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
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.py — two 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.ply → file_n=0,
T0_B.ply → file_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 aTimelapsefromfiles_by_tdictsmake_timepoint(graph_files, overlaps, neighbors)- build a standaloneTimepointfiles_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 attributenlayers along the lineagesinherit(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 withfilter_attr)filter_attr(attr_name, func)- reduce list-valued attributes.funcis aFiltername ('avg_all','avg_unique','fetch_single') or any callablecombine_attrs(a1, a2, out, func)- build one attribute from two.funcis aCombinename ('divide','pick_zone') or any callablelineages(attr_name)- one row per final cell, one column per timepointheat(path, attr_name)- export heatmap CSVs for every timepointsave(path)/load(path)- pickle the timelapse
Timepoint (a view: timelapse[t])
tp[attr_name]- values at this timepoint as an array; assigning writes the nodestp.load_attr(...),tp.filter_attr(...),tp.combine_attrs(...)- scoped to this timepointtp.heat(path, attr_name)- export this timepoint's heatmap CSVstp.frame()- a throwaway DataFrame of the nodes, for ad-hoc pandas
Error lookup
report(attrs)- prints all of the checks below at onceunparented(),childless(),incomplete_lineages(prune),missing_attr(name)phantom_cells()- cells invented by a failed parent lookup; should always be emptygraph_viz(root, attr_name),graph_check()teal.verbose(False)- silence the loading warnings (defaultTrue)
Lineages
lineages(attr_name)- one row per final cell, one column per timepointmap_lineages()- stamp each cell with its lineage number, so lineages can be exported as a heatmap. Early cells belong to several lineages, so reduce withfilter_attr('LINEAGE', 'first')beforeheat(..., 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
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a2769cf10202bbe393c60f2c90763be5eb9d80c8a38c81ecc7541dd477d5bfda
|
|
| MD5 |
f58c912149e1938c2e4bb918bca81519
|
|
| BLAKE2b-256 |
6c58f3cc279626bfa9416961afdea86d93955248fa000dc1565a24ab4213799c
|
Provenance
The following attestation bundles were made for teal_bio-0.1.0.tar.gz:
Publisher:
publish.yml on IronSheep16/Teal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
teal_bio-0.1.0.tar.gz -
Subject digest:
a2769cf10202bbe393c60f2c90763be5eb9d80c8a38c81ecc7541dd477d5bfda - Sigstore transparency entry: 2163909685
- Sigstore integration time:
-
Permalink:
IronSheep16/Teal@d50cc76ee38b7bd964ab525c7a70b757f53645bb -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/IronSheep16
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d50cc76ee38b7bd964ab525c7a70b757f53645bb -
Trigger Event:
release
-
Statement type:
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
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
166b3ad953f482c132a1746ed46141262a73b02e32b3fea4537d6ac34c510dea
|
|
| MD5 |
f1e3f1aa71bcf8a45536cc4937259d47
|
|
| BLAKE2b-256 |
a090c9d687e00ecbe5792863a3db12793dc6333637f5e180191c95e842b18ff8
|
Provenance
The following attestation bundles were made for teal_bio-0.1.0-py3-none-any.whl:
Publisher:
publish.yml on IronSheep16/Teal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
teal_bio-0.1.0-py3-none-any.whl -
Subject digest:
166b3ad953f482c132a1746ed46141262a73b02e32b3fea4537d6ac34c510dea - Sigstore transparency entry: 2163910302
- Sigstore integration time:
-
Permalink:
IronSheep16/Teal@d50cc76ee38b7bd964ab525c7a70b757f53645bb -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/IronSheep16
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@d50cc76ee38b7bd964ab525c7a70b757f53645bb -
Trigger Event:
release
-
Statement type: