UNPKG

@oazmi/kitchensink

Version:

a collection of personal utility functions

403 lines (402 loc) 20.9 kB
/** utility functions for handling images along with canvas tools. * * TODO: add test code within documentation. however, I'm uncertain how I would go about testing canvas/offscreencanvas features within Deno. * * @module */ import { console_assert, console_error, number_isInteger, promise_resolve } from "./alias.js"; import { DEBUG } from "./deps.js"; import { isString, positiveRect } from "./struct.js"; import { concatTyped, sliceSkipTypedSubarray } from "./typedbuffer.js"; let bg_canvas, bg_ctx; /** get the global background `OffscreenCanvas`. * * if the offscreen-canvas has not been initialized prior, it will be initialized with your provided optional `init_width` and `init_height` dimensions. * however, if it _has_ been already initialized, then it **will not** resize to your provided size. * thus, if you want a specific size, you should **always** set it manually yourself. * * the reason why we don't resize here, is because resizing clears the canvas, and thus, you will lose your image if you were planning to read it back. */ export const getBgCanvas = (init_width, init_height) => { bg_canvas ??= new OffscreenCanvas(init_width ?? 10, init_height ?? 10); return bg_canvas; }; /** get the global background `OffscreenCanvas`'s 2d-rendering context, and make it specialize in frequent reading (i.e. `willReadFrequently` is set to `true`). * * if the offscreen-canvas has not been initialized prior, it will be initialized with your provided optional `init_width` and `init_height` dimensions. * however, if it _has_ been already initialized, then it **will not** resize to your provided size. * thus, if you want a specific size, you should **always** set it manually yourself. * * the reason why we don't resize here, is because resizing clears the canvas, and thus, you will lose your image if you were planning to read it back. */ export const getBgCtx = (init_width, init_height) => { if (bg_ctx === undefined) { bg_ctx = getBgCanvas(init_width, init_height).getContext("2d", { willReadFrequently: true }); bg_ctx.imageSmoothingEnabled = false; } return bg_ctx; }; /** check of the provided string is a base64 string, by simply observing if it starts with `"data:image/"`. */ export const isBase64Image = (str) => { return str?.startsWith("data:image/") ?? false; }; /** get the header of a base64 image. * * @example * ```ts * import { assertEquals } from "jsr:@std/assert" * * const * img_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAD...", * img_header = getBase64ImageHeader(img_uri) * * assertEquals(img_header, "data:image/png;base64,") * ``` */ export const getBase64ImageHeader = (str) => str.slice(0, str.indexOf(";base64,") + 8); /** get the mime type of a base64 image. * * @example * ```ts * import { assertEquals } from "jsr:@std/assert" * * const * img_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAD...", * img_mime = getBase64ImageMIMEType(img_uri) * * assertEquals(img_mime, "image/png") * ``` */ export const getBase64ImageMIMEType = (str) => str.slice(5, str.indexOf(";base64,")); /** get the body data portion of a base64 image. * * @example * ```ts * import { assertEquals } from "jsr:@std/assert" * * const * img_uri = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAD...", * img_body = getBase64ImageBody(img_uri) * * assertEquals(img_body, "iVBORw0KGgoAAAANSUhEUgAAD...") * ``` */ export const getBase64ImageBody = (str) => str.substring(str.indexOf(";base64,") + 8); /** load an image as a `Blob`, with the chosen optional `type` encoding (default is "image/png"). * * possible image sources are: * - data uri for base64 encoded image `string` * - http url path `string` * - file url path `string` * - buffer of RGBA pixel byte data as `Uint8Array`, `Uint8ClampedArray`, `ArrayBuffer`, or `Array<number>` * - `ImageBitmapSource`, which includes: * - `Blob` * - `ImageData` * - `HTMLCanvasElement` or `OffscreenCanvas` * - `HTMLImageElement` or `SVGImageElement` or `HTMLVideoElement` * - `ImageBitmap` * * @param img_src anything image representation that can be constructed into an `ImageBitmap` using {@link constructImageBitmapSource} * @param width when using `Uint8Array`, `Uint8ClampedArray`, `ArrayBuffer`, or `Array<number>` as `img_src`, you must necessarily provide the width of the image * @param crop_rect specify a cropping rectangle * @param bitmap_options these are {@link ImageBitmapOptions} that can be used for optionally cropping the `img_src`, changing its colorSpace, etc... * @param blob_options specify `type`: {@link ImageMIMEType} and `quality`: number to encode your output Blob into * * > [!note] * > note that when using `Uint8Array`, `Uint8ClampedArray`, `ArrayBuffer`, or `Array<number>`, you should provide a `width` as the second argument to this function. * > you can also provide an optional image element as the third argument to load the given `img_src` onto, otherwise a new one will be made. */ export const constructImageBlob = async (img_src, width, crop_rect, bitmap_options, blob_options) => { if (crop_rect) { crop_rect = positiveRect(crop_rect); } const bitmap_src = await constructImageBitmapSource(img_src, width), bitmap = crop_rect ? await createImageBitmap(bitmap_src, crop_rect.x, crop_rect.y, crop_rect.width, crop_rect.height, bitmap_options) : await createImageBitmap(bitmap_src, bitmap_options), canvas = getBgCanvas(), ctx = getBgCtx(); canvas.width = bitmap.width; canvas.height = bitmap.height; ctx.globalCompositeOperation = "copy"; ctx.resetTransform(); ctx.drawImage(bitmap, 0, 0); return canvas.convertToBlob(blob_options); }; /** extract the {@link ImageData} from an image source (of type {@link CanvasImageSource}), with optional cropping. * * due to the fact that this function utilizes a `Canvas`/`OffscreenCanvas`, * it is important to note that the output `ImageData` is sometimes lossy in nature. * this is because gpu-accelerated web-browsers _approximate_ the colors (i.e. you don't truly get `256^3` unique colors), * and also due to rounding errors from/to internal float-valued colors and output integer-valued colors. * * but generally speaking, the `ImageData` can be lossless if all of the following are satisfied: * - disable gpu-acceleration of your web-browser, through the `flags` page (`"chrome://flags"` or `"about:config"`). * - your `img_src` has either no alpha-channel, or 100% visible alpha-channel throughout (i.e. non-transparent image). * - you have pre-multiplied alpha disabled (TODO: this part can be achieved/implemented by this library, but I haven't looked into it yet.). * check out this [script](https://github.com/backspaces/agentscript/blob/master/src/RGBADataSet.js#L27) * for using webgl for reading and writing bitmap pixels without losing color accuracy due to alpha premultiplication * * @param img_src an image source can be an `HTMLImageElement`, `HTMLCanvasElement`, `ImageBitmap`, etc.. * @param crop_rect dimension of the cropping rectangle. leave as `undefined` if you wish not to crop, or only provide a partial {@link Rect} */ export const constructImageData = async (img_src, width, crop_rect, bitmap_options, image_data_options) => { if (crop_rect) { crop_rect = positiveRect(crop_rect); } const bitmap_src = await constructImageBitmapSource(img_src, width), bitmap = crop_rect ? await createImageBitmap(bitmap_src, crop_rect.x, crop_rect.y, crop_rect.width, crop_rect.height, bitmap_options) : await createImageBitmap(bitmap_src, bitmap_options), canvas = getBgCanvas(), ctx = getBgCtx(); canvas.width = bitmap.width; canvas.height = bitmap.height; ctx.globalCompositeOperation = "copy"; ctx.resetTransform(); ctx.drawImage(bitmap, 0, 0); return ctx.getImageData(0, 0, bitmap.width, bitmap.height, image_data_options); }; /** conveniently construct an `ImageBitmapSource` from different image source types. * * if you use a raw buffer (`number[] | Uint8Array | Uint8ClampedArray | ArrayBuffer`) as the image source, * then you are expected to provide the `width` of the image. */ export const constructImageBitmapSource = async (img_src, width) => { if (isString(img_src)) { const new_img_element = new Image(); new_img_element.src = img_src; return new_img_element .decode() .then(() => new_img_element); } else if (img_src instanceof Uint8ClampedArray) { return promise_resolve(new ImageData(img_src, width)); } else if (ArrayBuffer.isView(img_src)) { return constructImageBitmapSource(new Uint8ClampedArray(img_src.buffer, img_src.byteOffset, img_src.byteLength), width); } else if (img_src instanceof ArrayBuffer) { return constructImageBitmapSource(new Uint8ClampedArray(img_src), width); } else if (img_src instanceof Array) { return constructImageBitmapSource(Uint8ClampedArray.from(img_src), width); } return img_src; }; /** get a grayscale intensity bitmap of multi-channel `pixel_buf` image buffer, * using an optional `alpha_channel` number to mask off the resulting intensity wherever the masking channel is zero * (or less than the optional `alpha_bias` parameter). * * @param pixels_buf flattened pixel bytes * @param channels number of color channels (ie: bytes per pixel). for instance, you'd use `4` for RGBA, `3` for RGB, `1` for L, `2` for AL, etc... * @param alpha_channel specify which channel is the alpha channel, or leave it as `undefined` to dictate lack of thereof. for instance, you'd use `3` for RGB**A**, `0` for **A**L, and `undefined` for RGB * @param alpha_bias if alpha is present, you can specify the minimum alpha value required for the pixel to be visible. anything less will make the pixel dull */ export const intensityBitmap = (pixels_buf, channels, alpha_channel, alpha_bias = 1) => { const pixel_len = pixels_buf.length / channels, alpha_visibility = new Uint8ClampedArray(pixel_len).fill(1), intensity = new Uint8ClampedArray(pixel_len); if (alpha_channel !== undefined) { for (let i = 0; i < pixel_len; i++) { alpha_visibility[i] = pixels_buf[i * channels + alpha_channel] < alpha_bias ? 0 : 1; } pixels_buf = pixels_buf.filter((v, i) => (i % channels) === alpha_channel ? false : true); // remove alpha channel bytes from `pixel_buf` and redefine it channels--; // because alpha channel has been discarded } // channel by channel, sum each channel's value to intensity for (let ch = 0; ch < channels; ch++) { for (let i = 0; i < pixel_len; i++) { intensity[i] += pixels_buf[i * channels + ch]; } } // finally, if necessary, multiply each `intensity` pixel by its `alpha_visibility` if (alpha_channel !== undefined) { for (let i = 0; i < pixel_len; i++) { intensity[i] *= alpha_visibility[i]; } } return new Uint8Array(intensity.buffer); }; /** get the bounding box {@link Rect} of an image, based on an accumulative `padding_condition` function that should return * `0.0` for padding/whitespace/empty pixels, and positive numbers (usually `1.0`) for non-padding/important pixels. * * - if the summation of `padding_condition` applied onto a particular row, or column of pixels is less than `minimum_non_padding_value`, * then that entire row/column will be counted as an empty padding space. * - else, if the summation of `padding_condition` is greater than `minimum_non_padding_value`, * then that specific row/column will be counted as one of the bounding box's sides. * - take a look at {@link trimImagePadding} to get an understanding of a potential use case. * * you do not need to specify the number of channels in your `img_data`, * because it will be calculated automatically via `img_data.width`, `img_data.height`, and `img_data.data.length`. * * ### A note on performance * * almost all performance depends purely on the complexity of your `padding_condition`. * - if the equations in `padding_condition` uses square-roots, exponents, and if-conditions, then expect a major performance drop. * - if your equations consist only of only numeric operations `+, -, *, /`, then the performance will be much faster. * * some unsuccessful benchmarks I've tried to boost the performance (in V8): * - defining `rowAt`, `colAt`, and `nonPaddingValue` outside of this function, instead of inlining them, made no difference in the performance. * - substituting `padding_condition` in `nonPaddingValue` with the actual arithmetic functions via inlining * (and thereby avoiding repeated function calls) makes no difference, thanks to the JIT doing the inlining on its own in V8. * - finally, the `colAt` inline function is surprisingly super fast (almost as fast as `rowAt`). * so, bounding top and bottom is not at all noticeably quicker than bounding left and right. */ export const getBoundingBox = (img_data, padding_condition, minimum_non_padding_value = 1) => { const { width, height, data } = img_data, channels = data.length / (width * height), rowAt = (y) => data.subarray((y * width) * channels, (y * width + width) * channels), colAt = (x) => { const col = new Uint8Array(height * channels); for (let y = 0; y < height; y++) { for (let ch = 0; ch < channels; ch++) { col[y * channels + ch] = data[(y * width + x) * channels + ch]; } } return col; }, nonPaddingValue = (data_row_or_col) => { let non_padding_value = 0; for (let px = 0, len = data_row_or_col.length; px < len; px += channels) { non_padding_value += padding_condition(data_row_or_col[px + 0], data_row_or_col[px + 1], data_row_or_col[px + 2], data_row_or_col[px + 3]); } return non_padding_value; }; if (DEBUG.ASSERT) { console_assert(number_isInteger(channels)); } let [top, left, bottom, right] = [0, 0, height, width]; // find top bounding row for (; top < height; top++) { if (nonPaddingValue(rowAt(top)) >= minimum_non_padding_value) { break; } } // find bottom bounding row for (; bottom >= top; bottom--) { if (nonPaddingValue(rowAt(bottom)) >= minimum_non_padding_value) { break; } } // find left bounding column for (; left < width; left++) { if (nonPaddingValue(colAt(left)) >= minimum_non_padding_value) { break; } } // find right bounding column for (; right >= left; right--) { if (nonPaddingValue(colAt(right)) >= minimum_non_padding_value) { break; } } return { x: left, y: top, width: right - left, height: bottom - top, }; }; /** crop an {@link ImageData} or arbitrary channel {@link SimpleImageData} with the provided `crop_rect`. * * the original `img_data` is not mutated, and the returned cropped image data contains data that has been copied over. */ export const cropImageData = (img_data, crop_rect) => { const { width, height, data } = img_data, channels = data.length / (width * height), crop = positiveRect({ x: 0, y: 0, width, height, ...crop_rect }), [top, left, bottom, right] = [crop.y, crop.x, crop.y + crop.height, crop.x + crop.width]; if (DEBUG.ASSERT) { console_assert(number_isInteger(channels)); } // trim padding from top, left, bottom, and right const row_slice_len = crop.width * channels, skip_len = ((width - right) + (left - 0)) * channels, trim_start = (top * width + left) * channels, trim_end = ((bottom - 1) * width + right) * channels, cropped_data_rows = sliceSkipTypedSubarray(data, row_slice_len, skip_len, trim_start, trim_end), cropped_data = concatTyped(...cropped_data_rows), cropped_img_data = channels === 4 ? new ImageData(cropped_data, crop.width, crop.height) : { width: crop.width, height: crop.height, data: cropped_data, colorSpace: img_data.colorSpace ?? "srgb" }; return cropped_img_data; }; /** trim the padding of an image based on sum of pixel conditioning of each border's rows and columns. * * > [!note] * > the output will always consist of at least 1-pixel width or 1-pixel height. * * @example * for example, to trim the whitespace border pixels of an "RGBA" image, irrespective of the alpha, * and a minimum requirement of at least three non-near-white pixels in each border row and column, * you would design your `padding_condition` as such: * * ```ts * import { assertEquals } from "jsr:@std/assert" * * // we want the distance between a pixel's rgb color and the white color (i.e. `(255, 255, 255,)`) * // to be less than `10 * Math.sqrt(3)`, in order for it to be considered near-white. * const my_white_padding_condition = (r: number, g: number, b: number, a: number) => { * const distance_from_white = (3 * 255**2) - (r**2 + g**2 + b**2) * return distance_from_white < (3 * 5**2) * ? 0.0 * : 1.0 * } * * const * width = 60, * img_data = new ImageData(new Uint8ClampedArray(4 * width * 30).fill(255), width), // fully white rgba image * trimmed_img_data = trimImagePadding(img_data, my_white_padding_condition, 3.0) // only a 1x1 white pixel remains * * assertEquals(trimmed_img_data.width, 1) * assertEquals(trimmed_img_data.height, 1) * assertEquals(trimmed_img_data.data, new Uint8ClampedArray(4).fill(255)) * ``` */ export const trimImagePadding = (img_data, padding_condition, minimum_non_padding_value = 1) => (cropImageData(img_data, getBoundingBox(img_data, padding_condition, minimum_non_padding_value))); /** get a function that maps index-coordinates of image0-coordinate-space to the index-coordinates of image1-coordinate-space. * * note that if you're mapping lots of indexes using `Array.map`, it would be nearly 40-times faster to use the {@link lambdacalc.vectorize1} function instead. * * @param `coords0` object defining the first ImageCoordSpace * @param `coords1` object defining the second ImageCoordSpace * @returns `(i0: number & coord0) => i1 as number & coord1` a function that takes in an integer index from coordinate space coords0 and converts it so that it's relative to coordinate space coords1 * * @example * ```ts * // suppose you have an RGBA image data buffer of `width = 100`, `height = 50`, `channels = 4`, * // and you have an array of 6 pixel indexes: `idx0 = [1040, 1044, 1048, 1140, 1144, 1148]`, * // and now, you want to convert these indexes to that of an "LA" image data buffer of: * // `width = 10`, `height = 10`, and `channels = 2`, `x = 5`, `y = 10` * // then: * const * coords0 = {x: 0, y: 0, width: 100, height: 50, channels: 4 as const}, * coords1 = {x: 5, y: 10, width: 10, height: 10, channels: 2 as const}, * coords0_to_coords1 = coordinateTransformer(coords0, coords1), * idx0 = [4040, 4044, 4048, 4440, 4444, 4448], * idx1 = idx0.map(coords0_to_coords1) // [10, 12, 14, 30, 32, 34] * ``` * * ### Derivation * * the equation for `mask_intervals` can be easily derived as follows: * - `p0 = px of data`, `y0 = y-coords of pixel in data`, `x0 = x-coords of pixel in data`, `w0 = width of data`, `c0 = channels of data` * - `p1 = px of mask`, `y1 = y-coords of pixel in mask`, `x1 = x-coords of pixel in mask`, `w1 = width of mask`, `c1 = channels of mask` * - `y = y-coords of mask's rect`, `x = x-coords of mask's rect` * * ```ts ignore * declare let [w0, w1, c0, c1]: number[] * let * p0 = (x0 + y0 * w0) * c0, * x0 = (p0 / c0) % w0, * y0 = trunc(p0 / (c0 * w0)), * p1 = (x1 + y1 * w1) * c1, * x1 = (p1 / c1) % w1, * y1 = trunc(p1 / (c1 * w1)), * x = x0 - x1, * y = y0 - y1 * * // so, now: * p1 = c1 * (x1 + y1 * w1) * p1 = c1 * ((x0 - x) + (y0 - y) * w1) * p1 = c1 * ((((p0 / c0) % w0) - x) + (((p0 / c0) / w0 | 0) - y) * w1) * ``` */ export const coordinateTransformer = (coords0, coords1) => { const { x: x0, y: y0, width: w0, channels: c0 } = coords0, { x: x1, y: y1, width: w1, channels: c1 } = coords1, x = (x1 ?? 0) - (x0 ?? 0), y = (y1 ?? 0) - (y0 ?? 0); return (i0) => c1 * ((((i0 / c0) % w0) - x) + (((i0 / c0) / w0 | 0) - y) * w1); }; /** TODO: implement the darn thing. * * EDIT: I don't even recall what this function was meant to do. * was it made to generate random rgba pixel color that can be assigned to the 2d-rendering context? * moreover, did I not want to permutate over a set number of colors instead of generating any random 256**3 color (i.e. less distinguishable)? */ export const randomRGBA = (alpha) => { console_error(DEBUG.ERROR && "not implemented"); };