diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ce68f3e..62ca6e9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,9 +40,13 @@ jobs: if: runner.os == 'Windows' uses: actions/cache@v4 with: - path: C:\vcpkg\installed - key: vcpkg-${{ runner.os }}-libraw-vulkan - restore-keys: vcpkg-${{ runner.os }}- + path: | + C:\vcpkg\installed + C:\vcpkg\downloads + # Hashes the workflow file itself. If you change the install step below, the cache resets automatically. + key: vcpkg-${{ runner.os }}-${{ hashFiles('.github/workflows/build.yml') }} + restore-keys: | + vcpkg-${{ runner.os }}- - name: Cache CMake build uses: actions/cache@v4 @@ -59,8 +63,8 @@ jobs: libxcb-cursor0 libxcb-xinerama0 libxcb-xinput0 libxcb-icccm4 \ libxcb-image0 libxcb-keysyms1 libxcb-render-util0 libxcb-shape0 \ libwayland-client0 libwayland-cursor0 libwayland-egl1 libxkbcommon-x11-0 \ - libwayland-dev wayland-protocols libwayland-server0 \ - imagemagick + libwayland-dev wayland-protocols libwayland-server0 libopencv-dev \ + imagemagick libtiff-dev wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage wget https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage chmod +x linuxdeploy-x86_64.AppImage linuxdeploy-plugin-qt-x86_64.AppImage @@ -68,7 +72,7 @@ jobs: - name: Install Dependencies (Windows) if: runner.os == 'Windows' run: | - vcpkg install libraw:x64-windows vulkan:x64-windows + vcpkg install libraw:x64-windows vulkan:x64-windows opencv4:x64-windows tiff:x64-windows - name: Configure CMake shell: bash @@ -110,7 +114,7 @@ jobs: # Tell the Qt plugin where to find QML files to scan for dependencies export QML_SOURCES_PATHS="${{ github.workspace }}/content" # Explicitly request extra platform plugins for better compatibility - export EXTRA_QT_PLUGINS="platforms,wayland-graphics-integration-client,wayland-shell-integration,imageformats" + export EXTRA_QT_PLUGINS="platforms,wayland-graphics-integration-client,wayland-shell-integration,imageformats,styles,controls,quickcontrols2" # Run linuxdeploy ./linuxdeploy-x86_64.AppImage --appdir AppDir -e build_dir/Photon -d Photon.desktop -i assets/icons/photon.png --plugin qt --output appimage diff --git a/.gitignore b/.gitignore index a77b961..f4d668f 100644 --- a/.gitignore +++ b/.gitignore @@ -80,6 +80,8 @@ CMakeLists.txt.user* .rcc/ .uic/ /build*/ +/dist/ +/research/ testphoton tmp Testing diff --git a/CMakeLists.txt b/CMakeLists.txt index 35533e5..ded8926 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,6 +1,6 @@ cmake_minimum_required(VERSION 3.16) -project(Photon VERSION 0.1.1 LANGUAGES CXX) +project(Photon VERSION 0.2.0 LANGUAGES CXX) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_CXX_STANDARD_REQUIRED ON) @@ -21,14 +21,18 @@ find_package(Vulkan REQUIRED) if(WIN32) # Use find_package for vcpkg compatibility on Windows find_package(LibRaw REQUIRED) + find_package(OpenCV REQUIRED) + find_package(TIFF REQUIRED) add_compile_definitions(WIN32_LEAN_AND_MEAN) add_compile_definitions(NOMINMAX) else() find_package(PkgConfig REQUIRED) pkg_check_modules(LIBRAW REQUIRED libraw) + pkg_check_modules(OPENCV REQUIRED opencv4) + pkg_check_modules(TIFF REQUIRED libtiff-4) endif() -qt_standard_project_setup(REQUIRES 6.8) +qt_standard_project_setup(REQUIRES 6.10) qt_add_executable(Photon src/main.cpp @@ -45,6 +49,8 @@ qt_add_executable(Photon src/engine/VulkanComputeContext.cpp src/engine/VulkanComputeContext.h src/engine/patch_search.comp + src/engine/Panorama.cpp + src/engine/Panorama.h src/components/RawViewport.cpp src/components/RawViewport.h src/components/ToneLutProvider.h @@ -114,6 +120,8 @@ qt_add_qml_module(Photon src/engine/GpuChromaFilter.h src/engine/ImageDeveloper.cpp src/engine/ImageDeveloper.h + src/engine/Panorama.cpp + src/engine/Panorama.h src/engine/VulkanComputeContext.cpp src/engine/VulkanComputeContext.h src/managers/AppStateManager.cpp @@ -183,6 +191,8 @@ qt_add_qml_module(Photon assets/icons/tube.svg assets/icons/trash.svg assets/icons/download.svg + assets/icons/panorama.svg + assets/icons/hdr.svg assets/icons/folder.svg assets/icons/home.svg assets/icons/eye.svg @@ -206,14 +216,18 @@ set_target_properties(Photon PROPERTIES if(WIN32) target_link_libraries(Photon - PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Gui Qt6::ShaderTools Qt6::Concurrent Qt6::GuiPrivate + PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Gui Qt6::ShaderTools Qt6::Concurrent Qt6::GuiPrivate PRIVATE ${LibRaw_LIBRARIES} + PRIVATE ${OpenCV_LIBRARIES} + PRIVATE ${TIFF_LIBRARIES} PRIVATE Vulkan::Vulkan ) else() target_link_libraries(Photon PRIVATE Qt6::Quick Qt6::QuickControls2 Qt6::Gui Qt6::ShaderTools Qt6::Concurrent Qt6::GuiPrivate PRIVATE ${LIBRAW_LIBRARIES} + PRIVATE ${OPENCV_LIBRARIES} + PRIVATE ${TIFF_LIBRARIES} PRIVATE Vulkan::Vulkan ) endif() @@ -234,6 +248,7 @@ target_include_directories(Photon PRIVATE ${CMAKE_CURRENT_BINARY_DIR} $<$>:/usr/include/qt6/QtGui/6.10.1> $<$>:/usr/include/qt6/QtGui/6.10.1/QtGui> + $<$>:${OPENCV_INCLUDE_DIRS}> ) # Ensure QML types are registered before main @@ -268,23 +283,40 @@ install(DIRECTORY content/ DESTINATION ${CMAKE_INSTALL_BINDIR}/content) install(DIRECTORY assets/ DESTINATION ${CMAKE_INSTALL_BINDIR}/assets) # Install vcpkg DLLs on Windows -if(WIN32) +# if(WIN32) # Find LibRaw DLL based on build configuration - if(CMAKE_BUILD_TYPE STREQUAL "Debug") - file(GLOB LIBRAW_DLL "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/debug/bin/rawd.dll") - else() - file(GLOB LIBRAW_DLL "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/raw.dll") - endif() - if(LIBRAW_DLL) - install(FILES ${LIBRAW_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) - endif() - - # Find and install all vcpkg DLLs (but not raw.dll again) - file(GLOB VCPKG_DLLS "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/*.dll") - if(VCPKG_DLLS) - list(REMOVE_ITEM VCPKG_DLLS "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/raw.dll") - install(FILES ${VCPKG_DLLS} DESTINATION ${CMAKE_INSTALL_BINDIR}) - endif() + # if(CMAKE_BUILD_TYPE STREQUAL "Debug") + # file(GLOB LIBRAW_DLL "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/debug/bin/rawd.dll") + # else() + # file(GLOB LIBRAW_DLL "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/raw.dll") + # endif() + # if(LIBRAW_DLL) + # install(FILES ${LIBRAW_DLL} DESTINATION ${CMAKE_INSTALL_BINDIR}) + # endif() + # + # # Find and install all vcpkg DLLs (but not raw.dll again) + # file(GLOB VCPKG_DLLS "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/*.dll") + # if(VCPKG_DLLS) + # list(REMOVE_ITEM VCPKG_DLLS "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin/raw.dll") + # install(FILES ${VCPKG_DLLS} DESTINATION ${CMAKE_INSTALL_BINDIR}) + # endif() + #endif() + +if(WIN32) + # 1. Determine the correct vcpkg bin folder based on build type + if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(VCPKG_BIN_DIR "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/debug/bin") + else() + set(VCPKG_BIN_DIR "${VCPKG_INSTALLED_DIR}/${VCPKG_TARGET_TRIPLET}/bin") + endif() + + # 2. Grab ALL DLLs from that folder (this handles LibRaw, OpenCV, and any others automatically) + file(GLOB VCPKG_DLLS "${VCPKG_BIN_DIR}/*.dll") + + # 3. Install them + if(VCPKG_DLLS) + install(FILES ${VCPKG_DLLS} DESTINATION ${CMAKE_INSTALL_BINDIR}) + endif() endif() if(WIN32) diff --git a/Dockerfile.linux b/Dockerfile.linux new file mode 100644 index 0000000..68f78f0 --- /dev/null +++ b/Dockerfile.linux @@ -0,0 +1,76 @@ +# Dockerfile for Linux AppImage build (Ubuntu 22.04) +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Install build tools and dependencies +RUN apt-get update && apt-get install -y \ + build-essential \ + cmake \ + git \ + wget \ + curl \ + python3 \ + python3-pip \ + libraw-dev \ + libvulkan-dev \ + libfuse2 \ + libgl1-mesa-dev \ + libxcb-cursor0 \ + libxcb-xinerama0 \ + libxcb-xinput0 \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-shape0 \ + libwayland-client0 \ + libwayland-cursor0 \ + libwayland-egl1 \ + libxkbcommon-x11-0 \ + libxkbcommon-dev \ + libxkbcommon-x11-dev \ + libxcb-xkb-dev \ + libfontconfig1-dev \ + libfreetype6-dev \ + libx11-xcb-dev \ + libwayland-dev \ + wayland-protocols \ + libwayland-server0 \ + libopencv-dev \ + imagemagick \ + libtiff-dev \ + file \ + && rm -rf /var/lib/apt/lists/* + +# Install aqtinstall for Qt installation +RUN pip3 install aqtinstall + +# Install Qt 6.10.1 (matches build.yml) +RUN aqt install-qt linux desktop 6.10.1 linux_gcc_64 -m qtshadertools --outputdir /opt/qt + +# Set Qt environment variables +ENV PATH="/opt/qt/6.10.1/gcc_64/bin:${PATH}" +ENV QT_DIR="/opt/qt/6.10.1/gcc_64" + +# Download and extract linuxdeploy tools +WORKDIR /tools +RUN wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage && \ + wget https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage && \ + chmod +x linuxdeploy-x86_64.AppImage linuxdeploy-plugin-qt-x86_64.AppImage && \ + ./linuxdeploy-x86_64.AppImage --appimage-extract && \ + mv squashfs-root linuxdeploy-extracted && \ + ./linuxdeploy-plugin-qt-x86_64.AppImage --appimage-extract && \ + mv squashfs-root linuxdeploy-plugin-qt-extracted && \ + rm linuxdeploy-x86_64.AppImage linuxdeploy-plugin-qt-x86_64.AppImage + +# Symlink binaries to /usr/local/bin for easy access +RUN ln -s /tools/linuxdeploy-extracted/usr/bin/linuxdeploy /usr/local/bin/linuxdeploy && \ + ln -s /tools/linuxdeploy-plugin-qt-extracted/usr/bin/linuxdeploy-plugin-qt /usr/local/bin/linuxdeploy-plugin-qt + +# Copy source code and build script +WORKDIR /app +COPY . . +RUN chmod +x build_internal.sh + +CMD ["/app/build_internal.sh"] diff --git a/Dockerfile.windows b/Dockerfile.windows new file mode 100644 index 0000000..ef89428 --- /dev/null +++ b/Dockerfile.windows @@ -0,0 +1,58 @@ +# Dockerfile for Windows build using MSVC 2022 +# This MUST be run on a Windows host with Windows Containers enabled +FROM mcr.microsoft.com/windows/servercore:ltsc2022 + +# Set shell to powershell for easier setup +SHELL ["powershell", "-Command", "$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue';"] + +# Install Visual Studio 2022 Build Tools +RUN Invoke-WebRequest -Uri https://aka.ms/vs/17/release/vs_buildtools.exe -OutFile vs_buildtools.exe; \ + Start-Process -FilePath vs_buildtools.exe -ArgumentList '--quiet', '--norestart', '--nocache', \ + '--add', 'Microsoft.VisualStudio.Workload.VCTools', \ + '--add', 'Microsoft.VisualStudio.Component.VC.ATLMFC', \ + '--add', 'Microsoft.VisualStudio.Component.Windows11SDK.22000' -Wait; \ + Remove-Item -Force vs_buildtools.exe + +# Install Chocolatey to manage other tools +RUN Set-ExecutionPolicy Bypass -Scope Process -Force; \ + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; \ + iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + +# Install CMake, Git, and Python +RUN choco install -y cmake git python3 + +# Install aqtinstall for Qt +RUN pip install aqtinstall + +# Install Qt 6.10.1 (matches build.yml) +RUN aqt install-qt windows desktop 6.10.1 win64_msvc2022_64 -m qtshadertools --outputdir C:\Qt + +# Install and bootstrap vcpkg +RUN git clone https://github.com/microsoft/vcpkg.git C:\vcpkg; \ + C:\vcpkg\bootstrap-vcpkg.bat + +# Set up work directory +WORKDIR C:\app +COPY . . + +# Build and Package script +# This script mirrors the build.yml steps +RUN @' \ +$env:PATH = \"C:\Qt\6.10.1\msvc2022_64\bin;C:\Program Files\CMake\bin;C:\Program Files\Git\cmd;C:\vcpkg;\" + $env:PATH; \ +# Install dependencies via vcpkg \ +vcpkg install libraw:x64-windows vulkan:x64-windows opencv4:x64-windows tiff:x64-windows; \ +# Configure and Build \ +mkdir build_win; cd build_win; \ +cmake .. -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE=C:/vcpkg/scripts/buildsystems/vcpkg.cmake; \ +cmake --build . --config Release -j $env:NUMBER_OF_PROCESSORS; \ +# Package \ +mkdir install; \ +cmake --install . --config Release --prefix install; \ +cd install\bin; \ +& \"C:\Qt\6.10.1\msvc2022_64\bin\windeployqt.exe\" --release --qmldir ..\..\..\content --no-compiler-runtime Photon.exe; \ +# Final Copy to dist (will be mapped via volume) \ +mkdir C:\app\dist\windows\Photon; \ +xcopy /E /I /Y C:\app\build_win\install\* C:\app\dist\windows\Photon\; \ +'@ | Out-File -FilePath C:\app\build_internal.ps1 -Encoding ascii + +CMD ["powershell", "-File", "C:\\app\\build_internal.ps1"] diff --git a/README.md b/README.md index b3dbb1e..5493a7d 100644 --- a/README.md +++ b/README.md @@ -6,44 +6,42 @@ ## What is this and who is this for? -Photon is an open source RAW image editor, born to (_try to_) replace basic Adobe Lightroom® functionalities, focused on ease of use and performance. -I'm an occasional photographer and lately I've been using the Lightroom mobile version to edit my photos as it is free and has all the features I want, except for panorama stitching, so I thought it was a good idea to attempt to create something to fit my needs and be finally free from Adobe. +Photon is an open source RAW image editor, focused on ease of use and performance. +I'm an occasional photographer and lately I've been using the mobile version of Lightroom to edit my photos as it is free and has all the features I need, but unfortunately it does not run on Linux. -If you are looking for a modern quick photo editor, this might be for you. On the other hand, if you want advanced AI features, local adjustment and so on, either pay for Lightroom or try [RapidRaw](https://github.com/CyberTimon/RapidRAW), which looks very promising. +If you are looking for a modern quick photo editor, this might be for you. On the other hand, if you want advanced AI features, local adjustments and so on, either pay for Lightroom or try [RapidRaw](https://github.com/CyberTimon/RapidRAW), which looks very promising. ## Current state -Not having much experience with both Qt6 and how images are processed, I used both Gemini and Copilot to kickstart the project, especially to implement what could have taken months and months of full time work, which I cannot afford right now. - -The project is in an advanced state and most of functionalities listed below as completed work good enough for me, so I decided to step back from automatic programming and start to implement and refine what's missing manually, to both asses the code quality produced up until now (I would be a liar if I say that I diligently reviewed all the AI output...) and to actually keep my skills sharp in these funny times. - -Here's what works and what is still in the backlog: - -| Feature | Status | -| :------------------------------------------ | :----: | -| RAW Decoding (LibRaw) | ✅ | -| GPU-Accelerated Rendering (Vulkan/RHI) | ✅ | -| Non-Destructive Editing (JSON Sidecars) | ✅ | -| Exposure & Contrast | ✅ | -| Vibrance & Saturation | ✅ | -| 8-Band HSL Adjustments | ✅ | -| Color Grading (Shadows/Midtones/Highlights) | ✅ | -| Film Grain & Vignette | ✅ | -| Live Histogram (RGB/Luma) | ✅ | -| Undo/Redo History | ✅ | -| Preset System | ✅ | -| EXIF Metadata & Orientation | ✅ | -| Hybrid Denoising (BM3D + GPU NLM) | ✅ | -| Interactive Viewport (Pan & Zoom) | ✅ | -| Image Export (JPEG/TIFF) | ✅ | -| Theme Customization (Light/Dark/Accents) | ✅ | -| Crop & Transform Tools | ✅ | -| Tone Curve (Spline UI) | ✅ | -| Batch Copy & Paste | ✅ | -| Lens Correction (Lensfun) | 🔁 | -| Panorama Stitching | 🔁 | -| HDR merge | 🔁 | -| Import/Export presetes | 🔁 | +Not having much experience with both Qt6 and how RAW images work, I used both Gemini and Copilot to kickstart the project, shrinking down months of full time research and work. + +> [!NOTE] +> From the first day of development I wanted to implement things as fast as possible to get a working application and start editing my photos on Linux, for this reason I skipped chores and code hygiene practices, but now I will slow down to clean up the project and fix all the little things and inconsistencies that annoy me. + +Features: + +- [x] RAW Decoding (LibRaw) +- [x] GPU-Accelerated Rendering (Vulkan/RHI) +- [x] Non-Destructive Editing (JSON Sidecars) +- [x] Exposure & Contrast +- [x] Vibrance & Saturation +- [x] 8-Band HSL Adjustments +- [x] Color Grading (Shadows/Midtones/Highlights) +- [x] Film Grain & Vignette +- [x] Live Histogram (RGB/Luma) +- [x] Undo/Redo History +- [x] Preset System +- [x] EXIF Metadata & Orientation +- [x] Hybrid Denoising (BM3D + GPU NLM) +- [x] Image Export (JPEG/TIFF) +- [x] Theme Customization (Light/Dark/Accents) +- [x] Crop & Transform Tools +- [x] Tone Curve (Spline UI) +- [x] Batch Copy & Paste +- [x] Panorama Stitching +- [ ] Lens Correction (Lensfun) +- [ ] HDR merge +- [ ] Import/Export presetes ## Getting Started @@ -77,5 +75,5 @@ make -j$(nproc) ## Why another editing tool? I've always used Lightroom to edit my photos and I never found a valid alternative: tools like Rawtherapee and Darktable are for sure very capable and powerful, but I find them unnecessary complex to perform simple edits. -Searching for alternatives on GitHub I found RapidRaw, a very promising editor with a stunning UI and some very powerful capabilities. I give it a shot and I really liked it, especially the UX that allowed me to quickly edit my last shooting session. However, while the editing workflow is exceptional, I found the performance disappointing, even on a laptop with a dedicated GPU: the preview takes a lot of time to render, the adjustment are applied slowly and the overall experience is laggy. -For these reasons I decided to start this journey, choosing to use QT6, which I think it's a better tool for implementing an high performance photo editor. +Searching for alternatives on GitHub I found RapidRaw, a very promising editor with a stunning UI and some very powerful capabilities. I gave it a shot and I really liked it, especially the UX that allowed me to quickly edit my last shooting session. However, while the editing workflow is exceptional, I found the performance disappointing, even on a laptop with a dedicated GPU: the preview takes a lot of time to render, the adjustment are applied slowly and the overall experience is laggy. +For these reasons I decided to start this journey, choosing to use QT6 and C++, which I think it's a better tool for implementing an high performance photo editor. diff --git a/TASKS.md b/TASKS.md index 133f7af..88b9509 100644 --- a/TASKS.md +++ b/TASKS.md @@ -498,15 +498,32 @@ - [x] Kept filter criteria cycling in active context-menu flow for faster iteration. - [x] Build + tests + offscreen runtime smoke validated after integration. -## Phase 36: Old session Not Found and log rotation +--- + +# Taking back control of the codebase + +## Phase 37: Old session Not Found and log rotation - [x] Pop up error when continue session folder is not found, then reset it and return to WelcomeView - [x] Auto log cleanup +## Phase 38: Panorama and other improvements + +- [x] Panorama Stitching + - [x] OpenCV integration + - [x] Stitching +- [ ] Dng export + - [x] Implement a DNG-like export + - [ ] Move the implementation to ExportManager +- [ ] New tone processing pipeline + - [x] Implement new pipeline + - [x] Port it to ImageDeveloper + - [ ] Tune tone targeting + ## Backlog / Future - [ ] **Perspective Correction** - [ ] Keystone/perspective transform controls. - [ ] **Lens Correction** - [ ] Integrate `lensfun` for automatic distortion/vignette removal. -- [ ] **Panorama Stitching** +- [ ] Let the use decide whether to use auto brightness or not (and threshold) diff --git a/assets/icons/hdr.svg b/assets/icons/hdr.svg new file mode 100644 index 0000000..e3e7ab0 --- /dev/null +++ b/assets/icons/hdr.svg @@ -0,0 +1 @@ + diff --git a/assets/icons/panorama.svg b/assets/icons/panorama.svg new file mode 100644 index 0000000..c0db581 --- /dev/null +++ b/assets/icons/panorama.svg @@ -0,0 +1 @@ + diff --git a/build_appimage.sh b/build_appimage.sh new file mode 100755 index 0000000..41fad4c --- /dev/null +++ b/build_appimage.sh @@ -0,0 +1,15 @@ +#!/bin/bash +# Script to build Photon AppImage using Docker + +# Ensure dist/linux directory exists +mkdir -p dist/linux + +# Build the Docker image (rebuilds everytime to collect changes) +echo "[ build_appimage.sh ] - Building Docker image..." +docker build -t photon-linux-builder -f Dockerfile.linux . + +# Run the container and mount the dist directory to get the output +echo "[ build_appimage.sh ] - Running container to build AppImage..." +docker run --rm -v "$(pwd)/dist:/app/dist" photon-linux-builder + +echo "[ build_appimage.sh ] - AppImage should be available at dist/linux/Photon-Linux.AppImage" diff --git a/build_internal.sh b/build_internal.sh new file mode 100644 index 0000000..4679d42 --- /dev/null +++ b/build_internal.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Internal build script for Photon AppImage + +mkdir -p build_dir && cd build_dir +cmake .. -DCMAKE_BUILD_TYPE=Release +cmake --build . -j$(nproc) +cd .. + +export QMAKE=$(which qmake) +export VERSION=$(git describe --tags --always || echo "latest") +export QML_SOURCES_PATHS="/app/content" +export EXTRA_QT_MODULES="quick,quickcontrols2,qml" +export EXTRA_QT_PLUGINS="platforms,wayland-graphics-integration-client,wayland-shell-integration,imageformats,styles,controls,quickcontrols2" +mkdir -p AppDir/usr/share/fonts +cp -r /usr/share/fonts/truetype AppDir/usr/share/fonts/ 2>/dev/null || true +cp -r /usr/share/fonts/opentype AppDir/usr/share/fonts/ 2>/dev/null || true +export QT_QPA_FONTDIR="$APPDIR/usr/share/fonts" +cat >AppDir/AppRun <<'EOF' +#!/bin/bash +APPDIR="$(dirname "$(readlink -f "$0")")" + +export QT_QPA_FONTDIR="$APPDIR/usr/share/fonts" +export FONTCONFIG_PATH="$APPDIR/usr/share/fonts" +export QT_QUICK_CONTROLS_STYLE="${QT_QUICK_CONTROLS_STYLE:-Basic}" +export QT_QPA_PLATFORM="${QT_QPA_PLATFORM:-wayland;xcb}" + +exec "$APPDIR/usr/bin/Photon" "$@" +EOF +chmod +x AppDir/AppRun +# Run linuxdeploy +linuxdeploy --appdir AppDir -e build_dir/Photon -d Photon.desktop -i assets/icons/photon.png --plugin qt --output appimage + +# Move and rename output +mkdir -p /app/dist/linux +mv Photon-*.AppImage /app/dist/linux/Photon-Linux.AppImage diff --git a/build_windows.sh b/build_windows.sh new file mode 100755 index 0000000..270d7a3 --- /dev/null +++ b/build_windows.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# Script to build Photon for Windows using Docker +# This MUST be run on a Windows machine (e.g., via Git Bash) with Windows Containers enabled + +# Check if we are likely on a Linux machine +if [[ "$OSTYPE" == "linux-gnu"* ]]; then + echo "[ build_windows.sh ] - WARNING: You are on Linux. Windows containers require a Windows host." + echo "This script will likely fail unless your Docker is configured for remote Windows build nodes." +fi + +# Ensure dist/windows directory exists +mkdir -p dist/windows + +# Build the Docker image (rebuilds everytime to collect changes) +echo "[ build_windows.sh ] - Building Docker image (this may take 30+ minutes the first time)..." +docker build -t photon-windows-builder -f Dockerfile.windows . + +# Run the container and mount the dist directory to get the output +# Using PWD formatted for Windows if on Git Bash +echo "[ build_windows.sh ] - Running container to build Windows binaries..." +docker run --rm -v "$(pwd)/dist:C:/app/dist" photon-windows-builder + +echo "[ build_windows.sh ] - Build complete. Outputs in dist/windows/Photon" diff --git a/content/components/PhotoContextMenu.qml b/content/components/PhotoContextMenu.qml index 1ea17d5..8a64150 100644 --- a/content/components/PhotoContextMenu.qml +++ b/content/components/PhotoContextMenu.qml @@ -1,9 +1,15 @@ import QtQuick -import QtQuick.Controls as T +import QtQuick.Controls.Basic as T import Main T.Menu { id: root + implicitWidth: 200 + topPadding: 4 + bottomPadding: 4 + closePolicy: T.Popup.CloseOnEscape | T.Popup.CloseOnPressOutside + + // ─── Properties ────────────────────────────────────────────────────────── property int selectionCount: 0 property bool canCopy: true @@ -14,6 +20,8 @@ T.Menu { property var operatorLabels: ["=", ">", "≥", "<", "≤"] property bool keepFilterMenuOpen: false + // ─── Signals ───────────────────────────────────────────────────────────── + signal copyRequested() signal pasteRequested() signal ratingRequested(int rating) @@ -23,7 +31,10 @@ T.Menu { signal rotateLeftRequested() signal flipHorizontalRequested() signal flipVerticalRequested() + signal createPanoramaRequested() + // ─── Helpers ───────────────────────────────────────────────────────────── + function openAt(x, y) { root.x = x root.y = y @@ -38,55 +49,140 @@ T.Menu { } onAboutToHide: { - if (!keepFilterMenuOpen) - return + if (!keepFilterMenuOpen) return keepFilterMenuOpen = false Qt.callLater(function() { - root.openAt(root.x, root.y) + root.x = root.x + root.y = root.y + root.open() filterMenu.open() }) } - background: Rectangle { + // ─── Internal components ───────────────────────────────────────────────── + + component MenuBg: Rectangle { color: Theme.secondary border.color: Theme.border border.width: 1 radius: Theme.radius } - T.MenuItem { + component StyledMenuItem: T.MenuItem { + id: item + implicitWidth: 200 + implicitHeight: 36 + leftPadding: 12 + rightPadding: 12 + topPadding: 0 + bottomPadding: 0 + spacing: 8 + + background: Rectangle { + color: item.highlighted ? Theme.accent : "transparent" + radius: Theme.radius + anchors.fill: parent + anchors.margins: 2 + } + + contentItem: Row { + spacing: item.spacing + anchors.verticalCenter: parent.verticalCenter + + Image { + source: item.icon.source + width: 16 + height: 16 + anchors.verticalCenter: parent.verticalCenter + visible: item.icon.source != "" + fillMode: Image.PreserveAspectFit + // Respect icon.color tinting if set + layer.enabled: item.icon.color !== Qt.rgba(0,0,0,0) && item.icon.color !== "#000000" + layer.effect: null + } + + Text { + text: item.text + font: Theme.fontSmall + color: item.enabled ? Theme.foreground : Theme.mutedFg + verticalAlignment: Text.AlignVCenter + height: item.implicitHeight + leftPadding: (item.icon.source == "" ) ? 24 : 0 + } + } + + // Submenu arrow indicator + indicator: Item { + width: 12 + height: item.implicitHeight + visible: item.subMenu !== null + anchors.right: parent.right + anchors.rightMargin: 8 + Text { + anchors.centerIn: parent + color: item.enabled ? Theme.foreground : Theme.mutedFg + } + } + } + + component StyledMenu: T.Menu { + implicitWidth: 150 + topPadding: 4 + bottomPadding: 4 + closePolicy: T.Popup.CloseOnEscape | T.Popup.CloseOnPressOutside + + background: MenuBg {} + + delegate: StyledMenuItem {} + } + + component StyledSeparator: T.MenuSeparator { + topPadding: 4 + bottomPadding: 4 + contentItem: Rectangle { + implicitWidth: root.implicitWidth + implicitHeight: 1 + color: Theme.mutedFg + } + } + + // ─── Root menu background ──────────────────────────────────────────────── + + background: MenuBg {} + delegate: StyledMenuItem {} + + // ─── Items ─────────────────────────────────────────────────────────────── + + StyledMenuItem { text: "Copy settings" icon.source: "qrc:/Main/assets/icons/copy-menu.svg" enabled: root.canCopy - onTriggered: { - root.copyRequested() - root.close() - } + onTriggered: { root.copyRequested(); root.close() } } - T.MenuItem { - text: canPaste ? root.selectionCount > 1 - ? "Paste settings to " + root.selectionCount + " photos" - : "Paste settings" : "Settings buffer empty" + StyledMenuItem { + text: root.canPaste + ? root.selectionCount > 1 + ? "Paste settings to " + root.selectionCount + " photos" + : "Paste settings" + : "Settings buffer empty" icon.source: "qrc:/Main/assets/icons/clipboard-paste-menu.svg" enabled: root.canPaste - onTriggered: { - root.pasteRequested() - root.close() - } + onTriggered: { root.pasteRequested(); root.close() } } - T.MenuSeparator {} + StyledSeparator {} - T.Menu { + StyledMenu { title: "Rating" - T.MenuItem { + + StyledMenuItem { text: "No rating" onTriggered: root.ratingRequested(0) } Repeater { model: 5 - delegate: T.MenuItem { + delegate: StyledMenuItem { required property int index text: (index + 1) + " " + root.stars(index + 1) onTriggered: root.ratingRequested(index + 1) @@ -94,24 +190,25 @@ T.Menu { } } - T.Menu { + StyledMenu { id: filterMenu title: "Filter" enabled: root.showFilterSection - T.MenuItem { + + StyledMenuItem { text: "Criteria: " + root.operatorLabels[root.filterOperator] onTriggered: { root.filterOperatorCycleRequested() root.keepFilterMenuOpen = true } } - T.MenuItem { + StyledMenuItem { text: "All" onTriggered: root.filterRatingRequested(0) } Repeater { model: 5 - delegate: T.MenuItem { + delegate: StyledMenuItem { required property int index text: (index + 1) + " " + root.stars(index + 1) onTriggered: root.filterRatingRequested(index + 1) @@ -119,44 +216,49 @@ T.Menu { } } - T.MenuSeparator { - visible: root.showFilterSection + StyledSeparator { visible: root.showFilterSection } + + StyledMenu { + id: mergeMenu + title: "Merge Photos" + enabled: root.selectionCount > 1 + + StyledMenuItem { + text: "Panorama" + icon.source: "qrc:/Main/assets/icons/panorama.svg" + onTriggered: { root.createPanoramaRequested(); root.close() } + } + StyledMenuItem { + text: "HDR" + icon.source: "qrc:/Main/assets/icons/hdr.svg" + enabled: false + } } - T.MenuItem { + StyledSeparator {} + + StyledMenuItem { text: "Rotate right" icon.source: "qrc:/Main/assets/icons/rotate-cw-menu.svg" - onTriggered: { - root.rotateRightRequested() - root.close() - } + onTriggered: { root.rotateRightRequested(); root.close() } } - T.MenuItem { + StyledMenuItem { text: "Rotate left" icon.source: "qrc:/Main/assets/icons/rotate-ccw.svg" icon.color: Theme.foreground - onTriggered: { - root.rotateLeftRequested() - root.close() - } + onTriggered: { root.rotateLeftRequested(); root.close() } } - T.MenuItem { + StyledMenuItem { text: "Flip horizontally" icon.source: "qrc:/Main/assets/icons/flip-horizontal-menu.svg" - onTriggered: { - root.flipHorizontalRequested() - root.close() - } + onTriggered: { root.flipHorizontalRequested(); root.close() } } - T.MenuItem { + StyledMenuItem { text: "Flip vertically" icon.source: "qrc:/Main/assets/icons/flip-vertical-menu.svg" - onTriggered: { - root.flipVerticalRequested() - root.close() - } + onTriggered: { root.flipVerticalRequested(); root.close() } } } diff --git a/content/components/PresetPanel.qml b/content/components/PresetPanel.qml index 3c146aa..a01d145 100644 --- a/content/components/PresetPanel.qml +++ b/content/components/PresetPanel.qml @@ -115,7 +115,7 @@ Rectangle { Layout.fillWidth: true } - T.Button { + PhotonButton { visible: parent.parent.hovered implicitWidth: 32 implicitHeight: 32 @@ -125,16 +125,11 @@ Rectangle { } icon.source: "qrc:/Main/assets/icons/trash.svg" - icon.width: 32 - icon.height: 32 + icon.width: 16 + icon.height: 16 + icon.color: Theme.foreground + variantDestructive: true - background: Rectangle { - color: Theme.destructive - radius: 4 - } - - T.ToolTip.visible: hovered - T.ToolTip.text: "Delete Preset" } } diff --git a/content/views/App.qml b/content/views/App.qml index 873d206..1e22dfb 100644 --- a/content/views/App.qml +++ b/content/views/App.qml @@ -1,6 +1,5 @@ import QtQuick import QtQuick.Layouts -import QtQuick.Controls import QtQuick.Controls.Basic as T import QtQuick.Dialogs import Main 1.0 @@ -24,6 +23,7 @@ Window { property int ratingFilter: 0 property int ratingOperator: 2 readonly property var ratingOperatorLabels: ["=", ">", "≥", "<", "≤"] + property int filmstripRestoreGeneration: 0 property var copiedSettings: ({}) property string contextMenuSourcePath: "" PhotonToastManager { id: toaster } @@ -40,6 +40,12 @@ Window { rawViewport.showSharpenMask = false; } } + Connections { + target: Panorama + function onStitchCompleted(result) { + toaster.show(result.message, result.success ? "info" : "error") + } + } // List model to hold the RAW files ListModel { @@ -61,7 +67,6 @@ Window { selectionCount: AppState.selectionCount canCopy: AppState.selectionCount == 1 canPaste: Object.keys(window.copiedSettings).length > 0 - showFilterSection: true filterOperator: window.ratingOperator filterRating: window.ratingFilter operatorLabels: window.ratingOperatorLabels @@ -78,19 +83,43 @@ Window { onRotateLeftRequested: window.rotateSelectionLeft() onFlipHorizontalRequested: window.flipSelectionHorizontal() onFlipVerticalRequested: window.flipSelectionVertical() + onCreatePanoramaRequested: Panorama.stitchAsync(AppState.selectedImages) } // Function to refresh the file list function refreshFiles() { - rawFilesModel.clear(); - + var previousFilmstripX = filmstripList ? filmstripList.contentX : 0 // Scan for RAW files in the current folder var files = fileScanner.scanForRawFiles(AppState.currentFolder); - if (!files) return; + if (!files) { + rawFilesModel.clear(); + return; + } + + // Filter to mirror Library view behavior + var filtered = []; + for (var i = 0; i < files.length; i++) { + var file = files[i]; + var r = file.rating || 0; + var match = true; + if (window.ratingFilter > 0) { + switch (window.ratingOperator) { + case 0: match = (r === window.ratingFilter); break; + case 1: match = (r > window.ratingFilter); break; + case 2: match = (r >= window.ratingFilter); break; + case 3: match = (r < window.ratingFilter); break; + case 4: match = (r <= window.ratingFilter); break; + } + } else if (window.ratingFilter === 0 && window.ratingOperator === 0) { + match = (r === 0); + } + + if (match) filtered.push(file); + } // Sort to match library view order var dir = window.sortAscending ? 1 : -1; - files.sort(function(a, b) { + filtered.sort(function(a, b) { switch (window.sortProperty) { case 0: return dir * a.name.localeCompare(b.name); case 1: @@ -102,8 +131,34 @@ Window { } }); - for (var i = 0; i < files.length; i++) { - var file = files[i]; + // Fast path: keep model (and filmstrip scroll) stable when ordering doesn't change. + var sameOrder = rawFilesModel.count === filtered.length; + if (sameOrder) { + for (var k = 0; k < filtered.length; k++) { + if (rawFilesModel.get(k).path !== filtered[k].path) { + sameOrder = false; + break; + } + } + } + if (sameOrder) { + for (var m = 0; m < filtered.length; m++) { + var current = rawFilesModel.get(m); + var updated = filtered[m]; + var updatedRating = updated.rating || 0; + if (current.name !== updated.name) rawFilesModel.setProperty(m, "name", updated.name); + if (current.size !== updated.size) rawFilesModel.setProperty(m, "size", updated.size); + if (current.modified !== updated.modified) rawFilesModel.setProperty(m, "modified", updated.modified); + if (current.rating !== updatedRating) rawFilesModel.setProperty(m, "rating", updatedRating); + } + return; + } + + var restoreGeneration = ++window.filmstripRestoreGeneration; + rawFilesModel.clear(); + + for (var j = 0; j < filtered.length; j++) { + var file = filtered[j]; rawFilesModel.append({ "path": file.path, "name": file.name, @@ -115,6 +170,23 @@ Window { // Pre-generate thumbnails thumbnailProvider.generateThumbnailAsync(file.path); } + + if (filmstripList) { + Qt.callLater(function() { + if (restoreGeneration !== window.filmstripRestoreGeneration) + return; + var maxContentX = Math.max(0, filmstripList.contentWidth - filmstripList.width); + filmstripList.contentX = Math.max(0, Math.min(previousFilmstripX, maxContentX)); + + // Apply once more on the next cycle to override delayed ListView relayouts. + Qt.callLater(function() { + if (restoreGeneration !== window.filmstripRestoreGeneration) + return; + var maxContentX2 = Math.max(0, filmstripList.contentWidth - filmstripList.width); + filmstripList.contentX = Math.max(0, Math.min(previousFilmstripX, maxContentX2)); + }); + }); + } } // Function to get all file paths in the model @@ -252,6 +324,8 @@ Window { onSortPropertyChanged: refreshFiles() onSortAscendingChanged: refreshFiles() + onRatingFilterChanged: refreshFiles() + onRatingOperatorChanged: refreshFiles() // Global keyboard shortcuts for rating and navigation Item { @@ -301,8 +375,6 @@ Window { Item { anchors.fill: parent - // Hover area to show topbar in Develop view (if we want it there, but currently topbar is hidden in Develop) - // For now, we disable the hover functionality as requested for Library/Settings. MouseArea { id: topbarHoverArea anchors.top: parent.top @@ -310,9 +382,6 @@ Window { width: viewportContainer.width height: 100 hoverEnabled: true - // Only enabled in Develop view if we want hover-to-show there, - // but the topbar is explicitly hidden in Develop view (visible: ... check). - // So we disable this entirely for now to follow the "removing hover functionality" request. enabled: false onEntered: window.showTopbar = true onExited: { @@ -484,6 +553,7 @@ Window { property real highlights: rawViewport.highlights property real shadows: rawViewport.shadows property real whites: rawViewport.whites + property real sceneWhite: rawViewport.sceneWhite property real blacks: rawViewport.blacks property real adaptation: rawViewport.adaptation property real vibrance: rawViewport.vibrance @@ -813,8 +883,8 @@ Window { flat: true enabled: rawViewport.canUndo opacity: enabled ? 1.0 : 0.3 - ToolTip.visible: hovered - ToolTip.text: "Undo" + T.ToolTip.visible: hovered + T.ToolTip.text: "Undo" display: AbstractButton.IconOnly padding: 0 background: null @@ -832,8 +902,8 @@ Window { flat: true enabled: rawViewport.canRedo opacity: enabled ? 1.0 : 0.3 - ToolTip.visible: hovered - ToolTip.text: "Redo" + T.ToolTip.visible: hovered + T.ToolTip.text: "Redo" display: AbstractButton.IconOnly padding: 0 background: null @@ -851,8 +921,8 @@ Window { flat: true enabled: !rawViewport.isDefault opacity: enabled ? 1.0 : 0.3 - ToolTip.visible: hovered - ToolTip.text: "Restore to Original" + T.ToolTip.visible: hovered + T.ToolTip.text: "Restore to Original" display: AbstractButton.IconOnly padding: 0 background: null @@ -868,8 +938,8 @@ Window { implicitHeight: 24 onClicked: window.showOriginal = !window.showOriginal flat: true - ToolTip.visible: hovered - ToolTip.text: "Before/After (B or \\)" + T.ToolTip.visible: hovered + T.ToolTip.text: "Before/After (B or \\)" display: AbstractButton.IconOnly padding: 0 background: null @@ -1063,7 +1133,7 @@ Window { orientation: ListView.Horizontal spacing: 10 model: rawFilesModel - ScrollBar.horizontal: PhotonScrollBar { orientation: Qt.Horizontal } + T.ScrollBar.horizontal: PhotonScrollBar { orientation: Qt.Horizontal } // Handle mouse wheel for horizontal scrolling MouseArea { @@ -1165,7 +1235,7 @@ Window { } window.contextMenuSourcePath = model.path var p = mapToItem(null, mouse.x, mouse.y) - developContextMenu.openAt(p.x, p.y) + developContextMenu.popup() } } } diff --git a/content/views/DevelopView.qml b/content/views/DevelopView.qml index 515eb17..4f03f52 100644 --- a/content/views/DevelopView.qml +++ b/content/views/DevelopView.qml @@ -199,7 +199,7 @@ Control { ControlGroup { title: "Shadows"; value: root.viewport ? root.viewport.shadows : 0.0; from: -100; to: 100; defaultValue: 0.0; onMoved: (v) => { if(root.viewport) root.viewport.shadows = v }; onReleased: if(root.viewport) root.viewport.commitEdit() } ControlGroup { title: "Whites"; value: root.viewport ? root.viewport.whites : 0.0; from: -100; to: 100; defaultValue: 0.0; onMoved: (v) => { if(root.viewport) root.viewport.whites = v }; onReleased: if(root.viewport) root.viewport.commitEdit() } ControlGroup { title: "Blacks"; value: root.viewport ? root.viewport.blacks : 0.0; from: -100; to: 100; defaultValue: 0.0; onMoved: (v) => { if(root.viewport) root.viewport.blacks = v }; onReleased: if(root.viewport) root.viewport.commitEdit() } - ControlGroup { title: "Adaptation"; value: root.viewport ? root.viewport.adaptation : 9.0; from: 0; to: 100; defaultValue: 9.0; onMoved: (v) => { if(root.viewport) root.viewport.adaptation = v }; onReleased: if(root.viewport) root.viewport.commitEdit() } + //ControlGroup { title: "Adaptation"; value: root.viewport ? root.viewport.adaptation : 9.0; from: 0; to: 100; defaultValue: 9.0; onMoved: (v) => { if(root.viewport) root.viewport.adaptation = v }; onReleased: if(root.viewport) root.viewport.commitEdit() } Rectangle { Layout.fillWidth: true; height: 1; color: "#1A1A1C"; Layout.topMargin: 4; Layout.bottomMargin: 4 } diff --git a/content/views/LibraryView.qml b/content/views/LibraryView.qml index eacd345..0e1de2c 100644 --- a/content/views/LibraryView.qml +++ b/content/views/LibraryView.qml @@ -48,6 +48,7 @@ Control { onRotateLeftRequested: AppState.rotateSelectedLeft("") onFlipHorizontalRequested: AppState.flipSelectedHorizontal("") onFlipVerticalRequested: AppState.flipSelectedVertical("") + onCreatePanoramaRequested: Panorama.stitchAsync(AppState.selectedImages) } // Function to refresh the file list @@ -322,22 +323,6 @@ Control { } } } - - Item { width: 8 } - - // Home button - Button { - icon.source: "qrc:/Main/assets/icons/home.svg" - icon.color: Theme.foreground - icon.width: 20; icon.height: 20 - flat: true - onClicked: AppState.setCurrentView(AppState.ViewState.Welcome) - background: Rectangle { - color: parent.hovered ? Theme.highlight : "transparent" - radius: Theme.radius - } - implicitWidth: 36; implicitHeight: 36 - } } // --- Central Grid --- diff --git a/src/components/RawViewport.cpp b/src/components/RawViewport.cpp index 10458a8..14df740 100644 --- a/src/components/RawViewport.cpp +++ b/src/components/RawViewport.cpp @@ -61,6 +61,10 @@ RawViewport::RawViewport(QQuickItem* parent) : QQuickItem(parent) { emit whitesChanged(); update(); }); + connect(&m_engine, &RawEngine::sceneWhiteChanged, this, [this]() { + emit sceneWhiteChanged(); + update(); + }); connect(&m_engine, &RawEngine::blacksChanged, this, [this]() { emit blacksChanged(); update(); @@ -398,10 +402,10 @@ RawViewport::RawViewport(QQuickItem* parent) : QQuickItem(parent) { } void RawViewport::setSource(const QString& source) { - LogManager::instance()->log(QString("[ RawViewport ] - setSource START: %1").arg(source), "DEBUG"); + LogManager::instance()->log(QString("[ RawViewport ] - setSource START: %1").arg(source), PHOTON_DEBUG); if (m_engine.source() == source) { - LogManager::instance()->log("[ RawViewport ] - setSource: same source, skipping", "DEBUG"); + LogManager::instance()->log("[ RawViewport ] - setSource: same source, skipping", PHOTON_DEBUG); return; } @@ -417,14 +421,14 @@ void RawViewport::setSource(const QString& source) { // This ensures the old image is removed before the new one loads update(); - LogManager::instance()->log("[ RawViewport ] - setSource: calling m_engine.setSource", "DEBUG"); + LogManager::instance()->log("[ RawViewport ] - setSource: calling m_engine.setSource", PHOTON_DEBUG); m_engine.setSource(source); - LogManager::instance()->log("[ RawViewport ] - setSource: emitting sourceChanged", "DEBUG"); + LogManager::instance()->log("[ RawViewport ] - setSource: emitting sourceChanged", PHOTON_DEBUG); emit sourceChanged(); update(); - LogManager::instance()->log("[ RawViewport ] - setSource END", "DEBUG"); + LogManager::instance()->log("[ RawViewport ] - setSource END", PHOTON_DEBUG); } void RawViewport::setExposure(float ev) { @@ -1139,7 +1143,7 @@ QSGNode* RawViewport::updatePaintNode(QSGNode* oldNode, UpdatePaintNodeData*) { .arg(m_zoom, 0, 'f', 3) .arg(m_panOffset.x(), 0, 'f', 2) .arg(m_panOffset.y(), 0, 'f', 2), - "DEBUG"); + PHOTON_DEBUG); } // --- THREAD-SAFE SIGNAL EMISSION --- diff --git a/src/components/RawViewport.frag b/src/components/RawViewport.frag index 064fcca..f60fb76 100644 --- a/src/components/RawViewport.frag +++ b/src/components/RawViewport.frag @@ -14,6 +14,7 @@ layout(std140, binding = 0) uniform buf { float highlights; float shadows; float whites; + float sceneWhite; float blacks; float adaptation; float vibrance; @@ -247,6 +248,74 @@ vec3 apply_white_balance(vec3 color, float temp, float tnt) { return color * temp_mult * tint_mult; } +vec3 sample_source_linear(vec2 uv) { + return srgb_to_linear(texture(source, uv).rgb); +} + +vec3 compute_fine_blur(vec2 uv, vec2 texelSize) { + const float r1 = 1.5; + const float r2 = 3.0; + + vec3 b = sample_source_linear(uv) * 0.18; + + b += sample_source_linear(uv + vec2( r1, 0.0) * texelSize) * 0.095; + b += sample_source_linear(uv + vec2(-r1, 0.0) * texelSize) * 0.095; + b += sample_source_linear(uv + vec2(0.0, r1) * texelSize) * 0.095; + b += sample_source_linear(uv + vec2(0.0, -r1) * texelSize) * 0.095; + b += sample_source_linear(uv + vec2( r1, r1) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2(-r1, r1) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2( r1, -r1) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2(-r1, -r1) * texelSize) * 0.055; + + b += sample_source_linear(uv + vec2( r2, 0.0) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2(-r2, 0.0) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2(0.0, r2) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2(0.0, -r2) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2( r2, r2) * texelSize) * 0.015; + b += sample_source_linear(uv + vec2(-r2, r2) * texelSize) * 0.015; + b += sample_source_linear(uv + vec2( r2, -r2) * texelSize) * 0.015; + b += sample_source_linear(uv + vec2(-r2, -r2) * texelSize) * 0.015; + + return b; +} + +vec3 compute_coarse_blur(vec2 uv, vec2 texelSize) { + const float r1 = 4.5; + const float r2 = 7.0; + const float r3 = 9.5; + + vec3 b = sample_source_linear(uv) * 0.20; + + b += sample_source_linear(uv + vec2( r1, 0.0) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2(-r1, 0.0) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2(0.0, r1) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2(0.0, -r1) * texelSize) * 0.055; + b += sample_source_linear(uv + vec2( r1, r1) * texelSize) * 0.038; + b += sample_source_linear(uv + vec2(-r1, r1) * texelSize) * 0.038; + b += sample_source_linear(uv + vec2( r1, -r1) * texelSize) * 0.038; + b += sample_source_linear(uv + vec2(-r1, -r1) * texelSize) * 0.038; + + b += sample_source_linear(uv + vec2( r2, 0.0) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2(-r2, 0.0) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2(0.0, r2) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2(0.0, -r2) * texelSize) * 0.04; + b += sample_source_linear(uv + vec2( r2, r2) * texelSize) * 0.03; + b += sample_source_linear(uv + vec2(-r2, r2) * texelSize) * 0.03; + b += sample_source_linear(uv + vec2( r2, -r2) * texelSize) * 0.03; + b += sample_source_linear(uv + vec2(-r2, -r2) * texelSize) * 0.03; + + b += sample_source_linear(uv + vec2( r3, 0.0) * texelSize) * 0.022; + b += sample_source_linear(uv + vec2(-r3, 0.0) * texelSize) * 0.022; + b += sample_source_linear(uv + vec2(0.0, r3) * texelSize) * 0.022; + b += sample_source_linear(uv + vec2(0.0, -r3) * texelSize) * 0.022; + b += sample_source_linear(uv + vec2( r3, r3) * texelSize) * 0.015; + b += sample_source_linear(uv + vec2(-r3, r3) * texelSize) * 0.015; + b += sample_source_linear(uv + vec2( r3, -r3) * texelSize) * 0.015; + b += sample_source_linear(uv + vec2(-r3, -r3) * texelSize) * 0.015; + + return b; +} + // --- Local Contrast, Clarity, Dehaze & Centre (Ported from RapidRAW) --- vec3 apply_local_contrast(vec3 color_linear, vec3 blurred_linear, float amount, int mode) { @@ -351,28 +420,211 @@ float get_hsl_influence(float hue, float center, float width) { } float compute_target_luma(float luma, float stops) { + if (stops == 0.0) return luma; float target = luma * pow(2.0, stops); + + // Symmetrical soft-clipping for HSL to prevent "blowing out" + // while maintaining a more linear response than the specialized shoulder function. + if (target > 1.0) { + float over = target - 1.0; + target = 1.0 + over / (1.0 + over * 1.25); + } + return max(target, 0.0); +} + +float compute_toe_target(float luma, float stops) { + if (stops == 0.0) return luma; + + // Multiplicative base + float target = luma * pow(2.0, stops); + if (stops > 0.0) { - // Compress brightening to avoid harsh clipping artifacts. - float over = max(target - 1.0, 0.0); - if (over > 0.0) { - float shoulder = 1.2 + 3.0 * clamp(stops, 0.0, 1.0); - target = 1.0 + over / (1.0 + over * shoulder); - } + // Soft Gamma Lift (Prevents Posterization) + // A power curve is much smoother than a linear lift for deep darks. + float liftGamma = 1.0 / (1.0 + stops * 0.5); + float liftTarget = pow(max(luma, 1e-6), liftGamma); + + // Only apply the gamma lift to the bottom 15% of the range + float toeMask = 1.0 - smoothstep(0.0, 0.15, luma); + target = mix(target, liftTarget, toeMask * 0.4); } + return max(target, 0.0); } + vec3 apply_luma_target(vec3 color, float lumaIn, float targetLuma) { - targetLuma = max(targetLuma, 0.0); - float safeLuma = max(lumaIn, 1e-4); - float lumaDelta = targetLuma - lumaIn; + targetLuma = max(targetLuma, 0.0); + float safeLuma = max(lumaIn, 1e-4); float lumaRatio = targetLuma / safeLuma; - // Additive in deep shadows, multiplicative in mids/highlights. - float blend = smoothstep(0.02, 0.34, lumaIn); - vec3 additive = color + vec3(lumaDelta); - vec3 multiplicative = color * lumaRatio; - return mix(additive, multiplicative, blend); + + // --- Noise Floor Protection --- + // Cap the lift ratio in deep blacks to prevent noise/posterization. + // 1.0x cap at pure black, scaling up to 10.0x at 0.08 luma. + float maxRatio = 1.0 + 9.0 * smoothstep(0.0, 0.08, lumaIn); + float safeRatio = clamp(lumaRatio, 0.0, maxRatio); + + // --- Pure Multiplicative Adjustment --- + // Scaling R, G, and B equally preserves Hue and Saturation + return color * safeRatio; +} + +const float PV_FLARE_LINEAR = 0.000244140625; // 2^-12 +const float PV_FLARE_LOG = -12.0; +const float PV_EPS = 0.00000190734; + +const mat3 RGB_TO_PROPHOTO = mat3( + 0.529285, 0.098394, 0.016823, + 0.330046, 0.873493, 0.117671, + 0.140669, 0.028113, 0.865506 +); + +const vec3 PROPHOTO_LUMA_WEIGHTS = vec3(0.25, 0.5, 0.25); + +vec3 eval_undo_render_curve(vec3 col) { + vec2 fMinMax; + vec2 nMinMax; + const float eps = 0.00001; + + fMinMax.x = min(min(col.r, col.g), col.b); + fMinMax.y = max(max(col.r, col.g), col.b); + + vec2 t = pow(fMinMax, vec2(3.14453125)); + nMinMax = pow(fMinMax, vec2(0.8125)) * 0.3828125 * (1.0 - t) + + (1.0 - pow(1.0 - fMinMax, vec2(0.69140625))) * t; + + fMinMax.y = (nMinMax.y - nMinMax.x) / (fMinMax.y - fMinMax.x + eps); + return (col - fMinMax.x) * fMinMax.y + nMinMax.x; +} + +float pv_working_luma_linear(vec3 c) { + vec3 prophoto = RGB_TO_PROPHOTO * clamp(c, 0.0001, 0.999); + vec3 unmapped = clamp(eval_undo_render_curve(prophoto), 0.0, 1.0); + return max(dot(unmapped, PROPHOTO_LUMA_WEIGHTS), PV_EPS); +} + +float pv_encode_log_luma(float linearLuma) { + return log2(max(linearLuma + PV_FLARE_LINEAR, PV_EPS)); +} + +float pv_decode_log_luma(float logLuma) { + return max(exp2(logLuma) - PV_FLARE_LINEAR, PV_EPS); +} + +vec2 endpoint_pin_mask(vec2 x) { + x = clamp(x, 0.0, 1.0); + vec2 invX = 1.0 - x; + vec2 inv2 = invX * invX; + vec2 inv4 = inv2 * inv2; + vec2 inv8 = inv4 * inv4; + vec2 inv16 = inv8 * inv8; + vec2 base = 1.0 - inv8; + vec2 strong = 1.0 - inv16; + return mix(base, strong, smoothstep(vec2(0.35), vec2(1.0), x)); +} + +float pv_log_luma(vec3 c) { + return pv_encode_log_luma(pv_working_luma_linear(c)); +} + +float pv_tent_weight(float value, float center, float halfWidth) { + return max(1.0 - abs(value - center) / max(halfWidth, PV_EPS), 0.0); +} + +vec3 apply_photon0001_tone_ranges( + vec3 color, + vec3 blurredFine, + vec3 blurredCoarse, + float highlightsAmt, + float shadowsAmt, + float whitesAmt, + float blacksAmt, + float clarityAmt, + float sceneWhiteNorm +) { + float srcGrayLinear = pv_working_luma_linear(color); + float srcGrayLog = pv_encode_log_luma(srcGrayLinear); + float blurFineLog = pv_log_luma(blurredFine); + float blurCoarseLog = pv_log_luma(blurredCoarse); + float toneMid = pv_encode_log_luma(max(sceneWhiteNorm * 0.18, PV_EPS)); + + // Approximation of tonal windows in log-space (black -> shadow -> mid -> highlight -> white). + // Moving the center (toneMid - center) changes the tonal range of action, + // while moving the width (second param), changes the overlap with other tones. + float wBlacks = pv_tent_weight(srcGrayLog, toneMid - 3.8, 1.8); + float wShadows = pv_tent_weight(srcGrayLog, toneMid - 1.9, 1.9); + float wHighlights = pv_tent_weight(srcGrayLog, toneMid + 1.0, 1.9); + float wWhites = pv_tent_weight(srcGrayLog, toneMid + 3.1, 2.2); + + // Stronger single-pass 2-scale local mask proxy (fine + coarse residuals). + float maskFine = clamp(srcGrayLog - blurFineLog, -2.0, 2.0); + float maskCoarse = clamp(blurFineLog - blurCoarseLog, -2.0, 2.0); + float mask = clamp(maskFine * 0.70 + maskCoarse * 0.45, -2.5, 2.5); + + float partSwitch = step(srcGrayLog, toneMid); + float compressedLow = toneMid + (srcGrayLog - toneMid) * 0.78; + float compressedHigh = toneMid + (srcGrayLog - toneMid) * 0.58; + float baseCompressed = mix(compressedHigh, compressedLow, partSwitch); + + float localContrastSignal = srcGrayLog + mask - baseCompressed; + localContrastSignal *= max(clarityAmt, 0.0); + localContrastSignal *= clamp(1.0 + 0.35 * (-highlightsAmt + shadowsAmt), 1.0, 2.0); + vec2 localContrastSignal2 = vec2(max(localContrastSignal, 0.0), min(localContrastSignal, 0.0)); + + vec2 lumWeight = vec2( + clamp(wHighlights + 0.6 * wWhites, 0.0, 1.0), + clamp(wShadows + 0.6 * wBlacks, 0.0, 1.0) + ); + vec2 endpointStrength = clamp( + vec2(abs(highlightsAmt) + 0.35 * abs(whitesAmt), abs(shadowsAmt) + 0.35 * abs(blacksAmt)), + 0.0, + 1.0 + ); + vec2 claritySHPinMask = mix(endpoint_pin_mask(lumWeight), vec2(1.0), endpointStrength * endpointStrength); + + vec2 hsPinMask; + hsPinMask.y = mix(0.5 + 0.5 * max(1.0 - sign(shadowsAmt), 0.0), 1.0, claritySHPinMask.x); + hsPinMask.x = mix(1.0, 0.5, (1.0 - claritySHPinMask.y) * max(-sign(highlightsAmt), 0.0)); + hsPinMask.x = mix(1.0, hsPinMask.x, clamp(abs(highlightsAmt), 0.0, 1.0)); + + float maxAbsHS = max(max(abs(highlightsAmt), abs(shadowsAmt)), PV_EPS); + float baseOffset = 0.85 * (highlightsAmt + shadowsAmt) / maxAbsHS; + vec2 offsetHS = vec2(wHighlights, wShadows) * vec2(abs(highlightsAmt), abs(shadowsAmt)) * baseOffset; + vec2 deltaHS = vec2(-highlightsAmt, shadowsAmt); + deltaHS = clamp(deltaHS, -1.0, 1.0); + deltaHS *= vec2(min(mask, 0.0), max(mask, 0.0)); + deltaHS += offsetHS; + + float deltaStops = dot(deltaHS, hsPinMask); + deltaStops += whitesAmt * wWhites * hsPinMask.x; + deltaStops += blacksAmt * wBlacks * hsPinMask.y; + deltaStops += dot(localContrastSignal2, claritySHPinMask); + + float deltaSign = sign(deltaStops); + float flareSwitch = 1.0 - max(deltaSign, 0.0); + float zeroSwitch = 1.0 - abs(deltaSign); + float flare = flareSwitch * PV_FLARE_LOG; + float startpoint = flare - (deltaStops + deltaStops); + float t1 = step(startpoint, srcGrayLog); + float t2 = step(srcGrayLog, startpoint); + float t = clamp((srcGrayLog - startpoint) / (flare - startpoint + zeroSwitch), 0.0, 1.0); + t *= t * (1.0 - mix(t2, t1, flareSwitch)); + deltaStops = mix(deltaStops, 0.0, t); + + // Analogous to ToneMapLimitShadowGain path (max +4 stops lift). + deltaStops = min(deltaStops, 4.0); + + float targetLog = srcGrayLog + deltaStops; + float targetLuma = pv_decode_log_luma(targetLog); + + if (targetLuma > sceneWhiteNorm && deltaStops > 0.0) { + float over = targetLuma - sceneWhiteNorm; + float knee = max(sceneWhiteNorm * 0.7, PV_EPS); + float compress = over / (1.0 + over / knee); + targetLuma = sceneWhiteNorm + compress; + } + + return apply_luma_target(color, srcGrayLinear, targetLuma); } float sample_tone_lut_channel(float value, int channel) { @@ -579,24 +831,11 @@ void main() color = apply_gpu_denoise(color, qt_TexCoord0, source, ubuf.denoiseAmount); // --- Approximated Blur for Local Contrast (Clarity, Structure, Sharpness) --- - // Dual-radius multi-tap blur for effective unsharp mask on denoised images. + // Stronger two-scale blur with more taps to better emulate a single-pass local pyramid mask. vec2 texelSize = 1.0 / ubuf.sourceSize; - // Inner ring (1.5 texels) — fine detail - vec3 blurred = color * 0.12; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(1.5, 1.5) * texelSize).rgb) * 0.07; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(-1.5, -1.5) * texelSize).rgb) * 0.07; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(1.5, -1.5) * texelSize).rgb) * 0.07; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(-1.5, 1.5) * texelSize).rgb) * 0.07; - // Outer axis ring (6.0 texels) — captures mid-frequency on denoised images - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(6.0, 0.0) * texelSize).rgb) * 0.08; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(-6.0, 0.0) * texelSize).rgb) * 0.08; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(0.0, 6.0) * texelSize).rgb) * 0.08; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(0.0, -6.0) * texelSize).rgb) * 0.08; - // Outer diagonal ring (4.5 texels) - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(4.5, 4.5) * texelSize).rgb) * 0.07; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(-4.5, -4.5) * texelSize).rgb) * 0.07; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(4.5, -4.5) * texelSize).rgb) * 0.07; - blurred += srgb_to_linear(texture(source, qt_TexCoord0 + vec2(-4.5, 4.5) * texelSize).rgb) * 0.07; + vec3 blurredFine = compute_fine_blur(qt_TexCoord0, texelSize); + vec3 blurredCoarse = compute_coarse_blur(qt_TexCoord0, texelSize); + vec3 blurred = mix(blurredFine, blurredCoarse, 0.35); // Compute edge mask with feathering, then gate by focus detection float edgeMask = compute_edge_mask(source, qt_TexCoord0, texelSize, ubuf.sharpenMask, ubuf.maskFeather); @@ -614,9 +853,6 @@ void main() // Blend sharpened vs original using combined mask color = mix(preSharp, color, finalMask); - // Apply Clarity (Mode 1) - color = apply_local_contrast(color, blurred, ubuf.clarity / 100.0, 1); - // Apply Structure (Mode 1, but with different scaling/interpretation if needed) color = apply_local_contrast(color, blurred, ubuf.structure / 100.0, 1); @@ -630,46 +866,37 @@ void main() color = apply_white_balance(color, ubuf.temperature / 100.0, ubuf.tint / 100.0); // 2. Exposure - color *= pow(2.0, ubuf.exposure); - - color = davinci_tonemap(color, ubuf.adaptation); + float exposure = pow(2.0, ubuf.exposure); + + color *= exposure; + float luma = get_luma(max(color, 0.0)); + if (luma > ubuf.sceneWhite && ubuf.exposure > 0.0) { + float over = luma - ubuf.sceneWhite; + // The higher the shoulder, the less the highlights get compressed + float knee = ubuf.sceneWhite * 0.7; // shoulder width + float compress = over / (1.0 + over / knee); // Reinhard-style on the excess + float targetL = ubuf.sceneWhite + compress; + color = apply_luma_target(color, luma, targetL); + } + float sceneWhiteNorm = max(ubuf.sceneWhite * exposure, 1e-4); + vec3 blurredFineTone = apply_white_balance(blurredFine, ubuf.temperature / 100.0, ubuf.tint / 100.0) * exposure; + vec3 blurredCoarseTone = apply_white_balance(blurredCoarse, ubuf.temperature / 100.0, ubuf.tint / 100.0) * exposure; + color = apply_photon0001_tone_ranges( + color, + blurredFineTone, + blurredCoarseTone, + ubuf.highlights / 100.0, + ubuf.shadows / 100.0, + ubuf.whites / 100.0, + ubuf.blacks / 100.0, + ubuf.clarity / 100.0, + sceneWhiteNorm + ); + //color = davinci_tonemap(color, ubuf.adaptation); // 3. Contrast color = max(vec3(0.0), color); color = pow(color, vec3(ubuf.contrast)); - - // 4. Whites & Blacks (smoother masks, bounded response) - float luma = get_luma(max(color, 0.0)); - if (ubuf.whites != 0.0) { - float w = clamp(ubuf.whites / 100.0, -1.0, 1.0); - float whiteMask = smoothstep(0.42, 1.20, luma); - float targetLuma = compute_target_luma(luma, w * 0.85 * whiteMask); - color = apply_luma_target(color, luma, targetLuma); - luma = get_luma(max(color, 0.0)); - } - if (ubuf.blacks != 0.0) { - float bAdj = clamp(ubuf.blacks / 100.0, -1.0, 1.0); - float blackMask = 1.0 - smoothstep(0.0, 0.48, luma); - float targetLuma = compute_target_luma(luma, bAdj * 0.90 * blackMask); - color = apply_luma_target(color, luma, targetLuma); - luma = get_luma(max(color, 0.0)); - } - - // 5. Highlights & Shadows (broader crossover, gentler extremes) - if (ubuf.shadows != 0.0) { - float s = clamp(ubuf.shadows / 100.0, -1.0, 1.0); - float shadowMask = 1.0 - smoothstep(0.05, 0.62, luma); - float targetLuma = compute_target_luma(luma, s * 0.95 * shadowMask); - color = apply_luma_target(color, luma, targetLuma); - luma = get_luma(max(color, 0.0)); - } - - if (ubuf.highlights != 0.0) { - float h = clamp(ubuf.highlights / 100.0, -1.0, 1.0); - float highlightMask = smoothstep(0.22, 1.25, luma); - float targetLuma = compute_target_luma(luma, h * 0.90 * highlightMask); - color = apply_luma_target(color, luma, targetLuma); - } // --- HSL PANEL --- vec3 hsv = rgb_to_hsv(color); @@ -788,3 +1015,4 @@ void main() fragColor = vec4(clamp(final_rgb, 0.0, 1.0), tex.a) * ubuf.qt_Opacity; } + diff --git a/src/components/RawViewport.h b/src/components/RawViewport.h index df84c79..7192635 100644 --- a/src/components/RawViewport.h +++ b/src/components/RawViewport.h @@ -21,6 +21,7 @@ class RawViewport : public QQuickItem { highlightsChanged) Q_PROPERTY(float shadows READ shadows WRITE setShadows NOTIFY shadowsChanged) Q_PROPERTY(float whites READ whites WRITE setWhites NOTIFY whitesChanged) + Q_PROPERTY(float sceneWhite READ sceneWhite NOTIFY sceneWhiteChanged) Q_PROPERTY(float blacks READ blacks WRITE setBlacks NOTIFY blacksChanged) Q_PROPERTY(float adaptation READ adaptation WRITE setAdaptation NOTIFY adaptationChanged) Q_PROPERTY( @@ -232,6 +233,8 @@ class RawViewport : public QQuickItem { float whites() const { return m_engine.whites(); } void setWhites(float val); + float sceneWhite() const { return m_engine.sceneWhite(); } + float blacks() const { return m_engine.blacks(); } void setBlacks(float val); @@ -488,6 +491,7 @@ class RawViewport : public QQuickItem { void highlightsChanged(); void shadowsChanged(); void whitesChanged(); + void sceneWhiteChanged(); void blacksChanged(); void adaptationChanged(); void vibranceChanged(); diff --git a/src/engine/GpuChromaFilter.cpp b/src/engine/GpuChromaFilter.cpp index 8a2308c..6801534 100644 --- a/src/engine/GpuChromaFilter.cpp +++ b/src/engine/GpuChromaFilter.cpp @@ -432,12 +432,12 @@ bool GpuChromaFilter::run(const float* guide, const float* input, QString("[ GpuChromaFilter ] - Starting GPU guided filter %1x%2") .arg(width) .arg(height), - "INFO"); + PHOTON_INFO); GpuResources res{}; if (!createResources(res, width, height)) { LogManager::instance()->log( - "[ GpuChromaFilter ] - Failed to create GPU resources", "ERROR"); + "[ GpuChromaFilter ] - Failed to create GPU resources", PHOTON_ERROR); destroyResources(res); return false; } @@ -447,7 +447,7 @@ bool GpuChromaFilter::run(const float* guide, const float* input, if (!uploadBuffer(res, BUF_GUIDE, guide, bufSize) || !uploadBuffer(res, BUF_INPUT, input, bufSize)) { LogManager::instance()->log( - "[ GpuChromaFilter ] - Failed to upload data to GPU", "ERROR"); + "[ GpuChromaFilter ] - Failed to upload data to GPU", PHOTON_ERROR); destroyResources(res); return false; } @@ -485,7 +485,7 @@ bool GpuChromaFilter::run(const float* guide, const float* input, // After 3 passes + swaps, result is in the buffer pointed to by inputBuf if (!readbackBuffer(res, inputBuf, output, bufSize)) { LogManager::instance()->log( - "[ GpuChromaFilter ] - Failed to read back GPU results", "ERROR"); + "[ GpuChromaFilter ] - Failed to read back GPU results", PHOTON_ERROR); destroyResources(res); return false; } @@ -493,7 +493,7 @@ bool GpuChromaFilter::run(const float* guide, const float* input, destroyResources(res); LogManager::instance()->log( - "[ GpuChromaFilter ] - GPU guided filter complete", "INFO"); + "[ GpuChromaFilter ] - GPU guided filter complete", PHOTON_INFO); return true; } diff --git a/src/engine/GpuSearcher.cpp b/src/engine/GpuSearcher.cpp index 2085da1..c0aa10c 100644 --- a/src/engine/GpuSearcher.cpp +++ b/src/engine/GpuSearcher.cpp @@ -19,7 +19,7 @@ GpuSearcher::~GpuSearcher() { std::vector GpuSearcher::runSearch( const float* luma, int width, int height, int searchWindow) { - + auto* ctx = VulkanComputeContext::instance(); if (ctx->device() == VK_NULL_HANDLE) return {}; @@ -29,7 +29,7 @@ std::vector GpuSearcher::runSearch( const auto& f = ctx->functions(); VkDevice device = ctx->device(); - LogManager::instance()->log(QString("[ GpuSearcher ] - Starting raw Vulkan search %1x%2").arg(width).arg(height), "INFO"); + LogManager::instance()->log(QString("[ GpuSearcher ] - Starting raw Vulkan search %1x%2").arg(width).arg(height), PHOTON_INFO); // 1. Create Resources VkBuffer lumaBuffer = VK_NULL_HANDLE, resultBuffer = VK_NULL_HANDLE; @@ -40,20 +40,20 @@ std::vector GpuSearcher::runSearch( ctx->createBuffer(lumaSize, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, lumaBuffer, lumaMemory); - + ctx->createBuffer(resultSize, VK_BUFFER_USAGE_STORAGE_BUFFER_BIT | VK_BUFFER_USAGE_TRANSFER_SRC_BIT, VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT, resultBuffer, resultMemory); if (lumaBuffer == VK_NULL_HANDLE || resultBuffer == VK_NULL_HANDLE) { - LogManager::instance()->log("Failed to create Vulkan buffers for search", "ERROR"); + LogManager::instance()->log("Failed to create Vulkan buffers for search", PHOTON_ERROR); return {}; } // Upload Luma void* dataPtr = nullptr; if (f.MapMemory(device, lumaMemory, 0, lumaSize, 0, &dataPtr) != VK_SUCCESS) { - LogManager::instance()->log("Failed to map luma memory", "ERROR"); + LogManager::instance()->log("Failed to map luma memory", PHOTON_ERROR); return {}; } memcpy(dataPtr, luma, lumaSize); @@ -78,7 +78,7 @@ std::vector GpuSearcher::runSearch( VkDescriptorSetLayout descriptorSetLayout = VK_NULL_HANDLE; if (f.CreateDescriptorSetLayout(device, &layoutInfo, nullptr, &descriptorSetLayout) != VK_SUCCESS) { - LogManager::instance()->log("Failed to create descriptor set layout", "ERROR"); + LogManager::instance()->log("Failed to create descriptor set layout", PHOTON_ERROR); return {}; } @@ -94,7 +94,7 @@ std::vector GpuSearcher::runSearch( VkDescriptorPool descriptorPool = VK_NULL_HANDLE; if (f.CreateDescriptorPool(device, &poolInfo, nullptr, &descriptorPool) != VK_SUCCESS) { - LogManager::instance()->log("Failed to create descriptor pool", "ERROR"); + LogManager::instance()->log("Failed to create descriptor pool", PHOTON_ERROR); return {}; } @@ -106,7 +106,7 @@ std::vector GpuSearcher::runSearch( VkDescriptorSet descriptorSet = VK_NULL_HANDLE; if (f.AllocateDescriptorSets(device, &allocInfo, &descriptorSet) != VK_SUCCESS) { - LogManager::instance()->log("Failed to allocate descriptor set", "ERROR"); + LogManager::instance()->log("Failed to allocate descriptor set", PHOTON_ERROR); return {}; } @@ -145,16 +145,16 @@ std::vector GpuSearcher::runSearch( VkPipelineLayout pipelineLayout = VK_NULL_HANDLE; if (f.CreatePipelineLayout(device, &pipelineLayoutInfo, nullptr, &pipelineLayout) != VK_SUCCESS) { - LogManager::instance()->log("Failed to create pipeline layout", "ERROR"); + LogManager::instance()->log("Failed to create pipeline layout", PHOTON_ERROR); return {}; } QFile shaderFile(":/Main/shaders/patch_search.comp.qsb"); if (!shaderFile.open(QIODevice::ReadOnly)) { - LogManager::instance()->log("Failed to open shader resource", "ERROR"); + LogManager::instance()->log("Failed to open shader resource", PHOTON_ERROR); return {}; } - + QShader shader = QShader::fromSerialized(shaderFile.readAll()); QByteArray spirvCode; auto shaders = shader.availableShaders(); @@ -166,14 +166,14 @@ std::vector GpuSearcher::runSearch( } if (spirvCode.isEmpty()) { - LogManager::instance()->log("Failed to extract SPIR-V from shader", "ERROR"); + LogManager::instance()->log("Failed to extract SPIR-V from shader", PHOTON_ERROR); return {}; } // Ensure alignment by copying to vector std::vector code(spirvCode.size() / 4); memcpy(code.data(), spirvCode.constData(), spirvCode.size()); - + VkShaderModuleCreateInfo shaderModuleCreateInfo{}; shaderModuleCreateInfo.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; shaderModuleCreateInfo.codeSize = code.size() * 4; @@ -181,7 +181,7 @@ std::vector GpuSearcher::runSearch( VkShaderModule computeShaderModule = VK_NULL_HANDLE; if (f.CreateShaderModule(device, &shaderModuleCreateInfo, nullptr, &computeShaderModule) != VK_SUCCESS) { - LogManager::instance()->log("Failed to create shader module", "ERROR"); + LogManager::instance()->log("Failed to create shader module", PHOTON_ERROR); return {}; } @@ -195,7 +195,7 @@ std::vector GpuSearcher::runSearch( VkPipeline pipeline = VK_NULL_HANDLE; if (f.CreateComputePipelines(device, VK_NULL_HANDLE, 1, &pipelineInfo, nullptr, &pipeline) != VK_SUCCESS) { - LogManager::instance()->log("Failed to create compute pipeline", "ERROR"); + LogManager::instance()->log("Failed to create compute pipeline", PHOTON_ERROR); return {}; } @@ -204,7 +204,7 @@ std::vector GpuSearcher::runSearch( if (cb != VK_NULL_HANDLE) { f.CmdBindPipeline(cb, VK_PIPELINE_BIND_POINT_COMPUTE, pipeline); f.CmdBindDescriptorSets(cb, VK_PIPELINE_BIND_POINT_COMPUTE, pipelineLayout, 0, 1, &descriptorSet, 0, nullptr); - + int pcs[3] = {width, height, searchWindow}; f.CmdPushConstants(cb, pipelineLayout, VK_SHADER_STAGE_COMPUTE_BIT, 0, 12, pcs); @@ -221,7 +221,7 @@ std::vector GpuSearcher::runSearch( } f.UnmapMemory(device, resultMemory); } else { - LogManager::instance()->log("Failed to map result memory for readback", "ERROR"); + LogManager::instance()->log("Failed to map result memory for readback", PHOTON_ERROR); } // 6. Cleanup diff --git a/src/engine/ImageDeveloper.cpp b/src/engine/ImageDeveloper.cpp index a15f436..81f2242 100644 --- a/src/engine/ImageDeveloper.cpp +++ b/src/engine/ImageDeveloper.cpp @@ -1,20 +1,26 @@ #include "ImageDeveloper.h" +#include +#include #include #include #include #include #include #include -#include #include #include +#include #include #include "../managers/LogManager.h" #include "Denoiser.h" #include "GpuSearcher.h" +#if defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) +#include +#endif + namespace photon { // ... (rest of colorspace math) @@ -66,8 +72,9 @@ static void davinci_tonemap(float& r, float& g, float& b, float adaptation) { const float input_white = 16.0f; const float output_white = 1.0f; - float bv = (input_white - (adaptation / 100.0f) * (input_white / output_white)) - / ((input_white / output_white) - 1.0f); + float bv = + (input_white - (adaptation / 100.0f) * (input_white / output_white)) / + ((input_white / output_white) - 1.0f); float a = output_white / (input_white / (input_white + bv)); r = std::min(r, input_white); @@ -143,14 +150,23 @@ static float smoothstep_local(float edge0, float edge1, float x) { static float mix_local(float a, float b, float t) { return a + t * (b - a); } static float compute_target_luma_cpp(float luma, float stops) { + if (stops == 0.0f) return luma; + float target = luma * std::pow(2.0f, stops); + if (target > 1.0f) { + float over = target - 1.0f; + target = 1.0f + over / (1.0f + over * 1.25f); + } + return std::max(target, 0.0f); +} + +static float compute_toe_target_cpp(float luma, float stops) { + if (stops == 0.0f) return luma; float target = luma * std::pow(2.0f, stops); if (stops > 0.0f) { - // Compress brightening to avoid harsh clipping artifacts. - float over = std::max(target - 1.0f, 0.0f); - if (over > 0.0f) { - float shoulder = 1.2f + 3.0f * std::clamp(stops, 0.0f, 1.0f); - target = 1.0f + over / (1.0f + over * shoulder); - } + float liftGamma = 1.0f / (1.0f + stops * 0.5f); + float liftTarget = std::pow(std::max(luma, 1e-6f), liftGamma); + float toeMask = 1.0f - smoothstep_local(0.0f, 0.15f, luma); + target = mix_local(target, liftTarget, toeMask * 0.4f); } return std::max(target, 0.0f); } @@ -159,20 +175,372 @@ static void apply_luma_target_cpp(float& r, float& g, float& b, float lumaIn, float targetLuma) { targetLuma = std::max(targetLuma, 0.0f); float safeLuma = std::max(lumaIn, 1e-4f); - float lumaDelta = targetLuma - lumaIn; float lumaRatio = targetLuma / safeLuma; - // Additive in deep shadows, multiplicative in mids/highlights. - float blend = smoothstep_local(0.02f, 0.34f, lumaIn); - float addR = r + lumaDelta; - float addG = g + lumaDelta; - float addB = b + lumaDelta; - r = mix_local(addR, r * lumaRatio, blend); - g = mix_local(addG, g * lumaRatio, blend); - b = mix_local(addB, b * lumaRatio, blend); + float maxRatio = 1.0f + 9.0f * smoothstep_local(0.0f, 0.08f, lumaIn); + float safeRatio = std::clamp(lumaRatio, 0.0f, maxRatio); + r *= safeRatio; + g *= safeRatio; + b *= safeRatio; +} + +struct Vec3fCpp { + float r; + float g; + float b; +}; + +static const std::array& srgb16_to_linear_lut_cpp() { + static const std::array lut = [] { + std::array v{}; + for (size_t i = 0; i < v.size(); ++i) { + v[i] = srgb_to_linear_f(static_cast(i) / 65535.0f); + } + return v; + }(); + return lut; +} + +static float step_local(float edge, float x) { return x < edge ? 0.0f : 1.0f; } + +static float sign_local(float x) { + if (x > 0.0f) return 1.0f; + if (x < 0.0f) return -1.0f; + return 0.0f; +} + +static Vec3fCpp clamp_vec3_cpp(const Vec3fCpp& c, float lo, float hi) { + return {std::clamp(c.r, lo, hi), std::clamp(c.g, lo, hi), + std::clamp(c.b, lo, hi)}; +} + +static Vec3fCpp sample_source_linear_bilinear_cpp(const ushort* src, int width, + int height, float u, float v, + const float* srgb16ToLinear) { + u = std::clamp(u, 0.0f, 1.0f); + v = std::clamp(v, 0.0f, 1.0f); + + const float xf = u * float(width) - 0.5f; + const float yf = v * float(height) - 0.5f; + const int x0 = int(std::floor(xf)); + const int y0 = int(std::floor(yf)); + const int x1 = x0 + 1; + const int y1 = y0 + 1; + const float tx = xf - float(x0); + const float ty = yf - float(y0); + + auto sample_texel = [src, width, height, + srgb16ToLinear](int x, int y) -> Vec3fCpp { + x = std::clamp(x, 0, width - 1); + y = std::clamp(y, 0, height - 1); + const int idx = (y * width + x) * 3; + return {srgb16ToLinear[src[idx]], srgb16ToLinear[src[idx + 1]], + srgb16ToLinear[src[idx + 2]]}; + }; + +#if defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) + auto sample_texel_sse = [src, width, height, + srgb16ToLinear](int x, int y) -> __m128 { + x = std::clamp(x, 0, width - 1); + y = std::clamp(y, 0, height - 1); + const int idx = (y * width + x) * 3; + return _mm_set_ps(0.0f, srgb16ToLinear[src[idx + 2]], + srgb16ToLinear[src[idx + 1]], srgb16ToLinear[src[idx]]); + }; + + const __m128 c00 = sample_texel_sse(x0, y0); + const __m128 c10 = sample_texel_sse(x1, y0); + const __m128 c01 = sample_texel_sse(x0, y1); + const __m128 c11 = sample_texel_sse(x1, y1); + + const float oneMinusTx = 1.0f - tx; + const float oneMinusTy = 1.0f - ty; + const float w00 = oneMinusTx * oneMinusTy; + const float w10 = tx * oneMinusTy; + const float w01 = oneMinusTx * ty; + const float w11 = tx * ty; + + __m128 out = _mm_setzero_ps(); + out = _mm_add_ps(out, _mm_mul_ps(c00, _mm_set1_ps(w00))); + out = _mm_add_ps(out, _mm_mul_ps(c10, _mm_set1_ps(w10))); + out = _mm_add_ps(out, _mm_mul_ps(c01, _mm_set1_ps(w01))); + out = _mm_add_ps(out, _mm_mul_ps(c11, _mm_set1_ps(w11))); + + float packed[4]; + _mm_storeu_ps(packed, out); + return {packed[0], packed[1], packed[2]}; +#else + const Vec3fCpp c00 = sample_texel(x0, y0); + const Vec3fCpp c10 = sample_texel(x1, y0); + const Vec3fCpp c01 = sample_texel(x0, y1); + const Vec3fCpp c11 = sample_texel(x1, y1); + + Vec3fCpp out{}; + out.r = mix_local(mix_local(c00.r, c10.r, tx), mix_local(c01.r, c11.r, tx), + ty); + out.g = mix_local(mix_local(c00.g, c10.g, tx), mix_local(c01.g, c11.g, tx), + ty); + out.b = mix_local(mix_local(c00.b, c10.b, tx), mix_local(c01.b, c11.b, tx), + ty); + return out; +#endif +} + +static Vec3fCpp compute_fine_blur_cpp(const ushort* src, int width, int height, + float u, float v, + const float* srgb16ToLinear) { + Vec3fCpp blur{0.0f, 0.0f, 0.0f}; + auto tap = [&](float dx, float dy, float w) { + Vec3fCpp s = sample_source_linear_bilinear_cpp( + src, width, height, u + dx / float(width), v + dy / float(height), + srgb16ToLinear); + blur.r += s.r * w; + blur.g += s.g * w; + blur.b += s.b * w; + }; + + constexpr float r1 = 1.5f; + constexpr float r2 = 3.0f; + + tap(0.0f, 0.0f, 0.18f); + tap(r1, 0.0f, 0.095f); + tap(-r1, 0.0f, 0.095f); + tap(0.0f, r1, 0.095f); + tap(0.0f, -r1, 0.095f); + tap(r1, r1, 0.055f); + tap(-r1, r1, 0.055f); + tap(r1, -r1, 0.055f); + tap(-r1, -r1, 0.055f); + + tap(r2, 0.0f, 0.04f); + tap(-r2, 0.0f, 0.04f); + tap(0.0f, r2, 0.04f); + tap(0.0f, -r2, 0.04f); + tap(r2, r2, 0.015f); + tap(-r2, r2, 0.015f); + tap(r2, -r2, 0.015f); + tap(-r2, -r2, 0.015f); + + return blur; +} + +static Vec3fCpp compute_coarse_blur_cpp(const ushort* src, int width, + int height, float u, float v, + const float* srgb16ToLinear) { + Vec3fCpp blur{0.0f, 0.0f, 0.0f}; + auto tap = [&](float dx, float dy, float w) { + Vec3fCpp s = sample_source_linear_bilinear_cpp( + src, width, height, u + dx / float(width), v + dy / float(height), + srgb16ToLinear); + blur.r += s.r * w; + blur.g += s.g * w; + blur.b += s.b * w; + }; + + constexpr float r1 = 4.5f; + constexpr float r2 = 7.0f; + constexpr float r3 = 9.5f; + + tap(0.0f, 0.0f, 0.20f); + tap(r1, 0.0f, 0.055f); + tap(-r1, 0.0f, 0.055f); + tap(0.0f, r1, 0.055f); + tap(0.0f, -r1, 0.055f); + tap(r1, r1, 0.038f); + tap(-r1, r1, 0.038f); + tap(r1, -r1, 0.038f); + tap(-r1, -r1, 0.038f); + + tap(r2, 0.0f, 0.04f); + tap(-r2, 0.0f, 0.04f); + tap(0.0f, r2, 0.04f); + tap(0.0f, -r2, 0.04f); + tap(r2, r2, 0.03f); + tap(-r2, r2, 0.03f); + tap(r2, -r2, 0.03f); + tap(-r2, -r2, 0.03f); + + tap(r3, 0.0f, 0.022f); + tap(-r3, 0.0f, 0.022f); + tap(0.0f, r3, 0.022f); + tap(0.0f, -r3, 0.022f); + tap(r3, r3, 0.015f); + tap(-r3, r3, 0.015f); + tap(r3, -r3, 0.015f); + tap(-r3, -r3, 0.015f); + + return blur; +} + +constexpr float PV_FLARE_LINEAR_CPP = 0.000244140625f; // 2^-12 +constexpr float PV_FLARE_LOG_CPP = -12.0f; +constexpr float PV_EPS_CPP = 0.00000190734f; + +static Vec3fCpp eval_undo_render_curve_cpp(const Vec3fCpp& col) { + constexpr float eps = 0.00001f; + const float fMin = std::min({col.r, col.g, col.b}); + const float fMax = std::max({col.r, col.g, col.b}); + + const float tMin = std::pow(fMin, 3.14453125f); + const float tMax = std::pow(fMax, 3.14453125f); + const float nMin = std::pow(fMin, 0.8125f) * 0.3828125f * (1.0f - tMin) + + (1.0f - std::pow(1.0f - fMin, 0.69140625f)) * tMin; + const float nMax = std::pow(fMax, 0.8125f) * 0.3828125f * (1.0f - tMax) + + (1.0f - std::pow(1.0f - fMax, 0.69140625f)) * tMax; + + const float scale = (nMax - nMin) / (fMax - fMin + eps); + return {(col.r - fMin) * scale + nMin, (col.g - fMin) * scale + nMin, + (col.b - fMin) * scale + nMin}; +} + +static float pv_working_luma_linear_cpp(const Vec3fCpp& c) { + Vec3fCpp clamped = clamp_vec3_cpp(c, 0.0001f, 0.999f); + Vec3fCpp prophoto{ + 0.529285f * clamped.r + 0.330046f * clamped.g + 0.140669f * clamped.b, + 0.098394f * clamped.r + 0.873493f * clamped.g + 0.028113f * clamped.b, + 0.016823f * clamped.r + 0.117671f * clamped.g + 0.865506f * clamped.b}; + Vec3fCpp unmapped = + clamp_vec3_cpp(eval_undo_render_curve_cpp(prophoto), 0.0f, 1.0f); + return std::max(unmapped.r * 0.25f + unmapped.g * 0.5f + unmapped.b * 0.25f, + PV_EPS_CPP); +} + +static float pv_encode_log_luma_cpp(float linearLuma) { + return std::log2(std::max(linearLuma + PV_FLARE_LINEAR_CPP, PV_EPS_CPP)); +} + +static float pv_decode_log_luma_cpp(float logLuma) { + return std::max(std::exp2(logLuma) - PV_FLARE_LINEAR_CPP, PV_EPS_CPP); +} + +static float endpoint_pin_mask_component_cpp(float x) { + x = std::clamp(x, 0.0f, 1.0f); + const float inv = 1.0f - x; + const float inv2 = inv * inv; + const float inv4 = inv2 * inv2; + const float inv8 = inv4 * inv4; + const float inv16 = inv8 * inv8; + const float base = 1.0f - inv8; + const float strong = 1.0f - inv16; + return mix_local(base, strong, smoothstep_local(0.35f, 1.0f, x)); +} + +static float pv_log_luma_cpp(const Vec3fCpp& c) { + return pv_encode_log_luma_cpp(pv_working_luma_linear_cpp(c)); +} + +static float pv_tent_weight_cpp(float value, float center, float halfWidth) { + return std::max( + 1.0f - std::abs(value - center) / std::max(halfWidth, PV_EPS_CPP), 0.0f); +} + +static Vec3fCpp apply_photon0001_tone_ranges_cpp( + const Vec3fCpp& color, const Vec3fCpp& blurredFine, + const Vec3fCpp& blurredCoarse, float highlightsAmt, float shadowsAmt, + float whitesAmt, float blacksAmt, float clarityAmt, float sceneWhiteNorm) { + const float srcGrayLinear = pv_working_luma_linear_cpp(color); + const float srcGrayLog = pv_encode_log_luma_cpp(srcGrayLinear); + const float blurFineLog = pv_log_luma_cpp(blurredFine); + const float blurCoarseLog = pv_log_luma_cpp(blurredCoarse); + const float toneMid = + pv_encode_log_luma_cpp(std::max(sceneWhiteNorm * 0.18f, PV_EPS_CPP)); + + const float wBlacks = pv_tent_weight_cpp(srcGrayLog, toneMid - 3.8f, 1.8f); + const float wShadows = pv_tent_weight_cpp(srcGrayLog, toneMid - 1.9f, 1.9f); + const float wHighlights = + pv_tent_weight_cpp(srcGrayLog, toneMid + 1.0f, 1.9f); + const float wWhites = pv_tent_weight_cpp(srcGrayLog, toneMid + 3.1f, 2.2f); + + const float maskFine = std::clamp(srcGrayLog - blurFineLog, -2.0f, 2.0f); + const float maskCoarse = std::clamp(blurFineLog - blurCoarseLog, -2.0f, 2.0f); + const float mask = + std::clamp(maskFine * 0.70f + maskCoarse * 0.45f, -2.5f, 2.5f); + + const float partSwitch = step_local(srcGrayLog, toneMid); + const float compressedLow = toneMid + (srcGrayLog - toneMid) * 0.78f; + const float compressedHigh = toneMid + (srcGrayLog - toneMid) * 0.58f; + const float baseCompressed = + mix_local(compressedHigh, compressedLow, partSwitch); + + float localContrastSignal = srcGrayLog + mask - baseCompressed; + localContrastSignal *= std::max(clarityAmt, 0.0f); + localContrastSignal *= + std::clamp(1.0f + 0.35f * (-highlightsAmt + shadowsAmt), 1.0f, 2.0f); + const float localSignalHigh = std::max(localContrastSignal, 0.0f); + const float localSignalLow = std::min(localContrastSignal, 0.0f); + + const float lumWeightHigh = + std::clamp(wHighlights + 0.6f * wWhites, 0.0f, 1.0f); + const float lumWeightLow = std::clamp(wShadows + 0.6f * wBlacks, 0.0f, 1.0f); + const float endpointHigh = std::clamp( + std::abs(highlightsAmt) + 0.35f * std::abs(whitesAmt), 0.0f, 1.0f); + const float endpointLow = std::clamp( + std::abs(shadowsAmt) + 0.35f * std::abs(blacksAmt), 0.0f, 1.0f); + const float clarityPinHigh = + mix_local(endpoint_pin_mask_component_cpp(lumWeightHigh), 1.0f, + endpointHigh * endpointHigh); + const float clarityPinLow = + mix_local(endpoint_pin_mask_component_cpp(lumWeightLow), 1.0f, + endpointLow * endpointLow); + + float hsPinY = + mix_local(0.5f + 0.5f * std::max(1.0f - sign_local(shadowsAmt), 0.0f), + 1.0f, clarityPinHigh); + float hsPinX = mix_local( + 1.0f, 0.5f, + (1.0f - clarityPinLow) * std::max(-sign_local(highlightsAmt), 0.0f)); + hsPinX = + mix_local(1.0f, hsPinX, std::clamp(std::abs(highlightsAmt), 0.0f, 1.0f)); + + const float maxAbsHS = std::max( + std::max(std::abs(highlightsAmt), std::abs(shadowsAmt)), PV_EPS_CPP); + const float baseOffset = 0.85f * (highlightsAmt + shadowsAmt) / maxAbsHS; + const float offsetHSHigh = wHighlights * std::abs(highlightsAmt) * baseOffset; + const float offsetHSLow = wShadows * std::abs(shadowsAmt) * baseOffset; + + float deltaHSHigh = std::clamp(-highlightsAmt, -1.0f, 1.0f); + float deltaHSLow = std::clamp(shadowsAmt, -1.0f, 1.0f); + deltaHSHigh *= std::min(mask, 0.0f); + deltaHSLow *= std::max(mask, 0.0f); + deltaHSHigh += offsetHSHigh; + deltaHSLow += offsetHSLow; + + float deltaStops = deltaHSHigh * hsPinX + deltaHSLow * hsPinY; + deltaStops += whitesAmt * wWhites * hsPinX; + deltaStops += blacksAmt * wBlacks * hsPinY; + deltaStops += + localSignalHigh * clarityPinHigh + localSignalLow * clarityPinLow; + + const float deltaSign = sign_local(deltaStops); + const float flareSwitch = 1.0f - std::max(deltaSign, 0.0f); + const float zeroSwitch = 1.0f - std::abs(deltaSign); + const float flare = flareSwitch * PV_FLARE_LOG_CPP; + const float startpoint = flare - (deltaStops + deltaStops); + const float t1 = step_local(startpoint, srcGrayLog); + const float t2 = step_local(srcGrayLog, startpoint); + float t = + std::clamp((srcGrayLog - startpoint) / (flare - startpoint + zeroSwitch), + 0.0f, 1.0f); + t *= t * (1.0f - mix_local(t2, t1, flareSwitch)); + deltaStops = mix_local(deltaStops, 0.0f, t); + + deltaStops = std::min(deltaStops, 4.0f); + const float targetLog = srcGrayLog + deltaStops; + float targetLuma = pv_decode_log_luma_cpp(targetLog); + + if (targetLuma > sceneWhiteNorm && deltaStops > 0.0f) { + const float over = targetLuma - sceneWhiteNorm; + const float knee = std::max(sceneWhiteNorm * 0.7f, PV_EPS_CPP); + const float compress = over / (1.0f + over / knee); + targetLuma = sceneWhiteNorm + compress; + } + + Vec3fCpp out = color; + apply_luma_target_cpp(out.r, out.g, out.b, srcGrayLinear, targetLuma); + return out; } static std::vector evalMonotonicSplineLut(const QVariantList& pts, - int lutSize) { + int lutSize) { std::vector lut(lutSize); int n = pts.size(); if (n < 2) { @@ -218,8 +586,7 @@ static std::vector evalMonotonicSplineLut(const QVariantList& pts, std::vector m(n, 0.0); m[0] = delta[0]; m[n - 1] = delta[n - 2]; - for (int i = 1; i < n - 1; i++) - m[i] = (delta[i - 1] + delta[i]) * 0.5; + for (int i = 1; i < n - 1; i++) m[i] = (delta[i - 1] + delta[i]) * 0.5; for (int i = 0; i < n - 1; i++) { if (std::abs(delta[i]) < 1e-12) { m[i] = 0.0; @@ -238,16 +605,21 @@ static std::vector evalMonotonicSplineLut(const QVariantList& pts, int seg = 0; for (int i = 0; i < lutSize; i++) { double t_val = double(i) / (lutSize - 1); - if (t_val <= xs[0]) { lut[i] = float(ys[0]); continue; } - if (t_val >= xs[n - 1]) { lut[i] = float(ys[n - 1]); continue; } + if (t_val <= xs[0]) { + lut[i] = float(ys[0]); + continue; + } + if (t_val >= xs[n - 1]) { + lut[i] = float(ys[n - 1]); + continue; + } while (seg < n - 2 && t_val > xs[seg + 1]) seg++; double dx = xs[seg + 1] - xs[seg]; double t = (t_val - xs[seg]) / dx; double t2 = t * t, t3 = t2 * t; double val = (2 * t3 - 3 * t2 + 1) * ys[seg] + (t3 - 2 * t2 + t) * dx * m[seg] + - (-2 * t3 + 3 * t2) * ys[seg + 1] + - (t3 - t2) * dx * m[seg + 1]; + (-2 * t3 + 3 * t2) * ys[seg + 1] + (t3 - t2) * dx * m[seg + 1]; lut[i] = float(std::clamp(val, 0.0, 1.0)); } return lut; @@ -261,10 +633,11 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, .arg(width) .arg(height) .arg((quintptr)QThread::currentThread()), - "DEBUG"); + PHOTON_DEBUG); if (!src || width <= 0 || height <= 0) { - LogManager::instance()->log("[ ImageDeveloper ] - develop ABORT: invalid params", "ERROR"); + LogManager::instance()->log( + "[ ImageDeveloper ] - develop ABORT: invalid params", PHOTON_ERROR); return QImage(); } @@ -274,8 +647,9 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, float high = obj["highlights"].toDouble(); float shad = obj["shadows"].toDouble(); float whites = obj["whites"].toDouble(); + float sceneWhite = obj["sceneWhite"].toDouble(1.0); float blacks = obj["blacks"].toDouble(); - float adaptation = obj["adaptation"].toDouble(); + float clarity = obj["clarity"].toDouble(); float temp = obj["temperature"].toDouble() / 100.0f; float tint = obj["tint"].toDouble() / 100.0f; float sat_global = obj["saturation"].toDouble(); @@ -285,24 +659,28 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, bool denoiseEnabled = obj["denoiseEnabled"].toBool(); bool denoiseSecondPass = obj["denoiseSecondPass"].toBool(); int denoiseSearchWindow = obj.contains("denoiseSearchWindow") - ? obj["denoiseSearchWindow"].toInt() : 19; - int denoiseGroupSize = obj.contains("denoiseGroupSize") - ? obj["denoiseGroupSize"].toInt() : 16; + ? obj["denoiseSearchWindow"].toInt() + : 19; + int denoiseGroupSize = + obj.contains("denoiseGroupSize") ? obj["denoiseGroupSize"].toInt() : 16; int denoiseChromaRadius = obj.contains("denoiseChromaRadius") - ? obj["denoiseChromaRadius"].toInt() : 4; + ? obj["denoiseChromaRadius"].toInt() + : 4; float denoiseChromaAmount = obj.contains("denoiseChromaAmount") - ? obj["denoiseChromaAmount"].toDouble() : 50.0f; + ? obj["denoiseChromaAmount"].toDouble() + : 50.0f; float denoiseChromaBm3d = obj.contains("denoiseChromaBm3d") - ? obj["denoiseChromaBm3d"].toDouble() : 50.0f; - - LogManager::instance()->log( - QString("[ ImageDeveloper ] - Params: exp=%1 con=%2 high=%3 shad=%4 denoise=%5") - .arg(exp, 0, 'f', 2) - .arg(con, 0, 'f', 2) - .arg(high, 0, 'f', 2) - .arg(shad, 0, 'f', 2) - .arg(denoiseAmount, 0, 'f', 1), - "DEBUG"); + ? obj["denoiseChromaBm3d"].toDouble() + : 50.0f; + + LogManager::instance()->log(QString("[ ImageDeveloper ] - Params: exp=%1 " + "con=%2 high=%3 shad=%4 denoise=%5") + .arg(exp, 0, 'f', 2) + .arg(con, 0, 'f', 2) + .arg(high, 0, 'f', 2) + .arg(shad, 0, 'f', 2) + .arg(denoiseAmount, 0, 'f', 1), + PHOTON_DEBUG); // HSL Params float hsl_h[8], hsl_s[8], hsl_l[8]; @@ -349,40 +727,50 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, QVariantList defaultPts; QVariantMap p0, p1; - p0["x"] = 0.0; p0["y"] = 0.0; - p1["x"] = 1.0; p1["y"] = 1.0; + p0["x"] = 0.0; + p0["y"] = 0.0; + p1["x"] = 1.0; + p1["y"] = 1.0; defaultPts << p0 << p1; - QVariantList tcLuma = obj.contains("toneCurveLuma") - ? jsonArrayToVariantList(obj["toneCurveLuma"].toArray()) : defaultPts; - QVariantList tcRed = obj.contains("toneCurveRed") - ? jsonArrayToVariantList(obj["toneCurveRed"].toArray()) : defaultPts; - QVariantList tcGreen = obj.contains("toneCurveGreen") - ? jsonArrayToVariantList(obj["toneCurveGreen"].toArray()) : defaultPts; - QVariantList tcBlue = obj.contains("toneCurveBlue") - ? jsonArrayToVariantList(obj["toneCurveBlue"].toArray()) : defaultPts; + QVariantList tcLuma = + obj.contains("toneCurveLuma") + ? jsonArrayToVariantList(obj["toneCurveLuma"].toArray()) + : defaultPts; + QVariantList tcRed = + obj.contains("toneCurveRed") + ? jsonArrayToVariantList(obj["toneCurveRed"].toArray()) + : defaultPts; + QVariantList tcGreen = + obj.contains("toneCurveGreen") + ? jsonArrayToVariantList(obj["toneCurveGreen"].toArray()) + : defaultPts; + QVariantList tcBlue = + obj.contains("toneCurveBlue") + ? jsonArrayToVariantList(obj["toneCurveBlue"].toArray()) + : defaultPts; constexpr int kToneLutEntries = 65536; constexpr float kToneLutMaxIndex = float(kToneLutEntries - 1); std::vector lutLuma = evalMonotonicSplineLut(tcLuma, kToneLutEntries); std::vector lutRed = evalMonotonicSplineLut(tcRed, kToneLutEntries); - std::vector lutGreen = evalMonotonicSplineLut(tcGreen, kToneLutEntries); + std::vector lutGreen = + evalMonotonicSplineLut(tcGreen, kToneLutEntries); std::vector lutBlue = evalMonotonicSplineLut(tcBlue, kToneLutEntries); // Check if tone curve is identity (skip application if so), using 16-bit // quantization to match shader LUT precision. bool toneCurveActive = false; for (int i = 0; i < kToneLutEntries && !toneCurveActive; i++) { - uint16_t qL = uint16_t(std::clamp(lutLuma[i] * kToneLutMaxIndex + 0.5f, 0.0f, - kToneLutMaxIndex)); + uint16_t qL = uint16_t(std::clamp(lutLuma[i] * kToneLutMaxIndex + 0.5f, + 0.0f, kToneLutMaxIndex)); uint16_t qR = uint16_t(std::clamp(lutRed[i] * kToneLutMaxIndex + 0.5f, 0.0f, kToneLutMaxIndex)); - uint16_t qG = uint16_t(std::clamp(lutGreen[i] * kToneLutMaxIndex + 0.5f, 0.0f, - kToneLutMaxIndex)); - uint16_t qB = uint16_t(std::clamp(lutBlue[i] * kToneLutMaxIndex + 0.5f, 0.0f, - kToneLutMaxIndex)); - if (qL != i || qR != i || qG != i || qB != i) - toneCurveActive = true; + uint16_t qG = uint16_t(std::clamp(lutGreen[i] * kToneLutMaxIndex + 0.5f, + 0.0f, kToneLutMaxIndex)); + uint16_t qB = uint16_t(std::clamp(lutBlue[i] * kToneLutMaxIndex + 0.5f, + 0.0f, kToneLutMaxIndex)); + if (qL != i || qR != i || qG != i || qB != i) toneCurveActive = true; } // HSL constants @@ -392,6 +780,7 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, float widths[8] = {35.0f / 360.0f, 45.0f / 360.0f, 40.0f / 360.0f, 90.0f / 360.0f, 60.0f / 360.0f, 60.0f / 360.0f, 55.0f / 360.0f, 50.0f / 360.0f}; + const auto& linearLut = srgb16_to_linear_lut_cpp(); QImage output(width, height, QImage::Format_RGB888); @@ -403,71 +792,53 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, uchar* scanline = output.scanLine(y); for (int x = 0; x < width; ++x) { int i = y * width + x; - float r = src[i * 3] / 65535.0f; - float g = src[i * 3 + 1] / 65535.0f; - float b = src[i * 3 + 2] / 65535.0f; - // 0. Initial sRGB to Linear (Since RawEngine develops with default gamma) - r = srgb_to_linear_f(r); - g = srgb_to_linear_f(g); - b = srgb_to_linear_f(b); + float r = linearLut[src[i * 3]]; + float g = linearLut[src[i * 3 + 1]]; + float b = linearLut[src[i * 3 + 2]]; + + const float u = (float(x) + 0.5f) / float(width); + const float v = (float(y) + 0.5f) / float(height); + Vec3fCpp blurredFine = + compute_fine_blur_cpp(src, width, height, u, v, linearLut.data()); + Vec3fCpp blurredCoarse = + compute_coarse_blur_cpp(src, width, height, u, v, linearLut.data()); // 1. WB & Exposure r *= r_wb * exp_mult; g *= g_wb * exp_mult; b *= b_wb * exp_mult; + Vec3fCpp color{r, g, b}; - // DaVinci tonemapping to smoothen the highlights - davinci_tonemap(r, g, b, adaptation); - - // 2. Contrast - r = std::max(0.0f, r); - g = std::max(0.0f, g); - b = std::max(0.0f, b); - r = std::pow(r, con); - g = std::pow(g, con); - b = std::pow(b, con); - - // 3. Whites & Blacks (smoother masks, bounded response) float luma = - get_luma_cpp(std::max(0.0f, r), std::max(0.0f, g), std::max(0.0f, b)); - if (whites != 0.0f) { - float w = std::clamp(whites / 100.0f, -1.0f, 1.0f); - float whiteMask = smoothstep(0.42f, 1.20f, luma); - float targetLuma = compute_target_luma_cpp(luma, w * 0.85f * whiteMask); - apply_luma_target_cpp(r, g, b, luma, targetLuma); - luma = get_luma_cpp(std::max(0.0f, r), std::max(0.0f, g), - std::max(0.0f, b)); - } - if (blacks != 0.0f) { - float bAdj = std::clamp(blacks / 100.0f, -1.0f, 1.0f); - float blackMask = 1.0f - smoothstep(0.0f, 0.48f, luma); - float targetLuma = - compute_target_luma_cpp(luma, bAdj * 0.90f * blackMask); - apply_luma_target_cpp(r, g, b, luma, targetLuma); - luma = get_luma_cpp(std::max(0.0f, r), std::max(0.0f, g), - std::max(0.0f, b)); + get_luma_cpp(std::max(0.0f, color.r), std::max(0.0f, color.g), + std::max(0.0f, color.b)); + if (luma > sceneWhite && exp > 0.0f) { + float over = luma - sceneWhite; + float knee = sceneWhite * 0.7f; + float compress = over / (1.0f + over / knee); + float targetL = sceneWhite + compress; + apply_luma_target_cpp(color.r, color.g, color.b, luma, targetL); } - // 4. Highlights & Shadows (broader crossover, gentler extremes) - if (shad != 0.0f) { - float s = std::clamp(shad / 100.0f, -1.0f, 1.0f); - float shadowMask = 1.0f - smoothstep(0.05f, 0.62f, luma); - float targetLuma = - compute_target_luma_cpp(luma, s * 0.95f * shadowMask); - apply_luma_target_cpp(r, g, b, luma, targetLuma); - luma = get_luma_cpp(std::max(0.0f, r), std::max(0.0f, g), - std::max(0.0f, b)); - } - if (high != 0.0f) { - float h = std::clamp(high / 100.0f, -1.0f, 1.0f); - float highlightMask = smoothstep(0.22f, 1.25f, luma); - float targetLuma = - compute_target_luma_cpp(luma, h * 0.90f * highlightMask); - apply_luma_target_cpp(r, g, b, luma, targetLuma); - } + Vec3fCpp blurredFineTone{blurredFine.r * r_wb * exp_mult, + blurredFine.g * g_wb * exp_mult, + blurredFine.b * b_wb * exp_mult}; + Vec3fCpp blurredCoarseTone{blurredCoarse.r * r_wb * exp_mult, + blurredCoarse.g * g_wb * exp_mult, + blurredCoarse.b * b_wb * exp_mult}; + const float sceneWhiteNorm = std::max(sceneWhite * exp_mult, 1e-4f); + color = apply_photon0001_tone_ranges_cpp( + color, blurredFineTone, blurredCoarseTone, high / 100.0f, + shad / 100.0f, whites / 100.0f, blacks / 100.0f, clarity / 100.0f, + sceneWhiteNorm); + + // 2. Contrast + r = std::pow(std::max(0.0f, color.r), con); + g = std::pow(std::max(0.0f, color.g), con); + b = std::pow(std::max(0.0f, color.b), con); - // 5. HSL + // 3. HSL HSV hsv_struct = rgb_to_hsv(r, g, b); float hue = hsv_struct.h; float h_norm = hue / 360.0f; @@ -564,7 +935,8 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, float lumaDelta = lumaOut - lumaIn; if (lumaDelta > 0.0f) { // Soften black-point lift sensitivity near absolute black. - float blackLiftAtten = mix(0.60f, 1.0f, smoothstep(0.0f, 0.20f, lumaIn)); + float blackLiftAtten = + mix(0.60f, 1.0f, smoothstep(0.0f, 0.20f, lumaIn)); lumaDelta *= blackLiftAtten; } float lumaRatio = (lumaIn > 0.001f) ? lumaOut / lumaIn : 1.0f; @@ -580,8 +952,10 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, cr = lutRed[idxR]; cg = lutGreen[idxG]; cb = lutBlue[idxB]; - float arCr = cr + lumaDelta, arCg = cg + lumaDelta, arCb = cb + lumaDelta; - float mrCr = cr * lumaRatio, mrCg = cg * lumaRatio, mrCb = cb * lumaRatio; + float arCr = cr + lumaDelta, arCg = cg + lumaDelta, + arCb = cb + lumaDelta; + float mrCr = cr * lumaRatio, mrCg = cg * lumaRatio, + mrCb = cb * lumaRatio; cr = mix(arCr, mrCr, blendShadow); cg = mix(arCg, mrCg, blendShadow); cb = mix(arCb, mrCb, blendShadow); @@ -616,7 +990,7 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, QString("[ ImageDeveloper ] - Denoising: amount=%1 (rhi=%2)") .arg(denoiseAmount, 0, 'f', 1) .arg((quintptr)rhi), - "INFO"); + PHOTON_INFO); std::vector gpuMatches; if (rhi) { int w = output.width(); @@ -638,9 +1012,15 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, gpuMatches = searcher.runSearch(luma.data(), w, h, 19); if (gpuMatches.empty()) { - LogManager::instance()->log("[ ImageDeveloper ] - GPU search produced no matches (possibly due to frame conflict or shader error). Falling back to CPU matching.", "WARNING"); + LogManager::instance()->log( + "[ ImageDeveloper ] - GPU search produced no matches (possibly due " + "to frame conflict or shader error). Falling back to CPU matching.", + PHOTON_WARNING); } else { - LogManager::instance()->log(QString("[ ImageDeveloper ] - GPU search successful: %1 matches").arg(gpuMatches.size()), "DEBUG"); + LogManager::instance()->log( + QString("[ ImageDeveloper ] - GPU search successful: %1 matches") + .arg(gpuMatches.size()), + PHOTON_DEBUG); } } photon::DenoiseParams dparams; @@ -650,8 +1030,8 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, dparams.chromaDenoise = denoiseChromaAmount; dparams.chromaBm3d = denoiseChromaBm3d; - output = Denoiser::denoise(output, denoiseAmount, nullptr, denoiseSecondPass, - 4, gpuMatches, dparams); + output = Denoiser::denoise(output, denoiseAmount, nullptr, + denoiseSecondPass, 4, gpuMatches, dparams); // Convert back to RGB888 if Denoiser changed format to RGBX64 if (output.format() != QImage::Format_RGB888) { @@ -661,8 +1041,8 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, // === Crop & Geometry transforms === // 1. Orientation steps (90° rotations) - int orientSteps = obj.contains("orientationSteps") - ? obj["orientationSteps"].toInt() : 0; + int orientSteps = + obj.contains("orientationSteps") ? obj["orientationSteps"].toInt() : 0; orientSteps = ((orientSteps % 4) + 4) % 4; if (orientSteps > 0) { QTransform rot; @@ -671,21 +1051,24 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, } // 2. Flip - bool flipH = obj.contains("flipHorizontal") - ? obj["flipHorizontal"].toBool() : false; - bool flipV = obj.contains("flipVertical") - ? obj["flipVertical"].toBool() : false; + bool flipH = + obj.contains("flipHorizontal") ? obj["flipHorizontal"].toBool() : false; + bool flipV = + obj.contains("flipVertical") ? obj["flipVertical"].toBool() : false; if (flipH && flipV) { - output = output.transformed(QTransform().scale(-1, -1), Qt::SmoothTransformation); + output = output.transformed(QTransform().scale(-1, -1), + Qt::SmoothTransformation); } else if (flipH) { - output = output.transformed(QTransform().scale(-1, 1), Qt::SmoothTransformation); + output = + output.transformed(QTransform().scale(-1, 1), Qt::SmoothTransformation); } else if (flipV) { - output = output.transformed(QTransform().scale(1, -1), Qt::SmoothTransformation); + output = + output.transformed(QTransform().scale(1, -1), Qt::SmoothTransformation); } // 3. Straighten (fine rotation) - double straighten = obj.contains("straightenAngle") - ? obj["straightenAngle"].toDouble() : 0.0; + double straighten = + obj.contains("straightenAngle") ? obj["straightenAngle"].toDouble() : 0.0; if (std::abs(straighten) > 0.01) { QTransform rot; rot.rotate(straighten); @@ -724,14 +1107,14 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, int pw = cropRight - cropLeft; int ph = cropBottom - cropTop; - if (pw > 0 && ph > 0) - output = output.copy(cropLeft, cropTop, pw, ph); + if (pw > 0 && ph > 0) output = output.copy(cropLeft, cropTop, pw, ph); } } } LogManager::instance()->log( - QString("[ ImageDeveloper ] - cropDebug preCrop=%1x%2 cropPx=[%3,%4 -> %5,%6] out=%7x%8") + QString("[ ImageDeveloper ] - cropDebug preCrop=%1x%2 cropPx=[%3,%4 -> " + "%5,%6] out=%7x%8") .arg(preCropW) .arg(preCropH) .arg(cropLeft) @@ -740,11 +1123,12 @@ QImage ImageDeveloper::develop(const ushort* src, int width, int height, .arg(cropBottom) .arg(output.width()) .arg(output.height()), - "DEBUG"); + PHOTON_DEBUG); - LogManager::instance()->log( - QString("[ ImageDeveloper ] - export END: %1x%2").arg(output.width()).arg(output.height()), - "DEBUG"); + LogManager::instance()->log(QString("[ ImageDeveloper ] - export END: %1x%2") + .arg(output.width()) + .arg(output.height()), + PHOTON_DEBUG); return output; } diff --git a/src/engine/Panorama.cpp b/src/engine/Panorama.cpp new file mode 100644 index 0000000..1061189 --- /dev/null +++ b/src/engine/Panorama.cpp @@ -0,0 +1,466 @@ +#include "Panorama.h" +#include "../managers/LogManager.h" + +using namespace photon; + + +static ColorInfo extractColorInfo(LibRaw *processor) { + ColorInfo info; + + // cam_xyz is [4][3]: camera-RGB → XYZ D50 + // Only first 3 rows (R,G,B) are needed; row 3 is an unused 4th channel + double cam2xyz[3][3]; + for (int i = 0; i < 3; i++) + for (int j = 0; j < 3; j++) + cam2xyz[i][j] = processor->imgdata.color.cam_xyz[i][j]; + + + cv::Mat C(3, 3, CV_64F, cam2xyz); + // NOTE: Not sure if the inverse is neede here. The DNG dump seems to work + // without issues, so keeping it here just in case. + // cv::Mat Cinv = C.inv(); // now XYZ D50 → camera: this is ColorMatrix1 + + for (int i = 0; i < 3; i++) + for (int j = 0; j < 3; j++) + info.matrix[i * 3 + j] = C.at(i, j); + + // WB is baked in (use_auto_wb=1), so tell DNG "no further WB needed" + info.asShotNeutral[0] = 1.0f; + info.asShotNeutral[1] = 1.0f; + info.asShotNeutral[2] = 1.0f; + + + return info; +} + +void Panorama::stitchAsync(const QStringList& inputFiles, + bool compensateExposure) { + QThread* thread = QThread::create([this, inputFiles, compensateExposure]() { + QVariantMap result = stitchPhotos(inputFiles, compensateExposure); + QMetaObject::invokeMethod( + this, [this, result]() { emit stitchCompleted(result); }); + }); + connect(thread, &QThread::finished, thread, &QObject::deleteLater); + thread->start(); + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Started panorama stitching thread"), PHOTON_INFO); +} + +cv::Mat Panorama::raw_to_linear(const QString& file, std::unique_ptr& colorInfo) { + LibRaw processor; + processor.imgdata.params.output_bps = 16; + processor.imgdata.params.no_auto_bright = 1; + processor.imgdata.params.use_camera_wb = 1; + processor.imgdata.params.output_color = 0; + processor.imgdata.params.use_camera_matrix = 1; + if (processor.open_file(file.toStdString().c_str()) != LIBRAW_SUCCESS) { + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Cannot open file %1").arg(file), PHOTON_ERROR); + return cv::Mat(); + } + + if (processor.unpack() != LIBRAW_SUCCESS) { + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Cannot unpack data of file %1").arg(file), + PHOTON_ERROR); + return cv::Mat(); + } + if (!colorInfo.get()) { + colorInfo = std::make_unique (ColorInfo (extractColorInfo(&processor))); + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Initialized ColorInfo on %1").arg(file), + PHOTON_DEBUG); + } + + if (processor.dcraw_process() != LIBRAW_SUCCESS) { + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Cannot dcraw file %1").arg(file), PHOTON_ERROR); + return cv::Mat(); + } + + libraw_processed_image_t* image = processor.dcraw_make_mem_image(); + + if (!image) { + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Cannot create processed image from %1") + .arg(file), + PHOTON_ERROR); + return cv::Mat(); + } + + cv::Mat rawRGB(image->height, image->width, CV_16UC3, image->data); + cv::Mat matBGR; + cv::cvtColor(rawRGB, matBGR, cv::COLOR_RGB2BGR); + LibRaw::dcraw_clear_mem(image); + + return matBGR; +} + +QVariantMap Panorama::stitchPhotos(const QStringList& inputFiles, + bool compensateExposure, size_t featuresThreshold) { + cv::ocl::setUseOpenCL(true); + QVariantMap result; + + if (inputFiles.size() < 2) { + LogManager::instance()->log( + "[ Panorama.cpp ] - Cannot stitch a single image", PHOTON_ERROR); + result["message"] = QString("Select more photos!"); + result["success"] = false; + return result; + } + + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Loading %1 images").arg(inputFiles.size()), + PHOTON_INFO); + + std::unique_ptr colorInfo; + + std::vector images16; + for (const auto& filename : inputFiles) { + cv::Mat img = Panorama::raw_to_linear(filename, colorInfo); + if (img.empty()) { + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Failed to process image %1") + .arg(filename), + PHOTON_ERROR); + continue; + } + images16.push_back(img.clone()); + } + + if (images16.size() < 2) { + LogManager::instance()->log( + "[ Panorama.cpp ] - Not enough valid images to stitch", PHOTON_ERROR); + result["message"] = QString("Not enough valid images to stitch"); + result["success"] = false; + return result; + } + + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Processing %1 images").arg(images16.size()), + PHOTON_DEBUG); + + // Create 8-bit gamma-corrected images for feature detection + // Feature detectors require 8-bit input + std::vector images8bit; + std::vector sizes; + for (const auto& img16 : images16) { + cv::Mat img32F, img8; + img16.convertTo(img32F, CV_32FC3, 1.0 / 65535.0); + cv::pow(img32F, 1.0 / 2.2, img32F); + img32F.convertTo(img8, CV_8UC3, 255.0); + images8bit.push_back(img8); + sizes.push_back(img8.size()); + } + + LogManager::instance()->log("[ Panorama.cpp ] - Phase 1: Feature detection", + PHOTON_DEBUG); + + cv::Ptr finder = cv::SIFT::create(); + std::vector features(images8bit.size()); + + for (size_t i = 0; i < images8bit.size(); i++) { + cv::detail::computeImageFeatures(finder, images8bit[i], features[i]); + features[i].img_idx = static_cast(i); + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Image %1: %2 features detected") + .arg(i) + .arg(static_cast(features[i].keypoints.size())), + PHOTON_DEBUG); + } + + + cv::Ptr matcher = + cv::makePtr(false, 0.3f); + std::vector pairwise_matches; + + (*matcher)(features, pairwise_matches); + matcher->collectGarbage(); + + int num_matches = 0; + for (const auto& match : pairwise_matches) { + if (match.confidence > 0.0) num_matches++; + } + + if (num_matches < static_cast(images8bit.size()) - 1) { + LogManager::instance()->log( + "[ Panorama.cpp ] - Not enough matching features found", PHOTON_ERROR); + result["message"] = + QString("Stitching failed: not enough matching features found."); + result["success"] = false; + return result; + } + + LogManager::instance()->log("[ Panorama.cpp ] - Phase 2: Camera estimation", + PHOTON_DEBUG); + + cv::Ptr estimator = + cv::makePtr(); + std::vector cameras; + + if (!(*estimator)(features, pairwise_matches, cameras)) { + LogManager::instance()->log( + "[ Panorama.cpp ] - Homography estimation failed", PHOTON_ERROR); + result["message"] = + QString("Stitching failed: failed to align the images."); + result["success"] = false; + return result; + } + + for (size_t i = 0; i < cameras.size(); ++i) + cameras[i].R.convertTo(cameras[i].R, CV_32F); + + + cv::Ptr adjuster = + cv::makePtr(); + + adjuster->setConfThresh(1.0); + if (!(*adjuster)(features, pairwise_matches, cameras)) { + LogManager::instance()->log("[ Panorama.cpp ] - Bundle adjustment failed", + PHOTON_ERROR); + result["message"] = + QString("Stitching failed: failed to optimize camera parameters."); + result["success"] = false; + return result; + } + + LogManager::instance()->log("[ Panorama.cpp ] - Phase 3: Warping images", + PHOTON_DEBUG); + + std::vector focals; + for (size_t i = 0; i < cameras.size(); ++i) { + focals.push_back(cameras[i].focal); + } + std::sort(focals.begin(), focals.end()); + float median_focal = static_cast(focals[focals.size() / 2]); + + float warped_image_scale = median_focal; + cv::Ptr warper_creator = + cv::makePtr(); + cv::Ptr warper = + warper_creator->create(static_cast(warped_image_scale)); + + LogManager::instance()->log("[ Panorama.cpp ] - Created warper", PHOTON_DEBUG); + std::vector images_warped16; + std::vector masks_warped; + std::vector images_warped16_umat; + std::vector masks_warped_umat; + std::vector corners; + std::vector sizes_warped; + + for (size_t i = 0; i < images16.size(); i++) { + cv::Mat K; + cameras[i].K().convertTo(K, CV_32F); + cv::Rect roi = warper->warpRoi(sizes[i], K, cameras[i].R); + corners.push_back(roi.tl()); + sizes_warped.push_back(roi.size()); + + cv::Mat warped; + warper->warp(images16[i], K, cameras[i].R, cv::INTER_LINEAR, + cv::BORDER_REFLECT, warped); + images_warped16.push_back(warped); + images_warped16_umat.push_back(warped.getUMat(cv::ACCESS_READ)); + + cv::Mat mask = cv::Mat::ones(sizes[i], CV_8U) * 255; + cv::Mat warped_mask; + warper->warp(mask, K, cameras[i].R, cv::INTER_NEAREST, cv::BORDER_CONSTANT, + warped_mask); + masks_warped.push_back(warped_mask); + masks_warped_umat.push_back(warped_mask.getUMat(cv::ACCESS_READ)); + } + + // As of now the user cannot choose to compensate for exposure. + // Will add that if I need it in my workflow. + if (compensateExposure) { + LogManager::instance()->log( + "[ Panorama.cpp ] - Phase 4: Exposure compensation", PHOTON_DEBUG); + + cv::Ptr compensator = + cv::makePtr(); + compensator->feed(corners, images_warped16_umat, masks_warped_umat); + for (size_t i = 0; i < images_warped16.size(); ++i) { + compensator->apply(static_cast(i), corners[i], images_warped16[i], + masks_warped[i]); + } + } else { + LogManager::instance()->log( + "[ Panorama.cpp ] - Phase 4: Skipping exposure compensation", PHOTON_DEBUG); + } + + LogManager::instance()->log("[ Panorama.cpp ] - Phase 5: Seam finding", + PHOTON_DEBUG); + + std::vector masks_binary; + for (auto& mask : masks_warped) { + cv::Mat mask_bin; + cv::threshold(mask, mask_bin, 127, 255, cv::THRESH_BINARY); + masks_binary.push_back(mask_bin.getUMat(cv::ACCESS_READ)); + } + + std::vector images_warped8_umat; + for (const auto& img16 : images_warped16) { + cv::Mat img8; + img16.convertTo(img8, CV_8UC3, 255.0 / 65535.0); + images_warped8_umat.push_back(img8.getUMat(cv::ACCESS_READ)); + } + + + cv::Ptr seam_finder = + cv::makePtr(); + seam_finder->find(images_warped8_umat, corners, masks_binary); + + LogManager::instance()->log("[ Panorama.cpp ] - Phase 6: Multi-band blending", + PHOTON_DEBUG); + + cv::Rect dst_roi = cv::detail::resultRoi(corners, sizes_warped); + + // Create multi-band blender with high number of bands for quality + // Blend width is typically based on image size - use 1/8 of the smaller + // dimension + int blend_width = std::min(dst_roi.width, dst_roi.height) / 8; + int num_bands = static_cast( + std::ceil(std::log(static_cast(blend_width)) / std::log(2.0))); + + // NOTE: + // It makes little difference, so I'm not going to cap it. + // num_bands = std::min(num_bands, 8); // Cap at 8 for performance + + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Using %1 bands for blending").arg(num_bands), + PHOTON_DEBUG); + + cv::Ptr blender = + cv::makePtr(false, num_bands); + blender->prepare(corners, sizes_warped); + + + float scale_factor = 2.0; + for (size_t i = 0; i < images_warped16.size(); i++) { + cv::Mat img16S; + images_warped16[i].convertTo(img16S, CV_16SC3, 1/scale_factor); + blender->feed(img16S, masks_binary[i], corners[i]); + } + + cv::Mat result_16s, result_mask; + blender->blend(result_16s, result_mask); + + cv::Mat result16; + result_16s.convertTo(result16, CV_16UC3, scale_factor); + + cv::Mat resultRGB; + cv::cvtColor(result16, resultRGB, cv::COLOR_BGR2RGB); + + if (!resultRGB.isContinuous()) resultRGB = resultRGB.clone(); + + if (resultRGB.empty()) { + LogManager::instance()->log("[ Panorama.cpp ] - Blending failed", PHOTON_ERROR); + result["message"] = QString("Stitching failed during blending!"); + result["success"] = false; + return result; + } + + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Blended result: %1x%2") + .arg(result16.cols) + .arg(result16.rows), + PHOTON_DEBUG); + + + LogManager::instance()->log("[ Panorama.cpp ] - Phase 7: Saving to DNG", + PHOTON_DEBUG); + + QString filename = + QDir::toNativeSeparators(QString("%1/%2.pano.dng") + .arg(QFileInfo(inputFiles[0]).absolutePath(), + QFileInfo(inputFiles[0]).baseName())); + + LogManager::instance()->log( + QString("[ Panorama.cpp ] - Saving panorama to %1").arg(filename), PHOTON_INFO); + + int max_dim = 256; + double scale = (double)max_dim / std::max(result16.cols, result16.rows); + cv::Mat thumbnail16; + cv::resize(result16, thumbnail16, cv::Size(), scale, scale, cv::INTER_AREA); + + cv::Mat thumbFloat; + thumbnail16.convertTo(thumbFloat, CV_32FC3, 1.0 / 65535.0); + cv::pow(thumbFloat, 1.0 / 2.2, thumbFloat); + + cv::Mat thumbnail8; + thumbFloat.convertTo(thumbnail8, CV_8UC3, 255.0); + + TIFF* out = TIFFOpen(filename.toStdString().c_str(), "w"); + if (!out) { + result["success"] = false; + result["message"] = "Could not open file for writing."; + return result; + } + + // --- Shared DNG metadata on IFD 0 --- + TIFFSetField(out, TIFFTAG_MAKE, "Photon"); + TIFFSetField(out, TIFFTAG_MODEL, "Panorama Engine"); + TIFFSetField(out, TIFFTAG_UNIQUECAMERAMODEL, "Photon Panorama Engine"); + + static const uint8_t dng_ver[] = {1, 4, 0, 0}; + TIFFSetField(out, TIFFTAG_DNGVERSION, dng_ver); + TIFFSetField(out, TIFFTAG_DNGBACKWARDVERSION, dng_ver); + + // --- Thumbnail image fields --- + TIFFSetField(out, TIFFTAG_SUBFILETYPE, FILETYPE_REDUCEDIMAGE); // 0x1 + TIFFSetField(out, TIFFTAG_IMAGEWIDTH, thumbnail8.cols); + TIFFSetField(out, TIFFTAG_IMAGELENGTH, thumbnail8.rows); + TIFFSetField(out, TIFFTAG_BITSPERSAMPLE, 8); + TIFFSetField(out, TIFFTAG_SAMPLESPERPIXEL, 3); + TIFFSetField(out, TIFFTAG_PHOTOMETRIC, PHOTOMETRIC_RGB); + TIFFSetField(out, TIFFTAG_COMPRESSION, COMPRESSION_JPEG); // JPEG is preferred by most viewers + TIFFSetField(out, TIFFTAG_JPEGQUALITY, 90); + TIFFSetField(out, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG); + TIFFSetField(out, TIFFTAG_ORIENTATION, ORIENTATION_TOPLEFT); + TIFFSetField(out, TIFFTAG_ROWSPERSTRIP, thumbnail8.rows); // single strip for thumbnail + + for (int row = 0; row < thumbnail8.rows; row++) { + uint8_t* rowPtr = thumbnail8.ptr(row); + TIFFWriteScanline(out, rowPtr, row, 0); + } + + TIFFWriteDirectory(out); // seals IFD 0, advances to IFD 1 + + TIFFSetField(out, TIFFTAG_SUBFILETYPE, 0); // full-resolution image + TIFFSetField(out, TIFFTAG_IMAGEWIDTH, resultRGB.cols); + TIFFSetField(out, TIFFTAG_IMAGELENGTH, resultRGB.rows); + TIFFSetField(out, TIFFTAG_SAMPLESPERPIXEL, 3); + TIFFSetField(out, TIFFTAG_BITSPERSAMPLE, 16); + TIFFSetField(out, TIFFTAG_ORIENTATION, ORIENTATION_TOPLEFT); + TIFFSetField(out, TIFFTAG_PLANARCONFIG, PLANARCONFIG_CONTIG); + TIFFSetField(out, TIFFTAG_PHOTOMETRIC, 34892); // LINEARRAW + TIFFSetField(out, TIFFTAG_SAMPLEFORMAT, SAMPLEFORMAT_UINT); + TIFFSetField(out, TIFFTAG_ROWSPERSTRIP, TIFFDefaultStripSize(out, 0)); + + uint32_t whiteLevel[3] = {65535, 65535, 65535}; + TIFFSetField(out, TIFFTAG_WHITELEVEL, 3, whiteLevel); + TIFFSetField(out, TIFFTAG_COLORMATRIX1, 9, colorInfo.get()->matrix); + TIFFSetField(out, TIFFTAG_ASSHOTNEUTRAL, 3, colorInfo.get()->asShotNeutral); + TIFFSetField(out, TIFFTAG_CALIBRATIONILLUMINANT1, 23); + + for (int row = 0; row < resultRGB.rows; row++) { + uint16_t* rowPtr = resultRGB.ptr(row); + if (TIFFWriteScanline(out, rowPtr, row, 0) < 0) { + TIFFClose(out); + result["success"] = false; + result["message"] = "Error writing scanline to DNG."; + return result; + } + } + + TIFFClose(out); + + LogManager::instance()->log( + "[ Panorama.cpp ] - Panorama stitching completed successfully", PHOTON_INFO); + + result["success"] = true; + result["message"] = "Success! DNG saved to " + filename; + result["filename"] = filename; + result["width"] = resultRGB.cols; + result["height"] = resultRGB.rows; + return result; +} diff --git a/src/engine/Panorama.h b/src/engine/Panorama.h new file mode 100644 index 0000000..4e8214f --- /dev/null +++ b/src/engine/Panorama.h @@ -0,0 +1,42 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace photon { + +struct ColorInfo { + float matrix[9]; // XYZ→camera (for ColorMatrix1) + float asShotNeutral[3]; // normalized WB + +}; + +class Panorama : public QObject { + Q_OBJECT + QML_ELEMENT + QML_SINGLETON + +private: + static cv::Mat raw_to_linear(const QString& file, std::unique_ptr& colorInfo); + + void estimateTransform(cv::Stitcher& stitcher); + QVariantMap stitchPhotos(const QStringList& inputFiles, bool compensateExposure, size_t featuresThreshold = 2000); + +public: + explicit Panorama(QObject* parent = nullptr) : QObject(parent) {} + Q_INVOKABLE void stitchAsync(const QStringList& inputFiles, bool compensateExposure = false); +signals: + void stitchCompleted(QVariantMap result); +}; + +} diff --git a/src/engine/RawEngine.cpp b/src/engine/RawEngine.cpp index f3a26bf..4a936de 100644 --- a/src/engine/RawEngine.cpp +++ b/src/engine/RawEngine.cpp @@ -16,13 +16,16 @@ #include #include #include +#if defined(__AVX2__) || defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) +#include +#endif +#include "../components/ToneLutProvider.h" #include "../managers/AppStateManager.h" #include "../managers/LogManager.h" #include "../managers/PreviewManager.h" #include "Denoiser.h" #include "GpuSearcher.h" -#include "../components/ToneLutProvider.h" using namespace photon; @@ -107,13 +110,23 @@ static void apply_region_tint_cpp(float& r, float& g, float& b, float hue, } static float compute_target_luma_hist(float luma, float stops) { + if (stops == 0.0f) return luma; + float target = luma * std::pow(2.0f, stops); + if (target > 1.0f) { + float over = target - 1.0f; + target = 1.0f + over / (1.0f + over * 1.25f); + } + return std::max(target, 0.0f); +} + +static float compute_toe_target_hist(float luma, float stops) { + if (stops == 0.0f) return luma; float target = luma * std::pow(2.0f, stops); if (stops > 0.0f) { - float over = std::max(target - 1.0f, 0.0f); - if (over > 0.0f) { - float shoulder = 1.2f + 3.0f * std::clamp(stops, 0.0f, 1.0f); - target = 1.0f + over / (1.0f + over * shoulder); - } + float liftGamma = 1.0f / (1.0f + stops * 0.5f); + float liftTarget = std::pow(std::max(luma, 1e-6f), liftGamma); + float toeMask = 1.0f - smoothstep(0.0f, 0.15f, luma); + target = lerp(target, liftTarget, toeMask * 0.4f); } return std::max(target, 0.0f); } @@ -122,15 +135,387 @@ static void apply_luma_target_hist(float& r, float& g, float& b, float lumaIn, float targetLuma) { targetLuma = std::max(targetLuma, 0.0f); float safeLuma = std::max(lumaIn, 1e-4f); - float lumaDelta = targetLuma - lumaIn; float lumaRatio = targetLuma / safeLuma; - float blend = smoothstep(0.02f, 0.34f, lumaIn); - float addR = r + lumaDelta; - float addG = g + lumaDelta; - float addB = b + lumaDelta; - r = lerp(addR, r * lumaRatio, blend); - g = lerp(addG, g * lumaRatio, blend); - b = lerp(addB, b * lumaRatio, blend); + float maxRatio = 1.0f + 9.0f * smoothstep(0.0f, 0.08f, lumaIn); + float safeRatio = std::clamp(lumaRatio, 0.0f, maxRatio); + r *= safeRatio; + g *= safeRatio; + b *= safeRatio; +} + +struct Vec3fHist { + float r; + float g; + float b; +}; + +static float step_hist(float edge, float x) { return x < edge ? 0.0f : 1.0f; } + +static float sign_hist(float x) { + if (x > 0.0f) return 1.0f; + if (x < 0.0f) return -1.0f; + return 0.0f; +} + +static Vec3fHist clamp_vec3_hist(const Vec3fHist& c, float lo, float hi) { + return {std::clamp(c.r, lo, hi), std::clamp(c.g, lo, hi), + std::clamp(c.b, lo, hi)}; +} + +#if defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) +static inline __m128 load_rgb16_norm_hist_sse(const ushort* p) { + constexpr float invU16 = 1.0f / 65535.0f; + return _mm_mul_ps(_mm_set_ps(0.0f, float(p[2]), float(p[1]), float(p[0])), + _mm_set1_ps(invU16)); +} +#endif + +#if defined(__AVX2__) +static inline __m128 load_rgb16_norm_hist_avx2(const ushort* p) { + alignas(16) uint16_t lanes[8] = {p[0], p[1], p[2], 0, 0, 0, 0, 0}; + const __m128i packed16 = + _mm_load_si128(reinterpret_cast(lanes)); + const __m256i expanded32 = _mm256_cvtepu16_epi32(packed16); + const __m256 asFloat = _mm256_mul_ps(_mm256_cvtepi32_ps(expanded32), + _mm256_set1_ps(1.0f / 65535.0f)); + return _mm256_castps256_ps128(asFloat); +} +#endif + +static Vec3fHist sample_source_linear_bilinear_hist(const ushort* src, + int width, int height, + float u, float v) { + u = std::clamp(u, 0.0f, 1.0f); + v = std::clamp(v, 0.0f, 1.0f); + + const float xf = u * float(width) - 0.5f; + const float yf = v * float(height) - 0.5f; + const int x0 = int(std::floor(xf)); + const int y0 = int(std::floor(yf)); + const int x1 = x0 + 1; + const int y1 = y0 + 1; + const float tx = xf - float(x0); + const float ty = yf - float(y0); + + auto sample_ptr = [src, width, height](int x, int y) { + x = std::clamp(x, 0, width - 1); + y = std::clamp(y, 0, height - 1); + return src + (y * width + x) * 3; + }; + + const ushort* p00 = sample_ptr(x0, y0); + const ushort* p10 = sample_ptr(x1, y0); + const ushort* p01 = sample_ptr(x0, y1); + const ushort* p11 = sample_ptr(x1, y1); + +#if defined(__AVX2__) + const __m128 c00 = load_rgb16_norm_hist_avx2(p00); + const __m128 c10 = load_rgb16_norm_hist_avx2(p10); + const __m128 c01 = load_rgb16_norm_hist_avx2(p01); + const __m128 c11 = load_rgb16_norm_hist_avx2(p11); + + const __m256 cTopBottom0 = + _mm256_insertf128_ps(_mm256_castps128_ps256(c00), c01, 1); + const __m256 cTopBottom1 = + _mm256_insertf128_ps(_mm256_castps128_ps256(c10), c11, 1); + const __m256 txV = _mm256_set1_ps(tx); + const __m256 oneMinusTxV = _mm256_set1_ps(1.0f - tx); + const __m256 horiz = + _mm256_add_ps(_mm256_mul_ps(cTopBottom0, oneMinusTxV), + _mm256_mul_ps(cTopBottom1, txV)); + + const __m256 top = _mm256_permute2f128_ps(horiz, horiz, 0x00); + const __m256 bottom = _mm256_permute2f128_ps(horiz, horiz, 0x11); + const __m256 out = _mm256_add_ps(_mm256_mul_ps(top, _mm256_set1_ps(1.0f - ty)), + _mm256_mul_ps(bottom, _mm256_set1_ps(ty))); + + alignas(16) float packed[4]; + _mm_store_ps(packed, _mm256_castps256_ps128(out)); + return {packed[0], packed[1], packed[2]}; +#elif defined(__SSE2__) || defined(_M_X64) || defined(_M_IX86_FP) + const __m128 c00 = load_rgb16_norm_hist_sse(p00); + const __m128 c10 = load_rgb16_norm_hist_sse(p10); + const __m128 c01 = load_rgb16_norm_hist_sse(p01); + const __m128 c11 = load_rgb16_norm_hist_sse(p11); + + const __m128 txV = _mm_set1_ps(tx); + const __m128 oneMinusTxV = _mm_set1_ps(1.0f - tx); + const __m128 top = + _mm_add_ps(_mm_mul_ps(c00, oneMinusTxV), _mm_mul_ps(c10, txV)); + const __m128 bottom = + _mm_add_ps(_mm_mul_ps(c01, oneMinusTxV), _mm_mul_ps(c11, txV)); + const __m128 out = _mm_add_ps(_mm_mul_ps(top, _mm_set1_ps(1.0f - ty)), + _mm_mul_ps(bottom, _mm_set1_ps(ty))); + + alignas(16) float packed[4]; + _mm_store_ps(packed, out); + return {packed[0], packed[1], packed[2]}; +#else + constexpr float invU16 = 1.0f / 65535.0f; + const Vec3fHist c00{p00[0] * invU16, p00[1] * invU16, p00[2] * invU16}; + const Vec3fHist c10{p10[0] * invU16, p10[1] * invU16, p10[2] * invU16}; + const Vec3fHist c01{p01[0] * invU16, p01[1] * invU16, p01[2] * invU16}; + const Vec3fHist c11{p11[0] * invU16, p11[1] * invU16, p11[2] * invU16}; + + const float topR = lerp(c00.r, c10.r, tx); + const float topG = lerp(c00.g, c10.g, tx); + const float topB = lerp(c00.b, c10.b, tx); + const float bottomR = lerp(c01.r, c11.r, tx); + const float bottomG = lerp(c01.g, c11.g, tx); + const float bottomB = lerp(c01.b, c11.b, tx); + return {lerp(topR, bottomR, ty), lerp(topG, bottomG, ty), + lerp(topB, bottomB, ty)}; +#endif +} + +static Vec3fHist compute_fine_blur_hist(const ushort* src, int width, + int height, float u, float v) { + Vec3fHist blur{0.0f, 0.0f, 0.0f}; + const float invW = 1.0f / float(width); + const float invH = 1.0f / float(height); + auto tap = [&](float dx, float dy, float w) { + Vec3fHist s = sample_source_linear_bilinear_hist(src, width, height, + u + dx * invW, + v + dy * invH); + blur.r += s.r * w; + blur.g += s.g * w; + blur.b += s.b * w; + }; + + constexpr float r1 = 1.5f; + constexpr float r2 = 3.0f; + + tap(0.0f, 0.0f, 0.18f); + tap(r1, 0.0f, 0.095f); + tap(-r1, 0.0f, 0.095f); + tap(0.0f, r1, 0.095f); + tap(0.0f, -r1, 0.095f); + tap(r1, r1, 0.055f); + tap(-r1, r1, 0.055f); + tap(r1, -r1, 0.055f); + tap(-r1, -r1, 0.055f); + + tap(r2, 0.0f, 0.04f); + tap(-r2, 0.0f, 0.04f); + tap(0.0f, r2, 0.04f); + tap(0.0f, -r2, 0.04f); + tap(r2, r2, 0.015f); + tap(-r2, r2, 0.015f); + tap(r2, -r2, 0.015f); + tap(-r2, -r2, 0.015f); + return blur; +} + +static Vec3fHist compute_coarse_blur_hist(const ushort* src, int width, + int height, float u, float v) { + Vec3fHist blur{0.0f, 0.0f, 0.0f}; + const float invW = 1.0f / float(width); + const float invH = 1.0f / float(height); + auto tap = [&](float dx, float dy, float w) { + Vec3fHist s = sample_source_linear_bilinear_hist(src, width, height, + u + dx * invW, + v + dy * invH); + blur.r += s.r * w; + blur.g += s.g * w; + blur.b += s.b * w; + }; + + constexpr float r1 = 4.5f; + constexpr float r2 = 7.0f; + constexpr float r3 = 9.5f; + + tap(0.0f, 0.0f, 0.20f); + tap(r1, 0.0f, 0.055f); + tap(-r1, 0.0f, 0.055f); + tap(0.0f, r1, 0.055f); + tap(0.0f, -r1, 0.055f); + tap(r1, r1, 0.038f); + tap(-r1, r1, 0.038f); + tap(r1, -r1, 0.038f); + tap(-r1, -r1, 0.038f); + + tap(r2, 0.0f, 0.04f); + tap(-r2, 0.0f, 0.04f); + tap(0.0f, r2, 0.04f); + tap(0.0f, -r2, 0.04f); + tap(r2, r2, 0.03f); + tap(-r2, r2, 0.03f); + tap(r2, -r2, 0.03f); + tap(-r2, -r2, 0.03f); + + tap(r3, 0.0f, 0.022f); + tap(-r3, 0.0f, 0.022f); + tap(0.0f, r3, 0.022f); + tap(0.0f, -r3, 0.022f); + tap(r3, r3, 0.015f); + tap(-r3, r3, 0.015f); + tap(r3, -r3, 0.015f); + tap(-r3, -r3, 0.015f); + return blur; +} + +constexpr float PV_FLARE_LINEAR_HIST = 0.000244140625f; // 2^-12 +constexpr float PV_FLARE_LOG_HIST = -12.0f; +constexpr float PV_EPS_HIST = 0.00000190734f; + +static Vec3fHist eval_undo_render_curve_hist(const Vec3fHist& col) { + constexpr float eps = 0.00001f; + const float fMin = std::min({col.r, col.g, col.b}); + const float fMax = std::max({col.r, col.g, col.b}); + const float tMin = std::pow(fMin, 3.14453125f); + const float tMax = std::pow(fMax, 3.14453125f); + const float nMin = std::pow(fMin, 0.8125f) * 0.3828125f * (1.0f - tMin) + + (1.0f - std::pow(1.0f - fMin, 0.69140625f)) * tMin; + const float nMax = std::pow(fMax, 0.8125f) * 0.3828125f * (1.0f - tMax) + + (1.0f - std::pow(1.0f - fMax, 0.69140625f)) * tMax; + const float scale = (nMax - nMin) / (fMax - fMin + eps); + return {(col.r - fMin) * scale + nMin, (col.g - fMin) * scale + nMin, + (col.b - fMin) * scale + nMin}; +} + +static float pv_working_luma_linear_hist(const Vec3fHist& c) { + Vec3fHist clamped = clamp_vec3_hist(c, 0.0001f, 0.999f); + Vec3fHist prophoto{ + 0.529285f * clamped.r + 0.330046f * clamped.g + 0.140669f * clamped.b, + 0.098394f * clamped.r + 0.873493f * clamped.g + 0.028113f * clamped.b, + 0.016823f * clamped.r + 0.117671f * clamped.g + 0.865506f * clamped.b}; + Vec3fHist unmapped = + clamp_vec3_hist(eval_undo_render_curve_hist(prophoto), 0.0f, 1.0f); + return std::max(unmapped.r * 0.25f + unmapped.g * 0.5f + unmapped.b * 0.25f, + PV_EPS_HIST); +} + +static float pv_encode_log_luma_hist(float linearLuma) { + return std::log2(std::max(linearLuma + PV_FLARE_LINEAR_HIST, PV_EPS_HIST)); +} + +static float pv_decode_log_luma_hist(float logLuma) { + return std::max(std::exp2(logLuma) - PV_FLARE_LINEAR_HIST, PV_EPS_HIST); +} + +static float endpoint_pin_mask_component_hist(float x) { + x = std::clamp(x, 0.0f, 1.0f); + const float inv = 1.0f - x; + const float inv2 = inv * inv; + const float inv4 = inv2 * inv2; + const float inv8 = inv4 * inv4; + const float inv16 = inv8 * inv8; + const float base = 1.0f - inv8; + const float strong = 1.0f - inv16; + return lerp(base, strong, smoothstep(0.35f, 1.0f, x)); +} + +static float pv_log_luma_hist(const Vec3fHist& c) { + return pv_encode_log_luma_hist(pv_working_luma_linear_hist(c)); +} + +static float pv_tent_weight_hist(float value, float center, float halfWidth) { + return std::max( + 1.0f - std::abs(value - center) / std::max(halfWidth, PV_EPS_HIST), 0.0f); +} + +static Vec3fHist apply_photon0001_tone_ranges_hist( + const Vec3fHist& color, const Vec3fHist& blurredFine, + const Vec3fHist& blurredCoarse, float highlightsAmt, float shadowsAmt, + float whitesAmt, float blacksAmt, float clarityAmt, float sceneWhiteNorm) { + const float srcGrayLinear = pv_working_luma_linear_hist(color); + const float srcGrayLog = pv_encode_log_luma_hist(srcGrayLinear); + const float blurFineLog = pv_log_luma_hist(blurredFine); + const float blurCoarseLog = pv_log_luma_hist(blurredCoarse); + const float toneMid = + pv_encode_log_luma_hist(std::max(sceneWhiteNorm * 0.18f, PV_EPS_HIST)); + + const float wBlacks = pv_tent_weight_hist(srcGrayLog, toneMid - 3.8f, 1.8f); + const float wShadows = pv_tent_weight_hist(srcGrayLog, toneMid - 1.9f, 1.9f); + const float wHighlights = + pv_tent_weight_hist(srcGrayLog, toneMid + 1.0f, 1.9f); + const float wWhites = pv_tent_weight_hist(srcGrayLog, toneMid + 3.1f, 2.2f); + + const float maskFine = std::clamp(srcGrayLog - blurFineLog, -2.0f, 2.0f); + const float maskCoarse = std::clamp(blurFineLog - blurCoarseLog, -2.0f, 2.0f); + const float mask = + std::clamp(maskFine * 0.70f + maskCoarse * 0.45f, -2.5f, 2.5f); + + const float partSwitch = step_hist(srcGrayLog, toneMid); + const float compressedLow = toneMid + (srcGrayLog - toneMid) * 0.78f; + const float compressedHigh = toneMid + (srcGrayLog - toneMid) * 0.58f; + const float baseCompressed = lerp(compressedHigh, compressedLow, partSwitch); + + float localContrastSignal = srcGrayLog + mask - baseCompressed; + localContrastSignal *= std::max(clarityAmt, 0.0f); + localContrastSignal *= + std::clamp(1.0f + 0.35f * (-highlightsAmt + shadowsAmt), 1.0f, 2.0f); + const float localSignalHigh = std::max(localContrastSignal, 0.0f); + const float localSignalLow = std::min(localContrastSignal, 0.0f); + + const float lumWeightHigh = + std::clamp(wHighlights + 0.6f * wWhites, 0.0f, 1.0f); + const float lumWeightLow = std::clamp(wShadows + 0.6f * wBlacks, 0.0f, 1.0f); + const float endpointHigh = std::clamp( + std::abs(highlightsAmt) + 0.35f * std::abs(whitesAmt), 0.0f, 1.0f); + const float endpointLow = std::clamp( + std::abs(shadowsAmt) + 0.35f * std::abs(blacksAmt), 0.0f, 1.0f); + const float clarityPinHigh = + lerp(endpoint_pin_mask_component_hist(lumWeightHigh), 1.0f, + endpointHigh * endpointHigh); + const float clarityPinLow = + lerp(endpoint_pin_mask_component_hist(lumWeightLow), 1.0f, + endpointLow * endpointLow); + + float hsPinY = + lerp(0.5f + 0.5f * std::max(1.0f - sign_hist(shadowsAmt), 0.0f), 1.0f, + clarityPinHigh); + float hsPinX = + lerp(1.0f, 0.5f, + (1.0f - clarityPinLow) * std::max(-sign_hist(highlightsAmt), 0.0f)); + hsPinX = lerp(1.0f, hsPinX, std::clamp(std::abs(highlightsAmt), 0.0f, 1.0f)); + + const float maxAbsHS = std::max( + std::max(std::abs(highlightsAmt), std::abs(shadowsAmt)), PV_EPS_HIST); + const float baseOffset = 0.85f * (highlightsAmt + shadowsAmt) / maxAbsHS; + const float offsetHSHigh = wHighlights * std::abs(highlightsAmt) * baseOffset; + const float offsetHSLow = wShadows * std::abs(shadowsAmt) * baseOffset; + + float deltaHSHigh = std::clamp(-highlightsAmt, -1.0f, 1.0f); + float deltaHSLow = std::clamp(shadowsAmt, -1.0f, 1.0f); + deltaHSHigh *= std::min(mask, 0.0f); + deltaHSLow *= std::max(mask, 0.0f); + deltaHSHigh += offsetHSHigh; + deltaHSLow += offsetHSLow; + + float deltaStops = deltaHSHigh * hsPinX + deltaHSLow * hsPinY; + deltaStops += whitesAmt * wWhites * hsPinX; + deltaStops += blacksAmt * wBlacks * hsPinY; + deltaStops += + localSignalHigh * clarityPinHigh + localSignalLow * clarityPinLow; + + const float deltaSign = sign_hist(deltaStops); + const float flareSwitch = 1.0f - std::max(deltaSign, 0.0f); + const float zeroSwitch = 1.0f - std::abs(deltaSign); + const float flare = flareSwitch * PV_FLARE_LOG_HIST; + const float startpoint = flare - (deltaStops + deltaStops); + const float t1 = step_hist(startpoint, srcGrayLog); + const float t2 = step_hist(srcGrayLog, startpoint); + float t = + std::clamp((srcGrayLog - startpoint) / (flare - startpoint + zeroSwitch), + 0.0f, 1.0f); + t *= t * (1.0f - lerp(t2, t1, flareSwitch)); + deltaStops = lerp(deltaStops, 0.0f, t); + + deltaStops = std::min(deltaStops, 4.0f); + const float targetLog = srcGrayLog + deltaStops; + float targetLuma = pv_decode_log_luma_hist(targetLog); + + if (targetLuma > sceneWhiteNorm && deltaStops > 0.0f) { + const float over = targetLuma - sceneWhiteNorm; + const float knee = std::max(sceneWhiteNorm * 0.7f, PV_EPS_HIST); + const float compress = over / (1.0f + over / knee); + targetLuma = sceneWhiteNorm + compress; + } + + Vec3fHist out = color; + apply_luma_target_hist(out.r, out.g, out.b, srcGrayLinear, targetLuma); + return out; } RawEngine::RawEngine(QObject* parent) @@ -222,10 +607,10 @@ RawEngine::RawEngine(QObject* parent) LogManager::instance()->log( QString("[ RawEngine ] - previewWatcher callback START (thread: %1)") .arg((quintptr)QThread::currentThread()), - "DEBUG"); + PHOTON_DEBUG); if (m_previewWatcher.isCanceled()) { LogManager::instance()->log("[ RawEngine ] - previewWatcher: canceled", - "DEBUG"); + PHOTON_DEBUG); return; } QImage result = m_previewWatcher.result(); @@ -234,11 +619,11 @@ RawEngine::RawEngine(QObject* parent) .arg(result.isNull()) .arg(result.width()) .arg(result.height()), - "DEBUG"); + PHOTON_DEBUG); m_previewImage = result; emit previewImageChanged(); LogManager::instance()->log("[ RawEngine ] - previewWatcher callback END", - "DEBUG"); + PHOTON_DEBUG); }); // Listen for background previews @@ -279,7 +664,9 @@ void RawEngine::releaseGpuResources() { void RawEngine::updateProcessingParams() { m_processor->imgdata.params.use_camera_wb = 1; m_processor->imgdata.params.output_bps = 16; - m_processor->imgdata.params.no_auto_bright = 1; + // NOTE: The user should be able to change/adjust this + m_processor->imgdata.params.no_auto_bright = 0; + m_processor->imgdata.params.auto_bright_thr = 0.01; m_processor->imgdata.params.half_size = m_halfSize ? 1 : 0; } @@ -299,14 +686,14 @@ void RawEngine::setHalfSize(bool half) { void RawEngine::setSource(const QString& source) { LogManager::instance()->log( - QString("[ RawEngine ] - setSource START: %1").arg(source), "INFO"); + QString("[ RawEngine ] - setSource START: %1").arg(source), PHOTON_INFO); // 1. Abort any ongoing denoise tasks m_abortDenoise = true; if (m_source == source) { LogManager::instance()->log( - "[ RawEngine ] - setSource: same source, skipping", "DEBUG"); + "[ RawEngine ] - setSource: same source, skipping", PHOTON_DEBUG); return; } @@ -330,7 +717,7 @@ void RawEngine::setSource(const QString& source) { // Try to get existing preview immediately LogManager::instance()->log("[ RawEngine ] - setSource: getting preview path", - "DEBUG"); + PHOTON_DEBUG); if (photon::PreviewManager::instance()) { m_previewPath = photon::PreviewManager::instance()->getPreviewPath(m_source); @@ -340,7 +727,7 @@ void RawEngine::setSource(const QString& source) { LogManager::instance()->log( QString("[ RawEngine ] - setSource: starting preview image load: %1") .arg(m_previewPath), - "DEBUG"); + PHOTON_DEBUG); m_previewWatcher.setFuture( QtConcurrent::run([path = m_previewPath]() { return QImage(path); })); } @@ -349,8 +736,8 @@ void RawEngine::setSource(const QString& source) { m_histogramUpdatePending = false; m_metadata.clear(); m_orientation = 1; - m_exposure = 0.0f; - m_contrast = 1.0f; + setExposure(0.0f); + setContrast(1.0f); m_hasDenoisedResult = false; // Clear geometry bake state @@ -372,10 +759,10 @@ void RawEngine::setSource(const QString& source) { // Start async loading LogManager::instance()->log("[ RawEngine ] - setSource: starting async load", - "DEBUG"); + PHOTON_DEBUG); loadRawFileAsync(source); - LogManager::instance()->log("[ RawEngine ] - setSource END", "INFO"); + LogManager::instance()->log("[ RawEngine ] - setSource END", PHOTON_INFO); } void RawEngine::setViewportSize(const QSize& size) { @@ -431,6 +818,12 @@ void RawEngine::setWhites(float val) { emit isDefaultChanged(); } +void RawEngine::setSceneWhite(float val) { + if (qFuzzyCompare(m_sceneWhite, val)) return; + m_sceneWhite = val; + emit sceneWhiteChanged(); +} + void RawEngine::setBlacks(float val) { if (qFuzzyCompare(m_blacks, val)) return; m_blacks = val; @@ -548,9 +941,12 @@ void RawEngine::setDenoiseSearchWindow(int val) { void RawEngine::setDenoiseGroupSize(int val) { // Snap to nearest power of 2 (4, 8, 16) - if (val <= 6) val = 4; - else if (val <= 12) val = 8; - else val = 16; + if (val <= 6) + val = 4; + else if (val <= 12) + val = 8; + else + val = 16; if (m_denoiseGroupSize == val) return; m_denoiseGroupSize = val; m_hasDenoisedResult = false; @@ -848,15 +1244,13 @@ void RawEngine::startAsyncDenoise(bool final, float zoom, const QRectF& roi) { dparams.chromaDenoise = m_denoiseChromaAmount; dparams.chromaBm3d = m_denoiseChromaBm3d; - QFuture future = QtConcurrent::run([img, amount, abortPtr, - useSecondPass, stride, gpuMatches, - dparams]() { - return photon::Denoiser::denoise(img, amount, abortPtr, useSecondPass, - stride, gpuMatches, dparams); - }); + QFuture future = QtConcurrent::run( + [img, amount, abortPtr, useSecondPass, stride, gpuMatches, dparams]() { + return photon::Denoiser::denoise(img, amount, abortPtr, useSecondPass, + stride, gpuMatches, dparams); + }); m_denoiseWatcher.setFuture(future); - LogManager::instance()->log( - QString("[ RawEngine.cpp ] - Started denoise")); + LogManager::instance()->log(QString("[ RawEngine.cpp ] - Started denoise")); } void RawEngine::clearDenoisedResult() { @@ -1203,7 +1597,7 @@ void RawEngine::setFlipVertical(bool flip) { } std::vector RawEngine::evalMonotonicSpline(const QVariantList& pts, - int lutSize) { + int lutSize) { std::vector lut(lutSize); int n = pts.size(); if (n < 2) { @@ -1316,7 +1710,8 @@ void RawEngine::rebuildToneLut() { constexpr int kToneLutSide = 256; constexpr int kToneLutRowsPerChannel = kToneLutEntries / kToneLutSide; // 256 constexpr int kToneLutChannels = 4; - constexpr int kToneLutHeight = kToneLutRowsPerChannel * kToneLutChannels; // 1024 + constexpr int kToneLutHeight = + kToneLutRowsPerChannel * kToneLutChannels; // 1024 auto lutL = evalMonotonicSpline(m_toneCurveLuma, kToneLutEntries); auto lutR = evalMonotonicSpline(m_toneCurveRed, kToneLutEntries); @@ -1336,13 +1731,13 @@ void RawEngine::rebuildToneLut() { uint16_t(std::clamp(lutG[i] * 65535.0f + 0.5f, 0.0f, 65535.0f)); uint16_t vB = uint16_t(std::clamp(lutB[i] * 65535.0f + 0.5f, 0.0f, 65535.0f)); - if (vL != i || vR != i || vG != i || vB != i) - active = true; + if (vL != i || vR != i || vG != i || vB != i) active = true; } // 256×1024 RGBA texture: 4 channel planes (Luma, R, G, B), each a 256×256 // tile encoding 65536 LUT entries packed as 16-bit in RG (high, low). - m_toneLutImage = QImage(kToneLutSide, kToneLutHeight, QImage::Format_RGBA8888); + m_toneLutImage = + QImage(kToneLutSide, kToneLutHeight, QImage::Format_RGBA8888); m_toneLutImage.fill(Qt::black); const std::vector* luts[4] = {&lutL, &lutR, &lutG, &lutB}; for (int channel = 0; channel < 4; channel++) { @@ -1385,7 +1780,9 @@ void RawEngine::requestHistogramUpdate() { float high = m_highlights; float shad = m_shadows; float whites = m_whites; + float sceneWhite = m_sceneWhite; float blacks = m_blacks; + float clarity = m_clarity; float temp = m_temperature / 100.0f; float tint = m_tint / 100.0f; @@ -1417,18 +1814,21 @@ void RawEngine::requestHistogramUpdate() { // Capture image data pointer and dimensions const ushort* src = reinterpret_cast(m_processedImage->data); - int totalPixels = m_processedImage->width * m_processedImage->height; + int imageWidth = m_processedImage->width; + int imageHeight = m_processedImage->height; + int totalPixels = imageWidth * imageHeight; if (!src || totalPixels <= 0) return; m_histogramUpdatePending = true; m_histogramNeedsUpdate = false; - m_histogramFuture = QtConcurrent::run([this, src, totalPixels, exp, con, high, - shad, whites, blacks, temp, tint, - hsl_h, hsl_s, hsl_l, cgSH, cgSS, cgSL, - cgMH, cgMS, cgML, cgHH, cgHS, cgHL, - cgBal, cgBlen]() { + m_histogramFuture = QtConcurrent::run([this, src, imageWidth, imageHeight, + totalPixels, exp, con, high, shad, + whites, sceneWhite, blacks, clarity, + temp, tint, hsl_h, hsl_s, hsl_l, cgSH, + cgSS, cgSL, cgMH, cgMS, cgML, cgHH, + cgHS, cgHL, cgBal, cgBlen]() { std::vector r_bins(256, 0); std::vector g_bins(256, 0); std::vector b_bins(256, 0); @@ -1451,54 +1851,53 @@ void RawEngine::requestHistogramUpdate() { float g = src[i * 3 + 1] / 65535.0f; float b = src[i * 3 + 2] / 65535.0f; + const int x = i % imageWidth; + const int y = i / imageWidth; + const float u = (float(x) + 0.5f) / float(imageWidth); + const float v = (float(y) + 0.5f) / float(imageHeight); + Vec3fHist blurredFine = + compute_fine_blur_hist(src, imageWidth, imageHeight, u, v); + Vec3fHist blurredCoarse = + compute_coarse_blur_hist(src, imageWidth, imageHeight, u, v); + // 1. WB & Exposure r *= r_wb * exp_mult; g *= g_wb * exp_mult; b *= b_wb * exp_mult; + Vec3fHist color{r, g, b}; + + float l_tone = 0.2126f * std::max(0.0f, color.r) + + 0.7152f * std::max(0.0f, color.g) + + 0.0722f * std::max(0.0f, color.b); + if (l_tone > sceneWhite && exp > 0.0f) { + float over = l_tone - sceneWhite; + float knee = sceneWhite * 0.7f; + float compress = over / (1.0f + over / knee); + float targetL = sceneWhite + compress; + apply_luma_target_hist(color.r, color.g, color.b, l_tone, targetL); + } + + Vec3fHist blurredFineTone{blurredFine.r * r_wb * exp_mult, + blurredFine.g * g_wb * exp_mult, + blurredFine.b * b_wb * exp_mult}; + Vec3fHist blurredCoarseTone{blurredCoarse.r * r_wb * exp_mult, + blurredCoarse.g * g_wb * exp_mult, + blurredCoarse.b * b_wb * exp_mult}; + const float sceneWhiteNorm = std::max(sceneWhite * exp_mult, 1e-4f); + color = apply_photon0001_tone_ranges_hist( + color, blurredFineTone, blurredCoarseTone, high / 100.0f, + shad / 100.0f, whites / 100.0f, blacks / 100.0f, clarity / 100.0f, + sceneWhiteNorm); + r = color.r; + g = color.g; + b = color.b; + // 2. Contrast r = std::pow(std::max(0.0f, r), con); g = std::pow(std::max(0.0f, g), con); b = std::pow(std::max(0.0f, b), con); - // 3. Whites & Blacks (smoother masks, bounded response) - float l_tone = 0.2126f * std::max(0.0f, r) + 0.7152f * std::max(0.0f, g) + - 0.0722f * std::max(0.0f, b); - if (whites != 0.0f) { - float w = std::clamp(whites / 100.0f, -1.0f, 1.0f); - float whiteMask = smoothstep(0.42f, 1.20f, l_tone); - float target = compute_target_luma_hist(l_tone, w * 0.85f * whiteMask); - apply_luma_target_hist(r, g, b, l_tone, target); - l_tone = 0.2126f * std::max(0.0f, r) + 0.7152f * std::max(0.0f, g) + - 0.0722f * std::max(0.0f, b); - } - if (blacks != 0.0f) { - float bAdj = std::clamp(blacks / 100.0f, -1.0f, 1.0f); - float blackMask = 1.0f - smoothstep(0.0f, 0.48f, l_tone); - float target = - compute_target_luma_hist(l_tone, bAdj * 0.90f * blackMask); - apply_luma_target_hist(r, g, b, l_tone, target); - l_tone = 0.2126f * std::max(0.0f, r) + 0.7152f * std::max(0.0f, g) + - 0.0722f * std::max(0.0f, b); - } - - // 4. Highlights & Shadows (broader crossover, gentler extremes) - if (shad != 0.0f) { - float s = std::clamp(shad / 100.0f, -1.0f, 1.0f); - float shadowMask = 1.0f - smoothstep(0.05f, 0.62f, l_tone); - float target = compute_target_luma_hist(l_tone, s * 0.95f * shadowMask); - apply_luma_target_hist(r, g, b, l_tone, target); - l_tone = 0.2126f * std::max(0.0f, r) + 0.7152f * std::max(0.0f, g) + - 0.0722f * std::max(0.0f, b); - } - if (high != 0.0f) { - float h = std::clamp(high / 100.0f, -1.0f, 1.0f); - float highlightMask = smoothstep(0.22f, 1.25f, l_tone); - float target = - compute_target_luma_hist(l_tone, h * 0.90f * highlightMask); - apply_luma_target_hist(r, g, b, l_tone, target); - } - // 5. HSL PANEL HSV hsv = rgb_to_hsv_cpp(r, g, b); float hue_shift = 0.0f; @@ -1579,7 +1978,8 @@ void RawEngine::requestHistogramUpdate() { std::sort(allBins.begin(), allBins.end()); size_t p99_idx = std::min(allBins.size() - 1, static_cast(allBins.size() * 0.99)); - uint32_t max_val = allBins.empty() ? 1 : std::max(allBins[p99_idx], uint32_t(1)); + uint32_t max_val = + allBins.empty() ? 1 : std::max(allBins[p99_idx], uint32_t(1)); QMetaObject::invokeMethod( this, @@ -1617,7 +2017,8 @@ void RawEngine::requestHistogramUpdate() { } void RawEngine::clearProcessedImage() { - // Wait for any in-flight histogram task that references m_processedImage->data + // Wait for any in-flight histogram task that references + // m_processedImage->data if (m_histogramUpdatePending) { m_histogramFuture.waitForFinished(); m_histogramUpdatePending = false; @@ -1628,6 +2029,65 @@ void RawEngine::clearProcessedImage() { } } +static constexpr float LUMA_R = 0.2126f; +static constexpr float LUMA_G = 0.7152f; +static constexpr float LUMA_B = 0.0722f; +static float srgb_to_linear(float c) { + return (c <= 0.04045f) ? (c / 12.92f) : std::pow((c + 0.055f) / 1.055f, 2.4f); +} + +float RawEngine::computeSceneWhite(const libraw_processed_image_t* img, + float percentile) { + constexpr int BINS = 2048; + constexpr float BIN_SCALE = BINS - 1; + + uint32_t hist[BINS] = {}; + + size_t pixelCount = img->width * img->height; + size_t step = 4; // subsample every 4th pixel + int channels = img->colors; // 3 for RGB + + for (size_t i = 0; i < pixelCount; i += step) { + float r, g, b; + + if (img->bits == 16) { + const uint16_t* px = + reinterpret_cast(img->data) + i * channels; + r = px[0] / 65535.0f; + g = px[1] / 65535.0f; + b = px[2] / 65535.0f; + } else { + const uint8_t* px = img->data + i * channels; + r = px[0] / 255.0f; + g = px[1] / 255.0f; + b = px[2] / 255.0f; + } + + // sRGB -> linear, matches your shader's srgb_to_linear() + auto decode = [](float x) -> float { + return (x <= 0.04045f) ? x / 12.92f + : std::pow((x + 0.055f) / 1.055f, 2.4f); + }; + + float luma = + 0.2126f * decode(r) + 0.7152f * decode(g) + 0.0722f * decode(b); + + int bin = static_cast(std::clamp(luma, 0.0f, 1.0f) * BIN_SCALE); + hist[bin]++; + } + + size_t sampledPixels = (pixelCount + step - 1) / step; + size_t threshold = static_cast(sampledPixels * percentile); + size_t cumulative = 0; + + for (int bin = 0; bin < BINS; ++bin) { + cumulative += hist[bin]; + if (cumulative >= threshold) return (bin + 0.5f) / BIN_SCALE; + } + + return 1.0f; +} + void RawEngine::loadRawFileAsync(const QString& path) { m_isLoading = true; emit isLoadingChanged(); @@ -1637,6 +2097,11 @@ void RawEngine::loadRawFileAsync(const QString& path) { QMutexLocker locker(&m_processorMutex); if (loadId != m_currentLoadId) return LoadResult{false, loadId}; bool ok = loadRawFileSync(path, loadId); + m_processor->dcraw_process(); + libraw_processed_image_t* img = m_processor->dcraw_make_mem_image(); + setSceneWhite(computeSceneWhite(img)); + LibRaw::dcraw_clear_mem(img); + img = nullptr; return LoadResult{ok, loadId}; }); m_loadWatcher.setFuture(future); @@ -1967,7 +2432,8 @@ static void applyJsonToState(RawEngine* e, const QJsonObject& obj) { if (obj.contains("shadows")) e->setShadows(obj["shadows"].toDouble()); if (obj.contains("whites")) e->setWhites(obj["whites"].toDouble()); if (obj.contains("blacks")) e->setBlacks(obj["blacks"].toDouble()); - if (obj.contains("adaptation")) e->setAdaptation(obj["adaptation"].toDouble()); + if (obj.contains("adaptation")) + e->setAdaptation(obj["adaptation"].toDouble()); if (obj.contains("vibrance")) e->setVibrance(obj["vibrance"].toDouble()); if (obj.contains("saturation")) e->setSaturation(obj["saturation"].toDouble()); @@ -2008,9 +2474,12 @@ static void applyJsonToState(RawEngine* e, const QJsonObject& obj) { if (obj.contains("structure")) e->setStructure(obj["structure"].toDouble()); if (obj.contains("centre")) e->setCentre(obj["centre"].toDouble()); if (obj.contains("sharpness")) e->setSharpness(obj["sharpness"].toDouble()); - if (obj.contains("sharpenMask")) e->setSharpenMask(obj["sharpenMask"].toDouble()); - if (obj.contains("maskFeather")) e->setMaskFeather(obj["maskFeather"].toDouble()); - if (obj.contains("focusDetect")) e->setFocusDetect(obj["focusDetect"].toDouble()); + if (obj.contains("sharpenMask")) + e->setSharpenMask(obj["sharpenMask"].toDouble()); + if (obj.contains("maskFeather")) + e->setMaskFeather(obj["maskFeather"].toDouble()); + if (obj.contains("focusDetect")) + e->setFocusDetect(obj["focusDetect"].toDouble()); if (obj.contains("hslRedHue")) e->setHslRedHue(obj["hslRedHue"].toDouble()); if (obj.contains("hslRedSaturation")) @@ -2224,8 +2693,9 @@ void RawEngine::loadEdits() { if (m_source.isEmpty()) return; QFileInfo fileInfo(m_source); - QString editsPath = QDir::toNativeSeparators(fileInfo.absolutePath() + "/.PhotonData/edits/" + - fileInfo.fileName() + ".json"); + QString editsPath = + QDir::toNativeSeparators(fileInfo.absolutePath() + "/.PhotonData/edits/" + + fileInfo.fileName() + ".json"); m_editStack.clear(); @@ -2260,10 +2730,11 @@ void RawEngine::loadEdits() { // Apply last state - this will trigger signals and update UI QJsonObject lastState = arr.last().toObject(); LogManager::instance()->log( - QString("[ RawEngine ] - Loading edits: denoiseEnabled=%1, denoiseAmount=%2") + QString( + "[ RawEngine ] - Loading edits: denoiseEnabled=%1, denoiseAmount=%2") .arg(lastState["denoiseEnabled"].toBool()) .arg(lastState["denoiseAmount"].toDouble()), - "DEBUG"); + PHOTON_DEBUG); applyJsonToState(this, lastState); emit editStackChanged(); @@ -2300,9 +2771,11 @@ void RawEngine::commitEdit() { // Save full stack to file QFileInfo fileInfo(m_source); - QString editsDir = QDir::toNativeSeparators(fileInfo.absolutePath() + "/.PhotonData/edits"); + QString editsDir = + QDir::toNativeSeparators(fileInfo.absolutePath() + "/.PhotonData/edits"); QDir().mkpath(editsDir); - QString editsPath = QDir::toNativeSeparators(editsDir + "/" + fileInfo.fileName() + ".json"); + QString editsPath = + QDir::toNativeSeparators(editsDir + "/" + fileInfo.fileName() + ".json"); QJsonArray arr; for (const auto& v : m_editStack) { @@ -2474,7 +2947,8 @@ bool RawEngine::isDefault() const { if (!qFuzzyIsNull(m_cgBalance)) return false; if (!qFuzzyCompare(m_cgBlending, 50.0f)) return false; - // Tone curve check (non-default = more than 2 points or non-identity endpoints) + // Tone curve check (non-default = more than 2 points or non-identity + // endpoints) auto isIdentityCurve = [](const QVariantList& pts) { if (pts.size() != 2) return false; auto p0 = pts[0].toMap(); @@ -2530,11 +3004,11 @@ QImage RawEngine::applyGeometryTransforms(const QImage& input, int orientSteps, output = output.transformed(QTransform().scale(-1, -1), Qt::SmoothTransformation); } else if (flipH) { - output = output.transformed(QTransform().scale(-1, 1), - Qt::SmoothTransformation); + output = + output.transformed(QTransform().scale(-1, 1), Qt::SmoothTransformation); } else if (flipV) { - output = output.transformed(QTransform().scale(1, -1), - Qt::SmoothTransformation); + output = + output.transformed(QTransform().scale(1, -1), Qt::SmoothTransformation); } // 3. Straighten (fine rotation) @@ -2576,7 +3050,9 @@ QImage RawEngine::applyGeometryTransforms(const QImage& input, int orientSteps, } LogManager::instance()->log( - QString("[ RawEngine.cpp ] - cropDebug applyGeometry in=%1x%2 orient=%3 flipH=%4 flipV=%5 straighten=%6 cropN=(%7,%8,%9,%10) preCrop=%11x%12 cropPx=[%13,%14 -> %15,%16] out=%17x%18") + QString("[ RawEngine.cpp ] - cropDebug applyGeometry in=%1x%2 orient=%3 " + "flipH=%4 flipV=%5 straighten=%6 cropN=(%7,%8,%9,%10) " + "preCrop=%11x%12 cropPx=[%13,%14 -> %15,%16] out=%17x%18") .arg(inputW) .arg(inputH) .arg(orientSteps) @@ -2595,7 +3071,7 @@ QImage RawEngine::applyGeometryTransforms(const QImage& input, int orientSteps, .arg(cropBottom) .arg(output.width()) .arg(output.height()), - "DEBUG"); + PHOTON_DEBUG); return output; } @@ -2605,7 +3081,7 @@ void RawEngine::reloadWithGeometry() { LogManager::instance()->log( "[ RawEngine.cpp ] - reloadWithGeometry: re-decoding with geometry bake", - "DEBUG"); + PHOTON_DEBUG); m_inCropMode = false; m_isLoading = true; @@ -2620,71 +3096,68 @@ void RawEngine::reloadWithGeometry() { QRectF crop = m_cropRect; bool hasGeom = hasNonDefaultGeometry(); - QFuture future = QtConcurrent::run( - [this, path, loadId, orientSteps, flipH, flipV, straighten, crop, - hasGeom]() { - QMutexLocker locker(&m_processorMutex); - if (loadId != m_currentLoadId) - return LoadResult{false, loadId}; - - // Re-decode from RAW file - bool ok = loadRawFileSync(path, loadId); - if (!ok || loadId != m_currentLoadId) - return LoadResult{false, loadId}; - - if (!hasGeom) { - m_geometryBuffer.clear(); - m_geometryWidth = 0; - m_geometryHeight = 0; - return LoadResult{true, loadId}; - } - - // Get processed image from LibRaw - if (!m_processedImage) { - int ret = m_processor->dcraw_process(); - if (ret != LIBRAW_SUCCESS) return LoadResult{false, loadId}; - m_processedImage = m_processor->dcraw_make_mem_image(&ret); - if (!m_processedImage) return LoadResult{false, loadId}; - } - - int w = m_processedImage->width; - int h = m_processedImage->height; - int colors = m_processedImage->colors; - - // Convert LibRaw buffer to QImage - QImage srcImg; - if (colors == 3) { - srcImg = QImage(w, h, QImage::Format_RGBX64); - const ushort* src = - reinterpret_cast(m_processedImage->data); - QRgba64* dst = reinterpret_cast(srcImg.bits()); - for (int i = 0; i < w * h; ++i) { - dst[i] = QRgba64::fromRgba64(src[i * 3], src[i * 3 + 1], - src[i * 3 + 2], 65535); - } - } else { - srcImg = - QImage(reinterpret_cast(m_processedImage->data), w, - h, QImage::Format_RGBA64) - .copy(); - } - - // Apply geometry transforms - QImage transformed = applyGeometryTransforms(srcImg, orientSteps, flipH, - flipV, straighten, crop); - - // Convert back to RGBA64 buffer for getProcessedData - transformed = transformed.convertToFormat(QImage::Format_RGBA64); - int tw = transformed.width(); - int th = transformed.height(); - size_t bufSize = static_cast(tw) * th * 8; - m_geometryBuffer.resize(bufSize); - memcpy(m_geometryBuffer.data(), transformed.constBits(), bufSize); - m_geometryWidth = tw; - m_geometryHeight = th; - - return LoadResult{true, loadId}; - }); + QFuture future = QtConcurrent::run([this, path, loadId, + orientSteps, flipH, flipV, + straighten, crop, hasGeom]() { + QMutexLocker locker(&m_processorMutex); + if (loadId != m_currentLoadId) return LoadResult{false, loadId}; + + // Re-decode from RAW file + bool ok = loadRawFileSync(path, loadId); + if (!ok || loadId != m_currentLoadId) return LoadResult{false, loadId}; + + if (!hasGeom) { + m_geometryBuffer.clear(); + m_geometryWidth = 0; + m_geometryHeight = 0; + return LoadResult{true, loadId}; + } + + // Get processed image from LibRaw + if (!m_processedImage) { + int ret = m_processor->dcraw_process(); + if (ret != LIBRAW_SUCCESS) return LoadResult{false, loadId}; + m_processedImage = m_processor->dcraw_make_mem_image(&ret); + if (!m_processedImage) return LoadResult{false, loadId}; + } + + int w = m_processedImage->width; + int h = m_processedImage->height; + int colors = m_processedImage->colors; + + // Convert LibRaw buffer to QImage + QImage srcImg; + if (colors == 3) { + srcImg = QImage(w, h, QImage::Format_RGBX64); + const ushort* src = + reinterpret_cast(m_processedImage->data); + QRgba64* dst = reinterpret_cast(srcImg.bits()); + for (int i = 0; i < w * h; ++i) { + dst[i] = QRgba64::fromRgba64(src[i * 3], src[i * 3 + 1], src[i * 3 + 2], + 65535); + } + } else { + srcImg = QImage(reinterpret_cast(m_processedImage->data), w, + h, QImage::Format_RGBA64) + .copy(); + } + + // Apply geometry transforms + QImage transformed = applyGeometryTransforms(srcImg, orientSteps, flipH, + flipV, straighten, crop); + + // Convert back to RGBA64 buffer for getProcessedData + transformed = transformed.convertToFormat(QImage::Format_RGBA64); + int tw = transformed.width(); + int th = transformed.height(); + size_t bufSize = static_cast(tw) * th * 8; + m_geometryBuffer.resize(bufSize); + memcpy(m_geometryBuffer.data(), transformed.constBits(), bufSize); + m_geometryWidth = tw; + m_geometryHeight = th; + + return LoadResult{true, loadId}; + }); m_geometryLoadWatcher.setFuture(future); } @@ -2694,7 +3167,7 @@ void RawEngine::enterCropMode() { LogManager::instance()->log( "[ RawEngine.cpp ] - enterCropMode: showing original for crop editing", - "DEBUG"); + PHOTON_DEBUG); m_inCropMode = true; @@ -2724,7 +3197,7 @@ void RawEngine::enterCropMode() { void RawEngine::exitCropMode() { LogManager::instance()->log( - "[ RawEngine.cpp ] - exitCropMode: re-baking geometry", "DEBUG"); + "[ RawEngine.cpp ] - exitCropMode: re-baking geometry", PHOTON_DEBUG); m_inCropMode = false; diff --git a/src/engine/RawEngine.h b/src/engine/RawEngine.h index ce19d2d..75ac1fe 100644 --- a/src/engine/RawEngine.h +++ b/src/engine/RawEngine.h @@ -30,6 +30,7 @@ class RawEngine : public QObject { highlightsChanged) Q_PROPERTY(float shadows READ shadows WRITE setShadows NOTIFY shadowsChanged) Q_PROPERTY(float whites READ whites WRITE setWhites NOTIFY whitesChanged) + Q_PROPERTY(float sceneWhite READ sceneWhite WRITE setSceneWhite NOTIFY sceneWhiteChanged) Q_PROPERTY(float blacks READ blacks WRITE setBlacks NOTIFY blacksChanged) Q_PROPERTY(float adaptation READ adaptation WRITE setAdaptation NOTIFY adaptationChanged) Q_PROPERTY( @@ -247,6 +248,9 @@ class RawEngine : public QObject { float whites() const { return m_whites; } void setWhites(float val); + float sceneWhite() const { return m_sceneWhite; } + void setSceneWhite(float val); + float blacks() const { return m_blacks; } void setBlacks(float val); @@ -508,6 +512,7 @@ class RawEngine : public QObject { void highlightsChanged(); void shadowsChanged(); void whitesChanged(); + void sceneWhiteChanged(); void blacksChanged(); void adaptationChanged(); void vibranceChanged(); @@ -615,6 +620,9 @@ class RawEngine : public QObject { }; void clearProcessedImage(); + + void recomputeSceneWhite(); + float computeSceneWhite(const libraw_processed_image_t* img, float percentile = 0.97f); void updateProcessingParams(); void rebuildToneLut(); static std::vector evalMonotonicSpline(const QVariantList& pts, @@ -633,6 +641,7 @@ class RawEngine : public QObject { float m_highlights = 0.0f; float m_shadows = 0.0f; float m_whites = 0.0f; + float m_sceneWhite = 0.0f; float m_blacks = 0.0f; float m_adaptation = 0.0f; float m_vibrance = 0.0f; diff --git a/src/engine/VulkanComputeContext.cpp b/src/engine/VulkanComputeContext.cpp index d100f9d..8b5802b 100644 --- a/src/engine/VulkanComputeContext.cpp +++ b/src/engine/VulkanComputeContext.cpp @@ -95,7 +95,7 @@ bool VulkanComputeContext::init(QRhi* rhi) { return false; } - LogManager::instance()->log("[ VulkanComputeContext ] - Initialized plain Vulkan compute context", "INFO"); + LogManager::instance()->log("[ VulkanComputeContext ] - Initialized plain Vulkan compute context", PHOTON_INFO); return true; } diff --git a/src/main.cpp b/src/main.cpp index bd1f215..645fded 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -4,6 +4,7 @@ #include #endif +#include #include #include @@ -26,6 +27,7 @@ #include "managers/PreviewManager.h" #include "managers/ThumbnailImageProvider.h" #include "managers/ThumbnailProvider.h" +#include "engine/Panorama.h" #include "components/ToneLutProvider.h" using namespace photon; @@ -42,6 +44,7 @@ typedef void (VKAPI_PTR *PFN_vkDestroyInstance_t)(VkInstance, const VkAllocationCallbacks*); int main(int argc, char* argv[]) { + // Enable RHI info and Vulkan logging // qputenv("QSG_INFO", "1"); // qputenv("QSG_RHI_DEBUG", "1"); @@ -140,6 +143,9 @@ int main(int argc, char* argv[]) { QGuiApplication app(argc, argv); + // Needed by OpenCL during panorama stitching!! + std::setlocale(LC_NUMERIC, "C"); + QVulkanInstance vulkanInstance; vulkanInstance.setLayers({}); if (!vulkanInstance.create()) { @@ -170,10 +176,12 @@ int main(int argc, char* argv[]) { qmlRegisterSingletonInstance("Main", 1, 0, "Logger", logManager); auto *keyTracker = new KeyTracker(&app); + auto *panorama = new photon::Panorama(&app); qmlRegisterSingletonInstance("Main", 1, 0, "KeyTracker", keyTracker); qmlRegisterSingletonInstance("Main", 1, 0, "PresetManager", presetManager); qmlRegisterSingletonInstance("Main", 1, 0, "PreviewManager", previewManager); qmlRegisterSingletonInstance("Main", 1, 0, "ExportManager", exportManager); + qmlRegisterSingletonInstance("Main", 1, 0, "Panorama", panorama); qmlRegisterType("Main", 1, 0, "RawViewport"); engine.rootContext()->setContextProperty("thumbnailProvider", thumbProvider); qmlRegisterType("Main", 1, 0, "FileScanner"); diff --git a/src/managers/AppStateManager.cpp b/src/managers/AppStateManager.cpp index ae390a8..e7235c7 100644 --- a/src/managers/AppStateManager.cpp +++ b/src/managers/AppStateManager.cpp @@ -115,8 +115,8 @@ void AppStateManager::loadSettings() { m_accentColor = m_settings.value(KEY_ACCENT_COLOR, "#3b82f6").toString(); m_previewDenoiseFull = m_settings.value(KEY_PREVIEW_DENOISE_FULL, false).toBool(); - QString level = m_settings.value("diagnostics/logLevel", "INFO").toString(); - LogManager::instance()->setMinLogLevel(level); + QString level = m_settings.value("diagnostics/logLevel", PHOTON_INFO).toString(); + LogManager::instance()->setLogLevel(level); emit lastOpenedFolderChanged(); emit hasLastSessionChanged(); @@ -241,7 +241,7 @@ void AppStateManager::setCurrentFolder(const QString& folder) { void AppStateManager::setCurrentImage(const QString& image) { LogManager::instance()->log( QString("[ AppStateManager ] - setCurrentImage START: %1").arg(image), - "DEBUG"); + PHOTON_DEBUG); if (m_currentImage != image) { m_currentImage = image; @@ -257,7 +257,7 @@ void AppStateManager::setCurrentImage(const QString& image) { } LogManager::instance()->log("[ AppStateManager ] - setCurrentImage END", - "DEBUG"); + PHOTON_DEBUG); } void AppStateManager::toggleSelection(const QString& path) { @@ -585,11 +585,11 @@ void AppStateManager::setLogLocation(const QString& location) { } QString AppStateManager::logLevel() const { - return LogManager::instance()->minLogLevel(); + return LogManager::instance()->logLevel(); } void AppStateManager::setLogLevel(const QString& level) { - LogManager::instance()->setMinLogLevel(level); + LogManager::instance()->setLogLevel(level); emit logLevelChanged(); m_settings.setValue("diagnostics/logLevel", level); m_settings.sync(); diff --git a/src/managers/ExportManager.cpp b/src/managers/ExportManager.cpp index 894d83c..de05c20 100644 --- a/src/managers/ExportManager.cpp +++ b/src/managers/ExportManager.cpp @@ -84,7 +84,8 @@ void ExportManager::processExport(const QStringList& paths, LibRaw processor; processor.imgdata.params.output_bps = 16; processor.imgdata.params.use_camera_wb = 1; - processor.imgdata.params.no_auto_bright = 1; + processor.imgdata.params.no_auto_bright = 0; + processor.imgdata.params.auto_bright_thr = 0.01; if (processor.open_file(path.toLocal8Bit().data()) == LIBRAW_SUCCESS) { if (processor.unpack() == LIBRAW_SUCCESS) { diff --git a/src/managers/FileScanner.cpp b/src/managers/FileScanner.cpp index a0c267b..851317a 100644 --- a/src/managers/FileScanner.cpp +++ b/src/managers/FileScanner.cpp @@ -1,13 +1,15 @@ #include "FileScanner.h" #include -#include +#include #include #include #include #include #include +#include "LogManager.h" + FileScanner::FileScanner(QObject* parent) : QObject(parent) { // Initialize supported RAW file extensions m_supportedExtensions << "arw" << "cr2" << "cr3" << "nef" << "dng" @@ -20,7 +22,9 @@ QVariantList FileScanner::scanForRawFiles(const QString& folderPath) const { QDir dir(folderPath); if (!dir.exists()) { - qWarning() << "Folder does not exist:" << folderPath; + photon::LogManager::instance()->log( + QString("Folder %1 does not exists").arg(folderPath) + ); return rawFiles; } @@ -36,6 +40,7 @@ QVariantList FileScanner::scanForRawFiles(const QString& folderPath) const { fileMap["name"] = fileInfo.fileName(); fileMap["size"] = fileInfo.size(); fileMap["modified"] = fileInfo.lastModified(); + fileMap["extension"] = fileInfo.suffix().toLower(); // Read rating from sidecar if it exists int rating = 0; @@ -65,4 +70,4 @@ QVariantList FileScanner::scanForRawFiles(const QString& folderPath) const { bool FileScanner::isRawFile(const QFileInfo& fileInfo) const { QString extension = fileInfo.suffix().toLower(); return m_supportedExtensions.contains(extension); -} \ No newline at end of file +} diff --git a/src/managers/LogManager.cpp b/src/managers/LogManager.cpp index aa3be33..42f0ae7 100644 --- a/src/managers/LogManager.cpp +++ b/src/managers/LogManager.cpp @@ -4,6 +4,7 @@ #include #include #include +#include namespace photon { @@ -45,19 +46,16 @@ void LogManager::setLogLocation(const QString& location) { } } -void LogManager::setMinLogLevel(const QString& level) { - if (m_minLogLevel != level) { - m_minLogLevel = level; - emit minLogLevelChanged(); - } + +void LogManager::setLogLevel(const QString& level) { + setLogLevel(strLevelToEnum(level)); } -int LogManager::levelToInt(const QString& level) const { - if (level == "DEBUG") return 0; - if (level == "INFO") return 1; - if (level == "WARNING") return 2; - if (level == "ERROR") return 3; - return 1; +void LogManager::setLogLevel(int level) { + if (m_logLevel != level) { + m_logLevel = level; + emit logLevelChanged(); + } } void LogManager::openLogFile() { @@ -69,20 +67,22 @@ void LogManager::openLogFile() { QIODevice::Text)) { qWarning() << "Failed to open log file at" << m_logLocation; } else { - log("Logging started at " + m_logLocation, "INFO"); + log("Logging started at " + m_logLocation, PHOTON_INFO); } } -void LogManager::log(const QString& message, const QString& level) { +void LogManager::log(const QString& message, int level) { QMutexLocker locker(&m_logMutex); - if (levelToInt(level) < levelToInt(m_minLogLevel)) return; + if (level < m_logLevel) return; if (!m_logFile.isOpen()) return; QTextStream out(&m_logFile); QString timestamp = QDateTime::currentDateTime().toString("yyyy-MM-dd HH:mm:ss.zzz"); - out << QString("[%1] [%2] %3\n").arg(timestamp, level, message); + out << QString("[ %1 ] [ %2 ] %3\n").arg(timestamp, enumLevelToStr(level), message); out.flush(); + if (level == PHOTON_FATAL) + QGuiApplication::quit(); } void LogManager::clearLog() { diff --git a/src/managers/LogManager.h b/src/managers/LogManager.h index f093f77..14736b6 100644 --- a/src/managers/LogManager.h +++ b/src/managers/LogManager.h @@ -11,12 +11,20 @@ namespace photon { +enum LogLevel { + PHOTON_DEBUG, + PHOTON_INFO, + PHOTON_WARNING, + PHOTON_ERROR, + PHOTON_FATAL +}; + class LogManager : public QObject { Q_OBJECT Q_PROPERTY(QString logLocation READ logLocation WRITE setLogLocation NOTIFY logLocationChanged) - Q_PROPERTY(QString minLogLevel READ minLogLevel WRITE setMinLogLevel NOTIFY - minLogLevelChanged) + Q_PROPERTY(QString logLevel READ logLevel WRITE setLogLevel NOTIFY + logLevelChanged) public: explicit LogManager(QObject* parent = nullptr); @@ -27,24 +35,63 @@ class LogManager : public QObject { QString logLocation() const { return m_logLocation; } void setLogLocation(const QString& location); - QString minLogLevel() const { return m_minLogLevel; } - void setMinLogLevel(const QString& level); - Q_INVOKABLE void log(const QString& message, const QString& level = "INFO"); + LogLevel strLevelToEnum(QString level) const { + if (level == "DEBUG") + return PHOTON_DEBUG; + else if (level == "INFO") + return PHOTON_INFO; + else if (level == "WARNING") + return PHOTON_WARNING; + else if (level == "ERROR") + return PHOTON_ERROR; + else if (level == "FATAL") + return PHOTON_FATAL; + + return PHOTON_DEBUG; + } + + QString enumLevelToStr(int level) const { + switch(level) { + case PHOTON_DEBUG: + return "DEBUG"; + + case PHOTON_INFO: + return "INFO"; + + case PHOTON_WARNING: + return "WARNING"; + + case PHOTON_ERROR: + return "ERROR"; + + case PHOTON_FATAL: + return "FATAL"; + } + return ""; + } + + QString logLevel() const { + return LogManager::enumLevelToStr(m_logLevel); + } + + void setLogLevel(int level); + void setLogLevel(const QString& level); + + Q_INVOKABLE void log(const QString& message, int level = PHOTON_INFO); Q_INVOKABLE void clearLog(); signals: void logLocationChanged(); - void minLogLevelChanged(); + void logLevelChanged(); private: static LogManager* s_instance; QString m_logLocation; - QString m_minLogLevel = "INFO"; + int m_logLevel = PHOTON_INFO; QFile m_logFile; QMutex m_logMutex; - int levelToInt(const QString& level) const; void openLogFile(); }; diff --git a/src/managers/PreviewManager.cpp b/src/managers/PreviewManager.cpp index 8f9577d..bdc40ee 100644 --- a/src/managers/PreviewManager.cpp +++ b/src/managers/PreviewManager.cpp @@ -167,12 +167,12 @@ void PreviewManager::cancelAll() { } void PreviewManager::processItem(const QString& rawPath, bool skipGpu) { - LogManager::instance()->log(QString("[ PreviewManager ] - processItem START: %1").arg(rawPath), "DEBUG"); + LogManager::instance()->log(QString("[ PreviewManager ] - processItem START: %1").arg(rawPath), PHOTON_DEBUG); { QMutexLocker locker(&m_mutex); if (m_abort) { - LogManager::instance()->log(QString("[ PreviewManager ] - processItem ABORTED: %1").arg(rawPath), "DEBUG"); + LogManager::instance()->log(QString("[ PreviewManager ] - processItem ABORTED: %1").arg(rawPath), PHOTON_DEBUG); return; } } @@ -185,7 +185,7 @@ void PreviewManager::processItem(const QString& rawPath, bool skipGpu) { fileInfo.fileName() + ".json"); QJsonObject lastState; if (QFile::exists(editsPath)) { - LogManager::instance()->log(QString("[ PreviewManager ] - Loading sidecar: %1").arg(editsPath), "DEBUG"); + LogManager::instance()->log(QString("[ PreviewManager ] - Loading sidecar: %1").arg(editsPath), PHOTON_DEBUG); QFile file(editsPath); if (file.open(QIODevice::ReadOnly)) { QJsonDocument doc = QJsonDocument::fromJson(file.readAll()); @@ -197,11 +197,12 @@ void PreviewManager::processItem(const QString& rawPath, bool skipGpu) { } // 2. Load RAW via LibRaw (Fast mode) - LogManager::instance()->log(QString("[ PreviewManager ] - Opening RAW file: %1").arg(rawPath), "DEBUG"); + LogManager::instance()->log(QString("[ PreviewManager ] - Opening RAW file: %1").arg(rawPath), PHOTON_DEBUG); LibRaw processor; processor.imgdata.params.output_bps = 16; processor.imgdata.params.use_camera_wb = 1; - processor.imgdata.params.no_auto_bright = 1; + processor.imgdata.params.no_auto_bright = 0; + processor.imgdata.params.auto_bright_thr = 0.01; processor.imgdata.params.half_size = 1; // 1080p is enough, half_size is fast if (processor.open_file(rawPath.toLocal8Bit().data()) == LIBRAW_SUCCESS) { @@ -250,7 +251,7 @@ void PreviewManager::processItem(const QString& rawPath, bool skipGpu) { } } } - LogManager::instance()->log(QString("[ PreviewManager ] - processItem END: %1").arg(rawPath), "DEBUG"); + LogManager::instance()->log(QString("[ PreviewManager ] - processItem END: %1").arg(rawPath), PHOTON_DEBUG); } } // namespace photon diff --git a/tests/auto/tst_RawEngine.cpp b/tests/auto/tst_RawEngine.cpp index e428d6f..16146f0 100644 --- a/tests/auto/tst_RawEngine.cpp +++ b/tests/auto/tst_RawEngine.cpp @@ -1,4 +1,5 @@ #include +#include #include #include @@ -11,6 +12,7 @@ class TestRawEngine : public QObject { void testLoadInvalidFile(); void testLoadValidFile(); void testProperties(); + void testSwitchingSourceResetsExposureAndContrast(); void testApplyGeometryTransformsStraightenKeepsFullFrame(); void testApplyGeometryTransformsCropRectOnRotatedFrame(); void testApplyGeometryTransformsCropPreservesAspectAndFocus(); @@ -64,6 +66,30 @@ void TestRawEngine::testProperties() { QCOMPARE(vignetteSpy.count(), 1); } +void TestRawEngine::testSwitchingSourceResetsExposureAndContrast() { + QTemporaryDir tempDir; + QVERIFY(tempDir.isValid()); + + RawEngine engine; + QSignalSpy exposureSpy(&engine, &RawEngine::exposureChanged); + QSignalSpy contrastSpy(&engine, &RawEngine::contrastChanged); + + engine.setSource(tempDir.filePath("first.arw")); + engine.setExposure(1.5f); + engine.setContrast(1.3f); + QCOMPARE(engine.exposure(), 1.5f); + QCOMPARE(engine.contrast(), 1.3f); + + const int exposureSignalsBeforeSwitch = exposureSpy.count(); + const int contrastSignalsBeforeSwitch = contrastSpy.count(); + + engine.setSource(tempDir.filePath("second.arw")); + QCOMPARE(engine.exposure(), 0.0f); + QCOMPARE(engine.contrast(), 1.0f); + QVERIFY(exposureSpy.count() > exposureSignalsBeforeSwitch); + QVERIFY(contrastSpy.count() > contrastSignalsBeforeSwitch); +} + void TestRawEngine::testApplyGeometryTransformsStraightenKeepsFullFrame() { QImage input(200, 100, QImage::Format_RGBA64); input.fill(Qt::black); diff --git a/tones_report.md b/tones_report.md deleted file mode 100644 index b704e2b..0000000 --- a/tones_report.md +++ /dev/null @@ -1,267 +0,0 @@ -# Tone/HSL Research Report: Photon vs darktable (and RawTherapee availability) - -## Scope - -I inspected the following code in `tmp/` and Photon: - -- `tmp/darktable/src/iop/toneequal.c` -- `tmp/darktable/src/iop/shadhi.c` -- `tmp/darktable/src/iop/colorzones.c` -- `tmp/darktable/src/iop/filmicrgb.c` -- `src/components/RawViewport.frag` -- `src/engine/ImageDeveloper.cpp` - -### RawTherapee note - -I searched `tmp/` for a RawTherapee source tree (`rawtherapee`, `RawTherapee`, `therapee`) and did not find one in this workspace, so the comparison below is darktable vs Photon with explicit Photon-focused recommendations. - ---- - -## 1) How Photon currently handles tone transitions and HSL - -## Tone controls (Whites/Blacks/Shadows/Highlights) - -Photon currently uses direct, mostly per-pixel formulas in linear RGB (shader preview and C++ export): - -- **Whites**: global division by a scalar (`white_level`) - - `RawViewport.frag:605-608` - - `ImageDeveloper.cpp:388-393` -- **Blacks**: shadow-only mask + multiplicative boost - - `RawViewport.frag:609-613` - - `ImageDeveloper.cpp:394-403` -- **Shadows**: mask `1 - smoothstep(0.0, 0.25, luma)` + multiplicative gain - - `RawViewport.frag:619-623` - - `ImageDeveloper.cpp:408-415` -- **Highlights**: mask from `smoothstep(0.3, 0.95, tanh(luma * 1.5))` + custom luma transform - - `RawViewport.frag:626-654` - - `ImageDeveloper.cpp:416-437` - -Observed characteristics from this design: - -1. Transition thresholds are fixed and relatively tight, so tonal crossover can feel abrupt on some images. -2. Strong positive/negative highlight/shadow moves can push values aggressively and increase visible noise/artifacts. -3. Final clipping happens late (`RawViewport.frag:749`, `ImageDeveloper.cpp:551-553`), so overshoot can collapse into hard white/black regions. - -## HSL controls - -Photon HSL is HSV-based with 8 fixed hue bands and Gaussian influence: - -- Band influence function: `exp(-1.5 * falloff^2)` (`RawViewport.frag:346-350`) -- Fixed centers/widths (`RawViewport.frag:664-665`, `ImageDeveloper.cpp:345-350`) -- Accumulated hue/sat/luma deltas (`RawViewport.frag:670-675`, `ImageDeveloper.cpp:444-453`) -- Luminance change is a direct RGB multiplier: `color *= (1.0 + lum_adj)` (`RawViewport.frag:680`, `ImageDeveloper.cpp:457-459`) - -Observed characteristics: - -1. Selection can feel too narrow or too “banded” depending on hue neighborhood. -2. Luminance is not adjusted in a perceptual lightness space; large values can create clipping “blisters” (white/black spot artifacts). -3. No specific low-chroma protection path is applied before hue/lightness remap (gray/near-gray colors can be unstable). - ---- - -## 2) What darktable does differently (relevant to your issue) - -## A) Tone Equalizer: EV-domain, smooth interpolation, edge-aware masking - -From `toneequal.c`: - -- Works as an **exposure-octave equalizer** in scene-linear domain (`toneequal.c:21-58`). -- Uses **Gaussian radial-basis interpolation** over EV channels for smooth transitions (`toneequal.c:45-53`, `760-768`, `1224-1242`). -- Builds a luminance mask and optionally runs **guided filter / EIGF** to preserve local contrast while smoothing masks (`toneequal.c:61-69`, `865-930`). -- Has controls for **blending diameter, feathering, quantization, contrast/exposure boost** (`toneequal.c:180-187`, `3388-3396`). -- Applies bounded correction factors (LUT correction clamped to `[0.25, 4.0]`) to reduce instability (`toneequal.c:795-800`, `1239-1242`). - -Why this helps: - -- Tonal transitions are intentionally smooth and continuous in EV space. -- Local details are preserved better because mask smoothing is edge-aware rather than purely global. - -## B) Shadows/Highlights module: base-layer separation + compression controls - -From `shadhi.c`: - -- Builds a softened base layer with **Gaussian or bilateral filter** (`shadhi.c:370-398`). -- Uses dedicated **compress** and chroma-correction controls (`shadhi.c:359-364`). -- Applies transformations in controlled chunks for shadows/highlights overlays (`shadhi.c:424-487`). - -Why this helps: - -- Strong highlight/shadow moves are constrained through compression and base/detail separation, reducing harsh transitions and color damage. - -## C) Color Zones (HSL-like): curve/LUT in Lab/LCh with smoother targeting options - -From `colorzones.c`: - -- Operates in **Lab/LCh-like space** and allows selection by lightness/chroma/hue (`colorzones.c:453-467`, `499-513`, `542-555`). -- Uses curve-generated LUTs with interpolation options (Catmull, monotonic Hermite, etc.) (`colorzones.c:78`, `2732-2738`, `2869-2898`). -- “Smooth mode” blends hue/lightness influence toward neutral for low-chroma pixels (`colorzones.c:555-563`). - -Why this helps: - -- Color targeting transitions are smoother and more controllable. -- Low-saturation regions are protected from hue/lightness artifacts. - -## D) Filmic RGB: toe/shoulder shaping + desaturation/reconstruction near clipping - -From `filmicrgb.c`: - -- Parametric toe/latitude/shoulder spline for controlled dynamic-range compression (`filmicrgb.c:946-1009`). -- Dedicated desaturation shaping near extremes (`filmicrgb.c:1011-1035`). -- Highlight mask/reconstruction with soft weighting and optional inpainted noise in clipping regions (`filmicrgb.c:1048-1089`). - -Why this helps: - -- Reduces hard clipping and preserves natural highlight roll-off under extreme edits. - ---- - -## 3) Direct comparison summary - -| Area | Photon (current) | darktable approach | Practical impact | -|---|---|---|---| -| Tone targeting | Fixed masks + direct multipliers | EV-channel equalization + smooth interpolation | Photon can feel harsher at crossover points | -| Local detail preservation | No dedicated edge-aware luminance mask in tone sliders | Guided/EIGF/bilateral mask smoothing | Better detail retention and fewer halos/noise bursts in darktable | -| Extreme edits handling | Late clamp, limited protection | Bounded correction + filmic/reconstruction strategies | Photon more prone to blown/blocked artifact spots | -| HSL selection smoothness | 8 fixed Gaussian hue bands in HSV | Curve/LUT with selectable interpolation, smooth/strong modes | darktable offers smoother color transitions | -| HSL luminance behavior | RGB multiply by `(1 + lum_adj)` | Lightness/chroma/hue remap in LCh-like model | Photon more likely to produce white/black blisters at extremes | -| Low-chroma safety | No explicit low-chroma blend protection | Explicit blend-to-neutral in smooth mode | darktable avoids gray-area hue/lightness artifacts better | - ---- - -## 4) Suggestions for Photon (proposed implementation direction) - -## Priority 1 — Tone transition quality and artifact resistance - -1. **Move tone targeting to EV-domain interpolation** - Implement a tone-equalizer-like mapping (multi-band EV controls + Gaussian/RBF interpolation) instead of hard fixed tonal masks. - -2. **Add edge-aware luminance-mask smoothing path** - Add guided-filter/EIGF-style smoothing on the luminance mask for large highlight/shadow moves (with feathering + quantization controls). - -3. **Add bounded correction and soft roll-off** - Bound correction factors and add a dedicated soft shoulder/toe rolloff before final output clamp to reduce blister artifacts. - -## Priority 2 — HSL smoothness and luminance safety - -4. **Migrate HSL processing from HSV RGB-multiply to perceptual space (LCh/OKLCh-style)** - Keep hue/chroma/lightness edits in a perceptual model; avoid direct RGB luminance scaling for large adjustments. - -5. **Introduce low-chroma protection blend** - Fade hue/lightness adjustments toward neutral when chroma is low (similar to `colorzones` smooth mode). - -6. **Add interpolation mode for color targeting** - Keep current behavior as “strong”, add a “smooth” mode with monotonic or centripetal interpolation to prevent cusps/oscillation. - -## Priority 3 — Robustness and parity - -7. **Unify shader and `ImageDeveloper` tone/HSL math exactly** - Keep one formula set to avoid preview/export divergence when edge cases are hit. - -8. **Add regression tests for extreme controls** - Add automated tests for: - - highlight/shadow extremes, - - HSL luminance ±100 on saturated and near-gray samples, - - continuity checks (no step discontinuities across tonal boundaries). - ---- - -If you want, next step I can convert this into an implementation checklist with exact code touchpoints (`RawViewport.frag` + `ImageDeveloper.cpp` + UI controls) so we can start iterating safely. - ---- - -## 5) Addendum — Tone curve banding (Photon vs darktable) - -### Photon current behavior (banding-relevant) - -- Tone curve LUT is rebuilt at **256 samples/channel** and quantized to **8-bit RGBA**: - - `RawEngine.cpp:1314-1317`, `1335-1342` - - `ToneLutProvider.h:24-31` -- Shader tone-curve sampling uses the 256×4 LUT rows (`toneLUT`) in the processing pass: - - `RawViewport.frag:721-738` - - `App.qml:348-353` -- Photon dithering is currently a single pseudo-random per-pixel add at amplitude `1/255`: - - `RawViewport.frag:538-540`, `769-770` - - `ImageDeveloper.cpp:127-131`, `584-588` -- In `ImageDeveloper`, dithering is applied **before** denoise (`579-589` then `596-637`), so part of anti-banding noise can be removed again by denoising. - -### darktable references - -- darktable tone curve uses float processing with a **0x10000 LUT** (65536 entries), not 256: - - `tmp/darktable/src/iop/tonecurve.c:136`, `755` -- darktable has a dedicated **dither/posterize** module for output quantization control: - - module intent: reduce output banding/posterization (`dither.c:114-115`) - - auto bit-depth-aware mode (`dither.c:344-383`) - - methods include Floyd-Steinberg error diffusion and random TPDF (`dither.c:393-400`, `574-607`) - -### Why Photon shows more banding after strong tone-curve edits - -1. LUT precision is effectively 8-bit/256-sample in the GPU path, so steep/curvy segments can staircase. -2. Dither strategy is fixed-amplitude and not export bit-depth aware. -3. In CPU preview/export path, dither can be attenuated by subsequent denoise. - -### Suggested direction (engine-side) - -1. Raise tone-LUT precision (e.g., 4096+ samples or 65536 table, plus higher-precision LUT texture/storage). -2. Keep curve application in high precision until final output quantization. -3. Move/export dithering to the **final step** only (after denoise and all tone/color operations), with bit-depth aware amplitude. -4. Prefer TPDF/blue-noise dithering for raster output; optional FS diffusion for 8-bit export paths. - ---- - -## 6) Addendum — Denoise softness / detail loss (Photon vs darktable) - -### Photon current behavior (detail-relevant) - -- BM3D strength maps linearly from slider to sigma (`sigma = intensity * 80`): - - `Denoiser.h:19-22` -- Pipeline is BM3D on luminance + chroma BM3D + multi-scale guided filter on chroma: - - `Denoiser.cpp:127-176`, `1059-1081` -- Full denoised buffers are returned by the engine when available: - - `RawEngine.cpp:1804-1823` -- Shader pass still applies real-time denoise from slider value unconditionally: - - `RawViewport.frag:567-568` -- CPU developer path applies denoise after linear->sRGB conversion and after dithering: - - `ImageDeveloper.cpp:579-589`, `596-637` - -### darktable references - -- `raw denoise` is explicitly early, scene-linear/raw pipeline: - - `tmp/darktable/src/iop/rawdenoise.c:139-143` -- raw denoise uses variance-stabilizing transform + wavelet denoise: - - `rawdenoise.c:219-233`, `449-450` -- profiled denoise has camera/ISO-driven model + controls for preserving detail: - - modes (NLMeans/wavelets): `denoiseprofile.c:68-72` - - parameters (`shadows`, `central pixel weight`, `overshooting`): `99-114` - - adaptive preconditioning with shadows/WB and scaling: `1682-1705` - - noise-profile-driven auto inference (`radius/scattering/shadows/bias`): `2650-2668` -- darktable NLMeans implementation includes scattering pattern to avoid grid artifacts and central-pixel weighting: - - `nlmeans_core.c:84-90`, `135-140`, `432-435` - -### Why Photon can look over-soft - -1. Strength mapping is global and not noise-profile adaptive (can oversmooth clean files). -2. Denoise placement in `ImageDeveloper` is late (after gamma/8-bit conversion path), which is suboptimal for detail retention. -3. Engine can provide denoised buffers while shader still applies denoise logic, increasing perceived softness. - -### Suggested direction (engine-side, no UI changes required) - -1. Ensure denoise is applied once in viewport path (skip shader denoise when `m_hasDenoisedResult` is active). -2. Move CPU denoise earlier in `ImageDeveloper` (before sRGB quantization/dither). -3. Replace fixed sigma scaling with profile/adaptive scaling (ISO/noise model + luma-aware strength). -4. Keep denoise/detail separation explicit (edge/detail protection mask or blend-back strategy for high frequencies). - ---- - -## 7) Practical implementation touchpoints for discussion - -- Tone-curve precision/banding: - - `src/engine/RawEngine.cpp` (`rebuildToneLut`) - - `src/components/ToneLutProvider.h` - - `content/views/App.qml` (tone LUT source path) - - `src/components/RawViewport.frag` (tone-LUT sample + final dither) - - `src/engine/ImageDeveloper.cpp` (CPU LUT + final dithering stage) -- Denoise softness: - - `src/engine/Denoiser.h/.cpp` (strength mapping and BM3D/chroma strategy) - - `src/engine/RawEngine.cpp` (`startAsyncDenoise`, `getProcessedData`) - - `src/components/RawViewport.frag` (real-time denoise pass placement) - - `src/engine/ImageDeveloper.cpp` (denoise ordering in export/preview path)