Source code for coseda.detector_geometry

"""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