chore: initial project scaffold from architecture design

- Add CLAUDE.md with project overview, tech stack, build commands,
  architecture description, coding standards, and sample images section
- Add full directory structure: src/, docs/, tests/, import/
- Add CMakeLists.txt with C++20, OpenCV/LibRaw/Qt6 dependencies,
  converter_core static lib, optional GUI, and GTest tests
- Add architecture documentation: ARCHITECTURE.md, PIPELINE.md, MODULES.md
- Add source skeletons for all pipeline stages:
  RawLoader, Preprocessor, NegativeDetector, Inverter, ColorCorrector,
  CropProcessor, OutputWriter, Pipeline, MainWindow, CliRunner, main.cpp
- Add initial test stubs for pipeline and rawloader
- Add sample ARW files in import/ for integration testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Christoph K.
2026-03-14 09:28:32 +01:00
commit 65b411b23d
34 changed files with 3191 additions and 0 deletions

149
src/cli/CliRunner.cpp Normal file
View File

@@ -0,0 +1,149 @@
#include "CliRunner.h"
#include "../converter/rawloader/RawLoader.h"
#include "../converter/preprocess/Preprocessor.h"
#include "../converter/negative/NegativeDetector.h"
#include "../converter/invert/Inverter.h"
#include "../converter/color/ColorCorrector.h"
#include "../converter/crop/CropProcessor.h"
#include "../converter/output/OutputWriter.h"
#include <format>
#include <iostream>
namespace photoconv {
std::expected<CliRunner::Config, Error> CliRunner::parse_args(int argc, char* argv[]) {
Config config;
bool reading_inputs = false;
for (int i = 1; i < argc; ++i) {
std::string arg{argv[i]};
if (arg == "-i" || arg == "--input") {
reading_inputs = true;
continue;
}
if (arg == "-o" || arg == "--output") {
reading_inputs = false;
if (i + 1 < argc) {
config.output_dir = argv[++i];
} else {
return std::unexpected(make_error(
ErrorCode::InvalidArgument, "Missing value for --output"));
}
continue;
}
if (arg == "--format") {
reading_inputs = false;
if (i + 1 < argc) {
config.output_format = argv[++i];
} else {
return std::unexpected(make_error(
ErrorCode::InvalidArgument, "Missing value for --format"));
}
continue;
}
if (arg == "--quality") {
reading_inputs = false;
if (i + 1 < argc) {
config.jpeg_quality = std::stoi(argv[++i]);
}
continue;
}
if (arg == "-v" || arg == "--verbose") {
config.verbose = true;
reading_inputs = false;
continue;
}
if (arg == "--cli") {
reading_inputs = false;
continue;
}
if (arg == "-h" || arg == "--help") {
std::cout <<
"Usage: photo-converter --cli -i <files...> -o <output_dir> [options]\n"
"\n"
"Options:\n"
" -i, --input <files...> Input image files (RAW or standard)\n"
" -o, --output <dir> Output directory (default: output/)\n"
" --format <fmt> Output format: png16, png8, tiff16, jpeg\n"
" --quality <0-100> JPEG quality (default: 95)\n"
" -v, --verbose Verbose output\n"
" -h, --help Show this help\n"
<< std::endl;
return std::unexpected(make_error(ErrorCode::InvalidArgument, "Help requested"));
}
// If reading inputs or no flag active, treat as input file
if (reading_inputs || arg[0] != '-') {
config.input_files.emplace_back(arg);
reading_inputs = true;
}
}
if (config.input_files.empty()) {
return std::unexpected(make_error(
ErrorCode::InvalidArgument, "No input files specified. Use -i <files...>"));
}
return config;
}
std::expected<int, Error> CliRunner::run(const Config& config) const {
// Determine output format
OutputFormat fmt = OutputFormat::PNG_16bit;
if (config.output_format == "png8") fmt = OutputFormat::PNG_8bit;
else if (config.output_format == "tiff16") fmt = OutputFormat::TIFF_16bit;
else if (config.output_format == "jpeg") fmt = OutputFormat::JPEG;
// Build pipeline
Pipeline pipeline;
pipeline.add_stage(std::make_unique<Preprocessor>());
pipeline.add_stage(std::make_unique<NegativeDetector>());
pipeline.add_stage(std::make_unique<Inverter>());
pipeline.add_stage(std::make_unique<ColorCorrector>());
pipeline.add_stage(std::make_unique<CropProcessor>());
pipeline.add_stage(std::make_unique<OutputWriter>(
OutputConfig{config.output_dir, fmt, config.jpeg_quality}));
RawLoader loader;
int success_count = 0;
for (const auto& file : config.input_files) {
std::cout << std::format("\n[CLI] Processing: {}", file.string()) << std::endl;
auto load_result = loader.load(file);
if (!load_result.has_value()) {
std::cerr << std::format("[CLI] Load failed: {}",
load_result.error().format()) << std::endl;
continue;
}
auto result = pipeline.execute(std::move(load_result.value()));
if (!result.has_value()) {
std::cerr << std::format("[CLI] Pipeline failed: {}",
result.error().format()) << std::endl;
continue;
}
++success_count;
}
std::cout << std::format("\n[CLI] Done: {}/{} files processed successfully",
success_count, config.input_files.size()) << std::endl;
return success_count;
}
std::expected<int, Error> CliRunner::parse_format(const std::string& fmt_str) {
if (fmt_str == "png16") return static_cast<int>(OutputFormat::PNG_16bit);
if (fmt_str == "png8") return static_cast<int>(OutputFormat::PNG_8bit);
if (fmt_str == "tiff16") return static_cast<int>(OutputFormat::TIFF_16bit);
if (fmt_str == "jpeg") return static_cast<int>(OutputFormat::JPEG);
return std::unexpected(make_error(
ErrorCode::InvalidArgument,
std::format("Unknown output format: '{}'. Use: png16, png8, tiff16, jpeg", fmt_str)));
}
} // namespace photoconv

62
src/cli/CliRunner.h Normal file
View File

@@ -0,0 +1,62 @@
#pragma once
#include "../converter/pipeline/Pipeline.h"
#include "../converter/pipeline/Error.h"
#include <expected>
#include <filesystem>
#include <string>
#include <vector>
namespace photoconv {
/**
* @brief CLI batch processing runner.
*
* Accepts command-line arguments, loads files, runs the pipeline,
* and writes output. Designed to work without Qt/GUI dependencies.
*
* Usage:
* photo-converter --cli -i input1.arw input2.cr2 -o output/ [--format png16]
*/
class CliRunner {
public:
/**
* @brief CLI configuration parsed from command-line arguments.
*/
struct Config {
std::vector<std::filesystem::path> input_files;
std::filesystem::path output_dir{"output"};
std::string output_format{"png16"}; // png16, png8, tiff16, jpeg
int jpeg_quality{95};
bool verbose{false};
};
CliRunner() = default;
~CliRunner() = default;
/**
* @brief Parse command-line arguments into Config.
*
* @param argc Argument count.
* @param argv Argument values.
* @return Parsed Config or Error.
*/
[[nodiscard]] static std::expected<Config, Error> parse_args(int argc, char* argv[]);
/**
* @brief Execute batch processing with the given configuration.
*
* @param config Parsed CLI configuration.
* @return Number of successfully processed files, or Error.
*/
[[nodiscard]] std::expected<int, Error> run(const Config& config) const;
private:
/**
* @brief Parse the output format string to OutputFormat enum.
*/
[[nodiscard]] static std::expected<int, Error> parse_format(const std::string& fmt_str);
};
} // namespace photoconv

