Persist drawn ROIs into the layer's plot config - #1238
Merged
Conversation
User-drawn ROIs lived only in the plotter, so every rebuild of it -- dashboard restart, layer removed and re-added, layer reconfigured, or a new job generation -- reverted to the config-time set and republished it over what the user had drawn. Each accepted edit now writes the ROI set back into the layer's stored params, which the orchestrator persists. Making that round trip lossless required settling what the bare numbers in the coordinates field mean. They were read as pixel indices, while edit-derived ROIs carry the unit of the readback's x/y coordinates, so writing a drawn ROI back and reading it again would silently reinterpret metres as pixels. They are now read in the plot's axis units throughout, which is also what a user typing in coordinates read off the axes expects; views with logical (unitless) coordinates are unaffected. As a result the params can only be turned into ROIs once compute() has seen the units, so the initial set is seeded there rather than in __init__. Fixes #1090
The axis-units convention was stated at each point of use but its reasoning lived only in the commit message, leaving the next reader to wonder why the unit is re-derived on every compute() instead of being persisted with the coordinates it belongs to.
Persisting an ROI edit rewrites the whole dashboard layout on the loop that serves every session. That was affordable while only rare topology mutations reached it; a drawn ROI reaches it once per mouse gesture, at 44 ms for ten grids of five layers and 88 ms for ten of ten. Coalesce mutations into one write per debounce window, and emit with libyaml where the wheel provides it -- the pure-Python emitter was over 99% of the cost, and its output is byte-identical. Together those take the same layouts to 12 ms and 24 ms, at most once per second. Store coordinates to six significant digits. A coordinate is a cursor pixel mapped onto the axis, so the digits past the first few are float noise, and the stored string is also the field the user reads and edits. Rounding the stored value rather than the display keeps the field honest and makes the round trip a fixed point. Significant digits rather than decimal places, because the axes range from pixel indices to wavelength in m. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PlotOrchestrator.shutdown() was never reached in production: DashboardServices.stop() stopped the update thread and the transport and left the orchestrator alone, so its flush ran only in tests. With writes now debounced, that dropped whatever a mutation left pending on every restart, not only on a crash -- the debounce window was the amount of layout change lost per deploy. Ordered after the update thread is joined, so nothing drives the plot model while it is torn down, and before the transport, which teardown does not need. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drawn ROIs lived only in the browser's edit tool. A presenter sends its pipe once, in __init__, so the element the server holds stayed at the set the presenter was constructed with. Any re-render -- switching back to a grid, a cell rebuilt on a job state change -- rebuilds the edit tool from that stale element, and the browser syncs the difference back as an edit. The drawn ROIs were dropped, and since the same handler rewrites params, the loss was persisted and reappeared on the next workflow generation. The edit handler now returns the accepted set and the presenter renders it, so the element cannot disagree with the plotter's live ROIs. The echo edit that pushing it may provoke is absorbed by the existing unchanged-edit guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3 tasks
SimonHeybrock
marked this pull request as ready for review
August 21, 2026 06:48
Member
Author
|
LGTM; color selection seems broken, but that was pre-existing |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1090.
What changes for the user
Drawn ROIs stay drawn. Until now they lived only in the running plotter — and, on screen, only in the browser's edit tool — so anything that rebuilt either threw them away and republished the configured set over the top. Every route is fixed:
The coordinates field tells the truth. It is rewritten as you draw, so opening "Reconfigure layer" shows the ROIs that are actually on the plot instead of a stale string from when the layer was created. It is labelled "Coordinates" rather than "Initial Coordinates" to match.
Coordinates are stored to six significant digits. A coordinate is a cursor pixel mapped onto the axis, so past the first few digits it is float noise — and since the stored string is also the field the user reads and edits, that noise was on screen. Rounding is applied to the stored value, not just the display: the field is editable, so a display that disagreed with the stored value would silently rewrite it on the next edit, and the round trip is now a fixed point, so a rebuild cannot perturb geometry. Significant digits rather than decimal places, because axes here run from pixel indices through Q in 1/Å to wavelength in m.
Typed-in coordinates are read off the axes. On a view with physical coordinates the field was interpreted as pixel indices, so typing
[0,0,10,10]drew a box spanning 0–10 m on screen while telling the backend "pixels 0–10". Coordinates are now taken in the units the plot's axes show, which is what you get by reading numbers off the plot. Views with logical (unitless) coordinates are unaffected, and no in-repo instrument config seeds a request plotter's coordinates — but a layer saved on a production dashboard with typed-in coordinates on a geometric view will move to where the numbers actually pointed.Implementation
Which of the two designs
The issue offered persist edits and seed from readback. Seeding from the readback turns out to be worse than it looks: ROI requests are addressed to
JobId(source_name, job_number), so a new job's readback starts empty, andsync_job_statesrebuilds the plotter on exactly that event. A readback-seeded plotter would therefore adopt the empty set and drop every drawn ROI on each workflow restart — trading a restart-scoped clobber for a per-run one. So: persist edits, dashboard stays authoritative.Each accepted edit builds updated params and hands them to a persister injected by
PlotOrchestrator, which stores them on the layer and writes the config store. It deliberately does not bump the topology version: the plotter and its presenters are the source of the change and must survive it.Why the stored geometry carries no unit
IntervalandPolygonROIread a missing unit as pixel indices, while edit-derived ROIs carry the unit of the readback'sx/ycoordinates, and geometric projections (xy_plane,cylinder_mantle) really do produce metres. Writing a drawn ROI back as bare numbers and reading it again would silently reinterpret metres as pixels, so the round trip forced the question.The unit is now stamped on at parse time from the data and never stored: it belongs to the view a layer is bound to, so persisting it would create a value that goes stale as soon as the layer is repointed, plus rules to resolve the disagreement. A re-derived unit cannot disagree. The rationale is recorded in the
roi_request_plotsmodule docstring.A consequence is that params can only be turned into ROIs once
compute()has seen the units, so the initial set is seeded there rather than in__init__. Presenters are only ever created aftercompute()has produced cached state, so nothing observes the empty interval.Keeping what is rendered and what is live in agreement
Drawn ROIs lived only in the browser's edit tool: a presenter sends its pipe once, in
__init__, so the element the server holds stayed at the set that presenter was constructed with. Any re-render — switching back to a grid, a cell rebuilt on a job state change — rebuilds the edit tool from that stale element, and the browser syncs the difference back as an edit. That silently dropped ROIs before this PR; with the geometry persisted, the loss would have been written to disk and reapplied to the next workflow generation. The edit handler now returns the accepted set for the presenter to render, so the element cannot disagree with the plotter's live ROIs. The echo edit that pushing it may provoke is absorbed by the existing unchanged-edit guard.Making the layout cheap enough to write per gesture
Persisting an edit rewrites the whole dashboard layout — every grid, cell and layer — and it happens on the loop that serves every session. That was fine while
_persist_to_storewas reached only by rare topology mutations; a drawn ROI reaches it once per mouse gesture, so it needed to get cheaper before this PR could land. Measured on a synthetic layout of image layers, per write:Two changes get there. The store now emits with libyaml's
CSafeDumperwhere the wheel provides it — the pure-Python emitter was over 99% of the cost, and the output is byte-identical, so a file written by either emitter is readable and diffable against the other. And_persist_to_storeno longer writes: it marks the layout dirty and a debounced flush writes once per second, so a burst of mutations costs one write instead of one each.shutdownflushes before it tears the model down, and it is now reached in production:DashboardServices.stopstopped the update thread and the transport but left the orchestrator alone, so that flush ran only in tests and every restart dropped whatever the debounce still held. With no loop running (tests, screenshot runs) the write stays inline.Net effect for the case that motivated this: dragging ROIs around now costs at most one ~12-24 ms write per second, instead of a ~44-88 ms write per gesture.
Known rough edge, not addressed here
The geometry is still stored as a string of bare numbers, because the params form builder renders no lists and no nested models —
RectanglesCoordinatesis documented as a wrapper existing "to get full-width card". The domain typeRectangleROIalready exists and already carries units; holding it directly would delete bothparse()implementations, both_parse_initial_geometry, both_format_geometryand both validators. That needs the widget builder extended first, and a deliberate migration: invalid persisted params currently revert a layer's entire params to defaults with only a warning log.Test plan
shutdownwrites the pending layout, and with no running loop the write is immediate; the file a store writes matches what the reference emitter would write.stopshuts the orchestrator down after the update thread is joined and before the transport stops.