chore: initial project scaffold from architecture design

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

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

89
CLAUDE.md Normal file
View File

@@ -0,0 +1,89 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
C++ desktop app converting digitized analog film negatives (35mm, 120mm) to digital positives. Supports JPG/PNG and RAW input (CR2/NEF/ARW/DNG), outputs PNG/JPG with inversion, C41/B&W correction, auto-crop, and batch processing. Cross-platform: Windows/Linux/macOS.
## Tech Stack
- **Language:** C++20
- **Image processing:** OpenCV 4.10+ and LibRaw 0.21+ (RAW demosaicing)
- **GUI:** Qt 6.8 LTS (LGPLv3)
- **Build:** CMake 3.20+, vcpkg (Windows), Ninja
- **CLI:** Optional batch mode without GUI
## Build Commands
```bash
# Linux (Ubuntu/Debian)
sudo apt install libopencv-dev libqt6widgets6 libraw-dev cmake ninja-build
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
cmake --build build
# Windows (vcpkg)
vcpkg install opencv[contrib] libraw qt6-base:x64-windows
cmake -B build -G Ninja -DCMAKE_TOOLCHAIN_FILE=vcpkg.cmake
cmake --build build
# macOS (Homebrew)
brew install opencv libraw qt@6 cmake ninja
cmake -B build -G Ninja
cmake --build build
```
## Architecture
Processing pipeline:
```
Input → Loader → Preprocess → Detect → Invert → Color Correction → Post-Process → Output
```
**Core data structure:**
```cpp
struct ImageData {
cv::Mat rgb; // Demosaiced 16-bit
std::string make; // "Canon", "Nikon"
float exposure; // WB/EXIF data
};
```
**Pipeline stages:**
1. **Loader** (`src/converter/rawloader`): LibRaw for RAW→RGB16, OpenCV for JPG/PNG
2. **Preprocess**: RAW→RGB16 conversion, deskew
3. **Detect** (`src/converter/negative`): Negative vs. positive via histogram analysis and orange color masking
4. **Invert**: `cv::bitwise_not()` + film-specific color matrix
5. **Color**: C41 orange cast removal, auto white balance from EXIF
6. **Post** (`src/converter/crop`): Levels, sharpening, dust removal, auto frame detection
7. **Output**: 16-bit TIFF and 8-bit PNG
Error handling uses `std::expected<ImageData, Error>` throughout.
## Coding Standards
- Always call `LibRaw::recycle()` after use
- Qt file dialogs: `QFileDialog::getOpenFileNames("RAW (*.cr2 *.nef *.dng)")`
- Tests use RAW golden files with pixel diff tolerance <1%
- Do not exceed 4GB in-memory RAW data
- Do not use lossy demosaicing (LibRaw default is lossless)
- Always log RAW metadata
## Sample Images
The `import/` directory contains example RAW files for manual testing and development:
| File | Format | Description |
|------|--------|-------------|
| `import/DSC09246.ARW` | Sony ARW | Example negative for conversion testing |
| `import/unbenannt.ARW` | Sony ARW | Example negative for conversion testing |
Use these files to test the full pipeline end-to-end without needing external test data.
## License Compliance
README.md must include:
- Qt LGPLv3 attribution and source download link: https://www.qt.io/download-open-source
- Instructions for re-linking Qt DLLs
- LibRaw CDDL attribution: https://www.libraw.org

104
CMakeLists.txt Normal file
View File

