Skip to content

ROI System

optari.roi.roi_records

ROIRecord: one ROI shape on one frame, the source-of-truth unit RoiController persists.

Records are frame-owned, not shape-owned: a region followed across frames is several records sharing one roi_group_uid, not one record holding a list of frames.

ROIRecord dataclass

ROIRecord(roi_id, track_id, frame_id, verts, kind, source=OPTARI_SOURCE_TAG, tissue_class='undefined', roi_group_uid=new_roi_group_uid())

One ROI shape on one frame.

Has 3 identifiers, for 3 scopes:

roi_id This shape on this frame. A session-local counter, shown on the shape in the viewer and used for display ordering. track_id Which tracked group the shape belongs to, i.e. the same ROI followed across frames. Session-local, used for analysis plots across frames, and for coloring shapes in the viewer. roi_group_uid The group's unique id. Persists through export, import and other machines. Shared by all shapes in the same ROI group, so they can be grouped together even if the session-local ids change.

__post_init__

__post_init__()

Coerce fields to their declared types. An empty roi_group_uid mints a fresh one.

Source code in src/optari/roi/roi_records.py
55
56
57
58
59
60
61
62
63
64
65
66
def __post_init__(self) -> None:
    """Coerce fields to their declared types. An empty *roi_group_uid* mints a fresh one."""
    self.roi_id = int(self.roi_id)
    self.track_id = int(self.track_id)
    self.roi_group_uid = (
        str(self.roi_group_uid or "") or new_roi_group_uid()
    )
    self.frame_id = int(self.frame_id)
    self.verts = np.asarray(self.verts, dtype=float).copy()
    self.kind = str(self.kind)
    self.source = str(self.source or OPTARI_SOURCE_TAG)
    self.tissue_class = str(self.tissue_class or "undefined")

new_roi_group_uid

new_roi_group_uid()

Create a stable identity for one ROI group.

track_id is just a session-specific counter. roi_group_uid persists through export, import and a move to another machine, so rows measured from the same ROI stay groupable wherever they end up. Created with a timestamp and 48 random bits to avoid collisions when multiple ROIs are created at the same time.

Source code in src/optari/roi/roi_records.py
18
19
20
21
22
23
24
25
26
def new_roi_group_uid() -> str:
    """Create a stable identity for one ROI group.

    ``track_id`` is just a session-specific counter. ``roi_group_uid`` persists through
    export, import and a move to another machine, so rows measured from the same
    ROI stay groupable wherever they end up.
    Created with a timestamp and 48 random bits to avoid collisions when multiple ROIs are created at the same time.
    """
    return f"{datetime.now(UTC):%Y%m%dT%H%M%S}-{uuid4().hex[:12]}"

optari.roi.roi_geometry

ROI geometry, shared by the viewer, presets, the saved analysis table and HDF5 export.

Described a shape, not a uniqe measurement

Every persisted form of an ROI uses PATATO coordinates. napari millimetres exist only inside the viewer, where they are relative to the field of view corner and therefore inconsistent once the FOV changes.

So we have 3 coordinate systems in OPTARI:

world mm What ROIRecord.verts always holds. The Shapes layer is never translated, so world millimetres are the shared physical frame every layer is registered into. Scan-loaded layers sit at translate 0, which makes world mm identical to the scan frame. PATATO metres What this module stores. Scan-anchored, like PATATO. origin at the image centre, derived from world mm and the scan's field of view. layer pixels Indices into a layer's array. A runtime-derived layer may sit at a nonzero translate (like deepmb) to register it against the others, so converting world mm to that layer's pixels means subtracting its translate first.

RoiGeometry dataclass

RoiGeometry(verts_m, kind='polygon', tissue_class='undefined', source=OPTARI_SOURCE_TAG)

An ROI shape in PATATO coordinates: metres, origin at the image centre.

FOV-independent, so the same geometry restores to the same physical location on any reconstruction whatever its field of view or pixel grid.

__post_init__

__post_init__()

Coerce verts_m to an (N, 2) array and the other fields to their types. Rejects an empty geometry.

Source code in src/optari/roi/roi_geometry.py
86
87
88
89
90
91
92
93
def __post_init__(self) -> None:
    """Coerce *verts_m* to an (N, 2) array and the other fields to their types. Rejects an empty geometry."""
    self.verts_m = np.asarray(self.verts_m, dtype=float).reshape(-1, 2)
    if self.verts_m.size == 0:
        raise ValueError("ROI geometry must contain vertices")
    self.kind = str(self.kind)
    self.tissue_class = str(self.tissue_class or "undefined")
    self.source = str(self.source or OPTARI_SOURCE_TAG)

from_dict classmethod

from_dict(data)

Rebuild from :meth:to_dict's output, defaulting any missing key.

Source code in src/optari/roi/roi_geometry.py
159
160
161
162
163
164
165
166
167
@classmethod
def from_dict(cls, data: dict) -> "RoiGeometry":
    """Rebuild from :meth:`to_dict`'s output, defaulting any missing key."""
    return cls(
        verts_m=data.get("verts_m", []),
        kind=data.get("kind", "polygon"),
        tissue_class=data.get("tissue_class", "undefined"),
        source=data.get("source", OPTARI_SOURCE_TAG),
    )

from_json classmethod

from_json(text)

