Qu

Images

Every table below is generated by reading the interpreter's own builtin dispatch table (engine/crates/qu-interp/src/lib.rs, inside Interp::call_builtin's giant match f { ... }), not written from a spec — it reflects exactly what runs today. Signatures use [name=default] for named (key=value) style arguments and [positional] for optional positional ones. Line numbers are cited so a future audit can re-verify them against a moving file.

Creating & Loading Images

FunctionSignatureDescription
image_newimage_new(width, height, [r=0, g=0, b=0])Creates a blank solid-color canvas. width and height are positive integers giving the canvas size in pixels. r, g, b are optional named numbers 0–255 giving the fill color's channel values (default 0, i.e. solid black). Returns a new Image of exactly width x height pixels. The only way to build a synthetic image with no file on disk.
image_from_matriximage_from_matrix(matrix)Builds a grayscale Image directly from matrix, a 2-D Mat of numbers; each value is rounded and clamped to 0–255 and written to R=G=B for that pixel (matrix rows become image rows, columns become image columns). Returns a new Image sized cols x rows to match the matrix's own shape. For a real pixel buffer you can further process/save, as opposed to imagesc's colormap display.
load_imageload_image(path)path is a string file path. Reads the file from disk and decodes it — PNG and BMP. The format is sniffed from the file's first bytes rather than its extension, because a .png that is really a BMP is a thing that happens and the bytes are never wrong. PNG: bit depths 1/2/4/8/16, colour types grey, truecolour, palette, grey+alpha and RGBA; alpha composites onto white, since Image is three channels. Adam7 interlacing is refused by name rather than decoded wrongly. Returns a new Image sized to the file's own dimensions.
save_imagesave_image(path, img)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: a .png extension writes a real (if uncompressed) PNG; any other extension, or none, writes a BMP. Returns nothing.
imagescimagesc(matrix, [colormap="viridis"])matrix is a 2-D Mat of arbitrary real-valued numbers (no 0–255 requirement). colormap is an optional named string (default "viridis") selecting the palette, same set as heatmap's. False-color raster display of the plain numeric matrix (no pixel buffer produced) — pushes a dense Heatmap onto the current plot panel, reusing the same renderer as heatmap, just without per-cell value labels or gridlines. Defaults to viridis rather than heatmap's blues. Returns nothing; it is a plotting side effect.
imshowimshow(img)img is an Image value (e.g. from load_image/grayscale/any transform below), of any size. Places it inline in the current plot panel as a native SVG <image> element at its own resolution. Takes an actual Image, not a scalar matrix — use imagesc for that. Returns nothing.

Basic Transforms

