Stills scaling: an image whose scale collapsed is dropped, not merged unscaled

Leaving it at G = 1 looked like the conservative choice and is the more damaging
of the two errors. The per-image scale enters as rlp/(partiality*G) and multiplies
intensity and sigma alike, so substituting 1 for a scale that was really 1/200 of
the run median puts the intensities in 200x too low with sigmas 200x too low too -
1/G^2 times the weight they deserve. The merge cannot defend itself against that,
because the number that is wrong is the number the weight is built from. And if
the collapsed value was instead a failed fit, G = 1 merges the image mis-scaled by
an unknown factor. Per-crystal scales on serial stills genuinely span orders of
magnitude, unlike frames of one rotation sweep, so both readings are live.

An image whose scale is not believable has no usable scale. Write NaN into its
image_scale_corr, which every merge path already skips on, so it drops out of the
merged intensities, the error model and the statistics consistently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 11:51:08 +02:00
co-authored by Claude Opus 5
parent da74197dea
commit a2adc4e021
+14 -8
View File
@@ -148,9 +148,15 @@ void ScaleOnTheFly::Scale(IntegrationOutcome &integration_outcome) const {
// 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.
// far below its neighbours; the stills path had no such guard.
//
// Such an image is DROPPED from the merge, not merged unscaled. Substituting G = 1 looks conservative
// but is the more damaging of the two errors: if the collapsed value was a failed fit, the image goes in
// mis-scaled by an unknown factor, and if it was a real scale (per-crystal scales on serial stills
// genuinely span orders of magnitude, unlike frames of one rotation sweep) then G = 1 divides both its
// intensities AND its sigmas by 1/G, so it enters at 1/G^2 times the weight it deserves - the merge
// cannot down-weight it, because the number that is wrong is the same number the weight is built from.
// An image whose scale is not believable has no usable scale, so it contributes nothing instead.
void ScaleOnTheFly::RejectCollapsedScales(std::vector<IntegrationOutcome> &integration) {
std::vector<double> fitted;
fitted.reserve(integration.size());
@@ -168,10 +174,10 @@ void ScaleOnTheFly::RejectCollapsedScales(std::vector<IntegrationOutcome> &integ
for (auto &i: integration) {
if (!i.image_scale_g || !std::isfinite(*i.image_scale_g) || *i.image_scale_g >= g_floor)
continue;
// A non-finite correction is what every merge path already skips on (Merge.cpp), so this drops
// the image consistently from the merged intensities, the error model and the statistics.
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;
r.image_scale_corr = NAN;
i.image_scale_cc.reset();
i.image_scale_cc_n.reset();
i.image_scale_g.reset();
@@ -179,8 +185,8 @@ void ScaleOnTheFly::RejectCollapsedScales(std::vector<IntegrationOutcome> &integ
}
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",
"Dropped {} image(s) from the merge: their per-image scale collapsed more than {:.0f}x below "
"the run median, so it is not a scale the intensities can be put on",
n_rejected, 1.0 / MIN_CREDIBLE_SCALE_RATIO);
}