feat: initial commit - massive-cloud point cloud processing system
This commit is contained in:
795
alg/delivery_v1.1/underframe_deflection/api.py
Normal file
795
alg/delivery_v1.1/underframe_deflection/api.py
Normal file
@@ -0,0 +1,795 @@
|
||||
"""Public, stable API for the underframe deflection algorithm.
|
||||
|
||||
This module is the contract integrators should depend on. The internal
|
||||
algorithm modules (`beams`, `fitting`, `reference`, ...) are implementation
|
||||
details and may change between releases without notice; this module's
|
||||
function signatures and `DeflectionResult` schema are the stable surface.
|
||||
|
||||
Typical usage::
|
||||
|
||||
from underframe_deflection import compute_deflection, load_point_cloud
|
||||
|
||||
points = load_point_cloud("scan.ply")
|
||||
result = compute_deflection(points)
|
||||
print(result.left.selected_signed_mm, result.right.selected_signed_mm)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Sequence
|
||||
|
||||
import numpy as np
|
||||
|
||||
from . import io as _io
|
||||
from ._version import ALGORITHM_VERSION, SCHEMA_VERSION
|
||||
from .beams import candidate_rows, extract_beam_slices, track_side_candidates
|
||||
from .config import cloud_geometry, load_config, resolve_numeric_parameters
|
||||
from .exceptions import InsufficientPointsError, InvalidPointCloudError
|
||||
from .fitting import (
|
||||
extract_representative_points,
|
||||
fit_representative_line,
|
||||
representatives_to_rows,
|
||||
evaluate_line,
|
||||
)
|
||||
from .frame import (
|
||||
build_bottom_roi_mask,
|
||||
frame_hypotheses_to_dict,
|
||||
infer_vehicle_frame,
|
||||
transform_points,
|
||||
transform_vectors,
|
||||
)
|
||||
from .preprocess import estimate_spacing, preprocess_point_cloud
|
||||
from .reference import detect_reference_line, evaluate_reference_line
|
||||
from .structures import (
|
||||
FittedLine,
|
||||
FrameHypothesis,
|
||||
ReferenceLine,
|
||||
RepresentativePoint,
|
||||
SliceResult,
|
||||
TrackSolution,
|
||||
)
|
||||
|
||||
|
||||
ProgressCallback = Callable[[str, float], None]
|
||||
"""Signature for progress callbacks: ``callback(stage_name, fraction_in_[0,1])``."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public result types
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SideDeflection:
|
||||
"""Per-side deflection summary.
|
||||
|
||||
All distances are millimetres. ``signed`` values are positive when the
|
||||
beam sits above the reference line and negative when it sags below.
|
||||
Fields whose value is ``None`` were not measurable on this side; check
|
||||
``available`` and the parent result's ``quality_flags`` for the reason.
|
||||
"""
|
||||
|
||||
side: str
|
||||
available: bool
|
||||
reference_available: bool
|
||||
reference_confidence: float
|
||||
max_downward_mm: float | None
|
||||
max_downward_x_mm: float | None
|
||||
max_upward_mm: float | None
|
||||
max_upward_x_mm: float | None
|
||||
selected_x_mm: float | None
|
||||
selected_signed_mm: float | None
|
||||
selected_absolute_mm: float | None
|
||||
beam_fit_residual_rms_z_mm: float | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeflectionCurves:
|
||||
"""Sampled deflection along the longitudinal axis.
|
||||
|
||||
Arrays are aligned by index to ``x_mm``. Deflection arrays may contain
|
||||
``NaN`` where the curve is undefined (no overlap of beam fit and
|
||||
reference line, or low confidence).
|
||||
"""
|
||||
|
||||
x_mm: np.ndarray
|
||||
left_deflection_mm: np.ndarray
|
||||
right_deflection_mm: np.ndarray
|
||||
left_confidence: np.ndarray
|
||||
right_confidence: np.ndarray
|
||||
|
||||
|
||||
@dataclass
|
||||
class _ComputeArtifacts:
|
||||
"""Internal payload consumed by ``artifacts`` writers.
|
||||
|
||||
Not part of the public API. Fields may change between releases and
|
||||
should only be accessed via the artifact writer functions in
|
||||
``underframe_deflection.artifacts``.
|
||||
"""
|
||||
|
||||
raw_points: np.ndarray
|
||||
filtered_vehicle: np.ndarray
|
||||
work_vehicle: np.ndarray
|
||||
work_roi: np.ndarray
|
||||
filtered_roi: np.ndarray
|
||||
frame_hypotheses: list[FrameHypothesis]
|
||||
best_frame: FrameHypothesis
|
||||
slices: list[SliceResult]
|
||||
left_tracks: list[TrackSolution]
|
||||
right_tracks: list[TrackSolution]
|
||||
best_left: TrackSolution | None
|
||||
best_right: TrackSolution | None
|
||||
left_reps: list[RepresentativePoint]
|
||||
right_reps: list[RepresentativePoint]
|
||||
left_line: FittedLine | None
|
||||
right_line: FittedLine | None
|
||||
left_reference: ReferenceLine
|
||||
right_reference: ReferenceLine
|
||||
x_grid: np.ndarray
|
||||
left_payload: dict[str, np.ndarray]
|
||||
right_payload: dict[str, np.ndarray]
|
||||
deflection_rows: list[dict]
|
||||
left_summary: dict
|
||||
right_summary: dict
|
||||
resolved_config: dict[str, Any]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeflectionResult:
|
||||
"""The result of :func:`compute_deflection`.
|
||||
|
||||
Public, stable fields:
|
||||
|
||||
- :attr:`schema_version`, :attr:`algorithm_version` — version stamps
|
||||
- :attr:`left`, :attr:`right` — :class:`SideDeflection` headline results
|
||||
- :attr:`curves` — :class:`DeflectionCurves` sampled curves
|
||||
- :attr:`quality_flags` — list of non-fatal data-quality warnings
|
||||
- :attr:`report` — full nested dict (same content as ``report.json``)
|
||||
|
||||
The private attribute :attr:`_artifacts` carries internal data needed
|
||||
by :mod:`underframe_deflection.artifacts` writers and should not be
|
||||
accessed directly by integrators.
|
||||
"""
|
||||
|
||||
schema_version: str
|
||||
algorithm_version: str
|
||||
left: SideDeflection
|
||||
right: SideDeflection
|
||||
curves: DeflectionCurves
|
||||
quality_flags: list[str]
|
||||
report: dict
|
||||
_artifacts: _ComputeArtifacts | None = field(default=None, repr=False, compare=False)
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Return a fully-serializable dict (same content as ``report.json``)."""
|
||||
return self.report
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public entry points
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def load_point_cloud(path: str | Path) -> np.ndarray:
|
||||
"""Load a point cloud from disk into an ``(N, 3)`` ``float64`` array.
|
||||
|
||||
Supported formats: ``.ply`` (ASCII), ``.xyz``, ``.txt``, ``.csv``.
|
||||
|
||||
Raises:
|
||||
UnsupportedFormatError: extension is not recognised.
|
||||
InvalidPointCloudError: file is malformed.
|
||||
"""
|
||||
try:
|
||||
return _io.read_point_cloud(path)
|
||||
except ValueError as exc:
|
||||
# io.read_point_cloud raises ValueError for both unsupported format
|
||||
# and malformed PLY headers; surface as a structured exception.
|
||||
from .exceptions import UnsupportedFormatError
|
||||
|
||||
if "Unsupported point cloud format" in str(exc):
|
||||
raise UnsupportedFormatError(str(exc)) from exc
|
||||
raise InvalidPointCloudError(str(exc)) from exc
|
||||
|
||||
|
||||
def compute_deflection(
|
||||
points: np.ndarray | Sequence[Sequence[float]],
|
||||
config: dict | None = None,
|
||||
profile: str = "auto",
|
||||
*,
|
||||
progress: ProgressCallback | None = None,
|
||||
roi_aabb: dict | None = None,
|
||||
) -> DeflectionResult:
|
||||
"""Run the full deflection pipeline on an in-memory point cloud.
|
||||
|
||||
This function performs no file I/O. To persist the result use the
|
||||
writers in :mod:`underframe_deflection.artifacts`.
|
||||
|
||||
Args:
|
||||
points: ``(N, 3)`` array-like of XYZ coordinates in millimetres.
|
||||
The X axis is assumed longitudinal, Y lateral, Z vertical
|
||||
(positive up). If your scan uses a different convention,
|
||||
transform it before calling this function.
|
||||
config: Optional config override dict. Use the same shape as
|
||||
:data:`underframe_deflection.config.DEFAULT_CONFIG`. Values
|
||||
you omit fall back to the profile defaults.
|
||||
profile: Tuning profile name. ``"auto"`` works for most scans.
|
||||
See :data:`underframe_deflection.config.PROFILE_OVERRIDES`
|
||||
for the available profiles.
|
||||
progress: Optional callback invoked at each major stage with the
|
||||
stage name and a ``fraction in [0, 1]``.
|
||||
|
||||
Returns:
|
||||
:class:`DeflectionResult` with the headline numbers, sampled
|
||||
curves, full report dict, and quality flags.
|
||||
|
||||
Raises:
|
||||
InvalidPointCloudError: ``points`` is not a 2D ``(N, 3)`` array.
|
||||
InsufficientPointsError: too few points to run the pipeline.
|
||||
"""
|
||||
raw_points = _validate_points(points)
|
||||
|
||||
def _emit(stage: str, fraction: float) -> None:
|
||||
if progress is not None:
|
||||
progress(stage, float(fraction))
|
||||
|
||||
_emit("preprocess", 0.0)
|
||||
base_config = load_config(None, profile)
|
||||
if config:
|
||||
from .config import deep_merge
|
||||
|
||||
base_config = deep_merge(base_config, config)
|
||||
|
||||
if roi_aabb is not None:
|
||||
from .roi import normalize_aabb
|
||||
|
||||
normalized = normalize_aabb(roi_aabb)
|
||||
base_config["roi"]["aabb"] = normalized
|
||||
passthrough = base_config["preprocess"].setdefault(
|
||||
"passthrough", {"enabled": False, "aabb": None}
|
||||
)
|
||||
if passthrough.get("aabb") is None:
|
||||
passthrough["enabled"] = True
|
||||
passthrough["aabb"] = normalized
|
||||
|
||||
initial_geometry = cloud_geometry(raw_points)
|
||||
initial_spacing = estimate_spacing(
|
||||
raw_points,
|
||||
sample_size=base_config["preprocess"]["spacing_sample_size"],
|
||||
)
|
||||
preprocess_config = resolve_numeric_parameters(base_config, initial_geometry, initial_spacing)
|
||||
preprocess_result = preprocess_point_cloud(raw_points, preprocess_config)
|
||||
_emit("preprocess", 1.0)
|
||||
|
||||
_emit("frame", 0.0)
|
||||
if base_config["frame"].get("use_input_axes", False):
|
||||
best_frame = FrameHypothesis(
|
||||
name="input_axes_identity",
|
||||
axes=np.eye(3, dtype=np.float64),
|
||||
origin=np.zeros(3, dtype=np.float64),
|
||||
score=1.0,
|
||||
bottom_score=1.0,
|
||||
symmetry_score=1.0,
|
||||
flatness_score=1.0,
|
||||
hole_score=1.0,
|
||||
continuity_score=1.0,
|
||||
)
|
||||
frame_hypotheses = [best_frame]
|
||||
else:
|
||||
frame_hypotheses, best_frame = infer_vehicle_frame(
|
||||
preprocess_result.work_points,
|
||||
preprocess_result.work_normals,
|
||||
preprocess_result.work_curvature,
|
||||
preprocess_config,
|
||||
)
|
||||
_emit("frame", 1.0)
|
||||
|
||||
filtered_vehicle = transform_points(
|
||||
preprocess_result.filtered_points, best_frame.axes, best_frame.origin
|
||||
)
|
||||
work_vehicle = transform_points(
|
||||
preprocess_result.work_points, best_frame.axes, best_frame.origin
|
||||
)
|
||||
work_normals_vehicle = transform_vectors(preprocess_result.work_normals, best_frame.axes)
|
||||
|
||||
vehicle_geometry = cloud_geometry(work_vehicle)
|
||||
resolved_config = resolve_numeric_parameters(
|
||||
base_config, vehicle_geometry, preprocess_result.spacing
|
||||
)
|
||||
roi_mask_work, roi_payload = build_bottom_roi_mask(work_vehicle, resolved_config)
|
||||
roi_mask_filtered = _apply_roi_mask(filtered_vehicle, roi_payload)
|
||||
|
||||
work_roi = work_vehicle[roi_mask_work]
|
||||
work_roi_normals = work_normals_vehicle[roi_mask_work]
|
||||
filtered_roi = filtered_vehicle[roi_mask_filtered]
|
||||
|
||||
_emit("beam_tracking", 0.0)
|
||||
slices = extract_beam_slices(work_roi, work_roi_normals, resolved_config)
|
||||
left_tracks = track_side_candidates(slices, "left", resolved_config)
|
||||
right_tracks = track_side_candidates(slices, "right", resolved_config)
|
||||
best_left = left_tracks[0] if left_tracks else None
|
||||
best_right = right_tracks[0] if right_tracks else None
|
||||
_emit("beam_tracking", 1.0)
|
||||
|
||||
_emit("line_fitting", 0.0)
|
||||
left_reps = (
|
||||
extract_representative_points(filtered_roi, best_left, resolved_config)
|
||||
if best_left
|
||||
else []
|
||||
)
|
||||
right_reps = (
|
||||
extract_representative_points(filtered_roi, best_right, resolved_config)
|
||||
if best_right
|
||||
else []
|
||||
)
|
||||
left_line = fit_representative_line(left_reps, resolved_config, "left")
|
||||
right_line = fit_representative_line(right_reps, resolved_config, "right")
|
||||
_emit("line_fitting", 1.0)
|
||||
|
||||
_emit("reference", 0.0)
|
||||
left_reference = (
|
||||
detect_reference_line(filtered_roi, left_line, resolved_config, "left")
|
||||
if left_line is not None
|
||||
else ReferenceLine("left", False, None, None, None, 0.0, None)
|
||||
)
|
||||
right_reference = (
|
||||
detect_reference_line(filtered_roi, right_line, resolved_config, "right")
|
||||
if right_line is not None
|
||||
else ReferenceLine("right", False, None, None, None, 0.0, None)
|
||||
)
|
||||
_emit("reference", 1.0)
|
||||
|
||||
_emit("deflection", 0.0)
|
||||
line_candidates = [line for line in (left_line, right_line) if line is not None]
|
||||
if line_candidates:
|
||||
grid_step = (
|
||||
resolved_config["representative"]["grid_step_mm"]
|
||||
* resolved_config["deflection"]["evaluation_grid_factor"]
|
||||
)
|
||||
x_min = min(float(line.x_support.min()) for line in line_candidates)
|
||||
x_max = max(float(line.x_support.max()) for line in line_candidates)
|
||||
x_grid = np.arange(x_min, x_max + grid_step, grid_step)
|
||||
else:
|
||||
x_grid = np.zeros(0, dtype=np.float64)
|
||||
|
||||
left_summary, left_payload = _evaluate_side_deflection("left", left_line, left_reference, x_grid)
|
||||
right_summary, right_payload = _evaluate_side_deflection(
|
||||
"right", right_line, right_reference, x_grid
|
||||
)
|
||||
deflection_rows = (
|
||||
_build_deflection_rows(x_grid, left_payload, right_payload) if len(x_grid) else []
|
||||
)
|
||||
_emit("deflection", 1.0)
|
||||
|
||||
symmetry = _side_symmetry_metrics(best_left, best_right)
|
||||
quality_flags = _collect_quality_flags(
|
||||
best_left,
|
||||
best_right,
|
||||
left_line,
|
||||
right_line,
|
||||
left_reference,
|
||||
right_reference,
|
||||
symmetry,
|
||||
resolved_config,
|
||||
)
|
||||
|
||||
report = {
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"algorithm_version": ALGORITHM_VERSION,
|
||||
"axis_convention": {"x": "longitudinal", "y": "lateral", "z": "height"},
|
||||
"profile": profile,
|
||||
"resolved_config": resolved_config,
|
||||
"preprocess": preprocess_result.stats,
|
||||
"frame_hypotheses": frame_hypotheses_to_dict(frame_hypotheses),
|
||||
"selected_frame": frame_hypotheses_to_dict([best_frame])[0],
|
||||
"roi": roi_payload,
|
||||
"symmetry_check": symmetry,
|
||||
"left_track": _track_to_dict(best_left),
|
||||
"right_track": _track_to_dict(best_right),
|
||||
"representative_lines": {
|
||||
"left": _fitted_line_to_dict(left_line),
|
||||
"right": _fitted_line_to_dict(right_line),
|
||||
},
|
||||
"reference_lines": {
|
||||
"left": _reference_to_dict(left_reference),
|
||||
"right": _reference_to_dict(right_reference),
|
||||
},
|
||||
"alternate_solutions": {
|
||||
"left_tracks": [_track_to_dict(track) for track in left_tracks[1:]],
|
||||
"right_tracks": [_track_to_dict(track) for track in right_tracks[1:]],
|
||||
"left_reference": [dict(item) for item in left_reference.alternates],
|
||||
"right_reference": [dict(item) for item in right_reference.alternates],
|
||||
},
|
||||
"deflection": {"left": left_summary, "right": right_summary},
|
||||
"quality_flags": quality_flags,
|
||||
"representative_points": {
|
||||
"left": representatives_to_rows(left_reps),
|
||||
"right": representatives_to_rows(right_reps),
|
||||
},
|
||||
}
|
||||
|
||||
artifacts = _ComputeArtifacts(
|
||||
raw_points=raw_points,
|
||||
filtered_vehicle=filtered_vehicle,
|
||||
work_vehicle=work_vehicle,
|
||||
work_roi=work_roi,
|
||||
filtered_roi=filtered_roi,
|
||||
frame_hypotheses=frame_hypotheses,
|
||||
best_frame=best_frame,
|
||||
slices=slices,
|
||||
left_tracks=left_tracks,
|
||||
right_tracks=right_tracks,
|
||||
best_left=best_left,
|
||||
best_right=best_right,
|
||||
left_reps=left_reps,
|
||||
right_reps=right_reps,
|
||||
left_line=left_line,
|
||||
right_line=right_line,
|
||||
left_reference=left_reference,
|
||||
right_reference=right_reference,
|
||||
x_grid=x_grid,
|
||||
left_payload=left_payload,
|
||||
right_payload=right_payload,
|
||||
deflection_rows=deflection_rows,
|
||||
left_summary=left_summary,
|
||||
right_summary=right_summary,
|
||||
resolved_config=resolved_config,
|
||||
)
|
||||
|
||||
return DeflectionResult(
|
||||
schema_version=SCHEMA_VERSION,
|
||||
algorithm_version=ALGORITHM_VERSION,
|
||||
left=_side_summary_to_public("left", left_summary, left_line),
|
||||
right=_side_summary_to_public("right", right_summary, right_line),
|
||||
curves=DeflectionCurves(
|
||||
x_mm=x_grid,
|
||||
left_deflection_mm=left_payload["deflection"],
|
||||
right_deflection_mm=right_payload["deflection"],
|
||||
left_confidence=np.minimum(left_payload["beam_conf"], left_payload["ref_conf"]),
|
||||
right_confidence=np.minimum(right_payload["beam_conf"], right_payload["ref_conf"]),
|
||||
),
|
||||
quality_flags=quality_flags,
|
||||
report=report,
|
||||
_artifacts=artifacts,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers (private)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _validate_points(points: np.ndarray | Sequence[Sequence[float]]) -> np.ndarray:
|
||||
array = np.asarray(points, dtype=np.float64)
|
||||
if array.ndim != 2 or array.shape[1] < 3:
|
||||
raise InvalidPointCloudError(
|
||||
f"Point cloud must be a 2D (N, 3+) array, got shape {array.shape}."
|
||||
)
|
||||
if array.shape[1] > 3:
|
||||
array = array[:, :3]
|
||||
if len(array) < 256:
|
||||
raise InsufficientPointsError(
|
||||
f"Need at least 256 points to run the pipeline, got {len(array)}."
|
||||
)
|
||||
return np.ascontiguousarray(array)
|
||||
|
||||
|
||||
def _apply_roi_mask(points_vehicle: np.ndarray, roi_payload: dict) -> np.ndarray:
|
||||
mask = (
|
||||
(points_vehicle[:, 0] >= roi_payload["x_min"])
|
||||
& (points_vehicle[:, 0] <= roi_payload["x_max"])
|
||||
& (points_vehicle[:, 2] <= roi_payload["z_ceiling"])
|
||||
)
|
||||
if roi_payload.get("source") == "external_aabb":
|
||||
mask &= (
|
||||
(points_vehicle[:, 1] >= roi_payload["y_min"])
|
||||
& (points_vehicle[:, 1] <= roi_payload["y_max"])
|
||||
& (points_vehicle[:, 2] >= roi_payload["z_min"])
|
||||
)
|
||||
return mask
|
||||
|
||||
|
||||
def _track_to_dict(track: TrackSolution | None) -> dict | None:
|
||||
if track is None:
|
||||
return None
|
||||
return {
|
||||
"side": track.side,
|
||||
"rank": track.rank,
|
||||
"score": track.score,
|
||||
"coverage_ratio": track.coverage_ratio,
|
||||
"coverage_count": track.coverage_count,
|
||||
"score_margin": track.score_margin,
|
||||
"confidence": track.confidence,
|
||||
"candidate_ids": track.candidate_ids,
|
||||
"candidates": [
|
||||
{
|
||||
"candidate_id": candidate.candidate_id,
|
||||
"slice_index": candidate.slice_index,
|
||||
"x_center": candidate.x_center,
|
||||
"y_center": candidate.y_center,
|
||||
"z_low": candidate.z_low,
|
||||
"width": candidate.width,
|
||||
"score": candidate.score,
|
||||
}
|
||||
for candidate in track.candidates
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _fitted_line_to_dict(line: FittedLine | None) -> dict | None:
|
||||
if line is None:
|
||||
return None
|
||||
return {
|
||||
"side": line.side,
|
||||
"y_residual_rms": line.y_residual_rms,
|
||||
"z_residual_rms": line.z_residual_rms,
|
||||
"support_x_min": float(line.x_support.min()),
|
||||
"support_x_max": float(line.x_support.max()),
|
||||
"support_count": int(len(line.x_support)),
|
||||
"low_confidence_segments": [list(segment) for segment in line.low_confidence_segments],
|
||||
"gaps": [list(segment) for segment in line.gaps],
|
||||
}
|
||||
|
||||
|
||||
def _reference_to_dict(reference: ReferenceLine) -> dict:
|
||||
return {
|
||||
"side": reference.side,
|
||||
"available": reference.available,
|
||||
"confidence": reference.confidence,
|
||||
"residual": reference.residual,
|
||||
"point_front": None if reference.point_front is None else reference.point_front.tolist(),
|
||||
"point_back": None if reference.point_back is None else reference.point_back.tolist(),
|
||||
"direction": None if reference.direction is None else reference.direction.tolist(),
|
||||
"candidates_front": [
|
||||
asdict(candidate) | {"center": candidate.center.tolist()}
|
||||
for candidate in reference.candidates_front
|
||||
],
|
||||
"candidates_back": [
|
||||
asdict(candidate) | {"center": candidate.center.tolist()}
|
||||
for candidate in reference.candidates_back
|
||||
],
|
||||
"alternates": [dict(item) for item in reference.alternates],
|
||||
}
|
||||
|
||||
|
||||
def _side_symmetry_metrics(
|
||||
left_track: TrackSolution | None, right_track: TrackSolution | None
|
||||
) -> dict:
|
||||
if not left_track or not right_track or not left_track.candidates or not right_track.candidates:
|
||||
return {"available": False}
|
||||
y_mid_samples = [
|
||||
candidate.y_mid for candidate in left_track.candidates + right_track.candidates
|
||||
]
|
||||
y_mid = float(np.median(y_mid_samples))
|
||||
left_offset = float(
|
||||
np.median([abs(candidate.y_center - y_mid) for candidate in left_track.candidates])
|
||||
)
|
||||
right_offset = float(
|
||||
np.median([abs(candidate.y_center - y_mid) for candidate in right_track.candidates])
|
||||
)
|
||||
symmetry_error = abs(left_offset - right_offset)
|
||||
return {
|
||||
"available": True,
|
||||
"midline_y": y_mid,
|
||||
"left_offset": left_offset,
|
||||
"right_offset": right_offset,
|
||||
"symmetry_error": symmetry_error,
|
||||
"relative_symmetry_error": symmetry_error / max((left_offset + right_offset) * 0.5, 1.0),
|
||||
}
|
||||
|
||||
|
||||
def _evaluate_side_deflection(
|
||||
side: str,
|
||||
line: FittedLine | None,
|
||||
reference: ReferenceLine,
|
||||
x_grid: np.ndarray,
|
||||
) -> tuple[dict, dict[str, np.ndarray]]:
|
||||
if line is None:
|
||||
empty = np.full_like(x_grid, np.nan, dtype=np.float64)
|
||||
return (
|
||||
{
|
||||
"side": side,
|
||||
"available": False,
|
||||
"reason": "representative_line_unavailable",
|
||||
"selected_measurement": {
|
||||
"available": False,
|
||||
"reason": "representative_line_unavailable",
|
||||
},
|
||||
},
|
||||
{
|
||||
"beam_y": empty,
|
||||
"beam_z": empty,
|
||||
"beam_conf": np.zeros_like(x_grid),
|
||||
"ref_y": empty,
|
||||
"ref_z": empty,
|
||||
"ref_conf": np.zeros_like(x_grid),
|
||||
"deflection": empty,
|
||||
},
|
||||
)
|
||||
|
||||
beam_y, beam_z, beam_conf = evaluate_line(line, x_grid)
|
||||
ref_eval, ref_conf = evaluate_reference_line(reference, x_grid)
|
||||
if np.ndim(ref_eval) != 2:
|
||||
ref_y = np.full_like(x_grid, np.nan, dtype=np.float64)
|
||||
ref_z = np.full_like(x_grid, np.nan, dtype=np.float64)
|
||||
else:
|
||||
ref_y = ref_eval[:, 0]
|
||||
ref_z = ref_eval[:, 1]
|
||||
|
||||
deflection = beam_z - ref_z
|
||||
invalid = np.isnan(ref_z) | (beam_conf <= 0.0) | (ref_conf <= 0.0)
|
||||
deflection[invalid] = np.nan
|
||||
valid = ~np.isnan(deflection)
|
||||
summary = {
|
||||
"side": side,
|
||||
"available": bool(np.any(valid)),
|
||||
"reference_available": reference.available,
|
||||
"beam_fit_residual_rms_y": line.y_residual_rms,
|
||||
"beam_fit_residual_rms_z": line.z_residual_rms,
|
||||
"reference_confidence": reference.confidence,
|
||||
"reference_residual": reference.residual,
|
||||
}
|
||||
if np.any(valid):
|
||||
valid_x = x_grid[valid]
|
||||
valid_deflection = deflection[valid]
|
||||
valid_indices = np.flatnonzero(valid)
|
||||
down_index = int(np.argmin(valid_deflection))
|
||||
up_index = int(np.argmax(valid_deflection))
|
||||
abs_index = int(np.argmax(np.abs(valid_deflection)))
|
||||
selected_x = float(valid_x[abs_index])
|
||||
global_index = int(valid_indices[abs_index])
|
||||
beam_point = np.array(
|
||||
[selected_x, float(beam_y[global_index]), float(beam_z[global_index])],
|
||||
dtype=np.float64,
|
||||
)
|
||||
reference_point = np.array(
|
||||
[selected_x, float(ref_y[global_index]), float(ref_z[global_index])],
|
||||
dtype=np.float64,
|
||||
)
|
||||
vertical_anchor = np.array(
|
||||
[selected_x, float(beam_y[global_index]), float(ref_z[global_index])],
|
||||
dtype=np.float64,
|
||||
)
|
||||
selected_value = float(valid_deflection[abs_index])
|
||||
summary.update(
|
||||
{
|
||||
"max_downward_deflection_mm": float(valid_deflection[down_index]),
|
||||
"max_downward_x_mm": float(valid_x[down_index]),
|
||||
"max_upward_deflection_mm": float(valid_deflection[up_index]),
|
||||
"max_upward_x_mm": float(valid_x[up_index]),
|
||||
"selected_measurement": {
|
||||
"available": True,
|
||||
"selection_rule": "maximum_absolute_vertical_deflection",
|
||||
"selected_type": "downward" if selected_value < 0.0 else "upward",
|
||||
"x_mm": selected_x,
|
||||
"signed_deflection_mm": selected_value,
|
||||
"absolute_deflection_mm": float(abs(selected_value)),
|
||||
"beam_point": beam_point.tolist(),
|
||||
"reference_point": reference_point.tolist(),
|
||||
"vertical_anchor_point": vertical_anchor.tolist(),
|
||||
"vertical_distance_mm": float(abs(selected_value)),
|
||||
},
|
||||
}
|
||||
)
|
||||
else:
|
||||
summary["reason"] = "reference_unavailable_or_no_valid_overlap"
|
||||
summary["selected_measurement"] = {
|
||||
"available": False,
|
||||
"reason": "reference_unavailable_or_no_valid_overlap",
|
||||
}
|
||||
return (
|
||||
summary,
|
||||
{
|
||||
"beam_y": beam_y,
|
||||
"beam_z": beam_z,
|
||||
"beam_conf": beam_conf,
|
||||
"ref_y": ref_y,
|
||||
"ref_z": ref_z,
|
||||
"ref_conf": ref_conf,
|
||||
"deflection": deflection,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_deflection_rows(
|
||||
x_grid: np.ndarray,
|
||||
left_payload: dict[str, np.ndarray],
|
||||
right_payload: dict[str, np.ndarray],
|
||||
) -> list[dict]:
|
||||
rows: list[dict] = []
|
||||
for index, x_value in enumerate(x_grid):
|
||||
rows.append(
|
||||
{
|
||||
"x": float(x_value),
|
||||
"left_beam_y": _nan_safe(left_payload["beam_y"][index]),
|
||||
"left_beam_z": _nan_safe(left_payload["beam_z"][index]),
|
||||
"left_ref_y": _nan_safe(left_payload["ref_y"][index]),
|
||||
"left_ref_z": _nan_safe(left_payload["ref_z"][index]),
|
||||
"left_deflection": _nan_safe(left_payload["deflection"][index]),
|
||||
"left_confidence": float(
|
||||
min(left_payload["beam_conf"][index], left_payload["ref_conf"][index])
|
||||
),
|
||||
"right_beam_y": _nan_safe(right_payload["beam_y"][index]),
|
||||
"right_beam_z": _nan_safe(right_payload["beam_z"][index]),
|
||||
"right_ref_y": _nan_safe(right_payload["ref_y"][index]),
|
||||
"right_ref_z": _nan_safe(right_payload["ref_z"][index]),
|
||||
"right_deflection": _nan_safe(right_payload["deflection"][index]),
|
||||
"right_confidence": float(
|
||||
min(right_payload["beam_conf"][index], right_payload["ref_conf"][index])
|
||||
),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def _nan_safe(value: float) -> float:
|
||||
return float(value) if not np.isnan(value) else float("nan")
|
||||
|
||||
|
||||
def _collect_quality_flags(
|
||||
best_left: TrackSolution | None,
|
||||
best_right: TrackSolution | None,
|
||||
left_line: FittedLine | None,
|
||||
right_line: FittedLine | None,
|
||||
left_reference: ReferenceLine,
|
||||
right_reference: ReferenceLine,
|
||||
symmetry: dict,
|
||||
resolved_config: dict,
|
||||
) -> list[str]:
|
||||
flags: list[str] = []
|
||||
low_conf = resolved_config["reference"]["low_confidence_threshold"]
|
||||
if best_left is None:
|
||||
flags.append("left_track_missing")
|
||||
if best_right is None:
|
||||
flags.append("right_track_missing")
|
||||
if left_line is None:
|
||||
flags.append("left_representative_line_missing")
|
||||
if right_line is None:
|
||||
flags.append("right_representative_line_missing")
|
||||
if not left_reference.available:
|
||||
flags.append("left_reference_unavailable")
|
||||
if not right_reference.available:
|
||||
flags.append("right_reference_unavailable")
|
||||
if left_reference.available and left_reference.confidence < low_conf:
|
||||
flags.append("left_reference_low_confidence")
|
||||
if right_reference.available and right_reference.confidence < low_conf:
|
||||
flags.append("right_reference_low_confidence")
|
||||
if symmetry.get("available") and symmetry["relative_symmetry_error"] > 0.18:
|
||||
flags.append("left_right_symmetry_warning")
|
||||
if best_left and best_left.coverage_ratio < 0.45:
|
||||
flags.append("left_track_low_coverage")
|
||||
if best_right and best_right.coverage_ratio < 0.45:
|
||||
flags.append("right_track_low_coverage")
|
||||
return flags
|
||||
|
||||
|
||||
def _side_summary_to_public(
|
||||
side: str, summary: dict, line: FittedLine | None
|
||||
) -> SideDeflection:
|
||||
selected = summary.get("selected_measurement", {}) or {}
|
||||
return SideDeflection(
|
||||
side=side,
|
||||
available=bool(summary.get("available", False)),
|
||||
reference_available=bool(summary.get("reference_available", False)),
|
||||
reference_confidence=float(summary.get("reference_confidence", 0.0) or 0.0),
|
||||
max_downward_mm=summary.get("max_downward_deflection_mm"),
|
||||
max_downward_x_mm=summary.get("max_downward_x_mm"),
|
||||
max_upward_mm=summary.get("max_upward_deflection_mm"),
|
||||
max_upward_x_mm=summary.get("max_upward_x_mm"),
|
||||
selected_x_mm=selected.get("x_mm"),
|
||||
selected_signed_mm=selected.get("signed_deflection_mm"),
|
||||
selected_absolute_mm=selected.get("absolute_deflection_mm"),
|
||||
beam_fit_residual_rms_z_mm=line.z_residual_rms if line is not None else None,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DeflectionResult",
|
||||
"SideDeflection",
|
||||
"DeflectionCurves",
|
||||
"ProgressCallback",
|
||||
"compute_deflection",
|
||||
"load_point_cloud",
|
||||
]
|
||||
Reference in New Issue
Block a user