Source code for wolfhece.opengl.glyph_atlas

"""
SDF glyph atlas for GPU text rendering.

Renders TrueType glyphs at high resolution via PIL, computes a Signed
Distance Field (SDF), and packs them into a single-channel OpenGL texture
atlas.  The SDF allows **resolution-independent** rendering with clean
glow/outline effects at any zoom level.

Usage::

    atlas = GlyphAtlas.get("arial.ttf")   # cached singleton per font
    atlas.bind(0)                          # bind to texture unit 0
    m = atlas.get_metrics('A')             # query glyph layout info

Author: HECE - University of Liege, Pierre Archambeau
Date: 2026

Copyright (c) 2026 University of Liege. All rights reserved.
"""

import logging
import math
import numpy as np
from dataclasses import dataclass

try:
    from PIL import Image, ImageDraw, ImageFont
[docs] _HAS_PIL = True
except ImportError: _HAS_PIL = False try: from scipy.ndimage import distance_transform_edt as _edt
[docs] _HAS_SCIPY = True
except ImportError: _HAS_SCIPY = False # ================================================================ # Default character set # ================================================================
[docs] _DEFAULT_CHARSET: list[str] = ( [chr(c) for c in range(32, 127)] + list( "àáâãäåæçèéêëìíîïðñòóôõöùúûüýÿ" "ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖÙÚÛÜÝ" "°±²³µ·€" ) )
# ================================================================ # Glyph metrics # ================================================================ @dataclass
[docs] class GlyphMetrics: """Layout metrics for a single glyph in the SDF atlas. All spatial values are **normalised** so that 1.0 equals one *em* (i.e. the ``RENDER_SIZE`` used during atlas construction). Attributes: char: The character. uv_x: Left U coordinate in the atlas (0..1). uv_y: Top V coordinate in the atlas (0..1). uv_w: U extent of the cell. uv_h: V extent of the cell. offset_x: X offset from cursor to quad left edge (normalised). offset_y: Y offset from baseline to quad top in font coords (negative = above baseline). quad_w: Total quad width including SDF padding (normalised). quad_h: Total quad height including SDF padding (normalised). advance: Horizontal advance to the next character (normalised). """
[docs] char: str = ''
[docs] uv_x: float = 0.0
[docs] uv_y: float = 0.0
[docs] uv_w: float = 0.0
[docs] uv_h: float = 0.0
[docs] offset_x: float = 0.0
[docs] offset_y: float = 0.0
[docs] quad_w: float = 0.0
[docs] quad_h: float = 0.0
[docs] advance: float = 0.0
# ================================================================ # Glyph Atlas # ================================================================
[docs] class GlyphAtlas: """SDF glyph atlas backed by a single-channel OpenGL texture. Build once per font; the atlas is cached and reused for all rendering. The SDF encoding (0.5 = edge, >0.5 = inside, <0.5 = outside) makes it trivial to render glow, outlines, and anti-aliased text at any scale from a single texture. Parameters: font_name: TrueType font file name (resolved via :func:`wolfhece.textpillow.load_font`). charset: List of characters to include. Defaults to ASCII printable + common accented latin characters. """
[docs] RENDER_SIZE: int = 64 # Pixels-per-em for high-res rasterisation
[docs] SDF_SPREAD: float = 8.0 # Distance spread in render pixels
[docs] PAD: int = 12 # Pixel padding around each glyph cell
[docs] ATLAS_MAX_WIDTH: int = 1024 # Maximum atlas texture width
[docs] _cache: dict[str, "GlyphAtlas"] = {}
def __init__(self, font_name: str = "arial.ttf", charset: list[str] | None = None):
[docs] self.font_name = font_name
[docs] self._charset = charset or list(_DEFAULT_CHARSET)
[docs] self._metrics: dict[str, GlyphMetrics] = {}
[docs] self._texture_id: int | None = None
[docs] self._atlas_width: int = 0
[docs] self._atlas_height: int = 0
[docs] self._atlas_data: np.ndarray | None = None
[docs] self._line_height: float = 1.0
[docs] self._ascender: float = 0.8
[docs] self._descender: float = 0.2
self._build() # ---------------------------------------------------------------- # Factory / cache # ---------------------------------------------------------------- @classmethod
[docs] def get(cls, font_name: str = "arial.ttf") -> "GlyphAtlas": """Return a cached atlas for *font_name*, creating it if needed.""" key = font_name.lower() if key not in cls._cache: cls._cache[key] = cls(font_name) return cls._cache[key]
@classmethod
[docs] def clear_cache(cls): """Destroy all cached atlases and free GPU textures.""" for atlas in cls._cache.values(): atlas.destroy() cls._cache.clear()
# ---------------------------------------------------------------- # Atlas construction # ----------------------------------------------------------------
[docs] def _build(self): """Render all glyphs, compute SDF, shelf-pack into atlas.""" from wolfhece.textpillow import load_font font = load_font(self.font_name, self.RENDER_SIZE) rs = float(self.RENDER_SIZE) pad = self.PAD # --- Line metrics from reference characters --- try: _l, _t, _r, _b = font.getbbox("Hg") self._line_height = (_b - _t) / rs # PIL top can be positive (below anchor) or negative (above); # the ascender is the positive distance from anchor to top. self._ascender = abs(_t) / rs self._descender = abs(_b) / rs except Exception: self._line_height = 1.0 self._ascender = 0.8 self._descender = 0.2 # --- First pass: render glyphs and measure cell sizes --- # Tuple: (char, sdf|None, cell_w, cell_h, left, top, right, bottom, advance) glyph_cells: list[tuple] = [] for char in self._charset: try: left, top, right, bottom = font.getbbox(char) except Exception: continue gw = right - left gh = bottom - top try: advance = font.getlength(char) except Exception: advance = float(gw) if gw <= 0 or gh <= 0: glyph_cells.append((char, None, 0, 0, 0, 0, 0, 0, advance)) continue cell_w = gw + 2 * pad cell_h = gh + 2 * pad img = Image.new('L', (cell_w, cell_h), 0) draw = ImageDraw.Draw(img) draw.text((pad - left, pad - top), char, font=font, fill=255) bitmap = np.asarray(img, dtype=np.float32) / 255.0 sdf = self._compute_sdf(bitmap, self.SDF_SPREAD) glyph_cells.append((char, sdf, cell_w, cell_h, left, top, right, bottom, advance)) # --- Second pass: shelf-based packing --- max_w = self.ATLAS_MAX_WIDTH shelf_x = 0 shelf_y = 0 shelf_h = 0 positions: list[tuple[int, int]] = [] for _, sdf, cw, ch, *_ in glyph_cells: if sdf is None: positions.append((0, 0)) continue if shelf_x + cw > max_w: shelf_y += shelf_h shelf_x = 0 shelf_h = 0 positions.append((shelf_x, shelf_y)) shelf_x += cw shelf_h = max(shelf_h, ch) atlas_h = shelf_y + shelf_h atlas_h = max(1 << (atlas_h - 1).bit_length() if atlas_h > 1 else 1, 64) atlas_w = max(max_w, 64) atlas = np.zeros((atlas_h, atlas_w), dtype=np.float32) # --- Place glyphs and build metrics --- for i, (char, sdf, cw, ch, left, top, right, bottom, advance) in enumerate(glyph_cells): m = GlyphMetrics(char=char) m.advance = advance / rs if sdf is None: self._metrics[char] = m continue ax, ay = positions[i] atlas[ay:ay + ch, ax:ax + cw] = sdf m.uv_x = ax / atlas_w m.uv_y = ay / atlas_h m.uv_w = cw / atlas_w m.uv_h = ch / atlas_h m.offset_x = (left - pad) / rs m.offset_y = (top - pad) / rs m.quad_w = cw / rs m.quad_h = ch / rs self._metrics[char] = m self._atlas_width = atlas_w self._atlas_height = atlas_h self._atlas_data = atlas
@staticmethod
[docs] def _compute_sdf(bitmap: np.ndarray, spread: float) -> np.ndarray: """Compute SDF from a greyscale bitmap. Returns array in ``[0, 1]``: 0.5 = edge, >0.5 = inside, <0.5 = outside. Falls back to a simple ramp when *scipy* is not installed. """ if not _HAS_SCIPY: return np.clip(bitmap * 0.5 + 0.25, 0.0, 1.0) threshold = 0.5 inside = bitmap >= threshold outside = ~inside dist_outside = _edt(outside).astype(np.float32) dist_inside = _edt(inside).astype(np.float32) sdf = (dist_inside - dist_outside) / (spread * 2.0) + 0.5 return np.clip(sdf, 0.0, 1.0)
# ---------------------------------------------------------------- # OpenGL texture management # ----------------------------------------------------------------
[docs] def upload(self): """Upload atlas data as a single-channel ``GL_R8`` OpenGL texture.""" if self._texture_id is not None: return if self._atlas_data is None: return from OpenGL.GL import ( glGenTextures, glBindTexture, glTexImage2D, glTexParameteri, glGenerateMipmap, glPixelStorei, GL_TEXTURE_2D, GL_RED, GL_UNSIGNED_BYTE, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_TEXTURE_WRAP_S, GL_TEXTURE_WRAP_T, GL_LINEAR_MIPMAP_LINEAR, GL_LINEAR, GL_CLAMP_TO_EDGE, GL_UNPACK_ALIGNMENT, ) import ctypes as _ct tex_id = (_ct.c_uint * 1)() glGenTextures(1, tex_id) self._texture_id = int(tex_id[0]) glBindTexture(GL_TEXTURE_2D, self._texture_id) glPixelStorei(GL_UNPACK_ALIGNMENT, 1) data_u8 = (self._atlas_data * 255).astype(np.uint8) # GL_RED as internal format — the driver promotes it to GL_R8 glTexImage2D(GL_TEXTURE_2D, 0, GL_RED, self._atlas_width, self._atlas_height, 0, GL_RED, GL_UNSIGNED_BYTE, data_u8.tobytes()) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE) glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE) glGenerateMipmap(GL_TEXTURE_2D) glBindTexture(GL_TEXTURE_2D, 0)
[docs] def bind(self, unit: int = 0): """Bind atlas texture to the given texture unit.""" if self._texture_id is None: self.upload() from OpenGL.GL import glActiveTexture, glBindTexture, GL_TEXTURE0, GL_TEXTURE_2D glActiveTexture(GL_TEXTURE0 + unit) glBindTexture(GL_TEXTURE_2D, self._texture_id)
[docs] def destroy(self): """Free GPU texture memory.""" if self._texture_id is not None: from OpenGL.GL import glDeleteTextures glDeleteTextures(1, [self._texture_id]) self._texture_id = None
# ---------------------------------------------------------------- # Queries # ----------------------------------------------------------------
[docs] def get_metrics(self, char: str) -> GlyphMetrics | None: """Return metrics for *char*, or ``None`` if absent from the atlas.""" return self._metrics.get(char)
@property
[docs] def metrics(self) -> dict[str, GlyphMetrics]: """Full metrics dictionary (read-only view).""" return self._metrics
@property
[docs] def line_height(self) -> float: """Line height relative to one em.""" return self._line_height
@property
[docs] def ascender(self) -> float: """Ascender relative to one em.""" return self._ascender
@property
[docs] def descender(self) -> float: """Descender relative to one em.""" return self._descender
@property
[docs] def texture_id(self) -> int | None: return self._texture_id
@property
[docs] def atlas_size(self) -> tuple[int, int]: """``(width, height)`` of the atlas texture in pixels.""" return (self._atlas_width, self._atlas_height)
@property
[docs] def atlas_data(self) -> np.ndarray | None: """Raw SDF atlas as a float32 array (for testing).""" return self._atlas_data