diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 4d0981b..68ab336 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -76,7 +76,14 @@ const CROP_PREVIEW_MIN_FRAC = 0.05; // Below it a plain filter and the model are the same picture and the filter // finishes in a second; above it (a small crop of a big photo) the model earns // the minutes it costs. Never a cap on the exported size itself. -const MODEL_GAIN = 1.5; +// +// Measured, 2048px source cropped and blown back up, model vs plain filter: +// at 2x PSNR/SSIM 18.27/0.3036 against the filter's 18.71/0.3792, at 3x +// 17.96/0.2570 against 18.14/0.2782 — the model's smoothness reads as plastic +// skin and lost texture next to a plain resample. Only from 4x, where the +// pixels truly do not exist, does it stop losing (17.60/0.2305 vs +// 17.84/0.2258). So the model is held back to 4x, its own factor. +const MODEL_GAIN = 4; // Zoom asks for a bigger copy; quantised so a smooth gesture does not re-cut it // on every notch. const PREVIEW_QUANTUM = 512; @@ -1483,7 +1490,7 @@ export function Workspace() { if (tier.maxExportDim && sourceLongest > tier.maxExportDim) { src = await resizedJpeg(source.bytes, tier.maxExportDim, 0.95); } - } else if (wanted > sourceLongest || needLongest > sourceLongest * MODEL_GAIN) { + } else if (needLongest > sourceLongest * MODEL_GAIN) { // The photo is short of the pixels being asked for — more so once the // crop is counted — so the model invents them here, on the device; the // picture never leaves it. The whole photo is enlarged by the factor the @@ -1492,10 +1499,11 @@ export function Workspace() { setBusy(t('act.upscaling', { done, total })) ); } else if (needLongest !== sourceLongest) { - // The photo already holds every pixel the export asks for, so there is - // nothing to invent: resample the whole photo (down, or a hair up to - // make up for the crop) and let the render cut the crop out of it. This - // is what keeps a 2400px photo exportable at 2K in a second. + // Within the model's gain the plain filter is the better picture (see + // MODEL_GAIN), so this covers every other case: down, and a hair or a + // few times up. The whole photo is resampled and the render cuts the + // crop out of it. This is what keeps a 2400px photo exportable at 2K in + // a second. src = await scaledJpeg(source.bytes, needLongest); } const out = await renderPhoto({ diff --git a/docker/frontend/src/engine/imageOps.ts b/docker/frontend/src/engine/imageOps.ts index 826cf19..2d24f52 100644 --- a/docker/frontend/src/engine/imageOps.ts +++ b/docker/frontend/src/engine/imageOps.ts @@ -23,6 +23,10 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0 const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) return bytes; + // The default is 'low', which point-samples: a one-pixel stripe in the + // source comes out as full-amplitude ripple (255 against the 36 an exact + // area average gives) instead of being averaged away. + ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, w, h); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); return new Uint8Array(await blob.arrayBuffer()); @@ -34,10 +38,9 @@ export async function resizedJpeg(bytes: Uint8Array, maxDim: number, quality = 0 // The same resample, but it enlarges as well as shrinks and always re-encodes. // `resizedJpeg` refuses to grow a photo back, which is right for the preview // copy; the export needs the growth because a committed crop keeps only part of -// the photo, so the whole photo has to be stretched a little for the crop to -// land on the requested size — and a plain filter is the honest answer when the -// photo already holds those pixels. Only ever called with a target the photo -// already covers (see the export's gain guard). +// the photo, so the whole photo has to be stretched for the crop to land on the +// requested size — and a plain filter is the honest answer while it still holds +// the detail (see the export's gain guard). export async function scaledJpeg(bytes: Uint8Array, targetLongest: number, quality = 0.95): Promise { const bitmap = await createImageBitmap(new Blob([bytes as BlobPart])); try { @@ -49,6 +52,9 @@ export async function scaledJpeg(bytes: Uint8Array, targetLongest: number, quali const canvas = new OffscreenCanvas(w, h); const ctx = canvas.getContext('2d'); if (!ctx) return bytes; + // 'high' for the same reason as `resizedJpeg`: the default aliases, and + // this path is now the export's upscale, where a ripple shows. + ctx.imageSmoothingQuality = 'high'; ctx.drawImage(bitmap, 0, 0, w, h); const blob = await canvas.convertToBlob({ type: 'image/jpeg', quality }); return new Uint8Array(await blob.arrayBuffer());