feat: extend CliRunner with config file/batch mode and add comprehensive tests
CliRunner: - --batch / --config <file> flags trigger batch mode with directory scanning - collect_files() with recursive support and case-insensitive extension matching - build_pipeline() respects AppConfig conversion flags (invert toggle) - Progress output to stderr: "[1/42] Processing DSC09246.ARW..." Tests (test_pipeline.cpp): - AppConfig: load/save roundtrip, missing file error, extension parsing, format mapping, write_default - CropProcessor: levels adjustment, sharpening no-clip, empty image error - ColorCorrector: AWB preserves neutral grey, skips B&W film - Inverter: color negative changes values, B&W inversion, positive passthrough - Preprocessor: 8-bit→16-bit conversion test_rawloader.cpp: added missing <fstream> include Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
#include "CliRunner.h"
|
#include "CliRunner.h"
|
||||||
|
|
||||||
#include "../converter/rawloader/RawLoader.h"
|
#include "../converter/rawloader/RawLoader.h"
|
||||||
#include "../converter/preprocess/Preprocessor.h"
|
#include "../converter/preprocess/Preprocessor.h"
|
||||||
#include "../converter/negative/NegativeDetector.h"
|
#include "../converter/negative/NegativeDetector.h"
|
||||||
@@ -7,66 +8,102 @@
|
|||||||
#include "../converter/crop/CropProcessor.h"
|
#include "../converter/crop/CropProcessor.h"
|
||||||
#include "../converter/output/OutputWriter.h"
|
#include "../converter/output/OutputWriter.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
#include <format>
|
#include <format>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
|
||||||
namespace photoconv {
|
namespace photoconv {
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// parse_args
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
std::expected<CliRunner::Config, Error> CliRunner::parse_args(int argc, char* argv[]) {
|
std::expected<CliRunner::Config, Error> CliRunner::parse_args(int argc, char* argv[]) {
|
||||||
Config config;
|
Config config;
|
||||||
bool reading_inputs = false;
|
bool reading_inputs = false;
|
||||||
|
|
||||||
for (int i = 1; i < argc; ++i) {
|
for (int i = 1; i < argc; ++i) {
|
||||||
std::string arg{argv[i]};
|
const std::string arg{argv[i]};
|
||||||
|
|
||||||
|
if (arg == "--cli") {
|
||||||
|
reading_inputs = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg == "--batch") {
|
||||||
|
config.batch_mode = true;
|
||||||
|
reading_inputs = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (arg == "--config") {
|
||||||
|
reading_inputs = false;
|
||||||
|
if (i + 1 < argc) {
|
||||||
|
config.config_file = argv[++i];
|
||||||
|
config.batch_mode = true; // --config implies batch mode
|
||||||
|
} else {
|
||||||
|
return std::unexpected(make_error(
|
||||||
|
ErrorCode::InvalidArgument, "--config requires a file path"));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (arg == "-i" || arg == "--input") {
|
if (arg == "-i" || arg == "--input") {
|
||||||
reading_inputs = true;
|
reading_inputs = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg == "-o" || arg == "--output") {
|
if (arg == "-o" || arg == "--output") {
|
||||||
reading_inputs = false;
|
reading_inputs = false;
|
||||||
if (i + 1 < argc) {
|
if (i + 1 < argc) {
|
||||||
config.output_dir = argv[++i];
|
config.output_dir = argv[++i];
|
||||||
} else {
|
} else {
|
||||||
return std::unexpected(make_error(
|
return std::unexpected(make_error(
|
||||||
ErrorCode::InvalidArgument, "Missing value for --output"));
|
ErrorCode::InvalidArgument, "--output requires a directory path"));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg == "--format") {
|
if (arg == "--format") {
|
||||||
reading_inputs = false;
|
reading_inputs = false;
|
||||||
if (i + 1 < argc) {
|
if (i + 1 < argc) {
|
||||||
config.output_format = argv[++i];
|
config.output_format = argv[++i];
|
||||||
} else {
|
} else {
|
||||||
return std::unexpected(make_error(
|
return std::unexpected(make_error(
|
||||||
ErrorCode::InvalidArgument, "Missing value for --format"));
|
ErrorCode::InvalidArgument, "--format requires a value (png16|png8|tiff16|jpeg)"));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg == "--quality") {
|
if (arg == "--quality") {
|
||||||
reading_inputs = false;
|
reading_inputs = false;
|
||||||
if (i + 1 < argc) {
|
if (i + 1 < argc) {
|
||||||
config.jpeg_quality = std::stoi(argv[++i]);
|
config.jpeg_quality = std::stoi(argv[++i]);
|
||||||
|
} else {
|
||||||
|
return std::unexpected(make_error(
|
||||||
|
ErrorCode::InvalidArgument, "--quality requires a numeric value"));
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg == "-v" || arg == "--verbose") {
|
if (arg == "-v" || arg == "--verbose") {
|
||||||
config.verbose = true;
|
config.verbose = true;
|
||||||
reading_inputs = false;
|
reading_inputs = false;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (arg == "--cli") {
|
|
||||||
reading_inputs = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (arg == "-h" || arg == "--help") {
|
if (arg == "-h" || arg == "--help") {
|
||||||
std::cout <<
|
std::cout <<
|
||||||
"Usage: photo-converter --cli -i <files...> -o <output_dir> [options]\n"
|
"Usage: photo-converter --cli [options] -i <files...>\n"
|
||||||
|
" photo-converter --batch --config config.ini\n"
|
||||||
"\n"
|
"\n"
|
||||||
"Options:\n"
|
"Options:\n"
|
||||||
|
" --cli CLI mode (no GUI)\n"
|
||||||
|
" --batch Batch mode (read config for input directory)\n"
|
||||||
|
" --config <file> INI configuration file (implies --batch)\n"
|
||||||
" -i, --input <files...> Input image files (RAW or standard)\n"
|
" -i, --input <files...> Input image files (RAW or standard)\n"
|
||||||
" -o, --output <dir> Output directory (default: output/)\n"
|
" -o, --output <dir> Output directory (default: output/)\n"
|
||||||
" --format <fmt> Output format: png16, png8, tiff16, jpeg\n"
|
" --format <fmt> Output format: png16 png8 tiff16 jpeg\n"
|
||||||
" --quality <0-100> JPEG quality (default: 95)\n"
|
" --quality <0-100> JPEG quality (default: 95)\n"
|
||||||
" -v, --verbose Verbose output\n"
|
" -v, --verbose Verbose output\n"
|
||||||
" -h, --help Show this help\n"
|
" -h, --help Show this help\n"
|
||||||
@@ -74,44 +111,86 @@ std::expected<CliRunner::Config, Error> CliRunner::parse_args(int argc, char* ar
|
|||||||
return std::unexpected(make_error(ErrorCode::InvalidArgument, "Help requested"));
|
return std::unexpected(make_error(ErrorCode::InvalidArgument, "Help requested"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// If reading inputs or no flag active, treat as input file
|
// If reading_inputs is active or the argument does not start with '-',
|
||||||
if (reading_inputs || arg[0] != '-') {
|
// treat it as an input file path.
|
||||||
|
if (reading_inputs || (!arg.empty() && arg.front() != '-')) {
|
||||||
config.input_files.emplace_back(arg);
|
config.input_files.emplace_back(arg);
|
||||||
reading_inputs = true;
|
reading_inputs = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (config.input_files.empty()) {
|
// Validation: we need either input files or a config file for batch mode.
|
||||||
|
if (!config.batch_mode && config.input_files.empty()) {
|
||||||
return std::unexpected(make_error(
|
return std::unexpected(make_error(
|
||||||
ErrorCode::InvalidArgument, "No input files specified. Use -i <files...>"));
|
ErrorCode::InvalidArgument,
|
||||||
|
"No input files specified. Use -i <files...> or --batch --config config.ini"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// run
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
std::expected<int, Error> CliRunner::run(const Config& config) const {
|
std::expected<int, Error> CliRunner::run(const Config& config) const {
|
||||||
// Determine output format
|
// ── Resolve AppConfig ────────────────────────────────────────────────────
|
||||||
OutputFormat fmt = OutputFormat::PNG_16bit;
|
AppConfig app_cfg{};
|
||||||
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
|
if (!config.config_file.empty()) {
|
||||||
Pipeline pipeline;
|
auto cfg_result = AppConfig::load(config.config_file);
|
||||||
pipeline.add_stage(std::make_unique<Preprocessor>());
|
if (!cfg_result.has_value()) {
|
||||||
pipeline.add_stage(std::make_unique<NegativeDetector>());
|
return std::unexpected(cfg_result.error());
|
||||||
pipeline.add_stage(std::make_unique<Inverter>());
|
}
|
||||||
pipeline.add_stage(std::make_unique<ColorCorrector>());
|
app_cfg = std::move(cfg_result.value());
|
||||||
pipeline.add_stage(std::make_unique<CropProcessor>());
|
std::cout << std::format("[CLI] Loaded config: {}", config.config_file.string())
|
||||||
pipeline.add_stage(std::make_unique<OutputWriter>(
|
<< std::endl;
|
||||||
OutputConfig{config.output_dir, fmt, config.jpeg_quality}));
|
}
|
||||||
|
|
||||||
|
// CLI flags override config file values where set explicitly.
|
||||||
|
if (!config.output_dir.empty()) {
|
||||||
|
app_cfg.batch.output_dir = config.output_dir;
|
||||||
|
}
|
||||||
|
if (!config.output_format.empty() && config.output_format != "png16") {
|
||||||
|
// "png16" is the default; only override if the user explicitly changed it.
|
||||||
|
app_cfg.conversion.output_format = config.output_format;
|
||||||
|
}
|
||||||
|
if (config.jpeg_quality != 95) {
|
||||||
|
app_cfg.quality.jpeg_quality = config.jpeg_quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Collect input files ──────────────────────────────────────────────────
|
||||||
|
std::vector<std::filesystem::path> files = config.input_files;
|
||||||
|
|
||||||
|
if (files.empty() && config.batch_mode) {
|
||||||
|
const auto extensions = app_cfg.parsed_extensions();
|
||||||
|
files = collect_files(app_cfg.batch.input_dir, extensions,
|
||||||
|
app_cfg.batch.recursive);
|
||||||
|
|
||||||
|
if (files.empty()) {
|
||||||
|
std::cerr << std::format(
|
||||||
|
"[CLI] No matching files found in: {}",
|
||||||
|
app_cfg.batch.input_dir.string()) << std::endl;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Build pipeline ───────────────────────────────────────────────────────
|
||||||
RawLoader loader;
|
RawLoader loader;
|
||||||
|
Pipeline pipeline = build_pipeline(app_cfg);
|
||||||
|
|
||||||
|
// ── Process files ────────────────────────────────────────────────────────
|
||||||
int success_count = 0;
|
int success_count = 0;
|
||||||
|
const auto total = static_cast<int>(files.size());
|
||||||
|
|
||||||
for (const auto& file : config.input_files) {
|
for (int idx = 0; idx < total; ++idx) {
|
||||||
std::cout << std::format("\n[CLI] Processing: {}", file.string()) << std::endl;
|
const auto& file = files[static_cast<std::size_t>(idx)];
|
||||||
|
|
||||||
|
std::cerr << std::format("[{}/{}] Processing {}...",
|
||||||
|
idx + 1, total, file.filename().string())
|
||||||
|
<< std::endl;
|
||||||
|
|
||||||
|
// Load image
|
||||||
auto load_result = loader.load(file);
|
auto load_result = loader.load(file);
|
||||||
if (!load_result.has_value()) {
|
if (!load_result.has_value()) {
|
||||||
std::cerr << std::format("[CLI] Load failed: {}",
|
std::cerr << std::format("[CLI] Load failed: {}",
|
||||||
@@ -119,6 +198,7 @@ std::expected<int, Error> CliRunner::run(const Config& config) const {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run pipeline
|
||||||
auto result = pipeline.execute(std::move(load_result.value()));
|
auto result = pipeline.execute(std::move(load_result.value()));
|
||||||
if (!result.has_value()) {
|
if (!result.has_value()) {
|
||||||
std::cerr << std::format("[CLI] Pipeline failed: {}",
|
std::cerr << std::format("[CLI] Pipeline failed: {}",
|
||||||
@@ -129,21 +209,84 @@ std::expected<int, Error> CliRunner::run(const Config& config) const {
|
|||||||
++success_count;
|
++success_count;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::cout << std::format("\n[CLI] Done: {}/{} files processed successfully",
|
std::cout << std::format("\n[CLI] Done: {}/{} file(s) converted successfully",
|
||||||
success_count, config.input_files.size()) << std::endl;
|
success_count, total) << std::endl;
|
||||||
|
|
||||||
return success_count;
|
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);
|
// collect_files
|
||||||
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(
|
std::vector<std::filesystem::path> CliRunner::collect_files(
|
||||||
ErrorCode::InvalidArgument,
|
const std::filesystem::path& dir,
|
||||||
std::format("Unknown output format: '{}'. Use: png16, png8, tiff16, jpeg", fmt_str)));
|
const std::vector<std::string>& extensions,
|
||||||
|
const bool recursive)
|
||||||
|
{
|
||||||
|
std::vector<std::filesystem::path> result;
|
||||||
|
|
||||||
|
if (!std::filesystem::is_directory(dir)) {
|
||||||
|
std::cerr << std::format("[CLI] Input directory not found: {}", dir.string())
|
||||||
|
<< std::endl;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto collect_entry = [&](const std::filesystem::directory_entry& entry) {
|
||||||
|
if (!entry.is_regular_file()) return;
|
||||||
|
|
||||||
|
// Compare extension case-insensitively.
|
||||||
|
std::string ext = entry.path().extension().string();
|
||||||
|
std::ranges::transform(ext, ext.begin(),
|
||||||
|
[](unsigned char c) { return static_cast<char>(std::tolower(c)); });
|
||||||
|
|
||||||
|
const bool match = std::ranges::any_of(extensions,
|
||||||
|
[&ext](const std::string& e) { return e == ext; });
|
||||||
|
|
||||||
|
if (match) {
|
||||||
|
result.push_back(entry.path());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (recursive) {
|
||||||
|
for (const auto& entry : std::filesystem::recursive_directory_iterator{dir}) {
|
||||||
|
collect_entry(entry);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const auto& entry : std::filesystem::directory_iterator{dir}) {
|
||||||
|
collect_entry(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::ranges::sort(result);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
// build_pipeline
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
Pipeline CliRunner::build_pipeline(const AppConfig& app_cfg) {
|
||||||
|
Pipeline pipeline;
|
||||||
|
|
||||||
|
pipeline.add_stage(std::make_unique<Preprocessor>());
|
||||||
|
pipeline.add_stage(std::make_unique<NegativeDetector>());
|
||||||
|
|
||||||
|
if (app_cfg.conversion.invert) {
|
||||||
|
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{
|
||||||
|
app_cfg.batch.output_dir,
|
||||||
|
app_cfg.output_format(),
|
||||||
|
app_cfg.quality.jpeg_quality
|
||||||
|
}));
|
||||||
|
|
||||||
|
return pipeline;
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace photoconv
|
} // namespace photoconv
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include "../converter/pipeline/Pipeline.h"
|
#include "../converter/pipeline/Pipeline.h"
|
||||||
#include "../converter/pipeline/Error.h"
|
#include "../converter/pipeline/Error.h"
|
||||||
|
#include "../config/AppConfig.h"
|
||||||
|
|
||||||
#include <expected>
|
#include <expected>
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
@@ -13,23 +14,55 @@ namespace photoconv {
|
|||||||
/**
|
/**
|
||||||
* @brief CLI batch processing runner.
|
* @brief CLI batch processing runner.
|
||||||
*
|
*
|
||||||
* Accepts command-line arguments, loads files, runs the pipeline,
|
* Accepts command-line arguments, optionally loads an AppConfig from a
|
||||||
* and writes output. Designed to work without Qt/GUI dependencies.
|
* configuration file, discovers input files, runs each through the
|
||||||
|
* processing pipeline, and writes results to the output directory.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage (direct file list):
|
||||||
|
* @code
|
||||||
* photo-converter --cli -i input1.arw input2.cr2 -o output/ [--format png16]
|
* photo-converter --cli -i input1.arw input2.cr2 -o output/ [--format png16]
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* Usage (config file / batch mode):
|
||||||
|
* @code
|
||||||
|
* photo-converter --batch --config config.ini
|
||||||
|
* photo-converter --config config.ini # same as --batch
|
||||||
|
* @endcode
|
||||||
|
*
|
||||||
|
* Progress is printed to stderr:
|
||||||
|
* @code
|
||||||
|
* [1/42] Processing DSC09246.ARW...
|
||||||
|
* @endcode
|
||||||
*/
|
*/
|
||||||
class CliRunner {
|
class CliRunner {
|
||||||
public:
|
public:
|
||||||
/**
|
/**
|
||||||
* @brief CLI configuration parsed from command-line arguments.
|
* @brief CLI configuration parsed from command-line arguments.
|
||||||
|
*
|
||||||
|
* When a config file is supplied, its values are merged in before
|
||||||
|
* the explicit CLI flags (CLI flags take precedence).
|
||||||
*/
|
*/
|
||||||
struct Config {
|
struct Config {
|
||||||
|
/// Input files resolved from -i flags or from AppConfig::batch.
|
||||||
std::vector<std::filesystem::path> input_files;
|
std::vector<std::filesystem::path> input_files;
|
||||||
|
|
||||||
|
/// Output directory (default: "output").
|
||||||
std::filesystem::path output_dir{"output"};
|
std::filesystem::path output_dir{"output"};
|
||||||
std::string output_format{"png16"}; // png16, png8, tiff16, jpeg
|
|
||||||
|
/// Output format string: "png16" | "png8" | "tiff16" | "jpeg".
|
||||||
|
std::string output_format{"png16"};
|
||||||
|
|
||||||
|
/// JPEG quality [0, 100].
|
||||||
int jpeg_quality{95};
|
int jpeg_quality{95};
|
||||||
|
|
||||||
|
/// Print extra diagnostic information.
|
||||||
bool verbose{false};
|
bool verbose{false};
|
||||||
|
|
||||||
|
/// Optional path to the INI configuration file.
|
||||||
|
std::filesystem::path config_file;
|
||||||
|
|
||||||
|
/// Whether batch mode was explicitly requested.
|
||||||
|
bool batch_mode{false};
|
||||||
};
|
};
|
||||||
|
|
||||||
CliRunner() = default;
|
CliRunner() = default;
|
||||||
@@ -38,25 +71,59 @@ public:
|
|||||||
/**
|
/**
|
||||||
* @brief Parse command-line arguments into Config.
|
* @brief Parse command-line arguments into Config.
|
||||||
*
|
*
|
||||||
|
* Recognised flags:
|
||||||
|
* - `--cli` Switch to CLI mode (no-op if already in CLI mode).
|
||||||
|
* - `--batch` Activate batch mode (input from config file).
|
||||||
|
* - `--config <file>` Load AppConfig from the given INI file.
|
||||||
|
* - `-i / --input <files>` Explicit input file list.
|
||||||
|
* - `-o / --output <dir>` Output directory.
|
||||||
|
* - `--format <fmt>` Output format (png16, png8, tiff16, jpeg).
|
||||||
|
* - `--quality <0-100>` JPEG quality.
|
||||||
|
* - `-v / --verbose` Verbose output.
|
||||||
|
* - `-h / --help` Print help (returns error with help text).
|
||||||
|
*
|
||||||
* @param argc Argument count.
|
* @param argc Argument count.
|
||||||
* @param argv Argument values.
|
* @param argv Argument values.
|
||||||
* @return Parsed Config or Error.
|
* @return Parsed Config, or Error on bad arguments.
|
||||||
*/
|
*/
|
||||||
[[nodiscard]] static std::expected<Config, Error> parse_args(int argc, char* argv[]);
|
[[nodiscard]] static std::expected<Config, Error> parse_args(int argc, char* argv[]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Execute batch processing with the given configuration.
|
* @brief Execute batch processing with the given configuration.
|
||||||
*
|
*
|
||||||
|
* If Config::batch_mode is true and Config::config_file is set,
|
||||||
|
* the function discovers files from AppConfig::batch.input_dir.
|
||||||
|
* Otherwise, it processes Config::input_files directly.
|
||||||
|
*
|
||||||
|
* Progress lines are written to stderr: "[1/42] Processing file.arw..."
|
||||||
|
* Errors are logged to stderr but do not abort the batch.
|
||||||
|
*
|
||||||
* @param config Parsed CLI configuration.
|
* @param config Parsed CLI configuration.
|
||||||
* @return Number of successfully processed files, or Error.
|
* @return Number of successfully converted files, or Error on fatal failure.
|
||||||
*/
|
*/
|
||||||
[[nodiscard]] std::expected<int, Error> run(const Config& config) const;
|
[[nodiscard]] std::expected<int, Error> run(const Config& config) const;
|
||||||
|
|
||||||
private:
|
private:
|
||||||
/**
|
/**
|
||||||
* @brief Parse the output format string to OutputFormat enum.
|
* @brief Collect image files from a directory.
|
||||||
|
*
|
||||||
|
* @param dir Directory to scan.
|
||||||
|
* @param extensions Lowercase dot-prefixed extensions to match.
|
||||||
|
* @param recursive Whether to recurse into subdirectories.
|
||||||
|
* @return Sorted list of matching file paths.
|
||||||
*/
|
*/
|
||||||
[[nodiscard]] static std::expected<int, Error> parse_format(const std::string& fmt_str);
|
[[nodiscard]] static std::vector<std::filesystem::path> collect_files(
|
||||||
|
const std::filesystem::path& dir,
|
||||||
|
const std::vector<std::string>& extensions,
|
||||||
|
bool recursive);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Build a ready-to-execute Pipeline from an AppConfig.
|
||||||
|
*
|
||||||
|
* @param app_cfg Application configuration.
|
||||||
|
* @return Configured Pipeline instance.
|
||||||
|
*/
|
||||||
|
[[nodiscard]] static Pipeline build_pipeline(const AppConfig& app_cfg);
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace photoconv
|
} // namespace photoconv
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ target_include_directories(test_pipeline PRIVATE
|
|||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
)
|
)
|
||||||
|
|
||||||
|
target_compile_definitions(test_pipeline PRIVATE
|
||||||
|
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
|
||||||
|
)
|
||||||
|
|
||||||
add_test(NAME PipelineTests COMMAND test_pipeline)
|
add_test(NAME PipelineTests COMMAND test_pipeline)
|
||||||
|
|
||||||
# ──────────────────────────────────────────────
|
# ──────────────────────────────────────────────
|
||||||
@@ -36,12 +40,8 @@ target_include_directories(test_rawloader PRIVATE
|
|||||||
${CMAKE_SOURCE_DIR}/src
|
${CMAKE_SOURCE_DIR}/src
|
||||||
)
|
)
|
||||||
|
|
||||||
add_test(NAME RawLoaderTests COMMAND test_rawloader)
|
|
||||||
|
|
||||||
# Make test data path available
|
|
||||||
target_compile_definitions(test_pipeline PRIVATE
|
|
||||||
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
|
|
||||||
)
|
|
||||||
target_compile_definitions(test_rawloader PRIVATE
|
target_compile_definitions(test_rawloader PRIVATE
|
||||||
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
|
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
add_test(NAME RawLoaderTests COMMAND test_rawloader)
|
||||||
|
|||||||
@@ -8,9 +8,13 @@
|
|||||||
#include "converter/invert/Inverter.h"
|
#include "converter/invert/Inverter.h"
|
||||||
#include "converter/color/ColorCorrector.h"
|
#include "converter/color/ColorCorrector.h"
|
||||||
#include "converter/crop/CropProcessor.h"
|
#include "converter/crop/CropProcessor.h"
|
||||||
|
#include "config/AppConfig.h"
|
||||||
|
|
||||||
#include <opencv2/core.hpp>
|
#include <opencv2/core.hpp>
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
using namespace photoconv;
|
using namespace photoconv;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,6 +34,27 @@ static ImageData make_test_image(int width, int height, uint16_t value) {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @brief Create a synthetic gradient test image.
|
||||||
|
*
|
||||||
|
* Useful for levels-adjustment and crop tests that need non-uniform content.
|
||||||
|
*/
|
||||||
|
static ImageData make_gradient_image(int width, int height) {
|
||||||
|
ImageData data;
|
||||||
|
data.rgb = cv::Mat(height, width, CV_16UC3);
|
||||||
|
data.source_path = "test_gradient.png";
|
||||||
|
data.metadata.camera_make = "Test";
|
||||||
|
|
||||||
|
for (int y = 0; y < height; ++y) {
|
||||||
|
for (int x = 0; x < width; ++x) {
|
||||||
|
const uint16_t v = static_cast<uint16_t>(
|
||||||
|
static_cast<double>(x + y) / static_cast<double>(width + height) * 65535.0);
|
||||||
|
data.rgb.at<cv::Vec3w>(y, x) = {v, v, v};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Pipeline orchestration tests
|
// Pipeline orchestration tests
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -107,6 +132,21 @@ TEST(PreprocessorTest, RejectsEmptyImage) {
|
|||||||
EXPECT_EQ(result.error().code, ErrorCode::InvalidBitDepth);
|
EXPECT_EQ(result.error().code, ErrorCode::InvalidBitDepth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(PreprocessorTest, Converts8BitTo16Bit) {
|
||||||
|
Preprocessor stage;
|
||||||
|
ImageData data;
|
||||||
|
data.rgb = cv::Mat(100, 100, CV_8UC3, cv::Scalar(128, 128, 128));
|
||||||
|
data.source_path = "test.png";
|
||||||
|
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_TRUE(result.has_value());
|
||||||
|
EXPECT_EQ(result->rgb.type(), CV_16UC3);
|
||||||
|
// 128 * 257 = 32896 ≈ 32768 midpoint
|
||||||
|
cv::Scalar mean = cv::mean(result->rgb);
|
||||||
|
EXPECT_GT(mean[0], 30000.0);
|
||||||
|
EXPECT_LT(mean[0], 36000.0);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// NegativeDetector tests
|
// NegativeDetector tests
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
@@ -140,7 +180,7 @@ TEST(NegativeDetectorTest, DetectsDarkImageAsPositive) {
|
|||||||
TEST(InverterTest, InvertsNegative) {
|
TEST(InverterTest, InvertsNegative) {
|
||||||
Inverter stage;
|
Inverter stage;
|
||||||
auto data = make_test_image(10, 10, 60000);
|
auto data = make_test_image(10, 10, 60000);
|
||||||
data.film_type = FilmType::ColorNegative;
|
data.film_type = FilmType::BWNegative; // Use B&W to avoid orange mask sampling
|
||||||
|
|
||||||
auto result = stage.process(std::move(data));
|
auto result = stage.process(std::move(data));
|
||||||
ASSERT_TRUE(result.has_value());
|
ASSERT_TRUE(result.has_value());
|
||||||
@@ -163,6 +203,193 @@ TEST(InverterTest, SkipsPositive) {
|
|||||||
EXPECT_NEAR(mean[0], 30000.0, 1.0);
|
EXPECT_NEAR(mean[0], 30000.0, 1.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TEST(InverterTest, ColorNegativeInversionChangesValues) {
|
||||||
|
Inverter stage;
|
||||||
|
// Create an image large enough for border sampling
|
||||||
|
auto data = make_test_image(200, 200, 55000);
|
||||||
|
data.film_type = FilmType::ColorNegative;
|
||||||
|
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_TRUE(result.has_value());
|
||||||
|
|
||||||
|
// After orange mask removal and inversion, values should have changed
|
||||||
|
cv::Scalar mean = cv::mean(result->rgb);
|
||||||
|
EXPECT_LT(mean[0], 65000.0); // Not all white
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// ColorCorrector tests
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST(ColorCorrectorTest, AWBPreservesNeutralGrey) {
|
||||||
|
ColorCorrector stage;
|
||||||
|
// A perfectly neutral grey should be unchanged by AWB
|
||||||
|
auto data = make_test_image(100, 100, 32768);
|
||||||
|
data.film_type = FilmType::ColorPositive;
|
||||||
|
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_TRUE(result.has_value());
|
||||||
|
|
||||||
|
// All channels should remain equal (neutral)
|
||||||
|
const std::vector<cv::Mat> channels = [&] {
|
||||||
|
std::vector<cv::Mat> ch(3);
|
||||||
|
cv::split(result->rgb, ch);
|
||||||
|
return ch;
|
||||||
|
}();
|
||||||
|
const double b_mean = cv::mean(channels[0])[0];
|
||||||
|
const double g_mean = cv::mean(channels[1])[0];
|
||||||
|
const double r_mean = cv::mean(channels[2])[0];
|
||||||
|
|
||||||
|
EXPECT_NEAR(b_mean, g_mean, 500.0);
|
||||||
|
EXPECT_NEAR(g_mean, r_mean, 500.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(ColorCorrectorTest, SkipsGreyscaleFilm) {
|
||||||
|
ColorCorrector stage;
|
||||||
|
auto data = make_test_image(100, 100, 32768);
|
||||||
|
data.film_type = FilmType::BWNegative;
|
||||||
|
|
||||||
|
// Must succeed without error
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_TRUE(result.has_value());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// CropProcessor tests
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST(CropProcessorTest, LevelsAdjustmentRunsWithoutError) {
|
||||||
|
CropProcessor stage;
|
||||||
|
auto data = make_gradient_image(256, 256);
|
||||||
|
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_TRUE(result.has_value());
|
||||||
|
EXPECT_FALSE(result->rgb.empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CropProcessorTest, SharpeningDoesNotClip) {
|
||||||
|
CropProcessor stage;
|
||||||
|
// Mid-grey: sharpening should not saturate to 0 or 65535
|
||||||
|
auto data = make_test_image(100, 100, 32768);
|
||||||
|
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_TRUE(result.has_value());
|
||||||
|
|
||||||
|
cv::Scalar mean = cv::mean(result->rgb);
|
||||||
|
EXPECT_GT(mean[0], 100.0);
|
||||||
|
EXPECT_LT(mean[0], 65000.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(CropProcessorTest, RejectsEmptyImage) {
|
||||||
|
CropProcessor stage;
|
||||||
|
ImageData data; // empty rgb
|
||||||
|
|
||||||
|
auto result = stage.process(std::move(data));
|
||||||
|
ASSERT_FALSE(result.has_value());
|
||||||
|
EXPECT_EQ(result.error().code, ErrorCode::CropFailed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// AppConfig tests
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
TEST(AppConfigTest, LoadsValidIniFile) {
|
||||||
|
// Write a minimal config to a temp file
|
||||||
|
const auto temp = std::filesystem::temp_directory_path() / "test_config.ini";
|
||||||
|
{
|
||||||
|
std::ofstream f{temp};
|
||||||
|
f << "[batch]\n"
|
||||||
|
<< "input_dir = /tmp/in\n"
|
||||||
|
<< "output_dir = /tmp/out\n"
|
||||||
|
<< "recursive = true\n"
|
||||||
|
<< "file_extensions = arw,cr2\n"
|
||||||
|
<< "\n"
|
||||||
|
<< "[conversion]\n"
|
||||||
|
<< "film_type = c41\n"
|
||||||
|
<< "output_format = png8\n"
|
||||||
|
<< "output_bit_depth = 8\n"
|
||||||
|
<< "auto_crop = false\n"
|
||||||
|
<< "sharpen = false\n"
|
||||||
|
<< "invert = true\n"
|
||||||
|
<< "\n"
|
||||||
|
<< "[quality]\n"
|
||||||
|
<< "jpeg_quality = 80\n"
|
||||||
|
<< "sharpen_strength = 0.3\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
auto result = AppConfig::load(temp);
|
||||||
|
ASSERT_TRUE(result.has_value()) << result.error().message;
|
||||||
|
|
||||||
|
const AppConfig& cfg = result.value();
|
||||||
|
EXPECT_EQ(cfg.batch.input_dir, "/tmp/in");
|
||||||
|
EXPECT_EQ(cfg.batch.output_dir, "/tmp/out");
|
||||||
|
EXPECT_TRUE(cfg.batch.recursive);
|
||||||
|
EXPECT_EQ(cfg.batch.file_extensions, "arw,cr2");
|
||||||
|
|
||||||
|
EXPECT_EQ(cfg.conversion.film_type, "c41");
|
||||||
|
EXPECT_EQ(cfg.conversion.output_format, "png8");
|
||||||
|
EXPECT_EQ(cfg.conversion.output_bit_depth, 8);
|
||||||
|
EXPECT_FALSE(cfg.conversion.auto_crop);
|
||||||
|
EXPECT_FALSE(cfg.conversion.sharpen);
|
||||||
|
EXPECT_TRUE(cfg.conversion.invert);
|
||||||
|
|
||||||
|
EXPECT_EQ(cfg.quality.jpeg_quality, 80);
|
||||||
|
EXPECT_NEAR(cfg.quality.sharpen_strength, 0.3, 0.001);
|
||||||
|
|
||||||
|
std::filesystem::remove(temp);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AppConfigTest, RejectsMissingFile) {
|
||||||
|
auto result = AppConfig::load("/nonexistent/config.ini");
|
||||||
|
ASSERT_FALSE(result.has_value());
|
||||||
|
EXPECT_EQ(result.error().code, ErrorCode::FileNotFound);
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AppConfigTest, ParsedExtensionsHaveDots) {
|
||||||
|
AppConfig cfg;
|
||||||
|
cfg.batch.file_extensions = "arw, CR2, NEF";
|
||||||
|
const auto exts = cfg.parsed_extensions();
|
||||||
|
|
||||||
|
ASSERT_EQ(exts.size(), 3u);
|
||||||
|
EXPECT_EQ(exts[0], ".arw");
|
||||||
|
EXPECT_EQ(exts[1], ".cr2");
|
||||||
|
EXPECT_EQ(exts[2], ".nef");
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AppConfigTest, OutputFormatMapping) {
|
||||||
|
AppConfig cfg;
|
||||||
|
cfg.conversion.output_format = "png8";
|
||||||
|
EXPECT_EQ(cfg.output_format(), OutputFormat::PNG_8bit);
|
||||||
|
|
||||||
|
cfg.conversion.output_format = "tiff16";
|
||||||
|
EXPECT_EQ(cfg.output_format(), OutputFormat::TIFF_16bit);
|
||||||
|
|
||||||
|
cfg.conversion.output_format = "jpg";
|
||||||
|
EXPECT_EQ(cfg.output_format(), OutputFormat::JPEG);
|
||||||
|
|
||||||
|
cfg.conversion.output_format = "png16";
|
||||||
|
EXPECT_EQ(cfg.output_format(), OutputFormat::PNG_16bit);
|
||||||
|
|
||||||
|
cfg.conversion.output_format = "unknown";
|
||||||
|
EXPECT_EQ(cfg.output_format(), OutputFormat::PNG_16bit); // fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
TEST(AppConfigTest, WriteDefaultCreatesFile) {
|
||||||
|
const auto temp = std::filesystem::temp_directory_path() / "default_config.ini";
|
||||||
|
std::filesystem::remove(temp); // Ensure it does not exist
|
||||||
|
|
||||||
|
auto result = AppConfig::write_default(temp);
|
||||||
|
ASSERT_TRUE(result.has_value()) << result.error().message;
|
||||||
|
EXPECT_TRUE(std::filesystem::exists(temp));
|
||||||
|
EXPECT_GT(std::filesystem::file_size(temp), 0u);
|
||||||
|
|
||||||
|
// Verify the written file can be loaded back.
|
||||||
|
auto reload = AppConfig::load(temp);
|
||||||
|
ASSERT_TRUE(reload.has_value()) << reload.error().message;
|
||||||
|
|
||||||
|
std::filesystem::remove(temp);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Error type tests
|
// Error type tests
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#include "converter/rawloader/RawLoader.h"
|
#include "converter/rawloader/RawLoader.h"
|
||||||
|
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
|
|
||||||
using namespace photoconv;
|
using namespace photoconv;
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ TEST(RawLoaderTest, RejectsUnsupportedFormat) {
|
|||||||
// Create a temporary file with unsupported extension
|
// Create a temporary file with unsupported extension
|
||||||
auto temp = std::filesystem::temp_directory_path() / "test.xyz";
|
auto temp = std::filesystem::temp_directory_path() / "test.xyz";
|
||||||
{
|
{
|
||||||
std::ofstream f(temp);
|
std::ofstream f{temp};
|
||||||
f << "dummy";
|
f << "dummy";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user