Files
video-setup/tools/gen_font_atlas/gen_font_atlas.py
mikael-lovqvists-claude-agent 611376dbc1 feat: xorg text overlays, font atlas generator, v4l2_view_cli
- tools/gen_font_atlas: Python/Pillow build tool — skyline packs DejaVu
  Sans glyphs 32-255 into a grayscale atlas, emits build/gen/font_atlas.h
  with pixel data and Font_Glyph[256] metrics table
- xorg: bitmap font atlas text overlay rendering (GL_R8 atlas texture,
  alpha-blended glyph quads, dark background rect per overlay)
- xorg: add xorg_viewer_set_overlay_text / clear_overlays API
- xorg: add xorg_viewer_handle_events for streaming use (events only,
  no redundant render)
- xorg_cli: show today's date as white text overlay
- v4l2_view_cli: new tool — V4L2 capture with format auto-selection
  (highest FPS then largest resolution), MJPEG/YUYV, measured FPS overlay
- docs: update README, planning, architecture to reflect current status

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 22:13:59 +00:00

230 lines
8.4 KiB
Python

#!/usr/bin/env python3
"""
Generate a bitmap font atlas from a TrueType font for use in the xorg viewer.
Output:
--out-header C header with pixel data (uint8_t array) and glyph metrics
--out-png Optional PNG for visual inspection
The atlas texture is grayscale (1 byte/pixel, GL_R8). Each glyph's alpha
channel is stored directly — the renderer blends using this value.
Usage:
gen_font_atlas.py [--font PATH] [--size PT]
[--out-header PATH] [--out-png PATH]
"""
import sys
import os
import argparse
from PIL import Image, ImageFont, ImageDraw
GLYPH_FIRST = 32
GLYPH_LAST = 255
FONT_SEARCH_PATHS = [
'/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/dejavu/DejaVuSans.ttf',
'/usr/share/fonts/TTF/DejaVuSans.ttf',
'/usr/share/fonts/dejavu-sans-fonts/DejaVuSans.ttf',
'/usr/local/share/fonts/truetype/dejavu/DejaVuSans.ttf',
]
def find_font():
for p in FONT_SEARCH_PATHS:
if os.path.exists(p):
return p
return None
# ---------------------------------------------------------------------------
# Simple per-column skyline bin packer
# ---------------------------------------------------------------------------
class Skyline:
"""
Skyline bin packer using a per-column height array.
For each candidate x position, the placement y is max(sky[x:x+w]).
O(atlas_width * n_glyphs) — fast enough for small atlases.
"""
def __init__(self, width, height):
self.w = width
self.h = height
self.sky = [0] * width
def place(self, gw, gh):
"""Find best-fit position for a glyph of size (gw, gh). Returns (x, y) or None."""
best_y = None
best_x = None
for x in range(self.w - gw + 1):
y = max(self.sky[x:x + gw])
if y + gh > self.h:
continue
if best_y is None or y < best_y:
best_y = y
best_x = x
if best_x is None:
return None
for i in range(best_x, best_x + gw):
self.sky[i] = best_y + gh
return (best_x, best_y)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument('--font', default=None,
help='Path to TrueType font file (default: search for DejaVu Sans)')
ap.add_argument('--size', type=int, default=16,
help='Font size in points (default: 16)')
ap.add_argument('--out-header', default='font_atlas.h',
help='Output C header path (default: font_atlas.h)')
ap.add_argument('--out-png', default=None,
help='Optional output PNG for visual inspection')
args = ap.parse_args()
font_path = args.font or find_font()
if not font_path:
sys.exit('error: DejaVu Sans not found; pass --font PATH')
print(f'font: {font_path}', file=sys.stderr)
print(f'size: {args.size}pt', file=sys.stderr)
font = ImageFont.truetype(font_path, args.size)
# ------------------------------------------------------------------
# Collect glyph metrics and bitmaps
# ------------------------------------------------------------------
glyphs = {}
for cp in range(GLYPH_FIRST, GLYPH_LAST + 1):
ch = chr(cp)
# getbbox returns (left, top, right, bottom) in local glyph coords.
# top is negative for glyphs that extend above the origin.
bbox = font.getbbox(ch)
adv = int(font.getlength(ch) + 0.5)
if bbox is None:
glyphs[cp] = {'bitmap': None, 'w': 0, 'h': 0,
'advance': adv, 'bearing_x': 0, 'bearing_y': 0}
continue
l, t, r, b = bbox
gw = r - l
gh = b - t
if gw <= 0 or gh <= 0:
# Non-printing (e.g. space) — store advance only
glyphs[cp] = {'bitmap': None, 'w': 0, 'h': 0,
'advance': adv, 'bearing_x': int(l), 'bearing_y': int(-t)}
continue
img = Image.new('L', (gw, gh), 0)
draw = ImageDraw.Draw(img)
draw.text((-l, -t), ch, font=font, fill=255)
glyphs[cp] = {
'bitmap': img, 'w': gw, 'h': gh,
'advance': adv,
'bearing_x': int(l), # horizontal distance from pen to left edge
'bearing_y': int(-t), # ascent: pixels above baseline (positive)
}
renderable = [cp for cp, g in glyphs.items() if g['bitmap'] is not None]
print(f'glyphs: {len(renderable)} renderable, '
f'{len(glyphs) - len(renderable)} non-printing', file=sys.stderr)
total_area = sum(glyphs[cp]['w'] * glyphs[cp]['h'] for cp in renderable)
# ------------------------------------------------------------------
# Pack into atlas — try increasing sizes until everything fits
# ------------------------------------------------------------------
# Sort by descending height for better skyline packing
sorted_cps = sorted(renderable,
key=lambda cp: (-glyphs[cp]['h'], -glyphs[cp]['w']))
PAD = 1 # 1-pixel gap between glyphs to avoid bilinear bleed
placements = {}
atlas_w = atlas_h = 0
for aw, ah in [(256, 256), (512, 256), (512, 512), (1024, 512)]:
packer = Skyline(aw, ah)
places = {}
failed = False
for cp in sorted_cps:
g = glyphs[cp]
pos = packer.place(g['w'] + PAD, g['h'] + PAD)
if pos is None:
failed = True
break
places[cp] = pos
if not failed:
placements = places
atlas_w, atlas_h = aw, ah
util = 100 * total_area // (aw * ah)
print(f'atlas: {aw}x{ah} ({util}% utilisation)', file=sys.stderr)
break
else:
sys.exit('error: could not fit all glyphs into any supported atlas size')
# ------------------------------------------------------------------
# Render atlas image
# ------------------------------------------------------------------
atlas = Image.new('L', (atlas_w, atlas_h), 0)
for cp, (px, py) in placements.items():
atlas.paste(glyphs[cp]['bitmap'], (px, py))
if args.out_png:
os.makedirs(os.path.dirname(os.path.abspath(args.out_png)), exist_ok=True)
atlas.save(args.out_png)
print(f'png: {args.out_png}', file=sys.stderr)
# ------------------------------------------------------------------
# Write C header
# ------------------------------------------------------------------
os.makedirs(os.path.dirname(os.path.abspath(args.out_header)), exist_ok=True)
pixels = list(atlas.tobytes())
with open(args.out_header, 'w') as f:
f.write('/* Auto-generated by gen_font_atlas.py — do not edit. */\n')
f.write('#pragma once\n\n')
f.write('#include <stdint.h>\n\n')
f.write(f'#define FONT_ATLAS_W {atlas_w}\n')
f.write(f'#define FONT_ATLAS_H {atlas_h}\n\n')
f.write('typedef struct {\n')
f.write('\tint x, y; /* top-left in atlas */\n')
f.write('\tint w, h; /* bitmap size in pixels */\n')
f.write('\tint advance; /* horizontal pen advance */\n')
f.write('\tint bearing_x; /* left-side bearing */\n')
f.write('\tint bearing_y; /* ascent above baseline */\n')
f.write('} Font_Glyph;\n\n')
# Glyph table — indexed directly by codepoint 0..255
f.write('static const Font_Glyph font_glyphs[256] = {\n')
for cp in range(256):
if cp in glyphs:
g = glyphs[cp]
px, py = placements.get(cp, (0, 0))
f.write(f'\t[{cp}] = {{ {px}, {py}, {g["w"]}, {g["h"]}, '
f'{g["advance"]}, {g["bearing_x"]}, {g["bearing_y"]} }},\n')
else:
f.write(f'\t[{cp}] = {{ 0, 0, 0, 0, 0, 0, 0 }},\n')
f.write('};\n\n')
# Pixel data — grayscale, row-major
f.write(f'static const uint8_t font_atlas_pixels[{atlas_w * atlas_h}] = {{\n')
for i in range(0, len(pixels), 16):
chunk = pixels[i:i + 16]
f.write('\t' + ', '.join(str(b) for b in chunk) + ',\n')
f.write('};\n')
print(f'header: {args.out_header}', file=sys.stderr)
if __name__ == '__main__':
main()