Metadata-Version: 2.4
Name: isoview
Version: 0.3.0
Summary: Multi-view light sheet microscopy image processing pipeline
Project-URL: Homepage, https://github.com/MillerBrainObservatory/isoview
Project-URL: Repository, https://github.com/MillerBrainObservatory/isoview
Author: Miller Brain Observatory
License: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Requires-Python: <3.14,>=3.12.7
Requires-Dist: mbo-utilities
Requires-Dist: numpy
Requires-Dist: scipy
Requires-Dist: tifffile
Requires-Dist: xmltodict
Description-Content-Type: text/markdown

# IsoView Light Sheet Microscopy Pipeline

Input is a flat directory of raw `SPC##_TM#####_ANG###_CM#_CHN##_PH#.stack` files
(little-endian `uint16`) with a `push_config` XML per channel describing the
acquisition (dimensions, pixel/axial spacing, wavelength, exposure, stage, etc.).

## Quickstart

1. Point `input_dir` in `pipeline/correct_stack.py` at your raw data directory.
2. Run `pipeline/correct_stack.py` (correction) then `pipeline/multi_fuse.py` (fusion).
3. Each step appends its config + `isoview_version` to `isoview_config.json` next to the data, so every run is reproducible.

## Usage

Two entrypoints, both take one `ProcessingConfig`: run `correct_stack(config)`
then `multi_fuse(config)`. Mode (timelapse vs tiled) is auto-detected from the
raw filenames — you do not set it. Editable templates live in
`pipeline/correct_stack.py` and `pipeline/multi_fuse.py`.

### Timelapse (multiple TM, one or more specimens)

```python
from pathlib import Path
from isoview import ProcessingConfig, correct_stack, multi_fuse

config = ProcessingConfig(
    input_dir=Path(r"E:\isoview\dataset"),  # flat SPC##_TM#####_*.stack files
    timepoints=None,                        # default=None · auto-detect all TM## (or pass [0, 1, 2])
    workers=4,                              # default=1 · process timepoints in parallel
    blending_method="geometric",            # default="geometric"
)

correct_stack(config)   # -> dataset.corrected/SPM00/TM000000/...
multi_fuse(config)      # -> dataset.fused/SPM00/TM000000/...
```

### Tiled (single TM, multiple SPC)

Same calls — auto-detected as tiled. Per-specimen crop overrides go through
`tile_crops`, keyed by `SPM##`, then crop param, then **camera index**:

```python
config = ProcessingConfig(
    input_dir=Path(r"E:\isoview\tiled_dataset"),
    tile_crops={
        "SPM00": {"crop_front": {0: 40}, "crop_depth": {0: 450}},
        "SPM01": {"crop_depth": {1: 382, 3: 400}},
    },
)

correct_stack(config)   # -> tiled_dataset.corrected/SPM00/, SPM01/, ...
multi_fuse(config)      # -> tiled_dataset.fused/SPM00/, ...
```

### BigStitcher export (optional stage 3)

Export the two fused views as a BigDataViewer/BigStitcher dataset, then register
VW00↔VW90 in the BigStitcher GUI (see `bigstitcher.md`):

```python
from isoview import generate_bigstitcher_xml

generate_bigstitcher_xml(config)   # -> dataset.stitcher/dataset.xml + dataset.ome.zarr/
```

### All parameters

Every field with its default and tuning guidance. **leave** = don't change
unless you have a reason (hardware/structural/auto-derived); **tune** = worth
adjusting per dataset, with a suggested range. The Gaussian segmentation kernels
(`gauss_kernel`, `gauss_sigma`) should be left as-is.

