Skip to content

geomotif.io.raster

Turn a design into pixels, in pure standard library.

Everything else this library writes is vector, and for good reason: a design is a set of curves and the formats that keep them curves are the ones worth writing. This module exists for the pixels, which vector cannot be -- the frames of an animation, and the stills of PNG and JPEG.

The picture is drawn once as a full-color frame -- one RGBA value per pixel, palette-free -- so that antialiasing, styling and every ink land in one place and every encoder reads the same quality. Two output shapes come out of it, in the :class:Raster type:

  • A direct RGBA (or RGB) bitmap, the natural input for PNG and JPEG, which keep all 255 levels of color and edge.
  • An indexed bitmap -- one palette entry per pixel -- which is what GIF wants and what keeps a line drawing small. When antialiasing creates blends the index frame cannot hold on its own, the RGBA frame is run through :func:quantize, which shrinks it to a shared palette of at most 256 colors (with optional error-diffusion dithering) and never drops an ink.

Index 0 of a palette is the background and the strokes take whatever indices their styles worked out to, so a two-pen design rasterizes in two colors without being told twice.

With antialiasing off (the default), rendering is just the whole-pixel, hard-edged Bresenham draw this module has always done; antialiasing supersamples and then blends by coverage, which is the only part that costs more than a plain single paint.

Classes:

Name Description
Raster

An in-memory picture, top-left origin.

Functions:

Name Description
colors_in

Return the palette a set of designs needs, background first.

colours_in

Keep the British spelling working while it is phased out.

rasterize

Draw a design into an indexed bitmap.

rasterize_rgba

Draw a design into a full-color RGBA frame, supersampled and antialiased.

quantize

Shrink RGBA frames to one shared indexed palette of at most max_colors.

Raster dataclass

Raster(width: int, height: int, pixels: bytes, palette: tuple[str, ...] = (), mode: str = 'indexed')

An in-memory picture, top-left origin.

A :class:Raster holds either an indexed bitmap -- one palette index per pixel, which is what GIF wants -- or a direct one -- RGB or RGBA bytes per pixel, which is what PNG and JPEG want. Everything downstream (antialiasing, styling, the encoders) feeds off one of the two, so the picture is drawn once and encoded many ways.

Parameters:

Name Type Description Default
width int

Size in pixels.

required
height int

Size in pixels.

required
pixels bytes

width * height indices (indexed), or width * height * 4 RGBA or width * height * 3 RGB bytes (direct), row-major.

required
palette tuple of str

The colors an indexed bitmap's indices name, as #rrggbb. Index 0 is the background. Unused by a direct bitmap.

()
mode str

"indexed", "rgb" or "rgba". Defaults to "indexed" so a bare four-argument :class:Raster is the picture it has always been.

'indexed'

colors_in

colors_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]

Return the palette a set of designs needs, background first.

Shared across every frame of an animation rather than worked out per frame: a GIF has one global color table, and an index that meant crimson in one frame and black in the next would make the whole thing flicker.

Source code in src/geomotif/io/raster.py
def colors_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]:
    """Return the palette a set of designs needs, background first.

    Shared across every frame of an animation rather than worked out per frame:
    a GIF has one global color table, and an index that meant crimson in one
    frame and black in the next would make the whole thing flicker.
    """
    palette = [background, ink]
    for design in designs:
        for style in (*styles_of(design), *point_styles_of(design)):
            if style is not None and style.stroke is not None and style.stroke not in palette:
                palette.append(style.stroke)
    return tuple(palette)

colours_in

colours_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]

Keep the British spelling working while it is phased out.

:func:colors_in is the name of this function now; this alias exists so 1.1.0 callers keep working, and it warns that it will go away in a future major release.

Source code in src/geomotif/io/raster.py
def colours_in(designs: Iterable[Design], *, ink: str, background: str) -> tuple[str, ...]:
    """Keep the British spelling working while it is phased out.

    :func:`colors_in` is the name of this function now; this alias exists so
    1.1.0 callers keep working, and it warns that it will go away in a future
    major release.
    """
    warnings.warn(
        "colours_in is deprecated; use colors_in instead",
        DeprecationWarning,
        stacklevel=2,
    )
    return colors_in(designs, ink=ink, background=background)

