Changelog#
All notable changes to this project are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
Add your changes under [Unreleased]; the release workflow promotes that section to a dated version entry automatically.
Entries for 0.3.2 and earlier were reconstructed from the git history after the fact and are summarised rather than exhaustive; from 0.4.0 on they are written as the work lands.
[Unreleased]#
Added#
Recording a stage as it happens (acia.analysis.StageContext) — the record is now
written while the stage runs, and is visible in the notebook that produced it:
StageContext.for_image(..., stage='Segment')names the stage up front, which is what lets everything below be written immediately rather than in one call at the end.log_params(),log_metrics(),log_figure(),log_artifact()persist tostage_manifest.jsonthe moment they are called. Parameters and figures are the parts of a run that re-running cannot recover, and a notebook that died half-way used to record nothing — whilescale(exist_skip=True)then skips that stage on every later run, so the run that died was exactly the one whose record was wanted.An unfinished stage is marked
"running"and a stage whose cell raised is marked"failed", with the exception recorded.finish()marks it"ok". Previously a crashed stage was indistinguishable from one that never ran.input_path()/output_path()state which artifacts a stage reads and writes. Direction is still observed rather than declared — that is what makes the dependency graph unable to fall out of date — but a declared output never written is reported asio.missing, and a declared read is recorded even for readers the audit hook cannot see (OpenCV’s C++ layer, a remote OMERO source).require()is now an alias ofinput_path().log_calibration(source)records the pixel size and frame interval a stage actually resolved, and warns when a later stage of the same population resolves a different one. The movie stays the single authority; this exists so that “what interval did this run use?” has an answer and a silent disagreement between two stages has something to notice it.calibration()reads it back.stage_table()gainserror_typeanderror_message, so a fan-out can say why a population stopped rather than only that it did — across a batch that is what separates one ROI running out of GPU memory from a chain that is broken for everything.ctxnow renders in a notebook: stages and how they ended, this stage’s parameters, metrics and figures, and every file in the folder with the stage that produced it.scale()records the parameters papermill injected, so a notebook no longer has to repeat its parameter cell intolog_params(), and renders each executed stage notebook to HTML beside it (needs the newreportextra; best-effort, never fatal).The manifest gains
status,params,metrics,figures,calibration,errorandio.declared_inputs, underacia.stage/v2. Additive: entries written by earlier versions have noschemakey and are read unchanged, andrecord()still writes its**extraflat at the top of the entry.
Interactive curation widgets (acia.notebook, new widget extra — the
whole module stays importable without anywidget installed):
SequenceDashboard: a three-pane curation UI (position gallery, ROI editor, selections list) over a multi-position acquisition, with a live client-side crop preview, numberedroi_01/roi_02default labels, Delete/Backspace to remove and Ctrl/Cmd+C to duplicate the active ROI, the open file’s path shown in the header, and auto-save on by default (save_dir=, honored byresume()).RegistrationDashboard: pick, verify and batch-apply a drift-correction method across every position, with verify progress and batch-apply ETA, a manifest checkpoint every 20 frames so an interrupted run loses at most that many, and a play/pause + scrubber side-by-side comparison player.ROICropper: a manual rotated-rectangle ROI on frame 0, fitted from ≥3 clicked points (cv2.minAreaRect) and/or dragged, resized and rotated; emits aRotatedCropSpec.FilterExplorer: live thresholds for the cell filters, one (min, max) slider per filter, recolouring the overlay as the handles move. Each contour’s value is precomputed once in Python and shipped to the browser, so dragging needs no kernel round-trip..paramsemits pint quantities;save()writesfilter_params.jsonfor a scaled run.
Frame registration (acia.registration):
A
RegistrationMethodabstraction with five peer implementations for rigid inter-frame drift —PhaseCorrelationHighpass,MaskedTemplateCorrelation,HoughLineRigidFit,FeatureRANSACEuclideanandGradientECC— each treating the microfluidic device’s static structure as the signal and the growing colony as noise.apply_correction,RegisteredSequenceSource(lazy per-frame correction),ImageSequenceSource.register(), andacia.registration_persistence(RegistrationRecord/RegistrationManifest+ save/load).GradientECCgained a coarse-to-fine pyramid (single-level ECC was slow and empirically prone to non-convergence at production resolution), plus opt-inearly_stop_delta_px(6–9× faster within the same ±0.5 px/±0.5° tolerance) andtranslation_only.Registration on time-lapses whose content changes (a colony growing into the field of view), where a fixed-reference correlation coefficient decays with elapsed biology rather than with misalignment and a confidence gate starts rejecting perfectly good fits in a contiguous tail of late frames:
GradientECC(exclude_rects=..., exclude_shrink_px=...)leaves the regions whose content changes (typically the ROIs a caller already marked) out of the ECC objective, viacv2.findTransformECC’sinputMask.exclude_shrink_pxkeeps a band just inside each rectangle’s border, whose static device geometry measurably helps precision.acia.registration.ReanchoringReferencewraps anyRegistrationMethodwith a reference policy:"fixed"(the previous behavior),"reanchor"(fall back to the last successfully registered frame and compose, only where a frame would otherwise be recorded as a failure), or"chained".acia.registration.compose(first, second)chains twoFrameTransforms; pivot-independent, so it needs no frame shape.FrameTransform.confidence: the estimating method’s own goodness-of-fit score, persisted per frame so a run’s confidence trend is auditable. Reported byGradientECC,MaskedTemplateCorrelationandFeatureRANSACEuclidean;Noneelsewhere.on_low_confidenceon each of those three methods (andRegistrationDashboard(low_confidence=...), which forwards it):"keep"(new default) returns a fit scoring below the method’s threshold anyway, with itsconfidenceset and aLowConfidenceWarningnaming the score and the threshold it missed;"reject"raisesRegistrationErroras before. Keeping meansregistration_transforms.jsonholds a transform for every frame, soRegisteredSequenceSourcestops falling back to an uncorrected (or nearest-neighbour) frame downstream — a weak fit is now reported by its score rather than by its absence. Failures with no estimate to keep (blank input, non-convergence, a non-finite result, a match pinned to the search-window edge, too few correspondences to fit a model at all) still raise regardless of the policy.RegistrationDashboard(method_kwargs=..., reference_mode=...)andbatch_apply(..., method_kwargs=...)for per-position settings — registration method settings are now a supported argument rather than something a caller has to reach in and patch.batch_apply(sources={position: source})registers a supplied (e.g. lazily sliced) source per position instead of the full position.acia.registration.apply_correction_to_speccarries aRotatedCropSpecdrawn on one frame onto the drift-corrected (reference-frame) view, sosource.register(transforms).crop_rotated(spec)takes the region the spec was drawn around rather than wherever that frame had drifted to. It shares its matrix withapply_correction, so crop geometry and corrected pixels cannot disagree.ImageSequenceSource.register(..., on_missing=...), also onload_registration:"warn"(default),"nearest"(correct a failed frame with its neighbor’s transform instead of exporting it uncorrected, which is off by the full accumulated drift), or"error".RegisteredSequenceSource.missing_framesreports every gap at once.
ROI curation (acia.selection, acia.notebook):
SequenceDashboard(preview_frame=...)chooses the frame the gallery thumbnails and the ROI editor open on, defaulting to the middle frame. In a growing culture frame 0 is typically empty, so opening there gave no indication whether a chamber held any cells worth curating.RoiSelection.anchor_frame: the frame an ROI was drawn on, recorded inselection.json. Additive and omitted when zero, so manifests written by earlier versions load unchanged and are re-saved byte-identically (schema staysacia.selection/v1).
Image sources and I/O:
open_sequence(path): one entry point that dispatches by suffix to the ND2, CZI, TIFF or folder reader.ND2SequenceSource: one position of a Nikon ND2 file as a lazy(T, H, W, C)series, read one frame at a time so peak memory is a single frame (handles ~80 GB files). Optionalnd2extra.Zeiss CZI read support (
CZISequenceSource) and TIFF export;save_tiff_stackgainedome/compression/channel_names, so a cropped or registered export can carry OME metadata and land compressed rather than as a raw ImageJ hyperstack.FolderSequenceSource: a folder of per-timepoint TIFFs as one lazy sequence, where file i (natural-sorted) is frame i.open_sequencegrew a directory branch — a folder holding the TIFFs is one position, a folder whose immediate subfolders hold them is one position per subfolder — so the dashboards and the selection/registration manifests get folder input for free.fsspec-based remote image sources: read TIFFs from SMB/SAMBA shares (and any fsspec backend) via
SambaSequenceSource/LocalSequenceSource, pluslist_sequence_sourcesfor folder discovery and a per-user credentials store (acia.config).SelectionManifest/save_selection: persistence for curation selections.read_tiff_calibration: pixel size and frame interval auto-detected from OME-XML or ImageJ metadata, resolved lazily on first access so the zero-I/O-on-construct contract holds for remote paths. An explicit constructor argument still wins per field.RotatedCropSpec,crop_rotated()and the lazyRotatedCropSequenceSource(warps each frame on demand, calibration passed through), plus a genericmaterialize()that freezes any lazy source into memory.save_crop_capture/load_crop_spec: the full source frame as a normalized 8-bit PNG plus a JSON sidecar holding the crop as an oriented-box label with provenance, auto-enumerated into a dataset directory.ImageSequenceSource.to_channel(c): a generic lazy single-channel view (equivalent toself[..., c]), now available on every source implementation (e.g.LocalSequenceSource), not justTHWCSequenceSource.ImageSequenceSource.to_rgb(*, channel=0, colors=None): a lazy RGB view across every source — grayscale (normalize + triplicate) or per-channel colour composite, with a starter palette in the newacia.colors.numpy-style
(T, H, W, C)indexing on image sequence sources (src[::2, 100:200, 50:150, 0], composable lazy views) and temporal slicing of overlays (overlay[:20]).pint calibration defined at load:
pixel_sizeandframe_interval/timepointsflow through slices, into overlays (per-detectioncontour.time) and are pulled automatically by the extractors (explicitinput_unitstill overrides).
Stage chains and batch execution (acia.analysis):
StageContext.for_image(...)replaces the ~50 lines every stage notebook re-declared: it resolves the output folder, parses the population identity from the ROI name (key_patterntakes any regex, orNone), and exposespath()/require()/has()for artifacts,record()/manifest()for the append-onlystage_manifest.json, andkeyed()for adding key columns to an exported table (carryingdf.attrs["units"]across the assign).read_manifest()andstages_run()are the reader side.Stages now record what the filesystem observed rather than what the author remembered to declare — a notebook written before this gains provenance with no edit. A PEP 578 audit hook sees reads; a before/after diff of the output folder sees writes that Python cannot observe (
cv2.imwrite, ffmpeg). Measured overhead of the hook: none. What follows from having the record:stage_graph()derives the dependency graph instead of declaring it,check_stale()reports a stage whose input changed since it ran (warning only),ctx.clear(stage)removes exactly what a stage recorded, andstage_table()turns a batch into one DataFrame.Source-aware notebook scaling:
acia.analysis.scaleaccepts OMERO ids, file paths/URLs, or parameter dicts, with per-type default execution naming.scale(max_workers=...): optional parallel notebook execution over aProcessPoolExecutorstarted withspawn(papermill’schdiris process-global, so threads race;forkwould duplicate a CUDA-initialised parent). Default1is the previous sequential behaviour.Labelled progress bars for
scale: sequential runs name the stage and its input (02_Track.ipynb | pos001_roi002.tiff); parallel runs render one bar per pool worker in the parent process, since children cannot draw into a shared stderr legibly. Newstage_progresschooses whether finished bars stay as a timing log, collapse, or are suppressed.
Analysis:
estimate_growth_rate: the log-linear growth-rate fit as a reusable, unit-aware call (statsmodels OLS of log(y) on time), returning aGrowthRateResult— growth rate, standard error, confidence interval, doubling time, R², p-value as pint quantities — plus a figure.extract_growth: one call combiningExtractorExecutor(frame + physical time + physical area) withestimate_growth_rate.A pluggable
CellFilterabstraction with calibratedvalue()and a pint(vmin, vmax)range, built-insAreaFilter,LengthFilter,WidthFilter,CircularityFilterandBoundaryClosenessFilter, andapply_cell_filters. Thresholds are given in µm/µm², so they are camera-invariant.compute_doubling_timesandplot_doubling_time_hose(acia.analysis.doubling_time): per-cell doubling time from clean divisions (exactly one identified mother, exactly two daughters), always reading real time viasource.timepoints[frame_idx]so irregular timestamps are handled, with the mean’s evolution shown as percentile-bootstrap confidence bands.plot_property_histograms(acia.analysis.properties): properties h-stacked as columns, before/after v-stacked as rows, sharing bin edges and limits per column so outliers are directly comparable. Optionalshow_removedoverlays the filtered-out cells in red.Unit-aware property-extractor tables via pint-pandas:
ExtractorExecutor.execute(units="none"|"header"|"pint")plusattach_units/strip_units/units_in_headerconverters.write_units_csv/read_units_csv: store a CSV with its units and reload it into unit-aware pint columns, so derived columns get their units automatically.BoundaryClosenessEx, which also makes the boundary margin plottable alongside the other properties.ExtractorExecutoris empty-overlay safe: a 0-cell ROI no longer raises.
Visualization (acia.viz):
tracklet_graph_to_segments()andplot_tracklet_lineage(): a tracklet graph reshaped to one line per cell cycle, and a one-call wrapper over it.annotate_tracklet_times()stampsstart_time/end_time, andTrackastraTrackernow calibrates the tracked overlay from the source, so lineage plots read real time off the nodes and label the axis themselves.compose_sequences()/label_sequence(): tile image sequences horizontally or vertically with optional per-panel titles.ComposedSequenceSourceis itself anImageSequenceSource(so grids come from nesting) and lazy.render_segmentation_mask(colors=...): colour masks from a per-cell value table (a pandas Series, dict or single-column DataFrame) matched by id — an (r, g, b) triple used directly, a number mapped throughcmap, anything else categorical.
Segmentation:
Three new backends, each a
SegmentationProcessorsubclass behind its own optional-dependency extra:StarDistSegmenter(acia.segm.processor.stardist,stardistextra) wraps StarDist’s star-convex instance segmentation — no bacteria-specific pretrained model exists upstream, so it’s a contrast baseline for round/oval targets rather than a primary bacterial segmenter;MicroSAMSegmenter(acia.segm.processor.microsam,microsamextra) wraps micro-sam’s (μSAM) AIS automatic-instance-segmentation path via the light-microscopy-finetunedvit_b_lmcheckpoint by default;DeltaSegmenter(acia.segm.processor.delta,deltaextra) wraps DeLTA 3.x’s segmentation U-Net only (no tracking model touched), selectable by imagingregime("2D"agar-pad vs"mothermachine"), targeting the torch backend so it shares a torch stack with Cellpose/Omnipose instead of pulling in TensorFlow. See_bmad-output/implementation-artifacts/spec-additional-segmentation-backends.md.FlowposeRTSegmenter(acia.segm.processor.flowpose_rt): omnipose-compatible segmentation backed by the lightweightflowpose-rtpackage (no cellpose/omnipose/numba at runtime), selectable via the newflowpose-rtextra. Processes frames in configurable batches (batch_size, default 20) with atqdmprogress bar, matchingOmniposeSegmenter’s existing behavior.weights_path=...loads a local checkpoint (e.g. a fine-tuned model) instead of the downloaded zoo weights;modelthen names the zoo entry whose preprocessing contract the checkpoint follows.Per-backend optional-dependency extras (
cellpose,cellpose-sam,omnipose,flowpose-rt). The backends were previously imported lazily and never declared, so a user met missing dependencies one failure at a time at segmentation time. They have conflicting torch/numpy/cellpose pins and are mutually exclusive — install one per environment.Lazy model construction across
SegmentationProcessor, withrelease(), a re-entrantload()context manager, and anautoreleaseflag (default on) so a one-time-lapse run frees the GPU after each call.CellposeSAMSegmentersegments under atqdm.autobar (Cellpose-SAM routes its own progress to a logger at 30 s intervals, so it looked frozen), reports which model/device/diam_meanactually loaded, and acceptspretrained_model.
Persistence formats:
acia.segm.formats.save_segmentation/load_segmentation: a compressed binary polygon archive. Ids, labels and sub-pixel coordinates survive exactly, so a reloaded overlay still joins to a property table exported from the same segmentation.load_segmentationsniffs the format from magic bytes and also reads plain and gzipped simple-segmentation JSON.acia.tracking.formats.save_tracking/load_tracking: returns the same(overlay, tracklet_graph, tracking_graph)triple a tracking processor returns, so a step that loads is a drop-in for a step that tracked. Format choice is benchmarked, not assumed: on 150k detections the archive is 41 MiB / 1.5 s against gzipped JSON’s 106 MiB / 43 s.
Documentation: the published site was rebuilt around a getting-started path —
five runnable tutorials (committed source-only and executed at build time, so every
build proves they still run), an API reference generated by autosummary during the
normal sphinx-build, task-shaped guide pages, and a glossary. The build is
warning-free under -W.
Changed#
RegistrationDashboardnow defaults toreference_mode="reanchor", so a frame that cannot be estimated against the reference is retried against the last successfully registered one instead of being recorded as a failure. This is a pure fallback — a frame that already succeeded takes exactly the path it always did — andreference_mode="fixed"restores the prior behavior. Resuming a partial run recorded under a different policy re-registers the position rather than merging incompatible transforms; a clean fixed-mode record (no failed frames) is still reused under"reanchor", since re-anchoring would have produced it identically.registration_transforms.jsongained optionalreference_mode,reference_frames, per-transformconfidence, andmethod_paramsfields. All are additive in both directions: older manifests load unchanged, and a fixed-reference run still writes exactly the JSON it always did (schema staysacia.registration/v1).A registration fit scoring below its method’s confidence threshold is now kept by default (
on_low_confidence="keep"), warned about, and stored like any other transform, instead of raising and leaving the frame infailed_frameswithout one. Two consequences worth knowing:ReanchoringReference’s fallback is driven byRegistrationError, so with the default policy a merely unconfident fit no longer re-anchors — the method reports success and the estimate is carried forward as-is.reference_mode="reanchor"stays valid but now fires only on outright failures; construct the method withon_low_confidence="reject"to get the older interplay, where a low score re-anchors.Re-running
batch_applyover an existingregistration_transforms.jsondoes not repair it: a complete position is skipped and a partial one resumes by frame count, so frames a previous run recorded as failures are never re-estimated. Delete the file (or the affected records) and register those positions again to pick up the new policy.
SequenceDashboardno longer resets the frame scrubber when you switch position — matching what clicking a row in the selections list already did, and letting the same timepoint be compared across positions. The scrubber’s “(view only)” label is gone: the frame on screen is now the frame a newly drawn or edited ROI is anchored to. An ROI whose anchor is not the frame being shown renders muted and dashed with its anchor frame on the label; moving it re-anchors it to the displayed frame. Merely clicking an ROI selects it without re-anchoring.RegisteredSequenceSourcenow warns once per missing frame index instead of once per read, so a lazy multi-pass consumer (crop → write) no longer repeats the same warning on every pass.Unmeasurable geometry is reported as
nanrather than raising or being silently measured as 0:LengthEx/WidthExon a collapsed rotated rectangle andPerimeterExon an absent polygon. 0 would be a claim, and a 0-length cell passes any open-below bound, so junk would survive filtering;nanstates the property is undefined andCellFilter.maskdrops non-finite rows. A genuinely zero measurement stays 0.PropertyExtractor._calibratewarns when a source has nopixel_size, since the column is then labelled µm while the values are px. This protects every consumer of the table, not just the filters.write_ctc_trackingwrites zlib-compressed masks — mostly background and highly repetitive, so ~50× smaller (1 GiB → 18 MiB for a 500-frame 1024×1024 movie). Output stays a valid TIFF.SelectionManifest.loadaccepts the directorysave_selectionwas given, not only theselection.jsonpath.Migrated CI/CD to GitHub Actions with automated, OIDC-based PyPI releases and GitHub Pages documentation.
Dependency floors and additions:
papermill>=2.6.0(the dict form ofprogress_barand the engine API post-date 2.4),statsmodelsfor the growth fit, andscipynow declared explicitly rather than relied on via scikit-image.
Removed#
apply_cell_filters(images=...)andFilterExplorer’s row-wise fallback. Both now require the properties table; passproperties=.... A filter whose column is absent raises and names the extractor to add, rather than silently falling back to the slow path.
Fixed#
Re-running a recorded stage left the manifest describing two runs as one. The new run inherited the previous one’s recorded outputs, so until it collected its own the entry showed fresh parameters beside stale outputs — and a re-run that died half-way left exactly that mixture looking like a completed run. A new run now starts from a clean entry, and re-running a stage warns, naming when it last ran and which downstream stages its results have just made stale.
stage_manifest.jsonwas rewritten non-atomically. With one write per stage the window was academic; recording as the stage runs widens it a hundredfold, and a truncated manifest is not a lost record but a poisoned folder — every laterfor_image()reads it while resolving the source, so the next run would die on aJSONDecodeError. Writes now go through a temp file andos.replace.StageContext.clear()rmtreed a directory artifact even when another stage also wrote into it, so clearing one stage could delete another’s figures. A shared directory is now left alone.stage_table()let a recorded setting shadow a derived column — a stage recordingduration_s=...overwrote the real duration. Derived columns are now applied last.Tracking input was silently misaligned for any overlay whose first detection was not on frame 0.
overlay_to_masksbuilt its stack fromOverlay.timeIterator(), which starts at the first populated frame, so an overlay beginning on frame 3 handed the tracker frame 3’s cells as frame 0 — associating every cell against the wrong image. Frames are now indexed absolutely. Overlays fromload_segmentation(path, source)carry an explicit frame extent and were never affected.write_ctc_trackinghad the same bug in itsman_track{i:04d}.tifnumbering, and is fixed the same way.overlay_to_maskson an empty overlay raisedValueError: zero-size array to reduction operation minimum; it now returns an empty(0, height, width)stack.Label values above 65535 silently wrapped in the
uint16mask stack. The stack now widens touint32when any label needs it.Cropping a drift-corrected source with an ROI drawn on a frame other than the registration reference placed the crop off by the drift accumulated up to that frame — silently, and worse the later the frame. The ROI’s
anchor_frameis now recorded and applied viaapply_correction_to_specbefore cropping. ROIs drawn on frame 0 — every selection made before this release, since frame 0 was the only frame the editor showed — are unaffected.scale()aborted the whole batch when a notebook’s kernel failed to start. Both the sequential and the parallel path caught onlypapermill.PapermillExecutionError— which means “a cell raised” — while a kernel that never becomes ready raises a bareRuntimeError(Kernel died before replying to kernel_info). That escaped, so the sources after the failing one were never attempted, losing a whole overnight batch to one transient hiccup. Every exception is now isolated to its own source, and the exception is logged:failed_idsonly carries the id, so the closing summary could report how many sources failed but never why. A kernel that fails to start is additionally retried once — the handshake fails before any cell runs, so re-executing repeats no work and duplicates no side effect, which turns a lost stage into a slow one. A kernel that dies mid-notebook raisesDeadKernelErrorand is not retried, since cells have already run.merge_cells_to_colonies()gave every blob in a frame the same id (-1), andExtractorExecutor.execute()joins extractor results on that id index — a join on a non-unique index is a cartesian product, so k blobs became k⁶ rows and each area was repeated k⁵ times. Any per-frame sum (total colony area in particular) was 32× (k=2), 243× (k=3) or 1024× (k=4) too high, destroying the log-linear growth fit. Blobs now get running unique ids, the frame number comes from the detections rather than the enumerate position, the input’s time model is carried into the colony overlay, andexecute()raises on duplicate contour ids instead of returning a table wrong by orders of magnitude.Instance.coordinatesraisedAttributeError: 'MultiPolygon' object has no attribute 'exterior'when a detection’s mask had more than one connected component — a cell the segmentation split in two, or a speck sharing its label. This abortedsave_segmentationpartway through. Such a mask has no single outline, so its largest part is now used, the same choiceInstance.drawalready made; the newacia.utils.largest_polygonhelper is shared by both (and by the CTC readers, which had the same latent crash).Instance.is_fragmentedreports when it applies, andsave_segmentationwarns once with a count, since those detections reload smaller than they were.Instance.areais computed from the mask and is unaffected.HoughLineRigidFitraisedIndexErroron every call under OpenCV 5, which changedcv2.HoughLinesP’s output from(N, 1, 4)to(N, 4). Detected segments are now reshaped instead of indexed at a fixed layout, so both layouts work.LocalSequenceSource.get_frame()/indexing now honornormalize_image(previously only__iter__did), and the 2D-frame-to-3-channel duplication inprepare_imageis gated by it too. TIFFs opened viaopen_sequence(which already requestsnormalize_image=False) now return their true dtype/channel data instead of silently-normalized, artificially-3-channel uint8 — this also fixes previously wrongdtype/channel-count metadata, and affects any quantitative analysis (registration, fluorescence/area extraction) run onopen_sequence-opened TIFFs.materialize()on a TIFF source peaked at T × the full stack (measured 41× on a 40-frame stack) and did T full-file decodes:_read_images()re-decoded the whole file perget_frame(), andmaterialize()built a list of all frames, which — withnormalize_image=False, where a frame is a view into its parent buffer — pinned T parent stacks alive at once. The decode is now cached (dropped byclose()) and the output array is filled frame by frame. Peak for a 40×512×512 uint16 stack: 860 MB → 21 MB.save_trackingguarantees full frame coverage.write_ctc_trackingnamed its masks by enumeratingtimeIterator(), which starts at the first populated frame, so an overlay with an empty frame 0 wrote a stack shifted against the movie — every reloaded detection on the wrong frame, with no error.load_trackingattaches the calibration before building the tracking graph.read_ctc_trackingbuilt it while the reloaded overlay was still uncalibrated, andctc_track_graphstamps nodetimefromcont.time, so re-attaching the time model afterwards left a timeless graph and a lineage plotted overtime_feature='time'silently had nothing to plot.render_videooutput is now Firefox-compatible:macro_block_sizedropped to 2 (the minimum yuv420p needs) so already-even frames are no longer stretched, and-movflags +faststartmoves the moov atom to the front of the file, which Firefox requires in order to play it at all.JupyterVisualizationMixin._repr_html_’s interactive preview no longer crashes on a genuinely single-channel raw(H, W, 1)frame (as now returned byopen_sequence-opened TIFFs, and already returned byND2SequenceSource/CZISequenceSource) — PIL’sImage.fromarrayhas no mode for a trailing size-1 channel axis; that axis is now squeezed before display.Releasing a
FlowposeRTSegmenternow also clearstorch.compile’s process-global CUDA-graph cache viatorch.compiler.reset()— the base class’s generictorch.cuda.empty_cache()doesn’t reclaim it, since flowpose-rt’s defaulttorch.compile(mode="reduce-overhead")(on CUDA) caches graphs outside the model instance. Each processed batch is also followed by an explicitgc.collect()to encourage prompt reclamation of its activation memory before the next batch starts.plot_property_histogramsdegrades instead of raising when a property has no finite values — the common, valid case of an emptydf_after(everything filtered out) or an empty ROI. That axis is drawn empty with a “no cells” note. Only an emptypropertieslist still raises. Unit labels render with pint’s~P(pretty Unicode, e.g. “µm²”) instead of~L, which matplotlib showed as literal\mathrm{...}markup.SequenceDashboard’s Source field was always empty (SequenceMetadata.to_dict()never emitted apathkey), and auto-save was a frontend-only flag: off by default, invisible to Python and lost on re-render. Both are now synced traits.Rendering fixes found during the viz performance work: track labels above 65535 wrapped through a uint16 cast; uint16 sources blended 0–255 overlay colours against a 0–65535 frame, leaving the overlay invisible; foreground was derived from the overlay colours, so a cell that randomly drew
(0, 0, 0)was treated as background.
Performance#
Property extraction and cell filtering, measured at 1024×1024 with 300 cells/frame, cumulatively 13.88 → 0.24 ms/cell (Instance-backed) and 0.66 → 0.045 (Contour-backed) — the 150k-detection reference ROI drops from ~35 min to under a minute, and a 107-ROI batch from ~62 h to ~1 h. Three independent causes:
The filters re-measured every contour that extraction had just measured. Each
CellFilternow reads the column named after it out of the table theExtractorExecutoralready produced, comparing whole numpy columns against bounds converted once per run (filtering 7.12 → 0.001 ms/cell).Instanceheld a reference to the whole frame’s label image and every geometry access scanned all of it for one cell, so cost tracked frame area rather than cell area. Geometry now comes from a cached crop of the label’s bounding box, shifted back into frame coordinates;overlay_from_masksgets every box from onescipy.ndimage.find_objectscall. At 2048²polygonwent 24.40 → 0.35 ms/cell (70×).Contour.polygonis cached too.Unit conversion went through pint per value.
convert_arrayapplies it as one multiply by a precomputed factor, having solved for the affine(scale, offset)and verified the identity on probe values, falling back to the per-value path if it does not hold. The minimum rotated rectangle is now derived once per overlay viashapely.oriented_envelope.
Verified against
tests/equivalence/golden.npz, a snapshot of the pre-change implementation: identical kept-id sets across 5 scenes × 12 filter configurations, property values exact, polygons equal, units unchanged.Mask and tracking rendering is 10–25× faster.
render_tracking_maskrebuilt the frame label mask with one full-image comparison per cell (O(n_cells·H·W)) whenoverlay_from_maskshad already handed every instance the same full-frame mask — a single LUT remap suffices.render_trackingrecomputed constant cell centers inside its per-edge loop.render_tracking_mask 1024², 400 cells: 3.3 → 60 fps render_tracking_mask 2048², 400 cells: 0.7 → 17 fps render_tracking 1024², 300 cells: 34 → 328 fps
render_trackingoutput is byte-identical;render_tracking_maskdiffers by at most 1/255 per channel, from blending in uint8 instead of float32.Overlay → label-mask rasterisation, the conversion every tracking backend runs before it can start (
overlay_to_masks, used byTrackastraTracker,LapTrack*,PyUATTracker,UltrackTracker), plusOverlay.toMasks, the two CTC exporters and fluorescence extraction. Each rasterised every cell over the whole frame and combined the results withnp.maximum, so cost trackedn_cells × frame areawith 3–5 frame-sized temporaries per cell. The shared fast path from the viz work (_frame_label_mask) now lives inacia.segm.rasterizeand backs all of them.overlay_to_masks 1024², 5×150 cells, Contour-backed: 1.70 → 0.031 s (55×) overlay_to_masks 1024², 5×150 cells, Instance-backed: 0.27 → 0.010 s (27×)
The
Contour-backed figure is the one that matters afterload_segmentation, which returns polygon-backed detections: those went through a full-framerasteriopass per cell. A frame of polygons is now burned in a singlerasteriocall, and mask-backed instances are written through their cached bounding box. Output is byte-identical — polygons keep rasterio’s pixel-centre rule via the newexact_polygonsflag rather than taking the renderers’cv2.fillPolyshortcut, which fills inclusively and would have dilated every cell by a pixel (~+11% area on bacterium-sized cells).Fluorescence extraction additionally stopped re-decoding the channel inside the per-cell loop (
image.get_channel()ran once per cell per channel) and now gathers each cell’s pixels inside its bounding box instead of building a frame-sizednp.ma.masked_array. Values are unchanged.
[0.3.2] - 2025-10-27#
Added#
Trace computation for tracking results.
Changed#
Faster segmentation-mask rendering; updated video rendering and lineage visualization.
Removed#
The superseded lineage-visualization helpers and the outdated examples.
[0.3.1] - 2025-08-01#
Added#
Plotly-based cell-lineage rendering, with figure width/height parameters. Adds
plotlyas a dependency.
Changed#
Clarified licensing information.
[0.3.0] - 2025-07-31#
Added#
YOLO segmentation backend, lineage-tree visualization, tracking utilities, a per-detection
scoreonInstance, and conversion of OMERO raw sources toTHWCSequenceSource.scale()can run notebooks under an explicitly chosen Jupyter kernel.
Changed#
Packaging moved to
pyproject.toml; PyPI publishing from CI.Updated the Trackastra and PyUAT tracker integrations.
Fixed#
Rendering of tracking and segmentation on frames with no detections; position, fluorescence and time extractor fixes; pint unit-registry fixes.
[0.1.18] – [0.2.37] - 2021-12-23 … 2024-12-10#
Thirty-seven releases from the GitLab-only era, before this changelog existed; summarised here as one entry rather than reconstructed individually. Only 0.2.35 and later are available on PyPI — the earlier tags exist in git only.
Over this period acia grew from the initial OMERO-backed prototype into the
library the 0.3 line built on: OMERO image sources, connection handling and ROI
storers; the property-extractor framework with a single shared pint unit
registry (area, length, width, position, fluorescence, time); segmentation and
tracking processors with online/remote and local execution paths; the CTC
import/export formats, including an efficient rewrite; rendering of
segmentation, tracking and video, with scalebar/time overlays, LUT support and
lineage subsampling; and the scale() batch-execution helper. Tooling
converged on black, flake8, pylint and pre-commit, with CI on GitLab and
automated version bumps.
[0.1.0] - 2021-07-30#
Added#
First release on PyPI.