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

- An image being traced into touch strokes and drawn on a phone screen + A colour landscape being traced into touch strokes and drawn on a phone screen

- 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)]]) Source image, detected edges, and the resulting stroke paths

-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%" tools etch_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())