Objective

Determine the actual section heights of a stepped-height microfluidic channel by fitting Beer–Lambert absorbance measurements acquired at five Allura Red concentrations under the microscope's 510/10 nm narrow-bandpass filter. Simultaneously recover the camera's quadratic nonlinearity parameter η from the channel data and anchor the absolute height scale using the 80 µm capillary reference geometry.

Background

Beer–Lambert law and transmittance

For a monochromatic beam of wavelength λ passing through a solution of molar concentration C [mol/L] over an optical path length h [cm], the monochromatic transmittance is:

T(λ) = I(λ) / I₀(λ) = 10−ε(λ)·C·h

where ε(λ) [L mol⁻¹ cm⁻¹] is the molar absorptivity (a property of the dye at wavelength λ), I₀(λ) is the incident intensity, and I(λ) is the transmitted intensity. The absorbance is:

A(λ) = −log₁₀ T(λ) = ε(λ)·C·h

Absorbance is proportional to both concentration and path length — this is the Beer–Lambert law.

Measured transmittance and absorbance

Images are dark-subtracted and normalised by a light (no-dye) reference:

T(x,y) = max(0, [I_dye(x,y) − I_dark(x,y)] / [I_light(x,y) − I_dark(x,y)])

The dark subtraction removes camera offset and fixed-pattern noise; division by the light reference removes spatial non-uniformity in illumination and pixel-to-pixel sensitivity variation. The max(0,·) enforces the physical constraint that transmittance cannot be negative. No pixel of T should be below 0.

Averaging T over a region of interest (ROI) and converting to absorbance:

A_meas = −log₁₀( ⟨T⟩_ROI )

Why the illumination spectrum doesn't matter.
The pixel signal for any channel of the camera is I ∝ ∫ I_source(λ)·R(λ)·F(λ)·T(λ) dλ, where I_source(λ) is the lamp spectrum, R(λ) is the camera channel's spectral response, and F(λ) is the 510/10 nm filter transmittance. In the transmittance ratio, I_source(λ)·R(λ) appears identically in numerator and denominator and cancels exactly. Within the 10 nm passband, I_source and R are both approximately flat, so T_meas reduces to the monochromatic Beer–Lambert transmittance. No knowledge of the lamp spectrum is required.

No Nanodrop spectrum or filter transmittance profile is used in the analysis. The Nanodrop spectra acquired in this session were examined qualitatively to confirm that Allura Red's absorption peak overlaps the filter passband — they are not used quantitatively.

Empirical forward model

The 510/10 nm filter has a ~10 nm FWHM bandwidth. The polychromatic Beer–Lambert correction for Allura Red × 510/10 is less than 0.5% across the full concentration and height range of interest. Beer–Lambert is therefore treated as linear in the product C·h, and the effective absorptivity s is determined empirically from the capillary calibration rather than from tabulated molar absorptivity values.

Converting to units convenient for this experiment (C in mM = 10⁻³ mol/L, h in µm = 10⁻⁴ cm), Beer–Lambert becomes:

A = ε·C·h ≡ s·C·h

where s ≡ ε × 10⁻³ mol/L per mM × 10⁻⁴ cm/µm is the effective absorptivity scale in AU/(mM·µm). In practice s also absorbs filter efficiency and camera quantum efficiency, so it is fit empirically from capillary measurements at a known path length rather than calculated from ε.

A real camera may exhibit a small quadratic nonlinearity. Parameterising this as:

A_pred(C, h) = s·C·h · [1 + η·(C·h / P_ref − 1)] ↑ A_lin

where P_ref [mM·µm] is the C·h product at the reference condition (chosen as 1.0 mM × 80 µm = 80 mM·µm). At C·h = P_ref the correction factor equals 1 (no correction); η = 0 recovers pure Beer–Lambert.

SymbolUnitsMeaning
CmMdye concentration
hµmoptical path length (channel or capillary depth)
sAU/(mM·µm)effective absorptivity scale; absorbs ε, filter efficiency, camera sensitivity
η—quadratic camera nonlinearity parameter (0 = linear)
P_refmM·µmreference C·h product; sets the pivot where η correction vanishes
In code
P_REF = 1.0 * 80.0    # mM·µm  (1.0 mM × 80 µm capillary reference)

