feat: fix Docker Windows cross-compile and add color grading

- Fix MXE GPG key import (gpg --dearmor + signed-by)
- Fix MXE package names (opencv4→opencv)
- Use Ubuntu 20.04 base for windows-builder (MXE focal compatibility)
- Install CMake 3.20+ from Kitware PPA for windows-builder
- Add ColorGradingParams.h for color grading pipeline
- Update ColorCorrector, AppConfig, MainWindow, ImageData, CliRunner

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-15 09:49:42 +01:00
parent 0cbac0ff12
commit 4e4e19e80d
11 changed files with 793 additions and 63 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
build/
build-windows/
dist-windows/
output/
.git/

View File

@@ -11,9 +11,8 @@
# docker build --target windows-builder -t photo-converter:windows .
# ─────────────────────────────────────────────────────────────────────────────
# Ubuntu 22.04 LTS: MXE-Pakete sind für focal/jammy verfügbar
ARG UBUNTU_VERSION=22.04
FROM ubuntu:${UBUNTU_VERSION} AS base
# Ubuntu 22.04 LTS für Linux-Build
FROM ubuntu:22.04 AS base
ENV DEBIAN_FRONTEND=noninteractive \
TZ=UTC
@@ -73,27 +72,46 @@ CMD ["--batch", "--config", "config.ini"]
# ─────────────────────────────────────────────────────────────────────────────
# Stage: windows-builder
# Windows Cross-Compilation via MXE (MinGW-w64 + statisch gelinkte Deps)
# MXE-Pakete sind für Ubuntu 20.04 (focal) gebaut → eigene Base nötig
# ─────────────────────────────────────────────────────────────────────────────
FROM base AS windows-builder
FROM ubuntu:20.04 AS windows-builder
ENV DEBIAN_FRONTEND=noninteractive \
TZ=UTC
# MXE Repository einrichten
RUN apt-get update && apt-get install -y --no-install-recommends \
ninja-build \
build-essential \
pkg-config \
curl \
ca-certificates \
gnupg \
apt-transport-https \
lsb-release \
&& rm -rf /var/lib/apt/lists/*
# CMake 3.20+ von Kitware (Ubuntu 20.04 liefert nur 3.16)
RUN curl -fsSL https://apt.kitware.com/keys/kitware-archive-latest.asc \
| gpg --dearmor -o /usr/share/keyrings/kitware-archive-keyring.gpg \
&& echo "deb [signed-by=/usr/share/keyrings/kitware-archive-keyring.gpg] https://apt.kitware.com/ubuntu/ focal main" \
> /etc/apt/sources.list.d/kitware.list \
&& apt-get update && apt-get install -y --no-install-recommends cmake \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /project
# MXE Repository einrichten (focal = Ubuntu 20.04)
RUN curl -fsSL "https://pkg.mxe.cc/repos/apt/client-conf/mxeapt.gpg" \
-o /etc/apt/trusted.gpg.d/mxeapt.gpg \
&& echo "deb [arch=amd64] https://pkg.mxe.cc/repos/apt focal main" \
| gpg --dearmor -o /usr/share/keyrings/mxeapt.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/mxeapt.gpg] https://pkg.mxe.cc/repos/apt focal main" \
> /etc/apt/sources.list.d/mxeapt.list
# MXE-Pakete: OpenCV + LibRaw + Qt6 (statisch, x86_64)
# Hinweis: Initial-Download ~3 GB, dauert je nach Bandbreite 10-30 Min.
# MXE-Pakete: OpenCV + LibRaw (statisch, x86_64)
# Hinweis: MXE unterstützt kein Qt6 → Windows-Build als CLI ohne GUI
# Initial-Download ~1-2 GB, dauert je nach Bandbreite 5-15 Min.
RUN apt-get update && apt-get install -y --no-install-recommends \
mxe-x86-64-w64-mingw32.static-cmake \
mxe-x86-64-w64-mingw32.static-opencv4 \
mxe-x86-64-w64-mingw32.static-opencv \
mxe-x86-64-w64-mingw32.static-libraw \
mxe-x86-64-w64-mingw32.static-qtbase \
mxe-x86-64-w64-mingw32.static-cc \
&& rm -rf /var/lib/apt/lists/*
@@ -102,12 +120,13 @@ ENV MXE_PREFIX=/usr/lib/mxe/usr/x86_64-w64-mingw32.static \
COPY . /project
# System-cmake (3.20+) mit MXE-Toolchain und TryRunResults
RUN cmake -B build-windows -G Ninja \
-DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-mingw64.cmake \
-DCMAKE_TOOLCHAIN_FILE="${MXE_PREFIX}/share/cmake/mxe-conf.cmake" \
-C /usr/lib/mxe/usr/share/cmake/modules/TryRunResults.cmake \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_PREFIX_PATH="${MXE_PREFIX}" \
-DCMAKE_INSTALL_PREFIX=/project/dist-windows \
-DBUILD_GUI=ON \
-DBUILD_GUI=OFF \
-DBUILD_TESTS=OFF \
&& cmake --build build-windows --parallel "$(nproc)" \
&& cmake --install build-windows

View File

@@ -286,7 +286,7 @@ Pipeline CliRunner::build_pipeline(const AppConfig& app_cfg) {
pipeline.add_stage(std::make_unique<Inverter>());
}
pipeline.add_stage(std::make_unique<ColorCorrector>());
pipeline.add_stage(std::make_unique<ColorCorrector>(app_cfg.color_params()));
pipeline.add_stage(std::make_unique<CropProcessor>());

View File

@@ -116,6 +116,15 @@ std::expected<AppConfig, Error> AppConfig::load(const std::filesystem::path& pat
if (key == "jpeg_quality") cfg.quality.jpeg_quality = std::stoi(value);
else if (key == "sharpen_strength") cfg.quality.sharpen_strength = std::stod(value);
}
else if (current_section == "color") {
if (key == "temperature") cfg.color.temperature = std::stof(value);
else if (key == "tint") cfg.color.tint = std::stof(value);
else if (key == "r_gain") cfg.color.r_gain = std::stof(value);
else if (key == "g_gain") cfg.color.g_gain = std::stof(value);
else if (key == "b_gain") cfg.color.b_gain = std::stof(value);
else if (key == "brightness") cfg.color.brightness = std::stof(value);
else if (key == "contrast") cfg.color.contrast = std::stof(value);
}
// Unknown sections are ignored silently.
}
@@ -202,7 +211,66 @@ std::expected<void, Error> AppConfig::write_default(const std::filesystem::path&
"# JPEG output quality [0-100]\n"
"jpeg_quality = 95\n"
"# Unsharp-mask strength [0.0-1.0]\n"
"sharpen_strength = 0.5\n";
"sharpen_strength = 0.5\n"
"\n"
"[color]\n"
"# Color temperature offset [-100..+100] (positive = cool / more blue)\n"
"temperature = 0\n"
"# Tint offset [-100..+100] (positive = more green)\n"
"tint = 0\n"
"# Per-channel gain multipliers [0.5..2.0]\n"
"r_gain = 1.0\n"
"g_gain = 1.0\n"
"b_gain = 1.0\n"
"# Additive brightness offset [-100..+100]\n"
"brightness = 0\n"
"# Contrast S-curve strength [-100..+100]\n"
"contrast = 0\n";
return {};
}
// ─────────────────────────────────────────────────────────────────────────────
// AppConfig::write
// ─────────────────────────────────────────────────────────────────────────────
std::expected<void, Error> AppConfig::write(const std::filesystem::path& path) const {
std::filesystem::create_directories(path.parent_path());
std::ofstream file{path};
if (!file.is_open()) {
return std::unexpected(make_error(
ErrorCode::OutputWriteFailed,
std::format("Cannot create config file: {}", path.string())));
}
file << "# photo-converter configuration\n\n"
<< "[batch]\n"
<< "input_dir = " << batch.input_dir.string() << "\n"
<< "output_dir = " << batch.output_dir.string() << "\n"
<< "recursive = " << (batch.recursive ? "true" : "false") << "\n"
<< "file_extensions = " << batch.file_extensions << "\n"
<< "\n"
<< "[conversion]\n"
<< "film_type = " << conversion.film_type << "\n"
<< "output_format = " << conversion.output_format << "\n"
<< "output_bit_depth = " << conversion.output_bit_depth << "\n"
<< "auto_crop = " << (conversion.auto_crop ? "true" : "false") << "\n"
<< "sharpen = " << (conversion.sharpen ? "true" : "false") << "\n"
<< "invert = " << (conversion.invert ? "true" : "false") << "\n"
<< "\n"
<< "[quality]\n"
<< "jpeg_quality = " << quality.jpeg_quality << "\n"
<< "sharpen_strength = " << quality.sharpen_strength << "\n"
<< "\n"
<< "[color]\n"
<< "temperature = " << color.temperature << "\n"
<< "tint = " << color.tint << "\n"
<< "r_gain = " << color.r_gain << "\n"
<< "g_gain = " << color.g_gain << "\n"
<< "b_gain = " << color.b_gain << "\n"
<< "brightness = " << color.brightness << "\n"
<< "contrast = " << color.contrast << "\n";
return {};
}

View File

@@ -1,5 +1,6 @@
#pragma once
#include "../converter/color/ColorGradingParams.h"
#include "../converter/output/OutputWriter.h"
#include <expected>
@@ -75,6 +76,23 @@ struct QualityConfig {
double sharpen_strength{0.5};
};
/**
* @brief Color grading settings persisted in the [color] INI section.
*/
struct ColorConfig {
float temperature{0.0f}; ///< -100..+100
float tint{0.0f}; ///< -100..+100
float r_gain{1.0f}; ///< 0.5..2.0
float g_gain{1.0f}; ///< 0.5..2.0
float b_gain{1.0f}; ///< 0.5..2.0
float brightness{0.0f}; ///< -100..+100
float contrast{0.0f}; ///< -100..+100
[[nodiscard]] ColorGradingParams to_params() const noexcept {
return {temperature, tint, r_gain, g_gain, b_gain, brightness, contrast};
}
};
/**
* @brief Aggregated application configuration.
*
@@ -102,9 +120,10 @@ struct QualityConfig {
* @endcode
*/
struct AppConfig {
BatchConfig batch;
BatchConfig batch;
ConversionConfig conversion;
QualityConfig quality;
QualityConfig quality;
ColorConfig color;
/**
* @brief Load configuration from an INI-style file.
@@ -125,6 +144,11 @@ struct AppConfig {
*/
[[nodiscard]] OutputFormat output_format() const noexcept;
/**
* @brief Build a ColorGradingParams from the [color] section.
*/
[[nodiscard]] ColorGradingParams color_params() const noexcept { return color.to_params(); }
/**
* @brief Build a list of file extensions that should be processed.
*
@@ -145,6 +169,15 @@ struct AppConfig {
*/
[[nodiscard]] static std::expected<void, Error> write_default(
const std::filesystem::path& path);
/**
* @brief Write the current configuration values to a file.
*
* @param path Destination path.
* @return Error on I/O failure.
*/
[[nodiscard]] std::expected<void, Error> write(
const std::filesystem::path& path) const;
};
} // namespace photoconv

