Merge branch 'worktree-agent-ae76aa0c66b33a4db' into grid-scan-integration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#include <catch2/catch_test_macros.hpp>
|
||||
#include <catch2/matchers/catch_matchers_floating_point.hpp>
|
||||
|
||||
#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<float> 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<float> 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<float> bkg{5.0f, std::numeric_limits<float>::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<uint8_t>(std::lround(0.5f * (kProtein.r + kIce.r))));
|
||||
CHECK(both.b == static_cast<uint8_t>(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));
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <vector>
|
||||
|
||||
#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<float> &GridScanProteinScore(const JFJochReaderDataset &) {
|
||||
static const std::vector<float> none;
|
||||
return none;
|
||||
}
|
||||
|
||||
inline const std::vector<float> &GridScanIceScore(const JFJochReaderDataset &) {
|
||||
static const std::vector<float> none;
|
||||
return none;
|
||||
}
|
||||
|
||||
inline const std::vector<GridScanCrystal> &GridScanCrystals(const JFJochReaderDataset &) {
|
||||
static const std::vector<GridScanCrystal> none;
|
||||
return none;
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
#include <QColor>
|
||||
|
||||
#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<float> 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<float> 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);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
||||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <algorithm>
|
||||
#include <array>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
#include <vector>
|
||||
|
||||
#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<uint8_t>(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<float> NormaliseBackground(const std::vector<float> &bkg) {
|
||||
std::vector<float> finite;
|
||||
finite.reserve(bkg.size());
|
||||
for (float v : bkg)
|
||||
if (std::isfinite(v))
|
||||
finite.push_back(v);
|
||||
|
||||
std::vector<float> out(bkg.size(), std::numeric_limits<float>::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<size_t>(0.02 * (finite.size() - 1))];
|
||||
const float hi = finite[static_cast<size_t>(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<FrameCorner, 4> 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<float>(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<FrameCorner, 4> local{{{hu, hv}, {-hu, hv}, {-hu, -hv}, {hu, -hv}}};
|
||||
|
||||
std::array<FrameCorner, 4> 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
|
||||
@@ -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<int64_t> indices(static_cast<size_t>(W) * H);
|
||||
std::iota(indices.begin(), indices.end(), 0);
|
||||
image_index = in_settings.Rearrange(indices, -1);
|
||||
}
|
||||
|
||||
void JFJochGridScanImage::loadData(const std::vector<float> &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<int64_t> 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<float> &data, const GridSca
|
||||
Redraw();
|
||||
}
|
||||
|
||||
void JFJochGridScanImage::loadComposite(const std::vector<float> &bkg, const std::vector<float> &protein,
|
||||
const std::vector<float> &ice,
|
||||
const std::vector<GridScanCrystal> &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<size_t>(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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<float> obj_map; // normalised background - where there is material
|
||||
std::vector<float> protein_map; // saturating score, used as it comes
|
||||
std::vector<float> ice_map;
|
||||
std::vector<GridScanCrystal> 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<float> &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<float> &bkg, const std::vector<float> &protein,
|
||||
const std::vector<float> &ice, const std::vector<GridScanCrystal> &crystals,
|
||||
const GridScanSettings &settings);
|
||||
void setImage(int64_t val);
|
||||
void clear();
|
||||
};
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user