14.26: adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening

This commit is contained in:
ZacharyZhang-NY committed 2026-10-11 13:33:45 -04:00
1 parent a1453df517
commit 32f6362787
12 files changed
+289 -153

No files matched your search

+3 -2
View File
@@ -195,7 +195,7 @@ Platform adaptations, each forced by a missing Qt analog:
- The name field (14.13, Swift 1.4.7: `8f7d344`): a rename's `QLineEdit` has no frame and the name label's height and top, two points further left and wider, since a `QLineEdit` insets its text by two where the `QLabel` does not, so the text stays where the name was; its ground (`Base`, set as the rename begins) is the active `WindowText` at a tenth, Swift's quaternary label colour, and the name label hides beneath it until the rename ends, since Swift edits the label itself. `LayerListFocusTests::theNameFieldKeepsTheNamesSizeAndPlace`.
- Tool bars that scroll (14.14, Swift 1.4.7: `68396ff`): `ToolHeaderBar::scrolling` is Swift's horizontal `ScrollView` with hidden indicators, as the Move and Type bars already had it: the controls in a frameless `QScrollArea` without bars that takes the row's spare room, their content packed at its own size at the leading edge (a stretch of its own), so a narrow window scrolls them (a sideways wheel or trackpad, or the focus moving in) rather than squeezing them. The Brush, Gradient and selection bars use it (`brushScroll`, `gradientScroll`, `selectionScroll`); the Brush bar's clone and mask notes now scroll after the controls, as Swift's moved them inside; the Gradient's Mask note, Cancel and Apply and the selection's Empty selection and Deselect stay at the bar's end. Tests: `ToolHeaderScrollTests` (each bar narrow and wide, and each note where Swift puts it); `ColorSwatchTests` and `GradientCanvasTests` widen their bars, since a scrolling bar's own size hint no longer holds every control.
- Revealing the chosen layer (14.15, Swift 1.4.8: `d2b8aaf`, `b4e147b`): `selectLayer` and `selectLayers` end with `revealActiveLayer` (declared in `LayerGroups+Session.h`, as Swift's extension), which opens every folder above the active layer and says whether one opened; `selectLayer` then signals again, since its setter signalled before, so the last signal sees the folders open. Swift's `seen` set, a guard against a parent cycle, is left out: a session's document always passes `LayerHierarchy::validate`, so the chain ends at the root. `NativeLayerList::update` remembers the active layer it last scrolled to and scrolls a newly active row into view once its row exists (`ensureWidgetVisible`, no margin), so a later rename or refresh leaves a scroll made by hand: the session's setter signals while a shut folder still hides the row, where SwiftUI sees the selection and the open folders in one update, so the list waits for the row, and first hands the scroll area its posted layout request, since rows made anew have not yet grown the scroll range. Swift's own tests went with `b4e147b`; the twin keeps them. Equivalent mutant: the guard on an empty collapsed set (the walk then removes nothing; Swift's too). Tests: `LayerRevealTests` (Swift's five, the last signal with both and only the inner folder shut, a primary outside every folder opening nothing, a child of a shut folder below the view scrolled to by either call); `ProjectSessionTests` now collapses its folder after choosing a layer inside it.
- Camera Raw's Shadows and Highlights (14.16, Swift 1.4.8: `0b4c56c`): `kernels/AdjustPixels.c` is 0b4c56c's, unchanged: each follows a bump, u(1 − 2u)², over its half of the tones (Shadows from black, Highlights from white), two and a half times the bump to lift Shadows or pull Highlights down and once the other way, so tones never swap, and black, middle gray and white stay. Tests: `CameraRawToneOrderTests` (Swift's ramp kept in order at ±100 and the dark tones lifted; every level of a gray ramp against the curve written apart, each way at full and partial amounts and both together, Highlights first).
- Camera Raw's Shadows and Highlights (14.16, Swift 1.4.8: `0b4c56c`; since 14.26 Photoshop's measured tables, below): `kernels/AdjustPixels.c` is 0b4c56c's, unchanged: each follows a bump, u(1 − 2u)², over its half of the tones (Shadows from black, Highlights from white), two and a half times the bump to lift Shadows or pull Highlights down and once the other way, so tones never swap, and black, middle gray and white stay. Tests: `CameraRawToneOrderTests` (Swift's ramp kept in order; since 14.26 its curve test went with the curve).
- The Layers panel's gray (14.17, Swift 1.4.8: `06313a9`): `layersLookEditable` is Swift's: `canEditLayers` without what lasts only while the mouse is down (a stroke, a warp, a drag's transform edit, a pixel move) or a moment (`isProjectBusy`, read as `showsBusy`, which shows after 250 ms). The list's rows and their eyes (an effect's eye keeps `canEditLayers`, as Swift makes each effect row anew with it), the footer's buttons (new layer, folder, adjustments, delete with an active layer), the effects menu (with a layer that has pixels) and the mask button (one layer selected, without a mask) follow it, so nothing flashes gray for a click; every action still asks the session's own gate, which refuses meanwhile. The effects menu's folder term is left out as dead: a folder never holds pixels, so the pixel term refuses it. Tests: `LayersLookEditableTests` (each moment looks editable while the gate stays shut, each lasting state grays, the footer and list mid-stroke and under a pending transform, the effects menu's and mask button's own terms); the busy cases in `BlendModePickerTests`, `LayersPanelTests`, `NativeLayerListTests` and `LayerEffectRowTests` now wait for the indicator, and a click before it changes nothing.
- Export As (14.18, Swift 1.4.8: `9287cf1`, `450af5f`, `b1db93d`): `ProjectController::exportAs(start, done)` replaces `exportJPEG`; `ExportFormat` (png, jpeg, pdf, Swift's raw values through `rawValue`, `allExportFormats`) starts the sheet, else the last format exported this run (`m_lastExportFormat`, PNG at first), and the File menu's Export JPEG… opens it on JPEG; Export As… (Ctrl+Alt+Shift+W, a new shortcut definition) on the last. The flow is the JPEG one: the render in a worker, the sheet, then the format's panel (Export PNG, Export JPEG or Export PDF, suggesting `.png`, `.jpeg` or `.pdf`), the write in a worker, one alert titled "Couldn’t export" for a failure past the render. `UI/ExportAsSheet` (Swift's sheet, renamed from the JPEG one): titled Export As; the format as three checkable buttons; the size as two `PickerField`s keeping the proportions (a number applies on Return or leaving, else both fields show the size again; a scrub or menu replaces their typing) and a scale menu of 25, 50, 75, 100 and 200%, every scale held between a hundredth and the side and surface limits; Quality for JPEG alone (saved only from a JPEG export), the page size in inches at the document's DPI for PDF alone, the background swatch ("Export Background") for JPEG and PDF; "Transparency kept · sRGB" for PNG, else "sRGB"; a change of format or size asks again only when the settings change, as Swift's `task(id:)`. The encoding resizes first (`ImageExporter::resized`: smooth, the same raster at the same size, `tooLarge` past the limits) and encodes by format (`pngData`; `jpeg`; `pdfData` over `flattened` for the preview). `JPEGPreview` takes the export's size beside its image (`setImage(image, pixels, updating)`), so Fit and 100% follow the chosen size, and draws Swift 1.4.8's checkerboard (20-point tiles of grays 0.30 and 0.35, anchored at the image) behind what it shows. Swift's PDF comes from `CGContext`'s PDF writer; `QPdfWriter` rounds a page to whole points, so `ImageExporter::pdfData` writes PDF 1.4 by hand: one page whose MediaBox is the print size in points (72 / resolution a pixel, 72 when none), the background as a filled rectangle when given, the pixels as a Flate RGB image with a gray soft mask, straight colour (adaptation; checked against poppler's `pdfinfo` and `pdftoppm`). A Flate stream that `qCompress` cannot make (it returns nothing when memory runs out) is `ExportError(render)`, never an empty stream. A zoom set before the first image (Ctrl+1 while the sheet still encodes) centres that image when it lands, as Swift's preview, made with its first result, centres on appearing. The page's inches round as Swift's formatter: its shortest digits, halves to even (45 pixels at 72 DPI are 0.62 in). A size field reads Swift's `Int` field (`typedInteger`, checked against Swift 6.0.3): a sign, decimal digits of any script read by code point, a fraction and an exponent (its leading zeros dropped), the number truncated toward zero and exact within Int64 (`1e3` is 1000, `12.7` is 12, `-9.223372036854775808e18` is Int64's least, `1e-400` is 0, `128e` is 128), grouping refused; known difference: ICU's lenient parse also reads a leading number before other text (`7abc` is 7, `1,000` is 1, `--5` is −5), where the twin puts the size back; and ICU rounds some long fractions before Swift truncates them (`199.99999999999999` is 200, `0.99999999999999999` is 1, while `9007199254740993.5` stays …993 and `12.99999999999999` stays 12), a rule the measured cases do not pin down, so the twin truncates the digits as typed. Encoding finds no memory as `ExportError(render)` at `encode`'s door, so Export As's PNG worker never meets a `bad_alloc`. Equivalent mutant: the parser's `point <= 0` as `< 0` (a point at zero leaves no digits, which read as 0). Tests: `ExportPDFTests` (the page, its size, the straight pixels and alpha, rows and channels, the background, a refusal, resizing, flattening), `ExportPDFFailureTests` (a binary of its own: a 360-pixel PDF, small enough that Qt converts it on the calling thread (a pool thread that cannot start hangs Qt 6.4's conversion), under limits from 64 KB to 4 MB throws or inflates whole, both reached, and a PNG likewise throws or matches the unstarved file), `ExportAsSheetTests` (formats and their rows and bytes, the size and its limits, the checkerboard, the page against Swift 6.0.3's formatter), `JPEGPreviewTests::aZoomBeforeTheFirstImageCentresIt`, `ProjectExportTests` (each format through the controller, the menu's Export As on the last format), the entry in `CompositorMenusTests` and `KeyboardShortcutsTests` (its chord, group and place). Equivalent mutants: none found.
- Filter › Scanlines (14.19, Swift 1.4.9: `06c3a22`, `1187736`, `cdde327`): Scanlines leaves Dither for a filter of its own. `kernels/DitherPixels.c/.h` are `cdde327`'s, with 13.17's adaptation carried on: Swift's GCD blocks become static band functions with context structs (`scanlines_tone_band`, `line_scan_band`, `blur_band`, `spread_band`, `column_band`, `split_band`), each failure flag an `atomic_int`, every band through `dither_bands`; the arithmetic is Swift's word for word, and `dither_glow`'s new easing near white came with it. `ScanlinesTests::theKernelDrawsSwiftsOwnBytes` holds that: Swift's own file, built unchanged with clang's blocks and libdispatch in the `swift:6.0.3` image, drew five scenes (37 by 53 and 70 by 300, every setting, both directions of Displace, 32 bands of columns, lines and rows) and a glow, and the twin's bytes hash the same (FNV-1a). `Document/Scanlines.h/.cpp` twin Swift's file: `ScanlinesSettings` with Swift's fields, defaults (glow 0 now, thickness 70, smoothness 50) and clamps, Line Spacing and Color Split rounded, `apply` handing the kernel the normalized settings and Two Colors' bytes (black on white otherwise), then 13.17's `glowing` moved here unchanged. `DitherStyle` loses `scanlines` (`usesPixelSize` is ASCII alone, `drawsMarks` every style without tones) and `DitherSettings` its four fields. `FilterKind::scanlines` ("Scanlines") sits after Dither in Swift's order, so the Filter menu lists it there; it previews whole, as Dither does. Its two colours reuse Dither's picker (`openDitherColorPicker`, `setDitherColor`, Swift's), choosing the edit's own settings, and the titles drop "Dither" (`Color Picker (Light Color)`, `(Dark Color)`). The sheet's `scanlines` (`UI/FilterSheet+Scanlines.cpp`) is Swift's `scanlinesControls`: twelve rows with Swift's ranges, units and help, then the Colors menu and swatches, which `colorChoices` builds for Dither too (the screen's and the lines' colour help for Scanlines). Equivalent mutant: `glowing` run at glow 0 (it adds nothing). Tests: `ScanlinesTests` (Swift's eight, Swift's own bytes, each setting reaching its field, Two Colors rounded, every clamp through `FilterSettings` too, clear pixels, the eased glow against a full-size blur), `ScanlinesSheetTests` (the menu place and a commit, the sheet's rows, ranges and fields, both pickers), the order in `FilterModelTests`, `FinishingFilterTests`, `DitherSheetTests` and `CompositorMenusTests`, the acceptance run's Scanlines row through the menu.
@@ -203,7 +203,8 @@ 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. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search), `CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows` (the order against the kernel itself) and Auto through the encoded mean, a dark pixel on its straight segment.
- Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` were 57ab3bb's: Exposure, white balance and Contrast ran as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after (one table since 14.26). `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes are raw DEFLATE, as Foundation's `.zlib` writes them, inflated through zlib (`ZLIB::ZLIB`, which the PKGBUILD and the flake now name). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search), `CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows` (the order against the kernel itself) and Auto through the encoded mean, a dark pixel on its straight segment.
- Adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (14.26, Swift 1.4.9: `75147a2`): `kernels/AdjustPixels.c/.h` are 75147a2's and the tables its 285 (`CRT2`, 1.4 MB): Contrast, Highlights and Shadows each blend four tables round their value (−100, −50, −25, 0, 25, 50, 100) and the picture's brightness (53, 80, 104, 130, 160, 190, 224 in sRGB levels, `between` over points, held at the ends, the lower pair taking a point whole), read before any stage by `camera_raw_statistics` (`CameraRawTables::brightness`, `Brightness`: the brightest channel's mean for Contrast, the log-average for Highlights, the luminance for Shadows), only when one of the three is set, else 128 each; every Light and Color slider is now one composed table (`stages` returns one list in Camera Raw's order) and `adjust_camera_raw` takes it alone. Dehaze removes or adds the haze colour of the brightest one percent and leaves one colour be; Texture and Clarity are guided filters at radii in proportion to the picture, so the preview's scale no longer reaches them (Glow's still does); Sharpening sharpens everywhere at Masking 0. These live in Swift's kernel, unchanged. The 14.16 tone curve is gone with it, and its curve test. Equivalent mutant: reading the brightness when none of the three is set (the stages then never read it). Tests: Swift's changed `CameraRawTests` (Highlights and Shadows by Photoshop's numbers, Dehaze on a shadow under a pale sky, one colour left be) and `CameraRawToneOrderTests` (Highlights −100's white), `CameraRawTablesTests` (the 285 tables' hash and entries, each slider's tables, the adaptive blends from an independent Python run, held ends and a measured point, three stages composed, mid gray's brightness), `CameraRawWiringTests::theTableReadsThePicturesBrightness` (the grade against the kernel through the picture's own brightness, not the default), `CameraRawRulesTests` (Glow reads the scale, Clarity no longer).
- 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.
+120 -51
View File
@@ -201,6 +201,16 @@ static double camera_clamp(double value) {
return value;
}
static double srgb_decode(double encoded) {
return encoded <= 0.04045 ? encoded / 12.92 : pow((encoded + 0.055) / 1.055, 2.4);
}
static double srgb_encode(double linear) {
if (linear <= 0) return 0;
if (linear >= 1) return 1;
return linear <= 0.0031308 ? linear * 12.92 : 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;
}
@@ -221,28 +231,6 @@ static void scale_luminance(double *r, double *g, double *b, double target) {
*b = camera_clamp(*b * scale);
}
// A bump over a range of tones, 0 at both ends: u(1 - 2u)², largest a sixth of the way in. Its slope runs from 1
// down to -1/3, so a curve adding up to three times it (or taking away up to once it) keeps rising: tones never swap
// places.
static double tone_bump(double u) {
if (u <= 0 || u >= 0.5) return 0;
double rest = 1.0 - 2.0 * u;
return u * rest * rest;
}
// Shadows lifts (or deepens) the dark tones, most a third of the way up to middle gray, leaving black at black and
// middle gray where it is. It once lifted black itself to middle gray, above the tones just over it, so the darkest,
// least certain pixels were the ones stretched furthest: a lifted dark area broke into blotches of color.
static double tone_shadows(double y, double amount) {
return camera_clamp(y + amount * (amount >= 0 ? 2.5 : 1.0) * tone_bump(y));
}
// Highlights, the same in the light tones: white stays white, and pulling them down no longer drops white below the
// tones under it.
static double tone_highlights(double y, double amount) {
return camera_clamp(y + amount * (amount >= 0 ? 1.0 : 2.5) * tone_bump(1.0 - y));
}
// 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];
@@ -324,10 +312,30 @@ 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, const float *before,
const float *after, int grid, double highlights, double shadows, int clipping) {
double highlightAmount = highlights / 100.0;
double shadowAmount = shadows / 100.0;
void camera_raw_statistics(const uint8_t *rgba, size_t width, size_t height, size_t stride, double *out) {
double brightest = 0, luminance = 0, logBrightest = 0, weight = 0;
for (size_t y = 0; y < height; ++y) {
const uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
const uint8_t *p = row + x * 4;
double alpha = p[3];
if (!alpha) continue;
double r = srgb_decode(fmin(1.0, p[0] / alpha)), g = srgb_decode(fmin(1.0, p[1] / alpha));
double b = srgb_decode(fmin(1.0, p[2] / alpha)), top = fmax(r, fmax(g, b)), w = alpha / 255.0;
brightest += w * top;
luminance += w * rec709(r, g, b);
logBrightest += w * log(top + 3e-4);
weight += w;
}
}
if (weight <= 0) { out[0] = out[1] = out[2] = 0.5; return; }
out[0] = srgb_encode(brightest / weight);
out[1] = srgb_encode(luminance / weight);
out[2] = srgb_encode(exp(logBrightest / weight) - 3e-4);
}
void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *table, int grid,
int clipping) {
for (size_t y = 0; y < height; ++y) {
uint8_t *row = rgba + y * stride;
for (size_t x = 0; x < width; ++x) {
@@ -337,10 +345,7 @@ 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;
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 (table) composed_lookup(table, 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;
@@ -398,6 +403,30 @@ static int box_blur_plane(const float *src, float *dst, size_t width, size_t hei
return 1;
}
// An edge-aware blur (He's guided filter, the plane guiding itself): smooth within regions, stopping at edges, so
// the detail taken against it has no halos. `epsilon` is the variance an edge needs to be kept.
static int guided_filter_plane(const float *src, float *dst, size_t width, size_t height, int radius, float epsilon) {
size_t count = width * height;
float *mean = malloc(count * sizeof(float)), *square = malloc(count * sizeof(float));
float *a = malloc(count * sizeof(float)), *b = malloc(count * sizeof(float));
int ok = mean && square && a && b && box_blur_plane(src, mean, width, height, radius);
if (ok) {
for (size_t i = 0; i < count; ++i) a[i] = src[i] * src[i];
ok = box_blur_plane(a, square, width, height, radius);
}
if (ok) {
for (size_t i = 0; i < count; ++i) {
float variance = square[i] - mean[i] * mean[i];
a[i] = variance / (variance + epsilon);
b[i] = mean[i] - a[i] * mean[i];
}
ok = box_blur_plane(a, mean, width, height, radius) && box_blur_plane(b, square, width, height, radius);
}
if (ok) for (size_t i = 0; i < count; ++i) dst[i] = mean[i] * src[i] + square[i];
free(mean); free(square); free(a); free(b);
return ok;
}
static int effects_radius(double base, double scale) {
double radius = base * (scale > 0 ? scale : 1);
if (radius < 1) radius = 1;
@@ -405,20 +434,51 @@ static int effects_radius(double base, double scale) {
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);
// Dehaze, after Camera Raw's: `haze` is the picture's haze color, its brightest one percent. Taking haze away
// (positive) removes it where a pixel's darkest channel, against the haze, says it lies; adding it (negative) veils the
// picture in it. Measured against Photoshop's Camera Raw Filter.
static void effects_dehaze(double *r, double *g, double *b, double amount, const double haze[3]) {
double d = amount / 100.0, c[3] = {*r, *g, *b};
if (d < 0) {
double veil = 0.3 * pow(-d, 1.3);
for (int k = 0; k < 3; ++k) c[k] = c[k] * (1 - veil) + haze[k] * veil;
} else {
double dark = 1;
for (int k = 0; k < 3; ++k) dark = fmin(dark, c[k] / fmax(haze[k], 1e-3));
double transmission = fmax(0.5, 1 - 0.7 * d * dark);
for (int k = 0; k < 3; ++k) c[k] = (c[k] - haze[k]) / transmission + haze[k];
}
*r = camera_clamp(c[0]);
*g = camera_clamp(c[1]);
*b = camera_clamp(c[2]);
}
// The picture's haze color for Dehaze: the mean of its brightest one percent of pixels by luminance. A picture of
// one color is all haze, so Dehaze leaves it be, as Photoshop's does.
static void dehaze_haze(const uint8_t *rgba, size_t width, size_t height, size_t stride, double haze[3]) {
double histogram[256] = {0};
for (size_t y = 0; y < height; ++y)
for (size_t x = 0; x < width; ++x) {
const uint8_t *p = rgba + y * stride + x * 4;
if (!p[3]) continue;
int level = (int)lround(255 * rec709(fmin(1.0, (double)p[0] / p[3]), fmin(1.0, (double)p[1] / p[3]), fmin(1.0, (double)p[2] / p[3])));
histogram[level] += p[3] / 255.0;
}
double total = 0;
for (int i = 0; i < 256; ++i) total += histogram[i];
int threshold = 255;
for (double above = 0; threshold > 0 && above + histogram[threshold] < total * 0.01; --threshold) above += histogram[threshold];
double sum[3] = {0, 0, 0}, weight = 0;
for (size_t y = 0; y < height; ++y)
for (size_t x = 0; x < width; ++x) {
const uint8_t *p = rgba + y * stride + x * 4;
if (!p[3]) continue;
double c[3] = {fmin(1.0, (double)p[0] / p[3]), fmin(1.0, (double)p[1] / p[3]), fmin(1.0, (double)p[2] / p[3])};
if (lround(255 * rec709(c[0], c[1], c[2])) < threshold) continue;
for (int k = 0; k < 3; ++k) sum[k] += c[k] * p[3];
weight += p[3];
}
for (int k = 0; k < 3; ++k) haze[k] = weight > 0 ? sum[k] / weight : 1;
}
/// The vignette's strength at a point `px`, `py` of a `width` × `height` frame (0 at its middle, 1 past its edges).
@@ -561,13 +621,18 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_
luma[y * width + x] = (float)rec709(r, g, b);
}
}
// Radii in proportion to the picture, as fitted to Photoshop's on 1456-pixel-wide photos (8 and 32 there), so
// a downscaled preview matches the full-size result.
double side = (double)(width > height ? width : height);
if (texture != 0) {
fine = malloc(count * sizeof(float));
if (!fine || !box_blur_plane(luma, fine, width, height, effects_radius(1, scale))) failed = 1;
int radius = (int)fmax(1, lround(side * 8 / 1456));
if (!fine || !guided_filter_plane(luma, fine, width, height, radius, 0.01f)) 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;
int radius = (int)fmax(2, lround(side * 32 / 1456));
if (!coarse || !guided_filter_plane(luma, coarse, width, height, radius, 0.01f)) failed = 1;
}
if (!failed && glow > 0) {
double spread = glowSpread / 100.0;
@@ -597,6 +662,8 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_
free(luma); free(fine); free(coarse); free(glowPlane);
return;
}
double haze[3] = {1, 1, 1};
if (dehaze != 0) dehaze_haze(rgba, width, height, stride, haze);
double warmth = glowWarmth / 100.0;
double glowRed, glowGreen, glowBlue, glowGain;
if (glowStyle == 2) {
@@ -624,11 +691,11 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_
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 (fine) detail += (texture / 100.0) * (texture > 0 ? 0.6 : 0.5) * (tone - fine[index]);
if (coarse) detail += (clarity / 100.0) * (clarity > 0 ? 0.85 : 0.5) * (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 (dehaze != 0) effects_dehaze(&r, &g, &b, dehaze, haze);
if (glowPlane && glow > 0) {
double add = glowPlane[index] * (glow / 100.0) * glowGain;
r = camera_clamp(r + add * glowRed);
@@ -1035,9 +1102,11 @@ void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t
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));
// Masking at 0 sharpens everywhere, as in Photoshop; raising it keeps the sharpening to edges.
double mask = threshold > 0 ? camera_clamp((edge * (0.5 + detailMix) - threshold) / fmax(0.04, 0.35 - threshold * 0.5)) : 1;
double high = luma[index] - work[index];
double sharpened = camera_clamp(luma[index] + high * amount * mask * (0.5 + detailMix));
// 1.9 × (0.5 + Detail) is Photoshop's strength: 1.4 at its default Detail of 25, measured on photos.
double sharpened = camera_clamp(luma[index] + high * amount * mask * 1.9 * (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);
+9 -7
View File
@@ -41,13 +41,15 @@ typedef struct {
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);
// What Camera Raw's adaptive sliders read from an image, as sRGB levels (0…1), opaque pixels counting by their
// alpha: the mean of each pixel's brightest channel in linear light (Contrast), the mean linear luminance (Shadows),
// and the log-average of the brightest channel (Highlights).
void camera_raw_statistics(const uint8_t *rgba, size_t width, size_t height, size_t stride, double *out);
// Camera Raw's Light and Color groups on premultiplied RGBA pixels, through one composed table (null for none).
// `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 *table, int grid,
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,
+5 -4
View File
@@ -135,10 +135,11 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip
if (!clipping && !sharpenMask && settings.calibration.adjusts())
settings.applyCalibration(pixels, width, height, stride);
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);
const CameraRawTables::Brightness brightness = settings.contrast != 0 || settings.highlights != 0 || settings.shadows != 0
? CameraRawTables::brightness(pixels, width, height, stride)
: CameraRawTables::Brightness();
const std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings, brightness));
adjust_camera_raw(pixels, w, h, s, table.empty() ? nullptr : table.data(), CameraRawTables::grid, mode);
}
if (paintColor)
settings.applyCurveColor(pixels, width, height, stride, visualizePointColor);
+46 -12
View File
@@ -4,6 +4,7 @@
#include "Logging.h"
#include <QFile>
#include <array>
#include <algorithm>
#include <stdexcept>
#include <zlib.h>
@@ -11,9 +12,11 @@
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;
// White balance, five sliders, then three adaptive ones.
constexpr int exposureStart = 81, whitesStart = 102, blacksStart = 111, saturationStart = 120, vibranceStart = 129;
constexpr int contrastStart = 138, highlightsStart = 187, shadowsStart = 236, count = 285;
constexpr std::array<double, 7> brightnesses{53, 80, 104, 130, 160, 190, 224};
constexpr std::array<double, 7> adaptiveValues{-100, -50, -25, 0, 25, 50, 100};
// Raw DEFLATE, as Foundation's `.zlib` writes it.
std::vector<uint8_t> inflated(const QByteArray &packed, size_t expected)
@@ -83,6 +86,26 @@ Between between(double value, double low, double step, int tables)
return {index, position - index};
}
// An adaptive slider: four tables round value and brightness.
Between between(double value, const std::array<double, 7> &points)
{
const double held = std::min(points.back(), std::max(points.front(), value));
int index = 0;
while (index < int(points.size()) - 2 && held > points[index + 1])
++index;
return {index, (held - points[index]) / (points[index + 1] - points[index])};
}
// An adaptive slider: four tables round its value and brightness.
CameraRawStage adaptive(int start, double value, double brightness)
{
const Between row = between(brightness, brightnesses), column = between(value, adaptiveValues);
const double down = row.fraction, across = column.fraction;
const int width = int(adaptiveValues.size()), base = start + row.index * width + column.index;
return CameraRawStage{{table(base), table(base + 1), table(base + width), table(base + width + 1)},
{float((1 - down) * (1 - across)), float((1 - down) * across), float(down * (1 - across)), float(down * across)}};
}
CameraRawStage stage(int start, double value, double low, double step, int tables)
{
const Between at = between(value, low, step, tables);
@@ -106,23 +129,34 @@ CameraRawStage whiteBalance(double temperature, double tint)
{float((1 - down) * (1 - across)), float((1 - down) * across), float(down * (1 - across)), float(down * across)}};
}
Stages stages(const CameraRawSettings &settings)
Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride)
{
Stages out;
double out[3] = {0, 0, 0};
camera_raw_statistics(pixels, size_t(width), size_t(height), size_t(stride), out);
return {out[0] * 255, out[1] * 255, out[2] * 255};
}
std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness)
{
std::vector<CameraRawStage> out;
if (settings.exposure != 0)
out.before.push_back(stage(exposureStart, settings.exposure, -5, 0.5, 21));
out.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));
out.push_back(whiteBalance(settings.temperature, settings.tint));
if (settings.contrast != 0)
out.before.push_back(stage(contrastStart, settings.contrast, -100, 25, 9));
out.push_back(adaptive(contrastStart, settings.contrast, brightness.brightest));
if (settings.highlights != 0)
out.push_back(adaptive(highlightsStart, settings.highlights, brightness.logBrightest));
if (settings.shadows != 0)
out.push_back(adaptive(shadowsStart, settings.shadows, brightness.luminance));
if (settings.whites != 0)
out.after.push_back(stage(whitesStart, settings.whites, -100, 25, 9));
out.push_back(stage(whitesStart, settings.whites, -100, 25, 9));
if (settings.blacks != 0)
out.after.push_back(stage(blacksStart, settings.blacks, -100, 25, 9));
out.push_back(stage(blacksStart, settings.blacks, -100, 25, 9));
if (settings.saturation != 0)
out.after.push_back(stage(saturationStart, settings.saturation, -100, 25, 9));
out.push_back(stage(saturationStart, settings.saturation, -100, 25, 9));
if (settings.vibrance != 0)
out.after.push_back(stage(vibranceStart, settings.vibrance, -100, 25, 9));
out.push_back(stage(vibranceStart, settings.vibrance, -100, 25, 9));
return out;
}
+9 -6
View File
@@ -8,7 +8,7 @@ extern "C" {
#include <optional>
#include <vector>
// Photoshop's Light and Color sliders, measured as 17³ colour tables.
// Photoshop's Light and Color, measured as 17³ tables; some adapt.
namespace CameraRawTables {
constexpr int size = 17;
// The grid the stages compose onto, finer than the tables.
@@ -18,12 +18,15 @@ constexpr int grid = 33;
const std::vector<uint8_t> &tables();
// Temperature and Tint: the four grid tables around them.
CameraRawStage whiteBalance(double temperature, double tint);
struct Stages {
std::vector<CameraRawStage> before;
std::vector<CameraRawStage> after;
// What adaptive sliders read first, in sRGB levels.
struct Brightness {
double brightest = 128;
double luminance = 128;
double logBrightest = 128;
};
// Before Highlights and Shadows, and after; zero sliders left out.
Stages stages(const CameraRawSettings &settings);
Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride);
// Camera Raw's order, sliders at zero left out.
std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness);
// 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.
Binary file not shown.
+5 -1
View File
@@ -130,12 +130,16 @@ void CameraRawRulesTests::theClippingAndMaskViewsShowNothingElse()
void CameraRawRulesTests::theScaleDefaultsToOneAndReachesTheRadii()
{
// Glow's reach reads the scale; Clarity's follows the picture.
CameraRawSettings settings;
settings.clarity = 100;
settings.glow = 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)));
CameraRawSettings clear;
clear.clarity = 100;
QVERIFY(pixels(clear.apply(edge, std::nullopt, 0.25)) == pixels(clear.apply(edge, std::nullopt, 1)));
// Grain's units read the scale: a negative one is one.
CameraRawSettings grainy;
grainy.grainAmount = 60;
+57 -21
View File
@@ -11,6 +11,7 @@ private slots:
void theTablesDecodeAsWritten();
void aStageBlendsTheTablesAroundItsValue();
void eachSliderPicksItsOwnTables();
void theAdaptiveSlidersBlendAcrossBrightness();
void neutralizeFindsTheGridsGrayest();
void identitySlidersComposeNothing();
};
@@ -32,14 +33,16 @@ int indexOf(const uint8_t *table)
void CameraRawTablesTests::theTablesDecodeAsWritten()
{
const std::vector<uint8_t> &tables = CameraRawTables::tables();
QCOMPARE(tables.size(), size_t(147) * 17 * 17 * 17 * 3);
QCOMPARE(tables.size(), size_t(285) * 17 * 17 * 17 * 3);
quint64 hash = 0xcbf29ce484222325ULL;
for (const uint8_t byte : tables)
hash = (hash ^ byte) * 0x100000001b3ULL;
QCOMPARE(hash, quint64(0x6a9f829e703f2df6ULL));
QCOMPARE(hash, quint64(0x770d674136320424ULL));
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}));
QCOMPARE(std::vector<int>(entry(200, 2456), entry(200, 2456) + 3), (std::vector<int>{172, 172, 172}));
QCOMPARE(std::vector<int>(entry(284, 100), entry(284, 100) + 3), (std::vector<int>{70, 108, 255}));
}
void CameraRawTablesTests::aStageBlendsTheTablesAroundItsValue()
@@ -64,29 +67,62 @@ 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 std::vector<CameraRawStage> stages = CameraRawTables::stages(settings, {});
QCOMPARE(stages.size(), size_t(6));
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);
QCOMPARE(pair(stages[0]), (std::vector<double>{93, 94, 0.5, 0.5}));
QCOMPARE(indexOf(stages[1].table[0]), 5 * 9 + 4);
QCOMPARE(pair(stages[2]), (std::vector<double>{109, 110, 0, 1}));
QCOMPARE(pair(stages[3]), (std::vector<double>{115, 116, 0.5, 0.5}));
QCOMPARE(pair(stages[4]), (std::vector<double>{121, 122, 0.5, 0.5}));
QVERIFY(indexOf(stages[5].table[0]) == 136 && std::abs(stages[5].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);
const std::vector<CameraRawStage> tinted = CameraRawTables::stages(tint, {});
QVERIFY(tinted.size() == 1 && indexOf(tinted[0].table[0]) == 4 * 9 + 3);
}
// Seven brightnesses down, seven values across, each read alone.
void CameraRawTablesTests::theAdaptiveSlidersBlendAcrossBrightness()
{
const auto four = [](const CameraRawStage &stage) {
return std::vector<double>{double(indexOf(stage.table[0])), double(indexOf(stage.table[1])), double(indexOf(stage.table[2])),
double(indexOf(stage.table[3])), stage.weight[0], stage.weight[1], stage.weight[2], stage.weight[3]};
};
CameraRawSettings settings;
settings.contrast = 10;
settings.highlights = -100;
settings.shadows = 100;
settings.saturation = 25;
const std::vector<CameraRawStage> stages = CameraRawTables::stages(settings, {120, 250, 30});
QCOMPARE(stages.size(), size_t(4));
QCOMPARE(four(stages[0]), (std::vector<double>{155, 156, 162, 163, 0.23076923191547394, 0.1538461595773697, 0.3692307770252228, 0.2461538463830948}));
QCOMPARE(four(stages[1]), (std::vector<double>{187, 188, 194, 195, 1, 0, 0, 0}));
QCOMPARE(four(stages[2]), (std::vector<double>{276, 277, 283, 284, 0, 0, 0, 1}));
QCOMPARE(indexOf(stages[3].table[0]), 125);
// Contrast reads the brightest channel, Highlights the log-average.
settings = CameraRawSettings();
settings.contrast = -37.5;
settings.highlights = 50;
const std::vector<CameraRawStage> low = CameraRawTables::stages(settings, {53, 0, 224});
QCOMPARE(four(low[0]), (std::vector<double>{139, 140, 146, 147, 0.5, 0.5, 0, 0}));
QCOMPARE(four(low[1]), (std::vector<double>{226, 227, 233, 234, 0, 0, 0, 1}));
// On a measured point the lower pair takes it whole.
settings = CameraRawSettings();
settings.shadows = 25;
const std::vector<CameraRawStage> on = CameraRawTables::stages(settings, {0, 104, 0});
QCOMPARE(four(on[0]), (std::vector<double>{246, 247, 253, 254, 0, 0, 0, 1}));
// A mid-gray picture reads 128 by every measure.
const QImage mid = gray(8, 8);
const CameraRawTables::Brightness read = CameraRawTables::brightness(mid.constBits(), 8, 8, mid.bytesPerLine());
QVERIFY(std::abs(read.brightest - 128) < 0.01 && std::abs(read.luminance - 128) < 0.01 && std::abs(read.logBrightest - 128) < 0.01);
}
void CameraRawTablesTests::neutralizeFindsTheGridsGrayest()
@@ -118,11 +154,11 @@ void CameraRawTablesTests::neutralizeFindsTheGridsGrayest()
void CameraRawTablesTests::identitySlidersComposeNothing()
{
const CameraRawTables::Stages none = CameraRawTables::stages(CameraRawSettings());
QVERIFY(none.before.empty() && none.after.empty() && CameraRawTables::compose(none.before).empty());
const std::vector<CameraRawStage> none = CameraRawTables::stages(CameraRawSettings(), {});
QVERIFY(none.empty() && CameraRawTables::compose(none).empty());
CameraRawSettings exposure;
exposure.exposure = 0.5;
const std::vector<float> composed = CameraRawTables::compose(CameraRawTables::stages(exposure).before);
const std::vector<float> composed = CameraRawTables::compose(CameraRawTables::stages(exposure, {}));
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);
@@ -131,7 +167,7 @@ void CameraRawTablesTests::identitySlidersComposeNothing()
three.exposure = 1;
three.temperature = 40;
three.contrast = 50;
const std::vector<float> run = CameraRawTables::compose(CameraRawTables::stages(three).before);
const std::vector<float> run = CameraRawTables::compose(CameraRawTables::stages(three, {}));
const auto at = [&](int r, int g, int b) {
const size_t index = ((size_t(r) * 33 + g) * 33 + b) * 3;
return std::array<float, 3>{run[index], run[index + 1], run[index + 2]};
@@ -139,8 +175,8 @@ void CameraRawTablesTests::identitySlidersComposeNothing()
const auto near = [](std::array<float, 3> got, std::array<float, 3> expected) {
return std::abs(got[0] - expected[0]) < 1e-6f && std::abs(got[1] - expected[1]) < 1e-6f && std::abs(got[2] - expected[2]) < 1e-6f;
};
QVERIFY(near(at(16, 16, 16), {0.94074458f, 0.85085034f, 0.72421813f}));
QVERIFY(near(at(16, 4, 28), {0.85322261f, 0.50834316f, 0.98791242f}));
QVERIFY(near(at(16, 16, 16), {0.94272572f, 0.86208665f, 0.74603087f}));
QVERIFY(near(at(16, 4, 28), {0.86772656f, 0.55830115f, 0.98769760f}));
}
QTEST_GUILESS_MAIN(CameraRawTablesTests)
+20 -8
View File
@@ -63,7 +63,8 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName()
CameraRawSettings settings;
settings.highlights = -100;
const auto recovered = pixels(settings.apply(brightAndMid));
QVERIFY(recovered[0][0] < 200 && std::abs(recovered[1][0] - 128) <= 2);
// Photoshop: 230 to 209, mid gray kept.
QVERIFY(recovered[0][0] < 222 && std::abs(recovered[1][0] - 128) <= 4);
settings = CameraRawSettings();
settings.whites = 100;
const auto clipped = pixels(settings.apply(brightAndMid));
@@ -74,7 +75,8 @@ void CameraRawTests::tonalSlidersMoveTheEndTheyName()
settings = CameraRawSettings();
settings.shadows = 100;
const auto opened = pixels(settings.apply(darkAndMid));
QVERIFY(opened[0][0] > 50 && std::abs(opened[1][0] - 128) <= 2);
// Photoshop: 20 to 33, mid gray kept.
QVERIFY(opened[0][0] > 22 && std::abs(opened[1][0] - 128) <= 6);
settings = CameraRawSettings();
settings.blacks = -100;
const auto crushed = pixels(settings.apply(darkAndMid));
@@ -172,15 +174,25 @@ void CameraRawTests::textureAndClaritySharpenAnEdgeAndLeaveAFlatField()
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);
// A shadow under a pale sky; one colour, no haze.
QImage skyAndShadow = BrushRaster::context(10, 10, false);
{
QPainter painter(&skyAndShadow);
painter.fillRect(QRect(0, 0, 10, 5), QColor(200, 195, 190));
painter.fillRect(QRect(0, 5, 10, 5), QColor(30, 30, 30));
}
const int shadow = 5 * 10;
QCOMPARE(pixels(skyAndShadow)[shadow][0], 30);
CameraRawSettings settings;
settings.dehaze = 100;
QVERIFY(pixels(settings.apply(dark))[0][0] < 30);
const std::array<int, 4> paleBefore = pixels(pale)[0];
QVERIFY(chroma(pixels(settings.apply(pale))[0]) > chroma(paleBefore));
QVERIFY(pixels(settings.apply(skyAndShadow))[shadow][0] < 30);
settings.dehaze = -100;
QVERIFY(pixels(settings.apply(dark))[0][0] > 30);
QVERIFY(chroma(pixels(settings.apply(pale))[0]) < chroma(paleBefore));
QVERIFY(pixels(settings.apply(skyAndShadow))[shadow][0] > 30);
const QImage pale = image(4, 4, 180 / 255.0, 150 / 255.0, 150 / 255.0);
for (const double amount : {100.0, -100.0}) {
settings.dehaze = amount;
QVERIFY((pixels(settings.apply(pale))[0] == std::array{180, 150, 150, 255}));
}
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]);
+4 -28
View File
@@ -2,7 +2,7 @@
#include <QtTest>
#include <cmath>
// Swift 1.4.8: Shadows and Highlights keep tones in order.
// Swift 1.4.9: Shadows and Highlights keep tones in order.
using namespace CameraRawFixtures;
namespace {
@@ -25,19 +25,12 @@ std::vector<int> levels(double shadows, double highlights)
out.push_back(pixel[1]);
return out;
}
// Swift's bump, u(1 - 2u)² to a half, apart.
double bump(double u)
{
return u <= 0 || u >= 0.5 ? 0 : u * (1 - 2 * u) * (1 - 2 * u);
}
}
class CameraRawToneOrderTests : public QObject {
Q_OBJECT
private slots:
void shadowsAndHighlightsKeepTonesInOrder();
void eachToneFollowsSwiftsCurve();
};
void CameraRawToneOrderTests::shadowsAndHighlightsKeepTonesInOrder()
@@ -45,27 +38,10 @@ void CameraRawToneOrderTests::shadowsAndHighlightsKeepTonesInOrder()
for (const auto &[shadows, highlights] : std::vector<std::pair<double, double>>{{100, 0}, {-100, 0}, {0, 100}, {0, -100}}) {
const std::vector<int> out = levels(shadows, highlights);
QVERIFY2(std::ranges::is_sorted(out), qPrintable(QStringLiteral("shadows %1, highlights %2").arg(shadows).arg(highlights)));
QVERIFY2(out[0] <= 2 && out[255] >= 253, qPrintable(QStringLiteral("black %1, white %2").arg(out[0]).arg(out[255])));
}
QVERIFY(levels(100, 0)[45] >= 45 + 20);
}
void CameraRawToneOrderTests::eachToneFollowsSwiftsCurve()
{
// Gray stays gray; Highlights first, then Shadows.
for (const auto &[shadows, highlights] : std::vector<std::pair<double, double>>{{100, 0}, {-100, 0}, {60, 0}, {-50, 0}, {0, 100}, {0, -100}, {0, -40}, {0, 50}, {60, -40}, {-50, 50}}) {
const std::vector<int> out = levels(shadows, highlights);
const double low = shadows / 100, high = highlights / 100;
for (int level = 0; level < 256; ++level) {
const double y = level / 255.0;
const double lit = std::clamp(y + high * (high >= 0 ? 1.0 : 2.5) * bump(1 - y), 0.0, 1.0);
const double lifted = std::clamp(lit + low * (low >= 0 ? 2.5 : 1.0) * bump(lit), 0.0, 1.0);
QVERIFY2(std::abs(out[size_t(level)] - std::lround(lifted * 255)) <= 1,
qPrintable(QStringLiteral("%1, %2 at %3: %4").arg(shadows).arg(highlights).arg(level).arg(out[size_t(level)])));
}
// Black, middle gray and white stay where they were.
QVERIFY(out[0] <= 1 && std::abs(out[128] - 128) <= 1 && out[255] >= 254);
// Photoshop's Highlights −100 brings white to 239 here.
QVERIFY2(out[0] <= 2 && out[255] >= (highlights < 0 ? 235 : 253), qPrintable(QStringLiteral("black %1, white %2").arg(out[0]).arg(out[255])));
}
QVERIFY(levels(100, 0)[45] >= 45 + 12);
}
QTEST_MAIN(CameraRawToneOrderTests)
+11 -13
View File
@@ -66,7 +66,7 @@ class CameraRawWiringTests : public QObject {
Q_OBJECT
private slots:
void aClipViewShowsOnAnUntouchedGrade();
void theTablesRunAroundHighlightsAndShadows();
void theTableReadsThePicturesBrightness();
void autoAveragesLinearLight();
void eachEyeClearsEveryFieldOfItsGroup();
void effectsReachTheKernelInOrder();
@@ -85,13 +85,13 @@ 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, nullptr, nullptr, CameraRawTables::grid, 0, 0, int(CameraRawClipping::highlights));
adjust_camera_raw(bytes, w, h, s, nullptr, CameraRawTables::grid, int(CameraRawClipping::highlights));
});
QVERIFY(pixels(clipped) == pixels(direct) && pixels(clipped) != pixels(picture));
}
// Exposure, balance and Contrast first; Whites to Vibrance after.
void CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows()
// One composed table, its adaptive stages read from the picture.
void CameraRawWiringTests::theTableReadsThePicturesBrightness()
{
const QImage picture = noise();
CameraRawSettings settings;
@@ -101,15 +101,13 @@ void CameraRawWiringTests::theTablesRunAroundHighlightsAndShadows()
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));
const CameraRawTables::Brightness read = CameraRawTables::brightness(picture.constBits(), picture.width(), picture.height(), picture.bytesPerLine());
QVERIFY(std::abs(read.brightest - 128) > 5);
const auto graded = [&](const CameraRawTables::Brightness &brightness) {
const std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings, brightness));
return kernel(picture, [&](uchar *bytes, size_t w, size_t h, size_t s) { adjust_camera_raw(bytes, w, h, s, table.data(), CameraRawTables::grid, 0); });
};
QVERIFY(pixels(settings.apply(picture)) == pixels(graded(read)) && pixels(graded(read)) != pixels(graded({})));
}
void CameraRawWiringTests::autoAveragesLinearLight()