fix(cctv): surface heading confidence — estimated bearings are now visible

Every pack sets headingConfidence on each camera, but nothing consumed it:
a bearing synthesized by fallbackHeadingFromId (a hash of the id string,
no geographic input) rendered exactly like a surveyed one — 2,827 of 3,997
cameras in a default catalog. Fixes #639.

Two consumers, plus a passthrough:

- HUD: the heading token is tagged when the bearing is synthetic —
  `HDG 194° (ESTIMATED)` — so a hash never reads as a measured facing.
- Wireframe: a low-confidence camera draws its coverage frustum with dashed
  polylines instead of solid ones. Colors, widths, and the active/idle
  emphasis are unchanged; the dash is the only signal.
- Public camera state gains `headingConfidence` (the raw pack flag) and
  `headingEstimated` (the derived bit) so UI consumers never re-derive it.

A human-vouched pose always wins over the pack flag: a manually saved
calibration (calSource:'manual') or a curated catalog entry
(poseSource:'curated') is never presented as estimated, mirroring the CAL
badge states in calibration.js.

- src/layers/cctv/headingConfidence.js: pure helpers (isHeadingEstimated,
  headingHudToken)
- src/layers/cctv/headingConfidence.test.mjs: confidence normalization,
  calibration/curation overrides, HUD token formatting
