No description
  • JavaScript 44.7%
  • Python 36.2%
  • CSS 9.3%
  • HTML 5.3%
  • Batchfile 4.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-10 08:13:07 +00:00
src/photo_cropper Crop square starts at output resolution and resets per photo 2026-08-10 07:33:58 +00:00
.gitattributes Fix Windows launcher vanishing on double-click 2026-08-10 08:12:58 +00:00
.gitignore Photo Cropper: local single-user batch cropper with web UI 2026-08-09 17:08:07 +00:00
pyproject.toml Photo Cropper: local single-user batch cropper with web UI 2026-08-09 17:08:07 +00:00
README.md README: troubleshooting note for the Windows launcher 2026-08-10 08:13:07 +00:00
run-windows.bat Fix Windows launcher vanishing on double-click 2026-08-10 08:12:58 +00:00
uv.lock Photo Cropper: local single-user batch cropper with web UI 2026-08-09 17:08:07 +00:00

Photo Cropper

A local, single-user photo batch cropper with a web UI. Point it at an input directory, set a target output resolution, and crop through your photos one by one — each photo shows an aspect-locked rectangle that you move, resize, and save to an output directory.

Requirements

  • Python 3.10+ (developed on 3.12)
  • uv for dependency management
  • A browser (the UI is dependency-free vanilla JS — works offline)

Quick start

cd photo_cropper
uv sync
uv run photo-cropper --input ./input --output ./output

The server starts on http://localhost:8000 and auto-opens your browser. All images found recursively under --input appear in a thumbnail strip.

Flag Default Meaning
--input ./input Directory scanned (recursively) for photos
--output ./output Directory where cropped photos are written
--port 8000 Local web server port

Windows (double-click)

For someone who just wants to crop photos without touching a terminal, there's run-windows.bat in the project root:

  1. One-time setup: install the repo somewhere (git clone or download the zip) and put photos into the input folder it creates. If uv isn't installed yet, the script installs it automatically (needs internet on first run).
  2. Every time: double-click run-windows.bat. A black console window opens, dependencies are checked, your browser opens at http://localhost:8000, and you crop through the photos. Cropped results land in the output folder next to the script.
  3. Stop: close the black window (or it closes itself if the app exits).

Notes for the person doing the setup:

  • A .ps1 file was deliberately not used: double-clicking a PowerShell script opens it in Notepad instead of running it (Windows file association) and the default execution policy would block it anyway. The .bat avoids both problems.
  • First run downloads uv's managed Python plus Flask/Pillow — takes a minute or two; later runs are near-instant.
  • The server binds only 127.0.0.1 (local machine), so Windows Firewall will not prompt.
  • Folders are created next to the script: input\ for source photos, output\ for crops.
  • Troubleshooting: if the black window ever flashes and closes instantly, re-download or re-clone the repo (the launcher relies on CRLF line endings; a stale copy with LF endings will misparse).
  • If you prefer PowerShell over the .bat, it's a fine choice — but it needs a shortcut with powershell -ExecutionPolicy Bypass -File run-windows.ps1 (none ships by default).

How it works

  1. Set the resolution. Enter target width × height (presets like 1080p included). This sets the aspect ratio of the crop rectangle and the exact pixel size of every output file.
  2. Open a photo. Every photo starts fresh with a rectangle at exactly the output resolution (shrunk proportionally if the photo is smaller, always aspect-locked), centered. Drag the rectangle to move it; drag a corner or edge handle to resize (aspect ratio always locked); the rectangle can never leave the image.
  3. Save. Enter or the Save button writes the crop to the output dir and auto-advances to the next photo. ← / → move freely without saving. Go back and re-save any photo anytime — it overwrites its own output file.

Keyboard shortcuts

Key Action
Enter Save crop & advance to next photo
← / → Previous / next photo (no save)
W / A / S / D Nudge rectangle 1 px (up/left/down/right)
Shift + W/A/S/D Nudge rectangle 10 px
+ / - Zoom in / out (mouse wheel also zooms)
0 Fit entire image in view
C Center view on the rectangle
R / Shift + R Rotate photo 90° clockwise / counter

Arrow keys navigate between photos; WASD nudges the crop rectangle (dragging works too). This split avoids the collision between the two gestures.

