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 @@ -
+
+
- 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 @@
-
+
- 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.
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