fix(tone): read the ramp's base from the pixel's own neighbourhood, so highlights and shadows move a region and keep the detail in it
This commit is contained in:
@@ -21,12 +21,13 @@
|
||||
// alone keeps the print size metadata.
|
||||
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
|
||||
|
||||
import { Skia } from './skiaShim';
|
||||
import { Skia, CK } from './skiaShim';
|
||||
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../shared/types';
|
||||
import { ASPECT_RATIO_W_H } from '../../shared/types';
|
||||
import { getSkiaColorMatrix, exposureStops } from '../../shared/utils/colorUtils';
|
||||
import {
|
||||
TONE_SKSL,
|
||||
TONE_BASE_SKSL,
|
||||
EXPOSURE_SKSL,
|
||||
GLOW_SKSL,
|
||||
SHARPEN_SKSL,
|
||||
@@ -37,8 +38,8 @@ import {
|
||||
DEHAZE_SKSL,
|
||||
dehazeUniformArray,
|
||||
getToneUniforms,
|
||||
TONE_BASE_RADIUS,
|
||||
TONE_BASE_SIGMA,
|
||||
toneBaseSurface,
|
||||
TONE_BASE_SCALE,
|
||||
toneIsActive,
|
||||
toneUniformArray,
|
||||
glowUniformArray,
|
||||
@@ -186,6 +187,7 @@ let glowEffect: any = null;
|
||||
let halationEffect: any = null;
|
||||
let curveEffect: any = null;
|
||||
let clarityBlurEffect: any = null;
|
||||
let toneBaseEffect: any = null;
|
||||
let clarityBlendEffect: any = null;
|
||||
let nrEffect: any = null;
|
||||
let dehazeEffect: any = null;
|
||||
@@ -201,6 +203,7 @@ function effects() {
|
||||
if (!halationEffect) halationEffect = Skia.RuntimeEffect.Make(HALATION_SKSL);
|
||||
if (!curveEffect) curveEffect = Skia.RuntimeEffect.Make(CURVE_SKSL);
|
||||
if (!clarityBlurEffect) clarityBlurEffect = Skia.RuntimeEffect.Make(CLARITY_BLUR_SKSL);
|
||||
if (!toneBaseEffect) toneBaseEffect = Skia.RuntimeEffect.Make(TONE_BASE_SKSL);
|
||||
if (!clarityBlendEffect) clarityBlendEffect = Skia.RuntimeEffect.Make(CLARITY_BLEND_SKSL);
|
||||
if (!nrEffect) nrEffect = Skia.RuntimeEffect.Make(NR_SKSL);
|
||||
if (!dehazeEffect) dehazeEffect = Skia.RuntimeEffect.Make(DEHAZE_SKSL);
|
||||
@@ -215,6 +218,7 @@ function effects() {
|
||||
halationEffect,
|
||||
curveEffect,
|
||||
clarityBlurEffect,
|
||||
toneBaseEffect,
|
||||
clarityBlendEffect,
|
||||
nrEffect,
|
||||
dehazeEffect,
|
||||
@@ -331,40 +335,73 @@ function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: num
|
||||
disposeAll([paint, filter, snap]);
|
||||
}
|
||||
|
||||
// The BASE layer of the tone ramp: the frame blurred out to TONE_BASE_RADIUS, as
|
||||
// an image the tone shader takes as its second child and reads once per pixel.
|
||||
// Blurred with Skia's own MakeBlur — the same draw drawBlurred makes for the
|
||||
// sharpening pass — because what the ramp needs is an AVERAGE of the
|
||||
// neighbourhood and a ring of point samples is not one: sampled on a fine ring
|
||||
// the luma aliases, the gain o(base)/base inherits the alias, and the
|
||||
// reconstruction paints it back as mottle (a 1160x774 frame at SHADOW +100 came
|
||||
// out with 0.063 of high-frequency gain against 0.005 for this blur — see
|
||||
// TONE_BASE_RADIUS in toneShader.ts). `shaderOf` is left owned by the caller: the
|
||||
// tone pass reads the same shader as its first child.
|
||||
function blurredBase(
|
||||
shaderOf: () => any,
|
||||
// The BASE layer of the tone ramp: the frame's own neighbourhood level out to
|
||||
// TONE_BASE_RADIUS, as an image the tone shader takes as its second child and
|
||||
// reads once per pixel. NOT a blur — a blur is an average of whatever is in the
|
||||
// neighbourhood, so at the frame's strong edges it lands across them and the
|
||||
// pixels beside a bright sky take the shadow side's move (the halo of
|
||||
// docker/halo_highlight_shadow_white_black.md). TONE_BASE_SKSL is the edge-aware
|
||||
// pass instead: one bilateral 1xN per axis, over a 1 px line-blind med3 of the
|
||||
// same axis, so the base is the level of the pixel's OWN side of an edge and a
|
||||
// twig finer than a couple of pixels is detail rather than tone (see
|
||||
// TONE_BASE_RADIUS in toneShader.ts), on a surface TONE_BASE_SCALE of the frame
|
||||
// so the two passes cost a quarter of what they do at full size and the base
|
||||
// carries exactly the detail a level that wide can. Two axis passes rather than
|
||||
// one NxN kernel is the same 2n+1 reads CLARITY_BLUR_SKSL buys its reference
|
||||
// with.
|
||||
//
|
||||
// Null only if the effect or a surface will not come up, in which case the caller
|
||||
// hands the tone shader the sharp child as its base: the base then lands on the
|
||||
// pixel and the ramp falls back to the global move. `shaderOf` is left owned by
|
||||
// the caller — the tone pass reads the same shader as its first child.
|
||||
function toneBaseAxis(
|
||||
effect: any,
|
||||
child: any,
|
||||
w: number,
|
||||
h: number,
|
||||
sigma: number
|
||||
dx: number,
|
||||
dy: number
|
||||
): any | null {
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return null;
|
||||
try {
|
||||
const shader = shaderOf();
|
||||
const shader = effect.makeShaderWithChildren([dx, dy], [child]);
|
||||
if (!shader) return null;
|
||||
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
||||
const paint = Skia.Paint();
|
||||
paint.setImageFilter(filter);
|
||||
paint.setShader(shader);
|
||||
surf.getCanvas().drawRect(Skia.XYWHRect(0, 0, w, h), paint);
|
||||
flush(surf);
|
||||
disposeAll([paint, filter]);
|
||||
disposeAll([paint, shader]);
|
||||
return surf.makeImageSnapshot() ?? null;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// The base the ramp reads, at TONE_BASE_SCALE of the frame: two axis passes over
|
||||
// toneBaseSurface's own step — the horizontal one reading the frame's full-size
|
||||
// child through baseDownscale, the vertical one reading what it left at 1:1. A
|
||||
// 1:1 draw is what keeps `dir` in the pass's OWN pixels (TONE_BASE_SKSL): the
|
||||
// scale lives in the child's local matrix, not in the canvas, so the tap offsets
|
||||
// are the half's own and the second pass needs no coordinate of its own.
|
||||
function toneBase(shaderOf: (lm?: any) => any, w: number, h: number): any | null {
|
||||
const { toneBaseEffect: effect } = effects();
|
||||
if (!effect) return null;
|
||||
const { width: bw, height: bh, step } = toneBaseSurface(w, h);
|
||||
const frame = shaderOf(baseDownscale());
|
||||
if (!frame) return null;
|
||||
const horizontal = toneBaseAxis(effect, frame, bw, bh, step, 0);
|
||||
if (!horizontal) return null;
|
||||
const child = imageShaderChild(horizontal);
|
||||
try {
|
||||
if (!child) return null;
|
||||
return toneBaseAxis(effect, child, bw, bh, 0, step);
|
||||
} finally {
|
||||
disposeAll([child]);
|
||||
horizontal.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// A blurred copy of what the surface holds right now, as an image a pass can
|
||||
// take as a child. Same `MakeBlur` as the ramp's base and the negative blurs,
|
||||
// but built from a SNAPSHOT rather than from a shader the caller still holds —
|
||||
@@ -666,10 +703,37 @@ function dehazeDarkChannel(
|
||||
|
||||
// An image as a child shader — what every effect here wants instead of a paint.
|
||||
// The caller owns the result (the renderer's `own`).
|
||||
function imageShaderChild(image: any): any {
|
||||
return image.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
function imageShaderChild(image: any, localMatrix?: any, fm = Skia.FilterMode.Linear): any {
|
||||
const tx = Skia.TileMode.Clamp;
|
||||
return localMatrix
|
||||
? image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None, localMatrix)
|
||||
: image.makeShaderOptions(tx, tx, fm, Skia.MipmapMode.None);
|
||||
}
|
||||
|
||||
// The base is built on a surface TONE_BASE_SCALE of the frame, so the frame's own
|
||||
// child is handed to it with the matrix that puts the frame's coordinates on that
|
||||
// surface (the local matrix runs the child's reads through its own space, i.e.
|
||||
// twice where the pixel is). Skia's sampler then does the box downscale for free,
|
||||
// to the same bytes as the equivalent 2:1 drawImageRectOptions — measured in the
|
||||
// head of tone-local-contrast-check.mjs. `CK` rather than the shim's `Skia`: the
|
||||
// RN API this file is a port of has no local matrix, so the shim has none.
|
||||
function baseDownscale(): any {
|
||||
return CK.Matrix.scaled(TONE_BASE_SCALE, TONE_BASE_SCALE);
|
||||
}
|
||||
|
||||
// ...and the base comes back the same way: the tone pass reads it at the frame's
|
||||
// own size through the inverse matrix, and the sampler does the 2x upscale —
|
||||
// NEAREST, not linear. The half the pass leaves is one texel per two frame
|
||||
// pixels, so a linear 2x read weights the texel across a step edge by a quarter
|
||||
// on the pixel beside it, and that quarter of the far side's level is exactly the
|
||||
// halo the edge-aware base exists to avoid (2 px at -20.9/255 on the step-edge
|
||||
// frame, measured in tone-local-contrast-check.mjs). Nearest replicates the texel
|
||||
// instead and the step lands on the edge; the base is a smoothed signal, so the
|
||||
// 2x2 blocks it costs are the levels the bilateral already had, and every pixel
|
||||
// still reads toneCurve at its own base. The same sampler calibrated: nearest at
|
||||
// 2x is floor(X / 2) exactly, 0/256 mismatches on a 16x16 field.
|
||||
function baseUpscale(): any {
|
||||
return CK.Matrix.scaled(1 / TONE_BASE_SCALE, 1 / TONE_BASE_SCALE);
|
||||
}
|
||||
|
||||
// The same child, read in the frame's coordinates rather than its own: a pass
|
||||
@@ -914,11 +978,14 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
// 3b. Tone shader.
|
||||
// TONE_BASE_RADIUS of the FRAME is the neighbourhood the ramp is drawn
|
||||
// through, so the preview and the file look at the same size of one — this is
|
||||
// the one place that knows how big the frame is. The base itself is a blurred
|
||||
// copy of the tone shader's own child (blurredBase), not a ring of samples
|
||||
// inside it: see TONE_BASE_RADIUS for why a ring was the mottle bug. A frame
|
||||
// too small or a surface that will not come up leaves the sharp child as the
|
||||
// base, which lands the base on the pixel and keeps the global ramp.
|
||||
// the one place that knows how big the frame is. The base itself is an
|
||||
// edge-aware copy of the tone shader's own child (toneBase), not a ring of
|
||||
// samples inside it and not a blur either: see TONE_BASE_RADIUS for why a
|
||||
// ring was the mottle bug and a blur is the halo one. It comes back at
|
||||
// TONE_BASE_SCALE and is upscaled by its own shader's local matrix (see
|
||||
// baseUpscale). A frame too small or a
|
||||
// surface that will not come up leaves the sharp child as the base, which
|
||||
// lands the base on the pixel and keeps the global ramp.
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
let toneShader: any = null;
|
||||
// 3c. Cinema seasonal grade (cinema → tone → image).
|
||||
@@ -942,32 +1009,41 @@ export async function renderPhoto(input: RenderInput): Promise<RenderResult | nu
|
||||
gradeImage = snapshot;
|
||||
}
|
||||
}
|
||||
const imageShaderOf = () =>
|
||||
own(
|
||||
gradeImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
)
|
||||
);
|
||||
const imageShaderOf = (lm?: any) => own(imageShaderChild(gradeImage, lm));
|
||||
// Exposure takes the place of the colour filter on the draw, so it too needs
|
||||
// the matrix to be IN its child — hence expOn in `graded` above. Everything
|
||||
// downstream that samples "the image" now samples after the exposure.
|
||||
let exposeShader: any = null;
|
||||
let exposeEffect: any = null;
|
||||
if (expOn) {
|
||||
const { exposureEffect: effect } = effects();
|
||||
exposeEffect = effect;
|
||||
if (effect) exposeShader = own(effect.makeShaderWithChildren([evStops], [imageShaderOf()]));
|
||||
}
|
||||
const baseShaderOf = () => exposeShader ?? imageShaderOf();
|
||||
// The frame's own child, at the coordinate space the caller asks for: the
|
||||
// base pass reads it at TONE_BASE_SCALE of the frame (see toneBase), every
|
||||
// other caller reads the frame itself. The exposure, if there is one, has to
|
||||
// be rebuilt over the scaled child — it is a per-pixel map, so its output is
|
||||
// the same either way, but the pixels it reads are not.
|
||||
const baseShaderOf = (lm?: any): any => {
|
||||
if (!lm) return exposeShader ?? imageShaderOf();
|
||||
if (!exposeShader || !exposeEffect) return imageShaderOf(lm);
|
||||
return own(exposeEffect.makeShaderWithChildren([evStops], [imageShaderOf(lm)]));
|
||||
};
|
||||
if (toneIsActive(tone)) {
|
||||
const { toneEffect: effect } = effects();
|
||||
if (effect) {
|
||||
// The base is built HERE and not earlier: it draws the same shader the
|
||||
// tone pass reads, and both have to see the pixels after the exposure.
|
||||
const sigma = width * TONE_BASE_RADIUS * TONE_BASE_SIGMA;
|
||||
const base = own(blurredBase(baseShaderOf, width, height, sigma));
|
||||
// It comes back TONE_BASE_SCALE of the frame, which is why the tone pass
|
||||
// takes it with baseUpscale()'s local matrix rather than as it stands —
|
||||
// and reads it NEAREST, so the upscale replicates the half's texels
|
||||
// instead of weighting the far side of an edge into the pixel beside it.
|
||||
const base = own(toneBase(baseShaderOf, width, height));
|
||||
toneShader = own(
|
||||
effect.makeShaderWithChildren(toneUniformArray(tone), [
|
||||
baseShaderOf(),
|
||||
base ? own(imageShaderChild(base)) : baseShaderOf(),
|
||||
base ? own(imageShaderChild(base, baseUpscale(), Skia.FilterMode.Nearest)) : baseShaderOf(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user