С примером в TypeScript

Во время работы над игровым проектом в TS нам понадобился декодер PNG для создания метаданных наших объектов из изображений обычным способом.

Мы пишем код с использованием TS / JS в браузере, поэтому очевидным решением является использование Canvas, но это было бы не весело, не так ли? Поэтому я решил поискать формат PNG, чтобы создать декодер, который будет соответствовать нашим потребностям.

Помимо официальной документации, которую поначалу может быть тяжело читать, я не нашел много примеров того, как это быстро реализовать, поэтому я решил поделиться своим опытом, и я надеюсь, что это поможет некоторым из вас начать работу 😊

В моем проекте я использовал класс Color для хранения пикселя файла, но в нашем примере я собираюсь вывести результаты в виде массива Uint8Array. Каждый массив будет хранить информацию об одном пикселе. (RGBA). Ниже приведен пример результата, который мы хотим реализовать с помощью Canvas, чтобы вы могли видеть, куда мы идем. Файл index.html предназначен только для визуального представления того, что мы делаем, он может показаться бесполезным для первого примера (с использованием холста), но он поможет нам позже.

Код для версии Canvas доступен здесь: https://github.com/achiev-open/png-decoder-intro/tree/1-canvas-example

Весь полезный код находится в src / index.ts. Эта реализация не является предметом нашей статьи, поэтому я не буду вдаваться в подробности, однако обратите внимание, что код просто загружает изображение в холст, а затем использует API холста для извлечения данных изображения.

Теперь, когда мы рассмотрели простой способ, давайте разберемся, как он работает, изобретя колесо. Первый запуск, очевидно, представляет собой официальную документацию такого типа: https://www.w3.org/TR/PNG/ или http://www.libpng.org/pub/png/spec/

Но я понимаю, они могут сбивать с толку, там много контента, и каждый раздел перенаправляется на другой, так что давайте посмотрим, с чего начать!

Сегодня мы не будем воссоздавать полностью функциональный декодер PNG: наша цель - понять достаточно, чтобы начать. Давайте сосредоточимся на одном конкретном цветовом типе (истинный цвет с альфа-каналом) и проигнорируем некоторые метаданные, такие как чересстрочная развертка или палитра. Наш декодер распознает только некоторые файлы PNG.

Если это будет интересно, мы увидим в другой статье, как реализовать другие элементы. Если вы не понимаете всех терминов, использованных в предыдущих предложениях, не волнуйтесь, мы скоро все объясним.

I. Прочтите файл и извлеките данные

Для работы нам нужны данные, поэтому нашим первым шагом будет считывание содержимого файла с URL-адреса и его преобразование в ArrayBuffer. Мы постараемся сделать код простым, чтобы сосредоточиться на новых концепциях.

Давайте отредактируем наш индексный файл, чтобы загрузить изображение из ресурсов:

(async function init() {
   const fileUri = "/assets/screen.png";
   
    const httpResponse = await fetch(fileUri);
    if (!httpResponse.ok) throw new Error(`${fileUri} not found`);
    
    const buffer: ArrayBuffer = await httpResponse.arrayBuffer();
    const bytes: Uint8Array = new Uint8Array(buffer); // File data to decode
})();

Давайте создадим новый класс PngImage в src / png-image.ts. PngImage примет Uint8Array в качестве параметра и будет его декодировать.

export default class PngImage {
    public content: Array<Uint8Array> = [];

    constructor(bytes: Uint8Array) {
        
    }
}

Когда мы закончим, контент будет содержать определения наших пикселей. Теперь мы можем вызвать его в индексе:

const image = new PngImage(bytes);

Вы можете найти код этого шага здесь: https://github.com/achiev-open/png-decoder-intro/tree/2-read-file-content

II. Понять формат файла Png

Прежде всего, чтобы убедиться, что мы загружаем файл png, мы собираемся проверить магическое число.

Магическое число - это последовательность байтов, присутствующая во всех файлах определенного формата (часто в начале). Это способ распознать формат файла. Для файлов PNG в начале файла используется магическое число 89 50 4E 47 0D 0A 1A 0A. Убедимся, что:

