diff --git a/CHANGELOG.md b/CHANGELOG.md index 533e940..88afc09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## Unreleased +- **Layers.** Several pictures can be loaded and arranged against each other + before anything is drawn, each with its own placement and its own tracer + settings. Hide one, reorder them, remove one; what is drawn is every visible + layer in order. +- **An eraser.** Drag across the live view with the eraser on and the strokes + under the cursor come out. Which strokes are near enough is decided after + placement, because the cursor is over a picture of the phone rather than over + the tracer's coordinate space. Re-tracing forgets erasures rather than + misapplying them: stroke five of one tracing is not stroke five of the next. +- **Flip, and buttons for the transforms that are not gestures.** `Flip`, + `Fit` and `Undo erase` sit under the phone; `Placement.mirrored` flips rather + than sets, so flipping twice is not flipping. +- **A screenshot taken by hand can stand in for capture.** When ADB capture + fails there is otherwise nothing to place a drawing against. A still picture + does not update, but its proportions are the ones that matter, and placement + now works with no phone attached at all - so a drawing can be arranged before + the cable is anywhere near. +- **Shapes and text from the command line**, with nothing to prepare: + `mthread shape heart`, `mthread shape star --points 7 --rotate 20`, + `mthread text "hello" --font arial.ttf`. Seven shapes, and text in any font + the machine has. + - **Place the drawing by hand.** Drag it over the live view, wheel to resize, Shift and wheel to turn, double-click to fit again. `Placement` holds the position in fractions of the screen and degrees, so it survives the phone diff --git a/README.md b/README.md index c42fae1..099b4c3 100644 --- a/README.md +++ b/README.md @@ -79,10 +79,29 @@ does not take. Windows SmartScreen says "unknown publisher" once — *More info* 1. **Turn on USB debugging** on the phone: Settings → About phone → tap *Build number* seven times → Developer options → *USB debugging*. 2. **Plug it in.** Or go wireless: `adb connect 192.168.1.42:5555`. -3. **Open MThread Draw** → *Connect device* → *Capture screen* → *Load image* → drag - it over the phone preview → **START DRAWING**. +3. **Open MThread Draw.** It finds the phone by itself, shows its screen live, + and lays the drawing over exactly where it will land. Drag to move it, wheel + to resize, Shift and wheel to turn — then **START DRAWING**. -No GUI, no clicking: +### What you can do to a drawing before it is drawn + +| | | +|---|---| +| **Move, resize, turn, flip** | Drag it over the live view; wheel resizes, Shift and wheel turns, and `Flip` mirrors it. `Fit` puts it back. | +| **Layers** | Load several pictures and arrange them against each other. Each keeps its own position and its own tracer settings; hide one, reorder them, remove one. | +| **Erase single strokes** | Turn the eraser on and drag across the lines you do not want. `Undo erase` brings them back. | +| **Change the tracing** | The *detail* slider re-traces what is already loaded. There is no need to open the file again. | +| **No live view?** | If capture fails, *Use a screenshot…* takes a picture you copied off the phone by hand. It does not update, but its proportions are what placement needs. | + +Nothing to install and nothing to draw with a mouse: + +```bash +mthread shape heart # a heart, fitted to the screen +mthread shape star --points 7 # a seven-pointed star, turned however you like +mthread text "hello" --y 0.35 # words, in any font this machine has +``` + +Or from code: ```python from mthread import Device @@ -211,14 +230,24 @@ This repository is two things: ## Record and replay gestures -Press record, do something on the phone, press stop. You get a JSON file with -every touch event and its timing. Replay it whenever you want, at whatever speed. +Press record, do something on the phone, press stop. You get a JSON file of +strokes and their timing, and you can replay it whenever you want, at whatever +speed. ```bash mthread record -o login.json # do the thing on the phone, press Enter mthread play login.json --speed 2 --repeat 5 ``` +**The file replays on a different phone.** It stores each touch as a fraction of +the screen rather than as a digitizer coordinate, so playing it back on another +device scales it to that screen. This was not true of the old format, and could +not be: a digitizer's range has little to do with any display, which is why +replaying one elsewhere used to be refused rather than attempted. Nor could the +old format be replayed on a *current* phone at all, since it went through +`/dev/input`, which every recent Pixel denies to the shell. Playback now goes +through the injector, like drawing. + Useful for regression passes, for reproducing a bug reliably, or for any repetitive tapping you would rather not do by hand. @@ -261,12 +290,26 @@ python tools/build_app.py --dmg # macOS ```bash mthread devices # what is attached mthread info # screen size and digitizer ranges + +mthread shape heart # draw a shape, nothing to prepare +mthread shape star --points 7 --rotate 20 +mthread shape circle --scale 0.4 --x 0.25 --y 0.3 +mthread text "hello world" # any font the machine has +mthread text "signed" --font arial.ttf --scale 0.5 --y 0.8 + mthread record -o session.json # record until Enter -mthread record -o session.json -d 30 # record for 30 seconds -mthread play session.json # replay once mthread play session.json --speed 0.5 --repeat 3 ``` +Every drawing command takes the same placement options: `--scale`, `--rotate`, +`--flip-x`, `--flip-y`, `--x`, `--y`, `--margin`, and `--speed`/`--human` for +how it is drawn. The shapes are `heart`, `star`, `circle`, `square`, `polygon`, +`spiral` and `wave`. + +Text is rendered with a real font and then traced, which is why any font on the +machine works and why the letters come out as outlines: a filled glyph is a +shape with an inside and an outside, and this draws with one finger. + ## Library ```python @@ -323,13 +366,18 @@ one of them, while leaving genuine closed shapes like circles intact. ## Known limits -- **Recordings are not portable between phones.** They contain raw digitizer - coordinates, so replaying a recording made on a different panel is refused - rather than silently misfiring. -- **Rotation is not handled.** Record and replay in the same orientation. -- **Some devices expose no touchscreen usable by `sendevent`.** Drawing then fails - with an explicit error instead of a wrong result; `Device.swipe()` still works, - and an automatic fallback is an open task. +- **Recordings are portable now**, holding fractions of a screen rather than + digitizer coordinates. A file written by an older version is not, and says so + rather than misfiring. +- **A recording does not know which way up the phone was.** Drawing follows the + orientation - the engine reads it from the captured frame, since `wm size` does + not change when a phone is turned - but a recording holds fractions of the + screen it was made on, so replaying a portrait one in landscape lands sideways. + Replay in the orientation you recorded in. +- **Replay timing carries a fixed overhead.** Starting and stopping the on-device + injector costs a second or two, so a two-second recording takes longer than two + seconds. The strokes and the gaps between them are faithful; the total is not, + and that matters if you are timing something. - **`mthread info` is the first thing to check** when touches land in the wrong place. @@ -343,13 +391,15 @@ are the easiest way in. Things worth doing: - SVG input, so line art skips edge detection entirely. - Auto-detect swapped X/Y axes (the `swap_xy` flag exists but nothing sets it). -- Rotation-aware coordinate mapping. -- An automatic `input swipe` fallback when raw touch is unavailable. +- Rotation in recordings: store which way up the phone was, and turn a replay to + match. Drawing already follows the orientation; replay does not. +- Take the fixed second or two out of replay by keeping the injector alive + between runs. - Trim recordings visually in the app; cut dead time at the start and end. - Assertions during replay — wait for a screenshot to match before continuing, which is what turns this into a real test runner. -- Skeletonise edges instead of halving contours, for cleaner line art. - Pressure-sensitive strokes from image darkness. +- Shed OpenCV. Five of its functions are used and it is half the download. --- @@ -412,8 +462,19 @@ explained in plain English and Russian in **[TERMS.md](TERMS.md)**. 1. **Включить отладку по USB**: Настройки → О телефоне → семь раз по «Номер сборки» → Для разработчиков → Отладка по USB. 2. **Подключить телефон.** Или по Wi-Fi: `adb connect 192.168.1.42:5555`. -3. **Открыть MThread Draw** → *Connect device* → *Capture screen* → *Load image* → - перетащить картинку на превью экрана → **START DRAWING**. +3. **Открыть MThread Draw.** Телефон находится сам, его экран показывается + живьём, а рисунок ложится поверх того места, куда он попадёт. Тащите мышью, + колесо меняет размер, Shift с колесом поворачивает — затем **START DRAWING**. + +Что можно сделать с рисунком до отрисовки: + +| | | +|---|---| +| **Двигать, размер, поворот, отражение** | Перетаскивание по живому экрану; колесо — размер, Shift с колесом — поворот, `Flip` — отражение, `Fit` — вписать заново. | +| **Слои** | Несколько картинок, расставленных друг относительно друга. У каждой своё положение и свои настройки трассировки; слой можно спрятать, переставить, удалить. | +| **Ластик** | Включите ластик и проведите по ненужным линиям. `Undo erase` возвращает их. | +| **Пересчёт** | Ползунок детализации перетрассирует уже загруженное. Открывать файл заново не нужно. | +| **Нет живого экрана?** | Если захват не работает, *Use a screenshot…* берёт снимок, снятый на телефоне вручную. Он не обновляется, но его пропорции — это то, что нужно для размещения. | @@ -507,16 +568,39 @@ python tools/fetch_platform_tools.py # ~7 МБ, прямо от Google ```bash mthread devices # какие устройства подключены mthread info # разрешение экрана и диапазоны тачскрина + +mthread shape heart # нарисовать фигуру, ничего не готовя +mthread shape star --points 7 --rotate 20 +mthread text "привет" # текст любым шрифтом, что есть в системе + mthread record -o session.json # запись до нажатия Enter mthread play session.json --speed 2 --repeat 5 ``` +У всех команд рисования одни и те же параметры размещения: `--scale`, +`--rotate`, `--flip-x`, `--flip-y`, `--x`, `--y`, `--margin`, а также +`--speed`/`--human` — как рисовать. Фигуры: `heart`, `star`, `circle`, +`square`, `polygon`, `spiral`, `wave`. + +Текст рисуется настоящим шрифтом и затем трассируется — поэтому доступен любой +шрифт системы, и поэтому буквы выходят контурами: залитая глифа это фигура с +внутренней и внешней границей, а здесь рисует один палец. + Если `adb` установлен в нестандартное место — укажите путь в переменной окружения `ADB_PATH`. ## Ограничения -- Записи **не переносятся между разными телефонами** — внутри сырые координаты +- Записи **переносятся между телефонами**: внутри доли экрана, а не сырые + координаты дигитайзера. Файлы старого формата не переносятся и честно об этом + сообщают, а не рисуют мимо. +- **Запись не знает, как был повёрнут телефон.** Рисование ориентацию учитывает — + движок читает её из снятого кадра, поскольку `wm size` при повороте не + меняется, — но запись хранит доли того экрана, на котором сделана, поэтому + портретная запись в горизонтальной ориентации ляжет набок. +- **У воспроизведения есть постоянная накладная стоимость** — секунда-две на + запуск и остановку инжектора. Штрихи и паузы между ними точны, а общая + длительность нет. Старые записи — сырые координаты тачскрина. Попытка воспроизвести запись на панели другого размера будет отклонена, а не выполнена криво. - Поворот экрана не учитывается: записывайте и воспроизводите в одной ориентации. diff --git a/examples/CREDITS.md b/examples/CREDITS.md index a6b0b80..02cb083 100644 --- a/examples/CREDITS.md +++ b/examples/CREDITS.md @@ -25,9 +25,3 @@ every line is real and none of them tells you what you are looking at. The three from Wikimedia Commons arrived there through its Unsplash import and carry the CC0 waiver on their file pages. The Met's photograph is part of that museum's Open Access release, also CC0. - -## castle.png - -Drawn from scratch by `tools/make_sample.py`. No third-party rights, and useful -as a test case precisely because it is synthetic: the tracer's output can be -compared against lines whose true positions are known. diff --git a/examples/castle.png b/examples/castle.png deleted file mode 100644 index ef37d7e..0000000 Binary files a/examples/castle.png and /dev/null differ diff --git a/mthread/cli.py b/mthread/cli.py index 9d84b6e..e633b02 100644 --- a/mthread/cli.py +++ b/mthread/cli.py @@ -11,6 +11,7 @@ from .errors import MThreadError from .player import replay from .recorder import Recorder +from .gestures import GestureSession from .session import Session @@ -63,31 +64,135 @@ def _cmd_record(args) -> int: input() except KeyboardInterrupt: print() - session = recorder.stop() - session.note = args.note or "" + raw = recorder.stop() if recorder.error: print(f"Recorder reported: {recorder.error}", file=sys.stderr) - if not session.events: + if not raw.events: print("Nothing was captured. Did the screen receive any touches?", file=sys.stderr) return 1 + + if args.raw: + # The kernel events as they arrived: faithful to the last detail, and + # replayable only on this phone, and only if it allows raw input. + raw.note = args.note or "" + raw.save(args.output) + print(f"Saved {len(raw.events)} raw events ({raw.duration:.1f}s) to {args.output}") + return 0 + + session = GestureSession.from_events( + raw.events, device.touch_device, + screen_size=device.screen_size, + device_model=device.model, + device_serial=device.serial, + note=args.note or "", + ) + if not session.strokes: + print("Events were captured but none of them were touches.", file=sys.stderr) + return 1 session.save(args.output) - print(f"Saved {len(session.events)} events ({session.duration:.1f}s) to {args.output}") + print(f"Saved {len(session.strokes)} strokes, {session.point_count} points " + f"({session.duration:.1f}s) to {args.output}") + print("Coordinates are fractions of the screen, so this replays on any phone.") return 0 def _cmd_play(args) -> int: device = Device(args.serial) - session = Session.load(args.input) - print(f"Replaying {len(session.events)} events ({session.duration:.1f}s) at {args.speed}x") + + try: + session = GestureSession.load(args.input) + except ValueError as reason: + # A version 1 file holds digitizer coordinates and replays through + # /dev/input, which most current phones refuse. Say so, and offer the + # old path rather than pretending the file is unreadable. + print(f"error: {reason}", file=sys.stderr) + print("Replay it as raw events with --raw if this is the phone it was " + "made on and that phone allows them.", file=sys.stderr) + if not args.raw: + return 2 + session = Session.load(args.input) + print(f"Replaying {len(session.events)} raw events at {args.speed}x") + replay(device, session, speed=args.speed, repeat=args.repeat) + print("Done.") + return 0 + + width, height = device.screen_size + where = f" recorded on {session.device_model}" if session.device_model else "" + print(f"Replaying {len(session.strokes)} strokes, {session.point_count} points " + f"({session.duration:.1f}s) at {args.speed}x{where}, scaled to {width}x{height}") def progress(done, total): - print(f"\r {done}/{total} events", end="", flush=True) + print(f"\r {done}/{total} strokes", end="", flush=True) - replay(device, session, speed=args.speed, repeat=args.repeat, progress=progress) - print("\nDone.") + played = device.play_gestures(session, speed=args.speed, repeat=args.repeat, + progress=progress) + print(f"\nDone, {played} strokes.") return 0 +def _placement_from(args): + """The placement options every drawing command shares.""" + from .placement import Placement + return Placement( + centre=(args.x, args.y), + scale=args.scale, + rotation=args.rotate, + flip_x=args.flip_x, + flip_y=args.flip_y, + ) + + +def _add_placement_options(command) -> None: + command.add_argument("--scale", type=float, default=1.0, + help="size, where 1.0 fills the screen with a margin") + command.add_argument("--rotate", type=float, default=0.0, metavar="DEGREES") + command.add_argument("--flip-x", action="store_true", help="mirror left to right") + command.add_argument("--flip-y", action="store_true", help="mirror top to bottom") + command.add_argument("--x", type=float, default=0.5, metavar="0..1", + help="where the middle lands across the screen") + command.add_argument("--y", type=float, default=0.5, metavar="0..1") + command.add_argument("--margin", type=float, default=0.06) + command.add_argument("--speed", type=float, default=1.0) + command.add_argument("--human", type=float, default=0.0, + help="0 draws mechanically; 1 to 3 like a hand") + + +def _draw_unit_paths(args, paths, what: str) -> int: + """Place paths given in a 0..1 square onto the screen, and draw them.""" + from .placement import place_on_screen + + device = Device(args.serial) + width, height = device.screen_size + placed = place_on_screen(paths, width, height, _placement_from(args), + margin=args.margin) + points = sum(len(path) for path in placed) + print(f"{what}: {len(placed)} strokes, {points} points on {width}x{height}") + + drawn = device.draw_paths(placed, speed=args.speed, human=args.human) + print(f"drawn {drawn} strokes") + return 0 + + +def _cmd_shape(args) -> int: + from .shapes import SHAPES + + maker = SHAPES[args.shape] + if args.shape == "star": + paths = maker(points=args.points) + elif args.shape == "polygon": + paths = maker(sides=args.points) + else: + paths = maker() + return _draw_unit_paths(args, paths, args.shape) + + +def _cmd_text(args) -> int: + from .shapes import text + + paths = text(args.words, font=args.font, size=args.size) + return _draw_unit_paths(args, paths, f"text {args.words!r}") + + def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(prog="mthread", description=__doc__) parser.add_argument("--version", action="version", version=f"mthread {__version__}") @@ -101,14 +206,37 @@ def build_parser() -> argparse.ArgumentParser: record.add_argument("-o", "--output", default="session.json") record.add_argument("-d", "--duration", type=float, help="stop automatically after N seconds") record.add_argument("--note", help="free-form description stored in the file") + record.add_argument("--raw", action="store_true", + help="store kernel events instead: exact, but replayable " + "only on this phone and only if it allows raw input") record.set_defaults(func=_cmd_record) play = sub.add_parser("play", help="replay a recorded file") play.add_argument("input") play.add_argument("--speed", type=float, default=1.0) play.add_argument("--repeat", type=int, default=1) + play.add_argument("--raw", action="store_true", + help="the file holds kernel events from an older version") play.set_defaults(func=_cmd_play) + # Drawing something without preparing a picture first. A heart is a + # parametric curve; there is no reason to make anyone find a JPEG of one. + from .shapes import SHAPES + shape = sub.add_parser("shape", help="draw a shape: " + ", ".join(SHAPES)) + shape.add_argument("shape", choices=sorted(SHAPES)) + shape.add_argument("--points", type=int, default=5, + help="points on a star, or sides on a polygon") + _add_placement_options(shape) + shape.set_defaults(func=_cmd_shape) + + words = sub.add_parser("text", help="draw text in any font on the machine") + words.add_argument("words") + words.add_argument("--font", help="a font file, or a name the system knows") + words.add_argument("--size", type=int, default=220, + help="pixels to render at before tracing; larger is smoother") + _add_placement_options(words) + words.set_defaults(func=_cmd_text) + return parser diff --git a/mthread/device.py b/mthread/device.py index 9bd2fab..7b9fd10 100644 --- a/mthread/device.py +++ b/mthread/device.py @@ -590,5 +590,22 @@ def estimate_duration(self, paths, *, method: str = "auto", speed: float = 1.0, # ----------------------------------------------------------------- streams def stream_getevent(self): - """Start ``getevent -t`` and return the live process for the recorder.""" - return popen_adb(self.adb_path, self._args(["shell", "getevent", "-t"])) + """Start ``getevent -t`` and return the live process for the recorder. + + The two ``-t`` before ``shell`` force a pty, and both are needed. Without + one, getevent's stdout on the device is a pipe, so its own libc buffering + holds output back in four-kilobyte blocks: a short recording ends + entirely inside that buffer and arrives only when the process exits, by + which time the recording has been stopped and looks empty. A minute of + touching works and five seconds of it does not, which is a memorable way + to conclude that recording is broken. + + One ``-t`` is not enough: adb refuses to allocate a pty when its own + stdin is not a terminal, which it never is for a subprocess, and says so + on stderr rather than failing. Two forces it, the way ``ssh -tt`` does. + + Note this is the opposite of what the screen mirror needs, which asks for + no pty because a pty would corrupt the JPEG it carries. + """ + return popen_adb(self.adb_path, + self._args(["shell", "-t", "-t", "getevent", "-t"])) diff --git a/mthread/placement.py b/mthread/placement.py new file mode 100644 index 0000000..35cc837 --- /dev/null +++ b/mthread/placement.py @@ -0,0 +1,115 @@ +"""Where a drawing goes on a screen, and how big, how tilted, which way round. + +Held in fractions of the screen and in degrees rather than in pixels, for three +reasons: it survives the phone being turned, it means the same thing on a +different device, and a scale of one can mean something recognisable - "as large +as it goes with a margin" - rather than a pixel count nobody can picture. + +This lives in the library rather than in the desktop app because placing a +drawing is part of drawing it. The command line places shapes and text with the +same code the window places a photograph with. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass, replace + +__all__ = ["Placement", "fit_to_screen", "place_on_screen"] + + +@dataclass(frozen=True) +class Placement: + """Attributes: + + centre: Where the middle of the drawing lands, 0 to 1 across the screen. + scale: Multiplier on the size it would have if fitted, so 1.0 is as large + as it goes with a margin. + rotation: Degrees clockwise. + flip_x: Mirror left to right. + flip_y: Mirror top to bottom. + """ + + centre: tuple[float, float] = (0.5, 0.5) + scale: float = 1.0 + rotation: float = 0.0 + flip_x: bool = False + flip_y: bool = False + + def moved(self, dx: float, dy: float) -> "Placement": + return replace(self, centre=(self.centre[0] + dx, self.centre[1] + dy)) + + def zoomed(self, factor: float, *, low: float = 0.05, high: float = 6.0) -> "Placement": + return replace(self, scale=max(low, min(high, self.scale * factor))) + + def turned(self, degrees: float) -> "Placement": + return replace(self, rotation=(self.rotation + degrees) % 360.0) + + def mirrored(self, *, horizontal: bool = False, vertical: bool = False) -> "Placement": + return replace(self, + flip_x=self.flip_x != horizontal, + flip_y=self.flip_y != vertical) + + +def place_on_screen(paths, width: int, height: int, + placement: Placement | None = None, *, margin: float = 0.06): + """Put *paths* on a screen of *width* x *height* as *placement* says. + + The base size is the one :func:`fit_to_screen` would choose, so a scale of + one means the same thing whatever the drawing and whatever the phone. + """ + placement = placement or Placement() + points = [point for path in paths for point in path] + if not points: + return [] + + min_x = min(x for x, _ in points) + max_x = max(x for x, _ in points) + min_y = min(y for _, y in points) + max_y = max(y for _, y in points) + source_width = max(max_x - min_x, 1e-9) + source_height = max(max_y - min_y, 1e-9) + source_centre = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) + + mirror_x = -1.0 if placement.flip_x else 1.0 + mirror_y = -1.0 if placement.flip_y else 1.0 + + # Rotation happens about the drawing's own middle, so turning it does not + # also throw it across the screen. + angle = math.radians(placement.rotation) + cos, sin = math.cos(angle), math.sin(angle) + + # Fit the rotated bounding box, not the upright one; a drawing turned + # forty-five degrees needs more room than it did before, and without this it + # would quietly grow past the edges of the screen. + turned_width = abs(source_width * cos) + abs(source_height * sin) + turned_height = abs(source_width * sin) + abs(source_height * cos) + + pad_x, pad_y = width * margin, height * margin + base = min((width - 2 * pad_x) / turned_width, (height - 2 * pad_y) / turned_height) + scale = base * placement.scale + + target_x = placement.centre[0] * width + target_y = placement.centre[1] * height + + placed = [] + for path in paths: + line = [] + for x, y in path: + ox = (x - source_centre[0]) * mirror_x + oy = (y - source_centre[1]) * mirror_y + rx = ox * cos - oy * sin + ry = ox * sin + oy * cos + line.append((round(target_x + rx * scale), round(target_y + ry * scale))) + placed.append(line) + return placed + + +def fit_to_screen(paths, width: int, height: int, *, margin: float = 0.06): + """Scale paths to fill the screen, keeping shape and a clear margin. + + The special case of :func:`place_on_screen` with nothing moved, kept as its + own name because callers that have no interest in placement read better + saying what they mean. + """ + return place_on_screen(paths, width, height, Placement(), margin=margin) diff --git a/mthread/recorder.py b/mthread/recorder.py index dbc6d25..d66609c 100644 --- a/mthread/recorder.py +++ b/mthread/recorder.py @@ -48,7 +48,8 @@ class Recorder: Args: device: A connected :class:`~mthread.device.Device`. only_devices: Restrict capture to these ``/dev/input`` paths. Defaults to - the detected touchscreen so stray button presses are not recorded. + all of them, the node Android calls the touchscreen not always being + the one touches arrive on. on_event: Optional callback invoked for every captured event, useful for driving a live counter in a UI. """ @@ -56,11 +57,14 @@ class Recorder: def __init__(self, device, only_devices=None, on_event: Callable[[InputEvent], None] | None = None): self.device = device self.on_event = on_event - if only_devices is None: - try: - only_devices = [device.touch_device.path] - except Exception: - only_devices = None + # Everything, unless a caller names the nodes it wants. + # + # This used to default to the touchscreen Android reports, which is + # wrong wherever that is not the node touches actually arrive on - an + # emulator has a dozen identical virtio multi-touch devices and uses one + # of the others, so recording produced a file with nothing in it and no + # error to explain why. The gesture decoder ignores everything that is + # not a touch, so the cost of listening to all of them is a few bytes. self.only_devices = set(only_devices) if only_devices else None self._process = None diff --git a/mthread/shapes.py b/mthread/shapes.py new file mode 100644 index 0000000..2b46484 --- /dev/null +++ b/mthread/shapes.py @@ -0,0 +1,191 @@ +"""Drawings that need no photograph: shapes from equations, text from a font. + + heart() a heart + star(points=5) a star with as many points as you like + text("hello") words, in whatever font the machine has + +The shapes are parametric curves evaluated straight into points, which is why +they come out smooth at any size and cost nothing to produce. + +Text is different, and deliberately so: rather than carry a stroke font around, +it renders the words with Pillow and hands the result to the ordinary tracer. +The advantage is that every font on the machine is available and the result +looks like that font; the cost is that letters come out as outlines, because +that is what a filled glyph is - a shape with an inside and an outside, and this +program draws with one finger. + +All of them return paths in a 0..1 square, so the caller places them on a screen +the same way it places a traced photograph. +""" + +from __future__ import annotations + +import math + +__all__ = ["SHAPES", "circle", "heart", "polygon", "spiral", "square", "star", "text", "wave"] + + +def _closed(points): + """Repeat the first point at the end, so a shape's outline joins up.""" + return list(points) + [points[0]] + + +def _fit(paths): + """Scale paths into the unit square, keeping their proportions.""" + everything = [point for path in paths for point in path] + min_x = min(x for x, _ in everything) + max_x = max(x for x, _ in everything) + min_y = min(y for _, y in everything) + max_y = max(y for _, y in everything) + span = max(max_x - min_x, max_y - min_y, 1e-9) + dx = (1.0 - (max_x - min_x) / span) / 2.0 + dy = (1.0 - (max_y - min_y) / span) / 2.0 + return [[((x - min_x) / span + dx, (y - min_y) / span + dy) for x, y in path] + for path in paths] + + +def heart(samples: int = 220): + """The usual parametric heart, upright and centred.""" + points = [] + for index in range(samples): + t = index / samples * 2 * math.pi + x = 16 * math.sin(t) ** 3 + # Negated because a screen's y runs downwards, and a heart that points + # the wrong way is not a heart. + y = -(13 * math.cos(t) - 5 * math.cos(2 * t) + - 2 * math.cos(3 * t) - math.cos(4 * t)) + points.append((x, y)) + return _fit([_closed(points)]) + + +def star(points: int = 5, inner: float = 0.42, samples_per_edge: int = 6): + """A star of *points* points; *inner* is how deep the notches cut.""" + if points < 2: + raise ValueError("a star needs at least two points") + corners = [] + for index in range(points * 2): + angle = index * math.pi / points - math.pi / 2 + radius = 1.0 if index % 2 == 0 else inner + corners.append((radius * math.cos(angle), radius * math.sin(angle))) + + # Corners alone would be enough for a straight-edged shape, but a few points + # along each edge keep the pacing even when the drawing is timed like a hand. + line = [] + for first, second in zip(corners, corners[1:] + corners[:1]): + for step in range(samples_per_edge): + fraction = step / samples_per_edge + line.append((first[0] + (second[0] - first[0]) * fraction, + first[1] + (second[1] - first[1]) * fraction)) + return _fit([_closed(line)]) + + +def circle(samples: int = 160): + return _fit([_closed([(math.cos(index / samples * 2 * math.pi), + math.sin(index / samples * 2 * math.pi)) + for index in range(samples)])]) + + +def square(): + return _fit([_closed([(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)])]) + + +def polygon(sides: int = 6): + if sides < 3: + raise ValueError("a polygon needs at least three sides") + return _fit([_closed([(math.cos(index / sides * 2 * math.pi - math.pi / 2), + math.sin(index / sides * 2 * math.pi - math.pi / 2)) + for index in range(sides)])]) + + +def spiral(turns: float = 3.0, samples: int = 400): + points = [] + for index in range(samples + 1): + fraction = index / samples + angle = fraction * turns * 2 * math.pi + radius = fraction + points.append((radius * math.cos(angle), radius * math.sin(angle))) + return _fit([points]) + + +def wave(cycles: float = 3.0, samples: int = 240): + # The x span is the number of cycles rather than one, so three cycles come + # out as a wave rather than as a narrow zigzag: _fit normalises by whichever + # side is longer, and with x fixed at one that side was always the height. + return _fit([[(index / samples * cycles, 0.35 * math.sin(index / samples * cycles * 2 * math.pi)) + for index in range(samples + 1)]]) + + +def text(words: str, *, font: str | None = None, size: int = 220, + sensitivity: float = 6.0, detail: float = 8.0): + """Trace *words* into paths, using a real font. + + Rendering and then tracing rather than carrying a stroke font: every font on + the machine becomes available, and the letters look like that font. Letters + arrive as outlines, an unavoidable consequence of a filled glyph being a + shape with an inside and an outside while this program draws with one finger. + + Args: + font: A font file, or a name Pillow can find. Falls back to whatever the + platform offers, and finally to Pillow's own bitmap font. + size: Pixel height to render at. Larger costs tracing time and buys + smoother letters; it does not change the final size, which the + caller chooses when placing the paths. + """ + from PIL import Image, ImageDraw, ImageFont + + from .vectorize import VectorizeSettings, Vectorizer + + if not words: + raise ValueError("there is nothing to draw") + + candidates = [font] if font else [] + candidates += ["arial.ttf", "segoeui.ttf", "DejaVuSans.ttf", "Helvetica.ttc"] + face = None + for name in candidates: + if not name: + continue + try: + face = ImageFont.truetype(name, size) + break + except OSError: + continue + if face is None: + face = ImageFont.load_default() + + # Measured before drawing, because a glyph may reach above and to the left + # of its own origin and would be clipped by a canvas sized after the fact. + scratch = ImageDraw.Draw(Image.new("L", (1, 1))) + left, top, right, bottom = scratch.textbbox((0, 0), words, font=face) + pad = max(8, size // 10) + canvas = Image.new("L", (right - left + pad * 2, bottom - top + pad * 2), 255) + ImageDraw.Draw(canvas).text((pad - left, pad - top), words, font=face, fill=0) + + settings = VectorizeSettings.from_sliders(sensitivity, detail, + target_width=None, method="canny") + vectorizer = Vectorizer() + vectorizer.load_array(_as_bgr(canvas)) + _, paths = vectorizer.process(settings) + if not paths: + raise ValueError("the text traced to nothing; try a larger size") + return _fit(paths) + + +def _as_bgr(grey): + """A greyscale PIL image as the BGR array the vectoriser expects.""" + import numpy as np + + array = np.asarray(grey.convert("RGB")) + return array[:, :, ::-1].copy() + + +#: The ones the command line offers by name, so `mthread shape --help` can list +#: them without the caller knowing the module. +SHAPES = { + "heart": heart, + "star": star, + "circle": circle, + "square": square, + "polygon": polygon, + "spiral": spiral, + "wave": wave, +} diff --git a/mthread_draw/geometry.py b/mthread_draw/geometry.py index 6ba2bde..8960bf6 100644 --- a/mthread_draw/geometry.py +++ b/mthread_draw/geometry.py @@ -5,10 +5,14 @@ from __future__ import annotations -import math -from dataclasses import dataclass, replace +from dataclasses import dataclass from typing import Sequence +# Placement moved into the library, placing a drawing being part of drawing +# it rather than part of any one interface. Re-exported so callers that +# already import it from here keep working. +from mthread.placement import Placement, fit_to_screen, place_on_screen + __all__ = ["CanvasView", "Placement", "fit_to_screen", "place_on_screen", "place_paths"] @@ -82,108 +86,3 @@ def place_paths( if len(points) >= 2: placed.append(points) return placed - - -@dataclass(frozen=True) -class Placement: - """Where a drawing goes on the screen, and how big and how tilted. - - Held in fractions of the screen rather than pixels so that it survives the - phone being turned, and so the same numbers mean the same thing on a - different device. - - Attributes: - centre: Where the middle of the drawing lands, 0 to 1 across the screen. - scale: Multiplier on the size it would have if fitted to the screen, so - 1.0 is "as large as it goes with a margin". - rotation: Degrees clockwise. - """ - - centre: tuple[float, float] = (0.5, 0.5) - scale: float = 1.0 - rotation: float = 0.0 - - def moved(self, dx: float, dy: float) -> "Placement": - return replace(self, centre=(self.centre[0] + dx, self.centre[1] + dy)) - - def zoomed(self, factor: float, *, low: float = 0.05, high: float = 6.0) -> "Placement": - return replace(self, scale=max(low, min(high, self.scale * factor))) - - def turned(self, degrees: float) -> "Placement": - return replace(self, rotation=(self.rotation + degrees) % 360.0) - - -def place_on_screen(paths, width: int, height: int, placement: Placement, - *, margin: float = 0.06): - """Put *paths* on a screen of *width* x *height* as *placement* says. - - The base size is the one :func:`fit_to_screen` would choose, so a scale of - one means the same thing whatever the drawing and whatever the phone, and - the slider a person turns is a multiplier on something recognisable. - """ - points = [point for path in paths for point in path] - if not points: - return [] - - min_x = min(x for x, _ in points) - max_x = max(x for x, _ in points) - min_y = min(y for _, y in points) - max_y = max(y for _, y in points) - source_width = max(max_x - min_x, 1) - source_height = max(max_y - min_y, 1) - source_centre = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) - - # Rotation happens about the drawing's own middle, so turning it does not - # also throw it across the screen. - angle = math.radians(placement.rotation) - cos, sin = math.cos(angle), math.sin(angle) - - # Fit the rotated bounding box, not the upright one; a drawing turned - # forty-five degrees needs more room than it did before, and without this it - # would quietly grow past the edges of the screen. - turned_width = abs(source_width * cos) + abs(source_height * sin) - turned_height = abs(source_width * sin) + abs(source_height * cos) - - pad_x, pad_y = width * margin, height * margin - base = min((width - 2 * pad_x) / turned_width, (height - 2 * pad_y) / turned_height) - scale = base * placement.scale - - target_x = placement.centre[0] * width - target_y = placement.centre[1] * height - - placed = [] - for path in paths: - line = [] - for x, y in path: - ox, oy = x - source_centre[0], y - source_centre[1] - rx = ox * cos - oy * sin - ry = ox * sin + oy * cos - line.append((round(target_x + rx * scale), round(target_y + ry * scale))) - placed.append(line) - return placed - - -def fit_to_screen(paths, width: int, height: int, *, margin: float = 0.06): - """Scale paths to fill the device screen, keeping shape and a clear margin. - - What the desktop app does by dragging, for callers that have no canvas to - drag on - the JSON engine, the command line, a test. - """ - points = [point for path in paths for point in path] - if not points: - return [] - - min_x = min(x for x, _ in points) - max_x = max(x for x, _ in points) - min_y = min(y for _, y in points) - max_y = max(y for _, y in points) - source_width = max(max_x - min_x, 1) - source_height = max(max_y - min_y, 1) - - pad_x, pad_y = width * margin, height * margin - scale = min((width - 2 * pad_x) / source_width, (height - 2 * pad_y) / source_height) - offset_x = pad_x + (width - 2 * pad_x - source_width * scale) / 2 - min_x * scale - offset_y = pad_y + (height - 2 * pad_y - source_height * scale) / 2 - min_y * scale - - return [[(round(x * scale + offset_x), round(y * scale + offset_y)) for x, y in path] - for path in paths] diff --git a/mthread_draw/server.py b/mthread_draw/server.py index 54e2536..d76d898 100644 --- a/mthread_draw/server.py +++ b/mthread_draw/server.py @@ -35,6 +35,7 @@ import threading import time import traceback +from dataclasses import dataclass, field from pathlib import Path from PIL import Image, ImageDraw @@ -52,6 +53,49 @@ OVERLAY_WIDTH = 540 +@dataclass +class Layer: + """One picture on the way to the screen, with its own settings and place. + + Several of these compose a drawing. Each keeps the paths it traced to, so + changing one does not re-trace the others, and its own placement, so they + can be arranged against each other before anything is drawn. + """ + + source: str + name: str + paths: list = field(default_factory=list) + #: Indices of strokes the eraser has taken out. Kept rather than deleted so + #: that re-tracing with different settings does not inherit erasures that + #: were aimed at strokes which no longer exist. + erased: set = field(default_factory=set) + placement: Placement = field(default_factory=Placement) + method: str = "canny" + sensitivity: float = 5.0 + detail: float = 7.0 + visible: bool = True + + @property + def kept(self) -> list: + return [path for index, path in enumerate(self.paths) if index not in self.erased] + + def summary(self) -> dict: + return { + "name": self.name, + "source": self.source, + "strokes": len(self.kept), + "erased": len(self.erased), + "points": sum(len(path) for path in self.kept), + "method": self.method, + "detail": self.detail, + "visible": self.visible, + "scale": round(self.placement.scale, 3), + "rotation": round(self.placement.rotation, 1), + "flip_x": self.placement.flip_x, + "flip_y": self.placement.flip_y, + } + + class Engine: """One device, one image, one drawing at a time.""" @@ -59,7 +103,12 @@ def __init__(self, write): self._write = write self.device: Device | None = None self.vectorizer = Vectorizer() - self.paths: list = [] + self.layers: list[Layer] = [] + self.current = 0 + #: Set when the automatic capture failed and a screenshot was supplied + #: by hand instead, so the front end has something to place against. + self.still: str | None = None + self.still_size: tuple[int, int] | None = None self.cancel = threading.Event() self.worker: threading.Thread | None = None self.drawing = threading.Event() @@ -69,9 +118,6 @@ def __init__(self, write): #: The size of the last frame captured, which is the only thing that #: knows which way up the phone is being held. self.frame_size: tuple[int, int] | None = None - #: Where the drawing sits. Starts fitted to the screen; the front end - #: moves, scales and turns it from there. - self.placement = Placement() self.recorder: Recorder | None = None self.session: GestureSession | None = None @@ -162,92 +208,225 @@ def _stop_mirror(self) -> None: self.mirror.close() self.mirror = self.mirror_thread = self.mirror_stop = None - def op_load_image(self, path: str) -> dict: - image = self.vectorizer.load_image(path) - return {"width": int(image.shape[1]), "height": int(image.shape[0])} + # ------------------------------------------------------------------ layers - def op_place(self, dx: float = 0.0, dy: float = 0.0, zoom: float = 1.0, - turn: float = 0.0, reset: bool = False) -> dict: - """Nudge the drawing about, and redraw the overlay that shows where. + @property + def layer(self) -> Layer: + if not self.layers: + raise MThreadError("Load an image first.") + self.current = max(0, min(self.current, len(self.layers) - 1)) + return self.layers[self.current] - Relative rather than absolute, because the front end is describing a - gesture - a drag of so far, a wheel notch - and relative keeps the two - ends from disagreeing about what the current position is. + def op_load_image(self, path: str, replace: bool = False) -> dict: + """Add a picture, or replace the one selected. + + Adding rather than replacing by default: several pictures arranged + against each other is the point of having layers at all, and a caller + that wants the old single-image behaviour asks for it. """ - if reset: - self.placement = Placement() + image = self.vectorizer.load_image(path) + layer = Layer(source=path, name=Path(path).name) + if replace and self.layers: + layer.placement = self.layer.placement + self.layers[self.current] = layer else: - if dx or dy: - self.placement = self.placement.moved(dx, dy) - if zoom and zoom != 1.0: - self.placement = self.placement.zoomed(zoom) - if turn: - self.placement = self.placement.turned(turn) + self.layers.append(layer) + self.current = len(self.layers) - 1 + self._trace(layer) return { + "width": int(image.shape[1]), + "height": int(image.shape[0]), + **self._layers_summary(), + } + + def op_layers(self) -> dict: + return self._layers_summary() + + def op_layer_select(self, index: int) -> dict: + if not 0 <= index < len(self.layers): + raise MThreadError(f"There is no layer {index}.") + self.current = index + return self._layers_summary() + + def op_layer_remove(self, index: int | None = None) -> dict: + if not self.layers: + raise MThreadError("There is nothing to remove.") + index = self.current if index is None else index + if not 0 <= index < len(self.layers): + raise MThreadError(f"There is no layer {index}.") + self.layers.pop(index) + self.current = min(self.current, max(0, len(self.layers) - 1)) + return self._layers_summary() + + def op_layer_visible(self, visible: bool, index: int | None = None) -> dict: + layer = self.layers[self.current if index is None else index] + layer.visible = bool(visible) + return self._layers_summary() + + def op_layer_raise(self, index: int | None = None) -> dict: + """Move a layer later in the order, so it is drawn on top.""" + index = self.current if index is None else index + if index + 1 < len(self.layers): + self.layers[index], self.layers[index + 1] = (self.layers[index + 1], + self.layers[index]) + if self.current == index: + self.current = index + 1 + return self._layers_summary() + + def _layers_summary(self) -> dict: + return { + "layers": [layer.summary() for layer in self.layers], + "current": self.current, "overlay": self._overlay(), - "centre": list(self.placement.centre), - "scale": round(self.placement.scale, 3), - "rotation": round(self.placement.rotation, 1), + "strokes": sum(len(layer.kept) for layer in self.layers if layer.visible), + "points": sum(len(path) for layer in self.layers if layer.visible + for path in layer.kept), } - def op_preview(self, sensitivity: float = 5, detail: float = 7, - method: str = "canny", margin: float = 0.06) -> dict: + # ----------------------------------------------------------------- tracing + + def _trace(self, layer: Layer) -> float: + """Re-run the tracer for one layer, from its own file and settings.""" started = time.perf_counter() - settings = VectorizeSettings.from_sliders(sensitivity, detail, method=method) + # The vectoriser holds one image, so a layer that is not the one it last + # loaded has to be read again. Reading a JPEG costs a few milliseconds; + # keeping a vectoriser per layer would cost a copy of every image. + self.vectorizer.load_image(layer.source) + settings = VectorizeSettings.from_sliders( + layer.sensitivity, layer.detail, method=layer.method) preview, paths = self.vectorizer.process(settings) - self.paths = paths + layer.paths = paths + # Erasures were aimed at strokes that may no longer exist. + layer.erased = set() path = Path(tempfile.gettempdir()) / "mthread_draw_preview.png" preview.save(path) + self._preview_path = str(path) + return time.perf_counter() - started + + def op_preview(self, sensitivity: float = 5, detail: float = 7, + method: str = "canny") -> dict: + """Re-trace the selected layer with new settings, without reloading it.""" + layer = self.layer + layer.sensitivity, layer.detail, layer.method = sensitivity, detail, method + seconds = self._trace(layer) return { - "path": str(path), - "overlay": self._overlay(), - "strokes": len(paths), - "points": sum(len(p) for p in paths), - "seconds": round(time.perf_counter() - started, 2), + "path": self._preview_path, + "seconds": round(seconds, 2), + **self._layers_summary(), } + # --------------------------------------------------------------- placement + + def op_place(self, dx: float = 0.0, dy: float = 0.0, zoom: float = 1.0, + turn: float = 0.0, flip_x: bool = False, flip_y: bool = False, + reset: bool = False) -> dict: + """Nudge the selected layer about, and redraw the overlay. + + Relative rather than absolute, because the front end is describing a + gesture - a drag of so far, a wheel notch - and relative keeps the two + ends from disagreeing about what the current position is. + """ + layer = self.layer + if reset: + layer.placement = Placement() + else: + if dx or dy: + layer.placement = layer.placement.moved(dx, dy) + if zoom and zoom != 1.0: + layer.placement = layer.placement.zoomed(zoom) + if turn: + layer.placement = layer.placement.turned(turn) + if flip_x or flip_y: + layer.placement = layer.placement.mirrored(horizontal=flip_x, + vertical=flip_y) + return self._layers_summary() + + # ------------------------------------------------------------------ eraser + + def op_erase(self, x: float = 0.0, y: float = 0.0, radius: float = 0.02, + undo: bool = False) -> dict: + """Take out the strokes of the selected layer near a point. + + The point and the radius are fractions of the screen, because that is + what the front end knows: it has a picture of the phone under the + cursor, not the tracer's coordinate space. Which stroke is near enough + is therefore decided after placement, not before it. + """ + layer = self.layer + if undo: + layer.erased = set() + return self._layers_summary() + + width, height = self.screen_now() + placed = place_on_screen(layer.paths, width, height, layer.placement) + reach = radius * max(width, height) + target_x, target_y = x * width, y * height + + for index, stroke in enumerate(placed): + if index in layer.erased: + continue + for px, py in stroke: + if (px - target_x) ** 2 + (py - target_y) ** 2 <= reach * reach: + layer.erased.add(index) + break + return self._layers_summary() + + # ---------------------------------------------------------------- overlays + def _overlay(self) -> str | None: - """The strokes where they will actually land, on a transparent ground. + """Every visible layer where it will land, on a transparent ground. A preview beside the phone tells you what will be drawn; a preview lying - on top of the phone tells you where. The second is the one people - actually want, and it costs nothing extra - the placement is the same - call the drawing itself makes. + on top of the phone tells you where. The selected layer is drawn bright + and the rest dim, so it is clear which one the mouse is about to move. """ - if self.device is None or not self.paths: + if not self.layers: return None width, height = self.screen_now() - placed = place_on_screen(self.paths, width, height, self.placement) - scale = OVERLAY_WIDTH / width image = Image.new("RGBA", (OVERLAY_WIDTH, round(height * scale)), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) - for stroke in placed: - if len(stroke) >= 2: - draw.line([(x * scale, y * scale) for x, y in stroke], - fill=(255, 64, 58, 235), width=2, joint="curve") + + for index, layer in enumerate(self.layers): + if not layer.visible or not layer.kept: + continue + colour = (255, 64, 58, 235) if index == self.current else (110, 190, 255, 150) + for stroke in place_on_screen(layer.kept, width, height, layer.placement): + if len(stroke) >= 2: + draw.line([(x * scale, y * scale) for x, y in stroke], + fill=colour, width=2, joint="curve") path = Path(tempfile.gettempdir()) / "mthread_draw_overlay.png" image.save(path) return str(path) + # ----------------------------------------------------------------- drawing + + def _all_placed(self) -> list: + """Every visible layer's strokes, in device pixels, in drawing order.""" + width, height = self.screen_now() + placed = [] + for layer in self.layers: + if layer.visible and layer.kept: + placed.extend(place_on_screen(layer.kept, width, height, layer.placement)) + return placed + def op_estimate(self, speed: float = 1.0, human: float = 0.0) -> dict: self._require_device() - if not self.paths: + placed = self._all_placed() + if not placed: return {"seconds": 0.0} - paths = simulate(self.paths, human, seed=0) if human > 0 else self.paths + paths = simulate(placed, human, seed=0) if human > 0 else placed return {"seconds": round(self.device.estimate_duration(paths, speed=speed), 1)} - def op_draw(self, speed: float = 1.0, human: float = 0.0, - margin: float = 0.06) -> dict: + def op_draw(self, speed: float = 1.0, human: float = 0.0) -> dict: self._require_device() - if not self.paths: - raise MThreadError("Load an image and take a preview first.") + placed = self._all_placed() + if not placed: + raise MThreadError("Load an image first.") - width, height = self.screen_now() - placed = place_on_screen(self.paths, width, height, self.placement) self.cancel.clear() self.drawing.set() self.status(f"Drawing {len(placed)} strokes") @@ -264,6 +443,7 @@ def op_draw(self, speed: float = 1.0, human: float = 0.0, self.drawing.clear() return {"strokes": drawn, "stopped": self.cancel.is_set()} + # ---------------------------------------------------- recording gestures def op_record_start(self) -> dict: @@ -335,6 +515,21 @@ def op_stop(self) -> dict: self.cancel.set() return {"stopping": True} + def op_still(self, path: str) -> dict: + """Use a screenshot taken by hand instead of one captured over ADB. + + Capture fails on some devices and in some emulators, and when it does + there is nothing to place a drawing against. A screenshot taken on the + phone and copied across is a picture of the same screen; it does not + update, but it is enough to arrange a drawing on, and its proportions + are the ones that matter for placement. + """ + with Image.open(path) as image: + self.still = path + self.still_size = image.size + return {"path": path, "width": self.still_size[0], "height": self.still_size[1], + **self._layers_summary()} + def screen_now(self) -> tuple[int, int]: """The screen as it is being held, not as it was manufactured. @@ -344,6 +539,11 @@ def screen_now(self) -> tuple[int, int]: captured frame is the one thing that knows: if it is the other way round from the natural size, so is the phone. """ + if self.device is None: + # No phone attached: a screenshot supplied by hand is the only thing + # that knows the shape of the screen, and failing that a common one + # keeps placement working so a drawing can be arranged in advance. + return self.still_size or (1080, 1920) width, height = self.device.screen_size if self.frame_size: frame_width, frame_height = self.frame_size @@ -357,8 +557,10 @@ def _require_device(self) -> None: #: Operations that may take a while, and so must not block the reader. -SLOW = {"preview", "draw", "screenshot", "connect", "mirror", "place", - "play", "record_stop", "session_open", "session_save"} +SLOW = {"preview", "draw", "screenshot", "connect", "mirror", "place", "erase", + "load_image", "still", "layer_select", "layer_remove", "layer_visible", + "layer_raise", "layers", "play", "record_stop", "session_open", + "session_save"} def serve(stdin=None, stdout=None) -> int: diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..1d84cc6 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,178 @@ +"""The engine's layers, eraser and placement, driven the way a front end does. + +No device is attached in any of this. Placement and erasing are decided against +a screen size, and the engine knows one whether or not a phone is plugged in - +which is also what makes a drawing arrangeable in advance, and what makes a +screenshot supplied by hand a workable substitute when capture fails. +""" + +import tempfile +import unittest +from pathlib import Path + +from mthread_draw.server import Engine + +#: Tiny drawings rather than the photographs in examples/. The pipeline being +#: exercised is the same one; tracing a real photograph a dozen times over cost +#: the suite most of a minute and told it nothing extra. +_FIXTURES = tempfile.TemporaryDirectory() + + +def _shape(name: str, kind: str) -> Path: + from PIL import Image, ImageDraw + + path = Path(_FIXTURES.name) / name + if not path.exists(): + image = Image.new("RGB", (200, 300), (255, 255, 255)) + draw = ImageDraw.Draw(image) + if kind == "bars": + for index in range(6): + draw.rectangle([(20 + index * 28, 40), (36 + index * 28, 260)], fill=(20, 20, 20)) + else: + for index in range(4): + draw.ellipse([(30 + index * 8, 40 + index * 8), + (170 - index * 8, 260 - index * 8)], outline=(20, 20, 20), width=6) + image.save(path) + return path + + +GUITAR = _shape("bars.png", "bars") +CAT = _shape("rings.png", "rings") + + +class LayerTests(unittest.TestCase): + + def setUp(self): + self.sent = [] + self.engine = Engine(self.sent.append) + + def load(self, image=GUITAR, **fields): + return self.engine.op_load_image(str(image), **fields) + + def test_loading_adds_a_layer_rather_than_replacing_one(self): + self.load(GUITAR) + result = self.load(CAT) + self.assertEqual(len(result["layers"]), 2) + self.assertEqual(result["current"], 1) + self.assertEqual(result["layers"][0]["name"], "bars.png") + self.assertEqual(result["layers"][1]["name"], "rings.png") + + def test_replacing_is_available_and_keeps_the_placement(self): + self.load(GUITAR) + self.engine.op_place(dx=0.2, zoom=0.5) + result = self.load(CAT, replace=True) + self.assertEqual(len(result["layers"]), 1) + self.assertAlmostEqual(result["layers"][0]["scale"], 0.5, places=3) + + def test_each_layer_keeps_its_own_placement(self): + self.load(GUITAR) + self.engine.op_place(zoom=0.25) + self.load(CAT) + self.engine.op_place(turn=90) + + layers = self.engine.op_layers()["layers"] + self.assertAlmostEqual(layers[0]["scale"], 0.25, places=3) + self.assertEqual(layers[0]["rotation"], 0.0) + self.assertAlmostEqual(layers[1]["scale"], 1.0, places=3) + self.assertEqual(layers[1]["rotation"], 90.0) + + def test_a_hidden_layer_is_not_drawn(self): + self.load(GUITAR) + self.load(CAT) + both = self.engine.op_layers()["strokes"] + self.engine.op_layer_visible(visible=False, index=0) + one = self.engine.op_layers()["strokes"] + self.assertLess(one, both) + self.assertEqual(len(self.engine._all_placed()), one) + + def test_removing_a_layer_keeps_the_selection_in_range(self): + self.load(GUITAR) + self.load(CAT) + result = self.engine.op_layer_remove() + self.assertEqual(len(result["layers"]), 1) + self.assertEqual(result["current"], 0) + + def test_raising_a_layer_changes_the_order_things_are_drawn_in(self): + self.load(GUITAR) + self.load(CAT) + self.engine.op_layer_select(index=0) + result = self.engine.op_layer_raise() + self.assertEqual([layer["name"] for layer in result["layers"]], + ["rings.png", "bars.png"]) + self.assertEqual(result["current"], 1) + + def test_selecting_a_layer_that_is_not_there_says_so(self): + self.load(GUITAR) + with self.assertRaises(Exception): + self.engine.op_layer_select(index=7) + + def test_placing_before_anything_is_loaded_says_what_to_do(self): + with self.assertRaises(Exception) as caught: + self.engine.op_place(dx=0.1) + self.assertIn("load an image", str(caught.exception).lower()) + + +class EraserTests(unittest.TestCase): + + def setUp(self): + self.engine = Engine(lambda message: None) + self.engine.op_load_image(str(GUITAR)) + + def centre_of_a_stroke(self): + """A point that is certainly on the drawing, in screen fractions.""" + from mthread.placement import place_on_screen + + width, height = self.engine.screen_now() + placed = place_on_screen(self.engine.layer.paths, width, height, + self.engine.layer.placement) + longest = max(placed, key=len) + x, y = longest[len(longest) // 2] + return x / width, y / height + + def test_erasing_removes_strokes_under_the_point(self): + before = self.engine.op_layers()["strokes"] + x, y = self.centre_of_a_stroke() + after = self.engine.op_erase(x=x, y=y, radius=0.03)["strokes"] + self.assertLess(after, before) + + def test_erasing_nowhere_near_anything_removes_nothing(self): + before = self.engine.op_layers()["strokes"] + after = self.engine.op_erase(x=0.0, y=0.0, radius=0.001)["strokes"] + self.assertEqual(after, before) + + def test_undo_brings_everything_back(self): + before = self.engine.op_layers()["strokes"] + x, y = self.centre_of_a_stroke() + self.engine.op_erase(x=x, y=y, radius=0.05) + self.assertLess(self.engine.op_layers()["strokes"], before) + self.assertEqual(self.engine.op_erase(undo=True)["strokes"], before) + + def test_erased_strokes_are_not_drawn(self): + x, y = self.centre_of_a_stroke() + kept = self.engine.op_erase(x=x, y=y, radius=0.05)["strokes"] + self.assertEqual(len(self.engine._all_placed()), kept) + + def test_re_tracing_forgets_erasures_rather_than_misapplying_them(self): + """Stroke five of one tracing is not stroke five of the next.""" + x, y = self.centre_of_a_stroke() + self.engine.op_erase(x=x, y=y, radius=0.05) + self.assertGreater(self.engine.layer.erased, set()) + self.engine.op_preview(sensitivity=5, detail=4, method="canny") + self.assertEqual(self.engine.layer.erased, set()) + + +class ReprocessTests(unittest.TestCase): + + def test_settings_change_without_the_file_being_loaded_again(self): + engine = Engine(lambda message: None) + engine.op_load_image(str(CAT)) + # Counted in points rather than strokes: detail sets how finely a line is + # followed, and on a simple shape the number of lines does not move. + coarse = engine.op_preview(sensitivity=5, detail=1, method="canny")["points"] + fine = engine.op_preview(sensitivity=5, detail=10, method="canny")["points"] + self.assertGreater(fine, coarse) + self.assertEqual(len(engine.layers), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_shapes.py b/tests/test_shapes.py new file mode 100644 index 0000000..7605b24 --- /dev/null +++ b/tests/test_shapes.py @@ -0,0 +1,131 @@ +"""Shapes from equations, text from a font, and where they land.""" + +import math +import unittest + +from mthread.placement import Placement, fit_to_screen, place_on_screen +from mthread.shapes import SHAPES, heart, star, text + + +def bounds(paths): + points = [point for path in paths for point in path] + return (min(x for x, _ in points), min(y for _, y in points), + max(x for x, _ in points), max(y for _, y in points)) + + +class ShapeTests(unittest.TestCase): + + def test_every_shape_fits_the_unit_square(self): + """Callers place them; they do not each invent their own coordinates.""" + for name, maker in SHAPES.items(): + with self.subTest(shape=name): + left, top, right, bottom = bounds(maker()) + self.assertGreaterEqual(left, -1e-9, name) + self.assertGreaterEqual(top, -1e-9, name) + self.assertLessEqual(right, 1 + 1e-9, name) + self.assertLessEqual(bottom, 1 + 1e-9, name) + + def test_every_shape_keeps_its_proportions(self): + """One side touches both edges; the other is centred, not stretched.""" + for name, maker in SHAPES.items(): + with self.subTest(shape=name): + left, top, right, bottom = bounds(maker()) + longer = max(right - left, bottom - top) + self.assertAlmostEqual(longer, 1.0, places=6, msg=name) + + def test_a_shape_closes(self): + outline = heart()[0] + self.assertEqual(outline[0], outline[-1]) + + def test_a_star_has_the_points_asked_for(self): + """Counted by how many times the radius reaches its maximum.""" + outline = star(points=7)[0][:-1] # the closing repeat is not a corner + centre = (0.5, 0.5) + radii = [math.dist(point, centre) for point in outline] + # Counted around the loop, because the first tip is at index zero and a + # plain sliding window never gets to consider it. + count = len(radii) + peaks = sum(1 for index in range(count) + if radii[index] > 0.45 + and radii[index] >= radii[index - 1] + and radii[index] >= radii[(index + 1) % count]) + self.assertEqual(peaks, 7) + + def test_a_star_needs_at_least_two_points(self): + with self.assertRaises(ValueError): + star(points=1) + + +class TextTests(unittest.TestCase): + + def test_it_traces_to_something(self): + paths = text("Hi", size=140) + self.assertGreater(len(paths), 0) + self.assertGreater(sum(len(path) for path in paths), 20) + left, top, right, bottom = bounds(paths) + self.assertAlmostEqual(max(right - left, bottom - top), 1.0, places=6) + + def test_wider_words_come_out_wider(self): + narrow = bounds(text("l", size=140)) + wide = bounds(text("mmmmmm", size=140)) + self.assertGreater(wide[2] - wide[0], narrow[2] - narrow[0]) + + def test_nothing_to_draw_says_so(self): + with self.assertRaises(ValueError): + text("") + + +class PlacementTests(unittest.TestCase): + + def setUp(self): + self.square = [[(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)]] + + def test_the_default_is_what_fitting_does(self): + self.assertEqual(place_on_screen(self.square, 1000, 2000), + fit_to_screen(self.square, 1000, 2000)) + + def test_half_scale_halves_it(self): + full = bounds(place_on_screen(self.square, 1000, 2000)) + half = bounds(place_on_screen(self.square, 1000, 2000, Placement(scale=0.5))) + self.assertAlmostEqual((half[2] - half[0]) / (full[2] - full[0]), 0.5, places=2) + + def test_a_turned_drawing_still_fits(self): + """The rotated box is what has to fit, not the upright one.""" + for degrees in (0, 30, 45, 60, 90, 135): + with self.subTest(degrees=degrees): + left, top, right, bottom = bounds( + place_on_screen(self.square, 1000, 2000, Placement(rotation=degrees))) + self.assertGreaterEqual(left, 55) # a 6% margin of 1000 + self.assertLessEqual(right, 945) + self.assertGreaterEqual(top, 115) + self.assertLessEqual(bottom, 1885) + + def test_flipping_mirrors_rather_than_moves(self): + plain = place_on_screen(self.square, 1000, 2000) + flipped = place_on_screen(self.square, 1000, 2000, Placement(flip_x=True)) + self.assertEqual(bounds(plain), bounds(flipped)) + self.assertEqual(flipped[0][0], plain[0][1]) + + def test_flipping_twice_is_not_flipping(self): + once = Placement().mirrored(horizontal=True) + twice = once.mirrored(horizontal=True) + self.assertTrue(once.flip_x) + self.assertFalse(twice.flip_x) + + def test_the_centre_is_where_the_middle_goes(self): + left, top, right, bottom = bounds( + place_on_screen(self.square, 1000, 2000, Placement(centre=(0.25, 0.75), scale=0.2))) + self.assertAlmostEqual((left + right) / 2, 250, delta=2) + self.assertAlmostEqual((top + bottom) / 2, 1500, delta=2) + + def test_zoom_and_turn_stay_within_their_limits(self): + self.assertEqual(Placement().zoomed(1000.0).scale, 6.0) + self.assertEqual(Placement().zoomed(0.0001).scale, 0.05) + self.assertEqual(Placement(rotation=350).turned(20).rotation, 10.0) + + def test_nothing_in_nothing_out(self): + self.assertEqual(place_on_screen([], 1000, 2000), []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/make_avd.py b/tools/make_avd.py new file mode 100644 index 0000000..08de8e9 --- /dev/null +++ b/tools/make_avd.py @@ -0,0 +1,132 @@ +"""Create an emulator without the SDK's package manager. + + python tools/make_avd.py --name mthread-test + +An AVD is two text files: an ini naming the folder, and a config.ini describing +the device and pointing at a system image. `avdmanager` writes them for you, and +on this machine it cannot: the current Android CLI crashes with a stack buffer +overrun, and the deprecated `sdkmanager` now forwards to it. Writing them +directly needs nothing but a system image on disk. + +It also means the emulator can be set up on a machine that has the image but not +the package metadata, which is the state Android Studio leaves behind when a +download is interrupted. + + python tools/make_avd.py --list # what images are on this machine +""" + +from __future__ import annotations + +import argparse +import os +from pathlib import Path + +#: Where an AVD lives, which is not inside the SDK. +AVD_HOME = Path(os.environ.get("ANDROID_AVD_HOME") + or Path.home() / ".android" / "avd") + + +def find_sdk() -> Path: + for name in ("ANDROID_HOME", "ANDROID_SDK_ROOT"): + value = os.environ.get(name) + if value and Path(value).is_dir(): + return Path(value) + for candidate in (Path.home() / "AppData" / "Local" / "Android" / "Sdk", + Path.home() / "Library" / "Android" / "sdk", + Path.home() / "Android" / "Sdk"): + if candidate.is_dir(): + return candidate + raise SystemExit("Could not find an Android SDK. Set ANDROID_HOME.") + + +def images(sdk: Path): + """Every system image with the files an emulator actually needs.""" + root = sdk / "system-images" + if not root.is_dir(): + return [] + found = [] + for path in sorted(root.glob("*/*/*")): + if (path / "system.img").is_file() or (path / "kernel-ranchu").is_file(): + api, tag, abi = path.parts[-3:] + found.append((api, tag, abi, path)) + return found + + +def config(image, abi: str, tag: str, width: int, height: int, density: int, + ram: int, storage: int, sdk: Path) -> str: + relative = image.relative_to(sdk).as_posix().replace("/", "\\") + "\\" + return "\n".join([ + "avd.ini.encoding=UTF-8", + f"abi.type={abi}", + f"hw.cpu.arch={'x86_64' if abi.endswith('x86_64') else 'arm64'}", + f"image.sysdir.1={relative}", + f"tag.id={tag}", + f"tag.display={tag}", + f"hw.lcd.width={width}", + f"hw.lcd.height={height}", + f"hw.lcd.density={density}", + f"hw.ramSize={ram}", + f"disk.dataPartition.size={storage}M", + "hw.keyboard=yes", + "hw.gpu.enabled=yes", + "hw.gpu.mode=auto", + "hw.audioInput=no", + "hw.audioOutput=no", + # No frame around the screen: the point of this emulator is that + # something else is looking at its pixels. + "showDeviceFrame=no", + "skin.dynamic=yes", + "hw.device.manufacturer=Google", + "hw.initialOrientation=Portrait", + "fastboot.forceColdBoot=no", + "", + ]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--name", default="mthread-test") + parser.add_argument("--list", action="store_true", help="show the images available") + parser.add_argument("--width", type=int, default=1080) + parser.add_argument("--height", type=int, default=2280) + parser.add_argument("--density", type=int, default=440) + parser.add_argument("--ram", type=int, default=2048, help="megabytes") + parser.add_argument("--storage", type=int, default=4096, help="megabytes of user data") + args = parser.parse_args() + + sdk = find_sdk() + available = images(sdk) + if args.list or not available: + print(f"sdk: {sdk}") + for api, tag, abi, path in available: + print(f" {api} {tag} {abi}") + if not available: + print(" no usable system image found - the folders exist but hold no system.img") + return 1 + return 0 + + api, tag, abi, image = available[-1] + folder = AVD_HOME / f"{args.name}.avd" + folder.mkdir(parents=True, exist_ok=True) + + (AVD_HOME / f"{args.name}.ini").write_text("\n".join([ + "avd.ini.encoding=UTF-8", + f"path={folder}", + f"path.rel=avd/{args.name}.avd", + f"target={api}", + "", + ]), encoding="utf-8") + + (folder / "config.ini").write_text( + config(image, abi, tag, args.width, args.height, args.density, + args.ram, args.storage, sdk), encoding="utf-8") + + print(f"created {args.name} from {api}/{tag}/{abi}") + print(f" {folder}") + print(f"start it with: {sdk / 'emulator' / 'emulator'} -avd {args.name}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/make_sample.py b/tools/make_sample.py deleted file mode 100644 index 24dde8f..0000000 --- a/tools/make_sample.py +++ /dev/null @@ -1,287 +0,0 @@ -"""Draw the sample image used by the README demo. - -Deliberately an ordinary colour illustration rather than clean black line art: -the point of the demo is that you feed MThread Draw a normal picture and it works -out the strokes itself. It is also busy on purpose - towers, crenellations, -bridge arches, a treeline - because a demo that only ever draws a smiley says -nothing about what the vectoriser does with real detail. - -Generated rather than checked in as a photograph so the repository owns its -demo art outright, with no attribution or licence to track. - - python tools/make_sample.py examples/castle.png -""" - -from __future__ import annotations - -import math -import sys -from pathlib import Path - -from PIL import Image, ImageDraw, ImageFilter - -W, H = 1400, 1000 -HORIZON = 660 - -SKY_TOP = (46, 66, 118) -SKY_MID = (150, 126, 158) -SKY_LOW = (248, 186, 122) - -SUN = (255, 232, 176) -CLOUD = (238, 206, 200) - -FAR_HILL = (96, 104, 148) -MID_HILL = (70, 80, 120) -SNOW = (226, 228, 240) - -CLIFF = (114, 102, 106) -CLIFF_DARK = (56, 48, 56) -GRASS = (104, 136, 104) -GRASS_DARK = (46, 72, 60) - -STONE = (226, 220, 206) -STONE_SHADE = (188, 180, 168) -STONE_DARK = (146, 140, 132) -ROOF = (158, 76, 70) -ROOF_DARK = (120, 56, 54) -WINDOW = (58, 52, 62) -FLAG = (206, 88, 78) - -WATER = (112, 144, 184) -WATER_LIGHT = (176, 200, 224) -TREE = (34, 60, 50) - - -def lerp(a, b, t): - return tuple(int(round(x + (y - x) * t)) for x, y in zip(a, b)) - - -def sky(draw: ImageDraw.ImageDraw) -> None: - """Vertical gradient. Canny finds nothing here, which is the intent - - a drawn version of this scene should be lines, not a shaded sky.""" - for y in range(HORIZON + 40): - t = y / (HORIZON + 40) - colour = lerp(SKY_TOP, SKY_MID, t / 0.72) if t < 0.72 else lerp(SKY_MID, SKY_LOW, (t - 0.72) / 0.28) - draw.line([(0, y), (W, y)], fill=colour) - - -def sun(draw: ImageDraw.ImageDraw) -> None: - cx, cy, r = 1055, 300, 74 - draw.ellipse([(cx - r, cy - r), (cx + r, cy + r)], fill=SUN) - for index in range(2): - pad = r + 26 + index * 30 - draw.ellipse([(cx - pad, cy - pad), (cx + pad, cy + pad)], - outline=lerp(SUN, SKY_MID, 0.55 + index * 0.15), width=2) - - -def clouds(draw: ImageDraw.ImageDraw) -> None: - bands = [ - (150, 210, 320, 30), (250, 232, 210, 22), (860, 168, 300, 26), - (980, 236, 200, 18), (420, 150, 240, 20), (620, 250, 180, 16), - ] - for x, y, width, height in bands: - draw.ellipse([(x, y), (x + width, y + height)], fill=CLOUD) - draw.ellipse([(x + width * 0.25, y - height * 0.6), (x + width * 0.8, y + height * 0.7)], fill=CLOUD) - - -def mountains(draw: ImageDraw.ImageDraw) -> None: - far = [(-40, 640), (140, 470), (250, 545), (390, 400), (520, 540), - (660, 452), (780, 556), (930, 430), (1080, 552), (1240, 470), - (1440, 620), (1440, 700), (-40, 700)] - draw.polygon(far, fill=FAR_HILL) - for peak_x, peak_y, spread in ((390, 400, 46), (930, 430, 40), (140, 470, 34)): - draw.polygon( - [(peak_x, peak_y), (peak_x + spread, peak_y + spread * 0.9), - (peak_x + spread * 0.4, peak_y + spread * 0.55), - (peak_x, peak_y + spread * 0.8), - (peak_x - spread * 0.45, peak_y + spread * 0.6), - (peak_x - spread, peak_y + spread * 0.95)], - fill=SNOW, - ) - - mid = [(-40, 700), (180, 590), (340, 660), (520, 585), (700, 668), - (900, 596), (1100, 664), (1300, 604), (1440, 676), (1440, 740), (-40, 740)] - draw.polygon(mid, fill=MID_HILL) - - -def cliff(draw: ImageDraw.ImageDraw) -> None: - """The rock the castle stands on, and the bank it grows out of.""" - rock = [(470, 596), (455, 660), (430, 700), (446, 760), (500, 806), - (600, 838), (740, 846), (880, 826), (960, 786), (986, 726), - (966, 664), (940, 606), (900, 580), (520, 580)] - draw.polygon(rock, fill=CLIFF, outline=CLIFF_DARK, width=4) - - for points in ( - [(520, 610), (496, 690), (528, 764), (566, 812)], - [(640, 600), (628, 690), (652, 780), (640, 836)], - [(770, 596), (790, 684), (762, 772), (784, 842)], - [(880, 604), (900, 678), (876, 750), (904, 812)], - ): - draw.line(points, fill=CLIFF_DARK, width=5, joint="curve") - - draw.polygon([(430, 700), (446, 760), (500, 806), (470, 700)], fill=CLIFF_DARK) - draw.polygon([(966, 664), (986, 726), (960, 786), (944, 690)], fill=CLIFF_DARK) - - -def tower(draw: ImageDraw.ImageDraw, x: int, top: int, width: int, bottom: int, - *, spire: bool = True, windows: int = 2) -> None: - draw.rectangle([(x, top), (x + width, bottom)], fill=STONE, outline=STONE_DARK, width=3) - draw.rectangle([(x + width * 0.62, top), (x + width, bottom)], fill=STONE_SHADE) - draw.rectangle([(x, top), (x + width, bottom)], outline=STONE_DARK, width=3) - - # Crenellated crown: a band plus the merlons standing on it. - band = 16 - draw.rectangle([(x - 7, top - band), (x + width + 7, top)], fill=STONE, outline=STONE_DARK, width=3) - merlon_w = (width + 14) / 7 - for index in range(0, 7, 2): - mx = x - 7 + index * merlon_w - draw.rectangle([(mx, top - band - 15), (mx + merlon_w, top - band)], - fill=STONE, outline=STONE_DARK, width=3) - - if spire: - apex = top - band - 15 - int(width * 1.15) - draw.polygon([(x - 14, top - band - 15), (x + width + 14, top - band - 15), - (x + width / 2, apex)], fill=ROOF, outline=ROOF_DARK) - draw.line([(x + width / 2, apex), (x + width / 2, apex - 40)], fill=STONE_DARK, width=3) - draw.polygon([(x + width / 2, apex - 40), (x + width / 2 + 44, apex - 28), - (x + width / 2, apex - 16)], fill=FLAG, outline=ROOF_DARK) - - for index in range(windows): - wy = top + 34 + index * 62 - wx = x + width / 2 - 11 - draw.rounded_rectangle([(wx, wy), (wx + 22, wy + 38)], 11, fill=WINDOW) - - -def castle(draw: ImageDraw.ImageDraw) -> None: - base = 600 - - # Curtain wall between the towers, with its own crenellations. - draw.rectangle([(500, 486), (900, base)], fill=STONE, outline=STONE_DARK, width=3) - for index in range(0, 17, 2): - mx = 500 + index * 25 - draw.rectangle([(mx, 466), (mx + 25, 486)], fill=STONE, outline=STONE_DARK, width=3) - - # Gatehouse: a half-round arch over a square opening, portcullis behind it. - draw.pieslice([(654, 500), (746, 592)], 180, 360, fill=STONE_SHADE) - draw.rectangle([(654, 546), (746, base)], fill=STONE_SHADE) - draw.arc([(654, 500), (746, 592)], 180, 360, fill=STONE_DARK, width=3) - draw.line([(654, 546), (654, base)], fill=STONE_DARK, width=3) - draw.line([(746, 546), (746, base)], fill=STONE_DARK, width=3) - - draw.pieslice([(668, 514), (732, 578)], 180, 360, fill=WINDOW) - draw.rectangle([(668, 546), (732, base)], fill=WINDOW) - for index in range(1, 4): - draw.line([(668 + index * 16, 528), (668 + index * 16, base)], fill=STONE_DARK, width=2) - for index in range(1, 4): - draw.line([(668, 534 + index * 22), (732, 534 + index * 22)], fill=STONE_DARK, width=2) - - tower(draw, 470, 404, 86, base, windows=2) - tower(draw, 828, 424, 78, base, windows=2) - tower(draw, 626, 300, 66, 486, windows=3) # keep, standing on the wall - tower(draw, 736, 350, 58, 486, windows=2) - - # A hall roof tucked between the keep and the right tower. - draw.polygon([(560, 486), (626, 486), (626, 430), (593, 404), (560, 430)], - fill=ROOF, outline=ROOF_DARK) - draw.line([(593, 404), (593, 486)], fill=ROOF_DARK, width=2) - - -def bridge(draw: ImageDraw.ImageDraw) -> None: - """A causeway from the near bank to the foot of the rock, on two arches.""" - left_x, left_y = 300, 838 - right_x, right_y = 474, 800 - - def deck(x: float) -> float: - return left_y + (right_y - left_y) * (x - left_x) / (right_x - left_x) - - for x in (344, 430): - top = deck(x) - draw.rectangle([(x - 20, top + 8), (x + 20, top + 104)], fill=STONE_SHADE, - outline=STONE_DARK, width=3) - - for x in (387,): - top = deck(x) + 26 - draw.pieslice([(x - 44, top - 44), (x + 44, top + 44)], 180, 360, - fill=WATER, outline=STONE_DARK, width=3) - - draw.polygon([(left_x, left_y), (right_x, right_y), (right_x, right_y + 22), (left_x, left_y + 22)], - fill=STONE, outline=STONE_DARK) - for index in range(8): - x = left_x + index * (right_x - left_x) / 7 - top = deck(x) - draw.line([(x, top), (x, top - 24)], fill=STONE_DARK, width=3) - draw.line([(left_x, left_y - 24), (right_x, right_y - 24)], fill=STONE_DARK, width=3) - - -def water(draw: ImageDraw.ImageDraw) -> None: - draw.rectangle([(0, 806), (W, H)], fill=WATER) - for index in range(16): - y = 826 + index * 11 - length = 120 + (index * 97) % 420 - x = 40 + (index * 233) % 900 - draw.line([(x, y), (x + length, y)], fill=WATER_LIGHT, width=3) - draw.line([(x + length + 60, y), (x + length + 60 + length * 0.4, y)], - fill=WATER_LIGHT, width=2) - - -def banks(draw: ImageDraw.ImageDraw) -> None: - draw.polygon([(-40, 848), (110, 812), (240, 832), (322, 818), (322, 1040), (-40, 1040)], fill=GRASS) - draw.polygon([(1104, 820), (1220, 792), (1440, 826), (1440, 1040), (1104, 1040)], fill=GRASS) - draw.line([(-40, 848), (110, 812), (240, 832), (322, 818)], fill=GRASS_DARK, width=4, joint="curve") - draw.line([(1104, 820), (1220, 792), (1440, 826)], fill=GRASS_DARK, width=4, joint="curve") - - -def pines(draw: ImageDraw.ImageDraw) -> None: - def pine(x: int, base_y: int, height: int) -> None: - width = height * 0.46 - draw.line([(x, base_y), (x, base_y - height * 0.22)], fill=CLIFF_DARK, - width=max(3, int(height * 0.05))) - for tier in range(3): - top = base_y - height + tier * height * 0.26 - spread = width * (0.55 + tier * 0.22) - draw.polygon([(x, top), (x + spread, top + height * 0.34), (x - spread, top + height * 0.34)], - fill=TREE, outline=GRASS_DARK) - - for x, base_y, height in ((60, 916, 200), (150, 884, 168), (246, 926, 210)): - pine(x, base_y, height) - for x, base_y, height in ((1168, 898, 176), (1262, 936, 216), (1356, 890, 158), (1432, 920, 190)): - pine(x, base_y, height) - - -def birds(draw: ImageDraw.ImageDraw) -> None: - for x, y, size in ((300, 300, 18), (348, 268, 14), (392, 312, 11), (1150, 420, 15), (1200, 396, 12)): - draw.arc([(x - size, y - size), (x, y + size)], 300, 360, fill=SKY_TOP, width=3) - draw.arc([(x, y - size), (x + size, y + size)], 180, 240, fill=SKY_TOP, width=3) - - -def build() -> Image.Image: - image = Image.new("RGB", (W, H), SKY_TOP) - draw = ImageDraw.Draw(image) - - sky(draw) - sun(draw) - clouds(draw) - mountains(draw) - water(draw) - cliff(draw) - castle(draw) - banks(draw) - bridge(draw) - pines(draw) - birds(draw) - - # A whisper of blur: flat vector edges are unrealistically sharp, and the - # demo is more honest if the vectoriser is fed something photograph-like. - return image.filter(ImageFilter.GaussianBlur(0.4)) - - -def main(argv: list[str]) -> int: - out = Path(argv[1]) if len(argv) > 1 else Path("examples/castle.png") - out.parent.mkdir(parents=True, exist_ok=True) - build().save(out) - print(f"wrote {out} ({W}x{H})") - return 0 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv)) diff --git a/tools/test_canvas.py b/tools/test_canvas.py index a068127..5d833ea 100644 --- a/tools/test_canvas.py +++ b/tools/test_canvas.py @@ -6,7 +6,7 @@ python tools/test_canvas.py # open the canvas python tools/test_canvas.py --pattern --shot out.png # calibration pattern - python tools/test_canvas.py --image examples/castle.png # draw a picture on it + python tools/test_canvas.py --image examples/guitar.jpg # draw a picture on it The pattern is a rectangle inset by a tenth of the screen, its diagonals, and a cross in the middle. Held against the canvas grid it answers the question this @@ -61,16 +61,25 @@ def fit_to_screen(paths, width, height, *, margin=0.06): return [[(round(x * scale + dx), round(y * scale + dy)) for x, y in path] for path in paths] -def calibration_paths(width: int, height: int, margin: float = 0.12) -> list[list[tuple[int, int]]]: +def calibration_paths(width: int, height: int, margin: float = 0.12, + top_margin: float = 0.20, + bottom_margin: float = 0.10) -> list[list[tuple[int, int]]]: """A rectangle, its diagonals and a centre cross, in display pixels. - The margin has to clear whatever chrome the canvas is sitting under - a - browser toolbar at the top, the gesture bar at the bottom - or the pattern - is drawn on the browser instead of on the page. + The margins clear whatever chrome the canvas is sitting under, and they are + not symmetric because the chrome is not: a browser puts an address bar and + the page's own header above the canvas and only a gesture bar below it. On a + 1080x2280 emulator that came to about 280 pixels at the top, so a twelfth of + the height was not enough and the rectangle's top edge was drawn on the + toolbar instead of on the page - which looks exactly like touches landing in + the wrong place, and is not. """ left, right = round(width * margin), round(width * (1 - margin)) - top, bottom = round(height * margin), round(height * (1 - margin)) - cx, cy = width // 2, height // 2 + top, bottom = round(height * top_margin), round(height * (1 - bottom_margin)) + # The rectangle's centre, not the screen's. With asymmetric margins those + # are different points, and a cross drawn at the screen's centre sits above + # where the diagonals meet - which reads as a calibration error and is not. + cx, cy = (left + right) // 2, (top + bottom) // 2 arm = min(width, height) // 12 def line(x1, y1, x2, y2, steps=40): @@ -99,6 +108,8 @@ def main() -> int: parser.add_argument("--image", help="also draw this image on the canvas") parser.add_argument("--sensitivity", type=float, default=3.0, help="edge sensitivity, 1-10") parser.add_argument("--detail", type=float, default=8.0, help="detail, 1-10") + parser.add_argument("--top-margin", type=float, default=0.20, + help="extra room at the top for browser chrome") parser.add_argument("--margin", type=float, default=0.12, help="fraction of the screen to keep clear around the drawing") parser.add_argument("--hand", type=float, default=0.0, @@ -143,7 +154,8 @@ def main() -> int: time.sleep(args.wait) if args.pattern: - drawn = device.draw_paths(calibration_paths(width, height, args.margin), + drawn = device.draw_paths(calibration_paths(width, height, args.margin, + args.top_margin), human=args.hand, speed=args.speed, seed=1) print(f"drew {drawn} strokes; the counter on the canvas should agree") print("The rectangle should sit a tenth of the screen in from every edge and") diff --git a/winui/MainWindow.xaml b/winui/MainWindow.xaml index 79fd174..b661d87 100644 --- a/winui/MainWindow.xaml +++ b/winui/MainWindow.xaml @@ -55,9 +55,28 @@