web: filter the exports the model was only smearing

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).
This commit is contained in:
2026-09-25 20:32:52 +07:00
parent a9030fc0c0
commit fd2d9935c1
2 changed files with 24 additions and 10 deletions
+10 -4
View File
@@ -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<Uint8Array> {
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());