// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include "FFTIndexer.h" #include #include "EigenRefine.h" FFTIndexer::FFTIndexer(const IndexingSettings &settings) : max_length_A(settings.GetFFT_MaxUnitCell_A()), min_length_A(settings.GetFFT_MinUnitCell_A()), min_angle_deg(settings.GetFFT_MinAngle_deg()), max_angle_deg(settings.GetFFT_MaxAngle_deg()), nDirections(settings.GetFFT_NumVectors()), result_fft(nDirections) { float maxQ = 2.0f * static_cast(M_PI) / settings.GetFFT_HighResolution_A(); histogram_spacing = 1.0f / (2.0f * max_length_A); histogram_size = std::ceil(maxQ / histogram_spacing); input_size = histogram_size * nDirections; output_size = (histogram_size / 2 + 1) * nDirections; if (max_length_A <= settings.GetFFT_HighResolution_A()) throw std::invalid_argument("Largest unit cell cannot be smaller than resolution"); if (nDirections <= 0) throw std::invalid_argument("FFTWIndexer: number of directions must be > 0"); if (!(max_length_A > 0.f)) throw std::invalid_argument("FFTWIndexer: max_length_A must be > 0"); if (!(settings.GetFFT_HighResolution_A() > 0.f)) throw std::invalid_argument("FFTWIndexer: high resolution must be > 0"); if (histogram_size < 1) throw std::invalid_argument("FFTWIndexer: histogram_size must be >= 1"); if (histogram_size > 1000000) throw std::invalid_argument("FFTWIndexer: histogram_size too large"); SetupDirectionVectors(); } void FFTIndexer::SetupDirectionVectors() { direction_vectors.reserve(static_cast(nDirections)); const double phi = (1.0 + std::sqrt(5.0)) / 2.0; // Golden ratio const double golden_angle = 2.0 * M_PI / phi; for (int i = 0; i < nDirections; i++) { // Half-sphere distribution (z in [0,1]) double z = static_cast(i) / static_cast(nDirections - 1); double theta = golden_angle * static_cast(i); double radius = std::sqrt(std::max(0.0, 1.0 - z * z)); double x = radius * std::cos(theta); double y = radius * std::sin(theta); // Add unit vector to the list direction_vectors.emplace_back(static_cast(x), static_cast(y), static_cast(z)); } } void FFTIndexer::SetupUnitCell(const std::optional &cell) { reference_unit_cell = cell; } void Sort(Coord &A, Coord &B, Coord &C) { // A is the smallest, C is the largest if (A.Length() > B.Length()) std::swap(A, B); if (B.Length() > C.Length()) std::swap(B, C); if (A.Length() > B.Length()) std::swap(A, B); } std::vector FFTIndexer::ReduceResults(const std::vector &results) const { if (results.size() < 3) return {}; std::vector candidates; for (int i = 0; i < 3; i++) { for (int j = 0; j < 3; j++) { for (int k = 0; k < 3; k++) { if (i + j + k + 2 >= results.size()) break; Coord A = results[i]; Coord B = results[(i + j + 1)]; Coord C = results[(i + j + 1) + k + 1]; // sort vectors by length for reduction Sort(A,B,C); // Lattice reduction B = B - std::round(B * A / (A * A)) * A; C = C - std::round(C * A / (A * A)) * A; C = C - std::round(C * B / (B * B)) * B; float alpha = angle_deg(B, C); float beta = angle_deg(A, C); float gamma = angle_deg(A, B); // Check if values are OK after lattice reduction if (A.Length() >= min_length_A && B.Length() >= min_length_A && C.Length() >= min_length_A && alpha >= min_angle_deg && alpha <= max_angle_deg && beta >= min_angle_deg && beta <= max_angle_deg && gamma >= min_angle_deg && gamma <= max_angle_deg) candidates.emplace_back(A, B, C); } } } return candidates; } std::vector FFTIndexer::FilterFFTResults() const { std::multimap fft_result_map; // Order vectors by max FFT magnitude and select the 30 strongest ones size_t max_vectors = 30; for (int i = 0; i < direction_vectors.size(); i++) fft_result_map.insert(std::make_pair(result_fft[i].magnitude, result_fft[i])); std::vector fft_result_filtered; int count = 0; for (auto it = fft_result_map.rbegin(); it != fft_result_map.rend() && count < max_vectors; ++it, ++count) { fft_result_filtered.emplace_back(it->second); } std::vector ret; // Remove vectors less than 5 deg apart, as most likely these are colinear constexpr float COS_5_DEG = 0.996194; // Minimum relative amplitude to accept a shorter vector (fundamental) // over a longer one (harmonic). // 0.25 means the fundamental frequency must have at least 25% of the // intensity of the harmonic. If less, the odd-indexed spots are likely // systematic absences or noise, and the longer vector is the true cell. constexpr float MIN_FUNDAMENTAL_PEAK_RATIO = 0.25f; std::vector ignore(fft_result_filtered.size(), false); for (int i = 0; i < fft_result_filtered.size(); i++) { if (ignore[i]) continue; Coord dir_i = direction_vectors.at(fft_result_filtered[i].direction); float len_i = fft_result_filtered[i].length; int best_idx = i; // Index of the vector we currently plan to keep for (int j = i + 1; j < fft_result_filtered.size(); j++) { if (ignore[j]) continue; Coord dir_j = direction_vectors.at(fft_result_filtered[j].direction); // If vectors are colinear (angle < 5 deg) if (std::fabs(dir_i * dir_j) > COS_5_DEG) { ignore[j] = true; // CHECK: Is the new candidate (j) shorter than current best (best_idx)? // We prefer shorter vectors (fundamental periodicity) over longer ones (harmonics) // BUT only if the shorter vector has "enough" amplitude to be real. if (fft_result_filtered[j].length < len_i * 0.9f) { // Compare against 'i' (the strongest in the cluster) to define the noise floor. // Using 'best_idx' could allow stepping down into noise if we already swapped to a weak peak. float magnitude_ratio = fft_result_filtered[j].magnitude / fft_result_filtered[i].magnitude; // Heuristic: If the shorter vector has at least 25% of the amplitude of the // stronger (longer) vector, assume the shorter one is the true unit cell. // If it's less than 25%, the shorter peak is likely noise/aliasing, // and the longer vector is the true primitive cell. if (magnitude_ratio > MIN_FUNDAMENTAL_PEAK_RATIO) { len_i = fft_result_filtered[j].length; best_idx = j; } } } } Coord best_dir = direction_vectors.at(fft_result_filtered[best_idx].direction); ret.push_back(best_dir * fft_result_filtered[best_idx].length); } return ret; } std::vector FFTIndexer::RunInternal(const std::vector &coord, size_t nspots) { if (nspots > coord.size()) nspots = coord.size(); if (nspots <= viable_cell_min_spots) return {}; assert(nspots <= FFT_MAX_SPOTS); assert(coord.size() <= FFT_MAX_SPOTS); ExecuteFFT(coord, nspots); auto f = FilterFFTResults(); auto r = ReduceResults(f); Eigen::MatrixX3 oCell(r.size() * 3u, 3u); Eigen::VectorX scores(r.size()); for (int i = 0; i < r.size(); i++) { oCell(i * 3u, 0u) = r[i].Vec0().x; oCell(i * 3u, 1u) = r[i].Vec0().y; oCell(i * 3u, 2u) = r[i].Vec0().z; oCell(i * 3u + 1, 0u) = r[i].Vec1().x; oCell(i * 3u + 1, 1u) = r[i].Vec1().y; oCell(i * 3u + 1, 2u) = r[i].Vec1().z; oCell(i * 3u + 2, 0u) = r[i].Vec2().x; oCell(i * 3u + 2, 1u) = r[i].Vec2().y; oCell(i * 3u + 2, 2u) = r[i].Vec2().z; // Bootstrap score scores(i) = 0.2; } RefineParameters parameters{ .viable_cell_min_spots = viable_cell_min_spots, .dist_tolerance_vs_reference = dist_tolerance_vs_reference, .reference_unit_cell = reference_unit_cell, .min_length_A = min_length_A, .max_length_A = max_length_A, .min_angle_deg = min_angle_deg, .max_angle_deg = max_angle_deg, .indexing_tolerance = indexing_tolerance }; auto ref_latt = Refine(coord, nspots, oCell, scores, parameters); if (ref_latt.size() >= 1) { auto uc = ref_latt.at(0).GetUnitCell(); if (uc.alpha < min_angle_deg || uc.alpha > max_angle_deg || uc.beta < min_angle_deg || uc.beta > max_angle_deg || uc.gamma < min_angle_deg || uc.gamma > max_angle_deg) return {}; } return ref_latt; }