Metadata-Version: 2.4
Name: Auto-Organotypic
Version: 0.6.0
Summary: Automated analysis of bioluminescence and fluorescence time-lapse recordings from organotypic suprachiasmatic nucleus (SCN) slices.
Author-email: Jamie Malcolm <jamiemalcolm12@gmail.com>
License-Expression: MIT
Project-URL: Homepage, https://pypi.org/project/Auto-Organotypic/
Keywords: circadian,suprachiasmatic nucleus,SCN,organotypic slice,bioluminescence,fluorescence,time-lapse,live imaging,neuroscience
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
Classifier: Topic :: Scientific/Engineering :: Image Processing
Classifier: Topic :: Scientific/Engineering :: Medical Science Apps.
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.24
Requires-Dist: scipy>=1.10
Requires-Dist: tifffile>=2023.7
Requires-Dist: scikit-image>=0.21
Requires-Dist: imagecodecs>=2023.3
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == "dev"
Provides-Extra: test
Requires-Dist: pytest>=8; extra == "test"
Provides-Extra: incucyte
Requires-Dist: PyIncucyte>=0.3.1; extra == "incucyte"
Provides-Extra: lv200
Requires-Dist: pylv200>=0.1.2; extra == "lv200"
Provides-Extra: register
Requires-Dist: opencv-python>=4.8; extra == "register"
Requires-Dist: scikit-image>=0.21; extra == "register"
Provides-Extra: image
Requires-Dist: pillow>=10.0; extra == "image"
Requires-Dist: matplotlib>=3.7; extra == "image"
Provides-Extra: video
Requires-Dist: imageio-ffmpeg; extra == "video"
Requires-Dist: imageio>=2.31; extra == "video"
Requires-Dist: pillow>=10.0; extra == "video"
Requires-Dist: matplotlib>=3.7; extra == "video"
Provides-Extra: rhythm
Requires-Dist: circadian-workbench<0.8,>=0.7; extra == "rhythm"
Requires-Dist: matplotlib>=3.7; extra == "rhythm"
Requires-Dist: pillow>=10.0; extra == "rhythm"
Provides-Extra: ui
Requires-Dist: fastapi>=0.110; extra == "ui"
Requires-Dist: uvicorn>=0.27; extra == "ui"
Provides-Extra: desktop
Requires-Dist: Auto-Organotypic[ui]; extra == "desktop"
Requires-Dist: pywebview>=5.0; extra == "desktop"
Dynamic: license-file

# Auto-Organotypic

Automated analysis of bioluminescence and fluorescence time-lapse recordings from
organotypic suprachiasmatic nucleus (SCN) slices.

The SCN is the master circadian pacemaker of the anterior hypothalamus. Kept alive as
an organotypic slice and imaged for days, it reports its own timekeeping as a movie.
Auto-Organotypic turns those movies into per-cell and whole-tissue rhythm measurements.

## What is here

The first step, and the one that usually costs a person an afternoon with a mouse:
**finding the tissue**. One call reads a registered recording, draws the accepted
two-lobe outline, rotates it so both lobes sit the same way up in every recording, and
writes a square crop centred on the outline — with no hand-drawn region of interest
anywhere in the chain.

```python
from auto_organotypic import outline

result = outline.automatic(
    "meanred_MCG_04_1_595.tif",
    valid_mask="validfield_MCG_04_1_595.tif",
    output_dir="out",
)
result["output"]           # the two-lobe label image, oriented
result["cropped_output"]   # tight square crop around the SCN centre
result["report"]           # every setting, hash and measurement of that run
```

A registered ImageJ or OME hyperstack works as well as a two-dimensional time mean.
`scn_channel` picks the outline channel and `scn_z` the depth plane, both using
one-based ImageJ numbering; `scn_time` is `"mean"` (the default), `"max"`, or a
one-based frame number such as `320`. Whichever plane is chosen determines one
orientation and one crop, which are then applied to every plane in the stack.
For a flattened RGB TIFF, `scn_channel=1`, `2`, or `3` selects its red, green, or
blue sample respectively. For a large online-only hyperstack,
`selected_source_only=True` writes only the chosen two-dimensional plane and
`hash_source=False` avoids downloading the rest merely to calculate a full-file
hash; the selected plane and all outputs remain hashed. `write_oriented_source=False`
keeps the orientation but skips writing the full oriented copy of the stack,
which costs a second streaming pass over every plane and a second full-size
file — wasted if the next step reads only the crop. The transform stays in the
report either way.

`lobes` defaults to `2`, preserving the accepted method. Set `lobes=1` (or
`--lobes 1`) to keep a single detected or drawn region whole, or `lobes="any"`
to let the midline evidence return either one or two. A one-lobe outline cannot
infer anatomical up from a gap between lobes, so it keeps source orientation
unless an angle is supplied; its square crop and `lobe_1` trace still run.

## A folder of recordings

```python
from auto_organotypic import batch

records = batch.run(
    "D:/recordings",                 # a folder, a file, or any list of paths
    output_root="D:/scn_out",        # each recording gets output_root/<stem>/
    scn_channel=1, scn_time="mean",  # shared by every recording
    per_source={"MCG_04_1_595": {"scn_channel": 2}},   # what differs
    workers=8,                       # recordings at once; unset chooses
    on_progress=lambda record: print(record["input"], record["ok"]),
)
```

`workers` is where a folder's wall-clock lives. The outline is single-threaded
and around thirty seconds for a 512×512 field, against four milliseconds of
file reading, so nothing about how the images are stored or passed in will move
it. Left unset it is half the machine's cores, at most eight. A 27-recording
library, measured on sixteen logical cores:

| processes | wall clock | speed-up | peak memory |
| --- | --- | --- | --- |
| 1 | 434 s | 1.00× | 0.2 GB |
| 2 | 196 s | 2.21× | 0.4 GB |
| 4 | 141 s | 3.07× | 0.8 GB |
| 8 | 90 s | 4.83× | 1.5 GB |

The cap is memory, not speed: the gain is still real at eight, and each process
holds its own recording, so a folder of stacks costs far more per process than
the single frames measured there. Pass `workers=1` for one process, or a bigger
number on a machine with the memory for it.

On Windows a process pool needs the calling script to have an
`if __name__ == "__main__":` guard — without one, each new process re-imports
the script and runs it again. A run that chose its own worker count meets that
by saying so and finishing in one process, so a script that worked before still
works. A run that was *told* `workers=8` fails loudly instead — every recording
comes back carrying the error — because a caller who named a number is owed the
news that it did not happen.

`batch.run` is a separate call rather than a list argument on
`outline.automatic`, because the two want opposite things when something
goes wrong: one recording should raise, a folder should finish and then tell you
which three failed. Each record carries `ok`, `skipped`, the report path, and
either the full single-call `result` or the `error` with its traceback; the same
records are written to `batch_scn_outline_manifest.json`.

The same run from a shell:

```
python -m auto_organotypic D:/recordings -o D:/scn_out --workers 8 --scn-channel 1
python -m auto_organotypic D:/recordings --dry-run     # list what would be processed
```

Installing also puts a `auto_organotypic` command on the path. It is argparse over
`batch.run` and nothing else — every flag is one of that call's
keywords, one for one. It prints a line per recording as it finishes and exits
0 when every recording produced output, 1 when any failed.

