5. Segment and quantify#

Open In Colab

This is the one that pays for the previous four. We take the raw time-lapse, segment every cell with a deep-learning model, measure them in physical units, throw out the artefacts, and end with a growth rate — the kind of number that goes in a figure.

Notably, none of this needs tracking. extract_growth() aggregates cell area per timepoint, so you get a population growth rate from segmentation alone. Tracking is what you add when you want per-lineage answers.

Tip

No GPU required. Omnipose’s bact_phase_omni is a small model — the whole notebook runs in about a minute on a plain CPU, so it works on a laptop or a free Colab runtime. A GPU makes it faster, not possible.

# 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

Install a segmentation backend#

We use Omnipose with its bact_phase_omni model, which is trained on exactly this kind of imagery: bacteria in phase contrast. It is also small (a 25 MB model) and fast enough on a CPU that this notebook runs without a GPU in about a minute.

acia supports several backends, but they pin conflicting versions of cellpose, torch and numpy, so exactly one can be installed per environment — see Installation. Swapping backends is a one-line change in the cell further down; swapping environments is the price.

try:
    import omnipose  # noqa: F401
except ImportError:
    %pip install -q omnipose==1.0.6 natsort
from pathlib import Path
from urllib.request import (
    HTTPBasicAuthHandler,
    HTTPPasswordMgrWithDefaultRealm,
    build_opener,
    install_opener,
    urlretrieve,
)

# A public ownCloud share. The share token acts as the username, so single
# frames can be fetched over WebDAV instead of downloading the whole 800-frame,
# ~800 MB archive.
SHARE = "D0A9ftcpqwKm1Rq"
BASE = "https://fz-juelich.sciebo.de/public.php/webdav"
SEQUENCE = Path("data/colony")

N_FRAMES = 20  # of 800 available
FRAME_STEP = 20  # every 20th frame -> 20 minutes between the frames we keep

mgr = HTTPPasswordMgrWithDefaultRealm()
mgr.add_password(None, BASE, SHARE, "")
install_opener(build_opener(HTTPBasicAuthHandler(mgr)))

SEQUENCE.mkdir(parents=True, exist_ok=True)
for i in range(N_FRAMES):
    name = f"t{i * FRAME_STEP:04d}.tif"
    target = SEQUENCE / name
    if not target.exists():
        urlretrieve(f"{BASE}/00/{name}", target)

print(SEQUENCE, "->", len(list(SEQUENCE.glob("*.tif"))), "frames (~1 MB each)")
data/colony -> 20 frames (~1 MB each)

Load the sequence#

We work with all 20 downloaded frames at full resolution — Omnipose is cheap enough that there is no need to shrink further here. On a longer sequence, or a heavier backend, the subsampling habit from tutorial 2 is what keeps iteration fast.

import torch

print("accelerator:", "cuda" if torch.cuda.is_available() else "none (CPU)")
print("Omnipose runs fine either way -- roughly 3 s per frame on a CPU.")
accelerator: none (CPU)
Omnipose runs fine either way -- roughly 3 s per frame on a CPU.
from acia import ureg
from acia.segm.open import open_sequence

src = open_sequence(SEQUENCE).position(0)
src = src.with_pixel_size(0.072 * ureg.micrometer).with_frame_interval(20 * ureg.minute)

print(f"{len(src)} frames, {src.size_h}x{src.size_w} px")
print("time span:", src.timepoints[-1].to("hour"))
20 frames, 1094x938 px
time span: 6.333333333333333 hour

Segment#

A segmenter is a callable: hand it a source, get back an Overlay of detections. The model is loaded lazily on first use and, by default, released afterwards so the GPU memory goes back to the system.

from acia.segm.processor.omnipose import OmniposeSegmenter

segmenter = OmniposeSegmenter(model="bact_phase_omni")

overlay = segmenter(src)

