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

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)