From 30ebc226a564dabd62fd0a429258b47a31b20043 Mon Sep 17 00:00:00 2001 From: Alan Morris Date: Mon, 24 Aug 2026 20:43:13 -0600 Subject: [PATCH] Fix #2638 - Studio: export image dialog crashes on open after an oversized image size is entered An export size past GL_MAX_RENDERBUFFER_SIZE aborted Studio from inside Apple's GL-on-Metal layer, and because the size was saved before it was rendered, the dialog restored it and died again on every subsequent open. export_to_pixmap now queries the hardware limit and renders anything larger as tiles stitched by vtkWindowToImageFilter, and the size is stored only once a render has survived it. Along the way: vtk_image_to_qimage no longer overflows its int index past two gigapixels, the preview renders capped instead of at full export size, the progress bar actually paints, and oversized values clamp to a budget explained in the dialog. Export is now the default button, the size fields follow the override checkbox, and there are view-multiple presets. --- Studio/Interface/ExportImageDialog.cpp | 303 ++++++++++++++++++------- Studio/Interface/ExportImageDialog.h | 19 ++ Studio/Interface/ExportImageDialog.ui | 113 ++++++++- Studio/Utils/StudioUtils.cpp | 11 +- Studio/Visualization/Visualizer.cpp | 26 ++- 5 files changed, 390 insertions(+), 82 deletions(-) diff --git a/Studio/Interface/ExportImageDialog.cpp b/Studio/Interface/ExportImageDialog.cpp index 2bb74d3d233..09278012323 100644 --- a/Studio/Interface/ExportImageDialog.cpp +++ b/Studio/Interface/ExportImageDialog.cpp @@ -1,12 +1,17 @@ // std +#include +#include #include // qt includes +#include #include #include #include #include #include +#include +#include // studio #include @@ -20,6 +25,29 @@ namespace shapeworks { +namespace { +//! Bounds memory, not the GPU: tiling lifted the hardware limit. 32768 x 32768, 4 GB as ARGB. +const qint64 MAX_EXPORT_PIXELS = 1024LL * 1024 * 1024; + +//! Sanity bound, well inside the width at which QImage's int bytes-per-line would overflow +const int MAX_EXPORT_DIMENSION = 65536; + +//! The preview only fills a label; rendering it full size would touch gigabytes per keystroke +const int MAX_PREVIEW_DIMENSION = 2048; + +//--------------------------------------------------------------------------- +QSize clamp_export_size(QSize size) { + QSize result(clamp(size.width(), 1, MAX_EXPORT_DIMENSION), clamp(size.height(), 1, MAX_EXPORT_DIMENSION)); + qint64 pixels = static_cast(result.width()) * result.height(); + if (pixels > MAX_EXPORT_PIXELS) { + double factor = std::sqrt(static_cast(MAX_EXPORT_PIXELS) / static_cast(pixels)); + result = QSize(std::max(1, static_cast(result.width() * factor)), + std::max(1, static_cast(result.height() * factor))); + } + return result; +} +} // namespace + //--------------------------------------------------------------------------- ExportImageDialog::ExportImageDialog(QWidget* parent, Preferences& prefs, QSharedPointer analysis_tool, QSharedPointer visualizer, bool pca_mode) @@ -39,12 +67,15 @@ ExportImageDialog::ExportImageDialog(QWidget* parent, Preferences& prefs, QShare setGeometry(rect); // load state from prefs - QIntValidator* size_validator = new QIntValidator(1, 65535, this); + QIntValidator* size_validator = new QIntValidator(1, MAX_EXPORT_DIMENSION, this); ui_->override_width->setValidator(size_validator); ui_->override_height->setValidator(size_validator); - ui_->override_width->setText(QString::number(prefs_.get_export_override_size().width())); - ui_->override_height->setText(QString::number(prefs_.get_export_override_size().height())); + // a size stored by an older version, or stored while it was still being typed, may be unrenderable + QSize export_size = clamp_export_size(prefs_.get_export_override_size()); + ui_->override_width->setText(QString::number(export_size.width())); + ui_->override_height->setText(QString::number(export_size.height())); ui_->override_window_size->setChecked(prefs_.get_export_override_size_enabled()); + ui_->window_size_widget->setEnabled(ui_->override_window_size->isChecked()); ui_->show_corner_widget->setChecked(prefs_.get_export_show_orientation_marker()); ui_->show_color_scale->setChecked(prefs_.get_export_show_color_scale()); ui_->pca_num_images->setValue(prefs_.get_export_num_pca_images()); @@ -64,7 +95,15 @@ ExportImageDialog::ExportImageDialog(QWidget* parent, Preferences& prefs, QShare }; connect(ui_->override_width, &QLineEdit::textChanged, start_timer); connect(ui_->override_height, &QLineEdit::textChanged, start_timer); + auto clear_size_message = [=]() { ui_->size_message->clear(); }; + connect(ui_->override_width, &QLineEdit::textEdited, clear_size_message); + connect(ui_->override_height, &QLineEdit::textEdited, clear_size_message); connect(ui_->override_window_size, &QCheckBox::toggled, this, &ExportImageDialog::update_preview); + connect(ui_->override_window_size, &QCheckBox::toggled, ui_->window_size_widget, &QWidget::setEnabled); + connect(ui_->preset_1x, &QPushButton::clicked, this, [=]() { apply_size_preset(1); }); + connect(ui_->preset_2x, &QPushButton::clicked, this, [=]() { apply_size_preset(2); }); + connect(ui_->preset_4x, &QPushButton::clicked, this, [=]() { apply_size_preset(4); }); + connect(ui_->preset_8x, &QPushButton::clicked, this, [=]() { apply_size_preset(8); }); connect(ui_->transparent_background, &QCheckBox::toggled, this, &ExportImageDialog::update_preview); connect(ui_->show_corner_widget, &QCheckBox::toggled, this, &ExportImageDialog::update_preview); connect(ui_->show_color_scale, &QCheckBox::toggled, this, &ExportImageDialog::update_preview); @@ -93,7 +132,16 @@ void ExportImageDialog::export_clicked() { filename = filename + ".png"; // default PNG } prefs_.set_last_directory(QFileInfo(filename).absolutePath()); - if (pixmap_.save(filename)) { + + // the preview renders small, so render again here at the full requested size + QScopedValueRollback guard(updating_preview_, true); + bool all_ready = true; + QPixmap image = render_image(get_export_size(), all_ready); + if (image.isNull()) { + SW_ERROR("Unable to generate the image to export"); + return; + } + if (image.save(filename)) { SW_LOG("Saved: " + filename.toStdString()); } else { SW_ERROR("Error saving: " + filename.toStdString()); @@ -106,103 +154,206 @@ void ExportImageDialog::export_clicked() { } //--------------------------------------------------------------------------- -void ExportImageDialog::update_preview() { - QSize size(ui_->override_width->text().toInt(), ui_->override_height->text().toInt()); - if (size.width() < 1 || size.height() < 1) { - ui_->preview->setPixmap(QPixmap{}); - return; - } - prefs_.set_export_override_size(size); - prefs_.set_export_override_size_enabled(ui_->override_window_size->isChecked()); - prefs_.set_export_show_orientation_marker(ui_->show_corner_widget->isChecked()); - prefs_.set_export_show_color_scale(ui_->show_color_scale->isChecked()); - prefs_.set_export_num_pca_images(ui_->pca_num_images->value()); - prefs_.set_export_pca_range(ui_->pca_range->value()); - prefs_.set_export_pca_modes(ui_->pca_modes->text()); - int num_pca_steps = ui_->pca_num_images->value(); - double pca_range = ui_->pca_range->value(); +void ExportImageDialog::begin_progress(int maximum, QString message) { + ui_->progress_label->setText(message); + ui_->progress->setMaximum(maximum); + ui_->progress->setValue(0); + ui_->progress_widget->show(); + QApplication::setOverrideCursor(Qt::WaitCursor); + // last chance to paint before the render blocks the GUI thread; input stays excluded so a click + // cannot re-enter the render or close the dialog under it + QApplication::processEvents(QEventLoop::ExcludeUserInputEvents); +} - auto mode_list = get_modes(ui_->pca_modes->text()); +//--------------------------------------------------------------------------- +void ExportImageDialog::step_progress(int value) { + ui_->progress->setValue(value); + QApplication::processEvents(QEventLoop::ExcludeUserInputEvents); +} + +//--------------------------------------------------------------------------- +void ExportImageDialog::end_progress() { + QApplication::restoreOverrideCursor(); + ui_->progress_widget->hide(); +} - if (!prefs_.get_export_override_size_enabled()) { - size = visualizer_->get_render_size(); +//--------------------------------------------------------------------------- +QSize ExportImageDialog::get_export_size() { + if (!ui_->override_window_size->isChecked()) { + return visualizer_->get_render_size(); } + return clamp_export_size(QSize(ui_->override_width->text().toInt(), ui_->override_height->text().toInt())); +} - bool transparent = ui_->transparent_background->isChecked(); +//--------------------------------------------------------------------------- +void ExportImageDialog::apply_size_preset(int multiplier) { + QSize view_size = visualizer_->get_render_size(); + QSize size = clamp_export_size(QSize(view_size.width() * multiplier, view_size.height() * multiplier)); + + // set every field without signals, then render once, rather than once per field + QSignalBlocker block_override(ui_->override_window_size); + QSignalBlocker block_width(ui_->override_width); + QSignalBlocker block_height(ui_->override_height); + ui_->override_window_size->setChecked(true); + ui_->window_size_widget->setEnabled(true); + ui_->override_width->setText(QString::number(size.width())); + ui_->override_height->setText(QString::number(size.height())); + ui_->size_message->clear(); - bool all_ready = true; + update_preview(); +} + +//--------------------------------------------------------------------------- +QPixmap ExportImageDialog::render_image(QSize size, bool& all_ready) { + all_ready = true; + bool transparent = ui_->transparent_background->isChecked(); ColorSchemes color_schemes; ColorScheme colors = color_schemes[prefs_.get_color_scheme()]; - if (pca_mode_) { - int num_columns = 2 * num_pca_steps + 1; - int num_rows = mode_list.size(); - double increment = pca_range / num_pca_steps; - double margin = size.height() * 0.2; - double side_margin = size.width() * 0.2; - auto canvas = - QPixmap(size.width() * num_columns + side_margin, size.height() * num_rows + margin); // extra 20% for labels - - canvas.fill(colors.background_qcolor(transparent ? 0 : 255)); - - int y = 0; - for (int mode_idx = 0; mode_idx < mode_list.size(); mode_idx++) { - int mode = mode_list[mode_idx]; - int x = side_margin; - for (int step = -num_pca_steps; step <= num_pca_steps; step++) { - double pca_value = step * increment; - visualizer_->display_shape(analysis_tool_->get_mode_shape(mode, pca_value)); - bool ready = false; - - bool orientation_marker = ui_->show_corner_widget->isChecked() && step == num_pca_steps && mode_idx == 0; - bool color_scale = ui_->show_color_scale->isChecked() && step == num_pca_steps; - auto pixmap = visualizer_->export_to_pixmap(size, transparent, orientation_marker, color_scale, ready); - if (!ready) { - all_ready = false; - } + if (!pca_mode_) { + // a single render has no intermediate steps to report, so this is a busy indicator + begin_progress(0, "Generating image"); + auto pixmap = visualizer_->export_to_pixmap(size, transparent, ui_->show_corner_widget->isChecked(), + ui_->show_color_scale->isChecked(), all_ready); + end_progress(); + return pixmap; + } - QString text = QString::number(pca_value, 'g', 3) + " SD"; - if (step == 0) { - text = "Mean Shape"; - } + int num_pca_steps = ui_->pca_num_images->value(); + double pca_range = ui_->pca_range->value(); + auto mode_list = get_modes(ui_->pca_modes->text()); - QPainter painter(&canvas); - painter.drawPixmap(x, y, pixmap); - painter.setPen(colors.get_text_color()); - QFont font = painter.font(); - font.setPixelSize(margin * 0.75); - painter.setFont(font); + int num_columns = 2 * num_pca_steps + 1; + int num_rows = mode_list.size(); + double increment = pca_range / num_pca_steps; + double margin = size.height() * 0.2; + double side_margin = size.width() * 0.2; + + // extra 20% for labels + qint64 canvas_width = static_cast(size.width()) * num_columns + side_margin; + qint64 canvas_height = static_cast(size.height()) * num_rows + margin; + if (canvas_width * canvas_height > MAX_EXPORT_PIXELS) { + SW_ERROR("Image too large: reduce the export size, the number of modes, or the number of images"); + return QPixmap{}; + } - if (mode_idx == mode_list.size() - 1) { - QRect rect = QRect(QPoint(x, y + pixmap.height()), QPoint(x + pixmap.width(), y + pixmap.height() + margin)); - painter.drawText(rect, Qt::AlignCenter, text); - } + auto canvas = QPixmap(static_cast(canvas_width), static_cast(canvas_height)); + if (canvas.isNull()) { + SW_ERROR("Unable to allocate a {}x{} image", canvas_width, canvas_height); + return QPixmap{}; + } + canvas.fill(colors.background_qcolor(transparent ? 0 : 255)); + + begin_progress(num_rows * num_columns, "Generating images"); + int completed = 0; + + int y = 0; + for (int mode_idx = 0; mode_idx < mode_list.size(); mode_idx++) { + int mode = mode_list[mode_idx]; + int x = side_margin; + for (int step = -num_pca_steps; step <= num_pca_steps; step++) { + double pca_value = step * increment; + visualizer_->display_shape(analysis_tool_->get_mode_shape(mode, pca_value)); + bool ready = false; + + bool orientation_marker = ui_->show_corner_widget->isChecked() && step == num_pca_steps && mode_idx == 0; + bool color_scale = ui_->show_color_scale->isChecked() && step == num_pca_steps; + auto pixmap = visualizer_->export_to_pixmap(size, transparent, orientation_marker, color_scale, ready); + if (!ready) { + all_ready = false; + } + step_progress(++completed); - // draw rotated "mode x" string - QString mode_string = "Mode " + QString::number(mode + 1); - QPointF anchor(0, y + size.height()); - QRect rect = QRect(0, 0, pixmap.height(), side_margin); - drawRotatedText(painter, mode_string, anchor, -90, rect); + QString text = QString::number(pca_value, 'g', 3) + " SD"; + if (step == 0) { + text = "Mean Shape"; + } + + QPainter painter(&canvas); + painter.drawPixmap(x, y, pixmap); + painter.setPen(colors.get_text_color()); + QFont font = painter.font(); + font.setPixelSize(margin * 0.75); + painter.setFont(font); - x += size.width(); + if (mode_idx == mode_list.size() - 1) { + QRect rect = QRect(QPoint(x, y + pixmap.height()), QPoint(x + pixmap.width(), y + pixmap.height() + margin)); + painter.drawText(rect, Qt::AlignCenter, text); } - y += size.height(); + + // draw rotated "mode x" string + QString mode_string = "Mode " + QString::number(mode + 1); + QPointF anchor(0, y + size.height()); + QRect rect = QRect(0, 0, pixmap.height(), side_margin); + drawRotatedText(painter, mode_string, anchor, -90, rect); + + x += size.width(); } - pixmap_ = canvas; + y += size.height(); + } + end_progress(); + return canvas; +} - } else { - pixmap_ = visualizer_->export_to_pixmap(size, ui_->transparent_background->isChecked(), - ui_->show_corner_widget->isChecked(), ui_->show_color_scale->isChecked(), - all_ready); +//--------------------------------------------------------------------------- +void ExportImageDialog::update_preview() { + // processEvents() still delivers timer events, so the update timer can land back in here + if (updating_preview_) { + return; } + QScopedValueRollback guard(updating_preview_, true); + QSize typed_size(ui_->override_width->text().toInt(), ui_->override_height->text().toInt()); + if (typed_size.width() < 1 || typed_size.height() < 1) { + ui_->preview->setPixmap(QPixmap{}); + return; + } + QSize requested_size = clamp_export_size(typed_size); + if (requested_size != typed_size) { + // QIntValidator calls an over-range number Intermediate, so the fields accept it as you type + QSignalBlocker block_width(ui_->override_width); + QSignalBlocker block_height(ui_->override_height); + ui_->override_width->setText(QString::number(requested_size.width())); + ui_->override_height->setText(QString::number(requested_size.height())); + // shown here rather than logged: this dialog is modal, so a warning dialog would open behind it + ui_->size_message->setText( + QString("Limited to %1 x %2 (maximum 1 gigapixel)").arg(requested_size.width()).arg(requested_size.height())); + } + + prefs_.set_export_override_size_enabled(ui_->override_window_size->isChecked()); + prefs_.set_export_show_orientation_marker(ui_->show_corner_widget->isChecked()); + prefs_.set_export_show_color_scale(ui_->show_color_scale->isChecked()); + prefs_.set_export_num_pca_images(ui_->pca_num_images->value()); + prefs_.set_export_pca_range(ui_->pca_range->value()); + prefs_.set_export_pca_modes(ui_->pca_modes->text()); + + // The export renders at the full size; the preview only has to fill a label. + QSize preview_size = get_export_size(); + if (preview_size.width() > MAX_PREVIEW_DIMENSION || preview_size.height() > MAX_PREVIEW_DIMENSION) { + preview_size.scale(MAX_PREVIEW_DIMENSION, MAX_PREVIEW_DIMENSION, Qt::KeepAspectRatio); + } + + bool all_ready = true; + pixmap_ = render_image(preview_size, all_ready); + if (pixmap_.isNull()) { + end_progress(); + ui_->preview->setPixmap(QPixmap{}); + ui_->preview->setText("Unable to generate the preview. See the log for details."); + return; + } + + // a shape was still loading, so keep an indicator up and come back for another pass ui_->progress_widget->setVisible(!all_ready); if (!all_ready) { + ui_->progress_label->setText("Waiting for shapes"); + ui_->progress->setMaximum(0); update_preview_timer_.start(2000); } ui_->preview->setPixmap(pixmap_); + + prefs_.set_export_override_size(requested_size); } //--------------------------------------------------------------------------- diff --git a/Studio/Interface/ExportImageDialog.h b/Studio/Interface/ExportImageDialog.h index b9827a31053..8a2551910a3 100644 --- a/Studio/Interface/ExportImageDialog.h +++ b/Studio/Interface/ExportImageDialog.h @@ -33,6 +33,23 @@ class ExportImageDialog : public QDialog { private: void update_preview(); + //! Render the composed image; reduced size for the preview, full requested size for the export + QPixmap render_image(QSize size, bool& all_ready); + + //! The size the export will actually be rendered at + QSize get_export_size(); + + //! Set the export size to a multiple of the size the view is currently rendered at + void apply_size_preset(int multiplier); + + //! Show the progress widget and paint it before a render blocks the GUI thread; maximum 0 is busy + void begin_progress(int maximum, QString message); + + //! Advance the progress bar and repaint between renders + void step_progress(int value); + + void end_progress(); + void drawRotatedText(QPainter& painter, QString text, QPointF point, qreal angle, QRect rect); QVector get_modes(QString string); @@ -44,6 +61,8 @@ class ExportImageDialog : public QDialog { bool pca_mode_ = false; QTimer update_preview_timer_; QSharedPointer analysis_tool_; + //! guards update_preview() against re-entering itself via the repaint it processes events for + bool updating_preview_ = false; }; } // namespace shapeworks diff --git a/Studio/Interface/ExportImageDialog.ui b/Studio/Interface/ExportImageDialog.ui index af1ac000411..cd344a222fd 100644 --- a/Studio/Interface/ExportImageDialog.ui +++ b/Studio/Interface/ExportImageDialog.ui @@ -141,14 +141,20 @@ + + 0 + - + 0 0 + + 80 + 2048 @@ -167,19 +173,114 @@ - + 0 0 + + 80 + 2048 + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + 0 + + + + + Multiple of view: + + + + + + + 1x + + + false + + + + + + + 2x + + + false + + + + + + + 4x + + + false + + + + + + + 8x + + + false + + + + + + + Qt::Horizontal + + + + 0 + 0 + + + + + + + + + + + true + + + + + + @@ -258,7 +359,7 @@ ranges separated by commas - + Qt::Vertical @@ -345,6 +446,9 @@ ranges separated by commas Cancel + + false + @@ -352,6 +456,9 @@ ranges separated by commas Export + + true + diff --git a/Studio/Utils/StudioUtils.cpp b/Studio/Utils/StudioUtils.cpp index a3707d77097..e31c42fde9f 100644 --- a/Studio/Utils/StudioUtils.cpp +++ b/Studio/Utils/StudioUtils.cpp @@ -55,7 +55,14 @@ QImage StudioUtils::vtk_image_to_qimage(vtkSmartPointer image_data int num_components = image_data->GetNumberOfScalarComponents(); QImage image(width, height, QImage::Format_ARGB32); - QRgb* rgb_ptr = reinterpret_cast(image.bits()) + width * (height - 1); + if (image.isNull()) { // the allocation can fail outright for a very large export + SW_ERROR("Unable to allocate a {}x{} image", width, height); + return QImage(); + } + + // qsizetype, not int: a tiled export can exceed the two gigapixels at which this index overflows + const qsizetype row_length = width; + QRgb* rgb_ptr = reinterpret_cast(image.bits()) + row_length * (height - 1); unsigned char* colors_ptr = reinterpret_cast(image_data->GetScalarPointer()); // Loop over the vtkImageData contents. @@ -70,7 +77,7 @@ QImage StudioUtils::vtk_image_to_qimage(vtkSmartPointer image_data colors_ptr += num_components; } - rgb_ptr -= width * 2; + rgb_ptr -= row_length * 2; } return image; diff --git a/Studio/Visualization/Visualizer.cpp b/Studio/Visualization/Visualizer.cpp index 24f66ef7f9a..ee646daa0f5 100644 --- a/Studio/Visualization/Visualizer.cpp +++ b/Studio/Visualization/Visualizer.cpp @@ -8,15 +8,19 @@ #include #include #include +#include #include #include #include +#include #include #include +// std #include #include #include +#include namespace shapeworks { @@ -616,7 +620,22 @@ QPixmap Visualizer::export_to_pixmap(QSize size, bool transparent_background, bo } }; + // Render in tiles within the GPU's maximum dimension: past it macOS aborts inside the Metal + // layer, uncatchably. vtkWindowToImageFilter stitches them, which lifts the export size limit. + int max_tile = vtkTextureObject::GetMaximumTextureSize(vtkOpenGLRenderWindow::SafeDownCast(render_window)); + if (max_tile < 1024) { // the context could not be queried; every GL 3.2 implementation clears this + max_tile = 2048; + } + max_tile = std::min(max_tile, 16384); + + int tile_scale = 1; + while (size.width() > max_tile * tile_scale || size.height() > max_tile * tile_scale) { + tile_scale++; + } + QSize tile_size((size.width() + tile_scale - 1) / tile_scale, (size.height() + tile_scale - 1) / tile_scale); + auto window_to_image_filter = vtkSmartPointer::New(); + window_to_image_filter->SetScale(tile_scale, tile_scale); int original_size[2]; original_size[0] = render_window->GetSize()[0]; @@ -657,13 +676,18 @@ QPixmap Visualizer::export_to_pixmap(QSize size, bool transparent_background, bo renderer = collection->GetNextItem(); } - off_render_window->SetSize(size.width(), size.height()); + off_render_window->SetSize(tile_size.width(), tile_size.height()); off_render_window->Modified(); off_render_window->Render(); window_to_image_filter->Update(); auto qimage = StudioUtils::vtk_image_to_qimage(window_to_image_filter->GetOutput()); + // tiles cover at least the requested size; trim the rounding when it does not divide evenly + if (!qimage.isNull() && qimage.size() != size) { + qimage = qimage.copy(0, 0, size.width(), size.height()); + } + // set back to the original render window collection->InitTraversal(); renderer = collection->GetNextItem();