Initial Commit
This commit is contained in:
191
packages/media/image/image-quantisation/src/quantise/quantise.ts
Normal file
191
packages/media/image/image-quantisation/src/quantise/quantise.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import type { Counter, Histogram } from "@opentelemetry/api";
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import type { ImageQuantization } from "image-q";
|
||||
import { applyPaletteSync, buildPaletteSync, utils } from "image-q";
|
||||
import {
|
||||
DEFAULT_ALGORITHM,
|
||||
DEFAULT_COLOURS,
|
||||
DEFAULT_DISTANCE,
|
||||
DEFAULT_DITHER,
|
||||
} from "../constants/index.js";
|
||||
import {
|
||||
QUANTISATION_CODES,
|
||||
QuantisationError,
|
||||
} from "../errors/image-quantisation-error.js";
|
||||
import { QuantiseOptionsSchema } from "../schemas/options.schema.js";
|
||||
import type {
|
||||
DitherAlgorithm,
|
||||
QuantiseOptions,
|
||||
QuantiseResult,
|
||||
RGBA,
|
||||
} from "../types/common.js";
|
||||
|
||||
/** OTel metric names. */
|
||||
const METRIC_QUANTISE_TOTAL = "image_quantisation.quantise.total";
|
||||
const METRIC_QUANTISE_DURATION_MS = "image_quantisation.quantise.duration_ms";
|
||||
|
||||
let quantiseCounter: Counter | undefined;
|
||||
let quantiseDuration: Histogram | undefined;
|
||||
|
||||
function getCounter(): Counter {
|
||||
if (!quantiseCounter) {
|
||||
const meter = metrics.getMeter("@suaveplan/image-quantisation");
|
||||
quantiseCounter = meter.createCounter(METRIC_QUANTISE_TOTAL, {
|
||||
description: "Total number of quantise() invocations",
|
||||
});
|
||||
}
|
||||
return quantiseCounter;
|
||||
}
|
||||
|
||||
function getDuration(): Histogram {
|
||||
if (!quantiseDuration) {
|
||||
const meter = metrics.getMeter("@suaveplan/image-quantisation");
|
||||
quantiseDuration = meter.createHistogram(METRIC_QUANTISE_DURATION_MS, {
|
||||
description: "Duration of quantise() in milliseconds",
|
||||
unit: "ms",
|
||||
});
|
||||
}
|
||||
return quantiseDuration;
|
||||
}
|
||||
|
||||
/** Map our dither names to image-q's imageQuantization strings. */
|
||||
function mapDither(d: DitherAlgorithm): ImageQuantization {
|
||||
switch (d) {
|
||||
case "floyd-steinberg":
|
||||
return "floyd-steinberg";
|
||||
case "riemersma":
|
||||
return "riemersma";
|
||||
/* istanbul ignore next — DitherAlgorithm union is exhaustive */
|
||||
default:
|
||||
return "nearest";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a pixel buffer and dimensions.
|
||||
*/
|
||||
function validateInput(
|
||||
pixels: Uint8Array,
|
||||
width: number,
|
||||
height: number
|
||||
): void {
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new QuantisationError(
|
||||
QUANTISATION_CODES.INVALID_DIMENSIONS,
|
||||
"quantisation.invalid_dimensions",
|
||||
{ cause: new Error(`width=${width}, height=${height}`) }
|
||||
);
|
||||
}
|
||||
if (pixels.length !== width * height * 4) {
|
||||
throw new QuantisationError(
|
||||
QUANTISATION_CODES.INVALID_PIXEL_BUFFER,
|
||||
"quantisation.invalid_pixel_buffer",
|
||||
{
|
||||
cause: new Error(
|
||||
`expected ${width * height * 4} bytes, got ${pixels.length}`
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantise an RGBA pixel buffer down to a target number of colours.
|
||||
* Wraps image-q's sync API with ergonomic options.
|
||||
*/
|
||||
export function quantise(
|
||||
pixels: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
options: QuantiseOptions = {}
|
||||
): QuantiseResult {
|
||||
const start = performance.now();
|
||||
validateInput(pixels, width, height);
|
||||
|
||||
const parsed = QuantiseOptionsSchema.safeParse(options);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => `${i.path.join(".")}: ${i.message}`)
|
||||
.join("; ");
|
||||
throw new QuantisationError(
|
||||
QUANTISATION_CODES.PROCESSING_FAILED,
|
||||
`quantisation.invalid_options: ${issues}`
|
||||
);
|
||||
}
|
||||
|
||||
const opts = {
|
||||
colours: parsed.data.colours ?? DEFAULT_COLOURS,
|
||||
algorithm: parsed.data.algorithm ?? DEFAULT_ALGORITHM,
|
||||
distance: parsed.data.distance ?? DEFAULT_DISTANCE,
|
||||
dither: parsed.data.dither ?? DEFAULT_DITHER,
|
||||
};
|
||||
|
||||
const inContainer = utils.PointContainer.fromUint8Array(
|
||||
pixels,
|
||||
width,
|
||||
height
|
||||
);
|
||||
|
||||
const palette = buildPaletteSync([inContainer], {
|
||||
colorDistanceFormula: opts.distance,
|
||||
paletteQuantization: opts.algorithm,
|
||||
colors: opts.colours,
|
||||
});
|
||||
|
||||
const outContainer = applyPaletteSync(inContainer, palette, {
|
||||
colorDistanceFormula: opts.distance,
|
||||
imageQuantization: mapDither(opts.dither),
|
||||
});
|
||||
|
||||
const outPixels = outContainer.toUint8Array();
|
||||
|
||||
const palettePoints = palette.getPointContainer().getPointArray();
|
||||
const paletteColours: RGBA[] = palettePoints.map((p) => ({
|
||||
r: p.r,
|
||||
g: p.g,
|
||||
b: p.b,
|
||||
a: p.a,
|
||||
}));
|
||||
|
||||
const duration = performance.now() - start;
|
||||
const counter = getCounter();
|
||||
const histogram = getDuration();
|
||||
counter.add(1, {
|
||||
"quantisation.algorithm": opts.algorithm,
|
||||
"quantisation.dither": opts.dither,
|
||||
});
|
||||
histogram.record(duration, {
|
||||
"quantisation.algorithm": opts.algorithm,
|
||||
"quantisation.dither": opts.dither,
|
||||
});
|
||||
|
||||
return {
|
||||
pixels: outPixels,
|
||||
width,
|
||||
height,
|
||||
palette: paletteColours,
|
||||
paletteSize: paletteColours.length,
|
||||
options: opts,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Quantise with automatic options based on complexity tier.
|
||||
* Uses the recommended colour count and dithering preference.
|
||||
*/
|
||||
export function quantiseAdaptive(
|
||||
pixels: Uint8Array,
|
||||
width: number,
|
||||
height: number,
|
||||
recommendedColours: number,
|
||||
recommendDithering: boolean,
|
||||
overrides: Partial<QuantiseOptions> = {}
|
||||
): QuantiseResult {
|
||||
return quantise(pixels, width, height, {
|
||||
colours: recommendedColours,
|
||||
algorithm: "wuquant",
|
||||
distance: "euclidean",
|
||||
dither: recommendDithering ? "floyd-steinberg" : "none",
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user