Added CX-8 as its own car

This commit is contained in:
Test User
2026-06-25 07:25:20 -05:00
committed by mr-one
commit a14a9727a8
5894 changed files with 1525255 additions and 0 deletions
View File
+136
View File
@@ -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()
+11
View File
@@ -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
+3
View File
@@ -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
+16
View File
@@ -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
+51
View File
@@ -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
+86
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env python3
from openpilot.system.hardware import HARDWARE
if __name__ == "__main__":
HARDWARE.set_power_save(False)
+596
View File
@@ -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 "$@"
+129
View File
@@ -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 $@
+718
View File
@@ -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 "$@"
+7
View File
@@ -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
+589
View File
@@ -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[@]}"
+344
View File
@@ -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 "$@"
+243
View File
@@ -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}"
+344
View File
@@ -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 "$@"
+10
View File
@@ -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
+10
View File
@@ -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
+15
View File
@@ -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
+117
View File
@@ -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"
+429
View File
@@ -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())
+7
View File
@@ -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 ""
+85
View File
@@ -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()
+31
View File
@@ -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})", "|"
)
+27
View File
@@ -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())
+284
View File
@@ -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())
+109
View File
@@ -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())
+95
View File
@@ -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())
+187
View File
@@ -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 "$@"
+7
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env sh
# Send SIGHUP to updater
pkill -1 -f system.updated
Executable
+12
View File
@@ -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
+26
View File
@@ -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
+89
View File
@@ -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);
}
}
+33
View File
@@ -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()