From fb80764e66c988e1612c5c87db59b9c4c223d289 Mon Sep 17 00:00:00 2001 From: Andy Kopra Date: Tue, 4 Aug 2026 18:31:51 +0200 Subject: [PATCH] Initial public release tree --- .gitignore | 46 + LICENSE.md | 11 + README.md | 241 + build.sh | 40 + doc/Rectify_user_guide.md | 1635 +++++++ rectify.spec | 111 + rectify/__init__.py | 3 + rectify/__main__.py | 5 + rectify/cli.py | 196 + rectify/debug.py | 87 + rectify/detect.py | 1303 ++++++ rectify/gui.py | 6631 +++++++++++++++++++++++++++ rectify/shortcuts.py | 142 + rectify/transform.py | 892 ++++ rectify/utils.py | 309 ++ requirements.txt | 5 + scripts/heic_thumbnailer.py | 41 + scripts/install_heic_thumbnailer.sh | 72 + 18 files changed, 11770 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100755 build.sh create mode 100644 doc/Rectify_user_guide.md create mode 100644 rectify.spec create mode 100644 rectify/__init__.py create mode 100644 rectify/__main__.py create mode 100644 rectify/cli.py create mode 100644 rectify/debug.py create mode 100644 rectify/detect.py create mode 100644 rectify/gui.py create mode 100644 rectify/shortcuts.py create mode 100644 rectify/transform.py create mode 100644 rectify/utils.py create mode 100644 requirements.txt create mode 100644 scripts/heic_thumbnailer.py create mode 100755 scripts/install_heic_thumbnailer.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59de2db --- /dev/null +++ b/.gitignore @@ -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~ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..517a5e1 --- /dev/null +++ b/LICENSE.md @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a4243fb --- /dev/null +++ b/README.md @@ -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%\\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. diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..6163b66 --- /dev/null +++ b/build.sh @@ -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 diff --git a/doc/Rectify_user_guide.md b/doc/Rectify_user_guide.md new file mode 100644 index 0000000..02d9d31 --- /dev/null +++ b/doc/Rectify_user_guide.md @@ -0,0 +1,1635 @@ +# Rectify — User Guide + +Andy Kopra — ack@acm.org + +## How this guide is organized + +This guide is divided into four parts: + +1. **[Introduction](#1-introduction)** — what Rectify is, the problem it solves, + and an overview of everything it can do. Conceptual; no software or photography + background assumed. +2. **[Using Rectify](#2-using-rectify)** — a complete, task-by-task guide to the + graphical application, written for someone who is not a programmer. It covers + installing the supplied `Rectify.dmg` on macOS and every feature of the + interface. Later sections assume some familiarity with photography and color. +3. **[For programmers](#3-for-programmers)** — running Rectify from source + (including full Linux instructions), the command-line interface, how the code + is organized, and how to extend it. Assumes you have also read §2 to learn what + the program does; this part is about *how it is built and driven*. +4. **[Background and reference](#4-background-and-reference)** — the design and + policy decisions behind Rectify, the mathematics of every processing stage, and + a self-contained color-theory primer. The earlier parts link here whenever a + "why" or a deeper "how" is worth having. + +## Contents + +- [1. Introduction](#1-introduction) + - [1.1 The perspective-correction problem](#11-the-perspective-correction-problem) + - [1.2 What you can do with Rectify](#12-what-you-can-do-with-rectify) + - [1.3 How detection works, in brief](#13-how-detection-works-in-brief) + - [1.4 A word about color](#14-a-word-about-color) + - [1.5 Two ways to use Rectify](#15-two-ways-to-use-rectify) +- [2. Using Rectify](#2-using-rectify) + - [2.1 Installing Rectify on macOS](#21-installing-rectify-on-macos) + - [2.2 The main window](#22-the-main-window) + - [2.3 Opening images](#23-opening-images) + - [2.4 How detection works, and the vocabulary](#24-how-detection-works-and-the-vocabulary) + - [2.5 Refining the detected region](#25-refining-the-detected-region) + - [2.6 The peel stack](#26-the-peel-stack) + - [2.7 Reset and Re-open](#27-reset-and-re-open) + - [2.8 Full-image perspective correction](#28-full-image-perspective-correction) + - [2.9 Keystone correction](#29-keystone-correction) + - [2.10 Aspect-ratio correction](#210-aspect-ratio-correction) + - [2.11 Bow correction](#211-bow-correction) + - [2.12 Color correction](#212-color-correction) + - [2.13 Saving your work](#213-saving-your-work) + - [2.14 Comparing before and after](#214-comparing-before-and-after) + - [2.15 What Rectify remembers](#215-what-rectify-remembers) + - [2.16 Keyboard shortcuts](#216-keyboard-shortcuts) + - [2.17 When automatic detection struggles](#217-when-automatic-detection-struggles) +- [3. For programmers](#3-for-programmers) + - [3.1 Installation](#31-installation) + - [3.2 How the code is organized](#32-how-the-code-is-organized) + - [3.3 The command-line interface](#33-the-command-line-interface) + - [3.4 What the color modes actually do](#34-what-the-color-modes-actually-do) + - [3.5 Extending Rectify](#35-extending-rectify) + - [3.6 Going deeper](#36-going-deeper) +- [4. Background and reference](#4-background-and-reference) + - [4.1 Design philosophy and policy decisions](#41-design-philosophy-and-policy-decisions) + - [4.2 Edge detection](#42-edge-detection) + - [4.3 Saturation-channel detection](#43-saturation-channel-detection) + - [4.4 Contour finding and polygon approximation](#44-contour-finding-and-polygon-approximation) + - [4.5 Corner ordering](#45-corner-ordering) + - [4.6 The perspective transform](#46-the-perspective-transform) + - [4.7 Aspect-ratio recovery from a single image](#47-aspect-ratio-recovery-from-a-single-image) + - [4.8 Scoring and strategy selection](#48-scoring-and-strategy-selection) + - [4.9 Coordinate mapping through the peel stack](#49-coordinate-mapping-through-the-peel-stack) + - [4.10 Rectified-space nudging](#410-rectified-space-nudging) + - [4.11 Detection padding](#411-detection-padding) + - [4.12 The transform pipeline](#412-the-transform-pipeline) + - [4.13 GUI architecture internals](#413-gui-architecture-internals) + - [4.14 Color-theory primer](#414-color-theory-primer) + - [4.15 References](#415-references) + +## 1. Introduction + +Rectify is a tool for straightening photographs of flat, rectangular things — +paintings, museum labels, posters, tile murals, documents, signs, stamps. When +you photograph a painting on a wall, you are almost never standing perfectly +square to it: you shoot from a little to the side, or a little below, and the +rectangular original comes out as a lopsided four-sided shape. Rectify finds that +shape in your photo and warps it back into its original rectangular form. + +The initial motivation for Rectify was museum photography — recovering usable, +undistorted images of artwork from the angled snapshots you can actually take in +a crowded gallery — but the same operation is useful any time you need the flat, +square-on version of something you had to photograph at an angle. + +In taking photographs of artworks, color accuracy is also important. Rectify +provides methods for color correction, described in +[§2.12](#212-color-correction). + +Rectify can also remove the perspective distortion that can result when +photographing buildings and other large-scale structures, restoring vertical and +horizontal lines to their correct orientation in the image. This process is +known as *keystone correction*, and is described in +[§2.9](#29-keystone-correction). + + +### 1.1 The perspective-correction problem + +A rectangle photographed at an angle appears in the image as a trapezoid, or more +generally as an arbitrary quadrilateral (a four-sided shape). Recovering the +original rectangle takes two steps: + +1. **Detection** — finding the four corners of the object in the photograph. +2. **Transformation** — computing and applying a *perspective warp* that maps + that quadrilateral back to a true rectangle. + +Both steps are harder than they sound. Detection has to tell the object apart +from its background, which may be a similar color, similarly bright, or busy with +texture. Transformation has to undo the geometric distortion without softening or +mangling the picture. Rectify automates the first step and does the second for +you, while giving you direct, hands-on control to fix anything the automation +gets wrong. + +### 1.2 What you can do with Rectify + +Beyond the core "straighten this painting" operation, Rectify offers a connected +set of capabilities. Each is covered step-by-step in [§2](#2-using-rectify); this +is the map. + +- **Extract a rectangular subject** from a photo — the default. Rectify detects + the object's quadrilateral and warps just that region into a head-on rectangle. +- **Correct the whole image's perspective** instead of cropping — like the + tilt/shift movements of a view camera. You pick something you know is + rectangular (a window, a door frame), and the entire scene is corrected so that + reference becomes square. See [§2.8](#28-full-image-perspective-correction). +- **Fix keystoning with line pairs** — when vertical or horizontal lines converge + because the camera was tilted, you align a pair of lines that *should* be + parallel and Rectify removes the convergence. See [§2.9](#29-keystone-correction). +- **Peel away nested borders** — a framed painting has an outer frame, an inner + mat, and the canvas. The "peel" mechanism strips these one layer at a time. See + [§2.6](#26-the-peel-stack). +- **Recover the true proportions** — a rectangle shot at an angle loses its + aspect ratio (the near side looks bigger). Rectify can compute the true ratio + from the photo's metadata, and you can override it. See + [§2.10](#210-aspect-ratio-correction). +- **Straighten residual bowing** — a cosmetic correction for the slight inward + bow that lens distortion can leave along the edges. See + [§2.11](#211-bow-correction). +- **Correct color** — neutralize a color cast (and optionally set exposure) by + sampling a known-neutral reference you placed in the shot. Three modes cover a + gray card, a plain neutral patch, and a diffuse-white reference. See + [§2.12](#212-color-correction). + +All of this is non-destructive: your source file is never altered. Rectify reads +from it and produces a new, corrected output image. + +### 1.3 How detection works, in brief + +Rectify finds the object's boundary using two complementary strategies: + +- **Grayscale detection** looks for boundaries where *brightness* changes — a + painting against a wall of a different tone. +- **Saturation detection** looks for boundaries where *color richness* changes — + a colorful tile mural against a similarly bright but grayer brick wall. + +When you open an image, Rectify automatically tries both strategies across a range +of sensitivity settings, scores each result for how rectangular and well-placed it +is, and shows you the best one. There are no detection knobs to set in the +graphical interface — the automatic sweep is always what runs, and you refine the +result by hand if needed. (The command line does expose the underlying parameters +for experimentation; see [§3.3](#33-the-command-line-interface).) The full +algorithm is described in [§4.8](#48-scoring-and-strategy-selection). + +### 1.4 A word about color + +For photographing artwork, color accuracy usually matters as much as geometry. The +most reliable way to get it is the same trick every raw-photo developer offers: +put a known-neutral reference — a gray card, a neutral patch, a white sheet — in +the frame beside the subject, then tell the software "this is neutral," and let it +remove the color cast of whatever light you were shooting under. + +Rectify supports three flavors of this, because "make this neutral" can mean +different things: just remove the cast and leave brightness alone; remove the cast +*and* set the exposure from a gray card of known reflectance; or anchor exposure to +a diffuse-white reference. The operational steps are in +[§2.12](#212-color-correction). If the underlying ideas — color cast, neutral +references, reflectance, middle gray, coupled vs. decoupled correction — are new +to you, the [color-theory primer in §4.14](#414-color-theory-primer) explains them +from the ground up, and the earlier sections link to it where useful. + +### 1.5 Two ways to use Rectify + +Rectify is one program with two front ends: + +- **The graphical application (GUI)** is the primary way to use it, and the + subject of [§2](#2-using-rectify). It is interactive: you see the detected + region, drag it into place, and watch the corrected result update live. On + macOS it is delivered as a ready-to-run `Rectify.dmg`. +- **The command line (CLI)** drives the same engine without a window, for + scripting and batch processing. On Linux, where Rectify is run from source, the + command line is the primary way to interact with it. The CLI is documented in + [§3.3](#33-the-command-line-interface). + +## 2. Using Rectify + +This part is a complete, feature-by-feature guide to the Rectify application. It +assumes no programming knowledge. The early sections assume nothing about +photography either; the later ones (aspect ratio, bow, color) assume you are +comfortable with ordinary photographic ideas, and point you to the +[primer in §4.14](#414-color-theory-primer) when a deeper concept comes up. + +### 2.1 Installing Rectify on macOS + +You have been given a file named **`Rectify.dmg`**. To install: + +1. **Double-click `Rectify.dmg`.** A window opens showing the **Rectify** app icon + and a shortcut to your **Applications** folder. +2. **Drag the Rectify icon onto the Applications folder** in that same window. + This copies the application onto your Mac. +3. Close the window and **eject** the disk image (drag it to the Trash, or click + the eject arrow next to it in a Finder sidebar). You no longer need the `.dmg`. +4. Open Rectify from **Applications** (or from Launchpad, or via Spotlight — + press ⌘-Space and type "Rectify"). + +The application is signed and notarized by Apple, so it opens like any other Mac +app — just double-click. If macOS ever shows a caution the very first time, +**right-click (or Control-click) the Rectify icon and choose "Open"**, then +confirm; you only need to do this once. + +Rectify runs on both Apple-silicon (M-series) and Intel Macs. There is nothing +else to install — everything it needs is inside the app. + +### 2.2 The main window + +When Rectify opens, the window has five areas, top to bottom: + +**Action bar (top).** Buttons for **Open**, **Reset**, **Save**, an **Increment** +checkbox, and — after a gap — a **depth** indicator with **Peel in** / **Peel +out**. These are discrete actions that "do something now." Save and its Increment +mode are described in [§2.13](#213-saving-your-work); the gap separates the +file actions from the peel actions. + +**Image panels (center).** Two side-by-side panels on a neutral gray background. + +- The **left panel** always shows your original photo with the detected region + drawn on top in green, with a round handle at each corner. +- The **right panel** shows the **result** — the corrected image you will save. + +Both panels zoom with the mouse wheel and pan when you drag the background. + +**Controls (below the panels).** Aspect-ratio correction, bow correction, the +language selector, and font-size **−/+** buttons. Detection has no controls here — +it is fully automatic. + +**Tooltips.** Hover over any control — toolbar button, checkbox, radio, slider, or +label — for a short explanation in a boxed popup; the toolbar buttons also show +their keyboard shortcut. Tooltips follow the selected interface language. + +**Action row (below the controls).** **Action: Extract / Transform** radio +buttons. **Extract** (the default) pulls the detected region out of the photo and +straightens it into a rectangle. **Transform** corrects the whole image's +perspective instead, and reveals additional controls on the same row (see +[§2.8](#28-full-image-perspective-correction) and +[§2.9](#29-keystone-correction)). + +**Status bar (bottom, italic).** Shows the input filename and dimensions, the peel +depth, the output dimensions, the transform mode (when active), the aspect ratio +(when enabled), the undo count, and the path of the last file you saved. + +**Language and font size.** The language dropdown switches all interface text +between English, German, and Finnish. The **−/+** buttons scale the font, and +every margin and control scales with it — handy on very large or very small +displays. + +### 2.3 Opening images + +There are several ways to load a photo: + +- Click **Open** (or press **⌘-O**) and choose a file. +- **Drag an image file** from the Finder directly onto the Rectify window. +- Once an image is open, press **⌘-↓ / ⌘-↑** to step to the **next / previous** + image in the same folder (it wraps around at the ends). This is the fast way to + work through a folder of photos — each one is detected fresh as it loads. + +**Supported formats:** JPEG, PNG, TIFF, BMP, WebP, and **HEIC/HEIF** (the format +iPhones save by default). HEIC photos are decoded to their standard image and +converted to sRGB so colors look right everywhere; if the photo carries an HDR +gain map it is ignored, which is the correct choice for documentation work. +Because HEIC (like JPEG) is a *finished* picture — the camera's white balance and +tone are already applied — color correction works on the residual cast, not raw +sensor data (see the [primer](#414-color-theory-primer)). Saved output is always +one of the standard formats; HEIC is read-only. + +When an image loads, Rectify immediately evaluates the detection and shows you the +best result — the green quadrilateral on the left, the straightened result on the +right. From there you refine by hand as needed. + +### 2.4 How detection works, and the vocabulary + +The green shape on the left panel marks the area Rectify will straighten. A few +consistent names make the rest of this guide easier to follow. + +- **The quad** — the four-sided green shape. It is always a quadrilateral (four + straight sides), though it usually is not rectangular until Rectify straightens + it. +- **Corners** — the four vertices, shown as green circular handles. Each is named + for where it ends up in the result: top-left, top-right, bottom-right, + bottom-left. +- **Edges** — the four sides connecting adjacent corners. + +**What the automatic detection does.** Each time an image loads, Rectify tries +both detection strategies (brightness-based and color-richness-based) across many +sensitivity settings, scores each candidate for how rectangular and well-placed it +is, and displays the best. You usually get a good result instantly, and fix any +imprecision with the gestures in [§2.5](#25-refining-the-detected-region). There +are no strategy or sensitivity controls in the application — the sweep is always +what runs. (The full method is in +[§4.8](#48-scoring-and-strategy-selection).) + +**Two kinds of interaction: positioning vs. editing.** The left panel responds +differently depending on where you click inside the quad. This distinction is the +key to the whole interface: + +- **Editing** (click a corner handle, click near an edge, or use the arrow keys) + *adjusts the detection*. The right panel updates immediately to show the effect. +- **Positioning** (click in the **center** of the quad and drag, or scroll the + wheel to resize it) *moves or scales the whole quad* to select a different area. + The right panel does **not** update, because the quad is now a rough selection, + not a finished detection — [Peel in](#26-the-peel-stack) will refine it. + +In detail, the **click zones** on the left panel are: + +- **On a corner handle** — the handle turns yellow; drag it, or nudge it with the + arrow keys (*editing*). +- **Near an edge** — the nearest edge turns yellow; the arrow keys nudge both of + its corners together (*editing*). +- **In the center** (roughly the inner half) — all four edges turn yellow. Then a + **mouse drag** moves the whole quad and the **scroll wheel** grows or shrinks it + (*positioning*); the **arrow keys** expand or contract it symmetrically + (*editing*). +- **Outside the quad** — the panel pans (scroll-hand drag). + +### 2.5 Refining the detected region + +When the automatic detection is close but not perfect, these gestures fix it. They +all update the right panel as you go. + +**Drag a corner.** Click and drag any corner handle (it turns yellow). For +accuracy, hover over the corner and **zoom in with the mouse wheel** first — the +view stays anchored on the corner — then drag it precisely into place. + +**Nudge with the arrow keys.** Click a corner (or an edge) and press the **arrow +keys** to move it one pixel at a time, in the *straightened* output's sense of +up/down/left/right, regardless of how the quad is tilted in the photo. + +**The zoom-edit-reset rhythm.** A fast mouse-only loop for precise corners: +hover a corner → **scroll up to zoom in** (anchored on the cursor) → drag the +corner into place → **right-click to reset the zoom** → move to the next corner. +The **0** key resets the zoom on both panels at once. While zoomed in, you can +click **outside** the quad and drag to pan. + +**Preview mid-drag.** While dragging a corner or edge with the left button held, +**click the right mouse button** to update the right panel without letting go — +preview the result, then keep adjusting. + +**Snap the nearest point to a click (⌘-Click).** For precision, hold **⌘** and +click exactly where the nearest point should sit — the closest corner jumps there, +no dragging. The cursor becomes a crosshair while ⌘ is held. It is meant for use +while zoomed in: zoom to the feature, then ⌘-click on it. + +**The alignment indicator.** When two corners of an edge line up exactly — +identical horizontal position (a true vertical edge) or identical vertical +position (a true horizontal edge) — that edge turns **blue**. A fully axis-aligned +rectangle shows all four edges blue. Use the arrow keys for pixel-perfect +alignment. + +**Nudge or drag a whole edge.** Click near an edge (not on a corner) to select it +(it turns yellow), then either press the **arrow keys** (perpendicular arrows trim +it in or out; parallel arrows slide it sideways) or **drag** it with the mouse. +This is ideal for trimming a thin strip of leftover frame after peeling. + +**Undo.** **⌘-Z** undoes a corner adjustment; **⌘-Shift-Z** redoes it. + +**Selecting a different subject (center-drag).** If your photo has several +subjects (a gallery wall of paintings), Rectify detects the most prominent one. To +grab a different one: + +1. Click in the **center** of the quad (all edges turn yellow). +2. **Drag** to move the quad over the subject you want. +3. **Scroll the wheel** (with the mouse button held) to grow or shrink the quad + until it roughly covers that subject. +4. Release. Then click **Peel in** — Rectify straightens the selected area and + re-detects *within* it to find the precise boundary. + +The quad only needs to enclose the subject with a little surrounding context; the +detector finds the real edges inside it. + +### 2.6 The peel stack + +Many paintings have nested borders: an outer frame, an inner mat, then the canvas. +"Peeling" strips these one layer at a time. + +1. The first detection finds the outermost boundary (usually the frame edge). +2. Click **Peel in** (or press **+**): Rectify crops to the detected region, + re-detects inside it to find the next inner boundary, and — if it finds one — + moves you one layer deeper. +3. The **left panel** keeps showing your original photo; the green overlay updates + to show the innermost region you have reached. The **right panel** shows the + final, fully-peeled result. +4. Click **Peel out** (or press **−**) to back out one layer. + +The **Depth** indicator shows the current level (0 = the original image). If Peel +in cannot find an inner region, it tells you and disables the button until you +peel out or edit the corners. Each level detects independently; the first +detection prefers the *largest* good region (more context), while peel-in prefers +a *tighter* inner boundary (skipping past borders). + +### 2.7 Reset and Re-open + +Two ways to restart work on an image: + +**Reset (⌘-D, or the Reset button)** throws away the current state and starts +fresh on the same pixels: back to depth 0, automatic detection from scratch, +undo history cleared, aspect ratio recomputed, and back to Extract mode if you +were in Transform. Use it when the current corners aren't worth keeping. + +**Re-open (⌘-R, keyboard only)** re-reads the file from disk but *restores your +edits* from Rectify's per-image memory: your corners, keystone lines, and bow +value come back, and the peel stack is rebuilt. Use it when you have edited the +source photo in another program and want your tuned corners applied to the updated +pixels — edit the source, save it, press ⌘-R. + +| | Reset | Re-open | +|---|---|---| +| Image | Same pixels | Re-read from disk | +| Corners | Re-detected from scratch | Restored from memory | +| Peel stack | Cleared | Rebuilt | +| Aspect ratio | Recomputed | Kept if you set it by hand, else recomputed | +| Undo history | Cleared | Cleared | +| Extract/Transform | Back to Extract | Preserved | + +### 2.8 Full-image perspective correction + +Instead of extracting one rectangle, Rectify can correct the perspective of the +*entire* image — like a view camera's tilt/shift. You choose a feature you know is +truly rectangular, and the whole scene is warped so that feature becomes square. + +1. In the **Action** row, select **Transform** (default is Extract). +2. A **Crop** checkbox appears. **On** (the default), the output is cropped to the + largest rectangle that fits entirely inside the corrected image, removing the + empty corners the warp creates. **Off**, the corrected image sits on a + background of your chosen **fill color** (click the color swatch to change it). +3. The right panel updates live. + +**Choosing the reference.** The quad now defines what "straight" means, so place +its corners on something genuinely rectangular: a window, a door frame, an +architectural panel — or any four points you know form a rectangle (matching +column bases on opposite sides of a nave, for instance). **Manual corner +placement is essential here** — drag the corners onto the reference points and +fine-tune with the arrow keys. + +**Notes.** Peeling is disabled in Transform mode. Switching from Extract to +Transform preserves your Extract work and restores it when you switch back. A +single correction straightens *one* plane perfectly; other planes in the scene +(a second wall) may be less correct. The command line offers the same feature for +batch use — see [§3.3](#33-the-command-line-interface). + +### 2.9 Keystone correction + +Keystone distortion is when parallel lines converge because the camera was tilted — +verticals lean together when you shoot upward at a building, horizontals when you +shoot from the side. Rectify removes it from line pairs. + +1. Select **Transform** in the Action row. +2. In the **Method** selector that appears, choose **Lines** (default is Quad). +3. **Vertical** and **Horizontal** checkboxes appear — Vertical is on by default. + Line segments with arrow-shaped handles appear, auto-placed on the strongest + edges. +4. Drag the arrow endpoints so each line lies along a feature you know is straight + and parallel to the other line in its pair. + +**Using line pairs.** **Vertical** corrects converging verticals (the common case +from tilting up at a building); check **Horizontal** to also correct converging +horizontals. Either or both can be on. Drag a line by its body to move it as a +whole, or drag an arrow endpoint to fine-tune one end. The **Crop** and **fill +color** options behave exactly as in [§2.8](#28-full-image-perspective-correction). +A keystone line turns **blue** when its endpoints share an exact horizontal or +vertical position — on a real tilted building that often means the auto-placement +has snapped to a stair-step artifact rather than a real edge, so nudge it onto the +intended feature with ⌘-Click or the arrow handles. + +**Typical workflow:** load a building photo with converging verticals → Transform → +Lines → align the two vertical lines with two true verticals (window frames, +columns) → the right panel shows them made parallel and upright → if horizontals +also converge, check Horizontal and align those too. The geometry behind this is +in [§4.12](#412-the-transform-pipeline). + +**Stretch.** Straightening the converging lines fixes the *axes* but cannot, on its +own, recover how wide the result should be relative to its height — the lines give +directions, not a scale, and (unlike Transform → Quad) there's no rectangle to read +a ratio from, so camera EXIF doesn't help here either. The **Stretch** slider lets +you correct that residual width-to-height relationship by eye: drag until the +proportions look right (squares look square, circles round). **1.00** leaves the +width unchanged; above 1.00 widens, below narrows. The value shows in gray italics +at 1.00 to signal it's doing nothing — exactly like Bow at 0.00. Stretch appears +only in Transform → Lines and is remembered per image. + +### 2.10 Aspect-ratio correction + +A rectangle photographed at an angle comes back with the right *shape* but not +necessarily the right *proportions* — the side nearer the camera looks larger, +which biases the width-to-height ratio. + +**Automatic.** When the photo carries the right metadata (its 35 mm-equivalent +focal length), Rectify computes the true ratio and, if it is confident, switches +the **Aspect ratio** correction on with that value. If you fix a detection by +editing corners, the automatic estimate follows your corrected quad — until you +set a value by hand, after which it stays put. + +**Manual.** The **Aspect ratio:** control is a label, a checkbox, and a value +slider/number. Toggle the checkbox to apply it or not; set any value from 0.30 to +3.00. The ratio is width ÷ height: a square is 1.0, landscape 4:3 is 1.333, +portrait 3:4 is 0.75. Use it when there is no metadata, when you know the true +ratio (1.0 for a square tile, 2.35 for a CinemaScope frame), or to tune by eye. + +The value's styling is a cue: **gray (italic)** means no action is needed from you +— the correction is off, or a reliable value was recovered automatically from the +photo's camera data (EXIF). **Black (normal)** means this image has no usable camera +data (a screenshot, or EXIF stripped), so the software cannot recover the ratio on +its own — drag the slider until the proportions look right. **Red** means the +recovered ratio is more extreme than the slider's 0.10–10.0 range allows, so it is +pinned at the limit (e.g. 10.0) and the proportions can't be fully reached — pick a +less extreme reference rectangle if you need them exact. The value is remembered +(not reset to 1.0), so turning the checkbox on uses what is shown. The correction +preserves the longer dimension and stretches the shorter one, so no pixel detail is +lost. Aspect ratio applies in **Extract mode and in Transform → Quad**; it is hidden +only in Transform → Lines (which derives its own geometry from the keystone +correction) and returns when you leave Lines. The math is in +[§4.7](#47-aspect-ratio-recovery-from-a-single-image). + +### 2.11 Bow correction + +Even after a careful extraction, the edges of the result sometimes bow slightly +at their middles — you see a sliver of frame where the painting should be, or the +edge curves out past it, while the corners look right. This is leftover lens +distortion, most visible when the camera was held nearly head-on. + +The **Bow** slider straightens it with a corner-preserving correction: the four +corners stay exactly where you put them, and the mid-edge content is moved. +*Positive* values push mid-edge content outward to straighten edges that bow +*inward* (**pincushion** distortion); *negative* values pull it inward to +straighten edges that bow *outward* (**barrel** distortion). + +The value is **in output pixels** — the maximum radial distance the correction +moves content (positive = expand outward, negative = contract inward). The slider +is centred on **0**, with its coloured fill growing left (negative) or right +(positive) from the middle, so the two directions read symmetrically. + +1. Make sure the corners are exactly on the painting's true corners (⌘-Click or + drag). +2. Zoom into one bowed edge in the right panel. +3. Drag the **Bow** slider, click the up/down arrows, or type a pixel value — up + for inward-bowed edges, down for outward-bowed ones. The panel updates in place + without losing your zoom. +4. Stop at the smallest magnitude that straightens the edge; overshooting curves it + the other way. + +The slider runs from **−200 to 200 px**. Typical magnitudes are roughly **18–74 px** +for iPhone main-camera photos (on a ~3000×4000 image). Because the value is an +absolute pixel distance, the same setting produces the same visual amount of +correction regardless of the output's size — internally it is converted to the +curvature against the output's half-diagonal. Each image's bow value is remembered. +This is a *cosmetic* correction anchored at the corners, not a true lens-distortion +model — for mild bowing it gives a visually straight result; extreme or strongly +off-center distortion won't fully straighten interior features. Extract mode only. + +### 2.12 Color correction + +When accurate color matters — and for photographing artwork it usually does — the +surest way to recover it is to put a **known-neutral reference** in the shot beside +the subject, then tell Rectify to neutralize the whole image to it. That removes +the color cast of whatever light you were under. It is the same "click on something +neutral" technique every raw developer offers, and Rectify supports three kinds of +reference. If the ideas here are unfamiliar, read the +[color-theory primer in §4.14](#414-color-theory-primer) first. + +**Choosing a mode.** Tick the **Color correct** checkbox and three radio buttons +appear — **Gray card**, **Neutral gray**, **90% White** — with *none* selected to +start, because the right choice depends on what you actually photographed: + +- **Gray card** — for a photographic gray card of a known rating. It *couples* + color and exposure: it removes the cast **and** sets the overall brightness from + a single **Reflectance** target (default **18%**, standard middle gray; + adjustable 3–80%). +- **Neutral gray** — for any patch you trust to be a neutral gray. It *decouples* + color from brightness: it removes the cast but leaves the patch at the brightness + the camera captured, so nothing is forced to pure white. A separate **Brightness** + control (in stops, centered at 0) then raises or lowers the whole result. This is + the mode for "just take the cast off." +- **90% White** — for a diffuse-white reference (a white card or clean white + sheet). Like Gray card it couples color and exposure, but the target is **locked + at the 90% diffuse-white standard**, so there is no target knob — just sample and + go. + +**How to use it:** + +1. Tick **Color correct** and click a mode. The **swatch**, the mode's control (if + any), and the **Sample size** control appear. +2. Click the **swatch**. The cursor becomes a crosshair, and a short reminder + appears over the left panel telling you what to click — the next click is a + *sample*, not an edit. (The reminder clears as soon as you move onto the panel.) +3. Click your reference in the **left (source) panel**. Rectify averages a small + square there and corrects the result on the right. A red square marks where you + sampled; re-click as often as you like to try other spots. Your source is never + altered — only the result. + +**The controls:** + +- **Reflectance** (Gray card) — the tone the sampled patch is mapped to, as a + reflectance percentage (the number printed on a gray card). Default **18%** + (middle gray). Set it to your card's actual rating, or use it as an exposure + lever — higher is brighter. Reflectance is *linear*: 18% is middle gray and 50% + is already quite bright, so don't reach for 50% expecting "neutral." Range 3–80%. +- **Brightness** (Neutral gray) — an overall lightness adjustment in stops, + centered at **0** (no change). At 0, only color is corrected and the captured + brightness is kept; go negative if neutralizing the cast pushes a bright area to + clip, positive to lighten. It never affects the color balance, only the level. +- **90% White** has no target control — the target is the fixed 90% standard. +- **Sample size** — the side length, in source pixels, of the averaged square. + **1** samples the single clicked pixel; **10** (default) averages a 10×10 block. + Increase it for a larger, more representative patch; decrease it when the neutral + area is tiny. +- **The swatch** shows the color you sampled, so you can see the cast you grabbed. + It turns **red** when the spot is unusable — clipped to white or crushed to black, + where the correction can't be computed. (White references sit near the top of the + range, so pick a sheet that isn't blown out.) +- **The status bar** shows the sampled color while correction is active (the mode + name and the three channel values, normalized 0–1). For a truly neutral + reference the three numbers are close; if blue reads much higher than red, the + spot is bluish and neutralizing it will warm the whole image — a sign the spot + wasn't really neutral. + +**Working zoomed in.** Picking a reference, switching modes, and adjusting +Reflectance, Brightness, or Sample size all keep the right panel's current zoom and +pan, so you can judge the correction on a detail. The sliders are *debounced* — +they update the value immediately but wait until you pause to recompute the (slow) +image — and saving always reflects the latest values. + +**Where to place the reference.** Anywhere in the frame outside the subject is +fine; sample it on the left panel where the whole photo is visible. Rectify reads +the correction from the source but applies it to the extracted or transformed +*output*, so the reference need not survive into the final crop. Color correction +works in every mode (Extract, Transform → Quad, Transform → Lines). + +**Reusing one reference across a batch.** The measured reference is remembered, so +if you shoot a series under the same light you only need the reference in *one* +frame: + +- A new image opens with **Color correct off**. Turn it on, and if you haven't + sampled on *that* image, Rectify borrows a **snapshot** of your last measured + reference. The swatch shows the borrowed color and there is **no red marker** — + the marker's absence tells you the correction is borrowed. +- The swatch's two clicks differ: **left-click** arms a new pick (then click a + neutral area); **right-click** re-applies your last measured reference to the + current image (use it to push an updated reference onto an image that already had + one). +- It is a **snapshot, not a live link** — borrowing copies the value; re-picking + the reference frame later won't change images that already borrowed the old one. +- A reference is only valid under the **same light** as the frame you measured it + on. Move to different lighting and pick a fresh one. + +**What each image remembers.** Every image keeps its own on/off state, **mode**, +sample point, and any borrowed color, so stepping between images with ⌘-↑/↓ +preserves exactly how each is corrected. A newly opened image starts with **no mode +chosen**. The three modes keep **separate** references on the same image, so you +can sample a gray card and a white sheet independently and switch between them +without losing either. **Reset (⌘-D)** clears the current image's correction but +keeps the remembered references, so a batch calibration in progress survives. + +**An honest limitation.** This corrects the *light's color* faithfully to the +file's encoding, but it cannot undo what the camera already baked into a JPEG or +HEIC, nor fix *uneven* lighting — a single global correction assumes the light's +color is the same across the whole frame. For a painting lit evenly by one source +it works very well; for mixed lighting it corrects the cast on average. + +### 2.13 Saving your work + +A single **Save** button writes the corrected result, with two modes set by the +**Increment** checkbox beside it: + +The default output name is the source image's name with **`_rectified`** appended +— for example `hotel.png` becomes `hotel_rectified.png` — so a save never +overwrites the original. + +- **Increment off** (default) — Save opens a dialog, pre-filled with that default + name. The extension you give picks the format (png, jpg, jpeg, tiff, tif, webp, + or bmp). An unsupported extension is rejected with a message listing the + supported ones. Type no extension and the last type you used is added (PNG to + begin with). +- **Increment on** — Save writes the next auto-numbered file with one click and no + dialog: *name*_rectified_1.*ext*, then *name*_rectified_2, … Rectify scans the + folder for the highest existing number and uses the next free one, so you never + overwrite an earlier save. + +Both modes share the **last folder you saved to** (the source image's folder the +first time) and the **last file type**. The folder, type, and Increment setting +are remembered across sessions. **⌘-S** also saves, using whichever mode is active. +The status bar shows the full path of the last file you saved. + +### 2.14 Comparing before and after + +Hold the **Space bar** to temporarily show the *original* image (without the green +overlay) in the right panel; release it to return to the corrected result. A quick +way to judge the correction. + +### 2.15 What Rectify remembers + +Rectify saves your preferences when you close it and restores them next time, so +you can pick up exactly where you left off — same image, same corners, same +settings. + +**Saved:** + +| Setting | Examples | +|---|---| +| Aspect ratio | On/off and value (auto per image, or your per-image override) | +| Extract/Transform | Mode, Crop toggle, fill color | +| Saving | Last folder, last file type, Increment on/off | +| Interface | Language, font size, window size/position, panel split | +| Per-image memory | For every image you've touched: corner positions at all peel depths, keystone line pairs, bow value, color-correction state, and any manual aspect override | +| Last image | The file to reopen on next launch | + +**Not saved:** undo/redo history, and detection settings (there are none to +remember — the automatic sweep runs on every load). + +Because each image's edits live in this per-image memory, you can prepare a whole +set in one session — adjusting corners, lines, bow, color, and aspect on each — +just by stepping between them with ⌘-↑/↓, without saving to disk between switches. + +The preferences file is stored per-user, so multiple users on one Mac keep +independent settings: + +| Platform | Location | +|---|---| +| macOS | `~/Library/Application Support//Rectify/settings.json` | + +(It is plain JSON and can be edited by hand if you ever need to, though normally +you never will. The Linux location is noted in [§3.1](#31-installation).) + +### 2.16 Keyboard shortcuts + +| Shortcut | Action | +|---|---| +| ⌘-O | Open image | +| ⌘-S | Save result (PNG if no extension given) | +| ⌘-D | Reset (clear state, re-detect from scratch) | +| ⌘-R | Re-open current file from disk (restores your cached edits) | +| ⌘-↓ / ⌘-↑ | Next / previous image in the folder (wraps) | +| ⌘-Z / ⌘-Shift-Z | Undo / redo a corner adjustment | +| + or = | Peel in | +| − | Peel out | +| Space (hold) | Show the original in the right panel (before/after) | +| Arrow keys | Nudge the selected corner, edge, or whole quad | +| Mouse wheel | Zoom (or adjust the element under the cursor with Shift held) | +| Shift + wheel | Adjust the quad element under the cursor | +| ⌘ + Left-click | Snap the nearest point to the click position | +| Right-click | Reset zoom on the clicked panel | +| 0 | Reset zoom on both panels | +| Esc | Clear temporary marks | +| Shift+Option (hold) | Show the keyboard-shortcut overlay (release to dismiss) | + +Hold **Shift+Option** at any time to see this table without leaving the window. + +### 2.17 When automatic detection struggles + +When detection grabs the wrong corners or misses the subject, your tools in the +application are the manual gestures from [§2.5](#25-refining-the-detected-region): +drag corners and edges, snap the nearest point with ⌘-Click, nudge with the arrow +keys, or use the center-drag "select a subregion" workflow to point detection at a +different part of the image. Common situations: + +- **Low contrast between subject and background** — edit the corners by hand; + often the boundary is clear to your eye even when the detector hesitates. +- **Busy or textured backgrounds (brick, patterned wallpaper)** — detection usually + still finds the subject; correct any stray corner by dragging. +- **Several rectangles in the frame** — Rectify picks the most prominent; use + center-drag to move the quad over the one you want, then Peel in. +- **Frames within frames** — use the [peel stack](#26-the-peel-stack), peeling in + past each layer. +- **Black-bordered screenshots** — handled automatically; the black border is + detected and the content boundary found without intervention. +- **Very small or thin regions** — if peeling would produce a degenerate region, + Rectify shows a message instead of failing. + +If you want to push detection harder by hand — choosing a strategy, raising the +sensitivity, changing the edge thresholds — those controls exist only on the +command line; see [§3.3](#33-the-command-line-interface). + + +## 3. For programmers + +This part covers running Rectify from source, driving it from the command line, +how the code is laid out, and how to extend it. It assumes you have read +[§2](#2-using-rectify) to learn what the features *do* — here we focus on how the +program is built and operated, and it links to +[§4](#4-background-and-reference) for the algorithms behind each stage. + +### 3.1 Installation + +#### macOS (the app) + +For day-to-day use on a Mac, install the supplied `Rectify.dmg` exactly as in +[§2.1](#21-installing-rectify-on-macos) — drag the app to Applications. That bundle +is a self-contained build (Python, Qt, and OpenCV included) and needs nothing +else. To *develop* on macOS instead, follow the from-source steps below; they work +on macOS with Homebrew or python.org Python (Apple silicon and Intel). + +#### Linux (from source) + +On Linux, Rectify is run from source, and the **command line is the primary way to +interact with it** (the GUI is available too, and behaves as described in §2). + +**Prerequisites:** Python 3.10 or later, `pip`, and `git`. + +1. **Obtain the source tree.** + + ```bash + git clone https://git.andykopra.com/ack/rectify.git + cd rectify + ``` + +2. **Create a virtual environment and install dependencies:** + + ```bash + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + + This pulls in OpenCV, NumPy, PySide6 (Qt), Pillow, and `pillow-heif` (HEIC/HEIF + reading — it bundles its own `libheif`, so no system library is required). + +3. **Qt system libraries.** If PySide6 fails to start, install the X11/XCB + libraries Qt needs. On Ubuntu/Debian: + + ```bash + sudo apt install libxcb-xinerama0 libxcb-cursor0 + ``` + +4. **(Optional) HEIC thumbnails in the file chooser.** Rectify reads HEIC + regardless, but the **Open** dialog's thumbnails come from your desktop, which + on older distributions can't render iPhone HDR HEICs. Run + `scripts/install_heic_thumbnailer.sh` once to enable them (it self-skips if your + system already handles HEIC). + +5. **Run it:** + + ```bash + source .venv/bin/activate # if not already active + + python -m rectify --gui # GUI, open a file later + python -m rectify --gui photo.jpg # GUI with an image + python -m rectify photo.jpg -o rectified.jpg # CLI, no window + ``` + +On Linux the per-user settings file lives at +`~/.local/share//Rectify/settings.json` (the macOS path is in +[§2.15](#215-what-rectify-remembers)). + +#### Windows (from source) + +On Windows, Rectify is run from source. Every dependency publishes a Windows +wheel — PySide6 bundles Qt and `pillow-heif` bundles `libheif` — so `pip +install` is the entire build step: no compiler is needed, and no system +libraries have to be installed the way they do on Linux. The **Open** dialog is +the standard Explorer dialog, complete with image thumbnails. + +**Prerequisites:** Python 3.10–3.13 from +[python.org](https://www.python.org/downloads/windows/), installed with **"Add +python.exe to PATH"** ticked. `git` is optional — the repository can be +downloaded as a ZIP instead. + +1. **Obtain the source tree and create a virtual environment.** In 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 + ``` + + From Command Prompt the only difference is the activation line, + `.venv\Scripts\activate.bat`. If PowerShell blocks the activation script, + permit it for that window alone with + `Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass`. + +2. **Run it:** + + ```powershell + python -m rectify --gui # GUI, open a file later + python -m rectify --gui photo.jpg # GUI with an image + python -m rectify photo.jpg -o rectified.jpg # CLI, no window + ``` + +On Windows the per-user settings file lives at +`%APPDATA%\\Rectify\settings.json`. + +**Two failure modes worth naming.** If PySide6 raises `ImportError: DLL load +failed while importing QtCore`, install the Microsoft Visual C++ 2015–2022 +Redistributable (x64), which Qt links against; it is already present on most +Windows machines. If `py` is not recognized, the Python launcher was not +installed — use `python -m venv .venv` instead. + +**Platform maturity.** Windows is not yet part of the regular test rotation, and +the code carries no Windows-specific branches (the only per-platform code in the +tree is a handful of macOS conditionals, which fall through to the generic path). +The areas most likely to reveal a problem, and therefore worth checking +deliberately on a first run, are: opening an iPhone HEIC/HEIF photo; display +scaling at 125% and 150%, against Rectify's own font scaling; the Ctrl-based +keyboard shortcuts; saving to a path containing spaces; and building +`dist\rectify.exe` with `pyinstaller rectify.spec`. + +### 3.2 How the code is organized + +``` +rectify/ +├── __main__.py Entry point (python -m rectify) +├── cli.py Argument parsing, CLI pipeline +├── gui.py PySide6 GUI (QMainWindow, QGraphicsView panels) +├── detect.py Detection engine (strategies, scoring, evaluation) +├── transform.py Perspective transform, color correction (NumPy only) +└── utils.py Image I/O, OpenCV↔Qt conversion +``` + +Dependencies flow one way: `cli.py` and `gui.py` drive `detect.py` and +`transform.py`, which depend only on `utils.py`. Crucially, **`detect.py` and +`transform.py` have no GUI dependencies** — they operate on NumPy arrays alone, so +the detection and transform engines can be reused in other contexts (a web API, a +mobile app, a batch processor) without modification. + +The algorithm internals behind these modules — edge detection, scoring, the +transform math, aspect recovery, the peel-stack coordinate mapping — live in +[§4](#4-background-and-reference). The GUI's internal structure (class hierarchy, +the QGraphicsView coordinate system, undo, cursor logic, settings persistence) is +in [§4.13](#413-gui-architecture-internals). + +### 3.3 The command-line interface + +The CLI drives the same engine as the GUI without a window — for scripting and +batch processing, and as the primary interaction mode on Linux. Each option below +links to the GUI description of the same capability. + +**Basic extraction** (auto strategy and sensitivity — the same automatic sweep the +GUI runs, see [§2.4](#24-how-detection-works-and-the-vocabulary)): + +```bash +python -m rectify photo.jpg -o rectified.jpg +``` + +**Detection parameters** (the knobs the GUI deliberately hides — see +[§2.17](#217-when-automatic-detection-struggles)). `--strategy` chooses the +detection channel; `-s/--sensitivity` and the Canny/area/epsilon parameters tune +it directly: + +```bash +python -m rectify photo.jpg -o rectified.jpg --strategy saturation -s 0.7 +python -m rectify photo.jpg -o rectified.jpg --blur 3 --canny-low 30 --canny-high 100 +``` + +`--strategy {auto,grayscale,saturation}`, `-s/--sensitivity 0.0–1.0`, `--blur`, +`--canny-low`, `--canny-high`, `--min-area`, `--epsilon`. The sensitivity-to- +parameters mapping is in [§4.2](#42-edge-detection). + +**Peeling** (the [peel stack](#26-the-peel-stack)): + +```bash +python -m rectify photo.jpg -o rectified.jpg --peel 1 # one layer +python -m rectify photo.jpg -o rectified.jpg --remove-frame # = --peel 1 +python -m rectify photo.jpg -o rectified.jpg --peel 3 # three layers +``` + +**Full-image perspective correction** (the [Transform mode](#28-full-image-perspective-correction)): + +```bash +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" +``` + +**Incremental, auto-numbered output** (the same scheme as the GUI's +[Save](#213-saving-your-work)). With no `-o`, files are written as +*prefix*_*N*.*ext*: + +```bash +python -m rectify photo.jpg # ./rectify_1.png, ... +python -m rectify photo.jpg --dir output/ --prefix museum --ext jpg +``` + +The sequence number N comes from scanning the output directory for existing files +matching the prefix across *all* extensions, so `museum_3.png` and `museum_5.jpg` +both count and the next is 6. + +**Batch processing** (Linux/macOS shell): + +```bash +for img in photos/*.jpg; do + python -m rectify "$img" --dir rectified/ --prefix painting --peel 1 +done +``` + +**Debug logging** records the detection pipeline's decisions (contour counts, area +filters, candidate scoring, the two-pass sweep, final selection), each entry +timestamped — useful for diagnosing why a detection was chosen or missed: + +```bash +python -m rectify photo.jpg -o out.jpg --debug # → rectify_debug.log +python -m rectify --gui photo.jpg --debug my_debug.log # custom path, GUI too +``` + +**Keyboard reference:** `rectify -k` (also `--keyboard`) prints the shortcut table +(the same one the GUI shows on Shift+Option). + +**Note:** color correction ([§2.12](#212-color-correction)) is interactive — +it depends on clicking a reference in the image — so it is a GUI-only feature; the +CLI does not apply color correction. + +### 3.4 What the color modes actually do + +All three modes ([§2.12](#212-color-correction)) share the same shape: sample a +small neutral patch, compute one **per-channel linear-light gain**, and multiply +the output image by it. They differ only in how the gain is derived. The theory — +reflectance, middle gray, why the math is done in linear light — is in the +[primer, §4.14](#414-color-theory-primer). The implementation lives in +`transform.py`. + +**Sampling.** `sample_patch_bgr(image, cx, cy, radius)` averages a `radius × radius` +square (radius 1 = the single clicked pixel) and returns the mean BGR as float in +0–255. A patch is rejected (the swatch goes red) if any channel is at or beyond the +clip guards (`≤ 3` crushed, `≥ 252` clipped), where a per-channel gain would blow +up or divide by ~zero. + +**Coupled correction — Gray card and 90% White** — `gray_correction_gains(sampled, +target_reflectance)`: + +``` +gain_c = target_reflectance / sampled_linear_c (von Kries, per channel) +``` + +The sampled BGR is converted from sRGB to linear light; the gain maps it to +`target_reflectance` (a linear quantity, used directly). Because the target is the +*same* for all three channels, one gain set simultaneously removes the cast (equal +targets ⇒ neutral) **and** sets exposure (the target level). **Gray card** passes +your Reflectance percentage as the target (default 0.18); **90% White** passes a +fixed `0.90`. That single difference — and the fact that the Gray card slider caps +at 0.80 — is the entire distinction between the two modes. + +**Decoupled correction — Neutral gray** — `white_correction_gains(sampled, +brightness_stops)`: + +``` +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 patch comes out neutral (R = G = B) at *exactly its original +luminance* — the cast is removed but nothing is forced to white, so a specular +glint or a visible bulb keeps its headroom. The brightness factor then scales the +whole result. This is why the mode is "just remove the cast." + +**Applying the gain.** `apply_gray_correction(image, gains)` multiplies the BGR +image by the per-channel gains in linear light and re-encodes to sRGB. The same +gain is applied to whatever the right panel shows (extracted or transformed), so +the reference need not survive into the final crop. + +**Persistence keys.** Internally the modes are `"gray"` (Gray card), `"white"` +(Neutral gray — the original decoupled mode; the key is kept for backward +compatibility), and `"white90"` (90% White). Each keeps its own sampled point, +color, radius, and sticky value, so the three references coexist on one image. + +### 3.5 Extending Rectify + +**Add a detection strategy.** In `detect.py`, write a function in the mold of +`_detect_grayscale` / `_detect_saturation` (prepare a channel, call +`_multipass_canny` with suitable thresholds, return ordered corners or `None`); add +its name to the `STRATEGIES` tuple; add a case in `detect_quad()`; add it to the +candidate loop in `evaluate_strategies()`; and add a `--strategy ` choice in +`cli.py`. (The GUI exposes no strategy selector; the CLI does.) + +**Add scoring criteria.** Modify `_quality_score()` or `_score_quad()` in +`detect.py`. They take corners plus image dimensions and return a float (higher is +better); keep it roughly normalized to 0.0–1.0 so the thresholds in +`evaluate_strategies()` still hold. See [§4.8](#48-scoring-and-strategy-selection) +for what the existing terms mean. + +**Add GUI controls.** Controls are built in `_build_ui()` in `RectifyMainWindow`. +Connect them to detection or display methods via Qt signals, and wrap programmatic +updates in `blockSignals(True/False)` to avoid cascading recomputation. See +[§4.13](#413-gui-architecture-internals). + +**Diagnostic aids built into the GUI.** `Ctrl+Shift+E` toggles a Canny edge-detection +overlay on the left panel (red edges over the source, using the grayscale +defaults) — useful for seeing what the detector sees. `Ctrl+Shift+Delete` (or +`Ctrl+Shift+Backspace`) clears the settings cache and resets all controls to +defaults. Two environment variables draw layout-debugging overlays for GUI work: +`RECTIFY_DEBUG_BORDERS=1` outlines every widget, and `RECTIFY_DEBUG_BASELINES=1` +draws a red line at each text widget's baseline. + +### 3.6 Going deeper + +[§4](#4-background-and-reference) covers the design decisions and the mathematics +of every stage, plus the color-theory primer. Before modifying a subsystem, read +the reasoning behind it: + +| Subsystem | Where it is explained | +|---|---| +| Detection — strategies, scoring, padding | [§4.2](#42-edge-detection)–[§4.4](#44-contour-finding-and-polygon-approximation), [§4.8](#48-scoring-and-strategy-selection), [§4.11](#411-detection-padding) | +| Perspective transform and keystone correction | [§4.5](#45-corner-ordering)–[§4.6](#46-the-perspective-transform), [§4.12](#412-the-transform-pipeline) | +| Geometry corrections — aspect ratio, bow | [§4.7](#47-aspect-ratio-recovery-from-a-single-image), [§4.12](#412-the-transform-pipeline) | +| Editing gestures and the peel stack | [§4.9](#49-coordinate-mapping-through-the-peel-stack)–[§4.10](#410-rectified-space-nudging) | +| Color correction and input formats | [§4.14](#414-color-theory-primer) | +| GUI architecture, state, and persistence | [§4.13](#413-gui-architecture-internals) | + + +## 4. Background and reference + +This part explains *why* Rectify behaves as it does and *how* each stage works +mathematically, and ends with a self-contained color-theory primer. It is meant to +be dipped into — the earlier parts link here by section number. + +### 4.1 Design philosophy and policy decisions + +A handful of deliberate choices shape the whole program. + +**Show results, not knobs.** The graphical interface exposes *no* detection +parameters — no strategy, sensitivity, threshold, or area controls. The reasoning +is that a person looking at a photo can see instantly whether the corners are right +and can drag them if not, whereas a panel of sliders invites fiddling without +insight. So detection runs a full automatic sweep on every load +([§4.8](#48-scoring-and-strategy-selection)), and *all* refinement is direct +manipulation of the result. The underlying parameters still exist for +experimentation, but only on the command line +([§3.3](#33-the-command-line-interface)). + +**Positioning vs. editing.** The single most important interaction rule +([§2.4](#24-how-detection-works-and-the-vocabulary)) is that some gestures *edit* +the detection (and update the result live) while others *position* a rough +selection (and intentionally do not update the result). A center-drag or wheel- +resize is a coarse "look over here" that [Peel in](#26-the-peel-stack) will refine; +updating the result from it would imply a precision the selection doesn't have. + +**Prefer larger first, smaller when peeling.** Initial detection prefers the +*largest* high-quality region (capture the most context); peel-in prefers the +*smallest* (find the meaningful inner boundary, skipping past frame borders rather +than trimming slivers). Both go through the same selection logic, which first +prunes to candidates within a tight quality tolerance and only then breaks ties by +area — so a slightly-larger but visibly-worse quad cannot win on area alone. A peel +"area floor" additionally blocks high-quality outliers whose edge has locked onto a +feature far inside the parent. Details in +[§4.8](#48-scoring-and-strategy-selection). + +**Each peel layer in its own coordinate space.** The peel stack does not map +coordinates between layers; each layer is simply the rectified image of the one +above it, detected afresh ([§4.9](#49-coordinate-mapping-through-the-peel-stack)). +This is simpler and more robust than the alternative of mapping every detection +back to original coordinates, and it means the detector always sees exactly what +you see. + +**Pad only the original.** Detection pads the image edges outward so tightly-framed +subjects get a margin for the edge detector and the margin score +([§4.11](#411-detection-padding)). Padding is applied only to the original image; +on a rectified peel result, replicating edge pixels would invent false boundaries. + +**HEIC is treated as documentation source.** HEIC/HEIF photos are decoded to their +standard-dynamic-range base image and converted from Display P3 to sRGB; any HDR +gain map is ignored. For print/catalog documentation that is the correct choice — +you want a predictable SDR image in a known color space, not an HDR rendering. + +**Aspect ratio: automatic, overridable, and lossless.** When metadata permits, +the true ratio is computed and applied ([§4.7](#47-aspect-ratio-recovery-from-a-single-image)); +editing corners updates the estimate until you set a value by hand, after which +your value sticks. The correction preserves the longer output dimension and +stretches the shorter one, so it never discards pixels. + +**Bow is cosmetic by design.** The bow correction ([§2.11](#211-bow-correction)) is +a corner-anchored radial nudge, not a calibrated lens-distortion model. It is meant +to clean up the few tenths of a percent of residual pincushion (edges bow inward) or +barrel (edges bow outward) distortion left after a camera's own correction — +deliberately simple, and honest about not being a true undistort. + +**Color correction corrects the light, faithfully but globally.** The color tools +([§2.12](#212-color-correction), [§3.4](#34-what-the-color-modes-actually-do)) remove +the illuminant's cast as encoded in the file. They cannot undo what a camera baked +into a JPEG/HEIC, and they assume the light's color is uniform across the frame (a +single global gain). The three modes exist because "make this neutral" has two +honest meanings — *also set exposure* (coupled) or *leave exposure alone* +(decoupled) — plus a dedicated diffuse-white anchor; see the +[primer, §4.14](#414-color-theory-primer). The measured reference is a *snapshot* +when reused across a batch, never a live link, so re-measuring one frame never +silently changes images that already borrowed the old value. + +**Nothing is destructive.** The source file is never modified. All edits live in a +per-image cache so a whole set can be prepared in one session and saved on demand. + +### 4.2 Edge detection + +Rectify finds boundary pixels with the Canny edge detector (Canny, 1986), which +operates in four stages: + +1. **Gaussian blur** — smooths the image to reduce noise; the kernel size comes + from the sensitivity parameter. More blur suppresses noise but can soften real + edges. +2. **Gradient computation** — the intensity gradient magnitude and direction at + each pixel, via Sobel operators: + + Gx = ∂I/∂x, Gy = ∂I/∂y + magnitude = √(Gx² + Gy²) + direction = atan2(Gy, Gx) + +3. **Non-maximum suppression** — thins edges to single-pixel width by keeping only + local maxima along the gradient direction. +4. **Hysteresis thresholding** — two thresholds: pixels above `high` are strong + edges; pixels between `low` and `high` are kept only if connected to a strong + edge; pixels below `low` are discarded. + +The two thresholds are the primary sensitivity controls. Rectify maps the +user-facing sensitivity (0.0–1.0) to them, and to three more parameters: + +| Parameter | s = 0.0 | s = 1.0 | Effect of increase | +|---|---|---|---| +| Blur kernel | 7 | 3 | Less smoothing, preserves detail | +| Canny low | 70 | 15 | Detects weaker edges | +| Canny high | 180 | 80 | Detects weaker edges | +| Min area ratio | 0.05 | 0.01 | Accepts smaller regions | +| Epsilon ratio | 0.04 | 0.015 | Tighter polygon approximation | + +The threshold lines explicitly: + + canny_low = 70 − 55 × sensitivity (70 → 15) + canny_high = 180 − 100 × sensitivity (180 → 80) + +After edge detection, morphological operations (dilation, closing) bridge small +gaps. Rectify uses a multi-pass strategy: if the first Canny pass yields no +quadrilateral, it tries morphological closing (7×7 kernel), then progressively +lower thresholds (60% and 40% of the original). + +**Reference:** J. Canny, "A computational approach to edge detection," *IEEE +TPAMI*, vol. 8, no. 6, pp. 679–698, 1986. + +### 4.3 Saturation-channel detection + +When subject and background are similarly bright but differ in color richness (a +ceramic mural on a brick wall), the luminance channel gives poor edges. The +saturation strategy instead works on the **S** channel of HSV: + + H = hue (color angle, 0°–360°) + S = saturation (color purity, 0–255) + V = value (brightness, 0–255) + +Two findings from development shape it: **no Gaussian blur** is applied (saturation +transitions are inherently smoother than luminance ones, and blurring smears away +the subtle edges that matter), and **fixed Canny thresholds** (low = 20, high = 60) +are used, tuned for the saturation channel's distribution. + +### 4.4 Contour finding and polygon approximation + +OpenCV's `findContours` traces the boundaries of connected edge regions. Rectify +filters them by area (rejecting those below a minimum fraction of the image or +above 95% of it) and sorts by area, descending. + +Each candidate is simplified with the Douglas–Peucker algorithm (Ramer, 1972; +Douglas & Peucker, 1973) via `approxPolyDP`, which removes points within a +tolerance ε of the simplified line: + + ε = epsilon_ratio × perimeter + +with `epsilon_ratio` typically 0.02–0.04. A simplified polygon with exactly four +vertices is accepted as a quadrilateral candidate. If none is found, Rectify falls +back to the convex hull of the largest contour, approximated to four vertices. + +**References:** U. Ramer, *Computer Graphics and Image Processing*, vol. 1, no. 3, +pp. 244–256, 1972; D. H. Douglas and T. K. Peucker, *Cartographica*, vol. 10, +no. 2, pp. 112–122, 1973. + +### 4.5 Corner ordering + +Four unordered points are assigned to [top-left, top-right, bottom-right, +bottom-left] by a sum/difference heuristic: + + For each point (x, y): sum = x + y, diff = y − x + + top-left = minimum sum (closest to origin) + bottom-right = maximum sum (farthest from origin) + top-right = minimum diff (far right, near top) + bottom-left = maximum diff (far left, near bottom) + +For a roughly upright rectangle the top-left corner minimizes both x and y (hence +minimum x+y) while the bottom-right maximizes both. + +### 4.6 The perspective transform + +A perspective (projective) transform maps a quadrilateral to a rectangle with a +3×3 homography **H**. For four source points **p**ᵢ and destinations **q**ᵢ: + + λᵢ [qᵢ; 1] = H [pᵢ; 1] + +with scalar factors λᵢ. The system has 8 degrees of freedom (the 9 entries of **H** +minus scale), exactly determined by 4 correspondences. OpenCV's +`getPerspectiveTransform` solves it and `warpPerspective` remaps every pixel with +bilinear interpolation. The destination rectangle is sized as: + + width = max(dist(TL, TR), dist(BL, BR)) + height = max(dist(TL, BL), dist(TR, BR)) + +The maximum of each opposite pair is used because the side nearer the camera is +less foreshortened and so a better estimate of the true dimension. + +**Reference:** R. Hartley and A. Zisserman, *Multiple View Geometry in Computer +Vision*, 2nd ed., Cambridge University Press, 2004 (ch. 2 and 4). + +### 4.7 Aspect-ratio recovery from a single image + +A rectangle photographed at an angle loses its true proportions — the near side +appears larger. Rectify recovers the ratio by homography decomposition with the +camera intrinsics, following Zhang (2000) and Criminisi et al. (2000). + +**Camera intrinsic matrix.** A pinhole with focal length *f* (pixels) and principal +point at the image center: + + ┌ f 0 cx ┐ + K = │ 0 f cy │ + └ 0 0 1 ┘ + +The focal length in pixels comes from EXIF: + + f_pixels = f_35mm × image_width / 36 + +where *f*₃₅ₘₘ is `FocalLengthIn35mmFilm` and 36 mm is the 35 mm frame width. + +**Homography decomposition.** The homography mapping a unit square to the image quad +factors as **H = K [r₁ r₂ t]**, so **M = K⁻¹H = [r₁ r₂ t]**. For a unit-square +input, **r₁** and **r₂** are the width and height directions in camera space. Since +rotation columns are orthonormal, a square requires |**r₁**| = |**r₂**|; for a +rectangle of aspect *a* = W/H the columns scale differently, giving: + + a = W/H = |r₁| / |r₂| + +**Orthogonality check.** The decomposition assumes no lens distortion. To catch +cases where that fails (common with phone cameras), Rectify checks + + cos θ = |(r₁ · r₂) / (|r₁| |r₂|)| + +and treats the estimate as unreliable (not applied automatically) when cos θ > +0.05. The user can still set the ratio by hand. + +**Quality preservation.** Applying the correction preserves the longer output +dimension and stretches the shorter one — upscaling the dimension with less +information rather than discarding pixels. + +**References:** Z. Zhang, *IEEE TPAMI*, vol. 22, no. 11, pp. 1330–1334, 2000; +A. Criminisi, I. Reid, A. Zisserman, *IJCV*, vol. 40, no. 2, pp. 123–148, 2000. + +### 4.8 Scoring and strategy selection + +**Geometric quality score.** `_quality_score` rates a quad without regard to area +(so large detections aren't penalized): + + quality = 0.35 × angle_score + 0.35 × margin_score + 0.30 × persp_score + +(With `USE_PERSPECTIVE_SCORE = False`: `0.5 × angle + 0.5 × margin`.) + +- **Angle score** — rectangularity: `max(0, 1 − avg_deviation / 30°)`, where + `avg_deviation` is the mean absolute difference of each interior angle from 90°. +- **Margin score** — distance from the image edges: 0 if any corner is within 2 + pixels of an edge (it has grabbed the boundary rather than the subject), else + `min(min_margin / (0.05 × min_dim), 1.0)`. +- **Perspective plausibility** — `max(0, 1 − |cos(angle)| / 0.3)`, where the cosine + is between the two rotation-matrix columns recovered from homography + decomposition (assuming a 50 mm focal length). A true projected rectangle gives + orthogonal columns (cos = 0, score 1.0); an implausible quad gives |cos| ≥ 0.3 + (score 0.0). This rejects accidental 4-vertex contours with good angles and + margin that no perspective could have produced. + +A separate full composite score, `_score_quad`, additionally weights area (ideal +15–70% of the image) and is used where absolute quality matters. + +**The two-pass sweep.** `evaluate_strategies` runs a coarse pass over both +strategies at sensitivities 0.0–1.0 in steps of 0.05 (21 × 2 = 42 runs), then a +fine pass within ±0.05 of the coarse winner at steps of 0.01 (~10 more), finding +the optimum at 0.01 granularity while staying under ~1 second. Saturation scores +are damped on low-saturation images: + + sat_confidence = clamp((sat_std − 10) / 40, 0.3, 1.0) + adjusted_score = quality × sat_confidence + +An image with a pure black border (found by scanning the 1-pixel perimeter) skips +saturation entirely and relaxes border rejection. + +**Final selection (`_select_best`)** prunes, then breaks ties by area: + +1. **Quality threshold** — keep candidates whose quality is at least + `max(best − SELECT_QUALITY_TOLERANCE, best × 0.75)` — within 0.01 of the maximum + (or, on a low-confidence image, within 25%). The tight tolerance stops a + clearly-worse quad from winning the area tiebreak. +2. **Peel area floor** (peel only) — when `prefer_larger=False` and more than one + candidate remains, drop any below `PEEL_AREA_FLOOR × max_good_area` (default + 0.90). This blocks outliers whose edge cuts through the subject far inside the + parent; the practical effect is that one peel finds a tighter inner boundary but + not a dramatically smaller one (deeper structure needs more peels). +3. **Area tiebreak** — `prefer_larger=True` (initial) picks the largest region; + `prefer_larger=False` (peel) picks the smallest above the floor. + +Returns `(strategy_name, best_sensitivity, corners)`. The sweep/selection constants +(`SENSITIVITY_COARSE_STEP`, `SENSITIVITY_FINE_STEP`, `SENSITIVITY_FINE_RANGE`, +`SELECT_QUALITY_TOLERANCE`, `PEEL_AREA_FLOOR`) live at the top of `detect.py`. + +### 4.9 Coordinate mapping through the peel stack + +The peel stack is a list of `(image_bgr, corners)` tuples — index 0 is the original +and its detected corners, index 1 is the rectified image of index 0 and *its* +corners, and so on. **Each layer lives in its own coordinate space; there is no +mapping between layers.** Peeling in rectifies the current image through its +corners, pushes the new image, and re-detects within it with `prefer_larger=False`. +Peeling out simply pops the stack (instant). + +Two places do need coordinate mapping, both by composing perspective transforms: + +**Drawing the overlay.** To show the innermost region on the original-image left +panel, the deepest corners are mapped *backward* through each level: + + For k = n−1 … 0: + dst_rect = compute_output_size_corrected(...) # aspect-corrected at level 0 + Hk_inv = getPerspectiveTransform(dst_rect, corners_k) + points = perspectiveTransform(points, Hk_inv) + +**Editing at depth > 0.** A point dragged in original-image coordinates is mapped +*forward* through all levels to update the deepest corners: + + For k = 0 … n−1: + Hk = getPerspectiveTransform(corners_k, dst_rect) + point = perspectiveTransform(point, Hk) + +### 4.10 Rectified-space nudging + +All nudges (corner, edge, whole-rectangle) operate in the *rectified output's* +coordinate system, so "move the top edge up" is unambiguous regardless of the +quad's orientation in the photo. The mechanism: read the corners in image space → +compute the transform to the output rectangle → apply the per-corner deltas in that +axis-aligned space → map back with the inverse transform. The three modes: + +- **Corner** — only the selected corner's delta is non-zero. +- **Edge** — in rectified space edges are axis-aligned: edges 0/2 (top/bottom) move + vertically for perpendicular arrows and shift horizontally for parallel ones; + edges 1/3 (right/left) the reverse. +- **Whole-rectangle** — all four corners move symmetrically; up/right expands + (+1), down/left shrinks (−1), each corner's delta pointing away from the centroid + (TL: −,−; TR: +,−; BR: +,+; BL: −,+). The active region is the inner 50% of the + quad; a click outside it but inside the quad selects edge mode instead. + +### 4.11 Detection padding + +Before detection, images are padded by replicating edge pixels outward: + + padded = cv2.copyMakeBorder(image, pad, pad, pad, pad, BORDER_REPLICATE) + +with `pad = 100` (`DETECTION_PADDING`). Corner coordinates are shifted back by +`pad` afterward. This gives subjects near the image boundary a comfortable margin +for the edge detector and the margin score; without it, such detections would be +penalized or missed. **Padding is applied only to the original image** (initial +detection and depth 0); on rectified peel results it is skipped, because +replicating the edge pixels of a cropped painting would create false boundaries. + +### 4.12 The transform pipeline + +Key functions in `transform.py`: + +- **`compute_output_size(corners)`** — the natural rectangle size (max of each + opposite-side pair; see [§4.6](#46-the-perspective-transform)). +- **`is_valid_quad(corners)`** — rejects degenerate quads: any two corners within 1 + pixel, output width/height under 4 pixels, or contour area under 16 px². +- **`rectify(image, corners)`** — validates, sizes the output, builds destination + points `[0,0], [w-1,0], [w-1,h-1], [0,h-1]`, then + `getPerspectiveTransform` + `warpPerspective`. +- **`rectify_full_image(image, corners, aspect_ratio, crop, fill_color)`** — + applies the same homography to the *whole* image: compute H, transform the four + image corners to find the output bounding box, compose a translation so all + coordinates are non-negative, cap dimensions at 16384 px, warp with a constant + fill, and optionally crop to the largest inscribed rectangle. +- **`_warp_full_image(image, H, crop, fill_color)`** — the shared helper for the + full-image and keystone paths (bounding box, translation, 16384 cap, optional + inscribed crop). +- **`_crop_inscribed_rect(...)`** — finds the largest axis-aligned rectangle inside + the warped (convex) quadrilateral, sweeping vertex-aligned y-values and computing + left/right boundaries per scanline. +- **`keystone_homography(line_pairs, w, h)`** — makes line pairs parallel by mapping + their vanishing point(s) to infinity. One pair: the minimum-norm solution sending + one vanishing point to infinity. Two pairs: the vanishing line through both + points is mapped to the line at infinity (affine rectification). A roll correction + then makes verticals truly vertical and horizontals truly horizontal. Computed in + centered coordinates (image midpoint) for symmetric distortion. +- **`keystone_correct(image, line_pairs, crop, fill_color, scale_x=1.0)`** — computes the + keystone homography and applies it via `_warp_full_image()`. + +### 4.13 GUI architecture internals + +**Class hierarchy.** `RectifyMainWindow(QMainWindow)` owns all state, builds the UI, +and handles signals. `SourcePanel(ImagePanel)` is the left panel (image + quad +overlay); `ImagePanel(QGraphicsView)` is the base zoom/pan display; +`CornerHandle(QGraphicsEllipseItem)` is a draggable, clamped corner; +`ArrowHandle(QGraphicsPolygonItem)` is a keystone-line endpoint. + +**Coordinate system.** `QGraphicsView` transforms automatically between view +(screen) and scene (image) space; corner handles live in scene coordinates, which +are image pixels. The `ItemIgnoresTransformations` flag keeps handles a constant +on-screen size at any zoom. + +**Undo/redo.** The undo stack stores copies of the 4×2 `corners` array (negligible +memory). An entry is pushed when a drag *begins*, not per pixel of motion. +Undo/redo are per-peel-level and cleared on peel in/out. + +**Cursor contexts.** The left panel uses `_find_element_at` to pick a cursor, with +corner proximity (`CORNER_SELECT_RADIUS`, in screen pixels converted to scene units +so the hit area is zoom-independent) taking priority over edges. Hover: crosshair on +a corner, four-arrow cross on an edge, open hand in the center or for panning, +plain arrow when the image fits. Active (button or Shift held): closed hand. + +**Zoom anchoring.** Zoom anchors under the cursor when it is over the image +(`AnchorUnderMouse`) and centers the image when the cursor is in the gray margin +(`AnchorViewCenter`). The scene rect is expanded at load so anchoring works from the +first tick; scroll bars are disabled (panning is by dragging). + +**Settings persistence.** `_save_settings()` (from `closeEvent`) writes JSON; +`_load_settings()` (from the constructor) restores it. The path is +`QStandardPaths.AppDataLocation` with `organizationName` set to the OS username. +Image/corner restoration is deferred to `showEvent` (`_restore_saved_image`) so the +window is laid out first; it rebuilds the peel stack by re-rectifying each level +from the saved corners. A `_transform_corners_edited` flag prevents floating-point +drift when carrying corners back from Transform to Extract. + +**Before/after.** Holding Space sets `_space_held`, and `_draw_result()` shows the +original instead of the result; auto-repeat is ignored to avoid flicker. + +### 4.14 Color-theory primer + +This primer explains the ideas the color tools rest on +([§2.12](#212-color-correction), [§3.4](#34-what-the-color-modes-actually-do)), +assuming no prior color science. + +**The color cast, and the illuminant.** A camera records the color of the *light* +multiplied by the color of the *surface*. Photograph a white wall under a tungsten +bulb and it comes out orange; under shade it comes out blue. That tint is the +**color cast** of the **illuminant** (the light). To recover the subject's true +color you have to divide the light's color back out. + +**Neutral references tell you the cast.** A *neutral* surface — one that reflects +all wavelengths roughly equally, i.e. some shade of gray or white — has no color of +its own, so whatever color it shows *is* the light. If you photograph a neutral +patch beside your subject and measure it, you have measured the cast directly. That +is why you place a gray card or a white sheet in the shot. + +**White balance (the von Kries method).** Once you know the cast, removing it is +simple: scale each color channel (red, green, blue) by whatever factor makes the +neutral patch read equal across channels. After that division the patch is truly +gray, and — assuming the light was the same color everywhere — so is everything +else's color relationship. This per-channel scaling is the classic *von Kries* +model of color adaptation, and it is exactly what Rectify computes +([§3.4](#34-what-the-color-modes-actually-do)). + +**Reflectance, and "middle gray."** *Reflectance* is the fraction of light a +surface returns — 0% is perfect black, 100% perfect white. A photographic **gray +card** is manufactured to a known reflectance, almost always **18%**, the standard +"middle gray." Eighteen percent sounds dark, but human lightness perception is +roughly logarithmic, so 18% *looks* about halfway between black and white. A +**diffuse white** reference (a white card, clean matte paper) sits near **90%** — +not 100%, because real matte surfaces don't reflect everything, and because leaving +headroom above it lets genuine highlights (a specular glint, a lamp) stay brighter. + +**Why the math is done in linear light.** The numbers stored in a JPEG are *sRGB- +encoded*, a deliberately non-linear curve that gives dark tones more code values +(matching perception). Physical light, though, adds linearly, so gains must be +computed in **linear light**: Rectify decodes the sampled patch from sRGB to linear, +computes the gain, applies it, and re-encodes. A consequence worth remembering: +18% reflectance encodes to about **118** on the 0–255 sRGB scale, *not* 128 — "50% +gray" (128) is a common slip that actually corresponds to a much higher reflectance. + +**Coupled vs. decoupled correction — the real choice.** Sampling a neutral fixes +the *color*; the open question is what to do with *brightness*: + +- **Coupled** (Rectify's **Gray card** and **90% White** modes): map the neutral to + a *target reflectance*. Because the target is the same on all three channels, one + operation both neutralizes the cast and sets the exposure (the patch lands at the + target level). Use this when your reference has a known reflectance — you get + correct color *and* a calibrated exposure in one click. Gray card lets you dial + the target (your card's rating, default 18%); 90% White fixes it at the + diffuse-white standard. +- **Decoupled** (Rectify's **Neutral gray** mode): neutralize the cast but leave the + patch at the brightness the camera actually captured, with a *separate* brightness + control. Use this when you only want the cast gone and don't want to force an + exposure — it preserves highlight headroom (nothing is driven to pure white). + +**Why three modes, and why 90% White is locked.** The genuine axis is coupled vs. +decoupled, not "gray vs. white." Gray card and 90% White are the *same* coupled +engine at different targets; what keeps 90% White from being merely "Gray card at +90%" is that the Gray card slider caps at 80%, so the two ranges don't overlap and +the diffuse-white anchor has its own one-click mode. Gray reflectances vary (cards +come in many ratings, so that target earns a slider); diffuse white is a single +standard (so it is a fixed, knob-free target). Making 90% White adjustable would +just duplicate Gray card. + +**Honest limits.** This corrects the *illuminant*, faithfully to the file's +encoding — but it cannot undo the white balance and tone curve a camera already +baked into a JPEG or HEIC (those formats are *display-referred*: already finished +pictures, not raw sensor data), and a single global gain assumes the light's color +is uniform across the frame. For a painting lit evenly by one source it works very +well; under mixed lighting it corrects the cast on average. + +**Reference:** J. von Kries, "Die Gesichtsempfindungen," in *Handbuch der +Physiologie des Menschen*, 1905 (the per-channel adaptation model underlying +white balance). + +### 4.15 References + +- J. Canny, "A computational approach to edge detection," *IEEE Transactions on + Pattern Analysis and Machine Intelligence*, vol. 8, no. 6, pp. 679–698, 1986. +- U. Ramer, "An iterative procedure for the polygonal approximation of plane + curves," *Computer Graphics and Image Processing*, vol. 1, no. 3, pp. 244–256, + 1972. +- D. H. Douglas and T. K. Peucker, "Algorithms for the reduction of the number of + points required to represent a digitized line or its caricature," + *Cartographica*, vol. 10, no. 2, pp. 112–122, 1973. +- R. Hartley and A. Zisserman, *Multiple View Geometry in Computer Vision*, 2nd + ed., Cambridge University Press, 2004. +- Z. Zhang, "A flexible new technique for camera calibration," *IEEE Transactions + on Pattern Analysis and Machine Intelligence*, vol. 22, no. 11, pp. 1330–1334, + 2000. +- A. Criminisi, I. Reid, and A. Zisserman, "Single view metrology," + *International Journal of Computer Vision*, vol. 40, no. 2, pp. 123–148, 2000. +- J. von Kries, "Die Gesichtsempfindungen," in *Handbuch der Physiologie des + Menschen*, vol. 3, 1905. + + + diff --git a/rectify.spec b/rectify.spec new file mode 100644 index 0000000..a8cfaf2 --- /dev/null +++ b/rectify.spec @@ -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', + ], + }, + ], + }, + ) diff --git a/rectify/__init__.py b/rectify/__init__.py new file mode 100644 index 0000000..4827a42 --- /dev/null +++ b/rectify/__init__.py @@ -0,0 +1,3 @@ +"""Rectify — perspective correction for paintings and rectangular objects.""" + +__version__ = "0.1.0" diff --git a/rectify/__main__.py b/rectify/__main__.py new file mode 100644 index 0000000..89c7aec --- /dev/null +++ b/rectify/__main__.py @@ -0,0 +1,5 @@ +"""Entry point for `python -m rectify`.""" + +from rectify.cli import main + +main() diff --git a/rectify/cli.py b/rectify/cli.py new file mode 100644 index 0000000..f6550dc --- /dev/null +++ b/rectify/cli.py @@ -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}") diff --git a/rectify/debug.py b/rectify/debug.py new file mode 100644 index 0000000..d7bc4d5 --- /dev/null +++ b/rectify/debug.py @@ -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})") diff --git a/rectify/detect.py b/rectify/detect.py new file mode 100644 index 0000000..bc43638 --- /dev/null +++ b/rectify/detect.py @@ -0,0 +1,1303 @@ +"""Quadrilateral detection for paintings and rectangular objects. + +Detection strategies: +- **grayscale**: Canny edge detection on the luminance channel. + Works well when there is a clear brightness difference between the + subject and its background (e.g., framed painting on a colored wall). +- **saturation**: Canny edge detection on the HSV saturation channel. + Works well when brightness is similar but color richness differs + (e.g., ceramic tiles on a brick wall). +- **auto** (default): tries grayscale first, rejects results that merely + trace the image border, then falls back to saturation. + +Each strategy uses multi-pass fallbacks internally (morphological close, +progressively lower thresholds). +""" + +import math + +import cv2 +import numpy as np + +from rectify.utils import to_grayscale +from rectify.debug import dbg, dbg_section, dbg_corners, is_debug_enabled + + +# Valid strategy names +STRATEGIES = ("auto", "grayscale", "saturation") + +# Padding added around images before detection. Extends edge pixels +# outward so that subjects near the image boundary are detected reliably +# without being penalized by the margin score. +DETECTION_PADDING = 100 # pixels + +# Sensitivity sweep for evaluate_strategies(). +# Pass 1: coarse sweep from 0.0 to 1.0 at SENSITIVITY_COARSE_STEP. +# Pass 2: fine sweep around the best coarse result, ±SENSITIVITY_FINE_RANGE, +# at SENSITIVITY_FINE_STEP. +SENSITIVITY_COARSE_STEP = 0.05 +SENSITIVITY_FINE_STEP = 0.01 +SENSITIVITY_FINE_RANGE = 0.05 + +# _select_best treats candidates within this absolute quality of the +# best as "tied" and breaks the tie by area (larger or smaller depending +# on prefer_larger). Anything below this band is considered meaningfully +# worse and excluded — this prevents a slightly-larger but visibly-worse +# quad from winning the area tiebreak when better candidates exist. +SELECT_QUALITY_TOLERANCE = 0.01 + +# In peel mode (prefer_larger=False), a "good" candidate's area must be +# at least this fraction of the largest good candidate's area. Stops +# the smallest-area tiebreak from picking high-quality outliers whose +# bottom (or any side) locks onto a spurious internal feature far inside +# the parent — the IMG_8093 s=1.00 pathology. The constraint allows +# peel to find a slightly-tighter inner boundary but not a dramatically +# smaller one; deeper nested structures should be reached by peeling +# more than once. +PEEL_AREA_FLOOR = 0.90 + +# When True, _quality_score includes a perspective plausibility term +# that penalizes quadrilaterals inconsistent with being a perspective +# projection of a rectangle (based on vanishing-point orthogonality). +# Set to False to disable and revert to the original angle+margin scoring. +USE_PERSPECTIVE_SCORE = True + + +# --------------------------------------------------------------------------- +# Sensitivity mapping +# --------------------------------------------------------------------------- + +def sensitivity_to_params(sensitivity: float) -> dict: + """Map a 0.0–1.0 sensitivity value to detection parameters. + + Higher sensitivity → detects subtler edges (lower Canny thresholds, + smaller minimum area, tighter polygon approximation). + + Returns dict with keys: blur_kernel, canny_low, canny_high, + min_area_ratio, epsilon_ratio. + """ + s = max(0.0, min(1.0, sensitivity)) + + # Blur kernel: 7 at low sensitivity, 3 at high (less smoothing = more detail) + blur_k = int(round(7 - 4 * s)) + if blur_k % 2 == 0: + blur_k += 1 # must be odd + + # Canny thresholds: high sensitivity → lower thresholds + canny_low = int(round(70 - 55 * s)) # 70 → 15 + canny_high = int(round(180 - 100 * s)) # 180 → 80 + + # Minimum contour area as fraction of image area + # This rejects small noise contours; paintings are typically 5-50% of image + min_area_ratio = 0.05 - 0.04 * s # 0.05 → 0.01 + + # Polygon approximation epsilon as fraction of perimeter + epsilon_ratio = 0.04 - 0.025 * s # 0.04 → 0.015 + + return { + "blur_kernel": blur_k, + "canny_low": canny_low, + "canny_high": canny_high, + "min_area_ratio": min_area_ratio, + "epsilon_ratio": epsilon_ratio, + } + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _find_quad_in_edges( + edges: np.ndarray, + image_area: int, + min_area_ratio: float, + epsilon_ratio: float, +) -> np.ndarray | None: + """Search for a 4-sided contour in an edge image. + + Returns the largest quad found as a (4,2) float32 array, or None. + Tries convex hull fallback on all candidates that didn't yield a + direct 4-vertex approximation. + """ + contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_SIMPLE) + + min_area = image_area * min_area_ratio + # Also reject contours that fill nearly the whole image (background) + max_area = image_area * 0.95 + candidates = [ + c for c in contours + if min_area <= cv2.contourArea(c) <= max_area + ] + candidates.sort(key=cv2.contourArea, reverse=True) + + dbg("detect", f" findContours: {len(contours)} total, " + f"{len(candidates)} after area filter " + f"(min={min_area:.0f}, max={max_area:.0f})") + + seen_areas = set() # track which candidates yielded a direct quad + + for i, contour in enumerate(candidates): + peri = cv2.arcLength(contour, True) + approx = cv2.approxPolyDP(contour, epsilon_ratio * peri, True) + area = cv2.contourArea(contour) + dbg("detect", f" candidate[{i}]: area={area:.0f} " + f"({area/image_area*100:.1f}% of image), " + f"vertices={len(approx)} after approxPolyDP(eps={epsilon_ratio:.4f})") + if len(approx) == 4: + result = order_corners(approx.reshape(4, 2).astype(np.float32)) + dbg_corners(" → quad", result, indent=2) + seen_areas.add(round(area)) + return result + + # Fallback: convex hull of each candidate that wasn't already found. + # Contours with >4 vertices often represent valid quads whose edges + # are noisy — the convex hull smooths away the noise and approxPolyDP + # can then reduce to 4 corners. + for i, contour in enumerate(candidates): + area = cv2.contourArea(contour) + if round(area) in seen_areas: + continue + hull = cv2.convexHull(contour) + peri = cv2.arcLength(hull, True) + approx = cv2.approxPolyDP(hull, epsilon_ratio * peri, True) + dbg("detect", f" hull fallback[{i}]: area={area:.0f} " + f"({area/image_area*100:.1f}%), hull_vertices={len(approx)}") + if len(approx) == 4: + result = order_corners(approx.reshape(4, 2).astype(np.float32)) + dbg_corners(" → hull quad", result, indent=2) + return result + elif len(approx) > 4: + dbg("detect", f" hull fallback[{i}]: still {len(approx)} vertices, skipped") + + dbg("detect", " → no quad found in these edges") + return None + + +def _is_nondegenerate(corners: np.ndarray) -> bool: + """Return True if no two corners are within 1px of each other.""" + for i in range(4): + for j in range(i + 1, 4): + if np.linalg.norm(corners[i] - corners[j]) < 1.0: + return False + return True + + +def _has_black_border(image: np.ndarray, threshold: int = 10) -> bool: + """Return True if the 1-pixel border of the image is all near-black. + + Checks pixels along all four edges, short-circuiting as soon as a + non-black pixel is found. For typical photos this returns False + almost immediately. Only examines the first 3 channels (BGR), + ignoring alpha if present. + """ + h, w = image.shape[:2] + # Work with BGR only, ignore alpha if present + if len(image.shape) == 3 and image.shape[2] == 4: + image = image[:, :, :3] + # Check top row + for x in range(w): + if image[0, x].max() > threshold: + return False + # Check bottom row + for x in range(w): + if image[h - 1, x].max() > threshold: + return False + # Check left column (skip corners, already checked) + for y in range(1, h - 1): + if image[y, 0].max() > threshold: + return False + # Check right column + for y in range(1, h - 1): + if image[y, w - 1].max() > threshold: + return False + return True + + +def _is_image_border(corners: np.ndarray, w: int, h: int) -> bool: + """Return True if the quad is just tracing the image edges.""" + margin = min(w, h) * 0.05 + tl_near = corners[0][0] < margin and corners[0][1] < margin + br_near = corners[2][0] > (w - margin) and corners[2][1] > (h - margin) + return tl_near and br_near + + +def _score_quad(corners: np.ndarray, w: int, h: int) -> float: + """Score a detected quadrilateral (higher is better). + + Considers: + - Whether it traces the image border (very bad) + - How close the angles are to 90° (rectangular is good) + - Area ratio — should be a substantial fraction of the image but + not nearly all of it + """ + if _is_image_border(corners, w, h): + return -1.0 + + image_area = w * h + + # Area ratio: ideal is roughly 0.15–0.70 of image area + quad_area = cv2.contourArea(corners) + area_ratio = quad_area / image_area + # Penalize very small (<5%) and very large (>90%) detections + if area_ratio < 0.02: + return 0.0 + area_score = min(area_ratio / 0.15, 1.0) * min((0.90 - area_ratio) / 0.20, 1.0) + area_score = max(0.0, area_score) + + # Rectangularity: measure how close each angle is to 90° + angle_diffs = [] + for i in range(4): + p0 = corners[i] + p1 = corners[(i + 1) % 4] + p2 = corners[(i - 1) % 4] + v1 = p1 - p0 + v2 = p2 - p0 + cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8) + cos_angle = np.clip(cos_angle, -1.0, 1.0) + angle = np.degrees(np.arccos(cos_angle)) + angle_diffs.append(abs(angle - 90.0)) + # Average deviation from 90° — 0 is perfect, 45 is terrible + avg_angle_diff = sum(angle_diffs) / 4 + angle_score = max(0.0, 1.0 - avg_angle_diff / 30.0) + + # Margin from image edges — corners well inside the image are better + min_margin = min( + corners[0][0], corners[0][1], # TL from top-left edge + w - corners[1][0], corners[1][1], # TR from top-right edge + w - corners[2][0], h - corners[2][1], # BR from bottom-right edge + corners[3][0], h - corners[3][1], # BL from bottom-left edge + ) + margin_ratio = min_margin / min(w, h) + margin_score = min(margin_ratio / 0.05, 1.0) # full score if >5% from edge + + return 0.4 * area_score + 0.4 * angle_score + 0.2 * margin_score + + +def _perspective_plausibility(corners: np.ndarray, w: int, h: int) -> float: + """Score how consistent a quad is with being a perspective-projected rectangle. + + Uses homography decomposition to check whether the two vanishing-point + directions are orthogonal (as they must be for a true rectangle). + Returns 1.0 for a perfect perspective projection, dropping toward 0.0 + for quads that cannot be produced by projecting a rectangle. + + Uses a default 50mm focal length — the score is not very sensitive to + this assumption because the orthogonality check is mainly about the + quad's shape, not the exact camera model. + """ + focal_px = 50.0 * w / 36.0 # 50mm equivalent + K = np.array([ + [focal_px, 0, w / 2.0], + [0, focal_px, h / 2.0], + [0, 0, 1], + ], dtype=np.float64) + K_inv = np.linalg.inv(K) + + unit_rect = np.array([[0, 0], [1, 0], [1, 1], [0, 1]], dtype=np.float32) + try: + H = cv2.getPerspectiveTransform(unit_rect, corners.astype(np.float32)) + except cv2.error: + return 0.0 + + 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 0.0 + + # |cos(angle)| = 0 means perfectly orthogonal (ideal rectangle). + # Values above ~0.3 indicate the quad is unlikely to be a rectangle. + cos_angle = abs(np.dot(r1, r2) / (norm_r1 * norm_r2)) + # Map: cos_angle 0.0 → 1.0, cos_angle 0.3 → 0.0 + score = max(0.0, 1.0 - cos_angle / 0.3) + return score + + +def _quality_score(corners: np.ndarray, w: int, h: int, + skip_border_check: bool = False) -> float: + """Score geometric quality only (rectangularity + margin), ignoring area. + + Used by evaluate_strategies to compare candidates on shape quality + without penalizing larger detections. Returns 0.0–1.0. + + If *skip_border_check* is True, don't reject quads near the image + edges (used for black-border images where the content boundary is + expected to be near the edge). + """ + if corners is None: + return -1.0 + if not skip_border_check and _is_image_border(corners, w, h): + dbg("evaluate", f" rejected: traces image border ({w}×{h})") + return -1.0 + + image_area = w * h + quad_area = cv2.contourArea(corners) + area_ratio = quad_area / image_area + if area_ratio < 0.02 or area_ratio > 0.95: + dbg("evaluate", f" rejected: area ratio {area_ratio:.3f} outside [0.02, 0.95]") + return -1.0 + + # Rectangularity + angle_diffs = [] + for i in range(4): + p0 = corners[i] + p1 = corners[(i + 1) % 4] + p2 = corners[(i - 1) % 4] + v1 = p1 - p0 + v2 = p2 - p0 + cos_angle = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-8) + cos_angle = np.clip(cos_angle, -1.0, 1.0) + angle = np.degrees(np.arccos(cos_angle)) + angle_diffs.append(abs(angle - 90.0)) + avg_angle_diff = sum(angle_diffs) / 4 + angle_score = max(0.0, 1.0 - avg_angle_diff / 30.0) + + # Margin from edges — heavily penalize corners that touch the image edge, + # since these often indicate the detection grabbed the image boundary + # rather than the actual subject. + min_margin = min( + corners[0][0], corners[0][1], + w - corners[1][0], corners[1][1], + w - corners[2][0], h - corners[2][1], + corners[3][0], h - corners[3][1], + ) + if not skip_border_check and min_margin < 2: + margin_score = 0.0 # corner on the image edge + else: + # Full score at 2% of the smaller dimension (e.g., 31px for 1536px). + # This is generous enough for tightly-framed photos while still + # penalizing detections that hug the image boundary. + margin_score = min((min_margin / min(w, h)) / 0.02, 1.0) + + if USE_PERSPECTIVE_SCORE: + persp_score = _perspective_plausibility(corners, w, h) + score = 0.35 * angle_score + 0.35 * margin_score + 0.30 * persp_score + dbg("evaluate", f" quality: angle_score={angle_score:.3f} " + f"(avg_diff={avg_angle_diff:.1f}°) " + f"margin_score={margin_score:.3f} (min_margin={min_margin:.1f}px) " + f"persp_score={persp_score:.3f} " + f"area_ratio={area_ratio:.3f} → score={score:.3f}") + else: + score = 0.5 * angle_score + 0.5 * margin_score + dbg("evaluate", f" quality: angle_score={angle_score:.3f} " + f"(avg_diff={avg_angle_diff:.1f}°) " + f"margin_score={margin_score:.3f} (min_margin={min_margin:.1f}px) " + f"area_ratio={area_ratio:.3f} → score={score:.3f}") + return score + + +def evaluate_strategies( + image: np.ndarray, + *, + prefer_larger: bool = True, + pad_image: bool = True, +) -> tuple[str, float, np.ndarray | None]: + """Try both strategies at multiple sensitivities, return the best. + + Two-pass sensitivity sweep: a coarse pass (step SENSITIVITY_COARSE_STEP) + followed by a fine pass (step SENSITIVITY_FINE_STEP) around the best + coarse result (±SENSITIVITY_FINE_RANGE). Returns the best combination. + + Parameters + ---------- + prefer_larger : bool + If True (default, for initial detection), among candidates with + similar geometric quality the largest region wins — capturing more + context so the user can peel inward. If False (for peel-in), + prefer the *smallest* good candidate — the most meaningful inner + boundary that skips past frames/borders. + + Returns (strategy_name, best_sensitivity, corners) where corners may + be None if nothing was found. + """ + h, w = image.shape[:2] + dbg_section(f"evaluate_strategies: image {w}×{h}, " + f"prefer_larger={prefer_larger}, pad_image={pad_image}") + + # Images with a pure black border (e.g., screen snapshots) have no + # useful saturation edges at the boundary — force grayscale only. + black_border = _has_black_border(image) + dbg("evaluate", f"black_border={black_border}") + + # Pad the image by extending edge pixels outward. This gives subjects + # near the image boundary a comfortable margin for detection, avoiding + # false penalization by the margin score. Padding is skipped for + # peel-in images (rectified results) where the subject fills the frame + # and replicating edge pixels would create false boundaries. + if pad_image: + pad = DETECTION_PADDING + padded = cv2.copyMakeBorder(image, pad, pad, pad, pad, cv2.BORDER_REPLICATE) + else: + pad = 0 + padded = image + ph, pw = padded.shape[:2] + dbg("evaluate", f"padded size: {pw}×{ph} (pad={pad})") + + # Compute saturation confidence on the original (padding doesn't change it) + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + sat_std = hsv[:, :, 1].std() + sat_confidence = min(1.0, max(0.3, (sat_std - 10) / 40)) + dbg("evaluate", f"saturation std={sat_std:.1f}, confidence={sat_confidence:.3f}") + + # Collect all candidates, detecting on the padded image + candidates = [] + padded_area = ph * pw + + def unpad_corners(corners): + """Shift corners from padded coordinates back to original.""" + return corners - np.array([pad, pad], dtype=np.float32) + + def sweep(sensitivities, label): + """Run detection at each sensitivity, appending to candidates.""" + dbg("evaluate", f"--- {label}: {len(sensitivities)} levels " + f"({sensitivities[0]:.2f}–{sensitivities[-1]:.2f})") + for s in sensitivities: + p = sensitivity_to_params(s) + dbg("evaluate", f"--- sensitivity {s:.2f}: blur={p['blur_kernel']} " + f"canny=({p['canny_low']},{p['canny_high']}) " + f"min_area={p['min_area_ratio']:.3f} eps={p['epsilon_ratio']:.4f}") + + dbg("detect", f" grayscale @ s={s:.2f}:") + gray_result = _detect_grayscale( + padded, p["blur_kernel"], p["canny_low"], p["canny_high"], + p["min_area_ratio"], p["epsilon_ratio"], + ) + if gray_result is not None: + dbg("evaluate", f" grayscale: quad found") + if not _is_nondegenerate(gray_result): + dbg("evaluate", f" grayscale: degenerate quad (corners too close)") + else: + orig_corners = unpad_corners(gray_result) + quality = _quality_score(gray_result, pw, ph, skip_border_check=black_border) + if quality > 0: + area = cv2.contourArea(orig_corners) / (w * h) + candidates.append((quality, area, "grayscale", s, orig_corners)) + dbg("evaluate", f" grayscale: ACCEPTED quality={quality:.3f} area={area:.3f}") + else: + dbg("evaluate", f" grayscale: rejected (quality={quality:.3f})") + else: + dbg("evaluate", f" grayscale: no quad found") + + if not black_border: + dbg("detect", f" saturation @ s={s:.2f}:") + sat_result = _detect_saturation( + padded, p["blur_kernel"], p["min_area_ratio"], p["epsilon_ratio"], + ) + if sat_result is not None: + dbg("evaluate", f" saturation: quad found") + if not _is_nondegenerate(sat_result): + dbg("evaluate", f" saturation: degenerate quad") + else: + orig_corners = unpad_corners(sat_result) + quality = _quality_score(sat_result, pw, ph) * sat_confidence + if quality > 0: + area = cv2.contourArea(orig_corners) / (w * h) + candidates.append((quality, area, "saturation", s, orig_corners)) + dbg("evaluate", f" saturation: ACCEPTED quality={quality:.3f} " + f"(×{sat_confidence:.3f} conf) area={area:.3f}") + else: + dbg("evaluate", f" saturation: rejected (quality={quality:.3f})") + else: + dbg("evaluate", f" saturation: no quad found") + + # Pass 1: coarse sweep + n_coarse = round(1.0 / SENSITIVITY_COARSE_STEP) + 1 + coarse_levels = [i * SENSITIVITY_COARSE_STEP for i in range(n_coarse)] + sweep(coarse_levels, "coarse pass") + + # Select the best coarse candidate to center the fine pass around + coarse_best = _select_best(candidates, prefer_larger) + + if coarse_best is not None: + _, _, _, coarse_sens, _ = coarse_best + + # Pass 2: fine sweep around the coarse winner + fine_lo = max(0.0, coarse_sens - SENSITIVITY_FINE_RANGE) + fine_hi = min(1.0, coarse_sens + SENSITIVITY_FINE_RANGE) + n_fine = round((fine_hi - fine_lo) / SENSITIVITY_FINE_STEP) + 1 + fine_levels = [fine_lo + i * SENSITIVITY_FINE_STEP for i in range(n_fine)] + # Skip levels already tested in the coarse pass + coarse_set = set(round(s, 4) for s in coarse_levels) + fine_levels = [s for s in fine_levels if round(s, 4) not in coarse_set] + if fine_levels: + sweep(fine_levels, f"fine pass around s={coarse_sens:.2f}") + + # Final selection across all candidates from both passes + dbg_section(f"evaluate_strategies: candidate selection " + f"({len(candidates)} candidates)") + + if not candidates: + dbg("evaluate", "NO candidates found — returning None") + return "grayscale", 0.5, None + + # Log all candidates sorted by quality + for i, (q, a, strat, sens, corners) in enumerate( + sorted(candidates, key=lambda c: c[0], reverse=True)): + dbg_corners(f"candidate[{i}] {strat} s={sens:.2f} " + f"quality={q:.3f} area={a:.3f}", corners, indent=2) + + result = _select_best(candidates, prefer_larger) + _, _, best_strategy, best_sensitivity, best_corners = result + dbg("evaluate", f"SELECTED: {best_strategy} s={best_sensitivity:.2f}") + dbg_corners("selected corners", best_corners) + + return best_strategy, best_sensitivity, best_corners + + +def _select_best(candidates, prefer_larger): + """Pick the best candidate based on quality threshold and area preference. + + Returns the winning (quality, area, strategy, sensitivity, corners) tuple, + or None if candidates is empty. + """ + if not candidates: + return None + + best_quality = max(c[0] for c in candidates) + # Tight absolute tolerance keeps near-tied candidates eligible for the + # area tiebreak; the 75 % relative floor preserves the original + # behavior on low-confidence images where best_quality itself is small. + threshold = max(best_quality - SELECT_QUALITY_TOLERANCE, + best_quality * 0.75) + good = [c for c in candidates if c[0] >= threshold] + dbg("evaluate", f"best_quality={best_quality:.3f}, " + f"threshold={threshold:.3f} " + f"(max-{SELECT_QUALITY_TOLERANCE:.2f} or 75 %), " + f"{len(good)} candidates above threshold") + + # Peel-mode area floor: reject candidates whose area is far below the + # largest good area. See PEEL_AREA_FLOOR docstring for rationale. + if not prefer_larger and len(good) > 1: + max_good_area = max(c[1] for c in good) + area_floor = max_good_area * PEEL_AREA_FLOOR + before = len(good) + good = [c for c in good if c[1] >= area_floor] + if len(good) < before: + dbg("evaluate", f"peel area floor: max_good_area={max_good_area:.3f}, " + f"floor={area_floor:.3f} ({PEEL_AREA_FLOOR:.0%}), " + f"{before - len(good)} candidates dropped, {len(good)} remain") + + if prefer_larger: + good.sort(key=lambda c: c[1], reverse=True) + dbg("evaluate", "sorting by LARGEST area") + else: + good.sort(key=lambda c: c[1]) + dbg("evaluate", "sorting by SMALLEST area") + + for i, (q, a, strat, sens, corners) in enumerate(good[:5]): + dbg("evaluate", f" top[{i}]: {strat} s={sens:.2f} " + f"quality={q:.3f} area={a:.3f}") + + return good[0] + + +def _multipass_canny( + blurred: np.ndarray, + image_area: int, + canny_low: int, + canny_high: int, + min_area_ratio: float, + epsilon_ratio: float, +) -> np.ndarray | None: + """Try multiple Canny/morphology combinations on a blurred single-channel image. + + Returns the first quad found, or None. + """ + morph_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3)) + + # Pass 1: Canny + dilate + dbg("detect", f" pass 1: Canny({canny_low},{canny_high}) + dilate") + edges = cv2.Canny(blurred, canny_low, canny_high) + edges = cv2.dilate(edges, morph_kernel, iterations=1) + result = _find_quad_in_edges(edges, image_area, min_area_ratio, epsilon_ratio) + if result is not None: + return result + + # Pass 2: Canny + morphological close (connects nearby edges) + dbg("detect", f" pass 2: Canny({canny_low},{canny_high}) + morph close 7×7") + edges = cv2.Canny(blurred, canny_low, canny_high) + close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7)) + edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, close_kernel) + result = _find_quad_in_edges(edges, image_area, min_area_ratio, epsilon_ratio) + if result is not None: + return result + + # Pass 3: progressively lower thresholds + for factor in [0.6, 0.4]: + low = max(5, int(canny_low * factor)) + high = max(20, int(canny_high * factor)) + dbg("detect", f" pass 3: Canny({low},{high}) factor={factor} + dilate×2") + edges = cv2.Canny(blurred, low, high) + edges = cv2.dilate(edges, morph_kernel, iterations=2) + result = _find_quad_in_edges(edges, image_area, min_area_ratio, epsilon_ratio) + if result is not None: + return result + + dbg("detect", " all passes — no quads found") + return None + + +# --------------------------------------------------------------------------- +# Individual strategies +# --------------------------------------------------------------------------- + +def _detect_grayscale( + image: np.ndarray, + blur_kernel: int, + canny_low: int, + canny_high: int, + min_area_ratio: float, + epsilon_ratio: float, +) -> np.ndarray | None: + """Detect a quad using grayscale (luminance) edges.""" + gray = to_grayscale(image) + h, w = gray.shape[:2] + blurred = cv2.GaussianBlur(gray, (blur_kernel, blur_kernel), 0) + return _multipass_canny(blurred, h * w, canny_low, canny_high, + min_area_ratio, epsilon_ratio) + + +def _detect_saturation( + image: np.ndarray, + blur_kernel: int, + min_area_ratio: float, + epsilon_ratio: float, +) -> np.ndarray | None: + """Detect a quad using HSV saturation channel edges. + + Uses fixed Canny thresholds tuned for saturation (which has a + different value range/distribution than luminance). Blur is + forced to 1 (no blur) — saturation edges are inherently smooth + and additional blurring smears away the subtle transitions that + distinguish subject from background. + """ + hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) + sat = hsv[:, :, 1] + h, w = sat.shape[:2] + # No blur for saturation — preserves subtle color-boundary edges + return _multipass_canny(sat, h * w, 20, 60, + min_area_ratio, epsilon_ratio) + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def detect_quad( + image: np.ndarray, + *, + strategy: str = "auto", + sensitivity: float | None = None, + blur_kernel: int = 5, + canny_low: int = 50, + canny_high: int = 150, + min_area_ratio: float = 0.05, + epsilon_ratio: float = 0.02, + pad_image: bool = True, +) -> np.ndarray | None: + """Detect the dominant quadrilateral in an image. + + Parameters + ---------- + strategy : "auto", "grayscale", or "saturation" + Which detection approach to use. "auto" tries grayscale first, + then saturation as a fallback. + sensitivity : float or None + If provided (0.0–1.0), overrides the individual parameters. + blur_kernel, canny_low, canny_high, min_area_ratio, epsilon_ratio : + Fine-grained detection parameters (used when sensitivity is None). + + Returns + ------- + (4, 2) float32 array of corners [TL, TR, BR, BL], or None. + """ + if strategy not in STRATEGIES: + raise ValueError(f"strategy must be one of {STRATEGIES}, got {strategy!r}") + + if sensitivity is not None: + p = sensitivity_to_params(sensitivity) + blur_kernel = p["blur_kernel"] + canny_low = p["canny_low"] + canny_high = p["canny_high"] + min_area_ratio = p["min_area_ratio"] + epsilon_ratio = p["epsilon_ratio"] + + h, w = image.shape[:2] + + # Pad image for detection, then shift corners back + if pad_image: + pad = DETECTION_PADDING + padded = cv2.copyMakeBorder(image, pad, pad, pad, pad, cv2.BORDER_REPLICATE) + else: + pad = 0 + padded = image + + if strategy == "grayscale": + result = _detect_grayscale(padded, blur_kernel, canny_low, canny_high, + min_area_ratio, epsilon_ratio) + if result is not None and pad > 0: + result = result - np.array([pad, pad], dtype=np.float32) + return result + + if strategy == "saturation": + result = _detect_saturation(padded, blur_kernel, min_area_ratio, epsilon_ratio) + if result is not None and pad > 0: + result = result - np.array([pad, pad], dtype=np.float32) + return result + + # --- auto mode: evaluate both and pick the better one --- + _, _, result = evaluate_strategies(image, pad_image=pad_image) + return result + + +# --------------------------------------------------------------------------- +# Corner ordering +# --------------------------------------------------------------------------- + +def order_corners(pts: np.ndarray) -> np.ndarray: + """Order 4 points as: top-left, top-right, bottom-right, bottom-left. + + Uses the sum (x+y) and difference (y-x) heuristic: + - top-left has the smallest sum + - bottom-right has the largest sum + - top-right has the smallest difference + - bottom-left has the largest difference + """ + ordered = np.zeros((4, 2), dtype=np.float32) + s = pts.sum(axis=1) + d = np.diff(pts, axis=1).flatten() + + ordered[0] = pts[np.argmin(s)] # top-left + ordered[2] = pts[np.argmax(s)] # bottom-right + ordered[1] = pts[np.argmin(d)] # top-right + ordered[3] = pts[np.argmax(d)] # bottom-left + + return ordered + + +# --------------------------------------------------------------------------- +# Keystone line detection +# --------------------------------------------------------------------------- + +def edge_debug_overlay( + image: np.ndarray, + blur_kernel: int = 5, + canny_low: int = 50, + canny_high: int = 150, +) -> np.ndarray: + """Combine the source image with its Canny edges for debugging. + + Mirrors the grayscale detection preprocessing: Gaussian blur with + *blur_kernel*, then Canny with the given thresholds. Returns a + BGR image where detected edges are red and non-edge pixels show + the original image. + """ + gray = to_grayscale(image) + k = max(1, int(blur_kernel)) + if k % 2 == 0: + k += 1 + if k > 1: + gray = cv2.GaussianBlur(gray, (k, k), 0) + edges = cv2.Canny(gray, int(canny_low), int(canny_high)) + overlay = image.copy() + overlay[edges > 0] = (0, 0, 255) # BGR red + return overlay + + +# Line detection tuning +KEYSTONE_MAX_ANGLE = 20.0 # degrees from target axis +KEYSTONE_MARGIN = 0.05 # reject lines in the outer fraction of the image +KEYSTONE_TOP_K = 7 # top-K candidates per half for arbiter pair search +KEYSTONE_VETO_RATIO = 5.0 # arbiter pair only overrides greedy if its alignment + # score is at least this multiple of the greedy pair's; + # smaller margins indicate the arbiter is gaming the + # alignment metric on essentially-equivalent pairs. +KEYSTONE_MAX_DIM = 1200 # downscale images wider than this for detection speed +KEYSTONE_TRIM_TOL = 2.0 # perpendicular px to consider an edge "on" the line +KEYSTONE_TRIM_MAX_GAP = 10 # max along-line gap (px) within a contiguous run +KEYSTONE_SCORE_BINS = 20 # spatial bins for arbiter alignment score +KEYSTONE_AXIS_BONUS = 0.5 # per-candidate bonus multiplier at exact axis-aligned +KEYSTONE_AXIS_SIGMA = 2.0 # degrees; controls how quickly the axis bonus decays +KEYSTONE_SCORE_MAX_DIM = 600 # downsample corrected image to this max dim before + # scoring with Hough — alignment metric is relative, + # so full resolution isn't needed and the K×K arbiter + # loop becomes much faster. + +# Triage and bilateral refinement (see _refine_with_bilateral below). +KEYSTONE_DENSITY_TRIGGER = 1.5 # Canny edges per line-pixel above which the + # baseline line is presumed to sit in a textured + # region and we re-run detection on a bilateral- + # filtered edge map as a refinement source. +KEYSTONE_BILAT_D = 9 # bilateral kernel diameter +KEYSTONE_BILAT_SIGMA = 50 # bilateral filter sigma (color + space) +KEYSTONE_REFINE_MIN_SPAN_FRAC = 0.40 # per-line span (perp. axis) for refinement + # to consider a line safe to swap in +KEYSTONE_REFINE_MARGIN_FRAC = 0.08 # per-line margin (parallel axis) for safety + + +def detect_keystone_lines( + image: np.ndarray, +) -> tuple[np.ndarray | None, np.ndarray | None]: + """Detect the best vertical and horizontal line pairs for keystone correction. + + Returns (vertical_pair, horizontal_pair) where each is a (4, 2) + float32 array [line1_start, line1_end, line2_start, line2_end], + or None if no suitable lines were found. + + Uses an "arbiter" search: within each half of the image, collects the + top-K line candidates by length × cos²(angle_from_axis); then enumerates + the K×K pair combinations, applies the keystone correction for each, and + picks the pair whose correction produces the most axis-aligned edges + (measured by a Hough re-scan of the corrected image). This directly + optimizes for correction quality instead of relying on per-line heuristics. + + If a baseline line's local Canny-edge density (a proxy for "this line is + through textured wall, not on a single architectural seam") exceeds + ``KEYSTONE_DENSITY_TRIGGER``, the function re-runs detection on a + bilateral-filtered edge map and picks per-line between baseline and + bilateral candidates by which combination's correction has higher axis + alignment — provided the swapped-in line passes per-line safety (length + span and image-margin). This adds a second detection pass only when + needed; clean baseline detections are unaffected. + """ + h_full, w_full = image.shape[:2] + scale = min(1.0, KEYSTONE_MAX_DIM / max(h_full, w_full)) + if scale < 1.0: + work = cv2.resize( + image, + (int(round(w_full * scale)), int(round(h_full * scale))), + interpolation=cv2.INTER_AREA, + ) + else: + work = image + + gray = to_grayscale(work) + h, w = gray.shape[:2] + edges = cv2.Canny(gray, 50, 150) + + vpair = _detect_pair(edges, w, h, vertical=True, image=work) + hpair = _detect_pair(edges, w, h, vertical=False, image=work) + + # Triage: if either baseline line sits in a high-edge-density region, + # refine by re-running detection on a bilateral-filtered edge map. + vpair = _refine_with_bilateral(vpair, work, gray, edges, vertical=True) + hpair = _refine_with_bilateral(hpair, work, gray, edges, vertical=False) + + # Rescale detected coords back to full-image space + if scale < 1.0: + inv = 1.0 / scale + if vpair is not None: + vpair = (vpair * inv).astype(np.float32) + if hpair is not None: + hpair = (hpair * inv).astype(np.float32) + + return vpair, hpair + + +def _line_edge_density( + p1: np.ndarray, p2: np.ndarray, edges: np.ndarray +) -> float: + """Mean Canny-edge-pixel count per along-line pixel within KEYSTONE_TRIM_TOL. + + A clean architectural seam scores near 1 (one edge per pixel along the + line); textured regions like decorative brickwork score well above 1 + because off-seam edges from the pattern fall within the perpendicular + tolerance and are counted too. Used as the triage signal that decides + whether to invoke bilateral-filtered refinement. + """ + d = p2 - p1 + L = float(np.linalg.norm(d)) + if L < 1e-6: + return 0.0 + dn = d / L + n = np.array([-dn[1], dn[0]], dtype=np.float32) + ys, xs = np.nonzero(edges) + if len(xs) == 0: + return 0.0 + pts = np.column_stack([xs, ys]).astype(np.float32) + rel = pts - p1 + t = rel @ dn + s = np.abs(rel @ n) + mask = (s <= KEYSTONE_TRIM_TOL) & (t >= 0) & (t <= L) + return float(mask.sum()) / L + + +def _line_safe_for_swap( + p1: np.ndarray, p2: np.ndarray, w: int, h: int, vertical: bool +) -> bool: + """Per-line safety check for swapping a refinement line into the baseline. + + Rejects lines whose perpendicular span is below + ``KEYSTONE_REFINE_MIN_SPAN_FRAC`` of the image extent, or whose parallel + coordinate lies within ``KEYSTONE_REFINE_MARGIN_FRAC`` of the image + edge — both are signs that the refinement candidate is an artifact (a + short line at the image boundary) rather than a genuine architectural + feature. + """ + extent = h if vertical else w + side = w if vertical else h + coord = 0 if vertical else 1 + span = abs(p1[1] - p2[1]) if vertical else abs(p1[0] - p2[0]) + if span < KEYSTONE_REFINE_MIN_SPAN_FRAC * extent: + return False + m, M = min(p1[coord], p2[coord]), max(p1[coord], p2[coord]) + margin = KEYSTONE_REFINE_MARGIN_FRAC * side + if m < margin or M > side - margin: + return False + return True + + +def _refine_with_bilateral( + baseline_pair: np.ndarray | None, + work: np.ndarray, + gray: np.ndarray, + edges_baseline: np.ndarray, + vertical: bool, +) -> np.ndarray | None: + """If baseline lines sit in textured regions, refine via bilateral-filtered + candidates. Returns the (possibly improved) pair in work-image coords. + + Triage: compute baseline per-line edge densities. If the max is below + ``KEYSTONE_DENSITY_TRIGGER``, return baseline unchanged. Otherwise run + detection on bilaterally-filtered edges and try every (L, R) combination + of safe candidates from the two sources; pick the one whose correction + has the highest axis-alignment score (the same metric the arbiter uses). + """ + if baseline_pair is None: + return None + L_dens = _line_edge_density(baseline_pair[0], baseline_pair[1], edges_baseline) + R_dens = _line_edge_density(baseline_pair[2], baseline_pair[3], edges_baseline) + if max(L_dens, R_dens) < KEYSTONE_DENSITY_TRIGGER: + return baseline_pair + + gray_f = cv2.bilateralFilter( + gray, KEYSTONE_BILAT_D, KEYSTONE_BILAT_SIGMA, KEYSTONE_BILAT_SIGMA, + ) + edges_f = cv2.Canny(gray_f, 50, 150) + h, w = gray.shape[:2] + filt_pair = _detect_pair(edges_f, w, h, vertical=vertical, image=work) + if filt_pair is None: + return baseline_pair + + bL_ok = _line_safe_for_swap(baseline_pair[0], baseline_pair[1], w, h, vertical) + bR_ok = _line_safe_for_swap(baseline_pair[2], baseline_pair[3], w, h, vertical) + fL_ok = _line_safe_for_swap(filt_pair[0], filt_pair[1], w, h, vertical) + fR_ok = _line_safe_for_swap(filt_pair[2], filt_pair[3], w, h, vertical) + L_opts, R_opts = [], [] + if bL_ok: L_opts.append((baseline_pair[0], baseline_pair[1])) + if fL_ok: L_opts.append((filt_pair[0], filt_pair[1])) + if bR_ok: R_opts.append((baseline_pair[2], baseline_pair[3])) + if fR_ok: R_opts.append((filt_pair[2], filt_pair[3])) + if not L_opts or not R_opts: + return baseline_pair + + from rectify.transform import keystone_correct + def alignment_of(pair: np.ndarray) -> float: + corr = keystone_correct(work, [pair], crop=False, fill_color=(0, 0, 0)) + return _axis_alignment_score(corr, vertical) if corr is not None else 0.0 + + best_pair = baseline_pair + best_align = alignment_of(baseline_pair) + for lp1, lp2 in L_opts: + for rp1, rp2 in R_opts: + pair = np.array([lp1, lp2, rp1, rp2], dtype=np.float32) + al = alignment_of(pair) + if al > best_align: + best_align, best_pair = al, pair + return best_pair + + +def _detect_pair( + edges: np.ndarray, + w: int, + h: int, + vertical: bool, + image: np.ndarray, +) -> np.ndarray | None: + """Find the best line pair via arbiter search.""" + if vertical: + mid = w // 2 + half1 = edges[:, :mid] + half2 = edges[:, mid:] + offset2 = (mid, 0) + else: + mid = h // 2 + half1 = edges[:mid, :] + half2 = edges[mid:, :] + offset2 = (0, mid) + + cands1 = _candidate_lines(half1, w, h, vertical) + cands2 = _candidate_lines(half2, w, h, vertical, offset=offset2) + if not cands1 or not cands2: + return None + + # Import here to avoid a circular import at module load time. + from rectify.transform import keystone_correct + + # Greedy baseline: top-1 candidate from each half. We compute its + # alignment score so the arbiter winner can be measured against it. + greedy = (cands1[0], cands2[0]) + g_pair = np.array([greedy[0][1], greedy[0][2], + greedy[1][1], greedy[1][2]], dtype=np.float32) + g_corr = keystone_correct(image, [g_pair], crop=False, fill_color=(0, 0, 0)) + greedy_align = _axis_alignment_score(g_corr, vertical) if g_corr is not None else 0.0 + + # Arbiter search across the top-K candidates per half. + k = min(KEYSTONE_TOP_K, len(cands1), len(cands2)) + best_align = greedy_align + best_pair = greedy + for c1 in cands1[:k]: + for c2 in cands2[:k]: + pair = np.array([c1[1], c1[2], c2[1], c2[2]], dtype=np.float32) + corrected = keystone_correct( + image, [pair], crop=False, fill_color=(0, 0, 0), + ) + if corrected is None: + continue + align = _axis_alignment_score(corrected, vertical) + if align > best_align: + best_align = align + best_pair = (c1, c2) + + # Veto: only use the arbiter winner if it's substantially better than + # the greedy baseline. When the margin is small the arbiter is + # typically picking a pair that games the alignment metric (extra + # spatial spread or rotation-distorted homography) rather than one + # that's genuinely a more truthful pair. Falling back to greedy + # preserves the long top-ranked candidate, which is usually a real + # architectural reference line. + if greedy_align > 0 and best_align < KEYSTONE_VETO_RATIO * greedy_align: + best_pair = greedy + + c1, c2 = best_pair + return np.array([c1[1], c1[2], c2[1], c2[2]], dtype=np.float32) + + +def _trim_line_to_edges( + p1: np.ndarray, + p2: np.ndarray, + edge_pts: np.ndarray, + tol: float = KEYSTONE_TRIM_TOL, + max_gap: float = KEYSTONE_TRIM_MAX_GAP, +) -> tuple[np.ndarray, np.ndarray]: + """Shorten a segment to its longest contiguous edge support. + + HoughLinesP with a non-zero maxLineGap returns segments that span + gaps in the underlying edges, and a sufficiently scattered set of + collinear edges from unrelated features can produce a "phantom" + segment whose pixels are mostly empty. This helper: + + 1. Finds edge pixels within *tol* perpendicular distance of the + infinite line through p1-p2 whose projection lies in [0, L]. + 2. Sorts those projections and walks them, breaking into runs + wherever the along-line gap exceeds *max_gap*. + 3. Returns the endpoints of the longest run, so the trimmed + segment is backed by genuinely contiguous edge support. + + If no edge pixels qualify, the original segment is returned. The + segment is only shortened, never extended. + """ + d = p2 - p1 + L = float(np.linalg.norm(d)) + if L < 1e-6 or edge_pts is None or len(edge_pts) == 0: + return p1, p2 + d = d / L + n = np.array([-d[1], d[0]], dtype=np.float32) + rel = edge_pts - p1 + t = rel @ d + s = np.abs(rel @ n) + mask = (s <= tol) & (t >= 0.0) & (t <= L) + if not np.any(mask): + return p1, p2 + t_vals = np.sort(t[mask]) + + # Find the longest run with consecutive gaps <= max_gap + gaps = np.diff(t_vals) + breaks = np.where(gaps > max_gap)[0] + starts = np.concatenate(([0], breaks + 1)) + ends = np.concatenate((breaks, [len(t_vals) - 1])) + lengths = t_vals[ends] - t_vals[starts] + best = int(np.argmax(lengths)) + t_min = float(t_vals[starts[best]]) + t_max = float(t_vals[ends[best]]) + if t_max - t_min < 1e-6: + return p1, p2 + new_p1 = (p1 + t_min * d).astype(np.float32) + new_p2 = (p1 + t_max * d).astype(np.float32) + return new_p1, new_p2 + + +def _candidate_lines( + edge_half: np.ndarray, + img_w: int, + img_h: int, + vertical: bool, + offset: tuple[int, int] = (0, 0), +) -> list[tuple[float, np.ndarray, np.ndarray]]: + """Scored line candidates: [(score, p1, p2), ...] sorted by score desc.""" + min_length = int(0.10 * (img_h if vertical else img_w)) + lines = cv2.HoughLinesP( + edge_half, + rho=1, + theta=np.pi / 180, + threshold=50, + minLineLength=min_length, + maxLineGap=20, + ) + if lines is None: + return [] + + # Edge pixel positions in half-image coordinates (trim happens before + # we add the offset, so coordinates match edge_half directly). + ys_edge, xs_edge = np.nonzero(edge_half) + edge_pts = (np.column_stack([xs_edge, ys_edge]).astype(np.float32) + if len(xs_edge) else None) + + ox, oy = offset + margin = KEYSTONE_MARGIN + out: list[tuple[float, np.ndarray, np.ndarray]] = [] + + for line in lines: + x1, y1, x2, y2 = line[0] + p1 = np.array([x1, y1], dtype=np.float32) + p2 = np.array([x2, y2], dtype=np.float32) + p1, p2 = _trim_line_to_edges(p1, p2, edge_pts) + x1, y1 = float(p1[0]) + ox, float(p1[1]) + oy + x2, y2 = float(p2[0]) + ox, float(p2[1]) + oy + + if vertical: + if x1 < img_w * margin or x1 > img_w * (1 - margin): + continue + if x2 < img_w * margin or x2 > img_w * (1 - margin): + continue + else: + if y1 < img_h * margin or y1 > img_h * (1 - margin): + continue + if y2 < img_h * margin or y2 > img_h * (1 - margin): + continue + + dx = abs(x2 - x1) + dy = abs(y2 - y1) + length = math.hypot(dx, dy) + # Re-apply minLineLength after trimming: HoughLinesP enforces it + # on the original (possibly gap-spanning) segment, but the trimmed + # contiguous run can be much shorter. + if length < min_length: + continue + + if vertical: + angle = math.degrees(math.atan2(dx, dy)) + else: + angle = math.degrees(math.atan2(dy, dx)) + if angle > KEYSTONE_MAX_ANGLE: + continue + + alignment = math.cos(math.radians(angle)) ** 2 + # Bonus for near-perfect-axis lines. Without this, a slightly-tilted + # long line (e.g. an 8°, length-824 phantom diagonal) outscores a + # shorter perfect-axis line (length-686 cornice at 0°), since length + # dominates a small cos² penalty. Real architectural references are + # usually at exactly 0° (or as close as detection noise permits), so + # we bump those. Decay is sharp enough (~σ=2°) that 8°+ candidates + # are essentially unaffected, preserving the arbiter's ability to + # reach for tilted-but-real lines when needed. + axis_bonus = 1.0 + KEYSTONE_AXIS_BONUS * math.exp( + -((angle / KEYSTONE_AXIS_SIGMA) ** 2) + ) + score = length * alignment * axis_bonus + out.append(( + score, + np.array([x1, y1], dtype=np.float32), + np.array([x2, y2], dtype=np.float32), + )) + + out.sort(key=lambda t: t[0], reverse=True) + return out + + +def _axis_alignment_score(image: np.ndarray, vertical: bool, tol_deg: float = 3.0) -> float: + """Spatially-binned alignment score for Hough segments within ±tol_deg. + + The arbiter calls this on a keystone-corrected image rendered with a + black fill (``fill_color=(0,0,0)``). The boundary between content + and fill produces strong, near-axis-aligned edges whose geometry + varies with the specific homography — counting them lets the + border bias the score away from the pair that best aligns actual + content. We build a mask of non-fill pixels, erode it by a few + pixels so the content/border seam itself is excluded, and zero the + Canny output outside the mask before running Hough. + + A plain ``sum(length × cos²)`` is gameable: a warp that locally + rotates many short densely-textured edges to near-axis-aligned can + outweigh a globally-distributed set of real architectural verticals + (see IMG_7153, where one cluster of brick texture in the corrected + image dominated the score and made the arbiter pick a tilted + correction). This version partitions the corrected image into + ``KEYSTONE_SCORE_BINS`` bins along the perpendicular axis, sums the + per-line contribution per bin, and returns ``sum(sqrt(per_bin))``. + Concavity penalizes concentration: one bin contributing N gives √N, + while N bins contributing 1 each give N × 1 = N. + """ + # Downsample for arbiter speed. The corrected image can be much + # larger than the input (up to 16384 px per side per transform.py's + # cap), and the K×K arbiter calls Canny + HoughLinesP on each + # candidate. At full resolution this dominates detect_keystone_lines' + # runtime. The score is purely relative across candidates, so + # operating on a thumbnail preserves the comparison. + h_in, w_in = image.shape[:2] + scale = min(1.0, KEYSTONE_SCORE_MAX_DIM / max(h_in, w_in)) + if scale < 1.0: + scored = cv2.resize( + image, + (max(1, int(round(w_in * scale))), max(1, int(round(h_in * scale)))), + interpolation=cv2.INTER_AREA, + ) + else: + scored = image + gray = to_grayscale(scored) + edges = cv2.Canny(gray, 50, 150) + # Non-fill mask: any non-black pixel is content. Eroded so the + # content/border boundary edges don't survive. + if scored.ndim == 3: + content = (scored.sum(axis=2) > 0).astype(np.uint8) + else: + content = (scored > 0).astype(np.uint8) + content = cv2.erode(content, np.ones((7, 7), np.uint8)) + edges = edges * content + h, w = gray.shape[:2] + min_len = int(0.05 * (h if vertical else w)) + lines = cv2.HoughLinesP( + edges, rho=1, theta=np.pi / 180, + threshold=50, minLineLength=min_len, maxLineGap=20, + ) + if lines is None: + return 0.0 + + bins = np.zeros(KEYSTONE_SCORE_BINS, dtype=np.float64) + denom = float(w if vertical else h) + for line in lines: + x1, y1, x2, y2 = line[0] + dx, dy = abs(x2 - x1), abs(y2 - y1) + length = math.hypot(dx, dy) + if length < 1e-6: + continue + if vertical: + angle = math.degrees(math.atan2(dx, dy)) + mid_perp = (x1 + x2) * 0.5 + else: + angle = math.degrees(math.atan2(dy, dx)) + mid_perp = (y1 + y2) * 0.5 + if angle > tol_deg: + continue + contrib = length * math.cos(math.radians(angle)) ** 2 + idx = int(mid_perp / denom * KEYSTONE_SCORE_BINS) + if idx < 0: + idx = 0 + elif idx >= KEYSTONE_SCORE_BINS: + idx = KEYSTONE_SCORE_BINS - 1 + bins[idx] += contrib + return float(np.sum(np.sqrt(bins))) + + diff --git a/rectify/gui.py b/rectify/gui.py new file mode 100644 index 0000000..3dd7141 --- /dev/null +++ b/rectify/gui.py @@ -0,0 +1,6631 @@ +"""PySide6 GUI for interactive perspective correction. + +Layout: + ┌─ Action bar ─────────────────────────────────────────────────────┐ + │ Open Re-detect Save-as │ Depth Peel± │ + ├───────────────────┬──────────────────────────────────────────────┤ + │ Current image + │ Rectified result │ + │ detected quad │ │ + │ (zoom/pan) │ (zoom/pan) │ + ├───────────────────┴───────────────────────────┤ + │ Strategy | Sensitivity | Advanced ▸ │ + ├───────────────────────────────────────────────┤ + │ Status bar │ + └───────────────────────────────────────────────┘ + +Features: +- QGraphicsView panels with zoom/pan +- Draggable corner handles with arrow key fine adjustment +- Peel stack for successive inner region detection +- Undo/redo for corner adjustments +- Drag and drop image files +- Keyboard shortcuts +- Before/after comparison (hold Space) +""" + +import math +import os +import sys +from contextlib import contextmanager + +import cv2 +import numpy as np + +from PySide6.QtCore import Qt, QPointF, QRectF, Signal, QEvent, QTimer, QLocale +from PySide6.QtGui import ( + QAction, QKeySequence, QPen, QBrush, QColor, QDragEnterEvent, + QDropEvent, QWheelEvent, QKeyEvent, QShortcut, QIcon, QPolygonF, + QPainter, +) +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QGraphicsView, QGraphicsScene, + QGraphicsPixmapItem, QGraphicsEllipseItem, QGraphicsLineItem, + QGraphicsPolygonItem, QGraphicsRectItem, + QWidget, QHBoxLayout, QVBoxLayout, QGridLayout, QSplitter, QToolBar, + QComboBox, QSlider, QLabel, QPushButton, + QDoubleSpinBox, QFileDialog, QMessageBox, QStatusBar, + QSizePolicy, QStyle, QRadioButton, QColorDialog, QButtonGroup, + QCheckBox, QFrame, +) + +from rectify.utils import load_image, save_image, bgr_to_qpixmap, resize_for_display, read_focal_length_35mm +from rectify.detect import detect_quad, evaluate_strategies, detect_keystone_lines, edge_debug_overlay +from rectify.transform import rectify, rectify_full_image, keystone_correct, estimate_aspect_ratio, compute_output_size_corrected, apply_bow_correction, sample_patch_bgr, patch_square, gray_correction_gains, white_correction_gains, apply_gray_correction, GRAY_REFLECTANCE_DEFAULT +from rectify.debug import dbg, dbg_section, dbg_corners +from rectify.shortcuts import SECTIONS as SHORTCUT_SECTIONS, key_label, SHOW_SHORTCUTS_KEYS + + +CORNER_RADIUS = 6 # scene units for corner handle radius +CORNER_HIT_RADIUS = 12 # larger hit area for easier clicking +CORNER_SELECT_RADIUS = 20 # pixel radius for selecting a corner (mouse proximity) +LINE_GRAB_DISTANCE = 12 # pixel distance for grabbing a keystone line +_IS_MAC = sys.platform == "darwin" # for the few per-platform layout tweaks + +# Parameter-row vertical layout strategy (macOS tuning). When True, the text +# (labels, radio/checkbox text, value boxes) is pinned to a common bottom line +# while sliders/swatches float centered — the "bottom-align" experiment, kept +# behind this flag and in _apply_bottom_align()/the SliderSpinBox branch. When +# False, every element is simply vertically centered (Qt's native behavior). +_BOTTOM_ALIGN = False + +ZOOM_FACTOR = 1.15 +ZOOM_MIN = 0.1 +ZOOM_MAX = 10.0 +# Scroll-to-zoom sensitivity for precise input devices (Magic Mouse, trackpad). +# A traditional wheel notch is 120 angle-units and zooms by exactly ZOOM_FACTOR; +# precise devices stream many small high-resolution deltas per gesture, so their +# contribution is damped by this factor to approximate the one-notch-per-step +# feel of a wheel. Tunable by feel. +PRECISE_ZOOM_SENSITIVITY = 1.00 +PANEL_MARGIN = 16 # pixels of background visible around the image + + +def _wheel_zoom_factor(event: QWheelEvent) -> float: + """Multiplicative zoom factor for a wheel/scroll event (1.0 = no change). + + Zoom is proportional to scroll distance — a 120-unit notch is one + ZOOM_FACTOR step — so a traditional mouse wheel keeps its familiar feel. + Precise devices (Magic Mouse / trackpad) emit a flood of small deltas, so + theirs is scaled by PRECISE_ZOOM_SENSITIVITY. Inertial momentum events (the + coast after the fingers lift) are ignored, so zoom stops when you do. + """ + if event.phase() == Qt.ScrollMomentum: # ignore the inertia tail + return 1.0 + delta = event.angleDelta().y() + if delta == 0: + return 1.0 + steps = delta / 120.0 + if not event.pixelDelta().isNull(): # precise device (Magic Mouse / trackpad) + steps *= PRECISE_ZOOM_SENSITIVITY + return ZOOM_FACTOR ** steps +PANEL_BG_COLOR = "#808080" # 50% gray +CONTROL_MARGIN = 8 # pixels of padding around toolbar and control panels +DEFAULT_FONT_SCALE = 1.25 # 150% of system default + +# Stylesheet for text-bearing checkboxes — sets the gap between the +# label text and the indicator to 6 px, matching the spacing used by +# the Action/Method radio-group sub-containers between their "Label:" +# QLabel and the first radio button. This keeps a consistent +# label-to-graphic gap whether the graphic is a radio button or a +# checkbox indicator. +CHECKBOX_TIGHT_STYLE = "QCheckBox { spacing: 6px; }" + +# Inactive-value visual: light gray + italic, distinct from Qt's default +# disabled gray. Used on (a) a value widget whose toggle is off (the +# aspect-ratio spin when its checkbox is unchecked), (b) a no-op value +# (the bow spin at 0.00), and (c) a disabled QPushButton (Peel in / Peel +# out / Save as / Save when their preconditions aren't met). The +# selector form is used on buttons so Qt drives the on/off transition +# from `setEnabled` automatically; the plain form is applied imperatively +# to spin widgets whose "inactive" state is condition-based (a value or +# a separate checkbox), not a Qt enabled flag. +INACTIVE_SPIN_STYLE = "color: gray; font-style: italic;" +# Red: the recovered value exceeds what the control can represent, so it's +# pinned at the slider's limit and the true correction isn't fully reachable. +CLAMPED_SPIN_STYLE = "color: red;" +INACTIVE_BUTTON_STYLE = ( + "QPushButton:disabled { color: gray; font-style: italic; }" +) + +# Bow correction is presented to the user as a radial *distance in output +# pixels* and converted to the curvature coefficient k that +# `apply_bow_correction` expects. The remap displaces a pixel at +# normalized radius u by r_max * k * u^2 * (1 - u); this peaks at u = 2/3 +# with value r_max * k * (4/27). So the peak radial displacement in pixels +# is d = k * r_max * BOW_PEAK_FRAC, giving the conversion +# k = d / (r_max * BOW_PEAK_FRAC) +# where r_max is the output image's half-diagonal. +BOW_PEAK_FRAC = 4.0 / 27.0 +# Slider half-range in pixels (so the control runs -BOW_PX_RANGE..+BOW_PX_RANGE). +BOW_PX_RANGE = 200 +# Safe bound on the derived k: the remap stays monotonic for roughly +# -1 <= k <= 3, so clamp symmetrically well inside that on small outputs +# where a large pixel value would otherwise push k out of range. +BOW_K_LIMIT = 1.0 + +# Image formats Rectify writes (OpenCV imwrite). SAVE_EXTENSIONS are the +# canonical types shown in the toolbar "Ext" selector; SAVE_EXTENSIONS_ACCEPTED +# adds the typed aliases the Save-as dialog also recognizes; SAVE_EXT_CANON maps +# an alias to its canonical Ext-selector value. +SAVE_EXTENSIONS = ("png", "jpg", "tiff", "webp", "bmp") +SAVE_EXTENSIONS_ACCEPTED = ("png", "jpg", "jpeg", "tiff", "tif", "webp", "bmp") +SAVE_EXT_CANON = {"jpeg": "jpg", "tif": "tiff"} + +GREEN_PEN = QPen(QColor("#00ff00"), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) +GREEN_PEN.setCosmetic(True) # constant width regardless of zoom +YELLOW_PEN = QPen(QColor("#ffff00"), 3, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) +YELLOW_PEN.setCosmetic(True) +BLUE_PEN = QPen(QColor("#4488ff"), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) +BLUE_PEN.setCosmetic(True) +RED_PEN = QPen(QColor("#ff3030"), 2, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin) +RED_PEN.setCosmetic(True) +RED_PEN_DASHED = QPen(QColor("#ff3030"), 2, Qt.DashLine, Qt.RoundCap, Qt.RoundJoin) +RED_PEN_DASHED.setCosmetic(True) +GREEN_BRUSH = QBrush(QColor("#00cc00")) +YELLOW_BRUSH = QBrush(QColor("#ddaa00")) +BLUE_BRUSH = QBrush(QColor("#4488ff")) +RED_BRUSH = QBrush(QColor("#ff3030")) +# Outline for the gray-card sample marker on the source panel: bright red, +# 2 image-pixels wide and NON-cosmetic, so it's a true 2-pixel ring in +# image space that scales with zoom (matching the radius×radius sample it +# surrounds — e.g. radius 1 is a single pixel inside a 2 px ring, 5×5 +# overall). Drawn just outside the sampled square so the sampled pixels +# sit inside the ring, not under it. +GRAY_MARKER_PEN = QPen(QColor("#ff0000"), 2, Qt.SolidLine, Qt.SquareCap, Qt.MiterJoin) + +# Default side length (in image pixels) of the gray-card sample square: a +# radius-N control averages an N×N block (N=1 is a single pixel). +GRAY_RADIUS_DEFAULT = 10 + +# Debounce interval (ms) for slider/spinbox/wheel adjustments. While a +# value is still changing, the expensive recompute (warp + correction, or +# peel-stack rebuild) is deferred; it runs only after this much quiet time, +# so rolling the wheel or dragging a slider on a large image doesn't fire a +# recompute on every increment — only once the value has settled. +SLIDER_RECOMPUTE_DEBOUNCE_MS = 500 + +# Delay (ms) before running the initial image load/detection scheduled from +# showEvent. The load is a synchronous, blocking decode + detection sweep on +# the main thread; running it at singleShot(0) — the very first event-loop +# iteration — blocks during the window in which macOS promotes a freshly +# launched (terminal-started) app to the foreground, leaving the app unable to +# become active (window shows but can't take focus or even close). A short +# delay lets the event loop process activation first, then load. Only affects +# the at-launch load; interactive Open is unaffected. +INITIAL_LOAD_DELAY_MS = 200 + +# Author-attribution label (lower-right status bar) font size, as a factor of +# the surrounding UI font — a small, subtle corner credit. +ATTRIBUTION_FONT_FACTOR = 0.75 + + +@contextmanager +def _busy_cursor(): + """Show Qt.WaitCursor for the duration of the block. + + Used around slow operations (keystone-line detection with bilateral + refinement, two-pass sensitivity sweep, peel-in) so the user gets + visual confirmation that processing is underway. ``processEvents`` + flushes the pending paint queue so the cursor change is visible + before the slow work starts; the ``finally`` clause guarantees the + cursor is restored even if the work raises. + """ + QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.processEvents() + try: + yield + finally: + QApplication.restoreOverrideCursor() + + +# ────────────────────────────────────────────────────────────────── +# Internationalization +# ────────────────────────────────────────────────────────────────── + +LANGUAGES = { + "en": { + "open": "Open", + "save": "Save", + "reset_detect": "Reset", + "reopen": "Re-open", + "peel_in": "Peel in", + "peel_out": "Peel out", + "images": "Images", + "all_files": "All files", + "undo": "Undo", + "redo": "Redo", + "depth": "Depth", + "aspect_ratio": "Aspect ratio", + "aspect_tip": ( + "Target width/height of the rectangle.\n" + "A gray (italic) value means no action is needed from you: the " + "correction is off, or a value was recovered automatically from the " + "photo's camera data.\n" + "A normal (black) value means this image has no usable camera data, " + "so the software cannot recover the ratio — adjust the slider until " + "the proportions look right." + ), + "bow": "Bow", + "shortcuts_hint_full": "To see the keyboard shortcuts, hold {keys}", + "shortcuts_hint_short": "Shortcuts: {keys}", + "stretch": "Stretch", + "stretch_tip": ( + "Adjusts the width-to-height relationship of the corrected image.\n" + "Lines mode straightens the converging lines but cannot recover how " + "wide the result should be relative to its height — drag this until " + "the proportions look right (squares look square, circles round).\n" + "1.00 leaves the width unchanged; above 1.00 widens, below narrows." + ), + "save_as": "Save as", + "save_next": "Save", + "save": "Save", + "increment": "Increment", + "increment_tip": ( + "Save mode. When checked, Save writes the next auto-numbered file " + "(name_1, name_2, …) into the last-used folder with one click — no " + "dialog. When unchecked, Save opens a dialog so you can set the " + "filename, type, and folder." + ), + "directory": "Directory", + "prefix": "Prefix", + "extension": "Ext", + "last_saved": "Last saved", + "browse": "...", + "select_directory": "Select output directory", + "open_image": "Open image", + "save_rectified": "Save rectified image", + "no_quad": "No quadrilateral detected", + "too_small": "Region too small to rectify", + "no_result": "No result", + "source_placeholder": "Source image", + "result_placeholder": "Result", + "nothing_to_save": "Nothing to save", + "nothing_to_save_detail": "Open an image and detect a quadrilateral first.", + "cannot_save": "Cannot save", + "cannot_save_detail": "Region too small to rectify.", + "unsupported_type": "Unsupported file type", + "unsupported_type_detail": "“.{ext}” is not a supported image type.\n\nSupported types: {types}", + "peel_too_small": "Region too small to peel further.", + "peel_no_quad": "No quadrilateral detected in the peeled region.", + "open_begin": "Open an image to begin", + "adv_params": "Advanced detection parameters", + "lang": "Language", + "action": "Action", + "extract": "Extract", + "crop": "Crop", + "transform": "Transform", + "input": "Input", + "output": "Output", + "full": "Full", + "fill_color": "Fill", + "method": "Method", + "quad": "Quad", + "lines": "Lines", + "vertical": "Vertical", + "horizontal": "Horizontal", + "color_correct": "Color correct", + "sample_size": "Sample size", + "color_value": "Value", + "gray_reflectance": "Reflectance", + "gray_swatch_tip": "Left-click then click a neutral gray area in the left image; right-click to reuse the last gray", + "gray_no_saved": "No saved reference to apply yet — pick one first", + "gray_status": "Gray", + "color_mode_gray": "Gray card", + "color_mode_white": "Neutral gray", + "color_mode_white90": "90% White", + "color_hint_gray": ("Click on a gray card in the photo.\n" + "Its color is neutralized and its\n" + "exposure is set to the Reflectance\n" + "target (18% is the standard reflectance)."), + "color_hint_white": ("Click on any neutral gray patch\n" + "in the photo. The color cast is\n" + "removed; its brightness is kept\n" + "(tune it with the Brightness slider)."), + "color_hint_white90": ("Click on a 90% white reference\n" + "in the photo. Its color is\n" + "neutralized and its exposure\n" + "is set to 90% reflectance."), + "white_brightness": "Brightness", + "white_status": "White", + # ── Tooltips (label help) ── + "action_tip": ( + "What Rectify does with the detected object.\n" + "Extract: warp just the quadrilateral to a head-on rectangle " + "(the painting alone).\n" + "Transform: correct the whole photo's perspective, keeping the " + "full frame." + ), + "method_tip": ( + "How the perspective is corrected.\n" + "Quad: drag the four corners onto the object; the quad is mapped " + "to a rectangle.\n" + "Lines: straighten converging vertical/horizontal lines " + "(keystone correction) without placing corners." + ), + "vertical_tip": "Lines mode: correct converging vertical lines (frame sides, walls).", + "horizontal_tip": "Lines mode: correct converging horizontal lines (top and bottom edges).", + "crop_tip": ( + "Transform mode: crop the corrected image to the largest rectangle " + "that contains no empty (fill-colored) border." + ), + "fill_color_tip": ( + "Color used to fill areas that fall outside the original photo " + "after a full-image perspective transform." + ), + "bow_tip": ( + "Straightens edges that bow in the extracted result (residual lens " + "distortion). Corners stay fixed.\n" + "The value is a radial distance in output pixels: positive pushes " + "mid-edge content outward to fix inward-bowed (pincushion) edges; " + "negative pulls it inward to fix outward-bowed (barrel) edges.\n" + "0 = off." + ), + "color_correct_tip": ( + "Neutralize a color cast and set exposure from a reference patch " + "you click in the photo." + ), + "color_value_tip": ( + "The color sampled from the photo (shown in the swatch as R,G,B). " + "This is the neutral reference the correction is built from." + ), + "gray_reflectance_tip": ( + "Gray-card mode: target reflectance for the sampled patch. 18% is " + "standard photographic middle gray; raise it for a lighter target." + ), + "white_brightness_tip": ( + "Neutral-gray mode: overall brightness of the corrected image. The " + "color cast is removed; this sets the exposure (0 = no change)." + ), + "sample_size_tip": ( + "Radius (in pixels) of the area averaged around your click when " + "picking a color reference. Larger values average more pixels " + "(less noise)." + ), + "depth_tip": ( + "Peel depth — how many nested quadrilaterals you've peeled into. " + "0 is the outermost detected object; Peel in (+) goes inward, " + "Peel out (−) backs out." + ), + "lang_tip": "Interface language.", + "open_tip": "Open an image to rectify.", + "reset_tip": ( + "Re-run automatic detection on the current image, discarding your " + "manual corner/line edits and parameters." + ), + "save_tip": ( + "Save the rectified image. With Increment on, writes the next " + "auto-numbered file with no dialog; off, opens a Save dialog for " + "name, type, and folder." + ), + "peel_in_tip": ( + "Peel inward: detect the next nested quadrilateral inside the " + "current one (e.g. the painting inside its frame)." + ), + "peel_out_tip": "Peel back outward to the previous (enclosing) quadrilateral.", + "font_smaller_tip": "Make the interface text smaller.", + "font_larger_tip": "Make the interface text larger.", + "shortcuts_title": "Keyboard shortcuts", + "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", + "sk_compare": "Compare original (hold)", + "sk_highlight": "Highlight quad element (hold)", + "sk_adjust_highlighted": "Adjust highlighted element", + "sk_nudge": "Nudge corner / edge / quad", + "sk_zoom": "Zoom in / out", + "sk_reset_zoom": "Reset zoom (both panels)", + "sk_reset_zoom_panel": "Reset zoom (this panel)", + "sk_show_shortcuts": "Show this list (hold)", + "sk_clear_cache": "Clear settings cache", + "sk_edge_debug": "Toggle edge debug overlay", + "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.)", + }, + "de": { + "open": "Öffnen", + "save": "Speichern", + "reset_detect": "Zurücksetzen", + "reopen": "Erneut öffnen", + "peel_in": "Schicht rein", + "peel_out": "Schicht raus", + "images": "Bilder", + "all_files": "Alle Dateien", + "undo": "Rückgängig", + "redo": "Wiederherstellen", + "depth": "Tiefe", + "aspect_ratio": "Seitenverhältnis", + "aspect_tip": ( + "Soll-Breite/Höhe des Rechtecks.\n" + "Ein grauer (kursiver) Wert bedeutet, dass nichts zu tun ist: Die " + "Korrektur ist aus, oder ein Wert wurde automatisch aus den " + "Kameradaten des Fotos ermittelt.\n" + "Ein normaler (schwarzer) Wert bedeutet, dass dieses Bild keine " + "verwertbaren Kameradaten hat, sodass das Verhältnis nicht " + "automatisch ermittelt werden kann — passen Sie den Regler an, bis " + "die Proportionen stimmen." + ), + "bow": "Wölbung", + "shortcuts_hint_full": "Zum Anzeigen der Tastenkürzel {keys} gedrückt halten", + "shortcuts_hint_short": "Tastenkürzel: {keys}", + "stretch": "Dehnung", + "stretch_tip": ( + "Passt das Breiten-zu-Höhen-Verhältnis des korrigierten Bildes an.\n" + "Der Linien-Modus richtet die zusammenlaufenden Linien gerade, kann " + "aber nicht ermitteln, wie breit das Ergebnis im Verhältnis zur Höhe " + "sein soll — ziehen Sie hieran, bis die Proportionen stimmen " + "(Quadrate quadratisch, Kreise rund).\n" + "1,00 lässt die Breite unverändert; über 1,00 verbreitert, darunter " + "verschmälert." + ), + "save_as": "Speichern unter", + "save": "Speichern", + "increment": "Nummerieren", + "increment_tip": ( + "Speichermodus. Wenn aktiviert, speichert „Speichern“ mit einem " + "Klick die nächste automatisch nummerierte Datei (Name_1, Name_2, …) " + "im zuletzt verwendeten Ordner — ohne Dialog. Wenn deaktiviert, " + "öffnet „Speichern“ einen Dialog für Dateiname, Typ und Ordner." + ), + "save_next": "Speichern", + "directory": "Verzeichnis", + "prefix": "Präfix", + "extension": "Erw", + "last_saved": "Zuletzt gespeichert", + "browse": "...", + "select_directory": "Ausgabeverzeichnis wählen", + "open_image": "Bild öffnen", + "save_rectified": "Entzerrtes Bild speichern", + "no_quad": "Kein Viereck erkannt", + "too_small": "Bereich zu klein zum Entzerren", + "no_result": "Kein Ergebnis", + "source_placeholder": "Quellbild", + "result_placeholder": "Ergebnis", + "nothing_to_save": "Nichts zu speichern", + "nothing_to_save_detail": "Öffnen Sie ein Bild und erkennen Sie ein Viereck.", + "cannot_save": "Speichern nicht möglich", + "cannot_save_detail": "Bereich zu klein zum Entzerren.", + "unsupported_type": "Nicht unterstützter Dateityp", + "unsupported_type_detail": "„.{ext}“ ist kein unterstützter Bildtyp.\n\nUnterstützte Typen: {types}", + "peel_too_small": "Bereich zu klein für weitere Schichten.", + "peel_no_quad": "Kein Viereck in der geschälten Region erkannt.", + "open_begin": "Öffnen Sie ein Bild, um zu beginnen", + "adv_params": "Erweiterte Erkennungsparameter", + "lang": "Sprache", + "action": "Aktion", + "extract": "Freistellen", + "crop": "Zuschneiden", + "transform": "Transformieren", + "input": "Eingabe", + "output": "Ausgabe", + "full": "Vollbild", + "fill_color": "Füllung", + "method": "Methode", + "quad": "Viereck", + "lines": "Linien", + "vertical": "Vertikal", + "horizontal": "Horizontal", + "color_correct": "Farbkorrektur", + "sample_size": "Probengröße", + "color_value": "Wert", + "gray_reflectance": "Reflexionsgrad", + "gray_swatch_tip": "Linksklick, dann auf eine neutralgraue Fläche im linken Bild klicken; Rechtsklick übernimmt das zuletzt gewählte Grau", + "gray_no_saved": "Noch keine gespeicherte Referenz vorhanden — zuerst eine wählen", + "gray_status": "Grau", + "color_mode_gray": "Graukarte", + "color_mode_white": "Neutralgrau", + "color_mode_white90": "90% Weiß", + "color_hint_gray": ("Klicken Sie auf eine Graukarte im Foto.\n" + "Ihre Farbe wird neutralisiert und ihre\n" + "Belichtung auf den Reflexionsgrad-Zielwert\n" + "gesetzt (18% ist der Standard-Reflexionsgrad)."), + "color_hint_white": ("Klicken Sie auf eine neutralgraue Stelle\n" + "im Foto. Der Farbstich wird entfernt;\n" + "die Helligkeit bleibt erhalten\n" + "(mit dem Helligkeitsregler anpassen)."), + "color_hint_white90": ("Klicken Sie auf eine 90%-Weißreferenz\n" + "im Foto. Ihre Farbe wird neutralisiert\n" + "und ihre Belichtung auf\n" + "90% Reflexionsgrad gesetzt."), + "white_brightness": "Helligkeit", + "white_status": "Weiß", + # ── Tooltips (Beschriftungshilfe) ── + "action_tip": ( + "Was Rectify mit dem erkannten Objekt macht.\n" + "Freistellen: verzerrt nur das Viereck zu einem frontalen Rechteck " + "(nur das Gemälde).\n" + "Transformieren: korrigiert die Perspektive des ganzen Fotos und " + "behält den vollen Bildausschnitt." + ), + "method_tip": ( + "Wie die Perspektive korrigiert wird.\n" + "Viereck: die vier Ecken auf das Objekt ziehen; das Viereck wird " + "auf ein Rechteck abgebildet.\n" + "Linien: richtet zusammenlaufende senkrechte/waagerechte Linien aus " + "(Stürzendekorrektur), ohne Ecken zu setzen." + ), + "vertical_tip": "Linien-Modus: zusammenlaufende senkrechte Linien korrigieren (Rahmenseiten, Wände).", + "horizontal_tip": "Linien-Modus: zusammenlaufende waagerechte Linien korrigieren (obere und untere Kanten).", + "crop_tip": ( + "Transformieren-Modus: das korrigierte Bild auf das größte Rechteck " + "ohne leeren (gefüllten) Rand zuschneiden." + ), + "fill_color_tip": ( + "Farbe zum Füllen von Bereichen, die nach einer " + "Ganzbild-Perspektivkorrektur außerhalb des ursprünglichen Fotos " + "liegen." + ), + "bow_tip": ( + "Richtet Kanten gerade, die sich im freigestellten Ergebnis wölben " + "(restliche Objektivverzeichnung). Die Ecken bleiben fest.\n" + "Der Wert ist ein radialer Abstand in Ausgabepixeln: positiv drückt " + "die Kantenmitte nach außen und behebt nach innen gewölbte " + "(kissenförmige) Kanten; negativ zieht sie nach innen und behebt " + "nach außen gewölbte (tonnenförmige) Kanten.\n" + "0 = aus." + ), + "color_correct_tip": ( + "Einen Farbstich neutralisieren und die Belichtung anhand eines im " + "Foto angeklickten Referenzfelds festlegen." + ), + "color_value_tip": ( + "Die aus dem Foto entnommene Farbe (im Feld als R,G,B angezeigt). " + "Das ist die neutrale Referenz, auf der die Korrektur beruht." + ), + "gray_reflectance_tip": ( + "Graukarten-Modus: Soll-Reflexionsgrad für das entnommene Feld. " + "18% ist das fotografische Standard-Mittelgrau; erhöhen Sie ihn für " + "ein helleres Ziel." + ), + "white_brightness_tip": ( + "Neutralgrau-Modus: Gesamthelligkeit des korrigierten Bildes. Der " + "Farbstich wird entfernt; dies legt die Belichtung fest " + "(0 = keine Änderung)." + ), + "sample_size_tip": ( + "Radius (in Pixeln) des um Ihren Klick gemittelten Bereichs bei der " + "Wahl einer Farbreferenz. Größere Werte mitteln über mehr Pixel " + "(weniger Rauschen)." + ), + "depth_tip": ( + "Schältiefe — wie viele verschachtelte Vierecke Sie hineingeschält " + "haben. 0 ist das äußerste erkannte Objekt; Schicht rein (+) geht " + "nach innen, Schicht raus (−) nach außen." + ), + "lang_tip": "Sprache der Benutzeroberfläche.", + "open_tip": "Ein Bild zum Entzerren öffnen.", + "reset_tip": ( + "Automatische Erkennung des aktuellen Bildes neu ausführen und " + "Ihre manuellen Ecken-/Linienänderungen und Parameter verwerfen." + ), + "save_tip": ( + "Das entzerrte Bild speichern. Bei aktivem Nummerieren wird ohne " + "Dialog die nächste automatisch nummerierte Datei geschrieben; " + "sonst öffnet sich ein Speichern-Dialog für Name, Typ und Ordner." + ), + "peel_in_tip": ( + "Nach innen schälen: das nächste verschachtelte Viereck im " + "aktuellen erkennen (z. B. das Gemälde innerhalb seines Rahmens)." + ), + "peel_out_tip": "Nach außen zurückschälen zum vorherigen (umschließenden) Viereck.", + "font_smaller_tip": "Die Schrift der Oberfläche verkleinern.", + "font_larger_tip": "Die Schrift der Oberfläche vergrößern.", + "shortcuts_title": "Tastenkürzel", + "sk_sec_file": "Datei", + "sk_sec_edit": "Bearbeiten", + "sk_sec_peel": "Schichten", + "sk_sec_view": "Ansicht", + "sk_sec_quad": "Viereck anpassen", + "sk_sec_dev": "Entwickler", + "sk_sec_help": "Hilfe", + "sk_compare": "Original vergleichen (halten)", + "sk_highlight": "Vierecks-Element hervorheben (halten)", + "sk_adjust_highlighted": "Hervorgehobenes Element anpassen", + "sk_nudge": "Ecke / Kante / Viereck verschieben", + "sk_zoom": "Vergrößern / verkleinern", + "sk_reset_zoom": "Zoom zurücksetzen (beide Panels)", + "sk_reset_zoom_panel": "Zoom zurücksetzen (dieses Panel)", + "sk_show_shortcuts": "Diese Liste anzeigen (halten)", + "sk_clear_cache": "Einstellungs-Cache löschen", + "sk_edge_debug": "Kanten-Debug-Anzeige umschalten", + "sk_next_image": "Nächstes Bild im Verzeichnis laden", + "sk_prev_image": "Vorheriges Bild im Verzeichnis laden", + "sk_snap_nearest": "Nächsten Punkt zum Klick verschieben", + "sk_screenshot": "Fenster-Screenshot in ~/rectify/ speichern", + "sk_annotate_line": "Alternative-Linie zeichnen (rot, kantengeglättet)", + "sk_clear_temp": "Temporäre Markierungen löschen", + }, + "fi": { + "open": "Avaa", + "save": "Tallenna", + "reset_detect": "Palauta", + "reopen": "Avaa uudelleen", + "peel_in": "Kuori sisään", + "peel_out": "Kuori ulos", + "images": "Kuvat", + "all_files": "Kaikki tiedostot", + "undo": "Kumoa", + "redo": "Tee uudelleen", + "depth": "Syvyys", + "aspect_ratio": "Kuvasuhde", + "aspect_tip": ( + "Suorakulmion tavoiteleveys/-korkeus.\n" + "Harmaa (kursivoitu) arvo tarkoittaa, ettei toimenpiteitä tarvita: " + "korjaus on pois päältä tai arvo on määritetty automaattisesti " + "valokuvan kameratiedoista.\n" + "Tavallinen (musta) arvo tarkoittaa, ettei kuvassa ole käyttö" + "kelpoisia kameratietoja, joten ohjelma ei voi määrittää suhdetta — " + "säädä liukusäädintä, kunnes mittasuhteet näyttävät oikeilta." + ), + "bow": "Kaarevuus", + "shortcuts_hint_full": "Näytä näppäinkomennot pitämällä {keys} pohjassa", + "shortcuts_hint_short": "Pikanäppäimet: {keys}", + "stretch": "Venytys", + "stretch_tip": ( + "Säätää korjatun kuvan leveyden ja korkeuden suhdetta.\n" + "Viivatila suoristaa yhteneväiset viivat, mutta ei voi päätellä, " + "kuinka leveä tuloksen tulisi olla korkeuteen nähden — vedä tästä, " + "kunnes mittasuhteet näyttävät oikeilta (neliöt neliöiltä, ympyrät " + "pyöreiltä).\n" + "1,00 säilyttää leveyden ennallaan; yli 1,00 leventää, alle kaventaa." + ), + "save_as": "Tallenna nimellä", + "save": "Tallenna", + "increment": "Numerointi", + "increment_tip": ( + "Tallennustila. Kun valittuna, Tallenna kirjoittaa seuraavan " + "automaattisesti numeroidun tiedoston (nimi_1, nimi_2, …) viimeksi " + "käytettyyn kansioon yhdellä napsautuksella — ei valintaikkunaa. " + "Kun ei valittuna, Tallenna avaa valintaikkunan tiedostonimeä, " + "tyyppiä ja kansiota varten." + ), + "save_next": "Tallenna", + "directory": "Hakemisto", + "prefix": "Etuliite", + "extension": "Pääte", + "last_saved": "Viimeksi tallennettu", + "browse": "...", + "select_directory": "Valitse tulostehakemisto", + "open_image": "Avaa kuva", + "save_rectified": "Tallenna oikaistu kuva", + "no_quad": "Nelikulmaa ei tunnistettu", + "too_small": "Alue liian pieni oikaistavaksi", + "no_result": "Ei tulosta", + "source_placeholder": "Lähdekuva", + "result_placeholder": "Tulos", + "nothing_to_save": "Ei tallennettavaa", + "nothing_to_save_detail": "Avaa kuva ja tunnista nelikulmio ensin.", + "cannot_save": "Tallennus ei onnistu", + "cannot_save_detail": "Alue liian pieni oikaistavaksi.", + "unsupported_type": "Tiedostotyyppiä ei tueta", + "unsupported_type_detail": "”.{ext}” ei ole tuettu kuvatyyppi.\n\nTuetut tyypit: {types}", + "peel_too_small": "Alue liian pieni kuorimiseen.", + "peel_no_quad": "Nelikulmiota ei tunnistettu kuoritussa alueessa.", + "open_begin": "Avaa kuva aloittaaksesi", + "adv_params": "Tunnistuksen lisäparametrit", + "lang": "Kieli", + "action": "Toiminto", + "extract": "Irrota", + "crop": "Rajaa", + "transform": "Muunna", + "input": "Syote", + "output": "Tulos", + "full": "Koko", + "fill_color": "Täyttö", + "method": "Tapa", + "quad": "Nelikulmio", + "lines": "Viivat", + "vertical": "Pystysuora", + "horizontal": "Vaakasuora", + "color_correct": "Värikorjaus", + "sample_size": "Näytteen koko", + "color_value": "Arvo", + "gray_reflectance": "Heijastavuus", + "gray_swatch_tip": "Napsauta vasemmalla ja sitten neutraalia harmaata aluetta vasemmassa kuvassa; oikea napsautus käyttää viimeksi valittua harmaata", + "gray_no_saved": "Ei vielä tallennettua viitettä — valitse ensin", + "gray_status": "Harmaa", + "color_mode_gray": "Harmaakortti", + "color_mode_white": "Neutraali harmaa", + "color_mode_white90": "90% valkoinen", + "color_hint_gray": ("Napsauta harmaakorttia kuvassa.\n" + "Sen väri neutraloidaan ja valotus\n" + "asetetaan heijastavuuden tavoitteeseen\n" + "(18% on vakioheijastavuus)."), + "color_hint_white": ("Napsauta mitä tahansa neutraalia\n" + "harmaata kohtaa kuvassa. Värivirhe\n" + "poistetaan; kirkkaus säilyy\n" + "(säädä kirkkausliukusäätimellä)."), + "color_hint_white90": ("Napsauta 90%-valkoista referenssiä\n" + "kuvassa. Sen väri neutraloidaan\n" + "ja valotus asetetaan\n" + "90% heijastavuuteen."), + "white_brightness": "Kirkkaus", + "white_status": "Valkoinen", + # ── Työkaluvihjeet (otsikko-ohjeet) ── + "action_tip": ( + "Mitä Rectify tekee tunnistetulle kohteelle.\n" + "Irrota: vääristää vain nelikulmion suoraan edestä kuvatuksi " + "suorakulmioksi (pelkkä taulu).\n" + "Muunna: korjaa koko valokuvan perspektiivin säilyttäen koko " + "kuva-alan." + ), + "method_tip": ( + "Miten perspektiivi korjataan.\n" + "Nelikulmio: vedä neljä kulmaa kohteen päälle; nelikulmio kuvataan " + "suorakulmioksi.\n" + "Viivat: suoristaa suppenevat pysty-/vaakaviivat (kallistuman " + "korjaus) asettamatta kulmia." + ), + "vertical_tip": "Viivat-tila: korjaa suppenevat pystyviivat (kehyksen reunat, seinät).", + "horizontal_tip": "Viivat-tila: korjaa suppenevat vaakaviivat (ylä- ja alareunat).", + "crop_tip": ( + "Muunna-tila: rajaa korjattu kuva suurimpaan suorakulmioon, jossa " + "ei ole tyhjää (täytettyä) reunaa." + ), + "fill_color_tip": ( + "Väri, jolla täytetään alueet, jotka jäävät alkuperäisen valokuvan " + "ulkopuolelle koko kuvan perspektiivimuunnoksen jälkeen." + ), + "bow_tip": ( + "Suoristaa reunat, jotka kaareutuvat irrotetussa tuloksessa " + "(jäännösobjektiivivääristymä). Kulmat pysyvät paikoillaan.\n" + "Arvo on säteittäinen etäisyys tulospikseleinä: positiivinen " + "työntää reunan keskiosaa ulospäin korjaten sisäänpäin kaareutuvat " + "(tyynymäiset) reunat; negatiivinen vetää sisäänpäin korjaten " + "ulospäin kaareutuvat (tynnyrimäiset) reunat.\n" + "0 = pois." + ), + "color_correct_tip": ( + "Neutraloi värivirheen ja asettaa valotuksen valokuvasta " + "napsauttamasi referenssialueen mukaan." + ), + "color_value_tip": ( + "Valokuvasta poimittu väri (näytetään näytepalassa muodossa R,G,B). " + "Tämä on neutraali referenssi, johon korjaus perustuu." + ), + "gray_reflectance_tip": ( + "Harmaakortti-tila: poimitun palan tavoiteheijastavuus. 18% on " + "valokuvauksen vakioharmaasävy; nosta arvoa vaaleampaa tavoitetta " + "varten." + ), + "white_brightness_tip": ( + "Neutraaliharmaa-tila: korjatun kuvan kokonaiskirkkaus. Värivirhe " + "poistetaan; tämä asettaa valotuksen (0 = ei muutosta)." + ), + "sample_size_tip": ( + "Sen alueen säde (pikseleinä), jolta väri keskiarvoistetaan " + "napsautuksen ympäriltä referenssiä valittaessa. Suuremmat arvot " + "keskiarvoistavat useammasta pikselistä (vähemmän kohinaa)." + ), + "depth_tip": ( + "Kuorintasyvyys — kuinka moneen sisäkkäiseen nelikulmioon olet " + "kuorinut. 0 on uloin tunnistettu kohde; Kuori sisään (+) menee " + "sisemmäs, Kuori ulos (−) takaisin ulos." + ), + "lang_tip": "Käyttöliittymän kieli.", + "open_tip": "Avaa oikaistava kuva.", + "reset_tip": ( + "Suorita kuvan automaattinen tunnistus uudelleen ja hylkää " + "manuaaliset kulma-/viivamuokkaukset ja parametrit." + ), + "save_tip": ( + "Tallenna oikaistu kuva. Kun Numerointi on päällä, kirjoitetaan " + "ilman ikkunaa seuraava automaattisesti numeroitu tiedosto; muuten " + "avautuu tallennusikkuna nimeä, tyyppiä ja kansiota varten." + ), + "peel_in_tip": ( + "Kuori sisäänpäin: tunnista seuraava sisäkkäinen nelikulmio " + "nykyisen sisältä (esim. taulu kehyksensä sisällä)." + ), + "peel_out_tip": "Kuori takaisin ulospäin edelliseen (ympäröivään) nelikulmioon.", + "font_smaller_tip": "Pienennä käyttöliittymän tekstiä.", + "font_larger_tip": "Suurenna käyttöliittymän tekstiä.", + "shortcuts_title": "Pikanäppäimet", + "sk_sec_file": "Tiedosto", + "sk_sec_edit": "Muokkaa", + "sk_sec_peel": "Kuori", + "sk_sec_view": "Näkymä", + "sk_sec_quad": "Säädä nelikulmiota", + "sk_sec_dev": "Kehittäjä", + "sk_sec_help": "Ohje", + "sk_compare": "Vertaa alkuperäiseen (pidä)", + "sk_highlight": "Korosta nelikulmion osa (pidä)", + "sk_adjust_highlighted": "Säädä korostettua osaa", + "sk_nudge": "Siirrä kulmaa / reunaa / nelikulmiota", + "sk_zoom": "Lähennä / loitonna", + "sk_reset_zoom": "Palauta zoomaus (molemmat paneelit)", + "sk_reset_zoom_panel": "Palauta zoomaus (tämä paneeli)", + "sk_show_shortcuts": "Näytä tämä lista (pidä)", + "sk_clear_cache": "Tyhjennä asetusvälimuisti", + "sk_edge_debug": "Vaihda reunadiagnostiikka", + "sk_next_image": "Lataa seuraava kuva hakemistosta", + "sk_prev_image": "Lataa edellinen kuva hakemistosta", + "sk_snap_nearest": "Siirrä lähin piste napsautuskohtaan", + "sk_screenshot": "Tallenna pääikkunan kuvakaappaus ~/rectify/-kansioon", + "sk_annotate_line": "Piirrä vaihtoehtoinen viiva (punainen, antialiasoitu)", + "sk_clear_temp": "Tyhjennä väliaikaiset merkinnät", + }, +} + +_current_lang = "en" + + +def T(key: str) -> str: + """Return the translated string for the current language. + + Falls back to the English string when the current language lacks the + key (so an English-only string — e.g. a newly added tooltip not yet + translated — shows real text rather than the raw key), and finally to + the key itself if English lacks it too. + """ + lang = LANGUAGES.get(_current_lang, LANGUAGES["en"]) + if key in lang: + return lang[key] + return LANGUAGES["en"].get(key, key) + + +# Tooltip box width, in pixels. Qt renders a plain-text tooltip with no +# embedded newline as a single long line; constraining the width via a +# rich-text table cell forces every tooltip to word-wrap into a consistent +# multi-line box. +TOOLTIP_WIDTH_PX = 500 +# Inner padding (px) around tooltip text, via the rich-text table cell, so the +# text doesn't crowd the box edges. +TOOLTIP_PADDING_PX = 8 + + +def tip(text: str) -> str: + """Wrap tooltip *text* as a fixed-width, padded, word-wrapped rich-text box. + + Without this, tips that contain explicit newlines render as a multi-line + box while single-sentence tips render as one long single-line strip — an + inconsistent look. Rendering every tip inside a width-constrained, + padded rich-text cell makes them all wrap uniformly with breathing room. + Existing manual line breaks (``\\n``) are preserved as ``
``. + """ + import html as _html + body = _html.escape(text).replace("\n", "
") + return (f'' + f'
{body}
') + + +TRUNCATE_PATH_MAX = 60 # max characters for displayed paths + + +def truncate_path(path: str, max_chars: int = TRUNCATE_PATH_MAX) -> str: + """Truncate a file path from the left at a directory boundary. + + Always preserves the complete filename. If truncation is needed, + the result starts with '…/' followed by as many complete trailing + directory components as fit within max_chars. + """ + if len(path) <= max_chars: + return path + parts = path.split(os.sep) + filename = parts[-1] + if len(filename) + 2 >= max_chars: # "…/" + filename + return "…/" + filename + # Build from the right, adding directory components + result = filename + for part in reversed(parts[:-1]): + candidate = part + "/" + result + if len("…/" + candidate) > max_chars: + break + result = candidate + return "…/" + result + + +# ────────────────────────────────────────────────────────────────── +# Incremental file naming +# ────────────────────────────────────────────────────────────────── + +def find_next_n(directory: str, prefix: str) -> int: + """Find the next available sequence number for prefix_N.* in directory. + + Scans all files matching prefix_N.* (any extension) and returns + one more than the highest N found, or 1 if none exist. + """ + import re + pattern = re.compile(re.escape(prefix) + r"_(\d+)\.") + max_n = 0 + try: + for name in os.listdir(directory): + m = pattern.match(name) + if m: + max_n = max(max_n, int(m.group(1))) + except OSError: + pass + return max_n + 1 + + +# ────────────────────────────────────────────────────────────────── +# Slider + spin box combination +# ────────────────────────────────────────────────────────────────── + +# Custom-slider geometry (fully painted, so identical on every platform). +_CF_HANDLE_D = 14 # handle diameter, px +_CF_TRACK_H = 4 # track thickness, px + + +class _CenterFillSlider(QSlider): + """Horizontal slider whose coloured fill grows from a fixed origin + position toward the handle, instead of always from the left edge. + + Used for bidirectional parameters (e.g. Bow) whose neutral value sits + in the middle of the range, so deflection to either side reads + symmetrically. Fully custom-painted (and custom hit-tested) so the + appearance and feel are identical on every platform — native sliders, + especially macOS, can't express a centre origin and always fill from + the left. + """ + + def __init__(self, parent=None): + super().__init__(Qt.Horizontal, parent) + self._origin = 0 # slider-int position the fill grows from + self.setMinimumHeight(_CF_HANDLE_D + 2) + + def set_origin(self, pos: int): + self._origin = pos + self.update() + + def _x_for(self, v: float) -> float: + """Center-x (in widget px) of the handle when the value is *v*.""" + span = self.width() - _CF_HANDLE_D + lo, hi = self.minimum(), self.maximum() + t = (v - lo) / (hi - lo) if hi > lo else 0.0 + return _CF_HANDLE_D / 2 + t * span + + def _value_from_x(self, x: float) -> int: + span = self.width() - _CF_HANDLE_D + t = (x - _CF_HANDLE_D / 2) / span if span > 0 else 0.0 + t = min(1.0, max(0.0, t)) + lo, hi = self.minimum(), self.maximum() + return round(lo + t * (hi - lo)) + + def mousePressEvent(self, event): + if event.button() == Qt.LeftButton: + self.setValue(self._value_from_x(event.position().x())) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + if event.buttons() & Qt.LeftButton: + self.setValue(self._value_from_x(event.position().x())) + event.accept() + return + super().mouseMoveEvent(event) + + def paintEvent(self, event): + cy = self.height() / 2.0 + x_handle = self._x_for(self.value()) + x_origin = self._x_for(self._origin) + span = self.width() - _CF_HANDLE_D + p = QPainter(self) + p.setRenderHint(QPainter.Antialiasing) + p.setPen(Qt.NoPen) + # Track (full width). + p.setBrush(QColor("#c0c0c0")) + p.drawRoundedRect(QRectF(_CF_HANDLE_D / 2, cy - _CF_TRACK_H / 2, + span, _CF_TRACK_H), 2, 2) + # Fill from origin to handle. + left, right = sorted((x_origin, x_handle)) + p.setBrush(QColor("#4488ff")) + p.drawRoundedRect(QRectF(left, cy - _CF_TRACK_H / 2, + right - left, _CF_TRACK_H), 2, 2) + # Handle. + p.setBrush(QColor("#f5f5f5")) + p.setPen(QPen(QColor("#808080"), 1)) + r = _CF_HANDLE_D / 2 - 1 + p.drawEllipse(QPointF(x_handle, cy), r, r) + + +class SliderSpinBox(QWidget): + """A linked slider and spin box that stay in sync. + + The spin box allows direct numeric entry; the slider allows quick + interactive adjustment. Editing either updates the other. + """ + + valueChanged = Signal(float) + + def __init__(self, min_val: float, max_val: float, step: float = 0.01, + decimals: int = 2, slider_width: int = 160, + center_fill: bool = False, parent=None): + super().__init__(parent) + self._min = min_val + self._max = max_val + self._step = step + self._center_fill = center_fill + self._updating = False + + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + if _BOTTOM_ALIGN: + # Fill the row vertically so the bottom-aligned spin reaches the row + # bottom (level with the labels/radios), slider stays centered. + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + + self.spin = QDoubleSpinBox() + self.spin.setRange(min_val, max_val) + self.spin.setSingleStep(step) + self.spin.setDecimals(decimals) + # Both children must be horizontally `Fixed` policy. + # QDoubleSpinBox and QSlider both default to `Expanding` — + # leaving that default makes the outer SliderSpinBox container + # claim more horizontal slack than its children can use, and + # the leftover appears as a visible gap between the spin and + # the slider. Width set in _apply_font_scale for the spin; + # below for the slider. + self.spin.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + if _IS_MAC: + # A small left inset for the value. No top/bottom inset — the spin's + # natural height already fits the font, and vertical margins would + # force a taller box than the compact rows can hold. Linux unaffected. + self.spin.lineEdit().setTextMargins(3, 0, 0, 0) + if _BOTTOM_ALIGN: + # The value (text) sits at the bottom of the row; the slider is + # centered. A QDoubleSpinBox ignores a layout-item AlignBottom, so + # pin it to the bottom with a stretch above it in a thin wrapper. + spin_box = QWidget() + spin_v = QVBoxLayout(spin_box) + spin_v.setContentsMargins(0, 0, 0, 0) + spin_v.setSpacing(0) + spin_v.addStretch(1) + spin_v.addWidget(self.spin) + layout.addWidget(spin_box) + else: + layout.addWidget(self.spin, 0, Qt.AlignVCenter) + + self.slider = _CenterFillSlider() if center_fill else QSlider(Qt.Horizontal) + self.slider.setFixedWidth(slider_width) + self.slider.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed) + # Map float range to integer slider (1000 steps) + self.slider.setRange(0, 1000) + if center_fill: + # Grow the fill from the slider position of value 0, so a + # bidirectional range reads symmetrically about its neutral. + t0 = (0.0 - min_val) / (max_val - min_val) if max_val > min_val else 0.0 + self.slider.set_origin(int(t0 * 1000)) + layout.addWidget(self.slider, 0, Qt.AlignVCenter) + + self.spin.valueChanged.connect(self._spin_changed) + self.slider.valueChanged.connect(self._slider_changed) + + def _spin_changed(self, value): + if self._updating: + return + self._updating = True + t = (value - self._min) / (self._max - self._min) if self._max > self._min else 0 + self.slider.setValue(int(t * 1000)) + self._updating = False + self.valueChanged.emit(value) + + def _slider_changed(self, pos): + if self._updating: + return + self._updating = True + value = self._min + (pos / 1000.0) * (self._max - self._min) + # Round to step + value = round(value / self._step) * self._step + self.spin.setValue(value) + self._updating = False + self.valueChanged.emit(value) + + def value(self) -> float: + return self.spin.value() + + def setValue(self, val: float): + self._updating = True + self.spin.setValue(val) + t = (val - self._min) / (self._max - self._min) if self._max > self._min else 0 + self.slider.setValue(int(t * 1000)) + self._updating = False + + def blockSignals(self, block: bool): + self.spin.blockSignals(block) + self.slider.blockSignals(block) + + +class GraySwatchButton(QPushButton): + """Color-correct swatch button with distinct left/right click actions. + + Left-click fires the normal ``clicked`` signal (arm a gray pick); + right-click fires ``rightClicked`` (reuse the last/sticky gray on this + image). QPushButton has no built-in right-click signal, so the right + button is intercepted here and consumed before it can start anything + else. + """ + + rightClicked = Signal() + + def mousePressEvent(self, event): + if event.button() == Qt.RightButton: + self.rightClicked.emit() + event.accept() + return + super().mousePressEvent(event) + + +def _color_swatch_style(color: QColor, armed: bool = False) -> str: + """Stylesheet for a flat color-swatch button: the color fills the square + edge-to-edge, a thin black outline surrounds it (2px dashed when *armed*), + with no native button chrome or padding.""" + border = "2px dashed palette(text)" if armed else "1px solid black" + return (f"QPushButton {{ background-color: {color.name()}; " + f"border: {border}; padding: 0px; margin: 0px; }}") + + +# ────────────────────────────────────────────────────────────────── +# Corner Handle +# ────────────────────────────────────────────────────────────────── + +class CornerHandle(QGraphicsEllipseItem): + """Draggable corner handle for the detected quadrilateral.""" + + def __init__(self, index: int, pos: QPointF, image_rect: QRectF, parent_panel): + r = CORNER_RADIUS + super().__init__(-r, -r, 2 * r, 2 * r) + self.index = index + self.image_rect = image_rect + self.parent_panel = parent_panel + self.setPos(pos) + self.setPen(QPen(Qt.white, 1.5)) + self.setBrush(GREEN_BRUSH) + self.setFlags( + QGraphicsEllipseItem.ItemIsMovable + | QGraphicsEllipseItem.ItemSendsGeometryChanges + | QGraphicsEllipseItem.ItemIgnoresTransformations + ) + self.setZValue(10) + + def itemChange(self, change, value): + if change == QGraphicsEllipseItem.ItemPositionChange and self.image_rect: + # Clamp to image bounds + p = value + x = max(self.image_rect.left(), min(p.x(), self.image_rect.right())) + y = max(self.image_rect.top(), min(p.y(), self.image_rect.bottom())) + clamped = QPointF(x, y) + self.setBrush(YELLOW_BRUSH) + self.parent_panel.on_corner_moved(self.index, clamped, dragging=True) + return clamped + return super().itemChange(change, value) + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + self.setBrush(GREEN_BRUSH) + self.parent_panel.on_corner_released(self.index) + + +# ────────────────────────────────────────────────────────────────── +# Arrow Handle (for keystone line pairs) +# ────────────────────────────────────────────────────────────────── + +class ArrowHandle(QGraphicsPolygonItem): + """Draggable arrow-shaped handle for keystone line pair endpoints.""" + + def __init__(self, pair_index: int, endpoint_index: int, + pos: QPointF, image_rect: QRectF, parent_panel): + arrow = QPolygonF([ + QPointF(8, 0), # tip + QPointF(-4, -5), # upper-left + QPointF(-4, 5), # lower-left + ]) + super().__init__(arrow) + self.pair_index = pair_index + self.endpoint_index = endpoint_index + self.image_rect = image_rect + self.parent_panel = parent_panel + self.setPos(pos) + self.setPen(Qt.NoPen) # no outline — flat colored triangle + self.setBrush(GREEN_BRUSH) + self.setFlags( + QGraphicsPolygonItem.ItemIsMovable + | QGraphicsPolygonItem.ItemSendsGeometryChanges + | QGraphicsPolygonItem.ItemIgnoresTransformations + ) + self.setZValue(10) + + def update_rotation(self, other_pos: QPointF): + """Rotate so the arrow tip points away from *other_pos*.""" + my = self.pos() + dx = my.x() - other_pos.x() + dy = my.y() - other_pos.y() + self.setRotation(math.degrees(math.atan2(dy, dx))) + + def itemChange(self, change, value): + if change == QGraphicsPolygonItem.ItemPositionChange and self.image_rect: + p = value + x = max(self.image_rect.left(), min(p.x(), self.image_rect.right())) + y = max(self.image_rect.top(), min(p.y(), self.image_rect.bottom())) + clamped = QPointF(x, y) + # Call on_arrow_moved first (it triggers _update_pair_lines, + # which sets the line pen + this handle's brush from the + # alignment check); then setBrush(YELLOW) overrides so the + # dragging handle is yellow regardless of alignment. + self.parent_panel.on_arrow_moved( + self.pair_index, self.endpoint_index, clamped, dragging=True, + ) + self.setBrush(YELLOW_BRUSH) + return clamped + return super().itemChange(change, value) + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + # Refresh the line pen + both endpoints' brushes through the + # parent's alignment logic — picks GREEN_BRUSH or BLUE_BRUSH to + # match the line color. + line_idx = self.pair_index * 2 + self.endpoint_index // 2 + self.parent_panel._set_pair_line_default_pen(line_idx) + self.parent_panel.on_arrow_released(self.pair_index, self.endpoint_index) + + +# ────────────────────────────────────────────────────────────────── +# Annotation Arrow Handle (for alternate-line endpoints) +# ────────────────────────────────────────────────────────────────── + +class AnnotationArrowHandle(QGraphicsPolygonItem): + """Draggable arrow-shaped handle for an alternate-line annotation endpoint. + + Visually distinct from the green keystone-line ``ArrowHandle``: + - Red fill (matching the line itself). + - ``Qt.NoPen`` border — no white outline, so the handle looks like a + flat red triangle rather than an outlined chip. + + Drag updates both the line endpoint and both endpoints' rotations + directly from ``itemChange``, so the annotation tracks the cursor in + real time without needing a parent-panel update call. After release + the brush reverts from yellow to red. + """ + + def __init__(self, pos: QPointF, image_rect: QRectF, parent_panel): + arrow = QPolygonF([ + QPointF(8, 0), # tip + QPointF(-4, -5), # upper-left + QPointF(-4, 5), # lower-left + ]) + super().__init__(arrow) + self.image_rect = image_rect + self.parent_panel = parent_panel + self.paired_handle: "AnnotationArrowHandle | None" = None + self.line_item: "QGraphicsLineItem | None" = None + self.setPos(pos) + self.setPen(Qt.NoPen) # no outline — distinct from keystone handles + self.setBrush(RED_BRUSH) + self.setFlags( + QGraphicsPolygonItem.ItemIsMovable + | QGraphicsPolygonItem.ItemSendsGeometryChanges + | QGraphicsPolygonItem.ItemIgnoresTransformations + ) + self.setZValue(21) # above the annotation line (zValue 20) + + def update_rotation_from(self, my_pos: QPointF, other_pos: QPointF): + """Set rotation so the arrow tip points away from ``other_pos``. + + Caller supplies my_pos explicitly so itemChange (where ``self.pos()`` + still returns the pre-commit position) can pass the just-clamped + new position. + """ + dx = my_pos.x() - other_pos.x() + dy = my_pos.y() - other_pos.y() + self.setRotation(math.degrees(math.atan2(dy, dx))) + + def itemChange(self, change, value): + if change == QGraphicsPolygonItem.ItemPositionChange and self.image_rect: + p = value + x = max(self.image_rect.left(), min(p.x(), self.image_rect.right())) + y = max(self.image_rect.top(), min(p.y(), self.image_rect.bottom())) + clamped = QPointF(x, y) + self.setBrush(YELLOW_BRUSH) + if self.line_item is not None and self.paired_handle is not None: + other = self.paired_handle.pos() + self.line_item.setLine( + clamped.x(), clamped.y(), other.x(), other.y(), + ) + self.update_rotation_from(clamped, other) + self.paired_handle.update_rotation_from(other, clamped) + return clamped + return super().itemChange(change, value) + + def mouseReleaseEvent(self, event): + super().mouseReleaseEvent(event) + self.setBrush(RED_BRUSH) + + +# ────────────────────────────────────────────────────────────────── +# Image Panel (QGraphicsView) +# ────────────────────────────────────────────────────────────────── + +class ImagePanel(QGraphicsView): + """A zoomable, pannable image display panel.""" + + def __init__(self, parent=None): + super().__init__(parent) + self._scene = QGraphicsScene(self) + self.setScene(self._scene) + self._pixmap_item: QGraphicsPixmapItem | None = None + self._pending_pixmap = None + self._zoom_level = 1.0 + self._user_zoomed = False # True after manual zoom; suppresses auto-refit + + # Appearance + self.setBackgroundBrush(QBrush(QColor(PANEL_BG_COLOR))) + from PySide6.QtGui import QPainter + self.setRenderHint(QPainter.Antialiasing, True) + self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse) + self.setResizeAnchor(QGraphicsView.AnchorViewCenter) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + + def set_image(self, pixmap, fixed_scale: float | None = None): + """Display a QPixmap. + + If *fixed_scale* is None, scale down to fit if larger or center at + 1:1 if smaller. If *fixed_scale* is given, display at that exact + scale factor, centered. This is used to maintain consistent sizing + across peel levels. + """ + from PySide6.QtCore import QTimer + self._scene.clear() + self._pixmap_item = self._scene.addPixmap(pixmap) + self._scene.setSceneRect(QRectF(pixmap.rect())) + self._pending_pixmap = pixmap + self._pending_fixed_scale = fixed_scale + self._zoom_level = 1.0 + self._user_zoomed = False + self.resetTransform() + self._fit_image() + QTimer.singleShot(0, self._fit_image) + + def replace_pixmap(self, pixmap): + """Swap the displayed pixmap in place without resetting zoom or pan. + + Used when regenerating the source image content (e.g. edge debug + overlay) at the same dimensions — preserves the user's view. + """ + if self._pixmap_item is not None: + self._pixmap_item.setPixmap(pixmap) + + def _fit_image(self): + """Apply the pending image scale.""" + if self._pixmap_item is None or self._pending_pixmap is None: + return + pixmap = self._pending_pixmap + vp = self.viewport().size() + if vp.width() < 2 or vp.height() < 2: + return + self._pending_pixmap = None + self.resetTransform() + + # Available space after subtracting margin on each side + avail_w = vp.width() - 2 * PANEL_MARGIN + avail_h = vp.height() - 2 * PANEL_MARGIN + if avail_w < 1 or avail_h < 1: + return + + if self._pending_fixed_scale is not None: + # Use the exact scale, centered + s = self._pending_fixed_scale + self.scale(s, s) + self._zoom_level = s + self.centerOn(self._pixmap_item) + elif pixmap.width() > avail_w or pixmap.height() > avail_h: + # Scale to fit within available space (viewport minus margins) + scale_x = avail_w / pixmap.width() + scale_y = avail_h / pixmap.height() + s = min(scale_x, scale_y) + self.scale(s, s) + self._zoom_level = s + self.centerOn(self._pixmap_item) + else: + self._zoom_level = 1.0 + self.centerOn(self._pixmap_item) + # Expand scene rect to viewport size so AnchorUnderMouse works + # from the very first zoom tick (avoids margin-induced shift) + vp = self.viewport().rect() + top_left = self.mapToScene(vp.topLeft()) + bot_right = self.mapToScene(vp.bottomRight()) + view_in_scene = QRectF(top_left, bot_right) + img_rect = QRectF(pixmap.rect()) + self._scene.setSceneRect(img_rect.united(view_in_scene)) + self._update_drag_mode() + + def get_fit_scale(self) -> float: + """Return the current scale factor (for use as a base scale).""" + return self._zoom_level + + def showEvent(self, event): + super().showEvent(event) + if self._pending_pixmap is not None: + self._fit_image() + + def resizeEvent(self, event): + super().resizeEvent(event) + self._center_hint() # keep the pick-hint overlay centered on resize + if self._pending_pixmap is not None: + self._fit_image() + elif self._pixmap_item is not None and not self._user_zoomed: + # Refit existing image to new panel size (only if user hasn't + # manually zoomed — otherwise scroll bar changes would reset zoom) + pixmap = self._pixmap_item.pixmap() + self._pending_pixmap = pixmap + self._fit_image() + + def show_hint(self, text: str): + """Show a centered instructional overlay over the image (the color-pick + prompt). It's a child of the viewport, so it floats above the image + and ignores zoom/pan; auto-hidden when the mouse enters the panel.""" + lbl = getattr(self, "_hint_label", None) + if lbl is None: + lbl = QLabel(self.viewport()) + lbl.setAlignment(Qt.AlignCenter) + lbl.setStyleSheet( + "QLabel { background-color: rgba(0, 0, 0, 0.80); color: white; " + "border: 1px solid rgba(255, 255, 255, 0.35); " + "border-radius: 8px; padding: 12px 16px; }") + self._hint_label = lbl + lbl.setText(text) + lbl.adjustSize() + lbl.show() + self._center_hint() + lbl.raise_() + + def hide_hint(self): + lbl = getattr(self, "_hint_label", None) + if lbl is not None and lbl.isVisible(): + lbl.hide() + + def _center_hint(self): + lbl = getattr(self, "_hint_label", None) + if lbl is None or not lbl.isVisible(): + return + c = self.viewport().rect().center() + lbl.move(c.x() - lbl.width() // 2, c.y() - lbl.height() // 2) + + def enterEvent(self, event): + # Clear the pick-hint as soon as the cursor is over the panel. + self.hide_hint() + super().enterEvent(event) + + def set_message(self, text: str): + """Display a centered text message instead of an image. + + White and double the normal size so it reads clearly as a panel + placeholder against the gray viewport (e.g. "Source image" / "Result" + on first run, or "No result"). + """ + self._scene.clear() + self._pixmap_item = None + t = self._scene.addText(text) + t.setDefaultTextColor(QColor("white")) + font = t.font() + if font.pointSizeF() > 0: + font.setPointSizeF(font.pointSizeF() * 2) + else: + font.setPixelSize(max(font.pixelSize(), 1) * 2) + t.setFont(font) + self._scene.setSceneRect(t.boundingRect()) + # Render the placeholder at 1:1 and centered. Without resetting the + # view transform, the message inherits the *previous image's* zoom — + # e.g. a large photo fit to the panel leaves a heavy zoom-out in + # effect, which shrinks "No result" to a few pixels. (First-run + # placeholders looked fine only because the transform was still + # identity.) resetTransform makes every placeholder the same, + # readable size regardless of what was shown before. + self.resetTransform() + self.centerOn(t) + + def _image_fits_in_viewport(self) -> bool: + """Return True if the image at current zoom fits within the viewport.""" + if self._pixmap_item is None: + return True + # Measure the IMAGE, not the scene rect (which we deliberately enlarge + # for free positioning — see _expand_scene_rect). + img_in_view = self.mapFromScene( + QRectF(self._pixmap_item.pixmap().rect())).boundingRect() + vp = self.viewport().rect() + return (img_in_view.width() <= vp.width() + 1 + and img_in_view.height() <= vp.height() + 1) + + def _expand_scene_rect(self): + """Enlarge the scene rect to cover the image plus a viewport of slack on + each side, so the image can be positioned freely — panned, or held under + the cursor while zooming, even when zoomed out smaller than the panel — + instead of Qt forcing it to center. Uniting with the current view means + this never shifts what's already on screen. + """ + if self._pixmap_item is None: + return + img_rect = QRectF(self._pixmap_item.pixmap().rect()) + vp = self.viewport().rect() + view_in_scene = QRectF(self.mapToScene(vp.topLeft()), + self.mapToScene(vp.bottomRight())) + rect = img_rect.united(view_in_scene) + rect = rect.adjusted(-view_in_scene.width(), -view_in_scene.height(), + view_in_scene.width(), view_in_scene.height()) + self._scene.setSceneRect(rect) + + def _update_scene_rect_for_zoom(self, zooming_in: bool): + """Give the image room to be positioned freely while zooming. On + zoom-out to "fit" WITHOUT a cursor lock, restore a tight rect and + re-center (the image-centered resting state). The cursor-lock zoom path + calls _expand_scene_rect directly instead, so a cursor-anchored zoom + overrides that centering. + """ + if self._pixmap_item is None: + return + if not zooming_in and self._image_fits_in_viewport(): + self._scene.setSceneRect(QRectF(self._pixmap_item.pixmap().rect())) + self.centerOn(self._pixmap_item) + else: + self._expand_scene_rect() + + def wheelEvent(self, event: QWheelEvent): + factor = _wheel_zoom_factor(event) + if factor == 1.0: + event.accept() + return + new_zoom = self._zoom_level * factor + if ZOOM_MIN <= new_zoom <= ZOOM_MAX: + self._user_zoomed = True + # Check if cursor is over the image before zooming + scene_pos = self.mapToScene(event.position().toPoint()) + img_rect = QRectF(self._pixmap_item.pixmap().rect()) if self._pixmap_item else None + cursor_on_image = img_rect is not None and img_rect.contains(scene_pos) + if cursor_on_image: + # Lock the point under the cursor in place. Done manually + # (NoAnchor + translate) instead of AnchorUnderMouse: that one + # centers until the scene rect has pan room, so the FIRST zoom-in + # from "fit" wouldn't track the cursor. Scale, expand the scene + # rect to give pan room, THEN translate — locks from the very + # first scroll. + self.setTransformationAnchor(QGraphicsView.NoAnchor) + # Pan-room FIRST (image + slack, united with the current view so + # nothing shifts), then scale + translate atomically. We expand + # — never re-center — so the cursor stays locked even zooming out + # past "fit": zoom overrides the center constraint. + self._expand_scene_rect() + before = self.mapToScene(event.position().toPoint()) + self.scale(factor, factor) + after = self.mapToScene(event.position().toPoint()) + self.translate(after.x() - before.x(), after.y() - before.y()) + self._zoom_level = new_zoom + self._expand_scene_rect() + else: + self.setTransformationAnchor(QGraphicsView.AnchorViewCenter) + self.scale(factor, factor) + self._zoom_level = new_zoom + self._update_scene_rect_for_zoom(factor > 1.0) + self._update_drag_mode() + event.accept() + + def _update_drag_mode(self): + """Set drag mode based on whether the image is larger than the viewport. + + ScrollHandDrag (hand cursor) when panning is possible; + NoDrag (pointer cursor) when the image fits entirely. + """ + if self._pixmap_item is None: + self.setDragMode(QGraphicsView.NoDrag) + return + # Pan only when the IMAGE exceeds the viewport — measure the image, not + # the (deliberately enlarged) scene rect. + img_in_view = self.mapFromScene( + QRectF(self._pixmap_item.pixmap().rect())).boundingRect() + vp = self.viewport().rect() + can_pan = (img_in_view.width() > vp.width() + 1 + or img_in_view.height() > vp.height() + 1) + self.setDragMode( + QGraphicsView.ScrollHandDrag if can_pan + else QGraphicsView.NoDrag + ) + + def mousePressEvent(self, event): + # Right-click (without left held) resets zoom to fit + if (event.button() == Qt.RightButton + and not (event.buttons() & Qt.LeftButton)): + self._reset_panel_zoom() + event.accept() + return + super().mousePressEvent(event) + + def _reset_panel_zoom(self): + """Reset this panel to default zoom.""" + if self._pixmap_item is not None: + self._user_zoomed = False + pixmap = self._pixmap_item.pixmap() + self._pending_pixmap = pixmap + self._pending_fixed_scale = None + self.resetTransform() + self._fit_image() + + def get_image_rect(self) -> QRectF | None: + if self._pixmap_item: + return QRectF(self._pixmap_item.pixmap().rect()) + return None + + +# ────────────────────────────────────────────────────────────────── +# Left Panel with quad overlay +# ────────────────────────────────────────────────────────────────── + +class SourcePanel(ImagePanel): + """Left panel: image with interactive quadrilateral overlay.""" + + corner_moved = Signal(int, float, float, bool) # index, x, y, dragging + corner_released = Signal(int) + center_drag_started = Signal() # emitted when user starts a center-drag + arrow_moved = Signal(int, int, float, float, bool) # pair, endpoint, x, y, dragging + arrow_released = Signal(int, int) # pair, endpoint + gray_point_picked = Signal(float, float) # x, y in image coords (gray-card pick) + + def __init__(self, parent=None): + super().__init__(parent) + self.setFocusPolicy(Qt.StrongFocus) + self.setMouseTracking(True) + self.viewport().setMouseTracking(True) + self._handles: list[CornerHandle] = [] + self._lines: list[QGraphicsLineItem] = [] + self._arrow_handles: list[ArrowHandle] = [] + self._pair_lines: list[QGraphicsLineItem] = [] + # Line-drag state: move a keystone line by dragging near it + self._line_drag_active: bool = False + self._line_drag_start: QPointF | None = None + self._line_drag_pair: int | None = None # pair index + self._line_drag_line: int | None = None # 0 or 1 within pair + self._line_drag_start_pts: np.ndarray | None = None # (2, 2) + self._selected_handle: int | None = None + self._selected_edge: int | None = None + self._selected_whole: bool = False # expand/shrink entire rectangle + # Center-drag state: move entire region by dragging in the center + self._center_drag_active: bool = False + self._center_drag_start: QPointF | None = None + self._center_drag_corners_start: np.ndarray | None = None + # Edge-drag state: move an edge perpendicular to itself + self._edge_drag_active: bool = False + self._edge_drag_start: QPointF | None = None + self._edge_drag_corners_start: np.ndarray | None = None + self._edge_drag_index: int | None = None + # Shift-wheel state: element detected on Shift press, used for wheel + self._shift_element: str = "none" # "corner", "edge", "center", "none" + self._shift_element_index: int | None = None + # True when dragging the image (pan) outside the quad + self._panning: bool = False + # True when Ctrl is held — show CrossCursor to preview the snap + # gesture (Ctrl+Click moves the nearest movable point to the click). + self._ctrl_held: bool = False + # True when Alt is held — show CrossCursor to preview the alternate- + # line annotation gesture (Alt+Click twice draws a red ideal line). + self._alt_held: bool = False + # Alternate-line annotation state. Each pair of Alt+Clicks defines + # one red antialiased line; the rubber-band tracks the cursor + # between the two clicks. After finalisation each annotation is a + # tuple of (line, handle0, handle1) — the handles are red + # AnnotationArrowHandles that allow the user to drag endpoints + # after the fact. All cleared by Esc or new-image load. + self._annotation_first_point: QPointF | None = None + self._annotation_rubber_line: QGraphicsLineItem | None = None + self._annotations: list[tuple[QGraphicsLineItem, + AnnotationArrowHandle, + AnnotationArrowHandle]] = [] + # True when the user has moved/resized the region via center-drag + self.region_moved: bool = False + # Gray-card color-correction pick: when armed (set by the + # "Color correct" swatch button), the next left-click reports its + # image-pixel position via gray_point_picked instead of running + # any quad/line/pan gesture. The marker shows the sampled square. + self._gray_pick_armed: bool = False + self._gray_marker: QGraphicsRectItem | None = None + + def set_image(self, pixmap, fixed_scale=None): + """Override to clear overlay references before scene.clear().""" + self._handles.clear() + self._lines.clear() + self._arrow_handles.clear() + self._pair_lines.clear() + # Drop annotation references before scene.clear() invalidates them. + self._annotations.clear() + self._annotation_rubber_line = None + self._annotation_first_point = None + self._selected_handle = None + self._center_drag_active = False + self._edge_drag_active = False + self._line_drag_active = False + self.region_moved = False + self._gray_marker = None # scene.clear() in super() invalidates it + super().set_image(pixmap, fixed_scale=fixed_scale) + + def set_message(self, text: str): + """Override to clear overlay references before scene.clear().""" + self._handles.clear() + self._lines.clear() + self._arrow_handles.clear() + self._pair_lines.clear() + self._annotations.clear() + self._annotation_rubber_line = None + self._annotation_first_point = None + self._selected_handle = None + self._center_drag_active = False + self._line_drag_active = False + self._gray_marker = None + super().set_message(text) + + def set_overlay(self, corners: np.ndarray | None, draggable: bool = True): + """Draw or clear the quadrilateral overlay with corner handles. + + If *draggable* is False, the corners are displayed but cannot be moved + (used when showing mapped-back corners at deeper peel levels). + """ + # Remove old overlay (if items still exist in scene) + for h in self._handles: + if h.scene() is not None: + self._scene.removeItem(h) + for l in self._lines: + if l.scene() is not None: + self._scene.removeItem(l) + self._handles.clear() + self._lines.clear() + self._selected_handle = None + + if corners is None: + return + + image_rect = self.get_image_rect() + if image_rect is None: + return + + # Create 4 edge lines + for i in range(4): + line = QGraphicsLineItem() + line.setPen(GREEN_PEN) + line.setZValue(5) + self._scene.addItem(line) + self._lines.append(line) + + # Create 4 corner handles + for i in range(4): + pos = QPointF(float(corners[i][0]), float(corners[i][1])) + handle = CornerHandle(i, pos, image_rect, self) + if not draggable: + handle.setFlag(QGraphicsEllipseItem.ItemIsMovable, False) + self._scene.addItem(handle) + self._handles.append(handle) + + self._update_lines() + + def update_handle_positions(self, corners: np.ndarray): + """Update handle positions without recreating them.""" + for i, handle in enumerate(self._handles): + handle.setPos(QPointF(float(corners[i][0]), float(corners[i][1]))) + self._update_lines() + + def _update_lines(self): + """Redraw the edge lines to connect current handle positions. + + Sets each edge's pen to blue if its endpoints are aligned + (same x for vertical, same y for horizontal), otherwise green. + """ + if len(self._handles) != 4 or len(self._lines) != 4: + return + for i in range(4): + p1 = self._handles[i].pos() + p2 = self._handles[(i + 1) % 4].pos() + self._lines[i].setLine(p1.x(), p1.y(), p2.x(), p2.y()) + aligned = (int(round(p1.x())) == int(round(p2.x())) + or int(round(p1.y())) == int(round(p2.y()))) + self._lines[i].setPen(BLUE_PEN if aligned else GREEN_PEN) + + def on_corner_moved(self, index: int, pos: QPointF, dragging: bool = True): + """Called by CornerHandle when dragged.""" + self._update_lines() + self.corner_moved.emit(index, pos.x(), pos.y(), dragging) + + def on_corner_released(self, index: int): + """Called by CornerHandle on mouse release.""" + self.corner_released.emit(index) + + # ── Lines overlay (keystone mode) ───────────────────────────── + + def set_lines_overlay(self, line_pairs: list[np.ndarray] | None): + """Draw or clear the keystone line pair overlay with arrow handles.""" + for h in self._arrow_handles: + if h.scene() is not None: + self._scene.removeItem(h) + for l in self._pair_lines: + if l.scene() is not None: + self._scene.removeItem(l) + self._arrow_handles.clear() + self._pair_lines.clear() + + if line_pairs is None: + return + + image_rect = self.get_image_rect() + if image_rect is None: + return + + for pi, pair in enumerate(line_pairs): + # Two lines per pair → 2 QGraphicsLineItems + for li in range(2): + line = QGraphicsLineItem() + line.setPen(GREEN_PEN) + line.setZValue(5) + self._scene.addItem(line) + self._pair_lines.append(line) + + # Four arrow handles per pair (2 per line) + for ei in range(4): + pos = QPointF(float(pair[ei][0]), float(pair[ei][1])) + handle = ArrowHandle(pi, ei, pos, image_rect, self) + self._scene.addItem(handle) + self._arrow_handles.append(handle) + + self._update_pair_lines() + + def _set_pair_line_default_pen(self, line_index: int): + """Set a keystone line's pen and both arrowhead brushes to match its + alignment state — blue when axis-aligned, green otherwise. + + Mirrors `_set_edge_default_pen` for Quad mode. Yellow (drag + highlight) takes precedence in the live drag path: `ArrowHandle. + itemChange` calls this via `on_arrow_moved` and then sets + `YELLOW_BRUSH` itself, so the dragging handle stays yellow while + the line and the other endpoint update to blue/green. Called + from `_update_pair_lines` for every non-dragging line and from + `ArrowHandle.mouseReleaseEvent` to restore the brush on release. + """ + if line_index >= len(self._pair_lines): + return + h1 = self._arrow_handles[line_index * 2] + h2 = self._arrow_handles[line_index * 2 + 1] + p1, p2 = h1.pos(), h2.pos() + aligned = (int(round(p1.x())) == int(round(p2.x())) + or int(round(p1.y())) == int(round(p2.y()))) + pen = BLUE_PEN if aligned else GREEN_PEN + brush = BLUE_BRUSH if aligned else GREEN_BRUSH + self._pair_lines[line_index].setPen(pen) + h1.setBrush(brush) + h2.setBrush(brush) + + def _update_pair_lines(self): + """Redraw lines connecting arrow handle pairs and update rotations.""" + is_dragging = self._line_drag_active + drag_idx = (self._line_drag_pair * 2 + self._line_drag_line + if is_dragging and self._line_drag_pair is not None + and self._line_drag_line is not None + else None) + for i, line in enumerate(self._pair_lines): + h1 = self._arrow_handles[i * 2] + h2 = self._arrow_handles[i * 2 + 1] + p1 = h1.pos() + p2 = h2.pos() + line.setLine(p1.x(), p1.y(), p2.x(), p2.y()) + # Don't overwrite the yellow drag highlight; otherwise pick + # blue/green based on axis-alignment. + if i != drag_idx: + self._set_pair_line_default_pen(i) + for i in range(len(self._pair_lines)): + h1 = self._arrow_handles[i * 2] + h2 = self._arrow_handles[i * 2 + 1] + h1.update_rotation(h2.pos()) + h2.update_rotation(h1.pos()) + + def on_arrow_moved(self, pair_index: int, endpoint_index: int, + pos: QPointF, dragging: bool = True): + """Called by ArrowHandle when dragged.""" + self._update_pair_lines() + self.arrow_moved.emit(pair_index, endpoint_index, + pos.x(), pos.y(), dragging) + + def on_arrow_released(self, pair_index: int, endpoint_index: int): + """Called by ArrowHandle on mouse release.""" + self.arrow_released.emit(pair_index, endpoint_index) + + def set_ctrl_held(self, held: bool): + """Track whether Ctrl is being held to preview the Ctrl+Click gesture. + + When held, the viewport cursor is forced to ``CrossCursor`` so the + user knows that clicking will snap the nearest movable point to the + click position (matching the corner-hover cursor for visual + consistency). When released, ``unsetCursor`` returns to the + default; the next mouse move re-runs the hover-cursor logic in + ``mouseMoveEvent`` to show the right cursor for the current + position. No-op if there's nothing to move (neither corner + handles nor arrow handles in scene). + """ + if held == self._ctrl_held: + return + self._ctrl_held = held + if not (self._handles or self._arrow_handles): + return + if held: + self.viewport().setCursor(Qt.CrossCursor) + else: + self.viewport().unsetCursor() + + def set_alt_held(self, held: bool): + """Track Alt for the alternate-line annotation gesture. + + Same pattern as ``set_ctrl_held``: while Alt is held, the viewport + cursor is forced to ``CrossCursor`` to signal that a left-click + starts an alternate-line annotation. On release, the cursor + reverts; if a partial annotation is in progress (one click made, + rubber-band visible), it survives the release so the user can + complete the second click without holding Alt continuously. + """ + if held == self._alt_held: + return + self._alt_held = held + if held: + self.viewport().setCursor(Qt.CrossCursor) + else: + self.viewport().unsetCursor() + + def set_gray_pick_armed(self, armed: bool): + """Arm/disarm gray-card pick mode for the next left-click. + + Same cursor pattern as ``set_ctrl_held``: while armed the + viewport shows ``CrossCursor`` to signal that a click will sample + the gray card; on disarm the cursor reverts and the next mouse + move re-runs the normal hover-cursor logic. + """ + if armed == self._gray_pick_armed: + return + self._gray_pick_armed = armed + if armed: + self.viewport().setCursor(Qt.CrossCursor) + else: + self.viewport().unsetCursor() + + def set_gray_marker(self, center: tuple[float, float] | None, radius: int): + """Draw (or clear) the red outline of the sampled square. + + *center* is in image-pixel coordinates (= scene coordinates). The + sampled square is *radius* × *radius* image pixels (radius 1 = a + single pixel); the outline is a 2-image-pixel ring drawn just + outside it, so the sampled pixels sit inside the ring rather than + under it. Both scale with zoom. Passing ``None`` removes it. + """ + if self._gray_marker is not None and self._gray_marker.scene() is not None: + self._scene.removeItem(self._gray_marker) + self._gray_marker = None + if center is None: + return + x0, y0, side = patch_square(center[0], center[1], radius) + # The sampled pixels occupy the scene rect [x0, x0+side] × [y0, + # y0+side]. Place the outline's rect boundary 1 px outside that, so + # the centered 2-wide pen fills the ring [x0-2, x0] (and the far + # side), leaving the sampled pixels uncovered. Net extent: side+4. + rect = QRectF(x0 - 1, y0 - 1, side + 2, side + 2) + item = self._scene.addRect(rect, GRAY_MARKER_PEN) + item.setZValue(6) # above edge lines (z=5) and pixmap + self._gray_marker = item + + def _start_annotation(self, scene_pos: QPointF): + """First Alt+Click: pin one endpoint and start the rubber-band line.""" + self._annotation_first_point = scene_pos + line = QGraphicsLineItem( + scene_pos.x(), scene_pos.y(), scene_pos.x(), scene_pos.y(), + ) + line.setPen(RED_PEN_DASHED) + line.setZValue(20) + self._scene.addItem(line) + self._annotation_rubber_line = line + + def _finalize_annotation(self, scene_pos: QPointF): + """Second Alt+Click: convert the rubber-band into a confirmed line. + + Creates a solid red ``QGraphicsLineItem`` between the two clicked + points plus two red ``AnnotationArrowHandle`` items at the + endpoints. The handles are wired to each other and to the line + so dragging an endpoint live-updates the line and both arrow + rotations. + """ + first = self._annotation_first_point + if first is None: + return + if self._annotation_rubber_line is not None: + self._scene.removeItem(self._annotation_rubber_line) + self._annotation_rubber_line = None + self._annotation_first_point = None + line = QGraphicsLineItem(first.x(), first.y(), scene_pos.x(), scene_pos.y()) + line.setPen(RED_PEN) + line.setZValue(20) + self._scene.addItem(line) + + image_rect = self.get_image_rect() + h0 = AnnotationArrowHandle(first, image_rect, self) + h1 = AnnotationArrowHandle(scene_pos, image_rect, self) + h0.paired_handle = h1; h0.line_item = line + h1.paired_handle = h0; h1.line_item = line + self._scene.addItem(h0) + self._scene.addItem(h1) + h0.update_rotation_from(first, scene_pos) + h1.update_rotation_from(scene_pos, first) + self._annotations.append((line, h0, h1)) + + def clear_annotations(self): + """Remove every alternate-line annotation, its arrow handles, and any + in-progress rubber-band. + + Called by the Esc key handler (general "clear temporary marks" + policy) and on every new-image load (annotations are image-specific). + Safe to call when nothing's been drawn. + """ + if self._annotation_rubber_line is not None: + if self._annotation_rubber_line.scene() is not None: + self._scene.removeItem(self._annotation_rubber_line) + self._annotation_rubber_line = None + self._annotation_first_point = None + for line, h0, h1 in self._annotations: + for item in (line, h0, h1): + if item.scene() is not None: + self._scene.removeItem(item) + self._annotations.clear() + + def _snap_nearest_to(self, scene_pos: QPointF) -> bool: + """Move the nearest movable point (arrow handle or quad corner) to scene_pos. + + Returns True if a point was moved. Used by the Ctrl+Left-Click + gesture: the user identifies a precise target position (typically + while zoomed in) and the closest existing point jumps there. + Routed through ``setPos`` so the existing itemChange clamping and + ``on_*_moved`` updates fire, then ``on_*_released`` is invoked so + the change is recorded in the undo stack as a single edit. + """ + candidates: list[tuple[float, object, str]] = [] + px, py = scene_pos.x(), scene_pos.y() + for ah in self._arrow_handles: + d = (ah.pos().x() - px) ** 2 + (ah.pos().y() - py) ** 2 + candidates.append((d, ah, 'arrow')) + for ch in self._handles: + d = (ch.pos().x() - px) ** 2 + (ch.pos().y() - py) ** 2 + candidates.append((d, ch, 'corner')) + for _, h0, h1 in self._annotations: + for h in (h0, h1): + d = (h.pos().x() - px) ** 2 + (h.pos().y() - py) ** 2 + candidates.append((d, h, 'annotation')) + if not candidates: + return False + candidates.sort(key=lambda t: t[0]) + _, item, kind = candidates[0] + item.setPos(scene_pos) # itemChange clamps to image rect + # For keystone/quad handles, _update_* was called from inside + # itemChange before Qt committed the new position — they read the + # OLD pos. During a drag this is invisible because the next move + # event fixes the 1-frame lag, but for a one-shot click we must + # refresh now that the position is committed. Annotation handles + # update their line and rotations directly from clamped (the new + # position) inside their own itemChange, so no extra refresh. + if kind == 'arrow': + self._update_pair_lines() + elif kind == 'corner': + self._update_lines() + # Reset the drag-state brush and notify the parent (annotations + # don't participate in undo/right-panel updates). + if kind == 'annotation': + item.setBrush(RED_BRUSH) + else: + item.setBrush(GREEN_BRUSH) + if kind == 'arrow': + self.on_arrow_released(item.pair_index, item.endpoint_index) + elif kind == 'corner': + self.on_corner_released(item.index) + return True + + def _find_nearest_line(self, scene_pos: QPointF) -> tuple[int, int] | None: + """Find the nearest keystone line within LINE_GRAB_DISTANCE. + + Returns (pair_index, line_index_within_pair) or None. + line_index_within_pair is 0 (first line) or 1 (second line). + """ + if not self._arrow_handles: + return None + px, py = scene_pos.x(), scene_pos.y() + # Convert LINE_GRAB_DISTANCE from screen pixels to scene units + grab_dist = LINE_GRAB_DISTANCE / self.transform().m11() + best = None + best_dist = grab_dist + for i in range(len(self._pair_lines)): + h1 = self._arrow_handles[i * 2] + h2 = self._arrow_handles[i * 2 + 1] + dist = self._point_to_segment_dist( + px, py, h1.pos().x(), h1.pos().y(), + h2.pos().x(), h2.pos().y()) + if dist < best_dist: + best_dist = dist + pair_idx = i // 2 + line_in_pair = i % 2 + best = (pair_idx, line_in_pair) + return best + + def select_handle(self, index: int | None): + """Select a corner handle for arrow key nudging.""" + self._selected_handle = index + self._selected_edge = None + + def _find_nearest_edge(self, scene_pos: QPointF) -> int | None: + """Return the index of the edge nearest to scene_pos. + + Edges are indexed 0–3: edge i connects corner i to corner (i+1)%4. + """ + if len(self._handles) != 4: + return None + best_dist = float('inf') + best_edge = None + px, py = scene_pos.x(), scene_pos.y() + for i in range(4): + p1 = self._handles[i].pos() + p2 = self._handles[(i + 1) % 4].pos() + # Distance from point to line segment + dist = self._point_to_segment_dist( + px, py, p1.x(), p1.y(), p2.x(), p2.y() + ) + if dist < best_dist: + best_dist = dist + best_edge = i + return best_edge + + @staticmethod + def _point_to_segment_dist(px, py, x1, y1, x2, y2) -> float: + """Distance from point (px,py) to line segment (x1,y1)-(x2,y2).""" + dx, dy = x2 - x1, y2 - y1 + length_sq = dx * dx + dy * dy + if length_sq < 1e-10: + return ((px - x1) ** 2 + (py - y1) ** 2) ** 0.5 + t = max(0, min(1, ((px - x1) * dx + (py - y1) * dy) / length_sq)) + proj_x = x1 + t * dx + proj_y = y1 + t * dy + return ((px - proj_x) ** 2 + (py - proj_y) ** 2) ** 0.5 + + def _get_corners_array(self) -> np.ndarray | None: + """Return current handle positions as a (4,2) float32 array.""" + if len(self._handles) != 4: + return None + return np.array([[h.pos().x(), h.pos().y()] for h in self._handles], + dtype=np.float32) + + def _nudge_in_rectified_space(self, rect_deltas: np.ndarray, active_indices: list[int] | None = None): + """Apply per-corner deltas in rectified space, mapping back to original. + + rect_deltas is a (4, 2) array of (dx, dy) for each corner in + the coordinate system of the rectified output rectangle. + The deltas are mapped back through the inverse perspective + transform to update the handles in original image space. + + active_indices: corners to highlight yellow during the nudge. + """ + from rectify.transform import compute_output_size + corners = self._get_corners_array() + if corners is None: + return + + width, height = compute_output_size(corners) + if width < 2 or height < 2: + return + + # Destination rectangle (rectified space) + dst = np.array([ + [0, 0], [width - 1, 0], + [width - 1, height - 1], [0, height - 1], + ], dtype=np.float32) + + # Apply deltas in rectified space + new_dst = dst + rect_deltas + + # Map back: inverse transform from new rectified positions to original + inv_matrix = cv2.getPerspectiveTransform(dst, corners) + new_pts = cv2.perspectiveTransform( + new_dst.reshape(-1, 1, 2), inv_matrix + ).reshape(4, 2) + + # Update handles, highlighting active ones + rect = self.get_image_rect() + for i in range(4): + x, y = float(new_pts[i][0]), float(new_pts[i][1]) + if rect: + x = max(rect.left(), min(x, rect.right())) + y = max(rect.top(), min(y, rect.bottom())) + self._handles[i].setPos(QPointF(x, y)) + if active_indices is not None and i in active_indices: + self._handles[i].setBrush(YELLOW_BRUSH) + self.corner_moved.emit(i, x, y, False) + self._update_lines() + self.corner_released.emit(0) + + def keyPressEvent(self, event: QKeyEvent): + """Arrow keys nudge in rectified space; Shift highlights for wheel adjust. + + - Corner selected: move that corner by 1px in rectified space + - Edge selected: move both corners of that edge by 1px + perpendicular or parallel to the edge (in rectified space, + edges are axis-aligned, so this is trivially up/down or left/right) + - Whole selected (center click): up/down expands/shrinks all edges + - Shift: highlight quad element under cursor for wheel adjustment + """ + if event.key() == Qt.Key_Shift and not event.isAutoRepeat(): + cursor_pos = self.mapFromGlobal(self.cursor().pos()) + scene_pos = self.mapToScene(cursor_pos) + element, index = self._find_element_at(scene_pos) + self._shift_element = element + self._shift_element_index = index + radius = self._corner_select_radius_scene() + dbg("gui", f"Shift press: element={element} index={index} " + f"scene=({scene_pos.x():.1f},{scene_pos.y():.1f}) " + f"corner_radius={radius:.1f}") + if element == "corner" and index is not None: + handle = self._handles[index] + dist = ((handle.pos().x() - scene_pos.x())**2 + + (handle.pos().y() - scene_pos.y())**2) ** 0.5 + dbg("gui", f" corner[{index}] at ({handle.pos().x():.1f}," + f"{handle.pos().y():.1f}), dist={dist:.1f}") + self._handles[index].setBrush(YELLOW_BRUSH) + self.viewport().setCursor(Qt.ClosedHandCursor) + elif element == "edge" and index is not None: + self._highlight_edge(index) + self.viewport().setCursor(Qt.ClosedHandCursor) + elif element == "center": + self._highlight_all_edges() + self.viewport().setCursor(Qt.ClosedHandCursor) + event.accept() + return + + arrow_dx, arrow_dy = 0, 0 + if event.key() == Qt.Key_Left: + arrow_dx = -1 + elif event.key() == Qt.Key_Right: + arrow_dx = 1 + elif event.key() == Qt.Key_Up: + arrow_dy = -1 + elif event.key() == Qt.Key_Down: + arrow_dy = 1 + + if arrow_dx == 0 and arrow_dy == 0: + super().keyPressEvent(event) + return + + if self._selected_whole and self._handles: + # Whole rectangle mode: up/right expands, down/left shrinks + amount = -(arrow_dx + arrow_dy) # up(-1)/right(+1) → positive = expand + if amount == 0: + super().keyPressEvent(event) + return + # Move each corner outward (expand) or inward (shrink) + # In rectified space: TL moves (-1,-1), TR moves (+1,-1), etc. + deltas = np.array([ + [-amount, -amount], # TL + [+amount, -amount], # TR + [+amount, +amount], # BR + [-amount, +amount], # BL + ], dtype=np.float32) + self._nudge_in_rectified_space(deltas, active_indices=[0, 1, 2, 3]) + event.accept() + return + + if self._selected_edge is not None and self._handles: + # Edge mode in rectified space. Edges in rectified space: + # Edge 0 (TL-TR): top edge, horizontal → up/down moves it vertically + # Edge 1 (TR-BR): right edge, vertical → left/right moves it horizontally + # Edge 2 (BR-BL): bottom edge, horizontal → up/down moves it vertically + # Edge 3 (BL-TL): left edge, vertical → left/right moves it horizontally + edge = self._selected_edge + i1 = edge + i2 = (edge + 1) % 4 + deltas = np.zeros((4, 2), dtype=np.float32) + + if edge in (0, 2): # horizontal edge: respond to up/down + if arrow_dy != 0: + deltas[i1][1] = arrow_dy + deltas[i2][1] = arrow_dy + elif arrow_dx != 0: + # left/right shifts along the edge + deltas[i1][0] = arrow_dx + deltas[i2][0] = arrow_dx + else: # vertical edge: respond to left/right + if arrow_dx != 0: + deltas[i1][0] = arrow_dx + deltas[i2][0] = arrow_dx + elif arrow_dy != 0: + # up/down shifts along the edge + deltas[i1][1] = arrow_dy + deltas[i2][1] = arrow_dy + + if np.any(deltas != 0): + self._nudge_in_rectified_space(deltas, active_indices=[i1, i2]) + event.accept() + return + + if self._selected_handle is not None and self._handles: + # Single corner mode: move in rectified space + deltas = np.zeros((4, 2), dtype=np.float32) + deltas[self._selected_handle] = [arrow_dx, arrow_dy] + self._nudge_in_rectified_space(deltas, active_indices=[self._selected_handle]) + event.accept() + return + + super().keyPressEvent(event) + + def keyReleaseEvent(self, event: QKeyEvent): + if event.key() == Qt.Key_Shift and not event.isAutoRepeat(): + # Restore default pens (green or blue for alignment) + for handle in self._handles: + handle.setBrush(GREEN_BRUSH) + self._restore_all_default_pens() + self._shift_element = "none" + self._shift_element_index = None + # Set cursor based on current position + if len(self._handles) == 4: + cursor_pos = self.mapFromGlobal(self.cursor().pos()) + scene_pos = self.mapToScene(cursor_pos) + element, _ = self._find_element_at(scene_pos) + if element == "corner": + self.viewport().setCursor(Qt.CrossCursor) + elif element == "edge": + self.viewport().setCursor(Qt.SizeAllCursor) + elif element == "center": + self.viewport().setCursor(Qt.OpenHandCursor) + else: + self.viewport().unsetCursor() + else: + self.viewport().unsetCursor() + event.accept() + return + super().keyReleaseEvent(event) + + def _is_in_center_region(self, scene_pos: QPointF) -> bool: + """Return True if the point is in the inner region of the quad. + + The inner region is defined by shrinking the quad by 25% on each + side — clicking here selects the whole rectangle for expand/shrink. + """ + if len(self._handles) != 4: + return False + corners = self._get_corners_array() + if corners is None: + return False + center = corners.mean(axis=0) + # Shrink corners toward center by 25% + shrunk = center + (corners - center) * 0.5 + # Point-in-polygon test on the shrunk quad + pt = np.array([scene_pos.x(), scene_pos.y()], dtype=np.float32) + result = cv2.pointPolygonTest(shrunk.reshape(-1, 1, 2), (float(pt[0]), float(pt[1])), False) + return result >= 0 + + def mousePressEvent(self, event): + """Select a corner handle, nearest edge, or center for arrow key control. + + Determines the click target BEFORE calling super() to prevent Qt + from grabbing a corner handle when the user clicks in the center + region (at certain zoom levels, ItemIgnoresTransformations makes + handle hit areas larger in scene space than our manhattan check). + Right-click (without left held) resets zoom via ImagePanel. + Ctrl+Left-Click moves the nearest movable point (arrow handle in + Lines mode, corner in Extract mode) to the click position. + """ + if (event.button() == Qt.RightButton + and not (event.buttons() & Qt.LeftButton)): + super().mousePressEvent(event) + return + + scene_pos = self.mapToScene(event.pos()) + + # Gray-card pick: when armed, a plain left-click reports the + # click position and consumes the event before any other gesture. + if event.button() == Qt.LeftButton and self._gray_pick_armed: + self.gray_point_picked.emit(scene_pos.x(), scene_pos.y()) + event.accept() + return + + if (event.button() == Qt.LeftButton + and event.modifiers() & Qt.ControlModifier): + if self._snap_nearest_to(scene_pos): + event.accept() + return + + # Alt+Left-Click: place an alternate-line endpoint. First click + # starts a rubber-band; second click finalises the red line. + if (event.button() == Qt.LeftButton + and event.modifiers() & Qt.AltModifier): + if self._annotation_first_point is None: + self._start_annotation(scene_pos) + else: + self._finalize_annotation(scene_pos) + event.accept() + return + + # Plain left-click on an annotation handle: let Qt dispatch the + # press to the item, which (with ItemIsMovable set) starts an + # endpoint drag. Without this check, in Lines mode the click + # falls through to the keystone-line-grab logic and in Extract mode + # to the pan branch, both of which would intercept the drag. + if (event.button() == Qt.LeftButton + and not (event.modifiers() + & (Qt.ControlModifier | Qt.AltModifier))): + radius = self._corner_select_radius_scene() + for _line, h0, h1 in self._annotations: + for h in (h0, h1): + dx = h.pos().x() - scene_pos.x() + dy = h.pos().y() - scene_pos.y() + if (dx * dx + dy * dy) ** 0.5 < radius: + # Make sure drag mode is NoDrag so Qt doesn't + # treat this as a pan; the handle's + # ItemIsMovable will then start an item drag. + self.setDragMode(QGraphicsView.NoDrag) + super().mousePressEvent(event) + return + + # ── Keystone line drag ── + if self._arrow_handles and not self._handles: + # Check arrow handles first — let Qt handle endpoint drags + radius = self._corner_select_radius_scene() + on_handle = False + for ah in self._arrow_handles: + dx = ah.pos().x() - scene_pos.x() + dy = ah.pos().y() - scene_pos.y() + if (dx * dx + dy * dy) ** 0.5 < radius: + on_handle = True + break + if on_handle: + super().mousePressEvent(event) + return + # Not on a handle — check for line grab + hit = self._find_nearest_line(scene_pos) + if hit is not None: + pair_idx, line_in_pair = hit + self._line_drag_active = True + self._line_drag_start = scene_pos + self._line_drag_pair = pair_idx + self._line_drag_line = line_in_pair + gi = pair_idx * 2 + line_in_pair + h1 = self._arrow_handles[gi * 2] + h2 = self._arrow_handles[gi * 2 + 1] + self._line_drag_start_pts = np.array([ + [h1.pos().x(), h1.pos().y()], + [h2.pos().x(), h2.pos().y()], + ], dtype=np.float32) + self._pair_lines[gi].setPen(YELLOW_PEN) + self.setDragMode(QGraphicsView.NoDrag) + self.viewport().setCursor(Qt.ClosedHandCursor) + event.accept() + return + # Not near a line — allow pan + self._panning = True + self._update_drag_mode() + self.viewport().setCursor(Qt.ClosedHandCursor) + super().mousePressEvent(event) + return + + element, index = self._find_element_at(scene_pos) + + if element == "corner" and index is not None: + super().mousePressEvent(event) + self._selected_handle = index + self._selected_edge = None + self._selected_whole = False + self._handles[index].setBrush(YELLOW_BRUSH) + self.setDragMode(QGraphicsView.NoDrag) + self.viewport().setCursor(Qt.ClosedHandCursor) + return + + if element == "center": + # Do NOT call super() — prevent Qt from grabbing a handle. + self._selected_whole = True + self._selected_handle = None + self._selected_edge = None + self._center_drag_active = True + self._center_drag_start = scene_pos + self._center_drag_corners_start = self._get_corners_array().copy() + self._highlight_all_edges() + self.setDragMode(QGraphicsView.NoDrag) + self.viewport().setCursor(Qt.ClosedHandCursor) + corners = self._center_drag_corners_start + dbg("gui", f"center-drag START at ({scene_pos.x():.1f},{scene_pos.y():.1f})") + for i in range(4): + dbg("gui", f" corner[{i}] = ({corners[i][0]:.1f},{corners[i][1]:.1f})") + self.center_drag_started.emit() + event.accept() + return + + if element == "edge" and index is not None: + self._selected_edge = index + self._selected_handle = None + self._selected_whole = False + self._edge_drag_active = True + self._edge_drag_start = scene_pos + self._edge_drag_corners_start = self._get_corners_array().copy() + self._edge_drag_index = index + self._highlight_edge(index) + self.setDragMode(QGraphicsView.NoDrag) + self.viewport().setCursor(Qt.ClosedHandCursor) + event.accept() + return + + # Click outside the quad — enable pan if image is zoomed in + self._selected_handle = None + self._selected_edge = None + self._selected_whole = False + self._panning = True + self._update_drag_mode() # sets ScrollHandDrag if pannable + self.viewport().setCursor(Qt.ClosedHandCursor) + super().mousePressEvent(event) # starts the drag if ScrollHandDrag + + def _highlight_edge(self, edge_idx: int): + """Visually highlight the selected edge.""" + for i, line in enumerate(self._lines): + if i == edge_idx: + line.setPen(YELLOW_PEN) # yellow, thicker + else: + self._set_edge_default_pen(i) + + def _highlight_all_edges(self): + """Highlight all edges (whole-rectangle mode).""" + for line in self._lines: + line.setPen(YELLOW_PEN) + + def _set_edge_default_pen(self, edge_idx: int): + """Set an edge's pen to blue (aligned) or green (not aligned).""" + if len(self._handles) != 4: + return + p1 = self._handles[edge_idx].pos() + p2 = self._handles[(edge_idx + 1) % 4].pos() + aligned = (int(round(p1.x())) == int(round(p2.x())) + or int(round(p1.y())) == int(round(p2.y()))) + self._lines[edge_idx].setPen(BLUE_PEN if aligned else GREEN_PEN) + + def _restore_all_default_pens(self): + """Restore all edges to their default pens (green or blue).""" + for i in range(len(self._lines)): + self._set_edge_default_pen(i) + + def mouseMoveEvent(self, event): + # ── Alternate-line rubber-band ── + # Tracks the cursor between the two Alt+Clicks that define an + # annotation line. Runs first so it stays responsive while other + # interactions (drag, pan) are not active. + if self._annotation_first_point is not None \ + and self._annotation_rubber_line is not None: + scene_pos = self.mapToScene(event.pos()) + first = self._annotation_first_point + self._annotation_rubber_line.setLine( + first.x(), first.y(), scene_pos.x(), scene_pos.y(), + ) + # ── Keystone line drag ── + if self._line_drag_active and self._line_drag_start is not None: + scene_pos = self.mapToScene(event.pos()) + dx = scene_pos.x() - self._line_drag_start.x() + dy = scene_pos.y() - self._line_drag_start.y() + gi = self._line_drag_pair * 2 + self._line_drag_line + rect = self.get_image_rect() + for k in range(2): + new_x = float(self._line_drag_start_pts[k][0] + dx) + new_y = float(self._line_drag_start_pts[k][1] + dy) + if rect: + new_x = max(rect.left(), min(new_x, rect.right())) + new_y = max(rect.top(), min(new_y, rect.bottom())) + handle = self._arrow_handles[gi * 2 + k] + handle.setPos(QPointF(new_x, new_y)) + self._update_pair_lines() + event.accept() + return + # ── Keystone line hover cursor ── + # Skip while Ctrl/Alt is held — those modifiers set their own + # CrossCursor (snap-nearest / annotation arming) and the hover + # logic would override it on the next mouse move. + if (self._arrow_handles and not self._handles + and not self._line_drag_active and not self._panning + and not self._ctrl_held and not self._alt_held + and not self._gray_pick_armed + and not (event.buttons() & Qt.LeftButton)): + scene_pos = self.mapToScene(event.pos()) + radius = self._corner_select_radius_scene() + on_handle = False + for ah in self._arrow_handles: + dx = ah.pos().x() - scene_pos.x() + dy = ah.pos().y() - scene_pos.y() + if (dx * dx + dy * dy) ** 0.5 < radius: + on_handle = True + break + if on_handle: + self.viewport().setCursor(Qt.CrossCursor) + elif self._find_nearest_line(scene_pos) is not None: + self.viewport().setCursor(Qt.SizeAllCursor) + else: + self.viewport().unsetCursor() + super().mouseMoveEvent(event) + return + if self._edge_drag_active and self._edge_drag_start is not None: + scene_pos = self.mapToScene(event.pos()) + dx = scene_pos.x() - self._edge_drag_start.x() + dy = scene_pos.y() - self._edge_drag_start.y() + edge = self._edge_drag_index + start = self._edge_drag_corners_start + i1 = edge + i2 = (edge + 1) % 4 + # Edge direction and perpendicular (outward normal) + ex = start[i2][0] - start[i1][0] + ey = start[i2][1] - start[i1][1] + length = (ex * ex + ey * ey) ** 0.5 + if length > 1e-10: + # Normal perpendicular to edge (pointing outward from quad center) + nx, ny = -ey / length, ex / length + center = start.mean(axis=0) + mid = (start[i1] + start[i2]) / 2 + outward = mid + np.array([nx, ny]) - center + if np.dot(outward, np.array([nx, ny])) < 0: + nx, ny = -nx, -ny + # Project mouse displacement onto the normal + proj = dx * nx + dy * ny + move_x = proj * nx + move_y = proj * ny + else: + move_x, move_y = dx, dy + rect = self.get_image_rect() + for i in (i1, i2): + new_x = start[i][0] + move_x + new_y = start[i][1] + move_y + if rect: + new_x = max(rect.left(), min(new_x, rect.right())) + new_y = max(rect.top(), min(new_y, rect.bottom())) + self._handles[i].setPos(QPointF(new_x, new_y)) + self._update_lines() + event.accept() + return + if self._center_drag_active and self._center_drag_start is not None: + scene_pos = self.mapToScene(event.pos()) + dx = scene_pos.x() - self._center_drag_start.x() + dy = scene_pos.y() - self._center_drag_start.y() + rect = self.get_image_rect() + start = self._center_drag_corners_start + clamped_any = False + for i in range(4): + nx = start[i][0] + dx + ny = start[i][1] + dy + if rect: + cx = max(rect.left(), min(nx, rect.right())) + cy = max(rect.top(), min(ny, rect.bottom())) + if cx != nx or cy != ny: + clamped_any = True + nx, ny = cx, cy + # setPos triggers itemChange → on_corner_moved → corner_moved signal + self._handles[i].setPos(QPointF(nx, ny)) + self._update_lines() + if clamped_any: + dbg("gui", f"center-drag: clamped to image bounds " + f"(dx={dx:.1f}, dy={dy:.1f})") + event.accept() + return + # Update cursor based on quad element under the mouse + # (skip while any drag, pan, Shift-select, Ctrl-snap-preview, or + # Alt-annotation-preview is active — those modifiers set their + # own cursor and the hover logic would override it on each move) + if (not self._center_drag_active and not self._edge_drag_active + and not self._panning + and not self._ctrl_held + and not self._alt_held + and not self._gray_pick_armed + and not (event.buttons() & Qt.LeftButton) + and self._shift_element == "none" + and len(self._handles) == 4): + scene_pos = self.mapToScene(event.pos()) + element, _ = self._find_element_at(scene_pos) + if element == "corner": + self.viewport().setCursor(Qt.CrossCursor) + elif element == "edge": + self.viewport().setCursor(Qt.SizeAllCursor) + elif element == "center": + self.viewport().setCursor(Qt.OpenHandCursor) + else: + self.viewport().unsetCursor() + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + was_line_drag = self._line_drag_active + was_center_drag = self._center_drag_active + was_edge_drag = self._edge_drag_active + was_panning = self._panning + self._line_drag_active = False + self._line_drag_start = None + self._line_drag_start_pts = None + self._center_drag_active = False + self._center_drag_start = None + self._center_drag_corners_start = None + self._edge_drag_active = False + self._edge_drag_start = None + self._edge_drag_corners_start = None + self._edge_drag_index = None + self._panning = False + if was_line_drag: + # Restore line pens + handle brushes (blue if axis-aligned, + # else green) for every line. We iterate all lines rather + # than just the dragged one because handles of other lines + # may have temporarily been set to yellow earlier; this is + # the cheap canonical refresh. + for i in range(len(self._pair_lines)): + self._set_pair_line_default_pen(i) + self.arrow_released.emit(self._line_drag_pair, + self._line_drag_line * 2) + self._line_drag_pair = None + self._line_drag_line = None + self.viewport().unsetCursor() + event.accept() + return + if not was_center_drag and not was_edge_drag: + super().mouseReleaseEvent(event) + self._update_drag_mode() + # Set cursor based on what's under the mouse now + if len(self._handles) == 4: + scene_pos = self.mapToScene(event.pos()) + element, _ = self._find_element_at(scene_pos) + if element == "corner": + self.viewport().setCursor(Qt.CrossCursor) + elif element == "edge": + self.viewport().setCursor(Qt.SizeAllCursor) + elif element == "center": + self.viewport().setCursor(Qt.OpenHandCursor) + else: + self.viewport().unsetCursor() + else: + self.viewport().unsetCursor() + # Restore default pens (green or blue for alignment) + self._restore_all_default_pens() + for handle in self._handles: + handle.setBrush(GREEN_BRUSH) + if was_center_drag: + self.region_moved = True + corners = self._get_corners_array() + if corners is not None: + dbg("gui", f"center-drag END") + for i in range(4): + dbg("gui", f" corner[{i}] = ({corners[i][0]:.1f},{corners[i][1]:.1f})") + # Update the right panel to reflect the new position. + self.corner_released.emit(0) + event.accept() + if was_edge_drag: + self.corner_released.emit(0) + event.accept() + + def _corner_select_radius_scene(self) -> float: + """Return CORNER_SELECT_RADIUS in scene coordinates. + + CORNER_SELECT_RADIUS is defined in screen pixels. We convert + to scene units by dividing by the current view scale so the + hit area stays constant on screen regardless of zoom level. + """ + scale = self.transform().m11() # horizontal scale factor + if abs(scale) < 1e-10: + return float(CORNER_SELECT_RADIUS) + return CORNER_SELECT_RADIUS / abs(scale) + + def _find_element_at(self, scene_pos: QPointF) -> tuple[str, int | None]: + """Identify the quad element at scene_pos. + + Returns ("corner", index), ("edge", index), ("center", None), + or ("none", None). + """ + if len(self._handles) != 4: + return ("none", None) + # Corner? Use Euclidean distance with zoom-adjusted radius + radius = self._corner_select_radius_scene() + for i, handle in enumerate(self._handles): + dx = handle.pos().x() - scene_pos.x() + dy = handle.pos().y() - scene_pos.y() + if (dx * dx + dy * dy) ** 0.5 < radius: + return ("corner", i) + # Center? + if self._is_in_center_region(scene_pos): + return ("center", None) + # Edge? Selectable inside the quad or within radius outside it + edge = self._find_nearest_edge(scene_pos) + if edge is not None: + px, py = scene_pos.x(), scene_pos.y() + p1 = self._handles[edge].pos() + p2 = self._handles[(edge + 1) % 4].pos() + dist = self._point_to_segment_dist( + px, py, p1.x(), p1.y(), p2.x(), p2.y()) + # Inside the quad: always selectable + corners = self._get_corners_array() + if corners is not None: + inside = cv2.pointPolygonTest( + corners.reshape(-1, 1, 2), + (px, py), False) >= 0 + if inside or dist < radius: + return ("edge", edge) + return ("none", None) + + def _wheel_adjust_element(self, element: str, index: int | None, + delta: int, event: QWheelEvent) -> bool: + """Apply scroll-wheel adjustment to a quad element. + + Returns True if handled. + """ + corners = self._get_corners_array() + if corners is None: + return False + rect = self.get_image_rect() + amount = 2.0 if delta > 0 else -2.0 + + if element == "center": + # Scale around centroid + centroid = corners.mean(axis=0) + scale = 1.03 if delta > 0 else 1.0 / 1.03 + for i in range(4): + vec = corners[i] - centroid + new_pt = centroid + vec * scale + nx, ny = float(new_pt[0]), float(new_pt[1]) + if rect: + nx = max(rect.left(), min(nx, rect.right())) + ny = max(rect.top(), min(ny, rect.bottom())) + self._handles[i].setPos(QPointF(nx, ny)) + self._highlight_all_edges() + + elif element == "corner" and index is not None: + # Move corner along vector to/from centroid + centroid = corners.mean(axis=0) + vec = corners[index] - centroid + length = np.linalg.norm(vec) + if length < 1e-10: + return False + direction = vec / length + new_pt = corners[index] + direction * amount + nx, ny = float(new_pt[0]), float(new_pt[1]) + if rect: + nx = max(rect.left(), min(nx, rect.right())) + ny = max(rect.top(), min(ny, rect.bottom())) + self._handles[index].setPos(QPointF(nx, ny)) + self._handles[index].setBrush(YELLOW_BRUSH) + + elif element == "edge" and index is not None: + # Move edge perpendicular to itself + i1 = index + i2 = (index + 1) % 4 + ex = corners[i2][0] - corners[i1][0] + ey = corners[i2][1] - corners[i1][1] + length = (ex * ex + ey * ey) ** 0.5 + if length < 1e-10: + return False + # Normal perpendicular to edge, pointing outward + nx_dir, ny_dir = -ey / length, ex / length + centroid = corners.mean(axis=0) + mid = (corners[i1] + corners[i2]) / 2 + if np.dot(mid + np.array([nx_dir, ny_dir]) - centroid, + np.array([nx_dir, ny_dir])) < 0: + nx_dir, ny_dir = -nx_dir, -ny_dir + move_x = amount * nx_dir + move_y = amount * ny_dir + for i in (i1, i2): + new_x = corners[i][0] + move_x + new_y = corners[i][1] + move_y + if rect: + new_x = max(rect.left(), min(new_x, rect.right())) + new_y = max(rect.top(), min(new_y, rect.bottom())) + self._handles[i].setPos(QPointF(new_x, new_y)) + self._highlight_edge(index) + + else: + return False + + self._update_lines() + return True + + def wheelEvent(self, event: QWheelEvent): + """Scroll wheel adjusts quad elements, or zooms. + + - Left-button held on an element + wheel: adjust that element + - Shift + wheel (no button): adjust element under cursor + - Otherwise: zoom + """ + delta = event.angleDelta().y() + if delta == 0: + super().wheelEvent(event) + return + + # Left-button held: use the active drag element + left_held = event.buttons() & Qt.LeftButton + shift_held = event.modifiers() & Qt.ShiftModifier + + if (left_held or shift_held) and len(self._handles) == 4: + if left_held: + # Use the currently active element from the drag + if self._center_drag_active: + element, index = "center", None + elif self._edge_drag_active: + element, index = "edge", self._edge_drag_index + elif self._selected_handle is not None: + element, index = "corner", self._selected_handle + else: + element, index = "none", None + else: + # Shift+wheel: use the element detected on Shift press + element, index = self._shift_element, self._shift_element_index + + if element != "none" and self._wheel_adjust_element(element, index, delta, event): + # Update drag baselines for center-drag + if self._center_drag_active: + self._center_drag_corners_start = self._get_corners_array().copy() + self._center_drag_start = self.mapToScene( + self.mapFromGlobal(event.globalPosition().toPoint())) + # Update drag baselines for edge-drag + if self._edge_drag_active: + self._edge_drag_corners_start = self._get_corners_array().copy() + self._edge_drag_start = self.mapToScene( + self.mapFromGlobal(event.globalPosition().toPoint())) + # For Shift+wheel (no drag active), emit release to update right panel + if not left_held: + self.corner_released.emit(0) + event.accept() + return + + super().wheelEvent(event) + + +# ────────────────────────────────────────────────────────────────── +# Shortcut overlay +# ────────────────────────────────────────────────────────────────── + +class ShortcutOverlay(QWidget): + """Frameless tooltip-style panel listing keyboard shortcuts. + + Shown while the Alt key is held. Sized to fit content (sizeHint) + with internal padding; centered over the main window by the caller. + The widget is non-focusable so showing it does not steal focus from + the underlying window. + """ + + PADDING = 18 # internal margin around content (px) + + def __init__(self, parent: QWidget | None = None): + flags = ( + Qt.Tool + | Qt.FramelessWindowHint + | Qt.WindowStaysOnTopHint + | Qt.WindowDoesNotAcceptFocus + ) + super().__init__(parent, flags) + self.setAttribute(Qt.WA_ShowWithoutActivating) + self.setFocusPolicy(Qt.NoFocus) + # Drop shadow / panel style — semi-transparent dark background, + # light text, subtle border, rounded corners. + self.setStyleSheet( + "ShortcutOverlay {" + " background-color: rgba(40, 40, 40, 235);" + " border: 1px solid #888;" + " border-radius: 12px;" + "}" + "QLabel { color: #f0f0f0; background: transparent; }" + "QLabel#sk_title { font-weight: bold; }" + "QLabel#sk_section { color: #c8c8c8; font-weight: bold; }" + "QLabel#sk_key { color: #ffe48a; }" + "QFrame#sk_sep { color: #555; }" + ) + self._content: QWidget | None = None + self._build() + + # Sections come from rectify.shortcuts so the CLI --help epilog + # stays in sync with this overlay. + SECTIONS = SHORTCUT_SECTIONS + + def _build(self): + """Build the layout from SECTIONS, using current translations.""" + # Discard any existing content widget (called on retranslate). + if self._content is not None: + self._content.deleteLater() + self._content = None + + outer = self.layout() + if outer is None: + outer = QVBoxLayout(self) + outer.setContentsMargins( + self.PADDING, self.PADDING, self.PADDING, self.PADDING) + outer.setSpacing(0) + # Clear outer layout (in case of re-build) + while outer.count(): + item = outer.takeAt(0) + w = item.widget() + if w is not None: + w.deleteLater() + + content = QWidget(self) + vbox = QVBoxLayout(content) + vbox.setContentsMargins(0, 0, 0, 0) + vbox.setSpacing(10) + + title = QLabel(T("shortcuts_title")) + title.setObjectName("sk_title") + title.setAlignment(Qt.AlignCenter) + vbox.addWidget(title) + + sep = QFrame() + sep.setObjectName("sk_sep") + sep.setFrameShape(QFrame.HLine) + sep.setFrameShadow(QFrame.Plain) + vbox.addWidget(sep) + + for sec_key, rows in self.SECTIONS: + sec_label = QLabel(T(sec_key)) + sec_label.setObjectName("sk_section") + vbox.addWidget(sec_label) + + grid = QGridLayout() + grid.setHorizontalSpacing(20) + grid.setVerticalSpacing(2) + grid.setContentsMargins(12, 0, 0, 0) + for r, row in enumerate(rows): + kl = QLabel(key_label(row)) + kl.setObjectName("sk_key") + kl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + grid.addWidget(kl, r, 0) + dl = QLabel(T(row[1])) + dl.setAlignment(Qt.AlignLeft | Qt.AlignVCenter) + grid.addWidget(dl, r, 1) + grid.setColumnStretch(1, 1) + vbox.addLayout(grid) + + outer.addWidget(content) + self._content = content + # Re-fit to content + self.adjustSize() + + def retranslate(self): + """Re-build with the current language.""" + self._build() + + def position_over(self, anchor: QWidget): + """Center this overlay over the anchor widget's frame geometry.""" + self.adjustSize() + sz = self.size() + geo = anchor.frameGeometry() + x = geo.x() + (geo.width() - sz.width()) // 2 + y = geo.y() + (geo.height() - sz.height()) // 2 + self.move(x, y) + + +# ────────────────────────────────────────────────────────────────── +# Main Window +# ────────────────────────────────────────────────────────────────── + +class RectifyMainWindow(QMainWindow): + def __init__(self, initial_path: str | None = None, initial_dir: str | None = None): + super().__init__() + # Ensure app name is set for QStandardPaths (settings persistence) + app = QApplication.instance() + if app and not app.applicationName(): + app.setApplicationName("Rectify") + app.setOrganizationName(os.getenv("USER", os.getenv("USERNAME", ""))) + self.setWindowTitle("Rectify — Perspective Correction") + self.setAcceptDrops(True) + + # State + self.image_stack: list[tuple[np.ndarray, np.ndarray | None]] = [] + self.undo_stack: list[np.ndarray] = [] + self.redo_stack: list[np.ndarray] = [] + self.current_filename: str = "" + self.last_directory: str = "" + self._last_opened_path: str = "" + # Per-image state cache, keyed by absolute file path. Each entry + # is a dict that may contain: "bow" (float), "corners" (list of + # 4-point arrays, one per peel depth — entries may be None), + # "keystone_pairs" (list of 4-point pair arrays). Allows + # switching between images without losing manual tuning. + self._image_cache: dict[str, dict] = {} + # Color correction. Two reference modes — "gray" (a neutral gray + # card, color + exposure coupled through a reflectance target) and + # "white" (a white sheet, color neutralized while preserving the + # patch's luminance, with an independent brightness factor). State + # is split into per-image and global (sticky) parts: + # - Per-image (in _image_cache): color_correct_enabled (master + # toggle), color_mode, and a *separate* pick for each mode + # (gray_center/gray_color, white_center/white_color). A new image + # opens uncorrected. + # - Global / sticky (settings top-level), per mode: the last + # measured neutral color, plus radius and the mode's target + # (reflectance for gray, brightness for white) as last-used + # values. A sticky color seeds a *snapshot* (a frozen copy) + # when reused on another image — never a live link — so a + # reference measured once carries to other shots under the same + # light without later edits propagating silently. + # The live per-mode pick/color/sticky/radius are kept in dicts + # keyed by mode so the active set is just ``[self._color_mode]`` — + # switching the radio never destroys the other mode's pick. Gains + # are computed from the active mode's sample and applied to the + # *output* in every mode (a per-channel gain commutes with warp). + # Reference mode: None (no choice yet — a newly opened image starts + # here, showing both radios so the user picks deliberately), "gray", + # or "white". Gray is *not* the default: it isn't always the most + # useful, so neither is preselected. + self._color_mode: str | None = None # current image's reference + self._color_correct_enabled: bool = False # master toggle + self._pick_center: dict[str, tuple[float, float] | None] = { + "gray": None, "white": None, "white90": None} # image px (own pick) + self._pick_color: dict[str, tuple[float, float, float] | None] = { + "gray": None, "white": None, "white90": None} # BGR frozen snapshot + self._sticky_color: dict[str, tuple[float, float, float] | None] = { + "gray": None, "white": None, "white90": None} # BGR last-measured + self._radius: dict[str, int] = { + "gray": GRAY_RADIUS_DEFAULT, "white": GRAY_RADIUS_DEFAULT, + "white90": GRAY_RADIUS_DEFAULT} + # Gray target: reflectance as a percentage (photographer-facing + # unit); 18% = standard middle gray. Converted to a 0–1 linear + # fraction when calling gray_correction_gains. + self._gray_reflectance: int = round(GRAY_REFLECTANCE_DEFAULT * 100) + # White target: brightness, a centered slider value in [-100, 100] + # (0 = no change). Maps to exposure stops via value/50 (±2 stops). + self._white_brightness: int = 0 + self._gray_pick_armed: bool = False # transient (not persisted) + + # Debounce for slider/spinbox/wheel adjustments. A single-shot + # timer carries one *pending recompute* — a tagged callback the + # active slider keeps rescheduling while the user adjusts it; the + # recompute runs only after SLIDER_RECOMPUTE_DEBOUNCE_MS of quiet. + # The tag lets a different slider interrupting a pending one flush + # it first, so no recompute is silently dropped. + self._pending_recompute: tuple[str, object] | None = None + self._recompute_timer = QTimer(self) + self._recompute_timer.setSingleShot(True) + self._recompute_timer.setInterval(SLIDER_RECOMPUTE_DEBOUNCE_MS) + self._recompute_timer.timeout.connect(self._run_pending_recompute) + self._space_held = False + self._last_result = None # cached for status bar dimensions + self._saved_extract_state = None # snapshot of Extract-mode state when switching to Transform + self._transform_corners_edited = False # True if corners changed in Transform mode + self._peel_exhausted = False # True after a failed peel attempt + self._base_scale: float | None = None # Set by first image, used for all levels + self._focal_length_35mm: float | None = None # From EXIF + # True when the aspect value in force is a confident auto estimate the + # user hasn't overridden — drives the inert ("no action needed") style. + self._aspect_auto = False + # True when a confident estimate exceeded the slider's range and the + # value was pinned at the limit — drives the red "clamped" styling. + self._aspect_clamped = False + self._last_saved_path: str = "" # for status bar display + # Save target state. _save_as_dir is the last-used folder, shared by + # both save modes and persisted; None until the first save (then falls + # back to the source image's folder). _save_stem is the base filename + # (reset to the source image's stem on load, updated by a dialog save); + # _save_ext is the last-used type, persisted (default png). + self._save_as_dir: str | None = None + self._save_stem: str = "" + self._save_ext: str = "png" + # Keystone (Lines mode) state + self._keystone_pairs: list[np.ndarray] = [] # each (4, 2) float32 + self._keystone_image_path: str | None = None # image these pairs belong to + self._cache_cleared: bool = False # show indicator in status bar + self._edge_debug: bool = False # toggle edge overlay on source panel + # Shortcut overlay (shown while Shift+Alt is held) + self._shortcut_overlay: ShortcutOverlay | None = None + self._alt_overlay_visible: bool = False + self._shift_held_global: bool = False + self._alt_held_global: bool = False + QApplication.instance().installEventFilter(self) + + # Window border + self.setStyleSheet("QMainWindow { border: 1px solid #808080; }") + + # Font scaling + self._font_scale = DEFAULT_FONT_SCALE + self._base_font_size = QApplication.font().pointSizeF() + if self._base_font_size <= 0: + self._base_font_size = 10.0 + + self._build_ui() + self._build_toolbar() + self._build_shortcuts() + self._apply_font_scale() + self._retranslate() + self._update_button_states() + + # Size and center window (defaults, may be overridden by saved settings) + screen = QApplication.primaryScreen().availableGeometry() + w = int(screen.width() * 0.80) + h = int(screen.height() * 0.80) + self.resize(w, h) + self.move( + screen.x() + (screen.width() - w) // 2, + screen.y() + (screen.height() - h) // 2, + ) + + # Deferred image restoration (set by _load_settings if applicable). + # The corresponding per-image state (corners, keystone pairs, bow) + # lives in _image_cache, keyed by absolute path. + self._saved_image_path: str | None = None + + # Restore saved settings (overrides defaults above) + self._load_settings() + self._retranslate() # re-apply in case language changed + + self._initial_path = initial_path + self._initial_dir = initial_dir + + def showEvent(self, event): + super().showEvent(event) + # Ensure equal splitter sizes if not restored from settings + if self.splitter.sizes()[0] == 0 or self.splitter.sizes()[1] == 0: + half = self.splitter.width() // 2 + self.splitter.setSizes([half, half]) + if self._initial_path: + from PySide6.QtCore import QTimer + path = self._initial_path + self._initial_path = None + self._initial_dir = None + # Deferred (not singleShot(0)) so foreground activation completes + # before the blocking load — see INITIAL_LOAD_DELAY_MS. + QTimer.singleShot(INITIAL_LOAD_DELAY_MS, + lambda: self._load_file(path)) + elif self._initial_dir: + from PySide6.QtCore import QTimer + d = self._initial_dir + self._initial_dir = None + self.last_directory = d + QTimer.singleShot(INITIAL_LOAD_DELAY_MS, self._open_file) + elif self._saved_image_path and os.path.exists(self._saved_image_path): + from PySide6.QtCore import QTimer + QTimer.singleShot(INITIAL_LOAD_DELAY_MS, self._restore_saved_image) + else: + # Nothing to load (fresh start, no image arg, no saved image): draw + # the empty-state placeholders ("Source image" / "Result") and the + # shortcut hint, so the panels aren't left blank. A deferred load in + # the branches above, when present, replaces this. + self._update_display() + + # ── Properties ──────────────────────────────────────────────── + + @property + def current_image(self) -> np.ndarray | None: + return self.image_stack[-1][0] if self.image_stack else None + + @property + def outermost_corners(self) -> np.ndarray | None: + """Corners on the original image (displayed on the left panel).""" + return self.image_stack[0][1] if self.image_stack else None + + @outermost_corners.setter + def outermost_corners(self, value: np.ndarray | None): + if self.image_stack: + img = self.image_stack[0][0] + self.image_stack[0] = (img, self._clamp_corners(value, img)) + + @property + def corners(self) -> np.ndarray | None: + """Corners on the current (deepest) stack level.""" + return self.image_stack[-1][1] if self.image_stack else None + + @corners.setter + def corners(self, value: np.ndarray | None): + if self.image_stack: + img = self.image_stack[-1][0] + self.image_stack[-1] = (img, self._clamp_corners(value, img)) + + @staticmethod + def _clamp_corners(corners: np.ndarray | None, image: np.ndarray) -> np.ndarray | None: + """Clamp corners to the image bounds (border pixels).""" + if corners is None: + return None + h, w = image.shape[:2] + clamped = corners.copy() + clamped[:, 0] = np.clip(clamped[:, 0], 0, w - 1) + clamped[:, 1] = np.clip(clamped[:, 1], 0, h - 1) + return clamped + + # ── UI Construction ─────────────────────────────────────────── + + def _build_ui(self): + central = QWidget() + self.setCentralWidget(central) + layout = QVBoxLayout(central) + layout.setContentsMargins(1, 1, 1, 1) # tight around the border + layout.setSpacing(0) # vertical gaps controlled by row margins only + + # Splitter with two image panels + self.splitter = QSplitter(Qt.Horizontal) + self.source_panel = SourcePanel() + self.result_panel = ImagePanel() + self.splitter.addWidget(self.source_panel) + self.splitter.addWidget(self.result_panel) + self.splitter.setStretchFactor(0, 1) + self.splitter.setStretchFactor(1, 1) + layout.addWidget(self.splitter, stretch=1) + + # Connect corner signals + self.source_panel.corner_moved.connect(self._on_corner_moved) + self.source_panel.corner_released.connect(self._on_corner_released) + self.source_panel.center_drag_started.connect(self._on_center_drag_started) + # Connect arrow (keystone) signals + self.source_panel.arrow_moved.connect(self._on_arrow_moved) + self.source_panel.arrow_released.connect(self._on_arrow_released) + # Gray-card pick (Color correct) + self.source_panel.gray_point_picked.connect(self._on_color_point_picked) + + # ── Controls below panels ── + # + # Single parameter row (the action row). Action: Extract/Transform + # radios on the left, mode-specific parameters following. Detection + # parameters (strategy / sensitivity / Canny) have been removed from + # the GUI entirely — they live only on the CLI. Language + Font + # controls have moved to the toolbar above (right-aligned). + + mh = CONTROL_MARGIN * 2 # horizontal (overridden by _apply_font_scale) + mv = CONTROL_MARGIN # vertical + + self._ctrl_action = ctrl_act = QWidget() + act_layout = QHBoxLayout(ctrl_act) + act_layout.setContentsMargins(mh, mv, mh, mv) + # Uniform inter-widget spacing — collapses correctly when + # widgets are hidden, unlike addSpacing() spacer items which + # persist as phantom gaps in Extract mode where most of the + # Transform sub-widgets are not shown. + act_layout.setSpacing(20) + + # Action group: label + Extract/Transform radios. Wrapped in + # a sub-container with tight internal spacing — radio button + # groups conventionally sit close together, distinct from the + # 20 px spacing the outer row uses between unrelated widgets. + self._action_grp = action_grp = QWidget() + action_grp_layout = QHBoxLayout(action_grp) + action_grp_layout.setContentsMargins(0, 0, 0, 0) + action_grp_layout.setSpacing(6) + + self.action_label = QLabel() + action_grp_layout.addWidget(self.action_label) + + self.action_extract_radio = QRadioButton() + self.action_extract_radio.setChecked(True) + action_grp_layout.addWidget(self.action_extract_radio) + + self.action_transform_radio = QRadioButton() + action_grp_layout.addWidget(self.action_transform_radio) + + act_layout.addWidget(action_grp) + + self._action_group = QButtonGroup(self) + self._action_group.addButton(self.action_extract_radio, 0) + self._action_group.addButton(self.action_transform_radio, 1) + self._action_group.idClicked.connect(lambda _: self._on_action_changed()) + + + # Transform sub-widgets (visible only when Transform is selected) + + # Method group: same tight sub-container pattern as Action. + self._method_grp = method_grp = QWidget() + method_grp_layout = QHBoxLayout(method_grp) + method_grp_layout.setContentsMargins(0, 0, 0, 0) + method_grp_layout.setSpacing(6) + + self.method_label = QLabel() + method_grp_layout.addWidget(self.method_label) + + self.method_quad_radio = QRadioButton() + self.method_quad_radio.setChecked(True) + method_grp_layout.addWidget(self.method_quad_radio) + + self.method_lines_radio = QRadioButton() + method_grp_layout.addWidget(self.method_lines_radio) + + act_layout.addWidget(method_grp) + + self._method_group = QButtonGroup(self) + self._method_group.addButton(self.method_quad_radio, 0) + self._method_group.addButton(self.method_lines_radio, 1) + self._method_group.idClicked.connect(lambda _: self._on_method_changed()) + + # Vertical / Horizontal checkboxes (Lines mode only) + # Vertical / Horizontal: text-attached checkboxes with the + # indicator placed *after* the label (text on the left of the + # indicator). Achieved with `setLayoutDirection(RightToLeft)` + # on each checkbox — this reverses the indicator/text order + # within the widget but doesn't affect the text reading order. + self.vertical_check = QCheckBox() + self.vertical_check.setChecked(True) + self.vertical_check.setLayoutDirection(Qt.RightToLeft) + self.vertical_check.setStyleSheet(CHECKBOX_TIGHT_STYLE) + self.vertical_check.toggled.connect(self._on_lines_check_toggled) + act_layout.addWidget(self.vertical_check) + + self.horizontal_check = QCheckBox() + self.horizontal_check.setLayoutDirection(Qt.RightToLeft) + self.horizontal_check.setStyleSheet(CHECKBOX_TIGHT_STYLE) + self.horizontal_check.toggled.connect(self._on_lines_check_toggled) + act_layout.addWidget(self.horizontal_check) + + + # Crop toggle (off = full canvas with fill color, on = auto-crop). + # Text on the checkbox itself with RightToLeft layout direction — + # produces "Crop ☐" (label first, then indicator), matching the + # Vertical / Horizontal pattern. + self.fullimg_crop_check = QCheckBox() + self.fullimg_crop_check.setChecked(True) + self.fullimg_crop_check.setLayoutDirection(Qt.RightToLeft) + self.fullimg_crop_check.setStyleSheet(CHECKBOX_TIGHT_STYLE) + self.fullimg_crop_check.toggled.connect(self._on_fullimg_mode_changed) + act_layout.addWidget(self.fullimg_crop_check) + + + # Fill group: "Fill:" label + color button. Sub-container with + # tight 6 px spacing — the label-to-graphic gap matches the + # Action/Method radio sub-containers. + self._fill_grp = fill_grp = QWidget() + fill_grp_layout = QHBoxLayout(fill_grp) + fill_grp_layout.setContentsMargins(0, 0, 0, 0) + fill_grp_layout.setSpacing(6) + self.fill_color_label = QLabel() + fill_grp_layout.addWidget(self.fill_color_label) + self.fill_color_btn = QPushButton() + self.fill_color_btn.setFixedWidth(40) + self._fill_color = QColor(0, 0, 0) + self._update_fill_color_icon() + self.fill_color_btn.clicked.connect(self._pick_fill_color) + fill_grp_layout.addWidget(self.fill_color_btn) + act_layout.addWidget(fill_grp) + + + # Aspect ratio group: text-on-checkbox (with RightToLeft layout + # for "Aspect ratio:☐" pattern) followed by the value slider. + # Visible in Extract mode and in Transform → Quad mode; hidden + # in Transform → Lines (`keystone_correct` derives its own + # output dimensions). Sub-container so the gap between the + # checkbox and its spinbox matches the radio-group tight + # spacing (6 px) rather than the outer row's 20 px. + self._aspect_grp = aspect_grp = QWidget() + aspect_grp_layout = QHBoxLayout(aspect_grp) + aspect_grp_layout.setContentsMargins(0, 0, 0, 0) + aspect_grp_layout.setSpacing(6) + self.aspect_check = QCheckBox() + self.aspect_check.setChecked(False) + self.aspect_check.setLayoutDirection(Qt.RightToLeft) + self.aspect_check.setStyleSheet(CHECKBOX_TIGHT_STYLE) + self.aspect_check.toggled.connect(self._on_aspect_toggled) + aspect_grp_layout.addWidget(self.aspect_check) + self.aspect_widget = SliderSpinBox(0.10, 10.00, step=0.01, decimals=3, + slider_width=90) + self.aspect_widget.setValue(1.0) + self.aspect_widget.setEnabled(False) + self.aspect_widget.valueChanged.connect(self._on_aspect_changed) + aspect_grp_layout.addWidget(self.aspect_widget) + act_layout.addWidget(aspect_grp) + # Apply initial gray-italic styling on the value for the + # off-state — the checkbox defaults to unchecked. + self._apply_aspect_style() + + + # Bow group: label + value. Extract-mode only — + # `apply_bow_correction` is a corner-anchored radial expansion + # that only makes sense on a rectified output whose corners are + # the image corners. Sub-container for the same 6 px + # label-to-spinbox gap as Aspect ratio. + # + # The value is the peak radial *distance in output pixels* by + # which content is moved: positive expands mid-edge content + # outward (straightens inward/pincushion bow), negative pulls it + # inward (straightens outward/barrel bow). Converted to the + # curvature coefficient k against the output half-diagonal in + # `_compute_full_result` (see BOW_PEAK_FRAC). Bidirectional, so + # the slider fills from its centre (0) outward. + self._bow_grp = bow_grp = QWidget() + bow_grp_layout = QHBoxLayout(bow_grp) + bow_grp_layout.setContentsMargins(0, 0, 0, 0) + bow_grp_layout.setSpacing(6) + self.bow_label = QLabel() + bow_grp_layout.addWidget(self.bow_label) + self.bow_widget = SliderSpinBox(-BOW_PX_RANGE, BOW_PX_RANGE, step=1, + decimals=0, slider_width=90, + center_fill=True) + self.bow_widget.setValue(0.0) + self._apply_bow_style() + self.bow_widget.valueChanged.connect(self._on_bow_changed) + bow_grp_layout.addWidget(self.bow_widget) + act_layout.addWidget(bow_grp) + + + # Stretch group: label + value. Transform → Lines only. Keystone + # correction fixes line directions but not the metric width/height + # ratio, so this is the user's by-eye correction for it (a horizontal + # stretch of the corrected output). 1.00 is a no-op; the value styles + # inert at 1.00 like Bow at 0.00. Same 6 px label-to-spinbox gap. + self._stretch_grp = stretch_grp = QWidget() + stretch_grp_layout = QHBoxLayout(stretch_grp) + stretch_grp_layout.setContentsMargins(0, 0, 0, 0) + stretch_grp_layout.setSpacing(6) + self.stretch_label = QLabel() + stretch_grp_layout.addWidget(self.stretch_label) + self.stretch_widget = SliderSpinBox(0.50, 2.00, step=0.01, decimals=2, + slider_width=90) + self.stretch_widget.setValue(1.0) + self._apply_stretch_style() + self.stretch_widget.valueChanged.connect(self._on_stretch_changed) + stretch_grp_layout.addWidget(self.stretch_widget) + act_layout.addWidget(stretch_grp) + + + # Color-correct group: a checkbox that's always visible plus a + # controls sub-container (swatch + radius + target) shown only + # when the checkbox is on. Applies in every mode (it's a pixel + # color operation, independent of the geometry). The swatch + # arms a pick gesture; the chosen center/radius/target persist + # per-image. The checkbox carries its own colon-suffixed text + # (RightToLeft) like Vertical/Horizontal/Crop. + self._gray_grp = gray_grp = QWidget() + gray_grp_layout = QHBoxLayout(gray_grp) + gray_grp_layout.setContentsMargins(0, 0, 0, 0) + gray_grp_layout.setSpacing(6) + self.gray_check = QCheckBox() + self.gray_check.setLayoutDirection(Qt.RightToLeft) + self.gray_check.setStyleSheet(CHECKBOX_TIGHT_STYLE) + self.gray_check.toggled.connect(self._on_color_toggled) + gray_grp_layout.addWidget(self.gray_check) + + # The mode controls live directly in gray_grp — the same nesting depth + # as the action row's parameter groups — so they share its baseline. + # (An extra container level here used to drop the value boxes ~2px below + # the checkbox.) Visibility is toggled per-widget by the Color-correct + # checkbox; ``gray_ctl_layout`` is just an alias so the adds read clearly. + gray_ctl_layout = gray_grp_layout + + # Reference-mode radio (Gray / White), built like the Action radio. + # Gray couples color + exposure via a reflectance target; White + # neutralizes the cast while preserving luminance, with a separate + # brightness factor. Each mode keeps its own pick and parameters. + self.color_gray_radio = QRadioButton() + gray_ctl_layout.addWidget(self.color_gray_radio) + self.color_white_radio = QRadioButton() + gray_ctl_layout.addWidget(self.color_white_radio) + self.color_white90_radio = QRadioButton() + gray_ctl_layout.addWidget(self.color_white90_radio) + self._color_mode_group = QButtonGroup(self) + self._color_mode_group.addButton(self.color_gray_radio, 0) + self._color_mode_group.addButton(self.color_white_radio, 1) + self._color_mode_group.addButton(self.color_white90_radio, 2) + self._color_mode_group.idClicked.connect( + lambda _: self._on_color_mode_changed()) + + # "Value:" label before the swatch — for parity with the other + # labeled controls (the swatch shows the sampled neutral color). + self.gray_value_label = QLabel() + gray_ctl_layout.addWidget(self.gray_value_label) + self.gray_swatch = GraySwatchButton() + self.gray_swatch.setFixedWidth(40) + self.gray_swatch.clicked.connect(self._arm_gray_pick) # left: pick + self.gray_swatch.rightClicked.connect(self._on_color_swatch_snapshot) # right: reuse last + gray_ctl_layout.addWidget(self.gray_swatch) + # Target comes before Radius: it's the meaningful parameter (the + # tone the neutral lands on, or the brightness), Radius is just a + # sampling refinement. Gray shows Reflectance; White shows + # Brightness — only one is visible at a time (by mode). + self.gray_reflectance_label = QLabel() + gray_ctl_layout.addWidget(self.gray_reflectance_label) + self.gray_reflectance_widget = SliderSpinBox(3, 80, step=1, decimals=0, + slider_width=90) + self.gray_reflectance_widget.spin.setSuffix("%") + self.gray_reflectance_widget.setValue(self._gray_reflectance) + self.gray_reflectance_widget.valueChanged.connect( + self._on_gray_reflectance_changed) + gray_ctl_layout.addWidget(self.gray_reflectance_widget) + # White brightness: centered slider in [-100, 100], 0 = no change. + self.white_brightness_label = QLabel() + gray_ctl_layout.addWidget(self.white_brightness_label) + self.white_brightness_widget = SliderSpinBox(-100, 100, step=1, decimals=0, + slider_width=90) + self.white_brightness_widget.setValue(self._white_brightness) + self.white_brightness_widget.valueChanged.connect( + self._on_white_brightness_changed) + gray_ctl_layout.addWidget(self.white_brightness_widget) + self.gray_radius_label = QLabel() + gray_ctl_layout.addWidget(self.gray_radius_label) + self.gray_radius_widget = SliderSpinBox(1, 100, step=1, decimals=0, + slider_width=90) + self.gray_radius_widget.setValue( + self._radius.get(self._color_mode, GRAY_RADIUS_DEFAULT)) + self.gray_radius_widget.valueChanged.connect(self._on_gray_radius_changed) + gray_ctl_layout.addWidget(self.gray_radius_widget) + + # Inter-group whitespace to match the first row. The action row + # separates its groups with setSpacing(20); this row uses setSpacing(6) + # throughout, so add the 14px difference as a left inset before each + # group's leading label (Value / Reflectance / Brightness / Sample + # size). Putting it on the label (not a spacer item) means it hides + # with the label when Gray/White modes toggle, leaving no orphan gap. + _group_gap, _within_gap = 20, 6 + for _lbl in (self.gray_value_label, self.gray_reflectance_label, + self.white_brightness_label, self.gray_radius_label): + _lbl.setContentsMargins(_group_gap - _within_gap, 0, 0, 0) + self._update_color_swatch() + self._update_color_widgets_visibility() + + act_layout.addStretch() + # Set size policy to fixed vertical so showing/hiding sub-widgets + # doesn't cause the row to resize + ctrl_act.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + self._update_transform_widgets_visibility() + layout.addWidget(ctrl_act) + + # Color-correct row — a separate line below the geometry parameters + # (both platforms). Color correction is categorically distinct (a + # pixel operation, used by more technical users), so it sits apart from + # the geometry params; with the toggle off, only the "Color correct" + # checkbox shows on this line. + self._ctrl_color = ctrl_color = QWidget() + color_layout = QHBoxLayout(ctrl_color) + color_layout.setContentsMargins(mh, mv, mh, mv) + color_layout.setSpacing(20) + color_layout.addWidget(self._gray_grp) + color_layout.addStretch() + ctrl_color.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed) + layout.addWidget(ctrl_color) + + # Advanced parameter panel (Blur / Canny low / Canny high / + # Min area / Epsilon) removed. Same reasoning as the Strategy + # and Sensitivity widgets: the parameters demand algorithmic + # familiarity that the Rectify GUI audience (now design- and + # art-oriented) doesn't need, and detection issues are + # addressed by manual corner editing. CLI flags above retain + # access for the technical audience. + + # Status bar — QStatusBar ignores padding/margin stylesheets, + # so we use a QLabel with explicit margins as the status content. + self.status_bar = QStatusBar() + self.setStatusBar(self.status_bar) + if _IS_MAC: + # macOS draws a separator line at the top of the status bar that the + # Linux style does not; drop it so the two match. + self.status_bar.setStyleSheet( + "QStatusBar { border: 0px; } QStatusBar::item { border: 0px; }") + self._status_label = QLabel() + mh = CONTROL_MARGIN * 2 + mv = CONTROL_MARGIN + self._status_label.setContentsMargins(mh, mv, mh, mv) + self.status_bar.addWidget(self._status_label, 1) + # Author attribution, flush-right in the status bar (the OS title bar + # can't host right-justified custom text). addPermanentWidget docks + # it on the right edge. Language-independent, gray so it reads as + # metadata; selectable so the email can be copied. Em dash per house + # style. (The keyboard-shortcut hint that used to live here moved to + # the top action bar.) + self._attribution_label = QLabel("Andy Kopra — ack@acm.org") + self._attribution_label.setContentsMargins(mh, mv, mh, mv) + self._attribution_label.setTextInteractionFlags(Qt.TextSelectableByMouse) + self.status_bar.addPermanentWidget(self._attribution_label) + + def _build_toolbar(self): + """Build the action bar above the panels as a plain widget row.""" + self._toolbar_widget = QWidget() + tb_layout = QHBoxLayout(self._toolbar_widget) + mh = CONTROL_MARGIN * 2 + tb_layout.setContentsMargins(mh, 2, mh, 2) + tb_layout.setSpacing(8) + + # ── File group: Open, Re-detect, Save as ── + self.btn_open = QPushButton() + self.btn_open.clicked.connect(self._open_file) + tb_layout.addWidget(self.btn_open) + QShortcut(QKeySequence.Open, self, self._open_file) + + self.btn_reset = QPushButton() + self.btn_reset.clicked.connect(self._reset) + tb_layout.addWidget(self.btn_reset) + QShortcut(QKeySequence("Ctrl+D"), self, self._reset) + + QShortcut(QKeySequence("Ctrl+R"), self, self._reopen) + + self.btn_save_as = QPushButton() + self.btn_save_as.setStyleSheet(INACTIVE_BUTTON_STYLE) + self.btn_save_as.clicked.connect(self._save) + tb_layout.addWidget(self.btn_save_as) + QShortcut(QKeySequence.Save, self, self._save) + + # Increment mode: when checked, Save auto-numbers (name_1, name_2, …) + # without a dialog; unchecked, Save opens the dialog to set name/type. + self.increment_check = QCheckBox() + tb_layout.addWidget(self.increment_check) + + # Space separating the file ops (Open/Reset/Save/Increment) from the + # peel ops (Depth/Peel in/out). + tb_layout.addSpacing(20) + + # ── Peel group: Depth, Peel in, Peel out ── + self.depth_label = QLabel() + tb_layout.addWidget(self.depth_label) + + self.btn_peel_in = QPushButton() + self.btn_peel_in.setStyleSheet(INACTIVE_BUTTON_STYLE) + self.btn_peel_in.clicked.connect(self._peel_in) + tb_layout.addWidget(self.btn_peel_in) + + self.btn_peel_out = QPushButton() + self.btn_peel_out.setStyleSheet(INACTIVE_BUTTON_STYLE) + self.btn_peel_out.clicked.connect(self._peel_out) + tb_layout.addWidget(self.btn_peel_out) + + # Undo/Redo via keyboard only (Ctrl+Z / Ctrl+Shift+Z) + QShortcut(QKeySequence.Undo, self, self._undo) + QShortcut(QKeySequence.Redo, self, self._redo) + + # Stretch pushes the right-hand group (Shortcuts hint, then the + # Language + Font controls) to the right edge. + tb_layout.addStretch() + + # Keyboard-shortcut discoverability hint ("Shortcuts: ⌥⇧"), first in + # the right-hand group. Always visible, so it advertises the overlay + # even on the empty first-run screen. Text set/localized in + # _retranslate. + self._shortcuts_hint = QLabel() + tb_layout.addWidget(self._shortcuts_hint) + tb_layout.addSpacing(16) + + # ── Language + Font scaling (flush right, after Shortcuts) ── + self.lang_label = QLabel() + tb_layout.addWidget(self.lang_label) + self.lang_combo = QComboBox() + self.lang_combo.addItems(sorted(LANGUAGES.keys())) + self.lang_combo.setCurrentText("en") + self.lang_combo.currentTextChanged.connect(self._change_language) + tb_layout.addWidget(self.lang_combo) + + # Font size adjustment. The '−' and '+' characters are + # rendered 1.5x the surrounding font size so they're easier to + # target; the stylesheet uses em units so it tracks the + # overall font scale. Width is set in _apply_font_scale so + # the buttons grow proportionally with the bigger character. + # Padding 0 gives the glyph maximum room inside the (fixed) + # button rectangle so the character can grow via setFont + # (applied in _apply_font_scale, post the QApplication-level + # setFont loop that would otherwise clobber a font set here). + font_btn_style = "QPushButton { padding: 0; }" + self.font_minus = QPushButton("−") + self.font_minus.setStyleSheet(font_btn_style) + self.font_minus.clicked.connect(self._font_smaller) + tb_layout.addWidget(self.font_minus) + self.font_plus = QPushButton("+") + self.font_plus.setStyleSheet(font_btn_style) + self.font_plus.clicked.connect(self._font_larger) + tb_layout.addWidget(self.font_plus) + + # Insert above the splitter in the central layout + central_layout = self.centralWidget().layout() + central_layout.insertWidget(0, self._toolbar_widget) + + def _build_shortcuts(self): + QShortcut(QKeySequence("+"), self, self._peel_in) + QShortcut(QKeySequence("="), self, self._peel_in) + QShortcut(QKeySequence("-"), self, self._peel_out) + QShortcut(QKeySequence("0"), self, self._reset_zoom) + QShortcut(QKeySequence("Ctrl+Down"), self, + lambda: self._navigate_image(+1)) + QShortcut(QKeySequence("Ctrl+Up"), self, + lambda: self._navigate_image(-1)) + + def _reset_zoom(self): + """Reset both panels to default zoom (fit if larger, center if smaller).""" + for panel in [self.source_panel, self.result_panel]: + panel._reset_panel_zoom() + + # ── Internationalization ────────────────────────────────────── + + def _change_language(self, lang_code: str): + global _current_lang + _current_lang = lang_code + # Install Qt's built-in translation for standard dialog buttons + from PySide6.QtCore import QTranslator, QLibraryInfo, QLocale + app = QApplication.instance() + # Remove previous translator if any + if hasattr(self, '_qt_translator') and self._qt_translator: + app.removeTranslator(self._qt_translator) + self._qt_translator = QTranslator() + qt_translations_path = QLibraryInfo.path(QLibraryInfo.TranslationsPath) + locale = QLocale(lang_code) + if self._qt_translator.load(locale, "qtbase", "_", qt_translations_path): + app.installTranslator(self._qt_translator) + self._retranslate() + + def _retranslate(self): + """Update all UI text for the current language.""" + # Toolbar buttons. Tooltips append the keyboard shortcut, using the + # platform's modifier glyph (⌘ on macOS, "Ctrl+" elsewhere). + mod = "⌘" if _IS_MAC else "Ctrl+" + self.btn_open.setText(T("open")) + self.btn_open.setToolTip(tip(f"{T('open_tip')} ({mod}O)")) + self.btn_reset.setText(T("reset_detect")) + self.btn_reset.setToolTip(tip(f"{T('reset_tip')} ({mod}D)")) + self.btn_save_as.setText(T("save")) + self.btn_save_as.setToolTip(tip(f"{T('save_tip')} ({mod}S)")) + self.increment_check.setText(T("increment")) + self.increment_check.setToolTip(tip(T("increment_tip"))) + self.btn_peel_in.setText(f"{T('peel_in')} [+]") + self.btn_peel_in.setToolTip(tip(f"{T('peel_in_tip')} (+)")) + self.btn_peel_out.setText(f"{T('peel_out')} [-]") + self.btn_peel_out.setToolTip(tip(f"{T('peel_out_tip')} (−)")) + self.font_minus.setToolTip(tip(T("font_smaller_tip"))) + self.font_plus.setToolTip(tip(T("font_larger_tip"))) + self._shortcuts_hint.setText( + T("shortcuts_hint_short").format(keys=SHOW_SHORTCUTS_KEYS)) + self._shortcuts_hint.setToolTip( + tip(T("shortcuts_hint_full").format(keys=SHOW_SHORTCUTS_KEYS))) + + # Controls below panels. Aspect ratio and Crop checkboxes + # carry their own text now (no separate label widget). + self.aspect_check.setText(f"{T('aspect_ratio')}:") + self.aspect_check.setToolTip(tip(T("aspect_tip"))) + self.aspect_widget.setToolTip(tip(T("aspect_tip"))) + self.bow_label.setText(f"{T('bow')}:") + self.bow_label.setToolTip(tip(T("bow_tip"))) + self.bow_widget.setToolTip(tip(T("bow_tip"))) + self.stretch_label.setText(f"{T('stretch')}:") + self.stretch_label.setToolTip(tip(T("stretch_tip"))) + self.stretch_widget.setToolTip(tip(T("stretch_tip"))) + self.lang_label.setText(f"{T('lang')}:") + self.lang_label.setToolTip(tip(T("lang_tip"))) + self.lang_combo.setToolTip(tip(T("lang_tip"))) + + # Action row + self.action_label.setText(f"{T('action')}:") + self.action_label.setToolTip(tip(T("action_tip"))) + self.action_extract_radio.setText(T("extract")) + self.action_extract_radio.setToolTip(tip(T("action_tip"))) + self.action_transform_radio.setText(T("transform")) + self.action_transform_radio.setToolTip(tip(T("action_tip"))) + self.method_label.setText(f"{T('method')}:") + self.method_label.setToolTip(tip(T("method_tip"))) + self.method_quad_radio.setText(T("quad")) + self.method_quad_radio.setToolTip(tip(T("method_tip"))) + self.method_lines_radio.setText(T("lines")) + self.method_lines_radio.setToolTip(tip(T("method_tip"))) + self.vertical_check.setText(f"{T('vertical')}:") + self.vertical_check.setToolTip(tip(T("vertical_tip"))) + self.horizontal_check.setText(f"{T('horizontal')}:") + self.horizontal_check.setToolTip(tip(T("horizontal_tip"))) + self.fullimg_crop_check.setText(f"{T('crop')}:") + self.fullimg_crop_check.setToolTip(tip(T("crop_tip"))) + self.fill_color_label.setText(f"{T('fill_color')}:") + self.fill_color_label.setToolTip(tip(T("fill_color_tip"))) + self.fill_color_btn.setToolTip(tip(T("fill_color_tip"))) + self.gray_check.setText(f"{T('color_correct')}:") + self.gray_check.setToolTip(tip(T("color_correct_tip"))) + self.color_gray_radio.setText(T("color_mode_gray")) + self.color_gray_radio.setToolTip(tip(T("color_hint_gray"))) + self.color_white_radio.setText(T("color_mode_white")) + self.color_white_radio.setToolTip(tip(T("color_hint_white"))) + self.color_white90_radio.setText(T("color_mode_white90")) + self.color_white90_radio.setToolTip(tip(T("color_hint_white90"))) + self.gray_value_label.setText(f"{T('color_value')}:") + self.gray_value_label.setToolTip(tip(T("color_value_tip"))) + self.gray_reflectance_label.setText(f"{T('gray_reflectance')}:") + self.gray_reflectance_label.setToolTip(tip(T("gray_reflectance_tip"))) + self.gray_reflectance_widget.setToolTip(tip(T("gray_reflectance_tip"))) + self.white_brightness_label.setText(f"{T('white_brightness')}:") + self.white_brightness_label.setToolTip(tip(T("white_brightness_tip"))) + self.white_brightness_widget.setToolTip(tip(T("white_brightness_tip"))) + self.gray_radius_label.setText(f"{T('sample_size')}:") + self.gray_radius_label.setToolTip(tip(T("sample_size_tip"))) + self.gray_radius_widget.setToolTip(tip(T("sample_size_tip"))) + self.gray_swatch.setToolTip(tip(T("gray_swatch_tip"))) + self.depth_label.setToolTip(tip(T("depth_tip"))) + + # Refresh depth label and status + self._update_button_states() + self._update_status() + + # Refresh shortcut overlay if it exists + if self._shortcut_overlay is not None: + self._shortcut_overlay.retranslate() + + # ── Font scaling ────────────────────────────────────────────── + + def _pin_widget_bottom(self, widget): + """Replace *widget* in its layout with a thin wrapper that pins it to the + bottom via a stretch above it. QLabel (and QDoubleSpinBox) ignore a + layout-item AlignBottom — they align to a shared top — so this stretch + wrapper is the reliable way to drop them onto the bottom line the + radios/checkboxes sit on.""" + root = widget.parentWidget().layout() + if root is None: + return + + def find(layout): + for i in range(layout.count()): + item = layout.itemAt(i) + if item.widget() is widget: + return layout, i + sub = item.layout() + if sub is not None: + hit = find(sub) + if hit is not None: + return hit + return None + + found = find(root) + if found is None: + return + layout, idx = found + layout.takeAt(idx) + box = QWidget() + v = QVBoxLayout(box) + v.setContentsMargins(0, 0, 0, 0) + v.setSpacing(0) + v.addStretch(1) + v.addWidget(widget) + layout.insertWidget(idx, box) + + def _apply_bottom_align(self): + """Pin all parameter-row text (labels, radio/checkbox text, value boxes) + to a common bottom line, leaving sliders/swatches centered. Preserved + behind _BOTTOM_ALIGN (see also the SliderSpinBox spin-wrapper branch); + works for most widgets but fights Qt's nested group containers, which is + why the centered alternative exists.""" + # Radios/checkboxes honour a layout-item AlignBottom, so pin them that way. + for wdg in (self.action_extract_radio, self.action_transform_radio, + self.method_quad_radio, self.method_lines_radio, + self.color_gray_radio, self.color_white_radio, + self.color_white90_radio, + self.aspect_check, self.vertical_check, self.horizontal_check, + self.fullimg_crop_check, self.gray_check): + wdg.setSizePolicy(wdg.sizePolicy().horizontalPolicy(), QSizePolicy.Fixed) + lay = wdg.parentWidget().layout() + if lay is not None: + lay.setAlignment(wdg, Qt.AlignBottom) + # Bare QLabels ignore item AlignBottom (they align to a shared top), so + # pin each to the bottom once with a stretch-above wrapper. + if not getattr(self, "_labels_pinned", False): + for lbl in (self.action_label, self.method_label, self.fill_color_label, + self.bow_label, self.stretch_label, self.gray_value_label, + self.gray_reflectance_label, self.white_brightness_label, + self.gray_radius_label): + self._pin_widget_bottom(lbl) + self._labels_pinned = True + + def _apply_font_scale(self): + """Apply the current font scale to all widgets and scale margins.""" + from PySide6.QtGui import QFontMetrics + size = self._base_font_size * self._font_scale + font = QApplication.font() + font.setPointSizeF(size) + # Set on the application so all widgets inherit it + QApplication.instance().setFont(font) + # Force all existing widgets to pick up the change + for widget in QApplication.instance().allWidgets(): + widget.setFont(font) + + fm = QFontMetrics(font) + em = fm.height() # one "em" — the natural spacing unit for this font + + # Spin boxes — fit content plus the up/down arrow buttons, + # nothing more. setFixedWidth so the natural sizeHint (which + # over-allocates for the arrows) can't grow them further. + # "10.000" is the widest content we ever display (aspect ratio + # at 3 decimals, up to 10.0); ~25 px covers the arrows plus padding. + spin_w = fm.horizontalAdvance("10.000") + 25 + self.aspect_widget.spin.setFixedWidth(spin_w) + self.bow_widget.spin.setFixedWidth(spin_w) + self.stretch_widget.spin.setFixedWidth(spin_w) + self.gray_radius_widget.spin.setFixedWidth(spin_w) + self.gray_reflectance_widget.spin.setFixedWidth(spin_w) + self.white_brightness_widget.spin.setFixedWidth(spin_w) + # Font-scale buttons: width tracks em so the box scales with + # the font scale. Height matches a regular toolbar button so + # the row stays at a uniform height. The "−" / "+" character + # is rendered with a font ~1.8× the application font, applied + # AFTER the QApplication-level setFont loop above (which would + # otherwise clobber a font set during widget construction); + # the button's padding is zeroed via stylesheet so the larger + # character has room inside the existing box. + font_btn_w = max(28, int(em * 1.6)) + font_btn_h = self.btn_open.sizeHint().height() + self.font_minus.setFixedSize(font_btn_w, font_btn_h) + self.font_plus.setFixedSize(font_btn_w, font_btn_h) + big_btn_font = font.__class__(font) + big_btn_font.setPointSizeF(font.pointSizeF() * 1.8) + self.font_minus.setFont(big_btn_font) + self.font_plus.setFont(big_btn_font) + + # Scale margins relative to font size. + mh = int(em * 1.5) # horizontal margin (same for all) + mv_outer = int(em * 1.5) # top/bottom of the control area (matches horizontal) + mv_inner = int(em * 0.25) # between rows (tight, ~1.5× line spacing) + + # Action row (only parameter row now): outer top spacing + # matches the toolbar's bottom; tight inner spacing below. + self._ctrl_action.layout().setContentsMargins(mh, mv_outer, mh, mv_inner) + # Lock the action row to a fixed height based on the spinbox + # (the tallest widget on the row). Without this, hiding the + # spinboxes in Lines mode would shrink the row vertically, + # and Bow's appearance in Extract mode would grow it. The + # spinbox sizeHint depends on the current font, so this + # tracks the font-scale slider. + spin_h = self.aspect_widget.spin.sizeHint().height() + box_h = spin_h + content_h = box_h + if _IS_MAC: + # The tallest element is the slider (~25px); the color swatch is + # only 16px and the radios/labels shorter still, so the slider drives + # the height. + tallest = max(box_h, + self.aspect_widget.slider.sizeHint().height(), + self.action_extract_radio.sizeHint().height()) + # Pin each SliderSpinBox *container* to the tallest element so macOS + # doesn't compress it down to the spin height — which clamps the + # slider and pushes the spin below center (the color row's symptom). + # The spin keeps its own natural height and centers within the box; + # forcing the spin itself taller doesn't work (QDoubleSpinBox renders + # the extra at the bottom, off-center). + for w in (self.aspect_widget, self.bow_widget, self.stretch_widget, + self.gray_reflectance_widget, self.white_brightness_widget, + self.gray_radius_widget): + w.setMinimumHeight(tallest) # the SliderSpinBox container... + w.spin.setMinimumHeight(box_h) # ...and the spin keeps its height + # Row content area = 1.25× the tallest element, so everything sits + # vertically centered with a quarter-element of margin above/below. + content_h = int(tallest * 1.25) + self._ctrl_action.setFixedHeight(content_h + mv_outer + mv_inner) + + # Color row sits tight under the action row (its top gap is the action + # row's bottom margin), with the standard bottom gap before the status. + self._ctrl_color.layout().setContentsMargins(mh, 0, mh, mv_inner) + self._ctrl_color.setFixedHeight(content_h + mv_inner) + + # Color swatches: flat 16px squares, color-filled with a 1px black + # outline — smaller than the value boxes, sitting centered in the row. + for sw in (self.fill_color_btn, self.gray_swatch): + sw.setFixedSize(16, 16) + + # Bottom-align strategy is preserved behind the flag; otherwise every + # element is simply left vertically centered (Qt's native behavior). + if _BOTTOM_ALIGN: + self._apply_bottom_align() + + # Toolbar (now a plain widget row) — half em vertical spacing + mv_toolbar = int(em * 0.5) + self._toolbar_widget.layout().setContentsMargins(mh, mv_toolbar, mh, mv_toolbar) + + # Status bar label — top margin is zero since the ctrl rows above + # provide the gap. + self._status_label.setContentsMargins(mh, 0, mh, mv_outer) + status_font = font.__class__(font) + status_font.setItalic(True) + self._status_label.setFont(status_font) + # Author attribution: a small, subtle corner credit at ~75% of the + # surrounding size. Set here, AFTER the QApplication-level setFont + # loop above (which would otherwise clobber a font assigned during + # construction — same reason the font-scale buttons are set here). + attribution_font = font.__class__(font) + attribution_font.setPointSizeF(font.pointSizeF() * ATTRIBUTION_FONT_FACTOR) + self._attribution_label.setFont(attribution_font) + + def _font_larger(self): + self._font_scale = min(self._font_scale + 0.1, 3.0) + self._apply_font_scale() + + def _font_smaller(self): + self._font_scale = max(self._font_scale - 0.1, 0.5) + self._apply_font_scale() + + # ── File operations ─────────────────────────────────────────── + + def _open_file(self): + # Start in the last opened image's folder, with that file selected + # (where the platform supports it). Absolutize first: a relative + # path — e.g. when the app was launched with a relative argument — + # is not resolved by the macOS native panel, which then silently + # falls back to the process's current working directory. + start = self._last_opened_path or self.last_directory + if start: + start = os.path.abspath(start) + path, _ = QFileDialog.getOpenFileName( + self, T("open_image"), start, + f"{T('images')} (*.jpg *.jpeg *.JPEG *.png *.bmp *.tiff *.webp *.heic *.heif *.HEIC *.HEIF);;{T('all_files')} (*)", + ) + if not path: + return + # Engage the wait cursor immediately rather than waiting for + # `_load_file`'s own `_busy_cursor` to kick in. When the dialog + # is dismissed via Enter, Qt processes a flurry of focus and + # leave/enter events between the dialog closing and the next + # function call — without an outer cursor set here, the visual + # feedback gap (default cursor briefly between dialog close and + # `_load_file`'s busy block) is perceptible. Qt's + # setOverrideCursor stack means this composes safely with the + # inner `_busy_cursor` calls. + QApplication.setOverrideCursor(Qt.WaitCursor) + QApplication.processEvents() # flush paint queue so the cursor is visible + try: + self._load_file(path) + finally: + QApplication.restoreOverrideCursor() + + def _handle_open_file(self, path: str): + """Open a file delivered by the macOS file-open event — Finder's + "Open With", double-clicking an associated image, dragging a file + onto the Dock icon, or ``open -a Rectify ``. + + Mirrors the initial-path flow: a document-open supersedes any + pending startup path, the load is deferred until the event loop + settles, and the window is raised (for opens that arrive while + Rectify is already running). + """ + self._initial_path = None + self._initial_dir = None + self._saved_image_path = None # don't briefly restore the last image + self.activateWindow() + self.raise_() + QTimer.singleShot(0, lambda: self._load_file(path)) + + # Extensions matched by the Open dialog and Ctrl+Down/Up navigation. + # Compared case-insensitively against the file's extension. + _NAV_IMAGE_EXTS = frozenset({ + ".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif", ".webp", + ".heic", ".heif", + }) + + def _navigate_image(self, direction: int): + """Load the next (+1) or previous (-1) image in the current directory. + + The file set matches the Open dialog's extensions, hidden files + are skipped, the order is case-insensitive alphabetical, and the + navigation wraps at both ends. Loading runs auto-detect from + scratch, like Open. + """ + if not self._last_opened_path: + return + directory = os.path.dirname(self._last_opened_path) + if not directory or not os.path.isdir(directory): + return + + try: + entries = os.listdir(directory) + except OSError: + return + files = sorted( + (n for n in entries + if not n.startswith(".") + and os.path.splitext(n)[1].lower() in self._NAV_IMAGE_EXTS), + key=str.casefold, + ) + if not files: + return + + current = os.path.basename(self._last_opened_path) + try: + idx = files.index(current) + except ValueError: + # Current file no longer present — pick the boundary that + # reads naturally for the requested direction. + idx = -1 if direction > 0 else 0 + next_idx = (idx + direction) % len(files) + self._load_file(os.path.join(directory, files[next_idx])) + + def _restore_saved_image(self): + """Restore the image and per-image state from the last session. + + Uses :attr:`_image_cache` populated during settings load. The + last-opened path is the cache key; corners, keystone pairs, and + bow value are restored if present. If the cache has nothing + for this image, only the image is loaded (no auto-detect runs — + that's the role of :meth:`_load_file`). + """ + path = self._saved_image_path + self._saved_image_path = None + if not path or not os.path.exists(path): + return + + try: + image_bgr = load_image(path) + except (FileNotFoundError, Exception): + return + + h, w = image_bgr.shape[:2] + dbg_section(f"RESTORE: {path} ({w}x{h})") + self.current_filename = os.path.basename(path) + self.last_directory = os.path.dirname(path) + self._last_opened_path = path + # Save base name defaults to the source image's stem. + self._save_stem = os.path.splitext(os.path.basename(path))[0] + self._focal_length_35mm = read_focal_length_35mm(path) + self.image_stack = [(image_bgr, None)] + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._saved_extract_state = None + self._base_scale = None + # Apply cached state (corners, keystone pairs, bow) if any. + # The cache also covers the legacy single-image settings + # (corners/keystone_pairs/bow_cache) via migration during load. + # Two-phase apply: depth-0 first, refresh aspect ratio for this + # image, then rebuild deeper peel levels with the correct ratio. + abspath = os.path.abspath(path) + entry = self._image_cache.get(abspath, {}) + self.bow_widget.blockSignals(True) + self.bow_widget.setValue(entry.get("bow", 0.0)) + self.bow_widget.blockSignals(False) + self._apply_bow_style() + self.stretch_widget.blockSignals(True) + self.stretch_widget.setValue(entry.get("stretch", 1.0)) + self.stretch_widget.blockSignals(False) + self._apply_stretch_style() + self._cache_apply_color(abspath) + self._cache_apply_widgets(abspath) + self._cache_apply_depth0(abspath) + self._update_aspect_ratio() + self._cache_apply_deeper(abspath) + self._update_display(force_source_image=True) + self.source_panel.setFocus() + + # ── Per-image state cache ──────────────────────────────────── + + def _cache_save_current(self): + """Snapshot the current image's per-image state into the cache. + + Called before switching to another image (in :meth:`_load_file`) + and before persisting settings (in :meth:`_save_settings`), so + that returning to an image restores the user's manual edits + (corner positions, keystone line endpoints, bow value). No-op + if no image is currently loaded. + """ + if not self._last_opened_path or not self.image_stack: + return + abspath = os.path.abspath(self._last_opened_path) + entry = self._image_cache.setdefault(abspath, {}) + # Corners at all peel depths; entries may be None for depths + # where detection hadn't run yet. + entry["corners"] = [ + (corners.tolist() if corners is not None else None) + for _, corners in self.image_stack + ] + # Keystone line pairs, if any have been detected / edited + if self._keystone_pairs: + entry["keystone_pairs"] = [p.tolist() for p in self._keystone_pairs] + # Bow value (also written immediately by _on_bow_changed; kept + # here for completeness/robustness) + entry["bow"] = self.bow_widget.value() + # Stretch value (also written immediately by _on_stretch_changed) + entry["stretch"] = self.stretch_widget.value() + # Color correction — per-image master toggle + mode + per-mode pick + # (also written immediately by the _on_color_* handlers; kept here + # for completeness/robustness). Sticky colors, radii, and targets + # are global last-used values saved at settings level. + entry["color_correct_enabled"] = self._color_correct_enabled + entry["color_mode"] = self._color_mode + for _m, _ck, _colk in (("gray", "gray_center", "gray_color"), + ("white", "white_center", "white_color"), + ("white90", "white90_center", "white90_color")): + entry[_ck] = (list(self._pick_center[_m]) + if self._pick_center[_m] is not None else None) + entry[_colk] = (list(self._pick_color[_m]) + if self._pick_color[_m] is not None else None) + # Aspect ratio: only cache when the user has manually overridden + # it. Without the flag, _update_aspect_ratio auto-computes from + # EXIF + geometry on every load, which is the desired behavior. + if entry.get("aspect_overridden"): + entry["aspect_enabled"] = self.aspect_check.isChecked() + entry["aspect_value"] = float(self.aspect_widget.value()) + + def _cache_apply_widgets(self, abspath: str): + """Restore aspect-ratio widgets from the cache when overridden. + + Aspect ratio fields are only restored when ``aspect_overridden`` + is set. Without an override, :meth:`_update_aspect_ratio` runs + normally and computes a fresh ratio from EXIF + geometry — so + restoring the widgets here would be pointless work (immediately + overwritten). + + Signal blocking prevents the restore from re-firing the handler + and re-setting the override flag. + """ + entry = self._image_cache.get(abspath) + if not entry: + return + if entry.get("aspect_overridden"): + self.aspect_check.blockSignals(True) + self.aspect_widget.blockSignals(True) + enabled = bool(entry.get("aspect_enabled", False)) + self.aspect_check.setChecked(enabled) + value = entry.get("aspect_value") + if value is not None: + self.aspect_widget.setValue(float(value)) + self.aspect_widget.setEnabled(enabled) + self.aspect_check.blockSignals(False) + self.aspect_widget.blockSignals(False) + self._apply_aspect_style() + + def _cache_apply_depth0(self, abspath: str) -> bool: + """Apply cached depth-0 corners + keystone pairs for the new image. + + Returns True if depth-0 corners were applied from the cache (so + the caller can skip auto-detect). Does **not** rebuild the + deeper peel stack — that must happen after + :meth:`_update_aspect_ratio` so the rebuild uses the correct + per-image aspect ratio. Use :meth:`_cache_apply_deeper` after + the aspect update. + """ + entry = self._image_cache.get(abspath) + if not entry: + return False + applied = False + corners_list = entry.get("corners") + if corners_list and self.image_stack and corners_list[0] is not None: + depth0_img = self.image_stack[0][0] + corners_0 = np.array(corners_list[0], dtype=np.float32) + self.image_stack = [(depth0_img, corners_0)] + applied = True + pairs_list = entry.get("keystone_pairs") + if pairs_list: + self._keystone_pairs = [ + np.array(p, dtype=np.float32) for p in pairs_list + ] + self._keystone_image_path = self._last_opened_path + return applied + + def _cache_apply_deeper(self, abspath: str): + """Rebuild deeper peel-stack levels from cached corners. + + Called after :meth:`_update_aspect_ratio` so the intermediate + re-rectifications at depth 1+ use the correct aspect ratio for + the now-active image. No-op if no cached corners exist or the + stack only has depth 0. + """ + entry = self._image_cache.get(abspath) + if not entry: + return + corners_list = entry.get("corners") + if not corners_list or len(corners_list) <= 1: + return + aspect = self._get_aspect_ratio() + for i in range(1, len(corners_list)): + prev_img, prev_corners = self.image_stack[i - 1] + if prev_corners is None: + break + ar = aspect if i - 1 == 0 else None + result = rectify(prev_img, prev_corners, aspect_ratio=ar) + if result is None: + break + inner_corners = (np.array(corners_list[i], dtype=np.float32) + if corners_list[i] is not None else None) + self.image_stack.append((result, inner_corners)) + + def _load_file(self, path: str): + # Wrap the whole load+detect pipeline in `_busy_cursor` so the + # wait cursor is visible from the moment Open is dismissed + # until the right panel updates. Without this, the cursor + # flickers between wait (during auto-detect/keystone-detect, + # each of which has its own _busy_cursor) and normal (during + # load_image and the inter-step state resets). Qt's + # setOverrideCursor stacks, so the inner busy_cursor wraps + # nest harmlessly within the outer one. + with _busy_cursor(): + # Snapshot the outgoing image's state so we can restore it + # if the user comes back later. Done before any state + # reset for the new image. + self._cache_save_current() + # Drop any pending slider recompute for the outgoing image — its + # result is about to be replaced, and the recompute must not land + # on the new image's state. + self._cancel_pending_recompute() + try: + image_bgr = load_image(path) + except FileNotFoundError as e: + QMessageBox.critical(self, "Error", str(e)) + return + + h, w = image_bgr.shape[:2] + dbg_section(f"LOAD FILE: {path} ({w}×{h})") + self.current_filename = os.path.basename(path) + self.last_directory = os.path.dirname(path) + self._last_opened_path = path + # Save base name defaults to the source image's stem. + self._save_stem = os.path.splitext(os.path.basename(path))[0] + self.image_stack = [(image_bgr, None)] + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._saved_extract_state = None + self._keystone_pairs.clear() + self._base_scale = None + self._focal_length_35mm = read_focal_length_35mm(path) + self.source_panel.clear_annotations() # annotations are per-image + abspath = os.path.abspath(path) + cache_entry = self._image_cache.get(abspath, {}) + # Bow value: restore cached if any, else default 0 + self.bow_widget.blockSignals(True) + self.bow_widget.setValue(cache_entry.get("bow", 0.0)) + self.bow_widget.blockSignals(False) + self._apply_bow_style() + # Stretch value: restore cached if any, else default 1.0 (no-op) + self.stretch_widget.blockSignals(True) + self.stretch_widget.setValue(cache_entry.get("stretch", 1.0)) + self.stretch_widget.blockSignals(False) + self._apply_stretch_style() + self._cache_apply_color(abspath) + # Restore strategy/sensitivity/aspect widgets from cache. + # Strategy + sensitivity are always restored when cached + # (so the widgets reflect what produced the cached corners); + # aspect fields restore only when override flag is set. + self._cache_apply_widgets(abspath) + # Two-phase cache apply: depth-0 corners + pairs first, then + # aspect ratio refreshed for *this* image, then deeper peel + # levels rebuilt using the correct aspect. Order matters — + # otherwise the deeper rebuild uses the previous image's + # aspect ratio and the inner crops come out distorted. + corners_applied = self._cache_apply_depth0(abspath) + if not corners_applied: + self._auto_detect() + self._update_aspect_ratio() + if corners_applied: + self._cache_apply_deeper(abspath) + if self._is_lines_mode() and not self._keystone_pairs: + self._init_keystone_pairs() + self._update_display(force_source_image=True) + self.source_panel.setFocus() + + def _save_dir(self) -> str: + """The folder both save modes use: the last-used Save folder, else the + source image's folder, else home.""" + if self._save_as_dir: + return self._save_as_dir + if self._last_opened_path: + return os.path.dirname(os.path.abspath(self._last_opened_path)) + return os.path.expanduser("~") + + def _save(self): + """Save button. Increment OFF → dialog (set name/type/folder); Increment + ON → write the next auto-numbered file with no dialog.""" + if self.current_image is None or self.corners is None: + QMessageBox.warning(self, T("nothing_to_save"), + T("nothing_to_save_detail")) + return + if self.increment_check.isChecked(): + self._save_incremented() + else: + self._save_dialog() + + def _output_stem(self) -> str: + """Default output base name: the save stem with ``_rectified`` appended. + + A bare source stem (e.g. ``hotel``) as the output name would overwrite + the input on a same-folder, same-type save; appending ``_rectified`` + (``hotel`` → ``hotel_rectified``) prevents that and labels the result. + Idempotent — won't double the suffix when re-saving a file that already + ends in ``_rectified`` (e.g. after a prior dialog save updates the stem). + """ + base = self._save_stem or "rectify" + if not base.endswith("_rectified"): + base = f"{base}_rectified" + return base + + def _save_dialog(self): + """Increment OFF: a Save dialog where the typed extension picks the type. + + Pre-filled with the default output target (source stem + ``_rectified`` + + last-used type) in the last-used folder. An all-files filter keeps Qt + from auto-appending a suffix (the old "painting.png.jpg" bug). On + success the save target (folder, stem, type) is updated so Increment + continues from it. + """ + prefill = os.path.join(self._save_dir(), + f"{self._output_stem()}.{self._save_ext}") + path, _ = QFileDialog.getSaveFileName( + self, T("save_rectified"), prefill, f"{T('all_files')} (*)", + ) + if not path: + return + ext = os.path.splitext(path)[1].lower().lstrip(".") + if not ext: + # No extension typed → use the last-used type. + ext = self._save_ext + path = f"{path}.{ext}" + elif ext in SAVE_EXTENSIONS_ACCEPTED: + ext = SAVE_EXT_CANON.get(ext, ext) # jpeg→jpg, tif→tiff + else: + QMessageBox.warning( + self, T("unsupported_type"), + T("unsupported_type_detail").format( + ext=ext, types=", ".join(SAVE_EXTENSIONS_ACCEPTED)), + ) + return + result = self._compute_full_result() + if result is None: + QMessageBox.warning(self, T("cannot_save"), T("cannot_save_detail")) + return + try: + save_image(path, result) + except IOError as e: + QMessageBox.critical(self, T("cannot_save"), str(e)) + return + abspath = os.path.abspath(path) + self._save_as_dir = os.path.dirname(abspath) + self._save_stem = os.path.splitext(os.path.basename(abspath))[0] + self._save_ext = ext + self._last_saved_path = abspath + rh, rw = result.shape[:2] + dbg("gui", f"SAVE: {path} ({rw}×{rh})") + self._update_status() + + def _save_incremented(self): + """Increment ON: write {stem}_{N}.{ext} into the last-used folder, with N + the next unused number. No dialog.""" + result = self._compute_full_result() + if result is None: + QMessageBox.warning(self, T("cannot_save"), T("cannot_save_detail")) + return + directory = self._save_dir() + stem = self._output_stem() + n = find_next_n(directory, stem) + path = os.path.join(directory, f"{stem}_{n}.{self._save_ext}") + try: + save_image(path, result) + except IOError as e: + QMessageBox.critical(self, T("cannot_save"), str(e)) + return + self._save_as_dir = os.path.abspath(directory) + self._last_saved_path = os.path.abspath(path) + rh, rw = result.shape[:2] + dbg("gui", f"SAVE (increment): {path} ({rw}×{rh})") + self._update_status() + + # ── Drag and Drop ───────────────────────────────────────────── + + def dragEnterEvent(self, event: QDragEnterEvent): + if event.mimeData().hasUrls(): + for url in event.mimeData().urls(): + path = url.toLocalFile().lower() + if any(path.endswith(ext) for ext in + ['.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp', + '.heic', '.heif']): + event.acceptProposedAction() + return + + def dropEvent(self, event: QDropEvent): + for url in event.mimeData().urls(): + path = url.toLocalFile() + if os.path.isfile(path): + self._load_file(path) + return + + # ── Detection ───────────────────────────────────────────────── + + def _auto_detect(self, prefer_larger: bool = True): + if not self.image_stack: + return + original = self.image_stack[0][0] + with _busy_cursor(): + _, _, best_corners = evaluate_strategies( + original, prefer_larger=prefer_larger, + ) + self.outermost_corners = best_corners + + def _run_detection(self): + """Re-detect at the current peel depth using auto strategy. + + Called only by :meth:`_rebuild_peel_stack` after aspect-ratio + changes alter the depth-1+ intermediate images. Always uses + the default "auto" strategy + automatic sensitivity sweep + (the GUI no longer exposes Strategy / Sensitivity controls; + CLI flags remain for the technical audience). + """ + if not self.image_stack: + return + self._peel_exhausted = False + depth = len(self.image_stack) - 1 + current_img = self.image_stack[-1][0] + h, w = current_img.shape[:2] + dbg_section(f"RUN DETECTION at depth {depth} ({w}×{h})") + with _busy_cursor(): + new_corners = detect_quad(current_img, pad_image=(depth == 0)) + dbg_corners("result", new_corners) + self.image_stack[-1] = (current_img, new_corners) + self.undo_stack.clear() + self.redo_stack.clear() + if depth == 0: + self._update_aspect_ratio() + self._update_display() + + def _on_aspect_toggled(self, checked): + self._mark_aspect_overridden() + self.aspect_widget.setEnabled(checked) + self._apply_aspect_style() + if len(self.image_stack) > 1: + self._rebuild_peel_stack() + else: + self._update_display() + + # ── Slider debounce ────────────────────────────────────────── + + def _schedule_recompute(self, key: str, fn): + """Defer *fn* (the expensive recompute) until the user pauses. + + Each slider tags its recompute with a *key* ("aspect" / "bow" / + "color"). Re-scheduling the same key just restarts the timer (the + debounce). Scheduling a *different* key while one is pending flushes + the pending one first — so moving from one slider to another within + the debounce window never drops the first slider's recompute. + """ + if (self._pending_recompute is not None + and self._pending_recompute[0] != key): + self._run_pending_recompute() # flush the interrupted gesture + self._pending_recompute = (key, fn) + self._recompute_timer.start() + + def _run_pending_recompute(self): + """Run and clear the pending recompute (timer fired, or flushed).""" + self._recompute_timer.stop() + pending = self._pending_recompute + self._pending_recompute = None + if pending is not None: + pending[1]() + + def _cancel_pending_recompute(self): + """Drop any pending recompute without running it. + + Used when the image context changes (load / restore / reset) so a + deferred recompute can't land on a different image's state. + """ + self._recompute_timer.stop() + self._pending_recompute = None + + def _on_aspect_changed(self, value): + self._mark_aspect_overridden() # cheap cache write — immediate + # Dragging the value makes it the user's own (no longer auto/clamped), + # so refresh the styling from inert/red to live black immediately. + self._apply_aspect_style() + if self.aspect_check.isChecked(): + if len(self.image_stack) > 1: + self._schedule_recompute("aspect", self._rebuild_peel_stack) + else: + self._schedule_recompute("aspect", self._update_display) + + def _mark_aspect_overridden(self): + """Flag that the user has manually touched the aspect controls. + + Subsequent loads will skip :meth:`_update_aspect_ratio`'s + auto-compute and restore the cached aspect_enabled + aspect_value + via :meth:`_cache_apply_widgets`. Reset (Ctrl+D) clears the + flag. + """ + if not self._last_opened_path: + return + abspath = os.path.abspath(self._last_opened_path) + entry = self._image_cache.setdefault(abspath, {}) + entry["aspect_overridden"] = True + entry["aspect_enabled"] = self.aspect_check.isChecked() + entry["aspect_value"] = float(self.aspect_widget.value()) + # A user-touched value is no longer the software's auto estimate, so it + # styles live (needs-attention) rather than inert; and a value the user + # set within the slider range is, by definition, not clamped. + self._aspect_auto = False + self._aspect_clamped = False + + def _on_bow_changed(self, value): + # Bow correction is a post-display cosmetic step on the extracted + # output: same dimensions, only the pixel content changes. Refresh + # just the result panel with preserve_view=True so the user can stay + # zoomed in on a specific edge while finding the minimum value that + # straightens it. Source panel and status bar aren't affected by + # the bow value, so they don't need redrawing. + if self._last_opened_path: + abspath = os.path.abspath(self._last_opened_path) + self._image_cache.setdefault(abspath, {})["bow"] = value + self._apply_bow_style() # cheap spinbox styling — immediate + self._schedule_recompute( + "bow", lambda: self._draw_result(preserve_view=True)) + + def _on_stretch_changed(self, value): + # Stretch changes the corrected output's width (and thus its + # dimensions), but only affects the result panel — the source image and + # its line overlays are unchanged. Redraw just the result panel; + # preserve_view degrades gracefully to a fresh fit when the width + # changes (the pixmap size differs), which is the expected behavior. + if self._last_opened_path: + abspath = os.path.abspath(self._last_opened_path) + self._image_cache.setdefault(abspath, {})["stretch"] = value + self._apply_stretch_style() # cheap spinbox styling — immediate + self._schedule_recompute( + "stretch", lambda: self._draw_result(preserve_view=True)) + + def _rebuild_peel_stack(self): + """Recompute intermediate images and re-detect after aspect ratio change. + + The aspect ratio affects the level-0 output shape, which changes + the intermediate images that inner levels were detected on. + """ + depth = len(self.image_stack) - 1 + aspect = self._get_aspect_ratio() + # Recompute each intermediate image from level 0 up + for i in range(1, len(self.image_stack)): + prev_img, prev_corners = self.image_stack[i - 1] + if prev_corners is None: + break + ar = aspect if i - 1 == 0 else None + new_img = rectify(prev_img, prev_corners, aspect_ratio=ar) + if new_img is None: + # Truncate stack here + self.image_stack = self.image_stack[:i] + break + self.image_stack[i] = (new_img, self.image_stack[i][1]) + # Re-detect at the deepest level on the recomputed image + self._run_detection() + + def _update_aspect_ratio(self): + """Compute aspect ratio from geometry + EXIF and update the control. + + Sets the controls silently (no signal cascades). The caller is + responsible for calling _update_display() afterward. + + If the user has manually overridden aspect ratio for the + current image (``aspect_overridden`` in the cache), the + override values are restored via :meth:`_cache_apply_widgets` + before this call, and this method returns early without + overwriting them. Override is cleared by Reset (Ctrl+D). + """ + if self._last_opened_path: + abspath = os.path.abspath(self._last_opened_path) + entry = self._image_cache.get(abspath) + if entry is not None and entry.get("aspect_overridden"): + # The restored value is the user's, not an auto estimate. + self._aspect_auto = False + self._aspect_clamped = False + self._apply_aspect_style() + return + # Block all signals to prevent cascading updates + self.aspect_check.blockSignals(True) + self.aspect_widget.blockSignals(True) + + if not self.image_stack or self.outermost_corners is None: + self.aspect_check.setChecked(False) + self.aspect_widget.setEnabled(False) + self._aspect_auto = False + self._aspect_clamped = False + else: + img = self.image_stack[0][0] + h, w = img.shape[:2] + ratio = estimate_aspect_ratio( + self.outermost_corners, w, h, self._focal_length_35mm, + ) + if ratio is not None and self._focal_length_35mm is not None: + # Confident estimate: the homography decomposition recovered the + # true ratio from the EXIF focal length. Apply it and mark it + # auto, so the value styles inert ("no action needed from you"). + # If the true ratio is outside the slider's 0.10–10.0 range, pin + # it at the limit and flag it clamped (red) — the control can't + # represent it, so the correction stays under-stretched. + lo, hi = 0.10, 10.0 + self._aspect_clamped = ratio > hi or ratio < lo + self.aspect_widget.setValue(min(max(ratio, lo), hi)) + self.aspect_check.setChecked(True) + self.aspect_widget.setEnabled(True) + self._aspect_auto = True + else: + # No usable camera data (no EXIF focal length, or the estimate + # was rejected as unreliable). The software can't recover the + # ratio, so the control becomes the user's responsibility: seed + # it with the raw projected ratio — which makes "on" produce the + # same pixels as "off" — but leave it on, enabled, and (via the + # not-auto flag) live-styled so its appearance flags that this + # value needs the user's attention. + raw_w, raw_h = compute_output_size_corrected( + self.outermost_corners, None) + self.aspect_widget.setValue(raw_w / raw_h) + self.aspect_check.setChecked(True) + self.aspect_widget.setEnabled(True) + self._aspect_auto = False + # No recovered "true" ratio to exceed the range — not clamped. + self._aspect_clamped = False + + self.aspect_check.blockSignals(False) + self.aspect_widget.blockSignals(False) + self._apply_aspect_style() + + def _get_aspect_ratio(self) -> float | None: + """Return the active aspect ratio, or None if correction is off.""" + if self.aspect_check.isChecked(): + return self.aspect_widget.value() + return None + + def _apply_aspect_style(self): + """Style the aspect-ratio value spinbox by whether it needs attention. + + Gray italics (`INACTIVE_SPIN_STYLE`) means **"no action needed from + you"**: either the correction is off, or a confident value was + recovered automatically from the photo's camera data (`_aspect_auto`). + Normal styling means the value is the user's responsibility — the + software found no usable camera data (so the shown value is just a + starting guess) or the user has overridden the estimate — so its live + appearance is the cue to review and adjust it. + + The checkmark, not the color, carries "applied vs not": an unchecked + gray value is off; a checked gray value is an applied auto estimate. + + **Red** takes precedence over both: it means the recovered ratio + exceeded the slider's 0.10–10.0 range and was pinned at the limit, so + the correction can't fully reach the true proportions. + + The displayed value is preserved either way; we don't force it to 1.0 + when off (Rectify uses geometric averaging when aspect is off, not a + 1.0 ratio, so showing 1.0 would be misleading). + """ + if self._aspect_clamped: + self.aspect_widget.spin.setStyleSheet(CLAMPED_SPIN_STYLE) + elif self._aspect_auto or not self.aspect_check.isChecked(): + self.aspect_widget.spin.setStyleSheet(INACTIVE_SPIN_STYLE) + else: + self.aspect_widget.spin.setStyleSheet("") + + def _apply_bow_style(self): + """Style the bow value spinbox by whether the value is a no-op. + + Bow at 0.00 reduces ``apply_bow_correction`` to identity, so the + spinbox is shown in gray italics to flag that the correction + isn't doing anything. Any non-zero value reverts to the normal + style. Bow has no on/off toggle (unlike aspect), so the + active/inactive distinction comes from the value itself. + """ + if self.bow_widget.value() == 0.0: + self.bow_widget.spin.setStyleSheet(INACTIVE_SPIN_STYLE) + else: + self.bow_widget.spin.setStyleSheet("") + + def _apply_stretch_style(self): + """Style the stretch value spinbox by whether the value is a no-op. + + Stretch at 1.00 is an identity scale (no change to the width/height + relationship), so the spinbox shows gray italics — inert, "no action + needed from you," the same convention Bow uses at 0.00. Any other + value reverts to the normal style. Like Bow, there's no on/off + toggle; the distinction comes from the value itself. + """ + if self.stretch_widget.value() == 1.0: + self.stretch_widget.spin.setStyleSheet(INACTIVE_SPIN_STYLE) + else: + self.stretch_widget.spin.setStyleSheet("") + + # ── Gray-card color correction ────────────────────────────── + + def _white_stops(self) -> float: + """White brightness as exposure stops (centered value/50 → ±2 stops).""" + return self._white_brightness / 50.0 + + def _mode_gains(self, sampled: np.ndarray | None) -> np.ndarray | None: + """Per-channel gains for the active mode from a sampled patch. + + Gray couples color + exposure via the reflectance target; White + neutralizes the cast while preserving the patch's luminance, with + an independent brightness factor. Returns None for an unusable + (clipped/crushed) patch in either mode. + """ + if self._color_mode == "white": + return white_correction_gains(sampled, self._white_stops()) + if self._color_mode == "gray": + return gray_correction_gains(sampled, self._gray_reflectance / 100.0) + if self._color_mode == "white90": + # Diffuse white: the coupled correction locked to the 90% + # reflectance standard (no adjustable target). + return gray_correction_gains(sampled, 0.90) + return None # no mode selected yet + + def _active_sample(self) -> np.ndarray | None: + """The neutral color the correction is currently based on (BGR). + + Reads the *active mode's* per-image state, in priority order: + 1. own pick (``_pick_center[mode]``) → measured live from this + image's pixels at that position. + 2. frozen snapshot (``_pick_color[mode]``) → a fixed value + carried from another image (position undefined), NOT a live + link — re-picking the reference later does not change it. + + The global sticky color is *not* read here; it only seeds a + snapshot at pick / right-click / enable time. Returns None when + the active mode has neither. + """ + m = self._color_mode + if m is None: + return None + center = self._pick_center[m] + if center is not None and self.image_stack: + src = self.image_stack[0][0] + return sample_patch_bgr(src, center[0], center[1], self._radius[m]) + color = self._pick_color[m] + if color is not None: + return np.array(color, dtype=np.float64) + return None + + def _snapshot_sticky_to_image(self) -> bool: + """Freeze a copy of the active mode's sticky color onto this image. + + Used by right-click (explicit "reuse the last reference") and by + enabling Color correct on an image with no reference of its own in + the active mode. Clears the live sample point so the image uses the + frozen color. Returns False (no-op) if there is no sticky color. + """ + m = self._color_mode + if m is None or self._sticky_color[m] is None: + return False + self._pick_color[m] = tuple(self._sticky_color[m]) + self._pick_center[m] = None + return True + + def _refresh_sticky_from_sample(self): + """Update the active mode's sticky color from this image's sample. + + Called after a pick or a radius change. Only a valid (non-clipped) + sample replaces the sticky color, so a bad click never poisons the + value inherited by other images. + """ + m = self._color_mode + if m is None: + return + center = self._pick_center[m] + if center is None or not self.image_stack: + return + src = self.image_stack[0][0] + sampled = sample_patch_bgr(src, center[0], center[1], self._radius[m]) + if sampled is not None and self._mode_gains(sampled) is not None: + self._sticky_color[m] = tuple(float(v) for v in sampled[:3]) + + def _color_gains(self) -> np.ndarray | None: + """Current per-channel gains, or None if correction isn't active. + + Returns None when disabled, when the active mode has no reference + (no pick and no frozen color), or when the patch is unusable + (clipped/crushed). + """ + if not self._color_correct_enabled: + return None + return self._mode_gains(self._active_sample()) + + def _apply_color_correction(self, result: np.ndarray) -> np.ndarray: + """Apply the current color-correction gains to a finished result.""" + gains = self._color_gains() + if gains is None: + return result + return apply_gray_correction(result, gains) + + def _update_color_widgets_visibility(self): + """Show the controls only when on, gated by whether a mode is chosen. + + When enabled but no mode is selected yet, only the Gray/White radio + is shown — the user must choose a mode first. Once a mode is + chosen, the swatch + Radius appear, plus the target widget for that + mode (Reflectance for Gray, Brightness for White — exactly one). + """ + checked = self.gray_check.isChecked() + has_mode = checked and self._color_mode in ("gray", "white", "white90") + is_gray = checked and self._color_mode == "gray" # Gray card -> Reflectance + is_white = checked and self._color_mode == "white" # Neutral gray -> Brightness + # The mode radios show whenever color correct is on (to pick a mode); + # the swatch, target and Sample size need a chosen mode. 90% White is + # locked at 90%, so it shows no target widget — swatch + Sample size only. + for r in (self.color_gray_radio, self.color_white_radio, + self.color_white90_radio): + r.setVisible(checked) + self.gray_value_label.setVisible(has_mode) + self.gray_swatch.setVisible(has_mode) + self.gray_reflectance_label.setVisible(is_gray) + self.gray_reflectance_widget.setVisible(is_gray) + self.white_brightness_label.setVisible(is_white) + self.white_brightness_widget.setVisible(is_white) + self.gray_radius_label.setVisible(has_mode) + self.gray_radius_widget.setVisible(has_mode) + self.gray_swatch.setToolTip(tip(self._color_hint_text())) + + def _update_color_swatch(self): + """Repaint the swatch: sampled color, red if unusable, armed border. + + Shows the active mode's neutral color — this image's sample if it + has a pick, otherwise its frozen snapshot color. Red when the + patch is clipped/crushed (no valid gains); neutral-gray placeholder + when the active mode has no reference. A dashed border indicates + pick mode is armed. + """ + sampled = self._active_sample() + if sampled is None: + color = QColor(128, 128, 128) # nothing sampled yet + elif self._mode_gains(sampled) is None: + color = QColor(220, 40, 40) # clipped / crushed — unusable + else: + b, g, r = sampled[:3] + color = QColor(int(round(r)), int(round(g)), int(round(b))) + self.gray_swatch.setStyleSheet( + _color_swatch_style(color, self._gray_pick_armed)) + + def _color_hint_text(self) -> str: + """The active mode's pick instruction — shared by the swatch tooltip + and the centered pop-up hint shown while a pick is armed.""" + key = {"gray": "color_hint_gray", "white": "color_hint_white", + "white90": "color_hint_white90"}.get(self._color_mode) + return T(key) if key else "" + + def _arm_gray_pick(self): + """Arm pick mode: the next source-panel click sets the sample center. + + Also shows a centered hint over the source panel prompting the user to + click a reference in the image; it clears when the mouse enters the + panel (ImagePanel.enterEvent) or when the pick completes / disarms. + """ + self._gray_pick_armed = True + self.source_panel.set_gray_pick_armed(True) + self.source_panel.show_hint(self._color_hint_text()) + self._update_color_swatch() + + def _disarm_gray_pick(self): + self._gray_pick_armed = False + self.source_panel.set_gray_pick_armed(False) + self.source_panel.hide_hint() + self._update_color_swatch() + + def _on_color_point_picked(self, x: float, y: float): + """Set the active mode's sample center from a source-panel click. + + Picking gives this image its own live measurement (clearing any + frozen snapshot color) and updates the active mode's sticky + color, so the freshly-measured reference becomes the default + snapshot for other images. + """ + if not self.image_stack or self._color_mode is None: + self._disarm_gray_pick() + return + m = self._color_mode + h, w = self.image_stack[0][0].shape[:2] + self._pick_center[m] = (min(max(x, 0.0), w - 1.0), + min(max(y, 0.0), h - 1.0)) + self._pick_color[m] = None # own pick supersedes any frozen snapshot + self._disarm_gray_pick() + self._refresh_sticky_from_sample() + self._cache_save_color() + self._update_color_swatch() + self._draw_source() # update marker + self._draw_result(preserve_view=True) # apply correction; keep output zoom + self._update_status() + + def _on_color_swatch_snapshot(self): + """Right-click the swatch: reuse the last (sticky) reference here. + + Freezes a snapshot of the active mode's sticky color onto this + image (position undefined, marker hidden). It's a fixed copy — + later changes to the reference image do not propagate here. No-op + with a status hint when nothing has been measured yet in this mode. + """ + if not self._color_correct_enabled: + return + if not self._snapshot_sticky_to_image(): + self.status_bar.showMessage(T("gray_no_saved"), 4000) + return + self._cache_save_color() + self._update_color_swatch() + self._draw_source() # marker disappears (no own pick) + self._draw_result(preserve_view=True) # keep output zoom + self._update_status() + + def _on_color_toggled(self, checked: bool): + self._color_correct_enabled = checked + if not checked and self._gray_pick_armed: + self._disarm_gray_pick() + # Enabling on an image with no reference of its own in the active + # mode snapshots the current sticky color (frozen, not a live + # link). If there's no sticky yet the user must pick. Never + # overwrites an existing own pick or frozen color. + m = self._color_mode + if (checked and m is not None and self._pick_center[m] is None + and self._pick_color[m] is None): + self._snapshot_sticky_to_image() + self._update_color_widgets_visibility() + self._cache_save_color() + self._update_color_swatch() + self._draw_source() # show/hide marker + self._draw_result(preserve_view=True) # apply/remove correction; keep output zoom + self._update_status() + + def _on_color_mode_changed(self): + """Switch the reference mode (Gray/White) for the current image. + + Swaps in the new mode's target widget, radius value, pick, and + frozen color. Does NOT auto-seed from sticky — switching just + reveals whatever the new mode already has (possibly nothing). + """ + if self.color_white90_radio.isChecked(): + self._color_mode = "white90" + elif self.color_white_radio.isChecked(): + self._color_mode = "white" + else: + self._color_mode = "gray" + self._sync_color_global_widgets() # show this mode's target + radius + self._cache_save_color() # persist the mode choice + self._update_color_widgets_visibility() + self._update_color_swatch() + self._draw_source() # marker follows the new mode's pick + self._draw_result(preserve_view=True) # keep output zoom + self._update_status() + + def _on_gray_radius_changed(self, value: float): + if self._color_mode is None: + return # radius widget is hidden without a mode; guard anyway + self._radius[self._color_mode] = int(round(value)) # per-mode last-used + # Cheap feedback stays immediate (re-sample, swatch, marker, the + # sampled-mean readout); only the warp recompute is debounced. + self._refresh_sticky_from_sample() # re-measures if this image has a pick + self._update_color_swatch() + self._draw_source() # marker size + self._update_status() # sampled mean (and readout) changed + self._schedule_recompute( + "color", lambda: self._draw_result(preserve_view=True)) + + def _on_gray_reflectance_changed(self, value: float): + self._gray_reflectance = int(round(value)) # gray target, last-used + # Reflectance (the target tone) affects the gains/result but not + # the sampled color, so the swatch and marker are unchanged. + self._schedule_recompute( + "color", lambda: self._draw_result(preserve_view=True)) + + def _on_white_brightness_changed(self, value: float): + self._white_brightness = int(round(value)) # white target, last-used + # Brightness scales the result but does not change the sampled + # color or its clip validity, so swatch and marker are unchanged. + self._schedule_recompute( + "color", lambda: self._draw_result(preserve_view=True)) + + def _cache_save_color(self): + """Persist this image's color-correction state (immediately). + + Per-image: ``color_correct_enabled`` (master toggle), + ``color_mode``, and a separate pick per mode (gray_center/gray_color, + white_center/white_color). Sticky colors, radii, and the targets + are global last-used values saved at the settings level. + """ + if not self._last_opened_path: + return + abspath = os.path.abspath(self._last_opened_path) + entry = self._image_cache.setdefault(abspath, {}) + entry["color_correct_enabled"] = self._color_correct_enabled + entry["color_mode"] = self._color_mode + for m, ckey, colkey in (("gray", "gray_center", "gray_color"), + ("white", "white_center", "white_color"), + ("white90", "white90_center", "white90_color")): + entry[ckey] = (list(self._pick_center[m]) + if self._pick_center[m] is not None else None) + entry[colkey] = (list(self._pick_color[m]) + if self._pick_color[m] is not None else None) + + def _cache_apply_color(self, abspath: str): + """Restore this image's color-correction state. + + Reads ``color_correct_enabled`` (falling back to the legacy + first-release ``gray_enabled``), ``color_mode`` (default "gray"), + and both modes' picks. Globals (sticky colors, radii, targets) + are left untouched so they carry across images. Signals blocked so + the restore doesn't re-fire the handlers. Legacy per-image + ``gray_radius`` / ``gray_reflectance`` / ``gray_target`` keys are + ignored. + """ + entry = self._image_cache.get(abspath, {}) + self._color_correct_enabled = bool( + entry.get("color_correct_enabled", + entry.get("gray_enabled", False))) + # Mode: a new image (no key) starts with *no* mode selected. Legacy + # first-release files predate color_mode but encode a gray + # correction via gray_enabled / gray_center / gray_color — migrate + # those to "gray" so existing corrections still load. + if "color_mode" in entry: + mode = entry["color_mode"] + elif (entry.get("gray_enabled") or entry.get("gray_center") + or entry.get("gray_color")): + mode = "gray" + else: + mode = None + self._color_mode = mode if mode in ("gray", "white", "white90") else None + for m, ckey, colkey in (("gray", "gray_center", "gray_color"), + ("white", "white_center", "white_color"), + ("white90", "white90_center", "white90_color")): + center = entry.get(ckey) + self._pick_center[m] = ((float(center[0]), float(center[1])) + if center else None) + color = entry.get(colkey) + self._pick_color[m] = (tuple(float(v) for v in color) + if color else None) + self._gray_pick_armed = False + self.source_panel.set_gray_pick_armed(False) + self.gray_check.blockSignals(True) + self.gray_check.setChecked(self._color_correct_enabled) + self.gray_check.blockSignals(False) + self._set_color_mode_radios(self._color_mode) + self._sync_color_global_widgets() + self._update_color_widgets_visibility() + self._update_color_swatch() + + def _set_color_mode_radios(self, mode: str | None): + """Reflect *mode* (None / "gray" / "white") in the radio buttons. + + An exclusive QButtonGroup won't let setChecked(False) clear the + selected button, so exclusivity is dropped while clearing to the + no-mode state and restored afterwards. + """ + grp = self._color_mode_group + grp.setExclusive(False) + radios = (self.color_gray_radio, self.color_white_radio, + self.color_white90_radio) + for r in radios: + r.blockSignals(True) + self.color_gray_radio.setChecked(mode == "gray") + self.color_white_radio.setChecked(mode == "white") + self.color_white90_radio.setChecked(mode == "white90") + for r in radios: + r.blockSignals(False) + grp.setExclusive(True) + + def _sync_color_global_widgets(self): + """Push the active mode's target + radius into their widgets. + + With no mode selected the target/radius widgets are hidden, so the + radius falls back to the default purely to keep the widget valid. + """ + self.gray_reflectance_widget.blockSignals(True) + self.gray_reflectance_widget.setValue(self._gray_reflectance) + self.gray_reflectance_widget.blockSignals(False) + self.white_brightness_widget.blockSignals(True) + self.white_brightness_widget.setValue(self._white_brightness) + self.white_brightness_widget.blockSignals(False) + self.gray_radius_widget.blockSignals(True) + self.gray_radius_widget.setValue( + self._radius.get(self._color_mode, GRAY_RADIUS_DEFAULT)) + self.gray_radius_widget.blockSignals(False) + + def _reset_color_state(self, clear_globals: bool = False): + """Clear color correction. + + Reset (Ctrl+D) clears only this image's correction (off, no picks in + either mode) and keeps the sticky colors + radii + targets so an + in-progress batch calibration survives. Clear-cache + (Ctrl+Shift+Delete) passes ``clear_globals=True`` to also reset the + global sticky colors / radii / targets and the mode to defaults. + """ + self._color_correct_enabled = False + # A reset image returns to the fresh state: no mode selected. + self._color_mode = None + for m in ("gray", "white", "white90"): + self._pick_center[m] = None + self._pick_color[m] = None + self._gray_pick_armed = False + self.source_panel.set_gray_pick_armed(False) + if clear_globals: + for m in ("gray", "white", "white90"): + self._sticky_color[m] = None + self._radius[m] = GRAY_RADIUS_DEFAULT + self._gray_reflectance = round(GRAY_REFLECTANCE_DEFAULT * 100) + self._white_brightness = 0 + self._sync_color_global_widgets() + self._set_color_mode_radios(None) + self.gray_check.blockSignals(True) + self.gray_check.setChecked(False) + self.gray_check.blockSignals(False) + self._update_color_widgets_visibility() + self._update_color_swatch() + + # ── Full-image perspective correction ──────────────────────── + + def _is_transform_mode(self) -> bool: + return self.action_transform_radio.isChecked() + + def _is_lines_mode(self) -> bool: + return self._is_transform_mode() and self.method_lines_radio.isChecked() + + def _on_action_changed(self): + self._update_transform_widgets_visibility() + if self._is_transform_mode() and self.image_stack: + # Save Extract-mode state so switching back restores it + self._saved_extract_state = { + "image_stack": list(self.image_stack), + "undo_stack": list(self.undo_stack), + "redo_stack": list(self.redo_stack), + "peel_exhausted": self._peel_exhausted, + } + # Collapse to depth 0 with the currently displayed quad + overlay = self._overlay_corners() + original = self.image_stack[0][0] + self.image_stack = [(original, overlay)] + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._transform_corners_edited = False + elif not self._is_transform_mode() and self._saved_extract_state is not None: + # Only carry corners back if they were edited in Transform mode + if self._transform_corners_edited: + current_corners = self.outermost_corners + saved_stack = self._saved_extract_state["image_stack"] + if current_corners is not None and saved_stack: + saved_img = saved_stack[0][0] + saved_stack[0] = (saved_img, current_corners.copy()) + # Restore the Extract-mode state (with or without updated corners) + self.image_stack = self._saved_extract_state["image_stack"] + self.undo_stack = self._saved_extract_state["undo_stack"] + self.redo_stack = self._saved_extract_state["redo_stack"] + self._peel_exhausted = self._saved_extract_state["peel_exhausted"] + self._saved_extract_state = None + # If switching into Transform while Lines is the active method, the + # keystone pairs must be detected for the current image before the + # display refresh — otherwise `_draw_result`'s Lines branch finds an + # empty `_keystone_pairs` (detection is deferred and never ran for a + # freshly-loaded image) and shows a blank result. `_on_method_changed` + # does this when the method radio changes, but that handler doesn't + # fire when only the action radio toggles. + if self._is_lines_mode() and self.image_stack: + if (not self._keystone_pairs + or self._keystone_image_path != self._last_opened_path): + self._init_keystone_pairs() + self._update_button_states() + self._update_display() + + def _on_fullimg_mode_changed(self): + self._update_transform_widgets_visibility() + if self._is_transform_mode(): + self._update_display() + + def _update_transform_widgets_visibility(self): + transform = self._is_transform_mode() + lines = transform and self._is_lines_mode() + # Group containers show/hide as a unit so their internal + # spacing collapses with them. + self._method_grp.setVisible(transform) + self.vertical_check.setVisible(lines) + self.horizontal_check.setVisible(lines) + self.fullimg_crop_check.setVisible(transform) + show_fill = transform and not self.fullimg_crop_check.isChecked() + self._fill_grp.setVisible(show_fill) + # Aspect ratio applies to the Extract output and to the Transform → + # Quad full-image warp: both map a reference rectangle, so its true + # width/height matters. Transform → Lines derives its own output + # dimensions from the keystone correction, so the control is hidden + # only there. + self._aspect_grp.setVisible(not lines) + # Bow correction operates on the extracted output and assumes + # the output's corners are the image corners. That's true in + # Extract mode but not in Transform mode (where the warped + # image's corners don't have any particular meaning), so hide + # the control there. + self._bow_grp.setVisible(not transform) + # Stretch corrects the residual width/height ratio that the keystone + # correction can't recover, so it's meaningful only in Transform → + # Lines. + self._stretch_grp.setVisible(lines) + + def _update_fill_color_icon(self): + """Paint the fill button as a flat color square outlined in black.""" + self.fill_color_btn.setStyleSheet(_color_swatch_style(self._fill_color)) + + def _pick_fill_color(self): + color = QColorDialog.getColor( + self._fill_color, self, T("fill_color"), + ) + if color.isValid(): + self._fill_color = color + self._update_fill_color_icon() + if self._is_transform_mode(): + self._update_display() + + # ── Keystone (Lines mode) ──────────────────────────────────── + + def _on_method_changed(self): + """Switch between Quad and Lines method within Transform mode.""" + self._update_transform_widgets_visibility() + if self._is_lines_mode(): + # Re-detect if no pairs or if the image changed since last detection + if (not self._keystone_pairs + or self._keystone_image_path != self._last_opened_path): + self._init_keystone_pairs() + self._draw_source() + else: + self._draw_source() + self._draw_result() + self._update_status() + self._update_button_states() + + def _on_lines_check_toggled(self, _checked: bool): + """Rebuild keystone pairs when Vertical/Horizontal checkboxes change.""" + if self._is_lines_mode(): + self._rebuild_keystone_pairs() + self._draw_source() + self._draw_result() + self._update_status() + + def _init_keystone_pairs(self): + """Detect default line pairs from the current image. + + Uses Hough line detection on each half of the image to find the + strongest vertical and horizontal edges. Falls back to geometric + defaults if detection finds nothing. Deferred until the user + selects Lines mode to avoid computation on every image load. + """ + if not self.image_stack: + return + image = self.image_stack[0][0] + h, w = image.shape[:2] + + with _busy_cursor(): + vpair, hpair = detect_keystone_lines(image) + + # Cache detected pairs and geometric fallbacks + if vpair is not None: + self._detected_vpair = vpair + else: + self._detected_vpair = np.array([ + [w * 0.25, h * 0.20], + [w * 0.25, h * 0.80], + [w * 0.75, h * 0.20], + [w * 0.75, h * 0.80], + ], dtype=np.float32) + + if hpair is not None: + self._detected_hpair = hpair + else: + self._detected_hpair = np.array([ + [w * 0.20, h * 0.25], + [w * 0.80, h * 0.25], + [w * 0.20, h * 0.75], + [w * 0.80, h * 0.75], + ], dtype=np.float32) + + self._keystone_image_path = self._last_opened_path + self._rebuild_keystone_pairs() + + def _rebuild_keystone_pairs(self): + """Build the keystone pairs list from checkbox state and cached detections.""" + self._keystone_pairs.clear() + if self.vertical_check.isChecked(): + vp = getattr(self, '_detected_vpair', None) + if vp is not None: + self._keystone_pairs.append(vp) + if self.horizontal_check.isChecked(): + hp = getattr(self, '_detected_hpair', None) + if hp is not None: + self._keystone_pairs.append(hp) + + def _on_arrow_moved(self, pair_index: int, endpoint_index: int, + x: float, y: float, dragging: bool): + """Handle arrow handle drag — update stored pair data.""" + if pair_index < len(self._keystone_pairs): + self._keystone_pairs[pair_index][endpoint_index] = [x, y] + + def _on_arrow_released(self, pair_index: int, endpoint_index: int): + """Handle arrow handle release — update result display.""" + self._draw_result() + self._update_status() + + # ── Peel stack ──────────────────────────────────────────────── + + def _peel_in(self): + if self.outermost_corners is None: + return + depth = len(self.image_stack) - 1 + dbg_section(f"PEEL IN from depth {depth}") + # Compute the current result WITHOUT the cosmetic post-steps (bow, + # gray-card color correction). This geometric-only image becomes + # the next peel layer's base and the one we detect on. Baking the + # post-steps in here would make each deeper level re-apply them + # (compounding the color correction on every peel) — they must be + # applied exactly once, at the final _compute_full_result exit. + result = self._compute_full_result(apply_post=False) + if result is None: + dbg("gui", "peel in failed: result is None (too small)") + QMessageBox.information(self, T("peel_in"), T("peel_too_small")) + self._peel_exhausted = True + self._update_button_states() + return + rh, rw = result.shape[:2] + dbg("gui", f"peel result: {rw}×{rh}, detecting inner quad...") + # Detect on the result before committing to the peel. + # When the user has moved/resized the region to select a subimage, + # prefer the largest detection (the painting fills most of the crop). + # For normal peel (looking for inner frame), prefer the smallest. + region_moved = self.source_panel.region_moved + # Try without padding first (preserves existing detection behavior). + # If that finds nothing, retry with padding — tight outer-frame + # crops can leave inner content near the image edges, causing the + # border-rejection check to discard valid detections. + with _busy_cursor(): + best_strategy, best_sensitivity, best_corners = evaluate_strategies( + result, prefer_larger=region_moved, pad_image=False, + ) + if best_corners is None: + dbg("gui", "peel: no detection without padding, retrying with padding") + best_strategy, best_sensitivity, best_corners = evaluate_strategies( + result, prefer_larger=region_moved, pad_image=True, + ) + if best_corners is None: + dbg("gui", "peel in failed: no quad detected in result") + QMessageBox.information(self, T("peel_in"), T("peel_no_quad")) + self._peel_exhausted = True + self._update_button_states() + return + dbg("gui", f"peel in success: {best_strategy} s={best_sensitivity:.1f}") + dbg_corners("inner corners", best_corners) + self.image_stack.append((result, best_corners)) + self.undo_stack.clear() + self.redo_stack.clear() + self.source_panel.region_moved = False + self._update_display() + + def _peel_out(self): + if len(self.image_stack) > 1: + removed_depth = len(self.image_stack) - 1 + dbg_section(f"PEEL OUT from depth {removed_depth}") + self.image_stack.pop() + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._update_display() + + # ── Re-detect (full reset) ─────────────────────────────────── + + def _reset(self): + """Reset all state and re-detect on the current image.""" + if not self.image_stack: + return + self._cancel_pending_recompute() # drop deferred slider recomputes + with _busy_cursor(): + depth = len(self.image_stack) - 1 + dbg_section(f"RESET (was depth {depth})") + original = self.image_stack[0][0] + self.image_stack = [(original, None)] + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._saved_extract_state = None + self._keystone_pairs.clear() + self._reset_color_state() + # Clear the per-image aspect override so _update_aspect_ratio + # below actually re-computes. Without this clear, a + # previously-overridden aspect would short-circuit and Reset + # would not visibly change the aspect. + if self._last_opened_path: + abspath = os.path.abspath(self._last_opened_path) + entry = self._image_cache.get(abspath) + if entry is not None: + entry.pop("aspect_overridden", None) + if self._is_transform_mode(): + self.action_extract_radio.setChecked(True) + self._update_transform_widgets_visibility() + self._auto_detect() + self._update_aspect_ratio() + self._update_display(force_source_image=True) + + def _reopen(self): + """Re-open the currently-loaded file (Ctrl+R). + + Delegates to :meth:`_load_file`, which snapshots the current + image's per-image state into ``_image_cache`` *before* re-reading + the file, then re-applies that snapshot afterward. Net result: + the pixels are re-read from disk while corners, keystone pairs, + and bow value are preserved — convenient when the source image + has been edited externally. Aspect ratio is re-computed by + :meth:`_update_aspect_ratio` (or restored from override cache, + if one exists). + """ + if self._last_opened_path: + self._load_file(self._last_opened_path) + + # ── Undo / Redo ─────────────────────────────────────────────── + + def _push_undo(self): + """Save the current active corners (at whatever depth) for undo.""" + corners = self.image_stack[-1][1] if self.image_stack else None + if corners is not None: + self.undo_stack.append(corners.copy()) + self.redo_stack.clear() + + def _refresh_aspect_after_edit(self): + """Re-derive the auto aspect ratio after a committed depth-0 quad edit. + + Only the depth-0 quad feeds the aspect estimate, so this is a no-op at + deeper peel levels. `_update_aspect_ratio` itself returns early when the + user has manually overridden aspect, so a hand-set value survives edits. + **Every operation that changes the depth-0 quad must call this** — corner/ + edge/center drag, arrow nudge, and Ctrl-snap (all via `_on_corner_released`), + AND undo/redo — so the estimate tracks the quad the user settles on rather + than freezing at whatever the previous edit left. (Transform/Quad always + sits at depth 0, so there it always fires; in Extract mode it correctly + skips depth > 0, where aspect is fixed at level 0.) + """ + if len(self.image_stack) - 1 == 0: + self._update_aspect_ratio() + + def _undo(self): + corners = self.image_stack[-1][1] if self.image_stack else None + if not self.undo_stack or corners is None: + return + self.redo_stack.append(corners.copy()) + img = self.image_stack[-1][0] + self.image_stack[-1] = (img, self.undo_stack.pop()) + self._refresh_aspect_after_edit() + self._update_display() + + def _redo(self): + corners = self.image_stack[-1][1] if self.image_stack else None + if not self.redo_stack or corners is None: + return + self.undo_stack.append(corners.copy()) + img = self.image_stack[-1][0] + self.image_stack[-1] = (img, self.redo_stack.pop()) + self._refresh_aspect_after_edit() + self._update_display() + + # ── Corner interaction ──────────────────────────────────────── + + def _on_center_drag_started(self): + """Collapse peel stack to depth 0 when center-drag starts. + + Center-drag selects a region on the original image. At depth > 0 + the overlay represents deepest corners mapped back to original + space — forward-mapping drag positions would produce nonsensical + results. Instead, adopt the overlay positions as the new + outermost corners and drop all peel levels. + """ + depth = len(self.image_stack) - 1 + if depth == 0 or not self.image_stack: + return + overlay = self._overlay_corners() + if overlay is None: + return + dbg("gui", f"center-drag at depth {depth}: collapsing to depth 0") + original = self.image_stack[0][0] + self.image_stack = [(original, overlay.copy())] + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._drag_undo_pushed = False + + def _on_corner_moved(self, index: int, x: float, y: float, dragging: bool): + if not self.image_stack: + return + if self._is_transform_mode(): + self._transform_corners_edited = True + # Don't push undo for center-drag (positioning only) + if dragging and not self.source_panel._center_drag_active \ + and not getattr(self, '_drag_undo_pushed', False): + self._push_undo() + self._drag_undo_pushed = True + + depth = len(self.image_stack) - 1 + if depth == 0: + # Direct: update outermost corners + if self.outermost_corners is not None: + self.outermost_corners[index] = [x, y] + else: + # Map the position in original coords forward to the deepest level + mapped = self._map_to_deepest(np.array([x, y], dtype=np.float32)) + deepest_corners = self.image_stack[-1][1] + if deepest_corners is not None and mapped is not None: + deepest_corners[index] = mapped + + def _on_corner_released(self, index: int): + self._drag_undo_pushed = False + # Re-derive the auto aspect ratio from the quad the user settled on, so + # a corrected detection updates the estimate instead of leaving it + # frozen. All committed quad gestures (corner drag, snap, nudge, + # edge/center drag) funnel through here; undo/redo share the same helper. + self._refresh_aspect_after_edit() + self._draw_result() + self._update_status() + self._update_button_states() + + # ── Display ─────────────────────────────────────────────────── + + def _update_display(self, force_source_image: bool = False): + """Refresh both panels, title, status, and button states.""" + self._draw_source(force_image=force_source_image) + self._draw_result() + self._update_title() + self._update_status() + self._update_button_states() + + def _draw_source(self, force_image: bool = False, + preserve_view: bool = False): + """Left panel: always the original loaded image with overlay. + + In Lines mode, shows arrow-handle line pairs instead of the quad. + If *force_image* is True, reload the image pixmap (needed on first + load or when opening a new file). Otherwise, just update the overlay. + If *preserve_view* is True and a pixmap already exists, swap the + pixmap in place without resetting zoom/pan (for edge-debug refresh). + """ + if not self.image_stack: + self.source_panel.set_message(T("source_placeholder")) + return + if force_image or self.source_panel._pixmap_item is None: + original = self.image_stack[0][0] + if self._edge_debug: + # Uses edge_debug_overlay's own defaults (blur=5, + # Canny lo=50, hi=150) — same constants as the + # grayscale detection pipeline. + display = edge_debug_overlay(original) + else: + display = original + pixmap = bgr_to_qpixmap(display) + if preserve_view and self.source_panel._pixmap_item is not None: + self.source_panel.replace_pixmap(pixmap) + else: + self.source_panel.set_image(pixmap) + if self._is_lines_mode(): + self.source_panel.set_overlay(None) # clear quad + self.source_panel.set_lines_overlay(self._keystone_pairs) + else: + self.source_panel.set_lines_overlay(None) # clear lines + self.source_panel.set_overlay(self._overlay_corners()) + # Gray-card sample marker (independent of the quad/lines overlay, + # drawn after them since the overlay calls re-add their own items). + _m = self._color_mode + if (self._color_correct_enabled and _m is not None + and self._pick_center[_m] is not None): + self.source_panel.set_gray_marker(self._pick_center[_m], self._radius[_m]) + else: + self.source_panel.set_gray_marker(None, 0) + + def _draw_result(self, preserve_view: bool = False): + """Right panel: the final result after applying all peel levels. + + If *preserve_view* is True and the new result has the same + dimensions as the currently-displayed pixmap, swap the pixmap + in place via :meth:`ImagePanel.replace_pixmap` so the user's + zoom and pan are kept. Used when a control changes the pixel + content but not the shape of the result (e.g. the Bow slider), + so the user can stay zoomed in on the same region while + adjusting the value. + """ + def show(pixmap): + if (preserve_view + and self.result_panel._pixmap_item is not None + and self.result_panel._pixmap_item.pixmap().size() + == pixmap.size()): + self.result_panel.replace_pixmap(pixmap) + else: + self.result_panel.set_image(pixmap) + + if self._is_lines_mode(): + if not self.image_stack: + self.result_panel.set_message(T("result_placeholder")) + return + if not self._keystone_pairs: + self.result_panel.set_message(T("no_result")) + return + if self._space_held: + show(bgr_to_qpixmap(self.image_stack[0][0])) + return + result = self._compute_full_result() + self._last_result = result + if result is None: + # Keystone failure has no easy single cause to name. + self.result_panel.set_message(T("no_result")) + return + show(bgr_to_qpixmap(result)) + return + if not self.image_stack or self.outermost_corners is None: + # First run → "Result" placeholder; image but no detected quad → + # the specific reason. + self.result_panel.set_message( + T("no_quad") if self.image_stack else T("result_placeholder") + ) + return + if self._space_held: + show(bgr_to_qpixmap(self.image_stack[0][0])) + return + result = self._compute_full_result() + self._last_result = result + if result is None: + self.result_panel.set_message(T("too_small")) + return + show(bgr_to_qpixmap(result)) + + def _compute_full_result(self, apply_post: bool = True) -> np.ndarray | None: + """Apply all peel levels sequentially to produce the final image. + + The aspect ratio correction is applied only to the outermost + (first) transform, since that's where the perspective distortion + originates. + + When full-image correction is active, warps the entire image + instead of extracting the detected quad as in Extract mode. + + *apply_post* controls the cosmetic post-processing steps — bow + correction and gray-card color correction. These are applied + only to the *displayed/saved* output (``apply_post=True``, the + default). ``_peel_in`` passes ``apply_post=False`` so the image it + stores as the next peel layer (and detects on) is the **pure + geometric** rectification: the post-steps must be applied exactly + once, at the very end, never baked into a stored intermediate — + otherwise each peel level would re-correct and compound them. + (This also matches the geometric-only intermediates that + ``_cache_apply_deeper`` / ``_rebuild_peel_stack`` rebuild.) + + Gray-card color correction (when enabled) is applied last, in + every mode — it's a per-channel pixel gain that commutes with the + geometry, so applying it to the finished result is equivalent to + correcting the input first. + """ + aspect = self._get_aspect_ratio() + result = None + + if self._is_lines_mode(): + # Keystone correction via line pairs + if not self._keystone_pairs or not self.image_stack: + return None + image = self.image_stack[0][0] + c = self._fill_color + fill_bgr = (c.blue(), c.green(), c.red()) + crop = self.fullimg_crop_check.isChecked() + result = keystone_correct( + image, self._keystone_pairs, + crop=crop, fill_color=fill_bgr, + scale_x=self.stretch_widget.value(), + ) + elif self._is_transform_mode(): + # Full-image (Quad) mode: warp the whole image using only the + # outermost level. Aspect ratio applies here just as in Extract: + # the recovered (or user-set) ratio sets the target rectangle the + # homography maps the reference quad onto, so windows and text keep + # their true proportions instead of the foreshortened projection. + image, corners = self.image_stack[0] + if corners is None: + return None + c = self._fill_color + fill_bgr = (c.blue(), c.green(), c.red()) + crop = self.fullimg_crop_check.isChecked() + result = rectify_full_image( + image, corners, aspect_ratio=aspect, + crop=crop, fill_color=fill_bgr, + ) + else: + for i, (image, corners) in enumerate(self.image_stack): + if corners is None: + break + # Apply aspect ratio only to the first level + ar = aspect if i == 0 else None + result = rectify(image, corners, aspect_ratio=ar) + if result is None: + return None + # Cosmetic bow correction applied to the final extracted image + # (Extract mode only). The widget value is a radial distance + # in output pixels; convert to the curvature coefficient k + # against this output's half-diagonal, then clamp k to the + # remap's safe monotonic band. + if apply_post and result is not None: + bow_px = self.bow_widget.value() + if bow_px != 0.0: + h, w = result.shape[:2] + r_max = 0.5 * math.hypot(w, h) + bow_k = bow_px / (r_max * BOW_PEAK_FRAC) if r_max > 0 else 0.0 + bow_k = max(-BOW_K_LIMIT, min(BOW_K_LIMIT, bow_k)) + if bow_k != 0.0: + result = apply_bow_correction(result, bow_k) + + if apply_post and result is not None: + result = self._apply_color_correction(result) + return result + + def _overlay_corners(self) -> np.ndarray | None: + """Get the corners to display on the left panel. + + At depth 0, this is just the outermost corners. At deeper levels, + maps the deepest level's corners back through all intermediate + perspective transforms to the original image's coordinate space. + Must use the same destination rectangles as _compute_full_result + (including aspect ratio correction at level 0). + """ + depth = len(self.image_stack) - 1 + if depth == 0: + return self.outermost_corners + + # Find the deepest level that has corners + deepest = depth + while deepest >= 0 and self.image_stack[deepest][1] is None: + deepest -= 1 + if deepest < 0: + return None + + aspect = self._get_aspect_ratio() + pts = self.image_stack[deepest][1].copy() + + # Map backwards through each level's transform + for level in range(deepest - 1, -1, -1): + corners_at_level = self.image_stack[level][1] + if corners_at_level is None: + return None + # Use corrected size at level 0 (same as rectify uses) + ar = aspect if level == 0 else None + width, height = compute_output_size_corrected(corners_at_level, ar) + dst = np.array([ + [0, 0], [width - 1, 0], + [width - 1, height - 1], [0, height - 1], + ], dtype=np.float32) + inv_matrix = cv2.getPerspectiveTransform(dst, corners_at_level) + pts_reshaped = pts.reshape(-1, 1, 2) + mapped = cv2.perspectiveTransform(pts_reshaped, inv_matrix) + pts = mapped.reshape(4, 2).astype(np.float32) + + return pts + + def _map_to_deepest(self, point_in_original: np.ndarray) -> np.ndarray | None: + """Map a point from original image coords forward to the deepest level's coords. + + Used when the user drags a corner on the left panel at depth > 0. + Must use the same destination rectangles as _compute_full_result. + """ + depth = len(self.image_stack) - 1 + if depth == 0: + return point_in_original + + aspect = self._get_aspect_ratio() + pt = point_in_original.copy().reshape(1, 1, 2).astype(np.float32) + + # Map forward through levels 0 to depth-1 + for level in range(depth): + corners_at_level = self.image_stack[level][1] + if corners_at_level is None: + return None + ar = aspect if level == 0 else None + width, height = compute_output_size_corrected(corners_at_level, ar) + dst = np.array([ + [0, 0], [width - 1, 0], + [width - 1, height - 1], [0, height - 1], + ], dtype=np.float32) + fwd_matrix = cv2.getPerspectiveTransform(corners_at_level, dst) + pt = cv2.perspectiveTransform(pt, fwd_matrix) + + return pt.reshape(2).astype(np.float32) + return pts + + def _update_title(self): + depth = len(self.image_stack) - 1 + if self.current_filename: + title = f"Rectify — {self.current_filename}" + if depth > 0: + title += f" (depth {depth})" + self.setWindowTitle(title) + + def _update_status(self): + # First-run / nothing loaded: nothing to report in the left status + # label (the empty-panel placeholders carry the "Source image" / + # "Result" cues, and the top bar's flush-right "Shortcuts: ⌥⇧" hint + # advertises the overlay). + if not self.image_stack: + self._status_label.setText("") + return + parts = [] + if self.current_image is not None: + h, w = self.current_image.shape[:2] + input_str = f"{T('input')}: " + if self.current_filename: + input_str += f"{self.current_filename} " + input_str += f"{w}x{h}" + parts.append(input_str) + depth = len(self.image_stack) - 1 + parts.append(f"{T('depth')}: {depth}") + ar = self._get_aspect_ratio() + # Aspect ratio is applied in Extract and Transform/Quad, not in Lines, + # so only report it where it actually shapes the output. + if ar is not None and not self._is_lines_mode(): + parts.append(f"{T('aspect_ratio')}: {ar:.3f}") + elif self._focal_length_35mm: + parts.append(f"Focal: {self._focal_length_35mm:.0f}mm") + if self._is_lines_mode(): + mode = T("crop") if self.fullimg_crop_check.isChecked() else T("full") + dirs = [] + if self.vertical_check.isChecked(): + dirs.append(T("vertical")) + if self.horizontal_check.isChecked(): + dirs.append(T("horizontal")) + parts.append(f"{T('lines')}: {'+'.join(dirs) if dirs else '—'}, {mode}") + stretch = self.stretch_widget.value() + if stretch != 1.0: + parts.append(f"{T('stretch')}: {stretch:.2f}") + elif self._is_transform_mode(): + mode = T("crop") if self.fullimg_crop_check.isChecked() else T("full") + parts.append(f"{T('transform')}: {mode}") + if self._last_result is not None: + rh, rw = self._last_result.shape[:2] + parts.append(f"{T('output')}: {rw}x{rh}") + # Sampled neutral color readout (R,G,B normalized 0–1), shown + # while color correction is active so the user can see how + # neutral / how blue the clicked reference actually is. + if self._color_correct_enabled and self._color_mode in ("gray", "white", "white90"): + label = {"gray": T("color_mode_gray"), + "white": T("color_mode_white"), + "white90": T("color_mode_white90")}.get(self._color_mode, "") + sample = self._active_sample() # BGR or None + if sample is not None: + b, g, r = float(sample[0]), float(sample[1]), float(sample[2]) + parts.append( + f"{label}: {r/255:.3f}/{g/255:.3f}/{b/255:.3f}" + ) + else: + parts.append(f"{label}: —") + if self.undo_stack: + parts.append(f"{T('undo')}: {len(self.undo_stack)}") + if self._last_saved_path: + parts.append(f"{T('last_saved')}: {self._last_saved_path}") + status = " | ".join(parts) + show_cleared = self._cache_cleared + self._cache_cleared = False + if show_cleared: + status += " | Cache cleared" + self._status_label.setText(status) + + def _update_button_states(self): + has_corners = self.outermost_corners is not None + fullimg = self._is_transform_mode() + lines = self._is_lines_mode() + has_result = has_corners or (lines and bool(self._keystone_pairs)) + can_peel_in = has_corners and not self._peel_exhausted and not fullimg + depth = len(self.image_stack) - 1 + # No image loaded → depth is -1; show 0 ("top level") rather than a + # meaningless negative. The real `depth` still drives the peel buttons. + self.depth_label.setText(f" {T('depth')}: {max(depth, 0)} ") + self.btn_peel_in.setEnabled(can_peel_in) + self.btn_peel_out.setEnabled(depth > 0 and not fullimg) + self.btn_save_as.setEnabled(has_result) + + # ── Before/After (Space bar) ────────────────────────────────── + + def keyPressEvent(self, event: QKeyEvent): + if event.key() == Qt.Key_Space and not event.isAutoRepeat(): + self._space_held = True + self._draw_result() + return + # Esc: clear all temporary marks (general policy). Currently + # this means alternate-line annotations and any in-progress + # rubber-band; future temporary visualisations should hook in here. + if event.key() == Qt.Key_Escape and not event.isAutoRepeat(): + self.source_panel.clear_annotations() + return + # Ctrl+Shift+Delete or Ctrl+Shift+Backspace: clear settings cache + if (event.key() in (Qt.Key_Delete, Qt.Key_Backspace) + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier)): + self._clear_settings_cache() + return + # Ctrl+Shift+E: toggle edge detection debug overlay + if (event.key() == Qt.Key_E + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier)): + self._edge_debug = not self._edge_debug + self._draw_source(force_image=True, preserve_view=True) + return + # Ctrl+Shift+W: save a screenshot of the main window to ~/rectify/ + if (event.key() == Qt.Key_W + and event.modifiers() == (Qt.ControlModifier | Qt.ShiftModifier)): + self._save_window_screenshot() + return + super().keyPressEvent(event) + + def _save_window_screenshot(self): + """Capture the main window via ``QWidget.grab()`` and write a PNG to + ``~/rectify/{loaded_image_stem}_screenshot_{NNN}.png``. + + Uses the same ``find_next_n`` auto-incrementing helper the Save + button uses, so the counter is per-image and per-output-directory. + The output directory is created on first use. When no image is + loaded, the stem falls back to ``rectify``. Status bar shows the + full saved path for a few seconds. + """ + out_dir = os.path.expanduser("~/rectify") + try: + os.makedirs(out_dir, exist_ok=True) + except OSError as e: + self.status_bar.showMessage(f"Could not create {out_dir}: {e}", 5000) + return + if self._last_opened_path: + stem = os.path.splitext(os.path.basename(self._last_opened_path))[0] + else: + stem = "rectify" + prefix = f"{stem}_screenshot" + n = find_next_n(out_dir, prefix) + path = os.path.join(out_dir, f"{prefix}_{n:03d}.png") + pixmap = self.grab() + if pixmap.save(path): + self.status_bar.showMessage(f"Saved screenshot: {path}", 5000) + else: + self.status_bar.showMessage(f"Failed to save screenshot: {path}", 5000) + + def _clear_settings_cache(self): + """Delete the settings file and reset detection parameters to defaults. + + Preserved: window geometry, language, font scale, splitter sizes, + last directory, the currently-loaded image, and the user's current + mode selections (Extract/Transform, Quad/Lines method, Vertical/ + Horizontal checkboxes, full-image-crop checkbox, fill color). + Reset: detection parameters (strategy, sensitivity, aspect ratio), + output settings, cached detected line pairs, peel stack, undo/redo. + The current image is then re-detected from scratch using the + preserved mode so the workflow continues uninterrupted. Intended + for use after software changes make cached values stale. + """ + path = self._settings_path() + try: + os.remove(path) + except OSError: + pass + + # Reset detection parameters. Strategy and sensitivity are no + # longer GUI controls — `_auto_detect` always runs the auto + # sweep — so only aspect needs explicit reset here. Block + # signals so no cascading update fires while we're resetting; + # _auto_detect runs further down. + self.aspect_check.blockSignals(True) + self.aspect_check.setChecked(False) + self.aspect_check.blockSignals(False) + self.aspect_widget.blockSignals(True) + self.aspect_widget.setValue(1.0) + self.aspect_widget.setEnabled(False) + self.aspect_widget.blockSignals(False) + self._aspect_auto = False + self._aspect_clamped = False + self._apply_aspect_style() + self._image_cache.clear() + self.bow_widget.blockSignals(True) + self.bow_widget.setValue(0.0) + self.bow_widget.blockSignals(False) + self._apply_bow_style() + self.stretch_widget.blockSignals(True) + self.stretch_widget.setValue(1.0) + self.stretch_widget.blockSignals(False) + self._apply_stretch_style() + self._reset_color_state(clear_globals=True) + + # Reset save state: type back to PNG, increment off. The folder and + # base name re-derive from the loaded image / next save. + self._save_ext = "png" + self._save_as_dir = None + self.increment_check.blockSignals(True) + self.increment_check.setChecked(False) + self.increment_check.blockSignals(False) + + # Reset cached detection state (keystone pair detections) but keep + # the UI mode selections — the user expects their current workflow + # mode (Extract/Transform, Quad/Lines, V/H checkboxes, fill color) to + # survive the cache clear. + self._keystone_pairs.clear() + self._keystone_image_path = None + self._saved_extract_state = None + self._detected_vpair = None + self._detected_hpair = None + + # Re-detect on the current image with default parameters + if self.image_stack: + original = self.image_stack[0][0] + self.image_stack = [(original, None)] + self.undo_stack.clear() + self.redo_stack.clear() + self._peel_exhausted = False + self._auto_detect() + self._update_aspect_ratio() + # If currently in Lines mode, also re-detect the keystone pairs + # so the user sees freshly-computed lines (not the stale ones + # we just cleared). _init_keystone_pairs is the same path + # used when the user first selects Lines mode. + if self._is_lines_mode(): + self._init_keystone_pairs() + + # Flag for status bar — consumed by the next _update_status call + self._cache_cleared = True + self._update_display() + + def keyReleaseEvent(self, event: QKeyEvent): + if event.key() == Qt.Key_Space and not event.isAutoRepeat(): + self._space_held = False + self._draw_result() + return + super().keyReleaseEvent(event) + + # ── Shift+Alt-held shortcut overlay ─────────────────────────── + + def eventFilter(self, obj, event): + """App-level filter to detect Shift+Alt regardless of focus. + + Installed on QApplication so we receive the event before any + focused child (spin boxes, line edits) can consume it. We never + consume the event. Plain Alt is left to the window manager (so + Alt-Tab still works); the overlay only appears when both Shift + and Alt are held simultaneously, in either order. + """ + et = event.type() + if et == QEvent.KeyPress and not event.isAutoRepeat(): + k = event.key() + if k == Qt.Key_Alt: + self._alt_held_global = True + self.source_panel.set_alt_held(True) + elif k == Qt.Key_Shift: + self._shift_held_global = True + elif k == Qt.Key_Control: + self.source_panel.set_ctrl_held(True) + self._update_shortcut_overlay() + elif et == QEvent.KeyRelease and not event.isAutoRepeat(): + k = event.key() + if k == Qt.Key_Alt: + self._alt_held_global = False + self.source_panel.set_alt_held(False) + elif k == Qt.Key_Shift: + self._shift_held_global = False + elif k == Qt.Key_Control: + self.source_panel.set_ctrl_held(False) + self._update_shortcut_overlay() + elif et == QEvent.WindowDeactivate: + # If the main window loses activation while the chord is held + # (e.g. user switches apps), key releases will not arrive — + # clear state and hide proactively. + if obj is self: + self._alt_held_global = False + self._shift_held_global = False + self.source_panel.set_ctrl_held(False) + self.source_panel.set_alt_held(False) + self._update_shortcut_overlay() + elif et == QEvent.Leave: + # When the mouse leaves a parameter spinbox/slider widget, + # revert keyboard focus to the source panel so that global + # shortcuts ("0" to reset zoom, "+/-" to peel in/out, etc.) + # work as expected. Without this, clicking the spinbox + # arrows leaves focus inside the spin and subsequent key + # presses go into the spinbox text field rather than + # triggering the global shortcut. + # + # Exception: don't steal focus while the user is actively + # typing a value into the spin box (keyboard focus is inside + # the widget), or direct numeric entry would be interrupted + # the moment the mouse drifts off the small control. + if obj in (self.aspect_widget, self.bow_widget, self.stretch_widget): + focused = QApplication.focusWidget() + if focused is None or not obj.isAncestorOf(focused): + self.source_panel.setFocus() + return super().eventFilter(obj, event) + + def _update_shortcut_overlay(self): + should_show = self._alt_held_global and self._shift_held_global + if should_show and not self._alt_overlay_visible: + self._show_shortcut_overlay() + elif not should_show and self._alt_overlay_visible: + self._hide_shortcut_overlay() + + def _show_shortcut_overlay(self): + if self._shortcut_overlay is None: + self._shortcut_overlay = ShortcutOverlay(self) + self._shortcut_overlay.position_over(self) + self._shortcut_overlay.show() + self._shortcut_overlay.raise_() + self._alt_overlay_visible = True + + def _hide_shortcut_overlay(self): + if self._shortcut_overlay is not None: + self._shortcut_overlay.hide() + self._alt_overlay_visible = False + + + # ── Settings persistence ─────────────────────────────────────── + + @staticmethod + def _settings_path() -> str: + """Return the platform-appropriate settings file path.""" + from PySide6.QtCore import QStandardPaths + data_dir = QStandardPaths.writableLocation( + QStandardPaths.AppDataLocation) + os.makedirs(data_dir, exist_ok=True) + return os.path.join(data_dir, "settings.json") + + def _save_settings(self): + """Save user preferences to disk.""" + self._cache_cleared = False + import json + # Make sure the current image's state is in the cache before + # serializing. Without this, the most recent edits to the + # active image would be lost on restart. + self._cache_save_current() + settings = { + # Strategy and sensitivity have been removed from the GUI; + # auto-detection runs unconditionally on every load (CLI + # flags --strategy / -s remain available). Aspect is still + # captured at the top level for the no-image legacy load + # path; per-image aspect overrides live in image_cache. + "aspect_enabled": self.aspect_check.isChecked(), + "aspect_value": self.aspect_widget.value(), + # Color-correction global (sticky) state, per mode: last + # measured neutral color (BGR) reused across images, plus + # last-used radius and the mode's target (reflectance for gray, + # brightness for white). + "gray_sticky_color": (list(self._sticky_color["gray"]) + if self._sticky_color["gray"] is not None + else None), + "white_sticky_color": (list(self._sticky_color["white"]) + if self._sticky_color["white"] is not None + else None), + "white90_sticky_color": (list(self._sticky_color["white90"]) + if self._sticky_color["white90"] is not None + else None), + "gray_radius": self._radius["gray"], + "white_radius": self._radius["white"], + "white90_radius": self._radius["white90"], + "gray_reflectance": self._gray_reflectance, + "white_brightness": self._white_brightness, + "image_cache": self._image_cache, + "action": "transform" if self._is_transform_mode() else "extract", + "transform_method": "lines" if self.method_lines_radio.isChecked() else "quad", + "transform_mode": "crop" if self.fullimg_crop_check.isChecked() else "canvas", + "fill_color": self._fill_color.name(), + "lines_vertical": self.vertical_check.isChecked(), + "lines_horizontal": self.horizontal_check.isChecked(), + "save_as_dir": self._save_as_dir, + "save_ext": self._save_ext, + "increment": self.increment_check.isChecked(), + "language": self.lang_combo.currentText(), + "font_scale": self._font_scale, + "window_width": self.width(), + "window_height": self.height(), + "window_x": self.x(), + "window_y": self.y(), + "last_directory": self.last_directory, + "splitter_sizes": self.splitter.sizes(), + "last_image_path": self._last_opened_path, + } + try: + path = self._settings_path() + with open(path, "w") as f: + json.dump(settings, f, indent=2) + except OSError: + pass # silently fail — settings are not critical + + def _load_settings(self): + """Restore user preferences from disk.""" + import json + try: + path = self._settings_path() + with open(path) as f: + settings = json.load(f) + except (OSError, json.JSONDecodeError): + return # no settings file or corrupt — use defaults + + # Block signals to prevent cascading detection/display updates. + # Legacy "strategy" and "sensitivity" keys in older settings + # files are ignored — the GUI no longer has those controls. + self.aspect_check.blockSignals(True) + self.aspect_widget.blockSignals(True) + + if "aspect_enabled" in settings: + self.aspect_check.setChecked(settings["aspect_enabled"]) + self.aspect_widget.setEnabled(settings["aspect_enabled"]) + if "aspect_value" in settings: + self.aspect_widget.setValue(settings["aspect_value"]) + # Color-correction global (sticky) state, per mode. Radii/targets + # fall back to defaults; sticky colors to None (no inherited + # correction yet). + for _mode, _key in (("gray", "gray_sticky_color"), + ("white", "white_sticky_color"), + ("white90", "white90_sticky_color")): + _sticky = settings.get(_key) + if isinstance(_sticky, (list, tuple)) and len(_sticky) == 3: + self._sticky_color[_mode] = tuple(float(v) for v in _sticky) + for _mode, _key in (("gray", "gray_radius"), ("white", "white_radius"), + ("white90", "white90_radius")): + if _key in settings: + try: + self._radius[_mode] = int(settings[_key]) + except (TypeError, ValueError): + pass + if "gray_reflectance" in settings: + try: + self._gray_reflectance = int(settings["gray_reflectance"]) + except (TypeError, ValueError): + pass + if "white_brightness" in settings: + try: + self._white_brightness = int(settings["white_brightness"]) + except (TypeError, ValueError): + pass + self._sync_color_global_widgets() + # Per-image cache (with one-time migration from the older + # standalone bow_cache + last-image-only corners/keystone_pairs + # layout). Future settings files only carry "image_cache". + if "image_cache" in settings and isinstance(settings["image_cache"], dict): + self._image_cache = { + str(k): dict(v) for k, v in settings["image_cache"].items() + if isinstance(v, dict) + } + if "bow_cache" in settings and isinstance(settings["bow_cache"], dict): + for k, v in settings["bow_cache"].items(): + try: + self._image_cache.setdefault(str(k), {})["bow"] = float(v) + except (TypeError, ValueError): + pass + last_path = settings.get("last_image_path") + if last_path: + legacy_entry = self._image_cache.setdefault( + os.path.abspath(last_path), {}) + if "corners" in settings and "corners" not in legacy_entry: + legacy_entry["corners"] = settings["corners"] + if ("keystone_pairs" in settings + and settings["keystone_pairs"] + and "keystone_pairs" not in legacy_entry): + legacy_entry["keystone_pairs"] = settings["keystone_pairs"] + if "action" in settings and settings["action"] == "transform": + self.action_transform_radio.setChecked(True) + if "transform_method" in settings and settings["transform_method"] == "lines": + self.method_lines_radio.setChecked(True) + if "lines_vertical" in settings: + self.vertical_check.setChecked(settings["lines_vertical"]) + if "lines_horizontal" in settings: + self.horizontal_check.setChecked(settings["lines_horizontal"]) + # NOTE: legacy "keystone_pairs" (single-image, last-loaded) is + # migrated into _image_cache above; per-image restoration + # happens in _restore_saved_image / _load_file via _cache_apply. + self._update_transform_widgets_visibility() + if "transform_mode" in settings: + self.fullimg_crop_check.setChecked(settings["transform_mode"] == "crop") + if "fill_color" in settings: + self._fill_color = QColor(settings["fill_color"]) + self._update_fill_color_icon() + # Last-used save type (default png) and Increment mode persist. + if settings.get("save_ext") in SAVE_EXTENSIONS: + self._save_ext = settings["save_ext"] + if "increment" in settings: + self.increment_check.blockSignals(True) + self.increment_check.setChecked(bool(settings["increment"])) + self.increment_check.blockSignals(False) + # Sticky Save-as folder persists across restarts; ignore it if the + # folder no longer exists (falls back to the source image's folder). + saved_as_dir = settings.get("save_as_dir") + if saved_as_dir and os.path.isdir(saved_as_dir): + self._save_as_dir = saved_as_dir + if "language" in settings: + self.lang_combo.setCurrentText(settings["language"]) + if "font_scale" in settings: + self._font_scale = settings["font_scale"] + self._apply_font_scale() + if "last_directory" in settings: + self.last_directory = settings["last_directory"] + if "splitter_sizes" in settings and len(settings["splitter_sizes"]) == 2: + self.splitter.setSizes(settings["splitter_sizes"]) + + # Restore window geometry + if all(k in settings for k in ("window_width", "window_height")): + self.resize(settings["window_width"], settings["window_height"]) + if all(k in settings for k in ("window_x", "window_y")): + self.move(settings["window_x"], settings["window_y"]) + + # Deferred image load happens in showEvent → _restore_saved_image. + # Per-image state (corners, pairs, bow) comes from _image_cache + # via _cache_apply, populated above with legacy migration. + if "last_image_path" in settings and settings["last_image_path"]: + self._saved_image_path = settings["last_image_path"] + + self.aspect_check.blockSignals(False) + self.aspect_widget.blockSignals(False) + # Sync the gray-italic styling with whatever aspect_check state + # was just loaded. (_update_aspect_ratio / _cache_apply_widgets + # will also call this after the image loads, but for the + # no-saved-image path this is the only opportunity.) + self._apply_aspect_style() + + def closeEvent(self, event): + """Save settings on window close.""" + self._save_settings() + super().closeEvent(event) + + +# ────────────────────────────────────────────────────────────────── +# Entry point +# ────────────────────────────────────────────────────────────────── + +class RectifyApplication(QApplication): + """QApplication that handles the macOS file-open event (QEvent.FileOpen). + + macOS delivers this event — not an argv entry — when a file is opened + via Finder's "Open With", by double-clicking an image associated with + Rectify, by dragging a file onto the Dock icon, or via + ``open -a Rectify ``. The event can arrive before the main window + exists (a cold launch *with* a document), so the path is buffered until + a handler is registered. Inert on Linux/Windows, where it never fires. + """ + + def __init__(self, argv): + super().__init__(argv) + self._pending_file: str | None = None + self._file_handler = None + + def event(self, e): + if e.type() == QEvent.FileOpen: + path = e.file() or e.url().toLocalFile() + if path: + if self._file_handler is not None: + self._file_handler(path) + else: + self._pending_file = path + return True + return super().event(e) + + def set_file_handler(self, handler) -> None: + """Register the file-open callback and flush any path that arrived + before the window was ready.""" + self._file_handler = handler + if self._pending_file is not None: + path, self._pending_file = self._pending_file, None + handler(path) + + +class _BaselineOverlay(QWidget): + """Debug aid: a transparent, click-through overlay that draws a thin red + line at the text baseline of every text widget (labels, radios, checkboxes, + value boxes). Enabled with RECTIFY_DEBUG_BASELINES=1 — a finer reference + than box outlines for judging whether *text* lines up across a row.""" + + def __init__(self, host): + super().__init__(host) + self.setAttribute(Qt.WA_TransparentForMouseEvents) + self._host = host + self._timer = QTimer(self) + self._timer.timeout.connect(self._tick) + self._timer.start(200) + + def _tick(self): + self.setGeometry(self._host.rect()) + self.raise_() + self.update() + + def paintEvent(self, event): + from PySide6.QtGui import QPainter + painter = QPainter(self) + painter.setPen(QPen(QColor(255, 0, 0, 200), 1)) + for wdg in self._host.findChildren(QWidget): + if wdg is self or not wdg.isVisible() or wdg.width() <= 0: + continue + if isinstance(wdg, (QLabel, QCheckBox, QRadioButton, QDoubleSpinBox)): + text = wdg.text() + else: + continue + if not text: + continue + fm = wdg.fontMetrics() + tl = wdg.mapTo(self._host, wdg.rect().topLeft()) + cy = tl.y() + wdg.height() / 2.0 + # Text is drawn vertically centered, so its baseline sits + # (ascent - descent)/2 below the text's vertical center. + baseline = int(round(cy + (fm.ascent() - fm.descent()) / 2.0)) + painter.drawLine(tl.x(), baseline, tl.x() + wdg.width(), baseline) + + +def launch_gui(initial_path: str | None = None, initial_dir: str | None = None): + """Launch the Rectify GUI, optionally loading an image immediately. + + If *initial_dir* is given (and *initial_path* is None), the file + dialog opens immediately in that directory. + """ + app = QApplication.instance() or RectifyApplication(sys.argv) + app.setApplicationName("Rectify") + app.setOrganizationName(os.getenv("USER", os.getenv("USERNAME", ""))) + # Number formatting: use U.S. English (period as the decimal separator, + # no thousands grouping surprises) for every spin box, regardless of the + # system locale or the chosen UI language. Setting the Qt-wide default + # here — before any widgets are constructed — is the single central place + # that governs how QDoubleSpinBox parses and renders values; the UI + # language (see _change_language) only swaps translated strings and Qt + # dialog-button text, not numeric formatting. + QLocale.setDefault(QLocale(QLocale.Language.English, + QLocale.Country.UnitedStates)) + if os.environ.get("RECTIFY_DEBUG_BORDERS"): + # Debug aid: outline every widget so the layout structure is visible. + app.setStyleSheet("* { border: 1px solid rgba(255,0,0,0.45); }") + window = RectifyMainWindow(initial_path, initial_dir=initial_dir) + window.show() + # Best-effort foreground activation. A GUI process launched from a + # terminal (especially detached/background) can draw its window without + # becoming the active application — the window appears in front but the + # menu bar and keyboard focus stay with the launching app, so controls + # look "dead". raise_()/activateWindow() ask the window manager to + # promote us; on macOS, also force the app-level activation policy to a + # regular (foreground) app and activate it, when AppKit is available. + window.raise_() + window.activateWindow() + if sys.platform == "darwin": + try: + from AppKit import NSApplication, NSApplicationActivationPolicyRegular + ns = NSApplication.sharedApplication() + ns.setActivationPolicy_(NSApplicationActivationPolicyRegular) + ns.activateIgnoringOtherApps_(True) + except Exception: + pass # pyobjc/AppKit not present (e.g. minimal venv) — skip + if os.environ.get("RECTIFY_DEBUG_BASELINES"): + # Debug aid: red line at each text widget's baseline (see _BaselineOverlay). + window._baseline_overlay = _BaselineOverlay(window.centralWidget() or window) + window._baseline_overlay.show() + # macOS: route "Open With" / double-click / drag-to-Dock file events to + # the window (also replays a file that opened the app cold). + if isinstance(app, RectifyApplication): + app.set_file_handler(window._handle_open_file) + sys.exit(app.exec()) diff --git a/rectify/shortcuts.py b/rectify/shortcuts.py new file mode 100644 index 0000000..202c683 --- /dev/null +++ b/rectify/shortcuts.py @@ -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) diff --git a/rectify/transform.py b/rectify/transform.py new file mode 100644 index 0000000..e268783 --- /dev/null +++ b/rectify/transform.py @@ -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) diff --git a/rectify/utils.py b/rectify/utils.py new file mode 100644 index 0000000..d896ba7 --- /dev/null +++ b/rectify/utils.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..458bdc1 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/scripts/heic_thumbnailer.py b/scripts/heic_thumbnailer.py new file mode 100644 index 0000000..f575f8e --- /dev/null +++ b/scripts/heic_thumbnailer.py @@ -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)) diff --git a/scripts/install_heic_thumbnailer.sh b/scripts/install_heic_thumbnailer.sh new file mode 100755 index 0000000..bd93044 --- /dev/null +++ b/scripts/install_heic_thumbnailer.sh @@ -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" </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"