Build Packages / build:windows:nocuda (push) Successful in 17m20s
Build Packages / build:windows:cuda (push) Successful in 19m52s
Build Packages / build:viewer-tgz:cpu (push) Successful in 9m38s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m18s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m34s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m42s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 20m33s
Build Packages / Create release (push) Successful in 33s
Build Packages / build:rugnux:windows (push) Successful in 12m0s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m42s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 14m59s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m8s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 14m35s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 16m55s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 16m58s
Build Packages / Generate python client (push) Successful in 16s
Build Packages / build:rpm (rocky8) (push) Successful in 15m21s
Build Packages / Build documentation (push) Successful in 54s
Build Packages / build:rpm (rocky9) (push) Successful in 16m23s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m2s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 10m6s
Build Packages / Unit tests (push) Successful in 1h10m26s
* Rugnux: basic support for CCD images (marCCD, SMV) and for gzipped miniCBF. * `jfjoch_viewer`: opens the CCD formats, and fixes to the dataset plots. * Documentation updates. Reviewed-on: #81 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
201 lines
10 KiB
C++
201 lines
10 KiB
C++
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochSMVReader.h"
|
|
|
|
#include <cmath>
|
|
#include <future>
|
|
#include <thread>
|
|
|
|
#include "../common/JFJochException.h"
|
|
#include "../common/JFJochMath.h"
|
|
#include "../common/Logger.h"
|
|
|
|
namespace {
|
|
|
|
// The base rotation axis in the internal frame (x along increasing detector column, y along
|
|
// increasing row, z along the beam). A SMV header names the circle that turned but never states
|
|
// a direction for it, so this is the convention an NXmx master writes for the same instruments, and
|
|
// a file that needs the other sign is settled from the data by the run's axis-sign rescue - the
|
|
// same arrangement JFJochCBFReader makes for a miniCBF that states no axis table.
|
|
const Coord ASSUMED_BASE_AXIS(-1.0f, 0.0f, 0.0f);
|
|
|
|
} // namespace
|
|
|
|
bool JFJochSMVReader::CanRead(const std::string &path) {
|
|
return smv::CanRead(path);
|
|
}
|
|
|
|
void JFJochSMVReader::ReadFiles(const std::string &path) {
|
|
files_ = smv::CollectSweep(path);
|
|
if (files_.empty())
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
"No SMV images found for " + path);
|
|
|
|
header0_ = smv::ReadHeader(files_[0]);
|
|
// 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 a default of 0 collapses all of them without
|
|
// a word.
|
|
if (!(header0_.pixel_x_m > 0.0) || !(header0_.pixel_y_m > 0.0))
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
files_[0] + " states no pixel size in its SMV header");
|
|
if (!(header0_.wavelength_A > 0.0))
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
files_[0] + " states no wavelength in its SMV header");
|
|
if (!(header0_.distance_m > 0.0))
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
files_[0] + " states no detector distance in its SMV header");
|
|
|
|
dataset_ = std::make_shared<JFJochReaderDataset>();
|
|
dataset_->experiment = default_experiment;
|
|
|
|
DetectorSetup detector = DetDECTRIS(header0_.nx, header0_.ny,
|
|
header0_.detector.empty() ? "SMV" : header0_.detector, {});
|
|
// Not rounded to whole micrometres, as the miniCBF path can afford to be: a PILATUS pixel is
|
|
// exactly 172 um, but these are 73.242 um, and rounding that to 73 is a 0.33% scale error on
|
|
// every cell edge the run reports.
|
|
detector.PixelSize_um(static_cast<float>(header0_.pixel_x_m * 1e6));
|
|
// A CCD has no sensor thickness worth correcting for: the phosphor converts at the surface and
|
|
// the fibre optic carries light, not X-rays, so the parallax correction a silicon sensor needs
|
|
// does not apply. Left at zero, which is what the geometry means by "no depth".
|
|
detector.SensorThickness_um(0);
|
|
// SMV states no saturation value at all - unlike marCCD, which at least carries one - so the
|
|
// container's own overflow is what decides, which is the safe direction: it can only fail to
|
|
// call a pixel saturated, never call a valid one an overload. Said out loud because a CCD at
|
|
// the top of its range really does saturate.
|
|
Logger("SMVReader").Warning("{}: the SMV format states no saturation value, so saturation is "
|
|
"judged on the 16-bit container alone; a detector that overloads "
|
|
"below 65535 will have its strongest reflections 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.
|
|
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));
|
|
|
|
// A detector swung out on a 2theta arm. The arm turns the detector about the sample and so
|
|
// carries the square-on geometry with it: the header's distance stays the distance along the
|
|
// detector normal and the beam centre stays the point of normal incidence, which is exactly
|
|
// what the PONI convention wants, so the swing is a PONI rotation and nothing else changes.
|
|
// The arm turns about the same axis as the spindle on the geometries these headers describe.
|
|
if (header0_.two_theta_deg != 0.0) {
|
|
float rot1 = 0, rot2 = 0, rot3 = 0;
|
|
PoniAnglesFromMatrix(RotMatrix(static_cast<float>(header0_.two_theta_deg * PI / 180.0),
|
|
ASSUMED_BASE_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));
|
|
// Only when the header states a sane one. The exposure field is not always filled in: one
|
|
// deposited sweep carries -2093438692 there, which is not a time at all, and passing it on
|
|
// refuses the whole dataset over a number that affects no geometry and no result. A day is a
|
|
// generous upper bound for a single frame.
|
|
if (header0_.exposure_s > 0.0 && header0_.exposure_s < 86400.0)
|
|
dataset_->experiment.FrameTime(
|
|
std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
std::chrono::duration<double>(header0_.exposure_s)),
|
|
std::chrono::duration_cast<std::chrono::nanoseconds>(
|
|
std::chrono::duration<double>(header0_.exposure_s)));
|
|
else
|
|
Logger("SMVReader").Warning("{} states an implausible exposure time ({} s); the "
|
|
"default frame time is kept. Nothing in the geometry or the "
|
|
"merge depends on it.", files_[0], header0_.exposure_s);
|
|
|
|
// The rotation angle of every image, from its own header. Reading one costs a 4 kB read, so on
|
|
// a sweep of several thousand frames this is worth spreading over the cores, as the CBF path
|
|
// does for the same reason.
|
|
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] = smv::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),
|
|
ASSUMED_BASE_AXIS, {}));
|
|
|
|
dataset_->error_value = -1;
|
|
dataset_->experiment.ImagesPerTrigger(static_cast<int64_t>(files_.size()));
|
|
// A CCD frame stores no untrusted-pixel marker - every value is a real reading, and the
|
|
// detector has no module gaps - so the sweep starts with nothing masked.
|
|
dataset_->pixel_mask = std::make_shared<const PixelMask>(static_cast<size_t>(header0_.nx),
|
|
static_cast<size_t>(header0_.ny));
|
|
|
|
SetStartMessage(dataset_);
|
|
}
|
|
|
|
uint64_t JFJochSMVReader::GetNumberOfImages() const {
|
|
return files_.size();
|
|
}
|
|
|
|
void JFJochSMVReader::Close() {
|
|
files_.clear();
|
|
dataset_.reset();
|
|
}
|
|
|
|
template <class Buffer>
|
|
CompressedImage JFJochSMVReader::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));
|
|
|
|
const auto h = smv::ReadInto(files_[image_number],
|
|
reinterpret_cast<int32_t *>(buffer.data()), npixel, scratch);
|
|
if (h.nx != header0_.nx || h.ny != header0_.ny)
|
|
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
|
|
"SMV 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 JFJochSMVReader::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;
|
|
|
|
std::vector<uint8_t> scratch;
|
|
message.image = DecodeInto(image_number, buffer, scratch);
|
|
message.number = image_number;
|
|
return true;
|
|
}
|
|
|
|
bool JFJochSMVReader::ReadRawImage(int64_t image_number, JFJochReaderRawImage &image) {
|
|
image.image = DecodeInto(image_number, image.image_buffer, image.read_buffer);
|
|
return true;
|
|
}
|
|
|
|
std::vector<SpotToSave> JFJochSMVReader::ReadSpots(int64_t) const {
|
|
return {}; // a raw SMV file stores no analysis results
|
|
}
|