"""
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
except ImportError:
_HAS_PIL = False
try:
from scipy.ndimage import distance_transform_edt as _edt
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).
"""
# ================================================================
# 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