ScanSlicer/AGENTS.md
dan 607f5e0c57 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
2026-09-11 21:33:59 +02:00

6.7 KiB
Raw Blame History

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 (Francesco Vannini). Runs on Linux over Wayland or X11.

This repo is a local fork of macrodev8/ScanSlicer (upstream owner inactive). License: GNU GPL v3 (must be preserved). Attribution: keep upstream credit and the "inspired by PhotoSlicer" note.

Goals of this fork

  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 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

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, 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):

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