feat: add a license plate reader camera tool

Add find_alpr_cameras over the layer's OpenStreetMap camera extracts:
cameras in a US or Canadian area of at most 3° per side, nearest first,
with operator, manufacturer and facing direction, and the source's
attribution. Areas outside coverage or too wide are reported.
This commit is contained in:
Sameh Khamis committed 2026-10-01 13:31:31 -07:00
1 parent c1957d884e
commit 0a454815cb
8 files changed
+203 -4

No files matched your search

+2
View File
@@ -35,6 +35,7 @@ import {
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { openInGodsEyeView } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js';
import {
findCctvCameras,
findRadioStations,
@@ -83,6 +84,7 @@ export const coreTools = Object.freeze([
satellitesOverhead,
findCctvCameras,
getCctvSnapshot,
findAlprCameras,
findRadioStations,
searchPlaces,
placesNearby,
+91
View File
@@ -0,0 +1,91 @@
/** Automated license plate reader cameras mapped in OpenStreetMap. */
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
areaCenter,
areaContains,
distanceKm,
resolveArea,
} from '../area.js';
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
const MAX_SIDE_DEGREES = 3;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
export const findAlprCameras = defineTool({
name: 'find_alpr_cameras',
title: 'License plate reader cameras',
description:
'Automated license plate reader (ALPR) cameras mapped in OpenStreetMap ' +
'in a US or Canadian area of at most 3° per side, nearest the center ' +
'first, with operator, manufacturer and facing direction.',
inputSchema: {
type: 'object',
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
required: ['area'],
additionalProperties: false,
},
requires: ['alpr'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
if (
area.west > area.east ||
area.north - area.south > MAX_SIDE_DEGREES ||
area.east - area.west > MAX_SIDE_DEGREES
)
throw new ToolError(
'invalid_arguments',
`The area must be at most ${MAX_SIDE_DEGREES}° on each side and not cross the antimeridian`,
);
const box = {
west: area.west,
south: area.south,
east: area.east,
north: area.north,
};
const result = await services.alpr.fetch(box, signal);
if (result.noCoverage)
return {
summary: `${area.label} is outside the US and Canadian camera coverage.`,
data: { ...capRows([], args.limit), coverage: 'US and Canada' },
};
if (result.zoomIn)
throw new ToolError(
'invalid_arguments',
'Use a smaller, city-sized area',
);
const center = areaCenter(area);
const rows = result.records
.map((camera) => ({
camera,
point: { lat: camera.latitude, lon: camera.longitude },
}))
.filter(({ point }) => areaContains(area, point))
.map(({ camera, point }) => ({
id: camera.id,
lat: round(point.lat, 6),
lon: round(point.lon, 6),
operator: camera.operator || null,
manufacturer: camera.manufacturer || null,
camera_type: camera.cameraType || null,
zone: camera.zone || null,
direction_deg: camera.directionDeg ?? null,
last_verified: camera.lastVerified || null,
distance_km: round(distanceKm(center, point), 2),
}))
.sort((a, b) => a.distance_km - b.distance_km);
return {
summary:
`${countNoun(rows.length, 'license plate reader camera')} mapped in ${area.label}` +
(result.stale ? ' (data may be stale).' : '.'),
data: {
...capRows(rows, args.limit),
source: services.alpr.label ?? null,
attribution: services.alpr.attribution ?? null,
},
};
},
});
+97
View File
@@ -0,0 +1,97 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
const camera = (id, latitude, longitude, extra = {}) => ({
id,
osmId: id,
latitude,
longitude,
operator: 'City PD',
manufacturer: 'Flock Safety',
cameraType: 'fixed',
zone: 'traffic',
directionDeg: 270,
ref: null,
lastVerified: '2026-05-01',
source: null,
...extra,
});
const alpr = (result) => ({
label: 'OpenStreetMap · community mapped',
attribution: { text: 'OpenStreetMap contributors' },
boxes: [],
async fetch(box) {
this.boxes.push(box);
return result;
},
});
test('mapped cameras are listed nearest first with their details', async () => {
const source = alpr({
records: [
camera('alpr:1', 30.3, -97.7),
camera('alpr:2', 30.27, -97.74, { directionDeg: null }),
camera('alpr:far', 31.5, -97.7),
],
stale: false,
});
const catalog = composeCatalog({
tools: coreTools,
services: { alpr: source },
});
const result = await catalog.call('find_alpr_cameras', {
area: { lat: 30.27, lon: -97.74, radius_km: 10 },
});
assert.equal(
result.summary,
'2 license plate reader cameras mapped in 10 km around 30.270, -97.740.',
);
assert.deepEqual(
result.data.rows.map((row) => row.id),
['alpr:2', 'alpr:1'],
);
assert.deepEqual(result.data.rows[1], {
id: 'alpr:1',
lat: 30.3,
lon: -97.7,
operator: 'City PD',
manufacturer: 'Flock Safety',
camera_type: 'fixed',
zone: 'traffic',
direction_deg: 270,
last_verified: '2026-05-01',
distance_km: 5.09,
});
assert.equal(result.data.source, 'OpenStreetMap · community mapped');
assert.ok(source.boxes[0].north - source.boxes[0].south < 1);
});
test('coverage, size and zoom limits are reported', async () => {
const outside = composeCatalog({
tools: coreTools,
services: { alpr: alpr({ records: [], noCoverage: true }) },
});
assert.equal(
(
await outside.call('find_alpr_cameras', {
area: { lat: 51.5, lon: -0.1, radius_km: 5 },
})
).summary,
'5 km around 51.500, -0.100 is outside the US and Canadian camera coverage.',
);
await assert.rejects(
outside.call('find_alpr_cameras', { area: { bbox: [-100, 30, -95, 35] } }),
/at most 3° on each side/,
);
const zoom = composeCatalog({
tools: coreTools,
services: { alpr: alpr({ records: [], zoomIn: true }) },
});
await assert.rejects(
zoom.call('find_alpr_cameras', {
area: { lat: 30, lon: -97, radius_km: 100 },
}),
/smaller, city-sized area/,
);
});
+2
View File
@@ -6,6 +6,7 @@
* address the app is served from.
*/
import { createAlprTileSource } from '../layers/alpr/source.js';
import { GBFS_CITY_REGISTRY } from '../layers/bikeshare/registry.js';
import { createBikeshareSource } from '../layers/bikeshare/source.js';
import { createCctvSource } from '../layers/cctv/source.js';
@@ -68,6 +69,7 @@ export function createToolServices({ fetchImpl, appUrl }) {
wind: createWindSource({ fetchImpl }),
imagery: createImageryService({ fetchImpl }),
cables: createBundledCableSource({ fetchImpl }),
alpr: createAlprTileSource({ tileFetchImpl: fetchImpl }),
regional: requests.regional,
terrain: requests.terrain,
summary: requests.summary,