```python
config = ProcessingConfig(
    # paths & selection
    input_dir=Path(r"E:\isoview\dataset"),  # required · folder of flat SPC##_TM#####_*.stack files
    output_dir=None,            # default=None · auto = <raw>.corrected[_suffix]; leave
    projection_dir=None,        # default=None · auto; leave
    specimen=0,                 # default=0 · timelapse specimen index; leave
    specimens=None,             # default=None · auto-detect SPC##; leave
    timepoints=None,            # default=None · auto-detect TM##; set a list for a subset
    cameras=None,               # default=None · derived from camera_pairs; leave
    views=[0, 90],              # default=[0, 90] · hardware-defined; leave
    output_suffix="",           # default="" · names a variant (.corrected_<x>/.fused_<x>); optional
    stitcher_suffix="",         # default="" · BigStitcher dir variant; leave

    # output format
    output_format="zarr",       # default="zarr" · zarr | tif | klb (pick one, not a tuning knob)
    compression="zstd",         # default="zstd" · zstd|lzw|deflate (tif), blosc-zstd (zarr), or None
    compression_level=3,        # default=3 · 1–22 zstd / 1–9 others · tune 1–9, diminishing past ~6
    zarr_chunks=None,           # default=None · auto (1, Y, X) one plane/chunk; leave unless tuning IO
    zarr_shards=None,           # default=None · auto; leave
    pyramid=True,               # default=True · leave
    pyramid_max_layers=4,       # default=4 · 0–~6; leave

    # parallelism & logging
    workers=1,                  # default=1 · 1–CPU cores · TUNE to machine; fusion is RAM-heavy, use fewer
    log=True,                   # default=True · leave
    overwrite=False,            # default=False · True to recompute existing outputs

    # dead-pixel correction
    median_kernel=(3, 3),       # default=(3, 3) · leave (None disables); larger blurs real signal
    background_percentile=5.0,  # default=5.0 · 0–~20 · leave (dark-current estimate)
    mask_percentile=1.0,        # default=1.0 · 0–~5 · leave
    subsample_factor=100,       # default=100 · 1–~1000 · speed only; higher = faster/coarser percentiles

    # segmentation
    segment_mode=1,             # default=1 · 0=off, 1=generate+save masks (2/3 not implemented)
    gauss_kernel=5,             # default=5 · LEAVE AS-IS
    gauss_sigma=2.0,            # default=2.0 · LEAVE AS-IS
    segment_threshold=0.4,      # default=0.4 · 0–1 · MAIN TUNE: lower = more foreground (try 0.2–0.6)
    splitting=10,               # default=10 · 1+ · memory knob; raise if OOM (more slabs = less RAM)
    apply_segmentation_mask=False,  # default=False · True zeros background; masks are saved either way

    # transforms (fusion; applied to the 2nd camera in each pair)
    rotation=0,                 # default=0 · -1|0|1 = 90ccw|none|90cw · hardware-defined; leave
    flip_horizontal=True,       # default=True · hardware-defined; leave
    flip_vertical=False,        # default=False · leave
    flip_z=False,               # default=False · True if opposing cameras need a Z flip

    # camera-camera blending
    blending_method="geometric",  # default="geometric" · geometric | adaptive | average (or auto)
    blending_range=4,           # default=4 · 1–~20 · tune 2–10 (Z-plane transition width)
    transition_plane=None,      # default=None=center · set a Z-index to move the crossover (geometric)
    front_flag=1,               # default=1 · 1|2 · which camera is sharp at low Z; flip if wrong

    # registration (coarse grid search + gradient-descent refine)
    search_offsets_x=(-50, 50, 10),  # default=(-50, 50, 10) · (start, stop, step) px · widen/finer if misaligned
    search_offsets_y=(-50, 50, 10),  # default=(-50, 50, 10) · same

    # microscope (read from XML when present)
    pixel_spacing_z=None,       # default=None · XML axial_step; set only if no XML
    detection_objective_mag=None,   # default=None · XML objective mag; set only if no XML
    pixel_spacing_camera=6.5,   # default=6.5 · sensor pixel size (µm); leave
    camera_view_map=None,       # default=None -> {0:0, 1:0, 2:90, 3:90}; leave
    camera_pairs=None,          # default=None -> [(0, 1), (2, 3)]; change only for different wiring

    # cropping (fusion; keyed by CAMERA index 0=CM00, 1=CM01, ...)
    crop_front=None,            # default=None · {camera: z-start}
    crop_depth=None,            # default=None · {camera: z-count}
    crop_top=None,              # default=None · {camera: y-start}
    crop_height=None,           # default=None · {camera: y-count}
    crop_left=None,             # default=None · {camera: x-start}
    crop_width=None,            # default=None · {camera: x-count}
    tile_crops=None,            # default=None · per-specimen overrides {"SPM03": {"crop_depth": {1: 460}}}

    # diagnostics
    do_tenengrad=False,         # default=False · True for per-Z focus (Tenengrad) QC plots
    diagnostics_dir=None,       # default=None · auto = output_dir/diagnostics; leave
)
```

Per-view fusion overrides — `blending_method_by_view`, `blending_range_by_view`,
`transition_plane_by_view`, `front_flag_by_view`, `flip_z_by_view`,
`flip_horizontal_by_view`, `flip_vertical_by_view`, `rotation_by_view`,
`search_offsets_x_by_view`, `search_offsets_y_by_view` — each take a
`{view: value}` dict (0=VW00, 90=VW90) and override the matching scalar above for
that view only.

## Acquisition Modes

Raw input is always flat — `SPC##_TM#####_ANG###_CM#_CHN##_PH#.stack` files in a single directory.
Mode is auto-detected from SPC/TM counts:

| Condition | Mode | Description |
|-----------|------|-------------|
| Multiple TM values | timelapse | time series, any number of specimens |
| Single TM + multiple SPC | tiled | spatial tiles, one timepoint |
| Single TM + single SPC | single | treated as timelapse with 1 timepoint |

