v1.4.0 — Qt-native rendering + sidebar consolidation
[1.4.0] - 2026-05-19
Major release shipping in two arcs from 1.3.1.
Arc 1: Qt-native rendering and event plumbing (pre-released as
1.4.0rc1, 2026-05-18). The video frame routes through QGraphicsView
QPixmapItem(fast_render=True; default off, DUSTrack 1.1.0
defaults on) and the annotation scatter through
QGraphicsItemGroup, leaving matplotlib responsible only for the
trace canvas. Wheel-zoom + middle-button pan/reset on the image pane;
rresets both image zoom and trace axes. Qt-native buttons
(QPushButtonin aQToolBar) and statevariables overlay (QLabel)
replace matplotlib widgets;qtpyshim makes the Qt binding
pluggable. Per-frame trace-recompute cache (_revisioncounter on
VideoAnnotation) gates the trace recomputation. Result: 3.94×
real-DUSTrack speedup (36 ms median, 27.8 fps) over 1.3.x. Full rc1
notes:
https://github.com/praneethnamburi/datanavigator/releases/tag/v1.4.0rc1.
Arc 2: Sidebar consolidation + data-layer cache invariant (this
release).
(a) The button host swaps from QToolBar to a QDockWidget +
QVBoxLayout left column, (b) state-variables are promoted from a
read-only text overlay to interactive Qt controls (dropdowns / toggles
/ labels) mounted in that same column beneath the buttons, and (c) a
focused robustness subset folded from the originally-planned rc3 band:
inner per-label annotation dicts become a dict subclass that bumps
_revision on every mutator, making the rc1-era cache invariant a
data-structure invariant rather than reviewer-side discipline. (a) +
(b) share a single goal -- one column of controls for an interactive
UI, no scattered widgets across the QMainWindow's dock areas. (c)
closes the bug class that bit check_labels_with_lk and DUSTrack's
copy_existing_annotations_from_overlay in rc1 at the data layer.
The Added / Changed / Removed / Fixed sections below describe the
Arc 2 work; refer to the v1.4.0rc1 GitHub release for the granular
Arc 1 changelog.
Added
_TrackedFrameDict-- internaldictsubclass that wraps every
per-label inner annotation dict atVideoAnnotation.data[label].
Bumpsparent._revisionon__setitem__/__delitem__/pop/
popitem/clear/update/setdefault. Turns the rc1-era
"route writes throughadd()/remove()/add_at_frame()"
discipline into a data-structure invariant: any future direct
mutation throughann.data[label][frame] = ...correctly bumps
revision without reviewer-side enforcement. Parent reference is a
weakref to avoid lifetime extension.__reduce__drops the
weakref for pickle round-tripping. The 1.4.0rc1 bypass sites
(check_labels_with_lkand DUSTrack's
copy_existing_annotations_from_overlay) were already fixed in
rc2 via the public API; this guard makes the next bypass
unrepresentable. Perf measured on 100k-frame synthetic data:
bulk-load 14 ms / 1M entries (negligible), per-frame write
sub-microsecond, 1M-entry read path 13% over bare dict -- well
under the 36 ms / frame budget oninterosseous_pn24-x.VideoAnnotation.datais now a property; setter calls
_wrap_label_dictswhich is idempotent (re-wraps only
foreign-bound or bare inner dicts). Wholesale-reassignment sites
(sort_data,clip_labels,keep_overlapping_frames,
keep_overlapping_continuous_frames,from_multiple_files) were
refactored to assemble in one shot and route through the setter --
the previous per-labelself.data[label] = {...}pattern would
have written bare dicts into the wrapped outer container.
add_labelupdated to useself.data = {**self._data, label: {}}
for the same reason._revision = 0hoisted before the first
data assignment in__init__so the guard finds it during
initial wrapping.
Added
Buttons.register_style(name, styler)+Buttons.add(..., style_tag=)
/Buttons.add_multi(specs with per-spec style_tag=)+ a new
Buttons.reapply_styles()method. Astyle_tag=declares which
registered styler should run on the freshly built button at the
tail of_finalize_button. Resolution is two-tier: consumer
registry first, then the dnav-shipped built-ins in the new
datanavigator.stylesmodule
(primary/secondary/neutral/warn); unknown tags raise
KeyErrorat add-time so typos / "forgot to register first" fail
loud. Consumers can shadow a built-in by re-registering the same
name -- DUSTrack rc2 does exactly this with its per-group
workflow / display / niche / utilities / swappalette. Replaces
the pre-rc2 pattern of consumer-side "collect buttons into lists,
run a batch styling pass at end-of-setup"; each button now lives
in one place (theaddcall) with its styling tag declared
inline.Buttons.add_multi(*specs)-- N buttons side-by-side in a single
row. Eachspecis a dict of kwargs accepted byButtons.add()
(text=,action_func=,type_=, ...); the call returns the list
of created buttons in spec order. On the Qt path a child QWidget
with a QHBoxLayout hosts the row inside the buttons column (new
_qt.make_qt_button_row(figure, specs)helper), so the row
consumes exactly one vertical slot regardless of N. On the mpl
fallback the row's width is divided evenly across N buttons at a
shared y. A new lazyButtons._mpl_row_cursorseparates "row
index used for y placement" from "button count" soadd_multi
(one row, N buttons) andadd_separator(style="double")(one call,
two slots) keep the mpl-path vertical rhythm correct; for
single-button-only flows the cursor equalslen(self)throughout,
preserving pre-rc2 placement. First consumers: DUSTrack's
"Trace: line / Trace: dot" and "Freeze plot axes / Unfreeze plot
axes" pairs (reclaims one sidebar row each).Buttons.add_separator(name=None, style="single")takes a new
stylekwarg."single"(default) is the pre-existing single
sunkenQFrame.HLine;"double"builds two stacked HLines (via
the new module-level_qt._make_qt_separator_widgethelper) for
a stronger group break.add_qt_separator(figure, style=...)
in_qt.pycarries the corresponding parameter. mpl fallback in
assets.pydoubles the invisible-button slot count so the
vertical rhythm matches._QtStatevarsWidgetappends a trailing double separator after
the last state-variable row, marking the visual end of the
statevars section in the left column. Motivated by DUSTrack
asking for an "after state variables" group boundary 2026-05-18.VideoPointAnnotator._add_default_buttons()-- overridable hook
for the post-__init__button installation (currently just the
Refresh UIaction). Subclasses with a hand-curated sidebar
order (DUSTrack) override to a no-op and add the same buttons at
the desired position. Pre-fix,Refresh UIwas added inline at
the end ofVideoPointAnnotator.__init__, locking it to slot 0
of the buttons column for every subclass.StateVariables.add(name, states, widget="label")takes a new
widgetkwarg. Allowed values:"label"(default; read-only text
line, matching pre-rc2 behavior),"dropdown"(QComboBox),
"toggle"(mutually-exclusiveQButtonGroupof checkable
QToolButton). The hint is read by the rc2 Qt sidebar; on non-Qt
backends (Agg) it's ignored and the value renders as plain text via
the legacyTextViewpath._qt._QtStatevarsWidget-- the new layout-managed sidebar widget.
Builds one row per state variable, picks control class from
StateVariable.widget, and on user interaction calls
state.set_state(value)followed byparent.update()-- the same
generic redraw every keybind handler already invokes after
state.cycle(). No new callback API is exposed; consumers stay on
theadd(...)surface.make_qt_statevars_widget(figure, statevars_container)-- mount
function for the new widget. Inserts it into the QDockWidget left
column (above anaddStretch) so any buttons added later still
stack above it.tests/qt_learning/08_rc2_statevars_widget.py-- Qt-headless smoke
exercising all threewidgetvalues plus the dropdown-pick /
toggle-click round-trip throughparent.update().TestStateVariableWidgetHintintests/test_core.py-- pure-model
pytest coverage for the new kwarg (default, each allowed value,
rejection of unknown values, propagation through
StateVariables.add, and the AggTextViewfallback).VideoAnnotation.keep_overlapping_frames()-- sibling of
keep_overlapping_continuous_frames()(thealt+qaction) without
the consecutive-runs constraint: drops frames where any label is
missing, but preserves isolated fully-labeled frames. Motivated by
DUSTrack's "Train DLC model" pre-flight, which needs a way to drop
partial-label frames before training -- DLC tolerates per-bodypart
NaN in its CSV but partial frames degrade the trained model in
practice. Not wired to a keybinding; DUSTrack is the only caller.VideoPointAnnotator.keep_overlapping_frames()-- wrapper that
calls the annotation method and triggersself.update().test_video_annotation_keep_overlapping_frames,
test_video_annotation_keep_overlapping_frames_keeps_non_continuous,
test_video_annotation_keep_overlapping_frames_no_overlap_aborts,
andtest_video_point_annotator_keep_overlapping_framesin
tests/test_pointtracking.py. The "keeps_non_continuous" case is
the load-bearing one -- distinguishes the new method from the
continuous variant.AssetContainer.remove(name)-- counterpart toadd(). Pops and
returns the asset whose.namematches; raisesKeyErrorif no
asset carries that name. The container only manages membership;
the caller is responsible for tearing down any plot handles /
Qt widgets the popped asset owns. Inherited unchanged by every
AssetContainer subclass (Buttons,Selectors,MemorySlots,
StateVariables,VideoAnnotations). Motivated by DUSTrack
1.1.0rc2's new "Remove layer" UI affordance which needs a way
to drop aVideoAnnotationfrom a live session without
restarting the annotator.VideoAnnotation.reload()-- inverse ofsave(). Wholesale-
replacesself.datawith the result ofload()(so the property
setter rewraps each per-label inner dict as_TrackedFrameDict),
then bumps_revisionexplicitly so per-frame caches keyed on
(label_list, _revision)invalidate. IffnameisNoneor
doesn't exist,load()'s empty-fallback branch returns
{str(i): {} for i in range(n)}-- so "reload from disk if a
file exists, otherwise reset to empty" is one method call. Mirrors
the explicit-bump pattern already used bysort_data,
sort_labels,clip_trailing_empty_labels,remove_empty_labels.
Drives the DUSTrack 1.1.0rc2 "Discard unsaved annotations" button.VideoPointAnnotator.remove_annotation_layer(name)-- removes an
annotation layer from a live session. Tears down the layer's
scatter + trace artists viaVideoAnnotation.clear_display(),
drops it fromself.annotationsviaAssetContainer.remove, then
resyncs theannotation_layer/annotation_overlaystate-
variables (rotation lists + current selections) through the new
_refresh_annotation_state_listshelper. Active-layer handoff:
if the removed layer was the primary, the previous-in-rotation
layer is auto-selected (falling through to the first surviving
layer if the removed one was at index 0); if it was the overlay,
the overlay clears toNone. RaisesValueErrorif it would
leave the container empty -- consumers wanting a "reset contents"
semantic should useVideoAnnotation.reload()on the surviving
layer instead. Treats every named entry the same at the dnav
layer; the"buffer"exclusion is a DUSTrack-side UI concern.
Drives the DUSTrack 1.1.0rc2 "Remove layer" button.VideoPointAnnotator._refresh_annotation_state_lists()-- helper
extracted from the inline statevar-rotation refresh that lived at
the tail ofadd_annotation_layers. Single source of truth for
theannotation_layer/annotation_overlayrotation resync,
shared byadd_annotation_layers(extending the rotation) and
the newremove_annotation_layer(shrinking it). Also clamps
each statevariable's_current_state_idxso the position is
never out-of-bounds after a shrink, providing a last-resort
safety net for the caller's "pick a new selection" decision.test_video_annotation_reload_with_file_on_disk,
test_video_annotation_reload_without_file,
test_asset_container_remove_happy_path_and_keyerror,
test_remove_annotation_layer_swaps_active_and_clears_overlay,
test_remove_annotation_layer_refuses_only_layer, and
test_remove_annotation_layer_preserves_overlay_when_unrelated
intests/test_pointtracking.py. Cover the newreload/
remove_annotation_layer/AssetContainer.removesurfaces.VideoAnnotations.reorder(names)-- permute the underlying
AssetContainer._listso layer names follownames(which must be
a permutation of the currentself.names). Idempotent whennames
already matches current order; raisesValueErrorotherwise. Sits
on the pluralVideoAnnotationsrather than the base
AssetContainerto limit blast radius to the annotation-layer use
case it was built for. Membership-only -- callers that own the
rotation of anannotation_layer/annotation_overlay
state-variable are responsible for resyncing via
_refresh_annotation_state_listsafter a reorder. Drives the
DUSTrack 1.1.0rc2 layer-regrouping pass.VideoPointAnnotator.refresh()+VideoAnnotation.invalidate_caches()F5keybinding (and inherited "Refresh UI" button) -- escape
hatch for the rare case of command-line direct mutations of.data
that bypass the publicadd()/remove()/add_at_frame()API
and therefore skip the_revisionbump the trace-display and
frame-marker caches key on.invalidate_caches()nulls
_trace_display_cache_key;refresh()calls it on every annotation
layer, nulls_frame_marker_cache, thenupdate()s. The
_TrackedFrameDictguard above makes a direct
ann.data[label][frame] = ...correctly bump revision, but
refresh()remains the insurance for any future bump miss in code
paths not yet thought through.
- Grouped Qt keybindings cheatsheet.
show_key_bindingsnow opens
a modelessQDialogwith sections (Annotation / Frame navigation /
Layer / label / LK / interpolate / View / File / Other) instead of
the pre-rc2 matplotlibTextViewpopup.add_key_bindinggains
group=andon_button=kwargs;on_button=Trueappends
" (key)"to the matching button's label via identity-matched
lookup againstButtons._action_funcs, so buttons advertise their
own shortcut.pointtracking.set_key_bindingsdeclares groups for
every binding mapping to the 5-step DUSTrack workflow (Select
annotation layer → Select annotation number → Navigate to frame →
Edit annotation → LK augmentation / refine).
GenericBrowser.set_default_keybindingsfollows suit. Stdout
fallback when no Qt window is available. Coverage:
tests/test_key_bindings.py(new module),
tests/qt_learning/21_dustrack_keybindings_smoke.py.
Changed
_qt._get_buttons_widgetreplaces the pre-rc2_get_buttons_toolbar.
Buttons now stack in aQVBoxLayoutinside aQDockWidgeton
LeftDockWidgetArea(pre-rc2:QToolBaronLeftToolBarArea). The
QDockWidgetis borderless and non-floatable. Cached attribute on
theQMainWindowis_dnav_left_column(the rc2 left-column
struct); the legacy_dnav_buttons_widgetattribute survives as an
alias pointing at the buttons sub-widget.Buttons.add_separator()on the Qt path now inserts a sunken
QFrame.HLineinto the buttonsQVBoxLayout(pre-rc2:
QToolBar.addSeparator()QAction). Visual is equivalent;
separator detection in tests now walks layout items.VideoAnnotation.set_plot_type(type_)now records the choice on
self._plot_typein addition to applying the visual style; the
plot_typeproperty setter becomes a thin delegate so the two
APIs are symmetric. Pre-fix,set_plot_type("line")only
updated trace handle linestyle/marker, leaving_plot_typeat
its__init__default"dot"; the nextre_setup_display
(which fires on every existing layer inside
add_annotation_layers) calledsetup_display→set_plot_type (self.plot_type), read the stale"dot", and reverted the
visual. Manifested as the DUSTrackdlccorr"renders as dots
after Reduce jitter" regression:apply_manual_correctionsset
dlccorr to line via the method (visual only); adding the LK
output layer triggeredre_setup_displayon every layer,
including dlccorr, which then reverted to dot. Two new tests in
tests/test_pointtracking.pyguard the sync and the
re-setup-survival.VideoPointAnnotatornon-fast_render path: the matplotlib gridspec
drops its dedicated state-variables column. Layout shrinks from
3x2 width_ratios=[1, 4]to3x1; the image axis is now
full-width. State-variables move from the in-figure mpl axis into
the QDockWidget left column. The_ax_statevarattribute is still
set (toNone) for backwards compatibility but is unused._QtStatevarsWidget._build_rowswitched fromQHBoxLayout
(name | control) toQVBoxLayout(name above control). Side-by-side
starved the combo of width so long state values
(e.g.dlc_iteration-3_250000from a DUSTrack DLC project) elided
todlc_...250000; stacking gives the combo the full column width
and the dropdown is set toAdjustToContentsso it grows to fit
the widest entry. Rows are now visually grouped with a sunken
QFrame.HLineseparator between adjacent state variables.
Reported during DUSTrack 1.1.0rc2 testing._QtStatevarsWidgetno longer renders the bold "State variables:"
title row; the trailing double separator + group rule already
delimit the section. The widget now also paints itself with a
slightly darker background (palettebase.darker(120), theme-
adaptive) so the statevars area reads as a visually distinct
group from the buttons column above it. Requested during DUSTrack
1.1.0rc2 sidebar polish.- Left-column dock host gets
setMinimumWidth(_LEFT_COLUMN_MIN_WIDTH=300)
(a touch above the pre-rc2sidebar_width=280default; tuned
empirically during DUSTrack 1.1.0rc2 testing). Combined with
_QtStatevarsWidgetswitching its horizontal size policy from
Fixed→Preferred, the statevars widget now fills the column
instead of sticking to its own sizeHint; each combo (already
Expandinghorizontal) therefore reaches the column edge even when
its current state value is short (e.g.select/place). Reported
during the same DUSTrack 1.1.0rc2 testing pass that motivated the
stacked-layout fix above. StateVariables.show()tries the new Qt widget path first and falls
back toTextViewon non-Qt backends. Thepos/faxarguments
are only consulted on the fallback (posis ignored on the Qt
path; the widget's position is dock-managed).make_image_paneno longer creates the fast_renderpane.sidebar
QLabel. That QLabel was the fast_render-Tier-2 statevariables text
sink; rc2's left column subsumes that role for both Tier 1 and Tier
2. Thesidebar_widthkwarg is kept on the signature for API
stability but is now ignored. Fast_rendermake_image_panenow
returns a pane with just[image_pane, trace_canvas].rkeybinding is now cursor-aware in fast_render (Tier 2).
VideoPointAnnotator._reset_view_alldispatches onevent.inaxes
(set by_patch_event_for_image_panein__call__): cursor over
the Tier 2 image pane resets the image zoom/pan only; cursor over
_ax_trace_x/_ax_trace_yresets the trace pair with x pinned
to(0, ann.n_frames)(the full video range) and y autoscaled to
data; cursor elsewhere orevent is Nonefalls back to the same
trace treatment plus the image-pane reset, preserving muscle memory
forrhit while hovering a button or off-figure. Pre-fix, a user
who panned the trace x-axis to inspect a feature then hitrto
refit the trace y also lost their image-pane zoom (and vice versa).
Pinning x to the full video range — rather than autoscaling to the
current annotation extent — keeps frames outside the annotation
envelope visible, which is the usual case when extending
annotations to a new region. Dissolves the Tier 2 image-zoom /
trace-axes coupling the 2026-05-19 trace-scaling audit flagged as a
follow-up. Tier 1 is unaffected (no image pane to scope). Binding
description is now"Reset view under cursor (traces use full-video x)". Regression test:test_r_keybinding_cursor_aware_dispatchin
tests/test_pointtracking.pycovers all three dispatch branches.alt+ris the autoscale-to-data-extent sibling ofr(Tier 2).
VideoPointAnnotator._reset_view_to_data_extentmirrors
_reset_view_all's dispatch structure, but the trace branch and
fallback autoscale x and y to the data extent (reset_axes(axis= "both", axes=[trace_x, trace_y])) instead of pinning x to the full
video. The image-pane branch is identical tor. Use when the
annotated region is much narrower than the full video and you want
to zoom in on it. Regression test:
test_alt_r_keybinding_cursor_aware_data_extent_dispatch.GenericBrowser.reset_axestakes a new optional keywordaxes=
that restricts the walk to a given subset (defaultNonewalks
self.figure.axesas before). Added to support the cursor-aware
r/alt+rdispatch above — the trace branches pass
[_ax_trace_x, _ax_trace_y]to scope the refit to the trace pair.
Regression test:test_reset_axes_axes_scope_kwargin
tests/test_core.py.- Label-aware y-refit for multi-label tracking. New
VideoPointAnnotator._fit_y_to_active_labelhelper computes the
y-extent from the active layer's active-label trace (and the overlay
layer's same-named label, if an overlay is set and contains the
label) with a 5% margin, thenset_ylimon both trace axes. Wired
toannotation_labelandlabel_rangestatevariable changes via a
newStateVariable.add_on_change(callback)callback list, so both
keyboard (digit keys,'/;,q/w,
select_label_with_mouse) and Qt-dropdown driven label switches
refit;VideoPointAnnotator._on_active_label_changede-dupes via
_last_active_labelso the increment_label_range double-fire
(cycle()thenset_state()) is a single fit. Layer flips
(primary or overlay) intentionally do NOT trigger a refit -- the
layer-flip comparison workflow keeps its current y window. Ther
trace branch now calls the helper directly (was
reset_axes(axis="y", ...)), so pressingrover a trace gives a
comfortable view of the active label rather than compressing it into
the union of every label's data extent;alt+rretains the union
autoscale (sibling, explicitly documented). Single-label sessions
see no behavior change -- the helper walks exactly one label.
Binding descriptions tightened:r->"Reset view under cursor (traces: full-video x, active label y)",alt+r->"Reset view under cursor (traces: data-extent x, all-labels y)". Regression
tests intests/test_pointtracking.py:
test_fit_y_to_active_label_active_only,
test_fit_y_to_active_label_with_overlay,
test_fit_y_to_active_label_empty_is_noop,
test_label_switch_triggers_yfit,
test_layer_switch_does_not_trigger_yfit,
test_label_range_switch_triggers_yfit,
test_r_keybinding_trace_branch_fits_active_label_only,
test_alt_r_keybinding_trace_branch_keeps_union.
Removed
_qt.make_sidebar_text_sink/_qt._QtSidebarTextSink(fast_render
Tier 2 statevariables text sink). Use the rc2 widget path via
StateVariables.show()-- it covers Tier 1 and Tier 2 in one mount
function.
Fixed
- Trace-pane scaling audit (Bug B: x-axis preserved across mid-session
layer adds).VideoAnnotation.setup_display_traceno longer
unconditionally callsax_x.set_xlim(0, self.n_frames). The call is
now guarded withax_x.get_autoscalex_on(), so the first
annotation constructed on the axes claims xlim (flipping autoscale
off as a side effect) and every subsequent layer-add is a no-op.
Pre-fix, each in-session_adopt_layercall in DUSTrack (Reduce
jitter / post-train DLC refresh / first-time Apply manual
corrections) blew away any user pan/zoom on the trace time-axis,
forcing reliance on DUSTrack'sFreeze plot axesworkaround. Press
rto refit. Regression tests:test_setup_display_trace_xlim_guardtest_video_point_annotator_xlim_preserved_across_layer_add.
- Trace-pane scaling audit (Bug A: Manual y-policy).
VideoPointAnnotator.update_frame_marker's cache-miss branch no
longer unconditionally re-appliesset_ylim(nanlim(trace_data))on
every annotation mutation, label switch, or frame-of-interest
toggle. The twoset_ylimcalls are guarded with
get_autoscaley_on(), so the first cache miss with real data fits
y (claiming autoscale) and subsequent mutations leave the user's
view alone. Pressrto refit. Pre-fix, everyadd/remove/
LK interpolate / copy-from-overlay / label change re-fitted y,
forcing reliance on DUSTrack'sFreeze plot axesworkaround. The
all-NaN case is now also a true no-op (pre-fix it silently
consumed the autoscale claim by setting ylim to itself). Regression
test:test_video_point_annotator_ylim_manual_policy(covers
first-fit / mutation-preserves / label-switch-preserves /
FOI-preserves / r-restores-refit). reset_axespolish (1.3.1 audit punchlist).
GenericBrowser.reset_axesswaps the deprecated
isinstance(ax, maxes.SubplotBase)filter for
ax.get_subplotspec() is not None(mpl 3.7+ deprecation, slated for
removal). The method now also folds each axis'sCollection
datalims intoax.dataLimbefore callingautoscale(), so
fill_betweenartists -- e.g. DUSTrackdisplay_type="fill"
events -- contribute to the autoscale extent. mpl'sAxes.relim()
walks Lines + Patches + Images but not Collections, so pressing
rpreviously left fill artists outside the refit ylim.
Regression tests:test_reset_axes_includes_fill_artists+
test_reset_axes_skips_non_subplot_axes.- Phase 2 smoke (
tests/qt_learning/04_phase2_smoke.py) now pins the
local source onsys.path[0]matching the pattern in 07. Pre-rc2 a
script-mode run would silently import the env's installed
datanavigator (which on older envs lacksTextView._overlay),
producing anAttributeErrorunrelated to the test's intent. fast_renderwindow resize re-fits the image.
_QtImagePane.resizeEventpre-rc2 gated the auto-re-fit on
transform().isIdentity(), but the initial_fit_view()leaves
a non-identity scale transform, so the check was False on every
subsequent resize and the image never re-fit. New
_PanZoomGraphicsView.user_adjustedflag is set True on wheel-zoom
and on real pan drags (cleared byreset_view());resizeEvent
now re-fits when the flag is False, preserving the original intent
("don't clobber a manual zoom") without the false negative on
auto-fit. Initial QMainWindow aspect ratio also tuned:
make_image_paneresizes the host window aftersetCentralWidget
so total height =canvas_h * 3, matching the layout's 2:1 stretch
hint and giving the image pane ~2x the canvas height (~600 px for
DUSTrack). Regression test:
test_tier2_resize_refits_until_user_adjusts.zselector listens on both trace axes. The interval-picker
Selectorhadax_list=[self._ax_trace_x], so pressingzwhile
hovering the y-trace was a no-op. Now
[self._ax_trace_x, self._ax_trace_y]-- both trace panes accept
interval bracketing for LK-RSTC.GenericBrowser.copy_to_clipboard(Ctrl+C) now grabs the entire
QMainWindowviaQWidget.grab()instead offigure.savefig-ing
the matplotlib canvas alone. On a Qt backend the clipboard image
now includes every Qt-side widget parented to the window --
left-column dock (buttons + state-variables), fast_render image
pane, and any downstream sidebars (DUSTrack). Pre-fix, a DUSTrack
window pasted into a doc as a thin trace strip with the entire
sidebar / image pane missing. mpl/Agg fallback retained via
find_qt_window(...) is None.- Labels promoted to first-class schema. Three coordinated
changes:
(1)VideoAnnotation.saveno longer callsremove_empty_labels()
on the way out. Empty-but-declared labels round-trip through JSON
as"label": {}instead of being silently pruned. The method
stays available for callers that explicitly want a lean export
(DUSTrack pre-flight before DLC training calls it directly).
(2)VideoAnnotation.to_trace(label)is now schema-tolerant: a
label missing fromself.datareturns the full NaN array
(matching the docstring's "unannotated frames are NaN" promise,
treated as every-frame-unannotated). Letsupdate_frame_marker
iterate every layer with one shared active label even when a
layer doesn't carry that label.
(3) The default-label bootstrap shrank from 10 placeholders to
1, since the no-prune save would otherwise write a slate of
empties on every fresh save.VideoAnnotation(n_labels=N)and
VideoPointAnnotator(..., n_labels=N)still let callers declare a
larger schema up front.
Net fix: DUSTrack'sapply_manual_correctionsno longer crashes
withAssertionError: label in self.labelswhen the user has
manually corrected only one of two labels (the patch overlay's
empty label was getting save-pruned, the corrections layer was
then missing that label, andupdate_frame_markerblew up
hstacking traces across layers). Tests:
test_video_annotation_save_preserves_empty_labels,
test_to_trace_returns_nan_for_missing_label,
test_corrections_layer_shaped_update_frame_marker,
test_add_annotation_layers_unions_declared_labels,
test_video_annotation_default_n_labels_is_one. VideoPointAnnotator._ensure_target_has_labels(target, labels)
static helper. Used by the three LK interpolate paths
(interpolate_with_lk,interpolate_with_lk_norstc,
check_labels_with_lk) to declare missing labels on the target
layer before bulkaddcalls. Pre-rc2 the 10-default-empty
bootstrap made cross-layer label-set parity implicit; with
first-class labels the LK callers declare what they need.add_annotation_layerssynthesis takes the union of every
layer's declared labels (was: labels-with-data only), so
empty-but-declared labels flow back into the layer set on
reload and a layer missing a peer's declared label gets it
added.