rasterize

rasterize(design: Design, *, width: int = 480, height: int = 480, padding: float = 8.0, bounds: Bounds | None = None, palette: Sequence[str] | None = None, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None) -> Raster

Draw a design into an indexed bitmap.

Parameters:

Name Type Description Default
design Design

What to draw.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
bounds Bounds

The world rectangle to map onto the canvas. Defaults to the design's own, which is right for a single image and wrong for a frame of an animation -- there, pass the same bounds to every frame or the drawing will swim about as its extent changes.

None
palette sequence of str

colors the indices name, background first. Defaults to (background, ink) plus whatever the design's styles add.

None
ink str

Default stroke color and the color behind everything.

'#0b0b0b'
background str

Default stroke color and the color behind everything.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None

Returns:

Type Description
Raster

Ready to hand to :func:geomotif.io.gif.to_gif.

Source code in src/geomotif/io/raster.py
def rasterize(
    design: Design,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    bounds: Bounds | None = None,
    palette: Sequence[str] | None = None,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
) -> Raster:
    """Draw a design into an indexed bitmap.

    Parameters
    ----------
    design : Design
        What to draw.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    bounds : Bounds, optional
        The world rectangle to map onto the canvas. Defaults to the design's
        own, which is right for a single image and wrong for a frame of an
        animation -- there, pass the same bounds to every frame or the drawing
        will swim about as its extent changes.
    palette : sequence of str, optional
        colors the indices name, background first. Defaults to
        ``(background, ink)`` plus whatever the design's styles add.
    ink, background : str
        Default stroke color and the color behind everything.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.

    Returns
    -------
    Raster
        Ready to hand to :func:`geomotif.io.gif.to_gif`.
    """
    if width < 1 or height < 1:
        raise ValueError(f"width and height must be >= 1, got {width}x{height}")
    if thickness < 1:
        raise ValueError(f"thickness must be >= 1, got {thickness}")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")

    entries = (
        tuple(palette)
        if palette is not None
        else colors_in([design], ink=ink, background=background)
    )
    pixels = bytearray(width * height)
    radius = thickness if dot_radius is None else dot_radius
    place = _placement(bounds if bounds is not None else _bounds_of(design), width, height, padding)

    for path, style in zip(design.paths, styles_of(design), strict=True):
        index = _index_for(style, entries)
        pen = _pen(style, thickness)
        drawn = [place(p) for p in path.points]
        if path.closed and len(drawn) > 2:
            drawn.append(drawn[0])
        if len(drawn) == 1:
            _stamp(pixels, width, height, drawn[0][0], drawn[0][1], index, pen)
        for a, b in itertools.pairwise(drawn):
            _line(pixels, width, height, a, b, index, pen)

    for point, style in zip(design.points, point_styles_of(design), strict=True):
        index = _index_for(style, entries)
        _disc(pixels, width, height, place(point), _pen(style, radius), index)

    return Raster(width, height, bytes(pixels), entries)

rasterize_rgba

rasterize_rgba(design: Design, *, width: int = 480, height: int = 480, padding: float = 8.0, bounds: Bounds | None = None, ink: str = '#0b0b0b', background: str = '#ffffff', thickness: int = 1, dot_radius: int | None = None, scale: int = _AA_SCALE, aa_level: int | None = None, transparent: bool = False) -> Raster

Draw a design into a full-color RGBA frame, supersampled and antialiased.

Every pixel is a real (r, g, b, a) value rather than a palette index, so the edges can blend into the background -- which is the whole point of antialiasing. The design is painted scale times as large and each output pixel is the stroke color over the background weighted by the fraction of its sub-pixels the ink covered.

Parameters:

Name Type Description Default
design Design

What to draw.

required
width int

Canvas size in pixels.

480
height int

Canvas size in pixels.

480
padding float

Margin reserved on all four sides, in pixels.

8.0
bounds Bounds

The world rectangle to map onto the canvas. Pass the same bounds to every frame of an animation or the drawing will swim about.

None
ink str

Default stroke color and the color behind everything. When transparent is set, background is only what index 0 of an indexed result stands for -- it contributes no color at all.

'#0b0b0b'
background str