- src/layers/cctv/presentation.js: HUD token + public-state passthrough
- src/layers/cctv/rendering.js: dashed materials for estimated bearings
This commit is contained in:
bassem chagra committed 2026-09-17 10:20:26 +02:00
1 parent 0d41b6be54
commit 5f27f6edf4
4 files changed
+144 -15

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
/**
* @module headingConfidence
*
* Consumers for the catalog's per-camera `headingConfidence` flag (#639).
*
* Packs whose feed publishes no facing get a synthetic bearing from
* `fallbackHeadingFromId` — a hash of the id string, with no geographic
* input — and are marked `headingConfidence: 'low'`. Until now nothing read
* the flag, so a guessed bearing rendered exactly like a surveyed one. These
* helpers make the guess visible: the HUD heading token gains an
* `(ESTIMATED)` tag and the coverage wireframe draws dashed instead of solid.
*
* A human-vouched pose always wins over the pack flag: a manually saved
* calibration (`calSource:'manual'`) or a hand-authored catalog entry
* (`poseSource:'curated'`) is never presented as estimated, mirroring the
* CAL badge states in calibration.js.
*/
/**
* Whether a camera's bearing is a synthetic guess that should present as
* provisional.
* @param {{headingConfidence?: string|null, calSource?: string|null,
* poseSource?: string|null}} camera - Catalog camera (or public state).
* @returns {boolean}
*/
export function isHeadingEstimated(camera) {
if (!camera) return false;
if (camera.calSource === 'manual') return false;
if (camera.poseSource === 'curated') return false;
return (
String(camera.headingConfidence || '')
.trim()
.toLowerCase() === 'low'
);
}
/**
* HUD heading token: `HDG 194°`, tagged `(ESTIMATED)` when the bearing is
* synthetic — so a hash can never read as a surveyed facing.
* @param {{headingDeg?: number, headingConfidence?: string|null,
* calSource?: string|null, poseSource?: string|null}} camera
* @returns {string}
*/
export function headingHudToken(camera) {
const hdg = Math.round(Number(camera?.headingDeg) || 0);
return `HDG ${hdg}°${isHeadingEstimated(camera) ? ' (ESTIMATED)' : ''}`;
}
@@ -0,0 +1,60 @@
// src/layers/cctv/headingConfidence.test.mjs
// Bearing-provenance consumers (#639): a pack's headingConfidence:'low'
// (a hashed synthetic bearing) must present as estimated — unless a human
// vouched for the pose via manual calibration or a curated catalog entry.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isHeadingEstimated, headingHudToken } from './headingConfidence.js';
test('low confidence marks the bearing estimated', () => {
assert.equal(isHeadingEstimated({ headingConfidence: 'low' }), true);
});
test('confidence matching is trimmed and case-insensitive', () => {
assert.equal(isHeadingEstimated({ headingConfidence: ' LOW ' }), true);
assert.equal(isHeadingEstimated({ headingConfidence: 'Low' }), true);
});
test('medium, high, missing, and absent cameras are not estimated', () => {
assert.equal(isHeadingEstimated({ headingConfidence: 'medium' }), false);
assert.equal(isHeadingEstimated({ headingConfidence: 'high' }), false);
assert.equal(isHeadingEstimated({}), false);
assert.equal(isHeadingEstimated(null), false);
assert.equal(isHeadingEstimated(undefined), false);
});
test('a manually saved calibration overrides a low pack flag', () => {
assert.equal(
isHeadingEstimated({ headingConfidence: 'low', calSource: 'manual' }),
false,
);
});
test('a curated catalog entry overrides a low pack flag', () => {
assert.equal(
isHeadingEstimated({ headingConfidence: 'low', poseSource: 'curated' }),
false,
);
});
test('HUD token tags a synthetic bearing and rounds the degrees', () => {
assert.equal(
headingHudToken({ headingDeg: 194.4, headingConfidence: 'low' }),
'HDG 194° (ESTIMATED)',
);
});
test('HUD token stays untagged for surveyed and calibrated bearings', () => {
assert.equal(
headingHudToken({ headingDeg: 67.5, headingConfidence: 'high' }),
'HDG 68°',
);
assert.equal(
headingHudToken({
headingDeg: 225,
headingConfidence: 'low',
calSource: 'manual',
}),
'HDG 225°',
);
});
+9 -1
View File
@@ -1,5 +1,6 @@
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';
export function createPresentation({
state: layerState,
@@ -35,7 +36,9 @@ export function createPresentation({
return [
`${active.camera.city.toUpperCase()} CCTV`,
`${active.camera.name.toUpperCase()}`,
`HDG ${Math.round(active.camera.headingDeg)}°`,
// 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)}°`,
`COVERAGE ${area.toFixed(2)}km²`,
overlapCount > 0 ? `OVERLAP ${overlapCount} cams` : 'ISOLATED VIEW',
@@ -76,6 +79,11 @@ export function createPresentation({
lat: camera.lat,
lon: camera.lon,
headingDeg: camera.headingDeg,
// Bearing provenance (#639): the pack's confidence flag plus the derived
// "is this a synthetic guess" bit (calibration-aware), so UI consumers
// never have to re-derive it.
headingConfidence: camera.headingConfidence || null,
headingEstimated: isHeadingEstimated(camera),
pitchDeg: camera.pitchDeg,
fovDeg: camera.fovDeg,
rangeM: camera.rangeM,
+28 -14
View File
@@ -1,5 +1,6 @@
import * as Cesium from 'cesium';
import { horizonOccluder } from '../../data/iconOrientation.js';
import { isHeadingEstimated } from './headingConfidence.js';
import {
ACTIVE_CAMERA_COLOR,
IDLE_CAMERA_COLOR,
@@ -228,6 +229,15 @@ export function createRendering({
);
const inVisibleSet = coverageVisible.has(record.camera.id);
// A synthetic bearing (headingConfidence 'low', no human calibration)
// draws its wireframe dashed so a guessed facing is visibly provisional
// rather than rendering identically to a surveyed one (#639). Colors,
// widths, and the active/idle emphasis are unchanged.
const bearingEstimated = isHeadingEstimated(record.camera);
const lineMaterial = (color) =>
bearingEstimated
? new Cesium.PolylineDashMaterialProperty({ color })
: color;
for (const entity of record.coverageEntities || []) {
// The frustum wireframe is part of the projection representation —
// force it on for the active camera and let it read through geometry
@@ -242,13 +252,15 @@ export function createRendering({
// active camera keeps its width/alpha emphasis in both schemes.
const hue = viewshedOn ? record.viewshedColors : null;
if (entity._coverageRole === 'cap') {
entity.polyline.material = hue
? isActive
? hue.lineActive
: hue.line
: isActive
? ACTIVE_COVERAGE_CENTER
: IDLE_COVERAGE_CENTER_MUTED;
entity.polyline.material = lineMaterial(
hue
? isActive
? hue.lineActive
: hue.line
: isActive
? ACTIVE_COVERAGE_CENTER
: IDLE_COVERAGE_CENTER_MUTED,
);
entity.polyline.width = isActive ? 2.2 : 1.0;
entity.polyline.depthFailMaterial = planeShowing
? hue
@@ -256,13 +268,15 @@ export function createRendering({
: ACTIVE_COVERAGE_CENTER_DEPTHFAIL
: undefined;
} else {
entity.polyline.material = hue
? isActive
? hue.lineActive
: hue.line.withAlpha(0.6)
: isActive
? ACTIVE_COVERAGE_EDGE
: IDLE_COVERAGE_EDGE_MUTED;
entity.polyline.material = lineMaterial(
hue
? isActive
? hue.lineActive
: hue.line.withAlpha(0.6)
: isActive
? ACTIVE_COVERAGE_EDGE
: IDLE_COVERAGE_EDGE_MUTED,
);
entity.polyline.width = isActive ? 1.8 : 0.9;
entity.polyline.depthFailMaterial = planeShowing
? hue