4. Calibration and units#

Open In Colab

A cell is not “412 units” big. It is 15.9 µm². The difference between those two statements is the pixel size, and getting it wrong — or forgetting it — is one of the easier ways to publish a wrong number.

acia handles this with pint: you declare the physical calibration once, at load, and it travels with the data through slicing, into overlays, and out through the property extractors.

This notebook uses a synthetic sequence so every number is checkable by hand.

# 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

Without calibration#

Build a sequence of discs of a known pixel radius, and a matching overlay.

import numpy as np

from acia.segm.formats import overlay_from_masks
from acia.segm.local import THWCSequenceSource

T, H, W = 6, 128, 128
RADIUS = 10  # pixels

yy, xx = np.mgrid[0:H, 0:W]
masks = np.zeros((T, H, W), dtype=np.uint16)
for t in range(T):
    masks[t][((yy - 64) ** 2 + (xx - 64) ** 2) < RADIUS**2] = 1

images = THWCSequenceSource(np.zeros((T, H, W, 1), dtype=np.uint8))
overlay = overlay_from_masks(masks)

print("detections:", len(overlay))
print("expected area:", round(np.pi * RADIUS**2, 1), "px^2")
detections: 6
expected area: 314.2 px^2

Run an area extractor against an uncalibrated source and watch what acia does: it still labels the column micrometer ** 2, because that is the extractor’s declared output unit — but it emits a warning telling you the number underneath is really pixels. A silently plausible wrong number is the thing to fear here, so acia refuses to produce one quietly.

from acia.analysis import AreaEx, ExtractorExecutor, FrameEx, TimeEx

df = ExtractorExecutor().execute(overlay, images, extractors=[FrameEx(), AreaEx()])

print(df.head(3))
print()
print("units:", df.attrs["units"])

pixel_area = df["area"].iloc[0]
print(
    "measured:",
    pixel_area,
    "pixels (ideal disc would be",
    round(np.pi * RADIUS**2, 1),
    "-- a rasterised circle is not exact)",
)
    frame   area
id              
1     0.0  305.0
2     1.0  305.0
3     2.0  305.0

units: {'frame': '1 dimensionless', 'area': 'micrometer ** 2'}
measured: 305.0 pixels (ideal disc would be 314.2 -- a rasterised circle is not exact)
/home/runner/work/acia-core/acia-core/acia/analysis/__init__.py:264: UserWarning: AreaEx: the image source has no pixel_size, so 'area' is measured in pixels but labelled <Unit('micrometer ** 2')>. Load the source with pixel_size=... for physically meaningful values.
  result_df, extractor_units = extractor.extract(overlay, images, df)

With calibration#

Declare pixel size and frame interval once. Both are pint quantities, so the units are part of the value, not a comment.

from acia import ureg

calibrated = images.with_pixel_size(0.065 * ureg.micrometer).with_frame_interval(
    10 * ureg.minute
)

print("pixel size ", calibrated.pixel_size)
print("timepoints ", calibrated.timepoints)
pixel size  0.065 micrometer
timepoints  [ 0 10 20 30 40 50] minute
df = ExtractorExecutor().execute(
    overlay, calibrated, extractors=[FrameEx(), AreaEx(), TimeEx()]
)

print(df.head(3))
print()
print("units:", df.attrs["units"])
print()
print("same object, now in physical units:")
print(f"  {pixel_area} px * (0.065 um)^2 = {pixel_area * 0.065**2:.6f} um^2")
print(f"  extractor reported            = {df['area'].iloc[0]:.6f} um^2")
    frame      area      time
id                           
1     0.0  1.288625  0.000000
2     1.0  1.288625  0.166667
3     2.0  1.288625  0.333333

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

same object, now in physical units:
  305.0 px * (0.065 um)^2 = 1.288625 um^2
  extractor reported            = 1.288625 um^2

Nothing about the extractor call changed. The extractors reach into the source for the calibration themselves — see Slicing and calibration for the precedence rules if you ever need to override them per extractor.

Calibration survives slicing#

This is the part that saves you. A slice is a different physical sampling, and acia transforms the calibration to match instead of quietly carrying the old numbers along.

print("original    ", calibrated.pixel_size, "|", calibrated.timepoints[:4])
print()

# temporal subsampling -> the effective interval scales
print("[::2]       ", calibrated[::2].pixel_size, "|", calibrated[::2].timepoints[:4])

# a spatial CROP does not change the pixel size
print("[:, :64]    ", calibrated[:, :64, :64].pixel_size)

# a spatial STEP does -- each output pixel now covers twice the distance
print("[:, ::2, ::2]", calibrated[:, ::2, ::2].pixel_size)
original     0.065 micrometer | [ 0 10 20 30] minute

[::2]        0.065 micrometer | [ 0 20 40] minute
[:, :64]     0.065 micrometer
[:, ::2, ::2] 0.13 micrometer

Unit-safe arithmetic#

The DataFrame above holds plain floats with the units recorded in df.attrs — convenient, but inert: nothing stops you adding an area to a length. Ask for units="pint" and the columns become unit-aware, so that mistake raises.

from acia.analysis import PerimeterEx

q = ExtractorExecutor().execute(
    overlay,
    calibrated,
    extractors=[FrameEx(), AreaEx(), PerimeterEx(), TimeEx()],
    units="pint",
)

print("dtypes:")
print(q.dtypes)
print()
print("converted:", q["area"].pint.to("nanometer ** 2").iloc[0])
print("ratio    :", (q["area"] / q["perimeter"]).iloc[0])
dtypes:
frame                               float64
area         pint[micrometer ** 2][Float64]
perimeter         pint[micrometer][Float64]
time                    pint[hour][Float64]
dtype: object

converted: 1288625.0 nanometer ** 2
ratio    : 0.26085526315789476 micrometer
try:
    q["area"] + q["perimeter"]
except Exception as err:
    print(type(err).__name__)
    print(err)
DimensionalityError
Cannot convert from 'micrometer ** 2' ([length] ** 2) to 'micrometer' ([length])

That exception is the whole point: an area and a length are not commensurable, and the type system now knows it.

There is a third form, units="header", which keeps floats but moves the unit into the column index — the one to use when writing a CSV somebody else will read. All three are convertible after the fact, so the choice is never a dead end. See Units in the extracted tables for the full comparison.

Where calibration comes from#

You will not always have to type it in:

  • ND2 and CZI carry pixel size and frame interval in their metadata, and the readers pick them up automatically.

  • TIFF may carry OME-XML or ImageJ calibration; read_tiff_calibration() reads it from the headers, and the TIFF sources call it lazily.

  • Anything explicitly passed to the constructor, or set later with with_pixel_size() / with_frame_interval(), wins over the file.

When a file genuinely has no calibration — as with the plain TIFFs in the other tutorials — you get None rather than a fabricated default, which is your cue to supply it from the acquisition settings.

What you learned#

  • Declare pixel_size and frame_interval once, as pint quantities.

  • Slicing transforms them: temporal steps scale the interval, spatial steps scale the pixel size, crops leave it alone.

  • Extractors pull calibration from the source, so results come out in µm² and hours without per-extractor configuration.

  • units="pint" makes the resulting table unit-safe, turning a dimensional mistake into an exception instead of a wrong figure.

Next: 5. Segment and quantify — the payoff, on real data.