Applied Agentic AI and computer vision for traffic flow analysis and predictive modelling
Agentic AIComputer VisionPRIVACY
Context
Climate change, falling river water levels, and population growth are reshaping how people share increasingly contested waterways. RIVERVIEW is a working, privacy-respecting computer vision prototype - fully local, end-to-end pipeline running of a €22.50 camera board - built to see that pressure clearly.
Approach
RIVERVIEW was built end-to-end through directed agentic development — firmware, detection, a privacy-preserving pipeline, a versioned API and MCP server, and a scheduled monitoring agent for quality validation and actionable insights. Every decision tested versus assumed, and proven cheaply - before anything expensive got built.
Learning
RIVERVIEW's hardest problems turned out to be video resolution limits and messy real-world data - the kind of constraint only a real deployment surfaces.
One camera, two local server processes, and a browser that talks to both of them directly. Everything here runs on one laptop.
The browser makes two independent local connections, not one proxied through the other: it loads the page shell from the Nitro server on :3000, then fetches every event, stat, and image directly from the FastAPI server on :8001. The two server processes never call each other — the only thing they share is the SQLite database and snapshots/ folder that detect_boats.py writes to. That's also why the backend has to accept browser requests from the dashboard's own local origin. The vision/OCR hop (amber, dashed) is the one optional, swappable step in the pipeline — everything else always runs.
Ports & processes
Process
Port
Role
api.py
8001
Data + images — the only writer/reader of the DB
frontend
3000
Page shell only
detect_boats.py
—
No server — runs continuously, writes rows + images
Running it — two terminals, one laptop
# Terminal 1 — backend
cd laptop/backend && source venv/bin/activate
python3 api.py
# Terminal 2 — frontend (already built)
cd laptop/frontend
npm run start
Then open http://localhost:3000/ — that's the dashboard. See the API quickstart below for the separate developer API on :8001.
API — Getting started
Quickstart
A versioned, key-authed read API over Riverview's sighting data, under /api/v1/* on the same FastAPI process the dashboard already runs (http://127.0.0.1:8001).
Local demo
This isn't reachable off your laptop, and there's no self-serve signup — see "Getting a key" below.
Where things run
Two separate local processes, two separate addresses — don't mix them up:
Address
What it is
Dashboard
http://localhost:3000/
The TanStack Start frontend — open this in a browser to see sightings
Developer API
http://127.0.0.1:8001/api/v1/*
The FastAPI backend — call this from code, curl, or the MCP server
They're on the same machine but different ports and, notably, different hostnames by convention: the dashboard is always referred to as localhost:3000, never 127.0.0.1:3000, matching the project README. Use 127.0.0.1 for the API as shown throughout this page.
Getting a key
Keys live locally, one key,label pair per line. Add a line yourself to issue a new key — no restart needed, the API rereads the file on every request. Delete a line to revoke one. Generate a key:
Every /api/v1/* route needs that same X-API-Key header. Missing or wrong key → 401. No keys configured at all → 503 (the error message tells you to add one).
Endpoints
Method
Path
Returns
GET
/api/v1/events
Detection events, newest first, filterable
GET
/api/v1/events/{timestamp}
One event by its exact timestamp
GET
/api/v1/boats
Distinct boats grouped by identifying-marks signature
GET
/api/v1/stats
Summary counts (includes boat_passage_count)
GET
/api/v1/passages
Boat sightings stitched into passages, paginated
Full parameter-by-parameter reference: API reference below, generated from the backend's OpenAPI spec. The interactive version (with "Try it out") is at http://127.0.0.1:8001/docs while the backend is running.
Pagination
limit (default 50, max 1000) and offset on /events, /boats, and /passages. Responses are bare JSON arrays — keep incrementing offset until a call returns fewer than limit results to know you've reached the end.
Errors
{"detail": "Missing or invalid X-API-Key header."}
401
Bad or missing key
404
/events/{timestamp} with no matching event
503
No keys configured yet
422
A query parameter failed validation — the body explains which one
Read-only on purpose
There's no way to edit or delete a detection, or trigger an image upload, through /api/v1. Those actions stay on the dashboard's own local-only routes and aren't part of the developer API — don't build against them.
API — Data model
Concepts
The generated reference tells you the shape of each response. This page explains what the data actually means, since that's what most confusion traces back to.
Events
An event is one processed camera frame (or one uploaded image). A frame with both a person and a boat in it is one event with two detections, not two — GET /api/v1/events groups rows this way, keyed by a shared timestamp.
Detections and their source
Every detection has a source: camera (the live ESP32 feed), upload (dropped into the dashboard's Upload tab), or manual (logged by a person, not YOLO-detected). All three run through the same pipeline — source just tells you how the image got in.
Boat analysis fields
When a boat is detected, it's optionally run through a vision-language model plus a dedicated OCR pass, producing:
boat_type
e.g. "pontoon boat", "fishing boat"
boat_color
Dominant hull color
people_visible
Count aboard
activity
e.g. "cruising", "docked", "fishing"
identifying_marks
Any legible text — name, registration/hull number, hailing port
ocr_text
An independent OCR reading of the same crop, for cross-checking against identifying_marks
notes
Free-text scene description
Any of these can be null — either analysis wasn't enabled for that capture, or the model genuinely found nothing to report.
Worth knowing
identifying_marks/ocr_text are the two fields most likely to contain something identifying (a boat's registration number) rather than purely descriptive — worth keeping in mind before republishing them further.
"Has marks" filtering
has_marks=true excludes more than empty values — vision models often answer with a full sentence ("No visible markings or text on the boat in the image provided") rather than a short "none," so the filter also excludes anything containing "no visible," plus a short exact-match list. It never counts a person-class detection as "having marks," even if that field happens to be populated on its row.
Boats — grouping repeat sightings
GET /api/v1/boats groups every boat detection with a real identifying_marks/ocr_text reading into one entry per distinct boat, by canonicalizing that text (lowercase, strip punctuation, collapse whitespace) into a signature. Crops too small to plausibly carry a reliable reading are excluded entirely rather than risking a false match. This mirrors the dashboard's own Track view, though it's a separate implementation kept close in behavior, not byte-identical.
Boat passages vs. raw detection counts
The camera pipeline logs a new boat-class detection roughly every 1-2 seconds while a vessel stays in frame, so a raw count of boat-class rows (get_stats()'s boat_count) substantially overcounts real boat traffic — one boat crossing frame can produce anywhere from a handful to dozens of rows.
GET /api/v1/passages and boat_passage_count on /api/v1/stats fix this by clustering boat detections into passages — one per real boat sighting — based on runs of closely-spaced timestamps whose bounding-box position steps in a consistent direction:
detection_ids are the raw rows stitched together; direction is rightward, leftward, or null for a single-detection passage. Two boats detected in the exact same frame are never merged into one passage, however close their positions — being in the same frame means they were seen simultaneously, so they can't be one boat sampled twice.
Which field to use
Use boat_passage_count//passages for anything about real boat traffic or congestion — boat_count is left as raw per-frame detections for backward compatibility, but it isn't a boat count in the way the name suggests.
Confidence
confidence is the YOLO detection confidence (0–1) for camera/upload detections. Manual captures don't have one — stored as null in the database but returned as 1.0 here, since a manual log is a deliberate, fully-confident human observation, not an absence of information.
API — Version history
Changelog
Breaking changes get a new /v2 prefix, living alongside /v1 rather than replacing it. Additive changes — a new optional field, a new endpoint — land in the current version without a bump.
v1 — 2026-08-24
Initial release of the versioned, key-authed developer API, as a separate surface from the dashboard's own internal routes, which stay local-only and unchanged.
GET /api/v1/events — filtered/searched/paginated detection events
GET /api/v1/events/{timestamp} — single event lookup
GET /api/v1/boats — boats grouped by identifying-marks signature (new — previously only available client-side in the dashboard's Track view)
GET /api/v1/stats — summary counts
X-API-Key header required on every /api/v1/* route, checked against your local key file
v1 — 2026-08-26 (additive)
Boat-passage stitching, fixing a monitor-agent finding that raw boat-class detection counts substantially overcount real boat traffic.
GET /api/v1/stats — new boat_passage_count field: boat sightings clustered into passages, alongside the unchanged boat_count
GET /api/v1/passages — new endpoint: the individual stitched passages behind boat_passage_count, paginated (new)
API — Reference
API reference
Every /api/v1 endpoint, generated from the backend's OpenAPI spec (v1.0.0) so it can't drift from the code. All routes need the X-API-Key header — see Quickstart.
Events
GET/api/v1/events
List detection events
Events grouped by processed frame, newest first. A single frame with both a person and a boat detected is one event with two detections, not two events. Supports the same filter/search parameters as the dashboard's own event feed.
Parameters
Name
In
Type
Description
class
query
string
all | boat | person | manual
default "all"
q
query
string
Case-insensitive search across notes/marks/OCR text
default ""
has_marks
query
boolean
Only events with a real identifying_marks reading (excludes empty/none-visible/illegible answers)
List distinct boats by identifying-marks signature
Groups every boat detection with a real, non-junk identifying-marks or OCR reading into one entry per distinct boat, most-recently-seen first -- a server-side port of the dashboard's Track/Boats view grouping (see queries.get_boats's docstring for how the signature is computed).
Raw boat-class detections clustered into passages -- one per real boat sighting -- by grouping same-direction detections within a short time gap (see queries.stitch_boat_passages()). Fixes the substantial overcount in boat_count from a single boat logging a new row every 1-2 seconds while it's in frame. Most-recent-first.
How a fresh monitor-agent run (each scheduled firing starts a new session with no memory of the last one) finds out where the previous run left off, without depending on any one file.
The one write route on /v1 -- see this module's docstring for why it's a deliberate, narrow exception to the otherwise read-only design. Inserts one row into the separate agent_findings table (never touches a detection row) and appends the same row to that run's mirrored markdown file, agent_findings/agent_findings_<run_id>.md. Always created with status="pending".
When this run generated the finding, not an event timestamp
category
string
yes
observation
string | null
evidence
string | null
proposed_action
string | null
status
string
yes
pending | approved | dismissed -- always "pending" on creation
FindingCreate
Field
Type
Req.
Description
run_id
string
yes
Groups every finding from one monitor-agent run -- reuse the same value for every record_finding call within a single run. An ISO-ish timestamp is the expected shape, e.g. "2026-08-25T14:00:00" (also names that run's mirrored markdown file).
Specific event timestamps / boat signatures the finding is based on
proposed_action
string
yes
The concrete action being proposed, pending human approval
LastFindingTimestamp
Field
Type
Req.
Description
timestamp
string | null
yes
The most recent finding's timestamp across every past run, or null if no run has ever recorded one. A fresh monitor-agent session (no memory of prior runs) reads this to know where the last one left off.
Passage
Field
Type
Req.
Description
detection_ids
integer[]
yes
Raw detections.id values stitched into this passage
start_timestamp
string
yes
end_timestamp
string
yes
event_count
integer
yes
How many raw boat-class rows this passage merged
direction
string | null
rightward | leftward | null (single-detection passage -- no second point to compare)
Stats
Field
Type
Req.
Description
total_events
integer
yes
boat_count
integer
yes
person_count
integer
yes
manual_count
integer
yes
boat_passage_count
integer
yes
Real boat-passage count from track-stitching -- boat_count above is raw per-frame boat detections, which substantially overcounts real traffic (one boat crossing frame can log a dozen-plus rows while it's in view). Use this field for traffic-mix/congestion analysis. See queries.stitch_boat_passages().
MCP — Server setup
MCP server setup
Lets Claude query sightings, boats, and stats conversationally instead of you writing API calls by hand — a thin local wrapper over the developer API above.
1. Backend running
cd laptop/backend
source venv/bin/activate
python3 api.py
Leave this running — the MCP server has nothing to query without it.
- list_events: List detection events (one per processed frame, newest first).
- get_event: Get one event by its exact timestamp (as returned by list_events),
- list_boats: List distinct boats, grouped by a canonicalized identifying-marks/
- get_stats: Summary counts: total events, and counts by class (boat/person/manual).
- list_boat_passages: List boat sightings stitched into passages -- one per real boat
- get_last_run: Most recent finding's timestamp across every past monitor-agent run.
- record_finding: Record one monitor-agent finding, pending human approval.
If this fails, the backend isn't reachable or the key doesn't match a line in your local key file — fix that before wiring up Claude Desktop.
Restart Claude Desktop (fully quit, not just close the window) — or start a new Claude Code session — after editing the config.
5. Worked example
Try asking
"What boats were seen this afternoon with any identifying marks?" — Claude should call list_events(has_marks=true, ...) or list_boats() and answer from the real data. If it says it has no way to check, the config didn't load — recheck step 4 and restart.
Seven tools. Five are read-only over detection data: list_events, get_event, list_boats, get_stats, list_boat_passages. Two back the Agentic Monitor: get_last_run (read) and record_finding — the one write, which only appends to the separate findings log (see Tools reference). There's deliberately no tool to edit, delete, or upload detections — those stay dashboard-only.
MCP — Tools reference
Tools reference
Assumes the server from Server setup is already wired up. Every tool here is a thin wrapper over one call to the developer API above — same data, same pagination, same field meanings (see Concepts for what a field actually means). All tools are read-only against real detection data, except record_finding — a narrow write path for the Agentic Monitor's own append-only findings log, explained below.
Detection events (one per processed camera frame or upload), newest first. Wraps GET /api/v1/events.
class_filter
"all" | "boat" | "person" | "manual"
q
Case-insensitive search across notes/identifying_marks/ocr_text
has_marks
Only events with a real identifying-marks reading
limit / offset
Pagination, max 1000 per page
get_event
get_event(timestamp: str)
One event by its exact timestamp (as returned by list_events), e.g. get_event("2026-08-21T10:05:00"). Wraps GET /api/v1/events/{timestamp}.
list_boats
list_boats(limit=50, offset=0)
Distinct boats, grouped by a canonicalized identifying-marks/OCR signature, most-recently-seen first. Wraps GET /api/v1/boats — see Concepts for how the signature is computed.
get_stats
get_stats()
Summary counts, including boat_passage_count (real boat sightings, track-stitched). Wraps GET /api/v1/stats.
Back the scheduled monitor agent — a fresh session each firing, with no memory of the last one. record_finding is the one write tool on this otherwise read-only server, deliberately narrow: it only ever appends a row to a separate agent_findings table and can never touch, edit, or delete a real detection row. A finding is always created status="pending" — a human approves or dismisses it on the Track page.
get_last_run
get_last_run()
The most recent finding's timestamp across every past run, or null if none has ever been recorded — how a fresh session knows where to pick up. Wraps GET /api/v1/findings/last.