Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file modified .github/workflows/tests.yml
Binary file not shown.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,6 @@ cython_debug/
marimo/_static/
marimo/_lsp/
__marimo__/

# Claude Code local settings (personal overrides, not for team)
.claude/settings.local.json
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ pytest tests/ --tb=short
- Use float32 not bfloat16 — bfloat16 silently corrupts DeepForest training
- Use scale_y for both axes in coordinate mapping — scale_x is wrong due to aerial subregion cropping
- uint8 subtraction overflows — always cast to int16 first
- Text filter disabled in detect.py — colony-row birds match text-alignment heuristics and get wrongly removed
- Red HSV wraps around 180° — always use two ranges [0,20] and [160,180]
- Vegetation boost applies to green channel only — do not boost all colors
- detect.py expects RGB input — BGR input silently produces wrong detections

## Boundaries
✅ Always: Load config from config.yaml, one test file per module
Expand Down
83 changes: 81 additions & 2 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Configuration for screenshot decomposition
# Configuration for bird annotation recovery pipeline

decompose:
# Grey pixel detection (R ≈ G ≈ B)
Expand All @@ -15,6 +15,8 @@ decompose:
boundary_max: 0.70
min_dialog_width_px: 80
low_confidence: 0.3
confidence_spread_denom: 0.25
safe_boundary_min_pct: 0.45

# Grey profile detection
grey_profile_threshold: 0.25
Expand All @@ -36,4 +38,81 @@ decompose:
title_bar_heights: [25, 35, 45]
taskbar_heights: [25, 35, 45]
title_grey_threshold: 50.0
taskbar_grey_threshold: 60.0
taskbar_grey_threshold: 60.0

detect:
# HSV color bins (measured from 1,199 candidate dots)
color_bins:
red:
h_ranges: [[0, 20], [160, 180]]
s_min: 80
v_min: 70
yellow:
h_ranges: [[16, 44]]
s_min: 80
v_min: 70
green:
h_ranges: [[36, 82]]
s_min: 80
v_min: 70
cyan:
h_ranges: [[78, 102]]
s_min: 80
v_min: 70
blue:
h_ranges: [[98, 138]]
s_min: 70
v_min: 70
magenta:
h_ranges: [[130, 170]]
s_min: 70
v_min: 70

# Morphology (measured from 25 study images)
min_comp_area: 8
min_circularity: 0.1
max_aspect_ratio: 3.0

# Vegetation adaptation
veg_h_lo: 30
veg_h_hi: 85
veg_s_lo: 20
veg_s_hi: 130
veg_v_lo: 30
veg_v_hi: 200
veg_boost_high: 40
veg_boost_mid: 25
veg_boost_low: 15
veg_boost_high_val: 70
veg_boost_mid_val: 50
veg_boost_low_val: 30

# Cluster splitting
min_split_filter_size: 5
split_dist_threshold: 1.5
split_area_ratio: 0.7

# Species matching
max_rel_diff: 10.0

legend:
# Marker detection in the dialog legend (colored-glyph anchoring)
blob_sat_min: 90 # strict: anchor rows, avoid window-chrome colors
marker_sat_min: 70 # looser: read color inside an already-located cell
marker_val_min: 55
blob_area_min: 6 # min connected-component area (px) for a marker
blob_area_max: 700 # max colored-blob area in the screenshot scan
marker_band_pad: 5

# Row grid (Windows list-view rows are evenly pitched)
row_pitch_min: 6.0 # smallest valid row gap (px)
row_pitch_max: 60.0 # largest valid row gap (px)

# Shape classification of marker glyphs
fg_grey_delta: 28 # grey marker vs grey row background
fill_filled: 0.55 # bbox fill ratio above which a glyph is "filled"
square_extent: 0.6 # corner occupancy for square vs circle

