spacr.ops_sbs¶
Decode in-situ sequencing barcodes out of an image stack.
WHAT THIS IS FOR, AND WHAT IT IS NOT¶
Optical pooled screening reads the guide barcode OUT OF THE IMAGES. Each sequencing cycle stains four channels, one per base, and a spot’s base in that cycle is whichever channel is brightest – once the channels have been put on a common scale. Read one base per cycle at the same spot and the cycles spell the barcode.
spacr.sequencing is NOT the back half of this and must not be pointed
at it. That module decodes FASTQ reads from an NGS run; there is no FASTQ here
and no sequencer, only pixels.
WHY EACH STEP IS THE STEP IT IS¶
Three of these carry nearly all of the correctness risk, and all three are counter-intuitive enough that implementing them from a description of the pipeline rather than from a working one produces something that runs and is wrong:
A read is not a bright spot. It is a spot whose base CHANGES between cycles. So the location estimate is the standard deviation ACROSS CYCLES, averaged over channels – a spot bright in every cycle is a piece of dirt or an autofluorescent blob and contributes no variance.
The brightest raw channel is not the base. Every channel has its own gain and background, and on a real plate both drift from cycle to cycle, so the raw argmax is biased toward whichever channel is brightest overall. Each cycle’s channels are divided by their median over the reads first. Unmixing the dyes’ bleed-through is available on top of that; on the first real plate it lowered the library match.
An ambiguous read is worse than a missing one. Error correction against the guide library only ever corrects to a UNIQUE closest match. A read that is equally close to two barcodes is discarded, because a wrongly assigned guide silently corrupts a screen’s results while a dropped one only costs statistical power.
The method here follows brieflow (Cheeseman lab; github.com/cheeseman-lab/ brieflow, MIT, Copyright 2025 Massachusetts Institute of Technology), whose source was read for the three points above rather than reconstructed from its stage names.
Functions¶
|
Give each segmented cell the barcode its reads agree on. |
|
Give each plate object the barcode its attributed reads agree on. |
|
Give each detected read to the object whose boundary is nearest. |
|
Turn per-cycle intensities into a barcode string and a quality per read. |
|
The intensities a call was made on, the barcode, and the margin per cycle. |
|
Undo dye bleed-through, so the brightest channel IS the base. |
|
Snap each read to the library, but ONLY on a unique closest match. |
|
Where the reads are: variance across cycles, not brightness. |
|
Per-cycle, per-channel intensity at each peak. |
|
Local maxima of |
Module Contents¶
- spacr.ops_sbs.assign_reads_to_cells(peaks: numpy.ndarray, barcodes: Sequence[str], labels: numpy.ndarray, *, quality: numpy.ndarray | None = None, min_reads: int = 2, min_fraction: float = 0.6) Dict[int, dict][source]¶
Give each segmented cell the barcode its reads agree on.
A cell contains several spots and they will not all decode identically: an out-of-focus spot, a spot shared with a neighbour, and a genuine second perturbation all look the same at this stage. So a cell is assigned only when its reads AGREE –
min_fractionof at leastmin_readsmust carry the same barcode – and is otherwise left unassigned.THE DEFAULTS REFUSE MORE THAN THEY ACCEPT, deliberately. One read is not evidence: it cannot be checked against anything, and a single mis-called base would silently hand a cell the wrong perturbation. A cell with no barcode costs statistical power; a cell with the WRONG barcode moves a real phenotype onto another guide’s average and is not recoverable downstream, because nothing later can tell it happened.
Reads landing on label 0 – background, between cells – are discarded rather than attached to the nearest cell. A read that segmentation did not place inside anything is a read whose owner is unknown.
- Parameters:
peaks –
(N, 2)(y, x)read positions.barcodes – one called barcode per read.
labels – the segmentation, as an integer label image where 0 is background.
quality – optional per-read quality from
call_reads(); when given it is averaged over the reads that agreed.min_reads – how many reads a cell needs before it may be assigned.
min_fraction – what share of them must agree.
- Returns:
{label: {"barcode", "reads", "agreeing", "fraction", "quality"}}for every cell that met the bar.
- spacr.ops_sbs.assign_reads_to_objects(owners: numpy.ndarray, barcodes: Sequence[str], *, quality: numpy.ndarray | None = None, min_reads: int = 2, min_fraction: float = 0.6) Dict[int, dict][source]¶
Give each plate object the barcode its attributed reads agree on.
The same rule as
assign_reads_to_cells()– at leastmin_readsreads,min_fractionof them carrying one barcode, and no tie for first – keyed by the object id each read was attributed to rather than by the label a read lands on. That is what lets reads collected from several fields vote once for the object they belong to.- Parameters:
owners – one object id per read; 0 or a negative id means the read has no owner and is ignored.
barcodes – one called barcode per read.
quality – optional per-read quality from
call_reads().min_reads – how many reads an object needs before it may be assigned.
min_fraction – what share of them must agree.
- Returns:
{object_id: {"barcode", "reads", "agreeing", "fraction", "quality"}}for every object that met the bar.
- spacr.ops_sbs.attribute_reads(peaks: numpy.ndarray, centroids: numpy.ndarray, areas: numpy.ndarray, *, footprint: float = 10.0, tie: float = 1.0) Tuple[numpy.ndarray, numpy.ndarray][source]¶
Give each detected read to the object whose boundary is nearest.
Reads are detected over the whole field first and attributed here, so a footprint that is too tight shows up as reads with no owner rather than staying invisible. The boundary is each object’s equivalent disc, the circle of its area about its centroid, and a read whose two nearest boundaries lie within
tieof each other is given to neither and flagged.- Parameters:
peaks –
(N, 2)read positions(y, x).centroids –
(M, 2)object centroids(y, x), in the same frame aspeaks.areas –
(M,)object areas in pixels.footprint – how far beyond an object’s boundary a read may lie and still be its read, in pixels. On the first real plate 98 % of spots lay within 10 px of a nucleus and 67 % within 3 px.
tie – how close the two nearest boundaries may be before a read is refused as ambiguous, in pixels.
- Returns:
(owner, ambiguous)– the index intocentroidsof each read’s owner, or -1, and whether each read was refused as ambiguous.
- spacr.ops_sbs.call_reads(values: numpy.ndarray, *, bases: Sequence[str] = BASES, compensate: bool = False, method: str = 'percentile', normalise: bool = True, gpu: bool = True) Tuple[List[str], numpy.ndarray][source]¶
Turn per-cycle intensities into a barcode string and a quality per read.
Quality is the margin between the winning channel and the runner-up, divided by their sum, per cycle – 0 when two bases are equally likely and 1 when the call is unambiguous. Reported per read as the MINIMUM over cycles, because a barcode is only as trustworthy as its worst base.
A cycle that was not measured is called
N. Where a read’s values for one cycle are NaN – the cycle’s file could not be read, or its registration was refused – that letter isNand the quality is taken over the cycles that were measured, so one lost cycle costs one base of each read and not the read.- Parameters:
values –
(N, cycles, channels)fromextract_bases().bases – the letter for each channel, in channel order.
compensate – undo cross-talk with
compensate_crosstalk()after normalising. Off by default: on the first real plate it lowered the library match.method – passed to
compensate_crosstalk().normalise – divide each cycle’s channels by their median over the reads first, which removes the per-channel gain and background drift between cycles. Skipped when there are too few reads for a median.
gpu – let the compensation’s multiply run on the card. False keeps the whole call on the CPU.
- Returns:
(barcodes, quality)– one string and one float per read.
- spacr.ops_sbs.called_bases(values: numpy.ndarray, *, bases: Sequence[str] = BASES, compensate: bool = False, method: str = 'percentile', normalise: bool = True, gpu: bool = True) Tuple[numpy.ndarray, List[str], numpy.ndarray][source]¶
The intensities a call was made on, the barcode, and the margin per cycle.
call_reads()is this, with the per-cycle margins reduced to their minimum. The three pieces are separated because theops_readstable of 372’s storage contract is one row per read PER CYCLE – base, quality and the per-channel intensity – and a caller that only gets the barcode and one number per read has to redo the normalisation to write it. Redoing it is what makes a stored intensity disagree with the call beside it.- Parameters:
values –
(N, cycles, channels)fromextract_bases().bases – the letter for each channel, in channel order.
compensate – undo cross-talk after normalising.
method – passed to
compensate_crosstalk().normalise – divide each cycle’s channels by their median over the reads first.
gpu – let the compensation’s multiply run on the card.
- Returns:
(data, barcodes, margin)–(N, cycles, channels)float32 intensities AFTER normalisation and any compensation, so they are the numbers the winner was picked from rather than the raw ones; one barcode string per read; and(N, cycles)float32 margins, NaN for a cycle that was not measured and whose letter is thereforeN.
- spacr.ops_sbs.compensate_crosstalk(values: numpy.ndarray, *, method: str = 'percentile', percentile: float = 95.0, gpu: bool = True) numpy.ndarray[source]¶
Undo dye bleed-through, so the brightest channel IS the base.
THE RAW ARGMAX IS NOT THE BASE. Each dye emits into its neighbours’ channels, so a channel that is bright overall wins comparisons it should lose, and the bias is systematic rather than noise: it mis-calls the same base everywhere.
The correction is fitted FROM THE DATA rather than from a calibration file, because it depends on the stain, the filters and the exposure – which are properties of the run, not of the instrument. For each channel, the spots where that channel dominates are found, and their mean vector becomes that channel’s axis; the matrix of those axes, inverted, maps observed intensity back onto base identity.
- Parameters:
values –
(N, cycles, channels)fromextract_bases().method –
"percentile"takes spots abovepercentilein a channel;"median"takes spots where the channel is the argmax. Percentile is the more robust of the two when one base is rare, which is the case a median call gets wrong.percentile – the cutoff for
"percentile".gpu – let the unmixing multiply run on the card. One tiny matrix applied to every spot of every cycle is a large multiply by a small operand, which is the shape that goes to a card well.
- Returns:
corrected intensities, same shape.
- Raises:
ValueError – on an unknown
method.
- spacr.ops_sbs.correct_to_library(barcodes: Sequence[str], library: Sequence[str], *, max_distance: int = 1) List[str | None][source]¶
Snap each read to the library, but ONLY on a unique closest match.
AMBIGUITY IS DISCARDED, NOT GUESSED. A read equally close to two library barcodes returns
None. That is deliberate and it is the whole point of the step: a misassigned guide moves a cell’s phenotype onto the wrong perturbation and silently corrupts every statistic downstream, while a dropped read only costs statistical power that more cells can buy back.An exact match short-circuits, so a clean run pays nothing for this.
An
Nis a base that was not measured, not a mismatch. It matches any letter and is not counted againstmax_distance; the uniqueness rule still applies, so a read whose lost base is the only thing separating two guides maps to neither.- Parameters:
barcodes – the called reads.
library – the guide barcodes the screen actually contains.
max_distance – the largest Hamming distance that may be corrected.
- Returns:
one entry per read – the library barcode, or None.
- spacr.ops_sbs.estimate_read_locations(stack: numpy.ndarray) numpy.ndarray[source]¶
Where the reads are: variance across cycles, not brightness.
A sequencing spot changes colour from cycle to cycle, which is exactly what a constant piece of debris does not do. Taking the standard deviation over the CYCLE axis and then the mean over channels therefore scores “something here changed” rather than “something here is bright”, and the brightest object in a field is very often not a read.
With a single cycle there is no cycle-to-cycle variance to measure, so the standard deviation is taken across channels instead – a spot that is one colour rather than grey. That is a weaker signal and a one-cycle experiment is a weaker experiment; it is supported because the alternative is failing on a legitimate input.
- Parameters:
stack –
(cycles, channels, Y, X)intensities.- Returns:
a
(Y, X)float32 map, high where a read is likely.- Raises:
ValueError – if
stackis not four-dimensional.
- spacr.ops_sbs.extract_bases(stack: numpy.ndarray, peaks: numpy.ndarray, *, window: int = 1) numpy.ndarray[source]¶
Per-cycle, per-channel intensity at each peak.
The maximum over a small window is taken rather than the single pixel, because the cycles are registered to within a pixel or so and reading the exact centre would sample the shoulder of the spot in whichever cycle drifted. A window of one – a 3x3 – is enough for that and small enough not to swallow a neighbour.
- Parameters:
stack –
(cycles, channels, Y, X)intensities.peaks –
(N, 2)array of(y, x)fromfind_peaks().window – half-width of the sampling window, in pixels.
- Returns:
(N, cycles, channels)float32 intensities.
- spacr.ops_sbs.find_peaks(score: numpy.ndarray, *, min_distance: int = 3, threshold: float | None = None, gpu: bool = True) numpy.ndarray[source]¶
Local maxima of
score, as an(N, 2)array of(y, x).A plain “is this pixel the largest in its window” test returns a clump of neighbouring pixels for one spot, so the window maximum is compared for EQUALITY with the pixel and ties are broken by taking the first – which is what makes one spot yield one peak.
- Parameters:
score – the map from
estimate_read_locations().min_distance – half-width of the suppression window, in pixels. Two reads closer than this cannot both be found, which is a property of the optics rather than of this function.
threshold – ignore maxima below this.
Nonekeeps every local maximum and leaves the decision to the caller, which is the right default because the useful cutoff depends on the stain.gpu – let the windowed maximum run on the card where there is a usable one. It is a max-pool, which is the single most expensive step in the decode chain on a well-sized field and the operation a GPU exists for. The result is identical either way – see
tests/test_the_ops_primitives_agree_on_every_backend.py.
- Returns:
peak coordinates, strongest first.