Files
Jungfraujoch/reader/SweepLayout.cpp
T
leonarski_fandClaude Opus 5 5f78fc156f Place CBF/marCCD/SMV frames on the sweep their own headers state
A series of one file per image was laid out end to end: the rotation start came
from the first file and the step from the difference between the first two, so a
series with frames missing came out compressed - one 179.8 degree deposited sweep
of 1108 files out of 1800 was read as 111 degrees, and every frame past the first
gap was analysed at the wrong spindle angle. Indexing then found a lattice that
took 4% of the validation spots, and two other gapped series aborted outright
with "it is not this crystal's lattice".

Every one of these formats writes each image's own start angle in its own header,
so the sweep is fully recoverable. The new reader/SweepLayout places each frame at
the slot its own angle puts it in and leaves a missing frame as a gap - a slot with
no file, which ReadRawImage reports as nothing to read, which every image loop in
the pipeline already passes over. The goniometer's start + increment * image_number
is then the true angle of every image, and the sweep range, the per-10-degree
delta-CC1/2 batches and the sweep-quality ledger all read the rotation the headers
describe. The rotation step is the smallest move between two frames that really are
adjacent, not the first pair.

The three readers shared this code by duplication; it is now written once. The same
place refuses what is not a sweep rather than averaging it into one: headers that
disagree about the detector distance, the beam centre, the wavelength or the
oscillation width, angles that do not sit on a single step (a folder of screening
shots), or two frames claiming the same angle - each naming the frames. A series
that does not turn at all is left exactly as it was.

A directory holding fewer files than its own numbering spans is also reported, with
both counts: that is the signal that a sweep was not unpacked or copied whole, which
otherwise shows up only as a resolution nobody can explain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nW6FNRP1bBJJ8pfHiByAT
2026-09-20 18:45:18 +02:00