View File

@@ -8,6 +8,14 @@
namespace photoconv {
// ─────────────────────────────────────────────────────────────────────────────
// Constructor
// ─────────────────────────────────────────────────────────────────────────────
ColorCorrector::ColorCorrector(ColorGradingParams params)
: params_{params}
{}
// ─────────────────────────────────────────────────────────────────────────────
// PipelineStage interface
// ─────────────────────────────────────────────────────────────────────────────
@@ -19,29 +27,36 @@ StageResult ColorCorrector::process(ImageData data) const {
"ColorCorrector received empty image"));
}
StageResult base_result;
switch (data.film_type) {
case FilmType::ColorNegative: {
std::cout << "[Color] Applying C-41 correction followed by AWB" << std::endl;
auto result = correct_c41(std::move(data));
if (!result.has_value()) return result;
return auto_white_balance(std::move(result.value()));
auto r = correct_c41(std::move(data));
if (!r.has_value()) return r;
base_result = auto_white_balance(std::move(r.value()));
break;
}
case FilmType::BWNegative:
case FilmType::BWPositive:
std::cout << "[Color] B&W image, skipping colour correction" << std::endl;
return data;
base_result = std::move(data);
break;
case FilmType::ColorPositive:
std::cout << "[Color] Positive applying auto white balance" << std::endl;
return auto_white_balance(std::move(data));
base_result = auto_white_balance(std::move(data));
break;
case FilmType::Unknown:
std::cout << "[Color] Unknown film type applying auto white balance" << std::endl;
return auto_white_balance(std::move(data));
base_result = auto_white_balance(std::move(data));
break;
}
return data;
if (!base_result.has_value()) return base_result;
return apply_grading(std::move(base_result.value()));
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -184,4 +199,74 @@ StageResult ColorCorrector::apply_exif_wb(ImageData data) {
return data;
}
// ─────────────────────────────────────────────────────────────────────────────
// apply_grading
// ─────────────────────────────────────────────────────────────────────────────
StageResult ColorCorrector::apply_grading(ImageData data) const {
const auto& p = params_;
// Skip if all parameters are at their default values.
const bool all_default =
p.temperature == 0.0f && p.tint == 0.0f &&
p.r_gain == 1.0f && p.g_gain == 1.0f && p.b_gain == 1.0f &&
p.brightness == 0.0f && p.contrast == 0.0f;
if (all_default) return data;
// ── Channel gains ─────────────────────────────────────────────────────────
// BGR channel order: channels[0]=B, channels[1]=G, channels[2]=R
// Start from manual per-channel gains.
double scale_b = static_cast<double>(p.b_gain);
double scale_g = static_cast<double>(p.g_gain);
double scale_r = static_cast<double>(p.r_gain);
// Temperature: positive = boosts B (cool), negative = boosts R (warm).
// Formula per plan: b_gain *= (1 + temp*0.005), r_gain *= (1 - temp*0.005)
scale_b *= (1.0 + static_cast<double>(p.temperature) * 0.005);
scale_r *= (1.0 - static_cast<double>(p.temperature) * 0.005);
// Tint: positive = boosts G (green shift).
scale_g *= (1.0 + static_cast<double>(p.tint) * 0.005);
// Clamp to a small positive value to avoid black or overflow artifacts.
scale_b = std::max(0.01, scale_b);
scale_g = std::max(0.01, scale_g);
scale_r = std::max(0.01, scale_r);
std::cout << std::format(
"[Color] Grading gains: B={:.3f} G={:.3f} R={:.3f} brightness={:.0f} contrast={:.0f}",
scale_b, scale_g, scale_r, static_cast<double>(p.brightness),
static_cast<double>(p.contrast)) << std::endl;
std::vector<cv::Mat> channels(3);
cv::split(data.rgb, channels);
channels[0].convertTo(channels[0], CV_16U, scale_b);
channels[1].convertTo(channels[1], CV_16U, scale_g);
channels[2].convertTo(channels[2], CV_16U, scale_r);
cv::merge(channels, data.rgb);
// ── Brightness ─────────────────────────────────────────────────────────
// Additive offset: 1 unit = 1% of full 16-bit range (655.35 ≈ 655).
if (p.brightness != 0.0f) {
const double offset = static_cast<double>(p.brightness) * 655.0;
data.rgb.convertTo(data.rgb, CV_16UC3, 1.0, offset);
}
// ── Contrast ───────────────────────────────────────────────────────────
// S-curve: v' = (v - 32768) * factor + 32768, factor = 1 + contrast/100.
// Using convertTo(alpha, beta): dst = src * alpha + beta
// alpha = factor
// beta = 32768 * (1 - factor)
if (p.contrast != 0.0f) {
const double factor = 1.0 + static_cast<double>(p.contrast) / 100.0;
const double beta = 32768.0 * (1.0 - factor);
data.rgb.convertTo(data.rgb, CV_16UC3, factor, beta);
}
return data;
}
} // namespace photoconv

