Western Blot Band Grayscale Quantification
Headless, reproducible densitometry for blot images. The math matches ImageJ's
Measure command exactly (Mean = Σ/N, IntDen = Σ,
NetIntDen = bgMean × Area − IntDen), so numbers can be compared against an
existing ImageJ protocol.
Convention: in a grayscale blot, darker = stronger signal. Therefore
Mean decreases with intensity (it is not the answer), and quantification
uses the background-corrected net density, which increases with intensity:
NetIntDen = bgMean × Area − IntDen # larger = stronger band
Requirements
Python 3.8+ with numpy and Pillow:
python -m pip install numpy pillow
No ImageJ install is needed. Everything runs headless.
Pick the right script
| Layout | Script | Notes |
|---|---|---|
| One band per lane | scripts/measure_bands.py |
most common |
| 1 or 2 bands per lane (1+2+1+2, isoforms, dimer+monomer) | scripts/measure_multi_bands.py |
iterative minimum finder |
| 2+ stacked blot frames in one image (co-IP) | scripts/measure_stacked_blots.py |
auto-detects each frame |
| Equalize loading across lanes | scripts/calc_loading_volume.py |
consumes the stacked CSV |
Quick start
S=scripts # this skill's scripts/ directory
# 1. one band per lane -> <base>.results.csv / .results.txt / .overlay.png
python $S/measure_bands.py blot.png --lanes 4
# 2. multi-band lanes
python $S/measure_multi_bands.py blot.png --lanes 4 --max-bands 2
# 3. co-IP: target frame on top, control frame below
python $S/measure_stacked_blots.py coip.png --lanes 5 \
--target-name "IP: anti-flag" --control-name "IB: anti-actin"
# 4. how much should each lane be loaded to equalize actin?
python $S/calc_loading_volume.py coip.stacked.csv --ref-lane 1 --ref-vol 5
Every script supports --help, --version, --outdir DIR and --quiet.
Outputs default to sitting next to the input image.
Installation check / regression test
python $S/make_test_image.py --verify
This builds a synthetic 4-lane blot with known relative intensities
[1.00, 2.00, 0.50, 1.50] and checks the pipeline recovers them. The generated
image deliberately reproduces the three layout traps described below, so a pass
is a real check of the read path.
The three layouts
Single-band. Per lane: split the width into N equal lanes, take the row
that is darkest on average across the lane, and measure a densest ± band_half
ROI. That is the whole algorithm.
Multi-band. A lane with two bands defeats a single argmin. The script
instead iteratively takes the darkest remaining row, accepts it if its
prominence exceeds --prominence, then masks ± --min-dist rows around it and
repeats. --min-dist (default 15) is what stops one broad band from being
split into two false "shoulder" minima — raise it if you see doubled bands.
Stacked frames. One image, several perfectly separated blot frames. The
script finds the frames by detecting near-fully-black border rows (requiring
both a high dark fraction of the row and a long continuous dark run, since
a row of wide bands can rival a border's dark fraction), splits each frame into
lanes, and reports target / control normalized to lane 1.
Reading the results
<base>.results.csv (single/multi) carries, per band:
x0,y0,W,H (ROI), Mean, IntDen, Area, Min, Max, BackgroundMean,
NetIntegratedDensity, RelativeExpression (or NormalizedToRef).
The stacked variant adds Ratio_Target_Ctrl and Normalized, and writes
<base>.stacked.csv.
Always look at the generated <base>.overlay.png before reporting
numbers. It draws every ROI on the original image. A mis-placed ROI is the
single most common failure mode and is obvious at a glance; the numbers alone
will look perfectly plausible.
What can go wrong (and why we handle it)
These are not hypothetical — each one produced a wrong answer on a real image
before being fixed. Full evidence in references/troubleshooting.md.
- Palette PNG with transparency.
Image.open(p).convert("L")on a PNG
with atransparencytable silently corrupts the array: transparent pixels
become pure black, and on a real 415×125 composite that was 25,278 of
51,875 pixels. Everything must be composited onto a white canvas first. - But compositing onto white alone is not enough. The transparent border
used to become black, and the "skip the pure-black scan border" logic
depended on that. Turn it white and the black frame rows enter the search
window — the darkest row then lands on the border instead of the band.
Both the alpha compositing and a border rule that handles black and
white margins must be present together. - A flat, dark edge line is darker than the real band. Screenshots leave
a 1-pixel line behind whose value is often below the darkest band pixel.
It must never win the argmin. Distinguishing feature, measured on real
scans: such rows have a spread (p90 − p10) of exactly 0, while genuine
imaged membrane rows never dropped below ~10.
Verifying a real analysis
- Check
<base>.overlay.png— ROIs on the bands, not on borders or the label. - Compare
BackgroundMeanacross lanes: it should be similar. A lane whose
background is far lower has a border or a region of interest inside it. - Sanity-check the span of
RelativeExpression. A fold-difference above ~5×
usually means an ROI is misplaced or a band saturated. - State the reference lane explicitly in any reported result.
Parameter reference
| Flag | Default | Meaning |
|---|---|---|
--lanes |
4 (auto for stacked) | vertical lanes across the frame |
--band-half |
5 | ROI half-height in rows (ROI = 2h+1 rows) |
--ref-lane |
1 | lane normalized to 1.000 |
--max-bands |
2 | max bands searched per lane (multi only) |
--min-dist |
15 | min row gap between two accepted bands |
--prominence |
8.0 | min grey-level prominence to accept a band |
--bg-top-rows |
7 | rows used as background (multi only) |
Details, derivations and the full rationale for each threshold are in
references/algorithm.md.
Loading-volume normalization
For "all lanes were loaded with 10 µL but actin is uneven — if lane 1 gets
5 µL, what should the rest get?":
NetIntDen ∝ C_i × V_i V_i = V_ref × (Actin_ref / Actin_i)
The original volume cancels out — the answer depends only on the relative
actin signals. Prefer the recommended protocol (take different sample volumes
but pad every lane to the same final volume with 1× loading buffer), otherwise
lane-to-lane salt/detergent differences distort the bands. Derivation, worked
examples and the caveats you must state are in
references/loading-volume.md.
Files
SKILL.md
scripts/measure_bands.py single band per lane
scripts/measure_multi_bands.py one or two bands per lane
scripts/measure_stacked_blots.py stacked co-IP frames (or single control strip)
scripts/calc_loading_volume.py loading-volume plan from a stacked CSV
scripts/make_test_image.py synthetic blot generator + self-check
references/algorithm.md full algorithm, ImageJ parity, thresholds
references/troubleshooting.md pitfall library with measured evidence
references/loading-volume.md loading-volume theory and worked examples