Privacy-focused image tools that run entirely in your browser. Resize, compress, convert, and strip metadata with Pillow running through Pyodide. No server, no upload; images never leave your device.
Photo Lab is a static Wybthon application: Python components running client-side through Pyodide, deployed straight from the main branch via GitHub Pages. Images are read with the browser's File API and processed in memory by Pillow compiled to WebAssembly. Nothing is uploaded, no analytics run, and closing the tab erases everything.
This is a starter, not a finished product. The skeleton works end to end (boot, lazy package install, drag-and-drop, live re-encoding, before/after comparison, download), and the interesting features are left to build. If you want to contribute to a Wybthon project, this is meant to be an easy place to start; see the roadmap below.
- Load an image: drag and drop, browse, or generate a sample image in memory (so the repo ships no binaries).
- Reactive controls: output format (JPEG, PNG, WebP), a quality slider, and a max-dimension field; an effect re-encodes the image live as you adjust them.
- Before and after: side-by-side previews with dimensions, encoded size, and a size-savings badge.
- Metadata removal: the output is re-encoded from pixels, so EXIF (camera, GPS, timestamps) is never copied over.
- Download: the processed image downloads straight from a data URL; no network involved.
- Lazy package loading: the page boots fast; Pillow and NumPy install via micropip only when the studio first mounts, behind a
Loadingboundary. A corrupt file raises into anErrorBoundaryinstead of crashing the page.
# Install Python (from .python-version) and all dependency groups
uv sync --all-groups
# Serve the static site
make serve # → http://localhost:8000The page needs nothing but a static file server; Pyodide, Wybthon, and the imaging stack are fetched at runtime from CDNs using the versions pinned in config.json and app/packages.py.
photo-lab/
├── index.html # Page shell, metadata, loading overlay
├── bootstrap.js # Pyodide + micropip + manifest-driven module loader
├── config.json # Pinned Pyodide + Wybthon versions
├── assets/
│ └── styles.css # Deliberately minimal theme; make it yours
├── app/
│ ├── main.py # async main(), called by bootstrap.js
│ ├── app.py # Shell: header, boundaries, privacy note, footer
│ ├── packages.py # Lazy micropip install of the imaging stack
│ ├── images.py # Pure Pillow/NumPy helpers (unit-tested)
│ └── studio.py # The studio: dropzone, controls, previews, download
├── tools/build_manifest.py # Regenerates manifest + cache busters
└── tests/ # Unit tests + opt-in browser smoke test
There are two layers, and they're intentionally separate:
- In the browser (what users run):
bootstrap.jsinstalls the pinnedwybthonwheel at boot, andapp/packages.pyinstallsPillowandnumpyvia micropip the first time the studio mounts. Add packages there as features need them. - Locally (what contributors run): the same packages are declared in
pyproject.tomlso Ruff, MyPy, your editor, and the unit tests resolve what the browser installs. After changing them, runuv lockand commituv.lock.
Components use the @component decorator; bodies run once at mount, and zero-argument callables embedded in the tree become fine-grained reactive holes. Start with app/studio.py for the UI and app/images.py for processing; keep image logic in images.py so it stays unit-testable without a browser.
Whenever you add, rename, remove, or edit files under app/ (or touch bootstrap.js / assets/styles.css), regenerate the manifest and commit the result:
make manifest # uv run python tools/build_manifest.pyValidate before pushing:
make ci # lint, typecheck, unit tests, manifest freshness
make e2e # optional: boots the real site in headless ChromiumSee CONTRIBUTING.md for commit and branch conventions.
Ideas that fit the project's goal (fully private, in-browser image processing), roughly ordered by difficulty. Pick one and open an issue to claim it:
- Metadata inspector: show the EXIF the original carries (Pillow's
getexif()) before it gets stripped, so users see exactly what's being removed. - Debounced re-encoding: dragging the quality slider re-encodes on every tick; debounce it, or move encoding into a Web Worker.
- Rotate and flip: simple orientation controls, including applying the EXIF orientation tag.
- Target file size: binary-search the quality setting to hit a size like "under 500 KB".
- Batch processing: accept multiple files, process them with shared settings in a keyed list, and download results (individually, or zipped via
zipfile). - HEIF/HEIC input: install
pillow-heiflazily (matching theapp/packages.pypattern) so iPhone photos open directly. - Crop: a draggable crop rectangle over the preview.
- OpenCV extras: optional filters (sharpen, denoise, edge detection) behind a lazily installed
opencv-python. - Zoomable previews: click to compare at 100% with a synced pan between before and after.
This repository deploys directly from the main branch; merging is the deployment. Go to Settings → Pages, set Source to Deploy from a branch, and choose main and / (root). The site appears at https://<user>.github.io/photo-lab/ within a minute or two. .nojekyll is required and already committed.
Built from the Wybthon demo template.
MIT © 2026 Owen Carey