spacr.ops_phenotype

Align phenotype fields to the sequencing acquisition by nuclear centroids.

The phenotype images are 2960x2960 at 20X and the sequencing images are 1480x1480 at 10X, of the same round well (phase A4 of the optical pooled screen pipeline). This step produces one transform per phenotype field into the sequencing frame, aligned on the nuclear stain. It does not resample either acquisition into the other: the transform is stored and the pixels are left where they were, because resampling would lose information.

Across a magnification change and two different acquisitions, pixel neighbourhoods do not correspond, but nuclei are identifiable at both scales. The alignment therefore uses nuclear centroids, and spacr.ops_merge.align_by_triangles() proposes the transform from their arrangement alone.

That proposal is not used as the final transform. Measured on the real plate, the triangle consensus is biased and the bias grows as the correspondence thins. Synthetic controls built from 593 real nuclei, transform known exactly:

what was asked for scale returned inliers identity 0.9878 56 % scale 0.25 0.2469 100 % scale 0.25, 1 px of jitter 0.2065 53 % scale 0.25, half the points kept 0.2916 23 % scale 0.25, a third kept 0.3596 15 %

A 1.2 % scale error is 18 px of displacement across a 1480 px field, which is why an exact copy of a point set scores 56 % rather than 100. The last two rows match real tile pairs: two tiles that partially overlap, imaged twice, share only some of their points, each displaced slightly.

The consensus is therefore used as a seed. This module finds the correspondences the seed implies, re-solves the similarity from those correspondences in closed form, and repeats on the correspondences the improved transform implies. align_by_triangles() already re-estimates its translation from the matched vertices once the scale and rotation are fixed; here the scale and the rotation are re-solved as well.

The result also reports how many nuclei agree, which the triangle consensus does not. align_by_triangles returned a transform for every pair it was given, including phenotype tiles and sequencing tiles from opposite sides of the well, because three coincidentally similar triangles can always be found in two sets of a few hundred points. An alignment is accepted only when enough nuclei agree (MIN_INLIERS, MIN_CHANCE_RATIO).

Classes

Alignment

One phenotype field placed in the sequencing frame.

Functions

align_phenotype_to_sbs(→ Optional[Alignment])

Fit one phenotype field's similarity transform into the sequencing frame.

nuclear_points(→ numpy.ndarray)

Return nuclear centres as smoothed intensity peaks, without segmentation.

phenotype_centres(→ Dict[int, Tuple[float, float]])

Predict every phenotype field's well-frame centre from measured anchors.

phenotype_site_map() → Dict[int, int])

Which sequencing tile covers each phenotype field.

refine_similarity(source, target, seed, *[, radius, ...])

Re-solve a seed transform from the correspondences it implies.

seed_by_scaled_pairs(source, target, scale, *[, ...])

A seed transform from two-point correspondences, with the scale known.

similarity_from_correspondences(source, target)

The best similarity taking source onto target, in closed form.

Module Contents

class spacr.ops_phenotype.Alignment[source]

One phenotype field placed in the sequencing frame.

Parameters:
  • scale – target pixels per source pixel.

  • rotation – the 2x2 rotation, source frame to target frame.

  • translation – the offset, applied after scale and rotation.

  • inliers – how many nuclei agree, at MATCH_RADIUS_PX.

  • residual_px – the median distance between agreeing nuclei, in target pixels. The number to read when asking how good it is; inliers says whether to believe it at all.

  • points – how many source nuclei were offered.

apply(points: numpy.ndarray) → numpy.ndarray[source]

Move (N, 2) source coordinates into the target frame.

Computes scale * (rotation @ points.T).T + translation, so callers do not reimplement the convention and risk applying it in the wrong order.

Parameters:

points – source-frame coordinates, an array of shape (N, 2) (converted to float), in the same axis order the alignment was fitted with.

property degrees: float[source]

The rotation in degrees, for a person reading a table.

spacr.ops_phenotype.align_phenotype_to_sbs(phenotype_points: numpy.ndarray, sbs_points: numpy.ndarray, *, expected_scale: float | None = None, tolerance: float = 0.02, radius: float = MATCH_RADIUS_PX, rounds: int = REFINE_ROUNDS, min_inliers: int = MIN_INLIERS, max_rotation: float = MAX_ROTATION_DEGREES, scale_tolerance: float = SCALE_TOLERANCE, min_chance_ratio: float = MIN_CHANCE_RATIO) → Alignment | None[source]

Fit one phenotype field’s similarity transform into the sequencing frame.

