GUI (aglaia)
PySide6 desktop app. Entry: uv run aglaia gui [PROJECT] — gui is the
default command, so uv run aglaia and uv run aglaia ~/book.agl open it too.
See the CLI reference.
Layout
A top tab bar switches between Scans, Edit pipeline, Settings, Log, and any open image/debug viewer tabs. The Scans tab is the main workspace: a collapsible left panel, the captured-scans area (grid / table / gallery views), and a right-edge sidebar whose ActivityBar icons swap the content pane between the Capture, Import, Pipeline, OCR and Export tabs. The live webcam preview lives inside the Capture tab.
┌─────────────────────────────────────────────────────────────────────────┐│ Scans │ Edit pipeline │ Settings │ Log │ … ← top tab bar │├─────────────────────────────────────────────────────────────────────────┤│ collap- │ Captured scans │ Sidebar tab │ Act- ││ sible │ (grid · table · gallery) │ content pane │ ivity ││ left │ │ ┌───────────┐ │ Bar ││ panel │ ┌─────────────────────────────────┐ │ │ Capture │ │ [▣] ││ │ │ ScanItemWidget #N │ │ │ webcam │ │ [⤓] ││ │ │ raw → step01 → … → output │ │ │ preview │ │ [⫶] ││ │ └─────────────────────────────────┘ │ └───────────┘ │ [A] ││ │ … (top = newest) │ Import·Pipe· │ [⇪] ││ │ │ OCR · Export │ │├─────────────────────────────────────────────────────────────────────────┤│ Status / Voice label ← bottom status bar │└─────────────────────────────────────────────────────────────────────────┘Sidebar tab widgets live in aglaia/gui/sidebar/tabs/ (CaptureTab,
ImportTab, PipelineTab, OcrTab, ExportTab); the ActivityBar +
content QStackedWidget are assembled in aglaia/gui/sidebar/SidebarPanel.py.
Threads / processes
- MainWindow runs on the Qt main thread.
- WebcamThread (
aglaia/gui/WebcamThread.py) — QThread,cv2.VideoCapture, applies rotation/mirror/flip per frame, emitschange_pixmap_signal.get_frame()returns the latest BGR frame on demand. 30 FPS cap. - ProcessMonitor (
aglaia/workers/ProcessMonitor.py) — QThread that blocks onlog_queue.get(timeout=0.1)and re-emits messages as Qt signals on the main thread. Handlesimage_event,worker_started,log_info/warning,error, andtiming(printed via Rich). - VoiceWorkerVosk (
aglaia/gui/VoiceWorkerVosk.py) — QThread running Vosk offline against a constrained grammar, on every platform. Emitscommand_detected(action)andtranscription_update(text). Skipped when thevoiceextra or the model is absent. (Apple Speech drove this until 2026-08; it is gone.) - Processing chain — separate worker processes started by
IntegratedProcessingChain.start(). Workers persist each step directly to the project.aglSQLite DB (no separate writer process).
Workflow
Projects are a single SQLite <slug>.agl file — there are no per-step output directories on disk. Raw captures and imports become scans rows plus a raw root nodes row pointing at a COLOR image blob (aglaia/storage/persister.py Persister); every pipeline result is persisted as a further node. The only sibling files are slug-prefixed debug dirs and the export target.
initialize(mode="capture")(aglaia/workers/Initializer.py) parses args/config and buildsargs.options. For capture modeargs.options["paths"]holds onlyroot,debug_prefix, andexport— noraw/outputdirs.load_calibration()reads<APP_DATA>/camera_params.json. If present,cv2.getOptimalNewCameraMatrixis computed at capture time and each grabbed frame is undistorted before it is persisted (no on-disk save).create_processing_chain(args, log_queue, db_path=…)builds theIntegratedProcessingChain(aglaia/workers/Initializer.py).chain.start()spawns the multiprocessing workers — they persist each step straight to the project DB (no separate writer process).load_existing_scansrebuilds the right-hand panel from the SQLite DB (ScanRepo.list_active→NodeRepo), replaying every persisted node into itsScanItemWidgetand seedingcurrent_idxfrom the highest scan idx.WebcamThread,ProcessMonitor,VoiceWorkerstart.- On user action:
- Scan (key
Space/S, voicescan|check|next|photo, SIFT auto-trigger, button — all funnel throughMainWindow.capture): grab frame → undistort (if calibrated) → BGR→RGB → in one DB session create the scan + persist the COLOR blob + raw root node (Persister.persist_image/persist_node,ScanRepo.set_root) → spawn the rawScanItemWidget→ enqueue anImageBuffer(carryingscan_id/parent_node_id/pipeline_version_id) on the chain input queue. No.jpgis written. - Import (Import tab →
_on_sidebar_import_requested→aglaia/workers/ImportHelpers.py):enqueue_image_files/enqueue_pdf_filespersist each image — and each PDF page, rendered per-page via pypdfium2 (pdf_extract.render_page) — as a scan + raw root node, emit ascan_importedlog_queueevent, and enqueue theImageBuffer.ProcessMonitorre-emits the event;MainWindow.on_scan_importedspawns the raw widget immediately, before any worker stage completes. - Trash/undo (
Backspace/D, voicetrash|delete|cancel): pop last from history, then soft-delete the scan in the DB (ScanRepo.soft_deletesetsscans.deleted_at) so it drops out of the active list. No blobs are removed. - Quit (⌘Q / Ctrl+Q via
QKeySequence.StandardKey, voicedone|quit): closes the window. - Rotate (
R): cycles preview rotation by 90°.
- Scan (key
- Worker
image_events are routed byscan_id(MainWindow.on_image_event→scan_widgets_by_scan[scan_id]) to update the matchingScanItemWidgetas each node lands. The user picks the kept page per branch in the widget; export happens via the Export tab (see below). - On close (
closeEvent): stop the webcam/monitor/voice threads, close the thumbnail loader, andshutil.rmtree(<workspace>/<output_dir_name>/._temp)if that temp dir exists. No PDF is generated on close.
ScanItemWidget
aglaia/gui/ScanItemWidget.py. One per captured scan. Shows the file’s progression through pipeline steps:
rawthumb → one thumb per pipeline step (pipeline_steps= theinstance_names computed inMainWindow.__init__).outputthumb is the latest persisted result for the scan.- A 200 ms single-shot timer coalesces the refreshes triggered by a burst of
image_events into one repaint. - On startup the widget is rebuilt from the project DB (
load_existing_scans→ScanRepo/NodeRepo), not from files on disk.
Per-page processor disable
Replaces the old exit-stage navigation (chevron step-back/forward, gallery
star, table select-as-chosen — all removed). Each page-layout can individually
disable a toggleable processor (linear COORDINATE/PIXEL_VALUE steps;
PageDetector and other ROI/branch-emitting steps are locked). Toggling writes a
step_overrides row and reruns that scan from raw (set_step_disabled →
_reprocess_snaps_callback); see storage.md.
The three views surface it differently, all via MainWindow.cell_disable_states
({node_id: (toggleable, disabled)}) + MainWindow.toggle_step_disabled:
-
Table (
ScansTableView) — primary. Click a stage cell to toggle it; disabled cells get a red strike. -
Grid (
ScanItemWidget) — keeps the chevrons (display nav only now). A round overlay on the displayed stage shows its pipeline index (orRfor replay) — blue = active, red✕= disabled; click toggles. A 3px band at the thumbnail’s top is a mini-map of the layout’s disabled steps (one red slot per disabled stage), hidden when nothing is disabled. -
Gallery (
ScansGalleryView) — a toggle (replacing the star) on the current stage; left/right still walks stages.Navigation. Arrows or WASD step: up/down between scans, left/right between stages of the current scan. Home / End jump to the first / last scan, and so do ⌘↑ / ⌘↓ — Home and End need Fn on most Mac keyboards, and ⌘↑/⌘↓ is the native start/end-of-document idiom there. With the mouse, shift-click the up/down chevron: the same “all the way” convention as a shift-click in a list, reusing a control already on screen rather than putting two more floating buttons over the page, which is the one part of this view that should stay clear. The chevron tooltips name the shortcuts, because a keyboard-only feature is an invisible one.
A jump carries the current stage across, exactly as a single step does — arriving at page 300 must not also discard the stage being examined.
Camera memory
Rotation, mirror and flip are remembered per camera, in the app-data
config DB (camera_transforms), and re-applied when that camera is opened
in any later project. The rig does not move between books, so the correction
that makes its feed upright is a property of the camera, not of the project.
Keyed by the device’s name (AVFoundation localizedName), not its index —
indexes shift when devices come and go. An explicit --transform on the
command line wins for that session; setting the transform back to identity
forgets the entry. (aglaia/gui/camera_memory.py)
Debug view / per-page editor (DebugViewerTab)
Click a stage thumb and a closable tab walks that page’s chain, root → leaf. Two panes: the stage strip on the left, the selected stage on the right.
The strip lists one row per pipeline step: a small thumbnail, the step
name, and a background that identifies the processor. It used to zebra on
the row INDEX, which carries no information — two adjacent look-alike stages
got two different shades and two unrelated stages got the same one, so
scrolling had no visible effect. Keyed by processor it reads as bands, and the
two DPIfixers and two SkewFinders of the default pipeline get a light/dark
variant so a repeat is still a seam. Thumbnails are small on purpose: at the
old 200-px portrait thumb a ten-step chain needed ~3700 px of strip, so the
whole thing was a scroll with no landmarks; a default book_curved_x2 chain
now fits a normal window without scrolling.
The stage pane shows the per-processor overlay composite — spans,
baselines, the fitted quad and grid — rendered in the background by
storage/debug_renderers.py. There is no “show overlays” toggle: this view
exists to show the debug data.
Manual tuning (M9). Three stages can be corrected by hand; the edit is
stored per page-layout in manual_overrides and the page-branch is rerun.
See storage.md and
processors.md.
| Stage | Control |
|---|---|
| SkewFinder | a rotation handle on the image and a slider, both on the same angle |
| PageDetector | the ROI polygon — drag a vertex, double-click an edge to add one |
| TrapezoidalCorrection | the column quad — drag a corner. Four corners, no more: a keystone is a projective map from exactly four points, so this polygon refuses insertion. When the step fell back and found no quad, the corners are seeded from the frame — that is precisely the page a user wants to draw one on |
| PageDewarper | sliders for arch, tilt and the spine γ, plus Force dewarp. The grid previews the sheet live |
Arch and tilt are not the fitted parameters. The solver fits α and β,
which are the sheet’s slopes at the LEFT and RIGHT page edges (z'(0) = α,
z'(1) = β, and z is pinned to 0 at both). Neither moves one visible thing
on its own — every drag of either reshapes the whole surface — which is what
made them unusable by hand. The editor rotates the pair:
arch = (α − β)/2 tilt = (α + β)/2α = arch + tilt β = tilt − archz(0.5) = (α − β)/8 = arch/4, so arch alone sets the mid-page rise — the
arch of a bound page, the thing the eye reads — and tilt alone slides its
crest left or right. The arch slider shows that rise as a percentage of the
page width beside its raw value. Ranges cover the whole delbrel-oc9 corpus
(276 fitted pages: |arch| ≤ 0.325, |tilt| ≤ 0.250, |γ| ≤ 0.100) at a 0.001
step, about one step per slider pixel.
The grid previews live. Dragging a curl slider redraws the sheet in
magenta over the fitted grid, from the same builder the remap uses
(dewarp_grid_lattice) with the row’s stamp and the edited curl substituted —
so it is the surface, not an approximation of it. The pose is the last fit’s:
a rerun re-optimises it around the frozen shape, so the final page shifts a
little. Magenta deliberately, not the renderer’s green: green is what was
fitted, magenta is what the sliders are asking for.
Force dewarp runs the fit past the min_spans guard and the max_oob
gate. Both are right by default and both are sometimes wrong — a sparse page
whose few spans are perfectly good, a wide fit the gate reads as runaway. The
result may be worse; the node records manual: force (and oob_forced when
the gate was the thing overridden), so a bad page stays explainable.
Handles are vector, painted by DebugEditCanvas.EditCanvas over the raster.
The renderers hand them the geometry as numbers (geom), in the coordinates
of the stage frame, with two mappings beside it:
origin— where that frame sits inside the composite: the label bar above it, the crop offset of a child drawn on its parent;scale— what_png_data_urlshrinks the composite by on its way out (Qt’s allocation cap). The picture on screen is not the composite.
A handle is drawn at (point + origin) × scale. Miss origin and every
handle sits a bar-height, or a crop, from its pixel; miss scale and the
error grows with the coordinate, which reads as everything shifted down and
right.
Every spatial edit stores the frame it was made on, so a polygon is validated rather than silently rescaled.
After a rerun the branch’s subtree is wiped and rewritten, so the tab’s
leaf node is a dead row. If the tab’s own branch is gone — the layout set
was edited and its layout deleted — reload_for re-targets the scan’s first
surviving branch instead of declining: _reprocess leaves the editor in a
busy state that only reload_for clears, so declining left it disabled on
“Reprocessing…” for the rest of the session over a page that had already
finished (#121). A scan with nothing left at all clears the busy state and
says so, rather than freezing. MainWindow._refresh_debug_tabs, on branch_ready,
re-targets every open tab of that page-branch and re-keys _debug_tabs —
without it the tab keeps showing the pre-edit chain and the editor reads as
broken.
While that rerun is in flight the tab keeps the previous composites on
screen (_adopt_stale_overlays), row by row and only where the processor
still matches. Dropping them meant the dewarp’s source | output picture was
replaced by the bare stage image plus the light Qt overlay for the whole
render — exactly while the user was comparing the live slider grid against
it (#106).
The layout set (PageDetector rows)
On a PageDetector row the handles work on every layout at once, in PARENT coordinates — the whole photo, not one child’s crop. Before, the polygon lived in the crop the detector had chosen and was clamped to it, so a page could only ever be corrected inwards: a vertex could not be dragged out to where the page really was, and the orange box was a wall.
- Drag a vertex anywhere on the photo; double-click an edge to insert one.
- Trash badge — a translucent disc at each layout’s barycentre. The last layout keeps none: deleting it would leave the page with nothing to process.
- Add badge — top-right of the picture. Drops in a rectangle to drag into shape, offset from the ones already there so a second Add is not hidden under the first.
The set is stored once per scan on the trunk (branch_path == "",
manual_overrides.layouts + layouts_frame_wh), because it decides how many
branches exist and so belongs to none of them. PageDetector reads it before
its own empty-page guard and lets it REPLACE detection: that is what makes a
deletion stick (a layout removed by hand is not found again next run), what
lets a layout be added to a page the detector saw as blank, and what lets the
crop follow the polygon instead of the other way round. smart_merge and
max_pages are skipped — they guard a guess, and this is not one.
A whole-scan rerun goes through MainWindow._rerun_scans_from_raw, which
calls ScanItemWidget.forget_layouts() on each card first. handle_event
only ever ADDS a stem, so a rerun producing FEWER layouts left the extra ones
on the card — a deleted layout kept showing its thumbnail, built from nodes
the rerun had already dropped from the DB (#123). The raw entry survives (it
is what the rerun feeds from); the incoming events rebuild the rest.
Editing the set reruns the whole scan, not one branch: which branches
exist is exactly what is being decided, so resuming from the split point would
rerun children about to be renumbered or deleted. Each resulting child is
stamped manual: layouts, named for the instrument — only that one explains a
changed page count.
Auto-process (on by default, remembered for the session) reruns the page
once a value settles — never per drag step. EditCanvas.edited fires on
every mouse-move so the handle tracks the cursor; edit_finished is the
commit, emitted on release (and immediately for an atomic edit like a
double-click vertex insert). Persisting and rerunning on edited launched a
chain rerun per move event, hundreds deep into one drag, until memory ran out
and the app died (#116). The sliders debounce the same way, on
sliderReleased.
Turn it off to make several edits and run once — the Reprocess button lights up, and is dimmed while auto-process is on because there would be nothing for it to do. Clear override drops this layout’s stored values and restores the automatic result.
Manual tuning survives a force rerun: the pages come back as they were
corrected. The Force-rerun dialog therefore offers three ways out — Cancel,
Reprocess all, and Reprocess all and clear manual overrides (in the danger
colour: it throws away work done by hand, which no rerun can recover). The
third clears manual_overrides only; the per-page step disables live in
step_overrides and keep their own toggles in the scan views.
Every rerun path — the card’s ↻, Reprocess all, and Apply pipeline +
reprocess — first calls _prepare_cards_for_rerun(), which puts each card
back to its raw source (ScanItemWidget.forget_layouts) before the wipe. A
card’s state is not a view of the DB, it is accumulated from events:
handle_event only ever ADDS a stem, and the decoded-pixmap cache is keyed by
image_id — a rowid the reprocess reuses for new pixels (257 ids came
back holding different content across one 121-scan rerun). So a card that is
not reset paints the previous run: ghost layout columns, and thumbnails of
images that no longer exist (#123, #162).
An edited pipeline also has to reach the live cards: _spawn_widget passes
pipeline_steps once and reuses the widget for the session, so
update_pipeline_context pushes the new list through
ScanItemWidget.set_pipeline_steps. Without it the card walks its original
stage rail with the new step names appended at the end, and is_final — which
decides the final-step zoom and where the OCR badge sits — stops matching.
When the chain goes idle, _reconcile_progress_if_idle forces the bar to 100 %
and clears stuck spinners (it exists to rescue a dropped branch_ready). That
is also how a run that genuinely lost work reports itself complete, so it now
calls _report_scans_left_unprocessed: active scans with no live branches
row are named in the Log tab and counted in a toast. One reprocess lost 35 of
121 scans and said 100 %; the hole only surfaced at export.
The slider ranges are chosen for manual tuning, not for the solver’s freedom: curl is clamped at ±0.5 internally but a page past ±0.35 is already extreme, and a full-width slider over the solver’s range would make every useful value a two-pixel move.
Hand-edited pages in the scan views
A page carrying manual overrides looked exactly like one the pipeline decided
alone, everywhere outside the debug editor. All three views now mark it with
one quiet dot — widgets.ManualPip, the primary accent, no glyph, no text —
whose tooltip names what was touched (“Hand-tuned: deskew angle, dewarp
curl”).
| View | Where |
|---|---|
| Table | beside the branch label, where the eye reads the row’s identity |
| Card grid | top-right of the layout thumbnail, clear of the disabled band (top edge) and the nav buttons |
| Gallery | top-right of the stage image, clear of the star (top-left) and the disabled glyph (centre) |
Quiet is the requirement: a hand-edited page is not a warning, so the mark must not compete with the disable strike (red) or the trashed state, and it is absent entirely on a page with no override — which is the common case, and the reason the mark says anything at all.
All three read MainWindow.manual_fields_for_layout(scan_id, branch_path),
which merges the layout’s own payload with the pre-split trunk’s. It is
memoised per scan like cell_disable_states — the views ask on every repaint
and it is one SQLite round-trip each — and the cache is dropped on
branch_ready and on every edit the editor writes.
Calibration buttons
- Full Calibration — guides the user through capturing
calnum(default 10) chessboard frames. Last sample is taken with board flat at “book distance” → its measured px-per-square sets the DPI. CallsCalibrator.finalize_calibration→save_calibration(...)→ writes<APP_DATA>/camera_params.json. Restart capture to pick up the new calibration. - Calibrate DPI — single-sample, updates only the DPI field while keeping the existing camera matrix.
Print assets/calibration/calibration-chessboard_A4_7x10sq_25mm.pdf on real A4 (at 100%) as the calibration target — generate it with scripts/gen_calibration_board.py. Default board is 6×9 inner corners at 25mm squares (see docs/calibration.md).
Voice commands
Defaults from config/default.yml:
voicecontrols: scan: [scan, check, next, photo] trash: [trash, delete, cancel] quit: [done, quit] debounce_time: 2Implementation: the recognizer runs continuously; only new words on each partial result are matched. A 2-second debounce prevents double-firing. Display label shows last ~10 words.
Keybindings
Defaults:
keycontrols: scan: [Space, S] trash: [Backspace, D] rotate: [R]Quit (⌘Q / Ctrl+Q) and close-tab (⌘W / Ctrl+W) use platform-standard
shortcuts wired via QKeySequence.StandardKey; they are not configurable
in keycontrols.
Editable in the app. The capture panel’s shortcut legend carries a small pencil; it opens a modal with two slots per action. Clicking a slot arms it — the next key or combination pressed is what it becomes, and focus leaves. No record button, no timeout. Tab and Escape stay the dialog’s, so an armed field cannot trap the user in it; bind Escape from the other slot.
Two slots per action is not decoration. A presentation remote’s fullscreen
button typically cycles between Shift+F5 and Esc, so driving capture from
one needs both bound to the same action — which is the case this was built
for.
Bindings are stored per user in the app-data config DB (KEY_KEYBINDINGS) as
QKeySequence portable strings, and override the YAML keycontrols per
action. An action the user cleared is stored as an empty list: that is a
decision, not an absence, so it does not fall back to the YAML default.
Matching goes through QKeySequence (aglaia/gui/keybindings.py). The
hand-rolled matcher it replaced compared key NAMES against event.text() and
a table of seven names, and never looked at event.modifiers() — so no
combination was expressible at all. Every legacy default (Space, S,
Backspace, D, R) parses as a QKeySequence, so a config written before
the change keeps working untouched.
Priority over the focused widget
The bindings are taken by an application-level event filter
(MainWindow.eventFilter, installed on the QApplication), not by
keyPressEvent. That method is the last stop in Qt’s propagation: whatever
has focus sees the press first, and anything it accepts never reaches the
window. So PgUp paged the list view’s scroll area instead of capturing —
it worked in the gallery, which does not consume it — and the first press
after a click was eaten by whatever had just taken focus, which reads as
“I have to press twice” (#119). A filter on the window would not have helped:
that is still downstream of its own children.
The filter only pre-empts while capture is genuinely in front
(_capture_keys_have_priority): the window is active, no modal dialog is
open, the capture panel is visible, and focus is not in a text entry
(keybindings.is_text_entry — QLineEdit, QTextEdit, QPlainTextEdit,
QAbstractSpinBox, an editable QComboBox, and their subclasses). Those two
exclusions are what keep s a letter in a filename field and let the
keybinding recorder — itself a QLineEdit, inside a modal — record a
bound key rather than fire it. Auto-repeat is ignored, so a stuck key cannot
spray captures. keyPressEvent stays as the path for a press that reaches
the window on its own.
Input transforms
WebcamThread.set_transform(str) parses a string like "180+mirror": rotation in {0, 90, 180, 270}, plus optional mirror (horizontal) and flip (vertical). The GUI transform buttons mutate this state live.
OCR tab
The sidebar OCR tab (aglaia/gui/sidebar/tabs/OcrTab.py) picks an engine via a
RadioCardGroup and fires run_requested(engine, languages, mode, complement)
→ MainWindow._on_ocr_run_requested → OcrWorker.
Only three cards are shown, with the rest behind a “N more engine(s)…”
handle. Six cards is a wall, and for almost everyone the answer is one of the
first two — Apple Document and Cloud on a Mac, Cloud and GLM elsewhere, which
is what the declared card order already lands on once _platform_ok has
dropped the engines this OS cannot run.
Which three: the first three that are usable (_visible_engine_keys). A
card whose weights are not downloaded is an Install button, and that is not
what someone opening this panel came for, so it waits with the rest. Two rules
keep the fold honest: the selected engine is always shown — hiding the
engine that is about to run would be a panel lying about what it will do, and
it also means someone whose persisted choice is Surya opens the panel already
unfolded — and when nothing is usable (a fresh install, no models, no key)
the first three are shown anyway, or the Install buttons would be unreachable.
Engine cards:
-
Apple Document engine (
apple_docs) — default on a capable Mac. macOS 26VNRecognizeDocumentsRequest: a structured, reading-ordered document tree (meta.document) plus a flat-line confidence pass. Lines Apple Vision can’t read (non-Latin scripts like Greek — per-line confidence below the confidence gate) are cropped and re-OCR’d by a complement engine chosen in the card’s Complement engine dropdown, filled from the registry (direct_block_engines(); Surya default, or None). Fail-open: if the complement is unavailable the Vision text is kept. The gate is a system param (default 0.7): envAGLAIA_OCR_CONFIDENCE_GATE→ SQLiteKEY_OCR_CONFIDENCE_GATE→ default, resolved byresolve_confidence_gate(). Raise it to offload more lines, lower it to offload fewer. Seeaglaia/workers/ocr/apple_docs.py. -
Apple Vision (
apple_vision) — the flatVNRecognizeTextRequestpath with the geometric Markdown heuristics. -
Surya 2 / GLM-OCR / Unlimited-OCR — local VLM engines, served by MLX on Apple Silicon and vLLM on CUDA (needed off-mac and for full-page VLM runs). PaddleOCR-VL was dropped in 2026-07.
-
Cloud OCR (Mistral) (
mistral_cloud) — whole-document engine. The selected pages are assembled into one PDF (bitonal scans → CCITT G4, the same codec as our exports; colour/grey → JPEG), uploaded once to Mistral’s Document AI (mistral-ocr-latest), and the per-page Markdown spliced back into per-branch results (meta.markdown, rendered verbatim by md_export). Reads any script (Greek, etc.) off-device.whole_doc = TruemakesOcrWorkersend every selected page (per run mode: missing / missing+stale / all) in onerecognize_batchcall. Mistral caps an upload at 1000 pages / 50 MB — over that, the engine truncates to the leading pages that fit, OCRs those, and leaves the rest pending (flaggedmeta.truncated→OcrWorkerfail()s them); a Log-tab advisory tells the user to run OCR again to continue. Page mapping is positional (Mistral page i → the i-th selected scan). Needs an API key; the SDK and the keychain are base dependencies. Seeaglaia/workers/ocr/mistral_cloud.py.API key — set via the card’s Set API key… button (masked dialog). Resolution order (
aglaia/app_data/secrets.py): envMISTRAL_API_KEY→APP_DATA/.env→ OS keychain (keyring)..envis checked before the keychain so a dotenv-style dev never triggers a keychain unlock prompt. Write prefers the OS keychain, falling back to a cleartextAPP_DATA/.env(0600) only when no keychain backend exists (headless Linux/Windows). Optional password-manager backends:uv sync --extra keyring-bitwarden/--extra keyring-1password(keyring auto-discovers them).Key status line — the card says where the key resolves from. Reading a keychain item pops a system password prompt on macOS only (
secrets.keychain_read_prompts), so there the card probes the keychain only once the user engages Cloud OCR, and shows a neutral hint until then. On Linux (Secret Service) and Windows (Credential Locker) the read is silent, so the card probes at startup — deferring there only hid a key that was stored.When there is no keychain — the key falls back to a plaintext
APP_DATA/.env(0600).secrets.keychain_backend()says which of the two reasons applies, and both the key dialog and the post-save message name it:not_installed(thekeyringpackage is absent — a damaged environment, since it is a base dependency) orno_backend(keyring is there, nothing answered — headless Linux, bare Windows). Reporting the second for the first read as a broken macOS Keychain on a machine whose Keychain was fine (#107).
Gating (aglaia/workers/ocr/apple_caps.py): not macOS → both Apple cards
disabled (“macOS only”); macOS pre-26 → only the Document card disabled
(“Requires macOS 26+”); macOS 26+ → both enabled. If the default card ends up
disabled, the tab falls back to the first enabled card. The Document engine
needs no Apple Intelligence.
Export
The sidebar Export tab (aglaia/gui/sidebar/tabs/ExportTab.py) shows four
built-in format cards — plus one card per installed export destination — picked
via a radio group, then one Export button dispatched by
MainWindow._on_export_clicked on the selected key:
- PDF —
make_pdf("output")→create_pdf_from_dbassembles the chosen branch terminals into one PDF. Toggles: JBIG2/G4 compression, and an optional OCR text layer (tagged with the engine, e.g._appleOCR). - Markdown —
_export_markdown→write_markdown(see markdown_export.md). Card is disabled until OCR data exists (set_markdown_available). - OCR textpack —
_export_textpack→write_textpack(see export.md): the searchable PDF, its Markdown page by page and the raw OCR response in one archive for a library. Disabled until OCR data exists, like Markdown (set_markdown_availablegates both cards). - Slim Aglaïa project —
_export_slim_project→slim_export, a pruned copy of the project DB (raw captures + chosen pages + their OCR only). - One card per destination (
refresh_destinations, keysend:<slug>): an installed export plugin is an export like any other. Its card carries an Export as picker when it accepts more than one of the formats Aglaïa writes. See destinations.md.
A card that writes a file prompts for a name with
QFileDialog.getSaveFileName (defaulting to the workspace dir + an
engine/DPI-tagged filename) and reveals the written file on success
(_reveal_in_finder). A destination card prompts for nothing: the export
goes to a private staging directory and is handed to the plugin, because the
file is a courier, not a deliverable.
Every send is logged, not just toasted. A destination export writes the
file name and size to the Log tab when the send starts, and the plugin’s own
outcome — message, URL, and whatever detail it returned — when it ends
(error level when the send failed). A toast lasts four seconds; a transfer
to someone else’s machine is worth keeping.
A PDF export can fail, and says so. With the OCR-layer toggle on,
create_pdf_from_db raises OcrLayerError and deletes the file when the text
layer cannot be written (#149); the status bar carries the message, naming the
pages that got no text. A page with no OCR run is not a failure.
Capture DPI
effective_dpi() is _dpi_base × zoom, and _dpi_base is unset until
someone calibrates — so it quietly falls back to input_dpi (100). The DPI is
per-session and distance-dependent, not a camera intrinsic: moving the rig
changes it, and nothing on screen insists.
That default is expensive and silent. At 100 dpi dpi_normalize_output still
takes every page to 300, so the output is upsampled 3× from resolution that
was never captured — large files, soft text, worse OCR — and by the time the
export looks wrong the rig has moved and the pages have to be shot again.
So prompt_dpi_if_uncalibrated() opens the DPI dialog when a camera becomes
live with no DPI set, on both paths (launched with --camera-id, and Activate
capture). Once per activation; closing it proceeds with the default, because
this is a reminder and not a gate. _deactivate_capture_clicked re-arms it —
the next camera may be at a different distance (#174).
Fixing a PDF’s input DPI
A PDF is imported by rendering each page (enqueue_pdf_files →
render_page), so the density is baked into the pixels at import. The
Fix-input-DPI table therefore cannot work by relabelling: a page rendered at
72 dpi holds 72 dpi worth of detail, and calling it 350 only changes which way
DPIfixer resamples. A 350 dpi PDF registered at 72 kept producing an export
several times the size of the source even after the number was corrected
(#173).
_on_apply now calls ImportHelpers.rerender_pdf_sources before the rerun:
for a scan whose source = 'pdf', the page is rendered again from
scans.source_ref (<file>#<page>) at the new DPI and the root image is
replaced, with the old render pruned when nothing else holds it. A capture or
an imported image is deliberately untouched — those pixels ARE the original,
and relabelling is the whole fix. A source file that has moved is named in the
Log tab and counted in a dialog, because its number changed and its pixels did
not.
Opening a project from the Finder
macOS does not pass a double-clicked document in argv; it sends a
QFileOpenEvent. _FileOpenFilter (in aglaia/app.py:_qt_app) catches it and
stashes the path in the aglaia_open_file app property, which the launcher
loop reads on each turn.
Timing is the whole problem. application:openFile: arrives after
applicationDidFinishLaunching — once an event loop is already turning — and
by then the launcher has usually shown StartupWindow, whose exec() is a
modal loop of its own. Setting a property reaches nobody inside it, and
cancelling the dialog used to return 0 and throw the path away: the user
double-clicked a project and got the picker (#168).
So the filter calls app.dismiss_launcher(), which reject()s a visible
StartupWindow. The loop treats a rejected dialog with a pending
aglaia_open_file as “go round again”, not as a quit. Three cases, one
mechanism:
| when the event lands | what happens |
|---|---|
| before the dialog | the existing processEvents() drain picks it up |
| while the dialog is up | the dialog is rejected, the loop reopens on the file |
| with a project window open | aglaia_restart="reopen", so the chain stops first |
Menu bar
MainWindow._build_menu_bar populates self.menuBar(). Qt places it natively
per platform — the global top-of-screen bar on macOS, an in-window bar on
Windows/Linux — so the same code serves all three; the QAction.MenuRole hints
(Preferences/Quit/About) only matter on macOS and are harmless no-ops elsewhere.
- File — New / Open (round-trip through the launcher via
_confirm_then_restart), Slim-down current project…, Close Project. - View — Show Downloader, Close Tab, and the Table/Grid/Gallery selector.
- Help — Documentation, Report a Bug…, About Aglaïa.
Slim-down current project (_on_slim_down_in_place) is the in-place
sibling of the slim export: it confirms (intermediate states are dropped but
regenerable since originals are kept), then closes the project and arms an
aglaia_restart="reopen" round-trip. main() runs slim_in_place on the now-
free DB file (the chain has stopped) and reopens the same path — so the view
rebuilds against the slimmed project. Both paths share
slim_export._prune_to_slim.
About Aglaïa (_open_about, also reachable from the Settings tab’s About
card) shows AboutDialog — a generated HTML page (build_about_html: version,
runtime stack, links, license) rendered in a QTextBrowser with links opened in
the system browser.