View File

@@ -0,0 +1,106 @@
#include "ColorCorrector.h"
#include <opencv2/imgproc.hpp>
#include <format>
#include <iostream>
namespace photoconv {
StageResult ColorCorrector::process(ImageData data) const {
if (data.rgb.empty()) {
return std::unexpected(make_error(
ErrorCode::ColorCorrectionFailed,
"ColorCorrector received empty image"));
}
switch (data.film_type) {
case FilmType::ColorNegative: {
std::cout << "[Color] Applying C-41 correction" << std::endl;
auto result = correct_c41(std::move(data));
if (!result.has_value()) return result;
return auto_white_balance(std::move(result.value()));
}
case FilmType::BWNegative:
case FilmType::BWPositive:
std::cout << "[Color] B&W image, skipping color correction" << std::endl;
return data;
case FilmType::ColorPositive:
std::cout << "[Color] Positive, applying auto white balance" << std::endl;
return auto_white_balance(std::move(data));
case FilmType::Unknown:
std::cout << "[Color] Unknown type, applying auto white balance" << std::endl;
return auto_white_balance(std::move(data));
}
return data;
}
StageResult ColorCorrector::correct_c41(ImageData data) {
// TODO: Implement proper C-41 orange cast correction.
// Strategy:
// 1. Convert to LAB color space
// 2. Analyze a/b channels for orange bias
// 3. Apply per-channel curve adjustment to neutralize
// 4. Convert back to BGR
return data;
}
StageResult ColorCorrector::auto_white_balance(ImageData data) {
// TODO: Implement gray-world auto white balance.
// Strategy:
// 1. Compute mean of each BGR channel
// 2. Compute overall gray mean
// 3. Scale each channel: channel *= (gray_mean / channel_mean)
// 4. Clamp to 16-bit range [0, 65535]
cv::Scalar channel_means = cv::mean(data.rgb);
const double gray_mean = (channel_means[0] + channel_means[1] + channel_means[2]) / 3.0;
if (channel_means[0] < 1.0 || channel_means[1] < 1.0 || channel_means[2] < 1.0) {
std::cout << "[Color] Skipping AWB: near-zero channel mean" << std::endl;
return data;
}
std::vector<cv::Mat> channels;
cv::split(data.rgb, channels);
for (int i = 0; i < 3; ++i) {
const double scale = gray_mean / channel_means[i];
channels[i].convertTo(channels[i], CV_16U, scale);
}
cv::merge(channels, data.rgb);
std::cout << std::format("[Color] AWB applied: scale B={:.3f} G={:.3f} R={:.3f}",
gray_mean / channel_means[0],
gray_mean / channel_means[1],
gray_mean / channel_means[2]) << std::endl;
return data;
}
StageResult ColorCorrector::apply_exif_wb(ImageData data) {
// Apply white balance from camera metadata
const auto& meta = data.metadata;
if (meta.wb_red <= 0.0f || meta.wb_blue <= 0.0f) {
return auto_white_balance(std::move(data));
}
std::vector<cv::Mat> channels;
cv::split(data.rgb, channels);
// channels[0]=B, channels[1]=G, channels[2]=R
channels[0].convertTo(channels[0], CV_16U, meta.wb_blue);
channels[2].convertTo(channels[2], CV_16U, meta.wb_red);
cv::merge(channels, data.rgb);
std::cout << std::format("[Color] EXIF WB applied: R={:.3f} G={:.3f} B={:.3f}",
meta.wb_red, meta.wb_green, meta.wb_blue) << std::endl;
return data;
}
} // namespace photoconv

View File

@@ -0,0 +1,43 @@
#pragma once
#include "../pipeline/PipelineStage.h"
namespace photoconv {
/**
* @brief Color correction stage: removes color casts and balances white.
*
* 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
*
* Uses the Strategy pattern internally: different correction algorithms
* are selected based on FilmType.
*/
class ColorCorrector : public PipelineStage {
public:
ColorCorrector() = default;
~ColorCorrector() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "ColorCorrection"; }
private:
/**
* @brief Apply C-41 specific color correction (orange cast removal).
*/
[[nodiscard]] static StageResult correct_c41(ImageData data);
/**
* @brief Apply auto white balance using gray-world assumption.
*/
[[nodiscard]] static StageResult auto_white_balance(ImageData data);
/**
* @brief Apply white balance from EXIF metadata.
*/
[[nodiscard]] static StageResult apply_exif_wb(ImageData data);
};
} // namespace photoconv

View File

@@ -0,0 +1,66 @@
#include "CropProcessor.h"
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <format>
#include <iostream>
namespace photoconv {
StageResult CropProcessor::process(ImageData data) const {
if (data.rgb.empty()) {
return std::unexpected(make_error(
ErrorCode::CropFailed, "CropProcessor received empty image"));
}
// Execute sub-stages in order
auto result = auto_crop(std::move(data));
if (!result.has_value()) return result;
result = adjust_levels(std::move(result.value()));
if (!result.has_value()) return result;
return sharpen(std::move(result.value()));
}
StageResult CropProcessor::auto_crop(ImageData data) {
// TODO: Implement frame detection.
// Strategy:
// 1. Convert to grayscale
// 2. Apply Gaussian blur + Canny edge detection
// 3. Find contours, select largest rectangular contour
// 4. Validate: area > kMinFrameAreaRatio * total area
// 5. Apply perspective transform if needed
// 6. Crop to bounding rect
std::cout << std::format("[PostProcess] Auto-crop: image {}x{} (pass-through)",
data.rgb.cols, data.rgb.rows) << std::endl;
return data;
}
StageResult CropProcessor::adjust_levels(ImageData data) {
// TODO: Implement histogram-based levels adjustment.
// Strategy:
// 1. Compute cumulative histogram per channel
// 2. Find black point at kBlackPointPercentile
// 3. Find white point at kWhitePointPercentile
// 4. Remap: output = (input - black) * 65535 / (white - black)
// 5. Clamp to [0, 65535]
std::cout << "[PostProcess] Levels adjustment (pass-through)" << std::endl;
return data;
}
StageResult CropProcessor::sharpen(ImageData data) {
// TODO: Implement unsharp mask.
// Strategy:
// 1. GaussianBlur with kSharpenSigma
// 2. sharpened = original + kSharpenStrength * (original - blurred)
// 3. Clamp to 16-bit range
std::cout << "[PostProcess] Sharpening (pass-through)" << std::endl;
return data;
}
} // namespace photoconv

View File

