"""Helpers to parse and apply optional detector panel geometry."""
from __future__ import annotations
from typing import List, Optional, Sequence, Tuple
import numpy as np
def _strip_inline_comment(value) -> str:
if value is None:
return ""
text = str(value)
for marker in (";", "#"):
idx = text.find(marker)
if idx != -1:
text = text[:idx]
return text.strip()
def _parse_bool(value, default: bool = False) -> bool:
text = _strip_inline_comment(value).lower()
if not text:
return bool(default)
if text in {"1", "true", "yes", "on"}:
return True
if text in {"0", "false", "no", "off"}:
return False
return bool(default)
def _parse_pair(value) -> Optional[Tuple[float, float]]:
text = _strip_inline_comment(value)
if not text:
return None
parts = [p.strip() for p in text.split(",")]
if len(parts) != 2:
return None
try:
return float(parts[0]), float(parts[1])
except ValueError:
return None
def _parse_matrix_2x2(value) -> Optional[np.ndarray]:
text = _strip_inline_comment(value)
if not text:
return None
parts = [p.strip() for p in text.split(",")]
if len(parts) != 4:
return None
try:
mat = np.array([float(parts[0]), float(parts[1]), float(parts[2]), float(parts[3])], dtype=np.float64)
except ValueError:
return None
if np.allclose(mat, 0.0):
return np.eye(2, dtype=np.float64)
return mat.reshape(2, 2)
def _panel_contains_raw(panel: dict, raw_x: float, raw_y: float, tol: float = 0.0) -> bool:
return (
panel["raw_min_x"] - tol <= raw_x <= panel["raw_max_x"] + tol
and panel["raw_min_y"] - tol <= raw_y <= panel["raw_max_y"] + tol
)
[docs]
def load_detector_geometry(config) -> Optional[dict]:
"""Parse optional [DetectorGeometry] section from a ConfigParser."""
if config is None or not config.has_section("DetectorGeometry"):
return None
sec = config["DetectorGeometry"]
if not _parse_bool(sec.get("enabled", "true"), default=True):
return None
global_offset = _parse_pair(sec.get("global_offset", "0,0")) or (0.0, 0.0)
global_A = _parse_matrix_2x2(sec.get("global_a", "1,0,0,1"))
if global_A is None:
global_A = np.eye(2, dtype=np.float64)
try:
global_A_inv = np.linalg.inv(global_A)
except np.linalg.LinAlgError:
return None
panel_ids_raw = _strip_inline_comment(sec.get("panels", ""))
if panel_ids_raw:
panel_ids = [p.strip().lower() for p in panel_ids_raw.split(",") if p.strip()]
else:
panel_ids = []
for key in sec.keys():
if key.endswith("_raw_min"):
panel_ids.append(key[:-8].strip().lower())
panel_ids = sorted(set(panel_ids))
panels = []
panel_by_id = {}
for panel_id in panel_ids:
raw_min = _parse_pair(sec.get(f"{panel_id}_raw_min"))
raw_max = _parse_pair(sec.get(f"{panel_id}_raw_max"))
offset = _parse_pair(sec.get(f"{panel_id}_offset", "0,0")) or (0.0, 0.0)
A = _parse_matrix_2x2(sec.get(f"{panel_id}_a", "1,0,0,1"))
if raw_min is None or raw_max is None or A is None:
continue
try:
A_inv = np.linalg.inv(A)
except np.linalg.LinAlgError:
continue
raw_min_x = float(min(raw_min[0], raw_max[0]))
raw_max_x = float(max(raw_min[0], raw_max[0]))
raw_min_y = float(min(raw_min[1], raw_max[1]))
raw_max_y = float(max(raw_min[1], raw_max[1]))
panel = {
"id": panel_id,
"raw_min_x": raw_min_x,
"raw_max_x": raw_max_x,
"raw_min_y": raw_min_y,
"raw_max_y": raw_max_y,
"offset": np.array(offset, dtype=np.float64),
"A": A,
"A_inv": A_inv,
}
panels.append(panel)
panel_by_id[panel_id] = panel
if not panels:
return None
return {
"global_offset": np.array(global_offset, dtype=np.float64),
"global_A": global_A,
"global_A_inv": global_A_inv,
"panels": panels,
"panel_by_id": panel_by_id,
}
[docs]
def validate_geometry_for_frame(
geometry: Optional[dict],
frame_size_x: int,
frame_size_y: int,
) -> Tuple[List[str], List[str]]:
"""
Validate detector geometry against actual frame shape.
Returns (errors, warnings).
"""
errors: List[str] = []
warnings: List[str] = []
if geometry is None:
return errors, warnings
if frame_size_x <= 0 or frame_size_y <= 0:
return errors, warnings
x_max_allowed = float(frame_size_x - 1)
y_max_allowed = float(frame_size_y - 1)
panels = geometry.get("panels", [])
for panel in panels:
pid = panel.get("id", "<unknown>")
pminx = float(panel["raw_min_x"])
pmaxx = float(panel["raw_max_x"])
pminy = float(panel["raw_min_y"])
pmaxy = float(panel["raw_max_y"])
if pminx < 0.0 or pmaxx > x_max_allowed or pminy < 0.0 or pmaxy > y_max_allowed:
errors.append(
f"Panel '{pid}' bounds [{pminx:.3f},{pmaxx:.3f}]x[{pminy:.3f},{pmaxy:.3f}] "
f"outside frame bounds [0,{x_max_allowed:.0f}]x[0,{y_max_allowed:.0f}]"
)
for i in range(len(panels)):
a = panels[i]
for j in range(i + 1, len(panels)):
b = panels[j]
overlap_x = min(float(a["raw_max_x"]), float(b["raw_max_x"])) - max(float(a["raw_min_x"]), float(b["raw_min_x"]))
overlap_y = min(float(a["raw_max_y"]), float(b["raw_max_y"])) - max(float(a["raw_min_y"]), float(b["raw_min_y"]))
if overlap_x >= 0.0 and overlap_y >= 0.0:
warnings.append(
f"Panels '{a.get('id', i)}' and '{b.get('id', j)}' overlap in raw bounds."
)
return errors, warnings
def _raw_to_corrected_local(raw_xy: np.ndarray, panel: dict) -> np.ndarray:
raw_min = np.array([panel["raw_min_x"], panel["raw_min_y"]], dtype=np.float64)
delta = raw_xy - raw_min
return raw_min + panel["offset"] + (panel["A"] @ delta)
def _corrected_local_to_raw(corrected_local: np.ndarray, panel: dict) -> np.ndarray:
raw_min = np.array([panel["raw_min_x"], panel["raw_min_y"]], dtype=np.float64)
delta = panel["A_inv"] @ (corrected_local - (raw_min + panel["offset"]))
return raw_min + delta
[docs]
def raw_to_corrected(
raw_xy: Sequence[float],
geometry: Optional[dict],
panel_hint: Optional[str] = None,
) -> Tuple[np.ndarray, Optional[str]]:
"""Map one raw coordinate to corrected coordinates."""
raw = np.asarray(raw_xy, dtype=np.float64)
if geometry is None:
return raw.copy(), None
panel = None
if panel_hint:
candidate = geometry["panel_by_id"].get(str(panel_hint).lower())
if candidate and _panel_contains_raw(candidate, raw[0], raw[1]):
panel = candidate
if panel is None:
for p in geometry["panels"]:
if _panel_contains_raw(p, raw[0], raw[1]):
panel = p
break
corrected_local = raw.copy() if panel is None else _raw_to_corrected_local(raw, panel)
corrected = geometry["global_offset"] + (geometry["global_A"] @ corrected_local)
return corrected, (panel["id"] if panel is not None else None)
[docs]
def corrected_to_raw(
corrected_xy: Sequence[float],
geometry: Optional[dict],
panel_hint: Optional[str] = None,
) -> Tuple[np.ndarray, Optional[str]]:
"""Map one corrected coordinate back to raw coordinates."""
corrected = np.asarray(corrected_xy, dtype=np.float64)
if geometry is None:
return corrected.copy(), None
corrected_local = geometry["global_A_inv"] @ (corrected - geometry["global_offset"])
panel_candidates: List[dict] = []
if panel_hint:
hint_panel = geometry["panel_by_id"].get(str(panel_hint).lower())
if hint_panel is not None:
panel_candidates.append(hint_panel)
panel_candidates.extend([p for p in geometry["panels"] if p not in panel_candidates])
for panel in panel_candidates:
raw = _corrected_local_to_raw(corrected_local, panel)
raw_x = float(raw[0])
raw_y = float(raw[1])
if _panel_contains_raw(panel, raw_x, raw_y, tol=0.5):
return raw, panel["id"]
# Fallback: only undo global transform if no panel mapping is available.
return corrected_local.copy(), None
[docs]
def raw_to_corrected_points(
raw_points: np.ndarray,
geometry: Optional[dict],
) -> Tuple[np.ndarray, List[Optional[str]]]:
"""Map N raw points to corrected coordinates."""
pts = np.asarray(raw_points, dtype=np.float64)
if pts.ndim != 2 or pts.shape[1] != 2:
raise ValueError("raw_points must be a (N,2) array.")
if geometry is None:
return pts.copy(), [None] * int(pts.shape[0])
corrected_local = pts.copy()
panel_ids: List[Optional[str]] = [None] * int(pts.shape[0])
assigned = np.zeros(pts.shape[0], dtype=bool)
for panel in geometry["panels"]:
mask = (
(pts[:, 0] >= panel["raw_min_x"])
& (pts[:, 0] <= panel["raw_max_x"])
& (pts[:, 1] >= panel["raw_min_y"])
& (pts[:, 1] <= panel["raw_max_y"])
& (~assigned)
)
if not np.any(mask):
continue
raw_min = np.array([panel["raw_min_x"], panel["raw_min_y"]], dtype=np.float64)
delta = pts[mask] - raw_min
corrected_local[mask] = raw_min + panel["offset"] + (delta @ panel["A"].T)
for idx in np.where(mask)[0]:
panel_ids[int(idx)] = panel["id"]
assigned[mask] = True
corrected = corrected_local @ geometry["global_A"].T
corrected += geometry["global_offset"]
return corrected, panel_ids
[docs]
def corrected_to_raw_points(
corrected_points: np.ndarray,
geometry: Optional[dict],
panel_hints: Optional[Sequence[Optional[str]]] = None,
) -> Tuple[np.ndarray, List[Optional[str]]]:
"""Map N corrected points back to raw coordinates."""
pts = np.asarray(corrected_points, dtype=np.float64)
if pts.ndim != 2 or pts.shape[1] != 2:
raise ValueError("corrected_points must be a (N,2) array.")
if geometry is None:
return pts.copy(), [None] * int(pts.shape[0])
if panel_hints is None:
panel_hints = [None] * int(pts.shape[0])
out = np.zeros_like(pts, dtype=np.float64)
used_panels: List[Optional[str]] = []
for i, point in enumerate(pts):
hint = panel_hints[i] if i < len(panel_hints) else None
raw, used = corrected_to_raw(
point,
geometry,
panel_hint=hint,
)
out[i] = raw
used_panels.append(used)
return out, used_panels