Inverse of :meth:to_json.

Source code in src/optari/roi/roi_geometry.py
173
174
175
176
@classmethod
def from_json(cls, text: str) -> "RoiGeometry":
    """Inverse of :meth:`to_json`."""
    return cls.from_dict(json.loads(text))

from_record classmethod

from_record(record, fov_x_m, fov_y_m)

Build from a record's world-space vertices.

Source code in src/optari/roi/roi_geometry.py
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
@classmethod
def from_record(
    cls, record: ROIRecord, fov_x_m: float, fov_y_m: float
) -> "RoiGeometry":
    """Build from a record's world-space vertices."""
    return cls(
        verts_m=napari_to_patato(
            np.asarray(record.verts, dtype=float)[:, -2:], fov_x_m, fov_y_m
        ),
        kind=record.kind,
        tissue_class=record.tissue_class,
        source=record.source,
    )

repositioned

repositioned(source_fov, target_fov)

Move the ROI to the same relative spot in a different FOV, keeping its size.

Source code in src/optari/roi/roi_geometry.py
135
136
137
138
139
140
141
142
143
144
145
146
147
148
def repositioned(
    self, source_fov: tuple[float, float], target_fov: tuple[float, float]
) -> "RoiGeometry":
    """Move the ROI to the same relative spot in a different FOV, keeping its size."""
    scale = np.asarray(target_fov, dtype=float) / np.asarray(
        source_fov, dtype=float
    )
    centre = self.verts_m.mean(axis=0)
    return RoiGeometry(
        verts_m=self.verts_m + (centre * scale - centre),
        kind=self.kind,
        tissue_class=self.tissue_class,
        source=self.source,
    )

to_dict

to_dict()

Serialize to a plain dict of JSON-safe values.

Source code in src/optari/roi/roi_geometry.py
150
151
152
153
154
155
156
157
def to_dict(self) -> dict:
    """Serialize to a plain dict of JSON-safe values."""
    return {
        "verts_m": self.verts_m.round(9).tolist(),
        "kind": self.kind,
        "tissue_class": self.tissue_class,
        "source": self.source,
    }

to_json

to_json()

Serialize to the JSON blob stored in the roi_geometry table column.

Source code in src/optari/roi/roi_geometry.py
169
170
171
def to_json(self) -> str:
    """Serialize to the JSON blob stored in the ``roi_geometry`` table column."""
    return json.dumps(self.to_dict())

to_record

to_record(*, roi_id, track_id, frame_id, fov_x_m, fov_y_m, roi_group_uid='')

Rebuild a record. An empty roi_group_uid mints a new group identity.

Source code in src/optari/roi/roi_geometry.py
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
def to_record(
    self,
    *,
    roi_id: int,
    track_id: int,
    frame_id: int,
    fov_x_m: float,
    fov_y_m: float,
    roi_group_uid: str = "",
) -> ROIRecord:
    """Rebuild a record. An empty *roi_group_uid* mints a new group identity."""
    return ROIRecord(
        roi_id=roi_id,
        track_id=track_id,
        frame_id=frame_id,
        roi_group_uid=roi_group_uid,
        verts=self.verts_mm(fov_x_m, fov_y_m),
        kind=self.kind,
        source=self.source,
        tissue_class=self.tissue_class,
    )

verts_mm

verts_mm(fov_x_m, fov_y_m)

Vertices as napari (y_mm, x_mm) for a target field of view.

Source code in src/optari/roi/roi_geometry.py
109
110
111
def verts_mm(self, fov_x_m: float, fov_y_m: float) -> np.ndarray:
    """Vertices as napari ``(y_mm, x_mm)`` for a target field of view."""
    return patato_to_napari(self.verts_m, fov_x_m, fov_y_m)

napari_to_patato

napari_to_patato(verts_yx_mm, fov_x_m, fov_y_m)

Inverse of :func:patato_to_napari.

Source code in src/optari/roi/roi_geometry.py
57
58
59
60
61
62
63
64
65
66
67
68
69
def napari_to_patato(
    verts_yx_mm: np.ndarray, fov_x_m: float, fov_y_m: float
) -> np.ndarray:
    """Inverse of :func:`patato_to_napari`."""
    y_mm = verts_yx_mm[:, 0]
    x_mm = verts_yx_mm[:, 1]
    return np.stack(
        [
            x_mm / 1000.0 - fov_x_m / 2.0,  # patato x (m)
            fov_y_m / 2.0 - y_mm / 1000.0,  # patato y (m)
        ],
        axis=1,
    )

patato_to_napari

patato_to_napari(pts_m, fov_x_m, fov_y_m)

Convert PATATO (x_m, y_m) polygon vertices to napari (y_mm, x_mm).

PATATO: origin at image centre, x→right, y→up, metres. napari: origin at top-left, y→down, mm.

Source code in src/optari/roi/roi_geometry.py
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
def patato_to_napari(
    pts_m: np.ndarray, fov_x_m: float, fov_y_m: float
) -> np.ndarray:
    """Convert PATATO ``(x_m, y_m)`` polygon vertices to napari ``(y_mm, x_mm)``.

    PATATO: origin at image centre, x→right, y→up, metres.
    napari:  origin at top-left,   y→down, mm.
    """
    x_m = pts_m[:, 0]
    y_m = pts_m[:, 1]
    return np.stack(
        [
            (fov_y_m / 2.0 - y_m) * 1000.0,  # y_mm  (y-axis flipped)
            (x_m + fov_x_m / 2.0) * 1000.0,  # x_mm  (origin shifted)
        ],
        axis=1,
    )

