image analysis: two per-image detection scores say whether there is protein and whether there is ice

proteinScore and iceScore are saturating scores in [0,1] that answer presence, not quality: a superb
crystal and a barely-diffracting one both read near 1, and neither the spot count nor the resolution
enters either of them as a term. iceRingScore already reports a magnitude - a ratio, unbounded - which
is not a number that can be thresholded; these are.

The protein score counts distinct d SHELLS above 5 A rather than spots, so a parasitic ring in the
low-resolution band cannot accumulate evidence, and weights each spot against the frame's own median
so a scattering of the weakest detections cannot fill a shell either. The ice score carries two
channels and takes the stronger: a radial one over the azimuthal profile, which runs the hexagonal
and the CUBIC phase as separate hypotheses and decides between them at the end (flash-cooled loops
show cubic or stacking-disordered ice at least as often as hexagonal, the two share only three
lines, and dropping the cubic hypothesis costs about 5 pp on iced loops), and a spot one that reads
an excess on the ice radii against the same band slid to every ice-free offset, which is what
catches ice arriving as discrete crystallites and leaving the radial profile flat.

Both read d out of the geometry, so both move with a beam-centre error; the centre is not fitted
here, and the one they were computed with is written beside them as scoreBeamCenterX/Y so a later
rescoring can tell an algorithm disagreement from a geometry one.

