From 0c6167b7c5cc7a77c91618da3be6697f426b605b Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Mon, 7 Sep 2026 23:59:56 +0200 Subject: [PATCH 1/2] viewer: a grid scan shows protein and ice over the shape of the loop, with a frame around each crystal Two scalar maps side by side never answered the question a raster is taken to answer. The grid-scan view gains a composite: the normalised background gives the loop its shape in grey, ice paints it cyan, protein orange, and where both are present the two mix in proportion. Protein and ice go in as they come - they are saturating scores already in [0,1], and stretching one would turn "no protein anywhere" into "protein everywhere". Only the background is normalised, with its range floored, so a scan of pure air stays flat instead of having its noise stretched into a convincing loop. Each crystal found in the raster gets a frame, drawn oriented: centred on the crystal, sized along its own principal axes and rotated by its angle. An axis-aligned box would hide the case the axes exist to expose - a 100 x 10 um needle at 45 degrees reads as a compact blob. The extent is in micrometres and goes through both step sizes separately, which a real 20 x 16 um raster needs. A blob within 1.2 of round is drawn as a circle, since its angle means nothing. The single-scalar path is untouched; the composite is one more entry in the metric combo, offered first for a grid scan. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N --- tests/CMakeLists.txt | 1 + tests/GridScanCompositeTest.cpp | 122 ++++++++++++++++++ viewer/CMakeLists.txt | 1 + viewer/JFJochViewerDatasetInfo.cpp | 16 ++- viewer/image_viewer/GridScanComposite.h | 128 +++++++++++++++++++ viewer/image_viewer/JFJochGridScanImage.cpp | 134 ++++++++++++++++++-- viewer/image_viewer/JFJochGridScanImage.h | 20 ++- 7 files changed, 405 insertions(+), 17 deletions(-) create mode 100644 tests/GridScanCompositeTest.cpp create mode 100644 viewer/image_viewer/GridScanComposite.h diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a4af137b6..70e29a78b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -103,6 +103,7 @@ ADD_EXECUTABLE(jfjoch_test LatticeReductionTest.cpp BeamCenterFromBackgroundTest.cpp BeamCenterFromSpotsTest.cpp + GridScanCompositeTest.cpp ) target_link_libraries(jfjoch_test Catch2WithMain JFJochBroker JFJochReceiver JFJochReader JFJochStreamWriter diff --git a/tests/GridScanCompositeTest.cpp b/tests/GridScanCompositeTest.cpp new file mode 100644 index 000000000..fb8846460 --- /dev/null +++ b/tests/GridScanCompositeTest.cpp @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#include +#include + +#include "../viewer/image_viewer/GridScanComposite.h" + +using namespace grid_scan; + +TEST_CASE("GridScanComposite_Background", "[grid_scan]") { + // Air only: a flat level with counting noise on it. Without a floor on the range this would + // be stretched to fill the whole grey scale and look like a loop. + std::vector air; + for (int i = 0; i < 100; i++) + air.push_back(5.0f + 0.02f * ((i % 7) - 3)); + + const auto flat = NormaliseBackground(air); + for (float v : flat) + CHECK(v < 0.1f); + + // A loop in the middle of the same air. + std::vector loop = air; + for (int i = 40; i < 60; i++) + loop[i] = 13.0f; + + const auto mapped = NormaliseBackground(loop); + CHECK(mapped[0] < 0.1f); + CHECK(mapped[50] > 0.9f); +} + +TEST_CASE("GridScanComposite_BackgroundKeepsGaps", "[grid_scan]") { + const std::vector bkg{5.0f, std::numeric_limits::quiet_NaN(), 13.0f}; + const auto out = NormaliseBackground(bkg); + REQUIRE(out.size() == 3); + CHECK(std::isnan(out[1])); + CHECK(std::isfinite(out[0])); + CHECK(std::isfinite(out[2])); +} + +TEST_CASE("GridScanComposite_Color", "[grid_scan]") { + // Nothing anywhere: the pale plate. + const rgb air = CompositeColor(0.0f, 0.0f, 0.0f); + CHECK(air.r == kPlate.r); + CHECK(air.g == kPlate.g); + CHECK(air.b == kPlate.b); + + // Material but no diffraction: the loop grey. + const rgb loop = CompositeColor(1.0f, 0.0f, 0.0f); + CHECK(loop.r == kLoop.r); + CHECK(loop.b == kLoop.b); + + // A saturated score paints its own colour whatever the background says. + const rgb protein = CompositeColor(1.0f, 1.0f, 0.0f); + CHECK(protein.r == kProtein.r); + CHECK(protein.g == kProtein.g); + CHECK(protein.b == kProtein.b); + + const rgb ice = CompositeColor(0.0f, 0.0f, 1.0f); + CHECK(ice.r == kIce.r); + CHECK(ice.b == kIce.b); + + // Equal parts of both sit half way between the two, not on either. + const rgb both = CompositeColor(1.0f, 0.5f, 0.5f); + CHECK(both.r == static_cast(std::lround(0.5f * (kProtein.r + kIce.r)))); + CHECK(both.b == static_cast(std::lround(0.5f * (kProtein.b + kIce.b)))); + + // Protein three times ice: still mostly orange, but on the way to cyan. + const rgb mostly_protein = CompositeColor(1.0f, 0.75f, 0.25f); + CHECK(mostly_protein.b > protein.b); + CHECK(mostly_protein.b < both.b); + + // A weak score is a weak tint over the base, not a full-strength colour. + const rgb faint = CompositeColor(1.0f, 0.1f, 0.0f); + CHECK(faint.r > loop.r); + CHECK(faint.b < loop.b); + CHECK(faint.b > kProtein.b); +} + +TEST_CASE("GridScanComposite_FrameGeometry", "[grid_scan]") { + // Steps differ, as they do in a real raster: 20 um across, 16 um down. + constexpr float step_x = 20.0f; + constexpr float step_y = 16.0f; + + // Along the grid x axis: 100 um is 5 cells across, 10 um is 0.625 cells down. + const auto along_x = CrystalFrameCorners(4.0f, 3.0f, 100.0f, 10.0f, 0.0f, step_x, step_y); + CHECK_THAT(along_x[0].x, Catch::Matchers::WithinAbs(4.5 + 2.5, 1e-4)); + CHECK_THAT(along_x[0].y, Catch::Matchers::WithinAbs(3.5 + 0.3125, 1e-4)); + CHECK_THAT(along_x[2].x, Catch::Matchers::WithinAbs(4.5 - 2.5, 1e-4)); + + // The same needle at 90 degrees: the long axis is now along y, and because the y step is the + // finer one it covers more cells than it did across - 100/16 rather than 100/20. + const auto along_y = CrystalFrameCorners(4.0f, 3.0f, 100.0f, 10.0f, 90.0f, step_x, step_y); + float min_x = along_y[0].x, max_x = along_y[0].x, min_y = along_y[0].y, max_y = along_y[0].y; + for (const auto &c : along_y) { + min_x = std::min(min_x, c.x); + max_x = std::max(max_x, c.x); + min_y = std::min(min_y, c.y); + max_y = std::max(max_y, c.y); + } + CHECK_THAT(max_x - min_x, Catch::Matchers::WithinAbs(10.0 / step_x, 1e-3)); + CHECK_THAT(max_y - min_y, Catch::Matchers::WithinAbs(100.0 / step_y, 1e-3)); + + // At 45 degrees the frame is still a 100 x 10 needle - its axis-aligned bounding box is the + // ~78 x 78 blob the oriented frame exists to avoid drawing. + const auto tilted = CrystalFrameCorners(4.0f, 3.0f, 100.0f, 10.0f, 45.0f, step_x, step_x); + const float long_side = std::hypot(tilted[0].x - tilted[1].x, tilted[0].y - tilted[1].y); + const float short_side = std::hypot(tilted[1].x - tilted[2].x, tilted[1].y - tilted[2].y); + CHECK_THAT(long_side, Catch::Matchers::WithinAbs(100.0 / step_x, 1e-3)); + CHECK_THAT(short_side, Catch::Matchers::WithinAbs(10.0 / step_x, 1e-3)); + float bbox = 0; + for (const auto &c : tilted) + bbox = std::max(bbox, std::abs(c.x - 4.5f)); + CHECK(bbox > 1.9f); // the bounding box is far wider than the needle itself +} + +TEST_CASE("GridScanComposite_RoundBlob", "[grid_scan]") { + CHECK(IsRoundBlob(30.0f, 30.0f)); + CHECK(IsRoundBlob(33.0f, 30.0f)); + CHECK(!IsRoundBlob(100.0f, 10.0f)); + CHECK(!IsRoundBlob(40.0f, 30.0f)); +} diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index a56b719f6..6f227c432 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -103,6 +103,7 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView widgets/ResolutionRingWidget.h image_viewer/JFJochGridScanImage.cpp image_viewer/JFJochGridScanImage.h + image_viewer/GridScanComposite.h widgets/PowderCalibrationWidget.cpp widgets/PowderCalibrationWidget.h ${APP_RESOURCES} diff --git a/viewer/JFJochViewerDatasetInfo.cpp b/viewer/JFJochViewerDatasetInfo.cpp index cd2bd1b1f..87e450886 100644 --- a/viewer/JFJochViewerDatasetInfo.cpp +++ b/viewer/JFJochViewerDatasetInfo.cpp @@ -20,6 +20,10 @@ namespace { return palette[((index % n) + n) % n]; } + // Combo value of the composite grid-scan map. Well clear of the running metric numbers and + // below the ROI block (100 and up), so a metric added later cannot collide with it. + constexpr int kCompositeMetric = 50; + // A run's image->original-number map as floats for the chart x-axis (empty => identity). std::vector XForRun(const JFJochReaderDataset &ds) { return {ds.source_image_number.begin(), ds.source_image_number.end()}; @@ -136,6 +140,10 @@ void JFJochViewerDatasetInfo::UpdateLabels() { } if (this->dataset) { + // First for a grid scan: the composite is what a raster is looked at for, and the whole + // point is that it comes up without anyone having to know to ask for it. + if (dataset->experiment.GetGridScan()) + combo_box->addItem("Protein / ice map", kCompositeMetric); combo_box->addItem("Background estimate", 0); combo_box->addItem("Ice ring score", 14); combo_box->addItem("Resolution estimate", 7); @@ -237,6 +245,7 @@ std::vector JFJochViewerDatasetInfo::ExtractMetric(const JFJochReaderData else if (val == 12) data = ds.integrated_reflections; else if (val == 13) data = ds.indexing_lattice_count; else if (val == 14) data = ds.ice_ring_score; + else if (val == kCompositeMetric) data = GridScanProteinScore(ds); else if (val >= 100) { const int roi_index = (val - 100) / 4; if (val % 4 == 0) { @@ -308,7 +317,12 @@ void JFJochViewerDatasetInfo::UpdatePlot() { std::move(overlays), primary_color, XForRun(*dataset), full_range); if (dataset->experiment.GetGridScan()) { - grid_scan_image->loadData(data, dataset->experiment.GetGridScan().value(), one_over_d2); + if (val == kCompositeMetric) + grid_scan_image->loadComposite(dataset->bkg_estimate, GridScanProteinScore(*dataset), + GridScanIceScore(*dataset), GridScanCrystals(*dataset), + dataset->experiment.GetGridScan().value()); + else + grid_scan_image->loadData(data, dataset->experiment.GetGridScan().value(), one_over_d2); grid_button->setEnabled(true); if (!image_button->isChecked()) grid_button->setChecked(true); diff --git a/viewer/image_viewer/GridScanComposite.h b/viewer/image_viewer/GridScanComposite.h new file mode 100644 index 000000000..af981bd17 --- /dev/null +++ b/viewer/image_viewer/GridScanComposite.h @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include +#include +#include +#include +#include + +#include "../../common/ColorScale.h" +#include "../../common/JFJochMath.h" + +// Colouring and frame geometry of the composite grid-scan map. Kept free of Qt so it can be +// tested on its own; the widget (JFJochGridScanImage) only feeds it and draws the result. +namespace grid_scan { + +// Cyan for ice and orange for protein are the viewer's own annotation colours - indices 0 and 2 of +// ROIAnnotationColor() in widgets/ROIColorPalette.h - repeated here as plain rgb rather than QColor. +// The two greys are the map's own: the pale one is where the beam saw nothing, the mid one is where +// the background estimate says there is material. +constexpr rgb kPlate{0xf2, 0xf2, 0xf2}; +constexpr rgb kLoop{0x8c, 0x8c, 0x8c}; +constexpr rgb kProtein{0xff, 0x57, 0x22}; +constexpr rgb kIce{0x00, 0xff, 0xff}; + +// A blob this close to round has no orientation worth drawing: angle_deg is then whatever the fit +// happened to return, and a tilted box would claim a direction the data do not carry. +constexpr float kRoundAspect = 1.2f; + +inline float Clamp01(float v) { + return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); +} + +// Colour of one cell. `obj` is the normalised background - it finds the loop, not the crystal - +// while `protein` and `ice` are the saturating detection scores and are used exactly as they come: +// they already live in [0,1], and stretching a saturating score would only turn "no protein +// anywhere" into "protein everywhere". +inline rgb CompositeColor(float obj, float protein, float ice) { + obj = Clamp01(obj); + protein = Clamp01(protein); + ice = Clamp01(ice); + + const float sum = protein + ice; + const float tot = std::min(sum, 1.0f); + // Hue of the mix: the two in proportion, so one alone is its own colour and equal parts sit + // half way between. Where there is neither, tot is 0 and the mix is not used. + const float w = (sum > 0.0f) ? 1.0f / sum : 0.0f; + + auto channel = [&](uint8_t plate_c, uint8_t loop_c, uint8_t protein_c, uint8_t ice_c) { + const float base = plate_c * (1.0f - obj) + loop_c * obj; + const float mix = (protein_c * protein + ice_c * ice) * w; + return static_cast(std::lround(base * (1.0f - tot) + mix * tot)); + }; + + return rgb{channel(kPlate.r, kLoop.r, kProtein.r, kIce.r), + channel(kPlate.g, kLoop.g, kProtein.g, kIce.g), + channel(kPlate.b, kLoop.b, kProtein.b, kIce.b)}; +} + +// The background estimate is not a saturating score, so unlike protein and ice it has to be +// normalised before it can be a grey level. Cells that carry no value stay NaN. +inline std::vector NormaliseBackground(const std::vector &bkg) { + std::vector finite; + finite.reserve(bkg.size()); + for (float v : bkg) + if (std::isfinite(v)) + finite.push_back(v); + + std::vector out(bkg.size(), std::numeric_limits::quiet_NaN()); + if (finite.empty()) + return out; + + // Percentiles, not min/max: one dead or one hot cell would otherwise set the whole scale. + std::sort(finite.begin(), finite.end()); + const float lo = finite[static_cast(0.02 * (finite.size() - 1))]; + const float hi = finite[static_cast(0.98 * (finite.size() - 1))]; + // The range is floored at half the low end. Over pure air the spread is only counting noise on + // a flat level, and without a floor that noise would be stretched into a convincing fake loop. + const float range = std::max({hi - lo, 0.5f * lo, 1e-6f}); + + for (size_t i = 0; i < bkg.size(); i++) + if (std::isfinite(bkg[i])) + out[i] = Clamp01((bkg[i] - lo) / range); + return out; +} + +// One corner of a crystal frame, in grid-cell coordinates: cell (i, j) covers [i, i+1] x [j, j+1]. +struct FrameCorner { + float x; + float y; +}; + +// Corners of the frame around a crystal, in the order they are drawn. The extent comes in +// micrometres along the crystal's own principal axes, so it has to go through both step sizes - +// they genuinely differ (20 x 16 um is a normal raster), and a frame laid out in cell units would +// come out the wrong shape and the wrong angle. +// +// angle_deg is taken counter-clockwise in the grid index frame, i.e. from +nx towards +ny. That is +// the frame the map itself is drawn in (nx to the right, ny downwards), so if the producer measures +// its angle with ny pointing up instead, the two sine terms below change sign. +inline std::array CrystalFrameCorners(float nx, float ny, + float major_um, float minor_um, + float angle_deg, + float step_x_um, float step_y_um) { + const float a = angle_deg * static_cast(PI) / 180.0f; + const float c = std::cos(a); + const float s = std::sin(a); + const float hu = 0.5f * major_um; + const float hv = 0.5f * minor_um; + + const std::array local{{{hu, hv}, {-hu, hv}, {-hu, -hv}, {hu, -hv}}}; + + std::array out{}; + for (int i = 0; i < 4; i++) { + const float dx_um = c * local[i].x - s * local[i].y; + const float dy_um = s * local[i].x + c * local[i].y; + out[i] = FrameCorner{nx + 0.5f + dx_um / step_x_um, ny + 0.5f + dy_um / step_y_um}; + } + return out; +} + +inline bool IsRoundBlob(float major_um, float minor_um) { + return !(major_um > kRoundAspect * minor_um); +} + +} // namespace grid_scan diff --git a/viewer/image_viewer/JFJochGridScanImage.cpp b/viewer/image_viewer/JFJochGridScanImage.cpp index ea6601f90..23d5f3587 100644 --- a/viewer/image_viewer/JFJochGridScanImage.cpp +++ b/viewer/image_viewer/JFJochGridScanImage.cpp @@ -11,30 +11,39 @@ void JFJochGridScanImage::clear() { W = 0; H = 0; this->settings = std::nullopt; + composite = false; + obj_map.clear(); + protein_map.clear(); + ice_map.clear(); + crystals.clear(); if (scene()) scene()->clear(); resetScenePointers(); } +void JFJochGridScanImage::SetupGrid(const GridScanSettings &in_settings) { + this->settings = in_settings; + + W = in_settings.GetGridSizeX_step(); + H = in_settings.GetGridSizeY_step(); + // Draw a cell in the proportion of the area it covers, so a scan with a coarse x step and a + // fine y step looks like the sample region it was taken over, not like a square grid. + pixel_aspect_ = std::fabs(in_settings.GetGridStepY_um() / in_settings.GetGridStepX_um()); + + std::vector indices(static_cast(W) * H); + std::iota(indices.begin(), indices.end(), 0); + image_index = in_settings.Rearrange(indices, -1); +} + void JFJochGridScanImage::loadData(const std::vector &data, const GridScanSettings &settings, bool in_one_over_d2) { if (data.empty()) { clear(); return; } - this->settings = settings; - - W = settings.GetGridSizeX_step(); - H = settings.GetGridSizeY_step(); - // Draw a cell in the proportion of the area it covers, so a scan with a coarse x step and a - // fine y step looks like the sample region it was taken over, not like a square grid. - pixel_aspect_ = std::fabs(settings.GetGridStepY_um() / settings.GetGridStepX_um()); + composite = false; + SetupGrid(settings); image_fp = settings.Rearrange(data, NAN); - std::vector indices(image_fp.size()); - std::iota(indices.begin(), indices.end(), 0); - - image_index = settings.Rearrange(indices, -1); - one_over_d2 = in_one_over_d2; if (one_over_d2) { for (int i = 0; i < image_fp.size(); i++) @@ -64,6 +73,61 @@ void JFJochGridScanImage::loadData(const std::vector &data, const GridSca Redraw(); } +void JFJochGridScanImage::loadComposite(const std::vector &bkg, const std::vector &protein, + const std::vector &ice, + const std::vector &in_crystals, + const GridScanSettings &settings) { + if (bkg.empty() && protein.empty() && ice.empty()) { + clear(); + return; + } + composite = true; + one_over_d2 = false; + SetupGrid(settings); + + obj_map = settings.Rearrange(grid_scan::NormaliseBackground(bkg), NAN); + // A score that has not been measured is not a score of zero, but for the colouring the two are + // the same thing: nothing painted over the grey. + protein_map = settings.Rearrange(protein, 0.0f); + ice_map = settings.Rearrange(ice, 0.0f); + crystals = in_crystals; + + image_fp = obj_map; + background = 0.0f; + foreground = 1.0f; + + RenderImage(); + Redraw(); +} + +void JFJochGridScanImage::ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const { + if (!composite) { + JFJochImage::ColorRow(y, map, out); + return; + } + + for (size_t x = 0; x < W; ++x) { + const size_t idx = y * W + x; + if (std::isnan(obj_map[idx])) { + out[x] = qRgb(map.gap.r, map.gap.g, map.gap.b); + continue; + } + const rgb c = grid_scan::CompositeColor(obj_map[idx], protein_map[idx], ice_map[idx]); + out[x] = qRgb(c.r, c.g, c.b); + } +} + +QString JFJochGridScanImage::PixelLabel(int x, int y) const { + if (!composite) + return JFJochImage::PixelLabel(x, y); + + const size_t idx = static_cast(y) * W + x; + if (std::isnan(obj_map[idx])) + return QStringLiteral("-"); + return QString("%1/%2").arg(QString::number(protein_map[idx], 'f', 2), + QString::number(ice_map[idx], 'f', 2)); +} + void JFJochGridScanImage::mouseHover(const QPointF &pt, Qt::KeyboardModifiers modifiers) { // Map mouse position to image pixel if inside bounds if (W == 0 || H == 0 || image_index.empty()) @@ -84,7 +148,15 @@ void JFJochGridScanImage::mouseHover(const QPointF &pt, Qt::KeyboardModifiers mo if (modifiers & Qt::ShiftModifier) emit imageSelected(image_id); - if (one_over_d2) { + if (composite) { + emit writeStatusBar(QString("Image %1 x %2 y %3 protein %4 ice %5") + .arg(image_id) + .arg(x) + .arg(y) + .arg(QString::number(protein_map[idx], 'f', 2)) + .arg(QString::number(ice_map[idx], 'f', 2)), + 6000); + } else if (one_over_d2) { if (std::isnan(image_fp[idx])) emit writeStatusBar(QString("Image %1 x %2 y %3 no resolution estimation") .arg(image_id) @@ -165,7 +237,41 @@ void JFJochGridScanImage::setImage(int64_t val) { Redraw(); } +void JFJochGridScanImage::drawCrystalFrames() { + // White over black rather than a colour from the palette: the frame has to stay legible over + // the pale plate, the grey loop, cyan, orange and everything in between, and no single hue + // does that. + QPen halo(Qt::black, 5); + halo.setCosmetic(true); + QPen line(Qt::white, 2); + line.setCosmetic(true); + + const float step_x = settings->GetGridStepX_um(); + const float step_y = settings->GetGridStepY_um(); + + for (const auto &c : crystals) { + if (grid_scan::IsRoundBlob(c.major_um, c.minor_um)) { + // No orientation to show, so no oriented box - a circle of the mean extent. + const float r_um = 0.25f * (c.major_um + c.minor_um); + const QRectF box(c.nx + 0.5 - r_um / step_x, c.ny + 0.5 - r_um / step_y, + 2 * r_um / step_x, 2 * r_um / step_y); + addOverlayItem(scene()->addEllipse(box, halo)); + addOverlayItem(scene()->addEllipse(box, line)); + } else { + QPolygonF poly; + for (const auto &corner : grid_scan::CrystalFrameCorners(c.nx, c.ny, c.major_um, c.minor_um, + c.angle_deg, step_x, step_y)) + poly << QPointF(corner.x, corner.y); + addOverlayItem(scene()->addPolygon(poly, halo)); + addOverlayItem(scene()->addPolygon(poly, line)); + } + } +} + void JFJochGridScanImage::addCustomOverlay() { + if (composite && settings) + drawCrystalFrames(); + if (current_image_W < 0 || current_image_H < 0 || current_image_W >= W || current_image_H >= H) return; @@ -174,4 +280,4 @@ void JFJochGridScanImage::addCustomOverlay() { auto *rect = scene()->addRect(current_image_W, current_image_H, 1, 1, pen); addOverlayItem(rect); -} \ No newline at end of file +} diff --git a/viewer/image_viewer/JFJochGridScanImage.h b/viewer/image_viewer/JFJochGridScanImage.h index e290a7eff..ab7b32f68 100644 --- a/viewer/image_viewer/JFJochGridScanImage.h +++ b/viewer/image_viewer/JFJochGridScanImage.h @@ -4,6 +4,7 @@ #pragma once #include "JFJochImage.h" +#include "GridScanComposite.h" #include "../../reader/JFJochReaderDataset.h" Q_DECLARE_METATYPE(GridScanSettings) @@ -17,10 +18,23 @@ class JFJochGridScanImage : public JFJochImage { int64_t current_image_H = -1; bool one_over_d2 = false; + // Composite mode: instead of one colourmapped scalar the map shows the loop in grey with ice + // and protein painted over it. The three arrays are rearranged to the grid like image_fp. + bool composite = false; + std::vector obj_map; // normalised background - where there is material + std::vector protein_map; // saturating score, used as it comes + std::vector ice_map; + std::vector crystals; + void mouseHover(const QPointF &scenePos, Qt::KeyboardModifiers modifiers) override; void mouseDoubleClickEvent(QMouseEvent *event) override; void loadImage(QMouseEvent *event); void addCustomOverlay() override; + void drawCrystalFrames(); + void ColorRow(size_t y, const PixelColorMap &map, QRgb *out) const override; + [[nodiscard]] QString PixelLabel(int x, int y) const override; + // Grid geometry and the cell -> image map, shared by both load paths. + void SetupGrid(const GridScanSettings &settings); signals: void imageSelected(int64_t number); void writeStatusBar(QString string, int timeout_ms = 0); @@ -28,8 +42,10 @@ public: JFJochGridScanImage(QWidget *parent = nullptr); public slots: void loadData(const std::vector &data, const GridScanSettings &settings, bool one_over_d2); + // Protein and ice over the shape of the loop, with a frame around each crystal found. + void loadComposite(const std::vector &bkg, const std::vector &protein, + const std::vector &ice, const std::vector &crystals, + const GridScanSettings &settings); void setImage(int64_t val); void clear(); }; - - From cd31747c9900039886ceecb0d3bdef22565b6e2a Mon Sep 17 00:00:00 2001 From: Filip Leonarski Date: Tue, 8 Sep 2026 00:00:13 +0200 Subject: [PATCH 2/2] viewer: TEMPORARY STUB - the grid-scan composite reads protein, ice and the crystal list from a local placeholder REVERT THIS COMMIT AT INTEGRATION. protein_score, ice_score and the grid-scan crystal list are not on JFJochReaderDataset yet; they arrive through reader/ with a separate change. Until then GridScanDataStub.h defines GridScanCrystal and three accessors that return nothing, so the widget and the dataset-info panel are already written against the real shape of the data and compile today. Reverting this commit leaves exactly the two includes and the CMake entry to replace: take GridScanCrystal from the reader, and the three accessors become ds.protein_score, ds.ice_score and ds.grid_scan_result at their call sites in JFJochViewerDatasetInfo.cpp. Nothing else changes - both data paths fill the same JFJochReaderDataset, so HTTP and HDF5 light up together. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N --- viewer/CMakeLists.txt | 1 + viewer/GridScanDataStub.h | 56 +++++++++++++++++++++++ viewer/JFJochViewerDatasetInfo.cpp | 1 + viewer/image_viewer/JFJochGridScanImage.h | 1 + 4 files changed, 59 insertions(+) create mode 100644 viewer/GridScanDataStub.h diff --git a/viewer/CMakeLists.txt b/viewer/CMakeLists.txt index 6f227c432..d8b72df09 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -104,6 +104,7 @@ ADD_EXECUTABLE(jfjoch_viewer jfjoch_viewer.cpp JFJochViewerWindow.cpp JFJochView image_viewer/JFJochGridScanImage.cpp image_viewer/JFJochGridScanImage.h image_viewer/GridScanComposite.h + GridScanDataStub.h widgets/PowderCalibrationWidget.cpp widgets/PowderCalibrationWidget.h ${APP_RESOURCES} diff --git a/viewer/GridScanDataStub.h b/viewer/GridScanDataStub.h new file mode 100644 index 000000000..634515962 --- /dev/null +++ b/viewer/GridScanDataStub.h @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute +// SPDX-License-Identifier: GPL-3.0-only + +#pragma once + +#include + +#include "../reader/JFJochReaderDataset.h" + +// =========================================================================================== +// STUB - DELETE THIS FILE AT INTEGRATION. +// +// The composite grid-scan map is drawn from the per-image protein and ice scores and from the +// crystal list of the whole raster. None of the three is on JFJochReaderDataset yet; they arrive +// with a separate change, through reader/. Until then this header is the one place in the viewer +// that names them, so the widget and the dataset-info panel are already written against the real +// shape of the data. +// +// At integration: drop this file and its two includes (image_viewer/JFJochGridScanImage.h and +// JFJochViewerDatasetInfo.cpp), take GridScanCrystal from the reader instead, and replace the three +// accessors below with `ds.protein_score`, `ds.ice_score` and `ds.grid_scan_result` at their call +// sites in JFJochViewerDatasetInfo.cpp. Nothing else has to change: both data paths (HTTP and HDF5) +// fill the same JFJochReaderDataset, so both light up at once. +// =========================================================================================== + +// One crystal found in a grid scan. Position and extent are in the grid's own frame; the extent is +// along the crystal's own principal axes, which is why angle_deg is needed to draw it. +struct GridScanCrystal { + float nx = 0; // centre, fractional grid coordinates, 0-based + float ny = 0; + float x_um = 0; // centre, signed offset along the grid axes + float y_um = 0; + int64_t image_number = -1; + float major_um = 0; // extent along the crystal's own principal axes + float minor_um = 0; + float angle_deg = 0; // major axis from +x grid axis, counter-clockwise, in [0, 180) + float score = 0; + float ice_score = 0; + float res_A = 0; + int64_t n_images = 0; +}; + +inline const std::vector &GridScanProteinScore(const JFJochReaderDataset &) { + static const std::vector none; + return none; +} + +inline const std::vector &GridScanIceScore(const JFJochReaderDataset &) { + static const std::vector none; + return none; +} + +inline const std::vector &GridScanCrystals(const JFJochReaderDataset &) { + static const std::vector none; + return none; +} diff --git a/viewer/JFJochViewerDatasetInfo.cpp b/viewer/JFJochViewerDatasetInfo.cpp index 87e450886..ac31320fd 100644 --- a/viewer/JFJochViewerDatasetInfo.cpp +++ b/viewer/JFJochViewerDatasetInfo.cpp @@ -6,6 +6,7 @@ #include #include "JFJochViewerDatasetInfo.h" +#include "GridScanDataStub.h" // STUB - see the note in that file namespace { // Stable colour per run by its position in the run list (Original is index 0 -> blue), so a diff --git a/viewer/image_viewer/JFJochGridScanImage.h b/viewer/image_viewer/JFJochGridScanImage.h index ab7b32f68..2af6f44eb 100644 --- a/viewer/image_viewer/JFJochGridScanImage.h +++ b/viewer/image_viewer/JFJochGridScanImage.h @@ -5,6 +5,7 @@ #include "JFJochImage.h" #include "GridScanComposite.h" +#include "../GridScanDataStub.h" // STUB: GridScanCrystal - see the note in that file #include "../../reader/JFJochReaderDataset.h" Q_DECLARE_METATYPE(GridScanSettings)