## XML Metadata

`read_xml_metadata(xml_path)` parses one `push_config` XML; `read_all_xml_metadata(input_dir, specimen)` returns `(common, per_camera)` — fields equal across cameras vs camera-specific.

| Field | Type | Source | Notes |
|-------|------|--------|-------|
| `data_header` | str | `@data_header` | acquisition session label |
| `specimen_name` | str | `@specimen_name` | |
| `timestamp` | str | `@timestamp` | acquisition datetime |
| `time_point` | int | `@time_point` | |
| `specimen_XYZT` | str | `@specimen_XYZT` | also parsed → `stage_x/y/z` (float, µm) |
| `angle` | float | `@angle` | |
| `camera_index` | str | `@camera_index` | comma-separated per camera |
| `camera_type` | str | `@camera_type` | |
| `camera_roi` | str | `@camera_roi` | |
| `wavelength` | str | `@wavelength` | emission, per camera |
| `illumination_arms` | str | `@illumination_arms` | per camera |
| `illumination_filter` | str | `@illumination_filter` | |
| `exposure_time` | float | `@exposure_time` | ms |
| `detection_filter` | str | `@detection_filter` | |
| `detection_objective` | str | `@detection_objective` | also parsed → `objective_mag` (float) |
| `dimensions` | ndarray | `@dimensions` | `(n_cameras, 3)` from `"WxHxD,WxHxD,…"` |
| `z_step` | float | `@z_step` | µm; VW00 (Z-scan) |
| `y_step` | float | `@y_step` | µm; VW90 (Y-scan) |
| `stack_direction` | str | `@stack_direction` | drives `camera_view_map` |
| `planes` | str | `@planes` | |
| `laser_power` | str | `@laser_power` | |
| `experiment_notes` | str | `@experiment_notes` | |
| `zplanes` | int | derived | `dimensions[0][-1]` |
| `fps` | float | derived | `1000.0 / exposure_time` |
| `vps` | float | derived | `fps / zplanes` |
| `camera_pixel_size_um` | float | constant | `6.5` (Hamamatsu C11440-22C) |
| `pixel_resolution_um` | float | derived | `camera_pixel_size_um / objective_mag` |
| `axial_step` | float | merged | unifies `z_step` / `y_step` across XMLs |
| `camera_view_map` | dict | synthesized | from `stack_direction`: Z-scan → `{0:0,1:0}`, Y-scan → `{2:90,3:90}` |

XML discovery order (in `read_all_xml_metadata`): `ch*_spec{NN}.xml` → `ch*.xml` → `*_CHN*.xml`. Channel inferred from filename via `ch(\d+)` → `CHN(\d+)` → `VW(\d+)`.

## Filename Tags

| Tag | Meaning | Raw | Corrected | Fused |
|-----|---------|-----|-----------|-------|
| `SPC##` / `SPM##` | specimen / tile | `SPC00` | `SPM00` | `SPM00` |
| `TM######` | timepoint | `TM00000` | `TM000000` | `TM000000` |
| `CM##` | camera | `CM0` | `CM00` | `CM00_CM01` (pair) |
| `CHN##` | acquisition channel | `CHN00`, `CHN01` | `CHN00`, `CHN01` | `CHN00`, `CHN01` |
| `VW##` | fused view (scan axis) | — | — | `VW00` (z-scan), `VW90` (y-scan) |
| `ANG###` | illumination angle | `ANG000` | — | — |
| `PH#` | phase | `PH0` | — | — |

Correction iterates over all `(camera, channel)` pairs present in the raw data,
so dual-channel acquisitions (same camera capturing multiple wavelengths) produce
one corrected volume per `(CM, CHN)`. Fusion fuses each camera pair separately
for every channel both cameras share.

## Pipeline & Output Layout

Three stages run in sequence, each writing a sibling directory next to the raw
input. The data reduces at every step — **4 cameras → 2 fused views → 1
registered volume**:

| Stage | Call | Output dir | Reduces |
|-------|------|-----------|---------|
| 1. Correction | `correct_stack` | `<raw>.corrected/` | raw 4 cameras → 4 corrected volumes (CM00–CM03) |
| 2. Fusion | `multi_fuse` | `<raw>.fused/` | 4 cameras → 2 orthogonal views (VW00, VW90) |
| 3. Stitch export | `generate_bigstitcher_xml` | `<raw>.stitcher/` | 2 views → 1 registered volume (in BigStitcher) |

- **`.corrected`** — per-camera correction: dead-pixel removal, background
  subtraction, and segmentation masks. Produces one corrected volume per camera,
  kept in raw orientation; rotation and flips are deferred to fusion.
- **`.fused`** — fuses each opposing camera pair into a single view: CM00+CM01 →
  **VW00** (Z-scan), CM02+CM03 → **VW90** (Y-scan). Applies the rotation/flip and
  intensity correction, then blends the two cameras along Z. Four cameras become
  two orthogonal views.
