diff --git a/AGENTS.md b/AGENTS.md
index 52bbd24..c96fb02 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -123,6 +123,7 @@ Platform adaptations, each forced by a missing Qt analog:
- Middle-button pan, tool defaults, Auto Select and the icon (12.6b, Swift 1.2.1): `Document/ToolDefaults` twins Swift's enum as a namespace: `boolean` (Swift's `bool`, a C++ keyword) and `set` keep Auto Select, the transform controls, the pixel grid, the layout grid, guides, rulers, Snap, the four Snap To switches and Lock Guides in `QSettings` under Swift's `tool.` keys, read by the session's members as they are made and written by each setter, and by the two places that show guides by themselves (`beginGuideCreation`, `addGuide`), as Swift's `didSet` sees those writes. Swift returns the defaults under XCTest; Qt has no such flag, and most test binaries run outside `QStandardPaths`' test mode, so the twin keeps the defaults until `ToolDefaults::enable()`, which `main` calls first (not under test, as the theme's construction is not). The settings file keeps text, so a stored value reads as `true` or `false`; anything else is ignored with a warning on `omaphoto.app`, where Swift's `as? Bool` is silent. The canvas's middle button (Swift's `otherMouseDown` for button 2) pans from any tool while a document is open, on a drag point of its own (`m_middlePanPoint`), so a left drag under way goes on beside it: a move with both buttons pans, then gives the left its turn (so a Hand drag pans twice), and a right-button tip drag waits for the middle's release. Known difference: macOS sends one drag event a motion, so which button Swift's canvas follows then is AppKit's choice. The press hides the brush circle and shows the closed hand, which outranks every other cursor; the release puts the tool's cursor and circle back. Qt can lose a release: a move without the middle button ends the pan. Unlike Swift's `mouseDown`, the press takes no focus. `transformPressLayer` gains Swift's foreground rule: with Auto Select, a press inside the active layer's box goes to the layer under the pointer when that is painted above it (a background holding every press no longer hides the layer on top); `above` reads both layers through checked lookups, since the pointer's layer and the visible active one are both drawn, where Swift guards each index. Swift's `underPointer != active.id` is left out as dead (`above` refuses a layer against itself); equivalent mutant: `>=` in `above` (only the active layer equals itself, and `selectLayer` of the active layer changes nothing). The reshaped app icon (`45ab8fd`) replaces `packaging/icons`. Tests: `ToolDefaultsTests` (the defaults until enabled, every toggle across sessions and as text, guides shown by a guide, a strange value), `theMiddleButtonPansFromAnyTool` in `CanvasNavigationTests`, Swift's `autoSelectPicksForegroundLayerStackedOnSelectedBackground` and a mask box past its layer in `TransformPressTests`, whose older Auto Select test now expects the layer above.
- Photoshop's blend modes (12.7a, Swift 1.2.2): `LayerBlendMode` takes Swift's order, Photoshop's, and `allLayerBlendModes` follows it, so the blend keys step Normal, Darken, Multiply…; the raw values are Swift's (`Linear Dodge (Add)` among them), so the manifest reads them without a new version, as Swift's. `blendGroup` stands for Swift's static `groups`: each group's modes sit together in the enum, so the group is the last first member a mode reaches. `BlendModePicker` draws a separator between groups, as Swift's pop-up does; QComboBox's keys step over them and never make one current, so no preview names one. Hard Light and Exclusion are QPainter's composition modes; the other eight go by hand (`SeparableBlend`, `HslBlend`). The PSD reader takes Swift's new keys (`lbrn`, `lddg`, `hLit`, `vLit`, `lLit`, `pLit`, `hMix`, `smud`, `fsub`, `fdiv`); Dissolve, Darker Color and Lighter Color fall to Normal with the conversion note, and Swift's renamed test uses Dissolve. A fix beyond Swift: Swift's `LiveMaskRenderer` composites a clipping stack's group and an adjustment's result through `cgMode`, which is Normal for the eight (v1.2.2's `prepareStacks`, `drawComposite` and `adjust`, unchanged through 1.3.3), so a base or an adjustment in one of them draws as Normal there; the twin hands every mode to `LayerRenderer::composite`, which blends the eight by hand, as Photoshop composites a clipping group with its base's mode (`ClippingStackTests::aStackBlendsOnceWithItsBasesMode`, `AdjustmentLayerTests::adjustmentBlendAndSoftMaskPreserveCoverage`). Swift's `coreImageFilter` has no twin: `needsSurface` names the modes. Equivalent mutants: `<=` against `<` at one half in Vivid and Pin Light (both branches give the backdrop there). Tests: the formulas in `BlendModeTests`, the menu's groups and keys in `BlendModePickerTests`, the order in `LayerAppearanceTests`, `BlendShortcutTests`, `EditorCanvasTests` and `ProjectManifestTests`, the keys in `PSDRoundTripTests` and `PSDImportTests`, the canvas against the export in `CanvasDisplayTests::photoshopsOwnModesShowAsTheExportDoes`.
- Black & White, Color Balance and Invert (12.7b, Swift 1.2.2): `kernels/AdjustPixels.c/.h` are v1.2.2's, with `adjust_black_white` and `adjust_color_balance`, run through `ImageAdjustmentPixels::run` on one thread as Swift's. `BlackWhiteSettings` and `ColorBalanceSettings` keep Swift's fields, defaults and bounds (the `isFinite` terms left out, as the bounds refuse NaN and infinities); `apply` throws `ProjectError(invalid)` past them and Color Balance hands an untouched image back. Swift's `FilterSettings.normalized` does not clamp either, so a number typed past a slider's range fails the preview with that error and a commit sets `brushError` with no step, as in Swift: the filter's two workers catch `ProjectError` beside their other errors, where it would otherwise escape as `QUnhandledException` and abort. `FilterKind` gains both after Grain (`isImageAdjustment`), `AdjustmentKind` Invert, Black & White and Color Balance in Swift's order, with `isEditable` (all but Invert) and `filterKind`; `LayerAdjustment` gains the two optionals with Swift's computed getter and setter pairs, validity and `apply` (Invert through `PixelInvert::run`, whole image, colour only); the manifest's `blackWhiteSettings` and `colorBalanceSettings` are Swift's synthesized `Codable`, every key required, left out while absent. `addAdjustment` opens the editor for editable kinds alone: an Invert layer applies at once. The editors carry both settings through `beginAdjustmentEditing` and `editedAdjustment`. A fix beyond Swift: Swift's Edit Adjustment… stays enabled on an Invert layer, whose editor switch breaks after the original is kept and the transaction opened, so nothing ever closes them and the session stays refused; the twin's entry rests for a kind that is not editable, and the two switches throw a `logic_error` for Invert, which nothing reaches. As Swift's row, an Invert layer says "Adjustment · Double-click to edit", yet a double click on its glyph renames it (`m_editableAdjustment`, Swift's `isEditable` test). The Image menu lists Black & White… and Color Balance… before Exposure…, the Layer menu's New Adjustment Layer entries take an ellipsis when editable, and a menu doubles the ampersand of "Black & White" (a lone one is a mnemonic in Qt), in the kinds' entries and in Undo and Redo's step names alike; object names drop it (`blackWhite`, `newBlackWhiteAdjustment`). `LayerIcons` draws Swift's three new symbols as paths: `circle.righthalf.filled`, `circle.filled.pattern.diagonalline.rectangle` (a rounded frame, diagonal lines, a filled circle) and `scale.3d` (three axes with arrowheads). The filter sheet's two kinds live in `UI/FilterSheet+Adjustments.cpp` (the sheet's file neared 500 lines): the six families and Tint's Hue and Saturation, shown while Tint is on (Swift's `if`, the rows `m_shownWhen` holds with Advanced's), and Color Balance's three headlines (13 pixels, semibold, Swift's `.headline`, as the effects sheet's) over three rows each and Preserve Luminosity; each `flag` is Swift's `Toggle` with its help as the tooltip. `NativeLayerList` stops listening in its destructor, as `ContentView` does: closing a list window hides it first, and a rename's focus-out then commits into a list half destroyed. Tests: Swift's `invertAppliesWithoutAnEditor` in `AdjustmentLayerTests`, its editor test over the editable kinds in `AdjustmentEditorTests` and `FloatingPanelTests`, `ColorAdjustmentTests` (the kernels against Photoshop's defaults and a tint worked in HSL, the tones of Color Balance and Preserve Luminosity, bounds, every manifest key, the sheet), the kinds, keys and menus in `FilterModelTests`, `LayerAdjustmentTests`, `AdjustmentPanelTests`, `CompositorMenusTests` and `SelectMenuTests`. `ProjectExportFailureTests`' two panel limits rose from 2,048 to 2,304 KB: the thousand-layer snapshot grew by the two optionals of every record; a copy still fails under them (both copy mutants caught, in the review's runs).
+- The Camera Raw filter's model (12.8a, Swift 1.2.3): `kernels/AdjustPixels.c/.h` are v1.2.3's, with the Camera Raw kernels and Grain's particle size (`59b5040`); that kernel warns under `-Wunused-parameter` and `-Wmisleading-indentation` through 1.3.3 (both lines date from `9982fad`; `075a71b` fixes another), so CMake silences those two for that file alone. `Document/CameraRaw.h/.cpp`, `CameraRawColor`, `CameraRawDetailOptics` and `CameraRawGeometryCalibration` twin Swift's four files; Swift's `CameraRaw.swift` runs to 638 lines, so its session extension is `CameraRaw+Session.cpp` with `CameraRaw+Session.h`. The enums keep Swift's raw values and order; `CameraRawClipping` keeps Swift's bits (highlights 1, shadows 2); `Balance` and `Gains` are structs for Swift's tuples; `region` returns a reference where Swift writes through a key path. `CameraRawDrag` sits in `CameraRaw.h`, where Swift's sits in `CameraRawColor.swift`: it holds the whole settings, which C++ needs complete. Swift's panel state sits on `FilterEdit` (the shown groups, pages, swatch, point index, the tools, the guide draft, clipping, sharpen mask, the scope and its mode, the readout); the twin gathers it in `CameraRawPanel`, `FilterEdit::rawPanel`, written through `setCameraRawPanel`, which notifies while an edit is open. `renderSettings` is Swift's settings with the hidden groups left out (`applying`); the commit renders and keeps those, so a hidden group stays out next time, and an identity grade closes as Cancel does. `previewJob` carries the panel's views; the preview's worker runs `CameraRawScope::preview`, whose scope lands on the panel. A fix beyond Swift: Swift's `preview` drops the point colour's view (it runs the job itself only for clipping or the sharpen mask, through 1.3.3), so Visualize never shows; the twin runs the job for it too. Geometry's warp is `QTransform::quadToQuad` from the image's rect to Swift's corners flipped to y down, drawn smooth and antialiased; a warp Qt cannot solve is a `logic_error`. Constrain Crop fits the frame inside `brush_alpha_bounds`; a crop whose copy finds no memory is `ExportError(render)`, where Swift's `cropping` returning nil falls back to the uncropped warp. Left out as dead: `apply`'s `isValid` guard and with it `isValid` (settings arrive normalized, and nothing else asks), `nudged`'s guard for an empty channel (`normalized` repairs every curve to two points; `at` throws if that breaks), the vectorscope's wrap of a negative hue (its angle's cosine and sine turn round alike), the parametric curve's `max(0, weight)` (a tone inside its region weighs 0 to 1), each group's `applying(shows:)` (no caller), and the defringe eyedropper's own `normalized` (`updateFilter` normalizes). Equivalent mutants: `tone < shadow` against `<=` (the regions meet with equal weight), the defringe tie's `<` against `<=`, the mixer's `weights > 0` against `>= 0` (a zero weight adds nothing), the identity commit's cancel (the history drops an unchanged step), and Auto's `committing` term (a committing edit refuses `updateFilter`, and the landing asks again). The Filter menu and the sheet leave Camera Raw out until its panel (12.8b). Tests: `CameraRawTests`, `CameraRawSessionTests` (Swift's session tests; the preview's views and scope), `CameraRawToolsTests`, `CameraRawRulesTests`, `CameraRawColorTests`, `CameraRawLensTests` (the kernel's calibration called directly), `CameraRawWiringTests` (Effects, Optics and Detail against their kernels called with the header's order, a clip view on an untouched grade, Auto in linear light, every eye's fields, Offset, Aspect and Constrain Crop against placed pictures, the scope's channels and hues, the curve, mixer and grading call with its floats written out, the sharpen-mask view, each lens flag alone, Grain's scale, a dull colour's reach, Rotate, Scale and Vertical against placed pictures), `CameraRawFixtures.h`, Grain's size in `ImageAdjustmentTests`, the kind in `FilterModelTests`.
- 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 the mean of its edge pixels, as Swift's `edgeTone`. `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`, a spacer 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 `QScrollArea` whose buttons are kept for the tabs that stay, moved for the tabs that moved, made for new tabs and deleted for tabs that went; its row is sized to its tabs by hand (a resizable scroll area would squeeze every title below Swift's 35 points) and resized again whenever a title changes, and the front tab scrolls into view at once; it and its viewport fill no background, so the toolbar shows through as Swift's mask lets it (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 39c446a..6a49da5 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -55,6 +55,11 @@ add_library(compositor_core STATIC
src/Document/ToolDefaults.cpp
src/Document/ObjectSelection.cpp
src/Document/Filters.cpp
+ src/Document/CameraRaw.cpp
+ src/Document/CameraRaw+Session.cpp
+ src/Document/CameraRawColor.cpp
+ src/Document/CameraRawDetailOptics.cpp
+ src/Document/CameraRawGeometryCalibration.cpp
src/Document/GuidedMatte.cpp
src/Document/SubjectRemoval.cpp
src/Document/FloatingSelection.cpp
@@ -191,6 +196,8 @@ target_sources(compositor_core PRIVATE ${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAP
set_source_files_properties(${OMAPHOTO_KERNELS}/AdjustPixels.c ${OMAPHOTO_KERNELS}/BrushPixels.c ${OMAPHOTO_KERNELS}/WandPixels.c ${OMAPHOTO_KERNELS}/ContentFill.c
${OMAPHOTO_KERNELS}/HealPixels.c ${OMAPHOTO_KERNELS}/LevelsPixels.c ${OMAPHOTO_KERNELS}/NoisePixels.c ${OMAPHOTO_KERNELS}/LensPixels.c
PROPERTIES COMPILE_OPTIONS -O2)
+# Swift's kernel warns so through 1.3.3, unchanged.
+set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_OPTIONS -Wno-unused-parameter -Wno-misleading-indentation)
# 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})
diff --git a/TASKS.md b/TASKS.md
index f999932..865f7f0 100644
--- a/TASKS.md
+++ b/TASKS.md
@@ -344,9 +344,13 @@ The panel of 4.6 shows and edits what the session holds so far. Each item adds i
- 12.7b round 1: FAIL, three findings. A Black & White or Color Balance value past its range aborted the app: the filter's workers now catch `ProjectError`, the sheet shows it and a commit reports it with no step, as Swift; Undo and Redo double a step's ampersand; yellows and cyans are told apart in the kernel test. Headlines semibold, Preserve Luminosity's help asserted.
- 12.7b round 2: FAIL, one finding: five Color Balance manifest keys sat at zero in every fixture; nine distinct amounts now, each key's value read.
- 12.7b round 3: FAIL, one finding, the third and last round: the sheet's rows were not held to their fields. Fixed after the round: every Black & White and Color Balance slider is set to a value of its own and read back, Hue to 360; the review's three survivors fail now (`mut-12.7b-r3.log`); the item closes on that.
-- [ ] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels.
-- [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas, its grading wheels turning the way their hue runs (1.2.9's `96edd5f`).
+- 12.8a round 1: FAIL, four findings: the kernel-warning note named a fix that never came (the warnings stand through 1.3.3); 24 of 30 mutants survived on the grade's wiring (kernel argument order, the clip view on an untouched grade, Auto's linear mean, each eye's fields, Offset, Aspect and Constrain Crop, the scope's channels and hues, the samples' hue branches, the Luminance tab); `isValid` had no caller; TASKS.md's 12.8b line lacked the canvas half. `CameraRawWiringTests` added, `isValid` and the vectorscope's dead wrap removed.
+- 12.8a round 2: FAIL, one finding: the curve, mixer and grading call, the sharpen-mask view and the lens flags were not held against their kernels; each is now, with the floats written out by hand, and the minor constants (Grain's scale, a dull colour's reach, Rotate, Scale, Vertical, a green sample's tone) with them.
+- 12.8a round 3: PASS. Its three minor gaps (Horizontal's factor, the sharpen mask's scale, calibration's green and blue fields) pinned after the round (`mut-12.8a-r6.log`).
+- [x] 12.8a Version 1.2.3, part one: the Camera Raw filter's model and pixel kernels.
+- [ ] 12.8b Version 1.2.3, part two: the Camera Raw panel beside the canvas (Swift's docked placement in `FloatingPanel.swift`), its grading wheels turning the way their hue runs (1.2.9's `96edd5f`), the Filter menu's entry, and the canvas half of the tools in `EditorCanvas.swift` (the readout on hover and its clearing on leaving, the three eyedroppers and the guide's press, each refocusing the panel, the guide and targeted drags, their release, `picking`'s four terms).
- [ ] 12.8c Version 1.2.3, part three: Inner Glow, Image Trim, blur and noise adjustment layers, the layer context menu without 1.2.5's dropped Layer Effects entry (`5d7adba`).
+- [ ] 12.8d A `std::bad_alloc` escapes a catch in `ProjectExportFailureTests::aJPEGRenderThatFindsNoMemoryIsExplained` under a parallel run (seen twice, 12.7b and 12.8a; alone it passes ten times of ten): find the throw and catch it where the house rule says.
- [ ] 12.9 Version 1.2.4: the finishing filters, stepped keyboard zoom, a steady tool rail and tab strip, the project format's version in one place.
- [ ] 12.10 Versions 1.2.5–1.2.7: copying and pasting whole layers within and between projects, duplicating every selected layer, crop ratios 3:4 and 9:16 and a crop that starts at the selection, Vignette's defaults, text colour previews.
- [ ] 12.11 Versions 1.2.8–1.2.10: the document's own pixel budget, PSB, simple Photoshop text as editable text, background-only PSDs, oversized Photoshop layers cropped, clicked text starting on its first baseline, live text opened by a double click with the Move tool (`a5721b2`), marching ants at screen resolution, SVG import (Qt SVG).
diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c
index 1348a3a..b82e2e9 100644
--- a/kernels/AdjustPixels.c
+++ b/kernels/AdjustPixels.c
@@ -1,5 +1,8 @@
#include "AdjustPixels.h"
+#include "LensPixels.h"
#include
+#include
+#include
void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table) {
for (size_t y = 0; y < height; y++) {
@@ -41,6 +44,21 @@ static inline float lattice(int64_t ix, int64_t iy, uint32_t seed) {
return (float)(h & 0xFFFFU) / 65535.0f + (float)(h >> 16) / 65535.0f - 1.0f;
}
+// Smooth seeded noise whose features follow `scale` document pixels. Keeping both the broad and
+// detailed patterns relative to the requested grain size makes Size remain visible at any Roughness.
+static inline float grain_field(double u, double v, double scale, uint32_t seed) {
+ double cellX = floor(u / scale), cellY = floor(v / scale);
+ float tx = (float)(u / scale - cellX), ty = (float)(v / scale - cellY);
+ tx = tx * tx * (3.0f - 2.0f * tx);
+ ty = ty * ty * (3.0f - 2.0f * ty);
+ int64_t ix = (int64_t)cellX, iy = (int64_t)cellY;
+ float n00 = lattice(ix, iy, seed), n10 = lattice(ix + 1, iy, seed);
+ float n01 = lattice(ix, iy + 1, seed), n11 = lattice(ix + 1, iy + 1, seed);
+ float top = n00 + (n10 - n00) * tx, bottom = n01 + (n11 - n01) * tx;
+ // Blending neighboring lattice values narrows the spread; restore approximately its original range.
+ return (top + (bottom - top) * ty) * 1.6f;
+}
+
static inline float clamp255(float value) { return value < 0 ? 0 : value > 255 ? 255 : value; }
void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size,
@@ -50,27 +68,18 @@ void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, dou
float strength = (float)(amount > 100 ? 1.0 : amount / 100.0) * 0.35f * 255.0f;
float rough = (float)(roughness < 0 ? 0.0 : roughness > 100 ? 1.0 : roughness / 100.0);
uint32_t fineSeed = mix32(seed ^ 0xA511E9B3U);
+ // Roughness adds smaller, less regular particles, as in Photoshop, but their size remains
+ // proportional to the Size control instead of collapsing to fixed one-pixel noise.
+ double detailSize = fmax(0.5, size * 0.35);
for (size_t y = 0; y < height; y++) {
double v = originY + ((double)y + 0.5) * unitsPerPixel;
- double cellY = floor(v / size);
- float ty = (float)(v / size - cellY);
- ty = ty * ty * (3.0f - 2.0f * ty);
- int64_t iy = (int64_t)cellY, fineY = (int64_t)floor(v);
uint8_t *p = rgba + y * stride;
for (size_t x = 0; x < width; x++, p += 4) {
unsigned a = p[3];
if (a == 0) continue;
double u = originX + ((double)x + 0.5) * unitsPerPixel;
- double cellX = floor(u / size);
- float tx = (float)(u / size - cellX);
- tx = tx * tx * (3.0f - 2.0f * tx);
- int64_t ix = (int64_t)cellX;
- float n00 = lattice(ix, iy, seed), n10 = lattice(ix + 1, iy, seed);
- float n01 = lattice(ix, iy + 1, seed), n11 = lattice(ix + 1, iy + 1, seed);
- float top = n00 + (n10 - n00) * tx, bottom = n01 + (n11 - n01) * tx;
- // Blending neighbors narrows the spread; scaling restores about the lattice's own.
- float smooth = (top + (bottom - top) * ty) * 1.6f;
- float fine = lattice((int64_t)floor(u), fineY, fineSeed);
+ float smooth = grain_field(u, v, size, seed);
+ float fine = grain_field(u, v, detailSize, fineSeed);
float noise = smooth + (fine - smooth) * rough;
float unpremultiply = a == 255 ? 1.0f : 255.0f / (float)a;
float r = p[0] * unpremultiply, g = p[1] * unpremultiply, b = p[2] * unpremultiply;
@@ -185,3 +194,868 @@ void adjust_color_balance(uint8_t *rgba, size_t width, size_t height, size_t str
}
}
}
+
+static double camera_clamp(double value) {
+ if (value < 0) return 0;
+ if (value > 1) return 1;
+ return value;
+}
+
+static double srgb_to_linear(double encoded) {
+ if (encoded <= 0.04045) return encoded / 12.92;
+ return pow((encoded + 0.055) / 1.055, 2.4);
+}
+
+static double linear_to_srgb(double linear) {
+ if (linear <= 0) return 0;
+ if (linear >= 1) return 1;
+ if (linear <= 0.0031308) return linear * 12.92;
+ return 1.055 * pow(linear, 1.0 / 2.4) - 0.055;
+}
+
+static double rec709(double r, double g, double b) {
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+}
+
+// Moves r, g, b so their Rec. 709 luminance becomes `target`, keeping the hue. Pure black cannot
+// be scaled, so a lift paints neutral light of that luminance.
+static void scale_luminance(double *r, double *g, double *b, double target) {
+ target = camera_clamp(target);
+ double y = rec709(*r, *g, *b);
+ if (fabs(target - y) < 1e-8) return;
+ if (y < 1e-8) {
+ if (target > y) *r = *g = *b = target;
+ return;
+ }
+ double scale = target / y;
+ *r = camera_clamp(*r * scale);
+ *g = camera_clamp(*g * scale);
+ *b = camera_clamp(*b * scale);
+}
+
+static double tone_highlights(double y, double amount) {
+ double t = camera_clamp((y - 0.5) / 0.5);
+ double weight = t * t;
+ if (amount >= 0) return camera_clamp(y + amount * weight * (1.0 - y));
+ return camera_clamp(y + amount * weight * (y - 0.5));
+}
+
+static double tone_shadows(double y, double amount) {
+ double t = camera_clamp((0.5 - y) / 0.5);
+ double weight = t * t;
+ if (amount >= 0) return camera_clamp(y + amount * weight * (0.5 - y));
+ return camera_clamp(y + amount * weight * y);
+}
+
+// The top quarter is the white point: +1 maps 0.875 to 1, −1 pulls everything above 0.75 down to 0.75.
+static double tone_whites(double y, double amount) {
+ if (y <= 0.75) return y;
+ return camera_clamp(0.75 + (y - 0.75) * (1.0 + amount));
+}
+
+// The bottom quarter is the black point. Negative amounts crush toward 0; positive ones lift toward 0.25.
+static double tone_blacks(double y, double amount) {
+ if (y >= 0.25) return y;
+ return camera_clamp(0.25 + (y - 0.25) * (1.0 - amount));
+}
+
+static void vibrance_and_saturation(double *r, double *g, double *b, double vibrance, double saturation) {
+ double lum = rec709(*r, *g, *b);
+ double maxc = fmax(*r, fmax(*g, *b));
+ double minc = fmin(*r, fmin(*g, *b));
+ double chroma = maxc - minc;
+ double sat = maxc <= 1e-8 ? 0 : chroma / maxc;
+ double hue = 0;
+ if (chroma > 1e-8) {
+ if (*r >= *g && *r >= *b) hue = 60.0 * fmod((*g - *b) / chroma, 6.0);
+ else if (*g >= *r && *g >= *b) hue = 60.0 * ((*b - *r) / chroma + 2.0);
+ else hue = 60.0 * ((*r - *g) / chroma + 4.0);
+ if (hue < 0) hue += 360.0;
+ }
+ double skin = 0;
+ if (hue >= 10.0 && hue <= 50.0) {
+ skin = hue <= 30.0 ? (hue - 10.0) / 20.0 : (50.0 - hue) / 20.0;
+ skin *= camera_clamp((sat - 0.15) / 0.35);
+ }
+ double amount = vibrance * (1.0 - sat);
+ if (vibrance > 0) amount *= 1.0 - 0.7 * skin;
+ double factor = 1.0 + amount;
+ *r = camera_clamp(lum + (*r - lum) * factor);
+ *g = camera_clamp(lum + (*g - lum) * factor);
+ *b = camera_clamp(lum + (*b - lum) * factor);
+ lum = rec709(*r, *g, *b);
+ factor = 1.0 + saturation;
+ *r = camera_clamp(lum + (*r - lum) * factor);
+ *g = camera_clamp(lum + (*g - lum) * factor);
+ *b = camera_clamp(lum + (*b - lum) * factor);
+}
+
+static void write_premultiplied(uint8_t *p, double r, double g, double b, double alpha) {
+ p[0] = (uint8_t)fmin(alpha, fmax(0.0, round(r * alpha)));
+ p[1] = (uint8_t)fmin(alpha, fmax(0.0, round(g * alpha)));
+ p[2] = (uint8_t)fmin(alpha, fmax(0.0, round(b * alpha)));
+}
+
+void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double redGain, double greenGain, double blueGain, double exposure, double contrast,
+ double highlights, double shadows, double whites, double blacks,
+ double vibrance, double saturation, int clipping) {
+ double light = exp2(exposure);
+ double contrastScale = 1.0 + contrast / 100.0;
+ double highlightAmount = highlights / 100.0;
+ double shadowAmount = shadows / 100.0;
+ double whiteAmount = whites / 100.0;
+ double blackAmount = blacks / 100.0;
+ double vibranceAmount = vibrance / 100.0;
+ double saturationAmount = saturation / 100.0;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ double r = fmin(255.0, p[0] * 255.0 / alpha) / 255.0;
+ double g = fmin(255.0, p[1] * 255.0 / alpha) / 255.0;
+ double b = fmin(255.0, p[2] * 255.0 / alpha) / 255.0;
+ r = camera_clamp(srgb_to_linear(r) * redGain * light);
+ g = camera_clamp(srgb_to_linear(g) * greenGain * light);
+ b = camera_clamp(srgb_to_linear(b) * blueGain * light);
+ r = camera_clamp(0.5 + (linear_to_srgb(r) - 0.5) * contrastScale);
+ g = camera_clamp(0.5 + (linear_to_srgb(g) - 0.5) * contrastScale);
+ b = camera_clamp(0.5 + (linear_to_srgb(b) - 0.5) * contrastScale);
+ scale_luminance(&r, &g, &b, tone_highlights(rec709(r, g, b), highlightAmount));
+ scale_luminance(&r, &g, &b, tone_shadows(rec709(r, g, b), shadowAmount));
+ scale_luminance(&r, &g, &b, tone_whites(rec709(r, g, b), whiteAmount));
+ scale_luminance(&r, &g, &b, tone_blacks(rec709(r, g, b), blackAmount));
+ vibrance_and_saturation(&r, &g, &b, vibranceAmount, saturationAmount);
+ if (clipping == 1) {
+ int rc = r >= 254.5 / 255.0, gc = g >= 254.5 / 255.0, bc = b >= 254.5 / 255.0;
+ r = rc ? 1 : 0;
+ g = gc ? 1 : 0;
+ b = bc ? 1 : 0;
+ } else if (clipping == 2) {
+ int rc = r <= 0.5 / 255.0, gc = g <= 0.5 / 255.0, bc = b <= 0.5 / 255.0;
+ if (rc || gc || bc) {
+ r = rc ? 0 : 1;
+ g = gc ? 0 : 1;
+ b = bc ? 0 : 1;
+ } else {
+ r = g = b = 1;
+ }
+ }
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+}
+
+static size_t clamped_index(int index, size_t limit) {
+ if (index < 0) return 0;
+ if ((size_t)index >= limit) return limit - 1;
+ return (size_t)index;
+}
+
+// Edge-clamped box blur. `dst` may not alias `src`. Returns 0 when the temporary row buffer cannot be allocated.
+static int box_blur_plane(const float *src, float *dst, size_t width, size_t height, int radius) {
+ if (radius < 1) {
+ memcpy(dst, src, width * height * sizeof(float));
+ return 1;
+ }
+ float *temp = malloc(width * height * sizeof(float));
+ if (!temp) return 0;
+ int window = radius * 2 + 1;
+ for (size_t y = 0; y < height; ++y) {
+ double sum = 0;
+ for (int k = -radius; k <= radius; ++k) sum += src[y * width + clamped_index(k, width)];
+ for (size_t x = 0; x < width; ++x) {
+ temp[y * width + x] = (float)(sum / window);
+ sum += src[y * width + clamped_index((int)x + radius + 1, width)];
+ sum -= src[y * width + clamped_index((int)x - radius, width)];
+ }
+ }
+ for (size_t x = 0; x < width; ++x) {
+ double sum = 0;
+ for (int k = -radius; k <= radius; ++k) sum += temp[clamped_index(k, height) * width + x];
+ for (size_t y = 0; y < height; ++y) {
+ dst[y * width + x] = (float)(sum / window);
+ sum += temp[clamped_index((int)y + radius + 1, height) * width + x];
+ sum -= temp[clamped_index((int)y - radius, height) * width + x];
+ }
+ }
+ free(temp);
+ return 1;
+}
+
+static int effects_radius(double base, double scale) {
+ double radius = base * (scale > 0 ? scale : 1);
+ if (radius < 1) radius = 1;
+ if (radius > 64) radius = 64;
+ return (int)lround(radius);
+}
+
+static void effects_dehaze(double *r, double *g, double *b, double amount) {
+ double d = amount / 100.0;
+ double y = rec709(*r, *g, *b);
+ double contrast = 1.0 + 0.8 * d;
+ double pivot = 0.45 - 0.1 * (d > 0 ? d : 0);
+ double y2 = camera_clamp(pivot + (y - 0.45) * contrast);
+ if (d < 0) y2 = camera_clamp(y2 + (-d) * (1.0 - y2) * 0.45);
+ else y2 = camera_clamp(y2 - d * fmax(0.0, 0.4 - y2));
+ scale_luminance(r, g, b, y2);
+ y2 = rec709(*r, *g, *b);
+ double sat = 1.0 + 0.7 * d;
+ *r = camera_clamp(y2 + (*r - y2) * sat);
+ *g = camera_clamp(y2 + (*g - y2) * sat);
+ *b = camera_clamp(y2 + (*b - y2) * sat);
+}
+
+static void effects_vignette(double *r, double *g, double *b, size_t x, size_t y, size_t width, size_t height,
+ double amount, double midpoint, double roundness, double feather, double highlights,
+ int style) {
+ 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 square = fmax(fabs(nx), fabs(ny));
+ double circle = hypot(nx, ny) / sqrt(2.0);
+ double shape = (1.0 - roundness / 100.0) * 0.5;
+ double dist = circle + (square - circle) * shape;
+ double start = (midpoint / 100.0) * 0.85;
+ double soft = feather / 100.0;
+ if (soft < 0.05) soft = 0.05;
+ double t = (dist - start) / soft;
+ t = camera_clamp(t);
+ double mask = t * t * (3.0 - 2.0 * t);
+ 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) {
+ double bright = camera_clamp((rec709(*r, *g, *b) - 0.45) / 0.55);
+ effect *= 1.0 - (highlights / 100.0) * bright;
+ }
+ if (effect < 0) {
+ double factor = 1.0 + effect;
+ *r *= factor; *g *= factor; *b *= factor;
+ } else if (effect > 0) {
+ *r = *r + (1.0 - *r) * effect;
+ *g = *g + (1.0 - *g) * effect;
+ *b = *b + (1.0 - *b) * effect;
+ }
+ if (style == 1 && mask > 0) {
+ double lum = rec709(*r, *g, *b);
+ double sat = 1.0 - 0.75 * mask * fabs(amount / 100.0);
+ *r = camera_clamp(lum + (*r - lum) * sat);
+ *g = camera_clamp(lum + (*g - lum) * sat);
+ *b = camera_clamp(lum + (*b - lum) * sat);
+ }
+}
+
+void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights) {
+ if (!shadows && !highlights) return;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ double r = fmin(1.0, p[0] / alpha);
+ double g = fmin(1.0, p[1] / alpha);
+ double b = fmin(1.0, p[2] / alpha);
+ if (shadows && (r <= 0.5 / 255.0 || g <= 0.5 / 255.0 || b <= 0.5 / 255.0)) {
+ r *= 0.35; g *= 0.35; b = b * 0.35 + 0.65;
+ }
+ if (highlights && (r >= 254.5 / 255.0 || g >= 254.5 / 255.0 || b >= 254.5 / 255.0)) {
+ r = r * 0.35 + 0.65; g *= 0.35; b *= 0.35;
+ }
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+}
+
+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,
+ double vignetteAmount, double vignetteMidpoint, double vignetteRoundness,
+ double vignetteFeather, double vignetteHighlights, int vignetteStyle,
+ double scale) {
+ if (width == 0 || height == 0) return;
+ if (texture == 0 && clarity == 0 && dehaze == 0 && !(glow > 0) && vignetteAmount == 0) return;
+ size_t count = width * height;
+ float *luma = NULL, *fine = NULL, *coarse = NULL, *glowPlane = NULL;
+ int glowRadius = 1;
+ int failed = 0;
+ if (texture != 0 || clarity != 0 || glow > 0) {
+ luma = malloc(count * sizeof(float));
+ if (!luma) return;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) { luma[y * width + x] = 0; continue; }
+ double r = fmin(1.0, p[0] / alpha);
+ double g = fmin(1.0, p[1] / alpha);
+ double b = fmin(1.0, p[2] / alpha);
+ luma[y * width + x] = (float)rec709(r, g, b);
+ }
+ }
+ if (texture != 0) {
+ fine = malloc(count * sizeof(float));
+ if (!fine || !box_blur_plane(luma, fine, width, height, effects_radius(1, scale))) failed = 1;
+ }
+ if (!failed && clarity != 0) {
+ coarse = malloc(count * sizeof(float));
+ if (!coarse || !box_blur_plane(luma, coarse, width, height, effects_radius(4, scale))) failed = 1;
+ }
+ if (!failed && glow > 0) {
+ double spread = glowSpread / 100.0;
+ double base = glowStyle == 1 ? 2.0 : 5.0;
+ double widened = base * (1.0 + spread);
+ if (widened < 1) widened = 1;
+ glowRadius = effects_radius(widened, scale);
+ float threshold = (float)(0.55 + 0.4 * (glowRange / 100.0));
+ glowPlane = malloc(count * sizeof(float));
+ float *source = malloc(count * sizeof(float));
+ if (!glowPlane || !source) failed = 1;
+ else {
+ float denom = 1.0f - threshold;
+ if (denom < 0.05f) denom = 0.05f;
+ for (size_t i = 0; i < count; ++i) {
+ float t = (luma[i] - threshold) / denom;
+ if (t < 0) t = 0;
+ if (t > 1) t = 1;
+ source[i] = t;
+ }
+ failed = !box_blur_plane(source, glowPlane, width, height, glowRadius);
+ }
+ free(source);
+ }
+ }
+ if (failed) {
+ free(luma); free(fine); free(coarse); free(glowPlane);
+ return;
+ }
+ double warmth = glowWarmth / 100.0;
+ double glowRed, glowGreen, glowBlue, glowGain;
+ if (glowStyle == 2) {
+ // Halation's fringe is red. Warmth pushes it further that way, rather than toward yellow or blue.
+ glowRed = 1;
+ glowGreen = 0.35 - 0.3 * warmth;
+ glowBlue = 0.2 - 0.2 * warmth;
+ glowGain = 1;
+ } else {
+ glowRed = 0.75 + 0.25 * warmth;
+ glowGreen = 0.6 + 0.2 * warmth;
+ glowBlue = 0.75 - 0.6 * warmth;
+ glowGain = glowStyle == 1 ? 1.4 : 1;
+ }
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ size_t index = y * width + x;
+ double r = fmin(1.0, p[0] / alpha);
+ double g = fmin(1.0, p[1] / alpha);
+ double b = fmin(1.0, p[2] / alpha);
+ if (fine || coarse) {
+ double tone = rec709(r, g, b);
+ double detail = 0;
+ if (fine) detail += (texture / 100.0) * (tone - fine[index]);
+ if (coarse) detail += (clarity / 100.0) * (tone - coarse[index]);
+ if (detail != 0) scale_luminance(&r, &g, &b, camera_clamp(tone + detail));
+ }
+ if (dehaze != 0) effects_dehaze(&r, &g, &b, dehaze);
+ if (glowPlane && glow > 0) {
+ double add = glowPlane[index] * (glow / 100.0) * glowGain;
+ r = camera_clamp(r + add * glowRed);
+ g = camera_clamp(g + add * glowGreen);
+ b = camera_clamp(b + add * glowBlue);
+ }
+ effects_vignette(&r, &g, &b, x, y, width, height, vignetteAmount, vignetteMidpoint, vignetteRoundness,
+ vignetteFeather, vignetteHighlights, vignetteStyle);
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+ free(luma);
+ free(fine);
+ free(coarse);
+ free(glowPlane);
+}
+
+static double lut_at(const float *lut, double value) {
+ double scaled = camera_clamp(value) * 255.0;
+ int lo = (int)scaled;
+ int hi = lo < 255 ? lo + 1 : 255;
+ double t = scaled - lo;
+ return lut[lo] + (lut[hi] - lut[lo]) * t;
+}
+
+static void rgb_to_hsl(double r, double g, double b, double *h, double *s, double *l) {
+ double maxc = fmax(r, fmax(g, b)), minc = fmin(r, fmin(g, b));
+ *l = (maxc + minc) * 0.5;
+ double d = maxc - minc;
+ if (d < 1e-6) { *h = 0; *s = 0; return; }
+ *s = d / (1.0 - fabs(2.0 * *l - 1.0));
+ if (maxc == r) *h = fmod((g - b) / d, 6.0);
+ else if (maxc == g) *h = (b - r) / d + 2.0;
+ else *h = (r - g) / d + 4.0;
+ *h /= 6.0;
+ if (*h < 0) *h += 1;
+}
+
+static double hue_to_rgb(double p, double q, double t) {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1.0 / 6) return p + (q - p) * 6 * t;
+ if (t < 0.5) return q;
+ if (t < 2.0 / 3) return p + (q - p) * (2.0 / 3 - t) * 6;
+ return p;
+}
+
+static void hsl_to_rgb(double h, double s, double l, double *r, double *g, double *b) {
+ if (s <= 1e-6) { *r = *g = *b = l; return; }
+ double q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ double p = 2 * l - q;
+ *r = hue_to_rgb(p, q, h + 1.0 / 3);
+ *g = hue_to_rgb(p, q, h);
+ *b = hue_to_rgb(p, q, h - 1.0 / 3);
+}
+
+static double circular_distance(double a, double b) {
+ double d = fabs(a - b);
+ return d > 0.5 ? 1 - d : d;
+}
+
+static const double mixer_centers[8] = {0, 30.0 / 360, 60.0 / 360, 120.0 / 360, 180.0 / 360, 240.0 / 360, 270.0 / 360, 300.0 / 360};
+
+static double point_weight(double h, double s, double l, const float *point) {
+ double hueHalf = point[6] > 0.01f ? point[6] : 0.01f;
+ double satHalf = point[7] > 0.01f ? point[7] : 0.01f;
+ double lumHalf = point[8] > 0.01f ? point[8] : 0.01f;
+ double hueW = 1 - circular_distance(h, point[0]) / hueHalf;
+ double satW = 1 - fabs(s - point[1]) / satHalf;
+ double lumW = 1 - fabs(l - point[2]) / lumHalf;
+ if (hueW < 0 || satW < 0 || lumW < 0) return 0;
+ return hueW * satW * lumW;
+}
+
+void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ const float *lumaLut, 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) {
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ 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);
+ double tone = rec709(r, g, b);
+ double mapped = lut_at(lumaLut, tone);
+ scale_luminance(&r, &g, &b, mapped);
+ if (refineSaturation != 0 && tone > 1e-4) {
+ double factor = 1 + refineSaturation * (mapped / tone - 1);
+ double lum = rec709(r, g, b);
+ r = camera_clamp(lum + (r - lum) * factor);
+ g = camera_clamp(lum + (g - lum) * factor);
+ b = camera_clamp(lum + (b - lum) * factor);
+ }
+ r = lut_at(redLut, r); g = lut_at(greenLut, g); b = lut_at(blueLut, b);
+ double h, s, l;
+ rgb_to_hsl(r, g, b, &h, &s, &l);
+ double sourceHue = h, sourceSat = s, sourceLum = l;
+ double hueDelta = 0, satDelta = 0, lumDelta = 0, weightSum = 0;
+ for (int i = 0; i < 8; ++i) {
+ double dist = circular_distance(h, mixer_centers[i]);
+ double w = 1 - dist / (40.0 / 360);
+ if (w <= 0) continue;
+ hueDelta += mixer[i] * w * (30.0 / 360);
+ satDelta += mixer[8 + i] * w;
+ lumDelta += mixer[16 + i] * w * 0.25;
+ weightSum += w;
+ }
+ if (weightSum > 1) { hueDelta /= weightSum; satDelta /= weightSum; lumDelta /= weightSum; }
+ h += hueDelta; if (h < 0) h += 1; if (h >= 1) h -= 1;
+ s = camera_clamp(s * (1 + satDelta));
+ l = camera_clamp(l + lumDelta);
+ for (int i = 0; i < pointCount; ++i) {
+ const float *point = points + i * 9;
+ double w = point_weight(h, s, l, point);
+ if (w <= 0) continue;
+ h += point[3] * w * (30.0 / 360);
+ s = camera_clamp(s * (1 + point[4] * w));
+ l = camera_clamp(l + point[5] * w * 0.25);
+ }
+ if (h < 0) h += 1; if (h >= 1) h -= 1;
+ hsl_to_rgb(h, s, l, &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.
+ double split = 0.5 - balance * 0.2;
+ double reach = 0.12 + blending * 0.38;
+ double shadowW = camera_clamp((split + reach - rec709(r, g, b)) / fmax(0.05, reach * 2));
+ double highlightW = camera_clamp((rec709(r, g, b) - (split - reach)) / fmax(0.05, reach * 2));
+ double midW = camera_clamp(1 - fabs(rec709(r, g, b) - split) / (0.35 + reach));
+ double sum = shadowW + midW + highlightW;
+ if (sum > 1e-4) { shadowW /= sum; midW /= sum; highlightW /= sum; }
+ double weights[4] = {shadowW, midW, highlightW, 1};
+ for (int wheel = 0; wheel < 4; ++wheel) {
+ double wh = grade[wheel * 3], ws = grade[wheel * 3 + 1], wl = grade[wheel * 3 + 2];
+ double w = weights[wheel];
+ if (w <= 0 || (ws <= 0 && wl == 0)) continue;
+ double cr, cg, cb;
+ hsl_to_rgb(wh, 1, 0.5, &cr, &cg, &cb);
+ r = camera_clamp(r + (cr - 0.5) * ws * w * 0.85);
+ g = camera_clamp(g + (cg - 0.5) * ws * w * 0.85);
+ b = camera_clamp(b + (cb - 0.5) * ws * w * 0.85);
+ if (wl != 0) scale_luminance(&r, &g, &b, camera_clamp(rec709(r, g, b) + wl * 0.25 * w));
+ }
+ if (visualize >= 0 && visualize < pointCount && point_weight(sourceHue, sourceSat, sourceLum, points + visualize * 9) <= 0.05) {
+ r *= 0.35; g *= 0.35; b *= 0.35;
+ }
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+}
+
+static double detail_radius(double slider, double scale) {
+ double base = 0.5 + (slider / 100.0) * 2.5;
+ double radius = base * (scale > 0 ? scale : 1);
+ if (radius < 0.5) radius = 0.5;
+ if (radius > 64) radius = 64;
+ return radius;
+}
+
+static double pixel_hue_deg(double r, double g, double b) {
+ double maxc = fmax(r, fmax(g, b)), minc = fmin(r, fmin(g, b));
+ double chroma = maxc - minc;
+ if (chroma < 1e-6) return 0;
+ double hue;
+ if (maxc == r) hue = fmod((g - b) / chroma, 6.0);
+ else if (maxc == g) hue = (b - r) / chroma + 2.0;
+ else hue = (r - g) / chroma + 4.0;
+ hue = hue * 60.0;
+ if (hue < 0) hue += 360.0;
+ return hue;
+}
+
+static int hue_in_range(double hue, double low, double high) {
+ if (low <= high) return hue >= low && hue <= high;
+ return hue >= low || hue <= high;
+}
+
+static float sharpen_edge_at(const float *luma, size_t width, size_t height, size_t x, size_t y, int radius) {
+ if (radius < 1) radius = 1;
+ float center = luma[y * width + x];
+ float sum = 0;
+ int count = 0;
+ for (int dy = -radius; dy <= radius; dy += radius) {
+ for (int dx = -radius; dx <= radius; dx += radius) {
+ if (dx == 0 && dy == 0) continue;
+ long sx = (long)x + dx, sy = (long)y + dy;
+ if (sx < 0 || sy < 0 || (size_t)sx >= width || (size_t)sy >= height) continue;
+ sum += fabsf(luma[sy * width + sx] - center);
+ count++;
+ }
+ }
+ return count ? sum / count : 0;
+}
+
+void adjust_camera_raw_sharpen_mask_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double sharpenRadius, double sharpenDetail, double sharpenMasking, double scale) {
+ if (width == 0 || height == 0) return;
+ size_t count = width * height;
+ float *luma = malloc(count * sizeof(float));
+ if (!luma) return;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) { luma[y * width + x] = 0; continue; }
+ double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
+ luma[y * width + x] = (float)rec709(r, g, b);
+ }
+ }
+ int radius = effects_radius(detail_radius(sharpenRadius, scale), 1);
+ double threshold = (sharpenMasking / 100.0) * 0.35;
+ double detailBoost = 0.5 + sharpenDetail / 100.0;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ float edge = sharpen_edge_at(luma, width, height, x, y, radius);
+ double mask = camera_clamp((edge * detailBoost - threshold) / fmax(0.04, 0.35 - threshold * 0.5));
+ uint8_t gray = (uint8_t)lround(mask * alpha);
+ p[0] = p[1] = p[2] = gray;
+ }
+ }
+ free(luma);
+}
+
+void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking,
+ double noiseLuminance, double noiseLuminanceDetail, double noiseLuminanceContrast,
+ double noiseColor, double noiseColorDetail, double noiseColorSmoothness, double scale) {
+ if (width == 0 || height == 0) return;
+ if (sharpenAmount == 0 && noiseLuminance == 0 && noiseColor == 0) return;
+ size_t count = width * height;
+ float *luma = malloc(count * sizeof(float));
+ float *work = malloc(count * sizeof(float));
+ if (!luma || !work) { free(luma); free(work); return; }
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) { luma[y * width + x] = 0; continue; }
+ double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
+ luma[y * width + x] = (float)rec709(r, g, b);
+ }
+ }
+ if (noiseLuminance > 0) {
+ int radius = effects_radius(1.0 + noiseLuminance / 50.0, scale);
+ if (!box_blur_plane(luma, work, width, height, radius)) { free(luma); free(work); return; }
+ double strength = noiseLuminance / 100.0;
+ double preserve = noiseLuminanceDetail / 100.0;
+ double contrast = noiseLuminanceContrast / 100.0;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ size_t index = y * width + x;
+ float edge = sharpen_edge_at(luma, width, height, x, y, 1);
+ double local = strength * (1.0 - preserve * fmin(1.0, edge * 6.0));
+ float blurred = work[index];
+ float target = (float)(luma[index] * (1.0 - local) + blurred * local);
+ if (contrast != 0) target = (float)(target + contrast * 0.25 * (luma[index] - blurred));
+ luma[index] = target;
+ double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
+ scale_luminance(&r, &g, &b, target);
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+ }
+ if (noiseColor > 0) {
+ int radius = effects_radius(1.0 + noiseColorSmoothness / 40.0, scale);
+ float *chroma = malloc(count * sizeof(float));
+ float *chromaBlur = malloc(count * sizeof(float));
+ if (!chroma || !chromaBlur) { free(chroma); free(chromaBlur); free(luma); free(work); return; }
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ 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);
+ double h, s, l;
+ rgb_to_hsl(r, g, b, &h, &s, &l);
+ chroma[y * width + x] = (float)s;
+ }
+ }
+ if (!box_blur_plane(chroma, chromaBlur, width, height, radius)) {
+ free(chroma); free(chromaBlur); free(luma); free(work); return;
+ }
+ double strength = noiseColor / 100.0;
+ double preserve = noiseColorDetail / 100.0;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ size_t index = y * width + x;
+ float edge = fabsf(chroma[index] - chromaBlur[index]);
+ double local = strength * (1.0 - preserve * fmin(1.0, edge * 4.0));
+ float sat = chroma[index] * (float)(1.0 - local) + chromaBlur[index] * (float)local;
+ double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
+ double h, s, l;
+ rgb_to_hsl(r, g, b, &h, &s, &l);
+ s = sat;
+ hsl_to_rgb(h, s, l, &r, &g, &b);
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+ free(chroma);
+ free(chromaBlur);
+ }
+ if (sharpenAmount > 0) {
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ 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);
+ luma[y * width + x] = (float)rec709(r, g, b);
+ }
+ }
+ int radius = effects_radius(detail_radius(sharpenRadius, scale), 1);
+ if (!box_blur_plane(luma, work, width, height, radius)) { free(luma); free(work); return; }
+ double amount = sharpenAmount / 100.0;
+ double detailMix = sharpenDetail / 100.0;
+ double threshold = (sharpenMasking / 100.0) * 0.35;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ size_t index = y * width + x;
+ float edge = sharpen_edge_at(luma, width, height, x, y, radius);
+ double mask = camera_clamp((edge * (0.5 + detailMix) - threshold) / fmax(0.04, 0.35 - threshold * 0.5));
+ double high = luma[index] - work[index];
+ double sharpened = camera_clamp(luma[index] + high * amount * mask * (0.5 + detailMix));
+ double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha);
+ scale_luminance(&r, &g, &b, sharpened);
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+ }
+ free(luma);
+ free(work);
+}
+
+static void optics_defringe(double *r, double *g, double *b, double purpleAmount, double purpleLow, double purpleHigh,
+ double greenAmount, double greenLow, double greenHigh) {
+ double hue = pixel_hue_deg(*r, *g, *b);
+ double maxc = fmax(*r, fmax(*g, *b)), minc = fmin(*r, fmin(*g, *b));
+ double chroma = maxc - minc;
+ if (chroma < 1e-6) return;
+ double sat = chroma / maxc;
+ double reduce = 0;
+ if (purpleAmount > 0 && hue_in_range(hue, purpleLow, purpleHigh)) reduce = fmax(reduce, purpleAmount / 100.0);
+ if (greenAmount > 0 && hue_in_range(hue, greenLow, greenHigh)) reduce = fmax(reduce, greenAmount / 100.0);
+ if (reduce <= 0) return;
+ double lum = rec709(*r, *g, *b);
+ double factor = 1.0 - reduce * sat;
+ *r = camera_clamp(lum + (*r - lum) * factor);
+ *g = camera_clamp(lum + (*g - lum) * factor);
+ *b = camera_clamp(lum + (*b - lum) * factor);
+}
+
+static void optics_chromatic(uint8_t *rgba, size_t width, size_t height, size_t stride, double strength) {
+ if (strength <= 0) return;
+ uint8_t *copy = malloc(height * stride);
+ if (!copy) return;
+ for (size_t y = 0; y < height; ++y) memcpy(copy + y * stride, rgba + y * stride, width * 4);
+ double cx = width * 0.5, cy = height * 0.5;
+ double maxR = hypot(cx, cy);
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ const uint8_t *srcRow = copy + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ if (!alpha) continue;
+ double dx = x + 0.5 - cx, dy = y + 0.5 - cy;
+ double radial = hypot(dx, dy) / maxR;
+ double shift = strength * radial * radial * 2.5;
+ long rx = (long)lround(x - shift), bx = (long)lround(x + shift);
+ const uint8_t *pr = srcRow + clamped_index(rx, width) * 4;
+ const uint8_t *pb = srcRow + clamped_index(bx, width) * 4;
+ double g = fmin(1.0, srcRow[x * 4 + 1] / alpha);
+ double r = fmin(1.0, pr[0] / fmax(1.0, pr[3]));
+ double b = fmin(1.0, pb[2] / fmax(1.0, pb[3]));
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+ free(copy);
+}
+
+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;
+ }
+}
+
+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) {
+ if (width == 0 || height == 0) return;
+ double profileVignette = lensProfile ? profileVignetting / 100.0 : 0;
+ double vignette = vignetteAmount + profileVignette * 35.0;
+ if (distortionK != 0) {
+ size_t bytes = height * stride;
+ uint8_t *copy = malloc(bytes);
+ if (!copy) return;
+ memcpy(copy, rgba, bytes);
+ lens_distort(copy, rgba, width, height, stride, distortionK);
+ free(copy);
+ }
+ if (removeChromatic) optics_chromatic(rgba, width, height, stride, 0.45);
+ if (purpleAmount == 0 && greenAmount == 0 && vignette == 0) return;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ 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);
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+}
+
+void adjust_camera_raw_calibration(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double shadowTint, double redHue, double redSaturation,
+ double greenHue, double greenSaturation, double blueHue, double blueSaturation,
+ int processVersion) {
+ if (width == 0 || height == 0) return;
+ double versionScale = processVersion <= 1 ? 0.55 : processVersion == 2 ? 0.65 : processVersion == 3 ? 0.75
+ : processVersion == 4 ? 0.85 : processVersion == 5 ? 0.92 : 1.0;
+ double tint = shadowTint / 100.0 * versionScale;
+ double rh = redHue / 100.0 * (15.0 / 360.0) * versionScale;
+ double rs = redSaturation / 100.0 * 0.45 * versionScale;
+ double gh = greenHue / 100.0 * (15.0 / 360.0) * versionScale;
+ double gs = greenSaturation / 100.0 * 0.45 * versionScale;
+ double bh = blueHue / 100.0 * (15.0 / 360.0) * versionScale;
+ double bs = blueSaturation / 100.0 * 0.45 * versionScale;
+ for (size_t y = 0; y < height; ++y) {
+ uint8_t *row = rgba + y * stride;
+ for (size_t x = 0; x < width; ++x) {
+ uint8_t *p = row + x * 4;
+ double alpha = p[3];
+ 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);
+ double h, s, l;
+ rgb_to_hsl(r, g, b, &h, &s, &l);
+ if (l < 0.35 && tint != 0) {
+ h += tint * 0.06;
+ if (h < 0) h += 1;
+ if (h >= 1) h -= 1;
+ }
+ double maxc = fmax(r, fmax(g, b)), minc = fmin(r, fmin(g, b));
+ if (maxc - minc > 1e-5) {
+ if (r >= g && r >= b) { h += rh; s = camera_clamp(s * (1 + rs)); }
+ else if (g >= r && g >= b) { h += gh; s = camera_clamp(s * (1 + gs)); }
+ else { h += bh; s = camera_clamp(s * (1 + bs)); }
+ if (h < 0) h += 1;
+ if (h >= 1) h -= 1;
+ }
+ hsl_to_rgb(h, s, l, &r, &g, &b);
+ write_premultiplied(p, r, g, b, alpha);
+ }
+ }
+}
diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h
index d02049f..fc73d75 100644
--- a/kernels/AdjustPixels.h
+++ b/kernels/AdjustPixels.h
@@ -8,7 +8,7 @@
void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table);
// Film grain on premultiplied RGBA pixels: the same brightness change on all three channels, strongest
// in the midtones. `amount` is 0–100, `size` the grain's scale in document units, and `roughness`
-// (0–100) mixes in per-unit noise. Pixel (x, y) sits at (originX + (x + 0.5) × unitsPerPixel,
+// (0–100) adds smaller irregular particles whose scale remains relative to `size`. Pixel (x, y) sits at (originX + (x + 0.5) × unitsPerPixel,
// originY + (y + 0.5) × unitsPerPixel), and its grain depends only on that position and `seed`, so a
// piece of an image gets the same grain as that part of the whole.
void adjust_grain(uint8_t *rgba, size_t width, size_t height, size_t stride, double amount, double size,
@@ -30,4 +30,55 @@ 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);
+// Camera Raw's Light and Color groups on premultiplied RGBA pixels, in this order: white balance
+// (the three channel gains), exposure in stops of linear light, contrast about mid gray, highlights,
+// shadows, whites, blacks, vibrance, then saturation. Temperature and tint are relative, so the gains
+// are computed by the caller. Amounts are Camera Raw's own ranges (exposure −5…5, the rest −100…100).
+// `clipping` 0 renders the grade; 1 replaces it with a highlight-clip view (clipped channels lit on
+// black); 2 replaces it with a shadow-clip view (clipped channels dark on white). Alpha is kept.
+void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double redGain, double greenGain, double blueGain, double exposure, double contrast,
+ double highlights, double shadows, double whites, double blacks,
+ double vibrance, double saturation, int clipping);
+// Camera Raw Effects after Light and Color. Texture is a fine local contrast, Clarity a broader one.
+// Dehaze raises contrast and saturation when positive and lifts the shadows when negative. Glow, its
+// range, spread and warmth do nothing until `glow` is above zero: styles are 0 diffusion, 1 bloom,
+// 2 halation. Vignette styles are 0 highlight priority, 1 color priority, 2 paint overlay; Highlights
+// protects bright pixels only while the amount darkens. `scale` is preview pixels per layer pixel, so
+// the radii match a full-size render. Grain is applied separately. Alpha is kept.
+// Blue over clipped shadows and red over clipped highlights, on top of the grade. Preview only.
+void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride, int shadows, int highlights);
+// Curve, Color Mixer, and Color Grading after the basic grade. `lumaLut` and the channel LUTs are 256
+// 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.
+void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ const float *lumaLut, 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);
+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,
+ double vignetteAmount, double vignetteMidpoint, double vignetteRoundness,
+ double vignetteFeather, double vignetteHighlights, int vignetteStyle,
+ double scale);
+// Manual noise reduction, then sharpening. `scale` maps radius to preview pixels. Applied after the creative grade.
+void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking,
+ double noiseLuminance, double noiseLuminanceDetail, double noiseLuminanceContrast,
+ double noiseColor, double noiseColorDetail, double noiseColorSmoothness, double scale);
+// Preview only: white where sharpening would land, black where masking protects. Uses the current sharpen sliders.
+void adjust_camera_raw_sharpen_mask_overlay(uint8_t *rgba, size_t width, size_t height, size_t stride,
+ double sharpenRadius, double sharpenDetail, double sharpenMasking, double scale);
+// Chromatic aberration, lens distortion, defringe, and lens-vignetting correction. `distortionK` matches `lens_distort`.
+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);
+// 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,
+ double greenHue, double greenSaturation, double blueHue, double blueSaturation,
+ int processVersion);
#endif
diff --git a/src/Document/CameraRaw+Session.cpp b/src/Document/CameraRaw+Session.cpp
new file mode 100644
index 0000000..3246c6d
--- /dev/null
+++ b/src/Document/CameraRaw+Session.cpp
@@ -0,0 +1,306 @@
+#include "Document/BrushStroke.h"
+#include "Document/EditorSession.h"
+#include "IO/ImageExporter.h"
+#include "Logging.h"
+#include
+#include
+#include
+#include
+
+// Swift's CameraRaw.swift session extension: samples, drags, guides.
+namespace {
+// One pixel's premultiplied bytes, or none past the image.
+std::optional> pixelAt(const QImage &image, QPointF pixel)
+{
+ if (!(pixel.x() >= 0 && pixel.y() >= 0 && pixel.x() < image.width() && pixel.y() < image.height()))
+ return std::nullopt;
+ QImage one = BrushRaster::context(1, 1, false);
+ {
+ QPainter painter(&one);
+ BrushRaster::draw(image, QRectF(0, 0, 1, 1), painter, QRectF(std::floor(pixel.x()), std::floor(pixel.y()), 1, 1));
+ }
+ const uchar *bytes = one.constBits();
+ return std::array{bytes[0], bytes[1], bytes[2], bytes[3]};
+}
+
+std::array straight(const std::array &bytes)
+{
+ return {std::min(1.0, bytes[0] / double(bytes[3])), std::min(1.0, bytes[1] / double(bytes[3])), std::min(1.0, bytes[2] / double(bytes[3]))};
+}
+
+double hueDegrees(double red, double green, double blue)
+{
+ const double high = std::max({red, green, blue}), low = std::min({red, green, blue}), chroma = high - low;
+ if (!(chroma > 1e-6))
+ return 0;
+ const double hue = high == red ? (green - blue) / chroma : high == green ? 2 + (blue - red) / chroma : 4 + (red - green) / chroma;
+ const double degrees = hue * 60;
+ return degrees < 0 ? degrees + 360 : degrees;
+}
+}
+
+void EditorSession::setCameraRawPanel(const CameraRawPanel &panel)
+{
+ if (!m_filterEdit)
+ return;
+ m_filterEdit->rawPanel = panel;
+ notify();
+}
+
+void EditorSession::sampleCameraRawWhiteBalance(QPointF point)
+{
+ if (!m_filterEdit || m_filterEdit->kind != FilterKind::cameraRaw || !m_filterEdit->rawPanel.samplesWhiteBalance || m_filterEdit->committing
+ || !m_document || !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->size().width() && point.y() < m_document->size().height()))
+ return;
+ const FilterEdit &edit = *m_filterEdit;
+ try {
+ const std::optional> bytes = pixelAt(edit.original.image(), edit.mapping.inverted().map(point));
+ if (!bytes || (*bytes)[3] == 0)
+ return;
+ const std::array rgb = straight(*bytes);
+ const std::optional solved = CameraRawSettings::neutralizeStraight(rgb[0], rgb[1], rgb[2]);
+ if (!solved)
+ return;
+ FilterSettings settings = edit.settings;
+ settings.cameraRaw.temperature = solved->temperature;
+ settings.cameraRaw.tint = solved->tint;
+ settings.cameraRaw.whiteBalance = CameraRawWhiteBalance::custom;
+ updateFilter(settings, edit.preview);
+ } catch (const ExportError &error) {
+ setBrushError(QString::fromUtf8(error.what()));
+ }
+}
+
+void EditorSession::applyCameraRawAutoWhiteBalance(std::function done)
+{
+ const auto finish = [this, done] {
+ if (done)
+ QMetaObject::invokeMethod(this, done, Qt::QueuedConnection);
+ };
+ if (!m_filterEdit || m_filterEdit->kind != FilterKind::cameraRaw || m_filterEdit->committing) {
+ finish();
+ return;
+ }
+ FilterSettings settings = m_filterEdit->settings;
+ settings.cameraRaw.whiteBalance = CameraRawWhiteBalance::automatic;
+ updateFilter(settings, m_filterEdit->preview);
+ // A worker takes the average; it lands if Auto stands.
+ auto *watcher = new QFutureWatcher>(this);
+ connect(watcher, &QFutureWatcherBase::finished, this, [this, watcher, id = m_filterEdit->id, finish] {
+ watcher->deleteLater();
+ const std::optional solved = watcher->result();
+ if (solved && m_filterEdit && m_filterEdit->id == id && !m_filterEdit->committing
+ && m_filterEdit->settings.cameraRaw.whiteBalance == CameraRawWhiteBalance::automatic) {
+ FilterSettings value = m_filterEdit->settings;
+ value.cameraRaw.temperature = solved->temperature;
+ value.cameraRaw.tint = solved->tint;
+ updateFilter(value, m_filterEdit->preview);
+ }
+ finish();
+ });
+ // A painted layer flattens here; a failure scans nothing.
+ watcher->setFuture(QtConcurrent::run([original = m_filterEdit->original]() -> std::optional {
+ try {
+ return CameraRawSettings::autoBalance(original.image());
+ } catch (const ExportError &error) {
+ qCWarning(lcApp) << "Camera Raw's Auto found no memory:" << error.what();
+ return std::nullopt;
+ }
+ }));
+}
+
+void EditorSession::sampleCameraRawDefringe(QPointF point)
+{
+ if (!m_filterEdit || m_filterEdit->kind != FilterKind::cameraRaw || !m_filterEdit->rawPanel.samplesDefringe || m_filterEdit->committing
+ || !m_document || !(point.x() >= 0 && point.y() >= 0 && point.x() < m_document->size().width() && point.y() < m_document->size().height()))
+ return;
+ const FilterEdit &edit = *m_filterEdit;
+ try {
+ const std::optional> bytes = pixelAt(edit.original.image(), edit.mapping.inverted().map(point));
+ if (!bytes || (*bytes)[3] == 0)
+ return;
+ const std::array rgb = straight(*bytes);
+ const double hue = hueDegrees(rgb[0], rgb[1], rgb[2]);
+ FilterSettings settings = edit.settings;
+ CameraRawOpticsSettings &optics = settings.cameraRaw.optics;
+ const double purpleCenter = 290, greenCenter = 90, span = 25;
+ if (std::abs(hue - purpleCenter) < std::abs(hue - greenCenter)) {
+ optics.purpleHueLow = hue - span;
+ optics.purpleHueHigh = hue + span;
+ if (optics.purpleAmount == 0)
+ optics.purpleAmount = 50;
+ } else {
+ optics.greenHueLow = hue - span;
+ optics.greenHueHigh = hue + span;
+ if (optics.greenAmount == 0)
+ optics.greenAmount = 50;
+ }
+ updateFilter(settings, edit.preview);
+ } catch (const ExportError &error) {
+ setBrushError(QString::fromUtf8(error.what()));
+ }
+}
+
+void EditorSession::updateCameraRawReadout(QPointF point)
+{
+ if (!m_filterEdit || m_filterEdit->kind != FilterKind::cameraRaw)
+ return;
+ FilterEdit &edit = *m_filterEdit;
+ const QImage &image = edit.preparedPreview ? *edit.preparedPreview : edit.previewSource;
+ std::optional> readout;
+ try {
+ const std::optional> bytes = pixelAt(image, edit.previewMapping.inverted().map(point));
+ if (bytes && (*bytes)[3] > 0) {
+ const int alpha = (*bytes)[3];
+ readout = std::array{};
+ for (size_t channel = 0; channel < 3; ++channel)
+ (*readout)[channel] = std::min(255, ((*bytes)[channel] * 255 + alpha / 2) / alpha);
+ }
+ } catch (const ExportError &error) {
+ // Swift's `try?`: no memory keeps the last readout.
+ qCWarning(lcApp) << "Camera Raw's readout found no memory:" << error.what();
+ return;
+ }
+ if (edit.rawPanel.readout == readout)
+ return;
+ edit.rawPanel.readout = readout;
+ notify();
+}
+
+void EditorSession::beginCameraRawDrag(QPointF point)
+{
+ if (!m_filterEdit || m_filterEdit->kind != FilterKind::cameraRaw)
+ return;
+ const std::optional sample = cameraRawSample(point);
+ if (!sample)
+ return;
+ m_filterEdit->rawPanel.drag = CameraRawDrag{point.y(), m_filterEdit->settings.cameraRaw, sample->tone, sample->hue};
+ notify();
+}
+
+void EditorSession::dragCameraRaw(QPointF point)
+{
+ if (!m_filterEdit || !m_filterEdit->rawPanel.drag)
+ return;
+ const FilterEdit &edit = *m_filterEdit;
+ const CameraRawDrag &drag = *edit.rawPanel.drag;
+ const double delta = (drag.startY - point.y()) * 0.35;
+ FilterSettings settings = edit.settings;
+ settings.cameraRaw = drag.settings;
+ const CameraRawPanel &panel = edit.rawPanel;
+ if (panel.targetsCurve) {
+ if (panel.curvePage == CameraRawCurvePage::parametric) {
+ CameraRawCurveSettings before = drag.settings.curve;
+ settings.cameraRaw.curve.region(drag.tone) = std::min(100.0, std::max(-100.0, before.region(drag.tone) + delta));
+ } else {
+ settings.cameraRaw.curve = drag.settings.curve.nudged(panel.pointChannel, drag.tone, delta / 100);
+ }
+ } else if (panel.targetsMixer) {
+ const std::array weights = CameraRawMixerSettings::weights(drag.hue);
+ std::array &target = panel.mixerTab == CameraRawMixerTab::hue ? settings.cameraRaw.mixer.hue
+ : panel.mixerTab == CameraRawMixerTab::saturation ? settings.cameraRaw.mixer.saturation
+ : settings.cameraRaw.mixer.luminance;
+ const std::array &from = panel.mixerTab == CameraRawMixerTab::hue ? drag.settings.mixer.hue
+ : panel.mixerTab == CameraRawMixerTab::saturation ? drag.settings.mixer.saturation
+ : drag.settings.mixer.luminance;
+ for (size_t index = 0; index < 8; ++index)
+ if (weights[index] > 0)
+ target[index] = std::min(100.0, std::max(-100.0, from[index] + delta * weights[index]));
+ }
+ updateFilter(settings, edit.preview);
+}
+
+void EditorSession::sampleCameraRawPointColor(QPointF point)
+{
+ if (!m_filterEdit || !m_filterEdit->rawPanel.samplesPointColor)
+ return;
+ const std::optional sample = cameraRawSample(point);
+ if (!sample)
+ return;
+ FilterEdit &edit = *m_filterEdit;
+ FilterSettings settings = edit.settings;
+ std::vector &points = settings.cameraRaw.mixer.points;
+ CameraRawPointColor color{.hue = sample->hue, .saturation = sample->saturation, .luminance = sample->luminance};
+ const int index = edit.rawPanel.pointIndex;
+ if (index >= 0 && size_t(index) < points.size()) {
+ color.hueShift = points[size_t(index)].hueShift;
+ color.saturationShift = points[size_t(index)].saturationShift;
+ color.luminanceShift = points[size_t(index)].luminanceShift;
+ points[size_t(index)] = color;
+ } else if (points.size() < 8) {
+ points.push_back(color);
+ edit.rawPanel.pointIndex = int(points.size()) - 1;
+ }
+ updateFilter(settings, edit.preview);
+}
+
+void EditorSession::beginCameraRawGeometryGuide(QPointF point)
+{
+ if (!m_filterEdit || m_filterEdit->kind != FilterKind::cameraRaw || !m_filterEdit->rawPanel.drawingGeometryGuide)
+ return;
+ const std::optional normalized = cameraRawNormalizedPoint(point);
+ if (!normalized)
+ return;
+ m_filterEdit->rawPanel.guideDraft = std::pair(*normalized, *normalized);
+ notify();
+}
+
+void EditorSession::continueCameraRawGeometryGuide(QPointF point)
+{
+ if (!m_filterEdit || !m_filterEdit->rawPanel.drawingGeometryGuide || !m_filterEdit->rawPanel.guideDraft)
+ return;
+ const std::optional normalized = cameraRawNormalizedPoint(point);
+ if (!normalized)
+ return;
+ m_filterEdit->rawPanel.guideDraft->second = *normalized;
+ notify();
+}
+
+void EditorSession::commitCameraRawGeometryGuide()
+{
+ if (!m_filterEdit || !m_filterEdit->rawPanel.guideDraft)
+ return;
+ FilterEdit &edit = *m_filterEdit;
+ const auto [start, end] = *std::exchange(edit.rawPanel.guideDraft, std::nullopt);
+ FilterSettings settings = edit.settings;
+ settings.cameraRaw.geometry.guides.push_back({start.x(), start.y(), end.x(), end.y()});
+ settings.cameraRaw.geometry.upright = CameraRawUprightMode::guided;
+ updateFilter(settings, edit.preview);
+}
+
+std::optional EditorSession::cameraRawNormalizedPoint(QPointF point) const
+{
+ if (!m_filterEdit)
+ return std::nullopt;
+ const QImage &image = m_filterEdit->previewSource;
+ const QPointF pixel = m_filterEdit->previewMapping.inverted().map(point);
+ if (!(pixel.x() >= 0 && pixel.y() >= 0 && pixel.x() < image.width() && pixel.y() < image.height()))
+ return std::nullopt;
+ return QPointF(pixel.x() / image.width(), pixel.y() / image.height());
+}
+
+std::optional EditorSession::cameraRawSample(QPointF point) const
+{
+ if (!m_filterEdit)
+ return std::nullopt;
+ const QImage &image = m_filterEdit->preparedPreview ? *m_filterEdit->preparedPreview : m_filterEdit->previewSource;
+ std::optional> bytes;
+ try {
+ bytes = pixelAt(image, m_filterEdit->previewMapping.inverted().map(point));
+ } catch (const ExportError &error) {
+ // Swift's `try?`: no memory, no sample.
+ qCWarning(lcApp) << "Camera Raw's sample found no memory:" << error.what();
+ return std::nullopt;
+ }
+ if (!bytes || (*bytes)[3] == 0)
+ return std::nullopt;
+ const auto [red, green, blue] = straight(*bytes);
+ const double high = std::max({red, green, blue}), low = std::min({red, green, blue}), chroma = high - low;
+ double hue = 0;
+ if (chroma > 1e-6) {
+ hue = (high == red ? (green - blue) / chroma : high == green ? 2 + (blue - red) / chroma : 4 + (red - green) / chroma) / 6;
+ if (hue < 0)
+ hue += 1;
+ }
+ return CameraRawSample{0.2126 * red + 0.7152 * green + 0.0722 * blue, hue * 360, high == 0 ? 0 : chroma / high, (high + low) / 2};
+}
diff --git a/src/Document/CameraRaw+Session.h b/src/Document/CameraRaw+Session.h
new file mode 100644
index 0000000..59e2ab9
--- /dev/null
+++ b/src/Document/CameraRaw+Session.h
@@ -0,0 +1,28 @@
+// Swift's CameraRaw.swift session extension; in EditorSession's body.
+public:
+ // The panel's state, which Swift's panel writes on the edit.
+ void setCameraRawPanel(const CameraRawPanel &panel);
+ // The white-balance eyedropper: the clicked pixel turns neutral.
+ void sampleCameraRawWhiteBalance(QPointF point);
+ // White Balance Auto: the mode now, the sliders later.
+ void applyCameraRawAutoWhiteBalance(std::function done = {});
+ // The defringe eyedropper centres a range on the fringe.
+ void sampleCameraRawDefringe(QPointF point);
+ // The adjusted preview's RGB under the pointer.
+ void updateCameraRawReadout(QPointF point);
+ void beginCameraRawDrag(QPointF point);
+ void dragCameraRaw(QPointF point);
+ void sampleCameraRawPointColor(QPointF point);
+ void beginCameraRawGeometryGuide(QPointF point);
+ void continueCameraRawGeometryGuide(QPointF point);
+ void commitCameraRawGeometryGuide();
+
+private:
+ std::optional cameraRawNormalizedPoint(QPointF point) const;
+ struct CameraRawSample {
+ double tone;
+ double hue;
+ double saturation;
+ double luminance;
+ };
+ std::optional cameraRawSample(QPointF point) const;
diff --git a/src/Document/CameraRaw.cpp b/src/Document/CameraRaw.cpp
new file mode 100644
index 0000000..2ddcdab
--- /dev/null
+++ b/src/Document/CameraRaw.cpp
@@ -0,0 +1,276 @@
+#include "Document/CameraRaw.h"
+#include "Document/BrushStroke.h"
+#include "Document/Filters.h"
+#include "Document/Levels.h"
+#include
+#include
+#include
+#include
+extern "C" {
+#include "AdjustPixels.h"
+#include "LevelsPixels.h"
+}
+
+using ImageAdjustmentPixels::clamp;
+
+namespace {
+double decode(double encoded)
+{
+ return encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4);
+}
+
+// The image drawn into the kernels' premultiplied context.
+QImage drawn(const QImage &image)
+{
+ QImage context = BrushRaster::context(image.width(), image.height(), false);
+ QPainter painter(&context);
+ BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter);
+ return context;
+}
+}
+
+bool CameraRawSettings::adjustsLight() const
+{
+ return exposure != 0 || contrast != 0 || highlights != 0 || shadows != 0 || whites != 0 || blacks != 0;
+}
+
+bool CameraRawSettings::adjustsColor() const
+{
+ return temperature != 0 || tint != 0 || vibrance != 0 || saturation != 0;
+}
+
+bool CameraRawSettings::adjustsEffects() const
+{
+ return texture != 0 || clarity != 0 || dehaze != 0 || glow != 0 || vignetteAmount != 0 || grainAmount != 0;
+}
+
+bool CameraRawSettings::isIdentity() const
+{
+ return !adjustsLight() && !adjustsColor() && !adjustsEffects() && !curve.adjusts() && !mixer.adjusts() && !grading.adjusts()
+ && !detail.adjusts() && !optics.adjusts() && !geometry.adjusts() && !calibration.adjusts();
+}
+
+CameraRawSettings CameraRawSettings::normalized() const
+{
+ CameraRawSettings result = *this;
+ result.exposure = clamp(exposure, exposureLow, exposureHigh, 0);
+ for (double CameraRawSettings::*tone : {&CameraRawSettings::contrast, &CameraRawSettings::highlights, &CameraRawSettings::shadows,
+ &CameraRawSettings::whites, &CameraRawSettings::blacks, &CameraRawSettings::temperature,
+ &CameraRawSettings::tint, &CameraRawSettings::vibrance, &CameraRawSettings::saturation,
+ &CameraRawSettings::texture, &CameraRawSettings::clarity, &CameraRawSettings::dehaze,
+ &CameraRawSettings::glowRange, &CameraRawSettings::glowSpread, &CameraRawSettings::glowWarmth,
+ &CameraRawSettings::vignetteAmount, &CameraRawSettings::vignetteRoundness})
+ result.*tone = clamp(this->*tone, -100, 100, 0);
+ result.glow = clamp(glow, 0, 100, 0);
+ result.vignetteMidpoint = clamp(vignetteMidpoint, 0, 100, 50);
+ result.vignetteFeather = clamp(vignetteFeather, 0, 100, 50);
+ result.vignetteHighlights = clamp(vignetteHighlights, 0, 100, 0);
+ result.grainAmount = clamp(grainAmount, 0, 100, 0);
+ result.grainSize = clamp(grainSize, 0, 100, 25);
+ result.grainRoughness = clamp(grainRoughness, 0, 100, 50);
+ result.curve = curve.normalized();
+ result.mixer = mixer.normalized();
+ result.grading = grading.normalized();
+ result.detail = detail.normalized();
+ result.optics = optics.normalized();
+ result.geometry = geometry.normalized();
+ result.calibration = calibration.normalized();
+ return result;
+}
+
+CameraRawSettings CameraRawSettings::applying(const CameraRawGroups &shown) const
+{
+ CameraRawSettings result = *this;
+ if (!shown.light)
+ result.exposure = result.contrast = result.highlights = result.shadows = result.whites = result.blacks = 0;
+ if (!shown.color)
+ result.temperature = result.tint = result.vibrance = result.saturation = 0;
+ if (!shown.effects)
+ result.texture = result.clarity = result.dehaze = result.glow = result.vignetteAmount = result.grainAmount = 0;
+ if (!shown.curve)
+ result.curve = CameraRawCurveSettings();
+ if (!shown.mixer)
+ result.mixer = CameraRawMixerSettings();
+ if (!shown.grading)
+ result.grading = CameraRawGradingSettings();
+ if (!shown.detail)
+ result.detail = CameraRawDetailSettings();
+ if (!shown.optics)
+ result.optics = CameraRawOpticsSettings();
+ if (!shown.geometry)
+ result.geometry = CameraRawGeometrySettings();
+ if (!shown.calibration)
+ result.calibration = CameraRawCalibrationSettings();
+ return result;
+}
+
+double CameraRawSettings::grainKernelSize() const
+{
+ return 0.5 + (grainSize / 100) * 19.5;
+}
+
+CameraRawSettings::Gains CameraRawSettings::gains() const
+{
+ const double warm = temperature / 100, magenta = tint / 100;
+ return {1 + temperatureGain * warm + tintRedBlue * magenta, 1 - tintGreen * magenta, 1 - temperatureGain * warm + tintRedBlue * magenta};
+}
+
+QImage CameraRawSettings::apply(const QImage &image, std::optional clipping, double scale, quint32 seed, int visualizePointColor,
+ bool sharpenMask) const
+{
+ const CameraRawSettings settings = normalized();
+ if (settings.isIdentity() && !clipping && visualizePointColor < 0 && !sharpenMask)
+ return image;
+ const Gains gain = settings.gains();
+ const int mode = clipping ? int(*clipping) : 0;
+ const double pixelScale = scale > 0 ? scale : 1;
+ const bool paintColor = !clipping && !sharpenMask
+ && (settings.curve.adjusts() || settings.mixer.adjusts() || settings.grading.adjusts() || visualizePointColor >= 0);
+ const bool paintEffects = !clipping && !sharpenMask && settings.adjustsEffects();
+ const bool paintDetailOptics = !clipping && (settings.detail.adjusts() || settings.optics.adjusts() || sharpenMask);
+ QImage source = image;
+ if (!clipping && !sharpenMask && visualizePointColor < 0 && settings.geometry.adjusts())
+ source = settings.geometry.apply(source);
+ return ImageAdjustmentPixels::run(source, [&](uchar *pixels, int width, int height, qsizetype stride) {
+ 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)
+ adjust_camera_raw(pixels, w, h, s, gain.red, gain.green, gain.blue, settings.exposure, settings.contrast, settings.highlights,
+ settings.shadows, settings.whites, settings.blacks, settings.vibrance, settings.saturation, mode);
+ if (paintColor)
+ settings.applyCurveColor(pixels, width, height, stride, visualizePointColor);
+ if (paintEffects) {
+ if (settings.texture != 0 || settings.clarity != 0 || settings.dehaze != 0 || settings.glow != 0 || settings.vignetteAmount != 0)
+ adjust_camera_raw_effects(pixels, w, h, s, settings.texture, settings.clarity, settings.dehaze, settings.glow, int(settings.glowStyle),
+ settings.glowRange, settings.glowSpread, settings.glowWarmth, settings.vignetteAmount,
+ settings.vignetteMidpoint, settings.vignetteRoundness, settings.vignetteFeather,
+ settings.vignetteHighlights, int(settings.vignetteStyle), pixelScale);
+ if (settings.grainAmount > 0)
+ adjust_grain(pixels, w, h, s, settings.grainAmount, settings.grainKernelSize(), settings.grainRoughness, seed, 0, 0, 1 / pixelScale);
+ }
+ if (paintDetailOptics)
+ settings.applyDetailOptics(pixels, width, height, stride, pixelScale, PixelFilter::lensStrength, sharpenMask);
+ });
+}
+
+std::optional CameraRawSettings::neutralizeLinear(double red, double green, double blue)
+{
+ if (!(red > 1e-4 && green > 1e-4 && blue > 1e-4))
+ return std::nullopt;
+ const double a1 = temperatureGain * red, b1 = tintRedBlue * red + tintGreen * green, c1 = green - red;
+ const double a2 = -temperatureGain * blue, b2 = tintRedBlue * blue + tintGreen * green, c2 = green - blue;
+ const double determinant = a1 * b2 - a2 * b1;
+ if (!(std::abs(determinant) > 1e-8))
+ return std::nullopt;
+ const double warm = (c1 * b2 - c2 * b1) / determinant, magenta = (a1 * c2 - a2 * c1) / determinant;
+ if (!std::isfinite(warm) || !std::isfinite(magenta))
+ return std::nullopt;
+ return Balance{warm * 100, magenta * 100};
+}
+
+std::optional CameraRawSettings::neutralizeStraight(double red, double green, double blue)
+{
+ return neutralizeLinear(decode(red), decode(green), decode(blue));
+}
+
+std::optional CameraRawSettings::autoBalance(const QImage &image)
+{
+ if (image.isNull())
+ return std::nullopt;
+ const QImage pixels = drawn(image);
+ double red = 0, green = 0, blue = 0, count = 0;
+ for (int y = 0; y < pixels.height(); ++y) {
+ const uchar *row = pixels.constScanLine(y);
+ for (int x = 0; x < pixels.width(); ++x) {
+ const uchar *pixel = row + x * 4;
+ const double alpha = pixel[3];
+ if (alpha == 0)
+ continue;
+ red += decode(std::min(1.0, pixel[0] / alpha));
+ green += decode(std::min(1.0, pixel[1] / alpha));
+ blue += decode(std::min(1.0, pixel[2] / alpha));
+ count += 1;
+ }
+ }
+ if (count == 0)
+ return std::nullopt;
+ return neutralizeLinear(red / count, green / count, blue / count);
+}
+
+double CameraRawScope::peak() const
+{
+ return std::max({LevelsHistogramDisplay::scale(red), LevelsHistogramDisplay::scale(green), LevelsHistogramDisplay::scale(blue)});
+}
+
+std::optional CameraRawScope::make(const QImage &image)
+{
+ if (image.isNull())
+ return std::nullopt;
+ const QImage pixels = drawn(image);
+ std::array bins{};
+ // Row by row: Qt may pad rows; the kernel counts.
+ for (int y = 0; y < pixels.height(); ++y)
+ levels_histogram(pixels.constScanLine(y), nullptr, size_t(pixels.width()), bins.data());
+ CameraRawScope scope;
+ std::copy_n(bins.begin() + binCount, binCount, scope.red.begin());
+ std::copy_n(bins.begin() + 2 * binCount, binCount, scope.green.begin());
+ std::copy_n(bins.begin() + 3 * binCount, binCount, scope.blue.begin());
+ for (int y = 0; y < pixels.height(); ++y) {
+ const uchar *row = pixels.constScanLine(y);
+ for (int x = 0; x < pixels.width(); ++x) {
+ const uchar *pixel = row + x * 4;
+ const double alpha = pixel[3];
+ if (alpha == 0)
+ continue;
+ const double r = std::min(1.0, pixel[0] / alpha), g = std::min(1.0, pixel[1] / alpha), b = std::min(1.0, pixel[2] / alpha);
+ const double high = std::max({r, g, b}), low = std::min({r, g, b}), chroma = high - low;
+ if (!(chroma > 1e-4 && high > 1e-4))
+ continue;
+ double hue = high == r ? (g - b) / chroma : high == g ? 2 + (b - r) / chroma : 4 + (r - g) / chroma;
+ // A negative hue needs no wrap: the angle turns round.
+ hue /= 6;
+ const double angle = hue * 2 * std::numbers::pi, reach = chroma / high;
+ const double plotX = 0.5 + std::cos(angle) * reach * 0.48, plotY = 0.5 + std::sin(angle) * reach * 0.48;
+ const int column = std::min(scopeSide - 1, std::max(0, int(plotX * scopeSide)));
+ const int line = std::min(scopeSide - 1, std::max(0, int(plotY * scopeSide)));
+ scope.vectorscope[size_t(line * scopeSide + column)] += alpha / 255;
+ }
+ }
+ return scope;
+}
+
+QImage CameraRawScope::overlay(const QImage &image, bool shadows, bool highlights)
+{
+ if (!shadows && !highlights)
+ return image;
+ return ImageAdjustmentPixels::run(image, [&](uchar *pixels, int width, int height, qsizetype stride) {
+ adjust_camera_raw_clip_overlay(pixels, size_t(width), size_t(height), size_t(stride), shadows ? 1 : 0, highlights ? 1 : 0);
+ });
+}
+
+std::pair CameraRawScope::preview(const FilterJob &job)
+{
+ FilterJob grade = job;
+ grade.cameraRawClipping = std::nullopt;
+ grade.showsShadowClipping = false;
+ grade.showsHighlightClipping = false;
+ grade.visualizesPointColor = -1;
+ grade.showsSharpenMask = false;
+ const QImage graded = PixelFilter::run(grade);
+ const CameraRawScope scope = make(graded).value_or(CameraRawScope());
+ // Swift leaves the point colour's view out; shown here.
+ if (job.cameraRawClipping || job.showsSharpenMask || job.visualizesPointColor >= 0)
+ return {PixelFilter::run(job), scope};
+ if (job.showsShadowClipping || job.showsHighlightClipping)
+ return {overlay(graded, job.showsShadowClipping, job.showsHighlightClipping), scope};
+ return {graded, scope};
+}
+
+int CameraRawPanel::pointColorVisualizeIndex(const CameraRawSettings &settings) const
+{
+ const std::vector &points = settings.mixer.points;
+ if (pointIndex < 0 || size_t(pointIndex) >= points.size() || !points[size_t(pointIndex)].visualize)
+ return -1;
+ return pointIndex;
+}
diff --git a/src/Document/CameraRaw.h b/src/Document/CameraRaw.h
new file mode 100644
index 0000000..d70d6ea
--- /dev/null
+++ b/src/Document/CameraRaw.h
@@ -0,0 +1,179 @@
+#pragma once
+#include "Document/CameraRawColor.h"
+#include "Document/CameraRawDetailOptics.h"
+#include "Document/CameraRawGeometryCalibration.h"
+#include
+#include
+#include
+#include
+
+struct FilterJob;
+
+// Relative offsets, not kelvin; `auto` is a keyword here.
+enum class CameraRawWhiteBalance { custom, automatic };
+enum class CameraRawGlowStyle { diffusion, bloom, halation };
+// Highlight Priority's Highlights slider protects bright pixels.
+enum class CameraRawVignetteStyle { highlightPriority, colorPriority, paintOverlay };
+// Option on a Light slider shows clipping; never written in.
+enum class CameraRawClipping { highlights = 1, shadows = 2 };
+enum class CameraRawScopeMode { histogram, vectorscope };
+
+// Swift's panel eyes: a hidden group leaves the grade.
+struct CameraRawGroups {
+ bool light = true;
+ bool color = true;
+ bool effects = true;
+ bool curve = true;
+ bool mixer = true;
+ bool grading = true;
+ bool detail = true;
+ bool optics = true;
+ bool geometry = true;
+ bool calibration = true;
+ friend bool operator==(const CameraRawGroups &, const CameraRawGroups &) = default;
+};
+
+// Defaults leave the image unchanged.
+struct CameraRawSettings {
+ static constexpr double exposureLow = -5, exposureHigh = 5;
+ // A full warm or cool swing on red and blue.
+ static constexpr double temperatureGain = 0.35;
+ static constexpr double tintRedBlue = 0.15;
+ static constexpr double tintGreen = 0.30;
+
+ CameraRawWhiteBalance whiteBalance = CameraRawWhiteBalance::custom;
+ // Cool to warm, −100 to 100; tint green to magenta.
+ double temperature = 0;
+ double tint = 0;
+ // Stops of linear light, −5 to 5.
+ double exposure = 0;
+ double contrast = 0;
+ double highlights = 0;
+ double shadows = 0;
+ double whites = 0;
+ double blacks = 0;
+ double vibrance = 0;
+ double saturation = 0;
+ // Local contrast: Texture the finer band, Clarity the broader.
+ double texture = 0;
+ double clarity = 0;
+ double dehaze = 0;
+ // 0 to 100; range, spread and warmth rest at zero.
+ double glow = 0;
+ CameraRawGlowStyle glowStyle = CameraRawGlowStyle::diffusion;
+ double glowRange = 0;
+ double glowSpread = 0;
+ double glowWarmth = 0;
+ // Negative darkens the edges, positive lightens; the centre stays.
+ double vignetteAmount = 0;
+ CameraRawVignetteStyle vignetteStyle = CameraRawVignetteStyle::highlightPriority;
+ double vignetteMidpoint = 50;
+ double vignetteRoundness = 0;
+ double vignetteFeather = 50;
+ double vignetteHighlights = 0;
+ double grainAmount = 0;
+ double grainSize = 25;
+ double grainRoughness = 50;
+ CameraRawCurveSettings curve;
+ CameraRawMixerSettings mixer;
+ CameraRawGradingSettings grading;
+ CameraRawDetailSettings detail;
+ CameraRawOpticsSettings optics;
+ CameraRawGeometrySettings geometry;
+ CameraRawCalibrationSettings calibration;
+
+ bool adjustsLight() const;
+ bool adjustsColor() const;
+ bool adjustsEffects() const;
+ bool isIdentity() const;
+ CameraRawSettings normalized() const;
+ // The grade with some panel eyes off: those groups zeroed.
+ CameraRawSettings applying(const CameraRawGroups &shown) const;
+ // Camera Raw's 0 to 100 size in `adjust_grain`'s pixels.
+ double grainKernelSize() const;
+ struct Gains {
+ double red;
+ double green;
+ double blue;
+ };
+ Gains gains() const;
+ // `clipping`: the Option view; `scale`: preview pixels a pixel.
+ QImage apply(const QImage &image, std::optional clipping = std::nullopt, double scale = 1, quint32 seed = 0,
+ int visualizePointColor = -1, bool sharpenMask = false) const;
+ struct Balance {
+ double temperature;
+ double tint;
+ };
+ // The temperature and tint making a linear pixel neutral.
+ static std::optional neutralizeLinear(double red, double green, double blue);
+ static std::optional neutralizeStraight(double red, double green, double blue);
+ // Gray-world balance of the opaque pixels.
+ static std::optional autoBalance(const QImage &image);
+ void applyCurveColor(uchar *pixels, int width, int height, qsizetype stride, int visualize) const;
+ void applyDetailOptics(uchar *pixels, int width, int height, qsizetype stride, double scale, double profileStrength, bool sharpenMask) const;
+ void applyCalibration(uchar *pixels, int width, int height, qsizetype stride) const;
+ friend bool operator==(const CameraRawSettings &, const CameraRawSettings &) = default;
+};
+
+// An RGB histogram and a hue and saturation vectorscope.
+struct CameraRawScope {
+ static constexpr int binCount = 256;
+ static constexpr int scopeSide = 64;
+ std::array red{};
+ std::array green{};
+ std::array blue{};
+ // Density from the centre out, row by row.
+ std::vector vectorscope = std::vector(scopeSide * scopeSide);
+ // One vertical scale, so the ribbons stay comparable.
+ double peak() const;
+ // Counts the graded image, clear pixels skipped.
+ static std::optional make(const QImage &image);
+ // Clipped shadows blue, clipped highlights red, over the grade.
+ static QImage overlay(const QImage &image, bool shadows, bool highlights);
+ // The preview and the scope of the grade itself.
+ static std::pair preview(const FilterJob &job);
+ friend bool operator==(const CameraRawScope &, const CameraRawScope &) = default;
+};
+
+// Swift's CameraRawDrag: a targeted adjustment under way.
+struct CameraRawDrag {
+ double startY;
+ CameraRawSettings settings;
+ double tone;
+ double hue;
+};
+
+// Swift's FilterEdit fields for the Camera Raw panel.
+struct CameraRawPanel {
+ // Eyes off drop a group from preview and OK.
+ CameraRawGroups shows;
+ CameraRawCurvePage curvePage = CameraRawCurvePage::parametric;
+ CameraRawPointChannel pointChannel = CameraRawPointChannel::rgb;
+ CameraRawMixerPage mixerPage = CameraRawMixerPage::hsl;
+ CameraRawMixerTab mixerTab = CameraRawMixerTab::hue;
+ int mixerSwatch = 0;
+ int pointIndex = 0;
+ CameraRawGradePage gradePage = CameraRawGradePage::threeWay;
+ bool targetsCurve = false;
+ bool targetsMixer = false;
+ bool samplesPointColor = false;
+ std::optional drag;
+ // The white-balance and defringe eyedroppers.
+ bool samplesWhiteBalance = false;
+ bool samplesDefringe = false;
+ // Guided Upright: lines dragged on the preview.
+ bool drawingGeometryGuide = false;
+ std::optional> guideDraft;
+ // Option on a Light slider, or on Sharpening's Masking.
+ std::optional clipping;
+ bool sharpenMask = false;
+ // The histogram's clipping marks, never baked in on OK.
+ bool showsShadowClipping = false;
+ bool showsHighlightClipping = false;
+ CameraRawScopeMode scopeMode = CameraRawScopeMode::histogram;
+ std::optional scope;
+ // The adjusted preview's RGB under the pointer.
+ std::optional> readout;
+ // The point colour being shown in isolation, or −1.
+ int pointColorVisualizeIndex(const CameraRawSettings &settings) const;
+};
diff --git a/src/Document/CameraRawColor.cpp b/src/Document/CameraRawColor.cpp
new file mode 100644
index 0000000..11495bd
--- /dev/null
+++ b/src/Document/CameraRawColor.cpp
@@ -0,0 +1,271 @@
+#include "Document/CameraRawColor.h"
+#include "Document/CameraRaw.h"
+#include "Document/ImageAdjustments.h"
+#include
+#include
+extern "C" {
+#include "AdjustPixels.h"
+}
+
+namespace {
+using ImageAdjustmentPixels::clamp;
+
+double point(double x, const std::vector &points)
+{
+ if (points.size() < 2)
+ return x;
+ CurvesSettings curve;
+ curve.channels[0].clear();
+ for (const CurvePoint &each : points)
+ curve.channels[0].push_back({each.x * 255, each.y * 255});
+ return curve.value(x * 255, 0) / 255;
+}
+
+// Swift's repair: sorted, ends pinned, inner points a hundredth apart.
+std::vector repair(const std::vector &points)
+{
+ std::vector sorted;
+ std::copy_if(points.begin(), points.end(), std::back_inserter(sorted), [](const CurvePoint &each) { return std::isfinite(each.x) && std::isfinite(each.y); });
+ std::stable_sort(sorted.begin(), sorted.end(), [](const CurvePoint &left, const CurvePoint &right) { return left.x < right.x; });
+ if (sorted.size() < 2)
+ return CameraRawCurveSettings::linear();
+ const auto unit = [](double value) { return std::min(1.0, std::max(0.0, value)); };
+ std::vector kept{{0, unit(sorted.front().y)}};
+ for (size_t index = 1; index + 1 < sorted.size(); ++index) {
+ const double x = std::min(0.99, std::max(0.01, sorted[index].x));
+ if (!(x > kept.back().x + 0.01))
+ continue;
+ kept.push_back({x, unit(sorted[index].y)});
+ }
+ kept.push_back({1, unit(sorted.back().y)});
+ return kept;
+}
+
+std::vector &pointsOf(CameraRawCurveSettings &curve, CameraRawPointChannel channel)
+{
+ switch (channel) {
+ case CameraRawPointChannel::rgb: return curve.rgb;
+ case CameraRawPointChannel::red: return curve.red;
+ case CameraRawPointChannel::green: return curve.green;
+ case CameraRawPointChannel::blue: return curve.blue;
+ }
+ throw std::logic_error("unknown point channel");
+}
+}
+
+std::vector CameraRawCurveSettings::linear()
+{
+ return {{0, 0}, {1, 1}};
+}
+
+std::vector CameraRawCurveSettings::mediumContrast()
+{
+ return {{0, 0}, {0.25, 0.18}, {0.75, 0.82}, {1, 1}};
+}
+
+std::vector CameraRawCurveSettings::strongContrast()
+{
+ return {{0, 0}, {0.25, 0.10}, {0.75, 0.90}, {1, 1}};
+}
+
+bool CameraRawCurveSettings::isLinear(const std::vector &points)
+{
+ return points == linear();
+}
+
+bool CameraRawCurveSettings::adjusts() const
+{
+ return shadows != 0 || darks != 0 || lights != 0 || highlights != 0 || refineSaturation != 0 || !isLinear(rgb) || !isLinear(red)
+ || !isLinear(green) || !isLinear(blue);
+}
+
+double CameraRawCurveSettings::parametric(double tone) const
+{
+ const double shadow = shadowSplit / 100, dark = darkSplit / 100, light = lightSplit / 100;
+ double amount, lo, hi;
+ if (tone < shadow)
+ amount = shadows, lo = 0, hi = shadow;
+ else if (tone < dark)
+ amount = darks, lo = shadow, hi = dark;
+ else if (tone < light)
+ amount = lights, lo = dark, hi = light;
+ else
+ amount = highlights, lo = light, hi = 1;
+ const double span = std::max(0.02, hi - lo);
+ const double weight = 1 - std::abs(tone - (lo + hi) / 2) / (span / 2);
+ // A tone in its region weighs 0 to 1, unfloored.
+ return std::min(1.0, std::max(0.0, tone + (amount / 100) * weight * 0.22));
+}
+
+std::vector CameraRawCurveSettings::lumaTable() const
+{
+ std::vector table(256);
+ for (int index = 0; index < 256; ++index)
+ table[size_t(index)] = float(point(parametric(index / 255.0), rgb));
+ return table;
+}
+
+std::vector CameraRawCurveSettings::channelTable(const std::vector &points) const
+{
+ std::vector table(256);
+ for (int index = 0; index < 256; ++index)
+ table[size_t(index)] = float(point(index / 255.0, points));
+ return table;
+}
+
+CameraRawCurveSettings CameraRawCurveSettings::nudged(CameraRawPointChannel channel, double tone, double delta) const
+{
+ CameraRawCurveSettings result = *this;
+ std::vector &points = pointsOf(result, channel);
+ // Swift's `min(by:)`: the first nearest point.
+ size_t nearest = 0;
+ for (size_t index = 1; index < points.size(); ++index)
+ if (std::abs(points[index].x - tone) < std::abs(points[nearest].x - tone))
+ nearest = index;
+ points.at(nearest).y = std::min(1.0, std::max(0.0, points[nearest].y + delta));
+ return result;
+}
+
+double &CameraRawCurveSettings::region(double tone)
+{
+ if (tone < shadowSplit / 100)
+ return shadows;
+ if (tone < darkSplit / 100)
+ return darks;
+ if (tone < lightSplit / 100)
+ return lights;
+ return highlights;
+}
+
+CameraRawCurveSettings CameraRawCurveSettings::normalized() const
+{
+ CameraRawCurveSettings result = *this;
+ result.shadows = clamp(shadows, -100, 100, 0);
+ result.darks = clamp(darks, -100, 100, 0);
+ result.lights = clamp(lights, -100, 100, 0);
+ result.highlights = clamp(highlights, -100, 100, 0);
+ result.refineSaturation = clamp(refineSaturation, -100, 100, 0);
+ result.shadowSplit = clamp(shadowSplit, 5, 90, 25);
+ result.darkSplit = clamp(darkSplit, result.shadowSplit + 2, 95, 50);
+ result.lightSplit = clamp(lightSplit, result.darkSplit + 2, 98, 75);
+ result.rgb = repair(rgb);
+ result.red = repair(red);
+ result.green = repair(green);
+ result.blue = repair(blue);
+ return result;
+}
+
+const std::array CameraRawMixerSettings::names{QStringLiteral("Reds"), QStringLiteral("Oranges"), QStringLiteral("Yellows"),
+ QStringLiteral("Greens"), QStringLiteral("Aquas"), QStringLiteral("Blues"),
+ QStringLiteral("Purples"), QStringLiteral("Magentas")};
+
+bool CameraRawMixerSettings::adjusts() const
+{
+ const auto moved = [](const std::array &values) { return std::any_of(values.begin(), values.end(), [](double value) { return value != 0; }); };
+ return moved(hue) || moved(saturation) || moved(luminance)
+ || std::any_of(points.begin(), points.end(), [](const CameraRawPointColor &each) {
+ return each.hueShift != 0 || each.saturationShift != 0 || each.luminanceShift != 0;
+ });
+}
+
+std::array CameraRawMixerSettings::weights(double degrees)
+{
+ std::array result{};
+ for (size_t index = 0; index < 8; ++index) {
+ double distance = std::abs(degrees - centers[index]);
+ if (distance > 180)
+ distance = 360 - distance;
+ result[index] = std::max(0.0, 1 - distance / 40);
+ }
+ return result;
+}
+
+std::vector CameraRawMixerSettings::mixerFloats() const
+{
+ std::vector result;
+ for (const std::array *values : {&hue, &saturation, &luminance})
+ for (const double value : *values)
+ result.push_back(float(value / 100));
+ return result;
+}
+
+std::vector CameraRawMixerSettings::pointFloats() const
+{
+ std::vector result;
+ for (const CameraRawPointColor &each : points)
+ for (const double value : {each.hue / 360, each.saturation, each.luminance, each.hueShift / 100, each.saturationShift / 100,
+ each.luminanceShift / 100, each.hueRange / 360, each.saturationRange, each.luminanceRange})
+ result.push_back(float(value));
+ return result;
+}
+
+CameraRawMixerSettings CameraRawMixerSettings::normalized() const
+{
+ CameraRawMixerSettings result = *this;
+ for (std::array *values : {&result.hue, &result.saturation, &result.luminance})
+ for (double &value : *values)
+ value = clamp(value, -100, 100, 0);
+ result.points.clear();
+ for (size_t index = 0; index < std::min(points.size(), 8); ++index)
+ result.points.push_back(points[index].normalized());
+ return result;
+}
+
+CameraRawPointColor CameraRawPointColor::normalized() const
+{
+ CameraRawPointColor result = *this;
+ result.hue = clamp(hue, 0, 360, 0);
+ result.saturation = clamp(saturation, 0, 1, 0);
+ result.luminance = clamp(luminance, 0, 1, 0);
+ result.hueShift = clamp(hueShift, -100, 100, 0);
+ result.saturationShift = clamp(saturationShift, -100, 100, 0);
+ result.luminanceShift = clamp(luminanceShift, -100, 100, 0);
+ result.hueRange = clamp(hueRange, 5, 180, 30);
+ result.saturationRange = clamp(saturationRange, 0.05, 1, 0.4);
+ result.luminanceRange = clamp(luminanceRange, 0.05, 1, 0.4);
+ return result;
+}
+
+CameraRawGradeWheel CameraRawGradeWheel::normalized() const
+{
+ return {clamp(hue, 0, 360, 0), clamp(saturation, 0, 100, 0), clamp(luminance, -100, 100, 0)};
+}
+
+std::array CameraRawGradingSettings::wheels() const
+{
+ return {shadows, midtones, highlights, global};
+}
+
+bool CameraRawGradingSettings::adjusts() const
+{
+ const std::array all = wheels();
+ return std::any_of(all.begin(), all.end(), [](const CameraRawGradeWheel &wheel) { return wheel.saturation != 0 || wheel.luminance != 0; });
+}
+
+std::vector CameraRawGradingSettings::gradeFloats() const
+{
+ std::vector result;
+ for (const CameraRawGradeWheel &wheel : wheels())
+ for (const double value : {wheel.hue / 360, wheel.saturation / 100, wheel.luminance / 100})
+ result.push_back(float(value));
+ return result;
+}
+
+CameraRawGradingSettings CameraRawGradingSettings::normalized() const
+{
+ return {shadows.normalized(), midtones.normalized(), highlights.normalized(), global.normalized(), clamp(blending, 0, 100, 50),
+ clamp(balance, -100, 100, 0)};
+}
+
+void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qsizetype stride, int visualize) const
+{
+ const CameraRawCurveSettings shape = curve.normalized();
+ const CameraRawMixerSettings mixing = mixer.normalized();
+ const CameraRawGradingSettings grade = grading.normalized();
+ const std::vector luma = shape.lumaTable(), redTable = shape.channelTable(shape.red), greenTable = shape.channelTable(shape.green),
+ blueTable = shape.channelTable(shape.blue), mixerFloats = mixing.mixerFloats(), pointFloats = mixing.pointFloats(),
+ gradeFloats = grade.gradeFloats();
+ adjust_camera_raw_curve_color(pixels, size_t(width), size_t(height), size_t(stride), luma.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);
+}
diff --git a/src/Document/CameraRawColor.h b/src/Document/CameraRawColor.h
new file mode 100644
index 0000000..b7e6036
--- /dev/null
+++ b/src/Document/CameraRawColor.h
@@ -0,0 +1,103 @@
+#pragma once
+#include "Document/Curves.h"
+#include
+#include
+#include
+
+// Swift's CameraRawColor.swift: Curve, Color Mixer, Color Grading.
+enum class CameraRawCurvePage { parametric, point };
+enum class CameraRawPointChannel { rgb, red, green, blue };
+enum class CameraRawMixerPage { hsl, color, point };
+enum class CameraRawMixerTab { hue, saturation, luminance };
+enum class CameraRawGradePage { threeWay, shadows, midtones, highlights, global };
+
+// Parametric regions and point curves, points 0 to 1.
+struct CameraRawCurveSettings {
+ double shadows = 0;
+ double darks = 0;
+ double lights = 0;
+ double highlights = 0;
+ // Dividers, 0 to 100: where each region hands off.
+ double shadowSplit = 25;
+ double darkSplit = 50;
+ double lightSplit = 75;
+ std::vector rgb = linear();
+ std::vector red = linear();
+ std::vector green = linear();
+ std::vector blue = linear();
+ // How far the composite curve moves saturation too.
+ double refineSaturation = 0;
+
+ static std::vector linear();
+ static std::vector mediumContrast();
+ static std::vector strongContrast();
+ static bool isLinear(const std::vector &points);
+ bool adjusts() const;
+ // Lifts or lowers the region a tone belongs to.
+ double parametric(double tone) const;
+ std::vector lumaTable() const;
+ std::vector channelTable(const std::vector &points) const;
+ CameraRawCurveSettings nudged(CameraRawPointChannel channel, double tone, double delta) const;
+ // Swift's region(for:): the parametric amount a tone moves.
+ double ®ion(double tone);
+ CameraRawCurveSettings normalized() const;
+ friend bool operator==(const CameraRawCurveSettings &, const CameraRawCurveSettings &) = default;
+};
+
+// One picked colour and how far its adjustment reaches.
+struct CameraRawPointColor {
+ double hue = 0;
+ double saturation = 0;
+ double luminance = 0;
+ double hueShift = 0;
+ double saturationShift = 0;
+ double luminanceShift = 0;
+ double hueRange = 30;
+ double saturationRange = 0.4;
+ double luminanceRange = 0.4;
+ bool visualize = false;
+ CameraRawPointColor normalized() const;
+ friend bool operator==(const CameraRawPointColor &, const CameraRawPointColor &) = default;
+};
+
+// Eight colour families, each hue, saturation, luminance −100 to 100.
+struct CameraRawMixerSettings {
+ static const std::array names;
+ static constexpr std::array centers{0, 30, 60, 120, 180, 240, 270, 300};
+ std::array hue{};
+ std::array saturation{};
+ std::array luminance{};
+ std::vector points{};
+ bool adjusts() const;
+ // How much each family shares a hue; neighbours overlap.
+ static std::array weights(double degrees);
+ std::vector mixerFloats() const;
+ std::vector pointFloats() const;
+ CameraRawMixerSettings normalized() const;
+ friend bool operator==(const CameraRawMixerSettings &, const CameraRawMixerSettings &) = default;
+};
+
+struct CameraRawGradeWheel {
+ double hue = 0;
+ double saturation = 0;
+ double luminance = 0;
+ CameraRawGradeWheel normalized() const;
+ friend bool operator==(const CameraRawGradeWheel &, const CameraRawGradeWheel &) = default;
+};
+
+// Four wheels, their overlap and which end they favour.
+struct CameraRawGradingSettings {
+ CameraRawGradeWheel shadows;
+ CameraRawGradeWheel midtones;
+ CameraRawGradeWheel highlights;
+ CameraRawGradeWheel global;
+ // 0 to 100: more lets the tonal wheels overlap.
+ double blending = 50;
+ // −100 to 100: negative favours shadows, positive highlights.
+ double balance = 0;
+ std::array wheels() const;
+ bool adjusts() const;
+ std::vector gradeFloats() const;
+ CameraRawGradingSettings normalized() const;
+ friend bool operator==(const CameraRawGradingSettings &, const CameraRawGradingSettings &) = default;
+};
diff --git a/src/Document/CameraRawDetailOptics.cpp b/src/Document/CameraRawDetailOptics.cpp
new file mode 100644
index 0000000..494aef8
--- /dev/null
+++ b/src/Document/CameraRawDetailOptics.cpp
@@ -0,0 +1,86 @@
+#include "Document/CameraRawDetailOptics.h"
+#include "Document/CameraRaw.h"
+#include "Document/ImageAdjustments.h"
+#include
+extern "C" {
+#include "AdjustPixels.h"
+}
+
+using ImageAdjustmentPixels::clamp;
+
+bool CameraRawDetailSettings::adjustsSharpening() const
+{
+ return sharpenAmount != 0;
+}
+
+bool CameraRawDetailSettings::adjustsNoise() const
+{
+ return noiseLuminance != 0 || noiseColor != 0;
+}
+
+bool CameraRawDetailSettings::adjusts() const
+{
+ return adjustsSharpening() || adjustsNoise();
+}
+
+CameraRawDetailSettings CameraRawDetailSettings::normalized() const
+{
+ return {clamp(sharpenAmount, 0, 150, 0), clamp(sharpenRadius, 0, 100, 10), clamp(sharpenDetail, 0, 100, 25),
+ clamp(sharpenMasking, 0, 100, 0), clamp(noiseLuminance, 0, 100, 0), clamp(noiseLuminanceDetail, 0, 100, 50),
+ clamp(noiseLuminanceContrast, 0, 100, 0), clamp(noiseColor, 0, 100, 0), clamp(noiseColorDetail, 0, 100, 50),
+ clamp(noiseColorSmoothness, 0, 100, 50)};
+}
+
+bool CameraRawOpticsSettings::adjusts() const
+{
+ return removeChromaticAberration || enableLensProfile || distortion != 0 || purpleAmount != 0 || greenAmount != 0 || vignetteAmount != 0;
+}
+
+CameraRawOpticsSettings CameraRawOpticsSettings::normalized() const
+{
+ CameraRawOpticsSettings result = *this;
+ result.profileDistortion = clamp(profileDistortion, 0, 100, 100);
+ result.profileVignetting = clamp(profileVignetting, 0, 100, 100);
+ result.distortion = clamp(distortion, -100, 100, 0);
+ result.purpleAmount = clamp(purpleAmount, 0, 100, 0);
+ result.greenAmount = clamp(greenAmount, 0, 100, 0);
+ result.vignetteAmount = clamp(vignetteAmount, -100, 100, 0);
+ result.vignetteMidpoint = clamp(vignetteMidpoint, 0, 100, 50);
+ result.purpleHueLow = clamp(purpleHueLow, 0, 360, 270);
+ result.purpleHueHigh = clamp(purpleHueHigh, 0, 360, 310);
+ result.greenHueLow = clamp(greenHueLow, 0, 360, 60);
+ result.greenHueHigh = clamp(greenHueHigh, 0, 360, 120);
+ if (result.purpleHueLow > result.purpleHueHigh)
+ std::swap(result.purpleHueLow, result.purpleHueHigh);
+ if (result.greenHueLow > result.greenHueHigh)
+ std::swap(result.greenHueLow, result.greenHueHigh);
+ return result;
+}
+
+double CameraRawOpticsSettings::distortionK(double profileStrength) const
+{
+ const double manual = distortion / 100 * profileStrength;
+ const double profile = enableLensProfile ? profileDistortion / 100 * profileStrength : 0;
+ return manual + profile;
+}
+
+void CameraRawSettings::applyDetailOptics(uchar *pixels, int width, int height, qsizetype stride, double scale, double profileStrength,
+ bool sharpenMask) const
+{
+ const CameraRawDetailSettings sharpening = detail.normalized();
+ const CameraRawOpticsSettings lens = optics.normalized();
+ const size_t w = size_t(width), h = size_t(height), s = size_t(stride);
+ if (sharpenMask) {
+ adjust_camera_raw_sharpen_mask_overlay(pixels, w, h, s, sharpening.sharpenRadius, sharpening.sharpenDetail, sharpening.sharpenMasking, scale);
+ return;
+ }
+ 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);
+ if (sharpening.adjusts())
+ adjust_camera_raw_detail(pixels, w, h, s, sharpening.sharpenAmount, sharpening.sharpenRadius, sharpening.sharpenDetail,
+ sharpening.sharpenMasking, sharpening.noiseLuminance, sharpening.noiseLuminanceDetail,
+ sharpening.noiseLuminanceContrast, sharpening.noiseColor, sharpening.noiseColorDetail,
+ sharpening.noiseColorSmoothness, scale);
+}
diff --git a/src/Document/CameraRawDetailOptics.h b/src/Document/CameraRawDetailOptics.h
new file mode 100644
index 0000000..83ff076
--- /dev/null
+++ b/src/Document/CameraRawDetailOptics.h
@@ -0,0 +1,45 @@
+#pragma once
+
+// Sharpening and noise reduction; Amount runs 0 to 150.
+struct CameraRawDetailSettings {
+ double sharpenAmount = 0;
+ double sharpenRadius = 10;
+ double sharpenDetail = 25;
+ double sharpenMasking = 0;
+ double noiseLuminance = 0;
+ double noiseLuminanceDetail = 50;
+ double noiseLuminanceContrast = 0;
+ double noiseColor = 0;
+ double noiseColorDetail = 50;
+ double noiseColorSmoothness = 50;
+ bool adjustsSharpening() const;
+ bool adjustsNoise() const;
+ bool adjusts() const;
+ CameraRawDetailSettings normalized() const;
+ friend bool operator==(const CameraRawDetailSettings &, const CameraRawDetailSettings &) = default;
+};
+
+// Generic lens corrections: a rendered layer carries no profile.
+struct CameraRawOpticsSettings {
+ bool removeChromaticAberration = false;
+ bool enableLensProfile = false;
+ double profileDistortion = 100;
+ double profileVignetting = 100;
+ // −100 to 100, the Lens Correction filter's sign.
+ double distortion = 0;
+ double purpleAmount = 0;
+ // Degrees, 0 to 360; the low handle stays lower.
+ double purpleHueLow = 270;
+ double purpleHueHigh = 310;
+ double greenAmount = 0;
+ double greenHueLow = 60;
+ double greenHueHigh = 120;
+ // Brightens the corners against lens falloff.
+ double vignetteAmount = 0;
+ double vignetteMidpoint = 50;
+ bool adjusts() const;
+ CameraRawOpticsSettings normalized() const;
+ // The radial distortion handed to `lens_distort`.
+ double distortionK(double profileStrength) const;
+ friend bool operator==(const CameraRawOpticsSettings &, const CameraRawOpticsSettings &) = default;
+};
diff --git a/src/Document/CameraRawGeometryCalibration.cpp b/src/Document/CameraRawGeometryCalibration.cpp
new file mode 100644
index 0000000..e99aea5
--- /dev/null
+++ b/src/Document/CameraRawGeometryCalibration.cpp
@@ -0,0 +1,200 @@
+#include "Document/CameraRawGeometryCalibration.h"
+#include "Document/BrushStroke.h"
+#include "Document/CameraRaw.h"
+#include "Document/ImageAdjustments.h"
+#include "IO/ImageExporter.h"
+#include
+#include
+#include
+#include
+#include
+extern "C" {
+#include "AdjustPixels.h"
+#include "BrushPixels.h"
+}
+
+using ImageAdjustmentPixels::clamp;
+
+namespace {
+bool readable(const CameraRawGeometryGuide &guide)
+{
+ return std::hypot(guide.endX - guide.startX, guide.endY - guide.startY) > 0.01;
+}
+
+// Swift's guidedCorrections: the first line levels, the second tilts.
+std::array guided(const std::vector &guides)
+{
+ if (guides.empty())
+ return {0, 0, 0};
+ const CameraRawGeometryGuide &first = guides.front();
+ const double dx = first.endX - first.startX, dy = first.endY - first.startY;
+ if (std::hypot(dx, dy) <= 1e-4)
+ return {0, 0, 0};
+ double rotate = -std::atan2(dy, dx) * 180 / std::numbers::pi;
+ if (rotate > 45)
+ rotate -= 90;
+ else if (rotate < -45)
+ rotate += 90;
+ double vertical = 0, horizontal = 0;
+ if (guides.size() > 1) {
+ const CameraRawGeometryGuide &second = guides[1];
+ const double sx = second.endX - second.startX, sy = second.endY - second.startY;
+ if (std::hypot(sx, sy) > 1e-4) {
+ const double angle = std::atan2(sy, sx) * 180 / std::numbers::pi;
+ vertical = std::abs(angle) > 45 ? (angle > 0 ? 25 : -25) : 0;
+ horizontal = std::abs(angle) <= 45 ? (angle > 0 ? 25 : -25) : 0;
+ }
+ }
+ return {vertical, horizontal, rotate};
+}
+}
+
+bool CameraRawGeometrySettings::usesGuides() const
+{
+ return upright == CameraRawUprightMode::guided && std::any_of(guides.begin(), guides.end(), readable);
+}
+
+bool CameraRawGeometrySettings::adjusts() const
+{
+ return usesGuides() || vertical != 0 || horizontal != 0 || rotate != 0 || aspect != 0 || scale != 0 || offsetX != 0 || offsetY != 0;
+}
+
+CameraRawGeometrySettings CameraRawGeometrySettings::normalized() const
+{
+ CameraRawGeometrySettings result = *this;
+ result.vertical = clamp(vertical, -100, 100, 0);
+ result.horizontal = clamp(horizontal, -100, 100, 0);
+ result.rotate = clamp(rotate, -45, 45, 0);
+ result.aspect = clamp(aspect, -100, 100, 0);
+ result.scale = clamp(scale, -100, 100, 0);
+ result.offsetX = clamp(offsetX, -100, 100, 0);
+ result.offsetY = clamp(offsetY, -100, 100, 0);
+ result.guides.clear();
+ std::copy_if(guides.begin(), guides.end(), std::back_inserter(result.guides), readable);
+ return result;
+}
+
+std::array CameraRawGeometrySettings::effectiveCorrections() const
+{
+ if (upright == CameraRawUprightMode::off)
+ return {vertical, horizontal, rotate};
+ const std::array extra = guided(guides);
+ return {vertical + extra[0], horizontal + extra[1], rotate + extra[2]};
+}
+
+// Core Image's corners, y measured up from the bottom.
+std::array CameraRawGeometrySettings::outputCorners(int width, int height, double vertical, double horizontal, double rotation) const
+{
+ const double w = width, h = height;
+ const double strength = projection == CameraRawProjection::perspective ? 1.0 : 0.55;
+ const double v = vertical / 100 * w * 0.18 * strength, hz = horizontal / 100 * h * 0.18 * strength;
+ const double aspectScale = 1 + aspect / 200, zoom = 1 + scale / 100;
+ const double shiftX = offsetX / 100 * w * 0.15, shiftY = offsetY / 100 * h * 0.15;
+ std::array corners{QPointF(-v + shiftX, h + shiftY), QPointF(w + v + shiftX, h + shiftY), QPointF(w + hz + shiftX, -shiftY),
+ QPointF(-hz + shiftX, -shiftY)};
+ const QPointF center(w / 2 + shiftX, h / 2 + shiftY);
+ const double radians = rotation * std::numbers::pi / 180, cosine = std::cos(radians), sine = std::sin(radians);
+ for (QPointF &corner : corners) {
+ const double dx = corner.x() - center.x(), dy = corner.y() - center.y();
+ corner = QPointF(center.x() + dx * cosine - dy * sine, center.y() + dx * sine + dy * cosine);
+ }
+ if (aspectScale != 1)
+ for (QPointF &corner : corners)
+ corner = QPointF(center.x() + (corner.x() - center.x()) * aspectScale, center.y() + (corner.y() - center.y()) / aspectScale);
+ if (zoom != 1)
+ for (QPointF &corner : corners)
+ corner = center + (corner - center) * zoom;
+ return corners;
+}
+
+QImage CameraRawGeometrySettings::apply(const QImage &image) const
+{
+ const CameraRawGeometrySettings settings = normalized();
+ const int width = image.width(), height = image.height();
+ if (!settings.adjusts() || width <= 0 || height <= 0)
+ return image;
+ const auto [vertical, horizontal, rotate] = settings.effectiveCorrections();
+ const std::array corners = settings.outputCorners(width, height, vertical, horizontal, rotate);
+ // Core Image counts y upward: top-down, a corner's y flips.
+ QPolygonF target;
+ for (const QPointF &corner : corners)
+ target << QPointF(corner.x(), height - corner.y());
+ QTransform warp;
+ // Normalized settings keep the quad whole; else a bug.
+ if (!QTransform::quadToQuad(QPolygonF(QRectF(0, 0, width, height)).mid(0, 4), target, warp))
+ throw std::logic_error("the geometry's corners fold");
+ QImage result = BrushRaster::context(width, height, false);
+ {
+ QPainter painter(&result);
+ painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
+ painter.setTransform(warp);
+ painter.drawImage(QRectF(0, 0, width, height), image);
+ }
+ if (!settings.constrainCrop)
+ return result;
+ std::array edges{};
+ brush_alpha_bounds(result.constBits(), size_t(width), size_t(height), size_t(result.bytesPerLine()), edges.data());
+ const QRect crop(int(edges[0]), int(edges[1]), int(edges[2] - edges[0]), int(edges[3] - edges[1]));
+ if (crop.width() < 1 || crop.height() < 1 || (crop.width() >= width && crop.height() >= height))
+ return result;
+ const QImage cropped = result.copy(crop);
+ if (cropped.isNull())
+ throw ExportError(ExportError::Kind::render);
+ QImage fitted = BrushRaster::context(width, height, false);
+ const double fit = std::min(double(width) / crop.width(), double(height) / crop.height());
+ QPainter painter(&fitted);
+ BrushRaster::draw(cropped, QRectF((width - crop.width() * fit) / 2, (height - crop.height() * fit) / 2, crop.width() * fit, crop.height() * fit),
+ painter);
+ return fitted;
+}
+
+QString rawValue(CameraRawProcessVersion version)
+{
+ return QStringLiteral("Version %1").arg(int(version) + 1);
+}
+
+QString summary(CameraRawProcessVersion version)
+{
+ switch (version) {
+ case CameraRawProcessVersion::version1:
+ return QStringLiteral("Earliest response. Hue, saturation, and shadow tint move about half as far as Version 6.");
+ case CameraRawProcessVersion::version2:
+ return QStringLiteral("A little stronger than Version 1. The sliders below still fall well short of the current look.");
+ case CameraRawProcessVersion::version3:
+ return QStringLiteral("Firmer color than Version 2. Primary shifts stay gentler than the current process.");
+ case CameraRawProcessVersion::version4:
+ return QStringLiteral("The 2012 response. Calibration reaches most of the strength used by Version 6.");
+ case CameraRawProcessVersion::version5:
+ return QStringLiteral("Close to the current process, with slightly softer primary and shadow shifts.");
+ case CameraRawProcessVersion::version6:
+ return QStringLiteral("Current default. The calibration sliders below apply at full strength.");
+ }
+ throw std::logic_error("unknown process version");
+}
+
+bool CameraRawCalibrationSettings::adjusts() const
+{
+ return shadowTint != 0 || redHue != 0 || redSaturation != 0 || greenHue != 0 || greenSaturation != 0 || blueHue != 0 || blueSaturation != 0;
+}
+
+CameraRawCalibrationSettings CameraRawCalibrationSettings::normalized() const
+{
+ return {process,
+ clamp(shadowTint, -100, 100, 0),
+ clamp(redHue, -100, 100, 0),
+ clamp(redSaturation, -100, 100, 0),
+ clamp(greenHue, -100, 100, 0),
+ clamp(greenSaturation, -100, 100, 0),
+ clamp(blueHue, -100, 100, 0),
+ clamp(blueSaturation, -100, 100, 0)};
+}
+
+void CameraRawSettings::applyCalibration(uchar *pixels, int width, int height, qsizetype stride) const
+{
+ const CameraRawCalibrationSettings primaries = calibration.normalized();
+ if (!primaries.adjusts())
+ 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);
+}
diff --git a/src/Document/CameraRawGeometryCalibration.h b/src/Document/CameraRawGeometryCalibration.h
new file mode 100644
index 0000000..196f98b
--- /dev/null
+++ b/src/Document/CameraRawGeometryCalibration.h
@@ -0,0 +1,62 @@
+#pragma once
+#include
+#include
+#include
+#include
+#include
+
+// Swift's CameraRawGeometryCalibration.swift: Geometry, Calibration.
+enum class CameraRawUprightMode { off, guided };
+enum class CameraRawProjection { perspective, rectilinear };
+
+// A guide line, 0 to 1 from the lower left.
+struct CameraRawGeometryGuide {
+ double startX = 0;
+ double startY = 0;
+ double endX = 0;
+ double endY = 0;
+ friend bool operator==(const CameraRawGeometryGuide &, const CameraRawGeometryGuide &) = default;
+};
+
+struct CameraRawGeometrySettings {
+ CameraRawUprightMode upright = CameraRawUprightMode::off;
+ CameraRawProjection projection = CameraRawProjection::perspective;
+ double vertical = 0;
+ double horizontal = 0;
+ double rotate = 0;
+ double aspect = 0;
+ double scale = 0;
+ double offsetX = 0;
+ double offsetY = 0;
+ bool constrainCrop = false;
+ std::vector guides{};
+ bool adjusts() const;
+ CameraRawGeometrySettings normalized() const;
+ // Perspective and turn; Constrain Crop trims empty edges.
+ QImage apply(const QImage &image) const;
+ friend bool operator==(const CameraRawGeometrySettings &, const CameraRawGeometrySettings &) = default;
+
+private:
+ bool usesGuides() const;
+ std::array effectiveCorrections() const;
+ std::array outputCorners(int width, int height, double vertical, double horizontal, double rotation) const;
+};
+
+enum class CameraRawProcessVersion { version1, version2, version3, version4, version5, version6 };
+QString rawValue(CameraRawProcessVersion version);
+// What this process does to the calibration sliders.
+QString summary(CameraRawProcessVersion version);
+
+struct CameraRawCalibrationSettings {
+ CameraRawProcessVersion process = CameraRawProcessVersion::version6;
+ double shadowTint = 0;
+ double redHue = 0;
+ double redSaturation = 0;
+ double greenHue = 0;
+ double greenSaturation = 0;
+ double blueHue = 0;
+ double blueSaturation = 0;
+ bool adjusts() const;
+ CameraRawCalibrationSettings normalized() const;
+ friend bool operator==(const CameraRawCalibrationSettings &, const CameraRawCalibrationSettings &) = default;
+};
diff --git a/src/Document/EditorSession+Jobs.h b/src/Document/EditorSession+Jobs.h
index 84cc1be..a0f626a 100644
--- a/src/Document/EditorSession+Jobs.h
+++ b/src/Document/EditorSession+Jobs.h
@@ -100,6 +100,8 @@ struct SessionJobs {
std::optional failure;
// The job's settings, which the preview was made with.
FilterSettings settings;
+ // Camera Raw's scope of the grade itself.
+ std::optional scope = std::nullopt;
};
struct FilterMade {
std::optional asset;
diff --git a/src/Document/EditorSession.h b/src/Document/EditorSession.h
index b6c869e..871bf99 100644
--- a/src/Document/EditorSession.h
+++ b/src/Document/EditorSession.h
@@ -315,6 +315,7 @@ public:
#include "Document/LayerAdjustment+Session.h"
#include "Document/AdjustmentEditing+Session.h"
#include "Document/LayerEffects+Session.h"
+#include "Document/CameraRaw+Session.h"
#include "Document/Guides+Session.h"
#include "Document/ObjectSelection+Session.h"
signals:
diff --git a/src/Document/Filters+Session.cpp b/src/Document/Filters+Session.cpp
index 00ab7a5..d007030 100644
--- a/src/Document/Filters+Session.cpp
+++ b/src/Document/Filters+Session.cpp
@@ -114,6 +114,10 @@ void EditorSession::renderFilterPreview()
m_filterRendering = true;
m_filterPreview.setFuture(QtConcurrent::run([job]() -> Filtered {
try {
+ if (job.kind == FilterKind::cameraRaw) {
+ auto [image, scope] = CameraRawScope::preview(job);
+ return Filtered{std::move(image), std::nullopt, job.settings, std::move(scope)};
+ }
return Filtered{PixelFilter::run(job), std::nullopt, job.settings};
} catch (const ContentFillError &error) {
return Filtered{std::nullopt, QString::fromUtf8(error.what()), job.settings};
@@ -139,6 +143,8 @@ void EditorSession::finishFilterPreview()
FilterEdit &edit = *m_filterEdit;
edit.preparing = false;
edit.previewError = result.failure;
+ if (result.scope)
+ edit.rawPanel.scope = result.scope;
if (edit.preview || isAutomatic(edit.kind)) {
edit.preparedPreview = result.image;
edit.preparedSettings = result.settings;
@@ -191,10 +197,15 @@ void EditorSession::commitFilter(std::function done)
finish();
return;
}
+ // A hidden Camera Raw group stays out next time too.
+ const FilterSettings rendered = edit.renderSettings();
+ if (edit.kind == FilterKind::cameraRaw)
+ m_filterSettings = rendered;
// Nothing to change closes as Cancel does, with no step.
if ((edit.kind == FilterKind::lensCorrection && edit.settings.distortion == 0)
|| (edit.kind == FilterKind::exposure && edit.settings.exposure == ExposureSettings())
- || (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0)) {
+ || (edit.kind == FilterKind::grain && edit.settings.grain.amount == 0)
+ || (edit.kind == FilterKind::cameraRaw && rendered.cameraRaw.isIdentity())) {
cancelFilter();
finish();
return;
@@ -203,7 +214,8 @@ void EditorSession::commitFilter(std::function done)
// Swift cancels the running preview: its result and queue drop.
edit.pending.reset();
m_filterPreviewFor = QUuid();
- m_filterSettings = edit.settings;
+ if (edit.kind != FilterKind::cameraRaw)
+ m_filterSettings = edit.settings;
setIsProjectBusy(true);
m_committingFilter = Committing{edit.id, std::move(done)};
// A mask hides the background, which can come back.
@@ -214,7 +226,7 @@ void EditorSession::commitFilter(std::function done)
const std::optional cached = isAutomatic(edit.kind) && edit.preparedSettings == edit.settings ? edit.preparedPreview : std::nullopt;
const bool spreads = edit.kind == FilterKind::gaussianBlur || edit.kind == FilterKind::motionBlur;
// A painted layer flattens in the worker, which catches failures.
- m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = edit.settings,
+ m_filterCommit.setFuture(QtConcurrent::run([kind = edit.kind, original = edit.original, grownImage = edit.grownImage, settings = rendered,
selection = edit.selection, mapping = edit.mapping, seed = edit.seed, cached,
grown = edit.grownTransform, spreads]() -> FilterMade {
try {
diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp
index f0c411d..062004f 100644
--- a/src/Document/Filters.cpp
+++ b/src/Document/Filters.cpp
@@ -24,6 +24,7 @@ QString rawValue(FilterKind kind)
case FilterKind::motionBlur: return QStringLiteral("Motion Blur");
case FilterKind::addNoise: return QStringLiteral("Add Noise");
case FilterKind::lensCorrection: return QStringLiteral("Lens Correction");
+ case FilterKind::cameraRaw: return QStringLiteral("Camera Raw Filter");
case FilterKind::removeBackground: return QStringLiteral("Remove Background");
case FilterKind::contentAwareFill: return QStringLiteral("Content-Aware Fill");
case FilterKind::curves: return QStringLiteral("Curves");
@@ -67,6 +68,7 @@ FilterSettings FilterSettings::normalized() const
result.exposure = exposure.normalized();
result.gradientMap = gradientMap.normalized();
result.grain = grain.normalized();
+ result.cameraRaw = cameraRaw.normalized();
return result;
}
@@ -118,6 +120,9 @@ QImage PixelFilter::run(const FilterJob &job)
case FilterKind::gradientMap: image = settings.gradientMap.apply(job.image); break;
case FilterKind::blackWhite: image = settings.blackWhite.apply(job.image); break;
case FilterKind::colorBalance: image = settings.colorBalance.apply(job.image); break;
+ case FilterKind::cameraRaw:
+ image = settings.cameraRaw.apply(job.image, job.cameraRawClipping, job.scale, job.seed, job.visualizesPointColor, job.showsSharpenMask);
+ break;
// Grain sits in layer pixels; the job's seed patterns it.
case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break;
case FilterKind::removeBackground: image = SubjectRemoval::run(job.image, settings); break;
@@ -243,5 +248,24 @@ std::optional FilterEdit::previewImage(QUuid id) const
FilterJob FilterEdit::previewJob() const
{
- return FilterJob{kind, previewSource, settings, previewScale, selection, previewMapping, seed};
+ return FilterJob{kind,
+ previewSource,
+ renderSettings(),
+ previewScale,
+ selection,
+ previewMapping,
+ seed,
+ rawPanel.clipping,
+ rawPanel.showsShadowClipping,
+ rawPanel.showsHighlightClipping,
+ rawPanel.pointColorVisualizeIndex(settings.cameraRaw),
+ rawPanel.sharpenMask};
+}
+
+FilterSettings FilterEdit::renderSettings() const
+{
+ FilterSettings value = settings;
+ if (kind == FilterKind::cameraRaw)
+ value.cameraRaw = value.cameraRaw.applying(rawPanel.shows);
+ return value;
}
diff --git a/src/Document/Filters.h b/src/Document/Filters.h
index 798681b..fb35ec6 100644
--- a/src/Document/Filters.h
+++ b/src/Document/Filters.h
@@ -1,4 +1,5 @@
#pragma once
+#include "Document/CameraRaw.h"
#include "Document/Curves.h"
#include "Document/ImageAdjustments.h"
#include "Document/LayerTransform.h"
@@ -14,7 +15,10 @@
struct ImageLayer;
// The Filter and Image menus' filters.
-enum class FilterKind { gaussianBlur, motionBlur, addNoise, lensCorrection, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite, colorBalance };
+enum class FilterKind {
+ gaussianBlur, motionBlur, addNoise, lensCorrection, cameraRaw, removeBackground, contentAwareFill, curves, exposure, gradientMap, grain, blackWhite,
+ colorBalance
+};
// The menu's title, Swift's rawValue.
QString rawValue(FilterKind kind);
// Made without settings: its preview is its result.
@@ -23,7 +27,8 @@ bool isAutomatic(FilterKind kind);
bool isImageAdjustment(FilterKind kind);
// Swift's allCases, the menus' order.
inline constexpr std::array allFilterKinds{FilterKind::gaussianBlur, FilterKind::motionBlur, FilterKind::addNoise,
- FilterKind::lensCorrection, FilterKind::removeBackground, FilterKind::contentAwareFill,
+ FilterKind::lensCorrection, FilterKind::cameraRaw, FilterKind::removeBackground,
+ FilterKind::contentAwareFill,
FilterKind::curves, FilterKind::exposure, FilterKind::gradientMap,
FilterKind::grain, FilterKind::blackWhite, FilterKind::colorBalance};
@@ -52,6 +57,7 @@ struct FilterSettings {
GrainSettings grain{};
BlackWhiteSettings blackWhite{};
ColorBalanceSettings colorBalance{};
+ CameraRawSettings cameraRaw{};
BackgroundQuality backgroundQuality = BackgroundQuality::basic;
// How far, in pixels, the mask is pulled onto edges.
double refineEdges = 12;
@@ -74,6 +80,13 @@ struct FilterJob {
QTransform mapping;
// Add Noise's pattern: the same seed, the same grain.
quint32 seed = 0;
+ // Camera Raw's preview-only views; a commit leaves them off.
+ std::optional cameraRawClipping = std::nullopt;
+ bool showsShadowClipping = false;
+ bool showsHighlightClipping = false;
+ // The point colour shown alone, or −1 for the grade.
+ int visualizesPointColor = -1;
+ bool showsSharpenMask = false;
};
// Swift's PixelFilter.
@@ -106,6 +119,8 @@ public:
// Pads the grid for the blur; it only ever grows.
void growForBlur();
std::optional previewImage(QUuid id) const;
+ // The settings as rendered: a hidden Camera Raw group drops.
+ FilterSettings renderSettings() const;
FilterJob previewJob() const;
// Swift's object identity: a preview lands on its own edit.
@@ -135,6 +150,7 @@ public:
// The settings the prepared preview was made with.
std::optional preparedSettings;
std::optional pending;
+ CameraRawPanel rawPanel;
private:
void grow(const QRectF &extent);
diff --git a/src/UI/CompositorMenus.cpp b/src/UI/CompositorMenus.cpp
index 3b21dfd..3d1baae 100644
--- a/src/UI/CompositorMenus.cpp
+++ b/src/UI/CompositorMenus.cpp
@@ -215,7 +215,8 @@ CompositorMenus::CompositorMenus(ProjectWorkspace &workspace, QMenuBar &bar, QWi
QMenu *filter = bar.addMenu(QStringLiteral("Fil&ter"));
for (const FilterKind kind : allFilterKinds) {
- if (kind != FilterKind::contentAwareFill && !isImageAdjustment(kind))
+ // Camera Raw's entry arrives with its panel (12.8b).
+ if (kind != FilterKind::contentAwareFill && kind != FilterKind::cameraRaw && !isImageAdjustment(kind))
add(filter, filterName(kind), rawValue(kind) + QStringLiteral("…"), QKeySequence(), [this, kind] { session().beginFilter(kind); });
}
@@ -357,7 +358,7 @@ void CompositorMenus::synchronize()
action(QString::fromLatin1(name))->setEnabled(s.canModifySelection());
action(QStringLiteral("levels"))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
for (const FilterKind kind : allFilterKinds) {
- if (kind != FilterKind::contentAwareFill)
+ if (kind != FilterKind::contentAwareFill && kind != FilterKind::cameraRaw)
action(filterName(kind))->setEnabled(s.canAdjustColors() && !s.hueSaturation());
}
action(QStringLiteral("hueSaturation"))->setEnabled(s.canAdjustColors());
diff --git a/src/UI/FilterSheet.cpp b/src/UI/FilterSheet.cpp
index c831521..42424bd 100644
--- a/src/UI/FilterSheet.cpp
+++ b/src/UI/FilterSheet.cpp
@@ -182,6 +182,8 @@ FilterSheet::FilterSheet(EditorSession &session, QWidget *parent)
break;
case FilterKind::removeBackground: background(); break;
case FilterKind::blackWhite: blackWhite(); break;
+ // CameraRawControls arrive with the panel (12.8b).
+ case FilterKind::cameraRaw: break;
case FilterKind::colorBalance: colorBalance(); break;
case FilterKind::contentAwareFill:
m_column->addWidget(words(QStringLiteral("Fill the selection using surrounding pixels from this layer."), false, this));
diff --git a/tests/CameraRawColorTests.cpp b/tests/CameraRawColorTests.cpp
new file mode 100644
index 0000000..fec4af9
--- /dev/null
+++ b/tests/CameraRawColorTests.cpp
@@ -0,0 +1,173 @@
+#include "CameraRawFixtures.h"
+#include
+#include
+
+// Curve, Color Mixer and Color Grading, rule by rule.
+using namespace CameraRawFixtures;
+
+namespace {
+using Points = std::vector;
+
+Points repaired(const Points &points)
+{
+ CameraRawCurveSettings curve;
+ curve.rgb = points;
+ return curve.normalized().rgb;
+}
+
+int shadowTint(double balance)
+{
+ CameraRawSettings settings;
+ settings.grading.shadows.saturation = 100;
+ settings.grading.balance = balance;
+ const std::array pixel = pixels(settings.apply(image(4, 4, 0.12, 0.12, 0.12)))[0];
+ return pixel[0] - pixel[1];
+}
+}
+
+class CameraRawColorTests : public QObject {
+ Q_OBJECT
+private slots:
+ void curvesAreRepairedIntoOrder();
+ void theParametricCurveLiftsItsRegion();
+ void aNudgeMovesTheFirstNearestPointWithinRange();
+ void splitsAndAmountsStayInTheirRanges();
+ void mixerFamiliesOverlapRoundTheWheel();
+ void pointColorsStayInTheirRanges();
+ void gradingWheelsAndBalanceReachTheKernel();
+ void aPointColorReachesOnlyItsRange();
+ void everyGroupSaysWhenItAdjusts();
+};
+
+void CameraRawColorTests::curvesAreRepairedIntoOrder()
+{
+ QCOMPARE(repaired({{1, 1}, {0.5, 0.2}, {0, 0}}), (Points{{0, 0}, {0.5, 0.2}, {1, 1}}));
+ QCOMPARE(repaired({{0.3, 0.4}}), CameraRawCurveSettings::linear());
+ QCOMPARE(repaired({{0, 0}, {std::nan(""), 0.5}, {1, 1}}), CameraRawCurveSettings::linear());
+ // Ends pinned to 0 and 1, heights clamped.
+ QCOMPARE(repaired({{0.1, -0.5}, {0.9, 1.5}}), CameraRawCurveSettings::linear());
+ // Inner points keep a hundredth from ends and neighbours.
+ QCOMPARE(repaired({{0, 0}, {0.995, 0.5}, {1, 1}}), (Points{{0, 0}, {0.99, 0.5}, {1, 1}}));
+ QCOMPARE(repaired({{0, 0}, {0.5, 0.3}, {0.505, 0.6}, {1, 1}}), (Points{{0, 0}, {0.5, 0.3}, {1, 1}}));
+ QCOMPARE(repaired({{0, 0}, {0.004, 0.3}, {1, 1}}), CameraRawCurveSettings::linear());
+ // A curve of one point passes its input through.
+ CameraRawCurveSettings curve;
+ const std::vector table = curve.channelTable({{0.5, 0.7}});
+ QCOMPARE(table[100], float(100 / 255.0));
+}
+
+void CameraRawColorTests::theParametricCurveLiftsItsRegion()
+{
+ CameraRawCurveSettings curve;
+ curve.shadows = 100;
+ // The region's middle takes the whole 22%.
+ QVERIFY(std::abs(curve.parametric(0.125) - 0.345) < 1e-12);
+ QCOMPARE(curve.parametric(0.375), 0.375);
+ curve.highlights = -100;
+ curve.lightSplit = 100;
+ // A region of no width still takes a fiftieth's span.
+ QVERIFY(std::abs(curve.parametric(1) - 0.78) < 1e-12);
+ // Region picks the amount a tone moves.
+ CameraRawCurveSettings regions;
+ regions.region(0.1) = 1;
+ regions.region(0.3) = 2;
+ regions.region(0.6) = 3;
+ regions.region(0.9) = 4;
+ QVERIFY(regions.shadows == 1 && regions.darks == 2 && regions.lights == 3 && regions.highlights == 4);
+}
+
+void CameraRawColorTests::aNudgeMovesTheFirstNearestPointWithinRange()
+{
+ const CameraRawCurveSettings curve;
+ // Halfway between two points, the first is nearest.
+ QCOMPARE(curve.nudged(CameraRawPointChannel::green, 0.5, 0.2).green, (Points{{0, 0.2}, {1, 1}}));
+ QCOMPARE(curve.nudged(CameraRawPointChannel::blue, 0.9, 5).blue, (Points{{0, 0}, {1, 1}}));
+ QCOMPARE(curve.nudged(CameraRawPointChannel::rgb, 0.1, -5).rgb, (Points{{0, 0}, {1, 1}}));
+ QCOMPARE(curve.nudged(CameraRawPointChannel::rgb, 0.9, -0.25).rgb, (Points{{0, 0}, {1, 0.75}}));
+}
+
+void CameraRawColorTests::splitsAndAmountsStayInTheirRanges()
+{
+ CameraRawCurveSettings curve;
+ curve.shadowSplit = 25;
+ curve.darkSplit = 20;
+ curve.lightSplit = 26;
+ curve.refineSaturation = 150;
+ const CameraRawCurveSettings normal = curve.normalized();
+ QVERIFY(normal.darkSplit == 27 && normal.lightSplit == 29 && normal.refineSaturation == 100);
+ curve.shadowSplit = 1;
+ QCOMPARE(curve.normalized().shadowSplit, 5.0);
+}
+
+void CameraRawColorTests::mixerFamiliesOverlapRoundTheWheel()
+{
+ const std::array near = CameraRawMixerSettings::weights(350);
+ QCOMPARE(near[0], 0.75);
+ QCOMPARE(near[7], 0.0);
+ const std::array orange = CameraRawMixerSettings::weights(30);
+ QVERIFY(orange[0] == 0.25 && orange[1] == 1 && orange[2] == 0.25 && orange[3] == 0);
+ CameraRawMixerSettings mixer;
+ mixer.points = std::vector(9);
+ QCOMPARE(mixer.normalized().points.size(), size_t(8));
+}
+
+void CameraRawColorTests::pointColorsStayInTheirRanges()
+{
+ const CameraRawPointColor wild{.hue = 400, .saturation = 2, .luminance = -1, .hueShift = 300, .saturationShift = -300, .luminanceShift = 101,
+ .hueRange = 200, .saturationRange = 0.01, .luminanceRange = 2};
+ const CameraRawPointColor normal = wild.normalized();
+ QVERIFY(normal.hue == 360 && normal.saturation == 1 && normal.luminance == 0 && normal.hueShift == 100 && normal.saturationShift == -100);
+ QVERIFY(normal.luminanceShift == 100 && normal.hueRange == 180 && normal.saturationRange == 0.05 && normal.luminanceRange == 1);
+ QCOMPARE(CameraRawPointColor{.hueRange = 2}.normalized().hueRange, 5.0);
+ const CameraRawGradeWheel wheel = CameraRawGradeWheel{400, -20, -150}.normalized();
+ QVERIFY(wheel.hue == 360 && wheel.saturation == 0 && wheel.luminance == -100);
+ CameraRawGradingSettings grading;
+ grading.blending = 150;
+ grading.balance = -150;
+ QVERIFY(grading.normalized().blending == 100 && grading.normalized().balance == -100);
+}
+
+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.
+ 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);
+}
+
+void CameraRawColorTests::aPointColorReachesOnlyItsRange()
+{
+ CameraRawSettings settings;
+ settings.mixer.points = {CameraRawPointColor{.hue = 0, .saturation = 1, .luminance = 0.5, .luminanceShift = -100, .hueRange = 30}};
+ const std::array red = pixels(settings.apply(image(4, 4, 1, 0, 0)))[0];
+ QVERIFY(red[0] < 240);
+ // Fifty degrees off lies outside thirty either way.
+ const QImage orange = image(4, 4, 1, 50 / 60.0, 0);
+ QVERIFY(pixels(settings.apply(orange)) == pixels(orange));
+}
+
+void CameraRawColorTests::everyGroupSaysWhenItAdjusts()
+{
+ CameraRawCurveSettings refined;
+ refined.refineSaturation = 10;
+ CameraRawCurveSettings blue;
+ blue.blue = {{0, 0.1}, {1, 1}};
+ QVERIFY(refined.adjusts() && blue.adjusts() && !CameraRawCurveSettings().adjusts());
+ CameraRawMixerSettings lighter;
+ lighter.points = {CameraRawPointColor{.luminanceShift = 10}};
+ QVERIFY(lighter.adjusts() && !CameraRawMixerSettings{.points = {CameraRawPointColor{.hue = 40}}}.adjusts());
+ CameraRawGradingSettings brighter;
+ brighter.global.luminance = 10;
+ CameraRawGradingSettings balanced;
+ balanced.balance = 50;
+ QVERIFY(brighter.adjusts() && !balanced.adjusts());
+}
+
+QTEST_GUILESS_MAIN(CameraRawColorTests)
+#include "CameraRawColorTests.moc"
diff --git a/tests/CameraRawFixtures.h b/tests/CameraRawFixtures.h
new file mode 100644
index 0000000..03cce8e
--- /dev/null
+++ b/tests/CameraRawFixtures.h
@@ -0,0 +1,86 @@
+#pragma once
+#include "Document/BrushStroke.h"
+#include "Document/CameraRaw.h"
+#include
+#include
+#include
+#include
+#include
+
+// Swift's CameraRawTests helpers: solid pictures and straight bytes.
+namespace CameraRawFixtures {
+inline QImage image(int width, int height, double red, double green, double blue, double alpha = 1)
+{
+ QImage context = BrushRaster::context(width, height, false);
+ QPainter painter(&context);
+ painter.setCompositionMode(QPainter::CompositionMode_Source);
+ painter.fillRect(context.rect(), QColor::fromRgbF(float(red), float(green), float(blue), float(alpha)));
+ return context;
+}
+
+inline QImage gray(int width = 4, int height = 4, double alpha = 1)
+{
+ return image(width, height, 128 / 255.0, 128 / 255.0, 128 / 255.0, alpha);
+}
+
+// Straight RGBA, top row first, rounded as Swift's.
+inline std::vector> pixels(const QImage &source)
+{
+ const QImage drawn = source.convertToFormat(QImage::Format_RGBA8888_Premultiplied);
+ std::vector> result;
+ for (int y = 0; y < drawn.height(); ++y) {
+ const uchar *row = drawn.constScanLine(y);
+ for (int x = 0; x < drawn.width(); ++x) {
+ const uchar *p = row + x * 4;
+ const int alpha = p[3];
+ std::array pixel{0, 0, 0, alpha};
+ for (int channel = 0; channel < 3; ++channel)
+ pixel[size_t(channel)] = alpha == 0 ? 0 : std::min(255, (p[channel] * 255 + alpha / 2) / alpha);
+ result.push_back(pixel);
+ }
+ }
+ return result;
+}
+
+inline int chroma(const std::array &pixel)
+{
+ return std::max({pixel[0], pixel[1], pixel[2]}) - std::min({pixel[0], pixel[1], pixel[2]});
+}
+
+// Two tones side by side, one pixel each.
+inline QImage pair(int left, int right)
+{
+ QImage context = BrushRaster::context(2, 1, false);
+ context.setPixelColor(0, 0, QColor(left, left, left));
+ context.setPixelColor(1, 0, QColor(right, right, right));
+ return context;
+}
+
+// A step, 40 to 200 at x 12.
+inline QImage step()
+{
+ QImage context = BrushRaster::context(24, 4, false);
+ for (int y = 0; y < 4; ++y)
+ for (int x = 0; x < 24; ++x)
+ context.setPixelColor(x, y, x < 12 ? QColor(40, 40, 40) : QColor(200, 200, 200));
+ return context;
+}
+
+// A checkerboard of 2-pixel squares: a warp shows on it.
+inline QImage checker(int width = 24, int height = 24)
+{
+ QImage context = BrushRaster::context(width, height, false);
+ for (int y = 0; y < height; ++y)
+ for (int x = 0; x < width; ++x) {
+ const bool light = ((x / 2) + (y / 2)) % 2 == 0;
+ const double level = light ? 0.9 : 0.1;
+ context.setPixelColor(x, y, QColor::fromRgbF(float(level), float(level), float(level)));
+ }
+ return context;
+}
+
+inline int peakIndex(const std::array &bins)
+{
+ return int(std::max_element(bins.begin(), bins.end()) - bins.begin());
+}
+}
diff --git a/tests/CameraRawLensTests.cpp b/tests/CameraRawLensTests.cpp
new file mode 100644
index 0000000..fa87148
--- /dev/null
+++ b/tests/CameraRawLensTests.cpp
@@ -0,0 +1,189 @@
+#include "CameraRawFixtures.h"
+#include
+#include
+extern "C" {
+#include "AdjustPixels.h"
+}
+
+// Detail, Optics, Geometry and Calibration, rule by rule.
+using namespace CameraRawFixtures;
+
+namespace {
+// Stripes two pixels wide, mirrored about the middle column.
+QImage stripes(int side = 24)
+{
+ QImage picture = BrushRaster::context(side, side, false);
+ for (int y = 0; y < side; ++y)
+ for (int x = 0; x < side; ++x) {
+ const int from = std::min(x, side - 1 - x);
+ picture.setPixelColor(x, y, (from / 2) % 2 ? QColor(230, 230, 230) : QColor(20, 20, 20));
+ }
+ return picture;
+}
+
+int transitions(const QImage &picture, int y)
+{
+ int count = 0;
+ for (int x = 1; x < picture.width(); ++x)
+ count += (qRed(picture.pixel(x, y)) > 128) != (qRed(picture.pixel(x - 1, y)) > 128);
+ return count;
+}
+
+bool mirrored(const QImage &picture)
+{
+ for (int y = 0; y < picture.height(); ++y)
+ for (int x = 0; x < picture.width() / 2; ++x)
+ if (std::abs(qRed(picture.pixel(x, y)) - qRed(picture.pixel(picture.width() - 1 - x, y))) > 2)
+ return false;
+ return true;
+}
+
+QImage warped(const std::function &change, const QImage &picture = stripes())
+{
+ CameraRawGeometrySettings settings;
+ change(settings);
+ return settings.apply(picture);
+}
+}
+
+class CameraRawLensTests : public QObject {
+ Q_OBJECT
+private slots:
+ void detailAndOpticsSayWhenTheyAdjust();
+ void opticsStayInTheirRanges();
+ void theProfileAddsDistortionOnlyWhenOn();
+ void verticalWidensTheTopAndHorizontalTheBottom();
+ void aGuidedLineTurnsAsTheSameRotationWould();
+ void aSecondLineTiltsAQuarter();
+ void projectionAspectAndScaleShapeTheWarp();
+ void constrainCropFillsTheFrame();
+ void calibrationRunsAtItsProcessVersion();
+};
+
+void CameraRawLensTests::detailAndOpticsSayWhenTheyAdjust()
+{
+ QVERIFY(CameraRawDetailSettings{.noiseColor = 10}.adjusts() && CameraRawDetailSettings{.noiseLuminance = 10}.adjusts());
+ QVERIFY(CameraRawOpticsSettings{.enableLensProfile = true}.adjusts() && CameraRawOpticsSettings{.vignetteAmount = -20}.adjusts());
+ QVERIFY(!CameraRawDetailSettings().adjusts() && !CameraRawOpticsSettings().adjusts());
+ QVERIFY(CameraRawGeometrySettings{.aspect = 20}.adjusts() && !CameraRawGeometrySettings().adjusts());
+ // A guide a two-hundredth long reads as no line.
+ CameraRawGeometrySettings shortLine{.upright = CameraRawUprightMode::guided};
+ shortLine.guides = {{0.5, 0.5, 0.505, 0.5}};
+ QVERIFY(!shortLine.adjusts() && shortLine.normalized().guides.empty());
+ shortLine.guides = {{0.5, 0.5, 0.52, 0.5}};
+ QVERIFY(shortLine.adjusts());
+}
+
+void CameraRawLensTests::opticsStayInTheirRanges()
+{
+ QCOMPARE(CameraRawDetailSettings{.sharpenAmount = 140}.normalized().sharpenAmount, 140.0);
+ QCOMPARE(CameraRawDetailSettings{.sharpenAmount = 160}.normalized().sharpenAmount, 150.0);
+ const CameraRawOpticsSettings swapped = CameraRawOpticsSettings{.purpleHueLow = 320, .purpleHueHigh = 250, .greenHueLow = 130, .greenHueHigh = 40,
+ .vignetteAmount = -50}
+ .normalized();
+ QVERIFY(swapped.purpleHueLow == 250 && swapped.purpleHueHigh == 320 && swapped.greenHueLow == 40 && swapped.greenHueHigh == 130);
+ QCOMPARE(swapped.vignetteAmount, -50.0);
+ QCOMPARE(CameraRawGeometrySettings{.rotate = 60}.normalized().rotate, 45.0);
+ QCOMPARE(CameraRawCalibrationSettings{.process = CameraRawProcessVersion::version2}.normalized().process, CameraRawProcessVersion::version2);
+}
+
+void CameraRawLensTests::theProfileAddsDistortionOnlyWhenOn()
+{
+ CameraRawOpticsSettings optics{.profileDistortion = 100, .distortion = 50};
+ QCOMPARE(optics.distortionK(0.35), 0.175);
+ optics.enableLensProfile = true;
+ QCOMPARE(optics.distortionK(0.35), 0.175 + 0.35);
+}
+
+void CameraRawLensTests::verticalWidensTheTopAndHorizontalTheBottom()
+{
+ const QImage picture = stripes();
+ const QImage vertical = warped([](CameraRawGeometrySettings &g) { g.vertical = 60; });
+ QVERIFY(transitions(vertical, 1) < transitions(vertical, 22));
+ QVERIFY(mirrored(vertical));
+ const QImage horizontal = warped([](CameraRawGeometrySettings &g) { g.horizontal = 60; });
+ QVERIFY(transitions(horizontal, 22) < transitions(horizontal, 1));
+ QVERIFY(mirrored(horizontal));
+ QVERIFY(transitions(picture, 1) == transitions(picture, 22));
+}
+
+void CameraRawLensTests::aGuidedLineTurnsAsTheSameRotationWould()
+{
+ const QImage picture = checker(24, 24);
+ for (const double direction : {1.0, -1.0}) {
+ // Steep: 82.9° either way, levelled by a quarter turn back.
+ const QImage guided = warped(
+ [direction](CameraRawGeometrySettings &g) {
+ g.upright = CameraRawUprightMode::guided;
+ g.guides = {{0.5, 0.5, 0.6, 0.5 + 0.8 * direction}};
+ },
+ picture);
+ const double angle = std::atan2(0.8 * direction, 0.1) * 180 / std::numbers::pi;
+ const double turn = -angle + (direction > 0 ? 90 : -90);
+ const QImage turned = warped([turn](CameraRawGeometrySettings &g) { g.rotate = turn; }, picture);
+ QVERIFY(pixels(guided) == pixels(turned));
+ }
+}
+
+void CameraRawLensTests::aSecondLineTiltsAQuarter()
+{
+ const QImage picture = checker(24, 24);
+ const auto guided = [&](CameraRawGeometryGuide second) {
+ return warped(
+ [second](CameraRawGeometrySettings &g) {
+ g.upright = CameraRawUprightMode::guided;
+ g.guides = {{0.2, 0.5, 0.8, 0.5}, second};
+ },
+ picture);
+ };
+ const auto manual = [&](double vertical, double horizontal) {
+ return warped([=](CameraRawGeometrySettings &g) { g.vertical = vertical, g.horizontal = horizontal; }, picture);
+ };
+ QVERIFY(pixels(guided({0.5, 0.1, 0.55, 0.9})) == pixels(manual(25, 0)));
+ QVERIFY(pixels(guided({0.5, 0.9, 0.55, 0.1})) == pixels(manual(-25, 0)));
+ QVERIFY(pixels(guided({0.1, 0.5, 0.9, 0.6})) == pixels(manual(0, 25)));
+ QVERIFY(pixels(guided({0.1, 0.5, 0.9, 0.4})) == pixels(manual(0, -25)));
+}
+
+void CameraRawLensTests::projectionAspectAndScaleShapeTheWarp()
+{
+ const QImage picture = checker(24, 24);
+ const QImage rectilinear = warped(
+ [](CameraRawGeometrySettings &g) {
+ g.projection = CameraRawProjection::rectilinear;
+ g.vertical = 40;
+ },
+ picture);
+ QVERIFY(pixels(rectilinear) == pixels(warped([](CameraRawGeometrySettings &g) { g.vertical = 40 * 0.55; }, picture)));
+ QVERIFY(pixels(warped([](CameraRawGeometrySettings &g) { g.aspect = 50; }, picture)) != pixels(picture));
+ QVERIFY(pixels(warped([](CameraRawGeometrySettings &g) { g.scale = 50; }, picture)) != pixels(picture));
+}
+
+void CameraRawLensTests::constrainCropFillsTheFrame()
+{
+ // Shrunk to its middle, the picture leaves every edge empty.
+ const QImage open = warped([](CameraRawGeometrySettings &g) { g.scale = -50; });
+ QCOMPARE(qAlpha(open.pixel(0, 0)), 0);
+ const QImage cropped = warped([](CameraRawGeometrySettings &g) { g.scale = -50, g.constrainCrop = true; });
+ QVERIFY(qAlpha(cropped.pixel(0, 0)) > 0 && qAlpha(cropped.pixel(23, 0)) > 0);
+}
+
+void CameraRawLensTests::calibrationRunsAtItsProcessVersion()
+{
+ // Each primary on top, so every field shows.
+ QImage red = BrushRaster::context(3, 1, false);
+ red.setPixelColor(0, 0, QColor(230, 120, 40));
+ red.setPixelColor(1, 0, QColor(60, 210, 110));
+ red.setPixelColor(2, 0, QColor(90, 50, 220));
+ for (const CameraRawProcessVersion version : {CameraRawProcessVersion::version1, CameraRawProcessVersion::version4, CameraRawProcessVersion::version6}) {
+ CameraRawSettings settings;
+ settings.calibration = {version, 20, 80, 30, -40, 60, -70, 50};
+ 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);
+ QVERIFY(pixels(settings.apply(red)) == pixels(direct));
+ }
+ QCOMPARE(rawValue(CameraRawProcessVersion::version3), QString("Version 3"));
+}
+
+QTEST_GUILESS_MAIN(CameraRawLensTests)
+#include "CameraRawLensTests.moc"
diff --git a/tests/CameraRawRulesTests.cpp b/tests/CameraRawRulesTests.cpp
new file mode 100644
index 0000000..15b3e1e
--- /dev/null
+++ b/tests/CameraRawRulesTests.cpp
@@ -0,0 +1,228 @@
+#include "CameraRawFixtures.h"
+#include "Document/Filters.h"
+#include
+#include
+
+// The grade's rules that Swift's tests leave to its code.
+using namespace CameraRawFixtures;
+
+namespace {
+// Every group but Light and Color moved, visibly.
+CameraRawSettings everything()
+{
+ CameraRawSettings settings;
+ settings.whites = 100;
+ settings.curve.rgb = CameraRawCurveSettings::strongContrast();
+ settings.grading.shadows.saturation = 100;
+ settings.vignetteAmount = -100;
+ settings.detail.sharpenAmount = 150;
+ settings.optics.distortion = 100;
+ settings.geometry.vertical = 60;
+ settings.calibration.redHue = 80;
+ return settings;
+}
+
+QImage colourful()
+{
+ QImage picture = checker(16, 16);
+ for (int y = 0; y < 16; ++y)
+ for (int x = 0; x < 8; ++x)
+ picture.setPixelColor(x, y, QColor(200, 40 + 10 * y, 30));
+ return picture;
+}
+}
+
+class CameraRawRulesTests : public QObject {
+ Q_OBJECT
+private slots:
+ void boundsAndNormalizingCoverEveryField();
+ void anUntouchedGradeHandsTheImageBack();
+ void theClippingAndMaskViewsShowNothingElse();
+ void theScaleDefaultsToOneAndReachesTheRadii();
+ void eachEyeDropsItsOwnGroupAlone();
+ void grainSizeMapsIntoTheKernelsScale();
+ void autoBalanceSkipsClearPixels();
+ void thePreviewShowsItsViewAndCountsTheGrade();
+ void aPointColorShowsAloneOnlyWhenAsked();
+};
+
+void CameraRawRulesTests::boundsAndNormalizingCoverEveryField()
+{
+ // Every tone clamps to ±100, every unit to 0–100.
+ CameraRawSettings past;
+ const std::array tones{&CameraRawSettings::contrast, &CameraRawSettings::highlights, &CameraRawSettings::shadows, &CameraRawSettings::whites,
+ &CameraRawSettings::blacks, &CameraRawSettings::temperature, &CameraRawSettings::tint, &CameraRawSettings::vibrance,
+ &CameraRawSettings::saturation, &CameraRawSettings::texture, &CameraRawSettings::clarity, &CameraRawSettings::dehaze,
+ &CameraRawSettings::glowRange, &CameraRawSettings::glowSpread, &CameraRawSettings::glowWarmth, &CameraRawSettings::vignetteAmount,
+ &CameraRawSettings::vignetteRoundness};
+ const std::array units{&CameraRawSettings::glow, &CameraRawSettings::vignetteMidpoint, &CameraRawSettings::vignetteFeather,
+ &CameraRawSettings::vignetteHighlights, &CameraRawSettings::grainAmount, &CameraRawSettings::grainSize,
+ &CameraRawSettings::grainRoughness};
+ for (double CameraRawSettings::*field : tones)
+ past.*field = 150;
+ for (double CameraRawSettings::*field : units)
+ past.*field = 150;
+ past.exposure = 9;
+ CameraRawSettings clamped = past.normalized();
+ for (double CameraRawSettings::*field : tones)
+ QCOMPARE(clamped.*field, 100.0);
+ for (double CameraRawSettings::*field : units)
+ QCOMPARE(clamped.*field, 100.0);
+ QCOMPARE(clamped.exposure, 5.0);
+ for (double CameraRawSettings::*field : tones)
+ past.*field = -150;
+ for (double CameraRawSettings::*field : units)
+ past.*field = -150;
+ past.exposure = -9;
+ clamped = past.normalized();
+ for (double CameraRawSettings::*field : tones)
+ QCOMPARE(clamped.*field, -100.0);
+ for (double CameraRawSettings::*field : units)
+ QCOMPARE(clamped.*field, 0.0);
+ QCOMPARE(clamped.exposure, -5.0);
+ CameraRawSettings wild;
+ wild.vignetteMidpoint = std::nan("");
+ wild.grainSize = 400;
+ wild.grainRoughness = -3;
+ wild.vignetteFeather = 250;
+ wild.glow = 101;
+ const CameraRawSettings normal = wild.normalized();
+ QVERIFY(normal.vignetteMidpoint == 50 && normal.grainSize == 100 && normal.grainRoughness == 0 && normal.vignetteFeather == 100 && normal.glow == 100);
+}
+
+void CameraRawRulesTests::anUntouchedGradeHandsTheImageBack()
+{
+ const QImage input = gray();
+ QCOMPARE(CameraRawSettings().apply(input).cacheKey(), input.cacheKey());
+ CameraRawSettings moved;
+ moved.exposure = 1;
+ QVERIFY(moved.apply(input).cacheKey() != input.cacheKey());
+}
+
+void CameraRawRulesTests::theClippingAndMaskViewsShowNothingElse()
+{
+ const QImage picture = colourful();
+ CameraRawSettings light;
+ light.whites = 100;
+ 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)));
+ // An edge off the centre, where the lens moves it.
+ QImage edge = image(24, 24, 0.9, 0.9, 0.9);
+ for (int y = 0; y < 24; ++y)
+ for (int x = 0; x < 5; ++x)
+ edge.setPixelColor(x, y, QColor(40, 40, 40));
+ QVERIFY(pixels(all.apply(edge, CameraRawClipping::highlights)) == pixels(light.apply(edge, CameraRawClipping::highlights)));
+ // The mask reads the Light grade, as Swift's does.
+ CameraRawSettings sharpening = light;
+ sharpening.detail = all.detail;
+ sharpening.optics = all.optics;
+ QVERIFY(pixels(all.apply(picture, std::nullopt, 1, 0, -1, true)) == pixels(sharpening.apply(picture, std::nullopt, 1, 0, -1, true)));
+ // Showing a point colour leaves the geometry out.
+ CameraRawSettings warped;
+ warped.geometry.vertical = 60;
+ warped.mixer.points = {CameraRawPointColor{.hue = 10, .saturation = 0.8, .luminance = 0.5, .visualize = true}};
+ CameraRawSettings flat = warped;
+ flat.geometry = CameraRawGeometrySettings();
+ QVERIFY(pixels(warped.apply(picture, std::nullopt, 1, 0, 0)) == pixels(flat.apply(picture, std::nullopt, 1, 0, 0)));
+ QVERIFY(pixels(warped.apply(picture)) != pixels(flat.apply(picture)));
+}
+
+void CameraRawRulesTests::theScaleDefaultsToOneAndReachesTheRadii()
+{
+ CameraRawSettings settings;
+ settings.clarity = 100;
+ const QImage edge = step();
+ QVERIFY(pixels(settings.apply(edge, std::nullopt, 0)) == pixels(settings.apply(edge, std::nullopt, 1)));
+ QVERIFY(pixels(settings.apply(edge, std::nullopt, -2)) == pixels(settings.apply(edge, std::nullopt, 1)));
+ QVERIFY(pixels(settings.apply(edge, std::nullopt, 0.25)) != pixels(settings.apply(edge, std::nullopt, 1)));
+ // Grain's units read the scale: a negative one is one.
+ CameraRawSettings grainy;
+ grainy.grainAmount = 60;
+ QVERIFY(pixels(grainy.apply(gray(16, 16), std::nullopt, -2, 3)) == pixels(grainy.apply(gray(16, 16), std::nullopt, 1, 3)));
+}
+
+void CameraRawRulesTests::eachEyeDropsItsOwnGroupAlone()
+{
+ const CameraRawSettings all = [] {
+ CameraRawSettings settings = everything();
+ settings.exposure = 1;
+ settings.temperature = 20;
+ settings.texture = 30;
+ settings.mixer.hue[0] = 50;
+ return settings;
+ }();
+ const CameraRawSettings color = all.applying({.color = false});
+ QVERIFY(color.temperature == 0 && color.saturation == 0 && color.exposure == 1);
+ CameraRawSettings saturated = all;
+ saturated.saturation = 40;
+ QCOMPARE(saturated.applying({.color = false}).saturation, 0.0);
+ const std::vector>> groups{
+ {{.mixer = false}, [](const CameraRawSettings &s) { return s.mixer == CameraRawMixerSettings(); }},
+ {{.grading = false}, [](const CameraRawSettings &s) { return s.grading == CameraRawGradingSettings(); }},
+ {{.optics = false}, [](const CameraRawSettings &s) { return s.optics == CameraRawOpticsSettings(); }},
+ {{.geometry = false}, [](const CameraRawSettings &s) { return s.geometry == CameraRawGeometrySettings(); }}};
+ for (const auto &[shown, dropped] : groups) {
+ const CameraRawSettings hidden = all.applying(shown);
+ QVERIFY(dropped(hidden));
+ QVERIFY(hidden.exposure == 1 && hidden.curve == all.curve && hidden.texture == 30);
+ }
+}
+
+void CameraRawRulesTests::grainSizeMapsIntoTheKernelsScale()
+{
+ CameraRawSettings settings;
+ QCOMPARE(settings.grainKernelSize(), 0.5 + 0.25 * 19.5);
+ settings.grainSize = 100;
+ QCOMPARE(settings.grainKernelSize(), 20.0);
+ settings.grainSize = 0;
+ QCOMPARE(settings.grainKernelSize(), 0.5);
+}
+
+void CameraRawRulesTests::autoBalanceSkipsClearPixels()
+{
+ const double red = 160 / 255.0, green = 140 / 255.0, blue = 120 / 255.0;
+ QImage half = image(8, 8, red, green, blue);
+ for (int y = 0; y < 8; ++y)
+ for (int x = 0; x < 4; ++x)
+ half.setPixelColor(x, y, Qt::transparent);
+ const CameraRawSettings::Balance solid = CameraRawSettings::autoBalance(image(8, 8, red, green, blue)).value();
+ const CameraRawSettings::Balance cut = CameraRawSettings::autoBalance(half).value();
+ QVERIFY(std::abs(cut.temperature - solid.temperature) < 1e-9 && std::abs(cut.tint - solid.tint) < 1e-9);
+ QVERIFY(!CameraRawSettings::autoBalance(image(8, 8, red, green, blue, 0)));
+}
+
+void CameraRawRulesTests::thePreviewShowsItsViewAndCountsTheGrade()
+{
+ FilterSettings settings;
+ settings.cameraRaw.whites = 100;
+ const QImage picture = pair(230, 20);
+ FilterJob job{FilterKind::cameraRaw, picture, settings, 1, std::nullopt, QTransform()};
+ const auto [graded, gradedScope] = CameraRawScope::preview(job);
+ QVERIFY(pixels(graded) == pixels(settings.cameraRaw.apply(picture)));
+ QVERIFY(gradedScope == CameraRawScope::make(graded).value());
+ job.cameraRawClipping = CameraRawClipping::highlights;
+ const auto [view, viewScope] = CameraRawScope::preview(job);
+ QVERIFY(pixels(view) == pixels(settings.cameraRaw.apply(picture, CameraRawClipping::highlights)));
+ QVERIFY(viewScope == gradedScope);
+ job.cameraRawClipping = std::nullopt;
+ job.showsHighlightClipping = true;
+ const auto [marked, markedScope] = CameraRawScope::preview(job);
+ QVERIFY(pixels(marked) == pixels(CameraRawScope::overlay(graded, false, true)) && pixels(marked) != pixels(graded));
+ QVERIFY(markedScope == gradedScope);
+}
+
+void CameraRawRulesTests::aPointColorShowsAloneOnlyWhenAsked()
+{
+ CameraRawSettings settings;
+ settings.mixer.points = {CameraRawPointColor{.hue = 10}, CameraRawPointColor{.hue = 200, .visualize = true}};
+ CameraRawPanel panel;
+ QCOMPARE(panel.pointColorVisualizeIndex(settings), -1);
+ panel.pointIndex = 1;
+ QCOMPARE(panel.pointColorVisualizeIndex(settings), 1);
+ panel.pointIndex = 2;
+ QCOMPARE(panel.pointColorVisualizeIndex(settings), -1);
+}
+
+QTEST_GUILESS_MAIN(CameraRawRulesTests)
+#include "CameraRawRulesTests.moc"
diff --git a/tests/CameraRawSessionTests.cpp b/tests/CameraRawSessionTests.cpp
new file mode 100644
index 0000000..da31650
--- /dev/null
+++ b/tests/CameraRawSessionTests.cpp
@@ -0,0 +1,236 @@
+#include "CameraRawFixtures.h"
+#include "Document/EditorSession.h"
+#include
+
+// Swift's CameraRawTests through the session, and geometry.
+using namespace CameraRawFixtures;
+
+namespace {
+bool awaited(const std::function)> &start)
+{
+ bool done = false;
+ start([&done] { done = true; });
+ return QTest::qWaitFor([&done] { return done; }, 20000);
+}
+
+std::unique_ptr opened(const QImage &image)
+{
+ auto session = std::make_unique();
+ session->createDocument(image.width(), image.height());
+ session->insert(ImportedImage(image, image, QStringLiteral("Picture")));
+ session->beginFilter(FilterKind::cameraRaw);
+ return session;
+}
+
+QImage layerImage(const EditorSession &session)
+{
+ return session.activeLayer().value().asset.value().image();
+}
+
+// Swift's `edit.samplesWhiteBalance = true`, through the panel.
+void arm(EditorSession &session, const std::function &change)
+{
+ CameraRawPanel panel = session.filterEdit().value().rawPanel;
+ change(panel);
+ session.setCameraRawPanel(panel);
+}
+}
+
+class CameraRawSessionTests : public QObject {
+ Q_OBJECT
+private slots:
+ void eyedropperAndAutoNeutralizeAWarmPixel();
+ void autoWhiteBalanceNeutralizesAfterTheScan();
+ void hiddenGroupIsLeftOutAndOkIsOneUndoOrNone();
+ void hiddenGroupStaysOutOfTheNextOpen();
+ void geometryWarpAndCalibrationPrimaries();
+ void thePreviewCarriesThePanelsViewsAndItsScope();
+ void guidedUprightFollowsADrawnLineAndLeavesAnUnguidedPicture();
+};
+
+void CameraRawSessionTests::eyedropperAndAutoNeutralizeAWarmPixel()
+{
+ const double red = 160 / 255.0, green = 140 / 255.0, blue = 120 / 255.0;
+ const QImage warm = image(8, 8, red, green, blue);
+ const std::array before = pixels(warm)[0];
+ const CameraRawSettings::Balance solved = CameraRawSettings::neutralizeStraight(red, green, blue).value();
+ CameraRawSettings settings;
+ settings.temperature = solved.temperature;
+ settings.tint = solved.tint;
+ QVERIFY(chroma(pixels(settings.apply(warm))[0]) < chroma(before) / 2);
+ const CameraRawSettings::Balance automatic = CameraRawSettings::autoBalance(warm).value();
+ settings.temperature = automatic.temperature;
+ settings.tint = automatic.tint;
+ QVERIFY(chroma(pixels(settings.apply(warm))[0]) < chroma(before) / 2);
+ const auto session = opened(warm);
+ arm(*session, [](CameraRawPanel &panel) { panel.samplesWhiteBalance = true; });
+ session->sampleCameraRawWhiteBalance(QPointF(1, 1));
+ const FilterEdit &edit = session->filterEdit().value();
+ QVERIFY(edit.settings.cameraRaw.whiteBalance == CameraRawWhiteBalance::custom);
+ QVERIFY(edit.settings.cameraRaw.temperature != 0 || edit.settings.cameraRaw.tint != 0);
+ QVERIFY2(chroma(pixels(edit.renderSettings().cameraRaw.apply(warm))[0]) < chroma(before) / 2, "the canvas sample neutralizes");
+ session->cancelFilter();
+ // A transparent pixel is ignored.
+ const auto empty = opened(image(8, 8, 1, 0, 0, 0));
+ const FilterSettings untouched = empty->filterEdit().value().settings;
+ arm(*empty, [](CameraRawPanel &panel) { panel.samplesWhiteBalance = true; });
+ empty->sampleCameraRawWhiteBalance(QPointF(1, 1));
+ QVERIFY(empty->filterEdit().value().settings == untouched);
+}
+
+void CameraRawSessionTests::autoWhiteBalanceNeutralizesAfterTheScan()
+{
+ const QImage warm = image(8, 8, 160 / 255.0, 140 / 255.0, 120 / 255.0);
+ const std::array before = pixels(warm)[0];
+ const auto session = opened(warm);
+ QVERIFY(awaited([&](std::function done) { session->applyCameraRawAutoWhiteBalance(std::move(done)); }));
+ const CameraRawSettings raw = session->filterEdit().value().settings.cameraRaw;
+ QVERIFY(raw.whiteBalance == CameraRawWhiteBalance::automatic);
+ QVERIFY(chroma(pixels(raw.apply(warm))[0]) < chroma(before) / 2);
+ session->cancelFilter();
+ const auto empty = opened(image(8, 8, 1, 0, 0, 0));
+ QVERIFY(awaited([&](std::function done) { empty->applyCameraRawAutoWhiteBalance(std::move(done)); }));
+ const CameraRawSettings untouched = empty->filterEdit().value().settings.cameraRaw;
+ QVERIFY(untouched.whiteBalance == CameraRawWhiteBalance::automatic);
+ QVERIFY2(untouched.temperature == 0 && untouched.tint == 0, "a layer with no coverage leaves the sliders alone");
+}
+
+void CameraRawSessionTests::hiddenGroupIsLeftOutAndOkIsOneUndoOrNone()
+{
+ const QImage input = gray(8, 8);
+ CameraRawSettings settings;
+ settings.exposure = 1;
+ settings.temperature = 40;
+ const CameraRawSettings hiddenLight = settings.applying({.light = false});
+ QVERIFY(pixels(hiddenLight.apply(input)) != pixels(input));
+ QVERIFY(hiddenLight.exposure == 0 && hiddenLight.temperature == 40);
+ const CameraRawSettings neither = settings.applying({.light = false, .color = false});
+ QVERIFY(neither.isIdentity() && pixels(neither.apply(input)) == pixels(input));
+ const auto session = opened(input);
+ const auto original = pixels(input);
+ int count = session->history.undoCount();
+ QVERIFY(awaited([&](std::function done) { session->commitFilter(std::move(done)); }));
+ QVERIFY2(!session->filterEdit() && session->history.undoCount() == count, "an unchanged grade is not an edit");
+ QVERIFY(pixels(layerImage(*session)) == original);
+ // An eye turned off drops that group.
+ const auto exposed = [&] {
+ session->beginFilter(FilterKind::cameraRaw);
+ FilterSettings edited = session->filterEdit().value().settings;
+ edited.cameraRaw.exposure = 1;
+ session->updateFilter(edited, true);
+ };
+ exposed();
+ arm(*session, [](CameraRawPanel &panel) { panel.shows.light = false; });
+ count = session->history.undoCount();
+ QVERIFY(awaited([&](std::function done) { session->commitFilter(std::move(done)); }));
+ QCOMPARE(session->history.undoCount(), count);
+ QVERIFY(pixels(layerImage(*session)) == original);
+ exposed();
+ session->cancelFilter();
+ QVERIFY(!session->filterEdit() && pixels(layerImage(*session)) == original);
+ exposed();
+ count = session->history.undoCount();
+ QVERIFY(awaited([&](std::function done) { session->commitFilter(std::move(done)); }));
+ QVERIFY(session->history.undoCount() == count + 1 && session->history.undoName() == "Camera Raw Filter");
+ QVERIFY2(std::abs(pixels(layerImage(*session))[0][0] - 176) <= 2, "OK bakes the grade");
+}
+
+void CameraRawSessionTests::hiddenGroupStaysOutOfTheNextOpen()
+{
+ const QImage input = gray(8, 8);
+ const auto session = opened(input);
+ FilterSettings edited = session->filterEdit().value().settings;
+ edited.cameraRaw.exposure = 1;
+ edited.cameraRaw.temperature = 40;
+ session->updateFilter(edited, true);
+ arm(*session, [](CameraRawPanel &panel) { panel.shows.light = false; });
+ QVERIFY(awaited([&](std::function done) { session->commitFilter(std::move(done)); }));
+ // OK bakes the grade, the hidden Light left out.
+ CameraRawSettings colour;
+ colour.temperature = 40;
+ QVERIFY(pixels(layerImage(*session)) == pixels(colour.apply(input)));
+ session->beginFilter(FilterKind::cameraRaw);
+ const CameraRawSettings restored = session->filterEdit().value().settings.cameraRaw;
+ QVERIFY(restored.exposure == 0 && restored.temperature == 40);
+ session->cancelFilter();
+}
+
+void CameraRawSessionTests::thePreviewCarriesThePanelsViewsAndItsScope()
+{
+ QImage picture = step();
+ for (int y = 0; y < 4; ++y)
+ for (int x = 0; x < 24; ++x)
+ picture.setPixelColor(x, y, x < 12 ? QColor(5, 5, 5) : QColor(250, 200, 240));
+ const auto session = opened(picture);
+ FilterSettings settings = session->filterEdit().value().settings;
+ settings.cameraRaw.whites = 100;
+ settings.cameraRaw.blacks = -100;
+ session->updateFilter(settings, true);
+ QTRY_VERIFY(!session->filterEdit().value().preparing);
+ const QImage graded = session->filterEdit().value().preparedPreview.value();
+ QVERIFY(pixels(graded) == pixels(settings.cameraRaw.apply(picture)));
+ QVERIFY(session->filterEdit().value().rawPanel.scope == CameraRawScope::make(graded));
+ // The panel's views reach the preview, never the scope.
+ const std::optional scope = CameraRawScope::make(graded);
+ const auto shown = [&](const std::function &change) {
+ arm(*session, change);
+ session->updateFilter(session->filterEdit().value().settings, true);
+ [[maybe_unused]] const bool landed = QTest::qWaitFor([&] { return !session->filterEdit().value().preparing; }, 5000);
+ return std::pair(pixels(session->filterEdit().value().preparedPreview.value()), session->filterEdit().value().rawPanel.scope == scope);
+ };
+ const std::vector, std::vector>>> views{
+ {[](CameraRawPanel &p) { p.clipping = CameraRawClipping::highlights; }, pixels(settings.cameraRaw.apply(picture, CameraRawClipping::highlights))},
+ {[](CameraRawPanel &p) { p.clipping.reset(); p.showsHighlightClipping = true; }, pixels(CameraRawScope::overlay(graded, false, true))},
+ {[](CameraRawPanel &p) { p.showsHighlightClipping = false; p.showsShadowClipping = true; }, pixels(CameraRawScope::overlay(graded, true, false))},
+ {[](CameraRawPanel &p) { p.showsShadowClipping = false; p.sharpenMask = true; },
+ pixels(settings.cameraRaw.apply(picture, std::nullopt, 1, 0, -1, true))},
+ };
+ for (size_t index = 0; index < views.size(); ++index) {
+ const auto [preview, scoped] = shown(views[index].first);
+ QVERIFY(preview == views[index].second && scoped);
+ QVERIFY2(preview != pixels(graded), qPrintable(QString::number(index)));
+ }
+ // A chosen point colour shows alone while it asks to.
+ FilterSettings pointed = settings;
+ pointed.cameraRaw.mixer.points = {CameraRawPointColor{.hue = 10, .saturation = 1, .luminance = 0.5, .visualize = true}};
+ session->updateFilter(pointed, true);
+ const auto alone = shown([](CameraRawPanel &p) { p.sharpenMask = false; }).first;
+ QVERIFY(alone == pixels(pointed.cameraRaw.apply(picture, std::nullopt, 1, 0, 0)) && alone != pixels(graded));
+ QVERIFY(session->filterEdit().value().rawPanel.scope == CameraRawScope::make(pointed.cameraRaw.apply(picture)));
+ // Settings arrive normalized: exposure stops at five.
+ pointed.cameraRaw.exposure = 9;
+ session->updateFilter(pointed, true);
+ QCOMPARE(session->filterEdit().value().settings.cameraRaw.exposure, 5.0);
+ session->updateFilter(settings, true);
+ // An eye turned off keeps its group out of view.
+ QVERIFY(shown([](CameraRawPanel &p) { p.sharpenMask = false; p.shows.light = false; }).first == pixels(picture));
+}
+
+void CameraRawSessionTests::geometryWarpAndCalibrationPrimaries()
+{
+ const QImage grid = checker(12, 12);
+ CameraRawSettings settings;
+ settings.geometry.vertical = 40;
+ QVERIFY(pixels(settings.geometry.apply(grid)) != pixels(grid));
+ settings = CameraRawSettings();
+ settings.calibration.redHue = 80;
+ const QImage red = image(4, 4, 1, 0, 0);
+ const std::array before = pixels(red)[0];
+ QVERIFY2(pixels(settings.apply(red))[0] != before, "calibration shifts a pure red");
+ QVERIFY(pixels(settings.applying({.calibration = false}).apply(red))[0] == before);
+}
+
+void CameraRawSessionTests::guidedUprightFollowsADrawnLineAndLeavesAnUnguidedPicture()
+{
+ const QImage cool = image(16, 16, 0.2, 0.45, 0.8), warm = image(16, 16, 0.85, 0.25, 0.15);
+ CameraRawSettings settings;
+ settings.geometry.upright = CameraRawUprightMode::guided;
+ QVERIFY(pixels(settings.apply(cool)) == pixels(cool));
+ QVERIFY(pixels(settings.apply(warm)) == pixels(warm));
+ settings.geometry.guides = {CameraRawGeometryGuide{0.1, 0.15, 0.9, 0.8}};
+ const auto coolGuided = pixels(settings.apply(cool)), warmGuided = pixels(settings.apply(warm));
+ QVERIFY(coolGuided != pixels(cool) && warmGuided != pixels(warm) && coolGuided != warmGuided);
+}
+
+QTEST_GUILESS_MAIN(CameraRawSessionTests)
+#include "CameraRawSessionTests.moc"
diff --git a/tests/CameraRawTests.cpp b/tests/CameraRawTests.cpp
new file mode 100644
index 0000000..270c0d3
--- /dev/null
+++ b/tests/CameraRawTests.cpp
@@ -0,0 +1,310 @@
+#include "CameraRawFixtures.h"
+#include "Document/Filters.h"
+#include
+#include
+
+// Swift's CameraRawTests: the grade on pixels, group by group.
+using namespace CameraRawFixtures;
+
+class CameraRawTests : public QObject {
+ Q_OBJECT
+private slots:
+ void defaultsLeavePixelsAndAlphaAlone();
+ void exposureAddsOneStopAndContrastPivotsAroundMidGray();
+ void tonalSlidersMoveTheEndTheyName();
+ void temperatureWarmsAndTintMovesTowardMagenta();
+ void vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesNot();
+ void textureAndClaritySharpenAnEdgeAndLeaveAFlatField();
+ void dehazeDeepensOrLiftsAndKeepsAlpha();
+ void glowIsIdleAtZeroAndHalationFringeIsRedderThanDiffusion();
+ void vignetteDarkensCornersAndHighlightsOnlyWhileDarkening();
+ void grainIsStableAndTheEffectsEyeDropsTheWholeGroup();
+ void histogramFollowsTheGradeAndClippingPaintStaysOffTheResult();
+ void curveMixerAndGradingChangeOnlyTheirOwnTones();
+ void detailSharpeningNoiseAndMaskingPreview();
+ void opticsDistortionDefringeAndDetailEye();
+};
+
+void CameraRawTests::defaultsLeavePixelsAndAlphaAlone()
+{
+ const QImage input = gray(4, 4, 0.5);
+ QVERIFY(pixels(CameraRawSettings().apply(input)) == pixels(input));
+ CameraRawSettings broken;
+ broken.exposure = std::nan("");
+ broken.temperature = 400;
+ QVERIFY(broken.normalized().exposure == 0 && broken.normalized().temperature == 100);
+ QVERIFY(!isImageAdjustment(FilterKind::cameraRaw));
+}
+
+void CameraRawTests::exposureAddsOneStopAndContrastPivotsAroundMidGray()
+{
+ const QImage input = gray();
+ CameraRawSettings settings;
+ settings.exposure = 1;
+ const std::array brighter = pixels(settings.apply(input))[0];
+ QVERIFY2(std::abs(brighter[0] - 176) <= 2, qPrintable(QString::number(brighter[0])));
+ QVERIFY(brighter[0] == brighter[1] && brighter[1] == brighter[2]);
+ const QImage translucent = gray(4, 4, 0.5);
+ QCOMPARE(pixels(settings.apply(translucent))[0][3], pixels(translucent)[0][3]);
+ settings = CameraRawSettings();
+ settings.contrast = 100;
+ const auto pushed = pixels(settings.apply(pair(64, 192)));
+ QVERIFY2(pushed[0][0] < 10 && pushed[1][0] > 250, "contrast +100 drives the pair apart");
+ settings.contrast = -100;
+ const auto flat = pixels(settings.apply(pair(64, 192)));
+ QVERIFY2(std::abs(flat[0][0] - 128) <= 2 && std::abs(flat[1][0] - 128) <= 2, "contrast −100 meets at mid gray");
+}
+
+void CameraRawTests::tonalSlidersMoveTheEndTheyName()
+{
+ const QImage brightAndMid = pair(230, 128);
+ CameraRawSettings settings;
+ settings.highlights = -100;
+ const auto recovered = pixels(settings.apply(brightAndMid));
+ QVERIFY(recovered[0][0] < 200 && std::abs(recovered[1][0] - 128) <= 2);
+ settings = CameraRawSettings();
+ settings.whites = 100;
+ const auto clipped = pixels(settings.apply(brightAndMid));
+ QVERIFY(clipped[0][0] == 255 && std::abs(clipped[1][0] - 128) <= 2);
+ const auto view = pixels(settings.apply(brightAndMid, CameraRawClipping::highlights));
+ QVERIFY2((view[0] == std::array{255, 255, 255, 255}) && (view[1] == std::array{0, 0, 0, 255}), "highlight clipping is not the grade");
+ const QImage darkAndMid = pair(20, 128);
+ settings = CameraRawSettings();
+ settings.shadows = 100;
+ const auto opened = pixels(settings.apply(darkAndMid));
+ QVERIFY(opened[0][0] > 50 && std::abs(opened[1][0] - 128) <= 2);
+ settings = CameraRawSettings();
+ settings.blacks = -100;
+ const auto crushed = pixels(settings.apply(darkAndMid));
+ QVERIFY(crushed[0][0] < 20 && std::abs(crushed[1][0] - 128) <= 2);
+ const auto shadowView = pixels(settings.apply(darkAndMid, CameraRawClipping::shadows));
+ QVERIFY2((shadowView[0] == std::array{0, 0, 0, 255}) && (shadowView[1] == std::array{255, 255, 255, 255}), "shadow clipping is not the grade");
+}
+
+void CameraRawTests::temperatureWarmsAndTintMovesTowardMagenta()
+{
+ const QImage input = gray();
+ CameraRawSettings settings;
+ settings.temperature = 100;
+ const std::array warm = pixels(settings.apply(input))[0];
+ QVERIFY(warm[0] > 128 && warm[2] < 128 && warm[0] > warm[2]);
+ settings = CameraRawSettings();
+ settings.tint = 100;
+ const std::array magenta = pixels(settings.apply(input))[0];
+ QVERIFY(magenta[1] < 128 && magenta[1] < magenta[0] && magenta[1] < magenta[2]);
+}
+
+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);
+ const QImage skin = image(4, 4, 153 / 255.0, 115 / 255.0, 77 / 255.0);
+ CameraRawSettings settings;
+ settings.vibrance = 100;
+ const int dullBefore = chroma(pixels(dullGreen)[0]), saturatedBefore = chroma(pixels(saturatedGreen)[0]), skinBefore = chroma(pixels(skin)[0]);
+ const int dullDelta = chroma(pixels(settings.apply(dullGreen))[0]) - dullBefore;
+ const int saturatedDelta = chroma(pixels(settings.apply(saturatedGreen))[0]) - saturatedBefore;
+ const int skinDelta = chroma(pixels(settings.apply(skin))[0]) - skinBefore;
+ QVERIFY(dullDelta > saturatedDelta + 10);
+ QCOMPARE(dullBefore, skinBefore);
+ QVERIFY(dullDelta > skinDelta + 10);
+ const QImage red = image(4, 4, 160 / 255.0, 120 / 255.0, 120 / 255.0), blue = image(4, 4, 100 / 255.0, 100 / 255.0, 140 / 255.0);
+ settings = CameraRawSettings();
+ settings.saturation = 100;
+ const double redRatio = double(chroma(pixels(settings.apply(red))[0])) / chroma(pixels(red)[0]);
+ const double blueRatio = double(chroma(pixels(settings.apply(blue))[0])) / chroma(pixels(blue)[0]);
+ QVERIFY2(std::abs(redRatio - 2) < 0.15 && std::abs(blueRatio - 2) < 0.15, "saturation doubles both");
+}
+
+void CameraRawTests::textureAndClaritySharpenAnEdgeAndLeaveAFlatField()
+{
+ const QImage flat = gray();
+ CameraRawSettings settings;
+ settings.texture = 100;
+ settings.clarity = 100;
+ QVERIFY2(pixels(settings.apply(flat)) == pixels(flat), "a flat field has no local contrast");
+ const QImage edge = step();
+ const auto original = pixels(edge);
+ settings = CameraRawSettings();
+ settings.texture = 100;
+ const auto textured = pixels(settings.apply(edge));
+ QCOMPARE(textured[0][0], original[0][0]);
+ QVERIFY2(textured[8][0] == original[8][0], "texture's fine radius does not reach this far");
+ QVERIFY(textured[11][0] != original[11][0]);
+ settings = CameraRawSettings();
+ settings.clarity = 100;
+ const auto clarified = pixels(settings.apply(edge));
+ QCOMPARE(clarified[0][0], original[0][0]);
+ QVERIFY2(clarified[8][0] != original[8][0], "clarity's wider radius reaches further in");
+ settings.clarity = -100;
+ const auto softened = pixels(settings.apply(edge));
+ QVERIFY(std::abs(softened[12][0] - softened[11][0]) < std::abs(original[12][0] - original[11][0]));
+}
+
+void CameraRawTests::dehazeDeepensOrLiftsAndKeepsAlpha()
+{
+ const QImage dark = image(4, 4, 30 / 255.0, 30 / 255.0, 30 / 255.0), pale = image(4, 4, 180 / 255.0, 150 / 255.0, 150 / 255.0);
+ CameraRawSettings settings;
+ settings.dehaze = 100;
+ QVERIFY(pixels(settings.apply(dark))[0][0] < 30);
+ const std::array paleBefore = pixels(pale)[0];
+ QVERIFY(chroma(pixels(settings.apply(pale))[0]) > chroma(paleBefore));
+ settings.dehaze = -100;
+ QVERIFY(pixels(settings.apply(dark))[0][0] > 30);
+ QVERIFY(chroma(pixels(settings.apply(pale))[0]) < chroma(paleBefore));
+ const QImage translucent = image(4, 4, 30 / 255.0, 30 / 255.0, 30 / 255.0, 0.5);
+ settings.dehaze = 100;
+ QCOMPARE(pixels(settings.apply(translucent))[0][3], pixels(translucent)[0][3]);
+}
+
+void CameraRawTests::glowIsIdleAtZeroAndHalationFringeIsRedderThanDiffusion()
+{
+ QImage spot = image(21, 21, 0, 0, 0);
+ for (int y = 8; y < 13; ++y)
+ for (int x = 8; x < 13; ++x)
+ spot.setPixelColor(x, y, Qt::white);
+ CameraRawSettings settings;
+ settings.glowWarmth = 100;
+ settings.glowRange = 100;
+ QVERIFY2(pixels(settings.apply(spot)) == pixels(spot), "range and warmth do nothing until Glow is raised");
+ settings.glow = 100;
+ settings.glowStyle = CameraRawGlowStyle::diffusion;
+ const auto diffusion = pixels(settings.apply(spot));
+ settings.glowStyle = CameraRawGlowStyle::halation;
+ const auto halation = pixels(settings.apply(spot));
+ const size_t fringe = 10 * 21 + 15;
+ QVERIFY(diffusion[fringe][0] > diffusion[0][0]);
+ QVERIFY((halation[0] == std::array{0, 0, 0, 255}));
+ QVERIFY(halation[fringe][0] - halation[fringe][1] > diffusion[fringe][0] - diffusion[fringe][1]);
+}
+
+void CameraRawTests::vignetteDarkensCornersAndHighlightsOnlyWhileDarkening()
+{
+ const QImage field = gray(9, 9);
+ CameraRawSettings settings;
+ settings.vignetteAmount = -100;
+ const auto darkened = pixels(settings.apply(field));
+ QVERIFY(std::abs(darkened[4 * 9 + 4][0] - 128) <= 2 && darkened[0][0] < darkened[4 * 9 + 4][0] - 40);
+ const QImage white = image(9, 9, 1, 1, 1);
+ settings.vignetteHighlights = 100;
+ settings.vignetteStyle = CameraRawVignetteStyle::highlightPriority;
+ const int kept = pixels(settings.apply(white))[0][0];
+ settings.vignetteHighlights = 0;
+ const int exposed = pixels(settings.apply(white))[0][0];
+ QVERIFY(kept > exposed + 40);
+ settings.vignetteHighlights = 100;
+ settings.vignetteStyle = CameraRawVignetteStyle::paintOverlay;
+ QVERIFY2(pixels(settings.apply(white))[0][0] < kept, "paint overlay does not use Highlights");
+ settings = CameraRawSettings();
+ settings.vignetteAmount = 100;
+ const auto plain = pixels(settings.apply(field));
+ settings.vignetteHighlights = 100;
+ QVERIFY2(plain == pixels(settings.apply(field)), "highlights is idle while the vignette lightens");
+}
+
+void CameraRawTests::grainIsStableAndTheEffectsEyeDropsTheWholeGroup()
+{
+ const QImage field = gray(16, 16);
+ CameraRawSettings settings;
+ settings.grainSize = 40;
+ settings.grainRoughness = 80;
+ QVERIFY(pixels(settings.apply(field, std::nullopt, 1, 4)) == pixels(field));
+ settings.grainAmount = 70;
+ const auto first = pixels(settings.apply(field, std::nullopt, 1, 4));
+ QVERIFY(first == pixels(settings.apply(field, std::nullopt, 1, 4)));
+ QVERIFY(first != pixels(field));
+ QVERIFY(first[0][0] == first[0][1] && first[0][1] == first[0][2]);
+ QCOMPARE(pixels(settings.apply(image(4, 4, 0.5, 0.5, 0.5, 0), std::nullopt, 1, 4))[0][3], 0);
+ const QImage edge = step();
+ settings = CameraRawSettings();
+ settings.texture = 100;
+ settings.grainAmount = 50;
+ const CameraRawSettings hidden = settings.applying({.effects = false});
+ QVERIFY(hidden.isIdentity());
+ QVERIFY(pixels(hidden.apply(edge, std::nullopt, 1, 2)) == pixels(edge));
+ QVERIFY(pixels(settings.apply(edge, std::nullopt, 1, 2)) != pixels(edge));
+}
+
+void CameraRawTests::histogramFollowsTheGradeAndClippingPaintStaysOffTheResult()
+{
+ const QImage black = image(4, 4, 0, 0, 0), white = image(4, 4, 1, 1, 1);
+ QCOMPARE(peakIndex(CameraRawScope::make(black).value().red), 0);
+ QCOMPARE(peakIndex(CameraRawScope::make(white).value().red), 255);
+ CameraRawSettings settings;
+ settings.exposure = 1;
+ QVERIFY(peakIndex(CameraRawScope::make(settings.apply(gray())).value().red) > 128);
+ const std::array shadowed = pixels(CameraRawScope::overlay(black, true, false))[0];
+ QVERIFY(shadowed[2] > shadowed[0]);
+ const std::array highlighted = pixels(CameraRawScope::overlay(white, false, true))[0];
+ QVERIFY(highlighted[0] > highlighted[2]);
+ QVERIFY(pixels(CameraRawScope::overlay(black, false, false)) == pixels(black));
+ const CameraRawScope scope = CameraRawScope::make(image(4, 4, 1, 0, 0)).value();
+ const auto hottest = std::max_element(scope.vectorscope.begin(), scope.vectorscope.end()) - scope.vectorscope.begin();
+ QVERIFY2(hottest % CameraRawScope::scopeSide > CameraRawScope::scopeSide / 2, "red sits on the right of the vectorscope");
+}
+
+void CameraRawTests::curveMixerAndGradingChangeOnlyTheirOwnTones()
+{
+ const QImage dark = image(4, 4, 0.12, 0.12, 0.12), light = image(4, 4, 0.62, 0.62, 0.62);
+ CameraRawSettings settings;
+ settings.curve.shadows = 100;
+ const int darkGain = pixels(settings.apply(dark))[0][0] - int(std::lround(0.12 * 255));
+ const int lightGain = pixels(settings.apply(light))[0][0] - int(std::lround(0.62 * 255));
+ QVERIFY(darkGain > lightGain + 8);
+ settings = CameraRawSettings();
+ settings.curve.rgb = CameraRawCurveSettings::strongContrast();
+ QVERIFY(pixels(settings.apply(image(4, 4, 0.25, 0.25, 0.25)))[0][0] < 55);
+ settings = CameraRawSettings();
+ settings.mixer.hue[0] = 100;
+ const std::array shifted = pixels(settings.apply(image(4, 4, 1, 0, 0)))[0];
+ QVERIFY(shifted[1] > shifted[2]);
+ settings = CameraRawSettings();
+ settings.grading.shadows.saturation = 100;
+ const std::array gradedDark = pixels(settings.apply(dark))[0];
+ const std::array gradedLight = pixels(settings.apply(image(4, 4, 1, 1, 1)))[0];
+ QVERIFY(gradedDark[0] > gradedDark[1] + 5 && std::abs(gradedLight[0] - gradedLight[1]) <= 2);
+ settings.grading.balance = 100;
+ const std::array balanced = pixels(settings.apply(dark))[0];
+ QVERIFY(balanced[0] - balanced[1] < gradedDark[0] - gradedDark[1]);
+ settings = CameraRawSettings();
+ settings.curve.shadows = 100;
+ QVERIFY(pixels(settings.applying({.curve = false}).apply(dark)) == pixels(dark));
+}
+
+void CameraRawTests::detailSharpeningNoiseAndMaskingPreview()
+{
+ const QImage edge = step();
+ CameraRawSettings settings;
+ settings.detail.sharpenAmount = 150;
+ settings.detail.sharpenRadius = 50;
+ QVERIFY(pixels(settings.apply(edge)) != pixels(edge));
+ settings = CameraRawSettings();
+ settings.detail.noiseLuminance = 80;
+ QVERIFY2(pixels(settings.apply(gray(8, 8))) == pixels(gray(8, 8)), "luminance NR leaves a flat field alone");
+ settings.detail.sharpenMasking = 50;
+ const auto mask = pixels(settings.apply(edge, std::nullopt, 1, 0, -1, true));
+ QVERIFY(std::all_of(mask.begin(), mask.end(), [](const std::array &p) { return p[0] == p[1] && p[1] == p[2]; }));
+ QVERIFY(settings.detail.adjusts());
+}
+
+void CameraRawTests::opticsDistortionDefringeAndDetailEye()
+{
+ const QImage stepped = checker();
+ CameraRawSettings settings;
+ settings.optics.distortion = 100;
+ QVERIFY2(pixels(settings.apply(stepped)) != pixels(stepped), "distortion resamples pixels");
+ const QImage purple = image(4, 4, 0.8, 0.2, 0.9);
+ settings = CameraRawSettings();
+ settings.optics.purpleAmount = 100;
+ settings.optics.purpleHueLow = 250;
+ settings.optics.purpleHueHigh = 320;
+ QVERIFY(chroma(pixels(settings.apply(purple))[0]) < chroma(pixels(purple)[0]));
+ settings = CameraRawSettings();
+ settings.optics.removeChromaticAberration = true;
+ QVERIFY(settings.optics.adjusts());
+ settings = CameraRawSettings();
+ settings.detail.sharpenAmount = 40;
+ QVERIFY(pixels(settings.applying({.detail = false}).apply(step())) == pixels(step()));
+}
+
+QTEST_GUILESS_MAIN(CameraRawTests)
+#include "CameraRawTests.moc"
diff --git a/tests/CameraRawToolsTests.cpp b/tests/CameraRawToolsTests.cpp
new file mode 100644
index 0000000..025225c
--- /dev/null
+++ b/tests/CameraRawToolsTests.cpp
@@ -0,0 +1,285 @@
+#include "CameraRawFixtures.h"
+#include "Document/EditorSession.h"
+#include
+#include
+
+// Swift's Camera Raw session tools: eyedroppers, drags, guides.
+using namespace CameraRawFixtures;
+
+namespace {
+std::unique_ptr opened(const QImage &image)
+{
+ auto session = std::make_unique();
+ session->createDocument(image.width(), image.height());
+ session->insert(ImportedImage(image, image, QStringLiteral("Picture")));
+ session->beginFilter(FilterKind::cameraRaw);
+ return session;
+}
+
+void arm(EditorSession &session, const std::function &change)
+{
+ CameraRawPanel panel = session.filterEdit().value().rawPanel;
+ change(panel);
+ session.setCameraRawPanel(panel);
+}
+
+const CameraRawSettings &raw(const EditorSession &session)
+{
+ return session.filterEdit().value().settings.cameraRaw;
+}
+}
+
+class CameraRawToolsTests : public QObject {
+ Q_OBJECT
+private slots:
+ void theDefringeEyedropperCentresTheNearerRange();
+ void theEyedroppersNeedArmingAPixelAndAnOpenEdit();
+ void theReadoutFollowsTheAdjustedPreview();
+ void aTargetedDragMovesTheCurveOrTheMixer();
+ void pointColorsArePickedAndReplaced();
+ void aDrawnGuideTurnsUprightGuided();
+ void theWhiteBalanceToolsKeepTheirMode();
+ void samplesReadEveryHueAndStayWithTheirKind();
+};
+
+void CameraRawToolsTests::theDefringeEyedropperCentresTheNearerRange()
+{
+ const auto purple = opened(image(8, 8, 0.8, 0.2, 0.9));
+ arm(*purple, [](CameraRawPanel &panel) { panel.samplesDefringe = true; });
+ purple->sampleCameraRawDefringe(QPointF(2, 2));
+ const double hue = (4 + 0.6 / 0.7) * 60;
+ QVERIFY(std::abs(raw(*purple).optics.purpleHueLow - (hue - 25)) < 0.5 && std::abs(raw(*purple).optics.purpleHueHigh - (hue + 25)) < 0.5);
+ QCOMPARE(raw(*purple).optics.purpleAmount, 50.0);
+ QCOMPARE(raw(*purple).optics.greenAmount, 0.0);
+ // A set amount stays; green fringe moves the green range.
+ FilterSettings settings = purple->filterEdit().value().settings;
+ settings.cameraRaw.optics.purpleAmount = 80;
+ purple->updateFilter(settings, true);
+ purple->sampleCameraRawDefringe(QPointF(2, 2));
+ QCOMPARE(raw(*purple).optics.purpleAmount, 80.0);
+ const auto green = opened(image(8, 8, 0.4, 0.8, 0.2));
+ arm(*green, [](CameraRawPanel &panel) { panel.samplesDefringe = true; });
+ green->sampleCameraRawDefringe(QPointF(2, 2));
+ const double greenHue = (2 + (0.2 - 0.4) / 0.6) * 60;
+ QVERIFY(std::abs(raw(*green).optics.greenHueLow - (greenHue - 25)) < 0.5 && std::abs(raw(*green).optics.greenHueHigh - (greenHue + 25)) < 0.5);
+ QCOMPARE(raw(*green).optics.greenAmount, 50.0);
+ QVERIFY(raw(*green).optics.purpleHueLow == 270 && raw(*green).optics.purpleAmount == 0);
+ // Past the wheel's end the range is held within 360.
+ const auto rose = opened(image(8, 8, 1, 0, 64 / 255.0));
+ arm(*rose, [](CameraRawPanel &panel) { panel.samplesDefringe = true; });
+ rose->sampleCameraRawDefringe(QPointF(2, 2));
+ QCOMPARE(raw(*rose).optics.purpleHueHigh, 360.0);
+}
+
+void CameraRawToolsTests::theEyedroppersNeedArmingAPixelAndAnOpenEdit()
+{
+ const auto session = opened(image(8, 8, 0.8, 0.2, 0.9));
+ const FilterSettings untouched = session->filterEdit().value().settings;
+ // Unarmed, outside the document or on a clear pixel: nothing.
+ session->sampleCameraRawDefringe(QPointF(2, 2));
+ session->sampleCameraRawWhiteBalance(QPointF(2, 2));
+ arm(*session, [](CameraRawPanel &panel) { panel.samplesDefringe = panel.samplesWhiteBalance = true; });
+ for (const QPointF outside : {QPointF(-0.5, 2), QPointF(8, 2), QPointF(2, 8)}) {
+ session->sampleCameraRawDefringe(outside);
+ session->sampleCameraRawWhiteBalance(outside);
+ }
+ QVERIFY(session->filterEdit().value().settings == untouched);
+ const auto clear = opened(image(8, 8, 0.8, 0.2, 0.9, 0));
+ arm(*clear, [](CameraRawPanel &panel) { panel.samplesDefringe = true; });
+ clear->sampleCameraRawDefringe(QPointF(2, 2));
+ QVERIFY(clear->filterEdit().value().settings.cameraRaw == CameraRawSettings());
+ // The panel's setter announces; with no edit it is silent.
+ QSignalSpy changes(session.get(), &EditorSession::changed);
+ arm(*session, [](CameraRawPanel &panel) { panel.mixerSwatch = 3; });
+ QCOMPARE(changes.count(), 1);
+ QCOMPARE(session->filterEdit().value().rawPanel.mixerSwatch, 3);
+ session->cancelFilter();
+ changes.clear();
+ session->setCameraRawPanel(CameraRawPanel());
+ QCOMPARE(changes.count(), 0);
+ // A clear pixel reads nothing.
+ clear->updateCameraRawReadout(QPointF(2, 2));
+ QVERIFY(!clear->filterEdit().value().rawPanel.readout);
+}
+
+void CameraRawToolsTests::theReadoutFollowsTheAdjustedPreview()
+{
+ const auto session = opened(image(8, 8, 160 / 255.0, 140 / 255.0, 120 / 255.0));
+ QSignalSpy changes(session.get(), &EditorSession::changed);
+ session->updateCameraRawReadout(QPointF(3, 3));
+ QCOMPARE(session->filterEdit().value().rawPanel.readout, std::optional(std::array{160, 140, 120}));
+ QCOMPARE(changes.count(), 1);
+ // The same pixel again says nothing; past the layer, none.
+ session->updateCameraRawReadout(QPointF(4, 4));
+ QCOMPARE(changes.count(), 1);
+ session->updateCameraRawReadout(QPointF(9, 3));
+ QVERIFY(!session->filterEdit().value().rawPanel.readout);
+ // Once a preview lands it reads the adjusted pixels.
+ FilterSettings settings = session->filterEdit().value().settings;
+ settings.cameraRaw.exposure = 1;
+ session->updateFilter(settings, true);
+ QTRY_VERIFY(!session->filterEdit().value().preparing);
+ session->updateCameraRawReadout(QPointF(3, 3));
+ const std::array read = session->filterEdit().value().rawPanel.readout.value();
+ const std::array expected = pixels(session->filterEdit().value().preparedPreview.value())[0];
+ QVERIFY(read[0] == expected[0] && read[1] == expected[1] && read[2] == expected[2] && read[0] > 160);
+}
+
+void CameraRawToolsTests::aTargetedDragMovesTheCurveOrTheMixer()
+{
+ const auto session = opened(gray(8, 8));
+ arm(*session, [](CameraRawPanel &panel) { panel.targetsCurve = true; });
+ // Mid gray is a light; a hundred points up, 35.
+ session->beginCameraRawDrag(QPointF(2, 5));
+ session->dragCameraRaw(QPointF(2, -95));
+ QCOMPARE(raw(*session).curve.lights, 35.0);
+ QCOMPARE(raw(*session).curve.darks, 0.0);
+ // Each step measures from the press, not the last step.
+ session->dragCameraRaw(QPointF(2, -1000));
+ QCOMPARE(raw(*session).curve.lights, 100.0);
+ session->dragCameraRaw(QPointF(2, 105));
+ QCOMPARE(raw(*session).curve.lights, -35.0);
+ // A drag starts over from the settings at its press.
+ FilterSettings moved = session->filterEdit().value().settings;
+ moved.cameraRaw.exposure = 1;
+ session->updateFilter(moved, true);
+ session->dragCameraRaw(QPointF(2, 5));
+ QVERIFY(raw(*session).exposure == 0 && raw(*session).curve.lights == 0);
+ // The point page nudges the nearest point, a hundredth.
+ arm(*session, [](CameraRawPanel &panel) { panel.curvePage = CameraRawCurvePage::point; panel.pointChannel = CameraRawPointChannel::red; });
+ session->beginCameraRawDrag(QPointF(2, 5));
+ session->dragCameraRaw(QPointF(2, 105));
+ QCOMPARE(raw(*session).curve.red, (std::vector{{0, 0}, {1, 0.65}}));
+ QVERIFY(raw(*session).curve.rgb == CameraRawCurveSettings::linear());
+ // The mixer's tab shares the drag by each family's weight.
+ const auto red = opened(image(8, 8, 1, 0, 0));
+ arm(*red, [](CameraRawPanel &panel) { panel.targetsMixer = true; panel.mixerTab = CameraRawMixerTab::saturation; });
+ red->beginCameraRawDrag(QPointF(2, 5));
+ red->dragCameraRaw(QPointF(2, -35));
+ QCOMPARE(raw(*red).mixer.saturation[0], 14.0);
+ QCOMPARE(raw(*red).mixer.saturation[1], 3.5);
+ QVERIFY(raw(*red).mixer.hue[0] == 0 && raw(*red).mixer.saturation[2] == 0);
+ // Without a drag begun, a drag does nothing.
+ const auto idle = opened(gray(8, 8));
+ arm(*idle, [](CameraRawPanel &panel) { panel.targetsCurve = true; });
+ idle->dragCameraRaw(QPointF(2, -95));
+ QVERIFY(raw(*idle) == CameraRawSettings());
+}
+
+void CameraRawToolsTests::pointColorsArePickedAndReplaced()
+{
+ const auto session = opened(image(8, 8, 1, 0.5, 0));
+ arm(*session, [](CameraRawPanel &panel) { panel.samplesPointColor = true; panel.pointIndex = -1; });
+ session->sampleCameraRawPointColor(QPointF(1, 1));
+ QCOMPARE(raw(*session).mixer.points.size(), size_t(1));
+ const CameraRawPointColor picked = raw(*session).mixer.points[0];
+ QVERIFY(std::abs(picked.hue - 30) < 0.5 && std::abs(picked.saturation - 1) < 0.01 && std::abs(picked.luminance - 0.5) < 0.01);
+ QCOMPARE(session->filterEdit().value().rawPanel.pointIndex, 0);
+ // Picked again, the chosen point takes it, keeping shifts.
+ FilterSettings settings = session->filterEdit().value().settings;
+ settings.cameraRaw.mixer.points[0].hueShift = 20;
+ settings.cameraRaw.mixer.points[0].saturationShift = -30;
+ settings.cameraRaw.mixer.points[0].luminanceShift = 40;
+ session->updateFilter(settings, true);
+ session->sampleCameraRawPointColor(QPointF(1, 1));
+ QCOMPARE(raw(*session).mixer.points.size(), size_t(1));
+ const CameraRawPointColor kept = raw(*session).mixer.points[0];
+ QVERIFY(kept.hueShift == 20 && kept.saturationShift == -30 && kept.luminanceShift == 40);
+ // A pick chooses its addition; with none chosen, eight.
+ for (int pick = 0; pick < 9; ++pick) {
+ arm(*session, [](CameraRawPanel &panel) { panel.pointIndex = -1; });
+ session->sampleCameraRawPointColor(QPointF(1, 1));
+ }
+ QCOMPARE(raw(*session).mixer.points.size(), size_t(8));
+ QCOMPARE(session->filterEdit().value().rawPanel.pointIndex, -1);
+ // A hue past red's end reads round the wheel.
+ const auto rose = opened(image(8, 8, 1, 0, 64 / 255.0));
+ arm(*rose, [](CameraRawPanel &panel) { panel.samplesPointColor = true; });
+ rose->sampleCameraRawPointColor(QPointF(1, 1));
+ QVERIFY(std::abs(raw(*rose).mixer.points.at(0).hue - (360 - 60 * 64 / 255.0)) < 0.01);
+}
+
+void CameraRawToolsTests::aDrawnGuideTurnsUprightGuided()
+{
+ const auto session = opened(gray(8, 8));
+ session->beginCameraRawGeometryGuide(QPointF(2, 2));
+ QVERIFY(!session->filterEdit().value().rawPanel.guideDraft);
+ arm(*session, [](CameraRawPanel &panel) { panel.drawingGeometryGuide = true; });
+ session->beginCameraRawGeometryGuide(QPointF(-1, 2));
+ QVERIFY(!session->filterEdit().value().rawPanel.guideDraft);
+ session->beginCameraRawGeometryGuide(QPointF(2, 2));
+ session->continueCameraRawGeometryGuide(QPointF(6, 4));
+ // A point past the preview leaves the draft alone.
+ session->continueCameraRawGeometryGuide(QPointF(9, 4));
+ QCOMPARE(session->filterEdit().value().rawPanel.guideDraft, std::optional(std::pair(QPointF(0.25, 0.25), QPointF(0.75, 0.5))));
+ session->commitCameraRawGeometryGuide();
+ QVERIFY(!session->filterEdit().value().rawPanel.guideDraft);
+ QVERIFY(raw(*session).geometry.upright == CameraRawUprightMode::guided);
+ QCOMPARE(raw(*session).geometry.guides, (std::vector{{0.25, 0.25, 0.75, 0.5}}));
+}
+
+void CameraRawToolsTests::theWhiteBalanceToolsKeepTheirMode()
+{
+ const QImage warm = image(8, 8, 160 / 255.0, 140 / 255.0, 120 / 255.0);
+ // The eyedropper sets Custom over Auto.
+ const auto sampled = opened(warm);
+ FilterSettings automatic = sampled->filterEdit().value().settings;
+ automatic.cameraRaw.whiteBalance = CameraRawWhiteBalance::automatic;
+ sampled->updateFilter(automatic, true);
+ arm(*sampled, [](CameraRawPanel &panel) { panel.samplesWhiteBalance = true; });
+ sampled->sampleCameraRawWhiteBalance(QPointF(1, 1));
+ QVERIFY(raw(*sampled).whiteBalance == CameraRawWhiteBalance::custom);
+ // Custom chosen before the scan lands keeps the sliders.
+ const auto changed = opened(warm);
+ bool done = false;
+ changed->applyCameraRawAutoWhiteBalance([&done] { done = true; });
+ FilterSettings custom = changed->filterEdit().value().settings;
+ custom.cameraRaw.whiteBalance = CameraRawWhiteBalance::custom;
+ changed->updateFilter(custom, true);
+ QTRY_VERIFY(done);
+ QVERIFY(raw(*changed).temperature == 0 && raw(*changed).tint == 0);
+}
+
+void CameraRawToolsTests::samplesReadEveryHueAndStayWithTheirKind()
+{
+ // Green and blue on top take their own hue branches.
+ const auto hueOf = [](double red, double green, double blue) {
+ const auto session = opened(image(8, 8, red, green, blue));
+ arm(*session, [](CameraRawPanel &panel) { panel.samplesPointColor = true; });
+ session->sampleCameraRawPointColor(QPointF(1, 1));
+ return raw(*session).mixer.points.at(0).hue;
+ };
+ QVERIFY(std::abs(hueOf(0.2, 0.8, 0.4) - (2 + 0.2 / 0.6) * 60) < 0.5);
+ QVERIFY(std::abs(hueOf(0.3, 0.2, 0.9) - (4 + 0.1 / 0.7) * 60) < 0.5);
+ // The Luminance tab moves luminance alone.
+ const auto red = opened(image(8, 8, 1, 0, 0));
+ arm(*red, [](CameraRawPanel &panel) { panel.targetsMixer = true; panel.mixerTab = CameraRawMixerTab::luminance; });
+ red->beginCameraRawDrag(QPointF(2, 5));
+ red->dragCameraRaw(QPointF(2, -35));
+ QVERIFY(raw(*red).mixer.luminance[0] > 0 && raw(*red).mixer.hue[0] == 0 && raw(*red).mixer.saturation[0] == 0);
+ // Rec. 709 tone: pure green is a light.
+ const auto green = opened(image(8, 8, 0, 1, 0));
+ arm(*green, [](CameraRawPanel &panel) { panel.targetsCurve = true; });
+ green->beginCameraRawDrag(QPointF(2, 5));
+ green->dragCameraRaw(QPointF(2, -95));
+ QVERIFY(raw(*green).curve.lights == 35 && raw(*green).curve.darks == 0);
+ // A soft pixel's readout rounds to nearest.
+ QImage soft = BrushRaster::context(4, 4, false);
+ soft.fill(Qt::transparent);
+ for (int y = 0; y < 4; ++y)
+ for (int x = 0; x < 4; ++x)
+ std::fill_n(soft.scanLine(y) + x * 4, 3, uchar(51)), soft.scanLine(y)[x * 4 + 3] = 128;
+ const auto readout = opened(soft);
+ readout->updateCameraRawReadout(QPointF(1, 1));
+ QCOMPARE(readout->filterEdit().value().rawPanel.readout, std::optional(std::array{102, 102, 102}));
+ // Another filter's edit takes no white balance.
+ const auto blur = opened(image(8, 8, 0.8, 0.5, 0.3));
+ blur->cancelFilter();
+ blur->beginFilter(FilterKind::gaussianBlur);
+ arm(*blur, [](CameraRawPanel &panel) { panel.samplesWhiteBalance = true; });
+ blur->sampleCameraRawWhiteBalance(QPointF(1, 1));
+ QVERIFY(raw(*blur).temperature == 0 && raw(*blur).tint == 0);
+}
+
+QTEST_GUILESS_MAIN(CameraRawToolsTests)
+#include "CameraRawToolsTests.moc"
diff --git a/tests/CameraRawWiringTests.cpp b/tests/CameraRawWiringTests.cpp
new file mode 100644
index 0000000..9c934e0
--- /dev/null
+++ b/tests/CameraRawWiringTests.cpp
@@ -0,0 +1,326 @@
+#include "CameraRawFixtures.h"
+#include "Document/Filters.h"
+#include
+#include
+#include
+extern "C" {
+#include "AdjustPixels.h"
+}
+
+// Each setting reaches its kernel argument, each eye its fields.
+using namespace CameraRawFixtures;
+
+namespace {
+// Colours that differ everywhere, so every kernel argument shows.
+QImage noise(int side = 32)
+{
+ QImage picture = BrushRaster::context(side, side, false);
+ quint32 state = 7;
+ for (int y = 0; y < side; ++y)
+ for (int x = 0; x < side; ++x) {
+ std::array channel{};
+ for (int &value : channel) {
+ state = state * 1664525 + 1013904223;
+ value = int(state >> 24);
+ }
+ picture.setPixelColor(x, y, QColor(channel[0], channel[1], channel[2]));
+ }
+ return picture;
+}
+
+QImage kernel(const QImage &picture, const std::function &run)
+{
+ QImage copy = picture.copy();
+ run(copy.bits(), size_t(copy.width()), size_t(copy.height()), size_t(copy.bytesPerLine()));
+ return copy;
+}
+
+// The picture drawn under a transform, as the warp does.
+QImage placed(const QImage &picture, const QTransform &transform)
+{
+ QImage result = BrushRaster::context(picture.width(), picture.height(), false);
+ QPainter painter(&result);
+ painter.setRenderHints(QPainter::Antialiasing | QPainter::SmoothPixmapTransform);
+ painter.setTransform(transform);
+ painter.drawImage(QRectF(0, 0, picture.width(), picture.height()), picture);
+ return result;
+}
+
+int farthest(const std::vector> &left, const std::vector> &right)
+{
+ int most = 0;
+ for (size_t index = 0; index < left.size(); ++index)
+ for (size_t channel = 0; channel < 4; ++channel)
+ most = std::max(most, std::abs(left[index][channel] - right[index][channel]));
+ return most;
+}
+
+double decode(double encoded)
+{
+ return encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4);
+}
+}
+
+class CameraRawWiringTests : public QObject {
+ Q_OBJECT
+private slots:
+ void aClipViewShowsOnAnUntouchedGrade();
+ void autoAveragesLinearLight();
+ void eachEyeClearsEveryFieldOfItsGroup();
+ void effectsReachTheKernelInOrder();
+ void opticsAndDetailReachTheKernelsInOrder();
+ void offsetAndAspectPlaceThePicture();
+ void constrainCropFitsTheWholeFrame();
+ void theScopeCountsEachChannelAndHue();
+ void curveMixerAndGradingReachTheKernelInOrder();
+ void theSharpenMaskAndEachLensFlagReachTheirKernels();
+ void grainScalesByThePreview();
+ void turnVerticalAndScalePlaceThePicture();
+};
+
+void CameraRawWiringTests::aClipViewShowsOnAnUntouchedGrade()
+{
+ const QImage picture = noise();
+ const QImage clipped = CameraRawSettings().apply(picture, CameraRawClipping::highlights);
+ const QImage direct = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) {
+ adjust_camera_raw(bytes, w, h, s, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, int(CameraRawClipping::highlights));
+ });
+ QVERIFY(pixels(clipped) == pixels(direct) && pixels(clipped) != pixels(picture));
+}
+
+void CameraRawWiringTests::autoAveragesLinearLight()
+{
+ QImage picture = BrushRaster::context(2, 1, false);
+ picture.setPixelColor(0, 0, QColor(200, 100, 50));
+ picture.setPixelColor(1, 0, QColor(20, 60, 120));
+ const auto mean = [](const std::function &read, int left, int right) { return (read(left / 255.0) + read(right / 255.0)) / 2; };
+ const auto same = [](double value) { return value; };
+ const CameraRawSettings::Balance linear =
+ CameraRawSettings::neutralizeLinear(mean(decode, 200, 20), mean(decode, 100, 60), mean(decode, 50, 120)).value();
+ const CameraRawSettings::Balance encoded =
+ CameraRawSettings::neutralizeLinear(mean(same, 200, 20), mean(same, 100, 60), mean(same, 50, 120)).value();
+ const CameraRawSettings::Balance automatic = CameraRawSettings::autoBalance(picture).value();
+ QVERIFY(std::abs(automatic.temperature - linear.temperature) < 1e-9 && std::abs(automatic.tint - linear.tint) < 1e-9);
+ QVERIFY(std::abs(automatic.temperature - encoded.temperature) > 1);
+}
+
+void CameraRawWiringTests::eachEyeClearsEveryFieldOfItsGroup()
+{
+ CameraRawSettings all;
+ double next = 1;
+ for (double CameraRawSettings::*field :
+ {&CameraRawSettings::exposure, &CameraRawSettings::contrast, &CameraRawSettings::highlights, &CameraRawSettings::shadows,
+ &CameraRawSettings::whites, &CameraRawSettings::blacks, &CameraRawSettings::temperature, &CameraRawSettings::tint,
+ &CameraRawSettings::vibrance, &CameraRawSettings::saturation, &CameraRawSettings::texture, &CameraRawSettings::clarity,
+ &CameraRawSettings::dehaze, &CameraRawSettings::glow, &CameraRawSettings::vignetteAmount, &CameraRawSettings::grainAmount})
+ all.*field = next++;
+ const auto cleared = [&](std::initializer_list fields) {
+ CameraRawSettings expected = all;
+ for (double CameraRawSettings::*field : fields)
+ expected.*field = 0;
+ return expected;
+ };
+ QVERIFY(all.applying({.light = false})
+ == cleared({&CameraRawSettings::exposure, &CameraRawSettings::contrast, &CameraRawSettings::highlights, &CameraRawSettings::shadows,
+ &CameraRawSettings::whites, &CameraRawSettings::blacks}));
+ QVERIFY(all.applying({.color = false})
+ == cleared({&CameraRawSettings::temperature, &CameraRawSettings::tint, &CameraRawSettings::vibrance, &CameraRawSettings::saturation}));
+ QVERIFY(all.applying({.effects = false})
+ == cleared({&CameraRawSettings::texture, &CameraRawSettings::clarity, &CameraRawSettings::dehaze, &CameraRawSettings::glow,
+ &CameraRawSettings::vignetteAmount, &CameraRawSettings::grainAmount}));
+}
+
+void CameraRawWiringTests::effectsReachTheKernelInOrder()
+{
+ const QImage picture = noise();
+ CameraRawSettings settings;
+ settings.texture = 30, settings.clarity = -20, settings.dehaze = 15, settings.glow = 40, settings.glowStyle = CameraRawGlowStyle::bloom;
+ settings.glowRange = 10, settings.glowSpread = -30, settings.glowWarmth = 25, settings.vignetteAmount = -60;
+ settings.vignetteStyle = CameraRawVignetteStyle::colorPriority, settings.vignetteMidpoint = 30, settings.vignetteRoundness = 20;
+ settings.vignetteFeather = 70, settings.vignetteHighlights = 40;
+ const QImage direct = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) {
+ adjust_camera_raw_effects(bytes, w, h, s, 30, -20, 15, 40, int(CameraRawGlowStyle::bloom), 10, -30, 25, -60, 30, 20, 70, 40,
+ int(CameraRawVignetteStyle::colorPriority), 1);
+ });
+ QVERIFY(pixels(settings.apply(picture)) == pixels(direct));
+}
+
+void CameraRawWiringTests::opticsAndDetailReachTheKernelsInOrder()
+{
+ const QImage picture = noise();
+ CameraRawSettings settings;
+ settings.optics = {.removeChromaticAberration = true, .enableLensProfile = true, .profileDistortion = 60, .profileVignetting = 40,
+ .distortion = 20, .purpleAmount = 30, .purpleHueLow = 260, .purpleHueHigh = 320, .greenAmount = 40, .greenHueLow = 50,
+ .greenHueHigh = 130, .vignetteAmount = -45, .vignetteMidpoint = 30};
+ settings.detail = {.sharpenAmount = 80, .sharpenRadius = 30, .sharpenDetail = 60, .sharpenMasking = 20, .noiseLuminance = 40,
+ .noiseLuminanceDetail = 70, .noiseLuminanceContrast = 30, .noiseColor = 50, .noiseColorDetail = 20,
+ .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_detail(bytes, w, h, s, 80, 30, 60, 20, 40, 70, 30, 50, 20, 80, 1);
+ });
+ QVERIFY(pixels(settings.apply(picture)) == pixels(direct));
+}
+
+void CameraRawWiringTests::offsetAndAspectPlaceThePicture()
+{
+ const QImage picture = checker(24, 24);
+ const auto warped = [&](const std::function &change) {
+ CameraRawGeometrySettings geometry;
+ change(geometry);
+ return pixels(geometry.apply(picture));
+ };
+ // Offset X slides; Offset Y stretches from the middle.
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.offsetX = 40; }), pixels(placed(picture, QTransform::fromTranslate(24 * 0.06, 0))))
+ <= 1);
+ const double stretch = (24 + 2 * 24 * 0.06) / 24;
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.offsetY = 40; }),
+ pixels(placed(picture, QTransform::fromTranslate(12, 12).scale(1, stretch).translate(-12, -12))))
+ <= 1);
+ // Aspect widens by its factor and flattens by the same.
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.aspect = 50; }),
+ pixels(placed(picture, QTransform::fromTranslate(12, 12).scale(1.25, 1 / 1.25).translate(-12, -12))))
+ <= 1);
+}
+
+void CameraRawWiringTests::constrainCropFitsTheWholeFrame()
+{
+ // A wide box fills the width, the top clear.
+ CameraRawGeometrySettings wide{.aspect = 50, .scale = -50, .constrainCrop = true};
+ const QImage filled = wide.apply(checker(24, 24));
+ QVERIFY(qAlpha(filled.pixel(0, 12)) > 0 && qAlpha(filled.pixel(23, 12)) > 0);
+ QCOMPARE(qAlpha(filled.pixel(12, 0)), 0);
+ // A box filling one side alone is still centred.
+ CameraRawGeometrySettings tall{.aspect = -50, .offsetX = 50};
+ const QImage loose = tall.apply(checker(24, 24));
+ tall.constrainCrop = true;
+ const QImage centred = tall.apply(checker(24, 24));
+ QCOMPARE(qAlpha(loose.pixel(3, 12)), 0);
+ QVERIFY(qAlpha(centred.pixel(3, 12)) > 0);
+}
+
+void CameraRawWiringTests::theScopeCountsEachChannelAndHue()
+{
+ QImage picture = BrushRaster::context(3, 1, false);
+ picture.setPixelColor(0, 0, QColor(0, 255, 0, 128));
+ picture.setPixelColor(1, 0, QColor(0, 0, 255));
+ picture.setPixelColor(2, 0, QColor(255, 0, 255));
+ const CameraRawScope scope = CameraRawScope::make(picture).value();
+ // Counts weigh by alpha: the green pixel is half.
+ const double half = 128 / 255.0;
+ const auto near = [](double left, double right) { return std::abs(left - right) < 1e-9; };
+ QVERIFY(near(scope.red[0], 1 + half) && near(scope.red[255], 1));
+ QVERIFY(near(scope.green[255], half) && near(scope.green[0], 2));
+ QVERIFY(near(scope.blue[255], 2) && near(scope.blue[0], half));
+ const auto cell = [&](int column, int line) { return scope.vectorscope[size_t(line * CameraRawScope::scopeSide + column)]; };
+ // Green at 120°, blue at 240°, magenta at 300°.
+ QVERIFY(std::abs(cell(16, 58) - 128 / 255.0) < 1e-9);
+ QCOMPARE(cell(16, 5), 1.0);
+ QCOMPARE(cell(47, 5), 1.0);
+}
+
+void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder()
+{
+ const QImage picture = noise();
+ CameraRawSettings settings;
+ CameraRawCurveSettings &curve = settings.curve;
+ curve.lights = 30, curve.shadows = -20, curve.refineSaturation = 40;
+ curve.rgb = {{0, 0.05}, {0.5, 0.6}, {1, 0.95}};
+ curve.red = {{0, 0}, {0.3, 0.45}, {1, 1}};
+ curve.green = {{0, 0.1}, {1, 0.8}};
+ curve.blue = {{0, 0}, {0.7, 0.5}, {1, 1}};
+ for (size_t index = 0; index < 8; ++index) {
+ settings.mixer.hue[index] = 5 + double(index);
+ settings.mixer.saturation[index] = -30 + 7 * double(index);
+ settings.mixer.luminance[index] = 20 - 4 * double(index);
+ }
+ settings.mixer.points = {{200, 0.6, 0.4, 25, -35, 15, 40, 0.5, 0.3, false}, {40, 0.8, 0.6, -10, 20, -45, 60, 0.3, 0.6, false}};
+ settings.grading = {{210, 40, -20}, {100, 25, 10}, {30, 55, 15}, {300, 10, -5}, 70, -30};
+ const CameraRawCurveSettings shape = curve.normalized();
+ const std::vector luma = shape.lumaTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green),
+ blue = shape.channelTable(shape.blue);
+ std::vector 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));
+ const std::vector 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 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, luma.data(), red.data(), green.data(), blue.data(), 0.4, mixer.data(), 2, points.data(),
+ grade.data(), 0.7, -0.3, visualize);
+ });
+ QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, visualize)) == pixels(direct));
+ }
+}
+
+void CameraRawWiringTests::theSharpenMaskAndEachLensFlagReachTheirKernels()
+{
+ const QImage picture = noise();
+ CameraRawSettings masked;
+ masked.detail = {.sharpenAmount = 50, .sharpenRadius = 30, .sharpenDetail = 60, .sharpenMasking = 20};
+ const QImage mask = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_sharpen_mask_overlay(bytes, w, h, s, 30, 60, 20, 1); });
+ QVERIFY(pixels(masked.apply(picture, std::nullopt, 1, 0, -1, true)) == pixels(mask));
+ const QImage half = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw_sharpen_mask_overlay(bytes, w, h, s, 80, 60, 20, 0.25); });
+ masked.detail.sharpenRadius = 80;
+ QVERIFY(pixels(masked.apply(picture, std::nullopt, 0.25, 0, -1, true)) == pixels(half));
+ for (const bool chromatic : {true, false}) {
+ CameraRawSettings lens;
+ lens.optics.removeChromaticAberration = chromatic;
+ 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);
+ });
+ QVERIFY(pixels(lens.apply(picture)) == pixels(direct));
+ }
+}
+
+void CameraRawWiringTests::grainScalesByThePreview()
+{
+ const QImage picture = noise();
+ CameraRawSettings settings;
+ settings.grainAmount = 40;
+ const QImage direct = kernel(picture, [](uchar *bytes, size_t w, size_t h, size_t s) { adjust_grain(bytes, w, h, s, 40, 5.375, 50, 3, 0, 0, 2); });
+ QVERIFY(pixels(settings.apply(picture, std::nullopt, 0.5, 3)) == pixels(direct));
+ // A dull colour plots nearer the middle: chroma over value.
+ QImage dull = BrushRaster::context(1, 1, false);
+ dull.setPixelColor(0, 0, QColor(128, 64, 64));
+ QCOMPARE(CameraRawScope::make(dull).value().vectorscope[size_t(32 * CameraRawScope::scopeSide + 47)], 1.0);
+}
+
+void CameraRawWiringTests::turnVerticalAndScalePlaceThePicture()
+{
+ const QImage picture = checker(24, 24);
+ const auto warped = [&](const std::function &change) {
+ CameraRawGeometrySettings geometry;
+ change(geometry);
+ return pixels(geometry.apply(picture));
+ };
+ // A positive turn goes counterclockwise on screen, as Core Image's.
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.rotate = 10; }),
+ pixels(placed(picture, QTransform::fromTranslate(12, 12).rotate(-10).translate(-12, -12))))
+ <= 1);
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.scale = 50; }),
+ pixels(placed(picture, QTransform::fromTranslate(12, 12).scale(1.5, 1.5).translate(-12, -12))))
+ <= 1);
+ // Vertical 40 widens the top by 0.072 of the width.
+ const double v = 0.4 * 24 * 0.18;
+ QTransform trapezoid;
+ QVERIFY(QTransform::quadToQuad(QPolygonF(QRectF(0, 0, 24, 24)).mid(0, 4),
+ QPolygonF({QPointF(-v, 0), QPointF(24 + v, 0), QPointF(24, 24), QPointF(0, 24)}), trapezoid));
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.vertical = 40; }), pixels(placed(picture, trapezoid))) <= 1);
+ QTransform wideBottom;
+ QVERIFY(QTransform::quadToQuad(QPolygonF(QRectF(0, 0, 24, 24)).mid(0, 4),
+ QPolygonF({QPointF(0, 0), QPointF(24, 0), QPointF(24 + v, 24), QPointF(-v, 24)}), wideBottom));
+ QVERIFY(farthest(warped([](CameraRawGeometrySettings &g) { g.horizontal = 40; }), pixels(placed(picture, wideBottom))) <= 1);
+}
+
+QTEST_GUILESS_MAIN(CameraRawWiringTests)
+#include "CameraRawWiringTests.moc"
diff --git a/tests/FilterModelTests.cpp b/tests/FilterModelTests.cpp
index f6ec651..52d750e 100644
--- a/tests/FilterModelTests.cpp
+++ b/tests/FilterModelTests.cpp
@@ -66,7 +66,8 @@ void FilterModelTests::kindsKeepSwiftsNames()
{
const std::pair kinds[] = {
{FilterKind::gaussianBlur, "Gaussian Blur"}, {FilterKind::motionBlur, "Motion Blur"}, {FilterKind::addNoise, "Add Noise"},
- {FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::removeBackground, "Remove Background"},
+ {FilterKind::lensCorrection, "Lens Correction"}, {FilterKind::cameraRaw, "Camera Raw Filter"},
+ {FilterKind::removeBackground, "Remove Background"},
{FilterKind::contentAwareFill, "Content-Aware Fill"}, {FilterKind::curves, "Curves"}, {FilterKind::exposure, "Exposure"},
{FilterKind::gradientMap, "Gradient Map"}, {FilterKind::grain, "Grain"}, {FilterKind::blackWhite, "Black & White"},
{FilterKind::colorBalance, "Color Balance"}};
@@ -77,7 +78,7 @@ void FilterModelTests::kindsKeepSwiftsNames()
QVERIFY(allFilterKinds[index] == kind);
QCOMPARE(rawValue(kind), QString(name));
QCOMPARE(isAutomatic(kind), kind == FilterKind::contentAwareFill || kind == FilterKind::removeBackground);
- QCOMPARE(isImageAdjustment(kind), index >= 6);
+ QCOMPARE(isImageAdjustment(kind), index >= 7);
}
// Remove Background's qualities, Swift's words and order.
QVERIFY(allBackgroundQualities.size() == 2 && allBackgroundQualities[0] == BackgroundQuality::basic);
diff --git a/tests/ImageAdjustmentTests.cpp b/tests/ImageAdjustmentTests.cpp
index d8198b3..ecf0434 100644
--- a/tests/ImageAdjustmentTests.cpp
+++ b/tests/ImageAdjustmentTests.cpp
@@ -54,6 +54,7 @@ private slots:
void exposureWorksInLinearLightWithOffsetAndGamma();
void gradientMapColorsByBrightnessAndReverses();
void grainIsFixedInDocumentSpaceAndLeavesTransparencyAlone();
+ void grainSizeControlsParticleScaleEvenWithRoughness();
void imageMenuExposureChangesTheLayerInOneStep();
void gradientMapColorsUseTheAppColorPicker();
void settingsSaveAndOlderAdjustmentsStillOpen();
@@ -118,6 +119,22 @@ void ImageAdjustmentTests::grainIsFixedInDocumentSpaceAndLeavesTransparencyAlone
QVERIFY2(std::all_of(cleared.begin(), cleared.end(), [](const std::array &pixel) { return pixel[3] == 0; }), "clear pixels stay clear");
}
+// Swift's 1.2.3 test: bigger grain, larger and smoother particles.
+void ImageAdjustmentTests::grainSizeControlsParticleScaleEvenWithRoughness()
+{
+ const QImage source = gray(64, 64);
+ const std::vector> small = pixels(GrainSettings{70, 1, 70, 17}.apply(source));
+ const std::vector> large = pixels(GrainSettings{70, 12, 70, 17}.apply(source));
+ const auto neighbouring = [](const std::vector> &values) {
+ double total = 0, count = 0;
+ for (int y = 0; y < 64; ++y)
+ for (int x = 1; x < 64; ++x, ++count)
+ total += std::abs(values[size_t(y * 64 + x)][0] - values[size_t(y * 64 + x - 1)][0]);
+ return total / count;
+ };
+ QVERIFY(neighbouring(large) < neighbouring(small) * 0.7);
+}
+
void ImageAdjustmentTests::imageMenuExposureChangesTheLayerInOneStep()
{
EditorSession session;
diff --git a/tests/Tests.cmake b/tests/Tests.cmake
index 4d40342..6783e35 100644
--- a/tests/Tests.cmake
+++ b/tests/Tests.cmake
@@ -206,6 +206,15 @@ compositor_test(RawImporterTests)
compositor_test(RawDevelopTests)
compositor_test(ToolDefaultsTests)
compositor_test(ColorAdjustmentTests)
+compositor_test(CameraRawTests)
+compositor_test(CameraRawSessionTests)
+compositor_test(CameraRawToolsTests)
+compositor_test(CameraRawRulesTests)
+compositor_test(CameraRawColorTests)
+compositor_test(CameraRawLensTests)
+target_include_directories(CameraRawLensTests PRIVATE ${OMAPHOTO_KERNELS})
+compositor_test(CameraRawWiringTests)
+target_include_directories(CameraRawWiringTests PRIVATE ${OMAPHOTO_KERNELS})
compositor_test(LayerEffectsRendererTests)
compositor_test(LayerFlipTests)
compositor_test(LayerMergeTests)