WobblePic started as a Windows-only app — Python, OpenGL, SAM2 via ONNX Runtime with DirectML. All solid technology choices for Windows, but every one of them posed an interesting question when we decided to bring the app to macOS. Some things ported in a single afternoon; others took days of trial and error.
What follows is the parts worth retelling: the CoreML backend that did not land where we expected, a year of shipping an app macOS called “damaged”, the .app and DMG plumbing, and the path bug that took half a day and one line to fix.
Why macOS?
WobblePic runs best on machines with capable GPUs, and Apple Silicon Macs are everywhere now. Every M-series chip pairs a strong integrated GPU with a dedicated Neural Engine, and on paper that is an ideal home for a segmentation model. Running natively on those machines was too compelling to skip — though, as it turned out, not for the reason we assumed.
We also knew the port wouldn’t be trivial. Some of our dependencies were Windows-specific, and the whole installation story (Windows installer vs. Mac drag-to-Applications) needed to be rethought.
The Cross-Platform Foundation
Before diving into what changed, it’s worth noting what didn’t change. A lot of WobblePic’s architecture was already cross-platform friendly:
- Python runs everywhere
- Pygame has working backends on macOS
- OpenGL 3.3+ works on Intel and Apple Silicon Macs (via a compatibility layer, but transparent to us)
- ONNX models are a portable format — the same weights run on Windows, macOS, Linux
- Our mesh deformation, physics, and shaders are pure math and GLSL
So the skeleton was intact. The work was mostly in the platform-specific plumbing: AI acceleration, installation, window chrome, and keyboard conventions.
CoreML: One Model, Three Backends
SAM2 is the largest compute workload in WobblePic. Encoding a single image through the transformer-based encoder costs:
| Backend | Hardware | Encoding Time |
|---|---|---|
| ONNX Runtime + DirectML | Windows GPU | ~225 ms |
| CoreML | Apple Silicon (CPU + GPU) | ~310 ms |
| ONNX Runtime | Intel Mac (CPU) | ~3 s |
The plan going in was straightforward: keep the ONNX model, point CoreML at it, let the Neural Engine run it, and enjoy transformer inference at a fraction of the power. Neither half of that survived contact with the hardware.
The Neural Engine does not run this model
SAM2’s Hiera backbone at 1024×1024 is not something the NE will accept, so the encoder runs on CPU + GPU instead. At ~310 ms that is a perfectly good result — it is the reasoning that had to change, not the shipping configuration.
What made this cost real time rather than just being a footnote: leaving computeUnits on .all does not skip the NE gracefully. CoreML attempts the NE compilation, fails, and falls back — about 16 seconds on first load, spent arriving at the placement it would have chosen instantly if told. The encoder is now loaded explicitly with ComputeUnit.CPU_AND_GPU.
(How Apple Neural Engine Differs from GPU and CPU goes into what the NE will and will not take.)
We did not convert the model — Apple had already done it
The intended path was coremltools, converting our existing sam2_encoder.onnx into an .mlpackage. That is not how it went. coremltools has moved away from converting ONNX directly, preferring conversion from the original framework, and every workaround for that added a step between us and a model we would then have to validate ourselves.
Apple publishes an official CoreML build of SAM2.1 — apple/coreml-sam2.1-baseplus — split into three parts: image encoder, prompt encoder, mask decoder. Adopting it removed the conversion problem entirely, and it is Apple’s own conversion of Meta’s model rather than our best attempt at one.
The result is that “ONNX everywhere” is only two thirds true:
- Windows → our ONNX model, ONNX Runtime + DirectML
- Intel Mac → the same ONNX model, ONNX Runtime on CPU
- Apple Silicon → Apple’s CoreML model, a different artifact entirely
The interface above them is identical, so the rest of the app never learns which one it is talking to. But the interchange format did not span all three platforms, and it is worth being honest that it did not.
Auto-Caching for Fast Startup
CoreML compiles a .mlpackage for the specific machine on first load, which is good for inference and bad for launch time. Both Mac backends therefore cache their compiled artifact under models/cache/:
- On first run, compile the model for this machine and write the result to disk
- On later runs, load the compiled artifact directly and skip compilation
On Apple Silicon that is a .mlmodelc loaded through CompiledMLModel, bringing model load down to about 0.3 s. On Intel Macs the same idea applies to ONNX Runtime’s own cache, at about 1.4 s. We also dropped pre-built .ort files from the Windows installer for the same reason — the cache builds them on first run anyway.
Making It Feel Like a Mac App
A cross-platform app should respect platform conventions. A few specifics we handled:
Cmd Instead of Ctrl
Python’s cross-platform libraries (pygame, tkinter, etc.) don’t automatically remap modifier keys. We intercept modifier checks and translate:
MOD_KEY = 'cmd' if sys.platform == 'darwin' else 'ctrl'
So a shortcut like Ctrl+C on Windows becomes Cmd+C on macOS with no extra code at the call site.
Trackpad Pinch-to-Zoom
macOS users expect pinch gestures for zoom. Pygame exposes MULTIGESTURE events on macOS, which we handle alongside mouse wheel:
if event.type == pygame.MULTIGESTURE:
# Pinch scale factor
zoom_delta = event.dPinchX * PINCH_SENSITIVITY
apply_zoom(zoom_delta)
The Dock Icon Saga
Setting a proper Dock icon on macOS turned out to be surprisingly fiddly. We went through:
- Attempt 1:
pygame.display.set_icon()— Works on Windows, but on macOS it was flashing a default pygame icon for a split second before our icon appeared. - Attempt 2: Skip
set_icon()entirely in the.appbundle and rely on theInfo.plisticon. This worked on macOS but broke the Windows icon. - Final fix: Conditionally skip
set_icon()only when we detect we’re running inside a macOS.appbundle. A 1024×1024 icon in the bundle then shows up correctly in the Dock.
The Path Bug That Took Half a Day
This is the bug that aged us. WobblePic uses a WOBBLEPIC_HOME environment variable to find bundled resources (shaders, icons, models, etc.). On Windows with PyInstaller, resources are placed next to the .exe:
WobblePic/
├── WobblePic.exe
├── shaders/
├── resource/
├── models/
└── _internal/ ← Python runtime
So WOBBLEPIC_HOME on Windows = the directory containing the exe.
PyInstaller on macOS, however, uses a different convention. Everything gets bundled into WobblePic.app/Contents/Resources, and PyInstaller exposes a special sys._MEIPASS attribute pointing to the extracted runtime directory. Resources are under that directory, not next to the executable.
We originally just used os.path.dirname(sys.executable) for both platforms. On Windows that’s correct. On macOS, it’s /Applications/WobblePic.app/Contents/MacOS/, which is not where the resources live.
The fix was small but critical:
if sys.platform == 'darwin' and hasattr(sys, '_MEIPASS'):
WOBBLEPIC_HOME = sys._MEIPASS
else:
WOBBLEPIC_HOME = os.path.dirname(sys.executable)
The bug manifested as shaders and models not being found, with cryptic “file not found” errors only in release builds. A classic “works on my machine (Python source)” scenario.
Building the .app and DMG
Shipping a macOS app means producing a proper .app bundle and an installer (usually a DMG). PyInstaller handles the bundle; we wrote a small build script for the DMG.
PyInstaller for macOS
PyInstaller’s macOS mode needs an Info.plist with at least:
CFBundleShortVersionString— version stringNSHighResolutionCapable— Retina display supportNSSupportsAutomaticGraphicsSwitching— Intel MacBook Pro integrated/discrete GPU switching
Our .spec file generates the bundle with our icon, version, and these plist entries baked in.
DMG Creation
For the installer, we use create-dmg (a small, well-maintained macOS tool). It produces a clean DMG with a custom background, an Applications folder alias, and the app icon positioned nicely:
create-dmg \
--volname "WobblePic" \
--icon-size 128 \
--window-size 540 380 \
--icon "WobblePic.app" 150 190 \
--app-drop-link 390 190 \
"WobblePic-1.1.3-arm64.dmg" \
"dist/WobblePic.app"
Users get the familiar “drag to Applications” experience with no extra explanation needed.
The Unsigned App Problem, and How It Ended
Update (August 2026): this section described the state of things at launch. WobblePic’s macOS builds have been signed and notarized since v1.4.6 in May 2026 — the app opens with a normal double-click, and the workaround below is no longer needed. The original problem is left here because it shaped the first year of the port.
At launch this was the biggest wart in the whole experience. Distributing a macOS app without warnings requires three things:
- Enrolment in the Apple Developer Program ($99/year)
- Code-signing with a Developer ID certificate
- Notarizing with Apple — upload, wait, then staple the ticket to the app
For a free app, $99/year is a real decision rather than a rounding error, and we shipped without it for the first several releases. The consequence was a Gatekeeper dialog claiming the app was “damaged” or could not be opened — wording that suggests a corrupt download rather than a missing signature, which is about the worst possible first impression. The workaround, documented on the download page at the time, was to open System Settings → Privacy & Security and click Open Anyway.
What eventually changed the calculus was not the warning itself but who it stopped. People who understand code signing shrug and click through; everyone else concludes the download is broken and leaves. That is not a cost you can see in your analytics, because it looks exactly like someone who never tried.
v1.4.6 signs the app bundle with a Developer ID Application certificate, submits the build to Apple’s notary service, and staples the resulting ticket to both the .app and the DMG — stapling matters, because it is what makes verification work offline. Both the Apple Silicon and Intel builds go through it.
One honest asymmetry remains: the Windows installer is still unsigned, so SmartScreen shows its own “unrecognized app” warning, cleared with More info → Run anyway. Authenticode certificates cost more than the Apple programme, and an EV certificate — the kind that passes SmartScreen reputation immediately — needs a hardware token and business verification on top. That one is still on the list.
Lessons Learned
A few takeaways from this port:
- Start cross-platform-aware from day one. Even if you’re shipping one platform first, being mindful of path handling, modifier keys, and hardware-specific code paths saves pain later.
- An interchange format buys you less than the diagram suggests. ONNX carried two of our three backends; the third runs a different artifact altogether. What actually made the port cheap was the interface around the model being narrow enough that swapping the implementation underneath it changed nothing else.
- Verify which hardware you are on before designing around it. We planned the macOS port around the Neural Engine and shipped on CPU + GPU. The assumption cost more than the difference did — the measured result was fine either way.
- Some costs live in the load path. A failed accelerator compile that adds 16 seconds to startup will never show up in a benchmark that times inference.
- Native feel matters. Cmd vs Ctrl, pinch vs wheel, Dock vs taskbar — users notice when these don’t match platform expectations.
What’s Next
With Windows and macOS done, we’re looking at iOS and Android. Our Python/OpenGL codebase will need more rework for mobile, but the core assets — GLSL shaders, mesh math, ONNX/CoreML models — are already portable.
If you want to try WobblePic on your Mac, head to the Download page. Apple Silicon or Intel, we’ve got you covered.