第15章 实战项目:开发图像滤镜

第15章 实战项目:开发图像滤镜

这是本书的最后一个实战项目,我们将挑战一个更有趣的任务:图像处理

我们将编写一个程序,读取一张彩色的 PNG 图片,将其转换为灰度(黑白)图片,然后保存为新文件。

这个项目将串联起我们之前学到的核心知识点:

  1. C 互操作:使用 libspng C 库来解码/编码 PNG 图片。
  2. 内存管理:手动分配缓冲区来存储像素数据。
  3. 位操作与算法:处理 RGB 像素数据。
  4. 文件 I/O:读取和写入二进制文件。

1. 准备工作

依赖库:libspng

为了处理 PNG 图片,我们需要一个解码库。这里我们选择 libspng,因为它简单、现代且性能优异。

你需要确保系统中安装了 libspng

  • macOS: brew install libspng
  • Linux (Ubuntu): sudo apt install libspng-dev
  • Windows: 建议使用 vcpkg 或直接源码集成。

项目结构

image-filter/
├── build.zig
├── src/
│   └── main.zig
└── input.png (找一张测试图片)

2. 导入 C 库

首先,我们需要在 main.zig 中导入 libspng 和 C 标准库。

const std = @import("std");

const c = @cImport({
    @cInclude("stdio.h");
    @cInclude("spng.h");
});

build.zig 中,别忘了链接这个库:

// build.zig
exe.linkSystemLibrary("spng");
exe.linkLibC();

3. 读取并解码 PNG

打开文件

由于 libspng 是基于 C 的 FILE* 接口工作的,我们这里直接使用 C 的文件操作函数会更方便。

fn decodePng(allocator: std.mem.Allocator, filename: []const u8) !struct { []u8, u32, u32 } {
    // 1. 打开文件
    // filename 是 Zig 切片,我们需要转为以 0 结尾的 C 字符串
    // 为了简单,假设 filename 是字面量,或者我们手动加个 0
    const file = c.fopen(filename.ptr, "rb");
    if (file == null) return error.FileNotFound;
    defer _ = c.fclose(file); // 确保关闭文件

    // 2. 创建解码上下文
    const ctx = c.spng_ctx_new(0);
    if (ctx == null) return error.OutOfMemory;
    defer c.spng_ctx_free(ctx);

    // 3. 关联文件
    if (c.spng_set_png_file(ctx, file) != 0) return error.PngError;

    // 4. 获取图片信息 (宽、高)
    var ihdr: c.spng_ihdr = undefined;
    if (c.spng_get_ihdr(ctx, &ihdr) != 0) return error.PngError;
    
    const width = ihdr.width;
    const height = ihdr.height;

    // 5. 计算解码所需的缓冲区大小
    var out_size: usize = 0;
    // SPNG_FMT_RGBA8 表示我们希望解码为 RGBA 格式 (每个像素 4 字节)
    if (c.spng_decoded_image_size(ctx, c.SPNG_FMT_RGBA8, &out_size) != 0) return error.PngError;

    // 6. 分配内存
    const buffer = try allocator.alloc(u8, out_size);
    // 注意:这里不能 defer free,因为我们要把 buffer 返回给调用者

    // 7. 解码
    if (c.spng_decode_image(ctx, buffer.ptr, out_size, c.SPNG_FMT_RGBA8, 0) != 0) {
        allocator.free(buffer); // 出错时释放内存
        return error.PngDecodeFailed;
    }

    return .{ buffer, width, height };
}

这段代码展示了典型的 C 互操作流程:

  1. 资源管理:使用 defer 关闭文件和释放 C 上下文。
  2. 错误处理:检查 C 函数返回值(通常 0 表示成功),并转换为 Zig 错误。
  3. 内存分配:使用 Zig 的 allocator 分配内存,并传递指针给 C 函数。

4. 灰度滤镜算法