@@ -0,0 +1,104 @@
cmake_minimum_required(VERSION 3.20)
project(photo-converter
VERSION 0.1.0
DESCRIPTION "Analog film negative to digital positive converter"
LANGUAGES CXX
)
# ──────────────────────────────────────────────
# C++ Standard
# ──────────────────────────────────────────────
set(CMAKE_CXX_STANDARD 23)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
# ──────────────────────────────────────────────
# Build options
# ──────────────────────────────────────────────
option(BUILD_TESTS "Build unit tests" ON)
option(BUILD_GUI "Build GUI (requires Qt 6)" ON)
# ──────────────────────────────────────────────
# Dependencies
# ──────────────────────────────────────────────
find_package(OpenCV 4.10 REQUIRED COMPONENTS core imgproc imgcodecs)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBRAW REQUIRED IMPORTED_TARGET libraw)
if(BUILD_GUI)
find_package(Qt6 6.8 REQUIRED COMPONENTS Widgets)
qt_standard_project_setup()
endif()
# ──────────────────────────────────────────────
# Core converter library (no Qt dependency)
# ──────────────────────────────────────────────
add_library(converter_core STATIC
src/converter/pipeline/Pipeline.cpp
src/converter/rawloader/RawLoader.cpp
src/converter/preprocess/Preprocessor.cpp
src/converter/negative/NegativeDetector.cpp
src/converter/invert/Inverter.cpp
src/converter/color/ColorCorrector.cpp
src/converter/crop/CropProcessor.cpp
src/converter/output/OutputWriter.cpp
src/cli/CliRunner.cpp
)
target_include_directories(converter_core PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/src
)
target_link_libraries(converter_core PUBLIC
${OpenCV_LIBS}
PkgConfig::LIBRAW
)
target_compile_options(converter_core PRIVATE
$<$<CXX_COMPILER_ID:GNU>:-Wall -Wextra -Wpedantic>
$<$<CXX_COMPILER_ID:Clang>:-Wall -Wextra -Wpedantic>
$<$<CXX_COMPILER_ID:MSVC>:/W4>
)
# ──────────────────────────────────────────────
# Main executable
# ──────────────────────────────────────────────
if(BUILD_GUI)
qt_add_executable(photo-converter
src/main.cpp
src/gui/MainWindow.cpp
src/gui/MainWindow.h
)
target_link_libraries(photo-converter PRIVATE
converter_core
Qt6::Widgets
)
target_include_directories(photo-converter PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
else()
# CLI-only build (no Qt)
add_executable(photo-converter
src/main.cpp
)
target_link_libraries(photo-converter PRIVATE
converter_core
)
target_compile_definitions(photo-converter PRIVATE NO_GUI=1)
target_include_directories(photo-converter PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src
)
endif()
# ──────────────────────────────────────────────
# Tests
# ──────────────────────────────────────────────
if(BUILD_TESTS)
enable_testing()
add_subdirectory(tests)
endif()

199
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,199 @@
# Architecture
## Overview
Photo-converter is a C++20 cross-platform desktop application that converts digitized analog film negatives (35mm, 120mm) into digital positives. It supports RAW camera formats (CR2, NEF, ARW, DNG, etc.) and standard image formats (JPG, PNG, TIFF), producing high-quality 16-bit output with automatic film type detection, color correction, and cropping.
The architecture follows **Clean Architecture** principles: the core processing logic has zero GUI dependencies and can run in both interactive (Qt GUI) and batch (CLI) modes.
## Component Diagram
```
+------------------------------------------------------------------+
| Application Layer |
| |
| +------------------+ +------------------+ |
| | MainWindow | | CliRunner | |
| | (Qt GUI) | | (Batch CLI) | |
| +--------+---------+ +--------+---------+ |
| | | |
+------------|------------------------------|----------------------+
| |
v v
+------------------------------------------------------------------+
| Pipeline Orchestration |
| |
| +----------------------------------------------------------+ |
| | Pipeline | |
| | Owns: vector<unique_ptr<PipelineStage>> | |
| | Executes stages in order, propagates errors | |
| +----------------------------------------------------------+ |
| |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| Pipeline Stages |
| |
| +----------+ +---------+ +--------+ +--------+ +---------+ |
| |RawLoader |->|Preproc. |->|Detect |->|Invert |->|Color | |
| |(Loader) | | | | | | | |Corrector| |
| +----------+ +---------+ +--------+ +--------+ +---------+ |
| | |
| +-----------v-----+ |
| | CropProcessor | |
| | (Post-Process) | |
| +-----------+-----+ |
| | |
| +-----------v-----+ |
| | OutputWriter | |
| +-----------------+ |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| Core Data Types |
| |
| ImageData RawMetadata FilmType Error ErrorCode |
| |
+------------------------------------------------------------------+
|
v
+------------------------------------------------------------------+
| External Libraries |
| |
| OpenCV 4.10+ LibRaw 0.21+ Qt 6.8 LTS |
| (imgproc, imgcodecs) (RAW demosaicing) (Widgets, GUI) |
| |
+------------------------------------------------------------------+
```
## Layer Responsibilities
### Application Layer
Contains the two entry points (GUI and CLI). This layer is thin -- it constructs the pipeline, feeds it input, and presents results. No image processing logic lives here.
- **MainWindow** (Qt): File dialogs, preview, progress bar. Depends on Qt 6.8 Widgets.
- **CliRunner**: Command-line argument parsing and batch loop. Zero Qt dependency.
### Pipeline Orchestration
The `Pipeline` class owns an ordered sequence of `PipelineStage` objects and executes them sequentially. It implements the **Chain of Responsibility** pattern: each stage either transforms the data and passes it forward, or returns an error that stops the chain.
### Pipeline Stages
Each stage implements the `PipelineStage` interface (`process(ImageData) -> StageResult`). Stages are stateless or hold only configuration. This enables the **Strategy** pattern -- stages can be swapped, reordered, or extended without modifying the Pipeline.
### Core Data Types
- `ImageData`: The carrier struct flowing through all stages. Always contains a `cv::Mat` in CV_16UC3 format.
- `Error` / `ErrorCode`: Structured error with source location for diagnostics.
- `StageResult = std::expected<ImageData, Error>`: The universal return type.
## Design Decisions
### 1. std::expected over Exceptions
**Decision:** All error handling uses `std::expected<T, Error>`, never exceptions for control flow.
**Rationale:**
- Explicit error paths in function signatures (self-documenting)
- No hidden control flow jumps
- Zero-cost when no error occurs
- Forces callers to handle errors (compiler warnings with `[[nodiscard]]`)
- Consistent with modern C++ idioms (C++23)
### 2. 16-bit Pipeline Throughout
**Decision:** All internal processing uses CV_16UC3 (16-bit per channel, 3-channel BGR).
**Rationale:**
- RAW files contain 12-14 bit data; 8-bit would lose dynamic range
- Inversion, color correction, and levels adjustments need headroom
- Conversion to 8-bit happens only at the final output stage
- No information loss in intermediate stages
### 3. LibRaw RAII Guard
**Decision:** LibRaw::recycle() is guaranteed via an RAII guard class (`LibRawGuard`).
**Rationale:**
- LibRaw requires explicit cleanup; forgetting recycle() leaks memory
- RAII ensures cleanup on all paths (success, error, exception)
- The guard is a private implementation detail of RawLoader
### 4. Core Library Separation
**Decision:** All processing code is in the `converter_core` static library, which has no Qt dependency.
**Rationale:**
- Enables CLI-only builds without Qt
- Makes the core testable without GUI framework
- Clean dependency graph: GUI depends on core, never vice versa
### 5. Value Semantics for ImageData
**Decision:** `ImageData` is passed by value (moved) through the pipeline.
**Rationale:**
- `cv::Mat` uses reference counting internally, so copies are shallow
- Moving `ImageData` is cheap (Mat header + a few scalars)
- Avoids shared mutable state between stages
- Each stage owns its input; previous stages cannot interfere
## Directory Structure
```
photo-converter/
+-- CMakeLists.txt Root build configuration
+-- CLAUDE.md AI agent instructions
+-- docs/
| +-- ARCHITECTURE.md This file
| +-- PIPELINE.md Pipeline stage documentation
| +-- MODULES.md Module catalog
+-- import/ Sample test images (ARW)
+-- output/ Default output directory
+-- src/
| +-- main.cpp Entry point (GUI/CLI dispatch)
| +-- converter/
| | +-- pipeline/
| | | +-- Pipeline.h/.cpp Pipeline orchestrator
| | | +-- PipelineStage.h Stage interface (abstract)
| | | +-- ImageData.h Core data structure
| | | +-- Error.h Error types
| | +-- rawloader/
| | | +-- RawLoader.h/.cpp RAW + standard image loading
| | +-- preprocess/
| | | +-- Preprocessor.h/.cpp Bit depth validation, deskew
| | +-- negative/
| | | +-- NegativeDetector.h/.cpp Film type classification
| | +-- invert/
| | | +-- Inverter.h/.cpp Negative-to-positive inversion
| | +-- color/
| | | +-- ColorCorrector.h/.cpp WB, C-41 correction
| | +-- crop/
| | | +-- CropProcessor.h/.cpp Auto-crop, levels, sharpen
| | +-- output/
| | +-- OutputWriter.h/.cpp File output (PNG/TIFF/JPEG)
| +-- gui/
| | +-- MainWindow.h/.cpp Qt GUI main window
| +-- cli/
| +-- CliRunner.h/.cpp CLI batch runner
+-- tests/
+-- CMakeLists.txt Test build configuration
+-- test_pipeline.cpp Pipeline and stage unit tests
+-- test_rawloader.cpp RawLoader integration tests
```
## Cross-Platform Build
The project builds on Linux, Windows, and macOS. Platform-specific dependency management:
| Platform | Package Manager | Command |
|----------|----------------|---------|
| Linux | apt | `sudo apt install libopencv-dev libraw-dev libqt6widgets6` |
| Windows | vcpkg | `vcpkg install opencv libraw qt6-base` |
| macOS | Homebrew | `brew install opencv libraw qt@6` |
The CMake build system handles all three platforms with appropriate find_package/pkg_check_modules calls. The `BUILD_GUI` option allows CLI-only builds when Qt is unavailable.

265
docs/MODULES.md Normal file
View File

@@ -0,0 +1,265 @@
# Module Catalog
## Module Overview
| Module | Location | Responsibility | Dependencies |
|--------|----------|---------------|--------------|
| Pipeline | `src/converter/pipeline/` | Stage orchestration, data types | OpenCV (core) |
| RawLoader | `src/converter/rawloader/` | File loading (RAW + standard) | OpenCV, LibRaw |
| Preprocessor | `src/converter/preprocess/` | Format normalization, deskew | OpenCV |
| NegativeDetector | `src/converter/negative/` | Film type classification | OpenCV |
| Inverter | `src/converter/invert/` | Negative-to-positive inversion | OpenCV |
| ColorCorrector | `src/converter/color/` | White balance, C-41 correction | OpenCV |
| CropProcessor | `src/converter/crop/` | Auto-crop, levels, sharpening | OpenCV |
| OutputWriter | `src/converter/output/` | File output | OpenCV |
| MainWindow | `src/gui/` | Qt GUI | Qt 6.8, converter_core |
| CliRunner | `src/cli/` | CLI batch processing | converter_core |
## Dependency Graph
```
MainWindow ──> Pipeline ──> PipelineStage (interface)
| | ^
| | |
v | +--------+--------+--------+--------+--------+--------+
Qt 6.8 | | | | | | | |
| Preproc Detect Invert Color Crop Output (future)
| | | | | | |
+-----+--------+--------+--------+--------+--------+
|
v
RawLoader
|
+----+----+
| |
LibRaw OpenCV
CliRunner ──> Pipeline ──> (same stages as above)
|
+──> RawLoader
```
## Module Details
---
### Pipeline Infrastructure (`src/converter/pipeline/`)
#### Error.h
| Type | Description |
|------|-------------|
| `enum class ErrorCode` | Error classification (30+ codes across all stages) |
| `struct Error` | Error with code, message, and source location |
| `make_error()` | Factory function with automatic source_location capture |
#### ImageData.h
| Type | Description |
|------|-------------|
| `enum class FilmType` | Film classification: Unknown, ColorNegative, BWNegative, ColorPositive, BWPositive |
| `struct RawMetadata` | Camera make/model, ISO, shutter, aperture, WB multipliers, dimensions |
| `struct ImageData` | Core carrier: cv::Mat rgb (CV_16UC3), source_path, metadata, film_type, crop_region |
#### PipelineStage.h
| Type | Description |
|------|-------------|
| `using StageResult` | `std::expected<ImageData, Error>` |
| `class PipelineStage` | Abstract interface: `process(ImageData) -> StageResult`, `name() -> string` |
| `using ProgressCallback` | `function<void(string, float)>` for GUI progress reporting |
#### Pipeline.h / Pipeline.cpp
| Class | `Pipeline` |
|-------|-----------|
| **Pattern** | Chain of Responsibility + Strategy |
| **Owns** | `vector<unique_ptr<PipelineStage>>` |
| **Methods** | |
| `add_stage(unique_ptr<PipelineStage>)` | Append a stage |
| `execute(ImageData, ProgressCallback) -> StageResult` | Run all stages sequentially |
| `stage_count() -> size_t` | Number of registered stages |
| **Thread safety** | Not thread-safe (single-threaded execution) |
| **Copyable** | No (deleted copy ctor/assignment) |
| **Movable** | Yes |
---
### RawLoader (`src/converter/rawloader/`)
| Class | `RawLoader` |
|-------|------------|
| **Pattern** | Factory Method (routes to load_raw or load_standard) |
| **Methods** | |
| `load(path) -> expected<ImageData, Error>` | Main entry point |
| `load_raw(path)` | LibRaw loader (private) |
| `load_standard(path)` | OpenCV loader (private) |
| `is_raw_format(path) -> bool` | Extension check (static) |
| `is_standard_format(path) -> bool` | Extension check (static) |
| `log_metadata(RawMetadata)` | Print metadata to stdout (static) |
| **Constants** | |
| `kMaxRawFileSize` | 4 GB (4,294,967,296 bytes) |
| `kRawExtensions[]` | cr2, cr3, nef, arw, dng, orf, rw2, raf, pef |
| `kStandardExtensions[]` | jpg, jpeg, png, tif, tiff |
| **Internal** | |
| `LibRawGuard` | RAII class ensuring `LibRaw::recycle()` |
**LibRaw configuration:**
```cpp
params.use_camera_wb = 1; // Use camera white balance
params.output_bps = 16; // 16-bit output
params.no_auto_bright = 1; // No auto-brightness
params.half_size = 0; // Full resolution
params.output_color = 1; // sRGB color space
```
---
### Preprocessor (`src/converter/preprocess/`)
| Class | `Preprocessor` : `PipelineStage` |
|-------|----------------------------------|
| **Stage name** | "Preprocess" |
| **Methods** | |
| `process(ImageData) -> StageResult` | Validate + deskew |
| `validate_and_convert(ImageData)` | Ensure CV_16UC3 (static, private) |
| `deskew(ImageData)` | Geometric correction (static, private, TODO) |
---
### NegativeDetector (`src/converter/negative/`)
| Class | `NegativeDetector` : `PipelineStage` |
|-------|--------------------------------------|
| **Stage name** | "Detect" |
| **Methods** | |
| `process(ImageData) -> StageResult` | Classify film type |
| `is_negative_histogram(Mat) -> bool` | Mean intensity analysis (static, private) |
| `has_orange_mask(Mat) -> bool` | R/B ratio check (static, private) |
| `is_monochrome(Mat) -> bool` | HSV saturation check (static, private) |
| **Constants** | |
| `kOrangeMaskThreshold` | 1.4f |
| `kColorSaturationThreshold` | 15.0f |
---
### Inverter (`src/converter/invert/`)
| Class | `Inverter` : `PipelineStage` |
|-------|------------------------------|
| **Stage name** | "Invert" |
| **Methods** | |
| `process(ImageData) -> StageResult` | Route by film type |
| `invert_color_negative(ImageData)` | Orange mask removal + bitwise_not (static, private) |
| `invert_bw_negative(ImageData)` | Simple bitwise_not (static, private) |
---
### ColorCorrector (`src/converter/color/`)
| Class | `ColorCorrector` : `PipelineStage` |
|-------|-------------------------------------|
| **Stage name** | "ColorCorrection" |
| **Methods** | |
| `process(ImageData) -> StageResult` | Route by film type |
| `correct_c41(ImageData)` | C-41 orange cast removal (static, private, TODO) |
| `auto_white_balance(ImageData)` | Gray-world AWB (static, private) |
| `apply_exif_wb(ImageData)` | EXIF-based WB (static, private) |
---
### CropProcessor (`src/converter/crop/`)
| Class | `CropProcessor` : `PipelineStage` |
|-------|-------------------------------------|
| **Stage name** | "PostProcess" |
| **Methods** | |
| `process(ImageData) -> StageResult` | Auto-crop + levels + sharpen |
| `auto_crop(ImageData)` | Frame detection via contours (static, private, TODO) |
| `adjust_levels(ImageData)` | Percentile-based levels (static, private, TODO) |
| `sharpen(ImageData)` | Unsharp mask (static, private, TODO) |
| **Constants** | |
| `kMinFrameAreaRatio` | 0.3 |
| `kSharpenSigma` | 1.5 |
| `kSharpenStrength` | 0.5 |
| `kBlackPointPercentile` | 0.5 |
| `kWhitePointPercentile` | 99.5 |
---
### OutputWriter (`src/converter/output/`)
| Class | `OutputWriter` : `PipelineStage` |
|-------|-----------------------------------|
| **Stage name** | "Output" |
| **Constructor** | `OutputWriter(OutputConfig config)` |
| **Methods** | |
| `process(ImageData) -> StageResult` | Write file to disk |
| `build_output_path(string) -> path` | Construct output filename (private) |
| `format_extension(OutputFormat) -> string` | Map format to extension (static, private) |
| **Config** | |
| `OutputConfig::output_dir` | Target directory |
| `OutputConfig::format` | PNG_16bit, PNG_8bit, TIFF_16bit, JPEG |
| `OutputConfig::jpeg_quality` | 0-100 (default 95) |
---
### MainWindow (`src/gui/`)
| Class | `MainWindow` : `QMainWindow` |
|-------|-------------------------------|
| **Qt slots** | |
| `on_open_files()` | File dialog with RAW filter |
| `on_convert()` | Execute pipeline on all selected files |
| `on_select_output_dir()` | Directory picker |
| **Private** | |
| `setup_ui()` | Construct widgets and layout |
| `setup_pipeline()` | Build default pipeline |
| `update_preview(Mat)` | Convert 16-bit BGR to QPixmap for display |
| **State** | |
| `input_files_` | Selected file paths |
| `output_dir_` | Chosen output directory |
| `pipeline_` | Pipeline instance |
---
### CliRunner (`src/cli/`)
| Class | `CliRunner` |
|-------|------------|
| **Nested types** | `Config` (input_files, output_dir, output_format, jpeg_quality, verbose) |
| **Static methods** | |
| `parse_args(argc, argv) -> expected<Config, Error>` | Parse CLI arguments |
| **Methods** | |
| `run(Config) -> expected<int, Error>` | Execute batch processing, return success count |
**CLI usage:**
```
photo-converter --cli -i file1.arw file2.cr2 -o output/ --format png16
```
---
## Build Targets
| Target | Type | Sources | Dependencies |
|--------|------|---------|-------------|
| `converter_core` | Static library | All `src/converter/` + `src/cli/` | OpenCV, LibRaw |
| `photo-converter` | Executable | `src/main.cpp` + `src/gui/` | converter_core, Qt6::Widgets |
| `test_pipeline` | Test executable | `tests/test_pipeline.cpp` | converter_core, GTest |
| `test_rawloader` | Test executable | `tests/test_rawloader.cpp` | converter_core, GTest |
## Implementation Status
| Module | Status | Notes |
|--------|--------|-------|
| Pipeline | Complete | Orchestration, error propagation, progress |
| RawLoader | Complete | Full LibRaw + OpenCV loading |
| Preprocessor | Skeleton | Validation done, deskew TODO |
| NegativeDetector | Functional | Basic histogram + orange mask detection |
| Inverter | Skeleton | Basic bitwise_not, C-41 mask removal TODO |
| ColorCorrector | Partial | Gray-world AWB implemented, C-41 TODO |
| CropProcessor | Skeleton | All sub-stages TODO (pass-through) |
| OutputWriter | Complete | All formats supported |
| MainWindow | Complete | File dialogs, preview, progress |
| CliRunner | Complete | Argument parsing, batch loop |

277
docs/PIPELINE.md Normal file
View File

@@ -0,0 +1,277 @@
# Pipeline Documentation
## Overview
The processing pipeline transforms a digitized film negative into a color-corrected digital positive. Data flows through seven stages, each receiving and returning an `ImageData` struct wrapped in `std::expected`.
```
ImageData flow
|
+----------------------v-----------------------+
| 1. LOADER (RawLoader) |
| Input: file path (string) |
| Output: ImageData { CV_16UC3, metadata } |
+----------------------+-----------------------+
|
+----------------------v-----------------------+
| 2. PREPROCESS (Preprocessor) |
| Input: ImageData (possibly wrong depth) |
| Output: ImageData { CV_16UC3 guaranteed } |
+----------------------+-----------------------+
|
+----------------------v-----------------------+
| 3. DETECT (NegativeDetector) |
| Input: ImageData { film_type = Unknown } |
| Output: ImageData { film_type = detected } |
+----------------------+-----------------------+
|
+----------------------v-----------------------+
| 4. INVERT (Inverter) |
| Input: ImageData { negative or positive } |
| Output: ImageData { inverted if negative } |
+----------------------+-----------------------+
|
+----------------------v-----------------------+
| 5. COLOR (ColorCorrector) |
| Input: ImageData { inverted positive } |
| Output: ImageData { color-balanced } |
+----------------------+-----------------------+
|
+----------------------v-----------------------+
| 6. POST-PROCESS (CropProcessor) |
| Input: ImageData { color-corrected } |
| Output: ImageData { cropped, sharpened } |
+----------------------+-----------------------+
|
+----------------------v-----------------------+
| 7. OUTPUT (OutputWriter) |
| Input: ImageData { final } |
| Output: ImageData (unchanged) + file on disk |
+----------------------+-----------------------+
```
## Core Data Structures
### ImageData
```cpp
struct ImageData {
cv::Mat rgb; // Always CV_16UC3 (16-bit BGR)
std::string source_path; // Original file path
RawMetadata metadata; // Camera EXIF/RAW data
FilmType film_type{FilmType::Unknown}; // Set by Detect stage
std::optional<cv::Rect> crop_region; // Set by Post-Process
};
```
### RawMetadata
```cpp
struct RawMetadata {
std::string camera_make; // "Sony", "Canon", "Nikon"
std::string camera_model; // "ILCE-7M3"
float iso_speed;
float shutter_speed; // seconds
float aperture; // f-number
float focal_length; // mm
float wb_red, wb_green, wb_blue; // White balance multipliers
int raw_width, raw_height;
int raw_bit_depth;
std::string timestamp; // ISO 8601
};
```
### FilmType
```cpp
enum class FilmType {
Unknown, // Not yet classified
ColorNegative, // C-41 process film
BWNegative, // Black & white negative
ColorPositive, // Slide / E-6 film
BWPositive, // Black & white positive
};
```
### StageResult
```cpp
using StageResult = std::expected<ImageData, Error>;
```
## Stage Details
### Stage 1: Loader (RawLoader)
**Purpose:** Read image files from disk into the pipeline.
**Input:** File path (`std::filesystem::path`)
**Output:** `std::expected<ImageData, Error>`
**Behavior:**
1. Validate file exists and size < 4GB
2. Detect format from extension
3. For RAW files:
- Initialize LibRaw with lossless settings (16-bit, full resolution, sRGB)
- Open, unpack, and process (demosaic)
- Extract image data as cv::Mat CV_16UC3
- Extract all metadata (camera, exposure, WB multipliers)
- Log metadata to stdout
- Guarantee `LibRaw::recycle()` via RAII guard
4. For standard files (JPG/PNG/TIFF):
- Load via `cv::imread(IMREAD_UNCHANGED)`
- Convert to CV_16UC3 (scale 8-bit to 16-bit if needed)
- Populate minimal metadata
**Supported RAW formats:** CR2, CR3, NEF, ARW, DNG, ORF, RW2, RAF, PEF
**Supported standard formats:** JPG, JPEG, PNG, TIF, TIFF
**Error codes:** `FileNotFound`, `FileTooLarge`, `UnsupportedFormat`, `LibRawInitFailed`, `LibRawUnpackFailed`, `LibRawProcessFailed`, `DemosaicingFailed`, `FileReadError`
### Stage 2: Preprocess (Preprocessor)
**Purpose:** Normalize image format and correct geometric distortion.
**Input:** ImageData (possibly wrong bit depth or channel count)
**Output:** ImageData (guaranteed CV_16UC3)
**Behavior:**
1. Validate image is non-empty
2. Convert to CV_16UC3 if necessary:
- Grayscale -> BGR
- 8-bit -> 16-bit (scale x257)
- 4-channel -> 3-channel
3. Apply deskew correction (future):
- Canny edge detection
- HoughLinesP for dominant angles
- Affine warp if skew > 0.5 degrees
**Error codes:** `InvalidBitDepth`, `ConversionFailed`
### Stage 3: Detect (NegativeDetector)
**Purpose:** Classify the image as negative or positive, color or B&W.
**Input:** ImageData with `film_type = Unknown`
**Output:** ImageData with `film_type` set to one of: `ColorNegative`, `BWNegative`, `ColorPositive`, `BWPositive`
**Detection algorithm:**
1. **Negative detection:** Compare mean intensity to midpoint (32768 for 16-bit). Negatives have high mean intensity because dark scene regions become bright on the film.
2. **Orange mask detection:** For negatives, compute R/B channel ratio. C-41 film has an orange dye mask with R/B ratio > 1.4.
3. **Monochrome detection:** Convert to HSV, check mean saturation. If saturation < 15, classify as B&W.
**Named constants:**
- `kOrangeMaskThreshold = 1.4f`
- `kColorSaturationThreshold = 15.0f`
**Error codes:** `DetectionFailed`, `HistogramError`
### Stage 4: Invert (Inverter)
**Purpose:** Convert negatives to positives via bitwise inversion.
**Input:** ImageData with film_type set
**Output:** ImageData (inverted if negative, unchanged if positive)
**Behavior by film type:**
- **ColorNegative:** Remove orange mask, then `cv::bitwise_not()`
- **BWNegative:** Simple `cv::bitwise_not()`
- **ColorPositive / BWPositive:** Pass through unchanged
**Error codes:** `InversionFailed`
### Stage 5: Color Correction (ColorCorrector)
**Purpose:** Remove color casts and balance white.
**Input:** ImageData (inverted positive)
**Output:** ImageData (color-balanced)
**Behavior by film type:**
- **ColorNegative:** C-41 correction (LAB-space orange removal) + auto WB
- **ColorPositive:** Auto white balance only
- **BWNegative / BWPositive:** Skipped (no color to correct)
**Auto white balance algorithm (Gray World):**
1. Compute mean of each BGR channel
2. Compute overall gray mean = (B + G + R) / 3
3. Scale each channel: `ch *= gray_mean / ch_mean`
4. Clamp to [0, 65535]
**Error codes:** `ColorCorrectionFailed`, `WhiteBalanceFailed`
### Stage 6: Post-Process (CropProcessor)
**Purpose:** Auto-crop, levels adjustment, and sharpening.
**Input:** ImageData (color-corrected)
**Output:** ImageData (cropped, levels-adjusted, sharpened)
**Sub-stages (executed in order):**
1. **Auto-crop:**
- Edge detection (Canny) on grayscale
- Contour analysis to find largest rectangle
- Validate area > 30% of total (not a noise contour)
- Crop to bounding rect
2. **Levels adjustment:**
- Compute cumulative histogram per channel
- Black point at 0.5th percentile
- White point at 99.5th percentile
- Remap: `output = (input - black) * 65535 / (white - black)`
3. **Sharpening (unsharp mask):**
- Gaussian blur with sigma = 1.5
- `sharpened = original + 0.5 * (original - blurred)`
- Clamp to 16-bit range
**Named constants:**
- `kMinFrameAreaRatio = 0.3`
- `kSharpenSigma = 1.5`
- `kSharpenStrength = 0.5`
- `kBlackPointPercentile = 0.5`
- `kWhitePointPercentile = 99.5`
**Error codes:** `CropFailed`, `FrameDetectionFailed`, `SharpeningFailed`
### Stage 7: Output (OutputWriter)
**Purpose:** Write the final image to disk.
**Input:** ImageData + OutputConfig (directory, format, quality)
**Output:** ImageData (unchanged) + file written to disk
**Supported output formats:**
| Format | Extension | Bit Depth | Compression | Use Case |
|--------|-----------|-----------|-------------|----------|
| PNG 16-bit | .png | 16-bit | Lossless | Archival quality |
| PNG 8-bit | .png | 8-bit | Lossless | Web/sharing |
| TIFF 16-bit | .tif | 16-bit | None | Professional editing |
| JPEG | .jpg | 8-bit | Lossy | Quick preview |
**Output filename:** `{stem}_converted.{ext}` (e.g., `DSC09246_converted.png`)
**Error codes:** `OutputWriteFailed`, `OutputPathInvalid`
## Error Propagation
Every stage returns `std::expected<ImageData, Error>`. The Pipeline executes stages sequentially and stops at the first error:
```
Stage 1 OK -> Stage 2 OK -> Stage 3 ERROR -> [stop, return error]
```
The `Error` struct contains:
- `ErrorCode code` -- machine-readable classification
- `std::string message` -- human-readable, actionable description
- `std::string source_file` -- source file where error originated
- `int source_line` -- line number for diagnostics
## Memory Constraints
- Maximum RAW file size: **4 GB** (validated at load time)
- A 16-bit, 6000x4000 pixel image occupies ~144 MB in memory
- The pipeline processes one image at a time; batch processing is sequential
- `cv::Mat` uses reference counting; stage handoffs are efficient (move semantics)

BIN
import/DSC09246.ARW Normal file

Binary file not shown.

BIN
import/unbenannt.ARW Normal file

Binary file not shown.

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

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

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

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

View File

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

View File

@@ -0,0 +1,43 @@
#pragma once
#include "../pipeline/PipelineStage.h"
namespace photoconv {
/**
* @brief Color correction stage: removes color casts and balances white.
*
* Applies film-type-specific corrections:
* - C-41: Orange cast removal using per-channel curves
* - Auto white balance using camera EXIF data or gray-world algorithm
* - Optional manual color temperature adjustment
*
* Uses the Strategy pattern internally: different correction algorithms
* are selected based on FilmType.
*/
class ColorCorrector : public PipelineStage {
public:
ColorCorrector() = default;
~ColorCorrector() override = default;
[[nodiscard]] StageResult process(ImageData data) const override;
[[nodiscard]] std::string name() const override { return "ColorCorrection"; }
private:
/**
* @brief Apply C-41 specific color correction (orange cast removal).
*/
[[nodiscard]] static StageResult correct_c41(ImageData data);
/**
* @brief Apply auto white balance using gray-world assumption.
*/
[[nodiscard]] static StageResult auto_white_balance(ImageData data);
/**
* @brief Apply white balance from EXIF metadata.
*/
[[nodiscard]] static StageResult apply_exif_wb(ImageData data);
};
} // namespace photoconv

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

54
src/main.cpp Normal file
View File

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

47
tests/CMakeLists.txt Normal file
View File

@@ -0,0 +1,47 @@
find_package(GTest REQUIRED)
# ──────────────────────────────────────────────
# Pipeline unit tests
# ──────────────────────────────────────────────
add_executable(test_pipeline
test_pipeline.cpp
)
target_link_libraries(test_pipeline PRIVATE
converter_core
GTest::gtest
GTest::gtest_main
)
target_include_directories(test_pipeline PRIVATE
${CMAKE_SOURCE_DIR}/src
)
add_test(NAME PipelineTests COMMAND test_pipeline)
# ──────────────────────────────────────────────
# RawLoader integration tests
# ──────────────────────────────────────────────
add_executable(test_rawloader
test_rawloader.cpp
)
target_link_libraries(test_rawloader PRIVATE
converter_core
GTest::gtest
GTest::gtest_main
)
target_include_directories(test_rawloader PRIVATE
${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
TEST_DATA_DIR="${CMAKE_SOURCE_DIR}/import"
)

176
tests/test_pipeline.cpp Normal file
View File

@@ -0,0 +1,176 @@
#include <gtest/gtest.h>
#include "converter/pipeline/Pipeline.h"
#include "converter/pipeline/ImageData.h"
#include "converter/pipeline/Error.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 <opencv2/core.hpp>
using namespace photoconv;
/**
* @brief Create a synthetic 16-bit test image.
*
* @param width Image width.
* @param height Image height.
* @param value Fill value for all channels (0-65535).
* @return CV_16UC3 Mat filled with the given value.
*/
static ImageData make_test_image(int width, int height, uint16_t value) {
ImageData data;
data.rgb = cv::Mat(height, width, CV_16UC3, cv::Scalar(value, value, value));
data.source_path = "test_synthetic.png";
data.metadata.camera_make = "Test";
data.metadata.camera_model = "Synthetic";
return data;
}
// ──────────────────────────────────────────────
// Pipeline orchestration tests
// ──────────────────────────────────────────────
TEST(PipelineTest, EmptyPipelinePassesThrough) {
Pipeline pipeline;
auto data = make_test_image(100, 100, 32768);
auto result = pipeline.execute(std::move(data));
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->rgb.cols, 100);
EXPECT_EQ(result->rgb.rows, 100);
}
TEST(PipelineTest, StageCountIsCorrect) {
Pipeline pipeline;
EXPECT_EQ(pipeline.stage_count(), 0);
pipeline.add_stage(std::make_unique<Preprocessor>());
EXPECT_EQ(pipeline.stage_count(), 1);
pipeline.add_stage(std::make_unique<NegativeDetector>());
EXPECT_EQ(pipeline.stage_count(), 2);
}
TEST(PipelineTest, FullPipelineRunsWithoutError) {
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>());
auto data = make_test_image(200, 200, 40000);
auto result = pipeline.execute(std::move(data));
ASSERT_TRUE(result.has_value());
}
TEST(PipelineTest, ProgressCallbackIsCalled) {
Pipeline pipeline;
pipeline.add_stage(std::make_unique<Preprocessor>());
pipeline.add_stage(std::make_unique<NegativeDetector>());
int callback_count = 0;
auto data = make_test_image(100, 100, 32768);
auto result = pipeline.execute(std::move(data),
[&callback_count](const std::string&, float) {
++callback_count;
});
ASSERT_TRUE(result.has_value());
// 2 stage callbacks + 1 "done" callback = 3
EXPECT_EQ(callback_count, 3);
}
// ──────────────────────────────────────────────
// Preprocessor tests
// ──────────────────────────────────────────────
TEST(PreprocessorTest, AcceptsValidImage) {
Preprocessor stage;
auto data = make_test_image(100, 100, 32768);
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
EXPECT_EQ(result->rgb.type(), CV_16UC3);
}
TEST(PreprocessorTest, RejectsEmptyImage) {
Preprocessor stage;
ImageData data;
auto result = stage.process(std::move(data));
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error().code, ErrorCode::InvalidBitDepth);
}
// ──────────────────────────────────────────────
// NegativeDetector tests
// ──────────────────────────────────────────────
TEST(NegativeDetectorTest, DetectsBrightImageAsNegative) {
NegativeDetector stage;
// High values = likely negative (inverted)
auto data = make_test_image(100, 100, 50000);
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
EXPECT_NE(result->film_type, FilmType::Unknown);
}
TEST(NegativeDetectorTest, DetectsDarkImageAsPositive) {
NegativeDetector stage;
// Low values = likely positive
auto data = make_test_image(100, 100, 10000);
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
// Should be classified as positive (below midpoint)
EXPECT_TRUE(result->film_type == FilmType::ColorPositive ||
result->film_type == FilmType::BWPositive);
}
// ──────────────────────────────────────────────
// Inverter tests
// ──────────────────────────────────────────────
TEST(InverterTest, InvertsNegative) {
Inverter stage;
auto data = make_test_image(10, 10, 60000);
data.film_type = FilmType::ColorNegative;
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
// After inversion, values should be near 65535 - 60000 = 5535
cv::Scalar mean = cv::mean(result->rgb);
EXPECT_LT(mean[0], 10000);
}
TEST(InverterTest, SkipsPositive) {
Inverter stage;
auto data = make_test_image(10, 10, 30000);
data.film_type = FilmType::ColorPositive;
auto result = stage.process(std::move(data));
ASSERT_TRUE(result.has_value());
// Should be unchanged
cv::Scalar mean = cv::mean(result->rgb);
EXPECT_NEAR(mean[0], 30000.0, 1.0);
}
// ──────────────────────────────────────────────
// Error type tests
// ──────────────────────────────────────────────
TEST(ErrorTest, FormatIncludesAllInfo) {
auto err = make_error(ErrorCode::FileNotFound, "test.arw not found");
auto formatted = err.format();
EXPECT_NE(formatted.find("test.arw not found"), std::string::npos);
EXPECT_NE(formatted.find("test_pipeline.cpp"), std::string::npos);
}