@@ -0,0 +1,62 @@
#pragma once
#include "../pipeline/PipelineStage.h"
namespace photoconv {
/**
* @brief Post-processing stage: auto-crop, levels, sharpening.
*
* Responsibilities:
* - Auto frame detection (find film frame borders)
* - Crop to detected frame
* - Levels adjustment (black/white point)
* - Unsharp mask sharpening
* - (Future) Dust/scratch removal
*/
class CropProcessor : public PipelineStage {
public:
/// Border detection: minimum contour area as fraction of image area.
static constexpr double kMinFrameAreaRatio = 0.3;
/// Sharpening: unsharp mask sigma.
static constexpr double kSharpenSigma = 1.5;
/// Sharpening: unsharp mask strength.
static constexpr double kSharpenStrength = 0.5;
/// Levels: percentile for black point clipping.
static constexpr double kBlackPointPercentile = 0.5;
/// Levels: percentile for white point clipping.
static constexpr double kWhitePointPercentile = 99.5;
CropProcessor() = default;
~CropProcessor() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "PostProcess"; }
private:
/**
* @brief Detect the film frame boundary and crop.
*
* Uses edge detection and contour analysis to find the largest
* rectangular region (the film frame).
*/
[[nodiscard]] static StageResult auto_crop(ImageData data);
/**
* @brief Adjust levels by clipping black and white points.
*
* Computes histogram percentiles and remaps the tonal range.
*/
[[nodiscard]] static StageResult adjust_levels(ImageData data);
/**
* @brief Apply unsharp mask sharpening.
*/
[[nodiscard]] static StageResult sharpen(ImageData data);
};
} // namespace photoconv

View File

@@ -0,0 +1,59 @@
#include "Inverter.h"
#include <opencv2/core.hpp>
#include <opencv2/imgproc.hpp>
#include <format>
#include <iostream>
namespace photoconv {
StageResult Inverter::process(ImageData data) const {
if (data.rgb.empty()) {
return std::unexpected(make_error(
ErrorCode::InversionFailed, "Inverter received empty image"));
}
switch (data.film_type) {
case FilmType::ColorNegative:
std::cout << "[Invert] Inverting color negative (C-41)" << std::endl;
return invert_color_negative(std::move(data));
case FilmType::BWNegative:
std::cout << "[Invert] Inverting B&W negative" << std::endl;
return invert_bw_negative(std::move(data));
case FilmType::ColorPositive:
case FilmType::BWPositive:
std::cout << "[Invert] Positive detected, skipping inversion" << std::endl;
return data;
case FilmType::Unknown:
std::cout << "[Invert] Unknown film type, applying default inversion" << std::endl;
return invert_color_negative(std::move(data));
}
return data; // Unreachable, but satisfies compiler
}
StageResult Inverter::invert_color_negative(ImageData data) {
// TODO: Implement proper C-41 orange mask removal.
// Strategy:
// 1. Sample unexposed border regions to characterize the orange mask
// 2. Compute per-channel mask color (typically R > G > B)
// 3. Subtract mask contribution from each channel
// 4. Apply bitwise_not inversion
// 5. Apply per-channel scaling to normalize levels
// Basic inversion for now
cv::bitwise_not(data.rgb, data.rgb);
return data;
}
StageResult Inverter::invert_bw_negative(ImageData data) {
cv::bitwise_not(data.rgb, data.rgb);
return data;
}
} // namespace photoconv

View File

@@ -0,0 +1,37 @@
#pragma once
#include "../pipeline/PipelineStage.h"
namespace photoconv {
/**
* @brief Inversion stage: converts negatives to positives.
*
* Applies cv::bitwise_not() as the base inversion, then applies
* film-specific color correction matrices depending on FilmType.
*
* For ColorNegative (C-41): removes orange mask before inversion.
* For BWNegative: simple inversion with optional contrast curve.
* For positives: passes through unchanged.
*/
class Inverter : public PipelineStage {
public:
Inverter() = default;
~Inverter() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "Invert"; }
private:
/**
* @brief Invert a color negative, removing orange mask.
*/
[[nodiscard]] static StageResult invert_color_negative(ImageData data);
/**
* @brief Invert a B&W negative.
*/
[[nodiscard]] static StageResult invert_bw_negative(ImageData data);
};
} // namespace photoconv

View File

@@ -0,0 +1,90 @@
#include "NegativeDetector.h"
#include <opencv2/imgproc.hpp>
#include <format>
#include <iostream>
namespace photoconv {
StageResult NegativeDetector::process(ImageData data) const {
if (data.rgb.empty()) {
return std::unexpected(make_error(
ErrorCode::DetectionFailed, "NegativeDetector received empty image"));
}
const bool is_negative = is_negative_histogram(data.rgb);
const bool is_bw = is_monochrome(data.rgb);
if (is_negative) {
if (is_bw) {
data.film_type = FilmType::BWNegative;
} else if (has_orange_mask(data.rgb)) {
data.film_type = FilmType::ColorNegative;
} else {
data.film_type = FilmType::ColorNegative; // Default negative to color
}
} else {
data.film_type = is_bw ? FilmType::BWPositive : FilmType::ColorPositive;
}
std::cout << std::format("[Detect] Film type detected: {}",
[&] {
switch (data.film_type) {
case FilmType::ColorNegative: return "Color Negative (C-41)";
case FilmType::BWNegative: return "B&W Negative";
case FilmType::ColorPositive: return "Color Positive (Slide)";
case FilmType::BWPositive: return "B&W Positive";
default: return "Unknown";
}
}()) << std::endl;
return data;
}
bool NegativeDetector::is_negative_histogram(const cv::Mat& rgb) {
// TODO: Implement full histogram skewness analysis.
// Strategy:
// 1. Compute histogram for each channel (256 or 65536 bins)
// 2. Find the peak position for each channel
// 3. Negatives typically have peaks in the upper intensity range
// (bright areas in the negative correspond to shadows in the scene)
// 4. Compare mean intensity to midpoint; if mean > midpoint, likely negative
cv::Scalar mean_val = cv::mean(rgb);
constexpr double midpoint = 32768.0; // Midpoint of 16-bit range
// If average of all channels is above midpoint, likely a negative
const double avg_mean = (mean_val[0] + mean_val[1] + mean_val[2]) / 3.0;
return avg_mean > midpoint;
}
bool NegativeDetector::has_orange_mask(const cv::Mat& rgb) {
// C-41 negatives have an orange tint from the mask dye.
// In BGR format: B < G < R for the orange mask region.
cv::Scalar mean_val = cv::mean(rgb);
const double b_mean = mean_val[0];
const double r_mean = mean_val[2];
if (b_mean < 1.0) return false; // Avoid division by zero
const auto ratio = static_cast<float>(r_mean / b_mean);
return ratio > kOrangeMaskThreshold;
}
bool NegativeDetector::is_monochrome(const cv::Mat& rgb) {
// Convert to HSV and check saturation channel
cv::Mat hsv;
cv::Mat rgb8;
rgb.convertTo(rgb8, CV_8UC3, 1.0 / 257.0);
cv::cvtColor(rgb8, hsv, cv::COLOR_BGR2HSV);
// Extract saturation channel
std::vector<cv::Mat> channels;
cv::split(hsv, channels);
const double mean_saturation = cv::mean(channels[1])[0];
return mean_saturation < kColorSaturationThreshold;
}
} // namespace photoconv

View File