Discovery keeps the recordings and drops the valid-field masks, the partial
writes and anything an earlier run wrote — from names alone, because opening a
folder's worth of online-only files to read their metadata downloads the folder.
A re-run skips any recording whose report already exists, so an interrupted
folder resumes where it stopped; pass `overwrite=True` to redo the work.

Crops are `"tight"`, `"standard"` (the default), `"wide"`, or an exact
`crop_size_px`. Every preset is checked to keep every outline pixel, and a custom
size that would cut the outline is refused rather than silently clipped.

## An instrument's folder, straight in

A PyIncucyte or PyLV200 download writes a folder of TIFFs and a manifest beside
them. `read_manifest` turns that manifest into recordings, and `outline_plan`
turns those into the two arguments `batch.run` already takes:

```python
from auto_organotypic import batch, outline_plan, read_manifest

recordings = read_manifest("D:/pull")        # either instrument, same records
sources, per_source = outline_plan(recordings, channel="red")
batch.run(sources, per_source=per_source, output_root="D:/scn_out")
```

`channel` takes a name — `"red"` also finds `TRITC` and `mCherry`, `"phase"`
finds `BF` and `brightfield` — or a one-based number. The index it produces
describes the stack as written, not the vessel, so a well that missed a channel
does not shift every name after it by one.

**No extra is needed to read a finished pull.** A manifest is plain JSON, so the
four dependencies above are enough. What a manifest says is worth looking at
before a long run:

```
auto-organotypic plan D:/pull
VID1234_A1_PhRd_20260820.tif  well A1  TCYX  393 frames  [Phase, Red]  -> channel 2
```

## The ClockCyteR cell grid, without Fiji