def A_pred(C_mM, h_um, s, eta, _pref=P_REF):
    """Empirical Beer-Lambert: A = s·C·h·(1 + eta·(C·h/P_REF − 1))."""
    p   = C_mM * h_um        # mM·µm
    A_l = s * p               # linear Beer-Lambert
    return A_l * (1.0 + eta * (p / _pref - 1.0))

Camera hardware

The camera produces 16-bit-per-channel RGB images (48-bit total). The ADC is 14-bit, stored left-aligned in the upper 14 bits of each 16-bit word — the 2 least significant bits carry no information.

  • Raw saturated pixel value: 16383 × 4 = 65532
  • Right-shift averaged values by 2 (divide by 4) to recover 0–16383 range.

Camera nonlinearity and scale degeneracy

With five concentrations spanning a 13× dynamic range (0.375–5 mM), η can be determined from the channel data alone by joint least-squares fitting (Stage 7). η from Stage 6 (capillary only, 3 DoF) provides an independent cross-check.

Beer–Lambert absorbance depends only on the product C·h, so a uniform scale error δ in all nominal concentrations aliases perfectly into the same fractional error in all fitted heights. The channel fit recovers correct height ratios and η regardless, but absolute heights require an independent anchor. The capillary at known L = 80 µm provides this: fitting s from capillary data at known path length pins the C·h product scale and thereby converts fitted height ratios into absolute microns.

Dataset Description

Directory structure

notebooks/2026-08-31_allura_red/          ← working directory for all notebooks
│
├── 2026-08-31_allura_16bit/              ← raw images (do not modify)
│   ├── capillary/
│   │   ├── light/                        ← illumination reference, 5 TIFFs
│   │   ├── dark/
│   │   ├── 0.375mM/  1.0mM/  1.5mM/  2.5mM/  5mM/
│   └── device_1/
│       ├── lights/
│       │   ├── pre-all/                  ← illumination reference before any dye
│       │   ├── post_0.375/  post_1.0/  post_1.5/  post_2.5/  post_5.0/
│       ├── dark/
│       └── dye/
│           ├── 0.375mM/  1.0mM/  1.5mM/  2.5mM/  5.0mM/
│                                                    ↑ note decimal (5.0mM, not 5mM)
│
└── 2026-08-31_allura_ave_14bit/          ← processed images (CREATED by pipeline)
    ├── capillary/
    │   ├── light/mean.tif  dark/mean.tif
    │   └── 0.375mM/  1.0mM/  1.5mM/  2.5mM/  5mM/
    └── device_1/
        ├── light_pre/mean.tif
        ├── light_post_0.375/mean.tif  light_post_1.0/mean.tif
        ├── light_post_1.5/mean.tif   light_post_2.5/mean.tif  light_post_5.0/mean.tif
        ├── dark/mean.tif
        └── 0.375mM/  1.0mM/  1.5mM/  2.5mM/  5.0mM/

Important naming difference: the capillary raw directory uses 5mM (no decimal); the device_1 raw directory uses 5.0mM. The processed (ave_14bit) directories mirror this distinction.

Condition key lists
CAP_KEYS = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5mM"]    # capillary
DEV_KEYS = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5.0mM"]  # device_1

Image specifications

PropertyValue
Format16-bit-per-channel RGB TIFF (48-bit total)
Significant bits per channel14 — stored left-aligned; max raw value = 65532
Images per condition5 (averaged in Stages 2 & 4)
Averaged output formatuint16 RGB TIFF, right-shifted by 2 (values 0–16383)
ChannelsRed (ignored), Green (signal), Blue (signal; compared in Stage 5a)
Filter510/10 nm narrow bandpass

JSON key names

capillary_crop.json — uses keys x0, y0, w, h (not width/height).

channel_rois.json — uses keys x0, y0, w, h in both crop and each section entry. Section ROI coordinates are relative to the crop origin. The capillary images are already cropped in Stage 2 — use the full processed image as the capillary ROI.

Absorbance Coverage

Target measurement window: 0.05–0.80 AU (lower bound keeps shallow channels above the noise floor; upper bound avoids detector saturation and stray-light nonlinearity). Measurements outside this window are not excluded — they enter the joint fit with reduced statistical weight (see Stage 7).

