@raven-js/cortex
Version:
Zero-dependency machine learning, AI, and data processing library for modern JavaScript
307 lines (259 loc) • 10.5 kB
JavaScript
/**
* @author Anonyfox <max@anonyfox.com>
* @license MIT
* @see {@link https://github.com/Anonyfox/ravenjs}
* @see {@link https://ravenjs.dev}
* @see {@link https://anonyfox.com}
*/
/**
* @file Lanczos resampling for image resizing.
*
* Implements the highest quality resizing algorithm using Lanczos kernel
* (windowed sinc function). Provides superior results for downscaling and
* professional image processing, at the cost of increased computation.
*/
import { clamp, clampCoord, getPixel, lanczosKernel } from "./utils.js";
/**
* Resizes RGBA pixel data using Lanczos resampling.
*
* Uses a windowed sinc function to sample source pixels, providing the highest
* quality results especially for downscaling. The algorithm minimizes aliasing
* and preserves fine details better than other methods.
*
* @param {Uint8Array} pixels - Source RGBA pixel data
* @param {number} srcWidth - Source image width
* @param {number} srcHeight - Source image height
* @param {number} dstWidth - Target image width
* @param {number} dstHeight - Target image height
* @param {number} a - Lanczos parameter (kernel size, typically 3)
* @returns {Uint8Array} Resized RGBA pixel data
*
* @example
* // Professional photo downscaling
* const thumbnail = resizeLanczos(pixels, 4096, 3072, 512, 384);
*
* @example
* // High-quality print preparation
* const print = resizeLanczos(pixels, 1920, 1080, 3840, 2160, 3);
*/
export function resizeLanczos(pixels, srcWidth, srcHeight, dstWidth, dstHeight, a = 3) {
// Allocate output buffer
const result = new Uint8Array(dstWidth * dstHeight * 4);
// Calculate scaling factors
const scaleX = srcWidth / dstWidth;
const scaleY = srcHeight / dstHeight;
// Determine kernel support (how many pixels to sample)
const supportX = Math.max(a, scaleX * a);
const supportY = Math.max(a, scaleY * a);
// Process each destination pixel
let dstOffset = 0;
for (let dstY = 0; dstY < dstHeight; dstY++) {
// Map destination Y to source coordinate
const srcYFloat = (dstY + 0.5) * scaleY - 0.5;
const centerY = Math.floor(srcYFloat);
for (let dstX = 0; dstX < dstWidth; dstX++) {
// Map destination X to source coordinate
const srcXFloat = (dstX + 0.5) * scaleX - 0.5;
const centerX = Math.floor(srcXFloat);
// Sample neighborhood using Lanczos kernel
const rgba = lanczosSample(
pixels,
srcWidth,
srcHeight,
srcXFloat,
srcYFloat,
centerX,
centerY,
supportX,
supportY,
scaleX,
scaleY,
a
);
// Store result with clamping to valid range
result[dstOffset] = clamp(Math.round(rgba[0]), 0, 255); // Red
result[dstOffset + 1] = clamp(Math.round(rgba[1]), 0, 255); // Green
result[dstOffset + 2] = clamp(Math.round(rgba[2]), 0, 255); // Blue
result[dstOffset + 3] = clamp(Math.round(rgba[3]), 0, 255); // Alpha
dstOffset += 4;
}
}
return result;
}
/**
* Samples pixel neighborhood using Lanczos kernel.
*
* @param {Uint8Array} pixels - Source pixel data
* @param {number} width - Source image width
* @param {number} height - Source image height
* @param {number} srcX - Source X coordinate (float)
* @param {number} srcY - Source Y coordinate (float)
* @param {number} centerX - Center X coordinate (integer)
* @param {number} centerY - Center Y coordinate (integer)
* @param {number} supportX - X kernel support radius
* @param {number} supportY - Y kernel support radius
* @param {number} scaleX - X scaling factor
* @param {number} scaleY - Y scaling factor
* @param {number} a - Lanczos parameter
* @returns {Array<number>} Interpolated RGBA values
*/
function lanczosSample(pixels, width, height, srcX, srcY, centerX, centerY, supportX, supportY, scaleX, scaleY, a) {
const rgba = [0, 0, 0, 0]; // RGBA accumulator
let totalWeight = 0;
// Calculate sampling bounds
const minX = Math.floor(centerX - supportX);
const maxX = Math.ceil(centerX + supportX);
const minY = Math.floor(centerY - supportY);
const maxY = Math.ceil(centerY + supportY);
// Sample pixels in kernel support region
for (let y = minY; y <= maxY; y++) {
const dy = y - srcY;
const wyNorm = scaleY > 1 ? dy / scaleY : dy; // Normalize for downscaling
const wy = lanczosKernel(wyNorm, a);
if (Math.abs(wy) < 1e-8) continue; // Skip negligible weights
for (let x = minX; x <= maxX; x++) {
const dx = x - srcX;
const wxNorm = scaleX > 1 ? dx / scaleX : dx; // Normalize for downscaling
const wx = lanczosKernel(wxNorm, a);
if (Math.abs(wx) < 1e-8) continue; // Skip negligible weights
const weight = wx * wy;
totalWeight += weight;
// Get pixel with bounds checking
const pixel = getPixel(pixels, x, y, width, height);
// Accumulate weighted contribution
rgba[0] += pixel[0] * weight; // Red
rgba[1] += pixel[1] * weight; // Green
rgba[2] += pixel[2] * weight; // Blue
rgba[3] += pixel[3] * weight; // Alpha
}
}
// Normalize by total weight to prevent brightness changes
if (totalWeight > 0) {
rgba[0] /= totalWeight;
rgba[1] /= totalWeight;
rgba[2] /= totalWeight;
rgba[3] /= totalWeight;
}
return rgba;
}
/**
* Optimized Lanczos resize using separable filtering.
*
* Performs resize in two passes for better performance and cache efficiency.
* Reduces computational complexity while maintaining quality.
*
* @param {Uint8Array} pixels - Source RGBA pixel data
* @param {number} srcWidth - Source image width
* @param {number} srcHeight - Source image height
* @param {number} dstWidth - Target image width
* @param {number} dstHeight - Target image height
* @param {number} a - Lanczos parameter
* @returns {Uint8Array} Resized RGBA pixel data
*/
export function resizeLanczosSeparable(pixels, srcWidth, srcHeight, dstWidth, dstHeight, a = 3) {
// If only one dimension changes, use single-pass optimization
if (srcWidth === dstWidth) {
return resizeLanczosVertical(pixels, srcWidth, srcHeight, dstHeight, a);
}
if (srcHeight === dstHeight) {
return resizeLanczosHorizontal(pixels, srcWidth, srcHeight, dstWidth, a);
}
// Two-pass resize: horizontal first, then vertical
const intermediate = resizeLanczosHorizontal(pixels, srcWidth, srcHeight, dstWidth, a);
return resizeLanczosVertical(intermediate, dstWidth, srcHeight, dstHeight, a);
}
/**
* Horizontal-only Lanczos resize.
*
* @param {Uint8Array} pixels - Source RGBA pixel data
* @param {number} srcWidth - Source image width
* @param {number} srcHeight - Source image height (unchanged)
* @param {number} dstWidth - Target image width
* @param {number} a - Lanczos parameter
* @returns {Uint8Array} Horizontally resized RGBA pixel data
*/
function resizeLanczosHorizontal(pixels, srcWidth, srcHeight, dstWidth, a) {
const result = new Uint8Array(dstWidth * srcHeight * 4);
const scaleX = srcWidth / dstWidth;
const support = Math.max(a, scaleX * a);
for (let y = 0; y < srcHeight; y++) {
const srcRowOffset = y * srcWidth * 4;
const dstRowOffset = y * dstWidth * 4;
for (let dstX = 0; dstX < dstWidth; dstX++) {
const srcXFloat = (dstX + 0.5) * scaleX - 0.5;
const centerX = Math.floor(srcXFloat);
const rgba = [0, 0, 0, 0];
let totalWeight = 0;
const minX = Math.floor(centerX - support);
const maxX = Math.ceil(centerX + support);
for (let x = minX; x <= maxX; x++) {
const dx = x - srcXFloat;
const dxNorm = scaleX > 1 ? dx / scaleX : dx;
const weight = lanczosKernel(dxNorm, a);
if (Math.abs(weight) < 1e-8) continue;
const clampedX = clampCoord(x, srcWidth);
const offset = srcRowOffset + clampedX * 4;
totalWeight += weight;
rgba[0] += pixels[offset] * weight;
rgba[1] += pixels[offset + 1] * weight;
rgba[2] += pixels[offset + 2] * weight;
rgba[3] += pixels[offset + 3] * weight;
}
const dstOffset = dstRowOffset + dstX * 4;
if (totalWeight > 0) {
result[dstOffset] = clamp(Math.round(rgba[0] / totalWeight), 0, 255);
result[dstOffset + 1] = clamp(Math.round(rgba[1] / totalWeight), 0, 255);
result[dstOffset + 2] = clamp(Math.round(rgba[2] / totalWeight), 0, 255);
result[dstOffset + 3] = clamp(Math.round(rgba[3] / totalWeight), 0, 255);
}
}
}
return result;
}
/**
* Vertical-only Lanczos resize.
*
* @param {Uint8Array} pixels - Source RGBA pixel data
* @param {number} srcWidth - Source image width (unchanged)
* @param {number} srcHeight - Source image height
* @param {number} dstHeight - Target image height
* @param {number} a - Lanczos parameter
* @returns {Uint8Array} Vertically resized RGBA pixel data
*/
function resizeLanczosVertical(pixels, srcWidth, srcHeight, dstHeight, a) {
const result = new Uint8Array(srcWidth * dstHeight * 4);
const scaleY = srcHeight / dstHeight;
const support = Math.max(a, scaleY * a);
for (let dstY = 0; dstY < dstHeight; dstY++) {
const srcYFloat = (dstY + 0.5) * scaleY - 0.5;
const centerY = Math.floor(srcYFloat);
const dstRowOffset = dstY * srcWidth * 4;
for (let x = 0; x < srcWidth; x++) {
const rgba = [0, 0, 0, 0];
let totalWeight = 0;
const minY = Math.floor(centerY - support);
const maxY = Math.ceil(centerY + support);
for (let y = minY; y <= maxY; y++) {
const dy = y - srcYFloat;
const dyNorm = scaleY > 1 ? dy / scaleY : dy;
const weight = lanczosKernel(dyNorm, a);
if (Math.abs(weight) < 1e-8) continue;
const clampedY = clampCoord(y, srcHeight);
const offset = clampedY * srcWidth * 4 + x * 4;
totalWeight += weight;
rgba[0] += pixels[offset] * weight;
rgba[1] += pixels[offset + 1] * weight;
rgba[2] += pixels[offset + 2] * weight;
rgba[3] += pixels[offset + 3] * weight;
}
const dstOffset = dstRowOffset + x * 4;
if (totalWeight > 0) {
result[dstOffset] = clamp(Math.round(rgba[0] / totalWeight), 0, 255);
result[dstOffset + 1] = clamp(Math.round(rgba[1] / totalWeight), 0, 255);
result[dstOffset + 2] = clamp(Math.round(rgba[2] / totalWeight), 0, 255);
result[dstOffset + 3] = clamp(Math.round(rgba[3] / totalWeight), 0, 255);
}
}
}
return result;
}