optari.roi.roi_features

Feature registry: computes every saved and live ROI statistic from a shared context.

FEATURE_REGISTRY maps a column name to a FeatureSpec, so adding a measurement is a one-line registry entry rather than a change to the measurement loop itself.

FeatureSpec dataclass

FeatureSpec(dtype, fn)

Pairs a dtype with a function from an :class:ROIContext to the column's value.

ROIContext dataclass

ROIContext(roi_id, track_id, roi_group_uid, src_layer, kind, roi_source, study_folder, scan_folder, scan_name, frame, channel, scan_ts, roi_centroid, roi_geometry, filepath, vals_raw, vals, clamp, sy, sx, verts)

Everything one measurement needs: identifiers, provenance, the geometry blob and the pixel values a FeatureSpec reads.

Provenance covers scan_name, frame, channel, src_layer, timestamps and the intensity filter. Pixel values are vals_raw and the filtered vals, alongside the pixel scale and the ROI's vertices.

numeric_feature_ids

numeric_feature_ids()

IDs of the features whose dtype is int or float.

Source code in src/optari/roi/roi_features.py
143
144
145
146
147
148
149
def numeric_feature_ids() -> list[str]:
    """IDs of the features whose dtype is ``int`` or ``float``."""
    return [
        fid
        for fid in ALL_FEATURE_COLUMNS
        if FEATURE_REGISTRY[fid].dtype in (int, float)
    ]

optari.roi.roi_table

The Saved Analysis table consisting of saved ROI measurements.

  • Saving never overwrites. Re-measuring an ROI appends a second set of rows under the same roi_group_uid, separated by roi_ts. TODO: should this be kept or cause error/delete prompt?
  • Every change writes to disk. Each change to the saved table autosaves
  • Re-importing the same file adds nothing. Identical rows are skipped, so analysis sheets can be combined

All the Qt stuff is in the controller

SavedRoiTable

SavedRoiTable(autosave_path=None)

Measured ROI rows, mirrored to autosave_path after every change.

autosave_path may be None to disable the save (e.g. for test cases)

Create an empty table that autosaves to autosave_path, if given.

Source code in src/optari/roi/roi_table.py
34
35
36
37
def __init__(self, autosave_path: Path | None = None):
    """Create an empty table that autosaves to *autosave_path*, if given."""
    self._rows = pd.DataFrame(columns=saved_export_columns())
    self._autosave_path = autosave_path

is_empty property

is_empty

True if the table has no rows.

rows property

rows

Every stored column, the full feature set regardless of visibility.

__len__

__len__()

Number of saved rows.

Source code in src/optari/roi/roi_table.py
40
41
42
def __len__(self) -> int:
    """Number of saved rows."""
    return len(self._rows)

add_measurements

add_measurements(rows)

Stamp rows with the measurement time and append them. Returns the roi_group_uids that already had earlier measurements.

Source code in src/optari/roi/roi_table.py
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
def add_measurements(self, rows: pd.DataFrame) -> set[str]:
    """Stamp *rows* with the measurement time and append them.
    Returns the ``roi_group_uid``s that already had earlier measurements.
    """
    rows = rows.copy()
    rows["roi_ts"] = self._next_measurement_stamp()
    rows = rows.loc[:, saved_export_columns()]

    remeasured = set(rows["roi_group_uid"]) & set(
        self._rows.get("roi_group_uid", [])
    )
    self._rows = (
        rows
        if self._rows.empty
        else pd.concat([self._rows, rows], ignore_index=True)
    )
    self._autosave()
    return remeasured

delete_at

delete_at(positions)

Drop rows by view position. Returns how many went.

Source code in src/optari/roi/roi_table.py
 97
 98
 99
100
101
102
103
104
105
106
def delete_at(self, positions: list[int]) -> int:
    """Drop rows by view position. Returns how many went."""
    if not positions:
        return 0
    # Map positions to index labels first, so the drop stays correct even if the
    # view is ever sorted or filtered.
    labels = self._rows.index[positions]
    self._rows = self._rows.drop(labels).reset_index(drop=True)
    self._autosave()
    return len(positions)

merge_imported

merge_imported(rows)

Append an imported table, skipping rows already present.

Returns (added, skipped). Appending rather than replacing lets analyses from several sessions or machines combine in one table. roi_group_uid keeps every row attributable to the ROI it was measured from.

Source code in src/optari/roi/roi_table.py
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def merge_imported(self, rows: pd.DataFrame) -> tuple[int, int]:
    """Append an imported table, skipping rows already present.

    Returns ``(added, skipped)``. Appending rather than replacing lets analyses
    from several sessions or machines combine in one table. ``roi_group_uid`` keeps
    every row attributable to the ROI it was measured from.
    """
    combined = pd.concat([self._rows, rows], ignore_index=True)
    # A measurement is identified by what was measured where and when.
    duplicates = combined[MEASUREMENT_KEY].astype(str).duplicated()
    self._rows = combined[~duplicates].reset_index(drop=True)
    self._autosave()

    skipped = int(duplicates.sum())
    return len(rows) - skipped, skipped

