@raven-js/cortex
Version:
Zero-dependency machine learning, AI, and data processing library for modern JavaScript
666 lines (578 loc) • 22.3 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 Reconstructs PNG pixels from unfiltered scanline data.
*
* After PNG scanline filters are reversed, the raw pixel data needs to be
* reconstructed into a usable RGBA format. This module handles:
* - Bit depth conversion (1, 2, 4, 8, 16-bit to 8-bit)
* - Color type conversion (grayscale, RGB, palette, alpha variants)
* - Palette lookup for indexed color images
* - Alpha channel handling and premultiplication
* - Interlaced image reconstruction (Adam7)
*
* @param {Uint8Array} unfilteredData - Unfiltered scanline data
* @param {{width: number, height: number, colorType: number, bitDepth: number, samplesPerPixel?: number}} ihdr - IHDR chunk information
* @param {Uint8Array} [palette] - PLTE chunk data for indexed images
* @param {Uint8Array} [transparency] - tRNS chunk data for transparency
* @returns {Uint8Array} RGBA pixel data (4 bytes per pixel)
*
* @example
* // Reconstruct RGB image pixels
* const pixels = reconstructPixels(unfilteredData, ihdr);
* console.log(`Reconstructed ${pixels.length / 4} pixels`);
*
* @example
* // Reconstruct palette image with transparency
* const pixels = reconstructPixels(unfilteredData, ihdr, palette, transparency);
* console.log(`Palette image: ${ihdr.width}×${ihdr.height}`);
*/
/**
* PNG color type constants.
*/
const COLOR_TYPE = {
GRAYSCALE: 0, // Each pixel is a grayscale sample
RGB: 2, // Each pixel is an R,G,B triple
PALETTE: 3, // Each pixel is a palette index
GRAYSCALE_ALPHA: 4, // Each pixel is a grayscale sample followed by alpha
RGBA: 6, // Each pixel is an R,G,B triple followed by alpha
};
/**
* Gets samples per pixel for a given color type.
*
* @param {number} colorType - PNG color type
* @returns {number} Number of samples per pixel
*/
function getSamplesPerPixelFromColorType(colorType) {
switch (colorType) {
case COLOR_TYPE.GRAYSCALE:
return 1;
case COLOR_TYPE.RGB:
return 3;
case COLOR_TYPE.PALETTE:
return 1;
case COLOR_TYPE.GRAYSCALE_ALPHA:
return 2;
case COLOR_TYPE.RGBA:
return 4;
default:
throw new Error(`Unknown color type: ${colorType}`);
}
}
/**
* Reconstructs PNG pixels from unfiltered scanline data.
*
* @param {Uint8Array} unfilteredData - Unfiltered scanline data
* @param {{width: number, height: number, colorType: number, bitDepth: number, samplesPerPixel?: number, interlaceMethod?: number}} ihdr - IHDR chunk information
* @param {Uint8Array} [palette] - PLTE chunk data for indexed images
* @param {Uint8Array} [transparency] - tRNS chunk data for transparency
* @returns {Uint8Array} RGBA pixel data (4 bytes per pixel)
*/
export function reconstructPixels(unfilteredData, ihdr, palette, transparency) {
// Parameter validation
if (!(unfilteredData instanceof Uint8Array)) {
throw new TypeError("unfilteredData must be a Uint8Array");
}
if (!ihdr || typeof ihdr !== "object") {
throw new TypeError("ihdr must be an object");
}
if (!validateReconstructionParameters(unfilteredData, ihdr)) {
throw new Error("Invalid reconstruction parameters");
}
const { width, height, colorType, bitDepth } = ihdr;
// Calculate samplesPerPixel if not provided (for test compatibility)
const samplesPerPixel = ihdr.samplesPerPixel || getSamplesPerPixelFromColorType(colorType);
// Handle interlaced images
if (ihdr.interlaceMethod === 1) {
const deinterlacedData = deinterlaceAdam7(unfilteredData, ihdr);
return reconstructPixels(deinterlacedData, { ...ihdr, interlaceMethod: 0 }, palette, transparency);
}
// Convert bit depth to 8-bit if needed
let pixelData = unfilteredData;
if (bitDepth !== 8) {
pixelData = convertBitDepth(unfilteredData, bitDepth, samplesPerPixel);
}
// Convert to RGBA based on color type
let rgbaData;
switch (colorType) {
case COLOR_TYPE.GRAYSCALE:
rgbaData = convertGrayscaleToRGBA(pixelData, width, height, false);
break;
case COLOR_TYPE.RGB:
rgbaData = convertRGBToRGBA(pixelData, width, height);
break;
case COLOR_TYPE.PALETTE:
if (!palette) {
throw new Error("PLTE chunk required for palette images");
}
rgbaData = convertPaletteToRGBA(pixelData, width, height, palette, transparency);
break;
case COLOR_TYPE.GRAYSCALE_ALPHA:
rgbaData = convertGrayscaleToRGBA(pixelData, width, height, true);
break;
case COLOR_TYPE.RGBA:
// Already RGBA, just copy
rgbaData = new Uint8Array(pixelData);
break;
default:
throw new Error(`Unsupported color type: ${colorType}`);
}
// Apply transparency from tRNS chunk if present (for non-alpha color types)
if (transparency && colorType !== COLOR_TYPE.GRAYSCALE_ALPHA && colorType !== COLOR_TYPE.RGBA) {
rgbaData = applyTransparency(rgbaData, transparency, colorType);
}
return rgbaData;
}
/**
* Converts bit depth to 8-bit values.
*
* @param {Uint8Array} data - Raw pixel data
* @param {number} bitDepth - Source bit depth (1, 2, 4, 8, 16)
* @param {number} samplesPerPixel - Number of samples per pixel
* @returns {Uint8Array} 8-bit normalized data
*/
export function convertBitDepth(data, bitDepth, samplesPerPixel) {
// Parameter validation
if (!(data instanceof Uint8Array)) {
throw new TypeError("data must be a Uint8Array");
}
if (typeof bitDepth !== "number" || ![1, 2, 4, 8, 16].includes(bitDepth)) {
throw new Error(`Invalid bit depth: ${bitDepth}. Must be 1, 2, 4, 8, or 16`);
}
if (typeof samplesPerPixel !== "number" || samplesPerPixel < 1 || samplesPerPixel > 4) {
throw new Error(`Invalid samplesPerPixel: ${samplesPerPixel}. Must be 1-4`);
}
// If already 8-bit, return as-is
if (bitDepth === 8) {
return new Uint8Array(data);
}
// Handle 16-bit to 8-bit conversion
if (bitDepth === 16) {
// Each 16-bit sample becomes one 8-bit sample
const result = new Uint8Array(data.length / 2);
for (let i = 0; i < result.length; i++) {
// Take the high byte (most significant) for 16->8 bit conversion
result[i] = data[i * 2];
}
return result;
}
// Handle sub-byte bit depths (1, 2, 4 bits)
if (bitDepth < 8) {
const pixelsPerByte = 8 / bitDepth;
const maxValue = (1 << bitDepth) - 1; // 2^bitDepth - 1
const scale = 255 / maxValue; // Scale factor to 8-bit
const totalPixels = data.length * pixelsPerByte;
const result = new Uint8Array(totalPixels * samplesPerPixel);
let resultIndex = 0;
for (let byteIndex = 0; byteIndex < data.length; byteIndex++) {
const byte = data[byteIndex];
// Extract pixels from this byte
for (let pixelInByte = 0; pixelInByte < pixelsPerByte; pixelInByte++) {
const shift = (pixelsPerByte - 1 - pixelInByte) * bitDepth;
const mask = maxValue << shift;
const value = (byte & mask) >> shift;
const scaledValue = Math.round(value * scale);
// Replicate the value for all samples in this pixel
for (let sample = 0; sample < samplesPerPixel; sample++) {
if (resultIndex < result.length) {
result[resultIndex++] = scaledValue;
}
}
}
}
return result;
}
// Should never reach here
throw new Error(`Unsupported bit depth: ${bitDepth}`);
}
/**
* Converts grayscale pixels to RGBA.
*
* @param {Uint8Array} grayscaleData - Grayscale pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {boolean} hasAlpha - Whether grayscale includes alpha channel
* @returns {Uint8Array} RGBA pixel data
*/
export function convertGrayscaleToRGBA(grayscaleData, width, height, hasAlpha) {
if (!(grayscaleData instanceof Uint8Array)) {
throw new TypeError("grayscaleData must be a Uint8Array");
}
if (typeof width !== "number" || width <= 0) {
throw new Error("width must be a positive number");
}
if (typeof height !== "number" || height <= 0) {
throw new Error("height must be a positive number");
}
if (typeof hasAlpha !== "boolean") {
throw new TypeError("hasAlpha must be a boolean");
}
const samplesPerPixel = hasAlpha ? 2 : 1;
const expectedSize = width * height * samplesPerPixel;
if (grayscaleData.length !== expectedSize) {
throw new Error(`Grayscale data size mismatch. Expected ${expectedSize}, got ${grayscaleData.length}`);
}
const rgbaData = new Uint8Array(width * height * 4);
let grayIndex = 0;
let rgbaIndex = 0;
for (let i = 0; i < width * height; i++) {
const grayValue = grayscaleData[grayIndex++];
const alphaValue = hasAlpha ? grayscaleData[grayIndex++] : 255;
// Set RGB to grayscale value
rgbaData[rgbaIndex++] = grayValue; // R
rgbaData[rgbaIndex++] = grayValue; // G
rgbaData[rgbaIndex++] = grayValue; // B
rgbaData[rgbaIndex++] = alphaValue; // A
}
return rgbaData;
}
/**
* Converts RGB pixels to RGBA (adds alpha channel).
*
* @param {Uint8Array} rgbData - RGB pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @returns {Uint8Array} RGBA pixel data
*/
export function convertRGBToRGBA(rgbData, width, height) {
if (!(rgbData instanceof Uint8Array)) {
throw new TypeError("rgbData must be a Uint8Array");
}
if (typeof width !== "number" || width <= 0) {
throw new Error("width must be a positive number");
}
if (typeof height !== "number" || height <= 0) {
throw new Error("height must be a positive number");
}
const expectedRGBSize = width * height * 3;
if (rgbData.length !== expectedRGBSize) {
throw new Error(`RGB data size mismatch. Expected ${expectedRGBSize}, got ${rgbData.length}`);
}
const rgbaData = new Uint8Array(width * height * 4);
let rgbIndex = 0;
let rgbaIndex = 0;
for (let i = 0; i < width * height; i++) {
// Copy RGB values
rgbaData[rgbaIndex++] = rgbData[rgbIndex++]; // R
rgbaData[rgbaIndex++] = rgbData[rgbIndex++]; // G
rgbaData[rgbaIndex++] = rgbData[rgbIndex++]; // B
rgbaData[rgbaIndex++] = 255; // A (fully opaque)
}
return rgbaData;
}
/**
* Converts palette indices to RGBA using PLTE and tRNS chunks.
*
* @param {Uint8Array} indexData - Palette index data
* @param {number} width - Image width
* @param {number} height - Image height
* @param {Uint8Array} palette - PLTE chunk data (RGB triplets)
* @param {Uint8Array} [transparency] - tRNS chunk data (alpha values)
* @returns {Uint8Array} RGBA pixel data
*/
export function convertPaletteToRGBA(indexData, width, height, palette, transparency) {
if (!(indexData instanceof Uint8Array)) {
throw new TypeError("indexData must be a Uint8Array");
}
if (typeof width !== "number" || width <= 0) {
throw new Error("width must be a positive number");
}
if (typeof height !== "number" || height <= 0) {
throw new Error("height must be a positive number");
}
if (!(palette instanceof Uint8Array)) {
throw new TypeError("palette must be a Uint8Array");
}
if (palette.length % 3 !== 0) {
throw new Error("palette length must be a multiple of 3 (RGB triplets)");
}
const expectedSize = width * height;
if (indexData.length !== expectedSize) {
throw new Error(`Index data size mismatch. Expected ${expectedSize}, got ${indexData.length}`);
}
const paletteEntries = palette.length / 3;
const rgbaData = new Uint8Array(width * height * 4);
let rgbaIndex = 0;
for (let i = 0; i < indexData.length; i++) {
const index = indexData[i];
if (index >= paletteEntries) {
throw new Error(`Palette index ${index} out of range (max: ${paletteEntries - 1})`);
}
const paletteOffset = index * 3;
const r = palette[paletteOffset];
const g = palette[paletteOffset + 1];
const b = palette[paletteOffset + 2];
const a = transparency && index < transparency.length ? transparency[index] : 255;
rgbaData[rgbaIndex++] = r;
rgbaData[rgbaIndex++] = g;
rgbaData[rgbaIndex++] = b;
rgbaData[rgbaIndex++] = a;
}
return rgbaData;
}
/**
* Reconstructs interlaced PNG using Adam7 algorithm.
*
* @param {Uint8Array} interlacedData - Interlaced scanline data
* @param {{width: number, height: number, colorType: number, bitDepth: number, samplesPerPixel?: number}} ihdr - IHDR chunk information
* @returns {Uint8Array} Deinterlaced pixel data
*/
export function deinterlaceAdam7(interlacedData, ihdr) {
const { width, height, bitDepth, colorType } = ihdr;
const samplesPerPixel = ihdr.samplesPerPixel || getSamplesPerPixelFromColorType(colorType);
const bytesPerPixel = Math.ceil((bitDepth * samplesPerPixel) / 8);
// Adam7 pass parameters: [startX, startY, stepX, stepY]
const passes = [
[0, 0, 8, 8], // Pass 1: every 8th pixel starting at (0,0)
[4, 0, 8, 8], // Pass 2: every 8th pixel starting at (4,0)
[0, 4, 4, 8], // Pass 3: every 4th pixel starting at (0,4)
[2, 0, 4, 4], // Pass 4: every 4th pixel starting at (2,0)
[0, 2, 2, 4], // Pass 5: every 2nd pixel starting at (0,2)
[1, 0, 2, 2], // Pass 6: every 2nd pixel starting at (1,0)
[0, 1, 1, 2], // Pass 7: every pixel starting at (0,1)
];
// Create output buffer for deinterlaced data
const outputSize = height * width * bytesPerPixel;
const output = new Uint8Array(outputSize);
let inputOffset = 0;
for (let passIndex = 0; passIndex < passes.length; passIndex++) {
const [startX, startY, stepX, stepY] = passes[passIndex];
// Calculate pass dimensions
const passWidth = Math.ceil((width - startX) / stepX);
const passHeight = Math.ceil((height - startY) / stepY);
if (passWidth <= 0 || passHeight <= 0) {
continue; // Skip empty passes
}
// Calculate bytes per scanline for this pass
const passBytesPerPixel = bytesPerPixel;
const passScanlineBytes = passWidth * passBytesPerPixel;
// Process each scanline in this pass
for (let passY = 0; passY < passHeight; passY++) {
const actualY = startY + passY * stepY;
if (actualY >= height) break;
// Process each pixel in this scanline
for (let passX = 0; passX < passWidth; passX++) {
const actualX = startX + passX * stepX;
if (actualX >= width) break;
// Copy pixel data from interlaced input to correct position in output
const inputPixelOffset = inputOffset + passX * passBytesPerPixel;
const outputPixelOffset = (actualY * width + actualX) * bytesPerPixel;
for (let b = 0; b < bytesPerPixel; b++) {
if (inputPixelOffset + b < interlacedData.length) {
output[outputPixelOffset + b] = interlacedData[inputPixelOffset + b];
}
}
}
// Move to next scanline in interlaced data
inputOffset += passScanlineBytes;
}
}
return output;
}
/**
* Applies transparency from tRNS chunk to RGB images.
*
* @param {Uint8Array} rgbaData - RGBA pixel data
* @param {Uint8Array} transparency - tRNS chunk data
* @param {number} colorType - PNG color type
* @returns {Uint8Array} RGBA data with transparency applied
*/
export function applyTransparency(rgbaData, transparency, colorType) {
if (!(rgbaData instanceof Uint8Array)) {
throw new TypeError("rgbaData must be a Uint8Array");
}
if (!(transparency instanceof Uint8Array)) {
throw new TypeError("transparency must be a Uint8Array");
}
if (typeof colorType !== "number") {
throw new TypeError("colorType must be a number");
}
// Create a copy to avoid mutating the original
const result = new Uint8Array(rgbaData);
switch (colorType) {
case COLOR_TYPE.GRAYSCALE: {
// For grayscale, tRNS contains a single 16-bit value (2 bytes)
if (transparency.length !== 2) {
throw new Error(`Invalid tRNS length for grayscale: expected 2, got ${transparency.length}`);
}
// Read the transparent gray value (16-bit big-endian, but we only use high byte for 8-bit)
const transparentGray = transparency[0];
// Apply transparency to matching pixels
for (let i = 0; i < result.length; i += 4) {
const gray = result[i]; // R, G, B should all be the same for grayscale
if (gray === transparentGray) {
result[i + 3] = 0; // Set alpha to 0 (transparent)
}
}
break;
}
case COLOR_TYPE.RGB: {
// For RGB, tRNS contains three 16-bit values (6 bytes total)
if (transparency.length !== 6) {
throw new Error(`Invalid tRNS length for RGB: expected 6, got ${transparency.length}`);
}
// Read the transparent RGB values (16-bit big-endian, but we only use high bytes for 8-bit)
const transparentR = transparency[0];
const transparentG = transparency[2];
const transparentB = transparency[4];
// Apply transparency to matching pixels
for (let i = 0; i < result.length; i += 4) {
const r = result[i];
const g = result[i + 1];
const b = result[i + 2];
if (r === transparentR && g === transparentG && b === transparentB) {
result[i + 3] = 0; // Set alpha to 0 (transparent)
}
}
break;
}
case COLOR_TYPE.PALETTE: {
// For palette, tRNS contains alpha values for palette entries
// This should already be handled in convertPaletteToRGBA, but we can double-check
console.warn("tRNS for palette images should be handled during palette conversion");
break;
}
default:
throw new Error(`tRNS transparency not applicable for color type ${colorType}`);
}
return result;
}
/**
* Validates pixel reconstruction parameters.
*
* @param {Uint8Array} unfilteredData - Unfiltered scanline data
* @param {{width: number, height: number, colorType: number, bitDepth: number, samplesPerPixel?: number}} ihdr - IHDR chunk information
* @returns {boolean} True if parameters are valid
*/
export function validateReconstructionParameters(unfilteredData, ihdr) {
// Basic type validation
if (!(unfilteredData instanceof Uint8Array)) {
return false;
}
if (!ihdr || typeof ihdr !== "object") {
return false;
}
// Required IHDR properties
if (
typeof ihdr.width !== "number" ||
typeof ihdr.height !== "number" ||
typeof ihdr.colorType !== "number" ||
typeof ihdr.bitDepth !== "number"
) {
return false;
}
// samplesPerPixel is optional for basic validation (can be calculated from colorType)
if (ihdr.samplesPerPixel !== undefined && typeof ihdr.samplesPerPixel !== "number") {
return false;
}
// Validate ranges
if (ihdr.width <= 0 || ihdr.height <= 0) {
return false;
}
if (![1, 2, 4, 8, 16].includes(ihdr.bitDepth)) {
return false;
}
if (![0, 2, 3, 4, 6].includes(ihdr.colorType)) {
return false;
}
// Validate samplesPerPixel if provided
const expectedSamplesPerPixel = getSamplesPerPixelFromColorType(ihdr.colorType);
if (ihdr.samplesPerPixel !== undefined && ihdr.samplesPerPixel !== expectedSamplesPerPixel) {
return false;
}
// Validate data size (basic check)
if (unfilteredData.length === 0) {
return false;
}
return true;
}
/**
* Gets the expected pixel data size for given image parameters.
*
* @param {number} width - Image width
* @param {number} height - Image height
* @param {number} colorType - PNG color type
* @param {number} bitDepth - PNG bit depth
* @returns {number} Expected pixel data size in bytes
*/
export function getExpectedPixelDataSize(width, height, colorType, bitDepth) {
if (typeof width !== "number" || width <= 0) {
throw new Error("width must be a positive number");
}
if (typeof height !== "number" || height <= 0) {
throw new Error("height must be a positive number");
}
if (typeof colorType !== "number" || ![0, 2, 3, 4, 6].includes(colorType)) {
throw new Error(`Invalid color type: ${colorType}`);
}
if (typeof bitDepth !== "number" || ![1, 2, 4, 8, 16].includes(bitDepth)) {
throw new Error(`Invalid bit depth: ${bitDepth}`);
}
const samplesPerPixel = getSamplesPerPixelFromColorType(colorType);
const bitsPerPixel = samplesPerPixel * bitDepth;
const bytesPerScanline = Math.ceil((width * bitsPerPixel) / 8);
// Add 1 byte per scanline for the filter type
const totalBytes = height * (bytesPerScanline + 1);
return totalBytes;
}
/**
* Analyzes pixel data statistics for debugging.
*
* @param {Uint8Array} pixelData - RGBA pixel data
* @param {number} width - Image width
* @param {number} height - Image height
* @returns {Object} Pixel statistics
*/
export function analyzePixelData(pixelData, width, height) {
if (!(pixelData instanceof Uint8Array)) {
throw new TypeError("pixelData must be a Uint8Array");
}
if (typeof width !== "number" || width <= 0) {
throw new Error("width must be a positive number");
}
if (typeof height !== "number" || height <= 0) {
throw new Error("height must be a positive number");
}
const expectedSize = width * height * 4;
if (pixelData.length !== expectedSize) {
throw new Error(`Pixel data size mismatch. Expected ${expectedSize}, got ${pixelData.length}`);
}
const totalPixels = width * height;
let totalRed = 0;
let totalGreen = 0;
let totalBlue = 0;
let totalAlpha = 0;
let hasTransparency = false;
let minValue = 255;
let maxValue = 0;
for (let i = 0; i < pixelData.length; i += 4) {
const r = pixelData[i];
const g = pixelData[i + 1];
const b = pixelData[i + 2];
const a = pixelData[i + 3];
totalRed += r;
totalGreen += g;
totalBlue += b;
totalAlpha += a;
if (a < 255) {
hasTransparency = true;
}
// Track color range (excluding alpha)
minValue = Math.min(minValue, r, g, b);
maxValue = Math.max(maxValue, r, g, b);
}
return {
totalPixels,
averageRed: Math.round(totalRed / totalPixels),
averageGreen: Math.round(totalGreen / totalPixels),
averageBlue: Math.round(totalBlue / totalPixels),
averageAlpha: Math.round(totalAlpha / totalPixels),
hasTransparency,
colorRange: { min: minValue, max: maxValue },
};
}