mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
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:
1 parent
c1957d884e
commit
0a454815cb
8 files changed
+203
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user