Files
Jungfraujoch/rugnux/RugnuxCommandLine.cpp
T
leonarski_fandClaude Opus 4.8 9a8c946555 Add self-calibrating adaptive spot detection for offline stills
The offline CPU spot finder marks a pixel strong when it clears a fixed photon
count AND a local-window SNR. The fixed photon floor forces per-dataset tuning:
its sweet spot tracks the background level (weak sets want a low threshold,
strong or high-background sets a high one) and the usable window is narrow, so
users hand-tune --spot-threshold/--spot-sigma per dataset.

Add an opt-in --adaptive-spots mode (AdaptiveSpotFinderCPU) that replaces the
fixed floor with a per-resolution-ring threshold derived from each image's own
noise. Per ring it computes a peak-excluded background mean and sigma (one plain
pass + two sigma-clip passes over the assembled photon image, binned by the
azimuthal-integration ring index) and sets

    thr = max( PoissonTail(mean, p), mean + z * sqrt(sigma^2 + read^2) )

with p = false_pixels_per_frame / n_pixels the single portable knob (default
100) and z = Phi^-1(1 - p). The Poisson arm is the correct significance where
the background is countable (it carries the sqrt(mean) shot noise, so a bright
low-resolution ring gets a high threshold); the read-noise-floored Gaussian arm
keeps the threshold physical where the background vanishes (empty high-resolution
rings), without which those rings flood. read is a detector-level constant, not
a per-dataset knob. Both arms are needed: Poisson alone floods near-zero
background, Gaussian alone drops the shot-noise term and under-thresholds bright
rings.

One --adaptive-spots setting then adapts across a wide range of serial datasets
with no per-dataset threshold, matching or beating hand-tuned thresholds and the
peakfinder8/xgandalf reference on both weak large-cell and strong serial data,
with equal merged R-free.

The finder runs on the CPU (offline/viewer path) and reads the host image, which
the GPU pipeline already keeps in sync, so it works in either build. The default
(non-adaptive) path and the online/FPGA path are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 17:23:19 +02:00