The five concentrations (0.375, 1.0, 1.5, 2.5, 5.0 mM) were chosen so that the middle two (1.0 and 1.5 mM) fall in-window at all eight heights, while the extremes (0.375 and 5.0 mM) extend coverage toward the edges of the range. The 6.7× concentration span (0.375–2.5 mM) provides strong leverage on η.

Degrees of freedom — joint {hi, η} fit

Ns = 8 sections; s fixed from capillary calibration.

Concentrations usedEquationsUnknownsDoF
189−1 (underdetermined)
2169+7
4329+23
5409+31

Analysis Pipeline

marimo skill requirement
Before creating, editing, or running any marimo notebook (.py file containing import marimo or marimo.App), invoke the marimo-pair skill via the Skill tool: Skill("marimo-pair").
All notebook mutations must go through the execute-code.sh script — never through direct Edit/Write on the .py file while the server is running.

The pipeline divides into four notebooks. Stages 1–4 prepare the data; Stages 5–8 perform the physics analysis.

Stage 1

Capillary crop ROI notebook — capillary_roi.py

Goal: interactively define the crop rectangle for capillary images and save coordinates to capillary_crop.json.

cd notebooks/2026-08-31_allura_red
uv run marimo edit --sandbox --no-token capillary_roi.py

Notebook cells:

  1. Imports — numpy, matplotlib, tifffile, pathlib, json.
  2. Load and average the 5 light TIFFs from 2026-08-31_allura_16bit/capillary/light/. Right-shift by 2 during averaging to recover 14-bit values.
  3. Interactive crop ROI — x0, y0, w, h number inputs. Show the cropped green-channel preview to verify uniform illumination.
  4. Save capillary_crop.json using on_click callback (not the value attribute). Closure variables must be captured as explicit default arguments (_cx0=cx0 etc.) — referencing outer variables directly breaks after marimo reactive re-execution.
def _do_save(_v, _cx0=cx0, _cy0=cy0, _cw=cw, _ch=ch):
    roi = {"x0": _cx0, "y0": _cy0, "w": _cw, "h": _ch}
    pathlib.Path("capillary_crop.json").write_text(json.dumps(roi, indent=2))
save_btn = mo.ui.button(label="Save", on_click=_do_save)
Stage 2

Capillary image processing — in process_images.py

Goal: crop every capillary image, average the 5 per condition, right-shift by 2, write one uint16 RGB TIFF per condition to 2026-08-31_allura_ave_14bit/capillary/<cond>/mean.tif.

def process_condition(src_dir, dst_path, x0, y0, w, h):
    files = sorted(src_dir.glob("*.tif"))
    assert len(files) == 5
    stack = np.stack([
        tifffile.imread(str(f)).astype(np.float32)[y0:y0+h, x0:x0+w, :]
        for f in files
    ])                          # (5, h, w, 3) float32
    if (stack >= 65532).any():
        print(f"WARNING: saturated pixels in {src_dir.name}")
    avg = stack.mean(axis=0)    # (h, w, 3) float32
    out = (avg / 4).astype(np.uint16)   # right-shift → 0–16383
    dst_path.parent.mkdir(parents=True, exist_ok=True)
    tifffile.imwrite(str(dst_path), out)
    return out

The capillary processed images are already cropped (shape = crop region H×W×3). In Stage 5, use the full processed image as the capillary integration ROI — do not re-index with the original crop coordinates.

Stage 3

Channel crop and section ROI notebook — channel_roi.py

Goal: define (a) overall crop rectangle for channel images and (b) 8 section ROIs within the cropped coordinate space. Save to channel_rois.json.

uv run marimo edit --sandbox --no-token channel_roi.py

Key implementation notes:

  • Load from 2026-08-31_allura_16bit/device_1/lights/pre-all/ (not channel/light/)
  • Show pixel-labeled axes using extent=[-0.5, cw-0.5, ch-0.5, -0.5] with origin="upper"
  • Use mo.ui.dictionary (nested) for section ROI inputs — plain Python dicts are not tracked by marimo's reactive graph
  • Save button: same on_click with default-argument capture as Stage 1