export default class PngImage {
    public content: Array<Uint8Array> = [];

    constructor(bytes: Uint8Array) {
        const magicNumberBytes = bytes.slice(0, 8);
        const magicNumber = Buffer.from(magicNumberBytes).toString("hex");
        if (magicNumber !== "89504e470d0a1a0a") throw new Error("Not a png file");
    }
}

Здесь ничего сложного, мы читаем первые 8 байтов, конвертируем их в шестнадцатеричную строку и сравниваем с ожидаемым значением.

Теперь начинается настоящая работа. Файл png содержит серию фрагментов в простом формате:

  • 4 байта: длина данных.
  • 4 байта: тип блока.
  • [длина] байтов: данные
  • 4 байта: CRC, здесь для обнаружения случайных изменений данных, мы не будем подробно рассказывать об этом в этой статье.

В этой статье мы сосредоточимся только на фрагменте, который нам нужен для декодирования истинного цвета с помощью альфа-файлов, но вы можете найти подробности обо всех возможных типах в официальной документации.

Нашим первым шагом будет перечисление всех фрагментов, представленных в файле. Мы создадим новый класс PngChunk для их анализа. Для этого потребуется Uint8Array, начиная с начала фрагмента и заканчивая концом файла.

export default class PngChunk {
    public totalLength: number;
    public dataLength: number;
    public type: string;
    public data: Uint8Array;
    public crc: Uint8Array;

    constructor(bytes: Uint8Array) {
        this.dataLength = this.getLength(bytes);
        this.type = this.getType(bytes);
        this.data = this.getData(bytes);
        this.crc = this.getCRC(bytes);
        this.totalLength = this.dataLength + 12;
    }

    private getLength(bytes: Uint8Array): number {
        const length_bytes: Uint8Array = bytes.slice(0, 4);
        const buffer = Buffer.from(length_bytes);
        return buffer.readUIntBE(0, length_bytes.length);
    }

    private getType(bytes: Uint8Array): string {
        const type_byte: Uint8Array = bytes.slice(4, 8);
        return (new TextDecoder("ascii")).decode(type_byte);
    }

    private getData(bytes: Uint8Array): Uint8Array {
        return bytes.slice(8, 8 + this.dataLength);
    }

    private getCRC(bytes: Uint8Array): Uint8Array {
        return bytes.slice(8 + this.dataLength, 8 + this.dataLength + 4);
    }
}

Теперь давайте используем его в нашем PngImage.

import PngChunk from "./png-chunk";

export default class PngImage {
    public content: Array<Uint8Array> = [];

    constructor(bytes: Uint8Array) {
        const magicNumberBytes = bytes.slice(0, 8);
        const magicNumber = Buffer.from(magicNumberBytes).toString("hex");
        if (magicNumber !== "89504e470d0a1a0a") throw new Error("Not a png file");

        let pos = 8;
        while (pos < bytes.length) {
            const chunk = new PngChunk(bytes.slice(pos));

            // We will parse the data here depending on the chunk type
            pos += chunk.totalLength;
        }
    }
}

Чтобы понять значение данных, содержащихся в наших блоках, мы можем взглянуть на:

Код для этого шага можно найти здесь: https://github.com/achiev-open/png-decoder-intro/tree/3-parse-chunk

III. Разбор нужных нам метаданных

Как мы уже говорили ранее, мы не будем обрабатывать все метаданные, но нам все же необходимо понять некоторые из них. Вся информация, которая нам нужна для этого примера, находится в блоке типа «IHDR».

Это заголовок изображения, он содержит информацию о том, как оно было закодировано, его размер ... Поскольку мы не будем реализовывать полный декодер PNG, мы собираемся проверить, правильно ли закодирован файл перед его обработкой.

Информацию о том, как данные хранятся в блоке IHDR, можно найти здесь https://www.w3.org/TR/PNG/#11IHDR. Подведем итоги:

