Files
Jungfraujoch/tests/GridScanCompositeTest.cpp
T
leonarski_fandClaude Opus 5 cc7214edb0 grid scan: a test spans the seam between the angle a raster measures and the frame drawn from it
The producer measures angle_deg from second moments in micrometres; the viewer draws
it counter-clockwise in the grid index frame. If those conventions disagree every
needle is drawn mirrored, and neither side's own tests notice - the producer checks
an angle it computed itself, the viewer checks a frame from an angle it was handed,
and the corpus needle sits at 45 degrees, which is invariant under exactly that flip.

Verified to fail: replacing the angle with 180 - angle breaks it, which is the whole
point of writing it at an asymmetric angle on an anisotropic step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-08 08:23:43 +02:00

183 lines
8.0 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "../viewer/image_viewer/GridScanComposite.h"
#include "../common/GridScanSettings.h"
#include "../common/ScanResult.h"
#include "../image_analysis/grid_scan_analysis/AnalyzeGridScan.h"
using namespace grid_scan;
TEST_CASE("GridScanComposite_Background", "[grid_scan]") {
// Air only: a flat level with counting noise on it. Without a floor on the range this would
// be stretched to fill the whole grey scale and look like a loop.
std::vector<float> air;
for (int i = 0; i < 100; i++)
air.push_back(5.0f + 0.02f * ((i % 7) - 3));
const auto flat = NormaliseBackground(air);
for (float v : flat)
CHECK(v < 0.1f);
// A loop in the middle of the same air.
std::vector<float> loop = air;
for (int i = 40; i < 60; i++)
loop[i] = 13.0f;
const auto mapped = NormaliseBackground(loop);
CHECK(mapped[0] < 0.1f);
CHECK(mapped[50] > 0.9f);
}
TEST_CASE("GridScanComposite_BackgroundKeepsGaps", "[grid_scan]") {
const std::vector<float> bkg{5.0f, std::numeric_limits<float>::quiet_NaN(), 13.0f};
const auto out = NormaliseBackground(bkg);
REQUIRE(out.size() == 3);
CHECK(std::isnan(out[1]));
CHECK(std::isfinite(out[0]));
CHECK(std::isfinite(out[2]));
}
TEST_CASE("GridScanComposite_Color", "[grid_scan]") {
// Nothing anywhere: the pale plate.
const rgb air = CompositeColor(0.0f, 0.0f, 0.0f);
CHECK(air.r == kPlate.r);
CHECK(air.g == kPlate.g);
CHECK(air.b == kPlate.b);
// Material but no diffraction: the loop grey.
const rgb loop = CompositeColor(1.0f, 0.0f, 0.0f);
CHECK(loop.r == kLoop.r);
CHECK(loop.b == kLoop.b);
// A saturated score paints its own colour whatever the background says.
const rgb protein = CompositeColor(1.0f, 1.0f, 0.0f);
CHECK(protein.r == kProtein.r);
CHECK(protein.g == kProtein.g);
CHECK(protein.b == kProtein.b);
const rgb ice = CompositeColor(0.0f, 0.0f, 1.0f);
CHECK(ice.r == kIce.r);
CHECK(ice.b == kIce.b);
// Equal parts of both sit half way between the two, not on either.
const rgb both = CompositeColor(1.0f, 0.5f, 0.5f);
CHECK(both.r == static_cast<uint8_t>(std::lround(0.5f * (kProtein.r + kIce.r))));
CHECK(both.b == static_cast<uint8_t>(std::lround(0.5f * (kProtein.b + kIce.b))));
// Protein three times ice: still mostly orange, but on the way to cyan.
const rgb mostly_protein = CompositeColor(1.0f, 0.75f, 0.25f);
CHECK(mostly_protein.b > protein.b);
CHECK(mostly_protein.b < both.b);
// A weak score is a weak tint over the base, not a full-strength colour.
const rgb faint = CompositeColor(1.0f, 0.1f, 0.0f);
CHECK(faint.r > loop.r);
CHECK(faint.b < loop.b);
CHECK(faint.b > kProtein.b);
}
TEST_CASE("GridScanComposite_FrameGeometry", "[grid_scan]") {
// Steps differ, as they do in a real raster: 20 um across, 16 um down.
constexpr float step_x = 20.0f;
constexpr float step_y = 16.0f;
// Along the grid x axis: 100 um is 5 cells across, 10 um is 0.625 cells down.
const auto along_x = CrystalFrameCorners(4.0f, 3.0f, 100.0f, 10.0f, 0.0f, step_x, step_y);
CHECK_THAT(along_x[0].x, Catch::Matchers::WithinAbs(4.5 + 2.5, 1e-4));
CHECK_THAT(along_x[0].y, Catch::Matchers::WithinAbs(3.5 + 0.3125, 1e-4));
CHECK_THAT(along_x[2].x, Catch::Matchers::WithinAbs(4.5 - 2.5, 1e-4));
// The same needle at 90 degrees: the long axis is now along y, and because the y step is the
// finer one it covers more cells than it did across - 100/16 rather than 100/20.
const auto along_y = CrystalFrameCorners(4.0f, 3.0f, 100.0f, 10.0f, 90.0f, step_x, step_y);
float min_x = along_y[0].x, max_x = along_y[0].x, min_y = along_y[0].y, max_y = along_y[0].y;
for (const auto &c : along_y) {
min_x = std::min(min_x, c.x);
max_x = std::max(max_x, c.x);
min_y = std::min(min_y, c.y);
max_y = std::max(max_y, c.y);
}
CHECK_THAT(max_x - min_x, Catch::Matchers::WithinAbs(10.0 / step_x, 1e-3));
CHECK_THAT(max_y - min_y, Catch::Matchers::WithinAbs(100.0 / step_y, 1e-3));
// At 45 degrees the frame is still a 100 x 10 needle - its axis-aligned bounding box is the
// ~78 x 78 blob the oriented frame exists to avoid drawing.
const auto tilted = CrystalFrameCorners(4.0f, 3.0f, 100.0f, 10.0f, 45.0f, step_x, step_x);
const float long_side = std::hypot(tilted[0].x - tilted[1].x, tilted[0].y - tilted[1].y);
const float short_side = std::hypot(tilted[1].x - tilted[2].x, tilted[1].y - tilted[2].y);
CHECK_THAT(long_side, Catch::Matchers::WithinAbs(100.0 / step_x, 1e-3));
CHECK_THAT(short_side, Catch::Matchers::WithinAbs(10.0 / step_x, 1e-3));
float bbox = 0;
for (const auto &c : tilted)
bbox = std::max(bbox, std::abs(c.x - 4.5f));
CHECK(bbox > 1.9f); // the bounding box is far wider than the needle itself
}
TEST_CASE("GridScanComposite_RoundBlob", "[grid_scan]") {
CHECK(IsRoundBlob(30.0f, 30.0f));
CHECK(IsRoundBlob(33.0f, 30.0f));
CHECK(!IsRoundBlob(100.0f, 10.0f));
CHECK(!IsRoundBlob(40.0f, 30.0f));
}
namespace {
// A plain raster: positive steps, no snake, so image number == grid index.
GridScanSettings MakeGrid(int64_t nx, int64_t ny, float step_x_um, float step_y_um) {
GridScanSettings grid(nx, step_x_um, step_y_um, false, false);
grid.ImageNum(nx * ny);
return grid;
}
ScanResult MakeScan(int64_t nx, int64_t ny,
const std::vector<std::pair<int64_t, int64_t>> &hits, float score) {
ScanResult scan;
for (int64_t i = 0; i < nx * ny; i++) {
ScanResultElem elem;
elem.number = i;
scan.images.push_back(elem);
}
for (const auto &[x, y]: hits)
scan.images[y * nx + x].protein_score = score;
return scan;
}
}
// ---------------------------------------------------------------------------
// The producer measures angle_deg from second moments in micrometres; the viewer
// draws it counter-clockwise in the grid index frame. If those two conventions
// disagree every needle is drawn mirrored, and NEITHER side's own tests notice: the
// producer checks an angle it computed itself, the viewer checks a frame from an
// angle it was handed, and the corpus needle sits at 45 degrees, which is invariant
// under exactly that flip. This is the test that spans the seam, so it uses a
// deliberately asymmetric angle on an anisotropic step.
TEST_CASE("GridScanComposite_FrameFollowsTheNeedleAcrossTheSeam", "[grid_scan]") {
// A staircase running lower-left to upper-right: nx and ny increase together.
const std::vector<std::pair<int64_t, int64_t>> needle{{1, 1}, {2, 2}, {3, 2}, {4, 3}, {5, 4}};
auto grid = MakeGrid(8, 8, 20.0f, 16.0f);
const auto result = AnalyzeGridScan(MakeScan(8, 8, needle, 0.9f), grid, 0.0f, 0.0f);
REQUIRE(result.crystals.size() == 1);
const auto &c = result.crystals.front();
REQUIRE(c.major_um > c.minor_um); // it is a needle, not a blob
const auto corners = CrystalFrameCorners(c.nx, c.ny, c.major_um, c.minor_um, c.angle_deg,
grid.GetGridStepX_um(), grid.GetGridStepY_um());
// The two corners furthest apart span the frame's long axis. In cell coordinates that axis
// must rise as it runs right, exactly like the cells it was measured from - a mirrored
// convention would give it the opposite slope while leaving its length untouched.
int a = 0, b = 0;
float longest = -1.0f;
for (int i = 0; i < 4; i++)
for (int j = i + 1; j < 4; j++) {
const float d = std::hypot(corners[i].x - corners[j].x, corners[i].y - corners[j].y);
if (d > longest) { longest = d; a = i; b = j; }
}
const float run = corners[b].x - corners[a].x;
const float rise = corners[b].y - corners[a].y;
CHECK(run * rise > 0.0f);
}