rows_at

rows_at(positions)

Rows by view position. The view is unsorted, so position maps 1:1.

Source code in src/optari/roi/roi_table.py
60
61
62
def rows_at(self, positions: list[int]) -> pd.DataFrame:
    """Rows by *view* position. The view is unsorted, so position maps 1:1."""
    return self._rows.iloc[positions]

visible_rows

visible_rows()

Only the columns the saved table displays, for the widget.

Source code in src/optari/roi/roi_table.py
54
55
56
57
58
def visible_rows(self) -> pd.DataFrame:
    """Only the columns the saved table displays, for the widget."""
    if self._rows.empty:
        return pd.DataFrame(columns=saved_table_columns())
    return self._rows.loc[:, saved_table_columns()]

optari.roi.roi_utils

Functions that measure ROIs against reconstructed image data.

iter_roi_masks turns world-space ROIs into pixel indices, IntensityClamp filters outlier values before a statistic is computed, and compute_roi_stats builds an ROIContext per ROI and evaluates the requested FEATURE_REGISTRY entries.

IntensityClamp dataclass

IntensityClamp(minimum=None, maximum=None, mode='clip')

How ROI pixel values are filtered before any statistic is computed.

clip bounds out-of-range values back into given range (keeping the pixel count). exclude drops them, which also changes n_pixels and size_mm. The default instance is a no-op, so callers that do not filter pass nothing.

label property

label

What is recorded with each measurement: the mode, or none without bounds.

apply

apply(values)

Apply the clip or exclude policy to values.

Source code in src/optari/roi/roi_utils.py
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
def apply(self, values: np.ndarray) -> np.ndarray:
    """Apply the clip or exclude policy to *values*."""
    if values.size == 0 or self.label == "none":
        return values

    low = None if self.minimum is None else float(self.minimum)
    high = None if self.maximum is None else float(self.maximum)

    if self.mode == "exclude":
        keep = np.ones(values.shape, dtype=bool)
        if low is not None:
            keep &= values >= low
        if high is not None:
            keep &= values <= high
        return values[keep]

    return np.clip(values, a_min=low, a_max=high)

MeasureScope dataclass

MeasureScope(all_layers=False, all_frames=False, all_channels=False, follow_track=False)

How wide a save reaches: which layers, frames and channels get measured.

The annotation dock builds one from its checkboxes; batch mode builds one from its plan. Keeping it a value rather than reading widgets inside the measurement loop is what lets both drive the identical code.

clear_mask_cache

clear_mask_cache()

Drop cached masks, e.g. when a scan is closed.

Source code in src/optari/roi/roi_utils.py
134
135
136
def clear_mask_cache() -> None:
    """Drop cached masks, e.g. when a scan is closed."""
    _rasterize.cache_clear()

compute_roi_spectra

compute_roi_spectra(records, active_recon_layer, frame_idx, *, clamp=NO_CLAMP)

Compute per-ROI mean intensity over channels for a fixed frame.

Source code in src/optari/roi/roi_utils.py
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
def compute_roi_spectra(
    records: list[ROIRecord],
    active_recon_layer,
    frame_idx: int,
    *,
    clamp: IntensityClamp = NO_CLAMP,
):
    """Compute per-ROI mean intensity over channels for a fixed frame."""

    data = active_recon_layer.data
    frame_idx = int(frame_idx)
    if not _is_reconstructed_frame(active_recon_layer, frame_idx):
        return np.asarray([]), {}, None

    n_channels = data.shape[1]
    # A categorical axis: one evenly spaced tick per channel, labelled with its
    # wavelength or chromophore, whatever the spacing of the wavelengths.
    x = np.arange(n_channels, dtype=float)
    x_tick_labels = [
        str(label) for label in active_recon_layer.metadata["axis1_labels"]
    ]

    img_shape = data.shape[-2:]

    series: dict[int, np.ndarray] = {}
    # One mask per ROI, reused across every channel (see iter_roi_masks).
    for roi, mask in iter_roi_masks(records, active_recon_layer, img_shape):
        y = []
        for channel in range(n_channels):
            vals = clamp.apply(data[frame_idx, channel][mask])
            y.append(float(np.nanmean(vals)) if vals.size else np.nan)
        series[roi.roi_id] = np.asarray(y, dtype=float)

    return x, series, x_tick_labels

compute_roi_stats

compute_roi_stats(records, active_recon_layer, frame_idx, channel_idx, *, clamp=NO_CLAMP, feature_ids=None)

Compute ROI statistics for all shapes for a specific frame/channel.