[`ClockCyteR.FIJI`](https://github.com/cabaJr/ClockCyteR.FIJI) is four ImageJ
macros that prepare a recording for `ClockCyteR.spatial`. The fourth lays a grid
of 5 x 5 pixel squares over the frame and measures every one of them in every
frame of every channel. `cellgrid` is that macro in NumPy:

```python
from auto_organotypic import cellgrid
grid = cellgrid.extract("D:/scn/L_1.tif", "D:/scn/L_1_results")
```

It writes the same two files the macro does, `grid_centroids.csv` and one
`Ch<n>_<n>_grid_vals.csv` per channel, and needs no Fiji.

A path, a `Series` or a `(frames, channels, y, x)` array all work — but **give it
the path for anything long**. Frames are measured and written a chunk at a time,
so nothing scales with the length of the recording: 4000 frames of a
three-channel 175 x 291 stack is a 1.2 GB file and 72 MB of memory. Handing it an
array instead means having the whole recording resident first, which past a
certain length is not a slow path but no path at all.

**The edge squares are the whole difficulty.** The macro's grid is sized with a
float division and `makeRectangle` clips the last square to the image, so the
final column and the final row come out *narrower than five pixels*: their means
are over fewer pixels and their centroids sit off the lattice. Every slice of the
published toy dataset is clipped, two of the three in both axes, so the obvious
`reshape(-1, 5, 5).mean()` gets the square count, the centroids and the edge
means all wrong. Laying it out the way the macro does reproduces all three
published `grid_centroids.csv` files byte for byte.

To run the macros themselves instead, point `AUTO_ORGANOTYPIC_FIJI` at the launcher:

```python
from auto_organotypic import fiji
fiji.extract_cellgrid("D:/scn")                 # macro 4
fiji.define_roi("D:/scn/hemis/Left")            # macro 3, re-using saved outlines
```

Only macro 4 is unattended in the first place. Macros 2 and 3 automate the
*second* pass, off regions somebody drew once, and say so before starting Fiji if
those are missing; macro 1 needs a line drawn on every stack and saves nothing to
re-use, so `auto_organotypic.orientation` is the answer there rather than a wrapper.

**Macro 4 measures in blocks of frames.** ImageJ's Multi Measure gets slower the
more rows are already in the Results table, so handing it a whole recording at
once is the expensive way to ask. Measured across two grids, the cost per square
sits at a median of about 74 microseconds while the table stays under roughly
50,000 rows and rises to about 272 by 230,400 — some 3.7 times worse. Blocks keep
a run on the flat part: for a 4800-square grid over 48 frames and two channels
the whole folder went from 131 s to 42 s. The blocks are stitched back into the
one published `Ch<n>_<n>_grid_vals.csv` before `extract_cellgrid` returns, byte
for byte what the unblocked macro wrote. The run-to-run spread on those timings
is wide, so treat them as a floor and a direction rather than a law, and do not
extrapolate past 230,400 rows. Even at that floor ImageJ is around 80 times
slower than `cellgrid` — 42 seconds against 0.5 for that stack — so the macro
route is for confirming the NumPy one, not for work.

## The whole sequence

```
auto-organotypic pipeline D:/pull -o D:/scn_out --channel red
auto-organotypic pipeline D:/pull --dry-run     # resolve every stage, run none
auto-organotypic stages                         # what is installed on this machine
```

Fourteen stages, in order: **acquire** (PyIncucyte or PyLV200), **index**,
**trim_before_crop**, **broad_crop**, **trim**, **register**, **split**,
**outline**, **image**, **grid**, **video**, **video_grid**, **trace** (one
call covering the cosmic-ray rule, a trace per region, the instrumental control
and the rhythm verdict) and **review** (the quality check, below). Only the
acquire stage needs another package, and the two movie stages need
`auto-organotypic[video]` for its encoder. The two trims and the split do nothing at
all unless asked, so a run that does not mention them is the run it always was.

Each is reached by dotted name, so a stage whose package is not installed is
reported as *pending* at the top of a run rather than raising four steps in;
`--allow-pending` runs the rest. A stage is also reported pending when a library
it imports *once it is already running* is missing — registration reaches for
scikit-image inside its estimator, and a run that discovered that forty minutes
in would be the exact failure this design exists to prevent.

Every stage appends what it did to `auto-organotypic-pipeline-run.json`, written after
each stage rather than at the end, because the stage that fails is the one whose
record matters.

Seven stages are opt-in. `acquire` runs only when an `--experiment` is given;
without one the folder is taken as already pulled. `broad_crop` runs with
`--broad-crop`, and rewrites every stack it crops, which is not something to do
to a plate somebody already framed by hand. Four are the display exports,
below, and the seventh is `review`.

## Pictures and movies out of the same run

```
auto-organotypic pipeline D:/pull -o D:/scn_out --image --grid --video --video-grid
auto-organotypic pipeline D:/pull -o D:/scn_out --grid-option moments=12 --grid-option columns=4
auto-organotypic pipeline D:/pull -o D:/scn_out --video-option hours_per_second=24
```

| Flag | Makes | Lands in |
| --- | --- | --- |
| `--image` | one still per recording | `visual/images/A1_mean.png` |
| `--grid` | every recording tiled into one sheet | `visual/images/grid_12moments.png` |
| `--video` | one movie per recording | `visual/videos/A1_24hps.mp4` |
| `--video-grid` | every recording playing at once in one movie | `visual/videos/grid_24hps.mp4` |

The trace stage now mirrors that image structure after it measures the accepted
outline: one `visual/traces/A1_trace.png` per recording and one
`visual/traces/all_slices_traces.png` comparison grid. A second collective
view, `visual/traces/all_slices_trace_supergraph.png`, overlays every recording
and emphasizes their average. The numeric source stays under `traces/A1/`;
figures are display-only and are never fed back into an analysis.

```text
auto-organotypic pipeline D:/pull -o D:/scn_out \
  --trace-option primary=48h \
  --trace-option circadian=periodogram \
  --trace-option xtick_hours=12 \
  --trace-option visual_normalise=zscore \
  --trace-option visual_signal=detrended \
  --trace-option visual_average=median
```

The default is the pixel-weighted union of both outline lobes, a 24 h rolling
detrend, periodogram plus cosinor annotation, x ticks every 24 h, and per-
recording 0–1 normalization. `visual_regions=all` draws the two numbered lobes
as well.

`visual_signal` chooses which series the figure shows, and the default is
`cosinor`: the fitted cosine on its own, normalized to fill 0–1, with the
measured wobble the fit already discounted left out. It is the shape the
analysis concluded, so a row of recordings compares on phase and period
without a reader having to see past the noise. Every measured series is opt-in
from there. `detrended` draws the detrended trace; `raw` draws the measured
trace, with its trend still in it; `both` draws the measured trace behind the
detrended one so you can see how much the baseline took out. The measured
trace is put on the detrended trace's own axis by the same divisor — each
region's window mean — so it keeps the shape it was measured with. Whichever
series is drawn on its own is also the one the normalization is computed from,
so a raw-only figure fills its axis with the raw range rather than someone
else's; the overlay is the one case that normalizes both to the measured
trace, because two scales would stretch the detrended trace back over the
trace it came out of. A raw-only figure carries no cosinor curve, since the
fit was made on the detrended values.

A cosinor-only figure needs a fit. Where there is none — `circadian=none` or
`circadian=periodogram` was asked for, or the periodogram found no peak to fit
— the figure falls back to `detrended` rather than drawing a curve for some
recordings and a blank for others; in a grid or a supergraph one unfitted
recording takes the whole figure with it, so every panel keeps showing the
same thing. The report records `requested_signal` beside the `signal` that was
actually drawn.

The supergraph defaults to the arithmetic mean over the time interval
shared by every recording. `visual_average=median` is resistant to isolated
extreme recordings; `visual_average=trimmed_mean` removes
`visual_trim_fraction` from each tail at every time point. Set
`visual_time_range=union` to retain the longest recording and average whichever
recordings are present there. Detrending and region measurement live in
`auto_organotypic.region_trace`;
the display code lives in `auto_organotypic.trace_plot`. Neither reaches the external
single-cell dLuc pipeline.

All four run after the outline and before the trace, so "index it, outline it
and show me" is a complete run that never pays for the trace half. Files are
named for the well and for what they are of, rather than for source stems that
reach 149 characters by this point in the pipeline.

**Every keyword of `auto-organotypic image`, `grid` and `video` is reachable**, as
`--<stage>-option KEY=VALUE`, repeatable. Values are read as JSON where they
parse as it, so `moments=12` is a number and `shared_range=false` is false, and
as plain text otherwise, so `lut=fire` works without a quoting puzzle. In Python
it is one mapping per stage:

```python
run_pipeline(folder, image=True, video_grid=True,
             image_options={"when": "max", "lut": "fire"},
             video_grid_options={"columns": 4, "hours_per_second": 24})
```

Giving a setting is itself the asking, so `--grid-option moments=12` turns the
stage on. A key the export will not take is refused **by name before the run
starts**, checked against the function's own signature — misspelling `colums`
costs a second rather than the outline stage's minutes.

Four things are filled in from what the run already knows, each only as a
default that anything you pass overrides: where the file goes, what it is
called, the frame interval from the recording's own metadata, and the well names
on a sheet's or a mosaic's tiles.

**The mosaic is the one with opinions**, because tiling recordings that disagree
is how a movie comes out looking right and being wrong:

- **Unequal windows are refused.** Nothing is dropped or held to make them look
  aligned. `on_length=shortest` is the explicit opt-out for a plate where one
  well stopped early, and it names what it cut. There is no "hold the last
  frame" at all: a finished recording still sitting there looking alive is a
  claim about the biology.
- **Two acquisition intervals cannot claim one biological speed.** Asking for
  `hours_per_second` across them is refused; a plain `fps` claims nothing and is
  allowed, and each tile keeps its own recording's timestamp.
- **`shared_range` is one contrast per recording across its whole window**, the
  same axis the still sheet uses — so a dim phase stays dim and a late bright
  phase is not clipped by an early frame. It is *not* one range across the
  plate: each well ranges itself, so comparing brightness between wells needs an
  explicit `display_range=(black, white)`.
- **Accepted outlines can be drawn over the tiles**, which the still sheet also
  does.
- **Every tile may start at its own hour zero.** `align="onset"` finds each
  recording's first rise and begins its tile there, so nine slices that came up
  at nine different times play in phase. What that does *not* change is the
  frame-for-frame advance: two cadences still drift apart after the frame they
  were aligned on, which is the same reason `hours_per_second` is refused across
  them.

A plate is **streamed, never assembled**: one frame is painted from each
recording, tiled, encoded and dropped before the next is read, so the memory is
one row of frames rather than one row of stacks.

The mosaic is also its own subcommand, `auto-organotypic video-grid`, for a folder
that never went through the pipeline.

**Nothing any of the four writes may be measured from.** Each marks its output
as a display artefact, the run record says so per stage, and none of them
touches the recordings it was handed — a lookup-table painted 8-bit picture
reaching the trace stage would be the one failure that looks like a result.

## Did it go well? The review

```
auto-organotypic pipeline D:/pull -o D:/scn_out --review   # as part of the run
auto-organotypic review D:/scn_out                         # or afterwards, on its own
```

Every stage already writes down what it decided and how sure it was: the crop
detector's reason for keeping a frame whole, registration's residual against
its own threshold, the outline's `verification` line and `open_questions`, the
accepted orientation rule's three top-end cues and which of them agreed, the
square crop's clipped-pixel count, the instrumental control's verdict. That is
six files per recording, in JSON and CSV, so on a plate of ninety-six nobody
reads any of them.

The review reads them and draws them into `visual/review/`:

| Panel | Answers |
| --- | --- |
| `review_scorecard.png` | every recording against every check, worst first |
| `review_geometry.png` | one column per recording, one row per stage of the geometry — registered mean, oriented square crop, crop with the outline over it |
| `review_registration.png` | each frame's residual, against the threshold the run recorded |
| `review_orientation.png` | which end of the medial gap each of the three cues chose, and how square the rotation came out |
| `review_rhythm.png` | period and power per region, and the instrumental control |
| `review.html` | all five inlined, plus every number as searchable text |

Five words and no sixth: **ok** (the stage's own gates passed and it left no
question open), **check** (it finished, and marked its own answer as one to
look at), **given** (a person answered instead of the method), **failed**, and
**not run**.

`check` is the one worth being clear about. It does not mean something went
wrong. An automatic outline on a recording nobody has hand-checked is `check`
every time — nothing is wrong with it, and nothing has confirmed it either. The
orientation is where it earns its keep: a slice can be outlined perfectly and
turned upside down, nothing downstream notices because a lobe trace is a lobe
trace either way, and the cue vote is the number that would have told you.

**The review re-judges nothing.** A verdict here is the verdict the stage
recorded, read back and given a colour — so it cannot disagree with a report,
and a run from last month says the same thing to somebody who was not there.
The picture strip resizes nothing either: a pixel means the same distance
everywhere on the sheet, which is why a plate *wraps* into blocks of twelve
(`--columns N`) rather than shrinking to fit.

```
auto-organotypic review D:/scn_out --crop tight       # fill each tile with tissue
auto-organotypic review D:/scn_out --panel geometry --strip-row raw \
    --strip-row cropped --strip-row outlined
```

`--crop` decides what the strip's tiles are framed on, and it is the one real
choice:

- `none` (the default) draws each row's whole frame, so the square crop
  visibly sits inside the frame it was cut from. This shows **what the crop
  did**.
- `tight`, `standard` or `wide` frame every tile on the outline at the accepted
  crop's own scales (1.05, 1.25, 1.50 — imported from `outline/crop.py`, not
  copied), so the tissue fills the tile and the empty field goes. This shows
  **what the boundary and the rotation are**, which is what the strip is
  usually opened for.