width (4 байта): ширина нашего изображения.

height (4 байта): высота нашего изображения.

bitDepth (1 byte): определите количество бит на пиксели, в этой статье мы будем обрабатывать только файлы с bitDepth равным 8.

colourType (1 байт): существует 5 возможных способов хранения цветов:

  • Оттенки серого (0): каждый пиксель представляет собой образец шкалы серого.
  • Истинный цвет (2): каждый пиксель представляет собой тройку R, G, B
  • Индексированный цвет (3): каждый пиксель является индексом палитры (палитра определяется в основном блоком PLTE)
  • Оттенки серого с альфа-каналом (4): каждый пиксель представляет собой образец оттенков серого, за которым следует образец альфа-канала.
  • Истинный цвет с альфа-каналом (6): каждый пиксель представляет собой тройку R, G, B, за которой следует альфа-образец

Как было сказано ранее, здесь мы будем работать с типом «Истинный цвет с альфа-каналом».

CompressionMethod (1 байт): указывает, как были сжаты данные изображения, в настоящее время в международном стандарте определено только значение 0, это означает, что данные были сжаты с использованием сжатия deflate / inflate. (zlib)

filterMethod (1 байт): определите набор фильтров, применяемых к нашим данным, мы подробно расскажем об этом позже. В этом примере мы будем обрабатывать только значение 0.

interlacedMethod (1 байт): 0 означает, что чересстрочная развертка отсутствует, и пиксели сохраняются последовательно слева направо. В этом примере мы не поддерживаем файлы с чересстрочной разверткой.

Теперь, когда мы знаем, что находится в блоке IHDR, мы можем реализовать это:

import PngChunk from "./png-chunk";

export default class PngImage {
    public content: Array<Uint8Array> = [];

    public width: number = -1;
    public height: number = -1;
    private bitDepth: number = -1;
    private colourType: number = -1;
    private compressionMethod: number = -1;
    private filterMethod: number = -1;
    private interlaceMethod: number = -1;

    constructor(bytes: Uint8Array) {
        const magicNumberBytes = bytes.slice(0, 8);
        const magicNumber = Buffer.from(magicNumberBytes).toString("hex");
        if (magicNumber !== "89504e470d0a1a0a") throw new Error("Not a png file");

        let pos = 8;
        while (pos < bytes.length) {
            const chunk = new PngChunk(bytes.slice(pos));

            switch (chunk.type) {
                case "IHDR":
                    this.parseIHDRChunk(chunk);
                    break;
            }
            // We will parse the data here depending on the chunk type
            pos += chunk.totalLength;
        }
    }

    private parseIHDRChunk(chunk: PngChunk) {
        this.width = Buffer.from(chunk.data.slice(0, 4)).readUIntBE(0, 4);
        this.height = Buffer.from(chunk.data.slice(4, 8)).readUIntBE(0, 4);

        this.bitDepth = chunk.data.slice(8,9)[0];
        if (this.bitDepth !== 8) throw new Error("bitDepth not supported");

        this.colourType = chunk.data.slice(9, 10)[0];
        if (this.colourType !== 6) throw new Error("colourType not supported");

        this.compressionMethod = chunk.data.slice(10, 11)[0];
        if (this.compressionMethod !== 0) throw new Error("compressionMethod not supported");

        this.filterMethod = chunk.data.slice(11, 12)[0];
        if (this.filterMethod !== 0) throw new Error("filterMethod not supported");

        this.interlaceMethod = chunk.data.slice(12, 13)[0];
        if (this.interlaceMethod !== 0) throw new Error("Interlacing not supported");
    }
}

Теперь у нас есть все необходимое для обработки нужных нам данных и отказа от файла PNG, который, как мы знаем, мы не можем обработать. Полный код этого раздела можно найти здесь: https://github.com/achiev-open/png-decoder-intro/tree/4-ihdr-chunk

IV. Разбор содержимого изображения

