Skip to main content

Cell lineage analysis for timelapse microscopy: one spatiotemporal graph of lineage and spatial neighbours, built from MorphoGraphX meshes.

Project description

Teal

Timelapse Express Analysis Library

Teal

A Python library for analyzing cell lineage data from timelapse microscopy, with a focus on plant tissue development. Meant to be used in combination with MorphoGraphX (Pierre Barbier de Reuille et al., 2015), but having an otherwise connected cell mesh is sufficient for usage.


Features

  • One Graph, One Truth: Every cell is a node carrying its attributes, its timepoint and the mesh labels it came from — no parallel dataframes to keep in sync
  • Graph-Based Lineage Tracking: NetworkX-powered lineage graphs with parent-child relationships
  • Multi-Mesh Merging: Stitch several overlapping meshes of the same timepoint into a single cell graph — a cell imaged in two meshes becomes one node (keeping both labels, so results still export back to each mesh), neighbours are linked across the seam, and cross-parents connect a cell to a parent living in the other mesh. Big samples rarely come out of the microscope as one clean mesh, and this is what lets you treat them as if they had
  • Attribute Propagation: Shift attributes a fixed number of layers along lineages, or inherit them from founders (parent→child) and descendants (child→parent)
  • Spatial Context: Maintain spatial neighbor relationships alongside lineage information
  • Error Lookup: Built-in checks for the ways lineage tracing quietly goes wrong — unparented cells, broken lineages, and cells invented by a bad parent file

Installation

pip install teal-bio

The distribution is called teal-bio (the name teal was already taken on PyPI), but you still import it as teal:

import teal

To work on the library itself:

git clone https://github.com/IronSheep16/Teal.git
cd Teal
pip install -e .

Requirements

Python 3.9+. numpy, pandas, networkx and matplotlib are installed automatically.


Quick Start

1. Export the cell graph from MorphoGraphX

Teal reads the cell connectivity of a segmented mesh as a .ply. In MGX, run this once per timepoint on the segmented mesh you want to analyse:

Process.Mesh__Export__Cell_Graph_Ply_File_Save(filename, 'No', 'Active Mesh')

Name the files so the timepoint is readable from the filename (02_T0_cellGraph.ply, 02_T1_cellGraph.ply, …) — that is what files_by_t keys them by. Attributes (growth, size, zones, …) and parent labels are the CSVs MGX already exports.

2. Build and analyse in Teal

import teal as tl
from teal import files_by_t

# gather the files, keyed by the timepoint in each filename
graph_files = files_by_t('data/graphs', 0)
parent_files = files_by_t('data/parents', 0)

# build the timelapse -- one graph, every cell is a node
tmlps = tl.make_timelapse(graph_files, parent_files=parent_files)

# map attributes straight onto the nodes
tmlps.load_attr('GROWTH', growth_files)

# shift along lineages, then reduce whatever the shift collected
tmlps << (1, 'GROWTH')                   # pull values up from descendants
tmlps.filter_attr('GROWTH', 'avg_all')   # a cell has many children -> average them

# one row per final-timepoint cell
tmlps.lineages('GROWTH')

Results go back to MGX as heatmap CSVs:

tmlps.heat('GROWTH_heat', 'GROWTH')      # one CSV per timepoint, per mesh

Core Concepts

Data model

The graph is the single source of truth. Every cell is one node, carrying its attribute values, its layer (timepoint), and the (label, file_n) mesh cells that collapse into it. Edges are either time (parent → child) or space (neighbors).

Timelapse
  └── data      NetworkX graph -- ALL cells, time + space edges   <- the only store
        └── Timelapse[t] -> Timepoint    a view onto one layer; owns no data

Reads and writes go straight to the nodes; there is nothing else to keep in sync.

Working with attributes

tmlps[t] the Timepoint view at timepoint t
tmlps[t]['GROWTH'] values at t as an array; assigning writes the nodes
tmlps['GROWTH'] the lineage matrix (lineages × timepoints)
tmlps >> (n, 'A') / tmlps << (n, 'A') shift: take the value n layers away
tmlps.inherit('A') inherit (down): every cell takes its founder's value
tmlps.inherit('A', backward=True) inherit (up): every cell gathers its descendants' values
tmlps.filter_attr('A', 'avg_all') reduce the lists left by overlaps, shifts and upward inherits
tmlps.lineages('A') one row per final cell, one column per timepoint

Which way inherit runs

inherit goes both ways, but the two directions are not symmetric — a cell has one parent but many children.

Down (inherit('FOUNDER')) — one parent each, so values stay scalar. This spreads founder/clonal identity:

t0: [1, 2, 3, 4]
t2: [1, 1, 1, 2, 3, 3, 3, 4, 4]        # founder 1 ended up with 3 descendants

