Present replay 3D with the cursor and open Analyze on a remembered video

Replay left the 3D pane on the rest mesh or a sparse overlay, and Analyze skipped the setup dialog unless the file was re-picked. Live Run no longer starts a chain on a video path. Verified with Studio tests for flush, overlay mask, Analyze open, live-video refusal, and locales.
This commit is contained in:
ykcheungab
2026-10-05 16:52:27 +08:00
parent 3433eb0fec
commit c0a10e6adb
26 changed files with 2226 additions and 158 deletions
+57 -44
View File
@@ -2,8 +2,7 @@
"app": { "app": {
"title": "Orisys Studio", "title": "Orisys Studio",
"about_title": "About", "about_title": "About",
"about_body": "Orisys Studio desktop application\nVersion: {app_version}", "about_body": "Orisys Studio desktop application\nVersion: {app_version}"
"scaffold_hint": "Scaffold shell — acquisition coming later"
}, },
"brand": { "brand": {
"name": "ORISYS", "name": "ORISYS",
@@ -40,8 +39,10 @@
"settings": "Settings area", "settings": "Settings area",
"plot_settings": "Plot settings…", "plot_settings": "Plot settings…",
"rotate_arrows": "Rotate arrows 90°", "rotate_arrows": "Rotate arrows 90°",
"recompute": "Recompute forces from the video", "recompute": "Analyze video",
"recompute_tip": "Off: the charts read the recorded forces.csv. On: re-run the vision pipeline over the recorded video (an algorithm check, forward-only).", "recompute_tip": "On: serve a stored analysis result for this video, or run the pass if there is none. Off: leave analysis and read the recording’s own forces.csv when it has one. To change profile, calibration or reference frame use View ▸ Analysis setup…",
"analysis_setup": "Analysis setup…",
"analysis_setup_tip": "Choose the profile, calibration and reference frame for this video, then run (or re-run) the analysis pass.",
"run": "Run / Stop", "run": "Run / Stop",
"stop": "Cancel acquisition", "stop": "Cancel acquisition",
"preview": "Preview / Stop preview", "preview": "Preview / Stop preview",
@@ -113,8 +114,8 @@
"use_camera_tip": "Go back to acquiring from a camera (the data source is a video file)", "use_camera_tip": "Go back to acquiring from a camera (the data source is a video file)",
"choose_video": "Choose a recorded video…", "choose_video": "Choose a recorded video…",
"choose_video_tip": "Open a recorded video: the 2D panel plays it and the charts read the forces.csv beside it", "choose_video_tip": "Open a recorded video: the 2D panel plays it and the charts read the forces.csv beside it",
"recompute": "Recompute from video…", "recompute": "Analyze video…",
"recompute_tip": "Run the pipeline over this recording again: the 2D panel then shows the processed image (the recording itself stores only the raw camera image) and the charts and 3D view use the recomputed values. You can press Stop at any time.", "recompute_tip": "Serve a stored analysis result for this video, or run the pass if there is none. The 2D panel can then draw arrows and depth; the charts may show force from the result. Stop cancels a pass that is still running. To change profile, calibration or reference frame use View ▸ Analysis setup…",
"replay_view_only": "Open a recording from the History replay view", "replay_view_only": "Open a recording from the History replay view",
"preview": "Preview", "preview": "Preview",
"preview_stop": "Stop preview", "preview_stop": "Stop preview",
@@ -126,13 +127,14 @@
"cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)", "cancel_tip": "Abort the connection (always available while connecting, shortcut Esc)",
"run_unavailable": "Not available yet", "run_unavailable": "Not available yet",
"run_disabled_video": "Select a video file first", "run_disabled_video": "Select a video file first",
"run_video_in_replay": "Play a video from the History replay view (Play and the chart cursor)",
"run_disabled_sensor": "Run is not available for this sensor type", "run_disabled_sensor": "Run is not available for this sensor type",
"playback": "▶ Play", "playback": "▶ Play",
"playback_pause": "⏸ Pause", "playback_pause": "⏸ Pause",
"playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses", "playback_tip": "Play this recording at 1× (space bar does the same); dragging the cursor pauses",
"playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)", "playback_pause_tip": "Pause playback (dragging the cursor, box zoom or the wheel also pauses)",
"playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)", "playback_disabled": "Open a recorded video first (“Choose a recorded video…” at the top left)",
"playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video use View ▸ Recompute forces from the video", "playback_needs_recording": "This video has no recording beside it (no forces.csv): replay needs a recording; to just watch the video open it as a video, or use View ▸ Analyze video",
"playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)", "playback_video_only": "Play this video (no recording beside it: the picture plays, the force charts are empty)",
"reset_sensor": "Reset sensor", "reset_sensor": "Reset sensor",
"reset_sensor_tip": "Zero the sensor tracker (the device's own zero). Zeroing it also restarts the display baseline, so the curves and the value cards re-learn their zero from the next quiet samples; the curve history is kept — View ▸ Reset views (F8) clears the views.", "reset_sensor_tip": "Zero the sensor tracker (the device's own zero). Zeroing it also restarts the display baseline, so the curves and the value cards re-learn their zero from the next quiet samples; the curve history is kept — View ▸ Reset views (F8) clears the views.",
@@ -140,7 +142,6 @@
"reset_display": "Reset display", "reset_display": "Reset display",
"reset_display_tip": "Clear the accumulated display: the curves, cursor and value cards go back to their defaults and the replay returns to the start of the recording. There is no device session in this view, so nothing of the device is touched; the 3D camera is View ▸ Reset views (F8).", "reset_display_tip": "Clear the accumulated display: the curves, cursor and value cards go back to their defaults and the replay returns to the start of the recording. There is no device session in this view, so nothing of the device is touched; the 3D camera is View ▸ Reset views (F8).",
"pause": "⏸ Pause", "pause": "⏸ Pause",
"pause_resume": "▶",
"pause_tip": "Pause the computation and the display (F7): no flow, depth or contact is computed, the picture holds its last frame, and the device stays connected for an immediate resume. A recording that spans a pause has a gap.", "pause_tip": "Pause the computation and the display (F7): no flow, depth or contact is computed, the picture holds its last frame, and the device stays connected for an immediate resume. A recording that spans a pause has a gap.",
"resume_tip": "Resume the computation and the display (F7), from the newest frame.", "resume_tip": "Resume the computation and the display (F7), from the newest frame.",
"resume_label": "▶ Resume", "resume_label": "▶ Resume",
@@ -155,8 +156,7 @@
"units_newton": "Newton", "units_newton": "Newton",
"units_raw": "Raw", "units_raw": "Raw",
"settings": "Settings", "settings": "Settings",
"settings_tip": "Show or hide the unit, record-mode and preview controls", "settings_tip": "Show or hide the unit, record-mode and preview controls"
"stopped_analysis": "analysis"
}, },
"units": { "units": {
"newton_short": "N", "newton_short": "N",
@@ -181,11 +181,24 @@
"export_identity": "file identity type {sensor_type} · SN {sn} · config {config}", "export_identity": "file identity type {sensor_type} · SN {sn} · config {config}",
"thumb_placeholder": "Product model", "thumb_placeholder": "Product model",
"family_unidentified": "Unidentified", "family_unidentified": "Unidentified",
"catalog_profile": "Profile {profile_id} · {shape} · from {origin} · SDK validation: {state}", "catalog_name": "name {name} · folder {folder}",
"catalog_profile": "Profile {profile_id} · {shape} · from {origin}",
"catalog_root": "root {origin}: {path}",
"catalog_state": "SDK validation: {state} ({note})",
"catalog_state_ok": "processing can start",
"catalog_state_degraded": "processing can start; something optional is missing",
"catalog_state_failed": "processing cannot start",
"catalog_state_invalid": "no bundle could be read here",
"catalog_state_unknown": "verdict not recognised",
"catalog_calibration_selected": "Calibration selected: {name}",
"catalog_calibration_none": "No calibration selected — raw force",
"catalog_duplicate": "Also provided by {refs}",
"catalog_shape_descriptor": "descriptor bundle", "catalog_shape_descriptor": "descriptor bundle",
"catalog_shape_compatibility": "compatibility bundle, no descriptor", "catalog_shape_compatibility": "compatibility bundle, no descriptor",
"catalog_shape_unreadable": "no readable bundle", "catalog_shape_unreadable": "no readable bundle",
"catalog_blocked": "Cannot start: {reason}" "catalog_blocked": "Cannot start: {reason}",
"catalog_warning": "Warning: {sentence}",
"catalog_error": "Error: {sentence}"
}, },
"panels": { "panels": {
"arrow": "2D image", "arrow": "2D image",
@@ -195,8 +208,8 @@
"view_recorded": "Recorded image", "view_recorded": "Recorded image",
"view_mode_tip": "Image display mode while running", "view_mode_tip": "Image display mode while running",
"replay_pipeline": "Recorded video (raw camera image)", "replay_pipeline": "Recorded video (raw camera image)",
"replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to check the algorithm, use View → Recompute forces from the video", "replay_pipeline_tip": "Replay shows the recorded raw camera image with no live arrow overlay; to get arrows and depth from analysis, use View → Analyze video",
"replay_raw_note": "A recording stores only the raw camera image: there is no flow or depth to draw, because this recording has no analysis result yet. Opening a recording runs the pass automatically, or press “Recompute from the video”; once a result exists this pane can draw arrows and the depth map", "replay_raw_note": "A recording stores only the raw camera image: there is no flow or depth to draw, because this recording has no analysis result yet. Opening a recording runs the pass automatically, or press “Analyze video”; once a result exists this pane can draw arrows and the depth map",
"frame_missing_note": "This frame carries no {fields}: there is nothing for this view to draw", "frame_missing_note": "This frame carries no {fields}: there is nothing for this view to draw",
"field_flow": "flow", "field_flow": "flow",
"field_depth": "depth", "field_depth": "depth",
@@ -227,12 +240,9 @@
"mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual.", "mesh3d_unavailable_gl_reason": "3D is not available on this machine: {reason}\nMeasuring, recording and the charts work as usual.",
"mesh3d_settings_tip": "3D display settings (view, housing, colour scale, frame rate)", "mesh3d_settings_tip": "3D display settings (view, housing, colour scale, frame rate)",
"mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses", "mesh3d_reset_tip": "Reset view: back to the framing the scene itself uses",
"analysis_pipeline": "Analysis result (one finished pass)",
"analysis_pipeline_tip": "This frame's arrows come from a stored analysis result rather than a chain running now; changing the threshold only changes which stored samples count as active.",
"analysis_empty": "the analysis result holds no frame here",
"replay_analysis_running": "this recording is being analysed: there are no arrows or depth to draw until it finishes. Progress and Cancel are on the analysis panel.", "replay_analysis_running": "this recording is being analysed: there are no arrows or depth to draw until it finishes. Progress and Cancel are on the analysis panel.",
"replay_analysis_failed": "there is no analysis result to draw: {reason}. Only the raw camera image can be shown here; once that is dealt with, Recompute from the video runs the pass again by hand.", "replay_analysis_failed": "there is no analysis result to draw: {reason}. Only the raw camera image can be shown here; once that is dealt with, Analyze video runs the pass again by hand.",
"replay_analysis_unavailable": "this is not a recording that can be analysed (its folder has no session.json, or no raw_*.mp4 segment): no pass can run, so this panel shows the raw camera image only. For forces and deformation from this video use View > Recompute forces from the video." "replay_analysis_unavailable": "this is not a recording that can be analysed (its folder has no session.json, or no raw_*.mp4 segment): no pass can run, so this panel shows the raw camera image only. For forces and deformation from this video use View > Analyze video."
}, },
"chart": { "chart": {
"fn": "FN", "fn": "FN",
@@ -250,6 +260,12 @@
"mode_browse_tip": "Display paused, capture and recording continue; drag or scroll to move the window, “back to latest” returns to live", "mode_browse_tip": "Display paused, capture and recording continue; drag or scroll to move the window, “back to latest” returns to live",
"mode_replay": "Replaying a recording (browsing)", "mode_replay": "Replaying a recording (browsing)",
"mode_replay_tip": "The charts read the open recording; capture and recording continue", "mode_replay_tip": "The charts read the open recording; capture and recording continue",
"mode_recomputed": "Recomputed from this video (browsing)",
"mode_recomputed_tip": "The charts read the force the analysis pass recomputed from this video and stored in its result. This is not the recorded forces.csv — no device measured these numbers",
"mode_no_trace": "No force trace — {reason}",
"mode_no_trace_tip": "There is nothing to plot, and the reason is a fact about the input rather than a loading state",
"trace_not_analysed": "this video has not been analysed yet",
"trace_no_force": "this profile's chain produced no force for this video",
"return_latest": "Back to latest", "return_latest": "Back to latest",
"return_latest_tip": "Leave history browsing and follow the newest data again", "return_latest_tip": "Leave history browsing and follow the newest data again",
"overview": "Whole session", "overview": "Whole session",
@@ -272,15 +288,14 @@
"browse_only_tip": "Drag or scroll on the chart to browse history" "browse_only_tip": "Drag or scroll on the chart to browse history"
}, },
"playback": { "playback": {
"title": "Replay recording",
"open": "Open a recorded video…", "open": "Open a recorded video…",
"video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)", "video_filter": "Recorded video (*.mp4 *.avi *.mov *.mkv);;All files (*)",
"no_video": "This recording has no usable video segment", "no_video": "This recording has no usable video segment",
"open_failed": "Could not open that recording: {error}", "open_failed": "Could not open that recording: {error}",
"not_a_recording": "“{name}” is not a recording: its folder has no forces.csv (a recording is the video plus forces.csv and session.json). Replay needs a recording; to just watch this video, use “Recompute forces from the video”.", "not_a_recording": "“{name}” is not a recording: its folder has no forces.csv (a recording is the video plus forces.csv and session.json). Replay needs a recording; to just watch this video, open it as a video or use View ▸ Analyze video.",
"video_only_opened": "“{name}” has no recording beside it (no forces.csv), so it is playing as a video: the picture and the timeline work, the force charts are empty. For forces and deformation use View ▸ Recompute forces from the video.", "video_only_opened": "“{name}” has no recording beside it (no forces.csv), so it is playing as a video: the picture and the timeline work, the force charts are empty. For forces and deformation use View ▸ Analyze video.",
"video_only_notice": "This video has no recording beside it (no forces.csv): it plays, but the force charts and the recording details are empty.", "video_only_notice": "This video has no recording beside it (no forces.csv): it plays, but the force charts and the recording details are empty.",
"video_only_panel": "This is not a recording, and this video cannot be decoded (no forces.csv beside it, and it does not read as playable video)\nYou can still try View ▸ Recompute forces from the video", "video_only_panel": "This is not a recording, and this video cannot be decoded (no forces.csv beside it, and it does not read as playable video)\nYou can still try View ▸ Analyze video",
"empty_recording": "This recording has no playable picture (no raw_*.mp4 segment), so it cannot be replayed", "empty_recording": "This recording has no playable picture (no raw_*.mp4 segment), so it cannot be replayed",
"none_found": "No recording found under outputs/recordings", "none_found": "No recording found under outputs/recordings",
"frame_dropped": "This frame never reached the video (the record queue overflowed)", "frame_dropped": "This frame never reached the video (the record queue overflowed)",
@@ -295,17 +310,28 @@
"warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ", "warn_out_of_order": "this recording was written by an older version: its rows are not in time order, so the replay re-sorts them and window edges may differ",
"analysis_empty": "this analysis result holds no frame to show", "analysis_empty": "this analysis result holds no frame to show",
"analysis_unreadable": "this frame cannot be read ({error})", "analysis_unreadable": "this frame cannot be read ({error})",
"analysis_complete": "replaying from the analysis result: {frames} frames, cursor on signal time (the recording's own frames.csv)",
"analysis_reused": "an analysis result was already stored and is being replayed: {frames} frames",
"analysis_failed": "the analysis did not finish ({reason}): playback is not served. Re-run it, or turn 「从视频重算」 off to read the recorded forces.csv",
"analysis_cancelled": "the analysis was cancelled: no complete result was written, so no playback is served", "analysis_cancelled": "the analysis was cancelled: no complete result was written, so no playback is served",
"analysis_no_source": "this recording has no video segment to analyse", "analysis_no_source": "this recording has no video segment to analyse",
"analysis_busy": "an analysis is already running: wait for it, or cancel it", "analysis_busy": "an analysis is already running: wait for it, or cancel it",
"analysis_grid_mismatch": "the current display settings moved the sample grid ({keys}); the stored candidate locations cannot serve it, so the replay keeps drawing the result's own grid", "analysis_grid_mismatch": "the current display settings moved the sample grid ({keys}); the stored candidate locations cannot serve it, so the replay keeps drawing the result's own grid",
"analysis_no_chain": "this recording does not say which sensor and configuration produced it ({error}), so no result can be read and no pass can be run" "analysis_no_chain": "this recording does not say which sensor and configuration produced it ({error}), so no result can be read and no pass can be run",
"analysis_setup_declined": "no analysis was set up for this video: the setup prompt was dismissed, so there is nothing to draw from. Use View ▸ Analysis setup… or open the video again"
},
"video_analysis": {
"title": "Analyse this video",
"video": "{name} — {frames} frames at {fps} fps ({seconds} s). It has no recording beside it (no forces.csv, no frames.csv), so the pass will work from a frame index generated for it.",
"profile": "Processing profile",
"calibration": "Calibration",
"calibration_default": "Profile default — {name}",
"calibration_none": "No calibration — raw force",
"calibration_file": "Chosen file — {name}",
"calibration_browse": "Choose a file…",
"calibration_filter": "Force calibration (*.json);;All files (*)",
"reference": "Reference frame",
"reference_time": "{seconds} s",
"blocked": "This profile cannot be used: {reason}"
}, },
"record": { "record": {
"label": "Record",
"mode_video_data": "Video + data", "mode_video_data": "Video + data",
"mode_data_only": "Data only", "mode_data_only": "Data only",
"mode_tip": "Video records the pre-processing camera input (no arrows or text overlays). MP4 is a lossy container, not a lossless copy of the raw data.", "mode_tip": "Video records the pre-processing camera input (no arrows or text overlays). MP4 is a lossy container, not a lossless copy of the raw data.",
@@ -319,7 +345,6 @@
"finished": "Recording finished — {frames} frames / {segments} segment(s) in {path}", "finished": "Recording finished — {frames} frames / {segments} segment(s) in {path}",
"finished_with_problems": "Recording finished with problems: {detail}", "finished_with_problems": "Recording finished with problems: {detail}",
"closing": "Closing the recording files…", "closing": "Closing the recording files…",
"failed_state": "The recording failed (see the summary and the log)",
"alarm": "Recording problem: {detail}", "alarm": "Recording problem: {detail}",
"state_idle": "No recording", "state_idle": "No recording",
"state_recording": "Recording {duration}", "state_recording": "Recording {duration}",
@@ -373,9 +398,6 @@
"col_fy": "FY mean", "col_fy": "FY mean",
"col_stability": "Stability", "col_stability": "Stability",
"col_state": "State", "col_state": "State",
"col_raw": "Raw mean",
"col_n": "n",
"col_std": "σ",
"state_ok": "OK", "state_ok": "OK",
"state_excluded": "Excluded", "state_excluded": "Excluded",
"state_validation": "Validation", "state_validation": "Validation",
@@ -403,13 +425,11 @@
"point_samples_value": "{valid} valid / {total} total", "point_samples_value": "{valid} valid / {total} total",
"point_axes": "Three-axis mean ± std", "point_axes": "Three-axis mean ± std",
"point_residual": "Fit residual", "point_residual": "Fit residual",
"col_actions": "",
"recording": "Recording calibration samples…", "recording": "Recording calibration samples…",
"recording_stop_tip": "Click to cancel this calibration recording (always available)", "recording_stop_tip": "Click to cancel this calibration recording (always available)",
"record_ok": "Recorded weight={weight} raw={raw}", "record_ok": "Recorded weight={weight} raw={raw}",
"record_failed_few": "Recording failed — too few samples", "record_failed_few": "Recording failed — too few samples",
"export_ok": "Exported {path}", "export_ok": "Exported {path}",
"export_local": "Also wrote {path}",
"export_warnings": "Skipped: {detail}", "export_warnings": "Skipped: {detail}",
"export_failed": "Export failed: {error}", "export_failed": "Export failed: {error}",
"export_no_model": "Nothing to export: {detail}", "export_no_model": "Nothing to export: {detail}",
@@ -484,7 +504,6 @@
"contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.", "contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.",
"quantity_flow_magnitude": "Optical flow magnitude", "quantity_flow_magnitude": "Optical flow magnitude",
"quantity_depth_response": "Depth response", "quantity_depth_response": "Depth response",
"mode_surface": "surface colouring",
"preset_default": "Default view", "preset_default": "Default view",
"legend_effective": "{quantity} [{unit}] · {mode} scale {lower} – {upper} · {quality}", "legend_effective": "{quantity} [{unit}] · {mode} scale {lower} – {upper} · {quality}",
"legend_auto": "automatic", "legend_auto": "automatic",
@@ -515,9 +534,9 @@
"source_live": "Live acquisition", "source_live": "Live acquisition",
"source_live_tip": "The picture comes from the data being acquired right now.", "source_live_tip": "The picture comes from the data being acquired right now.",
"source_replay": "Replay: the recording stores no 3D field", "source_replay": "Replay: the recording stores no 3D field",
"source_replay_tip": "A recording stores the raw camera image and the forces, not a per-frame deformation field. Turn on “Recompute forces from the video” and this pane will show the deformation recomputed from the recording.", "source_replay_tip": "A recording stores the raw camera image and the forces, not a per-frame deformation field. Turn on Analyze video and this pane will show the deformation from the analysis result.",
"source_recompute": "Recomputed from the recording — not the recorded values", "source_recompute": "From the analysis result — not the recorded values",
"source_recompute_tip": "This is what the current algorithm version says about the recorded footage: it is a check on the algorithm, not the measurement that was recorded at the time.", "source_recompute_tip": "This is what the current algorithm version says about the footage in the stored analysis result: it is not the measurement that was recorded at the time.",
"severity_info": "Note", "severity_info": "Note",
"severity_warning": "Warning", "severity_warning": "Warning",
"severity_error": "Error", "severity_error": "Error",
@@ -527,21 +546,15 @@
}, },
"analysis": { "analysis": {
"title": "Analysing the recording", "title": "Analysing the recording",
"subtitle": "once only: scrubbing, playing and stepping afterwards are reads",
"stage_open": "open chain",
"stage_frames": "frame by frame",
"stage_write": "write result",
"frames": "{written} / {expected} frames", "frames": "{written} / {expected} frames",
"segment": "segment {index} / {count}", "segment": "segment {index} / {count}",
"elapsed": "{seconds:.1f} s elapsed", "elapsed": "{seconds:.1f} s elapsed",
"cancel": "Cancel", "cancel": "Cancel",
"cancelling": "Cancelling…", "cancelling": "Cancelling…",
"cancel_tip": "Cancel this pass: the frames already written stay, but the result is marked incomplete and is never played back as if it were whole", "cancel_tip": "Cancel this pass: the frames already written stay, but the result is marked incomplete and is never played back as if it were whole",
"reused": "a stored result is already there — nothing to recompute",
"complete": "analysis finished: {frames} frames in {seconds:.1f} s", "complete": "analysis finished: {frames} frames in {seconds:.1f} s",
"cancelled": "analysis cancelled ({frames} frames written; the result is marked incomplete)", "cancelled": "analysis cancelled ({frames} frames written; the result is marked incomplete)",
"failed": "analysis failed: {reason}", "failed": "analysis failed: {reason}",
"incomplete": "the result is incomplete: {reason}",
"blocked": "a session is running: stop it before analysing" "blocked": "a session is running: stop it before analysing"
} }
} }
+76 -63
View File
@@ -2,15 +2,14 @@
"app": { "app": {
"title": "Orisys Studio", "title": "Orisys Studio",
"about_title": "关于", "about_title": "关于",
"about_body": "Orisys Studio 桌面应用\n版本: {app_version}", "about_body": "Orisys Studio 桌面应用\n版本: {app_version}"
"scaffold_hint": "脚手架界面 — 采集功能稍后加入"
}, },
"brand": { "brand": {
"name": "ORISYS", "name": "ORISYS",
"tagline": "叠动科技", "tagline": "叠动科技",
"nav_live": "实时监测", "nav_live": "实时",
"nav_live_tip": "当前视图:实时监测。点击可退出历史浏览。", "nav_live_tip": "当前视图:实时监测。点击可退出历史浏览。",
"nav_playback": "历史回放", "nav_playback": "回放",
"nav_playback_tip": "打开一段录制:视频、曲线与同一游标", "nav_playback_tip": "打开一段录制:视频、曲线与同一游标",
"nav_calibration": "标定", "nav_calibration": "标定",
"nav_calibration_tip": "显示标定面板" "nav_calibration_tip": "显示标定面板"
@@ -18,7 +17,7 @@
"menu": { "menu": {
"file": "文件", "file": "文件",
"open_video": "打开视频…", "open_video": "打开视频…",
"reveal_config": "在资源管理器中显示配置…", "reveal_config": "显示传感器配置…",
"playback": "打开录制回放…", "playback": "打开录制回放…",
"playback_latest": "回放最近一次录制", "playback_latest": "回放最近一次录制",
"view": "视图", "view": "视图",
@@ -33,15 +32,17 @@
"scene3d_settings": "三维显示设置…", "scene3d_settings": "三维显示设置…",
"show_fps": "显示 FPS", "show_fps": "显示 FPS",
"show_diagnostics": "诊断信息", "show_diagnostics": "诊断信息",
"raw_auto_zero": "Finger raw 启动归零", "raw_auto_zero": "启动时 raw 自动归零",
"raw_auto_zero_tip": "仅影响数字卡展示:启动后自动估计无载偏置与死区。曲线、统计与 CSV 仍为测量值。", "raw_auto_zero_tip": "仅影响数字卡展示:启动后自动估计无载偏置与死区。曲线、统计与 CSV 仍为测量值。",
"raw_reestimate": "重新估计 raw 基线…", "raw_reestimate": "重新估计 raw 基线…",
"raw_reestimate_tip": "丢弃当前展示基线,从接下来的无载样本重新估计(仅数字卡)。", "raw_reestimate_tip": "丢弃当前展示基线,从接下来的无载样本重新估计(仅数字卡)。",
"settings": "设置区", "settings": "设置区",
"plot_settings": "绘图设置…", "plot_settings": "绘图设置…",
"rotate_arrows": "旋转箭头图 90°", "rotate_arrows": "旋转箭头图 90°",
"recompute": "由视频重算力值", "recompute": "分析视频",
"recompute_tip": "关闭:曲线读录制的 forces.csv(当时测得的值)。打开:把录制的视频重新跑一遍视觉管线(算法复核,只能顺序前进)。", "recompute_tip": "打开:有兼容的分析结果就直接用,没有就跑一遍分析。关闭:离开分析,有 forces.csv 时曲线读录制值。要改配置、标定或参考帧请用「视图 ▸ 分析设置…」。",
"analysis_setup": "分析设置…",
"analysis_setup_tip": "为这段视频选择配置、标定与参考帧,然后跑(或重跑)分析。",
"run": "运行 / 停止", "run": "运行 / 停止",
"stop": "取消采集", "stop": "取消采集",
"preview": "预览 / 停止预览", "preview": "预览 / 停止预览",
@@ -70,13 +71,13 @@
"plot_shear_raw_max": "原始剪切上限", "plot_shear_raw_max": "原始剪切上限",
"plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程", "plot_shear_raw_max_tip": "未标定(原始)模式下的剪切力 FX/FY 纵轴 ±上限。0 = 自动量程",
"plot_deadband_n": "归零阈值(N)", "plot_deadband_n": "归零阈值(N)",
"plot_deadband_n_tip": "牛顿模式下的显示死区下限:读数在阈值内即显示为 0,曲线压平在零线上。0 = 自动(用启动静默窗口估计的噪声,牛顿模式无固定下限)。只影响显示,记录与 forces.csv 仍是实测值", "plot_deadband_n_tip": "牛顿模式下的显示死区下限:读数在阈值内即显示为 0,曲线压平在零线上。0 = 自动(用启动静默窗口估计的噪声,牛顿模式无固定下限)。只影响显示,记录与 forces.csv 仍保留测量值",
"plot_deadband_raw": "归零阈值(原始)", "plot_deadband_raw": "归零阈值(原始)",
"plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示", "plot_deadband_raw_tip": "原始模式下的显示死区下限(原始计数)。0 = 自动(默认下限 50 计数)。标定数据可疑时可手动放大,只影响显示",
"reset_defaults": "恢复默认", "reset_defaults": "恢复默认",
"plot_orientation_group": "手指 2D 显示方向(只影响 2D 画面;3D 视图仍用设计坐标系)", "plot_orientation_group": "手指 2D 显示方向(只影响 2D 画面;3D 视图仍用设计坐标系)",
"plot_orientation": "方向", "plot_orientation": "方向",
"plot_orientation_tip": "手指 2D 画布(展开域)的显示方向。默认 180° 是按展开图与设计坐标系推导出来的:画布的 +x 是设计系的 +X、屏幕向上是 −Z,与 3D 默认视图的 −X / +Z 正好都相反,所以要把画面转半圈,箭头左右才与 3D 一致。只影响显示,不改变记录与存储的数组", "plot_orientation_tip": "手指 2D 画布(展开域)的显示方向。默认 180° 是按展开图与设计坐标系推导出来的:画布的 +x 是设计系的 +X、屏幕向上是 −Z,与 3D 默认视图的 −X / +Z 正好都相反,因此需要旋转 180° 才能使箭头方向与 3D 视图一致。。只影响显示,不改变记录与存储的数组",
"plot_orientation_note": "只影响 2D 手指画布的显示:存储的光流、有效掩膜与录制数据始终保持域本身的朝向。3D 视图不受影响(它的坐标系是设计导出坐标系)", "plot_orientation_note": "只影响 2D 手指画布的显示:存储的光流、有效掩膜与录制数据始终保持域本身的朝向。3D 视图不受影响(它的坐标系是设计导出坐标系)",
"orientation_identity": "原始(不旋转)", "orientation_identity": "原始(不旋转)",
"orientation_rotate180": "旋转 180°(默认)", "orientation_rotate180": "旋转 180°(默认)",
@@ -91,7 +92,7 @@
"filter_median": "中值窗口", "filter_median": "中值窗口",
"filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟", "filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟",
"filter_lowpass": "低通截止", "filter_lowpass": "低通截止",
"filter_lowpass_tip": "力读数低通截止频率(Hz)。0 = 关。必须在奈奎斯特频率(帧率的一半)以下,否则设备不会滤", "filter_lowpass_tip": "力读数低通截止频率(Hz)。0 = 关。必须在奈奎斯特频率(帧率的一半)以下,否则设备不会进行滤波",
"filter_order": "低通阶数", "filter_order": "低通阶数",
"filter_order_tip": "低通滤波器阶数(1–6)。越高越陡,相位失真也越大", "filter_order_tip": "低通滤波器阶数(1–6)。越高越陡,相位失真也越大",
"filter_rate": "采样率", "filter_rate": "采样率",
@@ -101,7 +102,7 @@
"filter_measurement_note": "光流 EMA 属于“测量”设置:改了它,本次录制与之前的不可比。", "filter_measurement_note": "光流 EMA 属于“测量”设置:改了它,本次录制与之前的不可比。",
"filter_locked": "录制中不能更改滤波;下一次采集生效。", "filter_locked": "录制中不能更改滤波;下一次采集生效。",
"filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。", "filter_nyquist": "注意:低通 {cutoff} Hz 已达到或超过奈奎斯特频率 {nyquist} Hz(采样率 {rate} Hz)——设备不会滤。请调低截止频率或固定更高的采样率。",
"filter_off": "当前不滤波(原始读数)", "filter_off": "未滤波(原始读数)",
"ok": "确定", "ok": "确定",
"cancel": "取消", "cancel": "取消",
"close": "关闭" "close": "关闭"
@@ -113,8 +114,8 @@
"use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)", "use_camera_tip": "回到摄像机采集(当前的数据源是视频文件)",
"choose_video": "选择录制视频…", "choose_video": "选择录制视频…",
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)", "choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
"recompute": "由视频重算…", "recompute": "分析视频…",
"recompute_tip": "对这段录像重新跑一遍算法:2D 面板显示处理后的图像(录像本身只存了原始相机图像),曲线与三维也改用重算结果。可以随时「停止」。", "recompute_tip": "有兼容的分析结果就直接用,没有就跑一遍分析:2D 面板可画箭头与深度,曲线可读结果中的力。分析进行中可随时「停止」。要改配置、标定或参考帧请用「视图 ▸ 分析设置…」。",
"replay_view_only": "请在「历史回放」视图里打开录像", "replay_view_only": "请在「历史回放」视图里打开录像",
"preview": "预览", "preview": "预览",
"preview_stop": "停止预览", "preview_stop": "停止预览",
@@ -122,17 +123,18 @@
"stop": "停止", "stop": "停止",
"run_label": "▶ 运行", "run_label": "▶ 运行",
"stop_label": "■ 停止", "stop_label": "■ 停止",
"cancel_label": "■ 取消连接", "cancel_label": "■ 断开",
"cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)", "cancel_tip": "中断连接(连接卡住时随时可用,快捷键 Esc)",
"run_unavailable": "当前不可运行", "run_unavailable": "当前不可运行",
"run_disabled_video": "请先选择一个视频文件", "run_disabled_video": "请先选择一个视频文件",
"run_video_in_replay": "请在「历史回放」视图里播放视频(用播放与曲线游标)",
"run_disabled_sensor": "此传感器类型暂不支持运行", "run_disabled_sensor": "此传感器类型暂不支持运行",
"playback": "▶ 播放", "playback": "▶ 播放",
"playback_pause": "⏸ 暂停", "playback_pause": "⏸ 暂停",
"playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停", "playback_tip": "按 1× 播放这段录制(空格键同效);拖动游标即暂停",
"playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)", "playback_pause_tip": "暂停播放(拖动游标、框选或滚轮也会暂停)",
"playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)", "playback_disabled": "先打开一段录制视频(左上「选择录制视频…」)",
"playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请用 视图 ▸ 从视频重算力值", "playback_needs_recording": "这段视频旁边没有记录(缺 forces.csv):回放需要一条记录;只想看视频请直接打开视频,或用 视图 ▸ 分析视频",
"playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)", "playback_video_only": "播放这段视频(没有配套记录:可以看画面,力曲线为空)",
"reset_sensor": "重置传感器", "reset_sensor": "重置传感器",
"reset_sensor_tip": "把传感器追踪器归零(清零),这是设备自己的零点。归零同时会重新估计显示基线:曲线与数字卡按之后的静默样本重新学习零点;曲线历史保留 —— 要清空视图请用「视图 → 复位视图」(F8)。", "reset_sensor_tip": "把传感器追踪器归零(清零),这是设备自己的零点。归零同时会重新估计显示基线:曲线与数字卡按之后的静默样本重新学习零点;曲线历史保留 —— 要清空视图请用「视图 → 复位视图」(F8)。",
@@ -140,13 +142,12 @@
"reset_display": "复位显示", "reset_display": "复位显示",
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。", "reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
"pause": "⏸ 暂停", "pause": "⏸ 暂停",
"pause_resume": "▶",
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。", "pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。", "resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
"resume_label": "▶ 继续", "resume_label": "▶ 继续",
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。", "resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
"stop_playback": "停止", "stop_playback": "停止",
"stop_playback_tip": "停止回放:无论是普通播放还是「从视频重算」通道,都在这里停下(Esc 同效)。", "stop_playback_tip": "停止回放:无论是普通播放还是「分析视频」通道,都在这里停下(Esc 同效)。",
"stopped": "已停止:{what}", "stopped": "已停止:{what}",
"stopped_playback": "回放", "stopped_playback": "回放",
"stopped_pass": "重算通道", "stopped_pass": "重算通道",
@@ -155,8 +156,7 @@
"units_newton": "牛顿", "units_newton": "牛顿",
"units_raw": "原始", "units_raw": "原始",
"settings": "设置", "settings": "设置",
"settings_tip": "展开或收起单位、记录模式与预览", "settings_tip": "展开或收起单位、记录模式与预览"
"stopped_analysis": "分析"
}, },
"units": { "units": {
"newton_short": "N", "newton_short": "N",
@@ -181,11 +181,24 @@
"export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}", "export_identity": "文件身份 类型 {sensor_type} · SN {sn} · 配置 {config}",
"thumb_placeholder": "产品型号", "thumb_placeholder": "产品型号",
"family_unidentified": "未识别", "family_unidentified": "未识别",
"catalog_profile": "配置档案 {profile_id} · {shape} · 来源 {origin} · SDK 校验:{state}", "catalog_name": "名称 {name} · 文件夹 {folder}",
"catalog_profile": "配置档案 {profile_id} · {shape} · 来源 {origin}",
"catalog_root": "根目录({origin}):{path}",
"catalog_state": "SDK 校验:{state}({note})",
"catalog_state_ok": "可以开始处理",
"catalog_state_degraded": "可以开始处理;有可选项缺失",
"catalog_state_failed": "无法开始处理",
"catalog_state_invalid": "此目录没有可读取的配置包",
"catalog_state_unknown": "无法识别的校验结果",
"catalog_calibration_selected": "已选择标定:{name}",
"catalog_calibration_none": "未选择标定——使用原始力",
"catalog_duplicate": "同时由 {refs} 提供",
"catalog_shape_descriptor": "描述符配置包", "catalog_shape_descriptor": "描述符配置包",
"catalog_shape_compatibility": "兼容配置包(无描述符)", "catalog_shape_compatibility": "兼容配置包(无描述符)",
"catalog_shape_unreadable": "没有可读取的配置包", "catalog_shape_unreadable": "没有可读取的配置包",
"catalog_blocked": "无法启动:{reason}" "catalog_blocked": "无法启动:{reason}",
"catalog_warning": "警告:{sentence}",
"catalog_error": "错误:{sentence}"
}, },
"panels": { "panels": {
"arrow": "2D 图像", "arrow": "2D 图像",
@@ -195,8 +208,8 @@
"view_recorded": "原始图像", "view_recorded": "原始图像",
"view_mode_tip": "运行时的图像显示模式", "view_mode_tip": "运行时的图像显示模式",
"replay_pipeline": "录制视频(原始相机图像)", "replay_pipeline": "录制视频(原始相机图像)",
"replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要复核算法请用「视图 → 由视频重算力值」", "replay_pipeline_tip": "回放显示的是录制下来的原始相机图像,不含实时箭头叠加;要看分析得到的箭头与深度请用「视图 → 分析视频」",
"replay_raw_note": "录像只保存原始相机图像:这里没有光流/深度可画,因为这个录像还没有分析结果。打开录像会自动跑一遍分析,也可以点「由视频重算」手动生成;有结果后本面板就能画箭头和深度图", "replay_raw_note": "录像只保存原始相机图像:这里没有光流/深度可画,因为这个录像还没有分析结果。打开录像会自动跑一遍分析,也可以点「分析视频」手动生成;有结果后本面板就能画箭头和深度图",
"frame_missing_note": "本帧没有{fields}:这个视图这一帧没有数据可画", "frame_missing_note": "本帧没有{fields}:这个视图这一帧没有数据可画",
"field_flow": "光流", "field_flow": "光流",
"field_depth": "深度", "field_depth": "深度",
@@ -207,7 +220,7 @@
"arrow_empty_tip": "运行或预览后在此显示形变箭头图", "arrow_empty_tip": "运行或预览后在此显示形变箭头图",
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)", "depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
"depth_scale": "深度色标 {low} – {high}", "depth_scale": "深度色标 {low} – {high}",
"depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标低端色是一帧真实的零响应,不是缺数据", "depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标最低颜色是真实的零响应,不是缺数据",
"arrow_scale": "箭头按位移放大 ×{factor}", "arrow_scale": "箭头按位移放大 ×{factor}",
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。", "arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
"arrow_pipeline": "原始图像 → 算法输出", "arrow_pipeline": "原始图像 → 算法输出",
@@ -227,12 +240,9 @@
"mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。", "mesh3d_unavailable_gl_reason": "本机无法使用三维显示:{reason}\n采集、记录与力曲线照常工作。",
"mesh3d_settings_tip": "三维显示设置(视角、外壳、色标、显示频率)", "mesh3d_settings_tip": "三维显示设置(视角、外壳、色标、显示频率)",
"mesh3d_reset_tip": "复位视角:回到场景自带的取景", "mesh3d_reset_tip": "复位视角:回到场景自带的取景",
"analysis_pipeline": "分析结果(已跑完的一遍)",
"analysis_pipeline_tip": "这一帧的箭头来自存下来的分析结果,不是此刻在跑的链路;换阈值只改变哪些样本算「有效」,不会重算。",
"analysis_empty": "分析结果里没有这一帧",
"replay_analysis_running": "正在分析这个录像:跑完之前这里没有箭头/深度可画,进度与取消在分析面板上。", "replay_analysis_running": "正在分析这个录像:跑完之前这里没有箭头/深度可画,进度与取消在分析面板上。",
"replay_analysis_failed": "没有可画的分析结果:{reason}。这里只能显示原始相机图像;处理完之后可以点「由视频重算」再跑一遍。", "replay_analysis_failed": "没有可画的分析结果:{reason}。这里只能显示原始相机图像;处理完之后可以点「分析视频」再跑一遍。",
"replay_analysis_unavailable": "这不是一条可分析的录像(同目录没有 session.json,或没有 raw_*.mp4 分段):没有能跑的分析,本面板只能显示原始相机图像。要从这段视频得到力值/形变,请用 视图 ▸ 从视频重算力值。" "replay_analysis_unavailable": "这不是一条可分析的录像(同目录没有 session.json,或没有 raw_*.mp4 分段):没有能跑的分析,本面板只能显示原始相机图像。要从这段视频得到力值/形变,请用 视图 ▸ 分析视频。"
}, },
"chart": { "chart": {
"fn": "FN", "fn": "FN",
@@ -250,6 +260,12 @@
"mode_browse_tip": "显示暂停,采集与记录继续;拖动或滚轮改变窗口,点「返回最新」回到实时", "mode_browse_tip": "显示暂停,采集与记录继续;拖动或滚轮改变窗口,点「返回最新」回到实时",
"mode_replay": "回放录制(浏览)", "mode_replay": "回放录制(浏览)",
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续", "mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
"mode_recomputed": "从这段视频重算(浏览)",
"mode_recomputed_tip": "曲线读的是分析过程从这段视频重算、并存入结果的力。这不是记录的 forces.csv——没有任何设备测过这些数字",
"mode_no_trace": "没有力曲线 — {reason}",
"mode_no_trace_tip": "没有可画的东西,而原因是输入本身的事实,不是一种加载状态",
"trace_not_analysed": "这段视频还没有分析过",
"trace_no_force": "这套配置的链路对这段视频没有产生力",
"return_latest": "返回最新", "return_latest": "返回最新",
"return_latest_tip": "退出历史浏览,恢复跟随最新数据", "return_latest_tip": "退出历史浏览,恢复跟随最新数据",
"overview": "全程总览", "overview": "全程总览",
@@ -260,11 +276,11 @@
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)", "settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
"selection": "选区 {duration}s / {samples} 点 — {detail}", "selection": "选区 {duration}s / {samples} 点 — {detail}",
"selection_summary": "选区 {duration}s / {samples} 点", "selection_summary": "选区 {duration}s / {samples} 点",
"selection_detail": "均 {mean} · σ {std}", "selection_detail": "均值 {mean} · σ {std}",
"selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值", "selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值",
"selection_empty": "选区无有效样本", "selection_empty": "选区无有效样本",
"value_unavailable": "不可用", "value_unavailable": "不可用",
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会用原始值冒充)", "value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会以原始值替代)",
"zero_zone": "零区", "zero_zone": "零区",
"zero_zone_tip": "展示归零后的死区内读数;曲线、统计与 CSV 仍为原始测量值", "zero_zone_tip": "展示归零后的死区内读数;曲线、统计与 CSV 仍为原始测量值",
"unit_toggle": "切换单位", "unit_toggle": "切换单位",
@@ -272,40 +288,50 @@
"browse_only_tip": "拖动或滚轮即可进入历史浏览" "browse_only_tip": "拖动或滚轮即可进入历史浏览"
}, },
"playback": { "playback": {
"title": "录制回放",
"open": "打开录制视频…", "open": "打开录制视频…",
"video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)", "video_filter": "录制的视频 (*.mp4 *.avi *.mov *.mkv);;所有文件 (*)",
"no_video": "这段录制没有可用的视频分段", "no_video": "这段录制没有可用的视频分段",
"open_failed": "无法打开该录制: {error}", "open_failed": "无法打开该录制: {error}",
"not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请用「从视频重算力值」。", "not_a_recording": "「{name}」不是一条记录:同目录没有 forces.csv(一条记录 = 录像 + forces.csv + session.json)。回放需要记录;只想看这段视频请直接打开视频,或用「分析视频」。",
"video_only_opened": "「{name}」没有配套记录(无 forces.csv),已按视频播放:可以看画面与拖时间轴,力曲线为空。要得助力值/形变请用 视图 ▸ 从视频重算力值。", "video_only_opened": "「{name}」没有配套记录(无 forces.csv),已按视频播放:可以看画面与拖时间轴,力曲线为空。要得到力值/形变,请使用 视图 ▸ 分析视频。",
"video_only_notice": "这段视频没有配套记录(同目录无 forces.csv):可以播放,但力曲线与记录信息为空。", "video_only_notice": "这段视频没有配套记录(同目录无 forces.csv):可以播放,但力曲线与记录信息为空。",
"video_only_panel": "这不是一条记录,而且无法解码这段视频(没有 forces.csv,也不像可播放的视频文件)\n可以试试 视图 ▸ 从视频重算力值", "video_only_panel": "这不是一条记录,而且无法解码这段视频(没有 forces.csv,也不像可播放的视频文件)\n可以试试 视图 ▸ 分析视频",
"empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放", "empty_recording": "这条记录没有可播放的画面(没有 raw_*.mp4 视频分段),因此无法回放",
"none_found": "outputs/recordings 下没有找到录制", "none_found": "outputs/recordings 下没有找到录制",
"frame_dropped": "该帧未被写入视频(记录队列曾溢出)", "frame_dropped": "该帧未被写入视频(记录队列曾溢出)",
"frame_unreadable": "该帧无法从视频中解码", "frame_unreadable": "该帧无法从视频中解码",
"warnings": "回放提示: {detail}", "warnings": "回放提示: {detail}",
"decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)", "decimated": "该录制较长,曲线为降采样视图(显示 {shown} / 共 {total} 行;区间数据按需从磁盘读取)",
"warn_config_changed": "配置文件与录制时不一致:由视频重算的结果可能与当时不同", "warn_config_changed": "配置文件与录制时不一致:分析结果可能与当时不同",
"warn_sdk_changed": "SDK 版本与录制时不一致:由视频重算的结果可能不同", "warn_sdk_changed": "SDK 版本与录制时不一致:分析结果可能不同",
"warn_config_missing": "找不到配置哈希,无法校验重算一致性", "warn_config_missing": "找不到配置哈希,无法校验重算一致性",
"warn_rows_skipped": "有无法解析的 CSV 行被跳过", "warn_rows_skipped": "有无法解析的 CSV 行被跳过",
"warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)", "warn_frames_dropped": "有帧在队列溢出时丢失,仅保留索引记录(回放显示为“该帧未录制”)",
"warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同", "warn_out_of_order": "该录制由旧版本写入,行序不是时间序;回放已按时间重排,区间边界可能与当时不同",
"analysis_empty": "这个分析结果里没有可显示的帧", "analysis_empty": "这个分析结果里没有可显示的帧",
"analysis_unreadable": "这一帧读不出来({error})", "analysis_unreadable": "这一帧读不出来({error})",
"analysis_complete": "已用分析结果回放:{frames} 帧,游标按信号时间(录制自己的 frames.csv)",
"analysis_reused": "找到了现成的分析结果,直接回放:{frames} 帧",
"analysis_failed": "分析未完成({reason}):不提供回放。可以重新分析,或关闭「从视频重算」看录制的 forces.csv",
"analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放", "analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放",
"analysis_no_source": "这段录制没有可用于分析的视频分段", "analysis_no_source": "这段录制没有可用于分析的视频分段",
"analysis_busy": "分析进行中:先等待或取消当前这一遍", "analysis_busy": "分析进行中:先等待或取消当前这一遍",
"analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制", "analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制",
"analysis_no_chain": "这条录像没有说明用哪套传感器与配置录下来({error}),所以既读不出结果也重算不了" "analysis_no_chain": "这条录像没有说明用哪套传感器与配置录下来({error}),因此既无法读取结果,也无法重算",
"analysis_setup_declined": "这段视频没有建立分析:设置提示被关掉了,因此没有可画的东西。用「视图 ▸ 分析设置…」或重新打开视频即可分析"
},
"video_analysis": {
"title": "分析这段视频",
"video": "{name} — {frames} 帧 @ {fps} fps({seconds} 秒)。它旁边没有录制(没有 forces.csv、没有 frames.csv),因此这一遍会按为它生成的帧索引来跑。",
"profile": "处理配置",
"calibration": "标定",
"calibration_default": "配置默认 — {name}",
"calibration_none": "不标定 — 原始力",
"calibration_file": "已选文件 — {name}",
"calibration_browse": "选择文件…",
"calibration_filter": "力标定 (*.json);;所有文件 (*)",
"reference": "参考帧",
"reference_time": "{seconds} 秒",
"blocked": "这套配置不能用:{reason}"
}, },
"record": { "record": {
"label": "记录",
"mode_video_data": "视频 + 数据", "mode_video_data": "视频 + 数据",
"mode_data_only": "仅数据", "mode_data_only": "仅数据",
"mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。", "mode_tip": "视频记录的是处理前的相机输入图像(不含箭头与文字叠加)。MP4 是有损封装,不等于无损原始数据。",
@@ -319,7 +345,6 @@
"finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}", "finished": "记录完成 — {frames} 帧 / {segments} 段,已写入 {path}",
"finished_with_problems": "记录完成,但存在问题: {detail}", "finished_with_problems": "记录完成,但存在问题: {detail}",
"closing": "正在关闭记录文件…", "closing": "正在关闭记录文件…",
"failed_state": "记录已失败(详见摘要与日志)",
"alarm": "记录异常: {detail}", "alarm": "记录异常: {detail}",
"state_idle": "尚未记录", "state_idle": "尚未记录",
"state_recording": "记录中 {duration}", "state_recording": "记录中 {duration}",
@@ -373,9 +398,6 @@
"col_fy": "FY 均值", "col_fy": "FY 均值",
"col_stability": "稳定性", "col_stability": "稳定性",
"col_state": "状态", "col_state": "状态",
"col_raw": "原始均值",
"col_n": "n",
"col_std": "σ",
"state_ok": "正常", "state_ok": "正常",
"state_excluded": "已排除", "state_excluded": "已排除",
"state_validation": "验证点", "state_validation": "验证点",
@@ -403,13 +425,11 @@
"point_samples_value": "有效 {valid} / 共 {total}", "point_samples_value": "有效 {valid} / 共 {total}",
"point_axes": "三轴均值 ± 标准差", "point_axes": "三轴均值 ± 标准差",
"point_residual": "拟合残差", "point_residual": "拟合残差",
"col_actions": "",
"recording": "正在采集标定样本…", "recording": "正在采集标定样本…",
"recording_stop_tip": "点击可取消本次标定采集(随时可用)", "recording_stop_tip": "点击可取消本次标定采集(随时可用)",
"record_ok": "已记录 weight={weight} raw={raw}", "record_ok": "已记录 weight={weight} raw={raw}",
"record_failed_few": "采集失败 — 样本过少", "record_failed_few": "采集失败 — 样本过少",
"export_ok": "已导出 {path}", "export_ok": "已导出 {path}",
"export_local": "同时写入 {path}",
"export_warnings": "跳过: {detail}", "export_warnings": "跳过: {detail}",
"export_failed": "导出失败: {error}", "export_failed": "导出失败: {error}",
"export_no_model": "没有可导出的模型: {detail}", "export_no_model": "没有可导出的模型: {detail}",
@@ -427,7 +447,7 @@
"phase_connecting": "连接中", "phase_connecting": "连接中",
"phase_running": "采集中", "phase_running": "采集中",
"phase_error": "出错", "phase_error": "出错",
"rail_video_name": "视频文件: {name}", "rail_video_name": "视频文件: {name}",
"data_valid": "数据有效", "data_valid": "数据有效",
"data_invalid": "无效样本 {count}", "data_invalid": "无效样本 {count}",
"calibrated": "已标定", "calibrated": "已标定",
@@ -449,7 +469,7 @@
"run_failed": "启动失败: {error}", "run_failed": "启动失败: {error}",
"filters_saved": "滤波设置已保存:{describe}(下次采集生效)", "filters_saved": "滤波设置已保存:{describe}(下次采集生效)",
"rail_camera_fps": "摄像头 {fps}", "rail_camera_fps": "摄像头 {fps}",
"capture_slow": "摄像头交付的帧率低于配置值:{detail}。记录会按实际帧率保存。这是采集端(摄像头/驱动)的问题,软件改不了:换一颗真能交付 30 fps 的摄像头,或先确认线材/端口没有把它降速,然后再测。", "capture_slow": "摄像头交付的帧率低于配置值:{detail}。记录会按实际帧率保存。这是采集端(摄像头/驱动)的问题,软件改不了:换一个真正能达到 30 fps 的摄像头,或先确认线材/端口没有把它降速,然后再测。",
"video_ended": "视频已结束", "video_ended": "视频已结束",
"camera_disconnected": "摄像机已断开", "camera_disconnected": "摄像机已断开",
"reset_ok": "传感器追踪器已重置(显示基线重新估计)", "reset_ok": "传感器追踪器已重置(显示基线重新估计)",
@@ -484,7 +504,6 @@
"contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。", "contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。",
"quantity_flow_magnitude": "光流幅值", "quantity_flow_magnitude": "光流幅值",
"quantity_depth_response": "深度响应", "quantity_depth_response": "深度响应",
"mode_surface": "曲面着色",
"preset_default": "默认视角", "preset_default": "默认视角",
"legend_effective": "{quantity} [{unit}] · {mode}色标 {lower} – {upper} · {quality}", "legend_effective": "{quantity} [{unit}] · {mode}色标 {lower} – {upper} · {quality}",
"legend_auto": "自动", "legend_auto": "自动",
@@ -514,10 +533,10 @@
"state_unavailable": "三维不可用", "state_unavailable": "三维不可用",
"source_live": "实时采集", "source_live": "实时采集",
"source_live_tip": "画面来自此刻正在采集的数据。", "source_live_tip": "画面来自此刻正在采集的数据。",
"source_replay": "回放:录像不含三维场", "source_replay": "回放:录像不包含三维场",
"source_replay_tip": "录像只保存原始相机图像与力值,没有逐帧的形变场。开启「从视频重算力值」后,本面板会由录像重算并把形变显示在这里。", "source_replay_tip": "录像只保存原始相机图像与力值,没有逐帧的形变场。开启「分析视频」后,本面板会显示分析结果中的形变。",
"source_recompute": "重算通道:由录像重算,非记录值", "source_recompute": "分析结果:非录制时的测量值",
"source_recompute_tip": "这是在当前算法版本下对录像重算得到的形变,用于检查算法;它不是当初记录下来的测量值。", "source_recompute_tip": "这是存下来的分析结果里当前算法对这段视频算出的形变;它不是当初记录下来的测量值。",
"severity_info": "提示", "severity_info": "提示",
"severity_warning": "警告", "severity_warning": "警告",
"severity_error": "错误", "severity_error": "错误",
@@ -527,21 +546,15 @@
}, },
"analysis": { "analysis": {
"title": "正在分析录像", "title": "正在分析录像",
"subtitle": "只跑一遍:之后拖动时间轴、播放与单步都只是读结果",
"stage_open": "打开链路",
"stage_frames": "逐帧分析",
"stage_write": "写入结果",
"frames": "{written} / {expected} 帧", "frames": "{written} / {expected} 帧",
"segment": "分段 {index} / {count}", "segment": "分段 {index} / {count}",
"elapsed": "已用 {seconds:.1f} s", "elapsed": "已用 {seconds:.1f} s",
"cancel": "取消", "cancel": "取消",
"cancelling": "正在取消…", "cancelling": "正在取消…",
"cancel_tip": "取消这一遍:已完成的帧会留下,但结果会被标记为未完成,永远不会被当作有效结果回放", "cancel_tip": "取消这一遍:已完成的帧会留下,但结果会被标记为未完成,永远不会被当作完整结果回放",
"reused": "已有结果,无需重算",
"complete": "分析完成:{frames} 帧,用时 {seconds:.1f} s", "complete": "分析完成:{frames} 帧,用时 {seconds:.1f} s",
"cancelled": "分析已取消({frames} 帧已写完,结果标记为未完成)", "cancelled": "分析已取消({frames} 帧已写完,结果标记为未完成)",
"failed": "分析失败:{reason}", "failed": "分析失败:{reason}",
"incomplete": "结果未完成:{reason}",
"blocked": "正在采集:停止后再分析" "blocked": "正在采集:停止后再分析"
} }
} }
+1 -1
View File
@@ -36,7 +36,7 @@ Verify: `test_bare_video_analysis.py`, `test_bare_video.py`,
Opening a recording either serves a compatible stored result or runs one pass behind a Opening a recording either serves a compatible stored result or runs one pass behind a
progress-and-cancel surface. `on_analysis_requested` asks `AnalysisService.stored_result()` progress-and-cancel surface. `on_analysis_requested` asks `AnalysisService.stored_result()`
first — a file read, the surface showing `analysis.reused` — and otherwise starts first — a file read, with `AnalysisProgress.reused` filling the bar — and otherwise starts
`AnalysisWorker`, a plain thread whose `Progress` is coalesced at 0.1 s, with `AnalysisWorker`, a plain thread whose `Progress` is coalesced at 0.1 s, with
`AnalysisProgress` showing written/expected frames and elapsed time. The timeline stays in `AnalysisProgress` showing written/expected frames and elapsed time. The timeline stays in
`recording` mode until the pass commits: a half-written result never reaches the screen. `recording` mode until the pass commits: a half-written result never reaches the screen.
+3 -1
View File
@@ -193,7 +193,9 @@
{ "id": "act_rotate_arrows", "path": "act_rotate_arrows", "objectName": null, "class": "QAction", { "id": "act_rotate_arrows", "path": "act_rotate_arrows", "objectName": null, "class": "QAction",
"purpose": "view menu: rotate the 2D arrow field by 90 degrees for the mounted sensors" }, "purpose": "view menu: rotate the 2D arrow field by 90 degrees for the mounted sensors" },
{ "id": "act_recompute", "path": "act_recompute", "objectName": null, "class": "QAction", { "id": "act_recompute", "path": "act_recompute", "objectName": null, "class": "QAction",
"purpose": "view menu: recompute the vision pipeline over the recorded video, the same state as the rail's button" }, "purpose": "view menu: analyze video — serve a stored result or run the pass; same state as the rail's button" },
{ "id": "act_analysis_setup", "path": "act_analysis_setup", "objectName": null, "class": "QAction",
"purpose": "view menu: reopen the analysis setup dialog (profile, calibration, reference frame)" },
{ "id": "act_run", "path": "act_run", "objectName": null, "class": "QAction", { "id": "act_run", "path": "act_run", "objectName": null, "class": "QAction",
"purpose": "F5: start a run (the rail's transport is the primary control)" }, "purpose": "F5: start a run (the rail's transport is the primary control)" },
{ "id": "act_stop", "path": "act_stop", "objectName": null, "class": "QAction", { "id": "act_stop", "path": "act_stop", "objectName": null, "class": "QAction",
+121 -10
View File
@@ -169,6 +169,9 @@ class StudioMainWindow(QMainWindow):
self.prefs: dict[str, Any] = dict(prefs or {}) self.prefs: dict[str, Any] = dict(prefs or {})
self._theme = str(self.prefs.get("theme") or "dark") self._theme = str(self.prefs.get("theme") or "dark")
self._language = str(self.prefs.get("language") or "zh_CN") self._language = str(self.prefs.get("language") or "zh_CN")
#: Reference-frame override for the next analysis pass on a real recording
#: (from View ▸ Analysis setup…). Bare-video workspaces store this in session.json.
self._analysis_reference_override: int | None = None
# The view preset, the calibration panel, the 3D pane's switch and the fps # The view preset, the calibration panel, the 3D pane's switch and the fps
# readout are the controllers' state; the window reads and writes them through # readout are the controllers' state; the window reads and writes them through
# the shims below. # the shims below.
@@ -607,8 +610,11 @@ class StudioMainWindow(QMainWindow):
def analysis_service(self, recording, cancelled=None, *, on_progress=None): def analysis_service(self, recording, cancelled=None, *, on_progress=None):
return self._analysis_service(recording, cancelled, on_progress=on_progress) return self._analysis_service(recording, cancelled, on_progress=on_progress)
def bare_video_workspace(self, video): def bare_video_workspace(self, video, **kwargs):
return self._bare_video_workspace(video) return self._bare_video_workspace(video, **kwargs)
def recording_analysis_overrides(self, recording):
return self._recording_analysis_overrides(recording)
def device_geometry_provider(self): def device_geometry_provider(self):
return self._device_geometry_provider() return self._device_geometry_provider()
@@ -1116,13 +1122,16 @@ class StudioMainWindow(QMainWindow):
self.act_rotate_arrows.triggered.connect(self.display_ctl.on_rotate_arrows) self.act_rotate_arrows.triggered.connect(self.display_ctl.on_rotate_arrows)
self.view_menu.addAction(self.act_rotate_arrows) self.view_menu.addAction(self.act_rotate_arrows)
# The replay reads the recorded CSV (the values the session actually # Serve / leave a stored analysis result (or run the pass when there is none).
# measured). This switches it to re-running the vision pipeline over the
# recorded video instead — an algorithm check, not the record.
self.act_recompute = QAction(self) self.act_recompute = QAction(self)
self.act_recompute.setCheckable(True) self.act_recompute.setCheckable(True)
self.act_recompute.setChecked(False) self.act_recompute.setChecked(False)
self.view_menu.addAction(self.act_recompute) self.view_menu.addAction(self.act_recompute)
# Reopen the setup dialog after the first pass (profile / calibration / reference).
self.act_analysis_setup = QAction(self)
self.act_analysis_setup.triggered.connect(self.replay_ctl.on_analysis_setup)
self.view_menu.addAction(self.act_analysis_setup)
self.view_menu.addSeparator() self.view_menu.addSeparator()
self.act_run = QAction(self) self.act_run = QAction(self)
@@ -1344,9 +1353,15 @@ class StudioMainWindow(QMainWindow):
reason = "" reason = ""
if not sensor_ok: if not sensor_ok:
reason = t("toolbar.run_disabled_sensor") reason = t("toolbar.run_disabled_sensor")
elif self.toolbar.source_mode() == "video" and not self.toolbar.video_path(): elif self.toolbar.source_mode() == "video":
# Live acquires from a camera. A video file is played from the History
# replay view (Play and the chart cursor), not by starting a chain here.
source_ok = False source_ok = False
reason = t("toolbar.run_disabled_video") reason = (
t("toolbar.run_video_in_replay")
if self.toolbar.video_path()
else t("toolbar.run_disabled_video")
)
self.toolbar.set_run_tooltip_reason(reason) self.toolbar.set_run_tooltip_reason(reason)
# While a session exists the transport stays enabled — pausing and resuming # While a session exists the transport stays enabled — pausing and resuming
# are the whole point of it being checked, not an exception to an idle rule. # are the whole point of it being checked, not an exception to an idle rule.
@@ -1390,6 +1405,8 @@ class StudioMainWindow(QMainWindow):
self.act_rotate_arrows.setText(t("menu.rotate_arrows")) self.act_rotate_arrows.setText(t("menu.rotate_arrows"))
self.act_recompute.setText(t("menu.recompute")) self.act_recompute.setText(t("menu.recompute"))
self.act_recompute.setToolTip(t("menu.recompute_tip")) self.act_recompute.setToolTip(t("menu.recompute_tip"))
self.act_analysis_setup.setText(t("menu.analysis_setup"))
self.act_analysis_setup.setToolTip(t("menu.analysis_setup_tip"))
self.act_run.setText(t("menu.run")) self.act_run.setText(t("menu.run"))
self.act_stop.setText(t("menu.stop")) self.act_stop.setText(t("menu.stop"))
self.act_preview.setText(t("menu.preview")) self.act_preview.setText(t("menu.preview"))
@@ -2216,6 +2233,13 @@ class StudioMainWindow(QMainWindow):
self.toolbar.set_run_checked(False) self.toolbar.set_run_checked(False)
self.statusBar().showMessage(t("status.finger_run_unavailable")) self.statusBar().showMessage(t("status.finger_run_unavailable"))
return return
if self.toolbar.source_mode() == "video":
# Opening a clip sets source_mode to video and that flag survives a
# switch back to live. Starting a chain on the file painted live 3D
# while the chart cursor could not seek — playback belongs to replay.
self.toolbar.set_run_checked(False)
self.notify(t("toolbar.run_video_in_replay"))
return
ok, detail = orisys_importable() ok, detail = orisys_importable()
if not ok: if not ok:
self.toolbar.set_run_checked(False) self.toolbar.set_run_checked(False)
@@ -2747,16 +2771,28 @@ class StudioMainWindow(QMainWindow):
chain = chain_spec_for( chain = chain_spec_for(
session, calibration=synthetic.get("calibration") or None session, calibration=synthetic.get("calibration") or None
) )
reference = synthetic.get("reference_frame")
if reference is None:
reference = getattr(self, "_analysis_reference_override", None)
return AnalysisService( return AnalysisService(
Path(recording), Path(recording),
chain=chain, chain=chain,
cancelled=cancelled, cancelled=cancelled,
on_progress=on_progress, on_progress=on_progress,
store_force=bool(synthetic), store_force=bool(synthetic),
reference_frame=None if reference is None else int(reference),
) )
# ------------------------------------------------------ bare video (T1 setup) # ------------------------------------------------------ bare video (T1 setup)
def _bare_video_workspace(self, video: Path) -> Path | None: def _bare_video_workspace(
self,
video: Path,
*,
initial_profile: str = "",
initial_calibration: Path | None = None,
prefer_no_calibration: bool = False,
initial_reference_frame: int | None = None,
) -> Path | None:
"""Ask how to analyse a bare video, then generate the workspace to do it in. """Ask how to analyse a bare video, then generate the workspace to do it in.
The three answers a video cannot carry — the profile, the calibration and the The three answers a video cannot carry — the profile, the calibration and the
@@ -2769,7 +2805,7 @@ class StudioMainWindow(QMainWindow):
The workspace is written under :func:`paths.video_workspace_dir` — the app's own The workspace is written under :func:`paths.video_workspace_dir` — the app's own
per-user data — and never beside the video: the frame index and the session per-user data — and never beside the video: the frame index and the session
document are the app's derived data, and the folder the operator keeps footage in document are the app's derived data, and the folder the operator keeps footage in
is theirs. is theirs. Prefill arguments restore a prior setup when the dialog is reopened.
""" """
from orisys_app_shared.analysis import bare_video from orisys_app_shared.analysis import bare_video
from orisys_app_shared.sensors import scan_sensor_catalog from orisys_app_shared.sensors import scan_sensor_catalog
@@ -2777,12 +2813,16 @@ class StudioMainWindow(QMainWindow):
facts = bare_video.probe(bare_video.resolve_video(video)) facts = bare_video.probe(bare_video.resolve_video(video))
catalog = scan_sensor_catalog() catalog = scan_sensor_catalog()
selected = self.sensor_picker.selected_entry() selected = self.sensor_picker.selected_entry()
profile = initial_profile or (selected.ref if selected is not None else "")
dialog = VideoAnalysisDialog( dialog = VideoAnalysisDialog(
video=Path(video), video=Path(video),
facts=facts, facts=facts,
catalog=catalog, catalog=catalog,
window=self, window=self,
initial_profile=selected.ref if selected is not None else "", initial_profile=profile,
initial_calibration=initial_calibration,
prefer_no_calibration=prefer_no_calibration,
initial_reference_frame=initial_reference_frame,
theme=self._theme, theme=self._theme,
) )
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted: if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
@@ -2796,18 +2836,89 @@ class StudioMainWindow(QMainWindow):
) )
return workspace.directory return workspace.directory
def _recording_analysis_overrides(self, recording: Path) -> bool:
"""Ask for reference frame (and optional calibration) on a real recording.
The profile is locked to the session's own chain. Accept stores a reference-frame
override on the window for the next :meth:`_analysis_service` call; declining
leaves the current result alone.
"""
from orisys_app_shared.analysis import bare_video
from orisys_app_shared.record import RecordingReader
from orisys_app_shared.sensors import scan_sensor_catalog
reader = RecordingReader(Path(recording))
session = reader.session()
video = reader.segment_path(1)
if video is None or not Path(video).is_file():
self.notify(t("playback.analysis_no_source"))
return False
facts = bare_video.probe(bare_video.resolve_video(video))
catalog = scan_sensor_catalog()
config_ref = str(dict(session.get("device") or {}).get("config_ref") or "")
selected = self.sensor_picker.selected_entry()
dialog = VideoAnalysisDialog(
video=Path(video),
facts=facts,
catalog=catalog,
window=self,
initial_profile=config_ref or (selected.ref if selected is not None else ""),
initial_reference_frame=getattr(self, "_analysis_reference_override", None),
lock_profile=True,
theme=self._theme,
)
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
return False
setup = dialog.choice()
if setup is None:
return False
self._analysis_reference_override = setup.reference_frame
return True
def _show_analysis_frame(self, packet) -> None: def _show_analysis_frame(self, packet) -> None:
"""Hand one stored analysis frame to the 3D pane's producer. """Hand one stored analysis frame to the 3D pane's producer.
The pane snapshots a packet through its ordinary path, so a replay's figures go The pane snapshots a packet through its ordinary path, so a replay's figures go
through the same adapter, domain latch and mask intersection as a live run. The through the same adapter, domain latch and mask intersection as a live run. The
producer is O(1) and Qt-free, and it is the pane that decides whether it is on. producer is O(1) and Qt-free, and it is the pane that decides whether it is on.
Live acquisition advances the mailbox on the session run timer. Replay stops
that timer, so after submit the pane must be ticked here — otherwise the frame
sits pending and later submits are skipped (``has_pending_frame``). The overlay
policy is the open session's ``paint_policy``, so 3D arrows share the 2D canvas's
grid and threshold (including the box painter's 2 px floor).
""" """
view = self.mesh3d_panel.view view = self.mesh3d_panel.view
if view is None: if view is None:
return return
self._connect_analysis_display(self.mesh3d_panel) self._connect_analysis_display(self.mesh3d_panel)
policy = None
replay = getattr(self, "replay_ctl", None)
if replay is not None:
policy = replay._session_policy()
view.set_overlay_policy(policy)
view.submit_packet(packet) view.submit_packet(packet)
self._tick_analysis_3d()
def _tick_analysis_3d(self) -> None:
"""Compose and present the analysis frame while replay owns the image.
Replay has no run timer, so two blind ``tick`` calls left the compose in
flight on a thread pool and the rest model on screen. ``flush`` waits for
that compose and presents it before the cursor returns.
"""
panel = getattr(self, "mesh3d_panel", None)
if panel is None:
return
try:
flush = getattr(panel, "flush", None)
if callable(flush):
flush()
else:
panel.tick()
panel.tick()
except Exception: # noqa: BLE001 - display must not break scrubbing
logger.debug("3D analysis flush skipped", exc_info=True)
def _connect_analysis_display(self, mesh_panel) -> bool: def _connect_analysis_display(self, mesh_panel) -> bool:
"""Route every display-settings change through the replay controller. """Route every display-settings change through the replay controller.
@@ -260,19 +260,32 @@ class AnalysisSession:
the pass's own answer — the field is finite only at those cells, and the pass's own answer — the field is finite only at those cells, and
:meth:`serve_policy` refuses a display setting that moves the grid — so the grid :meth:`serve_policy` refuses a display setting that moves the grid — so the grid
travels from the result's provenance. *Which* candidates are active is a display travels from the result's provenance. *Which* candidates are active is a display
question, and ``magnitude`` is stored precisely so it can be asked again later, so question for the shared finger painter, and ``magnitude`` is stored precisely so it
the threshold travels from the display policy. can be asked again later. A box result on the live painter's grid is different: the
2D canvas calls ``orisys.util.draw_arrows``, which does not read the display
threshold, so the on/off floors stay at that painter's values (2 / 1.5 px). The 3D
overlay uses this same policy so the two figures zero at the same strength.
Without this, a display setting that moves the grid would be refused by Without this, a display setting that moves the grid would be refused by
``serve_policy`` and then applied to the drawing anyway, re-selecting the recovered ``serve_policy`` and then applied to the drawing anyway, re-selecting the recovered
field on a grid whose cells mostly hold ``NaN``: the canvas would go blank rather field on a grid whose cells mostly hold ``NaN``: the canvas would go blank rather
than draw the result's own samples. than draw the result's own samples.
""" """
from orisys_app_shared.acquisition.arrows import sdk_box_policy
stored = self.stored_policy() stored = self.stored_policy()
display = self.display_policy display = self.display_policy
box_grid = uses_sdk_box_grid(stored) or uses_sdk_box_grid(self.policy)
if box_grid:
painter = sdk_box_policy()
on_px = float(painter.flow_on_px)
off_px = float(painter.flow_off_px)
else:
on_px = float(display.flow_on_px)
off_px = float(display.flow_off_px)
policy = VisualPolicy( policy = VisualPolicy(
flow_on_px=display.flow_on_px, flow_on_px=on_px,
flow_off_px=display.flow_off_px, flow_off_px=off_px,
flow_grid_spacing=int( flow_grid_spacing=int(
stored.get("flow_grid_spacing", self.policy.flow_grid_spacing) stored.get("flow_grid_spacing", self.policy.flow_grid_spacing)
), ),
@@ -370,9 +383,9 @@ class AnalysisSession:
valid_mask=frame.valid_mask, valid_mask=frame.valid_mask,
flow_valid=True, flow_valid=True,
flow_frame_id=int(frame.frame_id), flow_frame_id=int(frame.frame_id),
depth=None, depth=None if frame.depth is None else np.asarray(frame.depth, dtype=np.float32),
depth_valid=False, depth_valid=bool(frame.has_depth),
depth_frame_id=None, depth_frame_id=int(frame.frame_id) if frame.has_depth else None,
) )
# ------------------------------------------------------------------ building # ------------------------------------------------------------------ building
@@ -385,6 +385,61 @@ class ReplayController(QObject):
return True return True
# ------------------------------------------------------------- bare video setup # ------------------------------------------------------------- bare video setup
def on_analysis_setup(self) -> None:
"""Reopen the analysis setup dialog after a first pass (or before one).
For a bare / synthetic video this rebuilds the workspace from the new answers;
a different calibration or reference frame changes the result key, so the pass
runs again. For a real recording, only the reference frame is overridden for the
next pass (the session's own profile stays locked).
"""
from orisys_app_shared.analysis import bare_video
if self.analysis_running():
self._host.notify(t("playback.analysis_busy"))
return
recording = self.recording_directory()
if recording is not None and bare_video.is_synthetic(recording):
video = bare_video.source_video(recording)
if video is None or not Path(video).is_file():
self._host.notify(t("playback.analysis_no_source"))
return
block = bare_video.read_synthetic(recording) or {}
cal = str(block.get("calibration") or "")
prefer_none = not cal
workspace = self._host.bare_video_workspace(
Path(video),
initial_profile=str(block.get("profile_ref") or ""),
initial_calibration=None if prefer_none else Path(cal),
prefer_no_calibration=prefer_none,
initial_reference_frame=block.get("reference_frame"),
)
if workspace is None:
return
self._video_workspace = Path(workspace)
self._clear_analysis_state()
self.refresh_state()
self.on_analysis_requested()
return
if recording is not None:
if not self._host.recording_analysis_overrides(Path(recording)):
return
self._clear_analysis_state()
# Drop a cached result that was keyed under a different reference so the
# new pass is what serves the cursor.
self.results.close()
self.refresh_state()
self.on_analysis_requested()
return
video = self.video_segment()
if video is None:
named = self._toolbar.video_path()
video = Path(named) if named else None
if video is not None and Path(video).is_file():
self.prepare_bare_video(Path(video))
return
self._host.notify(t("playback.no_video"))
def prepare_bare_video(self, video: Path) -> bool: def prepare_bare_video(self, video: Path) -> bool:
"""Ask how to analyse a bare video, then analyse it — in a generated workspace. """Ask how to analyse a bare video, then analyse it — in a generated workspace.
@@ -600,18 +655,25 @@ class ReplayController(QObject):
self.pause_playback() self.pause_playback()
if wanted: if wanted:
# A bare video may have been opened and the setup prompt declined: play-only, # A bare video may have been opened and the setup prompt declined: play-only,
# no workspace. 「从视频重算」 asks again — that is how numbers appear — then # no workspace. 「分析视频」 asks again — that is how numbers appear — then
# shares ``on_analysis_requested`` with opening a recording. # shares ``on_analysis_requested`` with opening a recording. A path that is
# only remembered on the toolbar (engine not open yet) goes through
# ``load_video``, which opens the clip and shows the same setup dialog.
if self.recording_directory() is None: if self.recording_directory() is None:
video = self.video_segment() video = self.video_segment()
if video is None: if video is None:
named = self._toolbar.video_path() named = self._toolbar.video_path()
video = Path(named) if named else None video = Path(named) if named else None
if video is not None and Path(video).is_file() and self._engine.is_video_only(): if video is None or not Path(video).is_file():
if not self.prepare_bare_video(Path(video)): self._host.notify(t("playback.no_video"))
self._set_recompute_checked(False) self._set_recompute_checked(False)
return return
self._host.notify(t("playback.no_video")) path = Path(video)
if self._engine.is_video_only():
if not self.prepare_bare_video(path):
self._set_recompute_checked(False)
return
if not self.load_video(path):
self._set_recompute_checked(False) self._set_recompute_checked(False)
return return
self.on_analysis_requested() self.on_analysis_requested()
@@ -424,7 +424,19 @@ class FingerFrameAdapter:
if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw: if arr.ndim == 3 and arr.shape[-1] == 2 and tuple(arr.shape[:2]) == self.shape_hw:
flow_uv = freeze(arr, dtype=np.float32) flow_uv = freeze(arr, dtype=np.float32)
device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch) device_for_flow, _ = _device_mask(packet, self.shape_hw, self.domain_latch)
flow_mask = _intersect(np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]), device_for_flow) # The overlay selector already requires a finite sample. The mask it
# is handed must be the sensing island (the 2D canvas's ``valid_mask``),
# not the finite cells of a recovered field. Those cells are only the
# stored candidates; requiring the arrow tip to land on another
# candidate drops almost every 3D arrow (the tip is typically one
# neighbour over, which is ``NaN`` on a recovered grid).
if device_for_flow is not None:
flow_mask = freeze(np.asarray(device_for_flow, dtype=bool), dtype=np.bool_)
else:
flow_mask = freeze(
np.isfinite(arr[..., 0]) & np.isfinite(arr[..., 1]),
dtype=np.bool_,
)
fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0) fn = float(getattr(packet, "normal_force_raw", 0.0) or 0.0)
if not np.isfinite(fn): if not np.isfinite(fn):
fn = float(getattr(packet, "normal_force", 0.0) or 0.0) fn = float(getattr(packet, "normal_force", 0.0) or 0.0)
@@ -404,6 +404,27 @@ class ThreeDCoordinator:
return None return None
# ------------------------------------------------------------------- consumer # ------------------------------------------------------------------- consumer
def flush(self) -> None:
"""Compose and present the pending frame now.
Live acquisition advances this coordinator on the session run timer. Replay
stops that timer, so a submit alone leaves the compose in flight and the
rest model on screen. Calling here ignores the frame-period gate, waits for
the in-flight future, then presents — one cursor move, one picture.
"""
if self._closed or not self._visible or self._assets is None:
return
self._last_compose_start = float("-inf")
self.tick()
item = self._inflight
if item is not None:
try:
# Wait only; acceptance and diagnostics stay in ``tick``.
item.future.result()
except Exception: # noqa: BLE001 - the next tick records the failure
pass
self.tick()
def tick(self) -> None: def tick(self) -> None:
"""GUI timer: start one compose, take the newest result, repaint.""" """GUI timer: start one compose, take the newest result, repaint."""
if self._closed or not self._visible or self._assets is None: if self._closed or not self._visible or self._assets is None:
@@ -415,19 +436,21 @@ class ThreeDCoordinator:
period = self.display_period_s() period = self.display_period_s()
now = self._clock() now = self._clock()
if self._inflight is None and (now - self._last_compose_start) >= period: if self._inflight is None and (now - self._last_compose_start) >= period:
if self._settings_dirty and self._last_field_frame is not None: # A new measurement always wins: replay sets overlay policy (which
# A gain change re-draws the field that is already on screen; taking # dirties settings) then submits the cursor frame. Recomposing the
# a newer one would show a different instant than the one the # held field first left that frame in the mailbox, later submits
# operator just changed the exaggeration of. # skipped (``has_pending_frame``), and the pane stayed on the first
# near-zero depth while 2D kept scrubbing.
frame = self._incoming.take()
if frame is not None:
self._settings_dirty = False
self._last_compose_start = now
self._start_compose(frame)
elif self._settings_dirty and self._last_field_frame is not None:
# No newer field: a gain/overlay change re-draws what is on screen.
self._settings_dirty = False self._settings_dirty = False
self._last_compose_start = now self._last_compose_start = now
self._start_compose(self._last_field_frame) self._start_compose(self._last_field_frame)
else:
self._settings_dirty = False
frame = self._incoming.take()
if frame is not None:
self._last_compose_start = now
self._start_compose(frame)
composed = self._composed.take() composed = self._composed.take()
if composed is not None: if composed is not None:
if not self._identity_is_current(composed.identity): if not self._identity_is_current(composed.identity):
@@ -496,6 +519,19 @@ class ThreeDCoordinator:
except AttributeError: # pragma: no cover - a composer stand-in without state except AttributeError: # pragma: no cover - a composer stand-in without state
pass pass
def set_overlay_policy(self, policy) -> None:
"""Pin the flow-arrow policy the composer uses for overlays, or clear it.
Replay analysis sets the session's ``paint_policy`` so 3D arrows share the 2D
canvas's grid and threshold. Live leaves this unset (``None``).
"""
try:
self._composer.overlay_policy = policy
except AttributeError: # pragma: no cover - a composer stand-in without state
pass
if self._assets is not None and self._last_field_frame is not None:
self._settings_dirty = True
def show_rest(self) -> None: def show_rest(self) -> None:
"""Put the reference model back on screen and stop claiming a measurement. """Put the reference model back on screen and stop claiming a measurement.
@@ -505,6 +541,7 @@ class ThreeDCoordinator:
measurement that is no longer being made, which is exactly what the rest frame measurement that is no longer being made, which is exactly what the rest frame
exists to prevent. Idempotent, and safe before any scene is active. exists to prevent. Idempotent, and safe before any scene is active.
""" """
self.set_overlay_policy(None)
if self._closed or self._assets is None or not self._visible: if self._closed or self._assets is None or not self._visible:
return return
self._invalidate() self._invalidate()
@@ -412,6 +412,11 @@ class Scene3DView(QWidget):
if adapter is not None and hasattr(adapter, "set_preferred_field"): if adapter is not None and hasattr(adapter, "set_preferred_field"):
adapter.set_preferred_field(settings.field) adapter.set_preferred_field(settings.field)
def set_overlay_policy(self, policy) -> None:
"""Pin (or clear) the flow-arrow policy used when composing analysis overlays."""
if self._coordinator is not None:
self._coordinator.set_overlay_policy(policy)
def apply_preset(self, name: str) -> None: def apply_preset(self, name: str) -> None:
if self._assets is None: if self._assets is None:
return return
@@ -767,6 +772,19 @@ class Scene3DView(QWidget):
if self._coordinator is None or not self._enabled: if self._coordinator is None or not self._enabled:
return return
self._coordinator.tick() self._coordinator.tick()
self._publish_after_tick()
def flush(self) -> None:
"""Compose and present the pending analysis frame without a run timer."""
if self._coordinator is None or not self._enabled:
return
self._coordinator.flush()
self._publish_after_tick()
def _publish_after_tick(self) -> None:
"""Publish coordinator state after a tick or flush."""
if self._coordinator is None:
return
# The coordinator owns the truths a pane cannot see from here — a refused # The coordinator owns the truths a pane cannot see from here — a refused
# field, an expired measurement, a failed compose. It has no Qt signals by # field, an expired measurement, a failed compose. It has no Qt signals by
# design, so the one place that already runs per tick publishes them. # design, so the one place that already runs per tick publishes them.
+3 -1
View File
@@ -79,7 +79,9 @@ class AnalysisSupport(Protocol):
self, recording: Path, cancelled: Any = None, *, on_progress: Any = None self, recording: Path, cancelled: Any = None, *, on_progress: Any = None
) -> Any: ... ) -> Any: ...
def bare_video_workspace(self, video: Path) -> Path | None: ... def bare_video_workspace(self, video: Path, **kwargs: Any) -> Path | None: ...
def recording_analysis_overrides(self, recording: Path) -> bool: ...
class LayoutShell(Protocol): class LayoutShell(Protocol):
@@ -518,6 +518,11 @@ class Mesh3dPanel(QFrame):
if self._view is not None: if self._view is not None:
self._view.tick() self._view.tick()
def flush(self) -> None:
"""Compose and present a pending analysis frame (replay has no run timer)."""
if self._view is not None:
self._view.flush()
def close_view(self) -> None: def close_view(self) -> None:
"""Release GL and the loader. Idempotent, and safe to call twice.""" """Release GL and the loader. Idempotent, and safe to call twice."""
if self._view is not None: if self._view is not None:
@@ -0,0 +1,336 @@
"""The setup prompt for a bare video: which profile, which calibration, which reference.
A recording carries everything a pass needs — the configuration it was taken under, the
calibration, the frame index. A bare video carries none of it, so the three answers the
pass cannot invent are asked for here, once, before any work starts:
* **profile** — the processing configuration, listed from the catalog with what it can do
in one line. A profile the SDK would refuse to start is shown with that refusal and
cannot be chosen: the catalog's own :meth:`CatalogEntry.start_blocked_reason` is the
verdict, so this dialog cannot disagree with the picker about the same bundle.
* **calibration** — optional, and defaulting to the profile's ``default_calibration`` when
its descriptor declares one (the box bundle resolves ``force_calibration.json`` beside
its ``sensor.json``). "No calibration — raw force" is a choice, not an empty field.
* **reference frame** — the frame the tracker reference is reset at, defaulting to the
first. The list is the video's own frames, not an index into them.
This is deliberately *not* the capability panel: it offers what the pass needs to know and
nothing about how the result will be drawn. What each profile can do is one line, and the
reason it cannot be used is one line, both in the language the catalog already speaks.
"""
from __future__ import annotations
from pathlib import Path
from orisys_app_shared.analysis.bare_video import (
BareVideoSetup,
VideoFacts,
profiles_for_video,
setup_blocked_reason,
)
from orisys_app_shared.i18n import t
from orisys_app_shared.sensors import CatalogEntry, SensorCatalog
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox,
QDialog,
QDialogButtonBox,
QFileDialog,
QFormLayout,
QHBoxLayout,
QLabel,
QPushButton,
QSpinBox,
QVBoxLayout,
)
from orisys_studio.ui.fit_text import ShrinkableLabel
#: The calibration combo's two fixed rows. The first is a *file* the profile declares; the
#: second is the absence of one, which is a different answer and never an empty field.
ROLE_DEFAULT = "default"
ROLE_NONE = "none"
ROLE_FILE = "file"
class VideoAnalysisDialog(QDialog):
"""Ask how one bare video should be analysed, and hand back the three answers."""
def __init__(
self,
*,
video: Path,
facts: VideoFacts,
catalog: SensorCatalog,
window=None,
initial_profile: str = "",
initial_calibration: Path | None = None,
prefer_no_calibration: bool = False,
initial_reference_frame: int | None = None,
lock_profile: bool = False,
theme: str = "dark",
) -> None:
super().__init__(window)
self._video = Path(video)
self._facts = facts
self._catalog = catalog
self._theme = str(theme)
self._entries: list[CatalogEntry] = profiles_for_video(catalog.entries)
self._custom_calibration: Path | None = None
self._restore_calibration = (
None
if prefer_no_calibration
else (None if initial_calibration is None else Path(initial_calibration))
)
self._prefer_no_calibration = bool(prefer_no_calibration)
self._restore_reference = (
1 if initial_reference_frame is None else int(initial_reference_frame)
)
self._lock_profile = bool(lock_profile)
self.setObjectName("videoAnalysisDialog")
self.setModal(True)
root = QVBoxLayout(self)
root.setSpacing(10)
self.title_label = QLabel()
self.title_label.setObjectName("panelTitle")
root.addWidget(self.title_label)
self.video_label = ShrinkableLabel()
self.video_label.setObjectName("panelMeta")
self.video_label.setWordWrap(True)
root.addWidget(self.video_label)
form = QFormLayout()
form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self.profile_label = QLabel()
self.profile_label.setObjectName("fieldLabel")
self.profile_combo = QComboBox()
self.profile_combo.setObjectName("profileCombo")
for entry in self._entries:
# The row is the bundle's **name** and nothing else — the catalog's own rule, and
# the reason the capability line is a label under the selector rather than text
# bolted onto the row. An entry that cannot be started is listed too, disabled,
# because "why is finger-mems not offered" has to have an answer on the surface
# rather than in a log.
self.profile_combo.addItem(catalog.row_label(entry), entry.key)
index = self.profile_combo.count() - 1
self.profile_combo.setItemData(index, entry.tooltip(), Qt.ItemDataRole.ToolTipRole)
if setup_blocked_reason(entry) is not None:
model = self.profile_combo.model()
item = model.item(index) if hasattr(model, "item") else None
if item is not None:
item.setEnabled(False)
form.addRow(self.profile_label, self.profile_combo)
self.capability_label = ShrinkableLabel()
self.capability_label.setObjectName("panelMeta")
self.capability_label.setWordWrap(True)
root.addLayout(form)
root.addWidget(self.capability_label)
calibration_form = QFormLayout()
calibration_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
self.calibration_label = QLabel()
self.calibration_label.setObjectName("fieldLabel")
self.calibration_combo = QComboBox()
self.calibration_combo.setObjectName("calibrationCombo")
calibration_row = QHBoxLayout()
calibration_row.addWidget(self.calibration_combo, 1)
self.btn_browse = QPushButton()
self.btn_browse.setProperty("compact", "true")
self.btn_browse.clicked.connect(self._on_browse)
calibration_row.addWidget(self.btn_browse)
calibration_form.addRow(self.calibration_label, calibration_row)
self.reference_label = QLabel()
self.reference_label.setObjectName("fieldLabel")
self.reference_spin = QSpinBox()
self.reference_spin.setObjectName("referenceSpin")
# The frame *ids*, not indices: the reference is a frame the recording names, and
# an id that is not on the list is one the pass would refuse anyway.
self.reference_spin.setRange(1, max(1, int(facts.frame_count)))
self.reference_spin.setValue(1)
self.reference_spin.valueChanged.connect(self._refresh_time_label)
reference_row = QHBoxLayout()
reference_row.addWidget(self.reference_spin, 1)
self.reference_time_label = ShrinkableLabel()
self.reference_time_label.setObjectName("panelMeta")
reference_row.addWidget(self.reference_time_label)
calibration_form.addRow(self.reference_label, reference_row)
root.addLayout(calibration_form)
self.note_label = ShrinkableLabel()
self.note_label.setObjectName("panelMeta")
self.note_label.setWordWrap(True)
root.addWidget(self.note_label)
self.buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
)
self.buttons.accepted.connect(self.accept)
self.buttons.rejected.connect(self.reject)
root.addWidget(self.buttons)
self.profile_combo.currentIndexChanged.connect(self._on_profile_changed)
self._select_initial(initial_profile)
if self._lock_profile:
self.profile_combo.setEnabled(False)
self.retranslate()
self._on_profile_changed()
if self._prefer_no_calibration:
self.select_calibration(None)
elif self._restore_calibration is not None:
self.select_calibration(self._restore_calibration)
self.reference_spin.setValue(max(1, int(self._restore_reference)))
# ------------------------------------------------------------------ selection
@property
def entries(self) -> list[CatalogEntry]:
return list(self._entries)
def entry(self) -> CatalogEntry | None:
key = self.profile_combo.currentData()
for candidate in self._entries:
if candidate.key == str(key):
return candidate
return None
def select_profile(self, ref_or_key: str) -> bool:
"""Choose a profile by its ``origin/folder`` ref or its selection key."""
wanted = str(ref_or_key or "")
for index, entry in enumerate(self._entries):
if entry.key == wanted or entry.ref == wanted:
self.profile_combo.setCurrentIndex(index)
return True
return False
def select_calibration(self, path: Path | None) -> None:
"""Choose "no calibration" (``None``) or a specific file."""
self._refresh_calibrations(custom=path)
if path is None:
for index in range(self.calibration_combo.count()):
if self.calibration_combo.itemData(index) == ROLE_NONE:
self.calibration_combo.setCurrentIndex(index)
return
def choice(self) -> BareVideoSetup | None:
"""The answers, or ``None`` when the chosen profile must not be started.
A refusal carries the reason the catalog (or this app's chain table) gave, which the
caller states: the dialog cannot start a profile the SDK rejected, and it does not
pretend the reason is its own.
"""
entry = self.entry()
if entry is None:
return None
blocked = setup_blocked_reason(entry)
if blocked:
self.note_label.setText(t("video_analysis.blocked", reason=blocked))
return None
reference = int(self.reference_spin.value())
return BareVideoSetup(
profile_ref=entry.ref,
calibration=self._chosen_calibration(),
# The first frame is the pass's own default: it starts from a clean tracker, so
# the first frame it processes already *is* the reference and no reset is owed.
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
# as one that happened.
reference_frame=None if reference == 1 else reference,
)
# ------------------------------------------------------------------ content
def _select_initial(self, wanted: str) -> None:
if wanted and self.select_profile(wanted):
return
if self.profile_combo.count():
self.profile_combo.setCurrentIndex(0)
def _on_profile_changed(self) -> None:
entry = self.entry()
self._custom_calibration = None
self._refresh_calibrations(custom=None)
self.capability_label.setText(
"" if entry is None else entry.capability_line()
)
blocked = None if entry is None else setup_blocked_reason(entry)
self.note_label.setText(
"" if not blocked else t("video_analysis.blocked", reason=blocked)
)
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
self._refresh_time_label()
def _refresh_calibrations(self, *, custom: Path | None) -> None:
"""The calibration field: the profile's default, raw force, or a chosen file."""
entry = self.entry()
self.calibration_combo.blockSignals(True)
self.calibration_combo.clear()
declared = None if entry is None or entry.profile is None else entry.profile.default_calibration
if declared is not None:
self.calibration_combo.addItem(
t("video_analysis.calibration_default", name=Path(declared).name),
ROLE_DEFAULT,
)
elif entry is not None and entry.selected_calibration:
self.calibration_combo.addItem(
t("video_analysis.calibration_default", name=entry.selected_calibration),
ROLE_DEFAULT,
)
self.calibration_combo.addItem(t("video_analysis.calibration_none"), ROLE_NONE)
if custom is not None:
self._custom_calibration = Path(custom)
self.calibration_combo.addItem(
t("video_analysis.calibration_file", name=Path(custom).name), ROLE_FILE
)
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
self.calibration_combo.blockSignals(False)
def _chosen_calibration(self) -> Path | None:
role = str(self.calibration_combo.currentData() or "")
entry = self.entry()
if role == ROLE_FILE:
return self._custom_calibration
if role == ROLE_NONE:
return None
if entry is None or entry.profile is None:
return None
return entry.profile.default_calibration
def _on_browse(self) -> None:
chosen, _filter = QFileDialog.getOpenFileName(
self,
t("video_analysis.calibration_browse"),
str(Path(self._video).parent),
t("video_analysis.calibration_filter"),
)
if chosen:
self._refresh_calibrations(custom=Path(chosen))
def _refresh_time_label(self) -> None:
fps = float(self._facts.fps) or 0.0
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0
self.reference_time_label.setText(
t("video_analysis.reference_time", seconds=f"{seconds:.3f}")
)
def retranslate(self) -> None:
self.setWindowTitle(t("video_analysis.title"))
self.title_label.setText(t("video_analysis.title"))
self.video_label.setText(
t(
"video_analysis.video",
name=Path(self._video).name,
frames=int(self._facts.frame_count),
fps=f"{float(self._facts.fps):.3f}",
seconds=f"{float(self._facts.duration_s):.3f}",
)
)
self.profile_label.setText(t("video_analysis.profile"))
self.calibration_label.setText(t("video_analysis.calibration"))
self.reference_label.setText(t("video_analysis.reference"))
self.btn_browse.setText(t("video_analysis.calibration_browse"))
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
self.buttons.button(QDialogButtonBox.StandardButton.Cancel).setText(t("dialog.cancel"))
self._refresh_time_label()
+24 -2
View File
@@ -12,6 +12,7 @@ broken application (the same rule ``apps/autocal`` follows).
from __future__ import annotations from __future__ import annotations
import itertools import itertools
import logging
import os import os
import shutil import shutil
import sys import sys
@@ -91,8 +92,29 @@ def make_window(qt_app, tmp_path, monkeypatch):
for window in created: for window in created:
try: try:
window.close() window.close()
except Exception: except Exception: # noqa: BLE001 - teardown must not fail a test that passed
pass # Reported rather than swallowed: a window that cannot close is a Qt lifetime
# problem worth seeing, but it must not turn a green test red.
logging.getLogger(__name__).debug("could not close a test window", exc_info=True)
@pytest.fixture
def decline_video_prompt(monkeypatch):
"""Answer the bare-video setup prompt with "no", so it cannot block a test.
Opening a video that is not a recording now asks *how* to analyse it, and the dialog is
modal: a test that drives ``load_video`` on a bare video without answering waits for a
person who is not there. Declining is the answer that leaves the old behaviour intact —
the video plays, nothing is analysed — which is what the tests predating the prompt are
about.
"""
from PySide6.QtWidgets import QDialog
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
monkeypatch.setattr(
VideoAnalysisDialog, "exec", lambda self: QDialog.DialogCode.Rejected
)
def visible_descendants(widget): def visible_descendants(widget):
@@ -674,3 +674,52 @@ def test_the_first_frame_is_the_prompt_s_default_reference(
assert dialog.reference_spin.value() == 4 assert dialog.reference_spin.value() == 4
finally: finally:
dialog.deleteLater() dialog.deleteLater()
def test_the_prompt_restores_a_prior_reference_and_no_calibration(
bare_clip, window, qt_app
):
"""Reopening Analysis setup must show the answers from the last pass."""
facts = bare_video.probe(bare_video.resolve_video(bare_clip))
dialog = VideoAnalysisDialog(
video=bare_clip,
facts=facts,
catalog=scan_sensor_catalog(),
window=window,
initial_profile="shipped/box",
prefer_no_calibration=True,
initial_reference_frame=4,
)
try:
assert dialog.reference_spin.value() == 4
assert dialog.choice().reference_frame == 4
assert dialog.calibration_combo.currentData() == "none"
assert dialog.choice().calibration is None
finally:
dialog.deleteLater()
def test_analysis_setup_reopens_the_prompt_after_the_first_pass(
bare_clip, window, monkeypatch
):
"""View ▸ Analysis setup… asks again; a new reference frame starts a new pass."""
open_bare_video(window, bare_clip, monkeypatch, reference=None)
settle(window)
first = window.replay_ctl.recording_directory()
assert first is not None
assert bare_video.is_synthetic(first)
# The box stitch probe consumes the first decoded frames; a reference that names
# one of them is refused. Frame 10 is past that offset on this clip.
seen: list = []
answer_prompt(monkeypatch, profile="shipped/box", reference=10, seen=seen)
window.replay_ctl.on_analysis_setup()
settle(window)
assert seen, "Analysis setup did not open the dialog"
second = window.replay_ctl.recording_directory()
assert second is not None
block = bare_video.read_synthetic(second)
assert block is not None
assert int(block["reference_frame"]) == 10
assert window.replay_ctl.available_result() is not None
+45 -3
View File
@@ -494,9 +494,12 @@ class _Host:
raise AssertionError("the test did not provide an analysis service") raise AssertionError("the test did not provide an analysis service")
return self.service_factory(recording, cancelled, on_progress) return self.service_factory(recording, cancelled, on_progress)
def bare_video_workspace(self, video): def bare_video_workspace(self, video, **kwargs):
return None return None
def recording_analysis_overrides(self, recording):
return False
class _RecordingReader: class _RecordingReader:
"""The recording reader as the engine and the controller use it.""" """The recording reader as the engine and the controller use it."""
@@ -816,7 +819,7 @@ def test_a_seek_equals_playing_forward_to_the_same_frame(session):
def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session): def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session):
"""One cursor, two figures: the packet's id, flow and mask are the frame's own.""" """One cursor, two figures: the packet's id, flow, depth and mask are the frame's own."""
frame = session.frame_at(session.time_of(LAST_FRAME_ID - 2)) frame = session.frame_at(session.time_of(LAST_FRAME_ID - 2))
assert frame is not None assert frame is not None
packet = session.packet(frame) packet = session.packet(frame)
@@ -828,10 +831,49 @@ def test_the_3d_pane_is_fed_the_same_frame_the_canvas_draws(session):
) )
assert np.array_equal(np.asarray(packet.valid_mask), np.asarray(frame.valid_mask)) assert np.array_equal(np.asarray(packet.valid_mask), np.asarray(frame.valid_mask))
assert packet.flow_valid is True and packet.flow_frame_id == int(frame.frame_id) assert packet.flow_valid is True and packet.flow_frame_id == int(frame.frame_id)
assert bool(packet.depth_valid) is bool(frame.has_depth)
if frame.has_depth:
assert np.array_equal(np.asarray(packet.depth), np.asarray(frame.depth))
assert packet.depth_frame_id == int(frame.frame_id)
else:
assert packet.depth is None and packet.depth_frame_id is None
assert packet.forces_valid is False, "a result stores no force, so none may be claimed" assert packet.forces_valid is False, "a result stores no force, so none may be claimed"
assert float(packet.mono_t) == pytest.approx(frame.t_rel_s) assert float(packet.mono_t) == pytest.approx(frame.t_rel_s)
def test_paint_policy_matches_the_2d_painter_threshold(session, box_session):
"""3D overlays use ``paint_policy``: finger keeps the display floor, box keeps 2 px."""
from orisys_app_shared.acquisition.arrows import sdk_box_policy
from orisys_app_shared.scene3d.visual_policy import VisualPolicy
finger = session.paint_policy()
assert finger.flow_on_px == pytest.approx(session.display_policy.flow_on_px)
assert int(finger.flow_grid_spacing) == int(
session.stored_policy().get("flow_grid_spacing", session.policy.flow_grid_spacing)
)
# Keep the stored grid; only the activity threshold moves.
adopted = session.display_policy
assert session.serve_policy(
VisualPolicy(
flow_on_px=0.9,
flow_off_px=0.6,
flow_grid_spacing=adopted.flow_grid_spacing,
flow_arrow_scale=adopted.flow_arrow_scale,
flow_edge_margin=adopted.flow_edge_margin,
flow_length_margin=adopted.flow_length_margin,
max_arrows=adopted.max_arrows,
)
)
assert session.paint_policy().flow_on_px == pytest.approx(0.9)
painter = sdk_box_policy()
box = box_session.paint_policy()
assert box.flow_on_px == pytest.approx(painter.flow_on_px)
assert box.flow_off_px == pytest.approx(painter.flow_off_px)
assert int(box.flow_grid_spacing) == int(painter.flow_grid_spacing)
# --------------------------------------------------------------- display settings # --------------------------------------------------------------- display settings
def test_a_display_change_is_a_read_and_writes_no_result(pass_root, session): def test_a_display_change_is_a_read_and_writes_no_result(pass_root, session):
"""The result key is blind to display settings, so a change must not write. """The result key is blind to display settings, so a change must not write.
@@ -1450,7 +1492,7 @@ def test_the_nothing_to_draw_line_names_the_action_that_produces_a_result():
seen[locale] = text seen[locale] = text
# The action that produces a result is named: the pass runs on open, and the # The action that produces a result is named: the pass runs on open, and the
# toolbar action re-runs it. # toolbar action re-runs it.
assert "由视频重算" in text or "Recompute from the video" in text, text assert "分析视频" in text or "Analyze video" in text, text
assert ( assert (
"分析" in text or "analysis result" in text "分析" in text or "analysis result" in text
), f"{locale} does not say a result is what is missing: {text}" ), f"{locale} does not say a result is what is missing: {text}"
+27 -1
View File
@@ -184,6 +184,8 @@ class _Host:
#: has no dialog, so the answer is the field a test sets. #: has no dialog, so the answer is the field a test sets.
self.workspace = None self.workspace = None
self.workspace_requests: list = [] self.workspace_requests: list = []
self.workspace_kwargs: dict = {}
self.override_requests: list = []
# AcquireGate reads (legacy private names kept as writeable fields for tests) # AcquireGate reads (legacy private names kept as writeable fields for tests)
@property @property
@@ -238,10 +240,15 @@ class _Host:
def current_source_kind(self) -> str: def current_source_kind(self) -> str:
return "recompute" return "recompute"
def bare_video_workspace(self, video): def bare_video_workspace(self, video, **kwargs):
self.workspace_requests.append(video) self.workspace_requests.append(video)
self.workspace_kwargs = dict(kwargs)
return self.workspace return self.workspace
def recording_analysis_overrides(self, recording):
self.override_requests.append(recording)
return False
def analysis_service(self, recording, cancelled=None, *, on_progress=None): def analysis_service(self, recording, cancelled=None, *, on_progress=None):
raise AssertionError("the test did not provide an analysis service") raise AssertionError("the test did not provide an analysis service")
@@ -577,3 +584,22 @@ def test_the_replay_entries_exist_only_in_the_replay_view(qt_app):
assert actions["open_video"].enabled is True assert actions["open_video"].enabled is True
assert actions["open_video"].tooltips[-1] == "" assert actions["open_video"].tooltips[-1] == ""
assert parts[0].recompute_enabled[-1] is True assert parts[0].recompute_enabled[-1] is True
def test_analyze_opens_a_remembered_video_that_is_not_loaded_yet(qt_app, tmp_path):
"""A toolbar path alone used to notify ``playback.no_video`` and skip the dialog.
The rail can show a file name from a previous session while the engine has not opened
anything. 「分析视频」 must call ``load_video`` so the setup prompt appears — the same
path as picking the file again in the explorer.
"""
bare = tmp_path / "1.mp4"
bare.write_bytes(b"not really a video")
engine = _Engine(has_video=False, video_only=False)
toolbar = _Toolbar(video_path=str(bare))
controller, host, engine, parts = _controller(engine=engine, toolbar=toolbar)
controller.on_recompute_toggled(True)
assert engine.opened == [("video", bare)], "the remembered file was not opened"
assert host.workspace_requests == [bare], "the setup dialog was not shown"
assert t("playback.no_video") not in host.notes
+20 -7
View File
@@ -180,7 +180,7 @@ def _replay_window(make_window):
QApplication.processEvents() QApplication.processEvents()
window.brand_bar.btn_playback.click() window.brand_bar.btn_playback.click()
QApplication.processEvents() QApplication.processEvents()
assert window._in_replay_view(), "the window did not switch to the replay view" assert window.layout_ctl.in_replay_view(), "the window did not switch to the replay view"
return window return window
@@ -716,11 +716,20 @@ def test_the_panel_states_a_failed_pass_with_its_reason(
) )
def test_the_panel_says_a_bare_video_cannot_be_analysed(tmp_path, own_window, monkeypatch): def test_a_bare_video_with_a_declined_prompt_promises_no_pass(
"""No session metadata: no pass exists to offer, and the line must not name one.""" tmp_path, own_window, monkeypatch, decline_video_prompt
):
"""A bare video whose setup prompt was dismissed: playback, and one honest line.
A bare video *can* be analysed now — that is what ``test_bare_video_analysis.py`` is
about, and it asks the prompt and answers it. This test is the other answer: the operator
opened the video and dismissed the question, so no pass is coming and the panel must not
say one is. The line names the action instead (the setup prompt), which is what the
"failed or refused, with its reason" state is for.
"""
from orisys_app_shared.acquisition.image_backend import ( from orisys_app_shared.acquisition.image_backend import (
RAW_NOTE_KEY, RAW_NOTE_KEY,
RESULT_IMPOSSIBLE, RESULT_FAILED,
) )
from orisys_app_shared.i18n import t from orisys_app_shared.i18n import t
@@ -734,9 +743,13 @@ def test_the_panel_says_a_bare_video_cannot_be_analysed(tmp_path, own_window, mo
QApplication.processEvents() QApplication.processEvents()
assert window.replay.is_video_only(), "the clip did not open as a bare video" assert window.replay.is_video_only(), "the clip did not open as a bare video"
assert window.replay_ctl.result_state() == (RESULT_IMPOSSIBLE, "") assert not window.replay_ctl.analysis_running(), "a pass ran after a dismissed prompt"
assert window.replay_ctl.result_state() == (
RESULT_FAILED,
t("playback.analysis_setup_declined"),
)
assert window.arrow_panel.note_label.text() == t( assert window.arrow_panel.note_label.text() == t(
"panels.replay_analysis_unavailable" "panels.replay_analysis_failed", reason=t("playback.analysis_setup_declined")
) )
assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text() assert t(RAW_NOTE_KEY) not in window.arrow_panel.note_label.text()
assert plan_image(captured[-1]).note_key == "panels.replay_analysis_unavailable" assert plan_image(captured[-1]).note_key == "panels.replay_analysis_failed"
@@ -173,6 +173,36 @@ def test_the_finger_adapter_publishes_a_field_already_in_the_domain(scene):
) )
def test_a_recovered_flow_keeps_the_island_as_the_overlay_mask(scene):
"""Replay flow is finite only at stored cells; the overlay mask must stay the island.
The selector already drops ``NaN`` samples. If the mask is those finite cells too,
an arrow tip one neighbour over is rejected and 3D keeps a handful of rim dots
while 2D draws the full grid.
"""
adapter = FingerFrameAdapter(
geometry_id=scene.geometry_id,
field_domain_id=scene.field_domain_id,
shape_hw=scene.shape_hw,
)
h, w = scene.shape_hw
flow = np.full((h, w, 2), np.nan, dtype=np.float32)
flow[1, 1, 0] = 7.0
island = np.ones((h, w), dtype=bool)
depth = np.zeros((h, w), dtype=np.float32)
frame = adapter.snapshot(
FakePacket(flow=flow, depth=depth, valid_mask=island),
runtime=None,
identity=_identity(),
)
assert frame is not None
assert frame.flow_uv is not None and frame.flow_valid_mask is not None
mask = np.asarray(frame.flow_valid_mask)
assert mask.all(), "the overlay mask shrank to the recovered sample cells"
finite = np.isfinite(np.asarray(frame.flow_uv)[..., 0])
assert int(finite.sum()) == 1
def test_the_finger_adapter_remaps_through_the_unroll_map(): def test_the_finger_adapter_remaps_through_the_unroll_map():
map_x = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2]], dtype=np.float32) map_x = np.array([[0, 1, 2], [0, 1, 2], [0, 1, 2]], dtype=np.float32)
map_y = np.array([[2, 2, 2], [1, 1, 1], [0, 0, 0]], dtype=np.float32) map_y = np.array([[2, 2, 2], [1, 1, 1], [0, 0, 0]], dtype=np.float32)
@@ -599,6 +599,54 @@ def test_repeated_start_and_stop_never_leaves_an_old_measurement(scene, pool):
coordinator.close() coordinator.close()
def test_flush_presents_a_submitted_field_without_a_run_timer(scene, pool):
"""Replay stops the run timer; flush must wait for compose and present the field."""
widget = FakeWidget()
composer = CountingComposer(delay_s=0.02)
coordinator = ThreeDCoordinator(widget, composer, pool, max_fps=1)
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
assert coordinator.submit(_frame(scene, frame_id=3, value=4.0)) == "accepted"
# Two blind ticks used to leave the compose in flight when the pool is slow.
coordinator.tick()
coordinator.tick()
assert not widget.frames or not np.allclose(
np.asarray(widget.frames[-1].field.scalar), 4.0
), "two ticks already presented a slow compose; the flush regression is moot"
coordinator.flush()
assert widget.frames, "flush left the rest model on screen"
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 4.0)
assert coordinator._inflight is None # noqa: SLF001
assert not coordinator.has_pending_frame()
coordinator.close()
def test_flush_presents_the_newer_frame_after_an_overlay_policy_change(scene):
"""Replay sets overlay policy then submits; the cursor frame must reach the widget.
``set_overlay_policy`` dirties settings so a slider change can recompose. If that
recompose of the *held* field wins over the mailbox, scrubbing leaves 3D on the
first (often empty) depth while 2D moves.
"""
widget = FakeWidget()
coordinator = ThreeDCoordinator(widget, SceneComposer(), ImmediateExecutor())
coordinator.set_visible(True)
coordinator.activate(scene, "live-A")
assert coordinator.submit(_frame(scene, frame_id=1, value=0.0)) == "accepted"
coordinator.flush()
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 0.0)
coordinator.set_overlay_policy(object())
assert coordinator.submit(_frame(scene, frame_id=80, value=7.0)) == "accepted"
coordinator.flush()
assert np.allclose(np.asarray(widget.frames[-1].field.scalar), 7.0), (
"the pane stayed on the first field after overlay policy dirtied settings"
)
assert int(widget.frames[-1].identity.frame_id) == 80
coordinator.close()
def test_an_older_request_id_cannot_move_the_picture_backwards(scene): def test_an_older_request_id_cannot_move_the_picture_backwards(scene):
"""R7: request ids are monotonic within a session, and the pane must obey.""" """R7: request ids are monotonic within a session, and the pane must obey."""
widget = FakeWidget() widget = FakeWidget()
@@ -2,6 +2,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
import numpy as np import numpy as np
from orisys_app_shared.scene3d.composer import SceneComposer from orisys_app_shared.scene3d.composer import SceneComposer
from orisys_app_shared.scene3d.contracts import ( from orisys_app_shared.scene3d.contracts import (
@@ -152,3 +154,36 @@ def test_both_views_build_their_policy_from_the_same_settings() -> None:
# Normalised, so a hand-edited preference cannot invert the hysteresis band. # Normalised, so a hand-edited preference cannot invert the hysteresis band.
inverted = policy_for_settings(DisplaySettings(flow_on_px=0.1, flow_off_px=0.9)) inverted = policy_for_settings(DisplaySettings(flow_on_px=0.1, flow_off_px=0.9))
assert inverted.flow_on_px >= inverted.flow_off_px assert inverted.flow_on_px >= inverted.flow_off_px
def test_a_sparse_recovered_field_still_draws_3d_arrows() -> None:
"""A recovered analysis field is ``NaN`` off the stored grid; tips land there.
Handing ``isfinite(flow)`` as the overlay mask required the tip to sit on another
stored cell, so replay 3D kept a few rim dots while 2D drew the island grid.
"""
assets = poc_scene()
h, w = assets.shape_hw
flow = np.full((h, w, 2), np.nan, dtype=np.float32)
flow[1, 1] = (0.9, 0.0)
settings = DisplaySettings(
flow_arrows_enabled=True,
contact_arrow_enabled=False,
flow_grid_spacing=1,
flow_on_px=0.4,
flow_off_px=0.3,
max_arrows=50,
)
dropped = SceneComposer().compose(
assets,
replace(
_depth_frame(assets, flow=flow),
flow_valid_mask=freeze(np.isfinite(flow[..., 0]), dtype=np.bool_),
),
settings,
)
n_dropped = 0 if dropped.flow_arrows is None else int(dropped.flow_arrows.origins.shape[0])
drawn = SceneComposer().compose(assets, _depth_frame(assets, flow=flow), settings)
n_drawn = 0 if drawn.flow_arrows is None else int(drawn.flow_arrows.origins.shape[0])
assert n_drawn >= 1, "the island mask still produced no 3D arrows"
assert n_drawn > n_dropped, (n_drawn, n_dropped)
+30
View File
@@ -208,6 +208,36 @@ def test_a_platform_without_a_context_says_so_instead_of_showing_an_empty_view(
# --------------------------------------------------------- one state, two controls # --------------------------------------------------------- one state, two controls
def test_an_analysis_packet_ticks_the_3d_pane(make_window, monkeypatch):
"""Replay has no run timer: submit must flush the compose so the frame is presented."""
window = make_window(sensor_type="finger")
try:
flushes: list[int] = []
submitted: list[object] = []
policies: list[object] = []
class _View:
def set_overlay_policy(self, policy) -> None:
policies.append(policy)
def submit_packet(self, packet) -> None:
submitted.append(packet)
def flush(self) -> None:
flushes.append(1)
# A full Scene3DView needs GL; only the submit/flush seam is under test here.
window.mesh3d_panel._view = _View() # noqa: SLF001
monkeypatch.setattr(window, "_connect_analysis_display", lambda panel: True)
window.show_analysis_packet(object())
assert submitted, "the analysis packet never reached the 3D producer"
assert policies, "the overlay policy was not handed to the 3D view"
assert flushes, "the pane was not flushed after submit"
finally:
window.mesh3d_panel._view = None # noqa: SLF001
window.close()
def test_the_pane_and_the_view_menu_are_the_same_flag(make_window): def test_the_pane_and_the_view_menu_are_the_same_flag(make_window):
window = make_window(sensor_type="finger") window = make_window(sensor_type="finger")
try: try:
+27
View File
@@ -1509,6 +1509,33 @@ def test_the_source_picker_follows_the_view(make_window):
_finish(window, stuck) _finish(window, stuck)
def test_live_run_refuses_a_video_source(make_window, monkeypatch):
"""Live acquires from a camera; a leftover video mode must not start a chain."""
from orisys_app_shared.i18n import t
from PySide6.QtWidgets import QApplication
window = make_window(sensor_type="finger", source_mode="camera")
try:
window.brand_bar.btn_live.click()
QApplication.processEvents()
window.toolbar.set_video_path(r"C:\tmp\demo.mp4")
window._set_source_mode("video")
QApplication.processEvents()
assert not window.toolbar.btn_run.isEnabled(), "Run is offered for a video file in live"
called: list[str] = []
monkeypatch.setattr(
"orisys_app_shared.acquisition.sdk_worker.orisys_importable",
lambda: called.append("import") or (True, ""),
)
window.toolbar.set_run_checked(True)
window._start_run()
assert called == [], "a video source reached the acquisition path"
assert window.toolbar.btn_run.isChecked() is False
assert t("toolbar.run_video_in_replay") in window.statusBar().currentMessage()
finally:
window.close()
def test_the_nav_items_switch_the_main_window_layout(make_window): def test_the_nav_items_switch_the_main_window_layout(make_window):
window, stuck = _running_window(make_window) window, stuck = _running_window(make_window)
for index in range(60): for index in range(60):
@@ -0,0 +1,607 @@
"""Manufacture the workspace the analysis pass already expects, for a bare video.
A video with no recording beside it has no ``frames.csv`` and no ``session.json``. The
pass reads nothing else: frame identity, signal time, the segment index and the chain
specification all come from those two files. There are two ways to make a bare video
analysable — teach :class:`~orisys_app_shared.analysis.service.AnalysisService` a second
input shape, or **generate the metadata it already reads**. This module does the second,
because the pass's rules about frame identity (Part A §5–§6 of ``replay-analysis.md``)
are exactly the rules a bare video needs and a second code path would be a second set of
them.
What is generated, and what it therefore means:
* **``frames.csv``** — one row per *container* frame of the video, with
``t_rel_s = index / fps``. A bare video has only container time, so here the container's
own clock **is** the signal time base; for a recording the two are different numbers
(33 frames at 30 fps is 1.100 s of video carrying 1.920 s of signal) and this module
never touches that path. Frame ids start at 1 and are dense, and ``in_video`` is 1 for
every row: nothing was dropped, because nothing was captured.
* **``session.json``** — the identity the pass keys a result by, the ``device`` block that
decides which chain to drive, and the ``stats`` the segment plan is read from. It is
**deterministic in the video and the chosen profile**: no wall-clock time, no
generated-at field inside anything ``recording_identity`` reads, so reopening the same
video resolves to the same key and reuses the stored result rather than recomputing it.
* **``raw_0001.mp4``** — the operator's own file, hard-linked when the filesystem allows
it and copied when it does not. One file is one segment, which is why a *multi-file*
input is refused rather than reduced to its first file (see :func:`resolve_video`).
**The workspace is app-owned.** It is written under
:func:`~orisys_app_shared.paths.video_workspace_dir` — the same per-user derived-data root
the analysis cache lives in — and never beside the video: the folder the operator keeps
their footage in is their data, and the app has no business writing a frame index into it.
The session document carries a ``synthetic`` block naming the source video, and the pass
copies it into the result's provenance, so a recomputed measurement can never be read as a
recorded one.
"""
from __future__ import annotations
import csv
import hashlib
import json
import logging
import os
import shutil
import time
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from orisys_app_shared import paths as paths_module
from orisys_app_shared.record.playback import RecordingFrame
if TYPE_CHECKING: # pragma: no cover - typing only
from orisys_app_shared.sensors.bundle_catalog import CatalogEntry
logger = logging.getLogger(__name__)
__all__ = [
"BareVideoError",
"BareVideoSetup",
"SyntheticWorkspace",
"VideoFacts",
"build_workspace",
"is_synthetic",
"profiles_for_video",
"read_synthetic",
"resolve_video",
"session_document",
"setup_blocked_reason",
"source_video",
"synthetic_frame_index",
"workspace_directory",
]
#: Generator identity, recorded in the session's ``synthetic`` block. A change to how the
#: frame index is derived must change this string: two workspaces with different rows must
#: not look like the same input.
GENERATOR = "orisys-bare-video-1"
#: The two files the pass reads, plus the single segment they name.
FRAMES_FILENAME = "frames.csv"
SESSION_FILENAME = "session.json"
VIDEO_FILENAME = "raw_0001.mp4"
#: Column order of ``frames.csv``; ``RecordingReader`` reads these names, and the ignored
#: ones are written empty rather than omitted so the file says what it does not know.
FRAME_COLUMNS = (
"frame_id",
"t_rel_s",
"acquired_at",
"segment",
"segment_frame",
"in_video",
"dropped",
)
#: Suffixes a video is recognised by. Only used to *find* the one file of an input; a file
#: with an unusual extension is still accepted when the operator points straight at it.
VIDEO_SUFFIXES = (".mp4", ".avi", ".mov", ".mkv", ".m4v", ".wmv", ".mpg", ".mpeg")
#: The hash window used for the video's identity. Two megabytes of a multi-gigabyte file is
#: enough to tell one video from another and cheap enough to run when the file is opened;
#: the file's size and name travel with it, so a truncated copy is a different video too.
_IDENTITY_CHUNK = 1 << 20
class BareVideoError(RuntimeError):
"""The input cannot become an analysable workspace, with the reason it cannot."""
@dataclass(frozen=True)
class VideoFacts:
"""What the container says about the video, measured rather than assumed."""
path: Path
frame_count: int
fps: float
width: int
height: int
@property
def duration_s(self) -> float:
return float(self.frame_count) / float(self.fps) if self.fps > 0 else 0.0
@dataclass(frozen=True)
class BareVideoSetup:
"""What the operator chose in the setup prompt.
``calibration`` is ``None`` for raw force: "no calibration" is a choice, not a missing
value, and the difference decides both the chain's ``cal_path`` and the result's
calibration key component.
"""
profile_ref: str
calibration: Path | None = None
#: The frame the tracker reference is reset at, or ``None`` for the pass's own default
#: (the first frame it processes — which *is* a clean tracker, so the first frame is the
#: reference without a reset).
reference_frame: int | None = None
@dataclass(frozen=True)
class SyntheticWorkspace:
"""One generated workspace, and the facts the caller needs about it."""
directory: Path
video: Path
facts: VideoFacts
frames: tuple[RecordingFrame, ...]
session: dict[str, Any]
@property
def frame_ids(self) -> tuple[int, ...]:
return tuple(int(row.frame_id) for row in self.frames)
@property
def first_frame_id(self) -> int:
return int(self.frames[0].frame_id) if self.frames else 0
@property
def last_frame_id(self) -> int:
return int(self.frames[-1].frame_id) if self.frames else 0
# ------------------------------------------------------------------------ the input
def _segment_family(video: Path) -> list[Path]:
"""Every sibling that is another *segment* of the same recording.
A recording the recorder split is ``raw_0001.mp4``, ``raw_0002.mp4``, …; a camera that
writes numbered files produces ``clip_0001.mp4`` beside ``clip_0002.mp4``. Both are one
measurement, so opening one member and analysing it silently would answer with a part
of the footage and present it as the whole — a measurement error, not a missing feature.
"""
stem = video.stem
digits = ""
for char in reversed(stem):
if not char.isdigit():
break
digits = char + digits
if len(digits) < 3:
return [video]
base = stem[: len(stem) - len(digits)]
siblings = sorted(
path
for path in video.parent.iterdir()
if path.is_file()
and path.suffix.lower() == video.suffix.lower()
and path.stem.startswith(base)
and path.stem[len(base) :].isdigit()
and len(path.stem) - len(base) >= 3
)
return siblings or [video]
def resolve_video(path: str | Path) -> Path:
"""The one video file an input names, or a refusal saying why it is not one.
A **folder** is read as "the videos in here": exactly one is the video, and more than
one is refused by name — analysing the first of several files while calling the result
the folder's measurement is the silent half-answer this exists to prevent. A **file**
that belongs to a numbered set (``raw_0001.mp4`` beside ``raw_0002.mp4``) is refused
for the same reason.
"""
candidate = Path(path)
if candidate.is_dir():
found = [
child
for child in sorted(candidate.iterdir())
if child.is_file() and child.suffix.lower() in VIDEO_SUFFIXES
]
if not found:
raise BareVideoError(f"{candidate} holds no video file")
if len(found) > 1:
names = ", ".join(child.name for child in found[:6])
raise BareVideoError(
f"{candidate} holds {len(found)} video files ({names}): one pass reads one "
"file, and a split recording is more than one measurement. Open the single "
"file to analyse."
)
candidate = found[0]
elif not candidate.is_file():
raise BareVideoError(f"{candidate} is neither a video file nor a folder")
if candidate.suffix.lower() not in VIDEO_SUFFIXES:
raise BareVideoError(
f"{candidate.name} is not a video this pass recognises "
f"({'/'.join(VIDEO_SUFFIXES)})"
)
family = _segment_family(candidate)
if len(family) > 1:
names = ", ".join(item.name for item in family[:6])
raise BareVideoError(
f"{candidate.name} is one segment of a split recording ({names}): a pass over "
"one segment would describe part of the measurement as the whole of it. "
"Analyse a single, complete video file."
)
return candidate
def probe(video: str | Path) -> VideoFacts:
"""Frames, rate and size, from the container. Raises when it cannot be decoded."""
import cv2
path = Path(video)
capture = cv2.VideoCapture(str(path))
try:
if not capture.isOpened():
raise BareVideoError(f"{path.name} could not be opened as a video")
fps = float(capture.get(cv2.CAP_PROP_FPS) or 0.0)
count = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
finally:
capture.release()
if not (fps > 0.0) or count <= 0:
# A timeline nobody can place a frame on: either number being absent makes every
# ``t_rel_s`` a guess, and a guess here is a cursor that points at another frame.
raise BareVideoError(
f"{path.name} reports no frame count or frame rate ({count} frames, {fps} fps), "
"so it has no timeline to analyse"
)
return VideoFacts(path=path, frame_count=count, fps=fps, width=width, height=height)
def synthetic_frame_index(facts: VideoFacts) -> tuple[RecordingFrame, ...]:
"""One row per container frame: frame id, signal time, segment and flags.
``t_rel_s`` is the container's own clock divided out, in seconds, from a dense frame
index — the only time base a bare video has. Nothing is marked dropped or absent,
because nothing was captured: the video *is* the record of the frames it holds.
"""
rows: list[RecordingFrame] = []
for index in range(int(facts.frame_count)):
rows.append(
RecordingFrame(
frame_id=index + 1,
t_rel_s=round(index / float(facts.fps), 9),
acquired_at="",
segment=1,
segment_frame=index + 1,
in_video=True,
dropped=False,
)
)
return tuple(rows)
def video_identity(video: str | Path) -> tuple[str, dict[str, Any]]:
"""A path-independent identity for a video, with the facts it was computed from.
Deliberately not the path: the same file opened from two places is the same video, and
a re-used path with different footage is not. Size and a two-megabyte head/tail digest
are cheap enough to take on every open and strong enough that the workspace directory
name changes when the footage does — which is what makes the analysis *key* stable for
an unchanged video and different for a changed one.
"""
path = Path(video)
size = int(path.stat().st_size)
hasher = hashlib.blake2b(digest_size=8)
hasher.update(path.name.encode("utf-8", "replace"))
hasher.update(str(size).encode("ascii"))
with path.open("rb") as handle:
head = handle.read(_IDENTITY_CHUNK)
hasher.update(head)
if size > _IDENTITY_CHUNK:
handle.seek(max(0, size - _IDENTITY_CHUNK))
hasher.update(handle.read(_IDENTITY_CHUNK))
digest = hasher.hexdigest()
return digest, {"name": path.name, "bytes": size, "digest": digest}
def _session_id(identity: str) -> int:
"""A stable integer session id from the video's own identity.
The recorder's counter is meaningless for a video nobody recorded, and a wall-clock
one would give the same video a new analysis key every time it was opened. The top
half of the identity digest is both stable and collision-free in practice.
"""
return int(identity[:12], 16) % (2**31)
def _calibration_identity(path: Path | None) -> str:
"""A calibration's key component: its name plus a digest of its own content.
The *content* is what decides the numbers the chain computes, so a coefficient file
edited in place must not keep serving a result computed from the old one. The name
travels with it because a person reading the provenance needs to know which file.
"""
if path is None:
return ""
try:
digest = hashlib.sha256(Path(path).read_bytes()).hexdigest()[:12]
except OSError:
return Path(path).name
return f"{Path(path).name}@{digest}"
def session_document(
facts: VideoFacts,
frames: Sequence[RecordingFrame],
*,
profile: CatalogEntry,
calibration: Path | None,
reference_frame: int | None,
identity: str,
source_facts: dict[str, Any],
) -> dict[str, Any]:
"""The ``session.json`` a bare video is analysed under.
Shaped exactly like a recording's, because the pass reads it through the same
``RecordingReader`` and ``chain_spec_for``: ``device`` decides which chain is driven and
with which configuration, ``stats`` decides how many segments there should be, and
``files`` names them. Everything ``recording_identity`` hashes is derived from the video
and the choice, never from the clock — that is what makes reopening the video a cache
hit rather than a second minute of work.
"""
sensor_json = profile.sensor_json_path
config_ref = str(sensor_json) if sensor_json is not None else str(profile.bundle_path)
return {
"session_id": _session_id(identity),
"started_at": "",
"ended_at": "",
"device": {
"sensor_type": _chain_type(profile),
"config_ref": config_ref,
"config_sha256": _file_sha256(sensor_json),
},
"stats": {
"frames": len(frames),
"video_frames": len(frames),
"segments": 1,
"fps": float(facts.fps),
},
"files": {"video": [VIDEO_FILENAME]},
"preroll_frames": 0,
"preroll_segment": "",
"calibration_id": _calibration_identity(calibration),
"depth": {"stored": True, "replay": "analysis"},
"synthetic": {
"generator": GENERATOR,
"source_video": str(Path(facts.path).resolve()),
"source_name": source_facts.get("name", Path(facts.path).name),
"source_bytes": int(source_facts.get("bytes") or 0),
"source_digest": str(source_facts.get("digest") or identity),
"identity": identity,
"profile_ref": str(profile.ref),
"profile_id": str(profile.profile_id or ""),
"calibration": "" if calibration is None else str(Path(calibration).resolve()),
"reference_frame": None
if reference_frame is None
else int(reference_frame),
"frame_index": (
"generated: one row per container frame, t_rel_s = index / fps; the "
"container's clock is the only time base a bare video has"
),
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
},
}
def _chain_type(profile: CatalogEntry) -> str:
"""Which chain this profile is driven by (``finger`` or ``box``).
The SDK's product family is the answer, and the two names the app's chains know are
``finger`` and ``box``: Mini is a single-camera box pipeline, so it drives the box
chain. A family that maps to neither is refused here rather than discovered at the
first chain call.
"""
family = str(profile.product_family or "").strip().lower()
if family == "finger":
return "finger"
if family in ("box", "mini"):
return "box"
raise BareVideoError(
f"{profile.name} declares product family {profile.product_family or 'unknown'!r}, "
"which this pass has no chain for"
)
def _file_sha256(path: Path | None) -> str:
if path is None:
return ""
try:
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
except OSError:
return ""
# -------------------------------------------------------------------- the workspace
def workspace_directory(identity: str, *, root: str | Path | None = None) -> Path:
"""Where one video's workspace lives: the app's own root, keyed by the video."""
base = paths_module.video_workspace_dir() if root is None else Path(root)
return Path(base) / identity
def _write_frames(path: Path, frames: Sequence[RecordingFrame]) -> None:
temporary = path.with_name(path.name + ".part")
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
writer = csv.writer(handle, lineterminator="\n")
writer.writerow(FRAME_COLUMNS)
for row in frames:
writer.writerow(
[
int(row.frame_id),
f"{float(row.t_rel_s):.9f}".rstrip("0").rstrip(".")
if row.t_rel_s
else "0",
row.acquired_at,
int(row.segment),
int(row.segment_frame),
1 if row.in_video else 0,
1 if row.dropped else 0,
]
)
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def _write_session(path: Path, document: dict[str, Any]) -> None:
temporary = path.with_name(path.name + ".part")
with temporary.open("w", encoding="utf-8", newline="\n") as handle:
json.dump(document, handle, indent=2, ensure_ascii=False, sort_keys=False)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
os.replace(temporary, path)
def _link_or_copy(video: Path, target: Path) -> str:
"""The video inside the workspace: a hard link when possible, else a copy.
A link is how the workspace can hold *the* video without holding a second copy of it —
a ten-minute clip is gigabytes. Where the filesystem refuses one (the workspace and the
video on different volumes, a filesystem without links), the copy is the honest
fallback: the numbers do not depend on which happened, and the session records it.
"""
if target.exists():
try:
if os.path.samefile(target, video):
return "existing"
except OSError:
pass
target.unlink()
try:
os.link(video, target)
return "link"
except OSError:
logger.info("could not link %s into the workspace; copying instead", video)
shutil.copy2(video, target)
return "copy"
def build_workspace(
video: str | Path,
*,
profile: CatalogEntry,
setup: BareVideoSetup | None = None,
root: str | Path | None = None,
) -> SyntheticWorkspace:
"""Generate (or refresh) the workspace for one video, and return what it says.
Idempotent by construction: the same video and profile produce the same rows, the same
document and the same directory, so reopening a video is a rewrite of two small text
files and a reuse of the stored analysis result rather than a second pass.
"""
resolved = resolve_video(video)
facts = probe(resolved)
identity, source_facts = video_identity(resolved)
calibration = None if setup is None else setup.calibration
reference_frame = None if setup is None else setup.reference_frame
if calibration is not None and not Path(calibration).is_file():
raise BareVideoError(f"the chosen calibration {calibration} is not there")
frames = synthetic_frame_index(facts)
document = session_document(
facts,
frames,
profile=profile,
calibration=calibration,
reference_frame=reference_frame,
identity=identity,
source_facts=source_facts,
)
directory = workspace_directory(identity, root=root)
directory.mkdir(parents=True, exist_ok=True)
mode = _link_or_copy(resolved, directory / VIDEO_FILENAME)
document["synthetic"]["video_in_workspace"] = mode
_write_frames(directory / FRAMES_FILENAME, frames)
_write_session(directory / SESSION_FILENAME, document)
return SyntheticWorkspace(
directory=directory,
video=resolved,
facts=facts,
frames=frames,
session=document,
)
def read_synthetic(directory: str | Path) -> dict[str, Any] | None:
"""The ``synthetic`` block of a workspace, or ``None`` for a real recording.
The one question the app asks a directory to know what it is about to analyse: a
recording carries no such block, so its absence is the answer "this is somebody's
measured session", which must never be given the bare-video treatment.
"""
path = Path(directory) / SESSION_FILENAME
if not path.is_file():
return None
try:
document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(document, dict):
return None
block = document.get("synthetic")
return dict(block) if isinstance(block, dict) else None
def is_synthetic(directory: str | Path) -> bool:
"""Whether this directory is a generated workspace rather than a recording."""
return read_synthetic(directory) is not None
def source_video(directory: str | Path) -> Path | None:
"""The operator's video behind a workspace, when the block names one that exists."""
block = read_synthetic(directory)
if not block:
return None
named = str(block.get("source_video") or "")
if not named:
return None
path = Path(named)
return path if path.is_file() else None
def profiles_for_video(entries: Iterable[CatalogEntry]) -> list[CatalogEntry]:
"""Every catalog entry a bare video could be set up with, in catalog order.
**Including the ones that must be refused**, because their reason is what the operator
has to read: a profile the SDK rejected and a profile whose product family this app has
no chain for are both listed, each disabled with its own sentence
(:func:`setup_blocked_reason`), rather than missing from the list with nothing to say
why. What is left out is a folder no bundle could be read from at all — there is no
profile behind it, so there is nothing to process with and nothing to refuse *by*.
"""
return [entry for entry in entries if entry.profile is not None]
def setup_blocked_reason(entry: CatalogEntry) -> str | None:
"""Why this profile cannot be set up for a bare video, or ``None`` when it can.
Two reasons from two owners, and the order matters: the SDK's own verdict first
(``CatalogEntry.start_blocked_reason`` — a validation failure, or a ``profile_id`` two
bundles both declare), then this app's own limit (no processing chain for the profile's
product family). The SDK's answer wins when both apply, because it is about the bundle
the operator chose and there is something they can do about it.
"""
blocked = entry.start_blocked_reason
if blocked:
return blocked
try:
_chain_type(entry)
except BareVideoError as exc:
return str(exc)
return None
@@ -95,6 +95,10 @@ class SceneComposer:
#: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from. #: The resolved ``#RRGGBB,...`` ramp both views build their lookup table from.
#: Set by the view when the theme changes; empty means this build's default. #: Set by the view when the theme changes; empty means this build's default.
self.ramp = str(ramp) self.ramp = str(ramp)
#: Optional flow-arrow policy for replay analysis. When set, overlays sample with
#: this policy instead of ``policy_for_settings`` so the 3D arrows match the 2D
#: canvas's ``paint_policy``. Live acquisition leaves it ``None``.
self.overlay_policy = None
def compose( def compose(
self, self,
@@ -253,7 +257,13 @@ class SceneComposer:
) )
return flow_arrows, contact_arrow return flow_arrows, contact_arrow
policy = policy_for_settings(settings) # Replay analysis can pin the same policy the 2D canvas uses (stored grid +
# display or painter threshold). Live leaves ``overlay_policy`` unset.
policy = (
self.overlay_policy
if self.overlay_policy is not None
else policy_for_settings(settings)
)
if ( if (
settings.flow_arrows_enabled settings.flow_arrows_enabled
and frame.flow_uv is not None and frame.flow_uv is not None
+505
View File
@@ -0,0 +1,505 @@
"""A bare video's generated workspace: the two files the pass reads, and what they say.
The pass has one input shape — a directory with ``frames.csv``, ``session.json`` and
``raw_*.mp4`` — and it is a good shape: frame identity comes from the recording's own
counter, signal time from its own table, and the chain from its own metadata. A bare video
gets that shape *manufactured* for it rather than a second code path in the pass, so these
tests defend three properties of the manufacture: the rows are the container's real frames,
the documents are deterministic enough that reopening the video is a cache hit, and nothing
is written anywhere near the operator's file.
No SDK and no real footage: a video written by OpenCV here is the input, and the pass itself
is exercised in ``apps/studio/tests/test_bare_video_analysis.py`` over recorded footage.
"""
from __future__ import annotations
import csv
import json
from pathlib import Path
import pytest
from orisys_app_shared import paths as paths_module
from orisys_app_shared.analysis import bare_video
from orisys_app_shared.analysis.model import ForceSample, encode_frame, decode_frame
from orisys_app_shared.analysis.service import ChainSpec
from orisys_app_shared.record.playback import RecordingReader
FRAMES = 12
FPS = 30.0
# ------------------------------------------------------------------------ fixtures
def write_video(path: Path, *, frames: int = FRAMES, fps: float = FPS) -> Path:
"""A decodable video of ``frames`` distinct frames, written by OpenCV."""
cv2 = pytest.importorskip("cv2", reason="OpenCV writes and reads the fixture video")
import numpy as np
path.parent.mkdir(parents=True, exist_ok=True)
writer = cv2.VideoWriter(
str(path), cv2.VideoWriter_fourcc(*"mp4v"), float(fps), (64, 48)
)
assert writer.isOpened(), "the fixture video could not be opened for writing"
for index in range(frames):
image = np.full((48, 64, 3), 30 + index * 8, dtype=np.uint8)
image[::8, :, :] = 200
image[:, ::8, :] = 90
writer.write(image)
writer.release()
assert path.is_file() and path.stat().st_size > 0
return path
class _Entry:
"""The catalog-entry fields the workspace generator asks about."""
def __init__(self, *, family: str = "box", name: str = "Box", blocked: str = "",
sensor_json: Path | None = None, ref: str = "shipped/box",
has_profile: bool = True) -> None:
self.product_family = family
self.name = name
self.ref = ref
self.folder = ref.split("/")[-1]
self.profile_id = f"orisys.{family}.r01"
self.display_name = name
self.bundle_path = Path(sensor_json).parent if sensor_json else Path(".")
self.sensor_json_path = sensor_json
#: ``None`` stands for a folder no bundle could be read from at all.
self.profile = object() if has_profile else None
self._blocked = blocked
@property
def start_blocked_reason(self) -> str | None:
return self._blocked or None
@pytest.fixture
def video(tmp_path) -> Path:
return write_video(tmp_path / "operator" / "clip.mp4")
@pytest.fixture
def workspace_root(tmp_path, monkeypatch) -> Path:
"""The app-owned root the workspace goes to, pointed at this test."""
root = tmp_path / "derived" / "video-workspaces"
monkeypatch.setattr(paths_module, "video_workspace_dir", lambda: root)
return root
def _build(video: Path, **kwargs) -> bare_video.SyntheticWorkspace:
return bare_video.build_workspace(video, profile=_Entry(**kwargs), setup=None)
def _entry_set(directory: Path) -> set:
"""Every entry under ``directory`` as ``(relative path, size)``."""
found = set()
for path in Path(directory).rglob("*"):
relative = str(path.relative_to(directory))
found.add((relative, -1 if path.is_dir() else path.stat().st_size))
return found
# ------------------------------------------------------------------- the frame index
def test_the_frame_index_is_one_row_per_container_frame(video, workspace_root):
"""``frames.csv`` is the video's own frames: ids from 1, signal time from the rate.
A bare video has one clock — the container's — so ``t_rel_s`` is that clock divided
out, and every row is in the video because nothing was captured and nothing was lost.
"""
workspace = _build(video)
assert [row.frame_id for row in workspace.frames] == list(range(1, FRAMES + 1))
assert [row.segment for row in workspace.frames] == [1] * FRAMES
assert [row.segment_frame for row in workspace.frames] == list(range(1, FRAMES + 1))
assert all(row.in_video for row in workspace.frames)
assert not any(row.dropped for row in workspace.frames)
assert workspace.frames[0].t_rel_s == 0.0
assert workspace.frames[-1].t_rel_s == pytest.approx((FRAMES - 1) / FPS, abs=1e-9)
# The file is read by the pass's own reader, so it has to say the same thing the
# in-memory rows do — that equality is the whole point of generating it.
reader = RecordingReader(workspace.directory)
try:
loaded = reader.info().frames
finally:
reader.close()
assert [(row.frame_id, row.segment, row.segment_frame, row.in_video) for row in loaded] == [
(row.frame_id, row.segment, row.segment_frame, row.in_video) for row in workspace.frames
]
assert [row.t_rel_s for row in loaded] == pytest.approx(
[row.t_rel_s for row in workspace.frames], abs=1e-9
)
def test_the_session_document_says_what_the_pass_needs(video, workspace_root, tmp_path):
"""``device``/``stats``/``files`` are what the chain and the segment plan are read from."""
config = tmp_path / "sensors" / "box" / "sensor.json"
config.parent.mkdir(parents=True)
config.write_text('{"camera": {"fps": 30}}', encoding="utf-8")
workspace = _build(video, sensor_json=config)
session = workspace.session
assert session["device"]["sensor_type"] == "box"
assert session["device"]["config_ref"] == str(config)
assert session["device"]["config_sha256"]
assert session["stats"]["frames"] == FRAMES
assert session["stats"]["video_frames"] == FRAMES
assert session["stats"]["segments"] == 1
assert session["files"]["video"] == ["raw_0001.mp4"]
assert (workspace.directory / "raw_0001.mp4").is_file()
# ...and the pass's own chain reader agrees, from the file rather than from memory.
reader = RecordingReader(workspace.directory)
try:
assert reader.session()["device"]["sensor_type"] == "box"
finally:
reader.close()
def test_the_workspace_is_marked_synthetic(video, workspace_root):
"""A generated workspace must be distinguishable from a recording, forever.
``read_synthetic`` is the question the app asks a directory before it decides how to
treat it, and it answers ``None`` for a real recording — the difference between
"somebody measured this" and "this was generated here".
"""
workspace = _build(video)
block = bare_video.read_synthetic(workspace.directory)
assert block is not None
assert block["generator"] == bare_video.GENERATOR
assert block["source_video"] == str(Path(video).resolve())
assert block["frame_index"].startswith("generated:")
assert bare_video.is_synthetic(workspace.directory)
assert bare_video.source_video(workspace.directory) == Path(video).resolve()
# A real recording's session.json has no such block, and a folder without one is not
# a workspace at all.
plain = workspace.directory.parent / "plain"
plain.mkdir()
(plain / "session.json").write_text(json.dumps({"session_id": 1}), encoding="utf-8")
assert bare_video.read_synthetic(plain) is None
assert bare_video.read_synthetic(workspace.directory / "nothing-here") is None
def test_reopening_the_same_video_is_the_same_analysis_key(video, workspace_root, tmp_path):
"""Determinism is not tidiness: the key is what makes a second open a cache read.
``recording_identity`` hashes the session's own facts, so anything derived from the
clock would give the same video a new result directory on every open — and the pass
would run again every time.
"""
config = tmp_path / "sensors" / "box" / "sensor.json"
config.parent.mkdir(parents=True)
config.write_text("{}", encoding="utf-8")
chain = ChainSpec(sensor_type="box", config_ref=config)
first = _build(video, sensor_json=config)
again = _build(video, sensor_json=config)
assert again.directory == first.directory
assert again.session["session_id"] == first.session["session_id"]
from orisys_app_shared.analysis.service import AnalysisService
keys = [
AnalysisService(item.directory, chain=chain).dependency_report()["key"].digest
for item in (first, again)
]
assert keys[0] == keys[1], "reopening the video produced a different result key"
# A different video is a different workspace and a different key.
other = write_video(video.parent / "other.mp4", frames=FRAMES + 3)
third = _build(other, sensor_json=config)
assert third.directory != first.directory
assert (
AnalysisService(third.directory, chain=chain).dependency_report()["key"].digest
!= keys[0]
)
def test_the_calibration_is_part_of_the_key_by_its_content(video, workspace_root, tmp_path):
"""Coefficients edited in place must not keep serving a result computed from the old ones."""
config = tmp_path / "sensors" / "box" / "sensor.json"
config.parent.mkdir(parents=True)
config.write_text("{}", encoding="utf-8")
calibration = tmp_path / "force_calibration.json"
calibration.write_text('{"gain": 1}', encoding="utf-8")
first = bare_video.build_workspace(
video,
profile=_Entry(sensor_json=config),
setup=bare_video.BareVideoSetup(profile_ref="shipped/box", calibration=calibration),
)
calibration.write_text('{"gain": 2}', encoding="utf-8")
second = bare_video.build_workspace(
video,
profile=_Entry(sensor_json=config),
setup=bare_video.BareVideoSetup(profile_ref="shipped/box", calibration=calibration),
)
assert first.session["calibration_id"] != second.session["calibration_id"]
# The name still travels with the digest, so a reader knows which file it was.
assert second.session["calibration_id"].startswith("force_calibration.json@")
raw = bare_video.build_workspace(
video,
profile=_Entry(sensor_json=config),
setup=bare_video.BareVideoSetup(profile_ref="shipped/box", calibration=None),
)
assert raw.session["calibration_id"] == ""
def test_a_chosen_calibration_that_is_not_there_is_refused(video, workspace_root, tmp_path):
with pytest.raises(bare_video.BareVideoError):
bare_video.build_workspace(
video,
profile=_Entry(),
setup=bare_video.BareVideoSetup(
profile_ref="shipped/box", calibration=tmp_path / "absent.json"
),
)
# ------------------------------------------------------------------- what is refused
def test_a_split_recording_is_refused_rather_than_half_analysed(tmp_path):
"""More than one file is one measurement, not one input: refuse it, by name.
Analysing the first segment of a split recording and calling the result that
recording's measurement is a measurement error, and it is silent — the only visible
symptom is a short timeline.
"""
segments = tmp_path / "split"
segments.mkdir()
write_video(segments / "raw_0001.mp4")
write_video(segments / "raw_0002.mp4", frames=4)
with pytest.raises(bare_video.BareVideoError) as failure:
bare_video.resolve_video(segments / "raw_0001.mp4")
assert "raw_0001.mp4" in str(failure.value) and "raw_0002.mp4" in str(failure.value)
assert "split recording" in str(failure.value)
# A folder is read as "the video in here", and a folder of two is the same refusal.
with pytest.raises(bare_video.BareVideoError) as folder_failure:
bare_video.resolve_video(segments)
assert "holds 2 video files" in str(folder_failure.value)
def test_a_lone_video_in_a_folder_is_that_video(tmp_path):
"""One file is unambiguous, however it is pointed at."""
folder = tmp_path / "single"
folder.mkdir()
only = write_video(folder / "clip.mp4")
assert bare_video.resolve_video(folder) == only
assert bare_video.resolve_video(only) == only
def test_something_that_is_not_a_video_is_refused_with_its_reason(tmp_path):
text = tmp_path / "notes.txt"
text.write_text("notes", encoding="utf-8")
with pytest.raises(bare_video.BareVideoError) as failure:
bare_video.resolve_video(text)
assert "not a video" in str(failure.value)
with pytest.raises(bare_video.BareVideoError):
bare_video.resolve_video(tmp_path / "absent.mp4")
empty = tmp_path / "empty"
empty.mkdir()
with pytest.raises(bare_video.BareVideoError):
bare_video.resolve_video(empty)
def test_a_container_without_a_timeline_is_refused(tmp_path):
"""No frame count or rate means every ``t_rel_s`` would be a guess."""
broken = tmp_path / "broken.mp4"
broken.write_bytes(b"not a container")
with pytest.raises(bare_video.BareVideoError):
bare_video.probe(broken)
def test_a_profile_with_no_chain_is_listed_with_its_own_reason(tmp_path):
"""Nothing is hidden: every entry is offered, and the refused ones say why.
Two different refusals, from two owners — the SDK's validation and this app's chain
table — and both have to reach the operator as a sentence rather than as an entry that
is simply missing from the list.
"""
entries = [
_Entry(family="box", ref="shipped/box"),
_Entry(family="finger", ref="shipped/finger"),
_Entry(family="finger-mems", ref="shipped/finger-mems", name="MEMS"),
_Entry(
family="box",
ref="user/box",
name="Box (user)",
blocked="the SDK rejected this profile (failed)",
),
]
assert [entry.ref for entry in bare_video.profiles_for_video(entries)] == [
"shipped/box",
"shipped/finger",
"shipped/finger-mems",
"user/box",
]
assert bare_video.setup_blocked_reason(entries[0]) is None
assert bare_video.setup_blocked_reason(entries[1]) is None
assert "finger-mems" in str(bare_video.setup_blocked_reason(entries[2]))
assert bare_video.setup_blocked_reason(entries[3]) == (
"the SDK rejected this profile (failed)"
), "the SDK's own verdict must win over this app's chain limit"
# A folder no bundle could be read from has no profile behind it, so there is nothing
# to process with — and nothing to refuse *by* either.
assert bare_video.profiles_for_video([_Entry(has_profile=False)]) == []
with pytest.raises(bare_video.BareVideoError) as failure:
bare_video.build_workspace(
write_video(tmp_path / "clip.mp4"), profile=_Entry(family="uv_unroll")
)
assert "no chain" in str(failure.value)
# ------------------------------------------------------------------------- the files
def test_nothing_is_written_beside_the_operator_s_video(video, workspace_root):
"""The frame index is the app's derived data, and the video's folder is the operator's."""
before = _entry_set(video.parent)
workspace = _build(video)
assert _entry_set(video.parent) == before, "the workspace wrote into the video's folder"
assert workspace.directory.parent == workspace_root
assert sorted(path.name for path in workspace.directory.iterdir()) == [
"frames.csv",
"raw_0001.mp4",
"session.json",
]
def test_the_video_inside_the_workspace_is_the_operator_s_own_file(video, workspace_root):
"""A link when the filesystem allows one, and the copy is the same bytes when it does not."""
workspace = _build(video)
inside = workspace.directory / "raw_0001.mp4"
assert inside.read_bytes() == video.read_bytes()
mode = workspace.session["synthetic"]["video_in_workspace"]
assert mode in ("link", "copy", "existing")
# Rebuilding finds the same file rather than stacking a second copy.
again = _build(video)
assert again.session["synthetic"]["video_in_workspace"] == "existing"
def test_the_frame_index_is_written_as_the_columns_the_reader_wants(video, workspace_root):
workspace = _build(video)
with (workspace.directory / "frames.csv").open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle))
assert list(rows[0]) == list(bare_video.FRAME_COLUMNS)
assert len(rows) == FRAMES
assert rows[0]["frame_id"] == "1"
assert rows[0]["in_video"] == "1"
assert rows[0]["dropped"] == "0"
assert rows[0]["acquired_at"] == ""
# ------------------------------------------------------------------ the force channel
def test_a_frame_carries_its_force_sample_when_the_pass_collected_one():
"""Six values and two verdicts, appended last so an older reader still reads the frame."""
sample = ForceSample(
normal=1.5,
shear_x=-0.25,
shear_y=0.0,
normal_raw=1200.0,
shear_x_raw=-30.0,
shear_y_raw=0.0,
valid=True,
calibrated=True,
)
record = encode_frame(
frame_id=7,
t_rel_s=0.25,
grid_id=0,
dx=[1.0, 2.0],
dy=[0.0, 0.0],
magnitude=[1.0, 2.0],
active=[True, False],
force=sample,
)
decoded = decode_frame(record)
assert decoded.frame_id == 7
assert decoded.force == sample
assert decoded.force.newtons() == (1.5, -0.25, 0.0)
assert decoded.force.raw() == (1200.0, -30.0, 0.0)
assert decoded.force.calibrated is True
# A frame the pass did not collect force for carries none, and says so by absence.
without = decode_frame(
encode_frame(
frame_id=8,
t_rel_s=0.5,
grid_id=0,
dx=[1.0],
dy=[0.0],
magnitude=[1.0],
active=[True],
)
)
assert without.force is None
# A readout the chain could not make is stored as "no measurement", never as zero.
empty = ForceSample.from_reading(
{
"normal_force": float("nan"),
"shear_x": float("nan"),
"shear_y": float("nan"),
"valid": False,
"force_calibrated": False,
}
)
assert not empty.any_finite and not empty.valid
restored = decode_frame(
encode_frame(
frame_id=9,
t_rel_s=0.75,
grid_id=0,
dx=[1.0],
dy=[0.0],
magnitude=[1.0],
active=[True],
force=empty,
)
).force
assert restored is not None and not restored.any_finite and restored.valid is False
def test_depth_does_not_swallow_the_force_sample_that_follows_it():
"""Force is appended after depth. The depth codec must stop at its own payload."""
import numpy as np
sample = ForceSample(
normal=0.5,
shear_x=0.25,
shear_y=-0.125,
normal_raw=80.0,
shear_x_raw=10.0,
shear_y_raw=-4.0,
valid=True,
calibrated=False,
)
sparse = np.zeros((16, 16), dtype=np.float32)
sparse[2, 3] = 0.03
noise = np.linspace(0.0, 1.0, 16 * 16, dtype=np.float32).reshape(16, 16)
for depth in (sparse, noise, np.zeros((8, 8), dtype=np.float32)):
decoded = decode_frame(
encode_frame(
frame_id=3,
t_rel_s=0.1,
grid_id=0,
dx=[1.0],
dy=[0.0],
magnitude=[1.0],
active=[True],
depth=depth,
force=sample,
)
)
assert decoded.force == sample
assert decoded.depth is not None
restored = decoded.depth_map()
assert restored is not None and restored.shape == depth.shape