diff --git a/cli/src/auto-optimizer.js b/cli/src/auto-optimizer.js new file mode 100644 index 00000000..1753d2f6 --- /dev/null +++ b/cli/src/auto-optimizer.js @@ -0,0 +1,67 @@ +import { instantiateEmscriptenWasm } from "./emscripten-utils.js"; + +import visdif from "../../codecs/visdif/visdif.js"; +import visdifWasm from "asset-url:../../codecs/visdif/visdif.wasm"; + +// `measure` is a (async) function that takes exactly one numeric parameter and +// returns a value. The function is assumed to be monotonic (an increase in `parameter` +// will result in an increase in the return value. The function uses binary search +// to find `parameter` such that `measure` returns `measureGoal`, within an error +// of `epsilon`. It will use at most `maxRounds` attempts. +export async function binarySearch( + measureGoal, + measure, + { min = 0, max = 100, epsilon = 0.1, maxRounds = 8 } = {} +) { + let parameter = (max - min) / 2 + min; + let delta = (max - min) / 4; + let value; + let round = 0; + do { + value = await measure(parameter); + if (value > measureGoal) { + parameter -= delta; + } else if (value < measureGoal) { + parameter += delta; + } + delta /= 2; + round++; + } while (Math.abs(value - measureGoal) > epsilon && round < maxRounds); + return { parameter, round, value }; +} +export async function autoOptimize( + bitmapIn, + encode, + decode, + { butteraugliDistanceGoal = 1.4, ...otherOpts } = {} +) { + const { VisDiff } = await instantiateEmscriptenWasm(visdif, visdifWasm); + + const comparator = new VisDiff( + bitmapIn.data, + bitmapIn.width, + bitmapIn.height + ); + + let bitmapOut; + let binaryOut; + // Increasing quality means _decrease_ in Butteraugli distance. + // `binarySearch` assumes that increasing `parameter` will + // increase the metric value. So multipliy Butteraugli values by -1. + const { parameter } = await binarySearch( + -1 * butteraugliDistanceGoal, + async quality => { + binaryOut = await encode(bitmapIn, quality); + bitmapOut = await decode(binaryOut); + return -1 * comparator.distance(bitmapOut.data); + }, + otherOpts + ); + comparator.delete(); + + return { + bitmap: bitmapOut, + binary: binaryOut, + quality: parameter + }; +} diff --git a/cli/src/codecs.js b/cli/src/codecs.js index 262b3d0f..6b33c698 100644 --- a/cli/src/codecs.js +++ b/cli/src/codecs.js @@ -1,4 +1,5 @@ import { promises as fsp } from "fs"; +import { instantiateEmscriptenWasm } from "./emscripten-utils.js"; // MozJPEG import mozEnc from "../../codecs/mozjpeg/enc/mozjpeg_enc.js"; @@ -18,16 +19,9 @@ import avifEncWasm from "asset-url:../../codecs/avif/enc/avif_enc.wasm"; import avifDec from "../../codecs/avif/dec/avif_dec.js"; import avifDecWasm from "asset-url:../../codecs/avif/dec/avif_dec.wasm"; -function instantiateEmscriptenWasm(factory, path) { - if (path.startsWith("file://")) { - path = path.slice("file://".length); - } - return factory({ - locateFile() { - return path; - } - }); -} +// Our decoders currently rely on a `ImageData` global. +import ImageData from "./image_data.js"; +globalThis.ImageData = ImageData; export default { mozjpeg: { @@ -53,6 +47,11 @@ export default { chroma_subsample: 2, separate_chroma_quality: false, chroma_quality: 75 + }, + autoOptimize: { + option: "quality", + min: 0, + max: 100 } }, webp: { @@ -89,6 +88,11 @@ export default { near_lossless: 100, use_delta_palette: 0, use_sharp_yuv: 0 + }, + autoOptimize: { + option: "quality", + min: 0, + max: 100 } }, avif: { @@ -104,6 +108,11 @@ export default { tileRowsLog2: 0, speed: 10, subsample: 0 + }, + autoOptimize: { + option: "maxQuantizer", + min: 0, + max: 62 } } }; diff --git a/cli/src/emscripten-utils.js b/cli/src/emscripten-utils.js new file mode 100644 index 00000000..c054e16a --- /dev/null +++ b/cli/src/emscripten-utils.js @@ -0,0 +1,11 @@ +export function instantiateEmscriptenWasm(factory, path) { + if (path.startsWith("file://")) { + path = path.slice("file://".length); + } + return factory({ + locateFile() { + return path; + } + }); +} + diff --git a/cli/src/index.js b/cli/src/index.js index bdc148e1..d0df2cc1 100644 --- a/cli/src/index.js +++ b/cli/src/index.js @@ -8,10 +8,7 @@ import { version } from "json:../package.json"; import supportedFormats from "./codecs.js"; import WorkerPool from "./worker_pool.js"; - -// Our decoders currently rely on a `ImageData` global. -import ImageData from "./image_data.js"; -globalThis.ImageData = ImageData; +import { autoOptimize } from "./auto-optimizer.js"; function clamp(v, min, max) { if (v < min) return min; @@ -51,18 +48,49 @@ async function decodeFile(file) { async function encodeFile({ file, size, - bitmap, + bitmap: bitmapIn, outputFile, encName, encConfig }) { + let out; const encoder = await supportedFormats[encName].enc(); - const out = encoder.encode( - bitmap.data.buffer, - bitmap.width, - bitmap.height, - encConfig - ); + if (encConfig === "auto") { + const optionToOptimize = supportedFormats[encName].autoOptimize.option; + const decoder = await supportedFormats[encName].dec(); + const encode = (bitmapIn, quality) => + encoder.encode( + bitmapIn.data, + bitmapIn.width, + bitmapIn.height, + Object.assign({}, supportedFormats[encName].defaultEncoderOptions, { + [optionToOptimize]: quality + }) + ); + const decode = binary => decoder.decode(binary); + const { bitmap, binary, quality } = await autoOptimize( + bitmapIn, + encode, + decode, + { + min: supportedFormats[encName].autoOptimize.min, + max: supportedFormats[encName].autoOptimize.max + } + ); + out = binary; + console.log( + `Used ${JSON.stringify({ + [optionToOptimize]: quality + })} for ${outputFile}` + ); + } else { + out = encoder.encode( + bitmapIn.data.buffer, + bitmapIn.width, + bitmapIn.height, + encConfig + ); + } await fsp.writeFile(outputFile, out); return { inputSize: size, @@ -89,11 +117,14 @@ async function processFiles(files) { if (!program[encName]) { continue; } - const encConfig = Object.assign( - {}, - value.defaultEncoderOptions, - JSON5.parse(program[encName]) - ); + const encConfig = + program[encName].toLowerCase() === "auto" + ? "auto" + : Object.assign( + {}, + value.defaultEncoderOptions, + JSON5.parse(program[encName]) + ); const outputFile = join(program.outputDir, `${base}.${value.extension}`); jobsStarted++; workerPool @@ -147,54 +178,3 @@ if (isMainThread) { } else { WorkerPool.useThisThreadAsWorker(encodeFile); } - -/* -const butteraugliGoal = 1.4; -const maxRounds = 8; -async function optimize(bitmapIn, encode, decode) { -const visdifModule = require("../codecs/visdif/visdif.js"); - let quality = 50; - let inc = 25; - let butteraugliDistance = 2; - let attempts = 0; - let bitmapOut; - let binaryOut; - - const { VisDiff } = await visdifModule(); - const comparator = new VisDiff( - bitmapIn.data, - bitmapIn.width, - bitmapIn.height - ); - do { - binaryOut = await encode(bitmapIn, quality); - bitmapOut = await decode(binaryOut); - butteraugliDistance = comparator.distance(bitmapOut.data); - console.log({ - butteraugliDistance, - quality, - attempts, - binaryOut, - bitmapOut - }); - if (butteraugliDistance > butteraugliGoal) { - quality += inc; - } else { - quality -= inc; - } - inc /= 2; - attempts++; - } while ( - Math.abs(butteraugliDistance - butteraugliGoal) > 0.1 && - attempts < maxRounds - ); - - comparator.delete(); - - return { - bitmap: bitmapOut, - binary: binaryOut, - quality, - attempts - }; -}*/