From 3e8a994d2e90cf751feffe118b123d0b546f7e3a Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Thu, 30 Jul 2026 11:13:51 +0200 Subject: [PATCH] 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) --- image_analysis/scale_merge/ScaleOnTheFly.cpp | 47 ++++++++++++++++++++ image_analysis/scale_merge/ScaleOnTheFly.h | 5 +++ 2 files changed, 52 insertions(+) diff --git a/image_analysis/scale_merge/ScaleOnTheFly.cpp b/image_analysis/scale_merge/ScaleOnTheFly.cpp index 2c941c65..42a510c1 100644 --- a/image_analysis/scale_merge/ScaleOnTheFly.cpp +++ b/image_analysis/scale_merge/ScaleOnTheFly.cpp @@ -3,6 +3,8 @@ #include "ScaleOnTheFly.h" +#include "../../common/Logger.h" + #include #include #include @@ -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 &integration) { + std::vector 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(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 &integration, size_t nthreads) const { if (nthreads == 0) nthreads = std::thread::hardware_concurrency(); @@ -171,4 +216,6 @@ void ScaleOnTheFly::Scale(std::vector &integration, size_t n for (auto &f: futures) f.get(); } + + RejectCollapsedScales(integration); } diff --git a/image_analysis/scale_merge/ScaleOnTheFly.h b/image_analysis/scale_merge/ScaleOnTheFly.h index bc8ae639..8290ba92 100644 --- a/image_analysis/scale_merge/ScaleOnTheFly.h +++ b/image_analysis/scale_merge/ScaleOnTheFly.h @@ -37,5 +37,10 @@ public: void Scale(IntegrationOutcome &integration_outcome) const; void Scale(std::vector &integration_outcome, size_t nthreads = 0) const; + + // Leave an image unscaled when its fitted scale collapsed far below the run median (see the .cpp). + // Called by the multi-image Scale, which is where the run median exists; public so a caller that + // scales images one at a time can apply it once it has them all. + static void RejectCollapsedScales(std::vector &integration); };