diff --git a/AGENTS.md b/AGENTS.md index d1d22bc..0430bb0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,7 +205,8 @@ Platform adaptations, each forced by a missing Qt analog: - Ctrl or Meta with a middle drag zooms (14.24, Swift 1.4.9: `e03923f`, Blender's gesture): a middle press with Ctrl or Meta (Swift's Command or Control) keeps `m_middleZoom` (where it began, the zoom then) in place of the middle pan; each move with the middle button zooms by 2^((start.y − y) / 100) about the press, as the Zoom tool's drag doubles every 100 points, up in and down out, under the Zoom tool's magnifier, the minus while below the press; the brush circle hides meanwhile; the release, or a move without the middle button (Qt can lose a release), ends it and puts the tool's cursor back, as the pan's does. A left drag beside it goes on, and a right-button tip drag waits, as beside the pan. Tests: `CanvasNavigationTests::ctrlOrMetaWithTheMiddleButtonZooms` (doubling and halving about an off-centre press, from 75% too, both cursors against the Zoom tool's, Meta, a lost release, a plain middle drag still panning, a Hand drag going on and a tip drag waiting beside it). - Camera Raw's Light and Color as Photoshop's (14.25, Swift 1.4.9: `57ab3bb`): `kernels/AdjustPixels.c/.h` were 57ab3bb's: Exposure, white balance and Contrast ran as one composed table before Highlights and Shadows, Whites, Blacks, Saturation and Vibrance as another after (one table since 14.26). `Document/CameraRawTables.h/.cpp` twin Swift's enum as a namespace: Photoshop's colour tables, 147 of 17³ (`size`), composed over a 33³ `grid`; `tables()` reads them once, `whiteBalance` (four tables round Temperature and Tint, 9 × 9 by 25), `stages` (Swift's order, sliders at zero left out), `compose` (empty without stages, the kernel then handed none; a grid that finds no memory is `ExportError(render)`) and `neutralize` (Swift's search: steps of 10, 2 and 0.5 round the best, the first of equals kept, counted by index where Swift strides, the same values). Swift's bundle resource is a Qt resource compiled into the library (`src/Resources/CameraRawTables.bin`, `qt_add_resources`), so it is always there: Swift's nil-table paths (no tables, no Light and Color, no neutral balance) have no twin, and a header or size that does not match is a `std::logic_error`, a broken build. Its planes were raw DEFLATE until 14.27 (xz since, below). `CameraRawSettings` loses `gains` and its three constants and `neutralizeLinear`; `neutralizeStraight` is the tables' search, and Auto hands it the linear mean encoded again (`encode`), as Swift's. The `Balance` is `CameraRawSettings::Balance`, where Swift's returns a tuple. Tests: Swift's three changed tests in `CameraRawTests` (exposure, translucent too, contrast, whites, blacks, saturation by Photoshop's numbers) and `whiteBalanceAndVibranceMatchPhotoshop`, 181 baked in `CameraRawSessionTests`, `CameraRawTablesTests` (the decoded tables' FNV hash and entries, stages and weights, each slider's tables, past the range, the composed corners, `neutralize`'s exact answers, refinements past −100 on either slider, each channel missing, ties among searched points, three stages composed in order, all from an independent Python decoder and search) (since 14.26 `CameraRawWiringTests::theTableReadsThePicturesBrightness`) and Auto through the encoded mean, a dark pixel on its straight segment. - Adaptive Contrast, Highlights and Shadows; Dehaze, Texture, Clarity and Sharpening (14.26, Swift 1.4.9: `75147a2`): `kernels/AdjustPixels.c/.h` were 75147a2's and the tables its 285 (`CRT2`, 1.4 MB; 763 since 14.27): Contrast, Highlights and Shadows each blend four tables round their value (−100, −50, −25, 0, 25, 50, 100) and the picture's brightness (53, 80, 104, 130, 160, 190, 224 in sRGB levels, `between` over points, held at the ends, the lower pair taking a point whole), read before any stage by `camera_raw_statistics` (`CameraRawTables::brightness`, `Brightness`: the brightest channel's mean for Contrast, the log-average for Highlights, the luminance for Shadows), only when one of the three is set, else 128 each; every Light and Color slider is now one composed table (`stages` returns one list in Camera Raw's order) and `adjust_camera_raw` takes it alone. Dehaze removes or adds the haze colour of the brightest one percent and leaves one colour be; Texture and Clarity are guided filters at radii in proportion to the picture, so the preview's scale no longer reaches them (Glow's still does); Sharpening sharpens everywhere at Masking 0. These live in Swift's kernel, unchanged. The 14.16 tone curve is gone with it, and its curve test. Equivalent mutant: reading the brightness when none of the three is set (the stages then never read it). Tests: Swift's changed `CameraRawTests` (Highlights and Shadows by Photoshop's numbers, Dehaze on a shadow under a pale sky, one colour left be; pinned beside them: Highlights alone reading the picture, Highlights before Whites, a faint step sharpened at Masking 0) and `CameraRawToneOrderTests` (Highlights −100's white), `CameraRawTablesTests` (the 285 tables' hash and entries, two colours' three measures against Python, each slider's tables, the adaptive blends from an independent Python run, held ends and a measured point, three stages composed, mid gray's brightness), `CameraRawWiringTests::theTableReadsThePicturesBrightness` (the grade against the kernel through the picture's own brightness, not the default), `CameraRawRulesTests` (Glow reads the scale, Clarity no longer). -- Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading as Photoshop's (14.27, Swift 1.4.9: `bdfb9c2`, `9dac4f1`, `7b5a592`, taken as their net change): `kernels/AdjustPixels.c/.h` are 7b5a592's (a stage holds up to eight tables, `adjust_camera_raw_curve_color` takes the Mixer's and Grading's composed tables, `adjust_camera_raw_optics` Exposure's tables for the lens vignette, the post-crop vignette darkens by a gain in linear light) and the tables its 763 (`CRT3`, 1.25 MB): a header of ten bytes (size, count, `step`), each entry a running difference in steps of `step` levels read as a signed byte and clamped, the planes an xz stream as Foundation's `.lzma` writes it, unpacked through liblzma (`lzma_stream_buffer_decode`, `LibLZMA::LibLZMA`; `liblzma-dev` in the dev image, `xz-devel` and `xz` in the Fedora and Arch images, `xz` in the PKGBUILD and the flake); the app no longer links zlib. `CameraRawTables` gains Swift's `blend` (up to eight parts; a null table is no change), `exposureTables`, `fifty` (−100 to 100 by 50), `gradingStages` (the four luminance sliders, then the global, midtone, highlight and shadow wheels: twelve hues by 30, saturations 25, 50 and 100 below which the lower is no change, the shadow and highlight wheels between Blending 0, 50 and 100, Balance as Swift's fitted shares of saturation, a wheel with no share left out) and `mixerStages` (luminance, saturation, hue, eight colours each); `stages` opens with Calibration's six primaries and ends with the parametric curve's four regions while `CameraRawCurveSettings::hasMeasuredDividers` holds (dividers 25, 50, 75 and Refine Saturation 0), when `toneTable` draws the point curve alone. `applyCurveColor` hands the kernel zeros for the Mixer's and Grading's formulas and their tables in their place, and their serializers (`mixerFloats`, `gradeFloats`), which Swift keeps without a caller, are gone; `applyCalibration` draws Shadows Tint alone; `apply` runs the Light table for Calibration or a measured curve too. Equivalent mutants: the shares' floor of zero (a negative share fails the `> 0` test as zero does) and `gradingStages`' `fmod` (a normalized hue lies within 0 to 360, where 360 wraps alike). Tests: Swift's `mixerCalibrationCurveAndVignettesMatchPhotoshop` in `CameraRawTests`; `CameraRawTablesTests` against an independent Python decoder of `CRT3` (the hash and entries; every earlier exact value redone on the stepped tables; the Mixer's, Calibration's, the curve's and Grading's tables and weights, a wheel wrapping past 330, Blending 0's own tables, Balance ±50 weighing each wheel's share and +100 leaving the shadows out, every Calibration binding, midtones' and highlights' luminance, Calibration, Light and the measured curve in order, moved dividers); the Highlights pins, Auto's dark pixel and the grading luminance by the same Python; `CameraRawCurveTests` (the tone table as measured and with dividers moved), `CameraRawLensTests` (Shadows Tint by the kernel, primaries through the table), `CameraRawRulesTests` (the clip views keep Calibration's table), `CameraRawWiringTests` (the curve call's tables). +- Camera Raw's Color Mixer, Calibration, curve, vignettes and Color Grading as Photoshop's (14.27, Swift 1.4.9: `bdfb9c2`, `9dac4f1`, `7b5a592`, taken as their net change): `kernels/AdjustPixels.c/.h` were 7b5a592's (a stage holds up to eight tables, `adjust_camera_raw_curve_color` takes the Mixer's and Grading's composed tables, `adjust_camera_raw_optics` Exposure's tables for the lens vignette, the post-crop vignette darkens by a gain in linear light) and the tables its 763 (`CRT3`, 1.25 MB): a header of ten bytes (size, count, `step`), each entry a running difference in steps of `step` levels read as a signed byte and clamped, the planes an xz stream as Foundation's `.lzma` writes it, unpacked through liblzma (`lzma_stream_buffer_decode`, `LibLZMA::LibLZMA`; `liblzma-dev` in the dev image, `xz-devel` and `xz` in the Fedora and Arch images, `xz` in the PKGBUILD and the flake); the app no longer links zlib. `CameraRawTables` gains Swift's `blend` (up to eight parts; a null table is no change), `exposureTables`, `fifty` (−100 to 100 by 50), `gradingStages` (the four luminance sliders, then the global, midtone, highlight and shadow wheels: twelve hues by 30, saturations 25, 50 and 100 below which the lower is no change, the shadow and highlight wheels between Blending 0, 50 and 100, Balance as Swift's fitted shares of saturation, a wheel with no share left out) and `mixerStages` (luminance, saturation, hue, eight colours each); `stages` opens with Calibration's six primaries and ends with the parametric curve's four regions while `CameraRawCurveSettings::hasMeasuredDividers` holds (dividers 25, 50, 75 and Refine Saturation 0), when `toneTable` draws the point curve alone. `applyCurveColor` hands the kernel zeros for the Mixer's and Grading's formulas and their tables in their place, and their serializers (`mixerFloats`, `gradeFloats`), which Swift keeps without a caller, are gone; `applyCalibration` draws Shadows Tint alone; `apply` runs the Light table for Calibration or a measured curve too. Equivalent mutants: the shares' floor of zero (a negative share fails the `> 0` test as zero does) and `gradingStages`' `fmod` (a normalized hue lies within 0 to 360, where 360 wraps alike). Tests: Swift's `mixerCalibrationCurveAndVignettesMatchPhotoshop` in `CameraRawTests`; `CameraRawTablesTests` against an independent Python decoder of `CRT3` (the hash and entries; every earlier exact value redone on the stepped tables; the Mixer's, Calibration's, the curve's and Grading's tables and weights, a wheel wrapping past 330, Blending 0's own tables, Balance ±50 weighing each wheel's share and +100 leaving the shadows out, every Calibration binding, midtones' and highlights' luminance, Calibration, Light and the measured curve in order, moved dividers); the Highlights pins, Auto's dark pixel and the grading luminance by the same Python; `CameraRawCurveTests` (the tone table as measured and with dividers moved), `CameraRawLensTests` (Shadows Tint by the kernel, primaries through the table), `CameraRawRulesTests` (the clip views keep Calibration's table), `CameraRawWiringTests` (the curve call's tables). +- Camera Raw's preview 5 to 20 times faster (14.28, Swift 1.4.9: `a8630e4`): `kernels/AdjustPixels.c/.h` are a8630e4's, with 13.17's adaptation carried on: Swift threads its passes through GCD blocks (`in_bands` over `dispatch_apply`, and `dispatch_apply` alone for the statistics and the scope), which GCC cannot compile, so each block's body moved word for word into a static band function (`compose_band`, `compose_onto_band`, `camera_raw_band`, `blur_rows_band`, `blur_columns_band`, `effects_luma_band`, `effects_band`, `curve_color_band`, `detail_luma_band`, `sharpen_luma_band`, `sharpen_band`, `optics_band`, `calibration_band`, `statistics_band`, `scope_band`) whose context struct carries what the block captured, handed over as a compound literal, with Swift's split kept (one band under 64 items, else 32; 32 and 16 for the two sums); the bands run through `adjust_bands`, a C hook in `Document/ImageAdjustments.cpp` on `PoolMap::blocking`, as Dither's do. `CameraRawSettings::apply` takes Swift's known brightness (`std::optional`, read before Calibration now, measured there when none comes), composes the Light table always, and folds Curve, the Mixer and Grading into it (`foldingColor`: from `CameraRawTables::identity` when Light made none, the curves through `camera_raw_table_curves`, the Mixer's and Grading's stages through `camera_raw_compose_onto`) unless a point colour, Refine Saturation, a point-colour view, a clip view or the sharpen mask needs the curve pass. `CameraRawTables::Brightness` is declared in `CameraRaw.h`, reopening the namespace, since `apply`'s signature needs it and `CameraRawTables.h` holds the kernel's types, which only the core sees; `brightness(QImage)` draws the picture and measures it, and a picture that cannot be drawn logs on `omaphoto.rendering` and reads 128, where Swift's `try?` is silent. `FilterJob` carries `cameraRawBrightness`, which `previewJob` fills for Camera Raw from `FilterEdit::previewBrightness`, measured once and kept by the preview source's `cacheKey` (Swift keys it by `previewSourceVersion`, which the twin dropped in 13.8). `CameraRawScope::make` reads every other pixel each way through `camera_raw_scope`, straight from an `RGBA8888_Premultiplied` image's bytes, else drawn first; each band starts its own rows, so a short picture counts every row. Equivalent mutants: `foldingColor`'s `shape.adjusts()` (an identity curve leaves the table within a float's rounding), the cache's key (Camera Raw's preview source never changes while its panel is open), the band counts of the `in_bands` split (they only spread the work; the two sums' counts are not: one band reads only its own rows). Tests: `CameraRawFoldTests` (the fold against the kernel's own calls, from identity too; a point colour, Refine Saturation and a point-colour view keeping the pass; the brightness read before Shadows Tint and a known one used as handed; the session's job carrying the preview source's brightness and none for other kinds; every band of a forty-row ramp measured and counted; the calibration kernel's blue hue and saturation by their own effects), the scope's sampling and both formats in `CameraRawWiringTests`, the panel's ramp two pixels a level in `CameraRawPanelTests`. - Drops between projects: `ProjectWorkspace::draggedLayerSource` finds the tab whose document holds the dragged id (Swift reads the drag pasteboard; the twin reads the drag's `QMimeData`, which every drop target has), `canReceiveDrag(data, destination)` refuses only a layer's own tab, and `receiveProviders` copies several ids one after another, as Swift awaits each provider, so a two-row drop on the New button opens two tabs as Swift's does. `ContentView` takes such a drop over its whole area (`setWorkspace` hands it the workspace; `acceptsDrop` keeps Swift's guards: busy, the sheet, the importer, a rename; `levels` arrives with 9.x), shows the 3-point accent ring inside the canvas while targeted and hands the drop's document point on when it fell on the canvas. `ProjectTabButton` takes drops for its tab (accent fill and rim, "Add to "), and `NewCanvasButton`, the toolbar's New canvas button with the action as its default, for a fresh tab ("Open in a new project tab"), both refusing an Alt drag, which duplicates in the panel; Swift's transient `NewTabDropSlot` in the strip has no twin, the New canvas button is `NewProjectDropTarget` already. Files and pictures arrived with 10.4. - `CanvasThumbnail` draws a layer or a mask inside the canvas's box at `backingScale` 2 (the panel's pixmaps carry that ratio); a mask's ground is `LayerMask::background`, white or black by its edge's majority, as the canvas treats it (Swift's `edgeTone` mean went with 12.12b). `LayerIcons` draws the panel's SF Symbols as paths, in one palette colour, like `ToolIcons`. `BlendModePicker` is a `QComboBox` whose popup previews the highlighted mode through `previewBlendMode` for the layer it opened on (Qt calls `hidePopup` before `activated`, so the preview, the target and the highlight are cleared from the event loop, after a choice had its chance) and refuses a choice once another layer is active. `LayerAppearanceControls` is Swift's blend and opacity row: the slider's press and release are one opacity drag (the release is watched application-wide while the slider is down, so a popup opened mid-drag cannot keep the step open), the percent field applies once through its `FocusOut`, which Return and Escape cause (Swift's `releasesFocusOnCommit`), and steps with Up and Down (Swift's `isFinite` test and the steps' clamp are left out: `setLayerOpacity` refuses a value that is no finite number and clamps the rest), and another active layer makes the controls anew, as Swift's `.id` does. - `ProjectWorkspaceView` is the `QMainWindow`: the front tab's `ContentView` as its central widget, made anew when another tab comes to the front, as SwiftUI's `.id` rebuilds it, and disabled while the workspace manages; the window holds the shown tab (`std::shared_ptr`) until the old editor is deleted, as Swift's views hold their session, so a replaced or closed tab's session never ends under its editor (`ProjectWorkspace::tab` is public for this); a `QToolBar` with the New canvas action, the `ProjectTabStrip` (which takes the toolbar's free width) and the four zoom actions, since SwiftUI's `.toolbar` modifier lives in `ContentView` but the toolbar belongs to the window. Its actions follow the front session's `changed()`, and on that signal the window syncs every tab's button as well, because a busy front tab dims the other tabs' buttons, which their own sessions' signals do not reach; the strip syncs its buttons on the workspace's signal, each button on its own session's. The window's title is the front tab's, with Qt's `[*]` placeholder, so `setWindowModified` can show the mark that `isDocumentEdited` shows; `setWindowFilePath` carries `representedURL`. The editor's own minimum size is cleared inside the window, which owns the 800 × 520 minimum for the whole, toolbar included. Closing the window ignores the event and asks the workspace (`closeWindow`), which confirms tab by tab and then closes the window again with the property `closeConfirmed` set, since the workspace may not include `UI/` headers; a confirmed close is accepted and the mark cleared, so a window shown again asks again. `ProjectTabStrip` is a plain `QWidget` whose buttons are kept for the tabs that stay, made for new tabs and deleted for tabs that went, and placed by hand from `projectTabOverflow` (13.14): every button at its own size hint, so no title is squeezed below Swift's 35 points, laid out again when the strip is resized, the workspace changes or any tab's session changes; it fills no background, so the toolbar shows through (a stylesheet would do the same but freezes the widgets' palette, so a theme switch would pass them by); the unsaved dot is a character before the title. Files named on the command line go to `ProjectWorkspace::receive`, as Finder's Open With reaches the delegate. Layer drops on tabs and the New canvas button are 4.7's; file and picture drops arrived with 10.4. diff --git a/kernels/AdjustPixels.c b/kernels/AdjustPixels.c index 9215c39..9aa5a80 100644 --- a/kernels/AdjustPixels.c +++ b/kernels/AdjustPixels.c @@ -4,6 +4,26 @@ #include <stdlib.h> #include <string.h> +// Linux: GCD's dispatch_apply, run on QtConcurrent by ImageAdjustments.cpp. +void adjust_bands(size_t bands, void (*band)(void *context, size_t index), void *context); + +typedef void (*band_body)(void *context, size_t start, size_t end); +typedef struct { size_t count, size; band_body body; void *context; } band_job; + +static void run_band(void *context, size_t band) { + const band_job *job = context; + size_t count = job->count, size = job->size; + size_t start = band * size, end = start + size < count ? start + size : count; + if (start < end) job->body(job->context, start, end); +} + +// Runs `body` over `count` items split into a few runs per core, each a (start, end) range, all at once. +static void in_bands(size_t count, band_body body, void *context) { + size_t bands = count < 64 ? 1 : 32, size = (count + bands - 1) / bands; + band_job job = { count, size, body, context }; + adjust_bands(bands, run_band, &job); +} + void adjust_gradient_map(uint8_t *rgba, size_t width, size_t height, size_t stride, const uint8_t *table) { for (size_t y = 0; y < height; y++) { uint8_t *p = rgba + y * stride; @@ -255,8 +275,15 @@ static void table_lookup(const uint8_t *table, int size, const double in[3], dou out[2] /= 255.0; } -void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int count, int size) { - for (int r = 0; r < grid; ++r) { +typedef struct { float *out; int grid; const CameraRawStage *stages; int count; int size; } compose_job; +static void compose_band(void *context, size_t first, size_t last) { + const compose_job *job = context; + float *out = job->out; + int grid = job->grid; + const CameraRawStage *stages = job->stages; + int count = job->count; + int size = job->size; + for (int r = (int)first; r < (int)last; ++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)}; @@ -270,6 +297,33 @@ void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int } } +void camera_raw_compose(float *out, int grid, const CameraRawStage *stages, int count, int size) { + in_bands((size_t)grid, compose_band, &(compose_job){out, grid, stages, count, size}); +} + +typedef struct { float *table; const CameraRawStage *stages; int count; int size; } compose_onto_job; +static void compose_onto_band(void *context, size_t first, size_t last) { + const compose_onto_job *job = context; + float *table = job->table; + const CameraRawStage *stages = job->stages; + int count = job->count; + int size = job->size; + + for (size_t i = first; i < last; ++i) { + double color[3] = {table[i * 3], table[i * 3 + 1], table[i * 3 + 2]}; + for (int s = 0; s < count; ++s) camera_raw_stage_color(&stages[s], size, color); + table[i * 3] = (float)color[0]; + table[i * 3 + 1] = (float)color[1]; + table[i * 3 + 2] = (float)color[2]; + } +} + +void camera_raw_compose_onto(float *table, int grid, const CameraRawStage *stages, int count, int size) { + if (count <= 0) return; + size_t entries = (size_t)grid * grid * grid; + in_bands(entries, compose_onto_band, &(compose_onto_job){table, stages, count, size}); +} + void camera_raw_stage_color(const CameraRawStage *stage, int size, double *rgb) { double next[3] = {0, 0, 0}; for (int t = 0; t < 8; ++t) { @@ -312,21 +366,47 @@ 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 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; +typedef struct { const uint8_t *rgba; size_t width; size_t height; size_t stride; size_t rows; double (*parts)[4]; const double *decode; } statistics_job; +static void statistics_band(void *context, size_t band) { + const statistics_job *job = context; + const uint8_t *rgba = job->rgba; + size_t width = job->width, height = job->height, stride = job->stride, rows = job->rows; + double (*parts)[4] = job->parts; + const double *decode = job->decode; + double brightest = 0, luminance = 0, logBrightest = 0, weight = 0; + size_t end = (band + 1) * rows < height ? (band + 1) * rows : height; + for (size_t y = band * rows; y < end; ++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, g, b; + if (p[3] == 255) { r = decode[p[0]]; g = decode[p[1]]; b = decode[p[2]]; } + else { r = srgb_decode(fmin(1.0, p[0] / alpha)); g = srgb_decode(fmin(1.0, p[1] / alpha)); b = srgb_decode(fmin(1.0, p[2] / alpha)); } + double 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; + } } + parts[band][0] = brightest; parts[band][1] = luminance; parts[band][2] = logBrightest; parts[band][3] = weight; +} + +void camera_raw_statistics(const uint8_t *rgba, size_t width, size_t height, size_t stride, double *out) { + enum { bands = 32 }; + double sums[bands][4]; + memset(sums, 0, sizeof sums); + double decoded[256]; + for (int i = 0; i < 256; ++i) decoded[i] = srgb_decode(i / 255.0); + double (*parts)[4] = sums; + const double *decode = decoded; + size_t rows = (height + bands - 1) / bands; + adjust_bands(bands, statistics_band, &(statistics_job){rgba, width, height, stride, rows, parts, decode}); + double brightest = 0, luminance = 0, logBrightest = 0, weight = 0; + for (int band = 0; band < bands; ++band) { + brightest += sums[band][0]; luminance += sums[band][1]; logBrightest += sums[band][2]; weight += sums[band][3]; } if (weight <= 0) { out[0] = out[1] = out[2] = 0.5; return; } out[0] = srgb_encode(brightest / weight); @@ -334,9 +414,66 @@ void camera_raw_statistics(const uint8_t *rgba, size_t width, size_t height, siz 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) { +typedef struct { const uint8_t *rgba; size_t width; size_t height; size_t stride; int step; int side; size_t binCount, scopeCount, rows; double *parts; } scope_job; +static void scope_band(void *context, size_t band) { + const scope_job *job = context; + const uint8_t *rgba = job->rgba; + size_t width = job->width, height = job->height, stride = job->stride, binCount = job->binCount, scopeCount = job->scopeCount, rows = job->rows; + int step = job->step, side = job->side; + double *parts = job->parts; + double *histogram = parts + band * (binCount + scopeCount), *plot = histogram + binCount; + size_t end = (band + 1) * rows < height ? (band + 1) * rows : height; + for (size_t y = band * rows; y < end; y += step) { + const uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; x += step) { + const uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) continue; + double red = fmin(1.0, p[0] / alpha), green = fmin(1.0, p[1] / alpha), blue = fmin(1.0, p[2] / alpha); + double w = alpha / 255.0; + histogram[(int)lround(red * 255)] += w; + histogram[256 + (int)lround(green * 255)] += w; + histogram[512 + (int)lround(blue * 255)] += w; + double top = fmax(red, fmax(green, blue)), bottom = fmin(red, fmin(green, blue)), chroma = top - bottom; + if (chroma <= 1e-4 || top <= 1e-4) continue; + double hue = top == red ? (green - blue) / chroma : top == green ? 2 + (blue - red) / chroma : 4 + (red - green) / chroma; + hue /= 6; + if (hue < 0) hue += 1; + double angle = hue * 2 * M_PI, saturation = chroma / top; + int column = (int)((0.5 + cos(angle) * saturation * 0.48) * side), line = (int)((0.5 + sin(angle) * saturation * 0.48) * side); + column = column < 0 ? 0 : column >= side ? side - 1 : column; + line = line < 0 ? 0 : line >= side ? side - 1 : line; + plot[line * side + column] += w; + } + } +} + +void camera_raw_scope(const uint8_t *rgba, size_t width, size_t height, size_t stride, int step, double *bins, double *scope, + int side) { + enum { bands = 16 }; + size_t binCount = 256 * 3, scopeCount = (size_t)side * side; + double *parts = calloc(bands * (binCount + scopeCount), sizeof(double)); + if (!parts) return; + size_t rows = (height + bands - 1) / bands; + adjust_bands(bands, scope_band, &(scope_job){rgba, width, height, stride, step, side, binCount, scopeCount, rows, parts}); + for (int band = 0; band < bands; ++band) { + const double *histogram = parts + band * (binCount + scopeCount); + for (size_t i = 0; i < binCount; ++i) bins[i] += histogram[i]; + for (size_t i = 0; i < scopeCount; ++i) scope[i] += histogram[binCount + i]; + } + free(parts); +} + +typedef struct { uint8_t *rgba; size_t width; size_t stride; const float *table; int grid; int clipping; } camera_raw_job; +static void camera_raw_band(void *context, size_t first, size_t last) { + const camera_raw_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t stride = job->stride; + const float *table = job->table; + int grid = job->grid; + int clipping = job->clipping; + for (size_t y = first; y < last; ++y) { uint8_t *row = rgba + y * stride; for (size_t x = 0; x < width; ++x) { uint8_t *p = row + x * 4; @@ -366,12 +503,58 @@ void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride } } +void adjust_camera_raw(uint8_t *rgba, size_t width, size_t height, size_t stride, const float *table, int grid, + int clipping) { + in_bands(height, camera_raw_band, &(camera_raw_job){rgba, width, stride, table, grid, clipping}); +} + static size_t clamped_index(int index, size_t limit) { if (index < 0) return 0; if ((size_t)index >= limit) return limit - 1; return (size_t)index; } +typedef struct { const float *src; float *temp; size_t width; int radius; int window; } blur_rows_job; +static void blur_rows_band(void *context, size_t first, size_t last) { + const blur_rows_job *job = context; + const float *src = job->src; + float *temp = job->temp; + size_t width = job->width; + int radius = job->radius; + int window = job->window; + + for (size_t y = first; y < last; ++y) { + double sum = 0; + for (int k = -radius; k <= radius; ++k) sum += src[y * width + clamped_index(k, width)]; + for (size_t x = 0; x < width; ++x) { + temp[y * width + x] = (float)(sum / window); + sum += src[y * width + clamped_index((int)x + radius + 1, width)]; + sum -= src[y * width + clamped_index((int)x - radius, width)]; + } + } +} + +typedef struct { float *dst; float *temp; size_t width; size_t height; int radius; int window; } blur_columns_job; +static void blur_columns_band(void *context, size_t first, size_t last) { + const blur_columns_job *job = context; + float *dst = job->dst; + float *temp = job->temp; + size_t width = job->width; + size_t height = job->height; + int radius = job->radius; + int window = job->window; + + for (size_t x = first; x < last; ++x) { + double sum = 0; + for (int k = -radius; k <= radius; ++k) sum += temp[clamped_index(k, height) * width + x]; + for (size_t y = 0; y < height; ++y) { + dst[y * width + x] = (float)(sum / window); + sum += temp[clamped_index((int)y + radius + 1, height) * width + x]; + sum -= temp[clamped_index((int)y - radius, height) * width + x]; + } + } +} + // Edge-clamped box blur. `dst` may not alias `src`. Returns 0 when the temporary row buffer cannot be allocated. static int box_blur_plane(const float *src, float *dst, size_t width, size_t height, int radius) { if (radius < 1) { @@ -381,24 +564,8 @@ static int box_blur_plane(const float *src, float *dst, size_t width, size_t hei float *temp = malloc(width * height * sizeof(float)); if (!temp) return 0; int window = radius * 2 + 1; - for (size_t y = 0; y < height; ++y) { - double sum = 0; - for (int k = -radius; k <= radius; ++k) sum += src[y * width + clamped_index(k, width)]; - for (size_t x = 0; x < width; ++x) { - temp[y * width + x] = (float)(sum / window); - sum += src[y * width + clamped_index((int)x + radius + 1, width)]; - sum -= src[y * width + clamped_index((int)x - radius, width)]; - } - } - for (size_t x = 0; x < width; ++x) { - double sum = 0; - for (int k = -radius; k <= radius; ++k) sum += temp[clamped_index(k, height) * width + x]; - for (size_t y = 0; y < height; ++y) { - dst[y * width + x] = (float)(sum / window); - sum += temp[clamped_index((int)y + radius + 1, height) * width + x]; - sum -= temp[clamped_index((int)y - radius, height) * width + x]; - } - } + in_bands(height, blur_rows_band, &(blur_rows_job){src, temp, width, radius, window}); + in_bands(width, blur_columns_band, &(blur_columns_job){dst, temp, width, height, radius, window}); free(temp); return 1; } @@ -610,6 +777,83 @@ void adjust_camera_raw_clip_overlay(uint8_t *rgba, size_t width, size_t height, } } +typedef struct { uint8_t *rgba; size_t width; size_t stride; float *luma; } effects_luma_job; +static void effects_luma_band(void *context, size_t first, size_t last) { + const effects_luma_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t stride = job->stride; + float *luma = job->luma; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) { luma[y * width + x] = 0; continue; } + double r = fmin(1.0, p[0] / alpha); + double g = fmin(1.0, p[1] / alpha); + double b = fmin(1.0, p[2] / alpha); + luma[y * width + x] = (float)rec709(r, g, b); + } + } +} + +typedef struct { uint8_t *rgba; size_t width; size_t height; size_t stride; double texture; double clarity; double dehaze; double glow; double glowRed; double glowGreen; double glowBlue; double glowGain; double vignetteAmount; double vignetteMidpoint; double vignetteRoundness; double vignetteFeather; double vignetteHighlights; int vignetteStyle; float *fine; float *coarse; float *glowPlane; const double *haze; } effects_job; +static void effects_band(void *context, size_t first, size_t last) { + const effects_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t height = job->height; + size_t stride = job->stride; + double texture = job->texture; + double clarity = job->clarity; + double dehaze = job->dehaze; + double glow = job->glow; + double glowRed = job->glowRed; + double glowGreen = job->glowGreen; + double glowBlue = job->glowBlue; + double glowGain = job->glowGain; + double vignetteAmount = job->vignetteAmount; + double vignetteMidpoint = job->vignetteMidpoint; + double vignetteRoundness = job->vignetteRoundness; + double vignetteFeather = job->vignetteFeather; + double vignetteHighlights = job->vignetteHighlights; + int vignetteStyle = job->vignetteStyle; + float *fine = job->fine; + float *coarse = job->coarse; + float *glowPlane = job->glowPlane; + const double *haze = job->haze; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) continue; + size_t index = y * width + x; + double r = fmin(1.0, p[0] / alpha); + double g = fmin(1.0, p[1] / alpha); + double b = fmin(1.0, p[2] / alpha); + if (fine || coarse) { + double tone = rec709(r, g, b); + double detail = 0; + if (fine) detail += (texture / 100.0) * (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, haze); + if (glowPlane && glow > 0) { + double add = glowPlane[index] * (glow / 100.0) * glowGain; + r = camera_clamp(r + add * glowRed); + g = camera_clamp(g + add * glowGreen); + b = camera_clamp(b + add * glowBlue); + } + effects_vignette(&r, &g, &b, x, y, width, height, vignetteAmount, vignetteMidpoint, vignetteRoundness, + vignetteFeather, vignetteHighlights, vignetteStyle); + write_premultiplied(p, r, g, b, alpha); + } + } +} + void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_t stride, double texture, double clarity, double dehaze, double glow, int glowStyle, double glowRange, double glowSpread, double glowWarmth, @@ -625,18 +869,7 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_ if (texture != 0 || clarity != 0 || glow > 0) { luma = malloc(count * sizeof(float)); if (!luma) return; - for (size_t y = 0; y < height; ++y) { - uint8_t *row = rgba + y * stride; - for (size_t x = 0; x < width; ++x) { - uint8_t *p = row + x * 4; - double alpha = p[3]; - if (!alpha) { luma[y * width + x] = 0; continue; } - double r = fmin(1.0, p[0] / alpha); - double g = fmin(1.0, p[1] / alpha); - double b = fmin(1.0, p[2] / alpha); - luma[y * width + x] = (float)rec709(r, g, b); - } - } + in_bands(height, effects_luma_band, &(effects_luma_job){rgba, width, stride, luma}); // 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); @@ -678,8 +911,9 @@ 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 hazeColor[3] = {1, 1, 1}; + if (dehaze != 0) dehaze_haze(rgba, width, height, stride, hazeColor); + const double *haze = hazeColor; double warmth = glowWarmth / 100.0; double glowRed, glowGreen, glowBlue, glowGain; if (glowStyle == 2) { @@ -694,35 +928,7 @@ void adjust_camera_raw_effects(uint8_t *rgba, size_t width, size_t height, size_ glowBlue = 0.75 - 0.6 * warmth; glowGain = glowStyle == 1 ? 1.4 : 1; } - for (size_t y = 0; y < height; ++y) { - uint8_t *row = rgba + y * stride; - for (size_t x = 0; x < width; ++x) { - uint8_t *p = row + x * 4; - double alpha = p[3]; - if (!alpha) continue; - size_t index = y * width + x; - double r = fmin(1.0, p[0] / alpha); - double g = fmin(1.0, p[1] / alpha); - double b = fmin(1.0, p[2] / alpha); - if (fine || coarse) { - double tone = rec709(r, g, b); - double detail = 0; - if (fine) detail += (texture / 100.0) * (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, haze); - if (glowPlane && glow > 0) { - double add = glowPlane[index] * (glow / 100.0) * glowGain; - r = camera_clamp(r + add * glowRed); - g = camera_clamp(g + add * glowGreen); - b = camera_clamp(b + add * glowBlue); - } - effects_vignette(&r, &g, &b, x, y, width, height, vignetteAmount, vignetteMidpoint, vignetteRoundness, - vignetteFeather, vignetteHighlights, vignetteStyle); - write_premultiplied(p, r, g, b, alpha); - } - } + in_bands(height, effects_band, &(effects_job){rgba, width, height, stride, texture, clarity, dehaze, glow, glowRed, glowGreen, glowBlue, glowGain, vignetteAmount, vignetteMidpoint, vignetteRoundness, vignetteFeather, vignetteHighlights, vignetteStyle, fine, coarse, glowPlane, haze}); free(luma); free(fine); free(coarse); @@ -824,12 +1030,28 @@ static double point_weight(double h, double s, double l, const float *point) { return hueW * satW * lumW; } -void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, - const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, - double refineSaturation, const float *mixer, int pointCount, const float *points, - const float *grade, double blending, double balance, int visualize, - const float *mixerTable, const float *gradeTable, int grid) { - for (size_t y = 0; y < height; ++y) { +typedef struct { uint8_t *rgba; size_t width; size_t stride; const float *toneLut; const float *redLut; const float *greenLut; const float *blueLut; const float *mixer; const float *points; const float *grade; const float *mixerTable; const float *gradeTable; double refineSaturation; int pointCount; double blending; double balance; int visualize; int grid; } curve_color_job; +static void curve_color_band(void *context, size_t first, size_t last) { + const curve_color_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t stride = job->stride; + const float *toneLut = job->toneLut; + const float *redLut = job->redLut; + const float *greenLut = job->greenLut; + const float *blueLut = job->blueLut; + const float *mixer = job->mixer; + const float *points = job->points; + const float *grade = job->grade; + const float *mixerTable = job->mixerTable; + const float *gradeTable = job->gradeTable; + double refineSaturation = job->refineSaturation; + int pointCount = job->pointCount; + double blending = job->blending; + double balance = job->balance; + int visualize = job->visualize; + int grid = job->grid; + for (size_t y = first; y < last; ++y) { uint8_t *row = rgba + y * stride; for (size_t x = 0; x < width; ++x) { uint8_t *p = row + x * 4; @@ -912,6 +1134,25 @@ void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, s } } +void adjust_camera_raw_curve_color(uint8_t *rgba, size_t width, size_t height, size_t stride, + const float *toneLut, const float *redLut, const float *greenLut, const float *blueLut, + double refineSaturation, const float *mixer, int pointCount, const float *points, + const float *grade, double blending, double balance, int visualize, + const float *mixerTable, const float *gradeTable, int grid) { + in_bands(height, curve_color_band, &(curve_color_job){rgba, width, stride, toneLut, redLut, greenLut, blueLut, mixer, points, grade, mixerTable, gradeTable, refineSaturation, pointCount, blending, balance, visualize, grid}); +} + +void camera_raw_table_curves(float *table, int grid, const float *toneLut, const float *redLut, const float *greenLut, + const float *blueLut) { + size_t entries = (size_t)grid * grid * grid; + for (size_t i = 0; i < entries; ++i) { + float *c = table + i * 3; + c[0] = (float)lut_at(redLut, lut_at(toneLut, c[0])); + c[1] = (float)lut_at(greenLut, lut_at(toneLut, c[1])); + c[2] = (float)lut_at(blueLut, lut_at(toneLut, c[2])); + } +} + static double detail_radius(double slider, double scale) { double base = 0.5 + (slider / 100.0) * 2.5; double radius = base * (scale > 0 ? scale : 1); @@ -989,6 +1230,77 @@ void adjust_camera_raw_sharpen_mask_overlay(uint8_t *rgba, size_t width, size_t free(luma); } +typedef struct { uint8_t *rgba; size_t width; size_t stride; float *luma; } detail_luma_job; +static void detail_luma_band(void *context, size_t first, size_t last) { + const detail_luma_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t stride = job->stride; + float *luma = job->luma; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) { luma[y * width + x] = 0; continue; } + double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); + luma[y * width + x] = (float)rec709(r, g, b); + } + } +} + +typedef struct { uint8_t *rgba; size_t width; size_t stride; float *luma; } sharpen_luma_job; +static void sharpen_luma_band(void *context, size_t first, size_t last) { + const sharpen_luma_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t stride = job->stride; + float *luma = job->luma; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) continue; + double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); + luma[y * width + x] = (float)rec709(r, g, b); + } + } +} + +typedef struct { uint8_t *rgba; size_t width; size_t height; size_t stride; float *luma; float *work; int radius; double amount; double detailMix; double threshold; } sharpen_job; +static void sharpen_band(void *context, size_t first, size_t last) { + const sharpen_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t height = job->height; + size_t stride = job->stride; + float *luma = job->luma; + float *work = job->work; + int radius = job->radius; + double amount = job->amount; + double detailMix = job->detailMix; + double threshold = job->threshold; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) continue; + size_t index = y * width + x; + float edge = sharpen_edge_at(luma, width, height, x, y, radius); + // 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]; + // 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); + } + } +} + void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t stride, double sharpenAmount, double sharpenRadius, double sharpenDetail, double sharpenMasking, double noiseLuminance, double noiseLuminanceDetail, double noiseLuminanceContrast, @@ -999,16 +1311,7 @@ void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t float *luma = malloc(count * sizeof(float)); float *work = malloc(count * sizeof(float)); if (!luma || !work) { free(luma); free(work); return; } - for (size_t y = 0; y < height; ++y) { - uint8_t *row = rgba + y * stride; - for (size_t x = 0; x < width; ++x) { - uint8_t *p = row + x * 4; - double alpha = p[3]; - if (!alpha) { luma[y * width + x] = 0; continue; } - double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); - luma[y * width + x] = (float)rec709(r, g, b); - } - } + in_bands(height, detail_luma_band, &(detail_luma_job){rgba, width, stride, luma}); if (noiseLuminance > 0) { int radius = effects_radius(1.0 + noiseLuminance / 50.0, scale); if (!box_blur_plane(luma, work, width, height, radius)) { free(luma); free(work); return; } @@ -1098,39 +1401,13 @@ void adjust_camera_raw_detail(uint8_t *rgba, size_t width, size_t height, size_t free(planes); } if (sharpenAmount > 0) { - for (size_t y = 0; y < height; ++y) { - uint8_t *row = rgba + y * stride; - for (size_t x = 0; x < width; ++x) { - uint8_t *p = row + x * 4; - double alpha = p[3]; - if (!alpha) continue; - double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); - luma[y * width + x] = (float)rec709(r, g, b); - } - } + in_bands(height, sharpen_luma_band, &(sharpen_luma_job){rgba, width, stride, luma}); int radius = effects_radius(detail_radius(sharpenRadius, scale), 1); if (!box_blur_plane(luma, work, width, height, radius)) { free(luma); free(work); return; } double amount = sharpenAmount / 100.0; double detailMix = sharpenDetail / 100.0; double threshold = (sharpenMasking / 100.0) * 0.35; - for (size_t y = 0; y < height; ++y) { - uint8_t *row = rgba + y * stride; - for (size_t x = 0; x < width; ++x) { - uint8_t *p = row + x * 4; - double alpha = p[3]; - if (!alpha) continue; - size_t index = y * width + x; - float edge = sharpen_edge_at(luma, width, height, x, y, radius); - // 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]; - // 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); - } - } + in_bands(height, sharpen_band, &(sharpen_job){rgba, width, height, stride, luma, work, radius, amount, detailMix, threshold}); } free(luma); free(work); @@ -1203,6 +1480,37 @@ static void optics_vignette_correct(double *r, double *g, double *b, size_t x, s *b = camera_clamp(low[2] + (high[2] - low[2]) * fraction); } +typedef struct { uint8_t *rgba; size_t width; size_t height; size_t stride; double purpleAmount; double purpleHueLow; double purpleHueHigh; double greenAmount; double greenHueLow; double greenHueHigh; double vignette; double vignetteMidpoint; const uint8_t *exposureTables; int tableSize; } optics_job; +static void optics_band(void *context, size_t first, size_t last) { + const optics_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t height = job->height; + size_t stride = job->stride; + double purpleAmount = job->purpleAmount; + double purpleHueLow = job->purpleHueLow; + double purpleHueHigh = job->purpleHueHigh; + double greenAmount = job->greenAmount; + double greenHueLow = job->greenHueLow; + double greenHueHigh = job->greenHueHigh; + double vignette = job->vignette; + double vignetteMidpoint = job->vignetteMidpoint; + const uint8_t *exposureTables = job->exposureTables; + int tableSize = job->tableSize; + for (size_t y = first; y < last; ++y) { + uint8_t *row = rgba + y * stride; + for (size_t x = 0; x < width; ++x) { + uint8_t *p = row + x * 4; + double alpha = p[3]; + if (!alpha) continue; + double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); + optics_defringe(&r, &g, &b, purpleAmount, purpleHueLow, purpleHueHigh, greenAmount, greenHueLow, greenHueHigh); + optics_vignette_correct(&r, &g, &b, x, y, width, height, vignette, vignetteMidpoint, exposureTables, tableSize); + write_premultiplied(p, r, g, b, alpha); + } + } +} + void adjust_camera_raw_optics(uint8_t *rgba, size_t width, size_t height, size_t stride, int removeChromatic, int lensProfile, double profileDistortion, double profileVignetting, double distortionK, double purpleAmount, double purpleHueLow, double purpleHueHigh, @@ -1222,35 +1530,23 @@ void adjust_camera_raw_optics(uint8_t *rgba, size_t width, size_t height, size_t } if (removeChromatic) optics_chromatic(rgba, width, height, stride, 0.45); if (purpleAmount == 0 && greenAmount == 0 && vignette == 0) return; - for (size_t y = 0; y < height; ++y) { - uint8_t *row = rgba + y * stride; - for (size_t x = 0; x < width; ++x) { - uint8_t *p = row + x * 4; - double alpha = p[3]; - if (!alpha) continue; - double r = fmin(1.0, p[0] / alpha), g = fmin(1.0, p[1] / alpha), b = fmin(1.0, p[2] / alpha); - optics_defringe(&r, &g, &b, purpleAmount, purpleHueLow, purpleHueHigh, greenAmount, greenHueLow, greenHueHigh); - optics_vignette_correct(&r, &g, &b, x, y, width, height, vignette, vignetteMidpoint, exposureTables, tableSize); - write_premultiplied(p, r, g, b, alpha); - } - } + in_bands(height, optics_band, &(optics_job){rgba, width, height, stride, purpleAmount, purpleHueLow, purpleHueHigh, greenAmount, greenHueLow, greenHueHigh, vignette, vignetteMidpoint, exposureTables, tableSize}); } -void adjust_camera_raw_calibration(uint8_t *rgba, size_t width, size_t height, size_t stride, - double shadowTint, double redHue, double redSaturation, - double greenHue, double greenSaturation, double blueHue, double blueSaturation, - int processVersion) { - if (width == 0 || height == 0) return; - double versionScale = processVersion <= 1 ? 0.55 : processVersion == 2 ? 0.65 : processVersion == 3 ? 0.75 - : processVersion == 4 ? 0.85 : processVersion == 5 ? 0.92 : 1.0; - double tint = shadowTint / 100.0 * versionScale; - double rh = redHue / 100.0 * (15.0 / 360.0) * versionScale; - double rs = redSaturation / 100.0 * 0.45 * versionScale; - double gh = greenHue / 100.0 * (15.0 / 360.0) * versionScale; - double gs = greenSaturation / 100.0 * 0.45 * versionScale; - double bh = blueHue / 100.0 * (15.0 / 360.0) * versionScale; - double bs = blueSaturation / 100.0 * 0.45 * versionScale; - for (size_t y = 0; y < height; ++y) { +typedef struct { uint8_t *rgba; size_t width; size_t stride; double tint; double rh; double rs; double gh; double gs; double bh; double bs; } calibration_job; +static void calibration_band(void *context, size_t first, size_t last) { + const calibration_job *job = context; + uint8_t *rgba = job->rgba; + size_t width = job->width; + size_t stride = job->stride; + double tint = job->tint; + double rh = job->rh; + double rs = job->rs; + double gh = job->gh; + double gs = job->gs; + double bh = job->bh; + double bs = job->bs; + for (size_t y = first; y < last; ++y) { uint8_t *row = rgba + y * stride; for (size_t x = 0; x < width; ++x) { uint8_t *p = row + x * 4; @@ -1277,3 +1573,20 @@ void adjust_camera_raw_calibration(uint8_t *rgba, size_t width, size_t height, s } } } + +void adjust_camera_raw_calibration(uint8_t *rgba, size_t width, size_t height, size_t stride, + double shadowTint, double redHue, double redSaturation, + double greenHue, double greenSaturation, double blueHue, double blueSaturation, + int processVersion) { + if (width == 0 || height == 0) return; + double versionScale = processVersion <= 1 ? 0.55 : processVersion == 2 ? 0.65 : processVersion == 3 ? 0.75 + : processVersion == 4 ? 0.85 : processVersion == 5 ? 0.92 : 1.0; + double tint = shadowTint / 100.0 * versionScale; + double rh = redHue / 100.0 * (15.0 / 360.0) * versionScale; + double rs = redSaturation / 100.0 * 0.45 * versionScale; + double gh = greenHue / 100.0 * (15.0 / 360.0) * versionScale; + double gs = greenSaturation / 100.0 * 0.45 * versionScale; + double bh = blueHue / 100.0 * (15.0 / 360.0) * versionScale; + double bs = blueSaturation / 100.0 * 0.45 * versionScale; + in_bands(height, calibration_band, &(calibration_job){rgba, width, stride, tint, rh, rs, gh, gs, bh, bs}); +} diff --git a/kernels/AdjustPixels.h b/kernels/AdjustPixels.h index e66a564..bf38b12 100644 --- a/kernels/AdjustPixels.h +++ b/kernels/AdjustPixels.h @@ -39,6 +39,15 @@ typedef struct { // 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); +// Runs `count` stages over every entry of a composed table already made (3 floats a color), in place. +void camera_raw_compose_onto(float *table, int grid, const CameraRawStage *stages, int count, int size); +// Runs a composed table's colors through a tone curve, then each channel's curve (256 entries each), in place. +void camera_raw_table_curves(float *table, int grid, const float *toneLut, const float *redLut, const float *greenLut, + const float *blueLut); +// Camera Raw's histogram (256 bins each of red, green and blue, added into `bins`) and vectorscope (`side`² cells, +// added into `scope`) from every `step`th pixel each way, opaque ones counting by their alpha. +void camera_raw_scope(const uint8_t *rgba, size_t width, size_t height, size_t stride, int step, double *bins, double *scope, + int side); // One stage on one sRGB color (0…1), in place. void camera_raw_stage_color(const CameraRawStage *stage, int size, double *rgb); // What Camera Raw's adaptive sliders read from an image, as sRGB levels (0…1), opaque pixels counting by their diff --git a/src/Document/CameraRaw.cpp b/src/Document/CameraRaw.cpp index fe870be..c28601d 100644 --- a/src/Document/CameraRaw.cpp +++ b/src/Document/CameraRaw.cpp @@ -6,10 +6,8 @@ #include <QPainter> #include <algorithm> #include <cmath> -#include <numbers> extern "C" { #include "AdjustPixels.h" -#include "LevelsPixels.h" } using ImageAdjustmentPixels::clamp; @@ -116,15 +114,17 @@ double CameraRawSettings::grainKernelSize() const } QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClipping> clipping, double scale, quint32 seed, int visualizePointColor, - bool sharpenMask) const + bool sharpenMask, std::optional<CameraRawTables::Brightness> known) const { const CameraRawSettings settings = normalized(); if (settings.isIdentity() && !clipping && visualizePointColor < 0 && !sharpenMask) return image; const int mode = clipping ? int(*clipping) : 0; const double pixelScale = scale > 0 ? scale : 1; - const bool paintColor = !clipping && !sharpenMask - && (settings.curve.adjusts() || settings.mixer.adjusts() || settings.grading.adjusts() || visualizePointColor >= 0); + // Curve, Mixer and Grading join the table unless curve-passed. + const bool foldsColor = !clipping && !sharpenMask && visualizePointColor < 0 && settings.mixer.points.empty() && settings.curve.refineSaturation == 0; + const bool colorAdjusts = settings.curve.adjusts() || settings.mixer.adjusts() || settings.grading.adjusts(); + const bool paintColor = !foldsColor && !clipping && !sharpenMask && (colorAdjusts || visualizePointColor >= 0); const bool paintEffects = !clipping && !sharpenMask && settings.adjustsEffects(); const bool paintDetailOptics = !clipping && (settings.detail.adjusts() || settings.optics.adjusts() || sharpenMask); QImage source = image; @@ -132,16 +132,17 @@ QImage CameraRawSettings::apply(const QImage &image, std::optional<CameraRawClip source = settings.geometry.apply(source); return ImageAdjustmentPixels::run(source, [&](uchar *pixels, int width, int height, qsizetype stride) { const size_t w = size_t(width), h = size_t(height), s = size_t(stride); + const bool adaptive = settings.contrast != 0 || settings.highlights != 0 || settings.shadows != 0; + const CameraRawTables::Brightness brightness = !adaptive ? CameraRawTables::Brightness() + : known ? known.value() + : CameraRawTables::brightness(pixels, width, height, stride); if (!clipping && !sharpenMask && settings.calibration.adjusts()) settings.applyCalibration(pixels, width, height, stride); - if (settings.adjustsLight() || settings.adjustsColor() || settings.calibration.adjusts() - || (settings.curve.hasMeasuredDividers() && settings.curve.adjusts()) || clipping) { - const CameraRawTables::Brightness brightness = settings.contrast != 0 || settings.highlights != 0 || settings.shadows != 0 - ? CameraRawTables::brightness(pixels, width, height, stride) - : CameraRawTables::Brightness(); - const std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings, brightness)); + std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings, brightness)); + if (foldsColor && colorAdjusts) + table = settings.foldingColor(std::move(table)); + if (!table.empty() || clipping) adjust_camera_raw(pixels, w, h, s, table.empty() ? nullptr : table.data(), CameraRawTables::grid, mode); - } if (paintColor) settings.applyCurveColor(pixels, width, height, stride, visualizePointColor); if (paintEffects) { @@ -196,36 +197,15 @@ std::optional<CameraRawScope> CameraRawScope::make(const QImage &image) { if (image.isNull()) return std::nullopt; - const QImage pixels = drawn(image); - std::array<double, binCount * 4> bins{}; - // Row by row: Qt may pad rows; the kernel counts. - for (int y = 0; y < pixels.height(); ++y) - levels_histogram(pixels.constScanLine(y), nullptr, size_t(pixels.width()), bins.data()); + // Every other pixel each way, from premultiplied bytes directly. + const QImage pixels = image.format() == QImage::Format_RGBA8888_Premultiplied ? image : drawn(image); + std::array<double, binCount * 3> bins{}; CameraRawScope scope; - std::copy_n(bins.begin() + binCount, binCount, scope.red.begin()); - std::copy_n(bins.begin() + 2 * binCount, binCount, scope.green.begin()); - std::copy_n(bins.begin() + 3 * binCount, binCount, scope.blue.begin()); - for (int y = 0; y < pixels.height(); ++y) { - const uchar *row = pixels.constScanLine(y); - for (int x = 0; x < pixels.width(); ++x) { - const uchar *pixel = row + x * 4; - const double alpha = pixel[3]; - if (alpha == 0) - continue; - const double r = std::min(1.0, pixel[0] / alpha), g = std::min(1.0, pixel[1] / alpha), b = std::min(1.0, pixel[2] / alpha); - const double high = std::max({r, g, b}), low = std::min({r, g, b}), chroma = high - low; - if (!(chroma > 1e-4 && high > 1e-4)) - continue; - double hue = high == r ? (g - b) / chroma : high == g ? 2 + (b - r) / chroma : 4 + (r - g) / chroma; - // A negative hue needs no wrap: the angle turns round. - hue /= 6; - const double angle = hue * 2 * std::numbers::pi, reach = chroma / high; - const double plotX = 0.5 + std::cos(angle) * reach * 0.48, plotY = 0.5 + std::sin(angle) * reach * 0.48; - const int column = std::min(scopeSide - 1, std::max(0, int(plotX * scopeSide))); - const int line = std::min(scopeSide - 1, std::max(0, int(plotY * scopeSide))); - scope.vectorscope[size_t(line * scopeSide + column)] += alpha / 255; - } - } + camera_raw_scope(pixels.constBits(), size_t(pixels.width()), size_t(pixels.height()), size_t(pixels.bytesPerLine()), 2, bins.data(), + scope.vectorscope.data(), scopeSide); + std::copy_n(bins.begin(), binCount, scope.red.begin()); + std::copy_n(bins.begin() + binCount, binCount, scope.green.begin()); + std::copy_n(bins.begin() + 2 * binCount, binCount, scope.blue.begin()); return scope; } diff --git a/src/Document/CameraRaw.h b/src/Document/CameraRaw.h index 135dd77..c01e548 100644 --- a/src/Document/CameraRaw.h +++ b/src/Document/CameraRaw.h @@ -33,6 +33,15 @@ struct CameraRawGroups { friend bool operator==(const CameraRawGroups &, const CameraRawGroups &) = default; }; +namespace CameraRawTables { +// What adaptive sliders read first, in sRGB levels. +struct Brightness { + double brightest = 128; + double luminance = 128; + double logBrightest = 128; +}; +} + // Defaults leave the image unchanged. struct CameraRawSettings { static constexpr double exposureLow = -5, exposureHigh = 5; @@ -87,9 +96,11 @@ struct CameraRawSettings { CameraRawSettings applying(const CameraRawGroups &shown) const; // Camera Raw's 0 to 100 size in `adjust_grain`'s pixels. double grainKernelSize() const; - // `clipping`: the Option view; `scale`: preview pixels a pixel. + // Option view, preview scale, and the source's brightness once known. QImage apply(const QImage &image, std::optional<CameraRawClipping> clipping = std::nullopt, double scale = 1, quint32 seed = 0, - int visualizePointColor = -1, bool sharpenMask = false) const; + int visualizePointColor = -1, bool sharpenMask = false, std::optional<CameraRawTables::Brightness> brightness = std::nullopt) const; + // Curve, Mixer and Grading run over a composed table. + std::vector<float> foldingColor(std::vector<float> composed) const; struct Balance { double temperature; double tint; diff --git a/src/Document/CameraRawColor.cpp b/src/Document/CameraRawColor.cpp index 948479d..4cb5f9a 100644 --- a/src/Document/CameraRawColor.cpp +++ b/src/Document/CameraRawColor.cpp @@ -255,6 +255,22 @@ CameraRawGradingSettings CameraRawGradingSettings::normalized() const clamp(balance, -100, 100, 0)}; } +std::vector<float> CameraRawSettings::foldingColor(std::vector<float> composed) const +{ + std::vector<float> table = composed.empty() ? CameraRawTables::identity() : std::move(composed); + const CameraRawCurveSettings shape = curve.normalized(); + if (shape.adjusts()) { + const std::vector<float> tone = shape.toneTable(), red = shape.channelTable(shape.red), green = shape.channelTable(shape.green), + blue = shape.channelTable(shape.blue); + camera_raw_table_curves(table.data(), CameraRawTables::grid, tone.data(), red.data(), green.data(), blue.data()); + } + std::vector<CameraRawStage> stages = CameraRawTables::mixerStages(mixer.normalized()); + const std::vector<CameraRawStage> graded = CameraRawTables::gradingStages(grading.normalized()); + stages.insert(stages.end(), graded.begin(), graded.end()); + camera_raw_compose_onto(table.data(), CameraRawTables::grid, stages.data(), int(stages.size()), CameraRawTables::size); + return table; +} + void CameraRawSettings::applyCurveColor(uchar *pixels, int width, int height, qsizetype stride, int visualize) const { const CameraRawCurveSettings shape = curve.normalized(); diff --git a/src/Document/CameraRawTables.cpp b/src/Document/CameraRawTables.cpp index dd80d61..8849310 100644 --- a/src/Document/CameraRawTables.cpp +++ b/src/Document/CameraRawTables.cpp @@ -1,9 +1,11 @@ #include "Document/CameraRawTables.h" +#include "Document/BrushStroke.h" #include "IO/ImageExporter.h" #include "Logging.h" #include <QFile> +#include <QPainter> #include <array> #include <cmath> #include <span> @@ -160,6 +162,20 @@ Brightness brightness(const uchar *pixels, int width, int height, qsizetype stri return {out[0] * 255, out[1] * 255, out[2] * 255}; } +Brightness brightness(const QImage &image) +{ + try { + QImage drawn = BrushRaster::context(image.width(), image.height(), false); + QPainter painter(&drawn); + BrushRaster::draw(image, QRectF(0, 0, image.width(), image.height()), painter); + painter.end(); + return brightness(drawn.constBits(), drawn.width(), drawn.height(), drawn.bytesPerLine()); + } catch (const ExportError &error) { + qCWarning(lcRendering) << "Camera Raw could not measure the picture's brightness:" << error.what(); + return {}; + } +} + std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness) { std::vector<CameraRawStage> out; @@ -252,10 +268,9 @@ std::vector<CameraRawStage> mixerStages(const CameraRawMixerSettings &mixer) return out; } -std::vector<float> compose(const std::vector<CameraRawStage> &stages) +namespace { +std::vector<float> composed(const std::vector<CameraRawStage> &stages) { - if (stages.empty()) - return {}; std::vector<float> out; try { out.resize(size_t(grid) * grid * grid * 3); @@ -265,6 +280,17 @@ std::vector<float> compose(const std::vector<CameraRawStage> &stages) camera_raw_compose(out.data(), grid, stages.data(), int(stages.size()), size); return out; } +} + +std::vector<float> identity() +{ + return composed({}); +} + +std::vector<float> compose(const std::vector<CameraRawStage> &stages) +{ + return stages.empty() ? std::vector<float>() : composed(stages); +} std::optional<CameraRawSettings::Balance> neutralize(double red, double green, double blue) { diff --git a/src/Document/CameraRawTables.h b/src/Document/CameraRawTables.h index b2a6e77..c62c127 100644 --- a/src/Document/CameraRawTables.h +++ b/src/Document/CameraRawTables.h @@ -20,19 +20,16 @@ const std::vector<uint8_t> &tables(); CameraRawStage whiteBalance(double temperature, double tint); // Exposure's 21 tables in turn, for the lens vignette. const uint8_t *exposureTables(); -// What adaptive sliders read first, in sRGB levels. -struct Brightness { - double brightest = 128; - double luminance = 128; - double logBrightest = 128; -}; Brightness brightness(const uchar *pixels, int width, int height, qsizetype stride); +Brightness brightness(const QImage &image); // Camera Raw's order, sliders at zero left out. std::vector<CameraRawStage> stages(const CameraRawSettings &settings, const Brightness &brightness); // Color Grading's stages: luminance, global, midtones, highlights, shadows. std::vector<CameraRawStage> gradingStages(const CameraRawGradingSettings &grading); // The Mixer's stages: luminance, then saturation, then hue. std::vector<CameraRawStage> mixerStages(const CameraRawMixerSettings &mixer); +// No change, on the composing grid. +std::vector<float> identity(); // The stages over the composing grid; empty without stages. std::vector<float> compose(const std::vector<CameraRawStage> &stages); // Temperature and Tint turning a straight sRGB colour gray. diff --git a/src/Document/Filters.cpp b/src/Document/Filters.cpp index 9413ec9..7d1f367 100644 --- a/src/Document/Filters.cpp +++ b/src/Document/Filters.cpp @@ -1,6 +1,7 @@ #include "Document/Filters.h" #include "Document/DocumentLimits.h" #include "Document/BrushStroke.h" +#include "Document/CameraRawTables.h" #include "Document/ContentFill.h" #include "Document/EditorSession+Model.h" #include "Document/PixelAdjust.h" @@ -161,7 +162,8 @@ QImage PixelFilter::run(const FilterJob &job) case FilterKind::blackWhite: image = settings.blackWhite.apply(job.image); break; case FilterKind::colorBalance: image = settings.colorBalance.apply(job.image); break; case FilterKind::cameraRaw: - image = settings.cameraRaw.apply(job.image, job.cameraRawClipping, job.scale, job.seed, job.visualizesPointColor, job.showsSharpenMask); + image = settings.cameraRaw.apply(job.image, job.cameraRawClipping, job.scale, job.seed, job.visualizesPointColor, job.showsSharpenMask, + job.cameraRawBrightness); break; // Grain sits in layer pixels; the job's seed patterns it. case FilterKind::grain: image = settings.grain.apply(job.image, QPointF(), 1 / job.scale, job.seed); break; @@ -325,7 +327,17 @@ FilterJob FilterEdit::previewJob() const rawPanel.pointColorVisualizeIndex(settings.cameraRaw), rawPanel.sharpenMask, {}, - canvas}; + canvas, + kind == FilterKind::cameraRaw ? std::optional(previewBrightness()) : std::nullopt}; +} + +CameraRawTables::Brightness FilterEdit::previewBrightness() const +{ + if (m_cameraRawBrightness && m_cameraRawBrightness->first == previewSource.cacheKey()) + return m_cameraRawBrightness->second; + const CameraRawTables::Brightness value = CameraRawTables::brightness(previewSource); + m_cameraRawBrightness = std::pair{previewSource.cacheKey(), value}; + return value; } FilterSettings FilterEdit::renderSettings() const diff --git a/src/Document/Filters.h b/src/Document/Filters.h index 0941faa..852dd65 100644 --- a/src/Document/Filters.h +++ b/src/Document/Filters.h @@ -114,6 +114,8 @@ struct FilterJob { QPointF noiseOrigin{}; // Vignette on an empty layer: the canvas it fills. std::optional<QRectF> canvas = std::nullopt; + // Camera Raw's adaptive sliders' reading of `image`, once known. + std::optional<CameraRawTables::Brightness> cameraRawBrightness = std::nullopt; }; // Swift's PixelFilter. @@ -149,6 +151,8 @@ public: // The settings as rendered: a hidden Camera Raw group drops. FilterSettings renderSettings() const; FilterJob previewJob() const; + // Measured once from the preview's source, again when it changes. + CameraRawTables::Brightness previewBrightness() const; // Swift's object identity: a preview lands on its own edit. const QUuid id = QUuid::createUuid(); @@ -193,4 +197,6 @@ private: void grow(const QRectF &extent); // Swift's prepared and prepare: mapping and preview copy. void prepare(const ImportedImage &source, const LayerTransform &placed); + // The source's identity and what it read. + mutable std::optional<std::pair<qint64, CameraRawTables::Brightness>> m_cameraRawBrightness; }; diff --git a/src/Document/ImageAdjustments.cpp b/src/Document/ImageAdjustments.cpp index 2da3526..711ebd3 100644 --- a/src/Document/ImageAdjustments.cpp +++ b/src/Document/ImageAdjustments.cpp @@ -1,15 +1,25 @@ #include "Document/ImageAdjustments.h" #include "Document/BrushStroke.h" #include "IO/ProjectStore.h" +#include "Rendering/PoolMap.h" #include <QPainter> #include <algorithm> #include <array> #include <cmath> +#include <numeric> extern "C" { #include "AdjustPixels.h" #include "LevelsPixels.h" } +// Swift's GCD bands for AdjustPixels.c, on QtConcurrent. +extern "C" void adjust_bands(size_t bands, void (*band)(void *context, size_t index), void *context) +{ + std::vector<size_t> indices(bands); + std::iota(indices.begin(), indices.end(), size_t(0)); + PoolMap::blocking(indices, [band, context](size_t index) { band(context, index); }); +} + QImage ImageAdjustmentPixels::run(const QImage &image, const std::function<void(uchar *, int, int, qsizetype)> &body) { QImage context = BrushRaster::context(image.width(), image.height(), false); diff --git a/tests/CameraRawFoldTests.cpp b/tests/CameraRawFoldTests.cpp new file mode 100644 index 0000000..793b800 --- /dev/null +++ b/tests/CameraRawFoldTests.cpp @@ -0,0 +1,198 @@ +#include "CameraRawFixtures.h" +#include "Document/CameraRawTables.h" +#include "Document/EditorSession.h" +#include "Document/Filters.h" +#include <QtTest> + +// Swift's a8630e4: one table, the brightness once, a sparser scope. +using namespace CameraRawFixtures; + +class CameraRawFoldTests : public QObject { + Q_OBJECT +private slots: + void curveMixerAndGradingFoldIntoTheTable(); + void aPointColourOrRefineSaturationKeepsTheCurvePass(); + void aKnownBrightnessIsReadBeforeCalibration(); + void aSessionMeasuresItsPreviewSourceOnce(); + void everyBandIsMeasuredAndCounted(); + void calibrationsBlueHueTurnsAndItsSaturationSaturates(); +}; + +namespace { +QImage noise(int side = 32) +{ + QImage picture = BrushRaster::context(side, side, false); + quint32 state = 11; + for (int y = 0; y < side; ++y) + for (int x = 0; x < side; ++x) { + std::array<int, 3> channel{}; + for (int &value : channel) { + state = state * 1664525 + 1013904223; + value = int(state >> 24); + } + picture.setPixelColor(x, y, QColor(channel[0], channel[1], channel[2])); + } + return picture; +} + +QImage kernel(const QImage &picture, const std::vector<float> &table) +{ + QImage copy = picture.copy(); + adjust_camera_raw(copy.bits(), size_t(copy.width()), size_t(copy.height()), size_t(copy.bytesPerLine()), table.data(), CameraRawTables::grid, 0); + return copy; +} + +CameraRawSettings coloured() +{ + CameraRawSettings settings; + settings.exposure = 0.5; + settings.curve.rgb = CameraRawCurveSettings::mediumContrast(); + settings.curve.red = {{0, 0}, {0.4, 0.5}, {1, 1}}; + settings.mixer.saturation[1] = -40; + settings.grading.highlights = {30, 40, 10}; + return settings; +} + +int farthest(const QImage &left, const QImage &right) +{ + int most = 0; + const auto a = pixels(left), b = pixels(right); + for (size_t i = 0; i < a.size(); ++i) + for (int c = 0; c < 4; ++c) + most = std::max(most, std::abs(a[i][size_t(c)] - b[i][size_t(c)])); + return most; +} +} + +void CameraRawFoldTests::curveMixerAndGradingFoldIntoTheTable() +{ + const QImage picture = noise(); + const CameraRawSettings settings = coloured().normalized(); + std::vector<float> table = CameraRawTables::compose(CameraRawTables::stages(settings, {})); + // The curve, then the Mixer and Grading, over Light's table. + const CameraRawCurveSettings &curve = settings.curve; + const std::vector<float> tone = curve.toneTable(), red = curve.channelTable(curve.red), green = curve.channelTable(curve.green), + blue = curve.channelTable(curve.blue); + camera_raw_table_curves(table.data(), CameraRawTables::grid, tone.data(), red.data(), green.data(), blue.data()); + std::vector<CameraRawStage> stages = CameraRawTables::mixerStages(settings.mixer); + const std::vector<CameraRawStage> graded = CameraRawTables::gradingStages(settings.grading); + stages.insert(stages.end(), graded.begin(), graded.end()); + camera_raw_compose_onto(table.data(), CameraRawTables::grid, stages.data(), int(stages.size()), CameraRawTables::size); + QVERIFY(pixels(settings.apply(picture)) == pixels(kernel(picture, table))); + // Without Light's table the fold starts from no change. + CameraRawSettings alone; + alone.curve.red = settings.curve.red; + const std::vector<float> identity = CameraRawTables::identity(); + QCOMPARE(identity.size(), size_t(33) * 33 * 33 * 3); + QVERIFY(identity[3 * (16 * 33 * 33 + 4 * 33 + 28)] == 0.5f && identity.back() == 1.0f); + std::vector<float> curved = identity; + const std::vector<float> straight = alone.curve.toneTable(), redOnly = alone.curve.channelTable(alone.curve.red); + camera_raw_table_curves(curved.data(), CameraRawTables::grid, straight.data(), redOnly.data(), straight.data(), straight.data()); + QVERIFY(alone.foldingColor({}) == curved); + QVERIFY(pixels(alone.apply(picture)) == pixels(kernel(picture, curved))); +} + +void CameraRawFoldTests::aPointColourOrRefineSaturationKeepsTheCurvePass() +{ + const QImage picture = noise(); + // A point colour that weighs nothing changes no pixel. + CameraRawSettings pointed = coloured(); + pointed.mixer.points = {CameraRawPointColor{.hue = 200, .saturation = 0.01, .luminance = 0.99}}; + const QImage folded = coloured().apply(picture), passed = pointed.apply(picture); + QVERIFY(farthest(folded, passed) <= 3 && pixels(folded) != pixels(passed)); + // Refine Saturation is the curve pass's alone. + CameraRawSettings refined = coloured(); + refined.curve.refineSaturation = 30; + const CameraRawSettings light = refined.normalized(); + QImage direct = kernel(picture, CameraRawTables::compose(CameraRawTables::stages(light, {}))); + light.applyCurveColor(direct.bits(), direct.width(), direct.height(), direct.bytesPerLine(), -1); + QVERIFY(pixels(refined.apply(picture)) == pixels(direct)); + // A point-colour view keeps the pass, with no points too. + const CameraRawSettings plain = coloured().normalized(); + QImage shown = kernel(picture, CameraRawTables::compose(CameraRawTables::stages(plain, {}))); + plain.applyCurveColor(shown.bits(), shown.width(), shown.height(), shown.bytesPerLine(), 0); + QVERIFY(pixels(coloured().apply(picture, std::nullopt, 1, 0, 0)) == pixels(shown)); +} + +void CameraRawFoldTests::aKnownBrightnessIsReadBeforeCalibration() +{ + // Darker noise, where Shadows Tint turns many hues. + QImage picture = noise(); + for (int y = 0; y < 32; ++y) + for (int x = 0; x < 32; ++x) { + const QColor colour = picture.pixelColor(x, y); + picture.setPixelColor(x, y, QColor(colour.red() / 2, colour.green() / 2, colour.blue() / 2)); + } + CameraRawSettings settings; + settings.shadows = 100; + settings.calibration.shadowTint = 100; + // Measured on the pixels before Shadows Tint changes them. + const CameraRawTables::Brightness before = CameraRawTables::brightness(picture); + QImage direct = picture.copy(); + settings.normalized().applyCalibration(direct.bits(), direct.width(), direct.height(), direct.bytesPerLine()); + const CameraRawTables::Brightness after = CameraRawTables::brightness(direct); + const QImage expected = kernel(direct, CameraRawTables::compose(CameraRawTables::stages(settings.normalized(), before))); + const QImage late = kernel(direct, CameraRawTables::compose(CameraRawTables::stages(settings.normalized(), after))); + QVERIFY(pixels(settings.apply(picture)) == pixels(expected)); + QVERIFY(pixels(late) != pixels(expected)); + // A brightness handed in is used as it is. + const CameraRawTables::Brightness dim{60, 60, 60}; + const QImage handed = kernel(direct, CameraRawTables::compose(CameraRawTables::stages(settings.normalized(), dim))); + QVERIFY(pixels(settings.apply(picture, std::nullopt, 1, 0, -1, false, dim)) == pixels(handed) && pixels(handed) != pixels(expected)); +} + +void CameraRawFoldTests::aSessionMeasuresItsPreviewSourceOnce() +{ + const QImage picture = noise(); + EditorSession session; + session.createDocument(32, 32); + session.insert(ImportedImage(picture, picture, QStringLiteral("Picture"))); + session.beginFilter(FilterKind::cameraRaw); + const FilterEdit &edit = session.filterEdit().value(); + const CameraRawTables::Brightness measured = CameraRawTables::brightness(edit.previewSource); + const CameraRawTables::Brightness carried = edit.previewJob().cameraRawBrightness.value(); + QVERIFY(carried.brightest == measured.brightest && carried.luminance == measured.luminance && carried.logBrightest == measured.logBrightest); + QCOMPARE(edit.previewBrightness().brightest, measured.brightest); + session.cancelFilter(); + session.beginFilter(FilterKind::gaussianBlur); + QVERIFY(!session.filterEdit().value().previewJob().cameraRawBrightness); +} + +// Forty rows of gray: every band's rows reach the sums. +void CameraRawFoldTests::everyBandIsMeasuredAndCounted() +{ + QImage ramp = BrushRaster::context(1, 40, false); + for (int y = 0; y < 40; ++y) + ramp.setPixelColor(0, y, QColor(y * 6, y * 6, y * 6)); + // Python's reading of all forty rows. + const CameraRawTables::Brightness read = CameraRawTables::brightness(ramp); + QVERIFY(std::abs(read.brightest - 139.556468482) < 1e-6 && std::abs(read.logBrightest - 91.779144447) < 1e-6); + // Sixteen bands of three rows, every other row within each. + const CameraRawScope scope = CameraRawScope::make(ramp).value(); + std::array<double, 256> expected{}; + for (int band = 0; band < 16; ++band) + for (int y = band * 3; y < std::min(40, band * 3 + 3); y += 2) + expected[size_t(y * 6)] += 1; + for (size_t level = 0; level < 256; ++level) + QVERIFY2(scope.red[level] == expected[level] && scope.blue[level] == expected[level], qPrintable(QString::number(level))); +} + +// The blue primary: hue turns the hue, saturation saturates. +void CameraRawFoldTests::calibrationsBlueHueTurnsAndItsSaturationSaturates() +{ + const auto hsl = [](const QImage &picture) { + const QColor colour = picture.pixelColor(0, 0).toHsl(); + return std::array<int, 3>{colour.hslHue(), colour.hslSaturation(), colour.lightness()}; + }; + QImage blue = BrushRaster::context(1, 1, false); + blue.setPixelColor(0, 0, QColor(60, 80, 200)); + const std::array<int, 3> before = hsl(blue); + QImage turned = blue.copy(), saturated = blue.copy(); + adjust_camera_raw_calibration(turned.bits(), 1, 1, size_t(turned.bytesPerLine()), 0, 0, 0, 0, 0, 80, 0, 6); + adjust_camera_raw_calibration(saturated.bits(), 1, 1, size_t(saturated.bytesPerLine()), 0, 0, 0, 0, 0, 0, -80, 6); + QVERIFY(std::abs(hsl(turned)[0] - before[0]) > 5 && std::abs(hsl(turned)[1] - before[1]) <= 3); + QVERIFY(std::abs(hsl(saturated)[0] - before[0]) <= 2 && hsl(saturated)[1] < before[1] - 40); +} + +QTEST_GUILESS_MAIN(CameraRawFoldTests) +#include "CameraRawFoldTests.moc" diff --git a/tests/CameraRawPanelTests.cpp b/tests/CameraRawPanelTests.cpp index 120b487..92a1b13 100644 --- a/tests/CameraRawPanelTests.cpp +++ b/tests/CameraRawPanelTests.cpp @@ -412,11 +412,11 @@ void CameraRawPanelTests::everyRowCarriesSwiftsTitleHelpAndClipping() void CameraRawPanelTests::theScopeDrawsItsRibbonsAndItsCells() { Panel panel; - // A gray ramp fills every bin: three full-height ribbons. - QImage ramp = BrushRaster::context(256, 4, false); + // A gray ramp, two pixels a level, fills each bin. + QImage ramp = BrushRaster::context(512, 4, false); for (int y = 0; y < 4; ++y) - for (int x = 0; x < 256; ++x) - ramp.setPixelColor(x, y, QColor(x, x, x)); + for (int x = 0; x < 512; ++x) + ramp.setPixelColor(x, y, QColor(x / 2, x / 2, x / 2)); panel.session.cancelFilter(); panel.session.insert(ImportedImage(ramp, ramp, QStringLiteral("Ramp"))); panel.session.beginFilter(FilterKind::cameraRaw); diff --git a/tests/CameraRawWiringTests.cpp b/tests/CameraRawWiringTests.cpp index f7c8c5b..3f58c9b 100644 --- a/tests/CameraRawWiringTests.cpp +++ b/tests/CameraRawWiringTests.cpp @@ -232,22 +232,26 @@ void CameraRawWiringTests::constrainCropFitsTheWholeFrame() void CameraRawWiringTests::theScopeCountsEachChannelAndHue() { - QImage picture = BrushRaster::context(3, 1, false); + // Every other pixel across; the red between is skipped. + QImage picture = BrushRaster::context(5, 1, false); + picture.fill(QColor(255, 0, 0)); picture.setPixelColor(0, 0, QColor(0, 255, 0, 128)); - picture.setPixelColor(1, 0, QColor(0, 0, 255)); - picture.setPixelColor(2, 0, QColor(255, 0, 255)); - const CameraRawScope scope = CameraRawScope::make(picture).value(); + picture.setPixelColor(2, 0, QColor(0, 0, 255)); + picture.setPixelColor(4, 0, QColor(255, 0, 255)); + for (const QImage &format : {picture, picture.convertToFormat(QImage::Format_ARGB32)}) { + const CameraRawScope scope = CameraRawScope::make(format).value(); // Counts weigh by alpha: the green pixel is half. const double half = 128 / 255.0; const auto near = [](double left, double right) { return std::abs(left - right) < 1e-9; }; - QVERIFY(near(scope.red[0], 1 + half) && near(scope.red[255], 1)); - QVERIFY(near(scope.green[255], half) && near(scope.green[0], 2)); - QVERIFY(near(scope.blue[255], 2) && near(scope.blue[0], half)); - const auto cell = [&](int column, int line) { return scope.vectorscope[size_t(line * CameraRawScope::scopeSide + column)]; }; - // Green at 120°, blue at 240°, magenta at 300°. - QVERIFY(std::abs(cell(16, 58) - 128 / 255.0) < 1e-9); - QCOMPARE(cell(16, 5), 1.0); - QCOMPARE(cell(47, 5), 1.0); + QVERIFY(near(scope.red[0], 1 + half) && near(scope.red[255], 1)); + QVERIFY(near(scope.green[255], half) && near(scope.green[0], 2)); + QVERIFY(near(scope.blue[255], 2) && near(scope.blue[0], half)); + const auto cell = [&](int column, int line) { return scope.vectorscope[size_t(line * CameraRawScope::scopeSide + column)]; }; + // Green at 120°, blue at 240°, magenta at 300°. + QVERIFY(std::abs(cell(16, 58) - 128 / 255.0) < 1e-9); + QCOMPARE(cell(16, 5), 1.0); + QCOMPARE(cell(47, 5), 1.0); + } } void CameraRawWiringTests::curveMixerAndGradingReachTheKernelInOrder() diff --git a/tests/Tests.cmake b/tests/Tests.cmake index 66b8fa0..f42270a 100644 --- a/tests/Tests.cmake +++ b/tests/Tests.cmake @@ -316,6 +316,8 @@ compositor_test(ToolDefaultsTests) compositor_test(ColorAdjustmentTests) compositor_test(CameraRawTests) compositor_test(CameraRawTablesTests) +compositor_test(CameraRawFoldTests) +target_include_directories(CameraRawFoldTests PRIVATE ${OMAPHOTO_KERNELS}) target_include_directories(CameraRawTablesTests PRIVATE ${OMAPHOTO_KERNELS}) compositor_test(CameraRawColorNoiseTests) compositor_test(CameraRawToneOrderTests)