Files
leonarski_fandClaude Opus 5 391736425f miniCBF: read the compressed file through a buffer the caller keeps
Slurp() returned a fresh std::vector per call, so every frame allocated the
whole compressed file - 18 MB on a 16 Mpx detector - and value-initialised it
before the read overwrote every byte. Measured on one such frame that is 9.2 ms
against 5.4 ms into a buffer that is already there.

ReadInto() now takes the scratch, and the raw image carries it, so a worker
reading a sweep reads every file into the same bytes. Slurp() fills a buffer
instead of returning one, which is also what makes the resize cost nothing when
the next file is the same size as the last.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 20:14:04 +02:00

97 lines
5.1 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#pragma once
#include <array>
#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;
// Goniometer head angles and the per-image increment of each, in degrees. A writer spells "this
// head has no such axis" as -9999, which is a sentinel and not an angle, so an axis the header
// does not really carry is absent here rather than 9999 degrees away from zero.
std::optional<double> chi_deg;
std::optional<double> omega_deg;
double chi_increment_deg = 0;
double phi_increment_deg = 0;
double omega_increment_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
// The imgCIF axis table the CBF template block carries after the "# " lines, as the file states
// it: vectors in the imgCIF laboratory frame (Z from the sample towards the source, Y opposite
// gravity, X completing a right-handed set). Turning them into any other frame is the caller's
// business, not this format reader's. Most headers carry no such table and leave all of these
// empty, which is not the same as their stating the usual mounting.
std::optional<std::array<double, 3>> fast_direction; // laboratory direction of a +1 column step
std::optional<std::array<double, 3>> slow_direction; // ... of a +1 row step
std::optional<std::array<double, 3>> spindle_axis; // the base goniometer rotation axis
std::optional<std::array<double, 3>> detector_axis; // the detector's own rotation axis: a 2theta arm
// "# Oscillation_axis X.CW +SLOW". Some writers say which of the two image directions the spindle
// runs along instead of stating a vector. The axis NAME on that line is a poor discriminator (see
// ScansPhi, and note that the one header carrying both a name and a table says "X" where its table
// says Y) but this token is not: where both are present they agree.
bool spindle_along_slow = false;
};
// Whether the sweep turns PHI rather than the base (omega) axis. What moved is the axis with a
// non-zero increment - a header routinely carries a "# Phi" angle for a phi that stands still - so
// the name is consulted only when no increment is stated at all. It is a poor discriminator: some
// writers put a direction convention there ("X.CW") instead of an axis name.
bool ScansPhi(const Header &h);
// 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).
// The compressed file is read through scratch, which the caller keeps between frames: a fresh
// vector per image is megabytes of allocation and of zeroing nothing ever reads.
// Throws if the image does not fit in capacity pixels.
Header ReadInto(const std::string &path, int32_t *out, size_t capacity, std::vector<uint8_t> &scratch);
// 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