Parameters:
  • phenotype_points – (N, 2) nuclear centres in the phenotype field.

  • sbs_points – (M, 2) nuclear centres in the sequencing field.

  • expected_scale – target pixels per source pixel, when the acquisition says. Given, a second seed is tried with the scale fixed; see seed_by_scaled_pairs().

  • tolerance – triangle-shape tolerance for the consensus seed.

  • radius – how close a moved nucleus must land to agree.

  • rounds – refinement rounds.

  • min_inliers – the fewest agreeing nuclei to return anything at all.

  • max_rotation – the largest rotation the two acquisitions may differ by, in degrees.

  • scale_tolerance – how far the answer’s scale may sit from expected_scale, as a fraction. Ignored without one.

  • min_chance_ratio – how many times the chance agreement rate the real one must beat. See MIN_CHANCE_RATIO.

Returns:

the Alignment, or None when no candidate passes the rotation, scale, inlier and chance-ratio checks, which is how non-overlapping fields are reported.

Up to two seeds are refined (the triangle consensus and, with expected_scale, the fixed-scale pair search), and the refined candidate with more agreeing nuclei is kept. Refinement converges to the basin its seed is in, so refining a single seed would choose the answer before comparing the evidence.

Inlier counts only decide between candidates that satisfy the rotation and scale bounds. Scored on inliers alone, a wrong answer wins: a solution 148 degrees out gathered 194 nuclei, and one at scale 0.2069 against a true 0.25 gathered 319 – more than the truth – because shrinking the field crowds the points. Both are excluded before they are scored, by the rotation and scale bounds, which come from the microscope setup rather than from tuning.

spacr.ops_phenotype.nuclear_points(image, *, sigma: float = 3.0, min_distance: int = 8, top: int = 600, percentile: float = 99.0) → numpy.ndarray[source]

Return nuclear centres as smoothed intensity peaks, without segmentation.

Parameters:
  • image – one 2-D plane of a nuclear channel.

  • sigma – Gaussian smoothing before peak finding, in pixels. Smoothing at roughly the nuclear scale prevents one nucleus from producing several peaks.

  • min_distance – the closest two accepted peaks may be, in pixels.

  • top – keep at most this many, brightest first. The triangle matcher is quadratic in the point count and a field’s brightest few hundred nuclei are as informative as all of them.

  • percentile – the intensity floor, as a percentile of the smoothed image. Relative rather than absolute, because the two acquisitions this module compares were taken at different exposures.

Returns:

(N, 2) row/column centres.

Alignment needs points that correspond between two frames, not masks; a smoothed local maximum on a nuclear stain provides them at a fraction of a segmentation’s cost. Segmentation happens later (phase B), once, on the composite.

spacr.ops_phenotype.phenotype_centres(layout: spacr.ops_layout.WellLayout | int, anchors: Mapping[int, Tuple[float, float]]) → Dict[int, Tuple[float, float]][source]

Predict every phenotype field’s well-frame centre from measured anchors.

Parameters:
  • layout – the phenotype acquisition’s spacr.ops_layout.WellLayout, or how many fields it holds.

  • anchors – {site: (y, x)}, the well-frame centres of at least three aligned fields whose grid positions are not all on one line. Six spread over the well is what the method was validated with.

Returns:

{site: (y, x)} for every field of the layout, anchors included – their fitted centres, not the measured ones.

Raises:
  • ValueError – when the anchors cannot fix a raster: fewer than three, on one line of the grid, or a centre that is not two finite numbers. Also for a field count no round well holds.

  • IndexError – when an anchor names a site the layout does not hold.

Six numbers place the whole well. The layout gives every field’s grid position, and the acquisition raster maps a grid position into the well frame by an origin, a column step and a row step, each a (dy, dx) pair carrying the stage’s few pixels of skew. Fitted by least squares on six fields of a real well, that predicted the other 472 fields’ measured centres to a median 1.2 px and a worst 4.0 px, and a second well’s 451 to a worst 4.4 px.

spacr.ops_phenotype.phenotype_site_map(phenotype_sites: spacr.ops_layout.WellLayout | int, sbs_centres: Mapping[int, Tuple[float, float]], anchors: Mapping[int, Tuple[float, float]], *, tile_shape: Tuple[float, float] = (1480, 1480)) → Dict[int, int][source]

Which sequencing tile covers each phenotype field.

