# RSaturate: saturation checker (R)

Score Western-blot bands for signal saturation (dynamic-range collapse), the state
that silently invalidates densitometric quantification. Runs entirely in R; there is
nothing to train and no data is stored.

This package scores **band crops you supply**. If you want bands found for you in a
figure or a whole membrane, use the Python package (see *Which package do I want?*).

## Install (one time)

```r
install.packages(c("torch", "magick"))
library(torch); torch::install_torch()   # one-time backend download
```

## Use

Put this folder anywhere. In R, from inside it:

```r
source("rsaturate_infer.R")

audit_blot("path/to/band.png",     model_dir = "model")   # one band crop
audit_blot("path/to/crops_folder", model_dir = "model")   # a folder of band crops
```

Two outputs are written:

- `rsaturate_results.csv` gives, per band, the saturation probability, the ensemble
  disagreement, and a saturated yes/no at the shipped threshold (0.47).
- `overlays/` holds a colour-coded copy of each input: **green = clean, red = saturated,
  amber = uncertain**, labelled with the probability. Pass `overlay = FALSE` to skip.

## What "saturated" means

A band is flagged when its core has flattened at the imaging ceiling. Such bands
cannot be quantified by densitometry, so treat them as non-quantitative.

## Preparing a crop

Each input image is treated as exactly one band, so how you crop matters more than
anything else in this README.

- **One band per image**, as a rectangle with a margin of clean background above and
  below it. The model judges the band core against its surroundings, so a crop cut
  tight to the band removes the very context it needs.

- **Aim for the band to fill roughly a third to two thirds of the crop.** This is the
  range the model is most reliable in, and both ends of it were measured on the 2,400
  validation crops rather than guessed. Below about a third fill, a crop is close to
  uniform and the low-contrast guard described under *Notes* keeps it flat, which is
  the same pattern a saturated plateau produces: unsaturated crops in that range
  average 0.60, above the 0.47 threshold, so expect false alarms. Above about half
  fill, the crop's median sits on the band rather than on the background and polarity
  canonicalization inverts the tile; the model was trained through this and handles it,
  but it is a different regime from the one most users have in mind. Separation between
  saturated and unsaturated crops is 0.76 in the well-filled range and 0.26 in the
  sparse range.

- **Dark bands on a light background.** The model was trained on published-figure
  material, which is overwhelmingly dark-on-light. If your imager writes bright signal
  on a dark field, invert first:

  ```r
  magick::image_write(magick::image_negate(magick::image_read("raw.tif")), "band.png")
  ```

- **Do not pass a whole figure or membrane.** It will be scored as though the entire
  image were one band, and the number returned will be meaningless rather than
  obviously wrong, which is worse.

## Which package do I want?

| You have | Use |
|---|---|
| Band crops already | **This R package.** Two dependencies, nothing to configure. |
| A membrane or a published figure | **The Python package.** It localizes bands, screens out non-band regions, and scores them. |

Both score through the same trained ensemble: the Python package loads a TorchScript
export of these exact weights.

Numeric agreement was measured on a shared 153-crop fixture and ships with the Python
bundle as `parity_report.csv`:

| | |
|---|---|
| Mean absolute difference in saturation probability | 0.008 |
| Largest difference | 0.098 |

The residual comes from the image resampler, which differs slightly between R's bundled
ImageMagick and Python's PIL and cannot be reconciled from the Python side.

**Status agreement is not currently claimed.** An earlier version of this file reported
agreement on a four-way status (clean, saturated, review, rejected) for 151 of 153
crops. That comparison was made against `audit_from_boxes.R`, the corpus-audit entry
point, and not against `rsaturate_infer.R`, which is what this package ships and which
applies no pre-scoring gates at all. The Python package's gates have since been found to
differ from `audit_from_boxes.R` in three ways, so the figure does not describe either
pair of shipped components and has been withdrawn pending re-measurement on a fixture
that includes crops below sixteen pixels and crops with a mean intensity above 240.
The probability agreement above is unaffected, because it is measured on crops that
reach the network in both packages.

## Notes

- The ensemble lives in `model/ensemble/member_*.pt` (five members). A score is the
  mean across members; their spread is the `ensemble_sd` column and drives the amber
  "uncertain" call.
- The 0.47 threshold is specificity-oriented: it is deliberately reluctant to call a
  band saturated, because a false alarm costs a scientist a usable blot.
- Contrast normalization uses a robust 1st-99th percentile range, with a fallback to
  absolute scaling when that range drops below 15% of full scale. The fallback exists
  so that a genuinely clipped plateau is not stretched back into apparent texture. Its
  side effect is the sparse-crop behaviour described under *Preparing a crop*.
- Two preprocessing details are preserved deliberately rather than fixed, because the
  shipped weights were trained through them and changing either would require
  retraining and a new operating point. Polarity is decided per crop from its median,
  which inverts crops whose band covers more than half the frame. Tile pixels are read
  column-major and rebuilt row-wise, so each row of the 64 x 256 tile is composed of
  four stacked columns of the source crop. Both are documented in the manuscript
  methods. Anyone reimplementing this scorer must reproduce both to get matching
  numbers.
- No detector ships with this package by design. If you find a `band_detector.ts`
  here, the bundle was built wrong.

License: MIT (see LICENSE). Gomes Lab, UC Davis.
