Processador de Imagens BMP em Zig — Tutorial Passo a Passo

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

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

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.