Why it exists
Product photography for a multi-SKU catalog. Dozens of new SKUs per week. Each photo needs background removed, square-cropped, scaled to 1000×1000, optionally stamped with the SKU filename. Doing this by hand in Photoshop is death.
Built for an internal team that ships hundreds of new product photos per week, and it's the front door of the photos-to-product-pages pipeline: the folder this app fills is the folder that automation watches. Open-sourced because the pattern is generic — anyone running a catalog with manual photo-prep grind can use it as-is or adapt it.
It was also the first piece of a longer story. When a spin-off company needed an order portal for its sales reps, this app's Drive folders and cleaned-up photos were what that portal pulled from to create product pages on its WooCommerce site. That WooCommerce work is what convinced me to close the loop back at Elevated, where the editor's output now feeds the site directly.
The pipeline
- Focal cut (
focal_cut.py, added Sept 2026) — finds the one hand-held product in the frame from cheap cues (colour classes, sharpness, texture, position), prompts SAM at it with negative points on the hand or glove, the pile behind, and the backdrop, then mattes a tight crop with BiRefNet for fine edges and defringes the backdrop colour. Hands, gloves, the pile and the tray are excluded. It declines, and hands off to step 2, on pile shots or anything it isn't sure about. - Full-frame fallback —
rembgwithbirefnet-generalon the whole frame. Filename prefixsmallsskips it (bulk product fills the frame already). - Bulk detection — if
rembgkept >85% coverage (no clear subject) OR <5% coverage (over-removed), falls back to the original. Saves the "pile of nugs" case. - Component cleanup — keeps everything connected to the main subject. Strips only small distant fragments (alpha > 30 threshold).
- Smart resize — a cut-out subject is cropped to its bounding box, scaled to 900×900 and centered on a 1000×1000 transparent canvas with a 50px border. A full-frame shot (pile, smalls, fallback) is center-cropped to fill the whole tile instead of letterboxing.
- Outputs three folders —
pendingProducts/(clean square PNG, catalog-ready),edited/(same image + SKU filename banner),original/(source files moved here, nothing destroyed).
Videos go through the same banner step. The model loads once per batch and runs on the CPU, about a second and a half per photo, so a rep processing a single shot never waits on a cold start.
What makes it different
The hard part wasn't calling rembg — it was the heuristic for "did the background-removal step actually work, or did it make things worse?" That came out of pairing with Claude on real failure cases. Two patterns broke naive bg-removal:
- Pile shots — no clear subject,
rembgwould over-remove and produce empty PNGs. - Scattered subjects — small fragments (loose nugs around a jar) got stripped as noise.
The coverage-percentage fallback turned a manual triage step into one heuristic. The component-cleanup threshold preserved scatter while still stripping JPEG artifacts.
The 2026 addition was the harder version of the same question: in a photo of a gloved hand holding one bud over a tray of a hundred more, which pixels are the product? No single model answers that. The focal cut gets there by making two models agree: SAM decides which object from point prompts the cue stage placed, BiRefNet decides the edge on a tight crop at three to four times the resolution of a full-frame pass, and the result only ships if both landed on one solid blob. Otherwise it declines and the old path runs. Declining on purpose is what keeps it safe to run unattended.
Photo type matrix
| Type | Example filename | Behavior |
|---|---|---|
| Hand-held product over a tray or pile | aaa-strain.jpg | Focal cut: product only, hand + pile + tray removed |
| Single subject on a clean backdrop | jar-001.jpg | Full-frame bg removal + resize + banner |
| Bulk / pile | smalls-batch-3.jpg | Skip bg removal (filename prefix), resize only |
| Scattered subject | jar-with-scatter.jpg | Full pipeline, keeps scattered pieces (cleanup tuned) |
| Over-removed by rembg | (auto-detected) | Falls back to original |
Stack
Native macOS Tkinter UI — no Electron, no web frontend, no 200MB Chromium runtime. Everything runs as ONNX on the CPU, no torch, so the standalone .app built with PyInstaller stays a single zip. There is no settings screen any more: the app remembers the last folder and that's the whole configuration. The old settings window and its n8n webhook went away in June 2026 when the pipeline moved to watching the output folder instead of being pinged.
Architecture
photoEditor/
├── combined_processor.py # pipeline: focal cut → rembg → cleanup → resize → banner
├── focal_cut.py # cues → SAM region → BiRefNet matte → defringe
├── test_focal_cut.py # regression set: real shots, expected verdicts
├── tk_app/app.py # Tkinter GUI: Browse · Start · Stop, progress + log
├── build_app.sh # PyInstaller build → dist/PhotoEditor.app
├── PhotoEditor.spec # bundled font, models, icon
├── PhotoEditor.icns · Inter-Bold.ttf
├── INSTALL.md # Gatekeeper walkthrough for the people who run it
└── requirements.txt # 36 deps
Who runs it
The photographer
Opens the app, picks the day's folder, hits Start. Comes back to three folders.
The pipeline
pendingProducts/ lands in a Drive folder that the 15-minute tick watches. No webhook, no button.
The company
A fork under the company's GitHub org carries the release. Upstream is mine; fixes flow both ways.
Logs
Rolling debug logs under ~/Library/Logs/CombinedProcessor/, the first thing to send when a photo comes out wrong.
Install
End users (Mac, no Python required)
Download the standalone .app bundle from GitHub Releases (the latest link always points at the newest build) and follow the macOS Gatekeeper walkthrough in INSTALL.md. The app is unsigned (no Apple Developer Program enrollment), so first launch needs Privacy & Security → Open Anyway. One-time setup; subsequent launches are normal.
Developers (build from source)
git clone https://github.com/jaded423/photoEditor.git
cd photoEditor
# Requires Python 3.13
./build_app.sh # → dist/PhotoEditor.app
./build_app.sh --clean # clean build artifacts first
# Or run directly without packaging
./run_python.sh
AI-assisted, end-to-end
Built through iterative dialogue with Claude, including the focal cut: the cue heuristics, the negative-prompt strategy and the "both models must agree" rule all came out of arguing over real failure photos. Same approach behind everything on this site — see the philosophy for more, or what I'm building right now.