2. The sequence model#

Open In Colab

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

src[5]

every 2nd frame

src[::2]

a frame range

src[3:23]

a spatial crop

src[:, 100:200, 50:150]

one channel

src[..., 0] or src.to_channel(0)

all of the above

src[::2, 100:200, 50:150, 0]

the whole thing as an array

src.materialize()

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.