Hardcode quantization pass

This commit is contained in:
Surma
2018-07-30 16:28:37 +01:00
parent 9d5ad83ff8
commit 5fbf6b297f
5 changed files with 433 additions and 10 deletions

View File

@@ -0,0 +1,78 @@
import imagequant from '../../../codecs/imagequant/imagequant';
// Using require() so TypeScript doesnt complain about this not being a module.
const wasmBinaryUrl = require('../../../codecs/imagequant/imagequant.wasm');
// API exposed by wasm module. Details in the codecs README.
interface ModuleAPI {
version(): number;
create_buffer(width: number, height: number): number;
destroy_buffer(pointer: number): void;
quantize(buffer: number, width: number, height: number, numColors: number, dither: number): void;
free_result(): void;
get_result_pointer(): number;
}
export default class ImageQuant {
private emscriptenModule: Promise<EmscriptenWasm.Module>;
private api: Promise<ModuleAPI>;
constructor() {
this.emscriptenModule = new Promise((resolve) => {
const m = imagequant({
// Just to be safe, dont automatically invoke any wasm functions
noInitialRun: false,
locateFile(url: string): string {
// Redirect the request for the wasm binary to whatever webpack gave us.
if (url.endsWith('.wasm')) {
return wasmBinaryUrl;
}
return url;
},
onRuntimeInitialized() {
// An Emscripten is a then-able that, for some reason, `then()`s itself,
// causing an infite loop when you wrap it in a real promise. Deleting the `then`
// prop solves this for now.
// See: https://github.com/kripken/emscripten/blob/incoming/src/postamble.js#L129
// TODO(surma@): File a bug with Emscripten on this.
delete (m as any).then;
resolve(m);
},
});
});
this.api = (async () => {
// Not sure why, but TypeScript complains that I am using
// `emscriptenModule` before its getting assigned, which is clearly not
// true :shrug: Using `any`
const m = await (this as any).emscriptenModule;
return {
version: m.cwrap('version', 'number', []),
create_buffer: m.cwrap('create_buffer', 'number', ['number', 'number']),
destroy_buffer: m.cwrap('destroy_buffer', '', ['number']),
quantize: m.cwrap('quantize', '', ['number', 'number', 'number', 'number', 'number']),
free_result: m.cwrap('free_result', '', []),
get_result_pointer: m.cwrap('get_result_pointer', 'number', []),
};
})();
}
async quantize(data: ImageData): Promise<ImageData> {
const m = await this.emscriptenModule;
const api = await this.api;
const p = api.create_buffer(data.width, data.height);
m.HEAP8.set(new Uint8Array(data.data), p);
api.quantize(p, data.width, data.height, 256, 1.0);
const resultPointer = api.get_result_pointer();
const resultView = new Uint8Array(
m.HEAP8.buffer,
resultPointer,
data.width * data.height * 4,
);
const result = new Uint8ClampedArray(resultView);
api.free_result();
api.destroy_buffer(p);
return new ImageData(result, data.width, data.height);
}
}

View File

