All projects Mulpur · Write-up

Template matching, 40 times a secondTemplate-Matching, 40-mal pro Sekunde

Mulpur is a macOS menu bar app that watches one region of the screen and clicks when a template you captured appears there. Most of it is computer vision and platform details.Mulpur ist eine macOS-Menüleisten-App, die einen Bildschirmbereich beobachtet und klickt, sobald dort eine selbst aufgenommene Vorlage erscheint. Der Großteil ist Bildverarbeitung und Plattform-Detailarbeit.

Runs locally · private

Type
macOS appmacOS-App
Stack
Swift · Python
Year
2026
Code
PrivatePrivat

What it does

You drag a box around the region to watch and snip the target yourself, so nothing is tied to one site’s markup. Mulpur captures that region in a loop, looks for the template in it, and only when the match clears your confidence threshold does it move the cursor there and click. The popover shows the watched region live, with the current confidence beside it.

There are two builds. The native Swift menu bar app is the current one; the original Python and Qt app is still the way to run it on Windows. Both read and write the same config file, so you can switch between them without losing settings.

Matching without OpenCV

The Python build uses OpenCV. The Swift build doesn’t: matching is normalized cross-correlation, the equivalent of OpenCV’s TM_CCOEFF_NORMED, written directly against CoreGraphics grayscale buffers. Normalizing subtracts each patch’s mean and divides by its spread, so the score doesn’t depend on the region’s overall brightness or contrast.

Three things keep it cheap enough for a loop every 25 ms. The working image is capped at 480 pixels on its longest side. Each scale is searched coarse-then-fine: a first pass steps up to four pixels at a time, and only the neighbourhood of its best hit is searched pixel by pixel. And the template is tried at six scales, from 0.85 to 1.25, starting with whichever matched last time, which keeps it working across Retina displays and browser zoom.

25 msPer loop on a background thread, about 40 captures a second.
480 pxThe cap on the working image’s longest side.
6 scalesFrom 0.85 to 1.25, starting with the one that matched last.

The detector that always matched

When the Swift build was compiled for the first time, the template detector turned out to be a stub: it reported a match at the centre of the region every time, whatever was on screen. The app would have clicked on a timer. That is why the confidence gate is the part worth stating: a click only fires when the real score clears the threshold, 0.75 by default.

Two coordinate systems

AppKit’s global coordinates start at the bottom left, with y pointing up. CoreGraphics capture and mouse events start at the top left, with y pointing down.

The same point in two coordinate systems: AppKit with its origin bottom left and y pointing up, CoreGraphics with its origin top left and y pointing down. AppKit CoreGraphics xyxy (154, 105) (154, 45) y′ = H − y origin bottom left · y uporigin top left · y down
Fig. 1One point, two systems. Converting is y′ = H − y, where H is the height of the primary display, not of the focused one: the assumption behind the multi-display bug.

On one display the difference is a flip. With several it’s a trap: the region selector and the full-screen fallback both assumed the main screen sits at the origin, which only holds when the focused screen is the primary display. Both now convert properly between the two systems.

After a click the cursor also moves about 80 pixels away, because its own graphic and the target’s hover highlight would otherwise sit in the watched region and spoil the next capture.

Permissions that survive a rebuild

Mulpur needs two grants that only a person can click through: Accessibility, to move the cursor and click, and Screen Recording, to capture the region. With ad-hoc signing there is no stable team ID, so macOS keys both grants to the exact binary hash, and every rebuild silently invalidates them. Signing with a self-signed local identity keeps them across rebuilds, and the build script picks that identity up automatically.

It runs as a menu bar agent without a Dock icon: left-click opens a popover, right-click a menu to toggle and quit, and the popover can be pinned to float above other windows.

Was es tut

Du ziehst einen Rahmen um den Bereich, der beobachtet werden soll, und schneidest das Ziel selbst aus, damit nichts am Markup einer bestimmten Seite hängt. Mulpur nimmt diesen Bereich in einer Schleife auf, sucht darin die Vorlage und bewegt den Cursor erst dann dorthin und klickt, wenn der Treffer deine Konfidenzschwelle überschreitet. Das Popover zeigt den beobachteten Bereich live, daneben die aktuelle Konfidenz.

Es gibt zwei Builds. Die native Swift-Menüleisten-App ist die aktuelle; die ursprüngliche App in Python und Qt ist weiterhin der Weg, es unter Windows zu nutzen. Beide lesen und schreiben dieselbe Konfigurationsdatei, man kann also wechseln, ohne Einstellungen zu verlieren.

