2026 — v1.0 in progress
blotquant
QC-first western blot densitometry — a bad number is a refusal, not a warning.
github.com/sofiapetrusenko/blotquant
- band detection F1
- 0.851
- mean error, clean bands
- 7.05%
- tests in CI
- 690
- real crops measured
- 0 of 19
band detection F1
mean error, clean bands
tests in CI
real crops measured
Problem
Western blot quantification is one of the most common measurements in molecular biology and one of the least reproducible: hand-drawn regions, silent saturation, undocumented normalization. Most tools give you a number; almost none tell you whether you should trust it.
Approach
A Python CLI, with an HTTP API alongside it, that detects lanes and bands, subtracts background, normalizes against loading controls — and treats quality control as a first-class output. Saturated or unverifiable bands are flagged and still reported, annotated rather than quietly dropped, and their exclusion from a normalization ratio is explicit and recorded with its reason. Every result document carries provenance: image hash, config digest, software version, and whether each lane was found by the pipeline or supplied by the caller.
Analysis
Explore an analysis — hover a lane, click for densitometry, open the provenance record.
Verification
Development is gated on a seeded synthetic gold set of 40 images with per-band ground truth, split 30 dev and 10 held-out test. On the dev split, band detection measures F1 0.851 at IoU ≥ 0.5, and intensity recovery is 7.05% mean / 4.60% median absolute error on bands carrying no ground-truth QC flag. The headline error over all matched bands is higher, 17.39%, because saturated bands and unresolved doublets are included rather than dropped — both are reported separately and neither is hidden. The test split has never been scored or tuned on, and nothing has been measured on a real blot. 690 tests run in CI, which also re-measures every recorded figure and re-verifies the digest of every frozen document, so a number that goes stale fails the build.
What QC is for
Mean absolute error over three subsets of the same run — everything matched, then progressively only the bands whose ground truth is clean.
- all matched bands17.39% / 279 bands
- excluding bands whose truth carries saturated17.06% / 256 bands
- bands with no truth QC flag at all7.05% / 211 bands
7.05% is the accuracy claim; 17.39% is what you get if you quantify everything and flag nothing. From the intensity-recovery table at c77fae3f9.
Parameter sweeps
The measurements behind the shipped configuration. Select a parameter; the shipped value is marked.
profile_smoothing_px
Lane F1 climbs to 0.9967 at the shipped window of 5 and then plateaus — every wider window measures the same. Band F1 does not: it peaks earlier, at 3 (0.8571), and degrades from there down to 0.7823 at the widest setting. The shipped window is therefore a stated trade-off and not an optimum for both: it buys the lane plateau for 0.0065 of band F1.
band.min_prominence_fraction vs band.min_prominence_sigma
Two criteria gate the same decision — whether a peak is a band — and the record shows they are redundant but not equally weighted. Dropping the prominence fraction to 0.001, the nearest legal stand-in for switching it off, costs 0.1522 of band F1 (0.6984 against 0.8506). Switching the noise criterion off outright at 0.0 costs 0.0039. One of the two is doing nearly all of the work, and the record is what says which.
profile_smoothing_px ships at 5 (lane F1 0.9967, band F1 0.8506). Moving average over both 1D profiles.
Generated by evals/sweep.py and re-measured by --check in CI. Read from evals/dev_sweeps.json at c77fae3f9.
External validation, designed and not yet run
The cross-check against Fiji/ImageJ is pre-registered and its inputs are assembled: 19 CC-BY blot crops from 13 published figures, each committed with its sha256, DOI and licence so the comparison runs on byte-identical files. Agreement thresholds were fixed before any measurement, so interpretation cannot be fitted to the outcome — Spearman r_s ≥ 0.9 is agreement, 0.7 to 0.9 is partial agreement with every discrepant blot individually explained, and below 0.7 the method is not corroborated and that result is published as stated. The comparison runs on normalized ratios rather than absolute intensities, because ImageJ's aperture convention differs and an absolute comparison would measure the conventions instead of the methods. The first real-data run then measured nothing: all 19 crops are three-channel PNGs and the loader quantifies single-channel images only, so it refused all 19 rather than picking a channel the pre-registration forbids choosing. That refusal is the correct behaviour and the honest result — no lane, band or QC flag has ever been produced from a real blot.
The first real-data run
Nineteen CC-BY crops, and what the pipeline did with them.
The run measured nothing: 0 of 19 crops produced a result document and 19 were refused. That is the recorded result, not a run still pending.
19 crops · refused
Every crop is a 3-channel PNG, and the loader quantifies single-channel images only.
- byte-identical channels10 · 0 DN
- divergence at or below the pre-named bound2 · 2 DN
- divergence above the bound6 · 3 DN to 43 DN
- real colour content1 · 255 DN
The fix is a documented loader decision about channel handling — the ruling puts 12 of 19 crops within reach — and it has to be specified before any measurement is taken, not after. It is not a change to the pre-registration, which stands as frozen. Read from c77fae3f9.
How it's built
The engineering process is part of the point. Each phase runs as an agentic loop in Claude Code: an implementer subagent writes, a reviewer subagent audits the full diff in a fresh context, and a phase is not done until that reviewer returns zero required changes. Standing rules were not enough on their own — one phase ran eight review cycles in which the code converged after three and the written record did not converge at all, because a claim gets fixed where a reviewer happens to read it while its duplicates elsewhere survive. So the claims surface became mechanical: a stdlib-only checker extracts each asserted quantity everywhere it appears and fails CI when two sites disagree, without ever hardcoding the right answer. Decisions live in NOTES.md, known weaknesses in a debt register, and the pipeline may never import from the generator that makes its test data.
Status
In active development. The CLI pipeline, QC, normalization and provenance are merged, as is the HTTP API with caller-supplied lane ROIs. Next: getting a real blot through the loader, then the ImageJ agreement run, and a three-state result status (pass, flagged, blocked) so an image the tool cannot honestly measure is refused with an explanation rather than quantified anyway.
Stack
- Python
- OpenCV / scikit-image
- FastAPI
- NumPy / SciPy
- pytest