tldr; Get image data from canvas, for each pixel intensity do;
threshold = scaleThisBitchTo0-255Range([row % n][col % n]);
if intensity <= threshold then make black, otherwise make white
Now do that whenever shit changes (fuckton of event listeners)
import ODW, { ALL } from "./ODW.js";
const defaultImage = "./static/ath.png"; // Path should be relative to index.
// Type cast to remove possible null warnings.
const canvas = document.getElementById("canvas") as HTMLCanvasElement;
const ctx = canvas.getContext("2d");
const MBuilder = document.getElementById("MBuilder") as HTMLDivElement;
const MSize = document.getElementById("MSize") as HTMLInputElement;
const MSizeLabel = document.getElementById("MSizeVal") as HTMLElement;
const gammaSlider = document.getElementById("gamma") as HTMLInputElement;
const gammaValue = document.getElementById("gammaValue");
const fileInput = document.getElementById("fileInput") as HTMLInputElement;
let image: ODW;
// Note: (its a feature not a mistake I swear) - modifying currentM is modifying these by reference.
// So it "saves" the M you created at each size.
const ThresholdMaps: { [size: number]: number[][] } = {
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]
]
};
let currentM = ThresholdMaps[4]; // Use 4x4 as default.
function createGammaLUT(gamma: number): Uint8Array {
const gammaCorrection = 1 / gamma;
return Uint8Array.from({ length: 256 }, (_, i) => Math.round(Math.pow(i / 255, gammaCorrection) * 255));
}
function gammaTransform(og: ODW, gamma: number): void {
const lut = createGammaLUT(gamma);
const data = og.im.data;
for (let i = 0; i < data.length; i += 4) {
data[i] = lut[data[i]]; // Red
data[i + 1] = lut[data[i + 1]]; // Green
data[i + 2] = lut[data[i + 2]]; // Blue
// Alpha remains unchanged.
}
}
function dither(og: ODW, M: number[][]): void {
const n = M.length;
og.transform([ALL, ALL], (oldRGBA, row, col) => {
const oldIntensity = oldRGBA[0];
const thresh = M[row % n][col % n] * (255 / ((n * n) - 1));
const newIntensity = oldIntensity <= thresh ? 0 : 255;
return [newIntensity, newIntensity, newIntensity, 255];
});
}
function processImage(): void {
const processed = ODW.clone(image);
canvas.width = image.width;
canvas.height = image.height;
gammaTransform(processed, Number(gammaValue?.textContent));
dither(processed, currentM);
ctx?.putImageData(processed.im, 0, 0);
}
async function loadImage(url: string = defaultImage): Promise {
try {
image = await ODW.fromImageUrl(url);
processImage();
} catch (error) {
console.error("Error processing new image:", error);
}
}
function handleTickerUpdate(i: number, j: number, val: string | number): void {
currentM[i][j] = Number(val);
processImage();
}
function createTicker(row: number, col: number, n: number): HTMLInputElement {
const ticker = document.createElement("input");
ticker.type = "number";
ticker.max = String(Math.pow(n, 2) - 1);
ticker.min = "0";
ticker.value = String(currentM[row][col]);
updateTickerStyle(ticker, currentM[row][col], n);
ticker.addEventListener("change", (event) => {
const input = event.currentTarget as HTMLInputElement;
const value = input.value;
updateTickerStyle(input, value, n);
handleTickerUpdate(row, col, value);
});
ticker.addEventListener("wheel", (event) => {
event.preventDefault();
const input = event.currentTarget as HTMLInputElement;
const value = adjustValueWithWheel(input, event.deltaY);
input.value = String(value);
updateTickerStyle(input, value, n);
handleTickerUpdate(row, col, value);
});
return ticker;
}
function updateTickerStyle(ticker: HTMLInputElement, value: string | number, n: number): void {
ticker.style.setProperty("--val", calculatePercentage(value, n));
ticker.style.setProperty("color", Number(value) > (Number(ticker.max) / 2) ? "black" : "white");
}
function calculatePercentage(value: string | number, n: number): string {
return `${(100 * Number(value)) / ((n * n) - 1)}%`;
}
function adjustValueWithWheel(input: HTMLInputElement, deltaY: number): number {
const step = Number(input.step) || 1;
const min = input.min !== "" ? Number(input.min) : -Infinity;
const max = input.max !== "" ? Number(input.max) : Infinity;
let value = Number(input.value) || 0;
value += deltaY < 0 ? step : -step;
return Math.max(min, Math.min(max, value));
}
function buildMatrixUI(): void {
const n = currentM.length;
MBuilder.innerHTML = "";
MBuilder.style.setProperty("--size", String(n));
for (let row = 0; row < n; row++) {
for (let col = 0; col < n; col++) {
const ticker = createTicker(row, col, n);
MBuilder.appendChild(ticker);
}
}
}
MSize.addEventListener("change", () => {
MSizeLabel.textContent = MSize.value;
currentM = ThresholdMaps[1 << Number(MSize.value)];
buildMatrixUI();
processImage();
});
gammaSlider.addEventListener("input", () => {
gammaValue!.textContent = gammaSlider.value;
processImage();
});
fileInput.addEventListener("change", async (event) => {
const file = (event.target as HTMLInputElement).files![0];
await loadImage(URL.createObjectURL(file));
});
canvas.addEventListener("dragover", (event) => {
event.preventDefault();
});
canvas.addEventListener("drop", async (event) => {
event.preventDefault();
const file = event.dataTransfer!.files[0];
await loadImage(URL.createObjectURL(file));
});
canvas.addEventListener("click", () => {
fileInput.click();
});
// Initialize with default settings.
loadImage();
buildMatrixUI();
Where ODW is a custom wrapper class I made to simplify canvas-based image processing. It literally stands
for "Omni's dumb wrapper."
Considering I over-engineered it (but still coded it like shit), I am now obligated to make more demos
like this so I can utilize all the features I needlessly coded into this class.
import { RGBA } from "./types";
export const ALL = '*';
export async function loadImage(src: string): Promise {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = src;
});
}
type rq = [number, number] | ['*', number] | [number, '*'];
type ForEachCB = (rgba: RGBA, row: number, col: number) => void;
/**
* ODW: OMNI'S DUMB WRAPPER.
*
* A wrapper for automating the canvas IP pipeline. Comes with abstraction functions for working in a more intuitive RGBA[][] configuration.
* Of course, this abstraction causes some overhead so for anything complex/slow you should work with the buffer directly.
*/
export default class ODW {
readonly im: ImageData;
readonly width: number;
readonly height: number;
constructor(im: ImageData) {
this.im = im;
this.width = im.width;
this.height = im.height;
}
static clone(existing: ODW): ODW {
// Directly copy the data array for performance
const cloneData = new ImageData(
new Uint8ClampedArray(existing.im.data),
existing.width,
existing.height
);
return new ODW(cloneData);
}
static fromImage(image: HTMLImageElement): ODW {
const osCanvas = new OffscreenCanvas(image.width, image.height);
const osCTX = osCanvas.getContext('2d')!;
osCTX.drawImage(image, 0, 0, image.width, image.height);
return new ODW(osCTX.getImageData(0, 0, image.width, image.height));
}
static async fromImageUrl(src: string): Promise {
const image = await loadImage(src);
return ODW.fromImage(image);
}
// ================================
// Getters
// ================================
getPixel(row: number, col: number): RGBA {
// Inline index calculation
const idx = (row * this.width + col) << 2; // (row * width + col) * 4
const data = this.im.data;
// Return a typed subarray as RGBA
return [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
] as RGBA;
}
getRow(row: number): RGBA[] {
const { data } = this.im;
const w = this.width;
const rowStart = row * w * 4;
const out: RGBA[] = new Array(w);
for (let col = 0; col < w; col++) {
const idx = rowStart + (col << 2); // rowStart + col * 4
out[col] = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
}
return out;
}
getColumn(col: number): RGBA[] {
const { data } = this.im;
const { width: w, height: h } = this;
const out: RGBA[] = new Array(h);
for (let row = 0; row < h; row++) {
const idx = (row * w + col) << 2;
out[row] = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
}
return out;
}
g([row, col]: rq) {
if (typeof row === 'number' && typeof col === 'number') {
return this.getPixel(row, col);
} else if (typeof row === 'number' && col === '*') {
return this.getRow(row);
} else if (typeof col === 'number' && row === '*') {
return this.getColumn(col);
} else {
throw new Error("Malformed get statement.");
}
}
// ================================
// Setters
// ================================
setPixel(row: number, col: number, rgba: RGBA): void {
// Inline checks + inlining clamp
if (row < 0 || row >= this.height) {
throw new RangeError(`Row index ${row} is out of bounds (height: ${this.height}).`);
}
if (col < 0 || col >= this.width) {
throw new RangeError(`Column index ${col} is out of bounds (width: ${this.width}).`);
}
const idx = (row * this.width + col) << 2;
const data = this.im.data;
data[idx] = clamp(rgba[0]);
data[idx + 1] = clamp(rgba[1]);
data[idx + 2] = clamp(rgba[2]);
data[idx + 3] = clamp(rgba[3] ?? 255);
}
setRow(row: number, fill: RGBA[]): void {
if (row < 0 || row >= this.height) {
throw new RangeError(`Row index ${row} is out of bounds (height: ${this.height}).`);
}
const { data } = this.im;
const w = this.width;
// Flatten to avoid calling setPixel repeatedly
let offset = row * w * 4;
for (let i = 0; i < Math.min(fill.length, w); i++) {
const rgba = fill[i];
data[offset++] = clamp(rgba[0]);
data[offset++] = clamp(rgba[1]);
data[offset++] = clamp(rgba[2]);
data[offset++] = clamp(rgba[3] ?? 255);
}
}
setCol(col: number, fill: RGBA[]): void {
if (col < 0 || col >= this.width) {
throw new RangeError(`Column index ${col} is out of bounds (width: ${this.width}).`);
}
const { data } = this.im;
const { width: w, height: h } = this;
// Flatten to avoid calling setPixel repeatedly
for (let i = 0; i < Math.min(fill.length, h); i++) {
const idx = (i * w + col) << 2;
const rgba = fill[i];
data[idx] = clamp(rgba[0]);
data[idx + 1] = clamp(rgba[1]);
data[idx + 2] = clamp(rgba[2]);
data[idx + 3] = clamp(rgba[3] ?? 255);
}
}
s([row, col]: rq, fill: RGBA | RGBA[]): void {
// Check if single RGBA or array
if (!Array.isArray(fill[0])) {
// Single RGBA
if (typeof row === 'number' && typeof col === 'number') {
this.setPixel(row, col, fill as RGBA);
} else {
throw new Error("Cannot set single pixel to a sequence of RGBA values.");
}
} else {
// RGBA[]
if (typeof row === 'number' && col === '*') {
this.setRow(row, fill as RGBA[]);
} else if (typeof col === 'number' && row === '*') {
this.setCol(col, fill as RGBA[]);
}
}
}
// ================================
// Iteration
// ================================
/* NOTE: IM ABANDONING MY D.R.Y MORALS HERE TO EXTRACT AS MUCH EFFICIENCY OUT OF THIS AS POSSIBLE
TRYING TO REDUCE OVERHEAD OF A BUNCH OF GET/SET/PIXEL CALLS. */
forEach([rowReq, colReq]: rq | ['*', '*'], callback: ForEachCB): void {
const { width: w, height: h, im: { data } } = this;
if (rowReq === '*' && colReq === '*') {
// All pixels
let idx = 0;
for (let r = 0; r < h; r++) {
for (let c = 0; c < w; c++) {
// RGBA from data
const rgba: RGBA = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
callback(rgba, r, c);
idx += 4;
}
}
} else if (rowReq === '*' && typeof colReq === 'number') {
// Single column
const col = colReq;
if (col < 0 || col >= w) {
throw new RangeError(`Column index ${col} is out of bounds (width: ${w}).`);
}
for (let r = 0; r < h; r++) {
const idx = (r * w + col) << 2;
const rgba: RGBA = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
callback(rgba, r, col);
}
} else if (typeof rowReq === 'number' && colReq === '*') {
// Single row
const row = rowReq;
if (row < 0 || row >= h) {
throw new RangeError(`Row index ${row} is out of bounds (height: ${h}).`);
}
let idx = row * w * 4;
for (let c = 0; c < w; c++) {
const rgba: RGBA = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
callback(rgba, row, c);
idx += 4;
}
} else {
throw new Error(
`Unsupported rq pattern for forEach: [${rowReq}, ${colReq}].
Valid patterns: [*, *], [*, number], [number, *].`
);
}
}
/**
* Run callback for every pixel in the image, update pixel to RGBA value from CB.
* @param rq Selector for row, col. Use a number to select a specific row/col or '*' to select all of them.
* @param transform transform callback, passed the following: (oldRGBA: RGBA, row: number, col: number) - The RGBA nple @ row/col.
* @param profile boolean for debugging, enable use of simple performance.now() check for execution time.
*/
public transform(
rq: rq | ['*', '*'],
transform: (oldRGBA: RGBA, row: number, col: number) => RGBA,
profile = false
): void {
const { width: w, height: h, im: { data } } = this;
const [rowReq, colReq] = rq;
const t0 = profile ? performance.now() : undefined;
if (rowReq === '*' && colReq === '*') {
// Entire image
let idx = 0;
for (let r = 0; r < h; r++) {
for (let c = 0; c < w; c++) {
const oldRGBA: RGBA = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
const newRGBA = transform(oldRGBA, r, c);
data[idx] = clamp(newRGBA[0]);
data[idx + 1] = clamp(newRGBA[1]);
data[idx + 2] = clamp(newRGBA[2]);
data[idx + 3] = clamp(newRGBA[3] ?? 255);
idx += 4;
}
}
} else if (rowReq === '*' && typeof colReq === 'number') {
// Single column
const col = colReq;
if (col < 0 || col >= w) {
throw new RangeError(`Column index ${col} out of bounds (width: ${w}).`);
}
for (let r = 0; r < h; r++) {
const idx = (r * w + col) << 2;
const oldRGBA: RGBA = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
const newRGBA = transform(oldRGBA, r, col);
data[idx] = clamp(newRGBA[0]);
data[idx + 1] = clamp(newRGBA[1]);
data[idx + 2] = clamp(newRGBA[2]);
data[idx + 3] = clamp(newRGBA[3] ?? 255);
}
} else if (typeof rowReq === 'number' && colReq === '*') {
// Single row
const row = rowReq;
if (row < 0 || row >= h) {
throw new RangeError(`Row index ${row} out of bounds (height: ${h}).`);
}
let idx = row * w * 4;
for (let c = 0; c < w; c++) {
const oldRGBA: RGBA = [
data[idx],
data[idx + 1],
data[idx + 2],
data[idx + 3]
];
const newRGBA = transform(oldRGBA, row, c);
data[idx] = clamp(newRGBA[0]);
data[idx + 1] = clamp(newRGBA[1]);
data[idx + 2] = clamp(newRGBA[2]);
data[idx + 3] = clamp(newRGBA[3] ?? 255);
idx += 4;
}
} else {
throw new Error(
`Unsupported rq pattern: [${rowReq}, ${colReq}].
Valid patterns: [*, *], [*, number], [number, *].`
);
}
if(profile) {
const t1 = performance.now();
console.log(`transform done in ${t1 - t0!} milliseconds`);
}
}
}
// Inlined clamp function for performance
function clamp(value: number): number {
const v = (value + 0.5) | 0; // round
return v < 0 ? 0 : v > 255 ? 255 : v;
}