moonimage

    Image codecs for MoonBit: PNG, JPEG, GIF, WebP, BMP, TGA, QOI, TIFF, ICO, PNM, Radiance HDR, OpenEXR, farbfeld and DDS, read and written, with the pixel arithmetic, filters and colour between them.

    image
    images
    image-codec
    codec
    codecs
    png
    apng
    jpeg
    jpg
    progressive-jpeg
    exif
    icc
    gif
    webp
    qoi
    bmp
    tga
    tiff
    lzw
    packbits
    bigtiff
    ico
    cur
    icon
    cursor
    pnm
    pbm
    pgm
    ppm
    pam
    netpbm
    hdr
    radiance
    rgbe
    exr
    openexr
    half-float
    float-image
    farbfeld
    dds
    bc1
    bc3
    bc5
    bc6h
    bc7
    bptc
    dxt
    decode
    encode
    transcode
    convert
    raster
    pixel
    pixels
    rgba
    resize
    scale
    resample
    lanczos
    bilinear
    catmull-rom
    gaussian
    blur
    sharpen
    unsharp-mask
    crop
    rotate
    flip
    premultiplied
    alpha
    animation
    metadata
    thumbnail
    texture
    moonbit
    Download zip
    Version
    0.1.3
    License
    Apache-2.0
    Last updated
    8 hours ago
    Downloads
    47

    #moonimage

    Image codecs for MoonBit: thirteen formats read, twelve written, and the pixel arithmetic that sits between them.

    No I/O, all four backends, and two dependencies: the deflate stream a PNG needs and the colour package a hue rotation needs.

    test {
    let image = @moonimage.Image::new(640, 480, @moonimage.PixelFormat::Rgba8)
    let small = image.scale(160, 120, sampling=@moonimage.Sampling::Bilinear)
    let file = @moonimage.encode(small, @moonimage.Format::Png)
    let back = @moonimage.decode(file)
    assert_eq(back.width, 160)
    assert_eq(back.height, 120)
    }

    Two runs to start from: moon run src/examples/01-tour walks one picture through every format and every operation, and moon run src/examples/02-guide is the program behind the guide.

    #Reading and writing

    decode reads the signature and hands the bytes to the package that knows that format. It takes the limits a reader works under, which are checked before anything is allocated:

    test {
    let image = @moonimage.Image::new(8, 8, @moonimage.PixelFormat::Rgb8)
    let file = @moonimage.encode(image, @moonimage.Format::Tiff)
    let safe = @moonimage.decode(file, limits=@moonimage.Limits::strict())
    assert_eq(safe.width, 8)
    }

    A file that is damaged, truncated, or larger than the limits raises Refused, which says where it stopped and what it wanted:

    test {
    let refused = try {
    ignore(@moonimage.decode(b"not an image"))
    abort("unreachable")
    } catch {
    error => error
    }
    assert_eq(refused is @moonimage.Refused::Unknown(_), true)
    }

    Unexpected and Checksum mean damaged bytes, Truncated means the file ends early, OverLimit means the caller's limits refused it, Unknown means it is not a format this library reads, and Unsupported means a part of a format it does not implement.

    encode writes the format the caller names, with that format's defaults, and raises Refused for the one format that is read-only. A caller that wants to choose how it is written goes to the format's own package, which takes its options record.

    FormatReadWriteAnimationPackage
    PNGevery colour type and depth, palette and tRNS, Adam7, five filters, CRC, gAMA cHRM sRGB iCCP tEXt zTXt iTXt8 and 16 bits, palette when it is smaller, adaptive filtering, interlaced on request, metadataAPNG@moonimage/png
    QOIall six opcodesthe reference encoder's choices, byte for byte—@moonimage/qoi
    BMPevery header version, 1/4/8/16/24/32 bits, BI_RGB BI_RLE4 BI_RLE8 BI_BITFIELDS, both row orders24 and 32 bits—@moonimage/bmp
    TGAcolour mapped, true colour and grey, run lengths, 8/15/16/24/32 bits, both row orders24 and 32 bits, run lengths on request—@moonimage/tga
    JPEGbaseline and progressive, every subsampling, greyscale, EXIF orientationbaseline, quality 1–100, three subsamplings—@moonimage/jpeg
    GIFpalettes, transparency, interlace, run lengthspalette by exact colours or median cut, ditheringyes@moonimage/gif
    WebPlossless (VP8L) with its four transforms, and lossy (VP8) with its boolean coder, predictors, loop filter and chroma upsampling; alpha in either; animated fileslossless, with a predictor transform and LZ77yes@moonimage/webp
    TIFFstrips, LZW, PackBits, deflate or none, horizontal prediction, grey, grey with alpha, RGB, RGBA and palette at 1 to 16 bits, planar or chunky, every page8 and 16 bits, three compressions, prediction on request, several pagespages@moonimage/tiff
    ICO / CURPNG entries and bitmap entries at 1/4/8/24/32 bits with the one-bit mask, every size in the filePNG entries, at any list of sizessizes@moonimage/ico
    PNMPBM, PGM, PPM and PAM, both spellings, any maximumall four shapes, both spellings—@moonimage/pnm
    Radiance HDRRGBE, the new run-length code and the flat one, all four originsRGBE with the run-length code—@moonimage/hdr
    OpenEXRscanlines in half or float, uncompressed, RLE, ZIPS or ZIPthe same three compressions—@moonimage/exr
    farbfeldthe whole formatthe whole format—@moonimage/farbfeld
    DDSBC1 to BC7 blocks, and uncompressed surfaces——@moonimage/dds

    Both halves of WebP are read; the lossless one, VP8L, is also written. VP8 is the intra frame of a video codec, so its picture is rebuilt rather than decoded: a boolean arithmetic coder over prediction modes and transform coefficients, ten predictors for four-by-four blocks and four for sixteen-by-sixteen ones, a residual carried as quantised coefficients, a loop filter along every macroblock and transform edge, and chroma interpolated between the two rows it sits between. What comes out is the reconstruction the format defines, sample for sample — a lossy decoder that is "nearly right" is one with a bug in it.

    A lossy picture has no alpha in VP8 itself, so a transparent one carries a separate ALPH chunk beside the picture. That is read too, in a still picture and inside an animation frame, and comes back as four samples per pixel.

    A TIFF is a directory of tags pointing at data anywhere in the file; this reader takes strips rather than tiles, and refuses a tiled file by name instead of reading its rows in the wrong order. A DDS is a block grid: four by four pixels become eight or sixteen bytes, and what those bytes hold is a format of its own — two endpoints and an index a pixel in BC1 to BC5, a mode number and a table of shapes in BC7, and half-precision light in BC6H. decode gives the picture in the layout the block is, and decode takes a format for the one a caller is drawing with, because a block layout is rarely a display layout.

    #The image

    An image is a rectangle of samples, the format that says how to read them, and the metadata that travelled with them.

    pub(all) struct Image {
    width : Int32
    height : Int32
    format : PixelFormat
    stride : Int32
    pixels : Bytes
    premultiplied : Bool
    metadata : Metadata
    }

    The row stride is separate from the width because a caller uploading to a GPU needs rows padded to a multiple of 256 bytes. Pixels are immutable: every operation returns a new image rather than writing into the old one.

    Pixel formatSamplesBytes a pixel
    Gray8, GrayAlpha8, Rgb8, Rgba88-bit1, 2, 3, 4
    Gray16, GrayAlpha16, Rgb16, Rgba1616-bit, big-endian2, 4, 6, 8
    RgbaF32floats, little-endian16

    #Moving pixels

    Conversions widen, narrow, add or drop alpha, and turn grey into three equal channels. Narrowing rounds rather than truncates, and colour becoming grey is a luminance rather than one of the channels:

    test {
    let image = @moonimage.Image::new(4, 2, @moonimage.PixelFormat::Rgba8)
    let grey = image.convert(@moonimage.PixelFormat::Gray8)
    let wide = image.convert(@moonimage.PixelFormat::Rgba16)
    assert_eq(grey.format, @moonimage.PixelFormat::Gray8)
    assert_eq(wide.format, @moonimage.PixelFormat::Rgba16)
    let flat = image.to_premultiplied()
    assert_eq(flat.premultiplied, true)
    assert_eq(flat.to_straight().premultiplied, false)
    }

    The operations that are a permutation keep the format and every sample exactly:

    test {
    let image = @moonimage.Image::new(3, 2, @moonimage.PixelFormat::Rgba8)
    assert_eq(image.flip_horizontal().width, 3)
    assert_eq(image.flip_vertical().height, 2)
    assert_eq(image.rotate(quarters=1).width, 2)
    assert_eq(image.crop(0, 0, 2, 1).height, 1)
    }

    Scaling has five filters. Nearest is what pixel art wants, bilinear what a photograph wants when it is scaled a little, Catmull-Rom and Lanczos what it wants when the edge matters, and the Gaussian the one to reach for when ringing is the problem:

    test {
    let image = @moonimage.Image::new(9, 7, @moonimage.PixelFormat::Rgb8)
    for
    sampling in [
    @moonimage.Sampling::Nearest,
    @moonimage.Sampling::Bilinear,
    @moonimage.Sampling::CatmullRom,
    @moonimage.Sampling::Gaussian,
    @moonimage.Sampling::Lanczos,
    ] {
    assert_eq(image.scale(16, 12, sampling~).width, 16)
    }
    }

    Alpha is resampled premultiplied even when the image is not stored that way, so a transparent pixel's colour does not bleed into its neighbours. The four filters that reach past two taps widen their footprint when the image is made smaller; bilinear stays at two taps, which is what makes it the cheap one.

    A rotation that is not a right angle resamples, and can grow the canvas to hold what comes out:

    test {
    let image = @moonimage.Image::new(8, 4, @moonimage.PixelFormat::Rgba8)
    let tilted = image.rotate_by(30.0, expand=true)
    assert_eq(tilted.width > 8, true)
    assert_eq(image.rotate_by(90.0).width, 4)
    }

    #Filters and colour

    Blurring is a separable Gaussian of the radius given, on premultiplied samples; sharpening is the difference between a picture and its own blur:

    test {
    let image = @moonimage.Image::new(6, 5, @moonimage.PixelFormat::Rgba8)
    let soft = image.blur(1.5)
    assert_eq(soft.width, 6)
    let crisp = soft.unsharp_mask(radius=1.0, amount=150.0, threshold=3)
    assert_eq(crisp.height, 5)
    }

    Brightness multiplies, contrast turns about mid-grey, the hue rotation keeps saturation and lightness, and inverting leaves alpha alone:

    test {
    let image = @moonimage.Image::new(3, 3, @moonimage.PixelFormat::Rgba8)
    assert_eq(image.brightness(1.5).contrast(1.2).hue_rotate(90.0).invert().width, 3)
    }

    A hue rotation is a conversion, so it goes through the colour package that owns the spaces: a pixel becomes a moonrgba colour and comes back.

    Compositing is source over, with the source converted to the destination's format and its alpha scaled by the opacity asked for:

    test {
    let under = @moonimage.Image::new(4, 4, @moonimage.PixelFormat::Rgba8)
    let over = @moonimage.Image::new(2, 2, @moonimage.PixelFormat::Rgba8)
    let drawn = under.composite(over, x=1, y=1, opacity=0.5)
    assert_eq(drawn.width, 4)
    }

    #Animation

    Three formats carry frames, and they agree on the shape even where they disagree on the arithmetic: a canvas, a rectangle per frame, a delay, a rule for what to do with the pixels underneath, and a rule for what to leave behind.

    test {
    let image = @moonimage.Image::new(4, 4, @moonimage.PixelFormat::Rgba8)
    let file = @moonimage.encode(image, @moonimage.Format::Gif)
    let animation = @moonimage.decode_animation(file)
    assert_eq(animation.frames.length(), 1)
    assert_eq(animation.compose().length(), 1)
    }

    compose applies the disposal and blending rules in order, which is what a viewer shows. A file that does not animate comes back as one frame over the whole canvas, and a TIFF comes back as a frame a page.

    #Metadata

    What a file says about itself travels with the image, and goes back out when it is written. Nothing here is interpreted: colour management is not this library's business.

    pub(all) struct Metadata {
    gamma : Double?
    chromaticities : Chromaticities?
    intent : Intent?
    icc : Icc?
    texts : Array<Text>
    }

    Text keeps the flavour of the chunk it came from — Latin for tEXt, Compressed for zTXt, International for iTXt — so that writing the image back produces the same kind of chunk. An ICC profile read from a PNG or a TIFF is carried and written back byte for byte.

    The orientation a camera wrote is read on its own, and applied when the caller asks for it:

    test {
    let image = @moonimage.Image::new(3, 2, @moonimage.PixelFormat::Rgb8)
    // Six is a quarter turn clockwise, which swaps the canvas.
    assert_eq(image.orient(6).width, 2)
    assert_eq(image.upright(None).width, 3)
    }

    #Limits

    Every reader takes a Limits record: the widest and tallest image, the most pixels, and the most bytes an expansion may produce.

    test {
    assert_eq(@moonimage.Limits::default().width, 1_048_576)
    assert_eq(@moonimage.Limits::strict().pixels, 67_108_864)
    }

    The defaults are what a desktop application can live with. A caller reading untrusted files should pass strict or a tighter record of its own.

    #What it does not do

    • AVIF. Decoding one needs a whole AV1 decoder, which is several times the size of this library, so it is not planned.
    • PIZ, the wavelet compression of OpenEXR. The three either side of it are read and written.
    • Writing tiled TIFF, writing lossy WebP, writing DDS.

    #Interface

    CallWhat it does
    decode(bytes, format?, limits?)read an image, whichever format the bytes are, in the layout asked for or the one the file holds
    decode_animation(bytes, limits?)read every frame; a still image is one frame
    encode(image, format)write an image, with that format's defaults
    encode_animation(animation, format)write the frames, or the first one where the format has no animation
    Format::of(bytes), Format::extension, Format::mime, Format::is_animatedwhat the bytes are, and how they are named
    Image::new, Image::ofan image, blank or over bytes that already exist
    Image::row, Image::pixelone row, one pixel
    Image::convertthe same image in another sample format
    Image::to_premultiplied, Image::to_straightcolour × alpha, and back
    Image::flip_horizontal, Image::flip_vertical, Image::rotate, Image::rotate_by, Image::cropmoving pixels without reading them
    Image::scalea new size, by any of five filters
    Image::blur, Image::unsharp_maska Gaussian, and the difference that undoes it
    Image::brightness, Image::contrast, Image::hue_rotate, Image::invertthe four colour knobs
    Image::compositeanother picture laid over this one
    Image::orient, Image::uprightthe way up a camera said the picture was
    Pixel::to_color, Pixel::of_color, Pixel::rotate_huethe seam to the colour package
    Animation::new, Animation::composeframes, and every frame as a full canvas
    png.encode, png.decode, png.EncodeOptions, png.FilterPNG, with its compression level and row filter
    qoi.encode, qoi.decodeQOI
    bmp.encode, bmp.decode, bmp.EncodeOptions, bmp.DepthBMP, 24 or 32 bits
    tga.encode, tga.decode, tga.EncodeOptions, tga.DepthTGA, with run lengths
    jpeg.encode, jpeg.decode, jpeg.EncodeOptions, jpeg.Subsampling, jpeg.exif_orientationJPEG, and the orientation a camera wrote
    gif.encode, gif.encode_animation, gif.decode, gif.decode_animation, gif.EncodeOptionsGIF, with dithering and transparency
    webp.decode, webp.decode_animation, webp.encode, webp.encode_animationWebP: lossless and lossy read, lossless written
    tiff.decode, tiff.decode_pages, tiff.encode, tiff.encode_pages, tiff.EncodeOptions, tiff.Compression, tiff.orientationTIFF, its compressions and its pages
    ico.decode, ico.decode_all, ico.encode, ico.EncodeOptions, ico.Entry, ico.Kindicons and cursors, at every size in the file
    pnm.decode, pnm.encode, pnm.EncodeOptions, pnm.Kindthe portable anymap, four shapes and two spellings
    hdr.decode, hdr.encodeRadiance HDR, samples above one and all
    exr.decode, exr.encode, exr.EncodeOptions, exr.Compression, exr.half_to_float, exr.float_to_halfOpenEXR, and the half-precision conversion it turns on
    farbfeld.decode, farbfeld.encodefarbfeld
    dds.decode, dds.Blockblock-compressed textures, read

    #Licence

    Apache-2.0. See LICENSE.

    Animation

    A sequence of frames over one canvas.

    Blend

    How a frame's pixels meet what is already on the canvas.

    Chromaticities

    The white point and the three primaries, as chromaticity coordinates.

    Eight numbers rather than a matrix: this is what a file writes, and a matrix would be a conversion, which this library does not do.

    Dispose

    What to do with the frame's rectangle once the frame has been shown.

    Format

    The formats this library reads and writes.

    Frame

    One frame: a picture, where it goes, and how long it stays.

    Icc

    An embedded ICC profile, kept by name and bytes. Not applied: a caller that wants colour management hands these bytes to something that does it.

    Image

    An image: its shape, its layout, and the bytes.

    Intent

    What a column of pixels was meant to be seen in.

    Limits

    The ceilings a decoder works under.

    The defaults are the ones a desktop application can live with: a texture no larger than a modern GPU takes, and a megabyte of pixels at the very most. A caller reading files from the network should set them lower; a caller reading its own assets can leave them alone.

    Metadata

    Everything the file said about itself besides its pixels.

    Pixel

    One pixel, in the units every operation in the library works in: four samples from 0 to 1, alpha straight.

    A grey pixel has r, g and b equal, which is why there is no grey variant: a reader that has to branch on whether a pixel is grey is a reader that will forget to inflate one of the three channels.

    PixelFormat

    How the samples of an image are laid out.

    The names are the usual ones: Gray8 is one byte per pixel, Rgba8 four, Rgba16 eight with each channel big-endian, RgbaF32 four floats.

    Refused

    A refusal to read bytes as an image, or to write an image as bytes.

    The variants separate the three questions a caller asks: is this the wrong kind of file (Unknown), is the file damaged (Unexpected, Truncated, Checksum), or is the caller asking for more than it allowed (OverLimit). Unsupported is the fourth: a part of a format this library does not read.

    Sampling

    How a scaler decides what an unknown pixel is worth.

    Text

    A text note, in the flavour of the chunk it came from, so that writing the image back produces the same kind of chunk.

    decode

    The image in these bytes, whichever format they are.

    Without format the picture comes back in the layout the file itself holds, which is what a second decode or a comparison against the original wants. With it, the picture comes back in the layout a caller is drawing with: a canvas wants Rgba8, a GPU upload of a normal map wants Rgb8, and a block texture's own layout is neither. That is one conversion, and none at all when the file already holds the format asked for.

    The limits default to Limits::default(); a caller reading untrusted files should pass Limits::strict() or a tighter record.

    decode_animation

    Every frame in these bytes, whichever format they are.

    A format that does not animate comes back as an animation of one frame over the whole canvas, so a caller that only draws animations does not need to branch. format is applied to every frame, for the same reason it exists on decode: a canvas draws in one layout and a file may hold several. A TIFF comes back as one frame a page, and an icon as one frame a size, because both are a sequence of pictures that a caller walking a sequence wants to see.

    encode

    The image as bytes of the named format, with that format's defaults.

    A caller that wants to choose how it is written calls the format's own package, which takes its options record.

    Dds is refused: a block-compressed texture is a picture whose blocks were chosen by a search, and this library reads them rather than guessing them.

    encode_animation

    The animation as bytes of the named format, with that format's defaults.

    The formats that animate keep every frame; the ones that do not write the first, which is what a viewer of a still image shows.

    Source Files