Rugnux: two diagnostic outputs a person can check the run by eye with
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
This commit is contained in:
1 parent
6dd9824eec
commit
4e4de57aae
9 files changed
+410
-8
No files matched your search
@@ -286,7 +286,7 @@ The vocabulary is **closed and stable**: a code is never renamed, and never reus
|
||||
meaning. New codes are only ever added, and adding one moves `REPORT_VERSION` at the next release.
|
||||
|
||||
The columns are: `FIRST_IMAGE`/`LAST_IMAGE` — inclusive, in processed-image ordinals (the numbering
|
||||
of `<prefix>_image.dat` and of every other per-image array `rugnux` writes; with `-s`/`--stride` the
|
||||
of `<prefix>_plot.txt` and of every other per-image array `rugnux` writes; with `-s`/`--stride` the
|
||||
source image is `start + ordinal * stride`); `ROTATION` — the width of the range in degrees;
|
||||
`SEVERITY` — the fraction of the run's typical diffracting power missing over the range, 0 (as good
|
||||
as the run) to 1 (nothing at all); `SCALE` and `CC` — the range's mean per-image scale and
|
||||
|
||||
+44
-5
@@ -116,7 +116,7 @@ or of the run's own measured centre, whichever is nearer), and the second pass
|
||||
re-indexes de novo and re-integrates at the refined geometry. The refined pass is the canonical
|
||||
`<prefix>_*` output; the header-geometry pass merges only to choose the space group and to judge the
|
||||
refined pass against, and writes no merged files of its own — no `<prefix>_01.mtz`, `.cif`, `.hkl` or
|
||||
`_01_image.dat`. (Where a process file is asked for at all, with `--no-merge` or
|
||||
`_01_image.dat`, and no `_01_plot.txt` or `_01_detector.jpg`. (Where a process file is asked for at all, with `--no-merge` or
|
||||
`--write-process-h5`, each pass still writes its own, so `<prefix>_01_process.h5` appears beside
|
||||
`<prefix>_process.h5`.) Disable it with `--rotation-no-postrefine`.
|
||||
|
||||
@@ -244,10 +244,49 @@ goniometer axis but you want per-frame stills processing anyway, add `--force-st
|
||||
- `<prefix>_report.txt` — the **results report**: what the run determined, in a form both a person and
|
||||
a beamline script can read. Always written, next to the files above. See
|
||||
[The results report](RUGNUX_REPORT.md#the-results-report).
|
||||
- `<prefix>_image.dat` — one row per processed image, whitespace-separated, with a `#` header line
|
||||
naming the columns: `image_number scale_G mosaicity_deg wedge_deg cc_to_merge cc_n` — the
|
||||
per-image scale, mosaicity, smoothing wedge and correlation-to-merge behind the sweep-quality
|
||||
table, ready for gnuplot or numpy.
|
||||
- `<prefix>_plot.txt` — **the per-image table**, one row per processed image, whitespace-separated,
|
||||
with a single `#` legend line over its own columns so gnuplot plots it as it stands
|
||||
(`plot '<prefix>_plot.txt' using 1:4`). Rotation runs only. The columns are:
|
||||
|
||||
| Column | Meaning |
|
||||
|--------|---------|
|
||||
| `image` | Processed-image ordinal, the numbering every per-image array `rugnux` writes uses; with `-s`/`--stride` the source image is `start + ordinal * stride`. |
|
||||
| `angle_deg` | Spindle angle at mid-exposure. |
|
||||
| `bkg` | Background estimate from the azimuthal profile (the `bkgEstimate` of the HDF5 file). |
|
||||
| `resolution_A` | How far the image's spots reach, from spot finding alone. |
|
||||
| `spots` | Spots found. |
|
||||
| `scale_G` | Per-image scale factor fitted by scaling. |
|
||||
| `sigma_M_deg` | Mosaicity as the rocking-curve **standard deviation** σ<sub>M</sub> in degrees — not a FWHM, and not XDS's `REFLECTING_RANGE`. See the note below. |
|
||||
| `cc_to_merge` | Correlation of the image's own observations with the merged data. |
|
||||
| `merged` | 1 where the image's observations are in the merged data, 0 where they are not — an image that never indexed, one an earlier guard dropped, and one ΔCC<sub>1/2</sub> convicted all read 0. |
|
||||
|
||||
A quantity nothing measured for an image is written `nan`, which gnuplot and numpy both skip; the
|
||||
columns never shift and a row is never left out.
|
||||
|
||||
**Reading `sigma_M_deg`.** It is fitted per image — a maximum-likelihood Gaussian rocking width
|
||||
over that image's own indexed spots — but it is then **smoothed in frame order** over the same
|
||||
rotation window as the per-image scale (`--smooth-g`), and any frame too sparse to fit one of its
|
||||
own is given the run's median. So the column varies only on the smoothing window's scale, and on a
|
||||
weakly diffracting crystal it is close to, or exactly, a constant: that flatness is the honest
|
||||
statement that the run measured no per-frame variation, not a placeholder. The value is the one
|
||||
scaling used to recompute partialities. σ<sub>M</sub> here is the width of a Gaussian
|
||||
rocking curve and nothing more — it is not a physical crystal mosaic spread, it absorbs beam
|
||||
divergence and every other source of angular spread, and it reads systematically **low** against
|
||||
XDS's `SIGMAR` (about 0.9× on the regression corpus). Use it to see whether the run's rocking
|
||||
width moves across the sweep, not as a number to quote.
|
||||
- `<prefix>_detector.jpg` — **the detector as the run saw it**, so the beam stop and the module gaps
|
||||
can be checked by eye. Rotation runs only, and only where `--detect-beam-stop` was left on (it is
|
||||
by default). The picture is the mean projection over the frames the beam-stop detection sampled —
|
||||
the very image it tested — coloured with `jfjoch_viewer`'s default map: white to indigo for the
|
||||
counts, **grey** for the module and chip gaps, **coral** for the shadow the run detected, and
|
||||
**magenta** for everything else the pixel mask holds. A shadow drawn where the background is
|
||||
unbroken, or a dark patch the run left uncoloured, is visible at a glance; read it against the
|
||||
`Beam stop shadow: N pixels ...` line in the log.
|
||||
- `<prefix>_image.dat` — the per-image **scaling** table: `image_number scale_G mosaicity_deg
|
||||
wedge_deg cc_to_merge cc_n`, whitespace-separated with a `#` header line. It predates `_plot.txt`
|
||||
and is kept because the in-tree comparison scripts read it and because it carries the per-image
|
||||
mosaicity and smoothing wedge, which `_plot.txt` does not. **For anything new, script against
|
||||
`_plot.txt`.**
|
||||
|
||||
Merged statistics (⟨I/σ⟩, CC1/2, completeness, …), the error model and timing are printed to the
|
||||
console. By default the written resolution is trimmed automatically where CC1/2 falls off
|
||||
|
||||
@@ -23,13 +23,15 @@ ADD_LIBRARY(Rugnux STATIC
|
||||
ReportDocument.h
|
||||
SpotWidth.cpp
|
||||
SpotWidth.h
|
||||
DiagnosticOutput.cpp
|
||||
DiagnosticOutput.h
|
||||
SpindleCuspLoss.cpp
|
||||
SpindleCuspLoss.h
|
||||
WriteModel.cpp
|
||||
WriteModel.h
|
||||
)
|
||||
|
||||
TARGET_LINK_LIBRARIES(Rugnux JFJochReader JFJochImageAnalysis JFJochWriter gemmi fftw3f)
|
||||
TARGET_LINK_LIBRARIES(Rugnux JFJochReader JFJochImageAnalysis JFJochWriter JFJochPreview gemmi fftw3f)
|
||||
|
||||
# rugnux is the single offline analysis CLI; --mode picks what it does: mx (the full pipeline, the
|
||||
# default), azint, scale (re-scale/merge stored reflections) or calibration (powder-ring geometry).
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include "DiagnosticOutput.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <fstream>
|
||||
|
||||
#include <spdlog/fmt/fmt.h>
|
||||
|
||||
#include "../common/ColorScale.h"
|
||||
#include "../common/CompressedImage.h"
|
||||
#include "../common/JFJochException.h"
|
||||
#include "../image_analysis/scale_merge/Merge.h"
|
||||
#include "../preview/JFJochJPEG.h"
|
||||
|
||||
namespace {
|
||||
// The brightest 0.1 % of the measured pixels saturate, as the preview and the viewer set their
|
||||
// contrast (preview/PreviewImage.cpp): a handful of hot pixels would otherwise take the whole
|
||||
// range and leave the background - which is what the shadow is read off - a flat white.
|
||||
float SaturationValue(const std::vector<float> &projection, const std::vector<uint32_t> &mask) {
|
||||
std::vector<float> valid;
|
||||
valid.reserve(projection.size());
|
||||
for (size_t i = 0; i < projection.size(); i++)
|
||||
if (mask[i] == 0 && std::isfinite(projection[i]))
|
||||
valid.push_back(projection[i]);
|
||||
if (valid.empty())
|
||||
return 1.0f;
|
||||
const size_t rank = valid.size() - std::max<size_t>(1, valid.size() / 1000);
|
||||
std::nth_element(valid.begin(), valid.begin() + rank, valid.end());
|
||||
return std::max(valid[rank], 1.0f);
|
||||
}
|
||||
|
||||
float Element(const std::vector<float> &v, size_t i) {
|
||||
return i < v.size() ? v[i] : NAN;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<rgb> DetectorDiagnosticRGB(const PixelMask &pixel_mask,
|
||||
const std::vector<float> &mean_projection) {
|
||||
constexpr uint32_t gap_bits = (1u << PixelMask::ModuleGapPixelBit)
|
||||
| (1u << PixelMask::ChipGapPixelBit)
|
||||
| (1u << PixelMask::ModuleEdgePixelBit);
|
||||
constexpr uint32_t beam_stop_bit = 1u << PixelMask::BeamStopPixelBit;
|
||||
|
||||
const auto &mask = pixel_mask.GetMask();
|
||||
if (mask.size() != mean_projection.size())
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"Mask and projection are of different size");
|
||||
|
||||
// Untouched: the viewer's defaults are the point, so that the JPEG and the picture the user
|
||||
// knows from jfjoch_viewer say the same thing with the same colours.
|
||||
ColorScale scale;
|
||||
const rgb gap_color = scale.Apply(ColorScaleSpecial::Gap);
|
||||
const rgb bad_color = scale.Apply(ColorScaleSpecial::BadPixel);
|
||||
const rgb beam_stop_color = scale.Apply(ColorScaleSpecial::BeamStop);
|
||||
|
||||
const auto &lut = scale.LUTData();
|
||||
const auto lut_size = static_cast<int64_t>(lut.size());
|
||||
const float inv_range = static_cast<float>(lut_size - 1) / SaturationValue(mean_projection, mask);
|
||||
|
||||
// The shadow is tested BEFORE the rest of the mask, so a pixel the detection claims is never
|
||||
// hidden under a noisy- or user-masked bit it also carries - the whole point is to see what
|
||||
// the detection did. Gaps come first because nothing can be shadowed there: they carry no
|
||||
// background for the comparison to miss.
|
||||
std::vector<rgb> image(mean_projection.size());
|
||||
for (size_t i = 0; i < image.size(); i++) {
|
||||
if ((mask[i] & gap_bits) != 0)
|
||||
image[i] = gap_color;
|
||||
else if ((mask[i] & beam_stop_bit) != 0)
|
||||
image[i] = beam_stop_color;
|
||||
else if (mask[i] != 0 || !std::isfinite(mean_projection[i]))
|
||||
image[i] = bad_color;
|
||||
else {
|
||||
const auto idx = static_cast<int64_t>(mean_projection[i] * inv_range + 0.5f);
|
||||
image[i] = lut[std::clamp<int64_t>(idx, 0, lut_size - 1)];
|
||||
}
|
||||
}
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
std::string RenderDetectorDiagnostic(const DiffractionExperiment &experiment,
|
||||
const PixelMask &pixel_mask,
|
||||
const std::vector<float> &mean_projection) {
|
||||
// Named, because CompressedImage does not own the pixels it is handed.
|
||||
const std::vector<rgb> image = DetectorDiagnosticRGB(pixel_mask, mean_projection);
|
||||
const CompressedImage rgb_image(image, experiment.GetXPixelsNumConv(),
|
||||
experiment.GetYPixelsNumConv());
|
||||
return WriteJPEGToMem(rgb_image, 85);
|
||||
}
|
||||
|
||||
void WritePerImagePlot(const std::string &output_prefix, const PerImagePlot &plot,
|
||||
const std::optional<GoniometerAxis> &goniometer, size_t image_count) {
|
||||
const std::string path = output_prefix + "_plot.txt";
|
||||
std::ofstream file(path, std::ofstream::out | std::ofstream::trunc);
|
||||
if (!file)
|
||||
throw JFJochException(JFJochExceptionCategory::FileWriteError,
|
||||
"Cannot open " + path + " for writing");
|
||||
|
||||
// One comment line, its names over their own columns, and nothing else a plotting program has
|
||||
// to be told to skip. The '#' takes the first character of the image-number field.
|
||||
file << "#" << fmt::format("{:>5} {:>10} {:>12} {:>12} {:>8} {:>12} {:>13} {:>12} {:>7}\n",
|
||||
"image", "angle_deg", "bkg", "resolution_A", "spots", "scale_G",
|
||||
"sigma_M_deg", "cc_to_merge", "merged");
|
||||
|
||||
for (size_t i = 0; i < image_count; i++) {
|
||||
const float angle = goniometer
|
||||
? goniometer->GetAngle_deg(static_cast<float>(i))
|
||||
+ goniometer->GetWedge_deg() / 2.0f
|
||||
: NAN;
|
||||
const float scale_g = Element(plot.scale_g, i);
|
||||
// Did this image's observations reach the merged data? The sweep-quality diagnostic says so
|
||||
// outright; where it did not run, an image with no fitted scale contributed nothing, which
|
||||
// is the same statement by a weaker route.
|
||||
const int merged = i < plot.frame_disposition.size()
|
||||
? plot.frame_disposition[i]
|
||||
!= static_cast<uint8_t>(FrameDisposition::Rejected)
|
||||
: std::isfinite(scale_g);
|
||||
|
||||
file << fmt::format("{:>6} {:>10.4f} {:>12.4g} {:>12.4g} {:>8.0f} {:>12.5g} {:>13.4f}"
|
||||
" {:>12.4f} {:>7}\n",
|
||||
i, angle, Element(plot.bkg_estimate, i), Element(plot.resolution_A, i),
|
||||
Element(plot.spot_count, i), scale_g, Element(plot.mosaicity_deg, i),
|
||||
Element(plot.cc_to_merge, i), merged);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../common/ColorScale.h"
|
||||
#include "../common/DiffractionExperiment.h"
|
||||
#include "../common/PixelMask.h"
|
||||
|
||||
// Two files a person reads to check by eye what the run decided, written next to the reflections on
|
||||
// every rotation run: the detector as the pipeline saw it, and one row per image.
|
||||
|
||||
// The mean projection the beam-stop pre-scan built, coloured with the viewer's own default map
|
||||
// (common/ColorScale.h): white-to-indigo for the counts, grey for the module and chip gaps, coral
|
||||
// for the detected shadow, magenta for everything else the mask holds. The shadow is a place where
|
||||
// the background is missing, so drawing the mask on top of the very projection the detection tested
|
||||
// is what makes a wrong shadow visible - a coral patch over unbroken background, or a dark patch
|
||||
// left uncoloured.
|
||||
std::vector<rgb> DetectorDiagnosticRGB(const PixelMask &pixel_mask,
|
||||
const std::vector<float> &mean_projection);
|
||||
|
||||
// The same picture as JPEG bytes, at the detector's own pixel count.
|
||||
std::string RenderDetectorDiagnostic(const DiffractionExperiment &experiment,
|
||||
const PixelMask &pixel_mask,
|
||||
const std::vector<float> &mean_projection);
|
||||
|
||||
// The columns of <prefix>_plot.txt. Every vector is indexed by the processed-image ordinal - the
|
||||
// numbering of <prefix>_image.dat and of every other per-image array rugnux writes - and a value
|
||||
// nothing measured is NAN. frame_disposition holds FrameDisposition (Merge.h) and is empty when the
|
||||
// sweep-quality diagnostic did not run.
|
||||
struct PerImagePlot {
|
||||
std::vector<float> bkg_estimate;
|
||||
std::vector<float> resolution_A;
|
||||
std::vector<float> spot_count;
|
||||
std::vector<float> scale_g;
|
||||
std::vector<float> mosaicity_deg;
|
||||
std::vector<float> cc_to_merge;
|
||||
std::vector<uint8_t> frame_disposition;
|
||||
};
|
||||
|
||||
// Writes <output_prefix>_plot.txt: one '#' legend line naming the columns, then one whitespace-
|
||||
// separated row per image, so `plot '<f>' using 1:4` works with nothing in between.
|
||||
void WritePerImagePlot(const std::string &output_prefix, const PerImagePlot &plot,
|
||||
const std::optional<GoniometerAxis> &goniometer, size_t image_count);
|
||||
+44
-1
@@ -7,6 +7,7 @@
|
||||
#include "WriteModel.h"
|
||||
#include "SpindleCuspLoss.h"
|
||||
#include "SpotWidth.h"
|
||||
#include "DiagnosticOutput.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -14,6 +15,7 @@
|
||||
#include <cmath>
|
||||
#include <condition_variable>
|
||||
#include <deque>
|
||||
#include <fstream>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <mutex>
|
||||
@@ -1183,8 +1185,9 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
|
||||
// frame of its own, and an unmasked shadow biases it by a few pixels at most, which is
|
||||
// three orders below what this comparison would notice. The centre the run reports and
|
||||
// consumes is the one fitted below instead, with the shadow out of the way.
|
||||
const std::vector<float> projection = finder.GetMeanProjection();
|
||||
if (const auto ring_center = FindBeamCenterFromBackground(experiment_, pixel_mask_,
|
||||
finder.GetMeanProjection())) {
|
||||
projection)) {
|
||||
finder.BeamCenter(ring_center->beam_x_pxl, ring_center->beam_y_pxl);
|
||||
logger.Info("Beam stop: comparing each pixel against its ring about the measured "
|
||||
"centre ({:.2f},{:.2f}) +- {:.2f} px, {:.2f} px from the file's",
|
||||
@@ -1211,6 +1214,12 @@ void Rugnux::PreScan(int start_image, int images_to_process, int frame_count, Ru
|
||||
logger.Info("Beam stop shadow: {} pixels ({:.2f}% of the detector) found in {} images",
|
||||
shadowed, 100.0 * static_cast<double>(shadowed) / static_cast<double>(shadow.size()),
|
||||
finder.GetFrameCount());
|
||||
|
||||
// The diagnostic picture, rendered here because this is where the projection and the mask
|
||||
// the detection has just written into it are both in hand; the file is written later, under
|
||||
// the canonical prefix, since on a two-pass run this is the pre-pass.
|
||||
if (config_.mode == ProcessMode::FullAnalysis && experiment_.IsRotationIndexing())
|
||||
detector_diagnostic_jpeg_ = RenderDetectorDiagnostic(experiment_, pixel_mask_, projection);
|
||||
}
|
||||
|
||||
// The beam centre is fitted again here, so that the shadow is in the mask by the time it is
|
||||
@@ -7488,6 +7497,40 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
|
||||
/*trusted_path=*/true);
|
||||
}
|
||||
|
||||
// The two files a person checks the run by eye with. Rotation only - a stills run has no spindle
|
||||
// angle and no sweep to project - and not on the geometry pre-pass or a pass that is thrown
|
||||
// away, which would otherwise leave a stale _01 copy beside the real one.
|
||||
if (full && write_files && !cancelled_ && !geometry_prepass && !superseded
|
||||
&& experiment_.IsRotationIndexing()) {
|
||||
if (!detector_diagnostic_jpeg_.empty()) {
|
||||
const std::string path = config_.output_prefix + "_detector.jpg";
|
||||
std::ofstream f(path, std::ofstream::out | std::ofstream::trunc | std::ofstream::binary);
|
||||
if (f) {
|
||||
f.write(detector_diagnostic_jpeg_.data(),
|
||||
static_cast<std::streamsize>(detector_diagnostic_jpeg_.size()));
|
||||
logger.Info("Detector diagnostic image written to {}", path);
|
||||
} else
|
||||
logger.Warning("Cannot open {} for writing", path);
|
||||
}
|
||||
|
||||
PerImagePlot plot;
|
||||
plots.GetPlotRaw(plot.bkg_estimate, PlotType::BkgEstimate, "");
|
||||
plots.GetPlotRaw(plot.resolution_A, PlotType::ResolutionEstimate, "");
|
||||
plots.GetPlotRaw(plot.spot_count, PlotType::SpotCount, "");
|
||||
if (indexer) {
|
||||
// The same source as <prefix>_image.dat, so the two files cannot disagree.
|
||||
const ScalingResult scaling(indexer->GetIntegrationOutcome());
|
||||
plot.scale_g = scaling.image_scale_g;
|
||||
plot.mosaicity_deg = scaling.mosaicity_deg;
|
||||
plot.cc_to_merge = scaling.image_cc;
|
||||
}
|
||||
if (result.has_merge_statistics)
|
||||
plot.frame_disposition = result.merge_statistics.sweep_quality.frame_disposition;
|
||||
WritePerImagePlot(config_.output_prefix, plot, experiment_.GetGoniometer(),
|
||||
static_cast<size_t>(images_to_process));
|
||||
logger.Info("Per-image table written to {}_plot.txt", config_.output_prefix);
|
||||
}
|
||||
|
||||
if (writer) {
|
||||
writer->WriteHDF5(end_msg);
|
||||
writer->Finalize();
|
||||
|
||||
@@ -491,6 +491,13 @@ class Rugnux {
|
||||
// first-pass ladder: they say how DEEP a pass reads without a resolution written into the program.
|
||||
std::vector<float> prescan_spot_quantile_d_A_;
|
||||
|
||||
// <prefix>_detector.jpg, rendered in the pre-scan from the projection the beam-stop detection
|
||||
// tested and held until the canonical pass writes its files. Kept as the finished JPEG rather
|
||||
// than as the projection: the picture is a few hundred kilobytes where the projection is four
|
||||
// bytes a pixel, and the pre-scan is the only place where both the projection and the shadow it
|
||||
// has just decided on exist. Empty outside a rotation run, and where the pre-scan did not look.
|
||||
std::string detector_diagnostic_jpeg_;
|
||||
|
||||
// Set by Run()'s supercell-collapse guard to force pass-1's full result into the second-pass indexer
|
||||
// (RunPipeline calls indexer->ForceRotationIndexerResult when this is set); reset right after that re-run.
|
||||
std::optional<RotationIndexerResult> force_rotation_result_;
|
||||
|
||||
@@ -55,6 +55,7 @@ ADD_EXECUTABLE(jfjoch_test
|
||||
ParallelForTest.cpp
|
||||
RugnuxTest.cpp
|
||||
ResultReportTest.cpp
|
||||
DiagnosticOutputTest.cpp
|
||||
PostRefineTest.cpp
|
||||
RugnuxLargeTest.cpp
|
||||
TestData.h
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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");
|
||||
}
|
||||
Reference in new issue
Block a user