Up (inherit('SIZE', backward=True)) — many children each, so values accumulate into a flattened list. This is a gather over the whole progeny, not a copy:

t2: [50, 45, 80, 180, 75, 40, 38, 70, 75]           # leaves keep their own value
t0: [[50, 45, 80], [180], [75, 40, 38], [70, 75]]   # each founder holds ALL its descendants

So an upward inherit almost always wants a filter_attr after it, to decide what the gathered list means — a total, a mean, or a resolution of conflicting values:

tmlps.inherit('SIZE', backward=True)
tmlps.filter_attr('SIZE', 'avg_all')         # mean size of my progeny

tmlps.inherit('ZONES', backward=True)        # a lineage may straddle two zones -> [1, 2, 2]
tmlps.filter_attr('ZONES', 'fetch_single', value=1)

A downward inherit never needs this.


Example Workflows

Both run standalone on toy datasets shipped with the repo:

python examples/minimal_pipeline.py      # start here
python examples/multi_file_pipeline.py   # the hard case

minimal_pipeline.py — one mesh per timepoint. Build → map attributes → inherit → shift → filter → lineages, on examples/data.

multi_file_pipeline.pytwo meshes per timepoint, on examples/data_multi. This is the part that trips people up, and it is worth reading before you point Teal at a real multi-mesh sample.

Multiple meshes per timepoint

Big samples are often exported as several overlapping meshes (an abaxial and an adaxial view of the same tissue, say). Three things follow, and each has its own file:

file what it solves
overlaps one physical cell appears in both meshes under two labels. Teal merges them into one node that remembers both (label, file_n) pairs. One file per pair of meshes.
neighbors two cells touch across the seam between meshes, so neither mesh's own graph knows they are adjacent. Format: label_a, "label_b label_c".
crossparents a cell's parent lives in the other mesh, so that mesh's parent file cannot express it.

file_n is decided by filename order within a timepoint: T0_A.plyfile_n=0, T0_B.plyfile_n=1. Every other per-mesh file (parents, attributes) must follow that same order.

A cross-parent filename encodes its own routing — T<t>_<parent_mesh><child_mesh>:

crossparents/T1_01.csv   # at T1: parent is in mesh 0, child is in mesh 1
crossparents/T2_10.csv   # at T2: parent is in mesh 1, child is in mesh 0

Get that number wrong and the parent lookup falls back to a raw mesh label, inventing a cell that never existed. That is what phantom_cells() catches — see Error lookup below.

Because a merged cell is measured once per mesh, its values arrive as a list, and a filter reconciles them:

tmlps.load_attr('SIZE', size_files, overwrite='add')
# merged cell -> SIZE = [100, 104]   (once from each mesh)
tmlps.filter_attr('SIZE', 'avg_all')
# merged cell -> SIZE = 102.0

Visualization & checks

Heatmaps

Exports one CSV per mesh file, ready to load back into MGX.

tmlps[4].heat('output_path', 'GROWTH')   # a single timepoint
tmlps.heat('output_path', 'GROWTH')      # every timepoint

Lineage graphs

tmlps.graph_viz(root='134_1', attr_name='GROWTH', edge_type='time')

Error lookup

tmlps.report(attrs=['GROWTH', 'SIZE'])   # summary of the usual failure modes
tmlps.unparented()                       # cells missing a time parent
tmlps.childless()                        # lineages dying before the last timepoint
tmlps.phantom_cells()                    # cells invented by a failed parent lookup (should be 0)
tmlps.missing_attr('GROWTH')             # cells with no value
tmlps.incomplete_lineages(prune=True)    # drop cells that don't span t0 -> tN

report() prints all of the above at once:

Nodes: 4217 | layers: 0 → 5
Lineage cycle present: False
Unparented (no time parent): 132
Childless (dies before last t): 78
Incomplete lineages: 1917
Phantom cells (invented parents): 0
Missing GROWTH: 1904  e.g. ['18_0', '21_0', '35_0']

Phantom cells should always be 0. Anything else means a parent lookup fell back to a raw mesh label instead of a real cell — almost always a wrong file index in a cross-parent filename. Every real cell carries the (label, file_n) pairs it came from; an invented one carries none, which is how they are spotted.

Warnings

Loading is deliberately noisy: a label in a file that matches no cell in the mesh usually means the file and the mesh came from different exports, and you want to know. Warnings name the file and the offending labels:

t5 [02_T4T5AD_PARENTS.csv]: 30 cells have no value in file_n=1: [730, 806, 909, ...]
t3 [01_T3_GROWTH.csv]: 6 labels have no cell in file_n=0: [11, 12, 13, ...]

Once a pipeline is settled and you know which gaps are expected, silence them globally:

import teal as tl

