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>
This commit is contained in:
2026-07-09 12:04:00 +02:00
co-authored by Claude Fable 5
parent 03dad9fe93
commit a4559e576d
9 changed files with 173 additions and 53 deletions
+3
View File
@@ -263,6 +263,7 @@ void JFJochBrokerHttp::register_routes(httplib::Server &server) {
parse_query_value<bool>(req, "show_user_mask"),
parse_query_value<bool>(req, "show_roi"),
parse_query_value<bool>(req, "show_spots"),
parse_query_value<bool>(req, "show_predictions"),
parse_query_value<bool>(req, "show_beam_center"),
parse_query_value<float>(req, "saturation"),
parse_query_value<int64_t>(req, "jpeg_quality"),
@@ -679,6 +680,7 @@ void JFJochBrokerHttp::image_buffer_image_jpeg_get(const std::optional<int64_t>
const std::optional<bool> &showUserMask,
const std::optional<bool> &showRoi,
const std::optional<bool> &showSpots,
const std::optional<bool> &showPredictions,
const std::optional<bool> &showBeamCenter,
const std::optional<float> &saturation,
const std::optional<int64_t> &jpegQuality,
@@ -692,6 +694,7 @@ void JFJochBrokerHttp::image_buffer_image_jpeg_get(const std::optional<int64_t>
settings.show_user_mask = showUserMask.value_or(false);
settings.show_roi = showRoi.value_or(false);
settings.show_spots = showSpots.value_or(true);
settings.show_predictions = showPredictions.value_or(false);
settings.saturation_value = saturation;
settings.background_value = 0.0;
settings.jpeg_quality = jpegQuality.value_or(100);
+1
View File
@@ -136,6 +136,7 @@ class JFJochBrokerHttp {
void image_buffer_image_cbor_get(const std::optional<int64_t> &id, httplib::Response &response);
void image_buffer_image_jpeg_get(const std::optional<int64_t> &id, const std::optional<bool> &showUserMask,
const std::optional<bool> &showRoi, const std::optional<bool> &showSpots,
const std::optional<bool> &showPredictions,
const std::optional<bool> &showBeamCenter, const std::optional<float> &saturation,
const std::optional<int64_t> &jpegQuality, const std::optional<float> &showResRing,
const std::optional<std::string> &color, const std::optional<bool> &showResEst,
+8
View File
@@ -168,6 +168,13 @@ components:
type: boolean
default: true
description: "Show spot finding results on the image"
show_predictions:
in: query
name: show_predictions
schema:
type: boolean
default: false
description: "Show Bragg spot predictions on the image (if available for the image)"
show_beam_center:
in: query
name: show_beam_center
@@ -3566,6 +3573,7 @@ paths:
- $ref: '#/components/parameters/show_user_mask'
- $ref: '#/components/parameters/show_roi'
- $ref: '#/components/parameters/show_spots'
- $ref: '#/components/parameters/show_predictions'
- $ref: '#/components/parameters/show_beam_center'
- $ref: '#/components/parameters/saturation'
- $ref: '#/components/parameters/jpeg_quality'
File diff suppressed because one or more lines are too long
+9
View File
@@ -1773,6 +1773,11 @@ export type background = number;
*/
export type show_spots = boolean;
/**
* Show Bragg spot predictions on the image (if available for the image)
*/
export type show_predictions = boolean;
/**
* Show beam center on the image
*/
@@ -3221,6 +3226,10 @@ export type getImageBufferImageJpegData = {
* Show spot finding results on the image
*/
show_spots?: boolean;
/**
* Show Bragg spot predictions on the image (if available for the image)
*/
show_predictions?: boolean;
/**
* Show beam center on the image
*/
+6
View File
@@ -868,6 +868,11 @@ export const zBackground = z.number().gte(-32767).lte(32767).default(0);
*/
export const zShowSpots = z.boolean().default(true);
/**
* Show Bragg spot predictions on the image (if available for the image)
*/
export const zShowPredictions = z.boolean().default(false);
/**
* Show beam center on the image
*/
@@ -1235,6 +1240,7 @@ export const zGetImageBufferImageJpegQuery = z.object({
show_user_mask: z.boolean().optional().default(false),
show_roi: z.boolean().optional().default(false),
show_spots: z.boolean().optional().default(true),
show_predictions: z.boolean().optional().default(false),
show_beam_center: z.boolean().optional().default(true),
saturation: z.number().gte(-32767).lte(32767).optional(),
jpeg_quality: z.coerce.bigint().gte(BigInt(0)).lte(BigInt(100)).optional().default(BigInt(100)),
+12
View File
@@ -20,6 +20,7 @@ import FormControl from "@mui/material/FormControl";
type preview_settings = {
saturation: number;
show_spots: boolean;
show_predictions: boolean;
show_roi: boolean;
jpeg_quality: number;
show_user_mask: boolean;
@@ -57,6 +58,7 @@ const default_preview_settings: preview_settings = {
saturation: 10,
jpeg_quality: 90,
show_spots: true,
show_predictions: false,
show_roi: false,
show_user_mask: false,
show_beam_center: true,
@@ -88,6 +90,7 @@ function PreviewImage({measuring, min_image_number, max_image_number}: MyProps)
url += `&saturation=${s.saturation}`;
url += `&jpeg_quality=${s.jpeg_quality}`
url += `&show_spots=${s.show_spots}`
url += `&show_predictions=${s.show_predictions}`
url += `&show_roi=${s.show_roi}`
url += `&show_user_mask=${s.show_user_mask}`;
if (s.res_estimate)
@@ -189,6 +192,12 @@ function PreviewImage({measuring, min_image_number, max_image_number}: MyProps)
getValues(s);
};
const showPredictionsToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s : preview_settings = {...settings, show_predictions: event.target.checked};
setSettings(s);
getValues(s);
};
const resEstToggle = (event: ChangeEvent<HTMLInputElement>) => {
let s: preview_settings = {...settings, res_estimate: event.target.checked};
setSettings(s);
@@ -338,6 +347,9 @@ function PreviewImage({measuring, min_image_number, max_image_number}: MyProps)
<FormControlLabel control={
<Checkbox checked={settings.show_spots} onChange={showSpotsToggle} name="Show spots"/>
} label="Show spots"/>
<FormControlLabel control={
<Checkbox checked={settings.show_predictions} onChange={showPredictionsToggle} name="Show predictions"/>
} label="Show predictions"/>
<FormControlLabel control={
<Checkbox checked={settings.show_roi} onChange={showROIToggle} name="Show ROI"/>
} label="Show ROI"/>
+120 -50
View File
@@ -1,18 +1,22 @@
// SPDX-FileCopyrightText: 2024 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <thread>
#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};
@@ -23,6 +27,14 @@ 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},
@@ -36,20 +48,6 @@ constexpr const static rgb plotly[] = {{0x1f, 0x77, 0xb4},
constexpr const static rgb gray = {.r = 0xbe, .g = 0xbe, .b = 0xbe};
// Smallest difference between two angles in degrees, accounting for wrap-around.
static float AngularDistance_deg(float a, float b) {
float d = std::fabs(a - b);
if (d > 180.0f)
d = 360.0f - d;
return d;
}
static bool InPhiSector(float phi_deg, float phi_min, float phi_max) {
if (phi_min <= phi_max)
return phi_deg >= phi_min && phi_deg <= phi_max;
return phi_deg >= phi_min || phi_deg <= phi_max; // sector wraps across 0
}
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;
@@ -133,8 +131,13 @@ std::vector<rgb> PreviewImage::GenerateRGB(const uint8_t *value_8,
}
void PreviewImage::AddBeamCenter(std::vector<rgb> &rgb_image) const {
size_t beam_x_int = std::lround(beam_x);
size_t beam_y_int = std::lround(beam_y);
// 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;
@@ -155,9 +158,9 @@ void PreviewImage::AddSpots(std::vector<rgb> &rgb_image,
int rectangle_size = 4;
int rectangle_width = 3;
rgb color = pink;
rgb color = green; // not indexed
if (s.indexed)
color = amber;
color = (s.lattice >= 1) ? coral : magenta; // secondary lattice vs primary
else if (s.ice_ring)
color = cyan;
@@ -215,46 +218,110 @@ void PreviewImage::AddROI(std::vector<rgb> &rgb_image) const {
DiffractionGeometry geom = experiment.GetDiffractionGeometry();
for (const auto &az: experiment.ROI().GetROIDefinition().azimuthal) {
const int width = 5;
const float r_inner = geom.ResToPxl(az.GetDMax_A()); // larger d -> smaller radius
const float r_outer = geom.ResToPxl(az.GetDMin_A());
const bool has_phi = az.HasPhi();
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;
for (int64_t y = 0; y <= ypixel; y++) {
for (int64_t x = 0; x <= xpixel; x++) {
const float dx = x - beam_x;
const float dy = y - beam_y;
const float dist = sqrtf(dx * dx + dy * dy);
const float phi_deg = geom.Phi_rad(x, y) * 180.0f / static_cast<float>(PI);
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
const bool in_sector = !has_phi || InPhiSector(phi_deg, az.GetPhiMin_deg(), az.GetPhiMax_deg());
const bool on_arc = (std::fabs(dist - r_inner) < width) || (std::fabs(dist - r_outer) < width);
DrawArc(rgb_image, geom, d_outer, phi0, phi1, color, 2);
DrawArc(rgb_image, geom, d_inner, phi0, phi1, color, 2);
// Radial edges of a sector: angular tolerance scaled to keep ~constant pixel width.
const float tol_deg = (dist > 1.0f) ? width / dist * 180.0f / static_cast<float>(PI) : 180.0f;
const bool on_edge = has_phi && (dist >= r_inner) && (dist <= r_outer)
&& (AngularDistance_deg(phi_deg, az.GetPhiMin_deg()) < tol_deg
|| AngularDistance_deg(phi_deg, az.GetPhiMax_deg()) < tol_deg);
if ((on_arc && in_sector) || on_edge)
roi(rgb_image, x, y, roi_counter);
}
// 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 {
DiffractionGeometry geom = experiment.GetDiffractionGeometry();
int width = 3;
float radius = geom.ResToPxl(d);
for (int64_t y = 0; y <= ypixel; y++) {
for (int64_t x = 0; x <= xpixel; x++) {
float dist = sqrtf((x - beam_x) * (x - beam_x) + (y - beam_y) * (y - beam_y));
DrawArc(rgb_image, experiment.GetDiffractionGeometry(), d, 0.0f, 2.0f * static_cast<float>(PI), orange, 1);
}
if ((dist > radius) && (dist < radius + width))
color_pixel(rgb_image, x, y, orange);
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);
}
}
@@ -289,8 +356,6 @@ void PreviewImage::Configure(const DiffractionExperiment &in_experiment, const P
experiment = in_experiment;
xpixel = experiment.GetXPixelsNum();
ypixel = experiment.GetYPixelsNum();
beam_x = experiment.GetBeamX_pxl();
beam_y = experiment.GetBeamY_pxl();
pixel_depth_bytes = experiment.GetByteDepthImage();
pixel_is_signed = experiment.IsPixelSigned();
@@ -359,6 +424,11 @@ std::vector<rgb> PreviewImage::GenerateRGB(const PreviewImageSettings &settings,
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);
+13 -2
View File
@@ -7,6 +7,7 @@
#include <mutex>
#include "../common/SpotToSave.h"
#include "../common/Reflection.h"
#include "../common/DiffractionExperiment.h"
#include "../common/PixelMask.h"
#include "PreviewCounter.h"
@@ -20,6 +21,7 @@ struct PreviewImageSettings {
int64_t jpeg_quality = 70;
bool show_beam_center = true;
bool show_spots = true;
bool show_predictions = false;
bool show_roi = false;
bool show_user_mask = true;
bool show_res_est = false;
@@ -39,8 +41,6 @@ class PreviewImage {
size_t ypixel = 0;
size_t pixel_depth_bytes = 2;
bool pixel_is_signed = false;
float beam_x = 0;
float beam_y = 0;
constexpr static uint8_t MaskGap = 1;
constexpr static uint8_t MaskUsr = 2;
@@ -59,8 +59,19 @@ class PreviewImage {
void AddResolutionRing(std::vector<rgb> &rgb_image, float d) const;
void AddBeamCenter(std::vector<rgb> &rgb_image) const;
void AddSpots(std::vector<rgb> &rgb_image, const std::vector<SpotToSave>& spots) const;
void AddPredictions(std::vector<rgb> &rgb_image, const std::vector<Reflection>& reflections, char centering) const;
void AddROI(std::vector<rgb> &rgb_image) const;
// Trace a fixed-resolution arc through the geometry (a circular arc on an untilted detector,
// a conic one on a tilted detector) by sweeping azimuth from phi_start to phi_end and joining
// the samples. A full ring uses phi_start=0, phi_end=2*pi.
void DrawArc(std::vector<rgb> &rgb_image, const DiffractionGeometry &geom, float d,
float phi_start, float phi_end, const rgb &color, int halfwidth) const;
void DrawThickLine(std::vector<rgb> &rgb_image, float x0, float y0, float x1, float y1,
const rgb &color, int halfwidth) const;
void DrawCircleOutline(std::vector<rgb> &rgb_image, float cx, float cy, float radius,
int width, const rgb &color) const;
void color_pixel(std::vector<rgb>& ret, int64_t xpixel, int64_t ypixel, const rgb &color) const;
void spot(std::vector<rgb>& ret, int64_t xpixel, int64_t ypixel, const rgb &color) const;
void roi(std::vector<rgb>& ret, int64_t xpixel, int64_t ypixel, int64_t roi_number) const;