Written on every rotation run, next to the reflections, with no flag to switch them on: <prefix>_detector.jpg - the mean projection the beam-stop pre-scan built, which is the very image the shadow detection tested, coloured with the viewer's own default map from common/ColorScale.h: white-to-indigo for the counts, grey for the module and chip gaps, coral for the detected shadow, magenta for the rest of the pixel mask. A shadow claimed where the background is unbroken, or a dark patch left uncoloured, is then visible at a glance instead of being a pixel count in the log. The projection is the one the pre-scan already holds, so no frame is read twice; the picture is rendered there and held as JPEG bytes until the canonical pass writes it, because on a two-pass run the pre-scan belongs to the superseded pass. <prefix>_plot.txt - one row per processed image, whitespace-separated with a single '#' legend line over its own columns, so gnuplot plots it as it stands: image, spindle angle, background, resolution, spot count, scale, sigma_M, CC to merge, and whether the image's observations reached the merged data. A quantity nothing measured is written nan, so the columns never shift and no row is ever left out. sigma_M is emitted as the pipeline produces it - a per-image Gaussian rocking width, smoothed in frame order and filled with the run median where a frame could not be fitted - and the documentation says so rather than presenting it as a crystal property. <prefix>_image.dat stays: the in-tree comparison scripts read it and it carries the smoothing wedge, which the new file does not. The docs name _plot.txt as the one to script against. Measured on a 1800-image rotation sweep: 29.6/30.4 s before against 29.8/30.5 s after, and the merged reflections are byte-identical. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
136 lines
5.4 KiB
C++
136 lines
5.4 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 <cmath>
|
|
#include <fstream>
|
|
#include <sstream>
|
|
|
|
#include "../rugnux/DiagnosticOutput.h"
|
|
#include "../image_analysis/scale_merge/Merge.h"
|
|
|
|
namespace {
|
|
bool same(const rgb &a, const rgb &b) {
|
|
return a.r == b.r && a.g == b.g && a.b == b.b;
|
|
}
|
|
|
|
// The columns of one row of <prefix>_plot.txt, whitespace-separated.
|
|
std::vector<std::string> Columns(const std::string &line) {
|
|
std::istringstream is(line);
|
|
std::vector<std::string> ret;
|
|
for (std::string token; is >> token;)
|
|
ret.push_back(token);
|
|
return ret;
|
|
}
|
|
|
|
std::vector<std::string> ReadLines(const std::string &path) {
|
|
std::ifstream f(path);
|
|
std::vector<std::string> ret;
|
|
for (std::string line; std::getline(f, line);)
|
|
ret.push_back(line);
|
|
return ret;
|
|
}
|
|
}
|
|
|
|
TEST_CASE("DiagnosticImage_Colors") {
|
|
ColorScale scale;
|
|
|
|
// One pixel of each kind, in the order the renderer tests them.
|
|
std::vector<uint32_t> mask_bits{
|
|
0, // plain, no counts
|
|
0, // plain, the brightest pixel
|
|
1u << PixelMask::ModuleGapPixelBit,
|
|
1u << PixelMask::BeamStopPixelBit,
|
|
1u << PixelMask::UserMaskedPixelBit,
|
|
(1u << PixelMask::ModuleGapPixelBit) | (1u << PixelMask::BeamStopPixelBit),
|
|
(1u << PixelMask::BeamStopPixelBit) | (1u << PixelMask::NoisyPixelBit)};
|
|
const PixelMask mask(mask_bits);
|
|
const std::vector<float> projection{0.0f, 1000.0f, NAN, NAN, NAN, NAN, NAN};
|
|
|
|
const auto image = DetectorDiagnosticRGB(mask, projection);
|
|
REQUIRE(image.size() == mask_bits.size());
|
|
|
|
CHECK(same(image[0], scale.Apply(0.0f))); // white: no counts
|
|
CHECK(same(image[1], scale.Apply(1.0f))); // indigo: the saturation value
|
|
CHECK(same(image[2], scale.Apply(ColorScaleSpecial::Gap))); // grey
|
|
CHECK(same(image[3], scale.Apply(ColorScaleSpecial::BeamStop))); // coral
|
|
CHECK(same(image[4], scale.Apply(ColorScaleSpecial::BadPixel))); // magenta
|
|
// A gap cannot be shadowed - it carries no background for the detection to miss - so the gap
|
|
// wins; every other mask bit loses to the shadow, which is what the picture is there to show.
|
|
CHECK(same(image[5], scale.Apply(ColorScaleSpecial::Gap)));
|
|
CHECK(same(image[6], scale.Apply(ColorScaleSpecial::BeamStop)));
|
|
}
|
|
|
|
TEST_CASE("DiagnosticPlot_Format") {
|
|
const std::string prefix = "diagnostic_plot_format_test";
|
|
|
|
PerImagePlot plot;
|
|
plot.bkg_estimate = {1.0f, 2.0f, 3.0f, 4.0f};
|
|
plot.resolution_A = {2.5f, 2.4f, NAN, 2.6f};
|
|
plot.spot_count = {100.0f, 120.0f, 0.0f, 90.0f};
|
|
plot.scale_g = {1.0f, 1.1f, NAN, 0.9f};
|
|
plot.mosaicity_deg = {0.12f, 0.12f, NAN, 0.13f};
|
|
plot.cc_to_merge = {0.98f, 0.97f, NAN, 0.5f};
|
|
plot.frame_disposition = {static_cast<uint8_t>(FrameDisposition::Merged),
|
|
static_cast<uint8_t>(FrameDisposition::Downgraded),
|
|
static_cast<uint8_t>(FrameDisposition::Rejected),
|
|
static_cast<uint8_t>(FrameDisposition::Rejected)};
|
|
|
|
const GoniometerAxis goniometer("omega", 10.0f, 0.5f, {0, 1, 0}, {});
|
|
WritePerImagePlot(prefix, plot, goniometer, 4);
|
|
|
|
const auto lines = ReadLines(prefix + "_plot.txt");
|
|
REQUIRE(lines.size() == 5);
|
|
|
|
// Exactly one legend row, and it is a comment.
|
|
REQUIRE(lines[0][0] == '#');
|
|
for (size_t i = 1; i < lines.size(); i++)
|
|
CHECK(lines[i].find('#') == std::string::npos);
|
|
|
|
const auto legend = Columns(lines[0]);
|
|
REQUIRE(legend.size() == 9);
|
|
CHECK(legend[0] == "#image");
|
|
CHECK(legend[1] == "angle_deg");
|
|
CHECK(legend[6] == "sigma_M_deg");
|
|
CHECK(legend[8] == "merged");
|
|
|
|
for (size_t i = 1; i < lines.size(); i++) {
|
|
const auto row = Columns(lines[i]);
|
|
REQUIRE(row.size() == legend.size());
|
|
CHECK(row[0] == std::to_string(i - 1));
|
|
}
|
|
|
|
// Mid-exposure angle: start + ordinal * increment + increment / 2.
|
|
CHECK(std::stof(Columns(lines[1])[1]) == 10.25f);
|
|
CHECK(std::stof(Columns(lines[3])[1]) == 11.25f);
|
|
|
|
// Merged and downgraded frames are in the merged data; a rejected one is not.
|
|
CHECK(Columns(lines[1])[8] == "1");
|
|
CHECK(Columns(lines[2])[8] == "1");
|
|
CHECK(Columns(lines[3])[8] == "0");
|
|
CHECK(Columns(lines[4])[8] == "0");
|
|
|
|
// A frame nothing measured leaves its own columns unplottable, not the row missing.
|
|
CHECK(Columns(lines[3])[3] == "nan");
|
|
}
|
|
|
|
TEST_CASE("DiagnosticPlot_NoDisposition") {
|
|
const std::string prefix = "diagnostic_plot_nodisp_test";
|
|
|
|
// Without the sweep-quality diagnostic, a frame counts as merged when it was scaled at all.
|
|
PerImagePlot plot;
|
|
plot.scale_g = {1.0f, NAN, 0.8f};
|
|
WritePerImagePlot(prefix, plot, {}, 3);
|
|
|
|
const auto lines = ReadLines(prefix + "_plot.txt");
|
|
REQUIRE(lines.size() == 4);
|
|
CHECK(Columns(lines[1])[8] == "1");
|
|
CHECK(Columns(lines[2])[8] == "0");
|
|
CHECK(Columns(lines[3])[8] == "1");
|
|
|
|
// No goniometer: the angle column is present and unplottable, so the columns never shift.
|
|
CHECK(Columns(lines[1]).size() == 9);
|
|
CHECK(Columns(lines[1])[1] == "nan");
|
|
}
|