现在我们有了一个包含所有像素数据的 []u8 缓冲区。 由于我们请求的是 RGBA8 格式,每 4 个字节代表一个像素:

  • Byte 0: Red
  • Byte 1: Green
  • Byte 2: Blue
  • Byte 3: Alpha (透明度)

灰度公式Gray = 0.2126 * R + 0.7152 * G + 0.0722 * B (这是常用的相对亮度公式)。

fn applyGrayscale(pixels: []u8) void {
    var i: usize = 0;
    // 每次跳过 4 个字节 (R, G, B, A)
    while (i < pixels.len) : (i += 4) {
        const r = pixels[i];
        const g = pixels[i+1];
        const b = pixels[i+2];
        // alpha (pixels[i+3]) 保持不变

        // 计算灰度值
        const gray_f = 
            @as(f32, @floatFromInt(r)) * 0.2126 +
            @as(f32, @floatFromInt(g)) * 0.7152 +
            @as(f32, @floatFromInt(b)) * 0.0722;
        
        const gray_u8: u8 = @intFromFloat(gray_f);

        // 将 RGB 都设为同一个灰度值
        pixels[i]   = gray_u8;
        pixels[i+1] = gray_u8;
        pixels[i+2] = gray_u8;
    }
}

这里我们使用了 @as@floatFromInt 来进行安全的类型转换,这是 Zig 严格类型系统的体现。

5. 保存图片

处理完像素后,我们需要将其编码回 PNG 格式并保存。

fn savePng(filename: []const u8, pixels: []const u8, width: u32, height: u32) !void {
    const file = c.fopen(filename.ptr, "wb");
    if (file == null) return error.FileOpenFailed;
    defer _ = c.fclose(file);

    // 创建编码上下文
    const ctx = c.spng_ctx_new(c.SPNG_CTX_ENCODER);
    if (ctx == null) return error.OutOfMemory;
    defer c.spng_ctx_free(ctx);

    if (c.spng_set_png_file(ctx, file) != 0) return error.PngError;

    // 设置图片头信息
    var ihdr: c.spng_ihdr = undefined;
    ihdr.width = width;
    ihdr.height = height;
    ihdr.color_type = c.SPNG_COLOR_TYPE_TRUECOLOR_ALPHA;
    ihdr.bit_depth = 8;
    
    if (c.spng_set_ihdr(ctx, &ihdr) != 0) return error.PngError;

    // 编码并写入
    if (c.spng_encode_image(ctx, pixels.ptr, pixels.len, c.SPNG_FMT_PNG, c.SPNG_ENCODE_FINALIZE) != 0) {
        return error.PngEncodeFailed;
    }
}

6. 整合 Main 函数

最后,将所有步骤在 main 中串联起来。

pub fn main() !void {
    // 使用通用分配器
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const input_file = "input.png";
    const output_file = "output.png";

    std.debug.print("Processing {s}...\n", .{input_file});

    // 1. 读取
    const image = try decodePng(allocator, input_file);
    const pixels = image[0];
    const width = image[1];
    const height = image[2];
    defer allocator.free(pixels); // 记得释放像素内存!

    std.debug.print("Image size: {d}x{d}\n", .{width, height});

    // 2. 处理
    applyGrayscale(pixels);

    // 3. 保存
    try savePng(output_file, pixels, width, height);

    std.debug.print("Saved to {s}\n", .{output_file});
}

总结

通过这个项目,我们不仅复习了内存管理和文件操作,更重要的是亲身体验了 Zig 与 C 语言互操作的便捷性。

  • 零开销互操作:我们像调用 Zig 函数一样调用了 spng_decode_image,没有任何封装成本。
  • 手动内存管理:我们清晰地知道何时分配内存(在 decode 中),何时释放(在 main 结束时)。
  • 类型安全:在处理像素计算时,Zig 强制我们进行显式类型转换,避免了潜在的溢出错误。

这正是 Zig 作为一门现代系统编程语言的魅力所在:它赋予你对底层的完全控制,同时提供现代化的工具来保障安全。


原文出处: https://pedropark99.github.io/zig-book/