Processador de Imagens BMP em Zig — Tutorial Passo a Passo
Neste tutorial, vamos construir um processador de imagens no formato BMP em Zig. O formato BMP é ideal para aprender manipulação de imagens porque tem uma estrutura simples e não utiliza compressão. Vamos ler, manipular pixels e salvar imagens com diversos filtros.
O Que Vamos Construir
Nosso processador vai:
- Ler arquivos BMP de 24 bits (RGB sem compressão)
- Aplicar filtros: escala de cinza, inversão de cores, espelhamento, brilho
- Gerar imagens BMP programaticamente (gradientes, padrões)
- Salvar o resultado em um novo arquivo BMP
Por Que Este Projeto?
Trabalhar com formatos binários é uma habilidade essencial. O BMP nos ensina sobre headers de arquivo, endianness, padding de bytes e manipulação direta de memória — áreas onde Zig brilha com seus packed struct e controle fino sobre layout de dados.
Pré-requisitos
- Zig 0.13+ instalado (guia de instalação)
- Familiaridade com I/O de arquivos
- Conhecimento de structs e packed structs
Passo 1: Estrutura do Projeto
mkdir image-converter
cd image-converter
zig init
Passo 2: Definindo os Headers BMP
O formato BMP começa com dois headers que descrevem o arquivo e a imagem. Usamos packed struct para garantir que o layout em memória corresponda exatamente ao formato do arquivo.
const std = @import("std");
const fs = std.fs;
const io = std.io;
const mem = std.mem;
/// Header do arquivo BMP (14 bytes).
/// packed struct garante layout exato sem padding.
const BitmapFileHeader = extern struct {
tipo: [2]u8, // "BM"
tamanho_arquivo: u32,
reservado1: u16,
reservado2: u16,
offset_dados: u32,
};
/// Header de informações da imagem (40 bytes, formato BITMAPINFOHEADER).
const BitmapInfoHeader = extern struct {
tamanho_header: u32,
largura: i32,
altura: i32,
planos: u16,
bits_por_pixel: u16,
compressao: u32,
tamanho_imagem: u32,
resolucao_x: i32,
resolucao_y: i32,
cores_usadas: u32,
cores_importantes: u32,
};
/// Representa um pixel RGB (24 bits).
/// No BMP, a ordem é BGR (Blue, Green, Red).
const Pixel = struct {
b: u8,
g: u8,
r: u8,
pub fn rgb(r: u8, g: u8, b: u8) Pixel {
return .{ .r = r, .g = g, .b = b };
}
/// Converte para escala de cinza usando luminância perceptual.
pub fn paraCinza(self: Pixel) Pixel {
const cinza: u8 = @intFromFloat(
@as(f32, @floatFromInt(self.r)) * 0.299 +
@as(f32, @floatFromInt(self.g)) * 0.587 +
@as(f32, @floatFromInt(self.b)) * 0.114,
);
return .{ .r = cinza, .g = cinza, .b = cinza };
}
/// Inverte as cores (negativo).
pub fn inverter(self: Pixel) Pixel {
return .{ .r = 255 - self.r, .g = 255 - self.g, .b = 255 - self.b };
}
/// Ajusta o brilho (-255 a +255).
pub fn ajustarBrilho(self: Pixel, delta: i16) Pixel {
return .{
.r = clampU8(@as(i16, self.r) + delta),
.g = clampU8(@as(i16, self.g) + delta),
.b = clampU8(@as(i16, self.b) + delta),
};
}
};
fn clampU8(valor: i16) u8 {
if (valor < 0) return 0;
if (valor > 255) return 255;
return @intCast(valor);
}
Passo 3: A Estrutura da Imagem
/// Representa uma imagem BMP em memória.
/// Armazena os pixels em um array 1D (row-major order).
const ImagemBMP = struct {
largura: u32,
altura: u32,
pixels: []Pixel,
allocator: mem.Allocator,
const Self = @This();
/// Cria uma nova imagem com cor de fundo uniforme.
pub fn criar(allocator: mem.Allocator, largura: u32, altura: u32, cor_fundo: Pixel) !Self {
const total = largura * altura;
const pixels = try allocator.alloc(Pixel, total);
@memset(pixels, cor_fundo);
return .{
.largura = largura,
.altura = altura,
.pixels = pixels,
.allocator = allocator,
};
}
pub fn deinit(self: *Self) void {
self.allocator.free(self.pixels);
}
/// Acessa um pixel por coordenadas (x, y).
pub fn getPixel(self: *const Self, x: u32, y: u32) Pixel {
return self.pixels[y * self.largura + x];
}
/// Define um pixel por coordenadas (x, y).
pub fn setPixel(self: *Self, x: u32, y: u32, pixel: Pixel) void {
self.pixels[y * self.largura + x] = pixel;
}
/// Carrega uma imagem BMP de um arquivo.
pub fn carregar(allocator: mem.Allocator, caminho: []const u8) !Self {
const arquivo = try fs.cwd().openFile(caminho, .{});
defer arquivo.close();
const reader = arquivo.reader();
// Ler file header
const file_header = try reader.readStruct(BitmapFileHeader);
if (!mem.eql(u8, &file_header.tipo, "BM")) {
return error.FormatoInvalido;
}
// Ler info header
const info_header = try reader.readStruct(BitmapInfoHeader);
if (info_header.bits_por_pixel != 24 or info_header.compressao != 0) {
return error.FormatoNaoSuportado;
}
const largura: u32 = @intCast(info_header.largura);
const altura: u32 = @intCast(@abs(info_header.altura));
const total = largura * altura;
const pixels = try allocator.alloc(Pixel, total);
errdefer allocator.free(pixels);
// Pular até os dados de pixel
const pos_atual = @sizeOf(BitmapFileHeader) + @sizeOf(BitmapInfoHeader);
if (file_header.offset_dados > pos_atual) {
try reader.skipBytes(file_header.offset_dados - pos_atual, .{});
}
// Padding: cada linha deve ter tamanho múltiplo de 4 bytes
const bytes_por_linha = largura * 3;
const padding = (4 - (bytes_por_linha % 4)) % 4;
// BMP armazena de baixo para cima (última linha primeiro)
var y: u32 = 0;
while (y < altura) : (y += 1) {
const linha_real = if (info_header.altura > 0) altura - 1 - y else y;
var x: u32 = 0;
while (x < largura) : (x += 1) {
const b = try reader.readByte();
const g = try reader.readByte();
const r = try reader.readByte();
pixels[linha_real * largura + x] = .{ .r = r, .g = g, .b = b };
}
// Pular padding
try reader.skipBytes(padding, .{});
}
return .{
.largura = largura,
.altura = altura,
.pixels = pixels,
.allocator = allocator,
};
}
/// Salva a imagem em formato BMP.
pub fn salvar(self: *const Self, caminho: []const u8) !void {
const arquivo = try fs.cwd().createFile(caminho, .{});
defer arquivo.close();
const writer = arquivo.writer();
const bytes_por_linha = self.largura * 3;
const padding = (4 - (bytes_por_linha % 4)) % 4;
const tamanho_dados = (bytes_por_linha + padding) * self.altura;
const offset_dados: u32 = @sizeOf(BitmapFileHeader) + @sizeOf(BitmapInfoHeader);
// File header
try writer.writeStruct(BitmapFileHeader{
.tipo = "BM".*,
.tamanho_arquivo = offset_dados + tamanho_dados,
.reservado1 = 0,
.reservado2 = 0,
.offset_dados = offset_dados,
});
// Info header
try writer.writeStruct(BitmapInfoHeader{
.tamanho_header = @sizeOf(BitmapInfoHeader),
.largura = @intCast(self.largura),
.altura = @intCast(self.altura),
.planos = 1,
.bits_por_pixel = 24,
.compressao = 0,
.tamanho_imagem = tamanho_dados,
.resolucao_x = 2835, // 72 DPI
.resolucao_y = 2835,
.cores_usadas = 0,
.cores_importantes = 0,
});
// Dados dos pixels (de baixo para cima)
const padding_bytes = [_]u8{0} ** 3;
var y: u32 = 0;
while (y < self.altura) : (y += 1) {
const linha = self.altura - 1 - y;
var x: u32 = 0;
while (x < self.largura) : (x += 1) {
const p = self.pixels[linha * self.largura + x];
try writer.writeByte(p.b);
try writer.writeByte(p.g);
try writer.writeByte(p.r);
}
if (padding > 0) {
try writer.writeAll(padding_bytes[0..padding]);
}
}
}
};
Passo 4: Filtros de Imagem
/// Aplica escala de cinza em toda a imagem.
fn filtroEscalaDeCinza(imagem: *ImagemBMP) void {
for (imagem.pixels) |*pixel| {
pixel.* = pixel.paraCinza();
}
}
/// Inverte todas as cores (negativo).
fn filtroInversao(imagem: *ImagemBMP) void {
for (imagem.pixels) |*pixel| {
pixel.* = pixel.inverter();
}
}
/// Espelha a imagem horizontalmente.
fn filtroEspelhoH(imagem: *ImagemBMP) void {
var y: u32 = 0;
while (y < imagem.altura) : (y += 1) {
var esq: u32 = 0;
var dir: u32 = imagem.largura - 1;
while (esq < dir) {
const idx_esq = y * imagem.largura + esq;
const idx_dir = y * imagem.largura + dir;
const tmp = imagem.pixels[idx_esq];
imagem.pixels[idx_esq] = imagem.pixels[idx_dir];
imagem.pixels[idx_dir] = tmp;
esq += 1;
dir -= 1;
}
}
}
/// Ajusta brilho de toda a imagem.
fn filtroBrilho(imagem: *ImagemBMP, delta: i16) void {
for (imagem.pixels) |*pixel| {
pixel.* = pixel.ajustarBrilho(delta);
}
}
/// Gera uma imagem com gradiente arco-íris.
fn gerarGradiente(allocator: mem.Allocator, largura: u32, altura: u32) !ImagemBMP {
var img = try ImagemBMP.criar(allocator, largura, altura, Pixel.rgb(0, 0, 0));
var y: u32 = 0;
while (y < altura) : (y += 1) {
var x: u32 = 0;
while (x < largura) : (x += 1) {
const r: u8 = @intCast(x * 255 / largura);
const g: u8 = @intCast(y * 255 / altura);
const b: u8 = @intCast(255 - r);
img.setPixel(x, y, Pixel.rgb(r, g, b));
}
}
return img;
}
Passo 5: Função Main
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const stdout = io.getStdOut().writer();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
try stdout.print(
\\
\\ ==========================================
\\ PROCESSADOR BMP - Zig
\\ ==========================================
\\ Uso:
\\ bmp gerar <saida.bmp> <largura> <altura>
\\ bmp cinza <entrada.bmp> <saida.bmp>
\\ bmp inverter <entrada.bmp> <saida.bmp>
\\ bmp espelhar <entrada.bmp> <saida.bmp>
\\ bmp brilho <entrada.bmp> <saida.bmp> <delta>
\\ ==========================================
\\
\\ Gerando imagem de demonstracao: demo.bmp
\\
, .{});
// Gerar imagem de demonstração
var img = try gerarGradiente(allocator, 256, 256);
defer img.deinit();
try img.salvar("demo.bmp");
try stdout.print(" Imagem demo.bmp (256x256) criada!\n", .{});
return;
}
const comando = args[1];
if (mem.eql(u8, comando, "gerar")) {
if (args.len < 5) {
try stdout.print("Uso: bmp gerar <saida.bmp> <largura> <altura>\n", .{});
return;
}
const largura = try std.fmt.parseInt(u32, args[3], 10);
const altura = try std.fmt.parseInt(u32, args[4], 10);
var img = try gerarGradiente(allocator, largura, altura);
defer img.deinit();
try img.salvar(args[2]);
try stdout.print("Imagem {s} ({d}x{d}) criada!\n", .{ args[2], largura, altura });
} else if (mem.eql(u8, comando, "cinza") or mem.eql(u8, comando, "inverter") or
mem.eql(u8, comando, "espelhar") or mem.eql(u8, comando, "brilho"))
{
if (args.len < 4) {
try stdout.print("Uso: bmp {s} <entrada.bmp> <saida.bmp>\n", .{comando});
return;
}
var img = try ImagemBMP.carregar(allocator, args[2]);
defer img.deinit();
if (mem.eql(u8, comando, "cinza")) {
filtroEscalaDeCinza(&img);
} else if (mem.eql(u8, comando, "inverter")) {
filtroInversao(&img);
} else if (mem.eql(u8, comando, "espelhar")) {
filtroEspelhoH(&img);
} else if (mem.eql(u8, comando, "brilho")) {
const delta = try std.fmt.parseInt(i16, args[4], 10);
filtroBrilho(&img, delta);
}
try img.salvar(args[3]);
try stdout.print("Filtro '{s}' aplicado: {s} -> {s}\n", .{ comando, args[2], args[3] });
} else {
try stdout.print("Comando desconhecido: {s}\n", .{comando});
}
}
Testes
test "pixel - escala de cinza" {
const branco = Pixel.rgb(255, 255, 255);
const cinza = branco.paraCinza();
try std.testing.expectEqual(@as(u8, 255), cinza.r);
}
test "pixel - inversao" {
const pixel = Pixel.rgb(100, 150, 200);
const inv = pixel.inverter();
try std.testing.expectEqual(@as(u8, 155), inv.r);
try std.testing.expectEqual(@as(u8, 105), inv.g);
try std.testing.expectEqual(@as(u8, 55), inv.b);
}
test "imagem - criar e acessar pixels" {
const allocator = std.testing.allocator;
var img = try ImagemBMP.criar(allocator, 10, 10, Pixel.rgb(0, 0, 0));
defer img.deinit();
img.setPixel(5, 5, Pixel.rgb(255, 0, 0));
const p = img.getPixel(5, 5);
try std.testing.expectEqual(@as(u8, 255), p.r);
try std.testing.expectEqual(@as(u8, 0), p.g);
}
test "clamp funciona nos extremos" {
try std.testing.expectEqual(@as(u8, 0), clampU8(-50));
try std.testing.expectEqual(@as(u8, 255), clampU8(300));
try std.testing.expectEqual(@as(u8, 128), clampU8(128));
}
Compilando e Executando
# Gerar imagem de demonstração
zig build run
# Gerar imagem com tamanho customizado
zig build run -- gerar minha_imagem.bmp 512 512
# Aplicar filtro de escala de cinza
zig build run -- cinza entrada.bmp saida_cinza.bmp
# Inverter cores
zig build run -- inverter entrada.bmp saida_inv.bmp
# Rodar testes
zig build test
Conceitos Aprendidos
- Formatos binários com structs mapeando headers
- Manipulação de pixels e aritmética de cores
- Padding de alinhamento em formatos de arquivo
- Leitura e escrita de arquivos binários
- Gerenciamento de memória com allocators
Próximos Passos
- Explore I/O de arquivos para formatos mais complexos
- Aprenda sobre packed structs para layout de memória
- Veja o projeto Ray Tracer que gera imagens 3D
- Consulte a stdlib de I/O para buffered writers