AXLR / Catalog / media

image_info

Read an image's format, width, height, bit depth, channels and colour type from its header without decoding pixels: PNG (IHDR, palette, transparency, dpi, APNG frames, chunk list, CRC check), JPEG (SOF markers, process, chroma subsampling, JFIF density, Exif APP1 presence, Adobe transform), GIF (screen, colour table, frames, loop, transparency), BMP (every DIB header version) and WebP (VP8, VP8L, VP8X). Bytes as hex or a relative file path.

Install

/tools install image_info
/tools install image_info@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · image_info 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool image_info; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
hexstringnoThe file's bytes as hexadecimal; whitespace allowed
pathstringnoRelative path to the image inside the working directory
Input schema (JSON)
{
  "type": "object",
  "additionalProperties": false,
  "properties": {
    "hex": {
      "type": "string",
      "description": "The file's bytes as hexadecimal; whitespace allowed"
    },
    "path": {
      "type": "string",
      "description": "Relative path to the image inside the working directory"
    }
  }
}

Example

Input

{
  "hex": "47494638396101000100800000ffffff00000021f90401000000002c00000000010001000002024401003b"
}

Output (stdout)

{
  "format": "GIF",
  "version": "89a",
  "width": 1,
  "height": 1,
  "frames": 1,
  "has_alpha": true
}

Files

The command the install card registers is python3 image_info.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

image_info.py · sha256 6fa4f8fc886078e6abe86fd4b290d3f1b79dbdb5560b29d69f296579a60da1d3
#!/usr/bin/env python3
"""Read the dimensions, format, bit depth and colour type of an image from its header, without
decoding pixels: PNG (IHDR and the chunk list), JPEG (SOFn markers, JFIF, Exif APP1 presence,
Adobe APP14), GIF (logical screen, frames, transparency, loop), BMP (all DIB header versions)
and WebP (VP8, VP8L, VP8X).

Input (JSON on stdin):
  hex    the file's bytes as hexadecimal (spaces and newlines allowed), or
  path   a relative path to the file inside the working directory
"""
import json
import os
import re
import struct
import sys
import zlib

MAX_BYTES = 64 * 1024 * 1024
PNG_SIG = bytes.fromhex("89504e470d0a1a0a")
PNG_TYPES = {0: ("greyscale", 1, (1, 2, 4, 8, 16)), 2: ("truecolour (RGB)", 3, (8, 16)),
             3: ("indexed-colour (palette)", 1, (1, 2, 4, 8)), 4: ("greyscale with alpha", 2, (8, 16)),
             6: ("truecolour with alpha (RGBA)", 4, (8, 16))}
SOF = {0xC0: "baseline DCT", 0xC1: "extended sequential DCT", 0xC2: "progressive DCT", 0xC3: "lossless",
       0xC5: "differential sequential DCT", 0xC6: "differential progressive DCT", 0xC7: "differential lossless",
       0xC9: "extended sequential DCT, arithmetic", 0xCA: "progressive DCT, arithmetic", 0xCB: "lossless, arithmetic",
       0xCD: "differential sequential DCT, arithmetic", 0xCE: "differential progressive DCT, arithmetic",
       0xCF: "differential lossless, arithmetic"}
BMP_HEADERS = {12: "BITMAPCOREHEADER", 16: "OS22XBITMAPHEADER (short)", 40: "BITMAPINFOHEADER", 52: "BITMAPV2INFOHEADER",
               56: "BITMAPV3INFOHEADER", 64: "OS22XBITMAPHEADER", 108: "BITMAPV4HEADER", 124: "BITMAPV5HEADER"}
BMP_COMPRESSION = {0: "BI_RGB (none)", 1: "BI_RLE8", 2: "BI_RLE4", 3: "BI_BITFIELDS", 4: "BI_JPEG", 5: "BI_PNG",
                   6: "BI_ALPHABITFIELDS", 11: "BI_CMYK", 12: "BI_CMYKRLE8", 13: "BI_CMYKRLE4"}


