web UI added and refactoring done
This commit is contained in:
@@ -65,3 +65,52 @@ def gpr_object_rows(collection: ResultCollection) -> np.ndarray:
|
||||
return centers[:, :3]
|
||||
|
||||
return np.zeros((0, 3), dtype=np.float32)
|
||||
|
||||
|
||||
def apply_object_draw_limits(
|
||||
rows: np.ndarray,
|
||||
limits: tuple[int, int] | None,
|
||||
) -> np.ndarray:
|
||||
"""Apply the object count/top-M drawing rules to already-filtered `[x, z, score]` rows.
|
||||
|
||||
`limits` is `(max_detected_objects, draw_top_objects)`, or `None` to disable
|
||||
(legacy GPR). When more than ``max_detected_objects`` survive, ALL are hidden
|
||||
(the scene is too cluttered to be meaningful); otherwise the top ``draw_top_objects``
|
||||
rows are kept (rows arrive already sorted by score descending).
|
||||
"""
|
||||
if limits is None or rows.size == 0:
|
||||
return rows
|
||||
max_detected_objects, draw_top_objects = limits
|
||||
if rows.shape[0] > int(max_detected_objects):
|
||||
return np.zeros((0, rows.shape[1]), dtype=rows.dtype)
|
||||
return rows[: max(0, int(draw_top_objects))]
|
||||
|
||||
|
||||
def filter_object_rows(
|
||||
rows: np.ndarray,
|
||||
*,
|
||||
min_score: float,
|
||||
x_bounds: tuple[float, float],
|
||||
z_bounds: tuple[float, float],
|
||||
draw_limits: tuple[int, int] | None,
|
||||
) -> np.ndarray:
|
||||
"""Filter `[x_m, z_m, score]` object rows for display/broadcast.
|
||||
|
||||
Drops non-finite rows, rows below ``min_score`` (the caller passes the mode's own
|
||||
threshold — a normalized float for coherent GPR, a pair count for legacy GPR; the
|
||||
comparison is identical either way), and rows outside the visible X/Z window, then
|
||||
applies ``draw_limits``. Mode-agnostic: all semantics enter through the parameters.
|
||||
"""
|
||||
if rows.size == 0:
|
||||
return rows
|
||||
x_min, x_max = x_bounds
|
||||
z_min, z_max = z_bounds
|
||||
visible_mask = (
|
||||
np.all(np.isfinite(rows[:, :3]), axis=1)
|
||||
& (rows[:, 2] >= min_score)
|
||||
& (rows[:, 0] >= x_min)
|
||||
& (rows[:, 0] <= x_max)
|
||||
& (rows[:, 1] >= z_min)
|
||||
& (rows[:, 1] <= z_max)
|
||||
)
|
||||
return apply_object_draw_limits(rows[visible_mask], draw_limits)
|
||||
|
||||
@@ -57,6 +57,12 @@ class ProcessingLiveConfig:
|
||||
# Locator filter parameters consumed by the C++ TCP locator server.
|
||||
gpr_min_visible_score: float = 0.0
|
||||
legacy_gpr_min_visible_pair_count: float = 0.0
|
||||
# Visible X/Z window (metres). The locator and the desktop plot both clip
|
||||
# detected objects to this window, so the socket broadcasts only what is shown.
|
||||
gpr_visible_x_min_m: float = -2.0
|
||||
gpr_visible_x_max_m: float = 2.0
|
||||
gpr_visible_z_min_m: float = 0.0
|
||||
gpr_visible_z_max_m: float = 14.0
|
||||
# When true, the C++ data_processor ignores socket-supplied vlc updates
|
||||
# and keeps using `gpr_speed_m_s` from this file.
|
||||
ignore_socket_speed: bool = False
|
||||
@@ -116,6 +122,10 @@ class ProcessingLiveConfig:
|
||||
"gpr_imaging_plane_y_m": float(self.gpr_imaging_plane_y_m),
|
||||
"gpr_min_visible_score": float(self.gpr_min_visible_score),
|
||||
"legacy_gpr_min_visible_pair_count": float(self.legacy_gpr_min_visible_pair_count),
|
||||
"gpr_visible_x_min_m": float(self.gpr_visible_x_min_m),
|
||||
"gpr_visible_x_max_m": float(self.gpr_visible_x_max_m),
|
||||
"gpr_visible_z_min_m": float(self.gpr_visible_z_min_m),
|
||||
"gpr_visible_z_max_m": float(self.gpr_visible_z_max_m),
|
||||
"ignore_socket_speed": bool(self.ignore_socket_speed),
|
||||
"reprocess_current_result": bool(self.reprocess_current_result),
|
||||
"history_command_seq": int(self.history_command_seq),
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""Crash auto-restart back-off policy (pure, GUI-independent)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RestartPolicy:
|
||||
"""Decide when to relaunch a crashed pipeline — retry forever with capped back-off.
|
||||
|
||||
This is an unattended appliance, so the pipeline never permanently gives up. The
|
||||
wait between restart attempts grows with the number of consecutive failures (so a
|
||||
persistently broken pipeline is not hammered) but is capped at ``max_interval_s``,
|
||||
and the failure streak resets to zero once genuine data flows again. A burst of
|
||||
crash signals within the current back-off window collapses to a single restart.
|
||||
"""
|
||||
|
||||
min_interval_s: float = 3.0
|
||||
max_interval_s: float = 60.0
|
||||
backoff_factor: float = 2.0
|
||||
|
||||
def backoff_for(self, consecutive_failures: int) -> float:
|
||||
"""Return the seconds to wait before the next restart for this failure streak.
|
||||
|
||||
``consecutive_failures`` is the number of restarts already attempted without a
|
||||
recovery: 0 → ``min_interval_s``, then each additional failure multiplies the
|
||||
wait by ``backoff_factor``, capped at ``max_interval_s``.
|
||||
"""
|
||||
if consecutive_failures <= 0:
|
||||
return self.min_interval_s
|
||||
# Beyond this many doublings the wait is always capped; clamp the exponent so a
|
||||
# long streak cannot overflow ``factor ** n``.
|
||||
max_exponent = max(1, math.ceil(math.log(self.max_interval_s / self.min_interval_s, self.backoff_factor)))
|
||||
exponent = min(int(consecutive_failures), max_exponent)
|
||||
return min(self.min_interval_s * (self.backoff_factor ** exponent), self.max_interval_s)
|
||||
|
||||
def should_restart_now(
|
||||
self,
|
||||
*,
|
||||
now_s: float,
|
||||
last_restart_s: float,
|
||||
consecutive_failures: int,
|
||||
) -> bool:
|
||||
"""Return whether enough back-off has elapsed since the last restart to retry."""
|
||||
return now_s - last_restart_s >= self.backoff_for(consecutive_failures)
|
||||
@@ -1,4 +1,9 @@
|
||||
"""Byte-wise cursor utilities for decoding binary ring payloads."""
|
||||
"""Byte-wise cursor utilities for decoding binary ring payloads.
|
||||
|
||||
Every read is bounds-checked and raises :class:`ValueError` on a truncated or
|
||||
malformed payload, so decoders surface a single, catchable error type for any
|
||||
corruption (rather than leaking ``struct.error``/``UnicodeDecodeError``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -6,48 +11,56 @@ import struct
|
||||
|
||||
|
||||
class ByteCursor:
|
||||
"""Read primitive values from bytes while tracking offset."""
|
||||
"""Read primitive values from bytes while tracking offset (bounds-checked)."""
|
||||
|
||||
def __init__(self, payload: bytes) -> None:
|
||||
"""Create cursor at start of payload."""
|
||||
self.payload = payload
|
||||
self.offset = 0
|
||||
|
||||
def _take(self, size: int) -> bytes:
|
||||
"""Consume ``size`` bytes, raising ValueError if the payload is too short."""
|
||||
end = self.offset + size
|
||||
if size < 0 or end > len(self.payload):
|
||||
raise ValueError(
|
||||
f"truncated payload: need {size} bytes at offset {self.offset}, "
|
||||
f"only {len(self.payload) - self.offset} remain"
|
||||
)
|
||||
data = self.payload[self.offset : end]
|
||||
self.offset = end
|
||||
return data
|
||||
|
||||
def read_u8(self) -> int:
|
||||
"""Read unsigned 8-bit integer."""
|
||||
value = struct.unpack_from("<B", self.payload, self.offset)[0]
|
||||
self.offset += 1
|
||||
return value
|
||||
return struct.unpack("<B", self._take(1))[0]
|
||||
|
||||
def read_u16(self) -> int:
|
||||
"""Read unsigned 16-bit integer."""
|
||||
value = struct.unpack_from("<H", self.payload, self.offset)[0]
|
||||
self.offset += 2
|
||||
return value
|
||||
return struct.unpack("<H", self._take(2))[0]
|
||||
|
||||
def read_u32(self) -> int:
|
||||
"""Read unsigned 32-bit integer."""
|
||||
value = struct.unpack_from("<I", self.payload, self.offset)[0]
|
||||
self.offset += 4
|
||||
return value
|
||||
return struct.unpack("<I", self._take(4))[0]
|
||||
|
||||
def read_u64(self) -> int:
|
||||
"""Read unsigned 64-bit integer."""
|
||||
value = struct.unpack_from("<Q", self.payload, self.offset)[0]
|
||||
self.offset += 8
|
||||
return value
|
||||
return struct.unpack("<Q", self._take(8))[0]
|
||||
|
||||
def read_f32(self) -> float:
|
||||
"""Read 32-bit float."""
|
||||
value = struct.unpack_from("<f", self.payload, self.offset)[0]
|
||||
self.offset += 4
|
||||
return float(value)
|
||||
return float(struct.unpack("<f", self._take(4))[0])
|
||||
|
||||
def read_bytes(self, size: int) -> bytes:
|
||||
"""Read raw byte slice of fixed size."""
|
||||
data = self.payload[self.offset : self.offset + size]
|
||||
self.offset += size
|
||||
return data
|
||||
"""Read raw byte slice of fixed size (bounds-checked)."""
|
||||
return self._take(size)
|
||||
|
||||
def read_str(self, size: int) -> str:
|
||||
"""Read a UTF-8 string of ``size`` bytes, raising ValueError on bad UTF-8."""
|
||||
raw = self._take(size)
|
||||
try:
|
||||
return raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("invalid UTF-8 in payload string") from exc
|
||||
|
||||
def remaining_bytes(self) -> int:
|
||||
"""Return unread byte count."""
|
||||
|
||||
@@ -78,7 +78,7 @@ def decode_result_collection(payload: bytes) -> ResultCollection:
|
||||
"""Decode one result payload from stream."""
|
||||
kind = cursor.read_u8()
|
||||
name_size = cursor.read_u16()
|
||||
name = cursor.read_bytes(name_size).decode("utf-8")
|
||||
name = cursor.read_str(name_size)
|
||||
|
||||
if kind == 1:
|
||||
point_count = cursor.read_u32()
|
||||
|
||||
@@ -35,12 +35,20 @@ class ShmRingReader:
|
||||
self._wait_for_ring_file(timeout_s=open_timeout_s, poll_s=open_poll_s)
|
||||
|
||||
self._file = self._path.open("r+b", buffering=0)
|
||||
self._mmap = mmap.mmap(self._file.fileno(), 0)
|
||||
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002)
|
||||
self._mmap: mmap.mmap | None = None
|
||||
try:
|
||||
self._mmap = mmap.mmap(self._file.fileno(), 0)
|
||||
self._validate_header_with_wait(timeout_s=1.0, poll_s=0.002)
|
||||
except BaseException:
|
||||
# A fail-fast open (absent/incompatible ring) must not leak the fd/mapping.
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close mmap and file handle."""
|
||||
self._mmap.close()
|
||||
if self._mmap is not None:
|
||||
self._mmap.close()
|
||||
self._mmap = None
|
||||
self._file.close()
|
||||
|
||||
def pop_payload(self) -> bytes | None:
|
||||
@@ -103,6 +111,45 @@ class ShmRingReader:
|
||||
return None
|
||||
return decode_result_collection(payload)
|
||||
|
||||
def peek_latest_payload(self) -> bytes | None:
|
||||
"""Return the most recently published payload WITHOUT consuming it.
|
||||
|
||||
Reads the newest slot through the seqlock and never advances `read_seq`, so a
|
||||
viewer can peek the freshest frame while the ring's real consumer keeps its own
|
||||
cursor — the two coexist without stealing each other's payloads. Inherently
|
||||
latest-wins: always the freshest published frame, or `None` when nothing has
|
||||
been published yet or the slot is being overwritten at this instant.
|
||||
"""
|
||||
write_seq = self._read_u64(24)
|
||||
if write_seq == 0:
|
||||
return None
|
||||
|
||||
latest_seq = write_seq - 1
|
||||
index = latest_seq % self.capacity
|
||||
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self.slot_size_bytes)
|
||||
|
||||
# Seqlock read with NO cursor advance: accept the slot only if its sequence
|
||||
# equals the published value both before and after the copy (i.e. the producer
|
||||
# did not lap this slot mid-read). Never touch read_seq, so the real consumer
|
||||
# is undisturbed.
|
||||
if self._read_u64(slot_offset + 8) != latest_seq + 1:
|
||||
return None
|
||||
payload_size = self._read_u32(slot_offset)
|
||||
if payload_size > self.slot_size_bytes:
|
||||
return None
|
||||
payload_offset = slot_offset + _SLOT_HEADER_SIZE
|
||||
payload = bytes(self._mmap[payload_offset : payload_offset + payload_size])
|
||||
if self._read_u64(slot_offset + 8) != latest_seq + 1:
|
||||
return None
|
||||
return payload
|
||||
|
||||
def peek_latest_result_collection(self) -> ResultCollection | None:
|
||||
"""Return the most recently published result collection without consuming it."""
|
||||
payload = self.peek_latest_payload()
|
||||
if payload is None:
|
||||
return None
|
||||
return decode_result_collection(payload)
|
||||
|
||||
def drop_all(self) -> int:
|
||||
"""Mark all unread slots as consumed and return number of dropped payloads."""
|
||||
write_seq = self._read_u64(24)
|
||||
|
||||
@@ -98,9 +98,12 @@ class ShmRingWriter:
|
||||
write_seq = self._read_u64(24)
|
||||
read_seq = self._read_u64(32)
|
||||
if max(0, write_seq - read_seq) >= self._capacity:
|
||||
self._write_u64(32, read_seq + 1)
|
||||
dropped = self._read_u64(40)
|
||||
self._write_u64(40, dropped + 1)
|
||||
# Advance the consumer cursor past the slot we are about to overwrite, but
|
||||
# re-read it first and move it only forward: a concurrent reader may have
|
||||
# already advanced it, and clobbering that backward would re-deliver an
|
||||
# already-consumed slot as a duplicate.
|
||||
self._write_u64(32, max(self._read_u64(32), read_seq + 1))
|
||||
self._write_u64(40, self._read_u64(40) + 1)
|
||||
|
||||
index = write_seq % self._capacity
|
||||
slot_offset = _HEADER_SIZE + index * (_SLOT_HEADER_SIZE + self._slot_size_bytes)
|
||||
|
||||
Reference in New Issue
Block a user