Данные png хранятся в блоках типа «IDAT». Имейте в виду, что файл может содержать несколько блоков IDAT, поэтому мы должны прочитать и объединить их все перед обработкой содержимого. Блок типа «IEND» будет указывать, что все блоки IDAT были предоставлены.

Добавим код, чтобы получить полный массив данных:

export default class PngImage {
    (...)
    private idatData: Uint8Array = new Uint8Array();

    constructor(bytes: Uint8Array) {
        (...)
        let pos = 8;
        while (pos < bytes.length) {
            const chunk = new PngChunk(bytes.slice(pos));

            switch (chunk.type) {
                case "IHDR":
                    this.parseIHDRChunk(chunk);
                    break;
                case "IDAT":
                    this.addIDATChunk(chunk);
                    break;
                case "IEND":
                    this.parseIDATData();
                    break;
            }
            pos += chunk.totalLength;
        }
    }

    private parseIHDRChunk(chunk: PngChunk) { ... }

    private addIDATChunk(chunk: PngChunk) {
        const tmp = this.idatData;
        this.idatData = new Uint8Array(tmp?.length + chunk.data.length);
        this.idatData.set(tmp);
        this.idatData.set(chunk.data, tmp.length);
    }

    private parseIDATData() {
    }
}

Теперь у нас есть полный массив данных, хранящийся в this.idatData. Как указано в метаданных, массив данных кодируется с использованием zlib. Мы собираемся использовать модуль pako (https://www.npmjs.com/package/pako) для его декодирования.

$> npm install --save pako
$> npm i --save-dev @types/pako

Обновите файл png-image.ts следующим образом:

import PngChunk from "./png-chunk";
import pako from "pako";

export default class PngImage {
    (...)

    constructor(bytes: Uint8Array) {
        (...)
    }

    private parseIHDRChunk(chunk: PngChunk) {...}

    private addIDATChunk(chunk: PngChunk) {...}

    private parseIDATData() {
        const data = pako.inflate(this.idatData);
    }
}

Переменные данные теперь содержат декодированную информацию о пикселях. Согласно документации для истинного цвета с альфа-каналом, данные форматируются таким образом, что для каждой строки изображения мы имеем:

  • 1 байт, указывающий фильтр, который нужно применить для этой строки
  • 4 байта для каждого пикселя строки, указывающего значение для использования в вычислении для R, G, B, A в этом порядке

Нашим первым шагом здесь будет определение фильтра и списка данных пикселей для каждой строки:

private parseIDATData() {
    const data = pako.inflate(this.idatData);

    let pos = 0;
    const scanlineLength: number = this.width * 4 + 1; // 4 bytes per pixel + 1 for the filter

    while (pos < data.length) {
        const line = data.slice(pos, pos + scanlineLength);
        const filter = line[0];
        const pixelsData = [];

        let linePos = 1;
        while (linePos < line.length) {
            pixelsData.push(line.slice(linePos, linePos + 4));
            linePos += 4;
        }
        // We will process the line data here
        pos += scanlineLength;
    }
}

В наборе, который мы обрабатываем, есть 5 различных фильтров (помните, что в разделе IHDR мы разрешили только один filterMethod):

  • Нет (0): данные как есть
  • Sub (1): данные показывают разницу между пикселем, который нам нужен, и пикселем, который находится непосредственно слева от него. Если пикселя нет, мы используем [0,0,0,0]
  • Вверх (2): то же самое, что и sub, но основывается на пикселе над тем, который нам нужен.
  • Среднее (3): данные показывают разницу между пикселем, который нам нужен, и средним пикселем слева и пикселем выше.
  • Paeth (4): данные указывают разницу между пикселем, который нам нужен, и значением, вычисленным на основе пикселя слева, пикселя выше и пикселя по диагонали слева вверху.

Сначала давайте добавим код, чтобы увидеть результат, а затем обработаем черным цветом каждую строку, которую мы не знаем, как обрабатывать. (На данный момент все, поскольку мы еще не реализовали ни один фильтр)

private parseIDATData() {
    (...)

    while (pos < data.length) {
        (...)
        while (linePos < line.length) {...}

        switch (filter) {
            default:
                pixelsData.map(() => {
                    this.content.push(new Uint8Array([0, 0, 0, 255]));
                });
                break;
        }
        pos += scanlineLength;
    }
}

и в index.ts:

const image = new PngImage(bytes);

if (image.content?.length) {
    const htmlContent = document.getElementById("content");
    if (htmlContent) {
        htmlContent.innerText = image.content.join(" | ").slice(0, 2000) + "...";
    }

    const htmlCanvas: HTMLCanvasElement = document.getElementById("canvas") as HTMLCanvasElement;
    htmlCanvas.width = image.width;
    htmlCanvas.height = image.height;
    if (htmlCanvas) {
        const htmlCtx = htmlCanvas.getContext("2d");
        const imageData = new ImageData(image.width, image.height);

        image.content.map((pixel, i) => {
            imageData.data[i * 4] = pixel[0];
            imageData.data[i * 4 + 1] = pixel[1];
            imageData.data[i * 4 + 2] = pixel[2];
            imageData.data[i * 4 + 3] = pixel[3];
        });
        htmlCtx?.putImageData(imageData , 0, 0);
    }
}

Если вы запустите код и откроете браузер по адресу http: // localhost: 8080, вы должны увидеть что-то вроде этого:

A) Фильтр «Нет» (фильтр == 0)

