14.25: Camera Raw's Light and Color sliders match Photoshop's
This commit is contained in:
17 files changed
+513
-136
No files matched your search
@@ -133,7 +133,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`, through 1.3.3; Swift 1.4 blends both in every mode, 13.9a), so a base or an adjustment in one of them drew 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 KB: the thousand-layer snapshot grew by the two optionals of every record (2,560 KB since 12.8d).
|
||||
- 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`.
|
||||
- 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 (encoded again since 14.25), 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`.
|
||||
- The Camera Raw panel (12.8b1, Swift 1.2.3): Swift docks the filter's `NSPanel` to the document window's right edge, full height, following it (`dockedToMainWindowRight`). Wayland lets no client place a window, and a tiling compositor would tile a second one, so the twin docks inside the window: `FloatingPanel::show` takes a dock slot, `ContentView`'s `panelDock` at the right of the middle row, `FloatingPanel::dockedWidth` (Swift's 440) wide, and there the panel is its own `PanelWindow` made a plain child (`Qt::Widget`), so `QDialog` still takes Escape as Cancel and Return as the default button; a header holds the title and a close button, which cancels. A docked frame is never remembered (`moveEvent` asks `isWindow`), so Gaussian Blur and the rest reopen where they were left, Swift's `dockedPlacementLeavesTheSavedFilterPosition`; `refocus` hands a docked panel the keys through `setFocus`, looking past the hidden floating panel of the same name, which is the window's child too. The dock takes room beside the layers panel and spans the middle row alone, so the tool bar and status bar keep their width, where Swift's panel lies over the window's whole height (known difference). Refocus hands the keys to the docked panel itself, not the field last typed in (known difference: `makeKey` keeps its first responder). `UI/CameraRawSlider` twins Swift's file: `CameraRawSliderTrack` with Swift's colours, the gradient drawn 4 points high across the groove under the style's knob, a double click on the knob (its rect two points out) resetting; a press on the track is `SliderSnap`'s (10.5): Swift's 0.18 s glide has no twin, and a drag goes on from the snapped knob, as every slider of the twin does (known differences); `display` keeps a knob under a drag, Swift's `isTrackingValue`; Camera Raw's own rows keep the track they are made with, since the rows whose tracks change (12.8b2's mixer) are made anew; since 12.14c `reshape` is Swift's track and bounds update in `updateNSView`, for the filter and Hue/Saturation sheets. `UI/CameraRawControls.h/.cpp` and `CameraRawControls+Basic.cpp` (Light, Color, Effects) twin Swift's view: the scope 110 high on black at 35%, the three ribbons at 55% in SwiftUI's dark red, green and blue (the Levels histogram's values) or the vectorscope's cells, square by the width and counted up from the bottom, so a 110-point graph shows its lowest 18 rows, as Swift's does, its context menu choosing the mode, the two clipping triangles lit in that blue and red, the readout; the words say Alt for Option and a right click for Control-click; the ten groups, Light and Color open, each eye shown while its group adjusts; a row's title (96 points at least, a double click resetting), `CameraRawSlider` and a 56-point `PickerField` showing Swift's `.number` to the row's decimals. A row's object name is its settings field (`vignetteAmountSlider`), since titles repeat. Alt is read from `QGuiApplication::keyboardModifiers`, Swift's `NSEvent.modifierFlags`, and its release (auto-repeat aside), from any window, ends the clipping and sharpen-mask views (Swift's local monitor). Curve to Calibration fill their groups in 12.8b2, and the Filter menu's entry waits for them. On the canvas (`EditorCanvas+CameraRaw.cpp`) a hover reads the readout (Swift's `mouseMoved`, never a drag), leaving clears it, the three eyedroppers sample and hand the panel the keys, the guide and the targeted drag begin, go on and end on release, and a move without the left button ends either, since Qt can lose a release; the four tools are `picking`, so the eyedropper shows. `EyedropperIcon::icon` came from `LevelsSheet`, which shares it. Left out as dead: the canvas's `kind == cameraRaw` and edit terms before the readout (the session asks), a hide of the other placement's panel in `show` (every edit closes its panel first), Swift's clipping reset before a row's reset (`assign` clears it), and the docked panel's own hide on close (its slot hides it). Equivalent mutants: the titles' minimum width, 96 points (a layout sizes labels offscreen alike), and the unlit triangle's 55% white (cosmetic, over whatever the scope shows). Two consequences of the named differences: a double click on a slider's track resets it, since the first press puts the knob under the second; and a canvas press takes the keys, so a field still being typed in commits at the click, where Swift's panel keeps its field editor. Tests: `CameraRawSliderTests` (Swift's three), `CameraRawPanelTests`, `CameraRawCanvasTests` (the dock in a real window among them), `FloatingPanelTests::dockedPlacementLeavesTheSavedFilterPosition`.
|
||||
- The Camera Raw panel's other groups (12.8b2, Swift 1.2.3): `UI/CameraRawColorControls.h/.cpp` (Curve), `+Mixer.cpp` and `+Grading.cpp`, `UI/CameraRawDetailOpticsControls` and `UI/CameraRawGeometryCalibrationControls` twin Swift's three files, each view a `QWidget` over the session, synchronized on `changed()`. `UI/CameraRawRow` is Linux-only: the row each Swift view builds for itself (`sharpenSlider`, `opticsSlider`, `geometrySlider`, `calibrationSlider`, the curve's and mixer's sliders, 12.8b1's `slider`): a title of at least or exactly a width, `CameraRawSlider`, a field or none, a double click on the title resetting where Swift's main groups do; each view rounds the slider's value as its Swift twin does (Detail to whole numbers, Optics when its range starts at zero, Geometry and Calibration always, the rest not). Swift's segmented pickers are `cameraRawSegments`, touching checkable buttons in an exclusive `QButtonGroup`. The curve graph: a still press and release adds a point, a drag past two points moves the nearest divider or inner point, a double click removes the nearest inner point within 0.04 and its trailing release adds nothing, so the net is Swift's: SwiftUI adds on each click, `repair` drops the second as a duplicate and the double tap removes one. The graph is `CameraRawCurveGraph`: a class defined in a `.cpp` outside an anonymous namespace is global, and the Curves sheet's `CurveGraph` would share its vtable across the link. The mixer's rows whose help or track follow its tab, swatch or chosen point are made anew when those change, as SwiftUI renders them anew; a picked colour's swatch is shown at once, since a layout shows late children only later. The grading wheel fills a `QConicalGradient`, which runs counterclockwise from the right as the hue does (1.2.9's `96edd5f`), takes presses and double clicks inside its circle alone (Swift's `contentShape`), and Swift's `theDockedPanelFitsTheGradingWheels` holds; its stops are 96edd5f's own, pure HSV hues every 30 degrees (v1.2.3's six SwiftUI system colours predate that fix). Detail's idle noise rows are disabled under a 45% `QGraphicsOpacityEffect`, Swift's `.opacity(0.45).disabled`. Targeted Adjustment and Draw Guides are checkable push buttons, checked while armed, where Swift tints them (known difference); their SF Symbols, `scope` and `line.diagonal`, are `LayerIcons` paths. The Filter menu lists Camera Raw Filter… after Lens Correction, as Swift's. Known differences: a mixer family's field still being typed in loses its typing when the tab changes (the row is made anew), where SwiftUI's field would commit into the new tab; Equivalent mutants: the wheel's saturation cap (`normalized` clamps it), the picked swatches made anew only when their count changes (it spares rebuilds; Qt guards a swatch deleted in its own click) and the page menu's size policy (layout). The point-colour eyedropper is flat like the other two, where Swift leaves it bordered (cosmetic). Cosmetic, Swift's values and unpinned: the swatches' 18 points, Detail's idle 45%, the hue ranges' inset (title width and 10), the rows' spacing of 10 and the 11-pixel subheadlines. Tests: `CameraRawRowTable.h` walked by `CameraRawGroupRowsTests` (every row against Swift's values), `CameraRawGroupDrawTests` (drawing, pages, gestures, the panel through `FilterSheet`), `CameraRawGroupWordsTests` (every text, captions, buddies, the pickers' states, the mixer's pages, the wheel's lower half and rim, the curve's line, repaint and selected point), `CameraRawCurveMixerTests`, `CameraRawGradingLensTests`, the menu's entries in `CompositorMenusTests`.
|
||||
- Hash containers on the render path (12.8e): Qt 6.4 declares `QHashPrivate::Data::findOrInsert` `noexcept` while it allocates, so a `QHash` or `QSet` insert that finds no memory calls `std::terminate`, past every catch (found by a backtrace of `ProjectExportFailureTests` under load: the JPEG worker's render aborted in `LiveMaskGraph::validate`). Code a worker runs under a catch that promises to survive memory running out uses `std::map` and `std::set`, whose inserts throw: `LiveMaskGraph::validate`, `FolderMaskClip::draw` and `LiveMaskRenderer`'s caches (and `LiveMaskBaker::bake` beside `validate`, though its workers catch `ExportError` alone, so a `bad_alloc` there still ends the app, by design), as `ImageExporter::render` already keyed its records. The UI thread's own `QHash`es stay: a failure there ends in Qt's event loop either way. `RenderStarvationTests` replaces `operator new` on its thread and fails each allocation of `validate`, `prepareStacks` and `FolderMaskClip::draw` in turn: each must throw `std::bad_alloc`; the three aborted with the old containers. Painting is left out of it: Qt's painter is not exception-safe, and a failure injected inside it can leave a lock held.
|
||||
@@ -203,6 +203,7 @@ Platform adaptations, each forced by a missing Qt analog:
|
||||
- Effects, text and shapes kept (14.22, Swift 1.4.9: `94e90bd`): `ImageLayer(record, asset, mask)` loads its live shape and text (`LayerShape::loaded`, `LayerText::loaded`), Swift's `ProjectSnapshot.documentLayers`, so `installProject` and `applyDocumentSize` (Canvas Size, Image Size, Crop, Trim) build the same layers; the canvas resizer carries effects, and Image Size scales them by the root of its two factors through `LayerEffects::scaled` (stroke, glows and blurs to 500, distances to 5,000, Photoshop's Scale Styles) while its resampling still flattens text and shapes, and a size that changes only the resolution keeps all three. Hue/Saturation, Levels, Invert and a floating merge keep the layer's effects. `docs/project-format.md` says so. Tests: `LayerEffectsSurviveTests` (Swift's five sizing cases; a stretch's mean factor and the ranges), `LayerEffectsTests::rebuildsAndResizesKeepThem` (the four rebuilds, both resizers), the shape and text halves in `ShapeLayerTests` and `TypeStoreTests`.
|
||||
- WebP (14.23, Swift 1.4.9: `5724099`): `ImageImporter` takes Qt's `webp` format beside JPEG, PNG and TIFF (`qt6-image-formats-plugins` carries it, as it carries TIFF; Fedora's `qt6-qtimageformats`, Arch's `qt6-imageformats` and nixpkgs' `qtimageformats` too), through the same reading, sRGB conversion and thumbnail; a dropped picture prefers WebP after TIFF and before Photoshop's, Swift's order; the unsupported and drop messages name WebP; the importer's filter offers `*.webp` and the desktop entry `image/webp`. Tests: `WebPImportTests` (Swift's lossless and lossy pictures: size, sRGB, the clear half, the budget, a drop opening a document; the drop order), the words in their files.
|
||||
- Ctrl or Meta with a middle drag zooms (14.24, Swift 1.4.9: `e03923f`, Blender's gesture): a middle press with Ctrl or Meta (Swift's Command or Control) keeps `m_middleZoom` (where it began, the zoom then) in place of the middle pan; each move with the middle button zooms by 2^((start.y − y) / 100) about the press, as the Zoom tool's drag doubles every 100 points, up in and down out, under the Zoom tool's magnifier, the minus while below the press; the brush circle hides meanwhile; the release, or a move without the middle button (Qt can lose a release), ends it and puts the tool's cursor back, as the pan's does. A left drag beside it goes on, and a right-button tip drag waits, as beside the pan. Tests: `CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms` (doubling and halving about an off-centre press, from 75% too, both cursors against the Zoom tool's, Meta, a lost release, a plain middle drag still panning, a Hand drag going on and a tip drag waiting beside it).
|
||||
- Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` are 57ab3bb's, unchanged: Exposure, white balance and Contrast run as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after. `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes are raw DEFLATE, as Foundation's `.zlib` writes them, inflated through zlib (`ZLIB::ZLIB`, which the PKGBUILD and the flake now name). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Equivalent mutant: `neutralize`'s range test (a point past ±100 reads the table at the end, already visited before it with the same cast, so it never wins strictly). Tests: Swift's three changed tests in `CameraRawTests` (exposure, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers and its first-of-ties, all from an independent Python decoder and search), `CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows` (the order against the kernel itself) and Auto through the encoded mean.
|
||||
- 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 <title>"), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4.
|
||||
- `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does.
|
||||
- `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4.
|
||||
|
||||
+5
-1
@@ -63,6 +63,7 @@ add_library(compositor_core STATIC
|
||||
src/Document/Scanlines.cpp
|
||||
src/Document/Filters.cpp
|
||||
src/Document/CameraRaw.cpp
|
||||
src/Document/CameraRawTables.cpp
|
||||
src/Document/CameraRaw+Session.cpp
|
||||
src/Document/CameraRawColor.cpp
|
||||
src/Document/CameraRawDetailOptics.cpp
|
||||
@@ -255,7 +256,10 @@ set_property(SOURCE ${OMAPHOTO_KERNELS}/AdjustPixels.c APPEND PROPERTY COMPILE_O
|
||||
# The per-pixel loops vectorise only at -O3.
|
||||
set_source_files_properties(src/Rendering/BrushCoverage.cpp src/Rendering/DownsampleCache.cpp PROPERTIES COMPILE_OPTIONS -O3)
|
||||
target_include_directories(compositor_core PUBLIC src PRIVATE ${OMAPHOTO_KERNELS})
|
||||
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW)
|
||||
find_package(ZLIB REQUIRED)
|
||||
# Swift's bundled resource, inside the binary.
|
||||
qt_add_resources(compositor_core camera_raw_tables PREFIX "/" BASE src/Resources FILES src/Resources/CameraRawTables.bin)
|
||||
target_link_libraries(compositor_core PUBLIC Qt6::Widgets Qt6::Concurrent PRIVATE ZLIB::ZLIB Qt6::Svg heif Fontconfig::Fontconfig onnxruntime::onnxruntime PkgConfig::LIBRAW)
|
||||
target_compile_options(compositor_core PUBLIC -Wall -Wextra
|
||||
$<$<BOOL:${OMAPHOTO_WERROR}>:-Werror>)
|
||||
# Empty optionals and bad indexes abort, not pass by luck.
|
||||
|
||||
@@ -98,7 +98,7 @@ scripts/dev.sh run # build, then the app on your Wayland display
|
||||
|
||||
`run` gives the container your home folder at its own path, so the app opens and saves your files and reads your Omarchy theme, and the GPU's render nodes (`/dev/dri/renderD*`), which Mesa's EGL wants. Files outside your home folder stay out of reach.
|
||||
|
||||
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04).
|
||||
By hand: C++20, Qt 6.4 or later (Widgets, Concurrent, image formats), CMake, Ninja, libheif, LibRaw, fontconfig, zlib and ONNX Runtime. `-DOMAPHOTO_MODEL=path/to/u2net.onnx` installs Remove Background's model. `scripts/distro-check.sh arch|fedora|nixos|resolute` builds and tests on those systems (`resolute` is Ubuntu 26.04).
|
||||
|
||||
## Licence
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
filter = path: type: !(builtins.elem path [ "${toString self}/build" "${toString self}/Compositor-Ref" ]);
|
||||
};
|
||||
nativeBuildInputs = with pkgs; [ cmake ninja pkg-config qt6.wrapQtAppsHook ];
|
||||
buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig onnxruntime ];
|
||||
buildInputs = with pkgs; [ qt6.qtbase qt6.qtsvg qt6.qtimageformats libheif libde265 libraw fontconfig zlib onnxruntime ];
|
||||
cmakeFlags = [ "-DOMAPHOTO_MODEL=${model}" ];
|
||||
ninjaFlags = [ "omaphoto" ];
|
||||
# Staged: profiles build MIME caches outside the store.
|
||||
|
||||
+73
-68
@@ -201,18 +201,6 @@ static double camera_clamp(double value) {
|
||||
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;
|
||||
}
|
||||
@@ -255,47 +243,79 @@ static double tone_highlights(double y, double amount) {
|
||||
return camera_clamp(y + amount * (amount >= 0 ? 1.0 : 2.5) * tone_bump(1.0 - 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));
|
||||
// A measured table's color for an sRGB color (0…1), between its eight nearest entries.
|
||||
static void table_lookup(const uint8_t *table, int size, const double in[3], double out[3]) {
|
||||
double f[3];
|
||||
int i[3];
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
double x = camera_clamp(in[c]) * (size - 1);
|
||||
i[c] = x >= size - 1 ? size - 2 : (int)x;
|
||||
f[c] = x - i[c];
|
||||
}
|
||||
out[0] = out[1] = out[2] = 0;
|
||||
for (int corner = 0; corner < 8; ++corner) {
|
||||
int dr = corner >> 2, dg = (corner >> 1) & 1, db = corner & 1;
|
||||
double w = (dr ? f[0] : 1 - f[0]) * (dg ? f[1] : 1 - f[1]) * (db ? f[2] : 1 - f[2]);
|
||||
if (w == 0) continue;
|
||||
const uint8_t *p = table + (((size_t)(i[0] + dr) * size + (i[1] + dg)) * size + (i[2] + db)) * 3;
|
||||
out[0] += w * p[0];
|
||||
out[1] += w * p[1];
|
||||
out[2] += w * p[2];
|
||||
}
|
||||
out[0] /= 255.0;
|
||||
out[1] /= 255.0;
|
||||
out[2] /= 255.0;
|
||||
}
|
||||
|
||||
// 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));
|
||||
void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int count, int size) {
|
||||
for (int r = 0; r < grid; ++r) {
|
||||
for (int g = 0; g < grid; ++g) {
|
||||
for (int b = 0; b < grid; ++b) {
|
||||
double color[3] = {(double)r / (grid - 1), (double)g / (grid - 1), (double)b / (grid - 1)};
|
||||
for (int s = 0; s < count; ++s) camera_raw_stage_color(&stages[s], size, color);
|
||||
float *o = out + (((size_t)r * grid + g) * grid + b) * 3;
|
||||
o[0] = (float)color[0];
|
||||
o[1] = (float)color[1];
|
||||
o[2] = (float)color[2];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
void camera_raw_stage_color(const CameraRawStage *stage, int size, double *rgb) {
|
||||
double next[3] = {0, 0, 0};
|
||||
for (int t = 0; t < 4; ++t) {
|
||||
double w = stage->weight[t];
|
||||
if (w == 0) continue;
|
||||
double looked[3];
|
||||
if (stage->table[t]) table_lookup(stage->table[t], size, rgb, looked);
|
||||
else memcpy(looked, rgb, sizeof looked);
|
||||
for (int c = 0; c < 3; ++c) next[c] += w * looked[c];
|
||||
}
|
||||
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);
|
||||
for (int c = 0; c < 3; ++c) rgb[c] = camera_clamp(next[c]);
|
||||
}
|
||||
|
||||
// A composed table's color for an sRGB color, between its eight nearest entries.
|
||||
static void composed_lookup(const float *table, int grid, double *r, double *g, double *b) {
|
||||
double in[3] = {*r, *g, *b}, f[3], out[3] = {0, 0, 0};
|
||||
int i[3];
|
||||
for (int c = 0; c < 3; ++c) {
|
||||
double x = camera_clamp(in[c]) * (grid - 1);
|
||||
i[c] = x >= grid - 1 ? grid - 2 : (int)x;
|
||||
f[c] = x - i[c];
|
||||
}
|
||||
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);
|
||||
for (int corner = 0; corner < 8; ++corner) {
|
||||
int dr = corner >> 2, dg = (corner >> 1) & 1, db = corner & 1;
|
||||
double w = (dr ? f[0] : 1 - f[0]) * (dg ? f[1] : 1 - f[1]) * (db ? f[2] : 1 - f[2]);
|
||||
if (w == 0) continue;
|
||||
const float *p = table + (((size_t)(i[0] + dr) * grid + (i[1] + dg)) * grid + (i[2] + db)) * 3;
|
||||
out[0] += w * p[0];
|
||||
out[1] += w * p[1];
|
||||
out[2] += w * p[2];
|
||||
}
|
||||
*r = camera_clamp(out[0]);
|
||||
*g = camera_clamp(out[1]);
|
||||
*b = camera_clamp(out[2]);
|
||||
}
|
||||
|
||||
static void write_premultiplied(uint8_t *p, double r, double g, double b, double alpha) {
|
||||
@@ -304,18 +324,10 @@ static void write_premultiplied(uint8_t *p, double r, double g, double b, double
|
||||
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;
|
||||
void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *before,
|
||||
const float *after, int grid, double highlights, double shadows, int clipping) {
|
||||
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) {
|
||||
@@ -325,17 +337,10 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride
|
||||
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 (before) composed_lookup(before, grid, &r, &g, &b);
|
||||
if (highlightAmount != 0) scale_luminance(&r, &g, &b, tone_highlights(rec709(r, g, b), highlightAmount));
|
||||
if (shadowAmount != 0) scale_luminance(&r, &g, &b, tone_shadows(rec709(r, g, b), shadowAmount));
|
||||
if (after) composed_lookup(after, grid, &r, &g, &b);
|
||||
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;
|
||||
|
||||
+18
-10
@@ -30,16 +30,24 @@ 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);
|
||||
// One step of Camera Raw's Light and Color: up to four measured color tables (`size`³ sRGB colors, red slowest,
|
||||
// 3 bytes each) blended by weight, for a slider value between the ones measured. A null table is no change.
|
||||
typedef struct {
|
||||
const uint8_t *table[4];
|
||||
float weight[4];
|
||||
} CameraRawStage;
|
||||
// Runs `count` stages in order over a `grid`³ lattice of sRGB colors into `out` (3 floats per color, 0…1, red
|
||||
// slowest), so a whole run of stages costs a pixel one lookup.
|
||||
void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int count, int size);
|
||||
// One stage on one sRGB color (0…1), in place.
|
||||
void camera_raw_stage_color(const CameraRawStage *stage, int size, double *rgb);
|
||||
// Camera Raw's Light and Color groups on premultiplied RGBA pixels: the composed table `before` (exposure, white
|
||||
// balance and contrast), highlights and shadows, then the composed table `after` (whites, blacks, saturation and
|
||||
// vibrance). Either table may be null, for none. Highlights and shadows are −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, const float *before,
|
||||
const float *after, int grid, double highlights, double shadows, 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,
|
||||
|
||||
@@ -6,7 +6,7 @@ pkgdesc='Layered image editing for Linux, a port of Compositor'
|
||||
arch=('x86_64')
|
||||
url='https://git.zacharyzhang.com/ZacharyZhang-NY/OmaPhoto'
|
||||
license=('MIT')
|
||||
depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'hicolor-icon-theme' 'shared-mime-info')
|
||||
depends=('qt6-base' 'qt6-svg' 'qt6-imageformats' 'libheif' 'libraw' 'libde265' 'fontconfig' 'zlib' 'hicolor-icon-theme' 'shared-mime-info')
|
||||
makedepends=('cmake' 'ninja')
|
||||
# ONNX Runtime ships inside: Arch's changes its symbols each release.
|
||||
source=('u2net.onnx::https://github.com/danielgatis/rembg/releases/download/v0.0.0/u2net.onnx'
|
||||
|
||||
+14
-27
@@ -1,5 +1,6 @@
|
||||
#include "Document/CameraRaw.h"
|
||||
#include "Document/BrushStroke.h"
|
||||
#include "Document/CameraRawTables.h"
|
||||
#include "Document/Filters.h"
|
||||
#include "Document/Levels.h"
|
||||
#include <QPainter>
|
||||
@@ -19,6 +20,11 @@ double decode(double encoded)
|
||||
return encoded <= 0.04045 ? encoded / 12.92 : std::pow((encoded + 0.055) / 1.055, 2.4);
|
||||
}
|
||||
|
||||
double encode(double linear)
|
||||
{
|
||||
return linear <= 0.0031308 ? linear * 12.92 : 1.055 * std::pow(linear, 1 / 2.4) - 0.055;
|
||||
}
|
||||
|
||||
// The image drawn into the kernels' premultiplied context.
|
||||
QImage drawn(const QImage &image)
|
||||
{
|
||||
@@ -109,19 +115,12 @@ 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<CameraRawClipping> 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
|
||||
@@ -135,9 +134,12 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip
|
||||
const size_t w = size_t(width), h = size_t(height), s = size_t(stride);
|
||||
if (!clipping && !sharpenMask && settings.calibration.adjusts())
|
||||
settings.applyCalibration(pixels, width, height, stride);
|
||||
if (settings.adjustsLight() || settings.adjustsColor() || clipping)
|
||||
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 (settings.adjustsLight() || settings.adjustsColor() || clipping) {
|
||||
const CameraRawTables::Stages stages = CameraRawTables::stages(settings);
|
||||
const std::vector<float> before = CameraRawTables::compose(stages.before), after = CameraRawTables::compose(stages.after);
|
||||
adjust_camera_raw(pixels, w, h, s, before.empty() ? nullptr : before.data(), after.empty() ? nullptr : after.data(), CameraRawTables::grid,
|
||||
settings.highlights, settings.shadows, mode);
|
||||
}
|
||||
if (paintColor)
|
||||
settings.applyCurveColor(pixels, width, height, stride, visualizePointColor);
|
||||
if (paintEffects) {
|
||||
@@ -154,24 +156,9 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip
|
||||
});
|
||||
}
|
||||
|
||||
std::optional<CameraRawSettings::Balance> 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::Balance> CameraRawSettings::neutralizeStraight(double red, double green, double blue)
|
||||
{
|
||||
return neutralizeLinear(decode(red), decode(green), decode(blue));
|
||||
return CameraRawTables::neutralize(red, green, blue);
|
||||
}
|
||||
|
||||
std::optional<CameraRawSettings::Balance> CameraRawSettings::autoBalance(const QImage &image)
|
||||
@@ -195,7 +182,7 @@ std::optional<CameraRawSettings::Balance> CameraRawSettings::autoBalance(const Q
|
||||
}
|
||||
if (count == 0)
|
||||
return std::nullopt;
|
||||
return neutralizeLinear(red / count, green / count, blue / count);
|
||||
return neutralizeStraight(encode(red / count), encode(green / count), encode(blue / count));
|
||||
}
|
||||
|
||||
double CameraRawScope::peak() const
|
||||
|
||||
@@ -36,10 +36,6 @@ struct CameraRawGroups {
|
||||
// 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.
|
||||
@@ -91,12 +87,6 @@ struct CameraRawSettings {
|
||||
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<CameraRawClipping> clipping = std::nullopt, double scale = 1, quint32 seed = 0,
|
||||
int visualizePointColor = -1, bool sharpenMask = false) const;
|
||||
@@ -104,8 +94,7 @@ struct CameraRawSettings {
|
||||
double temperature;
|
||||
double tint;
|
||||
};
|
||||
// The temperature and tint making a linear pixel neutral.
|
||||
static std::optional<Balance> neutralizeLinear(double red, double green, double blue);
|
||||
// The temperature and tint turning a straight sRGB colour gray.
|
||||
static std::optional<Balance> neutralizeStraight(double red, double green, double blue);
|
||||
// Gray-world balance of the opaque pixels.
|
||||
static std::optional<Balance> autoBalance(const QImage &image);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
#include "Document/CameraRawTables.h"
|
||||
|
||||
#include "IO/ImageExporter.h"
|
||||
#include "Logging.h"
|
||||
|
||||
#include <QFile>
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
#include <zlib.h>
|
||||
|
||||
namespace CameraRawTables {
|
||||
namespace {
|
||||
constexpr size_t entries = size_t(size) * size * size * 3;
|
||||
// White balance 9 × 9, then the six other sliders.
|
||||
constexpr int exposureStart = 81, contrastStart = 102, whitesStart = 111, blacksStart = 120;
|
||||
constexpr int saturationStart = 129, vibranceStart = 138, count = 147;
|
||||
|
||||
// Raw DEFLATE, as Foundation's `.zlib` writes it.
|
||||
std::vector<uint8_t> inflated(const QByteArray &packed, size_t expected)
|
||||
{
|
||||
std::vector<uint8_t> out(expected);
|
||||
z_stream stream{};
|
||||
if (inflateInit2(&stream, -MAX_WBITS) != Z_OK)
|
||||
throw std::logic_error("Camera Raw's tables: inflate would not start");
|
||||
stream.next_in = reinterpret_cast<Bytef *>(const_cast<char *>(packed.constData()));
|
||||
stream.avail_in = uInt(packed.size());
|
||||
stream.next_out = out.data();
|
||||
stream.avail_out = uInt(out.size());
|
||||
const int status = inflate(&stream, Z_FINISH);
|
||||
const size_t made = stream.total_out;
|
||||
inflateEnd(&stream);
|
||||
if (status != Z_STREAM_END || made != expected)
|
||||
throw std::logic_error("Camera Raw's tables do not inflate to their size");
|
||||
return out;
|
||||
}
|
||||
|
||||
// Each entry is a running difference from no change.
|
||||
std::vector<uint8_t> load()
|
||||
{
|
||||
QFile file(QStringLiteral(":/CameraRawTables.bin"));
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
throw std::logic_error("Camera Raw's tables are missing from the build");
|
||||
const QByteArray bytes = file.readAll();
|
||||
const auto byte = [&](int at) { return int(uchar(bytes[at])); };
|
||||
if (bytes.size() <= 8 || !bytes.startsWith("CRT2") || (byte(4) | byte(5) << 8) != size || (byte(6) | byte(7) << 8) != count)
|
||||
throw std::logic_error("Camera Raw's tables have the wrong header");
|
||||
const std::vector<uint8_t> planes = inflated(bytes.mid(8), count * entries);
|
||||
std::vector<uint8_t> out(count * entries);
|
||||
const size_t cells = size_t(size) * size * size;
|
||||
uint8_t level[size];
|
||||
for (int i = 0; i < size; ++i)
|
||||
level[i] = uint8_t(std::lround(i * 255.0 / (size - 1)));
|
||||
for (size_t table = 0; table < count; ++table) {
|
||||
for (size_t channel = 0; channel < 3; ++channel) {
|
||||
const size_t base = (table * 3 + channel) * cells;
|
||||
uint8_t running = 0;
|
||||
for (size_t cell = 0; cell < cells; ++cell) {
|
||||
running = uint8_t(running + planes[base + cell]);
|
||||
const size_t axis = channel == 0 ? cell / (size * size) : channel == 1 ? (cell / size) % size : cell % size;
|
||||
out[table * entries + cell * 3 + channel] = uint8_t(running + level[axis]);
|
||||
}
|
||||
}
|
||||
}
|
||||
qCInfo(lcRendering) << "Camera Raw's tables loaded:" << count << "tables";
|
||||
return out;
|
||||
}
|
||||
|
||||
const uint8_t *table(int index)
|
||||
{
|
||||
return tables().data() + size_t(index) * entries;
|
||||
}
|
||||
|
||||
struct Between {
|
||||
int index;
|
||||
double fraction;
|
||||
};
|
||||
|
||||
// The two tables beside `value`, and how far between.
|
||||
Between between(double value, double low, double step, int tables)
|
||||
{
|
||||
const double position = std::min(double(tables - 1), std::max(0.0, (value - low) / step));
|
||||
const int index = std::min(tables - 2, int(position));
|
||||
return {index, position - index};
|
||||
}
|
||||
|
||||
CameraRawStage stage(int start, double value, double low, double step, int tables)
|
||||
{
|
||||
const Between at = between(value, low, step, tables);
|
||||
return CameraRawStage{{table(start + at.index), table(start + at.index + 1), nullptr, nullptr},
|
||||
{float(1 - at.fraction), float(at.fraction), 0, 0}};
|
||||
}
|
||||
}
|
||||
|
||||
const std::vector<uint8_t> &tables()
|
||||
{
|
||||
static const std::vector<uint8_t> loaded = load();
|
||||
return loaded;
|
||||
}
|
||||
|
||||
CameraRawStage whiteBalance(double temperature, double tint)
|
||||
{
|
||||
const Between row = between(temperature, -100, 25, 9), column = between(tint, -100, 25, 9);
|
||||
const double down = row.fraction, across = column.fraction;
|
||||
return CameraRawStage{{table(row.index * 9 + column.index), table(row.index * 9 + column.index + 1), table((row.index + 1) * 9 + column.index),
|
||||
table((row.index + 1) * 9 + column.index + 1)},
|
||||
{float((1 - down) * (1 - across)), float((1 - down) * across), float(down * (1 - across)), float(down * across)}};
|
||||
}
|
||||
|
||||
Stages stages(const CameraRawSettings &settings)
|
||||
{
|
||||
Stages out;
|
||||
if (settings.exposure != 0)
|
||||
out.before.push_back(stage(exposureStart, settings.exposure, -5, 0.5, 21));
|
||||
if (settings.temperature != 0 || settings.tint != 0)
|
||||
out.before.push_back(whiteBalance(settings.temperature, settings.tint));
|
||||
if (settings.contrast != 0)
|
||||
out.before.push_back(stage(contrastStart, settings.contrast, -100, 25, 9));
|
||||
if (settings.whites != 0)
|
||||
out.after.push_back(stage(whitesStart, settings.whites, -100, 25, 9));
|
||||
if (settings.blacks != 0)
|
||||
out.after.push_back(stage(blacksStart, settings.blacks, -100, 25, 9));
|
||||
if (settings.saturation != 0)
|
||||
out.after.push_back(stage(saturationStart, settings.saturation, -100, 25, 9));
|
||||
if (settings.vibrance != 0)
|
||||
out.after.push_back(stage(vibranceStart, settings.vibrance, -100, 25, 9));
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<float> compose(const std::vector<CameraRawStage> &stages)
|
||||
{
|
||||
if (stages.empty())
|
||||
return {};
|
||||
std::vector<float> out;
|
||||
try {
|
||||
out.resize(size_t(grid) * grid * grid * 3);
|
||||
} catch (const std::bad_alloc &) {
|
||||
throw ExportError(ExportError::Kind::render);
|
||||
}
|
||||
camera_raw_compose(out.data(), grid, stages.data(), int(stages.size()), size);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::optional<CameraRawSettings::Balance> neutralize(double red, double green, double blue)
|
||||
{
|
||||
if (!(std::min({red, green, blue}) > 1e-4))
|
||||
return std::nullopt;
|
||||
const auto cast = [&](double temperature, double tint) {
|
||||
const CameraRawStage stage = whiteBalance(temperature, tint);
|
||||
double color[3] = {red, green, blue};
|
||||
camera_raw_stage_color(&stage, size, color);
|
||||
return std::max({color[0], color[1], color[2]}) - std::min({color[0], color[1], color[2]});
|
||||
};
|
||||
CameraRawSettings::Balance best{0, 0};
|
||||
double bestCast = cast(0, 0);
|
||||
for (const auto &[step, reach] : {std::pair{10.0, 100.0}, {2.0, 10.0}, {0.5, 2.0}}) {
|
||||
const CameraRawSettings::Balance center = best;
|
||||
const int steps = int(std::lround(2 * reach / step));
|
||||
for (int i = 0; i <= steps; ++i) {
|
||||
const double temperature = center.temperature - reach + step * i;
|
||||
for (int j = 0; j <= steps; ++j) {
|
||||
const double tint = center.tint - reach + step * j;
|
||||
if (std::abs(temperature) > 100 || std::abs(tint) > 100)
|
||||
continue;
|
||||
const double found = cast(temperature, tint);
|
||||
if (found < bestCast) {
|
||||
best = {temperature, tint};
|
||||
bestCast = found;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
#pragma once
|
||||
|
||||
#include "Document/CameraRaw.h"
|
||||
extern "C" {
|
||||
#include "AdjustPixels.h"
|
||||
}
|
||||
|
||||
#include <optional>
|
||||
#include <vector>
|
||||
|
||||
// Photoshop's Light and Color sliders, measured as 17³ colour tables.
|
||||
namespace CameraRawTables {
|
||||
constexpr int size = 17;
|
||||
// The grid the stages compose onto, finer than the tables.
|
||||
constexpr int grid = 33;
|
||||
|
||||
// Every table, red slowest, 3 bytes a colour; loaded once.
|
||||
const std::vector<uint8_t> &tables();
|
||||
// Temperature and Tint: the four grid tables around them.
|
||||
CameraRawStage whiteBalance(double temperature, double tint);
|
||||
struct Stages {
|
||||
std::vector<CameraRawStage> before;
|
||||
std::vector<CameraRawStage> after;
|
||||
};
|
||||
// Before Highlights and Shadows, and after; zero sliders left out.
|
||||
Stages stages(const CameraRawSettings &settings);
|
||||
// The stages over the composing grid; empty without stages.
|
||||
std::vector<float> compose(const std::vector<CameraRawStage> &stages);
|
||||
// Temperature and Tint turning a straight sRGB colour gray.
|
||||
std::optional<CameraRawSettings::Balance> neutralize(double red, double green, double blue);
|
||||
}
|
||||
Binary file not shown.
@@ -132,7 +132,7 @@ void CameraRawSessionTests::hiddenGroupIsLeftOutAndOkIsOneUndoOrNone()
|
||||
count = session->history.undoCount();
|
||||
QVERIFY(awaited([&](std::function<void()> 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");
|
||||
QVERIFY2(std::abs(pixels(layerImage(*session))[0][0] - 181) <= 2, "OK bakes the grade");
|
||||
}
|
||||
|
||||
void CameraRawSessionTests::hiddenGroupStaysOutOfTheNextOpen()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
#include "CameraRawFixtures.h"
|
||||
#include "Document/CameraRawTables.h"
|
||||
#include <QtTest>
|
||||
|
||||
// The measured tables as Swift reads them, checked against Python.
|
||||
using namespace CameraRawFixtures;
|
||||
|
||||
class CameraRawTablesTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void theTablesDecodeAsWritten();
|
||||
void aStageBlendsTheTablesAroundItsValue();
|
||||
void eachSliderPicksItsOwnTables();
|
||||
void neutralizeFindsTheGridsGrayest();
|
||||
void identitySlidersComposeNothing();
|
||||
};
|
||||
|
||||
namespace {
|
||||
const uint8_t *entry(int table, int cell)
|
||||
{
|
||||
constexpr size_t entries = size_t(CameraRawTables::size) * CameraRawTables::size * CameraRawTables::size * 3;
|
||||
return CameraRawTables::tables().data() + table * entries + size_t(cell) * 3;
|
||||
}
|
||||
|
||||
int indexOf(const uint8_t *table)
|
||||
{
|
||||
constexpr size_t entries = size_t(CameraRawTables::size) * CameraRawTables::size * CameraRawTables::size * 3;
|
||||
return table ? int((table - CameraRawTables::tables().data()) / qsizetype(entries)) : -1;
|
||||
}
|
||||
}
|
||||
|
||||
void CameraRawTablesTests::theTablesDecodeAsWritten()
|
||||
{
|
||||
const std::vector<uint8_t> &tables = CameraRawTables::tables();
|
||||
QCOMPARE(tables.size(), size_t(147) * 17 * 17 * 17 * 3);
|
||||
quint64 hash = 0xcbf29ce484222325ULL;
|
||||
for (const uint8_t byte : tables)
|
||||
hash = (hash ^ byte) * 0x100000001b3ULL;
|
||||
QCOMPARE(hash, quint64(0x6a9f829e703f2df6ULL));
|
||||
QCOMPARE(std::vector<int>(entry(40, 2456), entry(40, 2456) + 3), (std::vector<int>{128, 128, 128}));
|
||||
QCOMPARE(std::vector<int>(entry(81, 100), entry(81, 100) + 3), (std::vector<int>{0, 4, 32}));
|
||||
QCOMPARE(std::vector<int>(entry(146, 4912), entry(146, 4912) + 3), (std::vector<int>{255, 255, 255}));
|
||||
}
|
||||
|
||||
void CameraRawTablesTests::aStageBlendsTheTablesAroundItsValue()
|
||||
{
|
||||
const CameraRawStage balance = CameraRawTables::whiteBalance(30, -60);
|
||||
QCOMPARE((std::vector<int>{indexOf(balance.table[0]), indexOf(balance.table[1]), indexOf(balance.table[2]), indexOf(balance.table[3])}),
|
||||
(std::vector<int>{46, 47, 55, 56}));
|
||||
QCOMPARE((std::vector<float>{balance.weight[0], balance.weight[1], balance.weight[2], balance.weight[3]}),
|
||||
(std::vector<float>{0.32f, 0.48f, 0.08f, 0.12f}));
|
||||
// At the far corner the last pair takes it all.
|
||||
const CameraRawStage corner = CameraRawTables::whiteBalance(100, 100);
|
||||
QVERIFY(indexOf(corner.table[0]) == 70 && indexOf(corner.table[3]) == 80 && corner.weight[3] == 1 && corner.weight[0] == 0);
|
||||
// Past the range it holds at the end, never beyond.
|
||||
const CameraRawStage past = CameraRawTables::whiteBalance(150, 175);
|
||||
QVERIFY(indexOf(past.table[3]) == 80 && past.weight[3] == 1 && past.weight[0] == 0);
|
||||
const CameraRawStage low = CameraRawTables::whiteBalance(-100, -100);
|
||||
QVERIFY(indexOf(low.table[0]) == 0 && low.weight[0] == 1 && low.weight[1] == 0);
|
||||
}
|
||||
|
||||
void CameraRawTablesTests::eachSliderPicksItsOwnTables()
|
||||
{
|
||||
CameraRawSettings settings;
|
||||
settings.exposure = 1.25;
|
||||
settings.temperature = 30;
|
||||
settings.contrast = -100;
|
||||
settings.whites = 100;
|
||||
settings.blacks = 12.5;
|
||||
settings.saturation = -62.5;
|
||||
settings.vibrance = 80;
|
||||
const CameraRawTables::Stages stages = CameraRawTables::stages(settings);
|
||||
QCOMPARE(stages.before.size(), size_t(3));
|
||||
QCOMPARE(stages.after.size(), size_t(4));
|
||||
const auto pair = [](const CameraRawStage &stage) {
|
||||
return std::vector<double>{double(indexOf(stage.table[0])), double(indexOf(stage.table[1])), stage.weight[0], stage.weight[1]};
|
||||
};
|
||||
QCOMPARE(pair(stages.before[0]), (std::vector<double>{93, 94, 0.5, 0.5}));
|
||||
QCOMPARE(indexOf(stages.before[1].table[0]), 5 * 9 + 4);
|
||||
QCOMPARE(pair(stages.before[2]), (std::vector<double>{102, 103, 1, 0}));
|
||||
QCOMPARE(pair(stages.after[0]), (std::vector<double>{118, 119, 0, 1}));
|
||||
QCOMPARE(pair(stages.after[1]), (std::vector<double>{124, 125, 0.5, 0.5}));
|
||||
QCOMPARE(pair(stages.after[2]), (std::vector<double>{130, 131, 0.5, 0.5}));
|
||||
QVERIFY(indexOf(stages.after[3].table[0]) == 145 && std::abs(stages.after[3].weight[1] - 0.2f) < 1e-6);
|
||||
// Tint alone still makes the white balance stage.
|
||||
CameraRawSettings tint;
|
||||
tint.tint = -25;
|
||||
const CameraRawTables::Stages tinted = CameraRawTables::stages(tint);
|
||||
QVERIFY(tinted.before.size() == 1 && tinted.after.empty() && indexOf(tinted.before[0].table[0]) == 4 * 9 + 3);
|
||||
}
|
||||
|
||||
void CameraRawTablesTests::neutralizeFindsTheGridsGrayest()
|
||||
{
|
||||
// Python's own search over the same tables found these.
|
||||
const CameraRawSettings::Balance warm = CameraRawTables::neutralize(160 / 255.0, 140 / 255.0, 120 / 255.0).value();
|
||||
QVERIFY(warm.temperature == -19 && warm.tint == -7);
|
||||
const CameraRawSettings::Balance blue = CameraRawTables::neutralize(0.2, 0.45, 0.8).value();
|
||||
QVERIFY(blue.temperature == 100 && blue.tint == 25.5);
|
||||
const CameraRawSettings::Balance green = CameraRawTables::neutralize(0.3, 0.5, 0.25).value();
|
||||
QVERIFY(green.temperature == 5 && green.tint == 90.5);
|
||||
// White stays white anywhere: the first of the ties wins.
|
||||
const CameraRawSettings::Balance white = CameraRawTables::neutralize(1, 1, 1).value();
|
||||
QVERIFY(white.temperature == 0 && white.tint == 0);
|
||||
QVERIFY(!CameraRawTables::neutralize(0.5, 0.0001, 0.5));
|
||||
QVERIFY(CameraRawTables::neutralize(0.5, 0.00011, 0.5));
|
||||
}
|
||||
|
||||
void CameraRawTablesTests::identitySlidersComposeNothing()
|
||||
{
|
||||
const CameraRawTables::Stages none = CameraRawTables::stages(CameraRawSettings());
|
||||
QVERIFY(none.before.empty() && none.after.empty() && CameraRawTables::compose(none.before).empty());
|
||||
CameraRawSettings exposure;
|
||||
exposure.exposure = 0.5;
|
||||
const std::vector<float> composed = CameraRawTables::compose(CameraRawTables::stages(exposure).before);
|
||||
QCOMPARE(composed.size(), size_t(33) * 33 * 33 * 3);
|
||||
// The corners are its table's own black and white.
|
||||
QVERIFY(composed[0] == entry(92, 0)[0] / 255.0f && composed.back() == entry(92, 4912)[2] / 255.0f);
|
||||
}
|
||||
|
||||
QTEST_GUILESS_MAIN(CameraRawTablesTests)
|
||||
#include "CameraRawTablesTests.moc"
|
||||
@@ -10,9 +10,10 @@ class CameraRawTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void defaultsLeavePixelsAndAlphaAlone();
|
||||
void exposureAddsOneStopAndContrastPivotsAroundMidGray();
|
||||
void exposureAndContrastMatchPhotoshop();
|
||||
void tonalSlidersMoveTheEndTheyName();
|
||||
void temperatureWarmsAndTintMovesTowardMagenta();
|
||||
void whiteBalanceAndVibranceMatchPhotoshop();
|
||||
void vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesNot();
|
||||
void textureAndClaritySharpenAnEdgeAndLeaveAFlatField();
|
||||
void dehazeDeepensOrLiftsAndKeepsAlpha();
|
||||
@@ -36,23 +37,24 @@ void CameraRawTests::defaultsLeavePixelsAndAlphaAlone()
|
||||
QVERIFY(!isImageAdjustment(FilterKind::cameraRaw));
|
||||
}
|
||||
|
||||
void CameraRawTests::exposureAddsOneStopAndContrastPivotsAroundMidGray()
|
||||
// Photoshop's Camera Raw Filter's own numbers, measured.
|
||||
void CameraRawTests::exposureAndContrastMatchPhotoshop()
|
||||
{
|
||||
const QImage input = gray();
|
||||
CameraRawSettings settings;
|
||||
settings.exposure = 1;
|
||||
const std::array<int, 4> brighter = pixels(settings.apply(input))[0];
|
||||
QVERIFY2(std::abs(brighter[0] - 176) <= 2, qPrintable(QString::number(brighter[0])));
|
||||
QVERIFY2(std::abs(brighter[0] - 181) <= 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");
|
||||
QVERIFY2(std::abs(pushed[0][0] - 36) <= 3 && std::abs(pushed[1][0] - 213) <= 3, "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");
|
||||
QVERIFY2(std::abs(flat[0][0] - 88) <= 3 && std::abs(flat[1][0] - 174) <= 3, "contrast −100 draws them together");
|
||||
}
|
||||
|
||||
void CameraRawTests::tonalSlidersMoveTheEndTheyName()
|
||||
@@ -65,7 +67,7 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName()
|
||||
settings = CameraRawSettings();
|
||||
settings.whites = 100;
|
||||
const auto clipped = pixels(settings.apply(brightAndMid));
|
||||
QVERIFY(clipped[0][0] == 255 && std::abs(clipped[1][0] - 128) <= 2);
|
||||
QVERIFY2(clipped[0][0] == 255 && clipped[1][0] > 128, "whites lift the midtone too, as in Photoshop");
|
||||
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);
|
||||
@@ -76,7 +78,7 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName()
|
||||
settings = CameraRawSettings();
|
||||
settings.blacks = -100;
|
||||
const auto crushed = pixels(settings.apply(darkAndMid));
|
||||
QVERIFY(crushed[0][0] < 20 && std::abs(crushed[1][0] - 128) <= 2);
|
||||
QVERIFY2(crushed[0][0] == 0 && std::abs(crushed[1][0] - 91) <= 3, "blacks darken the midtone too");
|
||||
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");
|
||||
}
|
||||
@@ -94,6 +96,33 @@ void CameraRawTests::temperatureWarmsAndTintMovesTowardMagenta()
|
||||
QVERIFY(magenta[1] < 128 && magenta[1] < magenta[0] && magenta[1] < magenta[2]);
|
||||
}
|
||||
|
||||
// Once a fraction of Photoshop's strength (Swift's issue #252).
|
||||
void CameraRawTests::whiteBalanceAndVibranceMatchPhotoshop()
|
||||
{
|
||||
struct Case {
|
||||
const char *name;
|
||||
std::array<int, 3> input;
|
||||
std::function<void(CameraRawSettings &)> adjust;
|
||||
std::array<int, 3> expected;
|
||||
};
|
||||
const std::vector<Case> cases{
|
||||
{"temperature +100", {128, 128, 128}, [](CameraRawSettings &s) { s.temperature = 100; }, {235, 189, 117}},
|
||||
{"temperature −50", {128, 128, 128}, [](CameraRawSettings &s) { s.temperature = -50; }, {102, 146, 210}},
|
||||
{"tint +100", {128, 128, 128}, [](CameraRawSettings &s) { s.tint = 100; }, {159, 125, 215}},
|
||||
{"tint −100", {128, 128, 128}, [](CameraRawSettings &s) { s.tint = -100; }, {98, 198, 120}},
|
||||
{"vibrance +100 spares skin", {153, 107, 107}, [](CameraRawSettings &s) { s.vibrance = 100; }, {167, 98, 98}},
|
||||
{"vibrance +100 fills a dull cyan", {107, 153, 153}, [](CameraRawSettings &s) { s.vibrance = 100; }, {0, 160, 160}},
|
||||
{"vibrance −100", {92, 92, 230}, [](CameraRawSettings &s) { s.vibrance = -100; }, {164, 164, 210}},
|
||||
};
|
||||
for (const Case &test : cases) {
|
||||
CameraRawSettings settings;
|
||||
test.adjust(settings);
|
||||
const std::array<int, 4> got = pixels(settings.apply(image(4, 4, test.input[0] / 255.0, test.input[1] / 255.0, test.input[2] / 255.0)))[0];
|
||||
for (int c = 0; c < 3; ++c)
|
||||
QVERIFY2(std::abs(got[c] - test.expected[c]) <= 4, test.name);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -112,7 +141,8 @@ void CameraRawTests::vibranceFavorsDullColorsAndProtectsSkinWhileSaturationDoesN
|
||||
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");
|
||||
// Photoshop doubles chroma in linear light: less in sRGB.
|
||||
QVERIFY2(std::abs(redRatio - 1.85) < 0.15 && std::abs(blueRatio - 1.73) < 0.15, "saturation raises both");
|
||||
}
|
||||
|
||||
void CameraRawTests::textureAndClaritySharpenAnEdgeAndLeaveAFlatField()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#include "CameraRawFixtures.h"
|
||||
#include "Document/CameraRawTables.h"
|
||||
#include "Document/Filters.h"
|
||||
#include <QPainter>
|
||||
#include <QtTest>
|
||||
@@ -65,6 +66,7 @@ class CameraRawWiringTests : public QObject {
|
||||
Q_OBJECT
|
||||
private slots:
|
||||
void aClipViewShowsOnAnUntouchedGrade();
|
||||
void theTablesRunAroundHighlightsAndShadows();
|
||||
void autoAveragesLinearLight();
|
||||
void eachEyeClearsEveryFieldOfItsGroup();
|
||||
void effectsReachTheKernelInOrder();
|
||||
@@ -83,11 +85,33 @@ 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));
|
||||
adjust_camera_raw(bytes, w, h, s, nullptr, nullptr, CameraRawTables::grid, 0, 0, int(CameraRawClipping::highlights));
|
||||
});
|
||||
QVERIFY(pixels(clipped) == pixels(direct) && pixels(clipped) != pixels(picture));
|
||||
}
|
||||
|
||||
// Exposure, balance and Contrast first; Whites to Vibrance after.
|
||||
void CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows()
|
||||
{
|
||||
const QImage picture = noise();
|
||||
CameraRawSettings settings;
|
||||
settings.exposure = 1;
|
||||
settings.temperature = 40;
|
||||
settings.highlights = -80;
|
||||
settings.shadows = 60;
|
||||
settings.whites = -50;
|
||||
settings.saturation = 30;
|
||||
const CameraRawTables::Stages stages = CameraRawTables::stages(settings);
|
||||
const std::vector<float> before = CameraRawTables::compose(stages.before), after = CameraRawTables::compose(stages.after);
|
||||
const QImage direct = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) {
|
||||
adjust_camera_raw(bytes, w, h, s, before.data(), after.data(), CameraRawTables::grid, -80, 60, 0);
|
||||
});
|
||||
const QImage swapped = kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) {
|
||||
adjust_camera_raw(bytes, w, h, s, after.data(), before.data(), CameraRawTables::grid, -80, 60, 0);
|
||||
});
|
||||
QVERIFY(pixels(settings.apply(picture)) == pixels(direct) && pixels(direct) != pixels(swapped));
|
||||
}
|
||||
|
||||
void CameraRawWiringTests::autoAveragesLinearLight()
|
||||
{
|
||||
QImage picture = BrushRaster::context(2, 1, false);
|
||||
@@ -95,10 +119,12 @@ void CameraRawWiringTests::autoAveragesLinearLight()
|
||||
picture.setPixelColor(1, 0, QColor(20, 60, 120));
|
||||
const auto mean = [](const std::function<double(double)> &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();
|
||||
// The linear mean encoded again, as Swift's Auto does.
|
||||
const auto encode = [](double linear) { return linear <= 0.0031308 ? linear * 12.92 : 1.055 * std::pow(linear, 1 / 2.4) - 0.055; };
|
||||
const CameraRawSettings::Balance linear = CameraRawSettings::neutralizeStraight(encode(mean(decode, 200, 20)), encode(mean(decode, 100, 60)),
|
||||
encode(mean(decode, 50, 120))).value();
|
||||
const CameraRawSettings::Balance encoded =
|
||||
CameraRawSettings::neutralizeLinear(mean(same, 200, 20), mean(same, 100, 60), mean(same, 50, 120)).value();
|
||||
CameraRawSettings::neutralizeStraight(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);
|
||||
|
||||
+2
-1
@@ -315,6 +315,8 @@ target_include_directories(RawDitherTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(ToolDefaultsTests)
|
||||
compositor_test(ColorAdjustmentTests)
|
||||
compositor_test(CameraRawTests)
|
||||
compositor_test(CameraRawTablesTests)
|
||||
target_include_directories(CameraRawTablesTests PRIVATE ${OMAPHOTO_KERNELS})
|
||||
compositor_test(CameraRawColorNoiseTests)
|
||||
compositor_test(CameraRawToneOrderTests)
|
||||
compositor_test(LastFilterTests)
|
||||
@@ -389,5 +391,4 @@ compositor_test(SliderSnapTests)
|
||||
compositor_test(ProjectResizeFailureTests)
|
||||
|
||||
# The `.svgz` fixture is gzip, written through zlib.
|
||||
find_package(ZLIB REQUIRED)
|
||||
target_link_libraries(SVGImportTests PRIVATE ZLIB::ZLIB)
|
||||
Reference in new issue
Block a user