Each row's box is the outline's own bounding box walked back into that row's
frame — through the rotation, the registration overlap and the coarse box, each
hop a number some stage recorded. A row whose walk is missing a step is drawn
whole rather than guessed at, and the report names what every row was framed
from.

`--strip-row raw` adds the pre-registration recording as a row. It is the only
row that reopens a full source stack, so it is the slow one and is off unless
asked. Display only, like everything else in this section; it needs
`auto-organotypic[image]` and an output root, since a review is about a run and not
about a recording.

## Analysing part of a recording

```
auto-organotypic pipeline D:/pull -o D:/scn_out --trim 0..144h
auto-organotypic pipeline D:/pull -o D:/scn_out --split-at 72h
auto-organotypic pipeline D:/pull -o D:/scn_out --split baseline=0..70h --split drug=74h..144h
```

Both are said the same way: frames (`1..288`), hours (`0..72h`) or days
(`2d..5d`), with `..` between the two ends and either end left off to mean the
beginning or the end. A time window is half-open, so `0..72h` and `72h..144h`
are the two halves of a six-day recording and no frame is in both or in neither.

**`--trim 0..144h` drops frames nothing should see.** Use it when the microscope
lost focus or the slice drifted out of the field: registration crops its export
to the box every frame still covers, so a hundred bad frames at the end shrink
the field of view for the whole recording, including the good days. It reruns
registration, so it costs hours.

**`--split-at 72h` produces two complete sets of outputs** from one recording —
`traces/A1_start-72h/` and `traces/A1_72h-end/` — measured through one
registration, so the two are comparable. It reruns the outline and the trace, so
it costs about a minute. Name the spans if you would rather read the folders
later: `--split baseline=0..70h --split drug=74h..144h`, which also drops the
four hours around a media change without leaving a gap *inside* either series.

A window said for one well beats the plate's, through the command that already
exists for saying so:

```
auto-organotypic correct D:/pull --stage trim --only B2 --value '"0..120h"'
auto-organotypic correct D:/pull --stage split --only B2 --value '["0..48h", "48h.."]'
```

Moving a cut point writes new folders and leaves the old ones alone — nothing
here deletes results. The run says which segment folders it did not write this
time, so they are not read as current a month later.

`--trim-before-crop` puts the window on the stage before the broad crop instead
of the one after it. It is for the recording whose bad frames fooled the crop
detector, and it costs a full-frame copy of every recording it touches rather
than a cropped one.

## The broad crop

```
auto-organotypic crop D:/pull --mode wide --dry-run   # how much would wide take off?
auto-organotypic crop D:/pull -o D:/cropped --mode wide
auto-organotypic crop D:/pull --when max              # draw the box from other frames
auto-organotypic pipeline D:/pull --broad-crop --broad-crop-mode standard
```

`--dry-run` measures every recording, reports the box the chosen mode would
draw, and writes nothing — worth running before committing a plate to a nine-day
experiment. Without `-o`, crops go to `AI_Exports/<stem>_broad_crop/` beside the
source, which is where the folder form already knows not to look for inputs.

Registration costs what the frame costs. An Incucyte pull is 1152 × 1536 and the
slice is about a fifth of it, so aligning the empty four fifths costs the same
as aligning the tissue. This stage finds the tissue once and boxes it.

`tight`, `standard` and `wide` are 1.5, 2.0 and 2.8 times the box the tissue
occupies — larger than the region crop's numbers because they scale the whole
slice rather than the SCN outline, and because the surround they leave is what
the accepted outline later reads its own background from. `standard = 2.0` is
not a taste: three Incucyte wells exist in both the full frame and a crop a
person drew for this exact purpose, and those hand crops are 1.75 – 2.04 times
the tissue box this finds.

`--when` picks which frames the box is drawn from, using the same three
answers as `--scn-time`: `mean`, `max`, or a one-based frame number. Left alone
it uses this stage's own rule — a median inside each of five chunks spread
across the recording, then the maximum across them — which survives both a
cosmic ray and the circadian trough while reading two dozen planes instead of
all of them. Reach for `--when` when the default framed something you did not
want; `max` holds the union of everywhere a drifting slice went, and a frame
number is for the recording where you know which frame to look at.

**It never cuts tissue, and it does not get tighter as the signal gets weaker.**
Otsu's threshold on the frame's own histogram says what is certainly tissue; a
second, lower one says what is certainly background; and the box holds every
connected piece that reaches the first while spreading out to the second. Both
levels are read off the frame, so nothing has to be chosen in advance. Where the
tissue fills the frame, or the well is empty, or the histogram cannot separate
anything, it keeps the whole frame and the run record says why.

Four settings, three of them fractions of the frame and the fourth a compute
budget — down from eleven, because Otsu now reads off the histogram what three
of those settings used to have to be told.

`single_object=False` is the default and should stay off unless the stage did
not move: the detection frame is a maximum across time chunks, so a slice that
drifted shows as two blobs and the union of them is where the slice was. Turn it
on for a large frame with bright debris elsewhere in the well.
`region=(x0, y0, x1, y1)` crops to exactly what you name — obeyed, not judged,
though the record still says whether it holds the tissue.

Measured across 75 real recordings spanning a 138-fold range of signal-to-noise
ratio: 16 of the 75 cropped, none of the 16 hand-drawn outlines clipped, and the
ten viral-reporter recordings that the first version of this stage was quietly
cutting now keep every pixel of their tissue.

## Drawing it yourself, or just saying it

```
auto-organotypic pipeline D:/pull --broad-crop-rois D:/rois/crops    # the broad crop
auto-organotypic pipeline D:/registered --stages outline     --outline-rois D:/rois/outlines                           # the SCN outline
auto-organotypic crop D:/pull --rois D:/rois/crops --rois-if-missing raise
auto-organotypic crop D:/pull --region 300,220,900,760              # one box, whole plate
auto-organotypic D:/registered --angle 74 --crop-region 40,40,360,360
auto-organotypic D:/registered --orient-rois D:/rois/up --outline-crop-rois D:/rois/squares
```

Four measured steps take the answer instead. Each has a folder of drawings and
a value for the whole run, and they are separate options because they are cut
from different frames:

| step | drawn | said |
| --- | --- | --- |
| broad crop | `--broad-crop-rois` | `--broad-crop-region x0,y0,x1,y1` |
| SCN outline | `--outline-rois` | — |
| which way is up | `--orient-rois` | `--angle DEG`, `--flip` |
| square crop | `--outline-crop-rois` | `--outline-crop-region x0,y0,x1,y1` |

