Skip to content

A native Windows front end, tracing that survives a real photograph, and the AGPL - #4

Merged
MAXAWER merged 7 commits into
mainfrom
feature/installers-and-demo
Aug 26, 2026
Merged

MAXAWER merged 7 commits into
mainfrom
feature/installers-and-demo

Conversation

@MAXAWER

@MAXAWER MAXAWER commented Aug 26, 2026

Copy link
Copy Markdown
Owner

Seven commits, in three groups: a native Windows front end, tracing that
produces a recognisable drawing, and the licence the project should have had
from the start.

A WinUI 3 front end

winui/ is a real Windows application - Fluent controls, a device list, a
preview, pacing sliders, a progress bar. It draws the window and does nothing
else.

Everything difficult stays in the Python engine that macOS and Linux already
use. The front end launches it as a child process and speaks JSON lines to it
over a pipe: one request per line, replies carrying the request id, events for
progress and status. Porting the engine to C# would have meant two
implementations of the same subtleties, and they would have drifted apart by
the second release.

autodraw/server.py is that protocol, and it is useful past this one front
end: anything that can write a line of JSON can now drive AutoDraw.

python tools/build_app.py --winui builds the engine as a console executable,
publishes the front end self-contained, and folds the engine into it. Verified
end to end on this machine: the published application starts on its own, finds
the phone through its packaged engine and lists it.

Tracing that survives a real photograph

The app no longer asks which algorithm to run. It asks what is in the picture -
"buildings, machines, objects" or "portraits, animals, nature" - because nobody
choosing between two tracers is helped by the words Canny and edge tangent
flow
.

Both are kept because testing across a portrait, a landscape, an animal and an
object showed neither wins everywhere. Canny with a skeleton walk is never bad.
The flow filter wins clearly on anything organic: a face stays a face instead
of becoming film grain.

The flow filter went from fourteen seconds to 2.8, which is what makes it
offerable at all. Profiling rather than guesswork found both causes: the
tangent field is smooth by construction, so building it at half resolution
costs a quarter and loses nothing visible; and the bilinear sampling the filter
does thirty times over the whole image was four fancy-index gathers per call in
NumPy, where cv2.remap is the same arithmetic in one vectorised pass.

DexiNed stays as an opt-in third method. Its earlier poor showing was my fault
twice over - a 512-pixel input stretched back up, and a threshold that
discarded ninety-one per cent of what the model found. A network answers in
soft ridges, so thresholding gives a band and thinning a band gives the band's
outline. trace.ridges does what Canny does instead: non-maximum suppression
across the ridge, then hysteresis.

Licence

The project moves to AGPL-3.0-only, with a commercial licence available
from the author. TERMS.md states the terms in plain English and Russian,
CONTRIBUTING.md carries the CLA that makes dual licensing possible, and
AGENTS.md / CLAUDE.md put the same notice where a coding assistant will
read it before copying anything out of here.

Worth a reviewer's attention

Two WinUI traps are recorded in docs/RELEASING.md and in comments, because
neither says what is wrong:

  • Windows App SDK 1.6 cannot build without Visual Studio. Its PRI targets
    load MSBuild tasks that ship only with VS. 2.x does not, which is why the
    reference is pinned there.
  • Publishing an unpackaged WinUI app leaves the compiled XAML behind. An
    unpackaged project has no packaging tooling to nominate .xbf and .pri
    files, so the app dies inside InitializeComponent with a stowed exception
    naming nothing - while an ordinary build works fine. The project copies them
    after publish.

Not done yet, and known: the WinUI build is not in CI; the front end has no
drag-to-place preview or gesture recording; the self-contained publish is
405 MB, of which about 190 is OpenCV for five function calls.

Tests: python -m unittest discover -s tests - 166 tests, 1 skipped, all pass.

🤖 Generated with Claude Code

MAXAWER and others added 7 commits August 26, 2026 18:44
Instant was six milliseconds a point, because a stroke delivered faster than
the receiving app samples arrives as a press and a release with nothing in
between. Measured against the 1,679-point castle on a Pixel 8 Pro: 0 ms drew
it in 2.4 seconds and lost two thirds of the points; 1 ms delivers all of
them in 5.0 seconds; 3 ms takes 11; 6 ms takes 19. So one millisecond it is -
forty-four times faster than the input command path, with nothing dropped.

The injector also learned to carry a stroke as history inside a single event,
the way a 240 Hz digitizer talks to an app drawing at 120. That is genuinely
instant where the receiver reads the history, and useless where it does not:
Samsung Internet dropped it either way, whole-stroke or in chunks of eight,
so it stays available and off by default rather than being the default that
quietly loses detail. The test canvas now reads coalesced events, without
which a working injector looks broken.

