diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index be6dc7e90..499e715a0 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -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; diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 1252ce013..a9d12e23d 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -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)`; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index c504e14ad..06ffe3a64 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -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', () => { diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 9fa4a7cef..efa18e462 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -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,