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:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
build/
|
||||
build-windows/
|
||||
dist-windows/
|
||||
output/
|
||||
.git/
|
||||
@@ -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
|
||||
|
||||
@@ -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>());
|
||||
|
||||
|
||||
@@ -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 {};
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -105,6 +123,7 @@ struct AppConfig {
|
||||
BatchConfig batch;
|
||||
ConversionConfig conversion;
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
21
src/converter/color/ColorGradingParams.h
Normal file
21
src/converter/color/ColorGradingParams.h
Normal 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 (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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,15 +150,33 @@ 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};
|
||||
@@ -120,18 +188,48 @@ private:
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user