**Every flag says which crop**, because `pipeline` runs two of them. There is
no `--crop-rois` there: it used to mean the broad one, and a spelling that means
a different stage in a different command writes a finished run in which nothing
looks amiss. Typed anyway, it stops the run and names the four flags that do say
which. Under `auto-organotypic <folder>`, where the outline is the only stage, the
square crop is also spelled `--crop-rois` and `--crop-region`, beside the
`--crop` that already means it there.

Open the recording in Fiji, draw round the tissue with the freehand tool, save
the region as a `.roi` or a ROI Manager `RoiSet.zip` named after the recording
or its well, and point the run at the folder. A recording nobody drew is left to
the automatic method, which is the point: a plate where three wells needed a
person and ninety-three did not is the normal case. `--rois-if-missing skip`
leaves the undrawn ones alone; `raise` stops the run, which is how a fully
manual pass is asked for.

**Which way is up is one line, not a region.** Draw it from the ventral base to
the dorsal tip with the straight-line tool: the direction of the stroke is the
direction that ends up at the top. `Analyze > Measure` gives that stroke's angle,
and typing it into `--angle` is the same instruction — 0 to the right, 90
straight up the screen. When the automatic rule found the axis and only called
the dorsal end upside down, `--flip` is the whole correction. A per-recording
`angles.json` — `{"B2": 74.5}`, or `{"B2": {"flip": true}}` — says either for a
plate.

**Draw on what the step sees.** An ImageJ ROI stores pixel coordinates and not
the size of the image they were drawn on, so a region drawn on the raw recording
and applied to a registered stack lands in the wrong place and nothing in the
file says so. That is why the flags are separate: `--broad-crop-rois` is drawn
on the raw recording, `--outline-rois` on the plane the outline reads — the
`*_OUTLINE_INPUT_*.tif` a previous run wrote — and the square crop on the
`*_ORIENTED_SOURCE.tif`, because that crop is cut after the rotation. A drawing
that cannot fit inside the frame it is handed is refused rather than clamped
into a plausible crop. A drawn direction is the exception: an angle has no
position, so the same stroke means the same thing on either frame.

**A given box is obeyed, not judged — and counted.** The presets keep every
outline pixel and `--crop-size-px` is refused if it would not, but a box you
named is taken as given: the report carries `outline_pixels_clipped` and raises
an open question rather than quietly returning a smaller SCN.

**The default outline is two lobes.** Two drawn regions is one lobe each; one
region plus a line down the middle is the region cut along that line; one region
alone leaves the midline to the accepted split. `--lobes 1` instead keeps that
region whole, and `--lobes any` accepts either result. The report is written
under its own tool name so nothing claims the frozen accepted geometry for an
outline a person drew.

`pip install "auto-organotypic[incucyte]"` adds the Incucyte download and
`pip install "auto-organotypic[lv200]"` the LV200 one;
`pip install "auto-organotypic[rhythm]"` adds what the trace stage needs to put a
period and a saved trace figure on a trace.

The rhythm adapter imports only Circadian Workbench's public package-root
facade. Its period, phase, synchrony and channel answers therefore follow the
same versioned contract as a direct `circadian_workbench` call; the adapter
only reshapes those results for this pipeline.

## Something to watch

```
auto-organotypic video D:/pull --filters broad_crop,register,bioluminescence
auto-organotypic video D:/pull/A1.tif --hours-per-second 6 --lut C1=green --lut C2=red
auto-organotypic video-grid D:/pull --columns 3              # every well together
auto-organotypic video D:/outlined --outline                 # plain + outlined copy
auto-organotypic video --list-filters                 # the steps and their parameters
```

One movie per recording, and **the filtering happens on the way through**.
`--filters` is a chain of this package's own steps, run over each channel in
memory: steps separated by commas, parameters by colons, so
`register:downsample=2,unmix:coefficient=0.04` is a chain of two. A raw
instrument pull goes straight to something watchable, and the filtered stack
that used to sit between them — a gigabyte nobody wanted — is never written.
`--save-filtered` keeps it for the run where you do.

| step | what it does |
| --- | --- |
| `broad_crop` | box the tissue, drop the empty four fifths of the sensor |
| `register` | take the drift out, keep the field every frame still covers |
| `unmix` | subtract a scaled autofluorescence channel from this one |
| `cosmic_rays` | replace pixels sitting above what the same pixel does either side |
| `static_background` | the per-pixel whole-record mean out, band-limited, and back |
| `bioluminescence` | suppress only what matches each pixel's own noise |
| `amplitude` | each pixel against its own quiet level, so the rhythm is the picture |
| `no_drift` | the slow fade out, so what moves on screen is the rhythm |
| `smooth`, `local_contrast` | display smoothing, and each frame against its own blur |

Every step calls the owning module's own function rather than a copy: a chained
`bioluminescence` comes out bit-identical to `display.bioluminescence_display`'s
TIFF, and a test asserts it. The first two are **geometry** — they change how
big the picture is — so they are planned once for the recording and every
channel gets the same box.

**A week-long movie is mostly a picture of the battery running down.** The
substrate is used up, the tissue settles, the focus creeps, and that fade is
larger than the daily rhythm the recording is of: on the ten-recording reference
set only **0.40** of everything moving on screen is the rhythm. `no_drift` gives
every pixel a moving average of its own circadian period, subtracts it, and puts
the pixel's own long-run brightness back — a moving average over exactly one
period cancels the rhythm, so what the average holds is the fade alone. The
rhythm's share goes to **0.85**, and the anatomy does not move.

```
auto-organotypic video D:/pull --filters no_drift:window_h=22.4 --time-gain 6
```

Give it the recording's own fitted period where you know it; a window 10 % off
leaves about 9 % of the rhythm in the average and takes that much away. And
expect to want `--time-gain` with it: once the fade is gone the rhythm is left
using about **1.6 %** of the display range where the fade had been using 11.6 %,
so a drift-free video at 1x is honest and faint. It is display only.

**Order is yours and it means something.** `broad_crop,register` boxes the union
of everywhere the slice went and registers the small frame — the pipeline's
order, and the cheap one. `register,broad_crop` aligns the whole sensor first
and boxes where the slice *is*. A filter either side is the same choice: filter
the full frame, or filter the crop. The chain runs what is written down.

`broad_crop` takes the same `--when` the crop stage does:
`--filters broad_crop:when=max,register`.

**Nothing here may be measured from.** Every movie records itself as a display
artefact, and the one stack `--save-filtered` can write is marked
`_DISPLAY_ONLY` whatever its chain was: it carries the movie's record rather
than the unmixing coefficient's or the cosmic-ray rule's, so a number taken from
it would have no artefact behind it. `filtering.unmix` and
`cosmic.remove_cosmic_rays` are how a measurable stack is made.

A stack already marked `_DISPLAY_ONLY` **can** be cropped and registered — that
is usually the one you most want held still to watch, and the movie is
display-only either way. It still cannot be unmixed or de-spiked: that rule is
about steps that change what a pixel means, and moving the picture is not one.

Playback is stated in **experimental hours per second**, not frames per second.
At 6 — the default — one biological day takes four seconds of screen time
whatever the acquisition interval was, and the frame rate is derived. No frame
is ever dropped or duplicated to hit a rate. One number for every movie the
package writes, single tiles and mosaics alike, so two exports of one recording
never play at two speeds.

