Initial public release tree

This commit is contained in:
2026-08-04 18:31:51 +02:00
commit fb80764e66
18 changed files with 11770 additions and 0 deletions

46
.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
dist/
build/
# Virtual environment
.venv/
# IDE
.idea/
.vscode/
*.swp
*.swo
# Local Claude session/memory data (the .claude/skills/ dir IS tracked)
.claude/projects/
# OS
.DS_Store
Thumbs.db
# Test outputs
samples/result_*.jpg
# Generated synthetic test images (regenerated by samples/generate_test_images.py)
samples/test_*.jpg
# Debug log (default --debug output file)
rectify_debug.log
# Sweep outputs (regenerated by samples/sweep_keystone_lines.py)
doc/sweep_keystone_lines/
# Test photos (real images, too large/private for the repo)
doc/imgsrc/
# Scratch and demo material not intended for the repo
doc/demo/
doc/make_demo.py
notes/
# Editor backup files
*~
*.py~

11
LICENSE.md Normal file
View File

@@ -0,0 +1,11 @@
# License
Copyright © 2026 Andy Kopra. All rights reserved.
**This is a preliminary notice. A license will be published here.**
You may use this software and modify it for your own experimentation and use.
The name "Rectify" is reserved by the author. Nothing here grants permission to
distribute software under the name "Rectify" without the author's
authorization.

241
README.md Normal file
View File

