This commit is contained in:
firestar5683
2026-07-13 12:41:08 -05:00
parent 361f692d53
commit 7e4f8d4154
9 changed files with 408 additions and 65 deletions
@@ -37,9 +37,26 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--min-growth", type=float, default=1.0, help="Minimum tracked box area relative to its reviewed anchor.")
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("--min-tracking-confidence", type=float, default=1.01, help="Optical-flow confidence for detector-free tracks.")
parser.add_argument("--max-track-rank", type=int, default=4)
parser.add_argument("--important-repeat", type=int, default=2, help="Train repeats for accepted 30-65 mph samples.")
parser.add_argument("--other-repeat", type=int, default=1, help="Train repeats for other accepted speed samples.")
parser.add_argument(
"--focus-eval-csv",
type=Path,
help="Optional runtime event CSV; only reviewed rows where --focus-outcome is false are added.",
)
parser.add_argument(
"--focus-outcome",
choices=("candidate_hit", "publish_hit"),
default="publish_hit",
help="Runtime outcome used to select hard positives from --focus-eval-csv.",
)
parser.add_argument(
"--focus-train-only",
action="store_true",
help="Exclude selected records assigned to the route-level validation split.",
)
return parser.parse_args()
@@ -90,18 +107,48 @@ def yolo_label(bbox: tuple[int, int, int, int], image_path: Path) -> str | None:
return f"0 {values[0]:.8f} {values[1]:.8f} {values[2]:.8f} {values[3]:.8f}\n"
def reviewed_positive_rows(queue_path: Path, labels_path: Path) -> dict[str, dict[str, str]]:
def focus_record_keys(eval_path: Path, outcome: str) -> set[str]:
selected: set[str] = set()
with eval_path.expanduser().resolve().open(encoding="utf-8", newline="") as input_file:
reader = csv.DictReader(input_file)
if outcome not in (reader.fieldnames or ()):
raise ValueError(f"{eval_path} does not contain {outcome}")
for row in reader:
record_key = row.get("record_key", "")
outcome_value = row.get(outcome, "").strip().lower()
if record_key and outcome_value not in ("1", "true", "yes"):
selected.add(record_key)
return selected
def reviewed_positive_rows(
queue_path: Path,
labels_path: Path,
selected_record_keys: set[str] | None = None,
) -> dict[str, dict[str, str]]:
return {
row.get("record_key", ""): row
for row in merged_review_rows(queue_path, labels_path)
if row.get("record_key") and row.get("review_status") in POSITIVE_STATUSES and parse_speed(row.get("review_speed_limit_mph", ""))
if (
row.get("record_key") and
(selected_record_keys is None or row.get("record_key") in selected_record_keys) and
row.get("review_status") in POSITIVE_STATUSES and
parse_speed(row.get("review_speed_limit_mph", ""))
)
}
def reviewed_row_for_track(
track_row: dict[str, str],
reviewed_rows: dict[str, dict[str, str]],
) -> dict[str, str] | None:
return reviewed_rows.get(track_row.get("track_key", "")) or reviewed_rows.get(track_row.get("source_record_key", ""))
def trusted_track_row(row: dict[str, str], reviewed_row: dict[str, str], args: argparse.Namespace) -> bool:
original_bbox = parse_bbox(reviewed_row.get("bbox", ""))
corrected_bbox = parse_bbox(reviewed_row.get("review_bbox", ""))
if corrected_bbox is not None and corrected_bbox != original_bbox:
if corrected_bbox is not None and corrected_bbox != original_bbox and row.get("anchor_bbox_source") != "review_bbox":
# Existing tracks predate manual box correction, so their propagated boxes are stale.
return False
try:
@@ -110,13 +157,20 @@ def trusted_track_row(row: dict[str, str], reviewed_row: dict[str, str], args: a
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)
tracking_confidence = float(row.get("tracking_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 == reviewed_speed and growth >= args.min_growth and rank <= args.max_track_rank and (exact_read or detector_snap)
optical_flow_track = tracking_confidence >= args.min_tracking_confidence
return (
expected == reviewed_speed and
growth >= args.min_growth and
rank <= args.max_track_rank and
(exact_read or detector_snap or optical_flow_track)
)
def add_sample(
@@ -172,7 +226,7 @@ def add_track_samples(
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):
reviewed_row = reviewed_rows.get(row.get("track_key", ""))
reviewed_row = reviewed_row_for_track(row, reviewed_rows)
if reviewed_row is None or not trusted_track_row(row, reviewed_row, args):
counts["track_rejected"] += 1
continue
@@ -222,7 +276,14 @@ def main() -> int:
(output / "images" / split).mkdir(parents=True, exist_ok=True)
(output / "labels" / split).mkdir(parents=True, exist_ok=True)
reviewed_rows = reviewed_positive_rows(queue_path, labels_path)
selected_record_keys = focus_record_keys(args.focus_eval_csv, args.focus_outcome) if args.focus_eval_csv else None
reviewed_rows = reviewed_positive_rows(queue_path, labels_path, selected_record_keys)
if args.focus_train_only:
reviewed_rows = {
key: row
for key, row in reviewed_rows.items()
if split_for_key(row.get("route") or key, args.train_ratio) == "train"
}
counts = add_reviewed_anchors(reviewed_rows, output, args)
counts.update(add_track_samples(reviewed_rows, output, args))
dataset_yaml = write_dataset_yaml(args.base_yaml.expanduser().resolve(), output)
@@ -230,6 +291,9 @@ def main() -> int:
"accepted_source_records": len(reviewed_rows),
"base_yaml": str(args.base_yaml.expanduser().resolve()),
"dataset_yaml": str(dataset_yaml),
"focus_eval_csv": str(args.focus_eval_csv.expanduser().resolve()) if args.focus_eval_csv else None,
"focus_outcome": args.focus_outcome if args.focus_eval_csv else None,
"focus_train_only": args.focus_train_only,
"output": str(output),
"counts": dict(sorted(counts.items())),
}
@@ -55,7 +55,9 @@ def parse_args() -> argparse.Namespace:
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("--strong-rescue-min-support", type=int, help="Override agreeing crops required for tiny-sign strong rescue.")
parser.add_argument("--low-speed-change-consistent-detections", type=int, help="Override reads required to change from 30+ mph to below 30 mph.")
parser.add_argument("--low-speed-change-min-confidence", type=float, help="Override confidence required to change from 30+ mph to below 30 mph.")
parser.add_argument(
"--allow-low-speed-strong-consensus",
action="store_true",
@@ -88,6 +90,7 @@ def parse_args() -> argparse.Namespace:
parser.add_argument("--strong-detection-confidence", type=float, help="Override one-frame publication confidence.")
parser.add_argument("--consistent-detections", type=int, help="Override matching reads required for an initial publication.")
parser.add_argument("--change-consistent-detections", type=int, help="Override matching reads required to change a publication.")
parser.add_argument("--change-single-read-min-confidence", type=float, help="Override confidence for a one-read speed change.")
parser.add_argument("--max-cases", type=int, default=0, help="Optional evaluation cap after deduplication.")
return parser.parse_args()
@@ -206,8 +209,14 @@ def main() -> int:
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = args.strong_rescue_min_proposal_confidence
if args.strong_rescue_min_read_confidence is not None:
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE = args.strong_rescue_min_read_confidence
if args.strong_rescue_min_support is not None:
if args.strong_rescue_min_support < 1:
raise ValueError("--strong-rescue-min-support must be at least 1")
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_SUPPORT = args.strong_rescue_min_support
if args.low_speed_change_consistent_detections is not None:
slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = args.low_speed_change_consistent_detections
if args.low_speed_change_min_confidence is not None:
slv.LOW_SPEED_CHANGE_MIN_CONFIDENCE = args.low_speed_change_min_confidence
if args.allow_low_speed_strong_consensus:
slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS = True
if args.enable_strong_model_consensus:
@@ -226,6 +235,8 @@ def main() -> int:
slv.CONSISTENT_DETECTIONS = args.consistent_detections
if args.change_consistent_detections is not None:
slv.CHANGE_CONSISTENT_DETECTIONS = args.change_consistent_detections
if args.change_single_read_min_confidence is not None:
slv.CHANGE_SINGLE_READ_MIN_CONFIDENCE = args.change_single_read_min_confidence
cases = load_cases(queue_path, labels_path, args.dedupe_seconds)
if args.route_file:
selected_routes = {
@@ -322,6 +333,8 @@ def main() -> int:
"measured_classifier_forward_seconds": args.measured_classifier_forward_seconds,
"initial_speed_limit_mph": args.initial_speed_limit,
"low_speed_change_consistent_detections": slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS,
"low_speed_change_min_confidence": slv.LOW_SPEED_CHANGE_MIN_CONFIDENCE,
"change_single_read_min_confidence": slv.CHANGE_SINGLE_READ_MIN_CONFIDENCE,
"low_speed_change_allow_strong_consensus": slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS,
"strong_model_consensus_enabled": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED,
"strong_model_min_proposal_confidence": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE,
@@ -6,6 +6,7 @@ import csv
import hashlib
import math
from collections import deque
from dataclasses import dataclass
from pathlib import Path
@@ -35,6 +36,7 @@ class TrackCase:
frame_time_s: float
expected_speed_mph: int
anchor_bbox: tuple[int, int, int, int]
anchor_bbox_source: str
@dataclass(frozen=True)
@@ -43,6 +45,7 @@ class TrackSample:
bbox: tuple[int, int, int, int]
crop_bbox: tuple[int, int, int, int]
detector_confidence: float
tracking_confidence: float
predicted_speed_mph: int
read_confidence: float
sharpness: float
@@ -54,17 +57,20 @@ class TrackSample:
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Track human-reviewed signs into later, larger route frames.")
parser = argparse.ArgumentParser(description="Track human-reviewed signs into nearby 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-before", type=float, default=0.0, help="Seconds to track backward before the reviewed frame.")
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("--max-samples-before-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("--min-backward-area-ratio", type=float, default=0.20)
parser.add_argument("--limit", type=int, default=0)
return parser.parse_args()
@@ -200,6 +206,70 @@ def encode_jpeg(image: np.ndarray, quality: int = 90) -> bytes:
return encoded.tobytes()
def ranked_samples(candidates: list[TrackSample], limit: int, sample_interval: float) -> list[TrackSample]:
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(sample_interval * 1.5, 0.12) for kept in selected):
continue
selected.append(candidate)
if len(selected) >= limit:
break
return selected
def make_sample(
case: TrackCase,
daemon: slv.SpeedLimitVisionDaemon,
frame: np.ndarray,
bbox: tuple[int, int, int, int],
detector_confidence: float,
tracking_confidence: float,
anchor_area: int,
time_s: float,
time_distance_s: float,
backward: bool,
) -> TrackSample | None:
height, width = frame.shape[:2]
crop_box = expanded_bbox(bbox, width, height)
x1, y1, x2, y2 = crop_box
crop = frame[y1:y2, x1:x2]
if crop.size == 0:
return None
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
growth_score = math.log2(max(growth, 0.25)) * (0.15 if backward else 0.55)
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 = (
growth_score +
min(sharpness / 180.0, 1.0) * 0.35 +
detector_confidence * 0.45 +
tracking_confidence * 0.20 +
exact_bonus - wrong_penalty +
min(time_distance_s, 1.5) * (0.04 if backward else 0.08)
)
return TrackSample(
time_s=time_s,
bbox=bbox,
crop_bbox=crop_box,
detector_confidence=detector_confidence,
tracking_confidence=tracking_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(frame),
crop_jpeg=encode_jpeg(crop, 95),
)
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 ())
@@ -210,7 +280,8 @@ def load_cases(queue_path: Path, labels_path: Path, dedupe_seconds: float) -> tu
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", ""))
reviewed_bbox = parse_bbox(row.get("review_bbox", ""))
bbox = reviewed_bbox or parse_bbox(row.get("bbox", ""))
try:
frame_time_s = float(row.get("frame_time_s", ""))
segment = int(row.get("segment", ""))
@@ -225,18 +296,96 @@ def load_cases(queue_path: Path, labels_path: Path, dedupe_seconds: float) -> tu
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))
cases.append(TrackCase(
row,
f"sign_track_{digest}",
video_path.resolve(),
frame_time_s,
speed,
bbox,
"review_bbox" if reviewed_bbox is not None else "bbox",
))
return cases, fieldnames
def mine_backward_samples(
case: TrackCase,
daemon: slv.SpeedLimitVisionDaemon,
args: argparse.Namespace,
frames: list[tuple[int, np.ndarray]],
anchor_frame: np.ndarray,
anchor_frame_index: int,
fps: float,
anchor_bbox: tuple[int, int, int, int],
anchor_area: int,
) -> list[TrackSample]:
if not frames or args.max_samples_before_track <= 0:
return []
previous_gray = cv2.cvtColor(anchor_frame, cv2.COLOR_BGR2GRAY)
bbox = anchor_bbox
points = feature_points(previous_gray, bbox)
next_sample_elapsed = args.sample_interval
next_detector_elapsed = 0.0
candidates: list[TrackSample] = []
height, width = anchor_frame.shape[:2]
for frame_index, frame in reversed(frames):
elapsed = (anchor_frame_index - frame_index) / fps
current_gray = cv2.cvtColor(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
growth = bbox_area(bbox) / anchor_area
if growth < args.min_backward_area_ratio or growth > 1.35 or x1 <= 0 or y1 <= 0 or x2 >= width or y2 >= height:
break
detector_confidence = 0.0
if elapsed + 1e-6 >= next_detector_elapsed:
proposal = matching_proposal(daemon, frame, bbox)
next_detector_elapsed = elapsed + args.detector_interval
if proposal is not None:
detector_confidence, proposal_bbox = proposal
bbox = proposal_bbox
points = feature_points(previous_gray, bbox)
growth = bbox_area(bbox) / anchor_area
if elapsed + 1e-6 < next_sample_elapsed:
continue
next_sample_elapsed = elapsed + args.sample_interval
tracking_confidence = min(len(points) / 12.0, 1.0) if points is not None else 0.0
sample = make_sample(
case,
daemon,
frame,
bbox,
detector_confidence,
tracking_confidence,
anchor_area,
frame_index / fps,
elapsed,
backward=True,
)
if sample is not None:
candidates.append(sample)
return ranked_samples(candidates, args.max_samples_before_track, args.sample_interval)
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)
before_frame_count = max(int(round(args.window_before * fps)), 0)
earlier_frames: deque[tuple[int, np.ndarray]] = deque(maxlen=before_frame_count)
# 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():
for frame_index in range(anchor_frame_index):
if before_frame_count and frame_index >= anchor_frame_index - before_frame_count:
ok, frame = capture.read()
if not ok or frame is None:
capture.release()
return []
earlier_frames.append((frame_index, frame))
elif not capture.grab():
capture.release()
return []
ok, anchor_frame = capture.read()
@@ -250,6 +399,17 @@ def mine_case(case: TrackCase, daemon: slv.SpeedLimitVisionDaemon, args: argpars
capture.release()
return []
anchor_area = max(bbox_area(bbox), 1)
backward_samples = mine_backward_samples(
case,
daemon,
args,
list(earlier_frames),
anchor_frame,
anchor_frame_index,
fps,
bbox,
anchor_area,
)
previous_gray = cv2.cvtColor(anchor_frame, cv2.COLOR_BGR2GRAY)
points = feature_points(previous_gray, bbox)
next_sample_at = case.frame_time_s
@@ -280,41 +440,23 @@ def mine_case(case: TrackCase, daemon: slv.SpeedLimitVisionDaemon, args: argpars
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
growth = bbox_area(bbox) / anchor_area
if growth >= args.min_area_growth:
tracking_confidence = min(len(points) / 12.0, 1.0) if points is not None else 0.0
sample = make_sample(
case,
daemon,
current_frame,
bbox,
detector_confidence,
tracking_confidence,
anchor_area,
time_s,
max(time_s - case.frame_time_s, 0.0),
backward=False,
)
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 sample is not None:
candidates.append(sample)
if current_index >= end_frame_index:
break
ok, current_frame = capture.read()
@@ -323,14 +465,8 @@ def mine_case(case: TrackCase, daemon: slv.SpeedLimitVisionDaemon, args: argpars
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
selected = ranked_samples(candidates, args.max_samples_per_track, args.sample_interval)
return [*backward_samples, *selected]
def main() -> int:
@@ -371,9 +507,11 @@ def main() -> int:
"frame_path": str(frame_path),
"crop_path": str(crop_path),
"source_video_path": str(case.video_path),
"anchor_bbox_source": case.anchor_bbox_source,
"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}",
"tracking_confidence": f"{sample.tracking_confidence:.6f}",
"predicted_speed_limit_mph": str(sample.predicted_speed_mph or ""),
"read_confidence": f"{sample.read_confidence:.6f}",
"sharpness": f"{sample.sharpness:.3f}",
@@ -18,16 +18,60 @@ def load_local_module(name: str):
track_dataset = load_local_module("build_track_proposal_detector_dataset")
split_for_key = track_dataset.split_for_key
trusted_track_row = track_dataset.trusted_track_row
focus_record_keys = track_dataset.focus_record_keys
reviewed_row_for_track = track_dataset.reviewed_row_for_track
def args() -> Namespace:
return Namespace(min_exact_confidence=0.8, min_detector_confidence=0.3, min_growth=1.0, max_track_rank=4)
return Namespace(
min_exact_confidence=0.8,
min_detector_confidence=0.3,
min_tracking_confidence=1.01,
min_growth=1.0,
max_track_rank=4,
)
def test_split_for_key_keeps_route_samples_together():
assert split_for_key("route-a", 0.85) == split_for_key("route-a", 0.85)
def test_focus_record_keys_selects_failed_outcomes(tmp_path):
eval_path = tmp_path / "runtime.csv"
eval_path.write_text(
"\n".join((
"record_key,candidate_hit,publish_hit",
"published,true,true",
"single-read,true,false",
"missed,false,false",
"",
)),
encoding="ascii",
)
assert focus_record_keys(eval_path, "publish_hit") == {"single-read", "missed"}
assert focus_record_keys(eval_path, "candidate_hit") == {"missed"}
def test_focus_record_keys_rejects_missing_outcome(tmp_path):
eval_path = tmp_path / "runtime.csv"
eval_path.write_text("record_key,publish_hit\nmissed,false\n", encoding="ascii")
try:
focus_record_keys(eval_path, "candidate_hit")
except ValueError as exc:
assert "candidate_hit" in str(exc)
else:
raise AssertionError("missing focus outcome was accepted")
def test_fresh_track_source_key_can_resolve_reviewed_row():
reviewed = {"source-review": {"review_speed_limit_mph": "35"}}
track = {"track_key": "derived-track", "source_record_key": "source-review"}
assert reviewed_row_for_track(track, reviewed) == reviewed["source-review"]
def test_trusted_track_requires_current_review_speed_to_match():
track = {
"expected_speed_limit_mph": "45",
@@ -53,6 +97,29 @@ def test_trusted_track_accepts_detector_snap_without_classifier_read():
assert trusted_track_row(track, {"review_speed_limit_mph": "35"}, args())
def test_trusted_track_accepts_confident_optical_flow_from_corrected_anchor():
track = {
"expected_speed_limit_mph": "35",
"predicted_speed_limit_mph": "",
"read_confidence": "",
"detector_confidence": "0.0",
"tracking_confidence": "0.9",
"area_ratio_to_anchor": "0.5",
"rank": "2",
"anchor_bbox_source": "review_bbox",
}
review = {
"review_speed_limit_mph": "35",
"bbox": "10,10,40,50",
"review_bbox": "12,12,35,45",
}
options = args()
options.min_tracking_confidence = 0.8
options.min_growth = 0.3
assert trusted_track_row(track, review, options)
def test_trusted_track_rejects_low_growth_and_rank():
track = {
"expected_speed_limit_mph": "35",