refactor: 拆出 Studio 状态显示并记录分步迁移计划
让状态显示独立于主窗口,保留录制快照注入入口。验证结果和完整门禁限制见 docs/architecture/studio-window-refactor-plan.zh.md。
This commit is contained in:
@@ -21,7 +21,7 @@ from orisys_app_shared.acquisition.image_backend import (
|
|||||||
SOURCE_RECOMPUTE,
|
SOURCE_RECOMPUTE,
|
||||||
SOURCE_REPLAY,
|
SOURCE_REPLAY,
|
||||||
ImageFrame,
|
ImageFrame,
|
||||||
render_image, # re-exported: tests monkeypatch ``app_window.render_image``
|
render_image as render_image, # re-exported: tests monkeypatch ``app_window.render_image``
|
||||||
)
|
)
|
||||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||||
from orisys_app_shared.force.filters import FilterSettings
|
from orisys_app_shared.force.filters import FilterSettings
|
||||||
@@ -31,7 +31,7 @@ from orisys_app_shared.logging import report_error
|
|||||||
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
|
from orisys_app_shared.paths import logo_path, recordings_dir, sensor_json_path
|
||||||
from orisys_app_shared.prefs import save_preferences
|
from orisys_app_shared.prefs import save_preferences
|
||||||
from orisys_app_shared.record import RecordConfig
|
from orisys_app_shared.record import RecordConfig
|
||||||
from orisys_app_shared.theme import build_qss, clear_theme_cache, color
|
from orisys_app_shared.theme import build_qss, clear_theme_cache
|
||||||
from PySide6.QtCore import Qt, QTimer
|
from PySide6.QtCore import Qt, QTimer
|
||||||
from PySide6.QtGui import QAction, QActionGroup, QIcon
|
from PySide6.QtGui import QAction, QActionGroup, QIcon
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@@ -54,13 +54,13 @@ from orisys_studio.replay import ReplayEngine
|
|||||||
from orisys_studio.replay_controller import ReplayController
|
from orisys_studio.replay_controller import ReplayController
|
||||||
from orisys_studio.scene3d.prefs import settings_to_prefs
|
from orisys_studio.scene3d.prefs import settings_to_prefs
|
||||||
from orisys_studio.session_controller import SessionController
|
from orisys_studio.session_controller import SessionController
|
||||||
from orisys_studio.ui import record_status as record_states
|
from orisys_studio.status_presenter import RailState, StatusPresenter
|
||||||
from orisys_studio.ui.about_dialog import AboutDialog
|
from orisys_studio.ui.about_dialog import AboutDialog
|
||||||
from orisys_studio.ui.analysis_progress import AnalysisProgress
|
from orisys_studio.ui.analysis_progress import AnalysisProgress
|
||||||
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
from orisys_studio.ui.arrow_panel import ArrowPanel, bgr_to_qimage
|
||||||
from orisys_studio.ui.brand_bar import BrandBar
|
from orisys_studio.ui.brand_bar import BrandBar
|
||||||
from orisys_studio.ui.cal_panel import CalPanel
|
from orisys_studio.ui.cal_panel import CalPanel
|
||||||
from orisys_studio.ui.force_plots import CHANNEL_LABELS, ForcePlotsPanel
|
from orisys_studio.ui.force_plots import ForcePlotsPanel
|
||||||
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
||||||
from orisys_studio.ui.finger_orientation_dialog import FingerOrientationDialog
|
from orisys_studio.ui.finger_orientation_dialog import FingerOrientationDialog
|
||||||
from orisys_studio.ui.plot_settings_dialog import (
|
from orisys_studio.ui.plot_settings_dialog import (
|
||||||
@@ -250,6 +250,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.toolbar = ToolbarWidget(theme=self._theme)
|
self.toolbar = ToolbarWidget(theme=self._theme)
|
||||||
root.addWidget(self.toolbar)
|
root.addWidget(self.toolbar)
|
||||||
self.status_chip = self.toolbar.status_chip
|
self.status_chip = self.toolbar.status_chip
|
||||||
|
self.status_presenter = StatusPresenter(self.toolbar)
|
||||||
# Part B: the analysis pass a replay is served from. The surface sits directly
|
# Part B: the analysis pass a replay is served from. The surface sits directly
|
||||||
# under the rail, so "what is it doing and can I stop it" is answered where the
|
# under the rail, so "what is it doing and can I stop it" is answered where the
|
||||||
# transport is; it is hidden until a pass (or a cache hit) has something to say.
|
# transport is; it is hidden until a pass (or a cache hit) has something to say.
|
||||||
@@ -1431,184 +1432,54 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.statusBar().showMessage(message, int(ms))
|
self.statusBar().showMessage(message, int(ms))
|
||||||
|
|
||||||
def _refresh_status_chip(self) -> None:
|
def _refresh_status_chip(self) -> None:
|
||||||
chip = getattr(self, "status_chip", None)
|
self.status_presenter.refresh_phase(self.session.phase, theme=self._theme)
|
||||||
if chip is None:
|
|
||||||
return
|
|
||||||
# The session normalises the phase once, when it is set, so there is nothing
|
|
||||||
# to sanitise here: a second normalisation is a second rule to keep in step.
|
|
||||||
phase = self._session_phase
|
|
||||||
token = f"status_{phase}"
|
|
||||||
# One dot carries the colour; the text says what the state is, so the
|
|
||||||
# colour is never the only encoding.
|
|
||||||
chip.setText(f"● {t(f'status.phase_{phase}')}")
|
|
||||||
chip.setStyleSheet(
|
|
||||||
f"QLabel#statusChip {{ color: {color(self._theme, token)};"
|
|
||||||
" background: transparent; font-weight: 600; padding: 0 10px 0 4px; }"
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------ status rail state
|
# ------------------------------------------------------ status rail state
|
||||||
def _refresh_status_rail(self) -> None:
|
def _refresh_status_rail(self) -> None:
|
||||||
"""Sensor, source detail, calibration, validity, FPS and device identity.
|
result_type = ""
|
||||||
|
result_progress = None
|
||||||
The rail is the single place these facts are written; the bottom status
|
|
||||||
bar keeps only transient messages.
|
|
||||||
"""
|
|
||||||
config = self.sensor_picker.config_name()
|
|
||||||
self.toolbar.set_sensor_summary(
|
|
||||||
t(
|
|
||||||
"sensor.summary",
|
|
||||||
type=self.sensor_picker.sensor_type().upper(),
|
|
||||||
config=Path(config).name if config else t("status.config_none"),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# The picker on the rail already names the camera, so the summary carries
|
|
||||||
# only what it cannot: the frame rate, and — **in the replay view only** —
|
|
||||||
# the video part being played. Naming the video in the live view was how a
|
|
||||||
# replay's file stayed on the rail after switching back to the camera.
|
|
||||||
detail = ""
|
|
||||||
if self._view_preset == "replay":
|
if self._view_preset == "replay":
|
||||||
path = self.toolbar.video_path()
|
result = self.replay_ctl.available_result()
|
||||||
if path:
|
if result is not None:
|
||||||
key = (
|
provenance = getattr(getattr(result, "reader", None), "provenance", None)
|
||||||
"status.rail_video_name"
|
|
||||||
if self.replay.has_video()
|
|
||||||
else "status.rail_recent_video"
|
|
||||||
)
|
|
||||||
detail = t(key, name=Path(path).name)
|
|
||||||
# Name the *result's* profile and whether it was reused or just finished, so
|
|
||||||
# changing the sensor picker cannot look like the open figures came from the
|
|
||||||
# newly selected product.
|
|
||||||
session = self.replay_ctl.available_result()
|
|
||||||
if session is not None:
|
|
||||||
provenance = getattr(getattr(session, "reader", None), "provenance", None)
|
|
||||||
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
|
result_type = str(getattr(provenance, "sensor_type", "") or "").strip()
|
||||||
if result_type:
|
result_progress = str(self.analysis_progress.state())
|
||||||
result_label = t("status.rail_result_profile", type=result_type)
|
self.status_presenter.refresh_rail(RailState(
|
||||||
detail = f"{result_label} · {detail}" if detail else result_label
|
sensor_type=self.sensor_picker.sensor_type(),
|
||||||
progress = getattr(self.replay_ctl, "_progress", None)
|
config_name=self.sensor_picker.config_name(),
|
||||||
progress_state = (
|
view_preset=self._view_preset,
|
||||||
str(progress.state()) if progress is not None and hasattr(progress, "state") else ""
|
video_path=self.toolbar.video_path(),
|
||||||
)
|
has_video=self.replay.has_video(),
|
||||||
if progress_state == "reused":
|
replay_warnings=(
|
||||||
status_text = t("status.rail_result_reused")
|
self.replay.warnings_text()
|
||||||
elif progress_state == "complete":
|
if self._view_preset == "replay" and self.replay.has_video() else ""
|
||||||
status_text = t("status.rail_result_complete")
|
),
|
||||||
elif progress_state == "running":
|
calibration_summary=self._calibration_summary(),
|
||||||
status_text = t("status.rail_result_running")
|
result_sensor_type=result_type,
|
||||||
else:
|
result_progress=result_progress,
|
||||||
status_text = t("status.rail_result_ready")
|
show_fps=self._show_fps,
|
||||||
detail = f"{detail} · {status_text}" if detail else status_text
|
fps=self.session.latest_fps,
|
||||||
if self._show_fps and self._latest_fps is not None:
|
process_ms=self.session.latest_process_ms,
|
||||||
fps = f"{self._latest_fps:.1f} FPS"
|
capture_fps=self.session.latest_capture_fps,
|
||||||
# The cost goes next to the rate because one without the other cannot
|
capture_dropped=self.session.latest_capture_dropped,
|
||||||
# be read: "16.7 FPS" is either a slow camera or a pipeline that
|
sample_valid=self.session.last_sample_valid,
|
||||||
# cannot keep up, and only this number tells the two apart.
|
record_error_count=self.session.record_error_count,
|
||||||
if self._latest_process_ms:
|
))
|
||||||
fps = f"{fps} · {self._latest_process_ms:.0f} ms"
|
|
||||||
# ...and the camera's own rate settles it: a capture rate well above
|
|
||||||
# what we consumed means the frames were there and we did not take
|
|
||||||
# them (the drop counter says how many).
|
|
||||||
if self._latest_capture_fps:
|
|
||||||
fps = f"{fps} · {t('status.rail_camera_fps', fps=f'{self._latest_capture_fps:.1f}')}"
|
|
||||||
if self._latest_capture_dropped:
|
|
||||||
fps = f"{fps} (-{self._latest_capture_dropped})"
|
|
||||||
detail = f"{detail} · {fps}" if detail else fps
|
|
||||||
self.toolbar.set_source_summary(detail)
|
|
||||||
# The full path and the replay's notes do not fit on a 30 px rail, so
|
|
||||||
# they live in the summary's tooltip (the bar that used to show them is
|
|
||||||
# gone: the two views share one layout).
|
|
||||||
tip_parts = [detail, self.toolbar.video_path()]
|
|
||||||
if self._view_preset == "replay" and self.replay.has_video():
|
|
||||||
# Includes the "this is a video, not a recording" note for a bare video.
|
|
||||||
tip_parts.append(self.replay.warnings_text())
|
|
||||||
self.toolbar.set_source_summary_tip(
|
|
||||||
"\n".join(part for part in tip_parts if part)
|
|
||||||
)
|
|
||||||
self.toolbar.set_calibration_summary(self._calibration_summary())
|
|
||||||
if self._last_sample_valid is None:
|
|
||||||
self.toolbar.set_data_state("")
|
|
||||||
elif self._last_sample_valid:
|
|
||||||
self.toolbar.set_data_state(t("status.data_valid"), "success")
|
|
||||||
else:
|
|
||||||
self.toolbar.set_data_state(
|
|
||||||
t("status.data_invalid", count=self._record_error_count), "warning"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _calibration_summary(self) -> str:
|
def _calibration_summary(self) -> str:
|
||||||
"""What the calibration state means for the three axes.
|
return self.status_presenter.calibration_summary(
|
||||||
|
self.recording.active_calibration,
|
||||||
Before the first sample the only evidence is whether the device
|
self._last_display_fallback,
|
||||||
confirmed a calibration version. Afterwards the display decision is the
|
self._last_display_available,
|
||||||
evidence: an axis without a newton curve is an axis without calibration,
|
|
||||||
and the rail must not claim three calibrated axes for a one-axis device.
|
|
||||||
"""
|
|
||||||
if self._last_display_fallback is None:
|
|
||||||
return (
|
|
||||||
t("status.calibrated")
|
|
||||||
if self._active_calibration_id
|
|
||||||
else t("status.uncalibrated")
|
|
||||||
)
|
)
|
||||||
if self._last_display_fallback or self._last_display_available is None:
|
|
||||||
return t("status.uncalibrated")
|
|
||||||
axes = self._last_display_available
|
|
||||||
if all(axes):
|
|
||||||
return t("status.calibrated_all")
|
|
||||||
names = [CHANNEL_LABELS[index] for index, ok in enumerate(axes) if ok]
|
|
||||||
if not names:
|
|
||||||
return t("status.uncalibrated")
|
|
||||||
return t("status.calibrated_axes", axes="+".join(names))
|
|
||||||
|
|
||||||
# ---------------------------------------------------------- record state
|
# ---------------------------------------------------------- record state
|
||||||
def _record_stats(self):
|
def _record_stats(self):
|
||||||
"""``(state, stats, directory)`` — the single read of the record channel.
|
"""Keep the snapshot injection seam used by UI checks and demo screenshots."""
|
||||||
|
return self.status_presenter.record_stats(self.recording)
|
||||||
Kept as one seam so a preview/demo driver can inject a record state
|
|
||||||
without touching the recorder or the writer thread.
|
|
||||||
"""
|
|
||||||
recorder = self.recorder
|
|
||||||
raw_directory = getattr(recorder, "directory", None)
|
|
||||||
directory = Path(raw_directory).name if raw_directory else ""
|
|
||||||
if self.recording.is_recording:
|
|
||||||
return record_states.RECORDING, recorder.stats(), directory
|
|
||||||
if self.recording.report_pending:
|
|
||||||
return record_states.CLOSING, recorder.stats(), directory
|
|
||||||
report = self._record_last_report
|
|
||||||
if report is not None:
|
|
||||||
stats = report.stats
|
|
||||||
state = (
|
|
||||||
record_states.ERROR
|
|
||||||
if (stats.failed or stats.write_errors)
|
|
||||||
else record_states.DONE
|
|
||||||
)
|
|
||||||
return state, stats, directory
|
|
||||||
return record_states.IDLE, None, directory
|
|
||||||
|
|
||||||
def _refresh_record_state(self) -> None:
|
def _refresh_record_state(self) -> None:
|
||||||
"""Push the recording's live numbers (duration, drops, write errors)."""
|
self.status_presenter.refresh_record(*self._record_stats())
|
||||||
state, stats, directory = self._record_stats()
|
|
||||||
if stats is None:
|
|
||||||
self.toolbar.set_record_state(state, directory=directory)
|
|
||||||
return
|
|
||||||
self.toolbar.set_record_state(
|
|
||||||
state,
|
|
||||||
duration_s=stats.duration_s,
|
|
||||||
frames=stats.frames,
|
|
||||||
segments=stats.segments,
|
|
||||||
# An unrecorded gap is a lost row too: reporting only the two
|
|
||||||
# queue-overflow counters would understate the loss.
|
|
||||||
dropped=(
|
|
||||||
stats.dropped_data
|
|
||||||
+ stats.dropped_video
|
|
||||||
+ stats.dropped_gaps
|
|
||||||
+ stats.unrecorded_gaps
|
|
||||||
),
|
|
||||||
dropped_data=stats.dropped_data,
|
|
||||||
dropped_video=stats.dropped_video,
|
|
||||||
dropped_gaps=stats.dropped_gaps + stats.unrecorded_gaps,
|
|
||||||
errors=stats.write_errors,
|
|
||||||
detail=stats.last_error,
|
|
||||||
directory=directory,
|
|
||||||
)
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ nav
|
# ------------------------------------------------------------------ nav
|
||||||
def _set_theme(self, theme: str) -> None:
|
def _set_theme(self, theme: str) -> None:
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
"""Render shell status from read-only facts on the GUI thread.
|
||||||
|
|
||||||
|
The presenter holds only the toolbar. It neither owns session state nor calls
|
||||||
|
back into the main window; a rail snapshot is discarded after each refresh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
|
from orisys_studio.ui import record_status as record_states
|
||||||
|
from orisys_studio.ui.force_plots import CHANNEL_LABELS
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from orisys_app_shared.record import RecordStats
|
||||||
|
|
||||||
|
from orisys_studio.recording import RecordingCoordinator
|
||||||
|
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RailState:
|
||||||
|
"""One GUI-thread read of the facts needed to paint the status rail."""
|
||||||
|
|
||||||
|
sensor_type: str
|
||||||
|
config_name: str
|
||||||
|
view_preset: str
|
||||||
|
video_path: str
|
||||||
|
has_video: bool
|
||||||
|
replay_warnings: str
|
||||||
|
calibration_summary: str
|
||||||
|
result_sensor_type: str = ""
|
||||||
|
#: None means no result; an empty string means a result with no progress state.
|
||||||
|
result_progress: str | None = None
|
||||||
|
show_fps: bool = False
|
||||||
|
fps: float | None = None
|
||||||
|
process_ms: float | None = None
|
||||||
|
capture_fps: float = 0.0
|
||||||
|
capture_dropped: int = 0
|
||||||
|
sample_valid: bool | None = None
|
||||||
|
record_error_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class StatusPresenter:
|
||||||
|
"""Paint status without changing the acquisition or recording lifecycle."""
|
||||||
|
|
||||||
|
def __init__(self, toolbar: ToolbarWidget) -> None:
|
||||||
|
self._toolbar = toolbar
|
||||||
|
|
||||||
|
def refresh_phase(self, phase: str, *, theme: str) -> None:
|
||||||
|
# The session normalises phase; colour is never the only encoding.
|
||||||
|
chip = self._toolbar.status_chip
|
||||||
|
chip.setText(f"● {t(f'status.phase_{phase}')}")
|
||||||
|
token = f"status_{phase}"
|
||||||
|
chip.setStyleSheet(
|
||||||
|
f"QLabel#statusChip {{ color: {color(theme, token)};"
|
||||||
|
" background: transparent; font-weight: 600; padding: 0 10px 0 4px; }"
|
||||||
|
)
|
||||||
|
|
||||||
|
def refresh_rail(self, state: RailState) -> None:
|
||||||
|
toolbar = self._toolbar
|
||||||
|
toolbar.set_sensor_summary(
|
||||||
|
t(
|
||||||
|
"sensor.summary",
|
||||||
|
type=state.sensor_type.upper(),
|
||||||
|
config=Path(state.config_name).name
|
||||||
|
if state.config_name
|
||||||
|
else t("status.config_none"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
detail = ""
|
||||||
|
if state.view_preset == "replay":
|
||||||
|
if state.video_path:
|
||||||
|
key = (
|
||||||
|
"status.rail_video_name"
|
||||||
|
if state.has_video
|
||||||
|
else "status.rail_recent_video"
|
||||||
|
)
|
||||||
|
detail = t(key, name=Path(state.video_path).name)
|
||||||
|
# The displayed result keeps its own identity after a picker change.
|
||||||
|
if state.result_progress is not None:
|
||||||
|
if state.result_sensor_type:
|
||||||
|
label = t(
|
||||||
|
"status.rail_result_profile", type=state.result_sensor_type
|
||||||
|
)
|
||||||
|
detail = f"{label} · {detail}" if detail else label
|
||||||
|
if state.result_progress == "reused":
|
||||||
|
status_text = t("status.rail_result_reused")
|
||||||
|
elif state.result_progress == "complete":
|
||||||
|
status_text = t("status.rail_result_complete")
|
||||||
|
elif state.result_progress == "running":
|
||||||
|
status_text = t("status.rail_result_running")
|
||||||
|
else:
|
||||||
|
status_text = t("status.rail_result_ready")
|
||||||
|
detail = f"{detail} · {status_text}" if detail else status_text
|
||||||
|
if state.show_fps and state.fps is not None:
|
||||||
|
fps = f"{state.fps:.1f} FPS"
|
||||||
|
# Processing cost and capture rate distinguish a slow chain from a slow camera.
|
||||||
|
if state.process_ms:
|
||||||
|
fps = f"{fps} · {state.process_ms:.0f} ms"
|
||||||
|
if state.capture_fps:
|
||||||
|
fps = f"{fps} · {t('status.rail_camera_fps', fps=f'{state.capture_fps:.1f}')}"
|
||||||
|
if state.capture_dropped:
|
||||||
|
fps = f"{fps} (-{state.capture_dropped})"
|
||||||
|
detail = f"{detail} · {fps}" if detail else fps
|
||||||
|
toolbar.set_source_summary(detail)
|
||||||
|
tip_parts = [detail, state.video_path]
|
||||||
|
if state.view_preset == "replay" and state.has_video:
|
||||||
|
tip_parts.append(state.replay_warnings)
|
||||||
|
toolbar.set_source_summary_tip("\n".join(part for part in tip_parts if part))
|
||||||
|
toolbar.set_calibration_summary(state.calibration_summary)
|
||||||
|
if state.sample_valid is None:
|
||||||
|
toolbar.set_data_state("")
|
||||||
|
elif state.sample_valid:
|
||||||
|
toolbar.set_data_state(t("status.data_valid"), "success")
|
||||||
|
else:
|
||||||
|
toolbar.set_data_state(
|
||||||
|
t("status.data_invalid", count=state.record_error_count), "warning"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calibration_summary(
|
||||||
|
active_id: str,
|
||||||
|
fallback: bool | None,
|
||||||
|
available: tuple[bool, bool, bool] | None,
|
||||||
|
) -> str:
|
||||||
|
"""Confirmed identity is evidence until the first per-axis display decision."""
|
||||||
|
if fallback is None:
|
||||||
|
return t("status.calibrated") if active_id else t("status.uncalibrated")
|
||||||
|
if fallback or available is None:
|
||||||
|
return t("status.uncalibrated")
|
||||||
|
if all(available):
|
||||||
|
return t("status.calibrated_all")
|
||||||
|
names = [CHANNEL_LABELS[index] for index, ok in enumerate(available) if ok]
|
||||||
|
if not names:
|
||||||
|
return t("status.uncalibrated")
|
||||||
|
return t("status.calibrated_axes", axes="+".join(names))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def record_stats(
|
||||||
|
recording: RecordingCoordinator,
|
||||||
|
) -> tuple[str, RecordStats | None, str]:
|
||||||
|
"""Read live stats while closing; the final report is authoritative afterwards."""
|
||||||
|
recorder = recording.recorder
|
||||||
|
raw_directory = getattr(recorder, "directory", None)
|
||||||
|
directory = Path(raw_directory).name if raw_directory else ""
|
||||||
|
if recording.is_recording:
|
||||||
|
return record_states.RECORDING, recorder.stats(), directory
|
||||||
|
if recording.report_pending:
|
||||||
|
return record_states.CLOSING, recorder.stats(), directory
|
||||||
|
report = recording.last_report
|
||||||
|
if report is not None:
|
||||||
|
stats = report.stats
|
||||||
|
state = (
|
||||||
|
record_states.ERROR
|
||||||
|
if (stats.failed or stats.write_errors)
|
||||||
|
else record_states.DONE
|
||||||
|
)
|
||||||
|
return state, stats, directory
|
||||||
|
return record_states.IDLE, None, directory
|
||||||
|
|
||||||
|
def refresh_record(
|
||||||
|
self, state: str, stats: RecordStats | None, directory: str
|
||||||
|
) -> None:
|
||||||
|
if stats is None:
|
||||||
|
self._toolbar.set_record_state(state, directory=directory)
|
||||||
|
return
|
||||||
|
self._toolbar.set_record_state(
|
||||||
|
state,
|
||||||
|
duration_s=stats.duration_s,
|
||||||
|
frames=stats.frames,
|
||||||
|
segments=stats.segments,
|
||||||
|
# A gap that could not be recorded is also a lost row.
|
||||||
|
dropped=stats.dropped_data
|
||||||
|
+ stats.dropped_video
|
||||||
|
+ stats.dropped_gaps
|
||||||
|
+ stats.unrecorded_gaps,
|
||||||
|
dropped_data=stats.dropped_data,
|
||||||
|
dropped_video=stats.dropped_video,
|
||||||
|
dropped_gaps=stats.dropped_gaps + stats.unrecorded_gaps,
|
||||||
|
errors=stats.write_errors,
|
||||||
|
detail=stats.last_error,
|
||||||
|
directory=directory,
|
||||||
|
)
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
"""Status presentation without a main window or running acquisition."""
|
||||||
|
|
||||||
|
from dataclasses import replace
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.record.experiment import RecordStats
|
||||||
|
|
||||||
|
from orisys_studio.status_presenter import RailState, StatusPresenter
|
||||||
|
from orisys_studio.ui.toolbar import ToolbarWidget
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def surface(qt_app):
|
||||||
|
toolbar = ToolbarWidget(theme="dark")
|
||||||
|
yield toolbar, StatusPresenter(toolbar)
|
||||||
|
toolbar.close()
|
||||||
|
toolbar.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def rail(**changes):
|
||||||
|
return replace(
|
||||||
|
RailState(
|
||||||
|
sensor_type="finger",
|
||||||
|
config_name="finger/r01/sensor.json",
|
||||||
|
view_preset="live",
|
||||||
|
video_path="D:/clips/box.mp4",
|
||||||
|
has_video=True,
|
||||||
|
replay_warnings="video warning",
|
||||||
|
calibration_summary="calibration",
|
||||||
|
),
|
||||||
|
**changes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_live_summary_keeps_video_details_out_and_reports_capture_cost(surface):
|
||||||
|
toolbar, presenter = surface
|
||||||
|
state = rail(
|
||||||
|
show_fps=True,
|
||||||
|
fps=16.67,
|
||||||
|
process_ms=58.4,
|
||||||
|
capture_fps=30.0,
|
||||||
|
capture_dropped=7,
|
||||||
|
)
|
||||||
|
presenter.refresh_rail(state)
|
||||||
|
text = toolbar.source_summary.text()
|
||||||
|
assert "box.mp4" not in text
|
||||||
|
assert "video warning" not in toolbar.source_summary.toolTip()
|
||||||
|
assert "16.7 FPS" in text and "58 ms" in text
|
||||||
|
assert "30.0" in text and "(-7)" in text
|
||||||
|
assert state.view_preset == "live"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"loaded,key",
|
||||||
|
[
|
||||||
|
(True, "status.rail_video_name"),
|
||||||
|
(False, "status.rail_recent_video"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_replay_distinguishes_open_video_from_remembered_path(surface, loaded, key):
|
||||||
|
toolbar, presenter = surface
|
||||||
|
presenter.refresh_rail(rail(view_preset="replay", has_video=loaded))
|
||||||
|
assert toolbar.source_summary.text() == t(key, name="box.mp4")
|
||||||
|
assert ("video warning" in toolbar.source_summary.toolTip()) is loaded
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"progress,key",
|
||||||
|
[
|
||||||
|
("reused", "status.rail_result_reused"),
|
||||||
|
("complete", "status.rail_result_complete"),
|
||||||
|
("running", "status.rail_result_running"),
|
||||||
|
("", "status.rail_result_ready"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_result_identity_belongs_to_the_result_not_the_picker(surface, progress, key):
|
||||||
|
toolbar, presenter = surface
|
||||||
|
presenter.refresh_rail(
|
||||||
|
rail(
|
||||||
|
view_preset="replay",
|
||||||
|
result_sensor_type="box",
|
||||||
|
result_progress=progress,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
text = toolbar.source_summary.text()
|
||||||
|
assert text.startswith(t("status.rail_result_profile", type="box"))
|
||||||
|
assert text.endswith(t(key))
|
||||||
|
assert "FINGER" in toolbar.btn_sensor.text()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"version,fallback,axes,key,labels",
|
||||||
|
[
|
||||||
|
("", None, None, "status.uncalibrated", ""),
|
||||||
|
("confirmed", None, None, "status.calibrated", ""),
|
||||||
|
("confirmed", True, (True, True, True), "status.uncalibrated", ""),
|
||||||
|
("confirmed", False, None, "status.uncalibrated", ""),
|
||||||
|
("confirmed", False, (False, False, False), "status.uncalibrated", ""),
|
||||||
|
("confirmed", False, (True, True, True), "status.calibrated_all", ""),
|
||||||
|
("confirmed", False, (True, False, True), "status.calibrated_axes", "FN+FY"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_calibration_summary_preserves_unknown_raw_and_partial_axes(
|
||||||
|
version,
|
||||||
|
fallback,
|
||||||
|
axes,
|
||||||
|
key,
|
||||||
|
labels,
|
||||||
|
):
|
||||||
|
assert StatusPresenter.calibration_summary(version, fallback, axes) == t(
|
||||||
|
key, axes=labels
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_phase_repaints_after_a_theme_change_without_changing_the_phase(surface):
|
||||||
|
toolbar, presenter = surface
|
||||||
|
presenter.refresh_phase("connecting", theme="dark")
|
||||||
|
assert toolbar.status_chip.text() == f"● {t('status.phase_connecting')}"
|
||||||
|
presenter.refresh_phase("connecting", theme="light")
|
||||||
|
assert toolbar.status_chip.text() == f"● {t('status.phase_connecting')}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_close_and_all_gap_counts_remain_visible(surface):
|
||||||
|
toolbar, presenter = surface
|
||||||
|
stats = RecordStats(
|
||||||
|
frames=12,
|
||||||
|
duration_s=2.5,
|
||||||
|
dropped_data=1,
|
||||||
|
dropped_video=2,
|
||||||
|
dropped_gaps=3,
|
||||||
|
unrecorded_gaps=4,
|
||||||
|
write_errors=1,
|
||||||
|
last_error="disk full",
|
||||||
|
)
|
||||||
|
recording = SimpleNamespace(
|
||||||
|
recorder=SimpleNamespace(directory="D:/recordings/run", stats=lambda: stats),
|
||||||
|
is_recording=False,
|
||||||
|
report_pending=True,
|
||||||
|
last_report=SimpleNamespace(stats=RecordStats()),
|
||||||
|
)
|
||||||
|
snapshot = presenter.record_stats(recording)
|
||||||
|
assert snapshot == ("closing", stats, "run")
|
||||||
|
presenter.refresh_record(*snapshot)
|
||||||
|
displayed = toolbar.record_chip.stats()
|
||||||
|
assert displayed["state"] == "closing"
|
||||||
|
assert displayed["dropped"] == 10
|
||||||
|
assert displayed["dropped_gaps"] == 7
|
||||||
|
assert displayed["errors"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"failed,errors,expected",
|
||||||
|
[
|
||||||
|
(False, 0, "done"),
|
||||||
|
(True, 0, "error"),
|
||||||
|
(False, 1, "error"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_finished_recording_uses_the_final_report(surface, failed, errors, expected):
|
||||||
|
toolbar, presenter = surface
|
||||||
|
stats = RecordStats(failed=failed, write_errors=errors)
|
||||||
|
recording = SimpleNamespace(
|
||||||
|
recorder=SimpleNamespace(directory=None),
|
||||||
|
is_recording=False,
|
||||||
|
report_pending=False,
|
||||||
|
last_report=SimpleNamespace(stats=stats),
|
||||||
|
)
|
||||||
|
assert presenter.record_stats(recording) == (expected, stats, "")
|
||||||
|
presenter.refresh_record(expected, stats, "")
|
||||||
|
assert toolbar.record_chip.state() == expected
|
||||||
@@ -1,25 +1,50 @@
|
|||||||
# Studio 软件架构(测量版)
|
# Studio 软件架构
|
||||||
|
|
||||||
本文不是"理想架构",而是**当前代码的真实依赖结构**:所有框图、数字与结论都由下面的脚本从源码里扫出来,
|
本文的当前职责说明对应 2026-10-18 主窗口重构 S1;后续工作和验收由
|
||||||
每条判断后面都跟着它的证据。配套的矢量图是 `studio-architecture.svg`(浏览器直接打开)。
|
[分步重构计划](studio-window-refactor-plan.zh.md) 维护。下方旧扫描图表保留为历史证据,
|
||||||
|
不能用其中的行数或“已完成”判断当前代码。
|
||||||
|
|
||||||
生成方式(可复现,改完代码重跑即可):
|
## 当前职责与数据流
|
||||||
|
|
||||||
> **脚本已不在工作区。** 下面两条命令指向 `D:\orisys_ws\.repro\` 下的一次性扫描脚本,该目录已清理
|
`app_window.py::StudioMainWindow` 创建控件和控制器并连接信号;本次从 3,181 行减至
|
||||||
> (2026-09-27 工作区整理),扫描脚本本身没有保留。**本文的框图与数字仍然有效** —— 矢量图
|
3,052 行。它仍承担菜单、预览、标定交互、分析配置和运行协调,尚未完成薄窗口目标。
|
||||||
> `studio-architecture.svg` 和 `studio-architecture.png` 已入库,是本文配套的、保留至今的产物。
|
|
||||||
> 如需重跑,按下面的 `PYTHONPATH` 与两条命令的口径重写等价脚本即可;结论口径见下方"统计口径"。
|
|
||||||
|
|
||||||
```powershell
|
`status_presenter.py::StatusPresenter` 只持有工具栏,不持有窗口或会话控制器。
|
||||||
$env:PYTHONPATH = 'D:\orisys_ws\orisys-app\shared\src;D:\orisys_ws\orisys-app\apps\studio\src'
|
窗口在 GUI 线程收集一次性的 `RailState`,Presenter 负责 phase、来源、标定、有效性及
|
||||||
& $py D:\orisys_ws\.repro\studio_arch_scan.py # 模块清单、包级依赖、环、Qt/GL/SDK 归属 ← 脚本已删除
|
录制统计的文字和控件更新。快照不缓存、不反写状态;主题和语言刷新沿用原入口。
|
||||||
& $py D:\orisys_ws\.repro\studio_arch_imports.py # 哪些模块在 import 时就拉进 Qt/GL/SDK,哪些是惰性 ← 脚本已删除
|
分析结果的传感器身份来自结果 provenance,进度读取现有 `AnalysisProgress.state()`,
|
||||||
|
不再通过 `ReplayController._progress` 访问其私有控件。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
W[StudioMainWindow 装配与接线] --> S[SessionController 会话与遥测]
|
||||||
|
W --> R[ReplayController 回放与分析执行]
|
||||||
|
W --> L[ViewLayoutController 布局]
|
||||||
|
W --> D[DisplayPrefsController 显示偏好]
|
||||||
|
W --> C[RecordingCoordinator 记录与设备确认]
|
||||||
|
W --> P[StatusPresenter 状态显示]
|
||||||
|
F[RailState 只读显示事实] --> P
|
||||||
|
P --> T[ToolbarWidget 状态轨]
|
||||||
|
W --> A[AcquirePresenter 实时呈现]
|
||||||
|
W --> V[FrameView 二维与共享深度范围]
|
||||||
```
|
```
|
||||||
|
|
||||||
统计口径:行数含空行;`fan-in` = 有多少模块 import 它;`fan-out` = 它 import 了多少模块(同一模块多次
|
会话状态仍属于 `SessionController`,录制状态仍属于 `RecordingCoordinator`。
|
||||||
import 只算一条边)。
|
`_record_stats` 保留为 UI 检查和截图的注入入口,实际状态选择及显示已委托 Presenter。
|
||||||
|
关闭中的录制继续读取实时统计,完成后读取最终报告;丢失计数包括未能记下的 gap。
|
||||||
|
设备确认与录制仍走采集线程通道;本次未改变定时器、停止等待或 3D 懒加载。
|
||||||
|
|
||||||
---
|
验证入口:`test_status_presenter.py` 可在没有主窗口和设备的情况下检查状态显示;
|
||||||
|
`test_ui_smoke.py`、`test_replay_wiring.py` 和 `tools/ui_check.py` 覆盖窗口集成。
|
||||||
|
执行结果、环境及尚未验证范围记录在实施计划。状态转发暂留用于现有调用方,后续按职责迁移,
|
||||||
|
不增加新的全局状态容器或将剩余逻辑全部并入回放控制器。
|
||||||
|
|
||||||
|
## 历史测量的适用范围
|
||||||
|
|
||||||
|
以下第 1–6 节及配套 `studio-architecture.svg`、`studio-architecture.png` 是旧扫描快照。
|
||||||
|
原扫描的对应 revision 与完整运行输入不可用,生成脚本已于 2026-09-27 清理,无法原样复跑。
|
||||||
|
数字口径为含空行行数;fan-in/out 为模块导入关系。旧测试计数、无环结论和完成状态均未在本次
|
||||||
|
重新全量测量,不是当前保证;后续重构顺序以实施计划为准。
|
||||||
|
|
||||||
## 1. 分层视图(真实依赖方向)
|
## 1. 分层视图(真实依赖方向)
|
||||||
|
|
||||||
@@ -289,35 +314,24 @@ graph TB
|
|||||||
7. **验证面 > 生产面**:19.7k 行测试/工具 vs 14.7k 行 studio 生产代码。好处是回归门硬;代价是每次改接口
|
7. **验证面 > 生产面**:19.7k 行测试/工具 vs 14.7k 行 studio 生产代码。好处是回归门硬;代价是每次改接口
|
||||||
的成本被放大,尤其是 `ui_check` 里的字符串/布局断言(拆 `app_window` 时为保住这套门而留的转发,就是这个代价)。
|
的成本被放大,尤其是 `ui_check` 里的字符串/布局断言(拆 `app_window` 时为保住这套门而留的转发,就是这个代价)。
|
||||||
|
|
||||||
### 一句话结论
|
### 历史评估结论
|
||||||
|
|
||||||
**组织是合理的、容易维护的**(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方)。
|
**组织是合理的、容易维护的**(无环、分层单向、共享层无 Qt、危险知识集中在有测试的地方)。
|
||||||
原来唯一的明确单点 `app_window.py` 已按职责拆成
|
原来唯一的明确单点 `app_window.py` 已按职责拆成
|
||||||
`session_controller` / `replay_controller` / `view_layout` / `display_prefs` + 标定服务,
|
`session_controller` / `replay_controller` / `view_layout` / `display_prefs` + 标定服务,
|
||||||
窗口退回装配与转发;每个新模块都有自己的公开接口单测(会话元数据 15 + 四个控制器 37 +
|
窗口退回装配与转发;每个新模块都有自己的公开接口单测(会话元数据 15 + 四个控制器 37 +
|
||||||
标定服务 4 = 56 项),且在不需要窗口的情况下可测。
|
标定服务 4 = 56 项),且在不需要窗口的情况下可测。
|
||||||
下一步的性价比最高项因此换成了下面第 2 条(把"现在对"变成"以后不会错")与第 5 条
|
这是旧扫描时的评估结论;当前仍有窗口职责待迁移,实施顺序见下节链接。
|
||||||
(最脆的资产 `ui_check.py` 现在是 3401 行单文件)。
|
|
||||||
|
|
||||||
## 7. 建议(按性价比排序)
|
## 后续迁移
|
||||||
|
|
||||||
| # | 建议 | 收益 | 成本/风险 | 回归门 |
|
当前工作由 [分步重构计划](studio-window-refactor-plan.zh.md) 的 S2–S6 管理,
|
||||||
|---|---|---|---|---|
|
包括界面构建、标定交互、分析配置、运行与预览以及显示入口和兼容桥收尾。
|
||||||
| 1 | ~~**拆 `app_window`**~~ **已完成**(会话 → `session_controller`、回放 → `replay_controller`、布局 → `view_layout`、显示偏好 → `display_prefs`、标定规则 → `calibration_service`;窗口 3044 → 2309 行) | 改动热点从 1 个 3000 行文件变成 5 个 200–650 行文件 + 一个装配器;每个控制器可脱离窗口单测(新增 41 项) | ~~中~~(已付:纯搬运 + 一层转发,未改行为) | `test_ui_smoke`/`test_scene3d_view`/`ui_check` 全绿;`git diff` 只应有移动与转发 |
|
每一步完成后更新本节之前的当前职责;历史指标不随搬运代码自动改写。
|
||||||
| 1b | **删掉过渡转发**:窗口里那约 40 个 `self._x` 转发是给 tests/`ui_check` 用的桥,下一次改动落在哪个控制器,就顺手把该控制器的转发与测试调用点改成 `self.<controller>.*` | 窗口真正退到"只有装配";转发留着会慢慢变成新的隐式耦合 | 小-中(每次改动顺带;一次性清理会大面积改测试) | 全套 + `ui_check` |
|
|
||||||
| 2 | **给共享层加"依赖规则测试"**:AST 扫描断言 `shared` 不得 eager import PySide6/OpenGL(现状成立),以及 `studio.scene3d` 不得 import `acquisition`;把 `force → orisys` 的现状登记为"已知例外"或改为惰性 —— *部分已完成*:`apps/studio/tests/test_studio_binding_purity.py` 与 `apps/autocal/tests/test_binding_purity.py` 已经强制"只允许 PySide6"(含无作用域枚举的检查,且用真 binding 内省而不是手写枚举名单);`studio.scene3d` 与 `force → orisys` 两条仍未加 | 把"现在对"变成"以后不会错";顺手暴露第 4 条风险 | 小(一个测试文件,用现成扫描脚本改) | 新测试 + 现有套件 |
|
|
||||||
| 3 | **消歧命名**:`scene3d/panel.py` → `scene3d/controls.py`;`ui/mesh3d_panel.py` → `ui/scene3d_host.py`;`scene3d/replay` 与 `record/playback` 在 docstring 里互相指路 | 新人少走 3 次错门 | 小(改名 + import 更新),但会触及 ui_check 的引用 | 全套 + `ui_check` |
|
|
||||||
| 4 | **把 `force → SDK` 改为惰性**(`calibration_io` 里改成函数内 import,或把 SDK 相关函数拆出去) | 力曲线的纯计算(history/filters/scale)可在无 SDK 环境测试 | 中(要区分哪些函数真的需要 SDK) | 现有力测试 + 无 SDK 的 import 检查 |
|
|
||||||
| 5 | **`ui_check` 拆模块**(按场景拆 `tools/ui_checks/*.py`,保留单一入口与退出码) | 最脆的资产变成可分工、可 diff 的几块 | 中(3401 行搬运,语义不变) | 检查项计数不变(587 项 0 失败) |
|
|
||||||
| 6 | **测试私有访问清单化**:把所有 `obj._x` 访问集中到 `tests/support/` 一层小适配器(拆 `app_window` 时保留的转发就是为了不一次性打碎它们) | 内部重构不再一次性打碎几十个测试 | 小-中 | 全套 |
|
|
||||||
| 7 | **补上真机与 ARM 的验收**(本机无设备、无板卡):清零/标定确认、`VRAW` 链失败后的状态、2 小时 GUI 长时、Jetson Orin Nano / QCS6490 报告 | 把"代码上成立"变成"设备上成立"——这是当前最大的空白 | 中(要设备与板卡) | 阶段 9 的验收清单 |
|
|
||||||
|
|
||||||
不建议做的事:**不要**为了"看起来分层漂亮"把 `scene3d` 从 studio 搬进 shared(第 3 条第 5 项理由不硬,
|
|
||||||
但现在搬动没有收益,反而打断三维子系统已经稳定的接口);也**不要**为了统一而重写 `ui_check`。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
_本文与图由源码扫描生成;扫描脚本 `.repro/studio_arch_scan.py`、`.repro/studio_arch_imports.py`
|
_以上历史快照与图由源码扫描生成;扫描脚本 `.repro/studio_arch_scan.py`、`.repro/studio_arch_imports.py`
|
||||||
与图表脚本 `.repro/make_studio_svg.py` → `.repro/svg_to_png.py`、原始数据 `.repro/studio_arch.json`、
|
与图表脚本 `.repro/make_studio_svg.py` → `.repro/svg_to_png.py`、原始数据 `.repro/studio_arch.json`、
|
||||||
`.repro/studio_arch_imports.json` **均已在 2026-09-27 的工作区整理中删除**,未迁移。保留下来的是
|
`.repro/studio_arch_imports.json` **均已在 2026-09-27 的工作区整理中删除**,未迁移。保留下来的是
|
||||||
`.repro/` 之外的本文与图本身(`studio-architecture.svg`、`studio-architecture.png`)。
|
`.repro/` 之外的本文与图本身(`studio-architecture.svg`、`studio-architecture.png`)。
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
# Studio 主窗口分步重构计划
|
||||||
|
|
||||||
|
目的:plan。范围:`apps/studio/src/orisys_studio/app_window.py` 及其直接调用方。
|
||||||
|
当前架构由 [Studio 架构](studio-architecture.md) 维护;本文件是唯一实施进度记录。
|
||||||
|
|
||||||
|
## 目标与约束
|
||||||
|
|
||||||
|
主窗口保留组件装配、跨功能信号连接、窗口几何及明确的关闭顺序。
|
||||||
|
按职责迁移现有实现,不增加产品功能、依赖、线程或全局状态容器。
|
||||||
|
会话、回放、布局、显示偏好和记录状态继续由现有控制器持有。
|
||||||
|
显示层只读取状态;录制与标定采样继续消费采集线程的数据,不依赖刷新频率。
|
||||||
|
|
||||||
|
启动、停止、暂停、异常处理、信号顺序及懒加载时机均属于行为保持边界。
|
||||||
|
关闭时保留预览关闭、采集回调解绑、分析取消与等待、采集和录制收尾、3D 释放的顺序。
|
||||||
|
测试和工具确实使用的旧入口可以暂留,但每一步必须说明保留原因并更新真实调用方。
|
||||||
|
不提交、不切换分支;排除已有 Autocal、测试修复文档及工作区归档修改。
|
||||||
|
|
||||||
|
## 顺序与验收
|
||||||
|
|
||||||
|
每步都先检查调用方及相关测试,完成后更新本计划和架构文档。
|
||||||
|
任务顺序为 S0 → S1 → S2 → S3 → S4 → S5 → S6;每一步均可独立审查。
|
||||||
|
|
||||||
|
### S0:基线与计划
|
||||||
|
|
||||||
|
- [x] 记录 HEAD、索引和已有修改,建立独立 scratch 证据目录。
|
||||||
|
- [x] 在改动前运行状态显示与主窗口相关测试。
|
||||||
|
|
||||||
|
基线:2026-10-18,`orisys-app` 的 `main`,HEAD `433b76e`。
|
||||||
|
源码为 3,181 行;`StudioMainWindow` 有 212 个方法,含属性访问器。
|
||||||
|
原架构文档的 2,309 行及“拆分已完成”属于历史状态。
|
||||||
|
|
||||||
|
### S1:状态显示
|
||||||
|
|
||||||
|
- [x] 新建 `status_presenter.py`,接收显示事实与目标控件,不接收整个窗口。
|
||||||
|
- [x] 迁移 phase、来源摘要、标定摘要、有效性及录制统计的格式化与显示。
|
||||||
|
- [x] 保留 `_record_stats` 注入入口,保证已有截图工具和 UI 检查仍有效。
|
||||||
|
|
||||||
|
涉及主窗口、新 Presenter 和定向测试。验收:实时视图不显示回放标题;分析结果保持自己的
|
||||||
|
传感器身份;未知、无标定、部分轴标定仍可区分;录制关闭中不显示完成,丢失计数完整。
|
||||||
|
验证:状态 Presenter 独立测试、`test_ui_smoke`、`test_replay_wiring` 和现有 UI 检查。
|
||||||
|
|
||||||
|
### S2:界面构建
|
||||||
|
|
||||||
|
- [ ] 提取 `ui/window_ui.py` 的控件和布局构建,保持控件身份、顺序及 parent。
|
||||||
|
- [ ] 单独迁移菜单和 QAction 创建;由窗口显式连接功能操作。
|
||||||
|
- [ ] 迁移对应文字与主题更新,保留必要属性兼容入口。
|
||||||
|
|
||||||
|
依赖 S1。涉及主窗口、UI 模块及实际调用方;面板构建和菜单构建分两批验收。
|
||||||
|
验证:`test_ui_elements`、`test_view_layout`、`test_sensor_switch_window`、主题与语言检查。
|
||||||
|
3D 仍在原时机创建,启动恢复顺序及保存的布局不变。
|
||||||
|
|
||||||
|
### S3:实时标定交互
|
||||||
|
|
||||||
|
- [ ] 提取 `live_calibration.py` 的选择保存、解析、确认及提交请求。
|
||||||
|
- [ ] 文件身份和匹配规则复用 `CalibrationService`;对话框通过窄接口访问。
|
||||||
|
- [ ] 设备确认、记录版本及采样仍走现有记录通道。
|
||||||
|
|
||||||
|
依赖 S2。验证:`test_live_calibration_dialog`、`test_calibration_service` 及确认事件测试。
|
||||||
|
导出不等于应用,应用请求不等于设备确认,序列号不匹配的处理不变。
|
||||||
|
|
||||||
|
### S4:分析配置
|
||||||
|
|
||||||
|
- [ ] 提取 `analysis_setup.py` 的对话框、裸视频工作目录和分析服务构造。
|
||||||
|
- [ ] 迁移 reference override 的所有权,保持现有保存和取消语义。
|
||||||
|
- [ ] `ReplayController` 继续负责执行、取消、缓存及结果选择,不吸收配置逻辑。
|
||||||
|
|
||||||
|
依赖 S3 的标定选择接口。验证:`test_bare_video_analysis`、`test_analysis_progress`、
|
||||||
|
`test_replay_wiring`;历史录制仍使用自身 chain,取消配置不改变当前结果。
|
||||||
|
|
||||||
|
### S5:运行协调与预览
|
||||||
|
|
||||||
|
- [ ] 先提取 `preview_controller.py`,负责设备和预览定时器生命周期。
|
||||||
|
- [ ] 再提取 `activity_coordinator.py`,统一按钮和快捷键的运行、暂停、停止规则。
|
||||||
|
- [ ] worker 操作通过 `SessionController`;关闭流程继续在窗口显式排列。
|
||||||
|
|
||||||
|
依赖 S4。两批分别验证 `test_session_controller`、`test_pause_controls`、
|
||||||
|
`test_transport_review` 和连接中停止、断开、异常、重复关闭场景。
|
||||||
|
普通停止保持非阻塞,退出等待资源结束;预览与采集不能争用相机。
|
||||||
|
|
||||||
|
### S6:显示入口与兼容桥收尾
|
||||||
|
|
||||||
|
- [ ] 将 2D/3D 的共同交付协调归入现有 `FrameView`,调用方显式传入显示上下文。
|
||||||
|
- [ ] 更新测试和工具为真实控制器接口,删除已无调用方的窗口转发。
|
||||||
|
- [ ] 核对依赖方向、无循环、线程归属和文档现状;运行完整门禁。
|
||||||
|
|
||||||
|
依赖 S5。验证:`test_shared_depth_scale`、`test_scene3d_frame_views`、回放定位与
|
||||||
|
显示设置重绘测试。2D/3D 使用同一帧;实时不覆盖回放;重绘不启动新分析。
|
||||||
|
结束条件是主窗口只承担约定职责,且所有相关检查通过,不以行数作为唯一标准。
|
||||||
|
|
||||||
|
## 验证与当前进度
|
||||||
|
|
||||||
|
解释器使用 `.venv-build/Scripts/python.exe`。定向 pytest 显式使用独立的
|
||||||
|
`--basetemp` 和 `-p no:cacheprovider`;日志位于 `.scratch/studio-window-refactor/`。
|
||||||
|
每个迁移步骤运行对应检查;可交付批次运行 `tools/gate.ps1 -Task studio-window-refactor`。
|
||||||
|
文档使用工作区 `scripts/doc_lint.py` 和 `scripts/doc_count.py`,不扩大基线。
|
||||||
|
|
||||||
|
2026-10-18:S0 和 S1 已实施。改动前后同一组 `test_ui_smoke` 与
|
||||||
|
`test_replay_wiring` 均为 125 passed;新增 Presenter 独立测试为 19 passed。
|
||||||
|
主窗口暂为 3,052 行;状态显示已迁出,S2–S6 尚未实施。
|
||||||
|
|
||||||
|
首次完整门禁:lint、共享层(467 passed、18 skipped、3 deselected)及 UI 检查
|
||||||
|
(632 checks、0 failures)通过;Studio 为 548 passed、19 skipped、2 failed。
|
||||||
|
失败分别是 `test_adopting_a_box_result_never_flashes_the_overlay_refusal` 的两次绘制,
|
||||||
|
以及 `test_the_replay_view_loads_a_recording_and_scrubs_it` 的录制目录缺失。
|
||||||
|
|
||||||
|
两项在重构前后隔离运行均通过;重构前主窗口的完整顺序对照为 550 passed、19 skipped。
|
||||||
|
没有修改回放实现、测试断言或失败基线来消除这些失败;其发生条件尚未确认。
|
||||||
|
当前主窗口的最终串行 Studio 复测为 550 passed、19 skipped,与旧主窗口完整对照一致。
|
||||||
|
S1 本批检查完成,S2 尚未开始;首次门禁失败报告仍保留,不改写为通过。
|
||||||
|
|
||||||
|
UI 检查虽然断言通过,日志另有 `asset_rejected: this coordinator has been closed` 的
|
||||||
|
异步加载回调异常;其复现条件未确认,S5 的关闭流程验收需要复核。
|
||||||
|
|
||||||
|
本批使用 Windows、Python 3.10.19 和 offscreen Qt,不能作为真实设备、可见桌面操作
|
||||||
|
或硬件 GL 验证。测试反转了标定 fallback 判断,7 项相关用例中 2 项发现错误;
|
||||||
|
随后按字节恢复源码。197 个相关源码和测试文件的 SHA-256 在最终收尾比较中完全一致。
|
||||||
|
文档门禁无新增问题;架构文档仍保留一项已登记的历史长段落债务,没有扩大基线。
|
||||||
|
|
||||||
|
实际执行的完整检查如下,工作目录为 `D:\orisys_ws\orisys-app`;独立日志和 XML 留在
|
||||||
|
`.scratch/studio-window-refactor/20261018-200221/`,首次门禁汇总位于相邻的
|
||||||
|
`gate-20261018T120931Z/summary.md`。这些是本地证据,未纳入版本控制。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File tools\gate.ps1 -Task studio-window-refactor
|
||||||
|
& .\.venv-build\Scripts\python.exe -m pytest apps/studio/tests -m 'not timing' -p no:cacheprovider --basetemp=D:\orisys_ws\orisys-app\.scratch\studio-window-refactor\20261018-200221\pytest-final-studio --junitxml=D:\orisys_ws\orisys-app\.scratch\studio-window-refactor\20261018-200221\final-studio.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
并行任务将 App HEAD 从 `433b76e` 更新至 `cf3e638`,未改动本次主窗口基准;
|
||||||
|
相关回放分析修改不属于本批。
|
||||||
|
根仓库的计划索引和 inventory 登记被并行任务纳入 `bf44125`,根仓库目前无本批未提交修改。
|
||||||
|
本次没有执行提交、暂存或历史改写;产品仓库的五个交付文件保持未暂存。
|
||||||
Reference in New Issue
Block a user