viewer: fix thumbnail render crash (worker-thread exception / LUT UB)

The image-strip thumbnail renderer could take the whole viewer down:

- Only the image load was wrapped in try/catch; the rest of RenderThumbnail_i
  (pixel access, LUT mapping, QPainter) ran unguarded on the worker thread, so
  any exception there called std::terminate. Wrap the entire function.
- std::clamp(idx, 0, lutSize - 1) is undefined behaviour when the colour LUT is
  empty (lo > hi). Bail out early if the LUT has no entries.

A click landing while a later thumbnail is still rendering surfaced this.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-23 16:26:15 +02:00
co-authored by Claude Opus 4.8
parent 164c03f1ce
commit 94b66348c5
+66 -57
View File
@@ -1057,73 +1057,82 @@ void JFJochImageReadingWorker::RenderThumbnails(QVector<qint64> image_numbers, b
}
QImage JFJochImageReadingWorker::RenderThumbnail_i(int64_t image_number, bool show_spots) {
std::shared_ptr<JFJochReaderImage> img;
// Everything runs inside one try: an uncaught exception here would be on the worker thread and
// would std::terminate the whole app. On any failure we just skip this thumbnail.
try {
QMutexLocker locker(&m);
if (http_mode || image_number < 0 || image_number >= total_images)
std::shared_ptr<JFJochReaderImage> img;
{
QMutexLocker locker(&m);
if (http_mode || image_number < 0 || image_number >= total_images)
return {};
img = file_reader.LoadImage(image_number, 1);
}
if (!img)
return {};
img = file_reader.LoadImage(image_number, 1);
} catch (const std::exception &e) {
logger.Debug("Thumbnail load failed for image {}: {}", image_number, e.what());
return {};
}
if (!img)
return {};
const int W = static_cast<int>(img->Dataset().experiment.GetXPixelsNum());
const int H = static_cast<int>(img->Dataset().experiment.GetYPixelsNum());
const auto &px = img->Image();
if (W <= 0 || H <= 0 || static_cast<int64_t>(px.size()) < static_cast<int64_t>(W) * H)
return {};
const int W = static_cast<int>(img->Dataset().experiment.GetXPixelsNum());
const int H = static_cast<int>(img->Dataset().experiment.GetYPixelsNum());
const auto &px = img->Image();
if (W <= 0 || H <= 0 || static_cast<int64_t>(px.size()) < static_cast<int64_t>(W) * H)
return {};
// Downsample by block-maximum (keeps sharp Bragg spots), then map through the colour-scale LUT.
constexpr int maxDim = 120;
const double s = static_cast<double>(maxDim) / std::max(W, H);
const int tw = std::max(1, static_cast<int>(W * s));
const int th = std::max(1, static_cast<int>(H * s));
const int bx = std::max(1, W / tw);
const int by = std::max(1, H / th);
const auto &lut = thumb_color_scale_.LUTData();
const int lutSize = static_cast<int>(lut.size());
if (lutSize <= 0)
return {};
const float fg = std::max(1.0f, static_cast<float>(img->GetAutoContrastValue()));
const auto &lut = thumb_color_scale_.LUTData();
const int lutSize = static_cast<int>(lut.size());
const float invRange = fg > 0 ? (lutSize - 1) / fg : 0.0f;
const rgb gap = thumb_color_scale_.Apply(ColorScaleSpecial::Gap);
// Downsample by block-maximum (keeps sharp Bragg spots), then map through the LUT.
constexpr int maxDim = 120;
const double s = static_cast<double>(maxDim) / std::max(W, H);
const int tw = std::max(1, static_cast<int>(W * s));
const int th = std::max(1, static_cast<int>(H * s));
const int bx = std::max(1, W / tw);
const int by = std::max(1, H / th);
QImage out(tw, th, QImage::Format_RGB32);
for (int ty = 0; ty < th; ++ty) {
QRgb *line = reinterpret_cast<QRgb *>(out.scanLine(ty));
const int y1 = std::min(H, ty * by + by);
for (int tx = 0; tx < tw; ++tx) {
const int x1 = std::min(W, tx * bx + bx);
int32_t best = 0;
bool any = false;
for (int yy = ty * by; yy < y1; ++yy)
for (int xx = tx * bx; xx < x1; ++xx) {
const int32_t v = px[yy * W + xx];
if (v == GAP_PXL_VALUE || v == ERROR_PXL_VALUE) continue;
if (v == SATURATED_PXL_VALUE) { best = static_cast<int32_t>(fg); any = true; continue; }
if (!any || v > best) { best = v; any = true; }
const float fg = std::max(1.0f, static_cast<float>(img->GetAutoContrastValue()));
const float invRange = (lutSize - 1) / fg;
const rgb gap = thumb_color_scale_.Apply(ColorScaleSpecial::Gap);
QImage out(tw, th, QImage::Format_RGB32);
for (int ty = 0; ty < th; ++ty) {
QRgb *line = reinterpret_cast<QRgb *>(out.scanLine(ty));
const int y1 = std::min(H, ty * by + by);
for (int tx = 0; tx < tw; ++tx) {
const int x1 = std::min(W, tx * bx + bx);
int32_t best = 0;
bool any = false;
for (int yy = ty * by; yy < y1; ++yy)
for (int xx = tx * bx; xx < x1; ++xx) {
const int32_t v = px[yy * W + xx];
if (v == GAP_PXL_VALUE || v == ERROR_PXL_VALUE) continue;
if (v == SATURATED_PXL_VALUE) { best = static_cast<int32_t>(fg); any = true; continue; }
if (!any || v > best) { best = v; any = true; }
}
rgb c;
if (!any) {
c = gap;
} else {
int idx = static_cast<int>(std::max(0, best) * invRange + 0.5f);
idx = std::clamp(idx, 0, lutSize - 1);
c = lut[idx];
}
rgb c;
if (!any) {
c = gap;
} else {
int idx = static_cast<int>(std::max(0, best) * invRange + 0.5f);
idx = std::clamp(idx, 0, lutSize - 1);
c = lut[idx];
line[tx] = qRgb(c.r, c.g, c.b);
}
line[tx] = qRgb(c.r, c.g, c.b);
}
}
if (show_spots) {
QPainter p(&out);
p.setRenderHint(QPainter::Antialiasing);
for (const auto &sp : img->ImageData().spots) {
p.setPen(QPen(sp.indexed ? thumb_feature_color_ : thumb_spot_color_, 1.0));
p.drawEllipse(QPointF(sp.x * s, sp.y * s), 1.6, 1.6);
if (show_spots) {
QPainter p(&out);
p.setRenderHint(QPainter::Antialiasing);
for (const auto &sp : img->ImageData().spots) {
p.setPen(QPen(sp.indexed ? thumb_feature_color_ : thumb_spot_color_, 1.0));
p.drawEllipse(QPointF(sp.x * s, sp.y * s), 1.6, 1.6);
}
}
return out;
} catch (const std::exception &e) {
logger.Debug("Thumbnail render failed for image {}: {}", image_number, e.what());
return {};
} catch (...) {
return {};
}
return out;
}