print(len(overlay), "detections across", overlay.numFrames(), "frames")
2026-08-26 14:37:58,270 [INFO] >>bact_phase_omni<< model set to be used
2026-08-26 14:37:58,271 [INFO] Downloading: "https://www.cellpose.org/models/bact_phase_omnitorch_0" to /home/runner/.cellpose/models/bact_phase_omnitorch_0
https://www.cellpose.org/models/bact_phase_omnitorch_0 /home/runner/.cellpose/models/bact_phase_omnitorch_0
2026-08-26 14:37:59,447 [INFO] >>>> using CPU
346 detections across 20 frames
  0%|          | 0.00/25.3M [00:00<?, ?B/s]
  3%|▎         | 896k/25.3M [00:00<00:02, 9.12MB/s]
 27%|██▋       | 6.87M/25.3M [00:00<00:00, 40.6MB/s]
100%|██████████| 25.3M/25.3M [00:00<00:00, 98.9MB/s]

Each detection knows its frame, its id and its area in pixels — the raw geometric measurement, before any calibration is applied.

One thing a segmenter does not do is attach a time model: contour.time is None until the overlay is told what the frames mean. (Trackers do this for you; a bare segmentation does not.) Attaching it is one call, and worth doing because it makes every detection self-describing.

first = next(iter(overlay))
print(
    "before:", first.frame, first.id, round(first.area, 1), "px^2, time =", first.time
)

overlay = overlay.with_timepoints(src.timepoints)

first = next(iter(overlay))
print(
    "after :", first.frame, first.id, round(first.area, 1), "px^2, time =", first.time
)
before: 0 1 136.0 px^2, time = None
after : 0 1 136.0 px^2, time = 0 minute

See what the model did#

Never trust a segmentation you have not looked at. render_segmentation_mask() paints the masks over the image and, as always, returns a source — so it composes with the annotation and video helpers from tutorial 3.

import matplotlib.pyplot as plt
import numpy as np

from acia.viz import render_segmentation_mask

painted = render_segmentation_mask(src.to_rgb(), overlay, alpha=0.5)

