Skip to main content

EMPAIA App UI

Run EMPAIA analyses from xOpat: pick a slide, draw regions of interest, submit a job and read the results.

The user-facing half of the EMPAIA Workbench v3 integration. Lists the examination's slides, lets the pathologist mark regions of interest with xOpat's own annotation tools, submits them to the app as a STANDALONE job, tracks progress, and renders the results — annotations, class values, scalar primitives and pixel-map overlays. Requires the 'empaia-workbench' module and a workbench client embedding the viewer.

IDempaia-app-ui
Version0.1.0
AuthorxOpat
CategoriesAnalysis
Homepagedeveloper.empaia.org
Sourceplugins/empaia-app-ui

Keywords: empaia · workbench · ai · roi · jobs

Dependencies

Capabilities

  • empaia-app-ui.roi.create — Create regions of interest
  • empaia-app-ui.job.run — Run analyses

Additional configuration keys

resultPollMs

See include.json for details and defaults.

Documentation

EMPAIA App UI

The user-facing half of the EMPAIA Workbench v3 integration. All backend work lives in modules/empaia-workbench; this plugin is the panel. For the manual test matrix — which tutorial app exercises what, and what to look for on screen — see TESTING.md.

Two surfaces, split by how often they are used:

  • Plugins → EMPAIA Analysis (AppBar.Plugins.setMenu) — set-up: the examination, its slides, the mode, and drawing regions of interest.
  • Tools → Analyses (AppBar.Tools → a UI.DockableWindow) — the analyses themselves. It floats over the slide or docks as a MainLayout tab, because watching a run and comparing two results are done while looking at the slide. The canvas right-click and the panel's own button both open it, and a pulsing app-bar badge counts what is running.

What the setup panel does

SectionContent
StatusConnecting / not embedded / failed. Everything else stays hidden until the workbench session is up.
ExaminationApp, case and examination ids from the scope record.
SlidesEvery slide in the examination, with stain / tissue / block. Click opens it in the focused viewer.
Modestandalonepreprocessing, when the app declares both. Shows why an app cannot be driven from here (mixed single/multi inputs, nested collections, duplicate input types, no supported region type).
Regions of interestStart drawing, list what has been drawn, select what to analyse, run.
AnalysesOne line — how many exist, how many are running, how many are shown — and the way into the analyses window.

What the analyses window does

PartContent
HeaderWhich slide (and app) the list belongs to — in a grid the list is otherwise a wall of UUIDs with no anchor.
SearchFree text over job id, app name, the translated status and every error / validation message. Matched over the polled list, so it costs no requests.
FiltersAll / Running / Completed / Failed / Not started chips, each with a count. Grouped by Today / Yesterday / Earlier, newest first.
VisibilityAn eye per analysis, plus Newest only and Hide all. Right-click (or alt-click) the eye shows only that one. The header states N of M analyses shown on the slide.
RowOne line: eye, short id, relative time, runtime (or live percent), a warning icon carrying the failure text as its tooltip, and the status badge. Scanning twenty runs is reading down a column, not scrolling past twenty cards.
DetailExpand one analysis for everything else, laid out as one meta row — what it produced (as chips) on the left, run / stop / delete on the right — preceded by the progress bar and full messages when there are any, and followed only by sections that have content: the primitives table, and the channel / colour-map / inversion controls for its pixel maps. One analysis at a time.
NavigateThe annotations chip is a button while that analysis is shown: it frames its output in the viewport — the annotation itself when there is one, the union bounding box when there are several. Hidden analyses render it as a plain chip, since there would be nothing on screen to go to.

Past 200 matching rows the list stops rendering and says so — the search box is the right tool at that point, not scrolling.


Regions of interest are ordinary annotations

The plugin owns no geometry. "Draw region" does exactly two things through the annotation module's existing API:

presets.selectPreset(roiPresetId, true); // preset carries the factory + colour
annotations.setMode(annotations.Modes.CUSTOM);

The preset is bound to the factory the app's EAD asks for (rectangle → rect, polygon → polygon, circle → ellipse) and coloured from the EAD's rendering hints. Everything the annotation module already provides therefore works on ROIs for free: undo/redo, the annotation board, the canvas context menu, measurements, presets, layers, and the fixed-area mode.

Capture is a broadcast annotation-create handler filtered on the ROI preset — so it works in every viewport of a multi-viewport grid, and the viewer always comes from the event, never from window.VIEWER.

An annotation the user drew before deciding to analyse it is not lost to that filter: Use as region of interest hands it to wb.markAsRoi(), which is one changeAnnotationPreset per object — so the IO guards run, the change is undoable, and the workbench sink re-posts the record with is_roi. Shapes the EAD does not declare are refused there rather than converted: a polygon cannot become a rectangle input, and letting it through produces a job the backend rejects at input validation with nothing on screen to say why.

The steps, and which one you are on