Это самый простой способ, так как данные выдаются без фильтрации:

switch (filter) {
    case 0: // None
        this.content = this.content.concat(pixelsData);
        break;
    default:
        pixelsData.map(() => {
            this.content.push(new Uint8Array([0, 0, 0, 255]));
        });
        break;
}

Если вы посмотрите на результат в своем браузере, вы можете заметить, что первая строка пикселей теперь содержит серию [0,0,0,0] вместо черных пикселей [0,0,0,255].

[0,0,0,0] означает прозрачный, поэтому вы еще не видите реальных изменений в восстановленном изображении, поскольку мы только что преобразовали одну строку прозрачных пикселей.

Б) Фильтр «Суб» (фильтр == 1)

Данные показывают разницу между пикселем, который нам нужен, и пикселем, который находится непосредственно слева от него. Если слева нет пикселя, мы используем [0,0,0,0]:

(...) 
case 1: // Add this case to the filter
        this.content = this.content.concat(this.parseSubFilter(pixelsData));
        break;
(...)
private parseSubFilter(pixelsData: Array<Uint8Array>): Array<Uint8Array> {
    const content: Array<Uint8Array> = [];
    let previousArray = new Uint8Array([0, 0, 0, 0]);

    pixelsData.map((pixel: any) => {
        let newArray: Uint8Array = new Uint8Array([
            (pixel[0] + previousArray[0]) % 256,
            (pixel[1] + previousArray[1]) % 256,
            (pixel[2] + previousArray[2]) % 256,
            (pixel[3] + previousArray[3]) % 256,
        ]);
        previousArray = newArray;
        content.push(newArray);
    });
    return content;
}

Теперь мы должны начать видеть какой-то результат:

В) Фильтр «Вверх» (фильтр == 2)

Фильтр «UP» такой же, как и sub, но основан на пикселе над тем, который мы вычисляем:

(...)
case 2: // Up
    this.content = this.content.concat(this.parseUpFilter(pixelsData, {
        pos,
        scanlineLength,
    }));
    break;
(...)
private parseUpFilter(pixelsData: Array<Uint8Array>, metadata: any): Array<Uint8Array> {
    const content: Array<Uint8Array> = [];
    const previousLinePixels = this.content.slice((metadata.pos / metadata.scanlineLength - 1) * this.width,  (metadata.pos / metadata.scanlineLength - 1) * this.width + this.width);

    pixelsData.map((pixel: any, i: number) => {
        let previousArray = previousLinePixels[i];
        let newArray: Uint8Array = new Uint8Array([
            (pixel[0] + previousArray[0]) % 256,
            (pixel[1] + previousArray[1]) % 256,
            (pixel[2] + previousArray[2]) % 256,
            (pixel[3] + previousArray[3]) % 256,
        ]);
        content.push(newArray);
    });
    return content;
}