Output JSON structure
{
  "crop": {"x0": 850, "y0": 950, "w": 1000, "h": 250},
  "sections": {
    "30um":  {"x0": 780, "y0": 85, "w": 160, "h": 75},
    "40um":  {"x0": 680, "y0": 85, "w":  60, "h": 75},
    ...
    "200um": {"x0":  30, "y0": 85, "w":  90, "h": 75}
  }
}

All section coordinates are relative to the crop origin (crop-relative pixels).

Stage 4

Channel image processing — in process_images.py

Same process_condition() function as Stage 2; load crop from channel_rois.json["crop"]. Process all six light conditions plus dark and the five dye concentrations:

DEV_CONDITIONS = {
    # All six light images — compared in Stage 4a to detect adsorption
    "light_pre":        DEV_RAW_DIR / "lights" / "pre-all",
    "light_post_0.375": DEV_RAW_DIR / "lights" / "post_0.375",
    "light_post_1.0":   DEV_RAW_DIR / "lights" / "post_1.0",
    "light_post_1.5":   DEV_RAW_DIR / "lights" / "post_1.5",
    "light_post_2.5":   DEV_RAW_DIR / "lights" / "post_2.5",
    "light_post_5.0":   DEV_RAW_DIR / "lights" / "post_5.0",
    # Dark and dye
    "dark":    DEV_RAW_DIR / "dark",
    "0.375mM": DEV_RAW_DIR / "dye" / "0.375mM",
    "1.0mM":   DEV_RAW_DIR / "dye" / "1.0mM",
    "1.5mM":   DEV_RAW_DIR / "dye" / "1.5mM",
    "2.5mM":   DEV_RAW_DIR / "dye" / "2.5mM",
    "5.0mM":   DEV_RAW_DIR / "dye" / "5.0mM",
}

Each output lands in 2026-08-31_allura_ave_14bit/device_1/<key>/mean.tif.

Stage 4a

Adsorption diagnostic — adsorption_check.py

Goal: determine whether dye is adsorbing onto channel surfaces by comparing all six light images. Adsorption would cause the effective optical path length to be shorter than the physical channel height, biasing absorbance upward and height estimates downward.

Surface passivation context. The stepped-height channel was treated with BSA (bovine serum albumin) prior to imaging to passivate the channel surfaces and suppress non-specific dye adsorption. All surfaces are polymerized polyethylene glycol diacrylate (PEGDA), 3D-printed with a custom resin formulation on a custom printer. This stage checks how effective that passivation is.

Physical picture: between each dye run the channel is flushed with buffer and a new light image (post_*) is acquired. If dye adsorbs to the PEGDA surfaces, those surfaces remain absorbing even without dissolved dye. A post-dye light image will then show reduced intensity compared to pre-all.

Procedure

  1. Load all six averaged light images from ave_14bit/device_1/:
    LIGHT_KEYS = ["light_pre", "light_post_0.375", "light_post_1.0",
                  "light_post_1.5", "light_post_2.5", "light_post_5.0"]
    lights = {k: load_mean("device_1", k) for k in LIGHT_KEYS}
    dark   = load_mean("device_1", "dark")
  2. For each section ROI and each light key, compute the dark-subtracted mean green-channel signal:
    lights_mean = {
        sk: {lk: roi_mean(lights[lk], SEC_ROIS[sk], "green")
             for lk in LIGHT_KEYS}
        for sk in SEC_KEYS
    }
  3. Normalise to light_pre:
    lights_ratio = {
        sk: {lk: lights_mean[sk][lk] / lights_mean[sk]["light_pre"]
             for lk in LIGHT_KEYS}
        for sk in SEC_KEYS
    }
  4. Quantitative criterion — RMS fractional deviation of all post_* values from 1.0:
    all_ratios = [lights_ratio[sk][lk]
                  for sk in SEC_KEYS for lk in LIGHT_KEYS[1:]]
    rms_drift  = float(np.sqrt(np.mean([(r - 1.0)**2 for r in all_ratios])))

    Decision threshold: rms_drift < 0.005 (0.5%) → use light_pre for all concentrations. If ≥ 0.005 → use matched light references (below).

If adsorption is detected (rms_drift ≥ 0.005)

Assign the light image representing channel state during each dye measurement. The post-dye light taken immediately before a given concentration is the closest proxy:

