mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-14 22:02:09 +08:00
Robocop
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SPEED_VALUES = frozenset((15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Add trusted later-frame sign tracks to a classifier dataset.")
|
||||
parser.add_argument("--base", type=Path, required=True)
|
||||
parser.add_argument("--track-samples", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--train-ratio", type=float, default=0.85)
|
||||
parser.add_argument("--min-growth", type=float, default=1.10)
|
||||
parser.add_argument("--min-exact-confidence", type=float, default=0.80)
|
||||
parser.add_argument("--min-detector-confidence", type=float, default=0.30)
|
||||
parser.add_argument("--max-track-rank", type=int, default=3)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def split_for_key(key: str, train_ratio: float) -> str:
|
||||
fraction = int(hashlib.sha1(key.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
|
||||
return "train" if fraction < train_ratio else "val"
|
||||
|
||||
|
||||
def link_or_copy(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.exists():
|
||||
return
|
||||
try:
|
||||
os.link(source, destination)
|
||||
except OSError:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> int:
|
||||
removed = 0
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def trusted_track_row(row: dict[str, str], args: argparse.Namespace) -> bool:
|
||||
try:
|
||||
expected = int(row.get("expected_speed_limit_mph", ""))
|
||||
predicted = int(row.get("predicted_speed_limit_mph", "") or 0)
|
||||
read_confidence = float(row.get("read_confidence", "") or 0.0)
|
||||
detector_confidence = float(row.get("detector_confidence", "") or 0.0)
|
||||
growth = float(row.get("area_ratio_to_anchor", "") or 0.0)
|
||||
rank = int(row.get("rank", "") or 999)
|
||||
except ValueError:
|
||||
return False
|
||||
exact = predicted == expected and read_confidence >= args.min_exact_confidence
|
||||
detector_snap = detector_confidence >= args.min_detector_confidence
|
||||
return expected in SPEED_VALUES and growth >= args.min_growth and rank <= args.max_track_rank and (exact or detector_snap)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
base = args.base.expanduser().resolve()
|
||||
output = args.output.expanduser().resolve()
|
||||
counts: Counter[str] = Counter()
|
||||
validation_routes: set[str] = set()
|
||||
for split in ("train", "val"):
|
||||
for class_dir in (base / split).iterdir():
|
||||
if not class_dir.is_dir() or class_dir.name.startswith("._"):
|
||||
continue
|
||||
for source in class_dir.iterdir():
|
||||
if not source.is_file() or source.name.startswith("._") or source.suffix.lower() not in (".jpg", ".jpeg", ".png"):
|
||||
continue
|
||||
link_or_copy(source, output / split / class_dir.name / f"base_{source.name}")
|
||||
counts[f"base_{split}"] += 1
|
||||
|
||||
with args.track_samples.expanduser().resolve().open(encoding="utf-8", newline="") as input_file:
|
||||
for row in csv.DictReader(input_file):
|
||||
if not trusted_track_row(row, args):
|
||||
counts["track_rejected"] += 1
|
||||
continue
|
||||
source = Path(row.get("crop_path", "")).expanduser().resolve()
|
||||
if not source.is_file():
|
||||
counts["track_rejected"] += 1
|
||||
continue
|
||||
speed = int(row["expected_speed_limit_mph"])
|
||||
split = split_for_key(row.get("route") or row.get("track_key", ""), args.train_ratio)
|
||||
if split == "val" and row.get("route"):
|
||||
validation_routes.add(row["route"])
|
||||
name = f"track_{row.get('track_key', '')}_{row.get('rank', '')}{source.suffix.lower()}"
|
||||
link_or_copy(source, output / split / str(speed) / name)
|
||||
counts[f"track_{split}"] += 1
|
||||
counts[f"speed_{speed}"] += 1
|
||||
|
||||
counts["appledouble_removed"] = remove_appledouble_files(output)
|
||||
(output / "track_validation_routes.txt").write_text("\n".join(sorted(validation_routes)) + "\n", encoding="ascii")
|
||||
summary = {"base": str(base), "output": str(output), "counts": dict(sorted(counts.items()))}
|
||||
(output / "track_dataset_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="ascii")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,215 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from import_manual_review_queue import merged_review_rows, parse_speed # type: ignore
|
||||
else:
|
||||
from .import_manual_review_queue import merged_review_rows, parse_speed
|
||||
|
||||
|
||||
SPEED_VALUES = (15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75)
|
||||
SPEED_TO_CLASS = {speed: index for index, speed in enumerate(SPEED_VALUES)}
|
||||
POSITIVE_STATUSES = frozenset(("accepted", "corrected"))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Add reviewed comma sign tracks to a direct speed-class detector dataset.")
|
||||
parser.add_argument("--base", type=Path, required=True, help="Existing YOLO direct-value detector dataset.")
|
||||
parser.add_argument("--queue", type=Path, required=True, help="Source reviewed manual_review_queue.csv.")
|
||||
parser.add_argument("--labels", type=Path, help="Defaults to manual_review_labels.csv beside the queue.")
|
||||
parser.add_argument("--track-samples", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--train-ratio", type=float, default=0.85)
|
||||
parser.add_argument("--min-growth", type=float, default=1.10)
|
||||
parser.add_argument("--min-exact-confidence", type=float, default=0.80)
|
||||
parser.add_argument("--min-detector-confidence", type=float, default=0.30)
|
||||
parser.add_argument("--max-track-rank", type=int, default=3)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def parse_bbox(value: str) -> tuple[int, int, int, int] | None:
|
||||
try:
|
||||
values = tuple(int(round(float(part.strip()))) for part in value.split(","))
|
||||
except ValueError:
|
||||
return None
|
||||
if len(values) != 4:
|
||||
return None
|
||||
x1, y1, x2, y2 = values
|
||||
return values if x2 > x1 and y2 > y1 else None
|
||||
|
||||
|
||||
def split_for_key(key: str, train_ratio: float) -> str:
|
||||
fraction = int(hashlib.sha1(key.encode()).hexdigest()[:8], 16) / 0xFFFFFFFF
|
||||
return "train" if fraction < train_ratio else "val"
|
||||
|
||||
|
||||
def link_or_copy(source: Path, destination: Path) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.exists():
|
||||
return
|
||||
try:
|
||||
os.link(source, destination)
|
||||
except OSError:
|
||||
shutil.copy2(source, destination)
|
||||
|
||||
|
||||
def yolo_label(speed: int, bbox: tuple[int, int, int, int], image_path: Path) -> str | None:
|
||||
image = cv2.imread(str(image_path))
|
||||
if image is None:
|
||||
return None
|
||||
height, width = image.shape[:2]
|
||||
x1, y1, x2, y2 = bbox
|
||||
x1 = max(min(x1, width - 1), 0)
|
||||
y1 = max(min(y1, height - 1), 0)
|
||||
x2 = max(min(x2, width), 0)
|
||||
y2 = max(min(y2, height), 0)
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
return None
|
||||
values = (
|
||||
SPEED_TO_CLASS[speed],
|
||||
(x1 + x2) / (2 * width),
|
||||
(y1 + y2) / (2 * height),
|
||||
(x2 - x1) / width,
|
||||
(y2 - y1) / height,
|
||||
)
|
||||
return f"{values[0]} {values[1]:.8f} {values[2]:.8f} {values[3]:.8f} {values[4]:.8f}\n"
|
||||
|
||||
|
||||
def copy_base_dataset(base: Path, output: Path) -> Counter[str]:
|
||||
counts: Counter[str] = Counter()
|
||||
for split in ("train", "val"):
|
||||
image_dir = base / "images" / split
|
||||
label_dir = base / "labels" / split
|
||||
for source_image in image_dir.iterdir():
|
||||
if not source_image.is_file() or source_image.name.startswith("._"):
|
||||
continue
|
||||
source_label = label_dir / f"{source_image.stem}.txt"
|
||||
if not source_label.is_file():
|
||||
continue
|
||||
destination_stem = f"base_{source_image.stem}"
|
||||
link_or_copy(source_image, output / "images" / split / f"{destination_stem}{source_image.suffix.lower()}")
|
||||
link_or_copy(source_label, output / "labels" / split / f"{destination_stem}.txt")
|
||||
counts[f"base_{split}"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def add_reviewed_anchors(
|
||||
queue_path: Path,
|
||||
labels_path: Path,
|
||||
output: Path,
|
||||
train_ratio: float,
|
||||
seen_images: set[Path],
|
||||
) -> Counter[str]:
|
||||
counts: Counter[str] = Counter()
|
||||
for row in merged_review_rows(queue_path, labels_path):
|
||||
if row.get("review_status") not in POSITIVE_STATUSES:
|
||||
continue
|
||||
speed = parse_speed(row.get("review_speed_limit_mph", ""))
|
||||
bbox = parse_bbox(row.get("review_bbox") or row.get("bbox", ""))
|
||||
image_path = Path(row.get("frame_path", "")).expanduser()
|
||||
if speed not in SPEED_TO_CLASS or bbox is None or not image_path.is_file():
|
||||
continue
|
||||
resolved_image = image_path.resolve()
|
||||
if resolved_image in seen_images:
|
||||
continue
|
||||
label = yolo_label(speed, bbox, resolved_image)
|
||||
if label is None:
|
||||
continue
|
||||
split = split_for_key(row.get("route") or row.get("record_key", ""), train_ratio)
|
||||
stem = f"review_{row.get('record_key', hashlib.sha1(str(resolved_image).encode()).hexdigest()[:16])}"
|
||||
destination_image = output / "images" / split / f"{stem}{resolved_image.suffix.lower()}"
|
||||
link_or_copy(resolved_image, destination_image)
|
||||
(output / "labels" / split / f"{stem}.txt").write_text(label, encoding="ascii")
|
||||
seen_images.add(resolved_image)
|
||||
counts[f"anchor_{split}"] += 1
|
||||
counts[f"speed_{speed}"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def trusted_track_row(row: dict[str, str], args: argparse.Namespace) -> bool:
|
||||
try:
|
||||
expected = int(row.get("expected_speed_limit_mph", ""))
|
||||
predicted = int(row.get("predicted_speed_limit_mph", "") or 0)
|
||||
read_confidence = float(row.get("read_confidence", "") or 0.0)
|
||||
detector_confidence = float(row.get("detector_confidence", "") or 0.0)
|
||||
growth = float(row.get("area_ratio_to_anchor", "") or 0.0)
|
||||
rank = int(row.get("rank", "") or 999)
|
||||
except ValueError:
|
||||
return False
|
||||
exact_read = predicted == expected and read_confidence >= args.min_exact_confidence
|
||||
detector_snap = detector_confidence >= args.min_detector_confidence
|
||||
return expected in SPEED_TO_CLASS and growth >= args.min_growth and rank <= args.max_track_rank and (exact_read or detector_snap)
|
||||
|
||||
|
||||
def add_track_samples(args: argparse.Namespace, output: Path, seen_images: set[Path]) -> Counter[str]:
|
||||
counts: Counter[str] = Counter()
|
||||
with args.track_samples.expanduser().resolve().open(encoding="utf-8", newline="") as input_file:
|
||||
for row in csv.DictReader(input_file):
|
||||
if not trusted_track_row(row, args):
|
||||
counts["track_rejected"] += 1
|
||||
continue
|
||||
speed = int(row["expected_speed_limit_mph"])
|
||||
bbox = parse_bbox(row.get("bbox", ""))
|
||||
image_path = Path(row.get("frame_path", "")).expanduser().resolve()
|
||||
if bbox is None or not image_path.is_file() or image_path in seen_images:
|
||||
counts["track_rejected"] += 1
|
||||
continue
|
||||
label = yolo_label(speed, bbox, image_path)
|
||||
if label is None:
|
||||
counts["track_rejected"] += 1
|
||||
continue
|
||||
split = split_for_key(row.get("route") or row.get("track_key", ""), args.train_ratio)
|
||||
stem = f"track_{row.get('track_key', '')}_{row.get('rank', '')}"
|
||||
destination_image = output / "images" / split / f"{stem}{image_path.suffix.lower()}"
|
||||
link_or_copy(image_path, destination_image)
|
||||
(output / "labels" / split / f"{stem}.txt").write_text(label, encoding="ascii")
|
||||
seen_images.add(image_path)
|
||||
counts[f"track_{split}"] += 1
|
||||
counts[f"speed_{speed}"] += 1
|
||||
return counts
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
base = args.base.expanduser().resolve()
|
||||
queue_path = args.queue.expanduser().resolve()
|
||||
labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv")
|
||||
output = args.output.expanduser().resolve()
|
||||
for split in ("train", "val"):
|
||||
(output / "images" / split).mkdir(parents=True, exist_ok=True)
|
||||
(output / "labels" / split).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
counts = copy_base_dataset(base, output)
|
||||
seen_images: set[Path] = set()
|
||||
counts.update(add_reviewed_anchors(queue_path, labels_path, output, args.train_ratio, seen_images))
|
||||
counts.update(add_track_samples(args, output, seen_images))
|
||||
yaml_lines = [
|
||||
f"path: {output}",
|
||||
"train: images/train",
|
||||
"val: images/val",
|
||||
"names:",
|
||||
*(f" {index}: speed_limit_{speed}" for index, speed in enumerate(SPEED_VALUES)),
|
||||
]
|
||||
(output / "dataset.yaml").write_text("\n".join(yaml_lines) + "\n", encoding="ascii")
|
||||
summary = {"base": str(base), "output": str(output), "counts": dict(sorted(counts.items()))}
|
||||
(output / "track_dataset_summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="ascii")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from evaluate_runtime_manifest import expected_value, first_present, is_negative, load_rows # type: ignore
|
||||
from evaluate_reviewed_route_events import load_cases # type: ignore
|
||||
from replay_route_runtime import RouteReplayDaemon # type: ignore
|
||||
else:
|
||||
from .evaluate_runtime_manifest import expected_value, first_present, is_negative, load_rows
|
||||
from .evaluate_reviewed_route_events import load_cases
|
||||
from .replay_route_runtime import RouteReplayDaemon
|
||||
|
||||
|
||||
SPEED_VALUES = (15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Evaluate a one-stage direct speed-value detector.")
|
||||
parser.add_argument("--detector", type=Path, required=True)
|
||||
parser.add_argument("--manifest", type=Path, help="Static reviewed-frame manifest.")
|
||||
parser.add_argument("--queue", type=Path, help="Reviewed queue for cadence-aware route replay.")
|
||||
parser.add_argument("--labels", type=Path)
|
||||
parser.add_argument("--output-csv", type=Path)
|
||||
parser.add_argument("--confidence", type=float, default=0.06)
|
||||
parser.add_argument("--advisory-positive", action="store_true")
|
||||
parser.add_argument("--include-uncertain", action="store_true")
|
||||
parser.add_argument("--positive-only", action="store_true")
|
||||
parser.add_argument("--max-cases", type=int, default=0)
|
||||
parser.add_argument("--window-before", type=float, default=4.0)
|
||||
parser.add_argument("--window-after", type=float, default=3.0)
|
||||
parser.add_argument("--dedupe-seconds", type=float, default=3.0)
|
||||
parser.add_argument("--measured-inference-seconds", type=float, default=0.44)
|
||||
args = parser.parse_args()
|
||||
if bool(args.manifest) == bool(args.queue):
|
||||
parser.error("exactly one of --manifest or --queue is required")
|
||||
return args
|
||||
|
||||
|
||||
class DirectValueDetector:
|
||||
def __init__(self, model_path: Path, confidence: float):
|
||||
self.model_path = model_path.expanduser().resolve()
|
||||
self.confidence = confidence
|
||||
self.input_size = slv.SpeedLimitVisionDaemon._read_onnx_square_input_size(self.model_path, 256)
|
||||
self.net = cv2.dnn.readNetFromONNX(str(self.model_path))
|
||||
self.net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV)
|
||||
self.net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)
|
||||
self.last_forward_seconds = 0.0
|
||||
|
||||
def proposals(self, frame_bgr: np.ndarray) -> list[tuple[float, int, tuple[int, int, int, int]]]:
|
||||
frame_height, frame_width = frame_bgr.shape[:2]
|
||||
left_ratio, top_ratio, right_ratio, bottom_ratio = slv.ROI_WINDOWS[-1]["bounds"]
|
||||
origin_x = int(frame_width * left_ratio)
|
||||
origin_y = int(frame_height * top_ratio)
|
||||
right = int(frame_width * right_ratio)
|
||||
bottom = int(frame_height * bottom_ratio)
|
||||
region = frame_bgr[origin_y:bottom, origin_x:right]
|
||||
if region.size == 0:
|
||||
return []
|
||||
shape = (self.input_size, self.input_size)
|
||||
letterboxed, ratio, pad_width, pad_height = slv.SpeedLimitVisionDaemon._letterbox(region, shape=shape)
|
||||
blob = cv2.dnn.blobFromImage(letterboxed, scalefactor=1 / 255.0, size=shape, swapRB=True, crop=False)
|
||||
self.net.setInput(blob)
|
||||
started_at = time.monotonic()
|
||||
predictions = np.squeeze(self.net.forward())
|
||||
self.last_forward_seconds = time.monotonic() - started_at
|
||||
if predictions.ndim != 2:
|
||||
return []
|
||||
if predictions.shape[0] < predictions.shape[1]:
|
||||
predictions = predictions.T
|
||||
|
||||
region_height, region_width = region.shape[:2]
|
||||
proposals: list[tuple[float, int, tuple[int, int, int, int]]] = []
|
||||
for prediction in predictions:
|
||||
class_scores = prediction[4:]
|
||||
if class_scores.size not in (len(SPEED_VALUES) - 1, len(SPEED_VALUES)):
|
||||
continue
|
||||
class_id = int(np.argmax(class_scores))
|
||||
confidence = float(class_scores[class_id])
|
||||
if confidence < self.confidence:
|
||||
continue
|
||||
center_x, center_y, width, height = prediction[:4]
|
||||
x1 = max(int((center_x - width / 2 - pad_width) / ratio), 0)
|
||||
y1 = max(int((center_y - height / 2 - pad_height) / ratio), 0)
|
||||
x2 = min(int((center_x + width / 2 - pad_width) / ratio), region_width)
|
||||
y2 = min(int((center_y + height / 2 - pad_height) / ratio), region_height)
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
continue
|
||||
bbox = (x1 + origin_x, y1 + origin_y, x2 + origin_x, y2 + origin_y)
|
||||
box_width = bbox[2] - bbox[0]
|
||||
box_height = bbox[3] - bbox[1]
|
||||
if box_width < slv.MODEL_PROPOSAL_MIN_WIDTH or box_height < slv.MODEL_PROPOSAL_MIN_HEIGHT:
|
||||
continue
|
||||
if box_width * box_height > frame_width * frame_height * slv.MODEL_PROPOSAL_MAX_AREA_RATIO:
|
||||
continue
|
||||
if (bbox[0] + bbox[2]) / 2 < frame_width * slv.MODEL_PROPOSAL_MIN_X_RATIO:
|
||||
continue
|
||||
if bbox[1] > frame_height * slv.MODEL_PROPOSAL_MAX_Y_RATIO:
|
||||
continue
|
||||
proposals.append((confidence, class_id, bbox))
|
||||
return sorted(proposals, reverse=True)[:slv.MODEL_PROPOSAL_MAX_COUNT]
|
||||
|
||||
def detect(self, frame_bgr: np.ndarray) -> slv.Detection | None:
|
||||
proposals = self.proposals(frame_bgr)
|
||||
if not proposals:
|
||||
return None
|
||||
confidence, class_id, _bbox = proposals[0]
|
||||
return slv.Detection(SPEED_VALUES[class_id], min(confidence, 0.95), confidence >= 0.80)
|
||||
|
||||
|
||||
class DirectRouteReplayDaemon(RouteReplayDaemon):
|
||||
def __init__(self, detector: DirectValueDetector, measured_inference_seconds: float):
|
||||
super().__init__(runtime_context=None, measured_inference_seconds=measured_inference_seconds)
|
||||
self.direct_detector = detector
|
||||
|
||||
def _detect_sign(self, frame_bgr):
|
||||
return self._publishable_detection(self.direct_detector.detect(frame_bgr))
|
||||
|
||||
|
||||
def evaluate_manifest(args: argparse.Namespace, detector: DirectValueDetector) -> dict[str, object]:
|
||||
rows = load_rows(args.manifest.expanduser().resolve(), None)
|
||||
if not args.include_uncertain:
|
||||
rows = [row for row in rows if row.get("sample_type") != "uncertain_positive" and row.get("review_status") != "uncertain"]
|
||||
counts: Counter[str] = Counter()
|
||||
by_speed: dict[int, Counter[str]] = defaultdict(Counter)
|
||||
output_rows: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame", "image_path"))
|
||||
frame_bgr = cv2.imread(str(Path(image_text).expanduser())) if image_text else None
|
||||
if frame_bgr is None:
|
||||
counts["unreadable"] += 1
|
||||
continue
|
||||
detection = detector.detect(frame_bgr)
|
||||
predicted = detection.speed_limit_mph if detection else 0
|
||||
expected = expected_value(row)
|
||||
advisory_positive = args.advisory_positive and row.get("review_sign_type", "").strip().lower() == "advisory"
|
||||
negative = False if advisory_positive else is_negative(row)
|
||||
if negative:
|
||||
counts.update(negative=1, negative_fp=int(bool(predicted)))
|
||||
else:
|
||||
counts.update(positive=1, positive_any=int(bool(predicted)), positive_exact=int(predicted == expected))
|
||||
if expected is not None:
|
||||
by_speed[expected].update(total=1, exact=int(predicted == expected), any=int(bool(predicted)))
|
||||
output_rows.append({
|
||||
"record_key": row.get("record_key", ""),
|
||||
"expected_speed_limit_mph": expected or "",
|
||||
"predicted_speed_limit_mph": predicted or "",
|
||||
"confidence": detection.confidence if detection else "",
|
||||
"negative": negative,
|
||||
"image_path": image_text,
|
||||
})
|
||||
if args.output_csv:
|
||||
args.output_csv.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output_csv.open("w", encoding="utf-8", newline="") as output_file:
|
||||
writer = csv.DictWriter(output_file, fieldnames=output_rows[0].keys() if output_rows else ("record_key",))
|
||||
writer.writeheader()
|
||||
writer.writerows(output_rows)
|
||||
return {"counts": dict(counts), "by_speed": {str(speed): dict(values) for speed, values in sorted(by_speed.items())}}
|
||||
|
||||
|
||||
def replay_video_cases(cases, detector: DirectValueDetector, args: argparse.Namespace):
|
||||
daemons = {case.record_key: DirectRouteReplayDaemon(detector, args.measured_inference_seconds) for case in cases}
|
||||
capture = cv2.VideoCapture(str(cases[0].source_video_path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
windows = {
|
||||
case.record_key: (max(case.frame_time_s - args.window_before, 0.0), case.frame_time_s + args.window_after)
|
||||
for case in cases
|
||||
}
|
||||
first_frame = max(int(min(window[0] for window in windows.values()) * fps), 0)
|
||||
end_frame = max(int(max(window[1] for window in windows.values()) * fps), first_frame)
|
||||
frame_index = 0
|
||||
while frame_index < first_frame:
|
||||
if not capture.grab():
|
||||
break
|
||||
frame_index += 1
|
||||
while frame_index <= end_frame:
|
||||
ok, frame_bgr = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
frame_time_s = frame_index / fps
|
||||
for case in cases:
|
||||
start_s, end_s = windows[case.record_key]
|
||||
if start_s <= frame_time_s <= end_s:
|
||||
daemons[case.record_key].process_frame(frame_time_s - start_s, frame_bgr)
|
||||
frame_index += 1
|
||||
capture.release()
|
||||
return {
|
||||
case.record_key: (
|
||||
[int(event["candidateSpeedLimitMph"]) for event in daemons[case.record_key].events if event["event"] == "candidate"],
|
||||
[int(event["speedLimitMph"]) for event in daemons[case.record_key].events if event["event"] == "publish"],
|
||||
)
|
||||
for case in cases
|
||||
}
|
||||
|
||||
|
||||
def evaluate_queue(args: argparse.Namespace, detector: DirectValueDetector) -> dict[str, object]:
|
||||
queue_path = args.queue.expanduser().resolve()
|
||||
labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv")
|
||||
cases = load_cases(queue_path, labels_path, args.dedupe_seconds)
|
||||
if args.positive_only:
|
||||
cases = [case for case in cases if not case.negative]
|
||||
if args.max_cases > 0:
|
||||
cases = cases[:args.max_cases]
|
||||
cases_by_video = defaultdict(list)
|
||||
for case in cases:
|
||||
cases_by_video[case.source_video_path].append(case)
|
||||
results = {}
|
||||
for index, video_cases in enumerate(cases_by_video.values(), start=1):
|
||||
results.update(replay_video_cases(video_cases, detector, args))
|
||||
if index % 10 == 0:
|
||||
print(f"Replayed {index}/{len(cases_by_video)} video segments", flush=True)
|
||||
counts: Counter[str] = Counter()
|
||||
by_speed: dict[int, Counter[str]] = defaultdict(Counter)
|
||||
for case in cases:
|
||||
candidates, publishes = results.get(case.record_key, ([], []))
|
||||
if case.negative:
|
||||
counts.update(negative=1, candidate_fp=int(bool(candidates)), publish_fp=int(bool(publishes)))
|
||||
else:
|
||||
candidate_hit = case.expected_speed_limit_mph in candidates
|
||||
publish_hit = case.expected_speed_limit_mph in publishes
|
||||
counts.update(positive=1, candidate_hit=int(candidate_hit), publish_hit=int(publish_hit))
|
||||
by_speed[case.expected_speed_limit_mph].update(total=1, candidate_hit=int(candidate_hit), publish_hit=int(publish_hit))
|
||||
return {"counts": dict(counts), "by_speed": {str(speed): dict(values) for speed, values in sorted(by_speed.items())}}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
detector = DirectValueDetector(args.detector, args.confidence)
|
||||
result = evaluate_manifest(args, detector) if args.manifest else evaluate_queue(args, detector)
|
||||
print(json.dumps(result, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -51,6 +51,8 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--crop-ocr", action="store_true", help="Evaluate with crop OCR confirmation enabled.")
|
||||
parser.add_argument("--classifier-min-confidence", type=float, help="Override the value classifier confidence threshold.")
|
||||
parser.add_argument("--trusted-model-min-confidence", type=float, help="Override tiny-box trusted model confidence.")
|
||||
parser.add_argument("--classifier-expansion-limit", type=int, help="Evaluate only the first N detector crop expansions.")
|
||||
parser.add_argument("--classifier-expansion-indices", help="Comma-separated detector crop expansion indices to evaluate.")
|
||||
parser.add_argument("--strong-rescue-min-proposal-confidence", type=float, help="Override single-frame tiny-sign proposal confidence.")
|
||||
parser.add_argument("--strong-rescue-min-read-confidence", type=float, help="Override single-frame tiny-sign classifier confidence.")
|
||||
parser.add_argument("--low-speed-change-consistent-detections", type=int, help="Override reads required to change from 30+ mph to below 30 mph.")
|
||||
@@ -66,6 +68,7 @@ def parse_args() -> argparse.Namespace:
|
||||
)
|
||||
parser.add_argument("--initial-speed-limit", type=int, default=0, help="Seed each replay window with a currently published speed limit.")
|
||||
parser.add_argument("--positive-only", action="store_true", help="Replay only reviewed speed signs, omitting ignored-crop windows.")
|
||||
parser.add_argument("--route-file", type=Path, help="Only replay routes listed one per line in this file.")
|
||||
parser.add_argument("--max-cases", type=int, default=0, help="Optional evaluation cap after deduplication.")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -134,8 +137,13 @@ def replay_video_cases(cases: list[ReviewedCase], args: argparse.Namespace) -> d
|
||||
}
|
||||
first_frame = max(int(min(window[0] for window in windows.values()) * fps), 0)
|
||||
end_frame = max(int(max(window[1] for window in windows.values()) * fps), first_frame)
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, first_frame)
|
||||
frame_index = first_frame
|
||||
# Raw comma HEVC streams do not contain a usable random-access index.
|
||||
# OpenCV accepts CAP_PROP_POS_FRAMES but still returns frame zero.
|
||||
frame_index = 0
|
||||
while frame_index < first_frame:
|
||||
if not capture.grab():
|
||||
break
|
||||
frame_index += 1
|
||||
|
||||
while frame_index <= end_frame:
|
||||
ok, frame_bgr = capture.read()
|
||||
@@ -168,6 +176,15 @@ def main() -> int:
|
||||
slv.US_CLASSIFIER_MIN_CONFIDENCE = args.classifier_min_confidence
|
||||
if args.trusted_model_min_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = args.trusted_model_min_confidence
|
||||
if args.classifier_expansion_indices:
|
||||
indices = tuple(int(value) for value in args.classifier_expansion_indices.split(","))
|
||||
if not indices or min(indices) < 0 or max(indices) >= len(slv.DETECTOR_CLASSIFIER_EXPANSIONS):
|
||||
raise ValueError("--classifier-expansion-indices contains an invalid index")
|
||||
slv.DETECTOR_CLASSIFIER_EXPANSIONS = tuple(slv.DETECTOR_CLASSIFIER_EXPANSIONS[index] for index in indices)
|
||||
elif args.classifier_expansion_limit is not None:
|
||||
if args.classifier_expansion_limit < 1:
|
||||
raise ValueError("--classifier-expansion-limit must be at least 1")
|
||||
slv.DETECTOR_CLASSIFIER_EXPANSIONS = slv.DETECTOR_CLASSIFIER_EXPANSIONS[:args.classifier_expansion_limit]
|
||||
if args.strong_rescue_min_proposal_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = args.strong_rescue_min_proposal_confidence
|
||||
if args.strong_rescue_min_read_confidence is not None:
|
||||
@@ -179,6 +196,11 @@ def main() -> int:
|
||||
if args.enable_strong_model_consensus:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED = True
|
||||
cases = load_cases(queue_path, labels_path, args.dedupe_seconds)
|
||||
if args.route_file:
|
||||
selected_routes = {
|
||||
line.strip() for line in args.route_file.expanduser().resolve().read_text(encoding="utf-8").splitlines() if line.strip()
|
||||
}
|
||||
cases = [case for case in cases if case.route in selected_routes]
|
||||
if args.positive_only:
|
||||
cases = [case for case in cases if not case.negative]
|
||||
if args.max_cases > 0:
|
||||
|
||||
@@ -43,6 +43,8 @@ def parse_args() -> argparse.Namespace:
|
||||
crop_ocr_group.add_argument("--crop-ocr", action="store_true", dest="crop_ocr", default=None, help="Enable crop OCR confirmation.")
|
||||
crop_ocr_group.add_argument("--no-crop-ocr", action="store_false", dest="crop_ocr", help="Evaluate the model-only detector/classifier path.")
|
||||
parser.add_argument("--separate-reject-classifier", action="store_true", help="Enable the optional second-stage reject classifier during eval.")
|
||||
parser.add_argument("--classifier-expansion-limit", type=int, help="Evaluate only the first N detector crop expansions.")
|
||||
parser.add_argument("--classifier-expansion-indices", help="Comma-separated detector crop expansion indices to evaluate.")
|
||||
parser.add_argument("--include-uncertain", action="store_true", help="Include uncertain_positive review rows in positive metrics.")
|
||||
parser.add_argument("--advisory-positive", action="store_true", help="Score reviewed advisory rows as readable speed positives.")
|
||||
parser.add_argument("--strict-positive-recall", type=float, help="Exit non-zero if positive exact recall is below this value.")
|
||||
@@ -165,6 +167,15 @@ def main() -> int:
|
||||
slv.US_REJECT_CLASSIFIER_MIN_CONFIDENCE = args.classifier_reject_min_confidence
|
||||
if args.trusted_model_min_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = args.trusted_model_min_confidence
|
||||
if args.classifier_expansion_indices:
|
||||
indices = tuple(int(value) for value in args.classifier_expansion_indices.split(","))
|
||||
if not indices or min(indices) < 0 or max(indices) >= len(slv.DETECTOR_CLASSIFIER_EXPANSIONS):
|
||||
raise ValueError("--classifier-expansion-indices contains an invalid index")
|
||||
slv.DETECTOR_CLASSIFIER_EXPANSIONS = tuple(slv.DETECTOR_CLASSIFIER_EXPANSIONS[index] for index in indices)
|
||||
elif args.classifier_expansion_limit is not None:
|
||||
if args.classifier_expansion_limit < 1:
|
||||
raise ValueError("--classifier-expansion-limit must be at least 1")
|
||||
slv.DETECTOR_CLASSIFIER_EXPANSIONS = slv.DETECTOR_CLASSIFIER_EXPANSIONS[:args.classifier_expansion_limit]
|
||||
if args.strong_rescue_min_proposal_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = args.strong_rescue_min_proposal_confidence
|
||||
if args.strong_rescue_min_read_confidence is not None:
|
||||
|
||||
@@ -0,0 +1,432 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import math
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from import_manual_review_queue import merged_review_rows, parse_speed # type: ignore
|
||||
from replay_route_runtime import configure_models # type: ignore
|
||||
else:
|
||||
from .import_manual_review_queue import merged_review_rows, parse_speed
|
||||
from .replay_route_runtime import configure_models
|
||||
|
||||
|
||||
POSITIVE_STATUSES = frozenset(("accepted", "corrected"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrackCase:
|
||||
source_row: dict[str, str]
|
||||
track_key: str
|
||||
video_path: Path
|
||||
frame_time_s: float
|
||||
expected_speed_mph: int
|
||||
anchor_bbox: tuple[int, int, int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrackSample:
|
||||
time_s: float
|
||||
bbox: tuple[int, int, int, int]
|
||||
crop_bbox: tuple[int, int, int, int]
|
||||
detector_confidence: float
|
||||
predicted_speed_mph: int
|
||||
read_confidence: float
|
||||
sharpness: float
|
||||
brightness: float
|
||||
area_ratio_to_anchor: float
|
||||
score: float
|
||||
frame_jpeg: bytes
|
||||
crop_jpeg: bytes
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Track human-reviewed signs into later, larger route frames.")
|
||||
parser.add_argument("--queue", type=Path, required=True, help="Reviewed manual_review_queue.csv.")
|
||||
parser.add_argument("--labels", type=Path, help="Defaults to manual_review_labels.csv beside the queue.")
|
||||
parser.add_argument("--models-dir", type=Path, default=Path("starpilot/assets/vision_models"))
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--window-after", type=float, default=2.5, help="Seconds to track after the reviewed anchor frame.")
|
||||
parser.add_argument("--sample-interval", type=float, default=0.10, help="Minimum spacing between ranked samples.")
|
||||
parser.add_argument("--detector-interval", type=float, default=0.20, help="How often to snap optical flow to detector proposals.")
|
||||
parser.add_argument("--max-samples-per-track", type=int, default=4)
|
||||
parser.add_argument("--dedupe-seconds", type=float, default=3.0)
|
||||
parser.add_argument("--min-area-growth", type=float, default=0.85)
|
||||
parser.add_argument("--limit", type=int, default=0)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def parse_bbox(value: str) -> tuple[int, int, int, int] | None:
|
||||
try:
|
||||
values = tuple(int(round(float(part.strip()))) for part in value.split(","))
|
||||
except ValueError:
|
||||
return None
|
||||
if len(values) != 4:
|
||||
return None
|
||||
x1, y1, x2, y2 = values
|
||||
return values if x2 > x1 and y2 > y1 else None
|
||||
|
||||
|
||||
def bbox_area(bbox: tuple[int, int, int, int]) -> int:
|
||||
x1, y1, x2, y2 = bbox
|
||||
return max(x2 - x1, 0) * max(y2 - y1, 0)
|
||||
|
||||
|
||||
def bbox_iou(first: tuple[int, int, int, int], second: tuple[int, int, int, int]) -> float:
|
||||
ax1, ay1, ax2, ay2 = first
|
||||
bx1, by1, bx2, by2 = second
|
||||
intersection = max(min(ax2, bx2) - max(ax1, bx1), 0) * max(min(ay2, by2) - max(ay1, by1), 0)
|
||||
union = bbox_area(first) + bbox_area(second) - intersection
|
||||
return intersection / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def clamp_bbox(bbox: tuple[float, float, float, float], width: int, height: int) -> tuple[int, int, int, int] | None:
|
||||
x1, y1, x2, y2 = bbox
|
||||
result = (
|
||||
max(min(int(round(x1)), width - 1), 0),
|
||||
max(min(int(round(y1)), height - 1), 0),
|
||||
max(min(int(round(x2)), width), 0),
|
||||
max(min(int(round(y2)), height), 0),
|
||||
)
|
||||
return result if bbox_area(result) > 0 else None
|
||||
|
||||
|
||||
def expanded_bbox(bbox: tuple[int, int, int, int], width: int, height: int, padding: float = 0.12) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = bbox
|
||||
box_width = x2 - x1
|
||||
box_height = y2 - y1
|
||||
return (
|
||||
max(int(x1 - box_width * padding), 0),
|
||||
max(int(y1 - box_height * padding), 0),
|
||||
min(int(x2 + box_width * padding), width),
|
||||
min(int(y2 + box_height * padding), height),
|
||||
)
|
||||
|
||||
|
||||
def feature_points(gray: np.ndarray, bbox: tuple[int, int, int, int]) -> np.ndarray | None:
|
||||
mask = np.zeros_like(gray)
|
||||
x1, y1, x2, y2 = bbox
|
||||
inset_x = max((x2 - x1) // 12, 1)
|
||||
inset_y = max((y2 - y1) // 12, 1)
|
||||
mask[y1 + inset_y:y2 - inset_y, x1 + inset_x:x2 - inset_x] = 255
|
||||
return cv2.goodFeaturesToTrack(gray, mask=mask, maxCorners=60, qualityLevel=0.01, minDistance=3, blockSize=5)
|
||||
|
||||
|
||||
def flow_bbox(
|
||||
previous_gray: np.ndarray,
|
||||
current_gray: np.ndarray,
|
||||
bbox: tuple[int, int, int, int],
|
||||
points: np.ndarray | None,
|
||||
) -> tuple[tuple[int, int, int, int] | None, np.ndarray | None]:
|
||||
if points is None or len(points) < 6:
|
||||
points = feature_points(previous_gray, bbox)
|
||||
if points is None or len(points) < 4:
|
||||
return None, None
|
||||
next_points, status, errors = cv2.calcOpticalFlowPyrLK(
|
||||
previous_gray,
|
||||
current_gray,
|
||||
points,
|
||||
None,
|
||||
winSize=(25, 25),
|
||||
maxLevel=3,
|
||||
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 20, 0.03),
|
||||
)
|
||||
if next_points is None or status is None:
|
||||
return None, None
|
||||
good = status.reshape(-1).astype(bool)
|
||||
if errors is not None:
|
||||
good &= errors.reshape(-1) < 35.0
|
||||
old = points.reshape(-1, 2)[good]
|
||||
new = next_points.reshape(-1, 2)[good]
|
||||
if len(old) < 4:
|
||||
return None, None
|
||||
transform, inliers = cv2.estimateAffinePartial2D(old, new, method=cv2.RANSAC, ransacReprojThreshold=3.0)
|
||||
if transform is None or inliers is None or int(inliers.sum()) < 4:
|
||||
return None, None
|
||||
scale = math.hypot(float(transform[0, 0]), float(transform[0, 1]))
|
||||
if not 0.88 <= scale <= 1.18:
|
||||
return None, None
|
||||
x1, y1, x2, y2 = bbox
|
||||
corners = np.float32(((x1, y1), (x2, y1), (x2, y2), (x1, y2))).reshape(-1, 1, 2)
|
||||
moved = cv2.transform(corners, transform).reshape(-1, 2)
|
||||
tracked = clamp_bbox(
|
||||
(float(moved[:, 0].min()), float(moved[:, 1].min()), float(moved[:, 0].max()), float(moved[:, 1].max())),
|
||||
current_gray.shape[1],
|
||||
current_gray.shape[0],
|
||||
)
|
||||
if tracked is None:
|
||||
return None, None
|
||||
inlier_points = new[inliers.reshape(-1).astype(bool)].reshape(-1, 1, 2)
|
||||
return tracked, inlier_points
|
||||
|
||||
|
||||
def matching_proposal(daemon: slv.SpeedLimitVisionDaemon, frame: np.ndarray, tracked_bbox: tuple[int, int, int, int]):
|
||||
tx1, ty1, tx2, ty2 = tracked_bbox
|
||||
track_center = np.array(((tx1 + tx2) / 2, (ty1 + ty2) / 2))
|
||||
track_diagonal = max(math.hypot(tx2 - tx1, ty2 - ty1), 1.0)
|
||||
best = None
|
||||
best_score = 0.0
|
||||
for confidence, _class_id, bbox in daemon._collect_detector_classifier_proposals(frame):
|
||||
x1, y1, x2, y2 = bbox
|
||||
center = np.array(((x1 + x2) / 2, (y1 + y2) / 2))
|
||||
distance_ratio = float(np.linalg.norm(center - track_center)) / track_diagonal
|
||||
overlap = bbox_iou(tracked_bbox, bbox)
|
||||
if overlap < 0.06 and distance_ratio > 0.85:
|
||||
continue
|
||||
score = overlap * 2.0 + max(1.0 - distance_ratio, 0.0) + float(confidence) * 0.25
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best = float(confidence), bbox
|
||||
return best
|
||||
|
||||
|
||||
def encode_jpeg(image: np.ndarray, quality: int = 90) -> bytes:
|
||||
ok, encoded = cv2.imencode(".jpg", image, (cv2.IMWRITE_JPEG_QUALITY, quality))
|
||||
if not ok:
|
||||
raise RuntimeError("Could not encode tracked frame")
|
||||
return encoded.tobytes()
|
||||
|
||||
|
||||
def load_cases(queue_path: Path, labels_path: Path, dedupe_seconds: float) -> tuple[list[TrackCase], list[str]]:
|
||||
with queue_path.open(encoding="utf-8", newline="") as queue_file:
|
||||
fieldnames = list(csv.DictReader(queue_file).fieldnames or ())
|
||||
rows = merged_review_rows(queue_path, labels_path)
|
||||
seen: set[tuple[str, int, int, int]] = set()
|
||||
cases: list[TrackCase] = []
|
||||
for row in rows:
|
||||
if row.get("review_status") not in POSITIVE_STATUSES:
|
||||
continue
|
||||
speed = parse_speed(row.get("review_speed_limit_mph", ""))
|
||||
bbox = parse_bbox(row.get("review_bbox") or row.get("bbox", ""))
|
||||
try:
|
||||
frame_time_s = float(row.get("frame_time_s", ""))
|
||||
segment = int(row.get("segment", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
video_path = Path(row.get("source_video_path", "")).expanduser()
|
||||
if not speed or bbox is None or not video_path.is_file():
|
||||
continue
|
||||
bucket = int(frame_time_s / max(dedupe_seconds, 0.1))
|
||||
dedupe_key = (row.get("route", ""), segment, bucket, speed)
|
||||
if dedupe_key in seen:
|
||||
continue
|
||||
seen.add(dedupe_key)
|
||||
digest = hashlib.sha1(f"{row.get('record_key')}:{speed}".encode()).hexdigest()[:16]
|
||||
cases.append(TrackCase(row, f"sign_track_{digest}", video_path.resolve(), frame_time_s, speed, bbox))
|
||||
return cases, fieldnames
|
||||
|
||||
|
||||
def mine_case(case: TrackCase, daemon: slv.SpeedLimitVisionDaemon, args: argparse.Namespace) -> list[TrackSample]:
|
||||
capture = cv2.VideoCapture(str(case.video_path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
anchor_frame_index = max(int(round(case.frame_time_s * fps)), 0)
|
||||
# Raw comma HEVC streams have no seek index. CAP_PROP_POS_FRAMES silently
|
||||
# returns frame zero, so advance sequentially to preserve timestamp alignment.
|
||||
for _frame_index in range(anchor_frame_index):
|
||||
if not capture.grab():
|
||||
capture.release()
|
||||
return []
|
||||
ok, anchor_frame = capture.read()
|
||||
if not ok or anchor_frame is None:
|
||||
capture.release()
|
||||
return []
|
||||
|
||||
height, width = anchor_frame.shape[:2]
|
||||
bbox = clamp_bbox(case.anchor_bbox, width, height)
|
||||
if bbox is None:
|
||||
capture.release()
|
||||
return []
|
||||
anchor_area = max(bbox_area(bbox), 1)
|
||||
previous_gray = cv2.cvtColor(anchor_frame, cv2.COLOR_BGR2GRAY)
|
||||
points = feature_points(previous_gray, bbox)
|
||||
next_sample_at = case.frame_time_s
|
||||
next_detector_at = case.frame_time_s
|
||||
end_frame_index = anchor_frame_index + int(round(args.window_after * fps))
|
||||
candidates: list[TrackSample] = []
|
||||
|
||||
current_frame = anchor_frame
|
||||
current_index = anchor_frame_index
|
||||
while current_index <= end_frame_index:
|
||||
time_s = current_index / fps
|
||||
if current_index > anchor_frame_index:
|
||||
current_gray = cv2.cvtColor(current_frame, cv2.COLOR_BGR2GRAY)
|
||||
bbox, points = flow_bbox(previous_gray, current_gray, bbox, points)
|
||||
previous_gray = current_gray
|
||||
if bbox is None:
|
||||
break
|
||||
x1, y1, x2, y2 = bbox
|
||||
if bbox_area(bbox) > anchor_area * 12.0 or x1 <= 0 or y1 <= 0 or x2 >= width or y2 >= height:
|
||||
break
|
||||
detector_confidence = 0.0
|
||||
if time_s + 1e-6 >= next_detector_at:
|
||||
proposal = matching_proposal(daemon, current_frame, bbox)
|
||||
next_detector_at = time_s + args.detector_interval
|
||||
if proposal is not None:
|
||||
detector_confidence, proposal_bbox = proposal
|
||||
bbox = proposal_bbox
|
||||
points = feature_points(previous_gray, bbox)
|
||||
if time_s + 1e-6 >= next_sample_at:
|
||||
next_sample_at = time_s + args.sample_interval
|
||||
crop_box = expanded_bbox(bbox, width, height)
|
||||
x1, y1, x2, y2 = crop_box
|
||||
crop = current_frame[y1:y2, x1:x2]
|
||||
if crop.size:
|
||||
read = daemon._classify_speed_limit_from_model(crop)
|
||||
predicted_speed = int(read[0]) if read is not None else 0
|
||||
read_confidence = float(read[1]) if read is not None else 0.0
|
||||
gray_crop = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY)
|
||||
sharpness = float(cv2.Laplacian(gray_crop, cv2.CV_64F).var())
|
||||
brightness = float(gray_crop.mean())
|
||||
growth = bbox_area(bbox) / anchor_area
|
||||
exact_bonus = read_confidence * 2.0 if predicted_speed == case.expected_speed_mph else 0.0
|
||||
wrong_penalty = read_confidence * 1.5 if predicted_speed and predicted_speed != case.expected_speed_mph else 0.0
|
||||
score = (
|
||||
math.log2(max(growth, 0.25)) * 0.55 +
|
||||
min(sharpness / 180.0, 1.0) * 0.35 +
|
||||
detector_confidence * 0.45 +
|
||||
exact_bonus - wrong_penalty +
|
||||
min(max(time_s - case.frame_time_s, 0.0), 1.5) * 0.08
|
||||
)
|
||||
if growth >= args.min_area_growth:
|
||||
candidates.append(TrackSample(
|
||||
time_s=time_s,
|
||||
bbox=bbox,
|
||||
crop_bbox=crop_box,
|
||||
detector_confidence=detector_confidence,
|
||||
predicted_speed_mph=predicted_speed,
|
||||
read_confidence=read_confidence,
|
||||
sharpness=sharpness,
|
||||
brightness=brightness,
|
||||
area_ratio_to_anchor=growth,
|
||||
score=score,
|
||||
frame_jpeg=encode_jpeg(current_frame),
|
||||
crop_jpeg=encode_jpeg(crop, 95),
|
||||
))
|
||||
if current_index >= end_frame_index:
|
||||
break
|
||||
ok, current_frame = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
current_index += 1
|
||||
capture.release()
|
||||
|
||||
selected: list[TrackSample] = []
|
||||
for candidate in sorted(candidates, key=lambda item: item.score, reverse=True):
|
||||
if any(abs(candidate.time_s - kept.time_s) < max(args.sample_interval * 1.5, 0.12) for kept in selected):
|
||||
continue
|
||||
selected.append(candidate)
|
||||
if len(selected) >= args.max_samples_per_track:
|
||||
break
|
||||
return selected
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
queue_path = args.queue.expanduser().resolve()
|
||||
labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv")
|
||||
output_dir = args.output_dir.expanduser().resolve()
|
||||
frame_dir = output_dir / "frames"
|
||||
crop_dir = output_dir / "crops"
|
||||
frame_dir.mkdir(parents=True, exist_ok=True)
|
||||
crop_dir.mkdir(parents=True, exist_ok=True)
|
||||
configure_models(args.models_dir.expanduser().resolve())
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
cases, source_fieldnames = load_cases(queue_path, labels_path, args.dedupe_seconds)
|
||||
if args.limit > 0:
|
||||
cases = cases[:args.limit]
|
||||
|
||||
queue_rows: list[dict[str, str]] = []
|
||||
sample_rows: list[dict[str, str]] = []
|
||||
for index, case in enumerate(cases, start=1):
|
||||
samples = mine_case(case, daemon, args)
|
||||
if not samples:
|
||||
continue
|
||||
for rank, sample in enumerate(samples, start=1):
|
||||
stem = f"{case.track_key}_r{rank:02d}_t{sample.time_s:07.3f}".replace(".", "p")
|
||||
frame_path = frame_dir / f"{stem}.jpg"
|
||||
crop_path = crop_dir / f"{stem}_crop.jpg"
|
||||
frame_path.write_bytes(sample.frame_jpeg)
|
||||
crop_path.write_bytes(sample.crop_jpeg)
|
||||
sample_rows.append({
|
||||
"track_key": case.track_key,
|
||||
"source_record_key": case.source_row.get("record_key", ""),
|
||||
"route": case.source_row.get("route", ""),
|
||||
"segment": case.source_row.get("segment", ""),
|
||||
"frame_time_s": f"{sample.time_s:.3f}",
|
||||
"expected_speed_limit_mph": str(case.expected_speed_mph),
|
||||
"review_sign_type": case.source_row.get("review_sign_type", ""),
|
||||
"frame_path": str(frame_path),
|
||||
"crop_path": str(crop_path),
|
||||
"source_video_path": str(case.video_path),
|
||||
"bbox": ",".join(str(value) for value in sample.bbox),
|
||||
"crop_bbox": ",".join(str(value) for value in sample.crop_bbox),
|
||||
"detector_confidence": f"{sample.detector_confidence:.6f}",
|
||||
"predicted_speed_limit_mph": str(sample.predicted_speed_mph or ""),
|
||||
"read_confidence": f"{sample.read_confidence:.6f}",
|
||||
"sharpness": f"{sample.sharpness:.3f}",
|
||||
"brightness": f"{sample.brightness:.3f}",
|
||||
"area_ratio_to_anchor": f"{sample.area_ratio_to_anchor:.4f}",
|
||||
"track_score": f"{sample.score:.6f}",
|
||||
"rank": str(rank),
|
||||
})
|
||||
if rank == 1:
|
||||
review_row = dict(case.source_row)
|
||||
review_row.update({
|
||||
"record_key": case.track_key,
|
||||
"frame_time_s": f"{sample.time_s:.3f}",
|
||||
"frame_path": str(frame_path),
|
||||
"crop_path": str(crop_path),
|
||||
"source_video_path": str(case.video_path),
|
||||
"bbox": ",".join(str(value) for value in sample.bbox),
|
||||
"crop_bbox": ",".join(str(value) for value in sample.crop_bbox),
|
||||
"candidate_speed_limit_mph": str(case.expected_speed_mph),
|
||||
"candidate_confidence": f"{sample.read_confidence:.6f}",
|
||||
"model_read": str(sample.predicted_speed_mph or ""),
|
||||
"review_status": "",
|
||||
"review_speed_limit_mph": "",
|
||||
"review_sign_type": case.source_row.get("review_sign_type", "regulatory"),
|
||||
"review_bbox": "",
|
||||
"review_ignore_reason": "",
|
||||
"review_notes": f"tracked from {case.source_row.get('record_key', '')}",
|
||||
"source_record_key": case.source_row.get("record_key", ""),
|
||||
"source_review_status": case.source_row.get("review_status", ""),
|
||||
"source_review_speed_limit_mph": str(case.expected_speed_mph),
|
||||
})
|
||||
queue_rows.append(review_row)
|
||||
if index % 10 == 0:
|
||||
print(f"Tracked {index}/{len(cases)} reviewed signs; review rows={len(queue_rows)} samples={len(sample_rows)}", flush=True)
|
||||
|
||||
queue_fieldnames = list(source_fieldnames)
|
||||
for extra in ("source_record_key",):
|
||||
if extra not in queue_fieldnames:
|
||||
queue_fieldnames.append(extra)
|
||||
with (output_dir / "manual_review_queue.csv").open("w", encoding="utf-8", newline="") as output_file:
|
||||
writer = csv.DictWriter(output_file, fieldnames=queue_fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(queue_rows)
|
||||
sample_fieldnames = tuple(sample_rows[0]) if sample_rows else (
|
||||
"track_key", "source_record_key", "route", "segment", "frame_time_s", "expected_speed_limit_mph",
|
||||
)
|
||||
with (output_dir / "track_samples.csv").open("w", encoding="utf-8", newline="") as output_file:
|
||||
writer = csv.DictWriter(output_file, fieldnames=sample_fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(sample_rows)
|
||||
print(f"Track mining complete: cases={len(cases)} review_rows={len(queue_rows)} samples={len(sample_rows)} output={output_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Binary file not shown.
@@ -162,7 +162,6 @@ US_REJECT_CLASSIFIER_MIN_CONFIDENCE = 0.85
|
||||
DETECTOR_CLASSIFIER_EXPANSIONS = (
|
||||
(0.00, 0.00, 0.00, 0.00, 1.10),
|
||||
(0.10, 0.06, 0.10, 0.12, 1.00),
|
||||
(0.06, 0.00, 0.10, 0.10, 0.75),
|
||||
(0.00, 0.00, 0.18, 0.18, 0.55),
|
||||
)
|
||||
SCHOOL_ZONE_DIRECT_EXPANSIONS = (
|
||||
|
||||
Reference in New Issue
Block a user