Conclusão

Yaks Souza, linuxc languagePNGbinary filespictures
Back

Como interpretar uma imagem PNG em C puro

Eu queria muito saber se vocês já tiveram a oportunidade de trabalhar com imagens ou outros arquivos binários… Se já trabalhou tenho quase certeza que fez isso utilizando uma abstração (mais conhecido como biblioteca), e apesar de sempre ouvir todo mundo dizendo para não reinventar a roda, eu acho que em casos como esses você vai ganhar muito mais reinventando ela, já que vai ter um real conhecimento de como o formato funciona e de como criar o seu próprio formato.

Nesse post vou mostrar como que arquivos binários são interpretados por um programa e como as estruturas são montadas no arquivo, ao final deste poste você terá um programa em C que consegue interpretar uma imagem PNG e cuspir algumas informações relacionadas à imagem.

File signature

A primeira coisa que nós precisamos saber sobre arquivos binários é file signature, ou assinatura do arquivo, basicamente todo formato tem uma forma de identificar o arquivo, uma espécie de assinatura digital.. e geralmente são uma sequencia de bytes que fica no inicio do arquivo:

.jpg / .jpeg :

hex: ff       d8
dec: 255      216
bin: 11111111 11011000

.exe :

hex:   4d      5a
dec:   77      90
bin:   1001101 1011010
ascii: M       Z

E como agente vai trabalhar com uma PNG ( já que eu tenho que escolher algum formato para explicar todos os conceitos aqui ), essa é a assinatura de uma imagem PNG:

hex:   89       50      4e      47      0d   0a   1a    0a
dec:   137      80      78      71      13   10   26    10
bin:   10001001 1010000 1001110 1000111 1101 1010 11010 1010
ascii:          P       N       G

Mão na massa

Já que já entendemos essa parte nós vamos importar a seguinte imagem:

img

Mas redimensionada já que vai ficar mais simples de trabalharmos com uma imagem pequena, e quando acabarmos o programa estaremos prontos para usar a imagem acima, mas até lá iremos usar esta:

img

E tenha em mente que como não vamos implementar todos os suportes necessários, outras imagens podem não ser suportadas pelo código abordado neste post, dito isto vamos começar.

Primeiramente iremos importar o stdio.h no nosso arquivo C, criar a função main e abrir a imagem:

#include <stdio.h>
int main(){
    // Criando o arquivo e abrindo a imagem
    FILE * image = fopen("acdc.min.png", "rb");
    return 0;
}

Tá, mas por enquanto nosso código não faz nada, então iremos ler os primeiros 8 bytes e checar se o arquivo inserido é mesmo uma imagem PNG:

Reescreva o código abaixo logo após a linha onde importamos o arquivo

// Criando variável para guardar a assinatura
Byte signature[9];
signature[8] = 0; /* Caractere vazio para in-
                     dicar fim da string. */
// Lendo os 8 bytes da imagem e colocando na variavel signature
fread(signature, 1, 8, image);

E logo após isso temos que comparar as duas strings para averiguar se o arquivo é mesmo uma PNG, mas para isso teremos que importar o string.h no inicio do código para fazer a comparação:

#include <string.h>

Agora nós vamos fazer uma checagem e para isso primeiro temos que criar uma variável com os bytes certos:

Coloque logo abaixo da importação do _string.h_

typedef char Byte;
// Assinatura :        89    50    4e    47    0d    0a    1a    0a
Byte PNG_sign [] = { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0};

Agora é só fazer a comparação logo abaixo da nova variável e escrever na tela se o arquivo é ou não uma PNG:

if (strcmp(signature, PNG_sign) == 0)
    puts("A imagem é uma PNG!");
else puts("O arquivo inserido não é uma PNG!");

Código final

E o nosso código ficou ( ou deveria ter ficado ) assim:

#include <stdio.h>
#include <string.h>
// Assinatura :        89    50    4e    47    0d    0a    1a    0a
const Byte PNG_sign [] = { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0};
int main(){
    // Criando o arquivo e abrindo a imagem
    FILE * image = fopen("image.png", "rb");
    // Criando variável para guardar a assinatura
    Byte signature[9];
    signature[8] = 0; /* Caractere vazio para in-
                         dicar fim da string. */
    // Lendo os 8 bytes da imagem e colocando na variavel signature
    fread(signature, 1, 8, image);
    // Checando se o arquivo é ou não uma PNG
    if (strcmp(signature, PNG_sign) == 0)
        puts("A imagem é uma PNG!");
    else puts("O arquivo inserido não é uma PNG!");
    return 0;
}

