mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge pull request #1201 from Danielslay86/cctv-stills-aspect
cctv: draw stills at source aspect
This commit is contained in:
6 files changed
+194
-11
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
- Draw CCTV stills at their source aspect on the monitor plane instead of
|
||||||
|
stretching them to the 16:9 canvas. Most DOT cameras publish 4:3 frames, so
|
||||||
|
every still was widened; they are now centered and pillarboxed on the dark
|
||||||
|
the placeholder already ends on (Danielslay86, #1201).
|
||||||
@@ -4417,7 +4417,7 @@ silently demoting every later lookup for the session.
|
|||||||
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||||
- **CCTV v2 foundation:** a pitched
|
- **CCTV v2 foundation:** a pitched
|
||||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||||
far cap, retargeting the existing video/canvas texture pipeline. Manual calibration only —
|
far cap, retargeting the existing video/canvas texture pipeline. Stills are drawn at their source aspect, centered on the placeholder dark (`#01070c`), instead of being stretched to the 16:9 canvas. Manual calibration only —
|
||||||
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
auto-calibration and the drape mesh pipeline are deleted. A one-shot activation obstruction
|
||||||
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
probe (`pickFromRay` on camera activation, clamping the plane short of the first hit) remains;
|
||||||
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
ground placement is superseded by the shared-floor v3 behavior below. Calibration persists to
|
||||||
|
|||||||
@@ -557,6 +557,7 @@
|
|||||||
"src/data/cctvNswSource.test.mjs",
|
"src/data/cctvNswSource.test.mjs",
|
||||||
"src/data/cctvTxdotSource.test.mjs",
|
"src/data/cctvTxdotSource.test.mjs",
|
||||||
"src/layers/cctv/source.test.mjs",
|
"src/layers/cctv/source.test.mjs",
|
||||||
|
"src/layers/cctv/frames.test.mjs",
|
||||||
"src/layers/cctv/rendering.test.mjs",
|
"src/layers/cctv/rendering.test.mjs",
|
||||||
"src/data/alprCameras.js",
|
"src/data/alprCameras.js",
|
||||||
"src/data/alprCameras.test.mjs",
|
"src/data/alprCameras.test.mjs",
|
||||||
|
|||||||
+44
-10
@@ -3,6 +3,7 @@ import {
|
|||||||
FRAME_SIGNATURE_H,
|
FRAME_SIGNATURE_H,
|
||||||
PROJECTION_CANVAS_WIDTH,
|
PROJECTION_CANVAS_WIDTH,
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
PROJECTION_TEXTURE_SWAP_MS,
|
PROJECTION_TEXTURE_SWAP_MS,
|
||||||
ACTIVE_FRAME_REFRESH_MS,
|
ACTIVE_FRAME_REFRESH_MS,
|
||||||
FRAME_ENDPOINT,
|
FRAME_ENDPOINT,
|
||||||
@@ -12,6 +13,37 @@ import {
|
|||||||
PLACEHOLDER_REPAINT_MS,
|
PLACEHOLDER_REPAINT_MS,
|
||||||
} from './policy.js';
|
} from './policy.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Contain-fits a source frame into the projection canvas: scaled to the
|
||||||
|
* largest size that fits, centered, with the margins left for the matte fill.
|
||||||
|
* Falls back to the full canvas when either dimension is missing or zero.
|
||||||
|
*
|
||||||
|
* @param {number} sourceW - Source width in pixels.
|
||||||
|
* @param {number} sourceH - Source height in pixels.
|
||||||
|
* @returns {{x:number,y:number,w:number,h:number}} Draw rectangle in canvas pixels.
|
||||||
|
*/
|
||||||
|
export function fitToProjection(sourceW, sourceH) {
|
||||||
|
if (!sourceW || !sourceH)
|
||||||
|
return {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: PROJECTION_CANVAS_WIDTH,
|
||||||
|
h: PROJECTION_CANVAS_HEIGHT,
|
||||||
|
};
|
||||||
|
const scale = Math.min(
|
||||||
|
PROJECTION_CANVAS_WIDTH / sourceW,
|
||||||
|
PROJECTION_CANVAS_HEIGHT / sourceH,
|
||||||
|
);
|
||||||
|
const w = Math.round(sourceW * scale);
|
||||||
|
const h = Math.round(sourceH * scale);
|
||||||
|
return {
|
||||||
|
x: Math.round((PROJECTION_CANVAS_WIDTH - w) / 2),
|
||||||
|
y: Math.round((PROJECTION_CANVAS_HEIGHT - h) / 2),
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createFrames({ state: layerState, services, parts, source }) {
|
export function createFrames({ state: layerState, services, parts, source }) {
|
||||||
/**
|
/**
|
||||||
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
* FNV-1a over the RGB channels of a downsampled frame. Pure (takes the raw
|
||||||
@@ -182,7 +214,7 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
ctx.clearRect(0, 0, w, h);
|
ctx.clearRect(0, 0, w, h);
|
||||||
const g = ctx.createLinearGradient(0, 0, w, h);
|
const g = ctx.createLinearGradient(0, 0, w, h);
|
||||||
g.addColorStop(0, '#05111a');
|
g.addColorStop(0, '#05111a');
|
||||||
g.addColorStop(1, '#01070c');
|
g.addColorStop(1, PROJECTION_MATTE_COLOR);
|
||||||
ctx.fillStyle = g;
|
ctx.fillStyle = g;
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
@@ -320,7 +352,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
// base color (planeMaterial color = WHITE, alpha .95) for the frame or
|
||||||
// two Cesium needs to rebind, which IS the periodic white flash from the
|
// two Cesium needs to rebind, which IS the periodic white flash from the
|
||||||
// owner field tests (2026-07-04 and 2026-07-30).
|
// owner field tests (2026-07-04 and 2026-07-30).
|
||||||
const signature = projectionFrameSignature(runtime);
|
const fit = fitToProjection(
|
||||||
|
runtime.image.naturalWidth,
|
||||||
|
runtime.image.naturalHeight,
|
||||||
|
);
|
||||||
|
// Pixels are hashed at a fixed size, so the fit carries the aspect.
|
||||||
|
const pixels = projectionFrameSignature(runtime);
|
||||||
|
const signature =
|
||||||
|
pixels === null ? null : `${pixels}:${fit.w}x${fit.h}`;
|
||||||
runtime.drawnImageStamp = runtime.imageStamp;
|
runtime.drawnImageStamp = runtime.imageStamp;
|
||||||
if (signature !== null && signature === runtime.lastFrameSignature) {
|
if (signature !== null && signature === runtime.lastFrameSignature) {
|
||||||
// Identical pixels — leave the canvas, and therefore the bound
|
// Identical pixels — leave the canvas, and therefore the bound
|
||||||
@@ -328,19 +367,14 @@ export function createFrames({ state: layerState, services, parts, source }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastFrameSignature = signature;
|
runtime.lastFrameSignature = signature;
|
||||||
runtime.ctx.clearRect(
|
runtime.ctx.fillStyle = PROJECTION_MATTE_COLOR;
|
||||||
0,
|
runtime.ctx.fillRect(
|
||||||
0,
|
|
||||||
PROJECTION_CANVAS_WIDTH,
|
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
|
||||||
);
|
|
||||||
runtime.ctx.drawImage(
|
|
||||||
runtime.image,
|
|
||||||
0,
|
0,
|
||||||
0,
|
0,
|
||||||
PROJECTION_CANVAS_WIDTH,
|
PROJECTION_CANVAS_WIDTH,
|
||||||
PROJECTION_CANVAS_HEIGHT,
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
);
|
);
|
||||||
|
runtime.ctx.drawImage(runtime.image, fit.x, fit.y, fit.w, fit.h);
|
||||||
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
runtime.canvasStamp = (runtime.canvasStamp || 0) + 1;
|
||||||
runtime.lastPlaceholderPaintAt = 0;
|
runtime.lastPlaceholderPaintAt = 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// src/layers/cctv/frames.test.mjs
|
||||||
|
// Still-frame fit (#1201): stills draw at their source aspect, centered on the
|
||||||
|
// 1920x1080 projection canvas, instead of being stretched to fill it.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createFrames, fitToProjection } from './frames.js';
|
||||||
|
import {
|
||||||
|
FRAME_SIGNATURE_W,
|
||||||
|
FRAME_SIGNATURE_H,
|
||||||
|
PROJECTION_CANVAS_WIDTH,
|
||||||
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
|
} from './policy.js';
|
||||||
|
|
||||||
|
// The expected rectangles below are written for a 1920x1080 canvas.
|
||||||
|
assert.equal(PROJECTION_CANVAS_WIDTH, 1920);
|
||||||
|
assert.equal(PROJECTION_CANVAS_HEIGHT, 1080);
|
||||||
|
|
||||||
|
const FULL_CANVAS = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: PROJECTION_CANVAS_WIDTH,
|
||||||
|
h: PROJECTION_CANVAS_HEIGHT,
|
||||||
|
};
|
||||||
|
|
||||||
|
test('a 4:3 still is pillarboxed at full height', () => {
|
||||||
|
assert.deepEqual(fitToProjection(640, 480), {
|
||||||
|
x: 240,
|
||||||
|
y: 0,
|
||||||
|
w: 1440,
|
||||||
|
h: 1080,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a source wider than 16:9 is letterboxed at full width', () => {
|
||||||
|
assert.deepEqual(fitToProjection(3840, 1080), {
|
||||||
|
x: 0,
|
||||||
|
y: 270,
|
||||||
|
w: 1920,
|
||||||
|
h: 540,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a 16:9 still fills the canvas', () => {
|
||||||
|
assert.deepEqual(fitToProjection(1920, 1080), FULL_CANVAS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a missing or zero dimension falls back to the full canvas', () => {
|
||||||
|
assert.deepEqual(fitToProjection(0, 480), FULL_CANVAS);
|
||||||
|
assert.deepEqual(fitToProjection(640, 0), FULL_CANVAS);
|
||||||
|
assert.deepEqual(fitToProjection(640, undefined), FULL_CANVAS);
|
||||||
|
assert.deepEqual(fitToProjection(undefined, undefined), FULL_CANVAS);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw path: the fit reaches drawImage, and the unchanged-frame skip keys on the
|
||||||
|
// fitted size as well as the pixels (the 64x36 hash alone drops aspect).
|
||||||
|
function stillHarness() {
|
||||||
|
const calls = [];
|
||||||
|
const ctx = {
|
||||||
|
fillStyle: null,
|
||||||
|
fillRect: (...rect) => calls.push(['fillRect', ctx.fillStyle, ...rect]),
|
||||||
|
clearRect: () => {},
|
||||||
|
drawImage: (_image, ...rect) => calls.push(['drawImage', ...rect]),
|
||||||
|
};
|
||||||
|
// Every frame downsamples to the same pixels, so only the size differs.
|
||||||
|
const pixels = new Uint8ClampedArray(
|
||||||
|
FRAME_SIGNATURE_W * FRAME_SIGNATURE_H * 4,
|
||||||
|
).fill(7);
|
||||||
|
const runtime = {
|
||||||
|
mode: 'image',
|
||||||
|
ctx,
|
||||||
|
image: { naturalWidth: 0, naturalHeight: 0 },
|
||||||
|
imageReady: true,
|
||||||
|
// In flight, so refreshProjectionImage returns before touching image.src.
|
||||||
|
imageLoading: true,
|
||||||
|
imageStamp: 0,
|
||||||
|
drawnImageStamp: -1,
|
||||||
|
lastFrameSignature: null,
|
||||||
|
canvasStamp: 0,
|
||||||
|
signatureCtx: {
|
||||||
|
clearRect: () => {},
|
||||||
|
drawImage: () => {},
|
||||||
|
getImageData: () => ({ data: pixels }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const record = { camera: { id: 'still' }, projection: runtime };
|
||||||
|
const { drawProjectionFrame } = createFrames({
|
||||||
|
state: { _healthById: new Map(), _activeCameraId: null },
|
||||||
|
services: {},
|
||||||
|
parts: {},
|
||||||
|
source: {},
|
||||||
|
});
|
||||||
|
const load = (width, height) => {
|
||||||
|
runtime.image = { naturalWidth: width, naturalHeight: height };
|
||||||
|
runtime.imageStamp += 1;
|
||||||
|
calls.length = 0;
|
||||||
|
drawProjectionFrame(record);
|
||||||
|
return calls.slice();
|
||||||
|
};
|
||||||
|
return { runtime, load };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a still is drawn into its fitted rectangle over a full matte', () => {
|
||||||
|
const { runtime, load } = stillHarness();
|
||||||
|
assert.deepEqual(load(640, 480), [
|
||||||
|
[
|
||||||
|
'fillRect',
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PROJECTION_CANVAS_WIDTH,
|
||||||
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
],
|
||||||
|
['drawImage', 240, 0, 1440, 1080],
|
||||||
|
]);
|
||||||
|
assert.equal(runtime.canvasStamp, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same pixels at the same size skip the redraw', () => {
|
||||||
|
const { runtime, load } = stillHarness();
|
||||||
|
load(640, 480);
|
||||||
|
assert.deepEqual(load(640, 480), []);
|
||||||
|
assert.equal(runtime.canvasStamp, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('same pixels at a new aspect redraw at the new fit', () => {
|
||||||
|
const { runtime, load } = stillHarness();
|
||||||
|
load(640, 480);
|
||||||
|
assert.deepEqual(load(1920, 1080), [
|
||||||
|
[
|
||||||
|
'fillRect',
|
||||||
|
PROJECTION_MATTE_COLOR,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
PROJECTION_CANVAS_WIDTH,
|
||||||
|
PROJECTION_CANVAS_HEIGHT,
|
||||||
|
],
|
||||||
|
['drawImage', 0, 0, 1920, 1080],
|
||||||
|
]);
|
||||||
|
assert.equal(runtime.canvasStamp, 2);
|
||||||
|
});
|
||||||
@@ -35,6 +35,9 @@ export const PROJECTION_CANVAS_WIDTH = 1920;
|
|||||||
|
|
||||||
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
export const PROJECTION_CANVAS_HEIGHT = 1080;
|
||||||
|
|
||||||
|
/** Matte behind fitted stills; the placeholder gradient's dark end. */
|
||||||
|
export const PROJECTION_MATTE_COLOR = '#01070c';
|
||||||
|
|
||||||
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
// Downsample grid for the unchanged-frame signature (drawProjectionFrame).
|
||||||
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
// 64x36 keeps the 16:9 aspect and reads ~9 KB per check versus the 8.3 MB a
|
||||||
// full-resolution compare would touch.
|
// full-resolution compare would touch.
|
||||||
|
|||||||
Reference in new issue
Block a user