@@ -0,0 +1,330 @@
// import { h, Component } from 'preact';
// import { bind } from '../../lib/util';
// import * as styles from './styles.scss';
// type Props = {
// options: EncodeOptions,
// onChange(newOptions: EncodeOptions): void,
// };
// // From kLosslessPresets in config_enc.c
// // The format is [method, quality].
// const losslessPresets:[number, number][] = [
// [0, 0], [1, 20], [2, 25], [3, 30], [3, 50],
// [4, 50], [4, 75], [4, 90], [5, 90], [6, 100],
// ];
// const losslessPresetDefault = 6;
// function determineLosslessQuality(quality: number): number {
// const index = losslessPresets.findIndex(item => item[1] === quality);
// if (index !== -1) return index;
// // Quality doesn't match one of the presets.
// // This can happen when toggling 'lossless'.
// return losslessPresetDefault;
// }
// /**
// * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
// */
// function fieldCheckedAsNumber(field: any): number {
// return Number((field as HTMLInputElement).checked);
// }
// /**
// * @param field An HTMLInputElement, but the casting is done here to tidy up onChange.
// */
// function fieldValueAsNumber(field: any): number {
// return Number((field as HTMLInputElement).value);
// }
// export default class WebPEncoderOptions extends Component<Props, {}> {
// @bind
// onChange(event: Event) {
// const form = (event.currentTarget as HTMLInputElement).closest('form') as HTMLFormElement;
// const lossless = fieldCheckedAsNumber(form.lossless);
// const losslessPresetInput = (form.lossless_preset as HTMLInputElement);
// const options: EncodeOptions = {
// // Copy over options the form doesn't care about, eg emulate_jpeg_size
// ...this.props.options,
// // And now stuff from the form:
// lossless,
// // Special-cased inputs:
// // In lossless mode, the quality is derived from the preset.
// quality: lossless ?
// losslessPresets[Number(losslessPresetInput.value)][1] :
// fieldValueAsNumber(form.quality),
// // In lossless mode, the method is derived from the preset.
// method: lossless ?
// losslessPresets[Number(losslessPresetInput.value)][0] :
// fieldValueAsNumber(form.method_input),
// image_hint: (form.image_hint as HTMLInputElement).checked ?
// WebPImageHint.WEBP_HINT_GRAPH :
// WebPImageHint.WEBP_HINT_DEFAULT,
// // .checked
// exact: fieldCheckedAsNumber(form.exact),
// alpha_compression: fieldCheckedAsNumber(form.alpha_compression),
// autofilter: fieldCheckedAsNumber(form.autofilter),
// filter_type: fieldCheckedAsNumber(form.filter_type),
// use_sharp_yuv: fieldCheckedAsNumber(form.use_sharp_yuv),
// // .value
// near_lossless: fieldValueAsNumber(form.near_lossless),
// alpha_quality: fieldValueAsNumber(form.alpha_quality),
// alpha_filtering: fieldValueAsNumber(form.alpha_filtering),
// sns_strength: fieldValueAsNumber(form.sns_strength),
// filter_strength: fieldValueAsNumber(form.filter_strength),
// filter_sharpness: fieldValueAsNumber(form.filter_sharpness),
// pass: fieldValueAsNumber(form.pass),
// preprocessing: fieldValueAsNumber(form.preprocessing),
// segments: fieldValueAsNumber(form.segments),
// partitions: fieldValueAsNumber(form.partitions),
// };
// this.props.onChange(options);
// }
// private _losslessSpecificOptions(options: EncodeOptions) {
// return (
// <div>
// <label>
// Effort:
// <input
// name="lossless_preset"
// type="range"
// min="0"
// max="9"
// value={'' + determineLosslessQuality(options.quality)}
// onChange={this.onChange}
// />
// </label>
// <label>
// Slight loss:
// <input
// class={styles.flipRange}
// name="near_lossless"
// type="range"
// min="0"
// max="100"
// value={'' + options.near_lossless}
// onChange={this.onChange}
// />
// </label>
// <label>
// {/*
// Although there are 3 different kinds of image hint, webp only
// seems to do something with the 'graph' type, and I don't really
// understand what it does.
// */}
// <input
// name="image_hint"
// type="checkbox"
// checked={options.image_hint === WebPImageHint.WEBP_HINT_GRAPH}
// value={'' + WebPImageHint.WEBP_HINT_GRAPH}
// onChange={this.onChange}
// />
// Discrete tone image (graph, map-tile etc)
// </label>
// </div>
// );
// }
// private _lossySpecificOptions(options: EncodeOptions) {
// return (
// <div>
// <label>
// Effort:
// <input
// name="method_input"
// type="range"
// min="0"
// max="6"
// value={'' + options.method}
// onChange={this.onChange}
// />
// </label>
// <label>
// Quality:
// <input
// name="quality"
// type="range"
// min="0"
// max="100"
// step="0.01"
// value={'' + options.quality}
// onChange={this.onChange}
// />
// </label>
// <label>
// <input
// name="alpha_compression"
// type="checkbox"
// checked={!!options.alpha_compression}
// onChange={this.onChange}
// />
// Compress alpha
// </label>
// <label>
// Alpha quality:
// <input
// name="alpha_quality"
// type="range"
// min="0"
// max="100"
// value={'' + options.alpha_quality}
// onChange={this.onChange}
// />
// </label>
// <label>
// Alpha filter quality:
// <input
// name="alpha_filtering"
// type="range"
// min="0"
// max="2"
// value={'' + options.alpha_filtering}
// onChange={this.onChange}
// />
// </label>
// <label>
// Spacial noise shaping:
// <input
// name="sns_strength"
// type="range"
// min="0"
// max="100"
// value={'' + options.sns_strength}
// onChange={this.onChange}
// />
// </label>
// <label>
// <input
// name="autofilter"
// type="checkbox"
// checked={!!options.autofilter}
// onChange={this.onChange}
// />
// Auto adjust filter strength
// </label>
// <label>
// Filter strength:
// <input
// name="filter_strength"
// type="range"
// min="0"
// max="100"
// disabled={!!options.autofilter}
// value={'' + options.filter_strength}
// onChange={this.onChange}
// />
// </label>
// <label>
// <input
// name="filter_type"
// type="checkbox"
// checked={!!options.filter_type}
// onChange={this.onChange}
// />
// Strong filter
// </label>
// <label>
// Filter sharpness:
// <input
// class={styles.flipRange}
// name="filter_sharpness"
// type="range"
// min="0"
// max="7"
// value={'' + options.filter_sharpness}
// onChange={this.onChange}
// />
// </label>
// <label>
// <input
// name="use_sharp_yuv"
// type="checkbox"
// checked={!!options.use_sharp_yuv}
// onChange={this.onChange}
// />
// Sharp RGB->YUV conversion
// </label>
// <label>
// Passes:
// <input
// name="pass"
// type="range"
// min="1"
// max="10"
// value={'' + options.pass}
// onChange={this.onChange}
// />
// </label>
// <label>
// Preprocessing type:
// <select
// name="preprocessing"
// value={'' + options.preprocessing}
// onChange={this.onChange}
// >
// <option value="0">None</option>
// <option value="1">Segment smooth</option>
// <option value="2">Pseudo-random dithering</option>
// </select>
// </label>
// <label>
// Segments:
// <input
// name="segments"
// type="range"
// min="1"
// max="4"
// value={'' + options.segments}
// onChange={this.onChange}
// />
// </label>
// <label>
// Partitions:
// <input
// name="partitions"
// type="range"
// min="0"
// max="3"
// value={'' + options.partitions}
// onChange={this.onChange}
// />
// </label>
// </div>
// );
// }
// render({ options }: Props) {
// // I'm rendering both lossy and lossless forms, as it becomes much easier when
// // gathering the data.
// return (
// <form>
// <label>
// <input
// name="lossless"
// type="checkbox"
// checked={!!options.lossless}
// onChange={this.onChange}
// />
// Lossless
// </label>
// <div class={options.lossless ? '' : styles.hide}>
// {this._losslessSpecificOptions(options)}
// </div>
// <div class={options.lossless ? styles.hide : ''}>
// {this._lossySpecificOptions(options)}
// </div>
// <label>
// <input
// name="exact"
// type="checkbox"
// checked={!!options.exact}
// onChange={this.onChange}
// />
// Preserve transparent data. Otherwise, pixels with zero alpha will have RGB also zeroed.
// </label>
// </form>
// );
// }
// }