Esse código vai funcionar plenamente, mas vamos transformar ele em uma função de validação para deixar as coisas mais limpas nos próximos tópicos:

crie um outro arquivo chamado png.c e inclua a seguinte função junto com os _include_s nele.

#include <stdio.h>
#include <string.h>
#include <stdbool.h>
#include "png.h"
bool is_PNG(FILE * image){
    // Criando variável para guardar a assinatura
    Byte signature[9];
    signature[8] = 0; /* Caractere vazio para in-
                         dicar fim da string. */
    // Lendo os 8 bytes da imagem e colocando na variavel signature
    fread(signature, 1, 8, image);
    // Checando se o arquivo é ou não uma PNG
    if (strcmp(signature, PNG_sign) == 0)
        return  true;
    return false;
}

E adicione as linhas a seguir em um arquivo png.h:

typedef char Byte;
// Assinatura :        89    50    4e    47    0d    0a    1a    0a
const Byte PNG_sign [] = { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0};
bool is_PNG(FILE * image);

Como começamos a separar os arquivos teremos que compilar assim:

$ <compilador> *.c -o <executavel>

Chunks

Beleza, já sabemos como identificar o arquivo e vocês já tem uma idéia de como a leitura da imagem acontece, ta na hora de entendermos um pouco mais sobre os “blocos de byte” ou chunks, e eles variam de binário para binário, mas geralmente eles tem uma estrutura clara de como os bytes se organizam.

No caso de imagens PNG a estrutura é a seguinte:

tamanho  // 4 bytes com a quantidade de bytes do chunk
tipo     // 4 bytes com o tipo do chunk
dados    // <tamanho> bytes com os dados
crc      /* 4 bytes com o numero para validar
            se os dados não foram corrompidos */

E cada chunk tem uma especificação de como os dados dele devem ser interpretados.

Cabeçalho da imagem — IHDR

O chunk de cabeçalho ou IHDR que é o responsável por alguns metadados muito importantes para a imagem, como altura e largura:

tamanho: 13
tipo: IHDR
dados:
    comprimento: 4 bytes
    altura: 4 bytes
    profundidade de bits: 1 byte
    tipo de cor: 1 byte
    tipo de compressão: 1 byte
    tipo de intrelaçamento: 1 byte
crc: <crc>

E acho que você já percebeu que o chunk IHDR é responsável pelos metadados da imagem e também sempre vem primeiro, e nós vamos nos focar nele por enquanto para entendermos melhor como isso se encaixa em forma de código.

Eu acho que despejei muita informação de uma vez, mas vamos começar estruturando o tipo Chunk que vai representar os dados brutos em nosso código:

Adicione os códigos a seguir no arquivo png.h

#include <stdint.h> // Para usarmos o int32_t

typedef Byte char   // Pra ficar mais didatico
typedef struct {
    int32_t lenght; // O int 32 sempre tem 4 bytes
    Byte   type[4]; // O tipo é sempre uma string
    void *    data; // Os dados tem tamanho variável
    int32_t    crc;
} Chunk;

Agora iremos estruturar o tipo IHDR :

Sei que agora pode estar um pouco confuso para alguns de vocês, tenha calma que eu já to quase lá.

typedef struct {
    uint32_t   width; // Comprimento
    uint32_t  height; // Altura
    Byte      depth; // Profundidade de bits
    Byte       color; // Tipo de cor
    Byte   interlace; // Tipo de intrelaçamento
    Byte      filter; // Tipo de cor
    Byte compression; // Tipo de compressão
} IHDR;

E já que criamos nossas estruturas agora resta ler os dados da imagem e colocar nelas. Para isso primeiro temos que ler o chunk.

Em um arquivo main.c escreva os seguintes códigos:

#include <string.h> // strcmp
#include <stdio.h>  // FILE, fopen
#include <stdlib.h> // exit
#include "png.h"    // Chunk, Byte
// Função que interrompe o programa e exibe mensagem em caso de erros
void die(Byte * msg){
    fprintf(stderr, "Error: %s\n", msg);
    exit(1);
}
int main(){
    // Abrindo a imagem
    FILE * image = fopen("image.png", "rb");
    if (!image)
        die("Não foi possível ler a imagem");
    if (!is_PNG(image))
        die("A imagem não é uma PNG");
    return 0;
}

Agora que temos que realmente ler a imagem e colocar no chunk:

Chunk bloco;
fread(&bloco.lenght, 4, 1, image); // Lendo o tamanho
// Criando buffer de dados com o tamanho lido
bloco.data = (Byte *)malloc(bloco.lenght);
// Lendo o tipo e validando se o cabeçalho existe
fread(bloco.type, 4, 1, image);
bloco.type[4] = 0; /* O \0 é obrigatório em
                          strings */
if (strcmp(bloco.type, "IHDR"))
    die("Não foi possível ler o cabeçalho do arquivo");
// Lendo os dados do cabeçalho
fread(bloco.data, bloco.lenght, 1, image);
// Lendo o crc
fread(&bloco.crc, 4, 1, image); /* O ideal seria rodar
                                   um algoritmo de CRC
                                   e comparar com esse
                                   número, mas não vai
                                   fazer diferença pra
                                   gente,  então vamos
                                   ignorar essa  etapa */
// Salvando dados no cabeçalho
IHDR * cabecalho = (IHDR *)bloco.data;

E é aqui que você acha que está tudo certo, já que o código compila sem erros e funciona aparentemente sem erros…

E você só terá certeza disso exibindo as únicas variáveis que importam para agente por enquanto, bloco.lenght, cabecalho->height e cabecalho->width, se você exibi-las na tela como estratégia de depuração terá esse resultado:

printf("Tamanho do cabecalho: %i, Largura: %i, Altura: %i\n", bloco.lenght, cabecalho->height, cabecalho->width);

Saída:

Tamanho do cabecalho: 218103808, Largura: 486539264, Altura: 469762048

Rodando um file no arquivo acdc.min.png recebi esse resultado:

Deveria ser:

Tamanho do cabecalho: 13, Largura: 29, Altura: 28

E olha que beleza encontramos um bug.

Interpretando o Header corretamente

Como o código vai acabar um pouco complicado, então eu sugiro que você siga o post com esse gist aberto para conseguir localizar onde fica cada código, então leia o código e tente encontrá-lo por la…

Como foi visto no final do ultimo capítulo, sempre que exibimos as informações de tamanho do chunk.data ou até mesmo as dimensões da imagem (header.height e header.width) temos valores totalmente diferentes do que é esperado.

Neste post resolveremos este bug e eu direi algumas informações não explicadas sobre o cabeçalho do arquivo.

Little endianess

img

Basicamente todo o bug que vimos ocorrer no capítulo anterior foi causado por causa de uma coisa chamada “Little endianess”, que não tem uma tradução correta para o português, mas que iremos chamar aqui no post (por puro capricho) de “friscura do processador”, brincadeira… Mas antes de explicar exatamente sobre oque estou falando, vou dar uma exemplificada para tornar as coisas mais fáceis para o meu lado.

Para uma explicação decente sobre o assunto entre neste link.

Aqui nós temos o inicio da nossa imagem pequenininha em versão hexadecimal (é só usar um leitor de binário de sua preferência, eu estou em um linux então irei usar o xxd):

img

Se nós fizermos a leitura do nosso chunk e simplesmente escrevêssemos na tela como hexadecimal a parte do chunk direcionada ao tamanho do cabeçalho, teriamos isso:

// Criando o arquivo e abrindo a imagem
FILE * image = fopen("image.png", "rb");
if (!image)
    die("Não foi possível ler a imagem");
if (!is_PNG(image))
    die("A imagem não é uma PNG");

Chunk bloco;
fread(&bloco, 8, 1, image); // Criando buffer de dados com o tamanho lido
bloco.data = malloc(bloco.lenght); // Lendo o tipo e validando se o cabeçalho existe
bloco.type[4] = 0;
if (strcmp(bloco.type, "IHDR"))
    die("Cabeçalho do arquivo está corrompido");
printf("Tamanho do cabecalho: %x\n", bloco.lenght);
return 0;

Saída:

Tamanho do cabecalho: d000000

Se você notar, os primeiros 8 valores da parte selecionada na imagem é 0000 000d enquanto no programa ele retorna d00 000 ou 0d00 0000, já que o primeiro 0 é um zero a esquerda, então é desconsiderado.

Logo, é o mesmo valor só que invertido, isso é basicamente “little endian”, quando o C vai ler um valor e armazenar em uma variável ele inverte achando que está corrigindo o processo realizado pelo processador (já que é mais fácil ler da direita para a esquerda em baixo nível), e acaba dando esse bug, já que 0000000d é igual a 13 enquanto 0d00 0000 é igual a 218103808.

Logo, o que temos que fazer na hora de ler tais valores, é só, ler e invertê-los, para isso antes temos que escrever uma função para efetuar essa tarefa:

png.c:

void correct_litle_endian(Byte * bytes){
    char reverse[4];
    int i, r;
    for (i = 0, r = 3; i < 4 && r >= 0; i++, r--){
        reverse[i] = bytes[r];
    }
    for (i = 0; i < 4; i++)
        bytes[i] = reverse[i];
}

​ O próximo passo, é adicionar a função acima em png.h para armazenar nosso tipo em little endian:

void correct_litle_endian(Byte * bytes);

E por fim, inverter o trecho de bytes que queremos:

Chunk bloco;
fread(&bloco, 8, 1, image);
​
// Corrigindo bug de little endian
Byte * tamanho;
tamanho = (Byte *)&bloco; // O lenght fica no inicio do chunk
correct_litle_endian(tamanho);
​

E agora iremos fazer o mesmo para o cabecalho:

img

Os valores selecionados sao os bytes de altura e largura do conteúdo do cabeçalho, e basicamente esses valores foram armazenados em bloco.data como 1d00 0000 1c00 0000:

if (strcmp(bloco.type, "IHDR"))
    die("Não foi possível ler o cabeçalho do arquivo");
​
IHDR * cabecalho = (IHDR*)bloco.data;
{
    Byte * altura, * largura;

    // A largura começa no 1º byte, mas aqui ele ficou no 4º byte
    largura = (Byte *)cabecalho+4; // ... 0000 001c           ...
​
    // A altura começa no 4º byte, mas aqui ele ficou no 1º byte
    altura = (Byte *)cabecalho;    // ...           0000 001d ...

    correct_litle_endian(altura);
    correct_litle_endian(largura);
}
​
return 0;

Criando funções de abstração

E já que isso tería que ser feito toda vez que fossemos ler um IHDR e até mesmo um Chunk comum, então vamos montar umas funções com esta estrutura para facilitar as coisas no futuro:

png.c

void * next_chunk(FILE * image){
    Chunk* block;
    fread(block, 8, 1, image);

    // Corrigindo bug de little endian
    Byte * lenght = (Byte *)block;
    correct_litle_endian(lenght);

    // Tem que vir antes do data, para não corromper os dados do mesmo
    block->type[4] = 0;
    block->data = (char *)malloc(block->lenght);

    // Lendo os dados do chunk
    fread(block->data, block->lenght, 1, image);

    // Lendo o crc
    fread(&block->crc, 4, 1, image);     // Salvando dados no cabeçalho
    return block;
}
​
void trash_chunk(Chunk * block){
    free(block->data);
    free(block);
}
​
void* to_IHDR(const char * raw_data){
    Dimentions* data = (Dimentions*)raw_data;
    correct_litle_endian(data->width);
    correct_litle_endian(data->height);
    IHDR* header = (IHDR*)raw_data;
    return header;
}

png.h

typedef struct {
    Byte   width[4]; // Comprimento
    Byte  height[4]; // Altura
} SIZE_RAW;
typedef Dimentions SIZE_RAW;

main.c (substitua toda a função main)

// Criando o arquivo e abrindo a imagem
FILE * image = fopen("image.png", "rb");
if (!image)
    die("Não foi possível ler a imagem");
if (!is_PNG(image))
    die("A imagem não é uma PNG");Chunk * bloco = next_chunk(image);
​
if (strcmp(bloco.type, "IHDR"))
    die("Não foi possível ler o cabeçalho do arquivo");
IHDR * cabecalho = to_IHDR(bloco->data);
​
printf("Tamanho do cabecalho: %i, Largura: %i, Altura: %i\n",
        bloco->lenght, cabecalho->height, cabecalho->width);
​
trash_chunk(bloco);
return 0;

Para que servem os dados de um IHDR?

Bom, no último capítulo vimos as estruturas básicas de uma PNG (os chunks e o signature), e aprendemos a extrair o primeiro Chunk de toda PNG, o IHDR, e inclusive aprendemos neste capítulo, como corrigir um bug que nos atrapalharia muito na leitura dos dados da imagem (IDAT).

Dois desses dados são extremamente óbvios e tenho certeza que você já sabe o objetivo (altura e largura), mas os outros 5 dados são um pouco confusos, então vamos nos aprofundar mais sobre eles.

Bit depth

A profundidade de bits é um valor inteiro que tem o número de bits (é bit mesmo, não bytes) por sample, e só são válidos valores entre 1, 2, 4 e 16, sendo que cada tipo de cor pode aceitar um número específico de profundidade de bits.

O _*sample*_ é um byte que faz parte de algum tipo de cor, sendo que um tipo como o RGB possue _*3 samples*_, enquanto tipos como o _*grayscale*_ possue apenas _*1*_.

Color type

Este campo, recebe um valor único da imagem que deve estar entre 0, 2, 3, 4, 6 onde:

0 é usado para escala de cinzas, e geralmente é um valor só para cada sample.

Válido para todas as profundidades de bit.

2 é usado quando o campo de dados usa os valores R,G,B (vermelho, verde e azul) para representar os pixels.

Vádido apenas para profundidade 8 ou 16.

3 quando as cores são representadas pelo campo de paleta (PLTE).

Válido para as profundidades 1,2,4 e 8.

4 para escala de cinzas com camada transparente.

Vádido apenas para profundidade 8 ou 16.

6 para r,g,b com camada transparente.

Vádido apenas para profundidade 8 ou 16.

Interlace method

O método de intrelaçamento é um byte que indica a forma que a ordem de dados da imagem está indexada do campo de dados, podendo ter 2 tipos básicos, 0(sem intrelaçamento) ou 1(método Adam7).

Compression method

O método de compressão é bem óbvio, basicamente se refere a forma de compressão dos dados do campo de dados, como existem uma quantidade bem alta de compressores suportados, não irei citá-los aqui além do 0 que é quando não existe compressão.

Filter method

O método de filtragem é a forma com que os dados da imagem seríam filtrados, e assim como no Método de compressão, existem muitos métodos, então só citarei o 0, quando não há método de filtragem.

Tornando o código mais humano

E já que temos esses dados agora, vamos deixar nosso código mais humano e alterar o tipo IHDR para o seguinte código:

typedef enum {
    GrayScale = 0,
    RGB = 2,
    Pallete = 3,
    GrayScaleAlpha = 4,
    RGBAlpha = 6
} ColorType;
typedef enum {
    NoInterlace = 0,
    Adam7 = 1
} Interlace;
typedef struct {
    uint32_t          width; // Comprimento
    uint32_t         height; // Altura
    Byte              depth; // Profundidade de bits
    Byte              color; // Tipo de cor
    Byte          interlace; // Tipo de intrelaçamento
    Byte             filter; // Tipo de cor
    Byte        compression; // Tipo de compressão
} IHDR;

Assim poderemos usar esses valores mais intuitivos para ler os dados do _*IHDR*_

Execute este código online

E eu sei que agora você deve estar se perguntando como funciona esse lance de estruturação dos dados, e como interpretá-los e enfim… Mas eu vou ficando por aqui e no próximo capítulo lhes mostrarei como vai funcionar isso e indicar para onde vocês devem ir caso queiram adicionar suporte completo as suas bibliotecas.

O código completo está nesse gist citado no início do post.

Eu adoraria seguir com essa loucura e interpretar o IDAT também, mas acredito que cumprimos nosso objetivo, temos um prequeno programinha que consegue interpretar os dados de um arquivo binário em formato PNG (sem ajuda de nenhuma lib dedicada).

Até o próximo post.

© Yaks Souzalinkedin/in/plankiton
github/plankiton