An EMPAIA app can declare three: preprocessing (the platform runs it when the examination opens), standalone (draw a region, run), and postprocessing (the second half of the preprocessing flow — it consumes what preprocessing produced). The panel offers them in that order of usefulness, lands on the first one that can actually be started, and always states which one is active, even when there is only one. Hiding the selector for a single-mode app is what left TA12 (preprocessing + postprocessing, nothing standalone) looking like a broken panel: a "Draw region" button that silently did nothing and no indication why.

A step that cannot be started says so instead of failing later — the banner and every run button read the same runBlockers, so they cannot disagree.

Postprocessing needs to know which earlier result it is built on, and the answer is the one you are looking at. The App-UI flow diagram reads display preprocessing results → user interacts → run postprocessing, so showing a preprocessing analysis in the Analyses window is choosing it as the input. The panel names what it resolved to and links to the window when there is more than one candidate.

That is why the analyses list is not filtered by mode. One list per slide, every step, with a mode badge per row and a mode chip beside the status chips. Filtering it to the step you were about to run hid the results that step consumes, dropped every row on a switch, and stranded the visibility set on ids that no longer existed.

Results that have somewhere to go

  • Per-region values (one number per rectangle you drew) → the per-region table.
  • Shapes and classes → the slide, and named in the output chips with a count. They are never asked for as values: a collection of points has no value, and querying it produced one wasted request per output and a column of blank cells.
  • Per-annotation values (one confidence per detected nucleus) → onto the annotation, so the board row and the selection pill read tumor · confidence 0.93. The class leads, because it is what the annotation is; the number follows, because it is what one run said about it.
  • A very large result is counted, not fetched. The chip says how many and offers to load them, which beats a multi-megabyte response nobody asked for — and beats the timeout it used to become, which read as "produced nothing".

Two questions, not one

sections/region-eligibility.mjs judges every selected annotation on two independent axes, and keeping them apart is load-bearing:

  • analysable — can it be named as a job input now? Needs a server id, a shape the EAD declares, and the ROI preset. Whether an earlier analysis locked it is irrelevant: the lock is delete/update-scoped (see the module README), so a region one run consumed can be handed to the next.
  • convertible — can it be made a region of interest? Conversion is a preset change, i.e. an update, so every edit-blocking condition applies: locked, job output, or read-only all refuse here.

Collapsing the two is what made selecting a previously-analysed rectangle offer nothing and explain nothing. Job output needs no special case — it is never is_roi, so it can only reach the second question, where it is refused.

Drawing a region is not running an app

Two questions, and collapsing them is a mistake this integration has made in both directions. sections/region-eligibility.mjs holds one function each:

  • drawRefusal({ready}) — can a region exist? The session, and nothing else. With no client and no slide id the annotation could not be stored; every other condition is somebody else's question.
  • runRefusal({blockers, roiTypes, roiMode, singleOnly}) — can this app be started on one? The runBlockers() sentences, the same ones the panel banner and the run button use.

Nothing app-specific is written into a region. postAnnotations({is_roi: true}) never consults the EAD, the ROI preset id is a constant, and the module's only pre-create guard tests geometry. A region is a scope-owned annotation with a flag — it is stored, it has an id, and whether the current analysis will accept it is decided later by describeRegion and by the run button.

So the right-click entry ("Create job ROI") is always live, the panel section always renders, and roiTypeForDrawing (modules/empaia-workbench/inputs.ts) is total — the mode's declared shape, else another mode's, else a rectangle. For TA09 (a slide collection, regions nested two deep) that means regions can be drawn and stored normally; the section states the limit — "Regions are stored, but this analysis cannot take them as input." — and the refusal to run stays on the banner and the run button, where it belongs.

The failure this replaced is worth remembering: drawRefusal briefly consulted runBlockers(), so TA09's first blocker — "This app analyses several slides in one run" — was quoted on the region section, the right-click entry and the quick-mode toast. A sentence about slides, on three region-shaped surfaces. Only the quick-ROI mode still asks runRefusal, because it is not a drawing tool: it submits a job on release.

Related: when an app declares no usable region input, a selected region's verdict is roi.noRoiInput, not roi.wrongShape — "this shape is not accepted" is untrue when no shape would be.

No selected region is ever silently ignored

The rule the menu, the panel and the toasts all obey. refusalGroups collapses the ineligible ones by reason and plugin.describeRefusals turns that into one sentence, so the three surfaces cannot word the same refusal differently.

Concretely: the menu carries a dimmed "N selected regions cannot be used" row whose click explains them — in both modes, since that explanation used to sit in the else of if (multi) and so never fired for exactly the apps that collect several regions. Counts are honest about the total ("Add 2 of 3 regions to the run"), panel rows carry the reason as visible text rather than a hover title, disabled buttons carry a title, and a locked-but-usable row says so outright: "Used by analysis abc12345 — can no longer be edited, but can still be analysed again."

jobs.selectRoiFirst is reserved for a genuinely empty selection. With regions selected but none usable, the toast says what is wrong with them instead — telling someone looking at three highlighted regions to "select at least one region first" is what sent this surface back for rework.

The selection is the region set