180 lines
7.9 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "RugnuxCommandLine.h"
#include "../common/DiffractionExperiment.h"
#include <sstream>
#include <vector>
namespace {
std::string quote_if_needed(const std::string &s) {
if (s.find_first_of(" \t\"'") == std::string::npos)
return s;
std::string out = "\"";
for (char c: s) {
if (c == '"' || c == '\\')
out += '\\';
out += c;
}
out += '"';
return out;
}
const char *indexing_alg_flag(IndexingAlgorithmEnum a) {
switch (a) {
case IndexingAlgorithmEnum::FFBIDX: return "ffbidx";
case IndexingAlgorithmEnum::FFT: return "fft";
case IndexingAlgorithmEnum::FFTW: return "fftw";
case IndexingAlgorithmEnum::None: return "none";
case IndexingAlgorithmEnum::Auto:
default: return "auto";
}
}
const char *refine_flag(GeomRefinementAlgorithmEnum r) {
switch (r) {
case GeomRefinementAlgorithmEnum::None: return "none";
case GeomRefinementAlgorithmEnum::OrientationOnly: return "orientation";
case GeomRefinementAlgorithmEnum::Flex: return "flex";
case GeomRefinementAlgorithmEnum::BeamCenter:
default: return "beam_and_lattice";
}
}
std::string num(double v) {
std::ostringstream o;
o << v;
return o.str();
}
}
std::string RugnuxCommandLine(const ProcessConfig &config,
const DiffractionExperiment &experiment,
const std::string &input_file) {
std::vector<std::string> args;
const bool azint = (config.mode == ProcessMode::AzimuthalIntegration);
args.emplace_back("rugnux");
if (azint)
args.emplace_back("--azint-only");
auto add = [&](const std::string &flag, const std::string &val) {
args.push_back(flag);
args.push_back(val);
};
if (!config.output_prefix.empty())
add("-o", config.output_prefix);
add("-N", std::to_string(config.nthreads));
if (config.start_image != 0)
add("-s", std::to_string(config.start_image));
if (config.end_image >= 0)
add("-e", std::to_string(config.end_image));
if (config.stride != 1)
add("-t", std::to_string(config.stride));
if (azint) {
const auto a = experiment.GetAzimuthalIntegrationSettings();
add("--azim-min-q", num(a.GetLowQ_recipA()));
add("--azim-max-q", num(a.GetHighQ_recipA()));
add("--azim-q-spacing", num(a.GetQSpacing_recipA()));
add("--azim-phi-bins", std::to_string(a.GetAzimuthalBinCount()));
add("--polarization-correction", a.IsPolarizationCorrection() ? "on" : "off");
add("--solid-angle-correction", a.IsSolidAngleCorrection() ? "on" : "off");
} else {
const auto &sf = config.spot_finding;
add("--spot-sigma", num(sf.signal_to_noise_threshold));
add("--spot-threshold", std::to_string(sf.photon_count_threshold));
if (sf.adaptive_threshold)
add("--spot-false-pixels", num(sf.false_pixels_per_frame));
add("--spot-high-resolution", num(sf.high_resolution_limit));
add("--max-spots", std::to_string(experiment.GetMaxSpotCount()));
const auto idx = experiment.GetIndexingSettings();
add("-X", indexing_alg_flag(idx.GetAlgorithm()));
add("-r", refine_flag(idx.GetGeomRefinementAlgorithm()));
if (const auto sg = experiment.GetSpaceGroupNumber())
add("-S", std::to_string(*sg));
if (const auto uc = experiment.GetUnitCell()) {
std::ostringstream o;
o << uc->a << "," << uc->b << "," << uc->c << "," << uc->alpha << "," << uc->beta << "," << uc->gamma;
add("-C", o.str());
}
if (const auto bw = experiment.GetBandwidthFWHM())
add("--bandwidth", num(*bw));
const auto bragg = experiment.GetBraggIntegrationSettings();
std::ostringstream radii;
radii << bragg.GetR1() << "," << bragg.GetR2() << "," << bragg.GetR3();
add("--integration-radius", radii.str());
// Background trim defaults to 0.10 in the CLI, so emit it whenever the GUI value differs (a
// custom fraction, or 0 when the box is unchecked) to reproduce the GUI's choice faithfully.
if (bragg.GetBackgroundTrimFraction() != 0.10f)
add("--background-trim", num(bragg.GetBackgroundTrimFraction()));
if (config.rotation_indexing) {
if (config.two_pass_rotation)
// -R takes an optional argument, which getopt only accepts attached (-R100), never as a
// separate token - so emit it joined or the copied command line will not re-parse.
args.push_back("-R" + std::to_string(config.rotation_indexing_image_count));
else
args.emplace_back("--single-pass-rotation");
if (!config.reuse_rotation_spots)
args.emplace_back("--redo-rotation-spots");
} else if (experiment.GetGoniometer().has_value()) {
// rotation dataset processed as stills -> the user overrode the default with --force-still
args.emplace_back("--force-still");
}
// Stills geometry-refinement two-pass (--refine-geometry). getopt takes its optional argument
// only when attached (=N), never as a separate token, so emit it joined. The CLI defaults it ON
// for a stills-with-cell run, so emit =off when the GUI turned it off in that same case, to
// reproduce the GUI's choice in the copied command line.
const bool stills_with_cell = !config.rotation_indexing && experiment.GetUnitCell().has_value();
if (config.refine_geometry.has_value())
args.push_back("--refine-geometry=" + std::to_string(*config.refine_geometry));
else if (stills_with_cell)
args.emplace_back("--refine-geometry=off");
// Rotation two-pass geometry post-refine. The CLI defaults it ON for a rotation run, so emit the
// disable flag only when the GUI turned it off on a rotation dataset (to reproduce that choice).
if (config.rotation_indexing && !config.rotation_postrefine_geometry)
args.emplace_back("--rotation-no-postrefine");
// Merging is on by default; emit --no-merge only when it was turned off.
if (config.run_scaling) {
const auto sc = experiment.GetScalingSettings();
if (!sc.GetMergeFriedel())
args.emplace_back("-A");
if (sc.GetRefineB())
args.emplace_back("-B");
if (sc.GetPartialityUncertaintyCoeff() > 0.0)
add("--partiality-uncertainty", num(sc.GetPartialityUncertaintyCoeff()));
if (sc.GetStillsModulation())
args.emplace_back("--stills-modulation");
if (!sc.GetStillsPartialityRefine())
args.emplace_back("--simple-stills");
if (!sc.GetExpectedVarianceMerge())
args.emplace_back("--no-expected-variance-merge");
// When merging, the CLI skips the large _process.h5 unless asked; emit the flag when it is
// wanted so a copied command matches the GUI's "Save _process.h5" choice. (write_merged has
// no CLI equivalent - the CLI always writes the .mtz/.cif when merging.)
if (config.write_process_h5)
args.emplace_back("--write-process-h5");
} else {
args.emplace_back("--no-merge");
}
}
args.push_back(input_file);
std::ostringstream cmd;
for (size_t i = 0; i < args.size(); i++) {
if (i)
cmd << ' ';
cmd << quote_if_needed(args[i]);
}
return cmd.str();
}