aboutsummaryrefslogtreecommitdiff
path: root/tools/sim_screenshot.py
diff options
context:
space:
mode:
authorcirakg <cirakg@cirakg.xyz>2026-10-18 21:10:37 +0200
committercirakg <cirakg@cirakg.xyz>2026-10-18 21:10:37 +0200
commit2292bf700d28ade39cf2d64b45509e6db21034bc (patch)
treeef455320d39ebe51db236219dbbda2b148dcbe18 /tools/sim_screenshot.py
parent85f1ecabd7f58f306b2a042376209ac8cf49ab9b (diff)
test(tools): add screenshot, budget and product checks; enforce file headers
- sim_screenshot.py finds the device drawing in the simulator window, crops the screen and compares it with approved golden images. - check_budgets.py reads key=value DIAG/HARNESS lines (the simulator console escapes quotes, so JSON was dropped). - ciq_test_report.py accepts SDK 9.2.0's real summary line `PASSED (passed=N, failed=N, errors=N)`; a captured log is a fixture. - gen_products.py checks all manifest products are watches with API 5.0+ and at least 512 KB for apps. - lint_project.py rule HDR001: every file carries the Author/Summary header (JSON keys, PNG text chunks; generated package-lock exempt). - make_icon.py --all draws one icon per launcher size in use. - Golden screenshots for fr255s, venusq2, fr70 and fr965. - 44 tests, warnings as errors, ruff and mypy --strict clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'tools/sim_screenshot.py')
-rw-r--r--tools/sim_screenshot.py217
1 files changed, 217 insertions, 0 deletions
diff --git a/tools/sim_screenshot.py b/tools/sim_screenshot.py
new file mode 100644
index 0000000..81b41b6
--- /dev/null
+++ b/tools/sim_screenshot.py
@@ -0,0 +1,217 @@
+#!/usr/bin/env python3
+# Author: cirakg & claude ai
+# Summary: Cut the watch screen out of a simulator window screenshot and compare it with the approved golden image.
+"""Cut the watch screen out of a Connect IQ simulator screenshot.
+
+Usage:
+ sim_screenshot.py --raw RAW.png --device-dir DIR --out SCREEN.png
+ [--golden GOLDEN.png] [--approve]
+
+Steps:
+ 1. Find where the device drawing (DIR/device.png) sits in the window
+ screenshot. The window adds a menu bar and borders, so the offset is
+ searched for, comparing only the bezel: the display area is masked out,
+ because the app draws there.
+ 2. Crop the display rectangle from DIR/simulator.json (display.location) and
+ black out the corners of round displays, which show bezel, not screen.
+ 3. Fail if the screen is blank (the app drew nothing).
+ 4. With --golden, compare with the approved image: the check fails when more
+ than 0.5 % of the pixels differ by more than 24 levels in any channel. A
+ diff image (differences in red) is written next to --out.
+ 5. With --approve, save the screen as the new golden image instead.
+
+Exit: 0 ok, 1 differs from the golden image, 2 error, 3 no golden image yet.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+from PIL import Image, ImageChops, ImageDraw, ImageStat
+from PIL.PngImagePlugin import PngInfo
+
+EXIT_OK, EXIT_DIFFERENT, EXIT_ERROR, EXIT_NO_GOLDEN = 0, 1, 2, 3
+AUTHOR = "cirakg & claude ai"
+MAX_OFFSET_X, MAX_OFFSET_Y = 16, 64 # search range for the device drawing
+MIN_OVERLAP = 0.9 # the window may clip the drawing's edge, but not more than this
+MAX_BEZEL_DIFF = 6.0 # mean absolute difference per channel at the best offset
+PIXEL_TOLERANCE = 24 # per-channel difference that still counts as equal
+MAX_DIFF_FRACTION = 0.005 # share of pixels allowed to differ
+MIN_SCREEN_STDDEV = 2.0 # below this the screen counts as blank
+
+
+@dataclass(frozen=True)
+class Display:
+ """The watch screen inside the device drawing."""
+
+ x: int
+ y: int
+ width: int
+ height: int
+ shape: str # round, rectangle, semi-octagon, ...
+ image: str # file name of the device drawing, e.g. device.png or fr255s.png
+
+
+def open_image(path: Path) -> Image.Image:
+ """Load an image completely and close its file straight away (no open handles left behind)."""
+ with Image.open(path) as img:
+ img.load()
+ return img
+
+
+def load_display(device_dir: Path) -> Display:
+ """Display rectangle, shape and drawing file from the device's simulator.json."""
+ sim = json.loads((device_dir / "simulator.json").read_text(encoding="utf-8"))
+ loc = sim["display"]["location"]
+ return Display(
+ int(loc["x"]),
+ int(loc["y"]),
+ int(loc["width"]),
+ int(loc["height"]),
+ str(sim["display"]["shape"]),
+ str(sim.get("image", "device.png")),
+ )
+
+
+def bezel_only(device: Image.Image, display: Display) -> Image.Image:
+ """The device drawing with the display area made transparent."""
+ masked = device.convert("RGBA")
+ ImageDraw.Draw(masked).rectangle(
+ (display.x, display.y, display.x + display.width - 1, display.y + display.height - 1), fill=(0, 0, 0, 0)
+ )
+ return masked
+
+
+def find_offset(raw: Image.Image, bezel: Image.Image) -> tuple[int, int, float]:
+ """Best (dx, dy) placement of the bezel in the screenshot, and its mean difference.
+
+ When the window is exactly as wide as the drawing, the 1 px window border
+ pushes the drawing's last column out of the window, so only the part that
+ overlaps the screenshot is compared.
+ """
+ raw_rgba = raw.convert("RGBA")
+ best = (0, 0, float("inf"))
+ for dy in range(MAX_OFFSET_Y + 1):
+ for dx in range(MAX_OFFSET_X + 1):
+ width = min(bezel.width, raw.width - dx)
+ height = min(bezel.height, raw.height - dy)
+ if width < bezel.width * MIN_OVERLAP or height < bezel.height * MIN_OVERLAP:
+ continue
+ region = raw_rgba.crop((dx, dy, dx + width, dy + height))
+ expected = region.copy()
+ expected.alpha_composite(bezel.crop((0, 0, width, height)))
+ diff = ImageChops.difference(expected.convert("RGB"), region.convert("RGB"))
+ score = sum(ImageStat.Stat(diff).mean) / 3
+ if score < best[2]:
+ best = (dx, dy, score)
+ return best
+
+
+def crop_screen(raw: Image.Image, offset: tuple[int, int], display: Display) -> Image.Image:
+ """The display area, with the corners of round screens blacked out."""
+ dx, dy = offset
+ left, top = dx + display.x, dy + display.y
+ screen = raw.convert("RGB").crop((left, top, left + display.width, top + display.height))
+ if display.shape == "round":
+ mask = Image.new("L", screen.size, 0)
+ ImageDraw.Draw(mask).ellipse((0, 0, display.width - 1, display.height - 1), fill=255)
+ black = Image.new("RGB", screen.size, (0, 0, 0))
+ screen = Image.composite(screen, black, mask)
+ return screen
+
+
+def is_blank(screen: Image.Image) -> bool:
+ return max(ImageStat.Stat(screen).stddev) < MIN_SCREEN_STDDEV
+
+
+def compare(screen: Image.Image, golden: Image.Image) -> tuple[float, Image.Image]:
+ """Share of differing pixels and a diff image (differences in red)."""
+ if screen.size != golden.size:
+ return 1.0, Image.new("RGB", screen.size, (255, 0, 0))
+ diff = ImageChops.difference(screen.convert("RGB"), golden.convert("RGB"))
+ # A pixel differs when any channel differs by more than the tolerance.
+ channels = [band.point(lambda v: 255 if v > PIXEL_TOLERANCE else 0) for band in diff.split()]
+ differing = ImageChops.lighter(ImageChops.lighter(channels[0], channels[1]), channels[2])
+ count = sum(differing.histogram()[1:]) # every non-zero pixel
+ overlay = Image.composite(Image.new("RGB", screen.size, (255, 0, 0)), screen.convert("RGB"), differing)
+ return count / (screen.width * screen.height), overlay
+
+
+def save_png(img: Image.Image, path: Path, description: str) -> None:
+ info = PngInfo()
+ info.add_text("Author", AUTHOR)
+ info.add_text("Description", description)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ img.save(path, format="PNG", optimize=True, pnginfo=info)
+
+
+class ScreenshotError(Exception):
+ """A problem that makes the screenshot unusable; the message is shown to the user."""
+
+
+def extract_screen(raw_path: Path, device_dir: Path, out: Path) -> Image.Image:
+ """Find, crop, save and sanity-check the screen; raises ScreenshotError."""
+ name = device_dir.name
+ try:
+ raw = open_image(raw_path)
+ display = load_display(device_dir)
+ device = open_image(device_dir / display.image)
+ except (OSError, KeyError, ValueError) as err:
+ raise ScreenshotError(f"cannot read the screenshot or the device files: {err}") from err
+ dx, dy, score = find_offset(raw, bezel_only(device, display))
+ if score > MAX_BEZEL_DIFF:
+ raise ScreenshotError(f"device drawing not found in the screenshot (best difference {score:.1f})")
+ print(f"[{name}] device drawing found at window offset ({dx}, {dy}), bezel difference {score:.2f}")
+ screen = crop_screen(raw, (dx, dy), display)
+ save_png(screen, out, f"Simulator screen of {name}")
+ if is_blank(screen):
+ raise ScreenshotError("the screen is blank: the app drew nothing")
+ return screen
+
+
+def check_golden(screen: Image.Image, name: str, out: Path, golden: Path) -> int:
+ """Compare with the approved image; writes a diff image when they differ."""
+ if not golden.exists():
+ print(f"[{name}] no golden image yet: review {out}, then run 'make screenshots-approve'")
+ return EXIT_NO_GOLDEN
+ fraction, overlay = compare(screen, open_image(golden))
+ if fraction > MAX_DIFF_FRACTION:
+ diff_path = out.with_name(out.stem + "-diff.png")
+ save_png(overlay, diff_path, f"Differences from the golden image on {name}")
+ print(f"[{name}] differs from the golden image: {fraction:.2%} of pixels (see {diff_path})")
+ return EXIT_DIFFERENT
+ print(f"[{name}] matches the golden image ({fraction:.2%} of pixels differ)")
+ return EXIT_OK
+
+
+def main(argv: list[str] | None = None) -> int:
+ parser = argparse.ArgumentParser(description="Crop and check a simulator screenshot.")
+ parser.add_argument("--raw", type=Path, required=True, help="screenshot of the simulator window")
+ parser.add_argument("--device-dir", type=Path, required=True, help="the device's folder in ~/.Garmin")
+ parser.add_argument("--out", type=Path, required=True, help="where to write the cropped screen")
+ parser.add_argument("--golden", type=Path, help="approved image to compare with (or to write with --approve)")
+ parser.add_argument("--approve", action="store_true", help="save the screen as the new golden image")
+ args = parser.parse_args(argv)
+ name = args.device_dir.name
+
+ try:
+ screen = extract_screen(args.raw, args.device_dir, args.out)
+ except ScreenshotError as err:
+ print(f"[{name}] {err}")
+ return EXIT_ERROR
+ if args.golden is None:
+ print(f"[{name}] screen saved to {args.out}")
+ return EXIT_OK
+ if args.approve:
+ save_png(screen, args.golden, f"Approved simulator screen of {name} (golden image for make screenshots)")
+ print(f"[{name}] approved as {args.golden}")
+ return EXIT_OK
+ return check_golden(screen, name, args.out, args.golden)
+
+
+if __name__ == "__main__":
+ sys.exit(main())