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
This commit is contained in:
dan 2026-09-12 15:11:07 +02:00
parent 5f71b61596
commit 21f761406d
7 changed files with 289 additions and 158 deletions

View file

@ -11,3 +11,6 @@ out
.env .env
.scanslicer .scanslicer
.uv-cache .uv-cache
.venv
.posit
appimage

152
AGENTS.md
View file

@ -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-<version>-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).

266
MAINTENANCE.md Normal file
View file

@ -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-<version>-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.

View file

@ -6,7 +6,7 @@ AppDir:
id: scanslicer id: scanslicer
name: ScanSlicer name: ScanSlicer
icon: scanslicer icon: scanslicer
version: "1.0.0" version: "1.1.0"
exec: usr/bin/python3 exec: usr/bin/python3
exec_args: "-m scanslicer $@" exec_args: "-m scanslicer $@"

View file

@ -28,11 +28,25 @@ uv export \
--no-emit-project \ --no-emit-project \
--format requirements-txt \ --format requirements-txt \
--output-file "${BUILD_DIR}/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}" uv build --wheel --out-dir "${BUILD_DIR}"
WHEEL_PATH="$(ls -1 "${BUILD_DIR}"/scanslicer-*.whl | head -n 1)" # Select the wheel matching the project version from pyproject.toml. Listing
if [[ -z "${WHEEL_PATH}" ]]; then # with `ls` and taking the first result breaks once an older wheel for a
echo "Could not find built wheel in ${BUILD_DIR}" >&2 # 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 exit 1
fi fi

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "scanslicer" name = "scanslicer"
version = "1.0.0" version = "1.1.0"
description = "Detect, straighten and save photos from a flatbed scanner source image" description = "Detect, straighten and save photos from a flatbed scanner source image"
readme = { file = "README.md", content-type = "text/markdown" } readme = { file = "README.md", content-type = "text/markdown" }
requires-python = ">=3.10" requires-python = ">=3.10"

2
uv.lock generated
View file

@ -222,7 +222,7 @@ wheels = [
[[package]] [[package]]
name = "scanslicer" name = "scanslicer"
version = "1.0.0" version = "1.1.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },