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
40 lines
2.1 KiB
C++
40 lines
2.1 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#pragma once
|
|
|
|
#include <cmath>
|
|
#include <cstdint>
|
|
#include <vector>
|
|
|
|
// One crystal found in a grid scan. Positions are in the display grid of GridScanSettings -
|
|
// column 0 is the lowest x, row 0 the lowest y, whatever direction the stage actually moved in -
|
|
// so they match the per-image positions the scan writes with GetXContainer_m/GetYContainer_m.
|
|
struct GridScanCrystal {
|
|
float nx = 0, ny = 0; // centre, grid coords, fractional, 0-based
|
|
float x_um = 0, y_um = 0; // centre, signed offset from centre of cell (0,0), along grid axes
|
|
int64_t image_number = -1; // nearest COLLECTED image, for the DAQ to address
|
|
// Extent along the crystal's own principal axes. major_um >= minor_um always, and
|
|
// angle_deg points along major_um - so a consumer can draw a major_um by minor_um frame
|
|
// rotated by angle_deg without checking which of the two is the longer.
|
|
float major_um = 0, minor_um = 0;
|
|
// Major axis from the +x grid axis, counter-clockwise. This is an AXIS, not a direction, so it
|
|
// lives in [0,180) and wraps there: 179 deg is adjacent to 0 deg, and code comparing two angles
|
|
// has to fold the difference into [0,90]. On a round blob the axis is arbitrary and the value is
|
|
// whatever the numerics produced - major_um/minor_um near 1 is what says so.
|
|
float angle_deg = 0;
|
|
float score = 0, ice_score = 0; // 0-1
|
|
float res_A = NAN; // robust best resolution in the blob, NaN if none was measured
|
|
int64_t n_images = 0;
|
|
};
|
|
|
|
// Crystals found in one completed grid scan, sorted by score descending: element 0 is the one to
|
|
// collect. Empty when the raster hit nothing.
|
|
struct GridScanResult {
|
|
std::vector<GridScanCrystal> crystals;
|
|
|
|
// The beam the sizes above were measured with, along the grid axes. The crystal extents still
|
|
// contain it (see AnalyzeGridScan), so this says what a consumer has to take back out.
|
|
float beam_size_x_um = 0, beam_size_y_um = 0;
|
|
};
|