And the licence. This is source-available, not open source: personal use is
unrestricted, while commercial use, redistribution - free redistribution
included - and reskinning need written permission, with exceptions considered
on request. LICENSE carries the binding text, TERMS.md says the same thing in
plain English and Russian, the README leads with it, and AGENTS.md and
CLAUDE.md say it to the tools that read those, since an assistant confidently
describing this as MIT would do real damage.

Earlier releases went out under MIT. That grant stands for those versions and
does not extend past them; the licence says so itself.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Canny answers where brightness changes. On a photograph that is every
texture, every shadow and every compression artefact, and it answers in
outlines, so one line comes back as two with a gap between them. The launch
pad photo gave 715 strokes and 11,470 points of porridge that read as noise
at any size.

adbtouch/trace.py replaces it for photographs, in three steps that are all
arithmetic and no dependency:

XDoG decides where the lines are - a difference of Gaussians finds the scale
structure lives at, and a soft threshold makes the decision an artist makes,
this edge earns a line and that shading does not. Zhang-Suen thinning takes
whatever survives down to one pixel of width. Then the skeleton is walked
into polylines that carry straight on through junctions, because a lattice
tower is nothing but crossings and breaking at each one turns a girder into
thirty stubs.

Two details decide whether the result is a drawing or a scribble. The
threshold is a quantile, so the same slider means the same density on a dark
photograph and a bright one. And it is also floored in absolute contrast,
without which a relative threshold always finds its quota - point it at a
clear sky and it will confidently draw the sensor noise. A unit test on a
flat grey image is what caught that.

Same photograph, same settings: 294 strokes and 2,327 points, sky empty,
shuttle legible. Five times cheaper to draw and the first version of this
that looks like a drawing.

Also fixed: simplification reduces a straight stroke to its two endpoints,
and the min_points filter was then throwing away every straight line in the
picture. Length is what matters.

Canny stays available as method="canny"; it still suits flat vector art,
where every region genuinely is an outline.

examples/launchpad.jpg is NASA's Endeavour on pad 39A - a real photograph
with real detail, since an illustration proves nothing about tracing one.
Provenance in examples/CREDITS.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You were right that the last version was still bad, and the reason was
structural rather than a matter of tuning. Canny and XDoG both decide about
each pixel on its own, and a line is not a property of a pixel - it is a
direction being repeated by the neighbours. Neither has any way to prefer a
continuation, so a long edge breaks into dashes wherever its contrast dips
and a photograph comes back as fragments.

adbtouch/flowlines.py builds the missing structure first, following Kang,
Lee and Chui. An edge tangent flow says which way the line runs at every
pixel and stays coherent across the gaps where contrast fails. The
difference of Gaussians is then measured across that direction rather than
in a fixed x and y, and the response is reinforced by walking along the flow
- so a pixel whose neighbours along the line agree with it survives, and a
confident piece of noise with nothing agreeing with it fades. That last step
is what makes the output look drawn rather than detected.

Same photograph: 158 strokes and 824 points where Canny gave 715 and 11,470.
The sky is empty, the orbiter is one clean outline, and the tower reads as
structure. It also draws in under a second on the phone instead of three
minutes.

Two things were needed to make it usable rather than merely correct. The
tangent field depends only on the image and never on the settings, and it is
most of the cost, so it is computed once per image and reused when a slider
moves. And walking the flow is a bilinear sample of the whole image per
step, so the walk takes a fixed eight samples and strides out to cover the
coherence length - the cost then stops depending on how long the lines are
asked to be.

Canny and XDoG both remain, as method="canny" and method="xdog"; the first
still suits flat vector art, where every region really is an outline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
You preferred what Canny sees, and you were right about it: on a photograph
of something built - a tower, a machine - the detail it finds is what makes
the picture recognisable, and the flow filter was throwing half of it away
in exchange for calm.

What was actually wrong with Canny was never the detection. It was
findContours walking the boundary of each edge, which traces every line
twice, once up each side. Running Canny into the skeleton tracer instead
keeps all of the detail and draws each line once: 726 strokes and 4,967
points against 403 and 9,657, half the drawing time, and no doubled
outlines. Two seconds to compute rather than fifteen.

So canny is the default again, now meaning Canny plus thinning plus a
skeleton walk. flow and xdog remain for portraits and organic subjects,
where long calm strokes suit better. The original boundary-walking path is
still there as contour, which is the right answer for flat vector art, where
a region genuinely does have an outline.

