Skip to content

feat: hole detection + per-nest snips on the public dashboard (#165)#188

Merged
cofade merged 5 commits into
mainfrom
claude/hole-detection-planning-vjce1n
Jun 26, 2026
Merged

feat: hole detection + per-nest snips on the public dashboard (#165)#188
cofade merged 5 commits into
mainfrom
claude/hole-detection-planning-vjce1n

Conversation

@cofade

@cofade cofade commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

What & why

Phase 2 of the engagement story (#154). Today image-service's stub_classify() returns random 0/1 per nest, and those random values feed the sealed-% progress bars users already see. This makes detection real and adds snips — cropped images of individual nest holes — to the public dashboard. The crop is the privacy mechanism (#154): a snip shows only the hole, no garden/house background, so per-nest imagery stays public without auth. The stored snips also become labelled training patches for the planned ML model (#112).

Addresses #165.

Approach (decisions taken with the maintainer)

  • Grid: 4×4 = 16 holes (4 bee types × 4 nests). The stale dev-tools/circle.txt (4×3) is annotated as superseded.
  • Detection: hybrid — OpenCV HoughCircles, snap to grid, fall back to a normalized fixed grid. All in resolution-independent fractions (works at VGA/UXGA).
  • Bee type by measured diameter (rows ordered by median radius → ascending size), not by position — robust to camera-pose/orientation drift.
  • Binary empty/sealed via brightness+texture (sealed = ratio ≥ R OR std ≥ S): catches both shadowed-textured and bright-smooth plugs.
  • Snips kept with full history (nest_detections, duckdb-service sole writer per ADR-001) → enables phase-3 time-lapse (Engagement features on top of nest snips (#154 phase 3) #166).
  • Graceful degradation (hard contract): any detection failure → no snips, falls back to the stub, and /upload never 500s.

Changes

  • image-service: services/hole_detection.py (HoleDetector), wired into UploadPipeline (stub kept as the fallback); new /snips/:filename serving route; opencv-python-headless + numpy (+ libglib2.0-0 in the Dockerfile).
  • duckdb-service: nest_detections table + POST /record_detections and GET /detections (latest-per-nest fold).
  • backend: public GET /api/snips/:filename and GET /api/modules/:id/snips (snake→camel, validated wire shape).
  • contracts: NestSnip / NestSnipsResponse.
  • homepage: NestSnipGrid (4×4 grid mirroring the block, empty/sealed badges), getSnips/getSnipUrl, i18n. Gated by the existing dashboard-images flag.
  • scripts: one-shot backfill_detections.py for existing uploads.
  • docs: ADR-026, building-block, runtime upload-flow, api-reference, api-contracts, glossary (Snip), chapter-11 lesson.

Tests

  • New: test_hole_detection.py (incl. a diameter-vs-position test on synthetic circles), test_detections.py, snips-route.test.ts, NestSnipGrid.test.tsx, Playwright module-nest-snips.spec.ts (real chain: detector → duckdb → backend → nginx → decoded <img> pixels).
  • All green: image-service 90 · duckdb-service 220 · backend 213 + tsc · homepage 196 + build · make check-citations clean.

Known limitation

The Hough params and fallback-grid constants are calibrated against the 791×528 mock fixtures (uniform 3×4), which don't exercise the real block's 4th row or four distinct diameters. Recalibrate against a real UXGA capture (one sealed, one empty) before relying on it in the field — documented in the detector docstring, ADR-026, and circle.txt. The diameter logic itself is unit-pinned independently of the mock.

Out of scope

MaskRCNN/ML (#112) · phase-3 engagement extras (#166) · the small top-row camera/lighting fix (#12, #155 — detection degrades gracefully there).

🤖 Generated with Claude Code

https://claude.ai/code/session_01MEJZA4eWELoGpvZ7Ym41K6


Generated by Claude Code

claude added 4 commits June 23, 2026 19:25
Replace the random stub_classify with a real OpenCV HoleDetector: locate
nest holes (HoughCircles + normalized fixed-grid fallback), label bee type
by measured diameter, classify empty/sealed by brightness+texture, and crop
a per-nest snip. Snips are stored with full history (nest_detections,
duckdb-service sole writer) and served publicly at /api/snips/:filename —
the crop is the privacy mechanism, so no auth (#154). The existing
add_progress contract is unchanged; only the sealed values become real.
Detection degrades gracefully — a miss falls back to the stub and never 500s.

Frontend: NestSnipGrid renders a 4x4 grid mirroring the block, one row per
bee type, each cell a snip with an empty/sealed badge (gated by the existing
dashboard-images flag).

Adds opencv-python-headless + libglib2.0-0, a one-shot backfill script,
contracts (NestSnip), full test coverage (unit + component + Playwright),
ADR-026, and arc42 doc updates.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEJZA4eWELoGpvZ7Ym41K6
- Replace the tautological diameter test with one feeding synthetic circles
  whose vertical order disagrees with their diameter order, proving bee-type
  labelling follows measured diameter, not position.
- Backend /api/modules/:id/snips: extend the wire-shape guard to validate
  confidence + nest_index, and annotate the response as NestSnipsResponse so
  the producer-side mapping is compile-checked (ADR-004).
- Document the detector's mock-calibration limitation (no 4th row / distinct
  diameters) loudly; fix a wrong path in the backfill script docstring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEJZA4eWELoGpvZ7Ym41K6
…#165)

Real ESP captures (added under dev-tools/real_captures/) exposed that the
mock-tuned HoughCircles params find no circles on real low-contrast images, so
detect() hit the fixed-grid fallback and emitted 16 wood-sampled "sealed" snips
— confident garbage on the public dashboard, invisible because the only test
inputs were the high-contrast mocks.

Remove the grid-fabricating fallback and gate on a circle quorum: below it the
detector returns no detection and the pipeline falls back to the stub (honest
blank), never invented values. Real captures degrade gracefully today; robust
real-image detection (grid-fitting + a labelled corpus) is tracked as follow-up.

Adds the three real captures as regression fixtures + a test that forbids a
full all-sealed grid on unreadable input, a real_captures/README, and a
chapter-11 lesson ("a fallback may degrade but must never fabricate").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEJZA4eWELoGpvZ7Ym41K6
Sets up the "calibrate against real captures, not mocks" loop: a JSON label
format (<image>.labels.json: grid + sealed [row,col] list) and
dev-tools/calibrate_holes.py, which runs the detector over labelled real
captures and reports whether detection fired, holes kept, and predicted-vs-
labelled sealed counts (with --overlay to draw detected circles). README
documents the workflow. Recalibration becomes measurable the moment labelled
4x4 captures are added.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MEJZA4eWELoGpvZ7Ym41K6

cofade commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator Author

Next steps — recalibrate hole detection against real captures

Real ESP captures exposed that the mock-tuned HoughCircles params find no circles on real low-contrast images, so the detector previously fell back to a guessed grid and reported 16 wood-sampled "sealed" snips. That fabrication is removed: detection now degrades to no-detection below a circle quorum (honest blank, never invented values), pinned by test_real_capture_never_fabricates_a_full_sealed_grid. Three real captures are committed under dev-tools/real_captures/ as regression fixtures.

What's not done: tuning detection to actually read real captures. There is no single Hough config that fits both the high-contrast synthetic mocks and the low-contrast real images, so this needs a grid-fitting/candidate-selection stage plus a labelled real-capture corpus. The loop is set up to make that measurable rather than guesswork.

To do (on a PC with the module + opencv-python-headless, numpy)

  1. Capture a handful of 4×4 images from the module itself (not phone photos — ESP optics/resolution are what we're calibrating for), ideally spanning warm / tungsten / daylight.
  2. For each, drop image.jpg into dev-tools/real_captures/ and create image.labels.json (copy block_warm_1024.labels.example.json):
    • grid: [rows, cols] as they appear in that image,
    • sealed: the sealed holes as 1-based [row, col] (row 1 = top, col 1 = left); everything else is treated as empty.
  3. Run the harness from the repo root:
    python dev-tools/calibrate_holes.py            # per-image: fired? holes? pred vs truth sealed
    python dev-tools/calibrate_holes.py --overlay  # also writes <image>_overlay.png showing detected circles
    
  4. Tune image-service/services/hole_detection.py (Hough params, _MIN_CIRCLES_QUORUM, a grid-fitting step, empty/sealed thresholds) until every capture fires and pred_sealed == truth_sealed, then promote the best captures into the regression test.

Open question (blocks tuning)

Are the deployed modules a single 4×4 geometry, or do they vary? One of the labelled close-ups showed a wider block (~7 columns). The label format already carries a per-image grid, so mixed geometries are supported — just need confirmation before tuning.

Full rationale + workflow: dev-tools/real_captures/README.md, ADR-026, and the chapter-11 lesson ("a fallback may degrade but must never fabricate").

🤖 Generated with Claude Code

https://claude.ai/code/session_01MEJZA4eWELoGpvZ7Ym41K6


Generated by Claude Code

… ONNX)

Replace the OpenCV HoughCircles detector — which found no holes on real ESP
captures — with the trained YOLO26n-seg model, exported to ONNX and run through
the lean onnxruntime (no torch/ultralytics in the service image). The model
localizes every nest hole and crops a real per-nest snip from each; empty/sealed
classification is deferred, so snips carry state="undetermined" and
stub_classify() still drives the species progress bars.

image-service
- ONNX inference (conf 0.25, NMS 0.7 dedup), diameter-based bee-type labelling,
  no per-row cap so the irregular 7/5/5/4 (21) and 4x4 (16) blocks both keep
  every hole; ~50 ms CPU, graceful degradation preserved.
- onnxruntime + the committed 11 MB ONNX (models/hole_detector.onnx); libgomp1
  added to the Dockerfile.

duckdb / wire
- GET /detections folds to the latest capture and dedups per nest, so a vanished
  nest no longer latches a stale crop and a re-upload stays idempotent.
- new neutral "undetermined" state threaded through duckdb, backend, contracts,
  and the NestSnipGrid badge (+ i18n).

dev-tools/ml_hole_detection
- export_onnx.py (with an end2end-shape guard that rejects a non-YOLO26 fallback)
  and verify_onnx_parity.py (runtime vs ultralytics — committed proof of the
  bit-for-bit claim).

docs: arc42 chapter + ADR-027, image-service.md, glossary, runtime-view,
api-reference/contracts, and a troubleshooting entry for onnxruntime's libgomp1
need. Refs #165.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@cofade cofade marked this pull request as ready for review June 26, 2026 08:36
@cofade cofade merged commit 4257487 into main Jun 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants