# PTZ Time-Series Scanner Agent ## Context The camera-calibrator app needs Pan/Tilt/Zoom history for each Axis camera to auto-populate PTZ values when a user selects a frame. Currently `footer-parser.js` requires manual entry. The Axis overlay text (camera name, X/Y/Z, location, datetime) is burned into JPEG pixels at the bottom of each frame, with no EXIF metadata available. The camera rarely moves, so we can sample sparsely and binary-search for exact transition points, keeping bandwidth and Claude calls minimal. The source server (`node.redfish.com`) is on a Comcast residential modem. **Data extent:** | Camera | Years | Total days | |--------|-------|------------| | TreeHouse | 2021-2026 | ~1,370 | | LosAlamos1 | 2023-2026 | ~850 | Starting with 2026 (61 days each), working backwards over time. ## Deliverables 1. **`geo.camera/apps/camera-calibrator/scripts/ptz-scanner.js`** - Single-file Node.js script, zero npm deps 2. **`geo.camera/apps/cameras/losalamos1.json`** - New camera config 3. **Shadow JSONL** uploaded to `guerin.acequia.io/data/axis/{camera}/ptz-series.jsonl` ## Algorithm ### Phase 1: Coarse Scan (1 sample per hour, newest first) ``` for each year (newest first): fetch day list from Apache directory listing for each day (newest first): fetch hour list for each hour (newest first): fetch frame list for the hour pick first .jpg download image -> tmp/frame.jpg ffmpeg crop PTZ region (520x30 @ x=0,y=1050) -> tmp/ptz.raw (grayscale) compare raw pixels to previous hour's ptz.raw if MAD > threshold (default 3.0): flag transition save ptz-region hash to state throttle, check --max-images limit ``` ### Phase 2: Binary Search on Transitions When coarse scan detects a change between hour A and hour B: ``` build unified frame list spanning hours A..B binary search: pick midpoint frame download, crop PTZ region, compare to "before" reference narrow half until two consecutive frames differ OCR the "after" frame via claude -p (crop full bottom strip 1920x30) record {ts, pan, tilt, zoom, camera, location} in ptz-series ``` Expected: ~20 transitions per year -> ~20 Claude calls per year, ~140 binary-search downloads. ### Phase 3: Output & Upload - Write `ptz-state/{camera}-ptz-series.jsonl` locally (one JSON line per PTZ change) - Upload to shadow URL via WebDAV (guerin.acequia.io:2078) - Create remote dirs with MKCOL if needed ## Pixel Comparison Detail The overlay bottom strip is 1920x25 pixels. The PTZ values occupy roughly x=0..520 (camera name + `X:val Y:val Z:val`). The timestamp portion (x>520) changes every frame and must be excluded. ``` ffmpeg -i frame.jpg -vf "crop=520:30:0:1050" -f rawvideo -pix_fmt gray ptz.raw ``` Produces 15,600 bytes. Mean Absolute Difference (MAD) between two buffers: sum(|a[i]-b[i]|) / length. Threshold ~3.0 separates JPEG noise from actual text changes. Log MAD values in `--verbose` mode for tuning. ## OCR via `claude -p` Crop the full bottom strip for OCR (need the complete text including timestamp): ``` ffmpeg -i frame.jpg -vf "crop=1920:30:0:1050" -q:v 2 strip.jpg ``` Invoke: ```bash claude -p "Read the image at /path/to/strip.jpg. Extract the overlay text. Return ONLY valid JSON: {\"camera\":\"...\",\"pan\":number,\"tilt\":number,\"zoom\":number,\"location\":\"...\",\"datetime\":\"...\"}. X=pan, Y=tilt, Z=zoom." ``` ## Local Network Detection Before starting, resolve `node.redfish.com` and check if it's a LAN address (192.168.x.x or 10.x.x.x). If local: - Throttle: 200ms (vs 2000ms remote) - Log: "Running on local network, reduced throttle" ```javascript const { execSync } = require('child_process'); const ip = execSync('nslookup node.redfish.com 2>/dev/null', { encoding: 'utf8' }); const isLocal = /192\.168\.|10\.\d+\./.test(ip); const THROTTLE = isLocal ? 200 : 2000; ``` ## Weather Classification (Phase 2 hook) When OCR-ing a transition frame, the full image is already downloaded. Add an optional `--classify-weather` flag that sends the full frame to Claude with an extended prompt: ``` "...also classify weather: clear, partly-cloudy, overcast, rain, snow, fog, night. Add a 'weather' field." ``` This costs zero extra downloads (image already in hand) and only ~20 extra tokens per OCR call. Phase 2 can expand this to periodic weather sampling (e.g., 1 per hour) independent of PTZ changes. ## State File `ptz-state/{camera}-state.json`: ```json { "camera": "treehouse", "lastRun": "2026-03-01T22:00:00Z", "scannedHours": [ {"year": 2026, "day": 61, "hour": 4, "ts": 1772424032, "ptzHash": "a1b2c3..."} ], "pendingTransitions": [ {"beforeTs": 1772370000, "afterTs": 1772373600, "resolved": false} ], "ptzSeries": [ {"ts": 1772372100, "pan": -27.16, "tilt": -36.53, "zoom": 4.3} ], "stats": {"imagesDownloaded": 87, "ocrCalls": 3} } ``` Resume: skip hours already in `scannedHours`, retry unresolved transitions. ## CLI Interface ``` node ptz-scanner.js [options] --camera treehouse | losalamos1 (default: treehouse) --year Limit to one year (default: 2026) --max-images Stop after N downloads (default: 100) --threshold Pixel MAD threshold (default: 3.0) --throttle Delay between requests (default: auto-detect) --classify-weather Include weather in OCR (default: off) --no-upload Skip WebDAV shadow sync --verbose Log every MAD comparison --reset Clear state, start fresh --dry-run List what would be fetched ``` ## File Layout ``` geo.camera/apps/camera-calibrator/scripts/ ptz-scanner.js # the script ptz-state/ # gitignored treehouse-state.json losalamos1-state.json treehouse-ptz-series.jsonl losalamos1-ptz-series.jsonl tmp/ # cleaned per-run geo.camera/apps/cameras/ simtable1.json # existing losalamos1.json # new guerin.acequia.io/data/axis/ # shadow (WebDAV upload) treehouse/ptz-series.jsonl losalamos1/ptz-series.jsonl ``` ## Performance Estimate (2026 only, per camera) | Step | Requests | Time @ 2s throttle | |------|----------|--------------------| | Directory crawl (year + 61 days) | 62 | 2 min | | Coarse scan (24h x 61d = 1,464 hours, 2 req each) | 2,928 | 98 min | | Binary search (~20 transitions x ~10 req) | 200 | 7 min | | OCR (~20 calls, local) | 0 HTTP | ~60s | | **Total** | **~3,190** | **~107 min** | With `--max-images 100`, each run takes ~4 min. Full 2026 scan in ~30 runs. On local network (200ms throttle): full scan in ~11 minutes. ## Implementation Order 1. Create `losalamos1.json` camera config 2. Build `ptz-scanner.js` in this order: - CLI arg parsing (process.argv, no deps) - Camera config loader - Throttled HTTP fetcher (curl via execSync) - Apache directory listing parser (regex, adapted from frame-loader.js) - Temp file manager - ffmpeg PTZ region cropper - Pixel buffer comparator (MAD) - State manager (read/write/persist) - Coarse scanner loop - Binary search resolver - OCR engine (claude -p via execSync) - JSONL writer - WebDAV uploader (MKCOL + PUT via curl) - Main orchestrator with local-network auto-detection 3. Add `ptz-state/` to `.gitignore` 4. Test: `node ptz-scanner.js --camera treehouse --max-images 5 --verbose` 5. Tune threshold from verbose MAD output 6. Run: `node ptz-scanner.js --camera treehouse --year 2026` ## Verification 1. Run with `--max-images 5 --verbose` and confirm: - Images download correctly from node.redfish.com - ffmpeg crops produce correct-sized raw buffers (15,600 bytes) - MAD values are logged and make sense (< 2 for no change, > 5 for change) - Claude OCR returns valid JSON matching overlay text 2. Run with `--max-images 50` and check state file persists/resumes correctly 3. Verify JSONL output matches expected format 4. Verify WebDAV upload creates files at shadow URLs 5. Confirm `ptzAtTime(series, timestamp)` lookup returns correct PTZ for arbitrary timestamps