mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-06 11:24:00 +00:00
Merge pull request #452 from ch-bas/feat/cctv-model-spec-enrichment
feat(cctv): enrich identified cameras with datasheet FOV + specs
This commit is contained in:
8 files changed
+943
-1
No files matched your search
@@ -1,5 +1,14 @@
|
|||||||
# Changelog
|
# 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 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
|
- 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
|
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
|
instead of a blank tile. The icons are generated from `public/logo.svg` by
|
||||||
|
|||||||
@@ -186,6 +186,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) |
|
| **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) |
|
| **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 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
|
### ⚠️ TeleGeography is bundled but NonCommercial
|
||||||
|
|
||||||
@@ -211,6 +212,17 @@ commercial use with attribution and applicable database share-alike/access
|
|||||||
obligations; it does not license the application code. Trimmed test-only tile
|
obligations; it does not license the application code. Trimmed test-only tile
|
||||||
fixtures retain their provenance in `src/data/fixtures/README.md`.
|
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 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.
|
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.
|
||||||
|
|||||||
@@ -4215,6 +4215,14 @@ silently demoting every later lookup for the session.
|
|||||||
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
|
(`calSource: 'manual'`) or curated pose (`poseSource: 'curated'`) is never presented as
|
||||||
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
|
estimated, matching the CAL badge. Public camera state carries `headingConfidence` and
|
||||||
`headingEstimated` (`src/layers/cctv/headingConfidence.js`, #639).
|
`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.
|
- **FIRMS**: no ground clamping (zero 3D-tiles height sampling), ≤18 screen-decluttered ambient labels, click-to-inspect detail card, 2.5k/3k sprite budgets viewport-clipped by FRP.
|
||||||
- **CCTV v2 foundation:** a pitched
|
- **CCTV v2 foundation:** a pitched
|
||||||
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
frustum wireframe (4 corner rays + far-cap rectangle) with a monitor plane at the frustum's
|
||||||
|
|||||||
@@ -1187,6 +1187,7 @@
|
|||||||
"src/layers/cctv/ingestion.js",
|
"src/layers/cctv/ingestion.js",
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/model.js",
|
"src/layers/cctv/model.js",
|
||||||
|
"src/layers/cctv/modelSpecs.js",
|
||||||
"src/layers/cctv/navigation.js",
|
"src/layers/cctv/navigation.js",
|
||||||
"src/layers/cctv/policy.js",
|
"src/layers/cctv/policy.js",
|
||||||
"src/layers/cctv/presentation.js",
|
"src/layers/cctv/presentation.js",
|
||||||
@@ -1702,6 +1703,7 @@
|
|||||||
"src/layers/cctv/ingestion.js",
|
"src/layers/cctv/ingestion.js",
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/model.js",
|
"src/layers/cctv/model.js",
|
||||||
|
"src/layers/cctv/modelSpecs.js",
|
||||||
"src/layers/cctv/navigation.js",
|
"src/layers/cctv/navigation.js",
|
||||||
"src/layers/cctv/policy.js",
|
"src/layers/cctv/policy.js",
|
||||||
"src/layers/cctv/presentation.js",
|
"src/layers/cctv/presentation.js",
|
||||||
@@ -2463,6 +2465,7 @@
|
|||||||
"src/layers/cctv/ingestion.js",
|
"src/layers/cctv/ingestion.js",
|
||||||
"src/layers/cctv/lifecycle.js",
|
"src/layers/cctv/lifecycle.js",
|
||||||
"src/layers/cctv/model.js",
|
"src/layers/cctv/model.js",
|
||||||
|
"src/layers/cctv/modelSpecs.js",
|
||||||
"src/layers/cctv/navigation.js",
|
"src/layers/cctv/navigation.js",
|
||||||
"src/layers/cctv/policy.js",
|
"src/layers/cctv/policy.js",
|
||||||
"src/layers/cctv/presentation.js",
|
"src/layers/cctv/presentation.js",
|
||||||
|
|||||||
@@ -1,4 +1,41 @@
|
|||||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
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 }) {
|
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||||
const { CITY_POIS } = services.locations;
|
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),
|
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
|
||||||
pitchDeg: parts.model.clamp(seed.pitchDeg ?? -17, -40, -4),
|
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);
|
parts.model.ensureCameraPose(camera);
|
||||||
catalog.push(camera);
|
catalog.push(camera);
|
||||||
}
|
}
|
||||||
@@ -207,6 +249,19 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
model: String(source.model || ''),
|
model: String(source.model || ''),
|
||||||
manufacturer: String(source.manufacturer || ''),
|
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);
|
parts.model.ensureCameraPose(camera);
|
||||||
catalog.push(camera);
|
catalog.push(camera);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(' · ');
|
||||||
|
}
|
||||||
@@ -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), '');
|
||||||
|
});
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
import { CCTV_AMBIENT_CARD_MAX } from '../../data/cctvLod.js';
|
||||||
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
import { ACTIVE_FRAME_REFRESH_MS, IDLE_FRAME_REFRESH_MS } from './policy.js';
|
||||||
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
import { headingHudToken, isHeadingEstimated } from './headingConfidence.js';
|
||||||
|
import {
|
||||||
|
specSummary,
|
||||||
|
specFovCapabilityToken,
|
||||||
|
isDatasheetFov,
|
||||||
|
} from './modelSpecs.js';
|
||||||
|
|
||||||
export function createPresentation({
|
export function createPresentation({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -39,7 +44,12 @@ export function createPresentation({
|
|||||||
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
// A synthetic bearing (headingConfidence 'low', no human calibration)
|
||||||
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
// is tagged so a hashed guess never reads as a surveyed facing (#639).
|
||||||
headingHudToken(active.camera),
|
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²`,
|
`COVERAGE ${area.toFixed(2)}km²`,
|
||||||
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
|
||||||
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
|
`PROJ ${layerState._showProjection ? 'MONITOR' : 'OFF'}`,
|
||||||
@@ -49,6 +59,10 @@ export function createPresentation({
|
|||||||
? `SRC ${String(health.sourceKind).toUpperCase()}`
|
? `SRC ${String(health.sourceKind).toUpperCase()}`
|
||||||
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
|
: `SRC ${String(active.camera.feedType || 'image').toUpperCase()}`,
|
||||||
`${viewBand.toUpperCase()} CONTEXT`,
|
`${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)
|
.filter(Boolean)
|
||||||
.join(' · ');
|
.join(' · ');
|
||||||
@@ -86,6 +100,15 @@ export function createPresentation({
|
|||||||
headingEstimated: isHeadingEstimated(camera),
|
headingEstimated: isHeadingEstimated(camera),
|
||||||
pitchDeg: camera.pitchDeg,
|
pitchDeg: camera.pitchDeg,
|
||||||
fovDeg: camera.fovDeg,
|
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,
|
rangeM: camera.rangeM,
|
||||||
elevationM: camera.absoluteHeightM,
|
elevationM: camera.absoluteHeightM,
|
||||||
mountHeightM: camera.mountHeightM,
|
mountHeightM: camera.mountHeightM,
|
||||||
|
|||||||
Reference in new issue
Block a user