diff --git a/CHANGELOG.md b/CHANGELOG.md index 5117bdaa3..6b371af53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ vehicles take their mode from the fleet number first, so replacement buses on streetcar routes read as buses and out-of-service streetcars stay streetcars. +- 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 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). + - Ship a web app manifest and raster home-screen icons, so adding the app to a phone home screen or installing it on desktop uses the God's Eye View mark instead of a blank tile. The icons are generated from `public/logo.svg` by diff --git a/DATA_SOURCES.md b/DATA_SOURCES.md index e7dc816ce..4aa9b6317 100644 --- a/DATA_SOURCES.md +++ b/DATA_SOURCES.md @@ -195,6 +195,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 @@ -220,6 +221,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 edbbe5db0..6373d7255 100644 --- a/docs/CURRENT-STATE.md +++ b/docs/CURRENT-STATE.md @@ -4218,6 +4218,14 @@ 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 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). - **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 diff --git a/scripts/package-boundaries.json b/scripts/package-boundaries.json index d0bc8b3f4..ef3a9fdeb 100644 --- a/scripts/package-boundaries.json +++ b/scripts/package-boundaries.json @@ -1187,6 +1187,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", @@ -1702,6 +1703,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", @@ -2463,6 +2465,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/catalog.js b/src/layers/cctv/catalog.js index 76d530b11..900692939 100644 --- a/src/layers/cctv/catalog.js +++ b/src/layers/cctv/catalog.js @@ -1,4 +1,41 @@ import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js'; +import { + lookupModelSpec, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, +} from './modelSpecs.js'; + +/** + * Applies datasheet enrichment to a camera in place: when it names a known + * 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 {{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, hardware, clamp) { + const { model, manufacturer, explicitFov } = hardware; + const spec = model ? lookupModelSpec(model, manufacturer) : null; + if (!spec) return; + const range = horizontalFovRangeDeg(spec); + if (range) { + camera.specFovMinDeg = range.minDeg; + camera.specFovMaxDeg = range.maxDeg; + } + const fixedFov = fixedHorizontalFovDeg(spec); + if (fixedFov && !explicitFov && camera.poseSource !== 'curated') { + camera.fovDeg = clamp(fixedFov, 20, 125); + camera.fovSource = 'datasheet'; + } + camera.spec = spec; +} export function createCatalog({ state: layerState, services, parts, source }) { const { CITY_POIS } = services.locations; @@ -45,6 +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, + { model: seed.model, explicitFov: seed.fovDeg != null }, + parts.model.clamp, + ); parts.model.ensureCameraPose(camera); catalog.push(camera); } @@ -207,6 +249,19 @@ export function createCatalog({ state: layerState, services, parts, source }) { model: String(source.model || ''), manufacturer: String(source.manufacturer || ''), }; + applyModelEnrichment( + camera, + { + 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); catalog.push(camera); } diff --git a/src/layers/cctv/modelSpecs.js b/src/layers/cctv/modelSpecs.js new file mode 100644 index 000000000..1e5bc7cf1 --- /dev/null +++ b/src/layers/cctv/modelSpecs.js @@ -0,0 +1,531 @@ +/** + * @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 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. + * + * 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 (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 + * 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. + */ + +/** Verbatim records from the dataset's model-enrichment extract (v2.26.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, + }, + { + 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'], + }, +]; + +/** Lowercase + collapse whitespace, the shared normal form for matching. */ +function normalize(value) { + return String(value || '') + .trim() + .toLowerCase() + .replace(/\s+/g, ' '); +} + +/** + * 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); +} + +/** + * Descriptor words that never identify hardware: product-type nouns, codec + * and resolution/zoom/frequency tokens ("h.264", "2mp", "40x", "1080p"). + */ +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); + } +} + +/** 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] || []), + ]); +} + +/** + * 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 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. + * + * 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, 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; +} + +/** + * 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 {{minDeg:number, maxDeg:number}|null} Null when unstated. + */ +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; +} + +/** + * 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)'. + * @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)`; +} + +/** + * 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..9fbdb00dd --- /dev/null +++ b/src/layers/cctv/modelSpecs.test.mjs @@ -0,0 +1,301 @@ +// 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, + horizontalFovRangeDeg, + fixedHorizontalFovDeg, + specFovCapabilityToken, + isDatasheetFov, + specSummary, +} from './modelSpecs.js'; +import { createCatalog } from './catalog.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', () => { + // A part number without letters needs the brand. + const byAlias = lookupModelSpec('01959-004', 'Axis'); + assert.ok(byAlias); + 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). + // 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); + const vg4 = lookupModelSpec('VG4 AUTODOME H.264'); // Sarasota's string + assert.ok(vg4); + const axis = lookupModelSpec('Q6155-E'); // Sioux Falls + assert.deepEqual(horizontalFovRangeDeg(axis), { minDeg: 2.36, maxDeg: 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, not the 3950. + assert.equal(lookupModelSpec('3965', 'Cohu')?.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); + assert.equal(lookupModelSpec(null), null); + assert.equal(lookupModelSpec(undefined), null); +}); + +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('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('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 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, 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('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')), + '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..34c7dbcc9 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -1,6 +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, + isDatasheetFov, +} from './modelSpecs.js'; export function createPresentation({ state: layerState, @@ -39,7 +44,12 @@ 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) + // 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), `COVERAGE ${area.toFixed(2)}km²`, overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW', `PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`, @@ -49,6 +59,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 +100,15 @@ export function createPresentation({ headingEstimated: isHeadingEstimated(camera), pitchDeg: camera.pitchDeg, fovDeg: camera.fovDeg, + // Identified-hardware enrichment passthrough (catalog.js / modelSpecs.js). + fovSource: isDatasheetFov(camera) + ? 'datasheet' + : camera.fovSource === 'datasheet' + ? 'calibrated' + : camera.fovSource || 'estimated', + specFovMinDeg: camera.specFovMinDeg ?? null, + specFovMaxDeg: camera.specFovMaxDeg ?? null, + spec: camera.spec || null, rangeM: camera.rangeM, elevationM: camera.absoluteHeightM, mountHeightM: camera.mountHeightM,