Source code in src/optari/roi/roi_utils.py
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
def compute_roi_stats(
    records: list[ROIRecord],
    active_recon_layer,
    frame_idx: int,
    channel_idx: int,
    *,
    clamp: IntensityClamp = NO_CLAMP,
    feature_ids: list[str] | None = None,
):
    """Compute ROI statistics for all shapes for a specific frame/channel."""

    selected_feature_ids = _resolve_feature_ids(feature_ids)
    # Serializing geometry is the expensive context field, and the live table
    # refreshes on every drag -> only compute the caller wants the column
    needs_geometry = "roi_geometry" in selected_feature_ids
    frame_idx = int(frame_idx)
    if not _is_reconstructed_frame(active_recon_layer, frame_idx):
        # this frame was zero-padded → return empty stats
        return pd.DataFrame(columns=selected_feature_ids)

    img2d = active_recon_layer.data[frame_idx, channel_idx]
    layer_info = _LayerInfo.resolve(
        active_recon_layer, channel_idx, img2d.shape
    )
    scan_ts = slice_datetime(active_recon_layer, frame_idx, channel_idx)

    rows = []
    for roi, mask in iter_roi_masks(records, active_recon_layer, img2d.shape):
        vals_raw = img2d[mask]
        ctx = _roi_context(
            roi,
            layer_info,
            frame_idx=frame_idx,
            scan_ts=scan_ts,
            vals_raw=vals_raw,
            clamp=clamp,
            roi_geometry=(
                RoiGeometry.from_record(
                    roi, layer_info.fov_x_m, layer_info.fov_y_m
                ).to_json()
                if needs_geometry
                else ""
            ),
        )
        rows.append(
            {
                feature_id: FEATURE_REGISTRY[feature_id].fn(ctx)
                for feature_id in selected_feature_ids
            }
        )

    return pd.DataFrame(rows, columns=selected_feature_ids)

compute_roi_time_series

compute_roi_time_series(records, active_recon_layer, channel_idx, feature_id, *, clamp=NO_CLAMP)

Compute per-ROI feature over time for a fixed channel.

Each of records is measured with the same fixed shape on every frame. Used for the "Selected ROI" scope: whatever a shape looks like right now is used for the whole sequence.

Source code in src/optari/roi/roi_utils.py
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
def compute_roi_time_series(
    records: list[ROIRecord],
    active_recon_layer,
    channel_idx: int,
    feature_id: str,
    *,
    clamp: IntensityClamp = NO_CLAMP,
):
    """Compute per-ROI feature over time for a fixed channel.

    Each of *records* is measured with **the same fixed shape on every frame**.
    Used for the "Selected ROI" scope: whatever a shape looks like right now is used
    for the whole sequence.
    """
    data, frames, x, img_shape, layer_info = _time_series_setup(
        active_recon_layer, channel_idx
    )

    series: dict[int, np.ndarray] = {}
    # One mask per ROI, reused across every frame: geometry is fixed here, only the
    # pixel values underneath it change.
    for roi, mask in iter_roi_masks(records, active_recon_layer, img_shape):
        series[roi.roi_id] = _measure_series(
            active_recon_layer,
            data,
            channel_idx,
            frames,
            layer_info,
            feature_id,
            clamp,
            lambda frame_idx, roi=roi, mask=mask: (roi, mask),
        )

    return np.asarray(x, dtype=float), series

compute_roi_track_time_series

compute_roi_track_time_series(track_id, records, active_recon_layer, channel_idx, feature_id, *, clamp=NO_CLAMP)

Compute one feature over time for a tracked ROI.

Unlike compute_roi_time_series, each frame is measured on its own record. Used fot the "Track ID" scope. E.g. for a shape that was moved or restored frame by frame to follow anatomy. A frame the track has no record on is left as a gap (NaN)

Returns the same (x, {key: y}) shape as compute_roi_time_series, here with a single entry, keyed by track_id

Source code in src/optari/roi/roi_utils.py
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
def compute_roi_track_time_series(
    track_id: int,
    records: list[ROIRecord],
    active_recon_layer,
    channel_idx: int,
    feature_id: str,
    *,
    clamp: IntensityClamp = NO_CLAMP,
):
    """Compute one feature over time for a tracked ROI.

    Unlike ``compute_roi_time_series``, each frame is measured on **its own record**.
    Used fot the "Track ID" scope. E.g. for a shape that was moved or restored frame by frame to
    follow anatomy. A frame the track has no record on is left as a gap (``NaN``)

    Returns the same ``(x, {key: y})`` shape as ``compute_roi_time_series``, here
    with a single entry, keyed by *track_id*
    """
    data, frames, x, img_shape, layer_info = _time_series_setup(
        active_recon_layer, channel_idx
    )
    sy, sx = layer_info.sy, layer_info.sx
    ty, tx = _layer_translate(active_recon_layer)

    records_by_frame = records_by_track_and_frame(records, track_id)
    if not records_by_frame:
        return np.asarray(x, dtype=float), {}

    def record_and_mask_at(
        frame_idx: int,
    ) -> tuple[ROIRecord, np.ndarray] | None:
        """Track ID's resolver: *frame_idx*'s own tracked record and mask, or None if the track has none there."""
        record = records_by_frame.get(frame_idx)
        if record is None:
            return None
        mask = _roi_mask(
            record, sy=sy, sx=sx, ty=ty, tx=tx, image_shape=img_shape
        )
        return None if mask is None else (record, mask)

    y = _measure_series(
        active_recon_layer,
        data,
        channel_idx,
        frames,
        layer_info,
        feature_id,
        clamp,
        record_and_mask_at,
    )
    return np.asarray(x, dtype=float), {track_id: y}

ellipse_mask

ellipse_mask(verts_px, image_shape)

Rasterize napari's 4-corner ellipse box (may be rotated) to a boolean mask.

