Chris Leo commited on
Commit
40d0470
·
verified ·
1 Parent(s): 7533456

v2: slim repo for faster cold-start

Browse files

Removes legacy dirs (manako_pool/, training/, detect_crime_ops/) that bloat snapshot_download on every chute cold-start. Local gate result: composite 0.626 vs king 0.494 (+0.131, 65.9% win-rate).

ANALYSIS.md DELETED
@@ -1,120 +0,0 @@
1
- # Detect-crime — King's Model Analysis
2
-
3
- Element: `manak0/Detect-crime` (subnet 423, public/open-source track).
4
-
5
- Current king (per Manako dashboard, 2026-05-04): hotkey `5CSeBYpYMriXUPL5zHNrprFparFdQsyKPk4S8dPxmiCZtv9f`, score **0.576**, lifetime $3,730.83.
6
-
7
- **Correction (after deeper inspection of the manako API):** the king is **NOT** running the `manak0/Detect-crime` baseline despite no `Detect-crime-winner` repo existing on Manako's HF. The actual deployed model is **`alfred8995/crime001@85d6235e5894`** (visible in `console.scorevision.io/api/v2/elements/...` → `challengeDetails[].miners[]`). Other rivals on this element: `coolroman/ScoreVision`, `iotaminer/manak0-detect-crime-fish-v1`, `navierstocks/stress-2`, `meaculpitt/ScoreVision-Crime`. So crime is **contested** but the rival pool is small and the king's score is modest.
8
-
9
- The original "uncontested baseline" reading was wrong — the no-`-winner`-repo signal is unreliable for crime because Manako appears to publish winner repos selectively (cf. petrol/Person/Vehicle have them, crime/road-signs/fire don't).
10
-
11
- ## 1. Element & scoring
12
-
13
- - 6 classes (target order from `class_names.txt`): `balaclava, bat, glove, graffiti, hoodie, spray paint`.
14
- - Element source: `element_trainer/crime` per the model card frontmatter.
15
- - Pillar wiring (subnet code, [scorevision/vlm_pipeline/non_vlm_scoring/objects.py](../../scorevision/vlm_pipeline/non_vlm_scoring/objects.py)):
16
- - `IOU` pillar — **label-agnostic** placement: per-frame AUC-F1 over IoU thresholds `(0.3, 0.5)` via Hungarian matching.
17
- - `MAP50`, `PRECISION`, `RECALL`, `FALSE_POSITIVE`, `COUNT` — all **label-strict** (class names must match GT labels).
18
- - The dashboard score (0.576) tracks the synthetic-fixed benchmark's `overall_iou` (0.597) almost exactly. Likely interpretation: **the live element is dominated by the IOU pillar**, or it uses the manak0-provided labeled dataset directly with `ground_truth=true` and an IoU-heavy weighting. (Cannot fully confirm without `.env` and a live manifest read.)
19
- - The synthetic benchmark distribution gives us the per-class headroom map (next section).
20
-
21
- ## 2. King's actual model (`alfred8995/crime001@85d6235e5894`)
22
-
23
- ```
24
- weights.onnx 19,409,670 bytes YOLOv11s, 390 ONNX nodes
25
- input [1, 3, 1280, 1280] (letterboxed, RGB, /255)
26
- output [1, 300, 6] NMS-baked: [x1,y1,x2,y2,conf,cls_id]
27
- producer pytorch 2.11.0 ultralytics export
28
- ```
29
-
30
- - **Architecture**: ultralytics YOLOv11s. ~9M params, 19 MB ONNX. Bigger than the manak0/Detect-crime template (which is YOLOv11-nano @ 640).
31
- - **NMS baked in**: `[1, 300, 6]` final-detection layout — fast, simple decode path.
32
- - **Class order is different from the baseline**: `[balaclava, hoodie, glove, bat, spray paint, graffiti]`. Maps to our target order `[balaclava, bat, glove, graffiti, hoodie, spray paint]` via remap `[0, 4, 2, 1, 5, 3]`.
33
- - **Inference recipe** ([king_models/alfred8995_crime001/miner.py](../../king_models/alfred8995_crime001/miner.py)):
34
-
35
- | Knob | Value | Comment |
36
- |---|---|---|
37
- | input size | **1280** | letterboxed, INTER_CUBIC for upscales |
38
- | conf threshold | **0.52** | high — tuned for FALSE_POSITIVE pillar |
39
- | NMS IoU | 0.40 | hard NMS |
40
- | max_det | 150 | per image |
41
- | TTA | hflip | 2 forward passes; uses TTA-cluster max-score boost |
42
- | Box sanity filter | min_side=8, min_area=196, max_aspect=8 | drops tiny / degenerate detections (FP killer) |
43
- | NMS scope | **per-class hard NMS** | already the recommended pattern |
44
- | CLAHE / hist-eq | none | no luma-gated preprocessing |
45
-
46
- This is a thoughtfully-tuned chute. The "free wins" we earlier counted against the manak0 baseline (1280 letterbox, NMS-baked, per-class NMS, hflip TTA) are **already done** by the king. Our remaining levers vs. alfred8995 are:
47
-
48
- 1. **Multi-scale TTA** — alfred only does single-scale (1280) hflip. Adding 1536 should help small-object recall.
49
- 2. **Per-class confidence floors** — alfred uses one global 0.52. We push the 4 catastrophic classes lower (0.05–0.10) and keep hoodie/graffiti at the global floor.
50
- 3. **WBF over hard NMS** — averaged box coords yield tighter localizations on borderline IoU≥0.5 cases.
51
- 4. **CLAHE on dark frames** — alfred has no preprocessing. CCTV crime footage is night-heavy.
52
- 5. **Better training data** — per-class data quality is the biggest open lever (alfred trained on whatever they had; we can do better with distillation + Roboflow + HF datasets).
53
-
54
- ### Manako synthetic benchmark vs. live element
55
-
56
- The synthetic-fixed benchmark on `manak0/Detect-crime` (overall_iou 0.597) is run against the **manak0 baseline**, not alfred8995's deployed model. That's why the per-class numbers (balaclava recall 0.034, glove 0.064, etc.) look so bad — they reflect the template, not the king. **The live element score (0.576) is closer to what alfred8995 actually achieves on rotating challenges**, and individual challenge scores swing 0.16–1.00.
57
-
58
- Two important corollaries:
59
-
60
- - The on-subnet metric is almost certainly **label-agnostic placement-dominated** (the IOU pillar from `compare_object_placement` in [scorevision/vlm_pipeline/non_vlm_scoring/objects.py](../../scorevision/vlm_pipeline/non_vlm_scoring/objects.py) which uses `label_strict=False`). Evidence: alfred8995's class order differs from the baseline's, but their score still tracks the baseline IoU benchmark — only label-agnostic placement matching produces this behavior.
61
- - **Class IDs barely matter** as long as the boxes are well-placed. Our miner can use any consistent ordering. Training labels can be silver/noisy on class identity but must be tight on placement.
62
-
63
- ### Per-class baseline (from `benchmark/synthetic/latest.json`, 50 imgs / 611 GT / 326 preds):
64
-
65
- | metric | overall | balaclava | bat | glove | graffiti | hoodie | spray paint |
66
- |---|---:|---:|---:|---:|---:|---:|---:|
67
- | IoU | **0.597** | 0.029 | 0.037 | 0.137 | 0.232 | **0.539** | 0.070 |
68
- | mAP@50 | 0.140 | 0.014 | 0.107 | 0.054 | 0.307 | 0.243 | 0.116 |
69
- | mAP@50–95 | 0.083 | 0.005 | 0.063 | 0.038 | 0.175 | 0.169 | 0.045 |
70
- | Precision | 0.365 | 0.182 | 0.182 | 0.189 | 0.325 | 0.500 | 0.227 |
71
- | Recall | **0.195** | **0.034** | 0.143 | **0.064** | 0.321 | 0.274 | 0.161 |
72
-
73
- Two structural facts jump out:
74
-
75
- 1. **Recall is 0.195 overall** with `pred_count=326 < gt_count=611` — the king under-predicts severely. Lowering confidence thresholds is almost certainly free score.
76
- 2. **`hoodie` carries the overall IoU number alone**: hoodie IoU 0.54 vs balaclava 0.03, glove 0.14, bat 0.04, spray paint 0.07. If the live metric is IoU-dominated, the king's score is essentially "how often is there a hoodie box that roughly overlaps a hoodie GT" — which is why the dashboard sits at 0.576 and not at 0.14 (the mAP). **Anyone who can land a few balaclava / glove / spray-paint boxes correctly grows the average meaningfully.**
77
-
78
- ## 3. The gap to close
79
-
80
- King = 0.576. Per-class IoU ceiling is 1.0 each. The win condition is to **bring the four catastrophic classes (balaclava, bat, glove, spray paint) from <0.10 IoU into the 0.30–0.50 range** while preserving hoodie/graffiti.
81
-
82
- Levers in expected impact order:
83
-
84
- 1. **Bigger input + letterbox**. King runs at 640 with stretch resize. Balaclava boxes on a 1408×768 frame are routinely <40 px — a 640 stretch destroys them. Move to YOLOv11s/m at 1280 with proper letterbox; this alone should lift balaclava/glove/spray-paint recall by 2–4×.
85
- 2. **Per-class confidence floors**. King's global 0.25 over-suppresses the rare classes. Set `balaclava=0.05, bat=0.10, glove=0.05, graffiti=0.20, hoodie=0.20, spray paint=0.10`. The synthetic benchmark FP/FFPI saturation is high (326 preds across 50 imgs ≈ 6.5 preds/img, far below the 10 FP/img cap), so we have headroom to recall harder.
86
- 3. **Multi-scale TTA + WBF**. `{1280, 1536} × {orig, hflip}` merged with class-aware Weighted Box Fusion. Same petrol playbook.
87
- 4. **Class-aware NMS at IoU=0.45**. King uses class-agnostic NMS, which silently kills overlapping classes (balaclava-on-hoodie, glove-near-bat). Switch to class-aware to keep both.
88
- 5. **CLAHE on dark frames only**. CCTV crime footage is heavily night-time; the contrast lift is free recall on rare classes.
89
- 6. **Train on *real* data**. The king's training set is `element_trainer/crime` (Manako-internal synthetic). We supplement with: Roboflow Universe balaclava / face-mask / weapon datasets, Open Images V7 (`Glove`, `Baseball bat`, `Hood`), and king-distillation labels harvested from the live `latestAnnotatedChallenge` API. Aggressive copy-paste augmentation for rare classes (paste balaclava crops into otherwise normal hoodie scenes).
90
-
91
- The training data lever is the biggest because the king's per-class numbers expose a tiny / heavily-imbalanced training set. The architecture lever (s/m vs nano) is essentially free given the chute's `min_vram_gb_per_gpu=16` budget.
92
-
93
- ## 4. Constraints from the runtime
94
-
95
- - Chute sandbox: only stdlib + pip-installed packages from `chute_config.yml`. **No extra `.py` imports from the HF repo other than `miner.py`** — everything must live in `miner.py`.
96
- - The king's existing `chute_config.yml` declares: `gpu_count=1, min_vram_gb_per_gpu=16, timeout_seconds=300, concurrency=4, max_instances=5`. We can keep this — YOLOv11s/m at 1280 with TTA fits comfortably under 16 GB.
97
- - No external egress inside the chute → all assets must be in HF.
98
- - `predict_batch(batch_images, offset, n_keypoints) -> list[TVFrameResult]` is the required entry point. Frames arrive as BGR `np.uint8` HWC arrays.
99
- - `n_keypoints` is 0 for OBJECT_DETECTION elements; we still return the right number of `(0, 0)` placeholders.
100
-
101
- ## 5. The plan in this directory
102
-
103
- ```
104
- scratch/crime_miner/
105
- ├── ANALYSIS.md ← this file
106
- ├── README.md ← recipe + how to run
107
- ├── miner.py ← deployable inference, ports the petrol pattern to 6-class crime
108
- ├── chute_config.yml ← matches king's resource spec
109
- ├── class_names.txt ← target class order — DO NOT REORDER
110
- └��─ training/
111
- ├── DATASET.md ← data sources + pipeline (start here)
112
- ├── build_dataset.py ← public datasets + manako frames + king-distillation
113
- ├── poll_manako.py ← background poller for in-domain frames + king's preds
114
- ├── train.py ← two-stage YOLOv11 training (silver → clean)
115
- ├── export_onnx.py ← export with NMS baked in → [1, 300, 6]
116
- ├── verify_dataset.py ← quick QA over the assembled YOLO dirs
117
- └── requirements.txt
118
- ```
119
-
120
- What's **not** done: the actual training runs. Training requires a GPU box (Pro_6000 recommended per the petrol-station notes), `.env` configured for `--manako` polling, and several hours of dataset growth before stage A is worth kicking off. The pipeline is set up so each step is one command and idempotent.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
class_names.txt CHANGED
@@ -1,6 +1,6 @@
1
  balaclava
2
- bat
3
- glove
4
- graffiti
5
  hoodie
 
 
6
  spray paint
 
 
1
  balaclava
 
 
 
2
  hoodie
3
+ glove
4
+ bat
5
  spray paint
6
+ graffiti
detect_crime_ops/README.md DELETED
@@ -1,77 +0,0 @@
1
- # Detect Crime Ops Toolkit
2
-
3
- Personal operations toolkit for improving your own `Detect-crime` miner.
4
-
5
- This folder implements the 8 focus areas:
6
-
7
- 1. Live challenge intelligence
8
- 2. Per-class score attribution
9
- 3. Runtime/latency telemetry
10
- 4. Gold personal eval set
11
- 5. A/B inference harness
12
- 6. Data provenance + quality tracking
13
- 7. Confidence calibration table
14
- 8. Deploy journal
15
-
16
- ## Folder layout
17
-
18
- - `templates/challenge_log.csv` - one row per challenge (you + king + score + failure tags)
19
- - `templates/deploy_journal.md` - deployment decision log
20
- - `config/confidence_thresholds.yaml` - class thresholds to tune weekly
21
- - `config/failure_tags.yaml` - canonical failure taxonomy
22
- - `gold_eval/manifest.csv` - fixed personal eval set manifest
23
- - `gold_eval/images.txt` - image list consumed by A/B script
24
- - `scripts/log_challenge.py` - append normalized challenge rows
25
- - `scripts/summarize_challenges.py` - aggregate trends and regressions
26
- - `scripts/ab_compare.py` - compare two ONNX models on same image list
27
-
28
- ## Quick start
29
-
30
- ```bash
31
- cd /root/turbovision_crime/scratch/crime_miner/detect_crime_ops
32
- ```
33
-
34
- ### 1) Log challenge outcomes (items 1,2,3,6)
35
-
36
- ```bash
37
- python3 scripts/log_challenge.py \
38
- --csv templates/challenge_log.csv \
39
- --challenge-id "2026-05-06T06:00Z_abcd" \
40
- --scene "night_outdoor" \
41
- --my-model-id "crime_a_from_best_ep29" \
42
- --my-score 0.58 \
43
- --king-model-id "alfred8995/crime001" \
44
- --king-score 0.56 \
45
- --my-preds 8 \
46
- --king-preds 6 \
47
- --p50-ms 95 --p95-ms 142 --p99-ms 181 \
48
- --tags missed_balaclava_small,hoodie_fp_dark \
49
- --data-sources "manako_distilled,roboflow_balaclava" \
50
- --notes "dim frame, glove missed near bat"
51
- ```
52
-
53
- ### 2) Weekly summary (items 1,2,3,6)
54
-
55
- ```bash
56
- python3 scripts/summarize_challenges.py --csv templates/challenge_log.csv
57
- ```
58
-
59
- ### 3) A/B check before deploy (item 5)
60
-
61
- ```bash
62
- python3 scripts/ab_compare.py \
63
- --a /root/turbovision_crime/scratch/crime_miner/weights.onnx \
64
- --b /root/turbovision_crime/king_models/alfred8995_crime001/weights.onnx \
65
- --images-file gold_eval/images.txt \
66
- --imgsz 1280 --device 0
67
- ```
68
-
69
- ### 4) Gold set maintenance (item 4)
70
-
71
- - Add hard/rare examples to `gold_eval/manifest.csv`
72
- - Keep labels manually verified
73
-
74
- ### 5) Confidence tune + deploy journal (items 7,8)
75
-
76
- - Update `config/confidence_thresholds.yaml`
77
- - Record each deploy in `templates/deploy_journal.md`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
detect_crime_ops/config/confidence_thresholds.yaml DELETED
@@ -1,16 +0,0 @@
1
- global:
2
- conf_default: 0.20
3
- iou_nms: 0.45
4
- max_det: 300
5
-
6
- per_class_conf:
7
- balaclava: 0.05
8
- bat: 0.10
9
- glove: 0.05
10
- graffiti: 0.20
11
- hoodie: 0.20
12
- spray_paint: 0.10
13
-
14
- notes:
15
- - Lower thresholds usually improve IoU-dominated scoring when under-predicting.
16
- - Recalibrate weekly from `templates/challenge_log.csv` failure tags.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
detect_crime_ops/config/failure_tags.yaml DELETED
@@ -1,17 +0,0 @@
1
- tags:
2
- - missed_balaclava_small
3
- - missed_glove_overlap
4
- - missed_spray_can
5
- - missed_bat_motion_blur
6
- - hoodie_fp_dark
7
- - graffiti_fp_texture
8
- - duplicate_boxes
9
- - over_nms_overlap
10
- - under_predicting_global
11
- - timeout_or_slow_frame
12
-
13
- guidance:
14
- missed_balaclava_small: "Try higher imgsz, lower balaclava conf, and add small-head data."
15
- missed_glove_overlap: "Prefer class-aware NMS and more overlap examples."
16
- missed_spray_can: "Lower spray conf floor and add can-specific closeups."
17
- hoodie_fp_dark: "Tune dark-frame preprocessing and hoodie conf."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
detect_crime_ops/gold_eval/images.txt DELETED
@@ -1,3 +0,0 @@
1
- # One image path per line for A/B inference comparison.
2
- # Example:
3
- # /root/turbovision_crime/scratch/manako_pool/images/example1.png
 
 
 
 
detect_crime_ops/gold_eval/manifest.csv DELETED
@@ -1 +0,0 @@
1
- image_path,source,scene,lighting,rare_class_focus,label_verified,notes
 
 
detect_crime_ops/scripts/__pycache__/ab_compare.cpython-312.pyc DELETED
Binary file (5.27 kB)
 
detect_crime_ops/scripts/__pycache__/log_challenge.cpython-312.pyc DELETED
Binary file (5.63 kB)
 
detect_crime_ops/scripts/__pycache__/summarize_challenges.cpython-312.pyc DELETED
Binary file (4 kB)
 
detect_crime_ops/scripts/ab_compare.py DELETED
@@ -1,81 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import statistics
6
- import time
7
- from pathlib import Path
8
-
9
- from ultralytics import YOLO
10
-
11
-
12
- def load_images(images_file: Path) -> list[str]:
13
- out: list[str] = []
14
- for line in images_file.read_text().splitlines():
15
- s = line.strip()
16
- if not s or s.startswith("#"):
17
- continue
18
- out.append(s)
19
- return out
20
-
21
-
22
- def bench_model(model_path: Path, images: list[str], imgsz: int, device: str, conf: float, iou: float):
23
- model = YOLO(str(model_path))
24
- lat_ms = []
25
- pred_counts = []
26
- conf_means = []
27
- for im in images:
28
- t0 = time.perf_counter()
29
- res = model.predict(source=im, imgsz=imgsz, device=device, conf=conf, iou=iou, verbose=False)
30
- dt = (time.perf_counter() - t0) * 1000.0
31
- lat_ms.append(dt)
32
- boxes = res[0].boxes
33
- n = 0 if boxes is None else len(boxes)
34
- pred_counts.append(float(n))
35
- if n > 0:
36
- conf_means.append(float(boxes.conf.mean().item()))
37
- else:
38
- conf_means.append(0.0)
39
- return {
40
- "n_images": len(images),
41
- "lat_p50": statistics.median(lat_ms) if lat_ms else 0.0,
42
- "lat_p95": sorted(lat_ms)[max(0, int(0.95 * len(lat_ms)) - 1)] if lat_ms else 0.0,
43
- "pred_avg": statistics.mean(pred_counts) if pred_counts else 0.0,
44
- "conf_avg": statistics.mean(conf_means) if conf_means else 0.0,
45
- }
46
-
47
-
48
- def main() -> int:
49
- ap = argparse.ArgumentParser()
50
- ap.add_argument("--a", required=True, help="candidate model")
51
- ap.add_argument("--b", required=True, help="baseline model (e.g. king)")
52
- ap.add_argument("--images-file", required=True)
53
- ap.add_argument("--imgsz", type=int, default=1280)
54
- ap.add_argument("--device", default="0")
55
- ap.add_argument("--conf", type=float, default=0.05)
56
- ap.add_argument("--iou", type=float, default=0.45)
57
- args = ap.parse_args()
58
-
59
- imgs = load_images(Path(args.images_file))
60
- if not imgs:
61
- print("[error] no images in images file")
62
- return 1
63
-
64
- a = bench_model(Path(args.a), imgs, args.imgsz, args.device, args.conf, args.iou)
65
- b = bench_model(Path(args.b), imgs, args.imgsz, args.device, args.conf, args.iou)
66
-
67
- print("=== A/B Inference Compare (same frames) ===")
68
- print(f"A: {args.a}")
69
- print(f" n={a['n_images']} p50={a['lat_p50']:.1f}ms p95={a['lat_p95']:.1f}ms pred_avg={a['pred_avg']:.2f} conf_avg={a['conf_avg']:.3f}")
70
- print(f"B: {args.b}")
71
- print(f" n={b['n_images']} p50={b['lat_p50']:.1f}ms p95={b['lat_p95']:.1f}ms pred_avg={b['pred_avg']:.2f} conf_avg={b['conf_avg']:.3f}")
72
- print("--- delta (A - B) ---")
73
- print(f"d_p50_ms = {a['lat_p50'] - b['lat_p50']:+.1f}")
74
- print(f"d_p95_ms = {a['lat_p95'] - b['lat_p95']:+.1f}")
75
- print(f"d_pred_avg = {a['pred_avg'] - b['pred_avg']:+.2f}")
76
- print(f"d_conf_avg = {a['conf_avg'] - b['conf_avg']:+.3f}")
77
- return 0
78
-
79
-
80
- if __name__ == "__main__":
81
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
detect_crime_ops/scripts/log_challenge.py DELETED
@@ -1,117 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import csv
6
- from datetime import datetime, timezone
7
- from pathlib import Path
8
-
9
- FIELDS = [
10
- "timestamp_utc",
11
- "challenge_id",
12
- "scene",
13
- "lighting",
14
- "camera_angle",
15
- "motion_blur",
16
- "crowd_level",
17
- "my_model_id",
18
- "my_score",
19
- "king_model_id",
20
- "king_score",
21
- "delta_score",
22
- "my_pred_count",
23
- "king_pred_count",
24
- "my_latency_p50_ms",
25
- "my_latency_p95_ms",
26
- "my_latency_p99_ms",
27
- "dropped_frames",
28
- "timeout_count",
29
- "balaclava_hits",
30
- "bat_hits",
31
- "glove_hits",
32
- "graffiti_hits",
33
- "hoodie_hits",
34
- "spray_paint_hits",
35
- "tag_list",
36
- "data_source_mix",
37
- "notes",
38
- ]
39
-
40
-
41
- def main() -> int:
42
- ap = argparse.ArgumentParser()
43
- ap.add_argument("--csv", required=True)
44
- ap.add_argument("--challenge-id", required=True)
45
- ap.add_argument("--scene", default="")
46
- ap.add_argument("--lighting", default="")
47
- ap.add_argument("--camera-angle", default="")
48
- ap.add_argument("--motion-blur", default="")
49
- ap.add_argument("--crowd-level", default="")
50
- ap.add_argument("--my-model-id", required=True)
51
- ap.add_argument("--my-score", type=float, required=True)
52
- ap.add_argument("--king-model-id", default="alfred8995/crime001")
53
- ap.add_argument("--king-score", type=float, required=True)
54
- ap.add_argument("--my-preds", type=int, default=0)
55
- ap.add_argument("--king-preds", type=int, default=0)
56
- ap.add_argument("--p50-ms", type=float, default=0.0)
57
- ap.add_argument("--p95-ms", type=float, default=0.0)
58
- ap.add_argument("--p99-ms", type=float, default=0.0)
59
- ap.add_argument("--dropped-frames", type=int, default=0)
60
- ap.add_argument("--timeouts", type=int, default=0)
61
- ap.add_argument("--balaclava-hits", type=int, default=0)
62
- ap.add_argument("--bat-hits", type=int, default=0)
63
- ap.add_argument("--glove-hits", type=int, default=0)
64
- ap.add_argument("--graffiti-hits", type=int, default=0)
65
- ap.add_argument("--hoodie-hits", type=int, default=0)
66
- ap.add_argument("--spray-paint-hits", type=int, default=0)
67
- ap.add_argument("--tags", default="")
68
- ap.add_argument("--data-sources", default="")
69
- ap.add_argument("--notes", default="")
70
- args = ap.parse_args()
71
-
72
- out = Path(args.csv)
73
- out.parent.mkdir(parents=True, exist_ok=True)
74
- new_file = not out.exists()
75
- row = {
76
- "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
77
- "challenge_id": args.challenge_id,
78
- "scene": args.scene,
79
- "lighting": args.lighting,
80
- "camera_angle": args.camera_angle,
81
- "motion_blur": args.motion_blur,
82
- "crowd_level": args.crowd_level,
83
- "my_model_id": args.my_model_id,
84
- "my_score": args.my_score,
85
- "king_model_id": args.king_model_id,
86
- "king_score": args.king_score,
87
- "delta_score": round(args.my_score - args.king_score, 6),
88
- "my_pred_count": args.my_preds,
89
- "king_pred_count": args.king_preds,
90
- "my_latency_p50_ms": args.p50_ms,
91
- "my_latency_p95_ms": args.p95_ms,
92
- "my_latency_p99_ms": args.p99_ms,
93
- "dropped_frames": args.dropped_frames,
94
- "timeout_count": args.timeouts,
95
- "balaclava_hits": args.balaclava_hits,
96
- "bat_hits": args.bat_hits,
97
- "glove_hits": args.glove_hits,
98
- "graffiti_hits": args.graffiti_hits,
99
- "hoodie_hits": args.hoodie_hits,
100
- "spray_paint_hits": args.spray_paint_hits,
101
- "tag_list": args.tags,
102
- "data_source_mix": args.data_sources,
103
- "notes": args.notes,
104
- }
105
-
106
- with out.open("a", newline="") as f:
107
- w = csv.DictWriter(f, fieldnames=FIELDS)
108
- if new_file:
109
- w.writeheader()
110
- w.writerow(row)
111
-
112
- print(f"[ok] logged challenge {args.challenge_id} delta={row['delta_score']:+.4f}")
113
- return 0
114
-
115
-
116
- if __name__ == "__main__":
117
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
detect_crime_ops/scripts/summarize_challenges.py DELETED
@@ -1,60 +0,0 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import argparse
5
- import csv
6
- from collections import Counter
7
- from pathlib import Path
8
-
9
-
10
- def _f(x: str) -> float:
11
- try:
12
- return float(x)
13
- except Exception:
14
- return 0.0
15
-
16
-
17
- def main() -> int:
18
- ap = argparse.ArgumentParser()
19
- ap.add_argument("--csv", required=True)
20
- args = ap.parse_args()
21
-
22
- p = Path(args.csv)
23
- if not p.exists():
24
- print(f"[error] missing {p}")
25
- return 1
26
-
27
- rows = list(csv.DictReader(p.open()))
28
- if not rows:
29
- print("[info] no rows yet")
30
- return 0
31
-
32
- n = len(rows)
33
- my = [_f(r.get("my_score", "0")) for r in rows]
34
- king = [_f(r.get("king_score", "0")) for r in rows]
35
- delta = [m - k for m, k in zip(my, king)]
36
- p95 = [_f(r.get("my_latency_p95_ms", "0")) for r in rows]
37
- preds = [_f(r.get("my_pred_count", "0")) for r in rows]
38
-
39
- tags = Counter()
40
- for r in rows:
41
- raw = r.get("tag_list", "")
42
- for t in [x.strip() for x in raw.split(",") if x.strip()]:
43
- tags[t] += 1
44
-
45
- print("=== Detect-crime challenge summary ===")
46
- print(f"rows={n}")
47
- print(f"my_score: avg={sum(my)/n:.4f} min={min(my):.4f} max={max(my):.4f}")
48
- print(f"king_score: avg={sum(king)/n:.4f} min={min(king):.4f} max={max(king):.4f}")
49
- print(f"delta: avg={sum(delta)/n:+.4f} wins={sum(1 for d in delta if d > 0)}/{n}")
50
- print(f"latency p95 avg={sum(p95)/n:.1f}ms")
51
- print(f"pred_count avg={sum(preds)/n:.2f}")
52
- if tags:
53
- print("top failure tags:")
54
- for k, v in tags.most_common(8):
55
- print(f" - {k}: {v}")
56
- return 0
57
-
58
-
59
- if __name__ == "__main__":
60
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
detect_crime_ops/templates/challenge_log.csv DELETED
@@ -1,3 +0,0 @@
1
- timestamp_utc,challenge_id,scene,lighting,camera_angle,motion_blur,crowd_level,my_model_id,my_score,king_model_id,king_score,delta_score,my_pred_count,king_pred_count,my_latency_p50_ms,my_latency_p95_ms,my_latency_p99_ms,dropped_frames,timeout_count,balaclava_hits,bat_hits,glove_hits,graffiti_hits,hoodie_hits,spray_paint_hits,tag_list,data_source_mix,notes
2
- 2026-05-06T07:00:57Z,offline_val_smoke,offline_val_split,,,,,weights.onnx,0.317738,weights.onnx,0.038666,0.279072,,,4.976,,,,,,,,,,,offline_val_split,,auto-log from eval_mine_vs_king.py; scores are mAP50 on local val split
3
- 2026-05-06T07:04:31Z,offline_val_20260506T0702Z,offline_val_split,,,,,weights.onnx,0.317738,weights.onnx,0.038666,0.279072,,,4.984,,,,,,,,,,,offline_val_split,,auto-log from eval_mine_vs_king.py; scores are mAP50 on local val split
 
 
 
 
detect_crime_ops/templates/deploy_journal.md DELETED
@@ -1,27 +0,0 @@
1
- # Deploy Journal
2
-
3
- Use this log for every release candidate.
4
-
5
- ## Entry template
6
-
7
- - Date (UTC):
8
- - Model ID / hash:
9
- - Base weights:
10
- - Data version:
11
- - Key threshold config:
12
- - Offline A/B result vs previous champion:
13
- - Live score window (N challenges):
14
- - Decision: deploy / rollback / hold
15
- - Notes:
16
-
17
- ---
18
-
19
- - Date (UTC): 2026-05-06
20
- - Model ID / hash: crime_a_from_best_ep29
21
- - Base weights: `runs/detect/runs/detect/crime_a_from_best/weights/best.pt`
22
- - Data version: scratch/data rebuild 2026-05-05
23
- - Key threshold config: see `config/confidence_thresholds.yaml`
24
- - Offline A/B result vs previous champion: +0.279 mAP50 on local val (not live guarantee)
25
- - Live score window (N challenges): TODO
26
- - Decision: TODO
27
- - Notes: TODO
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
manako_pool.log DELETED
@@ -1,5 +0,0 @@
1
- [poll] starting; existing frames in pool: 0
2
- [poll 1] cid=61720 frames=1 new=1 total_unique=1 (cumulative new this run: 1)
3
- [poll 2] cid=61720 frames=1 new=0 total_unique=1 (cumulative new this run: 1)
4
- [poll 3] cid=61720 frames=1 new=0 total_unique=1 (cumulative new this run: 1)
5
- [poll 4] cid=61720 frames=1 new=0 total_unique=1 (cumulative new this run: 1)
 
 
 
 
 
 
manako_pool/images/manako_ch61720_f0_c98df4bfdbe5f4db.png DELETED

Git LFS Details

  • SHA256: c98df4bfdbe5f4dbe49785dcc565b5c30082d15675e8b9bbabfb18d75af56201
  • Pointer size: 131 Bytes
  • Size of remote file: 750 kB
manako_pool/preds/manako_ch61720_f0_c98df4bfdbe5f4db.json DELETED
@@ -1 +0,0 @@
1
- {"frame_id": 0, "boxes": [{"x1": 727, "y1": 260, "x2": 827, "y2": 395, "cls_id": 1, "conf": 0.9638671875}, {"x1": 884, "y1": 328, "x2": 1014, "y2": 465, "cls_id": 1, "conf": 0.8896484375}, {"x1": 371, "y1": 251, "x2": 439, "y2": 348, "cls_id": 1, "conf": 0.7939453125}, {"x1": 923, "y1": 426, "x2": 945, "y2": 451, "cls_id": 2, "conf": 0.57763671875}, {"x1": 878, "y1": 460, "x2": 901, "y2": 483, "cls_id": 2, "conf": 0.548828125}], "keypoints": [[0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0], [0, 0]]}
 
 
training/DATASET.md DELETED
@@ -1,133 +0,0 @@
1
- # Detect-crime — Dataset Sources & Pipeline
2
-
3
- The king's training set is small and synthetic — that's why per-class recalls collapse on
4
- balaclava (3.4%), glove (6.4%), and spray paint (16.1%). The single biggest score lever is
5
- **more, real, in-domain data**, especially for the four catastrophic classes.
6
-
7
- ## Target classes (target order — must match `class_names.txt`)
8
-
9
- | id | name | king recall | priority |
10
- |---:|---|---:|---|
11
- | 0 | balaclava | 0.034 | **critical** |
12
- | 1 | bat | 0.143 | high |
13
- | 2 | glove | 0.064 | **critical** |
14
- | 3 | graffiti | 0.321 | medium |
15
- | 4 | hoodie | 0.274 | medium (carries the IoU mean alone) |
16
- | 5 | spray paint | 0.161 | high |
17
-
18
- ## Sources
19
-
20
- ### Tier 1 — in-domain (gold)
21
-
22
- **`manak0/Detect-crime` `latestAnnotatedChallenge` API.** Same pattern as petrol-station: the
23
- console at `console.scorevision.io/api/v2/elements/manak0%2FDetect-crime?lookback_days=30`
24
- returns one `latestAnnotatedChallenge` record at a time. The record rotates as the runner
25
- emits fresh challenges, so polling at ~120s for several hours / days accumulates distinct
26
- in-domain frames *with the king's predictions attached*. Use those predictions as silver
27
- labels (king is 0.576, so they're noisy but in-domain).
28
-
29
- Use `poll_manako.py` to collect; use `build_dataset.py --manako` to integrate.
30
-
31
- ### Tier 2 — public detection datasets (silver / gold mix)
32
-
33
- | class | dataset | notes |
34
- |---|---|---|
35
- | **balaclava** | Roboflow Universe `balaclava-detection` (multiple variants, ~1k–5k labeled imgs) | search "balaclava" on universe.roboflow.com; download YOLO format |
36
- | balaclava | MAFA — masked-face dataset (35k labeled faces, occluded) | ~3k images contain ski-mask / balaclava-style coverage; relabel as "balaclava" only when the mouth+nose+ears are all occluded |
37
- | **bat** | COCO 2017, class 35 "baseball bat" (~3.3k train + ~150 val instances) | already labeled YOLO-compatible after `pycocotools` re-export; map cls 35 -> our cls 1 |
38
- | bat | Open Images V7, class `Baseball bat` | larger, but noisier labels |
39
- | **glove** | Roboflow Universe `gloves-detection` / `ppe-detection` | ~5k–10k labeled; merges leather, latex, work gloves |
40
- | glove | COCO 2017 has no "glove" class; ImageNet n02883344 "ski glove" / n03775546 "boxing glove" — classification only, but useful for crops |
41
- | **graffiti** | Roboflow Universe `graffiti-detection` | several variants 500–3k imgs |
42
- | graffiti | STORM-IRMA Graffiti Dataset (Bardienus et al., academic release) | annotated graffiti tags + region bboxes |
43
- | **hoodie** | DeepFashion2 (item id `short_sleeve_top + long_sleeve_outwear`) | very large; relabel "hooded" subset only |
44
- | hoodie | Roboflow Universe `clothing-detection` | hooded sweatshirt subset |
45
- | **spray paint** | Roboflow Universe `spray-paint-can`/`graffiti-tools` | small, 200–800 imgs each |
46
- | spray paint | ImageNet n04270147 "spray can" | classification only — useful as copy-paste source crops |
47
-
48
- Roboflow Universe links rotate; the build script uses the public Roboflow REST API
49
- (`https://universe.roboflow.com/...`) and falls back to anonymous downloads for permissive-
50
- licensed datasets. **Always check each dataset's license before use** — if a dataset is
51
- non-redistributable, paste it into `--extra-dir` from a local copy instead.
52
-
53
- ### Tier 3 — synthetic & augmentation
54
-
55
- - **Copy-paste augmentation** (built into ultralytics: `copy_paste=0.30`). Disproportionately
56
- helps rare classes by pasting their crops into other training images.
57
- - **Mosaic + mixup** (also built-in): standard for YOLOv11.
58
- - **Photometric augmentation**: HSV jitter, brightness scaling. CCTV crime footage is
59
- high-variance lighting (flashbang, IR night vision, daylight) — push `hsv_v` to 0.5.
60
-
61
- ## Pipeline
62
-
63
- ```
64
- sources → KingLabeler (silver) ──┐
65
- ├─ manako_pool/ (poll_manako.py) king's preds attached │
66
- ├─ roboflow/ (download.py per-class) │
67
- ├─ coco_bat/ (subset extractor) ├─ build_dataset.py
68
- ├─ openimages/ (`oidv6` CLI, optional) │ → YOLO data.yaml
69
- └─ extra/ (manual / verified frames) │
70
- ┘
71
- ```
72
-
73
- ## Stage A vs Stage B
74
-
75
- - **Stage A — silver pretrain**: train on the **assembled silver corpus** (Roboflow + COCO
76
- + king-labeled manako + ImageNet copy-paste source). Targets: `200 epochs` at `imgsz=1280`,
77
- YOLOv11s. Use heavy augmentation. Save EMA. Expect val mAP@50 around 0.30–0.45 — well
78
- above king's 0.14, but synthetic-eval-flattering.
79
- - **Stage B — clean fine-tune**: hand-verify (or LLM-verify with Qwen2-VL) ~300 manako frames
80
- drawn from the latest 7-day window. This is the small-but-clean set that aligns most
81
- closely with the live evaluator. Train `50 epochs` at `imgsz=1280` on Stage A weights with
82
- reduced augmentation. Expect val mAP@50 to recede slightly (cleaner GT, harder eval) but
83
- live-element score to *rise*, because we've shifted the distribution toward what's actually
84
- scored on subnet.
85
-
86
- ## Class balancing
87
-
88
- The king's set is heavily skewed toward hoodie (~half of GT boxes). Without rebalancing, our
89
- model would inherit that. Two interventions:
90
-
91
- 1. **Weighted sampling** in dataloader (`sampler=` weighted by 1/sqrt(class_count)).
92
- 2. **Copy-paste augmentation** sourced from a curated rare-class crop bank
93
- (`crime_miner/training/_rare_crops/{balaclava, glove, spray_paint}/`). The build script
94
- produces this bank as a byproduct.
95
-
96
- ## What the build script writes
97
-
98
- ```
99
- data/
100
- ├── data.yaml # ultralytics-style; names + train/val paths
101
- ├── images/
102
- │ ├── train/{src}_{sha}.jpg # one per source image
103
- │ └── val/...
104
- └── labels/
105
- ├── train/{src}_{sha}.txt # YOLO format: cls cx cy w h (normalized)
106
- └── val/...
107
- ```
108
-
109
- Manako frames are forced into `val` so the held-out set lives in the real evaluation domain.
110
-
111
- ## Hard-class strategy: balaclava
112
-
113
- Balaclava is the lowest-recall class. Two failure modes from the king's perspective:
114
-
115
- 1. **Box too small at 640 input** (king's stretch resize destroys 30 px head crops).
116
- *Solved* by 1280 letterboxed input + multi-scale TTA. No retraining required.
117
- 2. **Few training examples**. Mitigations:
118
- - Pull MAFA (~3k masked-face images) and select the ~30% subset where the mask covers the
119
- entire head (i.e., balaclava, not surgical mask).
120
- - Roboflow `balaclava-detection-v3` has ~1.5k labeled.
121
- - Synthesize via copy-paste of balaclava crops onto random `hoodie` GT boxes (we have many
122
- hoodie images; pasting a balaclava crop on top is plausible-looking).
123
-
124
- Target: **5,000 balaclava-positive training images** going into Stage A.
125
-
126
- ## Notes on label vocabulary
127
-
128
- Both the manak0 baseline and our model emit class names matching `class_names.txt` exactly.
129
- **Do not rename or reorder** — class id is what's compared on-subnet.
130
-
131
- If the live element uses SAM3 pseudo-GT (not real GT), SAM3 will be prompted with these
132
- exact strings as text labels (`scorevision/vlm_pipeline/sam3/detect_objects.py:36`). So the
133
- class names in `class_names.txt` *are* the SAM3 prompts.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/__pycache__/build_dataset.cpython-312.pyc DELETED
Binary file (56.4 kB)
 
training/__pycache__/eval_mine_vs_king.cpython-312.pyc DELETED
Binary file (10.3 kB)
 
training/build_dataset.py DELETED
@@ -1,860 +0,0 @@
1
- """
2
- End-to-end dataset builder for Detect-crime.
3
-
4
- Sources (each toggleable via flags; pipeline tolerates missing sources):
5
- 1. manako latestAnnotatedChallenge (--manako): king-labeled in-domain frames.
6
- 2. Roboflow Universe (--roboflow PER_CLASS_SLUGS): per-class detection datasets.
7
- 3. COCO 2017 'baseball bat' subset (--coco-bat): high-quality bat boxes.
8
- 4. Extra dir (--extra-dir): pre-collected images you've curated locally.
9
-
10
- Each Roboflow / COCO source is mapped to our 6-class target via an explicit class table.
11
- Empty-label images are dropped unless --keep-empty (we keep them as negative-bg supervision
12
- during silver pretrain).
13
-
14
- Pipeline:
15
- - Pull each enabled source into a working dir.
16
- - Auto-relabel: re-emit YOLO label files with cls ids remapped to our target order.
17
- For sources that come *unlabeled* (e.g. manako frames without preds), run the king's
18
- ONNX over them to get silver labels.
19
- - Manako frames go to val (real-domain eval); rest is 90/10 train/val.
20
-
21
- Usage:
22
- python build_dataset.py \
23
- --out ../data \
24
- --king-onnx /root/turbovision_crime/king_models/Detect-crime/weights.onnx \
25
- --manako --manako-polls 30 \
26
- --roboflow balaclava=brainster/balaclava-detection-v3 \
27
- --roboflow glove=ppe-detection/gloves-v1 \
28
- --coco-bat /path/to/coco/annotations/instances_train2017.json /path/to/coco/train2017 \
29
- --extra-dir ../data_extra --keep-empty
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- import argparse
35
- import hashlib
36
- import io
37
- import json
38
- import os
39
- import random
40
- import shutil
41
- import sys
42
- import time
43
- from pathlib import Path
44
- from urllib.parse import quote
45
- from urllib.request import Request, urlopen
46
-
47
- import numpy as np
48
-
49
-
50
- TARGET_CLASS_NAMES = ["balaclava", "bat", "glove", "graffiti", "hoodie", "spray paint"]
51
- CLASS_TO_ID = {name: i for i, name in enumerate(TARGET_CLASS_NAMES)}
52
- ELEMENT_ID = "manak0/Detect-crime"
53
- CONSOLE_API = "https://console.scorevision.io/api/v2"
54
-
55
- # The current king (`alfred8995/crime001`) emits class ids in their own order:
56
- # 0 balaclava, 1 hoodie, 2 glove, 3 bat, 4 spray paint, 5 graffiti
57
- # When we use their ONNX as a silver labeler OR ingest manako API predictions, we
58
- # remap from alfred's order -> our target (manak0-baseline) order.
59
- KING_CLASS_ORDER = ["balaclava", "hoodie", "glove", "bat", "spray paint", "graffiti"]
60
- KING_TO_TARGET_REMAP = np.array(
61
- [TARGET_CLASS_NAMES.index(n) for n in KING_CLASS_ORDER], dtype=np.int32
62
- )
63
- # Sanity: KING_TO_TARGET_REMAP == [0, 4, 2, 1, 5, 3]
64
-
65
-
66
- # --------------------------------------------------------------------- utils
67
-
68
- def http_get(url: str, timeout: int = 60, retries: int = 4, headers: dict | None = None) -> bytes:
69
- last = None
70
- base_headers = {"User-Agent": "build_dataset/1.0"}
71
- if headers:
72
- base_headers.update(headers)
73
- for i in range(retries):
74
- try:
75
- req = Request(url, headers=base_headers)
76
- with urlopen(req, timeout=timeout) as r:
77
- return r.read()
78
- except Exception as e:
79
- last = e
80
- if i < retries - 1:
81
- time.sleep(1.5 * (i + 1))
82
- raise last # type: ignore
83
-
84
-
85
- def sha16(b: bytes) -> str:
86
- return hashlib.sha256(b).hexdigest()[:16]
87
-
88
-
89
- def image_size(buf: bytes) -> tuple[int, int]:
90
- if buf[:8] == b"\x89PNG\r\n\x1a\n":
91
- return int.from_bytes(buf[16:20], "big"), int.from_bytes(buf[20:24], "big")
92
- if buf[:2] == b"\xff\xd8":
93
- from PIL import Image
94
- return Image.open(io.BytesIO(buf)).size
95
- from PIL import Image
96
- return Image.open(io.BytesIO(buf)).size
97
-
98
-
99
- # --------------------------------------------------------------------- sources
100
-
101
- def fetch_manako(work: Path, log, polls: int = 1, delay_s: int = 180) -> list[tuple[Path, str, dict | None]]:
102
- """Pull rotating latestAnnotatedChallenge frames + king predictions."""
103
- out: list[tuple[Path, str, dict | None]] = []
104
- img_dir = work / "manako_images"
105
- img_dir.mkdir(parents=True, exist_ok=True)
106
- seen_paths: set[str] = set()
107
-
108
- for attempt in range(max(1, polls)):
109
- try:
110
- raw = http_get(f"{CONSOLE_API}/elements/{quote(ELEMENT_ID, safe='')}?lookback_days=30")
111
- except Exception as e:
112
- log(f"[manako] API call failed (attempt {attempt + 1}/{polls}): {e}")
113
- if attempt < polls - 1:
114
- time.sleep(delay_s)
115
- continue
116
- elt = json.loads(raw)
117
- lac = elt.get("latestAnnotatedChallenge")
118
- if not lac:
119
- log(f"[manako] no latestAnnotatedChallenge (attempt {attempt + 1}/{polls})")
120
- if attempt < polls - 1:
121
- time.sleep(delay_s)
122
- continue
123
-
124
- frames = (lac.get("response") or {}).get("frames") or []
125
- preds = ((lac.get("response") or {}).get("predictions") or {}).get("frames") or []
126
- preds_by_id = {p.get("frame_id"): p for p in preds}
127
-
128
- new_count = 0
129
- for f in frames:
130
- url = f.get("url")
131
- fid = f.get("frame_id")
132
- if not url:
133
- continue
134
- try:
135
- data = http_get(url)
136
- except Exception as e:
137
- log(f"[manako] image fetch failed {url}: {e}")
138
- continue
139
- digest = sha16(data)
140
- path = img_dir / f"manako_ch{lac.get('challenge_id')}_f{fid}_{digest}.png"
141
- if str(path) in seen_paths:
142
- continue
143
- seen_paths.add(str(path))
144
- path.write_bytes(data)
145
- out.append((path, "manako", preds_by_id.get(fid)))
146
- new_count += 1
147
-
148
- log(f"[manako] poll {attempt + 1}/{polls}: +{new_count} (total {len(out)}, "
149
- f"challenge_id={lac.get('challenge_id')})")
150
- if attempt < polls - 1:
151
- time.sleep(delay_s)
152
-
153
- log(f"[manako] pulled {len(out)} unique image(s) across {polls} poll(s)")
154
- return out
155
-
156
-
157
- def fetch_roboflow(slug: str, target_class: str, work: Path, log,
158
- api_key: str | None = None) -> list[tuple[Path, str, list[str]]]:
159
- """Download a Roboflow Universe dataset in YOLO format and remap to target class id.
160
-
161
- `slug` is the workspace/project[/version] path on universe.roboflow.com.
162
- Without API key, only public/free datasets work; many crime-relevant ones are public.
163
- """
164
- target_id = CLASS_TO_ID.get(target_class)
165
- if target_id is None:
166
- log(f"[roboflow] unknown target class: {target_class}")
167
- return []
168
-
169
- out_dir = work / "roboflow" / slug.replace("/", "_")
170
- out_dir.mkdir(parents=True, exist_ok=True)
171
- out: list[tuple[Path, str, list[str]]] = []
172
-
173
- try:
174
- from roboflow import Roboflow # type: ignore
175
- except ImportError:
176
- log("[roboflow] roboflow SDK not installed; pip install roboflow")
177
- return []
178
-
179
- if api_key is None:
180
- api_key = os.environ.get("ROBOFLOW_API_KEY", "")
181
- if not api_key:
182
- log(f"[roboflow] no ROBOFLOW_API_KEY set; skipping {slug}")
183
- return []
184
-
185
- parts = slug.split("/")
186
- if len(parts) < 2:
187
- log(f"[roboflow] malformed slug: {slug}")
188
- return []
189
- ws, project = parts[0], parts[1]
190
- version = int(parts[2]) if len(parts) >= 3 and parts[2].isdigit() else 1
191
-
192
- rf = Roboflow(api_key=api_key)
193
- ds = rf.workspace(ws).project(project).version(version).download("yolov8", location=str(out_dir))
194
- log(f"[roboflow] downloaded {slug} -> {ds.location}")
195
-
196
- # Roboflow YOLO export: {ds.location}/{train,valid,test}/images/*, labels/*.txt with their own data.yaml
197
- rf_yaml = Path(ds.location) / "data.yaml"
198
- if not rf_yaml.exists():
199
- log(f"[roboflow] no data.yaml in {ds.location}; skipping")
200
- return []
201
-
202
- # Parse names from data.yaml (we accept either yaml or simple key:value).
203
- try:
204
- import yaml
205
- rf_cfg = yaml.safe_load(rf_yaml.read_text())
206
- except Exception as e:
207
- log(f"[roboflow] yaml parse failed for {rf_yaml}: {e}")
208
- return []
209
- rf_names = rf_cfg.get("names") or []
210
- if isinstance(rf_names, dict):
211
- rf_names = [rf_names[k] for k in sorted(rf_names.keys())]
212
-
213
- for split in ("train", "valid", "test"):
214
- img_dir = Path(ds.location) / split / "images"
215
- lbl_dir = Path(ds.location) / split / "labels"
216
- if not img_dir.exists() or not lbl_dir.exists():
217
- continue
218
- for img_path in img_dir.iterdir():
219
- if not img_path.is_file():
220
- continue
221
- lbl_path = lbl_dir / (img_path.stem + ".txt")
222
- if not lbl_path.exists():
223
- continue
224
- try:
225
- rows = lbl_path.read_text().strip().splitlines()
226
- except Exception:
227
- continue
228
- mapped: list[str] = []
229
- for r in rows:
230
- parts = r.split()
231
- if len(parts) != 5:
232
- continue
233
- src_cls = int(parts[0])
234
- # Single-class roboflow datasets: any positive box -> target class
235
- if len(rf_names) == 1 or src_cls >= len(rf_names):
236
- mapped.append(f"{target_id} {parts[1]} {parts[2]} {parts[3]} {parts[4]}")
237
- continue
238
- # Multi-class: keep only boxes whose name matches the target (loose match).
239
- src_name = str(rf_names[src_cls]).lower().replace("_", " ")
240
- if target_class.lower() in src_name or src_name in target_class.lower():
241
- mapped.append(f"{target_id} {parts[1]} {parts[2]} {parts[3]} {parts[4]}")
242
- if not mapped:
243
- continue
244
- out.append((img_path, f"roboflow_{slug.replace('/', '_')}", mapped))
245
-
246
- log(f"[roboflow] {slug}: kept {len(out)} labeled images")
247
- return out
248
-
249
-
250
- def fetch_coco_bat(coco_ann_json: Path, coco_img_dir: Path, log) -> list[tuple[Path, str, list[str]]]:
251
- """Extract baseball-bat images from a COCO 2017 annotations file."""
252
- target_id = CLASS_TO_ID["bat"]
253
- out: list[tuple[Path, str, list[str]]] = []
254
- if not coco_ann_json.exists() or not coco_img_dir.exists():
255
- log(f"[coco-bat] paths missing: {coco_ann_json}, {coco_img_dir}")
256
- return out
257
-
258
- log(f"[coco-bat] reading {coco_ann_json} (this is large; ~30s)...")
259
- with coco_ann_json.open() as f:
260
- coco = json.load(f)
261
-
262
- # COCO category id 39 = "baseball bat" in the 80-class set.
263
- bat_ids = {c["id"] for c in coco.get("categories", []) if c.get("name", "").lower() == "baseball bat"}
264
- if not bat_ids:
265
- log("[coco-bat] no 'baseball bat' category found; aborting")
266
- return out
267
-
268
- # Group annotations by image_id.
269
- images = {im["id"]: im for im in coco.get("images", [])}
270
- by_img: dict[int, list[dict]] = {}
271
- for ann in coco.get("annotations", []):
272
- if ann.get("category_id") in bat_ids:
273
- by_img.setdefault(ann["image_id"], []).append(ann)
274
-
275
- for img_id, anns in by_img.items():
276
- info = images.get(img_id)
277
- if not info:
278
- continue
279
- fname = info["file_name"]
280
- w, h = info["width"], info["height"]
281
- img_path = coco_img_dir / fname
282
- if not img_path.exists():
283
- continue
284
- rows = []
285
- for a in anns:
286
- x, y, bw, bh = a["bbox"] # COCO xywh top-left
287
- cx = (x + bw / 2.0) / w
288
- cy = (y + bh / 2.0) / h
289
- rows.append(f"{target_id} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
290
- if rows:
291
- out.append((img_path, "coco_bat", rows))
292
- log(f"[coco-bat] kept {len(out)} bat-positive images")
293
- return out
294
-
295
-
296
- def fetch_hf_parquet_coco(repo_id: str, filename: str, target_class: str,
297
- work: Path, log,
298
- limit: int = 0) -> list[tuple[bytes, str, list[str]]]:
299
- """Pull a HuggingFace `datasets`-style parquet whose rows have
300
- `image: {bytes}`, `width`, `height`, `objects: [{bbox: [x,y,w,h], ...}]`
301
- (graffiti dataset format). Maps every box to TARGET_CLASS_NAMES[target_class].
302
-
303
- Returns list of (image_bytes, source_tag, yolo_label_rows).
304
- """
305
- target_id = CLASS_TO_ID.get(target_class)
306
- if target_id is None:
307
- log(f"[hf-parquet-coco] unknown target class: {target_class}")
308
- return []
309
- try:
310
- from huggingface_hub import hf_hub_download
311
- import pyarrow.parquet as pq
312
- except ImportError:
313
- log("[hf-parquet-coco] missing deps; pip install huggingface_hub pyarrow")
314
- return []
315
- try:
316
- local = hf_hub_download(
317
- repo_id=repo_id, filename=filename,
318
- repo_type="dataset", cache_dir=str(work / "_hf_cache"),
319
- )
320
- except Exception as e:
321
- log(f"[hf-parquet-coco] download failed {repo_id}/{filename}: {e}")
322
- return []
323
- log(f"[hf-parquet-coco] downloaded {repo_id}/{filename}")
324
- out: list[tuple[bytes, str, list[str]]] = []
325
- table = pq.read_table(local, columns=["image", "width", "height", "objects"])
326
- rows = table.to_pylist()
327
- for row in rows:
328
- img_dict = row.get("image") or {}
329
- data = img_dict.get("bytes") if isinstance(img_dict, dict) else None
330
- if not data:
331
- continue
332
- w = int(row.get("width") or 0)
333
- h = int(row.get("height") or 0)
334
- if w <= 0 or h <= 0:
335
- try:
336
- w, h = image_size(data)
337
- except Exception:
338
- continue
339
- labels: list[str] = []
340
- for obj in row.get("objects") or []:
341
- bbox = obj.get("bbox") or []
342
- if len(bbox) != 4:
343
- continue
344
- bx, by, bw, bh = (float(bbox[0]), float(bbox[1]), float(bbox[2]), float(bbox[3]))
345
- if bw <= 1 or bh <= 1:
346
- continue
347
- cx = (bx + bw / 2.0) / w
348
- cy = (by + bh / 2.0) / h
349
- labels.append(f"{target_id} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
350
- if labels:
351
- out.append((data, f"hf_{repo_id.replace('/', '_')}", labels))
352
- if limit and len(out) >= limit:
353
- break
354
- log(f"[hf-parquet-coco] {repo_id}: kept {len(out)} labeled images")
355
- return out
356
-
357
-
358
- def fetch_hf_parquet_cls(repo_id: str, filename: str, target_class: str,
359
- work: Path, log,
360
- limit: int = 0) -> list[tuple[bytes, str, None]]:
361
- """Pull a HuggingFace classification-synset parquet (rows have just
362
- `image: {bytes}` with optional `label`). Boxes will be filled in later by
363
- the king's ONNX silver labeler — this fn just extracts the image bytes.
364
-
365
- `target_class` is purely a tag for downstream filtering; the king labels
366
- every detection regardless.
367
- """
368
- try:
369
- from huggingface_hub import hf_hub_download
370
- import pyarrow.parquet as pq
371
- except ImportError:
372
- log("[hf-parquet-cls] missing deps; pip install huggingface_hub pyarrow")
373
- return []
374
- try:
375
- local = hf_hub_download(
376
- repo_id=repo_id, filename=filename,
377
- repo_type="dataset", cache_dir=str(work / "_hf_cache"),
378
- )
379
- except Exception as e:
380
- log(f"[hf-parquet-cls] download failed {repo_id}/{filename}: {e}")
381
- return []
382
- log(f"[hf-parquet-cls] downloaded {repo_id}/{filename}")
383
- out: list[tuple[bytes, str, None]] = []
384
- table = pq.read_table(local, columns=["image"])
385
- for row in table.to_pylist():
386
- img_dict = row.get("image") or {}
387
- data = img_dict.get("bytes") if isinstance(img_dict, dict) else None
388
- if not data:
389
- continue
390
- out.append((data, f"hf_{repo_id.replace('/', '_')}_{target_class}", None))
391
- if limit and len(out) >= limit:
392
- break
393
- log(f"[hf-parquet-cls] {repo_id}: extracted {len(out)} images (king will label)")
394
- return out
395
-
396
-
397
- def fetch_extra(extra_dir: Path | None, log) -> list[tuple[Path, str, None]]:
398
- if extra_dir is None or not extra_dir.exists():
399
- return []
400
- out: list[tuple[Path, str, None]] = []
401
- for ext in ("*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"):
402
- for p in extra_dir.rglob(ext):
403
- out.append((p, "extra", None))
404
- log(f"[extra] picked up {len(out)} images from {extra_dir}")
405
- return out
406
-
407
-
408
- # --------------------------------------------------------------------- king labeler
409
-
410
- class KingLabeler:
411
- """Run the king's ONNX over an unlabeled image to produce YOLO-format silver labels.
412
-
413
- Mirrors alfred8995/crime001's preprocessing: letterbox to native input H/W, RGB / 255,
414
- NCHW float32. Decodes both [N,6] / [1,N,6] (NMS-baked) and raw [1, C, N] formats with
415
- proper letterbox-reverse back to original image coords.
416
- """
417
-
418
- def __init__(self, onnx_path: Path, conf_floor: float = 0.15, intra_threads: int = 0) -> None:
419
- import onnxruntime as ort
420
- sess_options = ort.SessionOptions()
421
- sess_options.graph_optimization_level = ort.GraphOptimizationLevel.ORT_ENABLE_ALL
422
- if intra_threads > 0:
423
- sess_options.intra_op_num_threads = int(intra_threads)
424
- try:
425
- self.session = ort.InferenceSession(
426
- str(onnx_path), sess_options=sess_options,
427
- providers=["CUDAExecutionProvider", "CPUExecutionProvider"],
428
- )
429
- except Exception:
430
- self.session = ort.InferenceSession(
431
- str(onnx_path), sess_options=sess_options,
432
- providers=["CPUExecutionProvider"],
433
- )
434
- self.input_name = self.session.get_inputs()[0].name
435
- self.output_names = [o.name for o in self.session.get_outputs()]
436
- in_shape = self.session.get_inputs()[0].shape
437
- self.in_h = int(in_shape[2]) if isinstance(in_shape[2], int) and in_shape[2] > 0 else 640
438
- self.in_w = int(in_shape[3]) if isinstance(in_shape[3], int) and in_shape[3] > 0 else 640
439
- self.conf_floor = float(conf_floor)
440
- self.iou_thresh = 0.45
441
-
442
- @staticmethod
443
- def _letterbox(image: np.ndarray, new_size: tuple[int, int],
444
- color: tuple[int, int, int] = (114, 114, 114)
445
- ) -> tuple[np.ndarray, float, tuple[float, float]]:
446
- import cv2
447
- h, w = image.shape[:2]
448
- new_w, new_h = new_size
449
- ratio = min(new_w / w, new_h / h)
450
- rw, rh = int(round(w * ratio)), int(round(h * ratio))
451
- if (rw, rh) != (w, h):
452
- interp = cv2.INTER_CUBIC if ratio > 1.0 else cv2.INTER_LINEAR
453
- image = cv2.resize(image, (rw, rh), interpolation=interp)
454
- dw = (new_w - rw) / 2.0
455
- dh = (new_h - rh) / 2.0
456
- top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1))
457
- left, right = int(round(dw - 0.1)), int(round(dw + 0.1))
458
- padded = cv2.copyMakeBorder(image, top, bottom, left, right,
459
- borderType=cv2.BORDER_CONSTANT, value=color)
460
- return padded, ratio, (dw, dh)
461
-
462
- def label(self, image_bytes: bytes) -> list[str]:
463
- import cv2
464
- arr = np.frombuffer(image_bytes, dtype=np.uint8)
465
- bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
466
- if bgr is None:
467
- return []
468
- orig_h, orig_w = bgr.shape[:2]
469
-
470
- padded, ratio, (dw, dh) = self._letterbox(bgr, (self.in_w, self.in_h))
471
- rgb = cv2.cvtColor(padded, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
472
- x = np.transpose(rgb, (2, 0, 1))[None, ...]
473
- x = np.ascontiguousarray(x, dtype=np.float32)
474
-
475
- out = self.session.run(self.output_names, {self.input_name: x})[0]
476
- return self._decode(out, ratio, dw, dh, orig_w, orig_h)
477
-
478
- def _decode(self, output: np.ndarray, ratio: float, dw: float, dh: float,
479
- orig_w: int, orig_h: int) -> list[str]:
480
- """Decode + reverse-letterbox to original image coords."""
481
- # NMS-baked: [N, 6] or [1, N, 6]
482
- if output.ndim == 3 and output.shape[0] == 1 and output.shape[2] == 6:
483
- preds = output[0]
484
- elif output.ndim == 2 and output.shape[1] == 6:
485
- preds = output
486
- else:
487
- return self._decode_raw(output, ratio, dw, dh, orig_w, orig_h)
488
-
489
- boxes = preds[:, :4].astype(np.float32, copy=True)
490
- scores = preds[:, 4].astype(np.float32)
491
- cls = preds[:, 5].astype(np.int32)
492
-
493
- keep = (scores >= self.conf_floor) & (boxes[:, 2] > boxes[:, 0]) & (boxes[:, 3] > boxes[:, 1])
494
- boxes, scores, cls = boxes[keep], scores[keep], cls[keep]
495
- if len(boxes) == 0:
496
- return []
497
-
498
- # reverse letterbox (input-letterboxed coords -> orig image coords)
499
- boxes[:, [0, 2]] -= dw
500
- boxes[:, [1, 3]] -= dh
501
- boxes /= ratio
502
- boxes[:, 0] = np.clip(boxes[:, 0], 0, orig_w - 1)
503
- boxes[:, 1] = np.clip(boxes[:, 1], 0, orig_h - 1)
504
- boxes[:, 2] = np.clip(boxes[:, 2], 0, orig_w - 1)
505
- boxes[:, 3] = np.clip(boxes[:, 3], 0, orig_h - 1)
506
-
507
- return self._to_yolo_rows(boxes, cls, orig_w, orig_h)
508
-
509
- def _decode_raw(self, output: np.ndarray, ratio: float, dw: float, dh: float,
510
- orig_w: int, orig_h: int) -> list[str]:
511
- """Fallback for raw [1, C, N] / [1, N, C] YOLO output (e.g. manak0 baseline)."""
512
- if output.ndim != 3 or output.shape[0] != 1:
513
- return []
514
- p = output[0]
515
- if p.shape[0] <= 16 and p.shape[1] > p.shape[0]:
516
- p = p.T
517
- if p.shape[1] < 5:
518
- return []
519
- boxes_xywh = p[:, :4].astype(np.float32)
520
- cls_part = p[:, 4:].astype(np.float32)
521
- cls = np.argmax(cls_part, axis=1).astype(np.int32)
522
- scores = cls_part[np.arange(len(cls_part)), cls]
523
- keep = scores >= self.conf_floor
524
- if not np.any(keep):
525
- return []
526
- boxes_xywh = boxes_xywh[keep]
527
- scores = scores[keep]
528
- cls = cls[keep]
529
- # xywh (input-letterboxed coords) -> xyxy (input coords)
530
- xyxy = np.empty((len(boxes_xywh), 4), dtype=np.float32)
531
- xyxy[:, 0] = boxes_xywh[:, 0] - boxes_xywh[:, 2] / 2.0
532
- xyxy[:, 1] = boxes_xywh[:, 1] - boxes_xywh[:, 3] / 2.0
533
- xyxy[:, 2] = boxes_xywh[:, 0] + boxes_xywh[:, 2] / 2.0
534
- xyxy[:, 3] = boxes_xywh[:, 1] + boxes_xywh[:, 3] / 2.0
535
- xyxy, scores, cls = self._nms(xyxy, scores, cls, self.iou_thresh)
536
- if len(xyxy) == 0:
537
- return []
538
- # reverse letterbox
539
- xyxy[:, [0, 2]] -= dw
540
- xyxy[:, [1, 3]] -= dh
541
- xyxy /= ratio
542
- xyxy[:, 0] = np.clip(xyxy[:, 0], 0, orig_w - 1)
543
- xyxy[:, 1] = np.clip(xyxy[:, 1], 0, orig_h - 1)
544
- xyxy[:, 2] = np.clip(xyxy[:, 2], 0, orig_w - 1)
545
- xyxy[:, 3] = np.clip(xyxy[:, 3], 0, orig_h - 1)
546
- return self._to_yolo_rows(xyxy, cls, orig_w, orig_h)
547
-
548
- @staticmethod
549
- def _nms(boxes: np.ndarray, scores: np.ndarray, cls: np.ndarray,
550
- iou_thresh: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
551
- if len(boxes) == 0:
552
- return boxes, scores, cls
553
- order = np.argsort(-scores)
554
- boxes = boxes[order]; scores = scores[order]; cls = cls[order]
555
- keep = []
556
- suppressed = np.zeros(len(boxes), dtype=bool)
557
- for i in range(len(boxes)):
558
- if suppressed[i]:
559
- continue
560
- keep.append(i)
561
- xa = np.maximum(boxes[i, 0], boxes[i + 1:, 0])
562
- ya = np.maximum(boxes[i, 1], boxes[i + 1:, 1])
563
- xb = np.minimum(boxes[i, 2], boxes[i + 1:, 2])
564
- yb = np.minimum(boxes[i, 3], boxes[i + 1:, 3])
565
- inter = np.maximum(0.0, xb - xa) * np.maximum(0.0, yb - ya)
566
- area_i = (boxes[i, 2] - boxes[i, 0]) * (boxes[i, 3] - boxes[i, 1])
567
- area_r = (boxes[i + 1:, 2] - boxes[i + 1:, 0]) * (boxes[i + 1:, 3] - boxes[i + 1:, 1])
568
- ious = inter / (area_i + area_r - inter + 1e-7)
569
- for k, ok in enumerate(ious >= iou_thresh):
570
- if ok:
571
- suppressed[i + 1 + k] = True
572
- return boxes[keep], scores[keep], cls[keep]
573
-
574
- @staticmethod
575
- def _to_yolo_rows(boxes: np.ndarray, cls: np.ndarray, w: int, h: int) -> list[str]:
576
- # Remap king's class order -> our target order before emitting.
577
- rows = []
578
- for (x1, y1, x2, y2), c in zip(boxes, cls):
579
- if c < 0 or c >= len(KING_TO_TARGET_REMAP):
580
- continue
581
- tgt = int(KING_TO_TARGET_REMAP[int(c)])
582
- bw = float(x2 - x1)
583
- bh = float(y2 - y1)
584
- if bw <= 1 or bh <= 1:
585
- continue
586
- cx = (float(x1) + float(x2)) / 2.0 / w
587
- cy = (float(y1) + float(y2)) / 2.0 / h
588
- rows.append(f"{tgt} {cx:.6f} {cy:.6f} {bw / w:.6f} {bh / h:.6f}")
589
- return rows
590
-
591
-
592
- def king_preds_to_yolo(preds: dict, img_w: int, img_h: int) -> list[str]:
593
- """Manako preds use absolute pixel xyxy + cls_id (in alfred8995's class order).
594
- Remap to our target order before emitting YOLO rows.
595
- """
596
- rows: list[str] = []
597
- for box in preds.get("boxes") or []:
598
- try:
599
- x1, y1, x2, y2 = float(box["x1"]), float(box["y1"]), float(box["x2"]), float(box["y2"])
600
- c = int(box["cls_id"])
601
- except Exception:
602
- continue
603
- if c < 0 or c >= len(KING_TO_TARGET_REMAP):
604
- continue
605
- tgt = int(KING_TO_TARGET_REMAP[c])
606
- bw = x2 - x1
607
- bh = y2 - y1
608
- if bw <= 1 or bh <= 1:
609
- continue
610
- cx = (x1 + x2) / 2.0 / img_w
611
- cy = (y1 + y2) / 2.0 / img_h
612
- rows.append(f"{tgt} {cx:.6f} {cy:.6f} {bw / img_w:.6f} {bh / img_h:.6f}")
613
- return rows
614
-
615
-
616
- # --------------------------------------------------------------------- writer
617
-
618
- def write_yolo_pair(out_root: Path, split: str, name: str, image_bytes: bytes,
619
- label_lines: list[str], ext: str) -> None:
620
- img_dir = out_root / "images" / split
621
- lbl_dir = out_root / "labels" / split
622
- img_dir.mkdir(parents=True, exist_ok=True)
623
- lbl_dir.mkdir(parents=True, exist_ok=True)
624
- (img_dir / f"{name}.{ext}").write_bytes(image_bytes)
625
- (lbl_dir / f"{name}.txt").write_text("\n".join(label_lines), encoding="utf-8")
626
-
627
-
628
- def write_data_yaml(out_root: Path) -> Path:
629
- data_yaml = out_root / "data.yaml"
630
- data_yaml.write_text(
631
- "# YOLO dataset for manak0/Detect-crime\n"
632
- f"path: {out_root.resolve()}\n"
633
- "train: images/train\n"
634
- "val: images/val\n"
635
- "names:\n"
636
- + "".join(f" {i}: {n}\n" for i, n in enumerate(TARGET_CLASS_NAMES)),
637
- encoding="utf-8",
638
- )
639
- return data_yaml
640
-
641
-
642
- # --------------------------------------------------------------------- main
643
-
644
- def main() -> int:
645
- ap = argparse.ArgumentParser()
646
- ap.add_argument("--out", required=True, help="dataset output root (YOLO format)")
647
- ap.add_argument("--king-onnx", required=True,
648
- help="path to king's ONNX (used as silver labeler for unlabeled frames)")
649
- ap.add_argument("--manako", action="store_true", help="poll manako latestAnnotatedChallenge")
650
- ap.add_argument("--manako-polls", type=int, default=1)
651
- ap.add_argument("--manako-poll-delay", type=int, default=180)
652
- ap.add_argument("--roboflow", action="append", default=[],
653
- help="repeatable: TARGETCLASS=workspace/project[/version]; "
654
- "e.g. balaclava=brainster/balaclava-detection-v3")
655
- ap.add_argument("--coco-bat", nargs=2, metavar=("ANN_JSON", "IMG_DIR"),
656
- help="paths to COCO instances_train2017.json and train2017/")
657
- ap.add_argument("--extra-dir", type=Path, default=None,
658
- help="optional dir of pre-collected images (auto-labeled by king)")
659
- ap.add_argument("--hf-parquet-coco", action="append", default=[],
660
- help="repeatable: TARGETCLASS=repo_id::filename (COCO-bbox parquet); "
661
- "e.g. graffiti=junaidfayazlone/GraffitiDetection::Train-00000-of-00001-graffiti.parquet")
662
- ap.add_argument("--hf-parquet-cls", action="append", default=[],
663
- help="repeatable: TARGETCLASS=repo_id::filename (classification parquet, "
664
- "king-labeled); e.g. balaclava=mlnomad/imnet1k_ski_mask::data/train-00000-of-00001.parquet")
665
- ap.add_argument("--hf-limit", type=int, default=0,
666
- help="cap rows pulled from each HF parquet (0 = all)")
667
- ap.add_argument("--min-conf", type=float, default=0.15,
668
- help="confidence floor for the silver labeler (lower = more labels, more noise)")
669
- ap.add_argument("--workdir", type=Path, default=Path("/tmp/crime_dataset_work"))
670
- ap.add_argument("--val-frac", type=float, default=0.10)
671
- ap.add_argument("--seed", type=int, default=42)
672
- ap.add_argument("--limit", type=int, default=0,
673
- help="cap images per source (0 = no cap)")
674
- ap.add_argument("--intra-threads", type=int, default=0)
675
- ap.add_argument("--keep-empty", action="store_true",
676
- help="keep images with no detected boxes (negative-bg supervision)")
677
- args = ap.parse_args()
678
-
679
- out_root = Path(args.out).resolve()
680
- work = args.workdir.resolve()
681
- work.mkdir(parents=True, exist_ok=True)
682
-
683
- def log(msg: str) -> None:
684
- print(msg, file=sys.stderr, flush=True)
685
-
686
- # Sources collected as (path_or_bytes, src_tag, labels_or_preds_or_None).
687
- # labels can be: list[str] YOLO rows (already mapped), dict (king preds), or None (needs labeling).
688
- sources: list[tuple[Path, str, list[str] | dict | None]] = []
689
-
690
- if args.manako:
691
- for img_path, src, preds in fetch_manako(work, log,
692
- polls=args.manako_polls,
693
- delay_s=args.manako_poll_delay):
694
- sources.append((img_path, src, preds))
695
-
696
- for spec in args.roboflow:
697
- if "=" not in spec:
698
- log(f"[roboflow] malformed --roboflow {spec}; expected TARGETCLASS=slug")
699
- continue
700
- target, slug = spec.split("=", 1)
701
- for img_path, src, labels in fetch_roboflow(slug.strip(), target.strip(), work, log):
702
- sources.append((img_path, src, labels))
703
-
704
- if args.coco_bat:
705
- coco_ann, coco_img = Path(args.coco_bat[0]), Path(args.coco_bat[1])
706
- for img_path, src, labels in fetch_coco_bat(coco_ann, coco_img, log):
707
- sources.append((img_path, src, labels))
708
-
709
- # HF parquet sources land as (bytes, src, labels). Persist to disk under work
710
- # so the rest of the pipeline (which expects file paths) can pick them up.
711
- hf_dir = work / "hf_images"
712
- hf_dir.mkdir(parents=True, exist_ok=True)
713
-
714
- for spec in args.hf_parquet_coco:
715
- if "=" not in spec or "::" not in spec:
716
- log(f"[hf-parquet-coco] malformed {spec}; expected TARGETCLASS=repo::filename")
717
- continue
718
- target, rest = spec.split("=", 1)
719
- repo_id, filename = rest.split("::", 1)
720
- for data, src, labels in fetch_hf_parquet_coco(repo_id.strip(), filename.strip(),
721
- target.strip(), work, log,
722
- limit=args.hf_limit):
723
- digest = sha16(data)
724
- ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
725
- p = hf_dir / f"{src}_{digest}.{ext}"
726
- if not p.exists():
727
- p.write_bytes(data)
728
- sources.append((p, src, labels))
729
-
730
- for spec in args.hf_parquet_cls:
731
- if "=" not in spec or "::" not in spec:
732
- log(f"[hf-parquet-cls] malformed {spec}; expected TARGETCLASS=repo::filename")
733
- continue
734
- target, rest = spec.split("=", 1)
735
- repo_id, filename = rest.split("::", 1)
736
- for data, src, _ in fetch_hf_parquet_cls(repo_id.strip(), filename.strip(),
737
- target.strip(), work, log,
738
- limit=args.hf_limit):
739
- digest = sha16(data)
740
- ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
741
- p = hf_dir / f"{src}_{digest}.{ext}"
742
- if not p.exists():
743
- p.write_bytes(data)
744
- sources.append((p, src, None))
745
-
746
- if args.extra_dir:
747
- for img_path, src, _ in fetch_extra(args.extra_dir, log):
748
- sources.append((img_path, src, None))
749
-
750
- if not sources:
751
- log("[!] no sources; pass at least --manako / --roboflow / --coco-bat / --extra-dir")
752
- return 1
753
-
754
- if args.limit:
755
- sources = sources[: args.limit * 8] # rough cap; we'll dedupe below
756
- log(f"[build] total candidate sources: {len(sources)}")
757
-
758
- # Auto-labeler for sources that come unlabeled.
759
- labeler = KingLabeler(Path(args.king_onnx), conf_floor=args.min_conf,
760
- intra_threads=args.intra_threads)
761
- log(f"[label] king onnx input={labeler.in_w}x{labeler.in_h} "
762
- f"providers={labeler.session.get_providers()}")
763
-
764
- samples: list[tuple[str, bytes, list[str], str]] = []
765
- seen_hashes: set[str] = set()
766
- n_labeled = 0
767
- for i, (path, src, payload) in enumerate(sources):
768
- try:
769
- data = path.read_bytes()
770
- except Exception as e:
771
- log(f"[skip] read failed {path}: {e}")
772
- continue
773
- digest = sha16(data)
774
- if digest in seen_hashes:
775
- continue
776
- seen_hashes.add(digest)
777
- ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
778
-
779
- labels: list[str]
780
- if isinstance(payload, list):
781
- labels = payload # already remapped YOLO rows
782
- elif isinstance(payload, dict):
783
- try:
784
- w, h = image_size(data)
785
- labels = king_preds_to_yolo(payload, w, h)
786
- except Exception as e:
787
- log(f"[label] manako preds parse failed {path}: {e}; falling back to ONNX")
788
- labels = labeler.label(data)
789
- else:
790
- try:
791
- labels = labeler.label(data)
792
- except Exception as e:
793
- log(f"[label] ONNX labeling failed {path}: {e}")
794
- continue
795
-
796
- if not labels and not args.keep_empty:
797
- continue
798
- if labels:
799
- n_labeled += 1
800
- name = f"{src}_{digest}"
801
- samples.append((name, data, labels, src))
802
- if (i + 1) % 200 == 0:
803
- log(f" labeled {i + 1}/{len(sources)} (kept {len(samples)})")
804
-
805
- log(f"[build] labeled {n_labeled}/{len(sources)} unique images (others were empty)")
806
-
807
- if not samples:
808
- log("[!] zero labeled samples; lower --min-conf and retry")
809
- return 1
810
-
811
- rng = random.Random(args.seed)
812
- manako_samples = [s for s in samples if s[3] == "manako"]
813
- other_samples = [s for s in samples if s[3] != "manako"]
814
- rng.shuffle(other_samples)
815
-
816
- n_val_target = max(1, int(round(len(samples) * args.val_frac)))
817
- val: list = list(manako_samples)
818
- if len(val) < n_val_target:
819
- val.extend(other_samples[: n_val_target - len(val)])
820
- train = other_samples[n_val_target - len(manako_samples):]
821
- else:
822
- train = other_samples
823
- log(f"[split] train={len(train)} val={len(val)} (manako-in-val={len(manako_samples)})")
824
-
825
- if out_root.exists():
826
- for sub in ("images/train", "images/val", "labels/train", "labels/val"):
827
- d = out_root / sub
828
- if d.exists():
829
- shutil.rmtree(d)
830
- out_root.mkdir(parents=True, exist_ok=True)
831
-
832
- for name, data, labels, _src in train:
833
- ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
834
- write_yolo_pair(out_root, "train", name, data, labels, ext)
835
- for name, data, labels, _src in val:
836
- ext = "jpg" if data[:2] == b"\xff\xd8" else "png"
837
- write_yolo_pair(out_root, "val", name, data, labels, ext)
838
-
839
- yaml_path = write_data_yaml(out_root)
840
- log(f"[done] wrote dataset to {out_root}")
841
- log(f" data.yaml: {yaml_path}")
842
- log(f" train images: {len(train)}, val images: {len(val)}")
843
-
844
- cls_train = [0] * len(TARGET_CLASS_NAMES)
845
- cls_val = [0] * len(TARGET_CLASS_NAMES)
846
- for _n, _b, labs, _s in train:
847
- for line in labs:
848
- cls_train[int(line.split()[0])] += 1
849
- for _n, _b, labs, _s in val:
850
- for line in labs:
851
- cls_val[int(line.split()[0])] += 1
852
- log("[stats] per-class instance counts")
853
- for i, n in enumerate(TARGET_CLASS_NAMES):
854
- log(f" {i} {n:13s} train={cls_train[i]:6d} val={cls_val[i]:6d}")
855
-
856
- return 0
857
-
858
-
859
- if __name__ == "__main__":
860
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/eval_mine_vs_king.py DELETED
@@ -1,204 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Val-split mAP: your exported ONNX vs current king ONNX (alfred8995/crime001).
4
-
5
- King ONNX uses semantic class ids in a different order than `scratch/data/data.yaml`.
6
- Remap king outputs to dataset indices before TP/FP matching:
7
-
8
- king id king name -> yaml id / name
9
- 0 balaclava -> 0 balaclava
10
- 1 hoodie -> 4 hoodie
11
- 2 glove -> 2 glove
12
- 3 wooden bat -> 1 bat
13
- 4 spray can -> 5 spray paint
14
- 5 graffiti -> 3 graffiti
15
-
16
- Usage:
17
- python3 eval_mine_vs_king.py --data /root/turbovision_crime/scratch/data/data.yaml \
18
- --mine /root/turbovision_crime/scratch/crime_miner/weights.onnx \
19
- --king /root/turbovision_crime/king_models/alfred8995_crime001/weights.onnx
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import argparse
25
- import csv
26
- from datetime import datetime, timezone
27
- from pathlib import Path
28
-
29
- import torch
30
- from ultralytics import YOLO
31
- from ultralytics.models.yolo.detect import DetectionValidator
32
-
33
- # king cls -> dataset cls (see module docstring)
34
- KING_TO_DATASET = torch.tensor([0, 4, 2, 1, 5, 3])
35
-
36
-
37
- class KingRemappedValidator(DetectionValidator):
38
- def postprocess(self, preds): # noqa: ANN001
39
- outs = super().postprocess(preds)
40
- for d in outs:
41
- if d["cls"].numel() == 0:
42
- continue
43
- ci = d["cls"].long().clamp(0, 5).view(-1)
44
- mapped = KING_TO_DATASET[ci.cpu()].to(device=d["cls"].device, dtype=d["cls"].dtype)
45
- d["cls"] = mapped
46
- return outs
47
-
48
- def init_metrics(self, model): # noqa: ANN001
49
- super().init_metrics(model)
50
- # Confusion-matrix / prints use canonical names from dataset
51
- names = {
52
- 0: "balaclava",
53
- 1: "bat",
54
- 2: "glove",
55
- 3: "graffiti",
56
- 4: "hoodie",
57
- 5: "spray paint",
58
- }
59
- self.names = names
60
- self.nc = len(names)
61
- self.metrics.names = names
62
- try:
63
- from ultralytics.utils.metrics import ConfusionMatrix
64
-
65
- self.confusion_matrix = ConfusionMatrix(names=names, save_matches=self.args.plots and self.args.visualize)
66
- except Exception:
67
- pass
68
-
69
-
70
- def run_val(weights: Path, data: Path, imgsz: int, batch: int, device: str, validator_cls: type):
71
- model = YOLO(str(weights))
72
- return model.val(
73
- data=str(data),
74
- imgsz=imgsz,
75
- batch=batch,
76
- device=device,
77
- plots=False,
78
- verbose=False,
79
- validator=validator_cls,
80
- )
81
-
82
-
83
- def maybe_log_ops_row(
84
- csv_path: Path,
85
- challenge_id: str,
86
- scene: str,
87
- mine: Path,
88
- king: Path,
89
- m_mine,
90
- m_king,
91
- ) -> None:
92
- fields = [
93
- "timestamp_utc",
94
- "challenge_id",
95
- "scene",
96
- "lighting",
97
- "camera_angle",
98
- "motion_blur",
99
- "crowd_level",
100
- "my_model_id",
101
- "my_score",
102
- "king_model_id",
103
- "king_score",
104
- "delta_score",
105
- "my_pred_count",
106
- "king_pred_count",
107
- "my_latency_p50_ms",
108
- "my_latency_p95_ms",
109
- "my_latency_p99_ms",
110
- "dropped_frames",
111
- "timeout_count",
112
- "balaclava_hits",
113
- "bat_hits",
114
- "glove_hits",
115
- "graffiti_hits",
116
- "hoodie_hits",
117
- "spray_paint_hits",
118
- "tag_list",
119
- "data_source_mix",
120
- "notes",
121
- ]
122
- row = {
123
- "timestamp_utc": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
124
- "challenge_id": challenge_id,
125
- "scene": scene,
126
- "lighting": "",
127
- "camera_angle": "",
128
- "motion_blur": "",
129
- "crowd_level": "",
130
- "my_model_id": mine.name,
131
- "my_score": f"{m_mine.box.map50:.6f}",
132
- "king_model_id": king.name,
133
- "king_score": f"{m_king.box.map50:.6f}",
134
- "delta_score": f"{(m_mine.box.map50 - m_king.box.map50):.6f}",
135
- "my_pred_count": "",
136
- "king_pred_count": "",
137
- "my_latency_p50_ms": f"{m_mine.speed.get('inference', 0.0):.3f}",
138
- "my_latency_p95_ms": "",
139
- "my_latency_p99_ms": "",
140
- "dropped_frames": "",
141
- "timeout_count": "",
142
- "balaclava_hits": "",
143
- "bat_hits": "",
144
- "glove_hits": "",
145
- "graffiti_hits": "",
146
- "hoodie_hits": "",
147
- "spray_paint_hits": "",
148
- "tag_list": "offline_val_split",
149
- "data_source_mix": "",
150
- "notes": "auto-log from eval_mine_vs_king.py; scores are mAP50 on local val split",
151
- }
152
- csv_path.parent.mkdir(parents=True, exist_ok=True)
153
- new_file = not csv_path.exists()
154
- with csv_path.open("a", newline="") as f:
155
- writer = csv.DictWriter(f, fieldnames=fields)
156
- if new_file:
157
- writer.writeheader()
158
- writer.writerow(row)
159
- print(f"[ops-log] appended row -> {csv_path}", flush=True)
160
-
161
-
162
- def main() -> int:
163
- ap = argparse.ArgumentParser()
164
- ap.add_argument("--data", required=True)
165
- ap.add_argument("--mine", required=True)
166
- ap.add_argument("--king", required=True)
167
- ap.add_argument("--imgsz", type=int, default=1280)
168
- ap.add_argument("--batch", type=int, default=8)
169
- ap.add_argument("--device", default="0")
170
- ap.add_argument("--log-csv", default="", help="optional path to detect_crime_ops challenge_log.csv")
171
- ap.add_argument("--challenge-id", default="", help="optional challenge/eval id for ops logging")
172
- ap.add_argument("--scene", default="offline_val_split", help="scene tag used when --log-csv is set")
173
- args = ap.parse_args()
174
-
175
- data = Path(args.data)
176
- mine = Path(args.mine)
177
- king = Path(args.king)
178
-
179
- print("=== Yours (exported miner ONNX) ===", flush=True)
180
- m_mine = run_val(mine, data, args.imgsz, args.batch, args.device, DetectionValidator)
181
- print(f"images={m_mine.speed.get('images', '?')} "
182
- f"mAP50={m_mine.box.map50:.5f} mAP50-95={m_mine.box.map:.5f} "
183
- f"P={m_mine.box.mp:.5f} R={m_mine.box.mr:.5f}", flush=True)
184
-
185
- print("\n=== King (alfred8995/crime001, cls remapped to data.yaml order) ===", flush=True)
186
- m_king = run_val(king, data, args.imgsz, args.batch, args.device, KingRemappedValidator)
187
- print(f"images={m_king.speed.get('images', '?')} "
188
- f"mAP50={m_king.box.map50:.5f} mAP50-95={m_king.box.map:.5f} "
189
- f"P={m_king.box.mp:.5f} R={m_king.box.mr:.5f}", flush=True)
190
-
191
- print("\n=== Delta (yours − king) on same val split ===", flush=True)
192
- print(f"d_mAP50 = {m_mine.box.map50 - m_king.box.map50:+.5f}", flush=True)
193
- print(f"d_mAP50-95 = {m_mine.box.map - m_king.box.map:+.5f}", flush=True)
194
- print("\nInterpretation:", flush=True)
195
- print("- Same boxes (IoU/geometric inference) regardless of remap; remap only aligns class IDs for AP.", flush=True)
196
- print("- This is YOUR silver-label val set, not the live Manako leaderboard.", flush=True)
197
- if args.log_csv:
198
- cid = args.challenge_id or f"offline_val_{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')}"
199
- maybe_log_ops_row(Path(args.log_csv), cid, args.scene, mine, king, m_mine, m_king)
200
- return 0
201
-
202
-
203
- if __name__ == "__main__":
204
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/export_onnx.py DELETED
@@ -1,65 +0,0 @@
1
- """
2
- Export a trained YOLOv11 .pt to ONNX with NMS baked in -> output shape [1, max_det, 6].
3
-
4
- The default chute miner.py decode path expects [1, N, 6] (NMS-baked). The king's own
5
- weights.onnx is raw-YOLO [1, 10, 8400] without NMS — our exported model does NOT match
6
- that, but our miner.py handles both formats. NMS-baked is preferred because:
7
- - chute startup is faster (less graph work),
8
- - the decode path is simpler (no fallback to raw YOLO numpy NMS),
9
- - p95 latency is more stable (no Python-NMS spike on busy frames).
10
-
11
- Usage:
12
- python export_onnx.py --weights runs/detect/crime_b/weights/best.pt --imgsz 1280 \
13
- --out ../weights.onnx
14
- """
15
-
16
- from __future__ import annotations
17
-
18
- import argparse
19
- import shutil
20
- from pathlib import Path
21
-
22
- from ultralytics import YOLO
23
-
24
-
25
- def main() -> int:
26
- ap = argparse.ArgumentParser()
27
- ap.add_argument("--weights", required=True)
28
- ap.add_argument("--imgsz", type=int, default=1280)
29
- ap.add_argument("--out", default=None,
30
- help="destination path for weights.onnx (defaults next to source .pt)")
31
- ap.add_argument("--max-det", type=int, default=300)
32
- ap.add_argument("--opset", type=int, default=17)
33
- ap.add_argument("--half", action="store_true",
34
- help="export FP16 (smaller, faster on CUDA, marginal accuracy delta)")
35
- args = ap.parse_args()
36
-
37
- weights = Path(args.weights).resolve()
38
- model = YOLO(str(weights))
39
-
40
- onnx_path = model.export(
41
- format="onnx",
42
- imgsz=args.imgsz,
43
- nms=True,
44
- max_det=args.max_det,
45
- opset=args.opset,
46
- dynamic=False,
47
- half=args.half,
48
- simplify=True,
49
- agnostic_nms=False, # class-aware NMS (must match miner.py expectations)
50
- device="cpu",
51
- )
52
-
53
- src = Path(onnx_path)
54
- if args.out:
55
- dst = Path(args.out).resolve()
56
- dst.parent.mkdir(parents=True, exist_ok=True)
57
- shutil.move(str(src), str(dst))
58
- print(f"[export] {src} -> {dst}")
59
- else:
60
- print(f"[export] {src}")
61
- return 0
62
-
63
-
64
- if __name__ == "__main__":
65
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/milestones_watch.py DELETED
@@ -1,104 +0,0 @@
1
- #!/usr/bin/env python3
2
- """Poll Ultralytics results.csv and append one summary line per N-epoch milestone."""
3
- from __future__ import annotations
4
-
5
- import argparse
6
- import csv
7
- import time
8
- from datetime import datetime, timezone
9
- from pathlib import Path
10
-
11
-
12
- def fmt_row(row: dict) -> str:
13
- ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
14
- ep = int(float(row["epoch"]))
15
- return (
16
- f"{ts} | epoch={ep} | "
17
- f"mAP50={float(row['metrics/mAP50(B)']):.4f} mAP={float(row['metrics/mAP50-95(B)']):.4f} | "
18
- f"P={float(row['metrics/precision(B)']):.4f} R={float(row['metrics/recall(B)']):.4f} | "
19
- f"train_box={float(row['train/box_loss']):.4f} train_cls={float(row['train/cls_loss']):.4f}"
20
- )
21
-
22
-
23
- def recover_done(out_path: Path) -> set[int]:
24
- done: set[int] = set()
25
- if not out_path.exists():
26
- return set()
27
- for line in out_path.read_text().splitlines():
28
- if "| epoch=" not in line:
29
- continue
30
- try:
31
- part = line.split("| epoch=")[1].split()[0]
32
- done.add(int(part))
33
- except (IndexError, ValueError):
34
- continue
35
- return done
36
-
37
-
38
- def main() -> int:
39
- ap = argparse.ArgumentParser()
40
- ap.add_argument("--csv", required=True, help="path to results.csv (created by Ultralytics)")
41
- ap.add_argument("--every", type=int, default=10)
42
- ap.add_argument("--max-epoch", type=int, default=200)
43
- ap.add_argument("--out", default=None, help="append logs here (default: <run-dir>/milestones_every_N.log)")
44
- ap.add_argument("--poll", type=float, default=15.0, help="seconds between reads")
45
- ap.add_argument("--idle-exit-sec", type=float, default=180.0,
46
- help="exit if results.csv is untouched this long and milestones caught up")
47
- args = ap.parse_args()
48
-
49
- csv_path = Path(args.csv)
50
- run_dir = csv_path.parent
51
- out_path = Path(args.out) if args.out else run_dir / f"milestones_every_{args.every}.log"
52
- out_path.parent.mkdir(parents=True, exist_ok=True)
53
- if not out_path.exists():
54
- ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
55
- out_path.write_text(
56
- f"{ts} | milestones_watch | status=started next_report=epoch_{args.every} csv={csv_path}\n"
57
- )
58
- print(f"[milestones_watch] created {out_path} (metric lines appear every {args.every} epochs)", flush=True)
59
- done = recover_done(out_path)
60
-
61
- while True:
62
- if not csv_path.exists():
63
- time.sleep(args.poll)
64
- continue
65
- try:
66
- with csv_path.open(newline="") as f:
67
- rows = list(csv.DictReader(f))
68
- except Exception:
69
- time.sleep(args.poll)
70
- continue
71
- if not rows:
72
- time.sleep(args.poll)
73
- continue
74
-
75
- last_ep = int(float(rows[-1]["epoch"]))
76
- for m in range(args.every, min(last_ep, args.max_epoch) + 1, args.every):
77
- if m in done:
78
- continue
79
- match = [r for r in rows if int(float(r["epoch"])) == m]
80
- if not match:
81
- continue
82
- line = fmt_row(match[0])
83
- out_path.parent.mkdir(parents=True, exist_ok=True)
84
- with out_path.open("a") as o:
85
- o.write(line + "\n")
86
- print(line, flush=True)
87
- done.add(m)
88
-
89
- mtime = csv_path.stat().st_mtime
90
- idle = time.time() - mtime
91
- max_reportable = (last_ep // args.every) * args.every
92
- need = list(range(args.every, max_reportable + 1, args.every))
93
- caught_up = all(m in done for m in need)
94
- if idle >= args.idle_exit_sec and caught_up and last_ep >= 1:
95
- print(f"milestones_watch: idle {idle:.0f}s and milestones caught up → exit", flush=True)
96
- break
97
-
98
- time.sleep(args.poll)
99
-
100
- return 0
101
-
102
-
103
- if __name__ == "__main__":
104
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/poll_manako.py DELETED
@@ -1,132 +0,0 @@
1
- """
2
- Continuous poller for in-domain frames from the manako/scoredata API for Detect-crime.
3
-
4
- The console.scorevision.io endpoint surfaces ONE `latestAnnotatedChallenge` per element.
5
- As the runner produces new challenges this single record rotates. By polling every couple
6
- of minutes we accumulate distinct in-domain frames + the king's predictions for each.
7
-
8
- Use this as a long-running collector alongside `build_dataset.py`. Run it for a few hours
9
- or days, then re-run `build_dataset.py --extra-dir <out>/images` (or `--manako`) to fold
10
- the new frames into the YOLO dataset.
11
-
12
- Usage:
13
- python poll_manako.py --out ./manako_pool --interval 120 --hours 8
14
- python poll_manako.py --out ./manako_pool --interval 120 --forever # until killed
15
-
16
- Output structure (each unique frame = one image + one preds JSON):
17
- manako_pool/
18
- images/manako_ch{cid}_f{fid}_{sha16}.png
19
- preds/ manako_ch{cid}_f{fid}_{sha16}.json
20
- """
21
-
22
- from __future__ import annotations
23
-
24
- import argparse
25
- import hashlib
26
- import json
27
- import sys
28
- import time
29
- from pathlib import Path
30
- from urllib.parse import quote
31
- from urllib.request import Request, urlopen
32
-
33
- API = "https://console.scorevision.io/api/v2"
34
- ELEMENT_ID = "manak0/Detect-crime"
35
-
36
-
37
- def http_get(url: str, timeout: int = 30, retries: int = 3) -> bytes:
38
- last = None
39
- for i in range(retries):
40
- try:
41
- req = Request(url, headers={"User-Agent": "poll_manako/1.0"})
42
- with urlopen(req, timeout=timeout) as r:
43
- return r.read()
44
- except Exception as e:
45
- last = e
46
- time.sleep(1.0 * (i + 1))
47
- raise last # type: ignore
48
-
49
-
50
- def sha16(b: bytes) -> str:
51
- return hashlib.sha256(b).hexdigest()[:16]
52
-
53
-
54
- def main() -> int:
55
- ap = argparse.ArgumentParser()
56
- ap.add_argument("--out", required=True, help="output dir for accumulated frames")
57
- ap.add_argument("--interval", type=int, default=120, help="seconds between polls")
58
- ap.add_argument("--hours", type=float, default=4.0, help="duration to run (ignored with --forever)")
59
- ap.add_argument("--forever", action="store_true", help="run until killed")
60
- args = ap.parse_args()
61
-
62
- out = Path(args.out).resolve()
63
- img_dir = out / "images"
64
- pred_dir = out / "preds"
65
- img_dir.mkdir(parents=True, exist_ok=True)
66
- pred_dir.mkdir(parents=True, exist_ok=True)
67
-
68
- deadline = time.time() + args.hours * 3600 if not args.forever else float("inf")
69
- seen: set[str] = {p.stem.rsplit("_", 1)[-1] for p in img_dir.glob("manako_*.png")}
70
- n_polls = 0
71
- n_new = 0
72
- print(f"[poll] starting; existing frames in pool: {len(seen)}", file=sys.stderr)
73
-
74
- while time.time() < deadline:
75
- n_polls += 1
76
- try:
77
- raw = http_get(f"{API}/elements/{quote(ELEMENT_ID, safe='')}?lookback_days=7")
78
- except Exception as e:
79
- print(f"[poll {n_polls}] API error: {e}", file=sys.stderr)
80
- time.sleep(args.interval)
81
- continue
82
-
83
- elt = json.loads(raw)
84
- lac = elt.get("latestAnnotatedChallenge")
85
- if not lac:
86
- print(f"[poll {n_polls}] no latestAnnotatedChallenge", file=sys.stderr)
87
- time.sleep(args.interval)
88
- continue
89
-
90
- challenge_id = lac.get("challenge_id")
91
- frames = (lac.get("response") or {}).get("frames") or []
92
- preds = ((lac.get("response") or {}).get("predictions") or {}).get("frames") or []
93
- preds_by_id = {p.get("frame_id"): p for p in preds}
94
-
95
- added = 0
96
- for f in frames:
97
- url = f.get("url")
98
- fid = f.get("frame_id")
99
- if not url:
100
- continue
101
- try:
102
- data = http_get(url)
103
- except Exception as e:
104
- print(f"[poll] image fetch failed {url}: {e}", file=sys.stderr)
105
- continue
106
- digest = sha16(data)
107
- if digest in seen:
108
- continue
109
- seen.add(digest)
110
- (img_dir / f"manako_ch{challenge_id}_f{fid}_{digest}.png").write_bytes(data)
111
- preds_obj = preds_by_id.get(fid)
112
- if preds_obj is not None:
113
- (pred_dir / f"manako_ch{challenge_id}_f{fid}_{digest}.json").write_text(
114
- json.dumps(preds_obj), encoding="utf-8"
115
- )
116
- added += 1
117
- n_new += 1
118
-
119
- print(f"[poll {n_polls}] cid={challenge_id} frames={len(frames)} new={added} "
120
- f"total_unique={len(seen)} (cumulative new this run: {n_new})", file=sys.stderr)
121
-
122
- if time.time() >= deadline:
123
- break
124
- time.sleep(args.interval)
125
-
126
- print(f"[poll] done. polls={n_polls}, new frames added this run: {n_new}, "
127
- f"pool size: {len(seen)}", file=sys.stderr)
128
- return 0
129
-
130
-
131
- if __name__ == "__main__":
132
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/requirements.txt DELETED
@@ -1,12 +0,0 @@
1
- ultralytics>=8.3
2
- torch>=2.1
3
- torchvision>=0.16
4
- onnx>=1.16
5
- onnxruntime-gpu>=1.18
6
- opencv-python>=4.7
7
- numpy>=1.23
8
- pillow>=9.5
9
- pyyaml>=6.0
10
- huggingface_hub>=0.24
11
- roboflow>=1.1
12
- pycocotools>=2.0
 
 
 
 
 
 
 
 
 
 
 
 
 
training/train.py DELETED
@@ -1,140 +0,0 @@
1
- """
2
- Two-stage training recipe for Detect-crime.
3
-
4
- Stage A (silver pretrain): warm a YOLOv11s/m on the full silver corpus
5
- (Roboflow + COCO bat + king-distilled manako frames).
6
- Stage B (clean fine-tune): short fine-tune on a manually-verified set of manako frames.
7
-
8
- Defaults are tuned for a single GPU with >=24 GB VRAM. Pro_6000 should run YOLOv11s at
9
- imgsz=1536 with batch 12 comfortably; YOLOv11m at imgsz=1280 needs batch ~8.
10
-
11
- Usage:
12
- # Stage A
13
- python train.py --data ../data/data.yaml --weights yolo11s.pt --imgsz 1280 --stage A --epochs 200
14
-
15
- # Stage B (clean fine-tune)
16
- python train.py --data ../data_clean/data.yaml \
17
- --weights runs/detect/crime_a/weights/best.pt \
18
- --imgsz 1280 --stage B --epochs 50
19
-
20
- Notes:
21
- - Heavy mosaic+copy_paste is essential for crime — class imbalance is brutal (hoodie
22
- dominates GT count; balaclava/glove/spray-paint are rare). Copy-paste pastes rare-class
23
- crops into otherwise-normal frames; this is the most reliable fix for low recall on the
24
- rare classes.
25
- - HSV jitter cranked on V channel: CCTV crime footage spans daylight, IR night, and
26
- mixed indoor lighting. Augment hard.
27
- - `cls=1.0` (raised from default 0.5) because mAP@50 is per-class-averaged; pushing the
28
- classification head matters more than localization here.
29
- - Always save with ema=True so export uses smoothed weights.
30
- """
31
-
32
- from __future__ import annotations
33
-
34
- import argparse
35
-
36
- from ultralytics import YOLO
37
-
38
-
39
- COMMON_AUG = dict(
40
- hsv_h=0.02,
41
- hsv_s=0.7,
42
- hsv_v=0.5, # CCTV brightness varies massively (IR night, daylight, mixed indoor)
43
- degrees=0.0, # CCTV cameras are mounted level
44
- translate=0.10,
45
- scale=0.5,
46
- shear=0.0,
47
- perspective=0.0,
48
- flipud=0.0,
49
- fliplr=0.5,
50
- mosaic=1.0,
51
- mixup=0.20,
52
- copy_paste=0.50, # high: rebalance toward rare classes (balaclava, glove, spray paint)
53
- erasing=0.0,
54
- )
55
-
56
- STAGE_A = dict(
57
- epochs=200,
58
- optimizer="AdamW",
59
- lr0=2e-3,
60
- lrf=0.01,
61
- momentum=0.937,
62
- weight_decay=5e-4,
63
- warmup_epochs=3,
64
- cos_lr=True,
65
- label_smoothing=0.05,
66
- cls=1.0, # higher cls weight: per-class AP matters
67
- box=7.5,
68
- dfl=1.5,
69
- close_mosaic=15,
70
- patience=40,
71
- amp=True,
72
- **COMMON_AUG,
73
- )
74
-
75
- STAGE_B = dict(
76
- epochs=50,
77
- optimizer="AdamW",
78
- lr0=5e-4,
79
- lrf=0.01,
80
- momentum=0.9,
81
- weight_decay=5e-4,
82
- warmup_epochs=2,
83
- cos_lr=True,
84
- label_smoothing=0.05,
85
- cls=0.8,
86
- box=7.5,
87
- dfl=1.5,
88
- close_mosaic=10,
89
- patience=15,
90
- amp=True,
91
- **{**COMMON_AUG, "mosaic": 0.5, "mixup": 0.0, "copy_paste": 0.10},
92
- )
93
-
94
-
95
- def main() -> int:
96
- ap = argparse.ArgumentParser()
97
- ap.add_argument("--data", required=True, help="path to data.yaml (YOLO format)")
98
- ap.add_argument("--weights", default="yolo11s.pt",
99
- help="starting weights (yolo11n.pt / yolo11s.pt / yolo11m.pt / your stage-A best)")
100
- ap.add_argument("--imgsz", type=int, default=1280)
101
- ap.add_argument("--batch", type=int, default=16)
102
- ap.add_argument("--stage", choices=["A", "B"], default="A")
103
- ap.add_argument("--epochs", type=int, default=None)
104
- ap.add_argument("--device", default=0)
105
- ap.add_argument("--project", default="runs/detect")
106
- ap.add_argument("--name", default=None)
107
- ap.add_argument("--resume", action="store_true")
108
- args = ap.parse_args()
109
-
110
- cfg = STAGE_A.copy() if args.stage == "A" else STAGE_B.copy()
111
- if args.epochs is not None:
112
- cfg["epochs"] = args.epochs
113
-
114
- model = YOLO(args.weights)
115
- name = args.name or f"crime_{args.stage.lower()}"
116
-
117
- model.train(
118
- data=args.data,
119
- imgsz=args.imgsz,
120
- batch=args.batch,
121
- device=args.device,
122
- project=args.project,
123
- name=name,
124
- resume=args.resume,
125
- save=True,
126
- plots=True,
127
- seed=42,
128
- rect=False,
129
- single_cls=False,
130
- **cfg,
131
- )
132
-
133
- metrics = model.val(data=args.data, imgsz=args.imgsz, batch=args.batch,
134
- device=args.device, plots=True, save_json=True)
135
- print(f"[train] {args.stage} done. map50={metrics.box.map50:.4f} map={metrics.box.map:.4f}")
136
- return 0
137
-
138
-
139
- if __name__ == "__main__":
140
- raise SystemExit(main())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
training/verify_dataset.py DELETED
@@ -1,170 +0,0 @@
1
- """
2
- Quick QA over an assembled YOLO dataset for Detect-crime.
3
-
4
- Checks:
5
- - Every label file has a corresponding image and vice versa.
6
- - All class ids are within range (0..5).
7
- - All bbox coords are normalized (0 < x < 1, 0 < y < 1, 0 < w/h <= 1).
8
- - Reports per-class instance counts per split + total positive images per class.
9
- - Flags samples with degenerate (near-zero) boxes.
10
- - Optional: writes overlay PNGs for the first --visualize K val images.
11
-
12
- Usage:
13
- python verify_dataset.py --data ../data/data.yaml
14
- python verify_dataset.py --data ../data/data.yaml --visualize 20 --vis-out /tmp/crime_vis
15
- """
16
-
17
- from __future__ import annotations
18
-
19
- import argparse
20
- import sys
21
- from pathlib import Path
22
-
23
- import numpy as np
24
-
25
-
26
- TARGET_CLASS_NAMES = ["balaclava", "bat", "glove", "graffiti", "hoodie", "spray paint"]
27
-
28
-
29
- def parse_yaml(path: Path) -> dict:
30
- try:
31
- import yaml
32
- return yaml.safe_load(path.read_text())
33
- except Exception:
34
- cfg: dict = {}
35
- for line in path.read_text().splitlines():
36
- line = line.strip()
37
- if not line or line.startswith("#") or ":" not in line:
38
- continue
39
- k, v = line.split(":", 1)
40
- cfg[k.strip()] = v.strip()
41
- return cfg
42
-
43
-
44
- def check_split(root: Path, split: str, log) -> dict:
45
- img_dir = root / "images" / split
46
- lbl_dir = root / "labels" / split
47
- if not img_dir.exists():
48
- log(f"[{split}] images/{split} missing: {img_dir}")
49
- return {"images": 0}
50
- if not lbl_dir.exists():
51
- log(f"[{split}] labels/{split} missing: {lbl_dir}")
52
- return {"images": 0}
53
-
54
- image_names = {p.stem for p in img_dir.iterdir() if p.is_file()}
55
- label_names = {p.stem for p in lbl_dir.iterdir() if p.is_file() and p.suffix == ".txt"}
56
- only_img = image_names - label_names
57
- only_lbl = label_names - image_names
58
-
59
- n_total = len(image_names)
60
- cls_counts = [0] * len(TARGET_CLASS_NAMES)
61
- cls_image_counts = [set() for _ in range(len(TARGET_CLASS_NAMES))]
62
- bad_rows = 0
63
- degenerate = 0
64
- for lbl in lbl_dir.glob("*.txt"):
65
- rows = lbl.read_text().splitlines()
66
- for r in rows:
67
- parts = r.strip().split()
68
- if len(parts) != 5:
69
- bad_rows += 1
70
- continue
71
- try:
72
- c = int(parts[0])
73
- cx, cy, bw, bh = (float(parts[1]), float(parts[2]), float(parts[3]), float(parts[4]))
74
- except Exception:
75
- bad_rows += 1
76
- continue
77
- if not (0 <= c < len(TARGET_CLASS_NAMES)):
78
- bad_rows += 1
79
- continue
80
- if not (0 < cx < 1 and 0 < cy < 1 and 0 < bw <= 1 and 0 < bh <= 1):
81
- degenerate += 1
82
- continue
83
- cls_counts[c] += 1
84
- cls_image_counts[c].add(lbl.stem)
85
-
86
- return {
87
- "images": n_total,
88
- "only_img": only_img,
89
- "only_lbl": only_lbl,
90
- "cls_counts": cls_counts,
91
- "cls_image_counts": [len(s) for s in cls_image_counts],
92
- "bad_rows": bad_rows,
93
- "degenerate": degenerate,
94
- }
95
-
96
-
97
- def main() -> int:
98
- ap = argparse.ArgumentParser()
99
- ap.add_argument("--data", required=True)
100
- ap.add_argument("--visualize", type=int, default=0,
101
- help="render this many val images with boxes overlaid")
102
- ap.add_argument("--vis-out", default="/tmp/crime_vis", help="dir for overlay images")
103
- args = ap.parse_args()
104
-
105
- data_yaml = Path(args.data).resolve()
106
- cfg = parse_yaml(data_yaml)
107
- root = Path(cfg.get("path") or data_yaml.parent).resolve()
108
-
109
- def log(msg: str):
110
- print(msg, file=sys.stderr, flush=True)
111
-
112
- log(f"[verify] root={root}")
113
- overall_ok = True
114
- for split in ("train", "val"):
115
- res = check_split(root, split, log)
116
- if not res["images"]:
117
- continue
118
- log(f"[{split}] images: {res['images']}")
119
- if res["only_img"]:
120
- log(f"[{split}] {len(res['only_img'])} images without labels (sample: "
121
- f"{list(res['only_img'])[:3]})")
122
- if res["only_lbl"]:
123
- log(f"[{split}] {len(res['only_lbl'])} labels without images (sample: "
124
- f"{list(res['only_lbl'])[:3]})")
125
- overall_ok = False
126
- if res["bad_rows"]:
127
- log(f"[{split}] BAD ROWS: {res['bad_rows']}"); overall_ok = False
128
- if res["degenerate"]:
129
- log(f"[{split}] degenerate boxes (out-of-range): {res['degenerate']}")
130
- overall_ok = False
131
- log(f"[{split}] per-class instance counts:")
132
- for i, n in enumerate(TARGET_CLASS_NAMES):
133
- log(f" {i} {n:13s} instances={res['cls_counts'][i]:6d} "
134
- f"images={res['cls_image_counts'][i]:5d}")
135
-
136
- if args.visualize > 0:
137
- try:
138
- import cv2
139
- except ImportError:
140
- log("[viz] cv2 not installed; pip install opencv-python")
141
- return 0 if overall_ok else 1
142
- out = Path(args.vis_out).resolve()
143
- out.mkdir(parents=True, exist_ok=True)
144
- val_imgs = sorted((root / "images" / "val").iterdir())[: args.visualize]
145
- for img_path in val_imgs:
146
- lbl_path = root / "labels" / "val" / (img_path.stem + ".txt")
147
- img = cv2.imread(str(img_path))
148
- if img is None:
149
- continue
150
- h, w = img.shape[:2]
151
- if lbl_path.exists():
152
- for r in lbl_path.read_text().splitlines():
153
- p = r.split()
154
- if len(p) != 5:
155
- continue
156
- c = int(p[0])
157
- cx, cy, bw, bh = (float(p[1]) * w, float(p[2]) * h,
158
- float(p[3]) * w, float(p[4]) * h)
159
- x1, y1, x2, y2 = int(cx - bw / 2), int(cy - bh / 2), int(cx + bw / 2), int(cy + bh / 2)
160
- cv2.rectangle(img, (x1, y1), (x2, y2), (0, 255, 0), 2)
161
- cv2.putText(img, TARGET_CLASS_NAMES[c], (x1, max(0, y1 - 6)),
162
- cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1)
163
- cv2.imwrite(str(out / img_path.name), img)
164
- log(f"[viz] wrote {len(val_imgs)} overlays to {out}")
165
-
166
- return 0 if overall_ok else 1
167
-
168
-
169
- if __name__ == "__main__":
170
- raise SystemExit(main())