thr_preview.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267
  1. """Render polar ``.thr`` pattern files to cached PNG previews.
  2. The firmware serves no thumbnails - only the raw ``.thr`` files (lists of
  3. ``<theta_radians> <rho_0..1>`` points). Clients render previews locally. We
  4. fetch each ``.thr`` from ``/sd/patterns/...``, draw the polar path, and cache
  5. the PNG on disk so it renders instantly next time.
  6. Previews are cached under ``preview_cache/<table-slug>/`` keyed by the pattern
  7. path plus a hash of the file contents, so a changed pattern re-renders.
  8. """
  9. from __future__ import annotations
  10. import asyncio
  11. import hashlib
  12. import logging
  13. import math
  14. import re
  15. from pathlib import Path
  16. from urllib.parse import urlparse
  17. logger = logging.getLogger("DuneWeaver.Preview")
  18. try:
  19. from PIL import Image, ImageDraw
  20. except ImportError: # pragma: no cover
  21. Image = None
  22. ImageDraw = None
  23. CACHE_ROOT = Path(__file__).parent / "preview_cache"
  24. # The dune-weaver backend's pattern catalog, when co-located (the touch app
  25. # normally runs on the same Pi as the backend). Reading .thr files from here
  26. # is instant; fetching them from the board's SD over HTTP runs at ESP32 speed
  27. # (measured ~30-60 KB/s), so local files are always preferred.
  28. LOCAL_PATTERNS_DIR = Path(__file__).parent.parent / "patterns"
  29. # Same supersampling scheme as the web backend (modules/core/preview.py):
  30. # draw at RENDER_SIZE, LANCZOS-downsample to IMAGE_SIZE for smooth thin lines.
  31. IMAGE_SIZE = 512 # final PNG is IMAGE_SIZE x IMAGE_SIZE
  32. _RENDER_SIZE = 2048 # supersampled draw size
  33. _MARGIN = 12 # dish margin, in IMAGE_SIZE pixels
  34. _LINE_WIDTH = 8 # in RENDER_SIZE pixels -> 2px at IMAGE_SIZE (web parity)
  35. # Bump when the rendering itself changes (colors, orientation, size): the
  36. # version is part of the cache filename, so stale renders are simply ignored.
  37. _RENDER_VERSION = 4
  38. # One download at a time: the board's web server serializes requests, and a
  39. # single in-flight .thr transfer already delays /sand_status by seconds
  40. # (measured: 2 concurrent transfers starve the 1 Hz status poll almost
  41. # completely). Previews are cached after first fetch, so this only slows the
  42. # very first grid load.
  43. _semaphore = asyncio.Semaphore(1)
  44. # Rendering is CPU-heavy (2048px supersampled draw + LANCZOS downscale).
  45. # A fast flick through uncached patterns schedules a render per tile; without
  46. # a cap they saturate every core on the Pi and starve the GUI thread. Two at
  47. # a time keeps a core free for the UI and one for the warmer/board I/O.
  48. _cpu_semaphore = asyncio.Semaphore(2)
  49. def _slug(base_url: str) -> str:
  50. host = urlparse(base_url).netloc or base_url or "table"
  51. return re.sub(r"[^A-Za-z0-9_.-]", "_", host)
  52. def _cache_path(base_url: str, rel_path: str, content_hash: str) -> Path:
  53. safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
  54. return CACHE_ROOT / _slug(base_url) / f"{safe}.{content_hash}.v{_RENDER_VERSION}.png"
  55. _local_index: dict[str, Path] | None = None
  56. def _find_local_thr(rel_path: str) -> Path | None:
  57. """Map a board-relative pattern path to a file in the host catalog.
  58. The reverse of the backend's ``make_sd_path_resolver`` matching: exact
  59. relative path first, then a host path whose *suffix* equals the board
  60. path (host 'custom_patterns/sand-patterns/patterns/x.thr' matches board
  61. 'sand-patterns/patterns/x.thr'), then a UNIQUE basename match. Ambiguity
  62. or no match returns None and the caller falls back to fetching from the
  63. board.
  64. """
  65. global _local_index
  66. if _local_index is None:
  67. index: dict[str, Path] = {}
  68. try:
  69. for p in LOCAL_PATTERNS_DIR.rglob("*.thr"):
  70. index[p.relative_to(LOCAL_PATTERNS_DIR).as_posix()] = p
  71. except OSError:
  72. pass
  73. _local_index = index
  74. rel = rel_path.replace("\\", "/").lstrip("/")
  75. hit = _local_index.get(rel)
  76. if hit:
  77. return hit
  78. suffix_hits = [p for r, p in _local_index.items()
  79. if r == rel or r.endswith("/" + rel)]
  80. if len(suffix_hits) == 1:
  81. return suffix_hits[0]
  82. if suffix_hits:
  83. return None # ambiguous
  84. base = rel.rsplit("/", 1)[-1]
  85. base_hits = [p for r, p in _local_index.items()
  86. if r.rsplit("/", 1)[-1] == base]
  87. return base_hits[0] if len(base_hits) == 1 else None
  88. def _parse_thr(text: str) -> list[tuple[float, float]]:
  89. points = []
  90. for line in text.splitlines():
  91. line = line.strip()
  92. if not line or line.startswith("#"):
  93. continue
  94. parts = line.replace(",", " ").split()
  95. if len(parts) < 2:
  96. continue
  97. try:
  98. theta = float(parts[0])
  99. rho = float(parts[1])
  100. except ValueError:
  101. continue
  102. points.append((theta, rho))
  103. return points
  104. def _render_png(points: list[tuple[float, float]], out_path: Path) -> bool:
  105. if Image is None:
  106. return False
  107. size = _RENDER_SIZE
  108. scale = _RENDER_SIZE / IMAGE_SIZE
  109. margin = _MARGIN * scale
  110. img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
  111. draw = ImageDraw.Draw(img)
  112. center = size / 2
  113. radius = center - margin
  114. # Sand-table dish backdrop — warm basalt, matching the UI's night palette
  115. # (the disc reads as "a window onto the table" in both UI themes).
  116. draw.ellipse(
  117. [margin, margin, size - margin, size - margin],
  118. fill=(27, 23, 18, 255), outline=(62, 54, 44, 255), width=round(2 * scale),
  119. )
  120. xy = []
  121. # Same orientation as the web backend's previews (modules/core/preview.py:
  122. # CENTER - r*cos/sin then a 180° rotate, which nets to CENTER + r*cos/sin).
  123. for theta, rho in points:
  124. rho = max(0.0, min(1.0, rho))
  125. r = rho * radius
  126. xy.append((center + r * math.cos(theta), center + r * math.sin(theta)))
  127. # Sand under warm light rather than clinical white.
  128. line_color = (216, 181, 120, 255)
  129. if len(xy) >= 2:
  130. draw.line(xy, fill=line_color, width=_LINE_WIDTH, joint="curve")
  131. elif len(xy) == 1:
  132. x, y = xy[0]
  133. r = _LINE_WIDTH
  134. draw.ellipse([x - r, y - r, x + r, y + r], fill=line_color)
  135. img = img.resize((IMAGE_SIZE, IMAGE_SIZE), Image.Resampling.LANCZOS)
  136. out_path.parent.mkdir(parents=True, exist_ok=True)
  137. tmp = out_path.with_suffix(".tmp.png")
  138. img.save(tmp, "PNG")
  139. tmp.replace(out_path)
  140. return True
  141. def cached_preview(base_url: str, rel_path: str) -> str:
  142. """Return an existing cached preview for this pattern, or "" if none.
  143. Cheap, synchronous lookup for the list model's ``data()`` fast path. Because
  144. the cache filename embeds a content hash we don't know here, we glob for any
  145. cached render of this pattern (patterns rarely change on disk).
  146. """
  147. folder = CACHE_ROOT / _slug(base_url)
  148. safe = re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
  149. if not folder.exists():
  150. return ""
  151. matches = sorted(folder.glob(f"{safe}.*.v{_RENDER_VERSION}.png"))
  152. return str(matches[0].absolute()) if matches else ""
  153. def safe_name(rel_path: str) -> str:
  154. """The sanitized form of a pattern path used in cache filenames."""
  155. return re.sub(r"[^A-Za-z0-9_.-]", "_", rel_path)
  156. def has_local_source(rel_path: str) -> bool:
  157. """True when the pattern's .thr resolves in the co-located host catalog.
  158. Used by the background cache warmer to render only patterns that cost
  159. no board I/O — board-only patterns stay lazy (rendered when viewed).
  160. """
  161. return _find_local_thr(rel_path) is not None
  162. def preview_index(base_url: str) -> dict[str, str]:
  163. """Map ``safe_name(rel_path)`` -> cached PNG path, in ONE directory scan.
  164. ``cached_preview`` globs the cache folder per pattern — fine for a lookup
  165. or two, quadratic when a 1000-pattern grid asks row by row (and the model
  166. asks from the GUI thread). Callers scan once per catalogue refresh and
  167. hand rows out of the dict instead.
  168. """
  169. folder = CACHE_ROOT / _slug(base_url)
  170. suffix = f".v{_RENDER_VERSION}.png"
  171. index: dict[str, str] = {}
  172. if not folder.exists():
  173. return index
  174. try:
  175. for p in sorted(folder.iterdir()):
  176. name = p.name
  177. if not name.endswith(suffix):
  178. continue
  179. # Filename shape: <safe>.<contenthash>.v<N>.png
  180. parts = name.rsplit(".", 3)
  181. if len(parts) != 4:
  182. continue
  183. index.setdefault(parts[0], str(p.absolute()))
  184. except OSError:
  185. pass
  186. return index
  187. async def render_preview(client, base_url: str, rel_path: str) -> str | None:
  188. """Fetch ``rel_path`` from the table and render its preview PNG.
  189. ``client`` is a :class:`firmware_client.FirmwareClient`. Returns the
  190. absolute path to the cached PNG, "" when the pattern genuinely has no
  191. renderable preview (empty/unparseable), or ``None`` on a *transient*
  192. failure (fetch timeout, board busy) that is worth retrying later —
  193. callers must not cache ``None`` as a permanent miss.
  194. """
  195. if Image is None:
  196. logger.warning("Pillow not available - cannot render previews")
  197. return ""
  198. # Local catalog first: instant, no board I/O, no semaphore needed.
  199. local = _find_local_thr(rel_path)
  200. if local is not None:
  201. try:
  202. data = await asyncio.to_thread(local.read_bytes)
  203. except OSError as exc:
  204. logger.debug(f"local pattern read failed for {rel_path}: {exc}")
  205. local = None
  206. if local is None:
  207. async with _semaphore:
  208. try:
  209. data = await client.fetch_sd_file(f"/patterns/{rel_path}")
  210. except Exception as exc:
  211. logger.debug(f"preview fetch failed for {rel_path}: {exc}")
  212. return None
  213. content_hash = hashlib.sha1(data).hexdigest()[:10]
  214. out_path = _cache_path(base_url, rel_path, content_hash)
  215. if out_path.exists():
  216. return str(out_path.absolute())
  217. try:
  218. text = data.decode("utf-8", errors="ignore")
  219. points = _parse_thr(text)
  220. if not points:
  221. return ""
  222. # Rendering is CPU-bound; keep it off the event loop and capped.
  223. async with _cpu_semaphore:
  224. ok = await asyncio.to_thread(_render_png, points, out_path)
  225. return str(out_path.absolute()) if ok else ""
  226. except Exception as exc:
  227. logger.error(f"preview render failed for {rel_path}: {exc}")
  228. return ""