O encoder completo: comprimindo uma imagem inteira no N.148i
Olá leitor, seja muito bem vindo de volta a mais uma etapa da nossa jornada aqui no Portal da Micilini! 😊
Este é o décimo sexto artigo da nossa série, e ele é o grande encontro de tudo. 🎉
Deixa eu te lembrar onde a gente está.
Na Fase 3, aprendemos a comprimir um bloco de 8×8 pixels, passando por DCT, quantização, zig-zag, RLE e Huffman, e transformamos aquele bloco em 12 bytes.
Na Fase 4, criamos o cabeçalho do N.148i (artigo 14) e aprendemos a carregar uma imagem de verdade e separar nos canais de cor (artigo 15).
Ou seja: temos todas as peças na mesa, e hoje a gente monta o quebra-cabeça completo.
No fim deste artigo, você vai rodar um programa que pega a sua foto, passa ela inteira pelo pipeline (todos os blocos, todos os canais) e escreve um arquivo de verdade, com a imagem comprimida dentro. 🤯.n148i
Pega um café bem reforçado ☕, porque este é o artigo mais denso de código da série. Mas fica tranquilo porque quase tudo você já conhece, a gente só vai juntar as peças.
O plano de voo
Antes de escrever uma linha, vamos entender o que precisa acontecer...
A ideia é que o nosso encoder tem que executar os seguintes passos:
- 📥 Ler o
(isso já sabemos, artigo 15).example.ppm
- 🎨 Separar em Y, Cb, Cr (isso também já sabemos).
- ✂️ Fatiar cada canal em blocos 8×8.
- 🔄 Passar cada bloco por DCT → quantização → zig-zag → RLE → Huffman.
- 📦 Juntar todos os bits num único fluxo.
- 💾 Escrever o cabeçalho + esse fluxo num arquivo
..n148i
Os passos 1, 2 e 4 a gente já domina. A novidade está nos passos 3, 5 e 6, e é aí que aparecem uns problemas práticos que a gente ainda não tinha encarado.
Vou te apresentar cada um deles antes de mostrar o código, porque entender o problema é metade da solução.
Problema 1: e se a imagem não for múltipla de 8?
Aqui está a primeira pedra no caminho, e é uma daquelas que quebra a cara de quem não pensa nela.
O nosso pipeline trabalha com blocos de exatamente 8×8 pixels. Mas a sua foto pode ter 317×241 pixels, por exemplo. E 317 não é divisível por 8! 😱
Fazendo a conta: 317 ÷ 8 = 39,625. Ou seja, cabem 39 blocos completos na horizontal, e sobra uma tirinha de 5 pixels, e isso pode levantar uma grande questão: o que a gente faz com essa sobra?
Pois bem, existem três abordagens possíveis:
- ❌ Ignorar a sobra: isso é péssimo, você perderia uma faixa da imagem.
- ❌ Fazer blocos menores nas bordas: ai você complica tudo, porque a DCT 8×8 exige 8×8.
- ✅ Completar o bloco (padding): com isso a gente arredonda pra cima (40 blocos) e "inventa" os pixels que faltam.
A terceira é o caminho que todos os codecs de verdade usam. Mas aí vem a pergunta: inventar como? 🤔
A solução mais esperta é repetir o pixel da borda. Se a imagem acaba na coluna 316, então as colunas 317, 318 e 319 recebem uma cópia do valor da coluna 316.
Por que repetir a borda é melhor do que, digamos, preencher com preto? Porque um bloco onde metade é foto e metade é preto teria uma transição brutal no meio, e transição brutal significa altas frequências, que é exatamente o que a DCT odeia (comprime mal e cria artefatos feios).
Repetindo a borda, o bloco fica suave, comprime bem e não polui a imagem. 🎯
E o mais bonito: dá pra implementar isso sem alocar memória extra nenhuma. Lembra da função que a gente criou no artigo 15? Ela já limita as coordenadas às bordas do plano:plane_sample()
int plane_sample(Plane *p, int x, int y) {
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x >= p->width) x = p->width - 1; // 👈 aqui mora o padding
if (y >= p->height) y = p->height - 1;
return p->data[(long) y * p->width + x];
}Se você pedir o pixel da coluna 319 numa imagem de 317 colunas, ela devolve o da coluna 316. O padding acontece de graça, só de ler os pixels por essa função.
Problema 2: os canais têm tamanhos diferentes!
Lembra do Chroma Subsampling do artigo 3? Chegou a hora de usar ele de verdade.
No modo 4:2:0, a gente guarda um valor de cor para cada bloco de 2×2 pixels. Ou seja, os canais Cb e Cr ficam com metade da largura e metade da altura do canal Y.
Numa imagem de 320×240:
Canal Y (brilho): 320 x 240 → 40 x 30 blocos = 1200 blocos
Canal Cb (cor): 160 x 120 → 20 x 15 blocos = 300 blocos
Canal Cr (cor): 160 x 120 → 20 x 15 blocos = 300 blocos
Total: 1800 blocosRepara na economia! 💰
Sem subsampling, os três canais teriam 1200 blocos cada, totalizando 3600. Com 4:2:0, caímos pra 1800 , metade do trabalho, e o olho quase não percebe (você viu isso com os próprios olhos no artigo 15, quando abriu os canais Cb e Cr e viu que eram borrados mesmo).
Pra fazer o downsample, a gente pega cada quadradinho de 2×2 pixels e tira a média dos quatro valores de cor, isso acontece de forma simples e eficaz. 🧮
Um detalhe fino: e se a imagem tiver largura ímpar, tipo 317? Aí a última coluna de chroma não tem um par pra fazer média.
A solução é a mesma de antes: repetir o valor existente. No código isso aparece naquele . 🔧(x0 + 1 < w) ? x0 + 1 : x0
Problema 3: o DC diferencial precisa de cuidado
Lembra do DPCM do artigo 13? A gente guarda o DC como a diferença em relação ao bloco anterior, porque blocos vizinhos têm brilhos parecidos.
Mas agora que temos três canais, surge uma pergunta importante: quando a gente termina o canal Y e começa o canal Cb, o "bloco anterior" é o último bloco do Y? 🤨
Não! E isso seria um erro sério. Os canais são grandezas completamente diferentes, o Y é brilho (que numa foto clara fica em torno de valores altos), enquanto o Cb e Cr são cor (que giram em torno de 128, o "neutro").
Usar o último DC do Y como referência pro primeiro bloco do Cb geraria uma diferença gigante e sem sentido, desperdiçando bits.
A regra é: o preditor de DC reinicia em zero no começo de cada canal. 🔄
No código, isso é aquela linha inocente dentro da função que codifica um plano:
int dc_prev = 0; // reinicia pra cada plano!É uma linha só, mas se você esquecer dela, o arquivo fica maior e a imagem sai com os tons errados nos canais de cor...
Problema 4: em que ordem gravar os dados?
Agora temos 1800 blocos comprimidos. Em que ordem eles vão pro arquivo?
Existem duas filosofias:
Planar (o que vamos usar): grava todos os blocos do Y, depois todos do Cb, depois todos do Cr:
[Y Y Y Y Y ... Y] [Cb Cb ... Cb] [Cr Cr ... Cr]Entrelaçado por MCU (o que o JPEG faz): agrupa em "unidades mínimas" (MCU), cada uma com os blocos de Y e os de croma daquela região:
[Y Y Y Y Cb Cr] [Y Y Y Y Cb Cr] [Y Y Y Y Cb Cr] ...
↑ uma MCU ↑ outra MCUO JPEG usa o entrelaçado porque isso permite ir desenhando a imagem conforme ela é baixada (útil na internet discada dos anos 90) e porque o decoder precisa de menos memória de uma vez.
Pro N.148i, eu escolhi o planar, por dois motivos: é bem mais simples de entender e implementar, e a diferença de tamanho final é desprezível (os mesmos dados, em outra ordem).
Anota isso, porque é uma decisão de design do nosso formato, e é justamente por termos um formato autoral que podemos fazer essas escolhas. Se um dia quisermos suportar carregamento progressivo, a gente muda pro entrelaçado e bumpa a versão do formato. 😉
Problema 5: o cabeçalho precisa crescer
E aqui vem um momento bonito da nossa jornada.
No artigo 14, a gente colocou um campo versão no cabeçalho, e eu disse: "é pra no futuro a gente poder mudar coisas sem quebrar arquivos antigos".
Pois bem, o futuro chegou! 😄
O nosso cabeçalho tinha 16 bytes e não guardava quantos bytes de dados comprimidos vêm depois dele. Sem essa informação, o decoder não sabe onde os dados terminam.
Então, para fazer isso funcionar, nós vamos adicionar um campo novo, de 4 bytes:
Campo │ Tamanho │ Exemplo
─────────────┼──────────┼──────────────
Assinatura │ 5 bytes │ "N148I"
Versão │ 1 byte │ 2 👈 subiu de 1 pra 2!
Largura │ 4 bytes │ 320
Altura │ 4 bytes │ 240
Qualidade │ 1 byte │ 50
Chroma │ 1 byte │ 2 (= 4:2:0)
Tam. dados │ 4 bytes │ 3027 👈 NOVO!
─────────────┴──────────┴──────────────
Total: 20 bytes de cabeçalhoRepara que a gente subiu a versão de 1 para 2. Assim, um leitor futuro pode olhar esse campo e saber exatamente qual formato está lendo.
Aquele campinho de 1 byte que parecia bobagem no artigo 14 acabou de justificar a sua existência. 🎉
💡 Na prática, você poderia argumentar que o tamanho dos dados é dedutível (é "tudo até o fim do arquivo"). E é verdade! Mas guardar explicitamente é mais robusto: permite detectar arquivo truncado, e no futuro deixa a porta aberta pra guardar outras coisas depois dos dados de imagem (metadados, miniatura, etc.).
Organizando o projeto em módulos
O nosso código cresceu bastante, e enfiar tudo num de 500 linhas seria uma tortura.main.c
Sendo assim, chegou a hora de cumprir a promessa do artigo 14 e separar tudo em pequenos módulos.
Onde cada módulo é uma dupla de arquivos, por exemplo:
- 📄 O
(header, ou cabeçalho): é a "ementa": diz o que o módulo oferece, sem mostrar como faz..h
- 📄 O
(implementação): é a "cozinha": onde o trabalho realmente acontece..c
Assim, quando o quer usar uma função de outro módulo, ele só precisa incluir o main.c.h correspondente. Fica organizado e fácil de navegar. 🧹
Isso significa que a nossa nova estrutura de arquivos ficará organizada dessa forma:
n148i/
├── src/
│ ├── main.c ← orquestra tudo
│ ├── header.h ← definições do cabeçalho .n148i
│ ├── header.c ← escrita/leitura do cabeçalho
│ ├── ppm.h ← definições de imagem e planos
│ ├── ppm.c ← leitura PPM, YCbCr, subsampling
│ ├── encoder.h ← definições do encoder
│ └── encoder.c ← DCT, quantização, zig-zag, RLE, Huffman
├── images/
│ └── example.ppm ← a sua imagem
└── output/
└── image.n148i ← o resultado!Existe uma coisinha que você vai ver nos arquivos : aquelas linhas estranhas no começo e no fim:.h
#ifndef HEADER_H
#define HEADER_H
... conteúdo ...
#endifIsso se chama include guard, e serve pra evitar que o mesmo arquivo seja incluído duas vezes (o que daria erro de "definido duas vezes").
Na verdade isso se trata de um padrão universal em C, sendo assim, sempre coloque esses includes nos seus . .h
O código: header.h e header.c
Vamos começar pelo módulo mais simples, que é a evolução do que fizemos no artigo 14.
src/header.h:
#ifndef HEADER_H
#define HEADER_H
#include <stdio.h>
#include <stdint.h>
#define N148I_MAGIC "N148I"
#define N148I_MAGIC_LEN 5
#define N148I_VERSION 2
// Chroma subsampling modes
#define CHROMA_444 0
#define CHROMA_420 2
typedef struct {
uint8_t version;
uint32_t width;
uint32_t height;
uint8_t quality;
uint8_t chroma;
uint32_t data_size; // how many bytes of compressed data follow
} N148iHeader;
void write_u8(FILE *f, uint8_t v);
void write_u32(FILE *f, uint32_t v);
uint8_t read_u8(FILE *f);
uint32_t read_u32(FILE *f);
int write_header(FILE *f, N148iHeader *h);
int read_header(FILE *f, N148iHeader *h);
const char* chroma_name(uint8_t c);
#endifsrc/header.c:
#include <string.h>
#include "header.h"
void write_u8(FILE *f, uint8_t v) {
fputc(v, f);
}
void write_u32(FILE *f, uint32_t v) {
fputc((v ) & 0xFF, f); // little-endian, byte by byte
fputc((v >> 8) & 0xFF, f);
fputc((v >> 16) & 0xFF, f);
fputc((v >> 24) & 0xFF, f);
}
uint8_t read_u8(FILE *f) {
return (uint8_t) fgetc(f);
}
uint32_t read_u32(FILE *f) {
uint32_t b0 = fgetc(f);
uint32_t b1 = fgetc(f);
uint32_t b2 = fgetc(f);
uint32_t b3 = fgetc(f);
return b0 | (b1 << 8) | (b2 << 16) | (b3 << 24);
}
int write_header(FILE *f, N148iHeader *h) {
fwrite(N148I_MAGIC, 1, N148I_MAGIC_LEN, f);
write_u8 (f, h->version);
write_u32(f, h->width);
write_u32(f, h->height);
write_u8 (f, h->quality);
write_u8 (f, h->chroma);
write_u32(f, h->data_size);
return 1;
}
int read_header(FILE *f, N148iHeader *h) {
char magic[6] = {0};
if (fread(magic, 1, N148I_MAGIC_LEN, f) != N148I_MAGIC_LEN) return 0;
if (memcmp(magic, N148I_MAGIC, N148I_MAGIC_LEN) != 0) return 0;
h->version = read_u8(f);
h->width = read_u32(f);
h->height = read_u32(f);
h->quality = read_u8(f);
h->chroma = read_u8(f);
h->data_size = read_u32(f);
return 1;
}
const char* chroma_name(uint8_t c) {
switch (c) {
case CHROMA_444: return "4:4:4";
case 1: return "4:2:2";
case CHROMA_420: return "4:2:0";
default: return "unknown";
}
}Repara que eu já escrevi a função também, mesmo que o encoder não precise dela.read_header()
A grande verdade é que ela vai ser essencial no próximo artigo, quando construirmos o decoder. 😉
O código: ppm.h e ppm.c
Esse módulo é a evolução do artigo 15. A grande novidade é a função , que agora faz o subsampling 4:2:0 de verdade. split_channels()
src/ppm.h:
#ifndef PPM_H
#define PPM_H
// An RGB image loaded from a PPM file
typedef struct {
int width;
int height;
unsigned char *pixels; // RGB interleaved: R,G,B,R,G,B...
} Image;
// A single 8-bit channel (Y, Cb or Cr). Chroma planes can be
// smaller than the image when subsampling is enabled.
typedef struct {
int width;
int height;
unsigned char *data;
} Plane;
int load_ppm(const char *path, Image *img);
void free_image(Image *img);
Plane create_plane(int width, int height);
void free_plane(Plane *p);
// Reads a pixel, clamping coordinates to the plane edges.
// This is what gives us "free" padding on partial blocks.
int plane_sample(Plane *p, int x, int y);
// Splits an RGB image into Y, Cb, Cr planes.
// subsample != 0 halves the chroma planes (4:2:0).
int split_channels(Image *img, Plane *y, Plane *cb, Plane *cr, int subsample);
int save_pgm(const char *path, Plane *p);
#endifsrc/ppm.c:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "ppm.h"
// ---- PPM header parsing helpers (from article 15) ----
static void skip_whitespace_and_comments(FILE *f) {
int c;
for (;;) {
c = fgetc(f);
if (c == '#') {
while (c != '\n' && c != EOF) c = fgetc(f);
} else if (c == ' ' || c == '\t' || c == '\n' || c == '\r') {
continue;
} else {
ungetc(c, f);
return;
}
}
}
static int read_number(FILE *f) {
skip_whitespace_and_comments(f);
int value = 0, c, digits = 0;
while ((c = fgetc(f)) != EOF && c >= '0' && c <= '9') {
value = value * 10 + (c - '0');
digits++;
}
return digits ? value : -1;
}
int load_ppm(const char *path, Image *img) {
FILE *f = fopen(path, "rb");
if (!f) {
printf("Could not open '%s'. Is the file in the images/ folder?\n", path);
return 0;
}
char magic[3] = {0};
if (fread(magic, 1, 2, f) != 2) { fclose(f); return 0; }
if (magic[0] != 'P' || magic[1] != '6') {
printf("Not a binary PPM (P6). Found '%s'.\n", magic);
fclose(f); return 0;
}
int width = read_number(f);
int height = read_number(f);
int maxval = read_number(f);
if (width <= 0 || height <= 0 || maxval != 255) {
printf("Unsupported PPM header.\n");
fclose(f); return 0;
}
long byte_count = (long) width * height * 3;
unsigned char *pixels = (unsigned char *) malloc(byte_count);
if (!pixels) { fclose(f); return 0; }
if (fread(pixels, 1, byte_count, f) != (size_t) byte_count) {
printf("Pixel data ended too early.\n");
free(pixels); fclose(f); return 0;
}
fclose(f);
img->width = width;
img->height = height;
img->pixels = pixels;
return 1;
}
void free_image(Image *img) {
free(img->pixels);
img->pixels = NULL;
}
Plane create_plane(int width, int height) {
Plane p;
p.width = width;
p.height = height;
p.data = (unsigned char *) malloc((long) width * height);
return p;
}
void free_plane(Plane *p) {
free(p->data);
p->data = NULL;
}
// Clamping the coordinates IS the padding for partial blocks.
int plane_sample(Plane *p, int x, int y) {
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x >= p->width) x = p->width - 1;
if (y >= p->height) y = p->height - 1;
return p->data[(long) y * p->width + x];
}
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 split_channels(Image *img, Plane *y, Plane *cb, Plane *cr, int subsample) {
int w = img->width, h = img->height;
long n = (long) w * h;
*y = create_plane(w, h);
// Build full-resolution chroma first, then average it down if needed
unsigned char *cb_full = (unsigned char *) malloc(n);
unsigned char *cr_full = (unsigned char *) malloc(n);
if (!y->data || !cb_full || !cr_full) return 0;
for (long i = 0; i < n; i++) {
double r = img->pixels[i * 3 + 0];
double g = img->pixels[i * 3 + 1];
double b = img->pixels[i * 3 + 2];
y->data[i] = clamp_byte( 0.299000*r + 0.587000*g + 0.114000*b);
cb_full[i] = clamp_byte(-0.168736*r - 0.331264*g + 0.500000*b + 128.0);
cr_full[i] = clamp_byte( 0.500000*r - 0.418688*g - 0.081312*b + 128.0);
}
if (!subsample) {
*cb = create_plane(w, h);
*cr = create_plane(w, h);
memcpy(cb->data, cb_full, n);
memcpy(cr->data, cr_full, n);
} else {
// 4:2:0 — one chroma sample for every 2x2 block of pixels
int cw = (w + 1) / 2;
int ch = (h + 1) / 2;
*cb = create_plane(cw, ch);
*cr = create_plane(cw, ch);
for (int yy = 0; yy < ch; yy++) {
for (int xx = 0; xx < cw; xx++) {
int x0 = xx * 2, y0 = yy * 2;
int x1 = (x0 + 1 < w) ? x0 + 1 : x0; // clamp on odd sizes
int y1 = (y0 + 1 < h) ? y0 + 1 : y0;
int sum_cb = cb_full[(long) y0*w + x0] + cb_full[(long) y0*w + x1]
+ cb_full[(long) y1*w + x0] + cb_full[(long) y1*w + x1];
int sum_cr = cr_full[(long) y0*w + x0] + cr_full[(long) y0*w + x1]
+ cr_full[(long) y1*w + x0] + cr_full[(long) y1*w + x1];
cb->data[(long) yy*cw + xx] = (unsigned char)((sum_cb + 2) / 4);
cr->data[(long) yy*cw + xx] = (unsigned char)((sum_cr + 2) / 4);
}
}
}
free(cb_full);
free(cr_full);
return 1;
}
int save_pgm(const char *path, Plane *p) {
FILE *f = fopen(path, "wb");
if (!f) return 0;
fprintf(f, "P5\n%d %d\n255\n", p->width, p->height);
fwrite(p->data, 1, (long) p->width * p->height, f);
fclose(f);
return 1;
}💡 Repara naquele ( no downsample. Por que o sum_cb + 2) / 4+ 2? É pra arredondar em vez de truncar. Dividindo 4 valores por 4, somar metade do divisor antes garante que 2,5 vire 3 e não 2.
É um truquezinho clássico de aritmética inteira. 🧮
O código: encoder.h e encoder.c
Agora chegou a parte de montarmos o motor em si...
Esse é o arquivo maior, mas quase tudo aqui você já viu nos artigos 9 a 13.
src/encoder.h:
#ifndef ENCODER_H
#define ENCODER_H
#include "ppm.h"
// Statistics filled in by encode_image(), so main.c can report them
typedef struct {
long blocks_y;
long blocks_cb;
long blocks_cr;
long data_size; // compressed bytes produced
} EncodeStats;
// Encodes the three planes into a freshly allocated buffer.
// *out_buffer must be freed by the caller.
int encode_image(Plane *y, Plane *cb, Plane *cr, int quality,
unsigned char **out_buffer, EncodeStats *stats);
#endifOlha que interface enxuta! 😍
O só precisa saber que existe uma função main.c que recebe os três planos e devolve um buffer de bytes.encode_image()
Toda a complexidade fica escondida no , e é pra isso que servem os módulos. .c
src/encoder.c: (esse é longo, mas vamos com calma)
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>
#include "encoder.h"
#define BLOCK_SIZE 8
#define PI 3.14159265358979323846
// ============================================================
// DCT (article 9)
// ============================================================
static double cos_table[BLOCK_SIZE][BLOCK_SIZE];
static double alpha[BLOCK_SIZE];
static int tables_ready = 0;
static void init_dct_tables(void) {
if (tables_ready) return;
for (int f = 0; f < BLOCK_SIZE; f++) {
alpha[f] = (f == 0) ? (1.0 / sqrt(2.0)) : 1.0;
for (int p = 0; p < BLOCK_SIZE; p++)
cos_table[f][p] = cos((2.0*p + 1.0) * f * PI / (2.0*BLOCK_SIZE));
}
tables_ready = 1;
}
static void dct_1d(double in[BLOCK_SIZE], double out[BLOCK_SIZE]) {
for (int f = 0; f < BLOCK_SIZE; f++) {
double s = 0.0;
for (int p = 0; p < BLOCK_SIZE; p++) s += in[p] * cos_table[f][p];
out[f] = 0.5 * alpha[f] * s;
}
}
static void dct_block(double block[8][8], double coef[8][8]) {
double temp[8][8];
for (int r = 0; r < 8; r++) {
double a[8], b[8];
for (int c = 0; c < 8; c++) a[c] = block[r][c] - 128.0; // level shift
dct_1d(a, b);
for (int c = 0; c < 8; c++) temp[r][c] = b[c];
}
for (int c = 0; c < 8; c++) {
double a[8], b[8];
for (int r = 0; r < 8; r++) a[r] = temp[r][c];
dct_1d(a, b);
for (int r = 0; r < 8; r++) coef[r][c] = b[r];
}
}
// ============================================================
// QUANTIZATION TABLES (article 10)
// ============================================================
static const int Q_LUMA_BASE[8][8] = {
{16, 11, 10, 16, 24, 40, 51, 61}, {12, 12, 14, 19, 26, 58, 60, 55},
{14, 13, 16, 24, 40, 57, 69, 56}, {14, 17, 22, 29, 51, 87, 80, 62},
{18, 22, 37, 56, 68,109,103, 77}, {24, 35, 55, 64, 81,104,113, 92},
{49, 64, 78, 87,103,121,120,101}, {72, 92, 95, 98,112,100,103, 99}
};
static const int Q_CHROMA_BASE[8][8] = {
{17, 18, 24, 47, 99, 99, 99, 99}, {18, 21, 26, 66, 99, 99, 99, 99},
{24, 26, 56, 99, 99, 99, 99, 99}, {47, 66, 99, 99, 99, 99, 99, 99},
{99, 99, 99, 99, 99, 99, 99, 99}, {99, 99, 99, 99, 99, 99, 99, 99},
{99, 99, 99, 99, 99, 99, 99, 99}, {99, 99, 99, 99, 99, 99, 99, 99}
};
static void scale_table(const int base[8][8], int quality, int out[8][8]) {
if (quality <= 0) quality = 1;
if (quality > 100) quality = 100;
int factor = (quality < 50) ? (5000 / quality) : (200 - quality * 2);
for (int r = 0; r < 8; r++)
for (int c = 0; c < 8; c++) {
int v = (base[r][c] * factor + 50) / 100;
if (v < 1) v = 1;
if (v > 255) v = 255;
out[r][c] = v;
}
}
// ============================================================
// ZIG-ZAG ORDER (article 12)
// ============================================================
static const int ZIGZAG[64] = {
0, 1, 8, 16, 9, 2, 3, 10, 17, 24, 32, 25, 18, 11, 4, 5,
12, 19, 26, 33, 40, 48, 41, 34, 27, 20, 13, 6, 7, 14, 21, 28,
35, 42, 49, 56, 57, 50, 43, 36, 29, 22, 15, 23, 30, 37, 44, 51,
58, 59, 52, 45, 38, 31, 39, 46, 53, 60, 61, 54, 47, 55, 62, 63
};
// ============================================================
// HUFFMAN TABLES (article 13) — now also the chroma ones
// ============================================================
static const int BITS_DC_LUMA[17] = {0, 0,1,5,1,1,1,1,1,1,0,0,0,0,0,0,0};
static const unsigned char VAL_DC_LUMA[12] = {0,1,2,3,4,5,6,7,8,9,10,11};
static const int BITS_DC_CHROMA[17] = {0, 0,3,1,1,1,1,1,1,1,1,1,0,0,0,0,0};
static const unsigned char VAL_DC_CHROMA[12] = {0,1,2,3,4,5,6,7,8,9,10,11};
static const int BITS_AC_LUMA[17] = {0, 0,2,1,3,3,2,4,3,5,5,4,4,0,0,1,125};
static const unsigned char VAL_AC_LUMA[162] = {
0x01,0x02,0x03,0x00,0x04,0x11,0x05,0x12,0x21,0x31,0x41,0x06,0x13,0x51,0x61,0x07,
0x22,0x71,0x14,0x32,0x81,0x91,0xa1,0x08,0x23,0x42,0xb1,0xc1,0x15,0x52,0xd1,0xf0,
0x24,0x33,0x62,0x72,0x82,0x09,0x0a,0x16,0x17,0x18,0x19,0x1a,0x25,0x26,0x27,0x28,
0x29,0x2a,0x34,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,0x49,
0x4a,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5a,0x63,0x64,0x65,0x66,0x67,0x68,0x69,
0x6a,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x83,0x84,0x85,0x86,0x87,0x88,0x89,
0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,0xa4,0xa5,0xa6,0xa7,
0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,0xb5,0xb6,0xb7,0xb8,0xb9,0xba,0xc2,0xc3,0xc4,0xc5,
0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,0xe1,0xe2,
0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf1,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,
0xf9,0xfa};
static const int BITS_AC_CHROMA[17] = {0, 0,2,1,2,4,4,3,4,7,5,4,4,0,1,2,119};
static const unsigned char VAL_AC_CHROMA[162] = {
0x00,0x01,0x02,0x03,0x11,0x04,0x05,0x21,0x31,0x06,0x12,0x41,0x51,0x07,0x61,0x71,
0x13,0x22,0x32,0x81,0x08,0x14,0x42,0x91,0xa1,0xb1,0xc1,0x09,0x23,0x33,0x52,0xf0,
0x15,0x62,0x72,0xd1,0x0a,0x16,0x24,0x34,0xe1,0x25,0xf1,0x17,0x18,0x19,0x1a,0x26,
0x27,0x28,0x29,0x2a,0x35,0x36,0x37,0x38,0x39,0x3a,0x43,0x44,0x45,0x46,0x47,0x48,
0x49,0x4a,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5a,0x63,0x64,0x65,0x66,0x67,0x68,
0x69,0x6a,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7a,0x82,0x83,0x84,0x85,0x86,0x87,
0x88,0x89,0x8a,0x92,0x93,0x94,0x95,0x96,0x97,0x98,0x99,0x9a,0xa2,0xa3,0xa4,0xa5,
0xa6,0xa7,0xa8,0xa9,0xaa,0xb2,0xb3,0xb4,0xb5,0xb6,0xb7,0xb8,0xb9,0xba,0xc2,0xc3,
0xc4,0xc5,0xc6,0xc7,0xc8,0xc9,0xca,0xd2,0xd3,0xd4,0xd5,0xd6,0xd7,0xd8,0xd9,0xda,
0xe2,0xe3,0xe4,0xe5,0xe6,0xe7,0xe8,0xe9,0xea,0xf2,0xf3,0xf4,0xf5,0xf6,0xf7,0xf8,
0xf9,0xfa};
typedef struct {
int code[256];
int len[256];
} HuffTable;
static void build_huffman(const int bits[17], const unsigned char *values,
HuffTable *table) {
for (int i = 0; i < 256; i++) { table->code[i] = 0; table->len[i] = 0; }
int code = 0, k = 0;
for (int length = 1; length <= 16; length++) {
for (int i = 0; i < bits[length]; i++) {
unsigned char symbol = values[k++];
table->code[symbol] = code;
table->len[symbol] = length;
code++;
}
code <<= 1;
}
}
// ============================================================
// BIT WRITER — grows on demand
// ============================================================
typedef struct {
unsigned char *buffer;
long capacity;
long size;
int bit_count;
unsigned int accumulator;
} BitWriter;
static int bw_init(BitWriter *w, long capacity) {
w->buffer = (unsigned char *) malloc(capacity);
if (!w->buffer) return 0;
w->capacity = capacity;
w->size = 0;
w->bit_count = 0;
w->accumulator = 0;
return 1;
}
static void bw_write_bits(BitWriter *w, int value, int count) {
for (int i = count - 1; i >= 0; i--) {
w->accumulator = (w->accumulator << 1) | ((value >> i) & 1);
w->bit_count++;
if (w->bit_count == 8) {
if (w->size >= w->capacity) { // ran out of room? double it
w->capacity *= 2;
w->buffer = (unsigned char *) realloc(w->buffer, w->capacity);
}
w->buffer[w->size++] = (unsigned char) w->accumulator;
w->accumulator = 0;
w->bit_count = 0;
}
}
}
static void bw_flush(BitWriter *w) {
while (w->bit_count != 0) bw_write_bits(w, 0, 1); // pad with zeros
}
// ============================================================
// CATEGORY / AMPLITUDE (article 13)
// ============================================================
static int category(int value) {
if (value == 0) return 0;
int a = abs(value), s = 0;
while (a) { s++; a >>= 1; }
return s;
}
static int amplitude(int value, int size) {
int mask = (1 << size) - 1;
return (value < 0) ? ((value - 1) & mask) : (value & mask);
}
// ============================================================
// ENCODE ONE 8x8 BLOCK
// ============================================================
static void encode_block(BitWriter *w, double block[8][8], const int quant[8][8],
HuffTable *dc_table, HuffTable *ac_table, int *dc_prev) {
double coef[8][8];
dct_block(block, coef);
// Quantize straight into zig-zag order
int zz[64];
for (int i = 0; i < 64; i++) {
int idx = ZIGZAG[i];
int r = idx / 8, c = idx % 8;
zz[i] = (int) round(coef[r][c] / quant[r][c]);
}
// --- DC: differential (DPCM) ---
int diff = zz[0] - *dc_prev;
*dc_prev = zz[0];
int s = category(diff);
bw_write_bits(w, dc_table->code[s], dc_table->len[s]);
if (s > 0) bw_write_bits(w, amplitude(diff, s), s);
// --- AC: (run, size) + amplitude ---
int run = 0;
for (int i = 1; i < 64; i++) {
if (zz[i] == 0) { run++; continue; }
while (run > 15) { // ZRL
bw_write_bits(w, ac_table->code[0xF0], ac_table->len[0xF0]);
run -= 16;
}
int size = category(zz[i]);
int symbol = (run << 4) | size;
bw_write_bits(w, ac_table->code[symbol], ac_table->len[symbol]);
bw_write_bits(w, amplitude(zz[i], size), size);
run = 0;
}
if (run > 0) // EOB
bw_write_bits(w, ac_table->code[0x00], ac_table->len[0x00]);
}
// ============================================================
// ENCODE A WHOLE PLANE, BLOCK BY BLOCK
// ============================================================
static long encode_plane(BitWriter *w, Plane *p, const int quant[8][8],
HuffTable *dc_table, HuffTable *ac_table) {
int blocks_x = (p->width + 7) / 8; // round up: partial blocks count too
int blocks_y = (p->height + 7) / 8;
int dc_prev = 0; // DC predictor resets per plane
for (int by = 0; by < blocks_y; by++) {
for (int bx = 0; bx < blocks_x; bx++) {
double block[8][8];
for (int r = 0; r < 8; r++)
for (int c = 0; c < 8; c++)
block[r][c] = (double) plane_sample(p, bx*8 + c, by*8 + r);
encode_block(w, block, quant, dc_table, ac_table, &dc_prev);
}
}
return (long) blocks_x * blocks_y;
}
// ============================================================
// ENCODE THE WHOLE IMAGE
// ============================================================
int encode_image(Plane *y, Plane *cb, Plane *cr, int quality,
unsigned char **out_buffer, EncodeStats *stats) {
init_dct_tables();
HuffTable dc_luma, ac_luma, dc_chroma, ac_chroma;
build_huffman(BITS_DC_LUMA, VAL_DC_LUMA, &dc_luma);
build_huffman(BITS_AC_LUMA, VAL_AC_LUMA, &ac_luma);
build_huffman(BITS_DC_CHROMA, VAL_DC_CHROMA, &dc_chroma);
build_huffman(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);
BitWriter w;
if (!bw_init(&w, 1 << 16)) return 0;
stats->blocks_y = encode_plane(&w, y, quant_luma, &dc_luma, &ac_luma);
stats->blocks_cb = encode_plane(&w, cb, quant_chroma, &dc_chroma, &ac_chroma);
stats->blocks_cr = encode_plane(&w, cr, quant_chroma, &dc_chroma, &ac_chroma);
bw_flush(&w);
stats->data_size = w.size;
*out_buffer = w.buffer;
return 1;
}Três detalhes que valem destaque
As tabelas de Huffman da crominância.
Lá no artigo 13, a gente usou só as tabelas de luminância. Agora que temos canais de cor de verdade, entram as tabelas de crominância (, BITS_DC_CHROMA), que são otimizadas pra estatística diferente dos canais Cb e Cr. BITS_AC_CHROMA
Usar as de luminância funcionaria, mas geraria arquivos maiores. 📊
O nas funções... Repara que quase todas as funções do static começam com encoder.c. Isso quer dizer "essa função só existe dentro deste arquivo".static
É uma forma de dizer "isso é assunto interno da cozinha, ninguém de fora precisa mexer". Só o (que está no encode_image()) é público. 🔒.h
O BitWriter que cresce. Repara que quando o buffer enche, a gente dobra a capacidade com . Isso é importante porque a gente não sabe de antemão quantos bytes a imagem vai gerar.realloc
Começar com 64 KB e ir dobrando é uma estratégia clássica e eficiente. 📈
O código: main.c
E finalmente o maestro, que junta todo mundo. Repara como ele fica limpo e legível, porque toda a complexidade está nos módulos:
E finalmente o maestro, que junta todo mundo. Repara como ele fica limpo e legível, porque toda a complexidade está nos módulos:
src/main.c:
#include <stdio.h>
#include <stdlib.h>
#include "header.h"
#include "ppm.h"
#include "encoder.h"
#define INPUT_PATH "../images/example.ppm"
#define OUTPUT_PATH "../output/image.n148i"
#define QUALITY 50
static long file_size(const char *path) {
FILE *f = fopen(path, "rb");
if (!f) return 0;
fseek(f, 0, SEEK_END);
long size = ftell(f);
fclose(f);
return size;
}
int main(void) {
// ---------- 1. Load the PPM ----------
Image img;
if (!load_ppm(INPUT_PATH, &img)) return 1;
printf("=== N.148i encoder ===\n\n");
printf("Input: %s\n", INPUT_PATH);
printf("Size: %d x %d pixels\n", img.width, img.height);
printf("Quality: %d\n\n", QUALITY);
// ---------- 2. Split into Y, Cb, Cr (with 4:2:0) ----------
Plane y, cb, cr;
if (!split_channels(&img, &y, &cb, &cr, 1)) {
printf("Could not split channels.\n");
free_image(&img);
return 1;
}
printf("Y plane: %d x %d\n", y.width, y.height);
printf("Cb/Cr planes: %d x %d (4:2:0)\n\n", cb.width, cb.height);
// ---------- 3. Encode everything ----------
unsigned char *compressed = NULL;
EncodeStats stats;
if (!encode_image(&y, &cb, &cr, QUALITY, &compressed, &stats)) {
printf("Encoding failed.\n");
free_image(&img); free_plane(&y); free_plane(&cb); free_plane(&cr);
return 1;
}
printf("Blocks encoded: Y=%ld Cb=%ld Cr=%ld (total %ld)\n",
stats.blocks_y, stats.blocks_cb, stats.blocks_cr,
stats.blocks_y + stats.blocks_cb + stats.blocks_cr);
printf("Compressed data: %ld bytes\n\n", stats.data_size);
// ---------- 4. Write the .n148i file ----------
FILE *f = fopen(OUTPUT_PATH, "wb");
if (!f) {
printf("Could not create '%s'\n", OUTPUT_PATH);
free(compressed);
free_image(&img); free_plane(&y); free_plane(&cb); free_plane(&cr);
return 1;
}
N148iHeader header;
header.version = N148I_VERSION;
header.width = img.width;
header.height = img.height;
header.quality = QUALITY;
header.chroma = CHROMA_420;
header.data_size = (uint32_t) stats.data_size;
write_header(f, &header);
fwrite(compressed, 1, stats.data_size, f);
fclose(f);
// ---------- 5. Report ----------
long ppm_bytes = file_size(INPUT_PATH);
long n148_bytes = file_size(OUTPUT_PATH);
printf("Wrote %s\n\n", OUTPUT_PATH);
printf("PPM original: %8ld bytes (%.1f KB)\n", ppm_bytes, ppm_bytes / 1024.0);
printf("N148i file: %8ld bytes (%.1f KB)\n", n148_bytes, n148_bytes / 1024.0);
printf("Header: %2d bytes\n", 20);
printf("Compression: %.1f:1 (%.1f%% smaller)\n",
(double) ppm_bytes / n148_bytes,
100.0 * (1.0 - (double) n148_bytes / ppm_bytes));
// ---------- 6. Clean up ----------
free(compressed);
free_image(&img);
free_plane(&y); free_plane(&cb); free_plane(&cr);
return 0;
}Compilando vários arquivos de uma vez
Agora que temos 4 arquivos .c, o comando de compilar muda um pouquinho: a gente precisa listar todos eles.
Dito isso, com o seu terminal aberto, entre na pasta src e mande o código abaixo:
cd n148i/src
gcc main.c header.c ppm.c encoder.c -o n148i -lm
./n148iRepara em duas coisas:
- Listamos os quatro
. Os.cnão entram na lista, eles são incluídos automaticamente pelos.h#include. 📄 - Aquele
no final é obrigatório agora! Ele diz pro compilador "linke a biblioteca matemática", que é onde vivem as funções-lm,cos()esqrt()que a DCT usa. Se você esquecer, vai tomar um erro de "undefined reference to cos". ⚠️round()
O grande momento: rodando o encoder
Antes de mais nada, vamos usar essa imagem de exemplo que existe neste link: baixe aqui e jogue para dentro de images.
A imagem acima é um exemplo e 320x240 e que eu usei no artigo 15, o resultado é este:
=== N.148i encoder ===
Input: ../images/example.ppm
Size: 320 x 240 pixels
Quality: 50
Y plane: 320 x 240
Cb/Cr planes: 160 x 120 (4:2:0)
Blocks encoded: Y=1200 Cb=300 Cr=300 (total 1800)
Compressed data: 3027 bytes
Wrote ../output/image.n148i
PPM original: 230415 bytes (225.0 KB)
N148i file: 3047 bytes (3.0 KB)
Header: 20 bytes
Compression: 75.6:1 (98.7% smaller)OLHA ISSO! 🎉🎉🎉
De 225 KB para 3 KB. Uma compressão de 75,6 para 1, ou seja, 98,7% menor. E dentro daqueles 3047 bytes está a sua imagem inteira: 1800 blocos, três canais, tudo comprimido pelo pipeline que a gente construiu ao longo de 16 artigos. 🤯
Vamos conferir as contas dos blocos, pra ver que tudo faz sentido:
Canal Y: 320÷8 = 40 blocos na horizontal
240÷8 = 30 blocos na vertical
40 × 30 = 1200 blocos ✅
Canal Cb: 160÷8 = 20 blocos na horizontal
120÷8 = 15 blocos na vertical
20 × 15 = 300 blocos ✅ (e o Cr é igual)Bate perfeitamente. 🎯
uhuuuuul...
Espiando o arquivo por dentro
Vamos fazer o que a gente sempre faz: olhar os bytes. Rodando o nos primeiros 20 bytes (o cabeçalho):od
od -A d -t x1 -N 20 ../output/image.n148iO resultado é este:
4e 31 34 38 49 02 40 01 00 00 f0 00 00 00 32 02 d3 0b 00 00E aqui está a decifração completa:
Bytes │ Campo │ Significado
─────────────────┼─────────────┼───────────────────────
4e 31 34 38 49 │ Assinatura │ "N148I"
02 │ Versão │ 2 (o formato evoluiu!)
40 01 00 00 │ Largura │ 320 (0x0140)
f0 00 00 00 │ Altura │ 240 (0x00f0)
32 │ Qualidade │ 50 (0x32)
02 │ Chroma │ 2 = 4:2:0
d3 0b 00 00 │ Tam. dados │ 3027 (0x0bd3)Confere o : em little-endian, isso é d3 0b 00 000x0bd3, que em decimal dá 3027, exatamente os bytes de dados comprimidos que o programa reportou. ✅
E depois desses 20 bytes, vêm os 3027 bytes da imagem comprimida.
O nosso formato está funcionando exatamente como projetado. 😍

