Qu

save_image

Builtin index · Images

save_image(path, img, [quality=90])

Input: path, img, quality · Output: no output

What it is

path is a string destination; img is the Image to write. Encodes img and writes it to path, dispatching on the path's own extension: .png writes a real (if uncompressed) PNG; .jpg/.jpeg writes a baseline JPEG; .tif/.tiff writes an uncompressed 8-bit RGB TIFF; any other extension, or none, writes a BMP. (Extension dispatch on write, magic-byte sniffing on read, is not an inconsistency: writing has no bytes to sniff yet, so the extension is the only statement of what the caller wants.) quality is an optional named number 1–100 (default 90) that is only meaningful for JPEG — it is accepted and ignored for the other formats, and a value outside 1–100 is an error rather than silently clamped. JPEG is lossy: a save_image/load_image round trip through .jpg does not return the pixels that went in. On the gradient images the test suite measures, the RMS difference is about 1.3–1.4 levels out of 255 at the default quality 90, and about 0.6 at quality 100 — small, but not zero, and it compounds if you re-save repeatedly. PNG, BMP and TIFF as written here are lossless, so their round trips are pixel-exact.

Overloads

One call shape — this function is not overloaded.

CodeInputOutputDescription
save_image(path, img, [quality=90])
path stringpath is a string destination.
img Imageimg is the Image to write.
quality number, optional (default 90)(Extension dispatch on write, magic-byte sniffing on read, is not an inconsistency: writing has no bytes to sniff yet, so the extension is the only statement of what the caller wants.) quality is an optional named number 1–100 (default 90) that is only meaningful for JPEG — it is accepted and ignored for the other formats, and a value outside 1–100 is an error rather than silently clamped.
no outputJPEG is lossy: a save_image/load_image round trip through .jpg does not return the pixels that went in. PNG, BMP and TIFF as written here are lossless, so their round trips are pixel-exact.

Examples

From the documentation, and run by the build -- the output and the figure below are what the engine actually produced. Simplest first.

# A gradient with a bright bar in it, so the rotation below is visible:

# a symmetric test image would look identical afterwards.

n = 48
g = zeros(n, n)
for i in 0 to n - 1
    for j in 0 to n - 1
        g[i, j] = 40 + 3 * j
        if i > 10 and i < 22 and j > 6 and j < 30
            g[i, j] = 240
        end if
    end for
end for
save_image("sample.bmp", image_from_matrix(g))

img = load_image("sample.bmp")
gray = grayscale(img)
imshow(gray)

from images

canvas = image_new(40, 30, r=200, g=200, b=200)

m = [10, 40, 250; 90, 140, 190; 220, 255, 30]
gray = image_from_matrix(m)
save_image("/tmp/gray.bmp", gray)

imagesc(m, colormap="viridis")          # false-color view of the same matrix


big = resize(canvas, 80, 60, method="bilinear")
piece = crop(big, 10, 10, 30, 20)
print(piece.width, piece.height)
imshow(piece)

from images

# Deliberately dark and low-contrast -- everything between 28 and 42 of

# 255 -- so there is something for `imadjust` to stretch.

n = 48
d = zeros(n, n)
for i in 0 to n - 1
    for j in 0 to n - 1
        d[i, j] = 28 + 14 * (i + j) / (2 * n)
    end for
end for
save_image("dark.bmp", image_from_matrix(d))

img = load_image("dark.bmp")
bright = imadjust(img)                # auto-stretches to the 1st/99th percentile

imshow(bright)
counts = imhist(bright, n_bins=64)
bar(counts)
output
· bar: figure 8 (64 points)

from images

# Three bright discs on a dark field, written out and read back. The blob

# count printed below is then checkable against an image the reader can

# see being built, rather than asserted about a file nobody has.

n = 60
field = zeros(n, n)
centres = [15, 15; 42, 20; 30, 45]
for i in 0 to n - 1
    for j in 0 to n - 1
        for k in 0 to 2
            dy = i - centres[k, 0]
            dx = j - centres[k, 1]
            if dy * dy + dx * dx < 64
                field[i, j] = 230
            end if
        end for
    end for
end for
save_image("cells.bmp", image_from_matrix(field))

img = load_image("cells.bmp")
level = otsu_threshold(img)
mask = threshold(img, level)
clean = bwareaopen(mask, 20)          # drop specks smaller than 20 px

imshow(clean)

labeled = bwlabel(clean)
print(labeled.count)                  # number of detected blobs


stats = regionprops(labeled)
for i = 1 to labeled.count
    r = stats[i - 1]
    print(r.label, r.area, r.centroid_x, r.centroid_y)
end for
output
3
1 193 15 15
2 193 45 30
3 193 20 42

from images

How it works

save_image(path, img) — dispatches on the path's extension: .bmp (default, including no/unknown extension, to match this builtin's original BMP-only behavior) uses image::encode_bmp; .png uses the real, spec-correct (if uncompressed) image::encode_png (BACKLOG.md item [25]'s PNG half — see that function's own doc for the hand-rolled DEFLATE-stored- block approach); .jpg/.jpeg and .tif/.tiff go through jpeg-encoder/tiff (2026-09-23). This is raster-pixel-data export only, NOT savefig's full-figure export -- see save_figure's own .png rejection for that distinction. Extension-dispatch on WRITE, magic-byte sniffing on READ, is not an inconsistency: writing has no bytes to sniff yet, and the extension is the only thing that says what the caller wants. Reading has the real bytes, which are never wrong.

From the note above this builtin in the interpreter, where the reasoning was written down as it was built.