Вы должны увидеть новую строку, появившуюся на вашем вычисленном изображении:

Как вы увидите, некоторые цвета кажутся странными. Это потому, что мы основываем наши вычисления на предыдущей строке, а наш код еще не обрабатывает каждую строку должным образом. Он исправится сам с реализацией двух отсутствующих фильтров.

D) Фильтр «Среднее» (фильтр == 3)

Средний фильтр похож на верхний и нижний, но в качестве разницы использует среднее значение между пикселем выше и пикселем слева.

(...)
case 3:
    this.content = this.content.concat(this.parseAverageFilter(pixelsData, {
        pos,
        scanlineLength,
    }));
    break;
(...)
private parseAverageFilter(pixelsData: Array<Uint8Array>, metadata: any): Array<Uint8Array> {
    const content: Array<Uint8Array> = [];
    const previousLinePixels = this.content.slice((metadata.pos / metadata.scanlineLength - 1) * this.width,  (metadata.pos / metadata.scanlineLength - 1) * this.width + this.width);
    let previousPixel: any = [0, 0, 0, 0];

    pixelsData.map((pixel: any, i: number) => {
        let left = previousPixel;
        let up = previousLinePixels[i];
        let newArray: Uint8Array = new Uint8Array([
            (pixel[0] + Math.floor((left[0] + up[0]) / 2)) % 256,
            (pixel[1] + Math.floor((left[1] + up[1]) / 2)) % 256,
            (pixel[2] + Math.floor((left[2] + up[2]) / 2)) % 256,
            (pixel[3] + Math.floor((left[3] + up[3]) / 2)) % 256,
        ]);
        previousPixel = newArray;
        content.push(previousPixel);
    });
    return  content;
}

Вы должны увидеть несколько новых линий, особенно внизу изображения.

E) Фильтр «Paeth» (фильтр == 4)

И, наконец, наш последний фильтр! Здесь в качестве разницы используется значение, вычисленное из пикселя слева, пикселя вверху и пикселя слева.

(...)
case 4:
    this.content = this.content.concat(this.parsePaethFilter(pixelsData, {
        pos,
        scanlineLength,
    }));
    break;
(...)
private parsePaethFilter(pixelsData: Array<Uint8Array>, metadata: any): Array<Uint8Array> {
    const content: Array<Uint8Array> = [];
    const previousLinePixels = this.content.slice((metadata.pos / metadata.scanlineLength - 1) * this.width,  (metadata.pos / metadata.scanlineLength - 1) * this.width + this.width);
    let previousPixel: any = [0, 0, 0, 0];

    pixelsData.map((pixel: any, i: number) => {
        let left = previousPixel;
        let up = previousLinePixels[i];
        let upperleft = i ? previousLinePixels[i - 1] : [0, 0, 0, 0];

        let bytes = pixel.map((byte: any, byte_index: number) => {
            const p = left[byte_index] + up[byte_index] - upperleft[byte_index];
            const pa = Math.abs(p - left[byte_index]);
            const pb = Math.abs(p - up[byte_index]);
            const pc = Math.abs(p - upperleft[byte_index]);

            if (pa <= pb && pa <= pc) return (byte + left[byte_index]);
            else if (pb <= pc) return (byte + up[byte_index]);
            else return (byte + upperleft[byte_index]);
        });
        previousPixel = bytes;
        content.push(previousPixel);
    });
    return content;
}

Вот и все ! Теперь, когда мы обрабатываем каждый фильтр, у вас должно быть полное изображение в вашем браузере, что означает, что мы успешно декодировали все данные!

Конечно, нашему декодеру все еще не хватает многих функций, но я надеюсь, что это введение помогло вам понять, как кодируются файлы PNG и как начать с ними работать.

Полный код доступен по адресу https://github.com/achiev-open/png-decoder-intro