fig, axes = plt.subplots(1, 3, figsize=(12, 4.2))
for ax, idx in zip(axes, [0, len(src) // 2, len(src) - 1], strict=True):
    ax.imshow(np.asarray(painted[idx].raw))
    ax.set_title(f"{src.timepoints[idx].to('hour'):~.1f}")
    ax.axis("off")
fig.suptitle("Omnipose segmentation")
fig.tight_layout()
plt.show()
../_images/94d85992b4d0834d0ca1609d844d486285fa09215d894756e80a6a92aa11128b.png
from IPython.display import Video

from acia.viz import render_video

render_video(painted, "segmented.mp4", framerate=4)
Video("segmented.mp4", embed=True, width=420)
[rawvideo @ 0x2387f180] Stream #0: not enough frames to estimate rate; consider increasing probesize

Measure#

ExtractorExecutor turns the overlay into a tidy, id-indexed DataFrame — one row per detection, one column per property. Because the source is calibrated, areas come out in µm² and times in hours without any further configuration.

from acia.analysis import (
    AreaEx,
    BoundaryClosenessEx,
    CircularityEx,
    ExtractorExecutor,
    FrameEx,
    PerimeterEx,
    TimeEx,
)

properties = ExtractorExecutor().execute(
    overlay,
    src,
    extractors=[
        FrameEx(),
        TimeEx(),
        AreaEx(),
        PerimeterEx(),  # CircularityEx is derived from area and perimeter,
        CircularityEx(),  # so PerimeterEx must come before it
        BoundaryClosenessEx(),
    ],
)

print(properties.head())
print()
print("units:", properties.attrs["units"])
    frame  time      area  perimeter  circularity  boundary_closeness
id                                                                   
1     0.0   0.0  0.705024      3.888     0.586086               2.808
2     0.0   0.0  0.305856      2.592     0.572080               0.000
3     0.0   0.0  0.165888      2.016     0.512913               0.000
4     0.0   0.0  2.286144      7.056     0.577027              29.520
5     0.0   0.0  2.073600      6.768     0.568871              29.304

units: {'frame': '1 dimensionless', 'time': 'hour', 'area': 'micrometer ** 2', 'perimeter': 'micrometer', 'circularity': 'dimensionless', 'boundary_closeness': 'micrometer'}

Throw out the artefacts#

Real segmentations contain debris and merged blobs. apply_cell_filters() takes the measured table and a list of filters, with thresholds in physical units — which is the whole reason we bothered with calibration.

from acia.segm.filter import AreaFilter, BoundaryClosenessFilter, apply_cell_filters

filters = [
    # a C. glutamicum cell is roughly 1-3 um^2; anything far below that is debris
    AreaFilter(vmin=0.3 * ureg.micrometer**2, vmax=10 * ureg.micrometer**2),
    # a cell clipped by the edge of the image has a meaningless area
    BoundaryClosenessFilter(min_distance=1 * ureg.micrometer),
]

filtered_overlay = apply_cell_filters(overlay, filters, properties=properties)

print(
    f"{len(overlay)} detections -> {len(filtered_overlay)} kept "
    f"({len(overlay) - len(filtered_overlay)} removed)"
)
346 detections -> 319 kept (27 removed)

plot_property_histograms() shows the before and after distributions together, so you can see what a threshold actually did rather than guessing.

from acia.analysis.properties import plot_property_histograms

properties_after = ExtractorExecutor().execute(
    filtered_overlay,
    src,
    extractors=[FrameEx(), TimeEx(), AreaEx(), PerimeterEx(), CircularityEx()],
)

plot_property_histograms(
    properties,
    ["area", "circularity"],
    df_after=properties_after,
    show_removed=True,
)
plt.show()
../_images/ba29d500c08ec25812f67a6ecfeccf93706287cbf8d878e02eadf3efd7aee0a1.png

The growth rate#

extract_growth() does the last step in one call: it aggregates total cell area per timepoint, fits an exponential model, and returns the table, the fit result and a figure.

from acia.analysis import extract_growth

table, result, figure = extract_growth(filtered_overlay, src, time_unit="hour")

print(table.head())
print()
print("growth rate  :", result.growth_rate)
print("doubling time:", result.doubling_time)
print("R^2          :", round(result.r_squared, 4))
plt.show()
    frame      time      area
id                           
1     0.0  0.000000  0.705024
4     0.0  0.000000  2.286144
5     0.0  0.000000  2.073600
10    1.0  0.333333  0.580608
14    1.0  0.333333  2.788992

growth rate  : 0.5427718663757394 / hour
doubling time: 1.2770506791155367 hour
R^2          : 0.9997
../_images/4140584a42da6cb0d615b26e63200614758214e9653e8a89919a8f002a7eb793.png

Filters are only safe when they are uncorrelated with the answer#

Both filters above improved the fit rather than distorting it — the R² went from 0.991 unfiltered to 1.000. That is the outcome you want, and it is not automatic.

BoundaryClosenessFilter is a good example of a filter whose correctness depends entirely on the data. Here the colony grows in the middle of a cultivation chamber, so whether a cell touches the image border is essentially independent of how much the colony has grown — dropping those cells removes noise and nothing else.

Point the same filter at a dense field where cells cover the whole frame and it does the opposite: border-touching becomes correlated with growth, so the filter removes the signal along with the artefacts, and you still get a confident-looking growth rate that is simply wrong.

The rule worth carrying: a filter is safe only when what it removes is independent of the quantity you are measuring. Always compare the before/after distributions and the resulting fit, rather than applying a filter because it sounds prudent.

Note

Treat the exact number with appropriate caution: this is one microcolony, 20 frames out of 800, segmented with a stock model and no parameter tuning. That said, ~1.3 h is a plausible doubling time for C. glutamicum under these conditions, and the fit is tight. What matters for the tutorial is that the pipeline is complete and every quantity carries its unit — scaling up to the full sequence, a tuned model, or hundreds of positions is a matter of changing parameters, not code.

Where to go next#

You now have the full loop: open → slice → visualize → segment → measure → filter → quantify.

  • Tracking and lineages. Add a tracker (TrackastraTracker, UltrackTracker, LaptrackTracker, PyUATTracker) to follow individual cells through divisions, then plot lineage trees and per-cell doubling times.

  • A different backend. Replace CellposeSAMSegmenter with CellposeSAMSegmenter (a strong generalist, but far heavier on CPU), CellposeSegmenter, CPNSegmenter or YOLOSegmenter — same call signature, different environment.

  • Scale it up. acia.analysis.scale() runs this notebook once per sequence across hundreds of positions; see Scaling a notebook over many sequences.

  • Real experiments. The acia-workflows collection has complete published analyses — growth-rate quantification, fluorescence co-culture, single-cell oxygen response — built on exactly these pieces.