`video-grid` makes one moving comparison sheet from several recordings. Its
visual controls are the image grid's: gutters are white and 2.5% of a tile, and
each recording gets one automatic range measured across its complete selected
window. `--timestamp-format`, `--columns`, `--gap-px`, `--background`,
`--tile-label` and `--own-range` mean the same thing in both grid commands.

**Two defaults differ from the sheet's, and both are about space.** A sheet's
rows are recordings, so one strip down the left names nine rows; a mosaic's
tiles wrap, so every tile pays for that strip and it takes a quarter of the
width. So `--well-label-position` defaults to `top-left` here — the name drawn
over the frame, costing no layout — with `top-right`, `bottom-left`,
`bottom-right`, and `left` for the outside strip.
`--well-label-orientation` is a property of that strip and is ignored at a
corner. And when the time cannot be hoisted into one heading (see below) it
goes over a corner too, taking the one the name is not using; asking for both
in the same corner by name is refused rather than quietly moved.

**A mosaic on one time axis says the time once.** Every tile is at the same
experimental hour, so nine copies of it is eight too many. Two settings, because
these are two questions:

| | | |
| --- | --- | --- |
| `--timestamp-scope` | how many times it is said | `auto` (once when the tiles read one time, per tile when they do not), `mosaic`, `tile`, `none` |
| `--timestamp-position` | where it goes | `auto`, `header`, `bottom`, or a corner — of the mosaic or of each tile, whichever the scope is |

`none` leaves the time off and keeps the names; `mosaic` insists on one caption
and refuses a mosaic whose tiles disagree. A caption names the frame on the
screen, but a frame chosen as the *nearest* to an instant is captioned by that
instant, softened by exactly what the recording cannot resolve — the rule a
contact sheet's columns already follow. Without it a tile sampled every 33
minutes reads 99.9 h beside eight reading 100, and the mosaic could never say
its time once.

Every input contributes one frame to every output frame, so unequal selected
frame counts are refused rather than silently truncated; `--on-length shortest`
stops at the shortest tile and names what it cut.

**Advancing by frame index is also what makes unlike cadences play at unlike
speeds**, and that is the refusal worth understanding. A plate holding a
30-minute recording and a 33-minute one covers different amounts of biological
time in the same number of output frames, so the second plays 11% fast for its
whole length and nothing on screen says so. Experimental-hours playback
therefore refuses it. There are two ways past:

| | what it does | what it costs |
| --- | --- | --- |
| `--on-cadence resample` | every tile on one grid of experimental hours, stepping at the finest cadence present; each tile shows its own nearest frame to each instant | a coarser tile repeats a frame where it has none, counted per tile in `resampled` |
| `--fps N` | plays at a stated frame rate and claims nothing about biological speed | the tiles really are at different speeds |

`resample` is the only one that makes the mosaic real time. It is the contact
sheet's rule applied to a movie — a sheet's columns are already each
recording's nearest frame to a requested hour. Each tile's timestamp always
follows its own recording.

## Something to put in a figure

```
auto-organotypic image D:/pull --filters broad_crop,register,bioluminescence
auto-organotypic image D:/pull/A1.tif --when 412 --lut red    # one named frame
auto-organotypic grid  D:/pull --moments 6                    # a time montage
auto-organotypic grid  D:/pull --when mean                    # every well, one sheet
```

`image` is `video` with the encoder taken off, argument for argument — the same
`--filters`, the same `--lut`, the same `--display-range`, the same
`--display-gamma`, because both draw through the same engine. A still made with a movie's arguments **is** that
movie's frame, to the pixel.

`--when` is the whole difference:

| `--when` | the picture is |
| --- | --- |
| `mean` | the window averaged. The default: a nine-day bioluminescence recording is mostly noise in any one frame |
| `max` | the brightest each pixel ever got — and a hot pixel too, if the cosmic-ray rule has not run |
| `412` | recording frame 412, one-based, the same vocabulary `--scn-time` uses |

A projection has its display range measured **on itself**, not on the frames
behind it. The mean of a thousand frames has a lower maximum than any frame in
it, so a range read off the frames would draw the mean too dark by exactly the
amount the averaging removed. Its caption says a stretch rather than an instant
— `Time: 0 h 00 min - 240 h 00 min` — because a mean happened at no single time.

`grid` tiles several of those into one file, and what varies decides what the
sheet is:

| | tiles |
| --- | --- |
| `--moments 12` | one recording across time — a montage |
| a folder | every recording at one moment — a contact sheet |
| a folder `--moments 6` | rows of recordings, columns of time |

A montage covers **one circadian cycle** unless told otherwise: see below.

Time-course rows put each well name once on the left; use
`--well-label-orientation horizontal|vertical` to turn those names.

**A caption every row repeats is said once, above its column.** Nine wells at
six times used to carry the time in a black band under all fifty-four tiles;
`--timestamp-position auto`, the default, puts six headings at the top instead
and the other eight bands go. It hoists only when every row of every column
says the same thing, so a contact sheet of nine differently named wells keeps
its names on its tiles. `header` asks for the hoist and refuses a sheet whose
columns disagree, naming the column that does; `bottom` is the old band under
each tile, and `top-left` or `top-right` draw over that corner.
`--timestamp-format` accepts `hours`, `elapsed`, `clock`, or a template such as
`{prefix} {total_hours:.0f} h, day {day}`.

#### A montage is one circadian cycle, trough to peak to trough

What a sheet of a slice is for is showing how it looks at each stage of its
rhythm. So `--moments 7` gives seven columns evenly over **one cycle** —
`CT 0, 4, 8, 12, 16, 20, 24` — and not seven days at the same phase, which
shows the cycle exactly once and the damping six times. That was the behaviour
until 2026-08-28.

Three defaults move together to do it, and any of them may be set on its own:

| | default | what else |
| --- | --- | --- |
| `--between` | `cycle` — one period from hour zero | `window`, or `24:96` in hours |
| `--time-scale` | `ct` — the caption counts from the rhythm | `elapsed`, `zt` |
| `--align` | `best` — hour zero is the best day's **trough** | `trough`, `onset`, `peak`, `start`, `clock`, hours |

**The cycle closes, and it opens at a trough.** Changed 2026-09-03, from six
columns opening on a rise. The first tile and the last are now the same phase a
day apart, which is a fixed point: if the two do not match, what is on the
sheet is not the rhythm. Seven and not six because an odd count with both ends
drawn puts one column exactly halfway, so a day cut at its trough shows its
peak in the middle tile. `--between 0:20` gets the open cycle back.

**Which cycle is best is measured.** Each complete cycle is scored on three
things and the product decides:

| | | catches |
| --- | --- | --- |
| swing | tenth percentile to ninetieth of the detrended cycle | a dead day |
| goodness | variance a cosinor at the period explains | a cosmic ray |
| consistency | how nearly it peaks where its neighbours peak | a one-off bump |

**No two are enough.** A cosmic ray gives a huge excursion and explains nothing;
a flat cycle explains most of its own tiny variance; and the mounting transient
— the slice recovering from being cut — is the largest, smoothest bump in most
organotypic records and scores well on both. The first cycle is therefore passed
over whenever another exists. Every cycle's score is in the report under
`cycles`, so the choice can be checked and disagreed with; `--align onset` takes
the first cycle deliberately, and `--align 36` takes whichever you like.

