:PROPERTIES:
:ID:       2a0fa5b2-ad95-4a57-8a85-dca16bb02d8b
:END:
#+title: s1_pyphenix
#+subtitle: automatically read in data from Opera Phenix and interactively visualize via napari widgets
#+author: Max Ferrin
#+email: ferrin@calicolabs.com
#+last_modified: <2026-01-02 Fri 17:51>
#+OPTIONS: toc:t num:nil author:t date:t title:t ^:nil broken-links:mark

* Description
1. given a path to an exported dataset or Harmony archive, automatically
   determine which data structure it is
2. load in image data and metadata
3. visualize with the correct scaling in napari
4. select different fields, wells, plates, etc. using napari widget
* Planning and progress notes
** design of basic metadata and data loader
- required inputs
  - path to experiment directory
- parameters
  - data structure (default: auto detect)
    - options: export, archive
    - write the method for export first; this is probably what most people use
      by default
  - row (default: first in range)
  - column (default: first in range)
  - field (default: first in range, unless stitching)
  - stitch fields? (default: no)
  - time points (default: all in range)
  - channel (default: all in range)
  - Z slice (default: all in range)
  - write to new file? (optional: no)
    - options: ome-tiff, numpy array, parquet
- printed outputs
  - plate layout
  - shape/dimensionality of data
    - rows
    - columns
    - fields
    - time
    - channels
    - Z
    - Y
    - X
  - channel names
  - time scale
  - Z physical size
  - Y, X physical size
- written files
  - image data file (as specified)
  - human-readable metadata file
- returned outputs
  - array of specified data
  - dictionary of metadata
** design of napari widget-based loader
- required inputs
  - path to experiment directory
- returned outputs
  - napari viewer displaying default data subset with physical scaling from metadata
  - widgets
    - well selector (drop-down menu populated by metadata)
    - stitch fields (yes/no)
    - field selector (drop-down menu populated by metadata, ignored if stitched)
    - timepoint selector (single or multi-selection, with option to select all,
      populated by metadata)
    - channel selector (single or multi-selection, with option to select all,
      populated by metadata)
    - z-slice selector (single or multi-selection, with option to select all,
      populated by metadata)
    - "visualize data" button to load and visualize the widget value-selected
      data subset in the viewer
** wishlist [0/2]
*** TODO alternate version that reads archive instead of export
*** TODO wrapper that automatically detects archive vs. export and reads accordingly
* Code
** Environment initial setup

This block creates the virtual environment, installs dependencies, and registers the project-specific Jupyter kernel.

#+begin_src bash :results raw drawer
# Create the pyproject.toml file
uv init --quiet

# Get rid of extra files generated by uv
rm main.py
rm README.md

# Create the virtual environment
uv venv

# Install jupyter
uv add jupyter

# Register the kernel that .dir-locals.el is configured to use
uv run python -m ipykernel install --user --name="s1_pyphenix" --display-name="Python (s1_pyphenix)"

# Force a refresh of the kernel list so Emacs can find the new one
uv run jupyter kernelspec list --json > /dev/null
#+end_src

#+RESULTS:
:results:
Installed kernelspec s1_pyphenix in /Users/ferrin/Library/Jupyter/kernels/s1_pyphenix
:end:

Refresh kernelspecs in emacs:
#+begin_src emacs-lisp :tangle no
(jupyter-refresh-kernelspecs)
#+end_src

#+RESULTS:
: Refreshing kernelspecs...done

** Install packages
#+begin_src bash :results raw drawer
# Napari is sensitive on arm-based macs; install with pip first
uv pip install 'napari[all]'

# Install packages (add more as needed)
uv add matplotlib bioio bioio-tifffile
#+end_src

#+RESULTS:
:results:
:end:

Additional packages can always be installed down the line with ~uv add [package]~

#+begin_src bash
uv pip install -e .
#+end_src

#+RESULTS:

** Github repo initial setup
/github CLI ~gh~ needs to be installed/

#+begin_src bash :results raw drawer
# Initialize local git repository
git add .
git commit -m "Initial commit from cookiecutter template"

# Create Github repository and push
# Replace 'private' with 'public' if desired
gh repo create s1_pyphenix --private --source=. --remote=origin --push
#+end_src

#+RESULTS:
:results:
[main (root-commit) 305f7cb] Initial commit from cookiecutter template
 11 files changed, 7970 insertions(+)
 create mode 100644 .dir-locals.el
 create mode 100644 .gitignore
 create mode 100644 .ob-jupyter/2e4964c2a0be550e39d5fa941d099a1219e57327.png
 create mode 100644 .ob-jupyter/aa17d8b12f4cd24f7f7f69e519ac531647da254a.png
 create mode 100644 .ob-jupyter/b4172680c71629c5c38bfd6ef19cc07fb6cbe75b.png
 create mode 100644 .ob-jupyter/f2d6210fb5988f310c53e30e49a1ab135fd918b8.png
 create mode 100644 .python-version
 create mode 100644 README.org
 create mode 100644 pyproject.toml
 create mode 100644 s1_pyphenix_functions.py
 create mode 100644 uv.lock
https://github.com/ferrinm/s1_pyphenix
branch 'main' set up to track 'origin/main'.
:end:

This should now be accessible at https://github.com/ferrinm/s1_pyphenix/blob/main/README.org

** Example use code (OUTDATED)
:PROPERTIES:
:header-args:python: :tangle no :kernel s1_pyphenix :session s1_pyphenix
:END:
- <2026-01-02 Fri> moving this over to [[id:ba84779b-19cd-44de-907a-198076a42682][s1_pyphenix_sandbox]] because the
  uv-controlled virtual environment got messed up by setting up the plugin
*** setup

#+begin_src python
import sys
from importlib import reload

from pyphenix import *
# for debugging
reload(sys.modules['pyphenix'])
from pyphenix import *
#+end_src

