rugnux stills with a given cell: one crystal form merged, distance and centre searched together, a stated distance kept

- A given cell keeps only the frames whose refined cell belongs to its population (GivenCellPopulation):
  the peak of the refined-volume histogram nearest the given cell, out to the valley that separates it
  from another population, with the bin set by the data's own spread. A sample of two crystal forms
  indexed against one form's cell otherwise merges both; a run with one population keeps every frame.
- The distance grid is run from every centre hypothesis, not only the one decided at the file's
  distance, and a local grid of centres follows at the distance decided. A wrong header distance and a
  centre several pixels off (both background estimators misread it on a Rayonix set) otherwise leave
  each other flat; now the cell alone finds both.
- A distance given with --detector-distance is not searched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
leonarski_fandClaude Opus 5.5 committed 2026-10-20 18:02:10 +02:00
1 parent ae7b68dd68
commit 380cdf3dc7
10 files changed
+233 -15

No files matched your search

@@ -11,6 +11,8 @@ ADD_LIBRARY(JFJochScaleMerge
Merge.h
ScaleOnTheFly.cpp
ScaleOnTheFly.h
CellPopulation.cpp
CellPopulation.h
StillsPartialityRefine.cpp
StillsPartialityRefine.h
RotationScaleMerge.cpp
@@ -0,0 +1,77 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "CellPopulation.h"
#include <algorithm>
#include <cmath>
#include <limits>
std::pair<double, double> GivenCellPopulation(const std::vector<double> &relative_volume) {
constexpr double everything = std::numeric_limits<double>::infinity();
// Below this many frames a histogram has no valleys to speak of.
constexpr size_t MIN_FRAMES = 50;
// A valley separates two populations only where it dips below this fraction of the kept peak; a
// shallower dip is noise in one population's tail.
constexpr double VALLEY_FRACTION = 0.5;
const size_t n = relative_volume.size();
if (n < MIN_FRAMES)
return {-everything, everything};
std::vector<double> v = relative_volume;
std::sort(v.begin(), v.end());
// The bin: twice a robust standard deviation over n^(1/3) (Freedman-Diaconis with the median
// absolute deviation in place of the interquartile range, which a second population inflates).
const double median = v[n / 2];
std::vector<double> dev(n);
for (size_t i = 0; i < n; i++)
dev[i] = std::fabs(v[i] - median);
std::nth_element(dev.begin(), dev.begin() + n / 2, dev.end());
const double bin = 2.0 * 1.4826 * dev[n / 2] / std::cbrt(static_cast<double>(n));
if (!(bin > 0))
return {-everything, everything};
const double lo = v.front();
const int nbins = static_cast<int>((v.back() - lo) / bin) + 1;
std::vector<double> count(nbins, 0.0);
for (double x : v)
count[std::min(nbins - 1, static_cast<int>((x - lo) / bin))] += 1.0;
// Three-bin running mean, so a single empty or full bin is not a valley or a peak.
std::vector<double> smooth(nbins, 0.0);
for (int i = 0; i < nbins; i++) {
const int a = std::max(0, i - 1), b = std::min(nbins - 1, i + 1);
double s = 0;
for (int j = a; j <= b; j++)
s += count[j];
smooth[i] = s / (b - a + 1);
}
// The peak nearest the given cell: from its bin, uphill.
int peak = std::clamp(static_cast<int>(std::floor(-lo / bin)), 0, nbins - 1);
while (true) {
if (peak + 1 < nbins && smooth[peak + 1] > smooth[peak]) peak++;
else if (peak > 0 && smooth[peak - 1] > smooth[peak]) peak--;
else break;
}
// Out from the peak on each side: the valley is the lowest point, below VALLEY_FRACTION of the peak,
// before the histogram rises again to twice its height by more than counting noise - the next
// population, however small. None: the population runs out to the end of the data.
const auto valley = [&](int step) {
int lowest = peak;
for (int i = peak + step; i >= 0 && i < nbins; i += step) {
if (smooth[i] < smooth[lowest])
lowest = i;
const double low = smooth[lowest], here = smooth[i];
// A three-bin mean of Poisson counts has a variance of a third of its value.
const bool rises = here >= 2.0 * low && here - low > 3.0 * std::sqrt((here + low) / 3.0);
if (low < VALLEY_FRACTION * smooth[peak] && rises)
return lowest;
}
return -1;
};
const int left = valley(-1), right = valley(+1);
return {left < 0 ? -everything : lo + (left + 0.5) * bin,
right < 0 ? everything : lo + (right + 0.5) * bin};
}
@@ -0,0 +1,19 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <utility>
#include <vector>
// Serial stills given a cell: which frames belong to that crystal. A sample can hold two crystal forms
// (a polymorph, a different hydration state), and a known-cell indexer fits both to the given cell's
// orientation search - the refined cells then fall into two volume populations, and merging both mixes
// two structures. The frames kept are the population the given cell sits in: the peak of the histogram
// of v / v_given - 1 nearest to 0, out to the valley on either side that separates it from another
// population. The histogram bin follows the spread of the data, so neither the
// width of a population nor its distance to the next is a fixed number. A run with one population has
// no such valley and keeps every frame.
//
// relative_volume: v / v_given - 1 per indexed frame. Returns the [low, high] range of it to keep.
std::pair<double, double> GivenCellPopulation(const std::vector<double> &relative_volume);
+2
View File
@@ -849,6 +849,8 @@ ProcessResult Rugnux::RunPipeline(RugnuxObserver *observer, bool write_output, b
// sample of strongly-indexed frames and apply it to experiment_, so everything below (azimuthal
// mapping, the main index+integrate pass, scaling) uses the refined geometry. Rotation is skipped
// inside (it has its own two-pass).
if (!experiment_.IsRotationIndexing())
stills_user_cell_ = experiment_.GetUnitCell();
if (full && config_.refine_geometry.has_value())
RefineStillsGeometry(start_image, end_image, images_to_process, observer);
+2
View File
@@ -164,6 +164,8 @@ struct ProcessConfig {
// two answers - the one comparison an on-failure trigger cannot make, because a centre wrong
// along the spindle does not fail.
bool beam_center_check = true;
// The detector distance was given on the command line: stills do not search it (DecideStillsGeometry).
bool detector_distance_given = false;
// Whether a saturation level the frames show below the file's limit replaces it (DetectSaturation):
// for every input but a Jungfraujoch-written file, whose stated limit is trusted.
bool detect_saturation = false;
+26
View File
@@ -83,6 +83,7 @@
#include <array>
#include <map>
#include "RugnuxPipeline.h"
#include "../image_analysis/scale_merge/CellPopulation.h"
using namespace rugnux_internal;
@@ -512,6 +513,31 @@ bool Rugnux::ScaleMergeAndSymmetry(PipelineLocals &p) {
logger.Info("Sweep ends: left out {} frame(s) at the start and {} at the end that recorded no "
"diffraction - a shutter or beam that was not on them", at_start, at_end);
}
// Stills given a cell: only the frames whose refined cell belongs to its population are merged
// (see GivenCellPopulation) - a second crystal form, fitted to the same cell, is left out.
if (!experiment_.IsRotationIndexing() && stills_user_cell_) {
auto &outcomes = indexer->GetIntegrationOutcome();
const double given = CrystalLattice(*stills_user_cell_).CalcVolume();
std::vector<double> rel;
for (const auto &o : outcomes)
if (!o.reflections.empty())
rel.push_back(o.latt.CalcVolume() / given - 1.0);
const auto [low, high] = GivenCellPopulation(rel);
size_t left_out = 0;
for (auto &o : outcomes) {
if (o.reflections.empty())
continue;
const double r = o.latt.CalcVolume() / given - 1.0;
if (r < low || r > high) {
o.reflections.clear();
left_out++;
}
}
if (left_out > 0)
logger.Info("Given cell: {} of {} indexed frames refined to a cell outside its population "
"({:+.1f}% to {:+.1f}% in volume) and are left out of the merge",
left_out, rel.size(), 100 * low, 100 * high);
}
size_t ice_flagged = 0, ice_total = 0;
{
auto &outcomes = indexer->GetIntegrationOutcome();
+53 -15
View File
@@ -95,8 +95,6 @@ void Rugnux::RefineStillsGeometry(int start_image, int end_image, int images_to_
return;
}
// The bundle anchors a known cell; without one there is nothing to refine against.
if (!stills_user_cell_)
stills_user_cell_ = experiment_.GetUnitCell();
const auto cell = stills_user_cell_;
if (!cell.has_value()) {
logger.Warning("--refine-geometry needs a reference unit cell (-C / -S / reference MTZ). Skipping.");
@@ -446,30 +444,41 @@ bool Rugnux::DecideStillsGeometry(int start_image, int images_to_process) {
// and only then is the distance a hypothesis of its own: without a cell a wrong distance indexes as a
// cell of the wrong size, which only the curvature far from the beam tells apart. The stills geometry
// refinement takes it from there (RunPipeline).
// A distance the user gave on the command line is taken as given: a sample of mixed crystal forms
// can put more spots on one given cell at a distance that is not the detector's.
float distance = file_distance;
if (experiment_.GetUnitCell().has_value()) {
float grid_best = file_distance;
int64_t grid_score = trial(x, y, file_distance, strongest);
for (float f = MIN_FACTOR; f <= MAX_FACTOR; f *= STEP) {
if (std::fabs(f - 1.0f) < 0.5f * (STEP - 1.0f))
continue; // the file's own, scored above
const int64_t score = trial(x, y, file_distance * f, strongest);
if (score > grid_score) {
grid_score = score;
grid_best = file_distance * f;
if (experiment_.GetUnitCell().has_value() && !config_.detector_distance_given) {
// Every centre hypothesis gets its own grid: where the file's distance is wrong, the centre
// decided at it had little indexing to go on, and a wrong centre in turn leaves the grid flat.
std::vector<std::pair<float, float>> centres{{x, y}, {file_x, file_y}};
centres.insert(centres.end(), candidates.begin(), candidates.end());
float grid_best = file_distance, grid_x = x, grid_y = y;
int64_t grid_score = -1;
for (const auto &[cx, cy] : centres)
for (float f = MIN_FACTOR; f <= MAX_FACTOR; f *= STEP) {
const int64_t score = trial(cx, cy, file_distance * f, strongest);
if (score > grid_score) {
grid_score = score;
grid_best = file_distance * f;
grid_x = cx;
grid_y = cy;
}
}
}
float best_distance = file_distance;
float best_distance = file_distance, best_x = x, best_y = y;
int64_t best = score_here;
for (const float f : {0.98f, 0.99f, 1.0f, 1.01f, 1.02f}) {
const int64_t score = trial(x, y, grid_best * f, frames);
const int64_t score = trial(grid_x, grid_y, grid_best * f, frames);
if (score > best) {
best = score;
best_distance = grid_best * f;
best_x = grid_x;
best_y = grid_y;
}
}
if (score_here < FILE_KEPT_ABOVE * best) {
distance = best_distance;
x = best_x;
y = best_y;
stills_distance_from_cell_ = true;
if (stills_user_cell_)
experiment_.SetUnitCell(*stills_user_cell_);
@@ -482,6 +491,35 @@ bool Rugnux::DecideStillsGeometry(int start_image, int images_to_process) {
decide_centre(distance);
}
// With a cell, also a local grid of centres about the one decided, at the distance decided: the two
// background estimators can both be several pixels off (a shadowed or uneven background), and the
// geometry refinement only moves the centre a few pixels. Coarse, then fine about the coarse best.
if (experiment_.GetUnitCell().has_value()) {
float gx = x, gy = y;
int64_t g_best = trial(x, y, distance, strongest);
for (const auto &[step, reach] : {std::pair{3.0f, 4}, std::pair{1.0f, 2}}) {
const float cx0 = gx, cy0 = gy;
for (int i = -reach; i <= reach; i++)
for (int j = -reach; j <= reach; j++) {
if (i == 0 && j == 0)
continue;
const int64_t score = trial(cx0 + step * i, cy0 + step * j, distance, strongest);
if (score > g_best) {
g_best = score;
gx = cx0 + step * i;
gy = cy0 + step * j;
}
}
}
const int64_t here = trial(x, y, distance, frames), there = trial(gx, gy, distance, frames);
logger.Info("Stills beam centre grid at {:.2f} mm: ({:.2f},{:.2f}) puts {} sampled spots on the lattice, "
"({:.2f},{:.2f}) {}", distance, gx, gy, there, x, y, here);
if (here < FILE_KEPT_ABOVE * there) {
x = gx;
y = gy;
}
}
logger.Info("Stills beam centre: the run uses ({:.2f},{:.2f}){}", x, y,
x == file_x && y == file_y ? ", the file's" : "");
experiment_.BeamX_pxl(x).BeamY_pxl(y).DetectorDistance_mm(distance);
+1
View File
@@ -3207,6 +3207,7 @@ static int RunRugnux(int argc, char **argv, bool no_gpu) {
config.two_pass_rotation = two_pass_rotation;
config.detect_beam_stop = detect_beam_stop;
config.beam_center_check = beam_center_check;
config.detector_distance_given = detector_distance_mm.has_value();
config.detect_saturation = !saturation_value
&& (input_is_cbf || input_is_marccd || input_is_smv
|| input_is_crystfel || input_is_cxi || input_is_xtc);
+1
View File
@@ -12,6 +12,7 @@ ADD_EXECUTABLE(jfjoch_portable_test EXCLUDE_FROM_ALL
JFJochReaderTest.cpp
CrystFELGeometryTest.cpp
SaturationCeilingTest.cpp
CellPopulationTest.cpp
SweepLayoutTest.cpp
CBORTest.cpp
ImageSpotFinderCPUTest.cpp
+50
View File
@@ -0,0 +1,50 @@
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <random>
#include <catch2/catch_all.hpp>
#include "../image_analysis/scale_merge/CellPopulation.h"
TEST_CASE("CellPopulation_OnePopulationKeepsEverything", "[CellPopulation][portable]") {
std::mt19937 rng(7);
std::normal_distribution<double> d(0.004, 0.01);
std::vector<double> r(2000);
for (auto &x : r) x = d(rng);
const auto [low, high] = GivenCellPopulation(r);
for (double x : r)
CHECK((x >= low && x <= high));
}
TEST_CASE("CellPopulation_SecondFormLeftOut", "[CellPopulation][portable]") {
// Two crystal forms 9% apart in volume, the given cell one of them; whichever is given, its own
// population is kept and the other left out.
std::mt19937 rng(11);
std::normal_distribution<double> small(0.0, 0.012), large(0.09, 0.008);
std::vector<double> r;
for (int i = 0; i < 450; i++) r.push_back(small(rng));
for (int i = 0; i < 150; i++) r.push_back(large(rng));
// Kept frames of each form, against either cell.
const auto kept = [](const std::vector<double> &x, size_t from, size_t to) {
const auto [low, high] = GivenCellPopulation(x);
size_t n = 0;
for (size_t i = from; i < to; i++)
n += (x[i] >= low && x[i] <= high);
return n;
};
CHECK(kept(r, 0, 450) > 440);
CHECK(kept(r, 450, 600) < 3);
std::vector<double> r2;
for (double x : r) r2.push_back((1.0 + x) / 1.09 - 1.0);
CHECK(kept(r2, 450, 600) > 145);
CHECK(kept(r2, 0, 450) < 5);
}
TEST_CASE("CellPopulation_TooFewFrames", "[CellPopulation][portable]") {
const auto [low, high] = GivenCellPopulation({0.0, 0.1, 0.2});
CHECK(std::isinf(low));
CHECK(std::isinf(high));
}