- Rewrite README for fork context, add changes-from-upstream section, prune sections duplicated by docs/, surface AppImage as preferred run - Add fork maintainer alongside upstream author in pyproject.toml - Sync AGENTS.md with completed patch and deliberate supply-chain decisions
152 lines
7.2 KiB
Markdown
152 lines
7.2 KiB
Markdown
# 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).
|