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:
149
src/cli/CliRunner.cpp
Normal file
149
src/cli/CliRunner.cpp
Normal 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
62
src/cli/CliRunner.h
Normal 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
|
||||
106
src/converter/color/ColorCorrector.cpp
Normal file
106
src/converter/color/ColorCorrector.cpp
Normal 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
|
||||
43
src/converter/color/ColorCorrector.h
Normal file
43
src/converter/color/ColorCorrector.h
Normal 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
|
||||
66
src/converter/crop/CropProcessor.cpp
Normal file
66
src/converter/crop/CropProcessor.cpp
Normal 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
|
||||
62
src/converter/crop/CropProcessor.h
Normal file
62
src/converter/crop/CropProcessor.h
Normal 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
|
||||
59
src/converter/invert/Inverter.cpp
Normal file
59
src/converter/invert/Inverter.cpp
Normal 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
|
||||
37
src/converter/invert/Inverter.h
Normal file
37
src/converter/invert/Inverter.h
Normal 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
|
||||
90
src/converter/negative/NegativeDetector.cpp
Normal file
90
src/converter/negative/NegativeDetector.cpp
Normal 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
|
||||
62
src/converter/negative/NegativeDetector.h
Normal file
62
src/converter/negative/NegativeDetector.h
Normal 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
|
||||
85
src/converter/output/OutputWriter.cpp
Normal file
85
src/converter/output/OutputWriter.cpp
Normal 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
|
||||
58
src/converter/output/OutputWriter.h
Normal file
58
src/converter/output/OutputWriter.h
Normal 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
|
||||
101
src/converter/pipeline/Error.h
Normal file
101
src/converter/pipeline/Error.h
Normal 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
|
||||
63
src/converter/pipeline/ImageData.h
Normal file
63
src/converter/pipeline/ImageData.h
Normal 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
|
||||
45
src/converter/pipeline/Pipeline.cpp
Normal file
45
src/converter/pipeline/Pipeline.cpp
Normal 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
|
||||
65
src/converter/pipeline/Pipeline.h
Normal file
65
src/converter/pipeline/Pipeline.h
Normal 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
|
||||
54
src/converter/pipeline/PipelineStage.h
Normal file
54
src/converter/pipeline/PipelineStage.h
Normal 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
|
||||
55
src/converter/preprocess/Preprocessor.cpp
Normal file
55
src/converter/preprocess/Preprocessor.cpp
Normal 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
|
||||
36
src/converter/preprocess/Preprocessor.h
Normal file
36
src/converter/preprocess/Preprocessor.h
Normal 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
|
||||
237
src/converter/rawloader/RawLoader.cpp
Normal file
237
src/converter/rawloader/RawLoader.cpp
Normal 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
|
||||
95
src/converter/rawloader/RawLoader.h
Normal file
95
src/converter/rawloader/RawLoader.h
Normal 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
180
src/gui/MainWindow.cpp
Normal 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
68
src/gui/MainWindow.h
Normal 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
54
src/main.cpp
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user