225 lines
11 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "SweepLayout.h"
#include <algorithm>
#include <cmath>
#include <cctype>
#include <filesystem>
#include <optional>
#include "../common/JFJochException.h"
#include "../common/Logger.h"
namespace {
// The difference between two recorded angles, brought into (-180, 180]. A sweep that runs past 360
// starts over at 0 in some writers' headers and keeps counting in others', and this reads both the
// same way. Safe because no gap seen in a deposited series comes close to half a turn - the widest
// in the corpus here is 0.7 degrees - so a folded step is the step and not an aliased one.
double Fold(double d) {
while (d <= -180.0) d += 360.0;
while (d > 180.0) d -= 360.0;
return d;
}
std::string Name(const std::string &path) {
return std::filesystem::path(path).filename().string();
}
// The frame number a file name ends in. Every one-file-per-image format here numbers its frames that
// way, and the numbering is a second, independent statement of how long the series should be: a
// directory holding fewer files than its own numbering spans is a directory that was not unpacked
// whole, which is worth saying out loud - one staged sweep here was short by 690 frames for months,
// and the only sign of it was a resolution nobody could explain.
std::optional<int64_t> TrailingNumber(const std::string &path) {
const std::string name = Name(path);
size_t end = name.find_last_of('.');
if (end == std::string::npos)
end = name.size();
// ".cbf.gz" and the like: step back over as many trailing extensions as there are.
while (end > 0 && !std::isdigit(static_cast<unsigned char>(name[end - 1]))) {
const size_t dot = name.find_last_of('.', end - 1);
if (dot == std::string::npos)
return {};
end = dot;
}
size_t begin = end;
while (begin > 0 && std::isdigit(static_cast<unsigned char>(name[begin - 1])))
begin--;
if (begin == end)
return {};
return std::stoll(name.substr(begin, end - begin));
}
// The first few frames of a list, by name, for a message a user has to act on. All of them would be
// hundreds of lines on the series this exists for.
std::string NameSome(const std::vector<std::string> &paths) {
const size_t show = std::min<size_t>(paths.size(), 5);
std::string out;
for (size_t i = 0; i < show; i++)
out += (i ? ", " : "") + Name(paths[i]);
if (paths.size() > show)
out += fmt::format(" and {} more", paths.size() - show);
return out;
}
// Whether two readings of the same instrument setting are the same reading. Relative, because what
// counts as the same distance depends on the distance; the bounds are far wider than a read-back
// jitters and far narrower than a real move.
bool Same(double a, double b, double rel_tol, double abs_tol) {
return std::abs(a - b) <= std::max(abs_tol, rel_tol * std::max(std::abs(a), std::abs(b)));
}
} // namespace
namespace sweep {
Layout Place(const std::vector<Frame> &frames, const std::string &logger_name) {
if (frames.empty())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"No images to place on a sweep");
Logger logger(logger_name);
// ---- one sweep, or several sets of images that happen to share a name?
//
// Everything below takes the geometry from the first frame and applies it to all of them, so a
// series whose headers disagree about where the detector was is not a sweep at all and must not
// be silently averaged into one. A folder of screening shots is the case that matters: it fails
// deep inside indexing, as a lattice nobody can explain, when it should fail here by name.
const Frame &f0 = frames[0];
std::vector<std::string> bad_distance, bad_beam, bad_wavelength, bad_increment;
for (const auto &f : frames) {
if (!Same(f.distance_m, f0.distance_m, 0.005, 1e-6))
bad_distance.push_back(f.path);
if (std::abs(f.beam_x_px - f0.beam_x_px) > 2.0 || std::abs(f.beam_y_px - f0.beam_y_px) > 2.0)
bad_beam.push_back(f.path);
if (!Same(f.wavelength_A, f0.wavelength_A, 0.001, 1e-9))
bad_wavelength.push_back(f.path);
if (!Same(f.increment_deg, f0.increment_deg, 0.01, 1e-6))
bad_increment.push_back(f.path);
}
const auto refuse = [&](const char *what, const std::vector<std::string> &who, double first) {
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
fmt::format("The images named here are not one sweep: {} differ(s) from "
"{} ({:g}) in {}. Process the sweeps separately.",
what, Name(f0.path), first, NameSome(who)));
};
if (!bad_distance.empty()) refuse("the detector distance", bad_distance, f0.distance_m);
if (!bad_beam.empty()) refuse("the beam centre", bad_beam, f0.beam_x_px);
if (!bad_wavelength.empty()) refuse("the wavelength", bad_wavelength, f0.wavelength_A);
if (!bad_increment.empty()) refuse("the oscillation width", bad_increment, f0.increment_deg);
// ---- the angles, unwrapped so a sweep that passes 360 keeps counting
std::vector<double> angle(frames.size());
angle[0] = f0.angle_deg;
for (size_t i = 1; i < frames.size(); i++)
angle[i] = angle[i - 1] + Fold(frames[i].angle_deg - angle[i - 1]);
// ---- one rotation step
//
// The step is the smallest move between two frames that ARE adjacent in the series, signed with
// the way it went. Taking the FIRST pair instead - which is what this code used to do - reads a
// gap as the step and compresses the whole sweep by however much is missing. The header's own
// Angle_increment is not used for this: it is the oscillation WIDTH, which a series with
// overlapping or spaced wedges does not step by.
//
// A difference smaller than half the oscillation width is not a step but jitter in the recorded
// angle: no instrument slices finer than it exposes, so wedges overlapping twofold would be a
// read-back wobble, and taking one as the step would spread the sweep over millions of slots.
const double too_fine = 0.5 * std::abs(f0.increment_deg);
double step = 0;
for (size_t i = 1; i < frames.size(); i++) {
const double d = Fold(angle[i] - angle[i - 1]);
if (std::abs(d) > std::max(too_fine, 1e-6) && (step == 0 || std::abs(d) < std::abs(step)))
step = d;
}
Layout out;
// A series that never turns: a grid scan, a set of stills, or a single image. There is no sweep
// to place anything on, so the files are the slots and the header's nominal increment stands.
if (step == 0) {
out.files.reserve(frames.size());
for (const auto &f : frames)
out.files.push_back(f.path);
out.start_deg = f0.angle_deg;
out.increment_deg = f0.increment_deg;
out.present = frames.size();
return out;
}
// ---- every frame on that step, or this is not a rotation series
std::vector<int64_t> slot(frames.size());
std::vector<std::string> off_grid;
for (size_t i = 0; i < frames.size(); i++) {
const double k = (angle[i] - angle[0]) / step;
slot[i] = std::llround(k);
if (std::abs(k - static_cast<double>(slot[i])) > 0.25)
off_grid.push_back(frames[i].path);
}
if (!off_grid.empty())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
fmt::format("The images named here do not lie on one rotation series: "
"{} starts at {:.4f} deg, the series steps by {:.4f} deg, "
"and {} sit(s) off that step. Screening images taken at "
"scattered angles are not a sweep.",
Name(f0.path), angle[0], step, NameSome(off_grid)));
// ---- the slots
//
// Numbered from the frame that comes FIRST on the spindle, which is not always the first file:
// slot 0 is where the goniometer's start angle is, and the files were only ever sorted by name.
const int64_t first = *std::min_element(slot.begin(), slot.end());
const int64_t last = *std::max_element(slot.begin(), slot.end());
out.files.assign(static_cast<size_t>(last - first) + 1, std::string());
std::vector<std::string> duplicates;
for (size_t i = 0; i < frames.size(); i++) {
std::string &at = out.files[static_cast<size_t>(slot[i] - first)];
if (!at.empty())
duplicates.push_back(frames[i].path);
at = frames[i].path;
}
if (!duplicates.empty())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
fmt::format("The images named here repeat an angle already taken by "
"another image of the series: {}. Two sweeps of the same "
"crystal have to be processed separately.",
NameSome(duplicates)));
out.start_deg = angle[0] + step * static_cast<double>(first);
out.increment_deg = step;
out.present = frames.size();
if (out.present < out.files.size())
logger.Warning("{} of the {} images the sweep spans are present; the {} missing ones are "
"left as gaps, so every image keeps the spindle angle its own header states "
"({:.2f} to {:.2f} deg). The merge will be that much less complete.",
out.present, out.files.size(), out.files.size() - out.present,
out.start_deg,
out.start_deg + step * static_cast<double>(out.files.size() - 1));
// The numbering says the same thing a second way, and says it about the ends of the series too,
// which the angles cannot: a sweep missing its first and last frames still spans only the angles
// that are there. Where the two disagree with the file count, the directory is short.
int64_t lo = 0, hi = 0;
bool numbered = true;
for (size_t i = 0; i < frames.size() && numbered; i++) {
const auto n = TrailingNumber(frames[i].path);
if (!n.has_value())
numbered = false;
else if (i == 0)
lo = hi = *n;
else
lo = std::min(lo, *n), hi = std::max(hi, *n);
}
if (numbered && hi - lo + 1 > static_cast<int64_t>(out.present))
logger.Warning("The file numbering runs {}..{}, which is {} frames, but the directory holds "
"{}: {} are not there. If this series should be complete, it was not unpacked "
"or copied whole - check the source.",
lo, hi, hi - lo + 1, out.present, hi - lo + 1 - static_cast<int64_t>(out.present));
return out;
}
} // namespace sweep