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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
284 lines
9.6 KiB
C++
284 lines
9.6 KiB
C++
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
#include "JFJochGridScanImage.h"
|
|
|
|
#include <QGraphicsItem>
|
|
|
|
JFJochGridScanImage::JFJochGridScanImage(QWidget *parent) : JFJochImage(parent) {}
|
|
|
|
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;
|
|
}
|
|
composite = false;
|
|
SetupGrid(settings);
|
|
image_fp = settings.Rearrange(data, NAN);
|
|
|
|
one_over_d2 = in_one_over_d2;
|
|
if (one_over_d2) {
|
|
for (int i = 0; i < image_fp.size(); i++)
|
|
if (!std::isnan(image_fp[i]))
|
|
image_fp[i] = 1.0f / (image_fp[i] * image_fp[i]);
|
|
}
|
|
|
|
float minv = std::numeric_limits<float>::infinity();
|
|
float maxv = -std::numeric_limits<float>::infinity();
|
|
for (float v : image_fp) {
|
|
if (std::isfinite(v)) {
|
|
if (v < minv) minv = v;
|
|
if (v > maxv) maxv = v;
|
|
}
|
|
}
|
|
if (!std::isfinite(minv) || !std::isfinite(maxv)) {
|
|
minv = 0.0f;
|
|
maxv = 1.0f;
|
|
}
|
|
if (!(maxv > minv)) {
|
|
maxv = minv + 1.0f;
|
|
}
|
|
background = minv;
|
|
foreground = maxv;
|
|
|
|
RenderImage();
|
|
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())
|
|
return;
|
|
|
|
// Convert view coordinates to image pixel by truncation
|
|
int x = static_cast<int>(pt.x());
|
|
int y = static_cast<int>(pt.y());
|
|
|
|
if (x < 0 || x >= W || y < 0 || y >= H)
|
|
return;
|
|
|
|
int idx = y * W + x;
|
|
if (idx < 0 || idx >= static_cast<int>(image_index.size()))
|
|
return;
|
|
int64_t image_id = image_index[idx];
|
|
if (image_id >= 0) {
|
|
if (modifiers & Qt::ShiftModifier)
|
|
emit imageSelected(image_id);
|
|
|
|
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)
|
|
.arg(x)
|
|
.arg(y),
|
|
6000);
|
|
else
|
|
emit writeStatusBar(QString("Image %1 x %2 y %3 d = %4 Å")
|
|
.arg(image_id)
|
|
.arg(x)
|
|
.arg(y)
|
|
.arg(QString::number(1 / std::sqrt(image_fp[idx]), 'f', 2)),
|
|
6000);
|
|
} else {
|
|
emit writeStatusBar(QString("Image %1 x %2 y %3 value %4")
|
|
.arg(image_id)
|
|
.arg(x)
|
|
.arg(y)
|
|
.arg(QString::number(image_fp[idx], 'f', 3)),
|
|
6000);
|
|
}
|
|
}
|
|
}
|
|
|
|
void JFJochGridScanImage::loadImage(QMouseEvent *event) {
|
|
// Map mouse position to image pixel if inside bounds
|
|
if (W == 0 || H == 0 || image_index.empty())
|
|
return;
|
|
|
|
const QPointF pt = mapToScene(event->pos());
|
|
|
|
// Convert view coordinates to image pixel by truncation
|
|
int x = static_cast<int>(pt.x());
|
|
int y = static_cast<int>(pt.y());
|
|
|
|
if (x < 0 || x >= W || y < 0 || y >= H)
|
|
return;
|
|
|
|
int idx = y * W + x;
|
|
if (idx < 0 || idx >= static_cast<int>(image_index.size()))
|
|
return;
|
|
int64_t image_id = image_index[idx];
|
|
if (image_id >= 0)
|
|
emit imageSelected(image_id);
|
|
}
|
|
|
|
void JFJochGridScanImage::mouseDoubleClickEvent(QMouseEvent *event) {
|
|
// Map mouse position to image pixel if inside bounds
|
|
if (W == 0 || H == 0 || image_index.empty())
|
|
return;
|
|
|
|
const QPointF pt = mapToScene(event->pos());
|
|
|
|
// Convert view coordinates to image pixel by truncation
|
|
int x = static_cast<int>(pt.x());
|
|
int y = static_cast<int>(pt.y());
|
|
|
|
if (x < 0 || x >= W || y < 0 || y >= H)
|
|
return;
|
|
|
|
int idx = y * W + x;
|
|
if (idx < 0 || idx >= static_cast<int>(image_index.size()))
|
|
return;
|
|
int64_t image_id = image_index[idx];
|
|
if (image_id >= 0)
|
|
emit imageSelected(image_id);
|
|
JFJochImage::mouseDoubleClickEvent(event);
|
|
}
|
|
|
|
void JFJochGridScanImage::setImage(int64_t val) {
|
|
if (settings) {
|
|
current_image_W = settings->GetElementPosX_step(val);
|
|
current_image_H = settings->GetElementPosY_step(val);
|
|
} else {
|
|
current_image_W = -1;
|
|
current_image_H = -1;
|
|
}
|
|
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;
|
|
|
|
QPen pen(feature_color, 3);
|
|
pen.setCosmetic(true);
|
|
|
|
auto *rect = scene()->addRect(current_image_W, current_image_H, 1, 1, pen);
|
|
addOverlayItem(rect);
|
|
}
|