Default stroke color and the color behind everything. When transparent is set, background is only what index 0 of an indexed result stands for -- it contributes no color at all.

'#0b0b0b'
thickness int

Stroke width in pixels.

1
dot_radius int

Radius for loose points. Defaults to thickness.

None
scale int

Supersampling factor; 1 disables antialiasing. Must be >= 1.

_AA_SCALE
aa_level int

If given, each sub-pixel coverage fraction is rounded to one of aa_level steps before blending, so an indexed frame later sees at most aa_level blended shades per color pair. Leave None for a PNG or JPEG, which keep full color depth.

None
transparent bool

Leave the background empty rather than painting it. A pixel with no ink becomes alpha 0; an antialiased edge becomes the stroke color with the coverage as its alpha, which is the straight alpha a PNG stores and the mask an indexed GIF flags. Off by default.

False

Returns:

Type Description
Raster

An "rgba" frame, ready to hand to :func:quantize or an encoder.

Source code in src/geomotif/io/raster.py
def rasterize_rgba(
    design: Design,
    *,
    width: int = 480,
    height: int = 480,
    padding: float = 8.0,
    bounds: Bounds | None = None,
    ink: str = "#0b0b0b",
    background: str = "#ffffff",
    thickness: int = 1,
    dot_radius: int | None = None,
    scale: int = _AA_SCALE,
    aa_level: int | None = None,
    transparent: bool = False,
) -> Raster:
    """Draw a design into a full-color RGBA frame, supersampled and antialiased.

    Every pixel is a real (r, g, b, a) value rather than a palette index, so
    the edges can blend into the background -- which is the whole point of
    antialiasing. The design is painted ``scale`` times as large and each
    output pixel is the stroke color over the background weighted by the
    fraction of its sub-pixels the ink covered.

    Parameters
    ----------
    design : Design
        What to draw.
    width, height : int
        Canvas size in pixels.
    padding : float
        Margin reserved on all four sides, in pixels.
    bounds : Bounds, optional
        The world rectangle to map onto the canvas. Pass the same bounds to
        every frame of an animation or the drawing will swim about.
    ink, background : str
        Default stroke color and the color behind everything. When
        ``transparent`` is set, ``background`` is only what index 0 of an
        indexed result stands for -- it contributes no color at all.
    thickness : int
        Stroke width in pixels.
    dot_radius : int, optional
        Radius for loose points. Defaults to ``thickness``.
    scale : int
        Supersampling factor; ``1`` disables antialiasing. Must be >= 1.
    aa_level : int, optional
        If given, each sub-pixel coverage fraction is rounded to one of
        ``aa_level`` steps before blending, so an indexed frame later sees at
        most ``aa_level`` blended shades per color pair. Leave ``None`` for a
        PNG or JPEG, which keep full color depth.
    transparent : bool
        Leave the background empty rather than painting it. A pixel with no
        ink becomes ``alpha 0``; an antialiased edge becomes the stroke color
        with the coverage as its alpha, which is the straight alpha a PNG
        stores and the mask an indexed GIF flags. Off by default.

    Returns
    -------
    Raster
        An ``"rgba"`` frame, ready to hand to :func:`quantize` or an encoder.
    """
    if width < 1 or height < 1:
        raise ValueError(f"width and height must be >= 1, got {width}x{height}")
    if thickness < 1:
        raise ValueError(f"thickness must be >= 1, got {thickness}")
    if padding < 0:
        raise ValueError(f"padding must be >= 0, got {padding}")
    if scale < 1:
        raise ValueError(f"scale must be >= 1, got {scale}")

    entries = colors_in([design], ink=ink, background=background)
    entry_rgb = [_rgb(color) for color in entries]
    sub_w, sub_h = width * scale, height * scale
    sub = bytearray(sub_w * sub_h)
    radius = thickness if dot_radius is None else dot_radius
    place = _placement(
        bounds if bounds is not None else _bounds_of(design), sub_w, sub_h, padding * scale
    )

    for path, style in zip(design.paths, styles_of(design), strict=True):
        index = _index_for(style, entries)
        pen = max(1, round(_pen(style, thickness) * scale))
        drawn = [place(p) for p in path.points]
        if path.closed and len(drawn) > 2:
            drawn.append(drawn[0])
        if len(drawn) == 1:
            _stamp(sub, sub_w, sub_h, drawn[0][0], drawn[0][1], index, pen)
        for first, second in itertools.pairwise(drawn):
            _line(sub, sub_w, sub_h, first, second, index, pen)

    for point, style in zip(design.points, point_styles_of(design), strict=True):
        index = _index_for(style, entries)
        _disc(sub, sub_w, sub_h, place(point), max(1, round(_pen(style, radius) * scale)), index)

    block = scale * scale
    out = bytearray(width * height * 4)
    for oy in range(height):
        sub_row = oy * scale
        for ox in range(width):
            counts = [0] * len(entry_rgb)
            for sy in range(scale):
                base = (sub_row + sy) * sub_w + ox * scale
                for sx in range(scale):
                    counts[sub[base + sx]] += 1
            at4 = 4 * (oy * width + ox)
            if transparent:
                # Index 0 (the background) covers nothing; alpha is the share
                # of the block the strokes actually paint, and the color is
                # that ink alone -- straight alpha, background-free.
                red = green = blue = norm = 0.0
                for index, count in enumerate(counts):
                    if count == 0 or index == 0:
                        continue
                    fraction = count / block
                    if aa_level is not None:
                        fraction = round(fraction * aa_level) / aa_level
                    norm += fraction
                    r, g, b = entry_rgb[index]
                    red += r * fraction
                    green += g * fraction
                    blue += b * fraction
                if aa_level is not None:
                    norm = round(norm * aa_level) / aa_level
                if norm <= 0:
                    out[at4 : at4 + 4] = (0, 0, 0, 0)
                    continue
                out[at4] = round(red / norm)
                out[at4 + 1] = round(green / norm)
                out[at4 + 2] = round(blue / norm)
                out[at4 + 3] = round(norm * 255)
                continue
            red = green = blue = 0.0
            for index, count in enumerate(counts):
                if count == 0:
                    continue
                fraction = count / block
                if aa_level is not None:
                    fraction = round(fraction * aa_level) / aa_level
                r, g, b = entry_rgb[index]
                red += r * fraction
                green += g * fraction
                blue += b * fraction
            out[at4 : at4 + 3] = (round(red), round(green), round(blue))
            out[at4 + 3] = 255
    return Raster(width, height, bytes(out), mode="rgba")