Source code in src/optari/roi/roi_utils.py
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def ellipse_mask(verts_px, image_shape):
    """Rasterize napari's 4-corner ellipse box (may be rotated) to a boolean mask."""
    p0, p1, p2 = verts_px[:3]
    cy, cx = verts_px.mean(axis=0)
    rr, cc = ellipse(
        cy,
        cx,
        np.linalg.norm(p2 - p1) / 2,
        np.linalg.norm(p1 - p0) / 2,
        shape=image_shape,
        rotation=-np.arctan2(*(p1 - p0)),
    )
    mask = np.zeros(image_shape, dtype=bool)
    mask[rr, cc] = True
    return mask

extract_roi_pixels_for_slice

extract_roi_pixels_for_slice(records, active_recon_layer, frame_idx, channel_idx, *, clamp=NO_CLAMP)

Extract pixel values per ROI for the given frame/channel.

Source code in src/optari/roi/roi_utils.py
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
def extract_roi_pixels_for_slice(
    records: list[ROIRecord],
    active_recon_layer,
    frame_idx: int,
    channel_idx: int,
    *,
    clamp: IntensityClamp = NO_CLAMP,
):
    """Extract pixel values per ROI for the given frame/channel."""

    frame_idx = int(frame_idx)
    if not _is_reconstructed_frame(active_recon_layer, frame_idx):
        return {}

    img2d = active_recon_layer.data[frame_idx, channel_idx]

    return {
        roi.roi_id: clamp.apply(img2d[mask])
        for roi, mask in iter_roi_masks(
            records, active_recon_layer, img2d.shape
        )
    }

iter_roi_masks

iter_roi_masks(records, active_recon_layer, image_shape)

Yield each usable ROI with its boolean mask over image_shape.

Only place to trasnform world-space ROI to pixel indices. skips broken geometry and any ROI that fails to rasterize, so the four measurement functions below all agree on which ROIs are measurable.

Source code in src/optari/roi/roi_utils.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
def iter_roi_masks(
    records: list[ROIRecord], active_recon_layer, image_shape
) -> Iterator[tuple[ROIRecord, np.ndarray]]:
    """Yield each usable ROI with its boolean mask over *image_shape*.

    Only place to trasnform world-space ROI to pixel indices.
    skips broken geometry and any ROI that fails to rasterize, so the four
    measurement functions below all agree on which ROIs are measurable.
    """
    sy, sx = _scale_sy_sx(active_recon_layer)
    ty, tx = _layer_translate(active_recon_layer)

    for roi in records:
        mask = _roi_mask(
            roi, sy=sy, sx=sx, ty=ty, tx=tx, image_shape=image_shape
        )
        if mask is not None:
            yield roi, mask

layer_fov_m

layer_fov_m(active_recon_layer, image_shape)

Return the layer's (fov_x_m, fov_y_m), derived from its own grid.

scale_from_patato_obj sets the scale to fov / n_pixels, so the napari extent shape * scale is the FOV. Deriving it here rather than reading scan metadata also covers layers reconstructed or unmixed at runtime, and layers that fell back to a configured default scale.

Source code in src/optari/roi/roi_utils.py
173
174
175
176
177
178
179
180
181
182
def layer_fov_m(active_recon_layer, image_shape) -> tuple[float, float]:
    """Return the layer's ``(fov_x_m, fov_y_m)``, derived from its own grid.

    ``scale_from_patato_obj`` sets the scale to ``fov / n_pixels``, so the napari
    extent ``shape * scale`` is the FOV. Deriving it here rather
    than reading scan metadata also covers layers reconstructed or unmixed at
    runtime, and layers that fell back to a configured default scale.
    """
    sy, sx = _scale_sy_sx(active_recon_layer)
    return image_shape[1] * sx / 1000.0, image_shape[0] * sy / 1000.0

polygon_mask

polygon_mask(verts_px, image_shape)

Rasterize a polygon (verts in pixel coords) to a boolean mask.

Source code in src/optari/roi/roi_utils.py
356
357
358
359
360
361
def polygon_mask(verts_px, image_shape):
    """Rasterize a polygon (verts in pixel coords) to a boolean mask."""
    rr, cc = polygon(verts_px[:, 0], verts_px[:, 1], image_shape)
    mask = np.zeros(image_shape, dtype=bool)
    mask[rr, cc] = True
    return mask

records_by_track_and_frame

records_by_track_and_frame(records, track_id)

One track's records, indexed by the frame each was measured on.

Source code in src/optari/roi/roi_utils.py
192
193
194
195
196
def records_by_track_and_frame(
    records: Iterable[ROIRecord], track_id: int
) -> dict[int, ROIRecord]:
    """One track's records, indexed by the frame each was measured on."""
    return {r.frame_id: r for r in records if r.track_id == track_id}

saved_export_columns

saved_export_columns()

Columns stored and exported: origin columns plus every feature

Source code in src/optari/roi/roi_utils.py
55
56
57
def saved_export_columns() -> list[str]:
    """Columns stored and exported: origin columns plus every feature"""
    return _saved_columns(ALL_FEATURE_COLUMNS)

saved_table_columns

saved_table_columns()

Columns shown in the saved table: origin columns plus the enabled features.

Source code in src/optari/roi/roi_utils.py
50
51
52
def saved_table_columns() -> list[str]:
    """Columns shown in the saved table: origin columns plus the enabled features."""
    return _saved_columns(visible_feature_columns())

