spacr.ops_phenotype

Phase A4: putting the phenotype acquisition on the sequencing one.

WHAT THIS STEP IS. The phenotype images are 2960x2960 at 20X and the sequencing images are 1480x1480 at 10X, of the same round well. 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 a field to compare it is a lossy way to say where it is.

WHY IT IS NOT A PIXEL PROBLEM. Across a magnification change and two different acquisitions a pixel neighbourhood means nothing, but a nucleus is a nucleus at both scales. So the alignment runs on NUCLEAR CENTROIDS, and spacr.ops_merge.align_by_triangles() proposes the transform from their arrangement alone.

WHY THAT PROPOSAL IS NOT THE ANSWER. 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. And the last two rows are the shape of every real pair this module was first tried on – two tiles that partially overlap, imaged twice, are exactly “some of the points, moved a little”.

SO THE CONSENSUS IS A SEED AND NOT A RESULT. This module takes it, finds the correspondences it implies, and RE-SOLVES the similarity from those correspondences in closed form; then does it again on the correspondences the better transform implies. That is the same argument align_by_triangles() already makes for its translation – “not a refinement, it is the difference between working and not” – applied to the scale and the rotation as well.

AND IT REPORTS HOW MANY NUCLEI AGREE, which the triangle consensus does not. align_by_triangles returned a transform for every pair it was ever handed, 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 with no inlier count is not evidence, and PART 6-A of 372 records the same lesson about placements: a count of things attempted is not a measure of anything succeeding.

Classes

Alignment

One phenotype field placed in the sequencing frame.

Functions

align_phenotype_to_sbs(→ Optional[Alignment])

One phenotype field's transform into the sequencing frame.

nuclear_points(→ numpy.ndarray)

Nuclear centres from a nuclear stain, without segmenting anything.

phenotype_site_map(→ Dict[int, int])

Which sequencing tile covers each phenotype tile.

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.

The stored transform, applied – so a caller never has to reconstruct the convention and get it the wrong way round, which is what cost 372’s PART 11-C a canvas one pitch per column too large.

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]

One phenotype field’s 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() for why that matters more than it sounds.

  • 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 – which is what “these two fields do not overlap” looks like, and it has to be sayable.

TWO SEEDS, AND THE ONE WITH MORE NUCLEI BEHIND IT WINS. Refinement converges to whatever basin its seed is in, so trying one seed and refining it is a decision made before any evidence is in. Scoring both on the same measure – how many nuclei agree – is not.

BUT INLIERS ONLY DECIDE BETWEEN CANDIDATES THAT ARE PHYSICALLY POSSIBLE. 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 here before they are scored, by the rotation and scale bounds, and both bounds are facts about the microscope rather than thresholds anybody tuned.

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

Nuclear centres from a nuclear stain, without segmenting anything.

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

  • sigma – Gaussian smoothing before peak finding; a nucleus is a blob, and smoothing at its own scale is what stops one nucleus answering as three.

  • 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.

A4 DOES NOT NEED MASKS. It needs points that mean the same thing in two frames, and a smoothed local maximum on a nuclear stain is that at a fraction of a segmentation’s cost. Phase B segments, once, on the composite; this is not that step and must not become it.

spacr.ops_phenotype.phenotype_site_map(phenotype_sites: int, sbs_sites: int) Dict[int, int][source]

Which sequencing tile covers each phenotype tile.

Parameters:
  • phenotype_sites – how many fields the phenotype acquisition holds.

  • sbs_sites – how many the sequencing acquisition holds.

Returns:

{phenotype site -> sequencing site}, omitting any phenotype tile whose sequencing position is outside that circle.

Raises:

ValueError – when either count is not a round well, which spacr.ops_layout.round_well_layout() decides.

IT IS A LAYOUT QUESTION, NOT AN IMAGE ONE, and answering it by image was how the first three attempts at A4 spent an afternoon: phenotype site N was matched against sequencing site N, which is a different part of the well, and every pair came back at 2-9 % inliers. The layout says it in closed form. The measured acquisition fits 41 columns at radius 20.15 against the sequencing well’s 21 at 10.25 – the same round well at twice the tile density, and 41 = 2 * 21 - 1 – so a phenotype grid position halves about the centre.

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 IS ALLOWED TO BE POOR. That is the point: the triangle consensus is biased by up to 44 % in scale on sparse data, and a bias that large still puts most of the field within a few pixels of the truth near the centre of rotation, which is enough correspondence to solve properly from.

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 scales it with the target, which is what it has to scale with – see PAIR_TRIALS.

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

  • max_rotation – the largest rotation to consider, in degrees. See MAX_ROTATION_DEGREES – this is what stops the search returning a symmetric coincidence with a good-looking score.

  • 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.

WHY THIS EXISTS RATHER THAN THE TRIANGLE CONSENSUS ALONE. The consensus estimates the scale, and on sparse or noisy correspondence it estimates it badly – 0.36 where the truth was 0.25, measured. Refinement cannot recover from that: a transform that wrong finds a small self-consistent cluster of points near its own fixed point and converges happily onto it. The scale, though, is not something A4 has to estimate. It is the ratio of the two acquisitions’ tile pitches, and the layout knows it.

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.

SCORED BY INLIERS, NOT BY RESIDUAL. A transform that maps four points onto four points perfectly beats nothing; one that maps two hundred within a nucleus’s radius is the answer.

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 lets a bad correspondence set produce a transform that fits beautifully and means nothing.