Skip to content

v1.4.0 — Qt-native rendering + sidebar consolidation

Choose a tag to compare

@praneethnamburi praneethnamburi released this 20 May 01:15
· 18 commits to master since this release

[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;
    r resets both image zoom and trace axes. Qt-native buttons
    (QPushButton in a QToolBar) and statevariables overlay (QLabel)
    replace matplotlib widgets; qtpy shim makes the Qt binding
    pluggable. Per-frame trace-recompute cache (_revision counter 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 -- internal dict subclass that wraps every
    per-label inner annotation dict at VideoAnnotation.data[label].
    Bumps parent._revision on __setitem__ / __delitem__ / pop /
    popitem / clear / update / setdefault. Turns the rc1-era
    "route writes through add() / remove() / add_at_frame()"
    discipline into a data-structure invariant: any future direct
    mutation through ann.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_lk and 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 on interosseous_pn24-x.
  • VideoAnnotation.data is now a property; setter calls
    _wrap_label_dicts which 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-label self.data[label] = {...} pattern would
    have written bare dicts into the wrapped outer container.
    add_label updated to use self.data = {**self._data, label: {}}
    for the same reason. _revision = 0 hoisted 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. A style_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.styles module
    (primary / secondary / neutral / warn); unknown tags raise
    KeyError at 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 / swap palette. 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 (the add call) with its styling tag declared
    inline.
  • Buttons.add_multi(*specs) -- N buttons side-by-side in a single
    row. Each spec is a dict of kwargs accepted by Buttons.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 lazy Buttons._mpl_row_cursor separates "row
    index used for y placement" from "button count" so add_multi
    (one row, N buttons) and add_separator(style="double") (one call,
    two slots) keep the mpl-path vertical rhythm correct; for
    single-button-only flows the cursor equals len(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
    style kwarg. "single" (default) is the pre-existing single
    sunken QFrame.HLine; "double" builds two stacked HLines (via
    the new module-level _qt._make_qt_separator_widget helper) for
    a stronger group break. add_qt_separator(figure, style=...)
    in _qt.py carries the corresponding parameter. mpl fallback in
    assets.py doubles the invisible-button slot count so the
    vertical rhythm matches.
  • _QtStatevarsWidget appends 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 UI action). 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 UI was added inline at
    the end of VideoPointAnnotator.__init__, locking it to slot 0
    of the buttons column for every subclass.
  • StateVariables.add(name, states, widget="label") takes a new
    widget kwarg. Allowed values: "label" (default; read-only text
    line, matching pre-rc2 behavior), "dropdown" (QComboBox),
    "toggle" (mutually-exclusive QButtonGroup of 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 legacy TextView path.
  • _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 by parent.update() -- the same
    generic redraw every keybind handler already invokes after
    state.cycle(). No new callback API is exposed; consumers stay on
    the add(...) surface.
  • make_qt_statevars_widget(figure, statevars_container) -- mount
    function for the new widget. Inserts it into the QDockWidget left
    column (above an addStretch) so any buttons added later still
    stack above it.
  • tests/qt_learning/08_rc2_statevars_widget.py -- Qt-headless smoke
    exercising all three widget values plus the dropdown-pick /
    toggle-click round-trip through parent.update().
  • TestStateVariableWidgetHint in tests/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 Agg TextView fallback).
  • VideoAnnotation.keep_overlapping_frames() -- sibling of
    keep_overlapping_continuous_frames() (the alt+q action) 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 triggers self.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,
    and test_video_point_annotator_keep_overlapping_frames in
    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 to add(). Pops and
    returns the asset whose .name matches; raises KeyError if 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 a VideoAnnotation from a live session without
    restarting the annotator.
  • VideoAnnotation.reload() -- inverse of save(). Wholesale-
    replaces self.data with the result of load() (so the property
    setter rewraps each per-label inner dict as _TrackedFrameDict),
    then bumps _revision explicitly so per-frame caches keyed on
    (label_list, _revision) invalidate. If fname is None or
    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 by sort_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 via VideoAnnotation.clear_display(),
    drops it from self.annotations via AssetContainer.remove, then
    resyncs the annotation_layer / annotation_overlay state-
    variables (rotation lists + current selections) through the new
    _refresh_annotation_state_lists helper. 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 to None. Raises ValueError if it would
    leave the container empty -- consumers wanting a "reset contents"
    semantic should use VideoAnnotation.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 of add_annotation_layers. Single source of truth for
    the annotation_layer / annotation_overlay rotation resync,
    shared by add_annotation_layers (extending the rotation) and
    the new remove_annotation_layer (shrinking it). Also clamps
    each statevariable's _current_state_idx so 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
    in tests/test_pointtracking.py. Cover the new reload /
    remove_annotation_layer / AssetContainer.remove surfaces.
  • VideoAnnotations.reorder(names) -- permute the underlying
    AssetContainer._list so layer names follow names (which must be
    a permutation of the current self.names). Idempotent when names
    already matches current order; raises ValueError otherwise. Sits
    on the plural VideoAnnotations rather than the base
    AssetContainer to limit blast radius to the annotation-layer use
    case it was built for. Membership-only -- callers that own the
    rotation of an annotation_layer / annotation_overlay
    state-variable are responsible for resyncing via
    _refresh_annotation_state_lists after a reorder. Drives the
    DUSTrack 1.1.0rc2 layer-regrouping pass.
  • VideoPointAnnotator.refresh() + VideoAnnotation.invalidate_caches()
    • F5 keybinding (and inherited "Refresh UI" button) -- escape
      hatch for the rare case of command-line direct mutations of .data
      that bypass the public add() / remove() / add_at_frame() API
      and therefore skip the _revision bump 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, then update()s. The
      _TrackedFrameDict guard 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_bindings now opens
    a modeless QDialog with sections (Annotation / Frame navigation /
    Layer / label / LK / interpolate / View / File / Other) instead of
    the pre-rc2 matplotlib TextView popup. add_key_binding gains
    group= and on_button= kwargs; on_button=True appends
    " (key)" to the matching button's label via identity-matched
    lookup against Buttons._action_funcs, so buttons advertise their
    own shortcut. pointtracking.set_key_bindings declares 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_keybindings follows 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_widget replaces the pre-rc2 _get_buttons_toolbar.
    Buttons now stack in a QVBoxLayout inside a QDockWidget on
    LeftDockWidgetArea (pre-rc2: QToolBar on LeftToolBarArea). The
    QDockWidget is borderless and non-floatable. Cached attribute on
    the QMainWindow is _dnav_left_column (the rc2 left-column
    struct); the legacy _dnav_buttons_widget attribute survives as an
    alias pointing at the buttons sub-widget.
  • Buttons.add_separator() on the Qt path now inserts a sunken
    QFrame.HLine into the buttons QVBoxLayout (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_type in addition to applying the visual style; the
    plot_type property 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_type at
    its __init__ default "dot"; the next re_setup_display
    (which fires on every existing layer inside
    add_annotation_layers) called setup_display → set_plot_type (self.plot_type), read the stale "dot", and reverted the
    visual. Manifested as the DUSTrack dlccorr "renders as dots
    after Reduce jitter" regression: apply_manual_corrections set
    dlccorr to line via the method (visual only); adding the LK
    output layer triggered re_setup_display on every layer,
    including dlccorr, which then reverted to dot. Two new tests in
    tests/test_pointtracking.py guard the sync and the
    re-setup-survival.
  • VideoPointAnnotator non-fast_render path: the matplotlib gridspec
    drops its dedicated state-variables column. Layout shrinks from
    3x2 width_ratios=[1, 4] to 3x1; the image axis is now
    full-width. State-variables move from the in-figure mpl axis into
    the QDockWidget left column. The _ax_statevar attribute is still
    set (to None) for backwards compatibility but is unused.
  • _QtStatevarsWidget._build_row switched from QHBoxLayout
    (name | control) to QVBoxLayout (name above control). Side-by-side
    starved the combo of width so long state values
    (e.g. dlc_iteration-3_250000 from a DUSTrack DLC project) elided
    to dlc_...250000; stacking gives the combo the full column width
    and the dropdown is set to AdjustToContents so it grows to fit
    the widest entry. Rows are now visually grouped with a sunken
    QFrame.HLine separator between adjacent state variables.
    Reported during DUSTrack 1.1.0rc2 testing.
  • _QtStatevarsWidget no 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 (palette base.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-rc2 sidebar_width=280 default; tuned
    empirically during DUSTrack 1.1.0rc2 testing). Combined with
    _QtStatevarsWidget switching its horizontal size policy from
    Fixed → Preferred, the statevars widget now fills the column
    instead of sticking to its own sizeHint; each combo (already
    Expanding horizontal) 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 to TextView on non-Qt backends. The pos / fax arguments
    are only consulted on the fallback (pos is ignored on the Qt
    path; the widget's position is dock-managed).
  • make_image_pane no longer creates the fast_render pane.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. The sidebar_width kwarg is kept on the signature for API
    stability but is now ignored. Fast_render make_image_pane now
    returns a pane with just [image_pane, trace_canvas].
  • r keybinding is now cursor-aware in fast_render (Tier 2).
    VideoPointAnnotator._reset_view_all dispatches on event.inaxes
    (set by _patch_event_for_image_pane in __call__): cursor over
    the Tier 2 image pane resets the image zoom/pan only; cursor over
    _ax_trace_x / _ax_trace_y resets the trace pair with x pinned
    to (0, ann.n_frames) (the full video range) and y autoscaled to
    data; cursor elsewhere or event is None falls back to the same
    trace treatment plus the image-pane reset, preserving muscle memory
    for r hit while hovering a button or off-figure. Pre-fix, a user
    who panned the trace x-axis to inspect a feature then hit r to
    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_dispatch in
    tests/test_pointtracking.py covers all three dispatch branches.
  • alt+r is the autoscale-to-data-extent sibling of r (Tier 2).
    VideoPointAnnotator._reset_view_to_data_extent mirrors
    _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 to r. 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_axes takes a new optional keyword axes=
    that restricts the walk to a given subset (default None walks
    self.figure.axes as before). Added to support the cursor-aware
    r / alt+r dispatch 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_kwarg in
    tests/test_core.py.
  • Label-aware y-refit for multi-label tracking. New
    VideoPointAnnotator._fit_y_to_active_label helper 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, then set_ylim on both trace axes. Wired
    to annotation_label and label_range statevariable changes via a
    new StateVariable.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_change de-dupes via
    _last_active_label so the increment_label_range double-fire
    (cycle() then set_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. The r
    trace branch now calls the helper directly (was
    reset_axes(axis="y", ...)), so pressing r over a trace gives a
    comfortable view of the active label rather than compressing it into
    the union of every label's data extent; alt+r retains 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 in tests/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_trace no longer
    unconditionally calls ax_x.set_xlim(0, self.n_frames). The call is
    now guarded with ax_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_layer call 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's Freeze plot axes workaround. Press
    r to refit. Regression tests: test_setup_display_trace_xlim_guard
    • test_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-applies set_ylim(nanlim(trace_data)) on
    every annotation mutation, label switch, or frame-of-interest
    toggle. The two set_ylim calls 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. Press r to refit. Pre-fix, every add / remove /
    LK interpolate / copy-from-overlay / label change re-fitted y,
    forcing reliance on DUSTrack's Freeze plot axes workaround. 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_axes polish (1.3.1 audit punchlist).
    GenericBrowser.reset_axes swaps 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's Collection
    datalims into ax.dataLim before calling autoscale(), so
    fill_between artists -- e.g. DUSTrack display_type="fill"
    events -- contribute to the autoscale extent. mpl's Axes.relim()
    walks Lines + Patches + Images but not Collections, so pressing
    r previously 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 on sys.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 lacks TextView._overlay),
    producing an AttributeError unrelated to the test's intent.
  • fast_render window resize re-fits the image.
    _QtImagePane.resizeEvent pre-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_adjusted flag is set True on wheel-zoom
    and on real pan drags (cleared by reset_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_pane resizes the host window after setCentralWidget
    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.
  • z selector listens on both trace axes. The interval-picker
    Selector had ax_list=[self._ax_trace_x], so pressing z while
    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
    QMainWindow via QWidget.grab() instead of figure.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.save no longer calls remove_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 from self.data returns the full NaN array
    (matching the docstring's "unannotated frames are NaN" promise,
    treated as every-frame-unannotated). Lets update_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's apply_manual_corrections no longer crashes
    with AssertionError: label in self.labels when 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, and update_frame_marker blew 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 bulk add calls. 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_layers synthesis 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.