Dye concentrationBest light reference
0.375 mMlight_pre
1.0 mMlight_post_0.375
1.5 mMlight_post_1.0
2.5 mMlight_post_1.5
5.0 mMlight_post_2.5
LIGHT_REF = {   # matched light reference for each dye concentration
    "0.375mM": lights["light_pre"],
    "1.0mM":   lights["light_post_0.375"],
    "1.5mM":   lights["light_post_1.0"],
    "2.5mM":   lights["light_post_1.5"],
    "5.0mM":   lights["light_post_2.5"],
}
Stage 5

Load averaged images and build helpers — analysis.py

uv run marimo edit --sandbox --no-token analysis.py
Empirical model cell
P_REF = 1.0 * 80.0    # mM·µm  (pivot: 1.0 mM × 80 µm)

def A_pred(C_mM, h_um, s, eta, _pref=P_REF):
    p   = C_mM * h_um
    A_l = s * p
    return A_l * (1.0 + eta * (p / _pref - 1.0))
Constants and image loader
ADC_OUT_MAX = 16383
CH          = {"red": 0, "green": 1, "blue": 2}
L_CAP_UM    = 80.0
HEIGHTS     = np.array([30, 40, 50, 70, 90, 120, 150, 200], dtype=float)
CONCS_mM    = np.array([0.375, 1.0, 1.5, 2.5, 5.0])
A_LO, A_HI  = 0.05, 0.80
CAP_KEYS    = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5mM"]
DEV_KEYS    = ["0.375mM", "1.0mM", "1.5mM", "2.5mM", "5.0mM"]

AVE_DIR = pathlib.Path("2026-08-31_allura_ave_14bit")

def load_mean(geo, cond):
    return tifffile.imread(str(AVE_DIR / geo / cond / "mean.tif")).astype(np.float32)

LIGHT_KEYS = ["light_pre", "light_post_0.375", "light_post_1.0",
              "light_post_1.5", "light_post_2.5", "light_post_5.0"]

cap = {c: load_mean("capillary", c) for c in ["light", "dark"] + CAP_KEYS}
dev = {c: load_mean("device_1",  c)
       for c in LIGHT_KEYS + ["dark"] + DEV_KEYS}

# Default to pre-all; updated after Stage 4a
LIGHT_REF = {k: dev["light_pre"] for k in DEV_KEYS}
Transmittance helper
def transmittance(I_dye, I_light, I_dark, ch_name, floor=1e-6):
    d = I_dye  [:, :, CH[ch_name]].astype(float)
    l = I_light[:, :, CH[ch_name]].astype(float)
    k = I_dark [:, :, CH[ch_name]].astype(float)
    denom = l - k
    valid = (denom > 0.01 * np.nanmax(denom)) & (l < ADC_OUT_MAX) & (d < ADC_OUT_MAX)
    T_raw = np.where(valid, (d - k) / denom, np.nan)
    return np.clip(T_raw, floor, 1.0)

valid masks pixels where T is undefined: near-zero denominator (dark region or dead pixel), or saturated light/dye pixel. Those become np.nan and are excluded by np.nanmean downstream. np.clip caps T at 1 (noise can push dye slightly above light) and raises the floor to floor (prevents log10(0)); NaN pixels pass through clip unchanged.

Stage 5a

Channel comparison — green vs. blue

Goal: select which channel to use for Stages 6–7. Use capillary data (known L = 80 µm) to compare linearity and noise.

The capillary processed images are already cropped — use the full image:

def cap_absorbance(cond_key, ch_name):
    T = transmittance(cap[cond_key], cap["light"], cap["dark"], ch_name)
    return -np.log10(np.nanmean(T))   # full image = capillary ROI

A_cap_green = np.array([cap_absorbance(c, "green") for c in CAP_KEYS])
A_cap_blue  = np.array([cap_absorbance(c, "blue")  for c in CAP_KEYS])

P_cap   = CONCS_mM * L_CAP_UM          # C·h products at capillary [mM·µm]
s_lin_g = np.dot(A_cap_green, P_cap) / np.dot(P_cap, P_cap)
A_lin_g = s_lin_g * P_cap

Also compute per-channel noise: σA = σT / (T̄ · ln 10).

Decision rule: select the channel with lower RMS residual from its linear fit (and lower σA). If equal, use green (conventional). Set ACTIVE_CH = "green" (or "blue") for all subsequent stages.

