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 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-14 09:41:23 +01:00
parent fd2d97ddeb
commit 71d535fc50
3 changed files with 421 additions and 107 deletions

View File

@@ -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 <QApplication>
#include <QFileDialog>
#include <QHBoxLayout>
#include <QMessageBox>
@@ -19,70 +21,163 @@
namespace photoconv {
// ─────────────────────────────────────────────────────────────────────────────
// ConversionWorker
// ─────────────────────────────────────────────────────────────────────────────
ConversionWorker::ConversionWorker(std::vector<std::string> 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<Preprocessor>());
pipeline.add_stage(std::make_unique<NegativeDetector>());
pipeline.add_stage(std::make_unique<Inverter>());
pipeline.add_stage(std::make_unique<ColorCorrector>());
pipeline.add_stage(std::make_unique<CropProcessor>());
pipeline.add_stage(std::make_unique<OutputWriter>(
OutputConfig{output_dir_, fmt_, quality_}));
const int total = static_cast<int>(files_.size());
int success_count = 0;
for (int idx = 0; idx < total; ++idx) {
const std::string& file = files_[static_cast<std::size_t>(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);
// ── Top toolbar ──────────────────────────────────────────────────────────
auto* toolbar = new QHBoxLayout();
// Button bar
auto* button_layout = new QHBoxLayout();
open_button_ = new QPushButton("Open Files...", this);
convert_button_ = new QPushButton("Convert", 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<int>(OutputFormat::PNG_16bit)});
format_combo_->addItem("PNG 8-bit (Web)", QVariant{static_cast<int>(OutputFormat::PNG_8bit)});
format_combo_->addItem("TIFF 16-bit (Editing)", QVariant{static_cast<int>(OutputFormat::TIFF_16bit)});
format_combo_->addItem("JPEG (Preview)", QVariant{static_cast<int>(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
// ── 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<Pipeline>();
// Note: Loader is called separately before the pipeline.
// Pipeline stages are: Preprocess -> Detect -> Invert -> Color -> PostProcess -> Output
pipeline_->add_stage(std::make_unique<Preprocessor>());
pipeline_->add_stage(std::make_unique<NegativeDetector>());
pipeline_->add_stage(std::make_unique<Inverter>());
pipeline_->add_stage(std::make_unique<ColorCorrector>());
pipeline_->add_stage(std::make_unique<CropProcessor>());
// 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<OutputFormat>(
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;
}
// Build pipeline with output stage
auto run_pipeline = std::make_unique<Pipeline>();
run_pipeline->add_stage(std::make_unique<Preprocessor>());
run_pipeline->add_stage(std::make_unique<NegativeDetector>());
run_pipeline->add_stage(std::make_unique<Inverter>());
run_pipeline->add_stage(std::make_unique<ColorCorrector>());
run_pipeline->add_stage(std::make_unique<CropProcessor>());
run_pipeline->add_stage(std::make_unique<OutputWriter>(
OutputConfig{output_dir_, OutputFormat::PNG_16bit}));
const AppConfig& app_cfg = cfg_result.value();
// Execute
auto result = run_pipeline->execute(
std::move(load_result.value()),
[this](const std::string& stage, float progress) {
progress_bar_->setValue(static_cast<int>(progress * 100));
status_label_->setText(QString::fromStdString(stage));
QApplication::processEvents();
});
// Discover files.
const auto extensions = app_cfg.parsed_extensions();
std::vector<std::string> discovered;
if (!result.has_value()) {
QMessageBox::warning(this, "Processing Error",
QString::fromStdString(result.error().format()));
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<char>(std::tolower(c)); });
if (std::ranges::any_of(extensions, [&ext](const std::string& e) { return e == ext; })) {
discovered.push_back(entry.path().string());
}
};
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<OutputFormat>(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<int>(
100.0 * static_cast<double>(index + 1) / static_cast<double>(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<int>(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<qsizetype>(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);
}

View File

@@ -2,29 +2,87 @@
#include "../converter/pipeline/Pipeline.h"
#include "../converter/pipeline/ImageData.h"
#include "../converter/output/OutputWriter.h"
#include "../config/AppConfig.h"
#include <QMainWindow>
#include <QComboBox>
#include <QGroupBox>
#include <QLabel>
#include <QMainWindow>
#include <QProgressBar>
#include <QPushButton>
#include <QThread>
#include <memory>
#include <vector>
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<std::string> 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<std::string> 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
// ── UI elements ──────────────────────────────────────────────────────────
QLabel* preview_label_{nullptr};
QProgressBar* progress_bar_{nullptr};
QPushButton* open_button_{nullptr};
QPushButton* convert_button_{nullptr};
QPushButton* output_dir_button_{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<std::string> input_files_;
std::string output_dir_;
std::unique_ptr<Pipeline> pipeline_;
/// 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

View File

@@ -1,35 +1,49 @@
#include "cli/CliRunner.h"
#include "gui/MainWindow.h"
#include <QApplication>
#include <algorithm>
#include <iostream>
#include <string>
#ifndef NO_GUI
#include "gui/MainWindow.h"
#include <QApplication>
#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 <file>`) 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()) {
// "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 1;
}
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
}