Raster drawing with GD#
GD is Perl’s binding to libgd, the small C library behind a great deal of the web’s generated images: thumbnails, charts, captchas, badges. It creates an image, draws on it, filters it, and saves it, and its drawing primitives are the ones from the algorithms chapter running at C speed. On pperl it is a native module: the C half is bound directly to the system’s libgd, and GD’s own Perl half (GD::Image, GD::Polygon, GD::Simple) runs as it is, so code written for GD on any Perl 5 runs here unchanged.
The API is positional and terse. Methods take coordinates and a colour in a fixed order, and most of them return nothing useful. The ones that can fail either return false or undef, and the idiom is to check those, or die with the reason, as the file loaders do.
use GD;
my $im = GD::Image->new(400, 300, 1); # width, height, true-colour
Colours#
GD’s defining quirk is how it names a colour. You never pass red, green and blue to a drawing method. You ask the image for a colour first, with colorAllocate, and then draw with the integer it returns. What that integer means depends on the kind of image.
A palette image (created without the third argument) holds a table of at most 256 colours, and every pixel stores an index into it. This is the model of GIF and of 256-colour displays. colorAllocate adds an entry and returns its index, and the first colour you allocate becomes the background.
A true-colour image (third argument 1) stores the colour itself in every pixel. colorAllocate has no table to add to, so it simply packs the channels into one integer, and that integer is the colour. Prefer true-colour for all new work: it has no 256-colour ceiling, and filters, resampling and alpha blending all assume it.
use strict;
use warnings;
use GD;
# a palette image: colorAllocate hands out table indices
my $pal = GD::Image->new(100, 100);
my $white = $pal->colorAllocate(255, 255, 255); # first one is the background
my $red = $pal->colorAllocate(255, 0, 0);
print "palette: white=$white red=$red, ",
$pal->colorsTotal, " colours in the table\n";
# a truecolor image: the "index" is the colour itself, packed
my $tc = GD::Image->new(100, 100, 1);
my $tw = $tc->colorAllocate(255, 255, 255);
my $tr = $tc->colorAllocate(255, 0, 0);
my $half = $tc->colorAllocateAlpha(255, 0, 0, 64); # about 50% transparent
printf "truecolor: white=0x%08x red=0x%08x half=0x%08x\n", $tw, $tr, $half;
palette: white=0 red=1, 2 colours in the table
truecolor: white=0x00ffffff red=0x00ff0000 half=0x40ff0000
On a palette image the table does fill up. The 257th colorAllocate returns -1, and colorResolve is the safe alternative: it returns an exact match if there is one, allocates if there is room, and otherwise falls back to the closest colour already in the table.
use strict;
use warnings;
use GD;
my $pal = GD::Image->new(10, 10);
$pal->colorAllocate($_, 0, 0) for 0 .. 255; # fill all 256 slots
print "table: ", $pal->colorsTotal, " colours\n";
print "allocate one more: ", $pal->colorAllocate(1, 2, 3), "\n";
print "resolve instead: ", $pal->colorResolve(1, 2, 3), "\n";
table: 256 colours
allocate one more: -1
resolve instead: 1
colorExact and colorClosest are the two halves of colorResolve on their own; rgb($colour) turns an index or packed colour back into its three channels.
Alpha, the GD way#
colorAllocateAlpha takes a fourth value, and it runs the opposite way to the foundations chapter: in GD, alpha 0 is fully opaque and 127 is fully transparent, a 7-bit range with transparency at the top. So 64 is roughly half and half.
What happens when you draw with a translucent colour depends on alphaBlending. With blending on (the default for true-colour images) the colour is composited source-over onto what is already there. With it off, the pixel is simply replaced, alpha and all.
use strict;
use warnings;
use GD;
my $im = GD::Image->new(100, 100, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $half = $im->colorAllocateAlpha(255, 0, 0, 64);
my @before = $im->rgb($im->getPixel(50, 50));
print "fresh canvas: @before\n"; # truecolor starts black
$im->filledRectangle(0, 0, 99, 99, $white);
$im->filledRectangle(0, 0, 49, 99, $half); # blending on: composite
$im->alphaBlending(0);
$im->filledRectangle(50, 0, 99, 99, $half); # blending off: replace
for my $x (25, 75) {
my $c = $im->getPixel($x, 50);
printf "x=%d: rgb=%s alpha=%d\n", $x, join(' ', $im->rgb($c)), $im->alpha($c);
}
fresh canvas: 0 0 0
x=25: rgb=255 128 128 alpha=0
x=75: rgb=255 0 0 alpha=64
The blended half is the pink 255 128 128 the foundations chapter computed by hand for red at half opacity over white. The other half keeps pure red with alpha 64 stored in the pixel, which is what you want when you are building an image with transparent regions to save (see transparency in files below).
The first line of output is a trap worth stating once: a new true-colour image starts black, not white. Always paint the background with a filledRectangle over the whole canvas before drawing anything else.
The primitives#
Every primitive takes its geometry first and the colour last, all positional. Rectangles use two corners, both inclusive, so a full 400 x 300 canvas is (0, 0, 399, 299). Ellipses and arcs use a centre plus the full width and height (not a radius), and arc angles are in degrees, starting at three o’clock and increasing clockwise (clockwise because y grows downward).
use strict;
use warnings;
use GD;
my $im = GD::Image->new(400, 300, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $black = $im->colorAllocate(0, 0, 0);
my $blue = $im->colorAllocate(0, 90, 200);
my $red = $im->colorAllocate(220, 40, 40);
my $green = $im->colorAllocate(60, 170, 80);
my $yellow = $im->colorAllocate(250, 200, 40);
$im->filledRectangle(0, 0, 399, 299, $white); # background
# lines: plain, dashed, and thick
$im->line(10, 10, 130, 60, $black);
$im->dashedLine(10, 30, 130, 80, $black);
$im->setThickness(5);
$im->line(10, 60, 130, 110, $blue);
$im->setThickness(1);
# rectangles: corner to corner, both corners inclusive
$im->rectangle(150, 10, 250, 80, $black);
$im->filledRectangle(270, 10, 390, 80, $yellow);
# ellipses and arcs: centre, then full width and height
$im->ellipse(70, 170, 110, 60, $black);
$im->filledEllipse(70, 170, 50, 50, $blue);
$im->arc(200, 170, 100, 100, 0, 270, $black); # three quarters
$im->filledArc(330, 170, 100, 100, 30, 330, $yellow); # a pie slice
$im->filledArc(330, 170, 100, 100, 30, 330, $black, gdEdged | gdNoFill);
# polygons come from a GD::Polygon
my $tri = GD::Polygon->new;
$tri->addPt(20, 290);
$tri->addPt(120, 290);
$tri->addPt(70, 220);
$im->filledPolygon($tri, $green);
$im->polygon($tri, $black);
my $zig = GD::Polygon->new;
$zig->addPt($_->[0], $_->[1]) for [150, 280], [180, 230], [210, 280], [240, 230];
$im->unclosedPolygon($zig, $red); # open path, no closing edge
# flood fills
$im->rectangle(270, 230, 390, 290, $black);
$im->line(330, 230, 330, 290, $black);
$im->fill(300, 260, $red); # fills the left cell only
$im->fillToBorder(360, 260, $black, $blue); # stops at black pixels
open my $fh, '>', 'primitives.png' or die "primitives.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
The result is a 3 x 3 sheet: three lines, an outlined and a filled rectangle; an ellipse around a disc, a three-quarter arc open at the top right, a pie with its mouth to the right; a filled triangle, a red zigzag, and a box whose two halves were flood-filled red and blue.
A few details the sheet shows:
filledArctakes an optional style. The default fills a pie slice;gdChordreplaces the curved edge with a straight line between the two end points,gdNoFilldraws only the outline, andgdEdgedadds the two radii. They combine with|, which is how the pie above gets its black outline on a second call.polygon(also spelledopenPolygon) draws a closed outline;unclosedPolygonleaves off the edge from the last point back to the first, which is the polyline.filledPolygonfills using the scanline method.fillfloods the connected region of the colour under the seed point, so it stopped at the black divider.fillToBorderfloods until it meets one named border colour, whatever it crosses on the way.setThicknesschanges the pen for every later line, outline and arc, until you set it back.
Single pixels are setPixel($x, $y, $colour) and getPixel($x, $y); the latter returns the index or packed colour, which rgb and alpha take apart.
Anti-aliasing#
Everything above is aliased: each pixel is fully on or fully off, the staircase of the algorithms chapter. GD anti-aliases through a special colour. You name the real colour with setAntiAliased, then draw with the constant gdAntiAliased in place of a colour:
use strict;
use warnings;
use GD;
my $im = GD::Image->new(220, 60, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $black = $im->colorAllocate(0, 0, 0);
$im->filledRectangle(0, 0, 219, 59, $white);
$im->line(10, 10, 100, 37, $black); # aliased
$im->setAntiAliased($black);
$im->line(120, 10, 210, 37, gdAntiAliased); # anti-aliased
# the same column of each line, three pixels high
for my $x (55, 165) {
my @grey = map { ($im->rgb($im->getPixel($x, $_)))[0] } 22 .. 24;
print "x=$x: @grey\n";
}
open my $fh, '>', 'aa.png' or die "aa.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
x=55: 255 255 0
x=165: 255 127 128
At that column the ideal line passes exactly between two pixel rows. The aliased line picks one and paints it solid black; the anti-aliased line gives each row half coverage, two mid greys. That is the coverage idea from the algorithms chapter, computed for you.
gdAntiAliased works for lines and for polygons, outlined or filled. filledEllipse ignores it and keeps a hard edge. For curves that must be smooth, see when GD is the wrong tool.
GD has three more special colours in the same style: gdStyled draws with a repeating pattern of colours set by setStyle (a custom dash), gdBrushed stamps a small image set by setBrush along the line, and gdTiled fills with an image set by setTile.
Text#
GD has two text systems. The built-in bitmap fonts need nothing installed: gdTinyFont, gdSmallFont, gdMediumBoldFont, gdLargeFont and gdGiantFont, fixed-size and ASCII-oriented, drawn with string (and stringUp for text rotated 90 degrees). The TrueType route, stringFT, renders any font file through FreeType at any size and angle, anti-aliased.
use strict;
use warnings;
use GD;
my $im = GD::Image->new(320, 150, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $black = $im->colorAllocate(0, 0, 0);
my $blue = $im->colorAllocate(0, 90, 200);
$im->filledRectangle(0, 0, 319, 149, $white);
# the built-in bitmap fonts: x, y is the top-left of the text
$im->string(gdSmallFont, 10, 10, 'gdSmallFont', $black);
$im->string(gdGiantFont, 10, 28, 'gdGiantFont', $black);
$im->stringUp(gdMediumBoldFont, 300, 140, 'stringUp', $blue);
printf "gdGiantFont cell: %dx%d\n", gdGiantFont->width, gdGiantFont->height;
# TrueType through FreeType: x, y is the baseline start
my $font = '/usr/share/fonts/TTF/DejaVuSans.ttf';
my @box = $im->stringFT($blue, $font, 20, 0, 10, 90, 'TrueType text')
or die "stringFT: $@";
print "bounding box: @box\n";
# angle is in radians, counter-clockwise
$im->stringFT($black, $font, 11, 0.3, 180, 135, 'at an angle')
or die "stringFT: $@";
# measure without drawing: call it on the class, not an image
my @size = GD::Image->stringFT($black, $font, 20, 0, 0, 0, 'TrueType text');
printf "width %d, height %d\n", $size[2] - $size[0], $size[1] - $size[5];
# with fontconfig on, a font name works instead of a path
$im->useFontConfig(1);
$im->stringFT($black, 'DejaVu Sans:bold', 11, 0, 10, 135, 'by name')
or die "stringFT: $@";
open my $fh, '>', 'text.png' or die "text.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
gdGiantFont cell: 9x15
bounding box: 9 95 188 95 188 70 9 70
width 178, height 24
The font path is a system detail; on your machine fc-list shows the installed fonts and their files, or turn on useFontConfig and name the font as the last call does.
Three things differ between the two systems, and all three catch people. The position: string places the top-left corner of the text, stringFT places the start of the baseline, so the same y puts the two at different heights. The argument order: string takes the font first and the colour last, stringFT takes the colour first. And the angle: stringFT wants radians, counter-clockwise, while arc wants degrees, clockwise.
stringFT returns the four corners of the text’s bounding box as eight numbers (lower-left, lower-right, upper-right, upper-left, each as x and y), and an empty list on failure with the reason in $@, so with a wrong font path the example above dies with stringFT: Could not find/open font. Called on the class instead of an image it measures without drawing, which is how you centre or right-align a caption.
Saving and loading#
Output is one method per format, each returning the encoded bytes; you write them to a file opened in binary mode.
use strict;
use warnings;
use GD;
sub save {
my ($file, $bytes) = @_;
defined $bytes or die "$file: this libgd cannot write that format\n";
open my $fh, '>', $file or die "$file: $!";
binmode $fh;
print $fh $bytes;
close $fh;
}
my $im = GD::Image->new(200, 100, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $red = $im->colorAllocate(200, 40, 40);
$im->filledRectangle(0, 0, 199, 99, $white);
$im->filledEllipse(100, 50, 160, 80, $red);
save('shape.png', $im->png); # optional 0..9 compression level
save('shape.jpg', $im->jpeg(85)); # quality 0..100
save('shape.webp', $im->webp(80)); # quality 0..100
save('shape.gif', $im->gif); # palette format: colours are reduced
save('shape.bmp', $im->bmp);
my @loaded = (
[ 'shape.png', GD::Image->newFromPng('shape.png', 1) ], # 1: true-colour
[ 'shape.jpg', GD::Image->newFromJpeg('shape.jpg', 1) ],
[ 'shape.webp', GD::Image->newFromWebp('shape.webp') ],
[ 'shape.gif', GD::Image->newFromGif('shape.gif') ],
[ 'shape.bmp', GD::Image->newFromBmp('shape.bmp') ],
);
for (@loaded) {
my ($file, $back) = @$_;
printf "%-10s %5d bytes %dx%d centre=%s\n", $file, -s $file,
$back->getBounds, join(' ', $back->rgb($back->getPixel(100, 50)));
}
shape.png 765 bytes 200x100 centre=200 40 40
shape.jpg 3216 bytes 200x100 centre=200 40 40
shape.webp 1274 bytes 200x100 centre=199 40 39
shape.gif 413 bytes 200x100 centre=204 42 44
shape.bmp 60054 bytes 200x100 centre=200 40 40
The centre pixel tells the story of each format. PNG and BMP are lossless and give back exactly 200 40 40. JPEG and WEBP are lossy; on a flat area like this they come back exact or within a step. GIF is a palette format, so the true-colour image was reduced to at most 256 colours on the way out, and the red moved to the nearest colour in that palette.
Loading is one constructor per format, each taking a file name or an open filehandle: newFromPng, newFromJpeg, newFromGif, newFromWebp, newFromBmp. Each has a Data variant for bytes you already hold in a scalar, such as newFromPngData($bytes). A loader either returns an image or dies: a missing file with ... not found, a file it cannot decode with the name of the libgd call that refused it. That is why the loop above checks nothing.
The second argument of newFromPng and newFromJpeg (and their Data variants) decides whether a true-colour file stays true-colour, and leaving it out has a cost:
use strict;
use warnings;
use GD;
my $pal = GD::Image->newFromPng('shape.png'); # no flag: palette
my $tc = GD::Image->newFromPng('shape.png', 1); # 1: true-colour
for ([ 'no flag' => $pal ], [ 'flag 1' => $tc ]) {
my ($how, $im) = @$_;
printf "%-8s isTrueColor=%d centre=%s\n", $how, $im->isTrueColor,
join(' ', $im->rgb($im->getPixel(100, 50)));
}
my $was = GD::Image->trueColor(1); # the program-wide default
my $any = GD::Image->new('shape.png');
printf "trueColor(1), was %d: new(file) isTrueColor=%d\n", $was, $any->isTrueColor;
eval { GD::Image->newFromPng('shape.jpg') };
my ($why) = $@ =~ /^(.*?) at /;
print "wrong format: $why\n";
no flag isTrueColor=0 centre=204 42 44
flag 1 isTrueColor=1 centre=200 40 40
trueColor(1), was 0: new(file) isTrueColor=1
wrong format: gdImageCreateFromPng error
Without the flag, GD converts the loaded file to a palette image, the same reduction the GIF went through, and the red moves. Pass 1 for anything you will filter, resample or blend, which is what the rest of this chapter assumes. GD::Image->trueColor(1) changes the default for the whole program and returns the previous one; it also makes GD::Image->new($width, $height) true-colour, and it is the only way to steer GD::Image->new($file), which recognises PNG, JPEG, GIF and WEBP from the file’s first bytes but takes no flag of its own. A palette file such as a GIF loads as a palette image either way, and paletteToTrueColor converts it in place.
Which formats work is decided by how the system’s libgd was built, not by GD. An encoder libgd was built without returns undef, which is why save above checks. GD::supportsFileType answers from libgd’s format table, which lists a format as present even when its codec is a stub, so the reliable test is the one save does: ask for the bytes and see whether you got any.
Transparency in files#
Two separate mechanisms make parts of a saved image transparent, one per image kind.
use strict;
use warnings;
use GD;
# a PNG with a real alpha channel
my $im = GD::Image->new(100, 100, 1);
$im->alphaBlending(0); # write alpha, do not blend
$im->saveAlpha(1); # keep it in the PNG
my $clear = $im->colorAllocateAlpha(0, 0, 0, 127); # fully transparent
my $glass = $im->colorAllocateAlpha(0, 90, 200, 64);
$im->filledRectangle(0, 0, 99, 99, $clear);
$im->filledEllipse(50, 50, 80, 80, $glass);
open my $fh, '>', 'glass.png' or die "glass.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
# a palette GIF with one colour marked transparent
my $gif = GD::Image->new(100, 100);
my $bg = $gif->colorAllocate(255, 255, 255);
my $ink = $gif->colorAllocate(0, 128, 0);
$gif->transparent($bg);
$gif->filledRectangle(25, 25, 74, 74, $ink);
open $fh, '>', 'stamp.gif' or die "stamp.gif: $!";
binmode $fh;
print $fh $gif->gif;
close $fh;
my $back = GD::Image->newFromPng('glass.png', 1);
printf "glass.png: corner alpha %d, centre alpha %d\n",
$back->alpha($back->getPixel(0, 0)), $back->alpha($back->getPixel(50, 50));
my $g = GD::Image->newFromGif('stamp.gif');
printf "stamp.gif: transparent index %d\n", $g->transparent;
glass.png: corner alpha 127, centre alpha 64
stamp.gif: transparent index 0
For a true-colour PNG, per-pixel alpha survives the round trip only when both switches are set: alphaBlending(0) so the alpha is written into the pixels rather than blended away, and saveAlpha(1) so the PNG encoder keeps the channel. Forget the second and the file comes out opaque. For a palette image, transparent($index) marks one colour of the table as “see-through”, all or nothing, which is the only kind of transparency GIF has.
Scaling and copying#
GD scales by copying from one image into another of a different size. You create the destination first, then copy a rectangle of the source into a rectangle of the destination. There are two copies, and the difference is the one between the aliased and anti-aliased lines. copyResized picks the nearest source pixel for each destination pixel. copyResampled averages the source pixels that land on each destination pixel, which is the supersampling downscale from the algorithms chapter. The example shrinks the primitives.png sheet saved earlier both ways:
use strict;
use warnings;
use GD;
my $src = GD::Image->newFromPng('primitives.png', 1);
my ($sw, $sh) = $src->getBounds;
# fit inside 160x160, keeping the aspect ratio
my $scale = 160 / ($sw > $sh ? $sw : $sh);
my ($dw, $dh) = (int($sw * $scale + 0.5), int($sh * $scale + 0.5));
my $fast = GD::Image->new($dw, $dh, 1);
my $smooth = GD::Image->new($dw, $dh, 1);
# src dstX dstY srcX srcY dstW dstH srcW srcH
$fast->copyResized($src, 0, 0, 0, 0, $dw, $dh, $sw, $sh);
$smooth->copyResampled($src, 0, 0, 0, 0, $dw, $dh, $sw, $sh);
for ([nearest => $fast], [resampled => $smooth]) {
my ($name, $im) = @$_;
my %colours;
for my $y (0 .. $dh - 1) {
$colours{ $im->getPixel($_, $y) }++ for 0 .. $dw - 1;
}
printf "%-9s %dx%d, %d distinct colours\n", $name, $dw, $dh, scalar keys %colours;
open my $fh, '>', "thumb-$name.png" or die "thumb-$name.png: $!";
binmode $fh;
print $fh $im->png;
close $fh;
}
nearest 160x120, 6 distinct colours
resampled 160x120, 133 distinct colours
The nearest-neighbour thumbnail keeps only the six colours it was drawn with, and pays for it: one-pixel lines fall between the sampled pixels and vanish, so the rectangle outline and the arc from the primitives sheet are simply gone. The resampled thumbnail blends them into faint but present lines, which is where its 133 in-between colours come from. Use copyResampled for anything a person will look at, and copyResized for pixel art you want to stay blocky when enlarged.
The same copy-a-rectangle shape does compositing:
use strict;
use warnings;
use GD;
my $canvas = GD::Image->new(100, 100, 1);
my $blue = $canvas->colorAllocate(0, 0, 255);
$canvas->filledRectangle(0, 0, 99, 99, $blue);
my $patch = GD::Image->new(40, 40, 1);
my $red = $patch->colorAllocate(200, 40, 40);
$patch->filledRectangle(0, 0, 39, 39, $red);
# src dstX dstY srcX srcY w h
$canvas->copy($patch, 10, 10, 0, 0, 40, 40); # stamp it
$canvas->copyMerge($patch, 50, 50, 0, 0, 40, 40, 50); # 50% blend
my $backup = $canvas->clone; # an independent copy
printf "copy: %s\n", join ' ', $canvas->rgb($canvas->getPixel(30, 30));
printf "copyMerge: %s\n", join ' ', $canvas->rgb($canvas->getPixel(70, 70));
printf "clone: %s\n", join ' ', $backup->rgb($backup->getPixel(70, 70));
copy: 200 40 40
copyMerge: 100 20 147
clone: 100 20 147
copy stamps the source unchanged. copyMerge takes a last argument from 0 to 100 and mixes that percentage of the source into the destination: half of 200 40 40 plus half of 0 0 255 is 100 20 147. clone returns a new image with the same pixels, for when you want to keep the original before filtering it. Storable’s dclone is no substitute: GD’s Storable hooks hand it an empty object with no image behind it, and the first method called on that object dies.
Rotating, flipping and cropping#
The quarter turns and mirror images are exact pixel moves, with no resampling and nothing lost. The copy forms return a new image and leave the source alone; flipHorizontal and flipVertical also come in place. crop returns a new image holding one rectangle of the source, given as an array reference of x, y, width and height. The example marks two corners of a card so the output can say where they went:
use strict;
use warnings;
use GD;
# a card that shows its orientation: red block top-left, blue block top-right
my $card = GD::Image->new(60, 40, 1);
my $white = $card->colorAllocate(255, 255, 255);
my $red = $card->colorAllocate(200, 40, 40);
my $blue = $card->colorAllocate(40, 90, 220);
$card->filledRectangle(0, 0, 59, 39, $white);
$card->filledRectangle(0, 0, 14, 14, $red);
$card->filledRectangle(50, 0, 59, 9, $blue);
# which corner pixel holds which block
sub corners {
my ($im) = @_;
my ($w, $h) = $im->getBounds;
my %at;
for ([ 'top-left', 0, 0 ], [ 'top-right', $w - 1, 0 ],
[ 'bottom-left', 0, $h - 1 ], [ 'bottom-right', $w - 1, $h - 1 ]) {
my ($name, $x, $y) = @$_;
my $c = $im->getPixel($x, $y);
$at{red} = $name if $c == $red;
$at{blue} = $name if $c == $blue;
}
return sprintf "%dx%d red %-12s blue %s", $w, $h,
$at{red} // '-', $at{blue} // '-';
}
print "original ", corners($card), "\n";
for my $method (qw(copyRotate90 copyRotate180 copyRotate270
copyFlipHorizontal copyFlipVertical)) {
my $turned = $card->$method; # a new image
printf "%-19s %s\n", $method, corners($turned);
}
my $mirror = $card->clone;
$mirror->flipHorizontal; # in place
print "flipHorizontal ", corners($mirror), "\n";
my $piece = $card->crop([0, 0, 30, 20]); # [x, y, width, height]
print "crop ", corners($piece), "\n";
# trim a border: every edge row and column within 0.5% of white goes
my $trimmed = $card->cropThreshold($white, 0.5);
print "cropThreshold ", corners($trimmed), "\n";
# a contact sheet of the card and its three rotations
my $sheet = GD::Image->new(250, 70, 1);
$sheet->filledRectangle(0, 0, 249, 69, $sheet->colorAllocate(200, 200, 200));
my $x = 5;
for my $im ($card, $card->copyRotate90, $card->copyRotate180, $card->copyRotate270) {
my ($w, $h) = $im->getBounds;
$sheet->copy($im, $x, 5, 0, 0, $w, $h);
$x += $w + 5;
}
open my $fh, '>', 'rotations.png' or die "rotations.png: $!";
binmode $fh;
print $fh $sheet->png;
close $fh;
original 60x40 red top-left blue top-right
copyRotate90 40x60 red top-right blue bottom-right
copyRotate180 60x40 red bottom-right blue bottom-left
copyRotate270 40x60 red bottom-left blue top-left
copyFlipHorizontal 60x40 red top-right blue top-left
copyFlipVertical 60x40 red bottom-left blue bottom-right
flipHorizontal 60x40 red top-right blue top-left
crop 30x20 red top-left blue -
cropThreshold 60x15 red bottom-left blue top-right
copyRotate90 turns the image a quarter clockwise as it appears on screen, the red corner moving from top-left to top-right, and swaps width and height; copyRotate270 is the quarter turn the other way. The contact sheet rotations.png shows the card and its three turns side by side. A flip is a mirror, not a turn: after copyFlipHorizontal the blue block is on the left, but both blocks are still at the top, which no rotation achieves.
cropThreshold strips a uniform border instead of taking a rectangle you name. It removes rows and columns from each edge for as long as every pixel in them lies within the threshold of the colour you pass, the threshold being a percentage of the largest possible colour distance, measured squared. On the card only the bottom goes: the top row, the left column and the right column each touch a coloured block, so the white below the red block’s 15 rows is all it can take. A threshold of 0 matches nothing, so a small positive value is the usual choice even for an exact colour.
Filters#
libgd ships a set of whole-image filters. Each works in place and returns true on success. Most of them are fixed 3 x 3 convolutions, the operation the algorithms chapter wrote by hand, which the example below makes visible by reading one pixel on the edge of a red square and one inside it:
use strict;
use warnings;
use GD;
# a test card: a red square on white, so edges show what a filter does
sub card {
my $im = GD::Image->new(40, 40, 1);
my $white = $im->colorAllocate(255, 255, 255);
my $red = $im->colorAllocate(200, 40, 40);
$im->filledRectangle(0, 0, 39, 39, $white);
$im->filledRectangle(10, 10, 29, 29, $red);
return $im;
}
my @filters = (
[ gaussianBlur => [] ],
[ meanRemoval => [] ], # sharpen
[ emboss => [] ],
[ edgeDetectQuick => [] ],
[ negate => [] ],
[ grayscale => [] ],
[ brightness => [40] ], # -255 .. 255
[ contrast => [50] ], # positive lowers contrast, negative raises it
[ smooth => [1] ], # weight of the centre pixel
[ pixelate => [8, 0] ], # block size, mode
);
for my $f (@filters) {
my ($method, $args) = @$f;
my $im = card();
$im->$method(@$args) or die "$method failed\n";
printf "%-16s edge=%-12s inside=%s\n", $method,
join('/', $im->rgb($im->getPixel(10, 20))),
join('/', $im->rgb($im->getPixel(20, 20)));
}
gaussianBlur edge=213/93/93 inside=200/40/40
meanRemoval edge=35/0/0 inside=200/40/40
emboss edge=209/255/255 inside=127/127/127
edgeDetectQuick edge=17/0/0 inside=127/127/127
negate edge=55/215/215 inside=55/215/215
grayscale edge=87/87/87 inside=87/87/87
brightness edge=240/80/80 inside=240/80/80
contrast edge=145/105/105 inside=145/105/105
smooth edge=218/111/111 inside=200/40/40
pixelate edge=255/255/255 inside=200/40/40
Reading the table:
gaussianBlurchanges only the edge. Its kernel is the 3 x 31 2 1 / 2 4 2 / 1 2 1, divided by 16; at the edge pixel the left column (weight 4 of 16) is white, so red becomes(4 x 255 + 12 x 200) / 16 = 213. One pass is a small blur; call it several times for more.meanRemovalis the sharpen kernel: nine times the centre minus the eight neighbours. Flat areas are untouched and the edge is pushed away from its neighbours, down to35/0/0.embossandedgeDetectQuickrespond only to change, and add a mid-grey offset of 127 so that “no change” is grey rather than black. That is why the flat inside reads127/127/127.negate,grayscaleandbrightnessare per-pixel, not convolutions, so edge and inside change alike.grayscaleuses the luma weights (0.299 R + 0.587 G + 0.114 B), giving 87 for this red.contrastis per-pixel too, and runs the way its name does not suggest: a positive value lowers the contrast. Every channel’s distance from mid-grey (127.5) is scaled by((100 - c) / 100)squared, so50keeps a quarter of it and red’s200becomes127.5 + 72.5 / 4 = 145. A negative value pushes channels apart instead, and-50already drives this red to pure255/0/0.smoothis a convolution with a centre weight you choose and weight 1 on the eight neighbours. With1all nine are equal, the plain box blur: three white pixels of nine give(3 x 255 + 6 x 200) / 9 = 218. A larger weight keeps more of the original pixel, so the blur gets weaker. Bothsmoothandcontrasttake fractional values, such assmooth(0.5)orcontrast(12.5).pixelatereplaces each block with one colour; mode0takes the block’s top-left pixel, mode1its average. The edge pixel sits in a block whose top-left corner is white.
Also available is selectiveBlur, which smooths regions while leaving strong edges alone (on the test card it leaves the edge exactly as it was).
GD::Simple#
GD::Simple ships with GD and wraps an image in a friendlier API: named colours (190 of them), a pen with a current position, and separate fill (bgcolor) and outline (fgcolor) colours, so one call draws a filled shape with a border.
use strict;
use warnings;
use GD::Simple;
my $img = GD::Simple->new(220, 120); # cleared to white
$img->bgcolor('yellow'); # fill colour
$img->fgcolor('blue'); # outline colour
$img->rectangle(10, 10, 90, 60);
$img->bgcolor('orange');
$img->fgcolor('red');
$img->moveTo(150, 40); # shapes draw at the pen position
$img->ellipse(80, 50);
$img->fgcolor('black');
$img->penSize(3, 3);
$img->moveTo(10, 100);
$img->angle(0); # heading: 0 points right
$img->line(100); # turtle-style: 100 px ahead
$img->moveTo(130, 105);
$img->font(gdMediumBoldFont);
$img->string('GD::Simple');
open my $fh, '>', 'simple.png' or die "simple.png: $!";
binmode $fh;
print $fh $img->png;
close $fh;
printf "%d colour names\n", scalar(my @names = $img->color_names);
190 colour names
The result is a yellow box with a blue border, an orange ellipse with a red border, a thick black line and a caption. Any method GD::Simple does not define is passed through to the GD::Image inside it, so the filters, copyResampled and the rest of this chapter all work on a GD::Simple object, and $img->gd returns the image itself. It is a good fit for charts, diagrams and quick sketches; for anything that needs exact control of colours and alpha, the plain GD::Image API is more direct.
A complete program#
Putting it together: a poster with a gradient background, two translucent discs whose overlap blends, a blur pass, an anti-aliased line and a TrueType caption drawn crisp on top, saved as a PNG plus a resampled JPEG thumbnail.
use strict;
use warnings;
use GD;
my ($W, $H) = (400, 300);
my $img = GD::Image->new($W, $H, 1);
# background: a vertical ramp from dark blue to near black
for my $y (0 .. $H - 1) {
my $t = $y / ($H - 1);
my $c = $img->colorAllocate(int(30 * (1 - $t)), int(40 * (1 - $t)), int(90 - 70 * $t));
$img->line(0, $y, $W - 1, $y, $c);
}
# two translucent discs; alpha blending composites the overlap
my $sun = $img->colorAllocateAlpha(255, 200, 60, 40);
my $sea = $img->colorAllocateAlpha(60, 200, 255, 40);
$img->filledEllipse(150, 140, 160, 160, $sun);
$img->filledEllipse(250, 140, 160, 160, $sea);
# soften: each gaussianBlur pass is one 3x3 kernel, so repeat for more
$img->gaussianBlur for 1 .. 3;
# a crisp anti-aliased horizon and a caption, drawn after the blur
$img->setAntiAliased($img->colorAllocate(255, 255, 255));
$img->line(20, 250, 380, 244, gdAntiAliased);
my $font = '/usr/share/fonts/TTF/DejaVuSans.ttf';
my $ink = $img->colorAllocate(240, 240, 240);
$img->stringFT($ink, $font, 16, 0, 20, 285, 'Perl, drawn with GD')
or die "stringFT: $@";
sub save {
my ($file, $bytes) = @_;
open my $fh, '>', $file or die "$file: $!";
binmode $fh;
print $fh $bytes;
close $fh;
}
save('poster.png', $img->png);
# a half-size thumbnail
my $thumb = GD::Image->new($W / 2, $H / 2, 1);
$thumb->copyResampled($img, 0, 0, 0, 0, $W / 2, $H / 2, $W, $H);
save('poster-thumb.jpg', $thumb->jpeg(85));
Note that the background allocates 300 colours in a loop. On a true-colour image that costs nothing, because colorAllocate only packs the channels; on a palette image the table would be full after 256 lines. Order matters too: the blur runs before the line and the caption, so the discs are soft and the text is sharp.
When GD is the wrong tool#
GD is raster, so it has the raster limitation: it works at a fixed resolution, and scaling past it degrades. Its anti-aliasing covers lines and polygons only, and its coordinates are integers. For smooth curves, sub-pixel positioning, gradients and output that must stay crisp at any size (a logo, a diagram destined for print or the web), use a vector engine: Cairo and SVG covers both routes, and pperl’s native Cairo binding is shown in the Cairo showcase.
When the job is a per-pixel algorithm no filter covers (a custom kernel, a colour transform, a generated texture), write it over the pixels yourself. For small images the plain-Perl loops of the algorithms chapter are enough, and setPixel and getPixel move the result in and out of GD; for large images, PDL does the same arithmetic on whole arrays at once.
When the drawing needs to appear in a live window rather than a file, that is the GUI toolkits chapter. For everything that is “make or modify a bitmap on disk”, GD is a small, fast and complete answer.