mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
feat(cctv): enrich identified cameras with datasheet FOV + specs
When a CCTV source pack (or a seed) names its hardware `model`, resolve it against a camera-model spec table: the coverage frustum then uses the manufacturer's horizontal FOV instead of the pose estimate, and the HUD meta line gains the model's resolution, night-vision, and PTZ specs. Cameras with no identified model are unchanged — unknown never implies anything, matching the layer's existing honesty about RAW PRIOR poses. Spec records are vendored verbatim from the CCTV Camera Database (https://github.com/ch-bas/cctv-camera-database, CC0-1.0), an open dataset of 12,000+ camera models transcribed from manufacturer datasheets. This lands a small starter table plus the join + parsing helpers; source packs that publish their hardware can grow it. Records describe camera products (optics, resolution, night vision) only — no endpoints or access data. - src/layers/cctv/modelSpecs.js: spec table, case-insensitive/alias lookup, wide-end FOV parser, HUD summary formatter - src/layers/cctv/modelSpecs.test.mjs: lookup/alias/unknown, FOV parsing across the real fov string shapes, summary rendering - src/layers/cctv/catalog.js: apply enrichment in both catalog builders - src/layers/cctv/presentation.js: label a datasheet FOV, append the spec summary, and pass fovSource + spec through the public camera state
This commit is contained in:
1 parent
b210ab0fe4
commit
ccdf8f0dc2
4 files changed
+229
-1
No files matched your search
@@ -1,4 +1,25 @@
|
|||||||
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
import { CAMERA_SEEDS, SOURCE_ENDPOINT } from './policy.js';
|
||||||
|
import { lookupModelSpec, horizontalFovDeg } from './modelSpecs.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies datasheet enrichment to a camera in place: when it names a known
|
||||||
|
* hardware `model`, the manufacturer's horizontal FOV replaces the pose
|
||||||
|
* estimate and the spec record is attached for the HUD. Unknown models — the
|
||||||
|
* common case — change nothing.
|
||||||
|
* @param {Object} camera - Camera being built (mutated).
|
||||||
|
* @param {string} [modelName] - Hardware model from a source or seed.
|
||||||
|
* @param {(v:number,min:number,max:number)=>number} clamp - Pose clamp helper.
|
||||||
|
*/
|
||||||
|
function applyModelEnrichment(camera, modelName, clamp) {
|
||||||
|
const spec = modelName ? lookupModelSpec(modelName) : null;
|
||||||
|
if (!spec) return;
|
||||||
|
const fov = horizontalFovDeg(spec);
|
||||||
|
if (fov) {
|
||||||
|
camera.fovDeg = clamp(fov, 20, 125);
|
||||||
|
camera.fovSource = 'datasheet';
|
||||||
|
}
|
||||||
|
camera.spec = spec;
|
||||||
|
}
|
||||||
|
|
||||||
export function createCatalog({ state: layerState, services, parts, source }) {
|
export function createCatalog({ state: layerState, services, parts, source }) {
|
||||||
const { CITY_POIS } = services.locations;
|
const { CITY_POIS } = services.locations;
|
||||||
@@ -45,6 +66,7 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
parts.model.clamp(seed.elevationM ?? 22, 8, 80),
|
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, seed.model, parts.model.clamp);
|
||||||
parts.model.ensureCameraPose(camera);
|
parts.model.ensureCameraPose(camera);
|
||||||
catalog.push(camera);
|
catalog.push(camera);
|
||||||
}
|
}
|
||||||
@@ -205,6 +227,11 @@ export function createCatalog({ state: layerState, services, parts, source }) {
|
|||||||
: null,
|
: null,
|
||||||
poseSource,
|
poseSource,
|
||||||
};
|
};
|
||||||
|
applyModelEnrichment(
|
||||||
|
camera,
|
||||||
|
source.model || seed?.model,
|
||||||
|
parts.model.clamp,
|
||||||
|
);
|
||||||
parts.model.ensureCameraPose(camera);
|
parts.model.ensureCameraPose(camera);
|
||||||
catalog.push(camera);
|
catalog.push(camera);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* @module cctvModelSpecs
|
||||||
|
*
|
||||||
|
* Hardware-model spec enrichment for the CCTV layer.
|
||||||
|
*
|
||||||
|
* When a catalog camera names its hardware model (source packs may set
|
||||||
|
* `model`; hand-authored seeds may too), the model resolves to a datasheet
|
||||||
|
* record: the camera's frustum then uses the manufacturer's horizontal FOV
|
||||||
|
* instead of an estimate, and the HUD meta line gains the model's real specs.
|
||||||
|
* Cameras without an identified model are untouched — unknown never implies
|
||||||
|
* anything.
|
||||||
|
*
|
||||||
|
* Spec records are vendored verbatim from the CCTV Camera Database
|
||||||
|
* (https://github.com/ch-bas/cctv-camera-database, CC0-1.0) — an open dataset
|
||||||
|
* of 12,000+ camera models transcribed from manufacturer datasheets (84% with
|
||||||
|
* FOV). This module ships a small starter table; city packs that publish
|
||||||
|
* their hardware models can grow it, or vendor the full extract.
|
||||||
|
*
|
||||||
|
* Scope guardrail: records describe camera PRODUCTS (optics, resolution,
|
||||||
|
* night vision) — never endpoints, stream paths, or access/config data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Verbatim records from the dataset's model-enrichment extract (v2.19.0). */
|
||||||
|
const RECORDS = [
|
||||||
|
{
|
||||||
|
id: 'axis-q6135-le',
|
||||||
|
brand: 'Axis',
|
||||||
|
model: 'Q6135-LE',
|
||||||
|
type: 'ptz',
|
||||||
|
url: 'https://www.cctv-database.com/camera/axis-q6135-le/',
|
||||||
|
aliases: ['01959-004'],
|
||||||
|
fov_deg: '58.3-2.4 horizontal',
|
||||||
|
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
|
||||||
|
ptz: true,
|
||||||
|
autotracking: true,
|
||||||
|
varifocal: true,
|
||||||
|
night_vision: { type: 'ir', range_m: 250 },
|
||||||
|
environment: ['outdoor'],
|
||||||
|
ndaa_compliant: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hanwha-xnp-6400rw',
|
||||||
|
brand: 'Hanwha',
|
||||||
|
model: 'XNP-6400RW',
|
||||||
|
type: 'ptz',
|
||||||
|
url: 'https://www.cctv-database.com/camera/hanwha-xnp-6400rw/',
|
||||||
|
aliases: ['Wisenet PTZ Plus 2MP 40x IR PTZ'],
|
||||||
|
fov_deg: '65.66-1.88 horizontal / 39.4-1.09 vertical',
|
||||||
|
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p' },
|
||||||
|
ptz: true,
|
||||||
|
autotracking: true,
|
||||||
|
varifocal: true,
|
||||||
|
night_vision: { type: 'ir', range_m: 200 },
|
||||||
|
environment: ['outdoor'],
|
||||||
|
ndaa_compliant: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hikvision-ds-2cd2087g3-li2uy',
|
||||||
|
brand: 'Hikvision',
|
||||||
|
model: 'DS-2CD2087G3-LI2UY',
|
||||||
|
type: 'bullet',
|
||||||
|
url: 'https://www.cctv-database.com/camera/hikvision-ds-2cd2087g3-li2uy/',
|
||||||
|
fov_deg: '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)',
|
||||||
|
resolution: { mp: 8, w: 3840, h: 2160, label: '8MP (4K)' },
|
||||||
|
night_vision: { type: 'color', range_m: 40 },
|
||||||
|
environment: ['outdoor'],
|
||||||
|
ndaa_compliant: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'axis-f4105-lre',
|
||||||
|
brand: 'Axis',
|
||||||
|
model: 'F4105-LRE',
|
||||||
|
type: 'dome',
|
||||||
|
url: 'https://www.cctv-database.com/camera/axis-f4105-lre/',
|
||||||
|
fov_deg: '110 horizontal, 60 vertical',
|
||||||
|
resolution: { mp: 2, w: 1920, h: 1080, label: '1080p HD' },
|
||||||
|
night_vision: { type: 'ir', range_m: 10 },
|
||||||
|
environment: ['outdoor'],
|
||||||
|
ndaa_compliant: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const BY_MODEL = new Map();
|
||||||
|
for (const record of RECORDS) {
|
||||||
|
BY_MODEL.set(record.model.toLowerCase(), record);
|
||||||
|
for (const alias of record.aliases || [])
|
||||||
|
BY_MODEL.set(alias.toLowerCase(), record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Looks up a spec record by model string (case-insensitive; aliases match too).
|
||||||
|
* @param {string} model - Hardware model as named by a source pack or seed.
|
||||||
|
* @returns {Object|null} The verbatim spec record, or null when unknown.
|
||||||
|
*/
|
||||||
|
export function lookupModelSpec(model) {
|
||||||
|
if (!model) return null;
|
||||||
|
return BY_MODEL.get(String(model).trim().toLowerCase()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Datasheet horizontal FOV in degrees: the FIRST number in the `fov_deg`
|
||||||
|
* string, which for varifocal/PTZ ranges ('58.3-2.4 horizontal') is the wide
|
||||||
|
* end — the honest default for a coverage cone.
|
||||||
|
* @param {Object} spec - A record from this module.
|
||||||
|
* @returns {number|null} Wide-end horizontal FOV, or null when unstated.
|
||||||
|
*/
|
||||||
|
export function horizontalFovDeg(spec) {
|
||||||
|
const match = String(spec?.fov_deg || '').match(/\d+(?:\.\d+)?/);
|
||||||
|
const value = match ? Number(match[0]) : NaN;
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-line spec summary for HUD meta text.
|
||||||
|
* @param {Object} spec - A record from this module.
|
||||||
|
* @returns {string} e.g. 'Axis Q6135-LE · 1080p · IR 250m · PTZ' ('' for null).
|
||||||
|
*/
|
||||||
|
export function specSummary(spec) {
|
||||||
|
if (!spec) return '';
|
||||||
|
const parts = [`${spec.brand} ${spec.model}`];
|
||||||
|
if (spec.resolution?.label) parts.push(spec.resolution.label);
|
||||||
|
if (spec.night_vision) {
|
||||||
|
parts.push(
|
||||||
|
`${spec.night_vision.type.toUpperCase()}${spec.night_vision.range_m ? ` ${spec.night_vision.range_m}m` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (spec.ptz) parts.push('PTZ');
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// src/layers/cctv/modelSpecs.test.mjs
|
||||||
|
// Hardware-model spec enrichment: lookup semantics (case-insensitive, alias
|
||||||
|
// matching, unknown → null — never a guess), datasheet-FOV parsing across the
|
||||||
|
// real fov_deg string shapes, and the HUD spec-summary line.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import {
|
||||||
|
lookupModelSpec,
|
||||||
|
horizontalFovDeg,
|
||||||
|
specSummary,
|
||||||
|
} from './modelSpecs.js';
|
||||||
|
|
||||||
|
test('lookupModelSpec matches case-insensitively and trims', () => {
|
||||||
|
const spec = lookupModelSpec(' q6135-le ');
|
||||||
|
assert.ok(spec);
|
||||||
|
assert.equal(spec.brand, 'Axis');
|
||||||
|
assert.equal(lookupModelSpec('Q6135-LE'), spec);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookupModelSpec matches aliases', () => {
|
||||||
|
const byAlias = lookupModelSpec('01959-004');
|
||||||
|
assert.ok(byAlias);
|
||||||
|
assert.equal(byAlias.model, 'Q6135-LE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('lookupModelSpec returns null for unknown or empty input — never guesses', () => {
|
||||||
|
assert.equal(lookupModelSpec('NOT-A-REAL-MODEL'), null);
|
||||||
|
assert.equal(lookupModelSpec(''), null);
|
||||||
|
assert.equal(lookupModelSpec(null), null);
|
||||||
|
assert.equal(lookupModelSpec(undefined), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('horizontalFovDeg takes the wide end of a varifocal/PTZ range', () => {
|
||||||
|
// '58.3-2.4 horizontal' → 58.3 (wide), not 2.4 (tele).
|
||||||
|
assert.equal(horizontalFovDeg(lookupModelSpec('Q6135-LE')), 58.3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('horizontalFovDeg reads multi-variant and plain fov strings', () => {
|
||||||
|
// '108.8 horizontal (2.8mm) / 93.3 horizontal (4mm)' → first variant.
|
||||||
|
assert.equal(horizontalFovDeg(lookupModelSpec('DS-2CD2087G3-LI2UY')), 108.8);
|
||||||
|
// '110 horizontal, 60 vertical' → horizontal figure.
|
||||||
|
assert.equal(horizontalFovDeg(lookupModelSpec('F4105-LRE')), 110);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('horizontalFovDeg returns null when no numeric FOV is stated', () => {
|
||||||
|
assert.equal(horizontalFovDeg({ fov_deg: '' }), null);
|
||||||
|
assert.equal(horizontalFovDeg({ fov_deg: 'wide dynamic range' }), null);
|
||||||
|
assert.equal(horizontalFovDeg({}), null);
|
||||||
|
assert.equal(horizontalFovDeg(null), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('specSummary renders the HUD line and degrades by omission', () => {
|
||||||
|
assert.equal(
|
||||||
|
specSummary(lookupModelSpec('Q6135-LE')),
|
||||||
|
'Axis Q6135-LE · 1080p · IR 250m · PTZ',
|
||||||
|
);
|
||||||
|
// No night vision / no PTZ → those segments are simply absent.
|
||||||
|
assert.equal(
|
||||||
|
specSummary({ brand: 'Axis', model: 'X', resolution: { label: '4K' } }),
|
||||||
|
'Axis X · 4K',
|
||||||
|
);
|
||||||
|
assert.equal(specSummary(null), '');
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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 } from './modelSpecs.js';
|
||||||
|
|
||||||
export function createPresentation({
|
export function createPresentation({
|
||||||
state: layerState,
|
state: layerState,
|
||||||
@@ -39,7 +40,8 @@ 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).
|
||||||
|
`FOV ${Math.round(active.camera.fovDeg)}°${active.camera.fovSource === 'datasheet' ? ' (DATASHEET)' : ''}`,
|
||||||
`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 +51,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 +92,9 @@ 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: camera.fovSource || 'estimated',
|
||||||
|
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