AnalyzeGridScan takes a finished ScanResult plus its GridScanSettings and returns the list of crystals the raster hit, sorted by score so element 0 is the one to collect. Pure function - no I/O, no FPGA, no JSON. Today the list holds nought or one entry; N is the point of the shape. The per-image protein score is scattered back onto the display grid through Rearrange, which already knows the snake order, the vertical flag and the step signs, thresholded, and labelled into blobs. Each blob is then measured: - Centre is a weighted centroid, pulled towards the cells that diffract best. The pull uses the RANK of the resolution inside the blob, never its value, so a salt grain reporting an absurd 0.8 A weighs exactly what a genuine best cell weighs and cannot drag the centre however extreme its number. A cell with no resolution gets the lowest weight rather than being dropped. The centroid of a banana- or L-shaped blob can land outside the blob, where no image exists, so image_number is snapped to the nearest cell that was actually collected. - Second moments are taken in micrometres, not in cells. A 20 x 16 um raster is ordinary and moments in cell units give a wrong angle - eight degrees wrong on the staircase in the tests. The angle is an axis, so it lives in [0,180) and wraps there. - The axis DIRECTION comes from the eigenvector but the LENGTH from the projected extent, because "how far do I scan" is an extent question and the constant taking a second moment to a length assumes a shape a blob of five cells does not have. Where the two disagree about which axis is longer - a moment dominated by clumps at the ends - the extents are swapped and the angle turned a quarter turn, so major_um >= minor_um with angle_deg along it is an invariant a consumer can draw a frame from. - score is the MEAN protein score over the blob, not the peak: the score saturates, so the peak is 1.0 for every real crystal and ranks nothing. res_A is the 25th percentile, not the minimum, the minimum being precisely where a salt spot or a hot pixel shows up; it is NaN when nothing in the blob measured a resolution. Sizes are measured and the beam is left in them. The beam is already in the file as incident_beam_size, so a consumer can deconvolve reproducibly and reversibly instead of inheriting ours; the result carries the beam size so it says what the extents contain. The header records that removing an anisotropic beam is a covariance-matrix subtraction followed by re-diagonalisation, not a per-axis quadrature removal, which is silently wrong whenever the crystal is not aligned with the grid - the needle case this design exists for. Labelling is a small dense flood fill in common/, 8-connected. StrongPixelSet::sparseccl is the wrong abstraction for a dense grid map: sparse union-find over raster-ordered strong pixels, hardcoded module dimensions, a 4000-pixel cap, spot-shape acceptance, and an FPGA header. 8-connected rather than 4 because where the step is coarser than the beam a needle at 45 degrees lands as corner-touching cells; under 4-connectivity that breaks into single cells and the minimum-size rule then discards the crystal entirely, which is the case oriented axes exist to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
207 lines
9.3 KiB
C++
207 lines
9.3 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <utility>
|
|
|
|
#include "../../common/ConnectedComponents.h"
|
|
#include "../../common/JFJochMath.h"
|
|
#include "AnalyzeGridScan.h"
|
|
|
|
namespace {
|
|
// A cell counts as protein above this. The per-image score saturates, so this only has to
|
|
// separate "something diffracted here" from "nothing did".
|
|
constexpr float PROTEIN_SCORE_THRESHOLD = 0.5f;
|
|
|
|
// Two cells can be the two ends of a single hit lying on a cell boundary; three is the
|
|
// smallest patch that is a shape rather than a coincidence.
|
|
constexpr int64_t MIN_BLOB_CELLS = 3;
|
|
|
|
constexpr float NO_VALUE = -1.0f;
|
|
}
|
|
|
|
GridScanResult AnalyzeGridScan(const ScanResult &scan,
|
|
const GridScanSettings &grid,
|
|
float beam_size_x_um,
|
|
float beam_size_y_um) {
|
|
const int64_t nx = grid.GetGridSizeX_step();
|
|
const int64_t ny = grid.GetGridSizeY_step();
|
|
const float step_x = fabsf(grid.GetGridStepX_um());
|
|
const float step_y = fabsf(grid.GetGridStepY_um());
|
|
|
|
// Scatter the per-image quantities onto the display grid. Rearrange knows about snake order,
|
|
// the vertical flag and the step signs, so nothing here has to.
|
|
std::vector<float> protein(nx * ny, NO_VALUE);
|
|
std::vector<float> ice(nx * ny, NO_VALUE);
|
|
std::vector<float> res(nx * ny, NO_VALUE);
|
|
std::vector<int64_t> image_no(nx * ny, -1);
|
|
|
|
for (const auto &elem: scan.images) {
|
|
if (elem.number < 0 || elem.number >= grid.GetNElem())
|
|
continue;
|
|
const int64_t i = grid.Rearrange(elem.number);
|
|
protein[i] = elem.protein_score.value_or(NO_VALUE);
|
|
ice[i] = elem.ice_score.value_or(NO_VALUE);
|
|
res[i] = elem.res.value_or(NO_VALUE);
|
|
image_no[i] = elem.number;
|
|
}
|
|
|
|
std::vector<uint8_t> above(nx * ny);
|
|
for (int64_t i = 0; i < nx * ny; i++)
|
|
above[i] = (protein[i] > PROTEIN_SCORE_THRESHOLD) ? 1 : 0;
|
|
|
|
const std::vector<int32_t> label = LabelConnectedComponents(above, nx, ny, MIN_BLOB_CELLS);
|
|
const int32_t n_label = label.empty() ? 0 : *std::max_element(label.begin(), label.end());
|
|
|
|
GridScanResult result;
|
|
result.beam_size_x_um = beam_size_x_um;
|
|
result.beam_size_y_um = beam_size_y_um;
|
|
|
|
for (int32_t l = 1; l <= n_label; l++) {
|
|
std::vector<int64_t> cell;
|
|
for (int64_t i = 0; i < nx * ny; i++) {
|
|
if (label[i] == l)
|
|
cell.push_back(i);
|
|
}
|
|
if (cell.empty())
|
|
continue; // label fell below MIN_BLOB_CELLS and was erased
|
|
|
|
const auto n = static_cast<float>(cell.size());
|
|
|
|
// Cell centres in MICROMETRES, taken once. Every geometric quantity below is computed from
|
|
// these and never from cell units: step_x and step_y genuinely differ (20 x 16 um is an
|
|
// ordinary raster), so a second moment taken in cells gives the wrong axis angle.
|
|
// The i / nx and i % nx are the row and column of a row-major index - an index split, and
|
|
// truncation is the whole point of it; there is no precision to lose there.
|
|
std::vector<float> px(cell.size()), py(cell.size());
|
|
for (size_t k = 0; k < cell.size(); k++) {
|
|
px[k] = static_cast<float>(cell[k] % nx) * step_x;
|
|
py[k] = static_cast<float>(cell[k] / nx) * step_y;
|
|
}
|
|
|
|
// Centre, pulled towards the cells that diffract best. The pull uses the RANK of the
|
|
// resolution within the blob, never its value: the best cell gets p = 1, the worst p = 0,
|
|
// and a cell with no resolution at all gets p = 0 rather than being dropped. A salt grain
|
|
// reporting an absurd 0.8 A is then weighted exactly like a genuine best cell, so no
|
|
// artefact can drag the centre however extreme its number is.
|
|
std::vector<int64_t> by_res;
|
|
for (int64_t i: cell) {
|
|
if (res[i] > 0)
|
|
by_res.push_back(i);
|
|
}
|
|
std::sort(by_res.begin(), by_res.end(), [&](int64_t a, int64_t b) { return res[a] < res[b]; });
|
|
|
|
std::vector<float> weight(cell.size(), 1.0f);
|
|
for (size_t r = 0; r < by_res.size(); r++) {
|
|
const float p = (by_res.size() == 1) ? 1.0f
|
|
: 1.0f - static_cast<float>(r) / static_cast<float>(by_res.size() - 1);
|
|
// cell is built in ascending grid order, so it can be searched directly
|
|
weight[std::lower_bound(cell.begin(), cell.end(), by_res[r]) - cell.begin()] = 1.0f + 0.5f * p;
|
|
}
|
|
|
|
float sum_w = 0, sum_wx = 0, sum_wy = 0;
|
|
for (size_t k = 0; k < cell.size(); k++) {
|
|
sum_w += weight[k];
|
|
sum_wx += weight[k] * px[k];
|
|
sum_wy += weight[k] * py[k];
|
|
}
|
|
const float cx_um = sum_wx / sum_w;
|
|
const float cy_um = sum_wy / sum_w;
|
|
|
|
// A weighted centroid of a banana- or L-shaped blob can land outside the blob, where no
|
|
// image was ever collected. The image number has to name a cell that exists, so snap.
|
|
size_t nearest = 0;
|
|
float nearest_d2 = INFINITY;
|
|
for (size_t k = 0; k < cell.size(); k++) {
|
|
const float d2 = (px[k] - cx_um) * (px[k] - cx_um) + (py[k] - cy_um) * (py[k] - cy_um);
|
|
if (d2 < nearest_d2) {
|
|
nearest_d2 = d2;
|
|
nearest = k;
|
|
}
|
|
}
|
|
|
|
float sxx = 0, syy = 0, sxy = 0;
|
|
for (size_t k = 0; k < cell.size(); k++) {
|
|
const float dx = px[k] - cx_um;
|
|
const float dy = py[k] - cy_um;
|
|
sxx += dx * dx;
|
|
syy += dy * dy;
|
|
sxy += dx * dy;
|
|
}
|
|
const float angle = 0.5f * atan2f(2 * sxy, sxx - syy);
|
|
const float cos_a = cosf(angle);
|
|
const float sin_a = sinf(angle);
|
|
|
|
// Direction from the eigenvector, LENGTH from the projected extent. "How far do I scan"
|
|
// is an extent question, and the constant taking a second moment to a length depends on
|
|
// an assumed shape that a blob of a few cells does not have.
|
|
float min_u = INFINITY, max_u = -INFINITY, min_v = INFINITY, max_v = -INFINITY;
|
|
for (size_t k = 0; k < cell.size(); k++) {
|
|
const float dx = px[k] - cx_um;
|
|
const float dy = py[k] - cy_um;
|
|
min_u = std::min(min_u, dx * cos_a + dy * sin_a);
|
|
max_u = std::max(max_u, dx * cos_a + dy * sin_a);
|
|
min_v = std::min(min_v, -dx * sin_a + dy * cos_a);
|
|
max_v = std::max(max_v, -dx * sin_a + dy * cos_a);
|
|
}
|
|
// The span runs between cell centres, so one cell has to be added back. A cell is a
|
|
// step_x by step_y rectangle, and its own width along a direction is that rectangle's
|
|
// support width - which is the plain step only when the axis lies along the grid.
|
|
const float cell_along_u = fabsf(step_x * cos_a) + fabsf(step_y * sin_a);
|
|
const float cell_along_v = fabsf(step_x * sin_a) + fabsf(step_y * cos_a);
|
|
|
|
float sum_protein = 0, sum_ice = 0;
|
|
for (int64_t i: cell) {
|
|
sum_protein += protein[i];
|
|
sum_ice += std::max(ice[i], 0.0f);
|
|
}
|
|
|
|
GridScanCrystal crystal;
|
|
crystal.nx = cx_um / step_x;
|
|
crystal.ny = cy_um / step_y;
|
|
crystal.x_um = cx_um;
|
|
crystal.y_um = cy_um;
|
|
crystal.image_number = image_no[cell[nearest]];
|
|
crystal.major_um = max_u - min_u + cell_along_u;
|
|
crystal.minor_um = max_v - min_v + cell_along_v;
|
|
crystal.angle_deg = angle * 180.0f / static_cast<float>(PI);
|
|
|
|
// The angle is the axis of the larger second MOMENT, the extents are MEASURED spans, and
|
|
// for a strongly non-convex blob the two can disagree about which axis is the longer.
|
|
// A consumer draws a major by minor frame rotated by angle_deg, so keep both facts by
|
|
// turning the frame a quarter turn rather than by dropping one of them.
|
|
if (crystal.major_um < crystal.minor_um) {
|
|
std::swap(crystal.major_um, crystal.minor_um);
|
|
crystal.angle_deg += 90.0f;
|
|
}
|
|
|
|
// atan2 returns (-pi,pi], so the half-angle is in (-pi/2,pi/2] and the quarter turn above
|
|
// can carry it past 180; an axis has no sign, so fold it into [0,180).
|
|
if (crystal.angle_deg < 0)
|
|
crystal.angle_deg += 180.0f;
|
|
if (crystal.angle_deg >= 180.0f)
|
|
crystal.angle_deg -= 180.0f;
|
|
|
|
// The MEAN protein score, not the peak: the score saturates, so the peak is 1.0 for every
|
|
// real crystal and ranks nothing. The mean stays a detection confidence and compares.
|
|
crystal.score = sum_protein / n;
|
|
crystal.ice_score = sum_ice / n;
|
|
|
|
// The 25th percentile, not the minimum: the single best cell in a blob is precisely where
|
|
// a salt spot or a hot pixel shows up.
|
|
if (!by_res.empty()) {
|
|
const auto q = static_cast<size_t>(0.25 * static_cast<double>(by_res.size() - 1) + 0.5);
|
|
crystal.res_A = res[by_res[q]];
|
|
}
|
|
crystal.n_images = static_cast<int64_t>(cell.size());
|
|
|
|
result.crystals.push_back(crystal);
|
|
}
|
|
|
|
std::sort(result.crystals.begin(), result.crystals.end(),
|
|
[](const GridScanCrystal &a, const GridScanCrystal &b) { return a.score > b.score; });
|
|
|
|
return result;
|
|
}
|