quantize

quantize(frames: Sequence[Raster], *, seeds: Sequence[str] = (), max_colors: int = 256, dither: bool = True, transparent: bool = False) -> tuple[Raster, ...]

Shrink RGBA frames to one shared indexed palette of at most max_colors.

The palette is built once across every frame -- so an animation does not flicker as the index a color means changes -- and is seeded with the colors given in seeds first, background first, keeping them exact. Any remaining color budget is filled from the blends the antialiasing made, cut down with median-cut when there are more of them than budget. A seed color is never dropped: asking for more seeds than max_colors raises instead.

With transparent set, index 0 is reserved for empty space: a pixel whose alpha lies below the half point maps there instead of to a color, and the transparent slot does not count against the color budget.

Parameters:

Name Type Description Default
frames sequence of Raster

"rgba" frames, all the same size, to share one palette.

required
seeds sequence of str

colors that must survive exactly, background first, as #rrggbb or a name. These lead the palette, in order. The first -- the background -- is the reserved transparent slot when transparent is set.

()
max_colors int

The largest palette the output may hold. Must be >= 1.

256
dither bool

Whether to error-diffuse (Floyd-Steinberg) the rounding of each pixel onto its neighbours, which keeps antialiased gradients smooth within a small palette. On by default, since indexed output is where the color budget bites.

True
transparent bool

Reserve index 0 for empty pixels and drop the background from the color budget. Off by default.

False

Returns:

Type Description
tuple of Raster

One indexed :class:Raster per input frame, all sharing one palette.

