From ccdf8f0dc287129bf0e45158807cec26bf478bff Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Mon, 14 Sep 2026 13:45:20 +0200 Subject: [PATCH 1/7] feat(cctv): enrich identified cameras with datasheet FOV + specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a CCTV source pack (or a seed) names its hardware `model`, resolve it against a camera-model spec table: the coverage frustum then uses the manufacturer's horizontal FOV instead of the pose estimate, and the HUD meta line gains the model's resolution, night-vision, and PTZ specs. Cameras with no identified model are unchanged — unknown never implies anything, matching the layer's existing honesty about RAW PRIOR poses. Spec records are vendored verbatim from the CCTV Camera Database (https://github.com/ch-bas/cctv-camera-database, CC0-1.0), an open dataset of 12,000+ camera models transcribed from manufacturer datasheets. This lands a small starter table plus the join + parsing helpers; source packs that publish their hardware can grow it. Records describe camera products (optics, resolution, night vision) only — no endpoints or access data. - src/layers/cctv/modelSpecs.js: spec table, case-insensitive/alias lookup, wide-end FOV parser, HUD summary formatter - src/layers/cctv/modelSpecs.test.mjs: lookup/alias/unknown, FOV parsing across the real fov string shapes, summary rendering - src/layers/cctv/catalog.js: apply enrichment in both catalog builders - src/layers/cctv/presentation.js: label a datasheet FOV, append the spec summary, and pass fovSource + spec through the public camera state --- src/layers/cctv/catalog.js | 27 ++++++ src/layers/cctv/modelSpecs.js | 129 ++++++++++++++++++++++++++++ src/layers/cctv/modelSpecs.test.mjs | 63 ++++++++++++++ src/layers/cctv/presentation.js | 11 ++- 4 files changed, 229 insertions(+), 1 deletion(-) create mode 100644 src/layers/cctv/modelSpecs.js create mode 100644 src/layers/cctv/modelSpecs.test.mjs diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 1a6ebe7c6..be6dc7e90 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,25 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { lookupModelSpec, horizontalFovDeg } 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. + * @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. + */ +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); + camera.fovSource = 'datasheet'; + } + camera.spec = spec; +} export function createCatalog({ state: layerState, services, parts, source }) { const { CITY_POIS } = services.locations; @@ -45,6 +66,7 @@ export function createCatalog({ state: layerState, services, parts, source }) { parts.model.clamp(seed.elevationM ?? 22, 8, 80), pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), }; + applyModelEnrichment(camera, seed.model, parts.model.clamp); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -205,6 +227,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { : null, poseSource, }; + applyModelEnrichment( + camera, + source.model || seed?.model, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js new file mode 100644 index 000000000..e9bb48856 --- /dev/null +++ b/src/layers/cctv/modelSpecs.js @@ -0,0 +1,129 @@ +/** + * @module cctvModelSpecs + * + * Hardware-model spec enrichment for the CCTV layer. + * + * 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. + * Cameras without an identified model are untouched — unknown never implies + * anything. + * + * Spec records are vendored verbatim from the CCTV Camera Database + * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset + * of 12,000+ camera models transcribed from manufacturer datasheets (84% with + * FOV). This module ships a small starter table; city packs that publish + * their hardware models can grow it, or vendor the full extract. + * + * Scope guardrail: records describe camera PRODUCTS (optics, resolution, + * night vision) — never endpoints, stream paths, or access/config data. + */ + +/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +const RECORDS = [ + { + id: 'axis-q6135-le', + brand: 'Axis', + model: 'Q6135-LE', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6135-le/', + aliases: ['01959-004'], + fov_deg: '58.3-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 250 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hanwha-xnp-6400rw', + brand: 'Hanwha', + model: 'XNP-6400RW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/', + aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'], + fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' }, + ptz: true, + autotracking: true, + varifocal: true, + night_vision: { type: 'ir', range_m: 200 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, + { + id: 'hikvision-ds-2cd2087g3-li2uy', + brand: 'Hikvision', + model: 'DS-2CD2087G3-LI2UY', + type: 'bullet', + url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/', + fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)', + resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' }, + night_vision: { type: 'color', range_m: 40 }, + environment: ['outdoor'], + ndaa_compliant: false, + }, + { + id: 'axis-f4105-lre', + brand: 'Axis', + model: 'F4105-LRE', + type: 'dome', + url: 'https://www.cctv-database.com/camera/axis-f4105-lre/', + fov_deg: '110 horizontal, 60 vertical', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + night_vision: { type: 'ir', range_m: 10 }, + environment: ['outdoor'], + ndaa_compliant: true, + }, +]; + +const BY_MODEL = new Map(); +for (const record of RECORDS) { + BY_MODEL.set(record.model.toLowerCase(), record); + for (const alias of record.aliases || []) + BY_MODEL.set(alias.toLowerCase(), record); +} + +/** + * Looks up a spec record by model string (case-insensitive; aliases match too). + * @param {string} model - Hardware model as named by a source pack or seed. + * @returns {Object|null} The verbatim spec record, or null when unknown. + */ +export function lookupModelSpec(model) { + if (!model) return null; + return BY_MODEL.get(String(model).trim().toLowerCase()) || null; +} + +/** + * 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. + * @param {Object} spec - A record from this module. + * @returns {number|null} Wide-end horizontal FOV, or 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; +} + +/** + * One-line spec summary for HUD meta text. + * @param {Object} spec - A record from this module. + * @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null). + */ +export function specSummary(spec) { + if (!spec) return ''; + const parts = [`${spec.brand} ${spec.model}`]; + if (spec.resolution?.label) parts.push(spec.resolution.label); + if (spec.night_vision) { + parts.push( + `${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`, + ); + } + if (spec.ptz) parts.push('PTZ'); + return parts.join(' · '); +} diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs new file mode 100644 index 000000000..4d6f80370 --- /dev/null +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -0,0 +1,63 @@ +// src/layers/cctv/modelSpecs.test.mjs +// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias +// matching, unknown → null — never a guess), datasheet-FOV parsing across the +// real fov_deg string shapes, and the HUD spec-summary line. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + lookupModelSpec, + horizontalFovDeg, + specSummary, +} from './modelSpecs.js'; + +test('lookupModelSpec matches case-insensitively and trims', () => { + const spec = lookupModelSpec(' q6135-le '); + assert.ok(spec); + assert.equal(spec.brand, 'Axis'); + assert.equal(lookupModelSpec('Q6135-LE'), spec); +}); + +test('lookupModelSpec matches aliases', () => { + const byAlias = lookupModelSpec('01959-004'); + assert.ok(byAlias); + assert.equal(byAlias.model, 'Q6135-LE'); +}); + +test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { + assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); + assert.equal(lookupModelSpec(''), null); + assert.equal(lookupModelSpec(null), null); + 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('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('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('specSummary renders the HUD line and degrades by omission', () => { + assert.equal( + specSummary(lookupModelSpec('Q6135-LE')), + 'Axis Q6135-LE · 1080p · IR 250m · PTZ', + ); + // No night vision / no PTZ → those segments are simply absent. + assert.equal( + specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }), + 'Axis X · 4K', + ); + assert.equal(specSummary(null), ''); +}); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index f8f83fcd1..9fa4a7cef 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,6 +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'; export function createPresentation({ state: layerState, @@ -39,7 +40,8 @@ export function createPresentation({ // A synthetic bearing (headingConfidence 'low', no human calibration) // is tagged so a hashed guess never reads as a surveyed facing (#639). headingHudToken(active.camera), - `FOV ${Math.round(active.camera.fovDeg)}°`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js). + `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -49,6 +51,10 @@ export function createPresentation({ ? `SRC ${String(health.sourceKind).toUpperCase()}` : `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`, `${viewBand.toUpperCase()} CONTEXT`, + // Identified hardware: append the model's datasheet specs + attribution. + active.camera.spec + ? `${specSummary(active.camera.spec).toUpperCase()} · SPECS: CCTV-DATABASE.COM` + : null, ] .filter(Boolean) .join(' · '); @@ -86,6 +92,9 @@ export function createPresentation({ headingEstimated: isHeadingEstimated(camera), pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, + // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). + fovSource: camera.fovSource || 'estimated', + spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, mountHeightM: camera.mountHeightM, From 97118f478100ca4cebdba60b8eaf7f56f4e4de4e Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:36:50 +0200 Subject: [PATCH 2/7] feat(cctv): vendor the model specs the integrated catalogs publish Grow the enrichment table from the 4-model starter to the hardware the currently integrated public catalogs actually publish, so identified cameras render datasheet FOV cones and spec cards instead of estimates: - King County (WA) road cameras: Cohu 3950 / 3960 (3965) / 3920 iDome (3925), Bosch AUTODOME IP 5000i. - Sarasota County (FL) + Sioux Falls (SD) open data: Bosch AUTODOME IP starlight 7000i / starlight 7000 HD / dynamic 7000 HD, the VG4 AUTODOME (500i), Bosch AUTODOME 800 (VG5-836), and Axis Q6155-E, Q6054-E, Q6000-E Mk II, and P3707-PE. All records are vendored verbatim from the CCTV Camera Database (CC0), each transcribed from a manufacturer datasheet. Analog Cohu positioners carry no fov_deg (their datasheets publish none), so they enrich the HUD specs without a datasheet cone; every other model carries a real FOV. Adds a test asserting the vendored table covers each integrated catalog's published hardware strings. --- src/layers/cctv/modelSpecs.js | 221 +++++++++++++++++++++++++++- src/layers/cctv/modelSpecs.test.mjs | 13 ++ 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index e9bb48856..463e750ad 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -13,8 +13,11 @@ * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset * of 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module ships a small starter table; city packs that publish - * their hardware models can grow it, or vendor the full extract. + * FOV). This module vendors the records that the currently integrated public + * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis + * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that + * appear in Sarasota County FL and Sioux Falls SD open data; packs that + * publish other hardware models can grow it, or vendor the full extract. * * Scope guardrail: records describe camera PRODUCTS (optics, resolution, * night vision) — never endpoints, stream paths, or access/config data. @@ -78,6 +81,220 @@ const RECORDS = [ environment: ['outdoor'], ndaa_compliant: true, }, + { + id: 'axis-p3707-pe', + brand: 'Axis', + model: 'P3707-PE', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-p3707-pe/', + aliases: ['AXIS P3707-PE', 'P3707-PE'], + fov_deg: '108-54 horizontal per sensor (4x 1080p)', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6000-e-mk-ii', + brand: 'Axis', + model: 'Q6000-E Mk II', + type: 'panoramic', + url: 'https://www.cctv-database.com/camera/axis-q6000-e-mk-ii/', + aliases: [ + 'AXIS Q6000-E Mk II', + 'Q6000-E Mk II 50 Hz', + 'Q6000-E Mk II 60 Hz', + ], + fov_deg: 'up to 360 combined; 113-152 horizontal per sensor', + resolution: { + mp: 8, + w: 1920, + h: 1080, + label: '4x 2MP (8MP total; 4x 1080p)', + }, + lens_count: 4, + environment: ['outdoor'], + }, + { + id: 'axis-q6054-e', + brand: 'Axis', + model: 'Q6054-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6054-e/', + aliases: ['AXIS Q6054-E', 'Q6054-E 50 Hz', 'Q6054-E 60 Hz'], + fov_deg: '62.9-2.2 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'axis-q6155-e', + brand: 'Axis', + model: 'Q6155-E', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/axis-q6155-e/', + aliases: ['AXIS Q6155-E', 'Q6155-E 50 Hz', 'Q6155-E 60 Hz'], + fov_deg: '66.7-2.36 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-500i', + brand: 'Bosch', + model: 'AutoDome 500i Series (VG4)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-500i/', + aliases: [ + 'VG4 AUTODOME', + 'VG4 AUTODOME H.264', + 'AutoDome 500i', + 'AutoDome 500i Series', + 'VG4-500i', + 'VG4-500', + ], + fov_deg: '57.8-1.7 horizontal', + resolution: { mp: 0.4, label: '540 TVL (NTSC/PAL)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-dynamic-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP dynamic 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-dynamic-7000-hd/', + aliases: ['AUTODOME IP dynamic 7000 HD', 'AUTODOME 7000 dynamic HD'], + fov_deg: '65-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-autodome-ip-starlight-7000-hd', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000 HD', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-autodome-ip-starlight-7000-hd/', + aliases: [ + 'AUTODOME IP starlight 7000 HD', + 'Starlight 7000HD', + 'VG5-7130-CPT4', + 'VG5-7130', + ], + fov_deg: '59-2.1 horizontal', + resolution: { mp: 1, w: 1280, h: 720, label: '720p HD (1.3MP sensor)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-5502-30', + brand: 'Bosch', + model: 'AUTODOME IP 5000i (NDP-5502-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-5502-30/', + aliases: [ + 'AUTODOME IP 5000i', + 'NDP-5502-Z30', + 'NDP-5502-Z30C', + 'NDP-5502-30', + ], + fov_deg: '60.9-2.4 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-ndp-7512-z30', + brand: 'Bosch', + model: 'AUTODOME IP starlight 7000i (NDP-7512-Z30)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-ndp-7512-z30/', + aliases: [ + 'AUTODOME IP starlight 7000i', + 'NDP-7512-Z30', + 'NDP-7512-Z30C', + 'NDP-7512-Z30K', + ], + fov_deg: '64.7-2.3 horizontal', + resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD (2MP)' }, + ptz: true, + autotracking: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'bosch-vg5-836-ecev', + brand: 'Bosch', + model: 'AUTODOME 800 Series HD PTZ (VG5-836-ECEV)', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/bosch-vg5-836-ecev/', + aliases: ['VG5-836-ECEV', 'VG5-836', 'AUTODOME 800 Series'], + resolution: { mp: 2, label: '1080p' }, + ptz: true, + autotracking: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3920-idome', + brand: 'Cohu', + model: '3920 iDome', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3920-idome/', + aliases: [ + '3920', + '3920 Series', + '3923', + '3925', + '3925 Dome', + 'iDome 3925', + 'Cohu iDome 3920 Series', + ], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3950-iview', + brand: 'Cohu', + model: '3950 iVIEW', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3950-iview/', + aliases: ['3950', '3950 Series', 'Cohu 3950 Series iVIEW'], + resolution: { mp: 0.4, label: 'NTSC / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, + { + id: 'cohu-3960', + brand: 'Cohu', + model: '3960', + type: 'ptz', + url: 'https://www.cctv-database.com/camera/cohu-3960/', + aliases: ['3960 Series', '3964', '3965', 'Cohu 3960 iView'], + resolution: { mp: 0.4, label: 'NTSC/PAL / 470 TVL' }, + ptz: true, + varifocal: true, + environment: ['outdoor'], + }, ]; const BY_MODEL = new Map(); diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 4d6f80370..b31f2d714 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -23,6 +23,19 @@ test('lookupModelSpec matches aliases', () => { assert.equal(byAlias.model, 'Q6135-LE'); }); +test('vendored table covers the integrated catalogs published hardware', () => { + // Models the live packs publish: King County (Cohu, AUTODOME IP 5000i), + // Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis). + 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); + 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); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From 0533f78be433f28fe9085d856caf000630691670 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 17 Sep 2026 11:56:15 +0200 Subject: [PATCH 3/7] feat(cctv): resolve verbose registry model strings via token fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some catalogs publish a full product title rather than the bare model, e.g. "AXIS Q6155-E PTZ Dome Network Camera" — which the exact model/alias match misses. Add a fallback: after the exact match, scan for a record whose DISTINCTIVE token (a >=4-char model/alias word containing a digit or hyphen, so "dome"/"network"/"ptz"/"hd" never qualify) appears as a whole word in the query. The most specific (longest) matched token wins; if two records tie on the longest matched token the result is ambiguous and returns null, so the lookup never guesses. Exact matches are unchanged and still take precedence. --- scripts/package-boundaries.json | 3 ++ src/layers/cctv/modelSpecs.js | 69 +++++++++++++++++++++++++++-- src/layers/cctv/modelSpecs.test.mjs | 37 ++++++++++++++++ 3 files changed, 105 insertions(+), 4 deletions(-) diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index 189192f10..a987623a4 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1079,6 +1079,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -1496,6 +1497,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", @@ -2136,6 +2138,7 @@ "src/layers/cctv/ingestion.js", "src/layers/cctv/lifecycle.js", "src/layers/cctv/model.js", + "src/layers/cctv/modelSpecs.js", "src/layers/cctv/navigation.js", "src/layers/cctv/policy.js", "src/layers/cctv/presentation.js", diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index 463e750ad..1252ce013 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -297,21 +297,82 @@ const RECORDS = [ }, ]; +/** Lowercase + collapse whitespace, the shared normal form for matching. */ +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + const BY_MODEL = new Map(); for (const record of RECORDS) { - BY_MODEL.set(record.model.toLowerCase(), record); + BY_MODEL.set(normalize(record.model), record); for (const alias of record.aliases || []) - BY_MODEL.set(alias.toLowerCase(), record); + BY_MODEL.set(normalize(alias), record); } /** - * Looks up a spec record by model string (case-insensitive; aliases match too). + * DISTINCTIVE tokens of a record — the whitespace words of its model and + * aliases that identify hardware rather than describe it. A token qualifies + * only when it is >= 4 chars AND contains a digit or hyphen, so generic words + * ("dome", "network", "camera", "series", "ptz") are never distinctive and a + * verbose registry string cannot match on them alone. Surrounding punctuation + * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". + * @param {Object} record + * @returns {string[]} Unique distinctive tokens, lowercased. + */ +function distinctiveTokens(record) { + const out = new Set(); + for (const name of [record.model, ...(record.aliases || [])]) { + for (const raw of normalize(name).split(' ')) { + const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); + if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); + } + } + return [...out]; +} + +/** + * Token index for the substring fallback: one precompiled whole-word matcher + * per distinctive token. Word boundaries are any non-alphanumeric char (or the + * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as + * whole words while "700" never matches inside "7000". + */ +const TOKEN_INDEX = RECORDS.flatMap((record) => + distinctiveTokens(record).map((token) => ({ + token, + record, + re: new RegExp( + `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, + ), + })), +); + +/** + * Looks up a spec record for a hardware-model string. + * + * Exact match first (case-insensitive, whitespace-collapsed) on the model and + * every alias. When that misses, a token fallback lets a VERBOSE registry + * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the + * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) + * matched token wins; if two different records tie on the longest matched + * token the result is ambiguous and returns null (never guesses). * @param {string} model - Hardware model as named by a source pack or seed. * @returns {Object|null} The verbatim spec record, or null when unknown. */ export function lookupModelSpec(model) { if (!model) return null; - return BY_MODEL.get(String(model).trim().toLowerCase()) || null; + const query = normalize(model); + if (!query) return null; + const exact = BY_MODEL.get(query); + if (exact) return exact; + const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); + if (!matches.length) return null; + const maxLen = Math.max(...matches.map((m) => m.token.length)); + const top = matches.filter((m) => m.token.length === maxLen); + const records = new Set(top.map((m) => m.record)); + return records.size === 1 ? top[0].record : null; } /** diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index b31f2d714..c504e14ad 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -36,6 +36,43 @@ test('vendored table covers the integrated catalogs published hardware', () => { assert.equal(horizontalFovDeg(axis), 66.7); }); +test('verbose registry strings resolve via distinctive-token fallback', () => { + // Sioux Falls publishes the full product title, not the bare model. + assert.equal( + lookupModelSpec('AXIS Q6155-E PTZ Dome Network Camera')?.model, + 'Q6155-E', + ); + assert.equal(lookupModelSpec('AXIS Q6000-E Mk II')?.model, 'Q6000-E Mk II'); + assert.equal( + lookupModelSpec('AUTODOME IP starlight 7000i camera')?.id, + 'bosch-ndp-7512-z30', + ); + // Punctuation around the model token doesn't defeat the match. + assert.equal( + lookupModelSpec('camera (P3707-PE), outdoor')?.model, + 'P3707-PE', + ); +}); + +test('token fallback never matches on generic words or wrong models', () => { + // Only generic descriptors — no distinctive hardware token — stays null. + assert.equal(lookupModelSpec('PTZ Dome Camera'), null); + assert.equal(lookupModelSpec('HD Network Camera'), null); + assert.equal(lookupModelSpec('Outdoor Dome Camera'), null); + // A real-looking but unknown model resolves to nothing, not a near neighbour. + assert.equal(lookupModelSpec('AXIS FooBar-9 Network Camera'), null); + // A generation token alone must not collide with a similar one: "7000" + // belongs to two records (dynamic / starlight 7000 HD), so a bare + // "AUTODOME 7000" is ambiguous → null rather than an arbitrary pick. + assert.equal(lookupModelSpec('AUTODOME 7000'), null); +}); + +test('exact match still wins over the token fallback', () => { + // "3965" is an alias of the 3960; exact alias match, not a token scan. + assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); +}); + test('lookupModelSpec returns null for unknown or empty input — never guesses', () => { assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null); assert.equal(lookupModelSpec(''), null); From ffa1891b01b6d39def2d2854560d6e927d4c9cfb Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:09:27 +0000 Subject: [PATCH 4/7] 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. --- src/layers/cctv/catalog.js | 25 ++++-- src/layers/cctv/modelSpecs.js | 64 +++++++++++--- src/layers/cctv/modelSpecs.test.mjs | 124 ++++++++++++++++++++++++---- src/layers/cctv/presentation.js | 7 +- 4 files changed, 186 insertions(+), 34 deletions(-) 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, From cce159c93d6c7023e65589c1024bf83de45a1499 Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Sun, 27 Sep 2026 12:24:13 +0000 Subject: [PATCH 5/7] docs(cctv): document identified-hardware specs and datasheet FOV in CHANGELOG and CURRENT-STATE CONTRIBUTING requires runtime-behavior changes to update docs/CURRENT-STATE.md and CHANGELOG.md in the same PR. --- CHANGELOG.md | 9 +++++++++ docs/CURRENT-STATE.md | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dbedfaa..21a8cb743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +- CCTV cameras with identified hardware show the model's datasheet specs. When a + source names a camera model found in the vendored CCTV Camera Database records + (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. + A fixed single-lens model's horizontal FOV replaces the estimate + (`FOV n° (DATASHEET)`); a PTZ or varifocal range is what the lens can do, not + its current zoom, so those cameras keep the estimate and show + `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, #452). + - CCTV cameras whose bearing is a guess now say so. Packs mark bearings derived from a hash of the camera id as `headingConfidence: 'low'`, but nothing read the flag, so roughly 70% of a default catalog rendered like surveyed facings. The HUD @@ -235,6 +243,7 @@ Add feed provenance to analyst/view answers and HUD context while retaining existing response fields and runner ownership (Matt Van Horn, #347). Analyst records for loaded satellites, datacenters and dams, with explicit bounded count/rank coverage (Matt Van Horn, #351). + - New Fire Perimeters layer (Events group): live NIFC WFIGS interagency wildfire incident perimeters as ground-clamped polygons with a containment-colored fire line, refreshed every 5 minutes from the public diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index fdb42fc0c..5e1ccd447 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -3390,6 +3390,13 @@ silently demoting every later lookup for the session. (`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as estimated, matching the CAL badge. Public camera state carries `headingConfidence` and `headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639). +- **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that + resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera + Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens + hardware replaces the estimated FOV, shown as `FOV n° (DATASHEET)`. PTZ, varifocal and + multi-sensor datasheets publish a capability range, not the lens's current zoom, so those + cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public + camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). - **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 frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's From 03929f1a009e1a5a590dd790bcb2e38c4725adae Mon Sep 17 00:00:00 2001 From: bassem chagra Date: Thu, 1 Oct 2026 12:34:07 +0200 Subject: [PATCH 6/7] docs(cctv): refresh the CCTV Camera Database size in the model-specs module The dataset has grown from 12,000+ to 28,000+ camera models (v2.26.0, 75% with a field of view). The 17 vendored records are unchanged in v2.26.0. --- src/layers/cctv/modelSpecs.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index a9d12e23d..cee3e5044 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,8 +14,8 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 12,000+ camera models transcribed from manufacturer datasheets (84% with - * FOV). This module vendors the records that the currently integrated public + * of 28,000+ camera models transcribed from manufacturer datasheets (75% with + * FOV, as of v2.26.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that * appear in Sarasota County FL and Sioux Falls SD open data; packs that From 265c8a1cc0ea5b396f0392aa83589d02a06be1fd Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Thu, 15 Oct 2026 17:27:55 -0700 Subject: [PATCH 7/7] Tighten datasheet model matching and keep given fields of view Codec, product and resolution words never identify a model; generation suffixes such as Mk II no longer fall back to the base model; a mismatched manufacturer never matches, and brand-only numeric names need the brand. The datasheet FOV only fills when the source and seed give none and the pose is not curated, and DATASHEET is shown only while no calibration offset applies. Adds the CC0 extract to DATA_SOURCES. --- CHANGELOG.md | 9 +- DATA_SOURCES.md | 12 ++ docs/CURRENT-STATE.md | 3 +- src/layers/cctv/catalog.js | 37 ++++-- src/layers/cctv/modelSpecs.js | 188 ++++++++++++++++++++-------- src/layers/cctv/modelSpecs.test.mjs | 108 +++++++++++++++- src/layers/cctv/presentation.js | 17 ++- 7 files changed, 295 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 834650d63..81ff88aa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,10 +3,11 @@ - CCTV cameras with identified hardware show the model's datasheet specs. When a source names a camera model found in the vendored CCTV Camera Database records (CC0), the HUD meta line adds its resolution, night vision and PTZ capability. - A fixed single-lens model's horizontal FOV replaces the estimate - (`FOV n° (DATASHEET)`); a PTZ or varifocal range is what the lens can do, not - its current zoom, so those cameras keep the estimate and show - `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, #452). + A fixed single-lens model's horizontal FOV fills in when the source states no + FOV and the pose is not curated (`FOV n° (DATASHEET)`); a PTZ or varifocal + range is what the lens can do, not its current zoom, so those cameras keep the + estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it (bassem chagra, + #452). - Add WSDOT highway cameras (Washington State) as a keyless CCTV source pack: WSDOT's official Travel Center camera layer, with frames registered only on diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index 8f3b55dbf..d1ecd1423 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -185,6 +185,7 @@ The [Bhote Koshi event pack](public/events/bhote-koshi-2026/README.md), under `p | **Natural Earth countries, states and provinces** (260 admin-0/map units + 4,587 admin-1 polygons) | `natural_earth/` | **Public domain** | ✅ (no restrictions) | "Made with Natural Earth" (courtesy credit — not legally required) | | **US Census Bureau counties** (3,235 county polygons) | `us_census_counties/` | **Public domain** | ✅ (no restrictions) | "U.S. Census Bureau" (courtesy — not legally required) | | **CCTV ground heights** (3,445 cameras) | `cctv_ground_heights/` | Precomputed camera placement heights, aligned to work with Google Photorealistic 3D Tiles (folder README) | — | — | +| **CCTV Camera Database** model-enrichment extract v2.26.0 (17 camera-model records) | `src/layers/cctv/modelSpecs.js` | **CC0-1.0** (public domain dedication) | ✅ (no restrictions) | "Specs: cctv-database.com" (courtesy — not legally required) | ### ⚠️ TeleGeography is bundled but NonCommercial @@ -210,6 +211,17 @@ commercial use with attribution and applicable database share-alike/access obligations; it does not license the application code. Trimmed test-only tile fixtures retain their provenance in `src/data/fixtures/README.md`. +### CCTV Camera Database (`src/layers/cctv/modelSpecs.js`) + +The CCTV layer's hardware-model specs are a small verbatim extract (v2.26.0) of the +[CCTV Camera Database](https://github.com/ch-bas/cctv-camera-database), an open dataset of +camera models transcribed from manufacturer datasheets, dedicated to the public domain under +**CC0-1.0**. Only the records for hardware that the integrated public catalogs name (King +County, Sarasota County and Sioux Falls) are bundled, compiled into the module. Nothing is +fetched at runtime. The records describe camera products (optics, resolution, night vision) +only, never endpoints, stream paths or access data. CC0 requires no attribution; the CCTV HUD +credits it anyway as `SPECS: CCTV-DATABASE.COM` beside any spec line it renders. + ### Bundled OpenStreetMap data Bundled OSM-derived data is distributed under [ODbL 1.0](https://opendatacommons.org/licenses/odbl/1-0/), separately from the MIT code: [datacenters](src/data/local_data/datacenters/), [dams](src/data/local_data/dams/) (via Open Infrastructure Map), [military names](src/data/local_data/osm_military_names/), and [OSM test fixtures](src/data/fixtures/). Keep © OpenStreetMap contributors and offer modified databases under ODbL. diff --git a/docs/CURRENT-STATE.md b/docs/CURRENT-STATE.md index d24d9d92d..b2be525c7 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4185,7 +4185,8 @@ silently demoting every later lookup for the session. - **CCTV identified hardware:** a camera whose source or seed names a hardware `model` that resolves in `src/layers/cctv/modelSpecs.js` (vendored CC0 records from the CCTV Camera Database) gets the model's specs appended to the HUD meta line. Only fixed single-lens - hardware replaces the estimated FOV, shown as `FOV n° (DATASHEET)`. PTZ, varifocal and + hardware fills the FOV, and only when the source or seed states none and the pose is not + curated, shown as `FOV n° (DATASHEET)` until a manual calibration moves it. PTZ, varifocal and multi-sensor datasheets publish a capability range, not the lens's current zoom, so those cameras keep their estimate and show `SPEC FOV min–max° (CAPABILITY)` beside it. Public camera state carries `fovSource`, `specFovMinDeg`, `specFovMaxDeg` and `spec` (#452). diff --git a/src/layers/cctv/catalog.js b/src/layers/cctv/catalog.js index 7804327d6..900692939 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -7,17 +7,22 @@ import { /** * Applies datasheet enrichment to a camera in place: when it names a known - * 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. + * hardware `model` (and, when published, a matching `manufacturer`), the spec + * record is attached for the HUD and the datasheet's horizontal FOV range + * rides along as `specFovMinDeg`/`specFovMaxDeg`. Only fixed-lens hardware + * fills the FOV, and only when none was given: an explicit source/seed + * `fovDeg` or a curated pose is never overwritten. 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 {{model?:string, manufacturer?:string, explicitFov?:boolean}} hardware + * - Hardware model/manufacturer from a source or seed, and whether that + * source or seed stated its own `fovDeg`. * @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper. */ -function applyModelEnrichment(camera, modelName, clamp) { - const spec = modelName ? lookupModelSpec(modelName) : null; +function applyModelEnrichment(camera, hardware, clamp) { + const { model, manufacturer, explicitFov } = hardware; + const spec = model ? lookupModelSpec(model, manufacturer) : null; if (!spec) return; const range = horizontalFovRangeDeg(spec); if (range) { @@ -25,7 +30,7 @@ function applyModelEnrichment(camera, modelName, clamp) { camera.specFovMaxDeg = range.maxDeg; } const fixedFov = fixedHorizontalFovDeg(spec); - if (fixedFov) { + if (fixedFov && !explicitFov && camera.poseSource !== 'curated') { camera.fovDeg = clamp(fixedFov, 20, 125); camera.fovSource = 'datasheet'; } @@ -77,7 +82,11 @@ export function createCatalog({ state: layerState, services, parts, source }) { parts.model.clamp(seed.elevationM ?? 22, 8, 80), pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4), }; - applyModelEnrichment(camera, seed.model, parts.model.clamp); + applyModelEnrichment( + camera, + { model: seed.model, explicitFov: seed.fovDeg != null }, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -242,7 +251,15 @@ export function createCatalog({ state: layerState, services, parts, source }) { }; applyModelEnrichment( camera, - source.model || seed?.model, + { + model: source.model || seed?.model, + manufacturer: source.model ? source.manufacturer : '', + explicitFov: + (source.fovDeg != null && Number.isFinite(Number(source.fovDeg))) || + CAMERA_SEEDS.some( + (entry) => entry.id === id && entry.fovDeg != null, + ), + }, parts.model.clamp, ); parts.model.ensureCameraPose(camera); diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js index cee3e5044..1e5bc7cf1 100644 --- a/src/layers/cctv/modelSpecs.js +++ b/src/layers/cctv/modelSpecs.js @@ -14,7 +14,7 @@ * * Spec records are vendored verbatim from the CCTV Camera Database * (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset - * of 28,000+ camera models transcribed from manufacturer datasheets (75% with + * of 28,000+ camera models transcribed from manufacturer datasheets (84% with * FOV, as of v2.26.0). This module vendors the records that the currently integrated public * catalogs publish hardware for — King County WA (Cohu, Bosch AUTODOME, Axis * Q6135-LE), plus the Bosch AUTODOME and Axis PTZ/multisensor models that @@ -25,7 +25,7 @@ * night vision) — never endpoints, stream paths, or access/config data. */ -/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */ +/** Verbatim records from the dataset's model-enrichment extract (v2.26.0). */ const RECORDS = [ { id: 'axis-q6135-le', @@ -307,74 +307,144 @@ function normalize(value) { .replace(/\s+/g, ' '); } -const BY_MODEL = new Map(); -for (const record of RECORDS) { - BY_MODEL.set(normalize(record.model), record); - for (const alias of record.aliases || []) - BY_MODEL.set(normalize(alias), record); +/** + * Whitespace words of a name, lowercased, with surrounding punctuation trimmed + * so "(NDP-7512-Z30)" reads as "ndp-7512-z30". Inner hyphens and dots stay: + * "f4105-lre-unknown" is one word, never "f4105-lre" plus noise. + * @param {string} value + * @returns {string[]} + */ +function words(value) { + return normalize(value) + .split(' ') + .map((raw) => raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, '')) + .filter(Boolean); } /** - * DISTINCTIVE tokens of a record — the whitespace words of its model and - * aliases that identify hardware rather than describe it. A token qualifies - * only when it is >= 4 chars AND contains a digit or hyphen, so generic words - * ("dome", "network", "camera", "series", "ptz") are never distinctive and a - * verbose registry string cannot match on them alone. Surrounding punctuation - * is trimmed so "(NDP-7512-Z30)" indexes as "ndp-7512-z30". - * @param {Object} record - * @returns {string[]} Unique distinctive tokens, lowercased. + * Descriptor words that never identify hardware: product-type nouns, codec + * and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p"). */ -function distinctiveTokens(record) { - const out = new Set(); - for (const name of [record.model, ...(record.aliases || [])]) { - for (const raw of normalize(name).split(' ')) { - const token = raw.replace(/^[^a-z0-9]+|[^a-z0-9]+$/g, ''); - if (token.length >= 4 && /[0-9-]/.test(token)) out.add(token); - } +const GENERIC_WORDS = new Set([ + 'network', + 'camera', + 'cam', + 'ip', + 'ptz', + 'dome', + 'bullet', + 'box', + 'hd', + 'ir', + 'outdoor', + 'indoor', + 'series', +]); + +function isGeneric(word) { + return ( + GENERIC_WORDS.has(word) || + /^h\.?\d+$/.test(word) || + /^\d+(?:\.\d+)?(?:mp|p|k|x|mm|hz|tvl)$/.test(word) + ); +} + +/** A model code: letters AND digits, and not a generic descriptor. */ +function isModelCode(word) { + return /[a-z]/.test(word) && /\d/.test(word) && !isGeneric(word); +} + +/** Hyphenated variant suffixes that do not change the hardware. */ +const VARIANT_SUFFIXES = new Set(['50hz', '60hz', 'ntsc', 'pal']); + +/** "q6155-e-60hz" → "q6155-e"; "f4105-lre-unknown" is left as is. */ +function stripVariantSuffix(word) { + let out = word; + for (;;) { + const cut = out.lastIndexOf('-'); + if (cut <= 0 || !VARIANT_SUFFIXES.has(out.slice(cut + 1))) return out; + out = out.slice(0, cut); } - return [...out]; +} + +/** Manufacturer names (lowercased words) accepted for each record brand. */ +const BRAND_ALIASES = { + Hanwha: ['wisenet', 'samsung'], + Hikvision: ['hiwatch'], +}; + +function brandWords(record) { + return new Set([ + ...words(record.brand), + ...(BRAND_ALIASES[record.brand] || []), + ]); } /** - * Token index for the substring fallback: one precompiled whole-word matcher - * per distinctive token. Word boundaries are any non-alphanumeric char (or the - * string ends), so hyphenated/dotted tokens like "q6155-e" or "h.264" match as - * whole words while "700" never matches inside "7000". + * One entry per record name (model + aliases). `required` is every + * non-generic word of the name — all must appear in a query, so a generation + * or suffix ("Mk II", "7000i" vs "7000") is never dropped. `hasCode` marks + * names carrying a letter+digit model code; names without one ("3950", + * "01959-004", "AUTODOME 800 Series") match only when the brand does. */ -const TOKEN_INDEX = RECORDS.flatMap((record) => - distinctiveTokens(record).map((token) => ({ - token, - record, - re: new RegExp( - `(?:^|[^a-z0-9])${token.replace(/[.*+?^${}()|[\]\\-]/g, '\\$&')}(?:[^a-z0-9]|$)`, - ), - })), -); +const NAME_INDEX = RECORDS.flatMap((record) => { + const brand = brandWords(record); + return [record.model, ...(record.aliases || [])].map((name) => { + const nameWords = words(name); + return { + record, + brand, + allowed: new Set(nameWords), + required: nameWords.filter((word) => !isGeneric(word)), + hasCode: nameWords.some(isModelCode), + }; + }); +}); /** * Looks up a spec record for a hardware-model string. * - * Exact match first (case-insensitive, whitespace-collapsed) on the model and - * every alias. When that misses, a token fallback lets a VERBOSE registry - * string resolve — e.g. "AXIS Q6155-E PTZ Dome Network Camera" finds the - * Q6155-E via its distinctive "q6155-e" token. The most specific (longest) - * matched token wins; if two different records tie on the longest matched - * token the result is ambiguous and returns null (never guesses). + * A record name matches when every non-generic word of that name appears in + * the query (case-insensitive, punctuation-trimmed) and every other query word + * is generic or the record's brand. That lets a VERBOSE registry string + * resolve — "AXIS Q6155-E PTZ Dome Network Camera" finds the Q6155-E — while + * an unrelated code ("Q1615"), a generation suffix ("Mk II") or a longer part + * number ("F4105-LRE-UNKNOWN") rejects the match. Codec, resolution and + * product-type words never identify hardware on their own. A name with no + * letter+digit model code ("3950") needs the brand, from `manufacturer` or the + * query itself. A `manufacturer` that is not the record's brand always + * rejects. Matches on two different records are ambiguous → null (never + * guesses). * @param {string} model - Hardware model as named by a source pack or seed. + * @param {string} [manufacturer] - The source's manufacturer, when published. * @returns {Object|null} The verbatim spec record, or null when unknown. */ -export function lookupModelSpec(model) { - if (!model) return null; - const query = normalize(model); - if (!query) return null; - const exact = BY_MODEL.get(query); - if (exact) return exact; - const matches = TOKEN_INDEX.filter((entry) => entry.re.test(query)); - if (!matches.length) return null; - const maxLen = Math.max(...matches.map((m) => m.token.length)); - const top = matches.filter((m) => m.token.length === maxLen); - const records = new Set(top.map((m) => m.record)); - return records.size === 1 ? top[0].record : null; +export function lookupModelSpec(model, manufacturer) { + const query = words(model).map(stripVariantSuffix); + if (!query.length) return null; + const maker = words(manufacturer); + const found = new Set(); + for (const entry of NAME_INDEX) { + const makerMatches = maker.some((word) => entry.brand.has(word)); + if (maker.length && !makerMatches) continue; + if ( + !entry.hasCode && + !makerMatches && + !query.some((word) => entry.brand.has(word)) + ) + continue; + if (!entry.required.length) continue; + if (!entry.required.every((word) => query.includes(word))) continue; + if ( + !query.every( + (word) => + entry.allowed.has(word) || entry.brand.has(word) || isGeneric(word), + ) + ) + continue; + found.add(entry.record); + } + return found.size === 1 ? [...found][0] : null; } /** @@ -416,6 +486,16 @@ export function fixedHorizontalFovDeg(spec) { return range && range.minDeg === range.maxDeg ? range.minDeg : null; } +/** + * True when the camera's CURRENT FOV is the datasheet figure: it was filled + * from a fixed-lens datasheet and no manual calibration offset has moved it. + * @param {Object} camera - Catalog camera (see applyModelEnrichment). + * @returns {boolean} + */ +export function isDatasheetFov(camera) { + return camera?.fovSource === 'datasheet' && !camera.calibration?.fovDeg; +} + /** * HUD token for a datasheet FOV that describes capability rather than the * camera's current state, e.g. 'SPEC FOV 2.4–58° (CAPABILITY)'. diff --git a/src/layers/cctv/modelSpecs.test.mjs b/src/layers/cctv/modelSpecs.test.mjs index 06ffe3a64..9fbdb00dd 100644 --- a/src/layers/cctv/modelSpecs.test.mjs +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -9,6 +9,7 @@ import { horizontalFovRangeDeg, fixedHorizontalFovDeg, specFovCapabilityToken, + isDatasheetFov, specSummary, } from './modelSpecs.js'; import { createCatalog } from './catalog.js'; @@ -21,7 +22,8 @@ test('lookupModelSpec matches case-insensitively and trims', () => { }); test('lookupModelSpec matches aliases', () => { - const byAlias = lookupModelSpec('01959-004'); + // A part number without letters needs the brand. + const byAlias = lookupModelSpec('01959-004', 'Axis'); assert.ok(byAlias); assert.equal(byAlias.model, 'Q6135-LE'); }); @@ -29,7 +31,8 @@ test('lookupModelSpec matches aliases', () => { test('vendored table covers the integrated catalogs published hardware', () => { // Models the live packs publish: King County (Cohu, AUTODOME IP 5000i), // Sarasota (Bosch AUTODOME 7000/VG4), Sioux Falls (Axis). - const cohu = lookupModelSpec('3950'); // King County publishes "3950" + // King County publishes "3950" with Manufacturer "Cohu". + const cohu = lookupModelSpec('3950', 'Cohu'); assert.equal(cohu?.brand, 'Cohu'); const autodome = lookupModelSpec('AUTODOME IP 5000i'); assert.ok(autodome && horizontalFovRangeDeg(autodome).maxDeg > 0); @@ -71,8 +74,8 @@ test('token fallback never matches on generic words or wrong models', () => { }); test('exact match still wins over the token fallback', () => { - // "3965" is an alias of the 3960; exact alias match, not a token scan. - assert.equal(lookupModelSpec('3965')?.id, 'cohu-3960'); + // "3965" is an alias of the 3960, not the 3950. + assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960'); assert.equal(lookupModelSpec('AUTODOME IP 5000i')?.id, 'bosch-ndp-5502-30'); }); @@ -167,10 +170,10 @@ function browserCatalog() { }); } -test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixed-lens datasheets', () => { +test('catalog keeps the estimated FOV for PTZ hardware and fills 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: 'fixed', lat: 47.7, lon: -122, model: 'F4105-LRE' }, { id: 'unknown', lat: 47.7, lon: -122, fovDeg: 44, model: 'NOPE-1' }, ]); @@ -191,6 +194,99 @@ test('catalog keeps the estimated FOV for PTZ hardware and substitutes only fixe assert.equal(unknown.specFovMaxDeg, undefined); }); +test('codec, format and product words never identify hardware', () => { + // "h.264" is in the VG4 AUTODOME alias; it is a codec, not a model code. + assert.equal(lookupModelSpec('Axis Q1615 H.264 Network Camera'), null); + assert.equal(lookupModelSpec('H.264 Network Camera'), null); + assert.equal(lookupModelSpec('H.265 IP Camera'), null); +}); + +test('generation and suffix distinctions are preserved', () => { + // A Mk II is not the vendored (Mk I) Q6135-LE. + assert.equal(lookupModelSpec('AXIS Q6135-LE Mk II'), null); + // A longer part number is a different product... + assert.equal(lookupModelSpec('F4105-LRE-UNKNOWN'), null); + // ...unless the rest is a mains-frequency variant suffix. + assert.equal(lookupModelSpec('Q6155-E-60Hz')?.id, 'axis-q6155-e'); +}); + +test('manufacturer must agree with the record brand', () => { + // Purely numeric models need a brand match. + assert.equal(lookupModelSpec('3950'), null); + assert.equal(lookupModelSpec('Pelco 3950'), null); + assert.equal(lookupModelSpec('3950', 'Pelco'), null); + // A model code under the wrong manufacturer is not that record either. + assert.equal(lookupModelSpec('Q6135-LE', 'Hikvision'), null); + // Case-insensitive, with company suffixes and aliases. + assert.equal(lookupModelSpec('3950', 'COHU')?.id, 'cohu-3950-iview'); + assert.equal( + lookupModelSpec('Q6135-LE', 'Axis Communications AB')?.id, + 'axis-q6135-le', + ); + assert.equal( + lookupModelSpec('XNP-6400RW', 'Hanwha Vision')?.id, + 'hanwha-xnp-6400rw', + ); + assert.equal( + lookupModelSpec('XNP-6400RW', 'Wisenet')?.id, + 'hanwha-xnp-6400rw', + ); +}); + +test('King County snapshot model strings still resolve', () => { + // Model/Manufacturer pairs as published in the King County feed. + assert.equal(lookupModelSpec('3950', 'Cohu')?.id, 'cohu-3950-iview'); + assert.equal(lookupModelSpec('3965', 'Cohu')?.id, 'cohu-3960'); + assert.equal(lookupModelSpec('3925 Dome', 'Cohu')?.id, 'cohu-3920-idome'); + assert.equal(lookupModelSpec('Q6135-LE', 'Axis')?.id, 'axis-q6135-le'); + assert.equal( + lookupModelSpec('AUTODOME IP 5000i', 'BOSCH')?.id, + 'bosch-ndp-5502-30', + ); +}); + +test('catalog never overwrites an explicit FOV or a curated pose', () => { + const [explicit, curated, cohu, pelco] = + browserCatalog().buildCatalogFromSources([ + { id: 'explicit', lat: 47.7, lon: -122, fovDeg: 44, model: 'F4105-LRE' }, + { + id: 'curated', + lat: 47.7, + lon: -122, + model: 'F4105-LRE', + poseSource: 'curated', + }, + { id: 'cohu', lat: 47.7, lon: -122, model: '3950', manufacturer: 'Cohu' }, + { + id: 'pelco', + lat: 47.7, + lon: -122, + model: '3950', + manufacturer: 'Pelco', + }, + ]); + assert.equal(explicit.fovDeg, 44); + assert.equal(explicit.fovSource, undefined); + assert.equal(explicit.spec.model, 'F4105-LRE'); + assert.equal(curated.fovDeg, 74); + assert.equal(curated.fovSource, undefined); + // The source's manufacturer reaches the lookup. + assert.equal(cohu.spec?.id, 'cohu-3950-iview'); + assert.equal(pelco.spec, undefined); +}); + +test('the DATASHEET label drops once calibration moves the FOV', () => { + const camera = { fovSource: 'datasheet', fovDeg: 110 }; + assert.equal(isDatasheetFov(camera), true); + assert.equal(isDatasheetFov({ ...camera, calibration: { fovDeg: 0 } }), true); + assert.equal( + isDatasheetFov({ ...camera, calibration: { fovDeg: 6 } }), + false, + ); + assert.equal(isDatasheetFov({ fovDeg: 74 }), false); + assert.equal(isDatasheetFov(null), false); +}); + test('specSummary renders the HUD line and degrades by omission', () => { assert.equal( specSummary(lookupModelSpec('Q6135-LE')), diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index efa18e462..34c7dbcc9 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,7 +1,11 @@ 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, specFovCapabilityToken } from './modelSpecs.js'; +import { + specSummary, + specFovCapabilityToken, + isDatasheetFov, +} from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -40,8 +44,9 @@ export function createPresentation({ // A synthetic bearing (headingConfidence 'low', no human calibration) // is tagged so a hashed guess never reads as a surveyed facing (#639). headingHudToken(active.camera), - // Identified hardware labels its FOV as datasheet-sourced (catalog.js). - `FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`, + // Identified hardware labels its FOV as datasheet-sourced (catalog.js) + // only while no manual calibration offset has moved it. + `FOV ${Math.round(active.camera.fovDeg)}°${isDatasheetFov(active.camera) ? ' (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), @@ -96,7 +101,11 @@ export function createPresentation({ pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). - fovSource: camera.fovSource || 'estimated', + fovSource: isDatasheetFov(camera) + ? 'datasheet' + : camera.fovSource === 'datasheet' + ? 'calibrated' + : camera.fovSource || 'estimated', specFovMinDeg: camera.specFovMinDeg ?? null, specFovMaxDeg: camera.specFovMaxDeg ?? null, spec: camera.spec || null,