Automated gel electrophoresis band segmentation and densitometry. A GelGenie U-Net segments bands in a gel image, and this pipeline turns that segmentation into per-band and per-lane quantitation tables - grouping bands into lanes, estimating local background, and (optionally) detecting shared "rows" across lanes or correcting for curved ("smiling") lanes.
It's an installable Python package (pip install -e .) usable as a
command-line tool or as a Python library/module (e.g. from a
Jupyter notebook). The library itself lives in the gel_analysis package
(src/gel_analysis/); gel-segment/gel-convert-scn/gel-select-roi are
its installed CLI commands (src/gel_analysis/cli/), and gel_session.py
(src/gel_session.py, a standalone module alongside the gel_analysis
package rather than inside it) is a third, object-oriented wrapper for
notebook use - see "Object-oriented interface" below. See "Library layout"
below for the module breakdown.
conda create -n gel-analysis python=3.12 -y
conda activate gel-analysis
pip install --index-url https://download.pytorch.org/whl/cpu torch torchvision
pip install -e /path/to/GelGenie/python-gelgenie
pip install -e . # base install: gel_analysis + gel_session, CLI commands
pip install -e ".[gui]" # + napari GUI (napari[pyside6], magicgui) - see "napari GUI" below
./download_model.sh # or reuse the models/ dir already included herepip install -e . installs gel_analysis/gel_session in
editable mode (src-layout: src/) and puts the CLI commands
below on PATH - no sys.path hacking needed from notebooks or scripts
afterward, import gel_analysis / import gel_session just work from
anywhere in the env. pyproject.toml declares its own
dependencies (a fixed copy of GelGenie's own requirements.txt, see that
file's comments for the two diffs); torch/torchvision and GelGenie itself
stay separate installs, as above, since neither is a normal pinned PyPI
dependency this project can declare.
Gels scanned straight off a Bio-Rad Image Lab gel-doc come out as .scn
files (a MIME multipart container), not TIFFs. Convert them first:
gel-convert-scn <input_scn_or_folder> <output_folder>Recursively finds .scn files if given a folder, and writes one 16-bit
grayscale TIFF per input into <output_folder> (flat - one level, no
subfolders) for gel-segment to read directly. Only needs numpy +
imageio, so this step doesn't require GelGenie/torch to be installed
(though the base pip install -e . above does need to have run,
since it's what puts gel-convert-scn on PATH).
gel-segment <input_image_or_folder> <output_folder> [options]Useful options (run --help for the full list):
| Flag | Purpose |
|---|---|
--max-lanes N |
Cap lane detection at the gel comb's known well count |
--detect-rows |
Detect shared migration "rows" across lanes and infer virtual bands for faint/missing detections (only for gels where lanes carry the same species, e.g. a time-course or dilution series) |
--exclude-lanes-from-rows 1,15 |
Exclude ladder lane(s) from row detection |
--fit-lane-curvature |
Correct for "smiling" gels (curved lanes) |
Gotcha - always exclude ladder/reference lanes when using --detect-rows.
A ladder's band spacing doesn't match any real product migration distance in
the sample lanes. Left in, its bands get treated as extra "rows" that only
the ladder occupies, so every sample lane gets a fabricated virtual band at
each of those phantom migration distances - sitting on pure background/
noise. In practice this is the single biggest source of negative
background_corrected_total values: on one real gel, excluding the ladder
lane cut row count from 9 (3 of them phantom) to 6, and negative-value bands
from 15+ down to 1, with no other parameter change. local_background_border
(default 5px, passed to quantitate()) can be widened for slightly more
stable background estimates, but doesn't fix genuine lane-to-lane deviations
from an expected monotonic trend - those held essentially unchanged across
every border size tested, which points to real experimental variation
(e.g. loading volume) rather than background-subtraction noise.
Each run prints, and writes to CSV, a straight-line-vs-curved RMS comparison
for lane positions - useful for judging whether --fit-lane-curvature is
actually needed for a given gel, even if you don't pass the flag.
Gotcha - min_row_spacing's default (40px) can silently drop a real
row. find_band_rows's peak-finding discards the shorter of two
candidate row-density peaks within min_row_spacing of each other, even
across a genuine valley - on two real gels checked so far, a row every
lane actually resolved sat closer to its neighbour than 40px (38px and,
on a second gel, 19px) and vanished from the row count with no warning.
Inspect the raw row-density profile for a new gel before trusting
--detect-rows' row count, and lower --min-row-spacing if a real row
looks missing.
from gel_analysis.analysis import load_model, segment_image, detect_lanes, quantitate, plot_overlay
model = load_model() # once, reuse across images
gel = segment_image(model, "gel1.tif") # runs the segmentation model
layout = detect_lanes(gel, max_lanes=15) # groups bands into lanes
plot_overlay(gel, layout) # inline sanity check against the image
# tweak parameters and re-check as many times as needed, e.g.:
layout = detect_lanes(gel, max_lanes=15, fit_lane_curvature=True)
plot_overlay(gel, layout)
# once layout looks right, get final per-band/per-lane/per-row tables:
band_df, lane_df, row_df = quantitate(gel, layout)(gel_analysis is pip-installed in editable mode - see "Installation"
above - so this import works directly from any script/notebook in the
gel-analysis conda env, no sys.path hacking needed.)
segment_image()runs the segmentation model and returns aGelImage(raw image, mask, per-band geometry). This is the slow step.detect_lanes()groups aGelImage's bands into lanes (and optionally rows), returning aLaneLayout. This is fast and doesn't touch the model, so it's cheap to call repeatedly with different parameters.plot_overlay()draws aGelImage+LaneLayoutfor visual inspection (displays inline in a notebook; passout_png_path=...to also save it).quantitate()turns aGelImage+LaneLayoutinto final band/lane/row tables (passout_csv_path=...etc. to also write CSVs).
A GelImage is never modified by detect_lanes(), so the same one can be
reused across any number of parameter sweeps without re-running
segmentation.
gel_session.py (src/gel_session.py, a standalone module
alongside the gel_analysis package rather than inside it - see its
module docstring for why) wraps the function-based pipeline above into a
stateful, method-call interface for interactive/notebook use:
from gel_session import GelSession, GelBatch, row_intensities
session = GelSession("gel1.tif", lane_params=dict(max_lanes=15),
row_params=dict(detect_rows=True, exclude_lanes_from_rows={1}))
session.segment(model).detect()
session.check_layout() # visual check
session.check_lane(3) # 1-D profile check for lane 3
band_df = session.quantitate(row_configs=[
dict(row_id=3, label="set_a", lane_range=dict(start_lane=2, end_lane=15), borrow_bounds_from={}),
])
row_intensities(band_df, row_id=3, label="set_a") # isolated intensity arrayGelBatch([session_a, session_b, ...]) runs the same segment/detect
calls across several gels and concatenates their quantitate() results
into one "gel"/"config_label"-tagged frame; row_intensities() still
isolates one gel/row/lane-range's array out of that combined frame by
(row_id, label) alone (no gel argument needed, since every row already
carries its own "gel" column) - GelBatch.row_intensities(gel, ...) is
the explicit-gel form for when two gels might reuse the same label.
See example/gel_analysis_example.ipynb for a runnable, end-to-end
walkthrough built on this interface.
napari is used as the GUI backend for this
project's optional interactive viewer. napari_gui (src/napari_gui/,
sibling to gel_analysis/ in the same src/ tree - its own top-level
package for the same "leaf consumer, don't drag its dependency into the
core package" reason gel_session.py lives outside gel_analysis/)
wraps GelSession in an interactive napari viewer:
pip install -e ".[gui]" # installs napari[pyside6] + magicgui, see "Installation" above
gel-gui [gel_path]Two docked panels group the individual steps (purely to cut down on
title-bar clutter - each step is still its own callback underneath):
"Load / crop / segment" covers load (fast, no model - accepts a .scn
directly, converting it to TIFF first via gel_analysis.io.convert_scn())
→ an optional crop-ROI Shapes layer (the napari analogue of
select_roi_gui()) + apply-crop (reloads the cropped file as the
displayed image, so segment - next - actually operates on the cropped
pixels) → segment (thread-worker-wrapped so model inference doesn't
freeze the GUI). "Detect / analyze / quantitate" covers detect (drawn as
lane/row Shapes layers, the interactive analogue of plot_overlay();
exclude_lanes_from_rows is required here whenever "detect rows" is
checked, same ladder/reference-lane gotcha as the CLI) → a lane-analysis
control that pops up GelSession.check_lane()'s plot as a floating
napari panel → quantitate (writes the same CSVs the CLI does).
row_configs-based virtual-band refinement isn't exposed
here - that stays script/notebook-only, same as the object-oriented
interface above.
src/ holds everything installable: the gel_analysis package,
the napari_gui package, and the standalone gel_session module (see
pyproject.toml's [tool.setuptools] for how all three are wired into
one gel-analysis distribution). gel_analysis/ itself splits along one
seam: most modules need only numpy/scipy/pandas/matplotlib and import
fine without GelGenie/torch installed; only segmentation.py (and
analysis.py/cli/, which wire it in) require GelGenie/torch. Import
the submodule you need directly (from gel_analysis.profiles import ...,
from gel_analysis.analysis import ...) rather than import gel_analysis
itself, which does no eager submodule imports for exactly this reason.
| Module | Needs torch? | Contents |
|---|---|---|
models.py |
no | GelImage / LaneLayout dataclasses |
io.py |
no | image loading, .scn → TIFF conversion |
lanes.py |
no | lane/row detection, curvature fitting, detect_lanes() |
profiles.py |
no | 1-D lane-profile extraction, baseline correction, peak-picking (exploratory cross-check, not wired into the default CLI output); also refine_band_bounds()/apply_borrowed_bounds(), a committed virtual-band refinement (see below) |
volume3d.py |
no | pseudo-3D per-strip band-volume reconstruction (exploratory); also reconstruct_lane_total_volume(), a committed per-lane total-loading normalization (see below) |
quantitation.py |
no | quantitate(), apply_profile_refinement(), apply_lane_load_normalization() |
viz.py |
no | plot_overlay() |
roi_select.py |
no | interactive whole-gel crop, select_roi_gui()/crop_gel_image() |
segmentation.py |
yes | GelGenie model loading, segment_image() |
analysis.py |
yes | wires everything above into the documented pipeline surface |
cli/ |
yes* | argparse CLI subpackage - segment.py (gel-segment), convert_scn.py (gel-convert-scn, torch-free), select_roi.py (gel-select-roi, torch-free) |
* only cli/segment.py needs GelGenie/torch; cli/convert_scn.py and
cli/select_roi.py only import io.py/roi_select.py, so those two
commands work even without GelGenie/torch installed.
- an RGB-labelled segmentation overlay, a transparent segmentation map PNG, and a side-by-side comparison figure (from GelGenie itself)
*_band_quantitation.csv: one row per band - lane ID, position within the lane (top-to-bottom), area, centroid, mean/total intensity, local background estimate, background-corrected total, and lane-relative % (of that lane's strongest band)*_lane_summary.csv: one row per lane - band count, summed raw and background-corrected intensity, mean local background*_lane_overlay.png: bounding boxes coloured/labelled by lane, as a visual check on lane grouping (and lane curvature, if used)- with
--detect-rows/detect_rows=True:*_row_summary.csvand*_row_by_lane_matrix.csv(row × lane pivot of corrected intensity), plusrow_id/detectedcolumns in the band table
--detect-rows virtual bands (see "Gotcha" above) get their intensity from
a flat box-sum at the row's density-derived center - fine as a first pass,
but not fitted to any particular lane's own faint peak. For a row where
that matters, refine it from the lane's own raw profile instead:
from gel_analysis.analysis import refine_row_band_profiles
from gel_analysis.quantitation import apply_profile_refinement
# lanes with NO real signal expected at all (e.g. a 0h-reaction control) -
# {lane_id: donor_lane_id} to borrow already-refined bounds from instead of
# searching independently (which would just lock onto noise) - domain
# knowledge, not inferred automatically
results = refine_row_band_profiles(gel, layout, row_id=3, borrow_bounds_from={1: 2, 9: 10})
band_df = apply_profile_refinement(band_df, results) # adds an intensity_source columnNot run automatically by quantitate() or the CLI - see
example/gel_analysis_example.ipynb, which calls this via
GelSession.quantitate(row_configs=...), for a runnable example.
A per-LANE (not per-band) tool for the same "unequal loading" problem: tells a real per-band change apart from a lane that was simply loaded lighter, or lost material to an insoluble/aggregated fraction before it ever ran - relevant whenever the experiment design expects equal total protein per lane (e.g. a time-course/dilution series) but that's never exact in practice.
from gel_analysis.analysis import compute_lane_total_loads
from gel_analysis.quantitation import apply_lane_load_normalization
# lane_id to skip entirely (e.g. a reference/ladder lane - not a
# same-species sample, so "loading" isn't a meaningful comparison for it) -
# domain knowledge, not inferred automatically
loads = compute_lane_total_loads(gel, layout, exclude_lanes={8})
lane_df = apply_lane_load_normalization(lane_df, loads) # adds total_load_profile / load_relative_to_mean columnsTiles each lane's width into strips (same mechanism as
reconstruct_band_volume) and sums each strip's baseline-corrected profile
over a shared gel-wide content ROI - the whole lane, not one band's
neighbourhood. Not run automatically by quantitate() or the CLI - see
compute_lane_total_loads()/apply_lane_load_normalization()'s
docstrings for details.
This codebase was developed with substantial AI assistance ("vibe coded" with Claude). It has been exercised against real gel images during development (see the per-module notes throughout this README and the worked example), but it has not been independently audited, and the author makes no guarantees about the correctness, accuracy, or reproducibility of any results it produces. Use it at your own risk - validate outputs against your own domain knowledge and, where it matters, an orthogonal method, before relying on them (e.g. for publication or other downstream decisions).
This pipeline's segmentation is powered by GelGenie (Apache License 2.0). If you use this pipeline, please also cite the original GelGenie work:
Aquilina, M., Wu, N.J.W., Kwan, K. et al. GelGenie: an AI-powered framework for gel electrophoresis image analysis. Nat Commun 16, 4087 (2025). https://doi.org/10.1038/s41467-025-59189-0
MIT - see LICENSE.md. This applies to the pipeline code in this repository only; GelGenie itself is licensed separately (Apache 2.0).