Ported from validated prototypes and checked against them frame by frame on stored data: mean
absolute difference 2.7e-5 (protein), 1.3e-8 (ice radial) and 3.4e-4 (ice spots). On a 41-loop
battery the protein score reaches 98.4% of confirmed-protein frames and 0.00% of water frames, and
finds no cluster on any water or ice raster. Cost is 0.01 ms/frame for the protein score and
0.08-0.32 ms/frame for the ice score.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
This commit is contained in:
2026-09-07 23:59:46 +02:00
co-authored by Claude Opus 5
parent 680c36c20d
commit 62ca2b24f6
33 changed files with 960 additions and 1 deletions
+205
View File
@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_all.hpp>
#include "../common/Definitions.h"
#include "../image_analysis/IceScore.h"
#include "../image_analysis/spot_finding/SpotUtils.h"
namespace {
constexpr float TWO_PI = 6.283185307f;
SpotToSave spot(float d_A, float intensity) {
return SpotToSave{.intensity = intensity, .d_A = d_A};
}
AzimuthalIntegrationSettings ice_settings() {
AzimuthalIntegrationSettings settings;
settings.QSpacing_recipA(0.006f).QRange_recipA(0.1f, 4.5f);
return settings;
}
int bin_of(const AzimuthalIntegrationSettings &settings, float d_A) {
return static_cast<int>(std::lround((TWO_PI / d_A - settings.GetLowQ_recipA())
/ settings.GetQSpacing_recipA() - 0.5f));
}
}
TEST_CASE("ProteinScore_EmptyAndHighResolutionOnly") {
CHECK(ProteinScore({}) == 0.0f);
// Nothing beyond 5 A: a salt or ice powder pattern, however strong, is not protein.
std::vector<SpotToSave> spots;
for (int i = 0; i < 200; i++)
spots.push_back(spot(1.0f + 0.015f * static_cast<float>(i), 5000.0f));
CHECK(ProteinScore(spots) == 0.0f);
}
TEST_CASE("ProteinScore_SaturatesAndCountsShellsNotSpots") {
// Six well-separated shells above 5 A, one strong spot each: enough evidence to be sure.
std::vector<SpotToSave> shells;
for (const float d: {5.5f, 6.5f, 8.0f, 10.0f, 14.0f, 20.0f})
shells.push_back(spot(d, 1000.0f));
const float six = ProteinScore(shells);
CHECK(six > 0.5f);
// Ten times as many spots, all in the SAME shell: a parasitic ring, not a lattice. One shell is
// worth at most 1, so it must score far below the six shells above.
std::vector<SpotToSave> one_ring;
for (int i = 0; i < 60; i++)
one_ring.push_back(spot(5.5f, 1000.0f));
CHECK(ProteinScore(one_ring) < 0.3f);
CHECK(ProteinScore(one_ring) < six);
// Saturation: making every spot a hundred times stronger does not raise the score, because the
// weight is measured against the frame's own median spot.
std::vector<SpotToSave> strong;
for (const float d: {5.5f, 6.5f, 8.0f, 10.0f, 14.0f, 20.0f})
strong.push_back(spot(d, 100000.0f));
CHECK(ProteinScore(strong) == Catch::Approx(six));
// And it stays inside [0, 1] however much evidence there is.
std::vector<SpotToSave> many;
for (int i = 0; i < 400; i++)
many.push_back(spot(5.1f + 0.08f * static_cast<float>(i % 300), 1000.0f));
CHECK(ProteinScore(many) > 0.9f);
CHECK(ProteinScore(many) < 1.0f);
}
TEST_CASE("IceScoreRadial_FlatProfileIsNotIce") {
const auto settings = ice_settings();
const int q_bins = settings.GetQBinCount();
const std::vector<float> flat(q_bins, 100.0f);
// Per-pixel standard deviation and pixel count: the score uses std / sqrt(count) = 2 photons.
const std::vector<float> sigma(q_bins, 20.0f);
const std::vector<uint64_t> count(q_bins, 100);
CHECK(IceScoreRadial(flat, sigma, count, q_bins, settings) == 0.0f);
// No standard deviation, no radial channel: the FPGA azimuthal integration does not produce one.
CHECK(IceScoreRadial(flat, {}, count, q_bins, settings) == 0.0f);
}
TEST_CASE("IceScoreRadial_HexagonalAndCubicPatterns") {
const auto settings = ice_settings();
const int q_bins = settings.GetQBinCount();
const std::vector<float> sigma(q_bins, 20.0f);
const std::vector<uint64_t> count(q_bins, 100);
// A single strong band is not ice - real ice shows a whole pattern, and the band-count
// concordance test is what refuses one bin.
std::vector<float> one_band(q_bins, 100.0f);
one_band[bin_of(settings, ICE_RING_RES_A[0])] = 400.0f;
CHECK(IceScoreRadial(one_band, sigma, count, q_bins, settings) < 0.5f);
// The whole hexagonal pattern is.
std::vector<float> hexagonal(q_bins, 100.0f);
for (const float d: ICE_RING_RES_A) {
const int b = bin_of(settings, d);
if (b >= 0 && b < q_bins)
hexagonal[b] = 130.0f;
}
CHECK(IceScoreRadial(hexagonal, sigma, count, q_bins, settings) > 0.5f);
// So is the cubic one, which shares only three lines with it - the phase that a hexagonal-only
// detector misses entirely.
std::vector<float> cubic(q_bins, 100.0f);
for (const float d: ICE_RING_CUBIC_RES_A) {
const int b = bin_of(settings, d);
if (b >= 0 && b < q_bins)
cubic[b] = 130.0f;
}
CHECK(IceScoreRadial(cubic, sigma, count, q_bins, settings) > 0.5f);
// The same excess spread over bins belonging to no phase is not ice.
std::vector<float> off_band(q_bins, 100.0f);
for (int i = 100; i < q_bins - 100; i += 37)
off_band[i] = 130.0f;
CHECK(IceScoreRadial(off_band, sigma, count, q_bins, settings) < 0.5f);
}
TEST_CASE("IceScoreRadial_AzimuthalProfileFoldsToTheSameAnswer") {
AzimuthalIntegrationSettings settings;
settings.QSpacing_recipA(0.006f).QRange_recipA(0.1f, 4.5f).AzimuthalBinCount(4);
const int q_bins = settings.GetQBinCount();
std::vector<float> flat(q_bins, 100.0f);
std::vector<float> sigma(q_bins, 20.0f);
std::vector<uint64_t> count(q_bins, 400);
for (const float d: ICE_RING_RES_A) {
const int b = bin_of(settings, d);
if (b >= 0 && b < q_bins)
flat[b] = 130.0f;
}
std::vector<float> sectors(static_cast<size_t>(q_bins) * 4);
std::vector<float> sectors_sigma(static_cast<size_t>(q_bins) * 4);
std::vector<uint64_t> sectors_count(static_cast<size_t>(q_bins) * 4);
for (int az = 0; az < 4; az++)
for (int q = 0; q < q_bins; q++) {
sectors[static_cast<size_t>(az) * q_bins + q] = flat[q];
sectors_sigma[static_cast<size_t>(az) * q_bins + q] = sigma[q];
sectors_count[static_cast<size_t>(az) * q_bins + q] = count[q] / 4;
}
CHECK(IceScoreRadial(sectors, sectors_sigma, sectors_count, q_bins, settings)
== Catch::Approx(IceScoreRadial(flat, sigma, count, q_bins, settings)));
}
TEST_CASE("IceScoreSpots_ExcessOnTheIceRadii") {
const auto settings = ice_settings();
const int q_bins = settings.GetQBinCount();
// A detector that covers every radius equally, so the control offsets are directly comparable.
const std::vector<uint64_t> count(q_bins, 10000);
// 400 spots spread evenly in q: whatever lands on an ice radius is what the control predicts.
std::vector<SpotToSave> even;
const float q_lo = 1.3f, q_hi = 4.2f;
for (int i = 0; i < 400; i++) {
const float q = q_lo + (q_hi - q_lo) * static_cast<float>(i) / 399.0f;
even.push_back(spot(TWO_PI / q, 1000.0f));
}
CHECK(IceScoreSpots(even, count, q_bins, settings) < 0.5f);
// The same frame with 10 extra spots planted on each hexagonal radius.
std::vector<SpotToSave> with_ice = even;
for (const float d: ICE_RING_RES_A)
for (int i = 0; i < 10; i++)
with_ice.push_back(spot(d, 1000.0f));
CHECK(IceScoreSpots(with_ice, count, q_bins, settings) > 0.5f);
// Two spots that both happen to sit on a ring are not ice: the ratio term refuses them even
// though the Poisson tail alone would not.
std::vector<SpotToSave> two;
two.push_back(spot(ICE_RING_RES_A[0], 1000.0f));
two.push_back(spot(ICE_RING_RES_A[1], 1000.0f));
CHECK(IceScoreSpots(two, count, q_bins, settings) < 0.5f);
CHECK(IceScoreSpots({}, count, q_bins, settings) == 0.0f);
}
TEST_CASE("IceScore_TakesTheStrongerChannel") {
const auto settings = ice_settings();
const int q_bins = settings.GetQBinCount();
const std::vector<uint64_t> count(q_bins, 10000);
const std::vector<float> sigma(q_bins, 200.0f);
// Powder ice, no spots at all: the radial channel carries it on its own.
std::vector<float> powder(q_bins, 100.0f);
for (const float d: ICE_RING_RES_A) {
const int b = bin_of(settings, d);
if (b >= 0 && b < q_bins)
powder[b] = 130.0f;
}
CHECK(IceScore(powder, sigma, count, q_bins, settings, {}) > 0.5f);
// Ice as discrete crystallites: the profile is flat and only the spot channel sees it.
const std::vector<float> flat(q_bins, 100.0f);
std::vector<SpotToSave> textured;
for (int i = 0; i < 400; i++)
textured.push_back(spot(TWO_PI / (1.3f + 2.9f * static_cast<float>(i) / 399.0f), 1000.0f));
for (const float d: ICE_RING_RES_A)
for (int i = 0; i < 10; i++)
textured.push_back(spot(d, 1000.0f));
CHECK(IceScoreRadial(flat, sigma, count, q_bins, settings) == 0.0f);
CHECK(IceScore(flat, sigma, count, q_bins, settings, textured) > 0.5f);
}