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:

BackendHardwareEncoding Time
ONNX Runtime DirectMLWindows GPU (mid-range)~200ms
ONNX Runtime CPUAny CPU2000–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:

  1. On first run, load the model, optimize/compile it for the current execution provider, and save the compiled result to disk
  2. On subsequent runs, check if a cached compiled version exists and matches the source
  3. 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:

  1. 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.
  2. Attempt 2: Skip set_icon() entirely in the .app bundle and rely on the Info.plist icon. This worked on macOS but broke the Windows icon.
  3. Final fix: Conditionally skip set_icon() only when we detect we’re running inside a macOS .app bundle. 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 string
  • NSHighResolutionCapable — Retina display support
  • NSSupportsAutomaticGraphicsSwitching — 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:

  1. Enroll in the Apple Developer Program ($99/year)
  2. Code-sign your app with a Developer ID certificate
  3. 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:

  1. Try to open the app (it will be blocked)
  2. Open System Settings → Privacy & Security
  3. 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.