@@ -0,0 +1,62 @@
#pragma once
#include "../pipeline/PipelineStage.h"
namespace photoconv {
/**
* @brief Detection stage: classifies image as negative or positive.
*
* Uses histogram analysis and orange color mask detection to determine
* the film type (C-41 color negative, B&W negative, slide, etc.).
*
* The detected FilmType is stored in ImageData::film_type for use
* by the Invert and Color Correction stages.
*
* Detection strategy:
* 1. Compute per-channel histograms
* 2. Analyze distribution skewness (negatives have inverted distributions)
* 3. Check for C-41 orange mask (dominant red/orange in unexposed regions)
* 4. Classify as ColorNegative, BWNegative, ColorPositive, or BWPositive
*/
class NegativeDetector : public PipelineStage {
public:
/// Threshold for orange mask detection (ratio of R to B channel means).
static constexpr float kOrangeMaskThreshold = 1.4f;
/// Minimum saturation to distinguish color from B&W.
static constexpr float kColorSaturationThreshold = 15.0f;
NegativeDetector() = default;
~NegativeDetector() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "Detect"; }
private:
/**
* @brief Analyze histogram to detect inverted (negative) distribution.
*
* @param rgb 16-bit BGR image.
* @return true if the image appears to be a negative.
*/
[[nodiscard]] static bool is_negative_histogram(const cv::Mat& rgb);
/**
* @brief Detect orange mask characteristic of C-41 color negatives.
*
* @param rgb 16-bit BGR image.
* @return true if an orange color mask is present.
*/
[[nodiscard]] static bool has_orange_mask(const cv::Mat& rgb);
/**
* @brief Determine whether the image is effectively monochrome.
*
* @param rgb 16-bit BGR image.
* @return true if saturation is below threshold (B&W film).
*/
[[nodiscard]] static bool is_monochrome(const cv::Mat& rgb);
};
} // namespace photoconv

View File

@@ -0,0 +1,85 @@
#include "OutputWriter.h"
#include <opencv2/imgcodecs.hpp>
#include <format>
#include <iostream>
#include <vector>
namespace photoconv {
OutputWriter::OutputWriter(OutputConfig config)
: config_{std::move(config)}
{}
StageResult OutputWriter::process(ImageData data) const {
if (data.rgb.empty()) {
return std::unexpected(make_error(
ErrorCode::OutputWriteFailed, "OutputWriter received empty image"));
}
// Ensure output directory exists
if (!std::filesystem::exists(config_.output_dir)) {
std::filesystem::create_directories(config_.output_dir);
}
const auto output_path = build_output_path(data.source_path);
// Prepare image for output format
cv::Mat output_img = data.rgb;
std::vector<int> params;
switch (config_.format) {
case OutputFormat::PNG_16bit:
params = {cv::IMWRITE_PNG_COMPRESSION, 3};
break;
case OutputFormat::PNG_8bit:
output_img.convertTo(output_img, CV_8UC3, 1.0 / 257.0);
params = {cv::IMWRITE_PNG_COMPRESSION, 3};
break;
case OutputFormat::TIFF_16bit:
// OpenCV writes TIFF as-is for 16-bit
break;
case OutputFormat::JPEG:
output_img.convertTo(output_img, CV_8UC3, 1.0 / 257.0);
params = {cv::IMWRITE_JPEG_QUALITY, config_.jpeg_quality};
break;
}
const bool success = cv::imwrite(output_path.string(), output_img, params);
if (!success) {
return std::unexpected(make_error(
ErrorCode::OutputWriteFailed,
std::format("Failed to write output: {}", output_path.string())));
}
std::cout << std::format("[Output] Written: {} ({}x{})",
output_path.string(),
output_img.cols, output_img.rows) << std::endl;
return data;
}
std::filesystem::path OutputWriter::build_output_path(
const std::string& source_path) const
{
std::filesystem::path src{source_path};
auto stem = src.stem().string();
auto ext = format_extension(config_.format);
return config_.output_dir / (stem + "_converted" + ext);
}
std::string OutputWriter::format_extension(OutputFormat fmt) {
switch (fmt) {
case OutputFormat::PNG_16bit:
case OutputFormat::PNG_8bit: return ".png";
case OutputFormat::TIFF_16bit: return ".tif";
case OutputFormat::JPEG: return ".jpg";
}
return ".png"; // Fallback
}
} // namespace photoconv

View File

@@ -0,0 +1,58 @@
#pragma once
#include "../pipeline/PipelineStage.h"
#include <filesystem>
#include <string>
namespace photoconv {
/**
* @brief Output format selection.
*/
enum class OutputFormat {
PNG_16bit, // 16-bit PNG (lossless, large)
PNG_8bit, // 8-bit PNG (lossless, smaller)
TIFF_16bit, // 16-bit TIFF (lossless, large)
JPEG, // 8-bit JPEG (lossy)
};
/**
* @brief Output configuration.
*/
struct OutputConfig {
std::filesystem::path output_dir;
OutputFormat format{OutputFormat::PNG_16bit};
int jpeg_quality{95}; // 0-100, only used for JPEG
};
/**
* @brief Final pipeline stage: writes the processed image to disk.
*
* Supports multiple output formats. Constructs the output filename
* from the source filename with appropriate extension.
*/
class OutputWriter : public PipelineStage {
public:
explicit OutputWriter(OutputConfig config);
~OutputWriter() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "Output"; }
private:
OutputConfig config_;
/**
* @brief Build the output file path from source path and config.
*/
[[nodiscard]] std::filesystem::path build_output_path(
const std::string& source_path) const;
/**
* @brief Get the file extension for the configured format.
*/
[[nodiscard]] static std::string format_extension(OutputFormat fmt);
};
} // namespace photoconv

View File

@@ -0,0 +1,101 @@
#pragma once
#include <string>
#include <source_location>
#include <format>
namespace photoconv {
/**
* @brief Error codes for all pipeline stages.
*/
enum class ErrorCode {
// Loader errors
FileNotFound,
FileReadError,
UnsupportedFormat,
FileTooLarge,
LibRawInitFailed,
LibRawUnpackFailed,
LibRawProcessFailed,
DemosaicingFailed,
// Preprocess errors
InvalidBitDepth,
ConversionFailed,
// Detection errors
DetectionFailed,
HistogramError,
// Inversion errors
InversionFailed,
// Color correction errors
ColorCorrectionFailed,
WhiteBalanceFailed,
// Crop / Post-process errors
CropFailed,
FrameDetectionFailed,
SharpeningFailed,
// Output errors
OutputWriteFailed,
OutputPathInvalid,
// General
InvalidArgument,
InternalError,
};
/**
* @brief Structured error type used throughout the pipeline.
*
* Carries an error code, human-readable message, and source location
* for diagnostics.
*/
struct Error {
ErrorCode code;
std::string message;
std::string source_file;
int source_line{0};
/**
* @brief Construct an Error with automatic source location capture.
*
* @param code The error classification.
* @param message A human-readable, actionable error description.
* @param loc Automatically captured source location.
*/
Error(ErrorCode code,
std::string message,
std::source_location loc = std::source_location::current())
: code{code}
, message{std::move(message)}
, source_file{loc.file_name()}
, source_line{static_cast<int>(loc.line())}
{}
/**
* @brief Format the error for logging.
*/
[[nodiscard]] std::string format() const {
return std::format("[{}:{}] Error({}): {}",
source_file, source_line,
static_cast<int>(code), message);
}
};
/**
* @brief Convenience macro-free helper to create errors at call site.
*/
[[nodiscard]] inline Error make_error(
ErrorCode code,
std::string message,
std::source_location loc = std::source_location::current())
{
return Error{code, std::move(message), loc};
}
} // namespace photoconv

View File