View File

@@ -1,5 +1,6 @@
#pragma once
#include "ColorGradingParams.h"
#include "../pipeline/PipelineStage.h"
namespace photoconv {
@@ -10,20 +11,23 @@ namespace photoconv {
* Applies film-type-specific corrections:
* - C-41: Orange cast removal using per-channel curves
* - Auto white balance using camera EXIF data or gray-world algorithm
* - Optional manual color temperature adjustment
* - Optional manual color temperature / tint / gain / brightness / contrast
*
* Uses the Strategy pattern internally: different correction algorithms
* are selected based on FilmType.
* are selected based on FilmType. After the base correction, user-defined
* ColorGradingParams are applied on top.
*/
class ColorCorrector : public PipelineStage {
public:
ColorCorrector() = default;
explicit ColorCorrector(ColorGradingParams params = {});
~ColorCorrector() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "ColorCorrection"; }
private:
ColorGradingParams params_;
/**
* @brief Apply C-41 specific color correction (orange cast removal).
*/
@@ -38,6 +42,11 @@ private:
* @brief Apply white balance from EXIF metadata.
*/
[[nodiscard]] static StageResult apply_exif_wb(ImageData data);
/**
* @brief Apply user-defined color grading on top of base corrections.
*/
[[nodiscard]] StageResult apply_grading(ImageData data) const;
};
} // namespace photoconv