class Bad(Exception):
    pass


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def load(request):
    if ("hex" in request) == ("path" in request):
        fail("give hex (the bytes) or path (a relative file), not both")
    if "hex" in request:
        text = request["hex"]
        if not isinstance(text, str):
            fail("hex must be a string")
        text = re.sub(r"\s+", "", text)
        if text[:2].lower() == "0x":
            text = text[2:]
        if len(text) > 2 * MAX_BYTES:
            fail("the image may be at most 64 MiB")
        try:
            return bytes.fromhex(text)
        except ValueError:
            fail("hex is not valid hexadecimal")
    path = request["path"]
    if not isinstance(path, str) or not path or os.path.isabs(path) or ".." in re.split(r"[\\/]", path):
        fail("path must be a relative path inside the working directory, without ..")
    full = os.path.realpath(path)
    if not full.startswith(os.path.realpath(os.getcwd()) + os.sep):
        fail("path must stay inside the working directory")
    try:
        with open(full, "rb") as handle:
            data = handle.read(MAX_BYTES + 1)
    except OSError as error:
        fail(f"cannot read {path}: {error.strerror}")
    if len(data) > MAX_BYTES:
        fail("the image may be at most 64 MiB")
    return data


def need(data, end, what):
    if len(data) < end:
        raise Bad(f"truncated {what}: needs {end} bytes, has {len(data)}")


def png(data):
    need(data, 33, "PNG IHDR")
    length, kind = struct.unpack(">I4s", data[8:16])
    if kind != b"IHDR" or length != 13:
        raise Bad("the first PNG chunk must be IHDR of length 13")
    width, height, depth, ctype, comp, filt, interlace = struct.unpack(">IIBBBBB", data[16:29])
    if ctype not in PNG_TYPES:
        raise Bad(f"invalid PNG colour type {ctype}")
    name, channels, depths = PNG_TYPES[ctype]
    crc_ok = zlib.crc32(data[12:29]) & 0xFFFFFFFF == struct.unpack(">I", data[29:33])[0]
    out = {"format": "PNG", "mime": "image/png", "width": width, "height": height, "bit_depth": depth,
           "colour_type": ctype, "colour_type_name": name, "channels": channels, "bits_per_pixel": depth * channels,
           "interlace": "Adam7" if interlace == 1 else "none" if interlace == 0 else f"invalid ({interlace})",
           "compression_method": comp, "filter_method": filt, "ihdr_crc_ok": crc_ok,
           "has_alpha": ctype in (4, 6)}
    problems = []
    if depth not in depths:
        problems.append(f"bit depth {depth} is not allowed for colour type {ctype}")
    if width == 0 or height == 0 or width > 2 ** 31 - 1 or height > 2 ** 31 - 1:
        problems.append("width and height must be 1 to 2^31-1")
    chunks, pos = [], 8
    while pos + 8 <= len(data) and len(chunks) < 10000:
        length, kind = struct.unpack(">I4s", data[pos:pos + 8])
        name_ = kind.decode("latin-1")
        body = data[pos + 8:pos + 8 + length]
        chunks.append(name_)
        if name_ == "tRNS":
            out["has_alpha"] = True
            out["transparency"] = "tRNS chunk"
        elif name_ == "PLTE":
            out["palette_entries"] = length // 3
        elif name_ == "pHYs" and len(body) == 9:
            ppx, ppy, unit = struct.unpack(">IIB", body)
            out["pixels_per_unit"] = {"x": ppx, "y": ppy, "unit": "metre" if unit == 1 else "unknown (aspect ratio only)"}
            if unit == 1:
                out["dpi"] = {"x": round(ppx * 0.0254, 2), "y": round(ppy * 0.0254, 2)}
        elif name_ == "acTL" and len(body) == 8:
            frames, plays = struct.unpack(">II", body)
            out["animated"] = True
            out["frames"] = frames
            out["loop_count"] = plays
        elif name_ in ("eXIf",):
            out["exif"] = True
        pos += 12 + length
        if name_ == "IEND":
            break
    out["chunks"] = chunks
    if "IEND" not in chunks:
        problems.append("no IEND chunk (truncated or only a header given)")
    if problems:
        out["warnings"] = problems
    return out


