My recent projects have obliged me to create and edit various kinds of image data. My VDP programming guide used some graphics I first created in a paint program, and which I then converted into the necessary format with custom code. Updating my logo graphics for the Sega Genesis obliged me to both write image-processing code from scratch as well as to do its own encoding work—and in order to actually provide images for the article I also needed to save out my various attempts as PNGs along the way. And early last year, when I was [working on renderings of the Mandelbrot set](https://bumbershootsoft….
My recent projects have obliged me to create and edit various kinds of image data. My VDP programming guide used some graphics I first created in a paint program, and which I then converted into the necessary format with custom code. Updating my logo graphics for the Sega Genesis obliged me to both write image-processing code from scratch as well as to do its own encoding work—and in order to actually provide images for the article I also needed to save out my various attempts as PNGs along the way. And early last year, when I was working on renderings of the Mandelbrot set I needed to output the actual results so that I could look at them.
I did all of this work in C, but that is also not really the language of choice for most people who might want to programmatically work with pixel art. This week, I’ll be working through doing programmatic pixel work in a variety of contemporary programming languages. There are five tasks I think are necessary for this sort of work:
- Loading in a source image from a file, at minimum as PNG or JPG, but ideally more if our libraries support it.
- Loading in a source image from a collection of bytes that are already in memory. We need this if we’re going to be packaging our images in some kind of resource system since the important data won’t be neatly packaged on the filesystem for us.
- Creating a blank source image of any size, for cases like the Mandelbrot set where the whole image is procedurally generated.
- Converting the source image into a consistent 32bpp RBGA format so that the same programming techniques will work regardless of language.
- Reading and writing individual pixels. For languages where this is awkward, then if this can be done effectively in bulk, we’ll do that too.
- Saving our final image out, at minimum as PNG, but ideally other formats if our libraries support it. I’ll be recapping my C approaches (which are also entirely suitable for C++ development), then look at Java and Go for examples of doing this work in modern, managed-memory environments, then round out the tour with Python and Rust. In each case my sample program will accept an input image and create an output image that has flipped its input vertically and recolored it into a photographic negative. In most of these languages that won’t hit all five of my bullet points, so we’ll cover what it misses in the text. Also, if the language includes image support in its standard library I will be relying entirely on it; for other languages I will rely on third-party libraries that cleanly meet these needs.
The output of all of today’s programs when fed the blog logo. Let’s begin.
C/C++
Since I’ve done all my previous image work here in C, I don’t really have much new advice for C and C++ developers. My old article on stb_image and SDL_image is still good, though here in 2025 I might underline that if you are writing a program that works with untrusted, potentially malicious user input, you should probably prefer SDL_image
as a library—it is a wrapper around the reference implementations of its formats and its developers expect the software to work in more hostile environments. The stb
libraries in general carry a presumption that they are empowering a developer to make more effective use of their own material—and since that’s the state of affairs for most game developers and ROM hackers, and since it’s also very easy to use, it’s a big part of why I’ve used them in my own work.
That article was only about loading images, though. To write out our new processed images, we’ll need a bit more. SDL 3—newly released since the last time I worked with contemporary stuff—includes an IMG_SavePNG
function as part of its own SDL_image
sublibrary. For stb_image
, the simplest approach is to rely on the stb_image_write
library that’s part of the same collection. Its stbi_write_png
function will do the job.
Loading and saving is pretty simple. The stb_image.h
header library offers a function named stbi_load(filename, width_out, height_out, channels_out, channels_requested)
which takes a string, three integer pointers (through which the function returns the dimensions of the image and the number of bytes per pixel) and finally a request for the number of bytes per pixel. The return value is an array of unsigned char
with width * height * channels
elements. Unless you specifically restrict which formats to include in its compile-time configuration, a wide array of image formats will be available for loading. It also offers stbi_load_from_memory
, which replaces the filename with an array of unsigned char
representing the encoded image data. Both of these functions will convert the image into one of the handful of pixel formats it supports, based on both the data in the image and the number of channels requested in the last argument. To get the RGBA format we want, pass in 4 for the number of channels requested.
The array returned represents each pixel as four entries, with their R, G, B, and A values in that order. The pixels are then packed in, left to right, then top to bottom, in the usual way. To read or edit the image, just consult the elements of this array. If we want to create an image completely from scratch, we may simply allocate an array of unsigned char
with that the appropriate number of elements ourselves.
To write the image out when we’re done, the stb_image_write.h
library offers a function stbi_write_png(filename, width, height, channels, data, stride)
. The stride value is the number of bytes per row of the image; it will generally be width * 4
.
Our sample program will open by including our header files, taking care to first set the definitions that will actually include the implementations of the library’s functions:
#include <stdio.h>
#define STB_IMAGE_IMPLEMENTATION
#define STB_IMAGE_WRITE_IMPLEMENTATION
#include "stb_image.h"
#include "stb_image_write.h"
We’ll break out the actual negation and flip into its own support function. We iterate through the image array from the edges to the center, calculating pointers for each pair of rows and swapping their channel values. To compute the photographic negative we edit the channel data for R, G, and B while leaving A alone.
static void negflip_img (unsigned char *img, int w, int h)
{
int y1 = 0;
int y2 = h-1;
while (y1 <= y2) {
unsigned char *row1 = img + (y1 * w * 4);
unsigned char *row2 = img + (y2 * w * 4);
int x;
for (x = 0; x < w * 4; ++x) {
unsigned char a = *row1;
unsigned char b = *row2;
if ((x & 3) != 3) {
/* Photo-negative non-alpha channels */
a = 255 - a;
b = 255 - b;
}
*row1++ = b;
*row2++ = a;
}
++y1;
--y2;
}
}
This is honestly a bit unusual because we’re working a color channel at a time in our loop rather than a full pixel.
The main program has no surprises: it checks its command line arguments, loads the image, calls a processing function, then writes it out and cleans up.
int main(int argc, char **argv)
{
int w, h, n;
if (argc != 2) {
fprintf(stderr, "Usage:\n\t%s {filename.png}\n", argv[0]);
return 1;
}
unsigned char *img = stbi_load(argv[1], &w, &h, &n, 4);
if (!img) {
fprintf(stderr, "Could not load %s\n", argv[1]);
return 1;
}
negflip_img(img, w, h);
stbi_write_png("result.png", w, h, 4, img, w*4);
stbi_image_free(img);
return 0;
}
Java
Java spent a long time as the most popular language for application and service programming, and as a result its library grew in idiosyncratic ways as it tried to become nearly all things to nearly all people. That does mean that we’ll find everything we need within its extended standard library, and that we’ll find it in there from a very long way back, but the things we need will be scattered about the library a bit. Image loading, deoding, and saving is managed via the methods in the javax.image.ImageIO
class, for instance, but the images themselves will be java.awt.image.BufferedImage
instances, whose package name marks them as being part of its original cross-platform GUI support. These classes will also rely on the panoply of abstractions provided by the core of the standard library itself. Java is more than a little bit infamous for this kind of stacking of levels, but I found that these APIs avoid the roughest edges.
The ImageIO.read(src)
method reads files. It can accept java.io.File
objects as its source, to load the image data from that file. It also accepts java.io.InputStream
as its source, which can read from memory via ByteArrayInputStream
objects or other, more exotic, sources. That function returns a BufferedImage
. If we wish to create a fresh image, we can construct them directly with an expression like new BufferedImage(width, height, TYPE_INT_ARGB)
.
To access pixels, the BufferedImage
class offers functions named getRGB(x, y)
and setRGB(x, y, argb)
for editing individual pixels in the image. Pixel colors are represented as 32-bit integers in ARGB order with alpha as the most significant byte and blue as the least. These may also be managed in arrays, with subrectangles being extracted with getRGB(int startX, int startY, int w, int h, int[] rgbArray, int offset, int scansize)
and an equivalent for setRGB
. See the API documentation for more on these.
We do face one danger here, though; while the getRGB()
functions will abstract away the actual underlying image format for us, the setRGB()
functions will not. If we happened to load in a paletted image, the colors we pass to setRGB()
will be coerced to the closest color present in the palette. This is effectively never what we want. When transforming one image into another, it’s generally going to be easiest to create a fresh destination image to put our results into instead of editing it in place.
Once our work is done, the ImageIO.write(data, format, dest)
method writes images out. This accepts both java.io.File
types as well as byte-oriented java.io.OutputStream
implementations for more generic results. The format is a string that names the format to use; Java understands a whole bunch.
All of these classes and methods are available all the way back to Java 8. Our implementation at the top level ends up pretty close to the C one; the main difference is that we need to account for the way that I/O failures manifest as java.io.IOException
objects instead of as error codes and null pointers. We can also see that our call to the transform
method is swapping out the original image we loaded for the guaranteed-ARGB-format replacement version.
package bumbershoot;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
import javax.imageio.ImageIO;
public class NegFlip {
public static void main(String[] args) {
if (args.length != 1) {
System.err.println("Usage:\n\tjava bumbershoot.NegFlip {filename.png}");
return;
}
BufferedImage img;
try {
img = ImageIO.read(new File(args[0]));
} catch (IOException e) {
System.err.println("Could not load "+args[0]+": "+e.getMessage());
return;
}
if (img == null) {
System.err.println(args[0] + " is not a recognized image file");
return;
}
img = transform(img);
File outFile = new File("result.png");
try {
ImageIO.write(img, "png", outFile);
} catch (IOException e) {
System.err.println("Could not write "+outFile.toString()+": "+e.getMessage());
}
}
We have some flexibility when it comes to implementing the transformation, and I took the opportunity to do things a bit differently than the C version. Instead of extracting and replacing the entire image all at once, I instead allocate two arrays, one for each row that an iteration might consider, and then reuse the array for each iteration. At a pixel level, each element of those arrays has all the channels in at at once so we may do the transform with a single XOR operation per pixel. That’s actually more straightforward than the C approach! Finally, I decided to be a bit defensive about my image bounds. While the documentation for BufferedImage
is pretty clear that the upper-left coordinate of an image created via our constructor is always (0,0), it doesn’t seem like the image API itself makes any such guarantees. It’s not that much extra work to adjust for the case where the source image has an unusual boundary rectangle, so I manage that along the way too.
private static BufferedImage transform(BufferedImage img)
{
int w = img.getWidth();
int h = img.getHeight();
int y0 = img.getMinY();
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_INT_ARGB);
int x0 = img.getMinX();
int y1 = y0;
int y2 = y1 + h - 1;
int[] row1 = new int[w];
int[] row2 = new int[w];
while (y1 <= y2) {
img.getRGB(x0, y1, w, 1, row1, 0, w);
img.getRGB(x0, y2, w, 1, row2, 0, w);
for (int i = 0; i < w; ++i) {
row1[i] ^= 0xffffff;
row2[i] ^= 0xffffff;
}
out.setRGB(0, y1-y0, w, 1, row2, 0, w);
out.setRGB(0, y2-y0, w, 1, row1, 0, w);
++y1; --y2;
}
return out;
}
}
One rough corner of Java is that its primitive integral types are all signed and the only escape from this are functions like java.lang.Integer.compareUnsigned()
—however, since the “sign bit” here is part of the alpha channel, working with colors we end up relying primarily on bitwise logic operations that mostly get to avoid that ugliness.
One very cool corner of this interface that we didn’t end up needing here is that BufferedImage
includes a method called createGraphics()
which turns the image into a render target for the default GUI toolkit’s drawing and painting operations. The standard way to convert an image from one pixel format to another is to create a new image the way we did and then just draw the source into the destination with a drawImage()
command!`
Golang
Go offers extensive support for image processing in its standard library—however, we will need to negotiate with the compiler a bit more along the way compared to the likes of Java. We face two complications immediately. First, while both the image and image/color packages offer a generic-looking API that would not be out of place in even the most aggressively object-oriented system, the Go programming language is an awkward fit at best for such designs. Our first task will be to escape from that into the concretely-typed structures that Go prefers. Our second complication is more a simple trap for the unwary—while the image/color
package offers a very respectable selection of color encodings, and while “RGBA” is the lingua-franca between them, it is not the same RGBA as the ones we’ve used in every other language. This format, here, is a premultiplied-alpha version of RGBA, that ensures that the R, G, and B values are never larger than the A value. The data format we want Go calls “NRGBA” for “non-premultiplied RGBA”.
There are two concrete types of interest to us. The first is color.NRGBA
, a simple structure with byte elements named R
, G
, B
, and A
. It represents a single color. The second is image.NRGBA
, which represents a full image. It has three elements: Pix
is an array of uint8
that is formatted as an unbroken sequence of R, G, B, and A values, Rect
defines the minimum and maximum X and Y values, and Stride
represents the number of bytes in any given row.
Go’s interaction with files and file formats are a bit different from other systems. The image.Decode()
method takes some object that implements io.Reader
, which is probably an open file but could be backed by a byte array slice or anything similar, so reading from files or memory is basically the same thing everywhere. However, the function will only recognize image formats that have been imported… and the compiler errors out if you import a package that you don’t explicitly use anything from. To import an image subpackage just to register the file, we need to put an underscore before the name. We need to do that to load JPEG files, though the fact that we’re writing out PNG files explicitly means we may import it normally. Our import header looks like this:
package main
import (
"image"
"image/color"
_ "image/jpeg"
"image/png"
"log"
"os"
)
Those subpackages include an Encode
function that accepts an io.Writer
and an image. This relatively generic function will save any object that fits the image.Image
interface to a PNG file.
func savePng(path string, img image.Image) error {
outfile, err := os.Create(path)
if err != nil {
return err
}
defer outfile.Close()
err = png.Encode(outfile, img)
if err != nil {
return err
}
return nil
}
We mostly won’t be working with image.Image
as a type, though; we need to concretize it as an image.NRGBA
item. These are created from Rectangle
objects with the image.NewNRGBA
constructor. Converting whatever image object is returned by our call to Decode
is a matter of creating a new NRGBA image and copying over each pixel. The copy process uses another abstract class, color.Color
, to manage the conversion. Our load function ends up looking like this:
func loadMutable(path string) (*image.NRGBA, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
img, _, err := image.Decode(file)
if err != nil {
return nil, err
}
bounds := img.Bounds()
mutableImage := image.NewNRGBA(bounds)
for y := bounds.Min.Y; y < bounds.Max.Y; y += 1 {
for x := bounds.Min.X; x < bounds.Max.X; x += 1 {
mutableImage.Set(x, y, img.At(x, y))
}
}
return mutableImage, err
}
If we have a color of unknown type, we can get a color.NRGBA
pixel value out of it with a conversion and a runtime cast. The expression for doing so is color.NRGBAModel.Convert(c).(color.NRGBA)
—I’m pretty sure that the conversion mechanism in the Set
and At
functions above ends up doing something like this once everything resolves out.
When we know we have an image.NRGBA
, though, we may work directly with color.NRGBA
values with the methods NRGBAAt(x, y)
and SetNRGBA(x, y, nrgba)
. These coordinates need to be within the image’s bounding rectangle, which is not actually guaranteed to start at or even include the zero point. Extracting the bounds we need doesn’t materially make the logic more complex than the C version, though:
func negFlip(img *image.NRGBA) {
b := img.Bounds()
y1 := b.Min.Y
y2 := b.Max.Y - 1
for y1 <= y2 {
for x := b.Min.X; x < b.Max.X; x += 1 {
c1 := img.NRGBAAt(x, y1)
c2 := img.NRGBAAt(x, y2)
img.SetNRGBA(x, y1, color.NRGBA{255 - c2.R, 255 - c2.G, 255 - c2.B, c2.A})
img.SetNRGBA(x, y2, color.NRGBA{255 - c1.R, 255 - c1.G, 255 - c1.B, c1.A})
}
y1 += 1
y2 -= 1
}
}
Had we decided to directly work with the image’s Pix
array we’d have wound up tracking the C code even more closely; not only is that array zero-indexed as all arrays are, the byte-by-byte format of the array actually exactly matches what stb_image
provides!
This implementation ended up doing all of its work in support functions, leaving very little for the main function to do on its own. Nevertheless, here it is:
func main() {
if len(os.Args) != 2 {
log.Fatal("Usage: " + os.Args[0] + " {filename}")
}
img, err := loadMutable(os.Args[1])
if err != nil {
log.Fatal(err)
}
negFlip(img)
savePng("result.png", img)
}
Python
Python does not include a built-in image processing library, but there has been a standard one available for some time: the Python Imaging Library, or PIL. In fact, it’s been available for so long that it’s been abandoned; the current maintained fork of it is named Pillow. If it’s not already installed on your system, you may install it on your version of Python with a command like pip install --user pillow
. (Pip is the standard package manager for Python; see the Pip documentation for more details if you need them.) Our needs here are pretty minimal, so we will not be using much of the library: our only import will be the PIL.Image
subpackage.
Reading in an image file is accomplished with the PIL.Image.open
function, which accepts either a filename, a pathlib.Path
object, or a file object. It does not directly support initialization from in-memory byte arrays or similar, but it doesn’t need to; the io.BytesIO
class in the standard library is a sufficient adapter.
Unlike the stb_image
library for C/C++, PIL’s decoders are powerful enough to handle various explicit color encodings—greyscale, paletted, even CMYK. In order to have a consistent pixel API, we will need to explicitly convert it to an RGBA format. PIL.Image.Image
objects have a convert
method that returns a copy of the image reformatted to the specified format. We’ll want "RGBA"
.
Once we have an RGBA-formatted image, we may read and write pixel data with the getpixel
and putpixel
methods. These take coordinates as (x, y)
pairs and take or give colors as (r, g, b, a)
tuples. The image dimensions are available as fields named width
and height
.
Once we’ve done our work, the save
method accepts the same arguments as the open
function but will save out the image instead. An optional second argument is a string specifying the image format we want to use; usually it can figure this out from the filename, but if we’re using BytesIO
again to dump to memory we’ll need to pass a string like "png"
here.
PIL is concise enough that there isn’t really much to decompose and we may present the entire program at once:
import sys
import PIL.Image
def flip_pixel(c):
return (255 - c[0], 255 - c[1], 255 - c[2], c[3])
if len(sys.argv) != 2:
print(f"Usage:\n\t{sys.argv[0]} <image>", file=sys.stderr)
sys.exit(1)
img = PIL.Image.open(sys.argv[1]).convert("RGBA")
y1 = 0
y2 = img.height - 1
while y1 <= y2:
for x in range(img.width):
c1 = img.getpixel((x, y1))
c2 = img.getpixel((x, y2))
img.putpixel((x, y1), flip_pixel(c2))
img.putpixel((x, y2), flip_pixel(c1))
y1 += 1
y2 -= 1
img.save("result.png")
Rust
Rust’s design space mostly competes with C++, offering stricter static checking and more structure to make passing those checks more feasible. I have yet to take on a large project with Rust, but I’ve meddled with it enough that I’m pretty confident in my usage of it here.
The Rust standard library also does not include image processing routines, but the image crate is both popular and actively maintained. The data types in this crate are very generic and make heavy use of type arguments. Its ImageReader
type returns values of a DynamicImage
type, but in order to do any useful work with it we’ll need to normalize its pixel type. This conversion is managed with the method into_rgba8()
, which consumes the self
argument and returns a version of it that is an ImageRgba8
. The pixel type associated with this is named Rgba8<u8>
and this in turn is a 1-tuple that holds a 4-element array of u8
. I break out the pixel-negation function here roughly the same way I do in Python, and destructuring assignment makes it less bad than it could be:
fn negpixel(p: &Rgba<u8>) -> Rgba<u8> {
let [r, g, b, a] = p.0;
Rgba([255 - r, 255 - g, 255 - b, a])
}
Pixel-level access is a little slippery. The underlying ImageBuffer
type offers a number of mechanisms for iterating through an image a pixel or a row of pixels at a time, but for the kind of random-access read-write operations, we’ll need to use get_pixel()
, get_pixel_mut()
, and put_pixel()
. The hitch here is that the pixel-getting functions borrow the pixel value out of the underlying image so Rust’s rules about borrowing the same structure multiple times will bite us unless we are careful.
In particular, we are not allowed to modify the image as long as any of the pixels in the image are being borrowed as constant values—but modifying the pixels we borrow as mutable values will edit the results before we’re ready to do that! We could copy the values we read with the standard clone()
method, but it’s more effective to just front-load our calls to negpixel()
so that we stop borrowing the pixel as soon as we compute the negative image.
Once we’ve made our edits, the save()
method will let us write the results out.
Error handling is managed via Rust’s usual Result<>
mechanism, with some custom types that implement the std::error::Error
trait. Because of this, and because of the way Rust’s error macros work, I found it easiest to package all of the program logic into a single function that returns a Box<dyn std::error::Error>
:
fn negflip(fname: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut img = ImageReader::open(fname)?.decode()?.into_rgba8();
let h = img.height();
let w = img.width();
if h > 0 && w > 0 {
let mut y1 = 0;
let mut y2 = h - 1;
while y1 <= y2 {
for x in 0..w {
let p1 = negpixel(img.get_pixel(x, y1));
let p2 = negpixel(img.get_pixel(x, y2));
img.put_pixel(x, y2, p1);
img.put_pixel(x, y1, p2);
}
y1 += 1;
y2 -= 1;
}
}
img.save("result.png")?;
Ok(())
}
We’ll also need to properly import the image crate, of course. Our Cargo.toml
manifest will need to reflect that:
[package]
name = "negflip"
version = "1.0.0"
edition = "2024"
[dependencies]
image = "0.25.8"
And then we’ll need to actually import it in our main.rs
file itself:
use image::{ImageReader, Rgba};
The main program function manages the command-line arguments and processes any error messages that come out of the negflip()
routine.
fn main() {
let args: Vec<String> = std::env::args().collect();
if args.len() != 2 {
eprintln!("Usage:\n\t{} <filename>", args[0]);
std::process::exit(1);
}
if let Err(e) = negflip(&args[1]) {
eprintln!("Error processing {}: {}", args[1], e);
std::process::exit(1);
}
}
A Concluding Collection of First Impressions
Here’s how I feel about all of these systems after implementing the same program five times.
- I expect to be sticking with my personal C-based workflow. None of the other languages really offer me anything with a significant advantage over what I already have.
- That said, none of these options were awful. I might blanch at Rust making me borrow individual pixels, or at how its
image
crate has ninety-eight dependencies, or at how Java’s pixel values are signed, but I didn’t hit any obvious deal breakers with any of these. - Java and Python significantly outperformed my expectations. Python’s
pillow
library in particular was extremely friendly to work with. Even though I expected that one to be easy to use going in, I stillended up pleasantly surprised. If you need to process or create some pixel art, all of these will work fine, and the code above should serve as enough of a template to get you off the ground.