**A column is a time, not a frame number.** Each recording supplies its nearest
frame to the hour a column names. Until 2026-08-28 they were frame numbers
shared across the plate, so on the reference set — eight wells imaged every 30
minutes and one every 33 — column three was 24 hours into eight of them and 26.6
into the ninth, and a reader comparing the row could not tell.

**The gap divides the cycle**: a half, third, quarter, sixth, eighth or twelfth
of the period, or a whole number of them. Over more than one cycle that means
every column falls at the same stage, so a row is one phase sampled repeatedly
rather than a beat between the sampling and the rhythm. The count you asked for
is honoured — the snap is capped at the widest gap that still fits it — and
`spacing` in the report says what it settled on. `--every-h 18` states the gap
outright and is never snapped; `--even-spacing` divides the span evenly.

`--period-h 23.4` says what the cycle is; `--period-h fit` reads it off the
recordings and takes the median. **Display only, and never a period
measurement** — six columns over a slice free-running at 23.4 hours walk 3.6
hours off its rhythm in a day if the sheet assumes 24.

`--between window` restores the old behaviour, and `--between 24:96` restricts
the columns to those hours. A recording too short to hold a cycle falls back to
its window and says so in the report; asking for `cycle` outright is refused
rather than shrunk.

#### What a tile is a picture of

A circadian day is a small change on a large structure. On the ten-recording
reference set a whole day is 4–6 % of the tissue's brightness — about **1.2
units of CIELAB `L*`**, where two units is the least a reader can tell apart
between two tiles side by side — so eight of those ten dishes draw a day nobody
can see. Four settings decide what to do about that, and the sheet ships doing
none of them:

| | | |
| --- | --- | --- |
| `--plot change` | each tile against the average of its own day | 68 grey levels where intensity shows 12 |
| `--time-gain 6` | the same departures drawn six times larger | 6.2 `L*` instead of 1.2 |
| `--soft-range` | the ends of the range bend instead of cutting | `auto`: on for a closing cycle |
| `--change-levels 28` | a change sheet's full scale, shared by every recording | `own` scales each by its own spread |

**A video grid takes the same four**, with the same defaults and the same
refusals — `auto-organotypic video-grid ... --plot change`, `--time-gain 6` — and a
frame is drawn against the mean image of the record exactly as a tile is drawn
against the mean image of its day. Pair `--time-gain` with `--filters no_drift`
on a long recording, or the gain amplifies the fade along with the rhythm. On a
video the note is printed on **every frame**: one frame travels on its own.

**One scale for the sheet, or the sheet is not fair.** Scaling each recording
by its own spread draws a well with no rhythm as vividly as the best one in the
set: across the reference recordings, what a sheet showed correlated 0.598 with
each dish's own fitted amplitude when the scale was per dish and 0.846 when it
was shared. The same is true of the gain — one number, every pixel, every
recording, so a well with half the rhythm shows half the change.

A change plot is drawn in the diverging `change` map by default — on a sheet
and on a video alike: cyan below the day's average, black at no change, red
above. **A dim tile on it is not dim tissue**; it is tissue below its own
average that day, and a single-hue ramp cannot say which side of the average a
pixel is on. Name a `lut` and that one is used instead.

Both are **display only**, and a sheet drawn either way prints `CHANGE` or
`DIFFERENCES x6` beside every recording's name and records the setting in its
own report, so it cannot travel without saying what it is.

#### Three clocks

`--time-scale` decides what the number *is* and `--timestamp-format` decides how
it is spelled, so `ZT 16:14` and `CT 3.4 h` are both available to either.

| `--time-scale` | zero is | wraps | answers |
| --- | --- | --- | --- |
| `elapsed` | the first drawn frame | never | how long in |
| `zt` | midnight, real time | 24 h | what time of day |
| `ct` | this slice's own rhythm | the period | where in its cycle |

**ZT here is the wall clock, not a light cycle.** A slice has no retina, so
nothing in the dish responds to light and there is no Zeitgeber to count from;
what the name is borrowed for is the real time of day a frame was taken. It is
read off the acquisition timestamp and **refused without one** —
`--clock-start 2026-07-10T16:13:53` supplies it for a derived stack that no
longer carries it.

`--align` puts hour zero, and therefore column one, at `best`, `onset`,
`peak`, `start`, `clock` (the last midnight), or a stated number of hours:

```bash
auto-organotypic grid D:/pull --moments 6                        # one cycle, CT, best
auto-organotypic grid D:/pull --moments 6 --align onset          # the first cycle
auto-organotypic grid D:/pull --moments 6 --between window       # five days, one phase
```

**`onset` is the first rise of a recording's own rhythm** — the upward crossing
of the midpoint between its first trough and its first peak. Which cycle that is
comes from a cosinor at the period, and where in it from the trace itself: the
largest maximum is not the first one, and on the reference plate it is a
mounting transient on day one in well 1423 and a cosmic ray on day nine in 1432.
`best` is the same rise, of the best cycle rather than the first. Both are
measured from a coarse tissue-mean trace, are display only, and are reported
under `clocks` so they can be checked or replaced with a number.

The same arguments are on `image`, `video` and `video-grid`. On a movie
`--between cycle` plays exactly one period and `--between 0:72` cuts the playing
window in hours rather than frames, so a movie can start at the best cycle's
rise and run one day. On the mosaic `--align best` starts **every tile at its
own zero**, so nine slices that came up at nine different times play in phase
and the grid shows how their rhythms differ rather than when each was mounted.

Tiles have white gutters 2.5% of their short side by default, following the
Plot That image-grid reference. `--gap-px` and `--background` override them.

**One display range for each complete recording or split.** It is measured
across the selected time window and applied to every time tile from that input,
so a dim phase stays dim and a late bright phase cannot be clipped by an early
tile. `--own-range` turns sharing off for a layout check.

**Automatic contrast: black at the 50th percentile of the empty field, white at
the 99.8th of the stack.** Measured, not chosen — see
[`docs/display-contrast.md`](docs/display-contrast.md). The old white point at
the 99.999th percentile was set by a handful of outlier pixels, so the tissue
lived in the bottom sixth of the ramp and a circadian cycle moved the bright
regions by 52 of 255 grey levels; at 99.8 it moves them by 66.

The black point took three rounds, and the first two were graded by the wrong
ruler. Clipping was measured over `largest_tissue_mask` — the Otsu mask the
floor is read *outside* of — so tissue the floor had crushed fell below that
threshold, sat outside the mask, and was never counted. Measured over the
**accepted SCN outline** instead, that mask covers 56% of the slice, a third of
the "empty field" is accepted tissue, and at 90 the floor drew **33.5% of the
slice pure black** (46% on the worst recording). At 50 it draws 2.0%, the dim
sixth of the slice goes from grey 2 to 44, and the empty field still renders at
a median of zero — 50 is the last floor at which it does.

**And the ramp is a curve, not a line: `--display-gamma`, 0.7 by default.**
Retuned on 2026-08-31, when the same complaint came back in the same words. The
2026-08-28 sweep measured only the bright tenth of the tissue and fixed the
bright tenth; measured again with a column for the **dim third** — the pixels
the complaint is about — a straight ramp draws it at 29 of 255, which a red
lookup table draws as near-black. No pair of endpoints reaches it, because a
slice glows on top of the medium it sits in and its dim third therefore sits
just above the black point however that point is chosen. At `0.7` the dim third
is drawn at 56 and swings 51 grey levels over a recording instead of 39, and
because the curve is applied *after* the clip the two percentiles still mean
exactly what they meant — clipping is unchanged at both ends. `1.0` is the old
straight line; `0.5` doubles the effect again for a dim preparation, at the cost
of visible grain in the empty field.