View File

@@ -0,0 +1,21 @@
#pragma once
namespace photoconv {
/**
* @brief User-adjustable color grading parameters applied after base correction.
*
* All parameters use normalised ranges that map to linear gain/offset operations
* on the 16-bit (065535) pipeline image.
*/
struct ColorGradingParams {
float temperature{0.0f}; ///< -100..+100 cool ←→ warm (shifts B/R channels)
float tint{0.0f}; ///< -100..+100 green ←→ magenta (shifts G channel)
float r_gain{1.0f}; ///< 0.5..2.0 R-channel multiplier
float g_gain{1.0f}; ///< 0.5..2.0 G-channel multiplier
float b_gain{1.0f}; ///< 0.5..2.0 B-channel multiplier
float brightness{0.0f}; ///< -100..+100 additive offset on all channels
float contrast{0.0f}; ///< -100..+100 S-curve around 16-bit midpoint
};
} // namespace photoconv

View File

@@ -1,5 +1,7 @@
#pragma once
#include "../color/ColorGradingParams.h"
#include <opencv2/core.hpp>
#include <cstdint>
@@ -58,6 +60,7 @@ struct ImageData {
RawMetadata metadata; // Camera/RAW metadata
FilmType film_type{FilmType::Unknown}; // Detected after Detect stage
std::optional<cv::Rect> crop_region; // Set by Crop stage
ColorGradingParams color_params{}; // User-defined color grading overrides
};
} // namespace photoconv

View File