@@ -0,0 +1,241 @@
# Rectify
Perspective correction for paintings and rectangular objects. Takes a photo of a painting on a wall (taken at an angle) and produces a head-on, undistorted rectangular image.
## Quick start
```bash
# From source
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
python -m rectify --gui photo.jpg
```
Or download a pre-built executable from the releases page — no Python required.
## Setup from source
```bash
git clone https://git.andykopra.com/ack/rectify.git
cd rectify
python3 -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
```
**Platform notes:**
- **Linux:** May need `sudo apt install libxcb-xinerama0 libxcb-cursor0` for Qt
- **macOS:** Works with Homebrew or python.org Python
- **Windows:** See [Run from source on Windows](#run-from-source-on-windows) below
### Run from source on Windows
Every dependency ships a Windows wheel — PySide6 bundles Qt and `pillow-heif`
bundles libheif — so `pip install` is the whole build step. No compiler and no
system libraries are required, and the **Open** dialog is the standard Explorer
dialog, with image thumbnails.
**Prerequisites:** Python 3.103.13 from
[python.org](https://www.python.org/downloads/windows/) — tick **"Add python.exe
to PATH"** in the installer. `git` is optional; you can download the repository
as a ZIP instead.
**PowerShell:**
```powershell
git clone https://git.andykopra.com/ack/rectify.git
cd rectify
py -m venv .venv
.\.venv\Scripts\Activate.ps1
pip install -r requirements.txt
python -m rectify --gui
```
If PowerShell refuses to run the activation script, allow it for that window
only:
```powershell
Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass
```
**Command Prompt** is identical except for the activation line:
```
.venv\Scripts\activate.bat
```
Usage is the same as on the other platforms — see [Usage](#usage) below, and
`python -m rectify --help` for the CLI flags. Per-user settings are written to
`%APPDATA%\<username>\Rectify\settings.json`.
**If something goes wrong:**
- *`ImportError: DLL load failed while importing QtCore`* — install the
[Microsoft Visual C++ 20152022 Redistributable (x64)](https://aka.ms/vs/17/release/vc_redist.x64.exe),
which Qt needs. It is already present on most Windows installations.
- *`py` is not recognized* — the Python launcher was not installed; use
`python -m venv .venv` instead, or re-run the installer with **"Add python.exe
to PATH"** ticked.
- *Blurry or oversized UI on a scaled display* — Rectify scales its own fonts;
report the display scaling percentage along with a screenshot.
**Reporting back on a Windows trial.** Windows is not yet a regularly tested
platform, so the following are the points most likely to differ. Please note how
each behaves:
1. Opening a **HEIC/HEIF** photo from an iPhone.
2. Display scaling at 125% and 150% — menu, panel, and control sizing.
3. The keyboard shortcuts (press **Shift+Alt** for the overlay) — all use Ctrl
on Windows.
4. Saving to a folder whose path contains spaces.
5. Building an executable with `pyinstaller rectify.spec` (see
[Building a standalone executable](#building-a-standalone-executable)) and
launching the resulting `dist\rectify.exe`.
## Usage
### GUI (primary interface)
```bash
python -m rectify --gui # launch empty, open file later
python -m rectify --gui photo.jpg # launch with an image
python -m rectify --gui /path/to/photos/ # launch with file dialog in that directory
```
The GUI provides:
- **Input formats** — JPEG, PNG, TIFF, BMP, WebP, and **HEIC/HEIF** (iPhone photos). HEICs are decoded to their SDR base image and converted from Display P3 to sRGB, so colors are correct; any HDR gain map is ignored (the right behavior for SDR documentation). Output is saved in the standard formats below
- **Two-panel view** — original image with detected quad (left), rectified result (right)
- **Zoom and pan** — mouse wheel to zoom (anchors under cursor), right-click to reset zoom, drag outside the quad to pan when zoomed in
- **Automatic detection** — opens the image, picks the best strategy (grayscale or saturation) and sensitivity by sweeping all combinations, and presents the result. No knobs to set in the GUI; for fine-tuning the detected quad, use the manual editing gestures below
- **Aspect ratio correction** — recovers the true width/height of a photographed rectangle from EXIF focal length and perspective geometry; manual override via a labelled checkbox plus value slider. The value's styling is a cue: a **gray italic** value means no action is needed from you (the correction is off, or a reliable value was recovered automatically from the photo's camera data); a **black** value means the photo has no usable camera data (e.g. a screenshot) so you should set the ratio by eye; a **red** value means the recovered ratio is wider/taller than the slider's 0.1010.0 range, so it's pinned at the limit and the proportions can't be fully reached. Applies to Extract and Transform → Quad output; hidden in Transform → Lines mode, which has its own **Stretch** control instead (below)
- **Bow correction** — centre-origin slider (200…200 px) that straightens edges which bow in the extracted output (typical mild residual lens distortion). Corner-anchored: corners stay fixed and mid-edge content is moved. The value is a radial distance in output pixels — positive straightens inward-bowed (pincushion) edges by pushing content outward, negative straightens outward-bowed (barrel) edges by pulling content inward (converted internally to a curvature coefficient against the output's half-diagonal). Drag the slider, use the spin box, or type a value to find the minimum magnitude that straightens the edges. Per-image: cached so switching between images preserves each image's bow value. Extract mode only
- **Color correct** — recover accurate color when a neutral reference is in the frame. Tick **Color correct**, then choose a reference mode — **Gray** or **White** (neither is preselected; pick the one that matches your reference). Click the swatch, then click the reference area in the left image; Rectify averages a small square around that point — its side length in pixels is the **Sample size** (1 = just the clicked pixel; default 10 = a 10×10 block) — and white-balances the output so the patch becomes neutral. **Gray** mode (a gray card, or any surface you trust as neutral gray) couples color and exposure: the **Reflectance** value is the target tone the patch is mapped to, in the photographer's unit — default **18%** (standard middle-gray card); set it to your card's rating (e.g. 12%) or adjust to taste (adjustable 380%). **White** mode (a white sheet or other white reference) *decouples* them: it neutralizes the color cast while keeping the patch's own brightness — so white is **not** forced to maximum and anything brighter (a highlight, a lamp) keeps its headroom — and a centered **Brightness** slider (0 = unchanged) then raises or lowers the whole result independently. The swatch shows the sampled color and turns red if the spot is too bright or too dark to use; a 2-pixel-wide red border marks the sampled region, with the averaged pixels just inside it. Sample the card outside the artwork — it's read from the source image but the correction is applied to the result, in every mode. **The measured gray is sticky:** shoot one reference frame with a gray card, pick it once, then for other images taken under the same light just tick Color correct and they reuse that gray automatically (no need for a card in every shot). Reusing a gray takes a frozen *snapshot* — re-picking the reference later won't silently change images that already borrowed it. **Left-click** the swatch to pick a gray on the current image; **right-click** it to re-apply the last-used gray (handy when an image already has its own value you want to replace). An image you picked on keeps its own measurement and shows the red marker; one using a borrowed gray shows the color in the swatch but no marker
- **Corner dragging** — click and drag corners; arrow keys for 1-pixel nudge
- **Edge dragging** — click near an edge and drag to move it perpendicular to itself; arrow keys for fine nudging
- **Ctrl+Left-Click** — moves the nearest movable point (a quad corner in Extract mode, a keystone-line arrow handle in Lines mode) to the click position. Designed for precision adjustment while zoomed in; the cursor becomes a crosshair whenever Ctrl is held so you know the gesture is armed
- **Alt+Left-Click ×2** — draws a red antialiased "alternate-line" annotation between two clicked points. Useful for sketching what auto-detection *should* produce in screenshots and bug reports. A dashed rubber-band tracks the cursor between the first and second click; multiple annotations accumulate; Esc clears them all (and any temporary marks in general); a new image load also clears.
- **Alignment indicator** — edges turn blue when their endpoints are vertically or horizontally aligned (same x or y value); the same blue/green indicator applies to keystone-line pairs in Lines mode
- **Subregion selection** — click in the center of the quad and drag to reposition; scroll wheel while dragging to resize. Use with Peel in to extract individual subjects from multi-subject photos
- **Peel stack** — Peel in/out to strip successive frame layers; left panel always shows the original with mapped-back overlay
- **Full-image perspective correction** — correct the entire image's perspective using a reference quad (e.g., a window or known rectangle), like a view camera tilt/shift. The Transform-mode **Crop** checkbox switches between a filled full canvas (default) and an auto-cropped rectangle
- **Keystone correction** — remove vertical and/or horizontal keystone distortion using line pairs. Draw one or two pairs of lines on features known to be parallel (e.g., building edges, door frames); the correction makes them parallel in the output. Arrow-shaped handles distinguish lines from the quad overlay
- **Stretch** (Transform → Lines) — keystone correction straightens converging lines but can't recover how wide the result should be relative to its height (the lines carry no scale, and there's no reference rectangle as in Quad mode). The **Stretch** slider is a by-eye correction for that residual width-to-height relationship: 1.00 leaves the width unchanged, above 1.00 widens, below narrows. Shown only in Lines mode; remembered per image
- **Saving** — one **Save** button with an **Increment** checkbox. The default output name is the source image's name with **`_rectified`** appended (e.g. `hotel.png``hotel_rectified.png`), so a save never overwrites the input. With Increment **off**, Save opens a dialog (pre-filled with that default) where the extension you type picks the format (png/jpg/jpeg/tiff/tif/webp/bmp; an unsupported type is rejected with the supported list). With Increment **on**, Save writes the next auto-numbered file (`hotel_rectified_1`, `hotel_rectified_2`, …) with one click — no dialog. Both modes share the last-used folder and type; folder, type, and the Increment setting persist across sessions
- **Undo/redo** — Ctrl+Z / Ctrl+Shift+Z for corner adjustments (keyboard only)
- **Drag and drop** — drop an image file onto the window
- **Before/after** — hold Space to compare
- **Tooltips** — hover any control for a short explanation in a readable boxed popup; toolbar buttons also show their keyboard shortcut. Fully translated in all three interface languages
- **Settings persistence** — all preferences (including your last save folder, file type, and Increment mode), window layout, and per-image state (corners, keystone lines, bow value, Stretch value, color-correction sample, plus any manual override of the aspect ratio) cached for every image you've touched. Switch between images with Ctrl+↑/↓ while preparing a batch; come back to any image and your tuning is intact. Saved on close and restored on next launch
### Keyboard shortcuts
| Shortcut | Action |
|----------|--------|
| Ctrl+O | Open image |
| Ctrl+S | Save (opens the dialog, or writes the next auto-numbered file when Increment is on) |
| Ctrl+D | Reset (re-detect from scratch) |
| Ctrl+R | Re-open the current file (re-read pixels from disk; per-image cache preserves corners, keystone pairs, bow) |
| Ctrl+↓ / Ctrl+↑ | Load next / previous image in the current directory (wraps around) |
| Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo |
| + or = | Peel in |
| - | Peel out |
| Space (hold) | Before/after comparison |
| Arrow keys | Nudge selected corner, edge, or whole quad |
| Mouse wheel | Zoom (or adjust element with Shift or left-button held) |
| Shift + wheel | Adjust quad element under cursor (corner, edge, or whole quad) |
| Ctrl + Left-click | Snap the nearest point (corner or line endpoint) to the click position |
| Alt + Left-click ×2 | Draw a red alternate-line annotation between two clicks |
| Esc | Clear temporary marks (annotations, etc.) |
| Right-click | Reset zoom on clicked panel |
| 0 | Reset zoom on both panels |
| Shift+Alt (hold) | Show keyboard-shortcut overlay (centered popup) |
The same shortcut table is printed by `rectify -k` (also `--keyboard`), so you can read it without opening the GUI.
### Command line
```bash
python -m rectify photo.jpg -o rectified.jpg
python -m rectify photo.jpg -o rectified.jpg --strategy saturation -s 0.7
python -m rectify photo.jpg -o rectified.jpg --peel 1
# Full-image perspective correction
python -m rectify photo.jpg -o corrected.jpg --full-image
python -m rectify photo.jpg -o corrected.jpg --full-image --full-image-crop
python -m rectify photo.jpg -o corrected.jpg --full-image --fill-color "#808080"
# Debug logging (writes detection details to a log file)
python -m rectify --gui photo.jpg --debug
python -m rectify --gui photo.jpg --debug my_debug.log
# Incremental output (auto-numbered)
python -m rectify photo.jpg --dir output/ --prefix museum --ext jpg
```
## Detection strategies
The GUI always runs the automatic two-pass sweep — both detection strategies, both ends of the sensitivity range, best result wins. The CLI exposes manual overrides via `--strategy {auto, grayscale, saturation}`, `-s/--sensitivity`, and the individual Canny parameters (`--blur`, `--canny-low`, `--canny-high`, `--min-area`, `--epsilon`).
- **Grayscale** — Edge detection on luminance. Best when subject and background differ in brightness.
- **Saturation** — Edge detection on HSV saturation channel (no blur). Best when brightness is similar but color richness differs (e.g., tiles on brick).
- **Auto** (default) — Evaluates both using a two-pass sensitivity sweep (coarse then fine), picks the best. Scores candidates by rectangularity, margin from image edges, and perspective plausibility (vanishing-point orthogonality). Prefers larger regions initially; smaller on peel-in.
## Installation methods
### Pre-built executables
Download the executable for your platform from the releases page. No Python installation is required.
**Linux:**
```bash
chmod +x rectify
./rectify --gui photo.jpg
```
**macOS:**
- Download `Rectify.dmg`, open it, and drag `Rectify.app` to your Applications folder
- Double-click `Rectify.app` to launch — it opens the GUI with a file dialog
- You can also drag an image file onto the `Rectify.app` icon in Finder or the Dock to open it directly
- Or download the command-line executable and run from Terminal:
```bash
chmod +x rectify
./rectify --gui photo.jpg
```
**Windows:**
- Download `rectify.exe`
- Double-click to launch (opens the GUI with a file dialog)
- Or run from Command Prompt / PowerShell:
```powershell
.\rectify.exe --gui photo.jpg
```
### Building a standalone executable
To create a distributable executable from source, two files are provided:
- **`build.sh`** — Shell script that installs PyInstaller (if needed) and runs the build. Supports `--onedir` for faster development builds.
- **`rectify.spec`** — PyInstaller spec file with the build configuration, including hidden imports for PySide6, macOS `.app` bundle settings, and image file type associations.
```bash
pip install pyinstaller
./build.sh # Linux/macOS: single-file executable
./build.sh --onedir # Linux/macOS: directory build (faster startup)
pyinstaller rectify.spec # Windows (from command prompt)
```
The signed, notarized macOS `.dmg` is produced by the maintainer — it needs
installer artwork and an Apple Developer ID, so it is not something this source
distribution can build. Download it from the releases page instead.
The output appears in `dist/`. On macOS, `rectify.spec` also creates a `Rectify.app` bundle with the bundle identifier `com.andykopra.rectify`.
**Platform-specific build notes:**
- **Linux:** The resulting binary is platform-specific (not cross-platform). It may require `libxcb` libraries on the target system.
- **macOS:** The spec file includes `BUNDLE` configuration for a `.app` bundle. Code signing may be needed for distribution outside of direct sharing.
- **Windows:** Use `pyinstaller rectify.spec` from a command prompt. The spec sets `console=False` to suppress the console window.
See `doc/Rectify_user_guide.md` for full documentation including usage examples and a programmer's guide.

40
build.sh Executable file
View File

@@ -0,0 +1,40 @@
#!/bin/bash
# Build Rectify as a standalone executable using PyInstaller.
#
# Usage:
# ./build.sh # Build for the current platform
# ./build.sh --onedir # Build as a directory (faster startup, for development)
#
# Prerequisites:
# pip install pyinstaller
#
# Output:
# dist/rectify (Linux/macOS executable)
# dist/rectify.exe (Windows executable)
# dist/Rectify.app (macOS app bundle, macOS only)
set -e
# Ensure PyInstaller is installed
if ! command -v pyinstaller &> /dev/null; then
echo "Installing PyInstaller..."
pip install pyinstaller
fi
# Build
if [ "$1" = "--onedir" ]; then
echo "Building (one-directory mode for development)..."
pyinstaller --noconfirm --onedir --console=false \
--name rectify \
--hidden-import PySide6.QtWidgets \
--hidden-import PySide6.QtGui \
--hidden-import PySide6.QtCore \
rectify/__main__.py
else
echo "Building (single-file mode for distribution)..."
pyinstaller --noconfirm rectify.spec
fi
echo ""
echo "Build complete. Output in dist/"
ls -lh dist/rectify* 2>/dev/null || true

1635
doc/Rectify_user_guide.md Normal file

File diff suppressed because it is too large Load Diff

111
rectify.spec Normal file
View File

@@ -0,0 +1,111 @@
# -*- mode: python ; coding: utf-8 -*-
"""PyInstaller spec file for Rectify.
Build with:
pyinstaller rectify.spec
Produces:
dist/rectify/ (onedir bundle: launcher + all libs as real files)
dist/rectify.exe (Windows single-file executable)
dist/Rectify.app (macOS .app bundle, macOS only)
macOS uses a ONEDIR build (not onefile) on purpose: notarization requires
every nested .dylib/.so to be individually code-signed, which is only
possible when they sit on disk as real files rather than packed inside a
single executable. UPX is disabled for the same reason it rewrites
Mach-O headers and invalidates signatures.
Signing and notarization are handled end-to-end by build_dmg.sh:
./build_dmg.sh --sign "Developer ID Application: Andrew Kopra (KF3QXUS8G6)" \
--notarize andykopra-notary
"""
import sys
from PyInstaller.utils.hooks import collect_all
VERSION = '0.2.0'
# pillow-heif ships a native libheif and has no bundled PyInstaller hook,
# so collect its binaries/data/submodules explicitly. (Verify on a real
# build per platform — native HEIF libs are the fragile part of bundling.)
_heif_datas, _heif_binaries, _heif_hidden = collect_all('pillow_heif')
a = Analysis(
['rectify/__main__.py'],
pathex=[],
binaries=_heif_binaries,
datas=_heif_datas,
hiddenimports=[
'PySide6.QtWidgets',
'PySide6.QtGui',
'PySide6.QtCore',
'pillow_heif',
'PIL.Image',
'PIL.ImageCms',
*_heif_hidden,
],
hookspath=[],
hooksconfig={},
runtime_hooks=[],
excludes=[],
noarchive=False,
)
pyz = PYZ(a.pure)
exe = EXE(
pyz,
a.scripts,
[],
exclude_binaries=True, # onedir: keep libraries OUT of the executable so each
# nested dylib/.so is a real, individually signable file
name='rectify',
debug=False,
bootloader_ignore_signals=False,
strip=False,
upx=False, # UPX rewrites Mach-O headers and breaks code signing
console=False, # GUI application — no console window
icon=None, # TODO: add application icon (.ico for Windows, .icns for macOS)
)
coll = COLLECT(
exe,
a.binaries,
a.datas,
strip=False,
upx=False,
upx_exclude=[],
name='rectify',
)
# macOS .app bundle (only used when building on macOS)
if sys.platform == 'darwin':
app = BUNDLE(
coll,
name='Rectify.app',
icon='resources/Rectify.icns',
bundle_identifier='com.andykopra.rectify',
info_plist={
'CFBundleName': 'Rectify',
'CFBundleDisplayName': 'Rectify',
'CFBundleShortVersionString': VERSION,
'CFBundleVersion': VERSION,
'NSHighResolutionCapable': True,
# File type associations — allows "Open with" and drag-to-icon
'CFBundleDocumentTypes': [
{
'CFBundleTypeName': 'Image',
'CFBundleTypeRole': 'Editor',
'LSHandlerRank': 'Alternate',
'LSItemContentTypes': [
'public.jpeg',
'public.png',
'public.tiff',
'com.microsoft.bmp',
'org.webmproject.webp',
],
},
],
},
)

3
rectify/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Rectify — perspective correction for paintings and rectangular objects."""
__version__ = "0.1.0"

5
rectify/__main__.py Normal file
View File

@@ -0,0 +1,5 @@
"""Entry point for `python -m rectify`."""
from rectify.cli import main
main()

196
rectify/cli.py Normal file
View File

@@ -0,0 +1,196 @@
"""Command-line interface for Rectify."""
import argparse
import os
import sys
from rectify.utils import load_image, save_image
from rectify.detect import detect_quad, evaluate_strategies
from rectify.transform import rectify, rectify_full_image
from rectify.shortcuts import format_shortcuts_text
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="rectify",
description="Perspective-correct a painting or rectangular object in a photo.",
epilog="Use -k/--keyboard to print the GUI keyboard-shortcut table.",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
parser.add_argument("input", nargs="?", default=None, help="Path to the input image")
parser.add_argument(
"-k", "--keyboard", action="store_true",
help="Print the GUI keyboard-shortcut table and exit",
)
parser.add_argument(
"-o", "--output", default=None,
help="Path for the output image (default: use --dir/--prefix/--ext)",
)
# Incremental output naming
parser.add_argument(
"--dir", default=".", metavar="DIR",
help="Output directory for incremental saves (default: current directory)",
)
parser.add_argument(
"--prefix", default="rectify",
help="Output filename prefix (default: rectify)",
)
parser.add_argument(
"--ext", default="png",
help="Output file extension/format (default: png)",
)
# Detection strategy
parser.add_argument(
"--strategy", choices=["auto", "grayscale", "saturation"], default="auto",
help="Detection strategy: auto (default), grayscale, or saturation",
)
# Detection parameters — simple mode
parser.add_argument(
"-s", "--sensitivity", type=float, default=None,
help="Detection sensitivity 0.01.0 (overrides individual params)",
)
# Detection parameters — advanced mode
parser.add_argument("--blur", type=int, default=5, help="Gaussian blur kernel size (odd, default 5)")
parser.add_argument("--canny-low", type=int, default=50, help="Canny low threshold (default 50)")
parser.add_argument("--canny-high", type=int, default=150, help="Canny high threshold (default 150)")
parser.add_argument("--min-area", type=float, default=0.05, help="Minimum contour area ratio (default 0.05)")
parser.add_argument("--epsilon", type=float, default=0.02, help="Polygon approximation epsilon ratio (default 0.02)")
# Peel layers (successive inner region detection)
parser.add_argument(
"--peel", type=int, default=0, metavar="N",
help="After rectifying, peel N additional inner layers (default 0)",
)
parser.add_argument(
"--remove-frame", action="store_true",
help="Shorthand for --peel 1",
)
# Full-image perspective correction
parser.add_argument(
"--full-image", action="store_true",
help="Correct perspective for the entire image (view-camera style)",
)
parser.add_argument(
"--full-image-crop", action="store_true",
help="With --full-image, crop to the largest inscribed rectangle",
)
parser.add_argument(
"--fill-color", default="#000000", metavar="COLOR",
help="Background fill color for --full-image (hex, default #000000)",
)
# GUI mode
parser.add_argument("--gui", action="store_true", help="Launch interactive GUI")
# Debug logging
parser.add_argument(
"--debug", nargs="?", const="rectify_debug.log", default=None,
metavar="FILE",
help="Write detailed debug log to FILE (default: rectify_debug.log)",
)
return parser
def _is_bundled_app() -> bool:
"""Return True if running as a PyInstaller .app bundle."""
return getattr(sys, 'frozen', False) and sys.platform == 'darwin'
def main(argv: list[str] | None = None) -> None:
parser = build_parser()
args = parser.parse_args(argv)
if args.keyboard:
print(format_shortcuts_text())
return
if args.debug:
from rectify.debug import enable_debug
enable_debug(args.debug)
print(f"Debug logging to: {args.debug}")
# When launched as a macOS .app bundle, always default to GUI
if args.gui or _is_bundled_app():
from rectify.gui import launch_gui
initial = args.input
initial_dir = None
if initial and os.path.isdir(initial):
initial_dir = os.path.abspath(initial)
initial = None
launch_gui(initial, initial_dir=initial_dir)
return
if args.input is None:
parser.error("an input image is required (unless using --gui)")
image = load_image(args.input)
# Build detection kwargs
detect_kwargs = {}
if args.sensitivity is not None:
detect_kwargs["sensitivity"] = args.sensitivity
else:
detect_kwargs.update(
blur_kernel=args.blur,
canny_low=args.canny_low,
canny_high=args.canny_high,
min_area_ratio=args.min_area,
epsilon_ratio=args.epsilon,
)
detect_kwargs["strategy"] = args.strategy
corners = detect_quad(image, **detect_kwargs)
if corners is None:
print("Error: no quadrilateral detected in the image.", file=sys.stderr)
print("Try adjusting --sensitivity or use --gui for interactive tuning.", file=sys.stderr)
sys.exit(1)
if args.full_image:
# Parse fill color
fill_hex = args.fill_color.lstrip("#")
r, g, b = int(fill_hex[0:2], 16), int(fill_hex[2:4], 16), int(fill_hex[4:6], 16)
fill_bgr = (b, g, r)
result = rectify_full_image(
image, corners,
crop=args.full_image_crop,
fill_color=fill_bgr,
)
if result is None:
print("Error: detected region is too small to rectify.", file=sys.stderr)
sys.exit(1)
else:
result = rectify(image, corners)
if result is None:
print("Error: detected region is too small to rectify.", file=sys.stderr)
sys.exit(1)
peel_count = args.peel if args.peel > 0 else (1 if args.remove_frame else 0)
for i in range(peel_count):
strategy, sensitivity, inner_corners = evaluate_strategies(result, prefer_larger=False, pad_image=False)
if inner_corners is None:
print(f"Peel {i + 1}: no inner region found; stopping.", file=sys.stderr)
break
peeled = rectify(result, inner_corners)
if peeled is None:
print(f"Peel {i + 1}: region too small; stopping.", file=sys.stderr)
break
result = peeled
print(f"Peel {i + 1}: {strategy} s={sensitivity:.2f}")
if args.output:
output_path = args.output
else:
from rectify.gui import find_next_n
ext = args.ext.lstrip(".")
n = find_next_n(args.dir, args.prefix)
output_path = os.path.join(args.dir, f"{args.prefix}_{n}.{ext}")
save_image(output_path, result)
print(f"Rectified image saved to: {output_path}")

87
rectify/debug.py Normal file
View File

@@ -0,0 +1,87 @@
"""Debug logging for Rectify.
When enabled via --debug, writes detailed information about detection
decisions, scoring, mask operations, and peel stack changes to a log
file. The output is designed to be read by a developer (or pasted
into a conversation with an AI assistant) to diagnose detection and
workflow problems.
Usage:
from rectify.debug import dbg, enable_debug
enable_debug("rectify_debug.log") # or None for no logging
dbg("detection", f"Found {n} contours")
"""
import os
import time
_debug_file = None
_start_time = None
def enable_debug(path: str | None = None):
"""Enable debug logging to the given file path.
If *path* is None, disable logging. The file is opened in write
mode (overwritten each run).
"""
global _debug_file, _start_time
if _debug_file is not None:
_debug_file.close()
_debug_file = None
if path is not None:
_debug_file = open(path, "w", encoding="utf-8")
_start_time = time.monotonic()
_debug_file.write(f"# Rectify debug log — {time.strftime('%Y-%m-%d %H:%M:%S')}\n")
_debug_file.write(f"# Working directory: {os.getcwd()}\n\n")
_debug_file.flush()
def is_debug_enabled() -> bool:
"""Return True if debug logging is active."""
return _debug_file is not None
def dbg(category: str, message: str):
"""Write a debug message if logging is enabled.
Categories help structure the output:
detect — detection pipeline (contours, edge passes, candidates)
evaluate — evaluate_strategies scoring and selection
gui — GUI actions (mask, unmask, peel, load, save)
transform — perspective transform, aspect ratio
"""
if _debug_file is None:
return
elapsed = time.monotonic() - _start_time
_debug_file.write(f"[{elapsed:8.3f}s] [{category:10s}] {message}\n")
_debug_file.flush()
def dbg_section(title: str):
"""Write a section header to the debug log."""
if _debug_file is None:
return
elapsed = time.monotonic() - _start_time
_debug_file.write(f"\n{'=' * 70}\n")
_debug_file.write(f"[{elapsed:8.3f}s] {title}\n")
_debug_file.write(f"{'=' * 70}\n\n")
_debug_file.flush()
def dbg_corners(label: str, corners, indent: int = 0):
"""Log a set of corners with a label."""
if _debug_file is None:
return
prefix = " " * indent
if corners is None:
dbg("", f"{prefix}{label}: None")
return
import cv2
area = cv2.contourArea(corners)
dbg("", f"{prefix}{label}: area={area:.0f} "
f"TL=({corners[0][0]:.1f},{corners[0][1]:.1f}) "
f"TR=({corners[1][0]:.1f},{corners[1][1]:.1f}) "
f"BR=({corners[2][0]:.1f},{corners[2][1]:.1f}) "
f"BL=({corners[3][0]:.1f},{corners[3][1]:.1f})")

1303
rectify/detect.py Normal file

File diff suppressed because it is too large Load Diff

6631
rectify/gui.py Normal file

File diff suppressed because it is too large Load Diff

142
rectify/shortcuts.py Normal file
View File

@@ -0,0 +1,142 @@
"""Keyboard-shortcut catalog shared by the GUI overlay and CLI --help.
The catalog uses i18n keys for section names and action descriptions.
The GUI overlay translates them via the LANGUAGES dict in gui.py; the
CLI --help epilog renders them using the English fallback labels
defined here.
Key labels adapt to the platform. On macOS the modifier words are shown
as the native symbols (⌘ ⌥ ⇧), reflecting Qt's Ctrl↔Cmd swap — the app's
"Ctrl" shortcuts are the ⌘ key on a Mac. A few mouse gestures carry an
explicit macOS label (an optional 3rd tuple element); notably the
secondary-click panel-zoom reset is ⌃-click (physical Control), which
macOS delivers as a right-click, while ⌘-click is the snap gesture.
"""
import sys
_IS_MAC = sys.platform == "darwin"
# (section_i18n_key, ((keys, action_i18n_key[, macos_keys]), ...))
SECTIONS = (
("sk_sec_file", (
("Ctrl+O", "open"),
("Ctrl+S", "save"),
("Ctrl+D", "reset_detect"),
("Ctrl+R", "reopen"),
("Ctrl+↓", "sk_next_image"),
("Ctrl+↑", "sk_prev_image"),
)),
("sk_sec_edit", (
("Ctrl+Z", "undo"),
("Ctrl+Shift+Z", "redo"),
)),
("sk_sec_peel", (
("+ or =", "peel_in"),
("", "peel_out"),
)),
("sk_sec_view", (
("0", "sk_reset_zoom"),
("Wheel", "sk_zoom"),
("Right-click", "sk_reset_zoom_panel", "⌃-click"),
("Space", "sk_compare"),
)),
("sk_sec_quad", (
("← → ↑ ↓", "sk_nudge"),
("Shift", "sk_highlight"),
("Shift + Wheel", "sk_adjust_highlighted"),
("Ctrl+Click", "sk_snap_nearest", "⌘-click"),
)),
("sk_sec_dev", (
("Alt+Click ×2", "sk_annotate_line", "⌥-click ×2"),
("Esc", "sk_clear_temp"),
("Ctrl+Shift+Del", "sk_clear_cache"),
("Ctrl+Shift+E", "sk_edge_debug"),
("Ctrl+Shift+W", "sk_screenshot"),
)),
("sk_sec_help", (
("Shift+Alt", "sk_show_shortcuts"),
)),
)
def _mac_translate(keys: str) -> str:
"""Render a Ctrl/Alt/Shift spec with macOS symbols, honouring Qt's
Ctrl↔Cmd swap (the app's 'Ctrl' is the ⌘ key on macOS)."""
s = keys
s = s.replace("Ctrl+Shift+", "⇧⌘") # macOS modifier order: ⇧ before ⌘
s = s.replace("Shift+Alt", "⌥⇧")
s = s.replace("Ctrl+", "")
s = s.replace("Alt+", "")
s = s.replace("Shift + ", "")
if s == "Shift":
s = ""
return s
def key_label(entry) -> str:
"""Display label for a SECTIONS row, adapted to the platform.
*entry* is ``(keys, action)`` or ``(keys, action, macos_keys)``.
"""
if _IS_MAC:
if len(entry) >= 3 and entry[2]:
return entry[2]
return _mac_translate(entry[0])
return entry[0]
# The hold-to-show-overlay combo, platform-adapted: "⌥⇧" on macOS, "Shift+Alt"
# on Linux. Derived from SECTIONS so it can never drift from the real binding.
SHOW_SHORTCUTS_KEYS = key_label(next(
row for _, rows in SECTIONS for row in rows
if row[1] == "sk_show_shortcuts"))
# English fallback labels used by the CLI. Must mirror the entries
# under LANGUAGES["en"] in gui.py for the keys that appear in SECTIONS.
_EN = {
"sk_sec_file": "File",
"sk_sec_edit": "Edit",
"sk_sec_peel": "Peel",
"sk_sec_view": "View",
"sk_sec_quad": "Adjust quad",
"sk_sec_dev": "Developer",
"sk_sec_help": "Help",
"open": "Open",
"save": "Save",
"reset_detect": "Reset",
"reopen": "Re-open",
"undo": "Undo",
"redo": "Redo",
"peel_in": "Peel in",
"peel_out": "Peel out",
"sk_reset_zoom": "Reset zoom (both panels)",
"sk_zoom": "Zoom in / out",
"sk_reset_zoom_panel": "Reset zoom (this panel)",
"sk_compare": "Compare original (hold)",
"sk_nudge": "Nudge corner / edge / quad",
"sk_highlight": "Highlight quad element (hold)",
"sk_adjust_highlighted": "Adjust highlighted element",
"sk_clear_cache": "Clear settings cache",
"sk_edge_debug": "Toggle edge debug overlay",
"sk_show_shortcuts": "Show this list (hold)",
"sk_next_image": "Load next image in directory",
"sk_prev_image": "Load previous image in directory",
"sk_snap_nearest": "Snap nearest point to click",
"sk_screenshot": "Save main-window screenshot to ~/rectify/",
"sk_annotate_line": "Draw an alternate-line annotation (red, antialiased)",
"sk_clear_temp": "Clear temporary marks (annotations, etc.)",
}
def format_shortcuts_text(indent: str = " ") -> str:
"""Render the catalog as plain text for the argparse --help epilog."""
key_width = max(len(key_label(row)) for _, rows in SECTIONS for row in rows)
lines = ["Keyboard shortcuts (GUI):"]
for sec_key, rows in SECTIONS:
lines.append("")
lines.append(f"{indent}{_EN[sec_key]}")
for row in rows:
lines.append(f"{indent} {key_label(row):<{key_width}} {_EN[row[1]]}")
return "\n".join(lines)

892
rectify/transform.py Normal file
View File

@@ -0,0 +1,892 @@
"""Perspective transformation — warp a quadrilateral region to a rectangle."""
import math
import cv2
import numpy as np
# Minimum output dimension in pixels. Anything smaller is degenerate.
MIN_OUTPUT_DIM = 4
def compute_output_size(corners: np.ndarray) -> tuple[int, int]:
"""Compute the width and height of the rectified output.
*corners* is a (4, 2) array ordered [TL, TR, BR, BL].
Width = max(dist(TL,TR), dist(BL,BR))
Height = max(dist(TL,BL), dist(TR,BR))
"""
tl, tr, br, bl = corners
width_top = np.linalg.norm(tr - tl)
width_bottom = np.linalg.norm(br - bl)
width = int(round(max(width_top, width_bottom)))
height_left = np.linalg.norm(bl - tl)
height_right = np.linalg.norm(br - tr)
height = int(round(max(height_left, height_right)))
return width, height
def compute_output_size_corrected(
corners: np.ndarray, aspect_ratio: float | None = None,
) -> tuple[int, int]:
"""Compute output size with aspect ratio correction applied.
Returns the same dimensions that rectify() would use.
"""
width, height = compute_output_size(corners)
if aspect_ratio is not None and aspect_ratio > 0:
current_ratio = width / height
if current_ratio < aspect_ratio:
width = int(round(height * aspect_ratio))
else:
height = int(round(width / aspect_ratio))
return max(width, 1), max(height, 1)
def is_valid_quad(corners: np.ndarray) -> bool:
"""Return True if the quadrilateral is non-degenerate.
Checks that no two corners are the same point, the output
dimensions are at least MIN_OUTPUT_DIM, and the quad has
positive area.
"""
if corners is None:
return False
# Check for duplicate corners
for i in range(4):
for j in range(i + 1, 4):
if np.linalg.norm(corners[i] - corners[j]) < 1.0:
return False
width, height = compute_output_size(corners)
if width < MIN_OUTPUT_DIM or height < MIN_OUTPUT_DIM:
return False
if cv2.contourArea(corners) < MIN_OUTPUT_DIM * MIN_OUTPUT_DIM:
return False
return True
def estimate_aspect_ratio(
corners: np.ndarray,
image_width: int,
image_height: int,
focal_length_35mm: float | None = None,
max_orthogonality_error: float = 0.10,
) -> float | None:
"""Estimate the true width/height aspect ratio of a rectangle from its
perspective projection.
Uses homography decomposition with the camera intrinsic matrix to
recover the aspect ratio exactly (assuming a pinhole camera model).
Parameters
----------
corners : (4, 2) float32 — [TL, TR, BR, BL]
image_width, image_height : image dimensions in pixels
focal_length_35mm : 35mm-equivalent focal length in mm, or None
to use a default of 50mm.
max_orthogonality_error : reject estimates where the decomposed
rotation columns are not sufficiently orthogonal (indicates
lens distortion or bad detection).
Returns width/height, or None if the estimate is unreliable.
"""
if focal_length_35mm is None:
focal_length_35mm = 50.0
focal_px = focal_length_35mm * image_width / 36.0
# Camera intrinsic matrix (pinhole model, principal point at center)
K = np.array([
[focal_px, 0, image_width / 2.0],
[0, focal_px, image_height / 2.0],
[0, 0, 1],
], dtype=np.float64)
K_inv = np.linalg.inv(K)
# Homography mapping a unit square to the image quad.
# H = K * [r1 r2 t] where r1, r2 are rotation matrix columns.
unit_rect = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32)
H = cv2.getPerspectiveTransform(unit_rect, corners.astype(np.float32))
# Decompose: M = K^-1 H = [r1 r2 t]
M = K_inv @ H
r1 = M[:, 0]
r2 = M[:, 1]
norm_r1 = np.linalg.norm(r1)
norm_r2 = np.linalg.norm(r2)
if norm_r1 < 1e-10 or norm_r2 < 1e-10:
return None
# Orthogonality check: |cos(angle between r1 and r2)| should be ~0
cos_angle = abs(np.dot(r1, r2) / (norm_r1 * norm_r2))
if cos_angle > max_orthogonality_error:
return None # estimate unreliable (lens distortion, bad detection)
# For a unit square input, W/H = |r1| / |r2|
ratio = norm_r1 / norm_r2
# Loose absurdity guard only — genuinely wide/tall rectangles (up to ~50:1)
# are returned so callers can decide how to present them. The GUI's aspect
# slider spans 0.1010.0 and flags (red) any returned ratio outside that
# range as clamped; beyond 50:1 the decomposition is almost certainly bad.
if ratio < 0.02 or ratio > 50.0:
return None
return ratio
def rectify(image: np.ndarray, corners: np.ndarray,
aspect_ratio: float | None = None) -> np.ndarray | None:
"""Apply a perspective warp to extract and rectify a quadrilateral region.
Parameters
----------
image : BGR image (numpy array)
corners : (4, 2) float32 array — [top-left, top-right, bottom-right, bottom-left]
aspect_ratio : optional target width/height ratio. If provided, the
output dimensions are adjusted to match this ratio, preserving the
longer dimension and stretching the shorter one (to maximize quality).
Returns
-------
Rectified image, or None if the quadrilateral is degenerate.
"""
if not is_valid_quad(corners):
return None
width, height = compute_output_size(corners)
# Apply aspect ratio correction
if aspect_ratio is not None and aspect_ratio > 0:
current_ratio = width / height
if current_ratio < aspect_ratio:
# Need wider: stretch width, keep height
width = int(round(height * aspect_ratio))
else:
# Need taller: stretch height, keep width
height = int(round(width / aspect_ratio))
if width < MIN_OUTPUT_DIM or height < MIN_OUTPUT_DIM:
return None
# Destination rectangle corners
dst = np.array([
[0, 0],
[width - 1, 0],
[width - 1, height - 1],
[0, height - 1],
], dtype=np.float32)
matrix = cv2.getPerspectiveTransform(corners, dst)
# BORDER_REPLICATE (not the default BORDER_CONSTANT/black): when the
# quad coincides with the input rectangle, the bottom-right source
# corner maps to (W, H) — one past the last valid index (W-1, H-1) —
# so the warp samples just outside the image along the right and
# bottom edges. Replicating the nearest edge pixel (rather than
# filling black) treats all four boundaries symmetrically: the
# top/left edges already sample the in-bounds (0, 0) corner, so this
# makes right/bottom include their boundary pixel too. It also
# matches apply_bow_correction, which already replicates, so a
# negative Bow no longer streaks black off the right/bottom edges.
result = cv2.warpPerspective(image, matrix, (width, height),
borderMode=cv2.BORDER_REPLICATE)
return result
def _warp_full_image(
image: np.ndarray,
H: np.ndarray,
crop: bool = False,
fill_color: tuple[int, int, int] = (0, 0, 0),
) -> np.ndarray | None:
"""Warp the entire image using homography *H*.
Computes the output bounding box, translates to positive
coordinates, and optionally crops to the largest inscribed
axis-aligned rectangle. Output dimensions are capped at
16384 px per side.
"""
img_h, img_w = image.shape[:2]
img_corners = np.array([
[0, 0], [img_w, 0], [img_w, img_h], [0, img_h],
], dtype=np.float32).reshape(-1, 1, 2)
warped_corners = cv2.perspectiveTransform(
img_corners, H.astype(np.float64),
).reshape(-1, 2)
x_min = warped_corners[:, 0].min()
x_max = warped_corners[:, 0].max()
y_min = warped_corners[:, 1].min()
y_max = warped_corners[:, 1].max()
T = np.array([
[1, 0, -x_min],
[0, 1, -y_min],
[0, 0, 1],
], dtype=np.float64)
out_w = int(round(x_max - x_min))
out_h = int(round(y_max - y_min))
MAX_DIM = 16384
if out_w > MAX_DIM or out_h > MAX_DIM:
scale = MAX_DIM / max(out_w, out_h)
S = np.array([[scale, 0, 0], [0, scale, 0], [0, 0, 1]], dtype=np.float64)
T = S @ T
out_w = int(round(out_w * scale))
out_h = int(round(out_h * scale))
if out_w < 1 or out_h < 1:
return None
result = cv2.warpPerspective(
image, T @ H, (out_w, out_h),
borderMode=cv2.BORDER_CONSTANT,
borderValue=fill_color,
)
if crop:
result = _crop_inscribed_rect(result, warped_corners, -x_min, -y_min)
return result
def rectify_full_image(
image: np.ndarray,
corners: np.ndarray,
aspect_ratio: float | None = None,
crop: bool = False,
fill_color: tuple[int, int, int] = (0, 0, 0),
) -> np.ndarray | None:
"""Apply perspective correction to the entire image.
Uses the same homography as rectify() (mapping *corners* to a
rectangle) but warps the whole image rather than cropping. The
result is equivalent to a view-camera tilt/shift correction.
Parameters
----------
image : BGR image
corners : (4, 2) float32 — reference quad [TL, TR, BR, BL]
aspect_ratio : optional target W/H for the reference quad
crop : if True, return the largest inscribed axis-aligned rectangle;
otherwise return the full canvas with *fill_color* background.
fill_color : BGR background color for uncovered areas (ignored
when *crop* is True).
Returns None if the quad is degenerate.
"""
if not is_valid_quad(corners):
return None
width, height = compute_output_size(corners)
if aspect_ratio is not None and aspect_ratio > 0:
current_ratio = width / height
if current_ratio < aspect_ratio:
width = int(round(height * aspect_ratio))
else:
height = int(round(width / aspect_ratio))
if width < MIN_OUTPUT_DIM or height < MIN_OUTPUT_DIM:
return None
dst = np.array([
[0, 0],
[width - 1, 0],
[width - 1, height - 1],
[0, height - 1],
], dtype=np.float32)
H = cv2.getPerspectiveTransform(corners, dst)
return _warp_full_image(image, H, crop, fill_color)
def _crop_inscribed_rect(
image: np.ndarray,
warped_corners: np.ndarray,
offset_x: float,
offset_y: float,
) -> np.ndarray | None:
"""Crop the largest axis-aligned rectangle inscribed in the warped quad.
*warped_corners* are the four original image corners after the
perspective warp (before translation). *offset_x/y* is the
translation applied to make coordinates non-negative.
"""
# Shift corners to image coordinates
pts = warped_corners.copy()
pts[:, 0] += offset_x
pts[:, 1] += offset_y
img_h, img_w = image.shape[:2]
# Build polygon edges (skip near-horizontal ones)
n = len(pts)
edges = []
for i in range(n):
p1 = pts[i]
p2 = pts[(i + 1) % n]
if abs(p2[1] - p1[1]) < 0.5:
continue
# Normalize so p1.y <= p2.y
if p1[1] > p2[1]:
p1, p2 = p2, p1
edges.append((p1.copy(), p2.copy()))
def _x_bounds_at_y(y: float) -> tuple[float, float]:
"""Return (x_left, x_right) of the polygon at scanline *y*.
For a convex quad there are exactly two intersections; the
smaller is the left boundary, the larger the right.
"""
xs = []
for p1, p2 in edges:
if p1[1] <= y <= p2[1]:
t = (y - p1[1]) / (p2[1] - p1[1])
xs.append(p1[0] + t * (p2[0] - p1[0]))
if len(xs) < 2:
return 0.0, float(img_w)
return min(xs), max(xs)
# The tightest x bounds over a y-range [y_top, y_bot] occur at
# vertices or the range endpoints (edges are linear).
y_coords = pts[:, 1]
y_lo = max(0, int(np.ceil(y_coords.min())))
y_hi = min(img_h, int(np.floor(y_coords.max())))
if y_hi <= y_lo:
return image
y_candidates = sorted(set([y_lo, y_hi] + [
int(round(p[1])) for p in pts
if y_lo <= p[1] <= y_hi
]))
best_area = 0
best_rect = None
for i, y_top in enumerate(y_candidates):
for y_bot in y_candidates[i + 1:]:
if y_bot <= y_top:
continue
# Tightest x span: check at y_top, y_bot, and every vertex
# y-value in between
x_left = 0.0
x_right = float(img_w)
for y in y_candidates:
if y < y_top or y > y_bot:
continue
xl, xr = _x_bounds_at_y(float(y))
x_left = max(x_left, xl)
x_right = min(x_right, xr)
if x_right <= x_left:
continue
area = (x_right - x_left) * (y_bot - y_top)
if area > best_area:
best_area = area
best_rect = (
int(np.ceil(x_left)),
y_top,
int(np.floor(x_right)),
y_bot,
)
if best_rect is None:
return image
x1, y1, x2, y2 = best_rect
x1 = max(0, x1)
y1 = max(0, y1)
x2 = min(img_w, x2)
y2 = min(img_h, y2)
if x2 <= x1 or y2 <= y1:
return image
return image[y1:y2, x1:x2]
# ── Corner-preserving radial (bow) correction ───────────────────
def apply_bow_correction(image: np.ndarray, k: float) -> np.ndarray:
"""Push mid-edge pixels outward to straighten inward-bowed edges.
Corners of the input image are fixed points; pixels at intermediate
radii are remapped outward. Intended for cosmetic correction of
mild residual pincushion distortion visible in extracted images,
where a straight world-line bows inward at its midpoint.
*k* controls strength and direction: 0 disables; k > 0 straightens
inward-bowed (pincushion) edges, k < 0 straightens outward-bowed
(barrel) edges. Typical useful magnitude 0.050.20; the mapping
stays monotonic for roughly -1 <= k <= 3.
Mapping (output → input, normalized radius u = r/r_max where r_max
is the half-diagonal):
u_in = u_out * (1 - k * u_out * (1 - u_out))
This satisfies u_in(0) = 0 and u_in(1) = 1, so the image center and
all four corners are fixed. For k > 0 and 0 < u_out < 1,
u_in < u_out, meaning each output pixel samples a more interior
location in the source — visually stretching mid-edge content
outward and straightening the bow. For k < 0 the inequality flips
(u_in > u_out), pulling mid-edge content inward.
Returns the corrected image with the same shape as *image*.
"""
if k == 0.0:
return image
h, w = image.shape[:2]
cx, cy = w / 2.0, h / 2.0
r_max = math.hypot(cx, cy)
ys, xs = np.indices((h, w), dtype=np.float32)
dx = xs - cx
dy = ys - cy
r_out = np.hypot(dx, dy)
u_out = r_out / r_max
u_in = u_out * (1.0 - k * u_out * (1.0 - u_out))
# Avoid division by zero at the center; ratio is 1.0 there.
with np.errstate(invalid="ignore", divide="ignore"):
ratio = np.where(r_out > 1e-6, u_in / u_out, 1.0)
src_x = cx + dx * ratio
src_y = cy + dy * ratio
return cv2.remap(
image,
src_x.astype(np.float32),
src_y.astype(np.float32),
interpolation=cv2.INTER_LINEAR,
borderMode=cv2.BORDER_REPLICATE,
)
# ── Gray-card color correction ─────────────────────────────────
# Default target reflectance for the sampled neutral patch: 0.18 (18%),
# standard photographic middle gray — the value printed on a Kodak-style
# gray card. Reflectance is a LINEAR-light quantity: 18% reflectance
# encodes to an sRGB value of ~118 on the 0255 scale, NOT 128 ("50%
# gray" is a common terminology slip). Photographers think in
# reflectance, so the GUI exposes this target as a percentage.
GRAY_REFLECTANCE_DEFAULT = 0.18
# Patch-validity thresholds. A channel at or beyond these bounds means
# the sampled card is clipped (highlight) or crushed (shadow), so the
# per-channel gain would blow up or divide by ~zero — reject it.
_GRAY_CLIP_LOW = 3.0
_GRAY_CLIP_HIGH = 252.0
def _srgb_to_linear(c: np.ndarray) -> np.ndarray:
"""Convert sRGB-encoded values in [0, 1] to linear light."""
return np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
def _linear_to_srgb(c: np.ndarray) -> np.ndarray:
"""Convert linear-light values in [0, 1] to sRGB-encoded."""
return np.where(c <= 0.0031308, c * 12.92, 1.055 * (c ** (1.0 / 2.4)) - 0.055)
def patch_square(cx: float, cy: float, radius: int) -> tuple[int, int, int]:
"""Integer top-left ``(x0, y0)`` and side length of the sample square.
The square is ``radius`` × ``radius`` image pixels — so radius 1 is the
single pixel nearest (*cx*, *cy*) — centered on that pixel via a
``floor(side / 2)`` offset. Bounds are NOT clamped to the image;
callers clamp as needed and the on-screen marker draws the nominal
square. Sharing this between the sampler and the marker keeps the
averaged pixels and the red outline exactly aligned.
"""
side = max(1, int(round(radius)))
half = side // 2
return int(round(cx)) - half, int(round(cy)) - half, side
def sample_patch_bgr(
image: np.ndarray, cx: float, cy: float, radius: int,
) -> np.ndarray | None:
"""Average the ``radius`` × ``radius`` square of *image* at (*cx*, *cy*).
The square spans ``radius`` pixels per side (radius 1 = the single
clicked pixel), positioned by :func:`patch_square` and clipped to the
image bounds. Returns the mean (B, G, R) as float64 in 0255, or None
if the region is empty.
"""
h, w = image.shape[:2]
x0, y0, side = patch_square(cx, cy, radius)
xa = max(0, x0)
xb = min(w, x0 + side)
ya = max(0, y0)
yb = min(h, y0 + side)
if xb <= xa or yb <= ya:
return None
patch = image[ya:yb, xa:xb].reshape(-1, image.shape[2]).astype(np.float64)
return patch[:, :3].mean(axis=0)
def gray_correction_gains(
sampled_bgr: np.ndarray | None,
target_reflectance: float = GRAY_REFLECTANCE_DEFAULT,
) -> np.ndarray | None:
"""Compute per-channel linear-light gains from a sampled neutral patch.
*target_reflectance* is the linear reflectance (01) the patch is
mapped to — e.g. 0.18 for a standard 18% gray card. Because the
target is identical across the three channels, a single gain set
simultaneously removes the color cast (equal target) and sets the
exposure (the target level). Reflectance is already a linear-light
quantity, so it is the target directly — no sRGB decode needed. Math
is the von Kries / per-channel gain method:
``gain_c = target_reflectance / sampled_linear_c``.
Returns a (3,) BGR gain array, or None if the patch is unusable
(any channel clipped high, crushed low, or non-positive in linear).
"""
if sampled_bgr is None:
return None
s = np.asarray(sampled_bgr, dtype=np.float64)
if s.shape[0] < 3:
return None
s = s[:3]
if np.any(s < _GRAY_CLIP_LOW) or np.any(s > _GRAY_CLIP_HIGH):
return None
s_lin = _srgb_to_linear(s / 255.0)
if np.any(s_lin < 1e-6):
return None
target_lin = max(0.0, min(1.0, float(target_reflectance)))
return target_lin / s_lin
# Rec.709 linear-light luminance weights in BGR channel order (the order
# OpenCV images and sampled patches use): B, G, R.
_LUMA_BGR = np.array([0.0722, 0.7152, 0.2126])
def white_correction_gains(
sampled_bgr: np.ndarray | None,
brightness_stops: float = 0.0,
) -> np.ndarray | None:
"""Decoupled white-balance gains from a sampled neutral/white patch.
Unlike :func:`gray_correction_gains` (which couples color and exposure
through a single reflectance target), this neutralizes the color cast
while **preserving the patch's own luminance**, then applies an
independent brightness factor:
gain_c = 2**brightness_stops * (L_patch / sampled_linear_c)
where ``L_patch`` is the patch's Rec.709 linear luminance. At
``brightness_stops == 0`` the sampled area comes out neutral
(R = G = B) at exactly its original luminance, so picking a white sheet
removes its color cast without forcing it to the maximum code value —
anything brighter (a specular glint, a bulb) stays brighter and keeps
its headroom. The brightness factor then scales the whole result up or
down.
Returns a (3,) BGR gain array, or None if the patch is unusable (any
channel clipped high, crushed low, or non-positive in linear).
"""
if sampled_bgr is None:
return None
s = np.asarray(sampled_bgr, dtype=np.float64)
if s.shape[0] < 3:
return None
s = s[:3]
if np.any(s < _GRAY_CLIP_LOW) or np.any(s > _GRAY_CLIP_HIGH):
return None
s_lin = _srgb_to_linear(s / 255.0)
if np.any(s_lin < 1e-6):
return None
luminance = float(_LUMA_BGR @ s_lin)
if luminance <= 1e-6:
return None
return (2.0 ** float(brightness_stops)) * (luminance / s_lin)
def apply_gray_correction(
image: np.ndarray, gains: np.ndarray | None,
) -> np.ndarray:
"""Apply per-channel linear-light *gains* (BGR) to a BGR *image*.
Linearizes (sRGB EOTF), multiplies each channel by its gain,
re-encodes, and clips to 8-bit. Returns a new uint8 image, or
*image* unchanged when *gains* is None.
A per-channel gain commutes with the geometric resampling done by
rectify / warp / bow correction, so applying it to the finished
output is equivalent to correcting the input before extraction —
which lets the gains be sampled from the full source image (where
the gray card lives, outside the extracted quad) yet applied to the
rectified result.
"""
if gains is None:
return image
g = np.asarray(gains, dtype=np.float64).reshape(1, 1, 3)
lin = _srgb_to_linear(image[:, :, :3].astype(np.float64) / 255.0)
lin = np.clip(lin * g, 0.0, 1.0)
out = np.clip(np.round(_linear_to_srgb(lin) * 255.0), 0, 255).astype(np.uint8)
if image.shape[2] > 3:
# Preserve any alpha / extra channels untouched.
out = np.concatenate([out, image[:, :, 3:]], axis=2)
return out
# ── Keystone correction ─────────────────────────────────────────
def keystone_homography(
line_pairs: list[np.ndarray],
image_width: int,
image_height: int,
) -> np.ndarray | None:
"""Compute a homography that makes line pairs parallel.
Each element of *line_pairs* is a (4, 2) float array:
[line1_start, line1_end, line2_start, line2_end].
One pair corrects keystoning in one direction (the vanishing point
is mapped to infinity). Two pairs correct both directions
simultaneously (affine rectification).
The computation is centered on the image midpoint to distribute
distortion symmetrically.
Returns a 3×3 homography, or None on degenerate input.
"""
cx, cy = image_width / 2.0, image_height / 2.0
vanishing_points = []
for pair in line_pairs:
p1, p2, p3, p4 = pair.astype(np.float64)
l1 = np.cross([p1[0], p1[1], 1.0], [p2[0], p2[1], 1.0])
l2 = np.cross([p3[0], p3[1], 1.0], [p4[0], p4[1], 1.0])
vp = np.cross(l1, l2)
if abs(vp[2]) < 1e-10:
continue # lines already parallel
vanishing_points.append(vp / vp[2]) # normalize to (x, y, 1)
if not vanishing_points:
return np.eye(3, dtype=np.float64) # nothing to correct
# Center coordinates for symmetric distortion
T_center = np.array([
[1, 0, -cx], [0, 1, -cy], [0, 0, 1],
], dtype=np.float64)
T_back = np.array([
[1, 0, cx], [0, 1, cy], [0, 0, 1],
], dtype=np.float64)
if len(vanishing_points) == 1:
# Determine if the pair is mostly vertical or horizontal.
pair = line_pairs[0].astype(np.float64)
d1 = pair[1] - pair[0]
d2_dir = pair[3] - pair[2]
# Canonicalise both to same half-plane before averaging
if abs(d1[1]) >= abs(d1[0]):
if d1[1] < 0: d1 = -d1
if d2_dir[1] < 0: d2_dir = -d2_dir
else:
if d1[0] < 0: d1 = -d1
if d2_dir[0] < 0: d2_dir = -d2_dir
avg_dir = d1 / np.linalg.norm(d1) + d2_dir / np.linalg.norm(d2_dir)
is_vert = abs(avg_dir[1]) >= abs(avg_dir[0])
# Iteratively estimate the camera roll and remove it before
# the projective correction. Each iteration refines the
# estimate by measuring the residual tilt after correction.
# Three iterations are sufficient for sub-0.01° accuracy.
R_centered = np.eye(3, dtype=np.float64)
pair_pts = line_pairs[0].astype(np.float64)
for _ in range(3):
cur = cv2.perspectiveTransform(
pair_pts.reshape(-1, 1, 2), R_centered,
).reshape(-1, 2)
cd1 = cur[1] - cur[0]
cd2 = cur[3] - cur[2]
n1 = np.linalg.norm(cd1)
n2 = np.linalg.norm(cd2)
if n1 < 1e-6 or n2 < 1e-6:
break
u1, u2 = cd1 / n1, cd2 / n2
if is_vert:
if u1[1] < 0: u1 = -u1
if u2[1] < 0: u2 = -u2
a = u1 + u2
delta = math.atan2(a[0], a[1])
else:
if u1[0] < 0: u1 = -u1
if u2[0] < 0: u2 = -u2
a = u1 + u2
delta = math.atan2(a[1], a[0])
if abs(delta) < 1e-10:
break
c, s = math.cos(delta), math.sin(delta)
Ri = np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]],
dtype=np.float64)
R_centered = T_back @ Ri @ T_center @ R_centered
# Compute the VP in the de-rolled coordinate system
vp_xy = vanishing_points[0][:2].reshape(1, 1, 2)
vp_derolled = cv2.perspectiveTransform(
vp_xy.astype(np.float64), R_centered,
).reshape(2)
vx = vp_derolled[0] - cx
vy = vp_derolled[1] - cy
d2 = vx * vx + vy * vy
if d2 < 1e-10:
return R_centered # only roll correction needed
h1, h2 = -vx / d2, -vy / d2
else:
# Two vanishing points → vanishing line in centered coords
v1 = np.array([
vanishing_points[0][0] - cx,
vanishing_points[0][1] - cy, 1.0,
])
v2 = np.array([
vanishing_points[1][0] - cx,
vanishing_points[1][1] - cy, 1.0,
])
vl = np.cross(v1, v2)
if abs(vl[2]) < 1e-10:
return np.eye(3, dtype=np.float64)
h1, h2 = vl[0] / vl[2], vl[1] / vl[2]
H_centered = np.array([
[1, 0, 0],
[0, 1, 0],
[h1, h2, 1],
], dtype=np.float64)
H_proj = T_back @ H_centered @ T_center
if len(vanishing_points) == 1:
# Compose: first de-roll, then projective correction.
H_pass1 = H_proj @ R_centered
# The minimum-norm projective maps the VP to infinity along
# (vx, vy), which may not be axis-aligned. Apply a final
# rotation to align the corrected lines to the target axis.
# Because the de-roll has already removed most of the roll,
# this rotation is small and introduces negligible horizontal
# skew.
pair_pts = line_pairs[0].astype(np.float64).reshape(-1, 1, 2)
warped = cv2.perspectiveTransform(pair_pts, H_pass1).reshape(-1, 2)
wd1 = warped[1] - warped[0]
wd2 = warped[3] - warped[2]
wn1 = np.linalg.norm(wd1)
wn2 = np.linalg.norm(wd2)
if wn1 > 1e-6 and wn2 > 1e-6:
u1, u2 = wd1 / wn1, wd2 / wn2
if is_vert:
if u1[1] < 0: u1 = -u1
if u2[1] < 0: u2 = -u2
a = u1 + u2
residual = math.atan2(a[0], a[1])
else:
if u1[0] < 0: u1 = -u1
if u2[0] < 0: u2 = -u2
a = u1 + u2
residual = math.atan2(a[1], a[0])
cr = math.cos(residual)
sr = math.sin(residual)
Rfinal = np.array([
[cr, -sr, 0], [sr, cr, 0], [0, 0, 1],
], dtype=np.float64)
return T_back @ Rfinal @ T_center @ H_pass1
return H_pass1
# ── Two pairs: roll correction ───────────────────────────────
# After projective rectification the two pairs are parallel.
# Compute a 2×2 linear transform that maps both directions to
# their target axes simultaneously.
pair_dirs = [] # (avg_direction, is_vertical) for each pair
for pair in line_pairs:
pts = pair.astype(np.float64).reshape(-1, 1, 2)
warped = cv2.perspectiveTransform(pts, H_proj).reshape(-1, 2)
dir1 = warped[1] - warped[0]
dir2 = warped[3] - warped[2]
n1 = np.linalg.norm(dir1)
n2 = np.linalg.norm(dir2)
if n1 < 1e-6 or n2 < 1e-6:
continue
d1 = dir1 / n1
d2 = dir2 / n2
is_v = abs(d1[1]) >= abs(d1[0])
# Canonicalise: vertical → downward, horizontal → rightward
if is_v:
if d1[1] < 0: d1 = -d1
if d2[1] < 0: d2 = -d2
else:
if d1[0] < 0: d1 = -d1
if d2[0] < 0: d2 = -d2
avg = d1 + d2
n = np.linalg.norm(avg)
if n > 1e-6:
pair_dirs.append((avg / n, is_v))
if len(pair_dirs) < 2:
return H_proj
d_v = d_h = None
for d, is_v in pair_dirs:
if is_v:
d_v = d
else:
d_h = d
if d_v is None or d_h is None:
return H_proj
# L maps d_h → (1,0) and d_v → (0,1): L = [d_h | d_v]^{-1}
M = np.array([[d_h[0], d_v[0]],
[d_h[1], d_v[1]]], dtype=np.float64)
det = M[0, 0] * M[1, 1] - M[0, 1] * M[1, 0]
if abs(det) < 1e-10:
return H_proj
L = np.array([
[M[1, 1] / det, -M[0, 1] / det],
[-M[1, 0] / det, M[0, 0] / det],
], dtype=np.float64)
H_L = np.array([
[L[0, 0], L[0, 1], 0],
[L[1, 0], L[1, 1], 0],
[0, 0, 1],
], dtype=np.float64)
return T_back @ H_L @ T_center @ H_proj
def keystone_correct(
image: np.ndarray,
line_pairs: list[np.ndarray],
crop: bool = False,
fill_color: tuple[int, int, int] = (0, 0, 0),
scale_x: float = 1.0,
) -> np.ndarray | None:
"""Remove keystone distortion using line pairs that should be parallel.
Parameters
----------
image : BGR image
line_pairs : list of (4, 2) arrays, each
[line1_start, line1_end, line2_start, line2_end]
crop : if True, crop to largest inscribed rectangle
fill_color : BGR background for uncovered areas
scale_x : horizontal stretch applied to the *corrected* output, changing
its width-to-height relationship. Line directions fix the axes but
carry no metric scale, so the residual width/height is undetermined
from the lines alone; this is the user's by-eye correction for it.
``1.0`` is an exact no-op (identity scale).
Returns corrected image, or None on failure.
"""
if not line_pairs:
return None
img_h, img_w = image.shape[:2]
H = keystone_homography(line_pairs, img_w, img_h)
if H is None:
return None
if scale_x != 1.0:
# Stretch the corrected output horizontally (output-space pre-multiply).
S = np.array([[scale_x, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.float64)
H = S @ H
return _warp_full_image(image, H, crop, fill_color)

309
rectify/utils.py Normal file
View File

@@ -0,0 +1,309 @@
"""Image I/O and color conversion helpers."""
import os
import cv2
import numpy as np
# Extensions handled by the HEIF/HEIC path (pillow-heif) rather than
# OpenCV. OpenCV has no HEIF support; these come from iPhones as
# Display-P3 SDR base images (often with a separate HDR gain map we
# ignore for SDR documentation work).
_HEIF_EXTS = {".heic", ".heif"}
# Linear Display-P3 → linear sRGB, D65 (both share the sRGB transfer
# function and white point; only the primaries differ, so the conversion
# is a fixed 3×3 matrix in linear light).
_P3_TO_SRGB = np.array([
[1.2249401, -0.2249404, 0.0],
[-0.0420569, 1.0420571, 0.0],
[-0.0196376, -0.0786361, 1.0982735],
], dtype=np.float64)
_heif_registered = False
def _ensure_heif() -> None:
"""Register the pillow-heif opener once; raise if the dep is missing."""
global _heif_registered
if _heif_registered:
return
try:
import pillow_heif
except ImportError as e: # pragma: no cover - depends on environment
raise ImportError(
"Reading HEIC/HEIF images requires the 'pillow-heif' package "
"(pip install pillow-heif)."
) from e
pillow_heif.register_heif_opener()
_heif_registered = True
def _extract_heif_icc(path: str) -> bytes | None:
"""Pull the embedded ICC profile out of a HEIF ``colr``/``prof`` box.
pillow-heif (1.x) does not surface the ICC via ``info['icc_profile']``,
so we read it from the container directly. Returns the largest valid
ICC profile found, or None.
"""
import struct
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return None
best = None
pos = 0
while True:
pos = data.find(b"colr", pos)
if pos < 4:
break
size = struct.unpack(">I", data[pos - 4:pos])[0]
if data[pos + 4:pos + 8] == b"prof":
icc = data[pos + 8:pos - 4 + size]
if len(icc) > 40 and icc[36:40] == b"acsp": # ICC signature
if best is None or len(icc) > len(best):
best = icc
pos += 4
return best
def _heif_is_display_p3(path: str) -> bool:
"""Best-effort: is this HEIF's base image Display-P3?
The ICC description is the most reliable signal (iPhone HDR files
carry a "Display P3 Primaries; PQ …" profile that LittleCMS can't
apply, but whose *description* still identifies the primaries).
Falls back to True — iPhone HEICs, the dominant source, are Display
P3 — unless a profile explicitly says sRGB.
"""
icc = _extract_heif_icc(path)
if icc:
try:
import io
from PIL import ImageCms
desc = ImageCms.getProfileDescription(
ImageCms.ImageCmsProfile(io.BytesIO(icc))
).lower()
if "p3" in desc:
return True
if "srgb" in desc:
return False
except Exception:
pass
return True
def _p3_to_srgb(rgb: np.ndarray) -> np.ndarray:
"""Convert an 8-bit Display-P3 RGB array to 8-bit sRGB.
Decodes the (shared) sRGB transfer curve, applies the P3→sRGB
primaries matrix in linear light, clips out-of-gamut values to the
sRGB cube, and re-encodes. Wide-gamut P3 colors beyond sRGB are
gamut-clipped — unavoidable for an sRGB output target.
"""
c = rgb.astype(np.float64) / 255.0
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
lin = np.clip(lin @ _P3_TO_SRGB.T, 0.0, 1.0)
enc = np.where(lin <= 0.0031308, 12.92 * lin,
1.055 * np.power(lin, 1.0 / 2.4) - 0.055)
return np.clip(np.round(enc * 255.0), 0, 255).astype(np.uint8)
def _load_heif_bgr(path: str) -> np.ndarray:
"""Decode a HEIC/HEIF file to a BGR uint8 array in sRGB.
Reads the SDR base image (any HDR gain map is ignored), converts
Display-P3 → sRGB when applicable, and returns BGR for the OpenCV
pipeline.
"""
_ensure_heif()
from PIL import Image
try:
im = Image.open(path)
im.load()
except Exception as e:
raise FileNotFoundError(f"Cannot load image: {path}") from e
rgb = np.asarray(im.convert("RGB"))
if _heif_is_display_p3(path):
rgb = _p3_to_srgb(rgb)
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
def load_image(path: str) -> np.ndarray:
"""Load an image from disk in BGR format. Raises FileNotFoundError if missing.
HEIC/HEIF files are decoded via pillow-heif (their SDR base image,
converted Display-P3 → sRGB); everything else goes through OpenCV.
"""
if os.path.splitext(path)[1].lower() in _HEIF_EXTS:
return _load_heif_bgr(path)
img = cv2.imread(path)
if img is None:
raise FileNotFoundError(f"Cannot load image: {path}")
return img
def save_image(path: str, image: np.ndarray) -> None:
"""Save an image to disk."""
success = cv2.imwrite(path, image)
if not success:
raise IOError(f"Failed to write image: {path}")
def to_grayscale(image: np.ndarray) -> np.ndarray:
"""Convert a BGR image to grayscale."""
if len(image.shape) == 2:
return image
return cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
def bgr_to_qpixmap(image: np.ndarray):
"""Convert a BGR numpy array to a QPixmap for display in Qt widgets."""
from PySide6.QtGui import QImage, QPixmap
if len(image.shape) == 2:
# Grayscale
h, w = image.shape
qimg = QImage(image.data, w, h, w, QImage.Format.Format_Grayscale8)
else:
h, w, ch = image.shape
# Ensure contiguous memory
image = np.ascontiguousarray(image)
bytes_per_line = w * ch
if ch == 4:
qimg = QImage(image.data, w, h, bytes_per_line, QImage.Format.Format_BGRA8888)
else:
qimg = QImage(image.data, w, h, bytes_per_line, QImage.Format.Format_BGR888)
return QPixmap.fromImage(qimg.copy()) # .copy() detaches from numpy memory
def _heif_focal_length_35mm(path: str) -> float | None:
"""Read FocalLengthIn35mmFilm from a HEIC/HEIF file's EXIF via PIL."""
try:
_ensure_heif()
from PIL import Image
from PIL.ExifTags import IFD
exif = Image.open(path).getexif()
sub = exif.get_ifd(IFD.Exif)
value = sub.get(0xA405) # FocalLengthIn35mmFilm
if value and float(value) > 0:
return float(value)
except Exception:
return None
return None
def read_focal_length_35mm(path: str) -> float | None:
"""Read the 35mm-equivalent focal length from EXIF metadata.
Returns the focal length in mm, or None if not available. JPEG is
parsed with a minimal built-in parser; HEIC/HEIF goes through PIL
(its EXIF isn't in the JPEG APP1 layout this parser expects).
"""
import struct
if os.path.splitext(path)[1].lower() in _HEIF_EXTS:
return _heif_focal_length_35mm(path)
try:
with open(path, "rb") as f:
data = f.read(65536)
except IOError:
return None
# Find EXIF APP1 marker
if data[:2] != b'\xff\xd8':
return None # not JPEG
pos = 2
while pos < len(data) - 4:
if data[pos] != 0xff:
break
marker = data[pos + 1]
if marker == 0xe1: # APP1 (EXIF)
break
length = struct.unpack('>H', data[pos + 2:pos + 4])[0]
pos += 2 + length
else:
return None
# Parse EXIF
exif_start = pos + 4 # skip marker + length
if data[exif_start:exif_start + 4] != b'Exif':
return None
tiff_start = exif_start + 6 # skip "Exif\x00\x00"
# Determine byte order
byte_order = data[tiff_start:tiff_start + 2]
if byte_order == b'II':
endian = '<'
elif byte_order == b'MM':
endian = '>'
else:
return None
def read_u16(offset):
return struct.unpack(endian + 'H', data[offset:offset + 2])[0]
def read_u32(offset):
return struct.unpack(endian + 'I', data[offset:offset + 4])[0]
def read_rational(offset):
num = read_u32(offset)
den = read_u32(offset + 4)
return num / den if den != 0 else 0
# Read IFD0
ifd_offset = tiff_start + read_u32(tiff_start + 4)
focal_35mm = None
focal_length = None
exif_ifd_offset = None
def scan_ifd(offset):
nonlocal focal_35mm, focal_length, exif_ifd_offset
if offset >= len(data) - 2:
return
num_entries = read_u16(offset)
for i in range(num_entries):
entry = offset + 2 + i * 12
if entry + 12 > len(data):
break
tag = read_u16(entry)
typ = read_u16(entry + 2)
count = read_u32(entry + 4)
value_offset = entry + 8
if tag == 0x8769: # ExifIFD pointer
exif_ifd_offset = tiff_start + read_u32(value_offset)
elif tag == 0xa405: # FocalLengthIn35mmFilm (SHORT)
focal_35mm = read_u16(value_offset)
elif tag == 0x920a: # FocalLength (RATIONAL)
rat_offset = tiff_start + read_u32(value_offset)
if rat_offset + 8 <= len(data):
focal_length = read_rational(rat_offset)
scan_ifd(ifd_offset)
if exif_ifd_offset is not None:
scan_ifd(exif_ifd_offset)
if focal_35mm and focal_35mm > 0:
return float(focal_35mm)
# Can't reliably convert actual focal length without sensor size
return None
def resize_for_display(image: np.ndarray, max_dim: int = 800) -> tuple[np.ndarray, float]:
"""Resize an image so its longest side is at most max_dim pixels.
Returns (resized_image, scale_factor) where scale_factor maps display
coordinates back to original coordinates.
"""
h, w = image.shape[:2]
if max(h, w) <= max_dim:
return image, 1.0
scale = max_dim / max(h, w)
new_w = int(w * scale)
new_h = int(h * scale)
resized = cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_AREA)
return resized, scale

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
opencv-python-headless>=4.8
numpy>=1.24
PySide6>=6.6
Pillow>=10.0 # HEIC decode (with pillow-heif) + ICC color management
pillow-heif>=0.13 # HEIC/HEIF reading (iPhone photos); bundles libheif

View File

@@ -0,0 +1,41 @@
"""HEIC/HEIF thumbnailer for the freedesktop (GNOME/GTK) thumbnail system.
Decodes a HEIC/HEIF file's SDR base image with pillow-heif (which bundles a
modern libheif) and writes a PNG thumbnail. Used on Linux systems whose
*system* libheif is too old to thumbnail iPhone HDR HEICs; installed under
/usr/local by scripts/install_heic_thumbnailer.sh so it is reachable inside
GNOME's bwrap thumbnailer sandbox (which binds /usr but not $HOME).
Invoked by the thumbnail system as: ... -s SIZE INPUT OUTPUT
"""
import sys
def main(argv):
args = argv[1:]
size, rest = 256, []
i = 0
while i < len(args):
if args[i] == "-s":
size = int(args[i + 1]); i += 2
else:
rest.append(args[i]); i += 1
if len(rest) < 2:
sys.stderr.write("usage: heic_thumbnailer.py -s SIZE INPUT OUTPUT\n")
return 2
inp, outp = rest[0], rest[1]
import pillow_heif
pillow_heif.register_heif_opener()
from PIL import Image
im = Image.open(inp)
im.load()
im = im.convert("RGB")
im.thumbnail((size, size), Image.LANCZOS)
im.save(outp, "PNG")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
#
# install_heic_thumbnailer.sh — enable HEIC/HEIF thumbnails in the Linux
# (GNOME/GTK, freedesktop) file chooser and file manager.
#
# Why this exists: OpenCV/Qt don't thumbnail HEIC, and GNOME's thumbnail
# system relies on the system libheif via gdk-pixbuf. On distros with an
# old libheif (e.g. Ubuntu/Pop 22.04 ships 1.12), iPhone HDR HEICs fail to
# thumbnail ("Metadata not correctly assigned"). This installs a small
# thumbnailer backed by pillow-heif (which bundles a modern libheif) under
# /usr/local — where it is reachable inside GNOME's bwrap thumbnailer
# sandbox (the sandbox binds /usr but not $HOME, so a project venv won't do).
#
# Idempotent and safe to re-run. Skips entirely when not needed:
# - non-Linux (macOS etc. thumbnail HEIC natively), or
# - the system already thumbnails HEIC (modern libheif present).
#
# Needs sudo for the /usr/local install; the registration is user-local.
#
# ./scripts/install_heic_thumbnailer.sh
#
set -euo pipefail
PREFIX=/usr/local/lib/rectify-thumbnailer
THUMB_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/thumbnailers"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TEST_HEIC="$SCRIPT_DIR/../tests/images/HEIC/IMG_0338.heic"
# 1. Linux only — other platforms handle HEIC natively (macOS Quick Look).
if [ "$(uname -s)" != "Linux" ]; then
echo "Not Linux: HEIC thumbnails are handled natively here. Nothing to do."
exit 0
fi
# 2. Already works natively? If the stock gdk-pixbuf thumbnailer can render
# our committed sample HEIC, the system libheif is new enough and this
# helper is unnecessary. (The sample lives under tests/, which is
# export-ignored from source archives; if absent we just proceed.)
if command -v gdk-pixbuf-thumbnailer >/dev/null 2>&1 && [ -f "$TEST_HEIC" ]; then
tmp="$(mktemp --suffix=.png)"
if gdk-pixbuf-thumbnailer -s 128 "$TEST_HEIC" "$tmp" >/dev/null 2>&1 \
&& [ -s "$tmp" ]; then
rm -f "$tmp"
echo "System already thumbnails HEIC natively (modern libheif). Nothing to do."
exit 0
fi
rm -f "$tmp"
fi
# 3. Install the pillow-heif decoder in an isolated venv under /usr/local.
echo "Installing the pillow-heif HEIC thumbnailer under $PREFIX (sudo required)…"
sudo install -d "$PREFIX"
if [ ! -x "$PREFIX/venv/bin/python3" ]; then
sudo python3 -m venv "$PREFIX/venv"
fi
sudo "$PREFIX/venv/bin/pip" install -q --upgrade pip pillow-heif
sudo cp "$SCRIPT_DIR/heic_thumbnailer.py" "$PREFIX/thumbnailer.py"
# 4. Register with the freedesktop thumbnail system (user-local, no sudo).
mkdir -p "$THUMB_DIR"
cat > "$THUMB_DIR/rectify-heic.thumbnailer" <<EOF
[Thumbnailer Entry]
TryExec=$PREFIX/venv/bin/python3
Exec=$PREFIX/venv/bin/python3 $PREFIX/thumbnailer.py -s %s %i %o
MimeType=image/heif;image/heic;
EOF
# 5. Drop cached thumbnail failures so HEICs are retried.
rm -rf "${XDG_CACHE_HOME:-$HOME/.cache}/thumbnails/fail" 2>/dev/null || true
echo "Done. HEIC thumbnails will appear in the file chooser and file manager."
echo "Restart the file manager to pick it up immediately: nautilus -q"