Files
IQ.Pilot/iqpilot/selfdrive/constructiond.py
T
2026-07-20 11:06:57 -05:00

153 lines
4.9 KiB
Python

#!/usr/bin/env python3
"""
Copyright © IQ.Lvbs, apart of Project Teal Lvbs, All Rights Reserved, licensed under https://konn3kt.com/tos/
constructiond: work-zone detector for Speed Limit Assist.
Samples the road camera at ~2 Hz and looks for work-zone orange (barrels, drums,
diamond signs) in the NV12 chroma plane. Sunlit yellow lane paint renders with
nearly identical hue to barrel orange on this camera, but its red chroma (V)
saturates below ~168 while retroreflective barrel orange reaches 170-190, so the
V floor is the load-bearing threshold — do not lower it without re-running the
paint/barrel separation sweep on real footage.
"""
from collections import deque
import time
import numpy as np
import cereal.messaging as messaging
from cereal import custom
from msgq.visionipc import VisionIpcClient, VisionStreamType
from openpilot.common.swaglog import cloudlog
State = custom.IQConstructionZone.State
ANALYSIS_PERIOD = 0.5
# fractions of the chroma plane; excludes sky and hood
ROI_TOP, ROI_BOTTOM = 0.40, 0.94
ROI_LEFT, ROI_RIGHT = 0.04, 0.96
V_MIN = 170 # paint tops out ~168 in every DAYLIGHT condition sampled
CB_MIN = 16
HUE_LO, HUE_HI = 0.65, 2.0 # (V-128)/(128-U): yellow paint ~0.4, barrel orange ~0.7-1.5, red ~3.0
# Daylight-only gate: retroreflective amber markers (guardrail chevrons, object
# markers) blaze past V_MIN under headlights at night — 137px on one chevron on
# real footage — and there is no night work-zone ground truth to tune against.
# Night ROI mean luma measured ~36-38, validated daytime footage 84-98.
LUMA_MIN = 65.0
# hits only accumulate at highway-approach speeds: brightly lit lots (truck
# stops) can pass the luma gate at night with orange signage, and the clamp is
# meaningless below it anyway. An already-active zone still holds while slowed.
MIN_ENTER_SPEED = 13.4 # m/s (~30 mph)
HIT_FRAC = 3.0e-4
WASH_FRAC = 0.10 # more orange than this is scene lighting (sunset), not objects
ENTER_HITS = 3
ENTER_WINDOW = 10 # analyses (~5 s)
HOLD_SEC = 120.0 # barrel-free stretches inside a zone last minutes; hold through them
def orange_fraction(buf) -> tuple[float, float]:
"""Returns (hot-orange fraction, mean luma) of the road ROI."""
h, w, stride, uv_off = buf.height, buf.width, buf.stride, buf.uv_offset
ch, cw = h // 2, w // 2
uv = buf.data[uv_off:uv_off + ch * stride].reshape(ch, stride)
r0, r1 = int(ROI_TOP * ch), int(ROI_BOTTOM * ch)
c0, c1 = int(ROI_LEFT * cw), int(ROI_RIGHT * cw)
step = 2 if cw > 700 else 1
u = uv[r0:r1:step, 2 * c0:2 * c1:2 * step].astype(np.int16)
v = uv[r0:r1:step, 2 * c0 + 1:2 * c1:2 * step].astype(np.int16)
cb = 128 - u
cr = v - 128
mask = (v >= V_MIN) & (cb >= CB_MIN) & (cr >= HUE_LO * cb) & (cr <= HUE_HI * cb)
y_plane = buf.data[:h * stride].reshape(h, stride)
luma = float(y_plane[2 * r0:2 * r1:4, 2 * c0:2 * c1:8].mean())
return float(mask.mean()), luma
class ConstructionZoneDetector:
def __init__(self):
self.recent = deque(maxlen=ENTER_WINDOW)
self.last_hit_t: float | None = None
self.active = False
self.frac = 0.0
@property
def state(self):
if self.active:
return State.active
if any(self.recent):
return State.pending
return State.inactive
def seconds_since_hit(self, now: float) -> float:
if self.last_hit_t is None:
return -1.0
return now - self.last_hit_t
def update(self, frac: float, now: float, luma: float = 255.0, v_ego: float = 255.0) -> bool:
self.frac = frac
hit = (HIT_FRAC <= frac <= WASH_FRAC) and luma >= LUMA_MIN and v_ego >= MIN_ENTER_SPEED
self.recent.append(hit)
if hit:
self.last_hit_t = now
if self.active:
if self.last_hit_t is None or (now - self.last_hit_t) > HOLD_SEC:
self.active = False
self.recent.clear()
elif sum(self.recent) >= ENTER_HITS:
self.active = True
return self.active
def main():
pm = messaging.PubMaster(["iqConstructionZone"])
car_state_sock = messaging.sub_sock("carState", conflate=True)
detector = ConstructionZoneDetector()
v_ego = 0.0
vipc = VisionIpcClient("camerad", VisionStreamType.VISION_STREAM_ROAD, True)
while not vipc.connect(False):
time.sleep(0.5)
cloudlog.info("constructiond: connected to road camera stream")
while True:
t0 = time.monotonic()
buf = vipc.recv(200)
if buf is None:
# no publish on camera stall: SLC sees us stale and releases the clamp
continue
cs = messaging.recv_one_or_none(car_state_sock)
if cs is not None:
v_ego = cs.carState.vEgo
frac, luma = orange_fraction(buf)
detector.update(frac, t0, luma, v_ego)
msg = messaging.new_message("iqConstructionZone")
msg.valid = True
cz = msg.iqConstructionZone
cz.state = detector.state
cz.active = detector.active
cz.orangeFraction = frac
cz.secondsSinceHit = detector.seconds_since_hit(t0)
pm.send("iqConstructionZone", msg)
time.sleep(max(0.0, ANALYSIS_PERIOD - (time.monotonic() - t0)))
if __name__ == "__main__":
main()