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:
Christoph K.
2026-03-14 09:42:01 +01:00
parent db39ef8c58
commit 73ccaa3e95
5 changed files with 499 additions and 61 deletions

View File

@@ -1,4 +1,5 @@
#include "CliRunner.h"
#include "../converter/rawloader/RawLoader.h"
#include "../converter/preprocess/Preprocessor.h"
#include "../converter/negative/NegativeDetector.h"
@@ -7,111 +8,189 @@
#include "../converter/crop/CropProcessor.h"
#include "../converter/output/OutputWriter.h"
#include <algorithm>
#include <format>
#include <iostream>
namespace photoconv {
// ─────────────────────────────────────────────────────────────────────────────
// parse_args
// ─────────────────────────────────────────────────────────────────────────────
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]};
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") {
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"));
ErrorCode::InvalidArgument, "--output requires a directory path"));
}
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"));
ErrorCode::InvalidArgument, "--format requires a value (png16|png8|tiff16|jpeg)"));
}
continue;
}
if (arg == "--quality") {
reading_inputs = false;
if (i + 1 < argc) {
config.jpeg_quality = std::stoi(argv[++i]);
} else {
return std::unexpected(make_error(
ErrorCode::InvalidArgument, "--quality requires a numeric value"));
}
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"
"Usage: photo-converter --cli [options] -i <files...>\n"
" photo-converter --batch --config config.ini\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"
" --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"
" -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] != '-') {
// If reading_inputs is active or the argument does not start with '-',
// treat it as an input file path.
if (reading_inputs || (!arg.empty() && arg.front() != '-')) {
config.input_files.emplace_back(arg);
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(
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;
}
// ─────────────────────────────────────────────────────────────────────────────
// run
// ─────────────────────────────────────────────────────────────────────────────
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;
// ── Resolve AppConfig ────────────────────────────────────────────────────
AppConfig app_cfg{};
// 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}));
if (!config.config_file.empty()) {
auto cfg_result = AppConfig::load(config.config_file);
if (!cfg_result.has_value()) {
return std::unexpected(cfg_result.error());
}
app_cfg = std::move(cfg_result.value());
std::cout << std::format("[CLI] Loaded config: {}", config.config_file.string())
<< std::endl;
}
// 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;
Pipeline pipeline = build_pipeline(app_cfg);
// ── Process files ────────────────────────────────────────────────────────
int success_count = 0;
const auto total = static_cast<int>(files.size());
for (const auto& file : config.input_files) {
std::cout << std::format("\n[CLI] Processing: {}", file.string()) << std::endl;
for (int idx = 0; idx < total; ++idx) {
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);
if (!load_result.has_value()) {
std::cerr << std::format("[CLI] Load failed: {}",
@@ -119,6 +198,7 @@ std::expected<int, Error> CliRunner::run(const Config& config) const {
continue;
}
// Run pipeline
auto result = pipeline.execute(std::move(load_result.value()));
if (!result.has_value()) {
std::cerr << std::format("[CLI] Pipeline failed: {}",
@@ -129,21 +209,84 @@ std::expected<int, Error> CliRunner::run(const Config& config) const {
++success_count;
}
std::cout << std::format("\n[CLI] Done: {}/{} files processed successfully",
success_count, config.input_files.size()) << std::endl;
std::cout << std::format("\n[CLI] Done: {}/{} file(s) converted successfully",
success_count, total) << 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);
// ─────────────────────────────────────────────────────────────────────────────
// collect_files
// ─────────────────────────────────────────────────────────────────────────────
return std::unexpected(make_error(
ErrorCode::InvalidArgument,
std::format("Unknown output format: '{}'. Use: png16, png8, tiff16, jpeg", fmt_str)));
std::vector<std::filesystem::path> CliRunner::collect_files(
const std::filesystem::path& dir,
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

View File

@@ -2,6 +2,7 @@
#include "../converter/pipeline/Pipeline.h"
#include "../converter/pipeline/Error.h"
#include "../config/AppConfig.h"
#include <expected>
#include <filesystem>
@@ -13,23 +14,55 @@ 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.
* Accepts command-line arguments, optionally loads an AppConfig from a
* 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]
* @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 {
public:
/**
* @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 {
/// Input files resolved from -i flags or from AppConfig::batch.
std::vector<std::filesystem::path> input_files;
/// Output directory (default: "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};
/// Print extra diagnostic information.
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;
@@ -38,25 +71,59 @@ public:
/**
* @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 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[]);
/**
* @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.
* @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;
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