Stills scaling: leave an image unscaled when its scale collapses

SolveScaleIRLS returns whatever it converged to and both writers accept any
G > 0, so a fit that collapsed to ~1e-3 multiplies that image's intensities by
a thousand. Nothing downstream notices, because the sigmas are multiplied by the
same factor and the merge's n-sigma outlier test is therefore blind to it - only
a total collapse self-heals, by overflowing corr to inf.

The rotation path refuses a per-frame scale this far below its neighbours; the
stills path had no guard. Judge each image against the median of the images that
did scale, and put a collapsed one back to G = 1 - the same state as an image
with too few reflections to fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-30 11:13:51 +02:00
co-authored by Claude Opus 5
parent af97ad61e3
commit 3e8a994d2e
2 changed files with 52 additions and 0 deletions
@@ -3,6 +3,8 @@
#include "ScaleOnTheFly.h"
#include "../../common/Logger.h"
#include <algorithm>
#include <chrono>
#include <cmath>
@@ -16,6 +18,10 @@ namespace {
// symmetry. Cauchy down-weights residuals beyond ~this many sigma without a hard cut.
constexpr double SCALE_ROBUST_K = 3.0;
// Smallest per-image scale, relative to the run's median, that is still believable as a scale
// rather than a failed fit. The same ratio guards the rotation path's per-frame scales.
constexpr double MIN_CREDIBLE_SCALE_RATIO = 0.02;
double SafeInv(double x, double fallback) {
if (!std::isfinite(x) || x == 0.0)
return fallback;
@@ -146,6 +152,45 @@ void ScaleOnTheFly::Scale(IntegrationOutcome &integration_outcome) const {
integration_outcome.image_scale_wedge_deg.reset();
}
// A per-image scale that has collapsed toward zero multiplies that image's intensities by 1/G - and its
// sigmas by the same factor, so nothing downstream can recognise it: the merge's n-sigma outlier test
// scales with the very number that is wrong. The rotation path already refuses a per-frame scale this
// far below its neighbours; the stills path had no such guard. An image whose fit collapsed is left
// UNSCALED (G = 1, the same state as an image with too few reflections to fit) rather than merged with
// its intensities blown up.
void ScaleOnTheFly::RejectCollapsedScales(std::vector<IntegrationOutcome> &integration) {
std::vector<double> fitted;
fitted.reserve(integration.size());
for (const auto &i: integration)
if (i.image_scale_g && std::isfinite(*i.image_scale_g) && *i.image_scale_g > 0.0)
fitted.push_back(*i.image_scale_g);
if (fitted.size() < 2)
return;
const size_t mid = fitted.size() / 2;
std::nth_element(fitted.begin(), fitted.begin() + mid, fitted.end());
const double g_floor = fitted[mid] * MIN_CREDIBLE_SCALE_RATIO;
int64_t n_rejected = 0;
for (auto &i: integration) {
if (!i.image_scale_g || !std::isfinite(*i.image_scale_g) || *i.image_scale_g >= g_floor)
continue;
for (auto &r: i.reflections)
r.image_scale_corr = (std::isfinite(r.rlp) && r.partiality > 0.0f)
? static_cast<float>(r.rlp / r.partiality)
: NAN;
i.image_scale_cc.reset();
i.image_scale_cc_n.reset();
i.image_scale_g.reset();
++n_rejected;
}
if (n_rejected > 0)
Logger("ScaleOnTheFly").Warning(
"Left {} image(s) unscaled: their per-image scale collapsed more than {:.0f}x below the "
"run median, which would have amplified their intensities by the same factor",
n_rejected, 1.0 / MIN_CREDIBLE_SCALE_RATIO);
}
void ScaleOnTheFly::Scale(std::vector<IntegrationOutcome> &integration, size_t nthreads) const {
if (nthreads == 0)
nthreads = std::thread::hardware_concurrency();
@@ -171,4 +216,6 @@ void ScaleOnTheFly::Scale(std::vector<IntegrationOutcome> &integration, size_t n
for (auto &f: futures)
f.get();
}
RejectCollapsedScales(integration);
}