FunctionSignatureDescription
grayscalegrayscale(img)img is an Image of any size. Converts RGB → luma via ITU-R BT.601 weights (matches plotting.rs's contrast_text_color). Returns a new Image, same width/height as img, with R=G=B at every pixel — still a full 3-channel RGB image, not a separate 1-channel type.
resizeresize(img, width, height, [method="nearest"])img is an Image; width/height are positive integers giving the absolute target size in pixels (not a scale factor — see imscale for that). method is an optional named string: "nearest" (default, cheap nearest-neighbor) or "bilinear" (smoother). Returns a new Image of exactly width x height pixels. Also overloaded for Vec (resize(vec, new_length, [fill=0.0]), a ReDim Preserve-style grow/shrink to new_length elements, padding new slots with the number fill when growing, truncating when shrinking, and returning a new Vec) — unrelated to images but the same builtin name.
cropcrop(img, x0, y0, width, height)img is an Image; x0, y0 are integers giving the top-left pixel coordinate of the sub-rectangle; width/height are positive integers giving its size in pixels. Extracts that sub-rectangle. Returns a new Image of exactly width x height pixels.
imrotateimrotate(img, angle_degrees, [method="bilinear"], [bbox="loose"])Rotates an image about its own center. img is an Image; angle_degrees is a number, the rotation angle about the image center — positive = counterclockwise (MATLAB's convention). method is an optional named string, "nearest" or "bilinear" (default), controlling resampling. bbox is an optional named string: "loose" (default) expands the canvas to fit the rotated content; "crop" keeps the original width/height, clipping content that rotates outside the frame. Returns a new Image.
imtranslateimtranslate(img, dx, dy, [method=], [bbox="crop"], [fill=0])img is an Image; dx/dy are numbers, the horizontal/vertical pixel shift (positive = right/down). method is an optional named string ("nearest"/"bilinear") controlling resampling. bbox is an optional named string, defaulting to "crop" (output stays the same size as the input, matching MATLAB imtranslate's 'OutputView','same') — the opposite default from imwarp/imscale. fill is an optional named number 0–255 (default 0), the byte value used on all 3 channels for pixels that map outside the source. Implemented as imwarp + affine_translate. Returns a new Image.
imscaleimscale(img, sx, [sy], [method=], [bbox="loose"], [fill=0])img is an Image; sx is a number, the scale factor (imscale(img, 2) doubles both dimensions); sy is an optional number, the vertical scale factor, defaulting to sx (uniform scale) when omitted. method/fill as in imtranslate. bbox is an optional named string, defaulting to "loose" so the output canvas grows/shrinks to fit the scaled content — the opposite default from imtranslate. Implemented as imwarp + affine_scale. Different from resize, which takes absolute output dimensions rather than a factor. Returns a new Image.

Overloads: resize

Case: Image

img an Image; width/height are the absolute output size in pixels. Resamples the whole picture to that size using method for interpolation — the resulting figure is the same content redrawn at a new resolution, nothing about the pixels' meaning changes.

img = image_new(10, 10, r=0, g=0, b=0)
big = resize(img, 20, 15, method="bilinear")
print(big.width, big.height)   # 20 15

output
20 15

Ask an image its dimensions with .width and .height, not with a tuple. Every call in this chapter that takes a size takes it as width then height — image_new(width, height), resize(img, width, height), crop(img, x0, y0, width, height) — and a named accessor cannot be read in the wrong order the way a pair can. img.width means the same thing however the language chooses to order a returned pair, so code written against it does not depend on remembering a convention, and does not change meaning if that convention is ever revisited.

For completeness: size(img) returns [width, height] — the same order every call above takes — and length(img) returns the pixel count, which has no order to get wrong. There is no worked example of size on an image on this page yet, deliberately: the build that renders these pages runs an engine older than that behaviour, so an example printing it would show you a number the current engine does not produce, over the build's signature. It will be added once the two agree.

Case: Vec

vec a Vec; new_length is the target element count, fill (optional, default 0.0) is the value used to pad new trailing slots when growing. A ReDim Preserve-style grow/shrink — growing keeps every existing element and appends fills, shrinking truncates from the end. Nothing to do with pixels at all; same builtin name, unrelated meaning.

v = [1, 2, 3]
print(resize(v, 5, fill=9))   # [1, 2, 3, 9, 9] -- grown, padded with 9

print(resize(v, 2))           # [1, 2]          -- shrunk, tail dropped

output
[1, 2, 3, 9, 9]
[1, 2]

Affine Transforms

The affine_* builtins each return a plain 3x3 homogeneous-coordinate Mat value — real matrices, not an opaque "transform" type — so they compose with Qu's own */matmul operator exactly like any other matrix. A point transforms as M @ [x, y, 1]^T. Composing A = affine_translate(...) * affine_rotate(...) * affine_scale(...) and applying A @ p computes translate(rotate(scale(p))): scale happens first (rightmost factor), then rotate, then translate last (leftmost factor) — the same right-to-left application order as OpenGL's M = T*R*S. imwarp (and imtranslate/imscale, which build their own transform internally) is what actually *applies* one of these matrices to an image, via one inverse-mapping resampling pass.

Note: affine_rotate's matrix is the plain textbook CCW rotation matrix in a standard (x right, y up) coordinate system, deliberately not special-cased for image pixel coordinates (y increasing downward) the way imrotate internally negates its angle. Applied to image pixels via imwarp, a positive affine_rotate angle therefore appears clockwise on screen — the opposite visual sign from imrotate's own angle_degrees.

FunctionSignatureDescription
affine_identityaffine_identity()Takes no arguments. Returns the 3x3 identity Mat — a no-op transform, useful as a composition base case.
affine_translateaffine_translate(dx, dy)dx/dy are numbers, the horizontal/vertical shift amount. Returns a new 3x3 homogeneous-coordinate Mat encoding that translation.
affine_scaleaffine_scale(sx, [sy])Builds a scaling transform for imwarp. sx is a number, the scale factor along x; sy is an optional number, the scale factor along y, defaulting to sx (uniform scale) when omitted — matching MATLAB imresize(img, scale)'s single-factor convention. Returns a new 3x3 Mat.
affine_rotateaffine_rotate(angle_degrees)angle_degrees is a number. Returns a new 3x3 Mat encoding a CCW rotation matrix in standard math convention (see the clockwise-on-screen note above for the visual difference from imrotate).
affine_shearaffine_shear(shx, shy)shx/shy are numbers, the shear factors along x and y. Returns a new 3x3 Mat encoding that shear.
imwarpimwarp(img, M, [method="bilinear"], [bbox="loose"], [fill=0])img is an Image; M is a 3x3 (or 2x3, auto-padded with [0,0,1]) affine Mat — from the affine_* builders above, a literal matrix, or any composition of the two via */matmul. method is an optional named string ("nearest"/"bilinear", default "bilinear"). bbox is an optional named string, "loose" (default, grows the canvas to fit) or "crop" (keeps the input's size). fill is an optional named number 0–255 (default 0) applied to all 3 channels for pixels that map outside the source image. Applies M to img in one resampling pass via inverse mapping. Returns a new Image.

Filtering & Enhancement

FunctionSignatureDescription
blurblur(img)img is an Image of any size. Applies a fixed 3x3 box-average blur kernel. Returns a new Image, the same dimensions as img.
sharpensharpen(img)img is an Image. Applies a fixed 3x3 unsharp-mask sharpen kernel. Returns a new Image, same dimensions as img.
edge_detectedge_detect(img)img is an Image. Applies a fixed 3x3 discrete Laplacian edge filter, highlighting intensity discontinuities. Returns a new Image, same dimensions as img.
imfilterimfilter(img, kernel)img is an Image; kernel is an arbitrary odd-sized (e.g. 3x3, 5x5) square Mat of numeric weights. Generic 2-D convolution with edge-clamped borders — the escape hatch when blur/sharpen/edge_detect's fixed kernels aren't enough. Returns a new Image, same dimensions as img.
imdilateimdilate(img, radius)img is a binary (0/255 mask-style) Image; radius is a positive integer, the half-width of a square structuring element of side 2*radius+1 (a documented choice over a disk element). Performs binary morphological dilation, growing foreground regions. Returns a new Image, same dimensions as img.
imcloseimclose(img, radius)img is a binary Image; radius is a positive integer (same square structuring-element convention as imdilate). Morphological closing (dilate then erode) — fills small background holes without enlarging the overall shape. Returns a new Image, same dimensions. (imopen/imerode, erosion and opening, also exist alongside it in the same match arm pair but aren't in this chapter's requested scope — see the "More functions" table below.)
imbothatimbothat(img, radius)img is a binary Image; radius is a positive integer. Bottom-hat: imclose(img, radius) - img, extracts small dark features / corrects a slowly-varying dark background. Returns a new Image, same dimensions. (imtophat, the bright-feature dual, is its sibling arm, documented below.)
histeqhisteq(img) (alias of imequalize)img is an Image of any size, color or grayscale. Global histogram equalization: builds a BT.601-luma cumulative distribution and remaps intensities to spread across the full range. RGB-preserving via luma-ratio remapping — not a grayscale-only fallback. Returns a new Image, same dimensions as img.
imadjustimadjust(img, [in_low=], [in_high=], [out_low=0.0], [out_high=1.0], [gamma=1.0])img is an Image. in_low/in_high are optional named numbers in [0,1], the input intensity range to stretch from; when both are omitted they auto-default to the image's own 1st/99th percentile (all channel bytes pooled) — robust to outlier pixels, like MATLAB's stretchlim. out_low/out_high are optional named numbers in [0,1] (default 0.0/1.0), the output range to stretch to. gamma is an optional named number (default 1.0) applying a power-law curve after the linear stretch. MATLAB-style contrast stretch, applied identically and independently to each of R/G/B. Returns a new Image, same dimensions as img.
imhistimhist(img, [n_bins=256])img is an Image; n_bins is an optional positive integer (default 256; may be given positionally or as a named argument), the number of histogram bins. Computes raw per-bin BT.601-luma intensity counts. Returns a Vec of length n_bins — data, not a rendered plot. Pipe the result into hist/bar to actually draw it.
imnoiseimnoise(img, type, [mean=0.0], [sigma=0.05], [density=0.05], [seed=])img is an Image; type is a required string, "gaussian" or "salt_pepper". For "gaussian": mean/sigma are optional named numbers (defaults 0.0/0.05), the additive per-channel Gaussian noise's mean/standard-deviation in normalized [0,1] intensity units. For "salt_pepper": density is an optional named number (default 0.05), the fraction of pixels (not channel samples) flipped to pure black/white. seed is an optional named integer for reproducibility (same convention as randn/uniform); omitted, a fresh random seed is used. Adds synthetic noise for testing other CV algorithms. Returns a new Image, same dimensions as img.

Overloads: imdilate

Case: binary mask

img a binary (0/255) Image. Dilation/erosion/opening/closing/top-hat/bottom-hat all reduce to their textbook binary definitions on a genuine mask: the foreground blob grows outward by radius, and every output pixel stays exactly 0 or 255 — no new grey levels ever appear.

mb = [0, 0, 0, 0, 0; 0, 0, 0, 0, 0; 0, 0, 255, 0, 0; 0, 0, 0, 0, 0; 0, 0, 0, 0, 0]
binary_img = image_from_matrix(mb)
grown = imdilate(binary_img, 1)
print(imhist(grown, n_bins=4))   # [16, 0, 0, 9] -- only the two extreme bins are ever used

output
[16, 0, 0, 9]

Case: grayscale image

img a genuine grayscale (or color) Image with more than two intensity levels. imdilate/imerode/imopen/imclose/imtophat/imbothat are all implemented as a per-channel MAX (dilate) or MIN (erode) over the structuring window — true grayscale morphology, not "threshold then grow." Fed a smooth image, the intermediate grey levels shift around instead of snapping everything to black/white.

mg = [10, 60, 110, 160, 210; 20, 70, 120, 170, 220; 30, 80, 130, 180, 230; 40, 90, 140, 190, 240; 50, 100, 150, 200, 250]
gray_img = image_from_matrix(mg)
grown = imdilate(gray_img, 1)
print(imhist(grown, n_bins=4))   # [0, 6, 7, 12] -- three separate bins in use, no black survives at all

output
[0, 6, 7, 12]

Overloads: imadjust

Case: explicit range

in_low/in_high given explicitly. The stretch maps exactly that input range to out_low..out_high linearly — passing the image's own full byte range (in_low=0.0, in_high=1.0) is a no-op that leaves a low-contrast image just as flat as it started.

m = [40, 42, 41, 43; 44, 40, 250, 41]
img = image_from_matrix(m)
fixed = imadjust(img, in_low=0.0, in_high=1.0)
print(imhist(fixed, n_bins=8))   # [0, 7, 0, 0, 0, 0, 0, 1] -- unchanged, still clustered in one bin

output
[0, 7, 0, 0, 0, 0, 0, 1]

Case: auto range (both omitted)

When both in_low and in_high are left out, they default to the image's own 1st/99th percentile of pooled channel bytes rather than 0.0/1.0 — a robust, data-driven stretch (MATLAB's stretchlim), not a no-op. The exact same source image that stayed flat above spreads across the full range once the percentile-based stretch kicks in.

m = [40, 42, 41, 43; 44, 40, 250, 41]
img = image_from_matrix(m)
auto = imadjust(img)
print(imhist(auto, n_bins=8))   # [7, 0, 0, 0, 0, 0, 0, 1] -- stretched out to both ends of the range

output
[7, 0, 0, 0, 0, 0, 0, 1]

Segmentation & Thresholding

FunctionSignatureDescription
thresholdthreshold(x, level)x is an Image, Signal, Vec, or scalar; level is a number, the cutoff. Elementwise binary threshold, general-purpose (not image-specific). Image → binary black/white image of the same dimensions (per-pixel BT.601 luma vs level); Signal → same-Fs Signal of 0.0/1.0; Vec/scalar → identical to x >= level (a Mask/Bool via the shared compare path). Returns the same kind of value x was: an Image from an Image, a same-Fs Signal from a Signal, a Mask from a Vec, a Bool from a scalar.
multithresholdmultithreshold(x, levels)x is an Image, Signal, Vec, or scalar; levels is a Vec/matrix or tuple List of cut-point numbers (sorted ascending regardless of input order). Quantizes into len(levels)+1 bands at the given cut points. Image → posterized image of the same dimensions with evenly-spaced gray levels per band; Signal/plain numeric → raw 0-based band index. Returns the same kind of value x was: an Image from an Image, a same-Fs Signal from a Signal, and otherwise a Vec/Num of 0-based band indices.
otsu_thresholdotsu_threshold(x)x is an Image, Vec, or Signal. Computes (does not apply) the classic Otsu optimal threshold from x's histogram (Image reads per-pixel luma; Vec/Signal read their own values). Returns a single number, the threshold level. Compose with threshold(x, otsu_threshold(x)) to segment.
otsuotsu(x)x is an Image, Vec, or Signal. Compute-and-apply convenience: otsu_threshold followed immediately by threshold in one call. Returns the same type/shape threshold(x, level) would (e.g. a new binary Image of the same dimensions for an Image input).
multi_otsumulti_otsu(x, n_classes)x is an Image, Vec, or Signal; n_classes is a positive integer, the desired number of intensity classes. The n_classes - 1-threshold generalization of otsu_threshold. Returns a Vec of n_classes - 1 cut points in ascending order. Compose with multithreshold(x, multi_otsu(x, n)) to segment into n classes.
kapur_thresholdkapur_threshold(x)x is an Image, Vec, or Signal. Same "compute, don't apply" shape as otsu_threshold, but using Kapur's entropy-maximizing criterion instead of Otsu's between-class variance — tends to handle unequal class sizes better. Returns a single threshold number.

Overloads: threshold

Case: Image

x an Image. Compares each pixel's BT.601 luma against level and returns a new binary (0/255) Image of the same dimensions — the classic black-and-white segmentation mask.

m = [10, 200; 50, 220]
img = image_from_matrix(m)
mask = threshold(img, 128)
imshow(mask)
print(mask.width, mask.height)   # 2 2

output
2 2

Case: Signal

x a Signal. Elementwise compare against level, but returned as a same-Fs Signal of 0.0/1.0 values rather than a Mask — so it stays a Signal and can keep flowing through DSP functions that expect one.

sig = signal([1, 5, 2, 8], 100)
print(threshold(sig, 4))   # signal(Fs=100) [0, 1, 0, 1]

output
signal(Fs=100) [0, 1, 0, 1]

Case: Vec or scalar

x a Vec or a plain number. Identical to x >= level — a Mask for a Vec, a plain bool for a scalar — reusing the exact same shared compare path the >= operator itself uses, rather than any image-specific logic.

v = [1, 5, 10, 2]
print(threshold(v, 4))    # [F, T, T, F]

print(threshold(7, 4))    # true

output
[F, T, T, F]
true

multithreshold branches on x's type the same way, but the results differ more: its Image case returns a posterized *image* (evenly-spaced gray bands), while its Signal/plain-numeric case returns a raw 0-based *band index* rather than anything image-shaped.

Region Analysis

FunctionSignatureDescription
bwlabelbwlabel(binary_img, [connectivity=8]) (alias of label_blobs)binary_img is a binary (0/255) Image. connectivity is an optional named number, 4 or 8 (default 8), the neighbor rule used for flood-fill labeling. Connected-component labeling. Returns a Model (kind "blobs") with fields .labels (a Mat of the same height/width as binary_img, integer label ids, background = 0), .count (a number, the blob count), .width, .height (the source image's own dimensions).
regionpropsregionprops(labeled) (alias of blob_stats)labeled is a Model produced by bwlabel/label_blobs. Takes that result and returns a List of one Record per blob, in label-id order, each with fields: label (integer id), area (pixel count), centroid_x, centroid_y (real pixel-coordinate means, not bbox-center approximations), bbox_x, bbox_y, bbox_width, bbox_height (tightest axis-aligned rectangle) — all numbers. Returns a List with one Record per blob, in label-id order — empty when labeled found none.
bwareaopenbwareaopen(binary_img, min_area) (alias of remove_small_blobs)binary_img is a binary Image; min_area is a number, the minimum pixel count a blob must have to survive. Labels internally (8-connectivity, matching MATLAB's default) and zeroes out every blob whose pixel count is below min_area. Returns a new binary Image, same dimensions as binary_img, with small blobs removed.
foreground_maskforeground_mask(img, level, [radius=1])img is an Image, color or grayscale (BT.601 luma either way); level is a required number, the threshold cutoff 0–255; radius is an optional named positive integer (default 1), the structuring-element radius for the cleanup step. Thresholds img at level, then applies imopen(radius) to remove noise specks. level is a required argument by design — pair with otsu_threshold for auto-thresholding: foreground_mask(img, otsu_threshold(img)). Returns a new binary Image, same dimensions as img.

Examples

Load, grayscale, and display

grayscale is usually the first step in a CV pipeline that doesn't care about color: it collapses a loaded RGB image down to R=G=B luma so that every downstream filter (edge detection, thresholding, morphology) only has to reason about one intensity value per pixel instead of three. The example below loads a photo, converts it, and shows it inline — the resulting figure looks identical in shape and size to the original, but every trace of color is gone, leaving a flat grey rendition where only brightness carries information.

# 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)

Edge detection

edge_detect runs a small Laplacian kernel over the image, so it responds strongly wherever intensity jumps sharply — silhouettes, text edges, texture boundaries — and stays near zero across smooth regions. Feeding it a grayscale image first (rather than raw color) keeps that response consistent across channels. The resulting figure is mostly dark, with the outlines of objects in the original photo picked out as thin bright (or high-contrast) lines, roughly like a pencil sketch of the scene's contours.

img = load_image("sample.bmp")
edges = edge_detect(grayscale(img))
imshow(edges)

Otsu thresholding and blob analysis

This is the classic segment-then-measure workflow: otsu_threshold picks a data-driven cutoff between foreground and background without any manual tuning, threshold applies it to get a binary mask, bwareaopen scrubs out tiny specks that are just noise, and bwlabel/regionprops turn the cleaned mask into a list of discrete objects with their own area and centroid. Viewed as an image, mask is stark black-and-white; clean is the same but with small isolated white flecks removed, leaving only the blobs large enough to be real objects (e.g. cells in a microscopy image). The printed rows give one line of measurements — label id, area in pixels, and centroid coordinates — per surviving blob.

# 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

Composable affine transform via imwarp

imwarp is the general-purpose way to move pixels: build up any combination of translation, rotation, scale, and shear as plain 3x3 matrices, multiply them together with Qu's own * operator, and apply the whole thing in a single resampling pass. Here a rotation and a translation are composed into one matrix and applied at once, with fill=255 painting the newly-exposed corners white instead of the default black. The resulting figure shows the source image rotated 30 degrees and shifted down and to the right, on a larger white canvas that has grown (bbox="loose") to fit the rotated content without clipping any of it.

img = load_image("sample.bmp")

# Rotate 30 degrees about the origin, then shift right/down by (50, 20).

M = affine_translate(50, 20) * affine_rotate(30)
out = imwarp(img, M, method="bilinear", bbox="loose", fill=255)
imshow(out)

Direct rotation with imrotate

For the common case of "just rotate the image," imrotate is more direct than composing an affine matrix by hand: give it a plain angle in degrees and it handles the center-of-rotation bookkeeping itself. The figure below shows the source image rotated 45 degrees counterclockwise, on a canvas that has grown (bbox="loose") enough to contain every rotated corner — the four original corners now sit inside a larger, diamond-oriented bounding image with black fill in the newly-exposed background.

img = load_image("sample.bmp")
rotated = imrotate(img, 45, method="bilinear", bbox="loose")
imshow(rotated)

Contrast enhancement and histogram

imadjust fixes an image that looks washed-out or too dark by stretching its intensity range to fill 0–1 (auto-picking the 1st/99th percentile as the input range when none is given), and imhist turns the resulting pixel intensities into a plain count-per-bin Vec that bar can plot. Starting from a very dark, low-contrast image, bright looks visibly lighter and punchier after the stretch; the accompanying bar chart shows intensity counts spread out across the bin range rather than bunched up near zero the way the original dark image's histogram would be.

# 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)

A runnable image

The examples above open files that are not in this repository, so they show the calls without showing the result. Building the image in Qu instead means the rest can actually run:

w = 120
h = 120
M = zeros(h, w)
for r in 0 to h - 1
    for c in 0 to w - 1
        M[r, c] = 40 + 60 * sin(c / 9) * cos(r / 14)
    end for
end for
for r in 45 to 75
    for c in 30 to 60
        M[r, c] = 210
    end for
end for

img = image_from_matrix(M)
imshow(img)
title("a synthetic field with one bright patch")
a synthetic field with one bright patch

image_from_matrix takes values in 0-255. Anything that produces a matrix produces an image, which is the useful direction: a simulation result, a correlation surface, or a measurement grid can be looked at with the same tools as a photograph.

More functions

FunctionSignatureDescription
imerodeimerode(img, radius)img is a binary Image; radius is a positive integer, the half-width of a square structuring element of side 2*radius+1. Binary morphological erosion — shrinks foreground regions and removes specks smaller than the element. Returns a new Image, same dimensions as img.
imopenimopen(img, radius)img is a binary Image; radius is a positive integer (same structuring-element convention as imerode). Morphological opening (erode then dilate) — removes specks smaller than the structuring element while leaving larger shapes their size. Returns a new Image, same dimensions as img.
imtophatimtophat(img, radius)img is a binary/grayscale Image; radius is a positive integer. The image minus its opening: what the opening removed. The standard way to pull small bright features off an uneven background. Returns a new Image, same dimensions as img.
imequalizeimequalize(img)img is an Image of any size. Histogram equalisation, spreading the intensities to fill the range (identical operation to histeq above). Reveals detail in a flat image and exaggerates noise in a clean one. Returns a new Image, same dimensions as img.
remove_small_blobsremove_small_blobs(img, min_area)img is a binary Image; min_area is a number, the minimum pixel area a connected component must have to survive. Drop connected components below that area, after labelling internally (identical operation to bwareaopen above). The cheap alternative to tuning a filter. Returns a new binary Image, same dimensions as img.
label_blobslabel_blobs(img)img is a binary Image. Connected-component labelling: every separate region gets its own integer (identical operation to bwlabel above). Returns a Model (kind "blobs") with .labels, .count, .width, .height fields.
blob_statsblob_stats(img)img is a Model produced by label_blobs/bwlabel. Per-region area, centroid and bounding box, from a labelled image (identical operation to regionprops above). Returns a List of Records, one per blob.

Building an image from scratch

No file on disk needed: image_new makes a blank canvas, and image_from_matrix turns any numeric matrix straight into a grayscale image. resize/crop then reshape it, imagesc shows the raw matrix as a false-color raster, and save_image writes real pixels to a scratch file. Running this script produces two very different-looking figures side by side: imagesc(m) renders the tiny 3x3 matrix as a smooth, colorful viridis heatmap where each of the 9 cells is one large colored square, while the final imshow(piece) shows a flat, uniformly light-grey rectangle — a 30x20 crop taken from the middle of the enlarged solid-grey canvas, with no texture since the canvas started as one flat color.

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)

Geometric transforms: translate, scale, and compose an affine matrix

imtranslate/imscale are ready-made pixel moves; affine_identity, affine_scale, and affine_shear build the raw 3x3 matrices that imwarp (see above) applies for anything more custom. Starting from a plain black 20x20 square, shifted is visually identical black content but now sitting on a canvas whose freshly-exposed left/top edge strip is filled white (fill=255) where the shift moved content away from it; the printed matrix M shows the combined scale-then-shear transform as raw numbers rather than a rendered image, since it is never applied to a pixel buffer in this example.

img = image_new(20, 20, r=0, g=0, b=0)

shifted = imtranslate(img, 5, 3, fill=255)
scaled = imscale(img, 1.5, fill=0)
imshow(shifted)

M = affine_scale(2, 1.5) * affine_shear(0.2, 0) * affine_identity()
print(shifted.width, scaled.width, M)
output
20 30 [2, 0.4, 0; 0, 1.5, 0; 0, 0, 1]
a synthetic field with one bright patch

Convolution filters: blur, sharpen, and a custom kernel

blur/sharpen are fixed 3x3 kernels; imfilter takes any odd-sized kernel for cases those don't cover. The source image here is a tight stripe pattern (alternating dark and light pixels); soft visibly smears those stripes into softer, blended bands, crisp pushes the opposite way and exaggerates the contrast between adjacent stripes so the pattern looks harsher, and custom — built from an explicit unsharp kernel via imfilter — looks similar to crisp but demonstrates that the same effect can be hand-specified as a raw kernel matrix rather than relying on the built-in sharpen.

m = [10, 200, 10, 200, 10; 200, 10, 200, 10, 200; 10, 200, 10, 200, 10]
img = image_from_matrix(m)

soft = blur(img)
crisp = sharpen(img)
imshow(soft)
imshow(crisp)

kernel = [0, -1, 0; -1, 5, -1; 0, -1, 0]      # explicit unsharp kernel

custom = imfilter(img, kernel)
print(soft.width, crisp.width, custom.width)
output
5 5 5
a synthetic field with one bright patch

Histogram equalization and synthetic noise

histeq and imequalize are the same operation under two names. imnoise adds Gaussian or salt-and-pepper noise, handy for testing the filters above. The source matrix here is a low-contrast image with all values clustered tightly around 88; after equalization, eq spreads those near-identical grey values apart so the same pattern shows visibly more contrast between neighboring pixels. noisy_sp looks like the original image but with a scattering of pure-black and pure-white pixels speckled across it — the "salt and pepper" the name describes — while a Gaussian-noise version would instead show a subtler, grainy variation across every pixel rather than isolated extreme dots.

m = [80, 90, 85, 95; 88, 92, 84, 91; 86, 89, 93, 87; 90, 85, 88, 92]
img = image_from_matrix(m)

eq = histeq(img)
eq2 = imequalize(img)                          # same result, other name

imshow(eq)

noisy_g = imnoise(img, "gaussian", sigma=0.05, seed=1)
noisy_sp = imnoise(img, "salt_pepper", density=0.1, seed=2)
imshow(noisy_sp)
print(eq.width, eq2.width, noisy_g.width, noisy_sp.width)
output
4 4 4 4
a synthetic field with one bright patch

Morphology: erode, dilate, open, close, and the hat transforms

The four base operations (imerode/imdilate/imopen/imclose) and their bright/dark-feature duals (imtophat/imbothat) on one small speckled image. The source img is a binary image with two solid 2x2 blobs, one lone single-pixel speck, and one small gap between two foreground pixels. Displayed side by side, img shows all of that detail; opened looks like img but with the lone speck erased, since opening removes anything smaller than the structuring element while leaving the larger 2x2 blobs untouched — eroded would shrink every blob including erasing the thin ones entirely, dilated would grow every blob and fuse nearby ones together, and closed would fill the small gap between neighboring foreground pixels.

m = [0, 0, 0, 0, 0, 0; 0, 255, 255, 0, 255, 0; 0, 255, 255, 0, 0, 0; 0, 0, 0, 0, 0, 0; 0, 255, 0, 0, 255, 255; 0, 0, 0, 0, 255, 255]
img = image_from_matrix(m)
imshow(img)

eroded = imerode(img, 1)
dilated = imdilate(img, 1)
opened = imopen(img, 1)                # drops the lone speck

closed = imclose(img, 1)               # fills small gaps

bright = imtophat(img, 1)              # small bright features

dark = imbothat(img, 1)                # small dark features

imshow(opened)
print(eroded.width, dilated.width, opened.width, closed.width, bright.width, dark.width)
output
6 6 6 6 6 6
a synthetic field with one bright patch

Thresholding: multi-level, Otsu, and Kapur

otsu computes-and-applies in one step; multi_otsu/kapur_threshold compute cut points that feed into multithreshold; foreground_mask thresholds and cleans up specks in one call. The source matrix is a 4x4 image with two clearly separated intensity groups (dark values near 10–30 and bright values near 200–230). seg (from otsu) renders as a crisp black-and-white split along that boundary; banded, built from multi_otsu's two cut points, instead shows three distinct grey bands rather than a plain binary split, since it quantizes into more classes than a single threshold could.

m = [10, 20, 30, 220; 15, 25, 200, 230; 120, 130, 210, 225; 125, 135, 205, 215]
img = image_from_matrix(m)

seg = otsu(img)                             # compute Otsu's level and apply it

imshow(seg)

cuts = multi_otsu(img, 3)                   # two cut points -> three bands

banded = multithreshold(img, cuts)
imshow(banded)

k = kapur_threshold(img)                    # entropy-based single threshold

fg = foreground_mask(img, k, radius=1)      # threshold + speck cleanup

print(cuts, k, seg.width, banded.width, fg.width)
output
[30.625, 135.46875] 130.3125 4 4 4
a synthetic field with one bright patch

Cleaning and measuring blobs

remove_small_blobs drops specks by area, label_blobs assigns each remaining region an id, and blob_stats reports area/centroid/bbox per region. The source binary image contains one solid 2x2 white blob in the top-left corner, one lone single white pixel next to it, and one solid 2x2 white blob in the bottom-right corner. After remove_small_blobs(binary, 3), the resulting cleaned image looks identical to the source except the lone single-pixel speck has vanished, leaving only the two real 2x2 blobs — which is exactly what the printed loop then reports: two rows, each with a label id, an area of 4 pixels, and a centroid at the geometric middle of its own 2x2 square.

m = [255, 255, 0, 0, 0, 0; 255, 255, 0, 255, 0, 0; 0, 0, 0, 0, 0, 0; 0, 255, 0, 0, 0, 0; 0, 0, 0, 0, 255, 255; 0, 0, 0, 0, 255, 255]
binary = image_from_matrix(m)

cleaned = remove_small_blobs(binary, 3)     # drop the two 1-pixel specks

imshow(cleaned)
labeled = label_blobs(cleaned)
stats = blob_stats(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
1 4 0.5 0.5
2 4 4.5 4.5
a synthetic field with one bright patch