Files
Jungfraujoch/reader/JFJochCBFReader.cpp
T
leonarski_fandClaude Opus 5 2ab8c55dfa rugnux and the viewer read SMV and gzipped miniCBF
Two more of the formats deposited data actually arrives in, found by processing a
corpus of it: every PETRA III EMBL set is .cbf.gz, and NSRRC and the whole ADSC
Quantum era are SMV. Both were previously "no native input".

SMV is an ASCII "KEY=value;" block between braces, then the pixels - no container,
no compression, nothing to decode by offset - so reader/SMV.{h,cpp} and
JFJochSMVReader are a smaller job than the marCCD pair they sit beside, and need no
new dependency at all. Two things the format does not give us, both said out loud
rather than papered over:

* It states no saturation value, so overloads are judged on the 16-bit container
  alone. That can only fail to call a pixel saturated, never condemn a good one,
  but a CCD at the top of its range does saturate, so the reader warns once.
* Its beam centre is in MILLIMETRES and which of X/Y is the fast direction is a
  convention rather than a rule. Measured on one ALS ADSC sweep the file's value is
  TRANSPOSED: as stated it indexes 2/60 frames, and the run's own beam-centre
  measurement (which adopts the right one automatically) indexes 60/60. Swapping it
  here would fit that writer and might break another, so the header is read as the
  format defines it and the measurement stays the arbiter. Revisit with a second
  vendor's SMV in hand.

.cbf.gz needed only Slurp() in MiniCBF.cpp, through which every read already passes:
it sniffs the two-byte gzip magic - not the file name - and takes a zlib path when it
is there, leaving the plain path free of zlib's buffer copy. zlib-ng is already in the
build, so this is a link line, not a dependency. The sweep template grew a suffix,
because ".cbf" and ".cbf.gz" are separate sweeps and std::filesystem cannot split the
double extension on its own.

The viewer's single cbf_reader becomes three, dispatched by CanRead() in the same
order as rugnux. Dispatch is by CONTENT in both: ".img" is used by miniCBF, marCCD
AND SMV depending on the writer, and a PDB detector label has now been wrong about
the format four times, so an extension decides nothing.

Measured, de novo, no flags: 9fcg (1800 gzipped frames) gives P4 and a cell 0.06%
from the deposited one at 1.37 A against a deposited 1.54; 6oel (ADSC SMV) gives
F4132 - 96 operations, the most a protein space group can have - and a cell 0.05%
out, 100% indexed. Tests cover both formats and the transposed-beam-centre case with
fixtures written byte for byte, so they need no external data.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 22:35:05 +02:00