tl.verbose(False)   # quiet
tl.verbose(True)    # back on (the default)

This only silences the loading warnings — it never hides report(), which is the thing you actually want to read.


Documentation

Full documentation available at: [link to docs]

Key Methods

Building

  • make_timelapse(graph_files, overlaps, neighbors, parent_files, cross_parent_files) - build a Timelapse from files_by_t dicts
  • make_timepoint(graph_files, overlaps, neighbors) - build a standalone Timepoint
  • files_by_t(folder, nth) - key a folder's files by the timepoint in their filename

Timelapse

  • load_attr(attr_name, files, t=None) - write an attribute onto the nodes
  • >> / << (n, attr_name) - shift an attribute n layers along the lineages
  • inherit(attr_name, backward=False) - down: every cell takes its founder's value. backward=True - up: every cell gathers its descendants' values into a list (follow with filter_attr)
  • filter_attr(attr_name, func) - reduce list-valued attributes. func is a Filter name ('avg_all', 'avg_unique', 'fetch_single') or any callable
  • combine_attrs(a1, a2, out, func) - build one attribute from two. func is a Combine name ('divide', 'pick_zone') or any callable
  • lineages(attr_name) - one row per final cell, one column per timepoint
  • heat(path, attr_name) - export heatmap CSVs for every timepoint
  • save(path) / load(path) - pickle the timelapse

Timepoint (a view: timelapse[t])

  • tp[attr_name] - values at this timepoint as an array; assigning writes the nodes
  • tp.load_attr(...), tp.filter_attr(...), tp.combine_attrs(...) - scoped to this timepoint
  • tp.heat(path, attr_name) - export this timepoint's heatmap CSVs
  • tp.frame() - a throwaway DataFrame of the nodes, for ad-hoc pandas

Error lookup

  • report(attrs) - prints all of the checks below at once
  • unparented(), childless(), incomplete_lineages(prune), missing_attr(name)
  • phantom_cells() - cells invented by a failed parent lookup; should always be empty
  • graph_viz(root, attr_name), graph_check()
  • teal.verbose(False) - silence the loading warnings (default True)

Lineages

  • lineages(attr_name) - one row per final cell, one column per timepoint
  • map_lineages() - stamp each cell with its lineage number, so lineages can be exported as a heatmap. Early cells belong to several lineages, so reduce with filter_attr('LINEAGE', 'first') before heat(..., as_int=True)

Contributing

Contributions are welcome — bug reports, fixes, documentation and new analysis functions. See CONTRIBUTING.md for how to report a problem, get help, set up a development environment, and submit a change.

If you hit a bug, the output of tmlps.report() is the single most useful thing to include.


License

This project is licensed under the GNU License - see LICENSE file for details.


Contact

Benjamin Lapointe - benjamin.lapointe@umontreal.ca

Project Link: https://github.com/IronSheep16/Teal


Acknowledgments

  • Developed for analyzing Arabidopsis leaf development
  • Built on NetworkX, pandas, and matplotlib
  • Designed for MGX microscopy timelapse integration
  • Written with the help of Claude Code

Citation

If you use TEAL in your research, please cite:

@software{teal,
  author = {Benjamin Lapointe},
  title = {TEAL: Timelapse Express Analysis Library},
  year = {2026},
  url = {https://github.com/IronSheep16/Teal}
}

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

teal_bio-0.1.0.tar.gz (48.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

teal_bio-0.1.0-py3-none-any.whl (39.9 kB view details)

Uploaded Python 3

File details

Details for the file teal_bio-0.1.0.tar.gz.

File metadata

  • Download URL: teal_bio-0.1.0.tar.gz
  • Upload date:
  • Size: 48.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for teal_bio-0.1.0.tar.gz
Algorithm Hash digest
SHA256 a2769cf10202bbe393c60f2c90763be5eb9d80c8a38c81ecc7541dd477d5bfda
MD5 f58c912149e1938c2e4bb918bca81519
BLAKE2b-256 6c58f3cc279626bfa9416961afdea86d93955248fa000dc1565a24ab4213799c

See more details on using hashes here.

Provenance

The following attestation bundles were made for teal_bio-0.1.0.tar.gz:

Publisher: publish.yml on IronSheep16/Teal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file teal_bio-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: teal_bio-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 39.9 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for teal_bio-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 166b3ad953f482c132a1746ed46141262a73b02e32b3fea4537d6ac34c510dea
MD5 f1e3f1aa71bcf8a45536cc4937259d47
BLAKE2b-256 a090c9d687e00ecbe5792863a3db12793dc6333637f5e180191c95e842b18ff8

See more details on using hashes here.

Provenance

The following attestation bundles were made for teal_bio-0.1.0-py3-none-any.whl:

Publisher: publish.yml on IronSheep16/Teal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page