The licence changes to AGPL-3.0 with a commercial licence available from the
author. The previous source-available terms said what you wanted but are not
OSI-approved, and Anthropic's open-source programme requires that an approved
licence be used. AGPL is the closest OSI licence to the intent: anyone may
use and share it, but a distributed version or a hosted service must publish
its source, which means a company wanting to keep theirs closed comes and
asks for terms instead. What it cannot preserve is the ban on free
redistribution - no OSI licence permits that.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The model did not lose the comparison; I ran it badly, twice over. It was
fed a 512-pixel square and stretched back to 900, which is below the size at
which a lattice tower has any lattice left. And it was thresholded at the
strongest nine per cent, discarding ninety-one per cent of what it had
found. The raw output had the detail all along.

The second mistake is the interesting one. A network answers in wide soft
ridges rather than lines, so thresholding one gives a band, and thinning a
band gives the outline of the band - little closed cells around every patch
of texture. That is what a neural edge map looks like when it is used
carelessly, and it is what the comparison showed. trace.ridges takes the
step Canny takes instead: keep a pixel only where it is at least as strong
as its neighbours across the ridge, leaving the crest one pixel wide, then
hysteresis to join the confident parts to the plausible ones.

Read that way, at the real resolution, DexiNed gives 1,557 strokes and 8,436
points with the structure intact - Canny detail with single lines and, more
to the point, a threshold that drops gravel rather than dropping whatever
happens to be faint. That is the reason to carry a model at all: it ranks
edges by whether a person would draw them, and contrast does not.

adbtouch/neural.py runs it through OpenCV's own DNN module, so there is no
new dependency, and downloads the weights on request rather than shipping
them - 46 MB is a lot to impose on someone who wanted to draw a stick
figure, and every other method still works without it. The weights are
OpenCV's MIT-licensed conversion from their Model Zoo, which is what makes
them distributable under both licences here.

detail_keep is the knob, and it chooses what to leave out rather than how
sensitive to be.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Tested across a portrait, a landscape, an animal and an object, no method
wins everywhere and two are worth keeping. Canny with the skeleton walk is
never bad and is what a building, a machine or a flower is made of. The flow
filter wins clearly on anything organic - a face stays a face instead of
becoming film grain, and fur is suggested rather than scribbled one hair at
a time.

So the app now asks what is in the picture rather than which algorithm to
run: "buildings, machines, objects" or "portraits, animals, nature". Nobody
choosing between two tracers is helped by the words Canny and edge tangent
flow.

The flow filter went from fourteen seconds to 2.8, which is what makes it
offerable at all. Two changes, and profiling rather than guesswork found
both: the tangent field is smooth by construction, so building it at half
resolution loses nothing visible and costs a quarter; and the bilinear
sampling that the filter does thirty times over the whole image was four
fancy-index gathers per call in NumPy, where cv2.remap is the same
arithmetic in one pass of vectorised C. Identical output, five times faster.

xdog is gone. It existed as the cheap approximation of the flow filter, and
the flow filter is now cheaper than it was, so it was a third thing to
explain for no gain. DexiNed stays as an opt-in method for photographs that
neither handles well; it is not a default - on a grainy portrait it draws
the grain, and on a flower it draws only the silhouette.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
winui/ is a native Windows application: Fluent controls, a device list, the
two tracers named by what is in the picture rather than by algorithm, sliders
for pacing, a preview and a progress bar. It draws the window and does
nothing else.

Everything that is actually difficult - tracing, ADB, the injector, hand
simulation - stays in the Python engine, which macOS and Linux use too. The
front end launches it as a child process and speaks JSON lines to it over a
pipe: one request per line, replies carrying the request id, events for
progress and status. Porting the engine to C# would have meant two
implementations of the same subtleties, and they would have drifted apart by
the second release.

autodraw/server.py is that protocol, and it is useful beyond this: anything
that can write a line of JSON can now drive AutoDraw.

tools/build_app.py --winui builds the engine as a console executable,
publishes the front end self-contained, and puts the engine inside it, so the
result runs on a machine with nothing installed. Verified end to end: the
published application starts, finds the phone through its packaged engine and
lists it.

Two traps worth recording, since both cost real time and neither says what is
wrong. Windows App SDK 1.6 cannot build without Visual Studio - its PRI
targets load MSBuild tasks that only ship with VS - and 2.x does not, which is
why the reference is pinned there. And publishing an unpackaged WinUI app
leaves the compiled XAML behind, because an unpackaged project has no
packaging tooling to nominate it; the app then dies inside InitializeComponent
with a stowed exception naming nothing, while building works fine. The project
copies those files afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@MAXAWER
MAXAWER merged commit 6883def into main Aug 26, 2026
2 of 13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant