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

+1 -1
View File
@@ -12,7 +12,7 @@ Queries cover earthquakes, active fires, recent launches, aircraft (in an
area, by identifier, tracks, and type and route lookups), ships (in an area,
by identifier, and tracks), satellites (next
pass over a point, and those overhead now), public cameras (including a
camera's current image), radio stations, place search, routing, bike-share
camera's current image), license plate reader cameras, radio stations, place search, routing, bike-share
stations, transit vehicles, road traffic flow, weather, weather map images
(radar, satellite, lightning), wind, the most recent satellite image of an
area, submarine cables, regional briefs, tropical cyclones, fire
+1
View File
@@ -119,6 +119,7 @@ public feeds the sources already use.
| `satellites_overhead` | `satellites` | Satellites in a CelesTrak group above a point now, highest first |
| `find_cctv_cameras` | `cctv` | Public cameras in an area, nearest first |
| `get_cctv_snapshot` | `cctv` | The current image from one camera, returned as image content |
| `find_alpr_cameras` | `alpr` | OpenStreetMap-mapped license plate readers in a US/Canadian area up to 3° |
| `find_radio_stations` | `radio` | Radio Browser stations by area and/or search terms, with stream URLs |
| `search_places` | `placeSearch` | Points of interest matching a query within an area (Google Places) |
| `places_nearby` | `placeSearch` | Notable places around a point (Google Places) |
+2 -1
View File
@@ -882,5 +882,6 @@
"src/tools/localRoute.test.mjs",
"src/tools/queries/atmosphere.test.mjs",
"src/tools/queries/imagery.test.mjs",
"src/tools/queries/cables.test.mjs"
"src/tools/queries/cables.test.mjs",
"src/tools/queries/surveillance.test.mjs"
]
+7 -2
View File
@@ -3112,7 +3112,8 @@
"src/layers/wind/model.js",
"src/layers/wind/fields.js",
"src/tools/queries/imagery.js",
"src/tools/queries/cables.js"
"src/tools/queries/cables.js",
"src/tools/queries/surveillance.js"
],
"external": ["satellite.js"]
},
@@ -3183,7 +3184,11 @@
"src/sources/windBody.js",
"src/layers/recentImagery/catalog.js",
"src/layers/recentImagery/model.js",
"src/layers/submarineCables/bundledSource.js"
"src/layers/submarineCables/bundledSource.js",
"src/layers/alpr/source.js",
"src/layers/alpr/policy.js",
"src/layers/alpr/records.js",
"src/layers/alpr/tileRecords.js"
],
"external": ["@mapbox/vector-tile", "pbf"]
}
+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,