Stage 6

Capillary calibration — fit (s, η)

Goal: fit the effective absorptivity scale s and camera nonlinearity ηcap from the 5 capillary measurements at known L = 80 µm. This uses only 2 free parameters for 5 measurements → 3 DoF.

def cap_cost_emp(params):
    s, eta = params
    return sum((A_cap_green[k] - A_pred(CONCS_mM[k], L_CAP_UM, s, eta))**2
               for k in range(len(CONCS_mM)))

_s0 = np.dot(A_cap_green, P_cap) / np.dot(P_cap, P_cap)
res = minimize(cap_cost_emp, x0=[_s0, 0.0], method="Nelder-Mead",
               options={"xatol": 1e-9, "fatol": 1e-14, "maxiter": 100000})
s_emp, eta_emp = float(res.x[0]), float(res.x[1])

s absorbs all unknown proportionality factors (molar absorptivity ε, filter efficiency, camera quantum efficiency, any systematic concentration offset). It is the only absolute reference tying the height scale to physical microns. η_emp from capillary provides an independent cross-check for Stage 7.

Stage 7

Joint channel height + nonlinearity fit

Goal: fit 8 section heights and η jointly from the 5×8 channel absorbance matrix. Use s from Stage 6 to anchor the absolute scale (31 DoF).

Assemble measurement matrix
A_meas_ch = np.zeros((8, 5))
for i, sk in enumerate(SEC_KEYS):
    s_roi = SEC_ROIS[sk]
    for k, ck in enumerate(DEV_KEYS):
        T = transmittance(dev[ck], LIGHT_REF[ck], dev["dark"], ACTIVE_CH)
        T_roi = T[s_roi["y0"]:s_roi["y0"]+s_roi["h"],
                  s_roi["x0"]:s_roi["x0"]+s_roi["w"]]
        A_meas_ch[i, k] = -np.log10(np.nanmean(T_roi))
Joint fit — 9 unknowns (8 heights + η); s fixed from Stage 6
def weight7(A):
    if A_LO <= A <= A_HI:
        return 1.0
    return (0.003 / max(abs(A - 0.5*(A_LO + A_HI)), 0.3))**2

def cost7(params):
    h_vec, eta = params[:8], params[8]
    total = 0.0
    for i in range(8):
        for k, c in enumerate(CONCS_mM):
            A_p = A_pred(c, abs(h_vec[i]), s_emp, eta)
            total += weight7(A_meas_ch[i, k]) * (A_meas_ch[i, k] - A_p)**2
    return total

result = minimize(cost7, x0=np.append(HEIGHTS.copy(), eta_emp),
                  method="Nelder-Mead",
                  options={"xatol": 1e-5, "fatol": 1e-12,
                            "maxiter": 200000, "adaptive": True})
h_abs   = np.abs(result.x[:8])
eta_fit = float(result.x[8])

No separate scale-anchoring step is needed. Because s is already determined from the capillary at known L = 80 µm, the fitted h_abs values are already in physical microns.

Expected results: η_fit from channel data may differ slightly from η_emp (capillary alone) because the channel spans a wider absorbance range; both values should be small (|η| < 0.1). h_abs values should be close to nominal HEIGHTS; deviations reflect actual fabrication geometry.

Stage 8

Diagnostics, cross-validation, and reporting

  1. Residual heatmap (8×5) — A_meas_ch − A_pred(Ck, h_abs[i], s_emp, η_fit)
  2. η agreement — compare η_fit vs. η_emp; large disagreement warrants investigation
  3. Height comparison — scatter h_abs vs. nominal HEIGHTS; include 1:1 line and ±5 µm band
  4. Coverage map — in-range / out-of-range indicator for all 40 measurements
  5. Optional cross-check — if both channels looked good in Stage 5a, repeat Stage 7 with the other channel and compare h_abs values

Key figures

  1. Channel comparison: A_meas_green and A_meas_blue vs. linear model (capillary)
  2. Averaged green-channel light image with crop + 8 section ROI boxes
  3. A_meas vs. concentration for each section (measured points + fitted model)
  4. Fitted vs. nominal heights (scatter with 1:1 line and ±5 µm band)
  5. Residuals heatmap (8 sections × 5 concentrations)

Key Implementation Notes