- **`.stitcher`** — exports the two views as a BigDataViewer/BigStitcher dataset
  (zarr + `dataset.xml`). BigStitcher registers VW00↔VW90 into one isotropic
  volume; that registration runs in the BigStitcher GUI, not here (see
  `bigstitcher.md`).

All three are siblings of the raw input directory. `output_suffix` is one shared
field appended to every stage dir (`.corrected_<x>`, `.fused_<x>`,
`.stitcher_<x>`); empty gives the bare `.corrected` / `.fused` / `.stitcher`.

### Correction (`correct_stack.py`)

| Mode | Path | Filename |
|------|------|----------|
| Timelapse | `root.corrected/SPM00/TM000000/` | `SPM00_TM000000_CM00_CHN00.ome.tif` |
| Tiled | `root.corrected/SPM00/` | `SPM00_CM00_CHN00.ome.tif` |

Each tile gets a `SPM0N` folder, where `N` is the tile index. Each `SPM##` dir
also contains a `projections/` subfolder with per-view XY MIPs and a `raw.xyProjection`
counterpart for QC. Masks share the same prefix with suffixes
`.segmentationMask`, `.xyMask`, `.xzMask`. `minIntensity.npz` carries the
percentile values used downstream.

### Fusion (`multi_fuse`)

| Mode | Path | Filename |
|------|------|----------|
| Timelapse | `root.fused/SPM00/TM000000/` | `SPM00_TM000000_CM00_CM01_VW00_CHN00.ome.zarr` |
| Tiled | `root.fused/SPM00/` | `SPM00_CM00_CM01_VW00_CHN00.ome.zarr` |

Fused volumes go directly under each `SPM##` (mirroring the corrected tree — no
per-method subfolder). Projections for QC land in a single shared
`root.fused/projections/` sibling of the `SPM##` dirs.

Default pairs: `[(0, 1), (2, 3)]` — cameras 0,1 fuse to VW00 (z-scan); cameras 2,3
fuse to VW90 (y-scan). The CHN value reflects the acquisition channel of the pair
(in the standard Keller IsoView wiring, CHN00 for CM0/1 and CHN01 for CM2/3). For
dual-channel acquisitions, each pair fuses once per shared channel, producing
multiple outputs with the same `VW##` but different `CHN##`.

Only the second camera in each pair gets rotation/flip transforms.

## Supported Output Formats

| Format | Extension | Notes |
|--------|-----------|-------|
| OME-TIFF | `.ome.tif` | with metadata, optional resolution pyramids |
| Zarr v3 | `ome.zarr` | OME-NGFF metadata |
| KLB | `.klb` | Keller Lab Block (bzip2) |

## MATLAB Parity

The correction and fusion math is a faithful port of the original IsoView MATLAB
pipeline, verified against `processTimepoint_RC.m`, `clusterPT_RC.m`, and `multiFuse.m`.

**Preserved end-to-end:**

| Stage | Algorithm |
|-------|-----------|
| Dead-pixel detection | std/mean projection, knee threshold, per-Z 2D median replacement |
| Knee threshold | 50k subsample, max-distance-from-line |
| Anisotropic Gaussian | separable, `[k, k, max(1, k/scaling)]` |
| Slab smoothing | `margin = 2·kernelSize`, crop-interior reassembly |
| Adaptive threshold | `level = minI + (meanI − minI)·threshold` |
| Background | Nth percentile of nonzero voxels, subsampled |
| Masks | uint16 0/1 |
| Camera registration | per-pair X/Y offset + rotation |
| Intensity correction | overlap-sum ratio, dimmer view scaled up |
| Blending | adaptive (mask) and geometric (crossover) paths |

**Intentionally omitted** (multi-channel features unused on single-color hardware):
reference/dependent channel groups, cross-channel mask OR-fusion, the 3-pass global
temporal mask, and per-channel-per-camera nesting. Rotation and cropping are deferred
from correction to fusion. `apply_segmentation_mask` defaults off (masks are saved,
not baked into the corrected volume). Downstream stages — temporal fusion, drift
correction, dF/F, isotropic interpolation — are out of scope.

**Known minor differences:** median-filter border handling (`symmetric` vs cv2
`replicate`, ~1px edge); coordinate masks use a mask-weighted centroid (equivalent
to the MATLAB NaN-mean for binary masks) with 0-based vs 1-based coordinates.

## `.stack` Reading

Raw `.stack` is little-endian `uint16`, shape `(D, H, W)`, memmappable. `(W, H)` from XML `@dimensions[0]`; `D` derived from file size (`stat().st_size // 2 // (H*W)`) — XML's `D` may be stale on aborted acquisitions.
