friar carl
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
DEBUG_BASE_DIR = Path("/data/media/0/vision_speed_limit_debug")
|
||||
|
||||
|
||||
def load_events(session_path: Path):
|
||||
events_path = session_path / "events.jsonl"
|
||||
if not events_path.is_file():
|
||||
raise FileNotFoundError(f"Missing events file: {events_path}")
|
||||
|
||||
events = []
|
||||
with events_path.open("r", encoding="utf-8") as log_file:
|
||||
for line in log_file:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
events.append(json.loads(line))
|
||||
return events
|
||||
|
||||
|
||||
def resolve_session(session_arg: str | None) -> Path | None:
|
||||
if session_arg:
|
||||
session_path = Path(session_arg)
|
||||
if session_path.is_dir():
|
||||
return session_path
|
||||
candidate = DEBUG_BASE_DIR / session_arg
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
raise FileNotFoundError(f"Session not found: {session_arg}")
|
||||
|
||||
if not DEBUG_BASE_DIR.is_dir():
|
||||
return None
|
||||
|
||||
sessions = sorted((path for path in DEBUG_BASE_DIR.iterdir() if path.is_dir()), reverse=True)
|
||||
return sessions[0] if sessions else None
|
||||
|
||||
|
||||
def list_sessions():
|
||||
if not DEBUG_BASE_DIR.is_dir():
|
||||
print(f"No debug sessions found in {DEBUG_BASE_DIR}")
|
||||
return
|
||||
|
||||
sessions = sorted((path for path in DEBUG_BASE_DIR.iterdir() if path.is_dir()), reverse=True)
|
||||
if not sessions:
|
||||
print(f"No debug sessions found in {DEBUG_BASE_DIR}")
|
||||
return
|
||||
|
||||
for session_path in sessions:
|
||||
events_path = session_path / "events.jsonl"
|
||||
event_count = 0
|
||||
bookmark_count = 0
|
||||
if events_path.is_file():
|
||||
with events_path.open("r", encoding="utf-8") as log_file:
|
||||
for line in log_file:
|
||||
if not line.strip():
|
||||
continue
|
||||
event_count += 1
|
||||
if '"event":"bookmark"' in line:
|
||||
bookmark_count += 1
|
||||
print(f"{session_path.name}: {event_count} events, {bookmark_count} bookmarks")
|
||||
|
||||
|
||||
def print_event(event: dict):
|
||||
fields = [
|
||||
event.get("wallTime", ""),
|
||||
event.get("event", ""),
|
||||
]
|
||||
if event.get("sessionSeconds") is not None:
|
||||
fields.append(f"t+{event['sessionSeconds']}s")
|
||||
|
||||
if event.get("roadName"):
|
||||
fields.append(f"road={event['roadName']}")
|
||||
if event.get("speedLimitMph"):
|
||||
fields.append(f"speed={event['speedLimitMph']} mph")
|
||||
if event.get("candidateSpeedLimitMph"):
|
||||
fields.append(f"candidate={event['candidateSpeedLimitMph']} mph")
|
||||
if event.get("confidence"):
|
||||
fields.append(f"conf={event['confidence']}")
|
||||
if event.get("candidateConfidence"):
|
||||
fields.append(f"candidateConf={event['candidateConfidence']}")
|
||||
if event.get("statusText"):
|
||||
fields.append(f"status={event['statusText']}")
|
||||
elif event.get("status"):
|
||||
fields.append(f"status={event['status']}")
|
||||
if event.get("snapshot"):
|
||||
fields.append(f"snapshot={event['snapshot']}")
|
||||
print(" | ".join(str(field) for field in fields if field != ""))
|
||||
|
||||
|
||||
def summarize_session(session_path: Path, window: int):
|
||||
events = load_events(session_path)
|
||||
print(f"Session: {session_path}")
|
||||
print(f"Events: {len(events)}")
|
||||
|
||||
bookmarks = [idx for idx, event in enumerate(events) if event.get("event") == "bookmark"]
|
||||
if not bookmarks:
|
||||
print("Bookmarks: none")
|
||||
return
|
||||
|
||||
print(f"Bookmarks: {len(bookmarks)}")
|
||||
for bookmark_number, event_idx in enumerate(bookmarks, start=1):
|
||||
print(f"\nBookmark {bookmark_number}")
|
||||
start = max(event_idx - window, 0)
|
||||
end = min(event_idx + window + 1, len(events))
|
||||
for idx in range(start, end):
|
||||
prefix = "->" if idx == event_idx else " "
|
||||
print(prefix, end="")
|
||||
print_event(events[idx])
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Summarize StarPilot speed-limit vision debug sessions.")
|
||||
parser.add_argument("session", nargs="?", help="Session id or full path. Defaults to the latest session.")
|
||||
parser.add_argument("--list", action="store_true", help="List available sessions and exit.")
|
||||
parser.add_argument("--window", type=int, default=5, help="How many events before/after each bookmark to print.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.list:
|
||||
list_sessions()
|
||||
return
|
||||
|
||||
session_path = resolve_session(args.session)
|
||||
if session_path is None:
|
||||
print(f"No debug sessions found in {DEBUG_BASE_DIR}")
|
||||
return
|
||||
|
||||
summarize_session(session_path, max(args.window, 0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
|
||||
class ReplayDaemon(slv.SpeedLimitVisionDaemon):
|
||||
def __init__(self):
|
||||
super().__init__(use_runtime=False)
|
||||
self.now = 0.0
|
||||
|
||||
def _write_debug_event(self, event_type, frame_bgr=None, snapshot_prefix=None, **fields):
|
||||
if event_type in ("candidate", "publish", "stale_clear"):
|
||||
print(f"t={self.now:6.2f}s {event_type:12} {fields}")
|
||||
|
||||
def _publish_status(self, status, clear_speed=False):
|
||||
if clear_speed:
|
||||
self._clear_detection()
|
||||
|
||||
def _publish_detection(self, speed_limit_mph, confidence, status_prefix):
|
||||
super()._publish_detection(speed_limit_mph, confidence, status_prefix)
|
||||
|
||||
def process_frame(self, now, frame_bgr):
|
||||
self.now = now
|
||||
slv.time.monotonic = lambda now=now: now
|
||||
self.current_frame_bgr = frame_bgr
|
||||
|
||||
detection = self._detect_sign(frame_bgr)
|
||||
if detection is not None:
|
||||
self._update_detection(detection)
|
||||
elif self.published_speed_limit_mph > 0 and self._published_detection_stale(now):
|
||||
print(f"t={self.now:6.2f}s stale_clear {{'reason': 'no_detection'}}")
|
||||
self._clear_detection()
|
||||
|
||||
|
||||
def iter_directory_frames(path: Path, fps: float):
|
||||
for index, frame_path in enumerate(sorted(path.glob("frame_*.png")), start=1):
|
||||
frame = cv2.imread(str(frame_path))
|
||||
if frame is None:
|
||||
continue
|
||||
yield (index - 1) / fps, frame
|
||||
|
||||
|
||||
def iter_video_frames(path: Path):
|
||||
cap = cv2.VideoCapture(str(path))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
|
||||
frame_index = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
yield frame_index / fps, frame
|
||||
frame_index += 1
|
||||
cap.release()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Replay StarPilot speed-limit vision on saved video or extracted frames.")
|
||||
parser.add_argument("path", help="Path to an fcamera.hevc file or a directory of frame_XXX.png images.")
|
||||
parser.add_argument("--frames-fps", type=float, default=5.0, help="FPS to assume when replaying an extracted frame directory.")
|
||||
parser.add_argument("--start", type=float, default=0.0, help="Skip frames before this timestamp in seconds.")
|
||||
parser.add_argument("--end", type=float, default=None, help="Stop once this timestamp in seconds is exceeded.")
|
||||
args = parser.parse_args()
|
||||
|
||||
path = Path(args.path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(path)
|
||||
|
||||
daemon = ReplayDaemon()
|
||||
frame_iter = iter_directory_frames(path, max(args.frames_fps, 0.1)) if path.is_dir() else iter_video_frames(path)
|
||||
for now, frame_bgr in frame_iter:
|
||||
if now < args.start:
|
||||
continue
|
||||
if args.end is not None and now > args.end:
|
||||
break
|
||||
daemon.process_frame(now, frame_bgr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
from generate_value_roi_classifier_dataset import augment_mask, extract_value_mask # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
from .generate_value_roi_classifier_dataset import augment_mask, extract_value_mask
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExampleSpec:
|
||||
name: str
|
||||
speed_limit_mph: int
|
||||
image_path: str | None = None
|
||||
frame_path: str | None = None
|
||||
bbox: tuple[int, int, int, int] | None = None
|
||||
|
||||
|
||||
DEFAULT_EXAMPLES = (
|
||||
ExampleSpec(
|
||||
name="live15_runtime",
|
||||
speed_limit_mph=15,
|
||||
frame_path=".tmp/live_c4_capture/stopped_sign_road.jpg",
|
||||
bbox=(725, 253, 768, 314),
|
||||
),
|
||||
ExampleSpec(
|
||||
name="school20_crop",
|
||||
speed_limit_mph=20,
|
||||
image_path=".tmp/route_vision/frame_041_sign_tight.jpg",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="town30_crop",
|
||||
speed_limit_mph=30,
|
||||
image_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="town30_late_runtime",
|
||||
speed_limit_mph=30,
|
||||
frame_path=".tmp/vision_iter/seg10_5fps/frame_054.png",
|
||||
bbox=(887, 275, 931, 378),
|
||||
),
|
||||
ExampleSpec(
|
||||
name="town40_crop",
|
||||
speed_limit_mph=40,
|
||||
image_path=".tmp/route_12c_seg9_10/seg10_real40_crop.png",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="highway40_crop",
|
||||
speed_limit_mph=40,
|
||||
image_path=".tmp/speed_route_frames_seg2_10_20/t12_sign_crop.png",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Inject curated real runtime-style masks into the classifier dataset.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--variants-per-example", type=int, default=80, help="Augmented mask variants to generate per example.")
|
||||
parser.add_argument("--seed", type=int, default=20260330, help="Random seed.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_crop(spec: ExampleSpec):
|
||||
if spec.image_path:
|
||||
image = cv2.imread(spec.image_path)
|
||||
if image is None:
|
||||
raise FileNotFoundError(spec.image_path)
|
||||
return image
|
||||
if spec.frame_path and spec.bbox:
|
||||
frame = cv2.imread(spec.frame_path)
|
||||
if frame is None:
|
||||
raise FileNotFoundError(spec.frame_path)
|
||||
x1, y1, x2, y2 = spec.bbox
|
||||
crop = frame[y1:y2, x1:x2]
|
||||
if crop.size == 0:
|
||||
raise ValueError(f"{spec.name}: empty crop for bbox {spec.bbox}")
|
||||
return crop
|
||||
raise ValueError(f"{spec.name}: provide image_path or frame_path+bbox")
|
||||
|
||||
|
||||
def save_mask(workspace: Path, split: str, speed_limit_mph: int, stem: str, mask_bgr) -> None:
|
||||
output_dir = ensure_dir(workspace / "classifier" / split / str(speed_limit_mph))
|
||||
cv2.imwrite(str(output_dir / f"{stem}.png"), mask_bgr)
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> None:
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
rng = random.Random(args.seed)
|
||||
written = 0
|
||||
|
||||
for spec in DEFAULT_EXAMPLES:
|
||||
crop = load_crop(spec)
|
||||
mask = extract_value_mask(crop)
|
||||
if mask is None:
|
||||
print(f"{spec.name}: skipped, no mask extracted")
|
||||
continue
|
||||
|
||||
base_mask = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
|
||||
save_mask(workspace, "train", spec.speed_limit_mph, f"real_runtime_{spec.name}_base", base_mask)
|
||||
written += 1
|
||||
|
||||
for variant_index in range(max(args.variants_per_example, 0)):
|
||||
split = "val" if variant_index % 7 == 0 else "train"
|
||||
augmented = augment_mask(mask, rng)
|
||||
save_mask(workspace, split, spec.speed_limit_mph, f"real_runtime_{spec.name}_{variant_index:03d}", augmented)
|
||||
written += 1
|
||||
|
||||
print(f"{spec.name}: added {1 + max(args.variants_per_example, 0)} mask(s) for {spec.speed_limit_mph} mph")
|
||||
|
||||
remove_appledouble_files(workspace / "classifier" / "train")
|
||||
remove_appledouble_files(workspace / "classifier" / "val")
|
||||
print(f"Wrote {written} classifier mask image(s)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
def parse_label(label_path: Path, image_shape: tuple[int, int, int]):
|
||||
lines = [line.strip() for line in label_path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||||
if len(lines) != 1:
|
||||
raise ValueError(f"Expected exactly one box in {label_path}")
|
||||
|
||||
class_id, x_center, y_center, width, height = lines[0].split()
|
||||
image_height, image_width = image_shape[:2]
|
||||
x_center = float(x_center) * image_width
|
||||
y_center = float(y_center) * image_height
|
||||
width = float(width) * image_width
|
||||
height = float(height) * image_height
|
||||
x1 = x_center - width / 2
|
||||
y1 = y_center - height / 2
|
||||
x2 = x_center + width / 2
|
||||
y2 = y_center + height / 2
|
||||
return int(class_id), np.array([x1, y1, x2, y2], dtype=np.float32)
|
||||
|
||||
|
||||
def write_label(label_path: Path, class_id: int, box: np.ndarray, image_shape: tuple[int, int, int]):
|
||||
image_height, image_width = image_shape[:2]
|
||||
x1, y1, x2, y2 = box.tolist()
|
||||
x_center = ((x1 + x2) / 2) / image_width
|
||||
y_center = ((y1 + y2) / 2) / image_height
|
||||
width = (x2 - x1) / image_width
|
||||
height = (y2 - y1) / image_height
|
||||
label_path.write_text(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n", encoding="utf-8")
|
||||
|
||||
|
||||
def random_motion_blur(image: np.ndarray, rng: random.Random):
|
||||
size = rng.choice((3, 5, 7))
|
||||
if rng.random() < 0.5:
|
||||
kernel = np.zeros((size, size), dtype=np.float32)
|
||||
kernel[size // 2, :] = 1.0 / size
|
||||
else:
|
||||
kernel = np.zeros((size, size), dtype=np.float32)
|
||||
kernel[:, size // 2] = 1.0 / size
|
||||
return cv2.filter2D(image, -1, kernel)
|
||||
|
||||
|
||||
def augment_image(image: np.ndarray, box: np.ndarray, rng: random.Random):
|
||||
image_height, image_width = image.shape[:2]
|
||||
|
||||
scale = rng.uniform(0.92, 1.08)
|
||||
translate_x = rng.uniform(-0.05, 0.05) * image_width
|
||||
translate_y = rng.uniform(-0.04, 0.04) * image_height
|
||||
center = (image_width / 2, image_height / 2)
|
||||
matrix = cv2.getRotationMatrix2D(center, rng.uniform(-1.5, 1.5), scale)
|
||||
matrix[0, 2] += translate_x
|
||||
matrix[1, 2] += translate_y
|
||||
|
||||
warped = cv2.warpAffine(
|
||||
image,
|
||||
matrix,
|
||||
(image_width, image_height),
|
||||
flags=cv2.INTER_LINEAR,
|
||||
borderMode=cv2.BORDER_REPLICATE,
|
||||
)
|
||||
|
||||
corners = np.array([
|
||||
[box[0], box[1], 1.0],
|
||||
[box[2], box[1], 1.0],
|
||||
[box[2], box[3], 1.0],
|
||||
[box[0], box[3], 1.0],
|
||||
], dtype=np.float32)
|
||||
transformed = corners @ matrix.T
|
||||
x_coords = transformed[:, 0]
|
||||
y_coords = transformed[:, 1]
|
||||
warped_box = np.array([
|
||||
np.clip(np.min(x_coords), 0, image_width - 1),
|
||||
np.clip(np.min(y_coords), 0, image_height - 1),
|
||||
np.clip(np.max(x_coords), 0, image_width - 1),
|
||||
np.clip(np.max(y_coords), 0, image_height - 1),
|
||||
], dtype=np.float32)
|
||||
|
||||
alpha = rng.uniform(0.85, 1.18)
|
||||
beta = rng.uniform(-18.0, 16.0)
|
||||
augmented = cv2.convertScaleAbs(warped, alpha=alpha, beta=beta)
|
||||
|
||||
if rng.random() < 0.55:
|
||||
augmented = cv2.GaussianBlur(augmented, (3, 3), rng.uniform(0.1, 1.0))
|
||||
if rng.random() < 0.35:
|
||||
augmented = random_motion_blur(augmented, rng)
|
||||
if rng.random() < 0.45:
|
||||
noise = rng.uniform(4.0, 12.0)
|
||||
augmented = np.clip(augmented.astype(np.float32) + np.random.normal(0.0, noise, augmented.shape), 0, 255).astype(np.uint8)
|
||||
|
||||
return augmented, warped_box
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Oversample bootstrapped real detector frames with light augmentation.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--split", default="train", choices=("train", "val"), help="Detector split to augment.")
|
||||
parser.add_argument("--variants-per-image", type=int, default=80, help="How many augmented variants to generate for each real_*.jpg frame.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
image_dir = workspace / "detector" / "images" / args.split
|
||||
label_dir = workspace / "detector" / "labels" / args.split
|
||||
ensure_dir(image_dir)
|
||||
ensure_dir(label_dir)
|
||||
|
||||
rng = random.Random(42)
|
||||
base_images = sorted(image_dir.glob("real_*.jpg"))
|
||||
created = 0
|
||||
for image_path in base_images:
|
||||
label_path = label_dir / f"{image_path.stem}.txt"
|
||||
image = cv2.imread(str(image_path))
|
||||
if image is None or not label_path.is_file():
|
||||
continue
|
||||
|
||||
class_id, box = parse_label(label_path, image.shape)
|
||||
for variant_index in range(args.variants_per_image):
|
||||
augmented, warped_box = augment_image(image, box, rng)
|
||||
if warped_box[2] - warped_box[0] < 10 or warped_box[3] - warped_box[1] < 12:
|
||||
continue
|
||||
|
||||
output_stem = f"{image_path.stem}_aug_{variant_index:03d}"
|
||||
output_image = image_dir / f"{output_stem}.jpg"
|
||||
output_label = label_dir / f"{output_stem}.txt"
|
||||
cv2.imwrite(str(output_image), augmented, [cv2.IMWRITE_JPEG_QUALITY, 92])
|
||||
write_label(output_label, class_id, warped_box, augmented.shape)
|
||||
created += 1
|
||||
|
||||
print(f"Augmented {len(base_images)} real detector images into {created} variants")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExampleSpec:
|
||||
name: str
|
||||
detector_class: int
|
||||
frame_path: str | None = None
|
||||
frame_dir: str | None = None
|
||||
template_path: str | None = None
|
||||
bbox_override: tuple[int, int, int, int] | None = None
|
||||
|
||||
|
||||
DEFAULT_EXAMPLES = (
|
||||
ExampleSpec(
|
||||
name="route_seg2_t12_speed40",
|
||||
detector_class=0,
|
||||
frame_path=".tmp/speed_route_frames_seg2_10_20/t12.png",
|
||||
template_path=".tmp/speed_route_frames_seg2_10_20/t12_sign_crop.png",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="route_seg38_school20",
|
||||
detector_class=2,
|
||||
frame_path=".tmp/route_vision/seg38_frames/frame_041.jpg",
|
||||
template_path=".tmp/route_vision/frame_041_sign_tight.jpg",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="route_seg10_early_speed40",
|
||||
detector_class=0,
|
||||
frame_path=".tmp/route_12c_seg9_10/seg10_early/frame_005.png",
|
||||
template_path=".tmp/route_12c_seg9_10/seg10_real40_crop.png",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="route_seg10_early_speed30",
|
||||
detector_class=0,
|
||||
frame_path=".tmp/route_12c_seg9_10/seg10_early/frame_012.png",
|
||||
template_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
|
||||
),
|
||||
ExampleSpec(
|
||||
name="route_seg10_late_speed30",
|
||||
detector_class=0,
|
||||
frame_path=".tmp/vision_iter/seg10_5fps/frame_054.png",
|
||||
template_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
|
||||
bbox_override=(885, 250, 941, 386),
|
||||
),
|
||||
ExampleSpec(
|
||||
name="route_seg10_late_speed30_clear",
|
||||
detector_class=0,
|
||||
frame_path=".tmp/vision_iter/seg10_5fps/frame_052.png",
|
||||
template_path=".tmp/route_12c_seg9_10/seg10_real30_crop.png",
|
||||
bbox_override=(829, 284, 870, 382),
|
||||
),
|
||||
ExampleSpec(
|
||||
name="live_capture_speed15",
|
||||
detector_class=0,
|
||||
frame_path=".tmp/live_c4_capture/stopped_sign_road.jpg",
|
||||
template_path=".tmp/live_c4_capture/stopped_sign_crop_manual.png",
|
||||
bbox_override=(724, 258, 763, 307),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def detector_label_line(detector_class: int, x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> str:
|
||||
image_h, image_w = image_shape[:2]
|
||||
x_center = ((x1 + x2) / 2) / image_w
|
||||
y_center = ((y1 + y2) / 2) / image_h
|
||||
width = (x2 - x1) / image_w
|
||||
height = (y2 - y1) / image_h
|
||||
return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
|
||||
|
||||
|
||||
def match_template(frame: cv2.typing.MatLike, template: cv2.typing.MatLike):
|
||||
result = cv2.matchTemplate(frame, template, cv2.TM_CCOEFF_NORMED)
|
||||
_, max_value, _, max_location = cv2.minMaxLoc(result)
|
||||
template_h, template_w = template.shape[:2]
|
||||
x1, y1 = max_location
|
||||
x2 = x1 + template_w
|
||||
y2 = y1 + template_h
|
||||
return float(max_value), (x1, y1, x2, y2)
|
||||
|
||||
|
||||
def resolve_match(spec: ExampleSpec):
|
||||
if spec.template_path is None:
|
||||
raise FileNotFoundError(f"{spec.name}: missing template_path")
|
||||
template = cv2.imread(spec.template_path)
|
||||
if template is None:
|
||||
raise FileNotFoundError(f"{spec.name}: failed to read template {spec.template_path}")
|
||||
|
||||
if spec.frame_path:
|
||||
frame = cv2.imread(spec.frame_path)
|
||||
if frame is None:
|
||||
raise FileNotFoundError(f"{spec.name}: failed to read frame {spec.frame_path}")
|
||||
if spec.bbox_override is not None:
|
||||
return Path(spec.frame_path), frame, 1.0, spec.bbox_override
|
||||
confidence, bbox = match_template(frame, template)
|
||||
return Path(spec.frame_path), frame, confidence, bbox
|
||||
|
||||
if spec.frame_dir:
|
||||
best = None
|
||||
for frame_path in sorted(Path(spec.frame_dir).glob("*")):
|
||||
frame = cv2.imread(str(frame_path))
|
||||
if frame is None:
|
||||
continue
|
||||
if frame.shape[0] < template.shape[0] or frame.shape[1] < template.shape[1]:
|
||||
continue
|
||||
confidence, bbox = match_template(frame, template)
|
||||
if best is None or confidence > best[2]:
|
||||
best = (frame_path, frame, confidence, bbox)
|
||||
if best is None:
|
||||
raise FileNotFoundError(f"{spec.name}: no readable frames found in {spec.frame_dir}")
|
||||
return best
|
||||
|
||||
raise ValueError(f"{spec.name}: one of frame_path or frame_dir must be provided")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import a few known real comma sign examples into the detector dataset.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--split", default="train", choices=("train", "val"), help="Detector dataset split to populate.")
|
||||
parser.add_argument("--manifest", type=Path, help="Optional CSV manifest path. Defaults to <workspace>/review/real_detector_examples.csv.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
split = args.split
|
||||
image_dir = ensure_dir(workspace / "detector" / "images" / split)
|
||||
label_dir = ensure_dir(workspace / "detector" / "labels" / split)
|
||||
manifest_path = args.manifest.resolve() if args.manifest else (ensure_dir(workspace / "review") / "real_detector_examples.csv")
|
||||
|
||||
records: list[dict[str, object]] = []
|
||||
for spec in DEFAULT_EXAMPLES:
|
||||
frame_path, frame_bgr, confidence, (x1, y1, x2, y2) = resolve_match(spec)
|
||||
stem = f"real_{spec.name}"
|
||||
image_path = image_dir / f"{stem}.jpg"
|
||||
label_path = label_dir / f"{stem}.txt"
|
||||
|
||||
cv2.imwrite(str(image_path), frame_bgr, [cv2.IMWRITE_JPEG_QUALITY, 95])
|
||||
label_path.write_text(detector_label_line(spec.detector_class, x1, y1, x2, y2, frame_bgr.shape), encoding="utf-8")
|
||||
|
||||
records.append({
|
||||
"name": spec.name,
|
||||
"split": split,
|
||||
"source_frame": str(frame_path),
|
||||
"template_path": spec.template_path or "",
|
||||
"template_match_confidence": round(confidence, 6),
|
||||
"detector_class": spec.detector_class,
|
||||
"bbox_x1": x1,
|
||||
"bbox_y1": y1,
|
||||
"bbox_x2": x2,
|
||||
"bbox_y2": y2,
|
||||
"dataset_image": str(image_path),
|
||||
"dataset_label": str(label_path),
|
||||
})
|
||||
|
||||
with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file:
|
||||
fieldnames = [
|
||||
"name",
|
||||
"split",
|
||||
"source_frame",
|
||||
"template_path",
|
||||
"template_match_confidence",
|
||||
"detector_class",
|
||||
"bbox_x1",
|
||||
"bbox_y1",
|
||||
"bbox_x2",
|
||||
"bbox_y2",
|
||||
"dataset_image",
|
||||
"dataset_label",
|
||||
]
|
||||
writer = csv.DictWriter(manifest_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
|
||||
print(f"Imported {len(records)} real detector examples into {split} split")
|
||||
print(f"Manifest: {manifest_path}")
|
||||
for record in records:
|
||||
print(
|
||||
f"{record['name']}: conf={record['template_match_confidence']} "
|
||||
f"bbox=({record['bbox_x1']},{record['bbox_y1']},{record['bbox_x2']},{record['bbox_y2']})"
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, VALUE_LABEL_FIELDS, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, VALUE_LABEL_FIELDS, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build a classifier crop dataset from detector labels and a value label manifest.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--labels-csv", type=Path, help="CSV manifest describing which labeled detector images map to which posted speed values.")
|
||||
parser.add_argument("--default-padding", type=float, default=0.10, help="Default crop padding ratio when a row does not provide one.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing classifier crops.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_rows(csv_path: Path) -> list[dict[str, str]]:
|
||||
with csv_path.open("r", encoding="utf-8", newline="") as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
missing = [field for field in VALUE_LABEL_FIELDS if field not in (reader.fieldnames or [])]
|
||||
if missing:
|
||||
raise ValueError(f"Missing required CSV columns: {', '.join(missing)}")
|
||||
return [row for row in reader if (row.get("image_path") or "").strip()]
|
||||
|
||||
|
||||
def resolve_image_path(workspace: Path, image_path_text: str) -> Path:
|
||||
image_path = Path(image_path_text).expanduser()
|
||||
if image_path.is_file():
|
||||
return image_path.resolve()
|
||||
|
||||
candidate = (workspace / image_path_text).resolve()
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
basename = Path(image_path_text).name
|
||||
for search_root in (workspace / "detector" / "images", workspace / "review" / "images"):
|
||||
if not search_root.is_dir():
|
||||
continue
|
||||
for found in search_root.rglob(basename):
|
||||
if found.is_file():
|
||||
return found.resolve()
|
||||
|
||||
raise FileNotFoundError(f"Image not found: {image_path_text}")
|
||||
|
||||
|
||||
def resolve_label_path(workspace: Path, image_path: Path, label_path_text: str, split: str) -> Path:
|
||||
if label_path_text:
|
||||
label_path = Path(label_path_text).expanduser()
|
||||
if label_path.is_file():
|
||||
return label_path.resolve()
|
||||
candidate = (workspace / label_path_text).resolve()
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
raise FileNotFoundError(f"Label path not found: {label_path_text}")
|
||||
|
||||
train_label = workspace / "detector" / "labels" / split / f"{image_path.stem}.txt"
|
||||
if train_label.is_file():
|
||||
return train_label.resolve()
|
||||
|
||||
for split_name in ("train", "val"):
|
||||
candidate = workspace / "detector" / "labels" / split_name / f"{image_path.stem}.txt"
|
||||
if candidate.is_file():
|
||||
return candidate.resolve()
|
||||
|
||||
raise FileNotFoundError(f"Detector label not found for {image_path.name}")
|
||||
|
||||
|
||||
def parse_yolo_labels(label_path: Path) -> list[tuple[int, float, float, float, float]]:
|
||||
boxes = []
|
||||
with label_path.open("r", encoding="utf-8") as label_file:
|
||||
for raw_line in label_file:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
class_id, x_center, y_center, width, height = line.split(maxsplit=4)
|
||||
boxes.append((int(class_id), float(x_center), float(y_center), float(width), float(height)))
|
||||
return boxes
|
||||
|
||||
|
||||
def crop_box(image, yolo_box: tuple[int, float, float, float, float], padding: float):
|
||||
_, x_center, y_center, width, height = yolo_box
|
||||
image_height, image_width = image.shape[:2]
|
||||
|
||||
box_width = width * image_width
|
||||
box_height = height * image_height
|
||||
pad_width = box_width * padding
|
||||
pad_height = box_height * padding
|
||||
|
||||
x1 = max(int(round((x_center * image_width) - box_width / 2 - pad_width)), 0)
|
||||
y1 = max(int(round((y_center * image_height) - box_height / 2 - pad_height)), 0)
|
||||
x2 = min(int(round((x_center * image_width) + box_width / 2 + pad_width)), image_width)
|
||||
y2 = min(int(round((y_center * image_height) + box_height / 2 + pad_height)), image_height)
|
||||
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
raise ValueError("Resolved crop has no area")
|
||||
return image[y1:y2, x1:x2]
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> None:
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
labels_csv = args.labels_csv.resolve() if args.labels_csv else (workspace / "classifier" / "value_labels.csv")
|
||||
rows = load_rows(labels_csv)
|
||||
|
||||
built = 0
|
||||
for row in rows:
|
||||
split = (row.get("split") or "train").strip().lower()
|
||||
if split not in ("train", "val"):
|
||||
raise ValueError(f"Unsupported split '{split}' in {labels_csv}")
|
||||
|
||||
speed_limit = (row.get("speed_limit_mph") or "").strip()
|
||||
if not speed_limit:
|
||||
raise ValueError(f"Missing speed_limit_mph for image {row['image_path']}")
|
||||
|
||||
bbox_index = int((row.get("bbox_index") or "0").strip())
|
||||
padding_text = (row.get("padding") or "").strip()
|
||||
padding = float(padding_text) if padding_text else args.default_padding
|
||||
|
||||
image_path = resolve_image_path(workspace, row["image_path"])
|
||||
label_path = resolve_label_path(workspace, image_path, (row.get("label_path") or "").strip(), split)
|
||||
boxes = parse_yolo_labels(label_path)
|
||||
if bbox_index >= len(boxes):
|
||||
raise IndexError(f"bbox_index {bbox_index} out of range for {label_path}")
|
||||
|
||||
image = cv2.imread(str(image_path))
|
||||
if image is None:
|
||||
raise RuntimeError(f"Failed to read {image_path}")
|
||||
|
||||
crop = crop_box(image, boxes[bbox_index], padding)
|
||||
output_dir = ensure_dir(workspace / "classifier" / split / speed_limit)
|
||||
output_path = output_dir / f"{image_path.stem}_bbox{bbox_index}.jpg"
|
||||
if output_path.exists() and not args.overwrite:
|
||||
continue
|
||||
|
||||
cv2.imwrite(str(output_path), crop)
|
||||
built += 1
|
||||
|
||||
remove_appledouble_files(workspace / "classifier")
|
||||
print(f"Built {built} classifier crop(s) into {workspace / 'classifier'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_WORKSPACE = REPO_ROOT / ".tmp" / "speed_limit_training"
|
||||
DEFAULT_DEBUG_BASE = Path("/data/media/0/vision_speed_limit_debug")
|
||||
REPO_ASSET_DIR = REPO_ROOT / "starpilot" / "assets" / "vision_models"
|
||||
DEFAULT_LOCAL_CLIP_ROOT = REPO_ROOT / ".tmp" / "live_route_clips" / "bookmark_windows" / "data" / "media" / "0" / "realdata"
|
||||
DEFAULT_LOCAL_QLOG_MTIMES = REPO_ROOT / ".tmp" / "live_routes_meta" / "qlog_mtimes.txt"
|
||||
DEFAULT_LOCAL_FILES_MANIFEST = REPO_ROOT / ".tmp" / "live_routes_meta" / "files.txt"
|
||||
DEFAULT_LOCAL_SESSION_ROUTE_MAP = REPO_ROOT / ".tmp" / "live_routes_meta" / "session_route_map.json"
|
||||
DEFAULT_EXTERNAL_ROOT = Path("/Volumes/T5/starpilot_speed_limit")
|
||||
|
||||
DETECTOR_CLASS_NAMES = (
|
||||
"regulatory_speed_limit",
|
||||
"advisory_speed_limit",
|
||||
"school_zone_speed_limit",
|
||||
)
|
||||
DEFAULT_SPEED_VALUES = (15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75)
|
||||
|
||||
DETECTOR_EXPORT_NAME = "speed_limit_us_detector.onnx"
|
||||
CLASSIFIER_EXPORT_NAME = "speed_limit_us_value_classifier.onnx"
|
||||
|
||||
|
||||
def resolve_workspace(path: str | Path | None) -> Path:
|
||||
return Path(path).expanduser().resolve() if path else DEFAULT_WORKSPACE
|
||||
|
||||
|
||||
def preferred_external_root() -> Path | None:
|
||||
return DEFAULT_EXTERNAL_ROOT if DEFAULT_EXTERNAL_ROOT.is_dir() else None
|
||||
|
||||
|
||||
def preferred_analysis_root() -> Path | None:
|
||||
external_root = preferred_external_root()
|
||||
if external_root is None:
|
||||
return None
|
||||
analysis_root = external_root / "analysis"
|
||||
return analysis_root if analysis_root.is_dir() else external_root
|
||||
|
||||
|
||||
def preferred_clip_root() -> Path:
|
||||
external_root = preferred_analysis_root()
|
||||
if external_root is not None:
|
||||
return external_root / "live_route_clips" / "bookmark_windows" / "data" / "media" / "0" / "realdata"
|
||||
return DEFAULT_LOCAL_CLIP_ROOT
|
||||
|
||||
|
||||
def preferred_qlog_mtimes_path() -> Path:
|
||||
external_root = preferred_analysis_root()
|
||||
if external_root is not None:
|
||||
return external_root / "live_routes_meta" / "qlog_mtimes.txt"
|
||||
return DEFAULT_LOCAL_QLOG_MTIMES
|
||||
|
||||
|
||||
def preferred_files_manifest_path() -> Path:
|
||||
external_root = preferred_analysis_root()
|
||||
if external_root is not None:
|
||||
return external_root / "live_routes_meta" / "files.txt"
|
||||
return DEFAULT_LOCAL_FILES_MANIFEST
|
||||
|
||||
|
||||
def preferred_session_route_map_path() -> Path:
|
||||
external_root = preferred_analysis_root()
|
||||
if external_root is not None:
|
||||
return external_root / "live_routes_meta" / "session_route_map.json"
|
||||
return DEFAULT_LOCAL_SESSION_ROUTE_MAP
|
||||
|
||||
|
||||
def load_session_route_map(path: str | Path | None = None) -> dict[str, str]:
|
||||
route_map_path = Path(path).expanduser().resolve() if path else preferred_session_route_map_path()
|
||||
if not route_map_path.is_file():
|
||||
return {}
|
||||
data = json.loads(route_map_path.read_text(encoding="utf-8"))
|
||||
return {str(key): str(value) for key, value in data.items() if key and value}
|
||||
|
||||
|
||||
def default_raw_root(workspace: str | Path | None = None) -> Path:
|
||||
resolved = resolve_workspace(workspace)
|
||||
if resolved.parent.name == "workspace":
|
||||
return resolved.parent.parent / "raw"
|
||||
return resolved / "raw"
|
||||
|
||||
|
||||
def ensure_dir(path: Path) -> Path:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def write_text(path: Path, text: str, force: bool = False) -> None:
|
||||
if path.exists() and not force:
|
||||
return
|
||||
ensure_dir(path.parent)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
|
||||
|
||||
def write_csv_header(path: Path, fieldnames: list[str], force: bool = False) -> None:
|
||||
if path.exists() and not force:
|
||||
return
|
||||
ensure_dir(path.parent)
|
||||
with path.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
|
||||
|
||||
def read_jsonl(path: Path) -> list[dict]:
|
||||
records: list[dict] = []
|
||||
with path.open("r", encoding="utf-8") as jsonl_file:
|
||||
for line in jsonl_file:
|
||||
line = line.strip()
|
||||
if line:
|
||||
records.append(json.loads(line))
|
||||
return records
|
||||
|
||||
|
||||
def latest_debug_sessions(debug_base: Path, count: int = 1) -> list[Path]:
|
||||
if not debug_base.is_dir():
|
||||
return []
|
||||
sessions = sorted((path for path in debug_base.iterdir() if path.is_dir()), reverse=True)
|
||||
return sessions[:max(count, 0)]
|
||||
|
||||
|
||||
def detector_dataset_yaml(workspace: Path) -> str:
|
||||
detector_root = workspace / "detector"
|
||||
lines = [
|
||||
f"path: {detector_root}",
|
||||
"train: images/train",
|
||||
"val: images/val",
|
||||
"names:",
|
||||
]
|
||||
for index, class_name in enumerate(DETECTOR_CLASS_NAMES):
|
||||
lines.append(f" {index}: {class_name}")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def workspace_readme(speed_values: tuple[int, ...]) -> str:
|
||||
supported_values = ", ".join(str(value) for value in speed_values)
|
||||
return f"""# Speed Limit Vision Training Workspace
|
||||
|
||||
This workspace is generated by `scripts/speed_limit_vision/init_workspace.py`.
|
||||
|
||||
Directory layout:
|
||||
|
||||
- `detector/images/train` and `detector/images/val`: detector training images
|
||||
- `detector/labels/train` and `detector/labels/val`: YOLO detector labels
|
||||
- `classifier/value_labels.csv`: value labels used to crop detector boxes into classifier folders
|
||||
- `classifier/train` and `classifier/val`: classifier-ready crop folders
|
||||
- `review/images`: imported snapshots from live debug sessions
|
||||
- `review/bookmarks.csv`: bookmark/publish/candidate manifest built from debug sessions
|
||||
- `review/leadins/frames` and `review/leadins/contact_sheets`: sampled frames from 5-second pre-bookmark route windows
|
||||
- `review/bookmark_leadins.csv`: manifest describing sampled pre-bookmark review frames
|
||||
- `exports`: exported ONNX models
|
||||
- `runs`: training outputs
|
||||
|
||||
Suggested detector classes:
|
||||
|
||||
- `regulatory_speed_limit`
|
||||
- `advisory_speed_limit`
|
||||
- `school_zone_speed_limit`
|
||||
|
||||
Suggested classifier values:
|
||||
|
||||
- `{supported_values}`
|
||||
|
||||
Generated manifests:
|
||||
|
||||
- `manifests/raw_sources.csv`: raw public/comma source provenance
|
||||
- `manifests/public_detector_samples.csv`: imported detector samples and sign metadata
|
||||
- `manifests/public_classifier_samples.csv`: imported classifier-ready value samples
|
||||
"""
|
||||
|
||||
|
||||
BOOKMARK_MANIFEST_FIELDS = [
|
||||
"record_key",
|
||||
"source_name",
|
||||
"source_region",
|
||||
"source_device",
|
||||
"source_driver",
|
||||
"session_id",
|
||||
"event_index",
|
||||
"event",
|
||||
"session_seconds",
|
||||
"wall_time",
|
||||
"road_name",
|
||||
"stream",
|
||||
"status",
|
||||
"candidate_speed_limit_mph",
|
||||
"candidate_confidence",
|
||||
"speed_limit_mph",
|
||||
"confidence",
|
||||
"published_speed_limit_mph",
|
||||
"published_confidence",
|
||||
"bookmark_count",
|
||||
"snapshot_path",
|
||||
"source_session_path",
|
||||
]
|
||||
|
||||
|
||||
BOOKMARK_LEADIN_MANIFEST_FIELDS = [
|
||||
"record_key",
|
||||
"source_name",
|
||||
"source_region",
|
||||
"source_device",
|
||||
"source_driver",
|
||||
"session_id",
|
||||
"bookmark_number",
|
||||
"route",
|
||||
"segment",
|
||||
"segment_offset_s",
|
||||
"leadin_start_s",
|
||||
"sample_offset_s",
|
||||
"window_result",
|
||||
"published_values",
|
||||
"candidate_values",
|
||||
"frame_path",
|
||||
"contact_sheet_path",
|
||||
"source_video_path",
|
||||
]
|
||||
|
||||
|
||||
VALUE_LABEL_FIELDS = [
|
||||
"image_path",
|
||||
"split",
|
||||
"speed_limit_mph",
|
||||
"bbox_index",
|
||||
"padding",
|
||||
"label_path",
|
||||
]
|
||||
|
||||
|
||||
RAW_SOURCE_FIELDS = [
|
||||
"source_name",
|
||||
"source_version",
|
||||
"source_license",
|
||||
"source_type",
|
||||
"raw_path",
|
||||
"notes",
|
||||
]
|
||||
|
||||
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS = [
|
||||
"record_key",
|
||||
"source_name",
|
||||
"split",
|
||||
"image_path",
|
||||
"label_path",
|
||||
"annotation_path",
|
||||
"source_image_id",
|
||||
"class_name",
|
||||
"speed_limit_mph",
|
||||
"sign_code",
|
||||
"bbox_left",
|
||||
"bbox_top",
|
||||
"bbox_right",
|
||||
"bbox_bottom",
|
||||
]
|
||||
|
||||
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS = [
|
||||
"record_key",
|
||||
"source_name",
|
||||
"split",
|
||||
"image_path",
|
||||
"speed_limit_mph",
|
||||
"bbox_index",
|
||||
"label_path",
|
||||
"source_image_id",
|
||||
"sign_code",
|
||||
]
|
||||
@@ -0,0 +1,270 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from dataclasses import dataclass
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
from scripts.speed_limit_vision import common
|
||||
|
||||
|
||||
API_HOST = os.getenv("COMMA_API_HOST", "https://api.commadotai.com")
|
||||
DEFAULT_FILES_MANIFEST = common.preferred_files_manifest_path()
|
||||
STREAM_FILE_NAMES = {
|
||||
"fcamera": {"fcamera.hevc"},
|
||||
"qlog": {"qlog.zst", "qlog.bz2", "qlog"},
|
||||
"rlog": {"rlog.zst", "rlog.bz2", "rlog"},
|
||||
"qcamera": {"qcamera.ts"},
|
||||
"dcamera": {"dcamera.hevc"},
|
||||
"ecamera": {"ecamera.hevc"},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteRequest:
|
||||
dongle_id: str
|
||||
log_id: str
|
||||
segment_filter: set[int] | None
|
||||
|
||||
@property
|
||||
def canonical_name(self) -> str:
|
||||
return f"{self.dongle_id}|{self.log_id}"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Download route files directly from comma connect into the speed-limit review layout.")
|
||||
parser.add_argument("routes", nargs="*", help="Route ids like 'dongle/logid' or segment ids like 'dongle/logid--9'.")
|
||||
parser.add_argument("--routes-file", type=Path, help="Optional file containing one route id per line.")
|
||||
parser.add_argument("--clip-root", type=Path, default=common.preferred_clip_root(), help="Destination root for downloaded segment directories.")
|
||||
parser.add_argument("--qlog-mtimes", type=Path, default=common.preferred_qlog_mtimes_path(), help="Path to qlog mtime manifest used by bookmark replay.")
|
||||
parser.add_argument("--files-manifest", type=Path, default=DEFAULT_FILES_MANIFEST, help="Path to downloaded-files manifest.")
|
||||
parser.add_argument("--streams", default="fcamera,qlog", help="Comma-separated stream set: fcamera,qlog,rlog,qcamera,dcamera,ecamera.")
|
||||
parser.add_argument("--segments", help="Optional segment filter, e.g. '0,2,5-8'.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Redownload files even if they already exist locally.")
|
||||
parser.add_argument("--timeout", type=float, default=60.0, help="HTTP timeout in seconds.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_token() -> str:
|
||||
token = os.getenv("COMMA_JWT", "").strip()
|
||||
if token:
|
||||
return token
|
||||
|
||||
auth_path = Path.home() / ".comma" / "auth.json"
|
||||
if not auth_path.is_file():
|
||||
raise FileNotFoundError(f"Missing auth token at {auth_path}. Run python3 tools/lib/auth.py first or set COMMA_JWT.")
|
||||
auth = json.loads(auth_path.read_text(encoding="utf-8"))
|
||||
token = auth.get("access_token", "").strip()
|
||||
if not token:
|
||||
raise ValueError(f"No access_token in {auth_path}")
|
||||
return token
|
||||
|
||||
|
||||
def parse_segment_spec(spec: str | None) -> set[int] | None:
|
||||
if not spec:
|
||||
return None
|
||||
|
||||
selected: set[int] = set()
|
||||
for part in spec.split(","):
|
||||
part = part.strip()
|
||||
if not part:
|
||||
continue
|
||||
if "-" in part:
|
||||
start_text, end_text = part.split("-", 1)
|
||||
start = int(start_text)
|
||||
end = int(end_text)
|
||||
selected.update(range(min(start, end), max(start, end) + 1))
|
||||
else:
|
||||
selected.add(int(part))
|
||||
return selected
|
||||
|
||||
|
||||
def load_route_inputs(args: argparse.Namespace) -> list[str]:
|
||||
raw_routes = list(args.routes)
|
||||
if args.routes_file:
|
||||
raw_routes.extend(line.strip() for line in args.routes_file.expanduser().resolve().read_text(encoding="utf-8").splitlines())
|
||||
if not raw_routes:
|
||||
raise ValueError("No routes provided.")
|
||||
return [route for route in raw_routes if route and not route.lstrip().startswith("#")]
|
||||
|
||||
|
||||
def parse_route_request(raw: str, default_segments: set[int] | None) -> RouteRequest:
|
||||
text = raw.strip().strip("'\"")
|
||||
text = text.replace("|", "/")
|
||||
match = re.fullmatch(r"([0-9a-f]{16})/([^/]+)", text)
|
||||
if not match:
|
||||
raise ValueError(f"Unrecognized route id: {raw}")
|
||||
|
||||
dongle_id = match.group(1)
|
||||
tail = match.group(2)
|
||||
segment_filter = set(default_segments) if default_segments else None
|
||||
|
||||
parts = tail.split("--")
|
||||
if len(parts) == 3 and parts[-1].isdigit():
|
||||
log_id = "--".join(parts[:2])
|
||||
segment = int(parts[-1])
|
||||
if segment_filter is None:
|
||||
segment_filter = {segment}
|
||||
else:
|
||||
segment_filter.add(segment)
|
||||
else:
|
||||
log_id = tail
|
||||
|
||||
if len(log_id) != 20:
|
||||
raise ValueError(f"Invalid log id in route: {raw}")
|
||||
return RouteRequest(dongle_id=dongle_id, log_id=log_id, segment_filter=segment_filter)
|
||||
|
||||
|
||||
def merge_route_requests(raw_routes: list[str], default_segments: set[int] | None) -> list[RouteRequest]:
|
||||
merged: dict[tuple[str, str], set[int] | None] = {}
|
||||
for raw in raw_routes:
|
||||
request = parse_route_request(raw, default_segments)
|
||||
key = (request.dongle_id, request.log_id)
|
||||
if key not in merged:
|
||||
merged[key] = None if request.segment_filter is None else set(request.segment_filter)
|
||||
continue
|
||||
if merged[key] is None or request.segment_filter is None:
|
||||
merged[key] = None
|
||||
else:
|
||||
merged[key].update(request.segment_filter)
|
||||
return [RouteRequest(dongle_id=dongle_id, log_id=log_id, segment_filter=segments) for (dongle_id, log_id), segments in sorted(merged.items())]
|
||||
|
||||
|
||||
def selected_file_names(streams_csv: str) -> set[str]:
|
||||
selected: set[str] = set()
|
||||
for stream in streams_csv.split(","):
|
||||
stream = stream.strip()
|
||||
if not stream:
|
||||
continue
|
||||
if stream not in STREAM_FILE_NAMES:
|
||||
raise ValueError(f"Unknown stream '{stream}'. Valid streams: {', '.join(sorted(STREAM_FILE_NAMES))}")
|
||||
selected.update(STREAM_FILE_NAMES[stream])
|
||||
return selected
|
||||
|
||||
|
||||
def api_get_json(session: requests.Session, endpoint: str, timeout: float):
|
||||
response = session.get(f"{API_HOST}/{endpoint.lstrip('/')}", timeout=timeout)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
|
||||
def iter_route_file_urls(files_payload: dict, file_names: set[str]):
|
||||
for value in files_payload.values():
|
||||
if not isinstance(value, list):
|
||||
continue
|
||||
for url in value:
|
||||
parsed = urlparse(url)
|
||||
parts = parsed.path.strip("/").split("/")
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
segment_text = parts[-2]
|
||||
file_name = parts[-1]
|
||||
if not segment_text.isdigit() or file_name not in file_names:
|
||||
continue
|
||||
yield int(segment_text), file_name, url
|
||||
|
||||
|
||||
def download_to_path(session: requests.Session, url: str, dest_path: Path, overwrite: bool, timeout: float) -> int | None:
|
||||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if dest_path.exists() and not overwrite:
|
||||
return int(dest_path.stat().st_mtime)
|
||||
|
||||
response = session.get(url, stream=True, timeout=timeout)
|
||||
response.raise_for_status()
|
||||
temp_path = dest_path.with_suffix(dest_path.suffix + ".part")
|
||||
with temp_path.open("wb") as handle:
|
||||
for chunk in response.iter_content(chunk_size=1 << 20):
|
||||
if chunk:
|
||||
handle.write(chunk)
|
||||
temp_path.replace(dest_path)
|
||||
|
||||
last_modified = response.headers.get("Last-Modified")
|
||||
if last_modified:
|
||||
epoch = int(parsedate_to_datetime(last_modified).timestamp())
|
||||
os.utime(dest_path, (epoch, epoch))
|
||||
return epoch
|
||||
return int(dest_path.stat().st_mtime)
|
||||
|
||||
|
||||
def load_manifest_lines(path: Path) -> dict[str, str]:
|
||||
if not path.is_file():
|
||||
return {}
|
||||
lines: dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
key = line.split(" ", 1)[0]
|
||||
lines[key] = line
|
||||
return lines
|
||||
|
||||
|
||||
def write_manifest(path: Path, lines: dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
for key in sorted(lines):
|
||||
handle.write(lines[key] + "\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
token = read_token()
|
||||
route_requests = merge_route_requests(load_route_inputs(args), parse_segment_spec(args.segments))
|
||||
file_names = selected_file_names(args.streams)
|
||||
clip_root = args.clip_root.expanduser().resolve()
|
||||
qlog_mtimes_path = args.qlog_mtimes.expanduser().resolve()
|
||||
files_manifest_path = args.files_manifest.expanduser().resolve()
|
||||
|
||||
api_session = requests.Session()
|
||||
api_session.headers.update({
|
||||
"Authorization": f"JWT {token}",
|
||||
"User-Agent": "OpenpilotTools",
|
||||
})
|
||||
download_session = requests.Session()
|
||||
download_session.headers.update({
|
||||
"User-Agent": "OpenpilotTools",
|
||||
})
|
||||
|
||||
qlog_lines = load_manifest_lines(qlog_mtimes_path)
|
||||
file_lines = load_manifest_lines(files_manifest_path)
|
||||
|
||||
for request in route_requests:
|
||||
route_meta = api_get_json(api_session, f"v1/route/{request.canonical_name}", timeout=args.timeout)
|
||||
files_payload = api_get_json(api_session, f"v1/route/{request.canonical_name}/files", timeout=args.timeout)
|
||||
log_id = request.log_id
|
||||
start_time = route_meta.get("start_time", "")
|
||||
print(f"{request.canonical_name}: downloading streams={sorted(file_names)} segments={sorted(request.segment_filter) if request.segment_filter else 'all'}")
|
||||
|
||||
for segment, file_name, url in iter_route_file_urls(files_payload, file_names):
|
||||
if request.segment_filter is not None and segment not in request.segment_filter:
|
||||
continue
|
||||
|
||||
segment_name = f"{log_id}--{segment}"
|
||||
segment_dir = clip_root / segment_name
|
||||
dest_path = segment_dir / file_name
|
||||
epoch = download_to_path(download_session, url, dest_path, args.overwrite, args.timeout)
|
||||
print(f" {segment_name}/{file_name} <- {urlparse(url).netloc}")
|
||||
file_lines[f"{segment_name} {dest_path}"] = f"{segment_name} {dest_path}"
|
||||
if file_name.startswith("qlog") and epoch is not None:
|
||||
qlog_lines[str(dest_path)] = f"{dest_path} {epoch}"
|
||||
|
||||
if start_time:
|
||||
print(f" route start_time={start_time}")
|
||||
|
||||
write_manifest(qlog_mtimes_path, qlog_lines)
|
||||
write_manifest(files_manifest_path, file_lines)
|
||||
print(f"Updated {qlog_mtimes_path}")
|
||||
print(f"Updated {files_manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import importlib
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import gdown
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, default_raw_root, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, default_raw_root, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
GLARE_ROOT_URL = "https://drive.google.com/drive/folders/1gmoOSgvjR4DP7jGfGS_xAmxcMShyeThx?usp=sharing"
|
||||
DEFAULT_PREFIXES = ("Images/", "Tracks/")
|
||||
MANIFEST_FIELDS = ["file_id", "relative_path", "local_path"]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Download the raw GLARE image/track files without checkpoint artifacts.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--output-root", type=Path, help="Download destination. Defaults to <raw>/glare_raw.")
|
||||
parser.add_argument("--prefix", dest="prefixes", nargs="+", default=list(DEFAULT_PREFIXES), help="Path prefixes to keep from the GLARE Drive tree.")
|
||||
parser.add_argument("--manifest", type=Path, help="Optional CSV path for the filtered file manifest.")
|
||||
parser.add_argument("--list-only", action="store_true", help="List matching files without downloading them.")
|
||||
parser.add_argument("--resume", action="store_true", help="Resume partial downloads and skip completed files.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
output_root = args.output_root.resolve() if args.output_root else (default_raw_root(workspace) / "glare_raw")
|
||||
manifest_path = args.manifest.resolve() if args.manifest else (output_root / "manifest.csv")
|
||||
ensure_dir(output_root)
|
||||
|
||||
download_folder_mod = importlib.import_module("gdown.download_folder")
|
||||
entries = download_folder_mod.download_folder(
|
||||
url=GLARE_ROOT_URL,
|
||||
skip_download=True,
|
||||
quiet=True,
|
||||
remaining_ok=True,
|
||||
)
|
||||
if entries is None:
|
||||
raise RuntimeError("Failed to enumerate the GLARE Drive tree")
|
||||
|
||||
selected = [entry for entry in entries if any(entry.path.startswith(prefix) for prefix in args.prefixes)]
|
||||
print(f"Matched {len(selected)} GLARE file(s) under prefixes: {', '.join(args.prefixes)}")
|
||||
|
||||
with manifest_path.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=MANIFEST_FIELDS)
|
||||
writer.writeheader()
|
||||
for entry in selected:
|
||||
local_path = output_root / entry.path
|
||||
writer.writerow({
|
||||
"file_id": entry.id,
|
||||
"relative_path": entry.path,
|
||||
"local_path": str(local_path),
|
||||
})
|
||||
if args.list_only:
|
||||
continue
|
||||
ensure_dir(local_path.parent)
|
||||
gdown.download(
|
||||
id=entry.id,
|
||||
output=str(local_path),
|
||||
quiet=False,
|
||||
resume=args.resume,
|
||||
)
|
||||
|
||||
print(f"GLARE manifest: {manifest_path}")
|
||||
print(f"GLARE output: {output_root}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
from scripts.speed_limit_vision import common
|
||||
|
||||
|
||||
DEFAULT_SESSION_ROOT = Path(".tmp/live_drive_debug")
|
||||
DEFAULT_CLIP_ROOT = common.preferred_clip_root()
|
||||
DEFAULT_QLOG_MTIMES = common.preferred_qlog_mtimes_path()
|
||||
EVENT_TYPES = ("candidate", "publish", "stale_clear")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BookmarkWindow:
|
||||
bookmark_number: int
|
||||
route: str
|
||||
segment: int
|
||||
segment_offset_s: float
|
||||
leadin_start_s: float
|
||||
spans_previous_segment: bool
|
||||
|
||||
|
||||
class LiveReplayDaemon(slv.SpeedLimitVisionDaemon):
|
||||
def __init__(self):
|
||||
super().__init__(use_runtime=False)
|
||||
self.now = 0.0
|
||||
self.captured_events: list[dict] = []
|
||||
|
||||
def _write_debug_event(self, event_type, frame_bgr=None, snapshot_prefix=None, **fields):
|
||||
if event_type not in EVENT_TYPES:
|
||||
return
|
||||
record = {"event": event_type, "t": round(self.now, 3)}
|
||||
record.update(fields)
|
||||
self.captured_events.append(record)
|
||||
|
||||
def _publish_status(self, status, clear_speed=False):
|
||||
if clear_speed:
|
||||
self._clear_detection()
|
||||
|
||||
def process_frame(self, now, frame_bgr):
|
||||
self.now = now
|
||||
slv.time.monotonic = lambda now=now: now
|
||||
self.current_frame_bgr = frame_bgr
|
||||
|
||||
inference_interval = slv.FOLLOWUP_INFERENCE_INTERVAL if now < self.followup_until else slv.INFERENCE_INTERVAL
|
||||
if now - self.last_inference_at < inference_interval:
|
||||
if self.published_speed_limit_mph > 0 and self._published_detection_stale(now):
|
||||
self._write_debug_event("stale_clear", reason="hold_timeout")
|
||||
self._publish_status("Scanning replay", clear_speed=True)
|
||||
return
|
||||
|
||||
self.last_inference_at = now
|
||||
detection = self._detect_sign(frame_bgr)
|
||||
if detection is not None:
|
||||
self._update_detection(detection)
|
||||
elif self.published_speed_limit_mph > 0 and self._published_detection_stale(now):
|
||||
self._write_debug_event("stale_clear", reason="no_detection")
|
||||
self._publish_status("Scanning replay", clear_speed=True)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Replay 5-second pre-bookmark sign windows through the live speed-limit vision path.")
|
||||
parser.add_argument("--session-root", type=Path, default=DEFAULT_SESSION_ROOT, help="Directory containing debug session folders.")
|
||||
parser.add_argument("--clip-root", type=Path, default=DEFAULT_CLIP_ROOT, help="Directory containing copied route clips under <route>--<seg>/fcamera.hevc.")
|
||||
parser.add_argument("--qlog-mtimes", type=Path, default=DEFAULT_QLOG_MTIMES, help="Text file with '<qlog path> <mtime epoch>' lines.")
|
||||
parser.add_argument("--session-route-map", type=Path, default=common.preferred_session_route_map_path(), help="JSON file mapping debug session ids to route log ids.")
|
||||
parser.add_argument("--models-dir", type=Path, help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.")
|
||||
parser.add_argument("--lead-in", type=float, default=5.0, help="Seconds before each bookmark to replay.")
|
||||
parser.add_argument("--sample-fps", type=float, help="Optional decode sample rate. Use 5 for faster bookmark sweeps that still match the live inference cadence.")
|
||||
parser.add_argument("--session", action="append", help="Optional session id filter. Repeat to run more than one.")
|
||||
parser.add_argument("--bookmark", action="append", type=int, help="Optional bookmark number filter within the selected sessions.")
|
||||
parser.add_argument("--json-out", type=Path, help="Optional path to write the summary JSON.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_qlog_mtimes(path: Path):
|
||||
route_mtimes: dict[str, dict[int, int]] = {}
|
||||
pattern = re.compile(r"/([^/]+)--(\d+)/(qlog(?:\.(?:zst|bz2))?)$")
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
qlog_path, timestamp = line.rsplit(" ", 1)
|
||||
match = pattern.search(qlog_path)
|
||||
if match is None:
|
||||
continue
|
||||
route, segment = match.group(1), int(match.group(2))
|
||||
route_mtimes.setdefault(route, {})[segment] = int(timestamp)
|
||||
return route_mtimes
|
||||
|
||||
|
||||
def load_bookmarks(session_path: Path):
|
||||
events = []
|
||||
with (session_path / "events.jsonl").open("r", encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
event = json.loads(line)
|
||||
if event.get("event") in ("bookmark", "auto_bookmark"):
|
||||
events.append(event)
|
||||
return events
|
||||
|
||||
|
||||
def locate_window(route: str, event: dict, route_mtimes: dict[str, dict[int, int]], lead_in: float):
|
||||
event_wall_time = datetime.fromisoformat(event["wallTime"]).timestamp()
|
||||
segment_mtimes = route_mtimes.get(route, {})
|
||||
for segment, start_epoch in sorted(segment_mtimes.items()):
|
||||
if start_epoch <= event_wall_time < start_epoch + 60:
|
||||
offset_s = event_wall_time - start_epoch
|
||||
return BookmarkWindow(
|
||||
bookmark_number=0,
|
||||
route=route,
|
||||
segment=segment,
|
||||
segment_offset_s=offset_s,
|
||||
leadin_start_s=offset_s - lead_in,
|
||||
spans_previous_segment=offset_s - lead_in < 0.0,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def iter_video_window(path: Path, start_s: float, end_s: float, sample_fps: float | None = None):
|
||||
capture = cv2.VideoCapture(str(path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
start_frame = max(int(start_s * fps), 0)
|
||||
end_frame = max(int(end_s * fps), start_frame)
|
||||
frame_step = 1
|
||||
if sample_fps is not None and sample_fps > 0.0 and sample_fps < fps:
|
||||
frame_step = max(int(round(fps / sample_fps)), 1)
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame)
|
||||
frame_index = start_frame
|
||||
|
||||
while frame_index <= end_frame:
|
||||
ok, frame_bgr = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
frame_time_s = frame_index / fps
|
||||
if start_s <= frame_time_s <= end_s:
|
||||
yield frame_time_s, frame_bgr
|
||||
frame_index += 1
|
||||
|
||||
skipped = 1
|
||||
while skipped < frame_step and frame_index <= end_frame:
|
||||
ok = capture.grab()
|
||||
if not ok:
|
||||
capture.release()
|
||||
return
|
||||
frame_index += 1
|
||||
skipped += 1
|
||||
|
||||
capture.release()
|
||||
|
||||
|
||||
def replay_window(window: BookmarkWindow, clip_root: Path, sample_fps: float | None = None):
|
||||
daemon = LiveReplayDaemon()
|
||||
elapsed_base_s = 0.0
|
||||
replayed_frames = 0
|
||||
segments: list[tuple[Path, float, float]] = []
|
||||
|
||||
if window.spans_previous_segment:
|
||||
previous_segment = window.segment - 1
|
||||
if previous_segment >= 0:
|
||||
previous_clip = clip_root / f"{window.route}--{previous_segment}" / "fcamera.hevc"
|
||||
previous_start_s = 60.0 + window.leadin_start_s
|
||||
segments.append((previous_clip, max(previous_start_s, 0.0), 60.0))
|
||||
elapsed_base_s += max(60.0 - max(previous_start_s, 0.0), 0.0)
|
||||
current_start_s = 0.0
|
||||
else:
|
||||
current_start_s = window.leadin_start_s
|
||||
|
||||
current_clip = clip_root / f"{window.route}--{window.segment}" / "fcamera.hevc"
|
||||
segments.append((current_clip, max(current_start_s, 0.0), window.segment_offset_s))
|
||||
|
||||
cumulative_offset_s = 0.0
|
||||
for clip_path, start_s, end_s in segments:
|
||||
if not clip_path.is_file():
|
||||
return {
|
||||
"missingClip": str(clip_path),
|
||||
"events": [],
|
||||
"replayedFrames": replayed_frames,
|
||||
}
|
||||
|
||||
for frame_time_s, frame_bgr in iter_video_window(clip_path, start_s, end_s, sample_fps=sample_fps):
|
||||
replay_time_s = cumulative_offset_s + (frame_time_s - start_s)
|
||||
daemon.process_frame(replay_time_s, frame_bgr)
|
||||
replayed_frames += 1
|
||||
|
||||
cumulative_offset_s += max(end_s - start_s, 0.0)
|
||||
|
||||
candidate_values = [event["candidateSpeedLimitMph"] for event in daemon.captured_events if event["event"] == "candidate"]
|
||||
published_values = [event["speedLimitMph"] for event in daemon.captured_events if event["event"] == "publish"]
|
||||
return {
|
||||
"events": daemon.captured_events,
|
||||
"candidateValues": candidate_values,
|
||||
"publishedValues": published_values,
|
||||
"replayedFrames": replayed_frames,
|
||||
"hit": bool(candidate_values or published_values),
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
selected_sessions = set(args.session or [])
|
||||
selected_bookmarks = set(args.bookmark or [])
|
||||
route_mtimes = load_qlog_mtimes(args.qlog_mtimes.expanduser().resolve())
|
||||
session_route_map = common.load_session_route_map(args.session_route_map)
|
||||
if not session_route_map:
|
||||
raise FileNotFoundError(f"No session route map found at {args.session_route_map}")
|
||||
|
||||
if args.models_dir:
|
||||
models_dir = args.models_dir.expanduser().resolve()
|
||||
detector_path = models_dir / "speed_limit_us_detector.onnx"
|
||||
classifier_path = models_dir / "speed_limit_us_value_classifier.onnx"
|
||||
if not detector_path.is_file():
|
||||
raise FileNotFoundError(detector_path)
|
||||
if not classifier_path.is_file():
|
||||
raise FileNotFoundError(classifier_path)
|
||||
slv.US_DETECTOR_MODEL_PATH = detector_path
|
||||
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
|
||||
|
||||
summaries = []
|
||||
for session_id, route in session_route_map.items():
|
||||
if selected_sessions and session_id not in selected_sessions:
|
||||
continue
|
||||
|
||||
session_path = args.session_root.expanduser().resolve() / session_id
|
||||
if not session_path.is_dir():
|
||||
continue
|
||||
|
||||
bookmarks = load_bookmarks(session_path)
|
||||
for bookmark_number, event in enumerate(bookmarks, start=1):
|
||||
if selected_bookmarks and bookmark_number not in selected_bookmarks:
|
||||
continue
|
||||
|
||||
window = locate_window(route, event, route_mtimes, args.lead_in)
|
||||
if window is None:
|
||||
summary = {
|
||||
"sessionId": session_id,
|
||||
"bookmarkNumber": bookmark_number,
|
||||
"route": route,
|
||||
"status": "unmapped",
|
||||
}
|
||||
else:
|
||||
window = BookmarkWindow(
|
||||
bookmark_number=bookmark_number,
|
||||
route=window.route,
|
||||
segment=window.segment,
|
||||
segment_offset_s=window.segment_offset_s,
|
||||
leadin_start_s=window.leadin_start_s,
|
||||
spans_previous_segment=window.spans_previous_segment,
|
||||
)
|
||||
replay_summary = replay_window(window, args.clip_root.expanduser().resolve(), sample_fps=args.sample_fps)
|
||||
summary = {
|
||||
"sessionId": session_id,
|
||||
"bookmarkNumber": bookmark_number,
|
||||
"route": route,
|
||||
"segment": window.segment,
|
||||
"segmentOffsetS": round(window.segment_offset_s, 3),
|
||||
"leadinStartS": round(window.leadin_start_s, 3),
|
||||
"spansPreviousSegment": window.spans_previous_segment,
|
||||
}
|
||||
summary.update(replay_summary)
|
||||
|
||||
summaries.append(summary)
|
||||
|
||||
hit_count = sum(1 for summary in summaries if summary.get("hit"))
|
||||
print(f"Bookmarks with detections in lead-in: {hit_count}/{len(summaries)}")
|
||||
for summary in summaries:
|
||||
if summary.get("status") == "unmapped":
|
||||
print(f"{summary['sessionId']} bookmark {summary['bookmarkNumber']:02d}: unmapped")
|
||||
continue
|
||||
|
||||
result = "hit" if summary.get("hit") else "miss"
|
||||
publish_values = ",".join(str(value) for value in summary.get("publishedValues", [])) or "-"
|
||||
candidate_values = ",".join(str(value) for value in summary.get("candidateValues", [])) or "-"
|
||||
note = ""
|
||||
if summary.get("missingClip"):
|
||||
note = f" missing={summary['missingClip']}"
|
||||
print(
|
||||
f"{summary['sessionId']} bookmark {summary['bookmarkNumber']:02d}: "
|
||||
f"seg {summary['segment']} @ {summary['segmentOffsetS']:.2f}s "
|
||||
f"lead-in [{summary['leadinStartS']:.2f}s, {summary['segmentOffsetS']:.2f}s] "
|
||||
f"{result} publish={publish_values} candidate={candidate_values}{note}"
|
||||
)
|
||||
|
||||
if args.json_out:
|
||||
args.json_out.expanduser().resolve().write_text(json.dumps(summaries, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from ultralytics import YOLO
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_SPEED_VALUES # type: ignore
|
||||
from generate_value_roi_classifier_dataset import extract_value_mask # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_SPEED_VALUES
|
||||
from .generate_value_roi_classifier_dataset import extract_value_mask
|
||||
|
||||
|
||||
DETECTOR_SIGN_CLASSES = {
|
||||
"regulatory_speed_limit",
|
||||
"school_zone_speed_limit",
|
||||
"speedLimit15",
|
||||
"speedLimit20",
|
||||
"speedLimit25",
|
||||
"speedLimit30",
|
||||
"speedLimit35",
|
||||
"speedLimit40",
|
||||
"speedLimit45",
|
||||
"speedLimit50",
|
||||
"speedLimit55",
|
||||
"speedLimit60",
|
||||
"speedLimit65",
|
||||
"speedLimit70",
|
||||
"speedLimit75",
|
||||
"schoolSpeedLimit25",
|
||||
"speedLimit55Ahead",
|
||||
}
|
||||
|
||||
|
||||
def iter_frames(path: Path):
|
||||
if path.is_dir():
|
||||
for frame_path in sorted(path.glob("frame_*")):
|
||||
frame = cv2.imread(str(frame_path))
|
||||
if frame is not None:
|
||||
yield frame_path.name, frame
|
||||
return
|
||||
|
||||
cap = cv2.VideoCapture(str(path))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
frame_index = 0
|
||||
while True:
|
||||
ok, frame = cap.read()
|
||||
if not ok:
|
||||
break
|
||||
yield f"t={frame_index / fps:06.2f}s", frame
|
||||
frame_index += 1
|
||||
cap.release()
|
||||
|
||||
|
||||
def crop_with_margin(frame: np.ndarray, xyxy: np.ndarray, margin_ratio: float = 0.16):
|
||||
frame_h, frame_w = frame.shape[:2]
|
||||
x1, y1, x2, y2 = xyxy.astype(int)
|
||||
box_w = x2 - x1
|
||||
box_h = y2 - y1
|
||||
margin_x = int(box_w * margin_ratio)
|
||||
margin_y = int(box_h * margin_ratio)
|
||||
left = max(x1 - margin_x, 0)
|
||||
top = max(y1 - margin_y, 0)
|
||||
right = min(x2 + margin_x, frame_w)
|
||||
bottom = min(y2 + margin_y, frame_h)
|
||||
return frame[top:bottom, left:right]
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Evaluate a detector + value classifier pair on saved frames or route video.")
|
||||
parser.add_argument("path", help="Frame directory or video path.")
|
||||
parser.add_argument("--detector", required=True, help="Ultralytics detector checkpoint (.pt).")
|
||||
parser.add_argument("--classifier", required=True, help="Ultralytics classifier checkpoint (.pt).")
|
||||
parser.add_argument("--conf", type=float, default=0.10, help="Detector confidence threshold.")
|
||||
parser.add_argument("--imgsz", type=int, default=960, help="Detector image size.")
|
||||
parser.add_argument("--device", default="mps", help="Inference device, such as mps or cpu.")
|
||||
parser.add_argument("--max-frames", type=int, default=0, help="Optional frame cap.")
|
||||
args = parser.parse_args()
|
||||
|
||||
detector = YOLO(args.detector)
|
||||
classifier = YOLO(args.classifier)
|
||||
path = Path(args.path).expanduser().resolve()
|
||||
speed_values = tuple(DEFAULT_SPEED_VALUES)
|
||||
|
||||
for frame_index, (label, frame_bgr) in enumerate(iter_frames(path), start=1):
|
||||
if args.max_frames > 0 and frame_index > args.max_frames:
|
||||
break
|
||||
|
||||
detector_result = detector.predict(source=frame_bgr, conf=args.conf, imgsz=args.imgsz, device=args.device, verbose=False)[0]
|
||||
if detector_result.boxes is None or len(detector_result.boxes) == 0:
|
||||
continue
|
||||
|
||||
printed = False
|
||||
for box, cls, conf in zip(detector_result.boxes.xyxy.cpu().numpy(), detector_result.boxes.cls.cpu().numpy(), detector_result.boxes.conf.cpu().numpy()):
|
||||
class_name = detector.names[int(cls)]
|
||||
if class_name not in DETECTOR_SIGN_CLASSES:
|
||||
continue
|
||||
|
||||
crop = crop_with_margin(frame_bgr, box)
|
||||
if crop.size == 0:
|
||||
continue
|
||||
|
||||
mask = extract_value_mask(crop)
|
||||
if mask is None:
|
||||
continue
|
||||
classifier_input = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
|
||||
classifier_result = classifier.predict(source=classifier_input, imgsz=128, device=args.device, verbose=False)[0]
|
||||
probabilities = classifier_result.probs
|
||||
if probabilities is None:
|
||||
continue
|
||||
|
||||
top_index = int(probabilities.top1)
|
||||
if top_index >= len(speed_values):
|
||||
continue
|
||||
predicted_value = speed_values[top_index]
|
||||
predicted_confidence = float(probabilities.top1conf)
|
||||
|
||||
if not printed:
|
||||
print(label)
|
||||
printed = True
|
||||
print(
|
||||
f" detector={class_name} det_conf={float(conf):.3f} "
|
||||
f"classifier={predicted_value} cls_conf={predicted_confidence:.3f} "
|
||||
f"box={[round(float(v), 1) for v in box.tolist()]}"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeCase:
|
||||
name: str
|
||||
frame_path: str
|
||||
expected_speed_limit_mph: int
|
||||
|
||||
|
||||
DEFAULT_CASES = (
|
||||
RuntimeCase("live15", ".tmp/live_c4_capture/stopped_sign_road.jpg", 15),
|
||||
RuntimeCase("school20", ".tmp/route_vision/seg38_frames/frame_041.jpg", 20),
|
||||
RuntimeCase("highway40", ".tmp/speed_route_frames_seg2_10_20/t12.png", 40),
|
||||
RuntimeCase("town40", ".tmp/route_12c_seg9_10/seg10_early/frame_005.png", 40),
|
||||
RuntimeCase("town30", ".tmp/route_12c_seg9_10/seg10_early/frame_012.png", 30),
|
||||
RuntimeCase("town30_late", ".tmp/vision_iter/seg10_5fps/frame_054.png", 30),
|
||||
RuntimeCase("town30_late_earlier", ".tmp/vision_iter/seg10_5fps/frame_052.png", 30),
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Evaluate the StarPilot speed-limit runtime path on known saved-frame cases.")
|
||||
parser.add_argument(
|
||||
"--models-dir",
|
||||
type=Path,
|
||||
default=Path("starpilot/assets/vision_models"),
|
||||
help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--case",
|
||||
action="append",
|
||||
dest="selected_cases",
|
||||
help="Optional case name filter. Repeat to run more than one case.",
|
||||
)
|
||||
parser.add_argument("--strict", action="store_true", help="Exit non-zero if any evaluated case misses the expected value.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_cases(selected_cases: list[str] | None):
|
||||
if not selected_cases:
|
||||
return DEFAULT_CASES
|
||||
|
||||
selected = set(selected_cases)
|
||||
cases = tuple(case for case in DEFAULT_CASES if case.name in selected)
|
||||
missing = sorted(selected - {case.name for case in cases})
|
||||
if missing:
|
||||
raise ValueError(f"Unknown case names: {', '.join(missing)}")
|
||||
return cases
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
models_dir = args.models_dir.expanduser().resolve()
|
||||
detector_path = models_dir / "speed_limit_us_detector.onnx"
|
||||
classifier_path = models_dir / "speed_limit_us_value_classifier.onnx"
|
||||
if not detector_path.is_file():
|
||||
raise FileNotFoundError(detector_path)
|
||||
if not classifier_path.is_file():
|
||||
raise FileNotFoundError(classifier_path)
|
||||
|
||||
slv.US_DETECTOR_MODEL_PATH = detector_path
|
||||
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
|
||||
failures = 0
|
||||
for case in resolve_cases(args.selected_cases):
|
||||
image_path = Path(case.frame_path).expanduser().resolve()
|
||||
frame_bgr = cv2.imread(str(image_path))
|
||||
if frame_bgr is None:
|
||||
raise FileNotFoundError(image_path)
|
||||
|
||||
detection = daemon._detect_sign(frame_bgr)
|
||||
predicted_speed = detection.speed_limit_mph if detection is not None else None
|
||||
confidence = round(detection.confidence, 4) if detection is not None else None
|
||||
passed = predicted_speed == case.expected_speed_limit_mph
|
||||
if not passed:
|
||||
failures += 1
|
||||
|
||||
print(
|
||||
f"{case.name}: expected={case.expected_speed_limit_mph} "
|
||||
f"predicted={predicted_speed} confidence={confidence} "
|
||||
f"{'PASS' if passed else 'FAIL'}"
|
||||
)
|
||||
|
||||
return 1 if args.strict and failures else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
CLASSIFIER_EXPORT_NAME,
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_EXPORT_NAME,
|
||||
REPO_ASSET_DIR,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
CLASSIFIER_EXPORT_NAME,
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_EXPORT_NAME,
|
||||
REPO_ASSET_DIR,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Export trained speed-limit detector/classifier checkpoints to ONNX.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--detector-weights", type=Path, help="Path to the trained detector .pt weights.")
|
||||
parser.add_argument("--classifier-weights", type=Path, help="Path to the trained classifier .pt weights.")
|
||||
parser.add_argument("--output-dir", type=Path, help="Where exported ONNX models should be written. Defaults to <workspace>/exports.")
|
||||
parser.add_argument("--detector-imgsz", type=int, default=640, help="Detector export image size.")
|
||||
parser.add_argument("--classifier-imgsz", type=int, default=128, help="Classifier export image size.")
|
||||
parser.add_argument("--opset", type=int, default=12, help="ONNX opset.")
|
||||
parser.add_argument("--install-repo-assets", action="store_true", help="Copy exported ONNX files into starpilot/assets/vision_models.")
|
||||
parser.add_argument("--skip-verify", action="store_true", help="Skip the OpenCV DNN load/forward smoke test after export.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def export_yolo(weights_path: Path, output_path: Path, imgsz: int, opset: int, nms: bool) -> None:
|
||||
from ultralytics import YOLO
|
||||
|
||||
model = YOLO(str(weights_path))
|
||||
exported_path = Path(model.export(format="onnx", imgsz=imgsz, opset=opset, simplify=False, dynamic=False, nms=nms))
|
||||
ensure_dir(output_path.parent)
|
||||
shutil.copy2(exported_path, output_path)
|
||||
|
||||
|
||||
def verify_onnx_with_opencv(model_path: Path, input_size: int) -> None:
|
||||
net = cv2.dnn.readNetFromONNX(str(model_path))
|
||||
blob = np.zeros((1, 3, input_size, input_size), dtype=np.float32)
|
||||
net.setInput(blob)
|
||||
_ = net.forward()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
output_dir = args.output_dir.resolve() if args.output_dir else (workspace / "exports")
|
||||
ensure_dir(output_dir)
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO # noqa: F401
|
||||
except Exception as exc:
|
||||
raise SystemExit(
|
||||
"Ultralytics is not installed. Run `uv sync --extra speedvision` in the repo root before exporting."
|
||||
) from exc
|
||||
|
||||
exported_paths: list[Path] = []
|
||||
|
||||
if args.detector_weights:
|
||||
detector_weights = args.detector_weights.resolve()
|
||||
detector_output = output_dir / DETECTOR_EXPORT_NAME
|
||||
export_yolo(detector_weights, detector_output, args.detector_imgsz, args.opset, nms=False)
|
||||
if not args.skip_verify:
|
||||
verify_onnx_with_opencv(detector_output, args.detector_imgsz)
|
||||
exported_paths.append(detector_output)
|
||||
|
||||
if args.classifier_weights:
|
||||
classifier_weights = args.classifier_weights.resolve()
|
||||
classifier_output = output_dir / CLASSIFIER_EXPORT_NAME
|
||||
export_yolo(classifier_weights, classifier_output, args.classifier_imgsz, args.opset, nms=False)
|
||||
if not args.skip_verify:
|
||||
verify_onnx_with_opencv(classifier_output, args.classifier_imgsz)
|
||||
exported_paths.append(classifier_output)
|
||||
|
||||
if not exported_paths:
|
||||
raise SystemExit("Pass at least one of --detector-weights or --classifier-weights")
|
||||
|
||||
if args.install_repo_assets:
|
||||
ensure_dir(REPO_ASSET_DIR)
|
||||
for exported_path in exported_paths:
|
||||
shutil.copy2(exported_path, REPO_ASSET_DIR / exported_path.name)
|
||||
|
||||
for exported_path in exported_paths:
|
||||
print(f"Exported {exported_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,315 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import math
|
||||
import random
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from PIL import Image, ImageDraw, ImageEnhance, ImageFilter, ImageFont
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
DEFAULT_BACKGROUND_DIR = DEFAULT_WORKSPACE / "backgrounds"
|
||||
HEADER_FONT_CANDIDATES = (
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
"/System/Library/Fonts/ArialHB.ttc",
|
||||
"/System/Library/Fonts/Supplemental/Arial.ttf",
|
||||
)
|
||||
NUMBER_FONT_CANDIDATES = (
|
||||
"/System/Library/Fonts/Supplemental/DIN Condensed Bold.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Black.ttf",
|
||||
"/System/Library/Fonts/Supplemental/Arial Bold.ttf",
|
||||
)
|
||||
KNOWN_REAL_CROPS = (
|
||||
(".tmp/live_c4_capture/stopped_sign_crop_manual.png", 15),
|
||||
(".tmp/route_vision/frame_041_sign_tight.jpg", 20),
|
||||
(".tmp/route_vision/frame_041_sign_manual.jpg", 20),
|
||||
(".tmp/route_12c_seg9_10/seg10_real30_crop.png", 30),
|
||||
(".tmp/speed_route_frames_seg2_10_20/t12_sign_crop.png", 40),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SignSpec:
|
||||
detector_class: int
|
||||
style: str
|
||||
speed_value: int | None
|
||||
|
||||
|
||||
def load_font(candidates: tuple[str, ...], size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
for candidate in candidates:
|
||||
if Path(candidate).exists():
|
||||
return ImageFont.truetype(candidate, size=size)
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def draw_centered(draw: ImageDraw.ImageDraw, box: tuple[int, int, int, int], text: str, font, fill: str):
|
||||
left, top, right, bottom = box
|
||||
bbox = draw.multiline_textbbox((0, 0), text, font=font, align="center", spacing=2)
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
x = left + (right - left - width) / 2
|
||||
y = top + (bottom - top - height) / 2
|
||||
draw.multiline_text((x, y), text, font=font, fill=fill, align="center", spacing=2)
|
||||
|
||||
|
||||
def render_regulatory_sign(speed_value: int, school_zone: bool, seed: int) -> Image.Image:
|
||||
rng = random.Random(seed)
|
||||
sign_w = rng.randint(260, 320)
|
||||
sign_h = rng.randint(390, 470)
|
||||
image = Image.new("RGBA", (sign_w, sign_h), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
border_radius = max(int(sign_w * 0.08), 16)
|
||||
draw.rounded_rectangle((0, 0, sign_w - 1, sign_h - 1), border_radius, fill="white", outline="black", width=max(sign_w // 38, 5))
|
||||
|
||||
header_font = load_font(HEADER_FONT_CANDIDATES, max(sign_w // 9, 26))
|
||||
number_font = load_font(NUMBER_FONT_CANDIDATES, max(sign_w // 3, 84))
|
||||
footer_font = load_font(HEADER_FONT_CANDIDATES, max(sign_w // 12, 18))
|
||||
|
||||
if school_zone:
|
||||
draw_centered(draw, (int(sign_w * 0.10), int(sign_h * 0.06), int(sign_w * 0.90), int(sign_h * 0.24)), "SCHOOL", header_font, "black")
|
||||
draw_centered(draw, (int(sign_w * 0.10), int(sign_h * 0.20), int(sign_w * 0.90), int(sign_h * 0.42)), "SPEED\nLIMIT", header_font, "black")
|
||||
draw_centered(draw, (int(sign_w * 0.12), int(sign_h * 0.42), int(sign_w * 0.88), int(sign_h * 0.78)), str(speed_value), number_font, "black")
|
||||
draw_centered(draw, (int(sign_w * 0.08), int(sign_h * 0.76), int(sign_w * 0.92), int(sign_h * 0.94)), "WHEN FLASHING", footer_font, "black")
|
||||
else:
|
||||
draw_centered(draw, (int(sign_w * 0.10), int(sign_h * 0.08), int(sign_w * 0.90), int(sign_h * 0.34)), "SPEED\nLIMIT", header_font, "black")
|
||||
draw_centered(draw, (int(sign_w * 0.12), int(sign_h * 0.40), int(sign_w * 0.88), int(sign_h * 0.84)), str(speed_value), number_font, "black")
|
||||
|
||||
if school_zone and rng.random() < 0.65:
|
||||
lamp_y = int(sign_h * 0.12)
|
||||
lamp_r = max(sign_w // 18, 12)
|
||||
for lamp_x in (int(sign_w * 0.16), int(sign_w * 0.84)):
|
||||
draw.ellipse((lamp_x - lamp_r, lamp_y - lamp_r, lamp_x + lamp_r, lamp_y + lamp_r), fill=(255, 192, 0), outline="black", width=3)
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def render_advisory_sign(speed_value: int, seed: int) -> Image.Image:
|
||||
rng = random.Random(seed)
|
||||
size = rng.randint(240, 320)
|
||||
image = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||
base = Image.new("RGBA", (size, size), (255, 255, 255, 0))
|
||||
draw = ImageDraw.Draw(base)
|
||||
|
||||
draw.polygon(((size / 2, 0), (size, size / 2), (size / 2, size), (0, size / 2)), fill=(255, 214, 10), outline="black")
|
||||
base = base.rotate(45, expand=True, resample=Image.Resampling.BICUBIC)
|
||||
bbox = base.getbbox()
|
||||
if bbox is not None:
|
||||
base = base.crop(bbox)
|
||||
|
||||
draw = ImageDraw.Draw(base)
|
||||
number_font = load_font(NUMBER_FONT_CANDIDATES, max(base.size[0] // 3, 72))
|
||||
footer_font = load_font(HEADER_FONT_CANDIDATES, max(base.size[0] // 12, 18))
|
||||
draw_centered(draw, (0, int(base.size[1] * 0.18), base.size[0], int(base.size[1] * 0.68)), str(speed_value), number_font, "black")
|
||||
if rng.random() < 0.7:
|
||||
draw_centered(draw, (0, int(base.size[1] * 0.68), base.size[0], int(base.size[1] * 0.92)), "MPH", footer_font, "black")
|
||||
return base
|
||||
|
||||
|
||||
def add_motion_blur(image: Image.Image, radius: int) -> Image.Image:
|
||||
if radius <= 1:
|
||||
return image
|
||||
kernel_size = radius * 2 + 1
|
||||
kernel = np.zeros((kernel_size, kernel_size), dtype=np.float32)
|
||||
kernel[kernel_size // 2, :] = 1.0 / kernel_size
|
||||
array = cv2.filter2D(np.array(image), -1, kernel)
|
||||
return Image.fromarray(array)
|
||||
|
||||
|
||||
def augment_sign(sign: Image.Image, rng: random.Random) -> Image.Image:
|
||||
image = sign.copy()
|
||||
if rng.random() < 0.7:
|
||||
image = image.filter(ImageFilter.GaussianBlur(radius=rng.uniform(0.2, 1.8)))
|
||||
if rng.random() < 0.35:
|
||||
image = add_motion_blur(image, radius=rng.randint(2, 5))
|
||||
|
||||
brightness = ImageEnhance.Brightness(image)
|
||||
image = brightness.enhance(rng.uniform(0.75, 1.18))
|
||||
contrast = ImageEnhance.Contrast(image)
|
||||
image = contrast.enhance(rng.uniform(0.85, 1.25))
|
||||
return image
|
||||
|
||||
|
||||
def choose_sign_spec(rng: random.Random, speed_values: tuple[int, ...]) -> SignSpec:
|
||||
roll = rng.random()
|
||||
if roll < 0.16:
|
||||
return SignSpec(detector_class=1, style="advisory", speed_value=rng.choice((20, 25, 30, 35, 40, 45)))
|
||||
if roll < 0.34:
|
||||
school_choices = tuple(value for value in speed_values if value in (15, 20, 25))
|
||||
return SignSpec(detector_class=2, style="school_zone", speed_value=rng.choice(school_choices or speed_values))
|
||||
return SignSpec(detector_class=0, style="regulatory", speed_value=rng.choice(speed_values))
|
||||
|
||||
|
||||
def paste_transformed(background_bgr: np.ndarray, sign_rgba: Image.Image, rng: random.Random):
|
||||
background = background_bgr.copy()
|
||||
bg_h, bg_w = background.shape[:2]
|
||||
sign = np.array(sign_rgba)
|
||||
sign_h, sign_w = sign.shape[:2]
|
||||
|
||||
target_h = int(rng.uniform(bg_h * 0.045, bg_h * 0.17))
|
||||
scale = target_h / max(sign_h, 1)
|
||||
target_w = max(int(sign_w * scale), 12)
|
||||
resized = cv2.resize(sign, (target_w, target_h), interpolation=cv2.INTER_LINEAR)
|
||||
sign_h, sign_w = resized.shape[:2]
|
||||
|
||||
center_x = int(rng.uniform(bg_w * 0.58, bg_w * 0.92))
|
||||
center_y = int(rng.uniform(bg_h * 0.10, bg_h * 0.58))
|
||||
|
||||
src = np.float32([[0, 0], [sign_w - 1, 0], [sign_w - 1, sign_h - 1], [0, sign_h - 1]])
|
||||
skew_x = sign_w * rng.uniform(0.04, 0.18)
|
||||
skew_y = sign_h * rng.uniform(0.02, 0.12)
|
||||
dst = np.float32([
|
||||
[center_x - sign_w * rng.uniform(0.35, 0.55), center_y - sign_h * rng.uniform(0.55, 0.70)],
|
||||
[center_x + sign_w * rng.uniform(0.35, 0.55), center_y - sign_h * rng.uniform(0.45, 0.70)],
|
||||
[center_x + sign_w * rng.uniform(0.28, 0.52), center_y + sign_h * rng.uniform(0.30, 0.58)],
|
||||
[center_x - sign_w * rng.uniform(0.26, 0.48), center_y + sign_h * rng.uniform(0.34, 0.62)],
|
||||
])
|
||||
dst += np.float32([
|
||||
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
|
||||
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
|
||||
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
|
||||
[rng.uniform(-skew_x, skew_x), rng.uniform(-skew_y, skew_y)],
|
||||
])
|
||||
|
||||
matrix = cv2.getPerspectiveTransform(src, dst)
|
||||
warped = cv2.warpPerspective(resized, matrix, (bg_w, bg_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
|
||||
alpha = warped[:, :, 3:4].astype(np.float32) / 255.0
|
||||
if alpha.max() <= 0.01:
|
||||
return background, None, None
|
||||
|
||||
warped_rgb = warped[:, :, :3].astype(np.float32)
|
||||
composite = background.astype(np.float32) * (1.0 - alpha) + warped_rgb * alpha
|
||||
composite = composite.astype(np.uint8)
|
||||
|
||||
ys, xs = np.where(alpha[:, :, 0] > 0.05)
|
||||
if len(xs) == 0 or len(ys) == 0:
|
||||
return background, None, None
|
||||
|
||||
x1, x2 = int(xs.min()), int(xs.max())
|
||||
y1, y2 = int(ys.min()), int(ys.max())
|
||||
bbox = (x1, y1, x2, y2)
|
||||
crop = composite[y1:y2 + 1, x1:x2 + 1]
|
||||
return composite, bbox, crop
|
||||
|
||||
|
||||
def detector_label_line(detector_class: int, bbox: tuple[int, int, int, int], image_shape: tuple[int, int, int]) -> str:
|
||||
image_h, image_w = image_shape[:2]
|
||||
x1, y1, x2, y2 = bbox
|
||||
x_center = ((x1 + x2) / 2) / image_w
|
||||
y_center = ((y1 + y2) / 2) / image_h
|
||||
width = (x2 - x1) / image_w
|
||||
height = (y2 - y1) / image_h
|
||||
return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
|
||||
|
||||
|
||||
def save_classifier_crop(base_dir: Path, split: str, speed_value: int, image_bgr: np.ndarray, stem: str):
|
||||
output_dir = ensure_dir(base_dir / split / str(speed_value))
|
||||
output_path = output_dir / f"{stem}.jpg"
|
||||
cv2.imwrite(str(output_path), image_bgr, [cv2.IMWRITE_JPEG_QUALITY, 92])
|
||||
|
||||
|
||||
def collect_backgrounds(background_dir: Path) -> list[Path]:
|
||||
if not background_dir.is_dir():
|
||||
return []
|
||||
return sorted(path for path in background_dir.iterdir() if path.suffix.lower() in {".jpg", ".jpeg", ".png"})
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a synthetic U.S. speed-limit detector/classifier dataset.")
|
||||
parser.add_argument("--workspace", default=str(DEFAULT_WORKSPACE), help="Training workspace root.")
|
||||
parser.add_argument("--background-dir", default=str(DEFAULT_BACKGROUND_DIR), help="Background image directory.")
|
||||
parser.add_argument("--train-count", type=int, default=9000, help="Number of synthetic training detector images.")
|
||||
parser.add_argument("--val-count", type=int, default=1200, help="Number of synthetic validation detector images.")
|
||||
parser.add_argument("--negative-ratio", type=float, default=0.18, help="Share of detector images with no sign.")
|
||||
parser.add_argument("--seed", type=int, default=20260330, help="Random seed.")
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
background_dir = Path(args.background_dir).expanduser().resolve()
|
||||
backgrounds = collect_backgrounds(background_dir)
|
||||
if not backgrounds:
|
||||
raise FileNotFoundError(f"No backgrounds found in {background_dir}")
|
||||
|
||||
detector_image_dir = workspace / "detector" / "images"
|
||||
detector_label_dir = workspace / "detector" / "labels"
|
||||
classifier_dir = workspace / "classifier"
|
||||
speed_values = tuple(DEFAULT_SPEED_VALUES)
|
||||
rng = random.Random(args.seed)
|
||||
|
||||
for split, count in (("train", max(args.train_count, 0)), ("val", max(args.val_count, 0))):
|
||||
ensure_dir(detector_image_dir / split)
|
||||
ensure_dir(detector_label_dir / split)
|
||||
ensure_dir(classifier_dir / split)
|
||||
|
||||
for index in range(count):
|
||||
background_path = rng.choice(backgrounds)
|
||||
background = cv2.imread(str(background_path))
|
||||
if background is None:
|
||||
continue
|
||||
|
||||
stem = f"{split}_{index:06d}"
|
||||
image_path = detector_image_dir / split / f"{stem}.jpg"
|
||||
label_path = detector_label_dir / split / f"{stem}.txt"
|
||||
detector_lines: list[str] = []
|
||||
|
||||
if rng.random() >= args.negative_ratio:
|
||||
sign_spec = choose_sign_spec(rng, speed_values)
|
||||
if sign_spec.style == "advisory":
|
||||
sign_image = render_advisory_sign(sign_spec.speed_value or 25, seed=rng.randint(0, 1_000_000))
|
||||
else:
|
||||
sign_image = render_regulatory_sign(sign_spec.speed_value or 25, school_zone=sign_spec.style == "school_zone", seed=rng.randint(0, 1_000_000))
|
||||
sign_image = augment_sign(sign_image, rng)
|
||||
composite, bbox, crop = paste_transformed(background, sign_image, rng)
|
||||
if bbox is not None:
|
||||
detector_lines.append(detector_label_line(sign_spec.detector_class, bbox, composite.shape))
|
||||
background = composite
|
||||
if crop is not None and sign_spec.speed_value is not None and sign_spec.detector_class != 1:
|
||||
save_classifier_crop(classifier_dir, split, sign_spec.speed_value, crop, stem)
|
||||
|
||||
if rng.random() < 0.45:
|
||||
alpha = rng.uniform(0.05, 0.20)
|
||||
overlay = np.full_like(background, int(rng.uniform(180, 240)))
|
||||
background = cv2.addWeighted(background, 1.0 - alpha, overlay, alpha, 0)
|
||||
if rng.random() < 0.3:
|
||||
background = cv2.GaussianBlur(background, (3, 3), rng.uniform(0.1, 0.9))
|
||||
if rng.random() < 0.25:
|
||||
noise = rng.normalvariate(0, 6)
|
||||
background = np.clip(background.astype(np.int16) + noise, 0, 255).astype(np.uint8)
|
||||
|
||||
cv2.imwrite(str(image_path), background, [cv2.IMWRITE_JPEG_QUALITY, 92])
|
||||
label_path.write_text("".join(detector_lines), encoding="utf-8")
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
imported_real = 0
|
||||
for relative_path, speed_value in KNOWN_REAL_CROPS:
|
||||
crop_path = repo_root / relative_path
|
||||
if not crop_path.is_file():
|
||||
continue
|
||||
image = cv2.imread(str(crop_path))
|
||||
if image is None:
|
||||
continue
|
||||
split = "val" if imported_real % 4 == 0 else "train"
|
||||
save_classifier_crop(classifier_dir, split, speed_value, image, f"real_{speed_value}_{imported_real:03d}")
|
||||
imported_real += 1
|
||||
|
||||
print(f"Generated synthetic detector data in {workspace / 'detector'}")
|
||||
print(f"Generated synthetic classifier data in {workspace / 'classifier'}")
|
||||
print(f"Backgrounds used: {len(backgrounds)}")
|
||||
print(f"Imported real crops: {imported_real}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
from generate_synthetic_us_speed_limits import KNOWN_REAL_CROPS, augment_sign, render_regulatory_sign # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
from .generate_synthetic_us_speed_limits import KNOWN_REAL_CROPS, augment_sign, render_regulatory_sign
|
||||
|
||||
|
||||
VALUE_TEMPLATE_ROIS = (
|
||||
(0.35, 0.82, 0.15, 0.78),
|
||||
(0.45, 0.85, 0.18, 0.78),
|
||||
(0.40, 0.84, 0.18, 0.75),
|
||||
)
|
||||
|
||||
|
||||
def normalize_binary_mask(binary_mask: np.ndarray, size=(72, 96), padding=6):
|
||||
points = cv2.findNonZero(binary_mask)
|
||||
if points is None:
|
||||
return None
|
||||
|
||||
x, y, width, height = cv2.boundingRect(points)
|
||||
digit = binary_mask[y:y + height, x:x + width]
|
||||
target_w, target_h = size
|
||||
scale = min((target_w - padding * 2) / max(width, 1), (target_h - padding * 2) / max(height, 1))
|
||||
resized_w = max(int(round(width * scale)), 1)
|
||||
resized_h = max(int(round(height * scale)), 1)
|
||||
resized = cv2.resize(digit, (resized_w, resized_h), interpolation=cv2.INTER_NEAREST)
|
||||
|
||||
canvas = np.zeros((target_h, target_w), dtype=np.uint8)
|
||||
offset_x = (target_w - resized_w) // 2
|
||||
offset_y = (target_h - resized_h) // 2
|
||||
canvas[offset_y:offset_y + resized_h, offset_x:offset_x + resized_w] = resized
|
||||
return canvas
|
||||
|
||||
|
||||
def extract_value_mask(sign_bgr: np.ndarray):
|
||||
gray = cv2.cvtColor(sign_bgr, cv2.COLOR_BGR2GRAY)
|
||||
height, width = gray.shape
|
||||
best_mask = None
|
||||
best_fill = 0.0
|
||||
|
||||
for top_ratio, bottom_ratio, left_ratio, right_ratio in VALUE_TEMPLATE_ROIS:
|
||||
roi = gray[int(height * top_ratio):int(height * bottom_ratio), int(width * left_ratio):int(width * right_ratio)]
|
||||
if roi.size == 0:
|
||||
continue
|
||||
|
||||
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8)).apply(roi)
|
||||
_, binary = cv2.threshold(clahe, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((2, 2), dtype=np.uint8))
|
||||
|
||||
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
|
||||
mask = np.zeros_like(binary)
|
||||
for label_idx in range(1, num_labels):
|
||||
x, y, comp_w, comp_h, area = stats[label_idx]
|
||||
if area < roi.shape[0] * roi.shape[1] * 0.01:
|
||||
continue
|
||||
if y < binary.shape[0] * 0.08:
|
||||
continue
|
||||
if comp_h < binary.shape[0] * 0.18:
|
||||
continue
|
||||
if comp_w > binary.shape[1] * 0.75:
|
||||
continue
|
||||
mask[labels == label_idx] = 255
|
||||
|
||||
normalized = normalize_binary_mask(mask, size=(72, 96))
|
||||
if normalized is None:
|
||||
continue
|
||||
|
||||
fill_ratio = float(np.count_nonzero(normalized)) / normalized.size
|
||||
if fill_ratio > best_fill:
|
||||
best_fill = fill_ratio
|
||||
best_mask = normalized
|
||||
|
||||
return best_mask
|
||||
|
||||
|
||||
def perspective_jitter(sign_rgba, rng: random.Random):
|
||||
sign = np.array(sign_rgba)
|
||||
sign_h, sign_w = sign.shape[:2]
|
||||
pad = max(sign_w, sign_h) // 5
|
||||
canvas = np.zeros((sign_h + pad * 2, sign_w + pad * 2, 4), dtype=np.uint8)
|
||||
canvas[pad:pad + sign_h, pad:pad + sign_w] = sign
|
||||
sign_h, sign_w = canvas.shape[:2]
|
||||
|
||||
src = np.float32([[0, 0], [sign_w - 1, 0], [sign_w - 1, sign_h - 1], [0, sign_h - 1]])
|
||||
jitter_x = sign_w * 0.08
|
||||
jitter_y = sign_h * 0.08
|
||||
dst = src + np.float32([
|
||||
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
|
||||
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
|
||||
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
|
||||
[rng.uniform(-jitter_x, jitter_x), rng.uniform(-jitter_y, jitter_y)],
|
||||
])
|
||||
matrix = cv2.getPerspectiveTransform(src, dst)
|
||||
warped = cv2.warpPerspective(canvas, matrix, (sign_w, sign_h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
|
||||
ys, xs = np.where(warped[:, :, 3] > 0)
|
||||
if len(xs) == 0 or len(ys) == 0:
|
||||
return canvas
|
||||
return warped[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
|
||||
|
||||
|
||||
def augment_mask(mask: np.ndarray, rng: random.Random):
|
||||
canvas = np.zeros((128, 128), dtype=np.uint8)
|
||||
resized = cv2.resize(mask, None, fx=rng.uniform(0.85, 1.15), fy=rng.uniform(0.85, 1.15), interpolation=cv2.INTER_NEAREST)
|
||||
offset_x = max((canvas.shape[1] - resized.shape[1]) // 2 + rng.randint(-8, 8), 0)
|
||||
offset_y = max((canvas.shape[0] - resized.shape[0]) // 2 + rng.randint(-8, 8), 0)
|
||||
end_x = min(offset_x + resized.shape[1], canvas.shape[1])
|
||||
end_y = min(offset_y + resized.shape[0], canvas.shape[0])
|
||||
canvas[offset_y:end_y, offset_x:end_x] = resized[:end_y - offset_y, :end_x - offset_x]
|
||||
|
||||
if rng.random() < 0.45:
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (rng.choice((1, 2, 3)), rng.choice((1, 2, 3))))
|
||||
operation = cv2.MORPH_DILATE if rng.random() < 0.5 else cv2.MORPH_ERODE
|
||||
canvas = cv2.morphologyEx(canvas, operation, kernel)
|
||||
if rng.random() < 0.55:
|
||||
canvas = cv2.GaussianBlur(canvas, (3, 3), rng.uniform(0.1, 1.0))
|
||||
if rng.random() < 0.35:
|
||||
noise = np.random.normal(0.0, rng.uniform(2.0, 9.0), canvas.shape).astype(np.float32)
|
||||
canvas = np.clip(canvas.astype(np.float32) + noise, 0, 255).astype(np.uint8)
|
||||
|
||||
return cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
|
||||
def save_mask(base_dir: Path, split: str, speed_value: int, image_bgr: np.ndarray, stem: str):
|
||||
output_dir = ensure_dir(base_dir / split / str(speed_value))
|
||||
cv2.imwrite(str(output_dir / f"{stem}.png"), image_bgr)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate a value-ROI classifier dataset from synthetic U.S. speed-limit signs.")
|
||||
parser.add_argument("--workspace", default=str(DEFAULT_WORKSPACE), help="Training workspace root.")
|
||||
parser.add_argument("--train-per-class", type=int, default=1800, help="Synthetic training samples per value.")
|
||||
parser.add_argument("--val-per-class", type=int, default=260, help="Synthetic validation samples per value.")
|
||||
parser.add_argument("--real-augmentations", type=int, default=28, help="Augmented mask samples to create per known real crop.")
|
||||
parser.add_argument("--seed", type=int, default=20260330, help="Random seed.")
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
classifier_dir = workspace / "classifier"
|
||||
if classifier_dir.exists():
|
||||
shutil.rmtree(classifier_dir)
|
||||
ensure_dir(classifier_dir / "train")
|
||||
ensure_dir(classifier_dir / "val")
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
speed_values = tuple(DEFAULT_SPEED_VALUES)
|
||||
|
||||
for split, per_class in (("train", max(args.train_per_class, 0)), ("val", max(args.val_per_class, 0))):
|
||||
for speed_value in speed_values:
|
||||
for index in range(per_class):
|
||||
school_zone = speed_value in (15, 20, 25) and rng.random() < 0.45
|
||||
sign_rgba = render_regulatory_sign(speed_value, school_zone=school_zone, seed=rng.randint(0, 1_000_000))
|
||||
sign_rgba = augment_sign(sign_rgba, rng)
|
||||
sign_rgba = perspective_jitter(sign_rgba, rng)
|
||||
sign_bgr = cv2.cvtColor(sign_rgba[:, :, :3], cv2.COLOR_RGB2BGR)
|
||||
mask = extract_value_mask(sign_bgr)
|
||||
if mask is None:
|
||||
continue
|
||||
output = augment_mask(mask, rng)
|
||||
save_mask(classifier_dir, split, speed_value, output, f"{split}_{speed_value}_{index:05d}")
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
imported_real = 0
|
||||
for relative_path, speed_value in KNOWN_REAL_CROPS:
|
||||
crop_path = repo_root / relative_path
|
||||
if not crop_path.is_file():
|
||||
continue
|
||||
crop_bgr = cv2.imread(str(crop_path))
|
||||
if crop_bgr is None:
|
||||
continue
|
||||
mask = extract_value_mask(crop_bgr)
|
||||
if mask is None:
|
||||
continue
|
||||
for augmentation_index in range(max(args.real_augmentations, 1)):
|
||||
split = "val" if augmentation_index % 5 == 0 else "train"
|
||||
output = augment_mask(mask, rng)
|
||||
save_mask(classifier_dir, split, speed_value, output, f"real_{speed_value}_{imported_real:03d}_{augmentation_index:03d}")
|
||||
imported_real += 1
|
||||
|
||||
print(f"Generated ROI classifier dataset in {classifier_dir}")
|
||||
print(f"Imported real crops: {imported_real}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import tarfile
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_CLASS_NAMES,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
VALUE_LABEL_FIELDS,
|
||||
default_raw_root,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_CLASS_NAMES,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
VALUE_LABEL_FIELDS,
|
||||
default_raw_root,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_ARCHIVE_RELATIVE = Path("external/arts_probe/Public/ARTS-V1/Challenging/challenging-dev.tar.gz")
|
||||
SOURCE_NAME = "arts_challenging"
|
||||
SOURCE_VERSION = "ARTS-V1 Challenging"
|
||||
SOURCE_LICENSE = "Research dataset, see source distribution"
|
||||
|
||||
ARTS_CODE_MAP: dict[str, tuple[str, int | None]] = {
|
||||
"R2-1": ("regulatory_speed_limit", None),
|
||||
"R2-125": ("regulatory_speed_limit", 25),
|
||||
"R2-130": ("regulatory_speed_limit", 30),
|
||||
"R2-135": ("regulatory_speed_limit", 35),
|
||||
"R2-140": ("regulatory_speed_limit", 40),
|
||||
"R2-145": ("regulatory_speed_limit", 45),
|
||||
"R2-150": ("regulatory_speed_limit", 50),
|
||||
"R2-155": ("regulatory_speed_limit", 55),
|
||||
"R2-165": ("regulatory_speed_limit", 65),
|
||||
"W13-1": ("advisory_speed_limit", None),
|
||||
"W13-2": ("advisory_speed_limit", None),
|
||||
"W13-3": ("advisory_speed_limit", None),
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import ARTS Challenging speed-limit samples into the training workspace.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--archive", type=Path, help="Path to challenging-dev.tar.gz. Defaults to the SSD raw-data layout.")
|
||||
parser.add_argument("--train-split", type=float, default=0.85, help="Fallback train split ratio when ARTS split files are unavailable.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite previously imported ARTS images/labels.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def default_archive_path(workspace: Path) -> Path:
|
||||
return default_raw_root(workspace) / DEFAULT_ARCHIVE_RELATIVE
|
||||
|
||||
|
||||
def read_existing_rows(path: Path) -> list[dict[str, str]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", newline="") as csv_file:
|
||||
return list(csv.DictReader(csv_file))
|
||||
|
||||
|
||||
def write_rows(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
|
||||
ensure_dir(path.parent)
|
||||
with path.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def build_fallback_split_lookup(stem_names: list[str], train_ratio: float) -> dict[str, str]:
|
||||
cutoff = int(len(stem_names) * max(0.0, min(train_ratio, 1.0)))
|
||||
fallback: dict[str, str] = {}
|
||||
for index, stem in enumerate(sorted(stem_names)):
|
||||
fallback[stem] = "train" if index < cutoff else "val"
|
||||
return fallback
|
||||
|
||||
|
||||
def yolo_box(size: tuple[int, int], bbox: tuple[int, int, int, int]) -> tuple[float, float, float, float]:
|
||||
width, height = size
|
||||
xmin, ymin, xmax, ymax = bbox
|
||||
box_width = max(xmax - xmin, 1)
|
||||
box_height = max(ymax - ymin, 1)
|
||||
x_center = xmin + box_width / 2.0
|
||||
y_center = ymin + box_height / 2.0
|
||||
return (
|
||||
x_center / width,
|
||||
y_center / height,
|
||||
box_width / width,
|
||||
box_height / height,
|
||||
)
|
||||
|
||||
|
||||
def parse_annotation(xml_bytes: bytes) -> tuple[tuple[int, int], list[dict[str, object]]]:
|
||||
root = ET.fromstring(xml_bytes)
|
||||
size_node = root.find("size")
|
||||
width = int(size_node.findtext("width", default="0"))
|
||||
height = int(size_node.findtext("height", default="0"))
|
||||
parsed: list[dict[str, object]] = []
|
||||
for obj in root.findall("object"):
|
||||
sign_code = (obj.findtext("name") or "").strip()
|
||||
mapped = ARTS_CODE_MAP.get(sign_code)
|
||||
if mapped is None:
|
||||
continue
|
||||
bbox_node = obj.find("bndbox")
|
||||
xmin = int(float(bbox_node.findtext("xmin", default="0")))
|
||||
ymin = int(float(bbox_node.findtext("ymin", default="0")))
|
||||
xmax = int(float(bbox_node.findtext("xmax", default="0")))
|
||||
ymax = int(float(bbox_node.findtext("ymax", default="0")))
|
||||
class_name, speed_value = mapped
|
||||
parsed.append({
|
||||
"sign_code": sign_code,
|
||||
"class_name": class_name,
|
||||
"speed_limit_mph": speed_value,
|
||||
"bbox": (xmin, ymin, xmax, ymax),
|
||||
})
|
||||
return (width, height), parsed
|
||||
|
||||
|
||||
def update_split_lookup(split_lookup: dict[str, str], split_name: str, text: str) -> None:
|
||||
normalized_split = "val" if split_name == "test" else split_name
|
||||
for line in text.splitlines():
|
||||
stem = line.strip()
|
||||
if stem:
|
||||
split_lookup[stem] = normalized_split
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
archive_path = args.archive.resolve() if args.archive else default_archive_path(workspace)
|
||||
if not archive_path.is_file():
|
||||
raise FileNotFoundError(f"ARTS archive not found: {archive_path}")
|
||||
|
||||
detector_manifest_path = workspace / "manifests" / "public_detector_samples.csv"
|
||||
classifier_manifest_path = workspace / "manifests" / "public_classifier_samples.csv"
|
||||
value_labels_path = workspace / "classifier" / "value_labels.csv"
|
||||
raw_sources_path = workspace / "manifests" / "raw_sources.csv"
|
||||
|
||||
existing_detector_rows = [row for row in read_existing_rows(detector_manifest_path) if row.get("source_name") != SOURCE_NAME]
|
||||
existing_classifier_rows = [row for row in read_existing_rows(classifier_manifest_path) if row.get("source_name") != SOURCE_NAME]
|
||||
existing_value_rows = [row for row in read_existing_rows(value_labels_path) if not (row.get("image_path") or "").startswith("detector/images/") or SOURCE_NAME not in (row.get("image_path") or "")]
|
||||
existing_source_rows = [row for row in read_existing_rows(raw_sources_path) if row.get("source_name") != SOURCE_NAME]
|
||||
|
||||
detector_rows: list[dict[str, str]] = []
|
||||
classifier_rows: list[dict[str, str]] = []
|
||||
value_rows: list[dict[str, str]] = []
|
||||
split_lookup: dict[str, str] = {}
|
||||
annotations_by_stem: dict[str, dict[str, object]] = {}
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile():
|
||||
continue
|
||||
if member.name == "challenging/ImageSets/Main/train.txt":
|
||||
update_split_lookup(split_lookup, "train", tar.extractfile(member).read().decode("utf-8", "ignore"))
|
||||
continue
|
||||
if member.name == "challenging/ImageSets/Main/val.txt":
|
||||
update_split_lookup(split_lookup, "val", tar.extractfile(member).read().decode("utf-8", "ignore"))
|
||||
continue
|
||||
if member.name == "challenging/ImageSets/Main/test.txt":
|
||||
update_split_lookup(split_lookup, "test", tar.extractfile(member).read().decode("utf-8", "ignore"))
|
||||
continue
|
||||
if not (member.name.startswith("challenging/Annotations/") and member.name.endswith(".xml")):
|
||||
continue
|
||||
stem = Path(member.name).stem
|
||||
image_size, parsed_boxes = parse_annotation(tar.extractfile(member).read())
|
||||
if not parsed_boxes:
|
||||
continue
|
||||
annotations_by_stem[stem] = {
|
||||
"image_size": image_size,
|
||||
"boxes": parsed_boxes,
|
||||
"annotation_name": member.name,
|
||||
}
|
||||
|
||||
if not split_lookup:
|
||||
split_lookup = build_fallback_split_lookup(list(annotations_by_stem), args.train_split)
|
||||
|
||||
imported_images = 0
|
||||
imported_boxes = 0
|
||||
class_counts: dict[str, int] = defaultdict(int)
|
||||
|
||||
with tarfile.open(archive_path, "r:gz") as tar:
|
||||
for member in tar:
|
||||
if not member.isfile():
|
||||
continue
|
||||
if not (member.name.startswith("challenging/JPEGImages/") and member.name.endswith(".jpg")):
|
||||
continue
|
||||
stem = Path(member.name).stem
|
||||
annotation = annotations_by_stem.get(stem)
|
||||
if annotation is None:
|
||||
continue
|
||||
|
||||
split = split_lookup.get(stem, "train")
|
||||
image_bytes = tar.extractfile(member).read()
|
||||
image_size = annotation["image_size"] # type: ignore[assignment]
|
||||
parsed_boxes = annotation["boxes"] # type: ignore[assignment]
|
||||
|
||||
image_out = workspace / "detector" / "images" / split / f"{SOURCE_NAME}_{stem}.jpg"
|
||||
label_out = workspace / "detector" / "labels" / split / f"{SOURCE_NAME}_{stem}.txt"
|
||||
if args.overwrite or not image_out.exists():
|
||||
ensure_dir(image_out.parent)
|
||||
image_out.write_bytes(image_bytes)
|
||||
yolo_lines: list[str] = []
|
||||
for bbox_index, box in enumerate(parsed_boxes):
|
||||
class_name = str(box["class_name"])
|
||||
speed_value = box["speed_limit_mph"]
|
||||
class_id = DETECTOR_CLASS_NAMES.index(class_name)
|
||||
x_center, y_center, width, height = yolo_box(image_size, box["bbox"]) # type: ignore[arg-type]
|
||||
yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
|
||||
xmin, ymin, xmax, ymax = box["bbox"] # type: ignore[misc]
|
||||
record_key = f"{SOURCE_NAME}:{stem}:{bbox_index}"
|
||||
detector_rows.append({
|
||||
"record_key": record_key,
|
||||
"source_name": SOURCE_NAME,
|
||||
"split": split,
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
"annotation_path": f"{archive_path}:{annotation['annotation_name']}",
|
||||
"source_image_id": stem,
|
||||
"class_name": class_name,
|
||||
"speed_limit_mph": "" if speed_value is None else str(speed_value),
|
||||
"sign_code": str(box["sign_code"]),
|
||||
"bbox_left": str(xmin),
|
||||
"bbox_top": str(ymin),
|
||||
"bbox_right": str(xmax),
|
||||
"bbox_bottom": str(ymax),
|
||||
})
|
||||
class_counts[class_name] += 1
|
||||
imported_boxes += 1
|
||||
if speed_value is not None:
|
||||
classifier_rows.append({
|
||||
"record_key": record_key,
|
||||
"source_name": SOURCE_NAME,
|
||||
"split": split,
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"bbox_index": str(bbox_index),
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
"source_image_id": stem,
|
||||
"sign_code": str(box["sign_code"]),
|
||||
})
|
||||
value_rows.append({
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"split": split,
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"bbox_index": str(bbox_index),
|
||||
"padding": "0.10",
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
})
|
||||
|
||||
if yolo_lines and (args.overwrite or not label_out.exists()):
|
||||
ensure_dir(label_out.parent)
|
||||
label_out.write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
|
||||
imported_images += 1
|
||||
|
||||
source_row = {
|
||||
"source_name": SOURCE_NAME,
|
||||
"source_version": SOURCE_VERSION,
|
||||
"source_license": SOURCE_LICENSE,
|
||||
"source_type": "public_detector_and_classifier_seed",
|
||||
"raw_path": str(archive_path),
|
||||
"notes": "ARTS challenging subset imported from VOC XML. Only mapped speed-limit classes were kept.",
|
||||
}
|
||||
|
||||
write_rows(raw_sources_path, RAW_SOURCE_FIELDS, existing_source_rows + [source_row])
|
||||
write_rows(detector_manifest_path, PUBLIC_DETECTOR_SAMPLE_FIELDS, existing_detector_rows + detector_rows)
|
||||
write_rows(classifier_manifest_path, PUBLIC_CLASSIFIER_SAMPLE_FIELDS, existing_classifier_rows + classifier_rows)
|
||||
write_rows(value_labels_path, VALUE_LABEL_FIELDS, existing_value_rows + value_rows)
|
||||
|
||||
class_summary = ", ".join(f"{name}={count}" for name, count in sorted(class_counts.items()))
|
||||
print(f"Imported {imported_images} ARTS image(s) and {imported_boxes} box(es) from {archive_path}")
|
||||
print(f" detector manifest: {detector_manifest_path}")
|
||||
print(f" classifier manifest: {classifier_manifest_path}")
|
||||
print(f" class counts: {class_summary or 'none'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import math
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
BOOKMARK_LEADIN_MANIFEST_FIELDS,
|
||||
DEFAULT_WORKSPACE,
|
||||
ensure_dir,
|
||||
preferred_clip_root,
|
||||
resolve_workspace,
|
||||
write_csv_header,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
BOOKMARK_LEADIN_MANIFEST_FIELDS,
|
||||
DEFAULT_WORKSPACE,
|
||||
ensure_dir,
|
||||
preferred_clip_root,
|
||||
resolve_workspace,
|
||||
write_csv_header,
|
||||
)
|
||||
|
||||
|
||||
DEFAULT_REPORT_JSON = Path(".tmp/live_route_clips/bookmark_windows_report.json")
|
||||
DEFAULT_CLIP_ROOT = preferred_clip_root()
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import sampled 5-second pre-bookmark route windows into the speed-limit training workspace.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--report-json", type=Path, default=DEFAULT_REPORT_JSON, help="JSON output from evaluate_bookmark_leadins.py.")
|
||||
parser.add_argument("--clip-root", type=Path, default=DEFAULT_CLIP_ROOT, help="Copied route clip root used by evaluate_bookmark_leadins.py.")
|
||||
parser.add_argument("--source-name", default="comma_bookmark", help="Logical source name for imported bookmark windows.")
|
||||
parser.add_argument("--source-region", default="", help="Optional region or market label, e.g. us_midwest.")
|
||||
parser.add_argument("--source-device", default="", help="Optional device identifier or platform label.")
|
||||
parser.add_argument("--source-driver", default="", help="Optional contributor/driver identifier.")
|
||||
parser.add_argument("--mode", choices=("misses", "hits", "all"), default="misses", help="Which bookmark windows to sample.")
|
||||
parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled review frames.")
|
||||
parser.add_argument("--max-samples", type=int, default=12, help="Optional cap on sampled frames per bookmark window.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite existing extracted review frames and contact sheets.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_existing_rows(manifest_path: Path) -> dict[str, dict[str, str]]:
|
||||
if not manifest_path.exists():
|
||||
return {}
|
||||
|
||||
with manifest_path.open("r", encoding="utf-8", newline="") as manifest_file:
|
||||
reader = csv.DictReader(manifest_file)
|
||||
return {row["record_key"]: row for row in reader if row.get("record_key")}
|
||||
|
||||
|
||||
def read_frame_at(video_path: Path, target_time_s: float):
|
||||
capture = cv2.VideoCapture(str(video_path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
frame_index = max(int(round(target_time_s * fps)), 0)
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
|
||||
ok, frame_bgr = capture.read()
|
||||
capture.release()
|
||||
if not ok or frame_bgr is None:
|
||||
return None
|
||||
actual_time_s = frame_index / fps
|
||||
return actual_time_s, frame_bgr
|
||||
|
||||
|
||||
def sample_offsets(leadin_start_s: float, segment_offset_s: float, sample_every: float, max_samples: int):
|
||||
if sample_every <= 0:
|
||||
raise ValueError("sample_every must be positive")
|
||||
|
||||
duration_s = max(segment_offset_s - leadin_start_s, 0.0)
|
||||
sample_count = int(math.floor(duration_s / sample_every)) + 1
|
||||
if max_samples > 0:
|
||||
sample_count = min(sample_count, max_samples)
|
||||
if sample_count <= 1:
|
||||
return [segment_offset_s]
|
||||
|
||||
if max_samples > 0 and sample_count == max_samples:
|
||||
return [leadin_start_s + duration_s * index / (sample_count - 1) for index in range(sample_count)]
|
||||
|
||||
offsets = []
|
||||
sample_offset_s = leadin_start_s
|
||||
while sample_offset_s <= segment_offset_s + 1e-6:
|
||||
offsets.append(sample_offset_s)
|
||||
sample_offset_s += sample_every
|
||||
return offsets[:sample_count]
|
||||
|
||||
|
||||
def extract_window_frames(row: dict, clip_root: Path, sample_every: float, max_samples: int):
|
||||
route = row["route"]
|
||||
segment = int(row["segment"])
|
||||
segment_offset_s = float(row["segmentOffsetS"])
|
||||
leadin_start_s = float(row["leadinStartS"])
|
||||
spans_previous_segment = bool(row.get("spansPreviousSegment"))
|
||||
|
||||
sampled = []
|
||||
previous_clip = clip_root / f"{route}--{segment - 1}" / "fcamera.hevc"
|
||||
current_clip = clip_root / f"{route}--{segment}" / "fcamera.hevc"
|
||||
|
||||
for relative_offset_s in sample_offsets(leadin_start_s, segment_offset_s, sample_every, max_samples):
|
||||
if relative_offset_s < 0.0 and spans_previous_segment and segment > 0:
|
||||
source_video = previous_clip
|
||||
source_time_s = 60.0 + relative_offset_s
|
||||
else:
|
||||
source_video = current_clip
|
||||
source_time_s = max(relative_offset_s, 0.0)
|
||||
|
||||
if not source_video.is_file():
|
||||
continue
|
||||
|
||||
frame_info = read_frame_at(source_video, source_time_s)
|
||||
if frame_info is None:
|
||||
continue
|
||||
|
||||
actual_time_s, frame_bgr = frame_info
|
||||
sampled.append({
|
||||
"relative_offset_s": relative_offset_s,
|
||||
"actual_time_s": actual_time_s,
|
||||
"source_video": source_video,
|
||||
"frame_bgr": frame_bgr,
|
||||
})
|
||||
|
||||
return sampled
|
||||
|
||||
|
||||
def write_contact_sheet(output_path: Path, frames: list[np.ndarray], labels: list[str], overwrite: bool):
|
||||
if output_path.exists() and not overwrite:
|
||||
return
|
||||
ensure_dir(output_path.parent)
|
||||
|
||||
columns = 4
|
||||
rows = max(int(math.ceil(len(frames) / columns)), 1)
|
||||
tile_height, tile_width = 256, 456
|
||||
header_height = 26
|
||||
sheet = np.full((rows * (tile_height + header_height), columns * tile_width, 3), 24, dtype=np.uint8)
|
||||
|
||||
for index, (frame_bgr, label) in enumerate(zip(frames, labels, strict=False)):
|
||||
row_index = index // columns
|
||||
column_index = index % columns
|
||||
y = row_index * (tile_height + header_height)
|
||||
x = column_index * tile_width
|
||||
|
||||
resized = cv2.resize(frame_bgr, (tile_width, tile_height), interpolation=cv2.INTER_AREA)
|
||||
sheet[y + header_height:y + header_height + tile_height, x:x + tile_width] = resized
|
||||
cv2.putText(sheet, label, (x + 8, y + 18), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (235, 235, 235), 1, cv2.LINE_AA)
|
||||
|
||||
cv2.imwrite(str(output_path), sheet, [cv2.IMWRITE_JPEG_QUALITY, 88])
|
||||
|
||||
|
||||
def include_row(row: dict, mode: str):
|
||||
hit = bool(row.get("hit"))
|
||||
if mode == "all":
|
||||
return "segment" in row
|
||||
if mode == "hits":
|
||||
return "segment" in row and hit
|
||||
return "segment" in row and not hit
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
report_rows = json.loads(args.report_json.expanduser().resolve().read_text(encoding="utf-8"))
|
||||
clip_root = args.clip_root.expanduser().resolve()
|
||||
|
||||
frame_dir = ensure_dir(workspace / "review" / "leadins" / "frames")
|
||||
contact_sheet_dir = ensure_dir(workspace / "review" / "leadins" / "contact_sheets")
|
||||
manifest_path = workspace / "review" / "bookmark_leadins.csv"
|
||||
write_csv_header(manifest_path, BOOKMARK_LEADIN_MANIFEST_FIELDS)
|
||||
manifest_rows = load_existing_rows(manifest_path)
|
||||
|
||||
imported_windows = 0
|
||||
imported_frames = 0
|
||||
for row in report_rows:
|
||||
if not include_row(row, args.mode):
|
||||
continue
|
||||
|
||||
sampled_frames = extract_window_frames(row, clip_root, args.sample_every, args.max_samples)
|
||||
if not sampled_frames:
|
||||
continue
|
||||
|
||||
imported_windows += 1
|
||||
window_result = "hit" if row.get("hit") else "miss"
|
||||
session_id = row["sessionId"]
|
||||
bookmark_number = int(row["bookmarkNumber"])
|
||||
route = row["route"]
|
||||
segment = int(row["segment"])
|
||||
published_values = ",".join(str(value) for value in row.get("publishedValues", []))
|
||||
candidate_values = ",".join(str(value) for value in row.get("candidateValues", []))
|
||||
|
||||
contact_sheet_name = f"{session_id}_bookmark_{bookmark_number:03d}_{window_result}.jpg"
|
||||
contact_sheet_path = contact_sheet_dir / contact_sheet_name
|
||||
contact_sheet_labels = []
|
||||
contact_sheet_frames = []
|
||||
|
||||
for sample_index, sample in enumerate(sampled_frames, start=1):
|
||||
frame_name = f"{session_id}_bookmark_{bookmark_number:03d}_sample_{sample_index:02d}.jpg"
|
||||
frame_path = frame_dir / frame_name
|
||||
if args.overwrite or not frame_path.exists():
|
||||
cv2.imwrite(str(frame_path), sample["frame_bgr"], [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
|
||||
imported_frames += 1
|
||||
contact_sheet_frames.append(sample["frame_bgr"])
|
||||
contact_sheet_labels.append(f"t={sample['relative_offset_s']:+.2f}s")
|
||||
|
||||
record_key = f"{session_id}:bookmark:{bookmark_number}:sample:{sample_index}"
|
||||
manifest_rows[record_key] = {
|
||||
"record_key": record_key,
|
||||
"source_name": args.source_name,
|
||||
"source_region": args.source_region,
|
||||
"source_device": args.source_device,
|
||||
"source_driver": args.source_driver,
|
||||
"session_id": session_id,
|
||||
"bookmark_number": str(bookmark_number),
|
||||
"route": route,
|
||||
"segment": str(segment),
|
||||
"segment_offset_s": str(row["segmentOffsetS"]),
|
||||
"leadin_start_s": str(row["leadinStartS"]),
|
||||
"sample_offset_s": f"{sample['relative_offset_s']:.3f}",
|
||||
"window_result": window_result,
|
||||
"published_values": published_values,
|
||||
"candidate_values": candidate_values,
|
||||
"frame_path": str(frame_path.relative_to(workspace)),
|
||||
"contact_sheet_path": str(contact_sheet_path.relative_to(workspace)),
|
||||
"source_video_path": str(sample["source_video"]),
|
||||
}
|
||||
|
||||
write_contact_sheet(contact_sheet_path, contact_sheet_frames, contact_sheet_labels, args.overwrite)
|
||||
|
||||
with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file:
|
||||
writer = csv.DictWriter(manifest_file, fieldnames=BOOKMARK_LEADIN_MANIFEST_FIELDS)
|
||||
writer.writeheader()
|
||||
for row in sorted(manifest_rows.values(), key=lambda entry: entry["record_key"]):
|
||||
writer.writerow(row)
|
||||
|
||||
print(f"Imported {imported_windows} lead-in window(s) and {imported_frames} sampled frame(s) into {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import shutil
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
BOOKMARK_MANIFEST_FIELDS,
|
||||
DEFAULT_DEBUG_BASE,
|
||||
DEFAULT_WORKSPACE,
|
||||
ensure_dir,
|
||||
latest_debug_sessions,
|
||||
read_jsonl,
|
||||
resolve_workspace,
|
||||
write_csv_header,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
BOOKMARK_MANIFEST_FIELDS,
|
||||
DEFAULT_DEBUG_BASE,
|
||||
DEFAULT_WORKSPACE,
|
||||
ensure_dir,
|
||||
latest_debug_sessions,
|
||||
read_jsonl,
|
||||
resolve_workspace,
|
||||
write_csv_header,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import speed-limit debug sessions into a training workspace manifest.")
|
||||
parser.add_argument("sessions", nargs="*", help="Session ids or full session paths. Defaults to the latest session under --debug-base.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--debug-base", type=Path, default=DEFAULT_DEBUG_BASE, help="Root directory containing vision debug sessions.")
|
||||
parser.add_argument("--latest", type=int, default=1, help="How many latest sessions to import when no session ids are provided.")
|
||||
parser.add_argument("--mode", choices=("symlink", "copy"), default="symlink", help="How to place snapshots into the workspace review/images directory.")
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite snapshot links/files if they already exist.")
|
||||
parser.add_argument("--events", nargs="+", default=["bookmark", "auto_bookmark", "publish", "candidate"], help="Event types to include in the manifest.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def resolve_sessions(session_args: list[str], debug_base: Path, latest_count: int) -> list[Path]:
|
||||
if session_args:
|
||||
resolved = []
|
||||
for session_arg in session_args:
|
||||
candidate = Path(session_arg).expanduser()
|
||||
if candidate.is_dir():
|
||||
resolved.append(candidate.resolve())
|
||||
continue
|
||||
candidate = (debug_base / session_arg).resolve()
|
||||
if candidate.is_dir():
|
||||
resolved.append(candidate)
|
||||
continue
|
||||
raise FileNotFoundError(f"Debug session not found: {session_arg}")
|
||||
return resolved
|
||||
|
||||
latest = latest_debug_sessions(debug_base, latest_count)
|
||||
if not latest:
|
||||
raise FileNotFoundError(f"No debug sessions found in {debug_base}")
|
||||
return latest
|
||||
|
||||
|
||||
def load_existing_rows(manifest_path: Path) -> dict[str, dict[str, str]]:
|
||||
if not manifest_path.exists():
|
||||
return {}
|
||||
|
||||
with manifest_path.open("r", encoding="utf-8", newline="") as manifest_file:
|
||||
reader = csv.DictReader(manifest_file)
|
||||
return {row["record_key"]: row for row in reader if row.get("record_key")}
|
||||
|
||||
|
||||
def stage_snapshot(source_path: Path, dest_path: Path, mode: str, force: bool) -> None:
|
||||
ensure_dir(dest_path.parent)
|
||||
if dest_path.exists() or dest_path.is_symlink():
|
||||
if not force:
|
||||
return
|
||||
if dest_path.is_dir():
|
||||
shutil.rmtree(dest_path)
|
||||
else:
|
||||
dest_path.unlink()
|
||||
|
||||
if mode == "copy":
|
||||
shutil.copy2(source_path, dest_path)
|
||||
else:
|
||||
dest_path.symlink_to(source_path)
|
||||
|
||||
|
||||
def event_row(event: dict, session_id: str, session_path: Path, event_index: int, snapshot_path: str) -> dict[str, str]:
|
||||
return {
|
||||
"record_key": f"{session_id}:{event_index}",
|
||||
"session_id": session_id,
|
||||
"event_index": str(event_index),
|
||||
"event": str(event.get("event") or ""),
|
||||
"session_seconds": str(event.get("sessionSeconds") or ""),
|
||||
"wall_time": str(event.get("wallTime") or ""),
|
||||
"road_name": str(event.get("roadName") or ""),
|
||||
"stream": str(event.get("stream") or ""),
|
||||
"status": str(event.get("status") or ""),
|
||||
"candidate_speed_limit_mph": str(event.get("candidateSpeedLimitMph") or ""),
|
||||
"candidate_confidence": str(event.get("candidateConfidence") or ""),
|
||||
"speed_limit_mph": str(event.get("speedLimitMph") or ""),
|
||||
"confidence": str(event.get("confidence") or ""),
|
||||
"published_speed_limit_mph": str(event.get("publishedSpeedLimitMph") or ""),
|
||||
"published_confidence": str(event.get("publishedConfidence") or ""),
|
||||
"bookmark_count": str(event.get("bookmarkCount") or ""),
|
||||
"snapshot_path": snapshot_path,
|
||||
"source_session_path": str(session_path),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
review_image_dir = ensure_dir(workspace / "review" / "images")
|
||||
manifest_path = workspace / "review" / "bookmarks.csv"
|
||||
write_csv_header(manifest_path, BOOKMARK_MANIFEST_FIELDS)
|
||||
|
||||
existing_rows = load_existing_rows(manifest_path)
|
||||
sessions = resolve_sessions(args.sessions, args.debug_base.expanduser().resolve(), args.latest)
|
||||
selected_events = set(args.events)
|
||||
|
||||
for session_path in sessions:
|
||||
events_path = session_path / "events.jsonl"
|
||||
if not events_path.is_file():
|
||||
continue
|
||||
|
||||
session_id = session_path.name
|
||||
for event_index, event in enumerate(read_jsonl(events_path)):
|
||||
event_type = str(event.get("event") or "")
|
||||
if event_type not in selected_events:
|
||||
continue
|
||||
|
||||
snapshot_rel_path = ""
|
||||
snapshot_name = event.get("snapshot")
|
||||
if snapshot_name:
|
||||
source_snapshot = session_path / str(snapshot_name)
|
||||
if source_snapshot.is_file():
|
||||
dest_name = f"{session_id}_{event_index:04d}_{event_type}{source_snapshot.suffix.lower()}"
|
||||
dest_snapshot = review_image_dir / dest_name
|
||||
stage_snapshot(source_snapshot, dest_snapshot, args.mode, args.force)
|
||||
snapshot_rel_path = str(dest_snapshot.relative_to(workspace))
|
||||
|
||||
row = event_row(event, session_id, session_path, event_index, snapshot_rel_path)
|
||||
existing_rows[row["record_key"]] = row
|
||||
|
||||
with manifest_path.open("w", encoding="utf-8", newline="") as manifest_file:
|
||||
writer = csv.DictWriter(manifest_file, fieldnames=BOOKMARK_MANIFEST_FIELDS)
|
||||
writer.writeheader()
|
||||
for row in sorted(existing_rows.values(), key=lambda entry: (entry["session_id"], int(entry["event_index"]))):
|
||||
writer.writerow(row)
|
||||
|
||||
print(f"Imported {len(sessions)} debug session(s) into {manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_CLASS_NAMES,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
VALUE_LABEL_FIELDS,
|
||||
default_raw_root,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_CLASS_NAMES,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
VALUE_LABEL_FIELDS,
|
||||
default_raw_root,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
SOURCE_NAME = "glare_images"
|
||||
SOURCE_VERSION = "GLARE Images"
|
||||
SOURCE_LICENSE = "CC BY 4.0"
|
||||
|
||||
SPEED_TAG_PATTERN = re.compile(r"(speedLimit|exitSpeedAdvisory|rampSpeedAdvisory)(\d+)$")
|
||||
GLARE_IGNORE_TAGS = {"speedLimit55Ahead"}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import GLARE image annotations into the speed-limit training workspace.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--images-root", type=Path, help="Path to the downloaded GLARE Images directory. Defaults to <raw>/glare_raw/Images.")
|
||||
parser.add_argument("--train-split", type=float, default=0.85, help="Train split ratio by origin-track hash.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite previously imported GLARE samples.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def default_images_root(workspace: Path) -> Path:
|
||||
return default_raw_root(workspace) / "glare_raw" / "Images"
|
||||
|
||||
|
||||
def read_existing_rows(path: Path) -> list[dict[str, str]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", newline="") as csv_file:
|
||||
return list(csv.DictReader(csv_file))
|
||||
|
||||
|
||||
def write_rows(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
|
||||
ensure_dir(path.parent)
|
||||
with path.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def infer_label(tag: str) -> tuple[str, int] | None:
|
||||
if tag in GLARE_IGNORE_TAGS:
|
||||
return None
|
||||
match = SPEED_TAG_PATTERN.fullmatch(tag)
|
||||
if not match:
|
||||
return None
|
||||
tag_type, speed_value_text = match.groups()
|
||||
speed_value = int(speed_value_text)
|
||||
if tag_type == "speedLimit":
|
||||
return ("regulatory_speed_limit", speed_value)
|
||||
return ("advisory_speed_limit", speed_value)
|
||||
|
||||
|
||||
def split_for_track(track_name: str, train_ratio: float) -> str:
|
||||
digest = hashlib.md5(track_name.encode("utf-8")).hexdigest()
|
||||
value = int(digest[:8], 16) / 0xFFFFFFFF
|
||||
return "train" if value < train_ratio else "val"
|
||||
|
||||
|
||||
def yolo_box(image_width: int, image_height: int, xmin: int, ymin: int, xmax: int, ymax: int) -> tuple[float, float, float, float]:
|
||||
box_width = max(xmax - xmin, 1)
|
||||
box_height = max(ymax - ymin, 1)
|
||||
x_center = xmin + box_width / 2.0
|
||||
y_center = ymin + box_height / 2.0
|
||||
return (
|
||||
x_center / image_width,
|
||||
y_center / image_height,
|
||||
box_width / image_width,
|
||||
box_height / image_height,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
images_root = args.images_root.resolve() if args.images_root else default_images_root(workspace)
|
||||
annotations_csv = images_root / "allAnnotations.csv"
|
||||
if not annotations_csv.is_file():
|
||||
raise FileNotFoundError(f"GLARE allAnnotations.csv not found: {annotations_csv}")
|
||||
|
||||
detector_manifest_path = workspace / "manifests" / "public_detector_samples.csv"
|
||||
classifier_manifest_path = workspace / "manifests" / "public_classifier_samples.csv"
|
||||
value_labels_path = workspace / "classifier" / "value_labels.csv"
|
||||
raw_sources_path = workspace / "manifests" / "raw_sources.csv"
|
||||
|
||||
existing_detector_rows = [row for row in read_existing_rows(detector_manifest_path) if row.get("source_name") != SOURCE_NAME]
|
||||
existing_classifier_rows = [row for row in read_existing_rows(classifier_manifest_path) if row.get("source_name") != SOURCE_NAME]
|
||||
existing_value_rows = [row for row in read_existing_rows(value_labels_path) if SOURCE_NAME not in (row.get("image_path") or "")]
|
||||
existing_source_rows = [row for row in read_existing_rows(raw_sources_path) if row.get("source_name") != SOURCE_NAME]
|
||||
|
||||
grouped: dict[str, list[dict[str, str]]] = defaultdict(list)
|
||||
with annotations_csv.open("r", encoding="utf-8", newline="") as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
for row in reader:
|
||||
tag = (row.get("Annotation tag") or "").strip()
|
||||
if infer_label(tag) is None:
|
||||
continue
|
||||
filename = (row.get("Filename") or "").strip()
|
||||
if filename:
|
||||
grouped[filename].append(row)
|
||||
|
||||
detector_rows: list[dict[str, str]] = []
|
||||
classifier_rows: list[dict[str, str]] = []
|
||||
value_rows: list[dict[str, str]] = []
|
||||
class_counts: dict[str, int] = defaultdict(int)
|
||||
imported_images = 0
|
||||
imported_boxes = 0
|
||||
|
||||
for filename in sorted(grouped):
|
||||
source_image = images_root / filename
|
||||
if not source_image.is_file():
|
||||
continue
|
||||
|
||||
box_rows = grouped[filename]
|
||||
track_name = (box_rows[0].get("Origin track") or filename).strip()
|
||||
split = split_for_track(track_name, args.train_split)
|
||||
stem = Path(filename).stem
|
||||
image_out = workspace / "detector" / "images" / split / f"{SOURCE_NAME}_{stem}.png"
|
||||
label_out = workspace / "detector" / "labels" / split / f"{SOURCE_NAME}_{stem}.txt"
|
||||
image_bgr = cv2.imread(str(source_image))
|
||||
if image_bgr is None:
|
||||
continue
|
||||
image_height, image_width = image_bgr.shape[:2]
|
||||
|
||||
if args.overwrite or not image_out.exists():
|
||||
ensure_dir(image_out.parent)
|
||||
image_out.write_bytes(source_image.read_bytes())
|
||||
|
||||
yolo_lines: list[str] = []
|
||||
for bbox_index, row in enumerate(box_rows):
|
||||
tag = row["Annotation tag"].strip()
|
||||
inferred = infer_label(tag)
|
||||
if inferred is None:
|
||||
continue
|
||||
class_name, speed_value = inferred
|
||||
class_id = DETECTOR_CLASS_NAMES.index(class_name)
|
||||
xmin = int(float(row["Upper left corner X"]))
|
||||
ymin = int(float(row["Upper left corner Y"]))
|
||||
xmax = int(float(row["Lower right corner X"]))
|
||||
ymax = int(float(row["Lower right corner Y"]))
|
||||
x_center, y_center, width, height = yolo_box(image_width, image_height, xmin, ymin, xmax, ymax)
|
||||
yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
|
||||
|
||||
record_key = f"{SOURCE_NAME}:{stem}:{bbox_index}"
|
||||
detector_rows.append({
|
||||
"record_key": record_key,
|
||||
"source_name": SOURCE_NAME,
|
||||
"split": split,
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
"annotation_path": str(annotations_csv),
|
||||
"source_image_id": filename,
|
||||
"class_name": class_name,
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"sign_code": tag,
|
||||
"bbox_left": str(xmin),
|
||||
"bbox_top": str(ymin),
|
||||
"bbox_right": str(xmax),
|
||||
"bbox_bottom": str(ymax),
|
||||
})
|
||||
classifier_rows.append({
|
||||
"record_key": record_key,
|
||||
"source_name": SOURCE_NAME,
|
||||
"split": split,
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"bbox_index": str(bbox_index),
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
"source_image_id": filename,
|
||||
"sign_code": tag,
|
||||
})
|
||||
value_rows.append({
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"split": split,
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"bbox_index": str(bbox_index),
|
||||
"padding": "0.10",
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
})
|
||||
class_counts[f"{class_name}:{speed_value}"] += 1
|
||||
imported_boxes += 1
|
||||
|
||||
if yolo_lines and (args.overwrite or not label_out.exists()):
|
||||
ensure_dir(label_out.parent)
|
||||
label_out.write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
|
||||
imported_images += 1
|
||||
|
||||
source_row = {
|
||||
"source_name": SOURCE_NAME,
|
||||
"source_version": SOURCE_VERSION,
|
||||
"source_license": SOURCE_LICENSE,
|
||||
"source_type": "public_detector_and_classifier_seed",
|
||||
"raw_path": str(images_root),
|
||||
"notes": "Imported GLARE Images/allAnnotations.csv speed-limit and advisory-speed tags.",
|
||||
}
|
||||
|
||||
write_rows(raw_sources_path, RAW_SOURCE_FIELDS, existing_source_rows + [source_row])
|
||||
write_rows(detector_manifest_path, PUBLIC_DETECTOR_SAMPLE_FIELDS, existing_detector_rows + detector_rows)
|
||||
write_rows(classifier_manifest_path, PUBLIC_CLASSIFIER_SAMPLE_FIELDS, existing_classifier_rows + classifier_rows)
|
||||
write_rows(value_labels_path, VALUE_LABEL_FIELDS, existing_value_rows + value_rows)
|
||||
|
||||
summary = ", ".join(f"{name}={count}" for name, count in sorted(class_counts.items()))
|
||||
print(f"Imported {imported_images} GLARE image(s) and {imported_boxes} box(es) from {images_root}")
|
||||
print(f" detector manifest: {detector_manifest_path}")
|
||||
print(f" classifier manifest: {classifier_manifest_path}")
|
||||
print(f" class counts: {summary or 'none'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,272 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import io
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from zipfile import ZipFile
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_CLASS_NAMES,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
VALUE_LABEL_FIELDS,
|
||||
default_raw_root,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_CLASS_NAMES,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
VALUE_LABEL_FIELDS,
|
||||
default_raw_root,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
SOURCE_NAME = "lisa_traffic_sign"
|
||||
SOURCE_VERSION = "Kaggle omkarnadkarni/lisa-traffic-sign"
|
||||
SOURCE_LICENSE = "See Kaggle dataset page"
|
||||
DEFAULT_ZIP_RELATIVE = Path("lisa/lisa_traffic_sign.zip")
|
||||
|
||||
LISA_LABEL_MAP: dict[str, tuple[str, int | None] | None] = {
|
||||
"speedLimit15": ("regulatory_speed_limit", 15),
|
||||
"speedLimit25": ("regulatory_speed_limit", 25),
|
||||
"speedLimit30": ("regulatory_speed_limit", 30),
|
||||
"speedLimit35": ("regulatory_speed_limit", 35),
|
||||
"speedLimit40": ("regulatory_speed_limit", 40),
|
||||
"speedLimit45": ("regulatory_speed_limit", 45),
|
||||
"speedLimit50": ("regulatory_speed_limit", 50),
|
||||
"speedLimit55": ("regulatory_speed_limit", 55),
|
||||
"speedLimit65": ("regulatory_speed_limit", 65),
|
||||
"speedLimitUrdbl": ("regulatory_speed_limit", None),
|
||||
"schoolSpeedLimit25": ("school_zone_speed_limit", 25),
|
||||
"rampSpeedAdvisory20": ("advisory_speed_limit", 20),
|
||||
"rampSpeedAdvisory35": ("advisory_speed_limit", 35),
|
||||
"rampSpeedAdvisory40": ("advisory_speed_limit", 40),
|
||||
"rampSpeedAdvisory45": ("advisory_speed_limit", 45),
|
||||
"rampSpeedAdvisory50": ("advisory_speed_limit", 50),
|
||||
"rampSpeedAdvisoryUrdbl": ("advisory_speed_limit", None),
|
||||
"truckSpeedLimit55": None,
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import speed-related LISA samples from the Kaggle ZIP into the training workspace.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--zip-path", type=Path, help="Path to lisa_traffic_sign.zip. Defaults to the SSD raw-data layout.")
|
||||
parser.add_argument("--train-split", type=float, default=0.85, help="Train split ratio by origin-track hash.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite previously imported LISA samples.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def default_zip_path(workspace: Path) -> Path:
|
||||
return default_raw_root(workspace) / DEFAULT_ZIP_RELATIVE
|
||||
|
||||
|
||||
def read_existing_rows(path: Path) -> list[dict[str, str]]:
|
||||
if not path.is_file():
|
||||
return []
|
||||
with path.open("r", encoding="utf-8", newline="") as csv_file:
|
||||
return list(csv.DictReader(csv_file))
|
||||
|
||||
|
||||
def write_rows(path: Path, fieldnames: list[str], rows: list[dict[str, str]]) -> None:
|
||||
ensure_dir(path.parent)
|
||||
with path.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def split_for_track(track_name: str, train_ratio: float) -> str:
|
||||
digest = hashlib.md5(track_name.encode("utf-8")).hexdigest()
|
||||
value = int(digest[:8], 16) / 0xFFFFFFFF
|
||||
return "train" if value < train_ratio else "val"
|
||||
|
||||
|
||||
def yolo_box(image_width: int, image_height: int, xmin: int, ymin: int, xmax: int, ymax: int) -> tuple[float, float, float, float]:
|
||||
box_width = max(xmax - xmin, 1)
|
||||
box_height = max(ymax - ymin, 1)
|
||||
x_center = xmin + box_width / 2.0
|
||||
y_center = ymin + box_height / 2.0
|
||||
return (
|
||||
x_center / image_width,
|
||||
y_center / image_height,
|
||||
box_width / image_width,
|
||||
box_height / image_height,
|
||||
)
|
||||
|
||||
|
||||
def parse_lisa_csv(csv_text: str) -> dict[str, list[dict[str, str]]]:
|
||||
grouped: dict[str, list[dict[str, str]]] = defaultdict(list)
|
||||
reader = csv.DictReader(io.StringIO(csv_text), delimiter=";")
|
||||
for row in reader:
|
||||
tag = (row.get("Annotation tag") or "").strip()
|
||||
mapped = LISA_LABEL_MAP.get(tag)
|
||||
if mapped is None:
|
||||
continue
|
||||
filename = (row.get("Filename") or "").strip()
|
||||
if filename:
|
||||
grouped[filename].append(row)
|
||||
return grouped
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
zip_path = args.zip_path.resolve() if args.zip_path else default_zip_path(workspace)
|
||||
if not zip_path.is_file():
|
||||
raise FileNotFoundError(f"LISA ZIP not found: {zip_path}")
|
||||
|
||||
detector_manifest_path = workspace / "manifests" / "public_detector_samples.csv"
|
||||
classifier_manifest_path = workspace / "manifests" / "public_classifier_samples.csv"
|
||||
value_labels_path = workspace / "classifier" / "value_labels.csv"
|
||||
raw_sources_path = workspace / "manifests" / "raw_sources.csv"
|
||||
|
||||
existing_detector_rows = [row for row in read_existing_rows(detector_manifest_path) if row.get("source_name") != SOURCE_NAME]
|
||||
existing_classifier_rows = [row for row in read_existing_rows(classifier_manifest_path) if row.get("source_name") != SOURCE_NAME]
|
||||
existing_value_rows = [row for row in read_existing_rows(value_labels_path) if SOURCE_NAME not in (row.get("image_path") or "")]
|
||||
existing_source_rows = [row for row in read_existing_rows(raw_sources_path) if row.get("source_name") != SOURCE_NAME]
|
||||
|
||||
detector_rows: list[dict[str, str]] = []
|
||||
classifier_rows: list[dict[str, str]] = []
|
||||
value_rows: list[dict[str, str]] = []
|
||||
class_counts: dict[str, int] = defaultdict(int)
|
||||
imported_images = 0
|
||||
imported_boxes = 0
|
||||
|
||||
with ZipFile(zip_path) as zip_file:
|
||||
csv_members = sorted(name for name in zip_file.namelist() if name.endswith("frameAnnotations.csv"))
|
||||
for csv_member in csv_members:
|
||||
csv_text = zip_file.read(csv_member).decode("utf-8", "ignore")
|
||||
grouped = parse_lisa_csv(csv_text)
|
||||
csv_parent = Path(csv_member).parent
|
||||
drive_slug = Path(csv_member).parts[0]
|
||||
|
||||
for filename in sorted(grouped):
|
||||
source_image_member = str(csv_parent / filename)
|
||||
try:
|
||||
image_bytes = zip_file.read(source_image_member)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
image_array = cv2.imdecode(np.frombuffer(image_bytes, dtype=np.uint8), cv2.IMREAD_COLOR)
|
||||
if image_array is None:
|
||||
continue
|
||||
image_height, image_width = image_array.shape[:2]
|
||||
box_rows = grouped[filename]
|
||||
track_name = (box_rows[0].get("Origin track") or filename).strip()
|
||||
split = split_for_track(f"{drive_slug}:{track_name}", args.train_split)
|
||||
stem = Path(filename).stem
|
||||
out_stem = f"{SOURCE_NAME}_{drive_slug}_{stem}"
|
||||
image_out = workspace / "detector" / "images" / split / f"{out_stem}.png"
|
||||
label_out = workspace / "detector" / "labels" / split / f"{out_stem}.txt"
|
||||
|
||||
if args.overwrite or not image_out.exists():
|
||||
ensure_dir(image_out.parent)
|
||||
image_out.write_bytes(image_bytes)
|
||||
|
||||
yolo_lines: list[str] = []
|
||||
valid_boxes = 0
|
||||
for bbox_index, row in enumerate(box_rows):
|
||||
tag = row["Annotation tag"].strip()
|
||||
mapped = LISA_LABEL_MAP.get(tag)
|
||||
if mapped is None:
|
||||
continue
|
||||
class_name, speed_value = mapped
|
||||
class_id = DETECTOR_CLASS_NAMES.index(class_name)
|
||||
xmin = int(float(row["Upper left corner X"]))
|
||||
ymin = int(float(row["Upper left corner Y"]))
|
||||
xmax = int(float(row["Lower right corner X"]))
|
||||
ymax = int(float(row["Lower right corner Y"]))
|
||||
x_center, y_center, width, height = yolo_box(image_width, image_height, xmin, ymin, xmax, ymax)
|
||||
yolo_lines.append(f"{class_id} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}")
|
||||
|
||||
record_key = f"{SOURCE_NAME}:{drive_slug}:{stem}:{bbox_index}"
|
||||
detector_rows.append({
|
||||
"record_key": record_key,
|
||||
"source_name": SOURCE_NAME,
|
||||
"split": split,
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
"annotation_path": f"{zip_path}:{csv_member}",
|
||||
"source_image_id": f"{drive_slug}/{filename}",
|
||||
"class_name": class_name,
|
||||
"speed_limit_mph": "" if speed_value is None else str(speed_value),
|
||||
"sign_code": tag,
|
||||
"bbox_left": str(xmin),
|
||||
"bbox_top": str(ymin),
|
||||
"bbox_right": str(xmax),
|
||||
"bbox_bottom": str(ymax),
|
||||
})
|
||||
if speed_value is not None:
|
||||
classifier_rows.append({
|
||||
"record_key": record_key,
|
||||
"source_name": SOURCE_NAME,
|
||||
"split": split,
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"bbox_index": str(bbox_index),
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
"source_image_id": f"{drive_slug}/{filename}",
|
||||
"sign_code": tag,
|
||||
})
|
||||
value_rows.append({
|
||||
"image_path": str(image_out.relative_to(workspace)),
|
||||
"split": split,
|
||||
"speed_limit_mph": str(speed_value),
|
||||
"bbox_index": str(bbox_index),
|
||||
"padding": "0.10",
|
||||
"label_path": str(label_out.relative_to(workspace)),
|
||||
})
|
||||
class_counts[f"{class_name}:{'' if speed_value is None else speed_value}"] += 1
|
||||
imported_boxes += 1
|
||||
valid_boxes += 1
|
||||
|
||||
if valid_boxes and (args.overwrite or not label_out.exists()):
|
||||
ensure_dir(label_out.parent)
|
||||
label_out.write_text("\n".join(yolo_lines) + "\n", encoding="utf-8")
|
||||
imported_images += 1
|
||||
|
||||
source_row = {
|
||||
"source_name": SOURCE_NAME,
|
||||
"source_version": SOURCE_VERSION,
|
||||
"source_license": SOURCE_LICENSE,
|
||||
"source_type": "public_detector_and_classifier_seed",
|
||||
"raw_path": str(zip_path),
|
||||
"notes": "Imported speed-related LISA samples directly from the Kaggle ZIP. Ignores truckSpeedLimit55.",
|
||||
}
|
||||
|
||||
write_rows(raw_sources_path, RAW_SOURCE_FIELDS, existing_source_rows + [source_row])
|
||||
write_rows(detector_manifest_path, PUBLIC_DETECTOR_SAMPLE_FIELDS, existing_detector_rows + detector_rows)
|
||||
write_rows(classifier_manifest_path, PUBLIC_CLASSIFIER_SAMPLE_FIELDS, existing_classifier_rows + classifier_rows)
|
||||
write_rows(value_labels_path, VALUE_LABEL_FIELDS, existing_value_rows + value_rows)
|
||||
|
||||
summary = ", ".join(f"{name}={count}" for name, count in sorted(class_counts.items()))
|
||||
print(f"Imported {imported_images} LISA image(s) and {imported_boxes} box(es) from {zip_path}")
|
||||
print(f" detector manifest: {detector_manifest_path}")
|
||||
print(f" classifier manifest: {classifier_manifest_path}")
|
||||
print(f" class counts: {summary or 'none'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,286 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import random
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
LOCALIZED_MANIFEST = Path(".tmp/bookmark_sign_localization/localized_bookmarks.csv")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Import high-confidence localized bookmark sign frames into the detector dataset.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--localized-manifest", type=Path, default=LOCALIZED_MANIFEST, help="CSV output from localize_bookmark_signs.py.")
|
||||
parser.add_argument("--manifest-out", type=Path, help="Optional output CSV manifest path.")
|
||||
parser.add_argument("--split", choices=("train", "val"), default="train", help="Target detector split.")
|
||||
parser.add_argument("--source-name", default="comma_bookmark_localized", help="Logical source name for imported localized bookmarks.")
|
||||
parser.add_argument("--source-region", default="", help="Optional region or market label, e.g. us_midwest.")
|
||||
parser.add_argument("--source-device", default="", help="Optional device identifier or platform label.")
|
||||
parser.add_argument("--source-driver", default="", help="Optional contributor/driver identifier.")
|
||||
parser.add_argument("--session-id", action="append", default=[], help="Optional session_id filter. May be specified multiple times.")
|
||||
parser.add_argument("--min-score", type=float, default=1.2, help="Minimum localization score to keep.")
|
||||
parser.add_argument("--min-width", type=int, default=25, help="Minimum bbox width in pixels.")
|
||||
parser.add_argument("--min-height", type=int, default=40, help="Minimum bbox height in pixels.")
|
||||
parser.add_argument("--require-consistent", type=int, default=2, help="Require this many matching non-empty value reads across model/ocr/full_detection.")
|
||||
parser.add_argument("--variants-per-example", type=int, default=6, help="Photometric variants to generate per localized frame.")
|
||||
parser.add_argument("--temporal-radius-s", type=float, default=0.0, help="Optional radius in seconds to sample neighboring source-video frames around each localized hit.")
|
||||
parser.add_argument("--temporal-step-s", type=float, default=0.15, help="Seconds between temporal neighbor samples when --temporal-radius-s is enabled.")
|
||||
parser.add_argument("--temporal-box-expand", type=float, default=0.14, help="Fractional box expansion to apply to temporal neighbor labels.")
|
||||
parser.add_argument("--seed", type=int, default=20260331, help="Random seed.")
|
||||
parser.add_argument("--jpeg-quality", type=int, default=95, help="JPEG quality for written detector images.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def detector_label_line(detector_class: int, x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> str:
|
||||
image_h, image_w = image_shape[:2]
|
||||
x_center = ((x1 + x2) / 2) / image_w
|
||||
y_center = ((y1 + y2) / 2) / image_h
|
||||
width = (x2 - x1) / image_w
|
||||
height = (y2 - y1) / image_h
|
||||
return f"{detector_class} {x_center:.6f} {y_center:.6f} {width:.6f} {height:.6f}\n"
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> None:
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def dominant_read(row: dict[str, str]) -> tuple[str, int]:
|
||||
counts: dict[str, int] = {}
|
||||
for key in ("model_read", "ocr_read", "full_detection"):
|
||||
raw = row.get(key, "")
|
||||
value = raw.split("@", 1)[0].strip() if raw else ""
|
||||
if value:
|
||||
counts[value] = counts.get(value, 0) + 1
|
||||
if not counts:
|
||||
return "", 0
|
||||
best_value, best_count = max(counts.items(), key=lambda item: (item[1], item[0]))
|
||||
return best_value, best_count
|
||||
|
||||
|
||||
def read_bbox(text: str) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = (int(part) for part in text.split(","))
|
||||
return x1, y1, x2, y2
|
||||
|
||||
|
||||
def clamp_bbox(x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int]) -> tuple[int, int, int, int]:
|
||||
image_h, image_w = image_shape[:2]
|
||||
x1 = max(0, min(x1, image_w - 2))
|
||||
y1 = max(0, min(y1, image_h - 2))
|
||||
x2 = max(x1 + 1, min(x2, image_w - 1))
|
||||
y2 = max(y1 + 1, min(y2, image_h - 1))
|
||||
return x1, y1, x2, y2
|
||||
|
||||
|
||||
def expand_bbox(x1: int, y1: int, x2: int, y2: int, image_shape: tuple[int, int, int], expand_ratio: float) -> tuple[int, int, int, int]:
|
||||
width = x2 - x1
|
||||
height = y2 - y1
|
||||
x_pad = int(round(width * max(expand_ratio, 0.0) * 0.5))
|
||||
y_pad = int(round(height * max(expand_ratio, 0.0) * 0.5))
|
||||
return clamp_bbox(x1 - x_pad, y1 - y_pad, x2 + x_pad, y2 + y_pad, image_shape)
|
||||
|
||||
|
||||
def read_frame_at(video_path: Path, target_time_s: float):
|
||||
capture = cv2.VideoCapture(str(video_path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
frame_index = max(int(round(target_time_s * fps)), 0)
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
|
||||
ok, frame_bgr = capture.read()
|
||||
capture.release()
|
||||
if not ok or frame_bgr is None:
|
||||
return None
|
||||
return frame_bgr
|
||||
|
||||
|
||||
def apply_variant(frame_bgr, variant_index: int, rng: random.Random):
|
||||
output = frame_bgr.copy()
|
||||
mode = variant_index % 6
|
||||
if mode == 0:
|
||||
alpha = rng.uniform(0.82, 0.94)
|
||||
beta = rng.randint(-18, 8)
|
||||
output = cv2.convertScaleAbs(output, alpha=alpha, beta=beta)
|
||||
elif mode == 1:
|
||||
alpha = rng.uniform(1.04, 1.14)
|
||||
beta = rng.randint(-8, 18)
|
||||
output = cv2.convertScaleAbs(output, alpha=alpha, beta=beta)
|
||||
elif mode == 2:
|
||||
output = cv2.GaussianBlur(output, (3, 3), rng.uniform(0.2, 0.8))
|
||||
elif mode == 3:
|
||||
noise = rng.normalvariate(0.0, 8.0)
|
||||
output = cv2.add(output, noise)
|
||||
elif mode == 4:
|
||||
hsv = cv2.cvtColor(output, cv2.COLOR_BGR2HSV)
|
||||
hsv[..., 2] = cv2.convertScaleAbs(hsv[..., 2], alpha=rng.uniform(0.78, 0.9), beta=0)
|
||||
output = cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
|
||||
else:
|
||||
encode_params = [cv2.IMWRITE_JPEG_QUALITY, rng.randint(72, 88)]
|
||||
ok, encoded = cv2.imencode(".jpg", output, encode_params)
|
||||
if ok:
|
||||
output = cv2.imdecode(encoded, cv2.IMREAD_COLOR)
|
||||
return output
|
||||
|
||||
|
||||
def iter_temporal_offsets(radius_s: float, step_s: float) -> list[float]:
|
||||
if radius_s <= 0.0 or step_s <= 0.0:
|
||||
return []
|
||||
steps = int(radius_s / step_s)
|
||||
offsets: list[float] = []
|
||||
for step in range(1, steps + 1):
|
||||
delta = round(step * step_s, 3)
|
||||
offsets.extend((-delta, delta))
|
||||
return sorted(offsets)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
localized_manifest = args.localized_manifest.expanduser().resolve()
|
||||
if not localized_manifest.is_file():
|
||||
raise FileNotFoundError(localized_manifest)
|
||||
session_filter = set(args.session_id)
|
||||
|
||||
image_dir = ensure_dir(workspace / "detector" / "images" / args.split)
|
||||
label_dir = ensure_dir(workspace / "detector" / "labels" / args.split)
|
||||
manifest_out = args.manifest_out.expanduser().resolve() if args.manifest_out else (ensure_dir(workspace / "review") / "localized_bookmark_detector_examples.csv")
|
||||
rng = random.Random(args.seed)
|
||||
temporal_offsets = iter_temporal_offsets(args.temporal_radius_s, args.temporal_step_s)
|
||||
|
||||
records: list[dict[str, object]] = []
|
||||
kept = 0
|
||||
|
||||
with localized_manifest.open("r", encoding="utf-8", newline="") as csv_file:
|
||||
reader = csv.DictReader(csv_file)
|
||||
for row in reader:
|
||||
if session_filter and row.get("session_id") not in session_filter:
|
||||
continue
|
||||
dominant_value, consistent_count = dominant_read(row)
|
||||
if consistent_count < max(args.require_consistent, 0):
|
||||
continue
|
||||
if row.get("is_regulatory", "").lower() != "true":
|
||||
continue
|
||||
score = float(row["score"])
|
||||
if score < args.min_score:
|
||||
continue
|
||||
x1, y1, x2, y2 = read_bbox(row["box"])
|
||||
width = x2 - x1
|
||||
height = y2 - y1
|
||||
if width < args.min_width or height < args.min_height:
|
||||
continue
|
||||
|
||||
frame_path = Path(row["frame_path"])
|
||||
frame_bgr = cv2.imread(str(frame_path))
|
||||
if frame_bgr is None:
|
||||
continue
|
||||
|
||||
class_id = int(row["class_id"])
|
||||
stem_base = f"real_localized_{row['session_id']}_{int(row['bookmark_number']):03d}"
|
||||
source_video_path = Path(row["source_video_path"]) if row.get("source_video_path") else None
|
||||
relative_time_s = float(row["relative_time_s"]) if row.get("relative_time_s") else 0.0
|
||||
|
||||
source_frames = [("base", frame_bgr, (x1, y1, x2, y2))]
|
||||
if source_video_path and source_video_path.is_file():
|
||||
for offset_s in temporal_offsets:
|
||||
temporal_frame = read_frame_at(source_video_path, max(relative_time_s + offset_s, 0.0))
|
||||
if temporal_frame is None:
|
||||
continue
|
||||
temporal_bbox = expand_bbox(x1, y1, x2, y2, temporal_frame.shape, args.temporal_box_expand)
|
||||
source_frames.append((f"t{offset_s:+.2f}s".replace(".", "p"), temporal_frame, temporal_bbox))
|
||||
|
||||
variants = []
|
||||
for source_suffix, source_bgr, source_bbox in source_frames:
|
||||
variants.append((source_suffix, source_bgr, source_bbox))
|
||||
for variant_index in range(max(args.variants_per_example, 0)):
|
||||
variants.append((f"{source_suffix}_var{variant_index:02d}", apply_variant(source_bgr, variant_index, rng), source_bbox))
|
||||
|
||||
for suffix, variant_bgr, variant_bbox in variants:
|
||||
image_path = image_dir / f"{stem_base}_{suffix}.jpg"
|
||||
label_path = label_dir / f"{stem_base}_{suffix}.txt"
|
||||
cv2.imwrite(str(image_path), variant_bgr, [cv2.IMWRITE_JPEG_QUALITY, args.jpeg_quality])
|
||||
vx1, vy1, vx2, vy2 = variant_bbox
|
||||
label_path.write_text(detector_label_line(class_id, vx1, vy1, vx2, vy2, variant_bgr.shape), encoding="utf-8")
|
||||
records.append({
|
||||
"record_key": f"{row['session_id']}_{row['bookmark_number']}_{suffix}",
|
||||
"split": args.split,
|
||||
"source_name": row.get("source_name", args.source_name),
|
||||
"source_region": row.get("source_region", args.source_region),
|
||||
"source_device": row.get("source_device", args.source_device),
|
||||
"source_driver": row.get("source_driver", args.source_driver),
|
||||
"session_id": row["session_id"],
|
||||
"bookmark_number": row["bookmark_number"],
|
||||
"route": row["route"],
|
||||
"segment": row["segment"],
|
||||
"relative_time_s": row["relative_time_s"],
|
||||
"score": row["score"],
|
||||
"class_id": class_id,
|
||||
"dominant_value": dominant_value,
|
||||
"consistent_count": consistent_count,
|
||||
"bbox_x1": vx1,
|
||||
"bbox_y1": vy1,
|
||||
"bbox_x2": vx2,
|
||||
"bbox_y2": vy2,
|
||||
"source_frame": str(frame_path),
|
||||
"source_crop": row["crop_path"],
|
||||
"source_video": str(source_video_path) if source_video_path else "",
|
||||
"source_relative_time_s": relative_time_s,
|
||||
"dataset_image": str(image_path),
|
||||
"dataset_label": str(label_path),
|
||||
})
|
||||
kept += 1
|
||||
|
||||
remove_appledouble_files(image_dir)
|
||||
remove_appledouble_files(label_dir)
|
||||
|
||||
fieldnames = [
|
||||
"record_key",
|
||||
"split",
|
||||
"source_name",
|
||||
"source_region",
|
||||
"source_device",
|
||||
"source_driver",
|
||||
"session_id",
|
||||
"bookmark_number",
|
||||
"route",
|
||||
"segment",
|
||||
"relative_time_s",
|
||||
"score",
|
||||
"class_id",
|
||||
"dominant_value",
|
||||
"consistent_count",
|
||||
"bbox_x1",
|
||||
"bbox_y1",
|
||||
"bbox_x2",
|
||||
"bbox_y2",
|
||||
"source_frame",
|
||||
"source_crop",
|
||||
"source_video",
|
||||
"source_relative_time_s",
|
||||
"dataset_image",
|
||||
"dataset_label",
|
||||
]
|
||||
with manifest_out.open("w", encoding="utf-8", newline="") as csv_file:
|
||||
writer = csv.DictWriter(csv_file, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(records)
|
||||
|
||||
print(f"Imported {kept} localized bookmark example(s)")
|
||||
print(f"Wrote {len(records)} detector image(s) into {args.split}")
|
||||
print(f"Manifest: {manifest_out}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
BOOKMARK_MANIFEST_FIELDS,
|
||||
BOOKMARK_LEADIN_MANIFEST_FIELDS,
|
||||
DEFAULT_SPEED_VALUES,
|
||||
VALUE_LABEL_FIELDS,
|
||||
DEFAULT_WORKSPACE,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
detector_dataset_yaml,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
workspace_readme,
|
||||
write_csv_header,
|
||||
write_text,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
BOOKMARK_MANIFEST_FIELDS,
|
||||
BOOKMARK_LEADIN_MANIFEST_FIELDS,
|
||||
DEFAULT_SPEED_VALUES,
|
||||
VALUE_LABEL_FIELDS,
|
||||
DEFAULT_WORKSPACE,
|
||||
PUBLIC_CLASSIFIER_SAMPLE_FIELDS,
|
||||
PUBLIC_DETECTOR_SAMPLE_FIELDS,
|
||||
RAW_SOURCE_FIELDS,
|
||||
detector_dataset_yaml,
|
||||
ensure_dir,
|
||||
resolve_workspace,
|
||||
workspace_readme,
|
||||
write_csv_header,
|
||||
write_text,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Initialize a speed-limit detector/classifier training workspace.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Workspace root. Defaults to .tmp/speed_limit_training under the repo root.")
|
||||
parser.add_argument("--force", action="store_true", help="Overwrite generated template files if they already exist.")
|
||||
parser.add_argument("--speed-values", nargs="+", type=int, default=list(DEFAULT_SPEED_VALUES), help="Classifier speed values to document in the workspace README.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
|
||||
for relative_dir in (
|
||||
"detector/images/train",
|
||||
"detector/images/val",
|
||||
"detector/labels/train",
|
||||
"detector/labels/val",
|
||||
"classifier/train",
|
||||
"classifier/val",
|
||||
"review/images",
|
||||
"review/leadins/frames",
|
||||
"review/leadins/contact_sheets",
|
||||
"manifests",
|
||||
"raw",
|
||||
"staging",
|
||||
"exports",
|
||||
"runs",
|
||||
):
|
||||
ensure_dir(workspace / relative_dir)
|
||||
|
||||
write_text(workspace / "detector" / "dataset.yaml", detector_dataset_yaml(workspace), force=args.force)
|
||||
write_text(workspace / "README.md", workspace_readme(tuple(args.speed_values)), force=args.force)
|
||||
write_csv_header(workspace / "review" / "bookmarks.csv", BOOKMARK_MANIFEST_FIELDS, force=args.force)
|
||||
write_csv_header(workspace / "review" / "bookmark_leadins.csv", BOOKMARK_LEADIN_MANIFEST_FIELDS, force=args.force)
|
||||
write_csv_header(workspace / "classifier" / "value_labels.csv", VALUE_LABEL_FIELDS, force=args.force)
|
||||
write_csv_header(workspace / "manifests" / "raw_sources.csv", RAW_SOURCE_FIELDS, force=args.force)
|
||||
write_csv_header(workspace / "manifests" / "public_detector_samples.csv", PUBLIC_DETECTOR_SAMPLE_FIELDS, force=args.force)
|
||||
write_csv_header(workspace / "manifests" / "public_classifier_samples.csv", PUBLIC_CLASSIFIER_SAMPLE_FIELDS, force=args.force)
|
||||
|
||||
print(f"Initialized speed-limit training workspace at {workspace}")
|
||||
print(f" detector dataset: {workspace / 'detector/dataset.yaml'}")
|
||||
print(f" review manifest: {workspace / 'review/bookmarks.csv'}")
|
||||
print(f" lead-in manifest: {workspace / 'review/bookmark_leadins.csv'}")
|
||||
print(f" value labels: {workspace / 'classifier/value_labels.csv'}")
|
||||
print(f" raw sources: {workspace / 'manifests/raw_sources.csv'}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ( # type: ignore
|
||||
CLASSIFIER_EXPORT_NAME,
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_EXPORT_NAME,
|
||||
resolve_workspace,
|
||||
)
|
||||
else:
|
||||
from .common import (
|
||||
CLASSIFIER_EXPORT_NAME,
|
||||
DEFAULT_WORKSPACE,
|
||||
DETECTOR_EXPORT_NAME,
|
||||
resolve_workspace,
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Copy exported speed-limit ONNX models to a comma device.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--detector", type=Path, help="Detector ONNX path. Defaults to <workspace>/exports/speed_limit_us_detector.onnx.")
|
||||
parser.add_argument("--classifier", type=Path, help="Classifier ONNX path. Defaults to <workspace>/exports/speed_limit_us_value_classifier.onnx.")
|
||||
parser.add_argument("--host", default="comma@192.168.3.110", help="scp target host.")
|
||||
parser.add_argument("--remote-dir", default="/data/openpilot/starpilot/assets/vision_models", help="Remote vision model directory.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def scp_file(local_path: Path, host: str, remote_dir: str) -> None:
|
||||
subprocess.run(["scp", str(local_path), f"{host}:{remote_dir}/{local_path.name}"], check=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
|
||||
detector_path = args.detector.resolve() if args.detector else (workspace / "exports" / DETECTOR_EXPORT_NAME)
|
||||
classifier_path = args.classifier.resolve() if args.classifier else (workspace / "exports" / CLASSIFIER_EXPORT_NAME)
|
||||
|
||||
copied = 0
|
||||
for local_path in (detector_path, classifier_path):
|
||||
if not local_path.is_file():
|
||||
continue
|
||||
scp_file(local_path, args.host, args.remote_dir)
|
||||
print(f"Copied {local_path.name} to {args.host}:{args.remote_dir}")
|
||||
copied += 1
|
||||
|
||||
if copied == 0:
|
||||
raise SystemExit("No ONNX models found to copy")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
from scripts.speed_limit_vision import common
|
||||
from scripts.speed_limit_vision import evaluate_bookmark_leadins as ebl
|
||||
|
||||
|
||||
DEFAULT_OUTPUT_DIR = Path(".tmp/bookmark_sign_localization")
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description="Search around bookmarks for the most sign-like speed-limit frame and crop.")
|
||||
parser.add_argument("--clip-root", type=Path, default=ebl.DEFAULT_CLIP_ROOT, help="Copied route clip root.")
|
||||
parser.add_argument("--qlog-mtimes", type=Path, default=ebl.DEFAULT_QLOG_MTIMES, help="Text file with '<qlog path> <mtime epoch>' lines.")
|
||||
parser.add_argument("--session-root", type=Path, default=ebl.DEFAULT_SESSION_ROOT, help="Directory containing debug session folders.")
|
||||
parser.add_argument("--session-route-map", type=Path, default=common.preferred_session_route_map_path(), help="JSON file mapping debug session ids to route log ids.")
|
||||
parser.add_argument("--models-dir", type=Path, help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.")
|
||||
parser.add_argument("--search-before", type=float, default=18.0, help="Seconds before the bookmark to scan.")
|
||||
parser.add_argument("--search-after", type=float, default=2.0, help="Seconds after the bookmark to scan.")
|
||||
parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled frames.")
|
||||
parser.add_argument("--top-k", type=int, default=1, help="How many candidate frames to save per bookmark.")
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR, help="Output directory for frames/crops/manifest.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def configure_models(models_dir: Path | None):
|
||||
if not models_dir:
|
||||
return
|
||||
models_dir = models_dir.expanduser().resolve()
|
||||
detector_path = models_dir / "speed_limit_us_detector.onnx"
|
||||
classifier_path = models_dir / "speed_limit_us_value_classifier.onnx"
|
||||
if not detector_path.is_file():
|
||||
raise FileNotFoundError(detector_path)
|
||||
if not classifier_path.is_file():
|
||||
raise FileNotFoundError(classifier_path)
|
||||
slv.US_DETECTOR_MODEL_PATH = detector_path
|
||||
slv.US_CLASSIFIER_MODEL_PATH = classifier_path
|
||||
|
||||
|
||||
def iter_video_samples(clip_path: Path, start_s: float, end_s: float, sample_every: float):
|
||||
capture = cv2.VideoCapture(str(clip_path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
start_frame = max(int(start_s * fps), 0)
|
||||
end_frame = max(int(end_s * fps), start_frame)
|
||||
|
||||
frame_index = 0
|
||||
while frame_index < start_frame:
|
||||
ok, _ = capture.read()
|
||||
if not ok:
|
||||
capture.release()
|
||||
return
|
||||
frame_index += 1
|
||||
|
||||
next_sample_s = start_s
|
||||
while frame_index <= end_frame:
|
||||
ok, frame_bgr = capture.read()
|
||||
if not ok or frame_bgr is None:
|
||||
break
|
||||
|
||||
frame_time_s = frame_index / fps
|
||||
if frame_time_s + 1e-6 >= next_sample_s:
|
||||
yield frame_time_s, frame_bgr
|
||||
next_sample_s += sample_every
|
||||
|
||||
frame_index += 1
|
||||
|
||||
capture.release()
|
||||
|
||||
|
||||
def iter_context_frames(clip_root: Path, window: ebl.BookmarkWindow, search_before: float, search_after: float, sample_every: float):
|
||||
ranges: list[tuple[Path, float, float]] = []
|
||||
start_s = window.segment_offset_s - search_before
|
||||
end_s = window.segment_offset_s + search_after
|
||||
|
||||
if start_s < 0.0 and window.segment > 0:
|
||||
previous_clip = clip_root / f"{window.route}--{window.segment - 1}" / "fcamera.hevc"
|
||||
if previous_clip.is_file():
|
||||
ranges.append((previous_clip, max(60.0 + start_s, 0.0), 60.0))
|
||||
start_s = 0.0
|
||||
|
||||
current_clip = clip_root / f"{window.route}--{window.segment}" / "fcamera.hevc"
|
||||
if current_clip.is_file():
|
||||
ranges.append((current_clip, max(start_s, 0.0), min(end_s, 60.0)))
|
||||
|
||||
for clip_path, range_start_s, range_end_s in ranges:
|
||||
for source_time_s, frame_bgr in iter_video_samples(clip_path, range_start_s, range_end_s, sample_every):
|
||||
if clip_path.parent.name.endswith(f"--{window.segment - 1}"):
|
||||
relative_time_s = source_time_s - 60.0
|
||||
else:
|
||||
relative_time_s = source_time_s
|
||||
yield relative_time_s, clip_path, source_time_s, frame_bgr
|
||||
|
||||
|
||||
def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, class_id: int, proposal_confidence: float, box, full_detection):
|
||||
frame_height, frame_width = frame_bgr.shape[:2]
|
||||
x1, y1, x2, y2 = box
|
||||
box_width = x2 - x1
|
||||
box_height = y2 - y1
|
||||
if box_width <= 0 or box_height <= 0:
|
||||
return None
|
||||
|
||||
best = None
|
||||
for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in slv.DETECTOR_CLASSIFIER_EXPANSIONS:
|
||||
expanded_x1 = max(int(x1 - box_width * expand_left), 0)
|
||||
expanded_y1 = max(int(y1 - box_height * expand_top), 0)
|
||||
expanded_x2 = min(int(x2 + box_width * expand_right), frame_width)
|
||||
expanded_y2 = min(int(y2 + box_height * expand_bottom), frame_height)
|
||||
if expanded_x2 <= expanded_x1 or expanded_y2 <= expanded_y1:
|
||||
continue
|
||||
|
||||
sign_crop = frame_bgr[expanded_y1:expanded_y2, expanded_x1:expanded_x2]
|
||||
if sign_crop.size == 0:
|
||||
continue
|
||||
|
||||
is_regulatory = daemon._is_regulatory_speed_sign(sign_crop) or class_id == 2
|
||||
model_read = daemon._classify_speed_limit_from_model(sign_crop)
|
||||
ocr_read = daemon._read_speed_limit_from_crop(sign_crop)
|
||||
if model_read is None and ocr_read is None:
|
||||
continue
|
||||
|
||||
if class_id == 2:
|
||||
read_result = model_read or ocr_read
|
||||
if read_result is None or read_result[0] not in slv.SCHOOL_ZONE_SPEED_VALUES:
|
||||
continue
|
||||
elif not is_regulatory:
|
||||
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
|
||||
continue
|
||||
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
|
||||
else:
|
||||
if model_read is not None and ocr_read is not None and model_read[0] == ocr_read[0]:
|
||||
read_result = (model_read[0], max(model_read[1], ocr_read[1]))
|
||||
else:
|
||||
read_result = model_read or ocr_read
|
||||
|
||||
speed_limit_mph, read_confidence = read_result
|
||||
area_ratio = ((expanded_x2 - expanded_x1) * (expanded_y2 - expanded_y1)) / max(frame_width * frame_height, 1)
|
||||
score = read_confidence * 1.1 + proposal_confidence * 0.16 + expansion_weight * 0.08
|
||||
score += min(area_ratio * 6.0, 0.18)
|
||||
if is_regulatory:
|
||||
score += 0.14
|
||||
if model_read is not None and ocr_read is not None and model_read[0] == ocr_read[0]:
|
||||
score += 0.24
|
||||
if full_detection is not None and full_detection.speed_limit_mph == speed_limit_mph:
|
||||
score += 0.18 + full_detection.confidence * 0.08
|
||||
|
||||
candidate = {
|
||||
"score": score,
|
||||
"box": (expanded_x1, expanded_y1, expanded_x2, expanded_y2),
|
||||
"proposal_confidence": proposal_confidence,
|
||||
"class_id": class_id,
|
||||
"is_regulatory": is_regulatory,
|
||||
"model_read": model_read,
|
||||
"ocr_read": ocr_read,
|
||||
"full_detection": full_detection,
|
||||
"read_result": read_result,
|
||||
}
|
||||
if best is None or candidate["score"] > best["score"]:
|
||||
best = candidate
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
|
||||
full_detection = daemon._detect_sign(frame_bgr)
|
||||
best = None
|
||||
|
||||
for proposal_confidence, class_id, (x1, y1, x2, y2) in daemon._collect_detector_classifier_proposals(frame_bgr):
|
||||
if class_id == 1:
|
||||
continue
|
||||
|
||||
candidate = _score_expanded_candidate(daemon, frame_bgr, class_id, proposal_confidence, (x1, y1, x2, y2), full_detection)
|
||||
if candidate is None:
|
||||
continue
|
||||
if best is None or candidate["score"] > best["score"]:
|
||||
best = candidate
|
||||
|
||||
return best
|
||||
|
||||
|
||||
def write_manifest(rows: list[dict], path: Path):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=[
|
||||
"session_id",
|
||||
"bookmark_number",
|
||||
"route",
|
||||
"segment",
|
||||
"relative_time_s",
|
||||
"source_video_path",
|
||||
"score",
|
||||
"proposal_confidence",
|
||||
"class_id",
|
||||
"is_regulatory",
|
||||
"model_read",
|
||||
"ocr_read",
|
||||
"full_detection",
|
||||
"frame_path",
|
||||
"crop_path",
|
||||
"box",
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
configure_models(args.models_dir)
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
route_mtimes = ebl.load_qlog_mtimes(args.qlog_mtimes.expanduser().resolve())
|
||||
session_route_map = common.load_session_route_map(args.session_route_map)
|
||||
if not session_route_map:
|
||||
raise FileNotFoundError(f"No session route map found at {args.session_route_map}")
|
||||
clip_root = args.clip_root.expanduser().resolve()
|
||||
session_root = args.session_root.expanduser().resolve()
|
||||
output_dir = args.output_dir.expanduser().resolve()
|
||||
frames_dir = output_dir / "frames"
|
||||
crops_dir = output_dir / "crops"
|
||||
frames_dir.mkdir(parents=True, exist_ok=True)
|
||||
crops_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
manifest_rows = []
|
||||
for session_id, route in session_route_map.items():
|
||||
bookmarks = ebl.load_bookmarks(session_root / session_id)
|
||||
for bookmark_number, event in enumerate(bookmarks, start=1):
|
||||
window = ebl.locate_window(route, event, route_mtimes, 5.0)
|
||||
if window is None:
|
||||
continue
|
||||
|
||||
ranked = []
|
||||
for relative_time_s, source_video_path, source_time_s, frame_bgr in iter_context_frames(
|
||||
clip_root,
|
||||
window,
|
||||
args.search_before,
|
||||
args.search_after,
|
||||
args.sample_every,
|
||||
):
|
||||
scored = score_frame(daemon, frame_bgr)
|
||||
if scored is None:
|
||||
continue
|
||||
ranked.append((scored["score"], relative_time_s, source_video_path, source_time_s, frame_bgr, scored))
|
||||
|
||||
ranked.sort(key=lambda item: item[0], reverse=True)
|
||||
for rank_index, (_, relative_time_s, source_video_path, _, frame_bgr, scored) in enumerate(ranked[:max(args.top_k, 1)], start=1):
|
||||
x1, y1, x2, y2 = scored["box"]
|
||||
crop = frame_bgr[y1:y2, x1:x2]
|
||||
|
||||
frame_name = f"{session_id}_bookmark_{bookmark_number:03d}_rank_{rank_index:02d}.jpg"
|
||||
crop_name = f"{session_id}_bookmark_{bookmark_number:03d}_rank_{rank_index:02d}_crop.jpg"
|
||||
frame_path = frames_dir / frame_name
|
||||
crop_path = crops_dir / crop_name
|
||||
cv2.imwrite(str(frame_path), frame_bgr, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
cv2.imwrite(str(crop_path), crop, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
|
||||
def fmt_detection(result):
|
||||
if result is None:
|
||||
return ""
|
||||
return f"{result[0]}@{result[1]:.3f}"
|
||||
|
||||
full_detection = scored["full_detection"]
|
||||
manifest_rows.append({
|
||||
"session_id": session_id,
|
||||
"bookmark_number": bookmark_number,
|
||||
"route": route,
|
||||
"segment": window.segment,
|
||||
"relative_time_s": f"{relative_time_s:.3f}",
|
||||
"source_video_path": str(source_video_path),
|
||||
"score": f"{scored['score']:.4f}",
|
||||
"proposal_confidence": f"{scored['proposal_confidence']:.4f}",
|
||||
"class_id": str(scored["class_id"]),
|
||||
"is_regulatory": str(bool(scored["is_regulatory"])),
|
||||
"model_read": fmt_detection(scored["model_read"]),
|
||||
"ocr_read": fmt_detection(scored["ocr_read"]),
|
||||
"full_detection": "" if full_detection is None else f"{full_detection.speed_limit_mph}@{full_detection.confidence:.3f}",
|
||||
"frame_path": str(frame_path),
|
||||
"crop_path": str(crop_path),
|
||||
"box": ",".join(str(value) for value in scored["box"]),
|
||||
})
|
||||
|
||||
manifest_path = output_dir / "localized_bookmarks.csv"
|
||||
write_manifest(manifest_rows, manifest_path)
|
||||
print(f"Wrote {len(manifest_rows)} localized candidate(s) to {manifest_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,134 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
|
||||
DEFAULT_MANIFEST = Path(".tmp/speed_limit_training/review/bookmark_leadins.csv")
|
||||
DEFAULT_OUTPUT = Path(".tmp/speed_limit_training/review/bookmark_leadin_shortlist.csv")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Rank sampled pre-bookmark lead-in frames by sign-likelihood for labeling.")
|
||||
parser.add_argument("--manifest", type=Path, default=DEFAULT_MANIFEST, help="CSV from import_bookmark_leadins.py.")
|
||||
parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT, help="Output CSV of top-ranked frames per bookmark.")
|
||||
parser.add_argument("--top-k", type=int, default=3, help="How many frames to keep per bookmark window.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_rows(path: Path):
|
||||
with path.open("r", encoding="utf-8", newline="") as handle:
|
||||
return [row for row in csv.DictReader(handle) if row.get("frame_path")]
|
||||
|
||||
|
||||
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
|
||||
detection = daemon._detect_sign_from_detector_classifier(frame_bgr)
|
||||
ocr_detection = daemon._detect_sign_from_ocr_candidates(frame_bgr)
|
||||
proposals = daemon._collect_detector_classifier_proposals(frame_bgr)
|
||||
|
||||
best_proposal_score = 0.0
|
||||
best_box = None
|
||||
frame_height, frame_width = frame_bgr.shape[:2]
|
||||
for proposal_confidence, class_id, (x1, y1, x2, y2) in proposals[:8]:
|
||||
box_width = x2 - x1
|
||||
box_height = y2 - y1
|
||||
if box_width <= 0 or box_height <= 0:
|
||||
continue
|
||||
crop = frame_bgr[y1:y2, x1:x2]
|
||||
regulatory_bonus = 0.15 if daemon._is_regulatory_speed_sign(crop) else 0.0
|
||||
class_bonus = 0.10 if class_id in (0, 2) else 0.0
|
||||
area_bonus = min((box_width * box_height) / max(frame_width * frame_height, 1), 0.04)
|
||||
score = proposal_confidence + regulatory_bonus + class_bonus + area_bonus
|
||||
if score > best_proposal_score:
|
||||
best_proposal_score = score
|
||||
best_box = (x1, y1, x2, y2)
|
||||
|
||||
score = best_proposal_score
|
||||
reason = "proposal"
|
||||
predicted_speed = ""
|
||||
if ocr_detection is not None:
|
||||
score = max(score, 0.55 + ocr_detection.confidence)
|
||||
reason = "ocr"
|
||||
predicted_speed = str(ocr_detection.speed_limit_mph)
|
||||
if detection is not None:
|
||||
score = max(score, 1.0 + detection.confidence)
|
||||
reason = "detector"
|
||||
predicted_speed = str(detection.speed_limit_mph)
|
||||
|
||||
return {
|
||||
"score": round(score, 4),
|
||||
"reason": reason,
|
||||
"predicted_speed": predicted_speed,
|
||||
"best_box": "" if best_box is None else ",".join(str(v) for v in best_box),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
manifest_path = args.manifest.expanduser().resolve()
|
||||
output_path = args.output.expanduser().resolve()
|
||||
rows = load_rows(manifest_path)
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
|
||||
grouped_rows = defaultdict(list)
|
||||
for row in rows:
|
||||
grouped_rows[(row["session_id"], row["bookmark_number"])].append(row)
|
||||
|
||||
shortlist_rows = []
|
||||
for (_, _), group_rows in grouped_rows.items():
|
||||
scored_rows = []
|
||||
for row in group_rows:
|
||||
frame_path = (manifest_path.parents[1] / row["frame_path"]).resolve()
|
||||
frame_bgr = cv2.imread(str(frame_path))
|
||||
if frame_bgr is None:
|
||||
continue
|
||||
scored_rows.append((score_frame(daemon, frame_bgr), row))
|
||||
|
||||
scored_rows.sort(key=lambda item: (-item[0]["score"], float(item[1]["sample_offset_s"])))
|
||||
for rank, (scored, row) in enumerate(scored_rows[:max(args.top_k, 1)], start=1):
|
||||
shortlist_rows.append({
|
||||
"session_id": row["session_id"],
|
||||
"bookmark_number": row["bookmark_number"],
|
||||
"rank": rank,
|
||||
"score": scored["score"],
|
||||
"reason": scored["reason"],
|
||||
"predicted_speed": scored["predicted_speed"],
|
||||
"sample_offset_s": row["sample_offset_s"],
|
||||
"frame_path": row["frame_path"],
|
||||
"contact_sheet_path": row["contact_sheet_path"],
|
||||
"window_result": row["window_result"],
|
||||
"best_box": scored["best_box"],
|
||||
})
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=[
|
||||
"session_id",
|
||||
"bookmark_number",
|
||||
"rank",
|
||||
"score",
|
||||
"reason",
|
||||
"predicted_speed",
|
||||
"sample_offset_s",
|
||||
"frame_path",
|
||||
"contact_sheet_path",
|
||||
"window_result",
|
||||
"best_box",
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerows(shortlist_rows)
|
||||
|
||||
print(f"Wrote {len(shortlist_rows)} shortlisted frame(s) to {output_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,192 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import random
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, DETECTOR_CLASS_NAMES, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, DETECTOR_CLASS_NAMES, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create a rebalanced detector dataset rooted in symlinks to the main workspace.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--output-root", type=Path, help="Output dataset root. Defaults to <workspace>/detector_rebalanced.")
|
||||
parser.add_argument("--max-other-train", type=int, default=4000, help="Maximum number of non-real train images to keep.")
|
||||
parser.add_argument("--real-val-count", type=int, default=0, help="Hold out this many real train images as extra validation examples.")
|
||||
parser.add_argument(
|
||||
"--source-cap",
|
||||
action="append",
|
||||
default=[],
|
||||
metavar="PREFIX=COUNT",
|
||||
help="Cap train images for a filename prefix, for example arts_challenging=1200 or lisa_traffic_sign=1000.",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=0, help="Random seed for sampling non-real images.")
|
||||
parser.add_argument("--copy", action="store_true", help="Copy files instead of creating symlinks.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def link_or_copy(src: Path, dst: Path, copy_files: bool) -> None:
|
||||
if dst.exists() or dst.is_symlink():
|
||||
dst.unlink()
|
||||
ensure_dir(dst.parent)
|
||||
if copy_files:
|
||||
dst.write_bytes(src.read_bytes())
|
||||
else:
|
||||
dst.symlink_to(src.resolve())
|
||||
|
||||
|
||||
def write_dataset_yaml(dataset_root: Path) -> Path:
|
||||
yaml_lines = [
|
||||
f"path: {dataset_root}",
|
||||
"train: images/train",
|
||||
"val: images/val",
|
||||
"names:",
|
||||
]
|
||||
for index, class_name in enumerate(DETECTOR_CLASS_NAMES):
|
||||
yaml_lines.append(f" {index}: {class_name}")
|
||||
dataset_yaml = dataset_root / "dataset.yaml"
|
||||
dataset_yaml.write_text("\n".join(yaml_lines) + "\n", encoding="utf-8")
|
||||
return dataset_yaml
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> None:
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file() or path.is_symlink():
|
||||
path.unlink()
|
||||
|
||||
|
||||
def visible_file_count(root: Path) -> int:
|
||||
return sum(1 for path in root.glob("*") if path.is_file() and not path.name.startswith("._"))
|
||||
|
||||
|
||||
def safe_unlink(path: Path) -> None:
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def parse_source_caps(values: list[str]) -> dict[str, int]:
|
||||
caps: dict[str, int] = {}
|
||||
for value in values:
|
||||
if "=" not in value:
|
||||
raise ValueError(f"Invalid --source-cap '{value}', expected PREFIX=COUNT")
|
||||
prefix, count_text = value.split("=", 1)
|
||||
prefix = prefix.strip()
|
||||
if not prefix:
|
||||
raise ValueError(f"Invalid --source-cap '{value}', missing prefix")
|
||||
caps[prefix] = max(int(count_text), 0)
|
||||
return caps
|
||||
|
||||
|
||||
def prefix_for_path(path: Path) -> str:
|
||||
name = path.name
|
||||
if name.startswith("real_"):
|
||||
return "real"
|
||||
if name.startswith("arts_challenging_"):
|
||||
return "arts_challenging"
|
||||
if name.startswith("lisa_traffic_sign_"):
|
||||
return "lisa_traffic_sign"
|
||||
if name.startswith("glare_images_"):
|
||||
return "glare_images"
|
||||
return name.split("_", 1)[0]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
source_root = workspace / "detector"
|
||||
output_root = args.output_root.resolve() if args.output_root else (workspace / "detector_rebalanced")
|
||||
source_caps = parse_source_caps(args.source_cap)
|
||||
rng = random.Random(args.seed)
|
||||
|
||||
train_images = sorted((source_root / "images" / "train").glob("*"))
|
||||
train_labels = source_root / "labels" / "train"
|
||||
val_image_dir = source_root / "images" / "val"
|
||||
val_label_dir = source_root / "labels" / "val"
|
||||
|
||||
real_images = [path for path in train_images if path.name.startswith("real_")]
|
||||
other_images = [path for path in train_images if not path.name.startswith("real_")]
|
||||
rng.shuffle(real_images)
|
||||
heldout_real_images = sorted(real_images[:max(args.real_val_count, 0)], key=lambda path: path.name)
|
||||
target_real_train_images = sorted(real_images[max(args.real_val_count, 0):], key=lambda path: path.name)
|
||||
|
||||
grouped_other_images: dict[str, list[Path]] = defaultdict(list)
|
||||
for image_path in other_images:
|
||||
grouped_other_images[prefix_for_path(image_path)].append(image_path)
|
||||
|
||||
sampled_other_images: list[Path] = []
|
||||
uncapped_other_images: list[Path] = []
|
||||
for prefix, paths in grouped_other_images.items():
|
||||
rng.shuffle(paths)
|
||||
if prefix in source_caps:
|
||||
sampled_other_images.extend(paths[:source_caps[prefix]])
|
||||
else:
|
||||
uncapped_other_images.extend(paths)
|
||||
|
||||
rng.shuffle(uncapped_other_images)
|
||||
remaining_slots = max(args.max_other_train - len(sampled_other_images), 0)
|
||||
sampled_other_images.extend(uncapped_other_images[:remaining_slots])
|
||||
|
||||
target_train_images = target_real_train_images + sampled_other_images
|
||||
target_train_images.sort(key=lambda path: path.name)
|
||||
|
||||
output_train_image_dir = ensure_dir(output_root / "images" / "train")
|
||||
output_train_label_dir = ensure_dir(output_root / "labels" / "train")
|
||||
output_val_image_dir = ensure_dir(output_root / "images" / "val")
|
||||
output_val_label_dir = ensure_dir(output_root / "labels" / "val")
|
||||
|
||||
for existing in output_train_image_dir.glob("*"):
|
||||
safe_unlink(existing)
|
||||
for existing in output_train_label_dir.glob("*"):
|
||||
safe_unlink(existing)
|
||||
for existing in output_val_image_dir.glob("*"):
|
||||
safe_unlink(existing)
|
||||
for existing in output_val_label_dir.glob("*"):
|
||||
safe_unlink(existing)
|
||||
|
||||
for image_path in target_train_images:
|
||||
label_path = train_labels / f"{image_path.stem}.txt"
|
||||
if not label_path.is_file():
|
||||
continue
|
||||
link_or_copy(image_path, output_train_image_dir / image_path.name, args.copy)
|
||||
link_or_copy(label_path, output_train_label_dir / label_path.name, args.copy)
|
||||
|
||||
for image_path in sorted(val_image_dir.glob("*")):
|
||||
label_path = val_label_dir / f"{image_path.stem}.txt"
|
||||
if not label_path.is_file():
|
||||
continue
|
||||
link_or_copy(image_path, output_val_image_dir / image_path.name, args.copy)
|
||||
link_or_copy(label_path, output_val_label_dir / label_path.name, args.copy)
|
||||
|
||||
for image_path in heldout_real_images:
|
||||
label_path = train_labels / f"{image_path.stem}.txt"
|
||||
if not label_path.is_file():
|
||||
continue
|
||||
link_or_copy(image_path, output_val_image_dir / image_path.name, args.copy)
|
||||
link_or_copy(label_path, output_val_label_dir / label_path.name, args.copy)
|
||||
|
||||
remove_appledouble_files(output_root)
|
||||
dataset_yaml = write_dataset_yaml(output_root)
|
||||
print(f"Created rebalanced detector dataset at {output_root}")
|
||||
print(f"Dataset YAML: {dataset_yaml}")
|
||||
print(f"Train images: {visible_file_count(output_train_image_dir)}")
|
||||
print(f" real train: {len(target_real_train_images)}")
|
||||
print(f" real held out to val: {len(heldout_real_images)}")
|
||||
print(f" sampled other: {len(sampled_other_images)}")
|
||||
if source_caps:
|
||||
print(f" source caps: {source_caps}")
|
||||
print(f"Val images: {visible_file_count(output_val_image_dir)}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/bin/zsh
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="/Users/dominickthompson/starpilot"
|
||||
WORKSPACE="/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean"
|
||||
LOG_DIR="$WORKSPACE/logs"
|
||||
BASE_DETECTOR_NAME="yolo11n-comma-us-clean-v1"
|
||||
GLARE_DETECTOR_NAME="yolo11n-comma-us-clean-glare-v1"
|
||||
CLASSIFIER_NAME="yolo11n-cls-speed-limit-us-clean-v1"
|
||||
EXPORT_DIR="$WORKSPACE/exports/overnight_latest"
|
||||
|
||||
mkdir -p "$LOG_DIR"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
echo "[$(date)] waiting for GLARE raw download to finish"
|
||||
while true; do
|
||||
if pgrep -f "download_glare_raw.py --workspace $WORKSPACE" >/dev/null; then
|
||||
sleep 30
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[$(date)] importing completed GLARE images"
|
||||
.venv/bin/python scripts/speed_limit_vision/import_glare_images.py --workspace "$WORKSPACE" --overwrite
|
||||
.venv/bin/python scripts/speed_limit_vision/build_value_dataset.py --workspace "$WORKSPACE" --overwrite
|
||||
|
||||
echo "[$(date)] waiting for base detector run to finish"
|
||||
while true; do
|
||||
if pgrep -f "train_detector.py --workspace $WORKSPACE .*--name $BASE_DETECTOR_NAME" >/dev/null; then
|
||||
sleep 30
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
BASE_DETECTOR_WEIGHTS="$WORKSPACE/runs/detector/$BASE_DETECTOR_NAME/weights/best.pt"
|
||||
if [[ ! -f "$BASE_DETECTOR_WEIGHTS" ]]; then
|
||||
echo "[$(date)] missing base detector weights: $BASE_DETECTOR_WEIGHTS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$(date)] starting GLARE-augmented detector fine-tune"
|
||||
.venv/bin/python scripts/speed_limit_vision/train_detector.py \
|
||||
--workspace "$WORKSPACE" \
|
||||
--device mps \
|
||||
--epochs 20 \
|
||||
--batch 24 \
|
||||
--workers 4 \
|
||||
--model "$BASE_DETECTOR_WEIGHTS" \
|
||||
--name "$GLARE_DETECTOR_NAME" \
|
||||
--exist-ok
|
||||
|
||||
DETECTOR_WEIGHTS="$WORKSPACE/runs/detector/$GLARE_DETECTOR_NAME/weights/best.pt"
|
||||
if [[ ! -f "$DETECTOR_WEIGHTS" ]]; then
|
||||
DETECTOR_WEIGHTS="$BASE_DETECTOR_WEIGHTS"
|
||||
fi
|
||||
|
||||
echo "[$(date)] training value classifier"
|
||||
.venv/bin/python scripts/speed_limit_vision/train_value_classifier.py \
|
||||
--workspace "$WORKSPACE" \
|
||||
--device mps \
|
||||
--epochs 40 \
|
||||
--batch 64 \
|
||||
--workers 4 \
|
||||
--name "$CLASSIFIER_NAME" \
|
||||
--exist-ok
|
||||
|
||||
CLASSIFIER_WEIGHTS="$WORKSPACE/runs/classifier/$CLASSIFIER_NAME/weights/best.pt"
|
||||
if [[ ! -f "$CLASSIFIER_WEIGHTS" ]]; then
|
||||
echo "[$(date)] missing classifier weights: $CLASSIFIER_WEIGHTS" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[$(date)] exporting ONNX models into repo assets"
|
||||
.venv/bin/python scripts/speed_limit_vision/export_models.py \
|
||||
--workspace "$WORKSPACE" \
|
||||
--detector-weights "$DETECTOR_WEIGHTS" \
|
||||
--classifier-weights "$CLASSIFIER_WEIGHTS" \
|
||||
--output-dir "$EXPORT_DIR" \
|
||||
--install-repo-assets
|
||||
|
||||
echo "[$(date)] evaluating runtime saved-frame cases"
|
||||
.venv/bin/python scripts/speed_limit_vision/evaluate_runtime_cases.py \
|
||||
--models-dir "$EXPORT_DIR" \
|
||||
--strict | tee "$LOG_DIR/runtime_cases_overnight.txt"
|
||||
|
||||
echo "[$(date)] evaluating bookmarked lead-ins"
|
||||
.venv/bin/python scripts/speed_limit_vision/evaluate_bookmark_leadins.py \
|
||||
--models-dir "$EXPORT_DIR" \
|
||||
--lead-in 7 \
|
||||
--sample-fps 5 \
|
||||
--json-out "$LOG_DIR/bookmark_windows_overnight.json" | tee "$LOG_DIR/bookmark_windows_overnight.txt"
|
||||
|
||||
echo "[$(date)] overnight clean pipeline complete"
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
|
||||
VIDEO_SUFFIXES = {".hevc", ".mp4", ".mov", ".mkv", ".avi"}
|
||||
|
||||
|
||||
def iter_source_files(paths: list[Path]):
|
||||
for input_path in paths:
|
||||
if input_path.is_file():
|
||||
yield input_path
|
||||
continue
|
||||
|
||||
if not input_path.exists():
|
||||
continue
|
||||
|
||||
for path in sorted(input_path.rglob("*")):
|
||||
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES | VIDEO_SUFFIXES:
|
||||
yield path
|
||||
|
||||
|
||||
def sample_images(source_files: list[Path], output_dir: Path, max_per_file: int):
|
||||
written = 0
|
||||
for file_index, image_path in enumerate(source_files, start=1):
|
||||
frame = cv2.imread(str(image_path))
|
||||
if frame is None:
|
||||
continue
|
||||
stem = image_path.stem.replace(" ", "_")
|
||||
output_path = output_dir / f"{file_index:05d}_{stem}.jpg"
|
||||
cv2.imwrite(str(output_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
written += 1
|
||||
if written >= max_per_file:
|
||||
break
|
||||
|
||||
|
||||
def sample_video(video_path: Path, output_dir: Path, seconds_between_frames: float, max_frames: int):
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
fps = cap.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||
frame_step = max(int(round(seconds_between_frames * fps)), 1)
|
||||
frame_indices = range(0, total_frames if total_frames > 0 else frame_step * max_frames, frame_step)
|
||||
written = 0
|
||||
|
||||
for frame_index in frame_indices:
|
||||
if written >= max_frames:
|
||||
break
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_index)
|
||||
ok, frame = cap.read()
|
||||
if not ok or frame is None:
|
||||
continue
|
||||
|
||||
timestamp = frame_index / max(fps, 1.0)
|
||||
safe_stem = video_path.parent.name.replace(" ", "_")
|
||||
output_path = output_dir / f"{safe_stem}_{timestamp:06.2f}s.jpg"
|
||||
cv2.imwrite(str(output_path), frame, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
written += 1
|
||||
|
||||
cap.release()
|
||||
return written
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Sample comma route frames into a reusable background pool.")
|
||||
parser.add_argument("inputs", nargs="*", help="Route/video/image directories. Defaults to .tmp/comma_speed_training/realdata")
|
||||
parser.add_argument("--workspace", default=str(DEFAULT_WORKSPACE), help="Training workspace root.")
|
||||
parser.add_argument("--output-dir", default=None, help="Override background output directory.")
|
||||
parser.add_argument("--sample-seconds", type=float, default=1.25, help="Seconds between sampled frames for each video.")
|
||||
parser.add_argument("--max-per-video", type=int, default=6, help="Maximum frames to sample from each video.")
|
||||
parser.add_argument("--limit-files", type=int, default=0, help="Optional cap on the number of source files to sample.")
|
||||
args = parser.parse_args()
|
||||
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
output_dir = Path(args.output_dir).expanduser().resolve() if args.output_dir else workspace / "backgrounds"
|
||||
ensure_dir(output_dir)
|
||||
|
||||
default_input = Path(".tmp/comma_speed_training/realdata")
|
||||
input_paths = [Path(path).expanduser().resolve() for path in args.inputs] if args.inputs else [default_input.resolve()]
|
||||
source_files = [path for path in iter_source_files(input_paths)]
|
||||
if args.limit_files > 0:
|
||||
source_files = source_files[:args.limit_files]
|
||||
|
||||
image_files = [path for path in source_files if path.suffix.lower() in IMAGE_SUFFIXES]
|
||||
video_files = [path for path in source_files if path.suffix.lower() in VIDEO_SUFFIXES]
|
||||
|
||||
if not image_files and not video_files:
|
||||
raise FileNotFoundError("No image or video sources found.")
|
||||
|
||||
written = 0
|
||||
if image_files:
|
||||
sample_images(image_files, output_dir, max_per_file=max(len(image_files), 1))
|
||||
written += len(image_files)
|
||||
|
||||
for video_path in video_files:
|
||||
written += sample_video(video_path, output_dir, max(args.sample_seconds, 0.1), max(args.max_per_video, 1))
|
||||
|
||||
print(f"Sampled {written} background frames into {output_dir}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, resolve_workspace
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Train the speed-limit detector using Ultralytics YOLO.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--data", type=Path, help="Detector dataset YAML. Defaults to <workspace>/detector/dataset.yaml.")
|
||||
parser.add_argument("--model", default="yolo11n.pt", help="Ultralytics detector checkpoint to fine-tune.")
|
||||
parser.add_argument("--epochs", type=int, default=80, help="Training epochs.")
|
||||
parser.add_argument("--imgsz", type=int, default=640, help="Training image size.")
|
||||
parser.add_argument("--batch", type=int, default=16, help="Batch size.")
|
||||
parser.add_argument("--workers", type=int, default=8, help="Data loader workers.")
|
||||
parser.add_argument("--device", default="cpu", help="Ultralytics device string, for example cpu, mps, 0, or 0,1.")
|
||||
parser.add_argument("--project", type=Path, help="Training output directory. Defaults to <workspace>/runs/detector.")
|
||||
parser.add_argument("--name", default="yolo11n-speed-limit-us", help="Run name under --project.")
|
||||
parser.add_argument("--patience", type=int, default=20, help="Early stopping patience.")
|
||||
parser.add_argument("--cache", action="store_true", help="Cache images in RAM if supported.")
|
||||
parser.add_argument("--exist-ok", action="store_true", help="Allow overwriting an existing run directory.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
data_path = args.data.resolve() if args.data else (workspace / "detector" / "dataset.yaml")
|
||||
project_path = args.project.resolve() if args.project else (workspace / "runs" / "detector")
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
except Exception as exc:
|
||||
raise SystemExit(
|
||||
"Ultralytics is not installed. Run `uv sync --extra speedvision` in the repo root before training."
|
||||
) from exc
|
||||
|
||||
model = YOLO(args.model)
|
||||
model.train(
|
||||
data=str(data_path),
|
||||
epochs=args.epochs,
|
||||
imgsz=args.imgsz,
|
||||
batch=args.batch,
|
||||
workers=args.workers,
|
||||
device=args.device,
|
||||
project=str(project_path),
|
||||
name=args.name,
|
||||
patience=args.patience,
|
||||
cache=args.cache,
|
||||
exist_ok=args.exist_ok,
|
||||
)
|
||||
print(f"Detector training complete under {project_path / args.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, resolve_workspace # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, resolve_workspace
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Train the speed-limit value classifier using Ultralytics YOLO classification.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--data", type=Path, help="Classifier dataset root. Defaults to <workspace>/classifier.")
|
||||
parser.add_argument("--model", default="yolo11n-cls.pt", help="Ultralytics classification checkpoint to fine-tune.")
|
||||
parser.add_argument("--epochs", type=int, default=60, help="Training epochs.")
|
||||
parser.add_argument("--imgsz", type=int, default=128, help="Training image size.")
|
||||
parser.add_argument("--batch", type=int, default=32, help="Batch size.")
|
||||
parser.add_argument("--workers", type=int, default=8, help="Data loader workers.")
|
||||
parser.add_argument("--device", default="cpu", help="Ultralytics device string, for example cpu, mps, 0, or 0,1.")
|
||||
parser.add_argument("--project", type=Path, help="Training output directory. Defaults to <workspace>/runs/classifier.")
|
||||
parser.add_argument("--name", default="yolo11n-cls-speed-limit-us", help="Run name under --project.")
|
||||
parser.add_argument("--patience", type=int, default=15, help="Early stopping patience.")
|
||||
parser.add_argument("--cache", action="store_true", help="Cache images in RAM if supported.")
|
||||
parser.add_argument("--exist-ok", action="store_true", help="Allow overwriting an existing run directory.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
data_path = args.data.resolve() if args.data else (workspace / "classifier")
|
||||
project_path = args.project.resolve() if args.project else (workspace / "runs" / "classifier")
|
||||
|
||||
try:
|
||||
from ultralytics import YOLO
|
||||
except Exception as exc:
|
||||
raise SystemExit(
|
||||
"Ultralytics is not installed. Run `uv sync --extra speedvision` in the repo root before training."
|
||||
) from exc
|
||||
|
||||
model = YOLO(args.model)
|
||||
model.train(
|
||||
data=str(data_path),
|
||||
epochs=args.epochs,
|
||||
imgsz=args.imgsz,
|
||||
batch=args.batch,
|
||||
workers=args.workers,
|
||||
device=args.device,
|
||||
project=str(project_path),
|
||||
name=args.name,
|
||||
patience=args.patience,
|
||||
cache=args.cache,
|
||||
exist_ok=args.exist_ok,
|
||||
)
|
||||
print(f"Classifier training complete under {project_path / args.name}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
from ultralytics import YOLO
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
from generate_value_roi_classifier_dataset import extract_value_mask # type: ignore
|
||||
else:
|
||||
from .common import DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
from .generate_value_roi_classifier_dataset import extract_value_mask
|
||||
|
||||
|
||||
EVAL_WINDOWS = (
|
||||
("full", (0.0, 0.0, 1.0, 1.0)),
|
||||
("roi3", (0.40, 0.12, 0.92, 0.86)),
|
||||
)
|
||||
SIGN_CLASSES = {
|
||||
"regulatory_speed_limit",
|
||||
"school_zone_speed_limit",
|
||||
"speedLimit15",
|
||||
"speedLimit20",
|
||||
"speedLimit25",
|
||||
"speedLimit30",
|
||||
"speedLimit35",
|
||||
"speedLimit40",
|
||||
"speedLimit45",
|
||||
"speedLimit50",
|
||||
"speedLimit55",
|
||||
"speedLimit60",
|
||||
"speedLimit65",
|
||||
"speedLimit70",
|
||||
"speedLimit75",
|
||||
"schoolSpeedLimit25",
|
||||
"speedLimit55Ahead",
|
||||
}
|
||||
REAL_CASES = (
|
||||
("live15", ".tmp/live_c4_capture/stopped_sign_road.jpg", 15),
|
||||
("school20", ".tmp/route_vision/seg38_frames/frame_041.jpg", 20),
|
||||
("highway40", ".tmp/speed_route_frames_seg2_10_20/t12.png", 40),
|
||||
("town40", ".tmp/route_12c_seg9_10/seg10_early/frame_005.png", 40),
|
||||
("town30", ".tmp/route_12c_seg9_10/seg10_early/frame_012.png", 30),
|
||||
("town30_late", ".tmp/vision_iter/seg10_5fps/frame_054.png", 30),
|
||||
)
|
||||
|
||||
|
||||
def detect_best(detector: YOLO, classifier: YOLO, frame_bgr):
|
||||
frame_height, frame_width = frame_bgr.shape[:2]
|
||||
best = None
|
||||
for window_name, (left_ratio, top_ratio, right_ratio, bottom_ratio) in EVAL_WINDOWS:
|
||||
left = int(frame_width * left_ratio)
|
||||
top = int(frame_height * top_ratio)
|
||||
right = int(frame_width * right_ratio)
|
||||
bottom = int(frame_height * bottom_ratio)
|
||||
roi = frame_bgr[top:bottom, left:right]
|
||||
if roi.size == 0:
|
||||
continue
|
||||
|
||||
detector_result = detector.predict(source=roi, conf=0.03, imgsz=640, device="cpu", verbose=False)[0]
|
||||
if detector_result.boxes is None:
|
||||
continue
|
||||
|
||||
for box, cls, det_conf in zip(
|
||||
detector_result.boxes.xyxy.cpu().numpy(),
|
||||
detector_result.boxes.cls.cpu().numpy(),
|
||||
detector_result.boxes.conf.cpu().numpy(),
|
||||
):
|
||||
class_name = detector.names[int(cls)]
|
||||
if class_name not in SIGN_CLASSES:
|
||||
continue
|
||||
|
||||
x1, y1, x2, y2 = box.astype(int)
|
||||
x1 += left
|
||||
x2 += left
|
||||
y1 += top
|
||||
y2 += top
|
||||
box_width = x2 - x1
|
||||
box_height = y2 - y1
|
||||
if box_width <= 0 or box_height <= 0:
|
||||
continue
|
||||
|
||||
expand = 0.18
|
||||
crop_left = max(x1 - int(box_width * expand), 0)
|
||||
crop_top = max(y1 - int(box_height * expand), 0)
|
||||
crop_right = min(x2 + int(box_width * expand), frame_width)
|
||||
crop_bottom = min(y2 + int(box_height * expand), frame_height)
|
||||
crop = frame_bgr[crop_top:crop_bottom, crop_left:crop_right]
|
||||
if crop.size == 0:
|
||||
continue
|
||||
|
||||
mask = extract_value_mask(crop)
|
||||
if mask is None:
|
||||
continue
|
||||
|
||||
classifier_result = classifier.predict(source=cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR), imgsz=128, device="cpu", verbose=False)[0]
|
||||
probabilities = classifier_result.probs
|
||||
if probabilities is None:
|
||||
continue
|
||||
|
||||
predicted_value = int(classifier_result.names[int(probabilities.top1)])
|
||||
classifier_confidence = float(probabilities.top1conf)
|
||||
score = min(classifier_confidence * 0.82 + float(det_conf) * 0.26, 0.95)
|
||||
candidate = {
|
||||
"window": window_name,
|
||||
"detectorClass": class_name,
|
||||
"detectorConfidence": round(float(det_conf), 4),
|
||||
"predictedValue": predicted_value,
|
||||
"classifierConfidence": round(classifier_confidence, 4),
|
||||
"score": round(score, 4),
|
||||
"box": [int(x1), int(y1), int(x2), int(y2)],
|
||||
}
|
||||
if best is None or candidate["score"] > best["score"]:
|
||||
best = candidate
|
||||
return best
|
||||
|
||||
|
||||
def evaluate_once(detector_weights: Path, classifier_weights: Path):
|
||||
detector = YOLO(str(detector_weights))
|
||||
classifier = YOLO(str(classifier_weights))
|
||||
records = []
|
||||
for label, frame_path, expected in REAL_CASES:
|
||||
frame = cv2.imread(frame_path)
|
||||
best = detect_best(detector, classifier, frame) if frame is not None else None
|
||||
correct = best is None if expected is None else bool(best is not None and best["predictedValue"] == expected)
|
||||
records.append({
|
||||
"case": label,
|
||||
"frame": frame_path,
|
||||
"expected": expected,
|
||||
"best": best,
|
||||
"correct": correct,
|
||||
})
|
||||
return records
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Watch a detector run and evaluate improved checkpoints on the saved comma examples.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--detector-weights", type=Path, required=True, help="Detector weights to watch, usually runs/.../weights/best.pt.")
|
||||
parser.add_argument("--classifier-weights", type=Path, required=True, help="Classifier weights to use for evaluation.")
|
||||
parser.add_argument("--interval", type=float, default=30.0, help="Polling interval in seconds.")
|
||||
parser.add_argument("--output", type=Path, help="JSONL output log path. Defaults to <workspace>/review/detector_watch.jsonl.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
detector_weights = args.detector_weights.resolve()
|
||||
classifier_weights = args.classifier_weights.resolve()
|
||||
output_path = args.output.resolve() if args.output else (ensure_dir(workspace / "review") / "detector_watch.jsonl")
|
||||
ensure_dir(output_path.parent)
|
||||
|
||||
last_mtime = None
|
||||
while True:
|
||||
if detector_weights.is_file():
|
||||
mtime = detector_weights.stat().st_mtime
|
||||
if last_mtime is None or mtime > last_mtime:
|
||||
last_mtime = mtime
|
||||
records = evaluate_once(detector_weights, classifier_weights)
|
||||
payload = {
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"detectorWeights": str(detector_weights),
|
||||
"classifierWeights": str(classifier_weights),
|
||||
"records": records,
|
||||
}
|
||||
with output_path.open("a", encoding="utf-8") as output_file:
|
||||
output_file.write(json.dumps(payload, separators=(",", ":")) + "\n")
|
||||
print(json.dumps(payload, indent=2))
|
||||
time.sleep(max(args.interval, 1.0))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user