O decoder: abrindo o .n148i e vendo a imagem de volta

O decoder: abrindo o .n148i e vendo a imagem de volta

Olá leitor, seja muito bem vindo de volta a mais uma etapa da nossa jornada aqui no Portal da Micilini! 😊

Este é o décimo sétimo artigo da nossa série, e ele é o momento da verdade. 🥁

No artigo passado, a gente construiu o encoder completo e comprimiu uma imagem de 225 KB num arquivo .n148i de 3 KB.

Foi emocionante... mas eu terminei o artigo com uma confissão desconfortável: o nosso codec era uma via de mão única. A gente sabia guardar a imagem, mas não sabia tirar ela de volta.

Era como ter um cofre sem a chave. Todos aqueles bytes bonitinhos no arquivo, e nenhuma forma de provar que eles realmente contêm a nossa foto.

Hoje a gente forja a chave capaz de abrir o .n148i. 🗝️

E no fim deste artigo, você vai ver com os próprios olhos a imagem saindo do arquivo .n148i, e vai descobrir se todo esse trabalho de dezessete artigos realmente funcionou de verdade rs

Pega o café ☕ e vem comigo fechar o ciclo! 🚀

O plano: desfazer tudo, na ordem inversa

O decoder é, essencialmente, o encoder de trás pra frente. Cada etapa que a gente fez na ida tem uma etapa correspondente na volta. Observe esse exemplo abaixo:

     ENCODER (ida)              │        DECODER (volta)
────────────────────────────────┼────────────────────────────────
1. Ler PPM                      │  1. Ler o cabeçalho .n148i
2. RGB → YCbCr                  │  2. Huffman ao contrário
3. Subsampling 4:2:0            │  3. RLE ao contrário (expandir)
4. Fatiar em blocos 8×8         │  4. Zig-zag ao contrário
5. DCT                          │  5. Dequantização
6. Quantização                  │  6. IDCT (DCT inversa)
7. Zig-zag                      │  7. Remontar os blocos
8. RLE                          │  8. Upsampling do chroma
9. Huffman                      │  9. YCbCr → RGB
10. Escrever o arquivo          │ 10. Salvar o PPM

Reparou algo interessante?

É praticamente uma espécie de espelho perfeito, ou seja, o que estava no topo de um lado está embaixo do outro.

E tem uma coisa importante pra ter em mente: a maioria dessas voltas é perfeita. O Huffman, o RLE, o zig-zag e a IDCT são todos reversíveis, devolvendo exatamente o que entrou.

A única etapa que não volta perfeita é a quantização. Lembra do artigo 10? Quando a gente dividiu e arredondou, então... aquele .38 do coeficiente se perdeu pra sempre 🫠

Então a imagem que vai sair do decoder não vai ser idêntica à original, vai ser uma aproximação, uma aproximação boa, mas ainda assim aproximação...

É por isso que o JPEG (e o nosso N.148i) são chamados de lossy. E a gente vai medir exatamente o tamanho dessa perda com o PSNR, no fim do artigo.

Antes de começar, vamos fazer uma faxina no código

Aqui vem uma lição de engenharia que vale a pena.

O decoder precisa das mesmas tabelas que o encoder: a ordem zig-zag, as tabelas de quantização, as definições de Huffman, precisamos também da DCT (a inversa dela, no caso), que usa a mesma tabela de cossenos.

A tentação mais preguiçosa que poderia nos acometer, é  simplesmente tomarmos a decisão de copiar e colar tudo pro decoder.c. Não faça isso! 🙅  

O problema é sutil e perverso: se um dia você ajustar uma tabela no encoder e esquecer de ajustar no decoder, o codec vai gerar arquivos que ele mesmo não consegue ler direito, e o bug vai ser um inferno de achar, onde no final das contas compila e roda, só que a imagem sai com as cores erradas.

A solução é extrair o que é comum pra módulos compartilhados, por exemplo:

  • tables.h / tables.c: zig-zag, tabelas de quantização, definições de Huffman.
  • dct.h / dct.c: a DCT e a IDCT, que compartilham a mesma tabela de cossenos.

Assim, encoder e decoder bebem da mesma fonte, e é impossível eles discordarem.

Por fim, a nossa estrutura ficará assim:

n148i/
├── src/
│   ├── main.c          ← orquestra tudo
│   ├── header.h/.c     ← o cabeçalho do arquivo
│   ├── ppm.h/.c        ← leitura/escrita PPM, planos, cores
│   ├── tables.h/.c     ← 📋 tabelas compartilhadas (NOVO)
│   ├── dct.h/.c        ← 🌊 DCT e IDCT (NOVO)
│   ├── encoder.h/.c    ← o caminho de ida
│   └── decoder.h/.c    ← 🔓 o caminho de volta (NOVO)
├── images/
│   └── example.ppm
└── output/
    ├── image.n148i     ← o comprimido
    └── decoded.ppm     ← 🎉 a imagem de volta!

O tables.c e o dct.c são basicamente código que já existia, só que mudou de casa (saiu do encoder.c e virou módulo próprio).

Por isso não vou reproduzir eles inteiros aqui — você encontra no repositório. O que interessa é o dct.h, porque ele ganhou uma função nova:

#ifndef DCT_H
#define DCT_H

// Builds the cosine tables. Safe to call more than once.
void init_dct_tables(void);

// Forward DCT: 8x8 pixels -> 8x8 frequency coefficients (article 9)
void dct_block(double block[8][8], double coef[8][8]);

// Inverse DCT: 8x8 coefficients -> 8x8 pixels
void idct_block(double coef[8][8], double block[8][8]);

#endif

Fora a própria implementação da IDCT, que é a novidade de verdade:

static void idct_1d(double in[BLOCK_SIZE], double out[BLOCK_SIZE]) {
    for (int p = 0; p < BLOCK_SIZE; p++) {
        double s = 0.0;
        for (int f = 0; f < BLOCK_SIZE; f++)
            s += alpha[f] * in[f] * cos_table[f][p];
        out[p] = 0.5 * s;
    }
}

void idct_block(double coef[8][8], double block[8][8]) {
    double temp[8][8];
    // Columns first, then rows (the mirror image of the forward pass)
    for (int c = 0; c < 8; c++) {
        double a[8], b[8];
        for (int r = 0; r < 8; r++) a[r] = coef[r][c];
        idct_1d(a, b);
        for (int r = 0; r < 8; r++) temp[r][c] = b[r];
    }
    for (int r = 0; r < 8; r++) {
        double a[8], b[8];
        for (int c = 0; c < 8; c++) a[c] = temp[r][c];
        idct_1d(a, b);
        for (int c = 0; c < 8; c++) block[r][c] = b[c] + 128.0;   // undo shift
    }
}

Repara em duas simetrias bonitas: a DCT faz linhas → colunas, e a IDCT faz colunas → linhas (a ordem inversa). E onde a DCT subtraiu 128 (o level shift), a IDCT soma 128 de volta.

Olha o nosso espelho em ação rs

Problema 1: como decodificar Huffman?

Agora o desafio mais interessante do artigo, e o que mais confunde quem está aprendendo.

Quando a gente escreve um código Huffman, é fácil: pega o símbolo, olha na tabela, escreve os bits. Mas quando a gente lê, tem um problema cabeludo 🤯

Os códigos têm tamanhos diferentes (de 2 a 16 bits). Como é que eu sei onde um código termina e o próximo começa?

Não tem separador entre eles! Os bits vêm todos grudados, num fluxo contínuo. Se eu leio 1 1 0 1 1 ..., será que isso é um código de 2 bits seguido de um de 3? Ou um código de 5 bits? 🤨  

A resposta está numa propriedade mágica do Huffman que a gente viu lá no artigo 6: ele é livre de prefixo (prefix-free). Isso significa que nenhum código é o começo de outro código.

Pensa no que isso garante: se 110 é um código válido, então nenhum código mais longo pode começar com 110. Então, quando eu li 110 e ele bate com um código conhecido, eu tenho certeza absoluta de que aquele é o símbolo, de modo que não existe ambiguidade possível.

Logo, o algoritmo de decodificação é:

  • Leia 1 bit.
  • O que você tem até agora é um código válido? Se sim, achou o símbolo. Pare. ✅
  • Se não, leia mais 1 bit e volte ao passo 2.

Simples assim! A propriedade de prefixo faz todo o trabalho pesado. 💪

O truque canônico (pra não sair procurando na tabela toda)

A forma ingênua de fazer o passo 2 é: a cada bit lido, varrer os 256 símbolos procurando um que bata. Funciona, mas é lento, e você faria centenas de comparações por símbolo.

O JPEG usa um truque muito mais elegante, baseado numa característica dos códigos canônicos (que é como as tabelas são construídas): os códigos de um mesmo tamanho são números consecutivos.

Por exemplo, se existem 5 códigos de 3 bits, eles serão algo como 010, 011, 100, 101, 110, ou seja, 2, 3, 4, 5, 6 em decimal. Consecutivos! 🔢

Então, pra cada tamanho de código, basta guardar três coisinhas:

  • min_code[l]: o menor código de tamanho l.
  • max_code[l]: o maior código de tamanho l.
  • val_index[l]: onde começam os símbolos desse tamanho na lista.

E aí o teste vira uma única comparação: "o que eu li até agora é menor ou igual ao max_code deste tamanho?". Se sim, o símbolo está na posição val_index[l] + (código - min_code[l]). 🚀

De centenas de comparações pra uma. É esse tipo de detalhe que separa um decoder de brinquedo de um decoder de verdade.

Problema 2: desfazendo o truque do sinal

Lembra da codificação de categoria + amplitude do artigo 13? Tinha aquele truque esperto pros números negativos: em vez de gastar um bit pro sinal, os negativos eram guardados como o "complemento" dos positivos.

Por exemplo, na categoria 3:

Valor │ Amplitude (3 bits)
──────┼───────────────────
  -7  │ 000    ← negativos ficam na metade de baixo
  -4  │ 011
   4  │ 100    ← positivos na metade de cima
   7  │ 111

Pra desfazer isso na leitura, a regra é: leia os size bits como um número normal. Se o resultado for menor que a metade do intervalo (ou seja, se o bit mais alto for 0), então era um negativo, e você aplica a correção.

Em código, fica mais ou menos assim:

static int br_read_amplitude(BitReader *r, int size) {
    if (size == 0) return 0;
    int value = 0;
    for (int i = 0; i < size; i++) value = (value << 1) | br_next_bit(r);
    if (value < (1 << (size - 1))) value += 1 - (1 << size);
    return value;
}

Vamos conferir com um exemplo. Lendo 011 na categoria 3:  

value = 3  (que é 011 em binário)
metade = 1 << 2 = 4
3 < 4? Sim! Era negativo.
correção: 3 + 1 - 8 = -4  ✅

E deu exatamente o -4 que a tabela previa. 🎯  

Problema 3: jogando fora o padding

Lembra do artigo 16, quando a imagem não era múltipla de 8 e a gente completou os blocos repetindo o pixel da borda?

Pois é, aqueles pixels inventados também foram comprimidos e estão lá no arquivo. Na hora de decodificar, a gente precisa descartar eles, senão a imagem sairia maior do que deveria (com uma faixa de lixo na borda).

A solução é simples: ao copiar o bloco decodificado de volta pro plano, a gente ignora tudo que cai fora dos limites reais da imagem, como por exemplo:

int x = bx*8 + cc;
int y = by*8 + rr;
if (x >= p->width || y >= p->height) continue;   // 👈 descarta o padding

Uma linha, e o nosso problema está resolvido. É a contraparte exata do plane_sample() que criou o padding na ida.  

Problema 4: devolvendo o tamanho do chroma

No encoder, a gente encolheu os canais Cb e Cr pela metade (4:2:0). Agora precisamos esticar eles de volta ao tamanho original, senão não dá pra combinar com o canal Y.

Esse processo se chama upsampling, e existem várias formas de fazer:

  • 🟦 Vizinho mais próximo (nearest neighbour): cada valor de cor é simplesmente repetido pelos 4 pixels do seu grupo 2×2. Simples e rápido.
  • 🌫️ Interpolação bilinear: calcula valores intermediários suavizados entre os vizinhos. Fica mais bonito, mas dá mais trabalho.

Pro N.148i, vamos começar com o vizinho mais próximo. É o suficiente pra fechar o ciclo, e o olho quase não reclama (justamente porque estamos falando de cor, que é onde a visão é menos exigente).

Na prática, é só uma divisão por 2 nas coordenadas:

int cx = px / scale_x;    // scale_x é 2 quando há subsampling
int cy = py / scale_y;

Os pixels (0,0), (0,1), (1,0) e (1,1) todos acabam lendo o chroma da posição (0,0). Exatamente o inverso da média que fizemos na ida. 🔄

💡 Anota isso como um ponto de melhoria futura: trocar o upsampling por interpolação bilinear reduz um pouco os artefatos de cor nas bordas. É o tipo de refinamento que a gente pode fazer lá no próximo artigo. 😉

Problema 5: YCbCr de volta pra RGB

A última peça. No artigo 2, a gente converteu RGB em YCbCr. Agora precisamos das fórmulas inversas:

R = Y + 1.402 × (Cr - 128)
G = Y - 0.344136 × (Cb - 128) - 0.714136 × (Cr - 128)
B = Y + 1.772 × (Cb - 128)

Repara naqueles - 128: na ida, a gente somou 128 pra deixar Cb e Cr no intervalo 0-255. Agora subtraímos pra voltar ao intervalo com sinal. 🔢

E um detalhe importante: o resultado dessas contas pode estourar os limites (dar -3 ou 260, por exemplo), por causa dos erros acumulados da quantização.

Por isso a gente sempre passa por um clamp_byte() que prende o valor entre 0 e 255. Sem isso, você teria pixels com cores malucas espalhados pela imagem.

O código: decoder.h

Vamos ao código de verdade. Primeiro a interface, que é bem enxuta:

src/decoder.h:

#ifndef DECODER_H
#define DECODER_H

#include "ppm.h"

// Statistics filled in by decode_image()
typedef struct {
    long blocks_y;
    long blocks_cb;
    long blocks_cr;
    long bytes_consumed;
} DecodeStats;

// Decodes a compressed buffer back into three planes.
// The planes are allocated here; the caller must free them.
int decode_image(unsigned char *buffer, long buffer_size,
                 int width, int height, int quality, int chroma,
                 Plane *y, Plane *cb, Plane *cr, DecodeStats *stats);

// Rebuilds an RGB image from the three planes, upsampling the
// chroma planes back to full resolution when needed.
int merge_channels(Plane *y, Plane *cb, Plane *cr, Image *out);

#endif

Aquele campo bytes_consumed nas estatísticas é um detalhe que eu adicionei de propósito: ele conta quantos bytes o decoder realmente leu.

Se esse número bater com o tamanho do payload, é um sinal forte de que a decodificação foi limpa, sem dessincronizar. É o nosso "termômetro de sanidade". 🌡️  

o código: decoder.c

Agora criaremos o motor da volta:

src/decoder.c:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include "decoder.h"
#include "tables.h"
#include "dct.h"

// ============================================================
// HUFFMAN TABLES FOR DECODING
// ============================================================
//
// Decoding needs the opposite of encoding. We read bits one at a
// time and ask: "is what I have so far a complete code?"
//
// The trick (from the JPEG spec) is that canonical Huffman codes
// of the same length are CONSECUTIVE numbers. So for each length
// we only need to know the first code (min_code), the last one
// (max_code) and where its symbols start in the values array.
// Then the test is a single comparison per length.

typedef struct {
    int min_code[17];
    int max_code[17];      // -1 means "no codes of this length"
    int val_index[17];
    const unsigned char *values;
} HuffDecodeTable;

static void build_decode_table(const int bits[17], const unsigned char *values,
                               HuffDecodeTable *table) {
    table->values = values;
    int code = 0, k = 0;
    for (int length = 1; length <= 16; length++) {
        if (bits[length] == 0) {
            table->max_code[length] = -1;         // nothing of this length
        } else {
            table->val_index[length] = k;
            table->min_code[length]  = code;
            code += bits[length];
            k    += bits[length];
            table->max_code[length]  = code - 1;
        }
        code <<= 1;
    }
}

// ============================================================
// BIT READER
// ============================================================

typedef struct {
    unsigned char *buffer;
    long size;
    long byte_pos;
    int  bit_pos;
} BitReader;

static void br_init(BitReader *r, unsigned char *buffer, long size) {
    r->buffer = buffer;
    r->size = size;
    r->byte_pos = 0;
    r->bit_pos = 0;
}

static int br_next_bit(BitReader *r) {
    if (r->byte_pos >= r->size) return 0;        // past the end: feed zeros
    int bit = (r->buffer[r->byte_pos] >> (7 - r->bit_pos)) & 1;
    r->bit_pos++;
    if (r->bit_pos == 8) { r->bit_pos = 0; r->byte_pos++; }
    return bit;
}

// Reads one Huffman symbol, one bit at a time.
static int br_read_symbol(BitReader *r, HuffDecodeTable *table) {
    int code = br_next_bit(r);
    for (int length = 1; length <= 16; length++) {
        if (table->max_code[length] >= 0 && code <= table->max_code[length]) {
            int offset = table->val_index[length] + (code - table->min_code[length]);
            return table->values[offset];
        }
        code = (code << 1) | br_next_bit(r);     // not yet: take one more bit
    }
    return -1;                                    // corrupt stream
}

// Reads 'size' amplitude bits and restores the signed value.
// This undoes the trick from article 13: if the top bit is 0 the
// original number was negative.
static int br_read_amplitude(BitReader *r, int size) {
    if (size == 0) return 0;
    int value = 0;
    for (int i = 0; i < size; i++) value = (value << 1) | br_next_bit(r);
    if (value < (1 << (size - 1))) value += 1 - (1 << size);
    return value;
}

// ============================================================
// DECODE ONE 8x8 BLOCK
// ============================================================

static void decode_block(BitReader *r, double block[8][8], const int quant[8][8],
                         HuffDecodeTable *dc_table, HuffDecodeTable *ac_table,
                         int *dc_prev) {
    int zz[64];
    memset(zz, 0, sizeof(zz));

    // --- DC: read the difference and add it to the previous one ---
    int s = br_read_symbol(r, dc_table);
    if (s < 0) s = 0;
    int diff = br_read_amplitude(r, s);
    zz[0] = *dc_prev + diff;
    *dc_prev = zz[0];

    // --- AC: expand (run, size) pairs back into the 63 slots ---
    int pos = 1;
    while (pos < 64) {
        int symbol = br_read_symbol(r, ac_table);
        if (symbol < 0)    break;
        if (symbol == 0x00) break;                    // EOB: rest stays zero
        if (symbol == 0xF0) { pos += 16; continue; }  // ZRL: 16 zeros

        int run  = symbol >> 4;
        int size = symbol & 0x0F;
        pos += run;                                   // skip the zeros
        if (pos >= 64) break;
        zz[pos] = br_read_amplitude(r, size);
        pos++;
    }

    // --- Dequantize and undo the zig-zag, in one pass ---
    double coef[8][8];
    for (int i = 0; i < 64; i++) {
        int idx = ZIGZAG[i];
        int rr = idx / 8, cc = idx % 8;
        coef[rr][cc] = (double)(zz[i] * quant[rr][cc]);
    }

    // --- Back to pixels ---
    idct_block(coef, block);
}

static long decode_plane(BitReader *r, Plane *p, const int quant[8][8],
                         HuffDecodeTable *dc_table, HuffDecodeTable *ac_table) {
    int blocks_x = (p->width  + 7) / 8;
    int blocks_y = (p->height + 7) / 8;
    int dc_prev = 0;                                  // resets per plane

    for (int by = 0; by < blocks_y; by++) {
        for (int bx = 0; bx < blocks_x; bx++) {
            double block[8][8];
            decode_block(r, block, quant, dc_table, ac_table, &dc_prev);

            // Copy the block back, DISCARDING the padding pixels that
            // fall outside the real image.
            for (int rr = 0; rr < 8; rr++) {
                for (int cc = 0; cc < 8; cc++) {
                    int x = bx*8 + cc;
                    int y = by*8 + rr;
                    if (x >= p->width || y >= p->height) continue;

                    double v = block[rr][cc];
                    if (v < 0.0)   v = 0.0;
                    if (v > 255.0) v = 255.0;
                    p->data[(long) y * p->width + x] = (unsigned char)(v + 0.5);
                }
            }
        }
    }
    return (long) blocks_x * blocks_y;
}

int decode_image(unsigned char *buffer, long buffer_size,
                 int width, int height, int quality, int chroma,
                 Plane *y, Plane *cb, Plane *cr, DecodeStats *stats) {
    init_dct_tables();

    HuffDecodeTable dc_luma, ac_luma, dc_chroma, ac_chroma;
    build_decode_table(BITS_DC_LUMA,   VAL_DC_LUMA,   &dc_luma);
    build_decode_table(BITS_AC_LUMA,   VAL_AC_LUMA,   &ac_luma);
    build_decode_table(BITS_DC_CHROMA, VAL_DC_CHROMA, &dc_chroma);
    build_decode_table(BITS_AC_CHROMA, VAL_AC_CHROMA, &ac_chroma);

    int quant_luma[8][8], quant_chroma[8][8];
    scale_table(Q_LUMA_BASE,   quality, quant_luma);
    scale_table(Q_CHROMA_BASE, quality, quant_chroma);

    // Chroma plane size depends on the subsampling mode in the header
    int chroma_w = (chroma == 2) ? (width  + 1) / 2 : width;
    int chroma_h = (chroma == 2) ? (height + 1) / 2 : height;

    *y  = create_plane(width, height);
    *cb = create_plane(chroma_w, chroma_h);
    *cr = create_plane(chroma_w, chroma_h);
    if (!y->data || !cb->data || !cr->data) return 0;

    BitReader r;
    br_init(&r, buffer, buffer_size);

    // Same order the encoder wrote them: all Y, then all Cb, then all Cr
    stats->blocks_y  = decode_plane(&r, y,  quant_luma,   &dc_luma,   &ac_luma);
    stats->blocks_cb = decode_plane(&r, cb, quant_chroma, &dc_chroma, &ac_chroma);
    stats->blocks_cr = decode_plane(&r, cr, quant_chroma, &dc_chroma, &ac_chroma);

    stats->bytes_consumed = r.byte_pos + (r.bit_pos > 0 ? 1 : 0);
    return 1;
}

// ============================================================
// YCbCr -> RGB, WITH CHROMA UPSAMPLING
// ============================================================

static unsigned char clamp_byte(double v) {
    if (v < 0.0)   return 0;
    if (v > 255.0) return 255;
    return (unsigned char)(v + 0.5);
}

int merge_channels(Plane *y, Plane *cb, Plane *cr, Image *out) {
    int w = y->width, h = y->height;

    out->width  = w;
    out->height = h;
    out->pixels = (unsigned char *) malloc((long) w * h * 3);
    if (!out->pixels) return 0;

    // How many luma pixels share one chroma sample
    int scale_x = (cb->width  < w) ? 2 : 1;
    int scale_y = (cb->height < h) ? 2 : 1;

    for (int py = 0; py < h; py++) {
        for (int px = 0; px < w; px++) {
            double luma = y->data[(long) py * w + px];

            // Nearest-neighbour upsampling: each chroma sample is
            // simply reused by the pixels of its 2x2 group.
            int cx = px / scale_x;
            int cy = py / scale_y;
            double blue_diff = plane_sample(cb, cx, cy) - 128.0;
            double red_diff  = plane_sample(cr, cx, cy) - 128.0;

            // Inverse of the formulas from article 2
            double r = luma + 1.402    * red_diff;
            double g = luma - 0.344136 * blue_diff - 0.714136 * red_diff;
            double b = luma + 1.772    * blue_diff;

            long i = ((long) py * w + px) * 3;
            out->pixels[i + 0] = clamp_byte(r);
            out->pixels[i + 1] = clamp_byte(g);
            out->pixels[i + 2] = clamp_byte(b);
        }
    }
    return 1;
}

Detalhes que valem a pena conferir

A ordem de leitura é sagrada. Repara que o decoder lê os planos na exata mesma ordem que o encoder escreveu: todo o Y, depois todo o Cb, depois todo o Cr. Se você trocar a ordem, o fluxo de bits dessincroniza e sai um borrão colorido.

É por isso que a ordem faz parte da especificação do formato, não sendo um mero detalhe de implementação.

Dequantização e zig-zag inverso na mesma passada. Repara que a gente faz as duas coisas num loop só: coef[rr][cc] = zz[i] * quant[rr][cc]. O índice ZIGZAG[i] já diz onde cada valor da sequência linear vai parar na matriz 8×8. Dois passos em um.

O bit reader que nunca estoura. Se por algum motivo o decoder tentar ler além do fim do buffer, o br_next_bit() devolve zeros em vez de invadir memória. É uma proteção simples contra arquivos corrompidos.

O código: main.c fazendo o ciclo completo

Agora o main.c faz a jornada inteira: comprime, salva, lê de volta, descomprime e compara.

Vou mostrar só a parte nova (a de decodificação e comparação), porque a de encoding é a mesma do artigo 16:

src/main.c:

    // ================= DECODE =================
    printf("=== N.148i decoder ===\n");

    f = fopen(N148I_PATH, "rb");
    if (!f) { printf("Could not open '%s'\n", N148I_PATH); return 1; }

    N148iHeader loaded;
    if (!read_header(f, &loaded)) {
        printf("This is not a valid N148i file!\n");
        fclose(f); return 1;
    }

    printf("Header:  version %d, %u x %u, quality %d, chroma %s\n",
           loaded.version, loaded.width, loaded.height,
           loaded.quality, chroma_name(loaded.chroma));
    printf("Payload: %u bytes\n", loaded.data_size);

    unsigned char *payload = (unsigned char *) malloc(loaded.data_size);
    if (fread(payload, 1, loaded.data_size, f) != loaded.data_size) {
        printf("File is truncated!\n");
        free(payload); fclose(f); return 1;
    }
    fclose(f);

    Plane dy, dcb, dcr;
    DecodeStats dec_stats;
    if (!decode_image(payload, loaded.data_size,
                      loaded.width, loaded.height,
                      loaded.quality, loaded.chroma,
                      &dy, &dcb, &dcr, &dec_stats)) return 1;

    printf("Decoded %ld blocks, consumed %ld of %u bytes\n",
           dec_stats.blocks_y + dec_stats.blocks_cb + dec_stats.blocks_cr,
           dec_stats.bytes_consumed, loaded.data_size);

    Image decoded;
    if (!merge_channels(&dy, &dcb, &dcr, &decoded)) return 1;
    save_ppm(DECODED_PATH, &decoded);
    printf("Wrote %s\n\n", DECODED_PATH);

Sem se esquecer também da nossa função que mede a qualidade do nosso velho conhecido PSNR:

// Peak Signal-to-Noise Ratio between the original and the decoded image
static double compute_psnr(Image *a, Image *b) {
    long n = (long) a->width * a->height * 3;
    double mse = 0.0;
    for (long i = 0; i < n; i++) {
        double d = (double) a->pixels[i] - (double) b->pixels[i];
        mse += d * d;
    }
    mse /= n;
    if (mse == 0.0) return 999.0;
    return 10.0 * log10(255.0 * 255.0 / mse);
}

Repara também no tratamento de erro: se o arquivo não começar com N148I, o programa avisa e para.

E se o fread não conseguir ler todos os bytes que o cabeçalho prometeu, ele detecta que o arquivo está truncado. Aquele campo data_size que a gente adicionou no artigo 16 está pagando dividendos. 💰  

De qualquer forma, se você se sentir perdido, pode dar uma olhada no nosso repositório, de modo a ver os arquivos completos 😄

Compilando (agora com 7 arquivos)

Com os módulos novos, o comando cresceu um pouquinho, sendo assim, com o terminal aberto dentro da pasta n148i/src, você vai precisar rodar:

gcc main.c header.c ppm.c tables.c dct.c encoder.c decoder.c -o n148i -lm
./n148i

Rodando o programa, teremos o seguinte resultado:

=== N.148i encoder ===
Input:   ../images/example.ppm  (320 x 240)
Quality: 50

Encoded 1800 blocks into 3027 bytes
Wrote ../output/image.n148i

=== N.148i decoder ===
Header:  version 2, 320 x 240, quality 50, chroma 4:2:0
Payload: 3027 bytes
Decoded 1800 blocks, consumed 3027 of 3027 bytes
Wrote ../output/decoded.ppm

=== Results ===
Original PPM:   230415 bytes (225.0 KB)
N148i file:       3047 bytes (3.0 KB)
Compression:  75.6:1
PSNR:         33.79 dB

FUNCIONOU! 🎉🎉🎉

Vamos ler esses números com carinho, porque cada linha é uma vitória diferente:

consumed 3027 of 3027 bytes: essa é a linha mais importante de todo o artigo. 🏆

O decoder leu exatamente todos os bytes que o encoder escreveu, nem um a mais, nem um a menos. Isso é a prova de que o fluxo de bits está perfeitamente sincronizado do começo ao fim.

Se tivesse qualquer errinho na decodificação do Huffman, esse número não bateria.

Decoded 1800 blocks: os mesmos 1800 blocos que o encoder produziu (1200 do Y + 300 do Cb + 300 do Cr). ✅

PSNR: 33.79 dB: lembra da escala do artigo 10? Acima de 30 dB é boa qualidade. Estamos em 33,79 dB com uma compressão de 75,6:1. 👏

Visualizando a imagem

Agora vai lá na pasta output e abre o decoded.ppm. (Se o seu visualizador não abrir PPM, converta com magick decoded.ppm decoded.png.)

E aí está ela: a sua imagem, saindo de um arquivo de 3 KB. 🤯

Abre o original e a decodificada lado a lado. À primeira vista, elas parecem idênticas, você precisa olhar bem de perto pra notar diferenças.

E olha que a decodificada passou por: conversão de cor, descarte de 3/4 da informação de cor, DCT, arredondamento na quantização, e todo o caminho de volta.

Aquele monte de teoria dos últimos dezessete artigos acabou de virar uma foto na sua tela. 😍

Brincando com a qualidade (agora dá pra ver rodando de verdade)

Aqui está a parte mais divertida. Agora que temos o decoder, o fator de qualidade deixou de ser um número abstrato, dá pra ver o efeito dele. Mude o #define QUALITY no main.c, recompile e compare os resultados:  

Qualidade │ Tamanho  │ Compressão │  PSNR
──────────┼──────────┼────────────┼──────────
    90    │ 5866 B   │   39,3:1   │ 36,17 dB
    50    │ 3047 B   │   75,6:1   │ 33,79 dB
    20    │ 1670 B   │  138,0:1   │ 31,45 dB
    10    │ 1465 B   │  157,3:1   │ 29,52 dB

Olha o cabo de guerra do artigo 10 acontecendo com números reais! Quanto menor a qualidade, menor o arquivo e menor o PSNR.

E agora vem o melhor: abra a imagem decodificada em qualidade 10 e dê um zoom. Lá estão eles, os famosos blocking artifacts que a gente estudou lá no artigo 10!

Você vai ver claramente os quadradinhos de 8×8, principalmente nas áreas de degradê suave (onde a mudança gradual vira "degraus" de cor) e ao redor das bordas (onde aparecem umas franjas coloridas, o chamado ringing).

Não é bug: é exatamente o comportamento previsto. Quando a quantização é agressiva, sobram tão poucos coeficientes que cada bloco vira quase uma cor chapada, e a fronteira entre eles fica visível.

Ver a teoria se materializar assim é uma das melhores sensações de construir um codec do zero. 🤓

E se o arquivo estiver corrompido?

Bem, se ele estiver corrompido, acho que vale você testar novamente a robustes do decoder. Vou deixar abaixo dois experimentos que você pode seguir.

Teste 1: um arquivo que não é N.148i. Crie um arquivo de texto qualquer, renomeie pra .n148i e tente abrir. O decoder confere a assinatura e recusa educadamente:  

This is not a valid N148i file!

Teste 2: um arquivo truncado. Pegue o seu image.n148i e corte ele pela metade. Agora o cabeçalho diz "tem 3027 bytes de payload", mas o arquivo acabou antes. O fread não consegue ler tudo e o programa detecta:  

File is truncated!

Com isso nós temos duas verificações simples que evitam que o programa tente interpretar lixo e produza uma imagem maluca (ou trave).

É importante ressaltar que todo formato de arquivo sério faz esse tipo de validação 😄

O que ainda falta?

Sendo transparente sobre o estado atual do N.148i:

O que funciona ✅

  • Ciclo completo: PPM → .n148i → PPM, com todas as etapas.
  • Padding correto na ida e descarte correto na volta.
  • Subsampling 4:2:0 com upsampling na volta.
  • Validação de assinatura e detecção de arquivo truncado.
  • Fator de qualidade controlando o trade-off tamanho × qualidade.

O que ainda não existe 🔜

  • Benchmark sério contra JPEG, PNG e WebP.
  • Medição com SSIM e VMAF (por enquanto só temos PSNR).
  • Upsampling bilinear (usamos vizinho mais próximo) — é o próximo artigo!
  • Tabelas de Huffman customizadas por imagem.
  • Modos 4:4:4 e 4:2:2 expostos no programa.

Repositório no GitHub

E sim, você pode acompanhar a evolução desse CODEC de imagens em um repositório no GitHub, commit por commit 👋

Segue o link abaixo do commit desse artigo, onde implementamos o decoder completo, a IDCT, a decodificação Huffman e a reconstrução da imagem:

Resumo

Recapitulando este artigo histórico:

  • O decoder é o espelho do encoder: cada etapa da ida tem a sua correspondente na volta, na ordem inversa.
  • Extraímos tables.h/.c e dct.h/.c como módulos compartilhados, pra encoder e decoder nunca discordarem sobre as tabelas.
  • A decodificação Huffman funciona graças à propriedade de prefixo: nenhum código é o começo de outro, então dá pra ler bit a bit até fechar um código válido. O truque canônico (min_code/max_code) reduz a busca a uma comparação por tamanho.
  • A leitura da amplitude desfaz o truque do sinal: se o valor lido for menor que a metade do intervalo, era negativo.
  • O padding dos blocos da borda é descartado na volta com um simples teste de limites.
  • O upsampling do chroma (vizinho mais próximo) devolve os canais Cb e Cr ao tamanho original.
  • As fórmulas inversas trazem YCbCr de volta pra RGB, sempre com clamp pra prender os valores em 0-255.
  • O resultado: 3027 de 3027 bytes consumidos, 1800 blocos decodificados, PSNR de 33,79 dB com compressão de 75,6:1, e a imagem aparecendo na tela.

E é isso: o N.148i está completo. 🎊

Ele comprime, ele descomprime, e a imagem volta. Aquele cofre agora tem chave.

Pensa no caminho que a gente percorreu: começamos no artigo 1 falando o que é um pixel, e agora temos um codec de imagem funcional, escrito do zero em C, com formato de arquivo autoral. Isso é coisa que pouquíssima gente faz. 💪

Até a próxima! 👋

Criadores de Conteúdo

Foto do William Lima
William Lima
Fundador da Micilini

Inventor nato, escreve conteudos de programação para o portal da micilini.

Torne-se um MIC 🤖

Mais de 100 mic's já estão conectados na plataforma.