Files
Jungfraujoch/image_analysis/indexing/SpindleBlindFraction.cpp
T
leonarski_fandClaude Opus 5 c477cf0be3 spindle: a row perpendicular to the axis is as blind as a row on it
The blind region a sweep leaves is a double cone, so a 2-fold sends it to two
places: 2*beta away, and 180-2*beta away. The severity took only the first, and
scored a row perpendicular to the spindle as 0 - "symmetry repairs everything" -
when such a 2-fold in fact carries the cone onto its opposite lobe, which the
sweep leaves equally unmeasured. Folding the miss-angle to min(beta, 90-beta)
covers both images and reproduces a Monte-Carlo of the true overlap to 0.002.

The failure was silent and in the dangerous direction, and it fired on the more
common geometry: for a random axis the perpendicular band is several times wider
than the aligned one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-04 10:59:05 +02:00

96 lines
4.6 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <cmath>
#include "SpindleBlindFraction.h"
namespace {
// Only rows short enough to be a plausible symmetry axis count. The cut is relative to the
// crystal's own shortest row, not an absolute length, so it works the same for a 40 A cell and
// a 200 A one: measured over 107 solved cells, 2.5 x the shortest row covers 88% of all
// symmetry axes and 98% of crystals' shortest one.
constexpr float MAX_ROW_LENGTH_RATIO = 2.5f;
// The reference length is taken over the strong rows only. A long-cell still makes the pass
// invent short spurious rows - measured at 0.4-0.6 of the true row's peak - and taking the
// reference over every row would let one of those shrink the window until the real aligned row
// fell outside it, turning a severe orientation into a reported zero.
constexpr float MIN_MAGNITUDE_RATIO = 0.5f;
// Where the search grid can no longer resolve the crystal's rows, the pass stops returning them
// and starts returning short spurious ones instead, and the shortlist becomes internally
// inconsistent: its strong rows are many times longer than its shortest entry. Measured over 384
// frames of 22 solved crystals the ratio never exceeded 2.34 and was 1.00 at the median; on a
// synthetic still whose cell is past the grid's reach it runs 4-13. Past this the score would be
// reporting a cone it cannot see into, so it reports nothing instead.
constexpr float MAX_REFERENCE_LENGTH_RATIO = 3.0f;
}
float BlindConeSelfOverlap(float x) {
if (x >= 1.0f)
return 0.0f;
if (x <= 0.0f)
return 1.0f;
return static_cast<float>(2.0 / M_PI) *
(std::acos(x) - x * std::sqrt(1.0f - x * x));
}
std::optional<SpindleSeverity> SpindleBlindFraction(const std::vector<Coord> &rows,
const std::vector<float> &magnitudes,
const Coord &spindle,
float theta_max_deg) {
if (rows.empty() || rows.size() != magnitudes.size() || theta_max_deg <= 0)
return {};
const float axis_length = spindle.Length();
if (axis_length < 1e-6f)
return {};
const Coord axis = spindle.Normalize();
float max_magnitude = 0;
for (const auto &m : magnitudes)
max_magnitude = std::max(max_magnitude, m);
float reference_length = 0;
for (size_t i = 0; i < rows.size(); i++)
if (magnitudes[i] >= MIN_MAGNITUDE_RATIO * max_magnitude) {
const float l = rows[i].Length();
if (reference_length == 0 || l < reference_length)
reference_length = l;
}
if (reference_length == 0)
return {};
float shortest_length = reference_length;
for (const auto &r : rows)
shortest_length = std::min(shortest_length, r.Length());
if (reference_length > MAX_REFERENCE_LENGTH_RATIO * shortest_length)
return {};
SpindleSeverity ret;
for (size_t i = 0; i < rows.size(); i++) {
const float length = rows[i].Length();
if (length <= 0 || length > MAX_ROW_LENGTH_RATIO * reference_length)
continue;
if (magnitudes[i] < MIN_MAGNITUDE_RATIO * max_magnitude)
continue;
const float cos_beta = std::min(1.0f, std::fabs(rows[i] * axis) / length);
const float beta_deg = static_cast<float>(std::acos(cos_beta) * 180.0 / M_PI);
// A row PERPENDICULAR to the spindle is as damaging as one along it, and far more common: a
// 2-fold about it carries the blind cone onto the cone's opposite lobe, which the same sweep
// leaves equally unmeasured. That is Friedel's rescue, which is no rescue - the cone is
// double-sided. Both ends of the range are the bad case and the safe zone lies between them,
// so the miss-angle is folded about 45 deg. Checked against a Monte-Carlo of the true
// spherical overlap the folded form is within 0.006 to theta_max = 20 deg and 0.024 to 45 deg;
// unfolded it is wrong by a full 1.0 at beta = 90 deg, reporting the worst case as the best.
const float fold_deg = std::min(beta_deg, 90.0f - beta_deg);
const float score = BlindConeSelfOverlap(fold_deg / theta_max_deg);
if (score > ret.score || ret.row_length_A == 0) {
ret.score = score;
ret.row_length_A = length;
ret.miss_angle_deg = beta_deg;
}
}
return ret;
}