diff --git a/AGENTS.md b/AGENTS.md index af27d16..0f07c72 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,8 +203,9 @@ Platform adaptations, each forced by a missing Qt analog: - Effects, text and shapes kept (14.22, Swift 1.4.9: `94e90bd`): `ImageLayer(record, asset, mask)` loads its live shape and text (`LayerShape::loaded`, `LayerText::loaded`), Swift's `ProjectSnapshot.documentLayers`, so `installProject` and `applyDocumentSize` (Canvas Size, Image Size, Crop, Trim) build the same layers; the canvas resizer carries effects, and Image Size scales them by the root of its two factors through `LayerEffects::scaled` (stroke, glows and blurs to 500, distances to 5,000, Photoshop's Scale Styles) while its resampling still flattens text and shapes, and a size that changes only the resolution keeps all three. Hue/Saturation, Levels, Invert and a floating merge keep the layer's effects. `docs/project-format.md` says so. Tests: `LayerEffectsSurviveTests` (Swift's five sizing cases; a stretch's mean factor and the ranges), `LayerEffectsTests::rebuildsAndResizesKeepThem` (the four rebuilds, both resizers), the shape and text halves in `ShapeLayerTests` and `TypeStoreTests`. - WebP (14.23, Swift 1.4.9: `5724099`): `ImageImporter` takes Qt's `webp` format beside JPEG, PNG and TIFF (`qt6-image-formats-plugins` carries it, as it carries TIFF; Fedora's `qt6-qtimageformats`, Arch's `qt6-imageformats` and nixpkgs' `qtimageformats` too), through the same reading, sRGB conversion and thumbnail; a dropped picture prefers WebP after TIFF and before Photoshop's, Swift's order; the unsupported and drop messages name WebP; the importer's filter offers `*.webp` and the desktop entry `image/webp`. Tests: `WebPImportTests` (Swift's lossless and lossy pictures: size, sRGB, the clear half, the budget, a drop opening a document; the drop order), the words in their files. - Ctrl or Meta with a middle drag zooms (14.24, Swift 1.4.9: `e03923f`, Blender's gesture): a middle press with Ctrl or Meta (Swift's Command or Control) keeps `m_middleZoom` (where it began, the zoom then) in place of the middle pan; each move with the middle button zooms by 2^((start.y − y) / 100) about the press, as the Zoom tool's drag doubles every 100 points, up in and down out, under the Zoom tool's magnifier, the minus while below the press; the brush circle hides meanwhile; the release, or a move without the middle button (Qt can lose a release), ends it and puts the tool's cursor back, as the pan's does. A left drag beside it goes on, and a right-button tip drag waits, as beside the pan. Tests: `CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms` (doubling and halving about an off-centre press, from 75% too, both cursors against the Zoom tool's, Meta, a lost release, a plain middle drag still panning, a Hand drag going on and a tip drag waiting beside it). -- Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` were 57ab3bb's: Exposure, white balance and Contrast ran as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after (one table since 14.26). `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes are raw DEFLATE, as Foundation's `.zlib` writes them, inflated through zlib (`ZLIB::ZLIB`, which the PKGBUILD and the flake now name). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search) (since 14.26 `CameraRawWiringTests::theTableReadsThePicturesBrightness`) and Auto through the encoded mean, a dark pixel on its straight segment. -- Adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (14.26, Swift 1.4.9: `75147a2`): `kernels/AdjustPixels.c/.h` are 75147a2's and the tables its 285 (`CRT2`, 1.4 MB): Contrast, Highlights and Shadows each blend four tables round their value (−100, −50, −25, 0, 25, 50, 100) and the picture's brightness (53, 80, 104, 130, 160, 190, 224 in sRGB levels, `between` over points, held at the ends, the lower pair taking a point whole), read before any stage by `camera_raw_statistics` (`CameraRawTables::brightness`, `Brightness`: the brightest channel's mean for Contrast, the log-average for Highlights, the luminance for Shadows), only when one of the three is set, else 128 each; every Light and Color slider is now one composed table (`stages` returns one list in Camera Raw's order) and `adjust_camera_raw` takes it alone. Dehaze removes or adds the haze colour of the brightest one percent and leaves one colour be; Texture and Clarity are guided filters at radii in proportion to the picture, so the preview's scale no longer reaches them (Glow's still does); Sharpening sharpens everywhere at Masking 0. These live in Swift's kernel, unchanged. The 14.16 tone curve is gone with it, and its curve test. Equivalent mutant: reading the brightness when none of the three is set (the stages then never read it). Tests: Swift's changed `CameraRawTests` (Highlights and Shadows by Photoshop's numbers, Dehaze on a shadow under a pale sky, one colour left be; pinned beside them: Highlights alone reading the picture, Highlights before Whites, a faint step sharpened at Masking 0) and `CameraRawToneOrderTests` (Highlights −100's white), `CameraRawTablesTests` (the 285 tables' hash and entries, two colours' three measures against Python, each slider's tables, the adaptive blends from an independent Python run, held ends and a measured point, three stages composed, mid gray's brightness), `CameraRawWiringTests::theTableReadsThePicturesBrightness` (the grade against the kernel through the picture's own brightness, not the default), `CameraRawRulesTests` (Glow reads the scale, Clarity no longer). +- Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` were 57ab3bb's: Exposure, white balance and Contrast ran as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after (one table since 14.26). `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes were raw DEFLATE until 14.27 (xz since, below). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search) (since 14.26 `CameraRawWiringTests::theTableReadsThePicturesBrightness`) and Auto through the encoded mean, a dark pixel on its straight segment. +- Adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (14.26, Swift 1.4.9: `75147a2`): `kernels/AdjustPixels.c/.h` were 75147a2's and the tables its 285 (`CRT2`, 1.4 MB; 763 since 14.27): Contrast, Highlights and Shadows each blend four tables round their value (−100, −50, −25, 0, 25, 50, 100) and the picture's brightness (53, 80, 104, 130, 160, 190, 224 in sRGB levels, `between` over points, held at the ends, the lower pair taking a point whole), read before any stage by `camera_raw_statistics` (`CameraRawTables::brightness`, `Brightness`: the brightest channel's mean for Contrast, the log-average for Highlights, the luminance for Shadows), only when one of the three is set, else 128 each; every Light and Color slider is now one composed table (`stages` returns one list in Camera Raw's order) and `adjust_camera_raw` takes it alone. Dehaze removes or adds the haze colour of the brightest one percent and leaves one colour be; Texture and Clarity are guided filters at radii in proportion to the picture, so the preview's scale no longer reaches them (Glow's still does); Sharpening sharpens everywhere at Masking 0. These live in Swift's kernel, unchanged. The 14.16 tone curve is gone with it, and its curve test. Equivalent mutant: reading the brightness when none of the three is set (the stages then never read it). Tests: Swift's changed `CameraRawTests` (Highlights and Shadows by Photoshop's numbers, Dehaze on a shadow under a pale sky, one colour left be; pinned beside them: Highlights alone reading the picture, Highlights before Whites, a faint step sharpened at Masking 0) and `CameraRawToneOrderTests` (Highlights −100's white), `CameraRawTablesTests` (the 285 tables' hash and entries, two colours' three measures against Python, each slider's tables, the adaptive blends from an independent Python run, held ends and a measured point, three stages composed, mid gray's brightness), `CameraRawWiringTests::theTableReadsThePicturesBrightness` (the grade against the kernel through the picture's own brightness, not the default), `CameraRawRulesTests` (Glow reads the scale, Clarity no longer). +- Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading as Photoshop's (14.27, Swift 1.4.9: `bdfb9c2`, `9dac4f1`, `7b5a592`, taken as their net change): `kernels/AdjustPixels.c/.h` are 7b5a592's (a stage holds up to eight tables, `adjust_camera_raw_curve_color` takes the Mixer's and Grading's composed tables, `adjust_camera_raw_optics` Exposure's tables for the lens vignette, the post-crop vignette darkens by a gain in linear light) and the tables its 763 (`CRT3`, 1.25 MB): a header of ten bytes (size, count, `step`), each entry a running difference in steps of `step` levels read as a signed byte and clamped, the planes an xz stream as Foundation's `.lzma` writes it, unpacked through liblzma (`lzma_stream_buffer_decode`, `LibLZMA::LibLZMA`; `liblzma-dev` in the dev image, `xz-devel` and `xz` in the Fedora and Arch images, `xz` in the PKGBUILD and the flake); the app no longer links zlib. `CameraRawTables` gains Swift's `blend` (up to eight parts; a null table is no change), `exposureTables`, `fifty` (−100 to 100 by 50), `gradingStages` (the four luminance sliders, then the global, midtone, highlight and shadow wheels: twelve hues by 30, saturations 25, 50 and 100 below which the lower is no change, the shadow and highlight wheels between Blending 0, 50 and 100, Balance as Swift's fitted shares of saturation, a wheel with no share left out) and `mixerStages` (luminance, saturation, hue, eight colours each); `stages` opens with Calibration's six primaries and ends with the parametric curve's four regions while `CameraRawCurveSettings::hasMeasuredDividers` holds (dividers 25, 50, 75 and Refine Saturation 0), when `toneTable` draws the point curve alone. `applyCurveColor` hands the kernel zeros for the Mixer's and Grading's formulas and their tables in their place; `applyCalibration` draws Shadows Tint alone; `apply` runs the Light table for Calibration or a measured curve too. Equivalent mutants: the shares' floor of zero (a negative share fails the `> 0` test as zero does) and `gradingStages`' `fmod` (a normalized hue lies within 0 to 360, where 360 wraps alike). Tests: Swift's `mixerCalibrationCurveAndVignettesMatchPhotoshop` in `CameraRawTests`; `CameraRawTablesTests` against an independent Python decoder of `CRT3` (the hash and entries; every earlier exact value redone on the stepped tables; the Mixer's, Calibration's, the curve's and Grading's tables and weights, a wheel wrapping past 330, Balance leaving a wheel out, moved dividers); the Highlights pins, Auto's dark pixel and the grading luminance by the same Python; `CameraRawCurveTests` (the tone table as measured and with dividers moved), `CameraRawLensTests` (Shadows Tint by the kernel, primaries through the table), `CameraRawRulesTests` (the clip views keep Calibration's table), `CameraRawWiringTests` (the curve call's tables). - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/CMakeLists.txt b/CMakeLists.txt index 475281c..c8d4717 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -256,10 +256,10 @@ set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_O # The per-pixel loops vectorise only at -O3. set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3) target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS}) -find_package(ZLIB REQUIRED) +find_package(LibLZMA REQUIRED) # Swift's bundled resource, inside the binary. qt_add_resources(compositor_core camera_raw_tables PREFIX "/" BASE src/Resources FILES src/Resources/CameraRawTables.bin) -target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE ZLIB::ZLIB Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW) +target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE LibLZMA::LibLZMA Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW) target_compile_options(compositor_core PUBLIC -Wall -Wextra $<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>) # Empty optionals and bad indexes abort, not pass by luck. diff --git a/Dockerfile.dev b/Dockerfile.dev index 7d117b0..45f93a8 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -3,7 +3,7 @@ FROM $BASE ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && apt-get install -y --no-install-recommends \ g++ cmake ninja-build qt6-base-dev qt6-svg-dev libgl1-mesa-dev qt6-wayland fonts-dejavu-core \ - qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev \ + qt6-image-formats-plugins libheif-dev libheif-plugin-libde265 libfontconfig-dev libraw-dev liblzma-dev \ ca-certificates curl dpkg-dev file fakeroot \ && rm -rf /var/lib/apt/lists/* # The ONNX Runtime the packages carry: Microsoft's own build. diff --git a/README.md b/README.md index 0da72c4..353cacd 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ scripts/dev.sh run # build, then the app on your Wayland display `run` gives the container your home folder at its own path, so the app opens and saves your files and reads your Omarchy theme, and the GPU's render nodes (`/dev/dri/renderD*`), which Mesa's EGL wants. Files outside your home folder stay out of reach. -By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig, zlib and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04). +By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig, liblzma and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04). ## Licence diff --git a/flake.nix b/flake.nix index d88bb0d..29b0996 100644 --- a/flake.nix +++ b/flake.nix @@ -20,7 +20,7 @@ filter = path: type: !(builtins.elem path [ "${toString self}/build" "${toString self}/Compositor-Ref" ]); }; nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ]; - buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig zlib onnxruntime ]; + buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig xz onnxruntime ]; cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ]; ninjaFlags = [ "omaphoto" ]; # Staged: profiles build MIME caches outside the store. diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c index c3977c9..9215c39 100644 --- a/kernels/AdjustPixels.c +++ b/kernels/AdjustPixels.c @@ -272,7 +272,7 @@ void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int void camera_raw_stage_color(const CameraRawStage *stage, int size, double *rgb) { double next[3] = {0, 0, 0}; - for (int t = 0; t < 4; ++t) { + for (int t = 0; t < 8; ++t) { double w = stage->weight[t]; if (w == 0) continue; double looked[3]; @@ -507,12 +507,28 @@ static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y double amount, double midpoint, double roundness, double feather, double highlights, int style) { if (amount == 0 || width == 0 || height == 0) return; - double mask = vignette_mask(x, y, width, height, midpoint, roundness, feather); + // Photoshop's is an ellipse fitted to the frame at Roundness 0, toward a rectangle below it, and starts a little + // further out than the Midpoint alone puts it. + double mask = vignette_mask(x, y, width, height, midpoint + 5.9, fmin(100, 100 + roundness), feather); double effect = (amount / 100.0) * mask; - // Highlight Priority eases a darkening vignette off bright pixels. The other styles do not. - if (effect < 0 && style == 0) { + if (style == 0 && mask > 0) { + // Highlight Priority, measured against Photoshop's, in linear light: darkening is a gain, to near black at + // −100 in the corners, eased off bright pixels (and more so with Highlights); lightening blends toward white, + // starting further out the gentler it is. + double linear[3] = {srgb_decode(*r), srgb_decode(*g), srgb_decode(*b)}; double bright = camera_clamp((rec709(*r, *g, *b) - 0.45) / 0.55); - effect *= 1.0 - (highlights / 100.0) * bright; + if (amount < 0) { + double ease = (1.0 - 0.3 * bright) * (1.0 - (highlights / 100.0) * bright); + double gain = exp(-5.8 * pow(-amount / 100.0, 1.43) * mask * ease); + for (int k = 0; k < 3; ++k) linear[k] *= gain; + } else { + double a = amount / 100.0, toward = pow(a, 0.85) * pow(mask, 1 + 1.5 * (1 - a)); + for (int k = 0; k < 3; ++k) linear[k] += (1.0 - linear[k]) * toward; + } + *r = srgb_encode(linear[0]); + *g = srgb_encode(linear[1]); + *b = srgb_encode(linear[2]); + return; } if (effect < 0) { double factor = 1.0 + effect; @@ -811,7 +827,8 @@ static double point_weight(double h, double s, double l, const float *point) { void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, double refineSaturation, const float *mixer, int pointCount, const float *points, - const float *grade, double blending, double balance, int visualize) { + const float *grade, double blending, double balance, int visualize, + const float *mixerTable, const float *gradeTable, int grid) { for (size_t y = 0; y < height; ++y) { uint8_t *row = rgba + y * stride; for (size_t x = 0; x < width; ++x) { @@ -836,6 +853,7 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s } r = curvedR; g = curvedG; b = curvedB; r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b); + if (mixerTable) composed_lookup(mixerTable, grid, &r, &g, &b); double h, s, l; rgb_to_hsl(r, g, b, &h, &s, &l); double sourceHue = h, sourceSat = s, sourceLum = l; @@ -863,6 +881,7 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s } if (h < 0) h += 1; if (h >= 1) h -= 1; hsl_to_rgb(h, s, l, &r, &g, &b); + if (gradeTable) composed_lookup(gradeTable, grid, &r, &g, &b); // Balance moves the crossover between the shadow and highlight wheels. Toward highlights // it has to move down, so more of the picture counts as highlight and the shadow wheel // loses its hold; the other sign strengthened the shadow tint it was meant to weaken. @@ -1164,31 +1183,32 @@ static void optics_chromatic(uint8_t *rgba, size_t width, size_t height, size_t free(copy); } +// Lens vignette correction, as Photoshop's: Exposure, through its measured tables (`exposure`, 21 of them −5…5 by +// 0.5, `size`³ each), by an amount that grows with the distance from the middle (1 at the corners): two stops times +// that distance to the 4.3 at ±100, less as the amount falls, and reaching further in as Midpoint does. static void optics_vignette_correct(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height, - double amount, double midpoint) { - if (amount == 0 || width == 0 || height == 0) return; - double nx = ((double)x + 0.5) / (double)width * 2.0 - 1.0; - double ny = ((double)y + 0.5) / (double)height * 2.0 - 1.0; - double dist = hypot(nx, ny) / sqrt(2.0); - double start = (midpoint / 100.0) * 0.85; - double t = camera_clamp((dist - start) / 0.35); - double mask = t * t * (3.0 - 2.0 * t); - double lift = (amount / 100.0) * mask; - if (lift > 0) { - *r = camera_clamp(*r + (1.0 - *r) * lift); - *g = camera_clamp(*g + (1.0 - *g) * lift); - *b = camera_clamp(*b + (1.0 - *b) * lift); - } else { - double factor = 1.0 + lift; - *r *= factor; *g *= factor; *b *= factor; - } + double amount, double midpoint, const uint8_t *exposure, int size) { + if (amount == 0 || width == 0 || height == 0 || !exposure) return; + double dx = (double)x + 0.5 - width / 2.0, dy = (double)y + 0.5 - height / 2.0; + double distance = hypot(dx, dy) / hypot(width / 2.0, height / 2.0) * (1 + (50 - midpoint) / 100); + double stops = (amount > 0 ? 1 : -1) * pow(fabs(amount) / 100, 1.15) * 1.98 * pow(distance, 4.3); + double position = fmin(20, fmax(0, (stops + 5) / 0.5)); + int index = position >= 20 ? 19 : (int)position; + double fraction = position - index, color[3] = {*r, *g, *b}, low[3], high[3]; + size_t entries = (size_t)size * size * size * 3; + table_lookup(exposure + index * entries, size, color, low); + table_lookup(exposure + (index + 1) * entries, size, color, high); + *r = camera_clamp(low[0] + (high[0] - low[0]) * fraction); + *g = camera_clamp(low[1] + (high[1] - low[1]) * fraction); + *b = camera_clamp(low[2] + (high[2] - low[2]) * fraction); } void adjust_camera_raw_optics(uint8_t *rgba, size_t width, size_t height, size_t stride, int removeChromatic, int lensProfile, double profileDistortion, double profileVignetting, double distortionK, double purpleAmount, double purpleHueLow, double purpleHueHigh, double greenAmount, double greenHueLow, double greenHueHigh, - double vignetteAmount, double vignetteMidpoint, double scale) { + double vignetteAmount, double vignetteMidpoint, double scale, + const uint8_t *exposureTables, int tableSize) { if (width == 0 || height == 0) return; double profileVignette = lensProfile ? profileVignetting / 100.0 : 0; double vignette = vignetteAmount + profileVignette * 35.0; @@ -1210,7 +1230,7 @@ void adjust_camera_raw_optics(uint8_t *rgba, size_t width, size_t height, size_t if (!alpha) continue; double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); optics_defringe(&r, &g, &b, purpleAmount, purpleHueLow, purpleHueHigh, greenAmount, greenHueLow, greenHueHigh); - optics_vignette_correct(&r, &g, &b, x, y, width, height, vignette, vignetteMidpoint); + optics_vignette_correct(&r, &g, &b, x, y, width, height, vignette, vignetteMidpoint, exposureTables, tableSize); write_premultiplied(p, r, g, b, alpha); } } diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h index 2ef3e6f..e66a564 100644 --- a/kernels/AdjustPixels.h +++ b/kernels/AdjustPixels.h @@ -30,11 +30,11 @@ void adjust_color_balance(uint8_t *rgba, size_t width, size_t height, size_t str // After resampling with a filter that rings (Lanczos), premultiplied RGBA colors can exceed their alpha; // this clamps each channel back to its pixel's alpha. `count` is the number of pixels. void rgba_clamp_premultiplied(uint8_t *rgba, size_t count); -// One step of Camera Raw's Light and Color: up to four measured color tables (`size`³ sRGB colors, red slowest, -// 3 bytes each) blended by weight, for a slider value between the ones measured. A null table is no change. +// One step of Camera Raw: up to eight measured color tables (`size`³ sRGB colors, red slowest, 3 bytes each) blended +// by weight, for settings between the ones measured. A null table is no change. typedef struct { - const uint8_t *table[4]; - float weight[4]; + const uint8_t *table[8]; + float weight[8]; } CameraRawStage; // Runs `count` stages in order over a `grid`³ lattice of sRGB colors into `out` (3 floats per color, 0…1, red // slowest), so a whole run of stages costs a pixel one lookup. @@ -62,10 +62,13 @@ void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, // entries. `mixer` is 24 floats: hue, saturation, luminance for eight families, −1…1. Each point color is // 9 floats (hue, saturation, luminance, three shifts −1…1, three range half-widths). `grade` is four wheels // of hue turns, saturation 0…1, and luminance −1…1. `visualize` darkens pixels outside that point color. +// `mixerTable` and `gradeTable` (null for none) are the Color Mixer's and Color Grading's composed tables, `grid`³ +// colors, run after the curve and before the grading formula. void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, double refineSaturation, const float *mixer, int pointCount, const float *points, - const float *grade, double blending, double balance, int visualize); + const float *grade, double blending, double balance, int visualize, + const float *mixerTable, const float *gradeTable, int grid); void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride, double texture, double clarity, double dehaze, double glow, int glowStyle, double glowRange, double glowSpread, double glowWarmth, @@ -98,7 +101,8 @@ void adjust_camera_raw_optics(uint8_t *rgba, size_t width, size_t height, size_t int removeChromatic, int lensProfile, double profileDistortion, double profileVignetting, double distortionK, double purpleAmount, double purpleHueLow, double purpleHueHigh, double greenAmount, double greenHueLow, double greenHueHigh, - double vignetteAmount, double vignetteMidpoint, double scale); + double vignetteAmount, double vignetteMidpoint, double scale, + const uint8_t *exposureTables, int tableSize); // Camera calibration before the main grade. Primary hue and saturation shifts are −100…100; shadow tint is green/magenta. void adjust_camera_raw_calibration(uint8_t *rgba, size_t width, size_t height, size_t stride, double shadowTint, double redHue, double redSaturation, diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 18b1b8f..d96f543 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -6,7 +6,7 @@ pkgdesc='Layered image editing for Linux, a port of Compositor' arch=('x86_64') url='https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto' license=('MIT') -depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'zlib' 'hicolor-icon-theme' 'shared-mime-info') +depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'xz' 'hicolor-icon-theme' 'shared-mime-info') makedepends=('cmake' 'ninja') # ONNX Runtime ships inside: Arch's changes its symbols each release. source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx' diff --git a/scripts/distro/arch/Dockerfile b/scripts/distro/arch/Dockerfile index 2921a3a..acd2ece 100644 --- a/scripts/distro/arch/Dockerfile +++ b/scripts/distro/arch/Dockerfile @@ -1,6 +1,6 @@ # Arch and Omarchy: Qt and GCC as shipped. FROM archlinux:latest -RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-svg qt6-imageformats libheif libde265 libraw \ +RUN pacman -Syu --noconfirm --needed base-devel cmake ninja pkgconf qt6-base qt6-svg qt6-imageformats libheif libde265 libraw xz \ fontconfig ttf-dejavu curl \ && pacman -Scc --noconfirm # The ONNX Runtime the packages carry: Microsoft's own build. diff --git a/scripts/distro/fedora/Dockerfile b/scripts/distro/fedora/Dockerfile index e92139d..4189dcf 100644 --- a/scripts/distro/fedora/Dockerfile +++ b/scripts/distro/fedora/Dockerfile @@ -3,7 +3,7 @@ ARG RELEASE=43 FROM fedora:${RELEASE} ARG RELEASE RUN dnf install -y https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-${RELEASE}.noarch.rpm \ - && dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtsvg-devel qt6-qtimageformats libheif-devel LibRaw-devel libheif-freeworld \ + && dnf install -y gcc-c++ cmake ninja-build pkgconf qt6-qtbase-devel qt6-qtsvg-devel qt6-qtimageformats libheif-devel LibRaw-devel xz-devel libheif-freeworld \ fontconfig-devel dejavu-sans-fonts dejavu-serif-fonts dejavu-sans-mono-fonts curl rpm-build file \ && dnf clean all # The ONNX Runtime the packages carry: Microsoft's own build. diff --git a/src/Document/CameraRaw.cpp b/src/Document/CameraRaw.cpp index 8d04041..fe870be 100644 --- a/src/Document/CameraRaw.cpp +++ b/src/Document/CameraRaw.cpp @@ -134,7 +134,8 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip const size_t w = size_t(width), h = size_t(height), s = size_t(stride); if (!clipping && !sharpenMask && settings.calibration.adjusts()) settings.applyCalibration(pixels, width, height, stride); - if (settings.adjustsLight() || settings.adjustsColor() || clipping) { + if (settings.adjustsLight() || settings.adjustsColor() || settings.calibration.adjusts() + || (settings.curve.hasMeasuredDividers() && settings.curve.adjusts()) || clipping) { const CameraRawTables::Brightness brightness = settings.contrast != 0 || settings.highlights != 0 || settings.shadows != 0 ? CameraRawTables::brightness(pixels, width, height, stride) : CameraRawTables::Brightness(); diff --git a/src/Document/CameraRawColor.cpp b/src/Document/CameraRawColor.cpp index 7d3f418..88277e9 100644 --- a/src/Document/CameraRawColor.cpp +++ b/src/Document/CameraRawColor.cpp @@ -1,5 +1,6 @@ #include "Document/CameraRawColor.h" #include "Document/CameraRaw.h" +#include "Document/CameraRawTables.h" #include "Document/ImageAdjustments.h" #include <algorithm> #include <cmath> @@ -106,11 +107,17 @@ double CameraRawCurveSettings::parametric(double tone) const return point(tone, anchors); } +bool CameraRawCurveSettings::hasMeasuredDividers() const +{ + return shadowSplit == 25 && darkSplit == 50 && lightSplit == 75 && refineSaturation == 0; +} + std::vector<float> CameraRawCurveSettings::toneTable() const { + const bool measured = hasMeasuredDividers(); std::vector<float> table(256); for (int index = 0; index < 256; ++index) - table[size_t(index)] = float(point(parametric(index / 255.0), rgb)); + table[size_t(index)] = float(point(measured ? index / 255.0 : parametric(index / 255.0), rgb)); return table; } @@ -271,10 +278,13 @@ void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qs const CameraRawCurveSettings shape = curve.normalized(); const CameraRawMixerSettings mixing = mixer.normalized(); const CameraRawGradingSettings grade = grading.normalized(); + // The eight colours and Color Grading come from Photoshop's tables. const std::vector<float> tone = shape.toneTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green), - blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(), - gradeFloats = grade.gradeFloats(); + blueTable = shape.channelTable(shape.blue), mixerFloats(24, 0), pointFloats = mixing.pointFloats(), gradeFloats(12, 0); + const std::vector<float> mixerTable = CameraRawTables::compose(CameraRawTables::mixerStages(mixing)); + const std::vector<float> gradeTable = CameraRawTables::compose(CameraRawTables::gradingStages(grade)); adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), tone.data(), redTable.data(), greenTable.data(), blueTable.data(), shape.refineSaturation / 100, mixerFloats.data(), int(mixing.points.size()), pointFloats.data(), - gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize); + gradeFloats.data(), grade.blending / 100, grade.balance / 100, visualize, mixerTable.empty() ? nullptr : mixerTable.data(), + gradeTable.empty() ? nullptr : gradeTable.data(), CameraRawTables::grid); } diff --git a/src/Document/CameraRawColor.h b/src/Document/CameraRawColor.h index ead27d1..79f61ee 100644 --- a/src/Document/CameraRawColor.h +++ b/src/Document/CameraRawColor.h @@ -35,6 +35,8 @@ struct CameraRawCurveSettings { bool adjusts() const; // Photoshop's parametric curve: gamma bends, smoothed as Curves. double parametric(double tone) const; + // Photoshop's tables draw the parametric curve: dividers as measured. + bool hasMeasuredDividers() const; // Applied to red, green and blue alike. std::vector<float> toneTable() const; std::vector<float> channelTable(const std::vector<CurvePoint> &points) const; diff --git a/src/Document/CameraRawDetailOptics.cpp b/src/Document/CameraRawDetailOptics.cpp index 494aef8..3da340d 100644 --- a/src/Document/CameraRawDetailOptics.cpp +++ b/src/Document/CameraRawDetailOptics.cpp @@ -1,5 +1,6 @@ #include "Document/CameraRawDetailOptics.h" #include "Document/CameraRaw.h" +#include "Document/CameraRawTables.h" #include "Document/ImageAdjustments.h" #include <utility> extern "C" { @@ -77,7 +78,8 @@ void CameraRawSettings::applyDetailOptics(uchar *pixels, int width, int height, if (lens.adjusts()) adjust_camera_raw_optics(pixels, w, h, s, lens.removeChromaticAberration ? 1 : 0, lens.enableLensProfile ? 1 : 0, lens.profileDistortion, lens.profileVignetting, lens.distortionK(profileStrength), lens.purpleAmount, lens.purpleHueLow, lens.purpleHueHigh, - lens.greenAmount, lens.greenHueLow, lens.greenHueHigh, lens.vignetteAmount, lens.vignetteMidpoint, scale); + lens.greenAmount, lens.greenHueLow, lens.greenHueHigh, lens.vignetteAmount, lens.vignetteMidpoint, scale, + CameraRawTables::exposureTables(), CameraRawTables::size); if (sharpening.adjusts()) adjust_camera_raw_detail(pixels, w, h, s, sharpening.sharpenAmount, sharpening.sharpenRadius, sharpening.sharpenDetail, sharpening.sharpenMasking, sharpening.noiseLuminance, sharpening.noiseLuminanceDetail, diff --git a/src/Document/CameraRawGeometryCalibration.cpp b/src/Document/CameraRawGeometryCalibration.cpp index e99aea5..f60edd1 100644 --- a/src/Document/CameraRawGeometryCalibration.cpp +++ b/src/Document/CameraRawGeometryCalibration.cpp @@ -192,9 +192,9 @@ CameraRawCalibrationSettings CameraRawCalibrationSettings::normalized() const void CameraRawSettings::applyCalibration(uchar *pixels, int width, int height, qsizetype stride) const { const CameraRawCalibrationSettings primaries = calibration.normalized(); - if (!primaries.adjusts()) + // The primaries come from Photoshop's tables; this draws Shadows Tint. + if (primaries.shadowTint == 0) return; - adjust_camera_raw_calibration(pixels, size_t(width), size_t(height), size_t(stride), primaries.shadowTint, primaries.redHue, - primaries.redSaturation, primaries.greenHue, primaries.greenSaturation, primaries.blueHue, - primaries.blueSaturation, int(primaries.process) + 1); + adjust_camera_raw_calibration(pixels, size_t(width), size_t(height), size_t(stride), primaries.shadowTint, 0, 0, 0, 0, 0, 0, + int(primaries.process) + 1); } diff --git a/src/Document/CameraRawTables.cpp b/src/Document/CameraRawTables.cpp index c33c375..dd80d61 100644 --- a/src/Document/CameraRawTables.cpp +++ b/src/Document/CameraRawTables.cpp @@ -5,54 +5,53 @@ #include <QFile> #include <array> +#include <cmath> +#include <span> #include <algorithm> #include <stdexcept> -#include <zlib.h> +#include <lzma.h> namespace CameraRawTables { namespace { constexpr size_t entries = size_t(size) * size * size * 3; // White balance, five sliders, then three adaptive ones. constexpr int exposureStart = 81, whitesStart = 102, blacksStart = 111, saturationStart = 120, vibranceStart = 129; -constexpr int contrastStart = 138, highlightsStart = 187, shadowsStart = 236, count = 285; +constexpr int contrastStart = 138, highlightsStart = 187, shadowsStart = 236; +// Then the Mixer, Calibration, the parametric curve, Color Grading. +constexpr int mixerStart = 285, calibrationStart = 405, parametricStart = 435, gradingStart = 455, gradingLuminanceStart = 743, count = 763; constexpr std::array<double, 7> brightnesses{53, 80, 104, 130, 160, 190, 224}; constexpr std::array<double, 7> adaptiveValues{-100, -50, -25, 0, 25, 50, 100}; -// Raw DEFLATE, as Foundation's `.zlib` writes it. -std::vector<uint8_t> inflated(const QByteArray &packed, size_t expected) +// An xz stream, as Foundation's `.lzma` writes it. +std::vector<uint8_t> unpacked(const QByteArray &packed, size_t expected) { std::vector<uint8_t> out(expected); - z_stream stream{}; - if (inflateInit2(&stream, -MAX_WBITS) != Z_OK) - throw std::logic_error("Camera Raw's tables: inflate would not start"); - stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(packed.constData())); - stream.avail_in = uInt(packed.size()); - stream.next_out = out.data(); - stream.avail_out = uInt(out.size()); - const int status = inflate(&stream, Z_FINISH); - const size_t made = stream.total_out; - inflateEnd(&stream); - if (status != Z_STREAM_END || made != expected) - throw std::logic_error("Camera Raw's tables do not inflate to their size"); + uint64_t memory = UINT64_MAX; + size_t read = 0, made = 0; + const lzma_ret status = lzma_stream_buffer_decode(&memory, 0, nullptr, reinterpret_cast<const uint8_t *>(packed.constData()), &read, + size_t(packed.size()), out.data(), &made, out.size()); + if (status != LZMA_OK || made != expected || read != size_t(packed.size())) + throw std::logic_error("Camera Raw's tables do not unpack to their size"); return out; } -// Each entry is a running difference from no change. +// Each entry: a running difference from no change, stepped. std::vector<uint8_t> load() { QFile file(QStringLiteral(":/CameraRawTables.bin")); if (!file.open(QIODevice::ReadOnly)) throw std::logic_error("Camera Raw's tables are missing from the build"); const QByteArray bytes = file.readAll(); - const auto byte = [&](int at) { return int(uchar(bytes[at])); }; - if (bytes.size() <= 8 || !bytes.startsWith("CRT2") || (byte(4) | byte(5) << 8) != size || (byte(6) | byte(7) << 8) != count) + const auto field = [&](int at) { return int(uchar(bytes[at])) | int(uchar(bytes[at + 1])) << 8; }; + if (bytes.size() <= 10 || !bytes.startsWith("CRT3") || field(4) != size || field(6) != count || field(8) <= 0) throw std::logic_error("Camera Raw's tables have the wrong header"); - const std::vector<uint8_t> planes = inflated(bytes.mid(8), count * entries); + const int step = field(8); + const std::vector<uint8_t> planes = unpacked(bytes.mid(10), count * entries); std::vector<uint8_t> out(count * entries); const size_t cells = size_t(size) * size * size; - uint8_t level[size]; + int level[size]; for (int i = 0; i < size; ++i) - level[i] = uint8_t(std::lround(i * 255.0 / (size - 1))); + level[i] = int(std::lround(i * 255.0 / (size - 1))); for (size_t table = 0; table < count; ++table) { for (size_t channel = 0; channel < 3; ++channel) { const size_t base = (table * 3 + channel) * cells; @@ -60,7 +59,7 @@ std::vector<uint8_t> load() for (size_t cell = 0; cell < cells; ++cell) { running = uint8_t(running + planes[base + cell]); const size_t axis = channel == 0 ? cell / (size * size) : channel == 1 ? (cell / size) % size : cell % size; - out[table * entries + cell * 3 + channel] = uint8_t(running + level[axis]); + out[table * entries + cell * 3 + channel] = uint8_t(std::clamp(level[axis] + int(int8_t(running)) * step, 0, 255)); } } } @@ -78,6 +77,22 @@ struct Between { double fraction; }; +struct Part { + const uint8_t *table; + double weight; +}; + +// Up to eight tables a stage; null means no change. +CameraRawStage blend(const std::vector<Part> &parts) +{ + CameraRawStage stage{}; + for (size_t index = 0; index < std::min<size_t>(parts.size(), 8); ++index) { + stage.table[index] = parts[index].table; + stage.weight[index] = float(parts[index].weight); + } + return stage; +} + // The two tables beside `value`, and how far between. Between between(double value, double low, double step, int tables) { @@ -86,8 +101,8 @@ Between between(double value, double low, double step, int tables) return {index, position - index}; } -// An adaptive slider: four tables round value and brightness. -Between between(double value, const std::array<double, 7> &points) +// The two of `points` (rising) beside `value`, and how far. +Between between(double value, std::span<const double> points) { const double held = std::min(points.back(), std::max(points.front(), value)); int index = 0; @@ -102,15 +117,19 @@ CameraRawStage adaptive(int start, double value, double brightness) const Between row = between(brightness, brightnesses), column = between(value, adaptiveValues); const double down = row.fraction, across = column.fraction; const int width = int(adaptiveValues.size()), base = start + row.index * width + column.index; - return CameraRawStage{{table(base), table(base + 1), table(base + width), table(base + width + 1)}, - {float((1 - down) * (1 - across)), float((1 - down) * across), float(down * (1 - across)), float(down * across)}}; + return blend({{table(base), (1 - down) * (1 - across)}, {table(base + 1), (1 - down) * across}, {table(base + width), down * (1 - across)}, + {table(base + width + 1), down * across}}); } CameraRawStage stage(int start, double value, double low, double step, int tables) { const Between at = between(value, low, step, tables); - return CameraRawStage{{table(start + at.index), table(start + at.index + 1), nullptr, nullptr}, - {float(1 - at.fraction), float(at.fraction), 0, 0}}; + return blend({{table(start + at.index), 1 - at.fraction}, {table(start + at.index + 1), at.fraction}}); +} + +CameraRawStage fifty(int start, double value) +{ + return stage(start, value, -100, 50, 5); } } @@ -124,9 +143,14 @@ CameraRawStage whiteBalance(double temperature, double tint) { const Between row = between(temperature, -100, 25, 9), column = between(tint, -100, 25, 9); const double down = row.fraction, across = column.fraction; - return CameraRawStage{{table(row.index * 9 + column.index), table(row.index * 9 + column.index + 1), table((row.index + 1) * 9 + column.index), - table((row.index + 1) * 9 + column.index + 1)}, - {float((1 - down) * (1 - across)), float((1 - down) * across), float(down * (1 - across)), float(down * across)}}; + const int base = row.index * 9 + column.index; + return blend({{table(base), (1 - down) * (1 - across)}, {table(base + 1), (1 - down) * across}, {table(base + 9), down * (1 - across)}, + {table(base + 10), down * across}}); +} + +const uint8_t *exposureTables() +{ + return table(exposureStart); } Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride) @@ -139,6 +163,12 @@ Brightness brightness(const uchar *pixels, int width, int height, qsizetype stri std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness) { std::vector<CameraRawStage> out; + const CameraRawCalibrationSettings &calibration = settings.calibration; + const std::array<double, 6> primaries{calibration.redHue, calibration.redSaturation, calibration.greenHue, + calibration.greenSaturation, calibration.blueHue, calibration.blueSaturation}; + for (int index = 0; index < 6; ++index) + if (primaries[size_t(index)] != 0) + out.push_back(fifty(calibrationStart + index * 5, primaries[size_t(index)])); if (settings.exposure != 0) out.push_back(stage(exposureStart, settings.exposure, -5, 0.5, 21)); if (settings.temperature != 0 || settings.tint != 0) @@ -157,6 +187,68 @@ std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brig out.push_back(stage(saturationStart, settings.saturation, -100, 25, 9)); if (settings.vibrance != 0) out.push_back(stage(vibranceStart, settings.vibrance, -100, 25, 9)); + const CameraRawCurveSettings &curve = settings.curve; + if (curve.hasMeasuredDividers()) { + const std::array<double, 4> regions{curve.shadows, curve.darks, curve.lights, curve.highlights}; + for (int index = 0; index < 4; ++index) + if (regions[size_t(index)] != 0) + out.push_back(fifty(parametricStart + index * 5, regions[size_t(index)])); + } + return out; +} + +std::vector<CameraRawStage> gradingStages(const CameraRawGradingSettings &grading) +{ + std::vector<CameraRawStage> out; + const std::array<CameraRawGradeWheel, 4> lumas{grading.shadows, grading.midtones, grading.highlights, grading.global}; + for (int index = 0; index < 4; ++index) + if (lumas[size_t(index)].luminance != 0) + out.push_back(fifty(gradingLuminanceStart + index * 5, lumas[size_t(index)].luminance)); + const double balance = grading.balance / 100; + const double highlightShare = std::max(0.0, balance < 0 ? 1 + 1.4 * balance : 1 + 2 * balance); + const double shadowShare = std::max(0.0, balance < 0 ? 1 - 1.2 * balance : 1 - 1.4 * balance); + constexpr std::array<double, 3> blendings{0, 50, 100}; + constexpr std::array<double, 4> saturations{0, 25, 50, 100}; + const Between blended = between(grading.blending, blendings); + // Blending 0 holds two wheels, 50 all four, 100 two. + const auto section = [](int blendIndex, int wheel) { return gradingStart + (blendIndex == 0 ? 0 : blendIndex == 1 ? 72 : 216) + wheel * 36; }; + struct Wheel { + int index; + CameraRawGradeWheel color; + double share; + bool blends; + }; + for (const Wheel &wheel : {Wheel{3, grading.global, 1, false}, Wheel{2, grading.midtones, 1, false}, Wheel{1, grading.highlights, highlightShare, true}, + Wheel{0, grading.shadows, shadowShare, true}}) { + if (!(wheel.color.saturation * wheel.share > 0)) + continue; + const double position = std::fmod(wheel.color.hue, 360) / 30; + const int first = int(position) % 12, second = (first + 1) % 12; + const double around = position - int(position); + const Between level = between(wheel.color.saturation * wheel.share, saturations); + std::vector<Part> parts; + const std::vector<std::pair<int, double>> blending = wheel.blends + ? std::vector<std::pair<int, double>>{{blended.index, 1 - blended.fraction}, {blended.index + 1, blended.fraction}} + : std::vector<std::pair<int, double>>{{1, 1}}; + for (const auto &[blendIndex, blendWeight] : blending) { + const int base = section(blendIndex, wheel.index); + for (const auto &[hue, hueWeight] : {std::pair{first, 1 - around}, std::pair{second, around}}) + for (const auto &[saturation, saturationWeight] : {std::pair{level.index, 1 - level.fraction}, std::pair{level.index + 1, level.fraction}}) + parts.push_back({saturation == 0 ? nullptr : table(base + hue * 3 + saturation - 1), blendWeight * hueWeight * saturationWeight}); + } + out.push_back(blend(parts)); + } + return out; +} + +std::vector<CameraRawStage> mixerStages(const CameraRawMixerSettings &mixer) +{ + std::vector<CameraRawStage> out; + const std::array<const std::array<double, 8> *, 3> kinds{&mixer.luminance, &mixer.saturation, &mixer.hue}; + for (int kind = 0; kind < 3; ++kind) + for (int color = 0; color < 8; ++color) + if ((*kinds[size_t(kind)])[size_t(color)] != 0) + out.push_back(fifty(mixerStart + (kind * 8 + color) * 5, (*kinds[size_t(kind)])[size_t(color)])); return out; } diff --git a/src/Document/CameraRawTables.h b/src/Document/CameraRawTables.h index 90a7895..b2a6e77 100644 --- a/src/Document/CameraRawTables.h +++ b/src/Document/CameraRawTables.h @@ -18,6 +18,8 @@ constexpr int grid = 33; const std::vector<uint8_t> &tables(); // Temperature and Tint: the four grid tables around them. CameraRawStage whiteBalance(double temperature, double tint); +// Exposure's 21 tables in turn, for the lens vignette. +const uint8_t *exposureTables(); // What adaptive sliders read first, in sRGB levels. struct Brightness { double brightest = 128; @@ -27,6 +29,10 @@ struct Brightness { Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride); // Camera Raw's order, sliders at zero left out. std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness); +// Color Grading's stages: luminance, global, midtones, highlights, shadows. +std::vector<CameraRawStage> gradingStages(const CameraRawGradingSettings &grading); +// The Mixer's stages: luminance, then saturation, then hue. +std::vector<CameraRawStage> mixerStages(const CameraRawMixerSettings &mixer); // The stages over the composing grid; empty without stages. std::vector<float> compose(const std::vector<CameraRawStage> &stages); // Temperature and Tint turning a straight sRGB colour gray. diff --git a/src/Resources/CameraRawTables.bin b/src/Resources/CameraRawTables.bin index 55d6d90..3af826e 100644 Binary files a/src/Resources/CameraRawTables.bin and b/src/Resources/CameraRawTables.bin differ diff --git a/tests/CameraRawColorTests.cpp b/tests/CameraRawColorTests.cpp index 644b591..fa7189a 100644 --- a/tests/CameraRawColorTests.cpp +++ b/tests/CameraRawColorTests.cpp @@ -126,13 +126,13 @@ void CameraRawColorTests::gradingWheelsAndBalanceReachTheKernel() // Balance runs in hundredths: half is between none and all. const int none = shadowTint(0), half = shadowTint(50), all = shadowTint(100); QVERIFY2(none > half && half > all, qPrintable(QStringLiteral("%1 %2 %3").arg(none).arg(half).arg(all))); - // Luminance runs in hundredths too: measured once, then held. + // Luminance comes from Photoshop's tables: Python's own lookup. const auto lifted = [](double luminance) { CameraRawSettings settings; settings.grading.shadows.luminance = luminance; return pixels(settings.apply(image(4, 4, 0.12, 0.12, 0.12)))[0][0]; }; - QVERIFY(lifted(0) == 31 && lifted(50) == 53 && lifted(100) == 76); + QVERIFY(lifted(0) == 31 && lifted(50) == 46 && lifted(100) == 56); } void CameraRawColorTests::aPointColorReachesOnlyItsRange() diff --git a/tests/CameraRawCurveTests.cpp b/tests/CameraRawCurveTests.cpp index 6005ca8..208a88b 100644 --- a/tests/CameraRawCurveTests.cpp +++ b/tests/CameraRawCurveTests.cpp @@ -1,5 +1,6 @@ #include "CameraRawFixtures.h" #include <QtTest> +#include <functional> #include <cmath> // Swift 1.4.5: Camera Raw's curves as Photoshop's, the model. @@ -161,8 +162,10 @@ void CameraRawCurveTests::eachRegionBendsOnlyItsRange() void CameraRawCurveTests::theToneTableRunsThroughThePointCurve() { + // Dividers moved: the formula draws the parametric curve. CameraRawCurveSettings curve; curve.lights = 59; + curve.shadowSplit = 30; curve.rgb = {{0, 0}, {0.5, 0.6}, {1, 1}}; const std::vector<float> table = curve.toneTable(); QCOMPARE(table.size(), size_t(256)); @@ -175,6 +178,17 @@ void CameraRawCurveTests::theToneTableRunsThroughThePointCurve() QVERIFY2(std::abs(table[size_t(index)] - expected) < 0.01, qPrintable(QString::number(index))); } QCOMPARE(table[255], 1.0f); + // As measured, the tables draw it: the point curve alone. + CameraRawCurveSettings measured = curve; + measured.shadowSplit = 25; + QVERIFY(measured.hasMeasuredDividers() && measured.toneTable() == point.toneTable()); + for (const auto &change : std::vector<std::function<void(CameraRawCurveSettings &)>>{[](CameraRawCurveSettings &c) { c.darkSplit = 49; }, + [](CameraRawCurveSettings &c) { c.lightSplit = 76; }, + [](CameraRawCurveSettings &c) { c.refineSaturation = 1; }}) { + CameraRawCurveSettings moved = measured; + change(moved); + QVERIFY(!moved.hasMeasuredDividers()); + } } void CameraRawCurveTests::refineSaturationEasesOrStrengthens() @@ -183,6 +197,7 @@ void CameraRawCurveTests::refineSaturationEasesOrStrengthens() CameraRawSettings settings; settings.curve.darks = -51; settings.curve.lights = 59; + settings.curve.darkSplit = 45; // At zero each channel runs through the tone curve alone. const std::vector<float> table = settings.curve.toneTable(); const std::array<int, 4> source = pixels(orange)[0], curved = pixels(settings.apply(orange))[0]; diff --git a/tests/CameraRawLensTests.cpp b/tests/CameraRawLensTests.cpp index fa87148..d820c9b 100644 --- a/tests/CameraRawLensTests.cpp +++ b/tests/CameraRawLensTests.cpp @@ -1,4 +1,5 @@ #include "CameraRawFixtures.h" +#include "Document/CameraRawTables.h" #include <QtTest> #include <cmath> extern "C" { @@ -178,8 +179,11 @@ void CameraRawLensTests::calibrationRunsAtItsProcessVersion() for (const CameraRawProcessVersion version : {CameraRawProcessVersion::version1, CameraRawProcessVersion::version4, CameraRawProcessVersion::version6}) { CameraRawSettings settings; settings.calibration = {version, 20, 80, 30, -40, 60, -70, 50}; + // Shadows Tint by the kernel; the primaries from Photoshop's tables. QImage direct = red.copy(); - adjust_camera_raw_calibration(direct.bits(), 3, 1, size_t(direct.bytesPerLine()), 20, 80, 30, -40, 60, -70, 50, int(version) + 1); + adjust_camera_raw_calibration(direct.bits(), 3, 1, size_t(direct.bytesPerLine()), 20, 0, 0, 0, 0, 0, 0, int(version) + 1); + const std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings.normalized(), {})); + adjust_camera_raw(direct.bits(), 3, 1, size_t(direct.bytesPerLine()), table.data(), CameraRawTables::grid, 0); QVERIFY(pixels(settings.apply(red)) == pixels(direct)); } QCOMPARE(rawValue(CameraRawProcessVersion::version3), QString("Version 3")); diff --git a/tests/CameraRawRulesTests.cpp b/tests/CameraRawRulesTests.cpp index 06e9617..611a669 100644 --- a/tests/CameraRawRulesTests.cpp +++ b/tests/CameraRawRulesTests.cpp @@ -102,8 +102,10 @@ void CameraRawRulesTests::anUntouchedGradeHandsTheImageBack() void CameraRawRulesTests::theClippingAndMaskViewsShowNothingElse() { const QImage picture = colourful(); + // Calibration's primaries ride in the Light table, as Swift's. CameraRawSettings light; light.whites = 100; + light.calibration.redHue = 80; const CameraRawSettings all = everything(); QVERIFY(pixels(all.apply(picture, CameraRawClipping::highlights)) == pixels(light.apply(picture, CameraRawClipping::highlights))); QVERIFY(pixels(all.apply(picture, CameraRawClipping::shadows)) == pixels(light.apply(picture, CameraRawClipping::shadows))); diff --git a/tests/CameraRawTablesTests.cpp b/tests/CameraRawTablesTests.cpp index 3ecce95..a801220 100644 --- a/tests/CameraRawTablesTests.cpp +++ b/tests/CameraRawTablesTests.cpp @@ -12,6 +12,7 @@ private slots: void aStageBlendsTheTablesAroundItsValue(); void eachSliderPicksItsOwnTables(); void theAdaptiveSlidersBlendAcrossBrightness(); + void theColourGroupsPickTheirTables(); void neutralizeFindsTheGridsGrayest(); void identitySlidersComposeNothing(); }; @@ -33,16 +34,18 @@ int indexOf(const uint8_t *table) void CameraRawTablesTests::theTablesDecodeAsWritten() { const std::vector<uint8_t> &tables = CameraRawTables::tables(); - QCOMPARE(tables.size(), size_t(285) * 17 * 17 * 17 * 3); + QCOMPARE(tables.size(), size_t(763) * 17 * 17 * 17 * 3); quint64 hash = 0xcbf29ce484222325ULL; for (const uint8_t byte : tables) hash = (hash ^ byte) * 0x100000001b3ULL; - QCOMPARE(hash, quint64(0x770d674136320424ULL)); + QCOMPARE(hash, quint64(0x4967372b0e6d3d38ULL)); QCOMPARE(std::vector<int>(entry(40, 2456), entry(40, 2456) + 3), (std::vector<int>{128, 128, 128})); - QCOMPARE(std::vector<int>(entry(81, 100), entry(81, 100) + 3), (std::vector<int>{0, 4, 32})); + QCOMPARE(std::vector<int>(entry(81, 100), entry(81, 100) + 3), (std::vector<int>{0, 5, 32})); QCOMPARE(std::vector<int>(entry(146, 4912), entry(146, 4912) + 3), (std::vector<int>{255, 255, 255})); - QCOMPARE(std::vector<int>(entry(200, 2456), entry(200, 2456) + 3), (std::vector<int>{172, 172, 172})); - QCOMPARE(std::vector<int>(entry(284, 100), entry(284, 100) + 3), (std::vector<int>{70, 108, 255})); + QCOMPARE(std::vector<int>(entry(200, 2456), entry(200, 2456) + 3), (std::vector<int>{173, 173, 173})); + QCOMPARE(std::vector<int>(entry(284, 100), entry(284, 100) + 3), (std::vector<int>{69, 107, 255})); + QCOMPARE(std::vector<int>(entry(500, 1000), entry(500, 1000) + 3), (std::vector<int>{63, 112, 211})); + QCOMPARE(std::vector<int>(entry(762, 4000), entry(762, 4000) + 3), (std::vector<int>{228, 235, 110})); } void CameraRawTablesTests::aStageBlendsTheTablesAroundItsValue() @@ -132,26 +135,92 @@ void CameraRawTablesTests::theAdaptiveSlidersBlendAcrossBrightness() QVERIFY(std::abs(read.brightest - 128) < 0.01 && std::abs(read.luminance - 128) < 0.01 && std::abs(read.logBrightest - 128) < 0.01); } +namespace { +// Each slot: index and weight, a null table reading −1. +std::vector<double> listed(const CameraRawStage &stage, int filled) +{ + std::vector<double> out; + for (int slot = 0; slot < filled; ++slot) { + out.push_back(indexOf(stage.table[slot])); + out.push_back(stage.weight[slot]); + } + return out; +} +} + +// Mixer, Calibration, the parametric curve and Grading, against Python. +void CameraRawTablesTests::theColourGroupsPickTheirTables() +{ + CameraRawGradingSettings grading{{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30}; + const std::vector<CameraRawStage> graded = CameraRawTables::gradingStages(grading); + QCOMPARE(graded.size(), size_t(8)); + QCOMPARE(listed(graded[0], 2), (std::vector<double>{744, 0.4f, 745, 0.6f})); + QCOMPARE(listed(graded[3], 2), (std::vector<double>{759, 0.1f, 760, 0.9f})); + QCOMPARE(listed(graded[4], 4), (std::vector<double>{-1, 0.6f, 665, 0.4f, -1, 0, 668, 0})); + QCOMPARE(listed(graded[5], 4), (std::vector<double>{-1, 0, 608, 0.6666666865348816, -1, 0, 611, 0.3333333432674408})); + QCOMPARE(listed(graded[6], 8), (std::vector<double>{566, 0.4343999922275543, 567, 0.1656000018119812, 569, 0, 570, 0, 710, 0.2896000146865845, 711, + 0.1103999987244606, 713, 0, 714, 0})); + QCOMPARE(listed(graded[7], 8), (std::vector<double>{549, 0.5472000241279602, 550, 0.052799999713897705, 552, 0, 553, 0, 693, 0.36480000615119934, 694, + 0.03519999980926514, 696, 0, 697, 0})); + // Past 330 a wheel wraps round to red's tables. + CameraRawGradingSettings wrapped; + wrapped.global = {345, 50, 0}; + QCOMPARE(listed(CameraRawTables::gradingStages(wrapped).at(0), 4), (std::vector<double>{668, 0, 669, 0.5, 635, 0, 636, 0.5})); + // Balance +100 leaves the shadow wheel no share at all. + CameraRawGradingSettings balanced; + balanced.shadows = {200, 40, 0}; + balanced.highlights = {30, 20, 0}; + balanced.balance = 100; + QCOMPARE(CameraRawTables::gradingStages(balanced).size(), size_t(1)); + CameraRawMixerSettings mixer; + for (size_t index = 0; index < 8; ++index) { + mixer.hue[index] = 5 + double(index); + mixer.saturation[index] = -30 + 7 * double(index); + mixer.luminance[index] = 20 - 4 * double(index); + } + const std::vector<CameraRawStage> mixed = CameraRawTables::mixerStages(mixer); + QCOMPARE(mixed.size(), size_t(23)); + QCOMPARE(listed(mixed[0], 2), (std::vector<double>{287, 0.6f, 288, 0.4f})); + QCOMPARE(listed(mixed[5], 2), (std::vector<double>{316, 0.08f, 317, 0.92f})); + QCOMPARE(listed(mixed[22], 2), (std::vector<double>{402, 0.76f, 403, 0.24f})); + CameraRawSettings settings; + settings.calibration = {CameraRawProcessVersion::version6, 15, 80, -40, 30, 0, -70, 50}; + settings.curve.shadows = -60; + settings.curve.darks = 10; + settings.curve.highlights = 75; + const std::vector<CameraRawStage> stages = CameraRawTables::stages(settings, {}); + QCOMPARE(stages.size(), size_t(8)); + QCOMPARE(listed(stages[0], 2), (std::vector<double>{408, 0.4f, 409, 0.6f})); + QCOMPARE(listed(stages[2], 2), (std::vector<double>{417, 0.4f, 418, 0.6f})); + QCOMPARE(listed(stages[4], 2), (std::vector<double>{433, 1, 434, 0})); + QCOMPARE(listed(stages[5], 2), (std::vector<double>{435, 0.2f, 436, 0.8f})); + QCOMPARE(listed(stages[7], 2), (std::vector<double>{453, 0.5f, 454, 0.5f})); + // Moved dividers leave the curve to the formula. + settings.curve.lightSplit = 70; + QCOMPARE(CameraRawTables::stages(settings, {}).size(), size_t(5)); + QCOMPARE(indexOf(CameraRawTables::exposureTables()), 81); +} + void CameraRawTablesTests::neutralizeFindsTheGridsGrayest() { // Python's own search over the same tables found these. const CameraRawSettings::Balance warm = CameraRawTables::neutralize(160 / 255.0, 140 / 255.0, 120 / 255.0).value(); - QVERIFY(warm.temperature == -19 && warm.tint == -7); + QVERIFY(warm.temperature == -19 && warm.tint == -6); const CameraRawSettings::Balance blue = CameraRawTables::neutralize(0.2, 0.45, 0.8).value(); - QVERIFY(blue.temperature == 100 && blue.tint == 25.5); + QVERIFY(blue.temperature == 100 && blue.tint == 26); const CameraRawSettings::Balance green = CameraRawTables::neutralize(0.3, 0.5, 0.25).value(); - QVERIFY(green.temperature == 5 && green.tint == 90.5); + QVERIFY(green.temperature == 5 && green.tint == 91); // White stays white anywhere: the first of the ties wins. const CameraRawSettings::Balance white = CameraRawTables::neutralize(1, 1, 1).value(); QVERIFY(white.temperature == 0 && white.tint == 0); // A refinement past −100 keeps to the range. const CameraRawSettings::Balance edge = CameraRawTables::neutralize(84 / 255.0, 1 / 255.0, 97 / 255.0).value(); - QVERIFY(edge.temperature == -14.5 && edge.tint == -100); + QVERIFY(edge.temperature == -16 && edge.tint == -100); const CameraRawSettings::Balance hot = CameraRawTables::neutralize(240 / 255.0, 80 / 255.0, 40 / 255.0).value(); QVERIFY(hot.temperature == -100 && hot.tint == -13.5); // Ties among searched points: tint rising within each temperature. const CameraRawSettings::Balance tie = CameraRawTables::neutralize(144 / 255.0, 216 / 255.0, 47 / 255.0).value(); - QVERIFY(tie.temperature == -100 && tie.tint == 60); + QVERIFY(tie.temperature == -100 && tie.tint == 82); // Any channel missing leaves nothing to balance from. QVERIFY(!CameraRawTables::neutralize(0.5, 0.0001, 0.5)); QVERIFY(!CameraRawTables::neutralize(0, 128 / 255.0, 128 / 255.0)); @@ -182,8 +251,8 @@ void CameraRawTablesTests::identitySlidersComposeNothing() const auto near = [](std::array<float, 3> got, std::array<float, 3> expected) { return std::abs(got[0] - expected[0]) < 1e-6f && std::abs(got[1] - expected[1]) < 1e-6f && std::abs(got[2] - expected[2]) < 1e-6f; }; - QVERIFY(near(at(16, 16, 16), {0.94272572f, 0.86208665f, 0.74603087f})); - QVERIFY(near(at(16, 4, 28), {0.86772656f, 0.55830115f, 0.98769760f})); + QVERIFY(near(at(16, 16, 16), {0.94676805f, 0.86324614f, 0.75338721f})); + QVERIFY(near(at(16, 4, 28), {0.87189686f, 0.55294615f, 0.98683822f})); } QTEST_GUILESS_MAIN(CameraRawTablesTests) diff --git a/tests/CameraRawTests.cpp b/tests/CameraRawTests.cpp index ba7985e..e731f17 100644 --- a/tests/CameraRawTests.cpp +++ b/tests/CameraRawTests.cpp @@ -14,6 +14,7 @@ private slots: void tonalSlidersMoveTheEndTheyName(); void temperatureWarmsAndTintMovesTowardMagenta(); void whiteBalanceAndVibranceMatchPhotoshop(); + void mixerCalibrationCurveAndVignettesMatchPhotoshop(); void vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesNot(); void textureAndClaritySharpenAnEdgeAndLeaveAFlatField(); void dehazeDeepensOrLiftsAndKeepsAlpha(); @@ -67,11 +68,11 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName() QVERIFY(recovered[0][0] < 222 && std::abs(recovered[1][0] - 128) <= 4); // Highlights alone reads the picture's log brightness. const auto bright = pixels(settings.apply(pair(220, 250))); - QVERIFY(bright[0][0] == 197 && bright[1][0] == 240); + QVERIFY((bright[0] == std::array{197, 197, 197, 255}) && (bright[1] == std::array{239, 240, 240, 255})); // Highlights before Whites, as Camera Raw orders them. settings.whites = 100; const auto both = pixels(settings.apply(pair(128, 230))); - QVERIFY(both[0][0] == 177 && both[1][0] == 255); + QVERIFY(both[0][0] == 179 && both[1][0] == 255); settings = CameraRawSettings(); settings.whites = 100; const auto clipped = pixels(settings.apply(brightAndMid)); @@ -132,6 +133,40 @@ void CameraRawTests::whiteBalanceAndVibranceMatchPhotoshop() } } +// Photoshop's own measured numbers, as Swift's test holds them. +void CameraRawTests::mixerCalibrationCurveAndVignettesMatchPhotoshop() +{ + struct Case { + const char *name; + std::array<int, 3> input; + std::function<void(CameraRawSettings &)> adjust; + std::array<int, 3> expected; + }; + const std::vector<Case> cases{ + {"orange saturation −60", {230, 115, 0}, [](CameraRawSettings &s) { s.mixer.saturation[1] = -60; }, {189, 130, 107}}, + {"red hue +60", {230, 92, 92}, [](CameraRawSettings &s) { s.mixer.hue[0] = 60; }, {227, 125, 83}}, + {"blue luminance +60", {92, 92, 230}, [](CameraRawSettings &s) { s.mixer.luminance[5] = 60; }, {142, 142, 248}}, + {"blue primary saturation −60", {92, 92, 230}, [](CameraRawSettings &s) { s.calibration.blueSaturation = -60; }, {149, 149, 226}}, + {"red primary hue +60", {230, 92, 92}, [](CameraRawSettings &s) { s.calibration.redHue = 60; }, {228, 118, 37}}, + {"darks −60", {130, 130, 130}, [](CameraRawSettings &s) { s.curve.darks = -60; }, {101, 101, 101}}, + }; + for (const Case &test : cases) { + CameraRawSettings settings; + test.adjust(settings); + const std::array<int, 4> got = pixels(settings.apply(image(4, 4, test.input[0] / 255.0, test.input[1] / 255.0, test.input[2] / 255.0)))[0]; + for (int c = 0; c < 3; ++c) + QVERIFY2(std::abs(got[c] - test.expected[c]) <= 4, test.name); + } + // Mid gray, 3:2, a corner: the lens lifts, post-crop darkens. + const QImage wide = image(300, 200, 128 / 255.0, 128 / 255.0, 128 / 255.0); + CameraRawSettings lens; + lens.optics.vignetteAmount = 100; + QVERIFY(std::abs(pixels(lens.apply(wide))[0][0] - 223) <= 6); + CameraRawSettings postCrop; + postCrop.vignetteAmount = -60; + QVERIFY(std::abs(pixels(postCrop.apply(wide))[0][0] - 30) <= 6); +} + void CameraRawTests::vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesNot() { const QImage dullGreen = image(4, 4, 77 / 255.0, 153 / 255.0, 77 / 255.0), saturatedGreen = image(4, 4, 20 / 255.0, 200 / 255.0, 20 / 255.0); diff --git a/tests/CameraRawWiringTests.cpp b/tests/CameraRawWiringTests.cpp index 9504e24..f7c8c5b 100644 --- a/tests/CameraRawWiringTests.cpp +++ b/tests/CameraRawWiringTests.cpp @@ -130,7 +130,7 @@ void CameraRawWiringTests::autoAveragesLinearLight() QImage dark = BrushRaster::context(1, 1, false); dark.setPixelColor(0, 0, QColor(3, 5, 8)); const CameraRawSettings::Balance shade = CameraRawSettings::autoBalance(dark).value(); - QVERIFY(shade.temperature == 50.5 && shade.tint == 20); + QVERIFY(shade.temperature == 53 && shade.tint == 15.5); } void CameraRawWiringTests::eachEyeClearsEveryFieldOfItsGroup() @@ -186,7 +186,8 @@ void CameraRawWiringTests::opticsAndDetailReachTheKernelsInOrder() .noiseColorSmoothness = 80}; const double k = settings.optics.distortionK(PixelFilter::lensStrength); const QImage direct = kernel(picture, [k](uchar *bytes, size_t w, size_t h, size_t s) { - adjust_camera_raw_optics(bytes, w, h, s, 1, 1, 60, 40, k, 30, 260, 320, 40, 50, 130, -45, 30, 1); + adjust_camera_raw_optics(bytes, w, h, s, 1, 1, 60, 40, k, 30, 260, 320, 40, 50, 130, -45, 30, 1, CameraRawTables::exposureTables(), + CameraRawTables::size); adjust_camera_raw_detail(bytes, w, h, s, 80, 30, 60, 20, 40, 70, 30, 50, 20, 80, 1); }); QVERIFY(pixels(settings.apply(picture)) == pixels(direct)); @@ -269,21 +270,17 @@ void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() const CameraRawCurveSettings shape = curve.normalized(); const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), blue = shape.channelTable(shape.blue); - std::vector<float> mixer; - for (size_t index = 0; index < 8; ++index) - mixer.push_back(float((5 + double(index)) / 100)); - for (size_t index = 0; index < 8; ++index) - mixer.push_back(float((-30 + 7 * double(index)) / 100)); - for (size_t index = 0; index < 8; ++index) - mixer.push_back(float((20 - 4 * double(index)) / 100)); + // The colours and wheels arrive as tables; formulas idle. + const std::vector<float> mixer(24, 0), grade(12, 0); + const std::vector<float> mixerTable = CameraRawTables::compose(CameraRawTables::mixerStages(settings.mixer.normalized())); + const std::vector<float> gradeTable = CameraRawTables::compose(CameraRawTables::gradingStages(settings.grading.normalized())); + QVERIFY(!mixerTable.empty() && !gradeTable.empty()); const std::vector<float> points{float(200 / 360.0), 0.6f, 0.4f, 0.25f, -0.35f, 0.15f, float(40 / 360.0), 0.5f, 0.3f, float(40 / 360.0), 0.8f, 0.6f, -0.1f, 0.2f, -0.45f, float(60 / 360.0), 0.3f, 0.6f}; - const std::vector<float> grade{float(210 / 360.0), 0.4f, -0.2f, float(100 / 360.0), 0.25f, 0.1f, - float(30 / 360.0), 0.55f, 0.15f, float(300 / 360.0), 0.1f, -0.05f}; for (const int visualize : {-1, 1}) { const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t stride) { adjust_camera_raw_curve_color(bytes, w, h, stride, tone.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(), - grade.data(), 0.7, -0.3, visualize); + grade.data(), 0.7, -0.3, visualize, mixerTable.data(), gradeTable.data(), CameraRawTables::grid); }); QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct)); } @@ -305,7 +302,8 @@ void CameraRawWiringTests::theSharpenMaskAndEachLensFlagReachTheirKernels() lens.optics.enableLensProfile = !chromatic; const double k = lens.optics.distortionK(PixelFilter::lensStrength); const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) { - adjust_camera_raw_optics(bytes, w, h, s, chromatic ? 1 : 0, chromatic ? 0 : 1, 100, 100, k, 0, 270, 310, 0, 60, 120, 0, 50, 1); + adjust_camera_raw_optics(bytes, w, h, s, chromatic ? 1 : 0, chromatic ? 0 : 1, 100, 100, k, 0, 270, 310, 0, 60, 120, 0, 50, 1, + CameraRawTables::exposureTables(), CameraRawTables::size); }); QVERIFY(pixels(lens.apply(picture)) == pixels(direct)); } diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 8d162d2..66b8fa0 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -391,4 +391,5 @@ compositor_test(SliderSnapTests) compositor_test(ProjectResizeFailureTests) # The `.svgz` fixture is gzip, written through zlib. +find_package(ZLIB REQUIRED) target_link_libraries(SVGImportTests PRIVATE ZLIB::ZLIB)