mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-07-14 13:52:12 +08:00
VACATION
This commit is contained in:
@@ -302,3 +302,112 @@ For temporal behavior on a saved frame directory or route extract, replay the ru
|
||||
```bash
|
||||
.venv/bin/python scripts/replay_speed_limit_vision.py .tmp/vision_iter/seg10_5fps --frames-fps 5
|
||||
```
|
||||
|
||||
The detector/classifier runtime is model-only by default. Use `--crop-ocr` with
|
||||
`evaluate_runtime_manifest.py` or `replay_route_runtime.py` only for an explicit
|
||||
legacy comparison. A model-only release must match reviewed-manifest accuracy
|
||||
and pass representative route replays at measured on-device cadence. Evaluate
|
||||
candidate recognition and temporal publish behavior separately: a correct
|
||||
single-frame candidate can still be suppressed by the history and speed-change
|
||||
confirmation policy.
|
||||
|
||||
Ignored review rows label the proposed crop, not the entire camera frame.
|
||||
Consequently, negative-window candidate and publish counts from
|
||||
`evaluate_reviewed_route_events.py` are an upper bound until the full frame is
|
||||
audited; another valid sign can be present outside the rejected crop. Use the
|
||||
per-row output and frame image to audit any regression delta before treating it
|
||||
as a runtime false positive.
|
||||
|
||||
## Promotion Gate
|
||||
|
||||
Do not promote a checkpoint from classifier validation accuracy alone. Export it
|
||||
to an isolated model directory and run the complete runtime pipeline against the
|
||||
reviewed positive, hard-negative, and failed-drive manifests. A candidate must
|
||||
preserve exact-value recall, avoid new wrong-value reads, and remain within the
|
||||
accepted false-positive budget before route replay.
|
||||
|
||||
Mine detector proposals that fool an integrated-reject classifier into a new
|
||||
reject class before retraining:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/speed_limit_vision/mine_classifier_reject_crops.py \
|
||||
--models-dir /path/to/candidate/models \
|
||||
--dataset /path/to/versioned/classifier \
|
||||
--manifest /path/to/reviewed-negative-manifest.csv
|
||||
```
|
||||
|
||||
Keep the resulting dataset version separate from the current training set. If a
|
||||
hard-negative retrain lowers reviewed recall, reject the checkpoint even when it
|
||||
improves aggregate validation accuracy or removes a known false positive.
|
||||
|
||||
## Active-Learning Review Pass
|
||||
|
||||
Keep parallel miners in separate directories and merge them only when their
|
||||
model and mining fingerprints match:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/speed_limit_vision/merge_manual_review_queues.py \
|
||||
/path/to/shard0 /path/to/shard1 /path/to/shard2 /path/to/shard3 \
|
||||
--output-dir /path/to/merged
|
||||
```
|
||||
|
||||
When rescanning with a new model, compare the fingerprinted queues before
|
||||
selecting another batch. The optional review output retains the full queue
|
||||
schema so it can be passed directly to the selector and review server:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/speed_limit_vision/compare_manual_review_queues.py \
|
||||
--before /path/to/baseline/manual_review_queue.csv \
|
||||
--after /path/to/candidate/manual_review_queue.csv \
|
||||
--output-csv /path/to/comparison.csv \
|
||||
--review-output /path/to/disagreements/manual_review_queue.csv
|
||||
|
||||
.venv/bin/python scripts/speed_limit_vision/select_manual_review_queue.py \
|
||||
--input /path/to/disagreements/manual_review_queue.csv \
|
||||
--output /path/to/review/manual_review_queue.csv \
|
||||
--max-rows 1200 \
|
||||
--min-seconds-per-route-speed 3
|
||||
```
|
||||
|
||||
The selector prioritizes value changes and gained/lost reads, balances routes
|
||||
and speed classes, and removes adjacent same-speed frames from one scene. Start
|
||||
the reviewer and import its labels without moving route media off the training
|
||||
volume:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/speed_limit_vision/serve_manual_review_queue.py \
|
||||
--manifest /path/to/review/manual_review_queue.csv \
|
||||
--port 8765
|
||||
|
||||
.venv/bin/python scripts/speed_limit_vision/import_manual_review_queue.py \
|
||||
--queue /path/to/review/manual_review_queue.csv
|
||||
```
|
||||
|
||||
## Re-mine the Route Backlog
|
||||
|
||||
Re-run the backlog after a candidate passes the reviewed-manifest and route
|
||||
replay gates. Use a model fingerprinted run so new pseudo-labels are staged next
|
||||
to, rather than merged into, the original route-mining data:
|
||||
|
||||
```bash
|
||||
.venv/bin/python scripts/speed_limit_vision/mine_route_training_samples.py \
|
||||
--workspace /Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean \
|
||||
--models-dir /path/to/promoted/models \
|
||||
--model-only \
|
||||
--run-id auto \
|
||||
--sample-every 2.0 \
|
||||
--transition-step 0.5 \
|
||||
--max-frames-per-route 720 \
|
||||
--max-positives-per-route 120 \
|
||||
--max-negatives-per-route 200
|
||||
```
|
||||
|
||||
The output is written under
|
||||
`staging/route_mining/model_<model-fingerprint>_run_<mining-fingerprint>/` with
|
||||
its own detector images, classifier labels, review manifest, and per-route
|
||||
completion state. The mining fingerprint includes the model-only mode,
|
||||
thresholds, sampling configuration, and relevant source code. Review and
|
||||
deduplicate that staged run before merging it into a training dataset. Never
|
||||
overwrite the canonical route samples or automatically train on every mined
|
||||
positive; map agreement and human review remain required because a stronger
|
||||
model can still reproduce its own mistakes at larger scale.
|
||||
|
||||
@@ -66,6 +66,9 @@ TRUCK_LONG_SMOOTH_CARS = {
|
||||
CAR.CHEVROLET_SILVERADO,
|
||||
CAR.CHEVROLET_SILVERADO_CC,
|
||||
}
|
||||
TRUCK_FRICTION_BRAKE_ENGAGE = 25
|
||||
TRUCK_FRICTION_BRAKE_RELEASE = 8
|
||||
TRUCK_FRICTION_BRAKE_IMMEDIATE_ACCEL = -0.65
|
||||
ACC_DASHBOARD_ZERO_RESERVED_CARS = {
|
||||
CAR.CHEVROLET_BLAZER,
|
||||
CAR.CHEVROLET_EQUINOX,
|
||||
@@ -212,11 +215,11 @@ def shape_truck_positive_accel(accel: float, v_ego: float, enabled: bool,
|
||||
if not enabled or accel <= 0.0 or v_ego < 12.0:
|
||||
return accel
|
||||
|
||||
low_scale = float(np.interp(v_ego, [12.0, 18.0, 25.0, 35.0], [0.95, 0.88, 0.82, 0.76]))
|
||||
mid_scale = float(np.interp(v_ego, [12.0, 18.0, 25.0, 35.0], [0.98, 0.94, 0.89, 0.84]))
|
||||
low_scale = float(np.interp(v_ego, [12.0, 18.0, 25.0, 35.0], [0.93, 0.84, 0.76, 0.70]))
|
||||
mid_scale = float(np.interp(v_ego, [12.0, 18.0, 25.0, 35.0], [0.97, 0.91, 0.85, 0.79]))
|
||||
|
||||
if lead_visible and set_speed_error > 0.0:
|
||||
follow_relief = float(np.interp(set_speed_error, [0.0, 1.0, 2.5, 4.0, 6.0], [0.0, 0.08, 0.18, 0.35, 0.55]))
|
||||
follow_relief = float(np.interp(set_speed_error, [0.0, 1.0, 2.5, 4.0, 6.0], [0.0, 0.04, 0.10, 0.18, 0.30]))
|
||||
low_scale += (1.0 - low_scale) * follow_relief
|
||||
mid_scale += (1.0 - mid_scale) * follow_relief
|
||||
|
||||
@@ -229,6 +232,27 @@ def shape_truck_positive_accel(accel: float, v_ego: float, enabled: bool,
|
||||
return accel
|
||||
|
||||
|
||||
def shape_truck_friction_brake(apply_brake: int, accel_cmd: float, stopping: bool, active: bool) -> tuple[int, bool]:
|
||||
if apply_brake <= 0:
|
||||
return 0, False
|
||||
|
||||
# Preserve full brake response for stop control and meaningful deceleration.
|
||||
if stopping or accel_cmd <= TRUCK_FRICTION_BRAKE_IMMEDIATE_ACCEL:
|
||||
return apply_brake, True
|
||||
|
||||
if active:
|
||||
if apply_brake <= TRUCK_FRICTION_BRAKE_RELEASE:
|
||||
return 0, False
|
||||
return apply_brake, True
|
||||
|
||||
if apply_brake >= TRUCK_FRICTION_BRAKE_ENGAGE:
|
||||
return apply_brake, True
|
||||
|
||||
# Keep tiny corrections in the continuous gas/regen torque path. Switching
|
||||
# to friction also forces max regen, which makes a small request perceptible.
|
||||
return 0, False
|
||||
|
||||
|
||||
def get_lka_steering_cmd_counter(next_counter: int, CS) -> int:
|
||||
if getattr(CS, "loopback_lka_steering_cmd_updated", False):
|
||||
return (getattr(CS, "loopback_lka_steering_cmd_counter", next_counter) + 1) % 4
|
||||
@@ -515,6 +539,7 @@ class CarController(CarControllerBase):
|
||||
self.gm_auto_hold_enabled = False
|
||||
self.bolt_acc_pedal_friction_release_frames = 0
|
||||
self.bolt_acc_pedal_friction_low_speed_active = False
|
||||
self.truck_friction_brake_active = False
|
||||
|
||||
def _reset_volt_one_pedal(self):
|
||||
self.volt_one_pedal_pid.reset()
|
||||
@@ -968,13 +993,14 @@ class CarController(CarControllerBase):
|
||||
if testing_ground.use_1:
|
||||
accel_max = min(accel_max, np.interp(CS.out.vEgo, [0.0, 4.0, 12.0], [1.25, 1.6, self.params.ACCEL_MAX]))
|
||||
|
||||
accel_input = actuators.accel + accel_due_to_pitch
|
||||
if (
|
||||
truck_long_smoothing = (
|
||||
getattr(starpilot_toggles, "truck_tuning", False) and
|
||||
self.CP.carFingerprint in TRUCK_LONG_SMOOTH_CARS and
|
||||
getattr(self.CP, "transmissionType", None) == TransmissionType.automatic and
|
||||
not self.CP.enableGasInterceptorDEPRECATED
|
||||
):
|
||||
)
|
||||
accel_input = actuators.accel + accel_due_to_pitch
|
||||
if truck_long_smoothing:
|
||||
accel_input = shape_truck_positive_accel(
|
||||
accel_input,
|
||||
CS.out.vEgo,
|
||||
@@ -999,6 +1025,12 @@ class CarController(CarControllerBase):
|
||||
brake_accel = min((scaled_torque - brake_switch) / (self.tireRadius * self.mass), 0)
|
||||
self.apply_gas = int(round(apply_gas_torque))
|
||||
self.apply_brake = int(round(np.interp(brake_accel, self.params.BRAKE_LOOKUP_BP, self.params.BRAKE_LOOKUP_V)))
|
||||
if truck_long_smoothing:
|
||||
self.apply_brake, self.truck_friction_brake_active = shape_truck_friction_brake(
|
||||
self.apply_brake, accel_cmd, stopping, self.truck_friction_brake_active,
|
||||
)
|
||||
else:
|
||||
self.truck_friction_brake_active = False
|
||||
if bolt_acc_pedal_friction_main_on:
|
||||
if self.apply_brake > 0:
|
||||
full_brake_accel = min(
|
||||
|
||||
@@ -54,6 +54,7 @@ from opendbc.car.gm.carcontroller import (
|
||||
get_acc_dashboard_status_active,
|
||||
get_stock_cc_active_for_cancel,
|
||||
shape_bolt_acc_pedal_low_speed_friction,
|
||||
shape_truck_friction_brake,
|
||||
shape_truck_positive_accel,
|
||||
should_use_fixed_stopping_brake,
|
||||
should_activate_auto_hold,
|
||||
@@ -827,7 +828,7 @@ def test_calc_pedal_command_keeps_strong_positive_requests_responsive():
|
||||
def test_shape_truck_positive_accel_softens_small_highway_requests():
|
||||
shaped = shape_truck_positive_accel(0.12, 26.0, True)
|
||||
|
||||
assert 0.09 < shaped < 0.10
|
||||
assert 0.08 < shaped < 0.095
|
||||
|
||||
|
||||
def test_shape_truck_positive_accel_keeps_mid_follow_requests_available():
|
||||
@@ -860,6 +861,21 @@ def test_shape_truck_positive_accel_does_not_relax_without_speed_error():
|
||||
assert no_error == base
|
||||
|
||||
|
||||
def test_shape_truck_friction_brake_suppresses_boundary_chatter():
|
||||
assert shape_truck_friction_brake(14, -0.3, False, False) == (0, False)
|
||||
|
||||
|
||||
def test_shape_truck_friction_brake_uses_hysteresis_once_engaged():
|
||||
assert shape_truck_friction_brake(25, -0.3, False, False) == (25, True)
|
||||
assert shape_truck_friction_brake(14, -0.3, False, True) == (14, True)
|
||||
assert shape_truck_friction_brake(8, -0.3, False, True) == (0, False)
|
||||
|
||||
|
||||
def test_shape_truck_friction_brake_never_delays_meaningful_braking():
|
||||
assert shape_truck_friction_brake(5, -0.65, False, False) == (5, True)
|
||||
assert shape_truck_friction_brake(5, -0.2, True, False) == (5, True)
|
||||
|
||||
|
||||
def test_use_interceptor_sng_launch_requires_actual_near_stop():
|
||||
CP = SimpleNamespace(vEgoStarting=0.25)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
|
||||
@@ -16,7 +17,7 @@ import starpilot.system.speed_limit_vision as slv
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import ensure_dir, preferred_clip_root, resolve_workspace # type: ignore
|
||||
from common import ensure_dir, preferred_clip_root, resolve_workspace # type: ignore # noqa: TID251
|
||||
from localize_bookmark_signs import configure_models # type: ignore
|
||||
from mine_route_training_samples import ( # type: ignore
|
||||
MapContext,
|
||||
@@ -25,6 +26,7 @@ if __package__ in (None, ""):
|
||||
iter_frames_at_times,
|
||||
load_segment_map_context,
|
||||
nearest_context,
|
||||
model_bundle_fingerprint,
|
||||
parse_route_id,
|
||||
read_frame_at,
|
||||
route_segments,
|
||||
@@ -42,6 +44,7 @@ else:
|
||||
iter_frames_at_times,
|
||||
load_segment_map_context,
|
||||
nearest_context,
|
||||
model_bundle_fingerprint,
|
||||
parse_route_id,
|
||||
read_frame_at,
|
||||
route_segments,
|
||||
@@ -57,6 +60,8 @@ DEFAULT_OUTPUT_NAME = "manual_review_queue_v1"
|
||||
PRIORITY_SPEED_VALUES = frozenset((30, 35, 40, 45, 50, 55, 60, 65))
|
||||
FIELDNAMES = [
|
||||
"record_key",
|
||||
"mining_fingerprint",
|
||||
"model_fingerprint",
|
||||
"route",
|
||||
"dongle_id",
|
||||
"log_id",
|
||||
@@ -112,14 +117,15 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--clip-root", type=Path, default=preferred_clip_root(), help="Route realdata root.")
|
||||
parser.add_argument("--bundle-state-dir", type=Path, default=DEFAULT_ROUTE_BUNDLE_STATE_DIR, help="Completed extraction marker directory.")
|
||||
parser.add_argument("--models-dir", type=Path, help="Optional model directory for mining with non-repo ONNXs.")
|
||||
parser.add_argument("--model-only", action="store_true", help="Do not run crop OCR while discovering review candidates.")
|
||||
parser.add_argument("--output-dir", type=Path, help=f"Defaults to <workspace>/review/{DEFAULT_OUTPUT_NAME}.")
|
||||
parser.add_argument("--manifest-out", type=Path, help="Defaults to <output-dir>/manual_review_queue.csv.")
|
||||
parser.add_argument("--sample-every", type=float, default=2.0, help="Seconds between regular video samples.")
|
||||
parser.add_argument("--seek-sampling", action="store_true", help="Seek directly to sampled frames instead of sequential grabbing.")
|
||||
parser.add_argument("--transition-radius", type=float, default=18.0, help="Extra seconds around map speed transitions to sample densely.")
|
||||
parser.add_argument("--transition-step", type=float, default=0.75, help="Seconds between transition-window samples.")
|
||||
parser.add_argument("--max-frames-per-route", type=int, default=1200, help="Maximum frames to score per route.")
|
||||
parser.add_argument("--max-candidates-per-route", type=int, default=500, help="Maximum review candidates to keep per route.")
|
||||
parser.add_argument("--max-frames-per-route", type=int, default=1200, help="Maximum frames to score per route. 0 scans the full route.")
|
||||
parser.add_argument("--max-candidates-per-route", type=int, default=500, help="Maximum review candidates to keep per route. 0 keeps all.")
|
||||
parser.add_argument("--max-candidates-per-frame", type=int, default=1, help="Maximum detector candidates to keep from a single video frame. 0 keeps all.")
|
||||
parser.add_argument("--max-negatives-per-route", type=int, default=60, help="Maximum empty/no-candidate frames to keep per route.")
|
||||
parser.add_argument("--min-proposal-confidence", type=float, default=0.025, help="Loose detector confidence floor for review candidates.")
|
||||
@@ -132,6 +138,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--include-advisory", action=argparse.BooleanOptionalAction, default=True, help="Include advisory-speed detector class candidates.")
|
||||
parser.add_argument("--include-full-detection", action="store_true", help="Also run the full runtime detector on each frame for extra context. Slower.")
|
||||
parser.add_argument("--overwrite-images", action="store_true", help="Rewrite existing review images.")
|
||||
parser.add_argument("--resume", action=argparse.BooleanOptionalAction, default=True, help="Resume a matching fingerprinted queue.")
|
||||
parser.add_argument("--dry-run", action="store_true", help="Score frames and print counts without writing images/CSV.")
|
||||
return parser.parse_args()
|
||||
|
||||
@@ -160,6 +167,33 @@ def read_routes(args: argparse.Namespace) -> list[str]:
|
||||
return deduped
|
||||
|
||||
|
||||
def review_mining_fingerprint(args: argparse.Namespace, model_fingerprint: str) -> str:
|
||||
config = {
|
||||
"schema_version": 1,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"model_only": args.model_only,
|
||||
"sample_every": args.sample_every,
|
||||
"transition_radius": args.transition_radius,
|
||||
"transition_step": args.transition_step,
|
||||
"max_frames_per_route": args.max_frames_per_route,
|
||||
"max_candidates_per_route": args.max_candidates_per_route,
|
||||
"max_candidates_per_frame": args.max_candidates_per_frame,
|
||||
"max_negatives_per_route": args.max_negatives_per_route,
|
||||
"min_proposal_confidence": args.min_proposal_confidence,
|
||||
"no_read_min_proposal_confidence": args.no_read_min_proposal_confidence,
|
||||
"school_zone_min_proposal_confidence": args.school_zone_min_proposal_confidence,
|
||||
"min_width": args.min_width,
|
||||
"min_height": args.min_height,
|
||||
"dedupe_seconds": args.dedupe_seconds,
|
||||
"include_advisory": args.include_advisory,
|
||||
"include_full_detection": args.include_full_detection,
|
||||
}
|
||||
digest = hashlib.sha256(json.dumps(config, sort_keys=True).encode("utf-8"))
|
||||
for source_path in (Path(__file__), Path(slv.__file__)):
|
||||
digest.update(source_path.resolve().read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def clamp_box(box: tuple[int, int, int, int], frame_shape: tuple[int, int, int]) -> tuple[int, int, int, int] | None:
|
||||
frame_height, frame_width = frame_shape[:2]
|
||||
x1, y1, x2, y2 = box
|
||||
@@ -245,7 +279,14 @@ def classify_map_relation(speed_limit_mph: int, context: MapContext, next_limit_
|
||||
return "no_map"
|
||||
|
||||
|
||||
def score_review_priority(class_id: int, proposal_confidence: float, chosen_vote: ReadVote | None, support_count: int, map_relation: str, reasons: set[str]) -> float:
|
||||
def score_review_priority(
|
||||
class_id: int,
|
||||
proposal_confidence: float,
|
||||
chosen_vote: ReadVote | None,
|
||||
support_count: int,
|
||||
map_relation: str,
|
||||
reasons: set[str],
|
||||
) -> float:
|
||||
score = proposal_confidence * 2.0
|
||||
if chosen_vote is not None:
|
||||
score += chosen_vote.confidence * 2.0
|
||||
@@ -276,7 +317,14 @@ def summarize_votes(votes: list[ReadVote]) -> str:
|
||||
return "|".join(compact)
|
||||
|
||||
|
||||
def analyze_proposal(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, proposal, full_detection, context: MapContext, args: argparse.Namespace):
|
||||
def analyze_proposal(
|
||||
daemon: slv.SpeedLimitVisionDaemon,
|
||||
frame_bgr,
|
||||
proposal,
|
||||
full_detection,
|
||||
context: MapContext,
|
||||
args: argparse.Namespace,
|
||||
):
|
||||
proposal_confidence, class_id, raw_box = proposal
|
||||
if class_id == 1 and not args.include_advisory:
|
||||
return None
|
||||
@@ -306,7 +354,8 @@ def analyze_proposal(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, proposal, fu
|
||||
is_regulatory = daemon._is_regulatory_speed_sign(crop) or class_id == 2
|
||||
any_regulatory = any_regulatory or is_regulatory
|
||||
add_vote(votes, daemon._classify_speed_limit_from_model(crop), "model", expansion_index, crop_box, is_regulatory, weight)
|
||||
add_vote(votes, daemon._read_speed_limit_from_crop(crop), "ocr", expansion_index, crop_box, is_regulatory, weight)
|
||||
if not args.model_only:
|
||||
add_vote(votes, daemon._read_speed_limit_from_crop(crop), "ocr", expansion_index, crop_box, is_regulatory, weight)
|
||||
|
||||
chosen_vote, support_count = choose_vote(votes)
|
||||
if chosen_vote is None and proposal_confidence < args.no_read_min_proposal_confidence and class_id != 2:
|
||||
@@ -368,15 +417,10 @@ def write_image(path: Path, image, quality: int, overwrite: bool) -> None:
|
||||
cv2.imwrite(str(path), image, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||
|
||||
|
||||
def cluster_key(route_id: str, segment: int, time_s: float, frame_shape: tuple[int, int, int], candidate: dict, dedupe_seconds: float) -> str:
|
||||
x1, y1, x2, y2 = candidate["bbox"]
|
||||
frame_height, frame_width = frame_shape[:2]
|
||||
center_x = ((x1 + x2) / 2) / max(frame_width, 1)
|
||||
center_y = ((y1 + y2) / 2) / max(frame_height, 1)
|
||||
def cluster_key(route_id: str, segment: int, time_s: float, candidate: dict, dedupe_seconds: float) -> str:
|
||||
time_bucket = int(math.floor(time_s / max(dedupe_seconds, 0.1)))
|
||||
grid_x = int(center_x * 12)
|
||||
grid_y = int(center_y * 8)
|
||||
return f"{route_id}|{segment}|{time_bucket}|{candidate['class_id']}|{grid_x}|{grid_y}"
|
||||
candidate_speed = candidate.get("candidate_speed_limit_mph") or "none"
|
||||
return f"{route_id}|{segment}|{time_bucket}|{candidate['class_id']}|{candidate_speed}"
|
||||
|
||||
|
||||
def candidate_record_key(route_key: str, segment: int, time_s: float, index: int) -> str:
|
||||
@@ -384,7 +428,14 @@ def candidate_record_key(route_key: str, segment: int, time_s: float, index: int
|
||||
return f"manual_review_{route_key}_{sample_index}"
|
||||
|
||||
|
||||
def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse.Namespace, output_dir: Path) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
def mine_route(
|
||||
route_id: str,
|
||||
daemon: slv.SpeedLimitVisionDaemon,
|
||||
args: argparse.Namespace,
|
||||
output_dir: Path,
|
||||
mining_fingerprint: str,
|
||||
model_fingerprint: str,
|
||||
) -> tuple[list[dict[str, object]], dict[str, object]]:
|
||||
route_id, dongle_id, log_id = parse_route_id(route_id)
|
||||
route_key = safe_key(route_id)
|
||||
clip_root = args.clip_root.expanduser().resolve()
|
||||
@@ -400,7 +451,10 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
frames_scored = 0
|
||||
|
||||
for segment in segments:
|
||||
if frames_scored >= args.max_frames_per_route or route_candidates >= args.max_candidates_per_route:
|
||||
if (
|
||||
(args.max_frames_per_route > 0 and frames_scored >= args.max_frames_per_route) or
|
||||
(args.max_candidates_per_route > 0 and route_candidates >= args.max_candidates_per_route)
|
||||
):
|
||||
break
|
||||
contexts = load_segment_map_context(segment.path)
|
||||
capture = cv2.VideoCapture(str(segment.video_path))
|
||||
@@ -414,7 +468,10 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
frame_iter = iter_frames_at_times(capture, fps, times)
|
||||
|
||||
for time_s, frame_bgr in frame_iter:
|
||||
if frames_scored >= args.max_frames_per_route or route_candidates >= args.max_candidates_per_route:
|
||||
if (
|
||||
(args.max_frames_per_route > 0 and frames_scored >= args.max_frames_per_route) or
|
||||
(args.max_candidates_per_route > 0 and route_candidates >= args.max_candidates_per_route)
|
||||
):
|
||||
break
|
||||
if frame_bgr is None:
|
||||
continue
|
||||
@@ -444,6 +501,8 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
crop_path = crop_dir / f"{record_key}_crop.jpg"
|
||||
row = {
|
||||
"record_key": record_key,
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"route": route_id,
|
||||
"dongle_id": dongle_id,
|
||||
"log_id": log_id,
|
||||
@@ -478,7 +537,7 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
"review_ignore_reason": "",
|
||||
"review_notes": "",
|
||||
}
|
||||
key = cluster_key(route_id, segment.segment, time_s, frame_bgr.shape, candidate, args.dedupe_seconds)
|
||||
key = cluster_key(route_id, segment.segment, time_s, candidate, args.dedupe_seconds)
|
||||
existing = rows_by_cluster.get(key)
|
||||
if existing is None or float(row["review_priority"]) > float(existing["review_priority"]):
|
||||
if not args.dry_run:
|
||||
@@ -494,6 +553,8 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
frame_path = frame_dir / f"{record_key}.jpg"
|
||||
row = {
|
||||
"record_key": record_key,
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"route": route_id,
|
||||
"dongle_id": dongle_id,
|
||||
"log_id": log_id,
|
||||
@@ -557,8 +618,17 @@ def write_manifest(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
writer.writerows(rows)
|
||||
|
||||
|
||||
def write_summary(path: Path, manifest_path: Path, rows: list[dict[str, object]], summaries: list[dict[str, object]]) -> None:
|
||||
def write_summary(
|
||||
path: Path,
|
||||
manifest_path: Path,
|
||||
rows: list[dict[str, object]],
|
||||
summaries: list[dict[str, object]],
|
||||
mining_fingerprint: str,
|
||||
model_fingerprint: str,
|
||||
) -> None:
|
||||
path.write_text(json.dumps({
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"routes": summaries,
|
||||
"manifest": str(manifest_path),
|
||||
"rows": len(rows),
|
||||
@@ -575,6 +645,9 @@ def main() -> int:
|
||||
|
||||
args = parse_args()
|
||||
configure_models(args.models_dir)
|
||||
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = not args.model_only
|
||||
model_fingerprint = model_bundle_fingerprint()
|
||||
mining_fingerprint = review_mining_fingerprint(args, model_fingerprint)
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
output_dir = args.output_dir.expanduser().resolve() if args.output_dir else ensure_dir(workspace / "review" / DEFAULT_OUTPUT_NAME)
|
||||
manifest_path = args.manifest_out.expanduser().resolve() if args.manifest_out else output_dir / "manual_review_queue.csv"
|
||||
@@ -586,25 +659,43 @@ def main() -> int:
|
||||
all_rows: list[dict[str, object]] = []
|
||||
summaries = []
|
||||
summary_path = output_dir / "manual_review_summary.json"
|
||||
completed_routes: set[str] = set()
|
||||
if args.resume and summary_path.is_file():
|
||||
prior_summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
if prior_summary.get("mining_fingerprint") != mining_fingerprint:
|
||||
raise RuntimeError("Existing review queue fingerprint does not match this run. Use a new --output-dir or --no-resume.")
|
||||
if manifest_path.is_file():
|
||||
with manifest_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
all_rows = list(csv.DictReader(handle))
|
||||
summaries = list(prior_summary.get("routes", []))
|
||||
completed_routes = {str(summary["route"]) for summary in summaries if summary.get("status") in ("mined", "missing_segments")}
|
||||
elif args.resume and (manifest_path.exists() or summary_path.exists()):
|
||||
raise RuntimeError("Existing review queue is missing fingerprinted resume state. Use a new --output-dir or --no-resume.")
|
||||
|
||||
for index, route_id in enumerate(routes, start=1):
|
||||
rows, summary = mine_route(route_id, daemon, args, output_dir)
|
||||
normalized_route, _, _ = parse_route_id(route_id)
|
||||
if normalized_route in completed_routes:
|
||||
print(f"[{index}/{len(routes)}] {normalized_route}: skipped (already mined)")
|
||||
continue
|
||||
rows, summary = mine_route(route_id, daemon, args, output_dir, mining_fingerprint, model_fingerprint)
|
||||
all_rows.extend(rows)
|
||||
summaries.append(summary)
|
||||
print(
|
||||
f"[{index}/{len(routes)}] {summary['route']}: {summary['status']} "
|
||||
f"frames={summary['frames']} candidates={summary['candidates']} negatives={summary['negatives']}"
|
||||
)
|
||||
progress = f"[{index}/{len(routes)}] {summary['route']}: {summary['status']}"
|
||||
counts = f"frames={summary['frames']} candidates={summary['candidates']} negatives={summary['negatives']}"
|
||||
print(f"{progress} {counts}")
|
||||
if not args.dry_run:
|
||||
all_rows.sort(key=lambda row: (-float(row["review_priority"]), str(row["record_key"])))
|
||||
write_manifest(manifest_path, all_rows)
|
||||
write_summary(summary_path, manifest_path, all_rows, summaries)
|
||||
write_summary(summary_path, manifest_path, all_rows, summaries, mining_fingerprint, model_fingerprint)
|
||||
|
||||
all_rows.sort(key=lambda row: (-float(row["review_priority"]), str(row["record_key"])))
|
||||
if not args.dry_run:
|
||||
write_manifest(manifest_path, all_rows)
|
||||
write_summary(summary_path, manifest_path, all_rows, summaries)
|
||||
write_summary(summary_path, manifest_path, all_rows, summaries, mining_fingerprint, model_fingerprint)
|
||||
print(f"Wrote {len(all_rows)} review rows to {manifest_path}")
|
||||
print(f"Summary: {summary_path}")
|
||||
print(f"Model fingerprint: {model_fingerprint}")
|
||||
print(f"Mining fingerprint: {mining_fingerprint}")
|
||||
else:
|
||||
print(f"Dry run rows={len(all_rows)} candidates={sum(1 for row in all_rows if row['detector_class'] != 'negative_empty')}")
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
VALID_SPEEDS = frozenset((15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75))
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Build an isolated classifier dataset from a base corpus and reviewed crops.")
|
||||
parser.add_argument("--base", type=Path, required=True, help="Existing Ultralytics classification dataset root.")
|
||||
parser.add_argument("--output", type=Path, required=True, help="New isolated dataset root.")
|
||||
parser.add_argument("--positive-manifest", type=Path, action="append", default=[], help="Reviewed positive crop manifest. Repeat as needed.")
|
||||
parser.add_argument("--reject-manifest", type=Path, action="append", default=[], help="Reviewed classifier reject manifest. Repeat as needed.")
|
||||
parser.add_argument(
|
||||
"--advisory-as-reject",
|
||||
action="store_true",
|
||||
help="Stage reviewed advisory-speed crops in the reject class instead of omitting them.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-advisory-positives",
|
||||
action="store_true",
|
||||
help="Train reviewed advisory crops as their numeric speed classes for recall-first models.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--advisory-reject-fraction",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Deterministic fraction of training advisories staged as reject; validation advisories are always retained.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_rows(paths: list[Path]):
|
||||
for path in paths:
|
||||
resolved = path.expanduser().resolve()
|
||||
with resolved.open("r", encoding="utf-8", newline="") as handle:
|
||||
yield from csv.DictReader(handle)
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> int:
|
||||
removed = 0
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def parse_speed(text: str) -> int:
|
||||
try:
|
||||
value = int(float((text or "").strip()))
|
||||
except ValueError:
|
||||
return 0
|
||||
return value if value in VALID_SPEEDS else 0
|
||||
|
||||
|
||||
def is_advisory(row: dict[str, str]) -> bool:
|
||||
return row.get("review_sign_type", "").strip().lower() == "advisory"
|
||||
|
||||
|
||||
def keep_advisory_reject(row: dict[str, str], fraction: float) -> bool:
|
||||
if row.get("split") == "val" or fraction >= 1.0:
|
||||
return True
|
||||
if fraction <= 0.0:
|
||||
return False
|
||||
digest = hashlib.sha256(row.get("record_key", "").encode("utf-8")).digest()
|
||||
return int.from_bytes(digest[:8], "big") / 2**64 < fraction
|
||||
|
||||
|
||||
def stage_crop(source: Path, destination_dir: Path, record_key: str) -> bool:
|
||||
if not source.is_file():
|
||||
return False
|
||||
digest = hashlib.sha256(source.read_bytes()).hexdigest()[:16]
|
||||
suffix = source.suffix.lower() if source.suffix.lower() in (".jpg", ".jpeg", ".png") else ".jpg"
|
||||
safe_key = "".join(char if char.isalnum() or char in "._-" else "_" for char in record_key)[:100]
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = destination_dir / f"review_{safe_key}_{digest}{suffix}"
|
||||
if not destination.exists():
|
||||
shutil.copyfile(source, destination)
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.advisory_as_reject and args.include_advisory_positives:
|
||||
raise ValueError("--advisory-as-reject and --include-advisory-positives are mutually exclusive")
|
||||
if not 0.0 <= args.advisory_reject_fraction <= 1.0:
|
||||
raise ValueError("--advisory-reject-fraction must be between 0 and 1")
|
||||
base = args.base.expanduser().resolve()
|
||||
output = args.output.expanduser().resolve()
|
||||
if not base.is_dir():
|
||||
raise FileNotFoundError(base)
|
||||
if output.exists():
|
||||
raise FileExistsError(f"Output dataset already exists: {output}")
|
||||
shutil.copytree(base, output, copy_function=shutil.copyfile)
|
||||
appledouble_removed = remove_appledouble_files(output)
|
||||
|
||||
positive_counts: Counter[str] = Counter()
|
||||
reject_counts: Counter[str] = Counter()
|
||||
skipped = 0
|
||||
for row in read_rows(args.positive_manifest):
|
||||
if is_advisory(row):
|
||||
if args.include_advisory_positives:
|
||||
pass
|
||||
elif args.advisory_as_reject and keep_advisory_reject(row, args.advisory_reject_fraction):
|
||||
split = row.get("split", "")
|
||||
source = Path(row.get("crop_path", "")).expanduser()
|
||||
if split in ("train", "val") and stage_crop(source, output / split / "reject", row.get("record_key", "advisory")):
|
||||
reject_counts[f"advisory_{split}"] += 1
|
||||
else:
|
||||
skipped += 1
|
||||
continue
|
||||
else:
|
||||
continue
|
||||
split = row.get("split", "")
|
||||
speed = parse_speed(row.get("speed_limit_mph", ""))
|
||||
source = Path(row.get("crop_path", "")).expanduser()
|
||||
if split not in ("train", "val") or not speed or not stage_crop(source, output / split / str(speed), row.get("record_key", "positive")):
|
||||
skipped += 1
|
||||
continue
|
||||
positive_counts[f"{split}/{speed}"] += 1
|
||||
|
||||
for row in read_rows(args.reject_manifest):
|
||||
split = row.get("split", "")
|
||||
source = Path(row.get("crop_path", "")).expanduser()
|
||||
if split not in ("train", "val") or not stage_crop(source, output / split / "reject", row.get("record_key", "reject")):
|
||||
skipped += 1
|
||||
continue
|
||||
reject_counts[split] += 1
|
||||
|
||||
appledouble_removed += remove_appledouble_files(output)
|
||||
for split in ("train", "val"):
|
||||
cache_path = output / f"{split}.cache"
|
||||
if cache_path.is_file():
|
||||
cache_path.unlink()
|
||||
|
||||
summary = {
|
||||
"base": str(base),
|
||||
"output": str(output),
|
||||
"positive_counts": dict(sorted(positive_counts.items())),
|
||||
"reject_counts": dict(sorted(reject_counts.items())),
|
||||
"skipped": skipped,
|
||||
"appledouble_removed": appledouble_removed,
|
||||
}
|
||||
summary_path = output / "review_dataset_summary.json"
|
||||
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Compare two fingerprinted manual-review queues by stable record key.")
|
||||
parser.add_argument("--before", type=Path, required=True, help="Baseline manual_review_queue.csv.")
|
||||
parser.add_argument("--after", type=Path, required=True, help="Candidate manual_review_queue.csv.")
|
||||
parser.add_argument("--output-csv", type=Path, required=True, help="Changed-row output CSV.")
|
||||
parser.add_argument("--review-output", type=Path, help="Optional review-compatible manifest containing the changed source rows.")
|
||||
parser.add_argument("--confidence-delta", type=float, default=0.05, help="Minimum confidence-only change to report.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_rows(path: Path) -> dict[str, dict[str, str]]:
|
||||
with path.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
|
||||
return {row["record_key"]: row for row in csv.DictReader(handle) if row.get("record_key")}
|
||||
|
||||
|
||||
def parse_float(text: str) -> float:
|
||||
try:
|
||||
return float(text)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def classify_change(before: dict[str, str] | None, after: dict[str, str] | None, confidence_delta: float) -> str:
|
||||
if before is None:
|
||||
return "added_proposal"
|
||||
if after is None:
|
||||
return "removed_proposal"
|
||||
|
||||
before_speed = before.get("candidate_speed_limit_mph", "")
|
||||
after_speed = after.get("candidate_speed_limit_mph", "")
|
||||
if not before_speed and after_speed:
|
||||
return "gained_read"
|
||||
if before_speed and not after_speed:
|
||||
return "lost_read"
|
||||
if before_speed != after_speed:
|
||||
return "value_changed"
|
||||
|
||||
confidence_change = abs(
|
||||
parse_float(after.get("candidate_confidence", "")) - parse_float(before.get("candidate_confidence", ""))
|
||||
)
|
||||
if confidence_change >= confidence_delta:
|
||||
return "confidence_changed"
|
||||
return ""
|
||||
|
||||
|
||||
def comparison_row(record_key: str, change: str, before: dict[str, str] | None, after: dict[str, str] | None) -> dict[str, str]:
|
||||
source = after or before or {}
|
||||
return {
|
||||
"record_key": record_key,
|
||||
"change": change,
|
||||
"route": source.get("route", ""),
|
||||
"segment": source.get("segment", ""),
|
||||
"frame_time_s": source.get("frame_time_s", ""),
|
||||
"detector_class": source.get("detector_class", ""),
|
||||
"proposal_confidence": source.get("proposal_confidence", ""),
|
||||
"before_speed_limit_mph": (before or {}).get("candidate_speed_limit_mph", ""),
|
||||
"before_confidence": (before or {}).get("candidate_confidence", ""),
|
||||
"after_speed_limit_mph": (after or {}).get("candidate_speed_limit_mph", ""),
|
||||
"after_confidence": (after or {}).get("candidate_confidence", ""),
|
||||
"before_support": (before or {}).get("read_support_count", ""),
|
||||
"after_support": (after or {}).get("read_support_count", ""),
|
||||
"frame_path": source.get("frame_path", ""),
|
||||
"crop_path": source.get("crop_path", ""),
|
||||
"source_video_path": source.get("source_video_path", ""),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
before = read_rows(args.before)
|
||||
after = read_rows(args.after)
|
||||
rows: list[dict[str, str]] = []
|
||||
review_rows: list[dict[str, str]] = []
|
||||
change_counts: Counter[str] = Counter()
|
||||
transition_counts: Counter[str] = Counter()
|
||||
|
||||
for record_key in sorted(before.keys() | after.keys()):
|
||||
before_row = before.get(record_key)
|
||||
after_row = after.get(record_key)
|
||||
change = classify_change(before_row, after_row, args.confidence_delta)
|
||||
if not change:
|
||||
continue
|
||||
row = comparison_row(record_key, change, before_row, after_row)
|
||||
rows.append(row)
|
||||
source_row = dict(after_row or before_row or {})
|
||||
source_row.update({
|
||||
"comparison_change": change,
|
||||
"before_speed_limit_mph": row["before_speed_limit_mph"],
|
||||
"before_confidence": row["before_confidence"],
|
||||
})
|
||||
review_rows.append(source_row)
|
||||
change_counts[change] += 1
|
||||
before_speed = row["before_speed_limit_mph"] or "none"
|
||||
after_speed = row["after_speed_limit_mph"] or "none"
|
||||
transition_counts[f"{before_speed}->{after_speed}"] += 1
|
||||
|
||||
output_path = args.output_csv.expanduser().resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = list(rows[0]) if rows else list(comparison_row("", "", None, None))
|
||||
with output_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
if args.review_output:
|
||||
review_output = args.review_output.expanduser().resolve()
|
||||
review_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
review_fieldnames = list(review_rows[0]) if review_rows else []
|
||||
with review_output.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=review_fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(review_rows)
|
||||
|
||||
summary = {
|
||||
"before": str(args.before.expanduser().resolve()),
|
||||
"after": str(args.after.expanduser().resolve()),
|
||||
"before_rows": len(before),
|
||||
"after_rows": len(after),
|
||||
"changed_rows": len(rows),
|
||||
"review_output": str(args.review_output.expanduser().resolve()) if args.review_output else "",
|
||||
"changes": dict(sorted(change_counts.items())),
|
||||
"transitions": dict(sorted(transition_counts.items(), key=lambda item: (-item[1], item[0]))),
|
||||
}
|
||||
output_path.with_suffix(".json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Evaluate the integrated value/reject classifier on reviewed crops.")
|
||||
parser.add_argument("--models-dir", type=Path, default=Path("starpilot/assets/vision_models"))
|
||||
parser.add_argument("--positive-manifest", type=Path, required=True)
|
||||
parser.add_argument("--reject-manifest", type=Path, required=True)
|
||||
parser.add_argument("--split", choices=("train", "val"), help="Optional source split filter.")
|
||||
parser.add_argument("--output-csv", type=Path)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_rows(path: Path) -> list[dict[str, str]]:
|
||||
with path.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
|
||||
return list(csv.DictReader(handle))
|
||||
|
||||
|
||||
def parse_speed(text: str) -> int | None:
|
||||
try:
|
||||
return int(float((text or "").strip()))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
models_dir = args.models_dir.expanduser().resolve()
|
||||
slv.US_DETECTOR_MODEL_PATH = models_dir / "speed_limit_us_detector.onnx"
|
||||
slv.US_CLASSIFIER_MODEL_PATH = models_dir / "speed_limit_us_value_classifier.onnx"
|
||||
reject_path = models_dir / "speed_limit_us_reject_classifier.onnx"
|
||||
if reject_path.is_file():
|
||||
slv.US_REJECT_CLASSIFIER_MODEL_PATH = reject_path
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
|
||||
cases: list[tuple[str, dict[str, str], int | None]] = []
|
||||
for row in read_rows(args.positive_manifest):
|
||||
if args.split and row.get("split") != args.split:
|
||||
continue
|
||||
kind = "advisory" if row.get("review_sign_type", "").strip().lower() == "advisory" else "regulatory"
|
||||
cases.append((kind, row, parse_speed(row.get("speed_limit_mph", "")) if kind == "regulatory" else None))
|
||||
for row in read_rows(args.reject_manifest):
|
||||
if not args.split or row.get("split") == args.split:
|
||||
cases.append(("hard_negative", row, None))
|
||||
|
||||
counts: Counter[str] = Counter()
|
||||
output_rows = []
|
||||
for kind, row, expected in cases:
|
||||
crop_path = Path(row.get("crop_path", "")).expanduser()
|
||||
crop = cv2.imread(str(crop_path))
|
||||
if crop is None:
|
||||
counts[f"{kind}_unreadable"] += 1
|
||||
continue
|
||||
result = daemon._classify_speed_limit_from_model(crop)
|
||||
predicted = result[0] if result is not None else None
|
||||
confidence = result[1] if result is not None else None
|
||||
counts[f"{kind}_total"] += 1
|
||||
if kind == "regulatory":
|
||||
counts[f"regulatory_speed_{expected}_total"] += 1
|
||||
if predicted is not None:
|
||||
counts["regulatory_any"] += 1
|
||||
if predicted == expected:
|
||||
counts["regulatory_exact"] += 1
|
||||
counts[f"regulatory_speed_{expected}_exact"] += 1
|
||||
elif predicted is not None:
|
||||
counts["regulatory_wrong"] += 1
|
||||
elif predicted is None:
|
||||
counts[f"{kind}_rejected"] += 1
|
||||
else:
|
||||
counts[f"{kind}_false_read"] += 1
|
||||
output_rows.append({
|
||||
"record_key": row.get("record_key", ""),
|
||||
"split": row.get("split", ""),
|
||||
"kind": kind,
|
||||
"crop_path": str(crop_path),
|
||||
"expected_speed_limit_mph": "" if expected is None else expected,
|
||||
"predicted_speed_limit_mph": "" if predicted is None else predicted,
|
||||
"confidence": "" if confidence is None else f"{confidence:.6f}",
|
||||
})
|
||||
|
||||
regulatory_total = counts["regulatory_total"]
|
||||
advisory_total = counts["advisory_total"]
|
||||
hard_negative_total = counts["hard_negative_total"]
|
||||
exact_rate = counts["regulatory_exact"] / regulatory_total if regulatory_total else 0.0
|
||||
advisory_reject_rate = counts["advisory_rejected"] / advisory_total if advisory_total else 0.0
|
||||
hard_negative_reject_rate = counts["hard_negative_rejected"] / hard_negative_total if hard_negative_total else 0.0
|
||||
regulatory_summary = f"Regulatory exact: {counts['regulatory_exact']}/{regulatory_total} ({exact_rate:.3f})"
|
||||
print(f"{regulatory_summary}; wrong reads: {counts['regulatory_wrong']}")
|
||||
print(f"Advisory rejected: {counts['advisory_rejected']}/{advisory_total} ({advisory_reject_rate:.3f})")
|
||||
hard_negative_summary = f"Hard negatives rejected: {counts['hard_negative_rejected']}/{hard_negative_total}"
|
||||
print(f"{hard_negative_summary} ({hard_negative_reject_rate:.3f})")
|
||||
speed_parts = []
|
||||
for speed in slv.US_CLASSIFIER_SPEED_VALUES:
|
||||
total = counts[f"regulatory_speed_{speed}_total"]
|
||||
if total:
|
||||
speed_parts.append(f"{speed}:{counts[f'regulatory_speed_{speed}_exact']}/{total}")
|
||||
print("Exact by speed: " + " ".join(speed_parts))
|
||||
|
||||
if args.output_csv:
|
||||
output = args.output_csv.expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=tuple(output_rows[0]) if output_rows else ("record_key",))
|
||||
writer.writeheader()
|
||||
writer.writerows(output_rows)
|
||||
print(f"Wrote {output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,267 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
|
||||
from collections import Counter, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from import_manual_review_queue import merged_review_rows, parse_speed # type: ignore
|
||||
from replay_route_runtime import RouteReplayDaemon, configure_models # type: ignore
|
||||
else:
|
||||
from .import_manual_review_queue import merged_review_rows, parse_speed
|
||||
from .replay_route_runtime import RouteReplayDaemon, configure_models
|
||||
|
||||
|
||||
POSITIVE_STATUSES = frozenset(("accepted", "corrected"))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReviewedCase:
|
||||
record_key: str
|
||||
route: str
|
||||
segment: int
|
||||
frame_time_s: float
|
||||
source_video_path: Path
|
||||
expected_speed_limit_mph: int
|
||||
negative: bool
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Evaluate reviewed sign events through realistic runtime cadence and publish logic.")
|
||||
parser.add_argument("--queue", type=Path, required=True, help="Reviewed manual_review_queue.csv.")
|
||||
parser.add_argument("--labels", type=Path, help="Defaults to manual_review_labels.csv beside the queue.")
|
||||
parser.add_argument("--models-dir", type=Path, default=Path("starpilot/assets/vision_models"), help="Candidate ONNX model directory.")
|
||||
parser.add_argument("--output-csv", type=Path, required=True, help="Per-event evaluation output.")
|
||||
parser.add_argument("--window-before", type=float, default=4.0, help="Seconds replayed before the reviewed frame.")
|
||||
parser.add_argument("--window-after", type=float, default=3.0, help="Seconds replayed after the reviewed frame.")
|
||||
parser.add_argument("--dedupe-seconds", type=float, default=3.0, help="Collapse nearby reviewed rows with the same expected value.")
|
||||
parser.add_argument("--measured-base-inference-seconds", type=float, default=0.44, help="Measured no-proposal comma inference cost.")
|
||||
parser.add_argument("--measured-classifier-forward-seconds", type=float, default=0.066, help="Measured comma cost per classifier forward.")
|
||||
parser.add_argument("--crop-ocr", action="store_true", help="Evaluate with crop OCR confirmation enabled.")
|
||||
parser.add_argument("--classifier-min-confidence", type=float, help="Override the value classifier confidence threshold.")
|
||||
parser.add_argument("--trusted-model-min-confidence", type=float, help="Override tiny-box trusted model confidence.")
|
||||
parser.add_argument("--strong-rescue-min-proposal-confidence", type=float, help="Override single-frame tiny-sign proposal confidence.")
|
||||
parser.add_argument("--strong-rescue-min-read-confidence", type=float, help="Override single-frame tiny-sign classifier confidence.")
|
||||
parser.add_argument("--low-speed-change-consistent-detections", type=int, help="Override reads required to change from 30+ mph to below 30 mph.")
|
||||
parser.add_argument(
|
||||
"--allow-low-speed-strong-consensus",
|
||||
action="store_true",
|
||||
help="Permit a strong multi-crop consensus to publish a low-speed change from one frame.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-strong-model-consensus",
|
||||
action="store_true",
|
||||
help="Mark three agreeing high-confidence regulatory model crops as strong consensus.",
|
||||
)
|
||||
parser.add_argument("--initial-speed-limit", type=int, default=0, help="Seed each replay window with a currently published speed limit.")
|
||||
parser.add_argument("--positive-only", action="store_true", help="Replay only reviewed speed signs, omitting ignored-crop windows.")
|
||||
parser.add_argument("--max-cases", type=int, default=0, help="Optional evaluation cap after deduplication.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_cases(queue_path: Path, labels_path: Path, dedupe_seconds: float) -> list[ReviewedCase]:
|
||||
rows = merged_review_rows(queue_path, labels_path)
|
||||
cases: list[ReviewedCase] = []
|
||||
seen_buckets: set[tuple[str, int, int, int, bool]] = set()
|
||||
for row in rows:
|
||||
status = row.get("review_status", "")
|
||||
positive = status in POSITIVE_STATUSES
|
||||
negative = status == "ignore" and row.get("review_sign_type") == "not_speed_limit"
|
||||
if not positive and not negative:
|
||||
continue
|
||||
speed = parse_speed(row.get("review_speed_limit_mph", "")) if positive else 0
|
||||
if positive and not speed:
|
||||
continue
|
||||
try:
|
||||
segment = int(row.get("segment", ""))
|
||||
frame_time_s = float(row.get("frame_time_s", ""))
|
||||
except ValueError:
|
||||
continue
|
||||
source_video = Path(row.get("source_video_path", "")).expanduser()
|
||||
if not source_video.is_file():
|
||||
continue
|
||||
bucket = int(frame_time_s / max(dedupe_seconds, 0.1))
|
||||
dedupe_key = (row.get("route", ""), segment, bucket, speed, negative)
|
||||
if dedupe_key in seen_buckets:
|
||||
continue
|
||||
seen_buckets.add(dedupe_key)
|
||||
cases.append(ReviewedCase(
|
||||
record_key=row.get("record_key", ""),
|
||||
route=row.get("route", ""),
|
||||
segment=segment,
|
||||
frame_time_s=frame_time_s,
|
||||
source_video_path=source_video.resolve(),
|
||||
expected_speed_limit_mph=speed,
|
||||
negative=negative,
|
||||
))
|
||||
return cases
|
||||
|
||||
|
||||
def replay_video_cases(cases: list[ReviewedCase], args: argparse.Namespace) -> dict[str, tuple[list[int], list[int], int]]:
|
||||
daemons = {
|
||||
case.record_key: RouteReplayDaemon(
|
||||
runtime_context=None,
|
||||
measured_inference_seconds=0.0,
|
||||
measured_base_inference_seconds=args.measured_base_inference_seconds,
|
||||
measured_classifier_forward_seconds=args.measured_classifier_forward_seconds,
|
||||
)
|
||||
for case in cases
|
||||
}
|
||||
for daemon in daemons.values():
|
||||
daemon.published_speed_limit_mph = args.initial_speed_limit
|
||||
daemon.last_published_support_at = 0.0
|
||||
capture = cv2.VideoCapture(str(cases[0].source_video_path))
|
||||
fps = capture.get(cv2.CAP_PROP_FPS) or 20.0
|
||||
frame_count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||
duration_s = frame_count / fps if frame_count > 0 else 60.0
|
||||
windows = {
|
||||
case.record_key: (
|
||||
max(case.frame_time_s - args.window_before, 0.0),
|
||||
min(case.frame_time_s + args.window_after, duration_s),
|
||||
)
|
||||
for case in cases
|
||||
}
|
||||
first_frame = max(int(min(window[0] for window in windows.values()) * fps), 0)
|
||||
end_frame = max(int(max(window[1] for window in windows.values()) * fps), first_frame)
|
||||
capture.set(cv2.CAP_PROP_POS_FRAMES, first_frame)
|
||||
frame_index = first_frame
|
||||
|
||||
while frame_index <= end_frame:
|
||||
ok, frame_bgr = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
frame_time_s = frame_index / fps
|
||||
for case in cases:
|
||||
start_s, end_s = windows[case.record_key]
|
||||
if start_s <= frame_time_s <= end_s:
|
||||
daemons[case.record_key].process_frame(frame_time_s - start_s, frame_bgr)
|
||||
frame_index += 1
|
||||
capture.release()
|
||||
|
||||
results = {}
|
||||
for case in cases:
|
||||
daemon = daemons[case.record_key]
|
||||
candidates = [int(event["candidateSpeedLimitMph"]) for event in daemon.events if event["event"] == "candidate"]
|
||||
publishes = [int(event["speedLimitMph"]) for event in daemon.events if event["event"] == "publish"]
|
||||
results[case.record_key] = candidates, publishes, daemon.inference_frames
|
||||
return results
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
queue_path = args.queue.expanduser().resolve()
|
||||
labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv")
|
||||
configure_models(args.models_dir)
|
||||
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr
|
||||
if args.classifier_min_confidence is not None:
|
||||
slv.US_CLASSIFIER_MIN_CONFIDENCE = args.classifier_min_confidence
|
||||
if args.trusted_model_min_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = args.trusted_model_min_confidence
|
||||
if args.strong_rescue_min_proposal_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = args.strong_rescue_min_proposal_confidence
|
||||
if args.strong_rescue_min_read_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE = args.strong_rescue_min_read_confidence
|
||||
if args.low_speed_change_consistent_detections is not None:
|
||||
slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = args.low_speed_change_consistent_detections
|
||||
if args.allow_low_speed_strong_consensus:
|
||||
slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS = True
|
||||
if args.enable_strong_model_consensus:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED = True
|
||||
cases = load_cases(queue_path, labels_path, args.dedupe_seconds)
|
||||
if args.positive_only:
|
||||
cases = [case for case in cases if not case.negative]
|
||||
if args.max_cases > 0:
|
||||
cases = cases[:args.max_cases]
|
||||
|
||||
output_rows: list[dict[str, object]] = []
|
||||
positive_by_speed: dict[int, Counter[str]] = defaultdict(Counter)
|
||||
negative_counts: Counter[str] = Counter()
|
||||
results: dict[str, tuple[list[int], list[int], int]] = {}
|
||||
cases_by_video: dict[Path, list[ReviewedCase]] = defaultdict(list)
|
||||
for case in cases:
|
||||
cases_by_video[case.source_video_path].append(case)
|
||||
for index, video_cases in enumerate(cases_by_video.values(), start=1):
|
||||
results.update(replay_video_cases(video_cases, args))
|
||||
if index % 10 == 0:
|
||||
print(f"Replayed {index}/{len(cases_by_video)} video segments", flush=True)
|
||||
|
||||
for case in cases:
|
||||
candidates, publishes, inference_frames = results.get(case.record_key, ([], [], 0))
|
||||
candidate_hit = case.expected_speed_limit_mph in candidates if not case.negative else False
|
||||
publish_hit = case.expected_speed_limit_mph in publishes if not case.negative else False
|
||||
false_candidate = bool(candidates) if case.negative else False
|
||||
false_publish = bool(publishes) if case.negative else False
|
||||
if case.negative:
|
||||
negative_counts.update(total=1, candidate_fp=int(false_candidate), publish_fp=int(false_publish))
|
||||
else:
|
||||
positive_by_speed[case.expected_speed_limit_mph].update(
|
||||
total=1,
|
||||
candidate_hit=int(candidate_hit),
|
||||
publish_hit=int(publish_hit),
|
||||
)
|
||||
output_rows.append({
|
||||
"record_key": case.record_key,
|
||||
"route": case.route,
|
||||
"segment": case.segment,
|
||||
"frame_time_s": f"{case.frame_time_s:.3f}",
|
||||
"expected_speed_limit_mph": "" if case.negative else case.expected_speed_limit_mph,
|
||||
"negative": case.negative,
|
||||
"candidate_values": "|".join(str(value) for value in candidates),
|
||||
"publish_values": "|".join(str(value) for value in publishes),
|
||||
"candidate_hit": candidate_hit,
|
||||
"publish_hit": publish_hit,
|
||||
"false_candidate": false_candidate,
|
||||
"false_publish": false_publish,
|
||||
"inference_frames": inference_frames,
|
||||
"source_video_path": str(case.source_video_path),
|
||||
})
|
||||
output_path = args.output_csv.expanduser().resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = list(output_rows[0]) if output_rows else []
|
||||
with output_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(output_rows)
|
||||
|
||||
totals = Counter()
|
||||
for counts in positive_by_speed.values():
|
||||
totals.update(counts)
|
||||
negative_scope = " ".join((
|
||||
"Reviewed negative labels apply to the proposed crop.",
|
||||
"Candidate/publish counts replay the full video window and are an upper bound, not confirmed false positives,",
|
||||
"because another valid sign may be visible in that window.",
|
||||
))
|
||||
summary = {
|
||||
"models_dir": str(args.models_dir.expanduser().resolve()),
|
||||
"crop_ocr": args.crop_ocr,
|
||||
"classifier_min_confidence": slv.US_CLASSIFIER_MIN_CONFIDENCE,
|
||||
"measured_base_inference_seconds": args.measured_base_inference_seconds,
|
||||
"measured_classifier_forward_seconds": args.measured_classifier_forward_seconds,
|
||||
"initial_speed_limit_mph": args.initial_speed_limit,
|
||||
"low_speed_change_consistent_detections": slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS,
|
||||
"low_speed_change_allow_strong_consensus": slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS,
|
||||
"strong_model_consensus_enabled": slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED,
|
||||
"positive": dict(totals),
|
||||
"positive_by_speed": {str(speed): dict(counts) for speed, counts in sorted(positive_by_speed.items())},
|
||||
"negative": dict(negative_counts),
|
||||
"negative_scope": negative_scope,
|
||||
}
|
||||
summary_path = output_path.with_suffix(".json")
|
||||
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -20,7 +20,7 @@ def parse_args() -> argparse.Namespace:
|
||||
default=Path("starpilot/assets/vision_models"),
|
||||
help="Directory containing speed_limit_us_detector.onnx and speed_limit_us_value_classifier.onnx.",
|
||||
)
|
||||
parser.add_argument("--manifest", type=Path, required=True, help="CSV manifest with dataset_image/frame_path and labels.")
|
||||
parser.add_argument("--manifest", type=Path, required=True, help="CSV manifest with dataset_image/frame_path/image_path and labels.")
|
||||
parser.add_argument("--split", action="append", help="Optional split filter. Repeat for multiple splits.")
|
||||
parser.add_argument("--max-rows", type=int, default=0, help="Optional cap after filtering.")
|
||||
parser.add_argument("--seed", type=int, default=0, help="Sampling seed used with --max-rows.")
|
||||
@@ -28,6 +28,9 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--detector-min-confidence", type=float, help="Override runtime US detector confidence threshold.")
|
||||
parser.add_argument("--classifier-min-confidence", type=float, help="Override runtime US classifier confidence threshold.")
|
||||
parser.add_argument("--classifier-reject-min-confidence", type=float, help="Override runtime reject-class confidence threshold.")
|
||||
parser.add_argument("--trusted-model-min-confidence", type=float, help="Override tiny-box trusted model confidence for evaluation.")
|
||||
parser.add_argument("--strong-rescue-min-proposal-confidence", type=float, help="Override single-frame tiny-sign proposal confidence.")
|
||||
parser.add_argument("--strong-rescue-min-read-confidence", type=float, help="Override single-frame tiny-sign classifier confidence.")
|
||||
parser.add_argument(
|
||||
"--detector-region-mode",
|
||||
choices=("full", "right_roi", "full_and_right_roi"),
|
||||
@@ -36,7 +39,12 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--right-roi-bounds", help="Override the right ROI as left,top,right,bottom ratios, for example 0.45,0,1,0.82.")
|
||||
parser.add_argument("--right-roi-min-confidence", type=float, help="Override the right ROI detector minimum confidence.")
|
||||
parser.add_argument("--full-frame-ocr", action="store_true", help="Enable the expensive full-frame OCR fallback during eval.")
|
||||
crop_ocr_group = parser.add_mutually_exclusive_group()
|
||||
crop_ocr_group.add_argument("--crop-ocr", action="store_true", dest="crop_ocr", default=None, help="Enable crop OCR confirmation.")
|
||||
crop_ocr_group.add_argument("--no-crop-ocr", action="store_false", dest="crop_ocr", help="Evaluate the model-only detector/classifier path.")
|
||||
parser.add_argument("--separate-reject-classifier", action="store_true", help="Enable the optional second-stage reject classifier during eval.")
|
||||
parser.add_argument("--include-uncertain", action="store_true", help="Include uncertain_positive review rows in positive metrics.")
|
||||
parser.add_argument("--advisory-positive", action="store_true", help="Score reviewed advisory rows as readable speed positives.")
|
||||
parser.add_argument("--strict-positive-recall", type=float, help="Exit non-zero if positive exact recall is below this value.")
|
||||
parser.add_argument("--strict-negative-fpr", type=float, help="Exit non-zero if negative false-positive rate is above this value.")
|
||||
return parser.parse_args()
|
||||
@@ -48,6 +56,10 @@ def configure_runtime_options(args: argparse.Namespace) -> None:
|
||||
|
||||
if args.full_frame_ocr:
|
||||
slv.FULL_FRAME_OCR_FALLBACK_ENABLED = True
|
||||
if args.crop_ocr is not None:
|
||||
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr
|
||||
if args.separate_reject_classifier:
|
||||
slv.SEPARATE_REJECT_CLASSIFIER_ENABLED = True
|
||||
|
||||
if args.right_roi_bounds:
|
||||
parts = [float(part.strip()) for part in args.right_roi_bounds.split(",")]
|
||||
@@ -81,7 +93,7 @@ def first_present(row: dict[str, str], keys: tuple[str, ...]) -> str:
|
||||
|
||||
|
||||
def expected_value(row: dict[str, str]) -> int | None:
|
||||
value_text = first_present(row, ("speed_limit_mph", "dominant_value"))
|
||||
value_text = first_present(row, ("expected_speed_limit_mph", "speed_limit_mph", "dominant_value"))
|
||||
if value_text:
|
||||
try:
|
||||
return int(float(value_text))
|
||||
@@ -151,6 +163,12 @@ def main() -> int:
|
||||
if args.classifier_reject_min_confidence is not None:
|
||||
slv.US_CLASSIFIER_REJECT_MIN_CONFIDENCE = args.classifier_reject_min_confidence
|
||||
slv.US_REJECT_CLASSIFIER_MIN_CONFIDENCE = args.classifier_reject_min_confidence
|
||||
if args.trusted_model_min_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = args.trusted_model_min_confidence
|
||||
if args.strong_rescue_min_proposal_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE = args.strong_rescue_min_proposal_confidence
|
||||
if args.strong_rescue_min_read_confidence is not None:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE = args.strong_rescue_min_read_confidence
|
||||
configure_runtime_options(args)
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
|
||||
@@ -163,7 +181,7 @@ def main() -> int:
|
||||
unreadable_count = 0
|
||||
|
||||
for row in rows:
|
||||
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame"))
|
||||
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame", "image_path"))
|
||||
if not image_text:
|
||||
unreadable_count += 1
|
||||
continue
|
||||
@@ -178,7 +196,8 @@ def main() -> int:
|
||||
predicted_value = detection.speed_limit_mph if detection is not None else None
|
||||
confidence = detection.confidence if detection is not None else None
|
||||
expected = expected_value(row)
|
||||
negative = is_negative(row)
|
||||
advisory_positive = args.advisory_positive and row.get("review_sign_type", "").strip().lower() == "advisory"
|
||||
negative = False if advisory_positive else is_negative(row)
|
||||
|
||||
if negative:
|
||||
negative_count += 1
|
||||
@@ -211,10 +230,9 @@ def main() -> int:
|
||||
if uncertain_count and not args.include_uncertain:
|
||||
print(f"Skipped uncertain rows: {uncertain_count}")
|
||||
print(f"Unreadable rows: {unreadable_count}")
|
||||
print(
|
||||
f"Positive exact: {positive_exact}/{positive_count} "
|
||||
f"({positive_exact_recall:.3f}); any detection: {positive_detected}/{positive_count} ({positive_any_recall:.3f})"
|
||||
)
|
||||
positive_summary = f"Positive exact: {positive_exact}/{positive_count} ({positive_exact_recall:.3f})"
|
||||
detection_summary = f"any detection: {positive_detected}/{positive_count} ({positive_any_recall:.3f})"
|
||||
print(f"{positive_summary}; {detection_summary}")
|
||||
print(f"Negative false positives: {negative_false_positive}/{negative_count} ({negative_fpr:.3f})")
|
||||
|
||||
if args.output_csv:
|
||||
|
||||
@@ -14,13 +14,16 @@ import cv2
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore
|
||||
from common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace # type: ignore # noqa: TID251
|
||||
else:
|
||||
from .common import DEFAULT_SPEED_VALUES, DEFAULT_WORKSPACE, ensure_dir, resolve_workspace
|
||||
|
||||
|
||||
CLASSIFIER_FIELDNAMES = [
|
||||
"record_key",
|
||||
"route",
|
||||
"log_id",
|
||||
"segment",
|
||||
"split",
|
||||
"speed_limit_mph",
|
||||
"review_sign_type",
|
||||
@@ -36,6 +39,9 @@ CLASSIFIER_FIELDNAMES = [
|
||||
|
||||
RUNTIME_FIELDNAMES = [
|
||||
"record_key",
|
||||
"route",
|
||||
"log_id",
|
||||
"segment",
|
||||
"split",
|
||||
"sample_type",
|
||||
"dataset_image",
|
||||
@@ -49,6 +55,9 @@ RUNTIME_FIELDNAMES = [
|
||||
|
||||
DETECTOR_MANIFEST_FIELDNAMES = [
|
||||
"record_key",
|
||||
"route",
|
||||
"log_id",
|
||||
"segment",
|
||||
"split",
|
||||
"sample_type",
|
||||
"speed_limit_mph",
|
||||
@@ -62,6 +71,23 @@ DETECTOR_MANIFEST_FIELDNAMES = [
|
||||
"detector_class",
|
||||
]
|
||||
|
||||
REJECT_FIELDNAMES = [
|
||||
"record_key",
|
||||
"route",
|
||||
"log_id",
|
||||
"segment",
|
||||
"split",
|
||||
"crop_path",
|
||||
"frame_path",
|
||||
"bbox",
|
||||
"crop_bbox",
|
||||
"candidate_speed_limit_mph",
|
||||
"candidate_confidence",
|
||||
"detector_class",
|
||||
"review_ignore_reason",
|
||||
"review_notes",
|
||||
]
|
||||
|
||||
POSITIVE_STATUSES = {"accepted", "corrected"}
|
||||
UNCERTAIN_STATUS = "uncertain"
|
||||
NEGATIVE_STATUS = "ignore"
|
||||
@@ -81,6 +107,7 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--classifier-manifest-out", type=Path, help="Positive crop manifest for import_manifest_classifier_masks.py.")
|
||||
parser.add_argument("--runtime-manifest-out", type=Path, help="Full-frame eval manifest including positives and true negatives.")
|
||||
parser.add_argument("--detector-manifest-out", type=Path, help="Manifest of imported detector examples.")
|
||||
parser.add_argument("--reject-manifest-out", type=Path, help="Reviewed proposal crops that should train the classifier reject class.")
|
||||
parser.add_argument("--source-name", default="manual_review", help="Filename prefix for detector dataset imports.")
|
||||
parser.add_argument("--mode", choices=("symlink", "copy"), default="symlink", help="How to place detector images.")
|
||||
parser.add_argument("--val-modulo", type=int, default=5, help="Hash modulo for validation split. 0 sends everything to train.")
|
||||
@@ -110,6 +137,10 @@ def split_for_key(key: str, val_modulo: int, val_remainder: int) -> str:
|
||||
return "val" if int(digest[:8], 16) % val_modulo == val_remainder else "train"
|
||||
|
||||
|
||||
def split_group_key(row: dict[str, str]) -> str:
|
||||
return row.get("route") or row.get("log_id") or row["record_key"]
|
||||
|
||||
|
||||
def parse_speed(text: str) -> int:
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
@@ -227,6 +258,10 @@ def is_positive(row: dict[str, str]) -> bool:
|
||||
return Path(row.get("crop_path", "")).is_file() and Path(row.get("frame_path", "")).is_file()
|
||||
|
||||
|
||||
def is_advisory_positive(row: dict[str, str]) -> bool:
|
||||
return is_positive(row) and effective_sign_type(row) == "advisory"
|
||||
|
||||
|
||||
def is_uncertain_positive(row: dict[str, str]) -> bool:
|
||||
if row.get("review_status") != UNCERTAIN_STATUS:
|
||||
return False
|
||||
@@ -243,10 +278,40 @@ def is_true_negative(row: dict[str, str]) -> bool:
|
||||
return Path(row.get("frame_path", "")).is_file()
|
||||
|
||||
|
||||
def is_classifier_reject(row: dict[str, str]) -> bool:
|
||||
if row.get("review_status") != NEGATIVE_STATUS or row.get("detector_class") == "negative_empty":
|
||||
return False
|
||||
if row.get("review_sign_type") != "not_speed_limit":
|
||||
return False
|
||||
return Path(row.get("crop_path", "")).is_file()
|
||||
|
||||
|
||||
def classifier_reject_row(row: dict[str, str], split: str) -> dict[str, object]:
|
||||
return {
|
||||
"record_key": row["record_key"],
|
||||
"route": row.get("route", ""),
|
||||
"log_id": row.get("log_id", ""),
|
||||
"segment": row.get("segment", ""),
|
||||
"split": split,
|
||||
"crop_path": row.get("crop_path", ""),
|
||||
"frame_path": row.get("frame_path", ""),
|
||||
"bbox": row.get("bbox", ""),
|
||||
"crop_bbox": row.get("crop_bbox", ""),
|
||||
"candidate_speed_limit_mph": row.get("candidate_speed_limit_mph", ""),
|
||||
"candidate_confidence": row.get("candidate_confidence", ""),
|
||||
"detector_class": row.get("detector_class", ""),
|
||||
"review_ignore_reason": row.get("review_ignore_reason", ""),
|
||||
"review_notes": row.get("review_notes", ""),
|
||||
}
|
||||
|
||||
|
||||
def positive_classifier_row(row: dict[str, str], split: str) -> dict[str, object]:
|
||||
speed = parse_speed(row.get("review_speed_limit_mph", ""))
|
||||
return {
|
||||
"record_key": row["record_key"],
|
||||
"route": row.get("route", ""),
|
||||
"log_id": row.get("log_id", ""),
|
||||
"segment": row.get("segment", ""),
|
||||
"split": split,
|
||||
"speed_limit_mph": speed,
|
||||
"review_sign_type": effective_sign_type(row),
|
||||
@@ -262,9 +327,13 @@ def positive_classifier_row(row: dict[str, str], split: str) -> dict[str, object
|
||||
|
||||
|
||||
def runtime_row(row: dict[str, str], split: str, sample_type: str) -> dict[str, object]:
|
||||
speed = parse_speed(row.get("review_speed_limit_mph", "")) if sample_type in ("positive", "uncertain_positive") else 0
|
||||
positive_sample_types = ("positive", "uncertain_positive", "advisory_negative")
|
||||
speed = parse_speed(row.get("review_speed_limit_mph", "")) if sample_type in positive_sample_types else 0
|
||||
return {
|
||||
"record_key": row["record_key"],
|
||||
"route": row.get("route", ""),
|
||||
"log_id": row.get("log_id", ""),
|
||||
"segment": row.get("segment", ""),
|
||||
"split": split,
|
||||
"sample_type": sample_type,
|
||||
"dataset_image": row.get("frame_path", ""),
|
||||
@@ -314,6 +383,9 @@ def import_detector_example(
|
||||
|
||||
return {
|
||||
"record_key": row["record_key"],
|
||||
"route": row.get("route", ""),
|
||||
"log_id": row.get("log_id", ""),
|
||||
"segment": row.get("segment", ""),
|
||||
"split": split,
|
||||
"sample_type": sample_type,
|
||||
"speed_limit_mph": parse_speed(row.get("review_speed_limit_mph", "")) if sample_type == "positive" else "",
|
||||
@@ -334,67 +406,94 @@ def main() -> int:
|
||||
queue_path = args.queue.expanduser().resolve()
|
||||
labels_path = args.labels.expanduser().resolve() if args.labels else queue_path.with_name("manual_review_labels.csv")
|
||||
output_dir = queue_path.parent
|
||||
classifier_manifest = args.classifier_manifest_out.expanduser().resolve() if args.classifier_manifest_out else output_dir / "manual_review_classifier_manifest.csv"
|
||||
runtime_manifest = args.runtime_manifest_out.expanduser().resolve() if args.runtime_manifest_out else output_dir / "manual_review_runtime_eval_manifest.csv"
|
||||
detector_manifest = args.detector_manifest_out.expanduser().resolve() if args.detector_manifest_out else output_dir / "manual_review_detector_import_manifest.csv"
|
||||
classifier_manifest = (
|
||||
args.classifier_manifest_out.expanduser().resolve()
|
||||
if args.classifier_manifest_out else output_dir / "manual_review_classifier_manifest.csv"
|
||||
)
|
||||
runtime_manifest = (
|
||||
args.runtime_manifest_out.expanduser().resolve()
|
||||
if args.runtime_manifest_out else output_dir / "manual_review_runtime_eval_manifest.csv"
|
||||
)
|
||||
detector_manifest = (
|
||||
args.detector_manifest_out.expanduser().resolve()
|
||||
if args.detector_manifest_out else output_dir / "manual_review_detector_import_manifest.csv"
|
||||
)
|
||||
reject_manifest = (
|
||||
args.reject_manifest_out.expanduser().resolve()
|
||||
if args.reject_manifest_out else output_dir / "manual_review_classifier_reject_manifest.csv"
|
||||
)
|
||||
|
||||
rows = merged_review_rows(queue_path, labels_path)
|
||||
positive_rows = [row for row in rows if is_positive(row)]
|
||||
advisory_positive_rows = [row for row in positive_rows if is_advisory_positive(row)]
|
||||
uncertain_positive_rows = [row for row in rows if is_uncertain_positive(row)]
|
||||
true_negative_rows = [row for row in rows if is_true_negative(row)]
|
||||
classifier_reject_rows = [row for row in rows if is_classifier_reject(row)]
|
||||
if args.max_detector_negatives > 0:
|
||||
true_negative_rows = true_negative_rows[:args.max_detector_negatives]
|
||||
|
||||
classifier_rows: list[dict[str, object]] = []
|
||||
runtime_rows: list[dict[str, object]] = []
|
||||
detector_rows: list[dict[str, object]] = []
|
||||
reject_rows: list[dict[str, object]] = []
|
||||
|
||||
for row in positive_rows:
|
||||
split = split_for_key(row["record_key"], args.val_modulo, args.val_remainder)
|
||||
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
|
||||
classifier_rows.append(positive_classifier_row(row, split))
|
||||
runtime_rows.append(runtime_row(row, split, "positive"))
|
||||
sample_type = "advisory_negative" if is_advisory_positive(row) else "positive"
|
||||
runtime_rows.append(runtime_row(row, split, sample_type))
|
||||
detector_row = import_detector_example(workspace, row, split, args.source_name, "positive", args.mode, args.overwrite)
|
||||
if detector_row is not None:
|
||||
detector_rows.append(detector_row)
|
||||
|
||||
for row in uncertain_positive_rows:
|
||||
split = split_for_key(row["record_key"], args.val_modulo, args.val_remainder)
|
||||
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
|
||||
runtime_rows.append(runtime_row(row, split, "uncertain_positive"))
|
||||
|
||||
for row in true_negative_rows:
|
||||
split = split_for_key(row["record_key"], args.val_modulo, args.val_remainder)
|
||||
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
|
||||
runtime_rows.append(runtime_row(row, split, "negative_empty"))
|
||||
detector_row = import_detector_example(workspace, row, split, args.source_name, "negative_empty", args.mode, args.overwrite)
|
||||
if detector_row is not None:
|
||||
detector_rows.append(detector_row)
|
||||
|
||||
for row in classifier_reject_rows:
|
||||
split = split_for_key(split_group_key(row), args.val_modulo, args.val_remainder)
|
||||
reject_rows.append(classifier_reject_row(row, split))
|
||||
|
||||
write_csv(classifier_manifest, CLASSIFIER_FIELDNAMES, classifier_rows)
|
||||
write_csv(runtime_manifest, RUNTIME_FIELDNAMES, runtime_rows)
|
||||
write_csv(detector_manifest, DETECTOR_MANIFEST_FIELDNAMES, detector_rows)
|
||||
write_csv(reject_manifest, REJECT_FIELDNAMES, reject_rows)
|
||||
|
||||
summary = {
|
||||
"queue": str(queue_path),
|
||||
"labels": str(labels_path),
|
||||
"reviewed_rows": len(rows),
|
||||
"positive_rows": len(positive_rows),
|
||||
"advisory_positive_rows": len(advisory_positive_rows),
|
||||
"uncertain_positive_rows": len(uncertain_positive_rows),
|
||||
"true_negative_rows": len(true_negative_rows),
|
||||
"classifier_reject_rows": len(reject_rows),
|
||||
"classifier_manifest": str(classifier_manifest),
|
||||
"runtime_manifest": str(runtime_manifest),
|
||||
"detector_manifest": str(detector_manifest),
|
||||
"detector_imported": len(detector_rows),
|
||||
"reject_manifest": str(reject_manifest),
|
||||
}
|
||||
summary_path = output_dir / "manual_review_import_summary.json"
|
||||
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
|
||||
print(
|
||||
"Imported manual review queue: "
|
||||
f"reviewed={len(rows)} positives={len(positive_rows)} uncertain_positives={len(uncertain_positive_rows)} true_negatives={len(true_negative_rows)} "
|
||||
f"detector_imported={len(detector_rows)}"
|
||||
)
|
||||
review_counts = " ".join((
|
||||
f"reviewed={len(rows)} positives={len(positive_rows)}",
|
||||
f"uncertain_positives={len(uncertain_positive_rows)} true_negatives={len(true_negative_rows)}",
|
||||
))
|
||||
import_counts = f"classifier_rejects={len(reject_rows)} detector_imported={len(detector_rows)}"
|
||||
print(f"Imported manual review queue: {review_counts} {import_counts}")
|
||||
print(f"Classifier manifest: {classifier_manifest}")
|
||||
print(f"Runtime eval manifest: {runtime_manifest}")
|
||||
print(f"Detector import manifest: {detector_manifest}")
|
||||
print(f"Classifier reject manifest: {reject_manifest}")
|
||||
print(f"Summary: {summary_path}")
|
||||
return 0
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import torch
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Linearly interpolate compatible Ultralytics checkpoints.")
|
||||
parser.add_argument("--base", type=Path, required=True, help="Baseline checkpoint used at alpha=0.")
|
||||
parser.add_argument("--candidate", type=Path, required=True, help="Candidate checkpoint used at alpha=1.")
|
||||
parser.add_argument("--alpha", type=float, required=True, help="Candidate weight in [0, 1].")
|
||||
parser.add_argument("--output", type=Path, required=True, help="Interpolated checkpoint path.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def checkpoint_model(checkpoint: dict):
|
||||
model = checkpoint.get("ema") or checkpoint.get("model")
|
||||
if model is None:
|
||||
raise ValueError("Checkpoint contains neither model nor ema weights")
|
||||
return model.float()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if not 0.0 <= args.alpha <= 1.0:
|
||||
raise ValueError("--alpha must be between 0 and 1")
|
||||
base_checkpoint = torch.load(args.base.expanduser().resolve(), map_location="cpu", weights_only=False)
|
||||
candidate_checkpoint = torch.load(args.candidate.expanduser().resolve(), map_location="cpu", weights_only=False)
|
||||
base_model = checkpoint_model(base_checkpoint)
|
||||
candidate_model = checkpoint_model(candidate_checkpoint)
|
||||
base_state = base_model.state_dict()
|
||||
candidate_state = candidate_model.state_dict()
|
||||
if base_state.keys() != candidate_state.keys():
|
||||
raise ValueError("Checkpoint model state keys differ")
|
||||
|
||||
interpolated_state = {}
|
||||
for key, base_value in base_state.items():
|
||||
candidate_value = candidate_state[key]
|
||||
if base_value.shape != candidate_value.shape:
|
||||
raise ValueError(f"Checkpoint tensor shape differs for {key}")
|
||||
if torch.is_floating_point(base_value):
|
||||
interpolated_state[key] = base_value * (1.0 - args.alpha) + candidate_value * args.alpha
|
||||
else:
|
||||
interpolated_state[key] = candidate_value if args.alpha >= 0.5 else base_value
|
||||
|
||||
interpolated_model = copy.deepcopy(base_model)
|
||||
interpolated_model.load_state_dict(interpolated_state)
|
||||
output_checkpoint = dict(base_checkpoint)
|
||||
output_checkpoint.update({
|
||||
"model": interpolated_model,
|
||||
"ema": None,
|
||||
"optimizer": None,
|
||||
"epoch": -1,
|
||||
"best_fitness": None,
|
||||
})
|
||||
output = args.output.expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
torch.save(output_checkpoint, output)
|
||||
print(f"Wrote alpha={args.alpha:.4f} interpolated checkpoint to {output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -100,7 +100,15 @@ def iter_context_frames(clip_root: Path, window: ebl.BookmarkWindow, search_befo
|
||||
yield relative_time_s, clip_path, source_time_s, frame_bgr
|
||||
|
||||
|
||||
def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, class_id: int, proposal_confidence: float, box, full_detection):
|
||||
def _score_expanded_candidate(
|
||||
daemon: slv.SpeedLimitVisionDaemon,
|
||||
frame_bgr,
|
||||
class_id: int,
|
||||
proposal_confidence: float,
|
||||
box,
|
||||
full_detection,
|
||||
use_ocr: bool = True,
|
||||
):
|
||||
frame_height, frame_width = frame_bgr.shape[:2]
|
||||
x1, y1, x2, y2 = box
|
||||
box_width = x2 - x1
|
||||
@@ -123,7 +131,7 @@ def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, cla
|
||||
|
||||
is_regulatory = daemon._is_regulatory_speed_sign(sign_crop) or class_id == 2
|
||||
model_read = daemon._classify_speed_limit_from_model(sign_crop)
|
||||
ocr_read = daemon._read_speed_limit_from_crop(sign_crop)
|
||||
ocr_read = daemon._read_speed_limit_from_crop(sign_crop) if use_ocr else None
|
||||
if model_read is None and ocr_read is None:
|
||||
continue
|
||||
|
||||
@@ -132,9 +140,14 @@ def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, cla
|
||||
if read_result is None or read_result[0] not in slv.SCHOOL_ZONE_SPEED_VALUES:
|
||||
continue
|
||||
elif not is_regulatory:
|
||||
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
|
||||
if use_ocr:
|
||||
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
|
||||
continue
|
||||
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
|
||||
elif model_read is None or model_read[1] < slv.DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE:
|
||||
continue
|
||||
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
|
||||
else:
|
||||
read_result = model_read
|
||||
else:
|
||||
if model_read is not None and ocr_read is not None and model_read[0] == ocr_read[0]:
|
||||
read_result = (model_read[0], max(model_read[1], ocr_read[1]))
|
||||
@@ -169,7 +182,7 @@ def _score_expanded_candidate(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, cla
|
||||
return best
|
||||
|
||||
|
||||
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
|
||||
def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr, use_ocr: bool = True):
|
||||
full_detection = daemon._detect_sign(frame_bgr)
|
||||
best = None
|
||||
|
||||
@@ -177,7 +190,15 @@ def score_frame(daemon: slv.SpeedLimitVisionDaemon, frame_bgr):
|
||||
if class_id == 1:
|
||||
continue
|
||||
|
||||
candidate = _score_expanded_candidate(daemon, frame_bgr, class_id, proposal_confidence, (x1, y1, x2, y2), full_detection)
|
||||
candidate = _score_expanded_candidate(
|
||||
daemon,
|
||||
frame_bgr,
|
||||
class_id,
|
||||
proposal_confidence,
|
||||
(x1, y1, x2, y2),
|
||||
full_detection,
|
||||
use_ocr=use_ocr,
|
||||
)
|
||||
if candidate is None:
|
||||
continue
|
||||
if best is None or candidate["score"] > best["score"]:
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Merge fingerprint-compatible speed-limit manual review queues.")
|
||||
parser.add_argument("inputs", type=Path, nargs="+", help="Queue directories or manual_review_queue.csv files.")
|
||||
parser.add_argument("--output-dir", type=Path, required=True, help="Destination queue directory.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def queue_paths(input_path: Path) -> tuple[Path, Path]:
|
||||
resolved = input_path.expanduser().resolve()
|
||||
if resolved.is_dir():
|
||||
return resolved / "manual_review_queue.csv", resolved / "manual_review_summary.json"
|
||||
return resolved, resolved.with_name("manual_review_summary.json")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
rows_by_key: dict[str, dict[str, str]] = {}
|
||||
summaries_by_route: dict[str, dict[str, object]] = {}
|
||||
fieldnames: list[str] | None = None
|
||||
mining_fingerprint = ""
|
||||
model_fingerprint = ""
|
||||
|
||||
for input_path in args.inputs:
|
||||
queue_path, summary_path = queue_paths(input_path)
|
||||
if not queue_path.is_file() or not summary_path.is_file():
|
||||
raise FileNotFoundError(f"Queue or summary missing for {input_path}")
|
||||
summary = json.loads(summary_path.read_text(encoding="utf-8"))
|
||||
current_mining = str(summary.get("mining_fingerprint", ""))
|
||||
current_model = str(summary.get("model_fingerprint", ""))
|
||||
if not current_mining or not current_model:
|
||||
raise RuntimeError(f"Queue is not fingerprinted: {input_path}")
|
||||
if mining_fingerprint and current_mining != mining_fingerprint:
|
||||
raise RuntimeError(f"Mining fingerprint mismatch: {input_path}")
|
||||
if model_fingerprint and current_model != model_fingerprint:
|
||||
raise RuntimeError(f"Model fingerprint mismatch: {input_path}")
|
||||
mining_fingerprint = current_mining
|
||||
model_fingerprint = current_model
|
||||
|
||||
with queue_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
current_fields = list(reader.fieldnames or [])
|
||||
if fieldnames is not None and current_fields != fieldnames:
|
||||
raise RuntimeError(f"Queue fields differ: {input_path}")
|
||||
fieldnames = current_fields
|
||||
for row in reader:
|
||||
key = row.get("record_key", "")
|
||||
if key:
|
||||
rows_by_key[key] = row
|
||||
for route_summary in summary.get("routes", []):
|
||||
route = str(route_summary.get("route", ""))
|
||||
if route:
|
||||
summaries_by_route[route] = route_summary
|
||||
|
||||
rows = sorted(rows_by_key.values(), key=lambda row: (-float(row.get("review_priority") or 0.0), row["record_key"]))
|
||||
route_summaries = sorted(summaries_by_route.values(), key=lambda item: str(item["route"]))
|
||||
output_dir = args.output_dir.expanduser().resolve()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
queue_output = output_dir / "manual_review_queue.csv"
|
||||
with queue_output.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames or [])
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
|
||||
summary_output = output_dir / "manual_review_summary.json"
|
||||
summary_output.write_text(json.dumps({
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"manifest": str(queue_output),
|
||||
"rows": len(rows),
|
||||
"candidates": sum(row.get("detector_class") != "negative_empty" for row in rows),
|
||||
"negatives": sum(row.get("detector_class") == "negative_empty" for row in rows),
|
||||
"routes": route_summaries,
|
||||
}, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(f"Merged {len(rows)} rows from {len(route_summaries)} routes into {queue_output}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,157 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from localize_bookmark_signs import configure_models # type: ignore
|
||||
else:
|
||||
from .localize_bookmark_signs import configure_models
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Mine model-confusing negative crops into an integrated classifier reject class.")
|
||||
parser.add_argument("--manifest", type=Path, action="append", required=True, help="Reviewed runtime manifest. Repeat for multiple sets.")
|
||||
parser.add_argument("--models-dir", type=Path, required=True, help="Detector/classifier ONNX bundle used to mine hard negatives.")
|
||||
parser.add_argument("--dataset", type=Path, required=True, help="Classifier dataset containing train/reject and val/reject.")
|
||||
parser.add_argument("--split", choices=("train", "val"), default="train", help="Destination dataset split.")
|
||||
parser.add_argument("--classifier-min-confidence", type=float, default=0.55, help="Minimum wrong speed confidence to mine.")
|
||||
parser.add_argument("--max-crops", type=int, default=2000, help="Maximum reject crops to add.")
|
||||
parser.add_argument("--overwrite", action="store_true", help="Overwrite an existing crop with the same content hash.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def first_present(row: dict[str, str], keys: tuple[str, ...]) -> str:
|
||||
for key in keys:
|
||||
value = row.get(key, "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def expected_value(row: dict[str, str]) -> int | None:
|
||||
value = first_present(row, ("expected_speed_limit_mph", "speed_limit_mph", "dominant_value"))
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return int(float(value))
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def is_reviewed_negative(row: dict[str, str]) -> bool:
|
||||
sample_type = row.get("sample_type", "").lower()
|
||||
review_status = row.get("review_status", "").lower()
|
||||
explicit_negative = row.get("negative", "").strip().lower() in ("1", "true", "yes")
|
||||
return explicit_negative or "negative" in sample_type or review_status in ("negative", "reject")
|
||||
|
||||
|
||||
def iter_negative_images(manifests: list[Path]):
|
||||
seen: set[Path] = set()
|
||||
for manifest in manifests:
|
||||
with manifest.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
|
||||
for row in csv.DictReader(handle):
|
||||
if not is_reviewed_negative(row):
|
||||
continue
|
||||
image_text = first_present(row, ("dataset_image", "frame_path", "source_frame", "image_path"))
|
||||
if not image_text:
|
||||
continue
|
||||
image_path = Path(image_text).expanduser().resolve()
|
||||
if image_path in seen or not image_path.is_file():
|
||||
continue
|
||||
seen.add(image_path)
|
||||
yield image_path
|
||||
|
||||
|
||||
def crop_hash(crop) -> str:
|
||||
ok, encoded = cv2.imencode(".jpg", crop, [cv2.IMWRITE_JPEG_QUALITY, 94])
|
||||
if not ok:
|
||||
return ""
|
||||
return hashlib.sha256(encoded.tobytes()).hexdigest()[:20]
|
||||
|
||||
|
||||
def remove_appledouble_files(root: Path) -> int:
|
||||
removed = 0
|
||||
for path in root.rglob("._*"):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
configure_models(args.models_dir)
|
||||
slv.US_CLASSIFIER_MIN_CONFIDENCE = args.classifier_min_confidence
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
dataset_root = args.dataset.expanduser().resolve()
|
||||
appledouble_removed = remove_appledouble_files(dataset_root)
|
||||
output_dir = dataset_root / args.split / "reject"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
added = 0
|
||||
frames = 0
|
||||
proposals = 0
|
||||
for image_path in iter_negative_images(args.manifest):
|
||||
if added >= args.max_crops:
|
||||
break
|
||||
frame_bgr = cv2.imread(str(image_path))
|
||||
if frame_bgr is None:
|
||||
continue
|
||||
frames += 1
|
||||
frame_height, frame_width = frame_bgr.shape[:2]
|
||||
|
||||
for _proposal_confidence, class_id, (x1, y1, x2, y2) in daemon._collect_detector_classifier_proposals(frame_bgr):
|
||||
if class_id == 1:
|
||||
continue
|
||||
proposals += 1
|
||||
box_width = x2 - x1
|
||||
box_height = y2 - y1
|
||||
if box_width <= 0 or box_height <= 0:
|
||||
continue
|
||||
|
||||
for expansion_index, (left, top, right, bottom, _weight) in enumerate(slv.DETECTOR_CLASSIFIER_EXPANSIONS):
|
||||
crop_x1 = max(int(x1 - box_width * left), 0)
|
||||
crop_y1 = max(int(y1 - box_height * top), 0)
|
||||
crop_x2 = min(int(x2 + box_width * right), frame_width)
|
||||
crop_y2 = min(int(y2 + box_height * bottom), frame_height)
|
||||
crop = frame_bgr[crop_y1:crop_y2, crop_x1:crop_x2]
|
||||
if crop.size == 0 or daemon._classify_speed_limit_from_model(crop) is None:
|
||||
continue
|
||||
|
||||
digest = crop_hash(crop)
|
||||
if not digest:
|
||||
continue
|
||||
output_path = output_dir / f"hardneg_{digest}_e{expansion_index}.jpg"
|
||||
if output_path.exists() and not args.overwrite:
|
||||
continue
|
||||
cv2.imwrite(str(output_path), crop, [cv2.IMWRITE_JPEG_QUALITY, 94])
|
||||
added += 1
|
||||
if added >= args.max_crops:
|
||||
break
|
||||
if added >= args.max_crops:
|
||||
break
|
||||
|
||||
if added:
|
||||
cache_path = dataset_root / f"{args.split}.cache"
|
||||
if cache_path.is_file():
|
||||
cache_path.unlink()
|
||||
summary = f"Hard-negative mining complete: frames={frames} proposals={proposals} added={added}"
|
||||
summary += f" appledouble_removed={appledouble_removed}"
|
||||
print(summary)
|
||||
print(f"Reject dataset: {output_dir}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -30,10 +30,14 @@ else:
|
||||
DEFAULT_ROUTE_BUNDLE_STATE_DIR = Path("/Volumes/T5/starpilot_speed_limit/analysis/route_bundles/state")
|
||||
DEFAULT_WORKSPACE = Path("/Volumes/T5/starpilot_speed_limit/workspace/speed_limit_training_clean")
|
||||
DEFAULT_REVIEW_MANIFEST_NAME = "route_training_samples.csv"
|
||||
MINING_RUN_SCHEMA_VERSION = 2
|
||||
MPH_PER_MS = 2.2369362920544
|
||||
VALID_WEAK_LABEL_VALUES = set(slv.US_CLASSIFIER_SPEED_VALUES)
|
||||
POSITIVE_FIELDNAMES = [
|
||||
"record_key",
|
||||
"mining_run_id",
|
||||
"mining_fingerprint",
|
||||
"model_fingerprint",
|
||||
"route",
|
||||
"dongle_id",
|
||||
"log_id",
|
||||
@@ -83,6 +87,9 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--clip-root", type=Path, default=preferred_clip_root(), help="Route realdata root.")
|
||||
parser.add_argument("--bundle-state-dir", type=Path, default=DEFAULT_ROUTE_BUNDLE_STATE_DIR, help="Completed extraction marker directory.")
|
||||
parser.add_argument("--models-dir", type=Path, help="Optional model directory for mining with non-repo ONNXs.")
|
||||
parser.add_argument("--model-only", action="store_true", help="Use detector/classifier output without OCR when weak-labeling signs.")
|
||||
parser.add_argument("--run-id", help="Version this mining pass. Use 'auto' to derive an id from the ONNX bundle.")
|
||||
parser.add_argument("--output-root", type=Path, help="Output root for this pass. Defaults to a versioned staging directory when --run-id is set.")
|
||||
parser.add_argument("--manifest-out", type=Path, help=f"Review manifest path. Defaults to <workspace>/review/{DEFAULT_REVIEW_MANIFEST_NAME}.")
|
||||
parser.add_argument("--sample-every", type=float, default=4.0, help="Seconds between regular video samples.")
|
||||
parser.add_argument("--seek-sampling", action="store_true", help="Seek directly to sampled frames instead of sequentially grabbing through each segment.")
|
||||
@@ -110,6 +117,54 @@ def safe_key(text: str) -> str:
|
||||
return text.replace("/", "_").replace("|", "_").replace(":", "_")
|
||||
|
||||
|
||||
def model_bundle_fingerprint() -> str:
|
||||
digest = hashlib.sha256()
|
||||
for path in (slv.US_DETECTOR_MODEL_PATH, slv.US_CLASSIFIER_MODEL_PATH):
|
||||
resolved = Path(path).expanduser().resolve()
|
||||
digest.update(resolved.name.encode("utf-8"))
|
||||
with resolved.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def mining_configuration_fingerprint(args: argparse.Namespace, model_fingerprint: str) -> str:
|
||||
config = {
|
||||
"schema_version": MINING_RUN_SCHEMA_VERSION,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"model_only": args.model_only,
|
||||
"sample_every": args.sample_every,
|
||||
"transition_radius": args.transition_radius,
|
||||
"transition_step": args.transition_step,
|
||||
"max_frames_per_route": args.max_frames_per_route,
|
||||
"max_positives_per_route": args.max_positives_per_route,
|
||||
"max_negatives_per_route": args.max_negatives_per_route,
|
||||
"positive_min_score": args.positive_min_score,
|
||||
"no_map_min_score": args.no_map_min_score,
|
||||
"min_proposal_confidence": args.min_proposal_confidence,
|
||||
"min_width": args.min_width,
|
||||
"min_height": args.min_height,
|
||||
"next_limit_distance": args.next_limit_distance,
|
||||
}
|
||||
digest = hashlib.sha256(json.dumps(config, sort_keys=True).encode("utf-8"))
|
||||
for source_path in (Path(__file__), Path(score_frame.__code__.co_filename), Path(slv.__file__)):
|
||||
digest.update(source_path.resolve().read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def resolve_run_id(requested: str | None, model_fingerprint: str, mining_fingerprint: str) -> str:
|
||||
if not requested:
|
||||
return ""
|
||||
run_id = (
|
||||
f"model_{model_fingerprint[:12]}_run_{mining_fingerprint[:12]}"
|
||||
if requested == "auto"
|
||||
else safe_key(requested.strip())
|
||||
)
|
||||
if not run_id:
|
||||
raise ValueError("--run-id must not be empty")
|
||||
return run_id
|
||||
|
||||
|
||||
def parse_route_id(text: str) -> tuple[str, str, str]:
|
||||
normalized = text.strip().replace("|", "/")
|
||||
if "/" not in normalized:
|
||||
@@ -390,6 +445,8 @@ def should_keep_positive(scored: dict, speed_limit_mph: int, consistent_count: i
|
||||
return False
|
||||
if float(scored["proposal_confidence"]) < args.min_proposal_confidence:
|
||||
return False
|
||||
if args.model_only and relation not in ("agree_current", "agree_next"):
|
||||
return False
|
||||
if relation in ("agree_current", "agree_next"):
|
||||
return float(scored["score"]) >= args.positive_min_score and consistent_count >= 1
|
||||
return float(scored["score"]) >= args.no_map_min_score and consistent_count >= 2
|
||||
@@ -443,16 +500,33 @@ def write_sample(frame_bgr, image_path: Path, label_path: Path, label_text: str,
|
||||
return True
|
||||
|
||||
|
||||
def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse.Namespace, workspace: Path, clip_root: Path, manifest_path: Path, route_state_dir: Path) -> dict[str, int | str | float]:
|
||||
def mine_route(
|
||||
route_id: str,
|
||||
daemon: slv.SpeedLimitVisionDaemon,
|
||||
args: argparse.Namespace,
|
||||
output_root: Path,
|
||||
clip_root: Path,
|
||||
manifest_path: Path,
|
||||
route_state_dir: Path,
|
||||
run_id: str,
|
||||
mining_fingerprint: str,
|
||||
model_fingerprint: str,
|
||||
) -> dict[str, int | str | float]:
|
||||
route_id, dongle_id, log_id = parse_route_id(route_id)
|
||||
route_key = safe_key(route_id)
|
||||
state_path = route_state_dir / f"{route_key}.json"
|
||||
if run_id and state_path.exists():
|
||||
state = json.loads(state_path.read_text(encoding="utf-8"))
|
||||
if state.get("model_fingerprint") != model_fingerprint or state.get("mining_fingerprint") != mining_fingerprint:
|
||||
raise RuntimeError(
|
||||
f"Mining state fingerprint mismatch for {route_id}. Use a new --run-id or output root instead of mixing runs."
|
||||
)
|
||||
if state_path.exists() and not args.force:
|
||||
return {"route": route_id, "status": "skipped", "positives": 0, "negatives": 0, "scored": 0}
|
||||
|
||||
split = route_split(route_id, args.val_route_modulo, args.val_route_remainder)
|
||||
image_dir = ensure_dir(workspace / "detector" / "images" / split)
|
||||
label_dir = ensure_dir(workspace / "detector" / "labels" / split)
|
||||
image_dir = ensure_dir(output_root / "detector" / "images" / split)
|
||||
label_dir = ensure_dir(output_root / "detector" / "labels" / split)
|
||||
segments = route_segments(clip_root, log_id)
|
||||
if not segments:
|
||||
return {"route": route_id, "status": "missing_segments", "positives": 0, "negatives": 0, "scored": 0}
|
||||
@@ -487,7 +561,7 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
continue
|
||||
|
||||
scored_frames += 1
|
||||
scored = score_frame(daemon, frame_bgr)
|
||||
scored = score_frame(daemon, frame_bgr, use_ocr=not args.model_only)
|
||||
context = nearest_context(contexts, time_s)
|
||||
|
||||
if scored is None:
|
||||
@@ -501,6 +575,9 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
negatives += 1
|
||||
route_rows.append({
|
||||
"record_key": record_key,
|
||||
"mining_run_id": run_id,
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"route": route_id,
|
||||
"dongle_id": dongle_id,
|
||||
"log_id": log_id,
|
||||
@@ -548,6 +625,9 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
bbox = ",".join(str(value) for value in scored["box"])
|
||||
route_rows.append({
|
||||
"record_key": record_key,
|
||||
"mining_run_id": run_id,
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"route": route_id,
|
||||
"dongle_id": dongle_id,
|
||||
"log_id": log_id,
|
||||
@@ -585,9 +665,13 @@ def mine_route(route_id: str, daemon: slv.SpeedLimitVisionDaemon, args: argparse
|
||||
|
||||
if not args.dry_run:
|
||||
merge_review_rows(manifest_path, route_rows)
|
||||
merge_value_labels(workspace / "classifier" / "value_labels.csv", value_rows)
|
||||
merge_value_labels(output_root / "classifier" / "value_labels.csv", value_rows)
|
||||
state_path.write_text(json.dumps({
|
||||
"route": route_id,
|
||||
"mining_run_id": run_id,
|
||||
"mining_fingerprint": mining_fingerprint,
|
||||
"model_fingerprint": model_fingerprint,
|
||||
"model_only": args.model_only,
|
||||
"status": "mined",
|
||||
"positives": positives,
|
||||
"negatives": negatives,
|
||||
@@ -613,20 +697,41 @@ def main() -> int:
|
||||
args = parse_args()
|
||||
workspace = resolve_workspace(args.workspace)
|
||||
clip_root = args.clip_root.expanduser().resolve()
|
||||
manifest_path = args.manifest_out.expanduser().resolve() if args.manifest_out else (ensure_dir(workspace / "review") / DEFAULT_REVIEW_MANIFEST_NAME)
|
||||
route_state_dir = ensure_dir(workspace / "review" / "route_training_samples_state")
|
||||
routes = read_routes(args)
|
||||
if not routes:
|
||||
raise SystemExit("No routes to mine. Pass route ids, --routes-file, or completed bundle markers.")
|
||||
|
||||
configure_models(args.models_dir)
|
||||
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = not args.model_only
|
||||
model_fingerprint = model_bundle_fingerprint()
|
||||
mining_fingerprint = mining_configuration_fingerprint(args, model_fingerprint)
|
||||
run_id = resolve_run_id(args.run_id, model_fingerprint, mining_fingerprint)
|
||||
if args.output_root:
|
||||
output_root = args.output_root.expanduser().resolve()
|
||||
elif run_id:
|
||||
output_root = workspace / "staging" / "route_mining" / run_id
|
||||
else:
|
||||
output_root = workspace
|
||||
manifest_path = args.manifest_out.expanduser().resolve() if args.manifest_out else (ensure_dir(output_root / "review") / DEFAULT_REVIEW_MANIFEST_NAME)
|
||||
route_state_dir = ensure_dir(output_root / "review" / "route_training_samples_state")
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
|
||||
total_positive = 0
|
||||
total_negative = 0
|
||||
total_scored = 0
|
||||
for index, route in enumerate(routes, start=1):
|
||||
result = mine_route(route, daemon, args, workspace, clip_root, manifest_path, route_state_dir)
|
||||
result = mine_route(
|
||||
route,
|
||||
daemon,
|
||||
args,
|
||||
output_root,
|
||||
clip_root,
|
||||
manifest_path,
|
||||
route_state_dir,
|
||||
run_id,
|
||||
mining_fingerprint,
|
||||
model_fingerprint,
|
||||
)
|
||||
total_positive += int(result.get("positives", 0))
|
||||
total_negative += int(result.get("negatives", 0))
|
||||
total_scored += int(result.get("scored", 0))
|
||||
@@ -641,6 +746,8 @@ def main() -> int:
|
||||
flush=True,
|
||||
)
|
||||
print(f"Review manifest: {manifest_path}", flush=True)
|
||||
print(f"Model fingerprint: {model_fingerprint}", flush=True)
|
||||
print(f"Mining fingerprint: {mining_fingerprint}", flush=True)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -193,6 +193,21 @@ def parse_args() -> argparse.Namespace:
|
||||
parser.add_argument("--right-roi-min-confidence", type=float, help="Override the right ROI detector minimum confidence.")
|
||||
parser.add_argument("--classifier-min-confidence", type=float, help="Override the value classifier confidence threshold.")
|
||||
parser.add_argument("--full-frame-ocr", action="store_true", help="Enable the expensive full-frame OCR fallback during replay.")
|
||||
crop_ocr_group = parser.add_mutually_exclusive_group()
|
||||
crop_ocr_group.add_argument("--crop-ocr", action="store_true", dest="crop_ocr", default=None, help="Enable crop OCR confirmation during replay.")
|
||||
crop_ocr_group.add_argument("--no-crop-ocr", action="store_false", dest="crop_ocr", help="Replay the model-only detector/classifier path.")
|
||||
parser.add_argument("--low-speed-change-consistent-detections", type=int, help="Override reads required to change from 30+ mph to below 30 mph.")
|
||||
parser.add_argument(
|
||||
"--allow-low-speed-strong-consensus",
|
||||
action="store_true",
|
||||
help="Permit a strong multi-crop consensus to publish a low-speed change from one frame.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--enable-strong-model-consensus",
|
||||
action="store_true",
|
||||
help="Mark three agreeing high-confidence regulatory model crops as strong consensus.",
|
||||
)
|
||||
parser.add_argument("--initial-speed-limit", type=int, default=0, help="Seed route replay with a currently published speed limit.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
@@ -299,6 +314,14 @@ def configure_runtime_options(args: argparse.Namespace) -> None:
|
||||
|
||||
if args.full_frame_ocr:
|
||||
slv.FULL_FRAME_OCR_FALLBACK_ENABLED = True
|
||||
if args.crop_ocr is not None:
|
||||
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = args.crop_ocr
|
||||
if args.low_speed_change_consistent_detections is not None:
|
||||
slv.LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = args.low_speed_change_consistent_detections
|
||||
if args.allow_low_speed_strong_consensus:
|
||||
slv.LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS = True
|
||||
if args.enable_strong_model_consensus:
|
||||
slv.DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED = True
|
||||
|
||||
if args.right_roi_bounds:
|
||||
parts = [float(part.strip()) for part in args.right_roi_bounds.split(",")]
|
||||
@@ -347,6 +370,7 @@ def replay_route(
|
||||
measured_inference_seconds: float,
|
||||
measured_base_inference_seconds: float | None = None,
|
||||
measured_classifier_forward_seconds: float = 0.0,
|
||||
initial_speed_limit_mph: int = 0,
|
||||
) -> tuple[RouteSummary, list[dict[str, str]]]:
|
||||
daemon = RouteReplayDaemon(
|
||||
runtime_context,
|
||||
@@ -354,6 +378,7 @@ def replay_route(
|
||||
measured_base_inference_seconds,
|
||||
measured_classifier_forward_seconds,
|
||||
)
|
||||
daemon.published_speed_limit_mph = initial_speed_limit_mph
|
||||
for segment_path in segments:
|
||||
segment = segment_index(segment_path)
|
||||
capture = cv2.VideoCapture(str(segment_path))
|
||||
@@ -397,11 +422,8 @@ def replay_route(
|
||||
|
||||
capture.release()
|
||||
if progress:
|
||||
print(
|
||||
f" seg {segment:02d}: sampled={daemon.sampled_frames} inference={daemon.inference_frames} "
|
||||
f"events={len(daemon.events)}",
|
||||
flush=True,
|
||||
)
|
||||
counts = f"sampled={daemon.sampled_frames} inference={daemon.inference_frames} events={len(daemon.events)}"
|
||||
print(f" seg {segment:02d}: {counts}", flush=True)
|
||||
|
||||
return summarize(log_id, len(segments), runtime_context is not None, daemon), daemon.events
|
||||
|
||||
@@ -490,6 +512,7 @@ def main() -> int:
|
||||
args.measured_inference_seconds,
|
||||
args.measured_base_inference_seconds,
|
||||
args.measured_classifier_forward_seconds,
|
||||
args.initial_speed_limit,
|
||||
)
|
||||
all_events.extend((log_id, event) for event in events)
|
||||
summary_line = "".join((
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
|
||||
if __package__ in (None, ""):
|
||||
import sys
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from compare_manual_review_queues import classify_change # type: ignore
|
||||
from mine_route_training_samples import model_bundle_fingerprint # type: ignore
|
||||
from replay_route_runtime import configure_models # type: ignore
|
||||
else:
|
||||
from .compare_manual_review_queues import classify_change
|
||||
from .mine_route_training_samples import model_bundle_fingerprint
|
||||
from .replay_route_runtime import configure_models
|
||||
|
||||
|
||||
EXTRA_FIELDS = (
|
||||
"comparison_change",
|
||||
"before_speed_limit_mph",
|
||||
"before_confidence",
|
||||
"rescore_status",
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Rescore stored manual-review crops with a new value classifier.")
|
||||
parser.add_argument("--input", type=Path, required=True, help="Baseline manual_review_queue.csv.")
|
||||
parser.add_argument("--models-dir", type=Path, required=True, help="Candidate detector/classifier ONNX directory.")
|
||||
parser.add_argument("--output", type=Path, required=True, help="Rescored review-compatible output CSV.")
|
||||
parser.add_argument("--confidence-delta", type=float, default=0.05, help="Minimum confidence-only change to report.")
|
||||
parser.add_argument("--shard-count", type=int, default=1, help="Number of deterministic row shards.")
|
||||
parser.add_argument("--shard-index", type=int, default=0, help="Zero-based row shard processed by this invocation.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def rescore_row(
|
||||
row: dict[str, str],
|
||||
daemon: slv.SpeedLimitVisionDaemon,
|
||||
model_fingerprint: str,
|
||||
confidence_delta: float,
|
||||
) -> dict[str, str]:
|
||||
output = dict(row)
|
||||
before_speed = row.get("candidate_speed_limit_mph", "")
|
||||
before_confidence = row.get("candidate_confidence", "")
|
||||
output["before_speed_limit_mph"] = before_speed
|
||||
output["before_confidence"] = before_confidence
|
||||
output["model_fingerprint"] = model_fingerprint
|
||||
|
||||
crop_path = Path(row.get("crop_path", "")).expanduser()
|
||||
crop = cv2.imread(str(crop_path)) if crop_path.is_file() else None
|
||||
if crop is None:
|
||||
output["comparison_change"] = "unreadable"
|
||||
output["rescore_status"] = "unreadable"
|
||||
return output
|
||||
|
||||
result = daemon._classify_speed_limit_from_model(crop)
|
||||
if result is None:
|
||||
output["candidate_speed_limit_mph"] = ""
|
||||
output["candidate_confidence"] = ""
|
||||
output["model_read"] = ""
|
||||
else:
|
||||
speed_limit_mph, confidence = result
|
||||
output["candidate_speed_limit_mph"] = str(int(speed_limit_mph))
|
||||
output["candidate_confidence"] = f"{float(confidence):.8f}"
|
||||
output["model_read"] = f"{int(speed_limit_mph)}@{float(confidence):.3f}"
|
||||
|
||||
change = classify_change(row, output, confidence_delta)
|
||||
output["comparison_change"] = change or "unchanged"
|
||||
output["rescore_status"] = "rescored_crop"
|
||||
return output
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
if args.shard_count <= 0 or not 0 <= args.shard_index < args.shard_count:
|
||||
raise ValueError("--shard-index must be within --shard-count")
|
||||
|
||||
configure_models(args.models_dir)
|
||||
slv.DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = False
|
||||
daemon = slv.SpeedLimitVisionDaemon(use_runtime=False)
|
||||
fingerprint = model_bundle_fingerprint()
|
||||
input_path = args.input.expanduser().resolve()
|
||||
with input_path.open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
input_fields = list(reader.fieldnames or [])
|
||||
rows = [row for index, row in enumerate(reader) if index % args.shard_count == args.shard_index]
|
||||
|
||||
output_rows: list[dict[str, str]] = []
|
||||
changes: Counter[str] = Counter()
|
||||
transitions: Counter[str] = Counter()
|
||||
for index, row in enumerate(rows, start=1):
|
||||
rescored = rescore_row(row, daemon, fingerprint, args.confidence_delta)
|
||||
output_rows.append(rescored)
|
||||
change = rescored["comparison_change"]
|
||||
changes[change] += 1
|
||||
before_speed = rescored["before_speed_limit_mph"] or "none"
|
||||
after_speed = rescored.get("candidate_speed_limit_mph", "") or "none"
|
||||
if change != "unchanged":
|
||||
transitions[f"{before_speed}->{after_speed}"] += 1
|
||||
if index % 1000 == 0:
|
||||
print(f"Rescored {index}/{len(rows)} crops", flush=True)
|
||||
|
||||
output_path = args.output.expanduser().resolve()
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fieldnames = [*input_fields, *(field for field in EXTRA_FIELDS if field not in input_fields)]
|
||||
with output_path.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(output_rows)
|
||||
|
||||
summary = {
|
||||
"input": str(input_path),
|
||||
"output": str(output_path),
|
||||
"models_dir": str(args.models_dir.expanduser().resolve()),
|
||||
"model_fingerprint": fingerprint,
|
||||
"shard_count": args.shard_count,
|
||||
"shard_index": args.shard_index,
|
||||
"rows": len(output_rows),
|
||||
"changes": dict(sorted(changes.items())),
|
||||
"transitions": dict(sorted(transitions.items(), key=lambda item: (-item[1], item[0]))),
|
||||
}
|
||||
output_path.with_suffix(".json").write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
|
||||
from collections import Counter, defaultdict, deque
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
PRIORITY_SPEED_ORDER = (60, 65, 55, 50, 45, 40, 35, 30, 25, 20, 70, 15, 75)
|
||||
COMPARISON_PRIORITY_BONUS = {
|
||||
"value_changed": 4.0,
|
||||
"gained_read": 3.0,
|
||||
"lost_read": 3.0,
|
||||
"added_proposal": 1.0,
|
||||
"removed_proposal": 1.0,
|
||||
}
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Select a diverse, high-value subset from a raw speed-limit review queue.")
|
||||
parser.add_argument("--input", type=Path, required=True, help="Raw manual_review_queue.csv.")
|
||||
parser.add_argument("--output", type=Path, required=True, help="Selected manual_review_queue.csv.")
|
||||
parser.add_argument("--max-rows", type=int, default=1000, help="Maximum selected rows.")
|
||||
parser.add_argument("--max-per-route", type=int, default=30, help="Maximum selected rows from one route.")
|
||||
parser.add_argument("--max-per-speed", type=int, default=140, help="Maximum rows for each predicted speed.")
|
||||
parser.add_argument(
|
||||
"--max-primary-speed",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Optional per-speed cap for the primary 30-65 mph range; defaults to --max-per-speed.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-speed-20",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Optional cap for 20 mph rows; defaults to --max-per-speed.",
|
||||
)
|
||||
parser.add_argument("--max-no-read", type=int, default=220, help="Maximum detector proposals without a value read.")
|
||||
parser.add_argument("--max-school", type=int, default=100, help="Maximum school-zone candidates.")
|
||||
parser.add_argument("--max-advisory", type=int, default=100, help="Maximum advisory candidates.")
|
||||
parser.add_argument(
|
||||
"--min-seconds-per-route-speed",
|
||||
type=float,
|
||||
default=3.0,
|
||||
help="Minimum spacing between selected rows from the same route, segment, and predicted-speed bucket.",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def read_rows(path: Path) -> tuple[list[str], list[dict[str, str]]]:
|
||||
with path.expanduser().resolve().open("r", encoding="utf-8", newline="") as handle:
|
||||
reader = csv.DictReader(handle)
|
||||
return list(reader.fieldnames or []), list(reader)
|
||||
|
||||
|
||||
def predicted_speed(row: dict[str, str]) -> int:
|
||||
text = (row.get("candidate_speed_limit_mph") or "").strip()
|
||||
if not text and row.get("comparison_change") == "lost_read":
|
||||
text = (row.get("before_speed_limit_mph") or "").strip()
|
||||
try:
|
||||
return int(float(text)) if text else 0
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
|
||||
def bucket_name(row: dict[str, str]) -> str:
|
||||
detector_class = row.get("detector_class", "")
|
||||
if detector_class == "school_zone_speed_limit":
|
||||
return "school"
|
||||
if detector_class == "advisory_speed_limit":
|
||||
return "advisory"
|
||||
speed = predicted_speed(row)
|
||||
return f"speed_{speed}" if speed else "no_read"
|
||||
|
||||
|
||||
def priority(row: dict[str, str]) -> tuple[float, float, float, str]:
|
||||
review_priority = (
|
||||
float(row.get("review_priority") or 0.0) +
|
||||
COMPARISON_PRIORITY_BONUS.get(row.get("comparison_change", ""), 0.0)
|
||||
)
|
||||
proposal_confidence = float(row.get("proposal_confidence") or 0.0)
|
||||
candidate_confidence = float(row.get("candidate_confidence") or 0.0)
|
||||
return review_priority, proposal_confidence, candidate_confidence, row.get("record_key", "")
|
||||
|
||||
|
||||
def temporal_key(row: dict[str, str]) -> tuple[str, str, str, float] | None:
|
||||
try:
|
||||
frame_time_s = float(row.get("frame_time_s", ""))
|
||||
except ValueError:
|
||||
return None
|
||||
return row.get("route", ""), row.get("segment", ""), bucket_name(row), frame_time_s
|
||||
|
||||
|
||||
def round_robin_routes(rows: list[dict[str, str]]):
|
||||
by_route: dict[str, deque[dict[str, str]]] = defaultdict(deque)
|
||||
for row in sorted(rows, key=priority, reverse=True):
|
||||
by_route[row.get("route", "")].append(row)
|
||||
route_order = deque(sorted(by_route, key=lambda route: priority(by_route[route][0]), reverse=True))
|
||||
while route_order:
|
||||
route = route_order.popleft()
|
||||
yield by_route[route].popleft()
|
||||
if by_route[route]:
|
||||
route_order.append(route)
|
||||
|
||||
|
||||
def select_rows(rows: list[dict[str, str]], args: argparse.Namespace) -> list[dict[str, str]]:
|
||||
buckets: dict[str, list[dict[str, str]]] = defaultdict(list)
|
||||
for row in rows:
|
||||
if row.get("detector_class") == "negative_empty":
|
||||
continue
|
||||
buckets[bucket_name(row)].append(row)
|
||||
|
||||
max_primary_speed = int(getattr(args, "max_primary_speed", 0))
|
||||
max_speed_20 = int(getattr(args, "max_speed_20", 0))
|
||||
primary_limit = max_primary_speed if max_primary_speed > 0 else args.max_per_speed
|
||||
speed_20_limit = max_speed_20 if max_speed_20 > 0 else args.max_per_speed
|
||||
limits = {
|
||||
f"speed_{speed}": (
|
||||
primary_limit if 30 <= speed <= 65 else speed_20_limit if speed == 20 else args.max_per_speed
|
||||
)
|
||||
for speed in PRIORITY_SPEED_ORDER
|
||||
}
|
||||
limits.update({"school": args.max_school, "advisory": args.max_advisory, "no_read": args.max_no_read})
|
||||
ordered_buckets = [f"speed_{speed}" for speed in PRIORITY_SPEED_ORDER] + ["school", "advisory", "no_read"]
|
||||
ordered_buckets.extend(sorted(set(buckets) - set(ordered_buckets)))
|
||||
|
||||
selected: list[dict[str, str]] = []
|
||||
selected_keys: set[str] = set()
|
||||
route_counts: Counter[str] = Counter()
|
||||
bucket_counts: Counter[str] = Counter()
|
||||
selected_times: dict[tuple[str, str, str], list[float]] = defaultdict(list)
|
||||
min_spacing = max(float(getattr(args, "min_seconds_per_route_speed", 0.0)), 0.0)
|
||||
|
||||
def try_add(row: dict[str, str], bucket: str) -> bool:
|
||||
key = row.get("record_key", "")
|
||||
route = row.get("route", "")
|
||||
if not key or key in selected_keys or route_counts[route] >= args.max_per_route:
|
||||
return False
|
||||
if bucket_counts[bucket] >= limits.get(bucket, args.max_per_speed):
|
||||
return False
|
||||
time_key = temporal_key(row)
|
||||
if time_key is not None and min_spacing > 0.0:
|
||||
route_key = time_key[:3]
|
||||
if any(abs(time_key[3] - selected_time) < min_spacing for selected_time in selected_times[route_key]):
|
||||
return False
|
||||
selected.append(row)
|
||||
selected_keys.add(key)
|
||||
route_counts[route] += 1
|
||||
bucket_counts[bucket] += 1
|
||||
if time_key is not None:
|
||||
selected_times[time_key[:3]].append(time_key[3])
|
||||
return True
|
||||
|
||||
iterators = {bucket: iter(round_robin_routes(buckets[bucket])) for bucket in ordered_buckets if buckets.get(bucket)}
|
||||
active = deque(bucket for bucket in ordered_buckets if bucket in iterators)
|
||||
while active and len(selected) < args.max_rows:
|
||||
bucket = active.popleft()
|
||||
iterator = iterators[bucket]
|
||||
added = False
|
||||
for row in iterator:
|
||||
if try_add(row, bucket):
|
||||
added = True
|
||||
break
|
||||
if added and bucket_counts[bucket] < limits.get(bucket, args.max_per_speed):
|
||||
active.append(bucket)
|
||||
|
||||
if len(selected) < args.max_rows:
|
||||
for row in sorted(rows, key=priority, reverse=True):
|
||||
if len(selected) >= args.max_rows:
|
||||
break
|
||||
try_add(row, bucket_name(row))
|
||||
|
||||
return sorted(selected, key=priority, reverse=True)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
fieldnames, rows = read_rows(args.input)
|
||||
selected = select_rows(rows, args)
|
||||
output = args.output.expanduser().resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with output.open("w", encoding="utf-8", newline="") as handle:
|
||||
writer = csv.DictWriter(handle, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(selected)
|
||||
|
||||
summary = {
|
||||
"input": str(args.input.expanduser().resolve()),
|
||||
"output": str(output),
|
||||
"input_rows": len(rows),
|
||||
"selected_rows": len(selected),
|
||||
"routes": len({row.get("route", "") for row in selected}),
|
||||
"buckets": dict(sorted(Counter(bucket_name(row) for row in selected).items())),
|
||||
"min_seconds_per_route_speed": args.min_seconds_per_route_speed,
|
||||
}
|
||||
summary_path = output.with_name("manual_review_selection_summary.json")
|
||||
summary_path.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
print(json.dumps(summary, indent=2, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,8 +4,8 @@ from __future__ import annotations
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import time
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
@@ -40,7 +40,8 @@ HTML = r"""<!doctype html>
|
||||
<style>
|
||||
:root { color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
||||
body { margin: 0; background: #111; color: #eee; }
|
||||
header { display: flex; align-items: center; gap: 16px; padding: 10px 14px; background: #1b1b1b; border-bottom: 1px solid #333; position: sticky; top: 0; z-index: 2; }
|
||||
header { display: flex; align-items: center; gap: 16px; padding: 10px 14px; background: #1b1b1b; }
|
||||
header { border-bottom: 1px solid #333; position: sticky; top: 0; z-index: 2; }
|
||||
button, select, input, textarea { background: #222; color: #eee; border: 1px solid #555; border-radius: 6px; padding: 7px 9px; font: inherit; }
|
||||
button { cursor: pointer; }
|
||||
button:hover { background: #333; }
|
||||
@@ -63,6 +64,7 @@ HTML = r"""<!doctype html>
|
||||
.speed button { min-width: 42px; }
|
||||
.muted { color: #aaa; }
|
||||
.status { white-space: nowrap; }
|
||||
.hint { margin-bottom: 10px; padding: 8px; border-left: 3px solid #3f7ec8; background: #20252b; color: #dbeaff; }
|
||||
@media (max-width: 980px) { main { grid-template-columns: 1fr; } img { max-height: 45vh; } }
|
||||
</style>
|
||||
</head>
|
||||
@@ -79,7 +81,8 @@ HTML = r"""<!doctype html>
|
||||
<option value="negative">Negatives</option>
|
||||
</select>
|
||||
<span class="status" id="status"></span>
|
||||
<span class="muted">Keys: Space/p accept model, type speed to correct, u uncertain, i/x ignore, Enter save correction, j/k next/prev, s school, r regulatory, a advisory</span>
|
||||
<span class="muted">Keys: Space/p accept model, type speed to correct, u uncertain, i/x ignore,
|
||||
Enter save correction, j/k next/prev, s school, r regulatory, a advisory</span>
|
||||
</header>
|
||||
<main>
|
||||
<section class="images">
|
||||
@@ -93,6 +96,7 @@ HTML = r"""<!doctype html>
|
||||
</div>
|
||||
</section>
|
||||
<aside class="panel">
|
||||
<div class="hint" id="taskHint" hidden></div>
|
||||
<div class="meta" id="meta"></div>
|
||||
<h3>Speed</h3>
|
||||
<div class="buttons speed" id="speedButtons"></div>
|
||||
@@ -270,9 +274,26 @@ function inferredType(row) {
|
||||
return "regulatory";
|
||||
}
|
||||
|
||||
function auditHint(row) {
|
||||
if (!row) return "";
|
||||
const previous = row.before_speed_limit_mph || "no value";
|
||||
const candidate = row.candidate_speed_limit_mph || "no value";
|
||||
if (row.comparison_change === "advisory_type_reaudit") {
|
||||
return `Recheck sign type for reviewed ${candidate}: Space = regulatory; A then Space = advisory; S then Space = school zone.`;
|
||||
}
|
||||
if (row.comparison_change === "lost_read") {
|
||||
return `Current model rejected previous ${previous}. Type the speed if readable; press i if it is not a speed sign.`;
|
||||
}
|
||||
if (row.comparison_change === "gained_read") return `Current model gained ${candidate}. Press Space if correct, or type the correct speed.`;
|
||||
if (row.comparison_change === "value_changed") {
|
||||
return `Model changed ${previous} to ${candidate}. Press Space for the current value, or type the correct speed.`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function ensureSpeedSignType() {
|
||||
if (draft.review_sign_type === "not_speed_limit") {
|
||||
draft.review_sign_type = inferredType(current);
|
||||
draft.review_sign_type = "regulatory";
|
||||
setActive("#typeButtons button", "type", draft.review_sign_type);
|
||||
}
|
||||
}
|
||||
@@ -329,12 +350,15 @@ function render() {
|
||||
draft = {
|
||||
review_status: current.review_status || "",
|
||||
review_speed_limit_mph: current.review_speed_limit_mph || current.candidate_speed_limit_mph || "",
|
||||
review_sign_type: current.review_sign_type || inferredType(current),
|
||||
review_sign_type: current.review_sign_type || "regulatory",
|
||||
review_bbox: current.review_bbox || current.bbox || "",
|
||||
review_ignore_reason: current.review_ignore_reason || "",
|
||||
review_notes: current.review_notes || "",
|
||||
};
|
||||
qs("#status").textContent = `${index + 1}/${rows.length}`;
|
||||
const hint = auditHint(current);
|
||||
qs("#taskHint").textContent = hint;
|
||||
qs("#taskHint").hidden = !hint;
|
||||
qs("#cropImg").src = current.crop_path ? `/media/${current.record_key}/crop` : "";
|
||||
qs("#frameImg").src = `/media/${current.record_key}/frame`;
|
||||
setBBox(draft.review_bbox, false);
|
||||
@@ -343,13 +367,18 @@ function render() {
|
||||
setActive("#speedButtons button", "speed", draft.review_speed_limit_mph);
|
||||
setActive("#typeButtons button", "type", draft.review_sign_type);
|
||||
setActive("#statusButtons button", "status", draft.review_status);
|
||||
qs("#acceptPredBtn").disabled = !current.candidate_speed_limit_mph;
|
||||
const mapSummary = `${current.map_relation} current=${current.map_current_speed_limit_mph}`;
|
||||
const nextMapSummary = `next=${current.map_next_speed_limit_mph} dist=${current.map_next_speed_limit_distance_m}`;
|
||||
qs("#meta").innerHTML = [
|
||||
["record", current.record_key],
|
||||
["change", current.comparison_change],
|
||||
["previous", `${current.before_speed_limit_mph || "none"} @ ${current.before_confidence || ""}`],
|
||||
["candidate", `${current.candidate_speed_limit_mph || "none"} @ ${current.candidate_confidence || ""}`],
|
||||
["class", `${current.detector_class} (${current.proposal_confidence})`],
|
||||
["bbox", draft.review_bbox],
|
||||
["reasons", current.review_reasons],
|
||||
["map", `${current.map_relation} current=${current.map_current_speed_limit_mph} next=${current.map_next_speed_limit_mph} dist=${current.map_next_speed_limit_distance_m}`],
|
||||
["map", `${mapSummary} ${nextMapSummary}`],
|
||||
["reads", current.read_sources],
|
||||
["route", current.route],
|
||||
["time", `seg ${current.segment} @ ${current.frame_time_s}s`],
|
||||
@@ -390,7 +419,6 @@ qs("#clearBBoxBtn").onclick = () => setBBox("");
|
||||
qs("#acceptPredBtn").onclick = () => {
|
||||
if (!current) return;
|
||||
draft.review_speed_limit_mph = current.candidate_speed_limit_mph || "";
|
||||
draft.review_sign_type = inferredType(current);
|
||||
save(true, "accepted");
|
||||
};
|
||||
qsa("#typeButtons button").forEach(btn => btn.onclick = () => {
|
||||
@@ -529,7 +557,7 @@ class ReviewServer(ThreadingHTTPServer):
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server: ReviewServer
|
||||
|
||||
def log_message(self, format, *args): # noqa: A003
|
||||
def log_message(self, _format, *args):
|
||||
return
|
||||
|
||||
def send_json(self, data, status=HTTPStatus.OK):
|
||||
@@ -548,7 +576,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self): # noqa: N802
|
||||
def do_GET(self):
|
||||
parsed = urlparse(self.path)
|
||||
if parsed.path == "/":
|
||||
self.send_text(HTML)
|
||||
@@ -582,7 +610,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
return
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
|
||||
def do_POST(self): # noqa: N802
|
||||
def do_POST(self):
|
||||
if urlparse(self.path).path != "/api/review":
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
@@ -596,7 +624,7 @@ class Handler(BaseHTTPRequestHandler):
|
||||
if record_key not in self.server.row_by_key:
|
||||
self.send_error(HTTPStatus.BAD_REQUEST, "Unknown record_key")
|
||||
return
|
||||
label = {"record_key": record_key, "reviewed_at_unix": f"{time.time():.3f}"}
|
||||
label = {"record_key": record_key, "reviewed_at_unix": f"{datetime.now(UTC).timestamp():.3f}"}
|
||||
for field in QUEUE_REVIEW_FIELDS:
|
||||
label[field] = str(payload.get(field) or "")
|
||||
self.server.labels[record_key] = label
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
import importlib.util
|
||||
|
||||
from argparse import Namespace
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_local_module(name: str):
|
||||
path = Path(__file__).resolve().with_name(f"{name}.py")
|
||||
spec = importlib.util.spec_from_file_location(f"test_local_{name}", path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
import_queue = load_local_module("import_manual_review_queue")
|
||||
build_review_classifier = load_local_module("build_review_classifier_dataset")
|
||||
select_queue = load_local_module("select_manual_review_queue")
|
||||
compare_queues = load_local_module("compare_manual_review_queues")
|
||||
rescore_queue = load_local_module("rescore_manual_review_queue")
|
||||
is_classifier_reject = import_queue.is_classifier_reject
|
||||
split_for_key = import_queue.split_for_key
|
||||
split_group_key = import_queue.split_group_key
|
||||
select_rows = select_queue.select_rows
|
||||
|
||||
|
||||
def review_row(key: str, route: str, speed: int, priority: float) -> dict[str, str]:
|
||||
return {
|
||||
"record_key": key,
|
||||
"route": route,
|
||||
"detector_class": "regulatory_speed_limit",
|
||||
"candidate_speed_limit_mph": str(speed),
|
||||
"review_priority": str(priority),
|
||||
"proposal_confidence": "0.8",
|
||||
"candidate_confidence": "0.99",
|
||||
}
|
||||
|
||||
|
||||
def test_review_selection_balances_routes_and_speeds():
|
||||
rows = [
|
||||
*(review_row(f"a-{index}", "route-a", 30, 10 - index) for index in range(5)),
|
||||
*(review_row(f"b-{index}", "route-b", 65, 10 - index) for index in range(5)),
|
||||
]
|
||||
args = Namespace(
|
||||
max_rows=4,
|
||||
max_per_route=2,
|
||||
max_per_speed=2,
|
||||
max_no_read=2,
|
||||
max_school=2,
|
||||
max_advisory=2,
|
||||
)
|
||||
|
||||
selected = select_rows(rows, args)
|
||||
|
||||
assert len(selected) == 4
|
||||
assert sum(row["route"] == "route-a" for row in selected) == 2
|
||||
assert sum(row["route"] == "route-b" for row in selected) == 2
|
||||
|
||||
|
||||
def test_review_selection_prioritizes_model_disagreements():
|
||||
unchanged = review_row("unchanged", "route-a", 30, 5.0)
|
||||
changed = {**review_row("changed", "route-a", 30, 2.0), "comparison_change": "value_changed"}
|
||||
args = Namespace(max_rows=1, max_per_route=2, max_per_speed=2, max_no_read=2, max_school=2, max_advisory=2)
|
||||
|
||||
assert select_rows([unchanged, changed], args)[0]["record_key"] == "changed"
|
||||
|
||||
|
||||
def test_review_selection_deduplicates_adjacent_same_speed_frames():
|
||||
first = {**review_row("first", "route-a", 40, 5.0), "segment": "1", "frame_time_s": "10.0"}
|
||||
duplicate = {**review_row("duplicate", "route-a", 40, 4.0), "segment": "1", "frame_time_s": "11.0"}
|
||||
different_speed = {**review_row("different", "route-a", 45, 3.0), "segment": "1", "frame_time_s": "11.0"}
|
||||
args = Namespace(
|
||||
max_rows=3,
|
||||
max_per_route=3,
|
||||
max_per_speed=3,
|
||||
max_no_read=3,
|
||||
max_school=3,
|
||||
max_advisory=3,
|
||||
min_seconds_per_route_speed=3.0,
|
||||
)
|
||||
|
||||
selected_keys = {row["record_key"] for row in select_rows([first, duplicate, different_speed], args)}
|
||||
assert selected_keys == {"first", "different"}
|
||||
|
||||
|
||||
def test_lost_reads_remain_balanced_by_previous_speed():
|
||||
row = {
|
||||
**review_row("lost", "route-a", 0, 5.0),
|
||||
"candidate_speed_limit_mph": "",
|
||||
"before_speed_limit_mph": "55",
|
||||
"comparison_change": "lost_read",
|
||||
}
|
||||
|
||||
assert select_queue.predicted_speed(row) == 55
|
||||
assert select_queue.bucket_name(row) == "speed_55"
|
||||
|
||||
|
||||
def test_primary_speed_limits_override_general_limit():
|
||||
rows = [
|
||||
*(review_row(f"primary-{index}", f"route-p-{index}", 40, 10 - index) for index in range(3)),
|
||||
*(review_row(f"low-{index}", f"route-l-{index}", 15, 10 - index) for index in range(3)),
|
||||
]
|
||||
args = Namespace(
|
||||
max_rows=6,
|
||||
max_per_route=1,
|
||||
max_per_speed=1,
|
||||
max_primary_speed=3,
|
||||
max_speed_20=2,
|
||||
max_no_read=1,
|
||||
max_school=1,
|
||||
max_advisory=1,
|
||||
min_seconds_per_route_speed=0.0,
|
||||
)
|
||||
|
||||
selected = select_rows(rows, args)
|
||||
assert sum(select_queue.predicted_speed(row) == 40 for row in selected) == 3
|
||||
assert sum(select_queue.predicted_speed(row) == 15 for row in selected) == 1
|
||||
|
||||
|
||||
def test_manual_import_splits_adjacent_frames_by_route():
|
||||
rows = [{"record_key": f"frame-{index}", "route": "dongle/route"} for index in range(8)]
|
||||
splits = {split_for_key(split_group_key(row), 5, 0) for row in rows}
|
||||
|
||||
assert len(splits) == 1
|
||||
|
||||
|
||||
def test_only_reviewed_proposal_crops_become_classifier_rejects(tmp_path):
|
||||
crop_path = tmp_path / "crop.jpg"
|
||||
crop_path.write_bytes(b"crop")
|
||||
row = {
|
||||
"review_status": "ignore",
|
||||
"review_sign_type": "not_speed_limit",
|
||||
"detector_class": "regulatory_speed_limit",
|
||||
"crop_path": str(crop_path),
|
||||
}
|
||||
|
||||
assert is_classifier_reject(row)
|
||||
assert not is_classifier_reject({**row, "detector_class": "negative_empty"})
|
||||
assert not is_classifier_reject({**row, "review_status": "uncertain"})
|
||||
|
||||
|
||||
def test_advisory_positive_is_a_runtime_negative(tmp_path):
|
||||
crop_path = tmp_path / "crop.jpg"
|
||||
frame_path = tmp_path / "frame.jpg"
|
||||
crop_path.write_bytes(b"crop")
|
||||
frame_path.write_bytes(b"frame")
|
||||
row = {
|
||||
"record_key": "advisory",
|
||||
"review_status": "corrected",
|
||||
"review_sign_type": "advisory",
|
||||
"review_speed_limit_mph": "40",
|
||||
"crop_path": str(crop_path),
|
||||
"frame_path": str(frame_path),
|
||||
}
|
||||
|
||||
assert import_queue.is_advisory_positive(row)
|
||||
runtime_row = import_queue.runtime_row(row, "val", "advisory_negative")
|
||||
assert runtime_row["sample_type"] == "advisory_negative"
|
||||
assert runtime_row["speed_limit_mph"] == 40
|
||||
assert build_review_classifier.is_advisory(row)
|
||||
assert build_review_classifier.keep_advisory_reject({**row, "split": "val"}, 0.0)
|
||||
assert not build_review_classifier.keep_advisory_reject({**row, "split": "train"}, 0.0)
|
||||
|
||||
|
||||
def test_queue_comparison_distinguishes_gained_lost_and_changed_reads():
|
||||
no_read = {"candidate_speed_limit_mph": "", "candidate_confidence": ""}
|
||||
speed_20 = {"candidate_speed_limit_mph": "20", "candidate_confidence": "0.99"}
|
||||
speed_30 = {"candidate_speed_limit_mph": "30", "candidate_confidence": "0.98"}
|
||||
|
||||
assert compare_queues.classify_change(no_read, speed_20, 0.05) == "gained_read"
|
||||
assert compare_queues.classify_change(speed_20, no_read, 0.05) == "lost_read"
|
||||
assert compare_queues.classify_change(speed_20, speed_30, 0.05) == "value_changed"
|
||||
assert compare_queues.classify_change(speed_20, {**speed_20, "candidate_confidence": "0.97"}, 0.05) == ""
|
||||
|
||||
|
||||
def test_rescore_row_preserves_before_values_and_marks_gained_read(tmp_path):
|
||||
crop_path = tmp_path / "crop.jpg"
|
||||
import cv2
|
||||
import numpy as np
|
||||
cv2.imwrite(str(crop_path), np.zeros((32, 32, 3), dtype=np.uint8))
|
||||
daemon = type("Daemon", (), {"_classify_speed_limit_from_model": lambda self, crop: (20, 0.99)})()
|
||||
row = {
|
||||
"record_key": "candidate",
|
||||
"crop_path": str(crop_path),
|
||||
"candidate_speed_limit_mph": "",
|
||||
"candidate_confidence": "",
|
||||
}
|
||||
|
||||
rescored = rescore_queue.rescore_row(row, daemon, "model", 0.05)
|
||||
|
||||
assert rescored["before_speed_limit_mph"] == ""
|
||||
assert rescored["candidate_speed_limit_mph"] == "20"
|
||||
assert rescored["comparison_change"] == "gained_read"
|
||||
@@ -14,6 +14,7 @@ METRIC_MARGIN = 12
|
||||
FONT_SIZE = 35
|
||||
METER_TO_FOOT = 3.28084
|
||||
_WHITE_DIM = rl.Color(255, 255, 255, 85)
|
||||
_FTM_OVERRIDE_COLOR = rl.Color(239, 68, 68, 255)
|
||||
|
||||
def parse_hex_color(hex_str: str, default_color=rl.WHITE) -> rl.Color:
|
||||
if not hex_str:
|
||||
@@ -36,6 +37,17 @@ def parse_hex_color(hex_str: str, default_color=rl.WHITE) -> rl.Color:
|
||||
return default_color
|
||||
|
||||
|
||||
def resolve_effective_torque_value(custom_enabled: bool, custom_value: float,
|
||||
live_enabled: bool, live_value: float,
|
||||
stock_value: float, configured_value: float) -> float:
|
||||
"""Mirror controlsd torque-param precedence for the onroad diagnostics."""
|
||||
if custom_enabled:
|
||||
return custom_value
|
||||
if live_enabled:
|
||||
return live_value
|
||||
return stock_value if stock_value != 0.0 else configured_value
|
||||
|
||||
|
||||
class DeveloperSidebar:
|
||||
def __init__(self):
|
||||
self._params = Params()
|
||||
@@ -44,6 +56,7 @@ class DeveloperSidebar:
|
||||
self._cached_metrics = [0] * 7
|
||||
self._cached_force_auto_tune_off = False
|
||||
self._cached_force_auto_tune = False
|
||||
self._cached_ftm_trial_applied = False
|
||||
self._cached_friction_stock = 0.0
|
||||
self._cached_friction = 0.0
|
||||
self._cached_lat_stock = 0.0
|
||||
@@ -60,6 +73,7 @@ class DeveloperSidebar:
|
||||
self._visible = False
|
||||
self._metric_color = rl.WHITE
|
||||
self._active_ids: list[int] = []
|
||||
self._ftm_override_metric_ids: set[int] = set()
|
||||
self._metrics: dict[int, tuple[str, str]] = {}
|
||||
|
||||
@property
|
||||
@@ -83,6 +97,7 @@ class DeveloperSidebar:
|
||||
self._cached_metrics = [self._params.get_int(f"DeveloperSidebarMetric{i}") for i in range(1, 8)]
|
||||
self._cached_force_auto_tune_off = self._params.get_bool("ForceAutoTuneOff")
|
||||
self._cached_force_auto_tune = self._params.get_bool("ForceAutoTune")
|
||||
self._cached_ftm_trial_applied = self._params.get_bool("FTMTrialApplied")
|
||||
self._cached_friction_stock = self._params.get_float("SteerFrictionStock")
|
||||
self._cached_friction = self._params.get_float("SteerFriction")
|
||||
self._cached_lat_stock = self._params.get_float("SteerLatAccelStock")
|
||||
@@ -200,20 +215,39 @@ class DeveloperSidebar:
|
||||
force_auto_tune = ui_state.starpilot_toggles.get("force_auto_tune", False) or self._cached_force_auto_tune
|
||||
use_params = live_torque_parameters.useParams if (live_torque_parameters and hasattr(live_torque_parameters, 'useParams')) else False
|
||||
using_live_torque = not force_auto_tune_off and (use_params or force_auto_tune)
|
||||
live_friction = live_torque_parameters.frictionCoefficientFiltered if (live_torque_parameters and hasattr(live_torque_parameters, 'frictionCoefficientFiltered')) else 0.0
|
||||
live_lat_factor = live_torque_parameters.latAccelFactorFiltered if (live_torque_parameters and hasattr(live_torque_parameters, 'latAccelFactorFiltered')) else 0.0
|
||||
custom_friction = float(ui_state.starpilot_toggles.get("friction", self._cached_friction) or 0.0)
|
||||
custom_lat_factor = float(ui_state.starpilot_toggles.get("latAccelFactor", self._cached_lat) or 0.0)
|
||||
use_custom_friction = bool(ui_state.starpilot_toggles.get("use_custom_friction", force_auto_tune_off))
|
||||
use_custom_lat_factor = bool(ui_state.starpilot_toggles.get("use_custom_latAccelFactor", force_auto_tune_off))
|
||||
|
||||
if not using_live_torque:
|
||||
friction_coeff = self._cached_friction_stock
|
||||
else:
|
||||
friction_coeff = live_torque_parameters.frictionCoefficientFiltered if (live_torque_parameters and hasattr(live_torque_parameters, 'frictionCoefficientFiltered')) else 0.0
|
||||
if friction_coeff == 0.0:
|
||||
friction_coeff = self._cached_friction if force_auto_tune_off else (live_torque_parameters.frictionCoefficientFiltered if (live_torque_parameters and hasattr(live_torque_parameters, 'frictionCoefficientFiltered')) else 0.0)
|
||||
self._ftm_override_metric_ids = set()
|
||||
if self._cached_ftm_trial_applied:
|
||||
ftm_metric_flags = {
|
||||
3: bool(ui_state.starpilot_toggles.get("use_custom_steerActuatorDelay", False)),
|
||||
4: use_custom_friction,
|
||||
5: use_custom_lat_factor,
|
||||
6: bool(ui_state.starpilot_toggles.get("use_custom_steerRatio", False)),
|
||||
}
|
||||
self._ftm_override_metric_ids = {metric_id for metric_id, enabled in ftm_metric_flags.items() if enabled}
|
||||
|
||||
if not using_live_torque:
|
||||
lat_factor = self._cached_lat_stock
|
||||
else:
|
||||
lat_factor = live_torque_parameters.latAccelFactorFiltered if (live_torque_parameters and hasattr(live_torque_parameters, 'latAccelFactorFiltered')) else 0.0
|
||||
if lat_factor == 0.0:
|
||||
lat_factor = self._cached_lat if force_auto_tune_off else (live_torque_parameters.latAccelFactorFiltered if (live_torque_parameters and hasattr(live_torque_parameters, 'latAccelFactorFiltered')) else 0.0)
|
||||
friction_coeff = resolve_effective_torque_value(
|
||||
use_custom_friction,
|
||||
custom_friction,
|
||||
using_live_torque,
|
||||
live_friction,
|
||||
self._cached_friction_stock,
|
||||
self._cached_friction,
|
||||
)
|
||||
lat_factor = resolve_effective_torque_value(
|
||||
use_custom_lat_factor,
|
||||
custom_lat_factor,
|
||||
using_live_torque,
|
||||
live_lat_factor,
|
||||
self._cached_lat_stock,
|
||||
self._cached_lat,
|
||||
)
|
||||
|
||||
lat_delay = live_delay.lateralDelay if live_delay else 0.0
|
||||
|
||||
@@ -271,5 +305,6 @@ class DeveloperSidebar:
|
||||
if metric_id <= 0 or metric_id not in self._metrics:
|
||||
continue
|
||||
label_first, label_second = self._metrics[metric_id]
|
||||
self._draw_metric(sidebar_rect, label_first, label_second, self._metric_color, y)
|
||||
ftm_overridden = metric_id in self._ftm_override_metric_ids
|
||||
self._draw_metric(sidebar_rect, label_first, label_second, _FTM_OVERRIDE_COLOR if ftm_overridden else self._metric_color, y)
|
||||
y += METRIC_HEIGHT + spacing
|
||||
|
||||
Binary file not shown.
@@ -6,7 +6,7 @@ import time
|
||||
|
||||
from collections import Counter, deque
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
@@ -30,6 +30,7 @@ DETECTOR_INPUT_SIZE_CANDIDATES = (640, 512, 448, 416, 384, 320, 288, 256, 224, 1
|
||||
DEFAULT_CLASSIFIER_INPUT_SIZE = 128
|
||||
CLASSIFIER_INPUT_SIZE_CANDIDATES = (128, 112, 96, 80, 64)
|
||||
FULL_FRAME_OCR_FALLBACK_ENABLED = False
|
||||
DETECTOR_CLASSIFIER_CROP_OCR_ENABLED = False
|
||||
DETECTOR_CLASSIFIER_REGION_MODE = "right_roi" # full, right_roi, full_and_right_roi
|
||||
DEVICE_BUSY_AVG_CPU_USAGE_PERCENT = 78.0
|
||||
DEVICE_BUSY_MAX_CPU_USAGE_PERCENT = 92.0
|
||||
@@ -42,8 +43,9 @@ HISTORY_SECONDS = 2.0
|
||||
CONSISTENT_DETECTIONS = 2
|
||||
# These counts must remain achievable at the measured 1.5 Hz onroad cadence.
|
||||
CHANGE_CONSISTENT_DETECTIONS = 2
|
||||
LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = 3
|
||||
LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS = 2
|
||||
LOW_SPEED_CHANGE_MIN_CONFIDENCE = 0.90
|
||||
LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS = True
|
||||
MODEL_DETECTION_SHORT_CIRCUIT_CONFIDENCE = 0.65
|
||||
PUBLISHED_HOLD_SECONDS = 300.0
|
||||
PUBLISHED_CHANGE_COOLDOWN_SECONDS = 1.4
|
||||
@@ -153,7 +155,7 @@ US_DETECTOR_CLASSES = {
|
||||
US_CLASSIFIER_SPEED_VALUES = (15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75)
|
||||
SCHOOL_ZONE_SPEED_VALUES = frozenset((15, 20, 25))
|
||||
US_DETECTOR_MIN_CONFIDENCE = 0.06
|
||||
US_CLASSIFIER_MIN_CONFIDENCE = 0.80
|
||||
US_CLASSIFIER_MIN_CONFIDENCE = 0.60
|
||||
US_CLASSIFIER_REJECT_MIN_CONFIDENCE = 0.85
|
||||
SEPARATE_REJECT_CLASSIFIER_ENABLED = False
|
||||
US_REJECT_CLASSIFIER_MIN_CONFIDENCE = 0.85
|
||||
@@ -195,10 +197,14 @@ DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_HEIGHT = 55
|
||||
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MAX_AREA_RATIO = 0.002
|
||||
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_PROPOSAL_CONFIDENCE = 0.18
|
||||
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_X_RATIO = 0.52
|
||||
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = 0.98
|
||||
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_READ_CONFIDENCE = 0.65
|
||||
DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT = 2
|
||||
DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE = 0.60
|
||||
DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_READ_CONFIDENCE = 0.995
|
||||
DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED = True
|
||||
DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_SUPPORT = 3
|
||||
DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_CONFIDENCE = 0.90
|
||||
DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_SUPPORT = 2
|
||||
SCHOOL_ZONE_SPEED_PRIOR = 0.12
|
||||
SCHOOL_ZONE_SUPPORT_BONUS = 0.08
|
||||
SCHOOL_ZONE_MIN_SUPPORT = 2
|
||||
@@ -339,7 +345,7 @@ class SpeedLimitVisionDaemon:
|
||||
if not self.use_runtime or self.params_memory is None or self.debug_session_id:
|
||||
return
|
||||
|
||||
timestamp = datetime.now(timezone.utc)
|
||||
timestamp = datetime.now(UTC)
|
||||
session_id = timestamp.strftime("%Y%m%d_%H%M%S")
|
||||
debug_dir = DEBUG_BASE_DIR / session_id
|
||||
suffix = 1
|
||||
@@ -501,7 +507,7 @@ class SpeedLimitVisionDaemon:
|
||||
event = {
|
||||
"event": event_type,
|
||||
"wallTimeNs": wall_time_ns,
|
||||
"wallTime": datetime.fromtimestamp(wall_time_ns / 1e9, timezone.utc).isoformat(),
|
||||
"wallTime": datetime.fromtimestamp(wall_time_ns / 1e9, UTC).isoformat(),
|
||||
"monoTimeNs": time.monotonic_ns(),
|
||||
"roadName": self.last_road_name,
|
||||
"stream": self.stream_name,
|
||||
@@ -739,7 +745,8 @@ class SpeedLimitVisionDaemon:
|
||||
return
|
||||
if self.current_frame_bgr is None:
|
||||
return
|
||||
if now - self.last_map_transition_miss_at < MAP_TRANSITION_MISS_CAPTURE_COOLDOWN_SECONDS and current_speed_limit_mph == self.last_map_transition_miss_speed_limit_mph:
|
||||
capture_in_cooldown = now - self.last_map_transition_miss_at < MAP_TRANSITION_MISS_CAPTURE_COOLDOWN_SECONDS
|
||||
if capture_in_cooldown and current_speed_limit_mph == self.last_map_transition_miss_speed_limit_mph:
|
||||
return
|
||||
if self._vision_recently_supported(current_speed_limit_mph, now):
|
||||
return
|
||||
@@ -1389,7 +1396,7 @@ class SpeedLimitVisionDaemon:
|
||||
|
||||
for school_crop, crop_weight in self._iter_school_zone_read_crops(sign_crop):
|
||||
read_result = self._classify_speed_limit_from_model(school_crop)
|
||||
if read_result is None:
|
||||
if read_result is None and DETECTOR_CLASSIFIER_CROP_OCR_ENABLED:
|
||||
read_result = self._read_speed_limit_from_crop(school_crop)
|
||||
if read_result is None:
|
||||
continue
|
||||
@@ -1433,6 +1440,9 @@ class SpeedLimitVisionDaemon:
|
||||
speed_support_counts: dict[int, int] = {}
|
||||
speed_regulatory_support: dict[int, int] = {}
|
||||
speed_trusted_model_support: dict[int, int] = {}
|
||||
speed_model_only_rescue_support: dict[int, int] = {}
|
||||
speed_direct_model_support: dict[int, int] = {}
|
||||
speed_strong_model_support: dict[int, int] = {}
|
||||
|
||||
for expand_left, expand_top, expand_right, expand_bottom, expansion_weight in DETECTOR_CLASSIFIER_EXPANSIONS:
|
||||
expanded_x1 = max(int(x1 - box_width * expand_left), 0)
|
||||
@@ -1466,22 +1476,33 @@ class SpeedLimitVisionDaemon:
|
||||
proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE and
|
||||
model_read[1] >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_READ_CONFIDENCE
|
||||
)
|
||||
model_only_consensus_read = (
|
||||
not DETECTOR_CLASSIFIER_CROP_OCR_ENABLED and
|
||||
class_id == 0 and
|
||||
model_read is not None and
|
||||
not is_small_box and
|
||||
model_read[1] >= DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_CONFIDENCE and
|
||||
(is_regulatory or proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_MODEL_MIN_PROPOSAL_CONFIDENCE)
|
||||
)
|
||||
needs_ocr_confirmation = (
|
||||
class_id != 2 and
|
||||
(not is_regulatory or is_tiny_low_conf_box) and
|
||||
not trusted_model_read and
|
||||
not strong_model_read
|
||||
)
|
||||
if model_read is None or needs_ocr_confirmation:
|
||||
if DETECTOR_CLASSIFIER_CROP_OCR_ENABLED and (model_read is None or needs_ocr_confirmation):
|
||||
ocr_read = self._read_speed_limit_from_crop(sign_crop)
|
||||
read_result = model_read or ocr_read
|
||||
if read_result is None:
|
||||
continue
|
||||
|
||||
if needs_ocr_confirmation:
|
||||
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
|
||||
if DETECTOR_CLASSIFIER_CROP_OCR_ENABLED:
|
||||
if model_read is None or ocr_read is None or model_read[0] != ocr_read[0]:
|
||||
continue
|
||||
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
|
||||
elif not trusted_model_read and not strong_model_read and not model_only_consensus_read:
|
||||
continue
|
||||
read_result = (model_read[0], min(model_read[1], ocr_read[1]))
|
||||
|
||||
speed_limit_mph, read_confidence = read_result
|
||||
score_is_regulatory = is_regulatory or trusted_model_read or strong_model_read
|
||||
@@ -1507,6 +1528,12 @@ class SpeedLimitVisionDaemon:
|
||||
speed_regulatory_support[speed_limit_mph] = speed_regulatory_support.get(speed_limit_mph, 0) + 1
|
||||
if trusted_model_read:
|
||||
speed_trusted_model_support[speed_limit_mph] = speed_trusted_model_support.get(speed_limit_mph, 0) + 1
|
||||
if strong_model_read:
|
||||
speed_strong_model_support[speed_limit_mph] = speed_strong_model_support.get(speed_limit_mph, 0) + 1
|
||||
if needs_ocr_confirmation and model_only_consensus_read:
|
||||
speed_model_only_rescue_support[speed_limit_mph] = speed_model_only_rescue_support.get(speed_limit_mph, 0) + 1
|
||||
elif model_read is not None:
|
||||
speed_direct_model_support[speed_limit_mph] = speed_direct_model_support.get(speed_limit_mph, 0) + 1
|
||||
|
||||
if not speed_scores:
|
||||
continue
|
||||
@@ -1520,6 +1547,12 @@ class SpeedLimitVisionDaemon:
|
||||
)
|
||||
if class_id == 2 and speed_limit_mph not in SCHOOL_ZONE_SPEED_VALUES:
|
||||
continue
|
||||
model_only_rescue_support = speed_model_only_rescue_support.get(speed_limit_mph, 0)
|
||||
if (
|
||||
speed_direct_model_support.get(speed_limit_mph, 0) < 1 and
|
||||
0 < model_only_rescue_support < DETECTOR_CLASSIFIER_MODEL_ONLY_CONSENSUS_MIN_SUPPORT
|
||||
):
|
||||
continue
|
||||
if (
|
||||
speed_regulatory_support.get(speed_limit_mph, 0) < 1 and
|
||||
0 < speed_trusted_model_support.get(speed_limit_mph, 0) < DETECTOR_CLASSIFIER_TRUSTED_MODEL_MIN_SUPPORT
|
||||
@@ -1541,7 +1574,10 @@ class SpeedLimitVisionDaemon:
|
||||
speed_limit_mph = competing_speed_limit_mph
|
||||
read_confidence = speed_best_confidences[speed_limit_mph]
|
||||
support_count = speed_support_counts[speed_limit_mph]
|
||||
strong_rescue = False
|
||||
strong_rescue = (
|
||||
DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_ENABLED and
|
||||
speed_strong_model_support.get(speed_limit_mph, 0) >= DETECTOR_CLASSIFIER_STRONG_MODEL_CONSENSUS_MIN_SUPPORT
|
||||
)
|
||||
score = min(
|
||||
read_confidence * 0.72 +
|
||||
proposal_confidence * 0.24 +
|
||||
@@ -1567,7 +1603,7 @@ class SpeedLimitVisionDaemon:
|
||||
continue
|
||||
if read_confidence < DETECTOR_CLASSIFIER_RESCUE_MIN_CONFIDENCE:
|
||||
continue
|
||||
strong_rescue = (
|
||||
strong_rescue = strong_rescue or (
|
||||
speed_trusted_model_support.get(speed_limit_mph, 0) >= DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_SUPPORT and
|
||||
proposal_confidence >= DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_PROPOSAL_CONFIDENCE and
|
||||
read_confidence >= DETECTOR_CLASSIFIER_STRONG_RESCUE_MIN_READ_CONFIDENCE
|
||||
@@ -1863,7 +1899,7 @@ class SpeedLimitVisionDaemon:
|
||||
allow_single_frame_consensus = has_strong_consensus
|
||||
if current_speed_limit >= 30 and candidate_speed_limit < 30:
|
||||
required_count = LOW_SPEED_CHANGE_CONSISTENT_DETECTIONS
|
||||
allow_single_frame_consensus = False
|
||||
allow_single_frame_consensus = has_strong_consensus and LOW_SPEED_CHANGE_ALLOW_STRONG_CONSENSUS
|
||||
if best_confidence < LOW_SPEED_CHANGE_MIN_CONFIDENCE:
|
||||
return None
|
||||
if candidate_count < required_count and not allow_single_frame_consensus:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
from collections import deque
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import starpilot.system.speed_limit_vision as slv
|
||||
from starpilot.system.speed_limit_vision import HistoryEntry, SpeedLimitVisionDaemon
|
||||
|
||||
|
||||
@@ -26,20 +28,79 @@ def test_speed_change_accepts_single_strong_consensus_read():
|
||||
assert daemon._confirm_detection() == pytest.approx((60, 0.74))
|
||||
|
||||
|
||||
def test_low_speed_change_requires_three_high_confidence_reads():
|
||||
daemon = daemon_with_history(40, [(25, 0.95), (25, 0.96)])
|
||||
def test_low_speed_change_requires_two_high_confidence_reads():
|
||||
daemon = daemon_with_history(40, [(25, 0.95)])
|
||||
assert daemon._confirm_detection() is None
|
||||
|
||||
daemon.history.append(HistoryEntry(25, 0.94, 2.0))
|
||||
daemon.history.append(HistoryEntry(25, 0.96, 1.0))
|
||||
assert daemon._confirm_detection() == pytest.approx((25, 0.96))
|
||||
|
||||
|
||||
def test_low_speed_change_ignores_single_strong_consensus_read():
|
||||
def test_low_speed_change_accepts_single_strong_consensus_read():
|
||||
daemon = daemon_with_history(40, [])
|
||||
daemon.history.append(HistoryEntry(25, 0.95, 1.0, strong_consensus=True))
|
||||
assert daemon._confirm_detection() is None
|
||||
assert daemon._confirm_detection() == pytest.approx((25, 0.95))
|
||||
|
||||
|
||||
def test_low_speed_change_rejects_low_confidence_sequence():
|
||||
daemon = daemon_with_history(40, [(25, 0.82), (25, 0.88), (25, 0.89)])
|
||||
assert daemon._confirm_detection() is None
|
||||
|
||||
|
||||
def detector_classifier_daemon(*, regulatory: bool, model_read, bbox=(700, 100, 780, 220), proposal_confidence=0.80):
|
||||
daemon = SpeedLimitVisionDaemon.__new__(SpeedLimitVisionDaemon)
|
||||
daemon._collect_detector_classifier_proposals = lambda _frame: [(proposal_confidence, 0, bbox)]
|
||||
daemon._is_regulatory_speed_sign = lambda _crop: regulatory
|
||||
daemon._classify_speed_limit_from_model = model_read if callable(model_read) else lambda _crop: model_read
|
||||
daemon._read_speed_limit_from_crop = lambda _crop: pytest.fail("detector/classifier runtime must not call OCR")
|
||||
return daemon
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def model_only_runtime(monkeypatch):
|
||||
monkeypatch.setattr(slv, "DETECTOR_CLASSIFIER_CROP_OCR_ENABLED", False)
|
||||
|
||||
|
||||
def test_detector_classifier_runtime_reads_regulatory_sign_without_ocr(model_only_runtime):
|
||||
daemon = detector_classifier_daemon(regulatory=True, model_read=(55, 0.99))
|
||||
detection = daemon._detect_sign_from_detector_classifier(np.zeros((480, 960, 3), dtype=np.uint8))
|
||||
|
||||
assert detection is not None
|
||||
assert detection.speed_limit_mph == 55
|
||||
|
||||
|
||||
def test_detector_classifier_marks_three_strong_model_crops_as_consensus(model_only_runtime):
|
||||
daemon = detector_classifier_daemon(regulatory=True, model_read=(20, 0.999), proposal_confidence=0.80)
|
||||
detection = daemon._detect_sign_from_detector_classifier(np.zeros((480, 960, 3), dtype=np.uint8))
|
||||
|
||||
assert detection is not None
|
||||
assert detection.speed_limit_mph == 20
|
||||
assert detection.strong_consensus
|
||||
|
||||
|
||||
def test_detector_classifier_runtime_rejects_single_untrusted_non_regulatory_model_read_without_ocr(model_only_runtime):
|
||||
reads = iter(((55, 0.99), None, None, None))
|
||||
daemon = detector_classifier_daemon(regulatory=False, model_read=lambda _crop: next(reads))
|
||||
detection = daemon._detect_sign_from_detector_classifier(np.zeros((480, 960, 3), dtype=np.uint8))
|
||||
|
||||
assert detection is None
|
||||
|
||||
|
||||
def test_detector_classifier_runtime_accepts_repeated_model_only_consensus_without_ocr(model_only_runtime):
|
||||
daemon = detector_classifier_daemon(regulatory=False, model_read=(60, 0.99))
|
||||
detection = daemon._detect_sign_from_detector_classifier(np.zeros((480, 960, 3), dtype=np.uint8))
|
||||
|
||||
assert detection is not None
|
||||
assert detection.speed_limit_mph == 60
|
||||
|
||||
|
||||
def test_detector_classifier_runtime_rejects_tiny_model_only_consensus_without_ocr(model_only_runtime):
|
||||
daemon = detector_classifier_daemon(
|
||||
regulatory=True,
|
||||
model_read=(40, 0.99),
|
||||
bbox=(700, 100, 720, 125),
|
||||
proposal_confidence=0.14,
|
||||
)
|
||||
detection = daemon._detect_sign_from_detector_classifier(np.zeros((480, 960, 3), dtype=np.uint8))
|
||||
|
||||
assert detection is None
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { html, reactive } from "/assets/vendor/arrow-core.js"
|
||||
import { createBrowserHistory, createRouter } from "/assets/vendor/remix-router-1.3.1.js"
|
||||
import { hideSidebar } from "/assets/js/utils.js"
|
||||
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=favorite-slots-5"
|
||||
import { DeviceSettings } from "/assets/components/tools/device_settings.js?v=ftm-overrides-1"
|
||||
import { ErrorLogs } from "/assets/components/tools/error_logs.js"
|
||||
import { VehicleFeatures } from "/assets/components/tools/vehicle_features.js"
|
||||
import { GalaxyPairing } from "/assets/components/tools/galaxy.js"
|
||||
@@ -19,7 +19,7 @@ import { ModelManager } from "/assets/components/tools/model_manager.js?v=202603
|
||||
import { LivePlots } from "/assets/components/tools/plots.js"
|
||||
import { ThemeMaker } from "/assets/components/tools/theme_maker.js"
|
||||
import { TestingGround } from "/assets/components/tools/testing_ground.js"
|
||||
import { Tuning } from "/assets/components/tools/tuning.js?v=ftm-workspace-6"
|
||||
import { Tuning } from "/assets/components/tools/tuning.js?v=ftm-workspace-9"
|
||||
import { Troubleshoot } from "/assets/components/tools/troubleshoot.js"
|
||||
import { TmuxLog } from "/assets/components/tools/tmux.js"
|
||||
import { ToggleControl } from "/assets/components/tools/toggles.js"
|
||||
|
||||
@@ -172,6 +172,51 @@
|
||||
margin-right: var(--margin-base);
|
||||
}
|
||||
|
||||
.ds-row-heading {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.ds-ftm-badge {
|
||||
background: var(--sidebar-bg);
|
||||
border: 1px solid var(--main-fg);
|
||||
border-radius: 999px;
|
||||
color: var(--main-fg);
|
||||
font-size: 0.68rem;
|
||||
font-weight: var(--font-weight-bold);
|
||||
letter-spacing: 0.02em;
|
||||
padding: 0.18rem 0.45rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ds-ftm-detail,
|
||||
.ds-ftm-summary {
|
||||
border-left: 2px solid var(--main-fg);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.45;
|
||||
margin-top: 0.45rem;
|
||||
padding-left: 0.65rem;
|
||||
}
|
||||
|
||||
.ds-ftm-summary > div + div {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.ds-ftm-link {
|
||||
color: var(--main-fg);
|
||||
display: inline-block;
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-top: 0.35rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ds-ftm-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.ds-row-desc {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
|
||||
@@ -12,8 +12,14 @@ const FAVORITE_OPTION_COLLATOR = new Intl.Collator(undefined, { numeric: true, s
|
||||
// Plain variables — scheduling/routing flags that must NOT be reactive
|
||||
let syncScheduled = false
|
||||
let lastParams = null
|
||||
let ftmWorkspaceInflight = null
|
||||
let lastFtmWorkspaceFetch = 0
|
||||
const DYNAMIC_DEFAULT_DEP_KEYS = new Set(["AccelerationProfile", "EVTuning", "TruckTuning"])
|
||||
const PANDA_FIRMWARE_TOGGLE_KEYS = new Set(["IgnoreIgnitionLine", "RemoteStartBootsComma", "HKGRemoteStartBootsComma"])
|
||||
const FTM_ADVANCED_LATERAL_KEYS = new Set([
|
||||
"AdvancedLateralTune", "ForceAutoTune", "ForceAutoTuneOff", "SteerDelay",
|
||||
"SteerFriction", "SteerKP", "SteerLatAccel", "SteerRatio",
|
||||
])
|
||||
|
||||
// Module-level state (persists across route changes)
|
||||
const state = reactive({
|
||||
@@ -22,6 +28,7 @@ const state = reactive({
|
||||
paramMetaByKey: {},
|
||||
values: {},
|
||||
defaultValues: {},
|
||||
ftmActiveTrial: null,
|
||||
loadingLayout: true,
|
||||
loadingValues: true,
|
||||
filter: "",
|
||||
@@ -267,8 +274,28 @@ async function fetchDefaultValues() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFtmWorkspace(force = false) {
|
||||
const now = Date.now()
|
||||
if (!force && now - lastFtmWorkspaceFetch < 1500) return
|
||||
if (ftmWorkspaceInflight) return ftmWorkspaceInflight
|
||||
|
||||
lastFtmWorkspaceFetch = now
|
||||
ftmWorkspaceInflight = fetch("/api/ftm/workspace", { cache: "no-store" })
|
||||
.then(async res => {
|
||||
if (!res.ok) return
|
||||
const workspace = await res.json()
|
||||
state.ftmActiveTrial = workspace?.activeTrial || null
|
||||
})
|
||||
.catch(error => console.warn("Failed to load active FTM trial state:", error))
|
||||
.finally(() => {
|
||||
ftmWorkspaceInflight = null
|
||||
})
|
||||
|
||||
return ftmWorkspaceInflight
|
||||
}
|
||||
|
||||
async function refreshParamsAndDefaults() {
|
||||
await fetchDefaultValues()
|
||||
await Promise.all([fetchDefaultValues(), fetchFtmWorkspace(true)])
|
||||
|
||||
try {
|
||||
const valuesRes = await fetch("/api/params/all")
|
||||
@@ -317,7 +344,8 @@ async function fetchLayoutAndParams() {
|
||||
|
||||
// Pull params once at page load; local state handles subsequent edits.
|
||||
try {
|
||||
if (!(await fetchDefaultValues())) {
|
||||
const [defaultsLoaded] = await Promise.all([fetchDefaultValues(), fetchFtmWorkspace(true)])
|
||||
if (!defaultsLoaded) {
|
||||
state.defaultValues = {}
|
||||
}
|
||||
|
||||
@@ -995,6 +1023,54 @@ function getSettingLockReason(param) {
|
||||
return ""
|
||||
}
|
||||
|
||||
function valuesEqual(left, right) {
|
||||
if (typeof left === "number" || typeof right === "number") {
|
||||
const leftNumber = Number(left)
|
||||
const rightNumber = Number(right)
|
||||
return Number.isFinite(leftNumber) && Number.isFinite(rightNumber) && Math.abs(leftNumber - rightNumber) < 1e-9
|
||||
}
|
||||
return left === right
|
||||
}
|
||||
|
||||
function getFtmParamStatus(key) {
|
||||
const trial = state.ftmActiveTrial
|
||||
if (!trial || !FTM_ADVANCED_LATERAL_KEYS.has(key)) return null
|
||||
|
||||
const applied = trial.appliedGenericParams || {}
|
||||
const hasExplicitMetadata = Object.prototype.hasOwnProperty.call(applied, key)
|
||||
const previous = trial.params || {}
|
||||
const hasPreviousValue = Object.prototype.hasOwnProperty.call(previous, key)
|
||||
|
||||
// Older active snapshots did not record the applied bundle, so infer only
|
||||
// changed values for compatibility. New snapshots always use explicit metadata.
|
||||
if (!hasExplicitMetadata && (!hasPreviousValue || valuesEqual(previous[key], state.values[key]))) return null
|
||||
|
||||
return {
|
||||
effectiveValue: state.values[key],
|
||||
previousValue: hasPreviousValue ? previous[key] : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function formatFtmValue(param, value) {
|
||||
if (value === undefined || value === null) return "not set"
|
||||
if (param.data_type === "bool") return value ? "On" : "Off"
|
||||
if (param.ui_type === "numeric") {
|
||||
const bounds = numericBounds(param)
|
||||
return formatSliderValue(value, String(bounds.step), param.precision, param.key)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function getFtmTrialSummary() {
|
||||
const trial = state.ftmActiveTrial
|
||||
if (!trial) return null
|
||||
const genericCount = Object.keys(trial.appliedGenericParams || {}).filter(key => key !== "AdvancedLateralTune").length
|
||||
const thresholdCount = Object.keys(trial.appliedFrictionThresholds || {}).length
|
||||
const vehicleKnobCount = Object.keys(trial.appliedVehicleKnobs || {}).length
|
||||
const title = [trial.pathLabel, trial.profileLabel].filter(Boolean).join(" / ") || "Active trial"
|
||||
return { title, genericCount, thresholdCount, vehicleKnobCount }
|
||||
}
|
||||
|
||||
function handleSectionTabClick(sectionSlug, event) {
|
||||
if (!sectionSlug || sectionSlug === state.activeSectionSlug) return
|
||||
|
||||
@@ -1155,6 +1231,8 @@ function renderSettingRow(p) {
|
||||
const isChild = p.parent_key ? "ds-child-modifier" : ""
|
||||
const lockReason = getSettingLockReason(p)
|
||||
const isLocked = lockReason !== ""
|
||||
const ftmParamStatus = getFtmParamStatus(p.key)
|
||||
const ftmTrialSummary = p.key === "AdvancedLateralTune" ? getFtmTrialSummary() : null
|
||||
let rowControl = ""
|
||||
|
||||
if (isNumeric) {
|
||||
@@ -1275,9 +1353,31 @@ function renderSettingRow(p) {
|
||||
<div class="ds-row ${isNumeric ? "ds-row-numeric" : ""} ${isChild}">
|
||||
<div class="ds-row-info">
|
||||
<div class="ds-row-text">
|
||||
<span class="ds-row-label">${p.label}</span>
|
||||
<div class="ds-row-heading">
|
||||
<span class="ds-row-label">${p.label}</span>
|
||||
${ftmParamStatus ? html`<span class="ds-ftm-badge">Currently overridden by FTM</span>` : ""}
|
||||
</div>
|
||||
${p.description ? html`<div class="ds-row-desc">${p.description}</div>` : ""}
|
||||
${lockReason ? html`<div class="ds-row-desc"><strong>Locked:</strong> ${lockReason}</div>` : ""}
|
||||
${ftmParamStatus ? html`
|
||||
<div class="ds-ftm-detail">
|
||||
Effective now: <strong>${formatFtmValue(p, ftmParamStatus.effectiveValue)}</strong>.
|
||||
Revert restores: <strong>${formatFtmValue(p, ftmParamStatus.previousValue)}</strong>.
|
||||
You can still edit this while the trial is active.
|
||||
</div>
|
||||
` : ""}
|
||||
${ftmTrialSummary ? html`
|
||||
<div class="ds-ftm-summary">
|
||||
<div><strong>FTM trial active:</strong> ${ftmTrialSummary.title}</div>
|
||||
<div>
|
||||
${ftmTrialSummary.genericCount} advanced setting${ftmTrialSummary.genericCount === 1 ? "" : "s"},
|
||||
${ftmTrialSummary.thresholdCount} friction curve${ftmTrialSummary.thresholdCount === 1 ? "" : "s"}, and
|
||||
${ftmTrialSummary.vehicleKnobCount} vehicle-specific knob${ftmTrialSummary.vehicleKnobCount === 1 ? "" : "s"} active.
|
||||
</div>
|
||||
<div>Revert from Lateral Tuning restores the exact settings saved before this trial.</div>
|
||||
<a class="ds-ftm-link" href="/tuning">Open Lateral Tuning</a>
|
||||
</div>
|
||||
` : ""}
|
||||
|
||||
${() => p.is_parent_toggle && isParamEnabledForChildren(p) ? html`
|
||||
<div class="ds-manage-btn" @click="${() => toggleManage(p.key)}">
|
||||
@@ -1340,6 +1440,8 @@ function resolveActiveSectionSlug(params) {
|
||||
export function DeviceSettings({ params }) {
|
||||
lastParams = params
|
||||
|
||||
fetchFtmWorkspace()
|
||||
|
||||
if (!state.fetched) {
|
||||
state.fetched = true
|
||||
fetchLayoutAndParams()
|
||||
|
||||
@@ -107,6 +107,51 @@
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.ftmFeedbackButtons .longManeuverButton.selected {
|
||||
box-shadow: 0 0 0 3px var(--text-color);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.ftmTuneComparison {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--padding-base);
|
||||
}
|
||||
|
||||
.ftmTuneComparisonTable {
|
||||
align-items: center;
|
||||
display: grid;
|
||||
gap: var(--gap-xs) var(--gap-sm);
|
||||
grid-template-columns: minmax(11rem, 1.5fr) minmax(7rem, 1fr) auto minmax(7rem, 1fr);
|
||||
margin-top: var(--margin-sm);
|
||||
}
|
||||
|
||||
.ftmTuneComparisonTable > div {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
padding: var(--padding-xs) 0;
|
||||
}
|
||||
|
||||
.ftmTuneComparisonHeader {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
}
|
||||
|
||||
.ftmTuneComparisonLabel {
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
}
|
||||
|
||||
.ftmTuneComparisonArrow {
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ftmTuneComparisonChanged {
|
||||
color: var(--main-fg);
|
||||
font-weight: var(--font-weight-demi-bold);
|
||||
}
|
||||
|
||||
.ftmDeltaBox {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: var(--border-radius-sm);
|
||||
@@ -126,18 +171,162 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ftmPlotWrap {
|
||||
.ftmTrackingOverview {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: var(--border-radius-sm);
|
||||
padding: var(--padding-base);
|
||||
}
|
||||
|
||||
.ftmTrackingLegend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-sm);
|
||||
}
|
||||
|
||||
.ftmTrackingLegend span {
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
display: inline-flex;
|
||||
font-size: var(--font-size-sm);
|
||||
gap: var(--gap-xs);
|
||||
}
|
||||
|
||||
.ftmTrackingLegend i {
|
||||
border-radius: 999px;
|
||||
display: inline-block;
|
||||
height: 0.2rem;
|
||||
width: 1.4rem;
|
||||
}
|
||||
|
||||
.ftmTrackingLegend i.desired {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.ftmTrackingLegend i.actual {
|
||||
background: #38bdf8;
|
||||
}
|
||||
|
||||
.ftmTrackingNotice {
|
||||
border-left: 2px solid var(--main-fg);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
margin-top: var(--margin-sm);
|
||||
padding: var(--padding-xs) var(--padding-sm);
|
||||
}
|
||||
|
||||
.ftmTrackingGrid {
|
||||
display: grid;
|
||||
gap: var(--gap-sm);
|
||||
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
|
||||
margin-top: var(--margin-base);
|
||||
}
|
||||
|
||||
.ftmPlotWrap svg {
|
||||
height: 140px;
|
||||
.ftmTrackingCard {
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: var(--border-radius-sm);
|
||||
min-width: 0;
|
||||
padding: var(--padding-sm);
|
||||
}
|
||||
|
||||
.ftmTrackingCardHeader {
|
||||
align-items: flex-start;
|
||||
display: flex;
|
||||
gap: var(--gap-sm);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ftmTrackingCardHeader > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.ftmTrackingCardHeader span,
|
||||
.ftmTrackingCard small {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.ftmTrackingCard small {
|
||||
display: block;
|
||||
margin-top: var(--margin-xs);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ftmTrackingPlot {
|
||||
display: block;
|
||||
height: auto;
|
||||
margin-top: var(--margin-xs);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ftmTrackingPlotBackground {
|
||||
fill: var(--input-bg);
|
||||
}
|
||||
|
||||
.ftmTrackingEventRegion {
|
||||
fill: rgba(139, 108, 197, 0.14);
|
||||
}
|
||||
|
||||
.ftmTrackingAxis,
|
||||
.ftmTrackingZero {
|
||||
fill: none;
|
||||
stroke: rgba(255, 255, 255, 0.18);
|
||||
stroke-width: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.ftmTrackingZero {
|
||||
stroke-dasharray: 3 4;
|
||||
}
|
||||
|
||||
.ftmTrackingDesired,
|
||||
.ftmTrackingActual {
|
||||
fill: none;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2;
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.ftmTrackingDesired {
|
||||
stroke: #ef4444;
|
||||
}
|
||||
|
||||
.ftmTrackingActual {
|
||||
stroke: #38bdf8;
|
||||
}
|
||||
|
||||
.ftmTrackingAxisLabel {
|
||||
fill: var(--text-muted);
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.ftmTrackingMeta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--gap-xs);
|
||||
margin-top: var(--margin-xs);
|
||||
}
|
||||
|
||||
.ftmTrackingMeta span {
|
||||
background: var(--input-bg);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
padding: 0.15rem 0.45rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 900px) {
|
||||
.ftmTwoColumn,
|
||||
.ftmProfileGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.ftmTuneComparisonTable {
|
||||
font-size: var(--font-size-sm);
|
||||
grid-template-columns: minmax(8rem, 1.3fr) minmax(5rem, 1fr) auto minmax(5rem, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,6 +430,13 @@ function setDimensionFeedback(dimensionId, mode) {
|
||||
state.feedbackIgnored = [...ignored]
|
||||
}
|
||||
|
||||
async function updateDimensionFeedback(dimensionId, mode) {
|
||||
if (state.runningAction) return
|
||||
const current = feedbackStateFor(dimensionId)
|
||||
setDimensionFeedback(dimensionId, current === mode ? "unset" : mode)
|
||||
await saveFeedback()
|
||||
}
|
||||
|
||||
async function saveFeedback() {
|
||||
if (!state.report?.reportId || state.runningAction) return
|
||||
state.runningAction = true
|
||||
@@ -508,6 +515,261 @@ function primaryPath() {
|
||||
return paths.find((path) => path.key === selectedPathKey) || paths.find((path) => path.isPrimary) || paths[0] || null
|
||||
}
|
||||
|
||||
function allReportProfiles() {
|
||||
const pathProfiles = reportPaths().flatMap((path) => path.profiles || [])
|
||||
return pathProfiles.length ? pathProfiles : (state.report?.profiles || [])
|
||||
}
|
||||
|
||||
function activeTrialProfile() {
|
||||
const activeTrial = state.workspace?.activeTrial
|
||||
if (!activeTrial || activeTrial.reportId !== state.report?.reportId) return null
|
||||
return allReportProfiles().find((profile) => profile.id === activeTrial.profileId) || null
|
||||
}
|
||||
|
||||
function mergedFtmOverrides() {
|
||||
const current = state.report?.currentParams?.FTMActiveOverrides || {}
|
||||
const trial = activeTrialProfile()?.ftmOverrides || {}
|
||||
return {
|
||||
baseFrictionThresholds: {
|
||||
...(current.baseFrictionThresholds || {}),
|
||||
...(trial.baseFrictionThresholds || {}),
|
||||
},
|
||||
vehicleKnobs: {
|
||||
...(current.vehicleKnobs || {}),
|
||||
...(trial.vehicleKnobs || {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatTuneComparisonValue(value) {
|
||||
if (Array.isArray(value)) return renderCurve(value)
|
||||
const numeric = Number(value)
|
||||
return Number.isFinite(numeric) ? numeric.toFixed(3) : String(value ?? "-")
|
||||
}
|
||||
|
||||
function tuneComparisonRows() {
|
||||
const stock = state.report?.stockParams
|
||||
const current = state.report?.currentParams
|
||||
if (!stock || !current) return []
|
||||
|
||||
const trialGeneric = activeTrialProfile()?.genericParams || {}
|
||||
const rows = [
|
||||
["Lat accel", "SteerLatAccel"],
|
||||
["Friction", "SteerFriction"],
|
||||
["Steer delay", "SteerDelay"],
|
||||
["Steer ratio", "SteerRatio"],
|
||||
["KP", "SteerKP"],
|
||||
].map(([label, key]) => ({
|
||||
key,
|
||||
label,
|
||||
stock: stock[key],
|
||||
current: Object.hasOwn(trialGeneric, key) ? trialGeneric[key] : current[key],
|
||||
}))
|
||||
|
||||
const overrides = mergedFtmOverrides()
|
||||
for (const [family, payload] of Object.entries(stock.FTMBaseFrictionThresholds || {})) {
|
||||
rows.push({
|
||||
key: `friction-threshold-${family}`,
|
||||
label: `${family} friction threshold`,
|
||||
stock: payload?.values || [],
|
||||
current: overrides.baseFrictionThresholds?.[family]?.values || payload?.values || [],
|
||||
})
|
||||
}
|
||||
|
||||
for (const [symbol, currentValue] of Object.entries(overrides.vehicleKnobs || {})) {
|
||||
if (!Object.hasOwn(stock.FTMVehicleKnobs || {}, symbol)) continue
|
||||
rows.push({
|
||||
key: symbol,
|
||||
label: symbol.split(".").slice(1).join("."),
|
||||
stock: stock.FTMVehicleKnobs[symbol],
|
||||
current: currentValue,
|
||||
codeLabel: symbol,
|
||||
})
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function comparisonValueChanged(row) {
|
||||
if (Array.isArray(row.stock) || Array.isArray(row.current)) {
|
||||
return JSON.stringify(row.stock || []) !== JSON.stringify(row.current || [])
|
||||
}
|
||||
return Math.abs(Number(row.stock) - Number(row.current)) > 0.0005
|
||||
}
|
||||
|
||||
function renderTuneComparison() {
|
||||
const rows = tuneComparisonRows()
|
||||
if (!rows.length) return ""
|
||||
const profile = activeTrialProfile()
|
||||
return html`
|
||||
<div class="ftmCardSubsection ftmTuneComparison">
|
||||
<div class="ftmCardHeader">
|
||||
<div>
|
||||
<h4>Stock vs Current FTM</h4>
|
||||
<p class="longManeuverMuted">
|
||||
${profile ? `Includes active trial: ${profile.pathLabel || "FTM"} / ${profile.label}` : "Current values captured when this route was analyzed."}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ftmTuneComparisonTable">
|
||||
<div class="ftmTuneComparisonHeader">Parameter</div>
|
||||
<div class="ftmTuneComparisonHeader">Stock</div>
|
||||
<div class="ftmTuneComparisonArrow"></div>
|
||||
<div class="ftmTuneComparisonHeader">FTM</div>
|
||||
${rows.map((row) => html`
|
||||
<div class="ftmTuneComparisonLabel" title="${row.codeLabel || row.key}">${row.label}</div>
|
||||
<div>${formatTuneComparisonValue(row.stock)}</div>
|
||||
<div class="ftmTuneComparisonArrow">></div>
|
||||
<div class="${comparisonValueChanged(row) ? "ftmTuneComparisonChanged" : ""}">${formatTuneComparisonValue(row.current)}</div>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
const TRACKING_OVERVIEW_GROUPS = [
|
||||
{ title: "Straight Tracking", buckets: new Set(["center_chatter"]) },
|
||||
{
|
||||
title: "Curve Response",
|
||||
buckets: new Set([
|
||||
"understeer", "oversteer", "early_turn_in", "late_turn_in",
|
||||
"notchy_mid_curve", "low_speed_unwillingness", "saturation_limited",
|
||||
]),
|
||||
},
|
||||
{ title: "Unwind Response", buckets: new Set(["unwind_too_slow", "unwind_too_fast"]) },
|
||||
]
|
||||
|
||||
function hasUsablePlotData(suggestion) {
|
||||
const plot = suggestion?.plotData
|
||||
return !!(
|
||||
plot?.driverOverrideFree !== false &&
|
||||
Array.isArray(plot?.times) && plot.times.length > 1 &&
|
||||
Array.isArray(plot?.desired) && plot.desired.length === plot.times.length &&
|
||||
Array.isArray(plot?.actual) && plot.actual.length === plot.times.length
|
||||
)
|
||||
}
|
||||
|
||||
function trackingOverviewItems() {
|
||||
const suggestions = [...(primaryPath()?.suggestions || [])]
|
||||
.filter(hasUsablePlotData)
|
||||
.sort((left, right) => Number(right.severity || 0) - Number(left.severity || 0))
|
||||
const selected = []
|
||||
const selectedDimensions = new Set()
|
||||
|
||||
for (const group of TRACKING_OVERVIEW_GROUPS) {
|
||||
const match = suggestions.find((suggestion) => (
|
||||
group.buckets.has(suggestion.bucket) && !selectedDimensions.has(suggestion.dimensionId)
|
||||
))
|
||||
if (!match) continue
|
||||
selected.push({ ...match, overviewTitle: group.title })
|
||||
selectedDimensions.add(match.dimensionId)
|
||||
}
|
||||
|
||||
for (const suggestion of suggestions) {
|
||||
if (selected.length >= 3) break
|
||||
if (selectedDimensions.has(suggestion.dimensionId)) continue
|
||||
selected.push({ ...suggestion, overviewTitle: "Additional Evidence" })
|
||||
selectedDimensions.add(suggestion.dimensionId)
|
||||
}
|
||||
|
||||
return selected.slice(0, 3)
|
||||
}
|
||||
|
||||
function plotPoints(times, values, duration, yMin, yMax) {
|
||||
const xStart = 34
|
||||
const xEnd = 410
|
||||
const yStart = 12
|
||||
const yEnd = 136
|
||||
const ySpan = Math.max(yMax - yMin, 0.001)
|
||||
return times.map((time, index) => {
|
||||
const x = xStart + (Math.max(0, Number(time)) / duration) * (xEnd - xStart)
|
||||
const y = yEnd - ((Number(values[index]) - yMin) / ySpan) * (yEnd - yStart)
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`
|
||||
}).join(" ")
|
||||
}
|
||||
|
||||
function renderTrackingPlot(plot) {
|
||||
const times = plot.times.map(Number)
|
||||
const desired = plot.desired.map(Number)
|
||||
const actual = plot.actual.map(Number)
|
||||
const duration = Math.max(Number(plot.windowDurationSec), times[times.length - 1] || 0, 0.1)
|
||||
const allValues = [...desired, ...actual, 0].filter(Number.isFinite)
|
||||
const rawMin = Math.min(...allValues)
|
||||
const rawMax = Math.max(...allValues)
|
||||
const padding = Math.max((rawMax - rawMin) * 0.12, 0.08)
|
||||
const yMin = rawMin - padding
|
||||
const yMax = rawMax + padding
|
||||
const desiredPoints = plotPoints(times, desired, duration, yMin, yMax)
|
||||
const actualPoints = plotPoints(times, actual, duration, yMin, yMax)
|
||||
const eventStart = Math.max(0, Math.min(Number(plot.eventStartSec) || 0, duration))
|
||||
const eventEnd = Math.max(eventStart, Math.min(Number(plot.eventEndSec) || eventStart, duration))
|
||||
const eventX = 34 + (eventStart / duration) * 376
|
||||
const eventWidth = Math.max(((eventEnd - eventStart) / duration) * 376, 2)
|
||||
const zeroY = 136 - ((0 - yMin) / Math.max(yMax - yMin, 0.001)) * 124
|
||||
|
||||
return html`
|
||||
<svg class="ftmTrackingPlot" viewBox="0 0 420 158" role="img" aria-label="Desired versus actual lateral acceleration">
|
||||
<rect class="ftmTrackingPlotBackground" x="0" y="0" width="420" height="158" rx="10"></rect>
|
||||
<rect class="ftmTrackingEventRegion" x="${eventX}" y="12" width="${eventWidth}" height="124"></rect>
|
||||
<line class="ftmTrackingZero" x1="34" y1="${zeroY}" x2="410" y2="${zeroY}"></line>
|
||||
<line class="ftmTrackingAxis" x1="34" y1="12" x2="34" y2="136"></line>
|
||||
<line class="ftmTrackingAxis" x1="34" y1="136" x2="410" y2="136"></line>
|
||||
<polyline class="ftmTrackingDesired" points="${desiredPoints}"></polyline>
|
||||
<polyline class="ftmTrackingActual" points="${actualPoints}"></polyline>
|
||||
<text class="ftmTrackingAxisLabel" x="2" y="18">${yMax.toFixed(1)}</text>
|
||||
<text class="ftmTrackingAxisLabel" x="2" y="138">${yMin.toFixed(1)}</text>
|
||||
<text class="ftmTrackingAxisLabel" x="34" y="152">0s</text>
|
||||
<text class="ftmTrackingAxisLabel" x="384" y="152">${duration.toFixed(1)}s</text>
|
||||
</svg>
|
||||
`
|
||||
}
|
||||
|
||||
function renderTrackingOverview() {
|
||||
const items = trackingOverviewItems()
|
||||
if (!items.length) return ""
|
||||
|
||||
return html`
|
||||
<div class="ftmCardSubsection ftmTrackingOverview">
|
||||
<div class="ftmCardHeader">
|
||||
<div>
|
||||
<h4>Tracking Overview</h4>
|
||||
<p class="longManeuverMuted">
|
||||
Desired vs actual lateral acceleration (m/s^2) in representative intervention-free windows. The shaded area is the classified event.
|
||||
</p>
|
||||
</div>
|
||||
<div class="ftmTrackingLegend" aria-label="Plot legend">
|
||||
<span><i class="desired"></i>Desired</span>
|
||||
<span><i class="actual"></i>Actual</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ftmTrackingNotice">
|
||||
No fit score by design. Small phase separation is normal, and closer traces do not automatically mean the steering feels better.
|
||||
</div>
|
||||
|
||||
<div class="ftmTrackingGrid">
|
||||
${items.map((item) => html`
|
||||
<article class="ftmTrackingCard">
|
||||
<div class="ftmTrackingCardHeader">
|
||||
<div>
|
||||
<strong>${item.overviewTitle}</strong>
|
||||
<span>${String(item.bucket || "event").replace(/_/g, " ")}</span>
|
||||
</div>
|
||||
<span>${Number(item.plotData.meanSpeedMph || 0).toFixed(1)} mph</span>
|
||||
</div>
|
||||
${renderTrackingPlot(item.plotData)}
|
||||
<div class="ftmTrackingMeta">
|
||||
<span>${item.evidence?.directionBias || item.plotData.direction || "center"}</span>
|
||||
<span>${item.evidence?.speedBand || item.plotData.speedBand || "mixed"}</span>
|
||||
<span>${Number(item.plotData.eventDurationSec || 0).toFixed(1)}s event</span>
|
||||
</div>
|
||||
<small title="${item.plotData.segmentLabel || ""}">${item.plotData.segmentLabel || "Unknown segment"}</small>
|
||||
</article>
|
||||
`)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function renderProfile(profile) {
|
||||
const genericEntries = Object.entries(profile.genericParams || {}).filter(([key]) => key !== "AdvancedLateralTune")
|
||||
const frictionEntries = Object.entries(profile.ftmOverrides?.baseFrictionThresholds || {})
|
||||
@@ -551,7 +813,6 @@ function renderProfile(profile) {
|
||||
|
||||
function renderSuggestion(suggestion) {
|
||||
const currentVsSuggested = suggestion.currentVsSuggested
|
||||
const feedbackState = feedbackStateFor(suggestion.dimensionId)
|
||||
return html`
|
||||
<div class="ftmCard">
|
||||
<div class="ftmCardHeader">
|
||||
@@ -565,14 +826,18 @@ function renderSuggestion(suggestion) {
|
||||
</div>
|
||||
<div class="ftmFeedbackButtons">
|
||||
<button
|
||||
class="longManeuverButton ${feedbackState === "accepted" ? "selected" : ""}"
|
||||
@click="${() => setDimensionFeedback(suggestion.dimensionId, feedbackState === "accepted" ? "unset" : "accepted")}">
|
||||
Matches Experience
|
||||
class="${() => `longManeuverButton ${feedbackStateFor(suggestion.dimensionId) === "accepted" ? "selected" : ""}`}"
|
||||
aria-pressed="${() => feedbackStateFor(suggestion.dimensionId) === "accepted" ? "true" : "false"}"
|
||||
disabled="${() => state.runningAction}"
|
||||
@click="${() => updateDimensionFeedback(suggestion.dimensionId, "accepted")}">
|
||||
${() => feedbackStateFor(suggestion.dimensionId) === "accepted" ? "Matched" : "Matches Experience"}
|
||||
</button>
|
||||
<button
|
||||
class="longManeuverButton ${feedbackState === "ignored" ? "danger selected" : "danger"}"
|
||||
@click="${() => setDimensionFeedback(suggestion.dimensionId, feedbackState === "ignored" ? "unset" : "ignored")}">
|
||||
Ignore
|
||||
class="${() => `longManeuverButton danger ${feedbackStateFor(suggestion.dimensionId) === "ignored" ? "selected" : ""}`}"
|
||||
aria-pressed="${() => feedbackStateFor(suggestion.dimensionId) === "ignored" ? "true" : "false"}"
|
||||
disabled="${() => state.runningAction}"
|
||||
@click="${() => updateDimensionFeedback(suggestion.dimensionId, "ignored")}">
|
||||
${() => feedbackStateFor(suggestion.dimensionId) === "ignored" ? "Ignored" : "Ignore"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -603,9 +868,6 @@ function renderSuggestion(suggestion) {
|
||||
`
|
||||
: html`<p class="longManeuverMuted">No trial adjustment suggested for this dimension.</p>`}
|
||||
|
||||
${suggestion.plotSvg
|
||||
? html`<div class="ftmPlotWrap"><p class="longManeuverMuted">Saved report includes an inline event plot for this finding.</p></div>`
|
||||
: ""}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
@@ -803,6 +1065,10 @@ export function Tuning() {
|
||||
<p><strong>Samples:</strong> ${safeCount(state.report.summary?.sampleCount)}</p>
|
||||
</div>
|
||||
|
||||
${() => renderTrackingOverview()}
|
||||
|
||||
${() => renderTuneComparison()}
|
||||
|
||||
<div class="ftmFindings">
|
||||
${reportPaths().map((path) => renderPathSummary(path))}
|
||||
</div>
|
||||
@@ -833,12 +1099,13 @@ export function Tuning() {
|
||||
<p class="longManeuverMuted">
|
||||
${primaryPath()?.whySelected || "Mark the dimensions that match what the driver felt."}
|
||||
</p>
|
||||
<p class="longManeuverMuted">Finding decisions save immediately and regenerate the trial profiles below.</p>
|
||||
</div>
|
||||
<button
|
||||
class="longManeuverButton"
|
||||
disabled="${() => state.runningAction || !state.report}"
|
||||
@click="${saveFeedback}">
|
||||
Save Feedback
|
||||
Save Notes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -57,6 +57,17 @@ TRIAL_PARAM_SPECS = {
|
||||
"FTMTrialApplied": "bool",
|
||||
}
|
||||
|
||||
FTM_ADVANCED_LATERAL_PARAM_KEYS = {
|
||||
"AdvancedLateralTune",
|
||||
"ForceAutoTune",
|
||||
"ForceAutoTuneOff",
|
||||
"SteerDelay",
|
||||
"SteerFriction",
|
||||
"SteerKP",
|
||||
"SteerLatAccel",
|
||||
"SteerRatio",
|
||||
}
|
||||
|
||||
GENERIC_PARAM_METADATA = {
|
||||
"SteerDelay": {"min": 0.01, "max": 1.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
|
||||
"SteerFriction": {"min": 0.0, "max": 1.0, "precision": 0.001, "deltaType": "absolute", "safeLiveTrial": True},
|
||||
@@ -483,19 +494,71 @@ def _analysis_eligibility_mask(samples: list[FTMSample]) -> list[bool]:
|
||||
return eligible
|
||||
|
||||
|
||||
def _build_plot_svg(samples: list[FTMSample], event: dict[str, Any]) -> str:
|
||||
start_idx = max(0, event["startIdx"] - 12)
|
||||
end_idx = min(len(samples) - 1, event["endIdx"] + 12)
|
||||
def _build_plot_data(samples: list[FTMSample], event: dict[str, Any], eligibility: list[bool] | None = None) -> dict[str, Any]:
|
||||
start_idx = int(event["startIdx"])
|
||||
end_idx = int(event["endIdx"])
|
||||
event_route = samples[start_idx].route
|
||||
event_segment = samples[start_idx].segment
|
||||
|
||||
# Add a small amount of context without crossing an intervention buffer or
|
||||
# segment boundary. The highlighted region remains the classified event.
|
||||
for _ in range(12):
|
||||
candidate = start_idx - 1
|
||||
if candidate < 0 or (eligibility is not None and not eligibility[candidate]):
|
||||
break
|
||||
if samples[candidate].route != event_route or samples[candidate].segment != event_segment:
|
||||
break
|
||||
start_idx = candidate
|
||||
for _ in range(12):
|
||||
candidate = end_idx + 1
|
||||
if candidate >= len(samples) or (eligibility is not None and not eligibility[candidate]):
|
||||
break
|
||||
if samples[candidate].route != event_route or samples[candidate].segment != event_segment:
|
||||
break
|
||||
end_idx = candidate
|
||||
|
||||
window = samples[start_idx:end_idx + 1]
|
||||
if len(window) < 2:
|
||||
return ""
|
||||
return {}
|
||||
|
||||
# Keep reports lightweight on unusually long windows while preserving both ends.
|
||||
if len(window) > 160:
|
||||
indices = np.linspace(0, len(window) - 1, 160, dtype=int)
|
||||
window = [window[int(idx)] for idx in indices]
|
||||
|
||||
times = np.array([sample.t for sample in window], dtype=float)
|
||||
desired = np.array([sample.desired_la for sample in window], dtype=float)
|
||||
actual = np.array([sample.actual_la for sample in window], dtype=float)
|
||||
relative_times = times - float(times[0])
|
||||
event_start_time = max(float(samples[int(event["startIdx"])].t - times[0]), 0.0)
|
||||
event_end_time = max(float(samples[int(event["endIdx"])].t - times[0]), event_start_time)
|
||||
|
||||
time_min = float(times.min())
|
||||
time_span = max(float(times.max() - time_min), 1e-3)
|
||||
return {
|
||||
"times": [round(float(value), 3) for value in relative_times],
|
||||
"desired": [round(float(value), 4) for value in desired],
|
||||
"actual": [round(float(value), 4) for value in actual],
|
||||
"windowDurationSec": round(float(relative_times[-1]), 2),
|
||||
"eventStartSec": round(event_start_time, 2),
|
||||
"eventEndSec": round(event_end_time, 2),
|
||||
"eventDurationSec": round(max(event_end_time - event_start_time, 0.0), 2),
|
||||
"meanSpeedMph": round(float(np.mean([sample.v_ego for sample in window])) * 2.236936, 1),
|
||||
"route": event_route,
|
||||
"segment": event_segment,
|
||||
"segmentLabel": _route_label(event_route, event_segment),
|
||||
"direction": str(event.get("direction", "center")),
|
||||
"speedBand": str(event.get("speedBand", "mixed")),
|
||||
"driverOverrideFree": bool(eligibility is None or all(eligibility[start_idx:end_idx + 1])),
|
||||
}
|
||||
|
||||
|
||||
def _build_plot_svg(plot_data: dict[str, Any]) -> str:
|
||||
times = np.array(plot_data.get("times", []), dtype=float)
|
||||
desired = np.array(plot_data.get("desired", []), dtype=float)
|
||||
actual = np.array(plot_data.get("actual", []), dtype=float)
|
||||
if len(times) < 2 or len(desired) != len(times) or len(actual) != len(times):
|
||||
return ""
|
||||
|
||||
time_span = max(float(times.max()), 1e-3)
|
||||
y_min = float(min(np.min(desired), np.min(actual)))
|
||||
y_max = float(max(np.max(desired), np.max(actual)))
|
||||
y_pad = max((y_max - y_min) * 0.10, 0.1)
|
||||
@@ -506,7 +569,7 @@ def _build_plot_svg(samples: list[FTMSample], event: dict[str, Any]) -> str:
|
||||
def _points(series):
|
||||
coords = []
|
||||
for t_val, y_val in zip(times, series, strict=True):
|
||||
x = ((float(t_val) - time_min) / time_span) * 380.0
|
||||
x = (float(t_val) / time_span) * 380.0
|
||||
y = 120.0 - (((float(y_val) - y_min) / y_span) * 120.0)
|
||||
coords.append(f"{x:.1f},{y:.1f}")
|
||||
return " ".join(coords)
|
||||
@@ -617,6 +680,31 @@ def _current_param_state(CP, params: Params) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _stock_param_state(CP, capabilities: dict[str, Any]) -> dict[str, Any]:
|
||||
torque_tune = CP.lateralTuning.torque if CP.lateralTuning.which() == "torque" else None
|
||||
friction_family = str(capabilities.get("frictionFamily", "standard"))
|
||||
rich_profile = capabilities.get("richProfileKey")
|
||||
rich_knobs = {
|
||||
symbol: float(meta["defaultValue"])
|
||||
for symbol, meta in get_ftm_supported_vehicle_knobs().items()
|
||||
if rich_profile and meta.get("profile") == rich_profile
|
||||
}
|
||||
return {
|
||||
"SteerDelay": float(getattr(CP, "steerActuatorDelay", 0.0) or 0.0),
|
||||
"SteerFriction": float(getattr(torque_tune, "friction", 0.0) or 0.0) if torque_tune is not None else 0.0,
|
||||
"SteerKP": float(KP),
|
||||
"SteerLatAccel": float(getattr(torque_tune, "latAccelFactor", 0.0) or 0.0) if torque_tune is not None else 0.0,
|
||||
"SteerRatio": float(getattr(CP, "steerRatio", 0.0) or 0.0),
|
||||
"FTMBaseFrictionThresholds": {
|
||||
friction_family: {
|
||||
"speedKnots": list(FTM_FRICTION_SPEED_KNOTS),
|
||||
"values": _baseline_family_curve(friction_family),
|
||||
},
|
||||
} if torque_tune is not None else {},
|
||||
"FTMVehicleKnobs": rich_knobs,
|
||||
}
|
||||
|
||||
|
||||
def _nonlinear_torque_map(CP) -> dict[str, Any]:
|
||||
if str(getattr(CP, "brand", "") or "") != "gm":
|
||||
return {}
|
||||
@@ -828,11 +916,11 @@ def _build_event_summaries(samples: list[FTMSample]) -> tuple[list[dict[str, Any
|
||||
for bucket, mask in base_masks.items():
|
||||
events = _group_masked_events(samples, mask, score_map[bucket])
|
||||
if events:
|
||||
summaries.extend(_summaries_from_events(bucket, samples, events))
|
||||
summaries.extend(_summaries_from_events(bucket, samples, events, eligibility))
|
||||
if straight_windows:
|
||||
summaries.extend(_summaries_from_events("center_chatter", samples, straight_windows))
|
||||
summaries.extend(_summaries_from_events("center_chatter", samples, straight_windows, eligibility))
|
||||
if curve_windows:
|
||||
summaries.extend(_summaries_from_events("notchy_mid_curve", samples, curve_windows))
|
||||
summaries.extend(_summaries_from_events("notchy_mid_curve", samples, curve_windows, eligibility))
|
||||
|
||||
left_errors = [abs(sample.actual_la) - abs(sample.desired_la) for sample in active_samples if sample.desired_la > 0.25]
|
||||
right_errors = [abs(sample.actual_la) - abs(sample.desired_la) for sample in active_samples if sample.desired_la < -0.25]
|
||||
@@ -864,12 +952,14 @@ def _build_event_summaries(samples: list[FTMSample]) -> tuple[list[dict[str, Any
|
||||
},
|
||||
"events": [],
|
||||
"plotSvg": "",
|
||||
"plotData": {},
|
||||
})
|
||||
|
||||
return sorted(summaries, key=lambda item: item["severity"], reverse=True), summary_stats
|
||||
|
||||
|
||||
def _summaries_from_events(bucket: str, samples: list[FTMSample], events: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
def _summaries_from_events(bucket: str, samples: list[FTMSample], events: list[dict[str, Any]],
|
||||
eligibility: list[bool] | None = None) -> list[dict[str, Any]]:
|
||||
grouped: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for event in events:
|
||||
key = (bucket, event["direction"])
|
||||
@@ -890,6 +980,7 @@ def _summaries_from_events(bucket: str, samples: list[FTMSample], events: list[d
|
||||
]
|
||||
top_event = strongest[0]
|
||||
top_speed_band = top_event["speedBand"]
|
||||
plot_data = _build_plot_data(samples, top_event, eligibility)
|
||||
summaries.append({
|
||||
"bucket": bucket_name,
|
||||
"dimensionId": f"{bucket_name}:{direction}:{top_speed_band}",
|
||||
@@ -904,7 +995,8 @@ def _summaries_from_events(bucket: str, samples: list[FTMSample], events: list[d
|
||||
"segments": strongest_labels,
|
||||
},
|
||||
"events": grouped_events,
|
||||
"plotSvg": _build_plot_svg(samples, top_event),
|
||||
"plotSvg": _build_plot_svg(plot_data),
|
||||
"plotData": plot_data,
|
||||
})
|
||||
return summaries
|
||||
|
||||
@@ -1225,6 +1317,8 @@ def build_suggestions(summaries: list[dict[str, Any]], capabilities: dict[str, A
|
||||
"whatNotToTouchYet": "Do not start cutting or adding turn-in. This sample does not show a clean controller-side miss.",
|
||||
"ifThatWasWrong": "If a stronger sample later shows actual lateral accel lagging or overshooting the plan, revisit with that route.",
|
||||
"strategy": strategy,
|
||||
"plotSvg": summary.get("plotSvg", ""),
|
||||
"plotData": summary.get("plotData", {}),
|
||||
})
|
||||
continue
|
||||
|
||||
@@ -1267,6 +1361,7 @@ def build_suggestions(summaries: list[dict[str, Any]], capabilities: dict[str, A
|
||||
"logSupport": f"Matched in {evidence.get('eventCount', 0)} event(s); strongest samples: {', '.join(item['label'] for item in evidence.get('segments', [])[:3]) or 'none'}",
|
||||
"whyThisKnob": _why_this_knob(adjustment),
|
||||
"plotSvg": summary.get("plotSvg", ""),
|
||||
"plotData": summary.get("plotData", {}),
|
||||
})
|
||||
return suggestions
|
||||
|
||||
@@ -1498,6 +1593,7 @@ def build_trial_profiles(report_id: str, suggestions: list[dict[str, Any]], feed
|
||||
path_key: str = "cleanup_pass", path_label: str = "Cleanup Pass") -> list[dict[str, Any]]:
|
||||
ignored = set(str(item) for item in feedback.get("ignoredDimensions", []))
|
||||
accepted = set(str(item) for item in feedback.get("acceptedDimensions", []))
|
||||
has_feedback_decisions = bool(ignored or accepted)
|
||||
|
||||
considered = [
|
||||
suggestion for suggestion in suggestions
|
||||
@@ -1505,7 +1601,7 @@ def build_trial_profiles(report_id: str, suggestions: list[dict[str, Any]], feed
|
||||
not accepted or suggestion.get("dimensionId") in accepted
|
||||
)
|
||||
]
|
||||
if not considered:
|
||||
if not considered and not has_feedback_decisions:
|
||||
considered = [suggestion for suggestion in suggestions if suggestion.get("primaryAdjustmentRaw")]
|
||||
actionable = [
|
||||
suggestion for suggestion in considered
|
||||
@@ -1753,6 +1849,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
|
||||
capabilities = dict(capabilities)
|
||||
capabilities["nonlinearTorqueMap"] = _nonlinear_torque_map(car_params)
|
||||
current_params = _current_param_state(car_params, params)
|
||||
stock_params = _stock_param_state(car_params, capabilities)
|
||||
|
||||
if torque_control:
|
||||
raw_summaries, summary_stats = classify_torque_samples(all_samples)
|
||||
@@ -1773,6 +1870,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
|
||||
"evidence": {"speedBand": "mixed", "directionBias": "center", "eventCount": 1, "segments": []},
|
||||
"events": [],
|
||||
"plotSvg": "",
|
||||
"plotData": {},
|
||||
}]
|
||||
suggestions = [{
|
||||
"dimensionId": "angle_control_diagnostic:overall",
|
||||
@@ -1785,6 +1883,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
|
||||
"whatNotToTouchYet": "Do not write torque-controller override blobs for an angle-control path.",
|
||||
"ifThatWasWrong": "If the car later moves to torque control, re-run FTM on a fresh route.",
|
||||
"plotSvg": "",
|
||||
"plotData": {},
|
||||
}]
|
||||
path_decision = {
|
||||
"primaryPathKey": "cleanup_pass",
|
||||
@@ -1820,6 +1919,7 @@ def analyze_routes(route_names: list[str], footage_paths: list[str], feedback: d
|
||||
"steerControlType": str(getattr(car_params, "steerControlType", car.CarParams.SteerControlType.torque)),
|
||||
},
|
||||
"capabilities": capabilities,
|
||||
"stockParams": stock_params,
|
||||
"currentParams": current_params,
|
||||
"summary": {
|
||||
**summary_stats,
|
||||
@@ -1894,6 +1994,38 @@ def select_report_path(report_id: str, path_key: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _active_trial_display_state(paths: dict[str, Path], snapshot: Any) -> dict[str, Any] | None:
|
||||
if not isinstance(snapshot, dict) or not snapshot:
|
||||
return None
|
||||
if "appliedGenericParams" in snapshot:
|
||||
return snapshot
|
||||
|
||||
report_id = str(snapshot.get("reportId", "") or "")
|
||||
profile_id = str(snapshot.get("profileId", "") or "")
|
||||
profiles = _read_json(paths["profiles"] / f"{report_id}.json", []) if report_id else []
|
||||
profile = next((
|
||||
item for item in profiles
|
||||
if isinstance(item, dict) and item.get("id") == profile_id
|
||||
), None) if isinstance(profiles, list) else None
|
||||
if profile is None:
|
||||
return snapshot
|
||||
|
||||
generic_params = dict(profile.get("genericParams", {}))
|
||||
ftm_overrides = normalize_ftm_overrides(profile.get("ftmOverrides", {}))
|
||||
return {
|
||||
**snapshot,
|
||||
"profileLabel": str(profile.get("label", "FTM") or "FTM"),
|
||||
"pathKey": str(profile.get("pathKey", "") or ""),
|
||||
"pathLabel": str(profile.get("pathLabel", "") or ""),
|
||||
"appliedGenericParams": {
|
||||
key: value for key, value in generic_params.items()
|
||||
if key in FTM_ADVANCED_LATERAL_PARAM_KEYS
|
||||
},
|
||||
"appliedFrictionThresholds": ftm_overrides.get("baseFrictionThresholds", {}),
|
||||
"appliedVehicleKnobs": ftm_overrides.get("vehicleKnobs", {}),
|
||||
}
|
||||
|
||||
|
||||
def list_workspace() -> dict[str, Any]:
|
||||
paths = ensure_ftm_workspace()
|
||||
reports = []
|
||||
@@ -1909,11 +2041,22 @@ def list_workspace() -> dict[str, Any]:
|
||||
"controlPath": payload.get("car", {}).get("controlPath", ""),
|
||||
})
|
||||
feedback_files = sorted(paths["feedback"].glob("*.json"), reverse=True)
|
||||
active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
|
||||
params = Params(return_defaults=True)
|
||||
current_profile_id = params.get("FTMActiveProfileId", encoding="utf-8") or ""
|
||||
raw_active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
|
||||
if not raw_active_snapshot and params.get_bool("FTMTrialApplied"):
|
||||
raw_active_snapshot = _find_revert_snapshot(paths, {}, current_profile_id) or {}
|
||||
if raw_active_snapshot:
|
||||
raw_active_snapshot = {
|
||||
**raw_active_snapshot,
|
||||
"profileId": current_profile_id or raw_active_snapshot.get("profileId", ""),
|
||||
"recoveryNeeded": True,
|
||||
}
|
||||
active_snapshot = _active_trial_display_state(paths, raw_active_snapshot)
|
||||
return {
|
||||
"reports": reports[:20],
|
||||
"feedbackCount": len(feedback_files),
|
||||
"activeTrial": active_snapshot if isinstance(active_snapshot, dict) and active_snapshot else None,
|
||||
"activeTrial": active_snapshot,
|
||||
"status": read_ftm_status(),
|
||||
}
|
||||
|
||||
@@ -1960,8 +2103,9 @@ def clear_workspace() -> dict[str, Any]:
|
||||
if status.get("running"):
|
||||
raise RuntimeError("Stop the active FTM analysis before clearing the workspace.")
|
||||
|
||||
params = Params(return_defaults=True)
|
||||
active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
|
||||
if isinstance(active_snapshot, dict) and active_snapshot.get("params"):
|
||||
if params.get_bool("FTMTrialApplied") or (isinstance(active_snapshot, dict) and active_snapshot.get("params")):
|
||||
raise RuntimeError("Revert the active FTM trial before clearing the workspace.")
|
||||
|
||||
removed = []
|
||||
@@ -2007,6 +2151,50 @@ def _apply_param_bundle(params: Params, bundle: dict[str, Any]) -> None:
|
||||
params.put(key, str(value or ""))
|
||||
|
||||
|
||||
def _merge_ftm_override_state(base: dict[str, Any], delta: dict[str, Any]) -> dict[str, Any]:
|
||||
base = normalize_ftm_overrides(base)
|
||||
delta = normalize_ftm_overrides(delta)
|
||||
merged = {
|
||||
"schemaVersion": 1,
|
||||
"baseFrictionThresholds": {
|
||||
**base.get("baseFrictionThresholds", {}),
|
||||
**delta.get("baseFrictionThresholds", {}),
|
||||
},
|
||||
"vehicleKnobs": {
|
||||
**base.get("vehicleKnobs", {}),
|
||||
**delta.get("vehicleKnobs", {}),
|
||||
},
|
||||
}
|
||||
return normalize_ftm_overrides(merged)
|
||||
|
||||
|
||||
def _find_revert_snapshot(paths: dict[str, Path], active_snapshot: dict[str, Any],
|
||||
current_profile_id: str = "") -> dict[str, Any] | None:
|
||||
if isinstance(active_snapshot, dict) and isinstance(active_snapshot.get("params"), dict):
|
||||
if not active_snapshot["params"].get("FTMTrialApplied", False):
|
||||
return active_snapshot
|
||||
|
||||
cutoff = float(active_snapshot.get("capturedAt", math.inf) or math.inf) if isinstance(active_snapshot, dict) else math.inf
|
||||
candidates = []
|
||||
for path in paths["snapshots"].glob("*.json"):
|
||||
if path.name == "active.json":
|
||||
continue
|
||||
candidate = _read_json(path, {})
|
||||
candidate_params = candidate.get("params", {}) if isinstance(candidate, dict) else {}
|
||||
if not isinstance(candidate_params, dict) or candidate_params.get("FTMTrialApplied", False):
|
||||
continue
|
||||
captured_at = float(candidate.get("capturedAt", 0.0) or 0.0)
|
||||
if captured_at > cutoff:
|
||||
continue
|
||||
candidates.append(candidate)
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
matching = [candidate for candidate in candidates if current_profile_id and candidate.get("profileId") == current_profile_id]
|
||||
pool = matching or candidates
|
||||
return max(pool, key=lambda candidate: float(candidate.get("capturedAt", 0.0) or 0.0))
|
||||
|
||||
|
||||
def apply_trial_profile(report_id: str, profile_id: str) -> dict[str, Any]:
|
||||
paths = ensure_ftm_workspace()
|
||||
params = Params(return_defaults=True)
|
||||
@@ -2017,18 +2205,83 @@ def apply_trial_profile(report_id: str, profile_id: str) -> dict[str, Any]:
|
||||
if profile is None:
|
||||
raise FileNotFoundError(profile_id)
|
||||
|
||||
generic_params = dict(profile.get("genericParams", {}))
|
||||
ftm_overrides = normalize_ftm_overrides(profile.get("ftmOverrides", {}))
|
||||
current_state = _snapshot_current_trial_state(params)
|
||||
raw_active_snapshot = _read_json(paths["snapshots"] / "active.json", {})
|
||||
previous_display_state = _active_trial_display_state(paths, raw_active_snapshot) or {}
|
||||
trial_already_active = bool(current_state.get("FTMTrialApplied", False))
|
||||
|
||||
if trial_already_active:
|
||||
baseline_snapshot = _find_revert_snapshot(
|
||||
paths,
|
||||
raw_active_snapshot,
|
||||
str(current_state.get("FTMActiveProfileId", "") or ""),
|
||||
)
|
||||
if baseline_snapshot is None:
|
||||
raise RuntimeError("The active FTM trial is missing its original rollback snapshot. Revert or reset the existing trial before applying another profile.")
|
||||
baseline_params = baseline_snapshot["params"]
|
||||
session_started_at = float(baseline_snapshot.get("sessionStartedAt", baseline_snapshot.get("capturedAt", time.time())) or time.time())
|
||||
else:
|
||||
baseline_params = current_state
|
||||
session_started_at = time.time()
|
||||
|
||||
previous_generic_params = dict(previous_display_state.get("appliedGenericParams", {}))
|
||||
for key in FTM_ADVANCED_LATERAL_PARAM_KEYS:
|
||||
if key in current_state and current_state.get(key) != baseline_params.get(key):
|
||||
previous_generic_params[key] = current_state[key]
|
||||
|
||||
baseline_overrides = normalize_ftm_overrides(baseline_params.get("FTMActiveOverrides", {}))
|
||||
current_overrides = normalize_ftm_overrides(current_state.get("FTMActiveOverrides", {}))
|
||||
previous_friction_thresholds = dict(previous_display_state.get("appliedFrictionThresholds", {}))
|
||||
for family, payload in current_overrides.get("baseFrictionThresholds", {}).items():
|
||||
if payload != baseline_overrides.get("baseFrictionThresholds", {}).get(family):
|
||||
previous_friction_thresholds[family] = payload
|
||||
previous_vehicle_knobs = dict(previous_display_state.get("appliedVehicleKnobs", {}))
|
||||
for symbol, value in current_overrides.get("vehicleKnobs", {}).items():
|
||||
if value != baseline_overrides.get("vehicleKnobs", {}).get(symbol):
|
||||
previous_vehicle_knobs[symbol] = value
|
||||
|
||||
applied_generic_params = {
|
||||
**previous_generic_params,
|
||||
**{
|
||||
key: value for key, value in generic_params.items()
|
||||
if key in FTM_ADVANCED_LATERAL_PARAM_KEYS
|
||||
},
|
||||
}
|
||||
applied_friction_thresholds = {
|
||||
**previous_friction_thresholds,
|
||||
**ftm_overrides.get("baseFrictionThresholds", {}),
|
||||
}
|
||||
applied_vehicle_knobs = {
|
||||
**previous_vehicle_knobs,
|
||||
**ftm_overrides.get("vehicleKnobs", {}),
|
||||
}
|
||||
now = time.time()
|
||||
snapshot = {
|
||||
"reportId": report_id,
|
||||
"profileId": profile_id,
|
||||
"capturedAt": time.time(),
|
||||
"params": _snapshot_current_trial_state(params),
|
||||
"profileLabel": str(profile.get("label", "FTM") or "FTM"),
|
||||
"pathKey": str(profile.get("pathKey", "") or ""),
|
||||
"pathLabel": str(profile.get("pathLabel", "") or ""),
|
||||
"capturedAt": session_started_at,
|
||||
"updatedAt": now,
|
||||
"sessionStartedAt": session_started_at,
|
||||
"revisionCount": int(previous_display_state.get("revisionCount", 0) or 0) + 1,
|
||||
"params": baseline_params,
|
||||
"appliedGenericParams": applied_generic_params,
|
||||
"appliedFrictionThresholds": applied_friction_thresholds,
|
||||
"appliedVehicleKnobs": applied_vehicle_knobs,
|
||||
}
|
||||
_write_json(paths["snapshots"] / "active.json", snapshot)
|
||||
_write_json(paths["snapshots"] / f"{report_id}-{profile_id.replace(':', '_')}.json", snapshot)
|
||||
|
||||
bundle = dict(profile.get("genericParams", {}))
|
||||
bundle = generic_params
|
||||
bundle["FTMActiveProfileId"] = profile_id
|
||||
bundle["FTMActiveOverrides"] = profile.get("ftmOverrides", {})
|
||||
bundle["FTMActiveOverrides"] = _merge_ftm_override_state(
|
||||
current_state.get("FTMActiveOverrides", {}),
|
||||
ftm_overrides,
|
||||
)
|
||||
bundle["FTMTrialApplied"] = True
|
||||
_apply_param_bundle(params, bundle)
|
||||
return {
|
||||
@@ -2041,17 +2294,23 @@ def revert_trial_profile() -> dict[str, Any]:
|
||||
paths = ensure_ftm_workspace()
|
||||
snapshot_path = paths["snapshots"] / "active.json"
|
||||
snapshot = _read_json(snapshot_path, {})
|
||||
if not isinstance(snapshot, dict) or "params" not in snapshot:
|
||||
raise FileNotFoundError("active trial snapshot")
|
||||
params = Params(return_defaults=True)
|
||||
_apply_param_bundle(params, snapshot["params"])
|
||||
current_profile_id = params.get("FTMActiveProfileId", encoding="utf-8") or ""
|
||||
revert_snapshot = _find_revert_snapshot(paths, snapshot if isinstance(snapshot, dict) else {}, current_profile_id)
|
||||
if revert_snapshot is None:
|
||||
raise FileNotFoundError("active trial snapshot")
|
||||
_apply_param_bundle(params, revert_snapshot["params"])
|
||||
try:
|
||||
snapshot_path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return {
|
||||
"message": "Reverted FTM trial state.",
|
||||
"snapshot": snapshot,
|
||||
"message": "Reverted the complete FTM trial session to its original baseline.",
|
||||
"snapshot": {
|
||||
**(snapshot if isinstance(snapshot, dict) else {}),
|
||||
"params": revert_snapshot["params"],
|
||||
"recoveredBaseline": revert_snapshot is not snapshot,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
<link rel="stylesheet" href="/assets/components/tools/speed_limits.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/theme_maker.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/testing_ground.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tuning.css?v=ftm-workspace-3">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tuning.css?v=ftm-workspace-6">
|
||||
<link rel="stylesheet" href="/assets/components/tools/troubleshoot.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tmux.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/toggles.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/update_manager.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=favorite-slots-5">
|
||||
<link rel="stylesheet" href="/assets/components/tools/device_settings.css?v=ftm-overrides-1">
|
||||
<link rel="stylesheet" href="/assets/components/tools/galaxy.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/longitudinal_maneuvers.css">
|
||||
<link rel="stylesheet" href="/assets/components/tools/tsk_manager.css">
|
||||
|
||||
@@ -189,7 +189,31 @@ def test_classify_torque_samples_detects_center_chatter(tmp_path):
|
||||
|
||||
summaries, stats = module.classify_torque_samples(samples)
|
||||
assert stats["sampleCount"] == len(samples) - 2 # Segment edges are event boundaries, not analysis samples.
|
||||
assert any(summary["bucket"] == "center_chatter" for summary in summaries)
|
||||
chatter = next(summary for summary in summaries if summary["bucket"] == "center_chatter")
|
||||
assert chatter["plotData"]["driverOverrideFree"] is True
|
||||
assert len(chatter["plotData"]["times"]) == len(chatter["plotData"]["desired"])
|
||||
assert len(chatter["plotData"]["times"]) == len(chatter["plotData"]["actual"])
|
||||
|
||||
|
||||
def test_plot_context_stops_at_ineligible_samples(tmp_path):
|
||||
module, _ = _load_ftm_workspace_module(tmp_path)
|
||||
samples = [_sample(module, t=idx * 0.1, desired_la=idx * 0.01, actual_la=idx * 0.009) for idx in range(20)]
|
||||
eligibility = [True] * len(samples)
|
||||
eligibility[5] = False
|
||||
eligibility[14] = False
|
||||
event = {
|
||||
"startIdx": 8,
|
||||
"endIdx": 10,
|
||||
"direction": "left",
|
||||
"speedBand": "mid",
|
||||
}
|
||||
|
||||
plot = module._build_plot_data(samples, event, eligibility)
|
||||
assert plot["driverOverrideFree"] is True
|
||||
assert plot["times"] == pytest.approx([idx * 0.1 for idx in range(8)])
|
||||
assert plot["eventStartSec"] == pytest.approx(0.2)
|
||||
assert plot["eventEndSec"] == pytest.approx(0.4)
|
||||
assert plot["segmentLabel"] == "route/0"
|
||||
|
||||
|
||||
def test_analysis_eligibility_masks_driver_override_with_settle_buffer(tmp_path):
|
||||
@@ -207,6 +231,22 @@ def test_analysis_eligibility_masks_driver_override_with_settle_buffer(tmp_path)
|
||||
assert eligible[31] is True
|
||||
|
||||
|
||||
def test_stock_param_state_captures_generic_and_rich_defaults(tmp_path):
|
||||
module, _ = _load_ftm_workspace_module(tmp_path)
|
||||
torque_tune = SimpleNamespace(friction=0.09, latAccelFactor=3.0)
|
||||
lateral_tuning = SimpleNamespace(which=lambda: "torque", torque=torque_tune)
|
||||
CP = SimpleNamespace(lateralTuning=lateral_tuning, steerActuatorDelay=0.1, steerRatio=14.26)
|
||||
capabilities = {"frictionFamily": "hkg_canfd", "richProfileKey": "hyundai_ioniq_6"}
|
||||
|
||||
stock = module._stock_param_state(CP, capabilities)
|
||||
assert stock["SteerLatAccel"] == pytest.approx(3.0)
|
||||
assert stock["SteerFriction"] == pytest.approx(0.09)
|
||||
assert stock["SteerDelay"] == pytest.approx(0.1)
|
||||
assert stock["SteerRatio"] == pytest.approx(14.26)
|
||||
assert len(stock["FTMBaseFrictionThresholds"]["hkg_canfd"]["values"]) == 5
|
||||
assert stock["FTMVehicleKnobs"]["hyundai_ioniq_6.turn_in_boost_left"] == pytest.approx(1.64)
|
||||
|
||||
|
||||
def test_classify_torque_samples_does_not_bridge_driver_override(tmp_path):
|
||||
module, _ = _load_ftm_workspace_module(tmp_path)
|
||||
samples = []
|
||||
@@ -477,6 +517,29 @@ def test_build_trial_profiles_suppresses_ignored_dimensions(tmp_path):
|
||||
assert profiles[0]["ftmOverrides"] == {}
|
||||
|
||||
|
||||
def test_build_trial_profiles_returns_none_when_every_dimension_is_ignored(tmp_path):
|
||||
module, _ = _load_ftm_workspace_module(tmp_path)
|
||||
suggestion = {
|
||||
"dimensionId": "understeer:left:mid",
|
||||
"severity": 0.8,
|
||||
"primaryAdjustmentRaw": {
|
||||
"type": "generic_param",
|
||||
"paramKey": "SteerLatAccel",
|
||||
"current": 1.6,
|
||||
"suggested": 1.7,
|
||||
"delta": 0.1,
|
||||
},
|
||||
}
|
||||
|
||||
profiles = module.build_trial_profiles(
|
||||
"report-all-ignored",
|
||||
[suggestion],
|
||||
{"acceptedDimensions": [], "ignoredDimensions": ["understeer:left:mid"]},
|
||||
{"richProfileKey": None},
|
||||
)
|
||||
assert profiles == []
|
||||
|
||||
|
||||
def test_merge_primary_adjustments_averages_conflicting_deltas(tmp_path):
|
||||
module, _ = _load_ftm_workspace_module(tmp_path)
|
||||
suggestions = [
|
||||
@@ -539,22 +602,159 @@ def test_apply_and_revert_trial_profile_round_trip(tmp_path):
|
||||
"ForceAutoTuneOff": False,
|
||||
"SteerLatAccel": 1.5,
|
||||
"FTMActiveProfileId": "",
|
||||
"FTMActiveOverrides": {},
|
||||
"FTMActiveOverrides": {
|
||||
"schemaVersion": 1,
|
||||
"baseFrictionThresholds": {},
|
||||
"vehicleKnobs": {"hyundai_ioniq_6.unwind_taper_left": 0.55},
|
||||
},
|
||||
"FTMTrialApplied": False,
|
||||
}
|
||||
|
||||
result = module.apply_trial_profile(report_id, profile_id)
|
||||
assert result["profile"]["id"] == profile_id
|
||||
active_snapshot = json.loads((workspace["snapshots"] / "active.json").read_text(encoding="utf-8"))
|
||||
assert active_snapshot["profileLabel"] == "Recommended"
|
||||
assert active_snapshot["appliedGenericParams"]["SteerLatAccel"] == pytest.approx(1.9)
|
||||
assert active_snapshot["appliedGenericParams"]["ForceAutoTuneOff"] is True
|
||||
assert active_snapshot["appliedVehicleKnobs"]["hyundai_ioniq_6.turn_in_boost_left"] == pytest.approx(0.08)
|
||||
assert active_snapshot["params"]["SteerLatAccel"] == pytest.approx(1.5)
|
||||
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.9)
|
||||
assert fake_params_cls._store["FTMActiveProfileId"] == profile_id
|
||||
assert fake_params_cls._store["FTMTrialApplied"] is True
|
||||
assert fake_params_cls._store["FTMActiveOverrides"]["vehicleKnobs"]["hyundai_ioniq_6.turn_in_boost_left"] == pytest.approx(0.08)
|
||||
assert fake_params_cls._store["FTMActiveOverrides"]["vehicleKnobs"]["hyundai_ioniq_6.unwind_taper_left"] == pytest.approx(0.55)
|
||||
|
||||
revert_result = module.revert_trial_profile()
|
||||
assert revert_result["snapshot"]["profileId"] == profile_id
|
||||
assert fake_params_cls._store["AdvancedLateralTune"] is False
|
||||
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.5)
|
||||
assert fake_params_cls._store["FTMTrialApplied"] is False
|
||||
assert fake_params_cls._store["FTMActiveOverrides"]["vehicleKnobs"]["hyundai_ioniq_6.unwind_taper_left"] == pytest.approx(0.55)
|
||||
|
||||
|
||||
def test_repeated_trial_revisions_revert_to_original_baseline(tmp_path):
|
||||
module, fake_params_cls = _load_ftm_workspace_module(tmp_path)
|
||||
workspace = module.ensure_ftm_workspace()
|
||||
first_report_id = "report-first"
|
||||
first_profile_id = f"{first_report_id}:cleanup_pass:recommended"
|
||||
second_report_id = "report-second"
|
||||
second_profile_id = f"{second_report_id}:cleanup_pass:recommended"
|
||||
first_profile = {
|
||||
"id": first_profile_id,
|
||||
"label": "Recommended",
|
||||
"pathKey": "cleanup_pass",
|
||||
"pathLabel": "Cleanup Pass",
|
||||
"genericParams": {"AdvancedLateralTune": True, "SteerLatAccel": 1.8},
|
||||
"ftmOverrides": {
|
||||
"schemaVersion": 1,
|
||||
"baseFrictionThresholds": {},
|
||||
"vehicleKnobs": {"hyundai_ioniq_6.turn_in_boost_left": 0.08},
|
||||
},
|
||||
}
|
||||
second_profile = {
|
||||
"id": second_profile_id,
|
||||
"label": "Recommended",
|
||||
"pathKey": "cleanup_pass",
|
||||
"pathLabel": "Cleanup Pass",
|
||||
"genericParams": {"AdvancedLateralTune": True, "SteerLatAccel": 1.9},
|
||||
"ftmOverrides": {
|
||||
"schemaVersion": 1,
|
||||
"baseFrictionThresholds": {},
|
||||
"vehicleKnobs": {"hyundai_ioniq_6.unwind_taper_left": 0.62},
|
||||
},
|
||||
}
|
||||
(workspace["profiles"] / f"{first_report_id}.json").write_text(json.dumps([first_profile]), encoding="utf-8")
|
||||
(workspace["profiles"] / f"{second_report_id}.json").write_text(json.dumps([second_profile]), encoding="utf-8")
|
||||
fake_params_cls._store = {
|
||||
"AdvancedLateralTune": False,
|
||||
"SteerLatAccel": 1.5,
|
||||
"FTMActiveProfileId": "",
|
||||
"FTMActiveOverrides": {},
|
||||
"FTMTrialApplied": False,
|
||||
}
|
||||
|
||||
module.apply_trial_profile(first_report_id, first_profile_id)
|
||||
module.apply_trial_profile(second_report_id, second_profile_id)
|
||||
|
||||
active_snapshot = json.loads((workspace["snapshots"] / "active.json").read_text(encoding="utf-8"))
|
||||
assert active_snapshot["revisionCount"] == 2
|
||||
assert active_snapshot["params"]["SteerLatAccel"] == pytest.approx(1.5)
|
||||
assert active_snapshot["params"]["FTMTrialApplied"] is False
|
||||
assert active_snapshot["appliedGenericParams"]["SteerLatAccel"] == pytest.approx(1.9)
|
||||
assert active_snapshot["appliedVehicleKnobs"]["hyundai_ioniq_6.turn_in_boost_left"] == pytest.approx(0.08)
|
||||
assert active_snapshot["appliedVehicleKnobs"]["hyundai_ioniq_6.unwind_taper_left"] == pytest.approx(0.62)
|
||||
assert fake_params_cls._store["FTMActiveOverrides"]["vehicleKnobs"]["hyundai_ioniq_6.turn_in_boost_left"] == pytest.approx(0.08)
|
||||
assert fake_params_cls._store["FTMActiveOverrides"]["vehicleKnobs"]["hyundai_ioniq_6.unwind_taper_left"] == pytest.approx(0.62)
|
||||
|
||||
module.revert_trial_profile()
|
||||
assert fake_params_cls._store["AdvancedLateralTune"] is False
|
||||
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.5)
|
||||
assert fake_params_cls._store["FTMTrialApplied"] is False
|
||||
assert fake_params_cls._store["FTMActiveOverrides"] == {}
|
||||
|
||||
|
||||
def test_orphaned_previous_revision_can_recover_its_baseline(tmp_path):
|
||||
module, fake_params_cls = _load_ftm_workspace_module(tmp_path)
|
||||
workspace = module.ensure_ftm_workspace()
|
||||
report_id = "report-recovery"
|
||||
profile_id = f"{report_id}:cleanup_pass:recommended"
|
||||
profile = {
|
||||
"id": profile_id,
|
||||
"label": "Recommended",
|
||||
"pathKey": "cleanup_pass",
|
||||
"pathLabel": "Cleanup Pass",
|
||||
"genericParams": {"AdvancedLateralTune": True, "SteerLatAccel": 1.8},
|
||||
"ftmOverrides": {},
|
||||
}
|
||||
(workspace["profiles"] / f"{report_id}.json").write_text(json.dumps([profile]), encoding="utf-8")
|
||||
fake_params_cls._store = {
|
||||
"AdvancedLateralTune": False,
|
||||
"SteerLatAccel": 1.5,
|
||||
"FTMActiveProfileId": "",
|
||||
"FTMActiveOverrides": {},
|
||||
"FTMTrialApplied": False,
|
||||
}
|
||||
module.apply_trial_profile(report_id, profile_id)
|
||||
(workspace["snapshots"] / "active.json").unlink()
|
||||
|
||||
active_trial = module.list_workspace()["activeTrial"]
|
||||
assert active_trial["recoveryNeeded"] is True
|
||||
assert active_trial["params"]["SteerLatAccel"] == pytest.approx(1.5)
|
||||
|
||||
module.revert_trial_profile()
|
||||
assert fake_params_cls._store["SteerLatAccel"] == pytest.approx(1.5)
|
||||
assert fake_params_cls._store["FTMTrialApplied"] is False
|
||||
|
||||
|
||||
def test_workspace_hydrates_display_metadata_for_existing_active_trial(tmp_path):
|
||||
module, _ = _load_ftm_workspace_module(tmp_path)
|
||||
workspace = module.ensure_ftm_workspace()
|
||||
report_id = "report-existing"
|
||||
profile_id = f"{report_id}:cleanup_pass:recommended"
|
||||
profile = {
|
||||
"id": profile_id,
|
||||
"label": "Recommended",
|
||||
"pathKey": "cleanup_pass",
|
||||
"pathLabel": "Cleanup Pass",
|
||||
"genericParams": {"AdvancedLateralTune": True, "SteerFriction": 0.25},
|
||||
"ftmOverrides": {
|
||||
"schemaVersion": 1,
|
||||
"baseFrictionThresholds": {},
|
||||
"vehicleKnobs": {"hyundai_ioniq_6.ff_gain_left": 0.15},
|
||||
},
|
||||
}
|
||||
(workspace["profiles"] / f"{report_id}.json").write_text(json.dumps([profile]), encoding="utf-8")
|
||||
(workspace["snapshots"] / "active.json").write_text(json.dumps({
|
||||
"reportId": report_id,
|
||||
"profileId": profile_id,
|
||||
"capturedAt": 123.0,
|
||||
"params": {"SteerFriction": 0.1},
|
||||
}), encoding="utf-8")
|
||||
|
||||
active_trial = module.list_workspace()["activeTrial"]
|
||||
assert active_trial["pathLabel"] == "Cleanup Pass"
|
||||
assert active_trial["appliedGenericParams"]["SteerFriction"] == pytest.approx(0.25)
|
||||
assert active_trial["appliedVehicleKnobs"]["hyundai_ioniq_6.ff_gain_left"] == pytest.approx(0.15)
|
||||
|
||||
|
||||
def test_delete_report_removes_saved_artifacts(tmp_path):
|
||||
|
||||
@@ -5739,6 +5739,8 @@ def setup(app):
|
||||
result = ftm_workspace.apply_trial_profile(report_id, profile_id)
|
||||
except FileNotFoundError:
|
||||
return jsonify({"error": "FTM profile not found."}), 404
|
||||
except RuntimeError as error:
|
||||
return jsonify({"error": str(error)}), 409
|
||||
|
||||
return jsonify(result), 200
|
||||
|
||||
|
||||
@@ -989,17 +989,19 @@ class GuiApplication:
|
||||
rl.draw_text_ex = _draw_text_ex_scaled
|
||||
|
||||
def _patch_scissor_mode(self):
|
||||
if self._scale == 1.0:
|
||||
return
|
||||
|
||||
if not hasattr(rl, "_orig_begin_scissor_mode"):
|
||||
rl._orig_begin_scissor_mode = rl.begin_scissor_mode
|
||||
|
||||
scale_x = self._scale * (self._pixel_scale_x if self._render_texture else 1.0)
|
||||
scale_y = self._scale * (self._pixel_scale_y if self._render_texture else 1.0)
|
||||
if scale_x == 1.0 and scale_y == 1.0:
|
||||
rl.begin_scissor_mode = rl._orig_begin_scissor_mode
|
||||
return
|
||||
|
||||
def _begin_scissor_mode_scaled(x, y, width, height):
|
||||
return rl._orig_begin_scissor_mode(
|
||||
int(x * self._scale * self._pixel_scale_x), int(y * self._scale * self._pixel_scale_y),
|
||||
int(math.ceil(width * self._scale * self._pixel_scale_x)),
|
||||
int(math.ceil(height * self._scale * self._pixel_scale_y)))
|
||||
int(x * scale_x), int(y * scale_y),
|
||||
int(math.ceil(width * scale_x)), int(math.ceil(height * scale_y)))
|
||||
|
||||
rl.begin_scissor_mode = _begin_scissor_mode_scaled
|
||||
|
||||
|
||||
Reference in New Issue
Block a user