CFFI bindings to IM, Tecgraf's toolkit for
image representation, storage, capture and processing, plus im(1), a command
line tool that drives them.
This project is unaffiliated with Tecgraf.
Requires tecgraf-im v2.2.1 or later: v0.8.0 binds the watershed, the edge-preserving denoising filters, Richardson-Lucy deconvolution and the shape and intensity measurements, none of which earlier releases export. v2.2.1 rather than v2.2.0 because two bugs found while writing those bindings are fixed in it, and working around them here cost more than the operations were worth — a progress callback killed the process outright in the OpenMP build, and asking for fewer regions than an image carries corrupted the heap. v0.7.x needs v2.1.1, the first usable release carrying the decorrelation stretch.
Built against lispnik/tecgraf-im, a
CMake fork of IM 3.15. The bindings cover 468 C functions — every function
exported by libim, libim_process, libim_capture, libim_fftw3 and the
format add-ons, apart from a documented list of driver internals.
Prebuilt binaries for Linux (amd64 and arm64), macOS (Apple Silicon) and Windows are attached to each release. They embed an SBCL core but do not bundle IM, so the shared libraries still have to be present -- see Finding the libraries below.
- SBCL, and ocicl for dependencies
- IM 3.15 shared libraries
ocicl install # restore the pinned dependencies
make # build bin/im
make test # run the test suiteThe bindings look for libim and its add-ons in this order:
im:*library-path*, if set- the
IM_LIBRARY_PATHenvironment variable - a
lib/directory beside the running executable — the release layout cffi:*foreign-library-directories*and the platform's own search path
IM_LIBRARY_PATH=/path/to/tecgraf-im/build/lib ./bin/im libraryim library reports the IM version and exactly which shared objects were
opened, which is the fastest way to tell whether an add-on is present.
Add-ons are optional. libim_jp2, libim_heif and libim_capture are all
switched off in upstream's default build, and the bindings load without them —
their formats simply do not appear in im formats.
im info FILE... format, dimensions, colour mode, attributes
im formats the registered formats and their compressions
im convert IN OUT format, compression, colour space or depth
im process IN OUT a pipeline of operations
im analyze FILE label connected regions and measure them
im stats FILE per-plane statistics and histograms
im compare A B RMS error and signal-to-noise ratio
im diff A B SSIM, perceptual hash, and a difference heatmap
im montage FILE... lay many images out as one contact sheet
im capture list capture devices, or grab a frame
im library IM version and the libraries in use
Every subcommand takes --json, which emits one JSON value:
im info photo.jpg --json | jq '.frames[0] | {width, height}'Operations in im process are given as repeated --op arguments and applied
in the order written, which is why they are not one flag each:
im process in.jpg out.png \
--op resize=50% --op colorspace=gray --op gaussian=1.5 --op sobel--op dstretch=SPACE[,SCALE] is the decorrelation stretch — the enhancement
DStretch is built on, which pulls apart colours that lie along a single axis so
faint differences become visible. lds is the general-purpose space and the
default scale is 6:
im process faded.jpg enhanced.png --op dstretch=ldslre, yre and crgb favour reds, yye and lye yellows, ybk and lbk
blacks and blues. im:decorrelation-fit and im:decorrelation-apply expose the
same thing as a transform that can be fitted to one image and applied to a
whole series, which is how you get consistent colour across a set.
Three filters smooth an image without blurring across its edges, which
gaussian and median cannot do. The parameter that matters in each is a
threshold in the image's own sample units, and the right value for it is
roughly the noise level:
im process noisy.png clean.png --op bilateral=3,25 # spatial, range
im process noisy.png clean.png --op diffusion=15,25 # iterations, kappa
im process noisy.png clean.png --op nlmeans=5,2,20 # search, patch, stddevnlmeans recovers repeated fine structure the other two smooth away, and is
one to two orders of magnitude slower for it. diffusion takes an optional
time step and conductance function — exponential, quadratic or tukey.
--op deconvolve=STDDEV[,ITERATIONS] is Richardson-Lucy deconvolution of a
Gaussian blur. The iteration count is the only regularisation there is: past a
few tens of steps it starts fitting the noise, which shows as ringing that
grows with every further one.
im process blurred.png sharp.png --op deconvolve=1.5,20im:deconvolve-richardson-lucy takes any point spread function image, for a
measured PSF rather than a Gaussian.
--op watershed[=CONNECTIVITY][,lines] splits touching objects that connected
component labelling has to call one region, and writes a label image. It does
not binarise for you — which binarisation is used decides what the objects are
— so put a threshold in front of it:
im process cells.png labels.png --op threshold=otsu --op watershed=8im process --list-ops lists all twenty-six. Sizes accept WxH, 800x or
x600 to preserve the aspect ratio, and 50%.
im analyze labels regions and measures them. --measure picks what to
report — area, centroid, bbox, hull, feret, intensity, or all;
the default is area and centroid. --watershed separates touching objects
first, the same split --op watershed does:
im analyze cells.png --watershed --measure all --json | jq '.regions[0]'feret is the caliper diameters — the longest distance across a region and its
narrowest width, with angles. intensity is the only measurement that reads
the image rather than the labels, and so the only one that says how bright a
region is rather than what shape it is.
Exit codes are 0 for success, 1 for an IM error, 2 for a usage error and 130
for an interrupt. Diagnostics go to stderr, so piping stdout to jq is safe.
im compare measures how far apart two images' pixels are; im diff answers
the structural question — are they the same picture — with SSIM, a perceptual
hash (which compares even across sizes and re-encodings), and, with --output,
a heatmap of where they differ:
im diff before.png after.png --output changed.png
im diff original.jpg thumbnail.jpg # different sizes: hashes still answerim montage composes many images into one contact sheet, normalising mixed
sizes, colour spaces and depths onto a plain background:
im montage shots/*.png --output sheet.png --columns 4 --tile 200x200bin/im-mcp (built by make) is a Model Context Protocol server, so an agent
can inspect, measure and transform images through the same binding. It speaks
JSON-RPC over stdio and exposes eight tools:
| Tool | What it answers |
|---|---|
im_info |
format, dimensions, colour space, data type, frames |
im_stats |
per-plane min, max, mean, stddev |
im_formats |
what this build can read and write |
im_diff |
are these the same picture — RMSE, PSNR, SSIM, perceptual hashes |
im_thumbnail |
the picture itself, inline |
im_montage |
many pictures as one contact sheet, inline |
im_analyze |
how many objects, where, and how big |
im_process |
a pipeline of operations, result inline |
im_thumbnail, im_montage and im_process return the image inline, so the
agent gets the picture rather than a path it cannot open. Point an MCP client
at the executable:
{ "command": "/path/to/bin/im-mcp" }im_analyze is the one that answers a question no thumbnail can. Counting
objects, measuring them, and separating the ones that touch is not something a
model can do by looking:
{ "name": "im_analyze",
"arguments": { "path": "cells.png", "watershed": true, "measure": "all" } }im_process takes the same --op vocabulary as im process, applied in
order, and returns a scaled preview inline — with output to write the
full-size result somewhere:
{ "name": "im_process",
"arguments": { "path": "noisy.png", "ops": ["bilateral=3,25", "unsharp=2,1,0"] } }Every tool reuses the image algebra behind im(1) rather than reimplementing
it — im_analyze and im_process call the very functions im analyze and
im process do — so the command line and the agent interface cannot drift.
(asdf:load-system :im)
(im:with-image (photo (im:load #p"photo.jpg"))
(im:with-image (edges (im:create-based photo :color-space :color-space-gray))
(im:convolve-sobel photo edges)
(im:save edges #p"edges.png")))im:image is a CLOS object wrapping IM's imImage. Its storage is released by
im:destroy, by im:with-image on unwind, or — for images that escape both —
by a finalizer. im:destroy is idempotent and disarms the finalizer, so the
two cannot race. Operating on a destroyed image signals im:invalid-image
rather than reading freed memory.
Pixel data is reached through im:plane-pointer, a raw foreign pointer. That
is deliberate, and it is IM's own reasoning: the library supports so many data
organisations that general-purpose per-pixel accessors would be both
complicated and slow. Planes are always unpacked and stored bottom-up.
Images carry named metadata, and formats store what they recognise. Attributes
are read from a file with im:attributes, and set on an image — which is what
im:save writes out — with im:set-image-attribute or setf:
(im:with-image (photo (im:load #p"photo.jpg"))
(setf (im:image-attribute photo "Author") "Ada Lovelace")
(im:set-image-attribute photo "Levels" #(1 2 3)) ; stored as int
(im:set-image-attribute photo "Gamma" 2.2d0) ; stored as double
(im:set-image-attribute photo "Small" 200 :data-type :data-type-byte)
(im:save photo #p"tagged.png"))
(im:attribute #p"tagged.png" "Author")
;; => ("Ada Lovelace" :DATA-TYPE-BYTE 13)The data type is inferred as the narrowest one that holds the value exactly —
byte for a string, int for integers, double for floats, cdouble for complex
numbers — and :data-type overrides it. A value that will not fit the type is
an error rather than a truncation, and so is a ratio, which no IM type holds
exactly; (setf (im:image-attribute ...) nil) removes the attribute.
Byte attributes do not round-trip as vectors: IM stores text in them, so
#(65 66) reads back as "AB".
What survives the write is the format's decision. PNG keeps "Author"; TIFF
has no tag for it and drops it silently. Read the file back rather than
assuming.
im:display writes a PNG and asks the attached editor to show it:
(im:with-image (photo (im:load #p"photo.jpg"))
(im:display photo))One thing has to be set up on the Emacs side, and neither can be checked from Lisp:
| Front end | Prerequisite | Where the image appears |
|---|---|---|
| SLY | (setq sly-enable-evaluate-in-emacs t) |
an *im-image* buffer |
| SLIME | (slime-setup '(slime-media)) |
inline, as the REPL result |
Both are one-way messages, so a missing prerequisite is reported by Emacs, not
here. With nothing attached at all — a bare REPL, a script — im:display
signals im:display-unavailable rather than writing a file no one will look
at. Bind im:*display-function* to render somewhere else.
im:enable-repl-images goes one step further under SLIME: a bare im:image
result then renders itself instead of printing #<IM:IMAGE …>. (SLY's mrepl
has no result hook, so there it signals and you call im:display yourself.)
Both paths under SLIME need the slime-media contrib, and this is where its absence shows: an image result errors in Emacs with
slime-dispatch-event: slime-dcase failed: (:write-image ((:type png :file "…")) "#<IM:IMAGE …>")
That is SLIME's core dispatcher rejecting the :write-image event because
slime-media — which handles it — was never loaded. Add it and reconnect:
(slime-setup '(slime-repl slime-media))or, to fix a running session without restarting, evaluate in Emacs:
(require 'slime-media)
(add-hook 'slime-event-hooks 'slime-dispatch-media-event)The processing operations take (source destination …) — you allocate the
destination, they fill it. That is the right primitive and an awkward shape for
the REPL, so im:pipe threads an image through a series of functional stages,
reclaiming each intermediate as the next consumes it:
(im:pipe (im:load #p"photo.jpg")
(im:resized :scale 0.5) ; functional wrappers for the two
#'im:grayscale ; shape-changing operations
(im:derive #'im:convolve-sobel) ; any (src dst) op, made functional
#'im:show) ; print stats + display, pass throughA stage is a function of one image returning a fresh one. im:derive turns any
same-shape operation (convolve-sobel, negative, morph-erode, …) into that
shape; im:grayscale and im:resized wrap the common shape-changers; anything
else is a one-line lambda. The image passed in and the one returned are the
caller's to own — only the intermediates are freed, and only after the next
stage has used them (im:destroy is idempotent and finalizer-backed, so this
is safe even for a stage that returns its own argument).
im:show prints an image's geometry and per-plane statistics and displays it,
returning the image so it drops into a pipe as a tap.
Every failure is a subtype of im:im-error, and each of IM's error codes has
its own class, so causes are distinguished by handler rather than by testing a
slot:
(handler-case (im:load path)
(im:open-error (c) (format t "cannot open: ~A" (im:error-detail c)))
(im:format-error (c) (format t "unrecognised format")))A callback installed with im:with-progress is called as an operation runs and
can stop it. Cancelling signals im:operation-aborted — a real error, not a
silent NIL — and the operation offers retry and continue restarts:
(im:with-progress ((lambda (id text percent)
(declare (ignore id text))
(< percent 500))) ; stop halfway
(im:convolve-gaussian source destination 8.0))| Path | Contents |
|---|---|
src/ffi/ |
The raw bindings. Generated, then hand-corrected. Do not add hand-written files here — the generator clears it. |
src/ffi-structs.lisp |
Hand-written C structs, kept outside the generated directory. |
src/*.lisp |
The Lisp API: conditions, library loading, images, files, processing, capture. |
src/cli/ |
im(1), one file per subcommand group. |
tools/gen-bindings.lisp |
The binding generator. Not part of any shipped system. |
make bindings IM_SOURCE=/path/to/tecgraf-imThe generator takes its symbol list from nm on the built libraries, not
from the headers, so it cannot bind a function that does not exist. Headers
declare several that no library implements. It also emits
src/ffi/manifest.lisp, which the test suite uses to check at runtime that
every bound function resolves.
Regenerating overwrites everything under src/ffi/. Run it into a clean tree
and read the diff.
- Names are spelled out and hyphenated:
imFileOpen→im:load,imProcessReduceBy4→im:resize. - Enums and bitfields are keywords:
:color-space-rgb,:data-type-byte. - IM packs a colour space and three configuration bits into one
int; the Lisp API keepsim:color-spaceandim:color-mode-configseparate. - C setters become
setffunctions, and out-parameters become multiple values.
libim_jp2 prints a JasPer deprecation banner on startup. It comes from
jas_init inside IM's JP2 driver, goes to stderr, and is harmless.
imProcessBitwiseOp's :xor is a true exclusive-or in this fork of IM;
upstream's computed NOR, which is available here as :nor. Code ported from
stock IM 3.15 changes behaviour silently.
On macOS, im capture can enumerate devices from a terminal but connecting to
one requires NSCameraUsageDescription in an application bundle. Without it
the process is killed by TCC rather than being allowed to fail.
MIT. See LICENSE.