From 4e4e19e80d00c886973079dfcc187d4631a5d664 Mon Sep 17 00:00:00 2001 From: "Christoph K." Date: Sun, 15 Mar 2026 09:49:42 +0100 Subject: [PATCH] feat: fix Docker Windows cross-compile and add color grading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .dockerignore | 5 + docker/Dockerfile | 49 ++- src/cli/CliRunner.cpp | 2 +- src/config/AppConfig.cpp | 70 +++- src/config/AppConfig.h | 37 +- src/converter/color/ColorCorrector.cpp | 99 +++++- src/converter/color/ColorCorrector.h | 15 +- src/converter/color/ColorGradingParams.h | 21 ++ src/converter/pipeline/ImageData.h | 3 + src/gui/MainWindow.cpp | 434 +++++++++++++++++++++-- src/gui/MainWindow.h | 121 ++++++- 11 files changed, 793 insertions(+), 63 deletions(-) create mode 100644 .dockerignore create mode 100644 src/converter/color/ColorGradingParams.h diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4e4f0a5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +build/ +build-windows/ +dist-windows/ +output/ +.git/ diff --git a/docker/Dockerfile b/docker/Dockerfile index daa37b9..08cff1d 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -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 diff --git a/src/cli/CliRunner.cpp b/src/cli/CliRunner.cpp index 3e0afb0..f8a220a 100644 --- a/src/cli/CliRunner.cpp +++ b/src/cli/CliRunner.cpp @@ -286,7 +286,7 @@ Pipeline CliRunner::build_pipeline(const AppConfig& app_cfg) { pipeline.add_stage(std::make_unique()); } - pipeline.add_stage(std::make_unique()); + pipeline.add_stage(std::make_unique(app_cfg.color_params())); pipeline.add_stage(std::make_unique()); diff --git a/src/config/AppConfig.cpp b/src/config/AppConfig.cpp index 11d7fa9..c323547 100644 --- a/src/config/AppConfig.cpp +++ b/src/config/AppConfig.cpp @@ -116,6 +116,15 @@ std::expected 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 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 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 {}; } diff --git a/src/config/AppConfig.h b/src/config/AppConfig.h index 5631109..8ee7562 100644 --- a/src/config/AppConfig.h +++ b/src/config/AppConfig.h @@ -1,5 +1,6 @@ #pragma once +#include "../converter/color/ColorGradingParams.h" #include "../converter/output/OutputWriter.h" #include @@ -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 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 write( + const std::filesystem::path& path) const; }; } // namespace photoconv diff --git a/src/converter/color/ColorCorrector.cpp b/src/converter/color/ColorCorrector.cpp index 6e656d6..ba08b36 100644 --- a/src/converter/color/ColorCorrector.cpp +++ b/src/converter/color/ColorCorrector.cpp @@ -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(p.b_gain); + double scale_g = static_cast(p.g_gain); + double scale_r = static_cast(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(p.temperature) * 0.005); + scale_r *= (1.0 - static_cast(p.temperature) * 0.005); + + // Tint: positive = boosts G (green shift). + scale_g *= (1.0 + static_cast(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(p.brightness), + static_cast(p.contrast)) << std::endl; + + std::vector 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(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(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 diff --git a/src/converter/color/ColorCorrector.h b/src/converter/color/ColorCorrector.h index e7f51c9..468449d 100644 --- a/src/converter/color/ColorCorrector.h +++ b/src/converter/color/ColorCorrector.h @@ -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 diff --git a/src/converter/color/ColorGradingParams.h b/src/converter/color/ColorGradingParams.h new file mode 100644 index 0000000..d7c7209 --- /dev/null +++ b/src/converter/color/ColorGradingParams.h @@ -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 (0–65535) 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 diff --git a/src/converter/pipeline/ImageData.h b/src/converter/pipeline/ImageData.h index e49216e..95b3487 100644 --- a/src/converter/pipeline/ImageData.h +++ b/src/converter/pipeline/ImageData.h @@ -1,5 +1,7 @@ #pragma once +#include "../color/ColorGradingParams.h" + #include #include @@ -58,6 +60,7 @@ struct ImageData { RawMetadata metadata; // Camera/RAW metadata FilmType film_type{FilmType::Unknown}; // Detected after Detect stage std::optional crop_region; // Set by Crop stage + ColorGradingParams color_params{}; // User-defined color grading overrides }; } // namespace photoconv diff --git a/src/gui/MainWindow.cpp b/src/gui/MainWindow.cpp index fd301d0..3826c7c 100644 --- a/src/gui/MainWindow.cpp +++ b/src/gui/MainWindow.cpp @@ -10,8 +10,13 @@ #include #include +#include +#include #include +#include #include +#include +#include #include #include @@ -29,25 +34,30 @@ ConversionWorker::ConversionWorker(std::vector 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()); - pipeline.add_stage(std::make_unique()); - pipeline.add_stage(std::make_unique()); - pipeline.add_stage(std::make_unique()); - pipeline.add_stage(std::make_unique()); - pipeline.add_stage(std::make_unique( + // ── Pre-color pipeline (Preprocessor → NegativeDetector → Inverter) ────── + Pipeline pre_pipeline; + pre_pipeline.add_stage(std::make_unique()); + pre_pipeline.add_stage(std::make_unique()); + pre_pipeline.add_stage(std::make_unique()); + + // ── Post-color pipeline (ColorCorrector → CropProcessor → OutputWriter) ── + Pipeline post_pipeline; + post_pipeline.add_stage(std::make_unique(params_)); + post_pipeline.add_stage(std::make_unique()); + post_pipeline.add_stage(std::make_unique( OutputConfig{output_dir_, fmt_, quality_})); const int total = static_cast(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(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(min * 100), static_cast(max * 100)); + slider->setValue(static_cast(value * 100)); + slider->setTickInterval(static_cast((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( + "T/N/R/G/B/L/C + ←→ to adjust · Ctrl+0 reset · Ctrl+S save", + 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(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 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(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( 100.0 * static_cast(index + 1) / static_cast(total)); progress_bar_->setValue(progress); - // Update status label. const QString icon = ok ? "OK" : "FAIL"; status_label_->setText( QString("[%1/%2] %3: %4").arg(index + 1).arg(total).arg(icon).arg(message)); } +void MainWindow::on_base_ready(cv::Mat image, int film_type) { + base_image_ = image; + base_film_type_ = static_cast(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(temp_spin_->value()); + color_params_.tint = static_cast(tint_spin_->value()); + color_params_.r_gain = static_cast(r_spin_->value()); + color_params_.g_gain = static_cast(g_spin_->value()); + color_params_.b_gain = static_cast(b_spin_->value()); + color_params_.brightness = static_cast(bright_spin_->value()); + color_params_.contrast = static_cast(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(temp_slider_), static_cast(temp_spin_), + static_cast(tint_slider_), static_cast(tint_spin_), + static_cast(r_slider_), static_cast(r_spin_), + static_cast(g_slider_), static_cast(g_spin_), + static_cast(b_slider_), static_cast(b_spin_), + static_cast(bright_slider_), static_cast(bright_spin_), + static_cast(contrast_slider_), static_cast(contrast_spin_)}) { + block(w, true); + } + + temp_slider_->setValue(static_cast(color_params_.temperature * 100)); + temp_spin_->setValue(static_cast(color_params_.temperature)); + tint_slider_->setValue(static_cast(color_params_.tint * 100)); + tint_spin_->setValue(static_cast(color_params_.tint)); + r_slider_->setValue(static_cast(color_params_.r_gain * 100)); + r_spin_->setValue(static_cast(color_params_.r_gain)); + g_slider_->setValue(static_cast(color_params_.g_gain * 100)); + g_spin_->setValue(static_cast(color_params_.g_gain)); + b_slider_->setValue(static_cast(color_params_.b_gain * 100)); + b_spin_->setValue(static_cast(color_params_.b_gain)); + bright_slider_->setValue(static_cast(color_params_.brightness * 100)); + bright_spin_->setValue(static_cast(color_params_.brightness)); + contrast_slider_->setValue(static_cast(color_params_.contrast * 100)); + contrast_spin_->setValue(static_cast(color_params_.contrast)); + + for (auto* w : {static_cast(temp_slider_), static_cast(temp_spin_), + static_cast(tint_slider_), static_cast(tint_spin_), + static_cast(r_slider_), static_cast(r_spin_), + static_cast(g_slider_), static_cast(g_spin_), + static_cast(b_slider_), static_cast(b_spin_), + static_cast(bright_slider_), static_cast(bright_spin_), + static_cast(contrast_slider_), static_cast(contrast_spin_)}) { + block(w, false); + } +} + } // namespace photoconv diff --git a/src/gui/MainWindow.h b/src/gui/MainWindow.h index 28072f4..8268de2 100644 --- a/src/gui/MainWindow.h +++ b/src/gui/MainWindow.h @@ -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 +#include +#include #include +#include #include #include #include #include +#include +#include #include #include @@ -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 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 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