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.
In this article, we’ll walk through the most interesting parts of the macOS port: swapping in CoreML for Apple Silicon, navigating the unsigned-app problem, building a proper .app bundle and DMG, and the sneaky path issue that took an embarrassingly long time to diagnose.
Why macOS?
WobblePic runs best on machines with capable GPUs — and Apple Silicon Macs are everywhere now. M1, M2, M3, and M4 chips all ship with a dedicated Neural Engine that’s perfect for running the SAM2 segmentation model. Running our app natively on those machines was too compelling to skip.
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: Letting the Neural Engine Do the Heavy Lifting
SAM2 is the largest compute workload in WobblePic. Encoding a single image through the transformer-based encoder takes:
| Backend | Hardware | Encoding Time |
|---|---|---|
| ONNX Runtime DirectML | Windows GPU (mid-range) | ~200ms |
| ONNX Runtime CPU | Any CPU | 2000–5000ms |
| CoreML (Neural Engine) | Apple Silicon | ~310ms |
Apple Silicon’s Neural Engine is roughly on par with a discrete Windows GPU for this workload, but with much better power efficiency. It would be a waste not to use it. (See How Apple Neural Engine Differs from GPU and CPU for the background.)
Converting ONNX to CoreML
Apple provides coremltools for converting models from various formats. For ONNX specifically:
import coremltools as ct
mlmodel = ct.convert(
"sam2_encoder.onnx",
inputs=[ct.TensorType(name="image", shape=(1, 3, 1024, 1024))],
convert_to="mlprogram", # Modern CoreML format
compute_units=ct.ComputeUnit.ALL, # CPU + GPU + Neural Engine
)
mlmodel.save("sam2_encoder.mlpackage")
The conversion itself took a bit of massaging — some ONNX ops don’t have direct CoreML equivalents, so we had to simplify parts of the model. Once converted, we could swap the encoder at runtime based on platform:
- Windows → ONNX Runtime + DirectML
- Apple Silicon → CoreML + Neural Engine
- Intel Mac → ONNX Runtime + CPU
Auto-Caching for Fast Startup
CoreML compiles the .mlpackage for the specific hardware on first load. This is great for performance (subsequent inferences are fast), but it adds several seconds to the first launch.
We implemented an auto-cache pattern that works for all backends:
- On first run, load the model, optimize/compile it for the current execution provider, and save the compiled result to disk
- On subsequent runs, check if a cached compiled version exists and matches the source
- If cached, skip the compilation step entirely
The result: first launch takes ~3 seconds, subsequent launches take ~1 second. The same pattern applies to DirectML-optimized ONNX on Windows — we dropped pre-built .ort files from our installer because 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
Here’s where we hit the biggest UX wart. To distribute a macOS app without warnings, you need to:
- Enroll in the Apple Developer Program ($99/year)
- Code-sign your app with a Developer ID certificate
- Notarize the app with Apple (upload, wait, staple the ticket)
For a free freeware app, the $99/year isn’t trivial, and we haven’t made that step yet. The consequence is that macOS Gatekeeper shows an ominous warning on first launch, claiming the app is “damaged” or “cannot be opened.”
We documented the workaround prominently on the Download page:
- Try to open the app (it will be blocked)
- Open System Settings → Privacy & Security
- Scroll down and click Open Anyway
This is a rough first-time experience, and we’re planning to get proper code signing in place for a future release.
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.
- ONNX is an incredible interchange format. The same model weights power three different runtimes (DirectML, CoreML, CPU ONNX) with radically different performance profiles.
- The Neural Engine is genuinely impressive. Having ~200ms transformer inference with a fraction of the power budget of a discrete GPU is a real game-changer for user-facing ML.
- 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.