4. Calibration and units#
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_sizeandframe_intervalonce, 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.