diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0d01db4..019cd78 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -4,23 +4,45 @@ name: Release
#
# git tag v1.1.0 && git push origin v1.1.0
#
-# which builds a standalone Windows .exe, builds the sdist and wheel, and
-# attaches all three to a GitHub release. Publishing to PyPI is opt-in: set the
-# repository variable PUBLISH_TO_PYPI to "true" once trusted publishing is
-# configured at https://pypi.org/manage/account/publishing/ .
+# which builds a Windows .msi, macOS .dmg for both architectures, the sdist and
+# the wheel, and attaches them all to a GitHub release. Each packaged build runs
+# its own self-test first, so an incomplete bundle fails here rather than in
+# somebody's dialog box.
+#
+# The same jobs run on a pull request that touches the packaging, without
+# publishing anything - packaging breaks quietly and only on the platform you
+# are not using.
+#
+# PyPI publishing is opt-in: set the repository variable PUBLISH_TO_PYPI to
+# "true" once trusted publishing is configured at
+# https://pypi.org/manage/account/publishing/ .
on:
push:
tags: ["v*"]
+ pull_request:
+ paths:
+ - "packaging/**"
+ - "tools/build_app.py"
+ - "tools/fetch_platform_tools.py"
+ - "tools/pyinstaller_entry.py"
+ - "tools/make_icon.py"
+ - ".github/workflows/release.yml"
workflow_dispatch:
permissions:
contents: write
+env:
+ # WiX 6 and later require accepting a separate maintenance-fee licence; 5 is
+ # the last version that builds unattended.
+ WIX_VERSION: "5.0.2"
+
jobs:
- windows-exe:
- name: Windows executable
+ windows:
+ name: Windows installer
runs-on: windows-latest
+ timeout-minutes: 30
steps:
- uses: actions/checkout@v4
@@ -28,25 +50,61 @@ jobs:
with:
python-version: "3.12"
- - name: Install
+ - 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 AutoDraw.exe
- run: >
- pyinstaller --onefile --windowed --name AutoDraw
- --collect-all customtkinter
- tools/pyinstaller_entry.py
+ - name: Build app, installer and archive
+ run: python tools/build_app.py --msi --archive
- uses: actions/upload-artifact@v4
with:
- name: AutoDraw-windows
- path: dist/AutoDraw.exe
+ name: windows
+ path: |
+ dist/*.msi
+ dist/*.zip
+
+ macos:
+ name: macOS app (${{ matrix.arch }})
+ runs-on: ${{ matrix.runner }}
+ # A retired runner label does not fail - it queues for ever, and the release
+ # job waits behind it. macos-13 did exactly that.
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: macos-latest
+ arch: arm64
+ - runner: macos-15-intel
+ arch: x86_64
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install build dependencies
+ run: |
+ python -m pip install --upgrade pip
+ python -m pip install -e ".[gui]" pyinstaller
+
+ - name: Build app and disk image
+ run: python tools/build_app.py --dmg
+
+ - uses: actions/upload-artifact@v4
+ with:
+ name: macos-${{ matrix.arch }}
+ path: dist/*.dmg
distribution:
- name: Build distribution
+ name: Python distribution
runs-on: ubuntu-latest
+ timeout-minutes: 15
steps:
- uses: actions/checkout@v4
@@ -67,26 +125,40 @@ jobs:
release:
name: Publish GitHub release
- needs: [windows-exe, distribution]
+ needs: [windows, macos, distribution]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
steps:
- uses: actions/download-artifact@v4
with:
- name: AutoDraw-windows
- path: artifacts
-
- - uses: actions/download-artifact@v4
- with:
- name: dist
path: artifacts
+ merge-multiple: true
- uses: softprops/action-gh-release@v2
with:
files: artifacts/*
generate_release_notes: true
- draft: false
- prerelease: false
+ body: |
+ ### Which file do I want?
+
+ | | |
+ |---|---|
+ | **Windows** | `AutoDraw-*-x64.msi` — installs like any other program, Start Menu entry included. The `.zip` is the same app with no installer. |
+ | **macOS** | `AutoDraw-*-arm64.dmg` for Apple Silicon, `AutoDraw-*-x64.dmg` for Intel. Drag it to Applications. |
+ | **Linux** | Run from source — see the [README](https://github.com/MAXAWER/AutoDraw-Sim#install-variants). |
+
+ Everything is included: Python, OpenCV and **adb** all travel inside
+ the application. There is nothing else to install and no Android SDK
+ to set up.
+
+ The builds are not code-signed, because signing certificates cost
+ money this project does not take. Windows SmartScreen will say
+ "unknown publisher" — *More info* → *Run anyway*. On macOS, the first
+ launch needs a right-click → **Open**, or:
+
+ ```
+ xattr -dr com.apple.quarantine /Applications/AutoDraw.app
+ ```
pypi:
name: Publish to PyPI
diff --git a/.gitignore b/.gitignore
index 056fffa..116b508 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,9 @@
# Build artifacts
/dist/
/build/
-*.spec
+# PyInstaller drops a generated spec beside whatever script it was given; the
+# one we maintain lives in packaging/ and must stay tracked.
+/*.spec
# Python
__pycache__/
@@ -22,6 +24,9 @@ __pycache__/
screen_tmp.png
*.log
+# Downloaded by tools/fetch_platform_tools.py
+/platform-tools/
+
# Environment
.env
venv/
@@ -32,3 +37,6 @@ screen_tmp.png
# Local virtualenvs
.venv/
+
+# Superseded by adbtouch/injector.jar, which ships inside the package
+/injector/*.jar
diff --git a/CHANGELOG.md b/CHANGELOG.md
index de2b948..6041898 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,11 +1,36 @@
# Changelog
+## Unreleased
+
+- **Drawing works on devices that refuse raw touch events** - every recent
+ Pixel, where SELinux denies the shell domain write access to `/dev/input`.
+ `Device.supports_raw_touch` probes for it and the drawing path is picked
+ accordingly, instead of reporting success and doing nothing.
+- **An on-device injector** (`adbtouch/injector.py`, built from `injector/`),
+ run once through `app_process` and fed points over stdin. One process for a
+ whole drawing rather than one per point, which is what makes realistic timing
+ possible at all. Measured on a Pixel 8 Pro: a 136-stroke, 1,679-point drawing
+ in about 19 seconds, against 3 minutes 40 through `input`.
+- **`adbtouch.hand`** simulates hand drawing: rounded corners, a velocity
+ profile, tremor, overshoot and a sensible stroke order.
+- **`adbtouch.paths`** joins the fragments `findContours` returns back into
+ strokes and drops specks - 215 strokes became 136 on the sample image, which
+ is both faster and cleaner.
+- `tools/test_canvas.py`: a drawing canvas served to the device over
+ `adb reverse`, with a calibration pattern, for checking any of this.
+- Speed and hand-drawing controls in the desktop app, with a time estimate.
+
## 1.1.0
Packaging and presentation release; no behavioural changes to drawing or replay.
-- Tagged releases now build a standalone `AutoDraw.exe` and the Python
- distribution automatically, and attach both to the GitHub release.
+- **Installers.** A Windows `.msi` that installs like any other program, and a
+ macOS `.dmg` for both Apple Silicon and Intel, built automatically from a tag.
+- **Self-contained.** The packaged builds carry their own `adb`: no Android SDK,
+ no platform-tools download, no `PATH` to edit. `find_adb` looks inside the
+ bundle first, so an installed AutoDraw behaves the same everywhere.
+- Packaged builds run a self-test before shipping, which imports the whole
+ application and runs the bundled adb.
- Optional PyPI publishing through trusted publishing — see
[docs/RELEASING.md](docs/RELEASING.md).
- `run.bat` and `run.sh`: one-click launchers that create the virtual
diff --git a/README.md b/README.md
index 9141894..9aa5f3a 100644
--- a/README.md
+++ b/README.md
@@ -14,34 +14,38 @@
-
+
- 38 strokes, 950 touch points — the exact path list adbtouch sends to the device, animated in the order it draws them.
+ An ordinary colour PNG in, 215 strokes and 1,542 touch points out — the exact path list adbtouch sends to the device, in the order it draws them.
Rendered from a real run of the vectoriser by tools/make_demo.py; playback speed here is arbitrary.
---
-## In three steps
+## Get it
-```bash
-# 1. Install (Python 3.9+, plus `adb` on your PATH)
-git clone https://github.com/MAXAWER/AutoDraw-Sim.git
-cd AutoDraw-Sim
-pip install -e ".[gui]"
+| | |
+|---|---|
+| **Windows** | [**Download the installer**](https://github.com/MAXAWER/AutoDraw-Sim/releases/latest) — `AutoDraw-x.y.z-x64.msi`. Installs like any other program, Start Menu entry and uninstaller included. |
+| **macOS** | [**Download the app**](https://github.com/MAXAWER/AutoDraw-Sim/releases/latest) — `.dmg` for Apple Silicon or Intel. Drag it to Applications. |
+| **Linux** | Run from source; three commands, [below](#from-source). |
-# 2. Plug the phone in with USB debugging on - or connect over Wi-Fi
-adb connect 192.168.1.42:5555 # optional, wireless
-adbtouch devices # should list your phone
+**Nothing else to install.** Python, OpenCV and **adb** all travel inside the
+application — no Android SDK, no platform-tools download, no `PATH` to edit.
-# 3. Start the app
-autodraw
-```
+The builds are not code-signed, because certificates cost money this project
+does not take. Windows SmartScreen says "unknown publisher" once — *More info* →
+*Run anyway*. macOS wants a right-click → **Open** on the first launch.
+
+## Then, in three steps
-In the app: **Connect device** → **Capture screen** → **Load image** → drag it over
-the phone preview → **START DRAWING**.
+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 AutoDraw** → *Connect device* → *Capture screen* → *Load image* → drag
+ it over the phone preview → **START DRAWING**.
No GUI, no clicking:
@@ -58,12 +62,16 @@ Device().draw_paths([[(100, 200), (400, 200), (400, 600)]])
-Feed it an ordinary picture. Edges are detected with Canny, contours are traced,
-retraced double lines are collapsed, and what is left is a list of polylines.
+Feed it an ordinary picture — colour, no preparation. Edges are detected with
+Canny, contours are traced, retraced double lines are collapsed, and what is left
+is a list of polylines. The scene above is `examples/castle.png`; nothing about it
+was drawn as line art first.
-Line art works best. A photo gives you its edges, which is usually not what you
-wanted — the *Edge sensitivity* and *Detail* sliders are there to tune that, and
-an optional background remover (`rembg`) helps with portraits and product shots.
+Colour is what gets lost: a finger draws one black line, so the output is always
+a line drawing. Illustrations and line art come out closest to the original,
+photographs come out as their edges — the *Edge sensitivity* and *Detail* sliders
+decide how much detail survives, and an optional background remover (`rembg`)
+helps with portraits and product shots.
---
@@ -125,23 +133,39 @@ adbtouch play login.json --speed 2 --repeat 5
Useful for regression passes, for reproducing a bug reliably, or for any
repetitive tapping you would rather not do by hand.
-## Install variants
+
-You need [Android platform-tools](https://developer.android.com/tools/releases/platform-tools)
-(`adb`) on your `PATH`, and USB debugging enabled on the phone:
-Settings → About phone → tap *Build number* seven times → Developer options →
-*USB debugging*.
+## From source
+
+[`run.bat`](run.bat) on Windows and [`run.sh`](run.sh) elsewhere do the whole
+thing: virtual environment, dependencies, and `adb` if the machine has none.
+Otherwise, by hand:
```bash
+git clone https://github.com/MAXAWER/AutoDraw-Sim.git
+cd AutoDraw-Sim
+
pip install -e . # library only - no dependencies at all
pip install -e ".[draw]" # + image vectorisation (OpenCV, NumPy, Pillow)
pip install -e ".[gui]" # + the desktop app
pip install -e ".[bg]" # + rembg background removal
```
-If `adb` is installed somewhere unusual, point `ADB_PATH` at it. On Windows you
-can also just run [`run.bat`](run.bat), which creates a virtual environment and
-starts the app for you; on macOS and Linux, [`run.sh`](run.sh).
+`adb` is found in this order: `ADB_PATH`, the copy inside a packaged build, your
+`PATH`, a `platform-tools` directory beside the working directory, then the usual
+Android SDK locations. If you have none of those:
+
+```bash
+python tools/fetch_platform_tools.py # ~7 MB, straight from Google
+```
+
+To build the packaged application and its installer yourself:
+
+```bash
+pip install pyinstaller
+python tools/build_app.py --msi # Windows, needs `dotnet tool install --global wix --version 5.0.2`
+python tools/build_app.py --dmg # macOS
+```
## Command line
@@ -184,6 +208,22 @@ screen commonly sits on a 4096-step digitizer. Sending display pixels straight t
`sendevent` puts the touch in the wrong place. `adbtouch` reads the real axis
ranges from `getevent -pl` and rescales. Run `adbtouch info` to see yours.
+**Three ways in, picked automatically.** Writing kernel events is fastest, but a
+recent Android refuses it: SELinux denies the shell domain write access to
+`/dev/input` however the file permissions read, so `sendevent` fails per line
+while the script exits cleanly. Where that happens, `adbtouch` streams points to
+a small injector it runs on the device instead - one process for a whole
+drawing, with the wait between points under our control. Failing even that, it
+shells out to `input` once per point, which needs nothing installed and costs
+about 110 ms each. `adbtouch info` says which path your device gets.
+
+**Drawing like a hand.** Timing is what gives a machine away, and the injector is
+what makes timing ours to choose. `adbtouch.hand` rounds corners, varies pen
+speed along a stroke, adds a slow tremor, overshoots stroke ends and reorders
+strokes the way a person would; `Pacing` decides how long each point takes. Set
+`human=0` and it draws as fast as the receiving app can sample - about 6 ms a
+point, since anything faster arrives between frames and is never seen.
+
**Retrace removal.** `findContours` walks the *boundary* of a region, and Canny
turns one pen stroke into two parallel edges — so the naive path traces up one
side of every line and back down the other, drawing everything twice.
@@ -260,36 +300,64 @@ MIT — see [LICENSE](LICENSE).
- **`AutoDraw`** — десктопное приложение поверх неё, для тех, кто предпочитает
кнопки коду.
-## Три шага
+## Установка
+
+| | |
+|---|---|
+| **Windows** | [**Скачать установщик**](https://github.com/MAXAWER/AutoDraw-Sim/releases/latest) — `AutoDraw-x.y.z-x64.msi`. Ставится как обычная программа, с ярлыком в меню «Пуск» и деинсталлятором. |
+| **macOS** | [**Скачать приложение**](https://github.com/MAXAWER/AutoDraw-Sim/releases/latest) — `.dmg` для Apple Silicon или Intel, перетащить в Applications. |
+| **Linux** | Из исходников, три команды — [ниже](#из-исходников). |
+
+**Больше ничего ставить не нужно.** Python, OpenCV и **adb** лежат внутри самого
+приложения: ни Android SDK, ни platform-tools скачивать не придётся, `PATH`
+трогать тоже.
+
+Сборки не подписаны — сертификаты стоят денег, которых у проекта нет. Windows
+один раз скажет «неизвестный издатель»: *Подробнее* → *Выполнить в любом случае*.
+На macOS первый запуск — правой кнопкой → **Открыть**.
+
+## Дальше три шага
+
+1. **Включить отладку по USB**: Настройки → О телефоне → семь раз по «Номер
+ сборки» → Для разработчиков → Отладка по USB.
+2. **Подключить телефон.** Или по Wi-Fi: `adb connect 192.168.1.42:5555`.
+3. **Открыть AutoDraw** → *Connect device* → *Capture screen* → *Load image* →
+ перетащить картинку на превью экрана → **START DRAWING**.
+
+
+
+### Из исходников
```bash
-# 1. Установка (нужен Python 3.9+ и `adb` в PATH)
git clone https://github.com/MAXAWER/AutoDraw-Sim.git
cd AutoDraw-Sim
pip install -e ".[gui]"
-
-# 2. Подключить телефон с включённой отладкой по USB - или по Wi-Fi
-adb connect 192.168.1.42:5555 # по желанию, беспроводное подключение
-adbtouch devices # телефон должен появиться в списке
-
-# 3. Запустить приложение
autodraw
```
-В приложении: **Connect device** → **Capture screen** → **Load image** →
-перетащить картинку на превью экрана → **START DRAWING**.
+Проще запустить [`run.bat`](run.bat) на Windows или [`run.sh`](run.sh) на macOS и
+Linux — они сами создадут виртуальное окружение, поставят зависимости и скачают
+`adb`, если своего на машине нет.
+
+`adb` ищется по порядку: `ADB_PATH`, копия внутри собранного приложения, ваш
+`PATH`, папка `platform-tools` рядом с рабочим каталогом, затем обычные пути
+Android SDK. Если ничего из этого нет:
-На Windows можно вместо этого запустить [`run.bat`](run.bat) — он сам создаст
-виртуальное окружение и откроет приложение. На macOS и Linux — [`run.sh`](run.sh).
+```bash
+python tools/fetch_platform_tools.py # ~7 МБ, прямо от Google
+```
## Как картинка превращается в касания
-Подаёте обычное изображение. Границы находятся детектором Кэнни, контуры
-трассируются, двойные обводки схлопываются — на выходе список ломаных линий.
+Подаёте обычное цветное изображение, ничего готовить заранее не нужно. Границы
+находятся детектором Кэнни, контуры трассируются, двойные обводки схлопываются —
+на выходе список ломаных линий. Пейзаж выше это `examples/castle.png`, он не был
+контурным рисунком.
-Лучше всего работает контурный рисунок. Из фотографии получатся её границы, что
-обычно не то, чего хотелось: ползунки *Edge sensitivity* и *Detail* как раз для
-настройки, а опциональное удаление фона (`rembg`) помогает с портретами и
+Теряется цвет: палец рисует одну чёрную линию, поэтому результат всегда штриховой.
+Ближе всего к оригиналу выходят иллюстрации и контурные рисунки, из фотографии
+получатся её границы. Ползунки *Edge sensitivity* и *Detail* решают, сколько
+деталей останется, а опциональное удаление фона (`rembg`) помогает с портретами и
предметной съёмкой.
## С чем работает
diff --git a/adbtouch/__init__.py b/adbtouch/__init__.py
index 976e254..ba97b86 100644
--- a/adbtouch/__init__.py
+++ b/adbtouch/__init__.py
@@ -22,6 +22,8 @@
from .adb import find_adb
from .device import Device, DeviceInfo, list_devices
+from .hand import HandSettings, reorder_strokes, simulate
+from .injector import InjectorUnavailableError, Pacing, TouchInjector
from .errors import (
AdbCommandError,
AdbNotFoundError,
@@ -57,6 +59,8 @@ def __dir__():
__all__ = [
"__version__",
"Device", "DeviceInfo", "list_devices", "find_adb",
+ "HandSettings", "simulate", "reorder_strokes",
+ "TouchInjector", "Pacing", "InjectorUnavailableError",
"Recorder", "Session", "InputEvent", "replay", "build_replay_script", "parse_getevent_line",
"TouchDevice", "parse_getevent_pl", "pick_touchscreen", "build_stroke_events",
"Vectorizer", "VectorizeSettings",
diff --git a/adbtouch/adb.py b/adbtouch/adb.py
index d1bbad5..1415b0c 100644
--- a/adbtouch/adb.py
+++ b/adbtouch/adb.py
@@ -10,7 +10,7 @@
from .errors import AdbCommandError, AdbNotFoundError
-__all__ = ["find_adb", "run_adb", "popen_adb", "no_window_kwargs"]
+__all__ = ["find_adb", "run_adb", "popen_adb", "no_window_kwargs", "bundled_candidates"]
#: Locations checked after ``PATH``, so the tool works on a machine where the
#: Android SDK was installed but never added to the shell environment.
@@ -34,6 +34,39 @@
}
+def bundled_candidates() -> list[str]:
+ """Paths where a packaged build keeps its own copy of ``adb``.
+
+ The installers ship platform-tools inside the application so that nobody has
+ to install the Android SDK to draw on their phone. PyInstaller unpacks
+ bundled data to ``sys._MEIPASS``; a one-directory build and a macOS ``.app``
+ also keep it beside the executable, the latter under ``Contents/Resources``.
+
+ In a plain source checkout none of this exists and the list comes back
+ empty, which is exactly what should happen - a developer's own ``adb``
+ stays in charge.
+ """
+ if not getattr(sys, "frozen", False):
+ return []
+
+ name = "adb.exe" if sys.platform.startswith("win") else "adb"
+ roots: list[Path] = []
+
+ meipass = getattr(sys, "_MEIPASS", None)
+ if meipass:
+ roots.append(Path(meipass))
+
+ exe_dir = Path(sys.executable).resolve().parent
+ roots.append(exe_dir)
+ roots.append(exe_dir.parent / "Resources")
+
+ candidates = []
+ for root in roots:
+ candidates.append(str(root / "platform-tools" / name))
+ candidates.append(str(root / name))
+ return candidates
+
+
def _platform_key() -> str:
if sys.platform.startswith("win"):
return "win32"
@@ -57,9 +90,14 @@ def no_window_kwargs() -> dict:
def find_adb(explicit: str | None = None) -> str:
"""Return a usable path to ``adb``.
- Resolution order: explicit argument, ``ADB_PATH`` environment variable,
- ``PATH``, a bundled binary next to the current working directory, then the
- default SDK location for the running platform.
+ Resolution order: explicit argument, ``ADB_PATH`` environment variable, the
+ copy shipped inside a packaged build, ``PATH``, a ``platform-tools``
+ directory or a bare binary beside the working directory, then the default
+ SDK location for the running platform.
+
+ The bundled copy outranks ``PATH`` on purpose: an installed AutoDraw should
+ behave the same on every machine, rather than inheriting whichever adb
+ happens to be lying around.
Raises:
AdbNotFoundError: if no candidate exists.
@@ -71,11 +109,16 @@ def find_adb(explicit: str | None = None) -> str:
if env:
candidates.append(env)
+ candidates.extend(bundled_candidates())
+
on_path = shutil.which("adb")
if on_path:
candidates.append(on_path)
+ # A source checkout that ran tools/fetch_platform_tools.py keeps adb here,
+ # so running from the repository needs no system-wide install either.
for local in ("adb", "adb.exe"):
+ candidates.append(str(Path.cwd() / "platform-tools" / local))
candidates.append(str(Path.cwd() / local))
for raw in _FALLBACKS[_platform_key()]:
diff --git a/adbtouch/cli.py b/adbtouch/cli.py
index 0656c22..964f700 100644
--- a/adbtouch/cli.py
+++ b/adbtouch/cli.py
@@ -36,6 +36,15 @@ def _cmd_info(args) -> int:
print(f"pressure : {touch.pressure_range}")
if touch.x_range and (touch.x_range[1] - touch.x_range[0] + 1) != width:
print("note : digitizer resolution differs from the display; coordinates are rescaled.")
+
+ if device.supports_raw_touch:
+ print("raw input : yes - drawing uses kernel events, which is the fast path")
+ else:
+ print("raw input : NO - this device refuses sendevent on /dev/input")
+ print(" Drawing falls back to Android's own input injection, which")
+ print(" works but costs about a tenth of a second per point.")
+ print(" Reading events is a separate permission: recording gestures")
+ print(" may still work here even though drawing raw events does not.")
return 0
diff --git a/adbtouch/device.py b/adbtouch/device.py
index 28c5e9c..0be0407 100644
--- a/adbtouch/device.py
+++ b/adbtouch/device.py
@@ -3,14 +3,18 @@
from __future__ import annotations
import os
+import math
+import random
import re
import tempfile
import uuid
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from typing import Iterable, Sequence
from .adb import find_adb, popen_adb, run_adb
from .errors import DeviceNotConnectedError, TouchDeviceNotFoundError
+from .hand import HandSettings, simulate
+from .injector import InjectorUnavailableError, Pacing, TouchInjector
from .touch import TouchDevice, build_stroke_events, parse_getevent_pl, pick_touchscreen
__all__ = ["DeviceInfo", "Device", "list_devices"]
@@ -40,6 +44,7 @@ def human_state(self) -> str:
}.get(self.state, self.state)
+
def list_devices(adb_path: str | None = None) -> list[DeviceInfo]:
"""Return every device ``adb`` currently knows about, ready or not."""
adb = adb_path or find_adb()
@@ -73,12 +78,24 @@ def __init__(self, serial: str | None = None, adb_path: str | None = None):
self.serial = serial
self._touch_device: TouchDevice | None = None
self._screen_size: tuple[int, int] | None = None
+ self._supports_raw: bool | None = None
+ #: Set when the injector refused to start and the slow path was used.
+ self.injector_error = False
# ------------------------------------------------------------------ basics
def _args(self, extra: Sequence) -> list:
return ["-s", self.serial, *extra]
+ def adb(self, *command, timeout: float | None = 30.0, check: bool = True):
+ """Run an ``adb`` command against this device.
+
+ For the subcommands that are not shell commands - ``reverse``,
+ ``forward``, ``install``, ``push`` - which callers would otherwise have
+ to assemble the ``-s `` themselves for.
+ """
+ return run_adb(self.adb_path, self._args(command), timeout=timeout, check=check)
+
def shell(self, *command, timeout: float | None = 30.0, check: bool = True):
"""Run a shell command on the device and return the completed process."""
return run_adb(self.adb_path, self._args(["shell", *command]), timeout=timeout, check=check)
@@ -172,6 +189,31 @@ def touch_device(self) -> TouchDevice:
self._touch_device = picked
return self._touch_device
+ @property
+ def supports_raw_touch(self) -> bool:
+ """Whether this device lets the shell user write to ``/dev/input``.
+
+ On a Pixel running a recent Android it does not, and not because of file
+ permissions: the node is ``crw-rw---- root input`` and the shell user is
+ in the ``input`` group, but SELinux denies the ``shell`` domain the write
+ anyway. ``sendevent`` then fails per line with "Permission denied" while
+ the script as a whole exits cleanly - which looks exactly like a program
+ that draws nothing for no reason.
+
+ So it is probed once, with a bare ``SYN_REPORT`` that changes nothing
+ even where it does land.
+ """
+ if self._supports_raw is None:
+ try:
+ path = self.touch_device.path
+ except TouchDeviceNotFoundError:
+ self._supports_raw = False
+ return self._supports_raw
+ proc = self.shell("sendevent", path, "0", "0", "0", check=False)
+ output = (proc.stdout or "") + (proc.stderr or "")
+ self._supports_raw = proc.returncode == 0 and "denied" not in output.lower()
+ return self._supports_raw
+
def input_devices(self) -> list[TouchDevice]:
"""Every ``/dev/input`` device the phone exposes, with its axis ranges."""
out = self.shell("getevent", "-pl", timeout=20.0).stdout
@@ -181,19 +223,151 @@ def draw_paths(
self,
paths: Sequence[Sequence[tuple[float, float]]],
*,
+ method: str = "auto",
+ speed: float = 1.0,
+ human: float = 0.0,
+ seed: int | None = None,
+ hand_settings: HandSettings | None = None,
+ pacing: Pacing | None = None,
point_delay_ms: int = 0,
stroke_delay_ms: int = 20,
chunk_size: int = 4000,
progress=None,
should_continue=None,
) -> int:
- """Draw *paths* (display pixel coordinates) using raw touch events.
+ """Draw *paths* (display pixel coordinates) and return the strokes sent.
+
+ Args:
+ method: ``"raw"`` writes kernel events directly. ``"injector"``
+ streams points to a small process running on the device, which
+ is the only path where the time between points is ours to
+ choose - and therefore the only one that can look like a hand.
+ ``"input"`` shells out to the ``input`` command once per point,
+ which needs nothing installed but costs about 110 ms each.
+ ``"auto"`` takes raw where it is allowed, the injector where it
+ is not, and ``input`` if even that fails.
+ speed: Multiplier on the pacing. Above 1 draws faster, below 1
+ slower. It cannot make ``"input"`` quick - that path costs about
+ a tenth of a second per point whatever happens - but it does let
+ either path be slowed to something a person could have done.
+ human: 0 draws the geometry exactly. Above 0 hands the paths to
+ :func:`adbtouch.hand.simulate`, which rounds the corners, varies
+ the pen speed, adds tremor, overshoots the ends and reorders the
+ strokes into the sequence a person would use. 1.0 is a steady
+ hand, 3.0 a careless one. Note that it changes the point count,
+ and so the time the drawing takes.
+ hand_settings: Full control over the simulation; see
+ :class:`adbtouch.hand.HandSettings`.
+ pacing: Timing for the injector path; see
+ :class:`adbtouch.injector.Pacing`. Defaults to hand speed when
+ *human* is set and to as-fast-as-possible when it is not.
+ seed: Fixes the randomness, so a "human" drawing can be repeated.
+ progress: Called with ``(done, total)`` as strokes are sent.
+ should_continue: Polled once per stroke; return False to stop.
+ """
+ if method not in ("auto", "raw", "input", "injector"):
+ raise ValueError(f"unknown drawing method {method!r}")
+ if method == "auto":
+ method = "raw" if self.supports_raw_touch else "injector"
+
+ shaped = simulate(paths, human, seed, hand_settings) if human else paths
+
+ if method == "raw":
+ return self._draw_paths_raw(
+ shaped, speed=speed, point_delay_ms=point_delay_ms,
+ stroke_delay_ms=stroke_delay_ms, chunk_size=chunk_size,
+ progress=progress, should_continue=should_continue,
+ )
+
+ if method == "injector":
+ try:
+ return self._draw_paths_injector(
+ shaped, speed=speed, human=human, seed=seed, pacing=pacing,
+ progress=progress, should_continue=should_continue,
+ )
+ except InjectorUnavailableError:
+ # Nothing about a device guarantees app_process will run a jar
+ # for us. The slow path always works, so say so and take it.
+ self.injector_error = True
+
+ return self._draw_paths_input(
+ shaped, speed=speed, human=human, seed=seed, stroke_delay_ms=stroke_delay_ms,
+ progress=progress, should_continue=should_continue,
+ )
+
+ def _draw_paths_injector(
+ self,
+ paths,
+ *,
+ speed: float = 1.0,
+ human: float = 0.0,
+ seed: int | None = None,
+ pacing: Pacing | None = None,
+ progress=None,
+ should_continue=None,
+ ) -> int:
+ """Draw through the on-device injector, one process for the whole job.
- Returns the number of strokes actually sent. Pass *should_continue* to
- support cancellation; it is polled once per stroke.
+ This is the path where timing is real. Points are streamed to a process
+ that is already running, so the wait between them is whatever we ask for
+ rather than however long it takes to start another one.
"""
width, height = self.screen_size
+ rng = random.Random(seed)
+
+ if pacing is None:
+ pacing = Pacing() if human else Pacing.instant()
+ if speed and speed != 1.0 and pacing.speed < 1e8:
+ pacing = replace(pacing, speed=pacing.speed * speed,
+ travel_speed=pacing.travel_speed * speed)
+
+ def clamp(point):
+ return (max(0.0, min(width - 1.0, float(point[0]))),
+ max(0.0, min(height - 1.0, float(point[1]))))
+
+ sent = 0
+ pen = None
+ with TouchInjector(self) as injector:
+ for index, path in enumerate(paths, start=1):
+ if should_continue is not None and not should_continue():
+ break
+ if len(path) < 2:
+ continue
+
+ points = [clamp(point) for point in path]
+ if pen is not None and pacing.lift_ms:
+ # The hand has to get there. Longer gaps take longer.
+ travel = math.dist(pen, points[0]) / max(pacing.travel_speed, 1.0) * 1000.0
+ injector.pause(pacing.lift_ms + travel)
+
+ injector.stroke(points, pacing, rng)
+ pen = points[-1]
+ sent += 1
+
+ if progress is not None and index % 8 == 0:
+ progress(index, len(paths))
+
+ injector.sync()
+
+ if progress is not None:
+ progress(len(paths), len(paths))
+ return sent
+
+ def _draw_paths_raw(
+ self,
+ paths,
+ *,
+ speed: float = 1.0,
+ point_delay_ms: int = 0,
+ stroke_delay_ms: int = 20,
+ chunk_size: int = 4000,
+ progress=None,
+ should_continue=None,
+ ) -> int:
+ """Draw by writing kernel input events, batched into pushed scripts."""
+ width, height = self.screen_size
device = self.touch_device
+ scale = 1.0 / max(speed, 0.01)
lines: list[str] = []
sent = 0
@@ -206,9 +380,9 @@ def draw_paths(
for etype, code, value in events:
lines.append(f"sendevent {device.path} {etype} {code} {value}")
if point_delay_ms and etype == 0:
- lines.append(f"sleep {point_delay_ms / 1000:.3f}")
+ lines.append(f"sleep {point_delay_ms * scale / 1000:.3f}")
if stroke_delay_ms:
- lines.append(f"sleep {stroke_delay_ms / 1000:.3f}")
+ lines.append(f"sleep {stroke_delay_ms * scale / 1000:.3f}")
sent += 1
if len(lines) >= chunk_size:
@@ -223,6 +397,91 @@ def draw_paths(
progress(len(paths), len(paths))
return sent
+ def _draw_paths_input(
+ self,
+ paths,
+ *,
+ speed: float = 1.0,
+ human: float = 0.0,
+ seed: int | None = None,
+ stroke_delay_ms: int = 20,
+ strokes_per_script: int = 12,
+ progress=None,
+ should_continue=None,
+ ) -> int:
+ """Draw through ``input motionevent``, for devices that refuse raw events.
+
+ Each ``input`` invocation starts a process on the device and costs
+ roughly a tenth of a second, so the commands are batched into pushed
+ scripts exactly as the raw path batches its events. Separate invocations
+ still join into one gesture - the DOWN, the MOVEs and the UP arrive as
+ one continuous stroke - which is what makes a polyline possible here at
+ all, rather than a row of disconnected segments.
+ """
+ width, height = self.screen_size
+ rng = random.Random(seed)
+ scale = 1.0 / max(speed, 0.01)
+ lines: list[str] = []
+ sent = 0
+ pending = 0
+
+ def clamp(value: float, limit: int) -> int:
+ return max(0, min(limit - 1, int(round(value))))
+
+ for index, path in enumerate(paths, start=1):
+ if should_continue is not None and not should_continue():
+ break
+ if len(path) < 2:
+ continue
+
+ points = [(clamp(x, width), clamp(y, height)) for x, y in path]
+ lines.append(f"input motionevent DOWN {points[0][0]} {points[0][1]}")
+ for x, y in points[1:]:
+ lines.append(f"input motionevent MOVE {x} {y}")
+ lines.append(f"input motionevent UP {points[-1][0]} {points[-1][1]}")
+
+ pause = stroke_delay_ms * scale / 1000
+ if human:
+ pause += rng.uniform(0, 0.12 * human)
+ if pause > 0.001:
+ lines.append(f"sleep {pause:.3f}")
+
+ sent += 1
+ pending += 1
+ if pending >= strokes_per_script:
+ self.run_script(lines)
+ lines = []
+ pending = 0
+ if progress is not None:
+ progress(index, len(paths))
+
+ if lines:
+ self.run_script(lines)
+ if progress is not None:
+ progress(len(paths), len(paths))
+ return sent
+
+ def estimate_duration(self, paths, *, method: str = "auto", speed: float = 1.0,
+ human: float = 0.0) -> float:
+ """Roughly how many seconds :meth:`draw_paths` will take.
+
+ Worth showing before starting: on the ``input`` path a detailed picture
+ is minutes of work, and a progress bar that appears to have frozen is
+ indistinguishable from one that has.
+ """
+ if method == "auto":
+ method = "raw" if self.supports_raw_touch else "injector"
+ points = sum(len(path) for path in paths if len(path) >= 2)
+ strokes = sum(1 for path in paths if len(path) >= 2)
+ if method == "input":
+ # Measured on a Pixel 8 Pro: one `input` process costs about 110 ms.
+ return (points + strokes * 2) * 0.11 / max(speed, 0.01)
+ if method == "injector":
+ # Two seconds to start the process, then whatever pacing asks for.
+ per_point = 0.012 if human else 0.0015
+ return 2.0 + (points * per_point + strokes * 0.12 * bool(human)) / max(speed, 0.01)
+ return (strokes * 0.02 + points * 0.0004) / max(speed, 0.01)
+
# ----------------------------------------------------------------- streams
def stream_getevent(self):
diff --git a/adbtouch/hand.py b/adbtouch/hand.py
new file mode 100644
index 0000000..5bda448
--- /dev/null
+++ b/adbtouch/hand.py
@@ -0,0 +1,317 @@
+"""Make a machine-drawn path look like something a hand did.
+
+A traced image gives away its origin immediately, and not because the lines are
+wrong. They are too right: every point sits exactly on the geometry, corners are
+mathematically sharp, points are evenly spaced, every stroke starts precisely
+where the line begins and stops precisely where it ends, and the strokes arrive
+in whatever order the contour finder happened to produce.
+
+A hand does none of that:
+
+- **It rounds corners.** Wrist and finger joints cannot turn a corner in zero
+ time; the pen cuts it.
+- **It speeds up and slows down.** Slow leaving the paper, fast through the
+ middle of a long line, slow again into a tight curve and at the end. On a
+ device where every point costs the same amount of time to send, that velocity
+ profile *is* the spacing between points - so it can be reproduced simply by
+ putting points closer together where the pen would have been slow.
+- **It wobbles**, in a slow drift rather than per-point static, with a much
+ smaller tremor on top.
+- **It overshoots and undershoots.** Strokes start a fraction late and end a
+ fraction long, and shapes do not close exactly.
+- **It draws in a sensible order**, moving to whatever is nearest rather than
+ jumping across the page and back.
+
+:func:`simulate` does all of it, driven by one ``amount`` dial, so callers do
+not have to understand any of the above to use it.
+"""
+
+from __future__ import annotations
+
+import math
+import random
+from dataclasses import dataclass, replace
+
+__all__ = ["HandSettings", "simulate", "reorder_strokes"]
+
+Point = tuple[float, float]
+Path = list[Point]
+
+
+@dataclass(frozen=True)
+class HandSettings:
+ """Every knob :func:`simulate` has, in the units it thinks in.
+
+ The defaults describe a steady hand drawing on a phone-sized screen. Scaling
+ them all together is what ``amount`` does; reach for the individual fields
+ when one aspect specifically is wrong.
+
+ Attributes:
+ spacing: Target distance between sent points, in pixels, at full speed.
+ Smaller is smoother and slower - and on devices without raw touch
+ access, points are the entire cost of a drawing.
+ tremor: Amplitude of the slow wobble along a stroke, in pixels.
+ micro: Amplitude of the per-point noise, in pixels.
+ smoothing: Corner rounding, 0 to 1.
+ corner_radius: The most, in pixels, a corner is allowed to be cut by.
+ ease: How much slower the pen is at the ends of a stroke than in the
+ middle. 0 is a constant speed.
+ curvature_drag: How much a tight curve slows the pen down.
+ overshoot: How far, in pixels, a stroke runs past its end.
+ entry_gap: How far, in pixels, a stroke starts short of its beginning.
+ reorder: Draw strokes nearest-first instead of in contour order.
+ max_points: Hard ceiling per stroke, so a pathological path cannot turn
+ into thousands of touch events.
+ """
+
+ spacing: float = 18.0
+ tremor: float = 1.1
+ micro: float = 0.35
+ smoothing: float = 0.6
+ corner_radius: float = 11.0
+ ease: float = 0.65
+ curvature_drag: float = 0.7
+ overshoot: float = 2.5
+ entry_gap: float = 1.5
+ reorder: bool = True
+ max_points: int = 220
+
+ def scaled(self, amount: float) -> "HandSettings":
+ """Turn the single ``amount`` dial into a full set of settings.
+
+ Not everything scales the same way. Wobble and overshoot scale linearly
+ with how unsteady the hand is; spacing tightens only a little, because
+ halving it doubles what the drawing costs; smoothing and easing saturate,
+ since a hand can only round a corner so far.
+ """
+ return replace(
+ self,
+ spacing=max(6.0, self.spacing / (1 + 0.25 * amount)),
+ tremor=self.tremor * amount,
+ micro=self.micro * amount,
+ smoothing=min(1.0, self.smoothing * (0.6 + 0.4 * amount)),
+ corner_radius=self.corner_radius * (0.7 + 0.3 * amount),
+ ease=min(0.9, self.ease * (0.6 + 0.4 * amount)),
+ overshoot=self.overshoot * amount,
+ entry_gap=self.entry_gap * amount,
+ )
+
+
+# --------------------------------------------------------------------- helpers
+
+
+def _distance(a: Point, b: Point) -> float:
+ return math.hypot(b[0] - a[0], b[1] - a[1])
+
+
+def _chaikin(points: Path, strength: float, radius: float) -> Path:
+ """Round the corners off a polyline.
+
+ One Chaikin pass replaces every corner with the two points a quarter and
+ three quarters along its arms, which is the cheapest convincing way to say
+ "a pen went round this rather than through it". *strength* interpolates
+ between the original corner and the rounded one, so the effect is dialable.
+ """
+ if len(points) < 3 or strength <= 0:
+ return list(points)
+
+ # The cut ratio is the dial. At 0 the new points sit on the old corners and
+ # nothing changes; at 0.25 this is textbook Chaikin. Blending the result
+ # back against the input instead would need the two polylines matched point
+ # for point, and matching them by index skews the shape wherever the
+ # segments are uneven - which is every traced contour.
+ ratio = 0.25 * min(strength, 1.0)
+
+ out: Path = [points[0]]
+ for first, second in zip(points, points[1:]):
+ # Cap the cut in pixels as well as in proportion. A wrist rounds a
+ # corner over a centimetre or so whatever the lines meeting there are
+ # like; a purely proportional cut chamfers a long edge into a hexagon.
+ length = _distance(first, second) or 1.0
+ t = min(ratio, radius / length)
+ out.append((first[0] + (second[0] - first[0]) * t,
+ first[1] + (second[1] - first[1]) * t))
+ out.append((first[0] + (second[0] - first[0]) * (1 - t),
+ first[1] + (second[1] - first[1]) * (1 - t)))
+ out.append(points[-1])
+ return out
+
+
+def _curvature(points: Path) -> list[float]:
+ """Per-vertex turn, from 0 (straight on) to 1 (doubling back)."""
+ values = [0.0] * len(points)
+ for index in range(1, len(points) - 1):
+ before, here, after = points[index - 1], points[index], points[index + 1]
+ ax, ay = here[0] - before[0], here[1] - before[1]
+ bx, by = after[0] - here[0], after[1] - here[1]
+ la, lb = math.hypot(ax, ay), math.hypot(bx, by)
+ if la < 1e-6 or lb < 1e-6:
+ continue
+ cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
+ values[index] = math.acos(cosine) / math.pi
+ return values
+
+
+def _resample(points: Path, settings: HandSettings) -> Path:
+ """Walk the polyline, placing points closer together where a pen is slower.
+
+ This is the velocity profile. Every point sent to the device costs the same
+ time, so the spacing between them is exactly how fast the pen appears to be
+ moving: dense at the ends and through corners, sparse along a long straight.
+ """
+ if len(points) < 2:
+ return list(points)
+
+ segments = [_distance(a, b) for a, b in zip(points, points[1:])]
+ total = sum(segments)
+ if total < 1e-6:
+ return [points[0], points[-1]]
+
+ curvature = _curvature(points)
+ cumulative = [0.0]
+ for length in segments:
+ cumulative.append(cumulative[-1] + length)
+
+ def at(distance: float) -> tuple[Point, float]:
+ """Point at *distance* along the polyline, and the local curvature."""
+ index = 0
+ while index < len(segments) - 1 and cumulative[index + 1] < distance:
+ index += 1
+ span = segments[index] or 1e-6
+ t = max(0.0, min(1.0, (distance - cumulative[index]) / span))
+ a, b = points[index], points[index + 1]
+ turn = max(curvature[index], curvature[min(index + 1, len(curvature) - 1)])
+ return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t), turn
+
+ out: Path = []
+ travelled = 0.0
+ while travelled < total and len(out) < settings.max_points:
+ point, turn = at(travelled)
+ out.append(point)
+
+ # Slow near both ends of the stroke, and through tight curves.
+ u = travelled / total
+ envelope = 1.0 - settings.ease * (1.0 - max(0.0, math.sin(math.pi * u)) ** 0.7)
+ drag = 1.0 / (1.0 + settings.curvature_drag * turn * 6.0)
+ step = settings.spacing * max(0.15, envelope * drag)
+ travelled += max(step, 1.0)
+
+ out.append(points[-1])
+ return out
+
+
+def _tremor(points: Path, settings: HandSettings, rng: random.Random) -> Path:
+ """A slow drift along the stroke, plus a little static on top.
+
+ Independent per-point noise looks like a bad scan, not like a hand. The slow
+ component is what reads as muscle, so it carries most of the amplitude.
+ """
+ if settings.tremor <= 0 and settings.micro <= 0:
+ return points
+
+ phase_x, phase_y = rng.uniform(0, math.tau), rng.uniform(0, math.tau)
+ freq_x = rng.uniform(0.10, 0.30)
+ freq_y = rng.uniform(0.10, 0.30)
+ amplitude = settings.tremor * rng.uniform(0.7, 1.3)
+
+ out: Path = []
+ for index, (x, y) in enumerate(points):
+ out.append((
+ x + math.sin(phase_x + index * freq_x) * amplitude + rng.gauss(0, settings.micro),
+ y + math.cos(phase_y + index * freq_y) * amplitude + rng.gauss(0, settings.micro),
+ ))
+ return out
+
+
+def _ends(points: Path, settings: HandSettings, rng: random.Random) -> Path:
+ """Start a fraction late and finish a fraction long.
+
+ A pen touches down after the line has notionally begun and lifts after it
+ has ended, which is why hand-drawn shapes never quite close and hand-drawn
+ corners always have a little tail.
+ """
+ if len(points) < 2:
+ return points
+
+ out = list(points)
+
+ if settings.entry_gap > 0:
+ (x1, y1), (x2, y2) = out[0], out[1]
+ length = _distance((x1, y1), (x2, y2)) or 1.0
+ gap = min(rng.uniform(0.2, 1.0) * settings.entry_gap, length * 0.4)
+ out[0] = (x1 + (x2 - x1) / length * gap, y1 + (y2 - y1) / length * gap)
+
+ if settings.overshoot > 0:
+ (x1, y1), (x2, y2) = out[-2], out[-1]
+ length = _distance((x1, y1), (x2, y2)) or 1.0
+ extra = rng.uniform(0.3, 1.0) * settings.overshoot
+ out.append((x2 + (x2 - x1) / length * extra, y2 + (y2 - y1) / length * extra))
+
+ return out
+
+
+def reorder_strokes(paths, start: Point = (0.0, 0.0)):
+ """Put the strokes in the order a person would draw them.
+
+ Nearest-neighbour from wherever the pen last was, and a stroke is reversed
+ when its far end is the closer one - nobody lifts the pen, crosses the page
+ and comes back to start a line they were already standing on. It also means
+ the pauses between strokes can follow the distance travelled.
+ """
+ remaining = [list(path) for path in paths if len(path) >= 2]
+ ordered = []
+ pen = start
+
+ while remaining:
+ best_index, best_cost, best_flip = 0, float("inf"), False
+ for index, path in enumerate(remaining):
+ head = _distance(pen, path[0])
+ tail = _distance(pen, path[-1])
+ if head <= tail:
+ cost, flip = head, False
+ else:
+ cost, flip = tail, True
+ if cost < best_cost:
+ best_index, best_cost, best_flip = index, cost, flip
+
+ path = remaining.pop(best_index)
+ if best_flip:
+ path.reverse()
+ ordered.append(path)
+ pen = path[-1]
+
+ return ordered
+
+
+def simulate(paths, amount: float = 1.0, seed: int | None = None,
+ settings: HandSettings | None = None):
+ """Redraw *paths* the way a hand would have.
+
+ Args:
+ amount: 0 returns the paths untouched. 1.0 is a steady hand; 2 to 3 is
+ a careless one. Everything scales from here.
+ seed: Fixes the randomness, so the same picture can be drawn twice.
+ settings: Full control, when the single dial is not enough. ``amount``
+ still scales whatever is passed.
+
+ Returns:
+ A new list of paths. Point counts change - that is the velocity profile
+ - so callers that care about cost should re-estimate afterwards.
+ """
+ if amount <= 0:
+ return paths
+
+ rng = random.Random(seed)
+ config = (settings or HandSettings()).scaled(amount)
+
+ ordered = reorder_strokes(paths) if config.reorder else [list(p) for p in paths if len(p) >= 2]
+
+ out = []
+ for path in ordered:
+ shaped = _chaikin(path, config.smoothing, config.corner_radius)
+ shaped = _resample(shaped, config)
+ shaped = _tremor(shaped, config, rng)
+ shaped = _ends(shaped, config, rng)
+ if len(shaped) >= 2:
+ out.append(shaped)
+ return out
diff --git a/adbtouch/injector.jar b/adbtouch/injector.jar
new file mode 100644
index 0000000..8195a82
Binary files /dev/null and b/adbtouch/injector.jar differ
diff --git a/adbtouch/injector.py b/adbtouch/injector.py
new file mode 100644
index 0000000..80b2b70
--- /dev/null
+++ b/adbtouch/injector.py
@@ -0,0 +1,261 @@
+"""Drive the on-device touch injector.
+
+Why this exists is in ``injector/src/com/adbtouch/Injector.java``: on a phone
+where the shell user cannot write to ``/dev/input``, the only other way in is
+the framework, and the ``input`` command pays a whole process - about 110 ms -
+for every single point. That is not a pacing problem that can be tuned away. A
+sixty-point stroke takes seven seconds, and nothing that takes seven seconds
+looks like a hand.
+
+The injector is started once, and then points are streamed to it over stdin. It
+injects them itself, in microseconds, and sleeps between them for exactly as
+long as we ask. That turns timing from something the transport imposes into
+something we choose, which is what makes both of the interesting cases possible:
+drawing as fast as the screen can take it, and drawing at the speed of a hand.
+"""
+
+from __future__ import annotations
+
+import math
+import random
+import subprocess
+import sys
+import threading
+from dataclasses import dataclass
+from pathlib import Path
+
+from .adb import no_window_kwargs
+from .errors import AdbTouchError
+
+__all__ = ["InjectorUnavailableError", "Pacing", "TouchInjector", "pace_stroke"]
+
+REMOTE_JAR = "/data/local/tmp/adbtouch-injector.jar"
+JAR_NAME = "injector.jar"
+
+
+class InjectorUnavailableError(AdbTouchError):
+ """The device would not run the injector; the caller should fall back."""
+
+
+def bundled_jar() -> Path | None:
+ """The jar shipped with the library, wherever it ended up.
+
+ It sits beside this module in a source checkout and in a wheel. In a
+ PyInstaller build the data files are unpacked somewhere else entirely, so
+ that is checked too.
+ """
+ here = Path(__file__).resolve().parent / JAR_NAME
+ if here.is_file():
+ return here
+
+ meipass = getattr(sys, "_MEIPASS", None)
+ if meipass:
+ for candidate in (Path(meipass) / "adbtouch" / JAR_NAME, Path(meipass) / JAR_NAME):
+ if candidate.is_file():
+ return candidate
+ return None
+
+
+@dataclass(frozen=True)
+class Pacing:
+ """How fast the pen moves, in the units a hand thinks in.
+
+ Attributes:
+ speed: Pen speed along a stroke, in pixels per second. A person drawing
+ on a phone runs somewhere around 600 to 1500.
+ ease: How much slower the pen is at the two ends of a stroke than in the
+ middle, from 0 to 1.
+ curvature_drag: How much a tight curve slows the pen.
+ min_step_ms / max_step_ms: Bounds on the wait between two points. The
+ floor keeps a fast stroke from becoming a flood of events the
+ compositor drops; the ceiling stops a hairpin from stalling.
+ lift_ms: Pen-up time between strokes, before travel is added.
+ travel_speed: How fast the hand crosses the gap between two strokes,
+ in pixels per second.
+ jitter: Proportional randomness on every wait, so the rhythm is not
+ metronomic.
+ """
+
+ speed: float = 1100.0
+ ease: float = 0.55
+ curvature_drag: float = 0.6
+ min_step_ms: float = 4.0
+ max_step_ms: float = 45.0
+ lift_ms: float = 90.0
+ travel_speed: float = 2600.0
+ jitter: float = 0.18
+
+ @classmethod
+ def instant(cls) -> "Pacing":
+ """As fast as anything on the other end can actually see.
+
+ Not zero. Whatever is receiving the touches samples them per frame -
+ a browser canvas, a drawing app, anything - so a stroke delivered in
+ two milliseconds arrives as a press and a release with nothing in
+ between, and the line never appears. Eight milliseconds is a frame at
+ 120 Hz, which is as fast as the fastest phone screen can register a
+ moving finger.
+ """
+ return cls(speed=1e9, ease=0.0, curvature_drag=0.0, min_step_ms=6.0,
+ max_step_ms=6.0, lift_ms=10.0, travel_speed=1e9, jitter=0.0)
+
+
+def pace_stroke(points, pacing: Pacing, rng: random.Random) -> list[float]:
+ """Work out how long to wait after each point of a stroke.
+
+ Distance over speed, with the pen slowing at both ends and through curves -
+ the same velocity profile a hand has, expressed here as time rather than as
+ point spacing, because with the injector the time is ours to set.
+ """
+ if len(points) < 2 or pacing.max_step_ms <= 0:
+ return [0.0] * len(points)
+
+ segments = [math.dist(a, b) for a, b in zip(points, points[1:])]
+ total = sum(segments) or 1.0
+
+ delays: list[float] = []
+ travelled = 0.0
+ for index, length in enumerate(segments):
+ travelled += length
+ u = travelled / total
+
+ # Clamp before the fractional power: rounding can push u a hair
+ # past 1, and a negative base raised to 0.7 is a complex number.
+ envelope = 1.0 - pacing.ease * (1.0 - max(0.0, math.sin(math.pi * u)) ** 0.7)
+
+ turn = 0.0
+ if 0 < index < len(segments) - 1:
+ ax = points[index][0] - points[index - 1][0]
+ ay = points[index][1] - points[index - 1][1]
+ bx = points[index + 1][0] - points[index][0]
+ by = points[index + 1][1] - points[index][1]
+ la, lb = math.hypot(ax, ay), math.hypot(bx, by)
+ if la > 1e-6 and lb > 1e-6:
+ cosine = max(-1.0, min(1.0, (ax * bx + ay * by) / (la * lb)))
+ turn = math.acos(cosine) / math.pi
+
+ speed = pacing.speed * max(0.12, envelope) / (1.0 + pacing.curvature_drag * turn * 5.0)
+ step = length / max(speed, 1.0) * 1000.0
+ if pacing.jitter:
+ step *= 1.0 + rng.uniform(-pacing.jitter, pacing.jitter)
+ delays.append(max(pacing.min_step_ms, min(pacing.max_step_ms, step)))
+
+ delays.append(0.0)
+ return delays
+
+
+class TouchInjector:
+ """A live injector process on the device.
+
+ Use it as a context manager; it starts the process, waits for its READY,
+ and shuts it down again on the way out::
+
+ with TouchInjector(device) as pen:
+ pen.stroke([(100, 200), (400, 200)], Pacing())
+ """
+
+ def __init__(self, device, jar: Path | None = None):
+ self.device = device
+ self.jar = Path(jar) if jar else bundled_jar()
+ self.process: subprocess.Popen | None = None
+ self._lock = threading.Lock()
+
+ # ------------------------------------------------------------------ start
+
+ def start(self) -> None:
+ if self.jar is None or not self.jar.is_file():
+ raise InjectorUnavailableError(
+ "The injector jar is missing. Build it with "
+ "`python tools/build_injector.py`, or draw with method='input'."
+ )
+
+ self.device.adb("push", str(self.jar), REMOTE_JAR, timeout=60.0)
+
+ command = [
+ self.device.adb_path, "-s", self.device.serial, "shell",
+ f"CLASSPATH={REMOTE_JAR} app_process / com.adbtouch.Injector",
+ ]
+ self.process = subprocess.Popen(
+ command,
+ stdin=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ bufsize=1,
+ **no_window_kwargs(),
+ )
+
+ greeting = (self.process.stdout.readline() or "").strip()
+ if greeting != "READY":
+ detail = greeting or "the process produced no output"
+ self.close()
+ raise InjectorUnavailableError(f"The injector would not start: {detail}")
+
+ # ------------------------------------------------------------------ using
+
+ def _write(self, lines) -> None:
+ if self.process is None or self.process.poll() is not None:
+ raise InjectorUnavailableError("The injector is not running.")
+ self.process.stdin.write("\n".join(lines) + "\n")
+ self.process.stdin.flush()
+
+ def stroke(self, points, pacing: Pacing | None = None,
+ rng: random.Random | None = None) -> None:
+ """Draw one stroke, waiting between points as *pacing* says to."""
+ if len(points) < 2:
+ return
+ pacing = pacing or Pacing()
+ rng = rng or random.Random()
+ delays = pace_stroke(points, pacing, rng)
+
+ lines = [f"D {points[0][0]:.1f} {points[0][1]:.1f}"]
+ for (x, y), delay in zip(points[1:], delays):
+ if delay > 0.05:
+ lines.append(f"S {delay:.2f}")
+ lines.append(f"M {x:.1f} {y:.1f}")
+ lines.append(f"U {points[-1][0]:.1f} {points[-1][1]:.1f}")
+
+ with self._lock:
+ self._write(lines)
+
+ def pause(self, millis: float) -> None:
+ if millis > 0.05:
+ with self._lock:
+ self._write([f"S {millis:.2f}"])
+
+ def sync(self, timeout: float = 600.0) -> None:
+ """Block until everything sent so far has been injected.
+
+ The injector reads its commands from a pipe, so a write returning says
+ nothing about the drawing being finished; this asks it to answer once it
+ has worked through the backlog.
+ """
+ with self._lock:
+ self._write(["P"])
+ if self.process is None:
+ return
+ reply = (self.process.stdout.readline() or "").strip()
+ if reply.startswith("ERR"):
+ raise AdbTouchError(f"The injector reported: {reply}")
+
+ # ------------------------------------------------------------------- stop
+
+ def close(self) -> None:
+ process, self.process = self.process, None
+ if process is None:
+ return
+ try:
+ if process.poll() is None and process.stdin:
+ process.stdin.write("Q\n")
+ process.stdin.flush()
+ process.stdin.close()
+ process.wait(timeout=10)
+ except (OSError, ValueError, subprocess.TimeoutExpired):
+ process.kill()
+
+ def __enter__(self) -> "TouchInjector":
+ self.start()
+ return self
+
+ def __exit__(self, *exc_info) -> None:
+ self.close()
diff --git a/adbtouch/paths.py b/adbtouch/paths.py
new file mode 100644
index 0000000..7aaf562
--- /dev/null
+++ b/adbtouch/paths.py
@@ -0,0 +1,97 @@
+"""Tidy up the polylines an edge detector produces, before anything draws them.
+
+``findContours`` does not return strokes, it returns fragments. A single line in
+a picture comes back as a handful of pieces that happen to end where the next
+one begins, plus a scattering of two-point specks from noise. Drawing that
+faithfully is both slower and worse-looking than it needs to be: every fragment
+costs a pen lift, and every speck costs a full press-move-release for a mark
+nobody can see.
+
+Joining the fragments back into strokes is the one change that improves speed
+and quality at the same time.
+"""
+
+from __future__ import annotations
+
+import math
+
+__all__ = ["join_strokes", "drop_specks", "stroke_length", "tidy"]
+
+
+def stroke_length(path) -> float:
+ """Total length along a polyline, in pixels."""
+ return sum(math.dist(a, b) for a, b in zip(path, path[1:]))
+
+
+def drop_specks(paths, min_length: float = 8.0, min_points: int = 2):
+ """Throw away fragments too small to be worth a pen stroke.
+
+ A two-point speck four pixels long is invisible in the result but costs the
+ same press, move and release as a real line - and on a device without raw
+ touch access, that is a tenth of a second each.
+ """
+ return [path for path in paths
+ if len(path) >= min_points and stroke_length(path) >= min_length]
+
+
+def join_strokes(paths, tolerance: float = 6.0):
+ """Reconnect fragments whose ends meet, into longer strokes.
+
+ Greedy and endpoint-only: take a fragment, then keep attaching whichever
+ unused fragment starts (or ends, in which case it is reversed) within
+ *tolerance* of where the current one stopped. That is enough to put a
+ contour back together, because the fragments really do share endpoints -
+ they were split by the tracer, not by the picture.
+
+ Args:
+ tolerance: How far apart two ends can be and still be treated as the
+ same point. A pixel or two covers rounding; much more starts joining
+ lines that were genuinely separate.
+ """
+ fragments = [list(path) for path in paths if len(path) >= 2]
+ if not fragments:
+ return []
+
+ used = [False] * len(fragments)
+ joined = []
+
+ for index, fragment in enumerate(fragments):
+ if used[index]:
+ continue
+ used[index] = True
+ current = fragment
+
+ extended = True
+ while extended:
+ extended = False
+ tail = current[-1]
+ for other, candidate in enumerate(fragments):
+ if used[other]:
+ continue
+ if math.dist(tail, candidate[0]) <= tolerance:
+ current = current + candidate[1:]
+ elif math.dist(tail, candidate[-1]) <= tolerance:
+ current = current + list(reversed(candidate))[1:]
+ else:
+ continue
+ used[other] = True
+ extended = True
+ break
+
+ joined.append(current)
+
+ return joined
+
+
+def tidy(paths, *, join_tolerance: float = 6.0, min_length: float = 8.0):
+ """Join what belongs together, then drop what is left over and too small.
+
+ In that order: a speck sitting between two fragments is worth keeping if it
+ joins them into one stroke, and only worth dropping once it turns out to be
+ on its own.
+ """
+ if join_tolerance > 0:
+ paths = join_strokes(paths, join_tolerance)
+ if min_length > 0:
+ paths = drop_specks(paths, min_length)
+ return paths
diff --git a/adbtouch/vectorize.py b/adbtouch/vectorize.py
index 63f2d5e..1d1ef2f 100644
--- a/adbtouch/vectorize.py
+++ b/adbtouch/vectorize.py
@@ -8,6 +8,8 @@
import numpy as np
from PIL import Image
+from .paths import tidy
+
__all__ = ["VectorizeSettings", "Vectorizer", "dedupe_retrace"]
@@ -59,6 +61,11 @@ class VectorizeSettings:
low_threshold / high_threshold: Canny hysteresis bounds.
epsilon: Douglas-Peucker tolerance; larger means fewer, coarser points.
min_points: Contours shorter than this are discarded as speckle.
+ join_tolerance: Fragments whose ends are this close are joined into one
+ stroke. findContours returns pieces, not strokes, and every extra
+ piece costs a pen lift.
+ min_length: Strokes shorter than this many pixels are dropped, being
+ invisible in the result and not free to draw.
remove_background: Use ``rembg`` if it is installed.
"""
@@ -67,6 +74,8 @@ class VectorizeSettings:
high_threshold: int = 150
epsilon: float = 1.0
min_points: int = 5
+ join_tolerance: float = 6.0
+ min_length: float = 10.0
remove_background: bool = False
@classmethod
@@ -163,6 +172,9 @@ def process(self, settings: VectorizeSettings | None = None):
if len(points) >= 2:
self.paths.append(points)
+ self.paths = tidy(self.paths, join_tolerance=settings.join_tolerance,
+ min_length=settings.min_length)
+
preview = np.full((*self.edges.shape, 3), 255, dtype=np.uint8)
for path in self.paths:
cv2.polylines(preview, [np.array(path, dtype=np.int32)], False, (0, 0, 0), 1)
diff --git a/autodraw/app.py b/autodraw/app.py
index 3901d5a..aa15162 100644
--- a/autodraw/app.py
+++ b/autodraw/app.py
@@ -12,7 +12,7 @@
from tkinter import filedialog, messagebox
from PIL import Image, ImageTk
-from adbtouch import Device, Recorder, Session, VectorizeSettings, Vectorizer, replay
+from adbtouch import Device, Recorder, Session, VectorizeSettings, Vectorizer, replay, simulate
from adbtouch.errors import AdbTouchError
from .geometry import CanvasView, place_paths
@@ -121,6 +121,20 @@ def _build_draw_tab(self) -> None:
self.chk_pointer = ctk.CTkCheckBox(side, text="Show touches while drawing")
self.chk_pointer.pack(fill="x", pady=(0, 12))
+ self.lbl_speed_value = ctk.CTkLabel(side, text="Speed 1.0x", anchor="w")
+ self.lbl_speed_value.pack(fill="x")
+ self.slider_speed = ctk.CTkSlider(side, from_=-2, to=2, number_of_steps=16,
+ command=self._on_speed_setting)
+ self.slider_speed.set(0)
+ self.slider_speed.pack(fill="x", pady=(0, 10))
+
+ self.lbl_human_value = ctk.CTkLabel(side, text="Draw like a hand off", anchor="w")
+ self.lbl_human_value.pack(fill="x")
+ self.slider_human = ctk.CTkSlider(side, from_=0, to=3, number_of_steps=12,
+ command=self._on_human_setting)
+ self.slider_human.set(0)
+ self.slider_human.pack(fill="x", pady=(0, 12))
+
ctk.CTkLabel(side, text="Calibration offset X / Y (px)", anchor="w").pack(fill="x")
offsets = ctk.CTkFrame(side, fg_color="transparent")
offsets.pack(fill="x", pady=(2, 12))
@@ -130,7 +144,11 @@ def _build_draw_tab(self) -> None:
self.entry_offset_y.pack(side="left")
self.lbl_paths = ctk.CTkLabel(side, text="No image loaded", anchor="w", text_color="gray70")
- self.lbl_paths.pack(fill="x", pady=(0, 12))
+ self.lbl_paths.pack(fill="x", pady=(0, 2))
+
+ self.lbl_method = ctk.CTkLabel(side, text="", anchor="w", justify="left",
+ wraplength=250, text_color="gray60")
+ self.lbl_method.pack(fill="x", pady=(0, 12))
self.btn_draw = ctk.CTkButton(side, text="START DRAWING", height=42, fg_color="#2e8b57", command=self.start_drawing)
self.btn_draw.pack(fill="x", pady=(0, 6))
@@ -376,6 +394,63 @@ def _settings(self) -> VectorizeSettings:
remove_background=bool(self.chk_remove_bg.get()),
)
+ @property
+ def draw_speed(self) -> float:
+ """The speed slider, as a multiplier. It is exponential: the useful
+ range runs from a quarter speed to four times, and a linear slider
+ spends most of its travel in places nobody wants."""
+ return float(2 ** self.slider_speed.get())
+
+ def _on_speed_setting(self, _value=None) -> None:
+ self.lbl_speed_value.configure(text=f"Speed {self.draw_speed:.2f}x")
+ self._refresh_estimate()
+
+ def _on_human_setting(self, _value=None) -> None:
+ amount = float(self.slider_human.get())
+ text = "off" if amount <= 0 else f"{amount:.2f}"
+ self.lbl_human_value.configure(text=f"Draw like a hand {text}")
+ self._refresh_estimate()
+
+ def _refresh_estimate(self) -> None:
+ """Say which injection path will be used, and how long it will take.
+
+ On a device that refuses raw touch events the drawing is minutes rather
+ than seconds, and a progress bar creeping along with no explanation is
+ indistinguishable from one that has hung.
+ """
+ paths = self.vectorizer.paths
+ if not paths:
+ self.lbl_method.configure(text="")
+ return
+ human = float(self.slider_human.get())
+ if human > 0:
+ # The simulation changes the point count - that is the velocity
+ # profile - so the estimate has to be made on what will be sent.
+ paths = simulate(paths, human, seed=0)
+ if self.device is None:
+ self.lbl_method.configure(text="Connect a device to estimate the time.")
+ return
+
+ try:
+ raw = self.device.supports_raw_touch
+ seconds = self.device.estimate_duration(
+ paths, method="raw" if raw else "input", speed=self.draw_speed)
+ except AdbTouchError as exc:
+ self.lbl_method.configure(text=str(exc))
+ return
+
+ minutes, secs = divmod(int(seconds + 0.5), 60)
+ span = f"{minutes} min {secs} s" if minutes else f"{secs} s"
+ if raw:
+ self.lbl_method.configure(
+ text=f"Raw touch events, about {span}.", text_color="gray60")
+ else:
+ self.lbl_method.configure(
+ text=(f"This device refuses raw touch events, so drawing goes through "
+ f"Android's own input injection: about {span}. Lower Detail to "
+ f"cut that down."),
+ text_color="#c9a227")
+
def _on_setting_change(self, _value=None) -> None:
if self.vectorizer.original_image is not None:
self.refresh_preview()
@@ -403,6 +478,7 @@ def work():
def apply():
self.preview_image = preview
self.lbl_paths.configure(text=f"{len(paths)} strokes, {points} points")
+ self._refresh_estimate()
if not self._placed:
self.center_image()
else:
@@ -425,6 +501,8 @@ def start_drawing(self) -> None:
scale = self.image_scale
offset = (self._int_entry(self.entry_offset_x), self._int_entry(self.entry_offset_y))
show_touches = bool(self.chk_pointer.get())
+ speed = self.draw_speed
+ human = float(self.slider_human.get())
def work():
self.set_status("Building paths...", "yellow")
@@ -434,12 +512,24 @@ def work():
self.set_status("Nothing to draw at this position", "orange")
return
+ raw = self.device.supports_raw_touch
+ method = "raw" if raw else "input"
+ costed = simulate(placed, human, seed=0) if human > 0 else placed
+ seconds = self.device.estimate_duration(costed, method=method, speed=speed)
+ minutes, secs = divmod(int(seconds + 0.5), 60)
+ span = f"{minutes}m {secs}s" if minutes else f"{secs}s"
+ how = "raw events" if raw else "input injection (slow)"
+
if show_touches:
self.device.set_pointer_location(True)
try:
- self.set_status(f"Drawing {len(placed)} strokes...", "yellow")
+ self.set_status(f"Drawing {len(placed)} strokes via {how}, about {span}...",
+ "yellow")
self.device.draw_paths(
placed,
+ method=method,
+ speed=speed,
+ human=human,
progress=lambda done, total: self.set_progress(done / max(total, 1)),
should_continue=self._should_continue,
)
diff --git a/docs/DEMO.md b/docs/DEMO.md
index 2667f15..9f53456 100644
--- a/docs/DEMO.md
+++ b/docs/DEMO.md
@@ -5,12 +5,14 @@ hand:
```bash
pip install -e ".[draw]"
-python tools/make_sample.py examples/sample.png # the line-art cat
-python tools/make_demo.py # -> docs/demo.gif, docs/pipeline.png
+python tools/make_sample.py examples/castle.png # the colour landscape
+python tools/make_demo.py --sensitivity 3 --detail 8 # -> docs/demo.gif, docs/pipeline.png
```
-`tools/make_demo.py` runs the real `Vectorizer` and animates the paths it
-produces, in the order the device receives them. Point it at any image:
+`tools/make_sample.py` draws the sample scene from scratch, so the repository owns
+its demo art with no licence to track. `tools/make_demo.py` then runs the real
+`Vectorizer` over it and animates the paths it produces, in the order the device
+receives them. Point it at any image:
```bash
python tools/make_demo.py path/to/your.png --out docs
diff --git a/docs/RELEASING.md b/docs/RELEASING.md
index 233ac60..a4fac5e 100644
--- a/docs/RELEASING.md
+++ b/docs/RELEASING.md
@@ -12,11 +12,68 @@
git push origin main --tags
```
-`.github/workflows/release.yml` takes it from there: it builds `AutoDraw.exe` on
-a Windows runner, builds the sdist and wheel, and attaches all three to a GitHub
-release with generated notes.
+`.github/workflows/release.yml` takes it from there:
-Tags must start with `v`. Anything else is ignored by the workflow.
+| Artifact | Built on |
+|---|---|
+| `AutoDraw--x64.msi`, and the same app as a `.zip` | windows-latest |
+| `AutoDraw--arm64.dmg` | macos-latest |
+| `AutoDraw--x64.dmg` | macos-13 |
+| sdist and wheel | ubuntu-latest |
+
+Tags must start with `v`. Anything else is ignored by the workflow. The same
+build jobs also run on any pull request that touches `packaging/` or the build
+scripts, without publishing - packaging breaks quietly, and only on the platform
+you are not developing on.
+
+## Building locally
+
+```bash
+pip install pyinstaller
+python tools/build_app.py --msi # Windows
+python tools/build_app.py --dmg # macOS
+python tools/build_app.py --archive # a plain zip / tar.gz anywhere
+```
+
+Each build runs the packaged app's own self-test (`AutoDraw --selftest`) before
+it is considered finished: it imports the whole application and resolves and
+runs the bundled adb. An incomplete bundle fails the build instead of failing on
+a user's desktop - which is how `adbtouch.vectorize`, imported lazily through
+`__getattr__` and therefore invisible to PyInstaller, was once left out.
+
+### WiX
+
+The Windows installer needs WiX **5**:
+
+```bash
+dotnet tool install --global wix --version 5.0.2
+```
+
+Version 6 and later refuse to build until you accept the Open Source Maintenance
+Fee licence, which is not something a build script can do unattended.
+
+## The bundled adb
+
+The installers carry Google's `adb` inside them, fetched at build time by
+`tools/fetch_platform_tools.py`, so that installing AutoDraw installs
+everything. Two things worth knowing:
+
+- platform-tools is published under the Android Software Development Kit
+ licence, which restricts redistribution. Bundling it is a deliberate choice -
+ the same one scrcpy and similar tools make - and `NOTICE.txt` ships beside the
+ binary for that reason. To ship without it, build with `--no-adb`; the app
+ then falls back to whatever adb the machine has, exactly like a source
+ checkout.
+- The bundled copy outranks `PATH`, so an installed AutoDraw behaves the same on
+ every machine.
+
+## Code signing
+
+Nothing is signed. Windows SmartScreen will warn once, and macOS will refuse the
+first launch until the user right-clicks and chooses Open. Signing needs an
+Authenticode certificate (a few hundred a year) and, for macOS, an Apple
+Developer account plus notarisation; the release notes tell users which button
+to press instead.
## Publishing to PyPI
diff --git a/docs/demo.gif b/docs/demo.gif
index 0c8d533..5649f4b 100644
Binary files a/docs/demo.gif and b/docs/demo.gif differ
diff --git a/docs/pipeline.png b/docs/pipeline.png
index c683c7a..b6c347f 100644
Binary files a/docs/pipeline.png and b/docs/pipeline.png differ
diff --git a/examples/castle.png b/examples/castle.png
new file mode 100644
index 0000000..ef37d7e
Binary files /dev/null and b/examples/castle.png differ
diff --git a/examples/sample.png b/examples/sample.png
deleted file mode 100644
index 1757f2c..0000000
Binary files a/examples/sample.png and /dev/null differ
diff --git a/injector/src/com/adbtouch/Injector.java b/injector/src/com/adbtouch/Injector.java
new file mode 100644
index 0000000..5fed809
--- /dev/null
+++ b/injector/src/com/adbtouch/Injector.java
@@ -0,0 +1,165 @@
+package com.adbtouch;
+
+import android.os.SystemClock;
+import android.view.InputDevice;
+import android.view.InputEvent;
+import android.view.MotionEvent;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.lang.reflect.Method;
+
+/**
+ * A touch injector that stays alive, so a stroke costs one process instead of
+ * one process per point.
+ *
+ *
The problem it solves: on a recent Android the shell user cannot write to
+ * /dev/input - SELinux denies the shell domain, whatever the file mode says -
+ * so drawing has to go through the framework. The `input` command does that,
+ * but every invocation starts a new process, which measures at about 110 ms on
+ * a Pixel 8 Pro. A stroke of sixty points then takes seven seconds, and no
+ * amount of pacing makes a seven-second stroke look like a hand.
+ *
+ *
This is the same trick scrcpy uses: a tiny jar run once through
+ * app_process under the shell uid, which reads points from stdin and injects
+ * them itself. Hidden-API restrictions apply to applications, not to a process
+ * started this way, so InputManager can be reached by reflection. Injection
+ * then costs microseconds, and the timing between points becomes ours to
+ * choose - which is the whole point, because the timing is what a hand is.
+ *
+ *
The protocol is one command per line, chosen to be trivial to generate:
+ *
+ *
+ * D <x> <y> press down and start a stroke
+ * M <x> <y> move
+ * U <x> <y> lift
+ * S <ms> wait, in milliseconds; fractions allowed
+ * P ping - replies OK, so the caller can wait for a batch to land
+ * Q quit
+ *
+ */
+public final class Injector {
+
+ /** InputManager.INJECT_INPUT_EVENT_MODE_ASYNC: do not wait for the app. */
+ private static final int INJECT_ASYNC = 0;
+
+ private static Object manager;
+ private static Method inject;
+
+ /** Events sharing a millisecond get coalesced, so time is forced forward. */
+ private static long lastEventTime;
+
+ private static void connect() throws Exception {
+ // Android 14 moved getInstance to InputManagerGlobal and left the old
+ // entry point behind. Try the new home first, then the old one.
+ try {
+ Class> global = Class.forName("android.hardware.input.InputManagerGlobal");
+ manager = global.getMethod("getInstance").invoke(null);
+ inject = global.getMethod("injectInputEvent", InputEvent.class, int.class);
+ return;
+ } catch (Throwable ignored) {
+ // Fall through to the pre-14 API.
+ }
+ Class> legacy = Class.forName("android.hardware.input.InputManager");
+ manager = legacy.getMethod("getInstance").invoke(null);
+ inject = legacy.getMethod("injectInputEvent", InputEvent.class, int.class);
+ }
+
+ private static void send(int action, float x, float y, long downTime) throws Exception {
+ long now = Math.max(SystemClock.uptimeMillis(), lastEventTime + 1);
+ lastEventTime = now;
+ MotionEvent event = MotionEvent.obtain(
+ downTime, now, action, x, y,
+ 1.0f, // pressure
+ 1.0f, // size
+ 0, // metaState
+ 1.0f, // xPrecision
+ 1.0f, // yPrecision
+ 0, // deviceId
+ 0); // edgeFlags
+ event.setSource(InputDevice.SOURCE_TOUCHSCREEN);
+ try {
+ inject.invoke(manager, event, INJECT_ASYNC);
+ } finally {
+ event.recycle();
+ }
+ }
+
+ private static void pause(double millis) throws InterruptedException {
+ if (millis <= 0) {
+ return;
+ }
+ // Thread.sleep is only good to a millisecond or so, which is finer than
+ // a touchscreen samples anyway; the fractional part is carried by the
+ // nanosecond argument rather than rounded away.
+ long whole = (long) millis;
+ int nanos = (int) ((millis - whole) * 1_000_000);
+ Thread.sleep(whole, nanos);
+ }
+
+ public static void main(String[] args) {
+ try {
+ connect();
+ } catch (Throwable error) {
+ System.out.println("ERR cannot reach InputManager: " + error);
+ System.out.flush();
+ return;
+ }
+
+ System.out.println("READY");
+ System.out.flush();
+
+ long downTime = 0;
+ try (BufferedReader in = new BufferedReader(new InputStreamReader(System.in))) {
+ String line;
+ while ((line = in.readLine()) != null) {
+ line = line.trim();
+ if (line.isEmpty()) {
+ continue;
+ }
+ char command = line.charAt(0);
+ String[] parts = line.split("\\s+");
+
+ try {
+ switch (command) {
+ case 'D':
+ downTime = Math.max(SystemClock.uptimeMillis(), lastEventTime + 1);
+ send(MotionEvent.ACTION_DOWN,
+ Float.parseFloat(parts[1]), Float.parseFloat(parts[2]), downTime);
+ break;
+ case 'M':
+ send(MotionEvent.ACTION_MOVE,
+ Float.parseFloat(parts[1]), Float.parseFloat(parts[2]), downTime);
+ break;
+ case 'U':
+ send(MotionEvent.ACTION_UP,
+ Float.parseFloat(parts[1]), Float.parseFloat(parts[2]), downTime);
+ break;
+ case 'S':
+ pause(Double.parseDouble(parts[1]));
+ break;
+ case 'P':
+ System.out.println("OK");
+ System.out.flush();
+ break;
+ case 'Q':
+ return;
+ default:
+ System.out.println("ERR unknown command: " + line);
+ System.out.flush();
+ break;
+ }
+ } catch (Throwable error) {
+ System.out.println("ERR " + line + ": " + error);
+ System.out.flush();
+ }
+ }
+ } catch (Throwable error) {
+ System.out.println("ERR " + error);
+ System.out.flush();
+ }
+ }
+
+ private Injector() {
+ }
+}
diff --git a/packaging/AutoDraw.spec b/packaging/AutoDraw.spec
new file mode 100644
index 0000000..15adada
--- /dev/null
+++ b/packaging/AutoDraw.spec
@@ -0,0 +1,98 @@
+# -*- mode: python ; coding: utf-8 -*-
+"""PyInstaller build definition for the AutoDraw desktop app.
+
+Used unchanged on all three platforms and both locally and in CI, so a release
+build is the same build a maintainer can reproduce:
+
+ python tools/build_app.py
+
+One directory rather than one file, deliberately. A one-file build unpacks
+itself into a temporary directory on every launch - with OpenCV and adb inside
+that is several seconds of nothing happening, and antivirus software treats the
+self-extraction as suspicious. The installers hide the directory anyway.
+
+adb comes from the platform-tools directory, put there by
+tools/fetch_platform_tools.py.
+If it is absent the build still works; the app then falls back to whatever adb
+the machine has, exactly like a source checkout.
+"""
+
+import sys
+from pathlib import Path
+
+from PyInstaller.utils.hooks import collect_data_files
+
+ROOT = Path(SPECPATH).parent
+IS_WINDOWS = sys.platform.startswith("win")
+IS_MAC = sys.platform == "darwin"
+
+VERSION = "1.1.0"
+
+datas = collect_data_files("customtkinter")
+datas += [(str(ROOT / "adbtouch" / "injector.jar"), "adbtouch")]
+
+platform_tools = ROOT / "platform-tools"
+if platform_tools.is_dir():
+ datas += [(str(item), "platform-tools") for item in platform_tools.iterdir() if item.is_file()]
+else:
+ print("AutoDraw.spec: platform-tools not found - the app will need adb on PATH")
+
+icon = str(ROOT / "packaging" / ("autodraw.ico" if IS_WINDOWS else "autodraw.png"))
+
+analysis = Analysis(
+ [str(ROOT / "tools" / "pyinstaller_entry.py")],
+ pathex=[str(ROOT)],
+ binaries=[],
+ datas=datas,
+ # adbtouch imports its vectoriser through __getattr__ so that the core
+ # library stays dependency-free. PyInstaller cannot see through that, and
+ # leaves the module out unless it is named here.
+ hiddenimports=["customtkinter", "adbtouch.vectorize"],
+ hookspath=[],
+ runtime_hooks=[],
+ # rembg and its onnxruntime are optional and enormous; someone who wants
+ # background removal can install the library from source.
+ excludes=["rembg", "onnxruntime", "matplotlib", "pytest", "tkinter.test"],
+ noarchive=False,
+)
+
+pyz = PYZ(analysis.pure)
+
+exe = EXE(
+ pyz,
+ analysis.scripts,
+ [],
+ exclude_binaries=True,
+ name="AutoDraw",
+ debug=False,
+ strip=False,
+ upx=False,
+ console=False,
+ icon=icon,
+)
+
+collected = COLLECT(
+ exe,
+ analysis.binaries,
+ analysis.datas,
+ strip=False,
+ upx=False,
+ name="AutoDraw",
+)
+
+if IS_MAC:
+ app = BUNDLE(
+ collected,
+ name="AutoDraw.app",
+ icon=icon,
+ bundle_identifier="io.github.maxawer.autodraw",
+ version=VERSION,
+ info_plist={
+ "CFBundleShortVersionString": VERSION,
+ "CFBundleVersion": VERSION,
+ "NSHighResolutionCapable": True,
+ # The app talks to a phone over USB; without this, macOS refuses
+ # the connection instead of prompting.
+ "NSAppleEventsUsageDescription": "AutoDraw drives adb to reach your device.",
+ },
+ )
diff --git a/packaging/AutoDraw.wxs b/packaging/AutoDraw.wxs
new file mode 100644
index 0000000..d2a9df9
--- /dev/null
+++ b/packaging/AutoDraw.wxs
@@ -0,0 +1,83 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packaging/autodraw.ico b/packaging/autodraw.ico
new file mode 100644
index 0000000..a66081b
Binary files /dev/null and b/packaging/autodraw.ico differ
diff --git a/packaging/autodraw.png b/packaging/autodraw.png
new file mode 100644
index 0000000..3166d44
Binary files /dev/null and b/packaging/autodraw.png differ
diff --git a/pyproject.toml b/pyproject.toml
index a28bf14..c1af198 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,3 +54,7 @@ autodraw = "autodraw.app:main"
[tool.setuptools]
packages = ["adbtouch", "autodraw"]
+
+[tool.setuptools.package-data]
+# The on-device injector, built by tools/build_injector.py.
+adbtouch = ["injector.jar"]
diff --git a/run.bat b/run.bat
index 849167e..7111922 100644
--- a/run.bat
+++ b/run.bat
@@ -1,38 +1,47 @@
-@echo off
-rem One-click launcher for Windows: builds a virtual environment on first run,
-rem then starts the AutoDraw desktop app. Double-click it.
-setlocal
-cd /d "%~dp0"
-
-set "PY=venv\Scripts\python.exe"
-
-if not exist "%PY%" (
- echo Creating a virtual environment in venv\ ...
- python -m venv venv
- if errorlevel 1 (
- echo.
- echo Could not create a virtual environment.
- echo Install Python 3.9 or newer from https://www.python.org/downloads/
- echo and tick "Add python.exe to PATH" during setup.
- pause
- exit /b 1
- )
-
- echo Installing AutoDraw and its dependencies. This takes a minute the first time ...
- "%PY%" -m pip install --upgrade pip
- "%PY%" -m pip install -e ".[gui]"
- if errorlevel 1 (
- echo.
- echo Installation failed - see the output above.
- pause
- exit /b 1
- )
-)
-
-"%PY%" -m autodraw
-if errorlevel 1 (
- echo.
- echo AutoDraw exited with an error. Check that adb is installed and that
- echo USB debugging is enabled on the phone.
- pause
-)
+@echo off
+rem One-click launcher for Windows: builds a virtual environment on first run,
+rem then starts the AutoDraw desktop app. Double-click it.
+setlocal
+cd /d "%~dp0"
+
+set "PY=venv\Scripts\python.exe"
+
+if not exist "%PY%" (
+ echo Creating a virtual environment in venv\ ...
+ python -m venv venv
+ if errorlevel 1 (
+ echo.
+ echo Could not create a virtual environment.
+ echo Install Python 3.9 or newer from https://www.python.org/downloads/
+ echo and tick "Add python.exe to PATH" during setup.
+ pause
+ exit /b 1
+ )
+
+ echo Installing AutoDraw and its dependencies. This takes a minute the first time ...
+ "%PY%" -m pip install --upgrade pip
+ "%PY%" -m pip install -e ".[gui]"
+ if errorlevel 1 (
+ echo.
+ echo Installation failed - see the output above.
+ pause
+ exit /b 1
+ )
+)
+
+rem adb, if this machine has none of its own. Downloaded once, from Google.
+where adb >nul 2>&1
+if errorlevel 1 (
+ if not exist "platform-toolsdb.exe" (
+ echo Fetching adb ...
+ "%PY%" toolsetch_platform_tools.py
+ )
+)
+
+"%PY%" -m autodraw
+if errorlevel 1 (
+ echo.
+ echo AutoDraw exited with an error. Check that USB debugging is enabled
+ echo on the phone and that the cable carries data.
+ pause
+)
diff --git a/run.sh b/run.sh
index c6251f5..df2441c 100755
--- a/run.sh
+++ b/run.sh
@@ -18,9 +18,10 @@ if [ ! -x "$PY" ]; then
"$PY" -m pip install -e ".[gui]"
fi
-if ! command -v adb >/dev/null 2>&1 && [ -z "${ADB_PATH:-}" ]; then
- echo "Warning: 'adb' is not on your PATH. Install Android platform-tools, or"
- echo "point ADB_PATH at the binary. https://developer.android.com/tools/releases/platform-tools"
+# adb, if this machine has none of its own. Downloaded once, from Google.
+if ! command -v adb >/dev/null 2>&1 && [ -z "${ADB_PATH:-}" ] && [ ! -x platform-tools/adb ]; then
+ echo "Fetching adb ..."
+ "$PY" tools/fetch_platform_tools.py
fi
exec "$PY" -m autodraw "$@"
diff --git a/tests/test_adb.py b/tests/test_adb.py
index 5c19bbe..71f46c3 100644
--- a/tests/test_adb.py
+++ b/tests/test_adb.py
@@ -7,7 +7,7 @@
from pathlib import Path
from unittest import mock
-from adbtouch.adb import find_adb, run_adb
+from adbtouch.adb import bundled_candidates, find_adb, run_adb
from adbtouch.errors import AdbCommandError, AdbNotFoundError
IS_WINDOWS = sys.platform.startswith("win")
@@ -100,6 +100,47 @@ def test_non_executable_candidate_is_skipped(self):
find_adb(str(plain))
+class BundledAdbTests(unittest.TestCase):
+ """The installers ship their own adb; a source checkout must not pretend to."""
+
+ def test_source_checkout_offers_nothing(self):
+ self.assertEqual(bundled_candidates(), [])
+
+ def test_frozen_build_looks_beside_itself(self):
+ with mock.patch.object(sys, "frozen", True, create=True), \
+ mock.patch.object(sys, "_MEIPASS", r"C:\app" if IS_WINDOWS else "/app", create=True):
+ found = bundled_candidates()
+ name = "adb.exe" if IS_WINDOWS else "adb"
+ self.assertTrue(any(c.endswith(os.path.join("platform-tools", name)) for c in found))
+
+ def test_project_local_platform_tools_is_found(self):
+ """What tools/fetch_platform_tools.py leaves behind in a source checkout.
+
+ The file has to carry adb's real name here, since that is exactly what
+ find_adb looks for; it never has to run.
+ """
+ with tempfile.TemporaryDirectory() as tmp:
+ local = Path(tmp) / "platform-tools"
+ local.mkdir()
+ fake = local / ("adb.exe" if IS_WINDOWS else "adb")
+ fake.write_text("not a real adb")
+ fake.chmod(fake.stat().st_mode | stat.S_IEXEC)
+ with mock.patch.dict(os.environ, {}, clear=True), \
+ mock.patch("adbtouch.adb.shutil.which", return_value=None), \
+ mock.patch("adbtouch.adb.Path.cwd", return_value=Path(tmp)), \
+ mock.patch.dict("adbtouch.adb._FALLBACKS", {"win32": [], "darwin": [], "linux": []}):
+ self.assertEqual(Path(find_adb()).resolve(), fake.resolve())
+
+ def test_bundled_copy_beats_whatever_is_on_path(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ bundled = make_fake_adb(Path(tmp))
+ elsewhere = make_fake_adb(Path(tempfile.mkdtemp()))
+ with mock.patch.dict(os.environ, {}, clear=True), \
+ mock.patch("adbtouch.adb.bundled_candidates", return_value=[str(bundled)]), \
+ mock.patch("adbtouch.adb.shutil.which", return_value=str(elsewhere)):
+ self.assertEqual(Path(find_adb()).resolve(), bundled.resolve())
+
+
class RunAdbTests(unittest.TestCase):
def test_success_returns_output(self):
with tempfile.TemporaryDirectory() as tmp:
diff --git a/tests/test_device.py b/tests/test_device.py
index 6182e2a..3d070c6 100644
--- a/tests/test_device.py
+++ b/tests/test_device.py
@@ -4,10 +4,11 @@
import unittest.mock
from adbtouch.device import Device, DeviceInfo, REMOTE_TMP
+from adbtouch.injector import InjectorUnavailableError
from adbtouch.touch import ABS_MT_POSITION_X, ABS_MT_TRACKING_ID, TouchDevice
-def make_device(screen=(1080, 2400), touch=None):
+def make_device(screen=(1080, 2400), touch=None, raw=True):
"""A Device with the ADB layer stubbed out, so batching logic can be tested."""
device = Device.__new__(Device)
device.adb_path = "/nonexistent/adb"
@@ -22,6 +23,8 @@ def make_device(screen=(1080, 2400), touch=None):
has_slot=True,
has_btn_touch=True,
)
+ device._supports_raw = raw
+ device.injector_error = False
device.scripts = []
device.run_script = lambda lines, **kwargs: device.scripts.append(list(lines))
return device
@@ -158,5 +161,95 @@ def test_temp_file_is_deleted_afterwards(self):
self.assertFalse(os.path.exists(local))
-if __name__ == "__main__":
- unittest.main()
+
+class FallbackDrawingTests(unittest.TestCase):
+ """Devices that refuse raw events - every recent Pixel - still have to draw.
+
+ SELinux denies the shell domain write access to /dev/input there, so
+ sendevent fails per line while the script exits cleanly. The framework's own
+ `input motionevent` injection works, and separate invocations join into one
+ gesture, which is what lets a polyline through.
+ """
+
+ def test_input_path_speaks_motionevent(self):
+ device = make_device(raw=False)
+ device.draw_paths([[(10, 10), (20, 20), (30, 10)]], method="input")
+ body = "\n".join(device.scripts[0])
+ self.assertIn("input motionevent DOWN 10 10", body)
+ self.assertIn("input motionevent MOVE 20 20", body)
+ self.assertIn("input motionevent UP 30 10", body)
+ self.assertNotIn("sendevent", body)
+
+ def test_auto_uses_raw_when_it_is_allowed(self):
+ device = make_device(raw=True)
+ device.draw_paths([[(10, 10), (20, 20)]])
+ self.assertIn("sendevent", "\n".join(device.scripts[0]))
+
+ def test_one_down_and_one_up_per_stroke(self):
+ device = make_device(raw=False)
+ device.draw_paths([[(1, 1), (2, 2)], [(3, 3), (4, 4)]], method="input")
+ body = "\n".join(line for script in device.scripts for line in script)
+ self.assertEqual(body.count("motionevent DOWN"), 2)
+ self.assertEqual(body.count("motionevent UP"), 2)
+
+ def test_coordinates_stay_on_the_screen(self):
+ device = make_device(screen=(1080, 2400), raw=False)
+ device.draw_paths([[(-50, -50), (5000, 9000)]], method="input")
+ body = "\n".join(device.scripts[0])
+ self.assertIn("DOWN 0 0", body)
+ self.assertIn("UP 1079 2399", body)
+
+ def test_unknown_method_is_rejected(self):
+ with self.assertRaises(ValueError):
+ make_device().draw_paths([[(1, 1), (2, 2)]], method="telepathy")
+
+ def test_estimate_warns_that_the_input_path_is_slow(self):
+ device = make_device(raw=False)
+ paths = [[(i, i) for i in range(50)] for _ in range(10)]
+ slow = device.estimate_duration(paths, method="input")
+ fast = device.estimate_duration(paths, method="raw")
+ self.assertGreater(slow, fast * 10)
+ self.assertAlmostEqual(device.estimate_duration(paths, method="input", speed=2),
+ slow / 2, places=3)
+
+ def test_auto_prefers_the_injector_when_raw_is_refused(self):
+ device = make_device(raw=False)
+ started = []
+
+ class Stub:
+ def __init__(self, owner, jar=None):
+ started.append(owner)
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, *exc):
+ return None
+
+ def stroke(self, points, pacing=None, rng=None):
+ started.append(("stroke", len(points)))
+
+ def pause(self, millis):
+ pass
+
+ def sync(self, timeout=600.0):
+ pass
+
+ with unittest.mock.patch("adbtouch.device.TouchInjector", Stub):
+ device.draw_paths([[(1, 1), (2, 2)]])
+ self.assertEqual(started[0], device)
+ self.assertEqual(device.scripts, [])
+
+ def test_a_refusing_injector_drops_through_to_input(self):
+ """Nothing guarantees app_process will run a jar for us. The slow path
+ always works, so an unavailable injector must not be fatal."""
+ device = make_device(raw=False)
+
+ def refuse(*args, **kwargs):
+ raise InjectorUnavailableError("no app_process here")
+
+ with unittest.mock.patch("adbtouch.device.TouchInjector", refuse):
+ device.draw_paths([[(1, 1), (2, 2)]])
+ self.assertIn("input motionevent", chr(10).join(device.scripts[0]))
+ self.assertTrue(device.injector_error)
+
diff --git a/tests/test_hand.py b/tests/test_hand.py
new file mode 100644
index 0000000..95747b3
--- /dev/null
+++ b/tests/test_hand.py
@@ -0,0 +1,103 @@
+import math
+import unittest
+
+from adbtouch.hand import HandSettings, reorder_strokes, simulate
+
+
+def bounds(paths):
+ points = [point for path in paths for point in path]
+ xs = [x for x, _ in points]
+ ys = [y for _, y in points]
+ return min(xs), min(ys), max(xs), max(ys)
+
+
+SQUARE = [[(100.0, 100.0), (400.0, 100.0), (400.0, 400.0), (100.0, 400.0), (100.0, 100.0)]]
+
+
+class SimulateTests(unittest.TestCase):
+ def test_zero_is_a_no_op(self):
+ self.assertIs(simulate(SQUARE, 0), SQUARE)
+
+ def test_the_drawing_stays_where_it_was(self):
+ """Character, not position. A hand wobbles; it does not move the paper."""
+ shaped = simulate(SQUARE, 1.0, seed=1)
+ before = bounds(SQUARE)
+ after = bounds(shaped)
+ for original, drawn in zip(before, after):
+ self.assertLess(abs(original - drawn), 12)
+
+ def test_nothing_lands_exactly_on_the_geometry(self):
+ shaped = simulate([[(0.0, 0.0), (200.0, 0.0)]], 1.0, seed=2)[0]
+ off_axis = [y for _, y in shaped if abs(y) > 0.05]
+ self.assertGreater(len(off_axis), len(shaped) // 2)
+
+ def test_the_seed_makes_it_repeatable(self):
+ self.assertEqual(simulate(SQUARE, 1.0, seed=9), simulate(SQUARE, 1.0, seed=9))
+ self.assertNotEqual(simulate(SQUARE, 1.0, seed=9), simulate(SQUARE, 1.0, seed=10))
+
+ def test_points_are_denser_where_the_pen_is_slower(self):
+ """The velocity profile: every point costs the same to send, so spacing
+ is speed. The ends of a stroke should be sampled more finely than the
+ middle of a long straight."""
+ shaped = simulate([[(0.0, 0.0), (600.0, 0.0)]], 1.0, seed=3,
+ settings=HandSettings(tremor=0, micro=0, smoothing=0, overshoot=0,
+ entry_gap=0))[0]
+ gaps = [math.dist(a, b) for a, b in zip(shaped, shaped[1:])]
+ third = max(len(gaps) // 3, 1)
+ self.assertLess(sum(gaps[:third]) / third, sum(gaps[third:-third or None]) / max(len(gaps) - 2 * third, 1))
+
+ def test_a_hard_ceiling_on_points_per_stroke(self):
+ huge = [[(float(i), float(i % 7)) for i in range(0, 40000, 3)]]
+ shaped = simulate(huge, 1.0, seed=4, settings=HandSettings(max_points=50))
+ self.assertLessEqual(len(shaped[0]), 52)
+
+ def test_more_amount_wobbles_more(self):
+ def spread(amount):
+ shaped = simulate([[(0.0, 0.0), (300.0, 0.0)]], amount, seed=6)[0]
+ return max(abs(y) for _, y in shaped)
+
+ self.assertGreater(spread(3.0), spread(0.5))
+
+ def test_degenerate_paths_are_dropped_not_crashed_on(self):
+ self.assertEqual(simulate([[(1.0, 1.0)], []], 1.0, seed=1), [])
+
+ def test_a_zero_length_stroke_survives(self):
+ shaped = simulate([[(5.0, 5.0), (5.0, 5.0)]], 1.0, seed=1)
+ self.assertLessEqual(len(shaped), 1)
+
+
+class ReorderTests(unittest.TestCase):
+ def test_nearest_stroke_comes_first(self):
+ far = [(900.0, 900.0), (950.0, 900.0)]
+ near = [(10.0, 10.0), (60.0, 10.0)]
+ self.assertEqual(reorder_strokes([far, near])[0], near)
+
+ def test_a_stroke_is_reversed_when_its_far_end_is_nearer(self):
+ ordered = reorder_strokes([[(500.0, 0.0), (10.0, 0.0)]], start=(0.0, 0.0))
+ self.assertEqual(ordered[0][0], (10.0, 0.0))
+
+ def test_short_paths_are_dropped(self):
+ self.assertEqual(reorder_strokes([[(1.0, 1.0)]]), [])
+
+ def test_every_stroke_is_kept(self):
+ paths = [[(float(i), 0.0), (float(i) + 5, 0.0)] for i in range(0, 100, 10)]
+ self.assertEqual(len(reorder_strokes(paths)), len(paths))
+
+
+class SettingsTests(unittest.TestCase):
+ def test_amount_scales_the_wobble_but_barely_the_spacing(self):
+ """Spacing is what a drawing costs, so it must not blow up with amount."""
+ base = HandSettings()
+ gentle = base.scaled(0.5)
+ wild = base.scaled(3.0)
+ self.assertGreater(wild.tremor, gentle.tremor * 4)
+ self.assertGreater(wild.spacing, base.spacing * 0.4)
+
+ def test_smoothing_and_ease_saturate(self):
+ extreme = HandSettings().scaled(50)
+ self.assertLessEqual(extreme.smoothing, 1.0)
+ self.assertLessEqual(extreme.ease, 0.9)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_paths.py b/tests/test_paths.py
new file mode 100644
index 0000000..18cb517
--- /dev/null
+++ b/tests/test_paths.py
@@ -0,0 +1,71 @@
+import unittest
+
+from adbtouch.paths import drop_specks, join_strokes, stroke_length, tidy
+
+
+class LengthTests(unittest.TestCase):
+ def test_measures_along_the_line(self):
+ self.assertAlmostEqual(stroke_length([(0, 0), (3, 4), (3, 14)]), 15.0)
+
+ def test_a_single_point_has_no_length(self):
+ self.assertEqual(stroke_length([(5, 5)]), 0.0)
+
+
+class JoinTests(unittest.TestCase):
+ def test_fragments_that_meet_become_one_stroke(self):
+ joined = join_strokes([[(0, 0), (10, 0)], [(10, 0), (20, 0)]])
+ self.assertEqual(joined, [[(0, 0), (10, 0), (20, 0)]])
+
+ def test_a_fragment_is_reversed_to_fit(self):
+ joined = join_strokes([[(0, 0), (10, 0)], [(20, 0), (10, 0)]])
+ self.assertEqual(joined, [[(0, 0), (10, 0), (20, 0)]])
+
+ def test_a_gap_within_tolerance_still_joins(self):
+ self.assertEqual(len(join_strokes([[(0, 0), (10, 0)], [(13, 0), (20, 0)]], tolerance=4)), 1)
+
+ def test_a_gap_beyond_tolerance_does_not(self):
+ self.assertEqual(len(join_strokes([[(0, 0), (10, 0)], [(30, 0), (40, 0)]], tolerance=4)), 2)
+
+ def test_a_chain_of_fragments_collapses_to_one(self):
+ pieces = [[(x, 0), (x + 10, 0)] for x in range(0, 100, 10)]
+ joined = join_strokes(pieces)
+ self.assertEqual(len(joined), 1)
+ self.assertEqual(joined[0][0], (0, 0))
+ self.assertEqual(joined[0][-1], (100, 0))
+
+ def test_no_point_is_invented_or_lost(self):
+ pieces = [[(0, 0), (10, 0)], [(10, 0), (10, 10)], [(50, 50), (60, 60)]]
+ joined = join_strokes(pieces)
+ self.assertEqual(sum(len(path) for path in joined), 5)
+
+ def test_degenerate_fragments_are_ignored(self):
+ self.assertEqual(join_strokes([[(1, 1)], []]), [])
+
+
+class SpeckTests(unittest.TestCase):
+ def test_short_fragments_go(self):
+ self.assertEqual(drop_specks([[(0, 0), (3, 0)]], min_length=8), [])
+
+ def test_long_ones_stay(self):
+ path = [(0, 0), (30, 0)]
+ self.assertEqual(drop_specks([path], min_length=8), [path])
+
+
+class TidyTests(unittest.TestCase):
+ def test_a_speck_that_bridges_two_fragments_is_kept(self):
+ """Order matters: join first, drop after. A four-pixel piece is worth
+ keeping when it is the join between two real strokes."""
+ pieces = [[(0, 0), (40, 0)], [(40, 0), (44, 0)], [(44, 0), (90, 0)]]
+ self.assertEqual(len(tidy(pieces, join_tolerance=2, min_length=10)), 1)
+
+ def test_a_speck_on_its_own_is_dropped(self):
+ pieces = [[(0, 0), (40, 0)], [(500, 500), (503, 500)]]
+ self.assertEqual(len(tidy(pieces, join_tolerance=2, min_length=10)), 1)
+
+ def test_both_steps_can_be_turned_off(self):
+ pieces = [[(0, 0), (2, 0)], [(2, 0), (4, 0)]]
+ self.assertEqual(tidy(pieces, join_tolerance=0, min_length=0), pieces)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tools/build_app.py b/tools/build_app.py
new file mode 100644
index 0000000..7264710
--- /dev/null
+++ b/tools/build_app.py
@@ -0,0 +1,205 @@
+"""Build the packaged AutoDraw application, and its installer.
+
+One command, the same one CI runs, so a release can be reproduced locally:
+
+ python tools/build_app.py # app only
+ python tools/build_app.py --msi # + Windows installer (needs WiX 5)
+ python tools/build_app.py --dmg # + macOS disk image (macOS only)
+ python tools/build_app.py --archive # + a .tar.gz / .zip of the app folder
+
+The result is self-contained: Python, OpenCV, Tk and adb all travel inside it,
+so the person installing AutoDraw installs nothing else.
+
+Requires PyInstaller, and for --msi the `wix` dotnet tool:
+
+ dotnet tool install --global wix
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import platform
+import shutil
+import stat
+import subprocess
+import sys
+import tarfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+DIST = ROOT / "dist"
+BUILD = ROOT / "build"
+SPEC = ROOT / "packaging" / "AutoDraw.spec"
+
+IS_WINDOWS = sys.platform.startswith("win")
+IS_MAC = sys.platform == "darwin"
+
+
+def version() -> str:
+ """Read the version from the package, so nothing carries a second copy."""
+ scope: dict = {}
+ for line in (ROOT / "autodraw" / "__init__.py").read_text(encoding="utf-8").splitlines():
+ if line.startswith("__version__"):
+ exec(line, scope) # noqa: S102 - a single literal assignment
+ return scope["__version__"]
+ raise SystemExit("could not find __version__ in autodraw/__init__.py")
+
+
+def arch_tag() -> str:
+ machine = platform.machine().lower()
+ return {"amd64": "x64", "x86_64": "x64", "arm64": "arm64", "aarch64": "arm64"}.get(machine, machine)
+
+
+def run(*command: str, cwd: Path | None = None) -> None:
+ print("+", " ".join(str(part) for part in command))
+ subprocess.run([str(part) for part in command], cwd=str(cwd or ROOT), check=True)
+
+
+def ensure_icon() -> None:
+ icon = ROOT / "packaging" / ("autodraw.ico" if IS_WINDOWS else "autodraw.png")
+ if not icon.is_file():
+ run(sys.executable, ROOT / "tools" / "make_icon.py")
+
+
+def ensure_platform_tools(skip: bool) -> None:
+ if skip:
+ print("skipping adb: the app will fall back to whatever the machine has")
+ return
+ run(sys.executable, ROOT / "tools" / "fetch_platform_tools.py", "--out", ROOT / "platform-tools")
+
+
+def build_app() -> Path:
+ app = DIST / ("AutoDraw.app" if IS_MAC else "AutoDraw")
+ # Clear the previous app, not the whole dist directory: dist also holds the
+ # installers, and on Windows something as ordinary as an open Explorer
+ # window keeps a handle on the folder itself.
+ if app.exists():
+ shutil.rmtree(app, ignore_errors=True)
+ run(sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", SPEC)
+
+ if not app.exists():
+ raise SystemExit(f"PyInstaller did not produce {app}")
+
+ fix_adb_permissions(app)
+ selftest(app)
+ return app
+
+
+def selftest(app: Path) -> None:
+ """Run the packaged app's own completeness check before anything ships.
+
+ Without this a broken build looks exactly like a working one until someone
+ double-clicks it and gets a traceback in a dialog box.
+ """
+ executable = app / "Contents" / "MacOS" / "AutoDraw" if IS_MAC else app / (
+ "AutoDraw.exe" if IS_WINDOWS else "AutoDraw")
+ report = BUILD / "selftest.txt"
+ report.unlink(missing_ok=True)
+
+ print("running the packaged app's self-test")
+ result = subprocess.run([str(executable), "--selftest", str(report)], cwd=str(ROOT))
+ text = report.read_text(encoding="utf-8") if report.is_file() else "(no report written)"
+ print(text)
+ if result.returncode != 0:
+ raise SystemExit("the packaged build is incomplete - see the self-test output above")
+
+
+def fix_adb_permissions(app: Path) -> None:
+ """Restore the execute bit on the bundled adb.
+
+ PyInstaller copies data files without their mode, so on macOS and Linux the
+ adb it ships arrives unrunnable - and the failure surfaces much later, as a
+ permission error from inside the app.
+ """
+ if IS_WINDOWS:
+ return
+ for adb in app.rglob("platform-tools/adb"):
+ adb.chmod(adb.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+ print(f" chmod +x {adb.relative_to(app.parent)}")
+
+
+def build_msi(app: Path) -> Path:
+ if not IS_WINDOWS:
+ raise SystemExit("--msi only works on Windows")
+ if shutil.which("wix") is None:
+ raise SystemExit("wix not found. Install it with: dotnet tool install --global wix")
+
+ out = DIST / f"AutoDraw-{version()}-{arch_tag()}.msi"
+ run(
+ "wix", "build", ROOT / "packaging" / "AutoDraw.wxs",
+ "-arch", "x64",
+ "-d", f"Version={version()}",
+ "-d", f"SourceDir={app}",
+ "-d", f"IconFile={ROOT / 'packaging' / 'autodraw.ico'}",
+ "-o", out,
+ )
+ size_mb = out.stat().st_size // 1024 // 1024
+ # WiX only warns when it harvests nothing - a relative SourceDir resolves
+ # against the .wxs file, not the working directory - and happily writes a
+ # perfectly valid installer that installs no application at all.
+ if size_mb < 20:
+ raise SystemExit(f"{out.name} is only {size_mb} MB; the file harvest found nothing")
+ print(f"built {out} ({size_mb} MB)")
+ return out
+
+
+def build_dmg(app: Path) -> Path:
+ if not IS_MAC:
+ raise SystemExit("--dmg only works on macOS")
+
+ out = DIST / f"AutoDraw-{version()}-{arch_tag()}.dmg"
+ staging = BUILD / "dmg"
+ if staging.exists():
+ shutil.rmtree(staging)
+ staging.mkdir(parents=True)
+ shutil.copytree(app, staging / app.name, symlinks=True)
+ # The customary drag-to-install layout.
+ os.symlink("/Applications", staging / "Applications")
+
+ if out.exists():
+ out.unlink()
+ run("hdiutil", "create", "-volname", "AutoDraw", "-srcfolder", staging,
+ "-ov", "-format", "UDZO", out)
+ print(f"built {out} ({out.stat().st_size // 1024 // 1024} MB)")
+ return out
+
+
+def build_archive(app: Path) -> Path:
+ """A plain archive of the app folder, for people who do not want an installer."""
+ stem = f"AutoDraw-{version()}-{platform.system().lower()}-{arch_tag()}"
+ if IS_WINDOWS:
+ out = Path(shutil.make_archive(str(DIST / stem), "zip", root_dir=app.parent, base_dir=app.name))
+ else:
+ out = DIST / f"{stem}.tar.gz"
+ with tarfile.open(out, "w:gz") as archive:
+ archive.add(app, arcname=app.name)
+ print(f"built {out} ({out.stat().st_size // 1024 // 1024} MB)")
+ return out
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("--msi", action="store_true", help="also build the Windows installer")
+ parser.add_argument("--dmg", action="store_true", help="also build the macOS disk image")
+ parser.add_argument("--archive", action="store_true", help="also build a zip / tar.gz")
+ parser.add_argument("--no-adb", action="store_true", help="do not bundle platform-tools")
+ args = parser.parse_args()
+
+ ensure_icon()
+ ensure_platform_tools(args.no_adb)
+ app = build_app()
+ print(f"built {app}")
+
+ if args.msi:
+ build_msi(app)
+ if args.dmg:
+ build_dmg(app)
+ if args.archive:
+ build_archive(app)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/build_injector.py b/tools/build_injector.py
new file mode 100644
index 0000000..e388248
--- /dev/null
+++ b/tools/build_injector.py
@@ -0,0 +1,122 @@
+"""Compile the on-device touch injector into a jar.
+
+Needs a JDK and an Android SDK with build-tools and one platform installed; it
+finds them itself if ANDROID_HOME or the usual per-platform location is set.
+
+ python tools/build_injector.py
+
+The result is adbtouch/injector.jar - a zip containing classes.dex, which is all
+app_process needs. It lives inside the package so that a wheel and a frozen
+build both carry it, and it is small enough (a few kilobytes) to be committed,
+so nobody needs an Android SDK just to use the library.
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import subprocess
+import sys
+import zipfile
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+SOURCE = ROOT / "injector" / "src" / "com" / "adbtouch" / "Injector.java"
+OUT_JAR = ROOT / "adbtouch" / "injector.jar"
+WORK = ROOT / "build" / "injector"
+
+IS_WINDOWS = sys.platform.startswith("win")
+
+
+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)
+
+ candidates = [
+ Path.home() / "AppData" / "Local" / "Android" / "Sdk",
+ Path.home() / "Library" / "Android" / "sdk",
+ Path.home() / "Android" / "Sdk",
+ ]
+ for candidate in candidates:
+ if candidate.is_dir():
+ return candidate
+ raise SystemExit("Could not find an Android SDK. Set ANDROID_HOME.")
+
+
+def newest(directory: Path) -> Path:
+ """The highest-numbered entry, so a machine with several keeps up."""
+ entries = sorted((item for item in directory.iterdir() if item.is_dir()),
+ key=lambda item: item.name)
+ if not entries:
+ raise SystemExit(f"{directory} is empty")
+ return entries[-1]
+
+
+def find_javac() -> str:
+ java_home = os.environ.get("JAVA_HOME")
+ if java_home:
+ candidate = Path(java_home) / "bin" / ("javac.exe" if IS_WINDOWS else "javac")
+ if candidate.is_file():
+ return str(candidate)
+ found = shutil.which("javac")
+ if not found:
+ raise SystemExit("Could not find javac. Install a JDK or set JAVA_HOME.")
+ return found
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--sdk", help="Android SDK root; found automatically otherwise")
+ args = parser.parse_args()
+
+ sdk = Path(args.sdk) if args.sdk else find_sdk()
+ android_jar = newest(sdk / "platforms") / "android.jar"
+ build_tools = newest(sdk / "build-tools")
+ d8 = build_tools / ("d8.bat" if IS_WINDOWS else "d8")
+ if not android_jar.is_file():
+ raise SystemExit(f"{android_jar} is missing")
+ if not d8.is_file():
+ raise SystemExit(f"{d8} is missing")
+
+ print(f"sdk : {sdk}")
+ print(f"android.jar : {android_jar}")
+ print(f"d8 : {d8}")
+
+ if WORK.exists():
+ shutil.rmtree(WORK)
+ classes = WORK / "classes"
+ classes.mkdir(parents=True)
+
+ # Source and target 8: the injector runs on whatever ART the phone has, and
+ # nothing here needs a newer language level.
+ subprocess.run(
+ [find_javac(), "-source", "8", "-target", "8", "-nowarn",
+ "-bootclasspath", str(android_jar), "-classpath", str(android_jar),
+ "-d", str(classes), str(SOURCE)],
+ check=True,
+ )
+
+ class_files = sorted(str(path) for path in classes.rglob("*.class"))
+ subprocess.run(
+ [str(d8), "--release", "--min-api", "24", "--lib", str(android_jar),
+ "--output", str(WORK), *class_files],
+ check=True,
+ )
+
+ dex = WORK / "classes.dex"
+ if not dex.is_file():
+ raise SystemExit("d8 produced no classes.dex")
+
+ OUT_JAR.parent.mkdir(parents=True, exist_ok=True)
+ with zipfile.ZipFile(OUT_JAR, "w", zipfile.ZIP_DEFLATED) as jar:
+ jar.write(dex, "classes.dex")
+
+ print(f"built {OUT_JAR} ({OUT_JAR.stat().st_size} bytes)")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/canvas.html b/tools/canvas.html
new file mode 100644
index 0000000..b85c708
--- /dev/null
+++ b/tools/canvas.html
@@ -0,0 +1,143 @@
+
+
+
+
+
+AutoDraw test canvas
+
+
+
+
+
+
+ strokes 0
+ points 0
+ last -
+
+
+
+
+
+
diff --git a/tools/fetch_platform_tools.py b/tools/fetch_platform_tools.py
new file mode 100644
index 0000000..71bca89
--- /dev/null
+++ b/tools/fetch_platform_tools.py
@@ -0,0 +1,107 @@
+"""Download Google's platform-tools so the installers can ship their own adb.
+
+The whole point of the packaged builds is that a person who just wants to draw
+on their phone should not have to find, download and PATH the Android SDK first.
+That means the application carries adb inside it.
+
+Note on redistribution: platform-tools is published by Google under the Android
+Software Development Kit licence, and bundling it inside a release is a choice
+the project makes deliberately - see docs/RELEASING.md. NOTICE.txt is kept
+alongside the binary for that reason; do not drop it.
+
+ python tools/fetch_platform_tools.py
+"""
+
+from __future__ import annotations
+
+import argparse
+import os
+import shutil
+import stat
+import sys
+import tempfile
+import urllib.request
+import zipfile
+from pathlib import Path
+
+URLS = {
+ "windows": "https://dl.google.com/android/repository/platform-tools-latest-windows.zip",
+ "darwin": "https://dl.google.com/android/repository/platform-tools-latest-darwin.zip",
+ "linux": "https://dl.google.com/android/repository/platform-tools-latest-linux.zip",
+}
+
+#: adb and what it needs to run, plus the licence notice. Everything else in the
+#: archive - fastboot, etc1tool, the systrace scripts - is dead weight in a
+#: drawing app, and platform-tools is 10 MB of which adb is about 5.
+KEEP = {
+ "adb", "adb.exe",
+ "AdbWinApi.dll", "AdbWinUsbApi.dll",
+ "NOTICE.txt", "source.properties",
+}
+
+
+def platform_key(explicit: str | None = None) -> str:
+ if explicit:
+ return explicit
+ if sys.platform.startswith("win"):
+ return "windows"
+ if sys.platform == "darwin":
+ return "darwin"
+ return "linux"
+
+
+def fetch(key: str, out: Path, *, slim: bool = True) -> Path:
+ url = URLS[key]
+ out.mkdir(parents=True, exist_ok=True)
+
+ with tempfile.TemporaryDirectory() as tmp:
+ archive = Path(tmp) / "platform-tools.zip"
+ print(f"downloading {url}")
+ with urllib.request.urlopen(url) as response, archive.open("wb") as handle:
+ shutil.copyfileobj(response, handle)
+ print(f" {archive.stat().st_size // 1024 // 1024} MB")
+
+ with zipfile.ZipFile(archive) as zipped:
+ for member in zipped.namelist():
+ name = Path(member).name
+ if not name or member.endswith("/"):
+ continue
+ if slim and name not in KEEP:
+ continue
+ target = out / name
+ with zipped.open(member) as source, target.open("wb") as handle:
+ shutil.copyfileobj(source, handle)
+
+ adb = out / ("adb.exe" if key == "windows" else "adb")
+ if not adb.is_file():
+ raise SystemExit(f"platform-tools archive did not contain {adb.name}")
+ if key != "windows":
+ # Zip archives carry a mode, but zipfile does not apply it on extract.
+ adb.chmod(adb.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
+
+ print(f"extracted {len(list(out.iterdir()))} files to {out}")
+ return adb
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--out", default="platform-tools",
+ help="where to put adb; the default is where find_adb looks")
+ parser.add_argument("--platform", choices=sorted(URLS), help="defaults to the running platform")
+ parser.add_argument("--full", action="store_true", help="keep the whole archive, not just adb")
+ parser.add_argument("--force", action="store_true", help="re-download even if it is already there")
+ args = parser.parse_args()
+
+ out = Path(args.out)
+ key = platform_key(args.platform)
+ adb = out / ("adb.exe" if key == "windows" else "adb")
+ if adb.is_file() and not args.force:
+ print(f"{adb} is already there; pass --force to replace it")
+ return 0
+
+ fetch(key, out, slim=not args.full)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/make_demo.py b/tools/make_demo.py
index f438f84..373bfc1 100644
--- a/tools/make_demo.py
+++ b/tools/make_demo.py
@@ -2,8 +2,11 @@
Nothing here is mocked: the strokes animated in the GIF are exactly the paths
:class:`adbtouch.Vectorizer` hands to the device, in the order the device draws
-them. What the GIF cannot show is the phone itself - see docs/DEMO.md for
-recording the real thing with scrcpy.
+them. The source panel is the unmodified colour image that produced them, which
+is the whole point - you feed it an ordinary picture, not prepared line art.
+
+What the GIF cannot show is the phone itself; see docs/DEMO.md for recording the
+real thing with scrcpy.
python tools/make_demo.py [image] [--out docs]
"""
@@ -17,24 +20,34 @@
from adbtouch.vectorize import VectorizeSettings, Vectorizer
-# Phone mock-up, in pixels of the finished GIF.
-PHONE_W, PHONE_H = 340, 640
-BEZEL = 14
-CORNER = 34
-SCREEN_MARGIN = 18
+CANVAS_W, CANVAS_H = 990, 450
+MARGIN = 24
+
+SOURCE_BOX = (MARGIN, 92, 420, 300) # x, y, w, h
+# The phone lies on its side: the sample is a landscape scene, and a wide
+# picture on a portrait screen would sit in a thin band in the middle.
+PHONE_X, PHONE_Y = 500, 66
+PHONE_W, PHONE_H = 466, 262
+BEZEL = 12
+CORNER = 26
+SCREEN_MARGIN = 14
-FRAMES = 52
-HOLD_FRAMES = 14
-FRAME_MS = 70
+FRAMES = 46
+HOLD_FRAMES = 12
+FRAME_MS = 80
+PAGE = (255, 255, 255)
BODY = (28, 30, 34)
SCREEN = (255, 255, 255)
INK = (17, 17, 17)
PEN = (226, 62, 62)
+LABEL = (110, 114, 122)
+RULE = (216, 219, 224)
-def _font(size: int) -> ImageFont.ImageFont:
- for name in ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf"):
+def _font(size: int, bold: bool = False) -> ImageFont.ImageFont:
+ names = ("segoeuib.ttf", "arialbd.ttf") if bold else ("segoeui.ttf", "arial.ttf", "DejaVuSans.ttf")
+ for name in names:
try:
return ImageFont.truetype(name, size)
except OSError:
@@ -63,58 +76,83 @@ def fit_paths(paths, box):
return [[(x * scale + dx, y * scale + dy) for x, y in path] for path in paths]
-def phone_shell() -> Image.Image:
- shell = Image.new("RGB", (PHONE_W, PHONE_H), (255, 255, 255))
- draw = ImageDraw.Draw(shell)
- draw.rounded_rectangle([(0, 0), (PHONE_W - 1, PHONE_H - 1)], CORNER, fill=BODY)
+def backdrop(source: Image.Image, stroke_count: int) -> Image.Image:
+ """Everything that does not change between frames: source panel and phone."""
+ canvas = Image.new("RGB", (CANVAS_W, CANVAS_H), PAGE)
+ draw = ImageDraw.Draw(canvas)
+
+ bx, by, bw, bh = SOURCE_BOX
+ thumb = source.copy()
+ thumb.thumbnail((bw, bh), Image.LANCZOS)
+ tx = bx + (bw - thumb.width) // 2
+ ty = by + (bh - thumb.height) // 2
+ canvas.paste(thumb, (tx, ty))
+ draw.rectangle([(tx - 1, ty - 1), (tx + thumb.width, ty + thumb.height)], outline=RULE)
+
+ draw.text((bx, by - 48), "an ordinary colour image", font=_font(21, bold=True), fill=BODY)
+ draw.text((bx, by - 22), f"{source.width}x{source.height} PNG, nothing prepared by hand",
+ font=_font(15), fill=LABEL)
+ draw.text((bx, ty + thumb.height + 14),
+ f"{stroke_count} strokes traced from it", font=_font(15), fill=LABEL)
+
+ # A long arrow across the gap, so the two panels read as one sentence.
+ y = PHONE_Y + PHONE_H // 2
+ draw.line([(bx + bw + 14, y), (PHONE_X - 24, y)], fill=RULE, width=3)
+ draw.polygon([(PHONE_X - 24, y), (PHONE_X - 40, y - 9), (PHONE_X - 40, y + 9)], fill=RULE)
+
+ draw.rounded_rectangle(
+ [(PHONE_X, PHONE_Y), (PHONE_X + PHONE_W - 1, PHONE_Y + PHONE_H - 1)], CORNER, fill=BODY
+ )
draw.rounded_rectangle(
- [(BEZEL, BEZEL), (PHONE_W - BEZEL - 1, PHONE_H - BEZEL - 1)], CORNER - 8, fill=SCREEN
+ [(PHONE_X + BEZEL, PHONE_Y + BEZEL),
+ (PHONE_X + PHONE_W - BEZEL - 1, PHONE_Y + PHONE_H - BEZEL - 1)],
+ CORNER - 8, fill=SCREEN,
)
draw.rounded_rectangle(
- [(PHONE_W // 2 - 26, 5), (PHONE_W // 2 + 26, 11)], 3, fill=(60, 62, 68)
+ [(PHONE_X + PHONE_W - 11, PHONE_Y + PHONE_H // 2 - 24),
+ (PHONE_X + PHONE_W - 5, PHONE_Y + PHONE_H // 2 + 24)],
+ 3, fill=(60, 62, 68),
)
- return shell
+ return canvas
-def render_gif(paths, out: Path) -> None:
- shell = phone_shell()
+def render_gif(paths, source: Image.Image, out: Path) -> None:
+ shell = backdrop(source, len(paths))
screen_box = (
- BEZEL + SCREEN_MARGIN,
- BEZEL + SCREEN_MARGIN + 24,
+ PHONE_X + BEZEL + SCREEN_MARGIN,
+ PHONE_Y + BEZEL + SCREEN_MARGIN,
PHONE_W - 2 * (BEZEL + SCREEN_MARGIN),
- PHONE_H - 2 * (BEZEL + SCREEN_MARGIN) - 48,
+ PHONE_H - 2 * (BEZEL + SCREEN_MARGIN),
)
placed = fit_paths(paths, screen_box)
total = sum(len(p) for p in placed)
- font = _font(15)
+ font = _font(14)
+ caption_y = PHONE_Y + PHONE_H + 26
frames = []
for index in range(FRAMES + HOLD_FRAMES):
- drawn_target = total if index >= FRAMES else int(total * (index + 1) / FRAMES)
+ target = total if index >= FRAMES else int(total * (index + 1) / FRAMES)
frame = shell.copy()
draw = ImageDraw.Draw(frame)
drawn = 0
pen = None
for path in placed:
- if drawn >= drawn_target:
+ if drawn >= target:
break
- take = min(len(path), drawn_target - drawn)
+ take = min(len(path), target - drawn)
if take >= 2:
- draw.line(path[:take], fill=INK, width=2, joint="curve")
+ draw.line(path[:take], fill=INK, width=1, joint="curve")
pen = path[take - 1]
drawn += len(path)
if index < FRAMES and pen is not None:
- draw.ellipse(
- [(pen[0] - 7, pen[1] - 7), (pen[0] + 7, pen[1] + 7)], outline=PEN, width=2
- )
+ draw.ellipse([(pen[0] - 6, pen[1] - 6), (pen[0] + 6, pen[1] + 6)], outline=PEN, width=2)
draw.ellipse([(pen[0] - 2, pen[1] - 2), (pen[0] + 2, pen[1] + 2)], fill=PEN)
- caption = "done" if index >= FRAMES else f"{drawn_target}/{total} points"
- draw.text((PHONE_W // 2, PHONE_H - BEZEL - 26), caption, font=font,
- fill=(120, 124, 130), anchor="mm")
- frames.append(frame.convert("P", palette=Image.ADAPTIVE, colors=32))
+ caption = "done" if index >= FRAMES else f"{target:,} / {total:,} touch points"
+ draw.text((PHONE_X + PHONE_W // 2, caption_y), caption, font=font, fill=LABEL, anchor="mm")
+ frames.append(frame.convert("P", palette=Image.ADAPTIVE, colors=64))
frames[0].save(
out,
@@ -130,23 +168,22 @@ def render_gif(paths, out: Path) -> None:
def render_pipeline(source, edges, preview, out: Path) -> None:
"""Three panels: what you feed in, what Canny sees, what gets drawn."""
- panel = 320
- gap, top = 22, 34
- canvas = Image.new("RGB", (panel * 3 + gap * 4, panel + top + gap), (255, 255, 255))
+ panel = 400
+ gap, top = 24, 36
+ canvas = Image.new("RGB", (panel * 3 + gap * 4, panel + top + gap), PAGE)
draw = ImageDraw.Draw(canvas)
- font = _font(15)
+ font = _font(16)
edges_img = Image.fromarray(255 - edges).convert("RGB")
- for index, (image, label) in enumerate(
- ((source, "1. your image"), (edges_img, "2. detected edges"), (preview, "3. strokes sent to the phone"))
- ):
+ labels = ("1. your image, as it is", "2. edges the vectoriser finds", "3. strokes sent to the phone")
+ for index, (image, label) in enumerate(zip((source, edges_img, preview), labels)):
thumb = image.copy()
thumb.thumbnail((panel, panel), Image.LANCZOS)
x = gap + index * (panel + gap) + (panel - thumb.width) // 2
y = top + (panel - thumb.height) // 2
canvas.paste(thumb, (x, y))
- draw.rectangle([(x - 1, y - 1), (x + thumb.width, y + thumb.height)], outline=(220, 222, 226))
- draw.text((gap + index * (panel + gap), 12), label, font=font, fill=(90, 94, 100))
+ draw.rectangle([(x - 1, y - 1), (x + thumb.width, y + thumb.height)], outline=RULE)
+ draw.text((gap + index * (panel + gap), 12), label, font=font, fill=LABEL)
canvas.save(out)
print(f"wrote {out} ({out.stat().st_size // 1024} KB)")
@@ -154,20 +191,24 @@ def render_pipeline(source, edges, preview, out: Path) -> None:
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("image", nargs="?", default="examples/sample.png")
+ parser.add_argument("image", nargs="?", default="examples/castle.png")
parser.add_argument("--out", default="docs", help="directory for demo.gif and pipeline.png")
+ parser.add_argument("--detail", type=float, default=7.0, help="1-10, as in the app")
+ parser.add_argument("--sensitivity", type=float, default=5.0, help="1-10, as in the app")
args = parser.parse_args()
out_dir = Path(args.out)
out_dir.mkdir(parents=True, exist_ok=True)
+ settings = VectorizeSettings.from_sliders(args.sensitivity, args.detail, target_width=900)
vectorizer = Vectorizer()
vectorizer.load_image(args.image)
- preview, paths = vectorizer.process(VectorizeSettings(target_width=800))
+ preview, paths = vectorizer.process(settings)
print(f"{len(paths)} strokes, {sum(len(p) for p in paths)} points")
- render_gif(paths, out_dir / "demo.gif")
- render_pipeline(Image.open(args.image), vectorizer.edges, preview, out_dir / "pipeline.png")
+ source = Image.open(args.image).convert("RGB")
+ render_gif(paths, source, out_dir / "demo.gif")
+ render_pipeline(source, vectorizer.edges, preview, out_dir / "pipeline.png")
return 0
diff --git a/tools/make_icon.py b/tools/make_icon.py
new file mode 100644
index 0000000..15675ad
--- /dev/null
+++ b/tools/make_icon.py
@@ -0,0 +1,78 @@
+"""Draw the application icon.
+
+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
+"""
+
+from __future__ import annotations
+
+import argparse
+from pathlib import Path
+
+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)
+
+
+def lerp(a, b, t):
+ return tuple(int(round(x + (y - x) * t)) for x, y in zip(a, b))
+
+
+def build() -> Image.Image:
+ image = Image.new("RGBA", (SIZE, SIZE), (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)
+
+ # 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")
+
+ # 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
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--out", default="packaging", help="directory for autodraw.png and autodraw.ico")
+ args = parser.parse_args()
+
+ out = Path(args.out)
+ out.mkdir(parents=True, exist_ok=True)
+ icon = build()
+
+ icon.save(out / "autodraw.png")
+ icon.save(out / "autodraw.ico", sizes=Ico_SIZES)
+ print(f"wrote {out / 'autodraw.png'} and {out / 'autodraw.ico'}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/tools/make_sample.py b/tools/make_sample.py
index e937066..ef185e2 100644
--- a/tools/make_sample.py
+++ b/tools/make_sample.py
@@ -1,66 +1,285 @@
-"""Draw the line-art sample used by the README demo.
+"""Draw the sample image used by the README demo.
-Kept as a script rather than a checked-in binary blob nobody can edit: the
-sample is deliberately thin black lines on white, which is the input shape the
-vectoriser is happiest with, and regenerating it is one command.
+Deliberately an ordinary colour illustration rather than clean black line art:
+the point of the demo is that you feed AutoDraw 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.
- python tools/make_sample.py examples/sample.png
+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
+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)
+
-SIZE = 900
-INK = (0, 0, 0)
-WIDTH = 4
+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 draw_cat(draw: ImageDraw.ImageDraw) -> None:
- # Ears first, so the head outline drawn over them hides the join.
- draw.line([(252, 300), (296, 138), (404, 246)], fill=INK, width=WIDTH, joint="curve")
- draw.line([(648, 300), (604, 138), (496, 246)], fill=INK, width=WIDTH, joint="curve")
- draw.line([(300, 268), (322, 190), (372, 250)], fill=INK, width=WIDTH, joint="curve")
- draw.line([(600, 268), (578, 190), (528, 250)], fill=INK, width=WIDTH, 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)
- draw.ellipse([(210, 220), (690, 640)], outline=INK, width=WIDTH)
+ 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)
- # Eyes: outline plus a filled pupil, which Canny turns into a clean ring.
- draw.ellipse([(320, 380), (410, 452)], outline=INK, width=WIDTH)
- draw.ellipse([(490, 380), (580, 452)], outline=INK, width=WIDTH)
- draw.ellipse([(352, 400), (378, 434)], fill=INK)
- draw.ellipse([(522, 400), (548, 434)], fill=INK)
- draw.polygon([(428, 486), (472, 486), (450, 514)], outline=INK, width=WIDTH)
- draw.arc([(390, 500), (450, 560)], start=0, end=90, fill=INK, width=WIDTH)
- draw.arc([(450, 500), (510, 560)], start=90, end=180, fill=INK, width=WIDTH)
+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)
- for y0, y1 in ((470, 442), (500, 500), (530, 558)):
- draw.line([(300, y0), (140, y1)], fill=INK, width=WIDTH)
- draw.line([(600, y0), (760, y1)], fill=INK, width=WIDTH)
- # Body: two flanks running from under the head down to the floor line.
- draw.line([(268, 560), (214, 700), (206, 812)], fill=INK, width=WIDTH, joint="curve")
- draw.line([(632, 560), (686, 700), (694, 812)], fill=INK, width=WIDTH, joint="curve")
- draw.line([(206, 812), (694, 812)], fill=INK, width=WIDTH)
+def build() -> Image.Image:
+ image = Image.new("RGB", (W, H), SKY_TOP)
+ draw = ImageDraw.Draw(image)
- draw.ellipse([(236, 760), (356, 820)], outline=INK, width=WIDTH)
- draw.ellipse([(544, 760), (664, 820)], outline=INK, width=WIDTH)
+ sky(draw)
+ sun(draw)
+ clouds(draw)
+ mountains(draw)
+ water(draw)
+ cliff(draw)
+ castle(draw)
+ banks(draw)
+ bridge(draw)
+ pines(draw)
+ birds(draw)
- # Tail, curling out to the right.
- draw.arc([(660, 640), (860, 840)], start=90, end=300, fill=INK, width=WIDTH)
+ # 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/sample.png")
- image = Image.new("RGB", (SIZE, SIZE), "white")
- draw_cat(ImageDraw.Draw(image))
+ out = Path(argv[1]) if len(argv) > 1 else Path("examples/castle.png")
out.parent.mkdir(parents=True, exist_ok=True)
- image.save(out)
- print(f"wrote {out} ({SIZE}x{SIZE})")
+ build().save(out)
+ print(f"wrote {out} ({W}x{H})")
return 0
diff --git a/tools/pyinstaller_entry.py b/tools/pyinstaller_entry.py
index 0be2273..d76b30d 100644
--- a/tools/pyinstaller_entry.py
+++ b/tools/pyinstaller_entry.py
@@ -1,9 +1,84 @@
"""Entry point for the PyInstaller build.
``autodraw/__main__.py`` uses a relative import, which PyInstaller cannot use as
-a top-level script. This module is the same two lines with an absolute import.
+a top-level script. This module is the same call with an absolute import, plus a
+self-test the build pipeline runs before shipping anything.
"""
-from autodraw.app import main
+from __future__ import annotations
-raise SystemExit(main() or 0)
+import sys
+from pathlib import Path
+
+
+def selftest(report: str | None = None) -> int:
+ """Check that the frozen build is actually complete, and say why if not.
+
+ A packaged build breaks in ways a source checkout never does. ``adbtouch``
+ imports its vectoriser lazily, through ``__getattr__``, so PyInstaller's
+ static analysis cannot see it and silently leaves it out; the bundled adb
+ can arrive without its execute bit; a data file can go missing. Every one of
+ those surfaces as a dialog box on the user's first launch.
+
+ Run by tools/build_app.py straight after the build, and by CI, so the
+ failure lands in a log instead.
+ """
+ lines: list[str] = []
+ ok = True
+
+ try:
+ import cv2
+ import numpy
+ import PIL
+
+ lines.append(f"opencv {cv2.__version__}, numpy {numpy.__version__}, pillow {PIL.__version__}")
+ except Exception as exc: # pragma: no cover - only reachable in a broken build
+ ok = False
+ lines.append(f"imaging stack missing: {exc!r}")
+
+ try:
+ import customtkinter
+
+ from adbtouch import Device, Recorder, Session, replay # noqa: F401
+ from adbtouch.vectorize import VectorizeSettings, Vectorizer # noqa: F401
+ from autodraw.app import App, main # noqa: F401
+
+ lines.append(f"customtkinter {customtkinter.__version__}, adbtouch and autodraw import cleanly")
+ except Exception as exc:
+ ok = False
+ lines.append(f"application imports failed: {exc!r}")
+
+ try:
+ from adbtouch.adb import bundled_candidates, find_adb, run_adb
+
+ path = find_adb()
+ bundled = any(Path(candidate) == Path(path) for candidate in bundled_candidates())
+ version = run_adb(path, ["version"], timeout=20.0).stdout.splitlines()[0]
+ lines.append(f"adb: {path}")
+ lines.append(f" {version} ({'bundled' if bundled else 'found on this machine'})")
+ if not bundled:
+ lines.append(" warning: this build is not carrying its own adb")
+ except Exception as exc:
+ ok = False
+ lines.append(f"adb unusable: {exc}")
+
+ text = "\n".join(lines)
+ print(text)
+ if report:
+ Path(report).write_text(text + f"\n\nresult: {'ok' if ok else 'FAILED'}\n", encoding="utf-8")
+ return 0 if ok else 1
+
+
+def run() -> int:
+ if "--selftest" in sys.argv:
+ index = sys.argv.index("--selftest")
+ report = sys.argv[index + 1] if len(sys.argv) > index + 1 else None
+ return selftest(report)
+
+ from autodraw.app import main
+
+ return main() or 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(run())
diff --git a/tools/test_canvas.py b/tools/test_canvas.py
new file mode 100644
index 0000000..c2d8385
--- /dev/null
+++ b/tools/test_canvas.py
@@ -0,0 +1,181 @@
+"""Put a drawing canvas on the device, for checking that touches land correctly.
+
+The canvas is an ordinary web page served from this machine; the device reaches
+it through ``adb reverse``, so it works the same on a phone over USB, a phone
+over wireless ADB, and an emulator, and needs no app installed.
+
+ 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
+
+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
+project keeps having to ask about unfamiliar hardware: are the coordinates
+arriving where they were aimed, or is the digitizer scaled, swapped or mirrored?
+"""
+
+from __future__ import annotations
+
+import argparse
+import functools
+import http.server
+import socketserver
+import sys
+import threading
+import time
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+from adbtouch import Device, list_devices # noqa: E402
+from adbtouch.errors import AdbTouchError # noqa: E402
+
+HERE = Path(__file__).resolve().parent
+DEFAULT_PORT = 8731
+
+
+def serve(port: int) -> socketserver.TCPServer:
+ handler = functools.partial(http.server.SimpleHTTPRequestHandler, directory=str(HERE))
+ handler.log_message = lambda *args, **kwargs: None # type: ignore[assignment]
+
+ server = socketserver.ThreadingTCPServer(("127.0.0.1", port), handler)
+ server.daemon_threads = True
+ threading.Thread(target=server.serve_forever, daemon=True).start()
+ return server
+
+
+def fit_to_screen(paths, width, height, *, margin=0.06):
+ """Scale paths to the screen, keeping their aspect ratio and a clear margin."""
+ 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)
+ src_w = max(max_x - min_x, 1)
+ src_h = max(max_y - min_y, 1)
+
+ pad_x, pad_y = width * margin, height * margin
+ scale = min((width - 2 * pad_x) / src_w, (height - 2 * pad_y) / src_h)
+ dx = pad_x + (width - 2 * pad_x - src_w * scale) / 2 - min_x * scale
+ dy = pad_y + (height - 2 * pad_y - src_h * scale) / 2 - min_y * scale
+ 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]]]:
+ """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.
+ """
+ left, right = round(width * margin), round(width * (1 - margin))
+ top, bottom = round(height * margin), round(height * (1 - margin))
+ cx, cy = width // 2, height // 2
+ arm = min(width, height) // 12
+
+ def line(x1, y1, x2, y2, steps=40):
+ return [(round(x1 + (x2 - x1) * i / steps), round(y1 + (y2 - y1) * i / steps))
+ for i in range(steps + 1)]
+
+ return [
+ line(left, top, right, top),
+ line(right, top, right, bottom),
+ line(right, bottom, left, bottom),
+ line(left, bottom, left, top),
+ line(left, top, right, bottom),
+ line(right, top, left, bottom),
+ line(cx - arm, cy, cx + arm, cy),
+ line(cx, cy - arm, cx, cy + arm),
+ ]
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument("-s", "--serial", help="target device serial")
+ parser.add_argument("--port", type=int, default=DEFAULT_PORT)
+ parser.add_argument("--pattern", action="store_true",
+ help="draw the calibration pattern once the canvas is open")
+ 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("--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,
+ help="draw like a hand: 0 is exact, 1 a steady hand, 3 careless")
+ parser.add_argument("--speed", type=float, default=1.0, help="pacing multiplier")
+ parser.add_argument("--shot", help="save a screenshot of the result here")
+ parser.add_argument("--wait", type=float, default=4.0,
+ help="seconds to let the page load before drawing")
+ parser.add_argument("--hold", action="store_true",
+ help="keep serving until Enter, when run from a terminal")
+ args = parser.parse_args()
+
+ if not list_devices():
+ print("No device is attached. Plug a phone in with USB debugging on, "
+ "start an emulator, or `adb connect :5555`.", file=sys.stderr)
+ return 1
+
+ device = Device(args.serial)
+ width, height = device.screen_size
+ url = f"http://localhost:{args.port}/canvas.html"
+
+ server = serve(args.port)
+ print(f"serving {HERE / 'canvas.html'} on port {args.port}")
+
+ device.adb("reverse", f"tcp:{args.port}", f"tcp:{args.port}")
+ print(f"adb reverse: the device can now reach {url}")
+
+ try:
+ device.shell("am", "start", "-a", "android.intent.action.VIEW", "-d", url)
+ print(f"opened {url} on {device.serial} ({width}x{height})")
+ except AdbTouchError as exc:
+ print(f"could not open a browser on the device: {exc}", file=sys.stderr)
+ print(f"open {url} there by hand instead", file=sys.stderr)
+
+ if args.wait:
+ print(f"waiting {args.wait}s for the page to load")
+ time.sleep(args.wait)
+
+ if args.pattern:
+ drawn = device.draw_paths(calibration_paths(width, height, args.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")
+ print("the cross dead centre. If it does not, start with `adbtouch info`.")
+
+ if args.image:
+ from adbtouch.vectorize import VectorizeSettings, Vectorizer
+
+ vectorizer = Vectorizer()
+ vectorizer.load_image(args.image)
+ _, paths = vectorizer.process(
+ VectorizeSettings.from_sliders(args.sensitivity, args.detail, target_width=900))
+ placed = fit_to_screen(paths, width, height, margin=args.margin)
+ seconds = device.estimate_duration(placed)
+ print(f"{len(paths)} strokes from {args.image}, about {seconds:.0f}s")
+ drawn = device.draw_paths(placed, human=args.hand, speed=args.speed, seed=1)
+ print(f"drew {drawn} strokes")
+
+ if args.shot:
+ time.sleep(1.0)
+ # screencap on a sleeping screen returns a black rectangle, which is a
+ # confusing thing to hand someone as evidence.
+ device.shell("input", "keyevent", "KEYCODE_WAKEUP", check=False)
+ time.sleep(0.5)
+ device.screenshot(args.shot)
+ print(f"screenshot: {args.shot}")
+
+ if args.hold and sys.stdin.isatty():
+ try:
+ input("Serving. Press Enter to stop. ")
+ except (KeyboardInterrupt, EOFError):
+ pass
+
+ server.shutdown()
+ device.adb("reverse", "--remove", f"tcp:{args.port}", check=False)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())