@@ -0,0 +1,63 @@
#pragma once
#include <opencv2/core.hpp>
#include <cstdint>
#include <optional>
#include <string>
namespace photoconv {
/**
* @brief Film type classification for processing decisions.
*/
enum class FilmType {
Unknown,
ColorNegative, // C-41 process
BWNegative, // B&W negative
ColorPositive, // Slide / E-6
BWPositive, // B&W positive
};
/**
* @brief RAW metadata extracted from the image file.
*
* Populated by the Loader stage and carried through the pipeline
* for use by downstream stages (e.g., color correction using WB data).
*/
struct RawMetadata {
std::string camera_make; // e.g. "Sony", "Canon", "Nikon"
std::string camera_model; // e.g. "ILCE-7M3"
float iso_speed{0.0f};
float shutter_speed{0.0f}; // seconds
float aperture{0.0f}; // f-number
float focal_length{0.0f}; // mm
float wb_red{1.0f}; // White balance multipliers
float wb_green{1.0f};
float wb_blue{1.0f};
int raw_width{0};
int raw_height{0};
int raw_bit_depth{0}; // Bits per channel in source
std::string timestamp; // ISO 8601
};
/**
* @brief Core data structure flowing through the entire pipeline.
*
* Every pipeline stage receives an ImageData, transforms it, and
* returns a new ImageData (or Error) via std::expected.
*
* Invariants:
* - rgb is always CV_16UC3 (16-bit, 3-channel BGR)
* - metadata is populated after the Loader stage
* - film_type is set after the Detect stage
*/
struct ImageData {
cv::Mat rgb; // 16-bit BGR (CV_16UC3)
std::string source_path; // Original file path
RawMetadata metadata; // Camera/RAW metadata
FilmType film_type{FilmType::Unknown}; // Detected after Detect stage
std::optional<cv::Rect> crop_region; // Set by Crop stage
};
} // namespace photoconv

View File

@@ -0,0 +1,45 @@
#include "Pipeline.h"
#include <format>
#include <iostream>
namespace photoconv {
void Pipeline::add_stage(std::unique_ptr<PipelineStage> stage) {
stages_.push_back(std::move(stage));
}
StageResult Pipeline::execute(ImageData data, ProgressCallback progress) const {
const auto total = stages_.size();
for (std::size_t i = 0; i < total; ++i) {
const auto& stage = stages_[i];
const auto stage_name = stage->name();
std::cout << std::format("[Pipeline] Executing stage {}/{}: {}",
i + 1, total, stage_name) << std::endl;
if (progress) {
progress(stage_name, static_cast<float>(i) / static_cast<float>(total));
}
auto result = stage->process(std::move(data));
if (!result.has_value()) {
std::cerr << std::format("[Pipeline] Stage '{}' failed: {}",
stage_name, result.error().format()) << std::endl;
return std::unexpected(std::move(result.error()));
}
data = std::move(result.value());
}
if (progress) {
progress("done", 1.0f);
}
return data;
}
std::size_t Pipeline::stage_count() const noexcept {
return stages_.size();
}
} // namespace photoconv

View File

@@ -0,0 +1,65 @@
#pragma once
#include "PipelineStage.h"
#include "ImageData.h"
#include "Error.h"
#include <expected>
#include <memory>
#include <string>
#include <vector>
namespace photoconv {
/**
* @brief Orchestrates the sequential execution of pipeline stages.
*
* The Pipeline owns an ordered list of PipelineStage instances and
* executes them in sequence, threading ImageData through each stage.
* If any stage fails, execution stops and the error is propagated.
*
* Design patterns:
* - Chain of Responsibility: stages are chained; each transforms or rejects
* - Strategy: individual stages are interchangeable implementations
* - Observer: optional progress callback for GUI integration
*/
class Pipeline {
public:
Pipeline() = default;
~Pipeline() = default;
// Non-copyable, movable
Pipeline(const Pipeline&) = delete;
Pipeline& operator=(const Pipeline&) = delete;
Pipeline(Pipeline&&) noexcept = default;
Pipeline& operator=(Pipeline&&) noexcept = default;
/**
* @brief Append a processing stage to the pipeline.
*
* Stages execute in the order they are added.
*
* @param stage Owning pointer to the stage.
*/
void add_stage(std::unique_ptr<PipelineStage> stage);
/**
* @brief Execute all stages in order on the given image data.
*
* @param data Initial ImageData (typically from the Loader).
* @param progress Optional callback for progress reporting.
* @return Final ImageData on success, or the first Error encountered.
*/
[[nodiscard]] StageResult execute(ImageData data,
ProgressCallback progress = nullptr) const;
/**
* @brief Number of stages currently registered.
*/
[[nodiscard]] std::size_t stage_count() const noexcept;
private:
std::vector<std::unique_ptr<PipelineStage>> stages_;
};
} // namespace photoconv

View File

@@ -0,0 +1,54 @@
#pragma once
#include "ImageData.h"
#include "Error.h"
#include <expected>
#include <functional>
#include <string>
namespace photoconv {
/**
* @brief Result type used by all pipeline stages.
*/
using StageResult = std::expected<ImageData, Error>;
/**
* @brief Abstract interface for a single processing stage.
*
* Each stage implements the Strategy pattern: stages are interchangeable
* and composable. The Pipeline class chains them together.
*
* Implementations must:
* - Accept ImageData by value (moved in)
* - Return StageResult (success with transformed ImageData, or Error)
* - Be stateless or hold only configuration (no side effects between calls)
*/
class PipelineStage {
public:
virtual ~PipelineStage() = default;
/**
* @brief Process the image data for this stage.
*
* @param data The image data from the previous stage.
* @return Transformed ImageData on success, or Error on failure.
*/
[[nodiscard]] virtual StageResult process(ImageData data) const = 0;
/**
* @brief Human-readable name for logging and diagnostics.
*/
[[nodiscard]] virtual std::string name() const = 0;
};
/**
* @brief Progress callback signature.
*
* @param stage_name Name of the current stage.
* @param progress Fraction complete [0.0, 1.0] for the overall pipeline.
*/
using ProgressCallback = std::function<void(const std::string& stage_name, float progress)>;
} // namespace photoconv

View File

@@ -0,0 +1,55 @@
#include "Preprocessor.h"
#include <opencv2/imgproc.hpp>
#include <format>
#include <iostream>
namespace photoconv {
StageResult Preprocessor::process(ImageData data) const {
auto result = validate_and_convert(std::move(data));
if (!result.has_value()) {
return result;
}
return deskew(std::move(result.value()));
}
StageResult Preprocessor::validate_and_convert(ImageData data) {
if (data.rgb.empty()) {
return std::unexpected(make_error(
ErrorCode::InvalidBitDepth, "Preprocessor received empty image"));
}
// Convert to CV_16UC3 if necessary
if (data.rgb.type() != CV_16UC3) {
cv::Mat converted;
if (data.rgb.channels() == 1) {
cv::cvtColor(data.rgb, data.rgb, cv::COLOR_GRAY2BGR);
}
if (data.rgb.depth() == CV_8U) {
data.rgb.convertTo(converted, CV_16UC3, 257.0);
data.rgb = std::move(converted);
} else if (data.rgb.depth() != CV_16U) {
data.rgb.convertTo(converted, CV_16UC3);
data.rgb = std::move(converted);
}
}
std::cout << std::format("[Preprocess] Image validated: {}x{} type=CV_16UC3",
data.rgb.cols, data.rgb.rows) << std::endl;
return data;
}
StageResult Preprocessor::deskew(ImageData data) {
// TODO: Implement Hough-line-based deskew detection.
// For now, pass through unchanged.
// Implementation should:
// 1. Convert to grayscale, apply Canny edge detection
// 2. Run HoughLinesP to find dominant lines
// 3. Compute median angle deviation from horizontal/vertical
// 4. If angle > threshold (e.g., 0.5 degrees), apply warpAffine
return data;
}
} // namespace photoconv