There is no tick-box list in this panel. What the analysis acts on is what is selected on the canvas, read through getSelectedAnnotations() and mirrored into state.selection from a broadcast annotation-selection-changed.

That is not a cosmetic change. A panel-only selection was invisible to a user working on the slide, so selecting two regions, right-clicking and asking to analyse them answered "Select at least one stored region of interest first." — the panel's set was empty and nothing said so.

The right-click reads ctx.selection, which the core registry resolves once in CanvasContextMenu.collect() before any provider runs — by asking the CanvasSelectionResolvers subsystems registered (annotations registers one per viewer), never by knowing about annotations itself. Resolving it per provider cannot work: providers are asked in priority order and plugins/annotations (priority 20, above this plugin's 15) calls setActiveObject while the menu is being built without updating the selection snapshot, so a later provider reading the live selection sees a different answer depending on who ran first.

Staged runs, for apps that collect several regions

An app whose EAD declares "type": "collection" for its regions (getRoiMode() === "multiple") does not analyse one shape per job. The panel gains a Staged run section: regions accumulate into a collection bound to a job sitting in ASSEMBLY, and running it is a separate, explicit act.

  • Drawing a region while such an app is active stages it automatically once the workbench hands back an id — that gesture is not ambiguous, and making the user then find and tick it is the friction this surface exists to remove. Existing annotations are staged from the selection instead.
  • The draft is a real job, so it is also an ordinary row in the analyses window, badged staging. Its Run and Delete buttons route through the batch (runBatch / discardBatch) rather than the raw job actions.
  • Staging is append-only — the backend has no route that removes a collection item — and the section says so once instead of offering a per-row remove that has to explain itself.
  • Running locks every staged region permanently. That is a standing property of the action, so it is a warning line above the button rather than a confirm dialog: a modal on every run is trained away within a week.

Per-region results

An app that computes one value per region declares it as a collection whose items reference io.<input>.items. The expanded analysis therefore leads with a per-region table — one row per region it was given, one column per per-item output, clicking a row frames that region — and the flat value table below it carries only the run-level scalars. Before this, the per-item values arrived as nameless rows in that flat table and the app's entire result was unreadable. The zip lives in the module (outputs.ts); this plugin only labels the rows the way it labels regions everywhere else.

Result annotations produced by a job are imported through the same annotation module (via the empaia convertor, presets merged), coloured per class value. They are not listed in this panel — the annotation board is where annotations belong.

One analysis at a time

The eye on an analysis decides whether everything that run produced is on the slide — its annotations, its pixel maps and its primitives, all keyed by the same job id (empaiaJobId on an annotation, creator_id on a map or a value). By default only the newest completed analysis is shown. Results are otherwise cumulative and cannot be deleted (they belong to the job's scope), so a few runs make the slide unreadable.

Show another run to compare two side by side; Hide all clears the slide of analysis output. The user's own annotations and regions of interest are never touched. The choice is not remembered across a reload — output presence is a projection of server records, re-derived from the job list, which is also why a failed or still-running analysis never takes the current result off screen. Once the user makes a choice on a slide, the default stops re-deriving there for the rest of the session.

The plugin owns none of this: empaia-workbench holds the visible set (per slide), and the window renders it and asks the module to change it. There is no second copy to fall out of step with the canvas.

Deleting a job's output

Refused, with a message naming Tools → Analyses as the place to change what is shown. The annotations module already refuses it — job output is imported readOnly — but its message has to serve every read-only source, so this plugin registers a guard above the module's (priority 1100) purely to give the user a next move. Same refusal, better sentence. The canvas right-click on a job-produced annotation offers the window directly.

Deleting a ROI a job depends on

A pre-delete guard is registered against the annotations module's own annotation resource. Deleting a region that a non-terminal job holds as an input is refused with a translated message, because the backend would otherwise keep the job pointing at a record that no longer exists. No bespoke event protocol — this is what the IO guard registry is for.


Permissions

Two capabilities, gated with this.can(...):

idGates
empaia-app-ui.roi.createCapturing and storing a drawn region.
empaia-app-ui.job.runSubmitting an analysis.

Ids are namespaced under the plugin id because the registry drops anything that is not, and XOpatUser.can() answers true for ids it never saw — an unnamespaced id is a gate no role config can ever close.

Client-side gating only — the workbench backend is the authority (src/USER_ROLES.md).


Configuration (ENV.plugins["empaia-app-ui"])

{
"resultPollMs": 3000
}

Everything else — origins, backend URL, tile format, job polling — is configured on the module.


Requirements

  • modules/empaia-workbench (declared in modules), which must be permaLoaded so the postMessage bridge is listening before the workbench sends anything.
  • modules/annotations for ROI drawing and result rendering.
  • The viewer must be embedded by an EMPAIA Workbench client. Standalone, the panel shows a "not running inside a workbench" notice and nothing else.

The plugin deliberately does not requires any auth module: the workbench module declares an auth context and whichever broker owns it supplies the mechanism (AGENTS.md §7).