mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-10-15 08:13:42 +08:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6e2fb1c33 | |||
| 9bebe42f73 | |||
| f4996a0529 | |||
| 0414843e2f | |||
| 5acb9d2c8e | |||
| 2bf8f88978 | |||
| e6e5b1c165 | |||
| b7d06aa440 | |||
| a9ccd8fedf | |||
| 6220734b2d | |||
| f3f7296776 | |||
| 9f3a2607ee | |||
| b4c3a19db1 | |||
| 04098893d9 | |||
| bc064e892f | |||
| be51bc793d | |||
| f7c65f7d6a | |||
| bd27f3b54f | |||
| d7c1827134 | |||
| 1344824746 | |||
| 75b5926f4f |
@@ -47,6 +47,7 @@ jobs:
|
||||
if [ "${{ inputs.target }}" = "big" ]; then
|
||||
NAME=$(python3 -c "from openpilot.sunnypilot.models.model_name import DEFAULT_BIG_MODEL; print(DEFAULT_BIG_MODEL)")
|
||||
HF_DEFAULTS_PATH="models/defaults/big"
|
||||
ONNX_PATH="openpilot/selfdrive/modeld/models/big_driving_tinygrad.pkl"
|
||||
elif [ "${{ inputs.target }}" = "dm" ]; then
|
||||
ONNX_PATH="openpilot/selfdrive/modeld/models/dmonitoring_model.onnx"
|
||||
HF_DEFAULTS_PATH="models/defaults/dm"
|
||||
@@ -116,20 +117,6 @@ jobs:
|
||||
${{ github.workspace }}/${{ env.SMALL_PKL }} \
|
||||
--device-input "*" --out-of-band --benchmark-runs 1
|
||||
|
||||
- name: Chunk small pkl
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
export PYTHONPATH=${{ github.workspace }}
|
||||
python3 -c "
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
|
||||
import os
|
||||
pkl = '${{ github.workspace }}/${{ env.SMALL_PKL }}'
|
||||
size = os.path.getsize(pkl)
|
||||
targets = get_chunk_targets(pkl, size)
|
||||
chunk_file(pkl, targets)
|
||||
print(f'Chunked into {len(targets)} files')
|
||||
"
|
||||
|
||||
- name: Prepare output
|
||||
env:
|
||||
MODEL_NAME: ${{ needs.resolve.outputs.safe_model_name }}
|
||||
@@ -141,8 +128,7 @@ jobs:
|
||||
PKL_BASE="driving_tinygrad.pkl"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
|
||||
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
|
||||
cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/"
|
||||
|
||||
ONNX_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.SMALL_ONNX }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2)
|
||||
|
||||
@@ -221,19 +207,33 @@ jobs:
|
||||
raise RuntimeError('Chestnut PCIe link not ready after 10 attempts')
|
||||
"
|
||||
|
||||
- name: Chunk big pkl
|
||||
- name: Compile big warp
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
export PYTHONPATH=${{ github.workspace }}
|
||||
python3 -c "
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
|
||||
import os
|
||||
pkl = '${{ github.workspace }}/${{ env.BIG_PKL }}'
|
||||
size = os.path.getsize(pkl)
|
||||
targets = get_chunk_targets(pkl, size)
|
||||
chunk_file(pkl, targets)
|
||||
print(f'Chunked into {len(targets)} files')
|
||||
"
|
||||
export PYTHONPATH="${PYTHONPATH}:${{ github.workspace }}/tinygrad_repo:${{ github.workspace }}"
|
||||
|
||||
CHESTNUT_TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
COMPILER="${{ github.workspace }}/tinygrad_repo/examples/openpilot"
|
||||
MODELS_DIR="${{ github.workspace }}/openpilot/selfdrive/modeld/models"
|
||||
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
|
||||
|
||||
for res in $(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')"); do
|
||||
CAM_W=$(echo $res | cut -d x -f1)
|
||||
CAM_H=$(echo $res | cut -d x -f2)
|
||||
STRIDE_INFO=$(python3 -c "
|
||||
import sys
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
s,y,u,sz = get_nv12_info(int(sys.argv[1]), int(sys.argv[2]))
|
||||
print(f'{s},{y},{u},{sz}')
|
||||
" $CAM_W $CAM_H)
|
||||
WARP_PKL="${MODELS_DIR}/big_driving_warp_${res}_tinygrad.pkl"
|
||||
env ${CHESTNUT_TG_FLAGS} python3 "${COMPILER}/compile_warp.py" \
|
||||
--frame ${CAM_W},${CAM_H},${STRIDE_INFO} \
|
||||
--warp-to ${MODEL_SIZE} \
|
||||
--layout yuv420 \
|
||||
--frames 2 \
|
||||
--output "${WARP_PKL}"
|
||||
done
|
||||
|
||||
- name: Prepare output
|
||||
env:
|
||||
@@ -246,10 +246,9 @@ jobs:
|
||||
PKL_BASE="big_driving_tinygrad.pkl"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
cp "$MODELS_DIR/${PKL_BASE}".chunk* "$OUTPUT_DIR/"
|
||||
cp "$MODELS_DIR/${PKL_BASE}.chunkmanifest" "$OUTPUT_DIR/"
|
||||
cp "$MODELS_DIR/${PKL_BASE}" "$OUTPUT_DIR/"
|
||||
|
||||
PKL_LFS_SHA256=$(sha256sum "$MODELS_DIR/${PKL_BASE}" | cut -d' ' -f1)
|
||||
PKL_LFS_SHA256=$(git cat-file blob "$(git ls-files -s "${{ github.workspace }}/${{ env.BIG_PKL }}" | awk '{print $2}')" | grep '^oid sha256:' | cut -d: -f2)
|
||||
|
||||
python3 "${{ github.workspace }}/release/ci/model_generator.py" \
|
||||
--model-dir "$MODELS_DIR" \
|
||||
@@ -258,6 +257,8 @@ jobs:
|
||||
--upstream-branch "${{ needs.resolve.outputs.onnx_ref }}" \
|
||||
--onnx-sha256 "$PKL_LFS_SHA256"
|
||||
|
||||
cp "$MODELS_DIR"/big_driving_warp_* "$OUTPUT_DIR/" || true
|
||||
|
||||
echo "model-${MODEL_NAME}-${{ github.run_number }}" > "$OUTPUT_DIR/artifact_name.txt"
|
||||
|
||||
- name: Upload big model artifact
|
||||
@@ -335,6 +336,8 @@ jobs:
|
||||
--hf-defaults-path "${{ needs.resolve.outputs.hf_defaults_path }}" \
|
||||
--artifact-name "$ARTIFACT_NAME" \
|
||||
--model-dir output \
|
||||
--onnx-path "${{ needs.resolve.outputs.onnx_path }}" \
|
||||
--onnx-ref "${{ needs.resolve.outputs.onnx_ref }}" \
|
||||
--model-name "${{ needs.resolve.outputs.model_name }}" \
|
||||
--tinygrad-ref "${{ needs.resolve.outputs.tinygrad_ref }}" \
|
||||
--run-number "${{ github.run_number }}"
|
||||
@@ -358,24 +361,14 @@ jobs:
|
||||
from datetime import datetime, UTC
|
||||
|
||||
dm_dir = Path('dm_output')
|
||||
manifest = list(dm_dir.glob('*.chunkmanifest'))
|
||||
assert manifest, 'No chunkmanifest found'
|
||||
pkl_name = manifest[0].name.removesuffix('.chunkmanifest')
|
||||
num_chunks = int(manifest[0].read_text().strip())
|
||||
|
||||
chunks = []
|
||||
for i in range(num_chunks):
|
||||
chunk = dm_dir / f'{pkl_name}.chunk{i+1:02d}of{num_chunks:02d}'
|
||||
chunks.append({
|
||||
'file_name': chunk.name,
|
||||
'sha256': hashlib.sha256(chunk.read_bytes()).hexdigest()
|
||||
})
|
||||
matches = sorted(dm_dir.glob('dmonitoring_*_tinygrad.pkl'))
|
||||
assert len(matches) == 1, f'expected one DM pkl in {dm_dir}, found {matches}'
|
||||
pkl_name = matches[0].name
|
||||
|
||||
digest = hashlib.sha256()
|
||||
for c in chunks:
|
||||
with open(dm_dir / c['file_name'], 'rb') as f:
|
||||
while block := f.read(1024*1024):
|
||||
digest.update(block)
|
||||
with open(dm_dir / pkl_name, 'rb') as f:
|
||||
while block := f.read(1024*1024):
|
||||
digest.update(block)
|
||||
|
||||
metadata = {
|
||||
'bundles': [{
|
||||
@@ -389,8 +382,7 @@ jobs:
|
||||
'type': 'chunked',
|
||||
'artifact': {
|
||||
'file_name': pkl_name,
|
||||
'download_uri': {'url': '', 'sha256': digest.hexdigest()},
|
||||
'chunks': chunks
|
||||
'download_uri': {'url': '', 'sha256': digest.hexdigest()}
|
||||
}
|
||||
}]
|
||||
}]
|
||||
@@ -453,20 +445,6 @@ jobs:
|
||||
${{ github.workspace }}/${{ env.DM_ONNX }} \
|
||||
${{ github.workspace }}/${{ env.DM_PKL }}
|
||||
|
||||
- name: Chunk DM pkl
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
export PYTHONPATH=${{ github.workspace }}
|
||||
python3 -c "
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
|
||||
import os
|
||||
pkl = '${{ github.workspace }}/${{ env.DM_PKL }}'
|
||||
size = os.path.getsize(pkl)
|
||||
targets = get_chunk_targets(pkl, size)
|
||||
chunk_file(pkl, targets)
|
||||
print(f'Chunked {pkl} into {len(targets)} chunks')
|
||||
"
|
||||
|
||||
- name: Compile DM warp
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
@@ -499,8 +477,7 @@ jobs:
|
||||
- name: Prepare DM output
|
||||
run: |
|
||||
mkdir -p dm_output
|
||||
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunk* dm_output/
|
||||
cp ${{ github.workspace }}/${{ env.DM_PKL }}.chunkmanifest dm_output/
|
||||
cp ${{ github.workspace }}/${{ env.DM_PKL }} dm_output/
|
||||
cp ${{ github.workspace }}/openpilot/selfdrive/modeld/models/dm_warp_* dm_output/
|
||||
|
||||
- name: Upload DM artifact
|
||||
@@ -514,4 +491,3 @@ jobs:
|
||||
run: |
|
||||
source ${UV_PROJECT_ENVIRONMENT}/bin/activate
|
||||
PYTHONPATH=$PYTHONPATH:${{ github.workspace }}/ ${{ github.workspace }}/scripts/manage-powersave.py --enable
|
||||
|
||||
|
||||
@@ -137,6 +137,7 @@ jobs:
|
||||
needs: build_model
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
id-token: write
|
||||
contents: write
|
||||
steps:
|
||||
@@ -179,18 +180,21 @@ jobs:
|
||||
echo "artifact_name=$ARTIFACT_NAME" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download and extract model artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ${{ steps.read-artifact-name.outputs.artifact_name }}
|
||||
path: output
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
|
||||
run: |
|
||||
id=$(gh api -X GET "repos/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID/artifacts" -f name="$ARTIFACT_NAME" --jq '.artifacts[0].id')
|
||||
url=$(curl -sf -o /dev/null -w '%{redirect_url}' -H "Authorization: Bearer $GH_TOKEN" "https://api.github.com/repos/$GITHUB_REPOSITORY/actions/artifacts/$id/zip")
|
||||
sudo apt-get install -y -qq aria2 > /dev/null
|
||||
aria2c -q -x16 -s16 -k8M --max-tries=5 --file-allocation=none -o model.zip "$url" && unzip -q model.zip -d output && rm model.zip
|
||||
|
||||
- name: Create models folder
|
||||
env:
|
||||
ARTIFACT_NAME: ${{ steps.read-artifact-name.outputs.artifact_name }}
|
||||
run: |
|
||||
mkdir -p "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}"
|
||||
cp -r output/* "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/"
|
||||
rm -f "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/artifact_name.txt"
|
||||
cp output/metadata.json "local_models/${RECOMPILED_DIR}/${ARTIFACT_NAME}/"
|
||||
|
||||
- name: Upload to Hugging Face
|
||||
env:
|
||||
|
||||
+14
-17
@@ -1,5 +1,5 @@
|
||||
name: Download HF model chunks
|
||||
description: Resolve and download model chunks from HuggingFace in parallel
|
||||
name: Download HF model
|
||||
description: Resolve and download models from HuggingFace in parallel
|
||||
|
||||
inputs:
|
||||
hf_repo:
|
||||
@@ -19,7 +19,7 @@ inputs:
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Download model chunks
|
||||
- name: Download models
|
||||
shell: bash
|
||||
env:
|
||||
HF_REPO: ${{ inputs.hf_repo }}
|
||||
@@ -33,7 +33,7 @@ runs:
|
||||
resolve_chunks() {
|
||||
local HF_PATH="$1" ONNX_HASH="$2" CANONICAL="$3" DEST_DIR="$4"
|
||||
local JSON_URL="https://huggingface.co/datasets/${HF_REPO}/resolve/main/${HF_PATH}/default_models.json"
|
||||
local DEFAULTS BUNDLE ARTIFACT BASE_URL NUM_CHUNKS
|
||||
local DEFAULTS BUNDLE ARTIFACT FILE_URL BASE_URL
|
||||
DEFAULTS=$(curl -fsSL "$JSON_URL")
|
||||
if [ -n "$TINYGRAD_REF" ]; then
|
||||
BUNDLE=$(echo "$DEFAULTS" | jq --arg hash "$ONNX_HASH" --arg tg "$TINYGRAD_REF" '
|
||||
@@ -51,26 +51,23 @@ runs:
|
||||
fi
|
||||
|
||||
ARTIFACT=$(echo "$BUNDLE" | jq -r '.models[0].artifact')
|
||||
BASE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url' | sed 's|/[^/]*$||')
|
||||
NUM_CHUNKS=$(echo "$ARTIFACT" | jq -r '.chunks | length')
|
||||
FILE_URL=$(echo "$ARTIFACT" | jq -r '.download_uri.url')
|
||||
BASE_URL=$(echo "$FILE_URL" | sed 's|/[^/]*$||')
|
||||
|
||||
mkdir -p "$DEST_DIR"
|
||||
while IFS= read -r CHUNK_NAME; do
|
||||
CHUNK_IDX=$(echo "$CHUNK_NAME" | grep -oP 'chunk\K[0-9]+of[0-9]+' || true)
|
||||
if [ -z "$CHUNK_IDX" ]; then
|
||||
echo "::error::Failed to parse chunk index from: $CHUNK_NAME"
|
||||
return 1
|
||||
fi
|
||||
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${CHUNK_NAME}', safe=':/'))")
|
||||
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}.chunk${CHUNK_IDX}" >> "$DOWNLOAD_LIST"
|
||||
done < <(echo "$ARTIFACT" | jq -r '.chunks[].file_name')
|
||||
echo "$NUM_CHUNKS" > "${DEST_DIR}/${CANONICAL}.chunkmanifest"
|
||||
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${FILE_URL}', safe=':/'))")
|
||||
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${CANONICAL}" >> "$DOWNLOAD_LIST"
|
||||
|
||||
if [ "$CANONICAL" = "dmonitoring_model_tinygrad.pkl" ]; then
|
||||
for warp in dm_warp_1928x1208_tinygrad.pkl dm_warp_1344x760_tinygrad.pkl; do
|
||||
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))")
|
||||
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST"
|
||||
done
|
||||
elif [ "$CANONICAL" = "big_driving_tinygrad.pkl" ]; then
|
||||
for warp in big_driving_warp_1928x1208_tinygrad.pkl big_driving_warp_1344x760_tinygrad.pkl; do
|
||||
ENCODED_URL=$(python3 -c "import urllib.parse; print(urllib.parse.quote('${BASE_URL}/${warp}', safe=':/'))")
|
||||
printf '%s\t%s\n' "$ENCODED_URL" "${DEST_DIR}/${warp}" >> "$DOWNLOAD_LIST"
|
||||
done
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -82,7 +79,7 @@ runs:
|
||||
done
|
||||
|
||||
TOTAL=$(wc -l < "$DOWNLOAD_LIST")
|
||||
echo "Downloading $TOTAL chunks with 8 parallel connections..."
|
||||
echo "Downloading $TOTAL files with 8 parallel connections..."
|
||||
xargs -P8 -d'\n' -I{} bash -c '
|
||||
URL="${1%% *}"
|
||||
DEST="${1#* }"
|
||||
@@ -149,7 +149,17 @@ jobs:
|
||||
git lfs pull -X "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/dmonitoring_*.onnx,**/selfdrive/modeld/models/big_*.pkl,**/selfdrive/modeld/models/dmonitoring_*.pkl"
|
||||
rm -f selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/dmonitoring_*.onnx selfdrive/modeld/models/big_*.pkl selfdrive/modeld/models/dmonitoring_*.pkl
|
||||
else
|
||||
git lfs pull -I "**/selfdrive/modeld/models/big_*.onnx,**/selfdrive/modeld/models/big_*.pkl" -X ""
|
||||
sudo apt-get install -y -qq aria2 > /dev/null # Parallel ranges because gh is flaky
|
||||
LFS_URL=$(git config -f "$(git rev-parse --show-toplevel)/.lfsconfig" lfs.url)
|
||||
for f in selfdrive/modeld/models/big_*.onnx selfdrive/modeld/models/big_*.pkl; do
|
||||
grep -qs "^version https://git-lfs" "$f" || continue
|
||||
OID=$(awk -F'sha256:' '/^oid/{print $2}' "$f"); SIZE=$(awk '/^size/{print $2}' "$f")
|
||||
HREF=$(curl -fsSL --retry 3 -X POST -H "Accept: application/vnd.git-lfs+json" -H "Content-Type: application/vnd.git-lfs+json" "$LFS_URL/objects/batch" \
|
||||
-d "{\"operation\":\"download\",\"transfer\":[\"basic\"],\"objects\":[{\"oid\":\"$OID\",\"size\":$SIZE}]}" | jq -r '.objects[0].actions.download.href')
|
||||
aria2c -x 16 -s 16 -k 16M --max-tries=5 --retry-wait=5 --file-allocation=none --summary-interval=30 --allow-overwrite=true \
|
||||
-d "$(dirname "$f")" -o "$(basename "$f")" "$HREF"
|
||||
echo "$OID $f" | sha256sum -c -
|
||||
done
|
||||
find selfdrive/modeld/models -type f \( -name "*.onnx" -o -name "*.pkl" \) ! -name "big_*.onnx" ! -name "big_*.pkl" -delete
|
||||
fi
|
||||
fi
|
||||
@@ -162,6 +172,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: models-${{ env.REF }}${{ inputs.artifact_suffix }}
|
||||
compression-level: 0
|
||||
path: |
|
||||
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.onnx
|
||||
${{ github.workspace }}/openpilot/openpilot/selfdrive/modeld/models/*.pkl
|
||||
@@ -224,11 +235,11 @@ jobs:
|
||||
MODEL_SIZE=$(python3 -c "from openpilot.common.transformations.model import MEDMODEL_INPUT_SIZE as s; print(f'{s[0]}x{s[1]}')")
|
||||
CAMERA_RES=$(python3 -c "from openpilot.common.transformations.camera import _ar_ox_fisheye as a, _os_fisheye as o; print(f'{a.width}x{a.height} {o.width}x{o.height}')")
|
||||
|
||||
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 JIT_BATCH_SIZE=0 OPENPILOT_HACKS=1"
|
||||
TG_FLAGS_QCOM="DEV=QCOM IMAGE=1 FLOAT16=1 NOLOCALS=1 OPENPILOT_HACKS=1"
|
||||
if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then
|
||||
echo "CHESTNUT build"
|
||||
export CHESTNUT=1
|
||||
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 JIT_BATCH_SIZE=0 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
TG_FLAGS="DEBUG=1 DEV=USB+AMD:LLVM FRAME_DEV=CPU FLOAT16=1 GMMU=0 TC_OPT=2 TC_MIN_GLOBALS=32"
|
||||
OUTPUT_PKL="${{ env.MODELS_DIR }}/big_driving_tinygrad.pkl"
|
||||
else
|
||||
echo "QCOM build"
|
||||
@@ -240,14 +251,15 @@ jobs:
|
||||
|
||||
if [ -f "${{ env.MODELS_DIR }}/new_arch.txt" ]; then
|
||||
echo "Detected new arch"
|
||||
DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" -print -quit)
|
||||
if [ "${{ inputs.target_hardware }}" == "chestnut" ]; then
|
||||
DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "big_*driving*.onnx" -print -quit)
|
||||
else
|
||||
DOWNLOADED_ONNX=$(find "${{ env.MODELS_DIR }}" -maxdepth 1 -name "*driving*.onnx" ! -name "big_*" -print -quit)
|
||||
fi
|
||||
if [ -n "$DOWNLOADED_ONNX" ]; then
|
||||
echo "Compiling ONNX: $DOWNLOADED_ONNX"
|
||||
env ${TG_FLAGS} taskset -c 7 python3 openpilot/sunnypilot/modeld_v2/compile_modeld_v2.py \
|
||||
"$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --out-of-band --benchmark-runs 1
|
||||
|
||||
echo "Chunking pkl"
|
||||
python3 -c "from openpilot.common.file_chunker import chunk_file, get_chunk_targets; import os; p='$OUTPUT_PKL'; chunk_file(p, get_chunk_targets(p, os.path.getsize(p)))"
|
||||
"$DOWNLOADED_ONNX" "$OUTPUT_PKL" --device-input "*" --benchmark-runs 1
|
||||
else
|
||||
echo "Failed to find downloaded ONNX."
|
||||
exit 1
|
||||
@@ -317,8 +329,7 @@ jobs:
|
||||
# Copy the model files
|
||||
rsync -avm \
|
||||
--include='*.dlc' \
|
||||
--include='*.chunk*' \
|
||||
--include='*.chunkmanifest' \
|
||||
--include='/${{ inputs.target_hardware == 'chestnut' && 'big_' || '' }}driving_tinygrad.pkl' \
|
||||
--exclude='*' \
|
||||
--delete-excluded \
|
||||
--chown=comma:comma \
|
||||
@@ -340,6 +351,7 @@ jobs:
|
||||
with:
|
||||
name: model-${{ env.MODEL_NAME }}${{ inputs.artifact_suffix }}-${{ github.run_number }}
|
||||
path: ${{ env.OUTPUT_DIR }}
|
||||
compression-level: 0
|
||||
|
||||
- name: Upload artifact name file
|
||||
uses: actions/upload-artifact@v4
|
||||
|
||||
@@ -503,8 +503,8 @@ jobs:
|
||||
mkdir -p ${{ env.OUTPUT_DIR }}
|
||||
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
|
||||
|
||||
- name: Download model chunks from HF
|
||||
uses: ./.github/workflows/download-hf-model-chunks
|
||||
- name: Download models from HF
|
||||
uses: ./.github/workflows/download-hf-model
|
||||
with:
|
||||
hf_repo: sunnypilot/sunnypilot_models_v1
|
||||
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
|
||||
@@ -573,8 +573,8 @@ jobs:
|
||||
mkdir -p ${{ env.OUTPUT_DIR }}
|
||||
tar xzf prebuilt.tar.gz -C ${{ env.OUTPUT_DIR }}
|
||||
|
||||
- name: Download model chunks from HF
|
||||
uses: ./.github/workflows/download-hf-model-chunks
|
||||
- name: Download models from HF
|
||||
uses: ./.github/workflows/download-hf-model
|
||||
with:
|
||||
hf_repo: sunnypilot/sunnypilot_models_v1
|
||||
dest_dir: ${{ env.OUTPUT_DIR }}/openpilot/selfdrive/modeld/models
|
||||
|
||||
@@ -47,38 +47,22 @@ jobs:
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Download Model Chunks in Parallel
|
||||
- name: Download model
|
||||
env:
|
||||
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
||||
MODEL_URL: ${{ matrix.artifact.download_uri.url }}
|
||||
MODEL_FILE: ${{ matrix.artifact.file_name }}
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p /tmp/model_chunks
|
||||
echo '${{ toJson(matrix.artifact.chunks) }}' > chunks.json
|
||||
|
||||
BASE_URL="${{ matrix.artifact.download_uri.url }}"
|
||||
export BASE_DIR=$(dirname "$BASE_URL")
|
||||
|
||||
python3 -c '
|
||||
import json, os
|
||||
with open("chunks.json") as f:
|
||||
chunks = json.load(f)
|
||||
manifest_path = f"/tmp/model_chunks/${{ matrix.artifact.file_name }}.chunkmanifest"
|
||||
with open(manifest_path, "w") as f:
|
||||
f.write(str(len(chunks)))
|
||||
base_dir = os.environ["BASE_DIR"]
|
||||
hf_token = os.environ.get("HF_TOKEN", "")
|
||||
with open("/tmp/curl_config.txt", "w") as f:
|
||||
for c in chunks:
|
||||
fn = c["file_name"]
|
||||
f.write(f"url = \"{base_dir}/{fn}\"\noutput = \"/tmp/model_chunks/{fn}\"\n")
|
||||
if hf_token:
|
||||
f.write(f"header = \"Authorization: Bearer {hf_token}\"\n")
|
||||
'
|
||||
curl -Z --parallel-immediate --parallel-max 12 --retry 3 --retry-all-errors -s -S -f -L -K /tmp/curl_config.txt
|
||||
mkdir -p /tmp/models
|
||||
AUTH=()
|
||||
if [ -n "$HF_TOKEN" ]; then AUTH=(-H "Authorization: Bearer $HF_TOKEN"); fi
|
||||
curl --retry 3 --retry-all-errors -s -S -f -L "${AUTH[@]}" -o "/tmp/models/$MODEL_FILE" "$MODEL_URL"
|
||||
|
||||
- name: Run Model Compatibility Test
|
||||
env:
|
||||
MODEL_BASE_NAME: ${{ matrix.artifact.file_name }}
|
||||
MODEL_CHUNK_DIR: "/tmp/model_chunks"
|
||||
MODEL_DIR: "/tmp/models"
|
||||
run: |
|
||||
export PYTHONPATH="$GITHUB_WORKSPACE/tinygrad_repo:$GITHUB_WORKSPACE:$PYTHONPATH"
|
||||
python3 -m pip uninstall -y tinygrad || uv pip uninstall -y tinygrad --system || true
|
||||
|
||||
@@ -162,10 +162,6 @@ inline static std::unordered_map<std::string, ParamKeyAttributes> keys = {
|
||||
{"DeviceBootMode", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
{"DevUIInfo", {PERSISTENT | BACKUP, INT, "0"}},
|
||||
{"EnableCopyparty", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"SunnydriveEnabled", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"SunnydriveAutoPairPrompt", {PERSISTENT | BACKUP, BOOL, "1"}},
|
||||
{"SunnydrivePairedClients", {PERSISTENT, JSON}},
|
||||
{"SunnydrivePairingRequests", {CLEAR_ON_MANAGER_START, JSON}},
|
||||
{"EnableGithubRunner", {PERSISTENT | BACKUP, BOOL}},
|
||||
{"GreenLightAlert", {PERSISTENT | BACKUP, BOOL, "0"}},
|
||||
{"GithubRunnerSufficientVoltage", {CLEAR_ON_MANAGER_START , BOOL}},
|
||||
|
||||
@@ -55,7 +55,7 @@ def chestnut_action(command):
|
||||
def compile_model(onnx_path, pkl_path):
|
||||
onnx_path, target_pkl_path = File(onnx_path).abspath, File(pkl_path).abspath
|
||||
cmd = (f'{tg_flags} {mac_brew_string} {taskset}python3 "{compiler}/compile_onnx.py" '
|
||||
f'"{onnx_path}" "{target_pkl_path}" --device-input "*" --out-of-band --benchmark-runs 1')
|
||||
f'"{onnx_path}" "{target_pkl_path}" --out-of-band --benchmark-runs 1')
|
||||
lenv.Command(
|
||||
target_pkl_path,
|
||||
tinygrad_files + [onnx_path, Value(cmd)],
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import io
|
||||
import pickle
|
||||
import struct
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from openpilot.common.file_chunker import get_manifest_path
|
||||
from openpilot.common.hardware import AGNOS
|
||||
from openpilot.common.hardware.usb import CHESTNUT_USB_PRODUCT, USB_DEVICES_PATH, is_chestnut_usb_id
|
||||
|
||||
MODELS_DIR = Path(__file__).resolve().parent / 'models'
|
||||
@@ -13,15 +15,14 @@ def modeld_pkl_path(chestnut: bool):
|
||||
prefix = 'big_' if chestnut else ''
|
||||
return MODELS_DIR / f'{prefix}driving_tinygrad.pkl'
|
||||
|
||||
def load_oob(f):
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
def buffers():
|
||||
while (h := f.read(8)):
|
||||
pb = pickle.PickleBuffer(bytearray(struct.unpack('<q', h)[0]))
|
||||
if f.readinto(pb) != pb.raw().nbytes:
|
||||
raise EOFError("incomplete model buffer")
|
||||
yield pb
|
||||
return pickle.load(io.BytesIO(opcodes), buffers=buffers())
|
||||
def load_oob(f, chestnut=False):
|
||||
import numpy as np
|
||||
from tinygrad import Context, Tensor
|
||||
device = 'USB+AMD:LLVM' if chestnut else 'QCOM' if AGNOS else 'METAL' if sys.platform == 'darwin' else 'CPU:LLVM'
|
||||
with Context(DEV=device):
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
buffers = Tensor(np.frombuffer(f.read(), np.uint8)).realize().uop.buffer
|
||||
return type("_Unpickler", (pickle.Unpickler,), {"persistent_load": lambda self, pid: buffers.view(*pid)})(io.BytesIO(opcodes)).load()
|
||||
|
||||
def chestnut_present() -> bool:
|
||||
for d in USB_DEVICES_PATH.glob("*"):
|
||||
|
||||
@@ -134,8 +134,8 @@ class FrameMeta:
|
||||
|
||||
|
||||
def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor:
|
||||
view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view)).reshape(shape)
|
||||
view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view, dtype)).reshape(shape)
|
||||
|
||||
|
||||
class ModelState(ModelStateBase):
|
||||
@@ -143,7 +143,7 @@ class ModelState(ModelStateBase):
|
||||
|
||||
def __init__(self, cam_w: int, cam_h: int, chestnut: bool):
|
||||
ModelStateBase.__init__(self)
|
||||
jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut)))
|
||||
jits = load_oob(open_file_chunked(modeld_pkl_path(chestnut)), chestnut)
|
||||
self.model_device = jits['input_specs']['new_img'][2]
|
||||
self.input_shapes = {name: (shape, np.dtype(dtype)) for name, (shape, dtype, _) in jits['input_specs'].items()}
|
||||
self.state_pairs = {name: f'next_{name}' for name in self.input_shapes if f'next_{name}' in jits['metadata']['output_shapes']}
|
||||
@@ -230,7 +230,8 @@ def main(demo=False):
|
||||
|
||||
CHESTNUT = chestnut_present() and chestnut_compiled()
|
||||
if CHESTNUT:
|
||||
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
|
||||
from tinygrad.runtime.ops_amd import AMDDevice
|
||||
AMDDevice.wait_timeout_ms = 3000
|
||||
params = Params()
|
||||
params.put_bool("ChestnutLoading", CHESTNUT)
|
||||
params.remove("ChestnutActive")
|
||||
|
||||
@@ -154,7 +154,7 @@ class SelfdriveD(CruiseHelper):
|
||||
self.state_machine = StateMachine()
|
||||
self.rk = Ratekeeper(100, print_delay_threshold=None)
|
||||
|
||||
self.ignored_processes = {'mapd', 'sunnydrived'}
|
||||
self.ignored_processes = {'mapd', }
|
||||
|
||||
# Determine startup event
|
||||
is_remote = build_metadata.openpilot.comma_remote or build_metadata.openpilot.sunnypilot_remote
|
||||
|
||||
@@ -4,10 +4,6 @@ Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import math
|
||||
|
||||
import pyray as rl
|
||||
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings import settings as OP
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.settings import SettingsBigButton
|
||||
from openpilot.selfdrive.ui.mici.layouts.settings.device import DeviceLayoutMici
|
||||
@@ -15,7 +11,6 @@ from openpilot.selfdrive.ui.mici.widgets.button import BigCircleButton
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog, BigDialog
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnylink import SunnylinkLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.models import ModelsLayoutMici
|
||||
from openpilot.selfdrive.ui.sunnypilot.mici.layouts.sunnydrive import SunnydriveLayoutMici
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.system.ui.lib.application import gui_app, FontWeight
|
||||
from openpilot.system.ui.lib.multilang import tr
|
||||
@@ -34,40 +29,6 @@ class SunnylinkBigButton(SettingsBigButton):
|
||||
return 56
|
||||
|
||||
|
||||
def sunnydrive_icon(width, height):
|
||||
"""The sunnydrive icon (a sun over a car roof), drawn once in code so it needs no image asset.
|
||||
Drawn at 4x on a 336x312 grid, then scaled down for smooth edges."""
|
||||
s = 4
|
||||
img = rl.gen_image_color(336 * s, 312 * s, rl.BLANK)
|
||||
p = rl.ffi.addressof(img)
|
||||
|
||||
def dot(x, y, r, color):
|
||||
rl.image_draw_circle(p, int(x * s), int(y * s), int(r * s), color)
|
||||
|
||||
def roof(r, color): # a shallow circular arc, apex under the sun, ends reaching down beside the wheels
|
||||
for i in range(-52, 53):
|
||||
t = math.radians(i)
|
||||
dot(168 + 194 * math.sin(t), 399 - 194 * math.cos(t), r, color)
|
||||
|
||||
dot(168, 100, 100, rl.WHITE) # the sun
|
||||
roof(28, rl.BLANK) # a gap between the sun and the roof
|
||||
roof(14, rl.WHITE) # the roof
|
||||
rl.image_draw_rectangle(p, 70 * s, 284 * s, 196 * s, 28 * s, rl.WHITE) # the car's body between its wheels
|
||||
for x in (84, 252):
|
||||
dot(x, 300, 18, rl.WHITE)
|
||||
dot(x, 300, 11, rl.BLANK) # wheel
|
||||
rl.image_resize(p, width, height)
|
||||
texture = rl.load_texture_from_image(img)
|
||||
rl.unload_image(img)
|
||||
rl.set_texture_filter(texture, rl.TextureFilter.TEXTURE_FILTER_BILINEAR)
|
||||
return texture
|
||||
|
||||
|
||||
class SunnydriveBigButton(SettingsBigButton):
|
||||
def _get_label_font_size(self):
|
||||
return 54 # "sunnydrive" wraps under the icon at 64
|
||||
|
||||
|
||||
class SettingsLayoutSP(OP.SettingsLayout):
|
||||
def __init__(self):
|
||||
OP.SettingsLayout.__init__(self)
|
||||
@@ -89,10 +50,6 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
models_btn = SettingsBigButton(tr("models"), "", gui_app.texture("../../sunnypilot/selfdrive/assets/offroad/icon_models.png", ICON_SIZE, ICON_SIZE))
|
||||
models_btn.set_click_callback(lambda: gui_app.push_widget(models_panel))
|
||||
|
||||
sunnydrive_panel = SunnydriveLayoutMici()
|
||||
sunnydrive_btn = SunnydriveBigButton(tr("sunnydrive"), "", sunnydrive_icon(64, 59))
|
||||
sunnydrive_btn.set_click_callback(lambda: gui_app.push_widget(sunnydrive_panel))
|
||||
|
||||
# onroad: enable button sits at the front (left of toggles)
|
||||
self._enable_offroad_btn_onroad = BigCircleButton(self.icon_offroad_enable, red=True)
|
||||
self._enable_offroad_btn_onroad.set_click_callback(lambda: self._handle_always_offroad(True))
|
||||
@@ -115,7 +72,6 @@ class SettingsLayoutSP(OP.SettingsLayout):
|
||||
# front slots (only one ever visible at a time): exit-always-offroad, then enable-onroad
|
||||
items.insert(0, self._enable_offroad_btn_onroad)
|
||||
items.insert(0, self._disable_offroad_btn)
|
||||
items.insert(0, sunnydrive_btn)
|
||||
# end slot: enable-offroad (right of developer)
|
||||
items.append(self._enable_offroad_btn_offroad)
|
||||
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
import time
|
||||
|
||||
from openpilot.selfdrive.ui.mici.widgets.button import BigButton, BigParamControl
|
||||
from openpilot.selfdrive.ui.mici.widgets.dialog import BigConfirmationDialog
|
||||
from openpilot.selfdrive.ui.ui_state import ui_state
|
||||
from openpilot.sunnypilot.sunnydrive import pairing
|
||||
from openpilot.system.ui.lib.application import gui_app
|
||||
from openpilot.system.ui.widgets.scroller import NavScroller
|
||||
|
||||
|
||||
class SunnydriveLayoutMici(NavScroller):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._pending_id = ""
|
||||
self._prompted_id = ""
|
||||
self._approval_dialog = None
|
||||
self._opened_for_prompt = False
|
||||
self._next_prompt_check = 0.0
|
||||
self._enable = BigParamControl("enabled", "SunnydriveEnabled")
|
||||
self._pairing_prompts = BigParamControl("allow pairing", "SunnydriveAutoPairPrompt")
|
||||
self._unpair = BigButton("paired phones", "none")
|
||||
self._unpair.set_click_callback(self._confirm_unpair_all)
|
||||
self._scroller.add_widgets([self._enable, self._pairing_prompts, self._unpair])
|
||||
gui_app.add_nav_stack_tick(self._pairing_tick)
|
||||
|
||||
def _update_state(self):
|
||||
super()._update_state()
|
||||
self._enable.refresh()
|
||||
self._pairing_prompts.refresh()
|
||||
requests = pairing.pairing_requests()
|
||||
pending = next(((request_id, request) for request_id, request in reversed(list(requests.items())) if request.get("status") == "pending"), None)
|
||||
self._pending_id = pending[0] if pending else ""
|
||||
count = len(pairing.paired_clients())
|
||||
self._unpair.set_value(f"{count} paired" if count else "none")
|
||||
self._unpair.set_visible(count > 0)
|
||||
|
||||
def _pairing_tick(self):
|
||||
if time.monotonic() < self._next_prompt_check:
|
||||
return
|
||||
self._next_prompt_check = time.monotonic() + 0.5
|
||||
if not ui_state.params.get_bool("SunnydriveAutoPairPrompt"):
|
||||
return
|
||||
requests = reversed(list(pairing.pairing_requests().items()))
|
||||
pending = next(((request_id, request) for request_id, request in requests if request.get("status") == "pending"), None)
|
||||
answered = not pending or pending[0] == self._prompted_id # approved, expired, or its slide was dismissed
|
||||
if self._opened_for_prompt and answered and gui_app.get_active_widget() is self: # back to whatever was showing
|
||||
self._opened_for_prompt = False
|
||||
self.dismiss()
|
||||
if pending and self._prompted_id != pending[0]:
|
||||
self._pending_id = self._prompted_id = pending[0]
|
||||
if self._approval_dialog and gui_app.widget_in_stack(self._approval_dialog):
|
||||
self._approval_dialog.dismiss(self._confirm_pending)
|
||||
else:
|
||||
self._confirm_pending()
|
||||
|
||||
def _confirm_pending(self):
|
||||
if not self._pending_id or (self._approval_dialog and gui_app.widget_in_stack(self._approval_dialog)):
|
||||
return
|
||||
request_id = self._pending_id
|
||||
icon = gui_app.texture("icons_mici/settings/device/pair.png", 64, 64)
|
||||
if not gui_app.widget_in_stack(self):
|
||||
# The screen right under a dialog keeps handling touches, and onroad that's the driving view,
|
||||
# whose swipe-left bookmark is the same gesture as this slider. Showing this panel underneath keeps it out.
|
||||
gui_app.push_widget(self)
|
||||
self._opened_for_prompt = True
|
||||
self._approval_dialog = BigConfirmationDialog("slide to approve phone", icon, lambda: pairing.approve_request(request_id))
|
||||
gui_app.push_widget(self._approval_dialog)
|
||||
|
||||
def _confirm_unpair_all(self):
|
||||
icon = gui_app.texture("icons_mici/settings/network/new/trash.png", 54, 64)
|
||||
gui_app.push_widget(BigConfirmationDialog("slide to unpair all phones", icon, lambda: pairing.unpair(), red=True))
|
||||
@@ -7,14 +7,12 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import atexit
|
||||
import codecs
|
||||
import gc
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import os
|
||||
import pickle
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from functools import partial
|
||||
@@ -22,8 +20,8 @@ from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets, open_file_chunked
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob, load_oob
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, read_parts, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
|
||||
import openpilot.sunnypilot.modeld_v2.stock_dependencies as stock
|
||||
from openpilot.system.camerad.cameras.nv12_info import get_nv12_info
|
||||
|
||||
@@ -55,16 +53,6 @@ def _patch_tinygrad_fetch_fw():
|
||||
pass
|
||||
_patch_tinygrad_fetch_fw()
|
||||
|
||||
def _patch_ops_serialization():
|
||||
try:
|
||||
from tinygrad.uop.ops import Ops
|
||||
def __reduce_ex__(self, proto):
|
||||
return (getattr, (self.__class__, self.name))
|
||||
Ops.__reduce_ex__ = __reduce_ex__
|
||||
except ImportError:
|
||||
pass
|
||||
_patch_ops_serialization()
|
||||
|
||||
MODEL_TYPES = ('vision_policy', 'supercombo', 'vision_multi_policy')
|
||||
WARP_INPUTS = ['tfm', 'big_tfm']
|
||||
POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs']
|
||||
@@ -72,6 +60,7 @@ POLICY_INPUTS = ['img_q', 'big_img_q', 'feat_q', 'desire_q', 'packed_npy_inputs'
|
||||
class MetadataOnnxPBParser(OnnxPBParser):
|
||||
def _parse_ModelProto(self) -> dict:
|
||||
obj: dict[str, Any] = {"graph": {"input": [], "output": []}, "metadata_props": []}
|
||||
self.opset_imports = {}
|
||||
for fid, wire_type in self._parse_message(self.reader.len):
|
||||
match fid:
|
||||
case 7:
|
||||
@@ -150,6 +139,10 @@ def get_policy_npy_shapes(input_shapes: dict, is_supercombo: bool = False) -> tu
|
||||
return shapes, sizes
|
||||
|
||||
|
||||
def host_view(tensor: Tensor) -> np.ndarray: # QCOM -> CPU round trip of tfm takes 5ms longer than host memory view
|
||||
return np.frombuffer(tensor._buffer()._host_mv(), dtype=np.float32).reshape(tensor.shape)
|
||||
|
||||
|
||||
def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = Device.DEFAULT,
|
||||
is_supercombo: bool = False) -> tuple[dict, dict]:
|
||||
road_key, _ = _detect_vision_keys(input_shapes)
|
||||
@@ -185,7 +178,7 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D
|
||||
'big_img_q': Tensor(np.zeros(img_buf_shape, dtype=np.uint8), device=device).contiguous().realize(),
|
||||
'desire_q': Tensor(np.zeros((frame_skip * desire_shape[1], desire_shape[0], desire_shape[2]),
|
||||
dtype=np.float32), device=device).contiguous().realize(),
|
||||
'packed_npy_inputs': Tensor(packed_npy_inputs, device=device if os.getenv('CHESTNUT') else 'NPY').realize(),
|
||||
'packed_npy_inputs': Tensor(packed_npy_inputs, device=device).realize(),
|
||||
}
|
||||
|
||||
if features_buffer:
|
||||
@@ -197,9 +190,11 @@ def generate_queues_and_npy(input_shapes: dict, frame_skip: int, device: str = D
|
||||
for key in ('tfm', 'big_tfm'):
|
||||
if key not in npy_arrays:
|
||||
npy_arrays[key] = np.eye(3, dtype=np.float32)
|
||||
queues[key] = Tensor(npy_arrays[key], device='NPY').realize()
|
||||
queues[key] = Tensor(npy_arrays[key], device=device).realize()
|
||||
|
||||
return queues, npy_arrays
|
||||
packed = host_view(queues['packed_npy_inputs'])
|
||||
npy_arrays |= {k: v.reshape(s) for (k, s), v in zip(shapes.items(), np.split(packed, split_indices) if sizes else [], strict=True)}
|
||||
return queues, npy_arrays | {k: host_view(queues[k]) for k in ('tfm', 'big_tfm')}
|
||||
|
||||
|
||||
def make_split_input_queues(vision_input_shapes: dict, policy_input_shapes: dict,
|
||||
@@ -221,8 +216,8 @@ def make_warp_queues(device=Device.DEFAULT):
|
||||
'tfm': np.zeros((3, 3), dtype=np.float32),
|
||||
'big_tfm': np.zeros((3, 3), dtype=np.float32),
|
||||
}
|
||||
queues = {k: Tensor(v, device='NPY').realize() for k, v in npy.items()}
|
||||
return queues, npy
|
||||
queues = {k: Tensor(v, device=device).realize() for k, v in npy.items()}
|
||||
return queues, {k: host_view(v) for k, v in queues.items()}
|
||||
|
||||
|
||||
def make_run_policy(vision_runner, policy_runners: list, features_slice: slice, frame_skip: int, input_shapes: dict):
|
||||
@@ -282,8 +277,19 @@ def make_run_policy(vision_runner, policy_runners: list, features_slice: slice,
|
||||
return run_policy
|
||||
|
||||
|
||||
class _CheckFrameRecorder:
|
||||
def __init__(self, jit):
|
||||
self.jit, self.frame = jit, None
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
if self.frame is not None or args or self.jit.cnt < 2:
|
||||
return self.jit(*args, **kwargs)
|
||||
self.frame, result = capture_frame(self.jit, kwargs, None)
|
||||
return result
|
||||
|
||||
|
||||
def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_random_inputs=None,
|
||||
benchmark_runs: int = 1, clear_refs=None, return_loaded: bool = True):
|
||||
benchmark_runs: int = 1, clear_refs=None):
|
||||
if callable(input_keys_or_make_inputs) and make_queues is None:
|
||||
fn = jit_or_fn
|
||||
make_inputs = input_keys_or_make_inputs
|
||||
@@ -334,22 +340,23 @@ def compile_jit(jit_or_fn, input_keys_or_make_inputs, make_queues=None, make_ran
|
||||
|
||||
print('capture + replay')
|
||||
gc.collect()
|
||||
test_val = run_eval(jit, 42, 3)
|
||||
print(f'pickle round trip ({benchmark_runs} runs per seed)')
|
||||
with tempfile.TemporaryFile(dir=".") as f:
|
||||
dump_oob(jit, f)
|
||||
if clear_refs:
|
||||
clear_refs()
|
||||
gc.collect()
|
||||
f.seek(0)
|
||||
loaded_jit = load_oob(f)
|
||||
recorder = _CheckFrameRecorder(jit)
|
||||
with record_kernel_linear() as recorded:
|
||||
test_val = run_eval(recorder, 42, 3)
|
||||
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
|
||||
part = serialize_jit(jit, recorded[0], recorder.frame)
|
||||
print(f'plain artifact round trip ({benchmark_runs} runs per seed)')
|
||||
if clear_refs:
|
||||
clear_refs()
|
||||
gc.collect()
|
||||
loaded_jit = PlainJit(part[0], memoryview(part[1]))
|
||||
|
||||
for seed in (42, 43):
|
||||
reference = test_val if seed == 42 else run_eval(jit_or_fn, seed, 1)
|
||||
actual = run_eval(loaded_jit, seed, benchmark_runs)
|
||||
for ref, val in zip(reference, actual, strict=True):
|
||||
np.testing.assert_array_equal(ref, val)
|
||||
return loaded_jit if return_loaded else jit
|
||||
return part
|
||||
|
||||
|
||||
def _parse_size(size_str: str) -> tuple[int, int]:
|
||||
@@ -357,16 +364,6 @@ def _parse_size(size_str: str) -> tuple[int, int]:
|
||||
return int(width), int(height)
|
||||
|
||||
|
||||
def read_file_chunked_to_disk(path):
|
||||
if not path:
|
||||
return None
|
||||
tmp_path = f'{path}.unchunked'
|
||||
with open(tmp_path, 'wb') as f, open_file_chunked(path) as src:
|
||||
shutil.copyfileobj(src, f)
|
||||
atexit.register(lambda: os.path.exists(tmp_path) and os.remove(tmp_path))
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _load_policy_runners(args: argparse.Namespace) -> tuple[list, list]:
|
||||
runners, keys = [], []
|
||||
for name, onnx_arg in [('policy', args.policy_onnx), ('off_policy', args.off_policy_onnx), ('on_policy', args.on_policy_onnx)]:
|
||||
@@ -387,10 +384,7 @@ def _compile_warp_resolution_worker(cam_w, cam_h, model_w, model_h, benchmark_ru
|
||||
warp = None
|
||||
compiled_jit = compile_jit(warp, WARP_INPUTS, make_warp_queues, make_random_inputs=make_random_warp_inputs,
|
||||
benchmark_runs=benchmark_runs, clear_refs=cleanup_warp)
|
||||
|
||||
result_data = (compiled_jit, Device.DEFAULT)
|
||||
with open(result_path, "wb") as f:
|
||||
dump_oob(result_data, f)
|
||||
write_bundle(result_path, {(cam_w, cam_h): compiled_jit}, {'device': Device.DEFAULT})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -421,12 +415,6 @@ if __name__ == "__main__":
|
||||
model_w, model_h = args.model_size
|
||||
output_data = {}
|
||||
|
||||
args.vision_onnx = read_file_chunked_to_disk(args.vision_onnx)
|
||||
args.policy_onnx = read_file_chunked_to_disk(args.policy_onnx)
|
||||
args.off_policy_onnx = read_file_chunked_to_disk(args.off_policy_onnx)
|
||||
args.on_policy_onnx = read_file_chunked_to_disk(args.on_policy_onnx)
|
||||
args.supercombo_onnx = read_file_chunked_to_disk(args.supercombo_onnx)
|
||||
|
||||
is_unified_supercombo = False
|
||||
if args.model_type == 'supercombo':
|
||||
assert args.supercombo_onnx
|
||||
@@ -449,8 +437,10 @@ if __name__ == "__main__":
|
||||
frame_copy_size=frame_copy_size)
|
||||
warp = stock.make_warp(nv12, model_w, model_h)
|
||||
run_model_jit = TinyJit(stock.make_run_model(warp, run_policy, model_metadata, frame_copy_size), prune=True)
|
||||
compiled_jit = compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues, benchmark_runs=args.benchmark_runs, return_loaded=False)
|
||||
output_data[(cam_w, cam_h)] = compiled_jit
|
||||
write_bundle(part_path := f"{args.output}.{cam_w}x{cam_h}", {(cam_w, cam_h): compile_jit(run_model_jit, stock.MODELD_INPUTS, make_model_queues,
|
||||
benchmark_runs=args.benchmark_runs)})
|
||||
output_data[(cam_w, cam_h)] = read_parts(part_path)[0][(cam_w, cam_h)]
|
||||
os.remove(part_path)
|
||||
gc.collect()
|
||||
del model_runner, run_policy
|
||||
gc.collect()
|
||||
@@ -518,18 +508,12 @@ if __name__ == "__main__":
|
||||
|
||||
tmp_files.append((cam_w, cam_h, tmp_res.name))
|
||||
for cam_w, cam_h, tmp_name in tmp_files:
|
||||
with open(tmp_name, "rb") as f:
|
||||
compiled_jit, dev_name = load_oob(f)
|
||||
output_data[(cam_w, cam_h)] = compiled_jit
|
||||
output_data['input_devices']['warp'] = dev_name
|
||||
warp_parts, warp_extras = read_parts(tmp_name)
|
||||
output_data[(cam_w, cam_h)] = warp_parts[(cam_w, cam_h)]
|
||||
output_data['input_devices']['warp'] = warp_extras['device']
|
||||
os.remove(tmp_name)
|
||||
output_data['metadata']['warp_dev'] = Device.DEFAULT
|
||||
|
||||
with open(args.output, "wb") as file:
|
||||
dump_oob(output_data, file)
|
||||
|
||||
pkl_size = os.path.getsize(args.output)
|
||||
print(f"Saved combined JIT to {args.output} ({pkl_size / 1e6:.2f} MB)")
|
||||
chunk_targets = get_chunk_targets(args.output, pkl_size)
|
||||
chunk_file(args.output, chunk_targets)
|
||||
print(f"Chunked into {len(chunk_targets) - 1} file(s)")
|
||||
parts = {key: output_data.pop(key) for key in [key for key, value in output_data.items() if isinstance(value, tuple)]}
|
||||
write_bundle(args.output, parts, output_data)
|
||||
print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(parts)} JITs)")
|
||||
|
||||
@@ -1,11 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
import runpy
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import _patch_ops_serialization
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, write_bundle
|
||||
from openpilot.sunnypilot.modeld_v2.plain_artifact_compiler import capture_frame, record_kernel_linear, serialize_jit
|
||||
|
||||
|
||||
def initialize_check_inputs(arrays: dict):
|
||||
rng = np.random.default_rng(7)
|
||||
for value in arrays.values():
|
||||
value[...] = rng.standard_normal(value.shape) if value.dtype.kind == "f" else rng.integers(0, 2 if value.dtype == np.bool_ else 16, value.shape)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_patch_ops_serialization()
|
||||
upstream_script = os.path.join(os.environ.get("TINYGRAD_PATH", "tinygrad_repo"), "examples/openpilot/compile_onnx.py")
|
||||
runpy.run_path(upstream_script, run_name="__main__")
|
||||
sys.path.insert(0, os.environ.get("TINYGRAD_PATH", "tinygrad_repo"))
|
||||
from examples.openpilot.compile_onnx import onnx_metadata
|
||||
from examples.openpilot.helpers import allocate_inputs, benchmark
|
||||
from tinygrad import Device, Tensor, TinyJit
|
||||
from tinygrad.dtype import _to_np_dtype
|
||||
from tinygrad.nn.onnx import OnnxRunner
|
||||
|
||||
parser = argparse.ArgumentParser(description="Compile a native-architecture ONNX model into a plain tinygrad artifact")
|
||||
parser.add_argument('onnx')
|
||||
parser.add_argument('output')
|
||||
parser.add_argument('--device-input', action='append', default=[])
|
||||
parser.add_argument('--benchmark-runs', type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
|
||||
runner, (properties, output_shapes) = OnnxRunner(args.onnx), onnx_metadata(args.onnx)
|
||||
device_inputs = runner.graph_inputs.keys() if '*' in args.device_input else args.device_input
|
||||
metadata = {'metadata': properties} | {f'{kind}_shapes': {name: tuple(d if isinstance(d, int) else 0 for d in shape) for name, shape in shapes.items()}
|
||||
for kind, shapes in [('input', {name: spec.shape for name, spec in runner.graph_inputs.items()}),
|
||||
('output', output_shapes)]}
|
||||
input_specs = {name: (tuple(s if isinstance(s, int) else 1 for s in spec.shape), np.dtype(_to_np_dtype(spec.dtype)).str,
|
||||
Device.DEFAULT if name in device_inputs else 'NPY') for name, spec in runner.graph_inputs.items()}
|
||||
|
||||
def model(inputs):
|
||||
return {name: value.contiguous() for name, value in runner({name: value.to(Device.DEFAULT) for name, value in inputs.items()}).items()}
|
||||
output_specs = {name: (value.shape, np.dtype(_to_np_dtype(value.dtype)).name, Device.DEFAULT) for name, value in model(allocate_inputs(input_specs)).items()}
|
||||
|
||||
@TinyJit(prune=True)
|
||||
def jit(output_buffers, **inputs):
|
||||
Tensor.realize(*(output_buffers[name].assign(value) for name, value in model(inputs).items()))
|
||||
|
||||
check_inputs = allocate_inputs(input_specs, initialize_check_inputs) | {'output_buffers': allocate_inputs(output_specs)}
|
||||
with record_kernel_linear() as recorded:
|
||||
for i in range(2 + args.benchmark_runs):
|
||||
benchmark(jit, cb=lambda t, i=i: print(f" [{i + 1}/{2 + args.benchmark_runs}] {t * 1e3:.2f} ms"), **check_inputs)
|
||||
assert len(recorded) == 1, f"expected one captured kernel list, got {len(recorded)}"
|
||||
|
||||
frame, _ = capture_frame(jit, check_inputs, 'output_buffers')
|
||||
part = serialize_jit(jit, recorded[0], frame)
|
||||
PlainJit(part[0], memoryview(part[1]))
|
||||
write_bundle(args.output, {'run': part}, {'metadata': metadata, 'input_specs': input_specs, 'output_specs': output_specs})
|
||||
print(f"Saved model to {args.output} ({os.path.getsize(args.output) / 1e6:.2f} MB, {len(part[0]['calls'])} kernels)")
|
||||
|
||||
@@ -5,14 +5,23 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import builtins
|
||||
import contextlib
|
||||
import dataclasses
|
||||
import functools
|
||||
import hashlib
|
||||
import io
|
||||
import struct
|
||||
import json
|
||||
import math
|
||||
import mmap
|
||||
import os
|
||||
import pickle
|
||||
import inspect
|
||||
import importlib
|
||||
import tempfile
|
||||
import shutil
|
||||
import enum
|
||||
import struct
|
||||
|
||||
MAGIC = b"SPTGPLN1"
|
||||
ALIGNMENT = 256
|
||||
METADATA_TYPES = ("slice", "tuple", "list", "dict", "set", "frozenset", "complex")
|
||||
RUNTIME_ENVIRONMENT = ("GMMU", "AMD_AQL", "AMD_DISABLE_SDMA", "GRAPH_ONE_KERNEL")
|
||||
|
||||
|
||||
def _patch_system_flock_acquire():
|
||||
@@ -32,86 +41,246 @@ def _patch_system_flock_acquire():
|
||||
pass
|
||||
_patch_system_flock_acquire()
|
||||
|
||||
def _pad_args(func, args, kwargs):
|
||||
try:
|
||||
sig = inspect.signature(func)
|
||||
except Exception:
|
||||
return args, kwargs
|
||||
params = list(sig.parameters.values())
|
||||
if inspect.isfunction(func) and params and params[0].name in ('cls', 'self'):
|
||||
params = params[1:]
|
||||
|
||||
new_args = list(args)
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in params)
|
||||
if len(new_args) > len(params) and not has_varargs:
|
||||
new_args = new_args[:len(params)]
|
||||
|
||||
for i in range(len(new_args), len(params)):
|
||||
param = params[i]
|
||||
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
||||
continue
|
||||
val = param.default if param.default is not inspect.Parameter.empty else None
|
||||
new_args.append(val)
|
||||
return new_args, kwargs
|
||||
class ArtifactMismatch(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _dynamic_factory(real_class):
|
||||
if isinstance(real_class, type) and issubclass(real_class, enum.Enum):
|
||||
return real_class
|
||||
class Blob:
|
||||
def __init__(self, sink=None):
|
||||
self.chunks: list = []
|
||||
self.locations: dict[bytes, list[int]] = {}
|
||||
self.size, self.write = 0, sink.write if sink else self.chunks.append
|
||||
|
||||
def factory(*args, **kwargs):
|
||||
try:
|
||||
return real_class(*args, **kwargs)
|
||||
except TypeError:
|
||||
new_args, new_kwargs = _pad_args(real_class, args, kwargs)
|
||||
return real_class(*new_args, **new_kwargs)
|
||||
|
||||
class DynamicMeta(type(real_class)):
|
||||
def __call__(cls, *args, **kwargs):
|
||||
return factory(*args, **kwargs)
|
||||
|
||||
class DynamicProxy(real_class, metaclass=DynamicMeta):
|
||||
__slots__ = ()
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
return factory(*args, **kwargs)
|
||||
|
||||
DynamicProxy.__name__ = real_class.__name__
|
||||
DynamicProxy.__module__ = real_class.__module__
|
||||
return DynamicProxy
|
||||
def add(self, data) -> list[int]:
|
||||
if (key := hashlib.blake2b(data, digest_size=16).digest()) not in self.locations:
|
||||
start = self.size + -self.size % ALIGNMENT
|
||||
self.write(bytes(start - self.size)), self.write(data)
|
||||
self.locations[key], self.size = [start, start + memoryview(data).nbytes], start + memoryview(data).nbytes
|
||||
return self.locations[key]
|
||||
|
||||
|
||||
class DynamicTinygradUnpickler(pickle.Unpickler):
|
||||
class _MetadataUnpickler(pickle.Unpickler):
|
||||
def find_class(self, module, name):
|
||||
real_class = getattr(importlib.import_module(module), name)
|
||||
if module.startswith("tinygrad"):
|
||||
return _dynamic_factory(real_class)
|
||||
return real_class
|
||||
if module == "builtins" and name in METADATA_TYPES:
|
||||
return getattr(builtins, name)
|
||||
raise pickle.UnpicklingError(f"{module}.{name} is not allowed in model metadata")
|
||||
|
||||
|
||||
def load_oob(f):
|
||||
opcodes = f.read(struct.unpack('<q', f.read(8))[0])
|
||||
def buffers():
|
||||
while (h := f.read(8)):
|
||||
pb = pickle.PickleBuffer(bytearray(struct.unpack('<q', h)[0]))
|
||||
f.readinto(pb)
|
||||
yield pb
|
||||
return DynamicTinygradUnpickler(io.BytesIO(opcodes), buffers=buffers()).load()
|
||||
def flatten(value) -> list:
|
||||
if isinstance(value, (tuple, list)):
|
||||
return [tensor for item in value for tensor in flatten(item)]
|
||||
return [] if value is None else [value]
|
||||
|
||||
|
||||
def dump_oob(obj, f):
|
||||
with tempfile.TemporaryFile(dir=".") as tmp:
|
||||
def buffer_cb(buffer):
|
||||
data = buffer.raw()
|
||||
tmp.write(struct.pack('<q', len(data)))
|
||||
tmp.write(data)
|
||||
buffer.release()
|
||||
return False
|
||||
def _relocate(value, part_blob: memoryview, blob: Blob):
|
||||
if isinstance(value, dict):
|
||||
return {k: blob.add(part_blob[slice(*v)]) if k in ("lib", "init", "data") else _relocate(v, part_blob, blob) for k, v in value.items()}
|
||||
return [_relocate(v, part_blob, blob) for v in value] if isinstance(value, list) else value
|
||||
|
||||
stream = io.BytesIO()
|
||||
pickle.Pickler(stream, protocol=5, buffer_callback=buffer_cb).dump(obj)
|
||||
opcodes = stream.getvalue()
|
||||
f.write(struct.pack('<q', len(opcodes)))
|
||||
f.write(opcodes)
|
||||
tmp.seek(0)
|
||||
shutil.copyfileobj(tmp, f)
|
||||
|
||||
def write_bundle(path: str, parts: dict, extras: dict | None = None):
|
||||
blob = Blob()
|
||||
headers = {key: _relocate(part_header, memoryview(part_blob), blob) for key, (part_header, part_blob) in parts.items()}
|
||||
extras_location = blob.add(pickle.dumps(extras or {}, protocol=4))
|
||||
entries = [{"key": list(key) if isinstance(key, tuple) else key, "blob": [0, blob.size], "header": header} for key, header in headers.items()]
|
||||
header = json.dumps({"parts": entries, "extras": extras_location}).encode()
|
||||
with open(path, "wb") as file:
|
||||
file.write(MAGIC + struct.pack("<Q", len(header)) + header + bytes(-(len(MAGIC) + 8 + len(header)) % ALIGNMENT))
|
||||
file.writelines(blob.chunks)
|
||||
|
||||
|
||||
def read_parts(path: str) -> tuple[dict, dict]:
|
||||
with open(path, "rb") as file:
|
||||
data = memoryview(mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_READ))
|
||||
if bytes(data[:len(MAGIC)]) != MAGIC:
|
||||
raise ValueError(f"{path} is not a plain tinygrad model, recompile it with compile_modeld.py")
|
||||
start = len(MAGIC) + 8 + struct.unpack_from("<Q", data, len(MAGIC))[0]
|
||||
header, blob = json.loads(bytes(data[len(MAGIC) + 8:start])), data[start + -start % ALIGNMENT:]
|
||||
parts = {tuple(e["key"]) if isinstance(e["key"], list) else e["key"]: (e["header"], blob[slice(*e["blob"])]) for e in header["parts"]}
|
||||
return parts, _MetadataUnpickler(io.BytesIO(bytes(blob[slice(*header["extras"])]))).load()
|
||||
|
||||
|
||||
def load_model(path: str, device_map: dict | None = None, verify: bool = True, cache: bool = True) -> dict:
|
||||
parts, extras = read_parts(path)
|
||||
if cache and (cached := _read_cache(path, parts, device_map)) is not None:
|
||||
return extras | cached
|
||||
shared: dict = {}
|
||||
jits = {key: PlainJit(header, blob, device_map, verify, shared) for key, (header, blob) in parts.items()}
|
||||
if cache:
|
||||
_write_cache(path, jits, device_map)
|
||||
return extras | jits
|
||||
|
||||
|
||||
@functools.cache
|
||||
def tinygrad_identity() -> str:
|
||||
import tinygrad
|
||||
root, digest = os.path.dirname(tinygrad.__file__), hashlib.sha256()
|
||||
for directory, _, files in sorted(os.walk(root)):
|
||||
for name in sorted(file for file in files if file.endswith(".py")):
|
||||
stat = os.stat(path := os.path.join(directory, name))
|
||||
digest.update(f"{os.path.relpath(path, root)}:{stat.st_size}:{stat.st_mtime_ns};".encode())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _cache_key(path: str, device_map: dict | None) -> bytes:
|
||||
stat = os.stat(path)
|
||||
return json.dumps({"format": MAGIC.decode(), "tinygrad": tinygrad_identity(), "size": stat.st_size, "mtime_ns": stat.st_mtime_ns,
|
||||
"device_map": sorted((device_map or {}).items()), "env": [os.environ.get(name, "") for name in RUNTIME_ENVIRONMENT]}).encode()
|
||||
|
||||
|
||||
def _cache_header_matches(file, path: str, device_map: dict | None) -> bool:
|
||||
return file.read(struct.unpack("<Q", file.read(8))[0]) == _cache_key(path, device_map)
|
||||
|
||||
|
||||
def cache_is_fresh(path: str, device_map: dict | None = None) -> bool: # entry point for warmup proc
|
||||
try:
|
||||
with open(f"{path}.cache", "rb") as file:
|
||||
return _cache_header_matches(file, path, device_map)
|
||||
except (OSError, struct.error):
|
||||
return False
|
||||
|
||||
|
||||
def _read_cache(path: str, parts: dict, device_map: dict | None) -> dict | None:
|
||||
try:
|
||||
with open(f"{path}.cache", "rb") as file:
|
||||
if not _cache_header_matches(file, path, device_map):
|
||||
return None
|
||||
shared: dict = {}
|
||||
buffers = {repr(key): _allocate_buffers(header, blob, device_map, shared) for key, (header, blob) in parts.items()}
|
||||
return type("_Unpickler", (pickle.Unpickler,), {"persistent_load": lambda self, pid: buffers[pid[0]][pid[1]]})(file).load()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _write_cache(path: str, jits: dict, device_map: dict | None) -> None:
|
||||
from tinygrad.device import Buffer
|
||||
owners = {id(buffer): (repr(key), index) for key, jit in jits.items() for index, buffer in enumerate(jit.buffers)}
|
||||
|
||||
class _Pickler(pickle.Pickler):
|
||||
def persistent_id(self, obj):
|
||||
return owners.get(id(obj)) if isinstance(obj, Buffer) else None
|
||||
|
||||
key, tmp_path = _cache_key(path, device_map), f"{path}.cache.{os.getpid()}.tmp"
|
||||
with contextlib.suppress(Exception):
|
||||
with open(tmp_path, "wb") as file:
|
||||
file.write(struct.pack("<Q", len(key)) + key)
|
||||
_Pickler(file, protocol=5).dump({name: _Jit(jit.captured, jit.input_names, jit.buffers) for name, jit in jits.items()})
|
||||
os.replace(tmp_path, f"{path}.cache")
|
||||
|
||||
|
||||
# Everything below this comment block calls tinygrad internals, so after a tinygrad bump this
|
||||
# is the only code to update, and models are not recompiled. How to do this when discountchubbs is not available:
|
||||
# 1. run modeld_v2/tests/test_plain_artifact.py on the new tinygrad and fix each failing call against the new API: Buffer,
|
||||
# UOp.from_buffer/param (ParamArg), ProgramInfo, Target, KernelInfo, compile_linear, CapturedJit and
|
||||
# _prepare_jit_inputs in tinygrad/engine/jit.py
|
||||
# 2. load a real model with verify=True: ArtifactMismatch means the rebuilt graph still differs from the one that was compiled
|
||||
# The boot cache is keyed on the tinygrad source, so it rebuilds itself. Only a change in how the device runs kernel binaries needs a recompile.
|
||||
# Honestly, I would just have your clanker try it because you likely have zero idea what you are looking at lol..
|
||||
|
||||
def _dtype(text: str):
|
||||
from tinygrad.dtype import dtypes
|
||||
return eval(text, {"__builtins__": {}}, {"dtypes": dtypes})
|
||||
|
||||
|
||||
def _device(recorded: str, device_map: dict | None) -> str:
|
||||
from tinygrad import Device
|
||||
device_map = device_map or {}
|
||||
return Device.canonicalize(device_map.get(recorded, device_map.get(recorded.split(":", 1)[0], recorded)))
|
||||
|
||||
|
||||
def _allocate_buffers(header: dict, blob: memoryview, device_map: dict | None, shared: dict) -> list:
|
||||
import numpy as np
|
||||
from tinygrad.device import Buffer
|
||||
written = {call["args"][i]["buffer"] for call in header["calls"] for i in (call["outs"] if call["kind"] == "program" else (0,))
|
||||
if "buffer" in call["args"][i]}
|
||||
buffers = []
|
||||
for index, entry in enumerate(header["buffers"]):
|
||||
device, data = _device(entry["device"], device_map), blob[slice(*entry["init"])] if "init" in entry else None
|
||||
# read-only data the parts share in the file (weights common to every camera resolution) is uploaded once to the nut
|
||||
key = (device, entry["dtype"], np.frombuffer(data, np.uint8).ctypes.data, len(data)) if data and index not in written else object()
|
||||
if key not in shared:
|
||||
shared[key] = Buffer(device, entry["nbytes"], initial_value=pickle.PickleBuffer(data) if data else None).ensure_allocated()
|
||||
buffers.append(shared[key])
|
||||
return buffers
|
||||
|
||||
|
||||
def _program_uop(call: dict, lib: bytes):
|
||||
from tinygrad.helpers import Target
|
||||
from tinygrad.uop.ops import KernelInfo, Ops, ProgramInfo, UOp
|
||||
parameters = []
|
||||
for name, compact_slot, dtype, shape in call["signature"]:
|
||||
parameter = UOp.param(call["globals"][compact_slot], _dtype(dtype), math.prod(shape))
|
||||
image = (shape[0], shape[1]) if len(shape) == 3 and shape[2] == 4 else None
|
||||
parameters.append(parameter.replace(arg=dataclasses.replace(parameter.arg, name=name, image=image)))
|
||||
info = ProgramInfo(global_size=tuple(call["global_size"]), local_size=tuple(call["local_size"]), vars=(), globals=tuple(call["globals"]),
|
||||
outs=tuple(call["outs"]), ins=tuple(call["ins"]), target=Target(**call["target"]))
|
||||
return UOp(Ops.PROGRAM, src=(UOp(Ops.SINK, arg=KernelInfo(name=call["name"])), UOp(Ops.LINEAR, src=tuple(parameters)),
|
||||
UOp(Ops.SOURCE, arg=""), UOp(Ops.BINARY, arg=lib)), arg=info)
|
||||
|
||||
|
||||
class _Jit:
|
||||
def __init__(self, captured, input_names: list, buffers: list):
|
||||
self.captured, self.input_names, self.buffers = captured, input_names, buffers
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
import tinygrad.engine.jit as jit
|
||||
input_uops, var_vals, names, _ = jit._prepare_jit_inputs(args, kwargs)
|
||||
if names != self.input_names:
|
||||
raise ValueError(f"expected inputs {self.input_names}, got {names}")
|
||||
return self.captured(input_uops, var_vals)
|
||||
|
||||
|
||||
class PlainJit(_Jit):
|
||||
def __init__(self, header: dict, blob: memoryview, device_map: dict | None = None, verify: bool = True, shared: dict | None = None):
|
||||
import numpy as np
|
||||
import tinygrad.engine.jit as jit
|
||||
from tinygrad import Tensor
|
||||
from tinygrad.device import Buffer
|
||||
from tinygrad.dtype import dtypes
|
||||
from tinygrad.uop.ops import Ops, UOp
|
||||
|
||||
inputs = {spec["slot"]: spec for spec in header["inputs"]}
|
||||
buffers = _allocate_buffers(header, blob, device_map, {} if shared is None else shared)
|
||||
bases = [UOp.from_buffer(buffer, _dtype(entry["dtype"])) for buffer, entry in zip(buffers, header["buffers"], strict=True)]
|
||||
|
||||
def argument(described: dict):
|
||||
if "input" in described:
|
||||
spec = inputs[described["input"]]
|
||||
return UOp.param(described["input"], _dtype(spec["dtype"]), spec["numel"], _device(spec["device"], device_map))
|
||||
base, dtype, offset, nbytes = bases[described["buffer"]], _dtype(described["dtype"]), described["offset"], described["nbytes"]
|
||||
if offset == 0 and nbytes == header["buffers"][described["buffer"]]["nbytes"] and dtype == base.dtype:
|
||||
return base
|
||||
return (base if base.dtype.itemsize == 1 else base.bitcast(dtypes.uint8)).shrink(((offset, offset + nbytes),)).bitcast(dtype)
|
||||
|
||||
calls = []
|
||||
for call in header["calls"]:
|
||||
arguments = [argument(described) for described in call["args"]]
|
||||
if call["kind"] == "program":
|
||||
calls.append(_program_uop(call, bytes(blob[slice(*call["lib"])])).call(*arguments))
|
||||
else:
|
||||
calls.append(arguments[0].store_call(arguments[1]))
|
||||
# stand-ins for JIT inputs: copy staging to GPU is decided from the input buffers, the real ones bind per call
|
||||
stand_ins = {slot: UOp.from_buffer(Buffer(_device(spec["device"], device_map), spec["numel"] * _dtype(spec["dtype"]).itemsize).ensure_allocated(),
|
||||
_dtype(spec["dtype"])) for slot, spec in inputs.items()}
|
||||
input_uops = tuple(stand_ins.get(slot, next(iter(stand_ins.values()), None)) for slot in range(max(stand_ins, default=-1) + 1))
|
||||
linear = jit.compile_linear(UOp(Ops.LINEAR, src=tuple(calls)), beam=0, input_uops=input_uops, cache=False)
|
||||
|
||||
returned = [Tensor(argument(t)).reshape(tuple(t["shape"])) for t in header["returns"]["tensors"]]
|
||||
ret = {"NoneType": None, "Tensor": returned[0] if returned else None, "tuple": tuple(returned), "list": returned}[header["returns"]["kind"]]
|
||||
super().__init__(jit.CapturedJit(ret, linear, header["input_names"], []), header["input_names"], buffers)
|
||||
|
||||
if not verify or not (check := header.get("check")):
|
||||
return
|
||||
def array(stored):
|
||||
return np.frombuffer(blob[slice(*stored["data"])], dtype=np.dtype(stored["dtype"])).reshape(stored["shape"]).copy()
|
||||
kwargs: dict = {}
|
||||
for path, stored, device in check["inputs"]:
|
||||
tensor = Tensor(array(stored), device=_device(device, device_map)).realize()
|
||||
kwargs[path[0]] = tensor if len(path) == 1 else {**kwargs.get(path[0], {}), path[1]: tensor}
|
||||
result = self(**kwargs)
|
||||
outputs = kwargs[check["output_container"]].values() if check["output_container"] else flatten(result)
|
||||
for index, (got, stored) in enumerate(zip([t.numpy() for t in outputs], check["outputs"], strict=True)):
|
||||
expected = array(stored)
|
||||
if got.shape != expected.shape or not np.allclose(got, expected, rtol=1e-3, atol=1e-3, equal_nan=True):
|
||||
raise ArtifactMismatch(f"output {index} differs from the compile-time reference frame; recompile the model")
|
||||
|
||||
@@ -23,8 +23,8 @@ from tinygrad.uop.ops import UOp
|
||||
|
||||
|
||||
def input_view(buffer: Buffer, shape: tuple[int, ...], dtype: DType, offset: int) -> Tensor:
|
||||
view = buffer.view(math.prod(shape), dtype, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view)).reshape(shape)
|
||||
view = buffer.view(math.prod(shape) * dtype.itemsize, offset).ensure_allocated()
|
||||
return Tensor(UOp.from_buffer(view, dtype)).reshape(shape)
|
||||
|
||||
|
||||
class BaseModelAdapter:
|
||||
|
||||
@@ -24,7 +24,6 @@ from openpilot.cereal.messaging import PubMaster, SubMaster
|
||||
from openpilot.cereal.visionipc import VisionStreamType
|
||||
from msgq.visionipc import VisionIpcClient, VisionBuf
|
||||
from opendbc.car.car_helpers import get_demo_car_params
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.filter_simple import FirstOrderFilter
|
||||
@@ -42,7 +41,7 @@ from openpilot.sunnypilot.modeld_v2.meta_helper import load_meta_constants
|
||||
from openpilot.sunnypilot.modeld_v2.camera_offset_helper import CameraOffsetHelper
|
||||
from openpilot.sunnypilot.livedelay.helpers import get_lat_delay
|
||||
from openpilot.sunnypilot.modeld_v2.modeld_base import ModelStateBase
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_oob
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_model
|
||||
from openpilot.sunnypilot.modeld_v2.model_adapters import get_model_adapter
|
||||
from openpilot.sunnypilot.models.helpers import get_active_bundle
|
||||
from openpilot.sunnypilot.selfdrive.controls.lib.relc import RoadEdgeLaneChangeController
|
||||
@@ -52,8 +51,7 @@ BIG_MODEL_TIMEOUT = 60
|
||||
|
||||
|
||||
def _pkl_exists(path):
|
||||
from openpilot.common.file_chunker import get_manifest_path
|
||||
return os.path.exists(path) or os.path.exists(get_manifest_path(path))
|
||||
return os.path.exists(path)
|
||||
|
||||
|
||||
def _find_driving_pkl(bundle):
|
||||
@@ -108,7 +106,7 @@ class ModelState(ModelStateBase):
|
||||
|
||||
def _init_combined(self, pkl_path, cam_w, cam_h, bundle):
|
||||
cloudlog.warning(f"loading combined pkl: {pkl_path}")
|
||||
jits = load_oob(open_file_chunked(pkl_path))
|
||||
jits = load_model(pkl_path)
|
||||
|
||||
metadata = jits['metadata']
|
||||
self.WARP_DEV = metadata.get('warp_dev', 'QCOM') if COMMA_HARDWARE else 'CPU'
|
||||
@@ -258,7 +256,8 @@ def main(demo=False):
|
||||
|
||||
CHESTNUT = chestnut_present()
|
||||
if CHESTNUT:
|
||||
os.environ['HCQDEV_WAIT_TIMEOUT_MS'] = '3000'
|
||||
from tinygrad.runtime.ops_amd import AMDDevice
|
||||
AMDDevice.wait_timeout_ms = 3000
|
||||
|
||||
params = Params()
|
||||
params.put_bool("ChestnutLoading", CHESTNUT)
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
import dataclasses
|
||||
import mmap
|
||||
import tempfile
|
||||
from contextlib import contextmanager
|
||||
from unittest import mock
|
||||
|
||||
import numpy as np
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import Blob, flatten
|
||||
|
||||
# Compile time only/ see discountchubbs 4 assistance: turns a captured TinyJit into plain data that helpers.load_model rebuilds. It reads internals of the
|
||||
# tinygrad that compiles the model, so a tinygrad bump that breaks it blocks new builds, never loading already built models.
|
||||
@contextmanager
|
||||
def record_kernel_linear():
|
||||
import tinygrad.engine.jit as jit
|
||||
import tinygrad.engine.realize as realize
|
||||
recorded, hcq_compile, compile_linear = [], realize.hcq_compile, jit.compile_linear
|
||||
|
||||
def recording_compile_linear(*args, **kwargs):
|
||||
with mock.patch.object(realize, "hcq_compile", lambda linear, *a, **k: (recorded.append(linear), hcq_compile(linear, *a, **k))[1]):
|
||||
return compile_linear(*args, **kwargs)
|
||||
|
||||
with mock.patch.object(jit, "compile_linear", recording_compile_linear):
|
||||
yield recorded
|
||||
|
||||
|
||||
def capture_frame(jit, kwargs: dict, output_container: str | None = None):
|
||||
inputs = [([name] if key is None else [name, key], tensor.numpy().copy(), tensor.device)
|
||||
for name, value in kwargs.items() for key, tensor in (value.items() if isinstance(value, dict) else [(None, value)])]
|
||||
result = jit(**kwargs)
|
||||
outputs = kwargs[output_container].values() if output_container else flatten(result)
|
||||
return {"inputs": inputs, "outputs": [tensor.numpy().copy() for tensor in outputs], "output_container": output_container}, result
|
||||
|
||||
|
||||
def serialize_jit(jit, kernel_linear, check: dict | None = None) -> tuple[dict, memoryview]:
|
||||
from tinygrad.uop.ops import Ops
|
||||
sink = tempfile.TemporaryFile(dir=".")
|
||||
blob, bases, input_specs = Blob(sink), {}, {}
|
||||
|
||||
def store(array) -> dict:
|
||||
array = np.ascontiguousarray(array)
|
||||
return {"data": blob.add(array.tobytes()), "dtype": array.dtype.str, "shape": list(array.shape)}
|
||||
|
||||
def describe(uop) -> dict:
|
||||
if uop.op is Ops.PARAM:
|
||||
input_specs.setdefault(uop.arg.slot, {"dtype": repr(uop.dtype), "numel": int(uop.max_numel()), "device": uop.device})
|
||||
return {"input": uop.arg.slot}
|
||||
buffer = uop.buffer
|
||||
root = buffer._base if buffer._base is not None else buffer
|
||||
base = bases.setdefault(id(root), (len(bases), root, uop.base.dtype if buffer._base is None else uop.dtype))
|
||||
return {"buffer": base[0], "offset": buffer.offset if buffer._base is not None else 0, "nbytes": buffer.nbytes, "dtype": repr(uop.dtype)}
|
||||
|
||||
calls, written, needs_init = [], set(), set()
|
||||
for call in kernel_linear.src:
|
||||
ast, arguments = call.src[0], [describe(argument) for argument in call.src[1:]]
|
||||
if ast.op is Ops.PROGRAM:
|
||||
elf, info = ast.to_elf(), ast.arg
|
||||
global_size, local_size = info.launch_dims({})
|
||||
calls.append({"kind": "program", "name": elf.name, "lib": blob.add(bytes(elf.lib)), "target": dataclasses.asdict(elf.target),
|
||||
"signature": [[name, slot, repr(dtype), [int(x) for x in shape]] for name, slot, dtype, shape in elf.signature],
|
||||
"global_size": [int(x) for x in global_size], "local_size": [int(x) for x in local_size],
|
||||
"globals": list(info.globals), "outs": list(info.outs), "ins": list(info.ins), "args": arguments})
|
||||
outs, ins = info.outs, info.ins
|
||||
elif ast.op is Ops.COPY:
|
||||
calls.append({"kind": "copy", "args": arguments})
|
||||
outs, ins = (0,), (1,)
|
||||
else:
|
||||
raise ValueError(f"unsupported call {ast.op} {ast.arg}")
|
||||
needs_init |= {arguments[i]["buffer"] for i in ins if "buffer" in arguments[i] and arguments[i]["buffer"] not in written}
|
||||
written |= {arguments[i]["buffer"] for i in outs if "buffer" in arguments[i]}
|
||||
|
||||
ret = jit.captured.ret
|
||||
returns = {"kind": type(ret).__name__, "tensors": [describe(t.uop.base) | {"shape": [int(x) for x in t.shape]} for t in flatten(ret)]}
|
||||
buffers = [{"nbytes": base.nbytes, "device": base.device, "dtype": repr(dtype)}
|
||||
| ({"init": blob.add(base.as_memoryview())} if index in needs_init else {}) for index, base, dtype in bases.values()]
|
||||
names = list(jit.captured.expected_names)
|
||||
if check is not None:
|
||||
check = {"inputs": [[path, store(array), device] for path, array, device in check["inputs"]], "outputs": [store(array) for array in check["outputs"]],
|
||||
"output_container": check["output_container"]}
|
||||
header = {"input_names": names, "buffers": buffers, "calls": calls, "returns": returns, "check": check,
|
||||
"inputs": [{"slot": slot, "name": names[slot] if slot < len(names) else None} | input_specs[slot] for slot in sorted(input_specs)]}
|
||||
with sink:
|
||||
sink.flush()
|
||||
return header, memoryview(mmap.mmap(sink.fileno(), 0, access=mmap.ACCESS_READ) if blob.size else b"")
|
||||
@@ -188,10 +188,8 @@ def make_pkl_data(archetype):
|
||||
|
||||
|
||||
def write_pkl(tmp_path, archetype):
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob
|
||||
pkl_path = tmp_path / 'driving_test_tinygrad.pkl'
|
||||
with open(pkl_path, 'wb') as f:
|
||||
dump_oob(make_pkl_data(archetype), f)
|
||||
pkl_path.write_bytes(pickle.dumps(make_pkl_data(archetype)))
|
||||
return pkl_path
|
||||
|
||||
|
||||
@@ -211,6 +209,7 @@ def patch_modeld(monkeypatch):
|
||||
def _patch(bundle):
|
||||
monkeypatch.setattr(helpers, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle)
|
||||
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: bundle)
|
||||
monkeypatch.setattr(modeld_module, 'load_model', lambda path: pickle.loads(pathlib.Path(path).read_bytes()))
|
||||
|
||||
return _patch
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ class TestStockEquivalence(OpenpilotTestCase):
|
||||
|
||||
def test_unified_run_model(self, tmp_path, monkeypatch, patch_modeld):
|
||||
from openpilot.common.hardware import hw
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob
|
||||
shapes = {'img': (1, 12, 128, 256), 'big_img': (1, 12, 128, 256), 'features_buffer': (1, 24, 32, 512),
|
||||
'desire_pulse': (1, 25, 8), 'traffic_convention': (1, 2), 'action_t': (1, 2)}
|
||||
import codecs
|
||||
@@ -102,8 +101,7 @@ class TestStockEquivalence(OpenpilotTestCase):
|
||||
(CAM_W, CAM_H): tests_helpers._noop_jit,
|
||||
'run_policy': tests_helpers._noop_jit,
|
||||
}
|
||||
with open(tmp_path / 'driving_test_tinygrad.pkl', 'wb') as f:
|
||||
dump_oob(pkl_data, f)
|
||||
(tmp_path / 'driving_test_tinygrad.pkl').write_bytes(pickle.dumps(pkl_data))
|
||||
bundle = DummyBundle(models=[DummyModel('supercombo', 'driving_test_tinygrad.pkl')])
|
||||
patch_modeld(bundle)
|
||||
monkeypatch.setattr(hw.Paths, 'model_root', staticmethod(lambda: str(tmp_path)))
|
||||
|
||||
@@ -5,16 +5,12 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from openpilot.common.parameterized import parameterized
|
||||
|
||||
from openpilot.common.file_chunker import chunk_file, get_chunk_targets
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key, read_file_chunked_to_disk
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import derive_frame_skip, _detect_desire_key
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
|
||||
|
||||
@@ -168,36 +164,6 @@ class TestOutputSlicePreservation(OpenpilotTestCase):
|
||||
"vision and policy slices should not overlap in keys"
|
||||
|
||||
|
||||
class TestReadFileChunkedToDisk(OpenpilotTestCase):
|
||||
def test_none_passthrough(self):
|
||||
assert read_file_chunked_to_disk(None) is None
|
||||
|
||||
def test_unchunked_source_staged_on_disk(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = Path(d) / "driving_supercombo.onnx"
|
||||
payload = os.urandom(1024)
|
||||
src.write_bytes(payload)
|
||||
|
||||
out = Path(read_file_chunked_to_disk(str(src)))
|
||||
|
||||
assert out.parent == Path(d)
|
||||
assert out.name == "driving_supercombo.onnx.unchunked"
|
||||
assert out.read_bytes() == payload
|
||||
|
||||
def test_chunked_source_reassembled_on_disk(self):
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
src = Path(d) / "driving_supercombo.onnx"
|
||||
payload = os.urandom(4096)
|
||||
src.write_bytes(payload)
|
||||
chunk_file(str(src), get_chunk_targets(str(src), len(payload)))
|
||||
assert not src.exists()
|
||||
|
||||
out = Path(read_file_chunked_to_disk(str(src)))
|
||||
|
||||
assert out.parent == Path(d)
|
||||
assert out.read_bytes() == payload
|
||||
|
||||
|
||||
class Test4DFeaturesBuffer(OpenpilotTestCase):
|
||||
def test_get_policy_npy_shapes_4d(self):
|
||||
from openpilot.sunnypilot.modeld_v2.compile_modeld import get_policy_npy_shapes
|
||||
|
||||
@@ -5,13 +5,11 @@ This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import io
|
||||
import pickle
|
||||
import requests
|
||||
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
from openpilot.common.hardware import hw
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import dump_oob
|
||||
import openpilot.sunnypilot.modeld_v2.modeld as modeld_module
|
||||
from openpilot.sunnypilot.modeld_v2.tests import helpers as tests_helpers
|
||||
from openpilot.sunnypilot.modeld_v2.tests.helpers import DummyModel, DummyBundle, CAM_W, CAM_H
|
||||
@@ -43,14 +41,11 @@ class TestFallback(OpenpilotTestCase):
|
||||
small_json = requests.get(ModelFetcher.MODEL_URL).json()
|
||||
small_bundle = ModelParser.parse_models(small_json)[-1]
|
||||
|
||||
buf = io.BytesIO()
|
||||
dump_oob(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']), buf)
|
||||
oob_bytes = buf.getvalue()
|
||||
oob_bytes = pickle.dumps(tests_helpers.make_pkl_data(tests_helpers.ARCHETYPES['supercombo_non20hz']))
|
||||
monkeypatch.setattr(modeld_module, 'load_model', lambda path: pickle.loads(open(path, 'rb').read()))
|
||||
|
||||
for bundle in (big_bundle, small_bundle):
|
||||
artifact = bundle.models[0].artifact
|
||||
for i in range(len(artifact.chunks)):
|
||||
(tmp_path / get_chunk_name(artifact.fileName, i, len(artifact.chunks))).write_bytes(oob_bytes if i == 0 else b"")
|
||||
(tmp_path / bundle.models[0].artifact.fileName).write_bytes(oob_bytes)
|
||||
|
||||
monkeypatch.setattr(modeld_module, 'get_active_bundle', lambda params=None, *, chestnut=None: small_bundle)
|
||||
assert modeld_module.ModelState(CAM_W, CAM_H, chestnut=False).chestnut is False
|
||||
|
||||
@@ -7,71 +7,14 @@ See the LICENSE.md file in the root directory for more details.
|
||||
|
||||
import os
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import load_oob
|
||||
from tinygrad.device import Device
|
||||
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import PlainJit, load_model
|
||||
|
||||
|
||||
class TestLegacyModels(unittest.TestCase):
|
||||
def test_legacy_model_load(self):
|
||||
base_name = os.environ.get("MODEL_BASE_NAME")
|
||||
if not base_name:
|
||||
raise unittest.SkipTest("MODEL_BASE_NAME env var not set, skipping integration test.")
|
||||
chunk_dir = os.environ.get("MODEL_CHUNK_DIR", "/tmp/model_chunks")
|
||||
base_path = os.path.join(chunk_dir, base_name)
|
||||
|
||||
try:
|
||||
f = open_file_chunked(base_path)
|
||||
except Exception as error:
|
||||
self.fail(f"Failed to open chunked file {base_path}: {error}")
|
||||
self.addCleanup(f.close)
|
||||
|
||||
real_getitem = Device.__class__.__getitem__
|
||||
|
||||
def safe_getitem(device_self, ix):
|
||||
if ix == "QCOM" and not os.path.exists("/dev/kgsl-3d0"):
|
||||
return real_getitem(device_self, "CPU")
|
||||
if ix == "AMD" and not os.path.exists("/dev/kfd"):
|
||||
return real_getitem(device_self, "CPU")
|
||||
return real_getitem(device_self, ix)
|
||||
|
||||
with patch.object(Device.__class__, "__getitem__", safe_getitem):
|
||||
obj = load_oob(f)
|
||||
|
||||
assert isinstance(obj, dict), "Parsed object is not a dictionary"
|
||||
assert "metadata" in obj, "Metadata key is missing"
|
||||
|
||||
def traverse_jits(d):
|
||||
if hasattr(d, "captured") and d.captured is not None:
|
||||
linear_attr = getattr(d.captured, "_linear", None)
|
||||
if linear_attr is None:
|
||||
try:
|
||||
linear_attr = getattr(d.captured, "linear", None)
|
||||
except AssertionError as e:
|
||||
if "alloc size must be positive" in str(e):
|
||||
return
|
||||
raise
|
||||
|
||||
if linear_attr is not None:
|
||||
stack = [linear_attr]
|
||||
visited = set()
|
||||
while stack:
|
||||
node = stack.pop()
|
||||
if id(node) in visited:
|
||||
continue
|
||||
visited.add(id(node))
|
||||
if hasattr(node, "src") and node.src is not None:
|
||||
for child in node.src:
|
||||
stack.append(child)
|
||||
elif isinstance(d, dict):
|
||||
for v in d.values():
|
||||
traverse_jits(v)
|
||||
elif isinstance(d, (list, tuple)):
|
||||
for v in d:
|
||||
traverse_jits(v)
|
||||
|
||||
if "run_model" in obj:
|
||||
traverse_jits(obj["run_model"])
|
||||
if "run_policy" in obj:
|
||||
traverse_jits(obj["run_policy"])
|
||||
class TestRegistryModels(unittest.TestCase):
|
||||
def test_model_loads(self):
|
||||
if not (base_name := os.environ.get("MODEL_BASE_NAME")):
|
||||
self.skipTest("MODEL_BASE_NAME env var not set, skipping integration test.")
|
||||
bundle = load_model(os.path.join(os.environ.get("MODEL_DIR", "/tmp/models"), base_name), {"QCOM": "CPU", "AMD": "CPU"}, verify=False, cache=False)
|
||||
jits = [value for value in bundle.values() if isinstance(value, PlainJit)]
|
||||
assert "metadata" in bundle and jits and all(len(jit.captured.linear.src) > 0 for jit in jits)
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Copyright (c) 2021-, Haibin Wen, sunnypilot, and a number of other contributors.
|
||||
|
||||
This file is part of sunnypilot and is licensed under the MIT License.
|
||||
See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import os
|
||||
os.environ['GMMU'] = '0'
|
||||
import multiprocessing
|
||||
import time
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.selfdrive.modeld.helpers import chestnut_present
|
||||
from openpilot.sunnypilot.modeld_v2.helpers import cache_is_fresh, load_model
|
||||
from openpilot.sunnypilot.models.helpers import get_selected_bundle
|
||||
|
||||
|
||||
def main():
|
||||
"""
|
||||
The main entry point for offroad cache creation.
|
||||
Runs every 10 seconds and checks for stale model caches.
|
||||
Chestnut cache needs the gpu to build, so that slot waits until it is plugged in, or simply runs during modeld startup.
|
||||
"""
|
||||
params, failed, spawn = Params(), set(), multiprocessing.get_context("spawn")
|
||||
while True:
|
||||
for bundle in [get_selected_bundle(params, source) for source in (("qcom", "chestnut") if chestnut_present() else ("qcom",))]:
|
||||
path = os.path.join(Paths.model_root(), bundle.models[0].artifact.fileName) if bundle and bundle.models else ""
|
||||
if not os.path.isfile(path) or cache_is_fresh(path) or (attempt := (path, os.stat(path).st_mtime_ns)) in failed:
|
||||
continue
|
||||
(worker := spawn.Process(target=load_model, args=(path,), daemon=True)).start()
|
||||
worker.join()
|
||||
if not cache_is_fresh(path):
|
||||
cloudlog.error(f"model cache warmup failed for {path} with exit code {worker.exitcode}")
|
||||
failed.add(attempt)
|
||||
time.sleep(10)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,12 +6,10 @@ See the LICENSE.md file in the root directory for more details.
|
||||
"""
|
||||
|
||||
import time
|
||||
import os
|
||||
import requests
|
||||
from requests.exceptions import (SSLError, RequestException, HTTPError)
|
||||
from openpilot.common.params import Params
|
||||
from openpilot.common.swaglog import cloudlog
|
||||
from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.sunnypilot.models.helpers import is_bundle_version_compatible
|
||||
from openpilot.cereal import custom
|
||||
|
||||
@@ -26,35 +24,11 @@ class ModelParser:
|
||||
download_uri.sha256 = download_uri_data.get("sha256")
|
||||
return download_uri
|
||||
|
||||
@staticmethod
|
||||
def _parse_chunk(chunk_data) -> custom.ModelManagerSP.Chunk:
|
||||
chunk = custom.ModelManagerSP.Chunk()
|
||||
chunk.fileName = chunk_data.get("file_name")
|
||||
chunk.sha256 = chunk_data.get("sha256")
|
||||
return chunk
|
||||
|
||||
@staticmethod
|
||||
def _parse_artifact(artifact_data) -> custom.ModelManagerSP.Artifact:
|
||||
artifact = custom.ModelManagerSP.Artifact()
|
||||
artifact.fileName = artifact_data.get("file_name")
|
||||
artifact.downloadUri = ModelParser._parse_download_uri(artifact_data.get("download_uri", {}))
|
||||
|
||||
if "chunks" in artifact_data:
|
||||
artifact.chunks = [ModelParser._parse_chunk(chunk_data) for chunk_data in artifact_data["chunks"]]
|
||||
|
||||
try:
|
||||
model_dir = Paths.model_root()
|
||||
os.makedirs(model_dir, exist_ok=True)
|
||||
manifest_path = os.path.join(model_dir, f"{artifact.fileName}.chunkmanifest")
|
||||
num_chunks = str(len(artifact.chunks))
|
||||
|
||||
if not os.path.exists(manifest_path) or open(manifest_path).read().strip() != num_chunks:
|
||||
with open(manifest_path, "w") as f:
|
||||
f.write(num_chunks)
|
||||
cloudlog.info(f"Wrote chunk manifest for {artifact.fileName}: {num_chunks} chunks")
|
||||
except Exception as e:
|
||||
cloudlog.warning(f"Failed to write chunk manifest for {artifact.fileName}: {e}")
|
||||
|
||||
return artifact
|
||||
|
||||
@staticmethod
|
||||
@@ -138,8 +112,8 @@ class ModelCache:
|
||||
|
||||
class ModelFetcher:
|
||||
"""Handles fetching and caching of model data from remote source"""
|
||||
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v23.json"
|
||||
MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v27.json"
|
||||
MODEL_URL = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_v25.json"
|
||||
MODEL_URL_CHESTNUT = "https://raw.githubusercontent.com/sunnypilot/sunnypilot-models/refs/heads/gh-pages/docs/driving_models_chestnut_v28.json"
|
||||
|
||||
MODEL_SOURCES = {
|
||||
"qcom": (MODEL_URL, ""),
|
||||
@@ -270,5 +244,3 @@ if __name__ == "__main__":
|
||||
model_overrides = {override.key: override.value for override in bundle.overrides}
|
||||
print(f"Bundle: {bundle.internalName}, Type: {model.type}, Status: {bundle.status}, Overrides: {model_overrides}")
|
||||
print(f"Artifact: {model.artifact.fileName}, Download URI: {model.artifact.downloadUri.uri}")
|
||||
if model.artifact.chunks:
|
||||
print(f"Contains {len(model.artifact.chunks)} chunks.")
|
||||
|
||||
@@ -16,7 +16,7 @@ from openpilot.common.hardware.hw import Paths
|
||||
from openpilot.selfdrive.modeld.helpers import chestnut_present
|
||||
|
||||
# SET ME TO THE EXACT JSON VERSION WE SET IN SUNNYPILOT_MODELS REPO
|
||||
REQUIRED_JSON_VERSION = 20
|
||||
REQUIRED_JSON_VERSION = 21
|
||||
|
||||
CUSTOM_MODEL_PATH = Paths.model_root()
|
||||
ModelManager = custom.ModelManagerSP
|
||||
@@ -29,9 +29,8 @@ _LAST_VALIDATED_RAW: dict[str, dict | None] = {}
|
||||
|
||||
|
||||
def _compute_hash(file_path: str) -> str | None:
|
||||
from openpilot.common.file_chunker import open_file_chunked
|
||||
try:
|
||||
with open_file_chunked(file_path) as file:
|
||||
with open(file_path, 'rb') as file:
|
||||
return hashlib.file_digest(file, "sha256").hexdigest().lower()
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -58,20 +57,11 @@ def is_bundle_version_compatible(bundle: dict) -> bool:
|
||||
|
||||
def _bundle_artifacts(bundle: custom.ModelManagerSP.ModelBundle) -> list[tuple[str, str]]:
|
||||
artifacts = []
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
for model in getattr(bundle, 'models', []) or []:
|
||||
for artifact in (getattr(model, 'artifact', None),):
|
||||
if artifact and getattr(artifact, 'fileName', None):
|
||||
if len(artifact.chunks) > 0:
|
||||
for i, chunk in enumerate(artifact.chunks):
|
||||
chunk_name = get_chunk_name(artifact.fileName, i, len(artifact.chunks))
|
||||
if getattr(chunk, 'sha256', None):
|
||||
artifacts.append((chunk_name, chunk.sha256))
|
||||
else:
|
||||
if getattr(artifact, 'downloadUri', None):
|
||||
sha256 = getattr(artifact.downloadUri, 'sha256', None)
|
||||
if sha256:
|
||||
artifacts.append((artifact.fileName, sha256))
|
||||
artifact = getattr(model, 'artifact', None)
|
||||
if artifact and getattr(artifact, 'fileName', None) and getattr(artifact, 'downloadUri', None):
|
||||
if sha256 := getattr(artifact.downloadUri, 'sha256', None):
|
||||
artifacts.append((artifact.fileName, sha256))
|
||||
return artifacts
|
||||
|
||||
|
||||
|
||||
@@ -109,50 +109,6 @@ class ModelManagerSP:
|
||||
# Clean up start time after download completes
|
||||
del self._download_start_times[model.fileName]
|
||||
|
||||
async def _download_chunked(self, base_url: str, base_path: str, artifact, skip: frozenset[int] | set[int] = frozenset()) -> None:
|
||||
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
|
||||
|
||||
num_chunks = len(artifact.chunks)
|
||||
if num_chunks == 0:
|
||||
raise ValueError("No chunks defined in artifact")
|
||||
|
||||
manifest_path = get_manifest_path(base_path)
|
||||
self._download_start_times[artifact.fileName] = time.monotonic()
|
||||
|
||||
# Shared connection saves a TCP+TLS handshake per chunk.
|
||||
# Keep sequential: the link saturates on one stream and Session is not thread-safe.
|
||||
completed = len(skip)
|
||||
with requests.Session() as session:
|
||||
for i, _ in enumerate(artifact.chunks):
|
||||
if i in skip:
|
||||
continue
|
||||
chunk_url = get_chunk_name(base_url, i, num_chunks)
|
||||
chunk_path = get_chunk_name(base_path, i, num_chunks)
|
||||
chunk_downloaded = 0
|
||||
with session.get(chunk_url, stream=True, timeout=DOWNLOAD_TIMEOUT) as response:
|
||||
response.raise_for_status()
|
||||
chunk_size = int(response.headers.get("content-length", 0))
|
||||
with open(chunk_path, 'wb') as f: # noqa: ASYNC230
|
||||
for data in response.iter_content(chunk_size=self._chunk_size):
|
||||
f.write(data)
|
||||
chunk_downloaded += len(data)
|
||||
if self._download_interrupted():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
intra = chunk_downloaded / max(chunk_size, 1)
|
||||
progress = min(99.0, ((completed + intra) / num_chunks) * 100)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloading
|
||||
artifact.downloadProgress.progress = progress
|
||||
artifact.downloadProgress.eta = self._calculate_eta(artifact.fileName, progress)
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
completed += 1
|
||||
|
||||
with open(manifest_path, 'w') as f: # noqa: ASYNC230
|
||||
f.write(str(num_chunks))
|
||||
if os.path.isfile(base_path): # noqa: ASYNC240
|
||||
os.remove(base_path)
|
||||
del self._download_start_times[artifact.fileName]
|
||||
|
||||
async def _process_artifact(self, artifact, destination_path: str) -> None:
|
||||
if not artifact.downloadUri.uri:
|
||||
return None
|
||||
@@ -165,28 +121,7 @@ class ModelManagerSP:
|
||||
full_path = os.path.join(destination_path, filename)
|
||||
|
||||
try:
|
||||
# progress counts only valid chunks so a resumed download continues the
|
||||
# bar from where verification left it, instead of falling back to zero
|
||||
is_cached = False
|
||||
valid_chunks: set[int] = set()
|
||||
if len(artifact.chunks) > 0:
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
num_chunks = len(artifact.chunks)
|
||||
for i, chunk in enumerate(artifact.chunks):
|
||||
if self._download_interrupted():
|
||||
raise DownloadCancelled("Download cancelled")
|
||||
if await verify_file(get_chunk_name(full_path, i, num_chunks), chunk.sha256):
|
||||
valid_chunks.add(i)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.verifying
|
||||
artifact.downloadProgress.progress = (len(valid_chunks) / num_chunks) * 100
|
||||
self._sync_artifact_progress(artifact)
|
||||
self._report_status()
|
||||
is_cached = len(valid_chunks) == num_chunks
|
||||
else:
|
||||
if await verify_file(full_path, expected_hash):
|
||||
is_cached = True
|
||||
|
||||
if is_cached:
|
||||
if await verify_file(full_path, expected_hash):
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.cached
|
||||
artifact.downloadProgress.progress = 100
|
||||
artifact.downloadProgress.eta = 0
|
||||
@@ -194,17 +129,9 @@ class ModelManagerSP:
|
||||
self._report_status()
|
||||
return
|
||||
|
||||
if len(artifact.chunks) > 0:
|
||||
await self._download_chunked(url, full_path, artifact, skip=valid_chunks)
|
||||
from openpilot.common.file_chunker import get_chunk_name
|
||||
for i, chunk in enumerate(artifact.chunks):
|
||||
chunk_path = get_chunk_name(full_path, i, len(artifact.chunks))
|
||||
if not await verify_file(chunk_path, chunk.sha256):
|
||||
raise ValueError(f"Hash validation failed for chunk {i+1} of {filename}")
|
||||
else:
|
||||
await self._download_file(url, full_path, artifact)
|
||||
if not await verify_file(full_path, expected_hash):
|
||||
raise ValueError(f"Hash validation failed for {filename}")
|
||||
await self._download_file(url, full_path, artifact)
|
||||
if not await verify_file(full_path, expected_hash):
|
||||
raise ValueError(f"Hash validation failed for {filename}")
|
||||
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
artifact.downloadProgress.progress = 100
|
||||
@@ -213,7 +140,6 @@ class ModelManagerSP:
|
||||
self._report_status()
|
||||
|
||||
except DownloadCancelled:
|
||||
# a cancel keeps whatever is on disk: complete chunks resume the next attempt
|
||||
self._download_start_times.pop(artifact.fileName, None)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
artifact.downloadProgress.eta = 0
|
||||
@@ -225,9 +151,8 @@ class ModelManagerSP:
|
||||
|
||||
except Exception as e:
|
||||
cloudlog.error(f"Error downloading {filename}: {str(e)}")
|
||||
for f in [full_path] + [p for p in (os.path.join(destination_path, f) for f in os.listdir(destination_path)) if filename in p]:
|
||||
if os.path.isfile(f): # noqa: ASYNC240
|
||||
os.remove(f)
|
||||
if os.path.isfile(full_path): # noqa: ASYNC240
|
||||
os.remove(full_path)
|
||||
artifact.downloadProgress.status = custom.ModelManagerSP.DownloadStatus.failed
|
||||
artifact.downloadProgress.eta = 0
|
||||
self._sync_artifact_progress(artifact)
|
||||
@@ -360,12 +285,11 @@ class ModelManagerSP:
|
||||
if model.artifact.fileName:
|
||||
active_files.append(model.artifact.fileName)
|
||||
|
||||
# Remove all files except active ones (including their chunk files)
|
||||
# Remove all files except active ones
|
||||
model_dir = Paths.model_root()
|
||||
try:
|
||||
for filename in os.listdir(model_dir):
|
||||
base = filename.split('.chunk')[0] if '.chunk' in filename else filename
|
||||
if base not in active_files and filename not in active_files:
|
||||
if filename not in active_files:
|
||||
file_path = os.path.join(model_dir, filename)
|
||||
if os.path.isfile(file_path):
|
||||
os.remove(file_path)
|
||||
|
||||
@@ -17,11 +17,9 @@ from typing import Any
|
||||
from unittest import mock
|
||||
|
||||
import requests
|
||||
from urllib3.connectionpool import HTTPConnectionPool
|
||||
|
||||
from openpilot.cereal import custom
|
||||
from openpilot.common.test import OpenpilotTestCase
|
||||
from openpilot.common.file_chunker import get_chunk_name, get_manifest_path
|
||||
from openpilot.selfdrive.test.helpers import http_server_context
|
||||
from openpilot.sunnypilot.models import manager as manager_module
|
||||
from openpilot.sunnypilot.models.fetcher import ModelFetcher, get_cached_bundles
|
||||
@@ -30,7 +28,6 @@ from openpilot.sunnypilot.models.helpers import (get_active_bundle, get_active_s
|
||||
resolve_bundle_by_ref, validate_active_bundles)
|
||||
from openpilot.sunnypilot.models.manager import ModelManagerSP
|
||||
|
||||
CHUNK_BODIES = [b'A' * 5000, b'B' * 5000, b'C' * 3000]
|
||||
WHOLE_BODY = b'Z' * 9000
|
||||
|
||||
|
||||
@@ -49,12 +46,7 @@ class DownloadHandler(http.server.BaseHTTPRequestHandler):
|
||||
pass
|
||||
|
||||
def _body_for(self, path):
|
||||
if path.endswith('.whole'):
|
||||
return WHOLE_BODY
|
||||
for i in range(len(CHUNK_BODIES)):
|
||||
if path.endswith(get_chunk_name('', i, len(CHUNK_BODIES))):
|
||||
return CHUNK_BODIES[i]
|
||||
return None
|
||||
return WHOLE_BODY if path.endswith('.pkl') else None
|
||||
|
||||
def do_GET(self):
|
||||
type(self).request_paths.append(self.path)
|
||||
@@ -119,35 +111,19 @@ class ManagerDownloadTestBase(OpenpilotTestCase):
|
||||
if artifact is not None:
|
||||
self.reported.append(float(artifact.downloadProgress.progress))
|
||||
|
||||
def make_artifact(self, chunked: bool):
|
||||
def make_artifact(self):
|
||||
bundle = custom.ModelManagerSP.ModelBundle.new_message()
|
||||
bundle.init('models', 1)
|
||||
artifact = bundle.models[0].artifact
|
||||
artifact.fileName = 'driving_test_tinygrad.pkl'
|
||||
if chunked:
|
||||
artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl'
|
||||
artifact.downloadUri.sha256 = sha256(b''.join(CHUNK_BODIES))
|
||||
artifact.init('chunks', len(CHUNK_BODIES))
|
||||
for i, body in enumerate(CHUNK_BODIES):
|
||||
artifact.chunks[i].sha256 = sha256(body)
|
||||
else:
|
||||
artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl.whole'
|
||||
artifact.downloadUri.sha256 = sha256(WHOLE_BODY)
|
||||
artifact.downloadUri.uri = self.base_url + '/driving_test_tinygrad.pkl'
|
||||
artifact.downloadUri.sha256 = sha256(WHOLE_BODY)
|
||||
self._bundle = bundle
|
||||
self.artifact = artifact
|
||||
return artifact
|
||||
|
||||
def chunk_paths(self, base_path):
|
||||
return [get_chunk_name(base_path, i, len(CHUNK_BODIES)) for i in range(len(CHUNK_BODIES))]
|
||||
|
||||
def assert_no_partials(self, base_path):
|
||||
leftovers = [p for p in [base_path, get_manifest_path(base_path)] + self.chunk_paths(base_path)
|
||||
if os.path.isfile(p)]
|
||||
assert leftovers == [], f"partial files left behind: {leftovers}"
|
||||
|
||||
|
||||
class TestManagerDownload(ManagerDownloadTestBase):
|
||||
"""Exercises the real _download_file / _download_chunked against a local server."""
|
||||
"""Exercises the real _download_file against a local server."""
|
||||
|
||||
def run_with_server(self, fn):
|
||||
with http_server_context(handler=DownloadHandler) as (host, port):
|
||||
@@ -156,7 +132,7 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
|
||||
def test_download_file_writes_exact_bytes(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=False)
|
||||
artifact = self.make_artifact()
|
||||
path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, path, artifact))
|
||||
with open(path, 'rb') as f:
|
||||
@@ -166,111 +142,67 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
assert artifact.fileName not in self.manager._download_start_times
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_download_chunked_writes_all_chunks_and_manifest(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
for i, expected in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
|
||||
assert f.read() == expected, f"chunk {i} body mismatch"
|
||||
|
||||
with open(get_manifest_path(base_path)) as f:
|
||||
assert f.read() == str(len(CHUNK_BODIES))
|
||||
|
||||
assert not os.path.isfile(base_path), "base file should be removed after chunking"
|
||||
assert artifact.fileName not in self.manager._download_start_times
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_progress_is_monotonic_and_bounded(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
assert self.reported, "expected progress reports"
|
||||
for a, b in zip(self.reported, self.reported[1:], strict=False):
|
||||
assert b >= a, f"progress went backwards: {a} -> {b}"
|
||||
assert max(self.reported) <= 99.0, f"chunked progress must stay <=99 until verify, got {max(self.reported)}"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_session_is_reused_across_chunks(self):
|
||||
"""One connection pool shared across every chunk."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
|
||||
pools = []
|
||||
original = HTTPConnectionPool.urlopen
|
||||
|
||||
def tracked(pool_self, *args, **kwargs):
|
||||
pools.append(id(pool_self))
|
||||
return original(pool_self, *args, **kwargs)
|
||||
|
||||
with mock.patch.object(HTTPConnectionPool, 'urlopen', tracked):
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
assert len(pools) == len(CHUNK_BODIES), f"expected one request per chunk, got {len(pools)}"
|
||||
assert len(set(pools)) == 1, f"connection pool not reused across chunks: {len(set(pools))} pools"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_http_error_propagates(self):
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
failing = '/' + os.path.basename(get_chunk_name(artifact.downloadUri.uri, 1, len(CHUNK_BODIES)))
|
||||
DownloadHandler.fail_paths = {failing: 404}
|
||||
DownloadHandler.fail_paths = {'/' + artifact.fileName: 404}
|
||||
|
||||
with self.assertRaises(requests.exceptions.HTTPError):
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
|
||||
# chunk 1 failed, so its file and the manifest must not exist
|
||||
assert not os.path.isfile(get_chunk_name(base_path, 1, len(CHUNK_BODIES)))
|
||||
assert not os.path.isfile(get_manifest_path(base_path))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert not os.path.isfile(base_path)
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_cancellation_mid_transfer(self):
|
||||
"""Cancellation is checked inside the byte loop; it must still fire after the port."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
self.manager.params.get.return_value = None # cancelled
|
||||
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert 'cancelled' in str(ctx.exception).lower()
|
||||
assert not os.path.isfile(get_manifest_path(base_path))
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_repeat_downloads_are_stable(self):
|
||||
"""Back-to-back runs must produce identical bytes and leak no start-time state."""
|
||||
def body():
|
||||
for _ in range(2):
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
for i, expected in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
|
||||
assert f.read() == expected
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
with open(base_path, 'rb') as f:
|
||||
assert f.read() == WHOLE_BODY
|
||||
assert self.manager._download_start_times == {}
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_download_ref_present_keeps_download_alive(self):
|
||||
"""A pending download request (DownloadRef set) must not be cancelled mid-transfer."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
self.manager.params.get.side_effect = lambda key: b"ref" if key == "ModelManager_DownloadRef" else None
|
||||
self.manager._download_ref = b"ref"
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(get_manifest_path(base_path))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(base_path)
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_cancellation_via_download_ref(self):
|
||||
"""Removing DownloadRef mid-transfer cancels the download."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
checks = {"n": 0}
|
||||
|
||||
@@ -283,20 +215,19 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
self.manager.params.get.side_effect = get
|
||||
self.manager._download_ref = b"ref"
|
||||
with self.assertRaises(Exception) as ctx:
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert 'cancelled' in str(ctx.exception).lower()
|
||||
assert not os.path.isfile(get_manifest_path(base_path))
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_replaced_download_ref_queues_instead_of_cancelling(self):
|
||||
"""Selecting another model mid-transfer lets the running download finish."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
self.manager.params.get.side_effect = lambda key: b"other-ref" if key == "ModelManager_DownloadRef" else None
|
||||
self.manager._download_ref = b"ref"
|
||||
asyncio.run(self.manager._download_chunked(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(get_manifest_path(base_path))
|
||||
asyncio.run(self.manager._download_file(artifact.downloadUri.uri, base_path, artifact))
|
||||
assert os.path.isfile(base_path)
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_replaced_download_ref_is_kept(self):
|
||||
@@ -315,11 +246,10 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
def test_cached_bundle_cancel_skips_slot_write(self):
|
||||
"""A cancel must stop an already-on-disk bundle before it is applied to the slot."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
for i, data in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f:
|
||||
f.write(data)
|
||||
with open(base_path, 'wb') as f:
|
||||
f.write(WHOLE_BODY)
|
||||
self._bundle.ref = "test-ref"
|
||||
params, store = self._make_params_with_store()
|
||||
store["ModelManager_DownloadRef"] = None # removed -> cancelled
|
||||
@@ -329,42 +259,7 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
asyncio.run(self.manager._download_bundle(self._bundle, self.dest, "qcom"))
|
||||
assert 'cancelled' in str(ctx.exception).lower()
|
||||
assert "ModelManager_ActiveBundle" not in store
|
||||
assert all(os.path.isfile(p) for p in self.chunk_paths(base_path)), "cancel must not delete cached chunks"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_resume_skips_valid_chunks(self):
|
||||
"""A chunk already on disk is kept and not re-downloaded; progress starts above its share."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
with open(get_chunk_name(base_path, 0, len(CHUNK_BODIES)), 'wb') as f:
|
||||
f.write(CHUNK_BODIES[0])
|
||||
|
||||
asyncio.run(self.manager._process_artifact(artifact, self.dest))
|
||||
|
||||
chunk0_suffix = get_chunk_name('', 0, len(CHUNK_BODIES))
|
||||
assert not any(p.endswith(chunk0_suffix) for p in DownloadHandler.request_paths), "valid chunk was re-downloaded"
|
||||
for i, expected in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'rb') as f:
|
||||
assert f.read() == expected
|
||||
assert os.path.isfile(get_manifest_path(base_path))
|
||||
assert min(self.reported) >= (1 / len(CHUNK_BODIES)) * 100 - 1, "progress must not restart below the resumed share"
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_verify_reports_valid_fraction_then_cached(self):
|
||||
"""A fully cached bundle publishes climbing verify progress and ends cached."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
base_path = os.path.join(self.dest, artifact.fileName)
|
||||
for i, data in enumerate(CHUNK_BODIES):
|
||||
with open(get_chunk_name(base_path, i, len(CHUNK_BODIES)), 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
asyncio.run(self.manager._process_artifact(artifact, self.dest))
|
||||
|
||||
assert DownloadHandler.request_paths == [], "cached bundle must not hit the network"
|
||||
assert [round(p) for p in self.reported[:3]] == [33, 67, 100]
|
||||
assert artifact.downloadProgress.status == custom.ModelManagerSP.DownloadStatus.cached
|
||||
assert os.path.isfile(base_path), "cancel must not delete the cached model"
|
||||
self.run_with_server(body)
|
||||
|
||||
def _make_params_with_store(self):
|
||||
@@ -384,7 +279,7 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
def test_download_writes_qcom_slot(self):
|
||||
"""A download resolved to the qcom source writes the qcom active bundle slot only."""
|
||||
def body():
|
||||
artifact = self.make_artifact(chunked=True)
|
||||
artifact = self.make_artifact()
|
||||
self._bundle.ref = "test-ref"
|
||||
self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION
|
||||
params, store = self._make_params_with_store()
|
||||
@@ -396,15 +291,13 @@ class TestManagerDownload(ManagerDownloadTestBase):
|
||||
assert self.manager.selected_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
assert self.manager.active_bundle is not None and self.manager.active_bundle.ref == "test-ref"
|
||||
assert self.manager.active_bundle.status == custom.ModelManagerSP.DownloadStatus.downloaded
|
||||
chunk_names = [get_chunk_name(artifact.fileName, i, len(artifact.chunks)) for i in range(len(artifact.chunks))]
|
||||
missing = [c for c in chunk_names if not os.path.isfile(os.path.join(self.dest, c))]
|
||||
assert missing == [], f"chunks missing from the cache: {missing}"
|
||||
assert os.path.isfile(os.path.join(self.dest, artifact.fileName))
|
||||
self.run_with_server(body)
|
||||
|
||||
def test_download_writes_chestnut_slot(self):
|
||||
"""A download resolved to the chestnut source writes the chestnut active bundle slot only."""
|
||||
def body():
|
||||
self.make_artifact(chunked=True)
|
||||
self.make_artifact()
|
||||
self._bundle.ref = "big-ref"
|
||||
self._bundle.minimumSelectorVersion = helpers.REQUIRED_JSON_VERSION
|
||||
params, store = self._make_params_with_store()
|
||||
@@ -782,7 +675,7 @@ class TestEffectiveSource(OpenpilotTestCase):
|
||||
|
||||
@unittest.skipUnless(os.environ.get('RUN_INTEGRATION_TESTS'), 'requires external network')
|
||||
class TestLiveModelManifest(OpenpilotTestCase):
|
||||
"""Every artifact and chunk URL in the published manifest must resolve."""
|
||||
"""Every artifact URL in the published manifest must resolve."""
|
||||
|
||||
def test_all_manifest_urls_available(self):
|
||||
from openpilot.sunnypilot.models.fetcher import ModelFetcher
|
||||
@@ -794,10 +687,7 @@ class TestLiveModelManifest(OpenpilotTestCase):
|
||||
for bundle in manifest.get('bundles', []):
|
||||
for model in bundle.get('models', []):
|
||||
artifact = model['artifact']
|
||||
url = artifact['download_uri']['url']
|
||||
chunks = artifact.get('chunks', [])
|
||||
urls = ([url] if not chunks
|
||||
else [get_chunk_name(url, i, len(chunks)) for i in range(len(chunks))])
|
||||
urls = [artifact['download_uri']['url']]
|
||||
for u in urls:
|
||||
try:
|
||||
r = session.head(u, timeout=15, allow_redirects=True)
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Local Sunnydrive pairing shared by sunnydrived and the comma UI."""
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from openpilot.common.params import Params
|
||||
|
||||
|
||||
PAIRED_KEY = "SunnydrivePairedClients"
|
||||
REQUESTS_KEY = "SunnydrivePairingRequests"
|
||||
PAIRING_SECONDS = 5 * 60
|
||||
CLIENT_ID_RE = re.compile(r"[A-Za-z0-9_-]{16,128}")
|
||||
|
||||
|
||||
def _params(params=None):
|
||||
return params or Params()
|
||||
|
||||
|
||||
def _dict(params, key):
|
||||
value = params.get(key)
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
|
||||
def device_id(params=None):
|
||||
params = _params(params)
|
||||
return params.get("DongleId") or params.get("HardwareSerial") or "unregistered"
|
||||
|
||||
|
||||
def device_name(params=None):
|
||||
identity = device_id(params)
|
||||
return f"comma {identity[-8:]}" if identity != "unregistered" else "comma"
|
||||
|
||||
|
||||
def is_offroad(params=None):
|
||||
return _params(params).get_bool("IsOffroad")
|
||||
|
||||
|
||||
def _require_allowed(params):
|
||||
if not params.get_bool("SunnydriveAutoPairPrompt"): # the comma's "allow pairing" switch
|
||||
raise PermissionError("pairing is turned off on the comma")
|
||||
|
||||
|
||||
def paired_clients(params=None):
|
||||
return _dict(_params(params), PAIRED_KEY)
|
||||
|
||||
|
||||
def is_paired(client_id, params=None):
|
||||
return client_id in paired_clients(params)
|
||||
|
||||
|
||||
def authorized(token, params=None):
|
||||
if not token:
|
||||
return False
|
||||
digest = hashlib.sha256(token.encode()).hexdigest()
|
||||
return any(secrets.compare_digest(digest, str(client.get("token_hash", ""))) for client in paired_clients(params).values())
|
||||
|
||||
|
||||
def _pair(client_id, name, params):
|
||||
if not CLIENT_ID_RE.fullmatch(client_id):
|
||||
raise ValueError("invalid client id")
|
||||
token = secrets.token_urlsafe(32)
|
||||
clients = paired_clients(params)
|
||||
clients[client_id] = {"name": str(name or "Sunnydrive phone")[:80], "token_hash": hashlib.sha256(token.encode()).hexdigest(),
|
||||
"paired_at": int(datetime.now(UTC).timestamp())}
|
||||
params.put(PAIRED_KEY, clients, block=True)
|
||||
return token
|
||||
|
||||
|
||||
def request_pairing(client_id, name, params=None):
|
||||
if not CLIENT_ID_RE.fullmatch(client_id):
|
||||
raise ValueError("invalid client id")
|
||||
params = _params(params)
|
||||
_require_allowed(params)
|
||||
now = int(time.monotonic())
|
||||
requests = {key: value for key, value in _dict(params, REQUESTS_KEY).items()
|
||||
if int(value.get("expires", 0)) > now and value.get("client_id") != client_id}
|
||||
request_id = secrets.token_urlsafe(18)
|
||||
requests[request_id] = {"client_id": client_id, "name": str(name or "Sunnydrive phone")[:80], "status": "pending", "expires": now + PAIRING_SECONDS}
|
||||
params.put(REQUESTS_KEY, requests, block=True)
|
||||
return request_id
|
||||
|
||||
|
||||
def pairing_requests(params=None):
|
||||
params = _params(params)
|
||||
now = int(time.monotonic())
|
||||
requests = _dict(params, REQUESTS_KEY)
|
||||
fresh = {key: value for key, value in requests.items() if int(value.get("expires", 0)) > now}
|
||||
if fresh != requests:
|
||||
params.put(REQUESTS_KEY, fresh, block=True) if fresh else params.remove(REQUESTS_KEY)
|
||||
return fresh
|
||||
|
||||
|
||||
def approve_request(request_id, params=None):
|
||||
params = _params(params)
|
||||
requests = pairing_requests(params)
|
||||
request = requests.get(request_id)
|
||||
if not request or request.get("status") != "pending":
|
||||
return False
|
||||
request["token"] = _pair(request["client_id"], request.get("name"), params)
|
||||
request["status"] = "approved"
|
||||
requests[request_id] = request
|
||||
params.put(REQUESTS_KEY, requests, block=True)
|
||||
return True
|
||||
|
||||
|
||||
def consume_request(request_id, params=None):
|
||||
params = _params(params)
|
||||
requests = pairing_requests(params)
|
||||
request = requests.get(request_id)
|
||||
if not request:
|
||||
return {"status": "expired"}
|
||||
result = {"status": request.get("status", "pending")}
|
||||
if request.get("status") == "approved":
|
||||
result["token"] = request.get("token", "")
|
||||
requests.pop(request_id, None)
|
||||
params.put(REQUESTS_KEY, requests, block=True) if requests else params.remove(REQUESTS_KEY)
|
||||
return result
|
||||
|
||||
|
||||
def unpair(client_id=None, params=None):
|
||||
params = _params(params)
|
||||
clients = paired_clients(params)
|
||||
if client_id is None:
|
||||
params.remove(PAIRED_KEY)
|
||||
return bool(clients)
|
||||
removed = clients.pop(client_id, None) is not None
|
||||
if removed:
|
||||
params.put(PAIRED_KEY, clients, block=True) if clients else params.remove(PAIRED_KEY)
|
||||
return removed
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,172 +0,0 @@
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
import unittest
|
||||
import unittest.mock
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from tempfile import TemporaryDirectory
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from openpilot.sunnypilot.sunnydrive import sunnydrived
|
||||
from openpilot.sunnypilot.sunnydrive import pairing
|
||||
from openpilot.sunnypilot.sunnydrive.sunnydrived import SunnydriveServer
|
||||
|
||||
|
||||
class SunnydriveApiTest(unittest.TestCase):
|
||||
class FakeParams:
|
||||
def __init__(self):
|
||||
self.values = {"DongleId": "comma-test-12345678", "IsOffroad": True, "SunnydriveAutoPairPrompt": True}
|
||||
|
||||
def get(self, key):
|
||||
return self.values.get(key)
|
||||
|
||||
def get_bool(self, key):
|
||||
return bool(self.values.get(key))
|
||||
|
||||
def put(self, key, value, block=False):
|
||||
self.values[key] = value
|
||||
|
||||
def remove(self, key):
|
||||
self.values.pop(key, None)
|
||||
|
||||
def test_slide_approval_pairing(self):
|
||||
params = self.FakeParams()
|
||||
request = pairing.request_pairing("phone_abcdefghijklmnop", "Second phone", params)
|
||||
replacement = pairing.request_pairing("phone_abcdefghijklmnop", "Second phone", params)
|
||||
self.assertNotEqual(replacement, request)
|
||||
request = replacement
|
||||
self.assertEqual(len(pairing.pairing_requests(params)), 1)
|
||||
self.assertTrue(pairing.approve_request(request, params))
|
||||
approved = pairing.consume_request(request, params)
|
||||
self.assertEqual(approved["status"], "approved")
|
||||
self.assertTrue(pairing.authorized(approved["token"], params))
|
||||
self.assertEqual(pairing.consume_request(request, params)["status"], "expired")
|
||||
|
||||
params.values["SunnydriveAutoPairPrompt"] = False
|
||||
with self.assertRaisesRegex(PermissionError, "turned off"):
|
||||
pairing.request_pairing("phone_switched_off_123456", "Off", params)
|
||||
params.values["SunnydriveAutoPairPrompt"] = True
|
||||
|
||||
params.values["IsOffroad"] = False # pairing works onroad too
|
||||
self.assertTrue(pairing.approve_request(pairing.request_pairing("phone_onroad_123456789", "Onroad", params), params))
|
||||
|
||||
def test_subscribed_services_exist(self): # a renamed service crashes the sample loop and freezes telemetry
|
||||
from openpilot.cereal.services import SERVICE_LIST
|
||||
self.assertEqual([name for name in sunnydrived.SERVICES if name not in SERVICE_LIST], [])
|
||||
|
||||
def test_discovery_is_metadata_only(self):
|
||||
packet = sunnydrived.DISCOVERY_PREFIX + b'{"v":1,"nonce":"12345678","clientId":"phone_1234567890123456"}'
|
||||
with patch.object(pairing, "device_id", return_value="comma-id"), \
|
||||
patch.object(pairing, "device_name", return_value="comma test"), \
|
||||
patch.object(pairing, "is_paired", return_value=False), \
|
||||
patch.object(pairing, "is_offroad", return_value=True):
|
||||
reply = sunnydrived.discovery_response(packet)
|
||||
body = json.loads(reply[len(sunnydrived.DISCOVERY_PREFIX):])
|
||||
self.assertEqual(set(body), {"v", "nonce", "deviceId", "name", "httpPort", "apiVersion", "paired", "offroad"})
|
||||
self.assertNotIn("token", body)
|
||||
|
||||
def test_settings_follow_sunnylink_safety_rules(self):
|
||||
with patch("openpilot.common.params.Params") as params_class, patch.object(sunnydrived, "_setting_engaged", return_value=False) as engaged:
|
||||
params_class.return_value.get_bool.return_value = False
|
||||
params_class.return_value.get_type.return_value = 1
|
||||
sunnydrived.sunnylink_set("AlphaLongitudinalEnabled", True)
|
||||
params_class.return_value.put.assert_called_once()
|
||||
with self.assertRaisesRegex(PermissionError, "onroad or engaged"):
|
||||
sunnydrived.sunnylink_set("Mads", True)
|
||||
engaged.return_value = True
|
||||
with self.assertRaisesRegex(PermissionError, "onroad or engaged"):
|
||||
sunnydrived.sunnylink_set("AlphaLongitudinalEnabled", True)
|
||||
params_class.return_value.put.assert_called_once()
|
||||
params_class.return_value.get.return_value = b"1" # TorqueParamsOverrideEnabled satisfies the schema's onroad alternative
|
||||
params_class.return_value.get_type.return_value = 3
|
||||
sunnydrived.sunnylink_set("TorqueParamsOverrideFriction", 0.1)
|
||||
self.assertEqual(params_class.return_value.put.call_count, 2)
|
||||
params_class.return_value.remove.assert_not_called()
|
||||
|
||||
def test_live_frames_start_at_keyframe(self):
|
||||
def msg(key, data):
|
||||
frame = SimpleNamespace(idx=SimpleNamespace(flags=0x8 if key else 0), header=b"H" if key else b"", data=data)
|
||||
return SimpleNamespace(which=lambda: "livestreamCabinEncodeData", livestreamCabinEncodeData=frame)
|
||||
params = unittest.mock.MagicMock()
|
||||
with patch.object(sunnydrived.messaging, "sub_sock"), \
|
||||
patch.object(sunnydrived.messaging, "recv_one", side_effect=[msg(False, b"p0"), None, msg(True, b"k1"), msg(False, b"p2")]):
|
||||
frames = sunnydrived.live_frames("driver", params)
|
||||
self.assertEqual([next(frames) for _ in range(3)], [None, (True, b"Hk1"), (False, b"p2")])
|
||||
self.assertEqual([c.args for c in params.put_bool.call_args_list], [("LivestreamRequestKeyframe", True), ("LivestreamRequestKeyframe", False)])
|
||||
|
||||
def test_route_list_cache(self):
|
||||
with TemporaryDirectory() as folder, patch.object(sunnydrived, "REALDATA", Path(folder)):
|
||||
sunnydrived._connect_routes.cache_clear()
|
||||
try:
|
||||
self.assertEqual(sunnydrived.connect_routes(), [])
|
||||
self.assertEqual(sunnydrived.connect_routes(), [])
|
||||
self.assertEqual(sunnydrived._connect_routes.cache_info().hits, 1)
|
||||
finally:
|
||||
sunnydrived._connect_routes.cache_clear()
|
||||
|
||||
def test_api_only(self):
|
||||
server = SunnydriveServer(("127.0.0.1", 0))
|
||||
server.publish_telemetry({"timestampMs": 123})
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
request = Request(base + "/telemetry", headers={"Origin": "https://ai.sunnypilot.sunnydrive"})
|
||||
with urlopen(request, timeout=2) as response:
|
||||
self.assertEqual(json.load(response), server.telemetry)
|
||||
self.assertEqual(response.headers["Access-Control-Allow-Origin"], "https://ai.sunnypilot.sunnydrive")
|
||||
with urlopen(base + "/telemetry/stream", timeout=2) as first, urlopen(base + "/telemetry/stream", timeout=2) as second:
|
||||
self.assertEqual(first.readline(), b'data: {"timestampMs":123}\n')
|
||||
self.assertEqual(second.readline(), b'data: {"timestampMs":123}\n')
|
||||
first.readline()
|
||||
second.readline()
|
||||
server.publish_telemetry({"timestampMs": 456})
|
||||
self.assertEqual(first.readline(), b'data: {"timestampMs":456}\n')
|
||||
self.assertEqual(second.readline(), b'data: {"timestampMs":456}\n')
|
||||
for path in ("/", "/index.html", "/replay", "/youtube-playlists"):
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urlopen(base + path, timeout=2)
|
||||
self.assertEqual(error.exception.code, 404)
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
def test_unpaired_phone_cannot_read_api(self):
|
||||
server = SunnydriveServer(("127.0.0.1", 0), allow_loopback=False)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{server.server_address[1]}"
|
||||
with patch.object(pairing, "authorized", return_value=False):
|
||||
with self.assertRaises(HTTPError) as error:
|
||||
urlopen(base + "/telemetry", timeout=2)
|
||||
self.assertEqual(error.exception.code, 401)
|
||||
with patch.object(pairing, "device_id", return_value="comma-id"), \
|
||||
patch.object(pairing, "device_name", return_value="comma test"), \
|
||||
patch.object(pairing, "is_paired", return_value=False), \
|
||||
patch.object(pairing, "is_offroad", return_value=True):
|
||||
with urlopen(base + "/pair/info?client_id=phone_1234567890123456", timeout=2) as response:
|
||||
self.assertEqual(json.load(response)["deviceId"], "comma-id")
|
||||
with patch.object(pairing, "authorized", return_value=True):
|
||||
with urlopen(base + "/telemetry?auth=paired", timeout=2) as response:
|
||||
self.assertIn("timestampMs", json.load(response))
|
||||
with patch.object(pairing, "authorized", return_value=True) as authorized: # unpairing cuts an open stream within a second
|
||||
with urlopen(base + "/telemetry/stream?auth=paired", timeout=5) as stream:
|
||||
self.assertTrue(stream.readline().startswith(b"data: "))
|
||||
stream.readline() # the event's blank line
|
||||
authorized.return_value = False
|
||||
time.sleep(1.1)
|
||||
server.publish_telemetry({"timestampMs": 789})
|
||||
self.assertEqual(stream.read(), b"")
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -71,9 +71,6 @@ def livestream(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
def use_copyparty(started, params, CP: car.CarParams) -> bool:
|
||||
return bool(params.get_bool("EnableCopyparty"))
|
||||
|
||||
def sunnydrive_enabled(started: bool, params: Params, CP: car.CarParams) -> bool:
|
||||
return params.get_bool("SunnydriveEnabled")
|
||||
|
||||
def sunnylink_ready_shim(started, params, CP: car.CarParams) -> bool:
|
||||
"""Shim for sunnylink_ready to match the process manager signature."""
|
||||
return sunnylink_ready(params)
|
||||
@@ -170,10 +167,10 @@ procs = [
|
||||
|
||||
# sunnypilot
|
||||
procs += [
|
||||
PythonProcess("sunnydrived", "openpilot.sunnypilot.sunnydrive.sunnydrived", sunnydrive_enabled),
|
||||
# Models
|
||||
PythonProcess("models_manager", "openpilot.sunnypilot.models.manager", only_offroad),
|
||||
NativeProcess("modeld_tinygrad", "openpilot/sunnypilot/modeld_v2", ["./modeld"], and_(only_onroad, is_tinygrad_model)),
|
||||
PythonProcess("modeld_warmup", "openpilot.sunnypilot.modeld_v2.warmup", only_offroad),
|
||||
|
||||
# Backup
|
||||
PythonProcess("backup_manager", "openpilot.sunnypilot.sunnylink.backups.manager", and_(only_offroad, sunnylink_ready_shim)),
|
||||
|
||||
@@ -54,18 +54,10 @@ def create_pkl_name(full_name: str) -> str:
|
||||
|
||||
|
||||
def _hash_pkl(pkl_path: Path) -> str:
|
||||
manifest = Path(f"{pkl_path}.chunkmanifest")
|
||||
if manifest.exists():
|
||||
num_chunks = int(manifest.read_text().strip())
|
||||
paths = [Path(f"{pkl_path}.chunk{i + 1:02d}of{num_chunks:02d}") for i in range(num_chunks)]
|
||||
else:
|
||||
paths = [pkl_path]
|
||||
|
||||
digest = hashlib.sha256()
|
||||
for path in paths:
|
||||
with path.open('rb') as f:
|
||||
while block := f.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
with pkl_path.open('rb') as f:
|
||||
while block := f.read(1024 * 1024):
|
||||
digest.update(block)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
@@ -74,22 +66,9 @@ def _find_driving_pkl(output_path: Path) -> Path | None:
|
||||
matches = sorted(output_path.glob(pattern))
|
||||
if matches:
|
||||
return matches[0]
|
||||
for pattern in ('*driving_tinygrad.pkl.chunkmanifest', '*driving_*_tinygrad.pkl.chunkmanifest'):
|
||||
matches = sorted(output_path.glob(pattern))
|
||||
if matches:
|
||||
return Path(str(matches[0]).removesuffix('.chunkmanifest'))
|
||||
return None
|
||||
|
||||
|
||||
def _rename_pkl_with_chunks(old_pkl: Path, new_pkl: Path) -> Path:
|
||||
manifest = Path(f"{old_pkl}.chunkmanifest")
|
||||
if manifest.exists():
|
||||
for f in sorted(old_pkl.parent.glob(f"{old_pkl.name}.chunk*")):
|
||||
f.rename(old_pkl.parent / f.name.replace(old_pkl.name, new_pkl.name, 1))
|
||||
return new_pkl
|
||||
return old_pkl.rename(new_pkl)
|
||||
|
||||
|
||||
def _hash_onnx_files(model_dir: Path) -> str | None:
|
||||
onnx_files = [f for f in sorted(model_dir.glob("*.onnx")) if f.stat().st_size > 1024]
|
||||
if not onnx_files:
|
||||
@@ -102,22 +81,9 @@ def _hash_onnx_files(model_dir: Path) -> str | None:
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def generate_chunked_model(driving_pkl: Path) -> dict:
|
||||
def generate_model(driving_pkl: Path) -> dict:
|
||||
tinygrad_hash = _hash_pkl(driving_pkl)
|
||||
|
||||
chunks_config = []
|
||||
manifest_file = Path(f"{driving_pkl}.chunkmanifest")
|
||||
if manifest_file.exists():
|
||||
num_chunks = int(manifest_file.read_text().strip())
|
||||
for i in range(num_chunks):
|
||||
chunk_path = Path(f"{driving_pkl}.chunk{i + 1:02d}of{num_chunks:02d}")
|
||||
if chunk_path.exists():
|
||||
chunk_hash = hashlib.sha256(chunk_path.read_bytes()).hexdigest()
|
||||
chunks_config.append({
|
||||
"file_name": chunk_path.name,
|
||||
"sha256": chunk_hash
|
||||
})
|
||||
|
||||
artifact_data = {
|
||||
"file_name": driving_pkl.name,
|
||||
"download_uri": {
|
||||
@@ -126,9 +92,6 @@ def generate_chunked_model(driving_pkl: Path) -> dict:
|
||||
}
|
||||
}
|
||||
|
||||
if chunks_config:
|
||||
artifact_data["chunks"] = chunks_config
|
||||
|
||||
return {
|
||||
"type": "chunked",
|
||||
"artifact": artifact_data,
|
||||
@@ -193,11 +156,11 @@ if __name__ == "__main__":
|
||||
if _pkl:
|
||||
new_pkl = _output_dir / f"driving_{_pkl}_tinygrad.pkl"
|
||||
if not new_pkl.exists():
|
||||
_driving_pkl = _rename_pkl_with_chunks(_driving_pkl, new_pkl)
|
||||
_driving_pkl = _driving_pkl.rename(new_pkl)
|
||||
else:
|
||||
_driving_pkl = new_pkl
|
||||
|
||||
_model_metadata = generate_chunked_model(_driving_pkl)
|
||||
_model_metadata = generate_model(_driving_pkl)
|
||||
_onnx_sha256 = args.onnx_sha256 or _hash_onnx_files(Path(args.model_dir))
|
||||
create_metadata_json([_model_metadata], _output_dir, args.custom_name, _short_name, args.is_20hz, args.upstream_branch,
|
||||
onnx_sha256=_onnx_sha256, is_big=is_big)
|
||||
|
||||
@@ -79,8 +79,6 @@ def main():
|
||||
artifact = bundle['models'][0]['artifact']
|
||||
hf_base = f"https://huggingface.co/datasets/{args.hf_repo}/resolve/main/{args.hf_defaults_path}/{folder_name}"
|
||||
artifact['download_uri']['url'] = f"{hf_base}/{artifact['file_name']}"
|
||||
for chunk in artifact.get('chunks', []):
|
||||
chunk['url'] = f"{hf_base}/{chunk['file_name']}"
|
||||
|
||||
print(f"Uploading model to {args.hf_defaults_path}/{folder_name}/")
|
||||
api.upload_folder(
|
||||
|
||||
+1
-1
Submodule tinygrad_repo updated: fe5d3169ba...69fd4d34d4
@@ -1,149 +0,0 @@
|
||||
# sunnydrive YOLO26 viewer
|
||||
|
||||
`yolo_viewer.py` is the entry point; `yolo_web.py` provides the browser server and calibrated overlays.
|
||||
This separate viewer runs on the Mac and listens only on localhost.
|
||||
|
||||
From the repository root, using the prepared Python environment:
|
||||
|
||||
```sh
|
||||
/private/tmp/roadseg-venv/bin/python tools/yolo/yolo_viewer.py --demo --cache /private/tmp/yolo26-models
|
||||
```
|
||||
|
||||
Open http://127.0.0.1:8768. The replay console opens in the Terminal running the command: Space pauses/resumes replay, s seeks forward 10 seconds, Shift+S seeks back 10 seconds, and q exits replay and the viewer. The browser Pause button pauses inference only. `--demo` starts the public comma demo replay locally in its own IPC namespace and stops that replay on Ctrl+C. Requires the existing built `openpilot/tools/replay/replay`, repository Python environment and msgq extension. The parked comma is not changed.
|
||||
|
||||
For live sunnydrive video:
|
||||
|
||||
```sh
|
||||
/private/tmp/roadseg-venv/bin/python tools/yolo/yolo_viewer.py --comma http://10.83.106.109:8766 --token-file /private/tmp/roadseg-tokens.json --cache /private/tmp/yolo26-models
|
||||
```
|
||||
|
||||
Choose Nano, Small, Medium, Large or Extra large, then enable Objects, Object masks and Scene segmentation independently. Object masks imply Objects. Turning everything off shows the camera without YOLO inference. Weights download from official Ultralytics assets on first use. Full scene combines detection and semantic segmentation at the selected size. Switching releases the previous models before loading the next selection. Road, wide and driver camera selection works for replay and live inputs. Area masks are saved in browser local storage separately for each camera/view. Area opacity controls their displayed shading; inference always receives fully black masked regions.
|
||||
|
||||
- Detection: COCO object boxes and class confidence.
|
||||
- Instance masks: individual object masks and boxes.
|
||||
- Scene segmentation: Cityscapes pixels including road, sky, traffic signs, vehicles, people and riders. Scene opacity applies here and in Full scene.
|
||||
|
||||
The viewer is observational. It does not control the vehicle. It does not read sign text, provide vehicle re-identification, or guarantee accurate distances. Replay runs at its recorded rate; the displayed processing FPS excludes the time waiting for a new replay frame. Frames are conflated so inference uses the latest available camera image rather than building a queue.
|
||||
|
||||
The Nano detection and segmentation models were checked for checkpoint loading and inference/rendering on the M5 GPU. Larger checkpoints are offered on demand and have not all been benchmarked locally.
|
||||
|
||||
Official model documentation: https://docs.ultralytics.com/models/yolo26/
|
||||
|
||||
Driver camera uses the inward-facing cabin feed. Its view through the windows is not a calibrated extension of the exterior road cameras. The demo launcher enables `--cabin`; existing manual replay should also include that flag.
|
||||
|
||||
Wide + driver sides splits the driver image vertically, places its right half to the left of wide and its left half to the right, accounting for the inward-facing camera, preserving panel aspect ratios. YOLO processes the composite. These are unaligned views, not a calibrated surround image; the latest driver frame is used only while it is under one second old.
|
||||
|
||||
Add blackout area lets you draw up to 16 independent polygons per camera/view. Click at least three vertices, then Finish area. Drag orange handles to reshape, including while drawing. Click an area’s X to delete it (Enter/Space also works when focused). Finish area becomes available after three points; Cancel area discards an unfinished outline. Remove area also deletes the selected polygon, or the last polygon when none is selected. Areas persist in browser local storage per view and are fully black in the model input. Area opacity adjusts their shading in the displayed output.
|
||||
|
||||
Only one sunnydrive web viewer may run, across all ports and both viewer scripts. A second launch exits before starting replay or loading models. Stop the existing viewer before relaunching; the OS releases the lock when it exits.
|
||||
|
||||
The video uses the full page width and follows the actual camera/composite aspect ratio. Left side rotation and Right side rotation sliders provide independent -180° to 180° adjustments in Wide + driver sides. Rotation occurs before inference; panel dimensions remain fixed, with black corners where rotated content falls outside the frame. Rotation settings persist in browser local storage. Side-panel areas follow camera rotation and panning automatically.
|
||||
|
||||
The last camera/view and model are saved on each selection to `~/.config/sunnydrive/yolo-viewer.json` and restored before loading a model on the next launch. Explicit `--camera` or `--model` arguments override the saved choice. `--settings-file` can use a different settings file.
|
||||
|
||||
When launched with `--demo`, replay shortcuts also work from the webpage: Space pauses/resumes replay, Left/Right seek 10 seconds, Shift+Left/Right seek 60 seconds, +/− change speed, and q quits. Native s/S, m/M, e, d, t, i, w and c shortcuts are also forwarded. Click the video/background first; shortcuts do not intercept typing, dropdowns, sliders or focused buttons. On-page replay buttons provide pause/play and ±10 seconds. The Terminal console remains interactive. Attaching with `--replay` does not give this script keyboard access to a separately launched replay process.
|
||||
|
||||
|
||||
Area selection offers Black out selected areas or Keep selected areas. In Keep mode, trace the visible windows (Add keep area → Finish area); the union of those polygons is retained and everything outside is fully black for inference, with display shading controlled by Area opacity. With no keep polygons, the frame is fully masked until a window is outlined. Selection mode persists in browser local storage separately for each camera/view.
|
||||
|
||||
Side-panel area masks follow the camera rotation and horizontal/vertical controls. Their layout reference is saved with the outlines so it survives restarts. Areas centered on the wide camera stay fixed.
|
||||
|
||||
Rotation, panning, area edits, and opacity work while paused. The viewer recomposes and processes the frozen camera frames when a setting changes; unchanged paused frames do not rerun inference. This also works when replay itself is paused.
|
||||
|
||||
Drag a side panel directly on the preview to pan it, including while paused. Exit Edit areas first so dragging controls the camera instead of polygon points.
|
||||
|
||||
Masked side-camera regions and empty rotation corners reveal the camera underneath when panels overlap. YOLO receives this same composite. Area opacity applies where a masked region has no usable camera underneath.
|
||||
|
||||
Reset sides restores both side cameras to zero rotation and their original positions, carrying their masks back with them.
|
||||
|
||||
Object-capable tasks (Detection, Instance masks, Pose, Oriented detection, and the detection model in Full scene) use ByteTrack with persistent IDs displayed on boxes. Camera/model/source switches, replay seeks, and scene-layout/mask changes reset tracking. Tracking IDs are temporary associations, not vehicle re-identification. The Python environment needs `lap>=0.5.12`.
|
||||
|
||||
Tracked objects show cyan recent trails and yellow one-second image-motion arrows, extrapolated from recent box-center movement. This is a constant-velocity image estimate, affected by ego motion and detection jitter; it is not a world-space heading or driving trajectory. Stationary and newly detected tracks omit the arrow.
|
||||
|
||||
Lane lines / edges / path displays comma modelV2 curves, projected using recorded extrinsicsCalibration. The demo launcher publishes these telemetry services; it does not run a new lane-detection network. Live mode reads sunnydrive telemetry.
|
||||
|
||||
Road-camera object labels show approximate radial ground-plane distance (`~20 m`). The bottom-center of the box is assumed to touch the calibrated road plane, so occlusion, slopes, truncation, and box errors affect estimates. Center-view boxes in the composite can use this calibration; side-camera boxes cannot. Where meters are unavailable, the label explicitly says meters unavailable. No additional depth network is run.
|
||||
|
||||
Vehicle labels use the box bottom-center and the same projected lane curves drawn on screen. They compare lane boundary intersections at that bottom pixel row, requiring 30% probability for ego boundaries and 15% for outer boundaries. If an outer boundary is missing, one adjacent lane width is inferred from the ego-lane width and labeled left/right lane. Unsupported projection/contact points remain lane unknown. This estimates the contact-center lane, not the vehicle's full footprint.
|
||||
|
||||
Lane labels cover five positions: left left lane, left lane, ego lane, right lane, and right right lane. The second lane on either side is inferred from adjacent lane width because comma supplies only four lane boundaries. With missing outer lines, both adjacent lanes are inferred from ego-lane width. These labels indicate relative lateral positions, not proof that another drivable lane exists.
|
||||
|
||||
Lane overlays preserve model x/y/z coordinates and use intrinsic × view-from-device × recorded calibration rotation (plus wide-from-device for the wide camera). Replay camera frame IDs are matched to model frame IDs within three frames; the matched sample stays frozen during paused edits. Older live telemetry without modelProjection/z uses the ground-plane fallback.
|
||||
|
||||
Lane UI width (1–16 source pixels) and Lane UI opacity independently control the comma lane/edge/path overlay. Opacity also affects its path fill. Values persist in browser storage and update while paused.
|
||||
|
||||
A top-down canvas to the right of the preview shows comma lane curves and detected vehicles up to 70 m ahead, using the same estimated ground positions and track IDs as camera labels. Ego is blue, ego-lane cars green, adjacent-lane cars yellow, and unknown-lane cars gray. Vehicles without usable road calibration/contact positions are omitted. Vehicle icons are nominal footprints, not measured dimensions or headings. It reuses inference outputs and a small per-frame event stream; no extra camera stream or model runs.
|
||||
|
||||
One compact box label combines track ID, class, confidence percentage, meters, and lane position above the box (for example `id:42 car 90% | ~20 m | ego lane`).
|
||||
|
||||
Top-down scenes are pushed immediately with each completed inference frame. Browser rendering keeps only the newest scene per animation frame; it does not wait for status polling or queue old scenes. Its rate follows inference FPS.
|
||||
|
||||
Camera and top-down views sit side by side on desktop; below 900 px viewport width, the top-down panel stacks underneath.
|
||||
|
||||
The demo launcher starts replay at 2:00 (`--start 120`).
|
||||
|
||||
On startup, the server waits up to three seconds for an existing viewer tab to reconnect through its status polling. If it reconnects, no new tab opens. Updated viewer tabs automatically reload after a server restart to reconnect the camera and top-down streams. Otherwise the launcher opens a browser tab as usual. `--no-open` disables automatic opening.
|
||||
|
||||
Displayed meters now estimate longitudinal clearance from the front bumper: max(0, ground-contact forward distance − measured camera-to-front-bumper offset). Enter that offset in the UI; it persists in browser storage. Without an offset, meters show ?. The target box bottom remains an approximate ground-contact proxy, not a measured bumper location. Top-down longitudinal positions and lane curves shift to the same front-bumper origin when the offset is set.
|
||||
|
||||
In demo mode the top-right Pause/Resume button controls replay itself. Its state follows the replay console status, including Terminal spacebar and browser replay shortcuts. Live/manual-attached mode retains inference pause behavior.
|
||||
|
||||
Display offers Full view, Objects only — masked, and Objects only — original. Both isolated modes use instance segmentation at the current model size and retain every detected object's silhouette. Masked adds segmentation tint; Original preserves camera colors. Background is black; annotations and top-down remain. Inference still receives the regular composite. Lane lines / edges / path remains available in both isolated views when its checkbox is checked. Selecting a different task restores Full view; display selection persists in browser storage.
|
||||
|
||||
Box labels sit inside their four corners: type top-left, confidence top-right, track number bottom-left, and bumper distance with lane symbol bottom-right. Text uses a fixed readable size instead of shrinking with small boxes.
|
||||
|
||||
Labels and Bounding boxes have independent visibility toggles. Tracking, silhouettes, motion arrows, and top-down remain active. Visibility persists in browser storage and changes apply while paused.
|
||||
|
||||
`yolo` starts the demo at 2:00. `yolo d59ca223dca8da93/00000498--3190be1e8b/6` starts that route segment at its beginning. Add `--start N` to offset within the selected route range. `yolo --comma IP` connects live; `yolo --replay` attaches to a separately running replay.
|
||||
|
||||
Lights / sign text enables conservative crop-based traffic-light color checks, road-sign OCR, and experimental vehicle-lamp activity labels. Traffic-light colors need three agreeing recent frames; ambiguous/distant crops are unknown. Full scene supplies semantic traffic-sign regions for general sign text; object-capable modes can read detected stop signs. OCR runs asynchronously on CPU, at most twice per second, with bundled RapidOCR models and a latest-job queue. Explicit SPEED LIMIT/MAXIMUM text plus a plausible number is required before labeling a speed limit; units are not inferred.
|
||||
|
||||
Vehicle lamp activity uses per-track temporal color measurements: repeated amber on/off transitions can show left signal?, right signal?, or hazards?. Simultaneous brightening of two red rear-lamp regions can show brake lights?. Constant red tail lamps do not imply braking. These are experimental image heuristics, affected by pose, lighting, reflections, camera motion, and occlusion—not trained brake/turn-signal classifiers. They make no driving decisions.
|
||||
|
||||
Object labels appear on one line outside their boxes, with leader lines pointing to the objects. Placement avoids other labels and keeps a fixed readable font; crowded scenes use the least occupied available position. Sign text uses the same layout.
|
||||
|
||||
Lights/sign text uses JC Traffic Sign Detection YOLO26 Small on raw sign crops in the background CPU worker (21 US sign classes, including speed limits 15–85). Weights are downloaded once into the model cache from pinned Hugging Face revision `2cb01e6788b82d904a1d1e35646e171a1b622fe7`. OCR remains a fallback for general sign text and missed detections. The model predicts sign values directly; it does not establish which sign applies to the vehicle's lane.
|
||||
|
||||
Sign recognition now uses only the dedicated JC YOLO26 US-sign model; OCR is disabled. Pending, unrecognized, and failed sign reads produce no label. Sign IDs and unknown traffic-light labels are hidden.
|
||||
|
||||
The size and feature choices are remembered together. Changing features releases previous models before loading the selected combination. Objects-only display automatically enables object masks while preserving the scene selection. Labels, bounding boxes, arrows/trails, lights/signs and lane overlays retain their separate visibility controls.
|
||||
|
||||
Performance: inference results are cached for the current frame and input geometry/masks. Labels, sign results, opacity, lanes and display toggles redraw without rerunning YOLO or advancing tracking. Changing the camera input, camera layout, mask geometry or model invalidates the cache. Annotation tensors transfer to CPU once per inference, scene colors use an equivalent OpenCV lookup, and video uses TCP_NODELAY. Sign crops remain serial because measured batching was slower; CPU inference uses one Torch thread, the fastest measured setting on this Mac. Status exposes inference, rendering, encoding and total processing times; FPS includes JPEG encoding and stays unchanged on cached redraws.
|
||||
|
||||
Tracking trails and arrows now use calibrated ground-contact positions with ego speed and available yaw-rate compensation. Past positions are transformed into the current vehicle frame, then object velocity is fitted over the recent track. Low-speed residuals are suppressed. Recorded model timestamps are used for replay, independent of playback speed. Signs/lights and uncalibrated camera regions get no motion arrows. Overhead rotations use the same compensated velocity. Live sunnydrive already supplies speed; yaw compensation additionally uses the new `car.yawRateRps` telemetry field when available. No comma service was restarted or deployed by this viewer change.
|
||||
|
||||
Enable Scene segmentation, then choose YOLO segmentation or comma10k segmentation. The source is persisted alongside model size and features; switches release the previous model combination. comma10k runs at 512×384 on Metal and does not have Nano/Small variants—the size selector controls the YOLO object model. It colors road, lane markings, vehicles/people and your car, leaving background untinted. YOLO object masks/tracking remain independent. With comma10k, the existing background US-sign model scans raw frames because comma10k does not locate signs. No OCR is used.
|
||||
|
||||
Radar overlay
|
||||
-------------
|
||||
The **Radar / sensor tracks** checkbox shows sensor markers on the camera and overhead view. Magenta diamonds are radar; cyan markers are camera-reported objects; amber marks candidate radar sources. Radar markers remain visible independently. A connector is drawn only after a clear one-to-one match persists across three distinct frames, with longitudinal/lateral gates, relative-motion checks when available, and ambiguity rejection. Matched camera labels use `R` for radar distance and show relative speed; matched overhead objects use radar positions. Radar source provenance is preserved. Radar distances are from the front bumper; camera projection uses the configured camera-to-bumper offset and ground-plane calibration. Marker labels include longitudinal distance and longitudinal relative speed in m/s. Stale data older than 200 ms is omitted.
|
||||
|
||||
For Hyundai replay recordings whose logged `radarTracks` are empty, the viewer decodes the confirmed 24-byte `0x3A5–0x3C4` CAN family on buses 0–2. The DBC generator is copied from the local `hyundai-radar-tracks` branch; no driving branch checkout or vehicle service change is needed. This fallback supports that family only. Its IDs identify CAN target slots, not persistent object identities. The supplied segment `d59ca223dca8da93/00000498--3190be1e8b/4` contains the full family on bus 1 at 20 Hz and produces nonempty decoded tracks.
|
||||
|
||||
Restart the viewer, then run:
|
||||
|
||||
```
|
||||
yolo d59ca223dca8da93/00000498--3190be1e8b/4
|
||||
```
|
||||
|
||||
Live sensor markers require the updated sunnydrive telemetry code and `tools/yolo/yolo_radar.py` plus its schema on the comma. This change does not deploy them or restart the comma. `/status` exposes a scene snapshot with separate vision and sensor tracks; uncertainty and occupancy remain unknown, and `control_ready` is false.
|
||||
|
||||
Qcam is available in the camera selector for routes/demo launched by the viewer. It decodes the low-resolution qcamera recording separately, synchronized by recorded encode indices, so Road/Qcam switches do not restart replay. Road-camera calibration scales to the Qcam resolution. Qcam is unavailable for live input or externally attached replay without a route.
|
||||
|
||||
Use the Replay / Live car selector in the top bar to switch inputs. Live defaults to `http://10.83.106.109:8766`; edit Comma address and press Connect for another address (for example `100.100.139.123`). Approve pairing on the comma if requested. A source change replaces the local viewer process and stops its owned replay; the existing browser reconnects without opening another tab. Switching back uses the previous route, or the demo if none was supplied.
|
||||
|
||||
Running `yolo` without arguments opens an idle browser UI. The default demo field is `d59ca223dca8da93/00000498--3190be1e8b/6`; press Connect to start it at 0 seconds, or choose Live car and connect a comma address. No video or inference starts until Connect. Explicit route arguments and `--demo` still start playback immediately.
|
||||
|
||||
Radar associations are heuristic and are exposed in `/status` under `scene.radar_associations`, with match costs (not calibrated probabilities). Unknown/camera/candidate sensor sources do not provide radar distances. Missing tracks, source-slot age resets, replay seeks, and camera/model/mask changes drop association history. Validation currently covers synthetic motion and ambiguity cases; recorded-scene accuracy still needs measurement before using associations for decisions.
|
||||
|
||||
Action preview
|
||||
--------------
|
||||
The **Actions / paths** toggle displays observational Gas/Brake/Stop/Go hypotheses and dashed candidate paths in the camera and overhead views. Red lights and stop signs produce stop/hold previews; yellow/mixed lights and yield signs produce brake previews. The display defaults to Go? when no stop/brake cue is observed. This is a display default; missing signals, unobserved road, and unknown right of way do not establish permission or clearance. Radar-backed and ground-positioned camera objects can mark candidate corridors blocked. Recognized no-left/no-right-turn signs mark the corresponding candidate blocked.
|
||||
|
||||
Enable Road / sky segmentation and supply the camera-to-bumper offset for grounded paths. Turn hypotheses sample a 1.8 m footprint over the existing calibrated ground segmentation, require a connected road exit and observed non-road before the opening, and reject a uniformly wide road as intersection evidence. Candidate geometry is not a maneuver plan: intersection topology, which signal/sign applies to a lane, permissions, stop-line distance, road elevation, and crossing traffic remain unverified. Dashed turns can still be wrong. This preview sends no controls; `scene.action_preview.control_ready` remains false.
|
||||
|
||||
Candidate paths are generated solely from the calibrated segmentation grid, with no use of comma model.position/path or lane lines as trajectory inputs. Calibration still comes from comma. The comma trajectory ribbon and center path are no longer rendered; lane-line/road-edge overlays remain independently selectable.
|
||||
|
||||
Straight preview candidates prioritize avoiding detected objects, then road coverage and avoiding yellow painted separators. Yellow is sampled from the original camera image on segmented road; it is a color heuristic, not a dedicated lane-marking classifier. Unknown ground never provides road support.
|
||||
|
||||
Our lanes / edges uses native segmentation pixels to trace open road-facing boundaries, with a continuous painted-divider trace and segmented cars excluded. Camera and top-down render the same curves (inverse camera calibration supplies ground coordinates). The coarse ground-grid contour and polynomial edge fitting implementations were removed. Path ranking also considers the midpoint between overlapping traced boundaries, in addition to obstacle-avoiding candidates; it does not use comma trajectories. Camera geometry, unknown visibility, and segmentation quality still limit accuracy.
|
||||
@@ -1,8 +0,0 @@
|
||||
"""Ensure comma trajectory is not drawn as an independently detected path."""
|
||||
import numpy as np
|
||||
from yolo_web import draw_comma_overlay
|
||||
sample={'roadCamera':{'ground':[0,100,320,-10,0,450,0,0,1],'width':640,'height':480},'model':{'path':{'x':[5,10,20],'y':[0,0,0],'z':[0,0,0]}}}
|
||||
image=np.zeros((480,640,3),np.uint8)
|
||||
draw_comma_overlay(image,sample,'road')
|
||||
assert not image.any()
|
||||
print('Comma model path is omitted from the camera overlay')
|
||||
@@ -1,22 +0,0 @@
|
||||
"""Run with the viewer's Python: python tools/yolo/check_object_labels.py."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
import cv2
|
||||
|
||||
# Load the pure layout helper without importing models or starting a viewer.
|
||||
source=ast.parse(Path(__file__).with_name('yolo_viewer.py').read_text())
|
||||
helper=next(node for node in source.body if isinstance(node,ast.FunctionDef) and node.name=='layout_object_labels')
|
||||
namespace={'cv2':cv2}
|
||||
exec(compile(ast.Module(body=[helper],type_ignores=[]),'label-layout','exec'),namespace)
|
||||
annotations=[((430+i*5,220+i*3,580+i*5,400+i*3),f'car 90% | {i} | ~18 m >') for i in range(12)]
|
||||
placed=namespace['layout_object_labels']((720,1280,3),annotations)
|
||||
assert len(placed)==len(annotations)
|
||||
rectangles=[entry[2] for entry in placed]
|
||||
for i,(x,y,x1,y1) in enumerate(rectangles):
|
||||
assert 0<=x<x1<=1280 and 0<=y<y1<=720
|
||||
for a,b,a1,b1 in rectangles[:i]:
|
||||
assert min(x1,a1)<=max(x,a) or min(y1,b1)<=max(y,b), 'Labels overlap'
|
||||
assert namespace['layout_object_labels']((720,1280,3),[])==[]
|
||||
long=namespace['layout_object_labels']((120,180,3),[((40,50,80,80),'very long sign text '*20)])[0]
|
||||
assert long[2][2]<=180 and long[1].endswith('...')
|
||||
print('Label layout: clustered objects, clipping and long text passed')
|
||||
@@ -1,82 +0,0 @@
|
||||
"""Small regression check for road-signal observations and sign-model labels."""
|
||||
import time
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from yolo_road_objects import RoadObjectReader,traffic_light_state,vehicle_light_activity,draw_vehicle_lights,sign_class_label,traffic_light_summary
|
||||
|
||||
|
||||
def main():
|
||||
summary=traffic_light_summary([(30,'green'),(10,'red'),(20,'red'),(0,'unknown')])
|
||||
assert summary['lights']==['red','red','green'] and summary['dominant_light']=='red'
|
||||
assert summary['dominant_fraction']==2/3
|
||||
assert traffic_light_summary([(1,'red'),(2,'green')])['dominant_light']=='mixed'
|
||||
assert traffic_light_summary([])['dominant_light'] is None
|
||||
central=traffic_light_summary([(10,'red'),(45,'green'),(55,'green'),(90,'red')],(33,67))
|
||||
assert central['lights']==['green','green'] and central['dominant_light']=='green'
|
||||
assert traffic_light_summary([(10,'red')],(33,67))['dominant_light']=='red'
|
||||
assert traffic_light_summary([(10,'red'),(45,'unknown')],(33,67))['lights']==['red']
|
||||
assert traffic_light_summary([(10,'unknown')],(33,67))['dominant_light'] is None
|
||||
|
||||
assert sign_class_label('speedLimit30')=='SPEED LIMIT 30'
|
||||
assert sign_class_label('doNotEnter')=='DO NOT ENTER'
|
||||
|
||||
car=np.zeros((100,160,3),np.uint8)
|
||||
cv2.rectangle(car,(15,40),(30,55),(0,0,200),-1)
|
||||
cv2.rectangle(car,(120,40),(135,55),(0,0,200),-1)
|
||||
overlay=car.copy()
|
||||
draw_vehicle_lights(overlay,car,(0,0,160,100),['brake lights?'])
|
||||
assert not np.array_equal(overlay[45,20],car[45,20])
|
||||
assert not np.array_equal(overlay[45,125],car[45,125])
|
||||
assert np.array_equal(overlay[90,80],car[90,80])
|
||||
assert np.array_equal(car[45,20],[0,0,200])
|
||||
amber=car.copy()
|
||||
amber[40:56,15:31]=(0,180,255)
|
||||
amber[40:56,120:136]=(0,180,255)
|
||||
overlay=amber.copy()
|
||||
draw_vehicle_lights(overlay,amber,(0,0,160,100),['left signal?'])
|
||||
assert not np.array_equal(overlay[45,20],amber[45,20])
|
||||
assert np.array_equal(overlay[45,125],amber[45,125])
|
||||
draw_vehicle_lights(overlay,amber,(0,0,160,100),['hazards?'])
|
||||
assert not np.array_equal(overlay[45,125],amber[45,125])
|
||||
|
||||
for state,color in [('red',(0,0,255)),('yellow',(0,255,255)),('green',(0,255,0))]:
|
||||
crop=np.zeros((90,30,3),np.uint8)
|
||||
cv2.circle(crop,(15,45),6,color,-1)
|
||||
assert traffic_light_state(crop)==state
|
||||
dim=np.zeros((18,8,3),np.uint8)
|
||||
dim[7:9,3:5]=(80,80,150)
|
||||
assert traffic_light_state(dim)=='red'
|
||||
assert traffic_light_state(np.zeros((90,30,3),np.uint8))=='unknown'
|
||||
ambiguous=np.zeros((90,30,3),np.uint8)
|
||||
cv2.circle(ambiguous,(15,20),6,(0,0,255),-1)
|
||||
cv2.circle(ambiguous,(15,65),6,(0,255,0),-1)
|
||||
assert traffic_light_state(ambiguous)=='unknown'
|
||||
flashing=[(t,[(3,.03 if on else 0),(3,0)]) for t,on in ((0,False),(.3,True),(.6,False),(.9,True))]
|
||||
assert vehicle_light_activity(flashing)==['left signal?']
|
||||
assert vehicle_light_activity([(t,[(12,0),(12,0)]) for t in (0,.3,.6,.9)])==[]
|
||||
assert vehicle_light_activity([(0,[(3,0),(3,0)]),(.3,[(3,0),(3,0)]),(.6,[(3,0),(3,0)]),(.9,[(12,0),(12,0)])])==['brake lights?']
|
||||
reader=RoadObjectReader()
|
||||
try:
|
||||
reader.reset('regression')
|
||||
reader.full_signs=([([1,2,20,30],'SPEED LIMIT 30')],time.monotonic())
|
||||
assert reader.read_boxes()[0][1]=='SPEED LIMIT 30'
|
||||
reader.reset('new camera')
|
||||
assert reader.read_boxes()==[]
|
||||
assert reader.sign_label('absent')==''
|
||||
reader.cache['empty']=('',time.monotonic())
|
||||
assert reader.sign_label('empty')==''
|
||||
assert reader.light('dim',dim,0)=='unknown'
|
||||
assert reader.light('dim',dim,0)=='unknown' # Paused redraw adds no vote.
|
||||
assert reader.light('dim',dim,.1)=='red'
|
||||
assert reader.light('dim',np.zeros_like(dim),.2)=='unknown'
|
||||
reader.cache['recognized']=('SPEED LIMIT 30',time.monotonic())
|
||||
assert reader.sign_label('recognized')=='SPEED LIMIT 30'
|
||||
finally:
|
||||
reader.close()
|
||||
print('Traffic-light states, amber flashing, brake brightening, and sign-model labels passed')
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
main()
|
||||
@@ -1,35 +0,0 @@
|
||||
"""Run with the road-segmentation environment: python tools/yolo/check_road_overlay.py."""
|
||||
import numpy as np
|
||||
from yolo_web import draw_comma_overlay
|
||||
|
||||
|
||||
def check():
|
||||
line = lambda y: {"x": [10, 20, 50], "y": [y, y, y]}
|
||||
sample = {
|
||||
"roadCamera": {"width": 1280, "height": 720, "ground": [640, -1000, 0, 360, 0, 1000, 1, 0, 0]},
|
||||
"wideRoadCamera": {"width": 1280, "height": 720, "ground": [640, -500, 0, 360, 0, 500, 1, 0, 0]},
|
||||
"model": {"path": line(0), "laneLines": [line(-1), line(1)], "laneLineProbs": [1, 1],
|
||||
"roadEdges": [line(-3), line(3)]},
|
||||
}
|
||||
road = np.zeros((720, 1280, 3), np.uint8)
|
||||
assert draw_comma_overlay(road, sample, "road")
|
||||
assert tuple(road[460, 340]) == (0, 128, 255) # model y=-3 is the left edge
|
||||
assert tuple(road[460, 540]) == (255, 255, 255) # left lane
|
||||
assert tuple(road[460, 640]) == (0, 0, 0) # comma trajectory is not rendered
|
||||
wide = np.zeros((360, 640, 3), np.uint8)
|
||||
assert draw_comma_overlay(wide, sample, "wideRoad")
|
||||
assert tuple(wide[205, 245]) == (0, 128, 255) # wide calibration + video resizing
|
||||
untouched = road.copy()
|
||||
assert not draw_comma_overlay(road, {}, "road")
|
||||
assert np.array_equal(road, untouched)
|
||||
invisible = np.zeros_like(road)
|
||||
assert draw_comma_overlay(invisible, sample, "road", 0)
|
||||
assert not invisible.any()
|
||||
half = np.zeros_like(road)
|
||||
assert draw_comma_overlay(half, sample, "road", .5)
|
||||
assert np.allclose(half[460, 540], np.array([255, 255, 255]) * .5, atol=1)
|
||||
print("Road/wide projection, video scaling, missing calibration, and opacity checks passed.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
check()
|
||||
@@ -1,101 +0,0 @@
|
||||
"""Regression checks for observational actions and grounded turn candidates."""
|
||||
import numpy as np
|
||||
from yolo_actions import action_preview
|
||||
|
||||
forward,left=np.meshgrid(np.linspace(70,0,141),np.linspace(20,-20,81),indexing='ij')
|
||||
road=np.zeros((141,81),np.int8);road[np.abs(left)<2]=1
|
||||
sample={'car':{'speedMps':8}}
|
||||
scene={'bumper_offset':2,'cars':[],'lights':['red'],'dominant_light':'red','signs':[]}
|
||||
preview=action_preview(scene,sample,road)
|
||||
assert preview['primary']=='stop' and not preview['control_ready']
|
||||
assert [p['direction'] for p in preview['paths']]==['straight']
|
||||
assert action_preview(scene,{'car':{'speedMps':0}},road)['primary']=='hold'
|
||||
scene.update(lights=['green'],dominant_light='green')
|
||||
assert action_preview(scene,sample,road)['primary']=='go?'
|
||||
assert action_preview(scene,sample,None)['primary']=='go?'
|
||||
scene['signs']=['STOP']
|
||||
assert action_preview(scene,sample,road)['primary']=='stop'
|
||||
scene['signs']=['YIELD']
|
||||
assert action_preview(scene,sample,road)['primary']=='brake'
|
||||
scene['signs']=[]
|
||||
unknown=np.full((141,81),-1,np.int8)
|
||||
assert not action_preview(scene,sample,unknown)['paths']
|
||||
assert [p['direction'] for p in action_preview(scene,sample,np.ones_like(road))['paths']]==['straight']
|
||||
right=road.copy();right[(forward>=8)&(forward<=24)&(left<=0)]=1
|
||||
paths=action_preview(scene,sample,right)['paths']
|
||||
assert 'right' in [p['direction'] for p in paths] and 'left' not in [p['direction'] for p in paths]
|
||||
turn=next(p for p in paths if p['direction']=='right')
|
||||
assert turn['points'][-1][1]<0
|
||||
left_turn=road.copy();left_turn[(forward>=8)&(forward<=24)&(left>=0)]=1
|
||||
assert 'left' in [p['direction'] for p in action_preview(scene,sample,left_turn)['paths']]
|
||||
scene['cars']=[{'forward':12,'left':0}]
|
||||
assert action_preview(scene,sample,road)['primary']=='stop'
|
||||
scene.update(cars=[],lights=[],dominant_light=None)
|
||||
assert action_preview(scene,sample,road)['primary']=='go?'
|
||||
print('Action preview: lights, stop/yield signs, obstacles, unknown coverage, straight road and left/right openings passed')
|
||||
|
||||
scene.update(signs=['NO RIGHT TURN'],dominant_light='green',lights=['green'])
|
||||
assert next(p for p in action_preview(scene,sample,right)['paths'] if p['direction']=='right')['blocked']
|
||||
scene['signs']=['NO STOPPING']
|
||||
assert action_preview(scene,sample,road)['primary']=='go?'
|
||||
|
||||
scene.update(cars=[],signs=[])
|
||||
first=action_preview(scene,sample,right)['paths']
|
||||
second=action_preview(scene,dict(sample,model={'path':{'x':[10,20,30],'y':[100,100,100]}}),right)['paths']
|
||||
assert first==second
|
||||
print('Candidate paths are independent of comma model trajectories')
|
||||
|
||||
# A clear road alternative beats a collision, even near a yellow separator.
|
||||
broad=np.ones_like(road)
|
||||
scene.update(cars=[{'forward':24,'left':0}],signs=[],dominant_light='green',lights=['green'])
|
||||
path=action_preview(scene,sample,broad)['paths'][0]
|
||||
assert not path['blocked'] and abs(path['points'][-1][1])>2
|
||||
marked=broad.copy();marked[(abs(left)>=2)&(abs(left)<=4)]=2
|
||||
path=action_preview(scene,sample,marked)['paths'][0]
|
||||
assert not path['blocked']
|
||||
# With no obstacle, prefer remaining inside the separators.
|
||||
scene['cars']=[]
|
||||
path=action_preview(scene,sample,marked)['paths'][0]
|
||||
assert abs(path['points'][-1][1])<2
|
||||
print('Road-only candidate ranking: collision avoidance first, yellow separator preference second')
|
||||
|
||||
assert path['selected']
|
||||
scene['cars']=[{'forward':12,'left':0}]
|
||||
assert not action_preview(scene,sample,road)['paths'][0]['selected']
|
||||
|
||||
# A visible opening ending at unknown ground yields a truncated possible turn.
|
||||
scene.update(cars=[],signs=[])
|
||||
limited=road.copy();limited[(forward>=12)&(forward<=24)&(left<=0)]=1;limited[forward>17]=-1
|
||||
preview=action_preview(scene,sample,limited)
|
||||
turn=next(p for p in preview['paths'] if p['direction']=='right')
|
||||
assert turn['partial'] and max(p[0] for p in turn['points'])<=17.25
|
||||
assert abs(turn['points'][-1][1])>=2.5
|
||||
assert not action_preview(scene,sample,np.full_like(road,-1))['paths']
|
||||
print('Partial turn stops at visible road; unknown ground is never bridged')
|
||||
|
||||
# The nearby opening wins even when another, longer bend fits farther ahead.
|
||||
opening=road.copy();opening[(forward>=8)&(forward<=17)&(left<=0)]=1
|
||||
turn=next(p for p in action_preview(scene,sample,opening)['paths'] if p['direction']=='right')
|
||||
first_bend=next(p for p in turn['points'] if abs(p[1])>.1)
|
||||
assert first_bend[0]<12
|
||||
assert max(p[0] for p in turn['points'])<18
|
||||
print('Turn previews prefer the near segmented opening, with tighter bend candidates')
|
||||
|
||||
|
||||
from yolo_actions import road_boundaries
|
||||
labels=np.full((180,320),2,np.uint8)
|
||||
labels[60:170,70:260]=0
|
||||
labels[60:170,100:103]=1
|
||||
labels[90:105,100:103]=0
|
||||
labels[100:135,225:260]=3
|
||||
edges=road_boundaries(labels,np.zeros_like(labels,bool),1)
|
||||
assert len(edges)>=2
|
||||
left_edge=min(edges,key=lambda e:np.median(np.asarray(e)[:,0]))
|
||||
assert max(p[0] for p in left_edge)-min(p[0] for p in left_edge)<3
|
||||
assert min(p[1] for p in left_edge)<=70 and max(p[1] for p in left_edge)>=160
|
||||
for edge in edges:
|
||||
for x,y in edge:
|
||||
assert not (230<x<255 and 105<y<130)
|
||||
assert not road_boundaries(np.full_like(labels,2),np.zeros_like(labels,bool),1)
|
||||
assert not road_boundaries(labels,np.ones_like(labels,bool),1)
|
||||
print('Image boundaries: continuous dashed divider, car exclusion, empty road and area masks passed')
|
||||
@@ -1,27 +0,0 @@
|
||||
"""Check feature combinations without loading weights or starting the viewer."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
source=ast.parse(Path(__file__).with_name('yolo_viewer.py').read_text())
|
||||
functions=[node for node in source.body if isinstance(node,ast.FunctionDef) and node.name in ('selected_features','feature_selection','load')]
|
||||
class Model:
|
||||
def __init__(self,path):
|
||||
self.path=path
|
||||
def to(self,device):
|
||||
return self
|
||||
namespace={'CommaSegmenter':lambda:Model('comma10k'),'YOLO':Model,'TASKS':{'detect':('Detection',''),'segment':('Masks','-seg'),'semantic':('Scene','-sem')}}
|
||||
exec(compile(ast.Module(body=functions,type_ignores=[]),'features','exec'),namespace)
|
||||
for size in 'nsmlx':
|
||||
for objects in (False,True):
|
||||
for masks in (False,True):
|
||||
for scene in (False,True):
|
||||
selection=namespace['feature_selection'](size,objects,masks,scene)
|
||||
assert namespace['selected_features'](selection)==dict(size=size,objects=objects or masks,masks=masks,scene=scene)
|
||||
models=namespace['load'](selection,Path('/tmp'))
|
||||
assert len(models)==int(scene)+int(objects or masks)
|
||||
assert any('-seg.pt' in model.path for model in models)==masks
|
||||
comma_models=namespace['load'](selection,Path('/tmp'),'comma10k')
|
||||
assert sum(model.path=='comma10k' for model in comma_models)==int(scene)
|
||||
assert len(comma_models)==len(models)
|
||||
assert not any('-sem.pt' in model.path for model in comma_models)
|
||||
print('All 40 model-size and feature combinations passed')
|
||||
@@ -1,61 +0,0 @@
|
||||
"""Check ego-compensated motion, including turns and replay time scaling."""
|
||||
import numpy as np
|
||||
from yolo_motion import compensated_motion
|
||||
|
||||
|
||||
def samples_for(world_velocity=(0.,0.),speed=10.,yaw=0.):
|
||||
rows=[]
|
||||
for t in np.linspace(0,1.5,20):
|
||||
theta=yaw*t
|
||||
ego=np.array([speed*t,0.]) if yaw==0 else np.array([speed*np.sin(theta)/yaw,speed*(1-np.cos(theta))/yaw])
|
||||
world=np.array([35.,4.])+np.array(world_velocity)*t
|
||||
delta=world-ego
|
||||
c,s=np.cos(theta),np.sin(theta)
|
||||
relative=np.array([c*delta[0]+s*delta[1],-s*delta[0]+c*delta[1]])
|
||||
rows.append((t,*relative,speed,yaw))
|
||||
return rows
|
||||
|
||||
for yaw in (0.,.2,-.2):
|
||||
points,velocity=compensated_motion(samples_for(yaw=yaw))
|
||||
assert np.linalg.norm(velocity)<1e-8,(yaw,velocity)
|
||||
assert np.allclose(points,points[-1])
|
||||
_,velocity=compensated_motion(samples_for((2.,1.),yaw=yaw))
|
||||
theta=yaw*1.5;c,s=np.cos(theta),np.sin(theta)
|
||||
assert np.allclose(velocity,[c*2+s,-s*2+c])
|
||||
# Accelerating ego, stationary object; trapezoidal speed integration is exact here.
|
||||
rows=[(t,35.-(5*t+t*t),4.,5+2*t,0.) for t in np.linspace(0,1.5,20)]
|
||||
assert np.linalg.norm(compensated_motion(rows)[1])<1e-8
|
||||
assert compensated_motion([]) is None
|
||||
assert compensated_motion([(1,30,0,10,0),(1,30,0,10,0)]) is None
|
||||
assert compensated_motion([(1,30,0,10,0),(0,30,0,10,0)]) is None
|
||||
assert compensated_motion([(0,30,0,10,0),(1,20,0,float('nan'),0)]) is None
|
||||
print('Motion: stationary/ moving objects, left/right turns, acceleration and invalid time passed')
|
||||
|
||||
# Exercise the viewer path: a stationary car must produce no painted trail.
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import cv2
|
||||
source=ast.parse(Path(__file__).with_name('yolo_viewer.py').read_text())
|
||||
helper=next(node for node in source.body if isinstance(node,ast.FunctionDef) and node.name=='draw_track_motion')
|
||||
class Tensor:
|
||||
def __init__(self,value):self.value=np.asarray(value)
|
||||
def cpu(self):return self
|
||||
def numpy(self):return self.value
|
||||
def tolist(self):return self.value.tolist()
|
||||
namespace={'np':np,'cv2':cv2,'compensated_motion':compensated_motion,
|
||||
'object_ground_position':lambda box,*args:np.asarray(box[:2]),
|
||||
'project_ground_points':lambda points,*args:np.asarray(points)+[0,30]}
|
||||
exec(compile(ast.Module(body=[helper],type_ignores=[]),'draw-motion','exec'),namespace)
|
||||
draw=namespace['draw_track_motion'];history={};image=np.zeros((100,100,3),np.uint8)
|
||||
sample={'car':{'speedMps':10.,'yawRateRps':0.}}
|
||||
for t in np.linspace(0,1,15):
|
||||
sample['motionTime']=float(t)
|
||||
boxes=SimpleNamespace(id=Tensor([1]),cls=Tensor([0]),xyxy=Tensor([[35-10*t,4,40,8]]))
|
||||
assert draw(image,boxes,history,100+t,True,{0:'car'},(sample,'road',None,None))=={}
|
||||
assert not image.any()
|
||||
count=len(history[1]);draw(image,boxes,history,101,True,{0:'car'},(sample,'road',None,None))
|
||||
assert len(history[1])==count
|
||||
sample['car']={}
|
||||
assert draw(image,boxes,history,101,True,{0:'car'},(sample,'road',None,None))=={} and not history
|
||||
print('Viewer: stationary trails suppressed, paused redraw unchanged, missing speed clears history')
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Check that redraws reuse inference and changed input runs each selected model once."""
|
||||
import ast
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
source=ast.parse(Path(__file__).with_name('yolo_viewer.py').read_text())
|
||||
helper=next(node for node in source.body if isinstance(node,ast.FunctionDef) and node.name=='infer')
|
||||
namespace={'cv2':cv2,'np':np}
|
||||
palette_nodes=[node for node in source.body if isinstance(node,ast.Assign) and ast.unparse(node).startswith(('SCENE_PALETTE =','SCENE_COLORS =','SCENE_COLORS['))]
|
||||
exec(compile(ast.Module(body=palette_nodes,type_ignores=[]),'scene-colors','exec'),namespace)
|
||||
labels=np.random.default_rng(0).integers(0,19,(120,160),dtype=np.int64)
|
||||
assert np.array_equal(cv2.applyColorMap(labels.astype(np.uint8),namespace['SCENE_COLORS']),namespace['SCENE_PALETTE'][labels])
|
||||
assert np.array_equal(np.flatnonzero(np.bincount(labels.reshape(-1),minlength=19)),np.unique(labels))
|
||||
exec(compile(ast.Module(body=[helper],type_ignores=[]),'inference-cache','exec'),namespace)
|
||||
class Tensor:
|
||||
def __init__(self):
|
||||
self.transfers=0
|
||||
def cpu(self):
|
||||
self.transfers+=1
|
||||
return self
|
||||
class Model:
|
||||
def __init__(self,task):
|
||||
self.task=task
|
||||
self.calls=0
|
||||
def predict(self,*args,**kwargs):
|
||||
self.calls+=1
|
||||
return [SimpleNamespace(boxes=Tensor() if self.task=='detect' else None,semantic_mask=Tensor() if self.task=='semantic' else None)]
|
||||
track=predict
|
||||
models=[Model('semantic'),Model('detect')]
|
||||
cache={}
|
||||
infer=namespace['infer']
|
||||
results=infer(models,None,640,cache,(1,'layout'))
|
||||
for _ in range(10):
|
||||
assert infer(models,None,640,cache,(1,'layout')) is results
|
||||
assert [m.calls for m in models]==[1,1]
|
||||
assert results[0][1].semantic_mask.transfers==1
|
||||
assert results[1][1].boxes.transfers==1
|
||||
infer(models,None,640,cache,(2,'layout'))
|
||||
infer(models,None,640,cache,(2,'edited mask'))
|
||||
assert [m.calls for m in models]==[3,3]
|
||||
cache.clear()
|
||||
assert infer([],None,640,cache,(2,'no models'))==[]
|
||||
print('Inference cache: redraw reuse, tensor transfers, new frames and mask edits passed')
|
||||
@@ -1,48 +0,0 @@
|
||||
"""Run with the viewer Python environment."""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
sys.path[:0]=[str(Path(__file__).resolve().parents[2]/'opendbc_repo')]
|
||||
from yolo_radar import radar_snapshot,decode_tracks,HyundaiReplayRadar
|
||||
from opendbc.can import CANPacker
|
||||
from types import SimpleNamespace
|
||||
r=HyundaiReplayRadar()
|
||||
p=CANPacker(r.parsers[1].dbc_name)
|
||||
frames=[]
|
||||
for addr in range(0x3a5,0x3c5):
|
||||
a,data,bus=p.make_can_msg(f'RADAR_TRACK_{addr:x}',1,dict(STATE=3,LONG_DIST=20,LAT_DIST=-3,REL_SPEED=-5,AGE=10))
|
||||
frames.append(SimpleNamespace(address=a,dat=data,src=bus))
|
||||
e=SimpleNamespace(logMonoTime=1_000_000_000,can=frames)
|
||||
s=r.update([e],1)
|
||||
assert s['status']=='ready' and len(s['tracks'])==32
|
||||
assert s['tracks'][0]['source']=='radar' and s['tracks'][0]['left']==-3
|
||||
assert not r.update([],1.3)['tracks']
|
||||
assert radar_snapshot(dict(points=[],errors={'canError':True}),1,1)['status']=='invalid'
|
||||
assert radar_snapshot(None,0,1)['status']=='unavailable'
|
||||
print('radar decoding, source, coordinate sign, freshness and errors passed')
|
||||
|
||||
from yolo_radar import RadarAssociator
|
||||
track=dict(id=1,bus=1,address=0x3a5,source='radar',forward=20.,left=0.,relative_speed=0.,track_age=10)
|
||||
obj=dict(id=5,forward=20.,left=0.,box=[10,10,40,40])
|
||||
m=RadarAssociator()
|
||||
assert not m.update([obj],[track],0.)
|
||||
for _ in range(4):assert not m.update([obj],[track],0.)
|
||||
assert not m.update([obj],[track],.2)
|
||||
assert m.update([obj],[track],.4)[5]['track']['id']==1
|
||||
assert not m.update([obj],[],.6)
|
||||
m.reset()
|
||||
for time in (0.,.2,.4):
|
||||
assert not m.update([obj,dict(obj,id=6,left=.1)],[track],time)
|
||||
m.reset()
|
||||
for time in (0.,.2,.4):
|
||||
assert not m.update([dict(obj,left=4)],[track],time)
|
||||
m.reset()
|
||||
for time in (0.,.2,.4):
|
||||
assert not m.update([obj],[track,dict(track,id=2,address=0x3a6,left=.1)],time)
|
||||
m.reset()
|
||||
m.update([obj],[track],0.);m.update([obj],[track],.2)
|
||||
assert not m.update([obj],[dict(track,track_age=1)],.4)
|
||||
assert not m.update([obj],[dict(track,source='camera')],.6)
|
||||
m.reset()
|
||||
m.update([obj],[track],0.)
|
||||
assert not m.update([dict(obj,forward=24.)],[track],.2)
|
||||
print('Association: persistence, paused frames, ambiguity, lateral/motion gates, stale data, slot reuse and provenance passed')
|
||||
@@ -1,171 +0,0 @@
|
||||
"""Observational action hypotheses. No actuator or vehicle-control interface."""
|
||||
import math
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def corridor_samples(points,ground):
|
||||
"""Sample a 1.8 m footprint; unknown pixels provide no road evidence."""
|
||||
if ground is None:return np.array([],np.int8)
|
||||
points=np.asarray(points,float)
|
||||
points=points[points[:,0]>=5]
|
||||
if len(points)<2:return np.array([],np.int8)
|
||||
delta=np.gradient(points,axis=0)
|
||||
normal=np.column_stack((-delta[:,1],delta[:,0]))
|
||||
normal/=np.maximum(np.linalg.norm(normal,axis=1,keepdims=True),1e-6)
|
||||
footprint=(points[:,None,:]+normal[:,None,:]*np.array([-.9,0,.9])[None,:,None]).reshape(-1,2)
|
||||
rows=np.rint((70-footprint[:,0])*2).astype(int);cols=np.rint((20-footprint[:,1])*2).astype(int)
|
||||
valid=(rows>=0)&(rows<141)&(cols>=0)&(cols<81)
|
||||
samples=np.full(len(rows),-1,np.int8)
|
||||
samples[valid]=ground[rows[valid],cols[valid]]
|
||||
return samples
|
||||
|
||||
|
||||
def corridor_support(points,ground):
|
||||
samples=corridor_samples(points,ground)
|
||||
return float(np.isin(samples,[1,2]).mean()) if len(samples) else 0.
|
||||
|
||||
|
||||
def clearance(points,objects):
|
||||
return min((float(np.linalg.norm(np.asarray(points)-[obj['forward'],obj['left']],axis=1).min())
|
||||
for obj in objects if obj['forward']>=0),default=100.)
|
||||
|
||||
|
||||
def blocked(points,objects):
|
||||
return clearance(points,objects)<2.
|
||||
|
||||
|
||||
def action_preview(scene,sample,ground):
|
||||
paths=[]
|
||||
forward=np.linspace(3,30,40)
|
||||
candidates=[]
|
||||
proposals=[np.column_stack((forward,shift*(forward/30)**2)) for shift in np.linspace(-6,6,13)]
|
||||
bounds=scene.get('our_edges',[])
|
||||
for first in bounds:
|
||||
for second in bounds:
|
||||
a=np.asarray(first);b=np.asarray(second)
|
||||
if len(a)<2 or len(b)<2:continue
|
||||
a=a[np.argsort(a[:,0])];b=b[np.argsort(b[:,0])]
|
||||
start=max(3.,a[0,0],b[0,0]);end=min(30.,a[-1,0],b[-1,0])
|
||||
if start>8 or end-start<10:continue
|
||||
x=np.linspace(start,end,40)
|
||||
left=np.interp(x,a[:,0],a[:,1]);right=np.interp(x,b[:,0],b[:,1])
|
||||
if not np.all((left-right>1.8)&(left-right<10)):continue
|
||||
proposals.append(np.column_stack((x,(left+right)*.5)))
|
||||
for points in proposals:
|
||||
shift=points[-1,1]
|
||||
support=corridor_support(points,ground)
|
||||
if support>=.85:
|
||||
gap=clearance(points,scene.get('cars',[]))
|
||||
separator=float((corridor_samples(points,ground)==2).mean())
|
||||
# Collision avoidance outranks separator preference; unknown ground never counts as road.
|
||||
score=(gap>=2.,gap if gap<2 else support-separator*.35+min(gap,6)*.03-abs(shift)*.005)
|
||||
candidates.append((score,points))
|
||||
if candidates:
|
||||
straight=max(candidates,key=lambda item:item[0])[1]
|
||||
paths.append(dict(direction='straight',points=straight.tolist(),blocked=blocked(straight,scene.get('cars',[]))))
|
||||
# A geometric turn is evidence of an opening, not intersection topology or permission.
|
||||
for direction,sign in (('left',1),('right',-1)):
|
||||
best=None
|
||||
for junction in range(5,31):
|
||||
for radius in (3,4,6,9,12):
|
||||
if ground is None:continue
|
||||
# Reject a uniformly wide road: require observed non-road before the opening.
|
||||
rows=np.rint((70-np.array([junction-4,junction-3,junction-2]))*2).astype(int)
|
||||
columns=np.rint((20-sign*np.linspace(3,radius+2,6))*2).astype(int)
|
||||
if not np.any((ground[rows[:,None],columns[None,:]]==0).sum(axis=0)>=2):continue
|
||||
angle=np.linspace(0,math.pi/2,25)
|
||||
turn=np.column_stack((junction+radius*np.sin(angle),sign*radius*(1-np.cos(angle))))
|
||||
approach=np.column_stack((np.linspace(3,junction,20),np.zeros(20)))
|
||||
if corridor_support(approach,ground)<.92:continue
|
||||
# Keep only the contiguous observed-road footprint, never bridge unseen ground.
|
||||
samples=corridor_samples(turn,ground).reshape(-1,3)
|
||||
unsupported=np.flatnonzero(~np.isin(samples,[1,2]).all(axis=1))
|
||||
count=int(unsupported[0]) if len(unsupported) else len(turn)
|
||||
if count<2 or angle[count-1]<math.radians(40) or abs(turn[count-1,1])<2.5:continue
|
||||
points=np.vstack((approach,turn[1:count]))
|
||||
partial=count<len(turn)
|
||||
if not partial:
|
||||
exit_points=np.column_stack((np.full(8,junction+radius),sign*np.linspace(radius,radius+3,8)))
|
||||
partial=corridor_support(exit_points,ground)<.9
|
||||
# Prefer the nearest supported opening, rather than a longer arc farther ahead.
|
||||
score=(not blocked(points,scene.get('cars',[])),-junction,not partial,angle[count-1],-radius)
|
||||
if best is None or score>best[0]:best=(score,points,partial)
|
||||
if best is not None:
|
||||
points=best[1]
|
||||
paths.append(dict(direction=direction,points=points.tolist(),partial=best[2],blocked=blocked(points,scene.get('cars',[]))))
|
||||
for path in paths:
|
||||
path['selected']=path['direction']=='straight' and not path['blocked']
|
||||
lights=scene.get('lights',[]);dominant=scene.get('dominant_light')
|
||||
signs=scene.get('signs',[])
|
||||
stop_sign=any(sign.upper() in ('STOP','STOP SIGN') for sign in signs)
|
||||
yield_sign=any(sign.upper() in ('YIELD','YIELD SIGN') for sign in signs)
|
||||
for path in paths:
|
||||
forbidden=any(sign.upper() in ('NO '+path['direction'].upper()+' TURN','NO TURN '+path['direction'].upper()) for sign in signs)
|
||||
if forbidden:path.update(blocked=True,selected=False,reason='prohibitory sign observed')
|
||||
red=dominant=='red' or (dominant=='mixed' and 'red' in lights)
|
||||
caution=dominant in ('yellow','mixed') or yield_sign
|
||||
obstacle=any(p['blocked'] for p in paths if p['direction']=='straight')
|
||||
speed=((sample or {}).get('car') or {}).get('speedMps')
|
||||
stopped=speed is not None and speed<.3
|
||||
must_stop=red or stop_sign or obstacle
|
||||
reason='red light observed' if red else 'stop sign observed' if stop_sign else 'object in corridor' if obstacle else 'yellow / mixed signal or yield sign' if caution else 'green light observed' if dominant=='green' else 'signal / right of way unknown'
|
||||
primary=('hold' if stopped else 'stop') if must_stop else 'brake' if caution else 'go?'
|
||||
go=not must_stop and not caution
|
||||
actions=[dict(name='Gas',state='candidate' if go else 'unknown'),
|
||||
dict(name='Brake',state='candidate' if not stopped and (must_stop or caution) else 'inactive'),
|
||||
dict(name='Stop',state='candidate' if must_stop else 'inactive'),
|
||||
dict(name='Go',state='candidate' if go else 'unknown')]
|
||||
return dict(primary=primary,reason=reason,actions=actions,paths=paths,
|
||||
path_status='ready' if paths else 'ground_unavailable' if ground is None else 'no_supported_corridor',
|
||||
status='unverified',right_of_way='unknown',cross_traffic='unknown',control_ready=False)
|
||||
|
||||
|
||||
def road_boundaries(labels,excluded,lane_class=None):
|
||||
"""Open image-space sides of the ego road, excluding segmented obstacles."""
|
||||
height,width=labels.shape
|
||||
# Work at native segmentation resolution; preserve masks and painted dividers.
|
||||
free=(labels==0)&~excluded
|
||||
if lane_class is not None:
|
||||
paint=(labels==lane_class).astype(np.uint8)
|
||||
paint=cv2.morphologyEx(paint,cv2.MORPH_CLOSE,np.ones((9,3),np.uint8))
|
||||
free &= ~paint.astype(bool)
|
||||
free=cv2.morphologyEx(free.astype(np.uint8),cv2.MORPH_OPEN,np.ones((3,3),np.uint8)).astype(bool)
|
||||
traces=[[],[]];paint_trace=[];center=width*.5
|
||||
for row in range(height-2,height//3,-1):
|
||||
columns=np.flatnonzero(free[row])
|
||||
if not len(columns):continue
|
||||
runs=np.split(columns,np.flatnonzero(np.diff(columns)>1)+1)
|
||||
runs=[run for run in runs if len(run)>=max(3,width//100)]
|
||||
if not runs:continue
|
||||
run=min(runs,key=lambda r:abs(np.clip(center,r[0],r[-1])-center))
|
||||
if abs(np.clip(center,run[0],run[-1])-center)>width*.12:continue
|
||||
center=.8*center+.2*(run[0]+run[-1])/2
|
||||
if lane_class is not None:
|
||||
lane_cols=np.flatnonzero((labels[row]==lane_class)&(np.arange(width)<center))
|
||||
if len(lane_cols) and (labels[row]==lane_class).sum()<width*.15:
|
||||
paint_trace.append((float(lane_cols[-1]+1),float(row)))
|
||||
for side,col in enumerate((run[0],run[-1])):
|
||||
if 1<col<width-2 and not excluded[row,col]:traces[side].append((float(col),float(row)))
|
||||
if len(paint_trace)>=12:traces[0]=paint_trace
|
||||
edges=[]
|
||||
for side,trace in enumerate(traces):
|
||||
if len(trace)<8:continue
|
||||
points=np.asarray(trace);x,y=points.T
|
||||
# Remove paint-gap excursions; preserve genuine intersection discontinuities.
|
||||
median=np.median(np.lib.stride_tricks.sliding_window_view(np.pad(x,(7,7),mode='edge'),15),axis=1)
|
||||
good=abs(x-median)<width*.035
|
||||
discontinuity=(abs(np.diff(median))>width*.04)
|
||||
if not (side==0 and len(paint_trace)>=12):discontinuity |= abs(np.diff(y))>3
|
||||
splits=np.flatnonzero(discontinuity)+1
|
||||
for ids in np.split(np.arange(len(points)),splits):
|
||||
ids=ids[good[ids]]
|
||||
if len(ids)<8:continue
|
||||
p=points[ids].copy();v=median[ids]
|
||||
dense_y=np.arange(p[-1,1],p[0,1]+1)[::-1]
|
||||
dense_x=np.interp(dense_y,p[::-1,1],v[::-1])
|
||||
p=np.column_stack((dense_x,dense_y));v=dense_x
|
||||
p[:,0]=np.convolve(np.pad(v,(2,2),mode='edge'),np.ones(5)/5,mode='valid')
|
||||
# Each segment is open; never wrap around a marking or a parked car.
|
||||
edges.append(p.tolist())
|
||||
return edges
|
||||
@@ -1,345 +0,0 @@
|
||||
const areaSVG=document.createElementNS('http://www.w3.org/2000/svg','svg');
|
||||
areaSVG.setAttribute('viewBox','0 0 1000 1000');areaSVG.setAttribute('preserveAspectRatio','none');
|
||||
areaSVG.setAttribute('aria-label','Area selection editor');
|
||||
areaSVG.style.cssText='position:absolute;display:none;touch-action:none;z-index:2;cursor:crosshair';
|
||||
video.parentElement.append(areaSVG);video.parentElement.style.userSelect='none';
|
||||
const areaControls=document.createElement('div');areaControls.className='controls';areaControls.style.marginTop='12px';
|
||||
areaControls.innerHTML='<button id="edit-area" type="button">Edit areas</button><button id="add-area" type="button">Add blackout area</button><button id="finish-area" type="button" hidden>Finish area</button><button id="remove-area" type="button">Remove area</button><span id="area-help" hidden>Add an area, then drag its points to adjust it</span>';
|
||||
document.querySelector('.opacity-controls').after(areaControls);
|
||||
const areaStorage='sunnydrive-yolo-blackout-v1';let blackoutAreas={};
|
||||
try{blackoutAreas=JSON.parse(localStorage.getItem(areaStorage)||'{}');}catch{}
|
||||
let drawingArea=null,selectedArea=null,dragArea=null;
|
||||
const maskLayoutStorage='sunnydrive-yolo-mask-layout-v1';let maskLayouts={},sideGeometry=null;
|
||||
let maskSides=[];
|
||||
try{maskSides=JSON.parse(localStorage.getItem('sunnydrive-yolo-mask-sides-v1')||'[]');}catch{}
|
||||
try{maskLayouts=JSON.parse(localStorage.getItem(maskLayoutStorage)||'{}');}catch{}
|
||||
function currentSideLayout(){return {rotation:{left:Number(rotationInputs[0].value),right:Number(rotationInputs[1].value)},position:{left:positionInputs.slice(0,2).map(i=>Number(i.value)/100),right:positionInputs.slice(2).map(i=>Number(i.value)/100)}};}
|
||||
function sidePoint(point,side,layout,inverse=false){
|
||||
const [height,width,driverHeight,leftNative,rightNative,wideWidth]=sideGeometry;
|
||||
const nativeWidth=side==='left'?leftNative:rightNative,panelWidth=Math.round(nativeWidth*height/driverHeight);
|
||||
const base=side==='left'?0:Math.round(leftNative*height/driverHeight)+wideWidth;
|
||||
const [px,py]=layout.position[side],r=layout.rotation[side]*Math.PI/180,c=Math.cos(r),s=Math.sin(r);
|
||||
const cx=(nativeWidth-1)/2,cy=(driverHeight-1)/2,sx=panelWidth/nativeWidth,sy=height/driverHeight;
|
||||
const tx=(sx-1)/2+base+Math.round(px*width),ty=(sy-1)/2+Math.round(py*height);
|
||||
if(inverse){
|
||||
const x=(point[0]*(width-1)-tx)/sx-cx,y=(point[1]*(height-1)-ty)/sy-cy;
|
||||
return [(c*x-s*y+cx)/(nativeWidth-1),(s*x+c*y+cy)/(driverHeight-1)];
|
||||
}
|
||||
const x=point[0]*(nativeWidth-1)-cx,y=point[1]*(driverHeight-1)-cy;
|
||||
return [(sx*(c*x+s*y+cx)+tx)/(width-1),(sy*(-s*x+c*y+cy)+ty)/(height-1)];
|
||||
}
|
||||
function syncSideAreas(){
|
||||
if(camera.value!=='wideSides'||!sideGeometry)return;
|
||||
const next=currentSideLayout(),previous=maskLayouts.wideSides;
|
||||
if(previous&&JSON.stringify(previous)!==JSON.stringify(next)){
|
||||
const move=(poly,index)=>{
|
||||
if(!poly.length)return poly;
|
||||
const center=poly.reduce((a,p)=>[a[0]+p[0]/poly.length,a[1]+p[1]/poly.length],[0,0]);
|
||||
for(const side of (index<maskSides.length?[maskSides[index]].filter(Boolean):['right','left'])){
|
||||
const local=sidePoint(center,side,previous,true);
|
||||
if(local.every(v=>v>=0&&v<=1)){maskSides[index]=side;return poly.map(p=>sidePoint(sidePoint(p,side,previous,true),side,next));}
|
||||
}
|
||||
maskSides[index]=null;return poly;
|
||||
};
|
||||
blackoutAreas.wideSides=areas().map(move);
|
||||
if(drawingArea)drawingArea=move(drawingArea,areas().length);
|
||||
}
|
||||
for(let index=maskSides.length;index<areas().length;index++){
|
||||
const poly=areas()[index],center=poly.reduce((a,p)=>[a[0]+p[0]/poly.length,a[1]+p[1]/poly.length],[0,0]);
|
||||
maskSides[index]=['right','left'].find(side=>sidePoint(center,side,next,true).every(v=>v>=0&&v<=1))||null;
|
||||
}
|
||||
maskSides.length=areas().length;
|
||||
localStorage.setItem('sunnydrive-yolo-mask-sides-v1',JSON.stringify(maskSides));
|
||||
maskLayouts.wideSides=next;
|
||||
localStorage.setItem(maskLayoutStorage,JSON.stringify(maskLayouts));
|
||||
}
|
||||
function updateSideGeometry(geometry){
|
||||
if(!geometry)return;
|
||||
const first=!sideGeometry;sideGeometry=geometry;
|
||||
if(first){syncSideAreas();areaChanged();}
|
||||
}
|
||||
|
||||
function areas(){return blackoutAreas[camera.value]||[];}
|
||||
let editing=false,dragPoint=null,areaTimer;
|
||||
function areaLayout(){
|
||||
const w=video.parentElement.clientWidth,h=video.parentElement.clientHeight;
|
||||
const ratio=video.naturalWidth/video.naturalHeight||16/9;
|
||||
const vw=Math.min(w,h*ratio),vh=vw/ratio;
|
||||
Object.assign(areaSVG.style,{left:(w-vw)/2+'px',top:(h-vh)/2+'px',width:vw+'px',height:vh+'px'});
|
||||
areaDraw();
|
||||
}
|
||||
function areaDraw(){
|
||||
const all=[...areas(),...(drawingArea?[drawingArea]:[])];
|
||||
const rx=1000/Math.max(areaSVG.clientWidth,1),ry=1000/Math.max(areaSVG.clientHeight,1);
|
||||
areaSVG.innerHTML=all.map((poly,a)=>{
|
||||
const outline=`<polygon data-area="${a}" points="${poly.map(p=>p.map(v=>v*1000).join(',')).join(' ')}" fill="transparent" stroke="${selectedArea===a?'#ffdc00':'#ff9666'}" stroke-width="2" vector-effect="non-scaling-stroke"/>`;
|
||||
const handles=poly.map((p,i)=>`<g data-area="${a}" data-point="${i}" style="cursor:grab"><ellipse cx="${p[0]*1000}" cy="${p[1]*1000}" rx="${16*rx}" ry="${16*ry}" fill="transparent"/><ellipse cx="${p[0]*1000}" cy="${p[1]*1000}" rx="${7*rx}" ry="${7*ry}" fill="#ff9666" stroke="#111" stroke-width="2" vector-effect="non-scaling-stroke"/></g>`).join('');
|
||||
if(a===areas().length)return outline+handles;
|
||||
const x=Math.max(18*rx,Math.min(1000-18*rx,Math.max(...poly.map(p=>p[0]*1000))+24*rx));
|
||||
const y=Math.max(18*ry,Math.min(1000-18*ry,Math.min(...poly.map(p=>p[1]*1000))));
|
||||
return outline+handles+`<g data-delete="${a}" role="button" tabindex="0" aria-label="Delete area ${a+1}" style="cursor:pointer"><title>Delete area ${a+1}</title><ellipse cx="${x}" cy="${y}" rx="${15*rx}" ry="${15*ry}" fill="#b32929" stroke="white" stroke-width="1" vector-effect="non-scaling-stroke"/><path d="M ${x-5*rx},${y-5*ry} L ${x+5*rx},${y+5*ry} M ${x+5*rx},${y-5*ry} L ${x-5*rx},${y+5*ry}" stroke="white" stroke-width="2" vector-effect="non-scaling-stroke"/></g>`;
|
||||
}).join('');
|
||||
document.querySelector('#finish-area').disabled=!drawingArea||drawingArea.length<3;
|
||||
document.querySelector('#remove-area').textContent=drawingArea?'Cancel area':'Remove area';
|
||||
}
|
||||
function deleteArea(index){areas().splice(index,1);if(camera.value==='wideSides')maskSides.splice(index,1);selectedArea=null;dragArea=null;dragPoint=null;areaChanged();}
|
||||
areaSVG.onkeydown=e=>{
|
||||
const button=e.target.closest('[data-delete]');
|
||||
if(button&&(e.key==='Enter'||e.key===' ')){e.preventDefault();deleteArea(Number(button.dataset.delete));}
|
||||
};
|
||||
async function areaSave(){
|
||||
syncSideAreas();
|
||||
localStorage.setItem(areaStorage,JSON.stringify(blackoutAreas));
|
||||
const maskResult=await fetch('/masks',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:camera.value,polygons:areas(),layout:camera.value==='wideSides'?maskLayouts.wideSides:null,sides:camera.value==='wideSides'&&maskSides.length===areas().length?maskSides:null})});
|
||||
if(!maskResult.ok)document.querySelector('#area-help').textContent='Could not save blackout areas; try again.';
|
||||
|
||||
}
|
||||
function areaChanged(){areaDraw();clearTimeout(areaTimer);areaTimer=setTimeout(areaSave,80);}
|
||||
function areaPosition(e){const r=areaSVG.getBoundingClientRect();return [Math.max(0,Math.min(1,(e.clientX-r.left)/r.width)),Math.max(0,Math.min(1,(e.clientY-r.top)/r.height))];}
|
||||
areaSVG.onpointerdown=e=>{
|
||||
const remove=e.target.closest('[data-delete]');
|
||||
if(remove){deleteArea(Number(remove.dataset.delete));return;}
|
||||
const handle=e.target.closest('[data-point]');
|
||||
if(handle){
|
||||
dragArea=Number(handle.dataset.area);dragPoint=Number(handle.dataset.point);
|
||||
selectedArea=dragArea;areaSVG.setPointerCapture(e.pointerId);areaDraw();return;
|
||||
}
|
||||
if(drawingArea){if(drawingArea.length<32)drawingArea.push(areaPosition(e));areaDraw();return;}
|
||||
const polygon=e.target.closest('[data-area]');
|
||||
selectedArea=polygon?Number(polygon.dataset.area):null;areaDraw();
|
||||
};
|
||||
areaSVG.onpointermove=e=>{
|
||||
if(dragPoint===null||dragArea===null)return;
|
||||
const poly=dragArea===areas().length?drawingArea:areas()[dragArea];
|
||||
if(!poly)return;
|
||||
poly[dragPoint]=areaPosition(e);
|
||||
if(poly===drawingArea)areaDraw();else areaChanged();
|
||||
};
|
||||
areaSVG.onpointerup=areaSVG.onpointercancel=()=>{dragPoint=null;dragArea=null;};
|
||||
|
||||
document.querySelector('#edit-area').onclick=()=>{editing=!editing;areaSVG.style.display=editing?'block':'none';document.querySelector('#area-help').hidden=!editing;document.querySelector('#edit-area').textContent=editing?'Done editing':'Edit areas';areaLayout();};
|
||||
document.querySelector('#add-area').onclick=()=>{
|
||||
if(areas().length>=16)return;
|
||||
if(!editing)document.querySelector('#edit-area').click();
|
||||
drawingArea=[];selectedArea=null;document.querySelector('#finish-area').hidden=false;
|
||||
document.querySelector('#area-help').textContent=maskModeSelect.value==='keep'?'Trace a window or area to keep, then Finish area. Add more keep areas for other windows. Everything else is masked.':'Click to outline an area, then Finish area. Drag orange points to reshape; click its X to delete it.';
|
||||
areaDraw();
|
||||
};
|
||||
document.querySelector('#finish-area').onclick=()=>{
|
||||
if(!drawingArea||drawingArea.length<3)return;
|
||||
blackoutAreas[camera.value]=[...areas(),drawingArea];selectedArea=areas().length-1;drawingArea=null;
|
||||
document.querySelector('#finish-area').hidden=true;areaChanged();
|
||||
};
|
||||
document.querySelector('#remove-area').onclick=()=>{
|
||||
if(drawingArea){drawingArea=null;document.querySelector('#finish-area').hidden=true;areaDraw();return;}
|
||||
const list=areas();if(!list.length)return;
|
||||
deleteArea(selectedArea===null?list.length-1:selectedArea);
|
||||
};
|
||||
camera.addEventListener('change',()=>{dragPoint=null;dragArea=null;selectedArea=null;drawingArea=null;document.querySelector('#finish-area').hidden=true;areaDraw();areaSave();});
|
||||
video.addEventListener('load',areaLayout);new ResizeObserver(areaLayout).observe(video.parentElement);
|
||||
fetch('/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{camera.value=s.camera;areaDraw();areaSave();});
|
||||
const rotationControls=document.createElement('div');rotationControls.className='opacity-controls';
|
||||
rotationControls.innerHTML=['left','right'].map(side=>`<label>${side==='left'?'Left':'Right'} side rotation <input id="rotate-${side}" aria-label="${side==='left'?'Left':'Right'} side rotation" type="range" min="-180" max="180" step="1" value="0"><output id="angle-${side}">0°</output></label>`).join('');
|
||||
areaControls.after(rotationControls);
|
||||
const rotationInputs=['left','right'].map(side=>document.querySelector('#rotate-'+side));
|
||||
const rotationStorage='sunnydrive-yolo-side-rotation-v1';let rotationTimer;
|
||||
try{const saved=JSON.parse(localStorage.getItem(rotationStorage)||'{}');rotationInputs.forEach((input,i)=>{const v=saved[['left','right'][i]];if(Number.isFinite(v)&&v>=-180&&v<=180)input.value=v;});}catch{}
|
||||
function rotationLabels(){rotationInputs.forEach((input,i)=>document.querySelector('#angle-'+['left','right'][i]).textContent=input.value+'°');}
|
||||
async function rotationSave(){
|
||||
const angles={left:Number(rotationInputs[0].value),right:Number(rotationInputs[1].value)};
|
||||
localStorage.setItem(rotationStorage,JSON.stringify(angles));
|
||||
const r=await fetch('/rotation',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(angles)});
|
||||
if(!r.ok)document.querySelector('#area-help').textContent='Could not save side rotation; try again.';
|
||||
}
|
||||
rotationInputs.forEach(input=>input.oninput=()=>{rotationLabels();syncSideAreas();areaChanged();clearTimeout(rotationTimer);rotationTimer=setTimeout(rotationSave,80);});
|
||||
function rotationVisibility(){sideControls.style.display=camera.value==='wideSides'?'grid':'none';}
|
||||
camera.addEventListener('change',rotationVisibility);
|
||||
fetch('/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{camera.value=s.camera;rotationVisibility();rotationLabels();rotationSave();});
|
||||
const positionControls=document.createElement('div');positionControls.className='opacity-controls';
|
||||
const positionKeys=['left-x','left-y','right-x','right-y'];
|
||||
positionControls.innerHTML=positionKeys.map(key=>{const [side,axis]=key.split('-'),label=(side==='left'?'Left':'Right')+' side '+(axis==='x'?'horizontal':'vertical');return `<label>${label} <input id="position-${key}" aria-label="${label}" type="range" min="-50" max="50" step="1" value="0"><output id="offset-${key}">0%</output></label>`;}).join('');
|
||||
rotationControls.after(positionControls);
|
||||
const positionInputs=positionKeys.map(key=>document.querySelector('#position-'+key));
|
||||
const positionStorage='sunnydrive-yolo-side-position-v1';let positionTimer;
|
||||
try{const saved=JSON.parse(localStorage.getItem(positionStorage)||'{}');positionInputs.forEach((input,i)=>{const v=saved[positionKeys[i]];if(Number.isFinite(v)&&v>=-50&&v<=50)input.value=v;});}catch{}
|
||||
function positionLabels(){positionInputs.forEach((input,i)=>document.querySelector('#offset-'+positionKeys[i]).textContent=input.value+'%');}
|
||||
async function positionSave(){
|
||||
const values=positionInputs.map(input=>Number(input.value));
|
||||
localStorage.setItem(positionStorage,JSON.stringify(Object.fromEntries(positionKeys.map((k,i)=>[k,values[i]]))));
|
||||
const r=await fetch('/position',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({left:values.slice(0,2).map(v=>v/100),right:values.slice(2).map(v=>v/100)})});
|
||||
if(!r.ok)document.querySelector('#area-help').textContent='Could not save side position; try again.';
|
||||
}
|
||||
positionInputs.forEach(input=>input.oninput=()=>{positionLabels();syncSideAreas();areaChanged();clearTimeout(positionTimer);positionTimer=setTimeout(positionSave,80);});
|
||||
function positionVisibility(){rotationVisibility();}
|
||||
camera.addEventListener('change',positionVisibility);
|
||||
fetch('/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{camera.value=s.camera;positionVisibility();positionLabels();positionSave();});
|
||||
|
||||
const sideControls=document.createElement('div');sideControls.className='side-controls';
|
||||
areaControls.after(sideControls);
|
||||
['left','right'].forEach((side,i)=>{
|
||||
const column=document.createElement('section');column.className='side-column';
|
||||
const title=document.createElement('strong');title.textContent=(side==='left'?'Left':'Right')+' side';column.append(title);
|
||||
const inputs=[rotationInputs[i],positionInputs[i*2],positionInputs[i*2+1]];
|
||||
inputs.forEach((input,index)=>{const label=input.closest('label');label.firstChild.textContent=['Rotation ','Horizontal ','Vertical '][index];column.append(label);});
|
||||
sideControls.append(column);
|
||||
});
|
||||
rotationControls.remove();positionControls.remove();rotationVisibility();
|
||||
const resetSides=document.createElement('button');resetSides.type='button';resetSides.textContent='Reset sides';
|
||||
resetSides.onclick=()=>{
|
||||
rotationInputs.forEach(input=>input.value=0);
|
||||
positionInputs.forEach(input=>input.value=0);
|
||||
rotationLabels();positionLabels();syncSideAreas();areaChanged();
|
||||
clearTimeout(rotationTimer);clearTimeout(positionTimer);
|
||||
rotationSave();positionSave();
|
||||
};
|
||||
sideControls.append(resetSides);
|
||||
|
||||
const maskModeLabel=document.createElement('label');maskModeLabel.style.cssText='display:flex;gap:10px;align-items:center';
|
||||
maskModeLabel.innerHTML='Area selection <select id="mask-mode" aria-label="Area selection"><option value="exclude">Black out selected areas</option><option value="keep">Keep selected areas</option></select>';
|
||||
areaControls.append(maskModeLabel);
|
||||
const maskModeSelect=document.querySelector('#mask-mode');
|
||||
const maskModesStorage='sunnydrive-yolo-mask-modes-v1';let maskModes={};
|
||||
try{maskModes=JSON.parse(localStorage.getItem(maskModesStorage)||'{}');}catch{}
|
||||
const legacyMaskMode=localStorage.getItem('sunnydrive-yolo-mask-mode')==='keep'?'keep':'exclude';
|
||||
async function saveMaskMode(){
|
||||
const view=camera.value,mode=maskModeSelect.value;
|
||||
maskModes[view]=mode;
|
||||
localStorage.setItem(maskModesStorage,JSON.stringify(maskModes));
|
||||
document.querySelector('#add-area').textContent=mode==='keep'?'Add keep area':'Add blackout area';
|
||||
areaDraw();
|
||||
const r=await fetch('/mask-mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:view,mode})});
|
||||
if(!r.ok)document.querySelector('#area-help').textContent='Could not save selection mode; try again.';
|
||||
}
|
||||
function restoreMaskMode(){
|
||||
maskModeSelect.value=maskModes[camera.value]||legacyMaskMode;
|
||||
saveMaskMode();
|
||||
}
|
||||
maskModeSelect.onchange=saveMaskMode;
|
||||
camera.addEventListener('change',restoreMaskMode);
|
||||
fetch('/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{camera.value=s.camera;restoreMaskMode();});
|
||||
restoreMaskMode();
|
||||
|
||||
// Drag a side panel in the preview; area editing keeps its point-drag behavior.
|
||||
let sideDrag=null;
|
||||
const preview=video.parentElement;
|
||||
function previewPoint(e){
|
||||
const rect=video.getBoundingClientRect(),ratio=video.naturalWidth/video.naturalHeight||16/9;
|
||||
const width=Math.min(rect.width,rect.height*ratio),height=width/ratio;
|
||||
return [(e.clientX-rect.left-(rect.width-width)/2)/width,(e.clientY-rect.top-(rect.height-height)/2)/height];
|
||||
}
|
||||
preview.addEventListener('pointerdown',e=>{
|
||||
if(editing||camera.value!=='wideSides'||!sideGeometry||e.button!==0)return;
|
||||
const point=previewPoint(e),layout=currentSideLayout();
|
||||
const side=['right','left'].find(side=>sidePoint(point,side,layout,true).every(v=>v>=0&&v<=1));
|
||||
if(!side)return;
|
||||
const offset=side==='left'?0:2;
|
||||
sideDrag={point,offset,x:Number(positionInputs[offset].value),y:Number(positionInputs[offset+1].value)};
|
||||
preview.setPointerCapture(e.pointerId);preview.style.cursor='grabbing';e.preventDefault();
|
||||
});
|
||||
preview.addEventListener('pointermove',e=>{
|
||||
if(!sideDrag)return;
|
||||
const point=previewPoint(e),{offset,x,y}=sideDrag;
|
||||
positionInputs[offset].value=Math.max(-50,Math.min(50,Math.round(x+(point[0]-sideDrag.point[0])*100)));
|
||||
positionInputs[offset+1].value=Math.max(-50,Math.min(50,Math.round(y+(point[1]-sideDrag.point[1])*100)));
|
||||
positionInputs[offset].oninput();
|
||||
});
|
||||
preview.addEventListener('pointerup',()=>{sideDrag=null;preview.style.cursor='';});
|
||||
preview.addEventListener('pointercancel',()=>{sideDrag=null;preview.style.cursor='';});
|
||||
const dragHint=document.createElement('span');dragHint.textContent='Drag side panels on the image to move them. Finish area editing first.';
|
||||
sideControls.append(dragHint);preview.style.touchAction='none';
|
||||
|
||||
const laneStyleControls=document.createElement('div');laneStyleControls.className='opacity-controls';
|
||||
laneStyleControls.innerHTML='<label>Lane UI width <input id="lane-ui-width" aria-label="Lane UI width" type="range" min="1" max="16" step="1" value="3"><output id="lane-ui-width-value">3 px</output></label><label>Lane UI opacity <input id="lane-ui-opacity" aria-label="Lane UI opacity" type="range" min="0" max="100" step="1" value="100"><output id="lane-ui-opacity-value">100%</output></label>';
|
||||
document.querySelector('.opacity-controls').after(laneStyleControls);
|
||||
const laneWidthInput=document.querySelector('#lane-ui-width'),laneAlphaInput=document.querySelector('#lane-ui-opacity');
|
||||
const laneStyleStorage='sunnydrive-yolo-lane-style-v1';let laneStyleTimer;
|
||||
try{
|
||||
const saved=JSON.parse(localStorage.getItem(laneStyleStorage)||'{}');
|
||||
if(Number.isInteger(saved.width)&&saved.width>=1&&saved.width<=16)laneWidthInput.value=saved.width;
|
||||
if(Number.isFinite(saved.opacity)&&saved.opacity>=0&&saved.opacity<=1)laneAlphaInput.value=Math.round(saved.opacity*100);
|
||||
}catch{}
|
||||
function laneStyleLabels(){
|
||||
document.querySelector('#lane-ui-width-value').textContent=laneWidthInput.value+' px';
|
||||
document.querySelector('#lane-ui-opacity-value').textContent=laneAlphaInput.value+'%';
|
||||
}
|
||||
async function saveLaneStyle(){
|
||||
const style={width:Number(laneWidthInput.value),opacity:Number(laneAlphaInput.value)/100};
|
||||
localStorage.setItem(laneStyleStorage,JSON.stringify(style));
|
||||
const result=await fetch('/lane-style',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(style)});
|
||||
if(!result.ok)document.querySelector('#area-help').textContent='Could not save lane style; try again.';
|
||||
}
|
||||
laneWidthInput.oninput=laneAlphaInput.oninput=()=>{laneStyleLabels();clearTimeout(laneStyleTimer);laneStyleTimer=setTimeout(saveLaneStyle,80);};
|
||||
laneStyleLabels();saveLaneStyle();
|
||||
|
||||
const bumperLabel=document.createElement('label');bumperLabel.style.cssText='display:flex;gap:10px;align-items:center';
|
||||
bumperLabel.innerHTML='Camera → front bumper (m) <input id="bumper-offset" aria-label="Camera to front bumper meters" type="number" min="0" max="5" step="0.01" placeholder="Measured offset" style="width:150px">';
|
||||
laneStyleControls.after(bumperLabel);
|
||||
const bumperInput=document.querySelector('#bumper-offset'),bumperStorage='sunnydrive-yolo-bumper-offset-v1';
|
||||
const savedBumper=localStorage.getItem(bumperStorage);
|
||||
if(savedBumper!==null&&savedBumper!==''&&Number.isFinite(Number(savedBumper))&&Number(savedBumper)>=0&&Number(savedBumper)<=5)bumperInput.value=savedBumper;
|
||||
async function saveBumperOffset(){
|
||||
if(!bumperInput.checkValidity())return;
|
||||
const meters=bumperInput.value===''?null:Number(bumperInput.value);
|
||||
if(meters===null)localStorage.removeItem(bumperStorage);else localStorage.setItem(bumperStorage,String(meters));
|
||||
const response=await fetch('/bumper-offset',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({meters})});
|
||||
if(!response.ok)document.querySelector('#area-help').textContent='Could not save bumper offset; try again.';
|
||||
}
|
||||
bumperInput.onchange=saveBumperOffset;saveBumperOffset();
|
||||
|
||||
|
||||
const objectModeLabel=document.createElement('label');objectModeLabel.style.cssText='display:flex;gap:8px;align-items:center';
|
||||
objectModeLabel.innerHTML='Display <select id="object-mode" aria-label="Object display"><option value="full">Full view</option><option value="masked">Objects only — masked</option><option value="original">Objects only — original</option></select>';
|
||||
document.querySelector('header .controls').append(objectModeLabel);
|
||||
const objectModeInput=document.querySelector('#object-mode'),objectModeStorage='sunnydrive-yolo-object-mode-v1';
|
||||
function updateObjectMode(mode){
|
||||
if(objectModeInput.disabled)return;
|
||||
objectModeInput.value=['full','masked','original'].includes(mode)?mode:'full';
|
||||
localStorage.setItem(objectModeStorage,objectModeInput.value);
|
||||
}
|
||||
async function saveObjectMode(){
|
||||
objectModeInput.disabled=true;
|
||||
try{
|
||||
const r=await fetch('/object-mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode:objectModeInput.value})});
|
||||
if(!r.ok)document.querySelector('#area-help').textContent='Could not change object display; try again.';
|
||||
}finally{objectModeInput.disabled=false;await update();}
|
||||
}
|
||||
objectModeInput.onchange=saveObjectMode;
|
||||
const savedObjectMode=localStorage.getItem(objectModeStorage)||(localStorage.getItem('sunnydrive-yolo-cars-only')==='true'?'original':'full');
|
||||
objectModeInput.value=['full','masked','original'].includes(savedObjectMode)?savedObjectMode:'full';
|
||||
if(objectModeInput.value!=='full')saveObjectMode();
|
||||
|
||||
const annotationControls=document.createElement('span');annotationControls.style.cssText='display:flex;gap:12px';
|
||||
annotationControls.innerHTML='<label class="overlay-toggle"><input type="checkbox" id="show-labels" checked> Labels</label><label class="overlay-toggle"><input type="checkbox" id="show-boxes" checked> Bounding boxes</label><label class="overlay-toggle"><input type="checkbox" id="show-motion" checked> Arrows and trails</label><label class="overlay-toggle"><input type="checkbox" id="road-signals" checked> Lights / sign text</label><label class="overlay-toggle"><input type="checkbox" id="show-radar" checked> Radar / sensor tracks</label><label class="overlay-toggle"><input type="checkbox" id="hide-stationary"> Hide stationary</label><label class="overlay-toggle"><input type="checkbox" id="top-seg"> Top-down segmentation</label><label class="overlay-toggle"><input type="checkbox" id="show-actions" checked> Actions / paths</label><label class="overlay-toggle"><input type="checkbox" id="our-edges"> Our lanes / edges</label>';
|
||||
document.querySelector('header .controls').append(annotationControls);
|
||||
const showLabelsInput=document.querySelector('#show-labels'),showBoxesInput=document.querySelector('#show-boxes'),showMotionInput=document.querySelector('#show-motion'),roadSignalsInput=document.querySelector('#road-signals');
|
||||
const showRadarInput=document.querySelector('#show-radar');
|
||||
const showActionsInput=document.querySelector('#show-actions');
|
||||
const topSegInput=document.querySelector('#top-seg');
|
||||
const hideStationaryInput=document.querySelector('#hide-stationary');
|
||||
const ourEdgesInput=document.querySelector('#our-edges');
|
||||
const annotationStorage='sunnydrive-yolo-annotations-v1';
|
||||
try{const saved=JSON.parse(localStorage.getItem(annotationStorage)||'{}');if(typeof saved.our_edges==='boolean')ourEdgesInput.checked=saved.our_edges;if(typeof saved.actions==='boolean')showActionsInput.checked=saved.actions;if(typeof saved.top_seg==='boolean')topSegInput.checked=saved.top_seg;if(typeof saved.labels==='boolean')showLabelsInput.checked=saved.labels;if(typeof saved.boxes==='boolean')showBoxesInput.checked=saved.boxes;if(typeof saved.motion==='boolean')showMotionInput.checked=saved.motion;if(typeof saved.hide_stationary==='boolean')hideStationaryInput.checked=saved.hide_stationary;if(typeof saved.radar==='boolean')showRadarInput.checked=saved.radar;if(typeof saved.signals==='boolean')roadSignalsInput.checked=saved.signals;}catch{}
|
||||
async function saveAnnotations(){
|
||||
const values={labels:showLabelsInput.checked,boxes:showBoxesInput.checked,motion:showMotionInput.checked,signals:roadSignalsInput.checked,radar:showRadarInput.checked,hide_stationary:hideStationaryInput.checked,top_seg:topSegInput.checked,actions:showActionsInput.checked,our_edges:ourEdgesInput.checked};
|
||||
localStorage.setItem(annotationStorage,JSON.stringify(values));
|
||||
const response=await fetch('/annotations',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(values)});
|
||||
if(!response.ok)document.querySelector('#area-help').textContent='Could not save annotation visibility; try again.';
|
||||
}
|
||||
showLabelsInput.onchange=showBoxesInput.onchange=showMotionInput.onchange=roadSignalsInput.onchange=showRadarInput.onchange=hideStationaryInput.onchange=topSegInput.onchange=showActionsInput.onchange=ourEdgesInput.onchange=saveAnnotations;saveAnnotations();
|
||||
|
||||
// Feature checkboxes are saved in the server model selection; overlays live in this browser.
|
||||
const laneToggleStorage='sunnydrive-yolo-lane-toggle-v1';
|
||||
let laneToggleBusy=false;
|
||||
const changeLaneOverlay=commaOverlay.onchange;
|
||||
commaOverlay.onchange=async()=>{
|
||||
localStorage.setItem(laneToggleStorage,JSON.stringify(commaOverlay.checked));
|
||||
laneToggleBusy=true;
|
||||
try{await changeLaneOverlay();}finally{laneToggleBusy=false;}
|
||||
};
|
||||
try{
|
||||
const saved=JSON.parse(localStorage.getItem(laneToggleStorage)||'null');
|
||||
if(typeof saved==='boolean'){
|
||||
commaOverlay.checked=saved;
|
||||
commaOverlay.onchange();
|
||||
}
|
||||
}catch{}
|
||||
@@ -1,30 +0,0 @@
|
||||
const modelSize=document.getElementById('model-size');
|
||||
const featureObjects=document.getElementById('feature-objects');
|
||||
const featureMasks=document.getElementById('feature-masks');
|
||||
const featureScene=document.getElementById('feature-scene');
|
||||
const segmentationSource=document.getElementById('segmentation-source');
|
||||
let featuresBusy=false;
|
||||
function updateFeatures(selection,source){
|
||||
if(featuresBusy||!selection)return;
|
||||
const [task,size]=selection.split('-');
|
||||
modelSize.value=size;
|
||||
if(source)segmentationSource.value=source;
|
||||
featureObjects.checked=['scene','sceneMask','detect','segment'].includes(task);
|
||||
featureMasks.checked=['sceneMask','segment'].includes(task);
|
||||
featureScene.checked=['scene','sceneMask','semantic'].includes(task);
|
||||
segmentationSource.disabled=!featureScene.checked;
|
||||
}
|
||||
async function changeFeatures(event){
|
||||
if(event.target===featureMasks&&featureMasks.checked)featureObjects.checked=true;
|
||||
if(event.target===featureObjects&&!featureObjects.checked)featureMasks.checked=false;
|
||||
featuresBusy=true;
|
||||
const controls=[modelSize,featureObjects,featureMasks,featureScene,segmentationSource];
|
||||
controls.forEach(input=>input.disabled=true);
|
||||
try{
|
||||
const response=await fetch('/features',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({size:modelSize.value,objects:featureObjects.checked,masks:featureMasks.checked,scene:featureScene.checked,segmentation_source:segmentationSource.value})});
|
||||
if(!response.ok)throw Error('Feature change failed');
|
||||
}finally{
|
||||
featuresBusy=false;controls.forEach(input=>input.disabled=false);await update();
|
||||
}
|
||||
}
|
||||
for(const input of [modelSize,featureObjects,featureMasks,featureScene,segmentationSource])input.onchange=changeFeatures;
|
||||
@@ -1,29 +0,0 @@
|
||||
"""Bring tracked ground positions into the current ego frame before estimating motion."""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def compensated_motion(samples):
|
||||
# Samples: recorded time, forward, left, ego speed, ego yaw rate (rad/s).
|
||||
if len(samples)<2:
|
||||
return None
|
||||
data=np.asarray(samples,dtype=float)
|
||||
dt=np.diff(data[:,0])
|
||||
if not np.isfinite(data).all() or np.any(dt<=0) or data[-1,0]-data[0,0]<.15:
|
||||
return None
|
||||
angle_step=(data[:-1,4]+data[1:,4])*.5*dt
|
||||
heading=np.r_[0.,np.cumsum(angle_step)]
|
||||
distance=(data[:-1,3]+data[1:,3])*.5*dt
|
||||
# Integrate forward travel along the turning arc, including the straight limit.
|
||||
forward=distance*np.sinc(angle_step/np.pi)
|
||||
left=distance*angle_step*.5*np.sinc(angle_step/(2*np.pi))**2
|
||||
c,s=np.cos(heading[:-1]),np.sin(heading[:-1])
|
||||
travel=np.column_stack((c*forward-s*left,s*forward+c*left))
|
||||
ego=np.vstack((np.zeros(2),np.cumsum(travel,axis=0)))
|
||||
c,s=np.cos(heading),np.sin(heading)
|
||||
world=np.column_stack((c*data[:,1]-s*data[:,2],s*data[:,1]+c*data[:,2]))+ego
|
||||
delta=world-ego[-1]
|
||||
c,s=np.cos(heading[-1]),np.sin(heading[-1])
|
||||
points=np.column_stack((c*delta[:,0]+s*delta[:,1],-s*delta[:,0]+c*delta[:,1]))
|
||||
time=data[:,0]-data[:,0].mean()
|
||||
velocity=time @ points / (time @ time)
|
||||
return points,velocity
|
||||
@@ -1,61 +0,0 @@
|
||||
# MIT: RadarData layout from sunnypilot/opendbc ccnc-port-radar-tracks.
|
||||
# Isolated schema IDs preserve extended source metadata without changing car schemas.
|
||||
@0xc3ff973bc6bf00a1;
|
||||
struct RadarData @0xc8d8f7ee713e964d {
|
||||
errors @3 :Error;
|
||||
points @1 :List(RadarPoint);
|
||||
trackSources @4 :List(RadarTrackSource);
|
||||
radarTracksAvailable @5 :Bool;
|
||||
|
||||
struct RadarTrackSource {
|
||||
startAddress @0 :UInt16;
|
||||
endAddress @1 :UInt16;
|
||||
bus @2 :UInt8;
|
||||
trackCount @3 :UInt16;
|
||||
}
|
||||
|
||||
struct Error {
|
||||
canError @0 :Bool;
|
||||
radarFault @1 :Bool;
|
||||
wrongConfig @2 :Bool;
|
||||
radarUnavailableTemporary @3 :Bool; # radar data is temporarily unavailable due to conditions the car sets
|
||||
}
|
||||
|
||||
# similar to LiveTracks
|
||||
struct RadarPoint {
|
||||
# all fields required
|
||||
trackId @0 :UInt64; # no trackId reuse
|
||||
dRel @1 :Float32; # m from the front bumper of the car
|
||||
yRel @2 :Float32; # m
|
||||
vRel @3 :Float32; # m/s
|
||||
|
||||
# optional radar-provided motion classification:
|
||||
# 0 = unknown/unavailable, 1 = stationary, 2 = moving
|
||||
motionState @7 :UInt8;
|
||||
|
||||
# optional source metadata for radars that combine multiple CAN ranges
|
||||
sourceAddress @8 :UInt16;
|
||||
sourceBus @9 :UInt8;
|
||||
|
||||
# number of consecutive radar cycles this track ID has remained valid
|
||||
trackAge @10 :UInt16;
|
||||
|
||||
deprecated :group {
|
||||
aRel @4 :Float32; # m/s^2
|
||||
yvRel @5 :Float32; # m/s
|
||||
measured @6 :Bool; # measurement VS estimate flag
|
||||
}
|
||||
}
|
||||
|
||||
enum ErrorDEPRECATED {
|
||||
canError @0;
|
||||
fault @1;
|
||||
wrongConfig @2;
|
||||
}
|
||||
|
||||
deprecated :group {
|
||||
canMonoTimes @2 :List(UInt64);
|
||||
errors @0 :List(ErrorDEPRECATED);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Read-only sensor-track provenance and a scene snapshot; never issue control commands."""
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
_schema=None
|
||||
|
||||
|
||||
def decode_tracks(message):
|
||||
global _schema
|
||||
if _schema is None:
|
||||
import capnp
|
||||
_schema=capnp.load(str(Path(__file__).with_name('yolo_radar.capnp')))
|
||||
# Cap'n Proto preserves unknown fields; re-read the extended branch layout.
|
||||
with _schema.RadarData.from_bytes(message.as_builder().to_bytes()) as reader:
|
||||
return reader.to_dict()
|
||||
|
||||
|
||||
def source_kind(address,bus,sources):
|
||||
source=next((s for s in sources if s['startAddress']<=address<=s['endAddress'] and s['bus']==bus),None)
|
||||
if source is None:
|
||||
return 'unknown','unclassified'
|
||||
first,last=source['startAddress'],source['endAddress']
|
||||
label=f'{first:03X}–{last:03X} / bus {bus}'
|
||||
if first==0x235 and last==0x248:
|
||||
return 'camera',label
|
||||
if first in (0x240,0x278):
|
||||
return 'candidate_radar',label
|
||||
if first in (0x500,0x602,0x210,0x3a5):
|
||||
return 'radar',label
|
||||
return 'unknown',label
|
||||
|
||||
|
||||
def radar_snapshot(raw,timestamp,reference):
|
||||
if raw is None:
|
||||
return dict(status='unavailable',timestamp=None,age_ms=None,tracks=[],errors={})
|
||||
age=abs(reference-timestamp)
|
||||
errors=raw.get('errors',{})
|
||||
status='stale' if age>.2 else 'invalid' if any(errors.values()) else 'ready'
|
||||
tracks=[]
|
||||
if status=='ready':
|
||||
sources=raw.get('trackSources',[])
|
||||
for p in raw.get('points',[]):
|
||||
values=[p.get(key) for key in ('dRel','yRel','vRel')]
|
||||
if any(v is None or not math.isfinite(v) for v in values):
|
||||
continue
|
||||
forward,left,relative_speed=values
|
||||
if not -50<=forward<=250 or abs(left)>100:
|
||||
continue
|
||||
address,bus=int(p.get('sourceAddress',0)),int(p.get('sourceBus',0))
|
||||
kind,label=source_kind(address,bus,sources)
|
||||
tracks.append(dict(id=int(p['trackId']),forward=float(forward),left=float(left),relative_speed=float(relative_speed),
|
||||
source=kind,source_label=label,address=address,bus=bus,motion_state=int(p.get('motionState',0)),
|
||||
track_age=int(p.get('trackAge',0)),uncertainty=None))
|
||||
return dict(status=status,timestamp=timestamp,age_ms=round(age*1000,1),tracks=tracks,errors=errors)
|
||||
|
||||
|
||||
def scene_snapshot(sample,top_down,timestamp):
|
||||
radar=(sample or {}).get('radar') or radar_snapshot(None,0,timestamp)
|
||||
objects=[dict(source=c.get('source','vision'),vision_forward=c.get('vision_forward',c['forward']),vision_left=c.get('vision_left',c['left']),relative_speed=c.get('relative_speed'),id=c['id'],type=c['type'],forward=c['forward'],left=c['left'],
|
||||
lane=c['lane'],heading=c.get('heading'),uncertainty=None) for c in top_down.get('cars',[])]
|
||||
return dict(timestamp=timestamp,frame='vehicle',distance_origin='front_bumper',ego=(sample or {}).get('car'),
|
||||
vision=objects,sensor_tracks=radar['tracks'],radar_status=radar['status'],radar_age_ms=radar.get('age_ms'),
|
||||
lanes=top_down.get('lanes',[]),lane_probabilities=top_down.get('probabilities',[]),
|
||||
traffic_lights=top_down.get('lights',[]),signal_lane_association=None,
|
||||
occupancy='unknown',control_ready=False)
|
||||
|
||||
class HyundaiReplayRadar:
|
||||
"""Decode the confirmed 3A5–3C4 family using the radar branch's DBC."""
|
||||
def __init__(self):
|
||||
import tempfile
|
||||
from opendbc.can import CANParser
|
||||
from yolo_radar_dbc import generate
|
||||
with tempfile.NamedTemporaryFile(suffix='.dbc',mode='w') as dbc:
|
||||
dbc.write(next(iter(generate().values())))
|
||||
dbc.flush()
|
||||
self.parsers=[CANParser(dbc.name,[(addr,20) for addr in range(0x3a5,0x3c5)],bus) for bus in range(3)]
|
||||
self.seen=[set() for _ in self.parsers]
|
||||
self.times=[0.0]*3
|
||||
|
||||
def update(self,events,reference):
|
||||
for event in events:
|
||||
timestamp=event.logMonoTime
|
||||
frames=[(p.address,bytes(p.dat),p.src) for p in event.can if 0x3a5<=p.address<=0x3c4 and len(p.dat)==24 and p.src<3]
|
||||
for bus,parser in enumerate(self.parsers):
|
||||
own=[p for p in frames if p[2]==bus]
|
||||
if own:
|
||||
if timestamp/1e9<self.times[bus]:
|
||||
self.seen[bus].clear()
|
||||
self.seen[bus].update(p[0] for p in own)
|
||||
parser.update([(timestamp,own)])
|
||||
self.times[bus]=timestamp/1e9
|
||||
candidates=[bus for bus in range(3) if len(self.seen[bus])==32 and abs(reference-self.times[bus])<=.2]
|
||||
if not candidates:
|
||||
return radar_snapshot(None,0,reference)
|
||||
bus=max(candidates,key=lambda b:self.times[b]); parser=self.parsers[bus]
|
||||
points=[]
|
||||
for addr in range(0x3a5,0x3c5):
|
||||
msg=parser.vl[addr]
|
||||
ts=parser.ts_nanos[addr].get('LONG_DIST',0)/1e9
|
||||
if int(msg['STATE']) not in (3,4) or abs(reference-ts)>.2:
|
||||
continue
|
||||
points.append(dict(trackId=bus*2048+addr,dRel=msg['LONG_DIST'],yRel=msg['LAT_DIST'],vRel=msg['REL_SPEED'],
|
||||
sourceAddress=addr,sourceBus=bus,motionState=int(msg['MOTION_STATE']),trackAge=int(msg['AGE'])))
|
||||
raw=dict(points=points,trackSources=[dict(startAddress=0x3a5,endAddress=0x3c4,bus=bus)],errors={})
|
||||
return radar_snapshot(raw,self.times[bus],reference)
|
||||
|
||||
class RadarAssociator:
|
||||
"""Conservative one-to-one association; ambiguous returns stay unmatched."""
|
||||
def __init__(self):
|
||||
self.reset()
|
||||
|
||||
def reset(self):
|
||||
self.previous={};self.pending={};self.matches={};self.timestamp=None
|
||||
|
||||
@staticmethod
|
||||
def key(track):
|
||||
return (track['bus'],track['address'],track['id'])
|
||||
|
||||
def update(self,objects,tracks,timestamp):
|
||||
objects={o['id']:o for o in objects if o.get('id') is not None}
|
||||
tracks={self.key(t):t for t in tracks if t['source']=='radar' and t['forward']>0}
|
||||
if self.timestamp is not None and timestamp<self.timestamp:
|
||||
self.reset()
|
||||
if timestamp==self.timestamp:
|
||||
return {identity:match for identity,match in self.matches.items() if identity in objects and self.key(match['track']) in tracks}
|
||||
old=self.previous;dt=timestamp-self.timestamp if self.timestamp is not None else None
|
||||
edges=[]
|
||||
for identity,obj in objects.items():
|
||||
for key,track in tracks.items():
|
||||
longitudinal=abs(obj['forward']-track['forward']);lateral=abs(obj['left']-track['left'])
|
||||
range_gate=max(5.,track['forward']*.20);side_gate=min(3.,1.5+track['forward']*.015)
|
||||
if longitudinal>range_gate or lateral>side_gate:continue
|
||||
score=(longitudinal/range_gate)**2+(lateral/side_gate)**2
|
||||
if dt is not None and .15<=dt<=1.0 and identity in old:
|
||||
speed=(obj['forward']-old[identity]['forward'])/dt
|
||||
difference=abs(speed-track['relative_speed'])
|
||||
if difference>8.:continue
|
||||
score+=(difference/8.)**2*.5
|
||||
if score<1.:edges.append((score,identity,key))
|
||||
edges.sort();pending={};matches={}
|
||||
for score,identity,key in edges:
|
||||
alternatives=[s for s,i,k in edges if (i==identity or k==key) and (i,k)!=(identity,key)]
|
||||
# Require a clear winner for both the visual object and radar return.
|
||||
if alternatives and min(alternatives)-score<.2:continue
|
||||
track=tracks[key];prior=self.pending.get(identity)
|
||||
continuous=prior is not None and prior['key']==key and 0<timestamp-prior['time']<=1.0 and track['track_age']>=prior['age']
|
||||
count=prior['count']+1 if continuous else 1
|
||||
start=prior['start'] if continuous else timestamp
|
||||
pending[identity]=dict(key=key,count=count,start=start,time=timestamp,age=track['track_age'])
|
||||
if count>=3 and timestamp-start>=.15:
|
||||
matches[identity]=dict(track=track,box=objects[identity]['box'],cost=round(score,3))
|
||||
self.previous=objects;self.pending=pending;self.matches=matches;self.timestamp=timestamp
|
||||
return matches
|
||||
@@ -1,169 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
|
||||
def _comment(*parts: str) -> str:
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
RADAR_3A5_3C4_MESSAGE_COMMENT = _comment(
|
||||
"Common Hyundai/Kia/Genesis 3A5-3C4 track layout, validated across HDA1/HDA2 and CCNC/non-CCNC routes.",
|
||||
"Rich width, length, absolute-speed, orientation, and shape data was populated only on EV9/Ioniq 9 in the tested corpus;",
|
||||
"HDA2 alone does not imply support, and the extension attributes belong to the same 32 tracks rather than extra objects.",
|
||||
"The HKG CAN-FD checksum uses the CAN address as its data ID.",
|
||||
"No dedicated oncoming value or flag was found; bit 29 remained zero even for physics-based oncoming candidates.",
|
||||
"Bits 26, 29, 39, 55, 117, 136-137, 143, 171, and 188-191 remained zero and are reserved.",
|
||||
"The Sportage HEV 2026 route uses an incompatible overlapping layout and must not be decoded with this DBC.",
|
||||
)
|
||||
|
||||
RADAR_3A5_3C4_SIGNAL_COMMENTS = (
|
||||
("CHECKSUM", "16-bit HKG CAN-FD checksum using the CAN address as the data ID."),
|
||||
("COUNTER", "8-bit transmit-cycle counter. It covers 0-255 and wraps; it is separate from the per-track update counter."),
|
||||
("STATE_ALT", "Compressed STATE mirror: 0=empty, 1=tentative/unresolved, 2=measured, 3=coasted."),
|
||||
("MOTION_STATE", _comment(
|
||||
"Ground-frame target-motion class: 0=unknown, 1=stationary, 2=moving.",
|
||||
"Related Hyundai radar data defines the unused fourth 2-bit value as stopped, not oncoming;",
|
||||
"that bit remained zero throughout the tested 3A5 corpus.",
|
||||
)),
|
||||
("TRACK_COUNTER", "Per-track modulo-4 update counter. It normally advances with AGE and may hold between updates."),
|
||||
("TRACK_QUALITY", _comment(
|
||||
"Unsigned 7-bit track quality/existence score. Empty and tentative tracks are low, measured tracks are high,",
|
||||
"and coasted tracks decline as COAST_AGE increases.",
|
||||
"A related Hyundai object layout defines its 7-bit quality level as reliability, validity, or probability evidence;",
|
||||
"the raw radar scale is not confirmed to be a percentage.",
|
||||
)),
|
||||
("AGE", "Per-track alive age/lifetime count, observed 0-255. It normally increments by one on an update and may hold."),
|
||||
("COAST_AGE", "Prediction age: normally 0 in measured STATE=3, starts at 1 in coasted STATE=4, and increments to 15."),
|
||||
("STATE", _comment(
|
||||
"Track lifecycle: 0=empty, 1/2=tentative acquisition, 3=measured, 4=coasted/predicted,",
|
||||
"7=unresolved tentative. Values 5 and 6 were not observed.",
|
||||
)),
|
||||
("RCS", _comment(
|
||||
"Signed radar cross-section/return-strength value. Distance-controlled samples consistently increase from small",
|
||||
"to passenger to large targets, and its distribution closely matches the detailed 210-21F field.",
|
||||
"The raw unit is not calibrated.",
|
||||
)),
|
||||
("LONG_DIST", _comment(
|
||||
"Longitudinal distance from the ego radar. The 13th bit continues through 204.8 m rather than being a flag.",
|
||||
"STATE=0 may carry endpoint sentinels.",
|
||||
)),
|
||||
("LAT_DIST", "Lateral position relative to the ego axis. STATE=0 values are empty-track data, not target positions."),
|
||||
("REL_SPEED", "Longitudinal speed relative to ego; negative means the target is moving longitudinally slower than ego."),
|
||||
("NEW_SIGNAL_4", "Unknown category, observed 0-2. Zero dominates; 1/2 occur more often on moving targets."),
|
||||
("REL_LAT_SPEED", "Target lateral speed relative to the ego vehicle axis."),
|
||||
("REL_ACCEL", "Target longitudinal acceleration relative to ego acceleration."),
|
||||
("NEW_SIGNAL_18", _comment(
|
||||
"Unknown optional measurement-status attribute. EV9/Ioniq 9 use 1/2 almost exclusively for measured STATE=3 tracks",
|
||||
"and 0 for coasted STATE=4 tracks; the distinction between 1 and 2 is unknown. The rare value 3 is also observed.",
|
||||
)),
|
||||
("NEW_SIGNAL_5", "Unknown sparse optional attribute. Observed 0-32 and almost always 0; no stable correlation was found."),
|
||||
("WIDTH", _comment(
|
||||
"Strongly identified target width in 0.1 m units from recurring approximately 2.0 m passenger cars.",
|
||||
"Observed 0.0-3.0 m; many platforms transmit 0. Every address in the range can carry it.",
|
||||
)),
|
||||
("LENGTH", _comment(
|
||||
"Strongly identified target length in 0.1 m units from approximately 4.0 m cars and 13.0-15.5 m long vehicles.",
|
||||
"This is an 8-bit field; many platforms transmit 0.",
|
||||
)),
|
||||
("ABS_SPEED", _comment(
|
||||
"Strongly inferred absolute target-speed magnitude. On Ioniq 9 it matches",
|
||||
"hypot(ego speed + REL_SPEED, REL_LAT_SPEED), including targets without dimensions; observed 0.0-54.4 m/s.",
|
||||
)),
|
||||
("ORIENTATION_ANGLE", _comment(
|
||||
"Strongly inferred target orientation relative to the ego axis. Moving vehicles cluster near 0 degrees;",
|
||||
"it is associated with dimension-bearing targets. Values near +/-180 can be real rearward orientation on rich tracks,",
|
||||
"while non-rich platforms use +180 as an unavailable/default value.",
|
||||
)),
|
||||
("NEW_SIGNAL_13", "Unknown optional 0-10 shape/geometry metric, nonzero only on dimension-bearing targets in tested routes."),
|
||||
("NEW_SIGNAL_12", _comment(
|
||||
"Strong geometry-age/maturity candidate: two Ioniq 9 routes progressed 0, 1, 2, ... 10 and saturated at 10.",
|
||||
"Cross-platform confirmation is still insufficient for a semantic rename.",
|
||||
)),
|
||||
("NEW_SIGNAL_14", "Unknown optional geometry category, observed 0-3. The common NEW_SIGNAL_14-17 tuple is 2,2,2,1."),
|
||||
("NEW_SIGNAL_15", "Unknown optional geometry category, observed 0-2. The common NEW_SIGNAL_14-17 tuple is 2,2,2,1."),
|
||||
("NEW_SIGNAL_16", "Unknown optional geometry category, observed 0-3. The common NEW_SIGNAL_14-17 tuple is 2,2,2,1."),
|
||||
("NEW_SIGNAL_17", "Unknown optional geometry category, observed 0-2. The common NEW_SIGNAL_14-17 tuple is 2,2,2,1."),
|
||||
)
|
||||
|
||||
|
||||
def generate():
|
||||
parts = []
|
||||
parts.append("""
|
||||
VERSION ""
|
||||
|
||||
|
||||
NS_ :
|
||||
NS_DESC_
|
||||
CM_
|
||||
BA_DEF_
|
||||
BA_
|
||||
VAL_
|
||||
CAT_DEF_
|
||||
CAT_
|
||||
FILTER
|
||||
BA_DEF_DEF_
|
||||
EV_DATA_
|
||||
ENVVAR_DATA_
|
||||
SGTYPE_
|
||||
SGTYPE_VAL_
|
||||
BA_DEF_SGTYPE_
|
||||
BA_SGTYPE_
|
||||
SIG_TYPE_REF_
|
||||
VAL_TABLE_
|
||||
SIG_GROUP_
|
||||
SIG_VALTYPE_
|
||||
SIGTYPE_VALTYPE_
|
||||
BO_TX_BU_
|
||||
BA_DEF_REL_
|
||||
BA_REL_
|
||||
BA_DEF_DEF_REL_
|
||||
BU_SG_REL_
|
||||
BU_EV_REL_
|
||||
BU_BO_REL_
|
||||
SG_MUL_VAL_
|
||||
|
||||
BS_:
|
||||
|
||||
BU_: XXX
|
||||
""")
|
||||
|
||||
for a in range(0x3A5, 0x3A5 + 32):
|
||||
parts.append(f"""
|
||||
BO_ {a} RADAR_TRACK_{a:x}: 24 RADAR
|
||||
SG_ CHECKSUM : 0|16@1+ (1,0) [0|65535] "" XXX
|
||||
SG_ COUNTER : 16|8@1+ (1,0) [0|255] "" XXX
|
||||
SG_ STATE_ALT : 25|2@0+ (1,0) [0|3] "" XXX
|
||||
SG_ MOTION_STATE : 28|2@0+ (1,0) [0|2] "" XXX
|
||||
SG_ TRACK_COUNTER : 31|2@0+ (1,0) [0|3] "" XXX
|
||||
SG_ TRACK_QUALITY : 38|7@0+ (1,0) [0|127] "" XXX
|
||||
SG_ AGE : 47|8@0+ (1,0) [0|255] "" XXX
|
||||
SG_ COAST_AGE : 51|4@0+ (1,0) [0|15] "" XXX
|
||||
SG_ STATE : 54|3@0+ (1,0) [0|7] "" XXX
|
||||
SG_ RCS : 62|7@0- (1,0) [-64|63] "" XXX
|
||||
SG_ LONG_DIST : 63|13@1+ (0.05,0) [0|409.55] "m" XXX
|
||||
SG_ LAT_DIST : 76|12@1- (0.05,0) [-102.4|102.35] "m" XXX
|
||||
SG_ REL_SPEED : 88|14@1- (0.01,0) [-81.92|81.91] "m/s" XXX
|
||||
SG_ NEW_SIGNAL_4 : 103|2@0+ (1,0) [0|2] "" XXX
|
||||
SG_ REL_LAT_SPEED : 104|13@1- (0.01,0) [-40.96|40.95] "m/s" XXX
|
||||
SG_ REL_ACCEL : 118|10@1- (0.02,0) [-10.24|10.22] "m/s^2" XXX
|
||||
SG_ NEW_SIGNAL_18 : 129|2@0+ (1,0) [0|3] "" XXX
|
||||
SG_ NEW_SIGNAL_5 : 135|6@0+ (1,0) [0|32] "" XXX
|
||||
SG_ WIDTH : 138|5@1+ (0.1,0) [0|3.1] "m" XXX
|
||||
SG_ LENGTH : 151|8@0+ (0.1,0) [0|25.5] "m" XXX
|
||||
SG_ ABS_SPEED : 152|10@1+ (0.1,0) [0|102.3] "m/s" XXX
|
||||
SG_ ORIENTATION_ANGLE : 162|9@1- (1,0) [-180|180] "deg" XXX
|
||||
SG_ NEW_SIGNAL_13 : 175|4@0+ (1,0) [0|10] "" XXX
|
||||
SG_ NEW_SIGNAL_12 : 179|4@0+ (1,0) [0|10] "" XXX
|
||||
SG_ NEW_SIGNAL_14 : 181|2@0+ (1,0) [0|3] "" XXX
|
||||
SG_ NEW_SIGNAL_15 : 183|2@0+ (1,0) [0|2] "" XXX
|
||||
SG_ NEW_SIGNAL_16 : 185|2@0+ (1,0) [0|3] "" XXX
|
||||
SG_ NEW_SIGNAL_17 : 187|2@0+ (1,0) [0|2] "" XXX
|
||||
|
||||
VAL_ {a} STATE_ALT 0 "EMPTY" 1 "TENTATIVE" 2 "MEASURED" 3 "COASTED" ;
|
||||
VAL_ {a} MOTION_STATE 0 "UNKNOWN" 1 "STATIONARY" 2 "MOVING" ;
|
||||
VAL_ {a} STATE 0 "EMPTY" 1 "TENTATIVE_1" 2 "TENTATIVE_2" 3 "MEASURED" 4 "COASTED" 7 "UNRESOLVED_TENTATIVE" ;
|
||||
""")
|
||||
|
||||
parts.append(f'CM_ BO_ {a} "{RADAR_3A5_3C4_MESSAGE_COMMENT}";\n')
|
||||
parts.extend(f'CM_ SG_ {a} {signal} "{comment}";\n' for signal, comment in RADAR_3A5_3C4_SIGNAL_COMMENTS)
|
||||
|
||||
return {"hyundai_radar_3a5_3c4.dbc": "".join(parts)}
|
||||
@@ -1,15 +0,0 @@
|
||||
async function sendReplayKey(key){
|
||||
try{
|
||||
const r=await fetch('/replay-key',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({key})});
|
||||
document.querySelector('#status').textContent=r.ok?'':'Replay control unavailable';
|
||||
await update();
|
||||
}catch{document.querySelector('#status').textContent='Replay control unavailable';}
|
||||
}
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.repeat||e.ctrlKey||e.metaKey||e.altKey||e.target.closest('input,select,textarea,button,[contenteditable="true"]'))return;
|
||||
let key=e.key;
|
||||
if(key==='ArrowLeft')key=e.shiftKey?'M':'S';
|
||||
if(key==='ArrowRight')key=e.shiftKey?'m':'s';
|
||||
if(![' ','s','S','m','M','e','d','t','i','w','c','+','-','q'].includes(key))return;
|
||||
e.preventDefault();sendReplayKey(key);
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
"""Traffic-light crop checks and asynchronous CPU sign recognition."""
|
||||
from collections import deque
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from urllib.request import urlretrieve
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
|
||||
def traffic_light_state(crop):
|
||||
if crop.size == 0 or min(crop.shape[:2]) < 6:
|
||||
return 'unknown'
|
||||
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
|
||||
hue,saturation,value = cv2.split(hsv)
|
||||
bright = (saturation >= 80) & (value >= 140)
|
||||
scores = {}
|
||||
for name,condition in [('red',(hue <= 10) | (hue >= 170)),('yellow',(hue >= 15) & (hue <= 38)),('green',(hue >= 40) & (hue <= 95))]:
|
||||
mask = (bright & condition).astype(np.uint8)
|
||||
count,labels,stats,_ = cv2.connectedComponentsWithStats(mask)
|
||||
score = 0.
|
||||
for index in range(1,count):
|
||||
x,y,w,h,area = stats[index]
|
||||
# Reject broad background patches; look for compact illuminated lamps.
|
||||
if area < 2 or area > crop.shape[0]*crop.shape[1]*.25 or not .4 <= w/h <= 2.5:
|
||||
continue
|
||||
if area/(w*h) < .4:
|
||||
continue
|
||||
score = max(score,float(value[labels == index].sum()))
|
||||
scores[name] = score
|
||||
ranked = sorted(scores,key=scores.get,reverse=True)
|
||||
return ranked[0] if scores[ranked[0]] > 0 and scores[ranked[0]] > scores[ranked[1]]*1.4 else 'unknown'
|
||||
|
||||
|
||||
def traffic_light_summary(readings,center_bounds=None):
|
||||
colors=[color for x,color in sorted(readings) if color in ('red','yellow','green')
|
||||
and (center_bounds is None or center_bounds[0]<=x<=center_bounds[1])]
|
||||
if not colors and center_bounds is not None:
|
||||
colors=[color for _,color in sorted(readings) if color in ('red','yellow','green')]
|
||||
counts={color:colors.count(color) for color in ('red','yellow','green')}
|
||||
maximum=max(counts.values())
|
||||
winners=[color for color,count in counts.items() if count==maximum]
|
||||
dominant=(winners[0] if len(winners)==1 else 'mixed') if maximum else None
|
||||
return {'lights':colors,'dominant_light':dominant,'dominant_fraction':maximum/len(colors) if colors else 0.}
|
||||
|
||||
|
||||
def sign_class_label(name):
|
||||
if name.startswith('speedLimit') and name[10:].isdigit():
|
||||
return 'SPEED LIMIT '+name[10:]
|
||||
return re.sub(r'([a-z])([A-Z])',r'\1 \2',name).upper()
|
||||
|
||||
|
||||
def sign_regions(labels,names,shape):
|
||||
indices = [int(key) for key,name in names.items() if str(name).lower().replace('_',' ').replace('-',' ') in ('traffic sign','traffic signs')]
|
||||
if not indices:
|
||||
return []
|
||||
mask = ((labels==indices[0]) if len(indices)==1 else np.isin(labels,indices)).astype(np.uint8)
|
||||
mask = cv2.resize(mask,(shape[1],shape[0]),interpolation=cv2.INTER_NEAREST)
|
||||
count,_,stats,_ = cv2.connectedComponentsWithStats(mask)
|
||||
return [[int(x),int(y),int(x+w),int(y+h)] for x,y,w,h,area in stats[1:] if w >= 16 and h >= 16 and area >= 100][:8]
|
||||
|
||||
|
||||
class RoadObjectReader:
|
||||
def __init__(self,sign_weights=None):
|
||||
self.sign_weights=sign_weights
|
||||
self.condition = threading.Condition()
|
||||
self.pending = None
|
||||
self.cache = {}
|
||||
self.full_signs=([],0.)
|
||||
self.context = None
|
||||
self.last_submit = 0
|
||||
self.last_frame = None
|
||||
self.revision = 0
|
||||
self.votes = {}
|
||||
self.last_timestamp = {}
|
||||
self.vehicle_samples = {}
|
||||
self.error = ''
|
||||
self.stop = False
|
||||
threading.Thread(target=self._worker,daemon=True).start()
|
||||
|
||||
def reset(self,context):
|
||||
with self.condition:
|
||||
if context != self.context:
|
||||
self.context=context;self.cache.clear();self.full_signs=([],0.);self.votes.clear();self.last_timestamp.clear();self.vehicle_samples.clear();self.pending=None;self.last_frame=None
|
||||
|
||||
def light(self,key,crop,timestamp):
|
||||
state = traffic_light_state(crop)
|
||||
history = self.votes.setdefault(key,deque(maxlen=5))
|
||||
if self.last_timestamp.get(key) != timestamp:
|
||||
history.append(state);self.last_timestamp[key]=timestamp
|
||||
return state if state != 'unknown' and history.count(state) >= 2 else 'unknown'
|
||||
|
||||
def vehicle(self,key,crop,timestamp):
|
||||
measurements=lamp_measurements(crop)
|
||||
if measurements is None:
|
||||
return []
|
||||
samples=self.vehicle_samples.setdefault(key,deque(maxlen=90))
|
||||
if not samples or samples[-1][0]!=timestamp:
|
||||
samples.append((timestamp,measurements))
|
||||
while samples and timestamp-samples[0][0]>3:
|
||||
samples.popleft()
|
||||
return vehicle_light_activity(list(samples))
|
||||
|
||||
def read(self,key):
|
||||
with self.condition:
|
||||
text,received = self.cache.get(key,('',0))
|
||||
return text if time.monotonic()-received < 2 else ''
|
||||
|
||||
def sign_label(self,key):
|
||||
return self.read(key)
|
||||
|
||||
def read_boxes(self):
|
||||
with self.condition:
|
||||
boxes,received=self.full_signs
|
||||
return boxes if time.monotonic()-received<2 else []
|
||||
|
||||
def submit(self,image,regions,frame=None,full_frame=False):
|
||||
now = time.monotonic()
|
||||
if (not regions and not full_frame) or now-self.last_submit < .5 or (frame is not None and frame==self.last_frame):
|
||||
return
|
||||
self.last_submit=now
|
||||
self.last_frame=frame
|
||||
crops=[('frame-signs',image.copy())] if full_frame else []
|
||||
for key,box in ([] if full_frame else regions[:6]):
|
||||
x0,y0,x1,y1=map(int,box)
|
||||
pad=max(4,int(max(x1-x0,y1-y0)*.15))
|
||||
crop=image[max(0,y0-pad):min(image.shape[0],y1+pad),max(0,x0-pad):min(image.shape[1],x1+pad)]
|
||||
if not crop.size or min(crop.shape[:2]) < 16:
|
||||
continue
|
||||
scale=min(6.,max(1.,120/min(crop.shape[:2])),640/max(crop.shape[:2]))
|
||||
crop=cv2.resize(crop,None,fx=scale,fy=scale)
|
||||
crops.append((key,crop.copy()))
|
||||
with self.condition:
|
||||
self.pending=(self.context,crops);self.condition.notify()
|
||||
|
||||
def close(self):
|
||||
with self.condition:
|
||||
self.stop=True;self.condition.notify()
|
||||
|
||||
def _worker(self):
|
||||
sign_model=None
|
||||
while True:
|
||||
with self.condition:
|
||||
self.condition.wait_for(lambda:self.pending is not None or self.stop)
|
||||
if self.stop:
|
||||
return
|
||||
context,crops=self.pending;self.pending=None
|
||||
try:
|
||||
if self.sign_weights is not None and sign_model is None:
|
||||
from ultralytics import YOLO
|
||||
if not self.sign_weights.exists():
|
||||
self.sign_weights.parent.mkdir(parents=True,exist_ok=True)
|
||||
partial=self.sign_weights.with_suffix('.part')
|
||||
urlretrieve('https://huggingface.co/cvtechniques/JC-Traffic-Sign-Detection/resolve/2cb01e6788b82d904a1d1e35646e171a1b622fe7/trainv26/weights/best.pt',partial)
|
||||
partial.replace(self.sign_weights)
|
||||
sign_model=YOLO(str(self.sign_weights))
|
||||
readings=[]
|
||||
full_signs=None
|
||||
for key,crop in crops:
|
||||
text=''
|
||||
if sign_model is not None:
|
||||
result=sign_model.predict(crop,imgsz=640,device='cpu',conf=.25,verbose=False)[0]
|
||||
if key=='frame-signs':
|
||||
full_signs=[(box.tolist(),sign_class_label(result.names[int(cls)])) for box,cls in zip(result.boxes.xyxy.numpy(),result.boxes.cls.tolist())]
|
||||
if len(result.boxes):
|
||||
index=int(result.boxes.conf.argmax())
|
||||
name=result.names[int(result.boxes.cls[index])]
|
||||
text=sign_class_label(name)
|
||||
readings.append((key,text))
|
||||
with self.condition:
|
||||
if context==self.context:
|
||||
if full_signs is not None:
|
||||
self.full_signs=(full_signs,time.monotonic())
|
||||
for key,text in readings:
|
||||
self.cache[key] = (text,time.monotonic())
|
||||
# Keep only current sign results rather than accumulating sign locations.
|
||||
self.cache={key:value for key,value in self.cache.items() if time.monotonic()-value[1]<2}
|
||||
self.error=''
|
||||
self.revision+=1
|
||||
except Exception as error:
|
||||
self.error=str(error)
|
||||
self.revision+=1
|
||||
|
||||
|
||||
def lamp_measurements(crop):
|
||||
"""Compare outer rear-lamp regions; this is observation, not brake intent."""
|
||||
if crop.size == 0 or crop.shape[0]<24 or crop.shape[1]<32:
|
||||
return None
|
||||
height,width=crop.shape[:2]
|
||||
hsv=cv2.cvtColor(crop,cv2.COLOR_BGR2HSV)
|
||||
hue,saturation,value=cv2.split(hsv)
|
||||
red=((hue<10)|(hue>170)) & (saturation>100) & (value>100)
|
||||
amber=(hue>=10)&(hue<=38)&(saturation>110)&(value>170)
|
||||
values=[]
|
||||
for left,right in ((.04,.42),(.58,.96)):
|
||||
region=np.s_[int(height*.2):int(height*.85),int(width*left):int(width*right)]
|
||||
pixels=value[region]
|
||||
values.append((float((pixels*red[region]).sum()/max(pixels.size,1)),float(amber[region].mean())))
|
||||
return values
|
||||
|
||||
|
||||
def vehicle_light_activity(samples):
|
||||
if len(samples)<4 or samples[-1][0]-samples[0][0]<.6:
|
||||
return []
|
||||
readings=[]
|
||||
for index,side in ((0,'left'),(1,'right')):
|
||||
flashing=[sample[1][index][1]>.01 for sample in samples]
|
||||
transitions=sum(a!=b for a,b in zip(flashing,flashing[1:]))
|
||||
if transitions>=2 and any(flashing):
|
||||
readings.append(side+' signal?')
|
||||
if len(readings)==2:
|
||||
readings=['hazards?']
|
||||
# Both red lamp regions must brighten relative to observed earlier values.
|
||||
previous=samples[:-1]
|
||||
brightened=[]
|
||||
for index in (0,1):
|
||||
baseline=float(np.percentile([sample[1][index][0] for sample in previous],20))
|
||||
current=samples[-1][1][index][0]
|
||||
brightened.append(baseline>1 and current>max(baseline*1.6,baseline+5))
|
||||
if all(brightened):
|
||||
readings.append('brake lights?')
|
||||
return readings
|
||||
|
||||
|
||||
def draw_vehicle_lights(overlay, image, box, activity):
|
||||
"""Highlight currently lit pixels in the regions used by the activity heuristic."""
|
||||
height,width=image.shape[:2]
|
||||
x0,y0,x1,y1=map(int,box)
|
||||
x0,y0,x1,y1=max(0,x0),max(0,y0),min(width,x1),min(height,y1)
|
||||
crop=image[y0:y1,x0:x1]
|
||||
if crop.size==0:
|
||||
return
|
||||
hsv=cv2.cvtColor(crop,cv2.COLOR_BGR2HSV)
|
||||
hue,saturation,value=cv2.split(hsv)
|
||||
red=((hue<10)|(hue>170)) & (saturation>100) & (value>100)
|
||||
amber=(hue>=10)&(hue<=38)&(saturation>110)&(value>170)
|
||||
target=overlay[y0:y1,x0:x1]
|
||||
h,w=red.shape
|
||||
for pixels,color,enabled in ((red,(0,40,255),['brake lights?' in activity]*2),
|
||||
(amber,(0,190,255),[side+' signal?' in activity or 'hazards?' in activity for side in ('left','right')])):
|
||||
mask=np.zeros(red.shape,np.uint8)
|
||||
for active,(left,right) in zip(enabled,((.04,.42),(.58,.96))):
|
||||
if active:
|
||||
region=np.s_[int(h*.2):int(h*.85),int(w*left):int(w*right)]
|
||||
mask[region]=pixels[region]
|
||||
radius=max(2,min(10,round(w*.025)))
|
||||
kernel=cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(radius*2+1,radius*2+1))
|
||||
glow=cv2.dilate(mask,kernel).astype(bool)
|
||||
target[glow]=(target[glow]*.2+np.array(color)*.8).astype(np.uint8)
|
||||
contours,_=cv2.findContours(mask,cv2.RETR_EXTERNAL,cv2.CHAIN_APPROX_SIMPLE)
|
||||
cv2.drawContours(target,contours,-1,(0,0,0),8,cv2.LINE_AA)
|
||||
cv2.drawContours(target,contours,-1,(255,255,255),6,cv2.LINE_AA)
|
||||
cv2.drawContours(target,contours,-1,color,3,cv2.LINE_AA)
|
||||
target[mask.astype(bool)]=color
|
||||
@@ -1,249 +0,0 @@
|
||||
const topDownPanel=document.createElement('section');
|
||||
topDownPanel.style.cssText='padding:0;border:1px solid #334155;border-radius:12px;background:#101722;min-width:0;box-sizing:border-box;overflow:hidden';
|
||||
topDownPanel.innerHTML='<canvas width="420" height="520" aria-label="Top-down lane lines and detected cars" style="width:100%;height:100%;display:block"></canvas>';
|
||||
const sceneLayout=document.createElement('div');sceneLayout.className='scene-layout';
|
||||
const cameraPanel=video.parentElement;cameraPanel.before(sceneLayout);sceneLayout.append(cameraPanel,topDownPanel);
|
||||
const trafficLightRow=document.createElement('div');
|
||||
trafficLightRow.className='traffic-light-row';trafficLightRow.setAttribute('aria-live','polite');
|
||||
trafficLightRow.style.cssText='position:absolute;top:10px;right:10px;z-index:3;pointer-events:none;display:flex;align-items:center;flex-wrap:wrap;gap:8px;padding:7px 10px;background:rgba(8,13,20,.8);border:1px solid rgba(148,163,184,.25);border-radius:8px;box-sizing:border-box;width:auto;max-width:calc(100% - 20px);font-size:12px';
|
||||
cameraPanel.append(trafficLightRow);
|
||||
const actionPanel=document.createElement('div');
|
||||
actionPanel.style.cssText='position:absolute;bottom:12px;left:12px;z-index:3;pointer-events:none;max-width:calc(100% - 24px);padding:9px 12px;border-radius:8px;background:rgba(8,13,20,.86);border:1px solid #334155;font-size:12px;display:none';
|
||||
cameraPanel.append(actionPanel);
|
||||
function updateActions(scene){
|
||||
const preview=scene?.action_preview;
|
||||
actionPanel.style.display=preview?'block':'none';actionPanel.replaceChildren();
|
||||
if(!preview)return;
|
||||
const heading=document.createElement('div');heading.style.cssText='display:flex;gap:16px;align-items:center;justify-content:space-between';
|
||||
const title=document.createElement('strong');title.textContent=preview.primary.replaceAll('?','').toUpperCase();
|
||||
title.style.cssText='font-size:20px;letter-spacing:.04em;color:'+(['stop','hold','brake'].includes(preview.primary)?'#ffb36b':'#66e1a1');
|
||||
const tag=document.createElement('span');tag.textContent='PREVIEW';tag.style.cssText='font-size:10px;letter-spacing:.1em;color:#8291a4';
|
||||
heading.append(title,tag);
|
||||
const reason=document.createElement('div');reason.textContent=preview.reason;reason.style.cssText='margin-top:4px;color:#cbd5e1';
|
||||
const paths=document.createElement('div');paths.style.cssText='margin-top:8px;padding-top:7px;border-top:1px solid #334155;color:#9bd2ff';
|
||||
paths.textContent=preview.paths.length?preview.paths.map(p=>({straight:'↑ Straight',left:'← Left',right:'→ Right'})[p.direction]+(p.blocked?' · blocked':p.partial?' · possible':'')).join(' '):({segmentation_off:'Enable Road / sky for paths',calibration_missing:'Waiting for calibration',bumper_offset_missing:'Set bumper offset',no_supported_corridor:'No clear path detected',ground_unavailable:'Road projection unavailable'})[preview.path_status]||'No clear path detected';
|
||||
actionPanel.append(heading,reason,paths);
|
||||
}
|
||||
function updateTrafficLights(scene){
|
||||
const colors={red:'#ff4c4c',yellow:'#ffd84d',green:'#66e1a1'};
|
||||
const lights=(scene?.lights||[]).filter(color=>colors[color]);
|
||||
trafficLightRow.replaceChildren();
|
||||
trafficLightRow.style.display=lights.length?'flex':'none';
|
||||
trafficLightRow.setAttribute('aria-label',lights.join(', '));
|
||||
for(const color of lights){
|
||||
const dot=document.createElement('span');
|
||||
dot.style.cssText='display:block;width:14px;height:14px;border-radius:50%;background:'+colors[color];
|
||||
trafficLightRow.append(dot);
|
||||
}
|
||||
const dominant=scene?.dominant_light;
|
||||
if(colors[dominant]){
|
||||
const dot=document.createElement('span');
|
||||
dot.style.cssText='display:block;width:19px;height:19px;border-radius:50%;margin-left:6px;box-shadow:0 0 0 2px rgba(255,255,255,.65);background:'+colors[dominant];
|
||||
dot.setAttribute('aria-label','Dominant '+dominant);
|
||||
trafficLightRow.append(dot);
|
||||
}
|
||||
}
|
||||
updateTrafficLights(null);
|
||||
const sceneLayoutStyle=document.createElement('style');
|
||||
sceneLayoutStyle.textContent='.scene-layout{display:grid;grid-template-columns:minmax(0,1fr) 340px;gap:16px;align-items:start;margin:16px 0}.scene-layout .video{min-width:0}@media(max-width:900px){.scene-layout{grid-template-columns:1fr}.scene-layout section{max-width:460px;width:100%;box-sizing:border-box}}';
|
||||
document.head.append(sceneLayoutStyle);
|
||||
function sizeTopDownPanel(){
|
||||
topDownPanel.style.height=window.innerWidth>900?cameraPanel.getBoundingClientRect().height+'px':'auto';
|
||||
topDownCanvas.style.height=window.innerWidth>900?'100%':'520px';
|
||||
drawTopDown(lastTopDownScene);
|
||||
}
|
||||
new ResizeObserver(sizeTopDownPanel).observe(cameraPanel);
|
||||
window.addEventListener('resize',sizeTopDownPanel);
|
||||
|
||||
const topDownCanvas=topDownPanel.querySelector('canvas'),topDownContext=topDownCanvas.getContext('2d');
|
||||
let lastTopDownScene=null,segmentationImage=null,segmentationUrl=null;
|
||||
function drawTopDown(scene){
|
||||
lastTopDownScene=scene;updateTrafficLights(scene);updateActions(scene);
|
||||
const rect=topDownCanvas.getBoundingClientRect(),w=rect.width||340,h=rect.height||520;
|
||||
const ratio=window.devicePixelRatio||1;
|
||||
const pixelWidth=Math.round(w*ratio),pixelHeight=Math.round(h*ratio);
|
||||
if(topDownCanvas.width!==pixelWidth||topDownCanvas.height!==pixelHeight){topDownCanvas.width=pixelWidth;topDownCanvas.height=pixelHeight;}
|
||||
const ctx=topDownContext,scale=Math.max(1,(h-44)/70),origin=h-24;
|
||||
ctx.setTransform(ratio,0,0,ratio,0,0);
|
||||
const point=(forward,left)=>[w/2-left*scale,origin-forward*scale];
|
||||
ctx.fillStyle='#101722';ctx.fillRect(0,0,w,h);
|
||||
if(scene?.segmentation!==segmentationUrl){
|
||||
segmentationUrl=scene?.segmentation;
|
||||
if(!segmentationUrl)segmentationImage=null;
|
||||
if(segmentationUrl){
|
||||
const url=segmentationUrl,img=new Image();
|
||||
img.onload=()=>{if(segmentationUrl===url){segmentationImage=img;drawTopDown(lastTopDownScene);}};
|
||||
img.src=url;
|
||||
}
|
||||
}
|
||||
if(segmentationImage)ctx.drawImage(segmentationImage,w/2-20*scale,origin-70*scale,40*scale,70*scale);
|
||||
ctx.font='11px system-ui';ctx.lineWidth=1;
|
||||
for(let distance=0;distance<=70;distance+=10){
|
||||
const y=point(distance,0)[1];ctx.strokeStyle='#253445';ctx.beginPath();ctx.moveTo(0,y);ctx.lineTo(w,y);ctx.stroke();
|
||||
ctx.fillStyle='#9cabbc';ctx.fillText(distance+' m',7,y-5);
|
||||
}
|
||||
if(scene){
|
||||
(scene.lanes||[]).forEach((line,index)=>{
|
||||
if((scene.probabilities||[])[index]<.05)return;
|
||||
ctx.strokeStyle='rgba(255,255,255,'+Math.max(.15,Math.min(1,(scene.probabilities||[])[index]||0))+')';ctx.lineWidth=2;ctx.beginPath();
|
||||
let drawing=false;
|
||||
(line.x||[]).forEach((x,i)=>{
|
||||
const y=(line.y||[])[i];
|
||||
if(!Number.isFinite(x)||!Number.isFinite(y)||x<0||x>70){drawing=false;return;}
|
||||
const p=point(x-(scene.bumper_offset||0),-y);if(drawing)ctx.lineTo(...p);else ctx.moveTo(...p);drawing=true;
|
||||
});ctx.stroke();
|
||||
});
|
||||
for(const edge of scene.our_edges||[]){
|
||||
ctx.save();ctx.strokeStyle='#00dcdc';ctx.lineWidth=2;ctx.beginPath();
|
||||
edge.forEach((p,i)=>{if(i)ctx.lineTo(...point(...p));else ctx.moveTo(...point(...p));});ctx.stroke();ctx.restore();
|
||||
}
|
||||
for(const path of scene.action_preview?.show_paths ? scene.action_preview.paths : []){
|
||||
ctx.save();
|
||||
if(path.selected){
|
||||
const start=point(0,0),end=point(...path.points[path.points.length-1]);
|
||||
const gradient=ctx.createLinearGradient(...start,...end);gradient.addColorStop(0,'#41e150');gradient.addColorStop(1,'#4bbfff');
|
||||
ctx.strokeStyle=gradient;ctx.lineCap='round';ctx.lineJoin='round';
|
||||
const draw=()=>{ctx.beginPath();ctx.moveTo(...start);path.points.forEach(p=>ctx.lineTo(...point(...p)));ctx.stroke();};
|
||||
ctx.globalAlpha=.3;ctx.lineWidth=1.8*scale;draw();ctx.globalAlpha=1;ctx.lineWidth=3;draw();
|
||||
ctx.fillStyle='#41e150';ctx.beginPath();ctx.arc(...start,5,0,Math.PI*2);ctx.fill();
|
||||
}else{
|
||||
ctx.strokeStyle=path.blocked?'#ff9d45':'#74ccff';ctx.lineWidth=2;ctx.setLineDash([5,4]);ctx.beginPath();
|
||||
path.points.forEach(([forward,left],i)=>{const p=point(forward,left);if(i)ctx.lineTo(...p);else ctx.moveTo(...p);});ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
const end=path.points[path.points.length-1];
|
||||
ctx.fillStyle=path.blocked?'#ff9d45':'#74ccff';ctx.fillText(path.direction+(path.partial?' · possible':''),...point(...end));
|
||||
}
|
||||
(scene.radar||[]).forEach(track=>{
|
||||
if(track.forward<0||track.forward>70)return;
|
||||
const [x,y]=point(track.forward,track.left);
|
||||
ctx.strokeStyle=({radar:'#ff50ff',camera:'#00dcff',candidate_radar:'#ffa000'})[track.source]||'#aaa';
|
||||
ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(x,y-5);ctx.lineTo(x+5,y);ctx.lineTo(x,y+5);ctx.lineTo(x-5,y);ctx.closePath();ctx.stroke();
|
||||
});
|
||||
(scene.cars||[]).forEach(car=>{
|
||||
if(car.forward<0||car.forward>70||Math.abs(car.left)>22)return;
|
||||
const [x,y]=point(car.forward,car.left);
|
||||
ctx.fillStyle=car.lane==='ego lane'?'#66e1a1':car.lane==='lane unknown'?'#a0a8b4':'#ffcf62';
|
||||
const length=car.type==='bus'||car.type==='truck'?6:4;
|
||||
ctx.save();ctx.translate(x,y);ctx.rotate(Number.isFinite(car.heading)?car.heading:0);
|
||||
ctx.fillRect(-.9*scale,-length*scale/2,1.8*scale,length*scale);
|
||||
ctx.restore();
|
||||
ctx.fillStyle='#eaf0f6';ctx.fillText(car.id===null?car.type:'ID '+car.id,x+1.2*scale,y+4);
|
||||
});
|
||||
}
|
||||
const [x,y]=point(0,0);ctx.fillStyle='#5aaaff';ctx.fillRect(x-.9*scale,y-2*scale,1.8*scale,4*scale);ctx.fillStyle='#eaf0f6';ctx.fillText('YOU',x+15,y+4);
|
||||
if(!scene){ctx.fillStyle='#9cabbc';ctx.fillText('Waiting for camera and lane data',85,35);}
|
||||
}
|
||||
drawTopDown(null);
|
||||
|
||||
// Render only the newest scene per browser paint; never accumulate old scenes.
|
||||
let pendingTopDown=null,topDownPaint=null;
|
||||
const topDownStream=new EventSource('/top-down-stream');
|
||||
topDownStream.onmessage=event=>{
|
||||
pendingTopDown=JSON.parse(event.data);
|
||||
if(topDownPaint===null)topDownPaint=requestAnimationFrame(()=>{topDownPaint=null;drawTopDown(pendingTopDown);});
|
||||
};
|
||||
window.addEventListener('pagehide',()=>{topDownStream.close();if(topDownPaint!==null)cancelAnimationFrame(topDownPaint);});
|
||||
|
||||
// Visible, aligned controls with one consistent layout.
|
||||
const viewerHeader=document.querySelector('header');
|
||||
const primaryControls=viewerHeader.querySelector('.controls');
|
||||
const settingsPanel=document.createElement('div');settingsPanel.className='viewer-settings';
|
||||
function settingsSection(title,nodes){
|
||||
const section=document.createElement('div');section.className='settings-section';section.setAttribute('role','group');section.setAttribute('aria-label',title);
|
||||
const content=document.createElement('div');content.className='settings-content';
|
||||
for(const node of nodes)if(node)content.append(node);
|
||||
section.append(content);settingsPanel.append(section);return section;
|
||||
}
|
||||
const featureControls=document.createElement('div');featureControls.className='controls';
|
||||
for(const id of ['feature-objects','feature-masks','feature-scene'])featureControls.append(document.getElementById(id).closest('label'));
|
||||
featureControls.append(segmentationSource);
|
||||
settingsSection('Detection',[featureControls]);
|
||||
const displayControls=document.createElement('div');displayControls.className='controls';
|
||||
displayControls.append(objectModeLabel,commaOverlay.closest('label'),annotationControls);
|
||||
const displaySection=settingsSection('Overlays',[displayControls,document.querySelector('.opacity-controls'),laneStyleControls]);
|
||||
displaySection.classList.add('wide-settings');
|
||||
settingsSection('Areas',[areaControls]);
|
||||
const sidesSection=settingsSection('Side cameras',[sideControls]);
|
||||
sidesSection.classList.add('wide-settings');
|
||||
const connectionSection=settingsSection('Source & calibration',[bumperLabel,document.querySelector('#connection')]);
|
||||
connectionSection.classList.add('full-settings');
|
||||
viewerHeader.append(settingsPanel);
|
||||
primaryControls.prepend(camera,modelSize);
|
||||
button.classList.add('primary-action');
|
||||
const cleanStyle=document.createElement('style');
|
||||
cleanStyle.textContent=`
|
||||
body{background:#080d14}main{padding:18px 24px;max-width:none}
|
||||
header{margin-bottom:12px;align-items:center;flex-wrap:wrap}header h1{font-size:21px;letter-spacing:-.5px}header p{font-size:12px}
|
||||
.controls{gap:10px}button,select{padding:9px 12px;font-size:13px;border-radius:8px}
|
||||
button{background:#243244;color:#eaf0f6;border:1px solid #35465c}button:hover{background:#30425a}.primary-action{background:#66e1a1;color:#09120e;border-color:#66e1a1;min-width:88px}
|
||||
.video{border-radius:10px}.scene-layout{margin:12px 0;gap:12px;grid-template-columns:minmax(0,1fr) 340px}
|
||||
header{display:flex;gap:8px 16px;padding:12px;background:#101722;border:1px solid #263241;border-radius:10px;justify-content:flex-start;align-items:center}
|
||||
header .controls{gap:8px;flex-wrap:wrap}header select,header button{font-size:12px;padding:7px 9px}
|
||||
.viewer-settings{display:contents}
|
||||
.settings-section{display:contents}.settings-content{display:contents}
|
||||
.settings-content .controls,.settings-content .opacity-controls,.settings-content .side-controls{display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin:0;width:auto;max-width:none}
|
||||
.settings-content .controls,.settings-content .opacity-controls,.settings-content .side-controls{padding-left:12px;border-left:1px solid #334155}
|
||||
.settings-content label,.settings-content .overlay-toggle{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#ccd6e3;white-space:nowrap}
|
||||
.settings-content input[type=checkbox]{margin:0;width:14px;height:14px}
|
||||
.settings-content .opacity-controls label{display:inline-flex;gap:6px}
|
||||
.settings-content input[type=range]{width:72px;min-width:0;padding:0}
|
||||
.settings-content output{font-size:11px;color:#94a3b8;width:34px;text-align:right}
|
||||
.settings-content #connection{margin:0;gap:8px}.settings-content #connection input{width:160px;max-width:100%;padding:7px;font-size:12px}
|
||||
.settings-content button{white-space:nowrap}
|
||||
.settings-content .side-column{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.settings-content .side-column label{display:inline-flex}
|
||||
.settings-content .side-column strong{font-size:11px;color:#8494a8}
|
||||
.settings-content .side-controls>span{font-size:11px;color:#8494a8}
|
||||
.settings-content #bumper-offset{width:64px!important;padding:6px;font-size:12px}
|
||||
@media(max-width:600px){header{padding:10px}header .controls{width:auto}.settings-content .controls,.settings-content .opacity-controls{padding-left:0;border-left:0}}
|
||||
footer{margin-top:8px;font-size:12px;gap:14px}#status{font-size:12px;margin-top:10px;line-height:1.5}
|
||||
.replay-controls{margin:0 0 12px;font-size:12px}.replay-controls span{color:#94a3b8}
|
||||
@media(max-width:900px){.scene-layout{grid-template-columns:1fr}}
|
||||
@media(max-width:600px){main{padding:12px}header .controls{width:100%}header .controls select{flex:1;min-width:0}.opacity-controls label{flex-wrap:wrap}}
|
||||
`;
|
||||
document.head.append(cleanStyle);
|
||||
annotationControls.style.flexWrap='wrap';
|
||||
annotationControls.style.gap='12px 18px';
|
||||
|
||||
function controlLabel(id,text){
|
||||
const input=document.getElementById(id),label=input?.closest('label');
|
||||
if(!label)return;
|
||||
for(const node of label.childNodes)if(node.nodeType===Node.TEXT_NODE)node.textContent='';
|
||||
const caption=document.createTextNode(text+' ');
|
||||
if(input.type==='checkbox')label.append(caption);else label.insertBefore(caption,label.firstChild);
|
||||
}
|
||||
for(const [id,text] of Object.entries({'feature-masks':'Masks','feature-scene':'Road / sky','comma-overlay':'Lanes / edges','show-boxes':'Boxes','show-motion':'Motion','road-signals':'Lights / signs','show-radar':'Radar','seg-opacity':'Scene','lane-opacity':'Areas','lane-ui-width':'Lane width','lane-ui-opacity':'Lanes'}))controlLabel(id,text);
|
||||
document.getElementById('add-area').textContent='Add area';
|
||||
document.getElementById('remove-area').textContent='Delete area';
|
||||
viewerHeader.querySelector('h1').parentElement.remove();
|
||||
|
||||
const routeForm=document.createElement('form');routeForm.className='controls';
|
||||
routeForm.innerHTML='<input aria-label="Replay route" id="replay-route" placeholder="dongle/route/segment" style="width:300px;max-width:55vw;font-size:12px;padding:7px"><button type="submit">Connect route</button>';
|
||||
primaryControls.append(routeForm);
|
||||
const connectionForm=document.querySelector('#connection');primaryControls.append(connectionForm);
|
||||
connect.textContent='Connect IP';
|
||||
connectionForm.style.cssText='display:flex;gap:8px;margin:0;flex-wrap:wrap';
|
||||
source.style.width='190px';
|
||||
source.closest('label').firstChild.textContent='Car IP ';
|
||||
source.placeholder='IP address or server URL';
|
||||
fetch('/status').then(r=>r.json()).then(s=>{
|
||||
document.getElementById('replay-route').value=s.replay_route||'d59ca223dca8da93/00000498--3190be1e8b/6';
|
||||
if(s.input_mode==='idle')waiting.textContent='Choose a route or car address, then Connect.';
|
||||
});
|
||||
async function connectInput(mode,route){
|
||||
const response=await fetch('/input-mode',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({mode,...(route?{route}:{})})});
|
||||
if(!response.ok)throw Error('Could not connect input; check the route or address');
|
||||
}
|
||||
routeForm.onsubmit=async event=>{
|
||||
event.preventDefault();const submit=routeForm.querySelector('button');submit.disabled=true;
|
||||
try{await connectInput('replay',document.getElementById('replay-route').value.trim());}
|
||||
catch(error){document.querySelector('#status').textContent=error.message;submit.disabled=false;}
|
||||
};
|
||||
connectionForm.onsubmit=async event=>{
|
||||
event.preventDefault();connect.disabled=true;
|
||||
try{
|
||||
const response=await fetch('/source',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:source.value})});
|
||||
if(!response.ok)throw Error('Invalid car address');
|
||||
await connectInput('live');
|
||||
}catch(error){document.querySelector('#status').textContent=error.message;connect.disabled=false;}
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,652 +0,0 @@
|
||||
"""YOLO browser server, sunnydrive transport, and calibrated lane overlays."""
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import webbrowser
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import HTTPError
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
MODELS = {} # Populated by the YOLO viewer.
|
||||
|
||||
WEB_PAGE = """<!doctype html><html lang="en"><meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1"><title>sunnydrive · Road segmentation</title>
|
||||
<style>
|
||||
*{box-sizing:border-box}body{margin:0;background:#0b1016;color:#eaf0f6;font:15px system-ui,sans-serif}
|
||||
main{max-width:1440px;margin:auto;padding:28px}header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:24px}
|
||||
h1{font-size:24px;margin:0 0 6px}p{margin:0;color:#94a3b8}button{background:#66e1a1;color:#09120e;border:0;border-radius:10px;padding:12px 22px;font:600 15px system-ui;cursor:pointer}
|
||||
.controls{display:flex;align-items:center;gap:12px;flex-wrap:wrap}select{background:#141c26;color:#eaf0f6;border:1px solid #334155;border-radius:10px;padding:11px;font:15px system-ui}.overlay-toggle{white-space:nowrap}input[type=checkbox]{width:auto;accent-color:#66e1a1}
|
||||
.video{background:#141c26;border:1px solid #263241;border-radius:16px;overflow:hidden;position:relative;min-height:220px;aspect-ratio:16/9;max-height:calc(100dvh - 210px);display:grid;place-items:center}
|
||||
img{width:100%;height:100%;object-fit:contain;position:absolute;inset:0}#waiting{padding:28px;text-align:center;z-index:1;background:#141c26}
|
||||
footer{display:flex;flex-wrap:wrap;gap:22px;margin-top:18px;color:#a9b6c7}.dot{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:7px}
|
||||
#stats{margin-left:auto;color:#eaf0f6;font-variant-numeric:tabular-nums}#status{margin-top:15px}#button:disabled{opacity:.5;cursor:wait}
|
||||
#connection{display:flex;align-items:center;gap:12px;margin-top:16px;flex-wrap:wrap}#connection label{display:flex;align-items:center;gap:12px;color:#94a3b8}input{background:#141c26;color:#eaf0f6;border:1px solid #334155;border-radius:8px;padding:9px;width:360px;max-width:55vw;font:14px system-ui}#connect{padding:9px 15px}#ip{color:#94a3b8;font-size:13px}
|
||||
.opacity-controls{display:flex;gap:28px;flex-wrap:wrap;margin-top:16px}.opacity-controls label{display:flex;align-items:center;gap:10px;color:#a9b6c7}.opacity-controls input{width:160px;padding:0;accent-color:#66e1a1}.opacity-controls output{width:42px;font-variant-numeric:tabular-nums}
|
||||
@media(max-width:600px){main{padding:16px}h1{font-size:20px}#stats{margin-left:0}}
|
||||
</style><main><header><div><h1>sunnydrive road segmentation</h1><p>Live comma camera · processing on your Mac</p></div><div class="controls"><select id="model" aria-label="Model">MODEL_OPTIONS</select><select id="camera" aria-label="Camera"><option value="road">Road camera</option><option value="wideRoad">Wide road camera</option></select><label class="overlay-toggle"><input id="comma-overlay" type="checkbox"> Comma overlay</label><button id="button" disabled>Pause</button></div></header>
|
||||
<div class="video"><img id="video" alt="Live road segmentation overlay"><div id="waiting">Choose a route or car address, then Connect.</div></div>
|
||||
<footer id="legend"></footer><div class="opacity-controls"><label>Segmentation opacity <input id="seg-opacity" aria-label="Segmentation opacity" type="range" min="0" max="100" value="45"><output id="seg-value">45%</output></label><label>Lane UI opacity <input id="lane-opacity" aria-label="Lane UI opacity" type="range" min="0" max="100" value="100"><output id="lane-value">100%</output></label></div><form id="connection"><label>Comma address <input id="source" aria-label="Comma address" placeholder="IP address or server URL" required></label><button id="connect">Connect</button><button id="lan" type="button">Use LAN IP</button><span id="ip"></span></form><p id="status" role="status">Starting…</p></main>
|
||||
<script>
|
||||
const button=document.querySelector('#button'),waiting=document.querySelector('#waiting'),video=document.querySelector('#video'),camera=document.querySelector('#camera'),model=document.querySelector('#model');
|
||||
const source=document.querySelector('#source'),connect=document.querySelector('#connect');
|
||||
const segOpacity=document.querySelector('#seg-opacity'),laneOpacity=document.querySelector('#lane-opacity');let opacityTimer,opacityEdited=false,viewerBoot=null;
|
||||
function opacityLabels(){document.querySelector('#seg-value').textContent=segOpacity.value+'%';document.querySelector('#lane-value').textContent=laneOpacity.value+'%';}
|
||||
segOpacity.oninput=laneOpacity.oninput=()=>{opacityEdited=true;opacityLabels();clearTimeout(opacityTimer);opacityTimer=setTimeout(()=>fetch('/opacity',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({segmentation:Number(segOpacity.value)/100,comma:Number(laneOpacity.value)/100})}),50);};
|
||||
const commaOverlay=document.querySelector('#comma-overlay');commaOverlay.onchange=async()=>{await fetch('/overlay',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({overlay:commaOverlay.checked})});await update();};
|
||||
document.querySelector('#lan').onclick=()=>{source.value='10.83.106.109';document.querySelector('#connection').requestSubmit();};
|
||||
video.onload=()=>waiting.hidden=true;video.src='/video';
|
||||
button.onclick=async()=>{button.disabled=true;try{const r=await fetch('/toggle',{method:'POST'});if(!r.ok)throw Error('Control failed');}finally{await update();}};
|
||||
camera.onchange=async()=>{camera.disabled=true;try{const r=await fetch('/camera',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({camera:camera.value})});if(!r.ok)throw Error('Camera switch failed');}finally{camera.disabled=false;await update();}};
|
||||
model.onchange=async()=>{model.disabled=true;try{const r=await fetch('/model',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({model:model.value})});if(!r.ok)throw Error('Model switch failed');}finally{model.disabled=false;await update();}};
|
||||
document.querySelector('#connection').onsubmit=async e=>{e.preventDefault();connect.disabled=true;try{const r=await fetch('/source',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({source:source.value})});if(!r.ok)throw Error('Use an http:// or https:// comma address');}catch(e){document.querySelector('#status').textContent=e.message;}finally{connect.disabled=false;}};
|
||||
async function update(){try{const r=await fetch('/status',{cache:'no-store'});const s=await r.json();
|
||||
if(viewerBoot!==null&&viewerBoot!==s.viewer_boot){location.reload();return;}viewerBoot=s.viewer_boot;
|
||||
document.querySelector('#status').textContent=(s.connection_message||s.message)+(s.input_mode!=='idle'&&s.comma_overlay&&!s.comma_overlay_ready?' · Waiting for comma model / calibration':'');button.disabled=!s.ready;button.textContent=s.paused?'Resume':'Pause';if(!camera.disabled)camera.value=s.camera;if(!model.disabled)model.value=s.requested_model;
|
||||
if(document.activeElement!==source&&!connect.disabled)source.value=s.comma_url;document.querySelector('#ip').textContent=s.input_mode==='idle'?'':s.comma_ip?`IP: ${s.comma_ip}`:(s.ready?'Connected':'Connecting…');
|
||||
commaOverlay.checked=s.comma_overlay;
|
||||
if(!opacityEdited){segOpacity.value=Math.round(s.segmentation_opacity*100);laneOpacity.value=Math.round(s.comma_opacity*100);}opacityLabels();
|
||||
waiting.hidden=!!s.ready;
|
||||
if(!s.ready){waiting.textContent=s.connection_message||s.message;}
|
||||
const colors=s.model==='comma10k'?[['Road','#3cc83c'],['Lane markings','#ffdc00'],['Vehicles / people','#f0aa14'],['Your car','#b450c8']]:[['Road','#3cc83c'],['Sidewalk','#b450c8'],['People','#f03232'],['Vehicles','#f0aa14']];
|
||||
if(s.comma_overlay)colors.push(['Comma lanes','#ffffff'],['Road edges','#ff8000'],['Model path','#409dff']);
|
||||
const legend=document.querySelector('#legend');legend.replaceChildren();for(const [label,color] of colors){const span=document.createElement('span'),dot=document.createElement('i');dot.className='dot';dot.style.background=color;span.append(dot,document.createTextNode(label));legend.append(span);}
|
||||
const stats=document.createElement('span');stats.id='stats';stats.textContent=s.fps?`${s.fps.toFixed(1)} processing FPS · ${s.age_ms} ms since decode`:'';legend.append(stats);
|
||||
}catch(e){document.querySelector('#status').textContent='Viewer disconnected. Restart the command in Terminal.';button.disabled=true;}}
|
||||
update();setInterval(update,750);
|
||||
</script></html>""".replace("MODEL_OPTIONS", "".join(f'<option value="{key}">{name}</option>' for key, name in MODELS.items()))
|
||||
|
||||
|
||||
def acquire_viewer_lock():
|
||||
"""One Mac web inference viewer, regardless of task or HTTP port."""
|
||||
handle = open(f"/tmp/sunnydrive-viewer-{os.getuid()}.lock", "a+")
|
||||
try:
|
||||
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except BlockingIOError:
|
||||
handle.close()
|
||||
raise SystemExit("A sunnydrive web viewer is already running. Stop it before starting another.") from None
|
||||
return handle
|
||||
|
||||
|
||||
def open_viewer_if_absent(address, browser_seen):
|
||||
# Existing viewer tabs poll status, including after their server restarts.
|
||||
if not browser_seen.wait(timeout=3):
|
||||
webbrowser.open(address)
|
||||
|
||||
|
||||
def start_web(args):
|
||||
if not getattr(args, "viewer_lock", None):
|
||||
args.viewer_lock = acquire_viewer_lock()
|
||||
changed = threading.Condition()
|
||||
browser_seen = threading.Event()
|
||||
enabled = threading.Event()
|
||||
enabled.set()
|
||||
state = {"model": args.model, "requested_model": args.model, "camera": args.camera, "comma_url": args.comma, "comma_ip": "", "comma_overlay": False, "comma_overlay_ready": False, "segmentation_opacity": .45, "comma_opacity": 1., "ready": False, "paused": False, "fps": 0, "age_ms": 0,
|
||||
"message": "Connecting / pairing: approve 'Mac road segmentation' on the comma if prompted."}
|
||||
state["viewer_boot"] = secrets.token_hex(8)
|
||||
state["input_mode"]="idle" if args.idle else "replay" if args.replay else "live"
|
||||
state["replay_route"]=args.route or args.replay_route or "d59ca223dca8da93/00000498--3190be1e8b/6"
|
||||
if args.idle:state["message"]="Choose a route or car address, then Connect."
|
||||
frame = [None, 0]
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def log_message(self, *_args):
|
||||
pass
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/top-down-stream" and getattr(args,"custom_masks",False):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type","text/event-stream")
|
||||
self.send_header("Cache-Control","no-store")
|
||||
self.end_headers()
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,1)
|
||||
version=-1
|
||||
try:
|
||||
while True:
|
||||
with changed:
|
||||
updated=changed.wait_for(lambda:frame[1]!=version,timeout=15)
|
||||
if updated:
|
||||
version=frame[1]
|
||||
payload=json.dumps(state.get("top_down"),separators=(",",":"))
|
||||
self.wfile.write((f"data: {payload}\n\n" if updated else ": keepalive\n\n").encode())
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError,ConnectionResetError):
|
||||
return
|
||||
elif self.path == "/video":
|
||||
self.connection.setsockopt(socket.IPPROTO_TCP,socket.TCP_NODELAY,1)
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
version = 0
|
||||
try:
|
||||
while True:
|
||||
with changed:
|
||||
if not changed.wait_for(lambda: frame[1] != version, timeout=15):
|
||||
continue
|
||||
jpeg, version = frame
|
||||
self.wfile.write(b"--frame\r\nContent-Type: image/jpeg\r\nContent-Length: " + str(len(jpeg)).encode() + b"\r\n\r\n" + jpeg + b"\r\n")
|
||||
self.wfile.flush()
|
||||
except (BrokenPipeError, ConnectionResetError):
|
||||
return
|
||||
elif self.path in ("/", "/status"):
|
||||
if "Mozilla/" in self.headers.get("User-Agent",""):
|
||||
browser_seen.set()
|
||||
with changed:
|
||||
if getattr(args,"get_replay_paused",None):
|
||||
state["paused"]=args.get_replay_paused()
|
||||
body = WEB_PAGE.encode() if self.path == "/" else json.dumps(state).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8" if self.path == "/" else "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def do_POST(self):
|
||||
if self.path not in ("/input-mode", "/toggle", "/camera", "/model", "/source", "/overlay", "/opacity", "/masks", "/rotation", "/position", "/replay-key", "/mask-mode", "/lane-style", "/bumper-offset", "/object-mode", "/annotations", "/features"):
|
||||
return self.send_error(404)
|
||||
# Only the local viewer may control inference; reject cross-site requests.
|
||||
if self.headers.get("Origin") != f"http://127.0.0.1:{args.port}":
|
||||
return self.send_error(403)
|
||||
if self.path == "/input-mode":
|
||||
try:
|
||||
length=int(self.headers.get('Content-Length','0'))
|
||||
if not 0<length<=256:raise ValueError('Invalid request size')
|
||||
body=json.loads(self.rfile.read(length))
|
||||
mode=body['mode']
|
||||
route=body.get('route')
|
||||
if route is not None:
|
||||
import re
|
||||
if not isinstance(route,str) or not re.fullmatch(r'[a-f0-9]{16}[/|][A-Za-z0-9_-]+(?:/\d+)?',route):
|
||||
raise ValueError('Invalid route')
|
||||
if mode not in ('live','replay'):raise ValueError('Invalid input mode')
|
||||
if not getattr(args,'switch_input',None):raise ValueError('Mode switching unavailable')
|
||||
except (ValueError,KeyError,TypeError):
|
||||
return self.send_error(400)
|
||||
switching=args.idle or (mode=='replay')!=args.replay or route is not None
|
||||
if route is not None:args.route=route
|
||||
if switching:
|
||||
with changed:state.update(ready=False,message='Switching to '+mode+'…')
|
||||
self.send_response(204);self.end_headers()
|
||||
if switching:threading.Timer(.25,args.switch_input,args=(mode,)).start()
|
||||
return
|
||||
if self.path == "/features":
|
||||
if not getattr(args,"feature_selection",None):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
length=int(self.headers.get("Content-Length","0"))
|
||||
if not 0<length<=256:
|
||||
raise ValueError("Invalid request size")
|
||||
body=json.loads(self.rfile.read(length))
|
||||
size=body["size"]
|
||||
objects,masks,scene=(body[key] for key in ("objects","masks","scene"))
|
||||
if size not in ('n','s','m','l','x') or any(type(value) is not bool for value in (objects,masks,scene)):
|
||||
raise ValueError("Invalid model features")
|
||||
source=body.get('segmentation_source',args.segmentation_source)
|
||||
if source not in ('yolo','comma10k'):
|
||||
raise ValueError("Invalid segmentation source")
|
||||
selection=args.feature_selection(size,objects,masks,scene)
|
||||
except (ValueError,KeyError,TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
if args.model==selection and args.segmentation_source==source:
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
state['connection_message']=''
|
||||
args.model=selection
|
||||
args.segmentation_source=source
|
||||
state['segmentation_source']=source
|
||||
if not masks:
|
||||
state['object_mode']='full'
|
||||
if getattr(args,'save_selection',None):
|
||||
args.save_selection()
|
||||
state.update(requested_model=selection,ready=False,message="Updating model features…")
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/annotations":
|
||||
if not getattr(args,"custom_masks",False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size=int(self.headers.get("Content-Length","0"))
|
||||
if not 0<size<=256:
|
||||
raise ValueError("Invalid request size")
|
||||
body=json.loads(self.rfile.read(size))
|
||||
labels,boxes=body["labels"],body["boxes"]
|
||||
motion=body.get("motion",True)
|
||||
signals=body.get("signals",True)
|
||||
radar=body.get("radar",True)
|
||||
hide_stationary=body.get("hide_stationary",False)
|
||||
top_seg=body.get("top_seg",False)
|
||||
actions=body.get("actions",True)
|
||||
our_edges=body.get("our_edges",False)
|
||||
if type(labels) is not bool or type(boxes) is not bool or type(motion) is not bool or type(signals) is not bool or type(radar) is not bool or type(hide_stationary) is not bool or type(top_seg) is not bool or type(actions) is not bool or type(our_edges) is not bool:
|
||||
raise ValueError("Invalid annotation visibility")
|
||||
except (ValueError,KeyError,TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state.update(show_labels=labels,show_boxes=boxes,show_motion=motion,road_signals=signals,show_radar=radar,hide_stationary=hide_stationary,show_top_seg=top_seg,show_actions=actions,show_our_edges=our_edges)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/object-mode":
|
||||
if not getattr(args,"custom_masks",False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size=int(self.headers.get("Content-Length","0"))
|
||||
if not 0<size<=64:
|
||||
raise ValueError("Invalid request size")
|
||||
value=json.loads(self.rfile.read(size))["mode"]
|
||||
if value not in ("full","masked","original"):
|
||||
raise ValueError("Invalid object display mode")
|
||||
except (ValueError,KeyError,TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state["object_mode"]=value
|
||||
if value!="full" and getattr(args,"feature_selection",None):
|
||||
task,size=args.model.split('-')
|
||||
args.model=args.feature_selection(size,True,True,task in ('scene','sceneMask','semantic'))
|
||||
if getattr(args,"save_selection",None):
|
||||
args.save_selection()
|
||||
state.update(requested_model=args.model,ready=False,message="Loading object instance masks…")
|
||||
elif value!="full" and not args.model.startswith("segment-"):
|
||||
args.model="segment-"+args.model.rsplit("-",1)[1]
|
||||
if getattr(args,"save_selection",None):
|
||||
args.save_selection()
|
||||
state.update(requested_model=args.model,ready=False,message="Loading object instance masks…")
|
||||
enabled.set()
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/bumper-offset":
|
||||
if not getattr(args,"custom_masks",False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size=int(self.headers.get("Content-Length","0"))
|
||||
if not 0<size<=256:
|
||||
raise ValueError("Invalid request size")
|
||||
value=json.loads(self.rfile.read(size))["meters"]
|
||||
if value is not None and (type(value) not in (int,float) or not np.isfinite(value) or not 0<=value<=5):
|
||||
raise ValueError("Invalid bumper offset")
|
||||
except (ValueError,KeyError,TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state["bumper_offset"]=value
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/lane-style":
|
||||
if not getattr(args,"custom_masks",False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size=int(self.headers.get("Content-Length","0"))
|
||||
if not 0<size<=256:
|
||||
raise ValueError("Invalid request size")
|
||||
body=json.loads(self.rfile.read(size))
|
||||
width,opacity=body["width"],body["opacity"]
|
||||
if type(width) is not int or not 1<=width<=16:
|
||||
raise ValueError("Invalid lane width")
|
||||
if type(opacity) not in (int,float) or not np.isfinite(opacity) or not 0<=opacity<=1:
|
||||
raise ValueError("Invalid lane opacity")
|
||||
except (ValueError,KeyError,TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state.update(lane_width=width,lane_opacity=opacity)
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/mask-mode":
|
||||
if not getattr(args, "custom_masks", False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 128:
|
||||
raise ValueError("Invalid request size")
|
||||
body = json.loads(self.rfile.read(size))
|
||||
mode = body["mode"]
|
||||
camera = body.get("camera",args.camera)
|
||||
if camera not in args.camera_choices:
|
||||
raise ValueError("Unknown camera")
|
||||
if mode not in ("exclude", "keep"):
|
||||
raise ValueError("Choose exclude or keep")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state.setdefault("mask_modes", {})[camera] = mode
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/replay-key":
|
||||
if not getattr(args, "send_replay_key", None):
|
||||
return self.send_error(409, "This viewer did not launch the replay")
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 128:
|
||||
raise ValueError("Invalid request size")
|
||||
key = json.loads(self.rfile.read(size))["key"]
|
||||
if not isinstance(key, str) or key not in (" ","s","S","m","M","e","d","t","i","w","c","+","-","q"):
|
||||
raise ValueError("Unknown replay shortcut")
|
||||
args.send_replay_key(key)
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
except (OSError, RuntimeError):
|
||||
return self.send_error(409, "Replay is no longer running")
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/position":
|
||||
if not getattr(args, "custom_masks", False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 1024:
|
||||
raise ValueError("Invalid request size")
|
||||
body = json.loads(self.rfile.read(size))
|
||||
positions = {side: body[side] for side in ("left", "right")}
|
||||
if any(not isinstance(p, list) or len(p) != 2 or any(type(v) not in (int, float) or not np.isfinite(v) or not -.5 <= v <= .5 for v in p) for p in positions.values()):
|
||||
raise ValueError("Position must be between -50% and 50%")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state["side_position"] = positions
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/rotation":
|
||||
if not getattr(args, "custom_masks", False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 1024:
|
||||
raise ValueError("Invalid request size")
|
||||
body = json.loads(self.rfile.read(size))
|
||||
angles = {side: body[side] for side in ("left", "right")}
|
||||
if any(type(v) not in (int, float) or not np.isfinite(v) or not -180 <= v <= 180 for v in angles.values()):
|
||||
raise ValueError("Rotation must be between -180 and 180 degrees")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state["side_rotation"] = angles
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/masks":
|
||||
if not getattr(args, "custom_masks", False):
|
||||
return self.send_error(404)
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 32768:
|
||||
raise ValueError("Invalid request size")
|
||||
body = json.loads(self.rfile.read(size))
|
||||
camera, polygons = body["camera"], body["polygons"]
|
||||
layout = body.get("layout")
|
||||
sides = body.get("sides")
|
||||
if sides is not None and (not isinstance(sides,list) or len(sides)!=len(polygons) or any(side not in (None,"left","right") for side in sides)):
|
||||
raise ValueError("Invalid mask attachment")
|
||||
if layout is not None:
|
||||
for side in ("left", "right"):
|
||||
angle,position = layout["rotation"][side],layout["position"][side]
|
||||
if type(angle) not in (int,float) or not np.isfinite(angle) or not -180 <= angle <= 180:
|
||||
raise ValueError("Invalid mask rotation")
|
||||
if not isinstance(position,list) or len(position)!=2 or any(type(v) not in (int,float) or not np.isfinite(v) or not -.5 <= v <= .5 for v in position):
|
||||
raise ValueError("Invalid mask position")
|
||||
if camera not in args.camera_choices or not isinstance(polygons, list) or len(polygons) > 16:
|
||||
raise ValueError("Invalid masks")
|
||||
for polygon in polygons:
|
||||
if not isinstance(polygon, list) or not 3 <= len(polygon) <= 32:
|
||||
raise ValueError("Invalid polygon")
|
||||
if any(not isinstance(p, list) or len(p) != 2 or any(type(v) not in (int, float) or not np.isfinite(v) or not -10 <= v <= 10 for v in p) for p in polygon):
|
||||
raise ValueError("Invalid coordinates")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state.setdefault("blackout_areas", {})[camera] = polygons
|
||||
state.setdefault("mask_layouts", {})[camera] = layout
|
||||
state.setdefault("mask_sides", {})[camera] = sides
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path == "/opacity":
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 1024:
|
||||
raise ValueError("Invalid request length")
|
||||
body = json.loads(self.rfile.read(size))
|
||||
values = [body["segmentation"], body["comma"]]
|
||||
if any(type(value) not in (int, float) or not np.isfinite(value) or not 0 <= value <= 1 for value in values):
|
||||
raise ValueError("Opacity must be between 0 and 1")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
state.update(segmentation_opacity=values[0], comma_opacity=values[1])
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if self.path in ("/camera", "/model", "/source", "/overlay"):
|
||||
try:
|
||||
size = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < size <= 1024:
|
||||
raise ValueError("Invalid request length")
|
||||
field = self.path[1:]
|
||||
value = json.loads(self.rfile.read(size))[field]
|
||||
if field == "overlay":
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("Overlay must be a boolean")
|
||||
with changed:
|
||||
state["comma_overlay"] = value
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
choices = getattr(args, "camera_choices", ("road", "wideRoad")) if field == "camera" else MODELS
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("Invalid selection")
|
||||
if field == "source":
|
||||
value = value.strip()
|
||||
if "://" not in value:
|
||||
value = "http://" + value + (":8766" if ":" not in value else "")
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ("", "/"):
|
||||
raise ValueError("Use a comma server URL without credentials or a path")
|
||||
value = value.rstrip("/")
|
||||
elif value not in choices:
|
||||
raise ValueError("Unknown selection")
|
||||
except (ValueError, KeyError, TypeError):
|
||||
return self.send_error(400)
|
||||
with changed:
|
||||
attribute = "comma" if field == "source" else field
|
||||
if getattr(args, attribute) != value:
|
||||
setattr(args, attribute, value)
|
||||
if field=="model" and state.get("object_mode","full")!="full" and not value.startswith("segment-"):
|
||||
state["object_mode"]="full"
|
||||
if field in ("camera", "model") and getattr(args, "save_selection", None):
|
||||
args.save_selection()
|
||||
state.update(ready=False, paused=False, connection_message="", message=f"Switching {field}…")
|
||||
state[{"camera": "camera", "model": "requested_model", "source": "comma_url"}[field]] = value
|
||||
if field == "source":
|
||||
state["comma_ip"] = ""
|
||||
enabled.set()
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
if getattr(args,"send_replay_key",None):
|
||||
try:
|
||||
args.send_replay_key(" ")
|
||||
except (OSError,RuntimeError):
|
||||
return self.send_error(409,"Replay is no longer running")
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
return
|
||||
with changed:
|
||||
if enabled.is_set():
|
||||
enabled.clear()
|
||||
else:
|
||||
enabled.set()
|
||||
state["paused"] = not enabled.is_set()
|
||||
state["message"] = "Paused — showing the last processed frame." if state["paused"] else "Live · segmentation runs on your Mac."
|
||||
self.send_response(204)
|
||||
self.end_headers()
|
||||
|
||||
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
||||
server.daemon_threads = True
|
||||
threading.Thread(target=server.serve_forever, daemon=True).start()
|
||||
address = f"http://127.0.0.1:{args.port}"
|
||||
print(f"Browser viewer: {address} (Ctrl+C to stop)", flush=True)
|
||||
if not args.no_open:
|
||||
threading.Thread(target=open_viewer_if_absent,args=(address,browser_seen),daemon=True).start()
|
||||
return state, frame, changed, enabled, server
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def api(base, path, body=None):
|
||||
data = json.dumps(body).encode() if body is not None else None
|
||||
with urlopen(Request(base + path, data=data, headers={"Content-Type": "application/json"}), timeout=10) as response:
|
||||
return json.load(response)
|
||||
|
||||
|
||||
def _save_tokens(path,saved):
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_TRUNC,0o600)
|
||||
os.fchmod(fd,0o600)
|
||||
with os.fdopen(fd,'w') as out:json.dump(saved,out)
|
||||
|
||||
|
||||
_pairing_lock=threading.Lock()
|
||||
|
||||
|
||||
def token_for(base,path,on_status=lambda message:None):
|
||||
with _pairing_lock:
|
||||
try:
|
||||
token=_token_for(base,path,on_status)
|
||||
on_status('')
|
||||
return token
|
||||
except Exception as error:
|
||||
on_status(str(error))
|
||||
raise
|
||||
|
||||
|
||||
def _token_for(base, path, on_status):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
saved = json.loads(path.read_text()) if path.exists() else {}
|
||||
if base in saved:
|
||||
try:
|
||||
with urlopen(Request(base+'/alerts',headers={'Authorization':'Bearer '+saved[base]}),timeout=5):pass
|
||||
return saved[base]
|
||||
except HTTPError as error:
|
||||
if error.code!=401:raise
|
||||
saved.pop(base)
|
||||
legacy=Path('/private/tmp/roadseg-tokens.json')
|
||||
if legacy.exists() and legacy!=path:
|
||||
try:previous=json.loads(legacy.read_text())
|
||||
except (OSError,ValueError):previous={}
|
||||
for token in dict.fromkeys(previous.values()):
|
||||
try:
|
||||
with urlopen(Request(base+'/alerts',headers={'Authorization':'Bearer '+token}),timeout=5):pass
|
||||
except HTTPError as error:
|
||||
if error.code in (401,403):continue
|
||||
raise
|
||||
saved[base]=token
|
||||
_save_tokens(path,saved)
|
||||
return token
|
||||
on_status('Approve Mac YOLO viewer on the comma to connect live video.')
|
||||
request = api(base, "/pair/request", {"clientId": "mac-roadseg-" + secrets.token_hex(12), "name": "Mac YOLO viewer"})["request"]
|
||||
print("Approve 'Mac YOLO viewer' on the comma.", flush=True)
|
||||
deadline = time.monotonic() + 240
|
||||
while time.monotonic() < deadline:
|
||||
status = api(base, "/pair/status?request=" + request)
|
||||
if status["status"] == "approved":
|
||||
saved[base] = status["token"]
|
||||
_save_tokens(path,saved)
|
||||
return saved[base]
|
||||
if status["status"] == "expired":
|
||||
break
|
||||
time.sleep(1)
|
||||
raise RuntimeError("Pairing was not approved before expiry")
|
||||
|
||||
|
||||
def read_exact(stream, size):
|
||||
data = bytearray()
|
||||
while len(data) < size:
|
||||
chunk = stream.read(size - len(data))
|
||||
if not chunk:
|
||||
raise EOFError("Comma video connection closed")
|
||||
data.extend(chunk)
|
||||
return bytes(data)
|
||||
|
||||
|
||||
def project_comma_line(shape, calibration, line, offset=0, z_offset=0):
|
||||
height,width = shape[:2]
|
||||
ground = np.asarray(calibration["ground"], dtype=float).reshape(3,3)
|
||||
x, y = np.asarray(line.get("x", []), float), np.asarray(line.get("y", []), float)
|
||||
if x.size != y.size or not x.size:
|
||||
return np.empty((0, 2), np.int32)
|
||||
# modelV2 uses calibration coordinates (y right); ground uses road coordinates (y left).
|
||||
z = np.asarray(line.get("z", []),float)
|
||||
if "modelProjection" in calibration and z.size==x.size:
|
||||
projection = np.asarray(calibration["modelProjection"],float).reshape(3,3)
|
||||
points = np.stack((x,y+offset,z+z_offset),axis=1)
|
||||
projected = points @ projection.T
|
||||
else:
|
||||
points = np.stack((x, -(y + offset), np.ones_like(x)), axis=1)
|
||||
projected = points @ ground.T
|
||||
valid = np.isfinite(projected).all(axis=1) & (x >= .5) & (x <= 100) & (projected[:, 2] > .05)
|
||||
projected = projected[valid]
|
||||
pixels = projected[:, :2] / projected[:, 2:3]
|
||||
pixels *= [width / calibration["width"], height / calibration["height"]]
|
||||
pixels = np.clip(pixels, [-width * 4, -height * 4], [width * 4, height * 4])
|
||||
return pixels.round().astype(np.int32)
|
||||
|
||||
|
||||
def draw_comma_overlay(image, sample, camera, opacity=1., line_width=None):
|
||||
"""Project the telemetry's XY curves using its calibrated ground-plane matrix."""
|
||||
calibration = sample.get("wideRoadCamera" if camera == "wideRoad" else "roadCamera")
|
||||
model = sample.get("model")
|
||||
if not calibration or not model:
|
||||
return False
|
||||
if opacity == 0:
|
||||
return True
|
||||
before = image.copy() if opacity < 1 else None
|
||||
ground = np.asarray(calibration["ground"], dtype=float).reshape(3, 3)
|
||||
height, width = image.shape[:2]
|
||||
|
||||
def project(line, offset=0, z_offset=0):
|
||||
return project_comma_line(image.shape,calibration,line,offset,z_offset)
|
||||
|
||||
for line in model.get("roadEdges", []):
|
||||
points = project(line)
|
||||
if len(points) > 1:
|
||||
cv2.polylines(image, [points], False, (0, 128, 255), line_width or 3, cv2.LINE_AA)
|
||||
for index, line in enumerate(model.get("laneLines", [])):
|
||||
points = project(line)
|
||||
probabilities = model.get("laneLineProbs", [])
|
||||
confidence = probabilities[index] if index < len(probabilities) else 0
|
||||
if len(points) > 1 and confidence is not None and confidence >= .05:
|
||||
layer = image.copy()
|
||||
cv2.polylines(layer, [points], False, (255, 255, 255), line_width or 3, cv2.LINE_AA)
|
||||
cv2.addWeighted(layer, float(np.clip(confidence, .15, 1)), image, 1-float(np.clip(confidence, .15, 1)), 0, dst=image)
|
||||
if before is not None:
|
||||
cv2.addWeighted(image, opacity, before, 1-opacity, 0, dst=image)
|
||||
return True
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user