Learn Zig Series (#46) - Image Tool: CLI Pipeline
Project C: Image Manipulation Tool (3/3)
What will I learn
- You will learn building a CLI that chains operations:
img-tool input.ppm --grayscale --blur 3 --output result.ppm; - You will learn parsing operation flags and their arguments using Zig's
std.process.args(); - You will learn the pipeline pattern: each operation transforms the image buffer in sequence;
- You will learn batch processing: apply the same pipeline to multiple files using glob-style arguments;
- You will learn error handling for missing files, unsupported formats, and invalid arguments;
- You will learn performance timing: measuring each operation and total pipeline time with
std.time; - You will learn adding a
--previewflag that outputs ASCII art to the terminal; - You will learn project retrospective: design decisions across all three episodes, what we'd change.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18) - Async Concepts and Event Loops
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline (this post)
Learn Zig Series (#46) - Image Tool: CLI Pipeline
We've built the I/O layer (episode 44) and the pixel operations (episode 45). Time to wire it all together into an actual tool that somebody could use from the command line. The kind of thing where you type img-tool photo.bmp --grayscale --blur 2 --brightness 1.3 --output result.ppm and it just does what you'd expect -- reads the input, runs the operations left to right, writes the output.
This is the final episode of Project C and honestly, this is my favorite part of building any tool: the moment all the pieces click together and you can actually USE the thing. The I/O code and pixel math don't mean much in isolation. But wrap them in a decent CLI and suddenly you've got something real ;-)
Here we go!
Designing the argument parser
Zig's standard library gives us std.process.argsAlloc() to get the command-line arguments as a slice of strings. That's our raw material. The question is: how do we turn a flat list of strings into a structured sequence of operations?
The design I'm going for is straightforward. Arguments fall into three categories:
- Input file -- the first non-flag argument (doesn't start with
--) - Operations -- flags like
--grayscale,--invert,--blur 3,--brightness 1.2 - Output control --
--output filename.ppmand--preview
Some operations take a parameter (blur needs a radius, brightness needs a factor) and some don't (grayscale, invert, sepia). We need to handle both.
Let's define an Operation tagged union to represent the parsed operations. Tagged unions are perfect here -- we covered them in episode 6 and used them for the state machine in episode 33.
const Operation = union(enum) {
grayscale,
invert,
sepia,
brightness: f32,
contrast: f32,
blur: u32,
edge,
channels: struct { r: f32, g: f32, b: f32 },
};
const CliConfig = struct {
input_path: []const u8,
output_path: ?[]const u8,
operations: []Operation,
preview: bool,
verbose: bool,
};
The CliConfig holds everything we need to execute a pipeline. The input_path is required, output_path is optional (if you just want --preview without saving), and operations is the ordered list of transformations.
Now the parsing function. We iterate through the args slice, match known flags, and consume extra arguments where needed:
fn parseArgs(allocator: std.mem.Allocator) !CliConfig {
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
var ops = std.ArrayList(Operation).init(allocator);
errdefer ops.deinit();
var input_path: ?[]const u8 = null;
var output_path: ?[]const u8 = null;
var preview = false;
var verbose = false;
var i: usize = 1; // skip program name at args[0]
while (i < args.len) : (i += 1) {
const arg = args[i];
if (std.mem.eql(u8, arg, "--grayscale")) {
try ops.append(.grayscale);
} else if (std.mem.eql(u8, arg, "--invert")) {
try ops.append(.invert);
} else if (std.mem.eql(u8, arg, "--sepia")) {
try ops.append(.sepia);
} else if (std.mem.eql(u8, arg, "--edge")) {
try ops.append(.edge);
} else if (std.mem.eql(u8, arg, "--blur")) {
i += 1;
if (i >= args.len) return error.MissingArgument;
const radius = try std.fmt.parseInt(u32, args[i], 10);
try ops.append(.{ .blur = radius });
} else if (std.mem.eql(u8, arg, "--brightness")) {
i += 1;
if (i >= args.len) return error.MissingArgument;
const factor = try std.fmt.parseFloat(f32, args[i]);
try ops.append(.{ .brightness = factor });
} else if (std.mem.eql(u8, arg, "--contrast")) {
i += 1;
if (i >= args.len) return error.MissingArgument;
const factor = try std.fmt.parseFloat(f32, args[i]);
try ops.append(.{ .contrast = factor });
} else if (std.mem.eql(u8, arg, "--output")) {
i += 1;
if (i >= args.len) return error.MissingArgument;
output_path = try allocator.dupe(u8, args[i]);
} else if (std.mem.eql(u8, arg, "--preview")) {
preview = true;
} else if (std.mem.eql(u8, arg, "--verbose")) {
verbose = true;
} else if (arg[0] != '-') {
// Non-flag argument: treat as input file
if (input_path != null) return error.MultipleInputFiles;
input_path = try allocator.dupe(u8, arg);
} else {
std.debug.print("Unknown flag: {s}\n", .{arg});
return error.UnknownFlag;
}
}
if (input_path == null) {
return error.NoInputFile;
}
return CliConfig{
.input_path = input_path.?,
.output_path = output_path,
.operations = try ops.toOwnedSlice(),
.preview = preview,
.verbose = verbose,
};
}
A couple things worth noting here. We dupe the string arguments (input_path, output_path) because argsAlloc/argsFree owns the original memory -- once we free it with defer, those pointers would dangle. By duplicating them into our own allocator we keep valid copies. We covered this pattern in episode 5 when we discussed string ownership.
The errdefer ops.deinit() ensures the ArrayList gets cleaned up if any error happens during parsing. On success, we call toOwnedSlice() which transfers ownership of the backing memory to the returned slice -- the ArrayList itself no longer owns it. This is a common pattern in Zig: build something with an ArrayList, then convert to a slice for long-term storage.
The i += 1 trick for consuming the next argument (like --blur 3) is simple but works. We increment i to skip past the flag, read args[i] as the parameter value, and the outer while loop's i += 1 will advance past it on the next iteration. If the parameter is missing (end of args), we return error.MissingArgument.
The pipeline executor
Now the core of the tool: the function that takes a CliConfig and actually runs the pipeline. It loads the image, applies each operation in order, and optionally writes the result and/or shows a preview.
fn executePipeline(config: *const CliConfig, allocator: std.mem.Allocator) !void {
const stdout = std.io.getStdOut().writer();
// Load the input image
var timer = try std.time.Timer.start();
var img = try fmt_mod.readImage(allocator, config.input_path);
defer img.deinit();
const load_ns = timer.read();
if (config.verbose) {
try stdout.print("Loaded {s} ({d}x{d}) in {d:.2}ms\n", .{
config.input_path,
img.width,
img.height,
@as(f64, @floatFromInt(load_ns)) / 1_000_000.0,
});
}
// Apply operations in sequence
var total_ops_ns: u64 = 0;
for (config.operations) |op| {
timer.reset();
switch (op) {
.grayscale => ops_mod.grayscale(&img),
.invert => ops_mod.invert(&img),
.sepia => ops_mod.sepia(&img),
.brightness => |f| ops_mod.brightness(&img, f),
.contrast => |f| ops_mod.contrast(&img, f),
.blur => |r| try ops_mod.boxBlur(&img, r),
.edge => try ops_mod.sobelEdge(&img),
.channels => |c| ops_mod.adjustChannels(&img, c.r, c.g, c.b),
}
const op_ns = timer.read();
total_ops_ns += op_ns;
if (config.verbose) {
try stdout.print(" {s}: {d:.2}ms\n", .{
@tagName(op),
@as(f64, @floatFromInt(op_ns)) / 1_000_000.0,
});
}
}
// Preview (ASCII art to terminal)
if (config.preview) {
try asciiPreview(&img, stdout);
}
// Write output
if (config.output_path) |out_path| {
timer.reset();
try fmt_mod.writeImage(&img, out_path);
const write_ns = timer.read();
if (config.verbose) {
try stdout.print("Wrote {s} in {d:.2}ms\n", .{
out_path,
@as(f64, @floatFromInt(write_ns)) / 1_000_000.0,
});
}
}
if (config.verbose) {
try stdout.print("Total processing: {d:.2}ms\n", .{
@as(f64, @floatFromInt(total_ops_ns)) / 1_000_000.0,
});
}
}
The switch on tagged unions is where Zig really shines. Each variant destructures cleanly -- .brightness => |f| gives us the float factor, .blur => |r| gives us the radius. The compiler checks that every variant is handled. If we add a new operation to the union but forget to handle it in the switch, the code won't compile. Compare that to a string-matching approach where a missing case is a silent bug that only shows up at runtime.
The @tagName(op) built-in returns the string name of the current enum variant -- "grayscale", "blur", etc. We use it for the verbose output so we don't have to maintain a separate name-to-string mapping. We saw @tagName back in episode 6.
The timing uses std.time.Timer which wraps the OS monotonic clock. On Linux that's clock_gettime(CLOCK_MONOTONIC), on macOS it's mach_absolute_time(), on Windows QueryPerformanceCounter(). The timer gives nanosecond precision which is more than enough for image operations. We convert to milliseconds for display because nobody wants to read nanosecond counts.
ASCII preview
This is a fun feature. Sometimes you want a quick visual check without opening an image viewer. The --preview flag dumps a rough ASCII rendering of the image to the terminal. The idea is simple: downsample the image to fit the terminal width (say 80 columns), convert each pixel to a brightness value, and map that brightness to an ASCII character.
const ascii_ramp = " .:-=+*#%@";
fn asciiPreview(img: *const Image, writer: anytype) !void {
const term_width: usize = 80;
const aspect_correction: f32 = 0.5; // terminal chars are ~2x taller than wide
const scale_x = if (img.width > term_width)
@as(f32, @floatFromInt(img.width)) / @as(f32, @floatFromInt(term_width))
else
1.0;
const scale_y = scale_x / aspect_correction;
const out_w: usize = @intFromFloat(@as(f32, @floatFromInt(img.width)) / scale_x);
const out_h: usize = @intFromFloat(@as(f32, @floatFromInt(img.height)) / scale_y);
try writer.print("\n--- ASCII Preview ({d}x{d}) ---\n", .{ out_w, out_h });
for (0..out_h) |row| {
for (0..out_w) |col| {
// Map output coords back to source coords
const src_x: usize = @intFromFloat(@as(f32, @floatFromInt(col)) * scale_x);
const src_y: usize = @intFromFloat(@as(f32, @floatFromInt(row)) * scale_y);
// Clamp to valid range
const sx = @min(src_x, img.width - 1);
const sy = @min(src_y, img.height - 1);
const idx = (sy * @as(usize, img.width) + sx) * 3;
const r = @as(u16, img.pixels[idx]);
const g = @as(u16, img.pixels[idx + 1]);
const b = @as(u16, img.pixels[idx + 2]);
// Luminance
const lum = (r * 77 + g * 150 + b * 29) >> 8;
// Map luminance 0-255 to ramp index 0-9
const ramp_idx = (lum * (ascii_ramp.len - 1)) / 255;
try writer.writeByte(ascii_ramp[ramp_idx]);
}
try writer.writeByte('\n');
}
try writer.print("--- End Preview ---\n\n", .{});
}
The aspect_correction factor of 0.5 accounts for the fact that terminal characters are roughly twice as tall as they are wide. Without this correction, the preview would look vertically stretched -- a circle would appear as a tall oval. By halving the vertical resolution relative to horizontal, we get something closer to the actual aspect ratio.
The ascii_ramp string maps brightness levels to characters. Space for the darkest pixels, @ for the brightest. This particular ramp is pretty standard -- you'll find variations of it in every ASCII art program ever written. Some people use longer ramps with more characters for finer gradation, but 10 levels is enough for a quick preview.
The sampling is nearest-neighbor (we just pick the closest source pixel for each output cell). For a preview this is fine. Bilinear interpolation would look smoother but it's wasted effort when your output is ASCII characters. The whole point is a rough check, not a high-quality render.
Error handling and help text
A good CLI tool tells you what went wrong and how to fix it. Let's add a usage message and proper error reporting:
fn printUsage() void {
const help =
\\Usage: img-tool <input> [operations...] [--output <file>] [--preview]
\\
\\Operations (applied left to right):
\\ --grayscale Convert to grayscale (BT.601 luminance)
\\ --invert Invert all channels (255 - value)
\\ --sepia Apply sepia tone (W3C matrix)
\\ --brightness <f> Adjust brightness (1.0 = no change)
\\ --contrast <f> Adjust contrast (1.0 = no change)
\\ --blur <radius> Box blur with given radius (1-20)
\\ --edge Sobel edge detection
\\
\\Options:
\\ --output <file> Write result to file (PPM or BMP)
\\ --preview Show ASCII art preview in terminal
\\ --verbose Print timing for each operation
\\
\\Supported formats: PPM (P6), BMP (24-bit)
\\
\\Examples:
\\ img-tool photo.bmp --grayscale --output gray.ppm
\\ img-tool input.ppm --blur 3 --brightness 1.2 --output out.bmp
\\ img-tool test.ppm --sepia --preview
\\ img-tool photo.bmp --edge --invert --output edges.ppm
;
std.debug.print("{s}\n", .{help});
}
The \\ multi-line string syntax (multiline string literals) is one of those small Zig features that makes life nicer. Each \\ starts a new line in the resulting string, and leading whitespace is trimmed up to the \\ column. No need for string concatenation or raw string hacks. We used these in episode 5.
Now we need to handle errors gracefully in main. The key insight: don't just let errors propagate to the default panic handler. Catch them and print something useful:
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
const check = gpa.deinit();
if (check == .leak) std.debug.print("WARNING: memory leak detected\n", .{});
}
const allocator = gpa.allocator();
const config = parseArgs(allocator) catch |err| {
switch (err) {
error.NoInputFile => std.debug.print("Error: no input file specified\n\n", .{}),
error.MissingArgument => std.debug.print("Error: flag requires an argument\n\n", .{}),
error.UnknownFlag => {}, // message already printed in parseArgs
error.MultipleInputFiles => std.debug.print("Error: only one input file allowed\n\n", .{}),
error.InvalidCharacter => std.debug.print("Error: invalid number format\n\n", .{}),
else => std.debug.print("Error parsing arguments: {}\n\n", .{err}),
}
printUsage();
std.process.exit(1);
};
defer allocator.free(config.operations);
defer if (config.output_path) |p| allocator.free(p);
defer allocator.free(config.input_path);
if (config.operations.len == 0 and !config.preview) {
std.debug.print("Nothing to do -- specify at least one operation or --preview\n\n", .{});
printUsage();
std.process.exit(1);
}
executePipeline(&config, allocator) catch |err| {
switch (err) {
error.FileNotFound => std.debug.print("Error: file not found: {s}\n", .{config.input_path}),
error.UnsupportedFormat => std.debug.print("Error: unsupported image format (use .ppm or .bmp)\n", .{}),
error.OutOfMemory => std.debug.print("Error: out of memory -- image might be too large\n", .{}),
error.InvalidData => std.debug.print("Error: corrupted or invalid image data\n", .{}),
else => std.debug.print("Error during processing: {}\n", .{err}),
}
std.process.exit(1);
};
}
This is a pattern I use in basically every CLI tool I write. The catch |err| { switch ... } block turns Zig's error codes into human-readable messages. Without it, the user sees something like error.FileNotFound which is ok for developers but unfriendly for everyone else.
The defer chain for freeing config members is important. Because parseArgs returns owned memory (we duped the strings and toOwnedSliced the operations), the caller is responsible for freeing them. The three defer statements ensure cleanup happens regardless of how main exits.
Notice the else => std.debug.print("Error during processing: {}\n", .{err}) catch-all. This handles errors we didn't explicitly anticipate -- which is important because readImage and writeImage can produce various I/O errors from the underlying OS. We can't (and shouldn't) enumerate every possible std.fs error. Having said that, the specific cases we DO handle cover 95% of what users will actually encounter.
Batch processing
What if you want to apply the same pipeline to multiple images? The simple approach: accept multiple input files and loop over them. We'll modify the config to support a list of inputs:
const BatchConfig = struct {
input_paths: []const []const u8,
output_dir: ?[]const u8,
operations: []Operation,
preview: bool,
verbose: bool,
};
fn processBatch(config: *const BatchConfig, allocator: std.mem.Allocator) !void {
const stdout = std.io.getStdOut().writer();
var total_timer = try std.time.Timer.start();
for (config.input_paths, 0..) |input_path, idx| {
try stdout.print("[{d}/{d}] Processing: {s}\n", .{
idx + 1,
config.input_paths.len,
input_path,
});
// Generate output filename
var out_path_buf: [512]u8 = undefined;
const out_path = if (config.output_dir) |dir| blk: {
// Extract filename from input path
const basename = std.fs.path.basename(input_path);
const written = std.fmt.bufPrint(&out_path_buf, "{s}/{s}", .{ dir, basename }) catch {
std.debug.print(" Skipping -- path too long\n", .{});
continue;
};
break :blk written;
} else blk: {
// Add "_processed" suffix before extension
const ext = std.fs.path.extension(input_path);
const stem = input_path[0 .. input_path.len - ext.len];
const written = std.fmt.bufPrint(&out_path_buf, "{s}_processed{s}", .{ stem, ext }) catch {
std.debug.print(" Skipping -- path too long\n", .{});
continue;
};
break :blk written;
};
// Load, process, save
var img = fmt_mod.readImage(allocator, input_path) catch |err| {
std.debug.print(" Error loading: {}\n", .{err});
continue; // skip this file, keep going
};
defer img.deinit();
for (config.operations) |op| {
switch (op) {
.grayscale => ops_mod.grayscale(&img),
.invert => ops_mod.invert(&img),
.sepia => ops_mod.sepia(&img),
.brightness => |f| ops_mod.brightness(&img, f),
.contrast => |f| ops_mod.contrast(&img, f),
.blur => |r| ops_mod.boxBlur(&img, r) catch |err| {
std.debug.print(" Error in blur: {}\n", .{err});
continue;
},
.edge => ops_mod.sobelEdge(&img) catch |err| {
std.debug.print(" Error in edge detect: {}\n", .{err});
continue;
},
.channels => |c| ops_mod.adjustChannels(&img, c.r, c.g, c.b),
}
}
fmt_mod.writeImage(&img, out_path) catch |err| {
std.debug.print(" Error writing {s}: {}\n", .{ out_path, err });
continue;
};
try stdout.print(" -> {s}\n", .{out_path});
}
const total_ns = total_timer.read();
try stdout.print("\nBatch complete: {d} files in {d:.2}ms\n", .{
config.input_paths.len,
@as(f64, @floatFromInt(total_ns)) / 1_000_000.0,
});
}
The critical design decision in batch mode is error resilience. When one file fails (corrupt data, wrong format, permission denied), we continue to the next file instead of aborting the entire batch. This matters a lot in practice. If you have 200 photos and one has a corrupt header, you don't want to lose the other 199 results because of it.
The output filename generation uses std.fs.path.basename and std.fs.path.extension to extract the filename parts. If the user provides --output-dir processed/, we put the result there with the same filename. Otherwise, we add _processed before the extension: photo.bmp becomes photo_processed.bmp. The 512-byte buffer for path construction is generous -- most paths are well under 256 characters, and if someone manages to hit the limit we skip that file with a message rather than crashing.
The blk: labeled blocks with break :blk are Zig's way of computing a value inside a complex expression. We need the if/else to produce a []const u8 slice for out_path, and the labeled break lets us return a value from inside the block. This is the same pattern we used inside the kv-store project in episode 40.
Performance timing breakdown
The --verbose flag already prints per-operation timing. But let's add a proper summary that shows the full breakdown:
const TimingEntry = struct {
name: []const u8,
duration_ns: u64,
};
fn executePipelineWithTiming(config: *const CliConfig, allocator: std.mem.Allocator) !void {
const stdout = std.io.getStdOut().writer();
var timings = std.ArrayList(TimingEntry).init(allocator);
defer timings.deinit();
// Load
var timer = try std.time.Timer.start();
var img = try fmt_mod.readImage(allocator, config.input_path);
defer img.deinit();
try timings.append(.{ .name = "load", .duration_ns = timer.read() });
// Operations
for (config.operations) |op| {
timer.reset();
switch (op) {
.grayscale => ops_mod.grayscale(&img),
.invert => ops_mod.invert(&img),
.sepia => ops_mod.sepia(&img),
.brightness => |f| ops_mod.brightness(&img, f),
.contrast => |f| ops_mod.contrast(&img, f),
.blur => |r| try ops_mod.boxBlur(&img, r),
.edge => try ops_mod.sobelEdge(&img),
.channels => |c| ops_mod.adjustChannels(&img, c.r, c.g, c.b),
}
try timings.append(.{ .name = @tagName(op), .duration_ns = timer.read() });
}
// Preview
if (config.preview) {
timer.reset();
try asciiPreview(&img, stdout);
try timings.append(.{ .name = "preview", .duration_ns = timer.read() });
}
// Write
if (config.output_path) |out_path| {
timer.reset();
try fmt_mod.writeImage(&img, out_path);
try timings.append(.{ .name = "write", .duration_ns = timer.read() });
}
// Summary
if (config.verbose) {
try stdout.print("\n--- Timing Summary ---\n", .{});
var total: u64 = 0;
for (timings.items) |entry| {
const ms = @as(f64, @floatFromInt(entry.duration_ns)) / 1_000_000.0;
try stdout.print(" {s:.<20} {d:>8.2} ms\n", .{ entry.name, ms });
total += entry.duration_ns;
}
try stdout.print(" {s:.<20} {d:>8.2} ms\n", .{
"TOTAL",
@as(f64, @floatFromInt(total)) / 1_000_000.0,
});
try stdout.print("--- End Timing ---\n", .{});
}
}
The {s:.<20} format specifier is a nice trick -- it pads the name to 20 characters and fills with dots. So the output looks like:
--- Timing Summary ---
load................ 3.21 ms
grayscale........... 1.87 ms
blur................ 42.15 ms
write............... 5.44 ms
TOTAL............... 52.67 ms
--- End Timing ---
This immediately tells you where the time goes. In most pipelines, blur dominates because of the O(n * k^2) complexity we discussed in episode 45. Point operations like grayscale and invert are almost free by comparision.
We covered std.fmt formatting options way back in episode 24 -- the fill/align/width specifiers work the same way here.
The final project structure
Here's what the complete project looks like across all three episodes:
img-tool/
src/
main.zig -- CLI entry point, arg parsing, pipeline execution
image.zig -- Image struct: init, deinit, getPixel, setPixel, pixelCount
ppm.zig -- PPM reader/writer (P6 binary format)
bmp.zig -- BMP reader/writer (24-bit, DIB header)
format.zig -- detectFormat, readImage, writeImage (format dispatch)
operations.zig -- grayscale, invert, brightness, contrast, sepia,
adjustChannels, boxBlur, sobelEdge,
invertSimd, grayscaleSimd
preview.zig -- ASCII art preview renderer
build.zig -- Build configuration
And build.zig ties it together:
const std = @import("std");
pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{});
const exe = b.addExecutable(.{
.name = "img-tool",
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
});
b.installArtifact(exe);
const run_cmd = b.addRunArtifact(exe);
run_cmd.step.dependOn(b.getInstallStep());
if (b.args) |args| {
run_cmd.addArgs(args);
}
const run_step = b.step("run", "Run img-tool");
run_step.dependOn(&run_cmd.step);
// Tests
const tests = b.addTest(.{
.root_source_file = b.path("src/image.zig"),
.target = target,
.optimize = optimize,
});
const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run unit tests");
test_step.dependOn(&run_tests.step);
}
With this build file you can:
zig build run -- photo.bmp --grayscale --blur 2 --output result.ppm --verbose
zig build test # run image I/O round-trip tests from ep044
zig build -Doptimize=ReleaseFast # optimized build for benchmarking
The -- photo.bmp --grayscale --blur 2 part is the args that get forwarded to the executable. Everything after -- goes to run_cmd.addArgs. We set up this build pattern in episode 15.
Project retrospective
Three episodes, one complete image tool. Let's look back at the design decisions and what we learned.
What worked well
The layered architecture was the right call. Separating I/O (ppm.zig, bmp.zig), data representation (image.zig), operations (operations.zig), and CLI (main.zig) meant each piece could be built and tested independently. When we added BMP support we didn't touch the operations code. When we added new operations in episode 45, the I/O code stayed unchanged. When we built the CLI in this episode, we imported the existing modules and just wired them together.
The flat []u8 pixel buffer turned out to be the right choice for a learning project. Yes, a more structured representation (array of pixel structs, planar layout) would be better for specific use cases. But a flat byte array is the closest to what hardware actually processes, it works with SIMD without any layout conversion, and it's what real image libraries use internally. Understanding this representation makes every other format easier to understand.
Tagged unions for the operation list made the pipeline type-safe. Every operation is explicitly handled in the switch, the compiler catches missing cases, and the data associated with each operation (radius, factor, channel weights) travels with the operation itself. No stringly-typed command dispatching.
What I'd do differently
Error handling in operations could be more informative. Right now boxBlur returns !void -- the error could be OutOfMemory or anything the allocator produces. We don't distinguish "blur failed because of memory" from "blur failed because radius was nonsensical." Adding more specific error types would help the CLI give better messages.
The batch processing should be parallel. Right now we process files sequentially. For a batch of 100 images, threads would give us a nice speedup -- especially on multi-core machines. We covered threads in episode 30 and all the operations are either in-place (no shared state) or use their own temporary buffers, so parallelizing would be straightforward. But that's scope creep for a tutorial project ;-)
Format detection should be more robust. We detect PPM vs BMP by reading magic bytes, which works for these two formats. But a production tool would need JPEG, PNG, TIFF, WebP... each with their own header formats and decompression requirements. For a real project you'd probably link against a C library like stb_image (which we could do with the C interop from episode 27) rather than implementing every codec from scratch.
What this project taught us
Across these three episodes we used a LOT of what we've learned in the series so far. Slices and memory management for the pixel buffer. Error handling with try, catch, and errdefer. Structs and tagged unions for data modeling. Comptime and inline for for SIMD. File I/O for reading and writing. The build system for project organization. Formatted output for the CLI. If you followed along and built the code yourself, you've essentially done a review of the entire first 45 episodes while building something practical.
That's the real purpose of project episodes in this series -- not just to build something cool (though it is cool), but to exercise your accumulated knowledge. If you struggled with any part, go back and re-read the relevant episode. The cross-references are there for exactly that reason.
And with Project C done, we're moving on to a new project next time. We'll be building something where the program itself is the interface -- a tool that gives you direct control over processes on your machine. Looking forward to it ;-)
Wat we geleerd hebben
- Building a CLI argument parser that handles flags with and without parameters, using
std.process.argsAlloc()and string comparison withstd.mem.eql - Using tagged unions (Operation) to represent a typed list of operations, ensuring exhaustive handling via switch
- The pipeline pattern: loading an image, applying operations in sequence by iterating the operations slice, and writing the result
- Batch processing with error resilience: continuing to the next file when one fails, generating output filenames from input paths
- Proper error reporting: catching errors from parseArgs and executePipeline, switching on error values to print human-readable messages
- Performance timing with
std.time.Timer: measuring individual operations and printing a formatted summary table - ASCII art preview: downsampling with aspect correction, luminance calculation, mapping to a character brightness ramp
- Project architecture retrospective: the value of layered design (I/O, data, operations, CLI), flat pixel buffers, and tagged union dispatch
Bedankt en tot de volgende keer!