390 lines
20 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochCBFReader.h"
#include <algorithm>
#include <cctype>
#include <cstring>
#include <filesystem>
#include <array>
#include <future>
#include <map>
#include <optional>
#include <thread>
#include <tuple>
#include "../common/JFJochException.h"
#include "../common/Logger.h"
#include "../common/JFJochMath.h"
namespace {
// ".cbf", or ".cbf.gz" - EMBL Hamburg's beamlines write the gzipped form by default, and the
// reader decompresses it in place, so a sweep of those is named here rather than converted first.
// Returns the suffix that matched, because the sweep template needs its length.
std::optional<std::string> CBFSuffix(const std::filesystem::path &p) {
std::string name = p.filename().string();
std::transform(name.begin(), name.end(), name.begin(),
[](unsigned char c) { return std::tolower(c); });
for (const char *suffix : {".cbf.gz", ".cbf"}) {
const std::string s(suffix);
if (name.size() > s.size() && name.compare(name.size() - s.size(), s.size(), s) == 0)
return s;
}
return {};
}
bool HasCBFExtension(const std::filesystem::path &p) {
return CBFSuffix(p).has_value();
}
// The sweep a file belongs to, as a template: everything before the trailing run of digits, the
// number of digits, and the extension. "o8_1_0042.cbf" -> {"o8_1_", 4}. A directory can hold several
// sweeps ("o8_1_*" beside "o8_2_*"), so collecting every .cbf in it would silently splice two
// crystals together; matching the template is what makes "point at any frame" safe.
struct Template {
std::string prefix;
size_t digits = 0;
std::string suffix = ".cbf"; // ".cbf" or ".cbf.gz"; the two are not one sweep
bool Matches(const std::string &name) const {
if (name.size() != prefix.size() + digits + suffix.size())
return false;
if (name.compare(0, prefix.size(), prefix) != 0)
return false;
if (name.compare(prefix.size() + digits, suffix.size(), suffix) != 0)
return false;
for (size_t i = 0; i < digits; i++)
if (!std::isdigit(static_cast<unsigned char>(name[prefix.size() + i])))
return false;
return true;
}
};
std::optional<Template> TemplateOf(const std::string &filename) {
const std::filesystem::path p(filename);
const auto suffix = CBFSuffix(p);
if (!suffix.has_value())
return {};
// The stem is the name with the whole matched suffix removed, which std::filesystem cannot do
// for ".cbf.gz" - its extension() there is ".gz" and its stem() still ends in ".cbf".
const std::string name = p.filename().string();
const std::string stem = name.substr(0, name.size() - suffix->size());
size_t end = stem.size();
while (end > 0 && std::isdigit(static_cast<unsigned char>(stem[end - 1])))
end--;
if (end == stem.size())
return {}; // no trailing number: not part of a numbered sweep
return Template{stem.substr(0, end), stem.size() - end, *suffix};
}
std::vector<std::string> CollectSweep(const std::string &path) {
std::filesystem::path p(path);
const bool is_dir = std::filesystem::is_directory(p);
// A frame named with no directory at all is in this one - parent_path() of a bare filename is
// empty, and iterating an empty path finds nothing, so naming a frame from inside its own
// directory found no sweep.
std::filesystem::path dir = is_dir ? p : p.parent_path();
if (dir.empty())
dir = ".";
// Naming a frame selects ITS sweep. Naming a directory selects the sweep with the most frames in
// it, which is the one a user pointing at a data directory means.
std::optional<Template> want;
if (!is_dir)
want = TemplateOf(p.filename().string());
std::map<std::tuple<std::string, size_t, std::string>, std::vector<std::string>> sweeps;
std::error_code ec;
for (const auto &e : std::filesystem::directory_iterator(dir, ec)) {
if (!e.is_regular_file() || !HasCBFExtension(e.path()))
continue;
const std::string name = e.path().filename().string();
const auto t = TemplateOf(name);
if (!t.has_value())
continue;
if (want.has_value() && !want->Matches(name))
continue;
sweeps[{t->prefix, t->digits, t->suffix}].push_back(e.path().string());
}
std::vector<std::string> out;
for (auto &[key, files] : sweeps)
if (files.size() > out.size())
out = std::move(files);
// The frame number is zero-padded in every PILATUS naming scheme in use, so within one template a
// plain sort is the collection order.
std::sort(out.begin(), out.end());
return out;
}
// An imgCIF laboratory direction in the internal frame. imgCIF puts Z from the sample towards the
// source and Y opposite gravity, while the internal frame has z along the beam and y along increasing
// row, so the two differ by a half turn about x - a rotation and not a mirror, so an axis carried
// through it turns the same way by the same angle. (writer/HDF5NXmx.cpp states the same relation from
// the other side, where it separates this from the McStas one, which is a half turn about z.)
Coord ImgCIFToInternal(const std::array<double, 3> &v) {
return {static_cast<float>(v[0]), static_cast<float>(-v[1]), static_cast<float>(-v[2])};
}
// The base rotation axis where a header states nothing about it, in the internal frame (x along
// increasing detector column, y along increasing row, z along the beam). It is a convention: such a
// header names its rotation axis but gives no direction, so this is the sign an NXmx master writes
// for the same instruments, and a file that needs the other one is settled from the data by the run's
// axis-sign rescue.
const Coord ASSUMED_BASE_AXIS(-1.0f, 0.0f, 0.0f);
// The base rotation axis of the instrument, in the internal frame.
//
// Two headers in every corpus examined here state it and were being overruled by the assumption. One
// beamline's PILATUS turns about the VERTICAL: its axis table says so outright, and its "# Oscillation
// _axis" line says so a second way, by naming the image direction the spindle runs along rather than a
// vector. Assuming the horizontal axis put the spindle 90 degrees out - which no amount of refinement
// recovers, and which a sign rescue cannot reach either, since it is not a sign - and the run indexed
// nothing. The sign is still the rescue's business; the DIRECTION is the file's.
Coord BaseAxis(const minicbf::Header &h) {
if (h.spindle_axis.has_value())
return ImgCIFToInternal(*h.spindle_axis);
if (h.spindle_along_slow)
return {0.0f, -1.0f, 0.0f}; // minus the slow direction, as the default is minus the fast
return ASSUMED_BASE_AXIS;
}
// How the stored image sits in the detector plane, where the header's axis table states it - the same
// thing the NXmx module directions say, in the form this format says it. Nothing comes back when the
// header carries no table, or when what it states is not one of the eight discrete orientations.
std::optional<DetectorOrientation> ImageOrientation(const minicbf::Header &h) {
if (!h.fast_direction.has_value() || !h.slow_direction.has_value())
return {};
return DetectorOrientation::Match(ImgCIFToInternal(*h.fast_direction),
ImgCIFToInternal(*h.slow_direction));
}
// The axis a miniCBF sweep turns about, in the internal frame (x along increasing detector column,
// y along increasing row, z along the beam).
//
// The base axis comes from the header where it states one (BaseAxis above) and is otherwise assumed.
// The sense of the omega rotation below follows the base axis, so a sweep parked at a non-zero omega
// inherits whichever direction it turns out to have.
//
// The head is base -> chi -> phi, so only the axes OUTSIDE the scanned one can tilt it. An omega
// scan turns about the base axis however the cradle is set - which is why a header carrying a large
// fixed chi still comes out as the base axis here - and only a phi scan is carried by chi and by
// omega. Chi turns about the beam, as the imgCIF axis convention has it, pointing back at the
// source; internal z points the other way, hence the minus. A head whose inner circle is inclined -
// a fixed-chi stage, a kappa arm - states that inclination in its axis table and nowhere else.
Coord RotationAxis(const minicbf::Header &h) {
const Coord base = BaseAxis(h);
if (!minicbf::ScansPhi(h))
return base;
const auto rad = [](double deg) { return static_cast<float>(deg * PI / 180.0); };
// Where the table states the inner axis outright, that IS the inclination, and the driven-circle
// angles do not describe it: a fixed-chi stage carries phi at a standing angle to the base with
// no chi circle to report, so "# Chi" reads zero while the axis is tens of degrees away.
const Coord chi_axis(0.0f, 0.0f, -1.0f);
const Coord tilted = h.inner_spindle_axis.has_value()
? ImgCIFToInternal(*h.inner_spindle_axis)
: RotMatrix(rad(h.chi_deg.value_or(0.0)), chi_axis) * base;
return RotMatrix(rad(h.omega_deg.value_or(0.0)), base) * tilted;
}
} // namespace
bool JFJochCBFReader::CanRead(const std::string &path) {
std::error_code ec;
if (std::filesystem::is_directory(path, ec))
return !CollectSweep(path).empty();
if (!HasCBFExtension(std::filesystem::path(path)))
return false;
try {
// A pixel size as well as the compression: a byte-offset CBF with no "# Pixel_size" line has
// no geometry, and XDS writes its correction files in exactly that shape. Claiming one here
// would open it with a pixel size of zero.
const auto h = minicbf::ReadHeader(path);
return h.byte_offset && h.pixel_x_m > 0.0 && h.pixel_y_m > 0.0;
} catch (const JFJochException &) {
return false;
}
}
void JFJochCBFReader::ReadFiles(const std::string &path) {
files_ = CollectSweep(path);
if (files_.empty())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"No CBF images found for " + path);
header0_ = minicbf::ReadHeader(files_[0]);
if (!header0_.byte_offset)
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Unsupported CBF compression (only x-CBF_BYTE_OFFSET)");
// Beside the compression, because a pixel size is what makes the file an IMAGE: every resolution,
// every scattering vector and the beam centre in millimetres scale by it, and the default of 0
// collapses all of them without a word. A byte-offset CBF with no "# Pixel_size" line is not a
// detector image from this family - XDS writes its correction files in exactly that shape.
if (!(header0_.pixel_x_m > 0.0) || !(header0_.pixel_y_m > 0.0))
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
files_[0] + " has no pixel size in its header (no '# Pixel_size' line); "
"it carries a CBF binary section but is not a detector image");
dataset_ = std::make_shared<JFJochReaderDataset>();
dataset_->experiment = default_experiment;
DetectorSetup detector = DetDECTRIS(header0_.nx, header0_.ny, header0_.detector, {});
detector.PixelSize_um(static_cast<int64_t>(std::lround(header0_.pixel_x_m * 1e6)));
detector.SensorThickness_um(static_cast<int64_t>(std::lround(header0_.thickness_m * 1e6)));
detector.SensorMaterial(header0_.material);
// Only when the header states one. Count_cutoff defaults to 0 and SaturationLimitFromValue(0) is
// 1, so an absent line marked EVERY pixel at or above one count as saturated - the integration
// accept gate then drops the whole reflection and the run comes out empty for a reason nothing
// reports. Left unset, DiffractionExperiment::GetSaturationLimit() falls back to the container's
// own overflow, which is the safe direction: it can only fail to call a pixel saturated.
if (header0_.count_cutoff > 0)
detector.SaturationLimit(SaturationLimitFromValue(header0_.count_cutoff));
else
Logger("CBFReader").Warning("{} states no Count_cutoff, so no pixel will be called "
"saturated; if this detector overloads, its strongest "
"reflections will be integrated as if they were valid.",
files_[0]);
// Images are handed out as signed 32-bit whatever the file stored, so that is the depth the rest
// of the code must see; the real overflow is the header's Count_cutoff, set above.
detector.BitDepthImage(32);
if (const auto orientation = ImageOrientation(header0_))
detector.ImageOrientation(orientation.value());
detector.MinFrameTime(std::chrono::microseconds(0));
detector.MinCountTime(std::chrono::microseconds(0));
detector.ReadOutTime(std::chrono::nanoseconds(0));
dataset_->experiment.Detector(detector);
dataset_->experiment.BeamX_pxl(static_cast<float>(header0_.beam_x_px));
dataset_->experiment.BeamY_pxl(static_cast<float>(header0_.beam_y_px));
dataset_->experiment.DetectorDistance_mm(static_cast<float>(header0_.distance_m * 1000.0));
// A detector swung out on a 2theta arm, which small-molecule collection uses routinely. The arm
// turns the detector about the sample, so it carries the square-on geometry with it: the header's
// Detector_distance stays the distance along the detector normal and Beam_xy stays the point of
// normal incidence, neither of which the swing moves - which is exactly what the PONI convention
// wants, so the swing is a PONI rotation and nothing else in the header changes. It turns about
// the axis the header's table gives the arm, and otherwise about the base spindle axis: on the
// four-circle geometry these headers describe the arm and the spindle are one axis, and the one
// header here that states both states them with the same vector.
if (header0_.two_theta_deg != 0.0) {
const Coord axis = header0_.detector_axis.has_value()
? ImgCIFToInternal(*header0_.detector_axis) : BaseAxis(header0_);
float rot1 = 0, rot2 = 0, rot3 = 0;
PoniAnglesFromMatrix(RotMatrix(static_cast<float>(header0_.two_theta_deg * PI / 180.0), axis),
rot1, rot2, rot3);
dataset_->experiment.PoniRot1_rad(rot1).PoniRot2_rad(rot2).PoniRot3_rad(rot3);
}
dataset_->experiment.IncidentEnergy_keV(WVL_1A_IN_KEV / static_cast<float>(header0_.wavelength_A));
dataset_->experiment.FrameTime(
std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::duration<double>(header0_.period_s)),
std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::duration<double>(header0_.exposure_s)));
// The rotation angle of every image, from its own header. Parsing one costs a few hundred
// microseconds - it is two dozen regular expressions - so on a sweep of several thousand frames
// this is seconds of startup before a single image is read, and the files are independent.
std::vector<double> angles(files_.size());
{
const size_t nthreads = std::min<size_t>(std::max(1u, std::thread::hardware_concurrency()), 8);
std::vector<std::future<void>> futures;
for (size_t t = 0; t < nthreads; t++)
futures.push_back(std::async(std::launch::async, [&, t] {
for (size_t i = t; i < files_.size(); i += nthreads)
angles[i] = minicbf::ReadHeader(files_[i]).start_angle_deg;
}));
for (auto &f : futures)
f.get();
}
double increment = header0_.angle_increment_deg;
if (files_.size() > 1) {
// Prefer the measured step over the header's nominal one, and unwrap a sweep that passes 360.
double d = angles[1] - angles[0];
if (d < -180.0) d += 360.0;
if (std::abs(d) > 1e-6) increment = d;
}
dataset_->experiment.Goniometer(GoniometerAxis(header0_.axis_name,
static_cast<float>(angles.front()),
static_cast<float>(increment),
RotationAxis(header0_), {}));
dataset_->error_value = -1;
dataset_->experiment.ImagesPerTrigger(static_cast<int64_t>(files_.size()));
// The untrusted pixels a PILATUS marks with a negative value: module gaps and the bad-pixel map.
// They are the same on every frame of a sweep, so frame 0 defines the mask.
std::vector<int32_t> first;
minicbf::Read(files_[0], first);
std::vector<uint32_t> mask(first.size(), 0);
for (size_t i = 0; i < first.size(); i++)
if (first[i] < 0)
mask[i] = 1;
dataset_->pixel_mask = std::make_shared<const PixelMask>(mask);
SetStartMessage(dataset_);
}
uint64_t JFJochCBFReader::GetNumberOfImages() const {
return files_.size();
}
void JFJochCBFReader::Close() {
files_.clear();
dataset_.reset();
}
template <class Buffer>
CompressedImage JFJochCBFReader::DecodeInto(int64_t image_number, Buffer &buffer,
std::vector<uint8_t> &scratch) const {
if (image_number < 0 || static_cast<size_t>(image_number) >= files_.size())
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Image number out of range");
const size_t npixel = static_cast<size_t>(header0_.nx) * static_cast<size_t>(header0_.ny);
buffer.resize(npixel * sizeof(int32_t));
// Decode straight into the caller's bytes: the pixels are plain int32 and nothing downstream has
// to decompress them, so NO_COMPRESSION over that buffer is the whole image.
const auto h = minicbf::ReadInto(files_[image_number],
reinterpret_cast<int32_t *>(buffer.data()), npixel, scratch);
if (static_cast<size_t>(h.nelem) != npixel)
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"CBF image size differs from the first image of the sweep");
return CompressedImage(buffer.data(), buffer.size(),
static_cast<size_t>(header0_.nx), static_cast<size_t>(header0_.ny),
CompressedImageMode::Int32, CompressionAlgorithm::NO_COMPRESSION);
}
bool JFJochCBFReader::LoadImage_i(std::shared_ptr<JFJochReaderDataset> &dataset,
DataMessage &message,
std::vector<uint8_t> &buffer,
int64_t image_number,
bool update_dataset) {
(void) update_dataset;
if (!dataset)
return false;
// The image must outlive this call, so it is decoded straight into the caller's buffer - the same
// thing the argument is for on the HDF5 path - and message.image only points at it.
std::vector<uint8_t> scratch;
message.image = DecodeInto(image_number, buffer, scratch);
message.number = image_number;
return true;
}
bool JFJochCBFReader::ReadRawImage(int64_t image_number, JFJochReaderRawImage &image) {
image.image = DecodeInto(image_number, image.image_buffer, image.read_buffer);
return true;
}
std::vector<SpotToSave> JFJochCBFReader::ReadSpots(int64_t) const {
return {}; // a raw CBF stores no analysis results
}