Files
StarPilot/scripts/speed_limit_vision/generate_synthetic_us_speed_limits.py
T
firestar5683 8b7c2b5f36 oo buddy boy
2026-07-20 13:18:27 -05:00

324 lines
14 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
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 # noqa: TID251
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)
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 not path.name.startswith("._") and 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("--speed-values", nargs="+", type=int, default=list(DEFAULT_SPEED_VALUES), help="Posted values to synthesize.")
parser.add_argument("--regulatory-only", action="store_true", help="Generate only regulatory signs for the requested values.")
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(dict.fromkeys(args.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 = (
SignSpec(detector_class=0, style="regulatory", speed_value=rng.choice(speed_values))
if args.regulatory_only
else 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()