QoraDFXM — Engine-First Architecture
Layers
src/qoradfxm/
core/ ← Pure engine. ZERO Qt/GUI imports. numpy/pandas/h5py/tifffile only.
io.py h5 / image loading + TIFF export, FramePath, H5Node
transform.py intensity transforms (adaptive_log)
warp.py geometric transforms (scale / rotate / flip) via OpenCV
fitting.py ellipse least-squares (Halir–Flusser) + geometry + Sampson error
ops.py Operation (kind+params) + OP_REGISTRY (pure funcs) + apply_op
history.py History — immutable ordered op sequence, JSON (de)serialize
results.py FitResult + ResultsFrame (pandas Master table, no Qt)
profile.py ring_profile — brightness along the fitted ellipse vs. scale k
dataset.py QoraDFXMDataset — fluent immutable domain object
cli/ ← Headless front-end. Imports Core; never imports Qt.
__init__.py subcommands: fit / ring / convert / info / gui, build_parser(), main()
spec.py recipe ⇄ argv translation (`--op sub_bg:/dark`), pure
__main__.py python -m qoradfxm.cli
gui/ ← View only. Calls Core, reflects results. No pixel math.
__main__.py launcher; `--cli` routes to qoradfxm.cli (single frozen binary)
cli_bridge.py CliJob — runs `qoradfxm ...` as a QProcess, streams output
ring_panel.py 링 프로파일 tab — live I(k) plot driven by the ellipse ROI
models.py MasterTableModel (QAbstractTableModel) over Core ResultsFrame
image_view.py dumb display of a processed ndarray
document.py DocumentSession — one open image; .ds holds a QoraDFXMDataset
prefs.py ViewPrefs — per-document display state
main_window.py MainWindow, composed from the mixins below
documents.py DocumentsMixin: tabs, file loading, fitting, cursor readout
pipeline.py PipelineMixin: preprocessing chain, ROIs, the object tree
results_table.py ResultsTableMixin: Master table, shot inspector
control_dock.py ControlDockMixin: control and analysis docks, ring panel
file_dock.py FileDockMixin: file explorer, filter, context menu
ribbon.py RibbonMixin: ribbon, shortcuts, tab-bar commands
roi.py, results_window.py, settings_dialog.py, widgets.py, theme.py,
icons.py, constants.py widgets, dialogs and styling used by the aboveDependency direction: core ← cli ← gui. Nothing points back. Verified invariants: import qoradfxm.cli pulls in no PySide6; the base install has no Qt at all (Qt lives in the gui extra).
Two front-ends, one engine
| install | entry point | |
|---|---|---|
| headless / beamline / batch | uv tool install "git+https://github.com/NaNIsaac/QoraDFXM" | qoradfxm fit|ring|convert|info |
| desktop | uv tool install "qoradfxm[gui] @ git+https://github.com/NaNIsaac/QoraDFXM" | qoradfxm gui, qoradfxm-gui, python -m qoradfxm.gui |
qoradfxm gui imports Qt lazily inside the command, so a Qt-less install still parses every other subcommand and fails with an install hint only if the GUI is actually asked for.
GUI → CLI
Batch work started from the GUI is meant to go out through the same command line a user could have typed, as a child process (gui/cli_bridge.py). The plumbing is in place; no GUI action uses it yet (see Roadmap fit below):
from .cli_bridge import CliJob, argv_for_dataset
job = CliJob(self)
job.line.connect(self._log) # stdout/stderr, line by line
job.finished_ok.connect(self._on_job_done) # exit code
job.start(argv_for_dataset(doc.ds, points_file=pts, out="results.csv"))cli.spec owns both directions of the translation (parse_op / format_op), so "what the GUI runs" and "what the CLI accepts" cannot drift. A subprocess (rather than an in-process call) keeps the event loop free, makes a job cancellable, contains crashes, and yields a printable, reproducible command (command_line(argv)).
QoraDFXMDataset — fluent, immutable
Every op returns a NEW dataset sharing the (never-mutated) raw array and a grown History. The recipe is data, not code → enables Replay, Undo, and SQLite session storage.
ds = (
QoraDFXMDataset.from_h5(path, "/run/s1/det/d/data")
.sub_bg(dataset_path="/dark") # append op, return new ds
.divide(file_path="flat.tif")
.apply_log()
.fit_ellipse(points)
) # ds.fit = FitResult
img = ds.image # raw + history replayed (memoized per instance)
row = ds.to_record() # one Master-table row
d = ds.to_dict() # JSON-serializable recipe+result (→ SQLite, Phase 3)- Reference data is a location, not an array. Ops store
dataset_path(same h5) orfile_path; arrays resolve lazily at.imagetime viasource_path. KeepsHistoryserializable. bg_applied/log_scaleare derived from the history.bg_appliedreplays it first, so a correction that cannot run is never reported as applied.- A
sub_bg/dividewhose reference is missing or has another shape raisesCorrectionErrorinstead of passing the image through. The GUI keeps the previous recipe and shows the message; the CLI exits with code 2. .undo()→ dataset with the last op dropped.
Who owns which state
Every display bug so far came from the same shape: one setting living in a widget, a QSettings key, a document dict, and the recipe at once. Three tiers now, and nothing is duplicated across them:
| tier | lives in | examples |
|---|---|---|
| document, Core-owned | doc.ds.history | log, gamma, sub_bg, scale/rotate/flip |
| document, view-owned | doc.prefs (gui/prefs.py ViewPrefs) | colormap, over-max, scale bar, tool |
| app-scoped defaults | MainWindow._app_prefs → QSettings | what the next opened document starts with |
Consequences worth remembering:
- A panel widget is never a store. Handlers call
_edit_prefs(**changes), which updates the app defaults and the current document, then push the change to the view._sync_panel_from(doc.prefs)re-reads on tab change. - The Log checkbox is a view of
ds.log_scale. Because it is a cross-file preference stored per document,_make_datasetapplies the op to every newly created dataset — otherwise the box says Log and the pixels are linear (the bug that motivated this split). ViewPrefs.apply_to(view)is the only path from prefs to anImageView.
Tests
tests/ runs head-less (QT_QPA_PLATFORM=offscreen, set in conftest.py before Qt loads) against synthetic data — a Gaussian ring on a known ellipse — so nothing depends on beamline files. uv run task test.
Ground truths worth keeping: the ring peak/FWHM/perimeter versus analytic values, the arc-length weighting actually correcting the equal-angle bias, the CLI op grammar round-tripping, and the GUI regressions (log across files, arrow-key preview, per-document prefs). GUI tests wipe QSettings per window — MainWindow restores last_file, which otherwise leaks between tests.
GUI ↔ Core contract
DocumentSession.dsis the single data model per open image.- Preprocessing chain =
doc.ds.history, shown/managed in the 오브젝트 tab (⚙ 전처리group). Dark/Flat sources chosen from the 구조/파일 sidebar context menus. Log is just alogop. - On any recipe change the GUI calls
view.update_processed(doc.ds.image)— Core computes, the view only displays. - Fit:
doc.ds = doc.ds.fit_ellipse(pts); the Master row isdoc.ds.to_record(). MasterTableModelis a Qt adapter; the data lives in CoreResultsFrame.
Ring profile — I(k)
core.profile.ring_profile(img, fit, k=(0.2, 2.0, 0.01), n_theta=720, width=…) scales the fitted ellipse by k about its centre and returns the mean brightness along the contour — the intensity per unit contour length.
- Arc-length weighting. Equal steps in the parameter
tare not equal steps in arc length, so each sample is weighted byds/dt. Verified against Ramanujan's perimeter (3e-6 relative) and a synthetic Gaussian ring (peakkexact, FWHM 0.1185 vs analytic 0.1177). - Linear intensity only.
mean(log I) ≠ log(mean I), so both the CLI and the GUI measure throughQoraDFXMDataset.linear_view(), which dropslog/pure_log/sqrt/gamma(core.ops.NONLINEAR_KINDS) but keeps geometry and background correction — the coordinates still match what the user picked. - Sampling is bilinear (
cv2.remap); samples off the image become NaN and are reported asvalid_frac.width(px or k) averagesn_subsub-rings. RingProfilecarries k / mean / std / total / perimeter / valid_frac pluspeak()(parabola-refined) andfwhm(), andkeep_map=Truealso returns the unrolledI(k, θ)array.- Surfaces:
qoradfxm ring …(CSV + optional--mapTIFF, ellipse from--pointsor--from-csva Master row) and the GUI 링 프로파일 tab, which recomputes on ellipse-ROI drag through a 120 ms coalescing timer.
Master schema (source of truth = core.results.MASTER_COLUMNS)
shot_id, status, center_x, center_y, major_axis, minor_axis, angle_deg, fit_error, points_json, bg_applied, log_scale
Roadmap fit
- Phase 1 (done): viewer, preproc chain, ellipse fit, Master table, CSV.
- Phase 2 (done): shot ↔ row 1:1 (
ResultsFrame.upsert_row/index_ofkeyed by shot_id;MainWindow._shot_mapmaps shot_id → file+frame). Populate shots, row-click switches shot (_goto_shot), fit auto-updates the row in place, EXCLUDE flag (_toggle_exclude) survives re-fitting and dims the row. - Phase 3: SQLite
.qoradfxm_projautosave/restore viaQoraDFXMDataset.to_dict()/ResultsFrame; ROI-drag ↔ table live sync. Undo/Redo already supported by the immutable history. Session persistence belongs incore/(a.qoradfxm_projmust be readable headlessly) — the old emptyqoradfxm/session/stub was removed. - Next for GUI → CLI: a "batch this recipe over N shots" panel on top of
CliJob(queue, progress, cancel) — the plumbing is there, the UI is not.
Preproc ops
- intensity: sub_bg, divide, log (adaptive), pure_log, sqrt, gamma(γ), normalize
- geometric (
core.ops.GEOMETRIC_KINDS): scale (sx, sy — aspect ratio), rotate (angle, expand), flip (h/v/both)
Geometric ops resample the grid, so they change the image shape: a sub_bg after them no longer matches its reference and raises CorrectionError, and points picked before them are stale (the GUI warns). Put geometry after the dark/flat corrections and before the fit.