2. The sequence model#
Everything in acia that produces or consumes images speaks one type:
ImageSequenceSource. Learn its shape conventions and its
indexing rules once, and every reader, every renderer and every processor
behaves the same way.
This notebook uses a synthetic sequence so it runs instantly and you can see exactly what goes in and what comes out.
# On Colab (or any fresh environment) this installs acia.
# Locally, if you already have acia installed, it is a no-op.
try:
import acia # noqa: F401
except ImportError:
%pip install -q acia
THWC#
acia lays out image sequences as (T, H, W, C) — time, height, width,
channel — with the channel axis last.
There is no Z. acia is a 2D+t library: it models a time-lapse of a single
focal plane, which is what live-cell imaging in a microfluidic chip or on a
coverslip actually produces.
THWCSequenceSource wraps a numpy array in that layout
and is the simplest source there is. It validates the shape, so a mistake is
caught at construction rather than three steps later.
import numpy as np
from acia.segm.local import THWCSequenceSource
rng = np.random.default_rng(0)
T, H, W, C = 24, 128, 160, 1
stack = np.zeros((T, H, W, C), dtype=np.uint8)
# a bright disc drifting diagonally across the field of view
yy, xx = np.mgrid[0:H, 0:W]
for t in range(T):
cy, cx = 30 + 2.5 * t, 30 + 4.0 * t
disc = ((yy - cy) ** 2 + (xx - cx) ** 2) < 12**2
stack[t, ..., 0] = (disc * 200).astype(np.uint8)
stack += rng.integers(0, 25, size=stack.shape, dtype=np.uint8)
src = THWCSequenceSource(stack)
src
Pass an array that is not 4-dimensional and you get told immediately:
try:
THWCSequenceSource(np.zeros((10, 64, 64))) # missing the channel axis
except ValueError as err:
print("ValueError:", err)
ValueError: Please make sure to have TxHxWxC image stack. Currently it is: (10, 64, 64)
Size and iteration#
A source is Sized and Iterable: len() is the number of timepoints, and
iterating yields one frame at a time — lazily, so a long sequence never has to
fit in memory.
print("len(src) ", len(src))
print("size_t ", src.size_t)
print("size_h ", src.size_h)
print("size_w ", src.size_w)
print("size_c ", src.size_c)
print("channels ", src.num_channels)
mean_per_frame = [float(np.asarray(f.raw).mean()) for f in src]
print("first 5 frame means:", [round(m, 1) for m in mean_per_frame[:5]])
len(src) 24
size_t 24
size_h 128
size_w 160
size_c 1
channels 1
first 5 frame means: [16.3, 16.4, 16.2, 16.3, 16.3]
Indexing: integers give frames, slices give views#
Indexing follows numpy over the same four axes. The one rule to remember:
an integer on the time axis returns that frame (a
BaseImage);a slice or list returns a new source — a lazy view.
frame = src[5] # integer -> one frame
view = src[5:15] # slice -> a lazy view sequence
print("src[5] ->", type(frame).__name__, np.asarray(frame.raw).shape)
print("src[5:15] ->", type(view).__name__, "with", len(view), "frames")
src[5] -> LocalImage (128, 160, 1)
src[5:15] -> SlicedSequenceSource with 10 frames
Subsample, crop, pick a channel#
All four axes at once, in one expression:
print("every 2nd frame ", len(src[::2]))
print("frames 3..22 ", len(src[3:23]))
print(
"spatial crop ",
(src[:, 20:100, 30:130].size_h, src[:, 20:100, 30:130].size_w),
)
print("channel 0 only ", src[..., 0].size_c)
composed = src[::2, 20:100, 30:130, 0]
print(
"subsample + crop + chan",
(composed.size_t, composed.size_h, composed.size_w, composed.size_c),
)
every 2nd frame 12
frames 3..22 20
spatial crop (80, 100)
channel 0 only 1
subsample + crop + chan (12, 80, 100, 1)
This is the single most useful habit in acia: subsample before you compute.
Segmentation and video rendering cost time proportional to frames and pixels, so
developing an analysis on src[::20, 256:768, 256:768] and only then turning the
knob back up turns a coffee break into a few seconds.
Views are lazy and they compose#
A view holds a reference to its parent and computes frames on demand. Nothing is copied until you ask for pixels, and views of views are fine.
v1 = src[::2] # every 2nd frame
v2 = v1[1:] # ... then drop the first of those
v3 = v2[:, :64, :64] # ... then crop
for name, s in [("src", src), ("src[::2]", v1), ("...[1:]", v2), ("...crop", v3)]:
print(f"{name:10} {type(s).__name__:24} T={len(s):3} H={s.size_h} W={s.size_w}")
src THWCSequenceSource T= 24 H=128 W=160
src[::2] SlicedSequenceSource T= 12 H=128 W=160
...[1:] SlicedSequenceSource T= 11 H=128 W=160
...crop SlicedSequenceSource T= 11 H=64 W=64
Channels#
to_channel(c) is the readable form of src[..., c], and works on every source
implementation.
# a two-channel sequence: the disc, plus an inverted copy
two_channel = THWCSequenceSource(np.concatenate([stack, 255 - stack], axis=-1))
print("channels ", two_channel.num_channels)
print("to_channel(1) ", two_channel.to_channel(1).num_channels)
print("equivalent to ", two_channel[..., 1].num_channels)
channels 2
to_channel(1) 1
equivalent to 1
When you do want it all in memory#
materialize() walks the sequence once and returns a
THWCSequenceSource backed by a real (T, H, W, C)
array. Use it deliberately — after cropping and subsampling, not before.
small = src[::4, 40:104, 40:104]
eager = small.materialize()
print(type(eager).__name__, "->", eager.image_stack.shape, eager.image_stack.dtype)
THWCSequenceSource -> (6, 64, 64, 1) uint8
What you learned#
You want |
You write |
|---|---|
one frame |
|
every 2nd frame |
|
a frame range |
|
a spatial crop |
|
one channel |
|
all of the above |
|
the whole thing as an array |
|
Views are lazy, compose freely, and never copy pixels until asked.
Next: 3. Look at your data — turning a source into something you can actually see.