reader: read a PILATUS miniCBF sweep natively, without libcbf
Most facilities still archive rotation data as a directory of miniCBF frames, which until now had to be converted to HDF5 before rugnux could see it. Nothing in that format needs a CIF parser or a library: it is an ASCII header, four separator bytes, then one byte-offset compressed image, and every value the reader wants sits on a "# " comment line or a MIME line. MiniCBF holds the format itself - header parse and the byte-offset decoder, which is a running value with deltas stored smallest-container-first. Verified byte-exact against dxtbx on PILATUS 6M, 6M-F, 300K, silicon and CdTe sensors, and three sensor thicknesses. JFJochCBFReader is a sibling of JFJochHDF5Reader under the JFJochReader base. NAMING ANY FRAME READS ITS WHOLE SWEEP: the sweep is identified by the template (prefix + digit count) the named frame belongs to, not by "every .cbf in the directory", so a directory holding two sweeps does not splice two crystals together. Naming a directory takes the sweep with the most frames in it. Images decode on demand, one per call, so any number of workers can read at once - there is no global lock as there is on the HDF5 path, HDF5 not being thread-safe. A raw CBF carries no analysis results, so the dataset it builds is the geometry, the mask and nothing else, exactly as a plain DECTRIS file with no /entry/MX gives. Two header quirks are handled because real files have them: the sensor material is written "Silicon" where the rest of the code compares against "CdTe", and the thickness unit is sometimes omitted. Headers are not a fixed size either - one set carries 6335 bytes - so the parse runs to the binary separator rather than over a fixed prefix. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,10 @@ ADD_LIBRARY(JFJochReader STATIC
|
||||
JFJochReader.cpp JFJochReader.h
|
||||
JFJochHDF5Reader.cpp
|
||||
JFJochHDF5Reader.h
|
||||
MiniCBF.cpp
|
||||
MiniCBF.h
|
||||
JFJochCBFReader.cpp
|
||||
JFJochCBFReader.h
|
||||
HDF5ImageLocator.cpp
|
||||
HDF5ImageLocator.h
|
||||
HDF5ImageSource.cpp
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// 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 <map>
|
||||
#include <optional>
|
||||
|
||||
#include "../common/JFJochException.h"
|
||||
#include "../common/JFJochMath.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool HasCBFExtension(const std::filesystem::path &p) {
|
||||
std::string ext = p.extension().string();
|
||||
std::transform(ext.begin(), ext.end(), ext.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
return ext == ".cbf";
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
bool Matches(const std::string &name) const {
|
||||
if (name.size() != prefix.size() + digits + 4) // + ".cbf"
|
||||
return false;
|
||||
if (name.compare(0, prefix.size(), prefix) != 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);
|
||||
if (!HasCBFExtension(p))
|
||||
return {};
|
||||
const std::string stem = p.stem().string();
|
||||
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};
|
||||
}
|
||||
|
||||
std::vector<std::string> CollectSweep(const std::string &path) {
|
||||
std::filesystem::path p(path);
|
||||
const bool is_dir = std::filesystem::is_directory(p);
|
||||
const std::filesystem::path dir = is_dir ? p : p.parent_path();
|
||||
|
||||
// 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::pair<std::string, size_t>, 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}].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;
|
||||
}
|
||||
|
||||
} // 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 {
|
||||
return minicbf::ReadHeader(path).byte_offset;
|
||||
} 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)");
|
||||
|
||||
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);
|
||||
detector.SaturationLimit(SaturationLimitFromValue(header0_.count_cutoff));
|
||||
// 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);
|
||||
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));
|
||||
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. A miniCBF names the axis but never
|
||||
// gives its direction, so the sign here is a convention: take the one an NXmx master writes, and
|
||||
// leave the run's axis-sign rescue to try the other if this one does not index.
|
||||
std::vector<double> angles(files_.size());
|
||||
for (size_t i = 0; i < files_.size(); i++)
|
||||
angles[i] = minicbf::ReadHeader(files_[i]).start_angle_deg;
|
||||
|
||||
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),
|
||||
Coord(-1.0f, 0.0f, 0.0f), {}));
|
||||
|
||||
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) 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);
|
||||
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.
|
||||
message.image = DecodeInto(image_number, buffer);
|
||||
message.number = image_number;
|
||||
return true;
|
||||
}
|
||||
|
||||
std::shared_ptr<JFJochReaderRawImage> JFJochCBFReader::GetRawImage(int64_t image_number) {
|
||||
auto ret = std::make_shared<JFJochReaderRawImage>();
|
||||
ret->image = DecodeInto(image_number, ret->image_buffer);
|
||||
return ret;
|
||||
}
|
||||
|
||||
std::vector<SpotToSave> JFJochCBFReader::ReadSpots(int64_t) const {
|
||||
return {}; // a raw CBF stores no analysis results
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "JFJochReader.h"
|
||||
#include "MiniCBF.h"
|
||||
|
||||
// Reads a rotation sweep straight from a directory of PILATUS miniCBF files - the form most
|
||||
// facilities still archive - with no conversion step and no libcbf.
|
||||
//
|
||||
// The sweep's geometry comes from the first file's header; the rotation angle of each image comes
|
||||
// from its own header, which costs only the bytes up to the binary separator. Images are decoded on
|
||||
// demand, one per call, so any number of workers can read at once - there is no global lock as there
|
||||
// is on the HDF5 path, HDF5 not being thread-safe.
|
||||
//
|
||||
// A raw CBF carries no analysis results, so this reader has no spots, no reflections and no snapshots;
|
||||
// the dataset it builds is the geometry, the mask and nothing else, exactly as a plain DECTRIS file
|
||||
// with no /entry/MX would give.
|
||||
class JFJochCBFReader : public JFJochReader {
|
||||
std::vector<std::string> files_;
|
||||
std::shared_ptr<JFJochReaderDataset> dataset_;
|
||||
minicbf::Header header0_;
|
||||
|
||||
bool LoadImage_i(std::shared_ptr<JFJochReaderDataset> &dataset,
|
||||
DataMessage &message,
|
||||
std::vector<uint8_t> &buffer,
|
||||
int64_t image_number,
|
||||
bool update_dataset) override;
|
||||
|
||||
// Decodes one image into the caller's byte buffer (RawByteBuffer or std::vector<uint8_t>) and
|
||||
// returns the image that points at it.
|
||||
template <class Buffer>
|
||||
CompressedImage DecodeInto(int64_t image_number, Buffer &buffer) const;
|
||||
|
||||
public:
|
||||
~JFJochCBFReader() override = default;
|
||||
|
||||
// True if the path names something this reader can open: a miniCBF file, or a directory holding
|
||||
// at least one. Cheap - it reads a few kB at most.
|
||||
static bool CanRead(const std::string &path);
|
||||
|
||||
// path is a directory of *.cbf, or one *.cbf inside the sweep to take the whole directory from.
|
||||
void ReadFiles(const std::string &path);
|
||||
|
||||
[[nodiscard]] uint64_t GetNumberOfImages() const override;
|
||||
void Close() override;
|
||||
|
||||
std::shared_ptr<JFJochReaderRawImage> GetRawImage(int64_t image_number) override;
|
||||
[[nodiscard]] std::vector<SpotToSave> ReadSpots(int64_t image) const override;
|
||||
};
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include "MiniCBF.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <limits>
|
||||
#include <regex>
|
||||
|
||||
#include "../common/JFJochException.h"
|
||||
|
||||
namespace minicbf {
|
||||
|
||||
namespace {
|
||||
|
||||
// One capture group, first match, or nothing. The headers are a few kB, so a regex per field is
|
||||
// cheap and keeps each rule next to the thing it reads.
|
||||
std::optional<std::string> Match(const std::string &text, const char *pattern) {
|
||||
std::smatch m;
|
||||
const std::regex re(pattern);
|
||||
if (!std::regex_search(text, m, re) || m.size() < 2)
|
||||
return {};
|
||||
return m[1].str();
|
||||
}
|
||||
|
||||
double Num(const std::string &text, const char *pattern, double fallback = 0.0) {
|
||||
const auto s = Match(text, pattern);
|
||||
return s.has_value() ? std::stod(*s) : fallback;
|
||||
}
|
||||
|
||||
int64_t Int(const std::string &text, const char *pattern, int64_t fallback = 0) {
|
||||
const auto s = Match(text, pattern);
|
||||
return s.has_value() ? std::stoll(*s) : fallback;
|
||||
}
|
||||
|
||||
int16_t ReadI16(const uint8_t *p) { int16_t v; std::memcpy(&v, p, 2); return v; }
|
||||
int32_t ReadI32(const uint8_t *p) { int32_t v; std::memcpy(&v, p, 4); return v; }
|
||||
int64_t ReadI64(const uint8_t *p) { int64_t v; std::memcpy(&v, p, 8); return v; }
|
||||
|
||||
} // namespace
|
||||
|
||||
std::optional<size_t> FindBinarySection(const uint8_t *data, size_t size) {
|
||||
if (size < sizeof(BINARY_SEPARATOR))
|
||||
return {};
|
||||
const auto *end = data + size;
|
||||
const auto *hit = std::search(data, end, std::begin(BINARY_SEPARATOR), std::end(BINARY_SEPARATOR));
|
||||
if (hit == end)
|
||||
return {};
|
||||
return static_cast<size_t>(hit - data) + sizeof(BINARY_SEPARATOR);
|
||||
}
|
||||
|
||||
Header ParseHeader(const char *data, size_t size) {
|
||||
const std::string t(data, size);
|
||||
Header h;
|
||||
|
||||
h.detector = Match(t, R"(#\s*Detector:\s*([^\r\n]+))").value_or("PILATUS");
|
||||
h.pixel_x_m = Num(t, R"(#\s*Pixel_size\s+([\d.eE+-]+)\s*m)");
|
||||
h.pixel_y_m = Num(t, R"(#\s*Pixel_size\s+[\d.eE+-]+\s*m\s*x\s*([\d.eE+-]+)\s*m)");
|
||||
// The unit is optional: one ALBA set writes "thickness 0.001000" with no " m" after it.
|
||||
h.thickness_m = Num(t, R"(sensor,\s*thickness\s+([\d.eE+-]+))");
|
||||
h.distance_m = Num(t, R"(#\s*Detector_distance\s+([\d.eE+-]+))");
|
||||
h.beam_x_px = Num(t, R"(#\s*Beam_xy\s*\(\s*([\d.eE+-]+))");
|
||||
h.beam_y_px = Num(t, R"(#\s*Beam_xy\s*\([^,]+,\s*([\d.eE+-]+))");
|
||||
h.wavelength_A = Num(t, R"(#\s*Wavelength\s+([\d.eE+-]+))");
|
||||
h.start_angle_deg = Num(t, R"(#\s*Start_angle\s+([\d.eE+-]+))");
|
||||
h.angle_increment_deg = Num(t, R"(#\s*Angle_increment\s+([\d.eE+-]+))");
|
||||
h.two_theta_deg = Num(t, R"(#\s*Detector_2theta\s+([\d.eE+-]+))");
|
||||
h.exposure_s = Num(t, R"(#\s*Exposure_time\s+([\d.eE+-]+))");
|
||||
h.period_s = Num(t, R"(#\s*Exposure_period\s+([\d.eE+-]+))");
|
||||
h.count_cutoff = Int(t, R"(#\s*Count_cutoff\s+(\d+))");
|
||||
h.axis_name = Match(t, R"(#\s*Oscillation_axis\s+(\S+))").value_or("omega");
|
||||
|
||||
// "# Silicon sensor, ..." / "# CdTe sensor, ...". The rest of the code compares the material
|
||||
// against "CdTe" (BraggIntegrationEngine), so an unnormalised "Silicon" would silently give a
|
||||
// CdTe sensor silicon's attenuation length.
|
||||
if (const auto m = Match(t, R"(#\s*(\w+)\s+sensor,)")) {
|
||||
std::string s = *m;
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
|
||||
h.material = (s == "cdte") ? "CdTe" : "Si";
|
||||
}
|
||||
|
||||
h.nx = Int(t, R"(X-Binary-Size-Fastest-Dimension:\s*(\d+))");
|
||||
h.ny = Int(t, R"(X-Binary-Size-Second-Dimension:\s*(\d+))");
|
||||
h.nelem = Int(t, R"(X-Binary-Number-of-Elements:\s*(\d+))");
|
||||
|
||||
const auto conv = Match(t, R"RE(conversions\s*=\s*"([^"]+)")RE").value_or("");
|
||||
h.byte_offset = conv.find("x-CBF_BYTE_OFFSET") != std::string::npos;
|
||||
|
||||
if (h.period_s <= 0.0)
|
||||
h.period_s = h.exposure_s;
|
||||
|
||||
return h;
|
||||
}
|
||||
|
||||
void DecodeByteOffset(const uint8_t *data, size_t size, int32_t *out, size_t n_pixels) {
|
||||
int64_t value = 0;
|
||||
size_t pos = 0;
|
||||
size_t written = 0;
|
||||
|
||||
while (written < n_pixels) {
|
||||
if (pos >= size)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"miniCBF byte-offset stream ended after " + std::to_string(written)
|
||||
+ " of " + std::to_string(n_pixels) + " pixels");
|
||||
|
||||
int64_t delta = static_cast<int8_t>(data[pos]);
|
||||
pos += 1;
|
||||
|
||||
if (delta == -128) {
|
||||
if (pos + 2 > size) break;
|
||||
delta = ReadI16(data + pos);
|
||||
pos += 2;
|
||||
if (delta == -32768) {
|
||||
if (pos + 4 > size) break;
|
||||
delta = ReadI32(data + pos);
|
||||
pos += 4;
|
||||
if (delta == INT32_MIN) {
|
||||
if (pos + 8 > size) break;
|
||||
delta = ReadI64(data + pos);
|
||||
pos += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
value += delta;
|
||||
out[written++] = static_cast<int32_t>(value);
|
||||
}
|
||||
|
||||
if (written != n_pixels)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"miniCBF byte-offset stream ended after " + std::to_string(written)
|
||||
+ " of " + std::to_string(n_pixels) + " pixels");
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
std::vector<uint8_t> Slurp(const std::string &path, size_t max_bytes) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"Cannot open CBF file " + path);
|
||||
f.seekg(0, std::ios::end);
|
||||
const auto file_size = static_cast<size_t>(f.tellg());
|
||||
f.seekg(0, std::ios::beg);
|
||||
std::vector<uint8_t> buf(std::min(file_size, max_bytes));
|
||||
f.read(reinterpret_cast<char *>(buf.data()), static_cast<std::streamsize>(buf.size()));
|
||||
buf.resize(static_cast<size_t>(f.gcount()));
|
||||
return buf;
|
||||
}
|
||||
|
||||
// Enough to reach the separator on any header seen in the wild (the longest measured is ~6.3 kB).
|
||||
constexpr size_t HEADER_PROBE_BYTES = 256 * 1024;
|
||||
|
||||
} // namespace
|
||||
|
||||
Header ReadHeader(const std::string &path) {
|
||||
const auto buf = Slurp(path, HEADER_PROBE_BYTES);
|
||||
const auto start = FindBinarySection(buf.data(), buf.size());
|
||||
if (!start.has_value())
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"No CBF binary section in " + path);
|
||||
return ParseHeader(reinterpret_cast<const char *>(buf.data()),
|
||||
*start - sizeof(BINARY_SEPARATOR));
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// One read of the file, header parsed, dimensions checked. The caller supplies where the pixels go.
|
||||
Header ReadCommon(const std::string &path, std::vector<uint8_t> &buf, size_t &binary_start) {
|
||||
buf = Slurp(path, std::numeric_limits<size_t>::max());
|
||||
const auto start = FindBinarySection(buf.data(), buf.size());
|
||||
if (!start.has_value())
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"No CBF binary section in " + path);
|
||||
|
||||
const Header h = ParseHeader(reinterpret_cast<const char *>(buf.data()),
|
||||
*start - sizeof(BINARY_SEPARATOR));
|
||||
if (!h.byte_offset)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"Unsupported CBF compression in " + path + " (only x-CBF_BYTE_OFFSET)");
|
||||
if (h.nelem <= 0 || h.nx <= 0 || h.ny <= 0 || h.nelem != h.nx * h.ny)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"Inconsistent image dimensions in " + path);
|
||||
|
||||
binary_start = *start;
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
Header Read(const std::string &path, std::vector<int32_t> &out) {
|
||||
std::vector<uint8_t> buf;
|
||||
size_t start = 0;
|
||||
const Header h = ReadCommon(path, buf, start);
|
||||
out.resize(static_cast<size_t>(h.nelem));
|
||||
DecodeByteOffset(buf.data() + start, buf.size() - start, out.data(),
|
||||
static_cast<size_t>(h.nelem));
|
||||
return h;
|
||||
}
|
||||
|
||||
Header ReadInto(const std::string &path, int32_t *out, size_t capacity) {
|
||||
std::vector<uint8_t> buf;
|
||||
size_t start = 0;
|
||||
const Header h = ReadCommon(path, buf, start);
|
||||
if (static_cast<size_t>(h.nelem) > capacity)
|
||||
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
||||
"CBF image does not fit the supplied buffer: " + path);
|
||||
DecodeByteOffset(buf.data() + start, buf.size() - start, out, static_cast<size_t>(h.nelem));
|
||||
return h;
|
||||
}
|
||||
|
||||
} // namespace minicbf
|
||||
@@ -0,0 +1,64 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <cstdint>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// PILATUS miniCBF: an ASCII header, four separator bytes, then one byte-offset compressed image.
|
||||
// No CIF parser and no libcbf are needed - every value is on a "# " comment line or a MIME line.
|
||||
namespace minicbf {
|
||||
|
||||
// The four bytes that end the MIME header and begin the binary section.
|
||||
inline constexpr unsigned char BINARY_SEPARATOR[4] = {0x0c, 0x1a, 0x04, 0xd5};
|
||||
|
||||
struct Header {
|
||||
std::string detector; // "PILATUS3 6M, S/N 60-0136"
|
||||
int64_t nx = 0; // fast dimension (columns)
|
||||
int64_t ny = 0; // slow dimension (rows)
|
||||
int64_t nelem = 0; // pixel count declared by the MIME header
|
||||
double pixel_x_m = 0;
|
||||
double pixel_y_m = 0;
|
||||
double thickness_m = 0;
|
||||
std::string material = "Si"; // NORMALISED: the file says "Silicon", the rest of the code wants "Si"
|
||||
double distance_m = 0;
|
||||
double beam_x_px = 0;
|
||||
double beam_y_px = 0;
|
||||
double wavelength_A = 0;
|
||||
double start_angle_deg = 0;
|
||||
double angle_increment_deg = 0;
|
||||
double two_theta_deg = 0;
|
||||
double exposure_s = 0;
|
||||
double period_s = 0;
|
||||
int64_t count_cutoff = 0; // saturation
|
||||
std::string axis_name = "omega";
|
||||
bool byte_offset = false; // the only conversion supported
|
||||
};
|
||||
|
||||
// Byte offset of the binary section (just past the separator), or nothing if there is none.
|
||||
std::optional<size_t> FindBinarySection(const uint8_t *data, size_t size);
|
||||
|
||||
// Parse the ASCII header. Pass the bytes BEFORE the separator; headers are not a fixed size (one
|
||||
// Diamond I24 set carries 6335 bytes, well past a 4 kB guess), so never parse a fixed prefix.
|
||||
Header ParseHeader(const char *data, size_t size);
|
||||
|
||||
// x-CBF_BYTE_OFFSET -> int32. Deltas against a running value, smallest container first: int8,
|
||||
// escaping to int16 via -128, to int32 via -32768, to int64 via INT32_MIN. Little-endian, packed.
|
||||
// Throws if the stream ends before n_pixels are produced. out must hold n_pixels.
|
||||
void DecodeByteOffset(const uint8_t *data, size_t size, int32_t *out, size_t n_pixels);
|
||||
|
||||
// Header + pixels of one file, read from disk.
|
||||
Header Read(const std::string &path, std::vector<int32_t> &out);
|
||||
|
||||
// The same, decoding into memory the caller already has (one read of the file, no extra copy).
|
||||
// Throws if the image does not fit in capacity pixels.
|
||||
Header ReadInto(const std::string &path, int32_t *out, size_t capacity);
|
||||
|
||||
// Header only - reads just enough of the file to reach the separator. Cheap enough to call per
|
||||
// frame for the rotation angles.
|
||||
Header ReadHeader(const std::string &path);
|
||||
|
||||
} // namespace minicbf
|
||||
Reference in New Issue
Block a user