Files
Jungfraujoch/preview/PreviewImage.cpp
T
leonarski_fandClaude Fable 5 a4559e576d Preview: geometry-following arcs, true beam center, unified colours, predictions
Draw the resolution ring and azimuthal-ROI outlines by sweeping ResPhiToPxl
through the geometry (a shared DrawArc/DrawThickLine helper), so they trace the
true conic on a tilted detector instead of PONI-centred circles. Draw the beam
crosshair at GetDirectBeam_pxl() (the real beam/detector intersection) rather
than the stored PONI.

Unify the spot overlay colours with the viewer: green = not indexed, magenta =
indexed (primary lattice), cyan = ice ring, coral = secondary/further lattice.
Add Bragg-prediction overlay (dark-red circles, skipping systematic absences)
gated on a new show_predictions preview setting, wired through the OpenAPI spec,
the image.jpeg HTTP handler, and the frontend toggle (+regenerated TS client).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:04:00 +02:00

475 lines
20 KiB
C++

// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <algorithm>
#include <cmath>
#include <future>
#include <optional>
#include <thread>
#include "PreviewImage.h"
#include "JFJochJPEG.h"
#include "JFJochTIFF.h"
#include "../common/JFJochException.h"
#include "../common/JFJochMath.h"
#include "../common/DiffractionGeometry.h"
#include "../frame_serialize/CBORStream2Deserializer.h"
#include "../compression/JFJochDecompress.h"
#include "../image_analysis/bragg_integration/SystematicAbsence.h"
constexpr const static rgb lime = {.r = 0xcd, .g = 0xdc, .b = 0x39};
constexpr const static rgb pink = {.r = 0xe9, .g = 0x1e, .b = 0x63};
constexpr const static rgb purple = {.r = 0x7b, .g = 0x1f, .b = 0xA2};
constexpr const static rgb orange = {.r = 0xff, .g = 0x57, .b = 0x22};
constexpr const static rgb amber = {.r =0xff, .g = 0xc1, .b = 0x07};
constexpr const static rgb blue = {.r = 0x0d, .g = 0x47, .b = 0xa1};
constexpr const static rgb cyan = {.r = 0x00, .g = 0xff, .b = 0xff}; // "ice" color
// Spot/prediction overlay palette, matching the jfjoch_viewer diffraction image so the two
// front-ends look identical: green = not indexed, magenta = indexed (primary lattice),
// cyan = on an ice ring, coral = secondary/further lattice, dark red = Bragg prediction.
constexpr const static rgb green = {.r = 0x00, .g = 0xff, .b = 0x00};
constexpr const static rgb magenta = {.r = 0xff, .g = 0x00, .b = 0xff};
constexpr const static rgb coral = {.r = 0xfa, .g = 0x72, .b = 0x68};
constexpr const static rgb dark_red = {.r = 0x80, .g = 0x00, .b = 0x00};
constexpr const static rgb plotly[] = {{0x1f, 0x77, 0xb4},
{0xff, 0x7f, 0x0e},
{0x2c, 0xa0, 0x2c},
{0xd6, 0x27, 0x28},
{0x94, 0x67, 0xbd},
{0x8c, 0x56, 0x4b},
{0xe3, 0x77, 0xc2},
{0x7f, 0x7f, 0x7f},
{0xbd, 0xbd, 0x22},
{0x17, 0xbe, 0xcf}};
constexpr const static rgb gray = {.r = 0xbe, .g = 0xbe, .b = 0xbe};
void PreviewImage::color_pixel(std::vector<rgb> &ret, int64_t in_xpixel, int64_t in_ypixel,const rgb &color) const {
if ((in_xpixel >= 0) && (in_xpixel < xpixel) && (in_ypixel >= 0) && (in_ypixel < ypixel))
ret[(in_ypixel * xpixel + in_xpixel)] = color;
}
void PreviewImage::spot(std::vector<rgb> &ret, int64_t in_xpixel, int64_t in_ypixel, const rgb &color) const {
color_pixel(ret, in_xpixel, in_ypixel, color);
}
void PreviewImage::roi(std::vector<rgb> &ret, int64_t in_xpixel, int64_t in_ypixel, int64_t roi_number) const {
color_pixel(ret, in_xpixel, in_ypixel, plotly[roi_number % 10]);
}
template<class T>
std::vector<rgb> PreviewImage::GenerateRGB(const uint8_t *value_8,
int64_t special_value_64,
int64_t sat_value_64,
const ColorScale &scale,
const PreviewImageSettings &settings) const {
auto value = reinterpret_cast<const T *>(value_8);
auto special_value = static_cast<T>(special_value_64);
float background = settings.background_value.value_or(0.0);
float foreground;
if (settings.saturation_value.has_value())
foreground = settings.saturation_value.value();
else {
// Auto-contrast procedure
std::vector<int64_t> valid;
valid.reserve(xpixel * ypixel);
for (int i = 0; i < xpixel * ypixel; i++) {
if ((value[i] != special_value)
&& (value[i] != sat_value_64)
&& (mask[i] != MaskDet)
&& (mask[i] != MaskGap)
&& (!settings.show_user_mask || (mask[i] != MaskUsr)))
valid.push_back(static_cast<int64_t>(value[i]));
}
if (!valid.empty()) {
const size_t m = valid.size();
size_t ignore = std::max<size_t>(1, static_cast<size_t>(std::floor(m * auto_foreground_range)));
if (ignore >= m) ignore = m - 1; // ensure at least one value remains
const size_t rank = m - ignore - 1; // 0-based index for the desired value
std::nth_element(valid.begin(), valid.begin() + rank, valid.end());
foreground = static_cast<float>(valid[rank]);
} else {
// Fallback to something above background if no valid pixels remain
foreground = background + 1.0f;
}
}
// LUT-based mapping (fast path)
const auto &lut = scale.LUTData();
const int64_t lut_size = static_cast<int64_t>(lut.size());
const float lut_scale = static_cast<float>(lut_size - 1);
const float inv_range = (foreground > background)
? (lut_scale / (foreground - background))
: 0.0f;
const rgb gap_color = scale.Apply(ColorScaleSpecial::Gap);
const rgb bad_color = scale.Apply(ColorScaleSpecial::BadPixel);
std::vector<rgb> ret(xpixel * ypixel);
for (int i = 0; i < xpixel * ypixel; i++) {
if (mask[i] == MaskGap) {
ret[i] = gap_color;
} else if ((value[i] == special_value)
|| (mask[i] == MaskDet)
|| (settings.show_user_mask && (mask[i] == MaskUsr))) {
ret[i] = bad_color;
} else {
const float v = static_cast<float>(value[i]);
int64_t idx = static_cast<int64_t>((v - background) * inv_range + 0.5f);
if (idx < 0) idx = 0;
else if (idx >= lut_size) idx = lut_size - 1;
ret[i] = lut[idx];
}
}
return ret;
}
void PreviewImage::AddBeamCenter(std::vector<rgb> &rgb_image) const {
// The true direct beam is where the primary beam hits the detector, which differs from the
// stored beam origin (PONI) whenever the detector is tilted.
auto [bx, by] = experiment.GetDiffractionGeometry().GetDirectBeam_pxl();
if (!std::isfinite(bx) || !std::isfinite(by))
return;
int64_t beam_x_int = std::lround(bx);
int64_t beam_y_int = std::lround(by);
int crosshair_size = 30;
int crosshair_width = 3;
for (int w = -crosshair_width; w <= crosshair_width; w++) {
for (int i = -crosshair_size; i <= crosshair_size; i++) {
color_pixel(rgb_image, beam_x_int + i, beam_y_int + w, lime);
color_pixel(rgb_image, beam_x_int + w, beam_y_int + i, lime);
}
}
}
void PreviewImage::AddSpots(std::vector<rgb> &rgb_image,
const std::vector<SpotToSave>& in_spots) const {
for (const auto &s: in_spots) {
int64_t spot_x_int = std::lround(s.x);
int64_t spot_y_int = std::lround(s.y);
int rectangle_size = 4;
int rectangle_width = 3;
rgb color = green; // not indexed
if (s.indexed)
color = (s.lattice >= 1) ? coral : magenta; // secondary lattice vs primary
else if (s.ice_ring)
color = cyan;
for (int z = rectangle_size; z < rectangle_size + rectangle_width; z++) {
for (int w = -z; w <= z; w++) {
spot(rgb_image, spot_x_int + z, spot_y_int + w, color);
spot(rgb_image, spot_x_int - z, spot_y_int + w, color);
spot(rgb_image, spot_x_int + w, spot_y_int + z, color);
spot(rgb_image, spot_x_int + w, spot_y_int - z, color);
}
}
}
}
void PreviewImage::AddROI(std::vector<rgb> &rgb_image) const {
int64_t roi_counter = 0;
for (const auto &box: experiment.ROI().GetROIDefinition().boxes) {
int rectangle_width = 5;
for (auto x = box.GetXMin() - rectangle_width; x <= box.GetXMax() + rectangle_width; x++) {
for (auto w = 1; w <= rectangle_width; w++) {
roi(rgb_image, x, box.GetYMax() + w, roi_counter);
roi(rgb_image, x, box.GetYMin() - w, roi_counter);
}
}
for (auto y = box.GetYMin() - rectangle_width; y <= box.GetYMax() + rectangle_width; y++) {
for (auto w = 1; w <= rectangle_width; w++) {
roi(rgb_image, box.GetXMax() + w, y, roi_counter);
roi(rgb_image, box.GetXMin() - w, y, roi_counter);
}
}
roi_counter++;
}
for (const auto &circle: experiment.ROI().GetROIDefinition().circles) {
int width = 5;
for (int64_t y = std::floor(circle.GetY() - circle.GetRadius_pxl() - width);
y <= std::ceil(circle.GetY() + circle.GetRadius_pxl() + width);
y++) {
for (int64_t x = std::floor(circle.GetX() - circle.GetRadius_pxl() - width);
x <= std::ceil(circle.GetX() + circle.GetRadius_pxl() + width);
x++) {
float dist = sqrtf((x - circle.GetX()) * (x - circle.GetX())
+ (y - circle.GetY()) * (y - circle.GetY()));
if ((dist > circle.GetRadius_pxl()) && (dist < circle.GetRadius_pxl() + width))
roi(rgb_image, x, y, roi_counter);
}
}
roi_counter++;
}
DiffractionGeometry geom = experiment.GetDiffractionGeometry();
for (const auto &az: experiment.ROI().GetROIDefinition().azimuthal) {
const rgb color = plotly[roi_counter % 10];
const float d_inner = az.GetDMax_A(); // larger d -> smaller radius (inner arc)
const float d_outer = az.GetDMin_A(); // smaller d -> larger radius (outer arc)
constexpr float deg2rad = static_cast<float>(PI) / 180.0f;
if (az.HasPhi()) {
float phi0 = az.GetPhiMin_deg() * deg2rad;
float phi1 = az.GetPhiMax_deg() * deg2rad;
if (phi1 < phi0)
phi1 += 2.0f * static_cast<float>(PI); // unwrap a sector that crosses 0
DrawArc(rgb_image, geom, d_outer, phi0, phi1, color, 2);
DrawArc(rgb_image, geom, d_inner, phi0, phi1, color, 2);
// Straight radial edges joining the inner and outer arc at each sector limit.
auto radial_edge = [&](float phi) {
try {
auto [ax, ay] = geom.ResPhiToPxl(d_outer, phi);
auto [bx, by] = geom.ResPhiToPxl(d_inner, phi);
if (std::isfinite(ax) && std::isfinite(ay) && std::isfinite(bx) && std::isfinite(by))
DrawThickLine(rgb_image, ax, ay, bx, by, color, 2);
} catch (...) {}
};
radial_edge(phi0);
radial_edge(phi1);
} else {
const float two_pi = 2.0f * static_cast<float>(PI);
DrawArc(rgb_image, geom, d_outer, 0.0f, two_pi, color, 2);
DrawArc(rgb_image, geom, d_inner, 0.0f, two_pi, color, 2);
}
roi_counter++;
}
}
void PreviewImage::AddResolutionRing(std::vector<rgb> &rgb_image, float d) const {
DrawArc(rgb_image, experiment.GetDiffractionGeometry(), d, 0.0f, 2.0f * static_cast<float>(PI), orange, 1);
}
void PreviewImage::DrawThickLine(std::vector<rgb> &rgb_image, float x0, float y0, float x1, float y1,
const rgb &color, int halfwidth) const {
const float dx = x1 - x0, dy = y1 - y0;
int n = static_cast<int>(std::ceil(std::max(std::fabs(dx), std::fabs(dy))));
if (n < 1) n = 1;
for (int i = 0; i <= n; i++) {
const float t = static_cast<float>(i) / static_cast<float>(n);
const int64_t px = std::lround(x0 + t * dx);
const int64_t py = std::lround(y0 + t * dy);
for (int a = -halfwidth; a <= halfwidth; a++)
for (int b = -halfwidth; b <= halfwidth; b++)
color_pixel(rgb_image, px + a, py + b, color);
}
}
void PreviewImage::DrawArc(std::vector<rgb> &rgb_image, const DiffractionGeometry &geom, float d,
float phi_start, float phi_end, const rgb &color, int halfwidth) const {
// Sample the constant-d arc finely enough that neighbouring samples stay a few pixels apart,
// then join them with straight segments. ResPhiToPxl carries the detector tilt, so this traces
// the true conic instead of a PONI-centred circle. It throws when d is too high for the
// wavelength, and returns NaN where the contour leaves the detector plane - break there.
const float r_est = geom.ResToPxl(d);
const float span = std::fabs(phi_end - phi_start);
const int steps = std::clamp<int>(static_cast<int>(std::lround(std::fabs(r_est) * span * 0.5f)), 60, 8192);
std::optional<std::pair<float, float>> prev;
for (int i = 0; i <= steps; i++) {
const float phi = phi_start + (phi_end - phi_start) * static_cast<float>(i) / static_cast<float>(steps);
std::pair<float, float> pt;
try {
pt = geom.ResPhiToPxl(d, phi);
} catch (...) {
return; // d too high for the wavelength - nothing to draw
}
if (!std::isfinite(pt.first) || !std::isfinite(pt.second)) {
prev.reset();
continue;
}
if (prev)
DrawThickLine(rgb_image, prev->first, prev->second, pt.first, pt.second, color, halfwidth);
prev = pt;
}
}
void PreviewImage::DrawCircleOutline(std::vector<rgb> &rgb_image, float cx, float cy, float radius,
int width, const rgb &color) const {
const int64_t x_lo = std::floor(cx - radius - width);
const int64_t x_hi = std::ceil(cx + radius + width);
const int64_t y_lo = std::floor(cy - radius - width);
const int64_t y_hi = std::ceil(cy + radius + width);
for (int64_t y = y_lo; y <= y_hi; y++) {
for (int64_t x = x_lo; x <= x_hi; x++) {
const float dist = std::sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));
if (dist >= radius && dist <= radius + width)
color_pixel(rgb_image, x, y, color);
}
}
}
void PreviewImage::AddPredictions(std::vector<rgb> &rgb_image, const std::vector<Reflection> &reflections,
char centering) const {
// Draw predictions as dark-red circles (spots are squares), matching the viewer overlay.
// Reflections absent under the lattice centering are integrated but not real predictions - skip them.
for (const auto &s : reflections) {
if (systematic_absence(s.h, s.k, s.l, centering))
continue;
DrawCircleOutline(rgb_image, s.predicted_x, s.predicted_y, 5.0f, 2, dark_red);
}
}
void PreviewImage::ConfigurePixel(const std::vector<uint32_t> &mask_tmp, size_t pixel_begin, size_t pixel_end) {
constexpr uint32_t gap_bits =
(1u << PixelMask::ModuleGapPixelBit)
| (1u << PixelMask::ChipGapPixelBit)
| (1u << PixelMask::ModuleEdgePixelBit);
constexpr uint32_t det_bits = 0xFEFEu; // bits 1-7 and 9-15
constexpr uint32_t usr_bits = (1u << PixelMask::UserMaskedPixelBit);
for (size_t i = pixel_begin; i < pixel_end; i++) {
const auto pixel_val = mask_tmp[i];
if (pixel_val == 0)
mask[i] = 0;
else if ((pixel_val & gap_bits) != 0)
mask[i] = MaskGap;
else if ((pixel_val & det_bits) != 0)
mask[i] = MaskDet;
else if ((pixel_val & usr_bits) != 0)
mask[i] = MaskUsr;
else
mask[i] = 0;
}
}
void PreviewImage::Configure(const DiffractionExperiment &in_experiment, const PixelMask &pixel_mask, size_t nthreads) {
std::unique_lock ul(m);
experiment = in_experiment;
xpixel = experiment.GetXPixelsNum();
ypixel = experiment.GetYPixelsNum();
pixel_depth_bytes = experiment.GetByteDepthImage();
pixel_is_signed = experiment.IsPixelSigned();
mask.resize(experiment.GetPixelsNum(), 0);
if (nthreads == 0)
nthreads = std::thread::hardware_concurrency();
nthreads = std::clamp<size_t>(nthreads, 1, 8);
auto &mask_tmp = pixel_mask.GetMask(experiment);
std::vector<std::future<void> > futures;
futures.reserve(nthreads);
size_t npixel = experiment.GetPixelsNum();
for (size_t t = 0; t < nthreads; ++t)
futures.emplace_back(std::async(std::launch::async,
&PreviewImage::ConfigurePixel, this, std::cref(mask_tmp),
t * npixel / nthreads,
(t + 1) * npixel / nthreads));
for (auto &f: futures)
f.get();
}
std::vector<rgb> PreviewImage::GenerateRGB(const PreviewImageSettings &settings, const DataMessage &msg) const {
std::vector<rgb> v(msg.image.GetWidth() * msg.image.GetHeight());
if (msg.image.GetUncompressedSize() == 0)
return {};
std::vector<uint8_t> tmp;
const uint8_t* image_ptr = msg.image.GetUncompressedPtr(tmp);
{
// JPEG compression is outside the critical loop protected by m
std::unique_lock ul(m);
ColorScale scale;
scale.Select(settings.scale);
switch (msg.image.GetMode()) {
case CompressedImageMode::Int8:
v = GenerateRGB<int8_t>(image_ptr, INT8_MIN, INT8_MAX, scale, settings);
break;
case CompressedImageMode::Int16:
v = GenerateRGB<int16_t>(image_ptr, INT16_MIN, INT16_MAX, scale, settings);
break;
case CompressedImageMode::Int32:
v = GenerateRGB<int32_t>(image_ptr, INT32_MIN, INT32_MAX, scale, settings);
break;
case CompressedImageMode::Uint8:
v = GenerateRGB<uint8_t>(image_ptr,UINT8_MAX, UINT8_MAX, scale, settings);
break;
case CompressedImageMode::Uint16:
v = GenerateRGB<uint16_t>(image_ptr,UINT16_MAX, UINT16_MAX, scale, settings);
break;
case CompressedImageMode::Uint32:
v = GenerateRGB<uint32_t>(image_ptr,UINT32_MAX, UINT32_MAX, scale, settings);
break;
default:
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid, "Mode not supported");
}
if (settings.show_spots)
AddSpots(v, msg.spots);
if (settings.show_predictions) {
const char centering = msg.lattice_type.has_value() ? msg.lattice_type->centering : 'P';
AddPredictions(v, msg.reflections, centering);
}
if (settings.show_roi)
AddROI(v);
if (settings.resolution_ring)
AddResolutionRing(v, settings.resolution_ring.value());
else if (settings.show_res_est && msg.resolution_estimate)
AddResolutionRing(v, msg.resolution_estimate.value());
if (settings.show_beam_center)
AddBeamCenter(v);
}
return v;
}
std::string PreviewImage::GenerateImage(const PreviewImageSettings& settings, const DataMessage &msg) const {
auto v = GenerateRGB(settings, msg);
CompressedImage rgb_image(v, msg.image.GetWidth(), msg.image.GetHeight());
switch (settings.format) {
case PreviewImageFormat::JPEG:
return WriteJPEGToMem(rgb_image, settings.jpeg_quality);
case PreviewImageFormat::TIFF:
return WriteTIFFToString(rgb_image);
default:
throw JFJochException(JFJochExceptionCategory::InputParameterInvalid,
"Preview image format not supported");
}
}
std::string PreviewImage::GenerateImage(const PreviewImageSettings &settings, const std::vector<uint8_t> &cbor_format) {
auto cbor = CBORStream2Deserialize(cbor_format);
if (!cbor || !cbor->data_message)
return {};
return GenerateImage(settings, *cbor->data_message);
}
std::string PreviewImage::GenerateTIFF(const std::vector<uint8_t>& cbor_format) {
auto cbor = CBORStream2Deserialize(cbor_format);
if (!cbor || !cbor->data_message)
return {};
return WriteTIFFToString(cbor->data_message->image);
}