# Glyph template extraction (markers upscaled so shape is analyzable)
glyph_upscale: 48 # upscale target px (z = round(target / cell_side))
template_size: 24
Binary file added data/fixtures/detect_output.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/dialogs/A_felicity_2012_dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/dialogs/B_gaillard_2011_dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/dialogs/D_raccoon_2011_dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/screenshots/A_felicity_2012.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/screenshots/B_gaillard_2011.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/screenshots/C_northdeer_2010.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added data/fixtures/screenshots/D_raccoon_2011.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added results/figures/fig1_localization.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added results/figures/fig2_marker_to_class.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added results/figures/fig3_classified_aerial.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added results/figures/fig4_counts_barchart.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added results/figures/fig5_color_vs_shape.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
112 changes: 112 additions & 0 deletions scripts/run_legend.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
"""
Batch-run the dialog legend parser over a folder of screenshots.

Usage:
python scripts/run_legend.py [input_glob]

Default input: data/fixtures/screenshots/*.png (plus data/fixtures/sample_screenshot.png)
Outputs an annotated image per screenshot to results/legend/ and prints a
per-image summary table of the recovered (color, shape) markers.

Drop full-resolution screenshots into data/fixtures/screenshots/ and run this.
"""
from __future__ import annotations

import glob
import os
import sys

import cv2
import numpy as np

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from src.decompose import ScreenshotDecomposer
from src.legend import parse_legend

OUT_DIR = "results/legend"

# Distinct BGR colors for drawing row boxes.
_DRAW = {
"red": (40, 40, 220), "orange": (40, 130, 240), "yellow": (40, 220, 230),
"green": (60, 200, 60), "cyan": (220, 220, 40), "blue": (230, 90, 40),
"magenta": (220, 60, 220), "grey": (150, 150, 150), "unknown": (80, 80, 80),
}


def annotate(dialog_rgb: np.ndarray, entries: list) -> np.ndarray:
"""Draw labeled boxes on each detected legend row + template strip."""
dialog_bgr = cv2.cvtColor(dialog_rgb, cv2.COLOR_RGB2BGR)
scale = max(1, int(round(420 / max(dialog_bgr.shape[1], 1))))
canvas = cv2.resize(
dialog_bgr, None, fx=scale, fy=scale, interpolation=cv2.INTER_NEAREST
)
for e in entries:
cx, cy = int(e.cx * scale), int(e.cy * scale)
col = _DRAW.get(e.color, (80, 80, 80))
r = max(6, int(5 * scale))
cv2.rectangle(canvas, (cx - r, cy - r), (cx + r, cy + r), col, 1)
cv2.putText(
canvas, f"{e.row}:{e.color}/{e.shape}", (cx + r + 3, cy + 3),
cv2.FONT_HERSHEY_SIMPLEX, 0.4, col, 1, cv2.LINE_AA,
)

# Template strip on the right.
if entries:
T = entries[0].template.shape[0]
strip = np.full((canvas.shape[0], T + 8, 3), 255, np.uint8)
y = 4
for e in entries:
t = (e.template * 255).astype(np.uint8)
t = cv2.cvtColor(t, cv2.COLOR_GRAY2BGR)
if y + T <= strip.shape[0]:
strip[y:y + T, 4:4 + T] = t
y += T + 4
canvas = np.hstack([canvas, strip])
return canvas


def main() -> None:
args = sys.argv[1:]
if args:
paths = sorted(glob.glob(args[0]))
else:
paths = sorted(glob.glob("data/fixtures/screenshots/*.png"))
extra = "data/fixtures/sample_screenshot.png"
if os.path.exists(extra):
paths.append(extra)

if not paths:
print("No screenshots found. Drop PNGs into data/fixtures/screenshots/")
return

os.makedirs(OUT_DIR, exist_ok=True)
dec = ScreenshotDecomposer()

for path in paths:
img = cv2.imread(path)
if img is None:
print(f"SKIP (unreadable): {path}")
continue
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
name = os.path.splitext(os.path.basename(path))[0]

res = dec.decompose(img_rgb, expect_dialog=True)
if res.dialog is None:
print(f"{name}: no dialog detected ({img.shape[1]}x{img.shape[0]})")
continue

entries = parse_legend(res.dialog)
print(f"\n=== {name} ({img.shape[1]}x{img.shape[0]}, "
f"dialog {res.dialog.shape[1]}x{res.dialog.shape[0]}, "
f"{len(entries)} rows) ===")
for e in entries:
print(f" row {e.row:2d} {e.color:8s} {e.shape:9s} "
f"(y={e.cy:.0f}, hue={e.hue})")

out = os.path.join(OUT_DIR, f"{name}_legend.png")
cv2.imwrite(out, annotate(res.dialog, entries))
print(f" -> {out}")


if __name__ == "__main__":
main()
Loading
Loading