Matching ohne OpenCV

Der Python-Build nutzt OpenCV. Der Swift-Build nicht: Das Matching ist normalisierte Kreuzkorrelation, das Gegenstück zu OpenCVs TM_CCOEFF_NORMED, direkt auf Graustufen-Puffern von CoreGraphics geschrieben. Die Normalisierung zieht von jedem Ausschnitt seinen Mittelwert ab und teilt durch seine Streuung, deshalb hängt der Wert nicht von Helligkeit oder Kontrast des Bereichs ab.

Drei Dinge halten das günstig genug für einen Durchlauf alle 25 ms. Das Arbeitsbild ist an seiner längsten Seite auf 480 Pixel begrenzt. Jede Skalierung wird grob-dann-fein durchsucht: Ein erster Durchgang springt bis zu vier Pixel weit, und nur die Umgebung seines besten Treffers wird Pixel für Pixel abgesucht. Und die Vorlage wird in sechs Größen probiert, von 0,85 bis 1,25, beginnend mit der, die zuletzt gepasst hat. So funktioniert es auf Retina-Displays und bei Browser-Zoom.

25 msPro Durchlauf in einem Hintergrund-Thread, rund 40 Aufnahmen pro Sekunde.
480 pxDie Grenze für die längste Seite des Arbeitsbilds.
6 GrößenVon 0,85 bis 1,25, beginnend mit der zuletzt passenden.

Der Detektor, der immer traf

Als der Swift-Build zum ersten Mal kompiliert wurde, stellte sich der Vorlagen-Detektor als Attrappe heraus: Er meldete jedes Mal einen Treffer in der Mitte des Bereichs, egal, was auf dem Bildschirm war. Die App hätte im Takt geklickt. Deshalb ist die Konfidenzschwelle der Teil, der eine Erwähnung wert ist: Ein Klick kommt nur, wenn der echte Wert die Schwelle überschreitet, standardmäßig 0,75.

Zwei Koordinatensysteme

Die globalen Koordinaten von AppKit beginnen unten links, y zeigt nach oben. Aufnahmen und Maus-Events in CoreGraphics beginnen oben links, y zeigt nach unten.

Derselbe Punkt in zwei Koordinatensystemen: AppKit mit Ursprung unten links und y nach oben, CoreGraphics mit Ursprung oben links und y nach unten. AppKit CoreGraphics xyxy (154, 105) (154, 45) y′ = H − y Ursprung unten links · y nach obenUrsprung oben links · y nach unten
Abb. 1Ein Punkt, zwei Systeme. Umgerechnet wird mit y′ = H − y, wobei H die Höhe des Hauptdisplays ist, nicht die des fokussierten: genau die Annahme hinter dem Fehler mit mehreren Displays.

Mit einem Display ist der Unterschied eine Spiegelung. Mit mehreren ist er eine Falle: Die Bereichsauswahl und der Vollbild-Rückfall gingen beide davon aus, dass der Hauptbildschirm am Ursprung liegt, was nur gilt, wenn der fokussierte Bildschirm das Hauptdisplay ist. Beide rechnen jetzt sauber zwischen den Systemen um.

Nach einem Klick fährt der Cursor außerdem rund 80 Pixel weg, weil sonst seine eigene Grafik und der Hover-Effekt des Ziels im beobachteten Bereich lägen und die nächste Aufnahme verfälschen würden.

Berechtigungen, die einen Rebuild überstehen

Mulpur braucht zwei Freigaben, die nur ein Mensch erteilen kann: Bedienungshilfen, um den Cursor zu bewegen und zu klicken, und Bildschirmaufnahme, um den Bereich aufzunehmen. Mit Ad-hoc-Signatur gibt es keine feste Team-ID, also bindet macOS beide Freigaben an den exakten Hash der Binary, und jeder Rebuild macht sie stillschweigend ungültig. Mit einer selbst signierten lokalen Identität bleiben sie über Rebuilds erhalten, und das Build-Skript greift diese Identität automatisch auf.

Es läuft als Menüleisten-Agent ohne Dock-Symbol: Linksklick öffnet ein Popover, Rechtsklick ein Menü zum Umschalten und Beenden, und das Popover lässt sich anheften, damit es über anderen Fenstern schwebt.