#+RESULTS:
:results:
# [goto error]
: [31m---------------------------------------------------------------------------[39m
: [31mModuleNotFoundError[39m                       Traceback (most recent call last)
: [36mCell[39m[36m [39m[32mIn[2][39m[32m, line 4[39m
: [32m      1[39m [38;5;28;01mimport[39;00m[38;5;250m [39m[34;01msys[39;00m
: [32m      2[39m [38;5;28;01mfrom[39;00m[38;5;250m [39m[34;01mimportlib[39;00m[38;5;250m [39m[38;5;28;01mimport[39;00m reload
: [32m----> [39m[32m4[39m [38;5;28;01mfrom[39;00m[38;5;250m [39m[34;01mpyphenix[39;00m[38;5;250m [39m[38;5;28;01mimport[39;00m *
: [32m      5[39m [38;5;66;03m# for debugging[39;00m
: [32m      6[39m reload(sys.modules[[33m'[39m[33mpyphenix[39m[33m'[39m])
: 
: [31mModuleNotFoundError[39m: No module named 'pyphenix'
:end:

*** read metadata without loading data

#+BEGIN_SRC python
export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'
# Returns None for data, but prints all metadata
nonedata, testmetadata = load_phenix_data(
    export_path,
    stitch_fields=True,
    metadata_only=True
)

print(f"Shape would be: {testmetadata['shape']['dimensions']}")
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Plate dimensions: 8 rows × 12 columns

Wells with data: 30
  Wells: 0202, 0203, 0204, 0207, 0208, 0209, 0302, 0303, 0304, 0307, 0308, 0309, 0402, 0403, 0404, 0502, 0503, 0504, 0507, 0508, 0509, 0602, 0603, 0604, 0607, 0608, 0609, 0702, 0703, 0704

Fields per well:
  r02c02: 25 fields (1-25)
  r02c03: 25 fields (1-25)
  r02c04: 25 fields (1-25)
  r02c07: 25 fields (1-25)
  r02c08: 25 fields (1-25)
  r02c09: 25 fields (1-25)
  r03c02: 25 fields (1-25)
  r03c03: 25 fields (1-25)
  r03c04: 25 fields (1-25)
  r03c07: 25 fields (1-25)
  r03c08: 25 fields (1-25)
  r03c09: 25 fields (1-25)
  r04c02: 25 fields (1-25)
  r04c03: 25 fields (1-25)
  r04c04: 25 fields (1-25)
  r05c02: 25 fields (1-25)
  r05c03: 25 fields (1-25)
  r05c04: 25 fields (1-25)
  r05c07: 25 fields (1-25)
  r05c08: 25 fields (1-25)
  r05c09: 25 fields (1-25)
  r06c02: 25 fields (1-25)
  r06c03: 25 fields (1-25)
  r06c04: 25 fields (1-25)
  r06c07: 25 fields (1-25)
  r06c08: 25 fields (1-25)
  r06c09: 25 fields (1-25)
  r07c02: 25 fields (1-25)
  r07c03: 25 fields (1-25)
  r07c04: 25 fields (1-25)

Timepoints: 1 (1-1)

Channels: 4
  Channel 1: Alexa 555
  Channel 2: Alexa 647
  Channel 3: Alexa 488
  Channel 4: DAPI

Z-planes: 4 (1-4)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================


============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c02

Data Shape: (1, 4, 4, 5400, 5400)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: Alexa 555
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.1 s
  Channel 2: Alexa 647
    Excitation: 640 nm
    Emission: 706 nm
    Exposure: 0.1 s
  Channel 3: Alexa 488
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.16 s
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
Timepoints: [1]
Z-slices: [1, 2, 3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm

,*** Fields have been STITCHED ***
============================================================


,*** METADATA ONLY - No image data loaded ***

Shape would be: (1, 4, 4, 5400, 5400)
#+end_example
:end:

*** Read and visualize specific subset of data

#+BEGIN_SRC python

export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'

# Read specific timepoints, z-slices, and channels
data, metadata = load_phenix_data(
    export_path,
    row=2,
    column=3,
    field=1,
    timepoints=[1],
    channels=[1, 4],
    z_slices=[3, 4]
)

viewer = visualize_in_napari(data, metadata)
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Plate dimensions: 8 rows × 12 columns

Wells with data: 30
  Wells: 0202, 0203, 0204, 0207, 0208, 0209, 0302, 0303, 0304, 0307, 0308, 0309, 0402, 0403, 0404, 0502, 0503, 0504, 0507, 0508, 0509, 0602, 0603, 0604, 0607, 0608, 0609, 0702, 0703, 0704

Fields per well:
  r02c02: 25 fields (1-25)
  r02c03: 25 fields (1-25)
  r02c04: 25 fields (1-25)
  r02c07: 25 fields (1-25)
  r02c08: 25 fields (1-25)
  r02c09: 25 fields (1-25)
  r03c02: 25 fields (1-25)
  r03c03: 25 fields (1-25)
  r03c04: 25 fields (1-25)
  r03c07: 25 fields (1-25)
  r03c08: 25 fields (1-25)
  r03c09: 25 fields (1-25)
  r04c02: 25 fields (1-25)
  r04c03: 25 fields (1-25)
  r04c04: 25 fields (1-25)
  r05c02: 25 fields (1-25)
  r05c03: 25 fields (1-25)
  r05c04: 25 fields (1-25)
  r05c07: 25 fields (1-25)
  r05c08: 25 fields (1-25)
  r05c09: 25 fields (1-25)
  r06c02: 25 fields (1-25)
  r06c03: 25 fields (1-25)
  r06c04: 25 fields (1-25)
  r06c07: 25 fields (1-25)
  r06c08: 25 fields (1-25)
  r06c09: 25 fields (1-25)
  r07c02: 25 fields (1-25)
  r07c03: 25 fields (1-25)
  r07c04: 25 fields (1-25)

Timepoints: 1 (1-1)

Channels: 4
  Channel 1: Alexa 555
  Channel 2: Alexa 647
  Channel 3: Alexa 488
  Channel 4: DAPI

Z-planes: 4 (1-4)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c03

Data Shape: (1, 2, 2, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: Alexa 555
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.1 s
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1]
Z-slices: [3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm
============================================================

============================================================
🚀 NAPARI VIEWER LAUNCHED
============================================================
Viewing: 10-20-25-iDA_DJ1KO_PFF3wks-redo - r02c03 - Field 1
Timepoint: 1 / 1
Channels: 2
Z-slices: 2
Image size: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================
#+end_example
:end:


#+begin_src python
import matplotlib.pyplot as plt

viz_check = data[0,0,0,...]

plt.hist(viz_check.ravel())
plt.yscale('log')
plt.figure()
plt.imshow(viz_check)

#+end_src

#+RESULTS:
:results:
: <matplotlib.image.AxesImage at 0x3ac6f5e50>
[[file:./.ob-jupyter/2e4964c2a0be550e39d5fa941d099a1219e57327.png]]
[[file:./.ob-jupyter/f2d6210fb5988f310c53e30e49a1ab135fd918b8.png]]
:end:

*** interactive loader and visualizer
**** Basic usage

#+BEGIN_SRC python

# Create interactive loader
viewer = create_phenix_data_loader(
    export_path
)

# The viewer will open with the widget on the right side
# Use the widget controls to select and visualize data
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
🎛️  INTERACTIVE DATA LOADER WIDGET INITIALIZED
============================================================
Experiment: 10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1
Wells available: 30
Channels: 4
============================================================
2025-12-08 20:23:06.767 python3[34129:1737943] The class 'NSSavePanel' overrides the method identifier.  This method is implemented by class 'NSWindow'
#+end_example
:end:

**** Programmatic access to widget

#+BEGIN_SRC python
# Create viewer with widget
viewer = create_phenix_data_loader(export_path)

# Access the widget (if needed)
widget = viewer.window._dock_widgets['Data Loader'].widget()

# Programmatically change selections (optional)
widget.well_combo.setCurrentIndex(2)  # Select third well
widget.stitch_checkbox.setChecked(True)  # Enable stitching
widget._visualize_data()  # Load data
#+END_SRC

**** Multiple experiments

#+BEGIN_SRC python
# Load multiple experiments in separate viewers
viewer1 = create_phenix_data_loader('/path/to/experiment1')
viewer2 = create_phenix_data_loader('/path/to/experiment2')
#+END_SRC

** Source code (OUTDATED)
:PROPERTIES:
:header-args:python: :tangle s1_pyphenix_functions.py :kernel s1_pyphenix :session s1_pyphenix
:END:
- <2026-01-02 Fri> I stopped updating the code here, instead writing to the
  files in [[file:src/pyphenix/]] that napari hub is expecting
*** load a single well from archive
:PROPERTIES:
:ID:       1656467d-3bcb-405e-a5e4-f390f918b075
:END:
note: z-resolution is calculated by (maximum z position - minimum z
position)/total z slices. This will not be accurate if different volumes have
different settings or positions!

#+BEGIN_SRC python
import napari
import numpy as np
import os
import pandas as pd
from bioio import BioImage
import xml.etree.ElementTree as ET
import json


def load_image_stack_with_metadata(base_path, target_row, target_col, target_field):
    """
    Load image stack and extract metadata from Phenix microscope data.
    
    Parameters
    ----------
    base_path : str
        Path to the base directory containing images folder and metadata files
    target_row : int
        Target row in the plate
    target_col : int
        Target column in the plate
    target_field : int
        Target field number
        
    Returns
    -------
    image_stack : np.ndarray
        5D numpy array with shape (T, C, Z, Y, X)
    metadata : dict
        Dictionary containing extracted metadata including:
        - 'channel_names': list of channel names
        - 'scale_x': X voxel size in µm
        - 'scale_y': Y voxel size in µm
        - 'scale_z': Z voxel size in µm
        - 'num_times': number of timepoints
        - 'num_channels': number of channels
        - 'num_planes': number of Z planes
        - 'field_df': DataFrame with file information
    """
    images_path = os.path.join(base_path, 'images')
    
    # =================================================================
    # PARSE METADATA FILES
    # =================================================================
    print("Reading metadata files...")
    
    # --- A) Parse image.index.txt ---
    index_file_path = os.path.join(images_path, 'image.index.txt')
    index_df = pd.read_csv(index_file_path, sep='\t', skiprows=2)
    
    cols_to_convert = ['Row', 'Column', 'Field', 'AbsoluteZ', 'Timepoint', 'Channel', 'Plane']
    for col in cols_to_convert:
        index_df[col] = pd.to_numeric(index_df[col], errors='coerce')
    
    index_df.dropna(subset=cols_to_convert, inplace=True)
    for col in ['Row', 'Column', 'Field', 'Timepoint', 'Channel', 'Plane']:
        index_df[col] = index_df[col].astype(int)
    
    field_df = index_df[
        (index_df['Row'] == target_row) &
        (index_df['Column'] == target_col) &
        (index_df['Field'] == target_field)
    ].copy()
    
    if field_df.empty:
        raise ValueError(
            f"No data found for Row={target_row}, Column={target_col}, Field={target_field}. "
            "Please check your target values and file paths."
        )
    
    # --- B) Parse the .kw.txt file for channel names ---
    kw_files = [f for f in os.listdir(base_path) if f.endswith('.kw.txt')]
    channel_names = []
    
    if kw_files:
        kw_file_path = os.path.join(base_path, kw_files[0])
        try:
            with open(kw_file_path, 'r') as f:
                full_content = f.read()
                start, end = full_content.find('{'), full_content.rfind('}') + 1
                if start != -1 and end != 0:
                    json_str = full_content[start:end]
                    metadata_kw = json.loads(json_str)
                    channel_names = metadata_kw.get('CHANNEL', [])
        except (FileNotFoundError, json.JSONDecodeError) as e:
            print(f"⚠️ Warning: Could not parse .kw.txt file. {e}")
    
    # --- C) Extract Voxel Sizes from Metadata ---
    scale_x, scale_y, scale_z = 1.0, 1.0, 1.0  # Default values
    
    xml_file_path = os.path.join(images_path, 'index.xml')
    try:
        tree = ET.parse(xml_file_path)
        root = tree.getroot()
        ns = {'h': '43B2A954-E3C3-47E1-B392-6635266B0DD3/HarmonyV7'}
        
        res_x_element = root.find('.//h:ImageResolutionX', ns)
        res_y_element = root.find('.//h:ImageResolutionY', ns)

        binning_x_element = root.find('.//h:BinningX', ns)
        binning_y_element = root.find('.//h:BinningY', ns)
        
        if res_x_element is not None and res_y_element is not None:
            scale_x = float(res_x_element.text) / float(binning_x_element.text) * 1e6  # Convert from meters to µm
            scale_y = float(res_y_element.text) / float(binning_y_element.text) * 1e6  # Convert from meters to µm
        else:
            print("⚠️ Warning: Could not find ImageResolutionX/Y tags in index.xml.")
            
    except (FileNotFoundError, ET.ParseError) as e:
        print(f"⚠️ Warning: Could not parse index.xml file. {e}")
    
    # Calculate Z voxel size from the AbsoluteZ column
    num_planes_float = field_df['Plane'].nunique()
    if num_planes_float > 1:
        min_z = field_df['AbsoluteZ'].min()
        max_z = field_df['AbsoluteZ'].max()
        scale_z = ((max_z - min_z) / (num_planes_float - 1)) * 1e6  # Convert from meters to µm
    else:
        scale_z = 1.0
    
    print("\nSuccessfully parsed metadata:")
    print(f"  - Channel Names: {channel_names}")
    print(f"  - Voxel Size (Z, Y, X) in µm: ({scale_z:.4f}, {scale_y:.4f}, {scale_x:.4f})")
    
    # =================================================================
    # ASSEMBLE THE 5D NUMPY ARRAY
    # =================================================================
    num_times = int(field_df['Timepoint'].max())
    num_channels = int(field_df['Channel'].max())
    num_planes = int(field_df['Plane'].nunique())
    
    first_img_path = os.path.join(images_path, field_df.iloc[0]['__URL'])
    first_img = BioImage(first_img_path)
    height, width = first_img.dims.Y, first_img.dims.X
    
    image_stack = np.zeros(
        (num_times, num_channels, num_planes, height, width),
        dtype=first_img.dtype
    )
    
    print("\nAssembling the 5D image array...")
    plane_map = {plane: i for i, plane in enumerate(sorted(field_df['Plane'].unique()))}
    
    for _, row in field_df.iterrows():
        t = row['Timepoint'] - 1
        c = row['Channel'] - 1
        z = plane_map[row['Plane']]
        
        img_path = os.path.join(images_path, row['__URL'])
        img_data = BioImage(img_path).data
        image_stack[t, c, z, :, :] = img_data
    
    print("✅ Assembly complete.")
    
    # Prepare metadata dictionary
    metadata = {
        'channel_names': channel_names,
        'scale_x': scale_x,
        'scale_y': scale_y,
        'scale_z': scale_z,
        'num_times': num_times,
        'num_channels': num_channels,
        'num_planes': num_planes,
        'field_df': field_df,
        'target_row': target_row,
        'target_col': target_col,
        'target_field': target_field
    }
    
    return image_stack, metadata
#+END_SRC
*** load a single well from export
#+BEGIN_SRC python
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
from PIL import Image
import re
from dataclasses import dataclass
import json
import warnings


@dataclass
class PhenixMetadata:
    """Container for Opera Phenix metadata"""
    plate_id: str
    plate_rows: int
    plate_columns: int
    wells: List[str]
    channels: Dict[int, Dict[str, str]]
    image_size: Tuple[int, int]
    pixel_size: Tuple[float, float]  # in meters
    z_step: Optional[float]
    timepoints: List[int]
    fields: List[int]
    planes: List[int]
    channel_ids: List[int]


class OperaPhenixReader:
    """
    Reader for Opera Phenix exported experiment data.
    
    Reads TIFF images and XML metadata from exported experiments,
    returning numpy arrays and metadata dictionaries.
    """
    
    def __init__(self, experiment_path: str):
        """
        Initialize reader with path to experiment directory.
        
        Parameters
        ----------
        experiment_path : str
            Path to the exported experiment directory
        """
        self.experiment_path = Path(experiment_path)
        self.images_path = self.experiment_path / "Images"
        self.index_xml_path = self.images_path / "Index.xml"
        
        if not self.images_path.exists():
            raise FileNotFoundError(f"Images directory not found: {self.images_path}")
        if not self.index_xml_path.exists():
            raise FileNotFoundError(f"Index.xml not found: {self.index_xml_path}")
        
        # Parse metadata
        self.tree = ET.parse(self.index_xml_path)
        self.root = self.tree.getroot()
        self.ns = {'ns': '43B2A954-E3C3-47E1-B392-6635266B0DD3/HarmonyV7'}
        
        self.metadata = self._parse_metadata()
        self.image_index = self._build_image_index()
        self.well_field_map = self._build_well_field_map()
    
    def _parse_metadata(self) -> PhenixMetadata:
        """Parse metadata from Index.xml"""
        # Parse plate information
        plate = self.root.find('.//ns:Plate', self.ns)
        plate_id = plate.find('ns:PlateID', self.ns).text
        plate_rows = int(plate.find('ns:PlateRows', self.ns).text)
        plate_columns = int(plate.find('ns:PlateColumns', self.ns).text)
        
        # Parse wells
        wells = [well.attrib['id'] for well in plate.findall('ns:Well', self.ns)]
        
        # Parse channel information
        channels = {}
        image_size_x = None
        image_size_y = None
        pixel_size_x = None
        pixel_size_y = None
        
        channel_maps = self.root.findall('.//ns:Maps/ns:Map', self.ns)
        for map_elem in channel_maps:
            entries = map_elem.findall('ns:Entry', self.ns)
            for entry in entries:
                # Check if this entry has channel metadata
                ch_name_elem = entry.find('ns:ChannelName', self.ns)
                if ch_name_elem is not None:
                    ch_id = int(entry.attrib['ChannelID'])
                    
                    # Extract channel information
                    channels[ch_id] = {
                        'name': ch_name_elem.text,
                        'excitation': entry.find('ns:MainExcitationWavelength', self.ns).text,
                        'emission': entry.find('ns:MainEmissionWavelength', self.ns).text,
                        'exposure': entry.find('ns:ExposureTime', self.ns).text,
                        'objective_mag': entry.find('ns:ObjectiveMagnification', self.ns).text,
                        'objective_na': entry.find('ns:ObjectiveNA', self.ns).text,
                    }
                    
                    # Get image dimensions and pixel size from first channel with this info
                    if image_size_x is None:
                        img_size_x_elem = entry.find('ns:ImageSizeX', self.ns)
                        img_size_y_elem = entry.find('ns:ImageSizeY', self.ns)
                        pix_size_x_elem = entry.find('ns:ImageResolutionX', self.ns)
                        pix_size_y_elem = entry.find('ns:ImageResolutionY', self.ns)
                        
                        if all([img_size_x_elem is not None, 
                               img_size_y_elem is not None,
                               pix_size_x_elem is not None,
                               pix_size_y_elem is not None]):
                            image_size_x = int(img_size_x_elem.text)
                            image_size_y = int(img_size_y_elem.text)
                            pixel_size_x = float(pix_size_x_elem.text)
                            pixel_size_y = float(pix_size_y_elem.text)
        
        # Fallback: if we didn't find image dimensions, check if we can get from first image
        if image_size_x is None:
            first_image = self.root.find('.//ns:Images/ns:Image', self.ns)
            if first_image is not None:
                # Will load one image to get dimensions
                url = first_image.find('ns:URL', self.ns).text
                img_path = self.images_path / url
                if img_path.exists():
                    from PIL import Image as PILImage
                    with PILImage.open(img_path) as img:
                        image_size_x = img.width
                        image_size_y = img.height
                    # Still need pixel size - use default if not found
                    pixel_size_x = pixel_size_x or 2.96688132474701E-07
                    pixel_size_y = pixel_size_y or 2.96688132474701E-07
        
        # Parse all images to determine dimensions
        images = self.root.findall('.//ns:Images/ns:Image', self.ns)
        timepoints = set()
        fields = set()
        planes = set()
        channel_ids = set()
        z_positions = set()
        
        for img in images:
            timepoints.add(int(img.find('ns:TimepointID', self.ns).text))
            fields.add(int(img.find('ns:FieldID', self.ns).text))
            planes.add(int(img.find('ns:PlaneID', self.ns).text))
            channel_ids.add(int(img.find('ns:ChannelID', self.ns).text))
            z_pos = float(img.find('ns:PositionZ', self.ns).text)
            z_positions.add(z_pos)
        
        # Calculate Z step
        z_step = None
        if len(z_positions) > 1:
            z_sorted = sorted(z_positions)
            z_step = abs(z_sorted[1] - z_sorted[0])
        
        return PhenixMetadata(
            plate_id=plate_id,
            plate_rows=plate_rows,
            plate_columns=plate_columns,
            wells=wells,
            channels=channels,
            image_size=(image_size_y, image_size_x),
            pixel_size=(pixel_size_y, pixel_size_x),
            z_step=z_step,
            timepoints=sorted(timepoints),
            fields=sorted(fields),
            planes=sorted(planes),
            channel_ids=sorted(channel_ids)
        )
    
    def _build_image_index(self) -> Dict:
        """Build index of all images for fast lookup"""
        index = {}
        images = self.root.findall('.//ns:Images/ns:Image', self.ns)
        
        for img in images:
            row = int(img.find('ns:Row', self.ns).text)
            col = int(img.find('ns:Col', self.ns).text)
            field = int(img.find('ns:FieldID', self.ns).text)
            plane = int(img.find('ns:PlaneID', self.ns).text)
            timepoint = int(img.find('ns:TimepointID', self.ns).text)
            channel = int(img.find('ns:ChannelID', self.ns).text)
            url = img.find('ns:URL', self.ns).text
            
            pos_x = float(img.find('ns:PositionX', self.ns).text)
            pos_y = float(img.find('ns:PositionY', self.ns).text)
            pos_z = float(img.find('ns:PositionZ', self.ns).text)
            
            key = (row, col, field, plane, timepoint, channel)
            index[key] = {
                'url': url,
                'position': (pos_x, pos_y, pos_z)
            }
        
        return index
    
    def _build_well_field_map(self) -> Dict:
        """Build a map of which fields exist for each well"""
        well_field_map = {}
        for key in self.image_index.keys():
            row, col, field, plane, timepoint, channel = key
            well_id = (row, col)
            if well_id not in well_field_map:
                well_field_map[well_id] = set()
            well_field_map[well_id].add(field)
        
        # Convert sets to sorted lists
        for well_id in well_field_map:
            well_field_map[well_id] = sorted(well_field_map[well_id])
        
        return well_field_map
    
    def _print_dataset_overview(self):
        """Print overview of entire dataset"""
        print("\n" + "="*60)
        print("DATASET OVERVIEW")
        print("="*60)
        
        print(f"\nPlate ID: {self.metadata.plate_id}")
        print(f"Plate dimensions: {self.metadata.plate_rows} rows × {self.metadata.plate_columns} columns")
        
        print(f"\nWells with data: {len(self.metadata.wells)}")
        print(f"  Wells: {', '.join(self.metadata.wells)}")
        
        print(f"\nFields per well:")
        for well_id in self.metadata.wells:
            row, col = int(well_id[:2]), int(well_id[2:])
            fields = self.well_field_map.get((row, col), [])
            print(f"  r{row:02d}c{col:02d}: {len(fields)} fields ({min(fields) if fields else 'N/A'}-{max(fields) if fields else 'N/A'})")
        
        print(f"\nTimepoints: {len(self.metadata.timepoints)} ({min(self.metadata.timepoints)}-{max(self.metadata.timepoints)})")
        
        print(f"\nChannels: {len(self.metadata.channel_ids)}")
        for ch_id in self.metadata.channel_ids:
            ch_info = self.metadata.channels[ch_id]
            print(f"  Channel {ch_id}: {ch_info['name']}")
        
        print(f"\nZ-planes: {len(self.metadata.planes)} ({min(self.metadata.planes)}-{max(self.metadata.planes)})")
        
        print(f"\nImage dimensions: {self.metadata.image_size[0]} × {self.metadata.image_size[1]} pixels")
        print(f"Pixel size: {self.metadata.pixel_size[0]*1e6:.3f} × {self.metadata.pixel_size[1]*1e6:.3f} µm")
        
        if self.metadata.z_step is not None:
            print(f"Z-step: {self.metadata.z_step*1e6:.3f} µm")
        
        print("="*60 + "\n")
    
    def read_data(self,
                row: Optional[int] = None,
                column: Optional[int] = None,
                field: Optional[int] = None,
                stitch_fields: bool = False,
                timepoints: Optional[Union[int, List[int]]] = None,
                channels: Optional[Union[int, List[int]]] = None,
                z_slices: Optional[Union[int, List[int]]] = None,
                metadata_only: bool = False,  # NEW PARAMETER
                output_file: Optional[str] = None,
                output_format: Optional[str] = None) -> Tuple[np.ndarray, Dict]:
        """
        Read image data from Opera Phenix experiment.

        Parameters
        ----------
        row : int, optional
            Well row (default: first available)
        column : int, optional
            Well column (default: first available)
        field : int, optional
            Field to read (default: first available, ignored if stitching)
        stitch_fields : bool, default False
            Whether to stitch multiple fields together
        timepoints : int or list of int, optional
            Timepoint(s) to read (default: all)
        channels : int or list of int, optional
            Channel(s) to read (default: all)
        z_slices : int or list of int, optional
            Z plane(s) to read (default: all)
        metadata_only : bool, default False
            If True, only print metadata without loading image data
        output_file : str, optional
            Path to save output file
        output_format : str, optional
            Format for output file: 'ome-tiff', 'numpy', or 'parquet'

        Returns
        -------
        data : np.ndarray or None
            Image data array with dimensions (T, C, Z, Y, X), or None if metadata_only=True
        metadata : dict
            Dictionary containing metadata
        """
        # Print dataset overview first
        self._print_dataset_overview()

        if metadata_only:
            # Set defaults for metadata preparation
            if row is None:
                row = min([int(w[:2]) for w in self.metadata.wells])
            if column is None:
                column = min([int(w[2:]) for w in self.metadata.wells])

            available_fields = self.well_field_map.get((row, column), self.metadata.fields)

            if stitch_fields:
                fields = available_fields
            else:
                if field is None:
                    field = available_fields[0] if available_fields else self.metadata.fields[0]
                fields = [field]

            if timepoints is None:
                timepoints = self.metadata.timepoints
            elif isinstance(timepoints, int):
                timepoints = [timepoints]

            if channels is None:
                channels = self.metadata.channel_ids
            elif isinstance(channels, int):
                channels = [channels]

            if z_slices is None:
                z_slices = self.metadata.planes
            elif isinstance(z_slices, int):
                z_slices = [z_slices]

            # Calculate what the shape would be without loading data
            n_time = len(timepoints)
            n_channels = len(channels)
            n_z = len(z_slices)
            img_h, img_w = self.metadata.image_size

            if stitch_fields:
                # Calculate stitched dimensions
                field_positions = {}
                for fld in fields:
                    key = (row, column, fld, z_slices[0], timepoints[0], channels[0])
                    if key in self.image_index:
                        pos = self.image_index[key]['position']
                        field_positions[fld] = (pos[0], pos[1])

                if field_positions:
                    pixel_size = self.metadata.pixel_size[0]
                    positions_x = [pos[0] for pos in field_positions.values()]
                    positions_y = [pos[1] for pos in field_positions.values()]
                    min_x, max_x = min(positions_x), max(positions_x)
                    min_y, max_y = min(positions_y), max(positions_y)
                    stitched_w = int((max_x - min_x) / pixel_size) + img_w
                    stitched_h = int((max_y - min_y) / pixel_size) + img_h
                    shape = (n_time, n_channels, n_z, stitched_h, stitched_w)
                else:
                    shape = (n_time, n_channels, n_z, img_h, img_w)
            else:
                shape = (n_time, n_channels, n_z, img_h, img_w)

            # Prepare and print metadata
            metadata_dict = self._prepare_metadata_dict(
                row, column, fields, timepoints, channels, z_slices, 
                stitch_fields, shape
            )
            self._print_metadata(metadata_dict)

            print("\n*** METADATA ONLY - No image data loaded ***\n")

            return None, metadata_dict
        
        # Set defaults
        if row is None:
            row = min([int(w[:2]) for w in self.metadata.wells])
        if column is None:
            column = min([int(w[2:]) for w in self.metadata.wells])
        
        # Get available fields for this well
        available_fields = self.well_field_map.get((row, column), self.metadata.fields)
        
        if stitch_fields:
            fields = available_fields
        else:
            if field is None:
                field = available_fields[0] if available_fields else self.metadata.fields[0]
            fields = [field]
        
        if timepoints is None:
            timepoints = self.metadata.timepoints
        elif isinstance(timepoints, int):
            timepoints = [timepoints]
        
        if channels is None:
            channels = self.metadata.channel_ids
        elif isinstance(channels, int):
            channels = [channels]
        
        if z_slices is None:
            z_slices = self.metadata.planes
        elif isinstance(z_slices, int):
            z_slices = [z_slices]
        
        # Read images
        if stitch_fields:
            data = self._read_and_stitch(row, column, fields, timepoints, 
                                        channels, z_slices)
        else:
            data = self._read_images(row, column, fields, timepoints,
                                   channels, z_slices)
        
        # Prepare metadata dictionary
        metadata_dict = self._prepare_metadata_dict(
            row, column, fields, timepoints, channels, z_slices, 
            stitch_fields, data.shape
        )
        
        # Print metadata
        self._print_metadata(metadata_dict)
        
        # Save output if requested
        if output_file is not None and output_format is not None:
            self._save_output(data, metadata_dict, output_file, output_format)
        
        return data, metadata_dict
    
    def _read_images(self, row: int, col: int, fields: List[int],
                    timepoints: List[int], channels: List[int],
                    z_slices: List[int]) -> np.ndarray:
        """Read images without stitching"""
        # Determine output shape
        n_time = len(timepoints)
        n_channels = len(channels)
        n_z = len(z_slices)
        img_h, img_w = self.metadata.image_size
        
        # Initialize array (always 5D: T, C, Z, Y, X)
        data = np.zeros((n_time, n_channels, n_z, img_h, img_w),
                       dtype=np.uint16)
        
        # Track missing images
        missing_images = []
        
        # Read images (only from first field since field parameter accepts single value)
        field = fields[0]
        for t_idx, timepoint in enumerate(timepoints):
            for c_idx, channel in enumerate(channels):
                for z_idx, z_slice in enumerate(z_slices):
                    key = (row, col, field, z_slice, timepoint, channel)
                    if key in self.image_index:
                        img_path = self.images_path / self.image_index[key]['url']
                        if img_path.exists():
                            img = Image.open(img_path)
                            data[t_idx, c_idx, z_idx] = np.array(img)
                        else:
                            missing_images.append({
                                'key': key,
                                'path': str(img_path),
                                'reason': 'file not found'
                            })
                    else:
                        missing_images.append({
                            'key': key,
                            'path': f"r{row:02d}c{col:02d}f{field:02d}p{z_slice:02d}-ch{channel}sk1fk1fl1.tiff",
                            'reason': 'not in index'
                        })
        
        # Print warnings for missing images
        if missing_images:
            print("\n" + "!"*60)
            print(f"WARNING: {len(missing_images)} missing images")
            print("!"*60)
            for miss in missing_images[:10]:  # Show first 10
                row, col, field, plane, timepoint, channel = miss['key']
                print(f"  Missing: r{row:02d}c{col:02d}f{field:02d}p{plane:02d}t{timepoint}ch{channel}")
                print(f"    Reason: {miss['reason']}")
            if len(missing_images) > 10:
                print(f"  ... and {len(missing_images) - 10} more")
            print("  These positions will be filled with zeros.")
            print("!"*60 + "\n")
        
        return data
    
    def _read_and_stitch(self, row: int, col: int, fields: List[int],
                        timepoints: List[int], channels: List[int],
                        z_slices: List[int]) -> np.ndarray:
        """Read and stitch multiple fields"""
        # Get field positions
        field_positions = {}
        for field in fields:
            key = (row, col, field, z_slices[0], timepoints[0], channels[0])
            if key in self.image_index:
                pos = self.image_index[key]['position']
                field_positions[field] = (pos[0], pos[1])
        
        if not field_positions:
            raise ValueError(f"No valid field positions found for well r{row:02d}c{col:02d}")
        
        # Calculate stitched dimensions
        img_h, img_w = self.metadata.image_size
        pixel_size = self.metadata.pixel_size[0]  # assume square pixels
        
        positions_x = [pos[0] for pos in field_positions.values()]
        positions_y = [pos[1] for pos in field_positions.values()]
        
        min_x, max_x = min(positions_x), max(positions_x)
        min_y, max_y = min(positions_y), max(positions_y)
        
        stitched_w = int((max_x - min_x) / pixel_size) + img_w
        stitched_h = int((max_y - min_y) / pixel_size) + img_h
        
        # Initialize stitched array
        n_time = len(timepoints)
        n_channels = len(channels)
        n_z = len(z_slices)
        
        data = np.zeros((n_time, n_channels, n_z, stitched_h, stitched_w),
                       dtype=np.uint16)
        
        # Track missing images
        missing_images = []
        
        # Stitch images
        for t_idx, timepoint in enumerate(timepoints):
            for c_idx, channel in enumerate(channels):
                for z_idx, z_slice in enumerate(z_slices):
                    for field in fields:
                        key = (row, col, field, z_slice, timepoint, channel)
                        if key in self.image_index:
                            img_path = self.images_path / self.image_index[key]['url']
                            if img_path.exists():
                                img = np.array(Image.open(img_path))
                                
                                # Calculate position in stitched image
                                pos = field_positions[field]
                                x_offset = int((pos[0] - min_x) / pixel_size)
                                # FIX: Invert y-offset calculation
                                y_offset = int((max_y - pos[1]) / pixel_size)
                                
                                data[t_idx, c_idx, z_idx,
                                    y_offset:y_offset+img_h,
                                    x_offset:x_offset+img_w] = img
                            else:
                                missing_images.append({
                                    'key': key,
                                    'path': str(img_path),
                                    'reason': 'file not found'
                                })
                        else:
                            missing_images.append({
                                'key': key,
                                'path': f"r{row:02d}c{col:02d}f{field:02d}p{z_slice:02d}-ch{channel}sk1fk1fl1.tiff",
                                'reason': 'not in index'
                            })
        
        # Print warnings for missing images
        if missing_images:
            print("\n" + "!"*60)
            print(f"WARNING: {len(missing_images)} missing images")
            print("!"*60)
            for miss in missing_images[:10]:  # Show first 10
                row, col, field, plane, timepoint, channel = miss['key']
                print(f"  Missing: r{row:02d}c{col:02d}f{field:02d}p{plane:02d}t{timepoint}ch{channel}")
                print(f"    Reason: {miss['reason']}")
            if len(missing_images) > 10:
                print(f"  ... and {len(missing_images) - 10} more")
            print("  These positions will be filled with zeros.")
            print("!"*60 + "\n")
        
        return data
    
    def _prepare_metadata_dict(self, row: int, col: int, fields: List[int],
                               timepoints: List[int], channels: List[int],
                               z_slices: List[int], stitched: bool,
                               shape: Tuple) -> Dict:
        """Prepare metadata dictionary for output"""
        channel_info = {ch: self.metadata.channels[ch] for ch in channels}
        
        metadata = {
            'plate_id': self.metadata.plate_id,
            'plate_layout': {
                'rows': self.metadata.plate_rows,
                'columns': self.metadata.plate_columns
            },
            'well': f"r{row:02d}c{col:02d}",
            'shape': {
                'description': 'T, C, Z, Y, X',
                'dimensions': shape
            },
            'fields': fields,
            'timepoints': timepoints,
            'channels': channel_info,
            'z_slices': z_slices,
            'pixel_size': {
                'x': self.metadata.pixel_size[1],
                'y': self.metadata.pixel_size[0],
                'unit': 'm'
            },
            'z_step': self.metadata.z_step,
            'stitched': stitched
        }
        
        return metadata
    
    def _print_metadata(self, metadata: Dict):
        """Print metadata to console"""
        print("\n" + "="*60)
        print("LOADED DATA SUMMARY")
        print("="*60)
        
        print(f"\nPlate ID: {metadata['plate_id']}")
        print(f"Well: {metadata['well']}")
        
        print(f"\nData Shape: {metadata['shape']['dimensions']}")
        print(f"  Dimension order: {metadata['shape']['description']}")
        
        print(f"\nChannels:")
        for ch_id, ch_info in metadata['channels'].items():
            print(f"  Channel {ch_id}: {ch_info['name']}")
            print(f"    Excitation: {ch_info['excitation']} nm")
            print(f"    Emission: {ch_info['emission']} nm")
            print(f"    Exposure: {ch_info['exposure']} s")
        
        print(f"\nFields: {metadata['fields']}")
        print(f"Timepoints: {metadata['timepoints']}")
        print(f"Z-slices: {metadata['z_slices']}")
        
        print(f"\nPhysical Dimensions:")
        print(f"  Pixel size (X): {metadata['pixel_size']['x']*1e6:.3f} µm")
        print(f"  Pixel size (Y): {metadata['pixel_size']['y']*1e6:.3f} µm")
        if metadata['z_step'] is not None:
            print(f"  Z step: {metadata['z_step']*1e6:.3f} µm")
        
        if metadata['stitched']:
            print(f"\n*** Fields have been STITCHED ***")
        
        print("="*60 + "\n")
    
    def _save_output(self, data: np.ndarray, metadata: Dict,
                    output_file: str, output_format: str):
        """Save data and metadata to file"""
        output_path = Path(output_file)
        
        if output_format == 'numpy':
            np.save(output_path, data)
            metadata_path = output_path.with_suffix('.json')
            with open(metadata_path, 'w') as f:
                json.dump(metadata, f, indent=2, default=str)
            print(f"Saved numpy array to: {output_path}")
            print(f"Saved metadata to: {metadata_path}")
        
        elif output_format == 'ome-tiff':
            try:
                import tifffile
                tifffile.imwrite(output_path, data, photometric='minisblack')
                metadata_path = output_path.with_suffix('.json')
                with open(metadata_path, 'w') as f:
                    json.dump(metadata, f, indent=2, default=str)
                print(f"Saved OME-TIFF to: {output_path}")
                print(f"Saved metadata to: {metadata_path}")
            except ImportError:
                print("Warning: tifffile not available. Falling back to numpy format.")
                self._save_output(data, metadata, str(output_path.with_suffix('.npy')), 'numpy')
        
        elif output_format == 'parquet':
            print("Warning: Parquet format not implemented for image data.")
            print("Saving as numpy instead.")
            self._save_output(data, metadata, str(output_path.with_suffix('.npy')), 'numpy')


def load_phenix_data(experiment_path: str, **kwargs) -> Tuple[np.ndarray, Dict]:
    """
    Convenience function to load Opera Phenix data.
    
    Parameters
    ----------
    experiment_path : str
        Path to experiment directory
    ,**kwargs
        Additional arguments passed to OperaPhenixReader.read_data()
    
    Returns
    -------
    data : np.ndarray
        Image data array with dimensions (T, C, Z, Y, X)
    metadata : dict
        Metadata dictionary
    """
    reader = OperaPhenixReader(experiment_path)
    return reader.read_data(**kwargs)
#+END_SRC

#+RESULTS:
:results:
:end:

#+BEGIN_SRC python :tangle no
import os
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, List, Optional, Tuple, Union
import numpy as np
from PIL import Image
import re
from dataclasses import dataclass
import json


@dataclass
class PhenixMetadata:
    """Container for Opera Phenix metadata"""
    plate_id: str
    plate_rows: int
    plate_columns: int
    wells: List[str]
    channels: Dict[int, Dict[str, str]]
    image_size: Tuple[int, int]
    pixel_size: Tuple[float, float]  # in meters
    z_step: Optional[float]
    timepoints: List[int]
    fields: List[int]
    planes: List[int]
    channel_ids: List[int]


class OperaPhenixReader:
    """
    Reader for Opera Phenix exported experiment data.
    
    Reads TIFF images and XML metadata from exported experiments,
    returning numpy arrays and metadata dictionaries.
    """
    
    def __init__(self, experiment_path: str):
        """
        Initialize reader with path to experiment directory.
        
        Parameters
        ----------
        experiment_path : str
            Path to the exported experiment directory
        """
        self.experiment_path = Path(experiment_path)
        self.images_path = self.experiment_path / "Images"
        self.index_xml_path = self.images_path / "Index.xml"
        
        if not self.images_path.exists():
            raise FileNotFoundError(f"Images directory not found: {self.images_path}")
        if not self.index_xml_path.exists():
            raise FileNotFoundError(f"Index.xml not found: {self.index_xml_path}")
        
        # Parse metadata
        self.tree = ET.parse(self.index_xml_path)
        self.root = self.tree.getroot()
        self.ns = {'ns': '43B2A954-E3C3-47E1-B392-6635266B0DD3/HarmonyV7'}
        
        self.metadata = self._parse_metadata()
        self.image_index = self._build_image_index()
    
    def _parse_metadata(self) -> PhenixMetadata:
        """Parse metadata from Index.xml"""
        # Parse plate information
        plate = self.root.find('.//ns:Plate', self.ns)
        plate_id = plate.find('ns:PlateID', self.ns).text
        plate_rows = int(plate.find('ns:PlateRows', self.ns).text)
        plate_columns = int(plate.find('ns:PlateColumns', self.ns).text)
        
        # Parse wells
        wells = [well.attrib['id'] for well in plate.findall('ns:Well', self.ns)]
        
        # Parse channel information
        channels = {}
        image_size_x = None
        image_size_y = None
        pixel_size_x = None
        pixel_size_y = None
        
        channel_maps = self.root.findall('.//ns:Maps/ns:Map', self.ns)
        for map_elem in channel_maps:
            entries = map_elem.findall('ns:Entry', self.ns)
            for entry in entries:
                # Check if this entry has channel metadata
                ch_name_elem = entry.find('ns:ChannelName', self.ns)
                if ch_name_elem is not None:
                    ch_id = int(entry.attrib['ChannelID'])
                    
                    # Extract channel information
                    channels[ch_id] = {
                        'name': ch_name_elem.text,
                        'excitation': entry.find('ns:MainExcitationWavelength', self.ns).text,
                        'emission': entry.find('ns:MainEmissionWavelength', self.ns).text,
                        'exposure': entry.find('ns:ExposureTime', self.ns).text,
                        'objective_mag': entry.find('ns:ObjectiveMagnification', self.ns).text,
                        'objective_na': entry.find('ns:ObjectiveNA', self.ns).text,
                    }
                    
                    # Get image dimensions and pixel size from first channel with this info
                    if image_size_x is None:
                        img_size_x_elem = entry.find('ns:ImageSizeX', self.ns)
                        img_size_y_elem = entry.find('ns:ImageSizeY', self.ns)
                        pix_size_x_elem = entry.find('ns:ImageResolutionX', self.ns)
                        pix_size_y_elem = entry.find('ns:ImageResolutionY', self.ns)
                        
                        if all([img_size_x_elem is not None, 
                               img_size_y_elem is not None,
                               pix_size_x_elem is not None,
                               pix_size_y_elem is not None]):
                            image_size_x = int(img_size_x_elem.text)
                            image_size_y = int(img_size_y_elem.text)
                            pixel_size_x = float(pix_size_x_elem.text)
                            pixel_size_y = float(pix_size_y_elem.text)
        
        # Fallback: if we didn't find image dimensions, check if we can get from first image
        if image_size_x is None:
            first_image = self.root.find('.//ns:Images/ns:Image', self.ns)
            if first_image is not None:
                # Will load one image to get dimensions
                url = first_image.find('ns:URL', self.ns).text
                img_path = self.images_path / url
                if img_path.exists():
                    from PIL import Image as PILImage
                    with PILImage.open(img_path) as img:
                        image_size_x = img.width
                        image_size_y = img.height
                    # Still need pixel size - use default if not found
                    pixel_size_x = pixel_size_x or 2.96688132474701E-07
                    pixel_size_y = pixel_size_y or 2.96688132474701E-07
        
        # Parse all images to determine dimensions
        images = self.root.findall('.//ns:Images/ns:Image', self.ns)
        timepoints = set()
        fields = set()
        planes = set()
        channel_ids = set()
        z_positions = set()
        
        for img in images:
            timepoints.add(int(img.find('ns:TimepointID', self.ns).text))
            fields.add(int(img.find('ns:FieldID', self.ns).text))
            planes.add(int(img.find('ns:PlaneID', self.ns).text))
            channel_ids.add(int(img.find('ns:ChannelID', self.ns).text))
            z_pos = float(img.find('ns:PositionZ', self.ns).text)
            z_positions.add(z_pos)
        
        # Calculate Z step
        z_step = None
        if len(z_positions) > 1:
            z_sorted = sorted(z_positions)
            z_step = abs(z_sorted[1] - z_sorted[0])
        
        return PhenixMetadata(
            plate_id=plate_id,
            plate_rows=plate_rows,
            plate_columns=plate_columns,
            wells=wells,
            channels=channels,
            image_size=(image_size_y, image_size_x),
            pixel_size=(pixel_size_y, pixel_size_x),
            z_step=z_step,
            timepoints=sorted(timepoints),
            fields=sorted(fields),
            planes=sorted(planes),
            channel_ids=sorted(channel_ids)
        )
    
    def _build_image_index(self) -> Dict:
        """Build index of all images for fast lookup"""
        index = {}
        images = self.root.findall('.//ns:Images/ns:Image', self.ns)
        
        for img in images:
            row = int(img.find('ns:Row', self.ns).text)
            col = int(img.find('ns:Col', self.ns).text)
            field = int(img.find('ns:FieldID', self.ns).text)
            plane = int(img.find('ns:PlaneID', self.ns).text)
            timepoint = int(img.find('ns:TimepointID', self.ns).text)
            channel = int(img.find('ns:ChannelID', self.ns).text)
            url = img.find('ns:URL', self.ns).text
            
            pos_x = float(img.find('ns:PositionX', self.ns).text)
            pos_y = float(img.find('ns:PositionY', self.ns).text)
            pos_z = float(img.find('ns:PositionZ', self.ns).text)
            
            key = (row, col, field, plane, timepoint, channel)
            index[key] = {
                'url': url,
                'position': (pos_x, pos_y, pos_z)
            }
        
        return index
    
    def read_data(self,
                  row: Optional[int] = None,
                  column: Optional[int] = None,
                  field: Optional[Union[int, List[int]]] = None,
                  stitch_fields: bool = False,
                  timepoints: Optional[Union[int, List[int]]] = None,
                  channels: Optional[Union[int, List[int]]] = None,
                  z_slices: Optional[Union[int, List[int]]] = None,
                  output_file: Optional[str] = None,
                  output_format: Optional[str] = None) -> Tuple[np.ndarray, Dict]:
        """
        Read image data from Opera Phenix experiment.
        
        Parameters
        ----------
        row : int, optional
            Well row (default: first available)
        column : int, optional
            Well column (default: first available)
        field : int or list of int, optional
            Field(s) to read (default: all if stitching, else first)
        stitch_fields : bool, default False
            Whether to stitch multiple fields together
        timepoints : int or list of int, optional
            Timepoint(s) to read (default: all)
        channels : int or list of int, optional
            Channel(s) to read (default: all)
        z_slices : int or list of int, optional
            Z plane(s) to read (default: all)
        output_file : str, optional
            Path to save output file
        output_format : str, optional
            Format for output file: 'ome-tiff', 'numpy', or 'parquet'
        
        Returns
        -------
        data : np.ndarray
            Image data array with dimensions (T, C, Z, Y, X) or subset
        metadata : dict
            Dictionary containing metadata
        """
        # Set defaults
        if row is None:
            row = min([int(w[:2]) for w in self.metadata.wells])
        if column is None:
            column = min([int(w[2:]) for w in self.metadata.wells])
        
        if field is None:
            if stitch_fields:
                field = self.metadata.fields
            else:
                field = [self.metadata.fields[0]]
        elif isinstance(field, int):
            field = [field]
        
        if timepoints is None:
            timepoints = self.metadata.timepoints
        elif isinstance(timepoints, int):
            timepoints = [timepoints]
        
        if channels is None:
            channels = self.metadata.channel_ids
        elif isinstance(channels, int):
            channels = [channels]
        
        if z_slices is None:
            z_slices = self.metadata.planes
        elif isinstance(z_slices, int):
            z_slices = [z_slices]
        
        # Read images
        if stitch_fields:
            data = self._read_and_stitch(row, column, field, timepoints, 
                                        channels, z_slices)
        else:
            data = self._read_images(row, column, field, timepoints,
                                   channels, z_slices)
        
        # Prepare metadata dictionary
        metadata_dict = self._prepare_metadata_dict(
            row, column, field, timepoints, channels, z_slices, 
            stitch_fields, data.shape
        )
        
        # Print metadata
        self._print_metadata(metadata_dict)
        
        # Save output if requested
        if output_file is not None and output_format is not None:
            self._save_output(data, metadata_dict, output_file, output_format)
        
        return data, metadata_dict
    
    def _read_images(self, row: int, col: int, fields: List[int],
                    timepoints: List[int], channels: List[int],
                    z_slices: List[int]) -> np.ndarray:
        """Read images without stitching"""
        # Determine output shape
        n_fields = len(fields)
        n_time = len(timepoints)
        n_channels = len(channels)
        n_z = len(z_slices)
        img_h, img_w = self.metadata.image_size
        
        # Initialize array
        data = np.zeros((n_fields, n_time, n_channels, n_z, img_h, img_w),
                       dtype=np.uint16)
        
        # Read images
        for f_idx, field in enumerate(fields):
            for t_idx, timepoint in enumerate(timepoints):
                for c_idx, channel in enumerate(channels):
                    for z_idx, z_slice in enumerate(z_slices):
                        key = (row, col, field, z_slice, timepoint, channel)
                        if key in self.image_index:
                            img_path = self.images_path / self.image_index[key]['url']
                            img = Image.open(img_path)
                            data[f_idx, t_idx, c_idx, z_idx] = np.array(img)
        
        # Squeeze if single field
        if n_fields == 1:
            data = data.squeeze(axis=0)
        
        return data
    
    def _read_and_stitch(self, row: int, col: int, fields: List[int],
                        timepoints: List[int], channels: List[int],
                        z_slices: List[int]) -> np.ndarray:
        """Read and stitch multiple fields"""
        # Get field positions
        field_positions = {}
        for field in fields:
            key = (row, col, field, z_slices[0], timepoints[0], channels[0])
            if key in self.image_index:
                pos = self.image_index[key]['position']
                field_positions[field] = (pos[0], pos[1])
        
        # Calculate stitched dimensions
        img_h, img_w = self.metadata.image_size
        pixel_size = self.metadata.pixel_size[0]  # assume square pixels
        
        positions_x = [pos[0] for pos in field_positions.values()]
        positions_y = [pos[1] for pos in field_positions.values()]
        
        min_x, max_x = min(positions_x), max(positions_x)
        min_y, max_y = min(positions_y), max(positions_y)
        
        stitched_w = int((max_x - min_x) / pixel_size) + img_w
        stitched_h = int((max_y - min_y) / pixel_size) + img_h
        
        # Initialize stitched array
        n_time = len(timepoints)
        n_channels = len(channels)
        n_z = len(z_slices)
        
        data = np.zeros((n_time, n_channels, n_z, stitched_h, stitched_w),
                       dtype=np.uint16)
        
        # Stitch images
        for t_idx, timepoint in enumerate(timepoints):
            for c_idx, channel in enumerate(channels):
                for z_idx, z_slice in enumerate(z_slices):
                    for field in fields:
                        key = (row, col, field, z_slice, timepoint, channel)
                        if key in self.image_index:
                            img_path = self.images_path / self.image_index[key]['url']
                            img = np.array(Image.open(img_path))
                            
                            # Calculate position in stitched image
                            pos = field_positions[field]
                            x_offset = int((pos[0] - min_x) / pixel_size)
                            y_offset = int((pos[1] - min_y) / pixel_size)
                            
                            data[t_idx, c_idx, z_idx,
                                y_offset:y_offset+img_h,
                                x_offset:x_offset+img_w] = img
        
        return data
    
    def _prepare_metadata_dict(self, row: int, col: int, fields: List[int],
                               timepoints: List[int], channels: List[int],
                               z_slices: List[int], stitched: bool,
                               shape: Tuple) -> Dict:
        """Prepare metadata dictionary for output"""
        channel_info = {ch: self.metadata.channels[ch] for ch in channels}
        
        metadata = {
            'plate_id': self.metadata.plate_id,
            'plate_layout': {
                'rows': self.metadata.plate_rows,
                'columns': self.metadata.plate_columns
            },
            'well': f"r{row:02d}c{col:02d}",
            'shape': {
                'description': 'T, C, Z, Y, X' if not stitched else 'T, C, Z, Y, X (stitched)',
                'dimensions': shape
            },
            'fields': fields,
            'timepoints': timepoints,
            'channels': channel_info,
            'z_slices': z_slices,
            'pixel_size': {
                'x': self.metadata.pixel_size[1],
                'y': self.metadata.pixel_size[0],
                'unit': 'm'
            },
            'z_step': self.metadata.z_step,
            'stitched': stitched
        }
        
        return metadata
    
    def _print_metadata(self, metadata: Dict):
        """Print metadata to console"""
        print("\n" + "="*60)
        print("OPERA PHENIX DATA SUMMARY")
        print("="*60)
        
        print(f"\nPlate ID: {metadata['plate_id']}")
        print(f"Well: {metadata['well']}")
        
        print(f"\nPlate Layout:")
        print(f"  Rows: {metadata['plate_layout']['rows']}")
        print(f"  Columns: {metadata['plate_layout']['columns']}")
        
        print(f"\nData Shape: {metadata['shape']['dimensions']}")
        print(f"  Dimension order: {metadata['shape']['description']}")
        
        print(f"\nChannels:")
        for ch_id, ch_info in metadata['channels'].items():
            print(f"  Channel {ch_id}: {ch_info['name']}")
            print(f"    Excitation: {ch_info['excitation']} nm")
            print(f"    Emission: {ch_info['emission']} nm")
            print(f"    Exposure: {ch_info['exposure']} s")
        
        print(f"\nFields: {metadata['fields']}")
        print(f"Timepoints: {metadata['timepoints']}")
        print(f"Z-slices: {metadata['z_slices']}")
        
        print(f"\nPhysical Dimensions:")
        print(f"  Pixel size (X): {metadata['pixel_size']['x']*1e6:.3f} µm")
        print(f"  Pixel size (Y): {metadata['pixel_size']['y']*1e6:.3f} µm")
        if metadata['z_step'] is not None:
            print(f"  Z step: {metadata['z_step']*1e6:.3f} µm")
        
        if metadata['stitched']:
            print(f"\n*** Fields have been STITCHED ***")
        
        print("="*60 + "\n")
    
    def _save_output(self, data: np.ndarray, metadata: Dict,
                    output_file: str, output_format: str):
        """Save data and metadata to file"""
        output_path = Path(output_file)
        
        if output_format == 'numpy':
            np.save(output_path, data)
            metadata_path = output_path.with_suffix('.json')
            with open(metadata_path, 'w') as f:
                json.dump(metadata, f, indent=2, default=str)
            print(f"Saved numpy array to: {output_path}")
            print(f"Saved metadata to: {metadata_path}")
        
        elif output_format == 'ome-tiff':
            try:
                import tifffile
                tifffile.imwrite(output_path, data, photometric='minisblack')
                metadata_path = output_path.with_suffix('.json')
                with open(metadata_path, 'w') as f:
                    json.dump(metadata, f, indent=2, default=str)
                print(f"Saved OME-TIFF to: {output_path}")
                print(f"Saved metadata to: {metadata_path}")
            except ImportError:
                print("Warning: tifffile not available. Falling back to numpy format.")
                self._save_output(data, metadata, str(output_path.with_suffix('.npy')), 'numpy')
        
        elif output_format == 'parquet':
            print("Warning: Parquet format not implemented for image data.")
            print("Saving as numpy instead.")
            self._save_output(data, metadata, str(output_path.with_suffix('.npy')), 'numpy')


def load_phenix_data(experiment_path: str, **kwargs) -> Tuple[np.ndarray, Dict]:
    """
    Convenience function to load Opera Phenix data.
    
    Parameters
    ----------
    experiment_path : str
        Path to experiment directory
    **kwargs
        Additional arguments passed to OperaPhenixReader.read_data()
    
    Returns
    -------
    data : np.ndarray
        Image data
    metadata : dict
        Metadata dictionary
    """
    reader = OperaPhenixReader(experiment_path)
    return reader.read_data(**kwargs)
#+END_SRC

#+RESULTS:
:results:
:end:

#+BEGIN_SRC python :tangle no
import xml.etree.ElementTree as ET
import numpy as np
from pathlib import Path
from typing import Dict, Any, List, Optional
import tifffile
import re


def load_phenix_image_stack_with_metadata(base_path: str, 
                                         well_id: Optional[str] = None,
                                         channel_id: Optional[int] = None,
                                         field_id: Optional[int] = None) -> tuple:
    """
    Load PerkinElmer Phenix image stack and extract metadata from index.xml.
    
    Parameters
    ----------
    base_path : str
        Path to the dataset directory containing the Images folder
    well_id : str, optional
        Specific well to load (e.g., '0202'). If None, loads all wells.
    channel_id : int, optional
        Specific channel to load (1, 2, 3, etc.). If None, loads all channels.
    field_id : int, optional
        Specific field to load. If None, loads all fields.
        
    Returns
    -------
    tuple
        (image_stack, metadata_dict)
    """
    base_path = Path(base_path)
    index_xml_path = base_path / "Images" / "Index.xml"
    
    if not index_xml_path.exists():
        raise FileNotFoundError(f"Index.xml not found at {index_xml_path}")
    
    # Parse the XML file with namespace handling
    tree = ET.parse(index_xml_path)
    root = tree.getroot()
    
    # Extract namespace
    namespace = root.tag.split('}')[0].strip('{') if '}' in root.tag else ''
    ns = {'ns': namespace} if namespace else {}
    
    # Extract metadata
    metadata = extract_phenix_metadata(root, ns)
    
    # Get filtered image list
    image_info = get_phenix_image_list(
        root, ns, well_id, channel_id, field_id
    )
    
    # Load Images
    image_stack = load_phenix_Images(
        image_info, base_path / "Images"
    )
    
    # Add image-specific metadata
    metadata['image_info'] = image_info
    metadata['filters_applied'] = {
        'well_id': well_id,
        'channel_id': channel_id,
        'field_id': field_id
    }
    
    return image_stack, metadata


def extract_phenix_metadata(root: ET.Element, ns: Dict[str, str]) -> Dict[str, Any]:
    """
    Extract metadata from PerkinElmer Phenix index.xml.
    
    Parameters
    ----------
    root : ET.Element
        Root element of the parsed index.xml
    ns : dict
        Namespace dictionary for XML parsing
        
    Returns
    -------
    dict
        Dictionary containing all extracted metadata
    """
    metadata = {}
    
    # Helper function to find elements with namespace
    def find_elem(parent, tag):
        if ns:
            return parent.find(f"ns:{tag}", ns)
        return parent.find(tag)
    
    def find_all(parent, tag):
        if ns:
            return parent.findall(f"ns:{tag}", ns)
        return parent.findall(tag)
    
    # Extract plate information
    plate = find_elem(root, "Plates")
    if plate is not None:
        plate_elem = find_elem(plate, "Plate")
        if plate_elem is not None:
            metadata['plate_id'] = get_elem_text(find_elem(plate_elem, "PlateID"))
            metadata['plate_name'] = get_elem_text(find_elem(plate_elem, "Name"))
            metadata['plate_type'] = get_elem_text(find_elem(plate_elem, "PlateTypeName"))
            metadata['plate_rows'] = get_elem_text(find_elem(plate_elem, "PlateRows"), int)
            metadata['plate_columns'] = get_elem_text(find_elem(plate_elem, "PlateColumns"), int)
            metadata['measurement_id'] = get_elem_text(find_elem(plate_elem, "MeasurementID"))
            metadata['measurement_start_time'] = get_elem_text(
                find_elem(plate_elem, "MeasurementStartTime")
            )
    
    # Extract user and instrument info
    metadata['user'] = get_elem_text(find_elem(root, "User"))
    metadata['instrument_type'] = get_elem_text(find_elem(root, "InstrumentType"))
    
    # Extract channel information from Maps
    channels = {}
    maps = find_elem(root, "Maps")
    if maps is not None:
        for map_elem in find_all(maps, "Map"):
            for entry in find_all(map_elem, "Entry"):
                channel_id = entry.get('ChannelID')
                if channel_id and find_elem(entry, "ChannelName") is not None:
                    channel_info = extract_channel_info(entry, ns)
                    if channel_info:
                        channels[int(channel_id)] = channel_info
    
    metadata['channels'] = channels
    
    # Extract well list
    wells = find_elem(root, "Wells")
    if wells is not None:
        well_list = []
        for well in find_all(wells, "Well"):
            well_id = get_elem_text(find_elem(well, "id"))
            row = get_elem_text(find_elem(well, "Row"), int)
            col = get_elem_text(find_elem(well, "Col"), int)
            if well_id:
                well_list.append({
                    'id': well_id,
                    'row': row,
                    'col': col
                })
        metadata['wells'] = well_list
    
    return metadata


def extract_channel_info(entry: ET.Element, ns: Dict[str, str]) -> Dict[str, Any]:
    """
    Extract channel-specific metadata from a Map Entry element.
    
    Parameters
    ----------
    entry : ET.Element
        Entry element containing channel information
    ns : dict
        Namespace dictionary
        
    Returns
    -------
    dict
        Channel metadata
    """
    def find_elem(parent, tag):
        if ns:
            return parent.find(f"ns:{tag}", ns)
        return parent.find(tag)
    
    channel_info = {}
    
    # Basic channel info
    channel_info['name'] = get_elem_text(find_elem(entry, "ChannelName"))
    channel_info['image_type'] = get_elem_text(find_elem(entry, "ImageType"))
    channel_info['channel_type'] = get_elem_text(find_elem(entry, "ChannelType"))
    
    # Resolution and dimensions
    channel_info['resolution_x'] = get_elem_text(
        find_elem(entry, "ImageResolutionX"), float
    )
    channel_info['resolution_y'] = get_elem_text(
        find_elem(entry, "ImageResolutionY"), float
    )
    channel_info['size_x'] = get_elem_text(find_elem(entry, "ImageSizeX"), int)
    channel_info['size_y'] = get_elem_text(find_elem(entry, "ImageSizeY"), int)
    
    # Binning
    channel_info['binning_x'] = get_elem_text(find_elem(entry, "BinningX"), int)
    channel_info['binning_y'] = get_elem_text(find_elem(entry, "BinningY"), int)
    
    # Wavelengths
    channel_info['excitation_wavelength'] = get_elem_text(
        find_elem(entry, "MainExcitationWavelength"), float
    )
    channel_info['emission_wavelength'] = get_elem_text(
        find_elem(entry, "MainEmissionWavelength"), float
    )
    
    # Acquisition parameters
    channel_info['exposure_time'] = get_elem_text(
        find_elem(entry, "ExposureTime"), float
    )
    channel_info['excitation_power'] = get_elem_text(
        find_elem(entry, "ExcitationPower"), float
    )
    
    # Objective info
    channel_info['objective_magnification'] = get_elem_text(
        find_elem(entry, "ObjectiveMagnification"), float
    )
    channel_info['objective_na'] = get_elem_text(
        find_elem(entry, "ObjectiveNA"), float
    )
    
    # Camera
    channel_info['camera_type'] = get_elem_text(find_elem(entry, "CameraType"))
    channel_info['max_intensity'] = get_elem_text(
        find_elem(entry, "MaxIntensity"), int
    )
    
    return channel_info


def get_phenix_image_list(root: ET.Element, 
                         ns: Dict[str, str],
                         well_id: Optional[str] = None,
                         channel_id: Optional[int] = None,
                         field_id: Optional[int] = None) -> List[Dict[str, Any]]:
    """
    Extract and filter image list from Phenix index.xml.
    
    Parameters
    ----------
    root : ET.Element
        Root element of the parsed index.xml
    ns : dict
        Namespace dictionary
    well_id : str, optional
        Filter by well ID
    channel_id : int, optional
        Filter by channel ID
    field_id : int, optional
        Filter by field ID
        
    Returns
    -------
    list
        List of dictionaries containing image information
    """
    def find_elem(parent, tag):
        if ns:
            return parent.find(f"ns:{tag}", ns)
        return parent.find(tag)
    
    def find_all(parent, tag):
        if ns:
            return parent.findall(f"ns:{tag}", ns)
        return parent.findall(tag)
    
    Images_elem = find_elem(root, "Images")
    if Images_elem is None:
        return []
    
    image_list = []
    
    for image in find_all(Images_elem, "Image"):
        img_info = {}
        
        # Extract all relevant fields
        img_info['id'] = get_elem_text(find_elem(image, "id"))
        img_info['url'] = get_elem_text(find_elem(image, "URL"))
        img_info['state'] = get_elem_text(find_elem(image, "State"))
        img_info['row'] = get_elem_text(find_elem(image, "Row"), int)
        img_info['col'] = get_elem_text(find_elem(image, "Col"), int)
        img_info['field_id'] = get_elem_text(find_elem(image, "FieldID"), int)
        img_info['plane_id'] = get_elem_text(find_elem(image, "PlaneID"), int)
        img_info['channel_id'] = get_elem_text(find_elem(image, "ChannelID"), int)
        img_info['timepoint_id'] = get_elem_text(find_elem(image, "TimepointID"), int)
        
        # Position information
        img_info['position_x'] = get_elem_text(find_elem(image, "PositionX"), float)
        img_info['position_y'] = get_elem_text(find_elem(image, "PositionY"), float)
        img_info['position_z'] = get_elem_text(find_elem(image, "PositionZ"), float)
        img_info['abs_position_z'] = get_elem_text(
            find_elem(image, "AbsPositionZ"), float
        )
        
        # Time information
        img_info['measurement_time_offset'] = get_elem_text(
            find_elem(image, "MeasurementTimeOffset"), float
        )
        img_info['abs_time'] = get_elem_text(find_elem(image, "AbsTime"))
        
        # Construct well_id from row and col
        if img_info['row'] is not None and img_info['col'] is not None:
            img_info['well_id'] = f"{img_info['row']:02d}{img_info['col']:02d}"
        
        # Apply filters
        if well_id and img_info.get('well_id') != well_id:
            continue
        if channel_id and img_info.get('channel_id') != channel_id:
            continue
        if field_id and img_info.get('field_id') != field_id:
            continue
        
        image_list.append(img_info)
    
    # Sort by well, field, channel, timepoint for consistent ordering
    image_list.sort(
        key=lambda x: (
            x.get('well_id', ''),
            x.get('field_id', 0),
            x.get('channel_id', 0),
            x.get('timepoint_id', 0)
        )
    )
    
    return image_list


def load_phenix_Images(image_info: List[Dict[str, Any]], 
                       Images_dir: Path) -> np.ndarray:
    """
    Load Phenix Images based on image info list.
    
    Parameters
    ----------
    image_info : list
        List of dictionaries containing image information
    Images_dir : Path
        Path to the Images directory
        
    Returns
    -------
    np.ndarray
        Image stack as numpy array
    """
    if not image_info:
        raise ValueError("No Images to load with current filters")
    
    Images = []
    
    for info in image_info:
        url = info.get('url')
        if not url:
            continue
        
        img_path = Images_dir / url
        
        if not img_path.exists():
            print(f"Warning: Image file not found: {img_path}")
            continue
        
        try:
            img = tifffile.imread(img_path)
            Images.append(img)
        except Exception as e:
            print(f"Warning: Failed to load {img_path}: {e}")
            continue
    
    if not Images:
        raise ValueError("No Images were successfully loaded")
    
    return np.stack(Images, axis=0)


def get_elem_text(elem: Optional[ET.Element], 
                 convert_type: Optional[type] = None) -> Any:
    """
    Helper function to safely extract text from XML element.
    
    Parameters
    ----------
    elem : ET.Element or None
        XML element
    convert_type : type, optional
        Type to convert the text to (int, float, etc.)
        
    Returns
    -------
    Any
        Extracted and optionally converted value
    """
    if elem is None:
        return None
    
    text = elem.text
    if text is None:
        return None
    
    text = text.strip()
    
    if convert_type is not None:
        try:
            return convert_type(text)
        except (ValueError, TypeError):
            return None
    
    return text
#+END_SRC

#+RESULTS:
:results:
:end:


*** simple napari visualization

#+BEGIN_SRC python
import napari
import numpy as np


def visualize_in_napari(data, metadata, time_index=0):
    """
    Visualize Opera Phenix image data in Napari with proper channel names, colors, and scaling.
    
    Parameters
    ----------
    data : np.ndarray
        5D numpy array with shape (T, C, Z, Y, X) from load_phenix_data
    metadata : dict
        Dictionary containing metadata from load_phenix_data
    time_index : int, optional
        Which timepoint to display (default: 0)
        
    Returns
    -------
    viewer : napari.Viewer
        Napari viewer instance
    """
    if data is None:
        raise ValueError("Cannot visualize: data is None (metadata_only mode?)")
    
    # Extract metadata
    well = metadata['well']
    channels_info = metadata['channels']
    pixel_size_x = metadata['pixel_size']['x']  # in meters
    pixel_size_y = metadata['pixel_size']['y']  # in meters
    z_step = metadata['z_step']  # in meters, can be None
    stitched = metadata['stitched']
    fields = metadata['fields']
    
    # Convert physical scales to micrometers for Napari
    scale_x = pixel_size_x * 1e6  # meters to micrometers
    scale_y = pixel_size_y * 1e6
    scale_z = z_step * 1e6 if z_step is not None else 1.0
    
    # Validate time_index
    if time_index >= data.shape[0]:
        print(f"Warning: time_index {time_index} exceeds available timepoints ({data.shape[0]})")
        print(f"Using time_index=0 instead")
        time_index = 0
    
    # Select timepoint to view
    data_to_view = data[time_index]  # Shape: (C, Z, Y, X)
    
    # Create viewer title
    if stitched:
        title = f"{metadata['plate_id']} - {well} - Stitched ({len(fields)} fields)"
    else:
        title = f"{metadata['plate_id']} - {well} - Field {fields[0]}"
    
    viewer = napari.Viewer(title=title)
    viewer.scale_bar.visible = True
    viewer.scale_bar.unit = "µm"
    
    # Define colors for common fluorophores
    color_map = {
        'DAPI': 'blue',
        'Hoechst': 'blue',
        'Alexa 488': 'green',
        'GFP': 'green',
        'Alexa 555': 'yellow',
        'mCherry': 'red',
        'Alexa 647': 'magenta',
        'Cy5': 'magenta',
    }
    
    default_colors = ['cyan', 'magenta', 'yellow', 'green', 'red', 'blue']
    
    # Add each channel to the viewer
    for ch_idx, (ch_id, ch_info) in enumerate(channels_info.items()):
        ch_name = ch_info['name']
        
        # Select color based on channel name
        color = None
        for key, value in color_map.items():
            if key.lower() in ch_name.lower():
                color = value
                break
        if color is None:
            color = default_colors[ch_idx % len(default_colors)]
        
        # Get channel data
        channel_data = data_to_view[ch_idx]  # Shape: (Z, Y, X)
        
        # Add to viewer
        viewer.add_image(
            channel_data,
            name=f"Ch{ch_id}: {ch_name}",
            colormap=color,
            blending='additive',
            scale=(scale_z, scale_y, scale_x),
            contrast_limits=[0, np.percentile(channel_data[channel_data > 0], 99.5)] if channel_data.max() > 0 else [0, 1]
        )
    
    # Print summary
    print("\n" + "="*60)
    print("🚀 NAPARI VIEWER LAUNCHED")
    print("="*60)
    print(f"Viewing: {title}")
    print(f"Timepoint: {time_index + 1} / {data.shape[0]}")
    print(f"Channels: {len(channels_info)}")
    print(f"Z-slices: {data.shape[2]}")
    print(f"Image size: {data.shape[3]} × {data.shape[4]} pixels")
    print(f"Pixel size: {scale_x:.3f} × {scale_y:.3f} µm")
    if z_step is not None:
        print(f"Z-step: {scale_z:.3f} µm")
    print("="*60 + "\n")
    
    return viewer
#+END_SRC

#+RESULTS:
:results:
:end:

#+begin_src python :tangle no

def visualize_in_napari(image_stack, metadata, time_index=0):
    """
    Visualize the image stack in Napari with proper channel names, colors, and scaling.
    
    Parameters
    ----------
    image_stack : np.ndarray
        5D numpy array with shape (T, C, Z, Y, X)
    metadata : dict
        Dictionary containing metadata from load_image_stack_with_metadata
    time_index : int, optional
        Which timepoint to display (default: 0)
        
    Returns
    -------
    viewer : napari.Viewer
        Napari viewer instance
    """
    # Extract metadata
    channel_names = metadata['channel_names']
    scale_x = metadata['scale_x']
    scale_y = metadata['scale_y']
    scale_z = metadata['scale_z']
    num_channels = metadata['num_channels']
    target_row = metadata['target_row']
    target_col = metadata['target_col']
    target_field = metadata['target_field']
    
    # Select timepoint to view
    data_to_view = image_stack[time_index]
    
    # Create viewer
    viewer = napari.Viewer(title=f"R{target_row} C{target_col} F{target_field}")
    viewer.scale_bar.visible = True
    
    # Define colors for channels
    colors = ['cyan', 'gray', 'green', 'magenta', 'yellow']
    
    # Add each channel to the viewer
    for i in range(num_channels):
        ch_name = channel_names[i] if i < len(channel_names) else f"Channel {i + 1}"
        viewer.add_image(
            data_to_view[i],
            name=ch_name,
            colormap=colors[i % len(colors)],
            blending='additive',
            scale=(scale_z, scale_y, scale_x)
        )
    
    print("\n🚀 Napari viewer is running!")
    
    return viewer
#+end_src
*** interactive napari data loading and visualization


#+BEGIN_SRC python
import napari
from napari.utils import notifications
import numpy as np
from pathlib import Path
from magicgui import magicgui
from typing import List
from qtpy.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, 
                            QComboBox, QListWidget, QLabel, QCheckBox,
                            QGroupBox, QAbstractItemView, QLineEdit)


class PhenixDataLoaderWidget(QWidget):
    """
    Interactive widget for loading and visualizing Opera Phenix data in Napari.
    """
    
    def __init__(self, experiment_path: str, viewer: napari.Viewer = None):
        """
        Initialize the data loader widget.
        
        Parameters
        ----------
        experiment_path : str
            Path to the Opera Phenix experiment directory
        viewer : napari.Viewer, optional
            Napari viewer instance. If None, creates new viewer.
        """
        super().__init__()
        
        self.experiment_path = experiment_path
        self.viewer = viewer if viewer is not None else napari.Viewer()
        
        # Initialize reader
        self.reader = OperaPhenixReader(experiment_path)
        self.metadata = self.reader.metadata
        
        # Build the widget UI
        self._build_ui()
        
        # Set default selections
        self._set_defaults()
        
        print("\n" + "="*60)
        print("🎛️  INTERACTIVE DATA LOADER WIDGET INITIALIZED")
        print("="*60)
        print(f"Experiment: {Path(experiment_path).name}")
        print(f"Wells available: {len(self.metadata.wells)}")
        print(f"Channels: {len(self.metadata.channels)}")
        print("="*60 + "\n")
    
    def _build_ui(self):
        """Build the user interface."""
        layout = QVBoxLayout()
        
        # Title
        title = QLabel(f"<h2>Opera Phenix Data Loader</h2>")
        layout.addWidget(title)
        
        # Experiment info
        exp_name = Path(self.experiment_path).name
        exp_label = QLabel(f"<b>Experiment:</b> {exp_name}")
        layout.addWidget(exp_label)
        
        # Well selector
        well_group = QGroupBox("Well Selection")
        well_layout = QVBoxLayout()
        
        self.well_combo = QComboBox()
        self.well_combo.addItems([f"r{w[:2]}c{w[2:]}" for w in self.metadata.wells])
        self.well_combo.currentTextChanged.connect(self._on_well_changed)
        well_layout.addWidget(QLabel("Select Well:"))
        well_layout.addWidget(self.well_combo)
        
        well_group.setLayout(well_layout)
        layout.addWidget(well_group)
        
        # Field selector
        field_group = QGroupBox("Field Selection")
        field_layout = QVBoxLayout()
        
        self.stitch_checkbox = QCheckBox("Stitch all fields")
        self.stitch_checkbox.stateChanged.connect(self._on_stitch_changed)
        field_layout.addWidget(self.stitch_checkbox)
        
        field_layout.addWidget(QLabel("Select Field:"))
        self.field_combo = QComboBox()
        field_layout.addWidget(self.field_combo)
        
        field_group.setLayout(field_layout)
        layout.addWidget(field_group)
        
        # Timepoint selector
        time_group = QGroupBox("Timepoint Selection")
        time_layout = QVBoxLayout()
        
        time_buttons = QHBoxLayout()
        self.time_select_all_btn = QPushButton("Select All")
        self.time_select_all_btn.clicked.connect(lambda: self._select_all(self.time_list))
        self.time_clear_all_btn = QPushButton("Clear All")
        self.time_clear_all_btn.clicked.connect(lambda: self._clear_all(self.time_list))
        time_buttons.addWidget(self.time_select_all_btn)
        time_buttons.addWidget(self.time_clear_all_btn)
        time_layout.addLayout(time_buttons)
        
        self.time_list = QListWidget()
        self.time_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.time_list.addItems([f"Timepoint {t}" for t in self.metadata.timepoints])
        time_layout.addWidget(self.time_list)
        
        time_group.setLayout(time_layout)
        layout.addWidget(time_group)
        
        # Channel selector
        channel_group = QGroupBox("Channel Selection")
        channel_layout = QVBoxLayout()
        
        channel_buttons = QHBoxLayout()
        self.channel_select_all_btn = QPushButton("Select All")
        self.channel_select_all_btn.clicked.connect(lambda: self._select_all(self.channel_list))
        self.channel_clear_all_btn = QPushButton("Clear All")
        self.channel_clear_all_btn.clicked.connect(lambda: self._clear_all(self.channel_list))
        channel_buttons.addWidget(self.channel_select_all_btn)
        channel_buttons.addWidget(self.channel_clear_all_btn)
        channel_layout.addLayout(channel_buttons)
        
        self.channel_list = QListWidget()
        self.channel_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        for ch_id in self.metadata.channel_ids:
            ch_name = self.metadata.channels[ch_id]['name']
            self.channel_list.addItem(f"Ch{ch_id}: {ch_name}")
        channel_layout.addWidget(self.channel_list)
        
        channel_group.setLayout(channel_layout)
        layout.addWidget(channel_group)
        
        # Z-slice selector
        z_group = QGroupBox("Z-slice Selection")
        z_layout = QVBoxLayout()
        
        z_buttons = QHBoxLayout()
        self.z_select_all_btn = QPushButton("Select All")
        self.z_select_all_btn.clicked.connect(lambda: self._select_all(self.z_list))
        self.z_clear_all_btn = QPushButton("Clear All")
        self.z_clear_all_btn.clicked.connect(lambda: self._clear_all(self.z_list))
        z_buttons.addWidget(self.z_select_all_btn)
        z_buttons.addWidget(self.z_clear_all_btn)
        z_layout.addLayout(z_buttons)
        
        self.z_list = QListWidget()
        self.z_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.z_list.addItems([f"Z-plane {z}" for z in self.metadata.planes])
        z_layout.addWidget(self.z_list)
        
        z_group.setLayout(z_layout)
        layout.addWidget(z_group)
        
        # Add save options before visualize button
        save_group = QGroupBox("Save Options")
        save_layout = QVBoxLayout()
        
        self.save_checkbox = QCheckBox("Save loaded data")
        save_layout.addWidget(self.save_checkbox)
        
        save_path_layout = QHBoxLayout()
        self.save_path_input = QLineEdit()
        self.save_path_input.setPlaceholderText("Output file path...")
        self.save_browse_btn = QPushButton("Browse")
        self.save_browse_btn.clicked.connect(self._browse_save_path)
        save_path_layout.addWidget(self.save_path_input)
        save_path_layout.addWidget(self.save_browse_btn)
        save_layout.addLayout(save_path_layout)
        
        self.save_format_combo = QComboBox()
        self.save_format_combo.addItems(["numpy", "ome-tiff"])
        save_layout.addWidget(QLabel("Save format:"))
        save_layout.addWidget(self.save_format_combo)
        
        save_group.setLayout(save_layout)
        layout.addWidget(save_group)
        
        # Visualize button
        self.visualize_btn = QPushButton("Visualize Data")
        self.visualize_btn.setStyleSheet("""
            QPushButton {
                background-color: #4CAF50;
                color: white;
                font-size: 14px;
                font-weight: bold;
                padding: 10px;
                border-radius: 5px;
            }
            QPushButton:hover {
                background-color: #45a049;
            }
        """)
        self.visualize_btn.clicked.connect(self._visualize_data)
        layout.addWidget(self.visualize_btn)
        
        self.setLayout(layout)
    
    def _browse_save_path(self):
        """Open file dialog for save path."""
        from qtpy.QtWidgets import QFileDialog
        
        file_path, _ = QFileDialog.getSaveFileName(
            self,
            "Save Data",
            "",
            "Numpy files (*.npy);;TIFF files (*.tiff *.tif)"
        )
        
        if file_path:
            self.save_path_input.setText(file_path)
            
    def _set_defaults(self):
        """Set default selections."""
        # Select first well
        self.well_combo.setCurrentIndex(0)
        
        # Update field selector
        self._update_field_selector()
        
        # Select first timepoint
        self.time_list.item(0).setSelected(True)
        
        # Select all channels
        self._select_all(self.channel_list)
        
        # Select all Z-slices
        self._select_all(self.z_list)
    
    def _on_well_changed(self):
        """Handle well selection change."""
        self._update_field_selector()
    
    def _on_stitch_changed(self):
        """Handle stitch checkbox change."""
        self.field_combo.setEnabled(not self.stitch_checkbox.isChecked())
    
    def _update_field_selector(self):
        """Update field selector based on selected well."""
        well_str = self.well_combo.currentText()
        row = int(well_str[1:3])
        col = int(well_str[4:6])
        
        available_fields = self.reader.well_field_map.get((row, col), self.metadata.fields)
        
        self.field_combo.clear()
        self.field_combo.addItems([f"Field {f}" for f in available_fields])
    
    def _select_all(self, list_widget: QListWidget):
        """Select all items in a list widget."""
        for i in range(list_widget.count()):
            list_widget.item(i).setSelected(True)
    
    def _clear_all(self, list_widget: QListWidget):
        """Clear all selections in a list widget."""
        list_widget.clearSelection()
    
    def _get_selected_indices(self, list_widget: QListWidget) -> List[int]:
        """Get list of selected indices from a list widget."""
        return [i.row() for i in list_widget.selectedIndexes()]
    
    def _visualize_data(self):
        """Load, visualize, and optionally save selected data."""
        # Get selections
        well_str = self.well_combo.currentText()
        row = int(well_str[1:3])
        col = int(well_str[4:6])
        
        stitch = self.stitch_checkbox.isChecked()
        
        if not stitch:
            field_str = self.field_combo.currentText()
            field = int(field_str.split()[1])
        else:
            field = None
        
        time_indices = self._get_selected_indices(self.time_list)
        if not time_indices:
            notifications.show_warning("No timepoints selected")
            return
        timepoints = [self.metadata.timepoints[i] for i in time_indices]
        
        channel_indices = self._get_selected_indices(self.channel_list)
        if not channel_indices:
            notifications.show_warning("No channels selected")
            return
        channels = [self.metadata.channel_ids[i] for i in channel_indices]
        
        z_indices = self._get_selected_indices(self.z_list)
        if not z_indices:
            notifications.show_warning("No Z-slices selected")
            return
        z_slices = [self.metadata.planes[i] for i in z_indices]
        
        # Show loading message
        notifications.show_info(f"Loading data for well {well_str}...")
        
        try:
            # Determine save parameters
            save_file = None
            save_format = None
            
            if self.save_checkbox.isChecked():
                save_file = self.save_path_input.text()
                if save_file:
                    save_format = self.save_format_combo.currentText()
                else:
                    notifications.show_warning("Save enabled but no path specified")
            
            # Load data
            data, metadata = self.reader.read_data(
                row=row,
                column=col,
                field=field,
                stitch_fields=stitch,
                timepoints=timepoints,
                channels=channels,
                z_slices=z_slices,
                output_file=save_file,
                output_format=save_format
            )
            
            # Clear existing layers
            self.viewer.layers.clear()
            
            # Visualize data
            self._add_layers_to_viewer(data, metadata)
            
            notifications.show_info("Data loaded successfully!")
            
        except Exception as e:
            notifications.show_error(f"Error loading data: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def _add_layers_to_viewer(self, data, metadata):
        """Add data layers to the viewer."""
        # Extract metadata
        channels_info = metadata['channels']
        pixel_size_x = metadata['pixel_size']['x'] * 1e6  # to µm
        pixel_size_y = metadata['pixel_size']['y'] * 1e6
        z_step = metadata['z_step'] * 1e6 if metadata['z_step'] is not None else 1.0
        
        # Color mapping
        color_map = {
            'DAPI': 'blue',
            'Hoechst': 'blue',
            'Alexa 488': 'green',
            'GFP': 'green',
            'Alexa 555': 'yellow',
            'mCherry': 'red',
            'Alexa 647': 'magenta',
            'Cy5': 'magenta',
        }
        default_colors = ['cyan', 'magenta', 'yellow', 'green', 'red', 'blue']
        
        # Add each channel
        for ch_idx, (ch_id, ch_info) in enumerate(channels_info.items()):
            ch_name = ch_info['name']
            
            # Select color
            color = None
            for key, value in color_map.items():
                if key.lower() in ch_name.lower():
                    color = value
                    break
            if color is None:
                color = default_colors[ch_idx % len(default_colors)]
            
            # Get channel data
            if data.shape[0] > 1:  # Multiple timepoints
                channel_data = data[:, ch_idx, :, :, :]  # (T, Z, Y, X)
                scale = (1, z_step, pixel_size_y, pixel_size_x)
            else:  # Single timepoint
                channel_data = data[0, ch_idx, :, :, :]  # (Z, Y, X)
                scale = (z_step, pixel_size_y, pixel_size_x)
            
            # Calculate contrast limits
            nonzero_data = channel_data[channel_data > 0]
            if len(nonzero_data) > 0:
                contrast_limits = [0, np.percentile(nonzero_data, 99.5)]
            else:
                contrast_limits = [0, 1]
            
            # Add to viewer
            self.viewer.add_image(
                channel_data,
                name=f"Ch{ch_id}: {ch_name}",
                colormap=color,
                blending='additive',
                scale=scale,
                contrast_limits=contrast_limits
            )
        
        # Update viewer title
        well = metadata['well']
        if metadata['stitched']:
            title = f"{metadata['plate_id']} - {well} - Stitched"
        else:
            title = f"{metadata['plate_id']} - {well} - Field {metadata['fields'][0]}"
        self.viewer.title = title
        
        # Enable scale bar
        self.viewer.scale_bar.visible = True
        self.viewer.scale_bar.unit = "µm"
        
        # Reset view
        self.viewer.reset_view()


def create_phenix_data_loader(experiment_path: str) -> napari.Viewer:
    """
    Create an interactive Opera Phenix data loader widget in Napari.
    
    This function opens a Napari viewer with an interactive widget for selecting
    and visualizing different subsets of Opera Phenix imaging data.
    
    Parameters
    ----------
    experiment_path : str
        Path to the Opera Phenix experiment directory
        
    Returns
    -------
    viewer : napari.Viewer
        Napari viewer with the data loader widget docked
        
    Examples
    --------
    >>> viewer = create_phenix_data_loader('/path/to/experiment')
    """
    # Create viewer
    viewer = napari.Viewer()
    
    # Create and dock widget
    widget = PhenixDataLoaderWidget(experiment_path, viewer)
    viewer.window.add_dock_widget(widget, name="Data Loader", area="right")
    
    return viewer
#+END_SRC

#+RESULTS:
:results:
:end:

#+BEGIN_SRC python :tangle no
import napari
from napari.utils import notifications
import numpy as np
from pathlib import Path
from typing import List
from qtpy.QtWidgets import (QWidget, QVBoxLayout, QHBoxLayout, QPushButton, 
                            QComboBox, QListWidget, QLabel, QCheckBox,
                            QGroupBox, QAbstractItemView, QLineEdit)


class PhenixDataLoaderWidget(QWidget):
    """
    Interactive widget for loading and visualizing Opera Phenix data in Napari.
    """
    
    def __init__(self, experiment_path: str, viewer: napari.Viewer = None):
        """
        Initialize the data loader widget.
        
        Parameters
        ----------
        experiment_path : str
            Path to the Opera Phenix experiment directory
        viewer : napari.Viewer, optional
            Napari viewer instance. If None, creates new viewer.
        """
        super().__init__()
        
        self.experiment_path = experiment_path
        self.viewer = viewer if viewer is not None else napari.Viewer()
        
        # Initialize reader
        self.reader = OperaPhenixReader(experiment_path)
        self.metadata = self.reader.metadata
        
        # Build the widget UI
        self._build_ui()
        
        # Set default selections
        self._set_defaults()
        
        print("\n" + "="*60)
        print("🎛️  INTERACTIVE DATA LOADER WIDGET INITIALIZED")
        print("="*60)
        print(f"Experiment: {Path(experiment_path).name}")
        print(f"Wells available: {len(self.metadata.wells)}")
        print(f"Channels: {len(self.metadata.channels)}")
        print("="*60 + "\n")
    
    def _build_ui(self):
        """Build the user interface."""
        layout = QVBoxLayout()
        
        # Title
        title = QLabel(f"<h2>Opera Phenix Data Loader</h2>")
        layout.addWidget(title)
        
        # Experiment info
        exp_name = Path(self.experiment_path).name
        exp_label = QLabel(f"<b>Experiment:</b> {exp_name[:50]}...")
        layout.addWidget(exp_label)
        
        # Well selector
        well_group = QGroupBox("Well Selection")
        well_layout = QVBoxLayout()
        
        self.well_combo = QComboBox()
        self.well_combo.addItems([f"r{w[:2]}c{w[2:]}" for w in self.metadata.wells])
        self.well_combo.currentTextChanged.connect(self._on_well_changed)
        well_layout.addWidget(QLabel("Select Well:"))
        well_layout.addWidget(self.well_combo)
        
        well_group.setLayout(well_layout)
        layout.addWidget(well_group)
        
        # Field selector
        field_group = QGroupBox("Field Selection")
        field_layout = QVBoxLayout()
        
        self.stitch_checkbox = QCheckBox("Stitch all fields")
        self.stitch_checkbox.stateChanged.connect(self._on_stitch_changed)
        field_layout.addWidget(self.stitch_checkbox)
        
        field_layout.addWidget(QLabel("Select Field:"))
        self.field_combo = QComboBox()
        field_layout.addWidget(self.field_combo)
        
        field_group.setLayout(field_layout)
        layout.addWidget(field_group)
        
        # Timepoint selector
        time_group = QGroupBox("Timepoint Selection")
        time_layout = QVBoxLayout()
        
        time_buttons = QHBoxLayout()
        self.time_select_all_btn = QPushButton("Select All")
        self.time_select_all_btn.clicked.connect(lambda: self._select_all(self.time_list))
        self.time_clear_all_btn = QPushButton("Clear All")
        self.time_clear_all_btn.clicked.connect(lambda: self._clear_all(self.time_list))
        time_buttons.addWidget(self.time_select_all_btn)
        time_buttons.addWidget(self.time_clear_all_btn)
        time_layout.addLayout(time_buttons)
        
        self.time_list = QListWidget()
        self.time_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.time_list.addItems([f"Timepoint {t}" for t in self.metadata.timepoints])
        time_layout.addWidget(self.time_list)
        
        time_group.setLayout(time_layout)
        layout.addWidget(time_group)
        
        # Channel selector
        channel_group = QGroupBox("Channel Selection")
        channel_layout = QVBoxLayout()
        
        channel_buttons = QHBoxLayout()
        self.channel_select_all_btn = QPushButton("Select All")
        self.channel_select_all_btn.clicked.connect(lambda: self._select_all(self.channel_list))
        self.channel_clear_all_btn = QPushButton("Clear All")
        self.channel_clear_all_btn.clicked.connect(lambda: self._clear_all(self.channel_list))
        channel_buttons.addWidget(self.channel_select_all_btn)
        channel_buttons.addWidget(self.channel_clear_all_btn)
        channel_layout.addLayout(channel_buttons)
        
        self.channel_list = QListWidget()
        self.channel_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        for ch_id in self.metadata.channel_ids:
            ch_name = self.metadata.channels[ch_id]['name']
            self.channel_list.addItem(f"Ch{ch_id}: {ch_name}")
        channel_layout.addWidget(self.channel_list)
        
        channel_group.setLayout(channel_layout)
        layout.addWidget(channel_group)
        
        # Z-slice selector
        z_group = QGroupBox("Z-slice Selection")
        z_layout = QVBoxLayout()
        
        z_buttons = QHBoxLayout()
        self.z_select_all_btn = QPushButton("Select All")
        self.z_select_all_btn.clicked.connect(lambda: self._select_all(self.z_list))
        self.z_clear_all_btn = QPushButton("Clear All")
        self.z_clear_all_btn.clicked.connect(lambda: self._clear_all(self.z_list))
        z_buttons.addWidget(self.z_select_all_btn)
        z_buttons.addWidget(self.z_clear_all_btn)
        z_layout.addLayout(z_buttons)
        
        self.z_list = QListWidget()
        self.z_list.setSelectionMode(QAbstractItemView.ExtendedSelection)
        self.z_list.addItems([f"Z-plane {z}" for z in self.metadata.planes])
        z_layout.addWidget(self.z_list)
        
        z_group.setLayout(z_layout)
        layout.addWidget(z_group)
        
        # Visualize button
        self.visualize_btn = QPushButton("🚀 Visualize Data")
        self.visualize_btn.setStyleSheet("""
            QPushButton {
                background-color: #4CAF50;
                color: white;
                font-size: 14px;
                font-weight: bold;
                padding: 10px;
                border-radius: 5px;
            }
            QPushButton:hover {
                background-color: #45a049;
            }
        """)
        self.visualize_btn.clicked.connect(self._visualize_data)
        layout.addWidget(self.visualize_btn)
        
        self.setLayout(layout)
    
    def _set_defaults(self):
        """Set default selections."""
        # Select first well
        self.well_combo.setCurrentIndex(0)
        
        # Update field selector
        self._update_field_selector()
        
        # Select first timepoint
        if self.time_list.count() > 0:
            self.time_list.item(0).setSelected(True)
        
        # Select all channels
        self._select_all(self.channel_list)
        
        # Select all Z-slices
        self._select_all(self.z_list)
    
    def _on_well_changed(self):
        """Handle well selection change."""
        self._update_field_selector()
    
    def _on_stitch_changed(self):
        """Handle stitch checkbox change."""
        self.field_combo.setEnabled(not self.stitch_checkbox.isChecked())
    
    def _update_field_selector(self):
        """Update field selector based on selected well."""
        well_str = self.well_combo.currentText()
        row = int(well_str[1:3])
        col = int(well_str[4:6])
        
        available_fields = self.reader.well_field_map.get((row, col), self.metadata.fields)
        
        self.field_combo.clear()
        self.field_combo.addItems([f"Field {f}" for f in available_fields])
    
    def _select_all(self, list_widget: QListWidget):
        """Select all items in a list widget."""
        for i in range(list_widget.count()):
            list_widget.item(i).setSelected(True)
    
    def _clear_all(self, list_widget: QListWidget):
        """Clear all selections in a list widget."""
        list_widget.clearSelection()
    
    def _get_selected_indices(self, list_widget: QListWidget) -> List[int]:
        """Get list of selected indices from a list widget."""
        return [i.row() for i in list_widget.selectedIndexes()]
    
    def _visualize_data(self):
        """Load and visualize selected data."""
        # Get selections
        well_str = self.well_combo.currentText()
        row = int(well_str[1:3])
        col = int(well_str[4:6])
        
        stitch = self.stitch_checkbox.isChecked()
        
        if not stitch:
            field_str = self.field_combo.currentText()
            field = int(field_str.split()[1])
        else:
            field = None
        
        time_indices = self._get_selected_indices(self.time_list)
        if not time_indices:
            notifications.show_warning("No timepoints selected")
            return
        timepoints = [self.metadata.timepoints[i] for i in time_indices]
        
        channel_indices = self._get_selected_indices(self.channel_list)
        if not channel_indices:
            notifications.show_warning("No channels selected")
            return
        channels = [self.metadata.channel_ids[i] for i in channel_indices]
        
        z_indices = self._get_selected_indices(self.z_list)
        if not z_indices:
            notifications.show_warning("No Z-slices selected")
            return
        z_slices = [self.metadata.planes[i] for i in z_indices]
        
        # Show loading message
        notifications.show_info(f"Loading data for well {well_str}...")
        
        try:
            # Load data
            data, metadata = self.reader.read_data(
                row=row,
                column=col,
                field=field,
                stitch_fields=stitch,
                timepoints=timepoints,
                channels=channels,
                z_slices=z_slices
            )
            
            # Clear existing layers
            self.viewer.layers.clear()
            
            # Visualize data
            self._add_layers_to_viewer(data, metadata)
            
            notifications.show_info("Data loaded successfully!")
            
        except Exception as e:
            notifications.show_error(f"Error loading data: {str(e)}")
            import traceback
            traceback.print_exc()
    
    def _add_layers_to_viewer(self, data, metadata):
        """Add data layers to the viewer."""
        # Extract metadata
        channels_info = metadata['channels']
        pixel_size_x = metadata['pixel_size']['x'] * 1e6  # to µm
        pixel_size_y = metadata['pixel_size']['y'] * 1e6
        z_step = metadata['z_step'] * 1e6 if metadata['z_step'] is not None else 1.0
        
        # Color mapping
        color_map = {
            'DAPI': 'blue',
            'Hoechst': 'blue',
            'Alexa 488': 'green',
            'GFP': 'green',
            'Alexa 555': 'yellow',
            'mCherry': 'red',
            'Alexa 647': 'magenta',
            'Cy5': 'magenta',
        }
        default_colors = ['cyan', 'magenta', 'yellow', 'green', 'red', 'blue']
        
        # Add each channel
        for ch_idx, (ch_id, ch_info) in enumerate(channels_info.items()):
            ch_name = ch_info['name']
            
            # Select color
            color = None
            for key, value in color_map.items():
                if key.lower() in ch_name.lower():
                    color = value
                    break
            if color is None:
                color = default_colors[ch_idx % len(default_colors)]
            
            # Get channel data
            if data.shape[0] > 1:  # Multiple timepoints
                channel_data = data[:, ch_idx, :, :, :]  # (T, Z, Y, X)
                scale = (1, z_step, pixel_size_y, pixel_size_x)
            else:  # Single timepoint
                channel_data = data[0, ch_idx, :, :, :]  # (Z, Y, X)
                scale = (z_step, pixel_size_y, pixel_size_x)
            
            # Calculate contrast limits
            nonzero_data = channel_data[channel_data > 0]
            if len(nonzero_data) > 0:
                contrast_limits = [0, np.percentile(nonzero_data, 99.5)]
            else:
                contrast_limits = [0, 1]
            
            # Add to viewer
            self.viewer.add_image(
                channel_data,
                name=f"Ch{ch_id}: {ch_name}",
                colormap=color,
                blending='additive',
                scale=scale,
                contrast_limits=contrast_limits
            )
        
        # Update viewer title
        well = metadata['well']
        if metadata['stitched']:
            title = f"{metadata['plate_id']} - {well} - Stitched"
        else:
            title = f"{metadata['plate_id']} - {well} - Field {metadata['fields'][0]}"
        self.viewer.title = title
        
        # Enable scale bar
        self.viewer.scale_bar.visible = True
        self.viewer.scale_bar.unit = "µm"
        
        # Reset view
        self.viewer.reset_view()


def create_phenix_data_loader(experiment_path: str) -> napari.Viewer:
    """
    Create an interactive Opera Phenix data loader widget in Napari.
    
    This function opens a Napari viewer with an interactive widget for selecting
    and visualizing different subsets of Opera Phenix imaging data.
    
    Parameters
    ----------
    experiment_path : str
        Path to the Opera Phenix experiment directory
        
    Returns
    -------
    viewer : napari.Viewer
        Napari viewer with the data loader widget docked
        
    Examples
    --------
    >>> viewer = create_phenix_data_loader('/path/to/experiment')
    """
    # Create viewer
    viewer = napari.Viewer()
    
    # Create and dock widget
    widget = PhenixDataLoaderWidget(experiment_path, viewer)
    viewer.window.add_dock_widget(widget, name="Data Loader", area="right")
    
    return viewer
#+END_SRC

#+RESULTS:
:results:
:end:

** Prototyping area (OUTDATED)
:PROPERTIES:
:header-args:python: :tangle no :kernel s1_pyphenix :session s1_pyphenix
:END:
- <2026-01-02 Fri> moving this over to [[id:ba84779b-19cd-44de-907a-198076a42682][s1_pyphenix_sandbox]] because the
  uv-controlled virtual environment got messed up by setting up the plugin
*** setup
**** import     
#+begin_src python
import sys
from importlib import reload

from s1_pyphenix_functions import *
reload(sys.modules['s1_pyphenix_functions'])
from s1_pyphenix_functions import *
#+end_src

#+RESULTS:
:results:
:end:
**** mount cb2
#+begin_src bash
umount ~/mount/cb2
sshfs cb2:/ ~/mount/cb2
#+end_src

#+RESULTS:

*** original function for loading and viewing archive data

from [[id:39fa5e30-d89d-4469-9c08-59cc197b95e3][Local analysis pipeline]]

#+begin_src python
# Configuration
base_path = os.path.expanduser(
    '~/mount/cb2/data/Phenix-archive-2024/Lolita/IL_11/IL11_expt1_CCD_KEL_24hr/hs/04001eed-2ba8-4c0e-bce6-65c22e9bb6ef'
)
TARGET_ROW = 7
TARGET_COL = 2 
TARGET_FIELD = 1

# Load data and metadata
image_stack, metadata = load_image_stack_with_metadata(
    base_path, TARGET_ROW, TARGET_COL, TARGET_FIELD
)

viewer = visualize_in_napari(image_stack, metadata, time_index=0)
#+end_src

#+RESULTS:
:results:
#+begin_example
Reading metadata files...

Successfully parsed metadata:
  - Channel Names: ['Alexa 647', 'Brightfield', 'HOECHST 33342']
  - Voxel Size (Z, Y, X) in µm: (1.0000, 0.1483, 0.1483)

Assembling the 5D image array...
✅ Assembly complete.

🚀 Napari viewer is running!
#+end_example
:end:
*** new function for loading export data

from [[id:fcab47a5-0582-4c0c-b70f-12b0d9df42df][p9_alveolar_organoid]]

this currently concatenates everything into 3d

#+begin_src python
# Configuration
base_path = os.path.expanduser(
    '~/mount/cb2/data/Ho_oh_data_archive/LaurenB/251118_AlvOrg/AlvOrg-test__2025-10-24T14_09_21-Measurement 1a'
)

# Load data and metadata
image_stack, metadata = load_phenix_image_stack_with_metadata(
    base_path, well_id='0203', field_id=1
)
#+end_src

#+RESULTS:
:results:
:end:

#+begin_src python
viewer = visualize_in_napari(image_stack, metadata, time_index=0)
#+end_src

#+RESULTS:
:results:
# [goto error]
#+begin_example
[31m---------------------------------------------------------------------------[39m
[31mKeyError[39m                                  Traceback (most recent call last)
[36mCell[39m[36m [39m[32mIn[17][39m[32m, line 1[39m
[32m----> [39m[32m1[39m viewer = [43mvisualize_in_napari[49m[43m([49m[43mimage_stack[49m[43m,[49m[43m [49m[43mmetadata[49m[43m,[49m[43m [49m[43mtime_index[49m[43m=[49m[32;43m0[39;49m[43m)[49m

[36mFile [39m[32m~/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/s1_pyphenix_functions.py:593[39m, in [36mvisualize_in_napari[39m[34m(image_stack, metadata, time_index)[39m
[32m    575[39m [38;5;250m[39m[33;03m"""[39;00m
[32m    576[39m [33;03mVisualize the image stack in Napari with proper channel names, colors, and scaling.[39;00m
[32m    577[39m [33;03m[39;00m
[32m   (...)[39m[32m    590[39m [33;03m    Napari viewer instance[39;00m
[32m    591[39m [33;03m"""[39;00m
[32m    592[39m [38;5;66;03m# Extract metadata[39;00m
[32m--> [39m[32m593[39m channel_names = [43mmetadata[49m[43m[[49m[33;43m'[39;49m[33;43mchannel_names[39;49m[33;43m'[39;49m[43m][49m
[32m    594[39m scale_x = metadata[[33m'[39m[33mscale_x[39m[33m'[39m]
[32m    595[39m scale_y = metadata[[33m'[39m[33mscale_y[39m[33m'[39m]

[31mKeyError[39m: 'channel_names'
#+end_example
:end:

from [[id:b192d10c-302d-4a9e-8827-1310aff9f5c4][p10_pff_fibrils]]


#+begin_src python
# Configuration
base_path = os.path.expanduser(
    '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'
)

# Load data and metadata
image_stack, metadata = load_phenix_image_stack_with_metadata(
    base_path, well_id='0203', field_id=1
)
#+end_src
*** developing export reader v2

**** Read only metadata for default well

#+BEGIN_SRC python
export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'
# Returns None for data, but prints all metadata
nonedata, testmetadata = load_phenix_data(
    export_path,
    metadata_only=True
)

print(f"Data is None: {nonedata is None}")  # True
print(f"Shape would be: {testmetadata['shape']['dimensions']}")
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Plate dimensions: 8 rows × 12 columns

Wells with data: 30
  Wells: 0202, 0203, 0204, 0207, 0208, 0209, 0302, 0303, 0304, 0307, 0308, 0309, 0402, 0403, 0404, 0502, 0503, 0504, 0507, 0508, 0509, 0602, 0603, 0604, 0607, 0608, 0609, 0702, 0703, 0704

Fields per well:
  r02c02: 25 fields (1-25)
  r02c03: 25 fields (1-25)
  r02c04: 25 fields (1-25)
  r02c07: 25 fields (1-25)
  r02c08: 25 fields (1-25)
  r02c09: 25 fields (1-25)
  r03c02: 25 fields (1-25)
  r03c03: 25 fields (1-25)
  r03c04: 25 fields (1-25)
  r03c07: 25 fields (1-25)
  r03c08: 25 fields (1-25)
  r03c09: 25 fields (1-25)
  r04c02: 25 fields (1-25)
  r04c03: 25 fields (1-25)
  r04c04: 25 fields (1-25)
  r05c02: 25 fields (1-25)
  r05c03: 25 fields (1-25)
  r05c04: 25 fields (1-25)
  r05c07: 25 fields (1-25)
  r05c08: 25 fields (1-25)
  r05c09: 25 fields (1-25)
  r06c02: 25 fields (1-25)
  r06c03: 25 fields (1-25)
  r06c04: 25 fields (1-25)
  r06c07: 25 fields (1-25)
  r06c08: 25 fields (1-25)
  r06c09: 25 fields (1-25)
  r07c02: 25 fields (1-25)
  r07c03: 25 fields (1-25)
  r07c04: 25 fields (1-25)

Timepoints: 1 (1-1)

Channels: 4
  Channel 1: Alexa 555
  Channel 2: Alexa 647
  Channel 3: Alexa 488
  Channel 4: DAPI

Z-planes: 4 (1-4)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================


============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c02

Data Shape: (1, 4, 4, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: Alexa 555
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.1 s
  Channel 2: Alexa 647
    Excitation: 640 nm
    Emission: 706 nm
    Exposure: 0.1 s
  Channel 3: Alexa 488
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.16 s
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1]
Z-slices: [1, 2, 3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm
============================================================


,*** METADATA ONLY - No image data loaded ***

Data is None: True
Shape would be: (1, 4, 4, 1080, 1080)
#+end_example
:end:


**** Basic usage - read first well, all channels and Z-slices

#+BEGIN_SRC python
export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'

# Load data from first available well
data, metadata = load_phenix_data(export_path)

print(f"Data shape: {data.shape}")
print(f"Channel names: {[ch['name'] for ch in metadata['channels'].values()]}")

viewer = visualize_in_napari(data, metadata)
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Plate dimensions: 8 rows × 12 columns

Wells with data: 30
  Wells: 0202, 0203, 0204, 0207, 0208, 0209, 0302, 0303, 0304, 0307, 0308, 0309, 0402, 0403, 0404, 0502, 0503, 0504, 0507, 0508, 0509, 0602, 0603, 0604, 0607, 0608, 0609, 0702, 0703, 0704

Fields per well:
  r02c02: 25 fields (1-25)
  r02c03: 25 fields (1-25)
  r02c04: 25 fields (1-25)
  r02c07: 25 fields (1-25)
  r02c08: 25 fields (1-25)
  r02c09: 25 fields (1-25)
  r03c02: 25 fields (1-25)
  r03c03: 25 fields (1-25)
  r03c04: 25 fields (1-25)
  r03c07: 25 fields (1-25)
  r03c08: 25 fields (1-25)
  r03c09: 25 fields (1-25)
  r04c02: 25 fields (1-25)
  r04c03: 25 fields (1-25)
  r04c04: 25 fields (1-25)
  r05c02: 25 fields (1-25)
  r05c03: 25 fields (1-25)
  r05c04: 25 fields (1-25)
  r05c07: 25 fields (1-25)
  r05c08: 25 fields (1-25)
  r05c09: 25 fields (1-25)
  r06c02: 25 fields (1-25)
  r06c03: 25 fields (1-25)
  r06c04: 25 fields (1-25)
  r06c07: 25 fields (1-25)
  r06c08: 25 fields (1-25)
  r06c09: 25 fields (1-25)
  r07c02: 25 fields (1-25)
  r07c03: 25 fields (1-25)
  r07c04: 25 fields (1-25)

Timepoints: 1 (1-1)

Channels: 4
  Channel 1: Alexa 555
  Channel 2: Alexa 647
  Channel 3: Alexa 488
  Channel 4: DAPI

Z-planes: 4 (1-4)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c02

Data Shape: (1, 4, 4, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: Alexa 555
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.1 s
  Channel 2: Alexa 647
    Excitation: 640 nm
    Emission: 706 nm
    Exposure: 0.1 s
  Channel 3: Alexa 488
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.16 s
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1]
Z-slices: [1, 2, 3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm
============================================================

Data shape: (1, 4, 4, 1080, 1080)
Channel names: ['Alexa 555', 'Alexa 647', 'Alexa 488', 'DAPI']

============================================================
🚀 NAPARI VIEWER LAUNCHED
============================================================
Viewing: 10-20-25-iDA_DJ1KO_PFF3wks-redo - r02c02 - Field 1
Timepoint: 1 / 1
Channels: 4
Z-slices: 4
Image size: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================
#+end_example
:end:

**** Read specific well and channel

#+BEGIN_SRC python
export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'
# Read only DAPI channel from row 2, column 2
data, metadata = load_phenix_data(
    export_path,
    row=2,
    column=2,
    channels=4  # DAPI is channel 4
)
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
OPERA PHENIX DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c02

Plate Layout:
  Rows: 8
  Columns: 12

Data Shape: (1, 1, 4, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1]
Z-slices: [1, 2, 3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm
============================================================
#+end_example
:end:

**** Stitch multiple fields

#+BEGIN_SRC python

export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'
# Read and stitch all fields for a well
data, metadata = load_phenix_data(
    export_path,
    row=2,
    column=2,
    stitch_fields=True,
    channels=[4]  # DAPI
)
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Plate dimensions: 8 rows × 12 columns

Wells with data: 30
  Wells: 0202, 0203, 0204, 0207, 0208, 0209, 0302, 0303, 0304, 0307, 0308, 0309, 0402, 0403, 0404, 0502, 0503, 0504, 0507, 0508, 0509, 0602, 0603, 0604, 0607, 0608, 0609, 0702, 0703, 0704

Fields per well:
  r02c02: 25 fields (1-25)
  r02c03: 25 fields (1-25)
  r02c04: 25 fields (1-25)
  r02c07: 25 fields (1-25)
  r02c08: 25 fields (1-25)
  r02c09: 25 fields (1-25)
  r03c02: 25 fields (1-25)
  r03c03: 25 fields (1-25)
  r03c04: 25 fields (1-25)
  r03c07: 25 fields (1-25)
  r03c08: 25 fields (1-25)
  r03c09: 25 fields (1-25)
  r04c02: 25 fields (1-25)
  r04c03: 25 fields (1-25)
  r04c04: 25 fields (1-25)
  r05c02: 25 fields (1-25)
  r05c03: 25 fields (1-25)
  r05c04: 25 fields (1-25)
  r05c07: 25 fields (1-25)
  r05c08: 25 fields (1-25)
  r05c09: 25 fields (1-25)
  r06c02: 25 fields (1-25)
  r06c03: 25 fields (1-25)
  r06c04: 25 fields (1-25)
  r06c07: 25 fields (1-25)
  r06c08: 25 fields (1-25)
  r06c09: 25 fields (1-25)
  r07c02: 25 fields (1-25)
  r07c03: 25 fields (1-25)
  r07c04: 25 fields (1-25)

Timepoints: 1 (1-1)

Channels: 4
  Channel 1: Alexa 555
  Channel 2: Alexa 647
  Channel 3: Alexa 488
  Channel 4: DAPI

Z-planes: 4 (1-4)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c02

Data Shape: (1, 1, 4, 5400, 5400)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25]
Timepoints: [1]
Z-slices: [1, 2, 3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm

,*** Fields have been STITCHED ***
============================================================
#+end_example
:end:

#+begin_src python
import matplotlib.pyplot as plt

viz_check = data[0,0,0,...]

plt.hist(viz_check.ravel())
plt.yscale('log')
plt.figure()
plt.imshow(viz_check)

#+end_src

#+RESULTS:
[[file:./.ob-jupyter/b4172680c71629c5c38bfd6ef19cc07fb6cbe75b.png]]
:RESULTS:
: <matplotlib.image.AxesImage at 0x363a70050>
[[file:./.ob-jupyter/aa17d8b12f4cd24f7f7f69e519ac531647da254a.png]]
:END:
:results:
:end:

#+begin_src python
voxel_scale = [
    metadata['z_step'],
    metadata['pixel_size']['y'],
    metadata['pixel_size']['x']
]

viewer = napari.Viewer()
viewer.add_image(data[0,0,...], scale=voxel_scale)
#+end_src

#+RESULTS:
:results:
: <Image layer 'Image' at 0x3ac67c190>
:end:

**** Save processed data

#+BEGIN_SRC python
# Read data and save as numpy array
data, metadata = load_phenix_data(
    '/path/to/experiment',
    row=2,
    column=2,
    output_file='processed_data.npy',
    output_format='numpy'
)

# Or save as OME-TIFF (requires tifffile package)
data, metadata = load_phenix_data(
    '/path/to/experiment',
    row=2,
    column=2,
    output_file='processed_data.ome.tiff',
    output_format='ome-tiff'
)
#+END_SRC

**** Read specific subset of data

#+BEGIN_SRC python

export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'

# Read specific timepoints, z-slices, and channels
data, metadata = load_phenix_data(
    export_path,
    row=2,
    column=3,
    field=1,
    timepoints=[1],
    channels=[1, 4],
    z_slices=[3, 4]
)
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Plate dimensions: 8 rows × 12 columns

Wells with data: 30
  Wells: 0202, 0203, 0204, 0207, 0208, 0209, 0302, 0303, 0304, 0307, 0308, 0309, 0402, 0403, 0404, 0502, 0503, 0504, 0507, 0508, 0509, 0602, 0603, 0604, 0607, 0608, 0609, 0702, 0703, 0704

Fields per well:
  r02c02: 25 fields (1-25)
  r02c03: 25 fields (1-25)
  r02c04: 25 fields (1-25)
  r02c07: 25 fields (1-25)
  r02c08: 25 fields (1-25)
  r02c09: 25 fields (1-25)
  r03c02: 25 fields (1-25)
  r03c03: 25 fields (1-25)
  r03c04: 25 fields (1-25)
  r03c07: 25 fields (1-25)
  r03c08: 25 fields (1-25)
  r03c09: 25 fields (1-25)
  r04c02: 25 fields (1-25)
  r04c03: 25 fields (1-25)
  r04c04: 25 fields (1-25)
  r05c02: 25 fields (1-25)
  r05c03: 25 fields (1-25)
  r05c04: 25 fields (1-25)
  r05c07: 25 fields (1-25)
  r05c08: 25 fields (1-25)
  r05c09: 25 fields (1-25)
  r06c02: 25 fields (1-25)
  r06c03: 25 fields (1-25)
  r06c04: 25 fields (1-25)
  r06c07: 25 fields (1-25)
  r06c08: 25 fields (1-25)
  r06c09: 25 fields (1-25)
  r07c02: 25 fields (1-25)
  r07c03: 25 fields (1-25)
  r07c04: 25 fields (1-25)

Timepoints: 1 (1-1)

Channels: 4
  Channel 1: Alexa 555
  Channel 2: Alexa 647
  Channel 3: Alexa 488
  Channel 4: DAPI

Z-planes: 4 (1-4)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 1.500 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: 10-20-25-iDA_DJ1KO_PFF3wks-redo
Well: r02c03

Data Shape: (1, 2, 2, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: Alexa 555
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.1 s
  Channel 4: DAPI
    Excitation: 375 nm
    Emission: 456 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1]
Z-slices: [3, 4]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 1.500 µm
============================================================
#+end_example
:end:


#+begin_src python
import matplotlib.pyplot as plt

viz_check = data[0,0,0,...]

plt.hist(viz_check.ravel())
plt.yscale('log')
plt.figure()
plt.imshow(viz_check)

#+end_src

#+RESULTS:
:results:
: <matplotlib.image.AxesImage at 0x3ac6f5e50>
[[file:./.ob-jupyter/2e4964c2a0be550e39d5fa941d099a1219e57327.png]]
[[file:./.ob-jupyter/f2d6210fb5988f310c53e30e49a1ab135fd918b8.png]]
:end:
*** interactive loader and visualizer
**** Basic usage

#+BEGIN_SRC python

export_path = '/Users/ferrin/mount/cb2/data/Ho_oh_data_archive/max/export_full_test/10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1'

# Create interactive loader
viewer = create_phenix_data_loader(
    export_path
)

# The viewer will open with the widget on the right side
# Use the widget controls to select and visualize data
#+END_SRC

#+RESULTS:
:results:
: 
: ============================================================
: 🎛️  INTERACTIVE DATA LOADER WIDGET INITIALIZED
: ============================================================
: Experiment: 10-20-25-iDA_DJ1KO_PFF3wks-redo__2025-10-20T11_40_47-Measurement 1
: Wells available: 30
: Channels: 4
: ============================================================
: 
:end:

**** Programmatic access to widget

#+BEGIN_SRC python
# Create viewer with widget
viewer = create_phenix_data_loader('/path/to/experiment')

# Access the widget (if needed)
widget = viewer.window._dock_widgets['Data Loader'].widget()

# Programmatically change selections (optional)
widget.well_combo.setCurrentIndex(2)  # Select third well
widget.stitch_checkbox.setChecked(True)  # Enable stitching
widget._visualize_data()  # Load data
#+END_SRC

**** Multiple experiments

#+BEGIN_SRC python
# Load multiple experiments in separate viewers
viewer1 = create_phenix_data_loader('/path/to/experiment1')
viewer2 = create_phenix_data_loader('/path/to/experiment2')
#+END_SRC
*** Emily's time series test data
- currently, ~zfsdata07~ needs to be mounted; my permissions don't work over
  sshfs to cb2
**** read metadata without loading data

#+BEGIN_SRC python
export_path = '/Volumes/Phenix-archive-2024-ro/Emily/250107-CAGE/CAGE-timelapse__2023-02-02T15_57_02-Measurement 2/'
# Returns None for data, but prints all metadata
nonedata, testmetadata = load_phenix_data(
    export_path,
    stitch_fields=False,
    metadata_only=True
)

print(f"Shape would be: {testmetadata['shape']['dimensions']}")
#+END_SRC

#+RESULTS:
:results:
#+begin_example

============================================================
DATASET OVERVIEW
============================================================

Plate ID: CAGE-timelapse
Plate dimensions: 16 rows × 24 columns

Wells with data: 180
  Wells: 0404, 0405, 0406, 0407, 0408, 0409, 0410, 0411, 0412, 0413, 0414, 0415, 0416, 0417, 0418, 0419, 0420, 0421, 0504, 0505, 0506, 0507, 0508, 0509, 0510, 0511, 0512, 0513, 0514, 0515, 0516, 0517, 0518, 0519, 0520, 0521, 0604, 0605, 0606, 0607, 0608, 0609, 0610, 0611, 0612, 0613, 0614, 0615, 0616, 0617, 0618, 0619, 0620, 0621, 0704, 0705, 0706, 0707, 0708, 0709, 0710, 0711, 0712, 0713, 0714, 0715, 0716, 0717, 0718, 0719, 0720, 0721, 0804, 0805, 0806, 0807, 0808, 0809, 0810, 0811, 0812, 0813, 0814, 0815, 0816, 0817, 0818, 0819, 0820, 0821, 0904, 0905, 0906, 0907, 0908, 0909, 0910, 0911, 0912, 0913, 0914, 0915, 0916, 0917, 0918, 0919, 0920, 0921, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1120, 1121, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321

Fields per well:
  r04c04: 2 fields (1-2)
  r04c05: 2 fields (1-2)
  r04c06: 2 fields (1-2)
  r04c07: 2 fields (1-2)
  r04c08: 2 fields (1-2)
  r04c09: 2 fields (1-2)
  r04c10: 2 fields (1-2)
  r04c11: 2 fields (1-2)
  r04c12: 2 fields (1-2)
  r04c13: 2 fields (1-2)
  r04c14: 2 fields (1-2)
  r04c15: 2 fields (1-2)
  r04c16: 2 fields (1-2)
  r04c17: 2 fields (1-2)
  r04c18: 2 fields (1-2)
  r04c19: 2 fields (1-2)
  r04c20: 2 fields (1-2)
  r04c21: 2 fields (1-2)
  r05c04: 2 fields (1-2)
  r05c05: 2 fields (1-2)
  r05c06: 2 fields (1-2)
  r05c07: 2 fields (1-2)
  r05c08: 2 fields (1-2)
  r05c09: 2 fields (1-2)
  r05c10: 2 fields (1-2)
  r05c11: 2 fields (1-2)
  r05c12: 2 fields (1-2)
  r05c13: 2 fields (1-2)
  r05c14: 2 fields (1-2)
  r05c15: 2 fields (1-2)
  r05c16: 2 fields (1-2)
  r05c17: 2 fields (1-2)
  r05c18: 2 fields (1-2)
  r05c19: 2 fields (1-2)
  r05c20: 2 fields (1-2)
  r05c21: 2 fields (1-2)
  r06c04: 2 fields (1-2)
  r06c05: 2 fields (1-2)
  r06c06: 2 fields (1-2)
  r06c07: 2 fields (1-2)
  r06c08: 2 fields (1-2)
  r06c09: 2 fields (1-2)
  r06c10: 2 fields (1-2)
  r06c11: 2 fields (1-2)
  r06c12: 2 fields (1-2)
  r06c13: 2 fields (1-2)
  r06c14: 2 fields (1-2)
  r06c15: 2 fields (1-2)
  r06c16: 2 fields (1-2)
  r06c17: 2 fields (1-2)
  r06c18: 2 fields (1-2)
  r06c19: 2 fields (1-2)
  r06c20: 2 fields (1-2)
  r06c21: 2 fields (1-2)
  r07c04: 2 fields (1-2)
  r07c05: 2 fields (1-2)
  r07c06: 2 fields (1-2)
  r07c07: 2 fields (1-2)
  r07c08: 2 fields (1-2)
  r07c09: 2 fields (1-2)
  r07c10: 2 fields (1-2)
  r07c11: 2 fields (1-2)
  r07c12: 2 fields (1-2)
  r07c13: 2 fields (1-2)
  r07c14: 2 fields (1-2)
  r07c15: 2 fields (1-2)
  r07c16: 2 fields (1-2)
  r07c17: 2 fields (1-2)
  r07c18: 2 fields (1-2)
  r07c19: 2 fields (1-2)
  r07c20: 2 fields (1-2)
  r07c21: 2 fields (1-2)
  r08c04: 2 fields (1-2)
  r08c05: 2 fields (1-2)
  r08c06: 2 fields (1-2)
  r08c07: 2 fields (1-2)
  r08c08: 2 fields (1-2)
  r08c09: 2 fields (1-2)
  r08c10: 2 fields (1-2)
  r08c11: 2 fields (1-2)
  r08c12: 2 fields (1-2)
  r08c13: 2 fields (1-2)
  r08c14: 2 fields (1-2)
  r08c15: 2 fields (1-2)
  r08c16: 2 fields (1-2)
  r08c17: 2 fields (1-2)
  r08c18: 2 fields (1-2)
  r08c19: 2 fields (1-2)
  r08c20: 2 fields (1-2)
  r08c21: 2 fields (1-2)
  r09c04: 2 fields (1-2)
  r09c05: 2 fields (1-2)
  r09c06: 2 fields (1-2)
  r09c07: 2 fields (1-2)
  r09c08: 2 fields (1-2)
  r09c09: 2 fields (1-2)
  r09c10: 2 fields (1-2)
  r09c11: 2 fields (1-2)
  r09c12: 2 fields (1-2)
  r09c13: 2 fields (1-2)
  r09c14: 2 fields (1-2)
  r09c15: 2 fields (1-2)
  r09c16: 2 fields (1-2)
  r09c17: 2 fields (1-2)
  r09c18: 2 fields (1-2)
  r09c19: 2 fields (1-2)
  r09c20: 2 fields (1-2)
  r09c21: 2 fields (1-2)
  r10c04: 2 fields (1-2)
  r10c05: 2 fields (1-2)
  r10c06: 2 fields (1-2)
  r10c07: 2 fields (1-2)
  r10c08: 2 fields (1-2)
  r10c09: 2 fields (1-2)
  r10c10: 2 fields (1-2)
  r10c11: 2 fields (1-2)
  r10c12: 2 fields (1-2)
  r10c13: 2 fields (1-2)
  r10c14: 2 fields (1-2)
  r10c15: 2 fields (1-2)
  r10c16: 2 fields (1-2)
  r10c17: 2 fields (1-2)
  r10c18: 2 fields (1-2)
  r10c19: 2 fields (1-2)
  r10c20: 2 fields (1-2)
  r10c21: 2 fields (1-2)
  r11c04: 2 fields (1-2)
  r11c05: 2 fields (1-2)
  r11c06: 2 fields (1-2)
  r11c07: 2 fields (1-2)
  r11c08: 2 fields (1-2)
  r11c09: 2 fields (1-2)
  r11c10: 2 fields (1-2)
  r11c11: 2 fields (1-2)
  r11c12: 2 fields (1-2)
  r11c13: 2 fields (1-2)
  r11c14: 2 fields (1-2)
  r11c15: 2 fields (1-2)
  r11c16: 2 fields (1-2)
  r11c17: 2 fields (1-2)
  r11c18: 2 fields (1-2)
  r11c19: 2 fields (1-2)
  r11c20: 2 fields (1-2)
  r11c21: 2 fields (1-2)
  r12c04: 2 fields (1-2)
  r12c05: 2 fields (1-2)
  r12c06: 2 fields (1-2)
  r12c07: 2 fields (1-2)
  r12c08: 2 fields (1-2)
  r12c09: 2 fields (1-2)
  r12c10: 2 fields (1-2)
  r12c11: 2 fields (1-2)
  r12c12: 2 fields (1-2)
  r12c13: 2 fields (1-2)
  r12c14: 2 fields (1-2)
  r12c15: 2 fields (1-2)
  r12c16: 2 fields (1-2)
  r12c17: 2 fields (1-2)
  r12c18: 2 fields (1-2)
  r12c19: 2 fields (1-2)
  r12c20: 2 fields (1-2)
  r12c21: 2 fields (1-2)
  r13c04: 2 fields (1-2)
  r13c05: 2 fields (1-2)
  r13c06: 2 fields (1-2)
  r13c07: 2 fields (1-2)
  r13c08: 2 fields (1-2)
  r13c09: 2 fields (1-2)
  r13c10: 2 fields (1-2)
  r13c11: 2 fields (1-2)
  r13c12: 2 fields (1-2)
  r13c13: 2 fields (1-2)
  r13c14: 2 fields (1-2)
  r13c15: 2 fields (1-2)
  r13c16: 2 fields (1-2)
  r13c17: 2 fields (1-2)
  r13c18: 2 fields (1-2)
  r13c19: 2 fields (1-2)
  r13c20: 2 fields (1-2)
  r13c21: 2 fields (1-2)

Timepoints: 32 (1-32)

Channels: 4
  Channel 1: CFP
  Channel 2: EGFP
  Channel 3: mStrawberry
  Channel 4: Brightfield

Z-planes: 3 (1-3)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 0.600 µm
============================================================


============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: CAGE-timelapse
Well: r04c04

Data Shape: (32, 4, 3, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: CFP
    Excitation: 425 nm
    Emission: 456 nm
    Exposure: 0.2 s
  Channel 2: EGFP
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.08 s
  Channel 3: mStrawberry
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.08 s
  Channel 4: Brightfield
    Excitation: 740 nm
    Emission: 0 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
Z-slices: [1, 2, 3]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 0.600 µm
============================================================


,*** METADATA ONLY - No image data loaded ***

Shape would be: (32, 4, 3, 1080, 1080)
#+end_example
:end:



**** interactive loader and visualizer

#+BEGIN_SRC python

# Create interactive loader
viewer = create_phenix_data_loader(
    export_path
)

# The viewer will open with the widget on the right side
# Use the widget controls to select and visualize data
#+END_SRC

#+RESULTS:
#+begin_example
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
INFO: Data loaded successfully!
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
#+end_example
:results:
#+begin_example

============================================================
🎛️  INTERACTIVE DATA LOADER WIDGET INITIALIZED
============================================================
Experiment: CAGE-timelapse__2023-02-02T15_57_02-Measurement 2
Wells available: 180
Channels: 4
============================================================
INFO: Loading data for well r04c04...

============================================================
DATASET OVERVIEW
============================================================

Plate ID: CAGE-timelapse
Plate dimensions: 16 rows × 24 columns

Wells with data: 180
  Wells: 0404, 0405, 0406, 0407, 0408, 0409, 0410, 0411, 0412, 0413, 0414, 0415, 0416, 0417, 0418, 0419, 0420, 0421, 0504, 0505, 0506, 0507, 0508, 0509, 0510, 0511, 0512, 0513, 0514, 0515, 0516, 0517, 0518, 0519, 0520, 0521, 0604, 0605, 0606, 0607, 0608, 0609, 0610, 0611, 0612, 0613, 0614, 0615, 0616, 0617, 0618, 0619, 0620, 0621, 0704, 0705, 0706, 0707, 0708, 0709, 0710, 0711, 0712, 0713, 0714, 0715, 0716, 0717, 0718, 0719, 0720, 0721, 0804, 0805, 0806, 0807, 0808, 0809, 0810, 0811, 0812, 0813, 0814, 0815, 0816, 0817, 0818, 0819, 0820, 0821, 0904, 0905, 0906, 0907, 0908, 0909, 0910, 0911, 0912, 0913, 0914, 0915, 0916, 0917, 0918, 0919, 0920, 0921, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1120, 1121, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321

Fields per well:
  r04c04: 2 fields (1-2)
  r04c05: 2 fields (1-2)
  r04c06: 2 fields (1-2)
  r04c07: 2 fields (1-2)
  r04c08: 2 fields (1-2)
  r04c09: 2 fields (1-2)
  r04c10: 2 fields (1-2)
  r04c11: 2 fields (1-2)
  r04c12: 2 fields (1-2)
  r04c13: 2 fields (1-2)
  r04c14: 2 fields (1-2)
  r04c15: 2 fields (1-2)
  r04c16: 2 fields (1-2)
  r04c17: 2 fields (1-2)
  r04c18: 2 fields (1-2)
  r04c19: 2 fields (1-2)
  r04c20: 2 fields (1-2)
  r04c21: 2 fields (1-2)
  r05c04: 2 fields (1-2)
  r05c05: 2 fields (1-2)
  r05c06: 2 fields (1-2)
  r05c07: 2 fields (1-2)
  r05c08: 2 fields (1-2)
  r05c09: 2 fields (1-2)
  r05c10: 2 fields (1-2)
  r05c11: 2 fields (1-2)
  r05c12: 2 fields (1-2)
  r05c13: 2 fields (1-2)
  r05c14: 2 fields (1-2)
  r05c15: 2 fields (1-2)
  r05c16: 2 fields (1-2)
  r05c17: 2 fields (1-2)
  r05c18: 2 fields (1-2)
  r05c19: 2 fields (1-2)
  r05c20: 2 fields (1-2)
  r05c21: 2 fields (1-2)
  r06c04: 2 fields (1-2)
  r06c05: 2 fields (1-2)
  r06c06: 2 fields (1-2)
  r06c07: 2 fields (1-2)
  r06c08: 2 fields (1-2)
  r06c09: 2 fields (1-2)
  r06c10: 2 fields (1-2)
  r06c11: 2 fields (1-2)
  r06c12: 2 fields (1-2)
  r06c13: 2 fields (1-2)
  r06c14: 2 fields (1-2)
  r06c15: 2 fields (1-2)
  r06c16: 2 fields (1-2)
  r06c17: 2 fields (1-2)
  r06c18: 2 fields (1-2)
  r06c19: 2 fields (1-2)
  r06c20: 2 fields (1-2)
  r06c21: 2 fields (1-2)
  r07c04: 2 fields (1-2)
  r07c05: 2 fields (1-2)
  r07c06: 2 fields (1-2)
  r07c07: 2 fields (1-2)
  r07c08: 2 fields (1-2)
  r07c09: 2 fields (1-2)
  r07c10: 2 fields (1-2)
  r07c11: 2 fields (1-2)
  r07c12: 2 fields (1-2)
  r07c13: 2 fields (1-2)
  r07c14: 2 fields (1-2)
  r07c15: 2 fields (1-2)
  r07c16: 2 fields (1-2)
  r07c17: 2 fields (1-2)
  r07c18: 2 fields (1-2)
  r07c19: 2 fields (1-2)
  r07c20: 2 fields (1-2)
  r07c21: 2 fields (1-2)
  r08c04: 2 fields (1-2)
  r08c05: 2 fields (1-2)
  r08c06: 2 fields (1-2)
  r08c07: 2 fields (1-2)
  r08c08: 2 fields (1-2)
  r08c09: 2 fields (1-2)
  r08c10: 2 fields (1-2)
  r08c11: 2 fields (1-2)
  r08c12: 2 fields (1-2)
  r08c13: 2 fields (1-2)
  r08c14: 2 fields (1-2)
  r08c15: 2 fields (1-2)
  r08c16: 2 fields (1-2)
  r08c17: 2 fields (1-2)
  r08c18: 2 fields (1-2)
  r08c19: 2 fields (1-2)
  r08c20: 2 fields (1-2)
  r08c21: 2 fields (1-2)
  r09c04: 2 fields (1-2)
  r09c05: 2 fields (1-2)
  r09c06: 2 fields (1-2)
  r09c07: 2 fields (1-2)
  r09c08: 2 fields (1-2)
  r09c09: 2 fields (1-2)
  r09c10: 2 fields (1-2)
  r09c11: 2 fields (1-2)
  r09c12: 2 fields (1-2)
  r09c13: 2 fields (1-2)
  r09c14: 2 fields (1-2)
  r09c15: 2 fields (1-2)
  r09c16: 2 fields (1-2)
  r09c17: 2 fields (1-2)
  r09c18: 2 fields (1-2)
  r09c19: 2 fields (1-2)
  r09c20: 2 fields (1-2)
  r09c21: 2 fields (1-2)
  r10c04: 2 fields (1-2)
  r10c05: 2 fields (1-2)
  r10c06: 2 fields (1-2)
  r10c07: 2 fields (1-2)
  r10c08: 2 fields (1-2)
  r10c09: 2 fields (1-2)
  r10c10: 2 fields (1-2)
  r10c11: 2 fields (1-2)
  r10c12: 2 fields (1-2)
  r10c13: 2 fields (1-2)
  r10c14: 2 fields (1-2)
  r10c15: 2 fields (1-2)
  r10c16: 2 fields (1-2)
  r10c17: 2 fields (1-2)
  r10c18: 2 fields (1-2)
  r10c19: 2 fields (1-2)
  r10c20: 2 fields (1-2)
  r10c21: 2 fields (1-2)
  r11c04: 2 fields (1-2)
  r11c05: 2 fields (1-2)
  r11c06: 2 fields (1-2)
  r11c07: 2 fields (1-2)
  r11c08: 2 fields (1-2)
  r11c09: 2 fields (1-2)
  r11c10: 2 fields (1-2)
  r11c11: 2 fields (1-2)
  r11c12: 2 fields (1-2)
  r11c13: 2 fields (1-2)
  r11c14: 2 fields (1-2)
  r11c15: 2 fields (1-2)
  r11c16: 2 fields (1-2)
  r11c17: 2 fields (1-2)
  r11c18: 2 fields (1-2)
  r11c19: 2 fields (1-2)
  r11c20: 2 fields (1-2)
  r11c21: 2 fields (1-2)
  r12c04: 2 fields (1-2)
  r12c05: 2 fields (1-2)
  r12c06: 2 fields (1-2)
  r12c07: 2 fields (1-2)
  r12c08: 2 fields (1-2)
  r12c09: 2 fields (1-2)
  r12c10: 2 fields (1-2)
  r12c11: 2 fields (1-2)
  r12c12: 2 fields (1-2)
  r12c13: 2 fields (1-2)
  r12c14: 2 fields (1-2)
  r12c15: 2 fields (1-2)
  r12c16: 2 fields (1-2)
  r12c17: 2 fields (1-2)
  r12c18: 2 fields (1-2)
  r12c19: 2 fields (1-2)
  r12c20: 2 fields (1-2)
  r12c21: 2 fields (1-2)
  r13c04: 2 fields (1-2)
  r13c05: 2 fields (1-2)
  r13c06: 2 fields (1-2)
  r13c07: 2 fields (1-2)
  r13c08: 2 fields (1-2)
  r13c09: 2 fields (1-2)
  r13c10: 2 fields (1-2)
  r13c11: 2 fields (1-2)
  r13c12: 2 fields (1-2)
  r13c13: 2 fields (1-2)
  r13c14: 2 fields (1-2)
  r13c15: 2 fields (1-2)
  r13c16: 2 fields (1-2)
  r13c17: 2 fields (1-2)
  r13c18: 2 fields (1-2)
  r13c19: 2 fields (1-2)
  r13c20: 2 fields (1-2)
  r13c21: 2 fields (1-2)

Timepoints: 32 (1-32)

Channels: 4
  Channel 1: CFP
  Channel 2: EGFP
  Channel 3: mStrawberry
  Channel 4: Brightfield

Z-planes: 3 (1-3)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 0.600 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: CAGE-timelapse
Well: r04c04

Data Shape: (32, 4, 3, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: CFP
    Excitation: 425 nm
    Emission: 456 nm
    Exposure: 0.2 s
  Channel 2: EGFP
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.08 s
  Channel 3: mStrawberry
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.08 s
  Channel 4: Brightfield
    Excitation: 740 nm
    Emission: 0 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
Z-slices: [1, 2, 3]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 0.600 µm
============================================================
INFO: Data loaded successfully!
INFO: Loading data for well r04c04...

============================================================
DATASET OVERVIEW
============================================================

Plate ID: CAGE-timelapse
Plate dimensions: 16 rows × 24 columns

Wells with data: 180
  Wells: 0404, 0405, 0406, 0407, 0408, 0409, 0410, 0411, 0412, 0413, 0414, 0415, 0416, 0417, 0418, 0419, 0420, 0421, 0504, 0505, 0506, 0507, 0508, 0509, 0510, 0511, 0512, 0513, 0514, 0515, 0516, 0517, 0518, 0519, 0520, 0521, 0604, 0605, 0606, 0607, 0608, 0609, 0610, 0611, 0612, 0613, 0614, 0615, 0616, 0617, 0618, 0619, 0620, 0621, 0704, 0705, 0706, 0707, 0708, 0709, 0710, 0711, 0712, 0713, 0714, 0715, 0716, 0717, 0718, 0719, 0720, 0721, 0804, 0805, 0806, 0807, 0808, 0809, 0810, 0811, 0812, 0813, 0814, 0815, 0816, 0817, 0818, 0819, 0820, 0821, 0904, 0905, 0906, 0907, 0908, 0909, 0910, 0911, 0912, 0913, 0914, 0915, 0916, 0917, 0918, 0919, 0920, 0921, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1120, 1121, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321

Fields per well:
  r04c04: 2 fields (1-2)
  r04c05: 2 fields (1-2)
  r04c06: 2 fields (1-2)
  r04c07: 2 fields (1-2)
  r04c08: 2 fields (1-2)
  r04c09: 2 fields (1-2)
  r04c10: 2 fields (1-2)
  r04c11: 2 fields (1-2)
  r04c12: 2 fields (1-2)
  r04c13: 2 fields (1-2)
  r04c14: 2 fields (1-2)
  r04c15: 2 fields (1-2)
  r04c16: 2 fields (1-2)
  r04c17: 2 fields (1-2)
  r04c18: 2 fields (1-2)
  r04c19: 2 fields (1-2)
  r04c20: 2 fields (1-2)
  r04c21: 2 fields (1-2)
  r05c04: 2 fields (1-2)
  r05c05: 2 fields (1-2)
  r05c06: 2 fields (1-2)
  r05c07: 2 fields (1-2)
  r05c08: 2 fields (1-2)
  r05c09: 2 fields (1-2)
  r05c10: 2 fields (1-2)
  r05c11: 2 fields (1-2)
  r05c12: 2 fields (1-2)
  r05c13: 2 fields (1-2)
  r05c14: 2 fields (1-2)
  r05c15: 2 fields (1-2)
  r05c16: 2 fields (1-2)
  r05c17: 2 fields (1-2)
  r05c18: 2 fields (1-2)
  r05c19: 2 fields (1-2)
  r05c20: 2 fields (1-2)
  r05c21: 2 fields (1-2)
  r06c04: 2 fields (1-2)
  r06c05: 2 fields (1-2)
  r06c06: 2 fields (1-2)
  r06c07: 2 fields (1-2)
  r06c08: 2 fields (1-2)
  r06c09: 2 fields (1-2)
  r06c10: 2 fields (1-2)
  r06c11: 2 fields (1-2)
  r06c12: 2 fields (1-2)
  r06c13: 2 fields (1-2)
  r06c14: 2 fields (1-2)
  r06c15: 2 fields (1-2)
  r06c16: 2 fields (1-2)
  r06c17: 2 fields (1-2)
  r06c18: 2 fields (1-2)
  r06c19: 2 fields (1-2)
  r06c20: 2 fields (1-2)
  r06c21: 2 fields (1-2)
  r07c04: 2 fields (1-2)
  r07c05: 2 fields (1-2)
  r07c06: 2 fields (1-2)
  r07c07: 2 fields (1-2)
  r07c08: 2 fields (1-2)
  r07c09: 2 fields (1-2)
  r07c10: 2 fields (1-2)
  r07c11: 2 fields (1-2)
  r07c12: 2 fields (1-2)
  r07c13: 2 fields (1-2)
  r07c14: 2 fields (1-2)
  r07c15: 2 fields (1-2)
  r07c16: 2 fields (1-2)
  r07c17: 2 fields (1-2)
  r07c18: 2 fields (1-2)
  r07c19: 2 fields (1-2)
  r07c20: 2 fields (1-2)
  r07c21: 2 fields (1-2)
  r08c04: 2 fields (1-2)
  r08c05: 2 fields (1-2)
  r08c06: 2 fields (1-2)
  r08c07: 2 fields (1-2)
  r08c08: 2 fields (1-2)
  r08c09: 2 fields (1-2)
  r08c10: 2 fields (1-2)
  r08c11: 2 fields (1-2)
  r08c12: 2 fields (1-2)
  r08c13: 2 fields (1-2)
  r08c14: 2 fields (1-2)
  r08c15: 2 fields (1-2)
  r08c16: 2 fields (1-2)
  r08c17: 2 fields (1-2)
  r08c18: 2 fields (1-2)
  r08c19: 2 fields (1-2)
  r08c20: 2 fields (1-2)
  r08c21: 2 fields (1-2)
  r09c04: 2 fields (1-2)
  r09c05: 2 fields (1-2)
  r09c06: 2 fields (1-2)
  r09c07: 2 fields (1-2)
  r09c08: 2 fields (1-2)
  r09c09: 2 fields (1-2)
  r09c10: 2 fields (1-2)
  r09c11: 2 fields (1-2)
  r09c12: 2 fields (1-2)
  r09c13: 2 fields (1-2)
  r09c14: 2 fields (1-2)
  r09c15: 2 fields (1-2)
  r09c16: 2 fields (1-2)
  r09c17: 2 fields (1-2)
  r09c18: 2 fields (1-2)
  r09c19: 2 fields (1-2)
  r09c20: 2 fields (1-2)
  r09c21: 2 fields (1-2)
  r10c04: 2 fields (1-2)
  r10c05: 2 fields (1-2)
  r10c06: 2 fields (1-2)
  r10c07: 2 fields (1-2)
  r10c08: 2 fields (1-2)
  r10c09: 2 fields (1-2)
  r10c10: 2 fields (1-2)
  r10c11: 2 fields (1-2)
  r10c12: 2 fields (1-2)
  r10c13: 2 fields (1-2)
  r10c14: 2 fields (1-2)
  r10c15: 2 fields (1-2)
  r10c16: 2 fields (1-2)
  r10c17: 2 fields (1-2)
  r10c18: 2 fields (1-2)
  r10c19: 2 fields (1-2)
  r10c20: 2 fields (1-2)
  r10c21: 2 fields (1-2)
  r11c04: 2 fields (1-2)
  r11c05: 2 fields (1-2)
  r11c06: 2 fields (1-2)
  r11c07: 2 fields (1-2)
  r11c08: 2 fields (1-2)
  r11c09: 2 fields (1-2)
  r11c10: 2 fields (1-2)
  r11c11: 2 fields (1-2)
  r11c12: 2 fields (1-2)
  r11c13: 2 fields (1-2)
  r11c14: 2 fields (1-2)
  r11c15: 2 fields (1-2)
  r11c16: 2 fields (1-2)
  r11c17: 2 fields (1-2)
  r11c18: 2 fields (1-2)
  r11c19: 2 fields (1-2)
  r11c20: 2 fields (1-2)
  r11c21: 2 fields (1-2)
  r12c04: 2 fields (1-2)
  r12c05: 2 fields (1-2)
  r12c06: 2 fields (1-2)
  r12c07: 2 fields (1-2)
  r12c08: 2 fields (1-2)
  r12c09: 2 fields (1-2)
  r12c10: 2 fields (1-2)
  r12c11: 2 fields (1-2)
  r12c12: 2 fields (1-2)
  r12c13: 2 fields (1-2)
  r12c14: 2 fields (1-2)
  r12c15: 2 fields (1-2)
  r12c16: 2 fields (1-2)
  r12c17: 2 fields (1-2)
  r12c18: 2 fields (1-2)
  r12c19: 2 fields (1-2)
  r12c20: 2 fields (1-2)
  r12c21: 2 fields (1-2)
  r13c04: 2 fields (1-2)
  r13c05: 2 fields (1-2)
  r13c06: 2 fields (1-2)
  r13c07: 2 fields (1-2)
  r13c08: 2 fields (1-2)
  r13c09: 2 fields (1-2)
  r13c10: 2 fields (1-2)
  r13c11: 2 fields (1-2)
  r13c12: 2 fields (1-2)
  r13c13: 2 fields (1-2)
  r13c14: 2 fields (1-2)
  r13c15: 2 fields (1-2)
  r13c16: 2 fields (1-2)
  r13c17: 2 fields (1-2)
  r13c18: 2 fields (1-2)
  r13c19: 2 fields (1-2)
  r13c20: 2 fields (1-2)
  r13c21: 2 fields (1-2)

Timepoints: 32 (1-32)

Channels: 4
  Channel 1: CFP
  Channel 2: EGFP
  Channel 3: mStrawberry
  Channel 4: Brightfield

Z-planes: 3 (1-3)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 0.600 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: CAGE-timelapse
Well: r04c04

Data Shape: (32, 4, 3, 1080, 2160)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: CFP
    Excitation: 425 nm
    Emission: 456 nm
    Exposure: 0.2 s
  Channel 2: EGFP
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.08 s
  Channel 3: mStrawberry
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.08 s
  Channel 4: Brightfield
    Excitation: 740 nm
    Emission: 0 nm
    Exposure: 0.1 s

Fields: [1, 2]
Timepoints: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
Z-slices: [1, 2, 3]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 0.600 µm

,*** Fields have been STITCHED ***
============================================================
#+end_example
:end:

*** preciscan test
- currently, ~zfsdata07~ needs to be mounted; my permissions don't work over
  sshfs to cb2
**** read metadata without loading data

#+BEGIN_SRC python
export_path = '/Volumes/Phenix-archive-2024-ro/Tracy/IOdataset/ds1_day9_0223/ds1day9IO-230213-2__2023-02-13T16_51_36-Measurement 1b/'
# Returns None for data, but prints all metadata
nonedata, testmetadata = load_phenix_data(
    export_path,
    stitch_fields=False,
    metadata_only=True
)

print(f"Shape would be: {testmetadata['shape']['dimensions']}")
#+END_SRC

#+RESULTS:
:results:
# [goto error]
#+begin_example
[31m---------------------------------------------------------------------------[39m
[31mFileNotFoundError[39m                         Traceback (most recent call last)
[36mCell[39m[36m [39m[32mIn[12][39m[32m, line 3[39m
[32m      1[39m export_path = [33m'[39m[33m/Volumes/Phenix-archive-2024-ro/Tracy/IOdataset/ds1_day9_0223/ds1day9IO-230213-2__2023-02-13T16_51_36-Measurement 1b/[39m[33m'[39m
[32m      2[39m [38;5;66;03m# Returns None for data, but prints all metadata[39;00m
[32m----> [39m[32m3[39m nonedata, testmetadata = [43mload_phenix_data[49m[43m([49m
[32m      4[39m [43m    [49m[43mexport_path[49m[43m,[49m
[32m      5[39m [43m    [49m[43mstitch_fields[49m[43m=[49m[38;5;28;43;01mFalse[39;49;00m[43m,[49m
[32m      6[39m [43m    [49m[43mmetadata_only[49m[43m=[49m[38;5;28;43;01mTrue[39;49;00m
[32m      7[39m [43m)[49m
[32m      9[39m [38;5;28mprint[39m([33mf[39m[33m"[39m[33mShape would be: [39m[38;5;132;01m{[39;00mtestmetadata[[33m'[39m[33mshape[39m[33m'[39m][[33m'[39m[33mdimensions[39m[33m'[39m][38;5;132;01m}[39;00m[33m"[39m)

[36mFile [39m[32m~/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/s1_pyphenix_functions.py:853[39m, in [36mload_phenix_data[39m[34m(experiment_path, **kwargs)[39m
[32m    835[39m [38;5;28;01mdef[39;00m[38;5;250m [39m[34mload_phenix_data[39m(experiment_path: [38;5;28mstr[39m, **kwargs) -> Tuple[np.ndarray, Dict]:
[32m    836[39m [38;5;250m    [39m[33;03m"""[39;00m
[32m    837[39m [33;03m    Convenience function to load Opera Phenix data.[39;00m
[32m    838[39m [33;03m    [39;00m
[32m   (...)[39m[32m    851[39m [33;03m        Metadata dictionary[39;00m
[32m    852[39m [33;03m    """[39;00m
[32m--> [39m[32m853[39m     reader = [43mOperaPhenixReader[49m[43m([49m[43mexperiment_path[49m[43m)[49m
[32m    854[39m     [38;5;28;01mreturn[39;00m reader.read_data(**kwargs)

[36mFile [39m[32m~/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/s1_pyphenix_functions.py:223[39m, in [36mOperaPhenixReader.__init__[39m[34m(self, experiment_path)[39m
[32m    220[39m [38;5;28mself[39m.index_xml_path = [38;5;28mself[39m.images_path / [33m"[39m[33mIndex.xml[39m[33m"[39m
[32m    222[39m [38;5;28;01mif[39;00m [38;5;129;01mnot[39;00m [38;5;28mself[39m.images_path.exists():
[32m--> [39m[32m223[39m     [38;5;28;01mraise[39;00m [38;5;167;01mFileNotFoundError[39;00m([33mf[39m[33m"[39m[33mImages directory not found: [39m[38;5;132;01m{[39;00m[38;5;28mself[39m.images_path[38;5;132;01m}[39;00m[33m"[39m)
[32m    224[39m [38;5;28;01mif[39;00m [38;5;129;01mnot[39;00m [38;5;28mself[39m.index_xml_path.exists():
[32m    225[39m     [38;5;28;01mraise[39;00m [38;5;167;01mFileNotFoundError[39;00m([33mf[39m[33m"[39m[33mIndex.xml not found: [39m[38;5;132;01m{[39;00m[38;5;28mself[39m.index_xml_path[38;5;132;01m}[39;00m[33m"[39m)

[31mFileNotFoundError[39m: Images directory not found: /Volumes/Phenix-archive-2024-ro/Tracy/IOdataset/ds1_day9_0223/ds1day9IO-230213-2__2023-02-13T16_51_36-Measurement 1b/Images
#+end_example
:end:



**** interactive loader and visualizer

#+BEGIN_SRC python

# Create interactive loader
viewer = create_phenix_data_loader(
    export_path
)

# The viewer will open with the widget on the right side
# Use the widget controls to select and visualize data
#+END_SRC

#+RESULTS:
#+begin_example
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
INFO: Data loaded successfully!
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
/Users/ferrin/Library/CloudStorage/GoogleDrive-ferrin@calicolabs.com/My Drive/software/s1_pyphenix/.venv/lib/python3.13/site-packages/napari/_vispy/layers/scalar_field.py:197: UserWarning: data shape (3, 1080, 2160) exceeds GL_MAX_TEXTURE_SIZE 2048 in at least one axis and will be downsampled. Rendering is currently in 3D mode.
  warnings.warn(
#+end_example
:results:
#+begin_example

============================================================
🎛️  INTERACTIVE DATA LOADER WIDGET INITIALIZED
============================================================
Experiment: CAGE-timelapse__2023-02-02T15_57_02-Measurement 2
Wells available: 180
Channels: 4
============================================================
INFO: Loading data for well r04c04...

============================================================
DATASET OVERVIEW
============================================================

Plate ID: CAGE-timelapse
Plate dimensions: 16 rows × 24 columns

Wells with data: 180
  Wells: 0404, 0405, 0406, 0407, 0408, 0409, 0410, 0411, 0412, 0413, 0414, 0415, 0416, 0417, 0418, 0419, 0420, 0421, 0504, 0505, 0506, 0507, 0508, 0509, 0510, 0511, 0512, 0513, 0514, 0515, 0516, 0517, 0518, 0519, 0520, 0521, 0604, 0605, 0606, 0607, 0608, 0609, 0610, 0611, 0612, 0613, 0614, 0615, 0616, 0617, 0618, 0619, 0620, 0621, 0704, 0705, 0706, 0707, 0708, 0709, 0710, 0711, 0712, 0713, 0714, 0715, 0716, 0717, 0718, 0719, 0720, 0721, 0804, 0805, 0806, 0807, 0808, 0809, 0810, 0811, 0812, 0813, 0814, 0815, 0816, 0817, 0818, 0819, 0820, 0821, 0904, 0905, 0906, 0907, 0908, 0909, 0910, 0911, 0912, 0913, 0914, 0915, 0916, 0917, 0918, 0919, 0920, 0921, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1120, 1121, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321

Fields per well:
  r04c04: 2 fields (1-2)
  r04c05: 2 fields (1-2)
  r04c06: 2 fields (1-2)
  r04c07: 2 fields (1-2)
  r04c08: 2 fields (1-2)
  r04c09: 2 fields (1-2)
  r04c10: 2 fields (1-2)
  r04c11: 2 fields (1-2)
  r04c12: 2 fields (1-2)
  r04c13: 2 fields (1-2)
  r04c14: 2 fields (1-2)
  r04c15: 2 fields (1-2)
  r04c16: 2 fields (1-2)
  r04c17: 2 fields (1-2)
  r04c18: 2 fields (1-2)
  r04c19: 2 fields (1-2)
  r04c20: 2 fields (1-2)
  r04c21: 2 fields (1-2)
  r05c04: 2 fields (1-2)
  r05c05: 2 fields (1-2)
  r05c06: 2 fields (1-2)
  r05c07: 2 fields (1-2)
  r05c08: 2 fields (1-2)
  r05c09: 2 fields (1-2)
  r05c10: 2 fields (1-2)
  r05c11: 2 fields (1-2)
  r05c12: 2 fields (1-2)
  r05c13: 2 fields (1-2)
  r05c14: 2 fields (1-2)
  r05c15: 2 fields (1-2)
  r05c16: 2 fields (1-2)
  r05c17: 2 fields (1-2)
  r05c18: 2 fields (1-2)
  r05c19: 2 fields (1-2)
  r05c20: 2 fields (1-2)
  r05c21: 2 fields (1-2)
  r06c04: 2 fields (1-2)
  r06c05: 2 fields (1-2)
  r06c06: 2 fields (1-2)
  r06c07: 2 fields (1-2)
  r06c08: 2 fields (1-2)
  r06c09: 2 fields (1-2)
  r06c10: 2 fields (1-2)
  r06c11: 2 fields (1-2)
  r06c12: 2 fields (1-2)
  r06c13: 2 fields (1-2)
  r06c14: 2 fields (1-2)
  r06c15: 2 fields (1-2)
  r06c16: 2 fields (1-2)
  r06c17: 2 fields (1-2)
  r06c18: 2 fields (1-2)
  r06c19: 2 fields (1-2)
  r06c20: 2 fields (1-2)
  r06c21: 2 fields (1-2)
  r07c04: 2 fields (1-2)
  r07c05: 2 fields (1-2)
  r07c06: 2 fields (1-2)
  r07c07: 2 fields (1-2)
  r07c08: 2 fields (1-2)
  r07c09: 2 fields (1-2)
  r07c10: 2 fields (1-2)
  r07c11: 2 fields (1-2)
  r07c12: 2 fields (1-2)
  r07c13: 2 fields (1-2)
  r07c14: 2 fields (1-2)
  r07c15: 2 fields (1-2)
  r07c16: 2 fields (1-2)
  r07c17: 2 fields (1-2)
  r07c18: 2 fields (1-2)
  r07c19: 2 fields (1-2)
  r07c20: 2 fields (1-2)
  r07c21: 2 fields (1-2)
  r08c04: 2 fields (1-2)
  r08c05: 2 fields (1-2)
  r08c06: 2 fields (1-2)
  r08c07: 2 fields (1-2)
  r08c08: 2 fields (1-2)
  r08c09: 2 fields (1-2)
  r08c10: 2 fields (1-2)
  r08c11: 2 fields (1-2)
  r08c12: 2 fields (1-2)
  r08c13: 2 fields (1-2)
  r08c14: 2 fields (1-2)
  r08c15: 2 fields (1-2)
  r08c16: 2 fields (1-2)
  r08c17: 2 fields (1-2)
  r08c18: 2 fields (1-2)
  r08c19: 2 fields (1-2)
  r08c20: 2 fields (1-2)
  r08c21: 2 fields (1-2)
  r09c04: 2 fields (1-2)
  r09c05: 2 fields (1-2)
  r09c06: 2 fields (1-2)
  r09c07: 2 fields (1-2)
  r09c08: 2 fields (1-2)
  r09c09: 2 fields (1-2)
  r09c10: 2 fields (1-2)
  r09c11: 2 fields (1-2)
  r09c12: 2 fields (1-2)
  r09c13: 2 fields (1-2)
  r09c14: 2 fields (1-2)
  r09c15: 2 fields (1-2)
  r09c16: 2 fields (1-2)
  r09c17: 2 fields (1-2)
  r09c18: 2 fields (1-2)
  r09c19: 2 fields (1-2)
  r09c20: 2 fields (1-2)
  r09c21: 2 fields (1-2)
  r10c04: 2 fields (1-2)
  r10c05: 2 fields (1-2)
  r10c06: 2 fields (1-2)
  r10c07: 2 fields (1-2)
  r10c08: 2 fields (1-2)
  r10c09: 2 fields (1-2)
  r10c10: 2 fields (1-2)
  r10c11: 2 fields (1-2)
  r10c12: 2 fields (1-2)
  r10c13: 2 fields (1-2)
  r10c14: 2 fields (1-2)
  r10c15: 2 fields (1-2)
  r10c16: 2 fields (1-2)
  r10c17: 2 fields (1-2)
  r10c18: 2 fields (1-2)
  r10c19: 2 fields (1-2)
  r10c20: 2 fields (1-2)
  r10c21: 2 fields (1-2)
  r11c04: 2 fields (1-2)
  r11c05: 2 fields (1-2)
  r11c06: 2 fields (1-2)
  r11c07: 2 fields (1-2)
  r11c08: 2 fields (1-2)
  r11c09: 2 fields (1-2)
  r11c10: 2 fields (1-2)
  r11c11: 2 fields (1-2)
  r11c12: 2 fields (1-2)
  r11c13: 2 fields (1-2)
  r11c14: 2 fields (1-2)
  r11c15: 2 fields (1-2)
  r11c16: 2 fields (1-2)
  r11c17: 2 fields (1-2)
  r11c18: 2 fields (1-2)
  r11c19: 2 fields (1-2)
  r11c20: 2 fields (1-2)
  r11c21: 2 fields (1-2)
  r12c04: 2 fields (1-2)
  r12c05: 2 fields (1-2)
  r12c06: 2 fields (1-2)
  r12c07: 2 fields (1-2)
  r12c08: 2 fields (1-2)
  r12c09: 2 fields (1-2)
  r12c10: 2 fields (1-2)
  r12c11: 2 fields (1-2)
  r12c12: 2 fields (1-2)
  r12c13: 2 fields (1-2)
  r12c14: 2 fields (1-2)
  r12c15: 2 fields (1-2)
  r12c16: 2 fields (1-2)
  r12c17: 2 fields (1-2)
  r12c18: 2 fields (1-2)
  r12c19: 2 fields (1-2)
  r12c20: 2 fields (1-2)
  r12c21: 2 fields (1-2)
  r13c04: 2 fields (1-2)
  r13c05: 2 fields (1-2)
  r13c06: 2 fields (1-2)
  r13c07: 2 fields (1-2)
  r13c08: 2 fields (1-2)
  r13c09: 2 fields (1-2)
  r13c10: 2 fields (1-2)
  r13c11: 2 fields (1-2)
  r13c12: 2 fields (1-2)
  r13c13: 2 fields (1-2)
  r13c14: 2 fields (1-2)
  r13c15: 2 fields (1-2)
  r13c16: 2 fields (1-2)
  r13c17: 2 fields (1-2)
  r13c18: 2 fields (1-2)
  r13c19: 2 fields (1-2)
  r13c20: 2 fields (1-2)
  r13c21: 2 fields (1-2)

Timepoints: 32 (1-32)

Channels: 4
  Channel 1: CFP
  Channel 2: EGFP
  Channel 3: mStrawberry
  Channel 4: Brightfield

Z-planes: 3 (1-3)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 0.600 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: CAGE-timelapse
Well: r04c04

Data Shape: (32, 4, 3, 1080, 1080)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: CFP
    Excitation: 425 nm
    Emission: 456 nm
    Exposure: 0.2 s
  Channel 2: EGFP
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.08 s
  Channel 3: mStrawberry
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.08 s
  Channel 4: Brightfield
    Excitation: 740 nm
    Emission: 0 nm
    Exposure: 0.1 s

Fields: [1]
Timepoints: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
Z-slices: [1, 2, 3]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 0.600 µm
============================================================
INFO: Data loaded successfully!
INFO: Loading data for well r04c04...

============================================================
DATASET OVERVIEW
============================================================

Plate ID: CAGE-timelapse
Plate dimensions: 16 rows × 24 columns

Wells with data: 180
  Wells: 0404, 0405, 0406, 0407, 0408, 0409, 0410, 0411, 0412, 0413, 0414, 0415, 0416, 0417, 0418, 0419, 0420, 0421, 0504, 0505, 0506, 0507, 0508, 0509, 0510, 0511, 0512, 0513, 0514, 0515, 0516, 0517, 0518, 0519, 0520, 0521, 0604, 0605, 0606, 0607, 0608, 0609, 0610, 0611, 0612, 0613, 0614, 0615, 0616, 0617, 0618, 0619, 0620, 0621, 0704, 0705, 0706, 0707, 0708, 0709, 0710, 0711, 0712, 0713, 0714, 0715, 0716, 0717, 0718, 0719, 0720, 0721, 0804, 0805, 0806, 0807, 0808, 0809, 0810, 0811, 0812, 0813, 0814, 0815, 0816, 0817, 0818, 0819, 0820, 0821, 0904, 0905, 0906, 0907, 0908, 0909, 0910, 0911, 0912, 0913, 0914, 0915, 0916, 0917, 0918, 0919, 0920, 0921, 1004, 1005, 1006, 1007, 1008, 1009, 1010, 1011, 1012, 1013, 1014, 1015, 1016, 1017, 1018, 1019, 1020, 1021, 1104, 1105, 1106, 1107, 1108, 1109, 1110, 1111, 1112, 1113, 1114, 1115, 1116, 1117, 1118, 1119, 1120, 1121, 1204, 1205, 1206, 1207, 1208, 1209, 1210, 1211, 1212, 1213, 1214, 1215, 1216, 1217, 1218, 1219, 1220, 1221, 1304, 1305, 1306, 1307, 1308, 1309, 1310, 1311, 1312, 1313, 1314, 1315, 1316, 1317, 1318, 1319, 1320, 1321

Fields per well:
  r04c04: 2 fields (1-2)
  r04c05: 2 fields (1-2)
  r04c06: 2 fields (1-2)
  r04c07: 2 fields (1-2)
  r04c08: 2 fields (1-2)
  r04c09: 2 fields (1-2)
  r04c10: 2 fields (1-2)
  r04c11: 2 fields (1-2)
  r04c12: 2 fields (1-2)
  r04c13: 2 fields (1-2)
  r04c14: 2 fields (1-2)
  r04c15: 2 fields (1-2)
  r04c16: 2 fields (1-2)
  r04c17: 2 fields (1-2)
  r04c18: 2 fields (1-2)
  r04c19: 2 fields (1-2)
  r04c20: 2 fields (1-2)
  r04c21: 2 fields (1-2)
  r05c04: 2 fields (1-2)
  r05c05: 2 fields (1-2)
  r05c06: 2 fields (1-2)
  r05c07: 2 fields (1-2)
  r05c08: 2 fields (1-2)
  r05c09: 2 fields (1-2)
  r05c10: 2 fields (1-2)
  r05c11: 2 fields (1-2)
  r05c12: 2 fields (1-2)
  r05c13: 2 fields (1-2)
  r05c14: 2 fields (1-2)
  r05c15: 2 fields (1-2)
  r05c16: 2 fields (1-2)
  r05c17: 2 fields (1-2)
  r05c18: 2 fields (1-2)
  r05c19: 2 fields (1-2)
  r05c20: 2 fields (1-2)
  r05c21: 2 fields (1-2)
  r06c04: 2 fields (1-2)
  r06c05: 2 fields (1-2)
  r06c06: 2 fields (1-2)
  r06c07: 2 fields (1-2)
  r06c08: 2 fields (1-2)
  r06c09: 2 fields (1-2)
  r06c10: 2 fields (1-2)
  r06c11: 2 fields (1-2)
  r06c12: 2 fields (1-2)
  r06c13: 2 fields (1-2)
  r06c14: 2 fields (1-2)
  r06c15: 2 fields (1-2)
  r06c16: 2 fields (1-2)
  r06c17: 2 fields (1-2)
  r06c18: 2 fields (1-2)
  r06c19: 2 fields (1-2)
  r06c20: 2 fields (1-2)
  r06c21: 2 fields (1-2)
  r07c04: 2 fields (1-2)
  r07c05: 2 fields (1-2)
  r07c06: 2 fields (1-2)
  r07c07: 2 fields (1-2)
  r07c08: 2 fields (1-2)
  r07c09: 2 fields (1-2)
  r07c10: 2 fields (1-2)
  r07c11: 2 fields (1-2)
  r07c12: 2 fields (1-2)
  r07c13: 2 fields (1-2)
  r07c14: 2 fields (1-2)
  r07c15: 2 fields (1-2)
  r07c16: 2 fields (1-2)
  r07c17: 2 fields (1-2)
  r07c18: 2 fields (1-2)
  r07c19: 2 fields (1-2)
  r07c20: 2 fields (1-2)
  r07c21: 2 fields (1-2)
  r08c04: 2 fields (1-2)
  r08c05: 2 fields (1-2)
  r08c06: 2 fields (1-2)
  r08c07: 2 fields (1-2)
  r08c08: 2 fields (1-2)
  r08c09: 2 fields (1-2)
  r08c10: 2 fields (1-2)
  r08c11: 2 fields (1-2)
  r08c12: 2 fields (1-2)
  r08c13: 2 fields (1-2)
  r08c14: 2 fields (1-2)
  r08c15: 2 fields (1-2)
  r08c16: 2 fields (1-2)
  r08c17: 2 fields (1-2)
  r08c18: 2 fields (1-2)
  r08c19: 2 fields (1-2)
  r08c20: 2 fields (1-2)
  r08c21: 2 fields (1-2)
  r09c04: 2 fields (1-2)
  r09c05: 2 fields (1-2)
  r09c06: 2 fields (1-2)
  r09c07: 2 fields (1-2)
  r09c08: 2 fields (1-2)
  r09c09: 2 fields (1-2)
  r09c10: 2 fields (1-2)
  r09c11: 2 fields (1-2)
  r09c12: 2 fields (1-2)
  r09c13: 2 fields (1-2)
  r09c14: 2 fields (1-2)
  r09c15: 2 fields (1-2)
  r09c16: 2 fields (1-2)
  r09c17: 2 fields (1-2)
  r09c18: 2 fields (1-2)
  r09c19: 2 fields (1-2)
  r09c20: 2 fields (1-2)
  r09c21: 2 fields (1-2)
  r10c04: 2 fields (1-2)
  r10c05: 2 fields (1-2)
  r10c06: 2 fields (1-2)
  r10c07: 2 fields (1-2)
  r10c08: 2 fields (1-2)
  r10c09: 2 fields (1-2)
  r10c10: 2 fields (1-2)
  r10c11: 2 fields (1-2)
  r10c12: 2 fields (1-2)
  r10c13: 2 fields (1-2)
  r10c14: 2 fields (1-2)
  r10c15: 2 fields (1-2)
  r10c16: 2 fields (1-2)
  r10c17: 2 fields (1-2)
  r10c18: 2 fields (1-2)
  r10c19: 2 fields (1-2)
  r10c20: 2 fields (1-2)
  r10c21: 2 fields (1-2)
  r11c04: 2 fields (1-2)
  r11c05: 2 fields (1-2)
  r11c06: 2 fields (1-2)
  r11c07: 2 fields (1-2)
  r11c08: 2 fields (1-2)
  r11c09: 2 fields (1-2)
  r11c10: 2 fields (1-2)
  r11c11: 2 fields (1-2)
  r11c12: 2 fields (1-2)
  r11c13: 2 fields (1-2)
  r11c14: 2 fields (1-2)
  r11c15: 2 fields (1-2)
  r11c16: 2 fields (1-2)
  r11c17: 2 fields (1-2)
  r11c18: 2 fields (1-2)
  r11c19: 2 fields (1-2)
  r11c20: 2 fields (1-2)
  r11c21: 2 fields (1-2)
  r12c04: 2 fields (1-2)
  r12c05: 2 fields (1-2)
  r12c06: 2 fields (1-2)
  r12c07: 2 fields (1-2)
  r12c08: 2 fields (1-2)
  r12c09: 2 fields (1-2)
  r12c10: 2 fields (1-2)
  r12c11: 2 fields (1-2)
  r12c12: 2 fields (1-2)
  r12c13: 2 fields (1-2)
  r12c14: 2 fields (1-2)
  r12c15: 2 fields (1-2)
  r12c16: 2 fields (1-2)
  r12c17: 2 fields (1-2)
  r12c18: 2 fields (1-2)
  r12c19: 2 fields (1-2)
  r12c20: 2 fields (1-2)
  r12c21: 2 fields (1-2)
  r13c04: 2 fields (1-2)
  r13c05: 2 fields (1-2)
  r13c06: 2 fields (1-2)
  r13c07: 2 fields (1-2)
  r13c08: 2 fields (1-2)
  r13c09: 2 fields (1-2)
  r13c10: 2 fields (1-2)
  r13c11: 2 fields (1-2)
  r13c12: 2 fields (1-2)
  r13c13: 2 fields (1-2)
  r13c14: 2 fields (1-2)
  r13c15: 2 fields (1-2)
  r13c16: 2 fields (1-2)
  r13c17: 2 fields (1-2)
  r13c18: 2 fields (1-2)
  r13c19: 2 fields (1-2)
  r13c20: 2 fields (1-2)
  r13c21: 2 fields (1-2)

Timepoints: 32 (1-32)

Channels: 4
  Channel 1: CFP
  Channel 2: EGFP
  Channel 3: mStrawberry
  Channel 4: Brightfield

Z-planes: 3 (1-3)

Image dimensions: 1080 × 1080 pixels
Pixel size: 0.297 × 0.297 µm
Z-step: 0.600 µm
============================================================

============================================================
LOADED DATA SUMMARY
============================================================

Plate ID: CAGE-timelapse
Well: r04c04

Data Shape: (32, 4, 3, 1080, 2160)
  Dimension order: T, C, Z, Y, X

Channels:
  Channel 1: CFP
    Excitation: 425 nm
    Emission: 456 nm
    Exposure: 0.2 s
  Channel 2: EGFP
    Excitation: 488 nm
    Emission: 522 nm
    Exposure: 0.08 s
  Channel 3: mStrawberry
    Excitation: 561 nm
    Emission: 599 nm
    Exposure: 0.08 s
  Channel 4: Brightfield
    Excitation: 740 nm
    Emission: 0 nm
    Exposure: 0.1 s

Fields: [1, 2]
Timepoints: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32]
Z-slices: [1, 2, 3]

Physical Dimensions:
  Pixel size (X): 0.297 µm
  Pixel size (Y): 0.297 µm
  Z step: 0.600 µm

,*** Fields have been STITCHED ***
============================================================
#+end_example
:end:
