Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/machinevisiontoolbox/blocks/Icons/README.md
Original file line number Diff line number Diff line change
@@ -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.
111 changes: 111 additions & 0 deletions src/machinevisiontoolbox/blocks/Icons/make_icons.py
Original file line number Diff line number Diff line change
@@ -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()
76 changes: 76 additions & 0 deletions tests/test_make_icons.py
Original file line number Diff line number Diff line change
@@ -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()
Loading