slice_datetime

slice_datetime(layer, frame_idx, channel_idx)

Wall-clock acquisition time of one slice, or "N/A" if the scan cannot be dated.

See patato_bridge.acquisition_start for when a scan counts as datable.

Source code in src/optari/roi/roi_utils.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
def slice_datetime(layer, frame_idx: int, channel_idx: int) -> str:
    """Wall-clock acquisition time of one slice, or "N/A" if the scan cannot be dated.

    See ``patato_bridge.acquisition_start`` for when a scan counts as datable.
    """
    start = layer.metadata.get("acquisition_start")
    if start is None:
        return "N/A"
    ts = layer.metadata["timestamps"]
    elapsed = float(ts[frame_idx, channel_idx] - ts[0, 0])
    return str(start + timedelta(seconds=elapsed))

time_axis

time_axis(active_recon_layer, channel_idx)

Resolve the frame indices to plot and the x-axis value for each.

Source code in src/optari/roi/roi_utils.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
def time_axis(
    active_recon_layer, channel_idx: int
) -> tuple[np.ndarray, np.ndarray]:
    """Resolve the frame indices to plot and the x-axis value for each."""
    n_frames = active_recon_layer.data.shape[0]

    frames_meta = active_recon_layer.metadata.get("frames")
    frames = (
        np.asarray(frames_meta, dtype=int)
        if frames_meta
        else np.arange(n_frames, dtype=int)
    )

    # The plot labels its x-axis from the same metadata, see AnalysisController.
    ts = active_recon_layer.metadata.get("timestamps")
    if ts is None:
        return frames, frames.astype(float)
    # Per-channel timestamps for the plotted channel, referenced to the scan start
    ts = np.asarray(ts, dtype=float)
    return frames, ts[frames, channel_idx] - ts[0, 0]

visible_feature_columns

visible_feature_columns()

Feature columns the user has enabled in settings, determines the live table's columns.

Source code in src/optari/roi/roi_utils.py
34
35
36
37
38
39
40
def visible_feature_columns() -> list[str]:
    """Feature columns the user has enabled in settings, determines the live table's columns."""
    return [
        c
        for c in ALL_FEATURE_COLUMNS
        if int(settings.annotation.roi_features.get(c, 0)) == 1
    ]

optari.roi.roi_shapes

ROI shapes generated from a segmentation class mask, used by the segmentation dock.

roi_verts_from_mask derives a new shape's geometry from the mask (largest-component isolation, centre-column anchoring, depth offset, contour extraction). It does not participate in persistence, unlike RoiGeometry.

ROIPlacementConfig dataclass

ROIPlacementConfig(width_mm, height_mm, depth_mm)

ROI placement configuration in mm.

class_top_at_center_column

class_top_at_center_column(mask)

Row and column of the topmost pixel of mask's largest component, at the image's horizontal centre.

used below by the box-shaped ROI generators, and by ROI preset auto-placement (RoiController._place_roi_preset_auto) to align a saved preset the same way.

Source code in src/optari/roi/roi_shapes.py
52
53
54
55
56
57
58
59
def class_top_at_center_column(mask: np.ndarray) -> tuple[int, int]:
    """Row and column of the topmost pixel of *mask*'s largest component, at the
    image's horizontal centre.

    used below by the box-shaped ROI generators, and by ROI preset auto-placement
    (``RoiController._place_roi_preset_auto``) to align a saved preset the same way.
    """
    return _top_at_center_column(largest_component(mask))

largest_component

largest_component(mask)

Boolean mask of mask's largest connected component.

Source code in src/optari/roi/roi_shapes.py
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def largest_component(mask: np.ndarray) -> np.ndarray:
    """Boolean mask of *mask*'s largest connected component."""
    mask_u8 = mask.astype(np.uint8)
    if mask_u8.max() == 1:
        mask_u8 *= 255  # cv2's connectivity analysis expects a 0/255 image
    if mask_u8.shape[0] == 0 or mask_u8.shape[1] == 0:
        raise ValueError("Empty mask")

    num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(
        mask_u8, connectivity=8
    )
    if num_labels < 2:
        raise ValueError("Selected class is empty or not found.")

    largest_label = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
    return labels == largest_label

roi_verts_from_mask

roi_verts_from_mask(shape_type, class_mask, config, *, sy, sx, ty=0.0, tx=0.0)

World-mm vertices of a shape_type ROI placed in class_mask's largest component.

rectangle and ellipse are boxes anchored to the class's top at the centre column; polygon follows the class outline, trimmed to the configured bounds. sy/sx is the mask's pixel size and ty/tx its layer's translate, in mm. Raises ValueError when the shape does not fit the class.

Source code in src/optari/roi/roi_shapes.py
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
def roi_verts_from_mask(
    shape_type: str,
    class_mask: np.ndarray,
    config: ROIPlacementConfig,
    *,
    sy: float,
    sx: float,
    ty: float = 0.0,
    tx: float = 0.0,
) -> np.ndarray:
    """World-mm vertices of a *shape_type* ROI placed in *class_mask*'s largest component.

    ``rectangle`` and ``ellipse`` are boxes anchored to the class's top at the centre
    column; ``polygon`` follows the class outline, trimmed to the configured bounds.
    *sy*/*sx* is the mask's pixel size and *ty*/*tx* its layer's translate, in mm.
    Raises ValueError when the shape does not fit the class.
    """
    if shape_type == "polygon":
        return _polygon_verts_px(class_mask, config, sy, sx) * (sy, sx) + (ty, tx)
    return _box_verts_mm(class_mask, config, sy, sx, ty, tx)