Brincando com a qualidade
Agora vem a parte divertida. Abra o , mude aquele main.c pra outro valor, recompile e rode de novo. Olha o que acontece com a mesma imagem:#define QUALITY 50
Qualidade 90 → 5866 bytes ( 39,3:1 )
Qualidade 50 → 3047 bytes ( 75,6:1 )
Qualidade 10 → 1465 bytes (157,3:1 )Lindo, né? 😍 O fator de qualidade que a gente estudou no artigo 10 agora está controlando o tamanho de um arquivo real no seu disco. Baixar de 90 pra 10 corta o arquivo em 4 vezes.
E lembra do que aprendemos: quanto menor a qualidade, mais coeficientes viram zero, mais o RLE e o Huffman comprimem, e mais os quadradinhos de 8×8 vão aparecer na imagem.
Você vai poder ver esse efeito no próximo artigo, quando a gente construir o decoder. 👀
⚠️ Uma ressalva honesta sobre esses números: a taxa de compressão depende muito da imagem.
A minha imagem de teste é um degradê suave com formas simples, que é o cenário ideal pro nosso codec (poucas altas frequências).
Se você usar uma foto real, cheia de textura e detalhe, é normal a taxa cair pra algo como 10:1 ou 20:1, que continua sendo excelente. Não estranhe se os seus números forem diferentes dos meus! 📊
O que ainda falta (e é bastante)
Antes de fechar, quero ser bem transparente sobre o estado do nosso codec:
O que já funciona ✅
- Lê PPM, converte pra YCbCr, faz subsampling 4:2:0.
- Fatia em blocos 8×8 com padding correto nas bordas.
- Pipeline completo: DCT → quantização → zig-zag → RLE → Huffman.
- Escreve um arquivo
.válido, com cabeçalho versionado.n148i
O que ainda não existe 🔜
- O decoder! Por enquanto, o nosso
é uma via de mão única: a gente sabe escrever, mas ainda não sabe ler de volta. É o assunto do próximo artigo..n148i - Tabelas de Huffman customizadas por imagem (usamos as padrão do JPEG).
- Suporte a 4:4:4 e 4:2:2 no encoder (a estrutura já aceita, mas o
está fixo em 4:2:0).main.c
Ter um encoder sem decoder é como ter um cofre sem a chave: você guarda tudo lá dentro, mas ainda não consegue tirar. 🔐
No próximo artigo, a gente forja a chave!
N148i VS JPEG
Você sabia que no último teste que fizemos, o N148i está comprimindo muito melhor do que o próprio JPEG? Não? Dê uma olhada nesses números:
O Nosso q50 deu 15.665 bytes contra 17.080 do JPEG de referência na mesma qualidade.
Usando uma comparação na mesma imagem e mesmo em 4:2:0, nós temos:
Nosso N.148i JPEG
q50 15.665 bytes / 22,98 dB 17.080 bytes / 23,40 dB
q90 36.851 bytes / 24,50 dB 38.360 bytes / 24,72 dBResumo: estamos praticamente empatados com o JPEG, com arquivos até menores 😍
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 encoder completo, a modularização do projeto e a gravação do arquivo :.n148i
Resumo
Recapitulando este artigo, que foi o maior de todos:
- Quando a imagem não é múltipla de 8, os blocos da borda passam do limite — e a gente completa repetindo o pixel da borda (padding), que é suave e comprime bem. A função
faz isso de graça.plane_sample() - Com 4:2:0, os canais Cb e Cr ficam com metade da largura e altura, feito pela média de cada 2×2. Numa imagem 320×240, isso derruba o total de 3600 pra 1800 blocos.
- O preditor do DC diferencial reinicia em zero a cada canal, e misturar o DC do Y com o do Cb seria um erro que desperdiça bits.
- Escolhemos a ordem planar (todo o Y, depois Cb, depois Cr) em vez do entrelaçado por MCU do JPEG: mais simples, mesmo tamanho final.
- O cabeçalho evoluiu pra versão 2 e ganhou o campo tamanho dos dados, com 20 bytes no total. Aquele campo "versão" do artigo 14 finalmente provou seu valor!
- Separamos o projeto em módulos (
,header,ppm,encoder), cada um com seumain(a ementa) e.h(a cozinha), usando include guards..c - Pra compilar vários arquivos:
(não esqueça ogcc main.c header.c ppm.c encoder.c -o n148i -lmda biblioteca matemática!).-lm - O resultado: uma imagem de 225 KB virou um arquivo
de 3 KB, ou seja compressão de 75,6:1..n148i
E é isso: o N.148i comprime imagens de verdade agora. 💪 Aquele arquivo na sua pasta image.n148i não é mais só uma etiqueta, tem uma foto inteira lá dentro, espremida pelo pipeline que a gente construiu peça por peça ao longo de dezesseis artigos.output
No próximo artigo, vamos fazer o caminho de volta: construir o decoder.
Vamos abrir o , ler o cabeçalho, desfazer o Huffman, o RLE, o zig-zag, a quantização e a DCT, e por fim, finalmente ver a imagem descomprimida na tela. É o momento da verdade, onde a gente descobre se tudo isso realmente funcionou. 👀.n148i
Prepara o café ☕, porque no próximo a gente fecha o ciclo.
Até a próxima! 👋

