diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9421e4..9c79d34 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -40,9 +40,11 @@ env: jobs: windows: - name: Windows installer + name: Windows installer and WinUI front end runs-on: windows-latest - timeout-minutes: 30 + # Two PyInstaller builds, a WiX build, a self-contained WinUI publish and a + # 400 MB zip. Thirty minutes was enough before the WinUI front end existed. + timeout-minutes: 50 steps: - uses: actions/checkout@v4 @@ -50,14 +52,18 @@ jobs: with: python-version: "3.12" + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: "8.0.x" + - name: Install build dependencies run: | python -m pip install --upgrade pip python -m pip install -e ".[gui]" pyinstaller dotnet tool install --global wix --version ${{ env.WIX_VERSION }} - - name: Build app, installer and archive - run: python tools/build_app.py --msi --archive + - name: Build app, installer, WinUI front end and archive + run: python tools/build_app.py --msi --winui --archive - uses: actions/upload-artifact@v4 with: @@ -143,7 +149,8 @@ jobs: | | | |---|---| - | **Windows** | `MThreadDraw-*-x64.msi` — installs like any other program, Start Menu entry included. The `.zip` is the same app with no installer. | + | **Windows** | `MThreadDraw-*-x64.msi` — installs like any other program, Start Menu entry included. `MThreadDraw-*-windows-x64.zip` is the same app with no installer. | + | **Windows, native interface** | `MThreadDraw-WinUI-*-x64.zip` — the WinUI 3 front end. Unzip anywhere and run `MThreadDraw.exe`; it needs no runtime installed and carries its own copy of the engine. Larger, and the newer of the two. | | **macOS** | `MThreadDraw-*-arm64.dmg` for Apple Silicon, `MThreadDraw-*-x64.dmg` for Intel. Drag it to Applications. | | **Linux** | Run from source — see the [README](https://github.com/MAXAWER/MThread-Draw#install-variants). | diff --git a/CHANGELOG.md b/CHANGELOG.md index b1d9225..0228c49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- **A live view of the device screen**, in its own shape, with the strokes laid + over exactly where they will land. `mthread.mirror.ScreenMirror` runs + `com.mthread.Mirror` from the same jar as the injector: the phone scales and + JPEG-compresses each frame, so the cable carries 27 kB instead of sixteen + megabytes. About 3.4 frames a second on a Pixel 8 Pro, against one frame every + two seconds for `adb exec-out screencap -p`. +- **Two sliders instead of five.** Edge sensitivity and detail were always + turned together; speed and hand simulation were one axis pretending to be two. + The margin is fixed at six per cent. +- The Windows front end connects by itself when there is exactly one device, and + opens an image named on its command line. + - **The project is now MThread Draw.** Everything is renamed: `import adbtouch` becomes `import mthread`, `autodraw` becomes `mthread_draw`, the console script is `mthread` and the desktop entry point `mthread-draw`, the diff --git a/README.md b/README.md index 03dac52..0ee0462 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,10 @@ -

MThread Draw + mthread

+

+ MThread Draw — a cat's portrait assembling itself out of white touch points +

- Draw any picture on an Android screen, and record and replay touch gestures.
- Over USB or wireless ADB. Nothing is installed on the phone. + Every white circle is one touch point, in the order it is sent to the phone. Nothing about that picture is illustrated: + it is a real tools/make_banner.py run over examples/cat.jpg.

@@ -14,12 +16,12 @@

- A colour photograph of a cat being traced into touch strokes and drawn on a phone screen + A colour photograph of a guitar being traced into touch strokes and drawn on a phone screen

- An ordinary photograph in, 283 strokes and 1,528 touch points out: the exact path list mthread sends to the device, in the order it draws them.
- On a Pixel 8 Pro that draws in about five seconds. Rendered from a real run by tools/make_demo.py; playback speed here is arbitrary.
+ An ordinary photograph in, 57 strokes and 478 touch points out: the exact path list mthread sends to the device, in the order it draws them.
+ On a Pixel 8 Pro that draws in under two seconds. Rendered from a real run by tools/make_demo.py; playback speed here is arbitrary.