def subsampling(components):
    if len(components) < 3:
        return None
    (h0, v0), (h1, v1), (h2, v2) = [(c["h"], c["v"]) for c in components[:3]]
    if (h1, v1) != (h2, v2) or h1 == 0 or v1 == 0:
        return f"{h0}x{v0},{h1}x{v1},{h2}x{v2}"
    ratio = (h0 // h1, v0 // v1)
    return {(1, 1): "4:4:4", (2, 1): "4:2:2", (2, 2): "4:2:0", (4, 1): "4:1:1", (1, 2): "4:4:0"}.get(ratio, f"{h0}x{v0},{h1}x{v1},{h2}x{v2}")


def jpeg(data):
    pos, out, markers = 2, {"format": "JPEG", "mime": "image/jpeg"}, []
    adobe = None
    sof = None
    while pos < len(data):
        if data[pos] != 0xFF:
            raise Bad(f"expected a marker at byte {pos}")
        while pos < len(data) and data[pos] == 0xFF:
            pos += 1
        if pos >= len(data):
            break
        marker = data[pos]
        pos += 1
        if marker in (0x01, 0xD8) or 0xD0 <= marker <= 0xD7:
            continue
        if marker == 0xD9:
            markers.append("EOI")
            break
        need(data, pos + 2, f"segment FF{marker:02X}")
        length = struct.unpack(">H", data[pos:pos + 2])[0]
        if length < 2:
            raise Bad(f"bad segment length at byte {pos}")
        body = data[pos + 2:pos + length]
        if 0xE0 <= marker <= 0xEF:
            name = f"APP{marker - 0xE0}"
        elif marker in SOF:
            name = f"SOF{marker - 0xC0}"
        else:
            name = {0xDB: "DQT", 0xC4: "DHT", 0xDA: "SOS", 0xDD: "DRI", 0xFE: "COM", 0xCC: "DAC", 0xDC: "DNL"}.get(marker, f"FF{marker:02X}")
        markers.append(name)
        if marker == 0xE0 and body.startswith(b"JFIF\x00") and len(body) >= 12:
            units = {0: "aspect ratio only", 1: "dots per inch", 2: "dots per cm"}.get(body[7], "unknown")
            out["jfif"] = {"version": f"{body[5]}.{body[6]:02d}", "density_units": units,
                           "x_density": struct.unpack(">H", body[8:10])[0], "y_density": struct.unpack(">H", body[10:12])[0]}
        elif marker == 0xE1 and body.startswith(b"Exif\x00\x00"):
            out["exif"] = True
        elif marker == 0xE1 and body.startswith(b"http://ns.adobe.com/xap/1.0/"):
            out["xmp"] = True
        elif marker == 0xE2 and body.startswith(b"ICC_PROFILE\x00"):
            out["icc_profile"] = True
        elif marker == 0xEE and body.startswith(b"Adobe") and len(body) >= 12:
            adobe = body[11]
        elif marker in SOF and sof is None:
            if len(body) < 6:
                raise Bad("truncated SOF segment")
            precision, height, width, ncomp = struct.unpack(">BHHB", body[:6])
            comps = []
            for i in range(ncomp):
                c = body[6 + 3 * i:9 + 3 * i]
                if len(c) < 3:
                    raise Bad("truncated SOF component list")
                comps.append({"id": c[0], "h": c[1] >> 4, "v": c[1] & 15, "quant_table": c[2]})
            sof = {"marker": f"SOF{marker - 0xC0}", "process": SOF[marker], "precision": precision, "height": height,
                   "width": width, "components": comps}
        if marker == 0xDA:
            break  # entropy-coded data follows; every header precedes it
        pos += length
    if sof is None:
        raise Bad("no SOF marker before the scan data (not a complete JPEG header)")
    n = len(sof["components"])
    ids = bytes(c["id"] for c in sof["components"])
    if n == 1:
        colour = "greyscale"
    elif n == 3:
        colour = "RGB" if adobe == 0 or ids == b"RGB" else "YCbCr"
    elif n == 4:
        colour = "YCCK" if adobe == 2 else "CMYK"
    else:
        colour = f"{n} components"
    out.update(width=sof["width"], height=sof["height"], bit_depth=sof["precision"], channels=n,
               bits_per_pixel=sof["precision"] * n, colour_type_name=colour, has_alpha=False,
               sof=sof["marker"], process=sof["process"], progressive=sof["marker"] in ("SOF2", "SOF6", "SOF10", "SOF14"),
               components=sof["components"], exif=out.get("exif", False), markers=markers)
    if n >= 3 and colour == "YCbCr":
        out["chroma_subsampling"] = subsampling(sof["components"])
    if adobe is not None:
        out["adobe_transform"] = adobe
    if sof["height"] == 0:
        out["warnings"] = ["height 0 in SOF: the height is given later by a DNL marker"]
    return out


def gif_sub_blocks(data, pos):
    """Skip a chain of data sub-blocks; return (end, joined data)."""
    parts = []
    while True:
        need(data, pos + 1, "GIF sub-block")
        size = data[pos]
        pos += 1
        if size == 0:
            return pos, b"".join(parts)
        need(data, pos + size, "GIF sub-block")
        parts.append(data[pos:pos + size])
        pos += size


def gif(data):
    need(data, 13, "GIF header")
    width, height, packed, bg, aspect = struct.unpack("<HHBBB", data[6:13])
    gct = bool(packed & 0x80)
    gct_bits = (packed & 7) + 1
    out = {"format": "GIF", "mime": "image/gif", "version": data[3:6].decode("latin-1"), "width": width, "height": height,
           "global_colour_table": gct, "global_colour_table_size": 2 ** gct_bits if gct else 0,
           "colour_resolution_bits": ((packed >> 4) & 7) + 1, "background_colour_index": bg,
           "colour_type_name": "indexed-colour (palette)", "channels": 1, "has_alpha": False}
    if aspect:
        out["pixel_aspect_ratio"] = round((aspect + 15) / 64, 4)
    pos = 13 + (3 * 2 ** gct_bits if gct else 0)
    frames, depth_bits, problems = 0, gct_bits if gct else 0, []
    try:
        while True:
            need(data, pos + 1, "GIF block")
            block = data[pos]
            if block == 0x3B:
                break
            if block == 0x21:
                need(data, pos + 2, "GIF extension")
                label = data[pos + 1]
                end, body = gif_sub_blocks(data, pos + 2)
                if label == 0xF9 and len(body) >= 1 and body[0] & 1:
                    out["has_alpha"] = True
                    out["transparency"] = "graphic control extension"
                elif label == 0xFF and body[:11] in (b"NETSCAPE2.0", b"ANIMEXTS1.0") and len(body) >= 14:
                    out["loop_count"] = struct.unpack("<H", body[12:14])[0]
                pos = end
            elif block == 0x2C:
                need(data, pos + 10, "GIF image descriptor")
                fpacked = data[pos + 9]
                pos += 10
                if fpacked & 0x80:
                    lbits = (fpacked & 7) + 1
                    depth_bits = max(depth_bits, lbits)
                    pos += 3 * 2 ** lbits
                pos += 1  # LZW minimum code size
                pos, _ = gif_sub_blocks(data, pos)
                frames += 1
            else:
                raise Bad(f"unknown GIF block 0x{block:02x} at byte {pos}")
    except Bad as error:
        problems.append(str(error))
    out.update(frames=frames, animated=frames > 1, bit_depth=depth_bits or None, bits_per_pixel=depth_bits or None)
    if problems:
        out["warnings"] = problems
    return out


def bmp(data):
    need(data, 18, "BMP header")
    file_size, offset, dib = struct.unpack("<I4xII", data[2:18])
    if dib not in BMP_HEADERS:
        raise Bad(f"unknown BMP DIB header size {dib}")
    need(data, 14 + dib, "BMP DIB header")
    h = data[14:14 + dib]
    out = {"format": "BMP", "mime": "image/bmp", "dib_header": BMP_HEADERS[dib], "declared_file_size": file_size,
           "pixel_data_offset": offset}
    if dib == 12:
        width, height, planes, bpp = struct.unpack("<HHHH", h[4:12])
        compression = 0
    else:
        width, height, planes, bpp = struct.unpack("<iiHH", h[4:16])
        compression = struct.unpack("<I", h[16:20])[0] if dib >= 20 else 0
        if dib >= 40:
            xppm, yppm, used = struct.unpack("<iiI", h[24:36])
            if xppm > 0 and yppm > 0:
                out["dpi"] = {"x": round(xppm * 0.0254, 2), "y": round(yppm * 0.0254, 2)}
            if bpp <= 8:
                out["palette_entries"] = used or 2 ** bpp
    out["top_down"] = height < 0
    alpha = False
    if bpp in (16, 32) and compression in (3, 6):
        if dib >= 56:
            alpha = struct.unpack("<I", h[52:56])[0] != 0
        elif dib == 40 and compression == 6 and len(data) >= 70:
            alpha = struct.unpack("<I", data[66:70])[0] != 0
    colour = "indexed-colour (palette)" if bpp <= 8 else "RGB" if not alpha else "RGBA"
    channels = 1 if bpp <= 8 else 4 if alpha else 3
    out.update(width=abs(width), height=abs(height), planes=planes, bits_per_pixel=bpp,
               bit_depth=bpp if bpp <= 8 else (8 if bpp in (24, 32) else 5 if bpp == 16 else bpp),
               channels=channels, colour_type_name=colour, has_alpha=alpha,
               compression=BMP_COMPRESSION.get(compression, f"unknown ({compression})"))
    if bpp == 16:
        out["note"] = "16-bit BMP: 5 bits per channel (or as the bit fields say)"
    return out


def webp(data):
    need(data, 16, "WebP header")
    riff_size = struct.unpack("<I", data[4:8])[0]
    out = {"format": "WebP", "mime": "image/webp", "riff_size": riff_size, "bit_depth": 8}
    pos, chunks, frames = 12, [], 0
    first = None
    while pos + 8 <= len(data) and len(chunks) < 100000:
        kind = data[pos:pos + 4].decode("latin-1")
        size = struct.unpack("<I", data[pos + 4:pos + 8])[0]
        body = data[pos + 8:pos + 8 + size]
        chunks.append(kind)
        if first is None:
            first = (kind, body)
        if kind == "ANMF":
            frames += 1
        pos += 8 + size + (size & 1)
    if first is None:
        raise Bad("no WebP chunk after the header")
    kind, body = first
    if kind == "VP8 ":
        if len(body) < 10 or body[3:6] != bytes.fromhex("9d012a"):
            raise Bad("VP8 chunk without the 9d 01 2a start code")
        w, hh = struct.unpack("<HH", body[6:10])
        out.update(encoding="lossy (VP8)", width=w & 0x3FFF, height=hh & 0x3FFF, has_alpha=False, channels=3,
                   colour_type_name="YUV 4:2:0 (lossy)", key_frame=not (body[0] & 1))
        if w >> 14 or hh >> 14:
            out["upscale"] = {"x": w >> 14, "y": hh >> 14}
    elif kind == "VP8L":
        if len(body) < 5 or body[0] != 0x2F:
            raise Bad("VP8L chunk without the 0x2f signature")
        bits = struct.unpack("<I", body[1:5])[0]
        alpha = bool((bits >> 28) & 1)
        out.update(encoding="lossless (VP8L)", width=(bits & 0x3FFF) + 1, height=((bits >> 14) & 0x3FFF) + 1,
                   has_alpha=alpha, channels=4 if alpha else 3, colour_type_name="RGBA" if alpha else "RGB",
                   vp8l_version=bits >> 29)
    elif kind == "VP8X":
        if len(body) < 10:
            raise Bad("truncated VP8X chunk")
        flags = body[0]
        width = int.from_bytes(body[4:7], "little") + 1
        height = int.from_bytes(body[7:10], "little") + 1
        alpha = bool(flags & 0x10)
        out.update(encoding="extended (VP8X)", width=width, height=height, has_alpha=alpha, channels=4 if alpha else 3,
                   colour_type_name="RGBA" if alpha else "RGB", icc_profile=bool(flags & 0x20), exif=bool(flags & 0x08),
                   xmp=bool(flags & 0x04), animated=bool(flags & 0x02))
        if "VP8L" in chunks:
            out["encoding"] += ", lossless image"
        elif "VP8 " in chunks:
            out["encoding"] += ", lossy image"
        if out["animated"]:
            out["frames"] = frames
    else:
        raise Bad(f"the first WebP chunk is {kind!r}, not VP8, VP8L or VP8X")
    out["bits_per_pixel"] = 8 * out["channels"]
    out["chunks"] = chunks
    return out


def main():
    try:
        request = json.load(sys.stdin)
    except ValueError as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    extra = set(request) - {"hex", "path"}
    if extra:
        fail(f"unknown field(s): {', '.join(sorted(extra))}")
    data = load(request)
    try:
        if data.startswith(PNG_SIG):
            out = png(data)
        elif data[:3] == b"\xff\xd8\xff":
            out = jpeg(data)
        elif data[:6] in (b"GIF87a", b"GIF89a"):
            out = gif(data)
        elif data[:2] == b"BM":
            out = bmp(data)
        elif data[:4] == b"RIFF" and data[8:12] == b"WEBP":
            out = webp(data)
        else:
            fail("not a PNG, JPEG, GIF, BMP or WebP file (unknown signature " + data[:12].hex() + ")")
    except Bad as error:
        fail(str(error))
    except struct.error as error:
        fail(f"truncated or malformed header: {error}")
    out["bytes"] = len(data)
    print(json.dumps(out))


if __name__ == "__main__":
    try:
        main()
    except SystemExit:
        raise
    except Exception as error:  # never a traceback
        fail(f"{type(error).__name__}: {error}")

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/image_info/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/image_info/1.0.0/image_info.py