Initial public release tree
This commit is contained in:
46
.gitignore
vendored
Normal file
46
.gitignore
vendored
Normal 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
11
LICENSE.md
Normal 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
241
README.md
Normal 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.10–3.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++ 2015–2022 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.10–10.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 3–80%). **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
40
build.sh
Executable 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
1635
doc/Rectify_user_guide.md
Normal file
File diff suppressed because it is too large
Load Diff
111
rectify.spec
Normal file
111
rectify.spec
Normal 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
3
rectify/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Rectify — perspective correction for paintings and rectangular objects."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
5
rectify/__main__.py
Normal file
5
rectify/__main__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Entry point for `python -m rectify`."""
|
||||||
|
|
||||||
|
from rectify.cli import main
|
||||||
|
|
||||||
|
main()
|
||||||
196
rectify/cli.py
Normal file
196
rectify/cli.py
Normal 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.0–1.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
87
rectify/debug.py
Normal 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
1303
rectify/detect.py
Normal file
File diff suppressed because it is too large
Load Diff
6631
rectify/gui.py
Normal file
6631
rectify/gui.py
Normal file
File diff suppressed because it is too large
Load Diff
142
rectify/shortcuts.py
Normal file
142
rectify/shortcuts.py
Normal 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
892
rectify/transform.py
Normal 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.10–10.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.05–0.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 0–255 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 0–255, 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 (0–1) 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
309
rectify/utils.py
Normal 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
5
requirements.txt
Normal 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
|
||||||
41
scripts/heic_thumbnailer.py
Normal file
41
scripts/heic_thumbnailer.py
Normal 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))
|
||||||
72
scripts/install_heic_thumbnailer.sh
Executable file
72
scripts/install_heic_thumbnailer.sh
Executable 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"
|
||||||
Reference in New Issue
Block a user