View File

@@ -0,0 +1,36 @@
#pragma once
#include "../pipeline/PipelineStage.h"
namespace photoconv {
/**
* @brief Preprocessing stage: validates bit depth, applies deskew.
*
* Ensures the image is in the correct format (CV_16UC3) for
* downstream stages and optionally corrects rotation/skew.
*/
class Preprocessor : public PipelineStage {
public:
Preprocessor() = default;
~Preprocessor() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "Preprocess"; }
private:
/**
* @brief Ensure the image is CV_16UC3.
*/
[[nodiscard]] static StageResult validate_and_convert(ImageData data);
/**
* @brief Detect and correct image skew.
*
* Uses Hough line detection to find dominant angles and
* applies affine rotation to correct.
*/
[[nodiscard]] static StageResult deskew(ImageData data);
};
} // namespace photoconv

View File

@@ -0,0 +1,237 @@
#include "RawLoader.h"
#include <libraw/libraw.h>
#include <opencv2/imgcodecs.hpp>
#include <opencv2/imgproc.hpp>
#include <algorithm>
#include <format>
#include <iostream>
#include <memory>
namespace photoconv {
namespace {
/**
* @brief RAII guard for LibRaw that ensures recycle() is always called.
*/
class LibRawGuard {
public:
explicit LibRawGuard(LibRaw& processor) : processor_{processor} {}
~LibRawGuard() { processor_.recycle(); }
LibRawGuard(const LibRawGuard&) = delete;
LibRawGuard& operator=(const LibRawGuard&) = delete;
private:
LibRaw& processor_;
};
/**
* @brief Convert a file extension to lowercase for comparison.
*/
[[nodiscard]] std::string to_lower_ext(const std::filesystem::path& path) {
auto ext = path.extension().string();
std::ranges::transform(ext, ext.begin(), ::tolower);
return ext;
}
} // anonymous namespace
std::expected<ImageData, Error> RawLoader::load(
const std::filesystem::path& file_path) const
{
// Validate file exists
if (!std::filesystem::exists(file_path)) {
return std::unexpected(make_error(
ErrorCode::FileNotFound,
std::format("File not found: {}", file_path.string())));
}
// Validate file size
const auto file_size = std::filesystem::file_size(file_path);
if (file_size > kMaxRawFileSize) {
return std::unexpected(make_error(
ErrorCode::FileTooLarge,
std::format("File exceeds 4GB limit: {} ({} bytes)",
file_path.string(), file_size)));
}
// Route to appropriate loader
if (is_raw_format(file_path)) {
return load_raw(file_path);
}
if (is_standard_format(file_path)) {
return load_standard(file_path);
}
return std::unexpected(make_error(
ErrorCode::UnsupportedFormat,
std::format("Unsupported file format: {}", file_path.extension().string())));
}
std::expected<ImageData, Error> RawLoader::load_raw(
const std::filesystem::path& file_path) const
{
LibRaw processor;
LibRawGuard guard{processor}; // Ensures recycle() on all exit paths
// Configure for lossless, full-resolution output
processor.imgdata.params.use_camera_wb = 1;
processor.imgdata.params.output_bps = 16;
processor.imgdata.params.no_auto_bright = 1;
processor.imgdata.params.half_size = 0; // Full resolution
processor.imgdata.params.output_color = 1; // sRGB
// Open file
int ret = processor.open_file(file_path.string().c_str());
if (ret != LIBRAW_SUCCESS) {
return std::unexpected(make_error(
ErrorCode::LibRawInitFailed,
std::format("LibRaw open_file failed for '{}': {}",
file_path.string(), libraw_strerror(ret))));
}
// Unpack RAW data
ret = processor.unpack();
if (ret != LIBRAW_SUCCESS) {
return std::unexpected(make_error(
ErrorCode::LibRawUnpackFailed,
std::format("LibRaw unpack failed for '{}': {}",
file_path.string(), libraw_strerror(ret))));
}
// Process (demosaic)
ret = processor.dcraw_process();
if (ret != LIBRAW_SUCCESS) {
return std::unexpected(make_error(
ErrorCode::LibRawProcessFailed,
std::format("LibRaw dcraw_process failed for '{}': {}",
file_path.string(), libraw_strerror(ret))));
}
// Get processed image
libraw_processed_image_t* image = processor.dcraw_make_mem_image(&ret);
if (image == nullptr || ret != LIBRAW_SUCCESS) {
return std::unexpected(make_error(
ErrorCode::DemosaicingFailed,
std::format("LibRaw dcraw_make_mem_image failed for '{}': {}",
file_path.string(), libraw_strerror(ret))));
}
// Convert LibRaw output to cv::Mat (16-bit RGB)
cv::Mat rgb16;
if (image->bits == 16) {
cv::Mat raw_mat(image->height, image->width, CV_16UC3, image->data);
cv::cvtColor(raw_mat, rgb16, cv::COLOR_RGB2BGR); // LibRaw outputs RGB, OpenCV uses BGR
} else {
// 8-bit fallback: convert to 16-bit
cv::Mat raw_mat(image->height, image->width, CV_8UC3, image->data);
cv::cvtColor(raw_mat, raw_mat, cv::COLOR_RGB2BGR);
raw_mat.convertTo(rgb16, CV_16UC3, 257.0); // Scale 0-255 to 0-65535
}
// Extract metadata
RawMetadata meta{};
meta.camera_make = processor.imgdata.idata.make;
meta.camera_model = processor.imgdata.idata.model;
meta.iso_speed = processor.imgdata.other.iso_speed;
meta.shutter_speed = processor.imgdata.other.shutter;
meta.aperture = processor.imgdata.other.aperture;
meta.focal_length = processor.imgdata.other.focal_len;
meta.raw_width = processor.imgdata.sizes.raw_width;
meta.raw_height = processor.imgdata.sizes.raw_height;
meta.raw_bit_depth = image->bits;
// Extract white balance multipliers
const auto& cam_mul = processor.imgdata.color.cam_mul;
if (cam_mul[1] > 0.0f) {
meta.wb_red = cam_mul[0] / cam_mul[1];
meta.wb_green = 1.0f;
meta.wb_blue = cam_mul[2] / cam_mul[1];
}
log_metadata(meta);
// Free LibRaw processed image
LibRaw::dcraw_clear_mem(image);
ImageData result{};
result.rgb = std::move(rgb16);
result.source_path = file_path.string();
result.metadata = std::move(meta);
return result;
}
std::expected<ImageData, Error> RawLoader::load_standard(
const std::filesystem::path& file_path) const
{
// Load with unchanged depth (IMREAD_UNCHANGED preserves 16-bit TIFFs)
cv::Mat img = cv::imread(file_path.string(), cv::IMREAD_UNCHANGED);
if (img.empty()) {
return std::unexpected(make_error(
ErrorCode::FileReadError,
std::format("OpenCV failed to read: {}", file_path.string())));
}
// Convert to 16-bit 3-channel BGR if needed
cv::Mat rgb16;
if (img.channels() == 1) {
cv::cvtColor(img, img, cv::COLOR_GRAY2BGR);
} else if (img.channels() == 4) {
cv::cvtColor(img, img, cv::COLOR_BGRA2BGR);
}
if (img.depth() == CV_8U) {
img.convertTo(rgb16, CV_16UC3, 257.0);
} else if (img.depth() == CV_16U) {
rgb16 = std::move(img);
} else {
img.convertTo(rgb16, CV_16UC3);
}
ImageData result{};
result.rgb = std::move(rgb16);
result.source_path = file_path.string();
// Minimal metadata for standard formats
result.metadata.camera_make = "Unknown";
result.metadata.camera_model = "Standard Image";
return result;
}
bool RawLoader::is_raw_format(const std::filesystem::path& file_path) {
const auto ext = to_lower_ext(file_path);
return std::ranges::any_of(kRawExtensions,
[&ext](std::string_view raw_ext) { return ext == raw_ext; });
}
bool RawLoader::is_standard_format(const std::filesystem::path& file_path) {
const auto ext = to_lower_ext(file_path);
return std::ranges::any_of(kStandardExtensions,
[&ext](std::string_view std_ext) { return ext == std_ext; });
}
void RawLoader::log_metadata(const RawMetadata& meta) {
std::cout << std::format(
"[RawLoader] Metadata:\n"
" Camera: {} {}\n"
" ISO: {:.0f}\n"
" Shutter: {:.4f}s\n"
" Aperture: f/{:.1f}\n"
" Focal Length: {:.1f}mm\n"
" RAW Size: {}x{}\n"
" Bit Depth: {}\n"
" WB (R/G/B): {:.3f} / {:.3f} / {:.3f}",
meta.camera_make, meta.camera_model,
meta.iso_speed, meta.shutter_speed,
meta.aperture, meta.focal_length,
meta.raw_width, meta.raw_height,
meta.raw_bit_depth,
meta.wb_red, meta.wb_green, meta.wb_blue
) << std::endl;
}
} // namespace photoconv

