feat: add explicit left/right orientation with swap button

Fixes mirrored slice exports by making orientation explicit:

- PhotoSlice.swapped flag persists a user left/right swap
- swap_left_right() mirrors [TL,TR,BR,BL] -> [TR,TL,BL,BR]
- normalize_top_left() honors the flag so squarize/reset_rotation
  no longer overwrite manual orientation
- Swap L/R toolbar button wired through canvas state + controller
- Color-code left (orange) vs right (cyan) corners and edges
This commit is contained in:
dan 2026-09-11 21:33:59 +02:00
parent 047ea60fa5
commit 607f5e0c57
7 changed files with 114 additions and 31 deletions

View file

@ -18,8 +18,8 @@ credit and the "inspired by PhotoSlicer" note.
## Goals of this fork
1. Fix a mirrored-export orientation bug and add an explicit left/right
orientation workflow (see below). This is the primary patch.
1. Fix the mirrored-export orientation bug and add an explicit left/right
orientation workflow (implemented, see below). This is 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
@ -29,40 +29,43 @@ 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 bug (primary work item)
## The orientation fix (implemented)
- Exported slices can come out mirrored (left/right swapped).
- Manual workaround: drag-switch the two corners of the top edge so the red
edge (edge 0) becomes the true top.
- Problem: `squarize()` and `reset_rotation()` re-run `normalize_top_left()`,
which re-derives "top" from geometry and silently overwrites the manual fix.
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.
Desired outcome:
The fix adds an explicit left/right orientation workflow:
1. A visual indication of left vs. right edges/corners (in addition to the
existing red top edge).
2. A "swap left/right" button for the active slice.
3. The corrected orientation must persist through `squarize()` /
`reset_rotation()`.
- `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]`.
- `PhotoSlice.normalize_top_left()` (in `slices.py`) picks a top-left corner and
resolves winding so edge 0→1 is the "most horizontal top" edge. This is the
source of the ambiguity.
- `PhotoSlice.squarize()` and `reset_rotation()` both call
`normalize_top_left()` at the end, discarding any manual orientation.
- `Autoslicer.save_slice()` (in `autoslicer.py`) builds the perspective
transform assuming the `[TL,TR,BR,BL]` order; mirroring arises upstream of
this from a wrongly "normalized" quad.
- Edge 0 is drawn red in `CanvasRenderMixin.paintEvent()` (`canvas_render.py`).
A persistent orientation flag on `PhotoSlice` (respected by
`normalize_top_left`/`squarize`/`reset_rotation`) plus a swap action is the
likely shape of the fix. Do not change the `[TL,TR,BR,BL]` contract of
`save_slice` without updating every consumer.
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
@ -106,10 +109,28 @@ uv run scanslicer /path/to/scans # or: python -m scanslicer
```
- AppImage (preferred): `bash packaging/appimage/build-appimage.sh`
- Docker: `docker compose up --build`
- AppImage via Docker: `bash packaging/appimage/build-appimage-docker.sh`
- Docker runtime: `docker compose up --build`
- Dependencies (from `pyproject.toml`): PySide6, opencv-python, numpy, Pillow.
- Python >= 3.10; dev env is Python 3.12 in `.venv`.
### 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/`)