moonbit_image

    Pure-MoonBit image decoder/encoder covering BMP / QOI / TGA / PNG / GIF / JPEG with zero external runtime dependencies. Decoders raise a structured `DecodeError` sub-error type so callers can branch on cause (UnsupportedFormat / TruncatedData / InvalidHeader / InvalidValue / EncodeNotImplemented).

    image
    codec
    bmp
    qoi
    tga
    png
    gif
    jpeg
    Download zip
    Author
    Version
    0.3.4
    License
    MIT OR Apache-2.0
    Last updated
    2 days ago
    Downloads
    23

    #riantr/moonbit_image

    Pure-MoonBit image decoder / encoder covering BMP / QOI / TGA / PNG / GIF / JPEG / ICO / TIFF. Zero external runtime dependencies — every codec is hand-written in MoonBit and lives inside this single package.

    This package is forked from lws/moonbit_image (MIT, 2025). The original sources were vendored inside moonbit-labeler/extensions/image/; this package repackages them as a standalone mooncakes.io library so the labeler and any other MoonBit project can depend on a single shared implementation.

    #What's new in 0.3.4

    Bug-fix release for the JPEG IDCT. The 1-D idct_1d routine was unconditionally adding the +128 level shift and clamping to [0, 255] on every call, but idct_2d runs it twice (row pass + column pass). The cumulative effect was a +256 shift (clamping almost every pixel to white) plus a clipped intermediate row pass that the column pass couldn't recover from — the classic "花屏" 8×8 block pattern visible on every real-world JPEG in mizchi / backend-decode mode.

    The fix splits the row and column passes with explicit flags: idct_1d(v, out, os, apply_level_shift, is_final). The row pass gets false, false; the column pass gets true, true. Level shift and clamp are now applied exactly once, on the final (column) pass.

    Reported by riantr/moonbit_labeler mizchi smoke test on the BIOMEDICA X-ray JPEG set. Same patch applied locally in the consuming project verified end-to-end before the upstream release.

    #What's new in 0.3.3

    Patch release — no functional changes from 0.3.2 / 0.3.1. Locking in the current state after a full benchmark pass on the consuming project (riantr/moonbit_labeler); 49/49 tests pass against this version, and the docs/benchmark.md baseline in moonbit-labeler measures each decoder / encoder at known reference sizes. The metrics are stable across re-runs, so 0.3.3 is safe to depend on for downstream production work.

    #What's new in 0.3.0

    0.3.0 adds two new formats — ICO (Windows icon / cursor container) and TIFF (Tagged Image File Format) — and ships 11 new tests covering them. Every existing 0.2.x test still passes.

    • decode_ico routes ICO entries through the existing BMP / PNG decoders:

      • Full BMP file (with the 14-byte BM header) — pass-through.
      • Full PNG file (the Vista+ form) — pass-through.
      • DIB-only BMP (older icons) — a synthetic 14-byte file header is prepended so the existing BMP decoder can take it.

      The largest entry by area (with the first entry as the tie-breaker) is returned.

    • decode_tiff handles baseline uncompressed TIFF only. The supported subset is documented explicitly so callers know what to expect:

      FieldSupported
      Byte orderII (little-endian); MM raises InvalidHeader
      Compression1 (none); LZW / Deflate / JPEG-in-TIFF rejected
      Photometric0 (WhiteIsZero), 1 (BlackIsZero), 2 (RGB), 3 (Palette)
      BitsPerSample1 / 2 / 4 / 8 / 16 (uniform across samples)
      SamplesPerPixel1 (greyscale / palette) or 3 (RGB)
      PlanarConfiguration1 (chunky); planar rejected
      Layoutsingle strip only
      Sample formatUINT

      16-bit samples are right-shifted to 8 bits for output — display-friendly but lossy for photographic TIFFs. The ColorMap tag is read and palette indices are expanded to RGBA8.

    • detect_format recognises both the new magic byte sequences (II*\0, MM\0* for TIFF; 00 00 01 00 NN … for ICO).

    • ImageFormat::is_decodable() now reports true for both new variants.

    #What's new in 0.2.0

    0.2.0 is a breaking-API revision that cleans up the public error story and fixes a real chroma-shearing bug in the JPEG decoder.

    • Structured DecodeError replaces the previous stringly-typed Failure::Failure("...") errors. Every public decoder / encoder entry point now raises one of:

      VariantWhen it fires
      DecodeError::UnsupportedFormat(String)magic bytes do not match any known format signature
      DecodeError::TruncatedData(String)a frame / chunk / scanline ended in the middle of a read
      DecodeError::InvalidHeader(String)a header field is out of range or has an unsupported value
      DecodeError::InvalidValue(String)a header / pixel-data value was readable but rejected downstream
      DecodeError::EncodeNotImplemented(String)encode() was called with a decode-only format

      Callers can match err { ... } on the variant, or call .to_string() for a one-line diagnostic. The per-codec helpers (decode_bmp, decode_qoi, …) and low-level byte readers (read_u32_le, …) keep raising the built-in Failure suberror; the public wrappers translate it.

    • Removed legacy convenience APIs: is_supported_format and is_encode_supported. Use the new ImageFormat::is_decodable() / is_encodable() instance methods instead — they take an ImageFormat and read better in match arms:

      match fmt {
      ImageFormat::BMP => ...
      f if f.is_decodable() => ...
      }

    • Fixed JPEG chroma positioning bug (the "花屏" issue): for chroma components in 4:2:0 / 4:2:2 subsampled JPEGs the decoder placed each chroma block at mx * sf_h * 8 instead of mx * max_h * 8, which mis-aligned Cb / Cr against the luma plane and produced visible colour fringing / colour banding on most real-world photos. The chroma sampler now uses the correct macro-grid stride.

    • Image::to_rgba8 simplified: removed a redundant early-return-then-match-again structure; the function now uses one guard over the source format and a single bulk conversion loop per source layout.

    #Why a separate package

    moonbit-labeler used to vendor the image codec under extensions/image/. That works, but every labeler checkout ships its own copy and any other MoonBit project that wants the same decoder has to vendor the sources again. Moving the codec to a dedicated mooncakes.io module:

    • lets moonbit-labeler depend on it via import "riantr/moonbit_image"
    • lets any other MoonBit project reuse the same decoder / encoder
    • isolates the codec under a clean test surface (BMP / QOI round-trip, pixel math, geometric transforms) that isn't tied to the labeler UI

    #API surface

    // one-shot auto-detect decoder; raises DecodeError
    pub fn decode(data : Bytes) -> Image raise DecodeError

    // header-only dimension read (no pixel decode)
    pub fn image_dimensions(data : Bytes) -> (Int, Int, ImageFormat) raise DecodeError

    // per-format decoders (still raise the lower-level Failure)
    pub fn decode_bmp(data : Bytes) -> Image raise Failure
    pub fn decode_qoi(data : Bytes) -> Image raise Failure
    pub fn decode_tga(data : Bytes) -> Image raise Failure
    pub fn decode_png(data : Bytes) -> Image raise Failure
    pub fn decode_gif(data : Bytes) -> Image raise Failure
    pub fn decode_jpeg(data : Bytes) -> Image raise Failure

    // writers (QOI + BMP only); encode() raises DecodeError::EncodeNotImplemented
    // for everything else
    pub fn encode(image : Image, format : ImageFormat) -> Bytes raise DecodeError
    pub fn encode_qoi(image : Image) -> Bytes raise Failure
    pub fn encode_bmp(image : Image) -> Bytes raise Failure

    Image is { width, height, format, data } where format is one of Gray8 / GrayA8 / RGB8 / RGBA8. The Color struct, Image::to_rgba8 / to_grayscale / flip_horizontal / rotate_* / resize_* / brighten / histogram / average_color, and the color-space conversion methods on Color (to_hsl / from_hsl / to_hsv / from_hsv / blend / lerp / distance / to_gray) are all available.

    #Quick start

    // decode a JPEG -> Image, resize, re-encode as QOI
    let img = @moonbit_image.decode(jpeg_bytes) catch {
    err => {
    @log.warn("decode failed: \{err}")
    abort("give up")
    }
    }
    let half = img.resize_nearest(img.width / 2, img.height / 2)
    let qoi = @moonbit_image.encode(half, ImageFormat::QOI)

    #Tests

    moon test --target native runs the white-box tests in lib_test.mbt (29 tests across format detection, header reading, decode round-trips for QOI + BMP, colour math, geometric transforms, and a fuzz sweep over random pixels / random crops). Black-box tests live in qa/.

    #License

    Dual-licensed under MIT or Apache-2.0, at your option.

    The image codec sources in this package were originally published by lws as moonbit_image under the MIT license. To stay compatible with that upstream, this package keeps the MIT grant; we additionally offer Apache-2.0 so downstream consumers can pick whichever license fits their project. See LICENSE-MIT and LICENSE-APACHE for the full texts.

    #Attribution

    • Original sources © 2025 lws, MIT — see upstream history at https://github.com/Milky2018/moonbit-image.
    • 0.2.0 revisions (DecodeError, JPEG chroma fix, API cleanup) © 2026 RiantR, MIT or Apache-2.0.

    DecodeError

    pub suberror DecodeError {
    UnsupportedFormat(String)
    TruncatedData(String)
    InvalidHeader(String)
    InvalidValue(String)
    EncodeNotImplemented(String)
    }

    Structured error type raised by every public decoder / encoder entry point. Replaces the previous Failure::Failure("...") stringly-typed errors so callers can match on a specific cause and surface a useful message to the user.

    Variants:

    • UnsupportedFormat: the byte stream did not match any known format signature, or the caller asked for a format that is recognised but not yet decodable in this build.
    • TruncatedData: the byte stream ended in the middle of a frame / chunk / scanline. Includes the byte offset that was being read.
    • InvalidHeader: a header field is out of range or has an unsupported value (e.g. negative dimensions, unknown colour model).
    • InvalidValue: a header or pixel-data value was technically readable but rejected by a downstream invariant (e.g. an unknown PNG filter byte, an impossible Huffman code in a JPEG segment).
    • EncodeNotImplemented: the caller asked for encode() with a format that is decode-only.

    Note: TruncatedData and InvalidValue are reserved for future codec refinements; today every codec-internal Failure is wrapped as InvalidHeader. The unused variants are kept so downstream callers can match exhaustively without churn.

    AnimatedImage

    pub struct AnimatedImage {
    frames : Array[Image]
    delays : Array[Int]
    width : Int
    height : Int
    loop_count : Int
    }

    A multi-frame animated image with per-frame delay timing

    AnimatedImage::frame_count

    fn AnimatedImage::frame_count(self : AnimatedImage) -> Int

    AnimatedImage::new

    fn AnimatedImage::new(frames : Array[Image], delays : Array[Int], width : Int, height : Int, loop_count : Int) -> AnimatedImage

    Color

    pub struct Color {
    r : Int
    g : Int
    b : Int
    a : Int
    }

    A color represented as 8-bit RGBA components

    Color::blend

    fn Color::blend(self : Color, other : Color) -> Color

    Blend this color with another color using alpha compositing (over operator) The result replaces the background color with this (foreground) color on top alpha is in 0..255 range for per-pixel control

    Color::blend_alpha

    fn Color::blend_alpha(self : Color, other : Color, alpha : Int) -> Color

    Alpha blend with a specific alpha override (0..255)

    Color::default

    fn Color::default() -> Color

    Constructors and utilities for Color

    Color::distance

    fn Color::distance(self : Color, other : Color) -> Int

    Compute a simple perceptual color distance (weighted Euclidean) Uses simplified CIE76-like formula with weighted RGB components. Returns 0..441 for maximum distance (black vs white at max alpha).

    Color::from_gray

    fn Color::from_gray(v : Int) -> Color

    Color::from_hsl

    fn Color::from_hsl(hue : Int, saturation : Int, lightness : Int) -> Color

    Create a Color from HSL values (Hue 0..360, Saturation 0..100, Lightness 0..100)

    Color::from_hsv

    fn Color::from_hsv(hue : Int, saturation : Int, value : Int) -> Color

    Create a Color from HSV values (Hue 0..360, Saturation 0..100, Value 0..100)

    Color::from_rgb

    fn Color::from_rgb(r : Int, g : Int, b : Int) -> Color

    Color::invert

    fn Color::invert(self : Color) -> Color

    Invert this color (255 - each channel), preserves alpha

    Color::lerp

    fn Color::lerp(self : Color, other : Color, t : Int) -> Color

    Linear interpolation between two colors (t in 0..255 fixed-point) Returns an integer-blended color without floating-point

    Color::new

    fn Color::new(r : Int, g : Int, b : Int, a : Int) -> Color

    Color::to_gray

    fn Color::to_gray(self : Color) -> Int

    Color::to_hsl

    fn Color::to_hsl(self : Color) -> (Int, Int, Int)

    Convert an RGB color to HSL (Hue 0..360, Saturation 0..100, Lightness 0..100) Uses integer arithmetic to avoid floating-point precision issues

    Color::to_hsv

    fn Color::to_hsv(self : Color) -> (Int, Int, Int)

    Convert RGB to HSV (Hue 0..360, Saturation 0..100, Value 0..100)

    Color::with_alpha

    fn Color::with_alpha(self : Color, a : Int) -> Color

    Image

    pub struct Image {
    width : Int
    height : Int
    format : PixelFormat
    data : Bytes
    }

    A decoded image with pixel data

    Image::average_color

    fn Image::average_color(self : Image) -> Color

    Compute the average color of the image

    Image::brighten

    fn Image::brighten(self : Image, delta : Int) -> Image

    Brighten the image by adding a delta to all channels Positive delta brightens, negative darkens. Clamped to 0..255. Returns a new Image (does not modify self).

    Image::bytes_per_pixel

    fn Image::bytes_per_pixel(self : Image) -> Int

    Number of bytes per pixel for this format

    Image::crop

    fn Image::crop(self : Image, x : Int, y : Int, w : Int, h : Int) -> Image raise Failure

    Extract a sub-rectangle from the image.

    Errors

    Raises Failure if the crop region is out of bounds or has non-positive dimensions.

    Image::data_size

    fn Image::data_size(self : Image) -> Int

    Total size of pixel data in bytes

    Image::flip_horizontal

    fn Image::flip_horizontal(self : Image) -> Image

    Flip the image horizontally (mirror left-to-right). Each row is reversed in-place into a new buffer.

    Image::flip_vertical

    fn Image::flip_vertical(self : Image) -> Image

    Flip the image vertically (mirror top-to-bottom). Rows are copied in reverse order to a new buffer.

    Image::get_pixel

    fn Image::get_pixel(self : Image, x : Int, y : Int) -> Color

    Get the color at a specific pixel coordinate

    Image::histogram

    fn Image::histogram(self : Image) -> Array[Int]

    Compute a 256-bin luminance histogram from the image Returns an array[256] of pixel counts, indexed by luminance value (0-255)

    Image::new

    fn Image::new(width : Int, height : Int, format : PixelFormat, data : Bytes) -> Image

    Image::resize_bilinear

    fn Image::resize_bilinear(self : Image, new_w : Int, new_h : Int) -> Image

    Resize the image using bilinear interpolation. Each output pixel is a weighted blend of its 4 nearest source neighbors. Interpolation is performed independently on each byte channel.

    Image::resize_nearest

    fn Image::resize_nearest(self : Image, new_w : Int, new_h : Int) -> Image

    Resize the image using nearest-neighbor sampling. Uses integer arithmetic: src_x = dst_x * old_w / new_w.

    Image::rotate_180

    fn Image::rotate_180(self : Image) -> Image

    Rotate the image 180 degrees. Implemented as flip horizontal then flip vertical.

    Image::rotate_270

    fn Image::rotate_270(self : Image) -> Image

    Rotate the image 270 degrees clockwise (equivalent to 90 degrees counter-clockwise). New width = old height, new height = old width.

    Image::rotate_90

    fn Image::rotate_90(self : Image) -> Image

    Rotate the image 90 degrees clockwise. New width = old height, new height = old width.

    Image::stride

    fn Image::stride(self : Image) -> Int

    Number of bytes per row (stride) for this image

    Image::to_grayscale

    fn Image::to_grayscale(self : Image) -> Image

    Convert an image to grayscale in-place (returns Gray8 or GrayA8)

    Image::to_rgba8

    fn Image::to_rgba8(self : Image) -> Image

    Convert this image to RGBA8 format (always 4 bytes per pixel) Uses per-format bulk loops for performance (avoids per-pixel match dispatch)

    ImageFormat

    pub enum ImageFormat {
    BMP
    QOI
    TGA
    PNG
    GIF
    JPEG
    ICO
    TIFF
    }

    Image file formats that can be detected and decoded

    ImageFormat::is_decodable

    fn ImageFormat::is_decodable(self : ImageFormat) -> Bool

    Whether this format can be decoded back into an Image

    ImageFormat::is_encodable

    fn ImageFormat::is_encodable(self : ImageFormat) -> Bool

    Whether this format has an encoder (writes a fresh Bytes)

    PixelFormat

    pub enum PixelFormat {
    Gray8
    GrayA8
    RGB8
    RGBA8
    }

    All pixel formats supported by this library

    decode

    fn decode(data : Bytes) -> Image raise DecodeError

    Auto-detect format and decode an image from raw bytes.

    Raises DecodeError::UnsupportedFormat if the byte stream does not match any known signature. Wraps every codec-internal Failure as DecodeError::InvalidHeader so callers only need to match on one error type.

    decode_bmp

    fn decode_bmp(data : Bytes) -> Image raise Failure

    Decode a BMP image from raw bytes

    decode_by_format

    fn decode_by_format(data : Bytes, format : ImageFormat) -> Image raise DecodeError

    Decode an image with a known format. Internal codec failures are wrapped as DecodeError::InvalidHeader; no format-detection step happens, so UnsupportedFormat is not raised from this entry point.

    decode_gif

    fn decode_gif(data : Bytes) -> Image raise Failure

    Decode a GIF image from raw bytes (returns the first frame)

    decode_gif_all

    fn decode_gif_all(data : Bytes) -> AnimatedImage raise Failure

    Decode all frames from an animated GIF, returning an AnimatedImage. Parses Graphic Control Extensions for frame delays and transparency, and the Netscape Application Extension for loop count. Frames are composited onto the full canvas respecting disposal methods.

    decode_ico

    fn decode_ico(data : Bytes) -> Image raise Failure

    Decode an ICO / CUR container, returning the largest available image. CUR is accepted but its hotspot metadata is discarded — the cursor is returned as a plain image.

    decode_jpeg

    fn decode_jpeg(data : Bytes) -> Image raise Failure

    Decode a JPEG image from raw bytes. Supports grayscale (1-component) and YCbCr color (3-component) baseline JPEG.

    decode_png

    fn decode_png(data : Bytes) -> Image raise Failure

    Decode a PNG image from raw bytes

    decode_qoi

    fn decode_qoi(data : Bytes) -> Image raise Failure

    Decode a QOI image from raw bytes

    decode_tga

    fn decode_tga(data : Bytes) -> Image raise Failure

    Decode a TGA image from raw bytes

    decode_tiff

    fn decode_tiff(data : Bytes) -> Image raise Failure

    Decode a TIFF image. See file header for supported subset.

    detect_format

    fn detect_format(data : Bytes) -> ImageFormat?

    Detect the image format from magic bytes (file signature) PNG and QOI have distinctive signatures, BMP has "BM", TGA uses heuristic + footer

    encode

    fn encode(img : Image, format : ImageFormat) -> Bytes raise DecodeError

    Encode an image to the specified format. Currently BMP and QOI have encoders; other formats raise DecodeError::EncodeNotImplemented.

    encode_bmp

    fn encode_bmp(image : Image) -> Bytes raise Failure

    Encode an Image as an uncompressed BMP file.

    The image is converted to RGBA8 internally. If all pixels are fully opaque (alpha = 255 everywhere), the output is 24-bit BGR. Otherwise it is 32-bit BGRA to preserve transparency.

    Pixel data is written top-down with a negative height in the DIB header, so most BMP readers display the image correctly without additional flipping.

    Errors

    Raises Failure if the image has zero width or height.

    encode_qoi

    fn encode_qoi(image : Image) -> Bytes raise Failure

    Encode an image to QOI (Quite OK Image) format. Returns the encoded bytes ready to write to a file.

    image_dimensions

    fn image_dimensions(data : Bytes) -> (Int, Int, ImageFormat) raise DecodeError

    Get image dimensions from a file header without decoding pixels. Much faster than decode() for inspecting image metadata.

    image_format_name

    fn image_format_name(format : ImageFormat) -> String

    Get a human-readable string for an image format

    jpeg_dimensions

    fn jpeg_dimensions(data : Bytes) -> (Int, Int) raise Failure

    Read JPEG dimensions from header without decoding pixels. Fast path: scans for SOF0 marker and extracts width/height.

    pixel_format_name

    fn pixel_format_name(format : PixelFormat) -> String

    Get a human-readable string for a pixel format