From c8194742d1014c756ff2c14fc283fa079e0999e7 Mon Sep 17 00:00:00 2001 From: dan Date: Sat, 12 Sep 2026 15:11:07 +0200 Subject: [PATCH] docs: add maintenance guide; fix AppImage version selection; bump to 1.1.0 - Add MAINTENANCE.md (dependency updates, EOL watch, build/smoke-test workflows, orientation-fix invariants) - Remove AGENTS.md; fold its unique context into MAINTENANCE.md - Add .venv/.posit/appimage to .dockerignore to shrink build context - Fix build-appimage.sh to select the wheel matching pyproject.toml version instead of alphabetically-first (bundled stale 1.0.0 code) - Bump version 1.0.0 -> 1.1.0 in pyproject.toml, uv.lock, AppImage recipe --- .dockerignore | 3 + AGENTS.md | 152 -------------- MAINTENANCE.md | 266 +++++++++++++++++++++++++ packaging/appimage/AppImageBuilder.yml | 2 +- packaging/appimage/build-appimage.sh | 20 +- pyproject.toml | 2 +- uv.lock | 2 +- 7 files changed, 289 insertions(+), 158 deletions(-) delete mode 100644 AGENTS.md create mode 100644 MAINTENANCE.md diff --git a/.dockerignore b/.dockerignore index 8cc7d0f..a51fd57 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,6 @@ out .env .scanslicer .uv-cache +.venv +.posit +appimage diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index a3624ea..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,152 +0,0 @@ -# ScanSlicer (fork) — Project Guide - -Purpose of this file: give any LLM working in this repo enough context to be -effective without re-reading everything. Keep it current as the code changes. - -## What this project is - -ScanSlicer is a desktop Qt application (Python/PySide6) that detects multiple -photos in a flatbed scan image, lets the user correct each box manually, then -exports perspective-corrected crops. Inspired by -[PhotoSlicer](https://github.com/francescovannini/photoslicer) (Francesco -Vannini). Runs on Linux over Wayland or X11. - -This repo is a **local fork** of -[macrodev8/ScanSlicer](https://github.com/macrodev8/ScanSlicer) (upstream owner -inactive). License: GNU GPL v3 (must be preserved). Attribution: keep upstream -credit and the "inspired by PhotoSlicer" note; `pyproject.toml` lists both the -fork maintainer and the upstream author, and `README.md` states the fork -relationship and the changes made. - -## Goals of this fork - -1. Fix the mirrored-export orientation bug and add an explicit left/right - orientation workflow. Implemented, tested in the GUI, and committed — see - "The orientation fix" below. This was the primary patch. -2. Keep the app usable on the maintainer's own photo scans; afterwards only - minimal maintenance is expected. -3. Push to the maintainer's Forgejo instance at `code.dgerdesmann.de` (no - GitHub fork). Keep licensing, attribution, and repository description proper. - -The maintainer is primarily an R user and new to Python/Git. Changes should be -small, self-checked, clean, and secure; do not assume the maintainer can vet -Python code. - -## The orientation fix (implemented) - -The bug: exported slices could come out mirrored (left/right swapped). Root -cause is `PhotoSlice.normalize_top_left()` (in `slices.py`), which re-derives -"top" from geometry — it picks a top-left corner and resolves winding so edge -0→1 is the "most horizontal" top edge. That inference is ambiguous, and because -`squarize()` and `reset_rotation()` both re-run `normalize_top_left()` at the -end, any manual correction was silently overwritten. - -The fix adds an explicit left/right orientation workflow: - -- `PhotoSlice.swapped` — persistent flag (default `False`): "user has explicitly - mirrored left/right". -- `PhotoSlice.swap_left_right()` — toggles `swapped` and reindexes the corners - `[TL,TR,BR,BL] -> [TR,TL,BL,BR]` (i.e. `bbox[[1,0,3,2]]`), a pure left/right - mirror that keeps the top (edge 0→1) and bottom edges in place. NOTE: the - earlier `[0,3,2,1]` reindex was wrong — it rotated the quad 90°. -- `normalize_top_left()` applies the same `[1,0,3,2]` reindex when `swapped` is - true (after its geometric inference), so manual orientation survives - `squarize()` / `reset_rotation()`. -- "Swap L/R" toolbar button (`ui_builders_preview.py`) -> - `CanvasStateMixin.swap_active_slice_left_right()` -> - `ScanSlicerCanvasActionsMixin.swap_active_box_left_right()`. -- Visual indication in `CanvasRenderMixin.paintEvent()` (`canvas_render.py`): - corners color-coded left = orange, right = cyan; edges colored red = top - (0→1), green = right (1→2), orange = bottom (2→3), and the left edge (3→0) - follows lock state (blue locked / lightgreen unlocked). - -## Key implementation facts - -- `PhotoSlice.bbox` is a 4×2 numpy array in the order - `[top-left, top-right, bottom-right, bottom-left]`. -- `Autoslicer.save_slice()` (in `autoslicer.py`) builds the perspective - transform assuming the `[TL,TR,BR,BL]` order. Do NOT change that contract - without updating every consumer. -- `PhotoSlice.squarize()` and `reset_rotation()` call `normalize_top_left()` at - the end; the `swapped` flag makes that safe for manual orientation. - -## Repository structure - -``` -scanslicer/ application source (package) - entry.py CLI parsing, Wayland/X11 backend selection - main.py QtScanSlicer main window, theme, app bootstrap - controller*.py UI action handlers (status, canvas, detection, - image I/O, params) - ui_builders*.py widget construction (controls, preview, shared) - canvas*.py interactive canvas (interaction/render/state mixins) - slices.py PhotoSlice geometry model + polys_iou - autoslicer.py detection/export engine wrapper (save_slice) - autoslicer_detection.py detection pipelines, contour→box extraction - autoslicer_feedback.py auto-tune profiles, scoring, feedback persistence - params.py parameter model, defaults, presets -packaging/appimage/ AppImage recipe + build scripts (preferred deploy) -Dockerfile container runtime image -docker-compose.yml GUI container wiring -pyproject.toml package metadata + dependencies (uv.lock pinned) -docs/ user-guide, architecture, parameters, autotune, - runtime-config, appimage, development -``` - -## Architecture in brief - -- `QtScanSlicer` (`main.py`) is a `QMainWindow` composed from UI builder mixins - and controller mixins. -- `QtSlicingCanvas` (`canvas.py`) = interaction + render + state mixins; holds - the image, zoom/pan state, and the list of `PhotoSlice` objects. -- Background work (detection, auto-tune) runs on a single-worker `QThreadPool`; - results are delivered back to the UI via Qt signals. -- Export flow: `save_all()` (`controller_image_io.py`) loops over locked slices - and calls `Autoslicer.save_slice(bbox, path, params)` for each. - -## Run / build - -```bash -uv sync --frozen -uv run scanslicer /path/to/scans # or: python -m scanslicer -``` - -- AppImage (preferred): `bash packaging/appimage/build-appimage.sh` -- AppImage via Docker: `bash packaging/appimage/build-appimage-docker.sh` -- Docker runtime: `docker compose up --build` -- Dependencies (from `pyproject.toml`): PySide6, opencv-python-headless, numpy. -- Python >= 3.10; dev env is Python 3.12 in `.venv`. - -Dependency / supply-chain decisions (deliberate — keep them): -- `opencv-python-headless` is intentional: the app uses no OpenCV GUI/highgui. -- Pillow was removed (it was unused). Don't re-add without a concrete need. -- The AppImage build exports hashes from `uv.lock` and installs with - `pip install --require-hashes`. - -### AppImage build: Docker context gotcha - -The maintainer's Docker points at Docker Desktop (`desktop-linux` context). Its -bind-mount filesystem forbids `chown`, but `appimage-builder` needs root -ownership while extracting `.deb` packages, so the build fails with -`dpkg-deb ... Cannot change ownership to uid 0, gid 0: Operation not permitted`. - -Workaround: run the Dockerized build against the native daemon (or switch -context with `docker context use default`): - -```bash -DOCKER_HOST=unix:///var/run/docker.sock bash packaging/appimage/build-appimage-docker.sh -``` - -Output lands in `appimage/dist/ScanSlicer--x86_64.AppImage` (the -`appimage/` dir is git-ignored). - -## Config / persistence - -Runtime files live in `~/.config/scanslicer/` (or `$XDG_CONFIG_HOME/scanslicer/`) -by default: `defaults.json`, `presets.json`, `autotune-feedback.json`, -`status.log`. Paths are overridable via `SCANSLICER_*_PATH` env vars. In the -Docker setup these land in `./.scanslicer/` on the host. - -## Notes - -- No automated test suite exists; validate changes by running the app on sample scans (startup, detection, save, auto-tune). diff --git a/MAINTENANCE.md b/MAINTENANCE.md new file mode 100644 index 0000000..e522f1c --- /dev/null +++ b/MAINTENANCE.md @@ -0,0 +1,266 @@ +# Maintaining ScanSlicer + +This file is a how-to for keeping the fork alive over time. The goals are +narrow and deliberate: + +1. **Keep the app functional** for its one real job (detect photos in a flatbed + scan, correct the boxes, export straightened crops). +2. **Avoid security issues and breakage from end-of-life (EOL) components.** + +There is **no ambition to develop new features**. Maintenance is manual: when +something drifts out of date, you bump a version, re-verify the app, and commit. +Do not add features, refactor, or "improve" things while doing maintenance — +small, focused changes only. + +> Scope guard: this repo is a local fork of `macrodev8/ScanSlicer`, published at +> `https://code.dgerdesmann.de/dan/ScanSlicer.git`. License is GPL v3. Keep the +> upstream credit and the "inspired by PhotoSlicer" note intact. See `README.md` +> and `docs/` for project context. + +--- + +## Where things live + +| Component | File(s) | +|---|---| +| Package metadata + dependency constraints | `pyproject.toml` | +| **Pinned** dependency versions (source of truth) | `uv.lock` | +| Runtime container image | `Dockerfile` (ubuntu:24.04 + uv) | +| AppImage recipe + build scripts | `packaging/appimage/` | +| Bundled third-party license notice | `THIRD_PARTY_NOTICES` | +| Dev env (Python 3.12) | `.venv/` (managed by uv) | + +Dependency management is done with **uv**. `pyproject.toml` declares the +requirements (mostly loose), and `uv.lock` pins the *exact* versions that get +installed and shipped. `uv.lock` is committed — do not edit it by hand; let uv +rewrite it. + +--- + +## Code invariants (orientation fix + bbox order) + +This fork's one real code change is the mirrored-export orientation fix. Keep +these invariants in mind when touching geometry code. + +- **`PhotoSlice.bbox` is a 4×2 numpy array in the order + `[top-left, top-right, bottom-right, bottom-left]`.** `Autoslicer.save_slice()` + (`autoslicer.py`) builds the perspective transform assuming that order. Do NOT + change this contract without updating every consumer. +- **`PhotoSlice.swapped`** (default `False`) is the persistent "user explicitly + mirrored left/right" flag. `swap_left_right()` toggles it and reindexes + corners `[TL,TR,BR,BL] -> [TR,TL,BL,BR]` (`bbox[[1,0,3,2]]`) — a pure + left/right mirror that keeps the top and bottom edges in place. +- **Root cause of the original bug:** `normalize_top_left()` (`slices.py`) + re-derives "top" from geometry, which is ambiguous, and `squarize()` / + `reset_rotation()` both re-run it at the end, silently overwriting manual + corrections. The fix makes `normalize_top_left()` apply the same `[1,0,3,2]` + reindex when `swapped` is true, so manual orientation survives. (Historical: + an earlier `[0,3,2,1]` reindex was wrong — it rotated the quad 90°.) +- **UI wiring:** "Swap L/R" button (`ui_builders_preview.py`) → + `CanvasStateMixin.swap_active_slice_left_right()` (`canvas_state.py`) → + `ScanSlicerCanvasActionsMixin.swap_active_box_left_right()` + (`controller_canvas_actions.py`) → `PhotoSlice.swap_left_right()`. +- **Visual color coding** (`canvas_render.py`): corners — left = orange, right + = cyan; edges — red = top (0→1), green = right (1→2), orange = bottom (2→3), + left edge (3→0) follows lock state (blue locked / lightgreen unlocked). + +--- + +## Current pinned versions (snapshot) + +Update this table whenever you bump something, so the next session has a +starting point. + +| Component | Pinned version | Where | +|---|---|---| +| PySide6 / PySide6-Addons / -Essentials / shiboken6 | 6.10.2 | `uv.lock` | +| opencv-python-headless | 4.13.0.92 | `uv.lock` (constraint `~=4.13.0` in `pyproject.toml`) | +| numpy | 2.2.6 (Py<3.11) / 2.4.2 (Py≥3.11) | `uv.lock` | +| Python (dev env) | 3.12 | `.venv` | +| Python floor | `>=3.10` | `pyproject.toml` | +| uv (both Dockerfiles) | 0.10.2 | `Dockerfile`, `packaging/appimage/Dockerfile` | +| appimage-builder | 1.1.0 | `packaging/appimage/Dockerfile` | +| AppImage base OS | Ubuntu **noble** (24.04) | `packaging/appimage/AppImageBuilder.yml` | +| Runtime image base | Ubuntu 24.04 | `Dockerfile` | + +--- + +## What can go stale, and the priority order + +Review these roughly in this order — the first items are the real security/EOL +surface; the last ones are packaging plumbing that only matters at build time. + +1. **PySide6 / Qt** — the largest attack surface and the component with the + most aggressive EOL cadence. Qt 6.x minor releases stop receiving updates + quickly; PySide6 tracks Qt. Keep this on a recent 6.x. +2. **opencv-python-headless** — image processing; bump to the latest 4.x. +3. **numpy** — bump to the latest 2.x. +4. **Python** — the *shipped* runtime is what matters. The AppImage bundles its + own Python (from Ubuntu noble, currently 3.12), so the app does not run on + the host's Python. The `requires-python = ">=3.10"` floor only affects + development resolution. Watch Python 3.10's EOL: when it goes EOL, raise the + floor (e.g. to `>=3.11`) so no EOL interpreter is ever considered valid. +5. **Packaging toolchain** — `uv` and `appimage-builder` versions pinned in the + two Dockerfiles. Only bump these when you have a reason (build breaks, or a + notable security fix in the tool itself). +6. **Ubuntu base images** — `noble` is an LTS (supported to ~2029). Revisit + the apt source and GPG key in `AppImageBuilder.yml` and the `FROM` line in + both Dockerfiles when noble approaches EOL. + +Suggested cadence: check PyPI/upstream releases every ~3–6 months, or promptly +when a security advisory is published for any of the above. + +--- + +## Updating Python dependencies + +All commands run from the repo root. The dev env is `.venv` (Python 3.12). + +### 1. Bring the environment up to the committed lock (baseline) + +```bash +uv sync --frozen +``` + +### 2. Bump a single dependency to its latest + +```bash +uv lock --upgrade-package PySide6 +uv lock --upgrade-package opencv-python-headless +uv lock --upgrade-package numpy +``` + +`--dry-run` previews the change without touching anything: + +```bash +uv lock --upgrade-package PySide6 --dry-run +``` + +To bump *everything* at once (use sparingly — harder to bisect if something +breaks): + +```bash +uv lock --upgrade +``` + +### 3. Install the updated set and verify + +```bash +uv sync +uv run scanslicer /path/to/test/scans +``` + +Then run the smoke-test checklist below. + +### Special case: opencv major/minor bump + +`opencv-python-headless` is the **only** dependency with a constraint in +`pyproject.toml` (`~=4.13.0`), which pins it to the 4.13.x line. To move to +4.14+, edit that constraint in `pyproject.toml` first, then run +`uv lock --upgrade-package opencv-python-headless`. PySide6 and numpy are +unpinned in `pyproject.toml`, so uv can move them freely. + +### After any version change + +- Commit `pyproject.toml` (if edited) **and** `uv.lock` together. +- Update the version table above. +- Update `THIRD_PARTY_NOTICES` — it lists concrete PySide6/Qt, numpy, and + opencv versions, and it ships inside every AppImage. Keep it aligned with + `uv.lock`. +- Rebuild and smoke-test the AppImage if the runtime stack (PySide6/Qt) changed. + +--- + +## Updating the AppImage packaging toolchain + +The AppImage build pins its own tool versions in `packaging/appimage/Dockerfile`: + +- `uv` (`COPY --from=ghcr.io/astral-sh/uv:0.10.2 ...`) +- `appimage-builder==1.1.0` (an `ARG` near the top) + +Bump either by editing that file. If you change `appimage-builder`, the +`AppImageBuilder.yml` recipe may need adjusting for new versions (rare). The +Ubuntu apt source and GPG key live in `AppImageBuilder.yml` — update those when +noble goes EOL or the key rotates. + +> **Hardcoded Python paths:** `AppImageBuilder.yml` hardcodes `python3.12` in +> `QT_PLUGIN_PATH` and `LD_LIBRARY_PATH`. If the AppImage base OS ever moves to +> a different Python minor, those two lines must be updated to match. + +--- + +## Updating the runtime Docker image + +`Dockerfile` (repo root) pins the base (`ubuntu:24.04`) and `uv 0.10.2`. +Bump these when needed, then rebuild and run the container smoke test: + +```bash +docker compose up --build +``` + +--- + +## Verifying a change (smoke-test checklist) + +There is **no automated test suite**. After any dependency or packaging change, +manually confirm the app still works: + +1. **Startup** on both Wayland and X11: + ```bash + uv run scanslicer --backend wayland + uv run scanslicer --backend x11 + ``` +2. **Detection** on a representative sample scan (boxes appear on load/Detect). +3. **Save flow** — lock a box, export, confirm the output is straight and not + mirrored (this fork's orientation fix is the thing most likely to regress). +4. **Auto-tune** — run a profile and confirm feedback is written/read without + error. +5. If the runtime stack changed: rebuild the AppImage and confirm it launches + and saves. +6. If the Docker image changed: `docker compose up --build` and confirm the app + opens. + +--- + +## The Docker gotcha (recap — keep this in mind) + +The maintainer's Docker points at **Docker Desktop** (`desktop-linux` context). +Its bind-mount filesystem forbids `chown`, but `appimage-builder` needs root +ownership while extracting `.deb` packages, so the Dockerized AppImage build +fails with: + +``` +dpkg-deb ... Cannot change ownership to uid 0, gid 0: Operation not permitted +``` + +Workaround: run the build against the **native** daemon instead: + +```bash +DOCKER_HOST=unix:///var/run/docker.sock bash packaging/appimage/build-appimage-docker.sh +``` + +(or `docker context use default` first). Output lands in +`appimage/dist/ScanSlicer--x86_64.AppImage` (the `appimage/` dir is +git-ignored). + +--- + +## Commit / release hygiene + +- Commit `uv.lock` and `pyproject.toml` (and `THIRD_PARTY_NOTICES` when + applicable) together as a single change. +- Write a short, factual commit message, e.g. `Bump PySide6 to 6.10.x`. +- Push to the Forgejo remote: `git push origin`. +- The AppImage build verifies package hashes (`--require-hashes`) from + `uv.lock`; publishing the AppImage alongside the matching committed source is + already handled by committing before building. + +--- + +## One-line summary + +Bump PySide6/Qt first and most often; bump opencv and numpy when convenient; +treat uv/appimage-builder/Ubuntu as build plumbing you only touch when +necessary. After every change, run the smoke-test checklist, rebuild the +AppImage if the runtime stack moved, and commit `uv.lock` + `pyproject.toml` +together. diff --git a/packaging/appimage/AppImageBuilder.yml b/packaging/appimage/AppImageBuilder.yml index 925bbd1..c7b5c97 100644 --- a/packaging/appimage/AppImageBuilder.yml +++ b/packaging/appimage/AppImageBuilder.yml @@ -6,7 +6,7 @@ AppDir: id: scanslicer name: ScanSlicer icon: scanslicer - version: "1.0.0" + version: "1.1.0" exec: usr/bin/python3 exec_args: "-m scanslicer $@" diff --git a/packaging/appimage/build-appimage.sh b/packaging/appimage/build-appimage.sh index aee544c..f8a66dd 100755 --- a/packaging/appimage/build-appimage.sh +++ b/packaging/appimage/build-appimage.sh @@ -28,11 +28,25 @@ uv export \ --no-emit-project \ --format requirements-txt \ --output-file "${BUILD_DIR}/requirements.txt" +# Remove stale wheels from previous versions so a version-specific lookup +# below cannot accidentally pick up an outdated build artifact. +rm -f "${BUILD_DIR}"/scanslicer-*.whl uv build --wheel --out-dir "${BUILD_DIR}" -WHEEL_PATH="$(ls -1 "${BUILD_DIR}"/scanslicer-*.whl | head -n 1)" -if [[ -z "${WHEEL_PATH}" ]]; then - echo "Could not find built wheel in ${BUILD_DIR}" >&2 +# Select the wheel matching the project version from pyproject.toml. Listing +# with `ls` and taking the first result breaks once an older wheel for a +# previous version is left in the build dir, because `ls` sorts +# alphabetically and would pick the stale artifact. +PROJECT_VERSION="$(sed -n 's/^version *= *"\([^"]*\)".*/\1/p' "${ROOT_DIR}/pyproject.toml" | head -n 1)" +if [[ -z "${PROJECT_VERSION}" ]]; then + echo "Could not determine project version from pyproject.toml" >&2 + exit 1 +fi +WHEEL_PATH="${BUILD_DIR}/scanslicer-${PROJECT_VERSION}-py3-none-any.whl" +if [[ ! -f "${WHEEL_PATH}" ]]; then + echo "Could not find built wheel for version ${PROJECT_VERSION} in ${BUILD_DIR}" >&2 + echo "Found wheels:" >&2 + ls -1 "${BUILD_DIR}"/scanslicer-*.whl >&2 || true exit 1 fi diff --git a/pyproject.toml b/pyproject.toml index db445a4..922896e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "scanslicer" -version = "1.0.0" +version = "1.1.0" description = "Detect, straighten and save photos from a flatbed scanner source image" readme = { file = "README.md", content-type = "text/markdown" } requires-python = ">=3.10" diff --git a/uv.lock b/uv.lock index 4cfe9d0..5668d10 100644 --- a/uv.lock +++ b/uv.lock @@ -222,7 +222,7 @@ wheels = [ [[package]] name = "scanslicer" -version = "1.0.0" +version = "1.1.0" source = { editable = "." } dependencies = [ { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },