From fd2d9935c11b8c0e9dc9ce3c9abf8c6d5e2b6d0e Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 25 Sep 2026 20:32:52 +0700 Subject: [PATCH] web: filter the exports the model was only smearing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upscaler ran for every enlargement, including the ones a plain resample wins: measured on a 2048px source cropped and blown back up it loses to lanczos on PSNR and SSIM at 2x and 3x, and its smoothness reads as plastic skin and lost texture next to it. From 4x — the model's own factor — it stops losing, so the threshold moves to 4 and the crop no longer drags a 2x export through it. The resamples it now carries never set imageSmoothingQuality, and the default 'low' point-samples: a 1px stripe comes out at full amplitude instead of the average of what it crossed. Both callers ask for 'high'. Probe on a 2400x1800 source exported at 4K: 299.9s -> 7.8s, correlation with the source's 1px/2px bands 0.89/0.95 -> 0.98/0.98, grain sd 25.4 -> 47.1 (a plain HQ resize of the same source keeps 16.1). --- docker/frontend/src/App.tsx | 20 ++++++++++++++------ docker/frontend/src/engine/imageOps.ts | 14 ++++++++++---- 2 files changed, 24 insertions(+), 10 deletions(-) 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());