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:
@@ -1,4 +1,5 @@
|
|||||||
#include "MainWindow.h"
|
#include "MainWindow.h"
|
||||||
|
|
||||||
#include "../converter/rawloader/RawLoader.h"
|
#include "../converter/rawloader/RawLoader.h"
|
||||||
#include "../converter/preprocess/Preprocessor.h"
|
#include "../converter/preprocess/Preprocessor.h"
|
||||||
#include "../converter/negative/NegativeDetector.h"
|
#include "../converter/negative/NegativeDetector.h"
|
||||||
@@ -7,6 +8,7 @@
|
|||||||
#include "../converter/crop/CropProcessor.h"
|
#include "../converter/crop/CropProcessor.h"
|
||||||
#include "../converter/output/OutputWriter.h"
|
#include "../converter/output/OutputWriter.h"
|
||||||
|
|
||||||
|
#include <QApplication>
|
||||||
#include <QFileDialog>
|
#include <QFileDialog>
|
||||||
#include <QHBoxLayout>
|
#include <QHBoxLayout>
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
@@ -19,70 +21,163 @@
|
|||||||
|
|
||||||
namespace photoconv {
|
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)
|
MainWindow::MainWindow(QWidget* parent)
|
||||||
: QMainWindow{parent}
|
: QMainWindow{parent}
|
||||||
{
|
{
|
||||||
setup_ui();
|
setup_ui();
|
||||||
setup_pipeline();
|
setWindowTitle("Photo Converter – Analog Negative to Digital Positive");
|
||||||
setWindowTitle("Photo Converter");
|
resize(900, 680);
|
||||||
resize(800, 600);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// setup_ui
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
void MainWindow::setup_ui() {
|
void MainWindow::setup_ui() {
|
||||||
auto* central = new QWidget(this);
|
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);
|
open_button_ = new QPushButton("Open Files...", this);
|
||||||
convert_button_ = new QPushButton("Convert", this);
|
|
||||||
output_dir_button_ = new QPushButton("Output Dir...", 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);
|
convert_button_->setEnabled(false);
|
||||||
|
|
||||||
button_layout->addWidget(open_button_);
|
toolbar->addWidget(open_button_);
|
||||||
button_layout->addWidget(output_dir_button_);
|
toolbar->addWidget(output_dir_button_);
|
||||||
button_layout->addWidget(convert_button_);
|
toolbar->addWidget(convert_button_);
|
||||||
layout->addLayout(button_layout);
|
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_ = new QLabel("No image loaded", this);
|
||||||
preview_label_->setAlignment(Qt::AlignCenter);
|
preview_label_->setAlignment(Qt::AlignCenter);
|
||||||
preview_label_->setMinimumSize(640, 480);
|
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_ = new QProgressBar(this);
|
||||||
progress_bar_->setRange(0, 100);
|
progress_bar_->setRange(0, 100);
|
||||||
progress_bar_->setValue(0);
|
progress_bar_->setValue(0);
|
||||||
layout->addWidget(progress_bar_);
|
root_layout->addWidget(progress_bar_);
|
||||||
|
|
||||||
// Status
|
// ── Status line ──────────────────────────────────────────────────────────
|
||||||
status_label_ = new QLabel("Ready", this);
|
status_label_ = new QLabel("Ready", this);
|
||||||
layout->addWidget(status_label_);
|
root_layout->addWidget(status_label_);
|
||||||
|
|
||||||
setCentralWidget(central);
|
setCentralWidget(central);
|
||||||
|
|
||||||
// Connections
|
// ── Signal/slot connections ───────────────────────────────────────────────
|
||||||
connect(open_button_, &QPushButton::clicked, this, &MainWindow::on_open_files);
|
connect(open_button_, &QPushButton::clicked, this, &MainWindow::on_open_files);
|
||||||
connect(convert_button_, &QPushButton::clicked, this, &MainWindow::on_convert);
|
connect(convert_button_, &QPushButton::clicked, this, &MainWindow::on_convert);
|
||||||
connect(output_dir_button_, &QPushButton::clicked, this, &MainWindow::on_select_output_dir);
|
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>();
|
// Slots
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
void MainWindow::on_open_files() {
|
void MainWindow::on_open_files() {
|
||||||
QStringList files = QFileDialog::getOpenFileNames(
|
const QStringList files = QFileDialog::getOpenFileNames(
|
||||||
this, "Open Image Files", QString(), kFileFilter);
|
this, "Open Image Files", QString{}, kFileFilter);
|
||||||
|
|
||||||
if (files.isEmpty()) return;
|
if (files.isEmpty()) return;
|
||||||
|
|
||||||
@@ -92,88 +187,204 @@ void MainWindow::on_open_files() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
status_label_->setText(
|
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());
|
convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::on_select_output_dir() {
|
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;
|
if (dir.isEmpty()) return;
|
||||||
|
|
||||||
output_dir_ = dir.toStdString();
|
output_dir_ = dir.toStdString();
|
||||||
output_dir_button_->setText(
|
output_dir_button_->setText(
|
||||||
QString::fromStdString(std::format("Output: {}", output_dir_)));
|
QString::fromStdString(std::format("Output: {}", output_dir_)));
|
||||||
|
|
||||||
convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty());
|
convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
void MainWindow::on_convert() {
|
void MainWindow::on_convert() {
|
||||||
if (input_files_.empty() || output_dir_.empty()) return;
|
if (input_files_.empty() || output_dir_.empty()) return;
|
||||||
|
|
||||||
|
// Disable buttons during conversion.
|
||||||
convert_button_->setEnabled(false);
|
convert_button_->setEnabled(false);
|
||||||
RawLoader loader;
|
open_button_->setEnabled(false);
|
||||||
|
batch_button_->setEnabled(false);
|
||||||
|
progress_bar_->setValue(0);
|
||||||
|
|
||||||
int processed = 0;
|
// Resolve output format from combo selection.
|
||||||
for (const auto& file : input_files_) {
|
const auto fmt = static_cast<OutputFormat>(
|
||||||
status_label_->setText(
|
format_combo_->currentData().toInt());
|
||||||
QString::fromStdString(std::format("Processing: {}", file)));
|
|
||||||
|
|
||||||
// Load
|
// Create worker + thread.
|
||||||
auto load_result = loader.load(file);
|
worker_thread_ = new QThread(this);
|
||||||
if (!load_result.has_value()) {
|
worker_ = new ConversionWorker(input_files_, output_dir_, fmt, /*quality=*/95);
|
||||||
QMessageBox::warning(this, "Load Error",
|
worker_->moveToThread(worker_thread_);
|
||||||
QString::fromStdString(load_result.error().format()));
|
|
||||||
continue;
|
// 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
|
const AppConfig& app_cfg = cfg_result.value();
|
||||||
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}));
|
|
||||||
|
|
||||||
// Execute
|
// Discover files.
|
||||||
auto result = run_pipeline->execute(
|
const auto extensions = app_cfg.parsed_extensions();
|
||||||
std::move(load_result.value()),
|
std::vector<std::string> discovered;
|
||||||
[this](const std::string& stage, float progress) {
|
|
||||||
progress_bar_->setValue(static_cast<int>(progress * 100));
|
|
||||||
status_label_->setText(QString::fromStdString(stage));
|
|
||||||
QApplication::processEvents();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.has_value()) {
|
const auto scan_entry = [&](const std::filesystem::directory_entry& entry) {
|
||||||
QMessageBox::warning(this, "Processing Error",
|
if (!entry.is_regular_file()) return;
|
||||||
QString::fromStdString(result.error().format()));
|
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 {
|
} else {
|
||||||
++processed;
|
for (const auto& e : std::filesystem::directory_iterator{app_cfg.batch.input_dir}) {
|
||||||
update_preview(result.value().rgb);
|
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);
|
progress_bar_->setValue(100);
|
||||||
status_label_->setText(
|
status_label_->setText(
|
||||||
QString::fromStdString(std::format("Done: {}/{} files processed",
|
QString::fromStdString(
|
||||||
processed, input_files_.size())));
|
std::format("Done: {}/{} file(s) converted successfully",
|
||||||
convert_button_->setEnabled(true);
|
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) {
|
void MainWindow::update_preview(const cv::Mat& image) {
|
||||||
if (image.empty()) return;
|
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;
|
cv::Mat display;
|
||||||
image.convertTo(display, CV_8UC3, 1.0 / 257.0);
|
image.convertTo(display, CV_8UC3, 1.0 / 257.0);
|
||||||
cv::cvtColor(display, display, cv::COLOR_BGR2RGB);
|
cv::cvtColor(display, display, cv::COLOR_BGR2RGB);
|
||||||
|
|
||||||
QImage qimg(display.data, display.cols, display.rows,
|
// Wrap in QImage (no copy – display must stay alive until pixmap is created).
|
||||||
static_cast<int>(display.step), QImage::Format_RGB888);
|
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);
|
preview_label_->setPixmap(pixmap);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,29 +2,87 @@
|
|||||||
|
|
||||||
#include "../converter/pipeline/Pipeline.h"
|
#include "../converter/pipeline/Pipeline.h"
|
||||||
#include "../converter/pipeline/ImageData.h"
|
#include "../converter/pipeline/ImageData.h"
|
||||||
|
#include "../converter/output/OutputWriter.h"
|
||||||
|
#include "../config/AppConfig.h"
|
||||||
|
|
||||||
#include <QMainWindow>
|
#include <QComboBox>
|
||||||
|
#include <QGroupBox>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
|
#include <QMainWindow>
|
||||||
#include <QProgressBar>
|
#include <QProgressBar>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
|
#include <QThread>
|
||||||
|
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
namespace photoconv {
|
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.
|
* @brief Main application window for the photo converter GUI.
|
||||||
*
|
*
|
||||||
* Provides:
|
* Provides:
|
||||||
* - File selection dialog (RAW + standard image filters)
|
* - File selection dialog (RAW + standard image filters)
|
||||||
* - Image preview (before/after)
|
* - Image preview (before/after)
|
||||||
* - Pipeline execution with progress bar
|
* - Pipeline execution with progress bar on a background thread
|
||||||
* - Output format selection
|
* - Output format selection (PNG 16-bit, PNG 8-bit, TIFF 16-bit, JPEG)
|
||||||
* - Batch processing support
|
* - 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
|
* The GUI is thin: it delegates all processing to the Pipeline and only
|
||||||
* and only handles user interaction and display.
|
* handles user interaction and display.
|
||||||
*/
|
*/
|
||||||
class MainWindow : public QMainWindow {
|
class MainWindow : public QMainWindow {
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
@@ -37,32 +95,55 @@ private slots:
|
|||||||
void on_open_files();
|
void on_open_files();
|
||||||
void on_convert();
|
void on_convert();
|
||||||
void on_select_output_dir();
|
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:
|
private:
|
||||||
void setup_ui();
|
void setup_ui();
|
||||||
void setup_pipeline();
|
|
||||||
void update_preview(const cv::Mat& image);
|
void update_preview(const cv::Mat& image);
|
||||||
|
|
||||||
// UI elements
|
// ── UI elements ──────────────────────────────────────────────────────────
|
||||||
QLabel* preview_label_{nullptr};
|
QLabel* preview_label_{nullptr};
|
||||||
QProgressBar* progress_bar_{nullptr};
|
QProgressBar* progress_bar_{nullptr};
|
||||||
QPushButton* open_button_{nullptr};
|
QPushButton* open_button_{nullptr};
|
||||||
QPushButton* convert_button_{nullptr};
|
QPushButton* convert_button_{nullptr};
|
||||||
QPushButton* output_dir_button_{nullptr};
|
QPushButton* output_dir_button_{nullptr};
|
||||||
|
QPushButton* batch_button_{nullptr};
|
||||||
QLabel* status_label_{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::vector<std::string> input_files_;
|
||||||
std::string output_dir_;
|
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 =
|
static constexpr const char* kFileFilter =
|
||||||
"All Supported (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef "
|
"All Supported (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef "
|
||||||
"*.jpg *.jpeg *.png *.tif *.tiff);;"
|
"*.jpg *.jpeg *.png *.tif *.tiff);;"
|
||||||
"RAW (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef);;"
|
"RAW (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef);;"
|
||||||
"Images (*.jpg *.jpeg *.png *.tif *.tiff);;"
|
"Images (*.jpg *.jpeg *.png *.tif *.tiff);;"
|
||||||
"All Files (*)";
|
"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
|
} // namespace photoconv
|
||||||
|
|||||||
46
src/main.cpp
46
src/main.cpp
@@ -1,35 +1,49 @@
|
|||||||
#include "cli/CliRunner.h"
|
#include "cli/CliRunner.h"
|
||||||
#include "gui/MainWindow.h"
|
|
||||||
|
|
||||||
#include <QApplication>
|
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
#ifndef NO_GUI
|
||||||
|
#include "gui/MainWindow.h"
|
||||||
|
#include <QApplication>
|
||||||
|
#endif
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Application entry point.
|
* @brief Application entry point.
|
||||||
*
|
*
|
||||||
* Supports two modes:
|
* Supports two operating modes:
|
||||||
* - GUI mode (default): launches the Qt MainWindow
|
*
|
||||||
* - CLI mode (--cli flag): batch processes files without GUI
|
* **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[]) {
|
int main(int argc, char* argv[]) {
|
||||||
// Check if CLI mode is requested
|
// Determine whether CLI/batch mode was requested.
|
||||||
bool cli_mode = false;
|
bool cli_mode = false;
|
||||||
for (int i = 1; i < argc; ++i) {
|
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;
|
cli_mode = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cli_mode) {
|
if (cli_mode) {
|
||||||
// CLI batch mode (no Qt dependency)
|
// ── CLI / Batch mode (no Qt dependency) ─────────────────────────────
|
||||||
auto config_result = photoconv::CliRunner::parse_args(argc, argv);
|
auto config_result = photoconv::CliRunner::parse_args(argc, argv);
|
||||||
if (!config_result.has_value()) {
|
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;
|
std::cerr << config_result.error().format() << std::endl;
|
||||||
return 1;
|
}
|
||||||
|
return is_help ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
photoconv::CliRunner runner;
|
photoconv::CliRunner runner;
|
||||||
@@ -39,16 +53,24 @@ int main(int argc, char* argv[]) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exit code 0 if at least one file was converted, 1 otherwise.
|
||||||
return result.value() > 0 ? 0 : 1;
|
return result.value() > 0 ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// GUI mode
|
#ifndef NO_GUI
|
||||||
QApplication app(argc, argv);
|
// ── GUI mode ─────────────────────────────────────────────────────────────
|
||||||
|
QApplication app{argc, argv};
|
||||||
app.setApplicationName("Photo Converter");
|
app.setApplicationName("Photo Converter");
|
||||||
app.setApplicationVersion("0.1.0");
|
app.setApplicationVersion("0.1.0");
|
||||||
|
app.setOrganizationName("photo-converter");
|
||||||
|
|
||||||
photoconv::MainWindow window;
|
photoconv::MainWindow window;
|
||||||
window.show();
|
window.show();
|
||||||
|
|
||||||
return app.exec();
|
return app.exec();
|
||||||
|
#else
|
||||||
|
std::cerr << "This build was compiled without GUI support.\n"
|
||||||
|
"Use --cli or --batch mode.\n";
|
||||||
|
return 1;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user