Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

3 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Gel Analysis Pipeline

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.

Installation

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 here

pip 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.

Converting Bio-Rad .scn scanner files

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).

Command-line usage

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.

Notebook / library usage

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 a GelImage (raw image, mask, per-band geometry). This is the slow step.
  • detect_lanes() groups a GelImage's bands into lanes (and optionally rows), returning a LaneLayout. This is fast and doesn't touch the model, so it's cheap to call repeatedly with different parameters.
  • plot_overlay() draws a GelImage + LaneLayout for visual inspection (displays inline in a notebook; pass out_png_path=... to also save it).
  • quantitate() turns a GelImage + LaneLayout into final band/lane/row tables (pass out_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.

Object-oriented interface

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 array

GelBatch([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 GUI (optional)

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.

Library layout

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.

Outputs

  • 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.csv and *_row_by_lane_matrix.csv (row × lane pivot of corrected intensity), plus row_id/detected columns in the band table

Virtual-band profile refinement (opt-in)

--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 column

Not 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.

Per-lane total-load normalization (opt-in)

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 columns

Tiles 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.

Disclaimer

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).

References

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

License

MIT - see LICENSE.md. This applies to the pipeline code in this repository only; GelGenie itself is licensed separately (Apache 2.0).

About

Protein SDS-PAGE gel-image analysis pipeline built on GelGenie

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages