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 de 3 KB..n148i
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 , e vai descobrir se todo esse trabalho de dezessete artigos realmente funcionou de verdade rs.n148i
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 PPMReparou 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 do coeficiente se perdeu pra sempre 🫠.38
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 . Não faça isso! 🙅 decoder.c
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: zig-zag, tabelas de quantização, definições de Huffman.tables.c/dct.h: a DCT e a IDCT, que compartilham a mesma tabela de cossenos.dct.c
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 e o tables.c são basicamente código que já existia, só que mudou de casa (saiu do dct.c e virou módulo próprio).encoder.c
Por isso não vou reproduzir eles inteiros aqui — você encontra no repositório. O que interessa é o , porque ele ganhou uma função nova:dct.h
#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]);
#endifFora 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 , será que isso é um código de 2 bits seguido de um de 3? Ou um código de 5 bits? 🤨 1 1 0 1 1 ...
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 é 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.110
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, ou seja, 2, 3, 4, 5, 6 em decimal. Consecutivos! 🔢110
Então, pra cada tamanho de código, basta guardar três coisinhas:
: o menor código de tamanhomin_code[l]l.: o maior código de tamanhomax_code[l]l.: onde começam os símbolos desse tamanho na lista.val_index[l]
E aí o teste vira uma única comparação: "o que eu li até agora é menor ou igual ao deste tamanho?". Se sim, o símbolo está na posição max_code. 🚀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 │ 111Pra desfazer isso na leitura, a regra é: leia os 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.size
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 na categoria 3: 011
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 que a tabela previa. 🎯 -4
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 paddingUma linha, e o nosso problema está resolvido. É a contraparte exata do que criou o padding na ida. plane_sample()
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 : na ida, a gente somou - 128128 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 que prende o valor entre clamp_byte()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);
#endifAquele campo nas estatísticas é um detalhe que eu adicionei de propósito: ele conta quantos bytes o decoder realmente leu.bytes_consumed
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ó: . O índice coef[rr][cc] = zz[i] * quant[rr][cc] já diz onde cada valor da sequência linear vai parar na matriz 8×8. Dois passos em um.ZIGZAG[i]
O bit reader que nunca estoura. Se por algum motivo o decoder tentar ler além do fim do buffer, o devolve zeros em vez de invadir memória. É uma proteção simples contra arquivos corrompidos.br_next_bit()
O código: main.c fazendo o ciclo completo
Agora o faz a jornada inteira: comprime, salva, lê de volta, descomprime e compara.main.c
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 não conseguir ler todos os bytes que o cabeçalho prometeu, ele detecta que o arquivo está truncado. Aquele campo fread que a gente adicionou no artigo 16 está pagando dividendos. 💰 data_size
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
./n148iRodando 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 dBFUNCIONOU! 🎉🎉🎉
Vamos ler esses números com carinho, porque cada linha é uma vitória diferente:
: essa é a linha mais importante de todo o artigo. 🏆consumed 3027 of 3027 bytes
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.
: os mesmos 1800 blocos que o encoder produziu (1200 do Y + 300 do Cb + 300 do Cr). ✅Decoded 1800 blocks
: 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. 👏PSNR: 33.79 dB
Visualizando a imagem
Agora vai lá na pasta output e abre o . (Se o seu visualizador não abrir PPM, converta com decoded.ppm.)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 no #define QUALITY, recompile e compare os resultados: main.c
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 dBOlha 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 e tente abrir. O decoder confere a assinatura e recusa educadamente: .n148i
This is not a valid N148i file!Teste 2: um arquivo truncado. Pegue o seu e corte ele pela metade. Agora o cabeçalho diz "tem 3027 bytes de payload", mas o arquivo acabou antes. O image.n148i não consegue ler tudo e o programa detecta: fread
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 →
→ PPM, com todas as etapas..n148i - 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
etables.h/.ccomo módulos compartilhados, pra encoder e decoder nunca discordarem sobre as tabelas.dct.h/.c - 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
pra prender os valores em 0-255.clamp - 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! 👋

