From 2206b532e03af6ea8a6cf85c0a0855fcb95b2a52 Mon Sep 17 00:00:00 2001 From: Peter Corke Date: Sat, 3 Oct 2026 17:15:33 -0400 Subject: [PATCH] docs: add a script and README for the block icons The bdtex2icon commands that made visjac_p.png and estpose_p.png were only in an untracked icons.sh, and need bdsim, LaTeX and the (unpublished) rvc-notation macros. Add make_icons.py, which renders TeX math to the same 250x250 black-on-transparent RGBA icons with matplotlib mathtext, so no new dependency and no LaTeX install is needed, and a README describing the convention (shared with bdsim's Icons folder). The existing icon PNGs are not regenerated. Adds tests/test_make_icons.py. Its checks include that the border is transparent and ink covers a small fraction of the icon: an early version of the script produced an opaque white-backed square (math_to_image saves a figure with an opaque background) and a weaker test let it through. Co-Authored-By: Claude Sonnet 5.5 --- .../blocks/Icons/README.md | 28 +++++ .../blocks/Icons/make_icons.py | 111 ++++++++++++++++++ tests/test_make_icons.py | 76 ++++++++++++ 3 files changed, 215 insertions(+) create mode 100644 src/machinevisiontoolbox/blocks/Icons/README.md create mode 100644 src/machinevisiontoolbox/blocks/Icons/make_icons.py create mode 100644 tests/test_make_icons.py diff --git a/src/machinevisiontoolbox/blocks/Icons/README.md b/src/machinevisiontoolbox/blocks/Icons/README.md new file mode 100644 index 00000000..fffd997c --- /dev/null +++ b/src/machinevisiontoolbox/blocks/Icons/README.md @@ -0,0 +1,28 @@ +# Block icons + +Icons for the bdsim blocks in this toolbox. They follow the conventions of +[bdsim's `Icons` folder](https://github.com/petercorke/bdsim/blob/main/src/bdsim/blocks/Icons/README.md): +250x250 RGBA PNG, black "ink" on a transparent background, named as the +lower-case version of the block's class name. + +| Icon | How it is made | +|---|---| +| `visjac_p.png`, `estpose_p.png` | TeX math, rendered by `make_icons.py` (below). The files in git were made with `bdtex2icon` from bdsim, which needs LaTeX; `make_icons.py` makes the same glyphs, with slightly different size and placement. | +| `camera.png`, `imageplane.png` | not made by `make_icons.py` | + +## Creating icons from TeX + +``` +python make_icons.py # all icons in the table at the top of the script +python make_icons.py visjac_p # one icon +python make_icons.py --outdir /tmp # write somewhere else, e.g. to compare +``` + +This needs only matplotlib and Pillow, which this toolbox already depends on, +and no LaTeX installation. To add an icon, add a line to the `ICONS` table in +`make_icons.py`. The math is rendered by matplotlib's `mathtext`, which +implements a subset of TeX and does not know the custom macros of the RVC +notation file (`\mat`, `\pose`, ...), so write those expanded, for example +`\mathbf{J}_p` rather than `\mat{J}_p`. + +Running the script overwrites the icons of the same name in this folder. diff --git a/src/machinevisiontoolbox/blocks/Icons/make_icons.py b/src/machinevisiontoolbox/blocks/Icons/make_icons.py new file mode 100644 index 00000000..3f138c4b --- /dev/null +++ b/src/machinevisiontoolbox/blocks/Icons/make_icons.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +""" +Create block icons from TeX math, using only matplotlib and Pillow. + +Run it from anywhere to (re)create the icons listed in :data:`ICONS`:: + + $ python make_icons.py # all icons, written next to this script + $ python make_icons.py visjac_p # just one + $ python make_icons.py --outdir /tmp/icons # somewhere else + +Each icon is a 250x250 RGBA PNG: black "ink" on a transparent background, the +size and style used by the bdsim block icons (see the README in this folder). + +The math is rendered by matplotlib's ``mathtext`` engine with the Computer +Modern font set, so no LaTeX installation is needed. ``mathtext`` implements a +subset of TeX, and not the custom macros (``\\mat``, ``\\pose``, ...) from the +``rvc-notation`` file that ``bdtex2icon`` (part of bdsim) uses, so the entries +in :data:`ICONS` are written with those macros expanded. +""" + +import argparse +import io +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") + +import numpy as np +from matplotlib import mathtext, rc_context +from PIL import Image + +IMSIZE = 250 + +#: icon file stem -> TeX math (without the surrounding ``$``) +ICONS: dict[str, str] = { + "visjac_p": r"\mathbf{J}_p", + "estpose_p": r"\xi(p, P)", +} + + +def tex_icon( + tex: str, path: Path, size: int = IMSIZE, margin: float = 0.08, dpi: int = 600 +) -> None: + """Render TeX math to a square, transparent, black-ink PNG icon. + + :param tex: TeX math expression, without the surrounding ``$`` + :param path: output file + :param size: width and height of the icon in pixels, defaults to 250 + :param margin: blank border as a fraction of ``size``, on each side, + defaults to 0.08 + :param dpi: resolution of the intermediate rendering, defaults to 600 + + The expression is rendered, cropped to its ink, scaled to fit inside the + margin, and centered on a transparent canvas. + """ + buf = io.BytesIO() + with rc_context({"mathtext.fontset": "cm"}): # Computer Modern, as LaTeX + mathtext.math_to_image(f"${tex}$", buf, dpi=dpi, format="png", color="black") + + # math_to_image saves a figure, so it is black ink on an *opaque white* + # background. The ink is black, so how dark a pixel is gives its coverage, + # and that is all the icon needs: black everywhere, with this as the alpha. + # Working on one channel also avoids mixing colors into anti-aliased edges. + alpha = 255 - np.asarray(Image.open(buf).convert("L")) + ys, xs = np.nonzero(alpha) + ink = Image.fromarray(alpha[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]) + + scale = size * (1 - 2 * margin) / max(ink.size) + ink = ink.resize( + (max(1, round(ink.width * scale)), max(1, round(ink.height * scale))), + Image.LANCZOS, + ) + + canvas = Image.new("L", (size, size), 0) + canvas.paste(ink, ((size - ink.width) // 2, (size - ink.height) // 2)) + + icon = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + icon.putalpha(canvas) + icon.save(path) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__.strip().splitlines()[0]) + parser.add_argument( + "names", + nargs="*", + metavar="name", + help=f"icons to create, one or more of {', '.join(ICONS)}; default is all", + ) + parser.add_argument( + "--outdir", + type=Path, + default=Path(__file__).resolve().parent, + help="output directory, defaults to the directory containing this script", + ) + args = parser.parse_args() + + unknown = [n for n in args.names if n not in ICONS] + if unknown: + parser.error(f"unknown icon(s) {unknown}, choose from {list(ICONS)}") + + args.outdir.mkdir(parents=True, exist_ok=True) + for name in args.names or ICONS: + path = args.outdir / f"{name}.png" + tex_icon(ICONS[name], path) + print(f"Written: {path}") + + +if __name__ == "__main__": + main() diff --git a/tests/test_make_icons.py b/tests/test_make_icons.py new file mode 100644 index 00000000..a3004af0 --- /dev/null +++ b/tests/test_make_icons.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python + +import importlib.util +import tempfile +import unittest +from pathlib import Path + +import numpy as np +from PIL import Image + +SCRIPT = ( + Path(__file__).resolve().parent.parent + / "src" + / "machinevisiontoolbox" + / "blocks" + / "Icons" + / "make_icons.py" +) + + +def load_make_icons(): + # the Icons folder is a data folder, not a package, so load the script by path + spec = importlib.util.spec_from_file_location("make_icons", SCRIPT) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class TestMakeIcons(unittest.TestCase): + def test_icons_are_square_black_ink_on_transparent(self): + """Every icon is a 250x250 RGBA image of black ink, centered, with + a transparent border""" + make_icons = load_make_icons() + self.assertGreater(len(make_icons.ICONS), 0) + + with tempfile.TemporaryDirectory() as tmpdir: + for name, tex in make_icons.ICONS.items(): + path = Path(tmpdir) / f"{name}.png" + make_icons.tex_icon(tex, path) + + im = Image.open(path) + self.assertEqual(im.size, (250, 250), name) + self.assertEqual(im.mode, "RGBA", name) + + rgba = np.asarray(im) + ink = rgba[..., 3] > 0 + self.assertTrue(ink.any(), f"{name}: no ink") + # black ink, wherever there is any + self.assertEqual(rgba[ink][:, :3].max(), 0, name) + + # the ink fits inside the margin, and the border is transparent + ys, xs = np.nonzero(ink) + self.assertGreaterEqual(min(xs.min(), ys.min()), 10, name) + self.assertLessEqual(max(xs.max(), ys.max()), 239, name) + for edge in (rgba[:10], rgba[-10:], rgba[:, :10], rgba[:, -10:]): + self.assertEqual(edge[..., 3].max(), 0, f"{name}: border not clear") + + # glyphs are thin strokes: a solid square (e.g. an opaque + # background mistaken for ink) would cover most of the icon + coverage = ink.mean() + self.assertGreater(coverage, 0.01, f"{name}: almost empty") + self.assertLess(coverage, 0.40, f"{name}: ink covers {coverage:.0%}") + + # some pixels are partially transparent, i.e. anti-aliased + self.assertTrue(((rgba[..., 3] > 0) & (rgba[..., 3] < 255)).any(), name) + + def test_unknown_tex_is_an_error(self): + """A malformed expression must raise rather than write a blank icon""" + make_icons = load_make_icons() + with tempfile.TemporaryDirectory() as tmpdir: + with self.assertRaises(ValueError): + make_icons.tex_icon(r"\notacommand{x}", Path(tmpdir) / "bad.png") + + +if __name__ == "__main__": + unittest.main()