"""Region-of-interest data model for NeuNorm 2.0."""
from typing import Optional, Union
from pydantic import BaseModel, model_validator
[docs]
class ROI(BaseModel):
"""Rectangular region of interest with named, self-documenting bounds.
Define it either by explicit stop indices or by size — the two forms are equivalent::
ROI(x0=10, y0=20, x1=30, y1=40) # exclusive stops
ROI(x0=10, y0=20, width=20, height=20) # the same 20x20 region
Stop indices are **exclusive** (Python slice semantics), matching ``apply_roi``,
``apply_air_region_correction`` and the ``background_roi`` flux proxy. An ``ROI`` may be passed
anywhere those APIs accept an ``(x0, y0, x1, y1)`` tuple; the bare-tuple form keeps working
unchanged for backward compatibility.
Parameters
----------
x0, y0 : int
Lower (inclusive) pixel bounds in x and y.
x1, y1 : int, optional
Upper (exclusive) pixel bounds. Provide these **or** ``width``/``height``.
width, height : int, optional
Extent in x and y; ``x1 = x0 + width`` and ``y1 = y0 + height``. Provide these **or**
``x1``/``y1``.
inclusive : bool, optional
Interpret the upper bounds as **inclusive** (default ``False`` = exclusive Python-slice
semantics). When ``True``, the resolved ``x1``/``y1`` are bumped by one so the region spans
``(width + 1) x (height + 1)`` pixels (and an explicit ``x1``/``y1`` is included). This is the
legacy NeuNorm 1.x / iBeatles convention; ``as_bounds()`` always returns exclusive stops, so
the rest of the library stays exclusive. Bare tuples are always exclusive — use the ``ROI``
type to opt into inclusive extents.
Examples
--------
>>> ROI(x0=10, y0=20, x1=30, y1=40).as_bounds()
(10, 20, 30, 40)
>>> ROI(x0=10, y0=20, width=20, height=20).as_bounds()
(10, 20, 30, 40)
>>> ROI(x0=10, y0=20, width=20, height=20, inclusive=True).as_bounds()
(10, 20, 31, 41)
"""
x0: int
y0: int
x1: Optional[int] = None
y1: Optional[int] = None
width: Optional[int] = None
height: Optional[int] = None
inclusive: bool = False
@model_validator(mode="after")
def _resolve_bounds(self):
"""Resolve width/height to exclusive stops and validate the rectangle (runs automatically)."""
if (self.x1 is None) == (self.width is None):
raise ValueError("ROI requires exactly one of 'x1' or 'width'")
if (self.y1 is None) == (self.height is None):
raise ValueError("ROI requires exactly one of 'y1' or 'height'")
if self.x1 is None:
self.x1 = self.x0 + self.width
if self.y1 is None:
self.y1 = self.y0 + self.height
if self.inclusive:
# inclusive upper bound -> exclusive stop covers one more pixel on each axis
self.x1 += 1
self.y1 += 1
if self.x0 < 0 or self.y0 < 0 or self.x1 <= self.x0 or self.y1 <= self.y0:
raise ValueError(f"Invalid ROI {self.as_bounds()}: need 0 <= x0 < x1 and 0 <= y0 < y1")
return self
[docs]
def as_bounds(self) -> tuple[int, int, int, int]:
"""Return the ROI as an ``(x0, y0, x1, y1)`` tuple with exclusive stop indices."""
return (self.x0, self.y0, self.x1, self.y1)
# An ``ROI`` or a bare 4-element ``(x0, y0, x1, y1)`` tuple/list — accepted interchangeably by
# ROI-taking APIs (``as_roi_bounds`` coerces either form to a bounds tuple).
ROILike = Union[ROI, tuple[int, int, int, int], list[int]]
[docs]
def as_roi_bounds(roi: ROILike) -> tuple[int, int, int, int]:
"""Coerce an :class:`ROI` (or a bare 4-element ``(x0, y0, x1, y1)`` sequence) to a bounds tuple.
A bare sequence is returned as a plain tuple so downstream code sees a consistent
``(x0, y0, x1, y1)`` form regardless of how the ROI was specified. Element-type/bounds validation
is left to the consumer (and to :class:`ROI` for the named form); only the 4-element length is
enforced here so a malformed bare sequence fails fast at one place.
"""
if isinstance(roi, ROI):
return roi.as_bounds()
bounds = tuple(roi)
if len(bounds) != 4:
raise ValueError(f"ROI must be a tuple of 4 integers (x0, y0, x1, y1), or an ROI; got {bounds!r}")
return bounds