mirror of
https://github.com/usetrmnl/byos_next.git
synced 2026-04-29 13:34:28 -07:00
fcca05d824
Replace the image-processing non-null assertions with explicit dimension checks and normalize the browser renderer suppressions so the CI Biome job exits cleanly. Made-with: Cursor
315 lines
8.4 KiB
TypeScript
315 lines
8.4 KiB
TypeScript
/** Quantize a single pixel value to the nearest available gray level
|
|
* e.g. levels=2 → 0 or 255, levels=4 → 0, 85, 170, 256
|
|
**/
|
|
export const quantizeValue = (value: number, levels: number): number => {
|
|
const step = 255 / (levels - 1);
|
|
const quantized = Math.round(value / step) * step;
|
|
return Math.min(255, Math.max(0, quantized));
|
|
};
|
|
|
|
/** Quantize each pixel to the nearest gray level with no dithering */
|
|
export const quantize = (grayscale: Uint8Array, levels = 2): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
for (let i = 0; i < grayscale.length; i++) {
|
|
result[i] = quantizeValue(grayscale[i], levels);
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/** Simple threshold dithering. Pixels below threshold map to black, at or above to white. */
|
|
export const ditherThreshold = (
|
|
grayscale: Uint8Array,
|
|
threshold = 128,
|
|
): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
for (let i = 0; i < grayscale.length; i++) {
|
|
result[i] = grayscale[i] < threshold ? 0 : 255;
|
|
}
|
|
return result;
|
|
};
|
|
|
|
/** Floyd-Steinberg error diffusion dithering */
|
|
export const ditherFloydSteinberg = (
|
|
grayscale: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
levels = 2,
|
|
): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
const buffer = new Float32Array(grayscale.length);
|
|
|
|
for (let i = 0; i < grayscale.length; i++) {
|
|
buffer[i] = grayscale[i];
|
|
}
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const index = y * width + x;
|
|
const oldPixel = buffer[index];
|
|
const newPixel = quantizeValue(oldPixel, levels);
|
|
result[index] = newPixel;
|
|
const error = oldPixel - newPixel;
|
|
|
|
if (x + 1 < width) buffer[index + 1] += (error * 7) / 16;
|
|
if (y + 1 < height && x > 0)
|
|
buffer[index + width - 1] += (error * 3) / 16;
|
|
if (y + 1 < height) buffer[index + width] += (error * 5) / 16;
|
|
if (y + 1 < height && x + 1 < width)
|
|
buffer[index + width + 1] += (error * 1) / 16;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/** Atkinson error diffusion dithering */
|
|
export const ditherAtkinson = (
|
|
grayscale: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
levels = 2,
|
|
): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
const buffer = new Float32Array(grayscale.length);
|
|
|
|
for (let i = 0; i < grayscale.length; i++) {
|
|
buffer[i] = grayscale[i];
|
|
}
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const index = y * width + x;
|
|
const oldPixel = buffer[index];
|
|
const newPixel = quantizeValue(oldPixel, levels);
|
|
result[index] = newPixel;
|
|
const error = Math.floor((oldPixel - newPixel) / 8);
|
|
|
|
if (x + 1 < width) buffer[index + 1] += error;
|
|
if (x + 2 < width) buffer[index + 2] += error;
|
|
if (y + 1 < height && x - 1 >= 0) buffer[index + width - 1] += error;
|
|
if (y + 1 < height) buffer[index + width] += error;
|
|
if (y + 1 < height && x + 1 < width) buffer[index + width + 1] += error;
|
|
if (y + 2 < height) buffer[index + width * 2] += error;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const BAYER_MATRICES = {
|
|
2: [
|
|
[0, 2],
|
|
[3, 1],
|
|
],
|
|
4: [
|
|
[0, 8, 2, 10],
|
|
[12, 4, 14, 6],
|
|
[3, 11, 1, 9],
|
|
[15, 7, 13, 5],
|
|
],
|
|
8: [
|
|
[0, 32, 8, 40, 2, 34, 10, 42],
|
|
[48, 16, 56, 24, 50, 18, 58, 26],
|
|
[12, 44, 4, 36, 14, 46, 6, 38],
|
|
[60, 28, 52, 20, 62, 30, 54, 22],
|
|
[3, 35, 11, 43, 1, 33, 9, 41],
|
|
[51, 19, 59, 27, 49, 17, 57, 25],
|
|
[15, 47, 7, 39, 13, 45, 5, 37],
|
|
[63, 31, 55, 23, 61, 29, 53, 21],
|
|
],
|
|
};
|
|
|
|
/** Bayer ordered dithering. patternSize selects the matrix: 2, 4, or 8 */
|
|
export const ditherBayer = (
|
|
grayscale: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
levels = 2,
|
|
patternSize = 8,
|
|
): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
|
|
const matrixSize = patternSize <= 2 ? 2 : patternSize <= 4 ? 4 : 8;
|
|
const matrix = BAYER_MATRICES[matrixSize];
|
|
const matrixLength = matrix.length;
|
|
|
|
const normalizedMatrix = matrix.map((row) =>
|
|
row.map((val) => Math.floor((val * 255) / (matrixLength * matrixLength))),
|
|
);
|
|
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const index = y * width + x;
|
|
const gray = grayscale[index];
|
|
const threshold = normalizedMatrix[y % matrixLength][x % matrixLength];
|
|
const adjustedValue = gray + (threshold - 128);
|
|
result[index] = quantizeValue(adjustedValue, levels);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/** Random noise dithering */
|
|
export const ditherRandom = (grayscale: Uint8Array, levels = 2): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
|
|
for (let i = 0; i < grayscale.length; i++) {
|
|
const adjustedValue = grayscale[i] + (Math.random() * 255 - 128);
|
|
result[i] = quantizeValue(adjustedValue, levels);
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/** Detect edge pixels by checking if a pixel or any 4-directional neighbor is near pure black or white.
|
|
* Returns a Uint8Array where 1 = edge pixel, 0 = non-edge. Border pixels are always 0. */
|
|
export const detectEdges = (
|
|
grayscale: Uint8Array,
|
|
width: number,
|
|
height: number,
|
|
fuzziness = 20,
|
|
): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
const limit = 255 - fuzziness;
|
|
|
|
for (let y = 1; y < height - 1; y++) {
|
|
for (let x = 1; x < width - 1; x++) {
|
|
const idx = y * width + x;
|
|
const isExtreme = (v: number) => v < fuzziness || v > limit;
|
|
const hasExtreme =
|
|
isExtreme(grayscale[idx]) ||
|
|
isExtreme(grayscale[idx - 1]) ||
|
|
isExtreme(grayscale[idx + 1]) ||
|
|
isExtreme(grayscale[idx - width]) ||
|
|
isExtreme(grayscale[idx + width]);
|
|
result[idx] = hasExtreme ? 1 : 0;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
/** For edge pixels, snap to the nearest quantized level instead of using the dithered value.
|
|
* Non-edge pixels pass through unchanged from the dithered input. */
|
|
export const applyEdgeSnap = (
|
|
grayscale: Uint8Array,
|
|
dithered: Uint8Array,
|
|
edges: Uint8Array,
|
|
levels = 2,
|
|
): Uint8Array => {
|
|
const result = new Uint8Array(grayscale.length);
|
|
for (let i = 0; i < grayscale.length; i++) {
|
|
result[i] = edges[i] ? quantizeValue(grayscale[i], levels) : dithered[i];
|
|
}
|
|
return result;
|
|
};
|
|
|
|
export enum DitheringMethod {
|
|
THRESHOLD = "threshold",
|
|
FLOYD_STEINBERG = "floyd-steinberg",
|
|
ATKINSON = "atkinson",
|
|
BAYER = "bayer",
|
|
RANDOM = "random",
|
|
NONE = "none",
|
|
}
|
|
|
|
export interface DitheringOptions {
|
|
width?: number;
|
|
height?: number;
|
|
levels?: number;
|
|
threshold?: number;
|
|
applyEdgeSnap?: boolean;
|
|
edgeDetectionFuzziness?: number;
|
|
bayerPatternSize?: 2 | 4 | 8;
|
|
}
|
|
|
|
export function applyDithering(
|
|
method: DitheringMethod,
|
|
grayscale: Uint8Array,
|
|
options: DitheringOptions = {},
|
|
): Uint8Array {
|
|
const {
|
|
width,
|
|
height,
|
|
levels,
|
|
threshold,
|
|
applyEdgeSnap: edgeSnap = false,
|
|
edgeDetectionFuzziness,
|
|
bayerPatternSize,
|
|
} = options;
|
|
|
|
const needsDimensions =
|
|
method === DitheringMethod.FLOYD_STEINBERG ||
|
|
method === DitheringMethod.ATKINSON ||
|
|
method === DitheringMethod.BAYER ||
|
|
edgeSnap;
|
|
|
|
if (needsDimensions && (width === undefined || height === undefined)) {
|
|
throw new Error(`width and height are required for ${method} dithering`);
|
|
}
|
|
if (bayerPatternSize !== undefined && ![2, 4, 8].includes(bayerPatternSize)) {
|
|
throw new Error("bayerPatternSize must be 2, 4, or 8");
|
|
}
|
|
|
|
const requireDimensions = (): [number, number] => {
|
|
if (width === undefined || height === undefined) {
|
|
throw new Error(`width and height are required for ${method} dithering`);
|
|
}
|
|
|
|
return [width, height];
|
|
};
|
|
|
|
let result: Uint8Array;
|
|
switch (method) {
|
|
case DitheringMethod.THRESHOLD:
|
|
result = ditherThreshold(grayscale, threshold);
|
|
break;
|
|
case DitheringMethod.FLOYD_STEINBERG: {
|
|
const [resolvedWidth, resolvedHeight] = requireDimensions();
|
|
result = ditherFloydSteinberg(
|
|
grayscale,
|
|
resolvedWidth,
|
|
resolvedHeight,
|
|
levels,
|
|
);
|
|
break;
|
|
}
|
|
case DitheringMethod.ATKINSON: {
|
|
const [resolvedWidth, resolvedHeight] = requireDimensions();
|
|
result = ditherAtkinson(grayscale, resolvedWidth, resolvedHeight, levels);
|
|
break;
|
|
}
|
|
case DitheringMethod.BAYER: {
|
|
const [resolvedWidth, resolvedHeight] = requireDimensions();
|
|
result = ditherBayer(
|
|
grayscale,
|
|
resolvedWidth,
|
|
resolvedHeight,
|
|
levels,
|
|
bayerPatternSize,
|
|
);
|
|
break;
|
|
}
|
|
case DitheringMethod.RANDOM:
|
|
result = ditherRandom(grayscale, levels);
|
|
break;
|
|
case DitheringMethod.NONE:
|
|
result = quantize(grayscale, levels);
|
|
break;
|
|
}
|
|
|
|
if (edgeSnap) {
|
|
const [resolvedWidth, resolvedHeight] = requireDimensions();
|
|
const edges = detectEdges(
|
|
grayscale,
|
|
resolvedWidth,
|
|
resolvedHeight,
|
|
edgeDetectionFuzziness,
|
|
);
|
|
return applyEdgeSnap(grayscale, result, edges, levels);
|
|
}
|
|
|
|
return result;
|
|
}
|