From 71d535fc50dcdeb34cb9492e5e1cdb8aa2066659 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Sat, 14 Mar 2026 09:41:23 +0100 Subject: [PATCH] feat: extend MainWindow with QThread worker, settings panel, and batch dialog - ConversionWorker runs pipeline on background QThread so the GUI stays responsive during conversion; emits file_done, preview_ready, finished - MainWindow adds: output-format QComboBox, film-type QComboBox, Batch button that opens an AppConfig INI file and discovers files from batch.input_dir - main.cpp: --batch and --config flags trigger CLI mode without Qt Co-Authored-By: Claude Sonnet 4.6 --- src/gui/MainWindow.cpp | 371 ++++++++++++++++++++++++++++++++--------- src/gui/MainWindow.h | 109 ++++++++++-- src/main.cpp | 48 ++++-- 3 files changed, 421 insertions(+), 107 deletions(-) diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index ca0b6d8..fd301d0 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -1,4 +1,5 @@ #include "MainWindow.h" + #include "../converter/rawloader/RawLoader.h" #include "../converter/preprocess/Preprocessor.h" #include "../converter/negative/NegativeDetector.h" @@ -7,6 +8,7 @@ #include "../converter/crop/CropProcessor.h" #include "../converter/output/OutputWriter.h" +#include #include #include #include @@ -19,70 +21,163 @@ namespace photoconv { +// ───────────────────────────────────────────────────────────────────────────── +// ConversionWorker +// ───────────────────────────────────────────────────────────────────────────── + +ConversionWorker::ConversionWorker(std::vector files, + std::string output_dir, + OutputFormat fmt, + int quality, + QObject* parent) + : QObject{parent} + , files_{std::move(files)} + , output_dir_{std::move(output_dir)} + , fmt_{fmt} + , quality_{quality} +{} + +void ConversionWorker::run() { + RawLoader loader; + + // Build pipeline + Pipeline pipeline; + pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique( + OutputConfig{output_dir_, fmt_, quality_})); + + const int total = static_cast(files_.size()); + int success_count = 0; + + for (int idx = 0; idx < total; ++idx) { + const std::string& file = files_[static_cast(idx)]; + + // Load + auto load_result = loader.load(std::filesystem::path{file}); + if (!load_result.has_value()) { + emit file_done(idx, total, false, + QString::fromStdString(load_result.error().message)); + continue; + } + + // Run pipeline + auto result = pipeline.execute(std::move(load_result.value())); + if (!result.has_value()) { + emit file_done(idx, total, false, + QString::fromStdString(result.error().message)); + continue; + } + + ++success_count; + + // Send the last processed image to the preview (non-blocking copy). + emit preview_ready(result.value().rgb.clone()); + emit file_done(idx, total, true, + QString::fromStdString( + std::format("OK: {}", std::filesystem::path{file}.filename().string()))); + } + + emit finished(success_count, total); +} + +// ───────────────────────────────────────────────────────────────────────────── +// MainWindow constructor +// ───────────────────────────────────────────────────────────────────────────── + MainWindow::MainWindow(QWidget* parent) : QMainWindow{parent} { setup_ui(); - setup_pipeline(); - setWindowTitle("Photo Converter"); - resize(800, 600); + setWindowTitle("Photo Converter – Analog Negative to Digital Positive"); + resize(900, 680); } +// ───────────────────────────────────────────────────────────────────────────── +// setup_ui +// ───────────────────────────────────────────────────────────────────────────── + void MainWindow::setup_ui() { auto* central = new QWidget(this); - auto* layout = new QVBoxLayout(central); + auto* root_layout = new QVBoxLayout(central); - // Button bar - auto* button_layout = new QHBoxLayout(); - open_button_ = new QPushButton("Open Files...", this); - convert_button_ = new QPushButton("Convert", this); + // ── Top toolbar ────────────────────────────────────────────────────────── + auto* toolbar = new QHBoxLayout(); + + open_button_ = new QPushButton("Open Files...", this); output_dir_button_ = new QPushButton("Output Dir...", this); + convert_button_ = new QPushButton("Convert", this); + batch_button_ = new QPushButton("Batch...", this); + convert_button_->setEnabled(false); - button_layout->addWidget(open_button_); - button_layout->addWidget(output_dir_button_); - button_layout->addWidget(convert_button_); - layout->addLayout(button_layout); + toolbar->addWidget(open_button_); + toolbar->addWidget(output_dir_button_); + toolbar->addWidget(convert_button_); + toolbar->addWidget(batch_button_); + toolbar->addStretch(); - // Preview area + root_layout->addLayout(toolbar); + + // ── Settings panel ─────────────────────────────────────────────────────── + settings_box_ = new QGroupBox("Settings", this); + auto* settings_layout = new QHBoxLayout(settings_box_); + + settings_layout->addWidget(new QLabel("Output format:", this)); + format_combo_ = new QComboBox(this); + format_combo_->addItem("PNG 16-bit (Archival)", QVariant{static_cast(OutputFormat::PNG_16bit)}); + format_combo_->addItem("PNG 8-bit (Web)", QVariant{static_cast(OutputFormat::PNG_8bit)}); + format_combo_->addItem("TIFF 16-bit (Editing)", QVariant{static_cast(OutputFormat::TIFF_16bit)}); + format_combo_->addItem("JPEG (Preview)", QVariant{static_cast(OutputFormat::JPEG)}); + settings_layout->addWidget(format_combo_); + + settings_layout->addSpacing(16); + settings_layout->addWidget(new QLabel("Film type:", this)); + film_combo_ = new QComboBox(this); + film_combo_->addItem("Auto-detect"); + film_combo_->addItem("C-41 Color Negative"); + film_combo_->addItem("B&W Negative"); + settings_layout->addWidget(film_combo_); + + settings_layout->addStretch(); + root_layout->addWidget(settings_box_); + + // ── Preview area ───────────────────────────────────────────────────────── preview_label_ = new QLabel("No image loaded", this); preview_label_->setAlignment(Qt::AlignCenter); preview_label_->setMinimumSize(640, 480); - layout->addWidget(preview_label_); + preview_label_->setFrameStyle(QFrame::Sunken | QFrame::StyledPanel); + root_layout->addWidget(preview_label_, /*stretch=*/1); - // Progress bar + // ── Progress bar ───────────────────────────────────────────────────────── progress_bar_ = new QProgressBar(this); progress_bar_->setRange(0, 100); progress_bar_->setValue(0); - layout->addWidget(progress_bar_); + root_layout->addWidget(progress_bar_); - // Status + // ── Status line ────────────────────────────────────────────────────────── status_label_ = new QLabel("Ready", this); - layout->addWidget(status_label_); + root_layout->addWidget(status_label_); setCentralWidget(central); - // Connections - connect(open_button_, &QPushButton::clicked, this, &MainWindow::on_open_files); - connect(convert_button_, &QPushButton::clicked, this, &MainWindow::on_convert); + // ── Signal/slot connections ─────────────────────────────────────────────── + connect(open_button_, &QPushButton::clicked, this, &MainWindow::on_open_files); + connect(convert_button_, &QPushButton::clicked, this, &MainWindow::on_convert); connect(output_dir_button_, &QPushButton::clicked, this, &MainWindow::on_select_output_dir); + connect(batch_button_, &QPushButton::clicked, this, &MainWindow::on_batch); } -void MainWindow::setup_pipeline() { - pipeline_ = std::make_unique(); - // Note: Loader is called separately before the pipeline. - // Pipeline stages are: Preprocess -> Detect -> Invert -> Color -> PostProcess -> Output - pipeline_->add_stage(std::make_unique()); - pipeline_->add_stage(std::make_unique()); - pipeline_->add_stage(std::make_unique()); - pipeline_->add_stage(std::make_unique()); - pipeline_->add_stage(std::make_unique()); - // OutputWriter is added dynamically when output_dir is known -} +// ───────────────────────────────────────────────────────────────────────────── +// Slots +// ───────────────────────────────────────────────────────────────────────────── void MainWindow::on_open_files() { - QStringList files = QFileDialog::getOpenFileNames( - this, "Open Image Files", QString(), kFileFilter); + const QStringList files = QFileDialog::getOpenFileNames( + this, "Open Image Files", QString{}, kFileFilter); if (files.isEmpty()) return; @@ -92,88 +187,204 @@ void MainWindow::on_open_files() { } status_label_->setText( - QString::fromStdString(std::format("{} file(s) selected", input_files_.size()))); + QString::fromStdString( + std::format("{} file(s) selected", input_files_.size()))); + convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty()); } void MainWindow::on_select_output_dir() { - QString dir = QFileDialog::getExistingDirectory(this, "Select Output Directory"); + const QString dir = QFileDialog::getExistingDirectory( + this, "Select Output Directory"); if (dir.isEmpty()) return; output_dir_ = dir.toStdString(); output_dir_button_->setText( QString::fromStdString(std::format("Output: {}", output_dir_))); + convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty()); } void MainWindow::on_convert() { if (input_files_.empty() || output_dir_.empty()) return; + // Disable buttons during conversion. convert_button_->setEnabled(false); - RawLoader loader; + open_button_->setEnabled(false); + batch_button_->setEnabled(false); + progress_bar_->setValue(0); - int processed = 0; - for (const auto& file : input_files_) { - status_label_->setText( - QString::fromStdString(std::format("Processing: {}", file))); + // Resolve output format from combo selection. + const auto fmt = static_cast( + format_combo_->currentData().toInt()); - // Load - auto load_result = loader.load(file); - if (!load_result.has_value()) { - QMessageBox::warning(this, "Load Error", - QString::fromStdString(load_result.error().format())); - continue; + // Create worker + thread. + worker_thread_ = new QThread(this); + worker_ = new ConversionWorker(input_files_, output_dir_, fmt, /*quality=*/95); + worker_->moveToThread(worker_thread_); + + // Wire up signals. + connect(worker_thread_, &QThread::started, + worker_, &ConversionWorker::run); + + connect(worker_, &ConversionWorker::file_done, + this, &MainWindow::on_file_done); + + connect(worker_, &ConversionWorker::preview_ready, + this, &MainWindow::on_preview_ready); + + connect(worker_, &ConversionWorker::finished, + this, &MainWindow::on_conversion_finished); + + // Clean up worker and thread after completion. + connect(worker_, &ConversionWorker::finished, + worker_thread_, &QThread::quit); + connect(worker_thread_, &QThread::finished, + worker_, &QObject::deleteLater); + connect(worker_thread_, &QThread::finished, + worker_thread_, &QObject::deleteLater); + + worker_thread_->start(); +} + +void MainWindow::on_batch() { + // Let the user pick a config file, then discover and convert all + // matching images from AppConfig::batch.input_dir. + + const QString config_path = QFileDialog::getOpenFileName( + this, "Open Batch Configuration", QString{}, kConfigFilter); + + if (config_path.isEmpty()) return; + + auto cfg_result = AppConfig::load(config_path.toStdString()); + if (!cfg_result.has_value()) { + QMessageBox::warning(this, "Config Error", + QString::fromStdString(cfg_result.error().message)); + return; + } + + const AppConfig& app_cfg = cfg_result.value(); + + // Discover files. + const auto extensions = app_cfg.parsed_extensions(); + std::vector discovered; + + const auto scan_entry = [&](const std::filesystem::directory_entry& entry) { + if (!entry.is_regular_file()) return; + std::string ext = entry.path().extension().string(); + std::ranges::transform(ext, ext.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + if (std::ranges::any_of(extensions, [&ext](const std::string& e) { return e == ext; })) { + discovered.push_back(entry.path().string()); } + }; - // Build pipeline with output stage - auto run_pipeline = std::make_unique(); - run_pipeline->add_stage(std::make_unique()); - run_pipeline->add_stage(std::make_unique()); - run_pipeline->add_stage(std::make_unique()); - run_pipeline->add_stage(std::make_unique()); - run_pipeline->add_stage(std::make_unique()); - run_pipeline->add_stage(std::make_unique( - OutputConfig{output_dir_, OutputFormat::PNG_16bit})); - - // Execute - auto result = run_pipeline->execute( - std::move(load_result.value()), - [this](const std::string& stage, float progress) { - progress_bar_->setValue(static_cast(progress * 100)); - status_label_->setText(QString::fromStdString(stage)); - QApplication::processEvents(); - }); - - if (!result.has_value()) { - QMessageBox::warning(this, "Processing Error", - QString::fromStdString(result.error().format())); + try { + if (app_cfg.batch.recursive) { + for (const auto& e : std::filesystem::recursive_directory_iterator{app_cfg.batch.input_dir}) { + scan_entry(e); + } } else { - ++processed; - update_preview(result.value().rgb); + for (const auto& e : std::filesystem::directory_iterator{app_cfg.batch.input_dir}) { + scan_entry(e); + } + } + } catch (const std::exception& ex) { + QMessageBox::warning(this, "Directory Error", ex.what()); + return; + } + + if (discovered.empty()) { + QMessageBox::information(this, "Batch", + QString::fromStdString( + std::format("No matching files found in: {}", + app_cfg.batch.input_dir.string()))); + return; + } + + // Populate state and trigger conversion. + input_files_ = discovered; + output_dir_ = app_cfg.batch.output_dir.string(); + output_dir_button_->setText( + QString::fromStdString(std::format("Output: {}", output_dir_))); + + // Select format combo to match the config. + for (int i = 0; i < format_combo_->count(); ++i) { + if (static_cast(format_combo_->itemData(i).toInt()) == + app_cfg.output_format()) { + format_combo_->setCurrentIndex(i); + break; } } + status_label_->setText( + QString::fromStdString( + std::format("Batch: {} file(s) from {}", + discovered.size(), app_cfg.batch.input_dir.string()))); + + // Start conversion on the background thread. + on_convert(); +} + +void MainWindow::on_file_done(int index, int total, bool ok, QString message) { + // Update progress bar. + const int progress = static_cast( + 100.0 * static_cast(index + 1) / static_cast(total)); + progress_bar_->setValue(progress); + + // Update status label. + const QString icon = ok ? "OK" : "FAIL"; + status_label_->setText( + QString("[%1/%2] %3: %4").arg(index + 1).arg(total).arg(icon).arg(message)); +} + +void MainWindow::on_preview_ready(cv::Mat image) { + update_preview(image); +} + +void MainWindow::on_conversion_finished(int success_count, int total) { progress_bar_->setValue(100); status_label_->setText( - QString::fromStdString(std::format("Done: {}/{} files processed", - processed, input_files_.size()))); - convert_button_->setEnabled(true); + QString::fromStdString( + std::format("Done: {}/{} file(s) converted successfully", + success_count, total))); + + // Re-enable UI controls. + convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty()); + open_button_->setEnabled(true); + batch_button_->setEnabled(true); + + worker_thread_ = nullptr; + worker_ = nullptr; } +// ───────────────────────────────────────────────────────────────────────────── +// update_preview +// ───────────────────────────────────────────────────────────────────────────── + void MainWindow::update_preview(const cv::Mat& image) { if (image.empty()) return; - // Convert 16-bit BGR to 8-bit RGB for Qt display + // Convert 16-bit BGR to 8-bit RGB for Qt display. cv::Mat display; image.convertTo(display, CV_8UC3, 1.0 / 257.0); cv::cvtColor(display, display, cv::COLOR_BGR2RGB); - QImage qimg(display.data, display.cols, display.rows, - static_cast(display.step), QImage::Format_RGB888); + // Wrap in QImage (no copy – display must stay alive until pixmap is created). + const QImage qimg{ + display.data, + display.cols, + display.rows, + static_cast(display.step), + QImage::Format_RGB888 + }; + + // Scale to fit preview label while preserving aspect ratio. + const QPixmap pixmap = QPixmap::fromImage(qimg).scaled( + preview_label_->size(), + Qt::KeepAspectRatio, + Qt::SmoothTransformation); - // Scale to fit preview label - QPixmap pixmap = QPixmap::fromImage(qimg).scaled( - preview_label_->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); preview_label_->setPixmap(pixmap); } diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 16ab0c6..28072f4 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -2,29 +2,87 @@ #include "../converter/pipeline/Pipeline.h" #include "../converter/pipeline/ImageData.h" +#include "../converter/output/OutputWriter.h" +#include "../config/AppConfig.h" -#include +#include +#include #include +#include #include #include +#include #include #include namespace photoconv { +/** + * @brief Worker object that runs pipeline conversions on a background thread. + * + * Moved to a QThread so that the GUI remains responsive during batch + * processing. Results are emitted via Qt signals. + */ +class ConversionWorker : public QObject { + Q_OBJECT + +public: + /** + * @brief Construct a worker with the list of files to convert. + * + * @param files Absolute paths to input images. + * @param output_dir Target directory for converted files. + * @param fmt Output format selection. + * @param quality JPEG quality (0-100). + */ + explicit ConversionWorker(std::vector files, + std::string output_dir, + OutputFormat fmt, + int quality, + QObject* parent = nullptr); + +public slots: + /** Run the conversion. Called when the thread starts. */ + void run(); + +signals: + /** Emitted after each file completes or fails. + * @param index 0-based index of the completed file. + * @param total Total number of files. + * @param ok Whether conversion succeeded. + * @param message Human-readable status or error text. + */ + void file_done(int index, int total, bool ok, QString message); + + /** Emitted with the final converted image for preview. */ + void preview_ready(cv::Mat image); + + /** Emitted when all files have been processed. */ + void finished(int success_count, int total); + +private: + std::vector files_; + std::string output_dir_; + OutputFormat fmt_; + int quality_; +}; + +// ───────────────────────────────────────────────────────────────────────────── + /** * @brief Main application window for the photo converter GUI. * * Provides: * - File selection dialog (RAW + standard image filters) * - Image preview (before/after) - * - Pipeline execution with progress bar - * - Output format selection - * - Batch processing support + * - Pipeline execution with progress bar on a background thread + * - Output format selection (PNG 16-bit, PNG 8-bit, TIFF 16-bit, JPEG) + * - Film type selection (Auto, C-41 Color, B&W) + * - Batch processing via "Batch..." button (loads an AppConfig INI file) * - * The GUI is thin: it delegates all processing to the Pipeline - * and only handles user interaction and display. + * The GUI is thin: it delegates all processing to the Pipeline and only + * handles user interaction and display. */ class MainWindow : public QMainWindow { Q_OBJECT @@ -37,32 +95,55 @@ private slots: void on_open_files(); void on_convert(); void on_select_output_dir(); + void on_batch(); + + /** Slot connected to ConversionWorker::file_done. */ + void on_file_done(int index, int total, bool ok, QString message); + + /** Slot connected to ConversionWorker::preview_ready. */ + void on_preview_ready(cv::Mat image); + + /** Slot connected to ConversionWorker::finished. */ + void on_conversion_finished(int success_count, int total); private: void setup_ui(); - void setup_pipeline(); void update_preview(const cv::Mat& image); - // UI elements - QLabel* preview_label_{nullptr}; + // ── UI elements ────────────────────────────────────────────────────────── + QLabel* preview_label_{nullptr}; QProgressBar* progress_bar_{nullptr}; QPushButton* open_button_{nullptr}; QPushButton* convert_button_{nullptr}; QPushButton* output_dir_button_{nullptr}; - QLabel* status_label_{nullptr}; + QPushButton* batch_button_{nullptr}; + QLabel* status_label_{nullptr}; - // State + // Settings widgets + QComboBox* format_combo_{nullptr}; // Output format selector + QComboBox* film_combo_{nullptr}; // Film type selector + QGroupBox* settings_box_{nullptr}; + + // ── State ──────────────────────────────────────────────────────────────── std::vector input_files_; - std::string output_dir_; - std::unique_ptr pipeline_; + std::string output_dir_; - /// Qt file dialog filter string for supported formats. + // Background thread for conversion + QThread* worker_thread_{nullptr}; + ConversionWorker* worker_{nullptr}; + + // ── Constants ──────────────────────────────────────────────────────────── + /// Qt file dialog filter string for all supported formats. static constexpr const char* kFileFilter = "All Supported (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef " "*.jpg *.jpeg *.png *.tif *.tiff);;" "RAW (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef);;" "Images (*.jpg *.jpeg *.png *.tif *.tiff);;" "All Files (*)"; + + /// Qt file dialog filter for INI configuration files. + static constexpr const char* kConfigFilter = + "Config files (*.ini *.cfg *.conf *.toml);;All Files (*)"; }; } // namespace photoconv diff --git a/src/main.cpp b/src/main.cpp index 07e9b31..a67d548 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,35 +1,49 @@ #include "cli/CliRunner.h" -#include "gui/MainWindow.h" - -#include #include #include #include +#ifndef NO_GUI +#include "gui/MainWindow.h" +#include +#endif + /** * @brief Application entry point. * - * Supports two modes: - * - GUI mode (default): launches the Qt MainWindow - * - CLI mode (--cli flag): batch processes files without GUI + * Supports two operating modes: + * + * **GUI mode** (default when compiled with Qt): + * Launches the Qt MainWindow. + * + * **CLI / batch mode** (activated by `--cli` or `--batch`): + * Processes files from the command line or a config file without any GUI. + * Progress is written to stderr; errors are logged but do not abort the batch. + * + * @note The `--batch` flag (or `--config `) implies CLI mode. */ int main(int argc, char* argv[]) { - // Check if CLI mode is requested + // Determine whether CLI/batch mode was requested. bool cli_mode = false; for (int i = 1; i < argc; ++i) { - if (std::string{argv[i]} == "--cli") { + const std::string arg{argv[i]}; + if (arg == "--cli" || arg == "--batch" || arg == "--config") { cli_mode = true; break; } } if (cli_mode) { - // CLI batch mode (no Qt dependency) + // ── CLI / Batch mode (no Qt dependency) ───────────────────────────── auto config_result = photoconv::CliRunner::parse_args(argc, argv); if (!config_result.has_value()) { - std::cerr << config_result.error().format() << std::endl; - return 1; + // "Help requested" is not an error – exit 0. + const bool is_help = config_result.error().message == "Help requested"; + if (!is_help) { + std::cerr << config_result.error().format() << std::endl; + } + return is_help ? 0 : 1; } photoconv::CliRunner runner; @@ -39,16 +53,24 @@ int main(int argc, char* argv[]) { return 1; } + // Exit code 0 if at least one file was converted, 1 otherwise. return result.value() > 0 ? 0 : 1; } - // GUI mode - QApplication app(argc, argv); +#ifndef NO_GUI + // ── GUI mode ───────────────────────────────────────────────────────────── + QApplication app{argc, argv}; app.setApplicationName("Photo Converter"); app.setApplicationVersion("0.1.0"); + app.setOrganizationName("photo-converter"); photoconv::MainWindow window; window.show(); return app.exec(); +#else + std::cerr << "This build was compiled without GUI support.\n" + "Use --cli or --batch mode.\n"; + return 1; +#endif }