View File

@@ -0,0 +1,7 @@
import QuantizerWorker from './Quantizer.worker';
export const name = 'Image Quanitzer';
export async function quantize(data: ImageData): Promise<ImageData> {
const quantizer = await new QuantizerWorker();
return quantizer.quantize(data);
}

View File

@@ -0,0 +1,6 @@
.flip-range {
transform: scaleX(-1);
}
.hide {
display: none;
}

View File

@@ -8,6 +8,7 @@ import Options from '../Options';
import { FileDropEvent } from './custom-els/FileDrop';
import './custom-els/FileDrop';
import * as quantizer from '../../codecs/imagequant/quantizer';
import * as mozJPEG from '../../codecs/mozjpeg/encoder';
import * as webP from '../../codecs/webp/encoder';
import * as identity from '../../codecs/identity/encoder';
@@ -64,18 +65,19 @@ async function compressImage(
// Special case for identity
if (encodeData.type === identity.type) return source.file;
const quantizedSource = await quantizer.quantize(source.data);
const compressedData = await (() => {
switch (encodeData.type) {
case mozJPEG.type: return mozJPEG.encode(source.data, encodeData.options);
case webP.type: return webP.encode(source.data, encodeData.options);
case browserPNG.type: return browserPNG.encode(source.data, encodeData.options);
case browserJPEG.type: return browserJPEG.encode(source.data, encodeData.options);
case browserWebP.type: return browserWebP.encode(source.data, encodeData.options);
case browserGIF.type: return browserGIF.encode(source.data, encodeData.options);
case browserTIFF.type: return browserTIFF.encode(source.data, encodeData.options);
case browserJP2.type: return browserJP2.encode(source.data, encodeData.options);
case browserBMP.type: return browserBMP.encode(source.data, encodeData.options);
case browserPDF.type: return browserPDF.encode(source.data, encodeData.options);
case mozJPEG.type: return mozJPEG.encode(quantizedSource, encodeData.options);
case webP.type: return webP.encode(quantizedSource, encodeData.options);
case browserPNG.type: return browserPNG.encode(quantizedSource, encodeData.options);
case browserJPEG.type: return browserJPEG.encode(quantizedSource, encodeData.options);
case browserWebP.type: return browserWebP.encode(quantizedSource, encodeData.options);
case browserGIF.type: return browserGIF.encode(quantizedSource, encodeData.options);
case browserTIFF.type: return browserTIFF.encode(quantizedSource, encodeData.options);
case browserJP2.type: return browserJP2.encode(quantizedSource, encodeData.options);
case browserBMP.type: return browserBMP.encode(quantizedSource, encodeData.options);
case browserPDF.type: return browserPDF.encode(quantizedSource, encodeData.options);
default: throw Error(`Unexpected encoder ${JSON.stringify(encodeData)}`);
}
})();