mirror of
https://jihulab.com/mr-one/openpilot.git
synced 2026-10-27 21:13:42 +08:00
Added CX-8 as its own car
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()
|
||||
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "usage: $0 <pull-request-number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE="https://github.com/commaai/openpilot/pull/"
|
||||
PR_NUM="$(echo $1 | grep -o -E '[0-9]+')"
|
||||
|
||||
curl -L $BASE/$PR_NUM.patch | git apply -3
|
||||
Executable
+3
@@ -0,0 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
nmcli connection modify --temporary esim ipv4.route-metric 1 ipv6.route-metric 1
|
||||
nmcli con up esim
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "usage: $0 <pull-request-number>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
BASE="https://github.com/commaai/openpilot/pull/"
|
||||
PR_NUM="$(echo $1 | grep -o -E '[0-9]+')"
|
||||
BRANCH=tmp-pr${PR_NUM}
|
||||
|
||||
git branch -D -f $BRANCH || true
|
||||
git fetch -u -f origin pull/$PR_NUM/head:$BRANCH
|
||||
git switch $BRANCH
|
||||
git reset --hard FETCH_HEAD
|
||||
Executable
+51
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
find . -name "matlab.*.md" -delete
|
||||
|
||||
find . -type d \( -iname "debug" -o -iname "test" -o -iname "tests" -o -name "__pycache__" \) -exec rm -rf {} +
|
||||
|
||||
find . -type f \( \
|
||||
-name "*.a" -o \
|
||||
-name "*.o" -o \
|
||||
-name "*.onnx" -o \
|
||||
-name "*.os" -o \
|
||||
-name "*.pyc" -o \
|
||||
-name "moc_*" \
|
||||
\) -delete
|
||||
|
||||
find .github -mindepth 1 -maxdepth 1 ! -name "workflows" -exec rm -rf {} +
|
||||
find .github/workflows -mindepth 1 ! \( \
|
||||
-type f \( \
|
||||
-name "compile_starpilot.yaml" -o \
|
||||
-name "publish_truenas_runner.yaml" -o \
|
||||
-name "review_pull_request.yaml" -o \
|
||||
-name "schedule_update.yaml" -o \
|
||||
-name "update_pr_branch.yaml" -o \
|
||||
-name "update_release_branch.yaml" -o \
|
||||
-name "update_tinygrad.yaml" \
|
||||
\) \
|
||||
\) -exec rm -rf {} +
|
||||
|
||||
find panda/board -type f \
|
||||
! -name "__init__.py" \
|
||||
! -name "bootstub.panda.bin" \
|
||||
! -name "bootstub.panda_h7.bin" \
|
||||
! -name "panda.bin.signed" \
|
||||
! -name "panda_h7.bin.signed" \
|
||||
-delete
|
||||
|
||||
find third_party/ -name "*Darwin*" -exec rm -rf {} +
|
||||
find third_party/ -name "*x86*" -exec rm -rf {} +
|
||||
|
||||
rm -f .gitignore .gitmodules .gitattributes .lfsconfig .overlay_init
|
||||
|
||||
rm -rf .sconsign.dblite .vscode/ Jenkinsfile release/ scripts/ site_scons/ teleoprtc_repo/
|
||||
|
||||
find . -type d -empty ! -path "./.git*" -delete
|
||||
|
||||
touch prebuilt
|
||||
Executable
+86
@@ -0,0 +1,86 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Collects high-signal diagnostics for UI freeze incidents on-device.
|
||||
# Usage:
|
||||
# ./scripts/debug_ui_freeze.sh
|
||||
# Optional env:
|
||||
# OUT_DIR=/data/media/0/realdata/ui_freeze_debug
|
||||
|
||||
OUT_DIR="${OUT_DIR:-/data/media/0/realdata/ui_freeze_debug}"
|
||||
TS="$(date +%Y%m%d_%H%M%S)"
|
||||
OUT_FILE="${OUT_DIR}/ui_freeze_${TS}.log"
|
||||
|
||||
mkdir -p "${OUT_DIR}"
|
||||
|
||||
{
|
||||
echo "=== UI Freeze Debug ==="
|
||||
echo "timestamp: $(date -Ins)"
|
||||
echo "hostname: $(hostname)"
|
||||
echo "kernel: $(uname -a)"
|
||||
echo
|
||||
|
||||
echo "=== Process Snapshot ==="
|
||||
ps -eo pid,ppid,stat,ni,etime,comm,args | grep -E "(manager.py|selfdrive.ui.ui|/selfdrive/ui/ui|weston|loggerd|encoderd|modeld|dmonitoringmodeld)" || true
|
||||
echo
|
||||
|
||||
echo "=== Manager State (tmux service output) ==="
|
||||
if command -v tmux >/dev/null 2>&1; then
|
||||
tmux capture-pane -pt comma:0 -S -220 2>/dev/null || true
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "=== UI Watchdog Files (/dev/shm/wd_*) ==="
|
||||
ls -l /dev/shm/wd_* 2>/dev/null || true
|
||||
echo
|
||||
|
||||
echo "=== UI PID + Watchdog Timestamp Delta ==="
|
||||
for ui_pid in $(pgrep -f "selfdrive.ui.ui|/selfdrive/ui/ui" 2>/dev/null || true); do
|
||||
echo "ui_pid=${ui_pid}"
|
||||
wd_file="/dev/shm/wd_${ui_pid}"
|
||||
if [[ -f "${wd_file}" ]]; then
|
||||
now_ns="$(python3 - <<'PY'
|
||||
import time
|
||||
print(int(time.monotonic_ns()))
|
||||
PY
|
||||
)"
|
||||
last_ns="$(python3 - "${wd_file}" <<'PY'
|
||||
import struct, sys
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
print(struct.unpack("Q", f.read())[0])
|
||||
PY
|
||||
)"
|
||||
python3 - "${now_ns}" "${last_ns}" <<'PY'
|
||||
import sys
|
||||
now_ns = int(sys.argv[1]); last_ns = int(sys.argv[2])
|
||||
print(f"watchdog_age_s={(now_ns-last_ns)/1e9:.3f}")
|
||||
PY
|
||||
else
|
||||
echo "watchdog_file_missing=${wd_file}"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
echo "=== /data/log (recent ui/watchdog/weston/gpu errors) ==="
|
||||
grep -RinE "Watchdog timeout for ui|Process ui crashed|ui.*exit|weston|Wayland|EGL|gbm|drm|GPU|segfault|SIGSEGV|killed process|OOM|fatal" /data/log/swaglog* 2>/dev/null | tail -n 400 || true
|
||||
echo
|
||||
|
||||
echo "=== dmesg (recent high-signal lines) ==="
|
||||
dmesg 2>/dev/null | grep -Ei "drm|gpu|msm|kgsl|weston|wayland|segfault|oom|killed process|hang" | tail -n 300 || true
|
||||
echo
|
||||
|
||||
echo "=== systemd journal (weston / ui if available) ==="
|
||||
journalctl -n 300 --no-pager 2>/dev/null | grep -Ei "weston|wayland|ui|kgsl|drm|gpu|segfault|oom" || true
|
||||
echo
|
||||
|
||||
echo "=== open files for UI process(es) ==="
|
||||
for ui_pid in $(pgrep -f "selfdrive.ui.ui|/selfdrive/ui/ui" 2>/dev/null || true); do
|
||||
echo "--- lsof for pid ${ui_pid} ---"
|
||||
lsof -p "${ui_pid}" 2>/dev/null | tail -n 200 || true
|
||||
done
|
||||
echo
|
||||
|
||||
echo "=== End ==="
|
||||
} > "${OUT_FILE}" 2>&1
|
||||
|
||||
echo "Wrote: ${OUT_FILE}"
|
||||
File diff suppressed because it is too large
Load Diff
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
from openpilot.system.hardware import HARDWARE
|
||||
|
||||
if __name__ == "__main__":
|
||||
HARDWARE.set_power_save(False)
|
||||
Executable
+596
@@ -0,0 +1,596 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
PLATFORM_KEY="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
HOST_PLATFORM_ROOT="${ROOT_DIR}/.host_runtime/${PLATFORM_KEY}"
|
||||
HOST_ROOT=""
|
||||
WORK_DIR=""
|
||||
HOST_VENV=""
|
||||
HOST_LOCK_DIR=""
|
||||
HOST_LOCK_PID_FILE=""
|
||||
HOST_LOCK_CMD_FILE=""
|
||||
HOST_LOCK_HELD=0
|
||||
HOST_BUCKETS=(shared cabana)
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./dev <command> [args...]
|
||||
./tool <command> [args...]
|
||||
./tools/host <command> [args...]
|
||||
|
||||
Commands:
|
||||
c3 Launch the desktop Qt UI from the isolated host cache.
|
||||
c4 Launch the small raylib UI from the isolated host cache.
|
||||
raybig Launch the large raylib UI from the isolated host cache.
|
||||
onroad Launch replay plus desktop UI(s) from the isolated host cache.
|
||||
replay Build and run replay from the isolated host cache.
|
||||
cabana Build and run cabana from the isolated host cache.
|
||||
plotjuggler Run PlotJuggler helper from the isolated host cache.
|
||||
juggle Alias for plotjuggler.
|
||||
sync Refresh the isolated host cache only.
|
||||
shell Open a shell inside the isolated host cache.
|
||||
help Show this help text.
|
||||
|
||||
Notes:
|
||||
- Host-tool builds happen under ./.host_runtime/ and do not touch the main tree.
|
||||
- `cabana` uses its own host-runtime bucket, so it can run together with `plotjuggler`.
|
||||
- Other commands that share a bucket still wait on that bucket's lock.
|
||||
- `./build` remains the device-target flow.
|
||||
- For c3/c4/raybig, pass the jobs count first to preserve existing shorthand:
|
||||
./dev c3 8
|
||||
./dev raybig 12
|
||||
- `./onroad --c3 f08912a233c1584f/2022-08-11--18-02-41/1` launches replay plus the selected desktop UI.
|
||||
- `./dev sync` refreshes all host buckets. Use `./dev sync cabana` to sync one.
|
||||
EOF
|
||||
}
|
||||
|
||||
default_jobs() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 8
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_host_bucket() {
|
||||
local name="${1:-shared}"
|
||||
|
||||
case "${name}" in
|
||||
shared|default|ui|c3|c4|raybig|onroad|replay|shell)
|
||||
echo "shared"
|
||||
;;
|
||||
cabana)
|
||||
echo "cabana"
|
||||
;;
|
||||
plotjuggler|juggle)
|
||||
echo "shared"
|
||||
;;
|
||||
*)
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
set_host_bucket() {
|
||||
local bucket="$1"
|
||||
|
||||
if [[ "${bucket}" == "shared" ]]; then
|
||||
HOST_ROOT="${HOST_PLATFORM_ROOT}"
|
||||
else
|
||||
HOST_ROOT="${HOST_PLATFORM_ROOT}/${bucket}"
|
||||
fi
|
||||
WORK_DIR="${HOST_ROOT}/worktree"
|
||||
HOST_VENV="${HOST_ROOT}/venv"
|
||||
HOST_LOCK_DIR="${HOST_ROOT}/lock"
|
||||
HOST_LOCK_PID_FILE="${HOST_LOCK_DIR}/pid"
|
||||
HOST_LOCK_CMD_FILE="${HOST_LOCK_DIR}/command"
|
||||
}
|
||||
|
||||
lock_owner_summary() {
|
||||
local lock_pid=""
|
||||
local lock_cmd="unknown"
|
||||
|
||||
if [[ -f "${HOST_LOCK_PID_FILE}" ]]; then
|
||||
lock_pid="$(<"${HOST_LOCK_PID_FILE}")"
|
||||
fi
|
||||
if [[ -f "${HOST_LOCK_CMD_FILE}" ]]; then
|
||||
lock_cmd="$(<"${HOST_LOCK_CMD_FILE}")"
|
||||
fi
|
||||
|
||||
if [[ -n "${lock_pid}" ]]; then
|
||||
echo "pid ${lock_pid} (${lock_cmd})"
|
||||
else
|
||||
echo "${lock_cmd}"
|
||||
fi
|
||||
}
|
||||
|
||||
lock_is_stale() {
|
||||
local lock_pid=""
|
||||
|
||||
if [[ ! -f "${HOST_LOCK_PID_FILE}" ]]; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
lock_pid="$(<"${HOST_LOCK_PID_FILE}")"
|
||||
if [[ ! "${lock_pid}" =~ ^[0-9]+$ ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if kill -0 "${lock_pid}" 2>/dev/null; then
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
release_host_lock() {
|
||||
if [[ "${HOST_LOCK_HELD}" != "1" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
rm -f "${HOST_LOCK_PID_FILE}" "${HOST_LOCK_CMD_FILE}"
|
||||
rmdir "${HOST_LOCK_DIR}" 2>/dev/null || rm -rf "${HOST_LOCK_DIR}"
|
||||
HOST_LOCK_HELD=0
|
||||
}
|
||||
|
||||
acquire_host_lock() {
|
||||
local lock_label="$1"
|
||||
local notified=0
|
||||
|
||||
mkdir -p "${HOST_ROOT}"
|
||||
|
||||
while true; do
|
||||
if mkdir "${HOST_LOCK_DIR}" 2>/dev/null; then
|
||||
printf '%s\n' "$$" > "${HOST_LOCK_PID_FILE}"
|
||||
printf '%s\n' "${lock_label}" > "${HOST_LOCK_CMD_FILE}"
|
||||
HOST_LOCK_HELD=1
|
||||
trap release_host_lock EXIT
|
||||
return
|
||||
fi
|
||||
|
||||
if lock_is_stale; then
|
||||
echo "Removing stale host runtime lock: $(lock_owner_summary)" >&2
|
||||
rm -rf "${HOST_LOCK_DIR}"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "${notified}" == "0" ]]; then
|
||||
echo "Waiting for host runtime lock in ${HOST_ROOT}. Another shorthand command is using it: $(lock_owner_summary)." >&2
|
||||
notified=1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
}
|
||||
|
||||
ensure_venv() {
|
||||
if [[ ! -x "${ROOT_DIR}/.venv/bin/python3" ]]; then
|
||||
echo "Missing .venv. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v uv >/dev/null 2>&1; then
|
||||
echo "Missing uv. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
sync_selected_bucket() {
|
||||
local bucket="$1"
|
||||
|
||||
set_host_bucket "${bucket}"
|
||||
acquire_host_lock "sync ${bucket}"
|
||||
sync_worktree
|
||||
echo "Host tool cache synced (${bucket}): ${WORK_DIR}"
|
||||
release_host_lock
|
||||
}
|
||||
|
||||
sync_all_buckets() {
|
||||
local bucket=""
|
||||
for bucket in "${HOST_BUCKETS[@]}"; do
|
||||
sync_selected_bucket "${bucket}"
|
||||
done
|
||||
}
|
||||
|
||||
purge_host_python_artifacts() {
|
||||
rm -f \
|
||||
"${WORK_DIR}/common/params_pyx.so" \
|
||||
"${WORK_DIR}/common/params_pyx.o" \
|
||||
"${WORK_DIR}/common/params_pyx.cpp" \
|
||||
"${WORK_DIR}/common/transformations/transformations.so" \
|
||||
"${WORK_DIR}/common/transformations/transformations.o" \
|
||||
"${WORK_DIR}/common/transformations/transformations.cpp" \
|
||||
"${WORK_DIR}/msgq_repo/msgq/ipc_pyx.so" \
|
||||
"${WORK_DIR}/msgq_repo/msgq/ipc_pyx.o" \
|
||||
"${WORK_DIR}/msgq_repo/msgq/ipc_pyx.cpp" \
|
||||
"${WORK_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.so" \
|
||||
"${WORK_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.o" \
|
||||
"${WORK_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.cpp"
|
||||
}
|
||||
|
||||
purge_host_desktop_ui_artifacts() {
|
||||
rm -f \
|
||||
"${WORK_DIR}/selfdrive/ui/libqt_widgets.a" \
|
||||
"${WORK_DIR}/selfdrive/ui/libqt_util.a" \
|
||||
"${WORK_DIR}/selfdrive/ui/assets.o" \
|
||||
"${WORK_DIR}/selfdrive/ui/main.o" \
|
||||
"${WORK_DIR}/selfdrive/ui/moc_ui.o" \
|
||||
"${WORK_DIR}/selfdrive/ui/ui.o" \
|
||||
"${WORK_DIR}/selfdrive/ui/ui" \
|
||||
"${WORK_DIR}/cereal/gen/cpp/"*.capnp.o
|
||||
}
|
||||
|
||||
ensure_host_python_tools() {
|
||||
ensure_venv
|
||||
|
||||
local root_py_minor
|
||||
root_py_minor="$("${ROOT_DIR}/.venv/bin/python3" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
|
||||
if [[ -x "${HOST_VENV}/bin/python3" ]]; then
|
||||
local host_py_minor
|
||||
host_py_minor="$("${HOST_VENV}/bin/python3" -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
|
||||
if [[ "${host_py_minor}" != "${root_py_minor}" ]]; then
|
||||
rm -rf "${HOST_VENV}"
|
||||
purge_host_python_artifacts
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -x "${HOST_VENV}/bin/scons" && -x "${HOST_VENV}/bin/cythonize" && -x "${HOST_VENV}/bin/python3" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
UV_PROJECT_ENVIRONMENT="${HOST_VENV}" UV_PYTHON="${ROOT_DIR}/.venv/bin/python3" uv sync --frozen --all-extras
|
||||
)
|
||||
}
|
||||
|
||||
sync_host_generated_headers() {
|
||||
if ! command -v capnpc >/dev/null 2>&1; then
|
||||
return
|
||||
fi
|
||||
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
mkdir -p cereal/gen/cpp
|
||||
capnpc --src-prefix=cereal \
|
||||
cereal/log.capnp \
|
||||
cereal/car.capnp \
|
||||
cereal/legacy.capnp \
|
||||
cereal/custom.capnp \
|
||||
-o c++:cereal/gen/cpp/
|
||||
)
|
||||
}
|
||||
|
||||
sync_worktree() {
|
||||
ensure_venv
|
||||
|
||||
mkdir -p "${HOST_ROOT}"
|
||||
|
||||
local excludes=(
|
||||
".git/"
|
||||
".venv/"
|
||||
".venv-linux-arm64/"
|
||||
".cache/"
|
||||
".comma_sysroot/"
|
||||
".host_runtime/"
|
||||
"__pycache__/"
|
||||
"*.pyc"
|
||||
"*.pyo"
|
||||
"*.o"
|
||||
"*.os"
|
||||
".sconsign.dblite"
|
||||
"compile_commands.json"
|
||||
"tools/plotjuggler/bin/"
|
||||
"tools/replay/replay"
|
||||
"tools/replay/tests/test_replay"
|
||||
"tools/cabana/cabana"
|
||||
"tools/cabana/tests/test_cabana"
|
||||
"selfdrive/ui/ui"
|
||||
"selfdrive/ui/ui.macos"
|
||||
"selfdrive/ui/ui.larch64"
|
||||
"selfdrive/ui/libqt_widgets.a"
|
||||
"selfdrive/ui/libqt_util.a"
|
||||
"cereal/libcereal.a"
|
||||
"cereal/libsocketmaster.a"
|
||||
"cereal/messaging/bridge"
|
||||
"common/libcommon.a"
|
||||
"common/params_pyx.so"
|
||||
"common/params_pyx.cpp"
|
||||
"common/transformations/libtransformations.a"
|
||||
"common/transformations/transformations.so"
|
||||
"msgq_repo/libmsgq.a"
|
||||
"msgq_repo/libvisionipc.a"
|
||||
"msgq_repo/msgq/ipc_pyx.so"
|
||||
"msgq_repo/msgq/visionipc/visionipc_pyx.so"
|
||||
"rednose_repo/rednose/helpers/ekf_sym_pyx.so"
|
||||
"selfdrive/modeld/models/commonmodel_pyx.so"
|
||||
"selfdrive/pandad/libcan_list_to_can_capnp.a"
|
||||
"selfdrive/pandad/pandad_api_impl.so"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.dylib"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.dylib"
|
||||
"third_party/libjson11.a"
|
||||
"third_party/libkaitai.a"
|
||||
)
|
||||
|
||||
local rsync_args=(-a --delete)
|
||||
local pattern=""
|
||||
for pattern in "${excludes[@]}"; do
|
||||
rsync_args+=(--exclude "${pattern}")
|
||||
done
|
||||
|
||||
local _capnp_before
|
||||
_capnp_before="$(stat -c '%Y' "${WORK_DIR}/cereal/custom.capnp" 2>/dev/null || echo 0)"
|
||||
rsync "${rsync_args[@]}" "${ROOT_DIR}/" "${WORK_DIR}/"
|
||||
local _capnp_after
|
||||
_capnp_after="$(stat -c '%Y' "${WORK_DIR}/cereal/custom.capnp" 2>/dev/null || echo 0)"
|
||||
if [[ "${_capnp_before}" != "${_capnp_after}" ]]; then
|
||||
rm -rf "${SP_SCONS_CACHE_DIR:-${HOST_ROOT}/scons_cache}"
|
||||
fi
|
||||
purge_host_desktop_ui_artifacts
|
||||
rm -f "${WORK_DIR}/third_party/libjson11.a" "${WORK_DIR}/third_party/libkaitai.a"
|
||||
sync_host_generated_headers
|
||||
ensure_host_python_tools
|
||||
rm -rf "${WORK_DIR}/.venv"
|
||||
ln -s "${HOST_VENV}" "${WORK_DIR}/.venv"
|
||||
}
|
||||
|
||||
setup_build_env() {
|
||||
if [[ -d /opt/homebrew/bin ]]; then
|
||||
export PATH="/opt/homebrew/bin:${PATH}"
|
||||
fi
|
||||
|
||||
mkdir -p "${HOST_ROOT}/scons_cache"
|
||||
export SP_SCONS_CACHE_DIR="${HOST_ROOT}/scons_cache"
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
export CC="/usr/bin/clang"
|
||||
export CXX="/usr/bin/clang++"
|
||||
export AR="/usr/bin/ar"
|
||||
export RANLIB="/usr/bin/ranlib"
|
||||
fi
|
||||
|
||||
unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPPFLAGS CFLAGS CXXFLAGS LDFLAGS
|
||||
}
|
||||
|
||||
export_workdir_pythonpath() {
|
||||
export PYTHONPATH="${WORK_DIR}:${WORK_DIR}/starpilot/third_party"
|
||||
local repo_dir=""
|
||||
for repo_dir in "${WORK_DIR}"/*_repo; do
|
||||
[[ -d "${repo_dir}" ]] && export PYTHONPATH="${PYTHONPATH}:${repo_dir}"
|
||||
done
|
||||
[[ -d "${WORK_DIR}/third_party/acados" ]] && export PYTHONPATH="${PYTHONPATH}:${WORK_DIR}/third_party/acados"
|
||||
}
|
||||
|
||||
run_host_scons() {
|
||||
local jobs="$1"
|
||||
shift || true
|
||||
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
source .venv/bin/activate
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${WORK_DIR}/.venv/bin/scons" --extras -j"${jobs}" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
run_in_worktree() {
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
"$@"
|
||||
)
|
||||
}
|
||||
|
||||
run_python_tool() {
|
||||
local script_path="$1"
|
||||
shift || true
|
||||
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
export_workdir_pythonpath
|
||||
source .venv/bin/activate
|
||||
exec "${WORK_DIR}/.venv/bin/python3" "${WORK_DIR}/${script_path}" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
launch_c3() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_in_worktree "${WORK_DIR}/scripts/launch_ui_desktop.sh" "${jobs}" "$@"
|
||||
}
|
||||
|
||||
launch_c4() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_in_worktree "${WORK_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}" "$@"
|
||||
}
|
||||
|
||||
launch_raybig() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_in_worktree "${WORK_DIR}/scripts/launch_ui_raybig_desktop.sh" "${jobs}" "$@"
|
||||
}
|
||||
|
||||
launch_onroad() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_in_worktree "${WORK_DIR}/scripts/launch_onroad_desktop.sh" "${jobs}" "$@"
|
||||
}
|
||||
|
||||
launch_replay() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_host_scons "${jobs}" tools/replay/replay
|
||||
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
export BASEDIR="${WORK_DIR}"
|
||||
exec "${WORK_DIR}/tools/replay/replay" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
launch_cabana() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_host_scons "${jobs}" tools/cabana/cabana
|
||||
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
export BASEDIR="${WORK_DIR}"
|
||||
exec "${WORK_DIR}/tools/cabana/cabana" "$@"
|
||||
)
|
||||
}
|
||||
|
||||
launch_plotjuggler() {
|
||||
local jobs
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
sync_worktree
|
||||
run_host_scons "${jobs}" \
|
||||
common/params_pyx.so \
|
||||
common/transformations/transformations.so \
|
||||
msgq_repo/msgq/ipc_pyx.so \
|
||||
msgq_repo/msgq/visionipc/visionipc_pyx.so
|
||||
run_python_tool "tools/plotjuggler/juggle.py" "$@"
|
||||
}
|
||||
|
||||
open_shell() {
|
||||
sync_worktree
|
||||
(
|
||||
cd "${WORK_DIR}"
|
||||
setup_build_env
|
||||
export_workdir_pythonpath
|
||||
source .venv/bin/activate
|
||||
exec "${SHELL:-/bin/bash}"
|
||||
)
|
||||
}
|
||||
|
||||
main() {
|
||||
local command="${1:-help}"
|
||||
local bucket=""
|
||||
if [[ $# -gt 0 ]]; then
|
||||
shift || true
|
||||
fi
|
||||
|
||||
case "${command}" in
|
||||
help|-h|--help)
|
||||
usage
|
||||
;;
|
||||
c3|c4|raybig|onroad|replay|shell)
|
||||
set_host_bucket "shared"
|
||||
acquire_host_lock "${command} $*"
|
||||
;;
|
||||
cabana)
|
||||
set_host_bucket "cabana"
|
||||
acquire_host_lock "${command} $*"
|
||||
;;
|
||||
plotjuggler|juggle)
|
||||
set_host_bucket "shared"
|
||||
acquire_host_lock "${command} $*"
|
||||
;;
|
||||
sync)
|
||||
if [[ $# -gt 0 ]]; then
|
||||
bucket="$(resolve_host_bucket "${1}")" || {
|
||||
echo "Unknown host bucket for sync: ${1}" >&2
|
||||
echo "Valid sync buckets: shared, cabana" >&2
|
||||
exit 1
|
||||
}
|
||||
shift || true
|
||||
sync_selected_bucket "${bucket}"
|
||||
else
|
||||
sync_all_buckets
|
||||
fi
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown command: ${command}" >&2
|
||||
echo >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "${command}" in
|
||||
c3)
|
||||
launch_c3 "$@"
|
||||
;;
|
||||
c4)
|
||||
launch_c4 "$@"
|
||||
;;
|
||||
raybig)
|
||||
launch_raybig "$@"
|
||||
;;
|
||||
onroad)
|
||||
launch_onroad "$@"
|
||||
;;
|
||||
replay)
|
||||
launch_replay "$@"
|
||||
;;
|
||||
cabana)
|
||||
launch_cabana "$@"
|
||||
;;
|
||||
plotjuggler|juggle)
|
||||
launch_plotjuggler "$@"
|
||||
;;
|
||||
shell)
|
||||
open_shell
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
set_host_bucket "shared"
|
||||
main "$@"
|
||||
Executable
+129
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
YELLOW='\033[0;33m'
|
||||
GREEN='\033[0;32m'
|
||||
UNDERLINE='\033[4m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
BRANCH="master"
|
||||
RUNS="20"
|
||||
|
||||
COOKIE_JAR=/tmp/cookies
|
||||
CRUMB=$(curl -s --cookie-jar $COOKIE_JAR 'https://jenkins.comma.life/crumbIssuer/api/xml?xpath=concat(//crumbRequestField,":",//crumb)')
|
||||
|
||||
FIRST_LOOP=1
|
||||
|
||||
function loop() {
|
||||
JENKINS_BRANCH="__jenkins_loop_${BRANCH}_$(date +%s)"
|
||||
API_ROUTE="https://jenkins.comma.life/job/openpilot/job/$JENKINS_BRANCH"
|
||||
|
||||
for run in $(seq 1 $((RUNS / 2))); do
|
||||
|
||||
N=2
|
||||
|
||||
if [[ $FIRST_LOOP ]]; then
|
||||
TEMP_DIR=$(mktemp -d)
|
||||
GIT_LFS_SKIP_SMUDGE=1 git clone --quiet -b $BRANCH --depth=1 --no-tags git@github.com:commaai/openpilot $TEMP_DIR
|
||||
git -C $TEMP_DIR checkout --quiet -b $JENKINS_BRANCH
|
||||
echo "TESTING: $(date)" >> $TEMP_DIR/testing_jenkins
|
||||
git -C $TEMP_DIR add testing_jenkins
|
||||
git -C $TEMP_DIR commit --quiet -m "testing"
|
||||
git -C $TEMP_DIR push --quiet -f origin $JENKINS_BRANCH
|
||||
rm -rf $TEMP_DIR
|
||||
FIRST_BUILD=1
|
||||
echo ''
|
||||
echo 'waiting on Jenkins...'
|
||||
echo ''
|
||||
sleep 90
|
||||
FIRST_LOOP=""
|
||||
fi
|
||||
|
||||
FIRST_BUILD=$(curl -s $API_ROUTE/api/json | jq .nextBuildNumber)
|
||||
LAST_BUILD=$((FIRST_BUILD+N-1))
|
||||
TEST_BUILDS=( $(seq $FIRST_BUILD $LAST_BUILD) )
|
||||
|
||||
# Start N new builds
|
||||
for i in ${TEST_BUILDS[@]};
|
||||
do
|
||||
echo "Starting build $i"
|
||||
curl -s --output /dev/null --cookie $COOKIE_JAR -H "$CRUMB" -X POST $API_ROUTE/build?delay=0sec
|
||||
sleep 5
|
||||
done
|
||||
echo ""
|
||||
|
||||
# Wait for all builds to end
|
||||
while true; do
|
||||
sleep 30
|
||||
|
||||
count=0
|
||||
for i in ${TEST_BUILDS[@]};
|
||||
do
|
||||
RES=$(curl -s -w "\n%{http_code}" --cookie $COOKIE_JAR -H "$CRUMB" $API_ROUTE/$i/api/json)
|
||||
HTTP_CODE=$(tail -n1 <<< "$RES")
|
||||
JSON=$(sed '$ d' <<< "$RES")
|
||||
|
||||
if [[ $HTTP_CODE == "200" ]]; then
|
||||
STILL_RUNNING=$(echo $JSON | jq .inProgress)
|
||||
if [[ $STILL_RUNNING == "true" ]]; then
|
||||
echo -e "Build $i: ${YELLOW}still running${NC}"
|
||||
continue
|
||||
else
|
||||
count=$((count+1))
|
||||
echo -e "Build $i: ${GREEN}done${NC}"
|
||||
fi
|
||||
else
|
||||
echo "No status for build $i"
|
||||
fi
|
||||
done
|
||||
echo "See live results: ${API_ROUTE}/buildTimeTrend"
|
||||
echo ""
|
||||
|
||||
if [[ $count -ge $N ]]; then
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
done
|
||||
}
|
||||
|
||||
function usage() {
|
||||
echo ""
|
||||
echo "Run the Jenkins tests multiple times on a specific branch"
|
||||
echo ""
|
||||
echo -e "${BOLD}${UNDERLINE}Options:${NC}"
|
||||
echo -e " ${BOLD}-n, --n${NC}"
|
||||
echo -e " Specify how many runs to do (default to ${BOLD}20${NC})"
|
||||
echo -e " ${BOLD}-b, --branch${NC}"
|
||||
echo -e " Specify which branch to run the tests against (default to ${BOLD}master${NC})"
|
||||
echo ""
|
||||
}
|
||||
|
||||
function _looper() {
|
||||
if [[ $# -eq 0 ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# parse Options
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-n | --n ) shift 1; RUNS="$1"; shift 1 ;;
|
||||
-b | --b | --branch | -branch ) shift 1; BRANCH="$1"; shift 1 ;;
|
||||
* ) usage; exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo -e "You are about to start $RUNS Jenkins builds against the $BRANCH branch."
|
||||
echo -e "If you expect this to run overnight, ${UNDERLINE}${BOLD}unplug the cold reboot power switch${NC} from the testing closet before."
|
||||
echo ""
|
||||
read -p "Press (y/Y) to confirm: " choice
|
||||
if [[ "$choice" == "y" || "$choice" == "Y" ]]; then
|
||||
loop
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
_looper $@
|
||||
Executable
+718
@@ -0,0 +1,718 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
HOST_ROOT_DIR="${COMMA_HOST_ROOT_DIR:-${ROOT_DIR}}"
|
||||
DOCKER_RUN_USER="${COMMA_DOCKER_RUN_USER:-$(id -u):$(id -g)}"
|
||||
HOST_VENV_DIR="${COMMA_HOST_VENV_DIR:-${HOST_ROOT_DIR}/.venv-linux-arm64}"
|
||||
|
||||
# Make Docker Desktop binaries discoverable (docker + credential helpers) even
|
||||
# when the caller shell PATH is minimal.
|
||||
if [[ -d /Applications/Docker.app/Contents/Resources/bin ]]; then
|
||||
export PATH="/Applications/Docker.app/Contents/Resources/bin:${PATH}"
|
||||
fi
|
||||
|
||||
IMAGE_NAME="${COMMA_BUILD_IMAGE:-starpilot-larch64-builder:latest}"
|
||||
SYSROOT_DIR_DEFAULT="${ROOT_DIR}/.comma_sysroot"
|
||||
SYSROOT_DIR="${COMMA_SYSROOT_DIR:-${SYSROOT_DIR_DEFAULT}}"
|
||||
HOST_SYSROOT_DIR="${COMMA_HOST_SYSROOT_DIR:-${SYSROOT_DIR}}"
|
||||
HOST_CACHE_DIR="${COMMA_HOST_CACHE_DIR:-${HOST_ROOT_DIR}/.cache}"
|
||||
HOST_DOCKERFILE_PATH="${COMMA_HOST_DOCKERFILE_PATH:-${HOST_ROOT_DIR}/tools/laptop_device_build/Dockerfile}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/laptop_device_build.sh doctor
|
||||
scripts/laptop_device_build.sh setup [device-host] [device-user] [ssh-port]
|
||||
scripts/laptop_device_build.sh setup-sysroot <device-host> [device-user] [ssh-port]
|
||||
scripts/laptop_device_build.sh setup-sysroot-agnos [manifest-path]
|
||||
scripts/laptop_device_build.sh build-image
|
||||
scripts/laptop_device_build.sh build [jobs] [scons args...]
|
||||
scripts/laptop_device_build.sh scons [scons args...]
|
||||
scripts/laptop_device_build.sh manager [jobs] [--no-build] [-- manager args...]
|
||||
scripts/laptop_device_build.sh shell
|
||||
|
||||
Modes:
|
||||
doctor Check host/container prerequisites for laptop-side device builds.
|
||||
setup One-time setup for laptop builds (venv + image + sysroot).
|
||||
setup-sysroot Sync required runtime/linker libs from a comma device over SSH.
|
||||
setup-sysroot-agnos Download AGNOS system image and extract sysroot locally.
|
||||
build-image Build the Linux/aarch64 container image used for device-target builds.
|
||||
build Run larch64 scons build in container and set prebuilt flag.
|
||||
scons Run raw scons in the larch64 container (no prebuilt touch).
|
||||
manager Run system/manager/manager.py in the larch64 container.
|
||||
shell Open an interactive shell in the build container with all mounts.
|
||||
|
||||
Environment overrides:
|
||||
COMMA_BUILD_IMAGE Container image tag (default: starpilot-larch64-builder:latest)
|
||||
COMMA_SYSROOT_DIR Sysroot location (default: ./.comma_sysroot)
|
||||
COMMA_AUTOSTART_DOCKER Auto-launch Docker Desktop on macOS when needed (default: 1)
|
||||
EOF
|
||||
}
|
||||
|
||||
err() {
|
||||
echo "ERROR: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"
|
||||
}
|
||||
|
||||
detect_engine() {
|
||||
local docker_cmd=""
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
docker_cmd="$(command -v docker)"
|
||||
elif [[ -x /Applications/Docker.app/Contents/Resources/bin/docker ]]; then
|
||||
docker_cmd="/Applications/Docker.app/Contents/Resources/bin/docker"
|
||||
fi
|
||||
|
||||
if [[ -n "${docker_cmd}" ]]; then
|
||||
# Needed for docker credential helpers (e.g. docker-credential-desktop).
|
||||
export PATH="$(dirname "${docker_cmd}"):${PATH}"
|
||||
if ! "${docker_cmd}" info >/dev/null 2>&1; then
|
||||
if [[ "$(uname -s)" == "Darwin" ]] && [[ "${COMMA_AUTOSTART_DOCKER:-1}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
if [[ -d /Applications/Docker.app ]] && command -v open >/dev/null 2>&1; then
|
||||
echo "Docker daemon is not running. Launching Docker Desktop..." >&2
|
||||
open -g -a Docker >/dev/null 2>&1 || open -a Docker >/dev/null 2>&1 || true
|
||||
for ((i=0; i<300; i++)); do
|
||||
if "${docker_cmd}" info >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
fi
|
||||
fi
|
||||
if ! "${docker_cmd}" info >/dev/null 2>&1; then
|
||||
err "Docker CLI found, but Docker daemon is not running. Start Docker Desktop (open -a Docker) and retry."
|
||||
fi
|
||||
fi
|
||||
echo "${docker_cmd}"
|
||||
return
|
||||
fi
|
||||
if command -v podman >/dev/null 2>&1; then
|
||||
local podman_cmd
|
||||
podman_cmd="$(command -v podman)"
|
||||
if ! "${podman_cmd}" info >/dev/null 2>&1; then
|
||||
err "Podman CLI found, but Podman service is not running."
|
||||
fi
|
||||
echo "${podman_cmd}"
|
||||
return
|
||||
fi
|
||||
err "No container runtime found (install docker or podman)."
|
||||
}
|
||||
|
||||
build_container_image_cmd() {
|
||||
local engine="$1"
|
||||
shift
|
||||
|
||||
if [[ "${engine##*/}" == "docker" ]] && "${engine}" buildx version >/dev/null 2>&1; then
|
||||
"${engine}" buildx build --load --platform linux/arm64 "$@"
|
||||
return
|
||||
fi
|
||||
|
||||
"${engine}" build --pull --platform linux/arm64 "$@"
|
||||
}
|
||||
|
||||
ensure_image_exists() {
|
||||
local engine="$1"
|
||||
if ! "${engine}" image inspect "${IMAGE_NAME}" >/dev/null 2>&1; then
|
||||
echo "Container image ${IMAGE_NAME} not found. Building it now..."
|
||||
build_container_image_cmd "${engine}" --pull -f "${HOST_DOCKERFILE_PATH}" -t "${IMAGE_NAME}" "${HOST_ROOT_DIR}"
|
||||
fi
|
||||
assert_image_arch "${engine}"
|
||||
ensure_image_capnp_version "${engine}"
|
||||
}
|
||||
|
||||
assert_image_arch() {
|
||||
local engine="$1"
|
||||
local arch
|
||||
arch="$("${engine}" image inspect "${IMAGE_NAME}" --format '{{.Architecture}}' 2>/dev/null || true)"
|
||||
if [[ "${arch}" != "arm64" ]]; then
|
||||
err "Container image ${IMAGE_NAME} is '${arch:-unknown}', expected 'arm64'. Rebuild with: ${engine} build --pull --platform linux/arm64 -f tools/laptop_device_build/Dockerfile -t ${IMAGE_NAME} ."
|
||||
fi
|
||||
}
|
||||
|
||||
expected_capnp_version() {
|
||||
if [[ -n "${COMMA_EXPECTED_CAPNP_VERSION:-}" ]]; then
|
||||
echo "${COMMA_EXPECTED_CAPNP_VERSION}"
|
||||
return
|
||||
fi
|
||||
|
||||
local dockerfile_version=""
|
||||
dockerfile_version="$(sed -n 's/^ARG CAPNP_VERSION=\(.*\)$/\1/p' "${ROOT_DIR}/tools/laptop_device_build/Dockerfile" | head -n 1)"
|
||||
if [[ -n "${dockerfile_version}" ]]; then
|
||||
echo "${dockerfile_version}"
|
||||
return
|
||||
fi
|
||||
|
||||
local header_path="${ROOT_DIR}/cereal/gen/cpp/custom.capnp.h"
|
||||
if [[ ! -f "${header_path}" ]]; then
|
||||
err "Unable to determine expected Cap'n Proto version. Set COMMA_EXPECTED_CAPNP_VERSION or restore tools/laptop_device_build/Dockerfile ARG CAPNP_VERSION."
|
||||
fi
|
||||
|
||||
local raw_version=""
|
||||
raw_version="$(sed -n 's/^#elif CAPNP_VERSION != \([0-9][0-9]*\)$/\1/p' "${header_path}" | head -n 1)"
|
||||
[[ -n "${raw_version}" ]] || err "Unable to determine expected Cap'n Proto version from ${header_path}."
|
||||
|
||||
local major=$(( raw_version / 1000000 ))
|
||||
local minor=$(( (raw_version / 1000) % 1000 ))
|
||||
local micro=$(( raw_version % 1000 ))
|
||||
echo "${major}.${minor}.${micro}"
|
||||
}
|
||||
|
||||
image_capnp_version() {
|
||||
local engine="$1"
|
||||
"${engine}" run --rm --platform linux/arm64 "${IMAGE_NAME}" bash -lc "capnp --version | awk '{print \$4}'" 2>/dev/null || true
|
||||
}
|
||||
|
||||
ensure_image_capnp_version() {
|
||||
local engine="$1"
|
||||
local expected actual
|
||||
expected="$(expected_capnp_version)"
|
||||
actual="$(image_capnp_version "${engine}")"
|
||||
|
||||
if [[ "${actual}" == "${expected}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
echo "Container image ${IMAGE_NAME} has Cap'n Proto ${actual:-unknown}, expected ${expected}. Rebuilding it now..."
|
||||
build_container_image_cmd "${engine}" --pull -f "${HOST_DOCKERFILE_PATH}" -t "${IMAGE_NAME}" "${HOST_ROOT_DIR}"
|
||||
|
||||
actual="$(image_capnp_version "${engine}")"
|
||||
if [[ "${actual}" != "${expected}" ]]; then
|
||||
err "Container image ${IMAGE_NAME} still has Cap'n Proto ${actual:-unknown} after rebuild; expected ${expected}."
|
||||
fi
|
||||
}
|
||||
|
||||
assert_runtime_machine() {
|
||||
local engine="$1"
|
||||
local machine
|
||||
machine="$("${engine}" run --rm --platform linux/arm64 "${IMAGE_NAME}" bash -lc "readelf -h \$(command -v python3) | awk -F: '/Machine/ {gsub(/^ +/, \"\", \$2); print \$2}'" 2>/dev/null || true)"
|
||||
if [[ "${machine}" != "AArch64" ]]; then
|
||||
err "Container Python runtime is '${machine:-unknown}', expected 'AArch64'. This image can still build device artifacts, but cannot run manager with larch64 Python extensions. Rebuild from an arm64-capable base image before using 'manager' mode."
|
||||
fi
|
||||
}
|
||||
|
||||
default_jobs() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 8
|
||||
fi
|
||||
}
|
||||
|
||||
build_container_image() {
|
||||
local engine
|
||||
engine="$(detect_engine)"
|
||||
build_container_image_cmd "${engine}" --pull \
|
||||
-f "${HOST_DOCKERFILE_PATH}" \
|
||||
-t "${IMAGE_NAME}" "${HOST_ROOT_DIR}"
|
||||
assert_image_arch "${engine}"
|
||||
}
|
||||
|
||||
sanitize_sysroot_headers() {
|
||||
# Device /usr/local/include can include older capnp/kj headers that conflict
|
||||
# with generated cereal headers in this tree. Keep ffmpeg headers, drop capnp.
|
||||
local removed=0
|
||||
local path=""
|
||||
for path in \
|
||||
"${SYSROOT_DIR}/usr/local/include/capnp" \
|
||||
"${SYSROOT_DIR}/usr/local/include/kj"; do
|
||||
if [[ -e "${path}" ]]; then
|
||||
rm -rf "${path}"
|
||||
removed=1
|
||||
fi
|
||||
done
|
||||
if [[ "${removed}" -eq 1 ]]; then
|
||||
echo "Sanitized sysroot headers: removed capnp/kj from usr/local/include"
|
||||
fi
|
||||
}
|
||||
|
||||
ensure_sysroot_layout() {
|
||||
[[ -d "${SYSROOT_DIR}/usr/local/lib" ]] || err "Missing ${SYSROOT_DIR}/usr/local/lib. Run setup-sysroot first."
|
||||
[[ -d "${SYSROOT_DIR}/usr/local/include" ]] || err "Missing ${SYSROOT_DIR}/usr/local/include. Run setup-sysroot first."
|
||||
[[ -d "${SYSROOT_DIR}/lib/aarch64-linux-gnu" ]] || err "Missing ${SYSROOT_DIR}/lib/aarch64-linux-gnu. Run setup-sysroot first."
|
||||
[[ -d "${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu" ]] || err "Missing ${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu. Run setup-sysroot first."
|
||||
[[ -d "${SYSROOT_DIR}/usr/include" ]] || err "Missing ${SYSROOT_DIR}/usr/include. Run setup-sysroot first."
|
||||
[[ -d "${SYSROOT_DIR}/system/vendor/lib64" ]] || err "Missing ${SYSROOT_DIR}/system/vendor/lib64. Run setup-sysroot first."
|
||||
sanitize_sysroot_headers
|
||||
repair_sysroot_linker
|
||||
}
|
||||
|
||||
is_aarch64_elf() {
|
||||
local path="${1:-}"
|
||||
[[ -f "${path}" ]] || return 1
|
||||
local desc
|
||||
desc="$(file -b "${path}" 2>/dev/null || true)"
|
||||
[[ "${desc}" == *"ELF 64-bit LSB"* && "${desc}" == *"ARM aarch64"* ]]
|
||||
}
|
||||
|
||||
repair_sysroot_linker() {
|
||||
local lib_ld="${SYSROOT_DIR}/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
|
||||
local usr_ld="${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
|
||||
|
||||
# Some sysroot extraction paths can create this as an empty directory.
|
||||
if [[ -d "${lib_ld}" ]]; then
|
||||
rm -rf "${lib_ld}"
|
||||
fi
|
||||
|
||||
# Ensure lib-path loader exists (symlink to usr/lib loader is fine).
|
||||
if [[ ! -e "${lib_ld}" && -f "${usr_ld}" ]]; then
|
||||
mkdir -p "$(dirname "${lib_ld}")"
|
||||
ln -s "../../usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1" "${lib_ld}"
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_ldso_source() {
|
||||
local lib_ld="${SYSROOT_DIR}/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
|
||||
local usr_ld="${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1"
|
||||
|
||||
repair_sysroot_linker
|
||||
|
||||
if is_aarch64_elf "${lib_ld}"; then
|
||||
echo "${lib_ld}"
|
||||
return 0
|
||||
fi
|
||||
if is_aarch64_elf "${usr_ld}"; then
|
||||
echo "${usr_ld}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
err "Missing valid aarch64 dynamic loader in sysroot. Expected one of: ${lib_ld} or ${usr_ld}. Re-run setup-sysroot."
|
||||
}
|
||||
|
||||
scrub_mixed_arch_artifacts() {
|
||||
# Desktop launchers can leave macOS objects with the same paths as larch64 outputs.
|
||||
# Remove known cross-target extension intermediates so device builds always relink.
|
||||
rm -f \
|
||||
"${ROOT_DIR}/.sconsign.dblite" \
|
||||
"${ROOT_DIR}/common/libcommon.a" \
|
||||
"${ROOT_DIR}/common/params.o" \
|
||||
"${ROOT_DIR}/common/params_pyx.cpp" \
|
||||
"${ROOT_DIR}/cereal/libcereal.a" \
|
||||
"${ROOT_DIR}/cereal/libsocketmaster.a" \
|
||||
"${ROOT_DIR}/cereal/gen/cpp/"*.capnp.c++ \
|
||||
"${ROOT_DIR}/cereal/gen/cpp/"*.capnp.h \
|
||||
"${ROOT_DIR}/cereal/gen/cpp/"*.capnp.o \
|
||||
"${ROOT_DIR}/cereal/messaging/"*.o \
|
||||
"${ROOT_DIR}/cereal/messaging/bridge" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/"*.os \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/"*.o \
|
||||
"${ROOT_DIR}/msgq_repo/libmsgq.a" \
|
||||
"${ROOT_DIR}/msgq_repo/libvisionipc.a" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/ipc_pyx.so" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/"*.os \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/"*.o \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.so" \
|
||||
"${ROOT_DIR}/msgq/ipc_pyx.so" \
|
||||
"${ROOT_DIR}/msgq/visionipc/visionipc_pyx.so" \
|
||||
"${ROOT_DIR}/common/params_pyx.o" \
|
||||
"${ROOT_DIR}/common/params_pyx.so" \
|
||||
"${ROOT_DIR}/common/transformations/transformations.o" \
|
||||
"${ROOT_DIR}/common/transformations/transformations.so" \
|
||||
"${ROOT_DIR}/selfdrive/pandad/can_list_to_can_capnp.o" \
|
||||
"${ROOT_DIR}/selfdrive/pandad/libcan_list_to_can_capnp.a" \
|
||||
"${ROOT_DIR}/selfdrive/modeld/models/commonmodel_pyx.o" \
|
||||
"${ROOT_DIR}/selfdrive/modeld/models/commonmodel_pyx.so"
|
||||
}
|
||||
|
||||
sync_sysroot_from_device() {
|
||||
local host="${1:-}"
|
||||
local user="${2:-comma}"
|
||||
local port="${3:-22}"
|
||||
[[ -n "${host}" ]] || err "setup-sysroot requires <device-host>."
|
||||
|
||||
require_cmd rsync
|
||||
mkdir -p "${SYSROOT_DIR}"
|
||||
local ssh_cmd="ssh -p ${port} -o StrictHostKeyChecking=accept-new"
|
||||
|
||||
rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/local/lib/" "${SYSROOT_DIR}/usr/local/lib/"
|
||||
rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/local/include/" "${SYSROOT_DIR}/usr/local/include/"
|
||||
rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/lib/aarch64-linux-gnu/" "${SYSROOT_DIR}/lib/aarch64-linux-gnu/"
|
||||
rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/lib/aarch64-linux-gnu/" "${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu/"
|
||||
rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:/usr/include/" "${SYSROOT_DIR}/usr/include/"
|
||||
|
||||
local vendor_src=""
|
||||
for candidate in /system/vendor/lib64 /vendor/lib64; do
|
||||
if ssh -p "${port}" -o StrictHostKeyChecking=accept-new "${user}@${host}" "test -d '${candidate}'"; then
|
||||
vendor_src="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${vendor_src}" ]]; then
|
||||
rsync -a --delete -e "${ssh_cmd}" "${user}@${host}:${vendor_src}/" "${SYSROOT_DIR}/system/vendor/lib64/"
|
||||
else
|
||||
local vendor_dst="${SYSROOT_DIR}/system/vendor/lib64"
|
||||
echo "Warning: no vendor lib64 dir found on device; falling back to usr/lib/aarch64-linux-gnu"
|
||||
if [[ -L "${vendor_dst}" || -f "${vendor_dst}" ]]; then
|
||||
rm -f "${vendor_dst}"
|
||||
elif [[ -d "${vendor_dst}" ]]; then
|
||||
rm -rf "${vendor_dst}"
|
||||
fi
|
||||
mkdir -p "${SYSROOT_DIR}/system/vendor"
|
||||
ln -s ../../usr/lib/aarch64-linux-gnu "${vendor_dst}"
|
||||
fi
|
||||
|
||||
sanitize_sysroot_headers
|
||||
|
||||
echo "Sysroot synced to: ${SYSROOT_DIR}"
|
||||
}
|
||||
|
||||
setup_sysroot_from_agnos() {
|
||||
local manifest="${1:-system/hardware/tici/agnos.json}"
|
||||
local engine
|
||||
engine="$(detect_engine)"
|
||||
ensure_image_exists "${engine}"
|
||||
mkdir -p "${SYSROOT_DIR}" "${ROOT_DIR}/.cache/agnos" "${HOST_CACHE_DIR}/agnos"
|
||||
|
||||
"${engine}" run --rm --platform linux/arm64 \
|
||||
--user "${DOCKER_RUN_USER}" \
|
||||
-v "${HOST_ROOT_DIR}:/work" \
|
||||
-v "${HOST_SYSROOT_DIR}:/opt/tici-sysroot" \
|
||||
-v "${HOST_CACHE_DIR}:/work/.cache" \
|
||||
-w /work \
|
||||
"${IMAGE_NAME}" \
|
||||
/usr/bin/python3 tools/laptop_device_build/extract_sysroot_from_agnos.py \
|
||||
--manifest "${manifest}" \
|
||||
--output-dir /opt/tici-sysroot \
|
||||
--cache-dir /work/.cache/agnos
|
||||
}
|
||||
|
||||
run_larch64_scons() {
|
||||
local jobs="$1"
|
||||
shift || true
|
||||
local engine
|
||||
local started_at
|
||||
engine="$(detect_engine)"
|
||||
ensure_image_exists "${engine}"
|
||||
ensure_sysroot_layout
|
||||
scrub_mixed_arch_artifacts
|
||||
started_at="$(date +%s)"
|
||||
|
||||
echo "==> Starting larch64 scons build"
|
||||
echo " jobs: ${jobs}"
|
||||
echo " note: warp artifact precompile can take several minutes on first run"
|
||||
|
||||
mkdir -p "${ROOT_DIR}/.cache/scons" "${HOST_CACHE_DIR}/scons" "${HOST_VENV_DIR}"
|
||||
|
||||
local extra_args=""
|
||||
local jobs_prefix="-j${jobs}"
|
||||
if [[ "$#" -gt 0 ]]; then
|
||||
for arg in "$@"; do
|
||||
case "${arg}" in
|
||||
-j|--jobs|-j*|--jobs=*)
|
||||
jobs_prefix=""
|
||||
;;
|
||||
esac
|
||||
extra_args+=" $(printf '%q' "${arg}")"
|
||||
done
|
||||
fi
|
||||
|
||||
local cmd
|
||||
cmd="$(cat <<EOF
|
||||
set -euo pipefail
|
||||
export UV_CACHE_DIR=/work/.cache/uv
|
||||
export SCONS_CACHE=/work/.cache/scons
|
||||
export SP_FORCE_TICI=1
|
||||
export SP_FORCE_ARCH=larch64
|
||||
export SP_TICI_SYSROOT=/opt/tici-sysroot
|
||||
export SP_BUILD_WARP_ARTIFACTS="\${SP_BUILD_WARP_ARTIFACTS:-0}"
|
||||
export SP_SKIP_DM_TINYGRAD_PKL="\${SP_SKIP_DM_TINYGRAD_PKL:-1}"
|
||||
export LD_LIBRARY_PATH=/usr/local/lib:/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu:/opt/tici-sysroot/usr/local/lib:/opt/tici-sysroot/usr/lib/aarch64-linux-gnu:/opt/tici-sysroot/lib/aarch64-linux-gnu:/system/vendor/lib64:\${LD_LIBRARY_PATH:-}
|
||||
export TMPDIR=/tmp
|
||||
export HOME=/tmp
|
||||
export PARAMS_ROOT=/tmp/params
|
||||
mkdir -p "\${UV_CACHE_DIR}"
|
||||
mkdir -p "\${PARAMS_ROOT}"
|
||||
UV_PROJECT_ENVIRONMENT=/work/.venv-linux-arm64 uv sync --frozen --all-extras
|
||||
source /work/.venv-linux-arm64/bin/activate
|
||||
CACHE_FLAG="--cache-disable"
|
||||
if [[ "\${SP_ENABLE_SCONS_CACHE:-0}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
CACHE_FLAG=""
|
||||
fi
|
||||
scons ${jobs_prefix} \${CACHE_FLAG}${extra_args}
|
||||
EOF
|
||||
)"
|
||||
|
||||
"${engine}" run --rm --platform linux/arm64 \
|
||||
--user "${DOCKER_RUN_USER}" \
|
||||
-v "${HOST_ROOT_DIR}:/work" \
|
||||
-v "${HOST_VENV_DIR}:/work/.venv-linux-arm64" \
|
||||
-v "${HOST_SYSROOT_DIR}:/opt/tici-sysroot:ro" \
|
||||
-v "${HOST_SYSROOT_DIR}/system/vendor/lib64:/system/vendor/lib64:ro" \
|
||||
-v "${HOST_CACHE_DIR}:/work/.cache" \
|
||||
-v "${HOST_CACHE_DIR}/scons:/data/scons_cache" \
|
||||
-w /work \
|
||||
"${IMAGE_NAME}" bash -lc "${cmd}"
|
||||
|
||||
echo "==> larch64 scons completed in $(( $(date +%s) - started_at ))s"
|
||||
}
|
||||
|
||||
setup_host_venv() {
|
||||
if [[ ! -f "${ROOT_DIR}/.venv/bin/activate" ]]; then
|
||||
if [[ -x "${ROOT_DIR}/tools/install_python_dependencies.sh" ]]; then
|
||||
(cd "${ROOT_DIR}" && tools/install_python_dependencies.sh)
|
||||
else
|
||||
err "Missing host .venv and installer script."
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
setup_all() {
|
||||
local host="${1:-}"
|
||||
local user="${2:-comma}"
|
||||
local port="${3:-22}"
|
||||
|
||||
setup_host_venv
|
||||
build_container_image
|
||||
|
||||
if [[ -n "${host}" ]]; then
|
||||
sync_sysroot_from_device "${host}" "${user}" "${port}"
|
||||
else
|
||||
setup_sysroot_from_agnos "system/hardware/tici/agnos.json"
|
||||
fi
|
||||
}
|
||||
|
||||
run_larch64_build() {
|
||||
local jobs="${1:-$(default_jobs)}"
|
||||
shift || true
|
||||
echo "==> Build pass 1/2: full project build"
|
||||
run_larch64_scons "${jobs}" "$@"
|
||||
# Ensure prebuilt runtime compatibility probes can import these modules.
|
||||
# scrub_mixed_arch_artifacts clears them at the start of each run, so
|
||||
# targeted builds must always regenerate this core set.
|
||||
# Do NOT regenerate dmonitoring_model_tinygrad.pkl on laptop builds:
|
||||
# it is backend-captured and should come from device/QCOM-compatible artifacts.
|
||||
echo "==> Build pass 2/2: required runtime artifacts"
|
||||
run_larch64_scons "${jobs}" \
|
||||
selfdrive/modeld/models/dmonitoring_model_metadata.pkl \
|
||||
selfdrive/modeld/models/driving_vision_metadata.pkl \
|
||||
selfdrive/modeld/models/driving_policy_metadata.pkl \
|
||||
selfdrive/modeld/models/driving_vision_tinygrad.pkl \
|
||||
selfdrive/modeld/models/driving_policy_tinygrad.pkl \
|
||||
rednose/helpers/ekf_sym_pyx.so \
|
||||
common/params_pyx.so \
|
||||
common/transformations/transformations.so \
|
||||
selfdrive/pandad/pandad_api_impl.so \
|
||||
selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so \
|
||||
selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so \
|
||||
selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so \
|
||||
selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so \
|
||||
selfdrive/modeld/models/commonmodel_pyx.so \
|
||||
cereal/messaging/bridge \
|
||||
msgq_repo/msgq/ipc_pyx.so \
|
||||
msgq_repo/msgq/visionipc/visionipc_pyx.so
|
||||
touch "${ROOT_DIR}/prebuilt"
|
||||
}
|
||||
|
||||
manager_artifacts_ready() {
|
||||
[[ -f "${ROOT_DIR}/msgq/ipc_pyx.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/msgq/visionipc/visionipc_pyx.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/common/params_pyx.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/selfdrive/pandad/pandad_api_impl.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.so" ]] &&
|
||||
[[ -f "${ROOT_DIR}/selfdrive/ui/ui" ]]
|
||||
}
|
||||
|
||||
run_manager() {
|
||||
local jobs="$(default_jobs)"
|
||||
local auto_build=1
|
||||
local manager_args=()
|
||||
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "${1}" in
|
||||
--no-build)
|
||||
auto_build=0
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift || true
|
||||
manager_args=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
manager_args+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${auto_build}" -eq 1 ]] && ! manager_artifacts_ready; then
|
||||
echo "Missing manager runtime artifacts. Running device-target build first..."
|
||||
run_larch64_build "${jobs}"
|
||||
fi
|
||||
|
||||
local engine
|
||||
engine="$(detect_engine)"
|
||||
ensure_image_exists "${engine}"
|
||||
assert_runtime_machine "${engine}"
|
||||
ensure_sysroot_layout
|
||||
mkdir -p "${ROOT_DIR}/.cache/scons" "${HOST_CACHE_DIR}/scons" "${HOST_VENV_DIR}"
|
||||
|
||||
local manager_args_q=""
|
||||
if [[ "${#manager_args[@]}" -gt 0 ]]; then
|
||||
for arg in "${manager_args[@]}"; do
|
||||
manager_args_q+=" $(printf '%q' "${arg}")"
|
||||
done
|
||||
fi
|
||||
|
||||
local cmd
|
||||
cmd="$(cat <<EOF
|
||||
set -euo pipefail
|
||||
export UV_CACHE_DIR=/work/.cache/uv
|
||||
export SP_FORCE_TICI=1
|
||||
export SP_FORCE_ARCH=larch64
|
||||
export SP_TICI_SYSROOT=/opt/tici-sysroot
|
||||
export SP_BUILD_WARP_ARTIFACTS="\${SP_BUILD_WARP_ARTIFACTS:-0}"
|
||||
export LD_LIBRARY_PATH=/usr/local/lib:/usr/lib/aarch64-linux-gnu:/lib/aarch64-linux-gnu:/opt/tici-sysroot/usr/local/lib:/opt/tici-sysroot/usr/lib/aarch64-linux-gnu:/opt/tici-sysroot/lib/aarch64-linux-gnu:/system/vendor/lib64:\${LD_LIBRARY_PATH:-}
|
||||
export TMPDIR=/tmp
|
||||
export HOME=/tmp
|
||||
export PARAMS_ROOT=/tmp/params
|
||||
mkdir -p "\${UV_CACHE_DIR}"
|
||||
mkdir -p "\${PARAMS_ROOT}"
|
||||
UV_PROJECT_ENVIRONMENT=/work/.venv-linux-arm64 uv sync --frozen --all-extras
|
||||
source /work/.venv-linux-arm64/bin/activate
|
||||
cd /work
|
||||
python3 system/manager/manager.py${manager_args_q}
|
||||
EOF
|
||||
)"
|
||||
|
||||
"${engine}" run --rm --platform linux/arm64 \
|
||||
--user "${DOCKER_RUN_USER}" \
|
||||
-v "${HOST_ROOT_DIR}:/work" \
|
||||
-v "${HOST_VENV_DIR}:/work/.venv-linux-arm64" \
|
||||
-v "${HOST_SYSROOT_DIR}:/opt/tici-sysroot:ro" \
|
||||
-v "${HOST_SYSROOT_DIR}/system/vendor/lib64:/system/vendor/lib64:ro" \
|
||||
-v "${HOST_CACHE_DIR}:/work/.cache" \
|
||||
-v "${HOST_CACHE_DIR}/scons:/data/scons_cache" \
|
||||
-w /work \
|
||||
"${IMAGE_NAME}" bash -lc "${cmd}"
|
||||
}
|
||||
|
||||
run_shell() {
|
||||
local engine
|
||||
engine="$(detect_engine)"
|
||||
ensure_image_exists "${engine}"
|
||||
ensure_sysroot_layout
|
||||
"${engine}" run --rm -it --platform linux/arm64 \
|
||||
--user "${DOCKER_RUN_USER}" \
|
||||
-v "${HOST_ROOT_DIR}:/work" \
|
||||
-v "${HOST_VENV_DIR}:/work/.venv-linux-arm64" \
|
||||
-v "${HOST_SYSROOT_DIR}:/opt/tici-sysroot:ro" \
|
||||
-v "${HOST_SYSROOT_DIR}/system/vendor/lib64:/system/vendor/lib64:ro" \
|
||||
-v "${HOST_CACHE_DIR}:/work/.cache" \
|
||||
-v "${HOST_CACHE_DIR}/scons:/data/scons_cache" \
|
||||
-w /work \
|
||||
"${IMAGE_NAME}" bash
|
||||
}
|
||||
|
||||
doctor() {
|
||||
local failed=0
|
||||
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
echo "OK: docker found"
|
||||
elif command -v podman >/dev/null 2>&1; then
|
||||
echo "OK: podman found"
|
||||
else
|
||||
echo "FAIL: install docker or podman"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if command -v rsync >/dev/null 2>&1; then
|
||||
echo "OK: rsync found"
|
||||
else
|
||||
echo "WARN: rsync missing (needed only for setup-sysroot from a physical device)"
|
||||
fi
|
||||
|
||||
if [[ -d "${SYSROOT_DIR}" ]]; then
|
||||
if [[ -d "${SYSROOT_DIR}/usr/local/lib" && -d "${SYSROOT_DIR}/usr/local/include" && -d "${SYSROOT_DIR}/lib/aarch64-linux-gnu" && -d "${SYSROOT_DIR}/usr/lib/aarch64-linux-gnu" && -d "${SYSROOT_DIR}/usr/include" && -d "${SYSROOT_DIR}/system/vendor/lib64" ]]; then
|
||||
echo "OK: sysroot present at ${SYSROOT_DIR}"
|
||||
else
|
||||
echo "WARN: sysroot dir exists but is incomplete (${SYSROOT_DIR})"
|
||||
failed=1
|
||||
fi
|
||||
else
|
||||
echo "WARN: sysroot missing (${SYSROOT_DIR})"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ -f "${ROOT_DIR}/.venv/bin/activate" ]]; then
|
||||
echo "OK: host .venv present"
|
||||
else
|
||||
echo "WARN: host .venv missing (run scripts/laptop_device_build.sh setup)"
|
||||
failed=1
|
||||
fi
|
||||
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local mode="${1:-}"
|
||||
case "${mode}" in
|
||||
doctor)
|
||||
doctor
|
||||
;;
|
||||
setup)
|
||||
shift || true
|
||||
setup_all "${1:-}" "${2:-comma}" "${3:-22}"
|
||||
;;
|
||||
setup-sysroot)
|
||||
shift
|
||||
sync_sysroot_from_device "${1:-}" "${2:-comma}" "${3:-22}"
|
||||
;;
|
||||
setup-sysroot-agnos)
|
||||
shift || true
|
||||
setup_sysroot_from_agnos "${1:-system/hardware/tici/agnos.json}"
|
||||
;;
|
||||
build-image)
|
||||
build_container_image
|
||||
;;
|
||||
build)
|
||||
shift || true
|
||||
local jobs_arg="${1:-$(default_jobs)}"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
shift || true
|
||||
else
|
||||
jobs_arg="$(default_jobs)"
|
||||
fi
|
||||
run_larch64_build "${jobs_arg}" "$@"
|
||||
;;
|
||||
scons)
|
||||
shift || true
|
||||
run_larch64_scons "$(default_jobs)" "$@"
|
||||
;;
|
||||
manager)
|
||||
shift || true
|
||||
run_manager "$@"
|
||||
;;
|
||||
shell)
|
||||
run_shell
|
||||
;;
|
||||
""|-h|--help|help)
|
||||
usage
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" >/dev/null && pwd)"
|
||||
|
||||
export FINGERPRINT="TOYOTA_COROLLA_TSS2"
|
||||
export SKIP_FW_QUERY="1"
|
||||
$DIR/../launch_openpilot.sh
|
||||
Executable
+589
@@ -0,0 +1,589 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if [[ ! -f .venv/bin/activate ]]; then
|
||||
echo "Missing .venv. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
default_jobs() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 8
|
||||
fi
|
||||
}
|
||||
|
||||
env_var_truthy() {
|
||||
[[ "${1:-}" =~ ^(1|true|yes|on)$ ]]
|
||||
}
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
./onroad [jobs] (--c3 | --c4 | --raybig | --all | --replay-only) [-nav] [--prefix name] <route-or-replay-args...>
|
||||
|
||||
Examples:
|
||||
./onroad --c3 <route>
|
||||
./onroad --c4 <route> --start 30
|
||||
./onroad --c4 -nav <route>
|
||||
./onroad --all <route>
|
||||
./onroad --replay-only --demo --no-vipc --no-loop
|
||||
|
||||
Notes:
|
||||
- This is host/dev only. It uses the isolated host worktree and does not touch the device path.
|
||||
- A private comma connect route still requires tools/lib/auth.py before replay can download it.
|
||||
- Use multiple UI flags together if you want more than one desktop UI at once.
|
||||
- -nav injects a fake navigation demo stream and blocks replay from publishing navInstruction/navRoute.
|
||||
EOF
|
||||
}
|
||||
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
PREFIX_ARG=""
|
||||
REPLAY_ARGS=()
|
||||
UI_TARGETS=()
|
||||
LEGACY_UI_SELECTION=""
|
||||
REPLAY_ONLY=0
|
||||
NAV_DEMO=0
|
||||
LIVE_CONTROLS=0
|
||||
REPLAY_PID=""
|
||||
NAV_PID=""
|
||||
UI_PIDS=()
|
||||
LIVE_PIDS=()
|
||||
|
||||
parse_args() {
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
--c3)
|
||||
UI_TARGETS+=(c3)
|
||||
shift
|
||||
;;
|
||||
--c4)
|
||||
UI_TARGETS+=(c4)
|
||||
shift
|
||||
;;
|
||||
--raybig)
|
||||
UI_TARGETS+=(raybig)
|
||||
shift
|
||||
;;
|
||||
--all)
|
||||
UI_TARGETS+=(c3 c4 raybig)
|
||||
shift
|
||||
;;
|
||||
--replay-only)
|
||||
REPLAY_ONLY=1
|
||||
shift
|
||||
;;
|
||||
-nav|--nav)
|
||||
NAV_DEMO=1
|
||||
shift
|
||||
;;
|
||||
--live|--live-controls)
|
||||
LIVE_CONTROLS=1
|
||||
shift
|
||||
;;
|
||||
--ui)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Missing value for --ui" >&2
|
||||
exit 1
|
||||
fi
|
||||
LEGACY_UI_SELECTION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--ui=*)
|
||||
LEGACY_UI_SELECTION="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
-p|--prefix)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Missing value for $1" >&2
|
||||
exit 1
|
||||
fi
|
||||
PREFIX_ARG="$2"
|
||||
shift 2
|
||||
;;
|
||||
--prefix=*)
|
||||
PREFIX_ARG="${1#*=}"
|
||||
shift
|
||||
;;
|
||||
--)
|
||||
shift
|
||||
REPLAY_ARGS+=("$@")
|
||||
break
|
||||
;;
|
||||
*)
|
||||
REPLAY_ARGS+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
expand_ui_targets() {
|
||||
local selection="${1// /}"
|
||||
|
||||
case "${selection,,}" in
|
||||
all|"")
|
||||
UI_TARGETS=(c3 c4 raybig)
|
||||
return
|
||||
;;
|
||||
none)
|
||||
UI_TARGETS=()
|
||||
return
|
||||
;;
|
||||
esac
|
||||
|
||||
local raw_targets=()
|
||||
IFS=',' read -r -a raw_targets <<< "${selection}"
|
||||
|
||||
local normalized=()
|
||||
local raw=""
|
||||
for raw in "${raw_targets[@]}"; do
|
||||
case "${raw,,}" in
|
||||
c3|c4|raybig)
|
||||
normalized+=("${raw,,}")
|
||||
;;
|
||||
*)
|
||||
echo "Unknown UI target in --ui: ${raw}" >&2
|
||||
echo "Valid values: all, none, c3, c4, raybig" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
local ordered_targets=(c3 c4 raybig)
|
||||
local target=""
|
||||
for target in "${ordered_targets[@]}"; do
|
||||
local candidate=""
|
||||
for candidate in "${normalized[@]}"; do
|
||||
if [[ "${candidate}" == "${target}" ]]; then
|
||||
UI_TARGETS+=("${target}")
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
dedupe_ui_targets() {
|
||||
local ordered_targets=(c3 c4 raybig)
|
||||
local deduped=()
|
||||
local target=""
|
||||
for target in "${ordered_targets[@]}"; do
|
||||
local candidate=""
|
||||
for candidate in "${UI_TARGETS[@]-}"; do
|
||||
if [[ "${candidate}" == "${target}" ]]; then
|
||||
deduped+=("${target}")
|
||||
break
|
||||
fi
|
||||
done
|
||||
done
|
||||
UI_TARGETS=("${deduped[@]-}")
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
trap - EXIT INT TERM
|
||||
|
||||
local pid=""
|
||||
for pid in "${UI_PIDS[@]-}"; do
|
||||
if [[ -n "${pid}" ]]; then
|
||||
kill "${pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
if [[ -n "${REPLAY_PID}" ]]; then
|
||||
kill "${REPLAY_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${NAV_PID}" ]]; then
|
||||
kill "${NAV_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
for pid in "${LIVE_PIDS[@]-}"; do
|
||||
if [[ -n "${pid}" ]]; then
|
||||
kill "${pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
for pid in "${UI_PIDS[@]-}"; do
|
||||
if [[ -n "${pid}" ]]; then
|
||||
wait "${pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
if [[ -n "${REPLAY_PID}" ]]; then
|
||||
wait "${REPLAY_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -n "${NAV_PID}" ]]; then
|
||||
wait "${NAV_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
for pid in "${LIVE_PIDS[@]-}"; do
|
||||
if [[ -n "${pid}" ]]; then
|
||||
wait "${pid}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done
|
||||
|
||||
if [[ -n "${OPENPILOT_PREFIX:-}" && "${OPENPILOT_PREFIX}" == desktop-onroad-* ]]; then
|
||||
echo "Cleaning up temporary prefix environment (${OPENPILOT_PREFIX})..."
|
||||
rm -rf "/dev/shm/msgq_${OPENPILOT_PREFIX}"
|
||||
rm -rf "/tmp/comma_download_cache${OPENPILOT_PREFIX}"
|
||||
rm -rf "${HOME}/.comma${OPENPILOT_PREFIX}"
|
||||
fi
|
||||
|
||||
exit "${exit_code}"
|
||||
}
|
||||
|
||||
append_blocked_service_names() {
|
||||
local existing="$1"
|
||||
local required="$2"
|
||||
local combined="${existing}"
|
||||
|
||||
if [[ -n "${combined}" ]]; then
|
||||
combined+=",${required}"
|
||||
else
|
||||
combined="${required}"
|
||||
fi
|
||||
|
||||
local deduped=()
|
||||
local seen=","
|
||||
local part=""
|
||||
local parts=()
|
||||
IFS=',' read -r -a parts <<< "${combined}"
|
||||
for part in "${parts[@]-}"; do
|
||||
part="${part// /}"
|
||||
if [[ -z "${part}" ]]; then
|
||||
continue
|
||||
fi
|
||||
if [[ "${seen}" != *",${part},"* ]]; then
|
||||
deduped+=("${part}")
|
||||
seen+="${part},"
|
||||
fi
|
||||
done
|
||||
|
||||
local joined=""
|
||||
for part in "${deduped[@]-}"; do
|
||||
if [[ -n "${joined}" ]]; then
|
||||
joined+=","
|
||||
fi
|
||||
joined+="${part}"
|
||||
done
|
||||
|
||||
printf '%s' "${joined}"
|
||||
}
|
||||
|
||||
ensure_nav_demo_replay_blocklist() {
|
||||
local nav_services="navInstruction,navRoute"
|
||||
local idx=0
|
||||
|
||||
for ((idx=0; idx<${#REPLAY_ARGS[@]}; idx++)); do
|
||||
case "${REPLAY_ARGS[$idx]}" in
|
||||
-b|--block)
|
||||
if (( idx + 1 >= ${#REPLAY_ARGS[@]} )); then
|
||||
echo "Missing value for ${REPLAY_ARGS[$idx]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
REPLAY_ARGS[$((idx + 1))]="$(append_blocked_service_names "${REPLAY_ARGS[$((idx + 1))]}" "${nav_services}")"
|
||||
return
|
||||
;;
|
||||
--block=*)
|
||||
REPLAY_ARGS[$idx]="--block=$(append_blocked_service_names "${REPLAY_ARGS[$idx]#*=}" "${nav_services}")"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPLAY_ARGS=(-b "${nav_services}" "${REPLAY_ARGS[@]}")
|
||||
}
|
||||
|
||||
ensure_live_controls_replay_blocklist() {
|
||||
local blocked_services="carState,carParams,carControl,controlsState,sendcan,carOutput"
|
||||
local idx=0
|
||||
|
||||
for ((idx=0; idx<${#REPLAY_ARGS[@]}; idx++)); do
|
||||
case "${REPLAY_ARGS[$idx]}" in
|
||||
-b|--block)
|
||||
if (( idx + 1 >= ${#REPLAY_ARGS[@]} )); then
|
||||
echo "Missing value for ${REPLAY_ARGS[$idx]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
REPLAY_ARGS[$((idx + 1))]="$(append_blocked_service_names "${REPLAY_ARGS[$((idx + 1))]}" "${blocked_services}")"
|
||||
return
|
||||
;;
|
||||
--block=*)
|
||||
REPLAY_ARGS[$idx]="--block=$(append_blocked_service_names "${REPLAY_ARGS[$idx]#*=}" "${blocked_services}")"
|
||||
return
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
REPLAY_ARGS=(-b "${blocked_services}" "${REPLAY_ARGS[@]}")
|
||||
}
|
||||
|
||||
prepare_env() {
|
||||
source .venv/bin/activate
|
||||
|
||||
if [[ -d /opt/homebrew/bin ]]; then
|
||||
export PATH="/opt/homebrew/bin:${PATH}"
|
||||
fi
|
||||
|
||||
export PATH="${ROOT_DIR}/.venv/bin:${PATH}"
|
||||
export PYTHONPATH="${ROOT_DIR}:${ROOT_DIR}/starpilot/third_party"
|
||||
local repo_dir=""
|
||||
for repo_dir in "${ROOT_DIR}"/*_repo; do
|
||||
[[ -d "${repo_dir}" ]] && export PYTHONPATH="${PYTHONPATH}:${repo_dir}"
|
||||
done
|
||||
[[ -d "${ROOT_DIR}/third_party/acados" ]] && export PYTHONPATH="${PYTHONPATH}:${ROOT_DIR}/third_party/acados"
|
||||
|
||||
export BASEDIR="${ROOT_DIR}"
|
||||
export NOBOARD=1
|
||||
export SIMULATION=1
|
||||
export SKIP_FW_QUERY=1
|
||||
export USE_WEBCAM=1
|
||||
export SP_C3_FAKE_WIFI=0
|
||||
export SP_C4_FAKE_WIFI=0
|
||||
export SP_RAYBIG_FAKE_WIFI=0
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=0
|
||||
export SP_ONROAD_NAV_DEMO="${NAV_DEMO}"
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]] || env_var_truthy "${ZMQ:-0}"; then
|
||||
export OPENPILOT_ZMQ_NAMESPACE="${PREFIX_ARG:-${OPENPILOT_ZMQ_NAMESPACE:-desktop-onroad-$$}}"
|
||||
unset OPENPILOT_PREFIX
|
||||
else
|
||||
export OPENPILOT_PREFIX="${PREFIX_ARG:-${OPENPILOT_PREFIX:-desktop-onroad-$$}}"
|
||||
mkdir -p "/dev/shm/msgq_${OPENPILOT_PREFIX}"
|
||||
fi
|
||||
}
|
||||
|
||||
seed_params() {
|
||||
"${ROOT_DIR}/.venv/bin/python3" - <<'PY'
|
||||
import os
|
||||
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
|
||||
params = Params()
|
||||
params.put("HasAcceptedTerms", terms_version)
|
||||
params.put("CompletedTrainingVersion", training_version)
|
||||
params.put_bool("OpenpilotEnabledToggle", True)
|
||||
params.put_bool("IsDriverViewEnabled", False)
|
||||
params.put_bool("ForceOnroad", False)
|
||||
params.put_bool("ForceOffroad", False)
|
||||
if os.getenv("SP_ONROAD_NAV_DEMO", "").lower() in {"1", "true", "yes", "on"}:
|
||||
params.put_bool("NavigationUI", True)
|
||||
PY
|
||||
}
|
||||
|
||||
seed_starpilot_theme() {
|
||||
"${ROOT_DIR}/.venv/bin/python3" - <<'PY'
|
||||
from openpilot.starpilot.common.starpilot_functions import seed_desktop_theme_assets
|
||||
|
||||
seed_desktop_theme_assets()
|
||||
PY
|
||||
}
|
||||
|
||||
build_replay() {
|
||||
local targets=(
|
||||
tools/replay/replay
|
||||
common/params_pyx.so
|
||||
common/transformations/transformations.so
|
||||
msgq_repo/msgq/ipc_pyx.so
|
||||
msgq_repo/msgq/visionipc/visionipc_pyx.so
|
||||
)
|
||||
if [[ "${LIVE_CONTROLS}" == "1" ]]; then
|
||||
targets+=(
|
||||
selfdrive/pandad/pandad_api_impl.so
|
||||
selfdrive/modeld/models/commonmodel_pyx.so
|
||||
selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
|
||||
selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
|
||||
)
|
||||
fi
|
||||
"${ROOT_DIR}/.venv/bin/scons" --cache-disable --extras -j"${jobs}" "${targets[@]}"
|
||||
}
|
||||
|
||||
prepare_c3_runtime() {
|
||||
SP_C3_COMPILE_ONLY=1 "${ROOT_DIR}/scripts/launch_ui_desktop.sh" "${jobs}"
|
||||
}
|
||||
|
||||
prepare_python_ui_runtime() {
|
||||
SP_KEEP_DESKTOP_RUNTIME_ARTIFACTS=1 SP_C4_COMPILE_ONLY=1 "${ROOT_DIR}/scripts/launch_ui_c4_desktop.sh" "${jobs}"
|
||||
}
|
||||
|
||||
launch_replay() {
|
||||
local replay_cmd=(
|
||||
"${ROOT_DIR}/tools/replay/replay"
|
||||
--headless
|
||||
--dcam
|
||||
--ecam
|
||||
)
|
||||
replay_cmd+=("${REPLAY_ARGS[@]}")
|
||||
|
||||
"${replay_cmd[@]}" &
|
||||
REPLAY_PID=$!
|
||||
|
||||
sleep 1
|
||||
if ! kill -0 "${REPLAY_PID}" >/dev/null 2>&1; then
|
||||
wait "${REPLAY_PID}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
launch_nav_demo() {
|
||||
echo "Starting fake nav demo publisher..."
|
||||
"${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/tools/replay/fake_nav_demo.py" &
|
||||
NAV_PID=$!
|
||||
|
||||
sleep 0.5
|
||||
if ! kill -0 "${NAV_PID}" >/dev/null 2>&1; then
|
||||
wait "${NAV_PID}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
launch_c3_ui() {
|
||||
local os_ext="linux"
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
os_ext="macos"
|
||||
fi
|
||||
|
||||
local host_ui="${ROOT_DIR}/selfdrive/ui/ui.${os_ext}"
|
||||
if [[ ! -x "${host_ui}" ]]; then
|
||||
echo "Missing ${host_ui}. C3 build did not produce the desktop binary." >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
"${host_ui}" &
|
||||
UI_PIDS+=("$!")
|
||||
}
|
||||
|
||||
launch_python_ui() {
|
||||
local big="$1"
|
||||
(
|
||||
export BIG="${big}"
|
||||
exec "${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/selfdrive/ui/ui.py"
|
||||
) &
|
||||
UI_PIDS+=("$!")
|
||||
}
|
||||
|
||||
launch_control_bar() {
|
||||
local watch_pids="${UI_PIDS[*]}"
|
||||
(
|
||||
export REPLAY_WATCH_PIDS="${watch_pids}"
|
||||
exec "${ROOT_DIR}/.venv/bin/python3" "${ROOT_DIR}/tools/replay/control_bar.py"
|
||||
) &
|
||||
UI_PIDS+=("$!")
|
||||
}
|
||||
|
||||
parse_args "$@"
|
||||
|
||||
if [[ -n "${LEGACY_UI_SELECTION}" ]]; then
|
||||
expand_ui_targets "${LEGACY_UI_SELECTION}"
|
||||
fi
|
||||
|
||||
if [[ "${REPLAY_ONLY}" == "1" ]]; then
|
||||
UI_TARGETS=()
|
||||
else
|
||||
dedupe_ui_targets
|
||||
fi
|
||||
|
||||
if [[ "${NAV_DEMO}" == "1" ]]; then
|
||||
ensure_nav_demo_replay_blocklist
|
||||
fi
|
||||
|
||||
if [[ "${LIVE_CONTROLS}" == "1" ]]; then
|
||||
ensure_live_controls_replay_blocklist
|
||||
fi
|
||||
|
||||
if [[ ${#REPLAY_ARGS[@]} -eq 0 ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${REPLAY_ONLY}" != "1" && ${#UI_TARGETS[@]} -eq 0 ]]; then
|
||||
echo "Select at least one UI with --c3, --c4, --raybig, or use --replay-only." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
prepare_env
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
echo "Using OPENPILOT_PREFIX=${OPENPILOT_PREFIX:-<default>}"
|
||||
if [[ -n "${OPENPILOT_ZMQ_NAMESPACE:-}" ]]; then
|
||||
echo "Using OPENPILOT_ZMQ_NAMESPACE=${OPENPILOT_ZMQ_NAMESPACE}"
|
||||
fi
|
||||
echo "Preparing replay and desktop UI runtime..."
|
||||
|
||||
build_replay
|
||||
|
||||
case " ${UI_TARGETS[*]-} " in
|
||||
*" c3 "*)
|
||||
prepare_c3_runtime
|
||||
;;
|
||||
esac
|
||||
|
||||
case " ${UI_TARGETS[*]-} " in
|
||||
*" c4 "*|*" raybig "*)
|
||||
prepare_python_ui_runtime
|
||||
;;
|
||||
esac
|
||||
|
||||
seed_params
|
||||
seed_starpilot_theme
|
||||
|
||||
echo "Starting replay: ${REPLAY_ARGS[*]}"
|
||||
launch_replay
|
||||
|
||||
if [[ "${NAV_DEMO}" == "1" ]]; then
|
||||
launch_nav_demo
|
||||
fi
|
||||
|
||||
if [[ "${LIVE_CONTROLS}" == "1" ]]; then
|
||||
echo "Launching live card and controlsd..."
|
||||
"${ROOT_DIR}/.venv/bin/python3" -m selfdrive.car.card &
|
||||
LIVE_PIDS+=("$!")
|
||||
"${ROOT_DIR}/.venv/bin/python3" -m selfdrive.controls.controlsd &
|
||||
LIVE_PIDS+=("$!")
|
||||
fi
|
||||
|
||||
if [[ ${#UI_TARGETS[@]} -eq 0 ]]; then
|
||||
echo "Replay is running without UI windows. Press Ctrl-C to stop."
|
||||
wait "${REPLAY_PID}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Launching UIs: ${UI_TARGETS[*]}"
|
||||
|
||||
local_target=""
|
||||
has_raylib=0
|
||||
for local_target in "${UI_TARGETS[@]}"; do
|
||||
case "${local_target}" in
|
||||
c3)
|
||||
launch_c3_ui
|
||||
;;
|
||||
c4)
|
||||
launch_python_ui 0
|
||||
has_raylib=1
|
||||
;;
|
||||
raybig)
|
||||
launch_python_ui 1
|
||||
has_raylib=1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ "${has_raylib}" == "1" ]]; then
|
||||
echo "Launching standalone replay controls bar..."
|
||||
launch_control_bar
|
||||
fi
|
||||
|
||||
wait "${UI_PIDS[@]}"
|
||||
Executable
+344
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if [[ ! -f .venv/bin/activate ]]; then
|
||||
echo "Missing .venv. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
default_jobs() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 8
|
||||
fi
|
||||
}
|
||||
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
if [[ -d /opt/homebrew/bin ]]; then
|
||||
export PATH="/opt/homebrew/bin:${PATH}"
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
# Ensure desktop host extension builds stay on Apple toolchain.
|
||||
export CC="/usr/bin/clang"
|
||||
export CXX="/usr/bin/clang++"
|
||||
fi
|
||||
unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPPFLAGS CFLAGS CXXFLAGS LDFLAGS
|
||||
|
||||
PY_BIN="${ROOT_DIR}/.venv/bin/python3"
|
||||
if [[ ! -x "${PY_BIN}" ]]; then
|
||||
echo "Missing ${PY_BIN}. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
export PATH="${ROOT_DIR}/.venv/bin:${PATH}"
|
||||
|
||||
export PYTHONPATH="${ROOT_DIR}:${ROOT_DIR}/starpilot/third_party"
|
||||
for d in "${ROOT_DIR}"/*_repo; do [[ -d "$d" ]] && export PYTHONPATH="${PYTHONPATH}:$d"; done
|
||||
[[ -d "${ROOT_DIR}/third_party/acados" ]] && export PYTHONPATH="${PYTHONPATH}:${ROOT_DIR}/third_party/acados"
|
||||
export OPENPILOT_ZMQ_NAMESPACE="${OPENPILOT_ZMQ_NAMESPACE:-desktop-c4-$$}"
|
||||
export BIG=0
|
||||
export NOBOARD=1
|
||||
export SIMULATION=1
|
||||
export SKIP_FW_QUERY=1
|
||||
export USE_WEBCAM=1
|
||||
|
||||
backup_dir="$(mktemp -d /tmp/starpilot_c4_ui_backup.XXXXXX)"
|
||||
backup_manifest="${backup_dir}/.artifact_manifest"
|
||||
PRE_TRACKED_DIRTY="$(mktemp /tmp/starpilot_c4_pretracked.XXXXXX)"
|
||||
POST_TRACKED_DIRTY="$(mktemp /tmp/starpilot_c4_posttracked.XXXXXX)"
|
||||
FAKE_WIFI_PID=""
|
||||
|
||||
runtime_artifacts=(
|
||||
"common/params_pyx.so"
|
||||
"msgq/ipc_pyx.so"
|
||||
"msgq/visionipc/visionipc_pyx.so"
|
||||
"common/transformations/transformations.so"
|
||||
"selfdrive/pandad/pandad_api_impl.so"
|
||||
"selfdrive/pandad/pandad_api_impl.o"
|
||||
"selfdrive/pandad/can_list_to_can_capnp.o"
|
||||
"selfdrive/pandad/libcan_list_to_can_capnp.a"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.dylib"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.dylib"
|
||||
)
|
||||
|
||||
collect_tracked_dirty() {
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
if ! git -C "${ROOT_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
git -C "${ROOT_DIR}" status --porcelain --untracked-files=no | sed -E 's/^.. //'
|
||||
}
|
||||
|
||||
restore_runtime_artifacts() {
|
||||
if [[ -d "${backup_dir}" ]]; then
|
||||
if [[ -f "${backup_manifest}" ]]; then
|
||||
while IFS= read -r rel; do
|
||||
[[ -z "${rel}" ]] && continue
|
||||
if [[ -f "${backup_dir}/${rel}" ]]; then
|
||||
cp -f "${backup_dir}/${rel}" "${ROOT_DIR}/${rel}"
|
||||
else
|
||||
rm -f "${ROOT_DIR}/${rel}"
|
||||
fi
|
||||
done < "${backup_manifest}"
|
||||
fi
|
||||
rm -rf "${backup_dir}"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_new_tracked_changes() {
|
||||
if ! collect_tracked_dirty > "${POST_TRACKED_DIRTY}"; then
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "${path}" ]] && continue
|
||||
if ! grep -Fxq "${path}" "${PRE_TRACKED_DIRTY}"; then
|
||||
git -C "${ROOT_DIR}" checkout -- "${path}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done < "${POST_TRACKED_DIRTY}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${FAKE_WIFI_PID}" ]]; then
|
||||
kill "${FAKE_WIFI_PID}" >/dev/null 2>&1 || true
|
||||
wait "${FAKE_WIFI_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ ! "${SP_KEEP_DESKTOP_RUNTIME_ARTIFACTS:-0}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
restore_runtime_artifacts
|
||||
restore_new_tracked_changes
|
||||
fi
|
||||
rm -f "${PRE_TRACKED_DIRTY}" "${POST_TRACKED_DIRTY}"
|
||||
}
|
||||
|
||||
collect_tracked_dirty > "${PRE_TRACKED_DIRTY}" || true
|
||||
trap cleanup EXIT
|
||||
|
||||
backup_if_elf() {
|
||||
local rel="$1"
|
||||
local src="${ROOT_DIR}/${rel}"
|
||||
echo "${rel}" >> "${backup_manifest}"
|
||||
if [[ -f "${src}" ]]; then
|
||||
mkdir -p "${backup_dir}/$(dirname "${rel}")"
|
||||
cp -f "${src}" "${backup_dir}/${rel}"
|
||||
fi
|
||||
}
|
||||
|
||||
for rel in "${runtime_artifacts[@]}"; do
|
||||
backup_if_elf "${rel}"
|
||||
done
|
||||
|
||||
archive_is_aarch64_elf() {
|
||||
local path="$1"
|
||||
[[ -f "${path}" ]] || return 1
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
readelf -h "${path}" 2>/dev/null | grep -iq "aarch64"
|
||||
else
|
||||
objdump -a "${path}" 2>/dev/null | head -n 12 | grep -iqE "elf64-littleaarch64|aarch64"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_if_elf() {
|
||||
local rel="$1"
|
||||
local path="${ROOT_DIR}/${rel}"
|
||||
if [[ -f "${path}" ]] && file "${path}" | grep -q "ELF"; then
|
||||
rm -f "${path}"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_common_host_artifacts() {
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/common/libcommon.a"; then
|
||||
rm -f \
|
||||
"${ROOT_DIR}/common/libcommon.a" \
|
||||
"${ROOT_DIR}/common/"*.o \
|
||||
"${ROOT_DIR}/common/params_pyx.o" \
|
||||
"${ROOT_DIR}/common/transformations/transformations.o"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_msgq_host_artifacts() {
|
||||
# Clear mixed-arch Python extension objects from both msgq trees. These
|
||||
# commonly conflict after switching between ./build (larch64) and ./c4 (macOS).
|
||||
remove_if_elf "msgq/ipc_pyx.o"
|
||||
remove_if_elf "msgq/visionipc/visionipc_pyx.o"
|
||||
remove_if_elf "msgq_repo/msgq/ipc_pyx.o"
|
||||
remove_if_elf "msgq_repo/msgq/visionipc/visionipc_pyx.o"
|
||||
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/msgq_repo/libmsgq.a" || archive_is_aarch64_elf "${ROOT_DIR}/msgq_repo/libvisionipc.a"; then
|
||||
rm -f \
|
||||
"${ROOT_DIR}/msgq_repo/libmsgq.a" \
|
||||
"${ROOT_DIR}/msgq_repo/libvisionipc.a" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/"*.os \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/"*.os \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/ipc_pyx.o" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.o"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_pandad_host_artifacts() {
|
||||
remove_if_elf "selfdrive/pandad/pandad_api_impl.so"
|
||||
remove_if_elf "selfdrive/pandad/pandad_api_impl.o"
|
||||
remove_if_elf "selfdrive/pandad/can_list_to_can_capnp.o"
|
||||
|
||||
# Host builds always regenerate this small archive locally. Device-built
|
||||
# variants are not reusable across platforms and can poison the link step.
|
||||
rm -f "${ROOT_DIR}/selfdrive/pandad/libcan_list_to_can_capnp.a"
|
||||
}
|
||||
|
||||
python_ui_runtime_ok() {
|
||||
"${PY_BIN}" - <<'PY'
|
||||
import pyray # noqa: F401
|
||||
import openpilot.common.params_pyx # noqa: F401
|
||||
import openpilot.common.transformations.transformations # noqa: F401
|
||||
import openpilot.selfdrive.pandad.pandad_api_impl # noqa: F401
|
||||
import msgq.ipc_pyx # noqa: F401
|
||||
import msgq.visionipc.visionipc_pyx # noqa: F401
|
||||
import openpilot.selfdrive.controls.lib.lateral_mpc_lib.c_generated_code.acados_ocp_solver_pyx # noqa: F401
|
||||
import openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.c_generated_code.acados_ocp_solver_pyx # noqa: F401
|
||||
PY
|
||||
}
|
||||
|
||||
sync_deps() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
UV_PROJECT_ENVIRONMENT=.venv uv sync --frozen --all-extras
|
||||
else
|
||||
echo "Missing uv. Install dependencies with tools/install_python_dependencies.sh."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
sync_raylib() {
|
||||
if "${PY_BIN}" - <<'PY' >/dev/null 2>&1
|
||||
import pyray # noqa: F401
|
||||
PY
|
||||
then
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
UV_PROJECT_ENVIRONMENT=.venv uv pip install "raylib<5.5.0.3"
|
||||
else
|
||||
echo "Missing uv. Install dependencies with tools/install_python_dependencies.sh."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_scons() {
|
||||
local jobs_arg="$1"
|
||||
shift || true
|
||||
local scons_bin="${ROOT_DIR}/.venv/bin/scons"
|
||||
if [[ -x "${scons_bin}" ]]; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${scons_bin}" -j"${jobs_arg}" "$@"
|
||||
elif "${PY_BIN}" -m SCons --version >/dev/null 2>&1; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${PY_BIN}" -m SCons -j"${jobs_arg}" "$@"
|
||||
else
|
||||
echo "SCons not found in .venv after sync."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
kill_stale_c4_ui() {
|
||||
pkill -f "selfdrive/ui/ui.py" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
start_fake_wifi() {
|
||||
if [[ ! "${SP_C4_FAKE_WIFI:-1}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=0
|
||||
return
|
||||
fi
|
||||
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=1
|
||||
"${PY_BIN}" selfdrive/debug/fake_wifi.py --network wifi --strength great --interval 0.2 &
|
||||
FAKE_WIFI_PID=$!
|
||||
}
|
||||
|
||||
seed_starpilot_theme() {
|
||||
"${PY_BIN}" - <<'PY'
|
||||
from openpilot.starpilot.common.starpilot_functions import seed_desktop_theme_assets
|
||||
|
||||
seed_desktop_theme_assets()
|
||||
PY
|
||||
}
|
||||
|
||||
if ! python_ui_runtime_ok >/dev/null 2>&1; then
|
||||
echo "Preparing host Python UI runtime extensions..."
|
||||
sync_deps
|
||||
sync_raylib
|
||||
prepare_common_host_artifacts
|
||||
prepare_msgq_host_artifacts
|
||||
prepare_pandad_host_artifacts
|
||||
remove_if_elf "common/params_pyx.so"
|
||||
remove_if_elf "common/transformations/transformations.so"
|
||||
remove_if_elf "msgq/ipc_pyx.so"
|
||||
remove_if_elf "msgq/visionipc/visionipc_pyx.so"
|
||||
remove_if_elf "selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
remove_if_elf "selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
|
||||
run_scons "${jobs}" common/params_pyx.so common/transformations/transformations.so
|
||||
# Building the Cython module pulls in the platform-specific acados solver
|
||||
# library via SCons dependencies, so the host path does not need to name the
|
||||
# solver shared library extension explicitly.
|
||||
run_scons "${jobs}" \
|
||||
selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so \
|
||||
selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
|
||||
(
|
||||
cd "${ROOT_DIR}/msgq_repo"
|
||||
local_scons_bin="${ROOT_DIR}/.venv/bin/scons"
|
||||
if [[ -x "${local_scons_bin}" ]]; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${local_scons_bin}" -j"${jobs}" msgq/ipc_pyx.so msgq/visionipc/visionipc_pyx.so
|
||||
elif "${PY_BIN}" -m SCons --version >/dev/null 2>&1; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${PY_BIN}" -m SCons -j"${jobs}" msgq/ipc_pyx.so msgq/visionipc/visionipc_pyx.so
|
||||
else
|
||||
echo "SCons not found in .venv after sync."
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
fi
|
||||
|
||||
if ! python_ui_runtime_ok >/dev/null 2>&1; then
|
||||
"${PY_BIN}" - <<'PY'
|
||||
import site
|
||||
import sys
|
||||
print("python:", sys.executable)
|
||||
print("version:", sys.version)
|
||||
print("site-packages:", site.getsitepackages())
|
||||
PY
|
||||
echo "Unable to load Python UI runtime extensions after rebuild."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${SP_C4_COMPILE_ONLY:-0}" == "1" ]]; then
|
||||
echo "C4 runtime artifacts prepared."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"${PY_BIN}" - <<'PY'
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
|
||||
params = Params()
|
||||
params.put("HasAcceptedTerms", terms_version)
|
||||
params.put("CompletedTrainingVersion", training_version)
|
||||
params.put_bool("OpenpilotEnabledToggle", True)
|
||||
params.put_bool("IsDriverViewEnabled", False)
|
||||
PY
|
||||
|
||||
seed_starpilot_theme
|
||||
kill_stale_c4_ui
|
||||
start_fake_wifi
|
||||
"${PY_BIN}" selfdrive/ui/ui.py "$@"
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if [[ ! -f .venv/bin/activate ]]; then
|
||||
echo "Missing .venv. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
default_jobs() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 8
|
||||
fi
|
||||
}
|
||||
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
OS_EXT="linux"
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
OS_EXT="macos"
|
||||
fi
|
||||
HOST_UI="${ROOT_DIR}/selfdrive/ui/ui.${OS_EXT}"
|
||||
PRE_TRACKED_DIRTY="$(mktemp /tmp/starpilot_c3_pretracked.XXXXXX)"
|
||||
POST_TRACKED_DIRTY="$(mktemp /tmp/starpilot_c3_posttracked.XXXXXX)"
|
||||
BACKUP_DIR="$(mktemp -d /tmp/starpilot_c3_backup.XXXXXX)"
|
||||
BACKUP_MANIFEST="${BACKUP_DIR}/.artifact_manifest"
|
||||
FAKE_WIFI_PID=""
|
||||
|
||||
runtime_artifacts=(
|
||||
"selfdrive/ui/ui"
|
||||
"common/libcommon.a"
|
||||
"common/transformations/libtransformations.a"
|
||||
"cereal/libsocketmaster.a"
|
||||
"msgq_repo/libmsgq.a"
|
||||
"msgq_repo/libvisionipc.a"
|
||||
"common/params_pyx.so"
|
||||
"common/transformations/transformations.so"
|
||||
"msgq_repo/msgq/ipc_pyx.so"
|
||||
"msgq_repo/msgq/visionipc/visionipc_pyx.so"
|
||||
)
|
||||
|
||||
collect_tracked_dirty() {
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
if ! git -C "${ROOT_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
git -C "${ROOT_DIR}" status --porcelain --untracked-files=no | sed -E 's/^.. //'
|
||||
}
|
||||
|
||||
backup_artifact() {
|
||||
local rel="$1"
|
||||
local src="${ROOT_DIR}/${rel}"
|
||||
echo "${rel}" >> "${BACKUP_MANIFEST}"
|
||||
if [[ -f "${src}" ]]; then
|
||||
mkdir -p "${BACKUP_DIR}/$(dirname "${rel}")"
|
||||
cp -f "${src}" "${BACKUP_DIR}/${rel}"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_runtime_artifacts() {
|
||||
if [[ ! -d "${BACKUP_DIR}" ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -f "${BACKUP_MANIFEST}" ]]; then
|
||||
while IFS= read -r rel; do
|
||||
[[ -z "${rel}" ]] && continue
|
||||
if [[ -f "${BACKUP_DIR}/${rel}" ]]; then
|
||||
cp -f "${BACKUP_DIR}/${rel}" "${ROOT_DIR}/${rel}"
|
||||
else
|
||||
rm -f "${ROOT_DIR}/${rel}"
|
||||
fi
|
||||
done < "${BACKUP_MANIFEST}"
|
||||
fi
|
||||
|
||||
rm -rf "${BACKUP_DIR}"
|
||||
}
|
||||
|
||||
restore_new_tracked_changes() {
|
||||
if ! collect_tracked_dirty > "${POST_TRACKED_DIRTY}"; then
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "${path}" ]] && continue
|
||||
if ! grep -Fxq "${path}" "${PRE_TRACKED_DIRTY}"; then
|
||||
git -C "${ROOT_DIR}" checkout -- "${path}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done < "${POST_TRACKED_DIRTY}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
restore_runtime_artifacts
|
||||
restore_new_tracked_changes
|
||||
stop_fake_wifi
|
||||
rm -f "${PRE_TRACKED_DIRTY}" "${POST_TRACKED_DIRTY}"
|
||||
}
|
||||
|
||||
start_fake_wifi() {
|
||||
if [[ ! "${SP_C3_FAKE_WIFI:-1}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=0
|
||||
return
|
||||
fi
|
||||
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=1
|
||||
"${ROOT_DIR}/.venv/bin/python3" selfdrive/debug/fake_wifi.py --network wifi --strength great --interval 0.2 &
|
||||
FAKE_WIFI_PID=$!
|
||||
}
|
||||
|
||||
seed_starpilot_theme() {
|
||||
"${ROOT_DIR}/.venv/bin/python3" - <<'PY'
|
||||
from openpilot.starpilot.common.starpilot_functions import seed_desktop_theme_assets
|
||||
|
||||
seed_desktop_theme_assets()
|
||||
PY
|
||||
}
|
||||
|
||||
stop_fake_wifi() {
|
||||
if [[ -n "${FAKE_WIFI_PID}" ]]; then
|
||||
kill "${FAKE_WIFI_PID}" >/dev/null 2>&1 || true
|
||||
FAKE_WIFI_PID=""
|
||||
fi
|
||||
}
|
||||
|
||||
archive_is_aarch64_elf() {
|
||||
local path="$1"
|
||||
[[ -f "${path}" ]] || return 1
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
readelf -h "${path}" 2>/dev/null | grep -iq "aarch64"
|
||||
else
|
||||
objdump -a "${path}" 2>/dev/null | head -n 12 | grep -iqE "elf64-littleaarch64|aarch64"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_if_elf() {
|
||||
local rel="$1"
|
||||
local path="${ROOT_DIR}/${rel}"
|
||||
if [[ -f "${path}" ]] && file "${path}" | grep -q "ELF"; then
|
||||
rm -f "${path}"
|
||||
fi
|
||||
}
|
||||
|
||||
purge_objects() {
|
||||
local root="$1"
|
||||
if [[ -d "${root}" ]]; then
|
||||
find "${root}" -type f \( -name '*.o' -o -name '*.os' \) -delete
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_host_artifacts() {
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/common/libcommon.a"; then
|
||||
rm -f "${ROOT_DIR}/common/libcommon.a" "${ROOT_DIR}/common/params_pyx.o"
|
||||
purge_objects "${ROOT_DIR}/common"
|
||||
fi
|
||||
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/cereal/libsocketmaster.a"; then
|
||||
rm -f "${ROOT_DIR}/cereal/libsocketmaster.a"
|
||||
purge_objects "${ROOT_DIR}/cereal/messaging"
|
||||
fi
|
||||
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/common/transformations/libtransformations.a"; then
|
||||
rm -f "${ROOT_DIR}/common/transformations/libtransformations.a" \
|
||||
"${ROOT_DIR}/common/transformations/transformations.o"
|
||||
purge_objects "${ROOT_DIR}/common/transformations"
|
||||
fi
|
||||
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/msgq_repo/libmsgq.a" || archive_is_aarch64_elf "${ROOT_DIR}/msgq_repo/libvisionipc.a"; then
|
||||
rm -f "${ROOT_DIR}/msgq_repo/libmsgq.a" "${ROOT_DIR}/msgq_repo/libvisionipc.a"
|
||||
purge_objects "${ROOT_DIR}/msgq_repo/msgq"
|
||||
purge_objects "${ROOT_DIR}/msgq_repo/msgq/visionipc"
|
||||
fi
|
||||
|
||||
remove_if_elf "common/params_pyx.so"
|
||||
remove_if_elf "common/transformations/transformations.so"
|
||||
remove_if_elf "msgq_repo/msgq/ipc_pyx.so"
|
||||
remove_if_elf "msgq_repo/msgq/visionipc/visionipc_pyx.so"
|
||||
|
||||
purge_objects "${ROOT_DIR}/selfdrive/ui"
|
||||
purge_objects "${ROOT_DIR}/starpilot/ui"
|
||||
rm -f "${ROOT_DIR}/selfdrive/ui/libqt_widgets.a" "${ROOT_DIR}/selfdrive/ui/libqt_util.a"
|
||||
rm -f "${ROOT_DIR}/selfdrive/ui/ui"
|
||||
}
|
||||
|
||||
collect_tracked_dirty > "${PRE_TRACKED_DIRTY}" || true
|
||||
trap cleanup EXIT
|
||||
|
||||
for rel in "${runtime_artifacts[@]}"; do
|
||||
backup_artifact "${rel}"
|
||||
done
|
||||
|
||||
source .venv/bin/activate
|
||||
if [[ -d /opt/homebrew/bin ]]; then
|
||||
export PATH="/opt/homebrew/bin:${PATH}"
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
# Keep desktop host builds on Apple toolchain even if shell exports Homebrew llvm.
|
||||
export CC="/usr/bin/clang"
|
||||
export CXX="/usr/bin/clang++"
|
||||
export AR="/usr/bin/ar"
|
||||
export RANLIB="/usr/bin/ranlib"
|
||||
fi
|
||||
unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPPFLAGS CFLAGS CXXFLAGS LDFLAGS
|
||||
|
||||
prepare_host_artifacts
|
||||
|
||||
export PYTHONPATH="${ROOT_DIR}:${ROOT_DIR}/starpilot/third_party"
|
||||
for d in "${ROOT_DIR}"/*_repo; do [[ -d "$d" ]] && export PYTHONPATH="${PYTHONPATH}:$d"; done
|
||||
[[ -d "${ROOT_DIR}/third_party/acados" ]] && export PYTHONPATH="${PYTHONPATH}:${ROOT_DIR}/third_party/acados"
|
||||
export OPENPILOT_ZMQ_NAMESPACE="${OPENPILOT_ZMQ_NAMESPACE:-desktop-c3-$$}"
|
||||
|
||||
scons --cache-disable -j"${jobs}" selfdrive/ui/ui common/params_pyx.so common/transformations/transformations.so msgq_repo/msgq/ipc_pyx.so msgq_repo/msgq/visionipc/visionipc_pyx.so
|
||||
|
||||
cp -f "${ROOT_DIR}/selfdrive/ui/ui" "${HOST_UI}"
|
||||
cleanup
|
||||
trap - EXIT
|
||||
|
||||
if [[ "${SP_C3_COMPILE_ONLY:-0}" == "1" ]]; then
|
||||
echo "C3 UI binary prepared."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
seed_starpilot_theme
|
||||
start_fake_wifi
|
||||
trap stop_fake_wifi EXIT
|
||||
"${HOST_UI}" "$@"
|
||||
rc=$?
|
||||
stop_fake_wifi
|
||||
trap - EXIT
|
||||
exit "${rc}"
|
||||
Executable
+344
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
if [[ ! -f .venv/bin/activate ]]; then
|
||||
echo "Missing .venv. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
default_jobs() {
|
||||
if command -v nproc >/dev/null 2>&1; then
|
||||
nproc
|
||||
elif command -v sysctl >/dev/null 2>&1; then
|
||||
sysctl -n hw.ncpu
|
||||
else
|
||||
echo 8
|
||||
fi
|
||||
}
|
||||
|
||||
jobs="$(default_jobs)"
|
||||
if [[ "${1:-}" =~ ^[0-9]+$ ]]; then
|
||||
jobs="$1"
|
||||
shift || true
|
||||
fi
|
||||
|
||||
if [[ -d /opt/homebrew/bin ]]; then
|
||||
export PATH="/opt/homebrew/bin:${PATH}"
|
||||
fi
|
||||
|
||||
if [[ "$(uname -s)" == "Darwin" ]]; then
|
||||
# Ensure desktop host extension builds stay on Apple toolchain.
|
||||
export CC="/usr/bin/clang"
|
||||
export CXX="/usr/bin/clang++"
|
||||
fi
|
||||
unset CPATH C_INCLUDE_PATH CPLUS_INCLUDE_PATH CPPFLAGS CFLAGS CXXFLAGS LDFLAGS
|
||||
|
||||
PY_BIN="${ROOT_DIR}/.venv/bin/python3"
|
||||
if [[ ! -x "${PY_BIN}" ]]; then
|
||||
echo "Missing ${PY_BIN}. Run tools/install_python_dependencies.sh first."
|
||||
exit 1
|
||||
fi
|
||||
export PATH="${ROOT_DIR}/.venv/bin:${PATH}"
|
||||
|
||||
export PYTHONPATH="${ROOT_DIR}:${ROOT_DIR}/starpilot/third_party"
|
||||
for d in "${ROOT_DIR}"/*_repo; do [[ -d "$d" ]] && export PYTHONPATH="${PYTHONPATH}:$d"; done
|
||||
[[ -d "${ROOT_DIR}/third_party/acados" ]] && export PYTHONPATH="${PYTHONPATH}:${ROOT_DIR}/third_party/acados"
|
||||
export OPENPILOT_ZMQ_NAMESPACE="${OPENPILOT_ZMQ_NAMESPACE:-desktop-raybig-$$}"
|
||||
export BIG=1
|
||||
export NOBOARD=1
|
||||
export SIMULATION=1
|
||||
export SKIP_FW_QUERY=1
|
||||
export USE_WEBCAM=1
|
||||
|
||||
backup_dir="$(mktemp -d /tmp/starpilot_raybig_ui_backup.XXXXXX)"
|
||||
backup_manifest="${backup_dir}/.artifact_manifest"
|
||||
PRE_TRACKED_DIRTY="$(mktemp /tmp/starpilot_raybig_pretracked.XXXXXX)"
|
||||
POST_TRACKED_DIRTY="$(mktemp /tmp/starpilot_raybig_posttracked.XXXXXX)"
|
||||
FAKE_WIFI_PID=""
|
||||
|
||||
runtime_artifacts=(
|
||||
"common/params_pyx.so"
|
||||
"msgq/ipc_pyx.so"
|
||||
"msgq/visionipc/visionipc_pyx.so"
|
||||
"common/transformations/transformations.so"
|
||||
"selfdrive/pandad/pandad_api_impl.so"
|
||||
"selfdrive/pandad/pandad_api_impl.o"
|
||||
"selfdrive/pandad/can_list_to_can_capnp.o"
|
||||
"selfdrive/pandad/libcan_list_to_can_capnp.a"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
"selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/libacados_ocp_solver_lat.dylib"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
"selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/libacados_ocp_solver_long.dylib"
|
||||
)
|
||||
|
||||
collect_tracked_dirty() {
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
if ! git -C "${ROOT_DIR}" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
git -C "${ROOT_DIR}" status --porcelain --untracked-files=no | sed -E 's/^.. //'
|
||||
}
|
||||
|
||||
restore_runtime_artifacts() {
|
||||
if [[ -d "${backup_dir}" ]]; then
|
||||
if [[ -f "${backup_manifest}" ]]; then
|
||||
while IFS= read -r rel; do
|
||||
[[ -z "${rel}" ]] && continue
|
||||
if [[ -f "${backup_dir}/${rel}" ]]; then
|
||||
cp -f "${backup_dir}/${rel}" "${ROOT_DIR}/${rel}"
|
||||
else
|
||||
rm -f "${ROOT_DIR}/${rel}"
|
||||
fi
|
||||
done < "${backup_manifest}"
|
||||
fi
|
||||
rm -rf "${backup_dir}"
|
||||
fi
|
||||
}
|
||||
|
||||
restore_new_tracked_changes() {
|
||||
if ! collect_tracked_dirty > "${POST_TRACKED_DIRTY}"; then
|
||||
return
|
||||
fi
|
||||
|
||||
while IFS= read -r path; do
|
||||
[[ -z "${path}" ]] && continue
|
||||
if ! grep -Fxq "${path}" "${PRE_TRACKED_DIRTY}"; then
|
||||
git -C "${ROOT_DIR}" checkout -- "${path}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
done < "${POST_TRACKED_DIRTY}"
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
if [[ -n "${FAKE_WIFI_PID}" ]]; then
|
||||
kill "${FAKE_WIFI_PID}" >/dev/null 2>&1 || true
|
||||
wait "${FAKE_WIFI_PID}" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ ! "${SP_KEEP_DESKTOP_RUNTIME_ARTIFACTS:-0}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
restore_runtime_artifacts
|
||||
restore_new_tracked_changes
|
||||
fi
|
||||
rm -f "${PRE_TRACKED_DIRTY}" "${POST_TRACKED_DIRTY}"
|
||||
}
|
||||
|
||||
collect_tracked_dirty > "${PRE_TRACKED_DIRTY}" || true
|
||||
trap cleanup EXIT
|
||||
|
||||
backup_if_elf() {
|
||||
local rel="$1"
|
||||
local src="${ROOT_DIR}/${rel}"
|
||||
echo "${rel}" >> "${backup_manifest}"
|
||||
if [[ -f "${src}" ]]; then
|
||||
mkdir -p "${backup_dir}/$(dirname "${rel}")"
|
||||
cp -f "${src}" "${backup_dir}/${rel}"
|
||||
fi
|
||||
}
|
||||
|
||||
for rel in "${runtime_artifacts[@]}"; do
|
||||
backup_if_elf "${rel}"
|
||||
done
|
||||
|
||||
archive_is_aarch64_elf() {
|
||||
local path="$1"
|
||||
[[ -f "${path}" ]] || return 1
|
||||
if command -v readelf >/dev/null 2>&1; then
|
||||
readelf -h "${path}" 2>/dev/null | grep -iq "aarch64"
|
||||
else
|
||||
objdump -a "${path}" 2>/dev/null | head -n 12 | grep -iqE "elf64-littleaarch64|aarch64"
|
||||
fi
|
||||
}
|
||||
|
||||
remove_if_elf() {
|
||||
local rel="$1"
|
||||
local path="${ROOT_DIR}/${rel}"
|
||||
if [[ -f "${path}" ]] && file "${path}" | grep -q "ELF"; then
|
||||
rm -f "${path}"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_common_host_artifacts() {
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/common/libcommon.a"; then
|
||||
rm -f \
|
||||
"${ROOT_DIR}/common/libcommon.a" \
|
||||
"${ROOT_DIR}/common/"*.o \
|
||||
"${ROOT_DIR}/common/params_pyx.o" \
|
||||
"${ROOT_DIR}/common/transformations/transformations.o"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_msgq_host_artifacts() {
|
||||
# Clear mixed-arch Python extension objects from both msgq trees. These
|
||||
# commonly conflict after switching between ./build (larch64) and ./raybig (macOS).
|
||||
remove_if_elf "msgq/ipc_pyx.o"
|
||||
remove_if_elf "msgq/visionipc/visionipc_pyx.o"
|
||||
remove_if_elf "msgq_repo/msgq/ipc_pyx.o"
|
||||
remove_if_elf "msgq_repo/msgq/visionipc/visionipc_pyx.o"
|
||||
|
||||
if archive_is_aarch64_elf "${ROOT_DIR}/msgq_repo/libmsgq.a" || archive_is_aarch64_elf "${ROOT_DIR}/msgq_repo/libvisionipc.a"; then
|
||||
rm -f \
|
||||
"${ROOT_DIR}/msgq_repo/libmsgq.a" \
|
||||
"${ROOT_DIR}/msgq_repo/libvisionipc.a" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/"*.os \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/"*.os \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/ipc_pyx.o" \
|
||||
"${ROOT_DIR}/msgq_repo/msgq/visionipc/visionipc_pyx.o"
|
||||
fi
|
||||
}
|
||||
|
||||
prepare_pandad_host_artifacts() {
|
||||
remove_if_elf "selfdrive/pandad/pandad_api_impl.so"
|
||||
remove_if_elf "selfdrive/pandad/pandad_api_impl.o"
|
||||
remove_if_elf "selfdrive/pandad/can_list_to_can_capnp.o"
|
||||
|
||||
# Host builds always regenerate this small archive locally. Device-built
|
||||
# variants are not reusable across platforms and can poison the link step.
|
||||
rm -f "${ROOT_DIR}/selfdrive/pandad/libcan_list_to_can_capnp.a"
|
||||
}
|
||||
|
||||
python_ui_runtime_ok() {
|
||||
"${PY_BIN}" - <<'PY'
|
||||
import pyray # noqa: F401
|
||||
import openpilot.common.params_pyx # noqa: F401
|
||||
import openpilot.common.transformations.transformations # noqa: F401
|
||||
import openpilot.selfdrive.pandad.pandad_api_impl # noqa: F401
|
||||
import msgq.ipc_pyx # noqa: F401
|
||||
import msgq.visionipc.visionipc_pyx # noqa: F401
|
||||
import openpilot.selfdrive.controls.lib.lateral_mpc_lib.c_generated_code.acados_ocp_solver_pyx # noqa: F401
|
||||
import openpilot.selfdrive.controls.lib.longitudinal_mpc_lib.c_generated_code.acados_ocp_solver_pyx # noqa: F401
|
||||
PY
|
||||
}
|
||||
|
||||
sync_deps() {
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
UV_PROJECT_ENVIRONMENT=.venv uv sync --frozen --all-extras
|
||||
else
|
||||
echo "Missing uv. Install dependencies with tools/install_python_dependencies.sh."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
sync_raylib() {
|
||||
if "${PY_BIN}" - <<'PY' >/dev/null 2>&1
|
||||
import pyray # noqa: F401
|
||||
PY
|
||||
then
|
||||
return
|
||||
fi
|
||||
|
||||
if command -v uv >/dev/null 2>&1; then
|
||||
UV_PROJECT_ENVIRONMENT=.venv uv pip install "raylib<5.5.0.3"
|
||||
else
|
||||
echo "Missing uv. Install dependencies with tools/install_python_dependencies.sh."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
run_scons() {
|
||||
local jobs_arg="$1"
|
||||
shift || true
|
||||
local scons_bin="${ROOT_DIR}/.venv/bin/scons"
|
||||
if [[ -x "${scons_bin}" ]]; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${scons_bin}" -j"${jobs_arg}" "$@"
|
||||
elif "${PY_BIN}" -m SCons --version >/dev/null 2>&1; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${PY_BIN}" -m SCons -j"${jobs_arg}" "$@"
|
||||
else
|
||||
echo "SCons not found in .venv after sync."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
kill_stale_raybig_ui() {
|
||||
pkill -f "selfdrive/ui/ui.py" >/dev/null 2>&1 || true
|
||||
}
|
||||
|
||||
start_fake_wifi() {
|
||||
if [[ ! "${SP_RAYBIG_FAKE_WIFI:-1}" =~ ^(1|true|yes|on)$ ]]; then
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=0
|
||||
return
|
||||
fi
|
||||
|
||||
export SP_ALLOW_DESKTOP_FAKE_WIFI=1
|
||||
"${PY_BIN}" selfdrive/debug/fake_wifi.py --network wifi --strength great --interval 0.2 &
|
||||
FAKE_WIFI_PID=$!
|
||||
}
|
||||
|
||||
seed_starpilot_theme() {
|
||||
"${PY_BIN}" - <<'PY'
|
||||
from openpilot.starpilot.common.starpilot_functions import seed_desktop_theme_assets
|
||||
|
||||
seed_desktop_theme_assets()
|
||||
PY
|
||||
}
|
||||
|
||||
if ! python_ui_runtime_ok >/dev/null 2>&1; then
|
||||
echo "Preparing host Python UI runtime extensions..."
|
||||
sync_deps
|
||||
sync_raylib
|
||||
prepare_common_host_artifacts
|
||||
prepare_msgq_host_artifacts
|
||||
prepare_pandad_host_artifacts
|
||||
remove_if_elf "common/params_pyx.so"
|
||||
remove_if_elf "common/transformations/transformations.so"
|
||||
remove_if_elf "msgq/ipc_pyx.so"
|
||||
remove_if_elf "msgq/visionipc/visionipc_pyx.so"
|
||||
remove_if_elf "selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
remove_if_elf "selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so"
|
||||
|
||||
run_scons "${jobs}" common/params_pyx.so common/transformations/transformations.so
|
||||
# Building the Cython module pulls in the platform-specific acados solver
|
||||
# library via SCons dependencies, so the host path does not need to name the
|
||||
# solver shared library extension explicitly.
|
||||
run_scons "${jobs}" \
|
||||
selfdrive/controls/lib/lateral_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so \
|
||||
selfdrive/controls/lib/longitudinal_mpc_lib/c_generated_code/acados_ocp_solver_pyx.so
|
||||
(
|
||||
cd "${ROOT_DIR}/msgq_repo"
|
||||
local_scons_bin="${ROOT_DIR}/.venv/bin/scons"
|
||||
if [[ -x "${local_scons_bin}" ]]; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${local_scons_bin}" -j"${jobs}" msgq/ipc_pyx.so msgq/visionipc/visionipc_pyx.so
|
||||
elif "${PY_BIN}" -m SCons --version >/dev/null 2>&1; then
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 "${PY_BIN}" -m SCons -j"${jobs}" msgq/ipc_pyx.so msgq/visionipc/visionipc_pyx.so
|
||||
else
|
||||
echo "SCons not found in .venv after sync."
|
||||
exit 1
|
||||
fi
|
||||
)
|
||||
fi
|
||||
|
||||
if ! python_ui_runtime_ok >/dev/null 2>&1; then
|
||||
"${PY_BIN}" - <<'PY'
|
||||
import site
|
||||
import sys
|
||||
print("python:", sys.executable)
|
||||
print("version:", sys.version)
|
||||
print("site-packages:", site.getsitepackages())
|
||||
PY
|
||||
echo "Unable to load Python UI runtime extensions after rebuild."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "${SP_RAYBIG_COMPILE_ONLY:-0}" == "1" ]]; then
|
||||
echo "Raybig runtime artifacts prepared."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
"${PY_BIN}" - <<'PY'
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.system.version import terms_version, training_version
|
||||
|
||||
params = Params()
|
||||
params.put("HasAcceptedTerms", terms_version)
|
||||
params.put("CompletedTrainingVersion", training_version)
|
||||
params.put_bool("OpenpilotEnabledToggle", True)
|
||||
params.put_bool("IsDriverViewEnabled", False)
|
||||
PY
|
||||
|
||||
seed_starpilot_theme
|
||||
kill_stale_raybig_ui
|
||||
start_fake_wifi
|
||||
"${PY_BIN}" selfdrive/ui/ui.py "$@"
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
FAIL=0
|
||||
|
||||
if grep -n '\(#\|//\)\([[:space:]]*\)NOMERGE' $@; then
|
||||
echo -e "NOMERGE comments found! Remove them before merging\n"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
exit $FAIL
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
FAIL=0
|
||||
|
||||
if grep -n '#include "third_party/raylib/include/raylib\.h"' $@ | grep -v '^system/ui/raylib/raylib\.h'; then
|
||||
echo -e "Bad raylib include found! Use '#include \"system/ui/raylib/raylib.h\"' instead\n"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
exit $FAIL
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
FAIL=0
|
||||
|
||||
if grep '^#!.*python' $@ | grep -v '#!/usr/bin/env python3$'; then
|
||||
echo -e "Invalid shebang! Must use '#!/usr/bin/env python3'\n"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
if grep '^#!.*bash' $@ | grep -v '#!/usr/bin/env bash$'; then
|
||||
echo -e "Invalid shebang! Must use '#!/usr/bin/env bash'"
|
||||
FAIL=1
|
||||
fi
|
||||
|
||||
exit $FAIL
|
||||
Executable
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
UNDERLINE='\033[4m'
|
||||
BOLD='\033[1m'
|
||||
NC='\033[0m'
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
ROOT="$DIR/../../"
|
||||
cd $ROOT
|
||||
|
||||
FAILED=0
|
||||
|
||||
IGNORED_FILES="uv\.lock|docs\/CARS.md"
|
||||
IGNORED_DIRS="^third_party.*|^msgq.*|^msgq_repo.*|^opendbc.*|^opendbc_repo.*|^cereal.*|^panda.*|^rednose.*|^rednose_repo.*|^tinygrad.*|^tinygrad_repo.*|^teleoprtc.*|^teleoprtc_repo.*"
|
||||
|
||||
function run() {
|
||||
shopt -s extglob
|
||||
case $1 in
|
||||
$SKIP | $RUN ) return 0 ;;
|
||||
esac
|
||||
|
||||
echo -en "$1"
|
||||
|
||||
for ((i=0; i<$((50 - ${#1})); i++)); do
|
||||
echo -n "."
|
||||
done
|
||||
|
||||
shift 1;
|
||||
CMD="$@"
|
||||
|
||||
set +e
|
||||
log="$((eval "$CMD" ) 2>&1)"
|
||||
|
||||
if [[ $? -eq 0 ]]; then
|
||||
echo -e "[${GREEN}✔${NC}]"
|
||||
else
|
||||
echo -e "[${RED}✗${NC}]"
|
||||
echo "$log"
|
||||
FAILED=1
|
||||
fi
|
||||
set -e
|
||||
}
|
||||
|
||||
function run_tests() {
|
||||
ALL_FILES=$1
|
||||
PYTHON_FILES=$2
|
||||
|
||||
run "ruff" ruff check $ROOT --quiet
|
||||
run "check_added_large_files" python3 -m pre_commit_hooks.check_added_large_files --enforce-all $ALL_FILES --maxkb=120
|
||||
run "check_shebang_scripts_are_executable" python3 -m pre_commit_hooks.check_shebang_scripts_are_executable $ALL_FILES
|
||||
run "check_shebang_format" $DIR/check_shebang_format.sh $ALL_FILES
|
||||
run "check_nomerge_comments" $DIR/check_nomerge_comments.sh $ALL_FILES
|
||||
|
||||
if [[ -z "$FAST" ]]; then
|
||||
run "mypy" mypy $PYTHON_FILES
|
||||
run "codespell" codespell $ALL_FILES
|
||||
fi
|
||||
|
||||
return $FAILED
|
||||
}
|
||||
|
||||
function help() {
|
||||
echo "A fast linter"
|
||||
echo ""
|
||||
echo -e "${BOLD}${UNDERLINE}Usage:${NC} op lint [TESTS] [OPTIONS]"
|
||||
echo ""
|
||||
echo -e "${BOLD}${UNDERLINE}Tests:${NC}"
|
||||
echo -e " ${BOLD}ruff${NC}"
|
||||
echo -e " ${BOLD}mypy${NC}"
|
||||
echo -e " ${BOLD}codespell${NC}"
|
||||
echo -e " ${BOLD}check_added_large_files${NC}"
|
||||
echo -e " ${BOLD}check_shebang_scripts_are_executable${NC}"
|
||||
echo ""
|
||||
echo -e "${BOLD}${UNDERLINE}Options:${NC}"
|
||||
echo -e " ${BOLD}-f, --fast${NC}"
|
||||
echo " Skip slow tests"
|
||||
echo -e " ${BOLD}-s, --skip${NC}"
|
||||
echo " Specify tests to skip separated by spaces"
|
||||
echo ""
|
||||
echo -e "${BOLD}${UNDERLINE}Examples:${NC}"
|
||||
echo " op lint mypy ruff"
|
||||
echo " Only run the mypy and ruff tests"
|
||||
echo ""
|
||||
echo " op lint --skip mypy ruff"
|
||||
echo " Skip the mypy and ruff tests"
|
||||
echo ""
|
||||
echo " op lint"
|
||||
echo " Run all the tests"
|
||||
}
|
||||
|
||||
SKIP=""
|
||||
RUN=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
-f | --fast ) shift 1; FAST="1" ;;
|
||||
-s | --skip ) shift 1; SKIP=" " ;;
|
||||
-h | --help | -help | --h ) help; exit 0 ;;
|
||||
* ) if [[ -n $SKIP ]]; then SKIP+="$1 "; else RUN+="$1 "; fi; shift 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
RUN=$([ -z "$RUN" ] && echo "" || echo "!($(echo $RUN | sed 's/ /|/g'))")
|
||||
SKIP="@($(echo $SKIP | sed 's/ /|/g'))"
|
||||
|
||||
GIT_FILES="$(git ls-files | sed -E "s/$IGNORED_FILES|$IGNORED_DIRS//g")"
|
||||
ALL_FILES=""
|
||||
for f in $GIT_FILES; do
|
||||
if [[ -f $f ]]; then
|
||||
ALL_FILES+="$f"$'\n'
|
||||
fi
|
||||
done
|
||||
PYTHON_FILES=$(echo "$ALL_FILES" | grep --color=never '.py$' || true)
|
||||
|
||||
run_tests "$ALL_FILES" "$PYTHON_FILES"
|
||||
@@ -0,0 +1,429 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import codecs
|
||||
import os
|
||||
import pickle
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPO_ROOT))
|
||||
|
||||
from openpilot.selfdrive.modeld.constants import ModelConstants
|
||||
from openpilot.starpilot.common.model_versions import uses_combined_driving_artifacts
|
||||
|
||||
DEFAULT_INPUT_ROOT = Path("/data/openpilot/uncompiledmodels")
|
||||
DEFAULT_OUTPUT_ROOT = Path("/data/openpilot/compiledmodels")
|
||||
COMPILE_SCRIPT = REPO_ROOT / "tinygrad_repo/examples/openpilot/compile3.py"
|
||||
COMBINED_COMPILE_SCRIPT = REPO_ROOT / "selfdrive/modeld/compile_modeld.py"
|
||||
MODEL_VERSIONS_CACHE = Path("/data/models/.model_versions.json")
|
||||
DM_MODEL_KEY = "dm"
|
||||
DM_MODEL_NAME = "dmonitoring_model"
|
||||
DM_TARGET_ALIASES = {DM_MODEL_KEY, "dmonitoring", DM_MODEL_NAME}
|
||||
DM_INPUT_CANDIDATES = ("dmonitoring_model.onnx", "dmonitoring.onnx", "dm.onnx")
|
||||
|
||||
COMPONENT_ALIASES = {
|
||||
"driving_off_policy": ("driving_off_policy", "off_policy", "offpolicy"),
|
||||
"driving_on_policy": ("driving_on_policy", "on_policy", "onpolicy"),
|
||||
"driving_policy": ("driving_policy", "policy"),
|
||||
"driving_vision": ("driving_vision", "vision"),
|
||||
}
|
||||
MEDMODEL_INPUT_SIZE = (512, 256)
|
||||
DEFAULT_CAMERA_RESOLUTIONS = (
|
||||
(1928, 1208),
|
||||
(1344, 760),
|
||||
)
|
||||
|
||||
|
||||
def build_compile_env(*, combined: bool = False) -> dict[str, str]:
|
||||
env = os.environ.copy()
|
||||
existing_pythonpath = env.get("PYTHONPATH", "")
|
||||
env["PYTHONPATH"] = f"{REPO_ROOT}:{existing_pythonpath}" if existing_pythonpath else str(REPO_ROOT)
|
||||
|
||||
numeric_defaults = {
|
||||
"DEBUG": "0",
|
||||
"FLOAT16": "1",
|
||||
"IMAGE": "2",
|
||||
"JIT_BATCH_SIZE": "0",
|
||||
"NOLOCALS": "1",
|
||||
}
|
||||
for key, default in numeric_defaults.items():
|
||||
value = env.get(key)
|
||||
try:
|
||||
int(str(value), 0)
|
||||
except (TypeError, ValueError):
|
||||
env[key] = default
|
||||
|
||||
return env
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Compile staged ONNX driving models into tinygrad pkls without touching selfdrive/modeld/models.",
|
||||
)
|
||||
parser.add_argument("--model", help="Output model key, for example sc2.")
|
||||
parser.add_argument("--dm", action="store_true", help="Compile the driver monitoring model into dmonitoring_model_tinygrad.pkl.")
|
||||
parser.add_argument("--input-dir", type=Path, default=DEFAULT_INPUT_ROOT, help="Directory containing staged ONNX files. Flat root files like driving_policy.onnx are preferred.")
|
||||
parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_ROOT, help="Directory for compiled tinygrad pkls and metadata.")
|
||||
parser.add_argument("--version", help="Model version. v16+ uses the combined driving_tinygrad artifact path. If omitted, split-policy staged models default to the combined build.")
|
||||
parser.add_argument("--list", action="store_true", help="List detected staged models and exit.")
|
||||
parser.add_argument("--force", action="store_true", help="Legacy no-op. Compiled outputs are always cleared before a build.")
|
||||
|
||||
args, unknown = parser.parse_known_args()
|
||||
dynamic_model_flags = [arg[2:] for arg in unknown if arg.startswith("--")]
|
||||
invalid = [arg for arg in unknown if not arg.startswith("--")]
|
||||
if invalid:
|
||||
parser.error(f"Unexpected arguments: {' '.join(invalid)}")
|
||||
if len(dynamic_model_flags) > 1:
|
||||
parser.error("Pass only one dynamic model flag, for example ./models --sc2")
|
||||
if args.model and dynamic_model_flags and args.model != dynamic_model_flags[0]:
|
||||
parser.error("Use either --model sc2 or --sc2, not both with different values.")
|
||||
args.model = args.model or (dynamic_model_flags[0] if dynamic_model_flags else None)
|
||||
if args.model and args.model.strip().lower() in DM_TARGET_ALIASES:
|
||||
args.dm = True
|
||||
args.model = None
|
||||
if args.dm and args.model:
|
||||
parser.error("Use either --dm or a driving model key, not both.")
|
||||
return args
|
||||
|
||||
|
||||
def detect_component(path: Path) -> str | None:
|
||||
stem = path.stem.lower()
|
||||
for component, aliases in COMPONENT_ALIASES.items():
|
||||
if any(alias in stem for alias in aliases):
|
||||
return component
|
||||
return None
|
||||
|
||||
|
||||
def find_staged_dm(input_root: Path) -> Path | None:
|
||||
if not input_root.is_dir():
|
||||
return None
|
||||
|
||||
for candidate in DM_INPUT_CANDIDATES:
|
||||
path = input_root / candidate
|
||||
if path.is_file():
|
||||
return path
|
||||
|
||||
for child in sorted(input_root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
for candidate in DM_INPUT_CANDIDATES:
|
||||
path = child / candidate
|
||||
if path.is_file():
|
||||
return path
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def find_staged_models(input_root: Path) -> dict[str, dict[str, Path]]:
|
||||
found: dict[str, dict[str, Path]] = {}
|
||||
if not input_root.is_dir():
|
||||
return found
|
||||
|
||||
for child in sorted(input_root.iterdir()):
|
||||
if not child.is_dir():
|
||||
continue
|
||||
model_files = {}
|
||||
for onnx_file in sorted(child.glob("*.onnx")):
|
||||
component = detect_component(onnx_file)
|
||||
if component:
|
||||
model_files[component] = onnx_file
|
||||
if model_files:
|
||||
found[child.name] = model_files
|
||||
|
||||
flat_root_files = {}
|
||||
for onnx_file in sorted(input_root.glob("*.onnx")):
|
||||
component = detect_component(onnx_file)
|
||||
if component is None:
|
||||
continue
|
||||
|
||||
model_key = None
|
||||
lowered = onnx_file.stem.lower()
|
||||
for alias in COMPONENT_ALIASES[component]:
|
||||
if lowered == alias:
|
||||
model_key = None
|
||||
break
|
||||
suffix = f"_{alias}"
|
||||
if lowered.endswith(suffix):
|
||||
model_key = onnx_file.stem[:-len(suffix)]
|
||||
break
|
||||
|
||||
if model_key in ("", "driving"):
|
||||
model_key = None
|
||||
|
||||
if model_key:
|
||||
found.setdefault(model_key, {})[component] = onnx_file
|
||||
else:
|
||||
flat_root_files[component] = onnx_file
|
||||
|
||||
if flat_root_files:
|
||||
found["_root"] = flat_root_files
|
||||
|
||||
return found
|
||||
|
||||
|
||||
def resolve_model_files(input_root: Path, model_key: str) -> dict[str, Path]:
|
||||
staged = find_staged_models(input_root)
|
||||
if model_key in staged:
|
||||
return staged[model_key]
|
||||
|
||||
root_files = staged.get("_root")
|
||||
if root_files and len(staged) == 1:
|
||||
return root_files
|
||||
|
||||
prefixed_files = {}
|
||||
for onnx_file in sorted(input_root.glob(f"{model_key}_*.onnx")):
|
||||
component = detect_component(onnx_file)
|
||||
if component:
|
||||
prefixed_files[component] = onnx_file
|
||||
return prefixed_files
|
||||
|
||||
|
||||
def get_metadata_value_by_name(model, name: str):
|
||||
for prop in model.metadata_props:
|
||||
if prop.key == name:
|
||||
return prop.value
|
||||
return None
|
||||
|
||||
|
||||
def write_metadata(onnx_path: Path, output_path: Path) -> None:
|
||||
import onnx
|
||||
|
||||
model = onnx.load(str(onnx_path))
|
||||
output_slices = get_metadata_value_by_name(model, "output_slices")
|
||||
if output_slices is None:
|
||||
raise ValueError(f"output_slices not found in metadata for {onnx_path.name}")
|
||||
|
||||
def get_name_and_shape(value_info) -> tuple[str, tuple[int, ...]]:
|
||||
shape = tuple(int(dim.dim_value) for dim in value_info.type.tensor_type.shape.dim)
|
||||
return value_info.name, shape
|
||||
|
||||
metadata = {
|
||||
"model_checkpoint": get_metadata_value_by_name(model, "model_checkpoint"),
|
||||
"output_slices": pickle.loads(codecs.decode(output_slices.encode(), "base64")),
|
||||
"input_shapes": dict(get_name_and_shape(x) for x in model.graph.input),
|
||||
"output_shapes": dict(get_name_and_shape(x) for x in model.graph.output),
|
||||
}
|
||||
|
||||
with open(output_path, "wb") as f:
|
||||
pickle.dump(metadata, f)
|
||||
|
||||
|
||||
def compile_component(onnx_path: Path, output_path: Path) -> None:
|
||||
subprocess.run(
|
||||
[sys.executable, str(COMPILE_SCRIPT), str(onnx_path), str(output_path)],
|
||||
cwd=REPO_ROOT,
|
||||
env=build_compile_env(combined=False),
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def compile_combined_model(component_paths: dict[str, Path], output_path: Path) -> None:
|
||||
vision_path = component_paths["driving_vision"]
|
||||
off_policy_path = component_paths["driving_off_policy"]
|
||||
on_policy_path = component_paths.get("driving_on_policy") or component_paths.get("driving_policy")
|
||||
if on_policy_path is None:
|
||||
raise ValueError("Combined compile requires driving_on_policy.onnx (or driving_policy.onnx) alongside driving_off_policy.onnx")
|
||||
|
||||
frame_skip = ModelConstants.MODEL_RUN_FREQ // ModelConstants.MODEL_CONTEXT_FREQ
|
||||
camera_resolutions = [f"{width}x{height}" for width, height in DEFAULT_CAMERA_RESOLUTIONS]
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
str(COMBINED_COMPILE_SCRIPT),
|
||||
"--model-size",
|
||||
f"{MEDMODEL_INPUT_SIZE[0]}x{MEDMODEL_INPUT_SIZE[1]}",
|
||||
"--camera-resolutions",
|
||||
*camera_resolutions,
|
||||
"--vision-onnx",
|
||||
str(vision_path),
|
||||
"--off-policy-onnx",
|
||||
str(off_policy_path),
|
||||
"--on-policy-onnx",
|
||||
str(on_policy_path),
|
||||
"--output",
|
||||
str(output_path),
|
||||
"--frame-skip",
|
||||
str(frame_skip),
|
||||
],
|
||||
cwd=REPO_ROOT,
|
||||
env=build_compile_env(combined=True),
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def infer_model_version(model_key: str, explicit_version: str | None) -> str:
|
||||
if explicit_version:
|
||||
return explicit_version.strip()
|
||||
|
||||
if MODEL_VERSIONS_CACHE.is_file():
|
||||
try:
|
||||
version_map = json.loads(MODEL_VERSIONS_CACHE.read_text())
|
||||
version = version_map.get(model_key)
|
||||
if isinstance(version, str) and version.strip():
|
||||
return version.strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def should_use_combined_artifacts(model_version: str, model_files: dict[str, Path]) -> bool:
|
||||
if uses_combined_driving_artifacts(model_version):
|
||||
return True
|
||||
if model_version.strip():
|
||||
return False
|
||||
|
||||
has_vision = "driving_vision" in model_files
|
||||
has_off_policy = "driving_off_policy" in model_files
|
||||
has_on_policy = "driving_on_policy" in model_files or "driving_policy" in model_files
|
||||
return has_vision and has_off_policy and has_on_policy
|
||||
|
||||
|
||||
def resolve_split_component_inputs(model_files: dict[str, Path]) -> dict[str, Path]:
|
||||
resolved: dict[str, Path] = {}
|
||||
|
||||
vision_path = model_files.get("driving_vision")
|
||||
if vision_path is not None:
|
||||
resolved["driving_vision"] = vision_path
|
||||
|
||||
policy_path = model_files.get("driving_policy") or model_files.get("driving_on_policy")
|
||||
if policy_path is not None:
|
||||
resolved["driving_policy"] = policy_path
|
||||
|
||||
off_policy_path = model_files.get("driving_off_policy")
|
||||
if off_policy_path is not None:
|
||||
resolved["driving_off_policy"] = off_policy_path
|
||||
|
||||
return resolved
|
||||
|
||||
|
||||
def clear_existing_outputs(output_dir: Path) -> list[Path]:
|
||||
removed = []
|
||||
for existing in sorted(output_dir.iterdir()):
|
||||
if existing.is_file() or existing.is_symlink():
|
||||
existing.unlink()
|
||||
elif existing.is_dir():
|
||||
shutil.rmtree(existing)
|
||||
removed.append(existing)
|
||||
return removed
|
||||
|
||||
|
||||
def list_models(staged: dict[str, dict[str, Path]], input_root: Path) -> int:
|
||||
dm_path = find_staged_dm(input_root)
|
||||
if not staged and dm_path is None:
|
||||
print(f"No staged models found in {input_root}")
|
||||
return 0
|
||||
|
||||
for model_key, files in sorted(staged.items()):
|
||||
print(model_key)
|
||||
for component, path in sorted(files.items()):
|
||||
print(f" {component}: {path}")
|
||||
|
||||
if dm_path is not None:
|
||||
print(DM_MODEL_KEY)
|
||||
print(f" {DM_MODEL_NAME}: {dm_path}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
staged = find_staged_models(args.input_dir)
|
||||
|
||||
if args.list:
|
||||
return list_models(staged, args.input_dir)
|
||||
|
||||
if args.dm:
|
||||
onnx_path = find_staged_dm(args.input_dir)
|
||||
if onnx_path is None:
|
||||
raise SystemExit(
|
||||
f"No staged ONNX file found for {DM_MODEL_NAME} in {args.input_dir}. "
|
||||
f"Use one of: {', '.join(str(args.input_dir / candidate) for candidate in DM_INPUT_CANDIDATES)}"
|
||||
)
|
||||
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
print(f"Compiling {DM_MODEL_NAME} from {onnx_path} -> {args.output_dir}")
|
||||
|
||||
removed = clear_existing_outputs(args.output_dir)
|
||||
if removed:
|
||||
print(f" cleared {len(removed)} existing output entries")
|
||||
|
||||
output_pkl = args.output_dir / f"{DM_MODEL_NAME}_tinygrad.pkl"
|
||||
output_metadata = args.output_dir / f"{DM_MODEL_NAME}_metadata.pkl"
|
||||
|
||||
compile_component(onnx_path, output_pkl)
|
||||
write_metadata(onnx_path, output_metadata)
|
||||
print(f" saved {output_pkl.name}")
|
||||
print(f" saved {output_metadata.name}")
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
if not args.model:
|
||||
available = ", ".join(sorted(k for k in staged if k != "_root"))
|
||||
if find_staged_dm(args.input_dir) is not None:
|
||||
available = f"{available}, {DM_MODEL_KEY}" if available else DM_MODEL_KEY
|
||||
raise SystemExit(f"Choose a model key, for example ./models --sc2 or ./models --dm. Available staged models: {available or 'none'}")
|
||||
|
||||
model_key = args.model.strip()
|
||||
files = resolve_model_files(args.input_dir, model_key)
|
||||
if not files:
|
||||
raise SystemExit(
|
||||
f"No staged ONNX files found for {model_key} in {args.input_dir}. "
|
||||
f"Use {args.input_dir}/driving_policy.onnx and {args.input_dir}/driving_vision.onnx, "
|
||||
f"or {args.input_dir}/driving_on_policy.onnx with {args.input_dir}/driving_off_policy.onnx, "
|
||||
f"or optionally {args.input_dir / model_key}/*.onnx"
|
||||
)
|
||||
|
||||
model_version = infer_model_version(model_key, args.version)
|
||||
use_combined_artifacts = should_use_combined_artifacts(model_version, files)
|
||||
|
||||
args.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
mode_label = "combined" if use_combined_artifacts else "split"
|
||||
version_label = model_version or ("auto-combined" if use_combined_artifacts else "legacy-default")
|
||||
print(f"Compiling {model_key} ({version_label}, {mode_label}) from {args.input_dir} -> {args.output_dir}")
|
||||
|
||||
removed = clear_existing_outputs(args.output_dir)
|
||||
if removed:
|
||||
print(f" cleared {len(removed)} existing output entries")
|
||||
|
||||
if use_combined_artifacts:
|
||||
required_components = {"driving_vision", "driving_off_policy"}
|
||||
if not (files.get("driving_on_policy") or files.get("driving_policy")):
|
||||
required_components.add("driving_on_policy")
|
||||
missing = sorted(component for component in required_components if component not in files)
|
||||
if missing:
|
||||
raise SystemExit(f"Missing required ONNX files for combined compile of {model_key}: {', '.join(missing)}")
|
||||
|
||||
output_pkl = args.output_dir / f"{model_key}_driving_tinygrad.pkl"
|
||||
compile_combined_model(files, output_pkl)
|
||||
print(f" saved {output_pkl.name}")
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
split_components = resolve_split_component_inputs(files)
|
||||
missing = sorted(component for component in ("driving_policy", "driving_vision") if component not in split_components)
|
||||
if missing:
|
||||
raise SystemExit(f"Missing required ONNX files for {model_key}: {', '.join(missing)}")
|
||||
|
||||
for component, onnx_path in sorted(split_components.items()):
|
||||
output_pkl = args.output_dir / f"{model_key}_{component}_tinygrad.pkl"
|
||||
output_metadata = args.output_dir / f"{model_key}_{component}_metadata.pkl"
|
||||
|
||||
print(f" compiling {component}: {onnx_path.name}")
|
||||
compile_component(onnx_path, output_pkl)
|
||||
write_metadata(onnx_path, output_metadata)
|
||||
print(f" saved {output_pkl.name}")
|
||||
print(f" saved {output_metadata.name}")
|
||||
|
||||
print("Done.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if [[ -f .git/hooks/post-commit.d/post-commit ]]; then
|
||||
.git/hooks/post-commit.d/post-commit
|
||||
fi
|
||||
tools/op.sh lint --fast
|
||||
echo ""
|
||||
@@ -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()
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import glob
|
||||
import onnx
|
||||
|
||||
BASEDIR = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), "../"))
|
||||
MASTER_PATH = os.getenv("MASTER_PATH", BASEDIR)
|
||||
MODEL_PATH = "/selfdrive/modeld/models/"
|
||||
|
||||
def get_checkpoint(f):
|
||||
model = onnx.load(f)
|
||||
metadata = {prop.key: prop.value for prop in model.metadata_props}
|
||||
return metadata['model_checkpoint'].split('/')[0]
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("| | master | PR branch |")
|
||||
print("|-| ----- | --------- |")
|
||||
|
||||
for f in glob.glob(BASEDIR + MODEL_PATH + "/*.onnx"):
|
||||
# TODO: add checkpoint to DM
|
||||
if "dmonitoring" in f:
|
||||
continue
|
||||
|
||||
fn = os.path.basename(f)
|
||||
master = get_checkpoint(MASTER_PATH + MODEL_PATH + fn)
|
||||
pr = get_checkpoint(BASEDIR + MODEL_PATH + fn)
|
||||
print(
|
||||
"|", fn, "|",
|
||||
f"[{master}](https://reporter.comma.life/experiment/{master})", "|",
|
||||
f"[{pr}](https://reporter.comma.life/experiment/{pr})", "|"
|
||||
)
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function fail {
|
||||
echo $1 >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
function retry {
|
||||
local n=1
|
||||
local max=3 # 3 retries before failure
|
||||
local delay=5 # delay between retries, 5 seconds
|
||||
while true; do
|
||||
echo "Running command '$@' with retry, attempt $n/$max"
|
||||
"$@" && break || {
|
||||
if [[ $n -lt $max ]]; then
|
||||
((n++))
|
||||
sleep $delay;
|
||||
else
|
||||
fail "The command has failed after $n attempts."
|
||||
fi
|
||||
}
|
||||
done
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then
|
||||
retry "$@"
|
||||
fi
|
||||
@@ -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,284 @@
|
||||
#!/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",
|
||||
"source_confidence",
|
||||
"source_event",
|
||||
"published_speed_limit_mph",
|
||||
"published_confidence",
|
||||
"map_source",
|
||||
"map_current_speed_limit_mph",
|
||||
"map_next_speed_limit_mph",
|
||||
"map_next_speed_limit_distance_m",
|
||||
"map_expected_speed_limit_mph",
|
||||
"map_relation",
|
||||
"previous_map_speed_limit_mph",
|
||||
"review_bucket",
|
||||
"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,173 @@
|
||||
#!/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", "training_candidate", "map_transition_miss", "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 ""),
|
||||
"source_confidence": str(event.get("sourceConfidence") or ""),
|
||||
"source_event": str(event.get("sourceEvent") or ""),
|
||||
"published_speed_limit_mph": str(event.get("publishedSpeedLimitMph") or ""),
|
||||
"published_confidence": str(event.get("publishedConfidence") or ""),
|
||||
"map_source": str(event.get("mapSource") or ""),
|
||||
"map_current_speed_limit_mph": str(event.get("mapCurrentSpeedLimitMph") or ""),
|
||||
"map_next_speed_limit_mph": str(event.get("mapNextSpeedLimitMph") or ""),
|
||||
"map_next_speed_limit_distance_m": str(event.get("mapNextSpeedLimitDistanceM") or ""),
|
||||
"map_expected_speed_limit_mph": str(event.get("mapExpectedSpeedLimitMph") or ""),
|
||||
"map_relation": str(event.get("mapRelation") or ""),
|
||||
"previous_map_speed_limit_mph": str(event.get("previousMapSpeedLimitMph") or ""),
|
||||
"review_bucket": str(event.get("reviewBucket") 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,289 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import bz2
|
||||
import csv
|
||||
import re
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import zstandard as zstd
|
||||
from cereal import log
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ensure_dir, preferred_clip_root, resolve_workspace # type: ignore
|
||||
from evaluate_bookmark_leadins import BookmarkWindow # type: ignore
|
||||
from import_bookmark_leadins import extract_window_frames, write_contact_sheet # type: ignore
|
||||
from localize_bookmark_signs import configure_models, iter_context_frames, score_frame # type: ignore
|
||||
else:
|
||||
from .common import ensure_dir, preferred_clip_root, resolve_workspace
|
||||
from .evaluate_bookmark_leadins import BookmarkWindow
|
||||
from .import_bookmark_leadins import extract_window_frames, write_contact_sheet
|
||||
from .localize_bookmark_signs import configure_models, iter_context_frames, score_frame
|
||||
|
||||
|
||||
DEFAULT_WORKSPACE = Path("/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean")
|
||||
ROUTE_ID_RE = re.compile(r"([0-9a-f]{16})/([^/]+)")
|
||||
BOOKMARK_TYPES = ("bookmarkButton", "userBookmark")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Mine Connect routes with preserved rlog/fcamera into bookmark review sheets and localized sign candidates.")
|
||||
parser.add_argument("routes", nargs="+", help="Route ids like 'dongle/logid'.")
|
||||
parser.add_argument("--workspace", type=Path, default=DEFAULT_WORKSPACE, help="Training workspace root.")
|
||||
parser.add_argument("--clip-root", type=Path, default=preferred_clip_root(), help="Downloaded route clip root.")
|
||||
parser.add_argument("--models-dir", type=Path, help="Optional directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.")
|
||||
parser.add_argument("--lead-in", type=float, default=7.0, help="Seconds before each bookmark to sample into review sheets.")
|
||||
parser.add_argument("--sample-every", type=float, default=0.5, help="Seconds between sampled lead-in frames.")
|
||||
parser.add_argument("--max-samples", type=int, default=12, help="Max sampled frames per bookmark for the contact sheet.")
|
||||
parser.add_argument("--search-before", type=float, default=10.0, help="Seconds before each bookmark to scan for the most sign-like frame.")
|
||||
parser.add_argument("--search-after", type=float, default=1.0, help="Seconds after each bookmark to scan for the most sign-like frame.")
|
||||
parser.add_argument("--localize-sample-every", type=float, default=0.25, help="Seconds between frames while searching for the best sign candidate.")
|
||||
parser.add_argument("--top-k", type=int, default=1, help="Number of localized candidates to keep per bookmark.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite any existing outputs.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def parse_route_id(text: str) -> tuple[str, str]:
|
||||
match = ROUTE_ID_RE.fullmatch(text.strip().replace("|", "/"))
|
||||
if match is None:
|
||||
raise ValueError(f"Unrecognized route id: {text}")
|
||||
return match.group(1), match.group(2)
|
||||
|
||||
|
||||
def read_log_bytes(path: Path) -> bytes:
|
||||
if path.suffix == ".zst":
|
||||
with path.open("rb") as handle:
|
||||
return zstd.ZstdDecompressor().stream_reader(handle).read()
|
||||
if path.suffix == ".bz2":
|
||||
return bz2.decompress(path.read_bytes())
|
||||
return path.read_bytes()
|
||||
|
||||
|
||||
def load_route_bookmarks(clip_root: Path, log_id: str) -> list[dict]:
|
||||
segment_dirs = sorted(clip_root.glob(f"{log_id}--*"), key=lambda path: int(path.name.rsplit("--", 1)[-1]))
|
||||
if not segment_dirs:
|
||||
raise FileNotFoundError(f"No downloaded segments found for {log_id} under {clip_root}")
|
||||
|
||||
route_start_monotime = None
|
||||
raw_events: list[tuple[float, str]] = []
|
||||
for segment_dir in segment_dirs:
|
||||
rlog_path = segment_dir / "rlog.zst"
|
||||
if not rlog_path.exists():
|
||||
rlog_path = segment_dir / "rlog.bz2"
|
||||
if not rlog_path.exists():
|
||||
continue
|
||||
|
||||
events = list(log.Event.read_multiple_bytes(read_log_bytes(rlog_path)))
|
||||
if not events:
|
||||
continue
|
||||
if route_start_monotime is None:
|
||||
route_start_monotime = events[0].logMonoTime
|
||||
|
||||
for event in events:
|
||||
event_type = event.which()
|
||||
if event_type not in BOOKMARK_TYPES:
|
||||
continue
|
||||
route_time_s = (event.logMonoTime - route_start_monotime) / 1e9
|
||||
raw_events.append((route_time_s, event_type))
|
||||
|
||||
raw_events.sort(key=lambda item: item[0])
|
||||
deduped: list[dict] = []
|
||||
for route_time_s, event_type in raw_events:
|
||||
if deduped and abs(route_time_s - deduped[-1]["route_time_s"]) <= 0.5:
|
||||
if event_type == "userBookmark":
|
||||
deduped[-1]["event_type"] = event_type
|
||||
deduped[-1]["route_time_s"] = route_time_s
|
||||
continue
|
||||
|
||||
segment = max(int(route_time_s // 60.0), 0)
|
||||
segment_offset_s = route_time_s - segment * 60.0
|
||||
deduped.append({
|
||||
"event_type": event_type,
|
||||
"route_time_s": route_time_s,
|
||||
"segment": segment,
|
||||
"segment_offset_s": segment_offset_s,
|
||||
})
|
||||
return deduped
|
||||
|
||||
|
||||
def write_localized_manifest(path: Path, rows: list[dict]) -> None:
|
||||
ensure_dir(path.parent)
|
||||
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 fmt_detection(result) -> str:
|
||||
if result is None:
|
||||
return ""
|
||||
return f"{result[0]}@{result[1]:.3f}"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
clip_root = args.clip_root.expanduser().resolve()
|
||||
review_root = ensure_dir(workspace / "review" / "connect_route_bookmarks")
|
||||
frame_dir = ensure_dir(review_root / "frames")
|
||||
crop_dir = ensure_dir(review_root / "crops")
|
||||
contact_sheet_dir = ensure_dir(review_root / "contact_sheets")
|
||||
leadin_manifest_path = review_root / "bookmark_leadins.csv"
|
||||
localized_manifest_path = review_root / "localized_bookmarks.csv"
|
||||
|
||||
configure_models(args.models_dir)
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
localized_rows: list[dict] = []
|
||||
leadin_rows: list[dict] = []
|
||||
|
||||
for raw_route in args.routes:
|
||||
dongle_id, log_id = parse_route_id(raw_route)
|
||||
session_id = f"connect_{dongle_id}_{log_id}"
|
||||
bookmarks = load_route_bookmarks(clip_root, log_id)
|
||||
if not bookmarks:
|
||||
print(f"{raw_route}: no bookmark events found in downloaded rlogs")
|
||||
continue
|
||||
|
||||
print(f"{raw_route}: found {len(bookmarks)} bookmark(s)")
|
||||
for bookmark_number, bookmark in enumerate(bookmarks, start=1):
|
||||
window = BookmarkWindow(
|
||||
bookmark_number=bookmark_number,
|
||||
route=log_id,
|
||||
segment=int(bookmark["segment"]),
|
||||
segment_offset_s=float(bookmark["segment_offset_s"]),
|
||||
leadin_start_s=float(bookmark["segment_offset_s"]) - args.lead_in,
|
||||
spans_previous_segment=float(bookmark["segment_offset_s"]) - args.lead_in < 0.0,
|
||||
)
|
||||
|
||||
sampled_frames = extract_window_frames({
|
||||
"route": log_id,
|
||||
"segment": window.segment,
|
||||
"segmentOffsetS": window.segment_offset_s,
|
||||
"leadinStartS": window.leadin_start_s,
|
||||
"spansPreviousSegment": window.spans_previous_segment,
|
||||
}, clip_root, args.sample_every, args.max_samples)
|
||||
|
||||
contact_sheet_frames = []
|
||||
contact_sheet_labels = []
|
||||
contact_sheet_name = f"{session_id}_bookmark_{bookmark_number:03d}.jpg"
|
||||
contact_sheet_path = contact_sheet_dir / contact_sheet_name
|
||||
|
||||
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])
|
||||
contact_sheet_frames.append(sample["frame_bgr"])
|
||||
contact_sheet_labels.append(f"t={sample['relative_offset_s']:+.2f}s")
|
||||
leadin_rows.append({
|
||||
"session_id": session_id,
|
||||
"bookmark_number": bookmark_number,
|
||||
"route": log_id,
|
||||
"segment": window.segment,
|
||||
"segment_offset_s": f"{window.segment_offset_s:.3f}",
|
||||
"sample_offset_s": f"{sample['relative_offset_s']:.3f}",
|
||||
"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"]),
|
||||
"event_type": bookmark["event_type"],
|
||||
"route_time_s": f"{bookmark['route_time_s']:.3f}",
|
||||
})
|
||||
|
||||
if contact_sheet_frames:
|
||||
write_contact_sheet(contact_sheet_path, contact_sheet_frames, contact_sheet_labels, args.overwrite)
|
||||
|
||||
ranked = []
|
||||
for relative_time_s, source_video_path, _, frame_bgr in iter_context_frames(
|
||||
clip_root,
|
||||
window,
|
||||
args.search_before,
|
||||
args.search_after,
|
||||
args.localize_sample_every,
|
||||
):
|
||||
scored = score_frame(daemon, frame_bgr)
|
||||
if scored is None:
|
||||
continue
|
||||
ranked.append((scored["score"], relative_time_s, source_video_path, 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 = frame_dir / frame_name
|
||||
crop_path = crop_dir / crop_name
|
||||
if args.overwrite or not frame_path.exists():
|
||||
cv2.imwrite(str(frame_path), frame_bgr, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
if crop.size != 0 and (args.overwrite or not crop_path.exists()):
|
||||
cv2.imwrite(str(crop_path), crop, [cv2.IMWRITE_JPEG_QUALITY, 90])
|
||||
|
||||
full_detection = scored["full_detection"]
|
||||
localized_rows.append({
|
||||
"session_id": session_id,
|
||||
"bookmark_number": bookmark_number,
|
||||
"route": log_id,
|
||||
"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"]),
|
||||
})
|
||||
|
||||
ensure_dir(leadin_manifest_path.parent)
|
||||
with leadin_manifest_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=[
|
||||
"session_id",
|
||||
"bookmark_number",
|
||||
"route",
|
||||
"segment",
|
||||
"segment_offset_s",
|
||||
"sample_offset_s",
|
||||
"frame_path",
|
||||
"contact_sheet_path",
|
||||
"source_video_path",
|
||||
"event_type",
|
||||
"route_time_s",
|
||||
])
|
||||
writer.writeheader()
|
||||
writer.writerows(leadin_rows)
|
||||
|
||||
write_localized_manifest(localized_manifest_path, localized_rows)
|
||||
print(f"Wrote {len(leadin_rows)} sampled lead-in frame row(s) to {leadin_manifest_path}")
|
||||
print(f"Wrote {len(localized_rows)} localized candidate row(s) to {localized_manifest_path}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(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())
|
||||
Executable
+187
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage:
|
||||
scripts/starpilot_build_flow.sh verify
|
||||
scripts/starpilot_build_flow.sh mac [jobs]
|
||||
scripts/starpilot_build_flow.sh device [jobs]
|
||||
scripts/starpilot_build_flow.sh laptop-setup [device-host]
|
||||
scripts/starpilot_build_flow.sh laptop-device [jobs]
|
||||
|
||||
Modes:
|
||||
verify Check critical StarPilot parity build guards in-tree.
|
||||
mac Run macOS developer validation build (Qt UI + Python compile checks).
|
||||
device Run full on-device build (comma hardware only) and set prebuilt flag.
|
||||
laptop-setup Prepare laptop device-build environment (venv + image + sysroot).
|
||||
laptop-device Run full device-target build in Linux/aarch64 container on laptop.
|
||||
|
||||
Notes:
|
||||
- mac mode does NOT produce device binaries.
|
||||
- device mode must be run on comma hardware (/TICI present).
|
||||
- laptop-device mode requires docker/podman and a prepared sysroot.
|
||||
- prepare sysroot via scripts/laptop_device_build.sh setup-sysroot-agnos
|
||||
or scripts/laptop_device_build.sh setup-sysroot <device-ip>.
|
||||
EOF
|
||||
}
|
||||
|
||||
require_venv() {
|
||||
if [[ ! -f "${ROOT_DIR}/.venv/bin/activate" ]]; then
|
||||
echo "Missing .venv. Initialize it first."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
verify_tree() {
|
||||
local failed=0
|
||||
|
||||
if ! rg -q "libs = \\['m', 'pthread'" system/camerad/SConscript; then
|
||||
echo "FAIL: system/camerad/SConscript is missing pthread link guard."
|
||||
failed=1
|
||||
else
|
||||
echo "OK: camerad link libs include pthread."
|
||||
fi
|
||||
|
||||
if ! rg -q "TimezoneFinder = None" system/timed.py; then
|
||||
echo "FAIL: system/timed.py missing TimezoneFinder fallback."
|
||||
failed=1
|
||||
else
|
||||
echo "OK: timed.py has TimezoneFinder fallback."
|
||||
fi
|
||||
|
||||
if ! rg -q "if tf is not None:" system/timed.py; then
|
||||
echo "FAIL: system/timed.py missing guarded timezone usage."
|
||||
failed=1
|
||||
else
|
||||
echo "OK: timed.py guards timezonefinder usage."
|
||||
fi
|
||||
|
||||
if ! rg -q "if os.path.isfile\\(\"/TICI\"\\)" common/spinner.py; then
|
||||
echo "FAIL: common/spinner.py missing device-only legacy spinner path."
|
||||
failed=1
|
||||
else
|
||||
echo "OK: spinner uses device-only legacy fallback."
|
||||
fi
|
||||
|
||||
if ! rg -q "if os.path.isfile\\(\"/TICI\"\\)" common/text_window.py; then
|
||||
echo "FAIL: common/text_window.py missing device-only legacy text path."
|
||||
failed=1
|
||||
else
|
||||
echo "OK: text window uses device-only legacy fallback."
|
||||
fi
|
||||
|
||||
if ! source .venv/bin/activate >/dev/null 2>&1; then
|
||||
echo "FAIL: .venv is required for loggerd import checks."
|
||||
failed=1
|
||||
else
|
||||
if ! python - <<'PY'
|
||||
import builtins
|
||||
import importlib
|
||||
|
||||
real_import = builtins.__import__
|
||||
def fake_import(name, *args, **kwargs):
|
||||
if name == "xattr":
|
||||
raise ModuleNotFoundError("simulated missing xattr")
|
||||
return real_import(name, *args, **kwargs)
|
||||
|
||||
builtins.__import__ = fake_import
|
||||
importlib.import_module("openpilot.system.loggerd.xattr_cache")
|
||||
print("xattr_cache import fallback OK")
|
||||
PY
|
||||
then
|
||||
echo "FAIL: loggerd xattr fallback check failed."
|
||||
failed=1
|
||||
else
|
||||
echo "OK: loggerd xattr fallback works when xattr module is missing."
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "${failed}" -ne 0 ]]; then
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
build_mac() {
|
||||
local jobs="${1:-8}"
|
||||
require_venv
|
||||
source .venv/bin/activate
|
||||
|
||||
verify_tree
|
||||
|
||||
# Homebrew tools are needed for SConstruct on macOS.
|
||||
export PATH="/opt/homebrew/bin:${PATH}"
|
||||
export TMPDIR="${TMPDIR:-/tmp}"
|
||||
|
||||
python -m py_compile \
|
||||
system/timed.py \
|
||||
common/spinner.py \
|
||||
common/text_window.py \
|
||||
starpilot/system/starpilot_stats.py
|
||||
|
||||
python -m compileall -q \
|
||||
selfdrive/ui \
|
||||
system/ui \
|
||||
starpilot/system/the_galaxy \
|
||||
starpilot/system/galaxy
|
||||
|
||||
SP_DISABLE_AUTO_DEVICE_SCONS=1 scons -j"${jobs}" selfdrive/ui/ui
|
||||
|
||||
echo "mac build validation complete."
|
||||
}
|
||||
|
||||
build_device() {
|
||||
local jobs="${1:-$(nproc)}"
|
||||
|
||||
if [[ ! -f /TICI ]]; then
|
||||
echo "device mode must run on comma hardware (/TICI not found)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
require_venv
|
||||
source .venv/bin/activate
|
||||
|
||||
verify_tree
|
||||
scons -j"${jobs}"
|
||||
touch prebuilt
|
||||
|
||||
echo "device build complete and prebuilt flag set."
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -lt 1 ]]; then
|
||||
usage
|
||||
exit 1
|
||||
fi
|
||||
|
||||
local mode="$1"
|
||||
shift || true
|
||||
|
||||
case "${mode}" in
|
||||
verify)
|
||||
verify_tree
|
||||
;;
|
||||
mac)
|
||||
build_mac "${1:-8}"
|
||||
;;
|
||||
device)
|
||||
build_device "${1:-$(nproc)}"
|
||||
;;
|
||||
laptop-setup)
|
||||
scripts/laptop_device_build.sh setup "${1:-}" "${2:-comma}" "${3:-22}"
|
||||
;;
|
||||
laptop-device)
|
||||
scripts/laptop_device_build.sh build "${1:-8}"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+7
@@ -0,0 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Stop updater
|
||||
pkill -2 -f system.updated.updated
|
||||
|
||||
# Remove pending update
|
||||
rm -f /data/safe_staging/finalized/.overlay_consistent
|
||||
Executable
+4
@@ -0,0 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# Send SIGHUP to updater
|
||||
pkill -1 -f system.updated
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# testing the GPU box
|
||||
|
||||
export XDG_CACHE_HOME=/data/tinycache
|
||||
mkdir -p $XDG_CACHE_HOME
|
||||
|
||||
cd /data/openpilot/tinygrad_repo/examples
|
||||
while true; do
|
||||
AMD=1 AMD_IFACE=usb python ./beautiful_cartpole.py
|
||||
sleep 1
|
||||
done
|
||||
@@ -0,0 +1,26 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null && pwd )"
|
||||
cd $DIR/../../tinygrad_repo
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m'
|
||||
|
||||
|
||||
#export DEBUG=2
|
||||
export PYTHONPATH=.
|
||||
export AM_RESET=1
|
||||
export AMD=1
|
||||
export AMD_IFACE=USB
|
||||
export AMD_LLVM=1
|
||||
|
||||
python3 -m unittest -q --buffer test.test_tiny.TestTiny.test_plus \
|
||||
> /tmp/test_tiny.log 2>&1 || (cat /tmp/test_tiny.log; exit 1)
|
||||
printf "${GREEN}Booted in ${SECONDS}s${NC}\n"
|
||||
printf "${GREEN}=============${NC}\n"
|
||||
|
||||
printf "\n\n"
|
||||
printf "${GREEN}Transfer speeds:${NC}\n"
|
||||
printf "${GREEN}================${NC}\n"
|
||||
python3 test/external/external_test_usb_asm24.py TestDevCopySpeeds
|
||||
@@ -0,0 +1,89 @@
|
||||
// gcc -O2 waste.c -lpthread -owaste
|
||||
// gcc -O2 waste.c -lpthread -owaste -DMEM
|
||||
|
||||
#define _GNU_SOURCE
|
||||
#include <stdio.h>
|
||||
#include <math.h>
|
||||
#include <sched.h>
|
||||
#include <string.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
#include <pthread.h>
|
||||
#include <arm_neon.h>
|
||||
#include <sys/sysinfo.h>
|
||||
#include "../common/timing.h"
|
||||
|
||||
int get_nprocs(void);
|
||||
double *ttime, *oout;
|
||||
|
||||
void waste(int pid) {
|
||||
cpu_set_t my_set;
|
||||
CPU_ZERO(&my_set);
|
||||
CPU_SET(pid, &my_set);
|
||||
int ret = sched_setaffinity(0, sizeof(cpu_set_t), &my_set);
|
||||
printf("set affinity to %d: %d\n", pid, ret);
|
||||
|
||||
// 128 MB
|
||||
float32x4_t *tmp = (float32x4_t *)malloc(0x800000*sizeof(float32x4_t));
|
||||
|
||||
// comment out the memset for CPU only and not RAM
|
||||
// otherwise we need this to avoid the zero page
|
||||
#ifdef MEM
|
||||
memset(tmp, 0xaa, 0x800000*sizeof(float32x4_t));
|
||||
#endif
|
||||
|
||||
float32x4_t out;
|
||||
|
||||
double sec = seconds_since_boot();
|
||||
while (1) {
|
||||
for (int i = 0; i < 0x10; i++) {
|
||||
for (int j = 0; j < 0x800000; j+=0x20) {
|
||||
out = vmlaq_f32(out, tmp[j+0], tmp[j+1]);
|
||||
out = vmlaq_f32(out, tmp[j+2], tmp[j+3]);
|
||||
out = vmlaq_f32(out, tmp[j+4], tmp[j+5]);
|
||||
out = vmlaq_f32(out, tmp[j+6], tmp[j+7]);
|
||||
out = vmlaq_f32(out, tmp[j+8], tmp[j+9]);
|
||||
out = vmlaq_f32(out, tmp[j+10], tmp[j+11]);
|
||||
out = vmlaq_f32(out, tmp[j+12], tmp[j+13]);
|
||||
out = vmlaq_f32(out, tmp[j+14], tmp[j+15]);
|
||||
out = vmlaq_f32(out, tmp[j+16], tmp[j+17]);
|
||||
out = vmlaq_f32(out, tmp[j+18], tmp[j+19]);
|
||||
out = vmlaq_f32(out, tmp[j+20], tmp[j+21]);
|
||||
out = vmlaq_f32(out, tmp[j+22], tmp[j+23]);
|
||||
out = vmlaq_f32(out, tmp[j+24], tmp[j+25]);
|
||||
out = vmlaq_f32(out, tmp[j+26], tmp[j+27]);
|
||||
out = vmlaq_f32(out, tmp[j+28], tmp[j+29]);
|
||||
out = vmlaq_f32(out, tmp[j+30], tmp[j+31]);
|
||||
}
|
||||
}
|
||||
double nsec = seconds_since_boot();
|
||||
ttime[pid] = nsec-sec;
|
||||
oout[pid] = out[0] + out[1] + out[2] + out[3];
|
||||
sec = nsec;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
int CORES = get_nprocs();
|
||||
ttime = (double *)malloc(CORES*sizeof(double));
|
||||
oout = (double *)malloc(CORES*sizeof(double));
|
||||
|
||||
pthread_t waster[CORES];
|
||||
for (long i = 0; i < CORES; i++) {
|
||||
ttime[i] = NAN;
|
||||
pthread_create(&waster[i], NULL, (void *(*)(void *))waste, (void*)i);
|
||||
}
|
||||
while (1) {
|
||||
double avg = 0.0;
|
||||
double iavg = 0.0;
|
||||
for (int i = 0; i < CORES; i++) {
|
||||
avg += ttime[i];
|
||||
iavg += 1/ttime[i];
|
||||
printf("%4.2f ", ttime[i]);
|
||||
}
|
||||
double mb_per_sec = (16.*0x800000/(1024*1024))*sizeof(float32x4_t)*iavg;
|
||||
printf("-- %4.2f -- %.2f MB/s \n", avg/CORES, mb_per_sec);
|
||||
sleep(1);
|
||||
}
|
||||
}
|
||||
|
||||
Executable
+33
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import time
|
||||
import numpy as np
|
||||
from multiprocessing import Process
|
||||
from setproctitle import setproctitle
|
||||
|
||||
def waste(core):
|
||||
os.sched_setaffinity(0, [core,])
|
||||
|
||||
m1 = np.zeros((200, 200)) + 0.8
|
||||
m2 = np.zeros((200, 200)) + 1.2
|
||||
|
||||
i = 1
|
||||
st = time.monotonic()
|
||||
j = 0
|
||||
while 1:
|
||||
if (i % 100) == 0:
|
||||
setproctitle(f"{core:3d}: {i:8d}")
|
||||
lt = time.monotonic()
|
||||
print(f"{core:3d}: {i:8d} {lt-st:f} {j:.2f}")
|
||||
st = lt
|
||||
i += 1
|
||||
j = np.sum(np.matmul(m1, m2))
|
||||
|
||||
def main(gctx=None):
|
||||
print("1-2 seconds is baseline")
|
||||
for i in range(os.cpu_count()):
|
||||
p = Process(target=waste, args=(i,))
|
||||
p.start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user