@@ -10,8 +10,13 @@
#include <QApplication>
#include <QFileDialog>
#include <QFrame>
#include <QGridLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPushButton>
#include <QScrollArea>
#include <QVBoxLayout>
#include <opencv2/imgproc.hpp>
@@ -29,25 +34,30 @@ ConversionWorker::ConversionWorker(std::vector<std::string> files,
std::string output_dir,
OutputFormat fmt,
int quality,
ColorGradingParams params,
QObject* parent)
: QObject{parent}
, files_{std::move(files)}
, output_dir_{std::move(output_dir)}
, fmt_{fmt}
, quality_{quality}
, params_{params}
{}
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>(
// ── Pre-color pipeline (Preprocessor → NegativeDetector → Inverter) ──────
Pipeline pre_pipeline;
pre_pipeline.add_stage(std::make_unique<Preprocessor>());
pre_pipeline.add_stage(std::make_unique<NegativeDetector>());
pre_pipeline.add_stage(std::make_unique<Inverter>());
// ── Post-color pipeline (ColorCorrector → CropProcessor → OutputWriter) ──
Pipeline post_pipeline;
post_pipeline.add_stage(std::make_unique<ColorCorrector>(params_));
post_pipeline.add_stage(std::make_unique<CropProcessor>());
post_pipeline.add_stage(std::make_unique<OutputWriter>(
OutputConfig{output_dir_, fmt_, quality_}));
const int total = static_cast<int>(files_.size());
@@ -64,8 +74,20 @@ void ConversionWorker::run() {
continue;
}
// Run pipeline
auto result = pipeline.execute(std::move(load_result.value()));
// Run pre-color pipeline
auto pre_result = pre_pipeline.execute(std::move(load_result.value()));
if (!pre_result.has_value()) {
emit file_done(idx, total, false,
QString::fromStdString(pre_result.error().message));
continue;
}
// Emit base image (after Inverter, before ColorCorrector) for live grading.
emit base_ready(pre_result.value().rgb.clone(),
static_cast<int>(pre_result.value().film_type));
// Run post-color pipeline
auto result = post_pipeline.execute(std::move(pre_result.value()));
if (!result.has_value()) {
emit file_done(idx, total, false,
QString::fromStdString(result.error().message));
@@ -74,7 +96,6 @@ void ConversionWorker::run() {
++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(
@@ -84,6 +105,38 @@ void ConversionWorker::run() {
emit finished(success_count, total);
}
// ─────────────────────────────────────────────────────────────────────────────
// ColorPreviewWorker
// ─────────────────────────────────────────────────────────────────────────────
ColorPreviewWorker::ColorPreviewWorker(cv::Mat base_image,
FilmType film_type,
ColorGradingParams params,
QObject* parent)
: QObject{parent}
, base_image_{std::move(base_image)}
, film_type_{film_type}
, params_{params}
{}
void ColorPreviewWorker::run() {
ImageData data;
data.rgb = base_image_; // ref-counted; no extra copy
data.film_type = film_type_;
ColorCorrector corrector{params_};
auto color_result = corrector.process(std::move(data));
if (color_result.has_value()) {
CropProcessor crop;
auto final_result = crop.process(std::move(color_result.value()));
if (final_result.has_value()) {
emit preview_ready(final_result.value().rgb.clone());
}
}
emit finished();
}
// ─────────────────────────────────────────────────────────────────────────────
// MainWindow constructor
// ─────────────────────────────────────────────────────────────────────────────
@@ -92,8 +145,16 @@ MainWindow::MainWindow(QWidget* parent)
: QMainWindow{parent}
{
setup_ui();
setup_color_dock();
setWindowTitle("Photo Converter Analog Negative to Digital Positive");
resize(900, 680);
resize(1100, 700);
// Debounce timer for live color preview (150 ms).
color_debounce_timer_ = new QTimer(this);
color_debounce_timer_->setSingleShot(true);
color_debounce_timer_->setInterval(150);
connect(color_debounce_timer_, &QTimer::timeout,
this, &MainWindow::on_reapply_color);
}
// ─────────────────────────────────────────────────────────────────────────────
@@ -171,6 +232,137 @@ void MainWindow::setup_ui() {
connect(batch_button_, &QPushButton::clicked, this, &MainWindow::on_batch);
}
// ─────────────────────────────────────────────────────────────────────────────
// setup_color_dock
// ─────────────────────────────────────────────────────────────────────────────
namespace {
/** Helper: create a paired QSlider + QDoubleSpinBox for a float parameter. */
void make_param_row(QGridLayout* grid, int row,
const QString& label,
double min, double max, double step, double value,
QSlider*& slider_out, QDoubleSpinBox*& spin_out,
QWidget* parent)
{
grid->addWidget(new QLabel(label, parent), row, 0);
auto* slider = new QSlider(Qt::Horizontal, parent);
// Map to integers scaled by 100 to preserve two decimal places.
slider->setRange(static_cast<int>(min * 100), static_cast<int>(max * 100));
slider->setValue(static_cast<int>(value * 100));
slider->setTickInterval(static_cast<int>((max - min) / 4.0 * 100));
grid->addWidget(slider, row, 1);
auto* spin = new QDoubleSpinBox(parent);
spin->setRange(min, max);
spin->setSingleStep(step);
spin->setDecimals(2);
spin->setValue(value);
spin->setFixedWidth(70);
grid->addWidget(spin, row, 2);
slider_out = slider;
spin_out = spin;
}
} // anonymous namespace
void MainWindow::setup_color_dock() {
color_dock_ = new QDockWidget("Color Grading", this);
color_dock_->setAllowedAreas(Qt::RightDockWidgetArea | Qt::LeftDockWidgetArea);
auto* container = new QWidget(color_dock_);
auto* outer = new QVBoxLayout(container);
auto* grid = new QGridLayout();
grid->setColumnStretch(1, 1);
// Row 0: Temperature
make_param_row(grid, 0, "Temperature",
-100.0, 100.0, 5.0, 0.0,
temp_slider_, temp_spin_, container);
// Row 1: Tint
make_param_row(grid, 1, "Tint",
-100.0, 100.0, 5.0, 0.0,
tint_slider_, tint_spin_, container);
// Row 2: R Gain
make_param_row(grid, 2, "R Gain",
0.5, 2.0, 0.05, 1.0,
r_slider_, r_spin_, container);
// Row 3: G Gain
make_param_row(grid, 3, "G Gain",
0.5, 2.0, 0.05, 1.0,
g_slider_, g_spin_, container);
// Row 4: B Gain
make_param_row(grid, 4, "B Gain",
0.5, 2.0, 0.05, 1.0,
b_slider_, b_spin_, container);
// Row 5: Brightness
make_param_row(grid, 5, "Brightness",
-100.0, 100.0, 5.0, 0.0,
bright_slider_, bright_spin_, container);
// Row 6: Contrast
make_param_row(grid, 6, "Contrast",
-100.0, 100.0, 5.0, 0.0,
contrast_slider_, contrast_spin_, container);
outer->addLayout(grid);
// ── Keyboard hint ─────────────────────────────────────────────────────
auto* hint = new QLabel(
"<small>T/N/R/G/B/L/C + ←→ to adjust · Ctrl+0 reset · Ctrl+S save</small>",
container);
hint->setWordWrap(true);
outer->addWidget(hint);
// ── Buttons ───────────────────────────────────────────────────────────
auto* btn_row = new QHBoxLayout();
auto* reset_btn = new QPushButton("Reset", container);
auto* save_btn = new QPushButton("Apply && Save to Config", container);
btn_row->addWidget(reset_btn);
btn_row->addWidget(save_btn);
outer->addLayout(btn_row);
outer->addStretch();
color_dock_->setWidget(container);
addDockWidget(Qt::RightDockWidgetArea, color_dock_);
// ── Wire slider ↔ spinbox synchronisation ─────────────────────────────
auto wire = [this](QSlider* slider, QDoubleSpinBox* spin) {
connect(slider, &QSlider::valueChanged, this, [this, slider, spin](int v) {
const double d = v / 100.0;
spin->blockSignals(true);
spin->setValue(d);
spin->blockSignals(false);
on_color_param_changed();
});
connect(spin, &QDoubleSpinBox::valueChanged, this, [this, slider, spin](double d) {
slider->blockSignals(true);
slider->setValue(static_cast<int>(d * 100));
slider->blockSignals(false);
on_color_param_changed();
});
};
wire(temp_slider_, temp_spin_);
wire(tint_slider_, tint_spin_);
wire(r_slider_, r_spin_);
wire(g_slider_, g_spin_);
wire(b_slider_, b_spin_);
wire(bright_slider_, bright_spin_);
wire(contrast_slider_, contrast_spin_);
connect(reset_btn, &QPushButton::clicked, this, &MainWindow::on_reset_color);
connect(save_btn, &QPushButton::clicked, this, &MainWindow::on_save_color_config);
}
// ─────────────────────────────────────────────────────────────────────────────
// Slots
// ─────────────────────────────────────────────────────────────────────────────
@@ -220,13 +412,17 @@ void MainWindow::on_convert() {
// Create worker + thread.
worker_thread_ = new QThread(this);
worker_ = new ConversionWorker(input_files_, output_dir_, fmt, /*quality=*/95);
worker_ = new ConversionWorker(
input_files_, output_dir_, fmt, /*quality=*/95, color_params_);
worker_->moveToThread(worker_thread_);
// Wire up signals.
connect(worker_thread_, &QThread::started,
worker_, &ConversionWorker::run);
connect(worker_, &ConversionWorker::base_ready,
this, &MainWindow::on_base_ready);
connect(worker_, &ConversionWorker::file_done,
this, &MainWindow::on_file_done);
@@ -248,9 +444,6 @@ void MainWindow::on_convert() {
}
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);
@@ -265,6 +458,10 @@ void MainWindow::on_batch() {
const AppConfig& app_cfg = cfg_result.value();
// Load color params from config into GUI sliders.
color_params_ = app_cfg.color_params();
update_color_widgets();
// Discover files.
const auto extensions = app_cfg.parsed_extensions();
std::vector<std::string> discovered;
@@ -302,13 +499,11 @@ void MainWindow::on_batch() {
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()) {
@@ -322,22 +517,24 @@ void MainWindow::on_batch() {
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_base_ready(cv::Mat image, int film_type) {
base_image_ = image;
base_film_type_ = static_cast<FilmType>(film_type);
}
void MainWindow::on_preview_ready(cv::Mat image) {
update_preview(image);
}
@@ -349,7 +546,6 @@ void MainWindow::on_conversion_finished(int success_count, int total) {
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);
@@ -358,8 +554,159 @@ void MainWindow::on_conversion_finished(int success_count, int total) {
worker_ = nullptr;
}
void MainWindow::on_color_param_changed() {
// Read slider/spinbox values back into color_params_.
color_params_.temperature = static_cast<float>(temp_spin_->value());
color_params_.tint = static_cast<float>(tint_spin_->value());
color_params_.r_gain = static_cast<float>(r_spin_->value());
color_params_.g_gain = static_cast<float>(g_spin_->value());
color_params_.b_gain = static_cast<float>(b_spin_->value());
color_params_.brightness = static_cast<float>(bright_spin_->value());
color_params_.contrast = static_cast<float>(contrast_spin_->value());
// Restart debounce timer.
color_debounce_timer_->start();
}
void MainWindow::on_reapply_color() {
if (base_image_.empty()) return;
// If a previous preview is still running, reschedule.
if (color_thread_ && color_thread_->isRunning()) {
color_debounce_timer_->start();
return;
}
auto* thread = new QThread(this);
auto* worker = new ColorPreviewWorker(
base_image_.clone(), base_film_type_, color_params_);
worker->moveToThread(thread);
connect(thread, &QThread::started, worker, &ColorPreviewWorker::run);
connect(worker, &ColorPreviewWorker::preview_ready,
this, &MainWindow::on_preview_ready);
connect(worker, &ColorPreviewWorker::finished, thread, &QThread::quit);
connect(thread, &QThread::finished, worker, &QObject::deleteLater);
connect(thread, &QThread::finished, thread, &QObject::deleteLater);
color_thread_ = thread;
color_worker_ = worker;
thread->start();
}
void MainWindow::on_reset_color() {
color_params_ = {};
update_color_widgets();
color_debounce_timer_->start();
}
void MainWindow::on_save_color_config() {
if (output_dir_.empty()) {
QMessageBox::information(this, "Save Config",
"Please select an output directory first.");
return;
}
const std::filesystem::path config_path =
std::filesystem::path{output_dir_} / "config.ini";
// Load existing config (or start from defaults) and patch color section.
AppConfig cfg{};
if (std::filesystem::exists(config_path)) {
auto r = AppConfig::load(config_path);
if (r.has_value()) cfg = r.value();
}
cfg.batch.output_dir = output_dir_;
cfg.color.temperature = color_params_.temperature;
cfg.color.tint = color_params_.tint;
cfg.color.r_gain = color_params_.r_gain;
cfg.color.g_gain = color_params_.g_gain;
cfg.color.b_gain = color_params_.b_gain;
cfg.color.brightness = color_params_.brightness;
cfg.color.contrast = color_params_.contrast;
auto write_result = cfg.write(config_path);
if (!write_result.has_value()) {
QMessageBox::warning(this, "Save Config",
QString::fromStdString(write_result.error().message));
return;
}
status_label_->setText(
QString::fromStdString(
std::format("Config saved: {}", config_path.string())));
}
// ─────────────────────────────────────────────────────────────────────────────
// update_preview
// Keyboard shortcuts
// ─────────────────────────────────────────────────────────────────────────────
void MainWindow::keyPressEvent(QKeyEvent* event) {
// Ctrl+0: reset all color params.
if (event->modifiers() & Qt::ControlModifier && event->key() == Qt::Key_0) {
on_reset_color();
return;
}
// Ctrl+S: save color config.
if (event->modifiers() & Qt::ControlModifier && event->key() == Qt::Key_S) {
on_save_color_config();
return;
}
// Set active parameter modifier key (no Ctrl/Alt/Shift).
if (!(event->modifiers() & (Qt::ControlModifier | Qt::AltModifier | Qt::ShiftModifier))) {
switch (event->key()) {
case Qt::Key_T: active_key_ = Qt::Key_T; return;
case Qt::Key_N: active_key_ = Qt::Key_N; return;
case Qt::Key_R: active_key_ = Qt::Key_R; return;
case Qt::Key_G: active_key_ = Qt::Key_G; return;
case Qt::Key_B: active_key_ = Qt::Key_B; return;
case Qt::Key_L: active_key_ = Qt::Key_L; return;
case Qt::Key_C: active_key_ = Qt::Key_C; return;
case Qt::Key_Left:
if (active_key_ != Qt::Key_unknown) {
adjust_active_param(-1.0f);
return;
}
break;
case Qt::Key_Right:
if (active_key_ != Qt::Key_unknown) {
adjust_active_param(+1.0f);
return;
}
break;
default:
break;
}
}
QMainWindow::keyPressEvent(event);
}
void MainWindow::keyReleaseEvent(QKeyEvent* event) {
if (event->key() == active_key_) {
active_key_ = Qt::Key_unknown;
}
QMainWindow::keyReleaseEvent(event);
}
void MainWindow::adjust_active_param(float delta) {
switch (active_key_) {
case Qt::Key_T: temp_spin_->setValue(temp_spin_->value() + delta * 5.0); break;
case Qt::Key_N: tint_spin_->setValue(tint_spin_->value() + delta * 5.0); break;
case Qt::Key_R: r_spin_->setValue(r_spin_->value() + delta * 0.05); break;
case Qt::Key_G: g_spin_->setValue(g_spin_->value() + delta * 0.05); break;
case Qt::Key_B: b_spin_->setValue(b_spin_->value() + delta * 0.05); break;
case Qt::Key_L: bright_spin_->setValue(bright_spin_->value() + delta * 5.0); break;
case Qt::Key_C: contrast_spin_->setValue(contrast_spin_->value() + delta * 5.0); break;
default: break;
}
// on_color_param_changed() is triggered by the spinbox valueChanged signal.
}
// ─────────────────────────────────────────────────────────────────────────────
// update_preview / update_color_widgets
// ─────────────────────────────────────────────────────────────────────────────
void MainWindow::update_preview(const cv::Mat& image) {
@@ -379,7 +726,6 @@ void MainWindow::update_preview(const cv::Mat& image) {
QImage::Format_RGB888
};
// Scale to fit preview label while preserving aspect ratio.
const QPixmap pixmap = QPixmap::fromImage(qimg).scaled(
preview_label_->size(),
Qt::KeepAspectRatio,
@@ -388,4 +734,44 @@ void MainWindow::update_preview(const cv::Mat& image) {
preview_label_->setPixmap(pixmap);
}
void MainWindow::update_color_widgets() {
// Block signals to avoid triggering on_color_param_changed recursively.
const auto block = [](QWidget* w, bool b) { w->blockSignals(b); };
for (auto* w : {static_cast<QWidget*>(temp_slider_), static_cast<QWidget*>(temp_spin_),
static_cast<QWidget*>(tint_slider_), static_cast<QWidget*>(tint_spin_),
static_cast<QWidget*>(r_slider_), static_cast<QWidget*>(r_spin_),
static_cast<QWidget*>(g_slider_), static_cast<QWidget*>(g_spin_),
static_cast<QWidget*>(b_slider_), static_cast<QWidget*>(b_spin_),
static_cast<QWidget*>(bright_slider_), static_cast<QWidget*>(bright_spin_),
static_cast<QWidget*>(contrast_slider_), static_cast<QWidget*>(contrast_spin_)}) {
block(w, true);
}
temp_slider_->setValue(static_cast<int>(color_params_.temperature * 100));
temp_spin_->setValue(static_cast<double>(color_params_.temperature));
tint_slider_->setValue(static_cast<int>(color_params_.tint * 100));
tint_spin_->setValue(static_cast<double>(color_params_.tint));
r_slider_->setValue(static_cast<int>(color_params_.r_gain * 100));
r_spin_->setValue(static_cast<double>(color_params_.r_gain));
g_slider_->setValue(static_cast<int>(color_params_.g_gain * 100));
g_spin_->setValue(static_cast<double>(color_params_.g_gain));
b_slider_->setValue(static_cast<int>(color_params_.b_gain * 100));
b_spin_->setValue(static_cast<double>(color_params_.b_gain));
bright_slider_->setValue(static_cast<int>(color_params_.brightness * 100));
bright_spin_->setValue(static_cast<double>(color_params_.brightness));
contrast_slider_->setValue(static_cast<int>(color_params_.contrast * 100));
contrast_spin_->setValue(static_cast<double>(color_params_.contrast));
for (auto* w : {static_cast<QWidget*>(temp_slider_), static_cast<QWidget*>(temp_spin_),
static_cast<QWidget*>(tint_slider_), static_cast<QWidget*>(tint_spin_),
static_cast<QWidget*>(r_slider_), static_cast<QWidget*>(r_spin_),
static_cast<QWidget*>(g_slider_), static_cast<QWidget*>(g_spin_),
static_cast<QWidget*>(b_slider_), static_cast<QWidget*>(b_spin_),
static_cast<QWidget*>(bright_slider_), static_cast<QWidget*>(bright_spin_),
static_cast<QWidget*>(contrast_slider_), static_cast<QWidget*>(contrast_spin_)}) {
block(w, false);
}
}
} // namespace photoconv

View File

@@ -3,14 +3,20 @@
#include "../converter/pipeline/Pipeline.h"
#include "../converter/pipeline/ImageData.h"
#include "../converter/output/OutputWriter.h"
#include "../converter/color/ColorGradingParams.h"
#include "../config/AppConfig.h"
#include <QComboBox>
#include <QDockWidget>
#include <QDoubleSpinBox>
#include <QGroupBox>
#include <QKeyEvent>
#include <QLabel>
#include <QMainWindow>
#include <QProgressBar>
#include <QPushButton>
#include <QSlider>
#include <QTimer>
#include <QThread>
#include <memory>
@@ -23,6 +29,9 @@ namespace photoconv {
*
* Moved to a QThread so that the GUI remains responsive during batch
* processing. Results are emitted via Qt signals.
*
* The pipeline is split at the Inverter stage so that the pre-color base
* image can be captured for live color-grading previews.
*/
class ConversionWorker : public QObject {
Q_OBJECT
@@ -35,11 +44,13 @@ public:
* @param output_dir Target directory for converted files.
* @param fmt Output format selection.
* @param quality JPEG quality (0-100).
* @param params Color grading parameters to apply.
*/
explicit ConversionWorker(std::vector<std::string> files,
std::string output_dir,
OutputFormat fmt,
int quality,
ColorGradingParams params = {},
QObject* parent = nullptr);
public slots:
@@ -55,6 +66,9 @@ signals:
*/
void file_done(int index, int total, bool ok, QString message);
/** Emitted with the image after Inverter (before ColorCorrector) for live grading. */
void base_ready(cv::Mat image, int film_type);
/** Emitted with the final converted image for preview. */
void preview_ready(cv::Mat image);
@@ -66,6 +80,37 @@ private:
std::string output_dir_;
OutputFormat fmt_;
int quality_;
ColorGradingParams params_;
};
// ─────────────────────────────────────────────────────────────────────────────
/**
* @brief Lightweight worker that re-applies color grading on a stored base image.
*
* Used for live-preview updates: runs ColorCorrector + CropProcessor on the
* pre-color base image without re-loading the RAW file.
*/
class ColorPreviewWorker : public QObject {
Q_OBJECT
public:
explicit ColorPreviewWorker(cv::Mat base_image,
FilmType film_type,
ColorGradingParams params,
QObject* parent = nullptr);
public slots:
void run();
signals:
void preview_ready(cv::Mat image);
void finished();
private:
cv::Mat base_image_;
FilmType film_type_;
ColorGradingParams params_;
};
// ─────────────────────────────────────────────────────────────────────────────
@@ -80,6 +125,7 @@ private:
* - 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)
* - Color Grading dock panel with live preview and keyboard shortcuts
*
* The GUI is thin: it delegates all processing to the Pipeline and only
* handles user interaction and display.
@@ -91,6 +137,10 @@ public:
explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override = default;
protected:
void keyPressEvent(QKeyEvent* event) override;
void keyReleaseEvent(QKeyEvent* event) override;
private slots:
void on_open_files();
void on_convert();
@@ -100,38 +150,86 @@ private slots:
/** Slot connected to ConversionWorker::file_done. */
void on_file_done(int index, int total, bool ok, QString message);
/** Slot connected to ConversionWorker::preview_ready. */
/** Slot connected to ConversionWorker::base_ready. */
void on_base_ready(cv::Mat image, int film_type);
/** Slot connected to ConversionWorker/ColorPreviewWorker::preview_ready. */
void on_preview_ready(cv::Mat image);
/** Slot connected to ConversionWorker::finished. */
void on_conversion_finished(int success_count, int total);
/** Called when a color grading parameter changes. */
void on_color_param_changed();
/** Fired by the debounce timer to start a live preview re-render. */
void on_reapply_color();
/** Reset all color grading params to defaults. */
void on_reset_color();
/** Persist current color params to config.ini in the output directory. */
void on_save_color_config();
private:
void setup_ui();
void setup_color_dock();
void update_preview(const cv::Mat& image);
void update_color_widgets();
void adjust_active_param(float delta);
// ── UI elements ──────────────────────────────────────────────────────────
QLabel* preview_label_{nullptr};
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};
QPushButton* open_button_{nullptr};
QPushButton* convert_button_{nullptr};
QPushButton* output_dir_button_{nullptr};
QPushButton* batch_button_{nullptr};
QLabel* status_label_{nullptr};
// Settings widgets
QComboBox* format_combo_{nullptr}; // Output format selector
QComboBox* film_combo_{nullptr}; // Film type selector
QComboBox* format_combo_{nullptr};
QComboBox* film_combo_{nullptr};
QGroupBox* settings_box_{nullptr};
// Color grading dock widgets (one slider + spinbox per parameter)
QDockWidget* color_dock_{nullptr};
QSlider* temp_slider_{nullptr};
QDoubleSpinBox* temp_spin_{nullptr};
QSlider* tint_slider_{nullptr};
QDoubleSpinBox* tint_spin_{nullptr};
QSlider* r_slider_{nullptr};
QDoubleSpinBox* r_spin_{nullptr};
QSlider* g_slider_{nullptr};
QDoubleSpinBox* g_spin_{nullptr};
QSlider* b_slider_{nullptr};
QDoubleSpinBox* b_spin_{nullptr};
QSlider* bright_slider_{nullptr};
QDoubleSpinBox* bright_spin_{nullptr};
QSlider* contrast_slider_{nullptr};
QDoubleSpinBox* contrast_spin_{nullptr};
// ── State ────────────────────────────────────────────────────────────────
std::vector<std::string> input_files_;
std::string output_dir_;
// Background thread for conversion
ColorGradingParams color_params_{}; ///< Current grading values
cv::Mat base_image_; ///< Image after Inverter (pre-color)
FilmType base_film_type_{FilmType::Unknown};
Qt::Key active_key_{Qt::Key_unknown}; ///< Keyboard shortcut modifier key
// Debounce timer for live preview
QTimer* color_debounce_timer_{nullptr};
// Background thread for full conversion
QThread* worker_thread_{nullptr};
ConversionWorker* worker_{nullptr};
// Background thread for color preview
QThread* color_thread_{nullptr};
ColorPreviewWorker* color_worker_{nullptr};
// ── Constants ────────────────────────────────────────────────────────────
/// Qt file dialog filter string for all supported formats.
static constexpr const char* kFileFilter =
@@ -144,6 +242,9 @@ private:
/// Qt file dialog filter for INI configuration files.
static constexpr const char* kConfigFilter =
"Config files (*.ini *.cfg *.conf *.toml);;All Files (*)";
/// Slider scale factor: sliders work in integer steps, params in float.
static constexpr int kGainSliderScale = 100; ///< gain 1.0 → slider 100
};
} // namespace photoconv