commit 9d49f3e90b3388e54cf8ba234db8d2517ca0e901 Author: Tanishq Dubey Date: Sat Dec 13 15:43:11 2025 -0500 Initial Commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1344dfb --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.tiff filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4ce22de --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/* +build/ +build diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..e942922 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Chrome is a high-performance C++17 port of negfix8, a tool for processing scanned color negative film. It inverts and color-corrects 16-bit linear Rec.709 TIFF images from film scanners. + +## Build Commands + +```bash +# Configure and build +cmake -B build/ -S . +cd build && make + +# The executable is built at build/chrome +``` + +**Dependencies:** OpenCV (core, imgproc, imgcodecs), OpenMP (optional, for parallelization) + +## Testing + +Manual testing only. Compare output against reference files in `test_data/`: +- `test_data/test_input.tiff` - Sample input scan +- `test_data/test_expected.tiff` - Reference output from original negfix8 +- `test_data/current_output.tiff` - Current implementation output + +```bash +./build/chrome -i test_data/test_input.tiff -o test_data/current_output.tiff +# Then visually compare or diff against test_data/test_expected.tiff +``` + +## Usage + +```bash +./chrome -i input.tif [-o output.tif] [flags] + +Flags: + -g Set gamma (default 2.15) + -cs Enable contrast stretching + -sat Enable saturation boost (+20%) + -m Mirror image horizontally + -r Binning (scale down by 1/N) + -c Create profile and save to file (exits after) + -u Use existing profile +``` + +## Architecture + +Single-file implementation in `main.cpp` with this pipeline: + +1. **Settings/Profile structs** - CLI options and image analysis data +2. **Image analysis** (`analyzeImage`) - Finds min/max per channel after shave+blur preprocessing +3. **Profile save/load** - Stores derived gamma corrections and offset values +4. **LUT generation** (`generateLUTs`) - Creates 65536-entry lookup tables for each channel using OpenMP +5. **Pixel processing** - Applies LUTs in parallel via OpenMP +6. **Post-processing** - Optional saturation boost, contrast stretch, mirror +7. **Output** - Writes LZW-compressed TIFF + +The core algorithm inverts negative film by dividing film base values by pixel values, applies per-channel gamma correction to neutralize orange mask, then applies global gamma. diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..56ba08e --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.10) +project(chrome_cpp) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -O3 -march=native") + +# FIX: Explicitly request ONLY the modules we need. +# This prevents the linker from looking for missing VTK/HDF5 libraries +# referenced by unused OpenCV modules. +find_package(OpenCV REQUIRED COMPONENTS core imgproc imgcodecs) + +find_package(OpenMP) + +if(OpenMP_CXX_FOUND) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${OpenMP_CXX_FLAGS}") +endif() + +add_executable(chrome main.cpp) + +# Link against the specific components found above +target_link_libraries(chrome ${OpenCV_LIBRARIES}) + +if(OpenMP_CXX_FOUND) + target_link_libraries(chrome OpenMP::OpenMP_CXX) +endif() diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..340483c --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 DWS (Dubey Web Services, DWS LLC) + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b4de71 --- /dev/null +++ b/README.md @@ -0,0 +1,128 @@ +# Chrome + +A high-performance C++ port of [negfix8](https://github.com/chrishunt/negfix8) for processing scanned color negative film. Inverts and color-corrects 16-bit linear Rec.709 TIFF images from film scanners. + +## Example + + + + + + + + + + + + +
Input (Scanned Negative)Output (Processed)
Scanned negative
chrome -cs -sat 50 -exp 1.2
Processed output
+ +## Performance + +| Tool | Time | Speedup | +|------|------|---------| +| negfix8 (original) | 18m 8s | 1x | +| **Chrome** | **~10s** | **~109x** | + +*Benchmark: 9098×12160 16-bit TIFF (~332MB) on AMD Ryzen 9 7950X3D (32 threads, 64GB RAM)* + +## Features + +- LUT-based pixel processing with OpenMP parallelization +- Per-channel gamma correction to neutralize orange mask +- Profile save/load for consistent batch processing +- Optional saturation boost, contrast stretching, exposure adjustment, mirror + +## Building + +### Dependencies + +- CMake 3.x+ +- OpenCV (core, imgproc, imgcodecs) +- OpenMP (optional, for parallelization) + +#### Ubuntu/Debian + +```bash +sudo apt install cmake libopencv-dev +``` + +#### macOS (Homebrew) + +```bash +brew install cmake opencv libomp +``` + +### Build + +```bash +cmake -B build -S . +cmake --build build +``` + +The executable is built at `build/chrome`. + +## Usage + +```bash +./build/chrome -i input.tif [-o output.tif] [flags] +``` + +### Flags + +| Flag | Description | +|------|-------------| +| `-g ` | Set gamma (default 2.15) | +| `-cs` | Enable contrast stretching | +| `-exp ` | Exposure adjustment in stops (-10 to +10) | +| `-sat [N]` | Saturation boost, N=0-100 (default 20 if no value) | +| `-m` | Mirror image horizontally | +| `-r ` | Binning/downscale by factor N | +| `-c ` | Create profile from image and save (exits after) | +| `-u ` | Use existing profile for processing | + +### Examples + +```bash +# Basic conversion +./build/chrome -i scan.tiff -o output.tiff + +# With adjustments (contrast stretch, 50% saturation boost, +1.2 stops exposure) +./build/chrome -i scan.tiff -o output.tiff -cs -sat 50 -exp 1.2 + +# Create a profile for batch processing +./build/chrome -i scan.tiff -c myfilm.profile + +# Apply saved profile to multiple images +./build/chrome -i scan2.tiff -o output2.tiff -u myfilm.profile +``` + +## Testing + +Test files are located in `test_data/`: + +| File | Description | +|------|-------------| +| `test_input.tiff` | Sample 16-bit scanned negative | +| `test_expected.tiff` | Reference output from original negfix8 | +| `current_output.tiff` | Chrome output (default settings) | +| `current_output_adj.tiff` | Chrome output with `-cs -sat 50 -exp 1.2` | + +```bash +# Basic conversion +./build/chrome -i test_data/test_input.tiff -o test_data/current_output.tiff + +# With adjustments (as shown in example above) +./build/chrome -i test_data/test_input.tiff -o test_data/current_output_adj.tiff -cs -sat 50 -exp 1.2 +``` + +## Credits + +- Original [negfix8](https://github.com/chrishunt/negfix8) by Chris Hunt +- Original negfix algorithm created by JaZ99 + +## License + +Copyright 2025 DWS (Dubey Web Services, DWS LLC) + +Licensed under the Apache License, Version 2.0. See [LICENSE](LICENSE) for details. diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..536104e --- /dev/null +++ b/main.cpp @@ -0,0 +1,419 @@ +/** + * negfix_cpp - High performance C++ port of negfix8 + * Expects Linear Rec 709 16-bit TIFF Input. + * + * Usage: + * ./negfix -i input.tif -o output.tif [flags] + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// Default settings from original script +struct Settings { + double gamma = 2.15; + bool contrastStretch = false; + bool mirror = false; + int binning = 1; + int saturationBoost = 0; // 0 = disabled, 1-100 = boost percentage + double exposureStops = 0.0; // 0 = disabled, range -10 to +10 + std::string profileLoadPath = ""; + std::string profileSavePath = ""; + std::string inputPath = ""; + std::string outputPath = ""; +}; + +struct Profile { + double minR = 0, minG = 0, minB = 0; + double maxR = 0, maxG = 0, maxB = 0; + double gammaG = 0, gammaB = 0; + double offset = 0; // Calculated offset for black point + bool valid = false; +}; + +// Helper to parse arguments +bool parseArgs(int argc, char** argv, Settings& settings) { + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "-i" && i + 1 < argc) settings.inputPath = argv[++i]; + else if (arg == "-o" && i + 1 < argc) settings.outputPath = argv[++i]; + else if (arg == "-g" && i + 1 < argc) settings.gamma = std::stod(argv[++i]); + else if (arg == "-cs") settings.contrastStretch = true; + else if (arg == "-m") settings.mirror = true; + else if (arg == "-sat") { + // Check if next argument is a number (optional value) + if (i + 1 < argc) { + try { + int val = std::stoi(argv[i + 1]); + if (val >= 0 && val <= 100) { + settings.saturationBoost = val; + ++i; // consume the value argument + } else { + settings.saturationBoost = 20; // default if out of range + } + } catch (...) { + // Next arg is not a number, use default + settings.saturationBoost = 20; + } + } else { + settings.saturationBoost = 20; // default + } + } + else if (arg == "-exp" && i + 1 < argc) { + double val = std::stod(argv[++i]); + if (val < -10.0 || val > 10.0) { + std::cerr << "Error: -exp value must be between -10 and +10 stops\n"; + exit(1); + } + settings.exposureStops = val; + } + else if (arg == "-r" && i + 1 < argc) settings.binning = std::stoi(argv[++i]); + else if (arg == "-c" && i + 1 < argc) settings.profileSavePath = argv[++i]; + else if (arg == "-u" && i + 1 < argc) settings.profileLoadPath = argv[++i]; + else if (arg == "-h" || arg == "--help") return false; + } + return !settings.inputPath.empty(); +} + +void printUsage() { + std::cout << "Usage: negfix_cpp -i input.tif [-o output.tif] [flags]\n" + << "Flags:\n" + << " -g Set gamma (default 2.15)\n" + << " -cs Enable contrast stretching\n" + << " -exp Exposure adjustment in stops (-10 to +10)\n" + << " -sat [N] Saturation boost, N=0-100 (default 20)\n" + << " -m Mirror image horizontally\n" + << " -r Binning (scale down by 1/N)\n" + << " -c Create profile and save to file (exits after)\n" + << " -u Use existing profile\n"; +} + +// Analyze image to find Min/Max (Film Base and Highlights) +// Simulates: -shave 10x10 -blur 3x3 then finding min/max +Profile analyzeImage(const cv::Mat& img) { + Profile prof; + + // Create a copy for analysis to avoid modifying original yet + cv::Mat work; + img.copyTo(work); + + // 1. Shave 10 pixels + int shave = 10; + if (work.cols > 20 && work.rows > 20) { + cv::Rect roi(shave, shave, work.cols - (shave*2), work.rows - (shave*2)); + work = work(roi); + } + + // 2. Blur 3x3 + cv::blur(work, work, cv::Size(3, 3)); + + // 3. Split channels + std::vector channels(3); + cv::split(work, channels); + + // 4. Find Min/Max + // Note: OpenCV loads 16bit TIFF as CV_16U. + // In original script: + // MINIMA (Darkest in file) corresponds to Scene HIGHLIGHTS (Densest negative) + // MAXIMA (Brightest in file) corresponds to Scene SHADOWS (Clear film base) + + double minVal, maxVal; + + // Blue (channel 0 in OpenCV) + cv::minMaxLoc(channels[0], &minVal, &maxVal); + prof.minB = minVal; prof.maxB = maxVal; + + // Green (channel 1) + cv::minMaxLoc(channels[1], &minVal, &maxVal); + prof.minG = minVal; prof.maxG = maxVal; + + // Red (channel 2) + cv::minMaxLoc(channels[2], &minVal, &maxVal); + prof.minR = minVal; prof.maxR = maxVal; + + // Validate + if (prof.minR == 0 || prof.maxR == 0) { + std::cerr << "Error: Image stats invalid (0 values found). scan might be empty.\n"; + exit(1); + } + + prof.valid = true; + return prof; +} + +void saveProfile(const std::string& path, const Profile& p, double userGamma) { + std::ofstream out(path); + // Format mimics original script: minR minG minB offset gammaG gammaB gammaGlobal + // Calculate the derived values to store + + // Replicating original math: + // Offset calculation from script: + // ((minR / maxR)^(1/gamma)) * QuantumRange * 0.95 + double quantum = 65535.0; + double offset = std::pow(p.minR / p.maxR, 1.0/userGamma) * quantum * 0.95; + + // Gammas + double gG = std::log(p.maxG / p.minG) / std::log(p.maxR / p.minR); + double gB = std::log(p.maxB / p.minB) / std::log(p.maxR / p.minR); + + out << p.minR << " " << p.minG << " " << p.minB << " " + << offset << " " << gG << " " << gB << " " << userGamma << "\n"; + out.close(); + std::cout << "Profile saved to " << path << "\n"; +} + +Profile loadProfile(const std::string& path) { + Profile p; + std::ifstream in(path); + if (!in.is_open()) { + std::cerr << "Could not open profile: " << path << "\n"; + exit(1); + } + double dummyGamma; + in >> p.minR >> p.minG >> p.minB >> p.offset >> p.gammaG >> p.gammaB >> dummyGamma; + p.valid = true; + return p; +} + +// Generate LUTs for blazing fast application +void generateLUTs(std::vector& lutR, std::vector& lutG, std::vector& lutB, + const Profile& p, double gamma) { + + lutR.resize(65536); + lutG.resize(65536); + lutB.resize(65536); + + // Calculate Gamma corrections if we analyzed image fresh + // If loaded from profile, these are already in p.gammaG/B + double gG, gB, offset; + + if (p.offset == 0 && p.gammaG == 0) { + // Just analyzed, calculate derived values + gG = std::log(p.maxG / p.minG) / std::log(p.maxR / p.minR); + gB = std::log(p.maxB / p.minB) / std::log(p.maxR / p.minR); + offset = std::pow(p.minR / p.maxR, 1.0/gamma) * 65535.0 * 0.95; + } else { + // Loaded from file + gG = p.gammaG; + gB = p.gammaB; + offset = p.offset; + } + + double invGamma = 1.0 / gamma; + + // OpenMP loop for LUT generation + #pragma omp parallel for + for (int i = 0; i < 65536; ++i) { + double u = (double)i; + if (u < 1.0) u = 1.0; // avoid div by zero + + // 1. Inversion Logic: Base / Input + // Original Script: R_out = MinR / Input + double r_norm = p.minR / u; + double g_norm = p.minG / u; + double b_norm = p.minB / u; + + // 2. Channel Gamma Alignment + // R is reference (gamma 1 relative to itself) + // G and B: ImageMagick's -gamma X applies pow(value, 1/X) + // So we need pow(g_norm, 1/gG) not pow(g_norm, gG) + double r_aligned = r_norm; + double g_aligned = std::pow(g_norm, 1.0/gG); + double b_aligned = std::pow(b_norm, 1.0/gB); + + // 3. Global Gamma Application + double r_final = std::pow(r_aligned, invGamma); + double g_final = std::pow(g_aligned, invGamma); + double b_final = std::pow(b_aligned, invGamma); + + // 4. Scale to QuantumRange (assume internal math was normalized to 0..1? + // No, min/u results in small numbers if u is large. + // Wait, if u = minR, r_norm = 1.0. If u = maxR, r_norm = small. + // The power functions preserve the 0..1 range mostly. + // We need to scale up to 65535 BEFORE subtraction? + // Let's trace unit: + // MinR is ~300. u is ~300. Ratio = 1. + // MinR is ~300. u is ~60000. Ratio = 0.005. + // So we are in 0..1 range effectively. + + r_final *= 65535.0; + g_final *= 65535.0; + b_final *= 65535.0; + + // 5. Subtract Offset + r_final -= offset; + g_final -= offset; + b_final -= offset; + + // Clamp + lutR[i] = cv::saturate_cast(r_final); + lutG[i] = cv::saturate_cast(g_final); + lutB[i] = cv::saturate_cast(b_final); + } +} + +int main(int argc, char** argv) { + Settings settings; + if (!parseArgs(argc, argv, settings)) { + printUsage(); + return 0; + } + + // Load Image + std::cout << "Loading " << settings.inputPath << "..." << std::endl; + cv::Mat img = cv::imread(settings.inputPath, cv::IMREAD_UNCHANGED); + if (img.empty()) { + std::cerr << "Failed to load image.\n"; + return 1; + } + + if (img.depth() != CV_16U) { + std::cerr << "Error: Input must be 16-bit.\n"; + return 1; + } + + // Determine Profile + Profile prof; + if (!settings.profileLoadPath.empty()) { + prof = loadProfile(settings.profileLoadPath); + std::cout << "Loaded profile " << settings.profileLoadPath << "\n"; + } else { + std::cout << "Analyzing image profile..." << std::endl; + prof = analyzeImage(img); + + if (!settings.profileSavePath.empty()) { + saveProfile(settings.profileSavePath, prof, settings.gamma); + return 0; // Exit after creating profile as per script logic behavior + } + } + + // Binning (Resize) + if (settings.binning > 1) { + cv::resize(img, img, cv::Size(img.cols/settings.binning, img.rows/settings.binning), 0, 0, cv::INTER_AREA); + } + + // Generate LUTs + std::cout << "Generating Lookup Tables..." << std::endl; + std::vector lutR, lutG, lutB; + generateLUTs(lutR, lutG, lutB, prof, settings.gamma); + + // Apply LUTs + std::cout << "Processing pixels..." << std::endl; + + // If mono, process differently, but assuming Color input for Negfix logic + int rows = img.rows; + int cols = img.cols; + + if (img.channels() == 3) { + // Parallel Loop over rows + #pragma omp parallel for + for (int r = 0; r < rows; ++r) { + uint16_t* ptr = img.ptr(r); + for (int c = 0; c < cols; ++c) { + // OpenCV is BGR by default + uint16_t b = ptr[3*c + 0]; + uint16_t g = ptr[3*c + 1]; + uint16_t r_val = ptr[3*c + 2]; + + ptr[3*c + 0] = lutB[b]; + ptr[3*c + 1] = lutG[g]; + ptr[3*c + 2] = lutR[r_val]; + } + } + } else { + // Monochrome case + #pragma omp parallel for + for (int r = 0; r < rows; ++r) { + uint16_t* ptr = img.ptr(r); + for (int c = 0; c < cols; ++c) { + ptr[c] = lutG[ptr[c]]; // Use Green channel logic for B&W usually + } + } + } + + // Optional: Exposure adjustment (applied before saturation) + if (settings.exposureStops != 0.0) { + std::cout << "Applying Exposure Adjustment (" << std::showpos << settings.exposureStops + << std::noshowpos << " stops)..." << std::endl; + + // multiplier = 2^stops: +1 stop = 2x, -1 stop = 0.5x + double multiplier = std::pow(2.0, settings.exposureStops); + + #pragma omp parallel for + for (int r = 0; r < rows; ++r) { + uint16_t* ptr = img.ptr(r); + for (int c = 0; c < cols * img.channels(); ++c) { + ptr[c] = cv::saturate_cast(ptr[c] * multiplier); + } + } + } + + // Optional: Saturation boost using Rec.709 luminance method + if (settings.saturationBoost > 0) { + std::cout << "Applying Saturation Boost (" << settings.saturationBoost << "%)..." << std::endl; + + // sat_factor: 1.0 = no change, 1.2 = 20% boost, 2.0 = 100% boost + double sat_factor = 1.0 + (settings.saturationBoost / 100.0); + + // Rec.709 luma coefficients (matches input colorspace) + const double luma_r = 0.2126; + const double luma_g = 0.7152; + const double luma_b = 0.0722; + + #pragma omp parallel for + for (int r = 0; r < rows; ++r) { + uint16_t* ptr = img.ptr(r); + for (int c = 0; c < cols; ++c) { + // OpenCV is BGR + double b = ptr[3*c + 0]; + double g = ptr[3*c + 1]; + double r_val = ptr[3*c + 2]; + + // Calculate Rec.709 luminance + double luma = luma_r * r_val + luma_g * g + luma_b * b; + + // Apply saturation: new = luma + sat_factor * (original - luma) + double new_r = luma + sat_factor * (r_val - luma); + double new_g = luma + sat_factor * (g - luma); + double new_b = luma + sat_factor * (b - luma); + + ptr[3*c + 0] = cv::saturate_cast(new_b); + ptr[3*c + 1] = cv::saturate_cast(new_g); + ptr[3*c + 2] = cv::saturate_cast(new_r); + } + } + } + + // Optional: Contrast Stretch + if (settings.contrastStretch) { + std::cout << "Contrast Stretching..." << std::endl; + // Simple MinMax normalization + cv::normalize(img, img, 0, 65535, cv::NORM_MINMAX); + } + + // Optional: Mirror + if (settings.mirror) { + cv::flip(img, img, 1); + } + + // Save + std::string out = settings.outputPath.empty() ? "output.tif" : settings.outputPath; + std::cout << "Saving to " << out << "..." << std::endl; + + // Set compression params for TIFF + std::vector params; + params.push_back(cv::IMWRITE_TIFF_COMPRESSION); + params.push_back(5); // LZW (equivalent to Zip roughly in OpenCV terms, usually 5 or 1) + + cv::imwrite(out, img, params); + std::cout << "Done." << std::endl; + + return 0; +} diff --git a/test_data/current_output.jpeg b/test_data/current_output.jpeg new file mode 100644 index 0000000..155ef74 --- /dev/null +++ b/test_data/current_output.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77068f2bfdf6ef8b756be99ad78a1595593bb4842d68567b9bf28dced1336aa8 +size 19151197 diff --git a/test_data/current_output.tiff b/test_data/current_output.tiff new file mode 100644 index 0000000..4be2995 --- /dev/null +++ b/test_data/current_output.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:747c3f624eb891d3f8d72a9c9949cf3358ad5f779e93804e798d5b7e7438904d +size 640825246 diff --git a/test_data/current_output_adj.jpeg b/test_data/current_output_adj.jpeg new file mode 100644 index 0000000..bcbcef0 --- /dev/null +++ b/test_data/current_output_adj.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28c0a29647303721c08822e470a512af60dcd6e31f6b90db42b53c860fe46b8e +size 36126399 diff --git a/test_data/current_output_adj.tiff b/test_data/current_output_adj.tiff new file mode 100644 index 0000000..58235c0 --- /dev/null +++ b/test_data/current_output_adj.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3a2e636d6064fcf2d315143de14fbac62f5262740f6ef580dff459071654e166 +size 709655682 diff --git a/test_data/test_expected.jpeg b/test_data/test_expected.jpeg new file mode 100644 index 0000000..4541e17 --- /dev/null +++ b/test_data/test_expected.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1440b5cf05894d6bd52a9cb5e32de20d0d8aeb6618afea067c8988f5cd3ab5f8 +size 19378038 diff --git a/test_data/test_expected.tiff b/test_data/test_expected.tiff new file mode 100644 index 0000000..9d42442 --- /dev/null +++ b/test_data/test_expected.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:06f769291837ee270ab64b1e2cbec3f689e9fc46cf89d281c6e85ac11a7889b4 +size 497108770 diff --git a/test_data/test_input.jpeg b/test_data/test_input.jpeg new file mode 100644 index 0000000..4afc21a --- /dev/null +++ b/test_data/test_input.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:654e6964b50d820c8794f7c5f6f7b8904cb377e48c2a4dd8ce8acb6d922cadce +size 32378009 diff --git a/test_data/test_input.tiff b/test_data/test_input.tiff new file mode 100644 index 0000000..8c8549c --- /dev/null +++ b/test_data/test_input.tiff @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc8793ead88a7c67098bfcdc759bb70ec22280948551672e4745da48fd1f3cbe +size 663863210