**When even a curve is not enough, `--filters amplitude`.** A slice whose tissue
sits a thousand counts above the empty field and swings a tenth of that spends
most of the ramp on the constant part however it is ranged. `amplitude` takes
each pixel against its own quiet level — the tenth percentile of its own time
course — so the ramp is spent on the rhythm, and on the reference plate that is
five times the visible amplitude. The price is that a tile then shows *change*
rather than brightness, which is why it is a filter you ask for and not a
default. Display only, like everything it feeds.

A recording that will not open stops the sheet. `--skip-bad` finishes without it
and prints what was left out, because a grid quietly missing three of its wells
is worse than no grid.

A sheet is **filed under the set of recordings that made it**, and lands in that
folder's own `AI_Exports`. Filing it under whichever recording was listed first
is not just odd to read — add a well and the old sheet's key would still match,
so a stale sheet would look current. A montage of a single recording is still
filed under that recording, because there it really is a fact about it.

The still grid and video grid are display only on the same terms as each
individual movie, and their records carry the same `display_only` mark.

### Accepted outlines on display exports

Every image, movie and either kind of grid takes the same outline controls:

```bash
auto-organotypic image D:/outlined --outline
auto-organotypic grid D:/outlined --outline --outline-mode only
auto-organotypic video A1.tif --outline A1_SCN_LABELS_ROI_CROP_TIGHT.tif
auto-organotypic video-grid D:/pull --outline D:/analysis/batch_scn_outline_manifest.json
```

`--outline` without a path finds the accepted labels from the outline-stage
source or `batch_scn_outline_manifest.json`. Python also accepts a label array,
an ordered list for a grid, or a mapping keyed by source path, filename, stem or
well name. The labels must already share the rendered image's orientation and
crop; they are never resized into a plausible-looking but displaced boundary.

The default `--outline-mode copy` keeps the unchanged export and adds an
`*_outline` copy. `--outline-mode only` writes just the outlined export at the
requested name. The default boundary is opaque cyan and two pixels wide;
`--outline-colour`, `--outline-width-px` and `--outline-opacity` change it.

## The window

```
auto-organotypic ui                                       # a window on this machine
auto-organotypic ui --browser                             # the same page, in a browser
auto-organotypic serve --host 0.0.0.0 --root D:/Imaging   # one machine, whole lab
```

A window, not a browser tab. Pick a folder — with the operating system's own
dialog — see what is in it, start a run and watch the stages tick.

Underneath, the window is a loopback server drawing the same page `serve`
serves. That is the reason it is not a desktop toolkit: Tkinter would give a
window and take away hosting, and both were asked for. The window announces
nothing and owns its server, so closing it stops it.

Every control on the page is a flag of `auto-organotypic pipeline` with the same
default, so nothing it can ask for is something a shell cannot — a test asserts
that rather than a paragraph promising it. The one thing the window can do that
the served page cannot is open that folder dialog, and a test pins the bridge to
exactly that.

Two behaviours worth knowing. A run is submitted and watched rather than
blocking, because registering ninety-six wells takes hours. And stopping takes
effect at the **next stage boundary** — a stage is one call into another
package, so "stop" honestly means "finish this one and do no more".

`serve` is the other half, and the only one with a host to worry about. There is
no login, so binding to anything but loopback **requires** at least one
`--root`, and every folder browsed, planned or run is checked against it.

```
$ auto-organotypic serve --host 0.0.0.0
--host 0.0.0.0 serves this to the network, so it needs at least one --root
saying which folders it may read and write. Without one the folder picker would
offer the whole machine to anybody who can reach the port, and there is no login
to stop them.
```

## The method is frozen

The outline is accepted Round 6 attempt 7 and the orientation accepted Round 9
attempt 6, from the Cry1-DIO-dLuc red-channel tuning project. The public settings
retain the accepted pixel values, because six declared attempts to simplify or
normalise them failed the truth, shape or generalisation gates — a shorter interface
would have meant a less portable method, not a tidier one.

`tests/test_automatic_scn_outline_parity.py` compares **output bytes** against ten
accepted fields rather than comparing behaviour. That evidence lives in the governed
tuning project rather than in this repository; point `AUTO_ORGANOTYPIC_TUNING_ROOT` at it to
run those tests, and without it they skip while the synthetic crop, orientation and
input-safety tests still run.

## Install

```
pip install auto-organotypic
```

Only numpy, scipy, tifffile, scikit-image and imagecodecs. No plotting stack,
no web framework, no audit layer: outlining a slice should not install any of
them. The last two are there because the method genuinely reaches them.
scikit-image for `convex_hull_image`, inside the consensus that places the
midline; it was declared under the `register` extra alone until 2026-08-25,
which meant a plain install imported, read a manifest, and then failed partway
through the first outline. imagecodecs because it is what tifffile decodes LZW
with — tifffile’s own fallback handles zlib, LZMA, Zstd and PackBits and not
LZW — so without it a plain install raised on the first frame of any recording
Bio-Formats or Fiji had written, which is most of them. Reading its own input
is not an optional capability, so it is not an extra.
`tests/test_self_contained.py` runs the outline with everything undeclared
blocked, which is the check that found the first of the two.

Eight extras, each needed only for what it names — and none of them to *read* a
finished pull, because a manifest is plain JSON:

| Extra | For |
| --- | --- |
| `auto-organotypic[incucyte]` | starting an Incucyte acquisition |
| `auto-organotypic[lv200]` | pulling from a running LV200 (PyLV200) |
| `auto-organotypic[register]` | registration (OpenCV, scikit-image) |
| `auto-organotypic[image]` | stills, grids and colour maps (Pillow, matplotlib) |
| `auto-organotypic[video]` | movies too, which need an encoder (adds imageio-ffmpeg, imageio) |
| `auto-organotypic[rhythm]` | periods, cosinor fits and the rhythm verdict (circadian-workbench) |
| `auto-organotypic[ui]` | the page and the server behind it (FastAPI, uvicorn) |
| `auto-organotypic[desktop]` | the window (adds pywebview, which borrows the webview your machine already has) |

## Where this came from

These modules lived in [PyMicroglia](https://pypi.org/project/PyMicroglia/) until
2026-08-23. Nothing about outlining a suprachiasmatic nucleus concerns microglia, and
the code was already a leaf — nothing in that package imported it. PyMicroglia keeps
the `automatic_scn_outline` action, which now delegates to
`auto_organotypic.outline`:

```
pip install "PyMicroglia[scn]"
```

That extra reaches PyPI with PyMicroglia's next release; the version published
there today predates the move and has neither the extra nor the delegating
module.

## Where this is going

Auto-Organotypic is the SCN layer of an automated instrument-to-rhythm pipeline:
download, crop broadly, register, outline and crop, trace, test the rhythm,
render videos. The headless command is stage 3 of that plan, the window stage 4
and this release stage 5. `docs/automated-scn-pipeline.md`, in the repository,
records which package owns which step and why.

## License

MIT.