Parameters:
  • phenotype_sites – the phenotype acquisition’s spacr.ops_layout.WellLayout, or how many fields it holds.

  • sbs_centres – {sequencing site: (y, x)}, the centre of each stitched sequencing tile in the well frame – its placement plus half a tile.

  • anchors – {phenotype site: (y, x)}, measured well-frame centres of at least three aligned phenotype fields, as phenotype_centres() takes them.

  • tile_shape – (height, width) of a sequencing tile, in well-frame pixels.

Returns:

{phenotype site: sequencing site}. Each field goes to the tile whose centre is nearest its predicted centre, among the tiles that contain it; a field whose predicted centre lies inside no tile is omitted.

Raises:

ValueError – when tile_shape is not two positive sizes, or when phenotype_centres() refuses the anchors or the count.

A position, not an index. The tile a field lies on depends on where the tile boundaries actually fall, and halving a phenotype grid index about the well centre cannot see them: at twice the tile density half the phenotype fields lie on or near a sequencing tile boundary. On the real plate halving found the exact tile for 0.45 to 0.61 of the aligned fields; predicting each centre from six anchors and taking the nearest stitched tile found it for 472 of 472 and 451 of 451.

spacr.ops_phenotype.refine_similarity(source: numpy.ndarray, target: numpy.ndarray, seed, *, radius: float = MATCH_RADIUS_PX, rounds: int = REFINE_ROUNDS)[source]

Re-solve a seed transform from the correspondences it implies.

Parameters:
  • source – (N, 2) points in the frame being moved.

  • target – (M, 2) points in the frame being moved to.

  • seed – (scale, rotation, translation) to start from.

  • radius – how close a moved point must land to count as a match.

  • rounds – how many times to re-solve.

Returns:

(scale, rotation, translation, source_in, target_in, distances), or None when a round finds too few correspondences to solve from.

The seed may be inaccurate. The triangle consensus is biased by up to 44 % in scale on sparse data, but a bias that large still puts the points near the centre of rotation within a few pixels of the truth, which gives enough correspondences to solve from. The first two rounds match at four and two times radius before narrowing to radius.

spacr.ops_phenotype.seed_by_scaled_pairs(source: numpy.ndarray, target: numpy.ndarray, scale: float, *, radius: float = MATCH_RADIUS_PX, trials: int | None = None, tolerance: float = PAIR_TOLERANCE, max_rotation: float = MAX_ROTATION_DEGREES, seed: int = 0)[source]

A seed transform from two-point correspondences, with the scale known.

Parameters:
  • source – (N, 2) points to be moved.

  • target – (M, 2) points to move to.

  • scale – target pixels per source pixel, from the acquisition.

  • radius – how close a moved point must land to agree.

  • trials – how many correspondences to try. None uses the larger of PAIR_TRIALS and TRIALS_PER_TARGET_POINT times the target point count.

  • tolerance – fractional slack on a pair’s separation.

  • max_rotation – the largest rotation to consider, in degrees. See MAX_ROTATION_DEGREES; the bound prevents the search from returning a symmetric false solution with a high inlier count.

  • seed – the random seed, so a run is reproducible.

Returns:

(scale, rotation, translation) for the best-scoring correspondence, or None if none scored above two points.

This complements the triangle consensus, which estimates the scale and on sparse or noisy correspondence estimates it poorly – 0.36 where the truth was 0.25, measured. Refinement cannot recover from that: a transform that far off finds a small self-consistent cluster of points near its own fixed point and converges onto it. The scale does not need to be estimated, because it is the ratio of the two acquisitions’ tile pitches, which the layout provides.

So this fixes the scale and searches only rotation and translation, which two matched points determine exactly. A source pair separated by d must land on a target pair separated by scale * d, and that single constraint is enough to make random sampling cheap: for a sampled target point, only its neighbours at the right distance can be the partner.

Candidates are scored by inlier count (moved points within four times radius of a target point), not by residual: a transform that maps four points exactly scores lower than one that maps two hundred approximately.

spacr.ops_phenotype.similarity_from_correspondences(source: numpy.ndarray, target: numpy.ndarray)[source]

The best similarity taking source onto target, in closed form.

Parameters:
  • source – (N, 2) points.

  • target – (N, 2) points, in the same order.

Returns:

(scale, rotation, translation), or None for fewer than two points or a degenerate source.

Umeyama’s solution: the least-squares scale, rotation and translation over every correspondence at once. Reflections are refused – two acquisitions of one well differ by a rigid motion and a magnification, never by a mirror, and allowing one would let a wrong correspondence set produce a mirrored transform with a small residual.