View File

@@ -0,0 +1,95 @@
#pragma once
#include "../pipeline/ImageData.h"
#include "../pipeline/Error.h"
#include <expected>
#include <filesystem>
#include <string>
namespace photoconv {
/// Maximum allowed RAW file size (4 GB).
inline constexpr std::uintmax_t kMaxRawFileSize = 4ULL * 1024 * 1024 * 1024;
/// Supported RAW extensions (lowercase).
inline constexpr std::string_view kRawExtensions[] = {
".cr2", ".cr3", ".nef", ".arw", ".dng", ".orf", ".rw2", ".raf", ".pef"
};
/// Supported standard image extensions (lowercase).
inline constexpr std::string_view kStandardExtensions[] = {
".jpg", ".jpeg", ".png", ".tif", ".tiff"
};
/**
* @brief Loads image files (RAW and standard formats) into ImageData.
*
* This is the first stage of the pipeline. It uses LibRaw for RAW formats
* and OpenCV for standard formats (JPG, PNG, TIFF).
*
* Responsibilities:
* - Validate file existence and size (< 4GB)
* - Detect format from extension
* - Demosaic RAW files losslessly via LibRaw
* - Load standard images via OpenCV
* - Extract and log RAW metadata
* - Ensure output is always CV_16UC3
*
* Invariants enforced:
* - LibRaw::recycle() is always called after use (RAII guard)
* - Lossless demosaicing only (no half-size or thumbnail modes)
* - Files > 4GB are rejected
*/
class RawLoader {
public:
RawLoader() = default;
~RawLoader() = default;
/**
* @brief Load an image file and produce ImageData.
*
* @param file_path Path to the image file (RAW or standard).
* @return ImageData on success, Error on failure.
*/
[[nodiscard]] std::expected<ImageData, Error> load(
const std::filesystem::path& file_path) const;
private:
/**
* @brief Load a RAW file via LibRaw.
*
* Handles CR2, NEF, ARW, DNG, etc. Always calls LibRaw::recycle().
*
* @param file_path Path to the RAW file.
* @return ImageData with populated metadata, or Error.
*/
[[nodiscard]] std::expected<ImageData, Error> load_raw(
const std::filesystem::path& file_path) const;
/**
* @brief Load a standard image (JPG/PNG/TIFF) via OpenCV.
*
* @param file_path Path to the image file.
* @return ImageData (metadata will have minimal fields), or Error.
*/
[[nodiscard]] std::expected<ImageData, Error> load_standard(
const std::filesystem::path& file_path) const;
/**
* @brief Check whether the file extension indicates a RAW format.
*/
[[nodiscard]] static bool is_raw_format(const std::filesystem::path& file_path);
/**
* @brief Check whether the file extension indicates a standard format.
*/
[[nodiscard]] static bool is_standard_format(const std::filesystem::path& file_path);
/**
* @brief Log RAW metadata to stdout.
*/
static void log_metadata(const RawMetadata& meta);
};
} // namespace photoconv

180
src/gui/MainWindow.cpp Normal file
View File