IssueNote
RGB images, not grayscale Raw TIFFs are 16-bit-per-channel RGB (shape H×W×3). Always index channel explicitly: img[:,:,1] = green, img[:,:,2] = blue.
14-bit ADC, left-aligned Raw saturated pixel = 65532 (= 16383 × 4). Right-shift averaged float by 4 (/ 4, cast to uint16) to recover 0–16383.
Capillary images already cropped Stage 2 crops capillary images. In Stage 5, use the full processed image as the capillary ROI — do not re-index with the original raw-pixel crop coordinates.
Light reference selection Use LIGHT_REF[ck] (not a single dev["light"]) when computing transmittance for each dye concentration in Stage 7. After Stage 4a this is either light_pre for all concentrations (no adsorption) or the matched pre-dye light image for each concentration (adsorption detected).
T ≥ 0 always np.clip(T_raw, floor, 1.0) floors at floor (1e-6). No pixel of T should be negative.
Average T, not A Compute A from −log10(mean(T)) over ROI, not mean(−log10(T)).
No Nanodrop spectrum The Nanodrop measurements were used only to visually confirm Allura Red's peak overlaps the 510/10 passband. They are not used in the forward model or any fitting.
5mM vs 5.0mM Capillary raw directory is 5mM; device_1 raw directory is 5.0mM. Both mirror into their respective ave_14bit subdirectories.
JSON keys w/h not width/height Both capillary_crop.json and channel_rois.json use keys w and h, not width and height.
Section ROI coordinates Section ROIs in channel_rois.json are in crop-relative pixel coordinates, not raw-image coordinates.
Save button closure mo.ui.button(on_click=fn) with values captured as explicit default args in fn's signature. The value attribute of a button is unreliable.
mo.ui.dictionary for reactive inputs Plain Python dicts of UI elements break marimo's reactive graph. Use mo.ui.dictionary({...}) for collections of inputs that must trigger downstream re-execution.
marimo-pair skill Always invoke Skill("marimo-pair") before any marimo notebook work. Edit cells with ctx.edit_cell(id, code=...) inside async with cm.get_context() as ctx: — never with Edit/Write on the .py file.
Leading-underscore variables Variables starting with _ are cell-local in marimo and not exported to the reactive graph. Use plain names for cross-cell variables.

References

Design analysis

notebooks/2026-08-24_spectra/spectra_analysis_static/allura_red_analysis.html

Key sections:

  • Multiple concentrations in stepped-height channel — rationale for 5-concentration strategy and DoF analysis
  • Do capillary measurements add anything? — scale degeneracy proof; why the capillary is needed for absolute heights but not height ratios
  • Single capillary concentration — discussion of nonlinearity detection with multiple channel concentrations

Prior imaging pipeline

notebooks/2026-08-22_Keyence_data_analysis/ — similar crop → transmittance → Beer–Lambert → height pipeline; code template for image loading and ROI extraction.

Quick-Start Checklist

  • Invoke Skill("marimo-pair") before any marimo notebook work
  • ── Data preparation ─────────────────────────────────────────────────
  • Stage 1 — Confirm 2026-08-31_allura_16bit/ structure; check 5 TIFFs per condition
  • Stage 1 — capillary_roi.py: define crop ROI; save capillary_crop.json
  • Stage 3 — channel_roi.py: define crop + 8 section ROIs; save channel_rois.json
  • Stages 2&4 — process_images.py: verify 2026-08-31_allura_ave_14bit/ populated (includes light_pre + 5 × light_post_* + dark + 5 dye conditions)
  • Stage 4a — Adsorption diagnostic: compare all 6 light images per section ROI; compute rms_drift; set LIGHT_REF dict (light_pre for all, or matched references)
  • ── Physics analysis ─────────────────────────────────────────────────
  • Stage 5 — analysis.py: define A_pred(), load all light images, define transmittance()
  • Stage 5a — Channel comparison: linear fit on capillary; set ACTIVE_CH
  • Stage 6 — Capillary calibration: fit (s, η_emp) from 5 measurements at L = 80 µm
  • Stage 7 — Build A_meas_ch[8×5]; joint fit {h₀..h₇, η_fit} with s fixed
  • Stage 8 — Residuals heatmap, η comparison, height comparison, final figures