Source code in src/geomotif/io/raster.py
def quantize(
    frames: Sequence[Raster],
    *,
    seeds: Sequence[str] = (),
    max_colors: int = 256,
    dither: bool = True,
    transparent: bool = False,
) -> tuple[Raster, ...]:
    """Shrink RGBA frames to one shared indexed palette of at most ``max_colors``.

    The palette is built once across every frame -- so an animation does not
    flicker as the index a color means changes -- and is **seeded** with the
    colors given in ``seeds`` first, background first, keeping them exact. Any
    remaining color budget is filled from the blends the antialiasing made,
    cut down with median-cut when there are more of them than budget. A seed
    color is never dropped: asking for more seeds than ``max_colors`` raises
    instead.

    With ``transparent`` set, index 0 is reserved for empty space: a pixel
    whose alpha lies below the half point maps there instead of to a color,
    and the transparent slot does not count against the color budget.

    Parameters
    ----------
    frames : sequence of Raster
        ``"rgba"`` frames, all the same size, to share one palette.
    seeds : sequence of str
        colors that must survive exactly, background first, as ``#rrggbb`` or
        a name. These lead the palette, in order. The first -- the background
        -- is the reserved transparent slot when ``transparent`` is set.
    max_colors : int
        The largest palette the output may hold. Must be >= 1.
    dither : bool
        Whether to error-diffuse (Floyd-Steinberg) the rounding of each pixel
        onto its neighbours, which keeps antialiased gradients smooth within
        a small palette. On by default, since indexed output is where the
        color budget bites.
    transparent : bool
        Reserve index 0 for empty pixels and drop the background from the
        color budget. Off by default.

    Returns
    -------
    tuple of Raster
        One indexed :class:`Raster` per input frame, all sharing one
        ``palette``.
    """
    if not frames:
        raise ValueError("cannot quantize no frames")
    if max_colors < 1:
        raise ValueError(f"max_colors must be >= 1, got {max_colors}")
    for frame in frames:
        if frame.mode != "rgba":
            raise ValueError(
                f"quantize needs 'rgba' frames, got {frame.mode!r}; render with rasterize_rgba"
            )
        if (frame.width, frame.height) != (frames[0].width, frames[0].height):
            raise ValueError("all frames must share one size to share one palette")

    seed_rgb = []
    for seed in seeds:
        parsed = _rgb(seed)
        if parsed not in seed_rgb:
            seed_rgb.append(parsed)

    if transparent:
        if not seed_rgb:
            raise ValueError("a transparent palette needs a background seed to reserve")
        background_rgb = seed_rgb[0]
        color_seeds = seed_rgb[1:]
        budget = max_colors - 1
        if len(color_seeds) > budget:
            raise ValueError(
                f"a palette of {max_colors} colors cannot hold the "
                f"{len(color_seeds) + 1} colors requested (index 0 is transparent); "
                f"restyle the design onto fewer, or raise the budget"
            )
    else:
        background_rgb = seed_rgb[0] if seed_rgb else (255, 255, 255)
        color_seeds = seed_rgb
        budget = max_colors
        if len(color_seeds) > budget:
            raise ValueError(
                f"a palette of {max_colors} colors cannot hold the {len(color_seeds)} "
                f"colors requested; restyle the design onto fewer, or raise the budget"
            )

    counts = Counter[tuple[int, int, int]]()
    for frame in frames:
        buffer = frame.pixels
        for at in range(0, len(buffer), 4):
            if transparent and buffer[at + 3] < _EMPTY_ALPHA:
                continue
            source = (buffer[at], buffer[at + 1], buffer[at + 2])
            if source not in color_seeds:
                counts[source] += 1

    palette = [background_rgb] if transparent else []
    palette.extend(color_seeds)
    pairs = sorted(counts.items(), key=lambda item: (-item[1], item[0]))
    room = budget - len(color_seeds)
    if room <= 0:
        tail: list[tuple[int, int, int]] = []
    elif len(pairs) <= room:
        tail = [color for color, _ in pairs]
    else:
        tail = _median_cut([color for color, _ in pairs], counts, room)
    palette.extend(tail)

    hex_palette = tuple(f"#{r:02x}{g:02x}{b:02x}" for r, g, b in palette)
    mapped = tuple(
        _map_to_palette(frame, palette, dither=dither, transparent=transparent) for frame in frames
    )
    return tuple(
        Raster(frame.width, frame.height, stride.pixels, hex_palette)
        for frame, stride in zip(frames, mapped, strict=True)
    )