Source code for wolfhece.opengl.glyph_atlas

"""
MSDF glyph atlas for GPU text rendering.

Renders TrueType glyphs at high resolution via PIL, computes a
Multi-channel Signed Distance Field (MSDF), and packs them into a
3-channel (RGB) OpenGL texture atlas.  The MSDF preserves sharp corners
that a single-channel SDF rounds off, giving noticeably crisper text
at small pixel sizes.

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 MSDF 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: """MSDF glyph atlas backed by a 3-channel RGB OpenGL texture. Build once per font; the atlas is cached and reused for all rendering. Each channel encodes a signed distance field for a different subset of contour edges (grouped by normal direction). The fragment shader reconstructs sharp corners via ``median(r, g, b)``. Encoding: 0.5 = edge, >0.5 = inside, <0.5 = outside (per channel). 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 msdf = self._compute_msdf(bitmap, self.SDF_SPREAD) glyph_cells.append((char, msdf, 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 _, msdf, cw, ch, *_ in glyph_cells: if msdf 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, 3), dtype=np.float32) # --- Place glyphs and build metrics --- for i, (char, msdf, cw, ch, left, top, right, bottom, advance) in enumerate(glyph_cells): m = GlyphMetrics(char=char) m.advance = advance / rs if msdf is None: self._metrics[char] = m continue ax, ay = positions[i] atlas[ay:ay + ch, ax:ax + cw, :] = msdf 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 a single-channel 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. Kept as a building block for :meth:`_compute_msdf`. """ 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)
@staticmethod
[docs] def _compute_msdf(bitmap: np.ndarray, spread: float) -> np.ndarray: """Compute a 3-channel MSDF from a greyscale bitmap. Each RGB channel encodes the signed distance to edge pixels whose *gradient direction* (outward normal) falls in a different 120° sector. The fragment shader recovers sharp corners via ``median(r, g, b)``. Returns ``(H, W, 3)`` float32 array in ``[0, 1]``. Falls back to 3× duplicated SDF when *scipy* is not available. """ if not _HAS_SCIPY: ch = np.clip(bitmap * 0.5 + 0.25, 0.0, 1.0) return np.stack([ch, ch, ch], axis=-1) from scipy.ndimage import sobel as _sobel threshold = 0.5 inside = bitmap >= threshold outside = ~inside # Full SDF (used as fallback where no directional edges exist) dist_out_full = _edt(outside).astype(np.float32) dist_in_full = _edt(inside).astype(np.float32) sdf_full = np.clip( (dist_in_full - dist_out_full) / (spread * 2.0) + 0.5, 0.0, 1.0 ) # Gradient of bitmap → approximate edge normals gx = _sobel(bitmap, axis=1).astype(np.float32) gy = _sobel(bitmap, axis=0).astype(np.float32) angle = np.arctan2(gy, gx) # [-π, π] # Edge mask: pixels that are on the boundary edge = inside ^ _edt(outside, return_distances=False, return_indices=False) if False else None # Simpler approach: edges are where gradient magnitude > 0 mag = np.sqrt(gx * gx + gy * gy) is_edge = mag > 1e-6 # Split edges into three 120° sectors based on outward normal angle # Channel 0 (R): normals in [-60°, 60°) → right-facing # Channel 1 (G): normals in [60°, 180°) → upper-left # Channel 2 (B): normals in [-180°, -60°) → lower-left sector_boundaries = [ (-math.pi / 3, math.pi / 3), (math.pi / 3, math.pi), (-math.pi, -math.pi / 3), ] channels = np.empty((*bitmap.shape, 3), dtype=np.float32) for ch_idx, (lo, hi) in enumerate(sector_boundaries): if lo < hi: in_sector = is_edge & (angle >= lo) & (angle < hi) else: # Wrapping sector (channel 1 upper half) in_sector = is_edge & ((angle >= lo) | (angle < hi)) if not np.any(in_sector): channels[:, :, ch_idx] = sdf_full continue # Build a partial bitmap: keep only edges in this sector partial = bitmap.copy() # Zero out edge pixels not in this sector so they don't # contribute to this channel's distance field not_in_sector = is_edge & ~in_sector partial[not_in_sector] = 0.0 p_inside = partial >= threshold p_outside = ~p_inside d_out = _edt(p_outside).astype(np.float32) d_in = _edt(p_inside).astype(np.float32) ch_sdf = np.clip( (d_in - d_out) / (spread * 2.0) + 0.5, 0.0, 1.0 ) channels[:, :, ch_idx] = ch_sdf return channels
# ---------------------------------------------------------------- # OpenGL texture management # ----------------------------------------------------------------
[docs] def upload(self): """Upload atlas data as a 3-channel ``GL_RGB8`` 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_RGB, 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) if not data_u8.flags['C_CONTIGUOUS']: data_u8 = np.ascontiguousarray(data_u8) glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB, self._atlas_width, self._atlas_height, 0, GL_RGB, 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 MSDF atlas as a float32 array of shape ``(H, W, 3)`` (for testing).""" return self._atlas_data