diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index adc528c44..4439870ca 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -113,6 +113,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..d8b72df09 100644 --- a/viewer/CMakeLists.txt +++ b/viewer/CMakeLists.txt @@ -103,6 +103,8 @@ 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 + 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 12abb776c..7e69d34dd 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 @@ -20,6 +21,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 +141,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("Spindle blind fraction", 15); @@ -239,6 +248,7 @@ std::vector JFJochViewerDatasetInfo::ExtractMetric(const JFJochReaderData else if (val == 13) data = ds.indexing_lattice_count; else if (val == 14) data = ds.ice_ring_ratio; else if (val == 15) data = ds.spindle_blind_fraction; + else if (val == kCompositeMetric) data = GridScanProteinScore(ds); else if (val >= 100) { const int roi_index = (val - 100) / 4; if (val % 4 == 0) { @@ -310,7 +320,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..2af6f44eb 100644 --- a/viewer/image_viewer/JFJochGridScanImage.h +++ b/viewer/image_viewer/JFJochGridScanImage.h @@ -4,6 +4,8 @@ #pragma once #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) @@ -17,10 +19,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 +43,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(); }; - -