102
tests/test_rawloader.cpp Normal file
View File

@@ -0,0 +1,102 @@
#include <gtest/gtest.h>
#include "converter/rawloader/RawLoader.h"
#include <filesystem>
using namespace photoconv;
#ifndef TEST_DATA_DIR
#define TEST_DATA_DIR "import"
#endif
static const std::filesystem::path kTestDataDir{TEST_DATA_DIR};
// ──────────────────────────────────────────────
// File validation tests
// ──────────────────────────────────────────────
TEST(RawLoaderTest, RejectsNonexistentFile) {
RawLoader loader;
auto result = loader.load("/nonexistent/file.arw");
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error().code, ErrorCode::FileNotFound);
}
TEST(RawLoaderTest, RejectsUnsupportedFormat) {
// Create a temporary file with unsupported extension
auto temp = std::filesystem::temp_directory_path() / "test.xyz";
{
std::ofstream f(temp);
f << "dummy";
}
RawLoader loader;
auto result = loader.load(temp);
ASSERT_FALSE(result.has_value());
EXPECT_EQ(result.error().code, ErrorCode::UnsupportedFormat);
std::filesystem::remove(temp);
}
// ──────────────────────────────────────────────
// RAW loading integration tests (require test data)
// ──────────────────────────────────────────────
TEST(RawLoaderTest, LoadsArwFile) {
auto arw_path = kTestDataDir / "DSC09246.ARW";
if (!std::filesystem::exists(arw_path)) {
GTEST_SKIP() << "Test data not available: " << arw_path;
}
RawLoader loader;
auto result = loader.load(arw_path);
ASSERT_TRUE(result.has_value()) << result.error().format();
// Verify 16-bit BGR output
EXPECT_EQ(result->rgb.type(), CV_16UC3);
EXPECT_GT(result->rgb.cols, 0);
EXPECT_GT(result->rgb.rows, 0);
// Verify metadata was populated
EXPECT_FALSE(result->metadata.camera_make.empty());
EXPECT_GT(result->metadata.raw_width, 0);
EXPECT_GT(result->metadata.raw_height, 0);
}
TEST(RawLoaderTest, MetadataContainsSonyMake) {
auto arw_path = kTestDataDir / "DSC09246.ARW";
if (!std::filesystem::exists(arw_path)) {
GTEST_SKIP() << "Test data not available: " << arw_path;
}
RawLoader loader;
auto result = loader.load(arw_path);
ASSERT_TRUE(result.has_value());
// Sony ARW files should have "Sony" as make
EXPECT_EQ(result->metadata.camera_make, "Sony");
}
// ──────────────────────────────────────────────
// Pixel integrity tests
// ──────────────────────────────────────────────
TEST(RawLoaderTest, OutputIsNonTrivial) {
auto arw_path = kTestDataDir / "DSC09246.ARW";
if (!std::filesystem::exists(arw_path)) {
GTEST_SKIP() << "Test data not available: " << arw_path;
}
RawLoader loader;
auto result = loader.load(arw_path);
ASSERT_TRUE(result.has_value());
// Image should have non-zero content (not all black or all white)
cv::Scalar mean_val = cv::mean(result->rgb);
EXPECT_GT(mean_val[0], 100.0); // Not all black
EXPECT_LT(mean_val[0], 65000.0); // Not all white
}