"""Helpers for extracting GPR objects from result collections. Locator TCP delivery now lives in the C++ data_processor. This module retains only the inspection helpers that the GUI uses for plotting. """ from __future__ import annotations import numpy as np from python_app.models.dataset_model import ResultCollection, ResultPayload def collection_payload_by_name( collection: ResultCollection, name: str, kind: int | None = None, ) -> ResultPayload | None: """Return the first collection payload matching name and optional kind.""" for payload in collection.collection_payloads: if payload.processing_name != name: continue if kind is not None and int(payload.kind) != int(kind): continue return payload return None def collection_payloads_by_prefix( collection: ResultCollection, prefix: str, kind: int | None = None, ) -> list[ResultPayload]: """Return collection payloads matching a processing-name prefix.""" payloads: list[ResultPayload] = [] for payload in collection.collection_payloads: if not str(payload.processing_name).startswith(prefix): continue if kind is not None and int(payload.kind) != int(kind): continue payloads.append(payload) return payloads def collection_has_gpr_payloads(collection: ResultCollection) -> bool: """Return whether collection carries GPR-specific collection payloads.""" return any( str(payload.processing_name).startswith("gpr_") for payload in collection.collection_payloads ) def gpr_object_rows(collection: ResultCollection) -> np.ndarray: """Return object rows as `[x_m, z_m, score]` from a GPR collection.""" points_payload = collection_payload_by_name(collection, "gpr_points", kind=4) if points_payload is not None: points = np.asarray(points_payload.table, dtype=np.float32) if points.ndim == 2 and points.shape[1] >= 3: return points[:, :3] centers_payload = collection_payload_by_name(collection, "gpr_region_centers", kind=4) if centers_payload is not None: centers = np.asarray(centers_payload.table, dtype=np.float32) if centers.ndim == 2 and centers.shape[1] >= 3: 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)