Behavior spec

The full set of product decisions, captured here so the tool stays honest:

  • Exact-resolution output. The cropped region is resized to exactly the requested width × height. The resolution input is not merely an aspect hint.
  • Upscale warning. When the selected region has fewer pixels than the target (or the source photo is simply small), the UI shows a badge like ⚠ will upscale 1.8×. Saving is still allowed — the warning is informational.
  • Output naming. IMG_1234.jpg → IMG_1234_cropped.jpg. If a basename collides (two inputs named IMG_1234.jpg from different folders, or a pre-existing output file), a counter is appended: IMG_1234_cropped_2.jpg. Re-saving the same photo always overwrites its own output.
  • Formats. Inputs: JPEG, PNG, WebP, BMP. Output: same format as the input (JPEG saved at quality ~92). Unsupported or corrupt files are marked failed/skipped in the UI and the workflow continues.
  • EXIF orientation. Camera rotation tags are baked into the pixels when a photo loads, so the preview, the rectangle, and the output are all visually consistent. Orientation is never a surprise at the end.
  • Recursive input scan. Every image under --input, including subfolders, is a candidate. Photos are ordered by filename (case-insensitive). Outputs always land flat in the output directory.
  • Progress persistence. A state file (.photo_cropper_state.json in the output dir) records each photo's status and rotation, and done-photos are detected from existing output files too. The crop square itself always resets to the output-resolution default for every photo — saved crop positions are deliberately not restored, so each photo starts clean.
  • Save & auto-advance. Saving writes the file and moves to the next photo — the fast path for a big batch. Navigation without saving is always free.
  • Zoom & pan is a view transform only. Crop state lives in image pixel coordinates; zooming and panning never mutate it. Dragging and resizing work identically at any zoom level, with more precision while zoomed in. Nudging (1 px / 10 px), Fit (0), and Center-on-rect (C) keep the rectangle reachable even when it's partially off-screen.
  • Rotation. 90° steps in the UI, per photo, persisted. The crop rectangle is measured against the rotated image, and the saved output is the rotated + cropped result. Rotation is applied at save time — source files are never modified. Keyboard split: arrows navigate photos, WASD nudges the crop rectangle (1 px, Shift = 10 px), so the two gestures never collide.
  • Default crop resets per photo. Opening any photo starts the square at the output resolution (clamped to the image if the photo is smaller), centered — never the previous photo's position or a previously saved position.
  • Result preview. After saving, a thumbnail of the actual output file is shown.

API

All endpoints are JSON except where noted.

Method Path Description
GET / The web UI (HTML)
GET /api/photos Batch: list of photos with status, dimensions, crop, output name
GET /api/photo/<path>/image The photo's pixels (EXIF orientation baked, cached)
GET /api/photo/<path>/metadata Loaded dimensions + source info
POST /api/photo/<path>/state Persist interim crop rect + rotation (auto-saved on change)
POST /api/photo/<path>/crop Write the output file; returns output name + preview URL
GET /api/output/<name> Serve a saved output file (previews)

<path> is the photo's path relative to the input dir (URL-encoded).

Project structure

photo_cropper/
├── README.md
├── pyproject.toml            # uv project, deps: flask, pillow
├── src/photo_cropper/
│   ├── __init__.py
│   ├── server.py             # Flask app, routes, CLI entry point
│   ├── images.py             # scanning, cropping, saving, output naming
│   ├── state.py              # progress persistence (.photo_cropper_state.json)
│   ├── templates/index.html  # single-page UI
│   └── static/
│       ├── app.js            # canvas crop UI (vanilla JS, zero deps)
│       └── app.css

Development

uv sync                 # install deps into the venv
uv run photo-cropper --input samples/in --output samples/out
uv run python -c "..."  # or use the venv for scripts

Architecture notes:

  • Backend is Flask + Pillow. Crop requests are handled synchronously — for a single user this is instantaneous for typical resolutions.
  • Frontend is a single canvas: image and rectangle are drawn together, mouse input is converted from screen pixels to image pixels. The rectangle is clamped to image bounds and aspect-locked in image space.
  • State is a JSON file in the output dir, keyed by input-relative path, so it survives restarts and remains portable with the outputs.