--- diff --git a/docs/banner.gif b/docs/banner.gif new file mode 100644 index 0000000..292010b Binary files /dev/null and b/docs/banner.gif differ diff --git a/docs/banner.png b/docs/banner.png new file mode 100644 index 0000000..dbbca90 Binary files /dev/null and b/docs/banner.png differ diff --git a/docs/demo.gif b/docs/demo.gif index ed15eca..45c2dc3 100644 Binary files a/docs/demo.gif and b/docs/demo.gif differ diff --git a/docs/examples.png b/docs/examples.png index 901a2f7..2beaef8 100644 Binary files a/docs/examples.png and b/docs/examples.png differ diff --git a/docs/pipeline.png b/docs/pipeline.png index 5ac0726..22feaf4 100644 Binary files a/docs/pipeline.png and b/docs/pipeline.png differ diff --git a/injector/src/com/mthread/Mirror.java b/injector/src/com/mthread/Mirror.java new file mode 100644 index 0000000..b50a559 --- /dev/null +++ b/injector/src/com/mthread/Mirror.java @@ -0,0 +1,134 @@ +package com.mthread; + +import android.graphics.Bitmap; +import android.util.Base64; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.ByteBuffer; +import java.nio.ByteOrder; + +/** + * Screen capture that scales and compresses on the phone, so the cable carries + * a thumbnail instead of a framebuffer. + * + *

Why this exists, in numbers measured on a Pixel 8 Pro at 1344x2992: + * + *

+ * + *

So the ceiling is the capture itself, which is roughly three frames a + * second. That is not video and this is not scrcpy: scrcpy is fast because it + * feeds the display straight into the hardware H.264 encoder, which needs a + * decoder on the other end. Three frames a second is enough to see what is on + * the phone and where a drawing is going to land, which is what this is for. + * + *

Pull-based: one line in, one frame out, so the host sets the rate and + * frames never queue up behind a slow reader. + * + *

+ *   <maxWidth> <quality>   capture, reply with "F " and the JPEG in base64
+ *   Q                          quit
+ * 
+ * + *

Base64, for a picture, over a link that ought to be binary-safe. In + * practice it is not: adb on Windows still turns a line feed into a carriage + * return and a line feed on the way out, and a JPEG is full of line feeds - so + * the image arrives longer than it left, the declared length no longer matches, + * and every frame after the first is out of step by however many newlines the + * one before it happened to contain. Base64 has no byte the translation can + * touch. It costs a third more on a 25 kB frame and removes the whole class of + * problem. + */ +public final class Mirror { + + public static void main(String[] args) throws Exception { + BufferedReader in = new BufferedReader(new InputStreamReader(System.in)); + + System.out.println("READY"); + System.out.flush(); + + String line; + while ((line = in.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) { + continue; + } + if (line.startsWith("Q")) { + break; + } + + String[] parts = line.split("\\s+"); + int maxWidth = parts.length > 0 ? Integer.parseInt(parts[0]) : 480; + int quality = parts.length > 1 ? Integer.parseInt(parts[1]) : 60; + + try { + byte[] jpeg = capture(maxWidth, quality); + System.out.println("F " + Base64.encodeToString(jpeg, Base64.NO_WRAP)); + } catch (Throwable error) { + System.out.println("E " + error); + } + System.out.flush(); + } + } + + /** Capture the screen, scaled to *maxWidth* on its longer edge, as JPEG. */ + private static byte[] capture(int maxWidth, int quality) throws Exception { + byte[] raw = readAll(Runtime.getRuntime().exec("screencap").getInputStream()); + if (raw.length < 16) { + throw new IllegalStateException("screencap returned " + raw.length + " bytes"); + } + + ByteBuffer header = ByteBuffer.wrap(raw).order(ByteOrder.LITTLE_ENDIAN); + int width = header.getInt(); + int height = header.getInt(); + header.getInt(); // pixel format; always RGBA_8888 in practice + + // Android 10 added a colour space to the header, so its size is not a + // constant. The pixels are the last width*height*4 bytes either way. + int offset = raw.length - width * height * 4; + if (offset < 12) { + throw new IllegalStateException( + "screencap gave " + raw.length + " bytes for " + width + "x" + height); + } + + Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888); + // ARGB_8888 is stored RGBA in memory on a little-endian machine, which + // is exactly what screencap produces, so the buffer copies straight in. + ByteBuffer pixels = ByteBuffer.wrap(raw, offset, width * height * 4); + bitmap.copyPixelsFromBuffer(pixels); + + int longer = Math.max(width, height); + if (longer > maxWidth) { + float scale = (float) maxWidth / longer; + bitmap = Bitmap.createScaledBitmap( + bitmap, Math.round(width * scale), Math.round(height * scale), true); + } + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.JPEG, quality, buffer); + return buffer.toByteArray(); + } + + private static byte[] readAll(InputStream stream) throws Exception { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(1 << 22); + byte[] chunk = new byte[1 << 16]; + int read; + while ((read = stream.read(chunk)) > 0) { + buffer.write(chunk, 0, read); + } + return buffer.toByteArray(); + } + + private Mirror() { + } +} diff --git a/mthread/__init__.py b/mthread/__init__.py index 3e377c3..2f3c814 100644 --- a/mthread/__init__.py +++ b/mthread/__init__.py @@ -21,7 +21,7 @@ """ from .adb import find_adb -from .device import Device, DeviceInfo, list_devices +from .device import Device, DeviceInfo, find_devices, list_devices, restart_server from .hand import HandSettings, reorder_strokes, simulate from .injector import InjectorUnavailableError, Pacing, TouchInjector from .errors import ( @@ -62,7 +62,7 @@ def __dir__(): __all__ = [ "__version__", - "Device", "DeviceInfo", "list_devices", "find_adb", + "Device", "DeviceInfo", "list_devices", "find_devices", "restart_server", "find_adb", "HandSettings", "simulate", "reorder_strokes", "TouchInjector", "Pacing", "InjectorUnavailableError", "Recorder", "Session", "InputEvent", "replay", "build_replay_script", "parse_getevent_line", diff --git a/mthread/device.py b/mthread/device.py index b92c8b4..c07e119 100644 --- a/mthread/device.py +++ b/mthread/device.py @@ -57,6 +57,32 @@ def list_devices(adb_path: str | None = None) -> list[DeviceInfo]: return devices +def restart_server(adb_path: str | None = None) -> None: + """Stop and start the adb daemon. + + The daemon can end up running but enumerating nothing - it was started + before the USB driver settled, or another copy of adb on the machine claimed + port 5037 first. ``adb devices`` then reports an empty list for a phone that + is plugged in and working, and no amount of asking again helps. + + Only worth doing when the list came back empty: killing the daemon + disconnects anything else using it, Android Studio included. + """ + adb = adb_path or find_adb() + run_adb(adb, ["kill-server"], check=False) + run_adb(adb, ["start-server"], check=False) + + +def find_devices(adb_path: str | None = None) -> list[DeviceInfo]: + """List devices, reviving a wedged daemon once before giving up.""" + adb = adb_path or find_adb() + devices = list_devices(adb) + if devices: + return devices + restart_server(adb) + return list_devices(adb) + + class Device: """A connected Android device. diff --git a/mthread/injector.jar b/mthread/injector.jar index e616746..62d02e2 100644 Binary files a/mthread/injector.jar and b/mthread/injector.jar differ diff --git a/mthread/mirror.py b/mthread/mirror.py new file mode 100644 index 0000000..b5d9275 --- /dev/null +++ b/mthread/mirror.py @@ -0,0 +1,134 @@ +"""Live view of the device screen. + +The work is done on the phone by ``com.mthread.Mirror`` in the same jar the +touch injector uses; see that class for why. The short version: capturing costs +about 300 ms and everything else - PNG encoding on the device, or sixteen +megabytes of framebuffer over the cable - costs seconds, so the phone scales +and JPEG-compresses before anything is sent. + + with ScreenMirror(device) as mirror: + jpeg = mirror.frame() + +Pull-based on purpose. One request, one frame: a slow reader gets fewer frames +rather than a growing backlog of stale ones. +""" + +from __future__ import annotations + +import base64 +import subprocess +import threading +from pathlib import Path + +from .adb import no_window_kwargs +from .errors import MThreadError +from .injector import REMOTE_JAR, bundled_jar + +__all__ = ["MirrorUnavailableError", "ScreenMirror"] + + +class MirrorUnavailableError(MThreadError): + """The mirror could not be started on the device.""" + + +class ScreenMirror: + """A capture process living on the device, asked for one frame at a time.""" + + def __init__(self, device, jar: Path | None = None, + max_width: int = 480, quality: int = 60): + self.device = device + self.jar = Path(jar) if jar else bundled_jar() + self.max_width = max_width + self.quality = quality + self.process: subprocess.Popen | None = None + self._lock = threading.Lock() + + def __enter__(self) -> "ScreenMirror": + self.start() + return self + + def __exit__(self, *_) -> None: + self.close() + + # ------------------------------------------------------------------ start + + def start(self) -> None: + if self.jar is None or not self.jar.is_file(): + raise MirrorUnavailableError( + "The injector jar is missing. Build it with " + "`python tools/build_injector.py`.") + + self.device.adb("push", str(self.jar), REMOTE_JAR, timeout=60.0) + self.process = subprocess.Popen( + # "shell -T", and neither of the two obvious alternatives. + # + # Plain "shell" allocates a pty, a pty turns every line feed into a + # carriage return and a line feed, and a JPEG is full of 0x0A bytes: + # the image arrives longer than it left and is no longer an image. + # The first frame looks like it worked and every frame after it is + # out of step by however many newlines the first one contained. + # + # "exec-out" has no pty and is the usual answer for binary output, + # but it is output only - stdin never reaches the process, so the + # first request is never heard and it hangs after READY. + # + # -T asks for no pty on a channel that still carries both. + [self.device.adb_path, "-s", self.device.serial, "shell", "-T", + f"CLASSPATH={REMOTE_JAR} app_process / com.mthread.Mirror"], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + **no_window_kwargs(), + ) + + greeting = self._readline() + if greeting != b"READY": + self.close() + raise MirrorUnavailableError( + f"The mirror would not start: {greeting.decode('utf-8', 'replace') or 'no output'}") + + # ------------------------------------------------------------------ using + + def frame(self) -> bytes: + """Capture one frame and return it as JPEG bytes.""" + with self._lock: + if self.process is None or self.process.poll() is not None: + raise MirrorUnavailableError("The mirror is not running.") + + self.process.stdin.write(f"{self.max_width} {self.quality}\n".encode()) + self.process.stdin.flush() + + reply = self._readline() + if not reply.startswith(b"F "): + raise MirrorUnavailableError( + reply.decode("utf-8", "replace") or "the mirror stopped answering") + return base64.b64decode(reply[2:]) + + def close(self) -> None: + if self.process is None: + return + try: + if self.process.poll() is None: + self.process.stdin.write(b"Q\n") + self.process.stdin.flush() + self.process.wait(timeout=3) + except Exception: + pass + finally: + if self.process.poll() is None: + self.process.kill() + self.process = None + + # ----------------------------------------------------------------- pipes + + def _readline(self) -> bytes: + """Read one line, tolerating whatever adb did to the line ending.""" + out = bytearray() + while True: + char = self.process.stdout.read(1) + if not char: + return bytes(out).strip() + if char == b"\n": + return bytes(out).strip() + out += char + diff --git a/mthread_draw/server.py b/mthread_draw/server.py index 7531db0..a603698 100644 --- a/mthread_draw/server.py +++ b/mthread_draw/server.py @@ -37,11 +37,18 @@ import traceback from pathlib import Path -from mthread import Device, VectorizeSettings, Vectorizer, list_devices, simulate +from PIL import Image, ImageDraw + +from mthread import Device, VectorizeSettings, Vectorizer, find_devices, simulate +from mthread.mirror import ScreenMirror from mthread.errors import MThreadError from .geometry import fit_to_screen +#: Width of the placement overlay. It is stretched over the mirror by the +#: front end, so it only has to be fine enough to read, not full resolution. +OVERLAY_WIDTH = 540 + class Engine: """One device, one image, one drawing at a time.""" @@ -53,6 +60,10 @@ def __init__(self, write): self.paths: list = [] self.cancel = threading.Event() self.worker: threading.Thread | None = None + self.drawing = threading.Event() + self.mirror: ScreenMirror | None = None + self.mirror_thread: threading.Thread | None = None + self.mirror_stop: threading.Event | None = None # ---------------------------------------------------------------- talking @@ -67,7 +78,7 @@ def status(self, text: str) -> None: def op_devices(self) -> dict: return {"devices": [ {"serial": d.serial, "state": d.state, "description": d.human_state} - for d in list_devices() + for d in find_devices() ]} def op_connect(self, serial: str | None = None) -> dict: @@ -86,12 +97,64 @@ def op_screenshot(self) -> dict: self.device.screenshot(str(path)) return {"path": str(path)} + # -------------------------------------------------------------- mirroring + + def op_mirror(self, on: bool = True, max_width: int = 520, + quality: int = 60) -> dict: + """Start or stop the live view of the device screen. + + Frames are written to two files in turn and announced as events. Two, + because a front end that has just been handed a path may still have the + file open when the next frame is ready, and one file would mean either a + torn image or a failed write. + """ + self._stop_mirror() + if not on: + return {"mirroring": False} + + self._require_device() + self.mirror = ScreenMirror(self.device, max_width=max_width, quality=quality) + self.mirror.start() + self.mirror_stop = threading.Event() + self.mirror_thread = threading.Thread(target=self._mirror_loop, daemon=True) + self.mirror_thread.start() + + width, height = self.device.screen_size + return {"mirroring": True, "width": width, "height": height} + + def _mirror_loop(self) -> None: + slot = 0 + while not self.mirror_stop.is_set(): + if self.drawing.is_set(): + # Capturing costs the device real work, and it is competing with + # the drawing for it. The view resumes when the stroke is done. + self.mirror_stop.wait(0.2) + continue + try: + jpeg = self.mirror.frame() + except Exception as error: + self.event("mirror_lost", error=str(error)) + return + slot = 1 - slot + path = Path(tempfile.gettempdir()) / f"mthread_draw_frame_{slot}.jpg" + path.write_bytes(jpeg) + self.event("frame", path=str(path)) + + def _stop_mirror(self) -> None: + if self.mirror_stop is not None: + self.mirror_stop.set() + if self.mirror_thread is not None: + self.mirror_thread.join(timeout=5) + if self.mirror is not 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])} def op_preview(self, sensitivity: float = 5, detail: float = 7, - method: str = "canny") -> dict: + method: str = "canny", margin: float = 0.06) -> dict: started = time.perf_counter() settings = VectorizeSettings.from_sliders(sensitivity, detail, method=method) preview, paths = self.vectorizer.process(settings) @@ -101,11 +164,38 @@ def op_preview(self, sensitivity: float = 5, detail: float = 7, preview.save(path) return { "path": str(path), + "overlay": self._overlay(margin), "strokes": len(paths), "points": sum(len(p) for p in paths), "seconds": round(time.perf_counter() - started, 2), } + def _overlay(self, margin: float) -> str | None: + """The strokes where they will actually 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. + """ + if self.device is None or not self.paths: + return None + + width, height = self.device.screen_size + placed = fit_to_screen(self.paths, width, height, margin=margin) + + 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") + + path = Path(tempfile.gettempdir()) / "mthread_draw_overlay.png" + image.save(path) + return str(path) + def op_estimate(self, speed: float = 1.0, human: float = 0.0) -> dict: self._require_device() if not self.paths: @@ -122,15 +212,19 @@ def op_draw(self, margin: float = 0.06, speed: float = 1.0, width, height = self.device.screen_size placed = fit_to_screen(self.paths, width, height, margin=margin) self.cancel.clear() + self.drawing.set() self.status(f"Drawing {len(placed)} strokes") - drawn = self.device.draw_paths( - placed, - speed=speed, - human=human, - progress=lambda done, total: self.event("progress", done=done, total=total), - should_continue=lambda: not self.cancel.is_set(), - ) + try: + drawn = self.device.draw_paths( + placed, + speed=speed, + human=human, + progress=lambda done, total: self.event("progress", done=done, total=total), + should_continue=lambda: not self.cancel.is_set(), + ) + finally: + self.drawing.clear() return {"strokes": drawn, "stopped": self.cancel.is_set()} def op_stop(self) -> dict: @@ -143,7 +237,7 @@ def _require_device(self) -> None: #: Operations that may take a while, and so must not block the reader. -SLOW = {"preview", "draw", "screenshot", "connect"} +SLOW = {"preview", "draw", "screenshot", "connect", "mirror"} def serve(stdin=None, stdout=None) -> int: diff --git a/packaging/mthreaddraw.ico b/packaging/mthreaddraw.ico index a66081b..b040c6d 100644 Binary files a/packaging/mthreaddraw.ico and b/packaging/mthreaddraw.ico differ diff --git a/packaging/mthreaddraw.png b/packaging/mthreaddraw.png index 3166d44..af6e6cd 100644 Binary files a/packaging/mthreaddraw.png and b/packaging/mthreaddraw.png differ diff --git a/tools/build_app.py b/tools/build_app.py index d7fb62c..88dd932 100644 --- a/tools/build_app.py +++ b/tools/build_app.py @@ -190,6 +190,13 @@ def build_winui() -> Path: size = sum(f.stat().st_size for f in out.rglob("*") if f.is_file()) print(f"built {out} ({size // 1024 // 1024} MB)") + + # A release asset has to be one file, and a folder of four thousand is a + # poor thing to ask anyone to download by hand. + archive = Path(shutil.make_archive( + str(DIST / f"MThreadDraw-WinUI-{version()}-{arch_tag()}"), "zip", + root_dir=out.parent, base_dir=out.name)) + print(f"built {archive} ({archive.stat().st_size // 1024 // 1024} MB)") return out diff --git a/tools/build_injector.py b/tools/build_injector.py index 932ade9..624302f 100644 --- a/tools/build_injector.py +++ b/tools/build_injector.py @@ -22,7 +22,10 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent -SOURCE = ROOT / "injector" / "src" / "com" / "mthread" / "Injector.java" +#: Every source under injector/src. Two classes share the jar: the touch +#: injector and the screen mirror, both of which have to be on the device +#: and both of which are a few kilobytes. +SOURCES = sorted((ROOT / "injector" / "src").rglob("*.java")) OUT_JAR = ROOT / "mthread" / "injector.jar" WORK = ROOT / "build" / "injector" @@ -95,7 +98,7 @@ def main() -> int: subprocess.run( [find_javac(), "-source", "8", "-target", "8", "-nowarn", "-bootclasspath", str(android_jar), "-classpath", str(android_jar), - "-d", str(classes), str(SOURCE)], + "-d", str(classes), *[str(source) for source in SOURCES]], check=True, ) diff --git a/tools/make_banner.py b/tools/make_banner.py new file mode 100644 index 0000000..761bc6e --- /dev/null +++ b/tools/make_banner.py @@ -0,0 +1,215 @@ +"""Render the repository banner: a drawing assembling itself out of touch points. + +The white circles are not decoration. Every one of them is a point this program +sends to the device, in the order it sends them, taken from a real +:class:`mthread.Vectorizer` run over `examples/guitar.jpg`. The line follows +behind because that is what the finger leaves. + + python tools/make_banner.py # -> docs/banner.gif + python tools/make_banner.py --still # -> docs/banner.png as well + +Everything is drawn at three times the size and scaled down; a one-pixel circle +drawn straight into the final image is a lump, not a circle. +""" + +from __future__ import annotations + +import argparse +import math +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +from mthread.vectorize import VectorizeSettings, Vectorizer + +WIDTH, HEIGHT = 1280, 460 +SUPERSAMPLE = 3 + +BACK = (12, 13, 17) +WHITE = (255, 255, 255) +DIM = (150, 154, 166) +FAINT = (34, 36, 44) + +FRAMES = 56 +HOLD = 16 +FRAME_MS = 70 + +#: How many of the most recently placed points still show as a bright head. +COMET = 46 + +TEXT_BOX = (78, 0, 430, HEIGHT) +ART_BOX = (600, 44, 600, 340) + + +def _font(size: int, bold: bool = False) -> ImageFont.ImageFont: + names = ("segoeuib.ttf", "arialbd.ttf", "DejaVuSans-Bold.ttf") if bold else ( + "segoeui.ttf", "arial.ttf", "DejaVuSans.ttf") + for name in names: + try: + return ImageFont.truetype(name, size) + except OSError: + continue + return ImageFont.load_default() + + +def trace(path: str, method: str = "flow"): + settings = VectorizeSettings.from_sliders(5.0, 8.0, target_width=900, method=method) + vectorizer = Vectorizer() + vectorizer.load_image(path) + _, paths = vectorizer.process(settings) + return paths + + +def fit(paths, box): + """Scale and centre the ink into *box* = (x, y, w, h).""" + points = [point for path in paths for point in path] + 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) + scale = min(box[2] / max(max_x - min_x, 1), box[3] / max(max_y - min_y, 1)) + dx = box[0] + (box[2] - (max_x - min_x) * scale) / 2 - min_x * scale + dy = box[1] + (box[3] - (max_y - min_y) * scale) / 2 - min_y * scale + return [[(x * scale + dx, y * scale + dy) for x, y in path] for path in paths] + + +def backdrop() -> Image.Image: + """The part that never moves: the ground, the ambient rings, the wordmark.""" + big = Image.new("RGB", (WIDTH * SUPERSAMPLE, HEIGHT * SUPERSAMPLE), BACK) + draw = ImageDraw.Draw(big) + scale = SUPERSAMPLE + + # Ambient circles: soft filled discs, not hairline rings. A ring at this + # weight reads as a scratch on the picture; a disc reads as a circle, which + # is the whole motif. + discs = [(150, 372, 128, 9), (1146, 92, 168, 8), (392, 74, 58, 12), + (1042, 406, 96, 7), (268, 172, 30, 16), (556, 402, 44, 10), + (1226, 262, 74, 9), (56, 96, 22, 14)] + for cx, cy, radius, level in discs: + draw.ellipse([((cx - radius) * scale, (cy - radius) * scale), + ((cx + radius) * scale, (cy + radius) * scale)], + fill=(BACK[0] + level, BACK[1] + level, BACK[2] + level)) + + # Two outlined rings, for the bit of structure the discs alone do not give. + for cx, cy, radius in ((196, 318, 212), (1096, 140, 250)): + draw.ellipse([((cx - radius) * scale, (cy - radius) * scale), + ((cx + radius) * scale, (cy + radius) * scale)], + outline=FAINT, width=2 * scale) + + image = big.resize((WIDTH, HEIGHT), Image.LANCZOS) + draw = ImageDraw.Draw(image) + + x = TEXT_BOX[0] + draw.ellipse([(x, 130), (x + 36, 166)], fill=WHITE) + draw.text((x, 192), "MThread Draw", font=_font(54, bold=True), fill=WHITE) + draw.text((x, 266), "Draw any picture on an Android screen over ADB.", + font=_font(19), fill=DIM) + draw.text((x, 294), "Record and replay touch gestures. Nothing installed on the phone.", + font=_font(19), fill=DIM) + return image + + +def render(paths, out: Path, still: bool) -> None: + placed = fit(paths, ART_BOX) + points = [point for path in placed for point in path] + # Segments carry the index of their later endpoint, so a segment appears at + # the moment its second point is placed rather than a whole stroke at a time. + segments, index = [], 0 + for path in placed: + for first, second in zip(path, path[1:]): + segments.append((index + 1, first, second)) + index += len(path) + total = len(points) + + shell = backdrop() + caption_font = _font(15) + + def frame_at(shown: int, head: bool) -> Image.Image: + scale = SUPERSAMPLE + layer = Image.new("RGB", (WIDTH * scale, HEIGHT * scale), BACK) + draw = ImageDraw.Draw(layer) + + for end, first, second in segments: + if end > shown: + break + draw.line([(first[0] * scale, first[1] * scale), + (second[0] * scale, second[1] * scale)], + fill=(214, 216, 224), width=scale) + + for order, (px, py) in enumerate(points[:shown]): + age = shown - order + if head and age <= COMET: + # The head fades from a bright wide dot to the resting size, so + # the eye follows where the finger is rather than the whole shape. + warmth = 1.0 - age / COMET + radius = (1.3 + 2.6 * warmth) * scale + level = int(150 + 105 * warmth) + draw.ellipse([(px * scale - radius, py * scale - radius), + (px * scale + radius, py * scale + radius)], + fill=(level, level, min(255, level + 6))) + elif order % 2 == 0: + # Every point at rest turns the line into a caterpillar; every + # other one keeps it legible as a row of circles. + radius = 1.5 * scale + draw.ellipse([(px * scale - radius, py * scale - radius), + (px * scale + radius, py * scale + radius)], + fill=(196, 199, 208)) + + small = layer.resize((WIDTH, HEIGHT), Image.LANCZOS) + # The art is composed onto the shell rather than drawn into it, so the + # wordmark and rings are rendered once instead of fifty-six times. + composed = shell.copy() + art = small.crop((ART_BOX[0] - 40, ART_BOX[1] - 20, + ART_BOX[0] + ART_BOX[2] + 40, ART_BOX[1] + ART_BOX[3] + 20)) + composed.paste(art, (ART_BOX[0] - 40, ART_BOX[1] - 20), _mask(art)) + return composed + + def _mask(art: Image.Image) -> Image.Image: + """Alpha from the art's own brightness, so the rings show through it. + + A hard threshold would be simpler and would throw away the antialiasing + that the whole supersampled render exists to produce - the dots would go + back to being lumps. + """ + return art.convert("L").point(lambda value: min(255, int(value * 1.4))) + + frames = [] + for step in range(FRAMES + HOLD): + shown = total if step >= FRAMES else max(2, int(total * (step + 1) / FRAMES)) + image = frame_at(shown, head=step < FRAMES) + caption = (f"{total:,} touch points" if step >= FRAMES + else f"{shown:,} / {total:,} touch points") + ImageDraw.Draw(image).text((ART_BOX[0] + ART_BOX[2] // 2, HEIGHT - 46), caption, + font=caption_font, fill=DIM, anchor="mm") + frames.append(image) + + if still: + frames[-1].save(out.with_suffix(".png")) + print(f"wrote {out.with_suffix('.png')}") + + palette = frames[-1].quantize(colors=128, method=Image.MEDIANCUT, dither=Image.NONE) + quantised = [f.quantize(palette=palette, dither=Image.NONE) for f in frames] + quantised[0].save(out, save_all=True, append_images=quantised[1:], duration=FRAME_MS, + loop=0, optimize=True, disposal=1) + print(f"wrote {out} ({out.stat().st_size // 1024} KB, {len(frames)} frames)") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--image", default="examples/cat.jpg") + parser.add_argument("--method", default="flow", choices=["canny", "flow"]) + parser.add_argument("--out", default="docs/banner.gif") + parser.add_argument("--still", action="store_true", help="also write a PNG of the last frame") + args = parser.parse_args() + + paths = trace(args.image, args.method) + print(f"{len(paths)} strokes, {sum(map(len, paths))} points") + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + render(paths, out, args.still) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/make_demo.py b/tools/make_demo.py index 23ef376..9649779 100644 --- a/tools/make_demo.py +++ b/tools/make_demo.py @@ -73,13 +73,21 @@ class Example: HOLD_FRAMES = 14 FRAME_MS = 80 -PAGE = (255, 255, 255) -BODY = (24, 26, 30) +# The page is dark and the marks on it are white, which is the identity the +# repository uses everywhere. The phone screen is the one exception: it stays +# white with black ink, because that is what the phone actually shows. +PAGE = (12, 13, 17) +BODY = (245, 246, 250) SCREEN = (255, 255, 255) INK = (20, 20, 22) PEN = (226, 62, 62) -LABEL = (108, 112, 120) -RULE = (214, 217, 223) +LABEL = (150, 154, 166) +RULE = (48, 51, 60) +#: Ink for a drawing shown on the page rather than on the phone. +PAGE_INK = (238, 240, 246) +#: The phone body. Once the page went dark this stopped being the text +#: colour: a light body around a light screen is one pale blob. +PHONE = (32, 34, 42) def _font(size: int, bold: bool = False) -> ImageFont.ImageFont: @@ -148,9 +156,9 @@ def stamp(canvas: Image.Image, mask: Image.Image, colour, origin=(0, 0)) -> None def drawing(paths, size, width: float = 1.5) -> Image.Image: - """A finished line drawing on white, at *size*.""" + """A finished line drawing on the page, at *size*.""" canvas = Image.new("RGB", size, PAGE) - stamp(canvas, ink_mask(paths, size, width), INK) + stamp(canvas, ink_mask(paths, size, width), PAGE_INK) return canvas @@ -183,7 +191,7 @@ def phone_backdrop(source: Image.Image, example: Example, stroke_count: int, lay draw.polygon([(phone_x - 30, y), (phone_x - 48, y - 10), (phone_x - 48, y + 10)], fill=RULE) draw.rounded_rectangle( - [(phone_x, phone_y), (phone_x + phone_w - 1, phone_y + phone_h - 1)], CORNER, fill=BODY) + [(phone_x, phone_y), (phone_x + phone_w - 1, phone_y + phone_h - 1)], CORNER, fill=PHONE) draw.rounded_rectangle( [(phone_x + BEZEL, phone_y + BEZEL), (phone_x + phone_w - BEZEL - 1, phone_y + phone_h - BEZEL - 1)], @@ -194,7 +202,7 @@ def phone_backdrop(source: Image.Image, example: Example, stroke_count: int, lay else: speaker = [(phone_x + phone_w // 2 - 28, phone_y + 6), (phone_x + phone_w // 2 + 28, phone_y + 13)] - draw.rounded_rectangle(speaker, 4, fill=(58, 60, 66)) + draw.rounded_rectangle(speaker, 4, fill=(78, 81, 92)) return canvas, ty + thumb.height + 40 @@ -350,7 +358,7 @@ def main() -> int: parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) parser.add_argument("--out", default="docs", help="directory for the generated assets") - parser.add_argument("--hero", default="cat", choices=[e.name for e in EXAMPLES], + parser.add_argument("--hero", default="guitar", choices=[e.name for e in EXAMPLES], help="which example is animated in demo.gif") parser.add_argument("--image", help="trace one image of your own instead") parser.add_argument("--method", default="canny", choices=["canny", "flow", "neural", "contour"]) diff --git a/tools/make_icon.py b/tools/make_icon.py index 4baa2cd..548af97 100644 --- a/tools/make_icon.py +++ b/tools/make_icon.py @@ -1,10 +1,15 @@ -"""Draw the application icon. +"""Draw the application icon: a white circle. Two outputs: a large PNG, which PyInstaller converts to an .icns for the macOS bundle, and a multi-resolution .ico for the Windows executable and its Start Menu shortcut. Generated rather than committed as binary art nobody can edit. python tools/make_icon.py + +The circle sits on a near-black square rather than on nothing. A white disc on +transparency is invisible against a light taskbar, a light Start Menu and half +of GitHub, which is not a mark - it is a hole. The square is what makes the +white read as white. """ from __future__ import annotations @@ -15,53 +20,39 @@ from PIL import Image, ImageDraw SIZE = 1024 -Ico_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] - -BACK_TOP = (58, 82, 152) -BACK_BOTTOM = (32, 44, 88) -PHONE = (250, 250, 252) -PHONE_EDGE = (206, 212, 226) -STROKE = (28, 32, 44) -PEN = (232, 72, 68) +ICO_SIZES = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)] +# Everything is drawn at this multiple and scaled down: PIL's ellipse has no +# antialiasing of its own, and a jagged circle is worse than no circle. +SUPERSAMPLE = 4 -def lerp(a, b, t): - return tuple(int(round(x + (y - x) * t)) for x, y in zip(a, b)) +BACK = (17, 18, 22) +CIRCLE = (255, 255, 255) +#: Fraction of the icon's width the circle spans. Small enough to breathe, +#: large enough to survive being drawn at sixteen pixels. +DIAMETER = 0.56 +CORNER = 0.22 def build() -> Image.Image: - image = Image.new("RGBA", (SIZE, SIZE), (0, 0, 0, 0)) + big = SIZE * SUPERSAMPLE + image = Image.new("RGBA", (big, big), (0, 0, 0, 0)) draw = ImageDraw.Draw(image) - # Rounded-square backdrop with a vertical gradient, drawn into a mask so the - # corners stay clean at every downscale. - gradient = Image.new("RGB", (1, SIZE)) - for y in range(SIZE): - gradient.putpixel((0, y), lerp(BACK_TOP, BACK_BOTTOM, y / SIZE)) - gradient = gradient.resize((SIZE, SIZE)) - mask = Image.new("L", (SIZE, SIZE), 0) - ImageDraw.Draw(mask).rounded_rectangle([(0, 0), (SIZE - 1, SIZE - 1)], SIZE * 0.22, fill=255) - image.paste(gradient, (0, 0), mask) - - # The phone, upright and centred. - px, py, pw, ph = 300, 168, 424, 688 - draw.rounded_rectangle([(px, py), (px + pw, py + ph)], 64, fill=PHONE, outline=PHONE_EDGE, width=6) - draw.rounded_rectangle([(px + pw / 2 - 52, py + 26), (px + pw / 2 + 52, py + 40)], 7, fill=PHONE_EDGE) + draw.rounded_rectangle([(0, 0), (big - 1, big - 1)], big * CORNER, fill=BACK) - # A single continuous stroke across the screen - the thing the app does. - path = [(368, 706), (404, 604), (462, 668), (516, 468), (568, 588), (614, 386), (656, 496)] - draw.line(path, fill=STROKE, width=26, joint="curve") + radius = big * DIAMETER / 2 + centre = big / 2 + draw.ellipse([(centre - radius, centre - radius), (centre + radius, centre + radius)], + fill=CIRCLE) - # The pen, caught mid-stroke at the end of the line. - tip = path[-1] - draw.ellipse([(tip[0] - 62, tip[1] - 62), (tip[0] + 62, tip[1] + 62)], outline=PEN, width=16) - draw.ellipse([(tip[0] - 22, tip[1] - 22), (tip[0] + 22, tip[1] + 22)], fill=PEN) - return image + return image.resize((SIZE, SIZE), Image.LANCZOS) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--out", default="packaging", help="directory for mthreaddraw.png and mthreaddraw.ico") + parser.add_argument("--out", default="packaging", + help="directory for mthreaddraw.png and mthreaddraw.ico") args = parser.parse_args() out = Path(args.out) @@ -69,7 +60,7 @@ def main() -> int: icon = build() icon.save(out / "mthreaddraw.png") - icon.save(out / "mthreaddraw.ico", sizes=Ico_SIZES) + icon.save(out / "mthreaddraw.ico", sizes=ICO_SIZES) print(f"wrote {out / 'mthreaddraw.png'} and {out / 'mthreaddraw.ico'}") return 0 diff --git a/winui/Engine.cs b/winui/Engine.cs index 4ffca73..726d798 100644 --- a/winui/Engine.cs +++ b/winui/Engine.cs @@ -28,6 +28,8 @@ public sealed class Engine : IAsyncDisposable public event Action? StatusChanged; public event Action? ProgressChanged; public event Action? Failed; + public event Action? FrameReady; + public event Action? MirrorLost; private Engine(Process process) { @@ -177,6 +179,12 @@ private void Dispatch(string name, JsonNode message) ProgressChanged?.Invoke(message["done"]?.GetValue() ?? 0, message["total"]?.GetValue() ?? 1); break; + case "frame": + FrameReady?.Invoke(message["path"]?.GetValue() ?? string.Empty); + break; + case "mirror_lost": + MirrorLost?.Invoke(message["error"]?.GetValue() ?? string.Empty); + break; } } diff --git a/winui/MainWindow.xaml b/winui/MainWindow.xaml index d6188f9..5425a66 100644 --- a/winui/MainWindow.xaml +++ b/winui/MainWindow.xaml @@ -8,15 +8,38 @@ mc:Ignorable="d" Title="MThread Draw"> - + + - - + + + + + + + + + + + + BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}" BorderThickness="0,1,0,1"> - -