fix(cctv): keep estimated FOV for PTZ/varifocal hardware; carry datasheet range as capability

A PTZ/varifocal datasheet FOV is a capability range, not the lens's
current zoom, so replacing the frustum's estimate with its wide end
presented unknown optical state as known. Parse the full horizontal
range (ignoring vertical, "combined" panoramic and lens-note figures),
carry it as specFovMinDeg/specFovMaxDeg, and substitute the datasheet
FOV only for fixed single-lens hardware. The HUD labels the range as
capability beside the estimate. This also stops the Q6000-E Mk II's
"360 combined" figure from being read as a 360-degree lens.
This commit is contained in:
bassem chagra committed 2026-09-27 12:09:27 +00:00
1 parent 0533f78be4
commit ffa1891b01
4 files changed
+186 -34

No files matched your search

+18 -7
View File
@@ -1,11 +1,17 @@
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js';
import {
lookupModelSpec,
horizontalFovRangeDeg,
fixedHorizontalFovDeg,
} from './modelSpecs.js';
/**
* Applies datasheet enrichment to a camera in place: when it names a known
* hardware `model`, the manufacturer's horizontal FOV replaces the pose
* estimate and the spec record is attached for the HUD. Unknown models — the
* common case — change nothing.
* hardware `model`, the spec record is attached for the HUD and the
* datasheet's horizontal FOV range rides along as `specFovMinDeg`/
* `specFovMaxDeg`. Only fixed-lens hardware replaces the pose estimate: a
* PTZ/varifocal range says what the lens CAN do, not where it is zoomed now.
* Unknown models — the common case — change nothing.
* @param {Object} camera - Camera being built (mutated).
* @param {string} [modelName] - Hardware model from a source or seed.
* @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper.
@@ -13,9 +19,14 @@ import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js';
function applyModelEnrichment(camera, modelName, clamp) {
const spec = modelName ? lookupModelSpec(modelName) : null;
if (!spec) return;
const fov = horizontalFovDeg(spec);
if (fov) {
camera.fovDeg = clamp(fov, 20, 125);
const range = horizontalFovRangeDeg(spec);
if (range) {
camera.specFovMinDeg = range.minDeg;
camera.specFovMaxDeg = range.maxDeg;
}
const fixedFov = fixedHorizontalFovDeg(spec);
if (fixedFov) {
camera.fovDeg = clamp(fixedFov, 20, 125);
camera.fovSource = 'datasheet';
}
camera.spec = spec;
+54 -10
View File
@@ -5,8 +5,10 @@
*
* When a catalog camera names its hardware model (source packs may set
* `model`; hand-authored seeds may too), the model resolves to a datasheet
* record: the camera's frustum then uses the manufacturer's horizontal FOV
* instead of an estimate, and the HUD meta line gains the model's real specs.
* record and the HUD meta line gains the model's real specs. A fixed-lens
* model's horizontal FOV replaces the frustum's estimate; a PTZ/varifocal or
* multi-sensor range is carried as capability metadata beside the estimate,
* because the lens's current zoom is unknown.
* Cameras without an identified model are untouched — unknown never implies
* anything.
*
@@ -376,16 +378,58 @@ export function lookupModelSpec(model) {
}
/**
* Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg`
* string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide
* end — the honest default for a coverage cone.
* Datasheet horizontal FOV as the range the hardware can cover. Only
* segments stating a horizontal figure count — vertical and "combined"
* panoramic figures never do — and parenthesized lens/sensor notes are
* ignored. A zoom ('58.3-2.4 horizontal') and a lens-option list
* ('108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)') both yield a range.
* @param {Object} spec - A record from this module.
* @returns {number|null} Wide-end horizontal FOV, or null when unstated.
* @returns {{minDeg:number, maxDeg:number}|null} Null when unstated.
*/
export function horizontalFovDeg(spec) {
const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/);
const value = match ? Number(match[0]) : NaN;
return Number.isFinite(value) && value > 0 ? value : null;
export function horizontalFovRangeDeg(spec) {
const values = [];
for (const segment of String(spec?.fov_deg || '').split(/[/;,]/)) {
if (!/horizontal/i.test(segment)) continue;
const figures = segment.replace(/\([^)]*\)/g, '').split(/horizontal/i)[0];
for (const match of figures.matchAll(/\d+(?:\.\d+)?/g)) {
const value = Number(match[0]);
if (Number.isFinite(value) && value > 0) values.push(value);
}
}
if (!values.length) return null;
return { minDeg: Math.min(...values), maxDeg: Math.max(...values) };
}
/**
* The datasheet horizontal FOV as the camera's CURRENT optical state, which
* holds only for fixed single-lens hardware stating one figure. PTZ,
* varifocal and multi-sensor models publish a capability range whose in-use
* value is unknown — known hardware capability is not known pose state — so
* they return null and keep their estimated FOV.
* @param {Object} spec - A record from this module.
* @returns {number|null} Fixed horizontal FOV in degrees, or null.
*/
export function fixedHorizontalFovDeg(spec) {
if (!spec || spec.ptz || spec.varifocal || (spec.lens_count || 1) > 1)
return null;
const range = horizontalFovRangeDeg(spec);
return range && range.minDeg === range.maxDeg ? range.minDeg : null;
}
/**
* HUD token for a datasheet FOV that describes capability rather than the
* camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'.
* @param {Object} camera - Catalog camera (see applyModelEnrichment).
* @returns {string|null} Null when the datasheet FOV already IS the camera's
* FOV (fixed lens) or no datasheet range is known.
*/
export function specFovCapabilityToken(camera) {
if (!camera || camera.fovSource === 'datasheet') return null;
const { specFovMinDeg: min, specFovMaxDeg: max } = camera;
if (!Number.isFinite(min) || !Number.isFinite(max)) return null;
const fmt = (deg) => (deg < 10 ? deg.toFixed(1) : String(Math.round(deg)));
const span = min === max ? fmt(max) : `${fmt(min)}–${fmt(max)}`;
return `SPEC FOV ${span}° (CAPABILITY)`;
}
/**
+108 -16
View File
@@ -6,9 +6,12 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
lookupModelSpec,
horizontalFovDeg,
horizontalFovRangeDeg,
fixedHorizontalFovDeg,
specFovCapabilityToken,
specSummary,
} from './modelSpecs.js';
import { createCatalog } from './catalog.js';
test('lookupModelSpec matches case-insensitively and trims', () => {
const spec = lookupModelSpec(' q6135-le ');
@@ -29,11 +32,11 @@ test('vendored table covers the integrated catalogs published hardware', () => {
const cohu = lookupModelSpec('3950'); // King County publishes "3950"
assert.equal(cohu?.brand, 'Cohu');
const autodome = lookupModelSpec('AUTODOME IP 5000i');
assert.ok(autodome && horizontalFovDeg(autodome) > 0);
assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0);
const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string
assert.ok(vg4);
const axis = lookupModelSpec('Q6155-E'); // Sioux Falls
assert.equal(horizontalFovDeg(axis), 66.7);
assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 66.7 });
});
test('verbose registry strings resolve via distinctive-token fallback', () => {
@@ -80,23 +83,112 @@ test('lookupModelSpec returns null for unknown or empty input — never guesses'
assert.equal(lookupModelSpec(undefined), null);
});
test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => {
// '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele).
assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3);
test('horizontalFovRangeDeg reads the full range across real fov_deg shapes', () => {
const range = (model) => horizontalFovRangeDeg(lookupModelSpec(model));
// PTZ zoom: both ends, not just the wide one.
assert.deepEqual(range('Q6135-LE'), { minDeg: 2.4, maxDeg: 58.3 });
// Vertical figures never count.
assert.deepEqual(range('XNP-6400RW'), { minDeg: 1.88, maxDeg: 65.66 });
// Lens options: parenthesized focal lengths are not FOV figures.
assert.deepEqual(range('DS-2CD2087G3-LI2UY'), {
minDeg: 93.3,
maxDeg: 108.8,
});
assert.deepEqual(range('F4105-LRE'), { minDeg: 110, maxDeg: 110 });
// The "360 combined" panoramic figure is not a horizontal lens FOV.
assert.deepEqual(range('Q6000-E Mk II'), { minDeg: 113, maxDeg: 152 });
assert.deepEqual(range('P3707-PE'), { minDeg: 54, maxDeg: 108 });
});
test('horizontalFovDeg reads multi-variant and plain fov strings', () => {
// '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant.
assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8);
// '110 horizontal, 60 vertical' → horizontal figure.
assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110);
test('horizontalFovRangeDeg returns null when no numeric FOV is stated', () => {
assert.equal(horizontalFovRangeDeg({ fov_deg: '' }), null);
assert.equal(horizontalFovRangeDeg({ fov_deg: 'wide dynamic range' }), null);
assert.equal(horizontalFovRangeDeg({ fov_deg: '60 vertical' }), null);
assert.equal(horizontalFovRangeDeg({}), null);
assert.equal(horizontalFovRangeDeg(null), null);
});
test('horizontalFovDeg returns null when no numeric FOV is stated', () => {
assert.equal(horizontalFovDeg({ fov_deg: '' }), null);
assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null);
assert.equal(horizontalFovDeg({}), null);
assert.equal(horizontalFovDeg(null), null);
test('fixedHorizontalFovDeg is the current FOV only for fixed single-lens hardware', () => {
// Fixed dome, one horizontal figure: the datasheet IS the optical state.
assert.equal(fixedHorizontalFovDeg(lookupModelSpec('F4105-LRE')), 110);
// Known hardware capability != known pose state: PTZ, multi-sensor, and a
// model sold in several lens options all stay estimated.
for (const model of [
'Q6135-LE',
'XNP-6400RW',
'AUTODOME IP 5000i',
'P3707-PE',
'Q6000-E Mk II',
'DS-2CD2087G3-LI2UY',
]) {
assert.equal(fixedHorizontalFovDeg(lookupModelSpec(model)), null, model);
}
assert.equal(fixedHorizontalFovDeg(null), null);
});
test('specFovCapabilityToken labels a datasheet range as capability, never as current FOV', () => {
assert.equal(
specFovCapabilityToken({ specFovMinDeg: 2.4, specFovMaxDeg: 58.3 }),
'SPEC FOV 2.4–58° (CAPABILITY)',
);
assert.equal(
specFovCapabilityToken({ specFovMinDeg: 113, specFovMaxDeg: 113 }),
'SPEC FOV 113° (CAPABILITY)',
);
// Already the camera's FOV (fixed lens) or nothing known: no token.
assert.equal(
specFovCapabilityToken({
fovSource: 'datasheet',
specFovMinDeg: 110,
specFovMaxDeg: 110,
}),
null,
);
assert.equal(specFovCapabilityToken({}), null);
assert.equal(specFovCapabilityToken(null), null);
});
/** Browser catalog with the pose math stubbed: only enrichment is under test. */
function browserCatalog() {
const model = {
safeNumber: (value, fallback = NaN) =>
Number.isFinite(Number(value)) ? Number(value) : fallback,
normalizeHeading: (deg) => ((deg % 360) + 360) % 360,
clamp: (value, lo, hi) => Math.min(hi, Math.max(lo, value)),
headingFromId: () => 0,
normalizeFeedType: (type) => type,
ensureCameraPose: () => {},
};
return createCatalog({
state: {},
services: { locations: { CITY_POIS: {} } },
parts: { model },
source: {},
});
}
test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixed-lens datasheets', () => {
const [ptz, fixed, unknown] = browserCatalog().buildCatalogFromSources([
{ id: 'ptz', lat: 47.7, lon: -122, fovDeg: 44, model: 'Q6135-LE' },
{ id: 'fixed', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' },
{ id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' },
]);
// PTZ: the frustum keeps its estimate; the datasheet range is metadata.
assert.equal(ptz.fovDeg, 44);
assert.equal(ptz.fovSource, undefined);
assert.equal(ptz.specFovMinDeg, 2.4);
assert.equal(ptz.specFovMaxDeg, 58.3);
assert.equal(ptz.spec.model, 'Q6135-LE');
// Fixed lens: the datasheet figure is the optical state.
assert.equal(fixed.fovDeg, 110);
assert.equal(fixed.fovSource, 'datasheet');
// Unknown hardware changes nothing.
assert.equal(unknown.fovDeg, 44);
assert.equal(unknown.spec, undefined);
assert.equal(unknown.specFovMaxDeg, undefined);
});
test('specSummary renders the HUD line and degrades by omission', () => {
+6 -1
View File
@@ -1,7 +1,7 @@
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
import { specSummary } from './modelSpecs.js';
import { specSummary, specFovCapabilityToken } from './modelSpecs.js';
export function createPresentation({
state: layerState,
@@ -42,6 +42,9 @@ export function createPresentation({
headingHudToken(active.camera),
// Identified hardware labels its FOV as datasheet-sourced (catalog.js).
`FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`,
// A PTZ/varifocal datasheet range is what the lens can do, not its
// current zoom, so it is shown beside the estimate, never as it.
specFovCapabilityToken(active.camera),
`COVERAGE ${area.toFixed(2)}km²`,
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
@@ -94,6 +97,8 @@ export function createPresentation({
fovDeg: camera.fovDeg,
// Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js).
fovSource: camera.fovSource || 'estimated',
specFovMinDeg: camera.specFovMinDeg ?? null,
specFovMaxDeg: camera.specFovMaxDeg ?? null,
spec: camera.spec || null,
rangeM: camera.rangeM,
elevationM: camera.absoluteHeightM,