optari.roi.roi_presets

ROI presets: reusable templates stored as JSON under presets/roi/.

A preset pairs a named RoiGeometry with the source_fov_m it was drawn at and a description. It carries no identifier, so placing it creates a fresh ROI each time.

RoiPreset dataclass

RoiPreset(name, description, created, geometry, source_fov_m, placement='static', top_margin_mm=0.0)

A named, reusable ROI template.

The shape itself is a :class:RoiGeometry, the same FOV-independent representation used by the saved analysis table and HDF5 export. source_fov_m is kept so it can be re-placed proportionally in a scan with a different fov.

placement is how the ROI wants to be positioned: static at its saved coordinates, or auto anchored onto the segmentation class named by geometry.tissue_class (shown as its location in the UI). It lives here rather than at each call site so that placing a preset means the same thing in the dock and in a batch run.

top_margin_mm is how far below the class's top edge (at the image centre) an auto preset's top is placed, horizontally centred. The save dialog prefills it with where the ROI sits when saved, and the user can change it.

__post_init__

__post_init__()

Coerce source_fov_m and top_margin_mm to floats and reject a non-positive FOV or unknown placement.

Source code in src/optari/roi/roi_presets.py
47
48
49
50
51
52
53
54
55
56
57
58
59
def __post_init__(self) -> None:
    """Coerce *source_fov_m* and *top_margin_mm* to floats and reject a non-positive FOV or unknown *placement*."""
    self.source_fov_m = (
        float(self.source_fov_m[0]),
        float(self.source_fov_m[1]),
    )
    self.top_margin_mm = float(self.top_margin_mm)
    if min(self.source_fov_m) <= 0:
        raise ValueError("ROI preset source FOV must be positive")
    if self.placement not in PLACEMENT_MODES:
        raise ValueError(
            f"ROI preset placement must be one of {PLACEMENT_MODES}: {self.placement}"
        )

from_dict classmethod

from_dict(name, data)

Rebuild from a preset file's dict. Raises if source_fov_m is missing.

Source code in src/optari/roi/roi_presets.py
61
62
63
64
65
66
67
68
69
70
71
72
73
74
@classmethod
def from_dict(cls, name: str, data: dict) -> "RoiPreset":
    """Rebuild from a preset file's dict. Raises if ``source_fov_m`` is missing."""
    if "source_fov_m" not in data:
        raise ValueError(f"ROI preset must record its source FOV: {name}")
    return cls(
        name=name,
        description=str(data.get("description", "")),
        created=str(data.get("created", "")),
        geometry=RoiGeometry.from_dict(data.get("geometry", {})),
        source_fov_m=data["source_fov_m"],
        placement=str(data.get("placement", "static")),
        top_margin_mm=data.get("top_margin_mm", 0.0),
    )

to_dict

to_dict()

Serialize to the dict written to the preset's JSON file.

Source code in src/optari/roi/roi_presets.py
76
77
78
79
80
81
82
83
84
85
def to_dict(self) -> dict:
    """Serialize to the dict written to the preset's JSON file."""
    return {
        "description": self.description,
        "created": self.created,
        "source_fov_m": list(self.source_fov_m),
        "placement": self.placement,
        "top_margin_mm": self.top_margin_mm,
        "geometry": self.geometry.to_dict(),
    }

RoiPresetStore

RoiPresetStore(directory)

Bases: PresetStore

Load and save named ROI presets in the user preset directory.

Source code in src/optari/utils/presets.py
27
28
def __init__(self, directory: Path):
    self.directory = Path(directory)

get

get(name)

Load the preset named name.

Source code in src/optari/roi/roi_presets.py
 98
 99
100
def get(self, name: str) -> RoiPreset:
    """Load the preset named *name*."""
    return RoiPreset.from_dict(name, self.load(name))

list_presets

list_presets()

Load every saved preset.

Source code in src/optari/roi/roi_presets.py
91
92
93
94
95
96
def list_presets(self) -> list[RoiPreset]:
    """Load every saved preset."""
    return [
        RoiPreset.from_dict(path.stem, self.load(path))
        for path in self.list_paths()
    ]

save_preset

save_preset(*, name, description, geometry, source_fov_m, placement='static', top_margin_mm=0.0)

Build a preset from the given fields, stamp it with the current time, and save it to disk.

Source code in src/optari/roi/roi_presets.py
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
def save_preset(
    self,
    *,
    name: str,
    description: str,
    geometry: RoiGeometry,
    source_fov_m: tuple[float, float],
    placement: str = "static",
    top_margin_mm: float = 0.0,
) -> Path:
    """Build a preset from the given fields, stamp it with the current time, and save it to disk."""
    preset = RoiPreset(
        name=name,
        description=description,
        created=datetime.now(UTC).isoformat(),
        geometry=geometry,
        source_fov_m=source_fov_m,
        placement=placement,
        top_margin_mm=top_margin_mm,
    )
    return self.save(name, preset.to_dict())