This project provides a robust C++ toolkit for image file I/O, designed to integrate seamlessly with the acrion/image container library. It bundles the powerful ImageMagick library for handling a wide range of common image formats and NASA's cfitsio library for native support of the FITS astronomical data format.
- Modern C++ library for reading/writing images.
- ImageMagick backend for common formats (JPEG, PNG, TIFF, BMP, TGA, etc.).
- FITS via cfitsio: uses the reference implementation, which is vendored (included directly) in this repository.
- Container-first design: interoperates with the separate acrion/image container library.
- Plugin mode: can be loaded by nexuslua to process images asynchronously via message passing.
- Zero-drama Windows runtime: at build time, the official ImageMagick build recipe (from the MSYS2 MINGW packages) is fetched, modified to support 32-bit depth, built, and then copied next to your binaries, including all modules and
.lafiles; no global installation is required.
Used by acrionphoto: This plugin provides the default image I/O for
acrionphoto.
acrionphotowill run without it, but cannot open or save images until an I/O plugin is installed.
The I/O role is replaceable: any plugin can provide image I/O as long as its main.lua declares the expected messages and parameters (see loadpath/savepath conventions below). This keeps I/O modular and allows alternative or additional providers.
Five messages draw into the working image. They are implemented in Lua, in
main.lua, not in C++ - deliberately, because this plugin is also meant to be read
as an example of what a plugin can do without leaving Lua. The pixel work uses peek, poke
and addoffset, which is the whole of the nexuslua memory API.
Where the boundary is: the messages that touch every pixel of an image (invert, the subtractions, the copies) are C++, because a full pass over a 3314x2489 frame is eight million round trips through the Lua stack. A shape touches its own outline or its own area, which is orders of magnitude less. If you need a filled shape the size of a whole frame, that is the moment to move that one function to C++ - not the rest of the file.
| message | draws |
|---|---|
CallDrawLine |
a straight line from x0,y0 to x1,y1, lineWidth pixels thick |
CallDrawRectangle |
a rectangle at rectX,rectY of rectWidth x rectHeight, outlined or filled |
CallDrawEllipse |
an ellipse around centerX,centerY with radiusX,radiusY, outlined or filled |
CallFloodFill |
fills the region around seedX,seedY whose brightness is within tolerance |
CallDrawText |
text at textX,textY in a built-in 5x7 font, magnified scale times |
The rectangle does not call its geometry width/height: those name the geometry of the
image in every message of every image plugin.
These five take their coordinates as parameters, so they are what a script or another plugin calls. What a user reaches for is a tool - see below.
CallDrawText needs no font from the system and no help from the host: the glyphs are part of
main.lua. A plugin has to work without acrionphoto, so asking the host to render
a string is not an option, and depending on an installed font would make the same call produce
different pixels on different machines.
The font covers the 95 printable ASCII characters. \n starts a new line at the original
textX; a byte outside that range - the two bytes of a UTF-8 umlaut, for instance - is drawn
as a filled block rather than skipped, so that a caller sees which plugin cannot render it
instead of wondering where the character went.
scale magnifies each font pixel into a square block, so text stays sharp at any size and a
caller can predict exactly how much room it takes. The reply carries textWidth and
textHeight, which is the one thing about a string a caller cannot work out from its own
arguments - and what centring a label or drawing a box around it both need.
The glyphs are stored in main.lua as a picture of themselves: sixteen side by side per band,
five columns each, seven rows tall. The usual form is 475 hexadecimal numbers, and a typo in
that is invisible in review, invisible in a diff, and beyond what a test can catch - "something
was drawn" passes whatever the shape turns out to be. Written as a sheet, reading the font is
proof-reading it. The parser checks the width of every row, so a dropped character is an error
when the plugin loads rather than a subtly wrong letter later.
A colour is three values in [0, 1] - colorRed, colorGreen, colorBlue, each defaulting
to 1.0 - mapped onto the displayed brightness range of the image (minBrightness ..
maxBrightness). The same call is therefore correct on an 8-bit photograph and on a floating
point FITS frame, and a plugin never needs to know the depth of the image it draws on.
For an ordinary integer image that range is the full range of the type, so 0.5 is mid gray. For a FITS frame it is the range of the data, which is the only meaningful notion of "white" a floating point image has. On a grayscale image the luma of the colour is used, so drawing "red" and drawing "green" differ as they should.
Alpha is never written. Drawing a shape on a four-channel image changes its colour, not its transparency.
clipX, clipY, clipWidth, clipHeight restrict a call to a rectangle; a width or height
of zero means the whole image. It is a parameter of every call, never a mode you set
beforehand - an agent may be replicated, and each replica runs the script in its own Lua state,
so a "current clip rectangle" would work until someone passed threads and would then silently
stop applying to part of the drawing.
Coordinates outside the image are clipped rather than rejected, so a rectangle half off the canvas draws its visible half instead of nothing.
Every drawing message replies with the region it changed:
{ invalidate = { x = 4, y = 4, width = 9, height = 9 } }A host can repaint that rectangle instead of the whole image, which on a large frame is the difference between an interactive plugin and an unusable one. The field is absent when nothing changed at all - which is a different statement from a rectangle of size zero.
What the field promises, and what a plugin sending it takes on: everything outside the rectangle is unchanged, in every image the message received - not only in the working one. acrionphoto refreshes both panes from it, so a message that changed the reference image elsewhere and still reported a rectangle would leave the display showing the old pixels. A plugin that cannot make that promise omits the field and gets a full repaint, which is what every plugin written before this contract existed gets.
The four values must be integers. A plugin computing its bounds with a division sends
floating point numbers, and the receiving side treats that as no promise at all: the image
would simply stop refreshing where it should. Use // rather than /.
CallDrawText additionally returns textWidth and textHeight, the size of what it drew.
CallFloodFill additionally returns filled, the number of pixels it changed. That is what
tells "the seed already had that colour" from "the whole image was one region" - the two
outcomes a wrong tolerance produces, indistinguishable from the outside otherwise.
A message that declares x and y becomes a checkable button in acrionphoto's tool
bar. The user selects it and then draws in the image: the host injects the pixel coordinate
under the mouse, on the press and on every position of the drag. That is how one paints, and
it needs nothing else - no mode to switch on, no settings stored between messages.
| tool | does |
|---|---|
DrawWithBrush |
paints a disc of radius in the chosen colour wherever the mouse goes |
DrawWhitePixel |
the minimal example: one white pixel, written in Lua with poke |
GetPixelValueOfChannel |
the other minimal example: reports the values under the cursor |
default, and nothing else
should be declared: whatever is declared is what the user is asked for before the stroke
begins. DrawWithBrush declares radius and the three colour components, and that is its
whole dialog.
A message that declares requestUserInput is not invoked by the user at all: acrionphoto sends
it the state of the mouse on every move, press, release and wheel notch, of both panes,
whichever tool is selected and whether the plugin is interested or not.
OnUserInput in main.lua demonstrates the contract and draws nothing.
The parameters are the seven image fields plus isRightImage, mouseX, mouseY,
mouseLeftButtonPressed, mouseRightButtonPressed, mouseMiddleButtonPressed,
mouseWheelUp, mouseWheelDown and, on the last broadcast a viewer ever sends, stop.
Three of them are not guaranteed and a handler has to say so:
mouseX/mouseYare absent until the mouse has moved once;- the
stopbroadcast carries no image at all - the buffer is about to go away; - a broadcast arrives for the reference pane too.
Return nothing when you did not act. A handler that returns a table is answered, and an
answer per mouse move is pure cost; a handler that did draw returns invalidate and that is
what makes the host repaint. Test the cheapest condition first, and use metadata.pending to
skip positions that a newer one has already made obsolete - see
addmessage.
The plugin works from the nexuslua interpreter; acrionphoto is one caller of it, not a
prerequisite. examples/draw.lua opens an image, draws on it and saves it:
nexuslua examples/draw.lua input.png output.png
Buffer lifetime. An image buffer stays alive as long as something holds a reference to it: the message that carries it, and any reply that carries it on. A Lua variable does not, because a pointer is a string in Lua and a string holds nothing - so the fields have to be passed along from one message to the next rather than stashed in a global.
Until BUG-34 was fixed this was worse than a rule of thumb: every message an agent handled decremented the reference counts of buffers that earlier messages had allocated, so a script that drew on an image and then saved it failed three times in five. If you are running an older build and see
acrion::image::Bitmap constructorfrom a message that should have worked, that is what it was.
- Read: TIFF (
.tif/.tiff), FITS (.fit/.fits), PNG (.png), JPEG (.jpg/.jpeg), BMP (.bmp), TARGA (.tga), DICOM (.dcm) — subject to ImageMagick delegates present at runtime. - Write: TIFF, FITS, PNG, JPEG, BMP, TARGA.
- Bit depths (read/write via ImageMagick): 8/16/32/64-bit unsigned per channel.
- Floating-point images are supported via FITS (read/write); colored FITS output is currently not supported.
- Format support ultimately depends on which ImageMagick coders are available in your build (this repo takes care of shipping them on Windows).
-
The project builds or stages the official ImageMagick packages per platform and exposes them via IMPORTED CMake targets.
-
On Windows (MSYS2 / UCRT64):
-
We build from
mingw-w64-imagemagick(with a modified configuration, primarily changing the quantum depth to 32-bit) and copy the runtime DLLs, modules, and.lafiles into your build and plugin directories. -
Modules are flattened to:
<your-binary-dir>/ modules-Q32/{coders,filters}/... config-Q32/...(No versioned
ImageMagick-7.x.yparent directory.) -
At runtime, the library sets:
MAGICK_CODER_MODULE_PATH→modules-Q32/codersMAGICK_FILTER_MODULE_PATH→modules-Q32/filtersYou don’t have to set these yourself.
-
-
On Linux:
- The necessary ImageMagick shared libraries are copied directly into the build's output directory. The executable's
RPATHis then patched to ensure it loads these local libraries instead of any system-wide installations. All required ImageMagick modules are linked directly into the main shared library, which simplifies runtime configuration by eliminating the need to set module search paths.
- The necessary ImageMagick shared libraries are copied directly into the build's output directory. The executable's
-
On macOS
- macOS support is currently under development. The planned approach is to adapt the official Homebrew formula for ImageMagick to meet this project's specific requirements (e.g., Q32 quantum depth). This is achieved by creating a custom local formula file and instructing Homebrew to build ImageMagick from source with the necessary modifications, analogous to the process on other platforms.
On all platforms, the library programmatically sets MAGICK_HOME and other necessary environment variables at runtime. This configuration is handled internally and is confined to the process using the library, ensuring that it does not interfere with the global user environment or other applications.
-
CMake ≥ 3.25
-
A modern C++ compiler
-
Ninja (recommended)
-
Platform tooling:
- Windows: MSYS2 UCRT64 shell (
ucrt64.exe) - Linux (Arch-based):
pacman(for the ImageMagick build path used here) - macOS: Homebrew (for toolchain/Qt if needed)
- Windows: MSYS2 UCRT64 shell (
This library is also built automatically by the umbrella repo nexuslua-build. If you plan to build the full stack (nexuslua, plugins, optional GUI), start there.
# From repo root
cmake -B build -D CMAKE_BUILD_TYPE=Release acrion_image_tools/
cmake --build build --parallelArtifacts are written to cmake-build-*/bin and the project root (for the plugin build results). On Windows, you’ll see libMagick* DLLs and the modules-Q32/config-Q32 folders placed automatically next to the binaries.
A minimal GoogleTest executable (acrion_image_tools_test) is produced. You can run it directly from cmake-build-*/bin.
#include "acrion_image_tools/io.hpp"
#include "acrion/image/bitmap.hpp"
using acrion::imagetools::io;
int main() {
std::string warning;
auto bmp = io::Read(L"input.tif", warning); // Returns shared_ptr<acrion::image::Bitmap>
if (!warning.empty()) {
// handle non-fatal warnings from ImageMagick
}
// ... process bmp ...
io::Write(*bmp, L"output.png", warning);
return 0;
}#include "acrion_image_tools/version_acrion_image_tools.hpp"
std::string lib_version = acrion::imagetools::GetVersion();
std::string im_version = acrion::imagetools::GetImageMagickVersion();
std::string fits_version = acrion::imagetools::GetCfitsioVersion();The built repository can be installed or directly symlinked as a nexuslua plugin. It exposes high-performance C++ functions for image processing, which are defined and made available as a nexuslua agent, communicating via concurrent messages.
The plugin provides a range of operations accessible through nexuslua messages:
- File I/O:
CallOpenImageFile,CallSaveImageFile - Image Manipulation:
CallSwap,CallCopyLeftToRight,CallCopyRightToLeft,CallInvertImage - Arithmetic:
CallSubtract*(no wrap, wrap, absolute difference) - Pixel Operations:
GetPixelValueOfChannel,DrawWhitePixel, etc.
In addition to the compiled C++ functions, the plugin architecture allows for direct pixel manipulation in nexuslua via memory access functions (peek, poke), enabling rapid prototyping of custom algorithms. For more details on the nexuslua scripting API, please refer to the official nexuslua documentation.
./install-development-plugin.shThis script creates a symbolic link from the project directory to your local nexuslua plugin folder. This is a convenient way to test changes live without needing to reinstall the plugin after every build.
During build, a nexuslua_plugin.toml is written into the plugin root with metadata consumed by the acrionphoto Plugin Manager and by nexuslua tooling. Example:
displayName = "acrion image tools"
version = "1.0.246"
isFreeware = true
description = "A set of essential tools for basic image manipulation."
urlHelp = "https://github.com/acrion/image-tools"
urlLicense = "https://github.com/acrion/image-tools/blob/main/LICENSE"
urlDownloadLinux = "https://github.com/acrion/image-tools/releases/download/1.0.246/image-tools-Linux.zip"
urlDownloadWindows = "https://github.com/acrion/image-tools/releases/download/1.0.246/image-tools-Windows.zip"
#urlDownloadDarwin = "https://github.com/acrion/image-tools/releases/download/1.0.246/image-tools-Darwin.zip"- The macOS download URL is commented while the macOS build is under refactoring.
- If
isFreeware = falseandurlPurchaseis provided,acrionphotoshows “Get License Key…”; it also exposes “Install key or other files…” for copying data into the plugin’spersistent/folder. - The planned
acrion/nexuslua-pluginsrepository will hold only the URLs of such TOML files; the files themselves live with each plugin.
main.lua # nexuslua message definitions and C++/Lua bridge
acrion_image_tools/
nexuslua_plugin.toml.template # Template for nexuslua plugin metadata file
CMakeLists.txt # Main build logic, including ExternalProject for ImageMagick
io.hpp|cpp # Public API for image I/O via ImageMagick + FITS
fits.hpp|cpp # FITS reading/writing using the vendored cfitsio library
imagemagick.hpp # ImageMagick headers/config (Q32 depth, HDRI toggle)
main.cpp # C++ entry points for functions exposed to nexuslua
im/ # ImageMagick build glue (PKGBUILDs, patches, scripts)
cfitsio/ # vendored cfitsio reference implementation
version_* # Version query utilities (library, IM, cfitsio)
- “NoDecodeDelegateForThisImageFormat 'JPEG'”
On Windows, ensure the runtime directory contains
modules-Q32/coders/jpeg.dllandjpeg.lanext to your application binary. Set the environment variableMAGICK_DEBUG=Module,Coderto see verbose module lookup paths. - Running from a different working directory The library derives necessary paths from the current binary's location, not the current working directory (CWD). As long as the dependent libraries and modules are placed next to your executable, they will be found.
Have questions about the library, want to share what you've built, or discuss image processing techniques? Join our community on Discord!
💬 Join the acrion image Discord Server
acrion image-tools is dual-licensed to support both open-source development and commercial use.
- AGPL v3 or later: For use in open-source projects that are compatible with the AGPL.
- Commercial License: For integration into proprietary applications or for cases where AGPLv3 terms cannot be met.
I would like to emphasize that offering a dual license does not restrict users of the normal open-source license (including commercial users). The dual licensing model is designed to support both open-source collaboration and commercial integration needs. For commercial licensing inquiries, please contact us at https://acrion.ch/sales.
acrion image-tools incorporates or links against several third-party libraries. Their licenses apply to their respective components and must be respected.
- cfitsio: The source code is included directly in this repository. It is released under a permissive, public-domain-like license granted by NASA. The full license text is available in
acrion_image_tools/cfitsio/License.txt. - ImageMagick: This library is downloaded and built at compile time. It is distributed under the ImageMagick License, which is a permissive, Apache 2.0-style license. You can find more details on the official ImageMagick website.