@@ -0,0 +1,180 @@
#include "MainWindow.h"
#include "../converter/rawloader/RawLoader.h"
#include "../converter/preprocess/Preprocessor.h"
#include "../converter/negative/NegativeDetector.h"
#include "../converter/invert/Inverter.h"
#include "../converter/color/ColorCorrector.h"
#include "../converter/crop/CropProcessor.h"
#include "../converter/output/OutputWriter.h"
#include <QFileDialog>
#include <QHBoxLayout>
#include <QMessageBox>
#include <QVBoxLayout>
#include <opencv2/imgproc.hpp>
#include <format>
#include <iostream>
namespace photoconv {
MainWindow::MainWindow(QWidget* parent)
: QMainWindow{parent}
{
setup_ui();
setup_pipeline();
setWindowTitle("Photo Converter");
resize(800, 600);
}
void MainWindow::setup_ui() {
auto* central = new QWidget(this);
auto* layout = new QVBoxLayout(central);
// Button bar
auto* button_layout = new QHBoxLayout();
open_button_ = new QPushButton("Open Files...", this);
convert_button_ = new QPushButton("Convert", this);
output_dir_button_ = new QPushButton("Output Dir...", this);
convert_button_->setEnabled(false);
button_layout->addWidget(open_button_);
button_layout->addWidget(output_dir_button_);
button_layout->addWidget(convert_button_);
layout->addLayout(button_layout);
// Preview area
preview_label_ = new QLabel("No image loaded", this);
preview_label_->setAlignment(Qt::AlignCenter);
preview_label_->setMinimumSize(640, 480);
layout->addWidget(preview_label_);
// Progress bar
progress_bar_ = new QProgressBar(this);
progress_bar_->setRange(0, 100);
progress_bar_->setValue(0);
layout->addWidget(progress_bar_);
// Status
status_label_ = new QLabel("Ready", this);
layout->addWidget(status_label_);
setCentralWidget(central);
// Connections
connect(open_button_, &QPushButton::clicked, this, &MainWindow::on_open_files);
connect(convert_button_, &QPushButton::clicked, this, &MainWindow::on_convert);
connect(output_dir_button_, &QPushButton::clicked, this, &MainWindow::on_select_output_dir);
}
void MainWindow::setup_pipeline() {
pipeline_ = std::make_unique<Pipeline>();
// Note: Loader is called separately before the pipeline.
// Pipeline stages are: Preprocess -> Detect -> Invert -> Color -> PostProcess -> Output
pipeline_->add_stage(std::make_unique<Preprocessor>());
pipeline_->add_stage(std::make_unique<NegativeDetector>());
pipeline_->add_stage(std::make_unique<Inverter>());
pipeline_->add_stage(std::make_unique<ColorCorrector>());
pipeline_->add_stage(std::make_unique<CropProcessor>());
// OutputWriter is added dynamically when output_dir is known
}
void MainWindow::on_open_files() {
QStringList files = QFileDialog::getOpenFileNames(
this, "Open Image Files", QString(), kFileFilter);
if (files.isEmpty()) return;
input_files_.clear();
for (const auto& f : files) {
input_files_.push_back(f.toStdString());
}
status_label_->setText(
QString::fromStdString(std::format("{} file(s) selected", input_files_.size())));
convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty());
}
void MainWindow::on_select_output_dir() {
QString dir = QFileDialog::getExistingDirectory(this, "Select Output Directory");
if (dir.isEmpty()) return;
output_dir_ = dir.toStdString();
output_dir_button_->setText(
QString::fromStdString(std::format("Output: {}", output_dir_)));
convert_button_->setEnabled(!input_files_.empty() && !output_dir_.empty());
}
void MainWindow::on_convert() {
if (input_files_.empty() || output_dir_.empty()) return;
convert_button_->setEnabled(false);
RawLoader loader;
int processed = 0;
for (const auto& file : input_files_) {
status_label_->setText(
QString::fromStdString(std::format("Processing: {}", file)));
// Load
auto load_result = loader.load(file);
if (!load_result.has_value()) {
QMessageBox::warning(this, "Load Error",
QString::fromStdString(load_result.error().format()));
continue;
}
// Build pipeline with output stage
auto run_pipeline = std::make_unique<Pipeline>();
run_pipeline->add_stage(std::make_unique<Preprocessor>());
run_pipeline->add_stage(std::make_unique<NegativeDetector>());
run_pipeline->add_stage(std::make_unique<Inverter>());
run_pipeline->add_stage(std::make_unique<ColorCorrector>());
run_pipeline->add_stage(std::make_unique<CropProcessor>());
run_pipeline->add_stage(std::make_unique<OutputWriter>(
OutputConfig{output_dir_, OutputFormat::PNG_16bit}));
// Execute
auto result = run_pipeline->execute(
std::move(load_result.value()),
[this](const std::string& stage, float progress) {
progress_bar_->setValue(static_cast<int>(progress * 100));
status_label_->setText(QString::fromStdString(stage));
QApplication::processEvents();
});
if (!result.has_value()) {
QMessageBox::warning(this, "Processing Error",
QString::fromStdString(result.error().format()));
} else {
++processed;
update_preview(result.value().rgb);
}
}
progress_bar_->setValue(100);
status_label_->setText(
QString::fromStdString(std::format("Done: {}/{} files processed",
processed, input_files_.size())));
convert_button_->setEnabled(true);
}
void MainWindow::update_preview(const cv::Mat& image) {
if (image.empty()) return;
// Convert 16-bit BGR to 8-bit RGB for Qt display
cv::Mat display;
image.convertTo(display, CV_8UC3, 1.0 / 257.0);
cv::cvtColor(display, display, cv::COLOR_BGR2RGB);
QImage qimg(display.data, display.cols, display.rows,
static_cast<int>(display.step), QImage::Format_RGB888);
// Scale to fit preview label
QPixmap pixmap = QPixmap::fromImage(qimg).scaled(
preview_label_->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation);
preview_label_->setPixmap(pixmap);
}
} // namespace photoconv

68
src/gui/MainWindow.h Normal file
View File

@@ -0,0 +1,68 @@
#pragma once
#include "../converter/pipeline/Pipeline.h"
#include "../converter/pipeline/ImageData.h"
#include <QMainWindow>
#include <QLabel>
#include <QProgressBar>
#include <QPushButton>
#include <memory>
#include <vector>
namespace photoconv {
/**
* @brief Main application window for the photo converter GUI.
*
* Provides:
* - File selection dialog (RAW + standard image filters)
* - Image preview (before/after)
* - Pipeline execution with progress bar
* - Output format selection
* - Batch processing support
*
* The GUI is thin: it delegates all processing to the Pipeline
* and only handles user interaction and display.
*/
class MainWindow : public QMainWindow {
Q_OBJECT
public:
explicit MainWindow(QWidget* parent = nullptr);
~MainWindow() override = default;
private slots:
void on_open_files();
void on_convert();
void on_select_output_dir();
private:
void setup_ui();
void setup_pipeline();
void update_preview(const cv::Mat& image);
// UI elements
QLabel* preview_label_{nullptr};
QProgressBar* progress_bar_{nullptr};
QPushButton* open_button_{nullptr};
QPushButton* convert_button_{nullptr};
QPushButton* output_dir_button_{nullptr};
QLabel* status_label_{nullptr};
// State
std::vector<std::string> input_files_;
std::string output_dir_;
std::unique_ptr<Pipeline> pipeline_;
/// Qt file dialog filter string for supported formats.
static constexpr const char* kFileFilter =
"All Supported (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef "
"*.jpg *.jpeg *.png *.tif *.tiff);;"
"RAW (*.cr2 *.cr3 *.nef *.arw *.dng *.orf *.rw2 *.raf *.pef);;"
"Images (*.jpg *.jpeg *.png *.tif *.tiff);;"
"All Files (*)";
};
} // namespace photoconv

54
src/main.cpp Normal file
View File

@@ -0,0 +1,54 @@
#include "cli/CliRunner.h"
#include "gui/MainWindow.h"
#include <QApplication>
#include <algorithm>
#include <iostream>
#include <string>
/**
* @brief Application entry point.
*
* Supports two modes:
* - GUI mode (default): launches the Qt MainWindow
* - CLI mode (--cli flag): batch processes files without GUI
*/
int main(int argc, char* argv[]) {
// Check if CLI mode is requested
bool cli_mode = false;
for (int i = 1; i < argc; ++i) {
if (std::string{argv[i]} == "--cli") {
cli_mode = true;
break;
}
}
if (cli_mode) {
// CLI batch mode (no Qt dependency)
auto config_result = photoconv::CliRunner::parse_args(argc, argv);
if (!config_result.has_value()) {
std::cerr << config_result.error().format() << std::endl;
return 1;
}
photoconv::CliRunner runner;
auto result = runner.run(config_result.value());
if (!result.has_value()) {
std::cerr << result.error().format() << std::endl;
return 1;
}
return result.value() > 0 ? 0 : 1;
}
// GUI mode
QApplication app(argc, argv);
app.setApplicationName("Photo Converter");
app.setApplicationVersion("0.1.0");
photoconv::MainWindow window;
window.show();
return app.exec();
}