Compare commits
7 Commits
63a45cfc1b
...
091d0fda35
| Author | SHA1 | Date | |
|---|---|---|---|
| 091d0fda35 | |||
| 3d2fb0c4eb | |||
| 19ae1f4968 | |||
| f2d7b3e83c | |||
| f2bbab76a6 | |||
| 48624dec99 | |||
| 7afd3169fd |
@@ -38,7 +38,7 @@
|
|||||||
"plot_settings": "Plot settings…",
|
"plot_settings": "Plot settings…",
|
||||||
"rotate_arrows": "Rotate arrows 90°",
|
"rotate_arrows": "Rotate arrows 90°",
|
||||||
"recompute": "Analyze video",
|
"recompute": "Analyze video",
|
||||||
"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 Analysis ▸ Analysis setup…",
|
"recompute_tip": "Use a result if there is one; otherwise analyse. Change settings in Analysis setup.",
|
||||||
"analysis_setup": "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.",
|
"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",
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
"reset_defaults": "Reset to defaults",
|
"reset_defaults": "Reset to defaults",
|
||||||
"plot_orientation_group": "Finger 2D presentation (2D only; the 3D view keeps the design frame)",
|
"plot_orientation_group": "Finger 2D presentation (2D only; the 3D view keeps the design frame)",
|
||||||
"plot_orientation": "Orientation",
|
"plot_orientation": "Orientation",
|
||||||
"plot_orientation_tip": "How the finger's 2D canvas (the unrolled domain) is presented. The 180° default is derived from the unroll map and the design frame: the canvas's +x is the frame's +X and its screen up is −Z, while the 3D default view puts −X right and +Z up, so both axes are reversed and a half turn is what makes the arrows agree left-to-right. Display only — the record and the stored arrays are untouched",
|
"plot_orientation_tip": "Affects the 2D view only; default rotation is 180°.",
|
||||||
"plot_orientation_note": "Affects the 2D finger canvas only: the stored flow, valid mask and recorded data keep the domain's own orientation. The 3D view is not affected (its frame is the design export's)",
|
"plot_orientation_note": "Affects the 2D view only; default rotation is 180°.",
|
||||||
"orientation_identity": "As drawn (no rotation)",
|
"orientation_identity": "As drawn (no rotation)",
|
||||||
"orientation_rotate180": "Rotate 180° (default)",
|
"orientation_rotate180": "Rotate 180° (default)",
|
||||||
"orientation_mirror_h": "Mirror horizontally",
|
"orientation_mirror_h": "Mirror horizontally",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
|
"filter_preset_tip": "A ready-made combination; editing any value below switches to Custom",
|
||||||
"filter_preset_custom": "Custom",
|
"filter_preset_custom": "Custom",
|
||||||
"filter_flow_ema": "Flow EMA α",
|
"filter_flow_ema": "Flow EMA α",
|
||||||
"filter_flow_ema_tip": "Temporal smoothing of the optical flow. 1.00 = off. It changes what is *measured* (contact statistics and forces along with it), so two recordings with different α are not comparable — only use it when the flow jitter really has to go",
|
"filter_flow_ema_tip": "1.00 is off. Changing it changes the measured force.",
|
||||||
"filter_median": "Median window",
|
"filter_median": "Median window",
|
||||||
"filter_median_tip": "Sliding-median window over the force readings (samples). 1 = off. Removes single-sample spikes at the cost of about half a window of delay",
|
"filter_median_tip": "Sliding-median window over the force readings (samples). 1 = off. Removes single-sample spikes at the cost of about half a window of delay",
|
||||||
"filter_lowpass": "Low-pass cutoff",
|
"filter_lowpass": "Low-pass cutoff",
|
||||||
@@ -106,6 +106,7 @@
|
|||||||
"filter_off": "Not filtering (raw readings)",
|
"filter_off": "Not filtering (raw readings)",
|
||||||
"ok": "OK",
|
"ok": "OK",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
|
"save": "Save",
|
||||||
"close": "Close"
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"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": "Analyze video…",
|
"recompute": "Analyze video…",
|
||||||
"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 Analysis ▸ Analysis setup…",
|
"recompute_tip": "Use a result if there is one; otherwise analyse. Change settings in 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",
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
"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_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 computation; the picture holds and the device stays connected (F7).",
|
||||||
"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",
|
||||||
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
|
"resume_label_tip": "Resume the computation and the display (F7): it carries on from the newest frame with the device still connected.",
|
||||||
@@ -193,6 +194,14 @@
|
|||||||
"catalog_state_unknown": "verdict not recognised",
|
"catalog_state_unknown": "verdict not recognised",
|
||||||
"catalog_calibration_selected": "Calibration selected: {name}",
|
"catalog_calibration_selected": "Calibration selected: {name}",
|
||||||
"catalog_calibration_none": "No calibration selected — raw force",
|
"catalog_calibration_none": "No calibration selected — raw force",
|
||||||
|
"live_calibration_section": "Live calibration",
|
||||||
|
"live_calibration_raw_tip": "Run will open with raw force. Apply while running only remembers the choice; restart to drop loaded coefficients.",
|
||||||
|
"live_calibration_default_tip": "Run will load the profile default {name}.",
|
||||||
|
"live_calibration_file_tip": "Run will load {name}; Apply while running hot-updates the device.",
|
||||||
|
"live_calibration_apply_tip": "Remember this choice; hot-update the device if a session is running",
|
||||||
|
"live_calibration_remembered": "Calibration choice remembered; it loads on the next run",
|
||||||
|
"live_calibration_applied": "Requested hot-update of calibration: {name}",
|
||||||
|
"live_calibration_invalid": "That calibration choice is not valid: {reason}",
|
||||||
"catalog_duplicate": "Also provided by {refs}",
|
"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",
|
||||||
@@ -206,12 +215,13 @@
|
|||||||
"view_arrows_pure": "Arrows",
|
"view_arrows_pure": "Arrows",
|
||||||
"view_arrows_stitched": "Arrows / Captured",
|
"view_arrows_stitched": "Arrows / Captured",
|
||||||
"view_depth": "Depth map",
|
"view_depth": "Depth map",
|
||||||
|
"view_depth_arrows": "Depth + arrows",
|
||||||
"view_recorded": "Recorded image",
|
"view_recorded": "Recorded image",
|
||||||
"view_mode_tip": "Image display mode while running",
|
"view_mode_tip": "Image display mode while running",
|
||||||
"arrow_settings_tip": "Finger 2D presentation (orientation; display only)",
|
"arrow_settings_tip": "Finger 2D presentation (orientation; display only)",
|
||||||
"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 get arrows and depth from analysis, use View → Analyze video",
|
"replay_pipeline_tip": "This is the recorded camera image. For arrows and depth use Analysis ▸ Analyze video.",
|
||||||
"replay_raw_note": "No analysis result yet. Opening a recording runs analysis, or choose Analyze video… above.",
|
"replay_raw_note": "No analysis result yet; use Analysis ▸ Analyze video.",
|
||||||
"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",
|
||||||
@@ -222,11 +232,10 @@
|
|||||||
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
|
"arrow_empty_tip": "The deformation arrow image appears here while running or previewing",
|
||||||
"arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video",
|
"arrow_render_failed": "Flow arrows could not be drawn for this frame\nTry another view, or reload the video",
|
||||||
"arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away",
|
"arrow_capture_unavailable": "The captured frame is not available for this overlay\nThe raw camera view is still one selection away",
|
||||||
"depth_unavailable": "No depth data for this frame\n(not a depth of zero, and no silent fallback to the processed image)",
|
"depth_unavailable": "No depth data for this frame",
|
||||||
"depth_scale": "Depth scale {low} – {high}",
|
"depth_scale": "Depth scale {low} – {high}",
|
||||||
"depth_empty": "This frame's depth is all zeros (0 of {pixels} px non-zero): the ramp's low colour is a real zero response, not missing data",
|
|
||||||
"arrow_scale": "Arrows: displacement ×{factor}",
|
"arrow_scale": "Arrows: displacement ×{factor}",
|
||||||
"arrow_scale_tip": "Display magnification of the relative displacement: the arrows are drawn from the algorithm output of the captured image, with the measured displacement multiplied by this factor and capped at 0.8 of the grid spacing (a capped arrow no longer shows its true factor).",
|
"arrow_scale_tip": "Arrow length is magnified by displacement.",
|
||||||
"arrow_pipeline": "Captured image → algorithm output",
|
"arrow_pipeline": "Captured image → algorithm output",
|
||||||
"arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow",
|
"arrow_pipeline_tip": "Arrows are drawn over the algorithm output, not over the raw camera frame: points below the threshold get no arrow",
|
||||||
"arrow_area_masked": "Valid area",
|
"arrow_area_masked": "Valid area",
|
||||||
@@ -246,9 +255,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",
|
||||||
"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": "Analysing; arrows and depth appear when it finishes.",
|
||||||
"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_failed": "Analysis failed: {reason}.",
|
||||||
"replay_analysis_unavailable": "This video has no analysis setup. Choose Analyze video… above to select one and generate results."
|
"replay_analysis_unavailable": "No analysis setup yet; use Analysis ▸ Analyze video."
|
||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"fn": "FN",
|
"fn": "FN",
|
||||||
@@ -267,7 +276,7 @@
|
|||||||
"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": "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_recomputed_tip": "Curves come from this analysis, not forces.csv.",
|
||||||
"mode_no_trace": "No force trace — {reason}",
|
"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",
|
"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_not_analysed": "this video has not been analysed yet",
|
||||||
@@ -280,10 +289,7 @@
|
|||||||
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
"box_zoom_tip": "Switch to rubber-band zoom (drag a region on the chart)",
|
||||||
"settings": "Chart settings",
|
"settings": "Chart settings",
|
||||||
"settings_tip": "Time window, axis ranges, and the acquisition filters (screen and record use the same data)",
|
"settings_tip": "Time window, axis ranges, and the acquisition filters (screen and record use the same data)",
|
||||||
"selection": "Selection {duration}s / {samples} samples — {detail}",
|
|
||||||
"selection_summary": "Selection {duration}s / {samples} samples",
|
"selection_summary": "Selection {duration}s / {samples} samples",
|
||||||
"selection_detail": "mean {mean} · σ {std}",
|
|
||||||
"selection_peak_tip": "The large number is the selection's peak; the line under it is its mean",
|
|
||||||
"selection_empty": "No valid samples in the selection",
|
"selection_empty": "No valid samples in the selection",
|
||||||
"value_unavailable": "n/a",
|
"value_unavailable": "n/a",
|
||||||
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
|
"value_unavailable_tip": "No curve is loaded for {channel}, so it has no value in this unit (raw counts are never shown in its place)",
|
||||||
@@ -298,7 +304,7 @@
|
|||||||
"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, open it as a video or use Analysis ▸ Analyze video.",
|
"not_a_recording": "No forces.csv beside it; cannot open as a recording.",
|
||||||
"video_only_opened": "Opened “{name}”; no analysis result yet. Choose Analyze video… above to generate one.",
|
"video_only_opened": "Opened “{name}”; no analysis result yet. Choose Analyze video… above to generate one.",
|
||||||
"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 Analysis ▸ Analyze 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 Analysis ▸ Analyze video",
|
||||||
@@ -319,13 +325,12 @@
|
|||||||
"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_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 Analysis ▸ Analysis setup… or open the video again"
|
"analysis_setup_declined": "no analysis was set up for this video: the setup prompt was dismissed, so there is nothing to draw from. Use Analysis ▸ Analysis setup… or open the video again"
|
||||||
},
|
},
|
||||||
"video_analysis": {
|
"video_analysis": {
|
||||||
"title": "Analyse this video",
|
"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.",
|
"video": "{name} — {frames} frames at {fps} fps",
|
||||||
"profile": "Processing profile",
|
"profile": "Processing profile",
|
||||||
"calibration": "Calibration",
|
"calibration": "Calibration",
|
||||||
"calibration_default": "Profile default — {name}",
|
"calibration_default": "Profile default — {name}",
|
||||||
@@ -333,6 +338,12 @@
|
|||||||
"calibration_file": "Chosen file — {name}",
|
"calibration_file": "Chosen file — {name}",
|
||||||
"calibration_browse": "Choose a file…",
|
"calibration_browse": "Choose a file…",
|
||||||
"calibration_filter": "Force calibration (*.json);;All files (*)",
|
"calibration_filter": "Force calibration (*.json);;All files (*)",
|
||||||
|
"device": "Device id",
|
||||||
|
"device_tip": "Must match the SN in the calibration file",
|
||||||
|
"device_required": "A Finger calibration needs the device id of the sensor in this video",
|
||||||
|
"device_missing_sn": "This calibration file has no SN, so it cannot be matched to a sensor",
|
||||||
|
"device_mismatch": "The calibration file's SN is {sn}, which is not the device id entered",
|
||||||
|
"device_family": "This calibration belongs to {family}, not Finger",
|
||||||
"reference": "Reference frame",
|
"reference": "Reference frame",
|
||||||
"reference_time": "{seconds} s",
|
"reference_time": "{seconds} s",
|
||||||
"blocked": "This profile cannot be used: {reason}"
|
"blocked": "This profile cannot be used: {reason}"
|
||||||
@@ -481,7 +492,7 @@
|
|||||||
"run_failed": "Run failed: {error}",
|
"run_failed": "Run failed: {error}",
|
||||||
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
|
"filters_saved": "Filter settings saved: {describe} (applies to the next session)",
|
||||||
"rail_camera_fps": "camera {fps}",
|
"rail_camera_fps": "camera {fps}",
|
||||||
"capture_slow": "The camera delivers less than the configured frame rate: {detail}. The recording keeps the real rate. This is a capture-side (camera/driver) matter that software cannot change: use a camera that really does deliver 30 fps — after checking that the cable or port is not slowing this one down — and measure again.",
|
"capture_slow": "Camera frame rate is below the configured value: {detail}.",
|
||||||
"video_ended": "Video ended",
|
"video_ended": "Video ended",
|
||||||
"camera_disconnected": "Camera disconnected",
|
"camera_disconnected": "Camera disconnected",
|
||||||
"reset_ok": "Sensor tracker reset",
|
"reset_ok": "Sensor tracker reset",
|
||||||
@@ -502,18 +513,20 @@
|
|||||||
"housing": "Housing",
|
"housing": "Housing",
|
||||||
"deform": "Exaggerate",
|
"deform": "Exaggerate",
|
||||||
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
|
"deform_tip": "Display-only magnification of the measured deformation. It changes the picture and nothing else: the recorded data, the calibration and the camera are not touched.",
|
||||||
"deform_unavailable": "This data source provides no 3D displacement: the pane can only colour the surface, so exaggeration is unavailable.",
|
"deform_unavailable": "This data has no 3D displacement; exaggeration is unavailable.",
|
||||||
"field": "Colour by",
|
"field": "Colour by",
|
||||||
"field_tip": "Which quantity colours the surface: the depth response (the chain's relative deformation response, unitless) or the optical-flow magnitude (the tracker's pixel displacement). Neither is a length in millimetres — both are approximate. When the chosen one cannot be drawn for a frame, the other is used and the line below says so.",
|
"field_tip": "Colour the surface by depth or optical flow.",
|
||||||
"field_depth": "Depth response",
|
"field_depth": "Depth response",
|
||||||
"field_flow": "Optical flow magnitude",
|
"field_flow": "Optical flow magnitude",
|
||||||
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
|
"field_substituted": "This frame shows {shown}, not the chosen {chosen}.",
|
||||||
"flow_arrows": "Surface flow arrows",
|
"flow_arrows": "Surface flow arrows",
|
||||||
"flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.",
|
"flow_arrows_tip": "Draw optical-flow arrows on the sensing surface using the same sample points as the 2D view.",
|
||||||
"flow_arrows_unavailable": "This scene package has no UV→scene surface mapping, so flow arrows cannot be drawn.",
|
"flow_arrows_unavailable": "This scene cannot draw flow arrows.",
|
||||||
"contact_arrow": "Contact normal arrow",
|
"contact_arrow": "Contact normal arrow",
|
||||||
"contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).",
|
"contact_arrow_tip": "Draw a yellow local-normal arrow at the contact centre (length from the normal response, capped — not a calibrated force vector).",
|
||||||
"contact_arrow_unavailable": "This scene package has no UV→scene surface mapping, so the contact arrow cannot be drawn.",
|
"contact_arrow_unavailable": "This scene cannot draw the contact arrow.",
|
||||||
|
"flow_threshold": "Arrow threshold",
|
||||||
|
"flow_threshold_tip": "Arrows below this displacement (px) are not drawn; shared by 2D and 3D.",
|
||||||
"quantity_flow_magnitude": "Optical flow magnitude",
|
"quantity_flow_magnitude": "Optical flow magnitude",
|
||||||
"quantity_depth_response": "Depth response",
|
"quantity_depth_response": "Depth response",
|
||||||
"preset_default": "Default view",
|
"preset_default": "Default view",
|
||||||
@@ -531,7 +544,7 @@
|
|||||||
"max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.",
|
"max_fps_tip": "How many frames per second the 3D view draws. A display budget only: the camera and the recording are unaffected, and a dropped frame here is never a dropped sample.",
|
||||||
"reset_view": "Reset view",
|
"reset_view": "Reset view",
|
||||||
"settings_title": "3D display settings",
|
"settings_title": "3D display settings",
|
||||||
"settings_hint": "Per-frame adaptive applies to both the 2D depth view and the 3D surface. Every other item affects the 3D display only. Measuring, recording and the charts are untouched, and each change applies at once.",
|
"settings_hint": "Per-frame adaptive applies to both 2D and 3D.",
|
||||||
"legend": "{quantity} [{unit}] · {quality}",
|
"legend": "{quantity} [{unit}] · {quality}",
|
||||||
"legend_none": "No measurement on screen — the surface model is shown uncoloured",
|
"legend_none": "No measurement on screen — the surface model is shown uncoloured",
|
||||||
"quality_calibrated": "calibrated",
|
"quality_calibrated": "calibrated",
|
||||||
@@ -554,9 +567,9 @@
|
|||||||
"severity_info": "Note",
|
"severity_info": "Note",
|
||||||
"severity_warning": "Warning",
|
"severity_warning": "Warning",
|
||||||
"severity_error": "Error",
|
"severity_error": "Error",
|
||||||
"stats": "shown {displayed} · display drops {dropped} · compose {compose} ms · paint {paint} ms",
|
"stats": "shown {displayed} · frames not shown in time {dropped} · compose {compose} ms · paint {paint} ms",
|
||||||
"source_analysis": "Analysis result: one finished pass, read by frame",
|
"source_analysis": "Analysis result: read by frame",
|
||||||
"source_analysis_tip": "The picture comes from a stored analysis result: the chain has already run and its output is on disk, so scrubbing and playing are file reads. Changing a threshold or a colour does not recompute."
|
"source_analysis_tip": "The picture comes from a stored analysis result. Changing a threshold or colour does not recompute."
|
||||||
},
|
},
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"title": "Analysing the recording",
|
"title": "Analysing the recording",
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
"plot_settings": "绘图设置…",
|
"plot_settings": "绘图设置…",
|
||||||
"rotate_arrows": "旋转箭头图 90°",
|
"rotate_arrows": "旋转箭头图 90°",
|
||||||
"recompute": "分析视频",
|
"recompute": "分析视频",
|
||||||
"recompute_tip": "打开:有兼容的分析结果就直接用,没有就跑一遍分析。关闭:离开分析,有 forces.csv 时曲线读录制值。要改配置、标定或参考帧请用「分析 ▸ 分析设置…」。",
|
"recompute_tip": "有结果就用,没有就分析。改配置用分析设置。",
|
||||||
"analysis_setup": "分析设置…",
|
"analysis_setup": "分析设置…",
|
||||||
"analysis_setup_tip": "为这段视频选择配置、标定与参考帧,然后跑(或重跑)分析。",
|
"analysis_setup_tip": "为这段视频选择配置、标定与参考帧,然后跑(或重跑)分析。",
|
||||||
"run": "运行 / 停止",
|
"run": "运行 / 停止",
|
||||||
@@ -78,8 +78,8 @@
|
|||||||
"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 正好都相反,因此需要旋转 180° 才能使箭头方向与 3D 视图一致。。只影响显示,不改变记录与存储的数组",
|
"plot_orientation_tip": "只影响 2D 画面,默认旋转 180°。",
|
||||||
"plot_orientation_note": "只影响 2D 手指画布的显示:存储的光流、有效掩膜与录制数据始终保持域本身的朝向。3D 视图不受影响(它的坐标系是设计导出坐标系)",
|
"plot_orientation_note": "只影响 2D 画面,默认旋转 180°。",
|
||||||
"orientation_identity": "原始(不旋转)",
|
"orientation_identity": "原始(不旋转)",
|
||||||
"orientation_rotate180": "旋转 180°(默认)",
|
"orientation_rotate180": "旋转 180°(默认)",
|
||||||
"orientation_mirror_h": "水平镜像",
|
"orientation_mirror_h": "水平镜像",
|
||||||
@@ -89,7 +89,7 @@
|
|||||||
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
|
"filter_preset_tip": "选择一档现成的滤波组合;改下面任一数值会变成“自定义”",
|
||||||
"filter_preset_custom": "自定义",
|
"filter_preset_custom": "自定义",
|
||||||
"filter_flow_ema": "光流 EMA α",
|
"filter_flow_ema": "光流 EMA α",
|
||||||
"filter_flow_ema_tip": "光流的时间平滑。1.00 = 关。它改变的是“测量本身”(接触统计与力值都会变),两个 α 不同的录制不可比 —— 只有确实需要压光流抖动时才调",
|
"filter_flow_ema_tip": "1.00 为关闭。改它会改变测到的力。",
|
||||||
"filter_median": "中值窗口",
|
"filter_median": "中值窗口",
|
||||||
"filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟",
|
"filter_median_tip": "力读数的滑动中值窗口(采样数)。1 = 关。去掉单点尖峰,代价是约半个窗口的延迟",
|
||||||
"filter_lowpass": "低通截止",
|
"filter_lowpass": "低通截止",
|
||||||
@@ -106,6 +106,7 @@
|
|||||||
"filter_off": "未滤波(原始读数)",
|
"filter_off": "未滤波(原始读数)",
|
||||||
"ok": "确定",
|
"ok": "确定",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
|
"save": "保存",
|
||||||
"close": "关闭"
|
"close": "关闭"
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
@@ -116,7 +117,7 @@
|
|||||||
"choose_video": "选择录制视频…",
|
"choose_video": "选择录制视频…",
|
||||||
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
|
"choose_video_tip": "打开一段录制的视频:2D 面板放视频,曲线读同目录的 forces.csv(原始记录值)",
|
||||||
"recompute": "分析视频…",
|
"recompute": "分析视频…",
|
||||||
"recompute_tip": "有兼容的分析结果就直接用,没有就跑一遍分析:2D 面板可画箭头与深度,曲线可读结果中的力。分析进行中可随时「停止」。要改配置、标定或参考帧请用「分析 ▸ 分析设置…」。",
|
"recompute_tip": "有结果就用,没有就分析。改配置用分析设置。",
|
||||||
"replay_view_only": "请在「历史回放」视图里打开录像",
|
"replay_view_only": "请在「历史回放」视图里打开录像",
|
||||||
"preview": "预览",
|
"preview": "预览",
|
||||||
"preview_stop": "停止预览",
|
"preview_stop": "停止预览",
|
||||||
@@ -143,7 +144,7 @@
|
|||||||
"reset_display": "复位显示",
|
"reset_display": "复位显示",
|
||||||
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
|
"reset_display_tip": "清空累积的显示:曲线、游标与数字卡恢复默认,回放回到录像开头。本视图没有设备会话,不会改动设备;三维视角请用「视图 → 复位视图」(F8)。",
|
||||||
"pause": "⏸ 暂停",
|
"pause": "⏸ 暂停",
|
||||||
"pause_tip": "暂停计算与显示(F7):不再做光流/深度/接触计算,画面停在最后一帧,设备保持连接,随时可继续。跨过暂停的记录会有一段空档。",
|
"pause_tip": "暂停计算,画面停住,设备保持连接(F7)。",
|
||||||
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
|
"resume_tip": "继续计算与显示(F7),从最新一帧接着跑。",
|
||||||
"resume_label": "▶ 继续",
|
"resume_label": "▶ 继续",
|
||||||
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
|
"resume_label_tip": "继续计算与显示(F7):从最新一帧接着跑,设备保持连接。",
|
||||||
@@ -193,6 +194,14 @@
|
|||||||
"catalog_state_unknown": "无法识别的校验结果",
|
"catalog_state_unknown": "无法识别的校验结果",
|
||||||
"catalog_calibration_selected": "已选择标定:{name}",
|
"catalog_calibration_selected": "已选择标定:{name}",
|
||||||
"catalog_calibration_none": "未选择标定——使用原始力",
|
"catalog_calibration_none": "未选择标定——使用原始力",
|
||||||
|
"live_calibration_section": "直播用标定",
|
||||||
|
"live_calibration_raw_tip": "点运行时按原始力打开。采集中点「应用到设备」只会记住选择,需重新运行才去掉已加载的系数。",
|
||||||
|
"live_calibration_default_tip": "点运行时加载配置默认标定 {name}。",
|
||||||
|
"live_calibration_file_tip": "点运行时加载 {name};采集中可点「应用到设备」热更新。",
|
||||||
|
"live_calibration_apply_tip": "记住当前选择;若正在采集则热更新到设备",
|
||||||
|
"live_calibration_remembered": "已记住标定选择;下次运行时加载",
|
||||||
|
"live_calibration_applied": "已请求热更新标定:{name}",
|
||||||
|
"live_calibration_invalid": "当前标定选择无效:{reason}",
|
||||||
"catalog_duplicate": "同时由 {refs} 提供",
|
"catalog_duplicate": "同时由 {refs} 提供",
|
||||||
"catalog_shape_descriptor": "描述符配置包",
|
"catalog_shape_descriptor": "描述符配置包",
|
||||||
"catalog_shape_compatibility": "兼容配置包(无描述符)",
|
"catalog_shape_compatibility": "兼容配置包(无描述符)",
|
||||||
@@ -206,12 +215,13 @@
|
|||||||
"view_arrows_pure": "箭头",
|
"view_arrows_pure": "箭头",
|
||||||
"view_arrows_stitched": "箭头/采集图像",
|
"view_arrows_stitched": "箭头/采集图像",
|
||||||
"view_depth": "深度图",
|
"view_depth": "深度图",
|
||||||
|
"view_depth_arrows": "深度 + 箭头",
|
||||||
"view_recorded": "原始图像",
|
"view_recorded": "原始图像",
|
||||||
"view_mode_tip": "运行时的图像显示模式",
|
"view_mode_tip": "运行时的图像显示模式",
|
||||||
"arrow_settings_tip": "手指 2D 显示方向(只影响画面)",
|
"arrow_settings_tip": "手指 2D 显示方向(只影响画面)",
|
||||||
"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": "深度",
|
||||||
@@ -222,11 +232,10 @@
|
|||||||
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
"arrow_empty_tip": "运行或预览后在此显示形变箭头图",
|
||||||
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
|
"arrow_render_failed": "本帧无法绘制形变箭头\n请换一种视图,或重新加载视频",
|
||||||
"arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看",
|
"arrow_capture_unavailable": "当前无法取得采集图像用于叠加\n原始相机画面仍可在同一选择器中查看",
|
||||||
"depth_unavailable": "本帧没有深度数据\n(不是深度为零,也未回退显示普通形变图)",
|
"depth_unavailable": "本帧没有深度数据",
|
||||||
"depth_scale": "深度色标 {low} – {high}",
|
"depth_scale": "深度色标 {low} – {high}",
|
||||||
"depth_empty": "本帧深度全为 0({pixels} px 中 0 个非零):色标最低颜色是真实的零响应,不是缺数据",
|
|
||||||
"arrow_scale": "箭头按位移放大 ×{factor}",
|
"arrow_scale": "箭头按位移放大 ×{factor}",
|
||||||
"arrow_scale_tip": "相对位移的显示放大倍率:箭头由原始采集图像经算法输出,长度按测量位移放大后绘制,上限为网格间距的 0.8 倍;触顶的箭头不再呈现真实倍率。",
|
"arrow_scale_tip": "箭头长度按位移放大显示。",
|
||||||
"arrow_pipeline": "原始图像 → 算法输出",
|
"arrow_pipeline": "原始图像 → 算法输出",
|
||||||
"arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头",
|
"arrow_pipeline_tip": "箭头画在算法输出上,不是相机原始帧:阈值以下的区域不画箭头",
|
||||||
"arrow_area_masked": "有效区域",
|
"arrow_area_masked": "有效区域",
|
||||||
@@ -246,9 +255,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": "复位视角:回到场景自带的取景",
|
||||||
"replay_analysis_running": "正在分析这个录像:跑完之前这里没有箭头/深度可画,进度与取消在分析面板上。",
|
"replay_analysis_running": "正在分析,完成后显示箭头与深度。",
|
||||||
"replay_analysis_failed": "没有可画的分析结果:{reason}。这里只能显示原始相机图像;处理完之后可以点「分析视频」再跑一遍。",
|
"replay_analysis_failed": "分析失败:{reason}。",
|
||||||
"replay_analysis_unavailable": "视频尚无分析配置。点顶部「分析视频…」选择配置并生成结果。"
|
"replay_analysis_unavailable": "尚无分析配置,请用分析 ▸ 分析视频。"
|
||||||
},
|
},
|
||||||
"chart": {
|
"chart": {
|
||||||
"fn": "FN",
|
"fn": "FN",
|
||||||
@@ -267,7 +276,7 @@
|
|||||||
"mode_replay": "回放录制(浏览)",
|
"mode_replay": "回放录制(浏览)",
|
||||||
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
|
"mode_replay_tip": "曲线读的是打开的录制;回放期间采集与记录继续",
|
||||||
"mode_recomputed": "从这段视频重算(浏览)",
|
"mode_recomputed": "从这段视频重算(浏览)",
|
||||||
"mode_recomputed_tip": "曲线读的是分析过程从这段视频重算、并存入结果的力。这不是记录的 forces.csv——没有任何设备测过这些数字",
|
"mode_recomputed_tip": "曲线来自这次分析,不是 forces.csv。",
|
||||||
"mode_no_trace": "没有力曲线 — {reason}",
|
"mode_no_trace": "没有力曲线 — {reason}",
|
||||||
"mode_no_trace_tip": "没有可画的东西,而原因是输入本身的事实,不是一种加载状态",
|
"mode_no_trace_tip": "没有可画的东西,而原因是输入本身的事实,不是一种加载状态",
|
||||||
"trace_not_analysed": "这段视频还没有分析过",
|
"trace_not_analysed": "这段视频还没有分析过",
|
||||||
@@ -280,10 +289,7 @@
|
|||||||
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
"box_zoom_tip": "切换为框选放大(拖动图表框选区域)",
|
||||||
"settings": "曲线图设置",
|
"settings": "曲线图设置",
|
||||||
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
|
"settings_tip": "时间窗口、纵轴量程,以及采集端滤波(界面与记录用同一份数据)",
|
||||||
"selection": "选区 {duration}s / {samples} 点 — {detail}",
|
|
||||||
"selection_summary": "选区 {duration}s / {samples} 点",
|
"selection_summary": "选区 {duration}s / {samples} 点",
|
||||||
"selection_detail": "均值 {mean} · σ {std}",
|
|
||||||
"selection_peak_tip": "上方大号数字是选区的峰值;均值为选区均值",
|
|
||||||
"selection_empty": "选区无有效样本",
|
"selection_empty": "选区无有效样本",
|
||||||
"value_unavailable": "不可用",
|
"value_unavailable": "不可用",
|
||||||
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会以原始值替代)",
|
"value_unavailable_tip": "{channel} 轴未加载标定曲线,因此没有该单位的数值(不会以原始值替代)",
|
||||||
@@ -298,7 +304,7 @@
|
|||||||
"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": "旁边没有 forces.csv,不能当记录打开。",
|
||||||
"video_only_opened": "已打开「{name}」;尚无分析结果。点顶部「分析视频…」生成。",
|
"video_only_opened": "已打开「{name}」;尚无分析结果。点顶部「分析视频…」生成。",
|
||||||
"video_only_notice": "这段视频没有配套记录(同目录无 forces.csv):可以播放,但力曲线与记录信息为空。",
|
"video_only_notice": "这段视频没有配套记录(同目录无 forces.csv):可以播放,但力曲线与记录信息为空。",
|
||||||
"video_only_panel": "这不是一条记录,而且无法解码这段视频(没有 forces.csv,也不像可播放的视频文件)\n可以试试 分析 ▸ 分析视频",
|
"video_only_panel": "这不是一条记录,而且无法解码这段视频(没有 forces.csv,也不像可播放的视频文件)\n可以试试 分析 ▸ 分析视频",
|
||||||
@@ -319,13 +325,12 @@
|
|||||||
"analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放",
|
"analysis_cancelled": "分析已取消:未写出完整结果,因此不提供回放",
|
||||||
"analysis_no_source": "这段录制没有可用于分析的视频分段",
|
"analysis_no_source": "这段录制没有可用于分析的视频分段",
|
||||||
"analysis_busy": "分析进行中:先等待或取消当前这一遍",
|
"analysis_busy": "分析进行中:先等待或取消当前这一遍",
|
||||||
"analysis_grid_mismatch": "当前显示设置改了采样网格({keys}),存下来的候选位置无法服务它:回放仍按结果自己的网格绘制",
|
|
||||||
"analysis_no_chain": "这条录像没有说明用哪套传感器与配置录下来({error}),因此既无法读取结果,也无法重算",
|
"analysis_no_chain": "这条录像没有说明用哪套传感器与配置录下来({error}),因此既无法读取结果,也无法重算",
|
||||||
"analysis_setup_declined": "这段视频没有建立分析:设置提示被关掉了,因此没有可画的东西。用「分析 ▸ 分析设置…」或重新打开视频即可分析"
|
"analysis_setup_declined": "这段视频没有建立分析:设置提示被关掉了,因此没有可画的东西。用「分析 ▸ 分析设置…」或重新打开视频即可分析"
|
||||||
},
|
},
|
||||||
"video_analysis": {
|
"video_analysis": {
|
||||||
"title": "分析这段视频",
|
"title": "分析这段视频",
|
||||||
"video": "{name} — {frames} 帧 @ {fps} fps({seconds} 秒)。它旁边没有录制(没有 forces.csv、没有 frames.csv),因此这一遍会按为它生成的帧索引来跑。",
|
"video": "{name} — {frames} 帧 @ {fps} fps",
|
||||||
"profile": "处理配置",
|
"profile": "处理配置",
|
||||||
"calibration": "标定",
|
"calibration": "标定",
|
||||||
"calibration_default": "配置默认 — {name}",
|
"calibration_default": "配置默认 — {name}",
|
||||||
@@ -333,6 +338,12 @@
|
|||||||
"calibration_file": "已选文件 — {name}",
|
"calibration_file": "已选文件 — {name}",
|
||||||
"calibration_browse": "选择文件…",
|
"calibration_browse": "选择文件…",
|
||||||
"calibration_filter": "力标定 (*.json);;所有文件 (*)",
|
"calibration_filter": "力标定 (*.json);;所有文件 (*)",
|
||||||
|
"device": "设备编号",
|
||||||
|
"device_tip": "与标定文件中的 SN 一致",
|
||||||
|
"device_required": "使用这份 Finger 标定需要填写视频对应的设备编号",
|
||||||
|
"device_missing_sn": "这份标定文件没有 SN,无法确认它属于哪台传感器",
|
||||||
|
"device_mismatch": "标定文件的 SN 是 {sn},与填写的设备编号不一致",
|
||||||
|
"device_family": "这份标定属于 {family},不能用于 Finger",
|
||||||
"reference": "参考帧",
|
"reference": "参考帧",
|
||||||
"reference_time": "{seconds} 秒",
|
"reference_time": "{seconds} 秒",
|
||||||
"blocked": "这套配置不能用:{reason}"
|
"blocked": "这套配置不能用:{reason}"
|
||||||
@@ -481,7 +492,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}。",
|
||||||
"video_ended": "视频已结束",
|
"video_ended": "视频已结束",
|
||||||
"camera_disconnected": "摄像机已断开",
|
"camera_disconnected": "摄像机已断开",
|
||||||
"reset_ok": "传感器追踪器已重置",
|
"reset_ok": "传感器追踪器已重置",
|
||||||
@@ -502,18 +513,20 @@
|
|||||||
"housing": "显示外壳",
|
"housing": "显示外壳",
|
||||||
"deform": "放大形变",
|
"deform": "放大形变",
|
||||||
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
|
"deform_tip": "只放大画面上的形变显示。它只改图像:记录的数据、标定与算法相机都不受影响。",
|
||||||
"deform_unavailable": "当前数据源未提供三维位移:本面板只有光流幅值的曲面着色,形变放大不可用。",
|
"deform_unavailable": "当前数据没有三维位移,形变放大不可用。",
|
||||||
"field": "颜色来源",
|
"field": "颜色来源",
|
||||||
"field_tip": "三维表面用哪个量上色:深度响应(链路的相对形变响应,无量纲)或光流幅值(追踪器的像素位移大小)。两者都不是毫米,都是近似量;选中的那个若在这一帧画不出来,会自动改用另一个,并在这行下面说明。",
|
"field_tip": "用深度或光流给表面着色。",
|
||||||
"field_depth": "深度响应",
|
"field_depth": "深度响应",
|
||||||
"field_flow": "光流幅值",
|
"field_flow": "光流幅值",
|
||||||
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
|
"field_substituted": "这一帧画的是 {shown},不是所选的 {chosen}。",
|
||||||
"flow_arrows": "表面光流箭头",
|
"flow_arrows": "表面光流箭头",
|
||||||
"flow_arrows_tip": "在传感表面上按与二维相同的采样点绘制光流箭头。",
|
"flow_arrows_tip": "在传感表面上按与二维相同的采样点绘制光流箭头。",
|
||||||
"flow_arrows_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制光流箭头。",
|
"flow_arrows_unavailable": "当前场景画不了光流箭头。",
|
||||||
"contact_arrow": "接触法向箭头",
|
"contact_arrow": "接触法向箭头",
|
||||||
"contact_arrow_tip": "在接触中心沿局部法向绘制黄色示意箭头(长度由法向响应限幅,不是标定合力)。",
|
"contact_arrow_tip": "在接触中心沿局部法向绘制黄色示意箭头(长度由法向响应限幅,不是标定合力)。",
|
||||||
"contact_arrow_unavailable": "当前场景包没有 UV→三维 表面映射,无法绘制接触法向箭头。",
|
"contact_arrow_unavailable": "当前场景画不了接触箭头。",
|
||||||
|
"flow_threshold": "箭头阈值",
|
||||||
|
"flow_threshold_tip": "低于此位移(像素)不画箭头;二维与三维共用。",
|
||||||
"quantity_flow_magnitude": "光流幅值",
|
"quantity_flow_magnitude": "光流幅值",
|
||||||
"quantity_depth_response": "深度响应",
|
"quantity_depth_response": "深度响应",
|
||||||
"preset_default": "默认视角",
|
"preset_default": "默认视角",
|
||||||
@@ -531,7 +544,7 @@
|
|||||||
"max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。",
|
"max_fps_tip": "三维视图每秒绘制多少帧。这只是显示预算:摄像头与记录不受影响,这里丢的帧永远不会变成丢的样本。",
|
||||||
"reset_view": "复位视角",
|
"reset_view": "复位视角",
|
||||||
"settings_title": "三维显示设置",
|
"settings_title": "三维显示设置",
|
||||||
"settings_hint": "「每帧自适应」同时作用于二维深度图和三维表面;其余项只影响三维显示。采集、记录与力曲线不受影响,改完立即生效。",
|
"settings_hint": "每帧自适应同时作用于二维和三维。",
|
||||||
"legend": "{quantity} [{unit}] · {quality}",
|
"legend": "{quantity} [{unit}] · {quality}",
|
||||||
"legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型",
|
"legend_none": "屏幕上没有测量值 — 仅显示不着色的表面模型",
|
||||||
"quality_calibrated": "已标定",
|
"quality_calibrated": "已标定",
|
||||||
@@ -554,9 +567,9 @@
|
|||||||
"severity_info": "提示",
|
"severity_info": "提示",
|
||||||
"severity_warning": "警告",
|
"severity_warning": "警告",
|
||||||
"severity_error": "错误",
|
"severity_error": "错误",
|
||||||
"stats": "已显示 {displayed} · 显示跳帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms",
|
"stats": "已显示 {displayed} · 未赶上显示的帧 {dropped} · 合成 {compose} ms · 绘制 {paint} ms",
|
||||||
"source_analysis": "分析结果:已跑完的一遍,按帧读取",
|
"source_analysis": "分析结果:按帧读取",
|
||||||
"source_analysis_tip": "画面来自存下来的分析结果:算法链路已经跑完并写入缓存,拖动时间轴与播放都只是读文件。换阈值或配色不会重算。"
|
"source_analysis_tip": "画面来自已存的分析结果。改阈值或配色不会重算。"
|
||||||
},
|
},
|
||||||
"analysis": {
|
"analysis": {
|
||||||
"title": "正在分析录像",
|
"title": "正在分析录像",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"profile_id": "orisys.finger.r01",
|
"profile_id": "orisys.finger.r01",
|
||||||
"display_name": "Finger R01",
|
"display_name": "Finger",
|
||||||
"product_family": "finger",
|
"product_family": "finger",
|
||||||
"processing_backend": "uv_unroll",
|
"processing_backend": "uv_unroll",
|
||||||
"sensor_json": "sensor.json",
|
"sensor_json": "sensor.json",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ same rows.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `schema_version` | `1` | The SDK's current descriptor schema (`CURRENT_DESCRIPTOR_SCHEMA`). |
|
| `schema_version` | `1` | The SDK's current descriptor schema (`CURRENT_DESCRIPTOR_SCHEMA`). |
|
||||||
| `profile_id` | `orisys.finger.r01` | The processing profile this folder is: finger design revision `r01`, whose unroll map and camera scale the configuration pins. Never a product-family name. |
|
| `profile_id` | `orisys.finger.r01` | The processing profile this folder is: finger design revision `r01`, whose unroll map and camera scale the configuration pins. Never a product-family name. |
|
||||||
| `display_name` | `Finger R01` | Shown in the picker. Excluded from the processing fingerprint. |
|
| `display_name` | `Finger` | Shown in the picker. Excluded from the processing fingerprint. |
|
||||||
| `product_family` | `finger` | What the operator calls it. `finger-mems` is a different family on a different pipeline. |
|
| `product_family` | `finger` | What the operator calls it. `finger-mems` is a different family on a different pipeline. |
|
||||||
| `processing_backend` | `uv_unroll` | The pipeline the finger actually runs: `FingerRuntime` builds a UV domain from the export's unroll map, then flow and force. |
|
| `processing_backend` | `uv_unroll` | The pipeline the finger actually runs: `FingerRuntime` builds a UV domain from the export's unroll map, then flow and force. |
|
||||||
| `sensor_json` | `sensor.json` | The configuration, still the only place camera, tracker and contact settings live. |
|
| `sensor_json` | `sensor.json` | The configuration, still the only place camera, tracker and contact settings live. |
|
||||||
|
|||||||
@@ -284,8 +284,8 @@
|
|||||||
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
|
"purpose": "write the calibration panel's coefficients (the panel's own action)" },
|
||||||
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
|
{ "id": "dialog_apply", "path": "sensor_dialog.btn_apply", "objectName": null, "class": "QPushButton",
|
||||||
"purpose": "push the last export into the running device" },
|
"purpose": "push the last export into the running device" },
|
||||||
{ "id": "dialog_close", "path": "sensor_dialog.btn_close", "objectName": null, "class": "QPushButton",
|
{ "id": "dialog_save", "path": "sensor_dialog.btn_save", "objectName": null, "class": "QPushButton",
|
||||||
"purpose": "close the dialog" }
|
"purpose": "keep the current sensor and calibration choice and dismiss the dialog" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import sys
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
from orisys_app_shared.acquisition.constants import FORCE_SAMPLE_RATE_HZ
|
||||||
from orisys_app_shared.acquisition.display_orientation import (
|
from orisys_app_shared.acquisition.display_orientation import (
|
||||||
DEFAULT_FINGER_ORIENTATION,
|
DEFAULT_FINGER_ORIENTATION,
|
||||||
@@ -16,16 +15,13 @@ from orisys_app_shared.acquisition.display_orientation import (
|
|||||||
normalise_orientation,
|
normalise_orientation,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.acquisition.image_backend import (
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
DEPTH,
|
|
||||||
SOURCE_ANALYSIS,
|
SOURCE_ANALYSIS,
|
||||||
SOURCE_LIVE,
|
SOURCE_LIVE,
|
||||||
SOURCE_PREVIEW,
|
SOURCE_PREVIEW,
|
||||||
SOURCE_RECOMPUTE,
|
SOURCE_RECOMPUTE,
|
||||||
SOURCE_REPLAY,
|
SOURCE_REPLAY,
|
||||||
DomainMaskLatch,
|
|
||||||
ImageFrame,
|
ImageFrame,
|
||||||
plan_image,
|
render_image, # re-exported: tests monkeypatch ``app_window.render_image``
|
||||||
render_image,
|
|
||||||
)
|
)
|
||||||
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
from orisys_app_shared.force.calibration_recorder import CalibrationRecorder
|
||||||
from orisys_app_shared.force.filters import FilterSettings
|
from orisys_app_shared.force.filters import FilterSettings
|
||||||
@@ -52,6 +48,7 @@ from orisys_studio import session_metadata
|
|||||||
from orisys_studio.acquire_presenter import AcquirePresenter
|
from orisys_studio.acquire_presenter import AcquirePresenter
|
||||||
from orisys_studio.calibration_service import CalibrationService
|
from orisys_studio.calibration_service import CalibrationService
|
||||||
from orisys_studio.display_prefs import DisplayPrefsController
|
from orisys_studio.display_prefs import DisplayPrefsController
|
||||||
|
from orisys_studio.frame_view import FrameView
|
||||||
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
from orisys_studio.recording import RecordingCoordinator, record_config_from_prefs
|
||||||
from orisys_studio.replay import ReplayEngine
|
from orisys_studio.replay import ReplayEngine
|
||||||
from orisys_studio.replay_controller import ReplayController
|
from orisys_studio.replay_controller import ReplayController
|
||||||
@@ -228,13 +225,8 @@ class StudioMainWindow(QMainWindow):
|
|||||||
#: Built once the panels exist (see below); until then the status rail
|
#: Built once the panels exist (see below); until then the status rail
|
||||||
#: treats calibration as unknown.
|
#: treats calibration as unknown.
|
||||||
self.acquire_presenter: AcquirePresenter | None = None
|
self.acquire_presenter: AcquirePresenter | None = None
|
||||||
self._depth_range_value: tuple[float, float] | None = None
|
#: Built once the panels exist; 2D paint and shared depth span live there.
|
||||||
self._last_depth_frame = None
|
self.frame_view: FrameView | None = None
|
||||||
#: The 2D domain boundary, latched for the session the way the depth range is:
|
|
||||||
#: the island is the device's own geometry and is rebuilt from the same remap
|
|
||||||
#: every frame, so re-deriving the edge per frame redrew a boundary that had not
|
|
||||||
#: moved (see ``image_backend.DomainMaskLatch``).
|
|
||||||
self._domain_mask = DomainMaskLatch()
|
|
||||||
|
|
||||||
icon = QIcon(str(logo_path()))
|
icon = QIcon(str(logo_path()))
|
||||||
if not icon.isNull():
|
if not icon.isNull():
|
||||||
@@ -266,12 +258,13 @@ class StudioMainWindow(QMainWindow):
|
|||||||
|
|
||||||
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
self.content_splitter = QSplitter(Qt.Orientation.Horizontal)
|
||||||
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
self.mesh3d_panel = Mesh3dPanel(theme=self._theme)
|
||||||
|
self.arrow_panel = ArrowPanel(theme=self._theme)
|
||||||
# The island latch is *upstream of both views*: the 2D canvas paints the
|
# The island latch is *upstream of both views*: the 2D canvas paints the
|
||||||
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
# latched boundary, and the 3D pane's validity texture is that same mask. Two
|
||||||
# views, one decision about where the measurement is — see
|
# views, one decision about where the measurement is — see
|
||||||
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
# ``image_backend.DomainMaskLatch`` and ``Scene3DView.set_domain_latch``.
|
||||||
self.mesh3d_panel.set_domain_latch(self._domain_mask)
|
self.frame_view = FrameView(self.arrow_panel, self.mesh3d_panel)
|
||||||
self.arrow_panel = ArrowPanel(theme=self._theme)
|
self.mesh3d_panel.set_domain_latch(self.frame_view.domain_mask)
|
||||||
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
self.force_plots = ForcePlotsPanel(theme=self._theme)
|
||||||
# The charts browse the whole session, not just their rolling buffer.
|
# The charts browse the whole session, not just their rolling buffer.
|
||||||
self.force_plots.set_store(self.session_store)
|
self.force_plots.set_store(self.session_store)
|
||||||
@@ -1838,11 +1831,171 @@ class StudioMainWindow(QMainWindow):
|
|||||||
"""Note the coefficients that were written. Nothing is applied here.
|
"""Note the coefficients that were written. Nothing is applied here.
|
||||||
|
|
||||||
Exporting and applying are separate actions: writing a file must never
|
Exporting and applying are separate actions: writing a file must never
|
||||||
change what a running device is using.
|
change what a running device is using. The new file becomes the dialog's
|
||||||
|
selected live calibration so Apply / the next Run can use it.
|
||||||
"""
|
"""
|
||||||
|
from orisys_studio.ui.sensor_dialog import ROLE_FILE
|
||||||
|
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||||
|
|
||||||
self._last_calibration_export = str(path)
|
self._last_calibration_export = str(path)
|
||||||
self.recorder.log_event("calibration_exported", str(path))
|
self.recorder.log_event("calibration_exported", str(path))
|
||||||
self.statusBar().showMessage(t("cal.export_only", path=Path(path).name))
|
export = Path(path)
|
||||||
|
_sensor_type, sn = calibration_identity(export)
|
||||||
|
self.set_live_calibration_choice(
|
||||||
|
role=ROLE_FILE,
|
||||||
|
path=export,
|
||||||
|
device_id=sn or None,
|
||||||
|
)
|
||||||
|
if self.sensor_dialog is not None:
|
||||||
|
self.sensor_dialog.select_calibration_file(export)
|
||||||
|
self.statusBar().showMessage(t("cal.export_only", path=export.name))
|
||||||
|
|
||||||
|
# ------------------------------------------------- live calibration choice
|
||||||
|
def _live_calibration_prefs_key(self) -> str | None:
|
||||||
|
entry = self.sensor_picker.selected_entry()
|
||||||
|
return None if entry is None else entry.ref
|
||||||
|
|
||||||
|
def live_calibration_for(
|
||||||
|
self, ref: str
|
||||||
|
) -> tuple[str, Path | None, str] | None:
|
||||||
|
"""Saved live choice for one catalog ref, or ``None`` when nothing was saved.
|
||||||
|
|
||||||
|
``None`` is not raw force: a caller that has its own default (the analysis
|
||||||
|
prompt) keeps that default until the sensor dialog has stored a choice.
|
||||||
|
"""
|
||||||
|
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||||
|
|
||||||
|
store = self.prefs.get("live_calibration")
|
||||||
|
if not ref or not isinstance(store, dict):
|
||||||
|
return None
|
||||||
|
entry = store.get(ref)
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
return None
|
||||||
|
role = str(entry.get("role") or ROLE_NONE)
|
||||||
|
if role not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
|
||||||
|
role = ROLE_NONE
|
||||||
|
path: Path | None = None
|
||||||
|
if role == ROLE_FILE:
|
||||||
|
raw = str(entry.get("path") or "").strip()
|
||||||
|
candidate = Path(raw) if raw else None
|
||||||
|
if candidate is None or not candidate.is_file():
|
||||||
|
return ROLE_NONE, None, ""
|
||||||
|
path = candidate
|
||||||
|
return role, path, str(entry.get("device_id") or "")
|
||||||
|
|
||||||
|
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
|
||||||
|
"""``(role, file_path_or_None, device_id)`` remembered for the selected bundle.
|
||||||
|
|
||||||
|
``file_path`` is only set for the browsed-file role; the profile default is
|
||||||
|
resolved when the chain opens. No saved choice is raw force for a live open.
|
||||||
|
"""
|
||||||
|
from orisys_studio.ui.sensor_dialog import ROLE_NONE
|
||||||
|
|
||||||
|
key = self._live_calibration_prefs_key()
|
||||||
|
found = None if key is None else self.live_calibration_for(key)
|
||||||
|
if found is None:
|
||||||
|
return ROLE_NONE, None, ""
|
||||||
|
return found
|
||||||
|
|
||||||
|
def set_live_calibration_choice(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
path: Path | str | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Remember the live calibration choice for the currently selected bundle."""
|
||||||
|
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||||
|
|
||||||
|
key = self._live_calibration_prefs_key()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
store = self.prefs.get("live_calibration")
|
||||||
|
if not isinstance(store, dict):
|
||||||
|
store = {}
|
||||||
|
self.prefs["live_calibration"] = store
|
||||||
|
wanted = str(role or ROLE_NONE)
|
||||||
|
if wanted not in (ROLE_DEFAULT, ROLE_FILE, ROLE_NONE):
|
||||||
|
wanted = ROLE_NONE
|
||||||
|
payload: dict[str, str] = {"role": wanted}
|
||||||
|
if wanted == ROLE_FILE and path is not None and str(path).strip():
|
||||||
|
payload["path"] = str(path)
|
||||||
|
if device_id and str(device_id).strip():
|
||||||
|
payload["device_id"] = str(device_id).strip()
|
||||||
|
store[key] = payload
|
||||||
|
save_preferences(self.prefs)
|
||||||
|
|
||||||
|
def _default_live_calibration_path(self) -> Path | None:
|
||||||
|
entry = self.sensor_picker.selected_entry()
|
||||||
|
if entry is None or entry.profile is None:
|
||||||
|
return None
|
||||||
|
declared = getattr(entry.profile, "default_calibration", None)
|
||||||
|
if declared is not None and Path(declared).is_file():
|
||||||
|
return Path(declared)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolved_live_calibration(
|
||||||
|
self,
|
||||||
|
) -> tuple[Path | None, str | None, str]:
|
||||||
|
"""``(cal_path, device_id, problem)`` for the next open or a hot apply.
|
||||||
|
|
||||||
|
``problem`` is non-empty when a Finger file choice fails the serial check.
|
||||||
|
"""
|
||||||
|
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE
|
||||||
|
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||||
|
|
||||||
|
role, path, device_id = self.live_calibration_choice()
|
||||||
|
if role == ROLE_NONE:
|
||||||
|
return None, None, ""
|
||||||
|
if role == ROLE_DEFAULT:
|
||||||
|
return self._default_live_calibration_path(), None, ""
|
||||||
|
if role != ROLE_FILE or path is None:
|
||||||
|
return None, None, ""
|
||||||
|
entry = self.sensor_picker.selected_entry()
|
||||||
|
is_finger = (
|
||||||
|
entry is not None
|
||||||
|
and str(entry.product_family or "").strip().lower() == "finger"
|
||||||
|
)
|
||||||
|
if not is_finger:
|
||||||
|
return path, None, ""
|
||||||
|
serial = str(device_id or "").strip()
|
||||||
|
if not serial:
|
||||||
|
return path, None, t("video_analysis.device_required")
|
||||||
|
sensor_type, sn = calibration_identity(path)
|
||||||
|
if sensor_type and sensor_type != "finger":
|
||||||
|
return path, None, t("video_analysis.device_family", family=sensor_type)
|
||||||
|
if not sn:
|
||||||
|
return path, None, t("video_analysis.device_missing_sn")
|
||||||
|
if sn != serial:
|
||||||
|
return path, None, t("video_analysis.device_mismatch", sn=sn)
|
||||||
|
return path, serial, ""
|
||||||
|
|
||||||
|
def apply_live_calibration_choice(self) -> None:
|
||||||
|
"""Remember the dialog's choice; hot-update the device when a session is open."""
|
||||||
|
if self.sensor_dialog is not None:
|
||||||
|
problem = self.sensor_dialog.choice_problem()
|
||||||
|
if problem:
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("sensor.live_calibration_invalid", reason=problem)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
cal_path, _device_id, problem = self._resolved_live_calibration()
|
||||||
|
if problem:
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("sensor.live_calibration_invalid", reason=problem)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
if cal_path is None or not self._running:
|
||||||
|
self.statusBar().showMessage(t("sensor.live_calibration_remembered"))
|
||||||
|
return
|
||||||
|
worker = self._active_worker
|
||||||
|
if worker is None or not hasattr(worker, "set_pending_calibration"):
|
||||||
|
self.statusBar().showMessage(t("cal.apply_no_target"))
|
||||||
|
return
|
||||||
|
worker.set_pending_calibration(cal_path)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("sensor.live_calibration_applied", name=cal_path.name)
|
||||||
|
)
|
||||||
|
|
||||||
# ------------------------------------------------- calibration (forwards)
|
# ------------------------------------------------- calibration (forwards)
|
||||||
# "What does this coefficient file claim, and does it match the bench?" lives in
|
# "What does this coefficient file claim, and does it match the bench?" lives in
|
||||||
@@ -1999,7 +2152,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
# A different product is a different UV island; the previous latch must not
|
# A different product is a different UV island; the previous latch must not
|
||||||
# paint the new canvas (a box result has no mask and would inherit a finger's).
|
# paint the new canvas (a box result has no mask and would inherit a finger's).
|
||||||
self._domain_mask.reset()
|
self.frame_view.reset_domain_mask()
|
||||||
self.mesh3d_panel.show_reference_only()
|
self.mesh3d_panel.show_reference_only()
|
||||||
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
|
# The pane follows the sensor: a box sensor has no 3D scene, and a finger's
|
||||||
# missing package must not be reported while looking at a box.
|
# missing package must not be reported while looking at a box.
|
||||||
@@ -2287,6 +2440,13 @@ class StudioMainWindow(QMainWindow):
|
|||||||
self.toolbar.set_run_checked(False)
|
self.toolbar.set_run_checked(False)
|
||||||
self.statusBar().showMessage(t("status.video_required"))
|
self.statusBar().showMessage(t("status.video_required"))
|
||||||
return
|
return
|
||||||
|
cal_path, device_id, cal_problem = self._resolved_live_calibration()
|
||||||
|
if cal_problem:
|
||||||
|
self.toolbar.set_run_checked(False)
|
||||||
|
self.statusBar().showMessage(
|
||||||
|
t("sensor.live_calibration_invalid", reason=cal_problem)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
if self._preview_open:
|
if self._preview_open:
|
||||||
self._stop_preview(announce=False)
|
self._stop_preview(announce=False)
|
||||||
@@ -2312,13 +2472,11 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# starts empty and is filled from that confirmation — a path is not a
|
# starts empty and is filled from that confirmation — a path is not a
|
||||||
# version, and the same path can hold different numbers.
|
# version, and the same path can hold different numbers.
|
||||||
self._active_calibration_id = ""
|
self._active_calibration_id = ""
|
||||||
self._depth_range_value = None
|
self.frame_view.clear_depth_latch()
|
||||||
self._last_depth_frame = None
|
|
||||||
self._publish_shared_depth_scale(None)
|
|
||||||
# The new session has not established a domain boundary yet; the previous
|
# The new session has not established a domain boundary yet; the previous
|
||||||
# session's island must not survive into it (a different sensor is a different
|
# session's island must not survive into it (a different sensor is a different
|
||||||
# island, and the latch compares against what it holds).
|
# island, and the latch compares against what it holds).
|
||||||
self._domain_mask.reset()
|
self.frame_view.reset_domain_mask()
|
||||||
# The session's own bookkeeping (its counter and telemetry) is the
|
# The session's own bookkeeping (its counter and telemetry) is the
|
||||||
# controller's: telling it a run starts is what drops the previous run's
|
# controller's: telling it a run starts is what drops the previous run's
|
||||||
# numbers, so none of them can be read as this run's.
|
# numbers, so none of them can be read as this run's.
|
||||||
@@ -2348,7 +2506,12 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# The acquisition thread applies it and records what the device reports.
|
# The acquisition thread applies it and records what the device reports.
|
||||||
self._active_worker.set_filter_settings(self._filter_settings())
|
self._active_worker.set_filter_settings(self._filter_settings())
|
||||||
try:
|
try:
|
||||||
self._active_worker.start(source, config_ref=config_ref)
|
self._active_worker.start(
|
||||||
|
source,
|
||||||
|
config_ref=config_ref,
|
||||||
|
cal_path=cal_path,
|
||||||
|
device_id=device_id,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
report_error(exc, message="Run failed")
|
report_error(exc, message="Run failed")
|
||||||
self.toolbar.set_run_checked(False)
|
self.toolbar.set_run_checked(False)
|
||||||
@@ -2611,147 +2774,34 @@ class StudioMainWindow(QMainWindow):
|
|||||||
# Compose after deliver_measurement submitted this tick's packet.
|
# Compose after deliver_measurement submitted this tick's packet.
|
||||||
presenter.tick_3d(display_paused=self._display_paused)
|
presenter.tick_3d(display_paused=self._display_paused)
|
||||||
|
|
||||||
# ------------------------------------------------------------ depth scale
|
# ---------------------------------------------------- frame view (forwards)
|
||||||
def _depth_display_settings(self):
|
# Depth colour span, domain latch and the 2D paint live in ``FrameView``. These
|
||||||
"""The display settings that decide the shared depth colour span."""
|
# keep the names tests and ``ImageSink`` callers already use.
|
||||||
panel = getattr(self, "mesh3d_panel", None)
|
@property
|
||||||
if panel is None or not hasattr(panel, "display_settings"):
|
def _domain_mask(self):
|
||||||
return None
|
return self.frame_view.domain_mask
|
||||||
try:
|
|
||||||
return panel.display_settings()
|
|
||||||
except Exception: # noqa: BLE001 - a missing pane must not blank the 2D view
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _fixed_depth_span(self) -> tuple[float, float] | None:
|
@property
|
||||||
"""Typed colour bounds, when the operator chose a fixed scale."""
|
def _depth_range_value(self) -> tuple[float, float] | None:
|
||||||
settings = self._depth_display_settings()
|
return self.frame_view.depth_range_value
|
||||||
scale = getattr(settings, "color_scale", None) if settings is not None else None
|
|
||||||
if scale is None or str(getattr(scale, "mode", "")) != "fixed":
|
|
||||||
return None
|
|
||||||
lo = float(scale.lower)
|
|
||||||
hi = float(scale.upper)
|
|
||||||
if hi <= lo:
|
|
||||||
hi = lo + 1.0
|
|
||||||
return lo, hi
|
|
||||||
|
|
||||||
def _depth_scale_adaptive(self) -> bool:
|
@_depth_range_value.setter
|
||||||
settings = self._depth_display_settings()
|
def _depth_range_value(self, value: tuple[float, float] | None) -> None:
|
||||||
return bool(getattr(settings, "depth_scale_adaptive", False))
|
self.frame_view.depth_range_value = value
|
||||||
|
|
||||||
def _depth_scale_mode(self) -> str:
|
@property
|
||||||
"""``fixed``, ``adaptive`` or ``latched`` — the rule both views follow."""
|
def _last_depth_frame(self):
|
||||||
if self._fixed_depth_span() is not None:
|
return self.frame_view.last_depth_frame
|
||||||
return "fixed"
|
|
||||||
if self._depth_scale_adaptive():
|
|
||||||
return "adaptive"
|
|
||||||
return "latched"
|
|
||||||
|
|
||||||
def _frame_depth_span(self, depth) -> tuple[float, float] | None:
|
@_last_depth_frame.setter
|
||||||
"""Finite minimum and maximum of one depth map."""
|
def _last_depth_frame(self, value) -> None:
|
||||||
if depth is None:
|
self.frame_view.last_depth_frame = value
|
||||||
return None
|
|
||||||
arr = np.asarray(depth, dtype=np.float64)
|
|
||||||
finite = np.isfinite(arr)
|
|
||||||
if arr.size == 0 or not np.any(finite):
|
|
||||||
return None
|
|
||||||
lo = float(np.min(arr[finite]))
|
|
||||||
hi = float(np.max(arr[finite]))
|
|
||||||
if hi <= lo:
|
|
||||||
hi = lo + 1.0
|
|
||||||
return lo, hi
|
|
||||||
|
|
||||||
def _depth_range(self) -> tuple[float, float] | None:
|
def _depth_range(self) -> tuple[float, float] | None:
|
||||||
"""The session latch: the first frame that carries finite depth.
|
return self.frame_view.depth_range()
|
||||||
|
|
||||||
Re-fitting the range on every frame made colours drift, so two frames
|
|
||||||
could look identical while holding different depths. Latching keeps
|
|
||||||
frames comparable for the whole session. The per-frame colour bar is
|
|
||||||
the adaptive setting; this method is only the latch.
|
|
||||||
"""
|
|
||||||
if self._depth_range_value is not None:
|
|
||||||
return self._depth_range_value
|
|
||||||
span = self._frame_depth_span(self._last_depth_frame)
|
|
||||||
if span is None:
|
|
||||||
return None
|
|
||||||
self._depth_range_value = span
|
|
||||||
return self._depth_range_value
|
|
||||||
|
|
||||||
def _active_depth_span(self) -> tuple[float, float] | None:
|
|
||||||
"""The span both views colour with for the depth now on screen.
|
|
||||||
|
|
||||||
Leaving the adaptive or fixed rule re-latches on this frame, so the
|
|
||||||
next comparison does not keep a range from the other mode.
|
|
||||||
"""
|
|
||||||
mode = self._depth_scale_mode()
|
|
||||||
previous = getattr(self, "_depth_scale_mode_applied", "latched")
|
|
||||||
if mode == "latched" and previous != "latched":
|
|
||||||
self._depth_range_value = None
|
|
||||||
self._depth_scale_mode_applied = mode
|
|
||||||
if mode == "fixed":
|
|
||||||
return self._fixed_depth_span()
|
|
||||||
if mode == "adaptive":
|
|
||||||
return self._frame_depth_span(self._last_depth_frame)
|
|
||||||
return self._depth_range()
|
|
||||||
|
|
||||||
def _depth_scale_tip_key(self) -> str:
|
|
||||||
mode = getattr(self, "_depth_scale_mode_applied", "latched")
|
|
||||||
if mode == "fixed":
|
|
||||||
return "panels.depth_scale_tip_fixed"
|
|
||||||
if mode == "adaptive":
|
|
||||||
return "panels.depth_scale_tip_adaptive"
|
|
||||||
return "panels.depth_scale_tip"
|
|
||||||
|
|
||||||
def _publish_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
|
||||||
"""Hand the depth colour span to the 3D pane, when one exists."""
|
|
||||||
panel = getattr(self, "mesh3d_panel", None)
|
|
||||||
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
|
|
||||||
panel.set_shared_depth_scale(span)
|
|
||||||
|
|
||||||
def _depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
|
||||||
"""The colour scale, plus — when the frame's depth is exactly zero — why it is flat.
|
|
||||||
|
|
||||||
A depth view of an all-zero map is a flat island in the ramp's low colour, which
|
|
||||||
looks the same as a broken view. The frame knows which it is: the pass stored the
|
|
||||||
channel, its non-zero count and its range, so "0 of 84,000 pixels" can be said
|
|
||||||
instead of leaving the operator to wonder. It is not the same statement as
|
|
||||||
``panels.depth_unavailable``, which is for a frame that carries no depth at all.
|
|
||||||
"""
|
|
||||||
span = self._active_depth_span()
|
|
||||||
text = "" if span is None else t(
|
|
||||||
"panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}"
|
|
||||||
)
|
|
||||||
if frame is None or frame.depth is None:
|
|
||||||
return text
|
|
||||||
array = np.asarray(frame.depth)
|
|
||||||
if array.size and not bool(np.any(array)):
|
|
||||||
empty = t("panels.depth_empty", pixels=int(array.size))
|
|
||||||
return f"{text} · {empty}" if text else empty
|
|
||||||
return text
|
|
||||||
|
|
||||||
def reset_domain_mask(self) -> None:
|
def reset_domain_mask(self) -> None:
|
||||||
"""Drop the latched UV island: a new recording or sensor has its own geometry."""
|
self.frame_view.reset_domain_mask()
|
||||||
self._domain_mask.reset()
|
|
||||||
|
|
||||||
def _latched_domain_mask(self, frame: ImageFrame):
|
|
||||||
"""The 2D domain boundary to draw, latched for the session.
|
|
||||||
|
|
||||||
Exactly the treatment ``_depth_range`` gives the colour scale, and for the same
|
|
||||||
reason: the boundary is not a per-frame measurement. It is the device's own UV
|
|
||||||
island — geometry, rebuilt from the same remap on every frame — and the depth
|
|
||||||
view paints it as its "no depth" grey, so rebuilding it per frame redrew an edge
|
|
||||||
that had not moved while any difference in ``frame.valid_mask`` repainted the
|
|
||||||
whole boundary, which reads as lag. The latch holds the boundary until a frame
|
|
||||||
no longer describes the same island (see ``DomainMaskLatch``).
|
|
||||||
|
|
||||||
A frame with no mask (the box chain) must not keep a previous finger island: that
|
|
||||||
shape fails the domain check and left a silent black arrow canvas on reload.
|
|
||||||
"""
|
|
||||||
held = self._domain_mask.mask
|
|
||||||
if frame.valid_mask is None and held is not None and frame.image_bgr is not None:
|
|
||||||
image_hw = tuple(int(v) for v in np.asarray(frame.image_bgr).shape[:2])
|
|
||||||
if tuple(int(v) for v in held.shape[:2]) != image_hw:
|
|
||||||
self._domain_mask.reset()
|
|
||||||
return self._domain_mask.update(frame.valid_mask)
|
|
||||||
|
|
||||||
def _render_run_image(self, packet) -> None:
|
def _render_run_image(self, packet) -> None:
|
||||||
"""Live / recompute entry into the shared 2D→3D deliver path."""
|
"""Live / recompute entry into the shared 2D→3D deliver path."""
|
||||||
@@ -2809,77 +2859,19 @@ class StudioMainWindow(QMainWindow):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _show_image_frame(self, frame: ImageFrame) -> None:
|
def _show_image_frame(self, frame: ImageFrame) -> None:
|
||||||
"""The single 2D sink: plan the frame, render it, hand the panel one image.
|
"""The single 2D sink: plan the frame, render it, hand the panel one image."""
|
||||||
|
|
||||||
Live, recomputed, replayed and previewed frames all arrive here, so the mode
|
|
||||||
set and the annotations follow the frame's own fields rather than which view
|
|
||||||
happens to be on screen.
|
|
||||||
"""
|
|
||||||
from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr
|
|
||||||
|
|
||||||
plan = plan_image(frame)
|
|
||||||
self.arrow_panel.set_plan(plan)
|
|
||||||
mode = plan.resolve(self.arrow_panel.view_mode())
|
|
||||||
self.arrow_panel.set_view_mode(mode)
|
|
||||||
if self.acquire_presenter is not None:
|
|
||||||
self.acquire_presenter.note_rendered_mode(mode)
|
|
||||||
draw_style = self._draw_style()
|
draw_style = self._draw_style()
|
||||||
# The legend is decided by the same inputs as the picture: the arrow
|
presenter = self.acquire_presenter
|
||||||
# magnification of this sensor's draw style, and whether this frame
|
self.frame_view.show(
|
||||||
# carries a valid-area mask. It stays the *frame's* statement rather than the
|
|
||||||
# latched boundary's, because that is what the chip claims: the arrows are
|
|
||||||
# gated by this frame's own mask on every path, latched boundary or not.
|
|
||||||
boundary = self._latched_domain_mask(frame)
|
|
||||||
self.arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
|
||||||
self.arrow_panel.set_valid_area(frame.valid_mask is not None)
|
|
||||||
if frame.depth is not None:
|
|
||||||
# Remember every depth-bearing frame, whichever 2D mode is showing,
|
|
||||||
# so a later depth view and the 3D surface name the same interval.
|
|
||||||
self._last_depth_frame = frame.depth
|
|
||||||
span = self._active_depth_span()
|
|
||||||
self._publish_shared_depth_scale(span)
|
|
||||||
if mode == DEPTH:
|
|
||||||
self.arrow_panel.set_depth_scale(
|
|
||||||
self._depth_scale_text(frame),
|
|
||||||
tip_key=self._depth_scale_tip_key(),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
self.arrow_panel.set_depth_scale(None)
|
|
||||||
rendered = render_image(
|
|
||||||
frame,
|
frame,
|
||||||
plan,
|
|
||||||
mode=mode,
|
|
||||||
theme=self._theme,
|
theme=self._theme,
|
||||||
draw_style=draw_style,
|
draw_style=draw_style,
|
||||||
depth_range=span,
|
arrow_policy=self._arrow_policy(),
|
||||||
canvas_bgr=hex_to_bgr(color(self._theme, "arrow_canvas", "#0B1830")),
|
|
||||||
domain_mask=boundary,
|
|
||||||
# A frame read from a stored analysis result says which policy its *recovered*
|
|
||||||
# field can be sampled with; a live field is dense and takes the display's own.
|
|
||||||
arrow_policy=frame.arrow_policy or self._arrow_policy(),
|
|
||||||
# The 2D presentation of the finger's unrolled canvas. Display only: the
|
|
||||||
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
|
|
||||||
# not involved at all (its frame is the design export's).
|
|
||||||
orientation=effective_orientation(
|
orientation=effective_orientation(
|
||||||
self._finger_orientation, draw_style=draw_style
|
self._finger_orientation, draw_style=draw_style
|
||||||
),
|
),
|
||||||
|
on_mode=None if presenter is None else presenter.note_rendered_mode,
|
||||||
)
|
)
|
||||||
diagnostic = (
|
|
||||||
None if rendered is None else getattr(rendered, "frame_diagnostic", None)
|
|
||||||
)
|
|
||||||
if rendered is None or diagnostic is not None:
|
|
||||||
# Never substitute another picture here: a missing depth map that showed
|
|
||||||
# the processed frame made "no depth" look like a depth reading. A draw
|
|
||||||
# failure used to return the black base image; say so instead.
|
|
||||||
if mode == DEPTH and rendered is None:
|
|
||||||
message = t("panels.depth_unavailable")
|
|
||||||
elif diagnostic is not None:
|
|
||||||
message = t("panels.arrow_render_failed")
|
|
||||||
else:
|
|
||||||
message = t("panels.arrow_empty")
|
|
||||||
self.arrow_panel.clear_preview(message)
|
|
||||||
return
|
|
||||||
self.arrow_panel.show_frame(bgr_to_qimage(rendered))
|
|
||||||
|
|
||||||
# ------------------------------------------------------ analysis (Part B)
|
# ------------------------------------------------------ analysis (Part B)
|
||||||
# What this window owes the replay controller, and nothing more. The *policy*
|
# What this window owes the replay controller, and nothing more. The *policy*
|
||||||
@@ -2912,7 +2904,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
session = RecordingReader(Path(recording)).session()
|
session = RecordingReader(Path(recording)).session()
|
||||||
synthetic = dict(session.get("synthetic") or {})
|
synthetic = dict(session.get("synthetic") or {})
|
||||||
chain = chain_spec_for(
|
chain = chain_spec_for(
|
||||||
session, calibration=synthetic.get("calibration") or None
|
session,
|
||||||
|
calibration=synthetic.get("calibration") or None,
|
||||||
|
device_id=synthetic.get("device_id") or None,
|
||||||
)
|
)
|
||||||
reference = synthetic.get("reference_frame")
|
reference = synthetic.get("reference_frame")
|
||||||
if reference is None:
|
if reference is None:
|
||||||
@@ -2935,6 +2929,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
initial_calibration: Path | None = None,
|
initial_calibration: Path | None = None,
|
||||||
prefer_no_calibration: bool = False,
|
prefer_no_calibration: bool = False,
|
||||||
initial_reference_frame: int | None = None,
|
initial_reference_frame: int | None = None,
|
||||||
|
initial_device_id: str | None = None,
|
||||||
) -> Path | 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.
|
||||||
|
|
||||||
@@ -2966,7 +2961,9 @@ class StudioMainWindow(QMainWindow):
|
|||||||
initial_calibration=initial_calibration,
|
initial_calibration=initial_calibration,
|
||||||
prefer_no_calibration=prefer_no_calibration,
|
prefer_no_calibration=prefer_no_calibration,
|
||||||
initial_reference_frame=initial_reference_frame,
|
initial_reference_frame=initial_reference_frame,
|
||||||
|
initial_device_id=initial_device_id,
|
||||||
theme=self._theme,
|
theme=self._theme,
|
||||||
|
remembered_calibration=self.live_calibration_for,
|
||||||
)
|
)
|
||||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||||
return None
|
return None
|
||||||
@@ -3009,6 +3006,7 @@ class StudioMainWindow(QMainWindow):
|
|||||||
initial_reference_frame=getattr(self, "_analysis_reference_override", None),
|
initial_reference_frame=getattr(self, "_analysis_reference_override", None),
|
||||||
lock_profile=True,
|
lock_profile=True,
|
||||||
theme=self._theme,
|
theme=self._theme,
|
||||||
|
remembered_calibration=self.live_calibration_for,
|
||||||
)
|
)
|
||||||
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
if dialog.exec() != VideoAnalysisDialog.DialogCode.Accepted:
|
||||||
return False
|
return False
|
||||||
|
|||||||
@@ -0,0 +1,279 @@
|
|||||||
|
"""2D paint path and shared depth colour span for Studio.
|
||||||
|
|
||||||
|
Live, recomputed, replayed and previewed frames all arrive at
|
||||||
|
:meth:`FrameView.show`. The shell keeps the ImageSink method names and hands
|
||||||
|
each call the display facts (theme, draw style, arrow policy, orientation);
|
||||||
|
this module owns the latch state those paints share with the 3D pane.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from orisys_app_shared.acquisition.arrows import arrow_display_scale, hex_to_bgr
|
||||||
|
from orisys_app_shared.acquisition.image_backend import (
|
||||||
|
DEPTH,
|
||||||
|
DEPTH_ARROWS,
|
||||||
|
DomainMaskLatch,
|
||||||
|
ImageFrame,
|
||||||
|
plan_image,
|
||||||
|
)
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.theme import color
|
||||||
|
|
||||||
|
from orisys_studio.ui.arrow_panel import bgr_to_qimage
|
||||||
|
|
||||||
|
|
||||||
|
class FrameView:
|
||||||
|
"""One 2D sink: depth colour span, domain latch, and the arrow canvas paint."""
|
||||||
|
|
||||||
|
def __init__(self, arrow_panel: Any, mesh_panel: Any) -> None:
|
||||||
|
self._arrow_panel = arrow_panel
|
||||||
|
self._mesh_panel = mesh_panel
|
||||||
|
self._depth_range_value: tuple[float, float] | None = None
|
||||||
|
self._last_depth_frame = None
|
||||||
|
self._depth_scale_mode_applied = "latched"
|
||||||
|
#: The 2D domain boundary, latched for the session the way the depth range is:
|
||||||
|
#: the island is the device's own geometry and is rebuilt from the same remap
|
||||||
|
#: every frame, so re-deriving the edge per frame redrew a boundary that had not
|
||||||
|
#: moved (see ``image_backend.DomainMaskLatch``).
|
||||||
|
self.domain_mask = DomainMaskLatch()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def depth_range_value(self) -> tuple[float, float] | None:
|
||||||
|
return self._depth_range_value
|
||||||
|
|
||||||
|
@depth_range_value.setter
|
||||||
|
def depth_range_value(self, value: tuple[float, float] | None) -> None:
|
||||||
|
self._depth_range_value = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_depth_frame(self):
|
||||||
|
return self._last_depth_frame
|
||||||
|
|
||||||
|
@last_depth_frame.setter
|
||||||
|
def last_depth_frame(self, value) -> None:
|
||||||
|
self._last_depth_frame = value
|
||||||
|
|
||||||
|
def clear_depth_latch(self) -> None:
|
||||||
|
"""Drop the session colour span and tell the 3D pane nothing is shared yet."""
|
||||||
|
self._depth_range_value = None
|
||||||
|
self._last_depth_frame = None
|
||||||
|
self.publish_shared_depth_scale(None)
|
||||||
|
|
||||||
|
def reset_domain_mask(self) -> None:
|
||||||
|
"""Drop the latched UV island: a new recording or sensor has its own geometry."""
|
||||||
|
self.domain_mask.reset()
|
||||||
|
|
||||||
|
def depth_display_settings(self):
|
||||||
|
"""The display settings that decide the shared depth colour span."""
|
||||||
|
panel = self._mesh_panel
|
||||||
|
if panel is None or not hasattr(panel, "display_settings"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return panel.display_settings()
|
||||||
|
except Exception: # noqa: BLE001 - a missing pane must not blank the 2D view
|
||||||
|
return None
|
||||||
|
|
||||||
|
def fixed_depth_span(self) -> tuple[float, float] | None:
|
||||||
|
"""Typed colour bounds, when the operator chose a fixed scale."""
|
||||||
|
settings = self.depth_display_settings()
|
||||||
|
scale = getattr(settings, "color_scale", None) if settings is not None else None
|
||||||
|
if scale is None or str(getattr(scale, "mode", "")) != "fixed":
|
||||||
|
return None
|
||||||
|
lo = float(scale.lower)
|
||||||
|
hi = float(scale.upper)
|
||||||
|
if hi <= lo:
|
||||||
|
hi = lo + 1.0
|
||||||
|
return lo, hi
|
||||||
|
|
||||||
|
def depth_scale_adaptive(self) -> bool:
|
||||||
|
settings = self.depth_display_settings()
|
||||||
|
return bool(getattr(settings, "depth_scale_adaptive", False))
|
||||||
|
|
||||||
|
def depth_scale_mode(self) -> str:
|
||||||
|
"""``fixed``, ``adaptive`` or ``latched`` — the rule both views follow."""
|
||||||
|
if self.fixed_depth_span() is not None:
|
||||||
|
return "fixed"
|
||||||
|
if self.depth_scale_adaptive():
|
||||||
|
return "adaptive"
|
||||||
|
return "latched"
|
||||||
|
|
||||||
|
def frame_depth_span(self, depth) -> tuple[float, float] | None:
|
||||||
|
"""Finite minimum and maximum of one depth map."""
|
||||||
|
if depth is None:
|
||||||
|
return None
|
||||||
|
arr = np.asarray(depth, dtype=np.float64)
|
||||||
|
finite = np.isfinite(arr)
|
||||||
|
if arr.size == 0 or not np.any(finite):
|
||||||
|
return None
|
||||||
|
lo = float(np.min(arr[finite]))
|
||||||
|
hi = float(np.max(arr[finite]))
|
||||||
|
if hi <= lo:
|
||||||
|
hi = lo + 1.0
|
||||||
|
return lo, hi
|
||||||
|
|
||||||
|
def depth_range(self) -> tuple[float, float] | None:
|
||||||
|
"""The session latch: the first frame that carries finite depth.
|
||||||
|
|
||||||
|
Re-fitting the range on every frame made colours drift, so two frames
|
||||||
|
could look identical while holding different depths. Latching keeps
|
||||||
|
frames comparable for the whole session. The per-frame colour bar is
|
||||||
|
the adaptive setting; this method is only the latch.
|
||||||
|
"""
|
||||||
|
if self._depth_range_value is not None:
|
||||||
|
return self._depth_range_value
|
||||||
|
span = self.frame_depth_span(self._last_depth_frame)
|
||||||
|
if span is None:
|
||||||
|
return None
|
||||||
|
self._depth_range_value = span
|
||||||
|
return self._depth_range_value
|
||||||
|
|
||||||
|
def active_depth_span(self) -> tuple[float, float] | None:
|
||||||
|
"""The span both views colour with for the depth now on screen.
|
||||||
|
|
||||||
|
Leaving the adaptive or fixed rule re-latches on this frame, so the
|
||||||
|
next comparison does not keep a range from the other mode.
|
||||||
|
"""
|
||||||
|
mode = self.depth_scale_mode()
|
||||||
|
previous = self._depth_scale_mode_applied
|
||||||
|
if mode == "latched" and previous != "latched":
|
||||||
|
self._depth_range_value = None
|
||||||
|
self._depth_scale_mode_applied = mode
|
||||||
|
if mode == "fixed":
|
||||||
|
return self.fixed_depth_span()
|
||||||
|
if mode == "adaptive":
|
||||||
|
return self.frame_depth_span(self._last_depth_frame)
|
||||||
|
return self.depth_range()
|
||||||
|
|
||||||
|
def depth_scale_tip_key(self) -> str:
|
||||||
|
mode = self._depth_scale_mode_applied
|
||||||
|
if mode == "fixed":
|
||||||
|
return "panels.depth_scale_tip_fixed"
|
||||||
|
if mode == "adaptive":
|
||||||
|
return "panels.depth_scale_tip_adaptive"
|
||||||
|
return "panels.depth_scale_tip"
|
||||||
|
|
||||||
|
def publish_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Hand the depth colour span to the 3D pane, when one exists."""
|
||||||
|
panel = self._mesh_panel
|
||||||
|
if panel is not None and hasattr(panel, "set_shared_depth_scale"):
|
||||||
|
panel.set_shared_depth_scale(span)
|
||||||
|
|
||||||
|
def depth_scale_text(self, frame: ImageFrame | None = None) -> str:
|
||||||
|
"""The colour scale for the depth now on screen.
|
||||||
|
|
||||||
|
``frame`` is kept so callers can pass the painted frame; the label is
|
||||||
|
the latched / adaptive / fixed span. A frame with no depth channel is
|
||||||
|
handled by ``panels.depth_unavailable``.
|
||||||
|
"""
|
||||||
|
_ = frame
|
||||||
|
span = self.active_depth_span()
|
||||||
|
if span is None:
|
||||||
|
return ""
|
||||||
|
return t(
|
||||||
|
"panels.depth_scale", low=f"{span[0]:.3f}", high=f"{span[1]:.3f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def latched_domain_mask(self, frame: ImageFrame):
|
||||||
|
"""The 2D domain boundary to draw, latched for the session.
|
||||||
|
|
||||||
|
Exactly the treatment :meth:`depth_range` gives the colour scale, and for the same
|
||||||
|
reason: the boundary is not a per-frame measurement. It is the device's own UV
|
||||||
|
island — geometry, rebuilt from the same remap on every frame — and the depth
|
||||||
|
view paints it as its "no depth" grey, so rebuilding it per frame redrew an edge
|
||||||
|
that had not moved while any difference in ``frame.valid_mask`` repainted the
|
||||||
|
whole boundary, which reads as lag. The latch holds the boundary until a frame
|
||||||
|
no longer describes the same island (see ``DomainMaskLatch``).
|
||||||
|
|
||||||
|
A frame with no mask (the box chain) must not keep a previous finger island: that
|
||||||
|
shape fails the domain check and left a silent black arrow canvas on reload.
|
||||||
|
"""
|
||||||
|
held = self.domain_mask.mask
|
||||||
|
if frame.valid_mask is None and held is not None and frame.image_bgr is not None:
|
||||||
|
image_hw = tuple(int(v) for v in np.asarray(frame.image_bgr).shape[:2])
|
||||||
|
if tuple(int(v) for v in held.shape[:2]) != image_hw:
|
||||||
|
self.domain_mask.reset()
|
||||||
|
return self.domain_mask.update(frame.valid_mask)
|
||||||
|
|
||||||
|
def show(
|
||||||
|
self,
|
||||||
|
frame: ImageFrame,
|
||||||
|
*,
|
||||||
|
theme: str,
|
||||||
|
draw_style: str,
|
||||||
|
arrow_policy: Any = None,
|
||||||
|
orientation: str | None = None,
|
||||||
|
on_mode: Callable[[str], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""The single 2D sink: plan the frame, render it, hand the panel one image.
|
||||||
|
|
||||||
|
Live, recomputed, replayed and previewed frames all arrive here, so the mode
|
||||||
|
set and the annotations follow the frame's own fields rather than which view
|
||||||
|
happens to be on screen.
|
||||||
|
"""
|
||||||
|
plan = plan_image(frame)
|
||||||
|
self._arrow_panel.set_plan(plan)
|
||||||
|
mode = plan.resolve(self._arrow_panel.view_mode())
|
||||||
|
self._arrow_panel.set_view_mode(mode)
|
||||||
|
if on_mode is not None:
|
||||||
|
on_mode(mode)
|
||||||
|
# The legend is decided by the same inputs as the picture: the arrow
|
||||||
|
# magnification of this sensor's draw style, and whether this frame
|
||||||
|
# carries a valid-area mask. It stays the *frame's* statement rather than the
|
||||||
|
# latched boundary's, because that is what the chip claims: the arrows are
|
||||||
|
# gated by this frame's own mask on every path, latched boundary or not.
|
||||||
|
boundary = self.latched_domain_mask(frame)
|
||||||
|
self._arrow_panel.set_arrow_scale(arrow_display_scale(draw_style))
|
||||||
|
self._arrow_panel.set_valid_area(frame.valid_mask is not None)
|
||||||
|
if frame.depth is not None:
|
||||||
|
# Remember every depth-bearing frame, whichever 2D mode is showing,
|
||||||
|
# so a later depth view and the 3D surface name the same interval.
|
||||||
|
self._last_depth_frame = frame.depth
|
||||||
|
span = self.active_depth_span()
|
||||||
|
self.publish_shared_depth_scale(span)
|
||||||
|
if mode in (DEPTH, DEPTH_ARROWS):
|
||||||
|
self._arrow_panel.set_depth_scale(
|
||||||
|
self.depth_scale_text(frame),
|
||||||
|
tip_key=self.depth_scale_tip_key(),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._arrow_panel.set_depth_scale(None)
|
||||||
|
# Look up through the shell module so tests that monkeypatch
|
||||||
|
# ``app_window.render_image`` still see every paint.
|
||||||
|
from orisys_studio.app_window import render_image
|
||||||
|
|
||||||
|
rendered = render_image(
|
||||||
|
frame,
|
||||||
|
plan,
|
||||||
|
mode=mode,
|
||||||
|
theme=theme,
|
||||||
|
draw_style=draw_style,
|
||||||
|
depth_range=span,
|
||||||
|
canvas_bgr=hex_to_bgr(color(theme, "arrow_canvas", "#0B1830")),
|
||||||
|
domain_mask=boundary,
|
||||||
|
# A frame read from a stored analysis result says which policy its *recovered*
|
||||||
|
# field can be sampled with; a live field is dense and takes the display's own.
|
||||||
|
arrow_policy=frame.arrow_policy or arrow_policy,
|
||||||
|
# The 2D presentation of the finger's unrolled canvas. Display only: the
|
||||||
|
# frame's own flow/mask/image are handed over untouched, and the 3D pane is
|
||||||
|
# not involved at all (its frame is the design export's).
|
||||||
|
orientation=orientation,
|
||||||
|
)
|
||||||
|
diagnostic = (
|
||||||
|
None if rendered is None else getattr(rendered, "frame_diagnostic", None)
|
||||||
|
)
|
||||||
|
if rendered is None or diagnostic is not None:
|
||||||
|
# Never substitute another picture here: a missing depth map that showed
|
||||||
|
# the processed frame made "no depth" look like a depth reading. A draw
|
||||||
|
# failure used to return the black base image; say so instead.
|
||||||
|
if mode in (DEPTH, DEPTH_ARROWS) and rendered is None:
|
||||||
|
message = t("panels.depth_unavailable")
|
||||||
|
elif diagnostic is not None:
|
||||||
|
message = t("panels.arrow_render_failed")
|
||||||
|
else:
|
||||||
|
message = t("panels.arrow_empty")
|
||||||
|
self._arrow_panel.clear_preview(message)
|
||||||
|
return
|
||||||
|
self._arrow_panel.show_frame(bgr_to_qimage(rendered))
|
||||||
@@ -51,7 +51,6 @@ from orisys_studio.replay_analysis import (
|
|||||||
AnalysisSession,
|
AnalysisSession,
|
||||||
ResultLibrary,
|
ResultLibrary,
|
||||||
ResultPolicyError,
|
ResultPolicyError,
|
||||||
policy_divergence,
|
|
||||||
)
|
)
|
||||||
from orisys_studio.shell_ports import ReplayShell
|
from orisys_studio.shell_ports import ReplayShell
|
||||||
|
|
||||||
@@ -439,6 +438,7 @@ class ReplayController(QObject):
|
|||||||
initial_calibration=None if prefer_none else Path(cal),
|
initial_calibration=None if prefer_none else Path(cal),
|
||||||
prefer_no_calibration=prefer_none,
|
prefer_no_calibration=prefer_none,
|
||||||
initial_reference_frame=block.get("reference_frame"),
|
initial_reference_frame=block.get("reference_frame"),
|
||||||
|
initial_device_id=str(block.get("device_id") or "") or None,
|
||||||
)
|
)
|
||||||
if workspace is None:
|
if workspace is None:
|
||||||
return
|
return
|
||||||
@@ -775,27 +775,19 @@ class ReplayController(QObject):
|
|||||||
state this view is served in.
|
state this view is served in.
|
||||||
|
|
||||||
A policy that moves the sample *grid* cannot be served by the stored candidate
|
A policy that moves the sample *grid* cannot be served by the stored candidate
|
||||||
locations; :meth:`AnalysisSession.serve_policy` refuses it, the message names the
|
locations; :meth:`AnalysisSession.serve_policy` refuses it and the frame is
|
||||||
keys, and the frame is still redrawn from the result's own grid, because
|
still redrawn from the result's own grid — without a status-bar warning —
|
||||||
re-gridding it would draw samples nothing measured.
|
because re-gridding it would draw samples nothing measured.
|
||||||
"""
|
"""
|
||||||
session = self.available_result()
|
session = self.available_result()
|
||||||
if session is None:
|
if session is None:
|
||||||
return
|
return
|
||||||
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||||
|
|
||||||
policy = policy_for_settings(settings)
|
# A policy that moves the sample grid is refused inside ``serve_policy``;
|
||||||
if not session.serve_policy(policy):
|
# the frame is still redrawn from the result's own grid. No status-bar
|
||||||
# A box result's grid is the live painter's, not a display preference.
|
# lecture: the operator cannot change the stored locations from here.
|
||||||
# The display policy is the shared selector's, so the two always differ,
|
session.serve_policy(policy_for_settings(settings))
|
||||||
# and saying so would scold the operator for a grid they cannot change.
|
|
||||||
from orisys_app_shared.acquisition.arrows import uses_sdk_box_grid
|
|
||||||
|
|
||||||
if not uses_sdk_box_grid(session.stored_policy()):
|
|
||||||
moved = policy_divergence(session.stored_policy(), policy)
|
|
||||||
self._host.notify(
|
|
||||||
t("playback.analysis_grid_mismatch", keys="·".join(moved))
|
|
||||||
)
|
|
||||||
self._engine.redraw()
|
self._engine.redraw()
|
||||||
|
|
||||||
def on_analysis_requested(self) -> None:
|
def on_analysis_requested(self) -> None:
|
||||||
|
|||||||
@@ -263,6 +263,24 @@ def shader_source(profile: Profile, program: str) -> tuple[str, str]:
|
|||||||
return header + vertex, header + body
|
return header + vertex, header + body
|
||||||
|
|
||||||
|
|
||||||
|
def _finite_minmax(scalar, valid) -> tuple[float, float] | None:
|
||||||
|
"""Finite minimum and maximum — the same interval ``FrameView.frame_depth_span`` uses."""
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
values = np.asarray(scalar, dtype=np.float64).reshape(-1)
|
||||||
|
mask = np.asarray(valid, dtype=bool).reshape(-1)
|
||||||
|
if values.size != mask.size:
|
||||||
|
return None
|
||||||
|
usable = values[mask & np.isfinite(values)]
|
||||||
|
if usable.size == 0:
|
||||||
|
return None
|
||||||
|
lo = float(np.min(usable))
|
||||||
|
hi = float(np.max(usable))
|
||||||
|
if hi <= lo:
|
||||||
|
hi = lo + 1.0
|
||||||
|
return lo, hi
|
||||||
|
|
||||||
|
|
||||||
def _valid_scalar_range(
|
def _valid_scalar_range(
|
||||||
scalar, valid, *, signed: bool = False
|
scalar, valid, *, signed: bool = False
|
||||||
) -> tuple[float, float] | None:
|
) -> tuple[float, float] | None:
|
||||||
@@ -379,6 +397,10 @@ def _field_scalar_range(
|
|||||||
hi = float(shared_depth_scale[1])
|
hi = float(shared_depth_scale[1])
|
||||||
if hi > lo and not (math.isnan(lo) or math.isnan(hi)):
|
if hi > lo and not (math.isnan(lo) or math.isnan(hi)):
|
||||||
return lo, hi
|
return lo, hi
|
||||||
|
if quantity == "depth_response":
|
||||||
|
# Same interval as the 2D canvas (finite min/max). A P5/P95 blend here
|
||||||
|
# made the 3D colour bar crawl toward a different number than the 2D label.
|
||||||
|
return _finite_minmax(field.scalar, field.valid)
|
||||||
measured = _valid_scalar_range(
|
measured = _valid_scalar_range(
|
||||||
field.scalar,
|
field.scalar,
|
||||||
field.valid,
|
field.valid,
|
||||||
|
|||||||
@@ -155,6 +155,13 @@ class ThreeDPanel(QFrame):
|
|||||||
#: Whether the pane explains the pipeline around the picture (the window's
|
#: Whether the pane explains the pipeline around the picture (the window's
|
||||||
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
#: 「诊断信息」 switch). Off by default: the picture and what it *is* stay.
|
||||||
self._show_diagnostics = False
|
self._show_diagnostics = False
|
||||||
|
#: Sample-grid fields have no controls: they stay at the values restored from
|
||||||
|
#: prefs (or the dataclass defaults) so a colour-scale click cannot reset them.
|
||||||
|
self._flow_grid_spacing = 10
|
||||||
|
self._max_arrows = 400
|
||||||
|
#: Depth colour span published by the 2D canvas. Shown here immediately so the
|
||||||
|
#: two legends do not wait on the next GL paint (which is rate-limited).
|
||||||
|
self._shared_depth_span: tuple[float, float] | None = None
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(8, 6, 8, 6)
|
root.setContentsMargins(8, 6, 8, 6)
|
||||||
@@ -244,6 +251,20 @@ class ThreeDPanel(QFrame):
|
|||||||
self.contact_arrow_check.toggled.connect(self._emit_settings)
|
self.contact_arrow_check.toggled.connect(self._emit_settings)
|
||||||
form.addRow(self.contact_arrow_check)
|
form.addRow(self.contact_arrow_check)
|
||||||
|
|
||||||
|
# One threshold for both the 2D canvas and the 3D overlays. ``flow_off_px``
|
||||||
|
# is derived (on − 0.1): neither view applies hysteresis today, and a band
|
||||||
|
# only one side honoured was the 2D/3D arrow-count mismatch.
|
||||||
|
self.flow_threshold_label = QLabel()
|
||||||
|
self.flow_threshold_spin = QDoubleSpinBox()
|
||||||
|
self.flow_threshold_spin.setRange(0.0, 50.0)
|
||||||
|
self.flow_threshold_spin.setDecimals(2)
|
||||||
|
self.flow_threshold_spin.setSingleStep(0.1)
|
||||||
|
self.flow_threshold_spin.setSuffix(" px")
|
||||||
|
self.flow_threshold_spin.setValue(0.4)
|
||||||
|
self.flow_threshold_spin.setMaximumWidth(SPIN_WIDTH)
|
||||||
|
self.flow_threshold_spin.valueChanged.connect(self._emit_settings)
|
||||||
|
form.addRow(self.flow_threshold_label, self.flow_threshold_spin)
|
||||||
|
|
||||||
self.scale_label = QLabel()
|
self.scale_label = QLabel()
|
||||||
self.scale_combo = QComboBox()
|
self.scale_combo = QComboBox()
|
||||||
self.scale_combo.addItem("fixed", "fixed")
|
self.scale_combo.addItem("fixed", "fixed")
|
||||||
@@ -472,7 +493,16 @@ class ThreeDPanel(QFrame):
|
|||||||
if quantity.startswith("scene3d."):
|
if quantity.startswith("scene3d."):
|
||||||
quantity = descriptor.quantity
|
quantity = descriptor.quantity
|
||||||
quality = t(f"scene3d.quality_{descriptor.quality}")
|
quality = t(f"scene3d.quality_{descriptor.quality}")
|
||||||
if effective is None or effective.scale_mode == "":
|
shared = (
|
||||||
|
self._shared_depth_span
|
||||||
|
if str(descriptor.quantity) == "depth_response"
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if shared is not None:
|
||||||
|
lo, hi = shared
|
||||||
|
elif effective is not None and effective.scale_mode:
|
||||||
|
lo, hi = effective.scale_lower, effective.scale_upper
|
||||||
|
else:
|
||||||
self._set_legend(
|
self._set_legend(
|
||||||
t(
|
t(
|
||||||
"scene3d.legend",
|
"scene3d.legend",
|
||||||
@@ -485,7 +515,7 @@ class ThreeDPanel(QFrame):
|
|||||||
return
|
return
|
||||||
mode = t(
|
mode = t(
|
||||||
"scene3d.legend_auto"
|
"scene3d.legend_auto"
|
||||||
if effective.scale_mode == "automatic"
|
if (effective is None or effective.scale_mode != "fixed")
|
||||||
else "scene3d.legend_fixed"
|
else "scene3d.legend_fixed"
|
||||||
)
|
)
|
||||||
self._set_legend(
|
self._set_legend(
|
||||||
@@ -494,15 +524,25 @@ class ThreeDPanel(QFrame):
|
|||||||
quantity=quantity,
|
quantity=quantity,
|
||||||
unit=descriptor.unit,
|
unit=descriptor.unit,
|
||||||
mode=mode,
|
mode=mode,
|
||||||
# Three significant digits: the footer is one line, and "0.033 – 0.90"
|
lower=self._format_bound(lo),
|
||||||
# says as much as "0.03337 – 0.8993" while leaving room for the rest.
|
upper=self._format_bound(hi),
|
||||||
lower=f"{effective.scale_lower:.3g}",
|
|
||||||
upper=f"{effective.scale_upper:.3g}",
|
|
||||||
quality=quality,
|
quality=quality,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
self.legend_label.setVisible(self._show_diagnostics)
|
self.legend_label.setVisible(self._show_diagnostics)
|
||||||
|
|
||||||
|
def set_shared_depth_scale(self, span: tuple[float, float] | None) -> None:
|
||||||
|
"""Adopt the 2D canvas's depth colour span and refresh the legend now."""
|
||||||
|
if span is None:
|
||||||
|
self._shared_depth_span = None
|
||||||
|
else:
|
||||||
|
self._shared_depth_span = (float(span[0]), float(span[1]))
|
||||||
|
self._render_scale_bounds()
|
||||||
|
self._render_legend()
|
||||||
|
|
||||||
|
def _format_bound(self, value: float) -> str:
|
||||||
|
return f"{float(value):.3f}"
|
||||||
|
|
||||||
def _render_scale_bounds(self) -> None:
|
def _render_scale_bounds(self) -> None:
|
||||||
"""Show only the numeric bounds needed to read the color ramp."""
|
"""Show only the numeric bounds needed to read the color ramp."""
|
||||||
descriptor = self._descriptor
|
descriptor = self._descriptor
|
||||||
@@ -513,16 +553,20 @@ class ThreeDPanel(QFrame):
|
|||||||
upper = ""
|
upper = ""
|
||||||
mode = str(self.scale_combo.currentData() or "fixed")
|
mode = str(self.scale_combo.currentData() or "fixed")
|
||||||
effective = self._effective_scale
|
effective = self._effective_scale
|
||||||
if visible and mode == "fixed":
|
quantity = "" if descriptor is None else str(descriptor.quantity)
|
||||||
lower = f"{self.lower_spin.value():.3g}"
|
if visible and quantity == "depth_response" and self._shared_depth_span is not None:
|
||||||
upper = f"{self.upper_spin.value():.3g}"
|
lower = self._format_bound(self._shared_depth_span[0])
|
||||||
|
upper = self._format_bound(self._shared_depth_span[1])
|
||||||
|
elif visible and mode == "fixed":
|
||||||
|
lower = self._format_bound(self.lower_spin.value())
|
||||||
|
upper = self._format_bound(self.upper_spin.value())
|
||||||
elif (
|
elif (
|
||||||
visible
|
visible
|
||||||
and effective is not None
|
and effective is not None
|
||||||
and effective.scale_mode == "automatic"
|
and effective.scale_mode == "automatic"
|
||||||
):
|
):
|
||||||
lower = f"{effective.scale_lower:.3g}"
|
lower = self._format_bound(effective.scale_lower)
|
||||||
upper = f"{effective.scale_upper:.3g}"
|
upper = self._format_bound(effective.scale_upper)
|
||||||
self.scale_lower_label.setText(lower)
|
self.scale_lower_label.setText(lower)
|
||||||
self.scale_upper_label.setText(upper)
|
self.scale_upper_label.setText(upper)
|
||||||
self.scale_lower_label.setVisible(visible)
|
self.scale_lower_label.setVisible(visible)
|
||||||
@@ -539,20 +583,15 @@ class ThreeDPanel(QFrame):
|
|||||||
self.state_label.setVisible(self._show_diagnostics and state != "live")
|
self.state_label.setVisible(self._show_diagnostics and state != "live")
|
||||||
|
|
||||||
def set_source_kind(self, kind: str) -> None:
|
def set_source_kind(self, kind: str) -> None:
|
||||||
"""Say where the field comes from: ``live``, ``replay`` or ``recompute``.
|
"""Say where the field comes from: live, replay, recompute or analysis.
|
||||||
|
|
||||||
Three states, and the difference between the last two is the point of the
|
|
||||||
whole replay story:
|
|
||||||
|
|
||||||
* ``live`` — acquired from the device now;
|
* ``live`` — acquired from the device now;
|
||||||
* ``replay`` — a recording is open and **no** 3D field is stored in it, so the
|
* ``replay`` — a recording is open and **no** 3D field is stored in it;
|
||||||
pane is showing the surface model only;
|
* ``recompute`` — a recompute pass is running over the recording's video;
|
||||||
* ``recompute`` — a recompute pass is running over the recording's video, so
|
* ``analysis`` — a finished stored result is being read frame by frame.
|
||||||
what is on screen is what *this* algorithm version says about that footage.
|
|
||||||
It is not the recorded measurement, and the label says so.
|
|
||||||
"""
|
"""
|
||||||
kind = str(kind)
|
kind = str(kind)
|
||||||
if kind not in ("live", "replay", "recompute"):
|
if kind not in ("live", "replay", "recompute", "analysis"):
|
||||||
kind = "live"
|
kind = "live"
|
||||||
self._source_kind = kind
|
self._source_kind = kind
|
||||||
self.source_label.setText(t(f"scene3d.source_{kind}"))
|
self.source_label.setText(t(f"scene3d.source_{kind}"))
|
||||||
@@ -661,6 +700,7 @@ class ThreeDPanel(QFrame):
|
|||||||
self.gain_spin,
|
self.gain_spin,
|
||||||
self.flow_arrows_check,
|
self.flow_arrows_check,
|
||||||
self.contact_arrow_check,
|
self.contact_arrow_check,
|
||||||
|
self.flow_threshold_spin,
|
||||||
self.scale_combo,
|
self.scale_combo,
|
||||||
self.fps_combo,
|
self.fps_combo,
|
||||||
self.reset_button,
|
self.reset_button,
|
||||||
@@ -694,6 +734,9 @@ class ThreeDPanel(QFrame):
|
|||||||
self.field_combo.setCurrentIndex(index)
|
self.field_combo.setCurrentIndex(index)
|
||||||
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
|
self.flow_arrows_check.setChecked(bool(settings.flow_arrows_enabled))
|
||||||
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
|
self.contact_arrow_check.setChecked(bool(settings.contact_arrow_enabled))
|
||||||
|
self.flow_threshold_spin.setValue(float(settings.flow_on_px))
|
||||||
|
self._flow_grid_spacing = max(1, int(settings.flow_grid_spacing))
|
||||||
|
self._max_arrows = max(1, int(settings.max_arrows))
|
||||||
self.depth_adaptive_check.setChecked(bool(settings.depth_scale_adaptive))
|
self.depth_adaptive_check.setChecked(bool(settings.depth_scale_adaptive))
|
||||||
scale = settings.color_scale
|
scale = settings.color_scale
|
||||||
if scale is not None:
|
if scale is not None:
|
||||||
@@ -725,6 +768,9 @@ class ThreeDPanel(QFrame):
|
|||||||
deform = bool(self.deform_check.isChecked()) and bool(
|
deform = bool(self.deform_check.isChecked()) and bool(
|
||||||
self._capabilities and self._capabilities.displacement_available
|
self._capabilities and self._capabilities.displacement_available
|
||||||
)
|
)
|
||||||
|
flow_on = max(0.0, float(self.flow_threshold_spin.value()))
|
||||||
|
# Derived off band: kept for prefs / VisualPolicy, not applied as hysteresis.
|
||||||
|
flow_off = max(0.0, flow_on - 0.1)
|
||||||
return DisplaySettings(
|
return DisplaySettings(
|
||||||
display_gain=float(self.gain_spin.value()),
|
display_gain=float(self.gain_spin.value()),
|
||||||
deform_enabled=deform,
|
deform_enabled=deform,
|
||||||
@@ -740,6 +786,10 @@ class ThreeDPanel(QFrame):
|
|||||||
field=self.field(),
|
field=self.field(),
|
||||||
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
|
flow_arrows_enabled=bool(self.flow_arrows_check.isChecked()),
|
||||||
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
|
contact_arrow_enabled=bool(self.contact_arrow_check.isChecked()),
|
||||||
|
flow_on_px=flow_on,
|
||||||
|
flow_off_px=flow_off,
|
||||||
|
flow_grid_spacing=int(self._flow_grid_spacing),
|
||||||
|
max_arrows=int(self._max_arrows),
|
||||||
depth_scale_adaptive=bool(self.depth_adaptive_check.isChecked()),
|
depth_scale_adaptive=bool(self.depth_adaptive_check.isChecked()),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -810,6 +860,9 @@ class ThreeDPanel(QFrame):
|
|||||||
self._render_field_note()
|
self._render_field_note()
|
||||||
self.flow_arrows_check.setText(t("scene3d.flow_arrows"))
|
self.flow_arrows_check.setText(t("scene3d.flow_arrows"))
|
||||||
self.contact_arrow_check.setText(t("scene3d.contact_arrow"))
|
self.contact_arrow_check.setText(t("scene3d.contact_arrow"))
|
||||||
|
self.flow_threshold_label.setText(t("scene3d.flow_threshold"))
|
||||||
|
self.flow_threshold_label.setToolTip(t("scene3d.flow_threshold_tip"))
|
||||||
|
self.flow_threshold_spin.setToolTip(t("scene3d.flow_threshold_tip"))
|
||||||
self.scale_label.setText(t("scene3d.scale"))
|
self.scale_label.setText(t("scene3d.scale"))
|
||||||
self.range_label.setText(t("scene3d.scale_range"))
|
self.range_label.setText(t("scene3d.scale_range"))
|
||||||
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
|
self.range_label.setToolTip(t("scene3d.scale_range_tip"))
|
||||||
|
|||||||
@@ -317,6 +317,8 @@ class Scene3DView(QWidget):
|
|||||||
"""Colour depth with the 2D canvas's latched ``(lo, hi)``."""
|
"""Colour depth with the 2D canvas's latched ``(lo, hi)``."""
|
||||||
if self._widget is not None:
|
if self._widget is not None:
|
||||||
self._widget.set_shared_depth_scale(span)
|
self._widget.set_shared_depth_scale(span)
|
||||||
|
if self._panel is not None:
|
||||||
|
self._panel.set_shared_depth_scale(span)
|
||||||
|
|
||||||
def set_theme(self, theme: str) -> None:
|
def set_theme(self, theme: str) -> None:
|
||||||
"""Adopt the application theme's colour ramp.
|
"""Adopt the application theme's colour ramp.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from orisys_app_shared.acquisition.image_backend import (
|
|||||||
ARROW_PURE,
|
ARROW_PURE,
|
||||||
ARROW_STITCHED,
|
ARROW_STITCHED,
|
||||||
DEPTH,
|
DEPTH,
|
||||||
|
DEPTH_ARROWS,
|
||||||
ImagePlan,
|
ImagePlan,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
@@ -32,7 +33,7 @@ from PySide6.QtWidgets import (
|
|||||||
from orisys_studio.ui import icons
|
from orisys_studio.ui import icons
|
||||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||||
|
|
||||||
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
VIEW_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS)
|
||||||
#: While replaying a recording there is no optical flow to draw and no depth is
|
#: While replaying a recording there is no optical flow to draw and no depth is
|
||||||
#: stored: the panel shows the recorded frames, and nothing else.
|
#: stored: the panel shows the recorded frames, and nothing else.
|
||||||
REPLAY_VIEW_MODES = ("recorded",)
|
REPLAY_VIEW_MODES = ("recorded",)
|
||||||
@@ -53,6 +54,7 @@ _VIEW_LABELS = {
|
|||||||
ARROW_PURE: "panels.view_arrows_pure",
|
ARROW_PURE: "panels.view_arrows_pure",
|
||||||
ARROW_STITCHED: "panels.view_arrows_stitched",
|
ARROW_STITCHED: "panels.view_arrows_stitched",
|
||||||
DEPTH: "panels.view_depth",
|
DEPTH: "panels.view_depth",
|
||||||
|
DEPTH_ARROWS: "panels.view_depth_arrows",
|
||||||
"recorded": "panels.view_recorded",
|
"recorded": "panels.view_recorded",
|
||||||
}
|
}
|
||||||
_ROTATION_STEPS = (0, 90, 180, 270)
|
_ROTATION_STEPS = (0, 90, 180, 270)
|
||||||
@@ -433,15 +435,17 @@ class ArrowPanel(QFrame):
|
|||||||
stays regardless is what the picture *is*: the plan's own line ("this recording
|
stays regardless is what the picture *is*: the plan's own line ("this recording
|
||||||
stores only the raw camera image"), the depth scale, and the colour bar.
|
stores only the raw camera image"), the depth scale, and the colour bar.
|
||||||
"""
|
"""
|
||||||
depth = self.view_mode() == DEPTH
|
mode = self.view_mode()
|
||||||
|
depth = mode == DEPTH
|
||||||
|
overlay = mode == DEPTH_ARROWS
|
||||||
modes = self.view_modes()
|
modes = self.view_modes()
|
||||||
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes
|
has_arrows = ARROW_PURE in modes or ARROW_STITCHED in modes or overlay
|
||||||
explain = bool(self._show_diagnostics)
|
explain = bool(self._show_diagnostics)
|
||||||
self.scale_label.setVisible(explain and not depth and has_arrows)
|
self.scale_label.setVisible(explain and not depth and has_arrows)
|
||||||
self.pipeline_label.setVisible(explain and not depth)
|
self.pipeline_label.setVisible(explain and not depth)
|
||||||
self.area_label.setVisible(explain and not depth and has_arrows)
|
self.area_label.setVisible(explain and not depth and has_arrows)
|
||||||
self.colorbar.setVisible(depth)
|
self.colorbar.setVisible(depth or overlay)
|
||||||
self.depth_scale_label.setVisible(depth and bool(self._depth_scale_text))
|
self.depth_scale_label.setVisible((depth or overlay) and bool(self._depth_scale_text))
|
||||||
self.note_label.setVisible(bool(self._plan_note))
|
self.note_label.setVisible(bool(self._plan_note))
|
||||||
|
|
||||||
def set_diagnostics_visible(self, visible: bool) -> None:
|
def set_diagnostics_visible(self, visible: bool) -> None:
|
||||||
|
|||||||
@@ -49,7 +49,8 @@ class FingerOrientationDialog(QDialog):
|
|||||||
self.orientation_note.setWordWrap(True)
|
self.orientation_note.setWordWrap(True)
|
||||||
|
|
||||||
form.addRow(self.orientation_label, self.orientation_combo)
|
form.addRow(self.orientation_label, self.orientation_combo)
|
||||||
form.addRow(self.orientation_note)
|
# Tip lives on the combo; the note repeated the same sentence.
|
||||||
|
self.orientation_note.setVisible(False)
|
||||||
root.addLayout(form)
|
root.addLayout(form)
|
||||||
|
|
||||||
self.button_box = QDialogButtonBox(
|
self.button_box = QDialogButtonBox(
|
||||||
|
|||||||
@@ -1057,15 +1057,14 @@ class ForcePlotsPanel(QFrame):
|
|||||||
next to cards reading the live 0.0, which reads as "the replay is not
|
next to cards reading the live 0.0, which reads as "the replay is not
|
||||||
open". Availability is decided per sample here as well, so a cursor on a
|
open". Availability is decided per sample here as well, so a cursor on a
|
||||||
row with no value in this unit shows a dash, never a stale number.
|
row with no value in this unit shows a dash, never a stale number.
|
||||||
|
|
||||||
|
A zoomed window is still the cursor: the strip may name how long that
|
||||||
|
window is, but the big numbers stay on the sample under the playhead.
|
||||||
"""
|
"""
|
||||||
if self._mode != "browse" and not self.is_recording_source():
|
if self._mode != "browse" and not self.is_recording_source():
|
||||||
return
|
return
|
||||||
if not self._source_ready:
|
if not self._source_ready:
|
||||||
return
|
return
|
||||||
if self._selection_active():
|
|
||||||
# The cards are stating the selection's peak / mean / σ; the cursor is
|
|
||||||
# inside that window and must not overwrite them.
|
|
||||||
return
|
|
||||||
first, last = self._source.extent()
|
first, last = self._source.extent()
|
||||||
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
|
t0 = max(first, self._cursor_t - CURSOR_READOUT_S)
|
||||||
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
|
t1 = min(last, self._cursor_t + CURSOR_READOUT_S)
|
||||||
@@ -1095,12 +1094,11 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._render_value_cards()
|
self._render_value_cards()
|
||||||
|
|
||||||
def _update_selection_readout(self) -> None:
|
def _update_selection_readout(self) -> None:
|
||||||
"""Refresh what the selection is worth: the cards, and the strip's size.
|
"""Refresh the strip's window size; the cards stay on the cursor.
|
||||||
|
|
||||||
The per-channel numbers live on the cards (peak, then mean · σ); the strip
|
The strip under the charts keeps how long the selection is and how many
|
||||||
under the charts keeps only how long the selection is and how many samples
|
samples it holds. Peak / mean / σ used to sit on the value cards when the
|
||||||
it holds, because that is all that fits there. The full line stays in the
|
window was inset; those cards now always follow the playhead.
|
||||||
strip's tooltip.
|
|
||||||
"""
|
"""
|
||||||
if not self._source_ready:
|
if not self._source_ready:
|
||||||
self._selection = None
|
self._selection = None
|
||||||
@@ -1109,12 +1107,10 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._render_value_cards()
|
self._render_value_cards()
|
||||||
return
|
return
|
||||||
if not self._selection_active():
|
if not self._selection_active():
|
||||||
# Following live, or fitted to the whole session: the cards are the
|
|
||||||
# readout of the cursor, not of a window.
|
|
||||||
self._selection = None
|
self._selection = None
|
||||||
self.stats_label.setText("")
|
self.stats_label.setText("")
|
||||||
self.stats_label.setToolTip("")
|
self.stats_label.setToolTip("")
|
||||||
self._render_value_cards()
|
self._readout_at_cursor()
|
||||||
return
|
return
|
||||||
t0, t1 = self._window
|
t0, t1 = self._window
|
||||||
stats = self._source.statistics(t0, t1, unit=self._unit)
|
stats = self._source.statistics(t0, t1, unit=self._unit)
|
||||||
@@ -1122,37 +1118,17 @@ class ForcePlotsPanel(QFrame):
|
|||||||
self._selection = None
|
self._selection = None
|
||||||
self.stats_label.setText(t("chart.selection_empty"))
|
self.stats_label.setText(t("chart.selection_empty"))
|
||||||
self.stats_label.setToolTip("")
|
self.stats_label.setToolTip("")
|
||||||
self._render_value_cards()
|
self._readout_at_cursor()
|
||||||
return
|
return
|
||||||
self._selection = stats
|
self._selection = stats
|
||||||
self.stats_label.setText(
|
summary = t(
|
||||||
t(
|
"chart.selection_summary",
|
||||||
"chart.selection_summary",
|
|
||||||
duration=f"{stats.duration_s:.2f}",
|
|
||||||
samples=stats.samples,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
self.stats_label.setToolTip(self._selection_tooltip(stats))
|
|
||||||
self._render_value_cards()
|
|
||||||
|
|
||||||
def _selection_tooltip(self, stats) -> str:
|
|
||||||
"""The full selection readout, for the strip's tooltip."""
|
|
||||||
unit = unit_display(self._unit)
|
|
||||||
parts = []
|
|
||||||
for index, label in enumerate(CHANNEL_LABELS):
|
|
||||||
peak = float(stats.peaks[index])
|
|
||||||
if not np.isfinite(peak):
|
|
||||||
continue
|
|
||||||
parts.append(
|
|
||||||
f"{label} 峰 {peak:.3g}{unit} · 均 {float(stats.means[index]):.3g} "
|
|
||||||
f"· σ {float(stats.stds[index]):.3g}"
|
|
||||||
)
|
|
||||||
return t(
|
|
||||||
"chart.selection",
|
|
||||||
duration=f"{stats.duration_s:.2f}",
|
duration=f"{stats.duration_s:.2f}",
|
||||||
samples=stats.samples,
|
samples=stats.samples,
|
||||||
detail=" ".join(parts),
|
|
||||||
)
|
)
|
||||||
|
self.stats_label.setText(summary)
|
||||||
|
self.stats_label.setToolTip(summary)
|
||||||
|
self._readout_at_cursor()
|
||||||
|
|
||||||
def _selection_active(self) -> bool:
|
def _selection_active(self) -> bool:
|
||||||
"""Whether the visible window is a *selection* and not the whole session.
|
"""Whether the visible window is a *selection* and not the whole session.
|
||||||
@@ -1421,20 +1397,8 @@ class ForcePlotsPanel(QFrame):
|
|||||||
curve.setData([], [])
|
curve.setData([], [])
|
||||||
|
|
||||||
def _render_value_cards(self) -> None:
|
def _render_value_cards(self) -> None:
|
||||||
"""One card per channel: what it reads *now*, in the active unit.
|
"""One card per channel: the sample under the cursor, in the active unit."""
|
||||||
|
|
||||||
While a selection is being inspected the big number is that window's peak
|
|
||||||
and the line under it its mean and σ — the selection statistics used to be
|
|
||||||
a single long line under the charts, which could not fit and pushed the
|
|
||||||
panel wider.
|
|
||||||
"""
|
|
||||||
decimals = (
|
|
||||||
self.presentation.newton_decimals
|
|
||||||
if self._unit == "N" else self.presentation.raw_decimals
|
|
||||||
)
|
|
||||||
fmt = "{value:." + str(decimals) + "f}"
|
|
||||||
unavailable = t("chart.value_unavailable")
|
unavailable = t("chart.value_unavailable")
|
||||||
selecting = self._selection
|
|
||||||
for index, (value_label, unit_label, detail_label, value) in enumerate(
|
for index, (value_label, unit_label, detail_label, value) in enumerate(
|
||||||
zip(
|
zip(
|
||||||
self.value_labels,
|
self.value_labels,
|
||||||
@@ -1444,11 +1408,7 @@ class ForcePlotsPanel(QFrame):
|
|||||||
)
|
)
|
||||||
):
|
):
|
||||||
available = self._available[index]
|
available = self._available[index]
|
||||||
if selecting is not None:
|
if self.presentation.enabled:
|
||||||
peak = float(selecting.peaks[index])
|
|
||||||
available = bool(np.isfinite(peak))
|
|
||||||
value = peak
|
|
||||||
elif self.presentation.enabled:
|
|
||||||
value = self._display_values[index]
|
value = self._display_values[index]
|
||||||
if not available:
|
if not available:
|
||||||
# An axis with no curve in this unit: a dash, never a raw count
|
# An axis with no curve in this unit: a dash, never a raw count
|
||||||
@@ -1462,44 +1422,18 @@ class ForcePlotsPanel(QFrame):
|
|||||||
continue
|
continue
|
||||||
value_label.setText(
|
value_label.setText(
|
||||||
format_force(value, self._unit, presentation=self.presentation)
|
format_force(value, self._unit, presentation=self.presentation)
|
||||||
if selecting is None
|
|
||||||
else fmt.format(value=value)
|
|
||||||
)
|
)
|
||||||
tip = t("chart.selection_peak_tip") if selecting is not None else ""
|
|
||||||
zero_zone = (
|
zero_zone = (
|
||||||
selecting is None
|
self.presentation.enabled
|
||||||
and self.presentation.enabled
|
|
||||||
and abs(float(value)) == 0.0
|
and abs(float(value)) == 0.0
|
||||||
and index < len(self._display_suppressed)
|
and index < len(self._display_suppressed)
|
||||||
and self._display_suppressed[index]
|
and self._display_suppressed[index]
|
||||||
)
|
)
|
||||||
if zero_zone:
|
value_label.setToolTip(t("chart.zero_zone_tip") if zero_zone else "")
|
||||||
tip = t("chart.zero_zone_tip")
|
|
||||||
value_label.setToolTip(tip)
|
|
||||||
unit_label.setText(unit_display(self._unit))
|
unit_label.setText(unit_display(self._unit))
|
||||||
if selecting is not None:
|
detail_label.setText(t("chart.zero_zone") if zero_zone else "")
|
||||||
detail_label.setText(self._selection_detail(index))
|
|
||||||
elif zero_zone:
|
|
||||||
detail_label.setText(t("chart.zero_zone"))
|
|
||||||
else:
|
|
||||||
detail_label.setText("")
|
|
||||||
self.unit_badge.setText(unit_display(self._unit))
|
self.unit_badge.setText(unit_display(self._unit))
|
||||||
|
|
||||||
def _selection_detail(self, index: int) -> str:
|
|
||||||
"""``mean · σ`` for one channel of the current selection."""
|
|
||||||
stats = self._selection
|
|
||||||
if stats is None:
|
|
||||||
return ""
|
|
||||||
mean = float(stats.means[index])
|
|
||||||
std = float(stats.stds[index])
|
|
||||||
if not np.isfinite(mean):
|
|
||||||
return ""
|
|
||||||
return t(
|
|
||||||
"chart.selection_detail",
|
|
||||||
mean=f"{mean:.4g}",
|
|
||||||
std=f"{std:.4g}" if np.isfinite(std) else "-",
|
|
||||||
)
|
|
||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
self.title.setText(t("panels.plots"))
|
self.title.setText(t("panels.plots"))
|
||||||
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
for badge, label in zip(self.channel_badges, CHANNEL_LABELS):
|
||||||
|
|||||||
@@ -5,11 +5,12 @@ product and config the window is configured for is worth reading at a glance,
|
|||||||
but the pickers themselves — and the calibration that belongs to them — need
|
but the pickers themselves — and the calibration that belongs to them — need
|
||||||
more room than a 26 px rail can give.
|
more room than a 26 px rail can give.
|
||||||
|
|
||||||
The calibration half is read-only on purpose: it states which calibration the
|
The calibration half lets the operator choose the force curve for the **next**
|
||||||
*device* confirmed, which axes it covers, and what the last exported
|
live run (and hot-apply it while a session is already open): the profile's
|
||||||
coefficients file says it belongs to. Exporting and applying stay single-entry
|
default file when the bundle declares one, raw force, or a browsed file — the
|
||||||
actions (they are the calibration panel's own methods), so there is exactly one
|
same three answers the bare-video analysis prompt asks for. Exporting still
|
||||||
implementation of each.
|
writes a coefficients file without changing a running device until Apply is
|
||||||
|
used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -26,10 +27,14 @@ from orisys_app_shared.sensors import (
|
|||||||
)
|
)
|
||||||
from PySide6.QtCore import Qt
|
from PySide6.QtCore import Qt
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
|
QComboBox,
|
||||||
QDialog,
|
QDialog,
|
||||||
|
QFileDialog,
|
||||||
|
QFormLayout,
|
||||||
QFrame,
|
QFrame,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QScrollArea,
|
QScrollArea,
|
||||||
QSizePolicy,
|
QSizePolicy,
|
||||||
@@ -38,6 +43,7 @@ from PySide6.QtWidgets import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
from orisys_studio.ui.fit_text import ShrinkableLabel
|
from orisys_studio.ui.fit_text import ShrinkableLabel
|
||||||
|
from orisys_studio.ui.video_analysis_dialog import calibration_identity
|
||||||
|
|
||||||
#: How the catalog's shape identifiers read here. One literal key per kind, so the locale scan
|
#: How the catalog's shape identifiers read here. One literal key per kind, so the locale scan
|
||||||
#: sees them and a new kind is a visible fallback rather than a printed identifier.
|
#: sees them and a new kind is a visible fallback rather than a printed identifier.
|
||||||
@@ -57,15 +63,22 @@ _STATE_KEYS: dict[str, str] = {
|
|||||||
STATE_UNREADABLE: "sensor.catalog_state_invalid",
|
STATE_UNREADABLE: "sensor.catalog_state_invalid",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ROLE_DEFAULT = "default"
|
||||||
|
ROLE_NONE = "none"
|
||||||
|
ROLE_FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
class SensorDialog(QDialog):
|
class SensorDialog(QDialog):
|
||||||
"""Sensor type / config picker plus the calibration facts that follow it."""
|
"""Sensor type / config picker plus the live calibration choice that follows it."""
|
||||||
|
|
||||||
def __init__(self, *, window, picker, theme: str = "dark") -> None:
|
def __init__(self, *, window, picker, theme: str = "dark") -> None:
|
||||||
super().__init__(window)
|
super().__init__(window)
|
||||||
self._window = window
|
self._window = window
|
||||||
self._theme = str(theme)
|
self._theme = str(theme)
|
||||||
self.picker = picker
|
self.picker = picker
|
||||||
|
self._custom_calibration: Path | None = None
|
||||||
|
self._device_source: Path | None = None
|
||||||
|
self._syncing = False
|
||||||
self.setObjectName("sensorDialog")
|
self.setObjectName("sensorDialog")
|
||||||
self.setModal(False)
|
self.setModal(False)
|
||||||
|
|
||||||
@@ -116,6 +129,37 @@ class SensorDialog(QDialog):
|
|||||||
)
|
)
|
||||||
root.addWidget(self.profile_scroll)
|
root.addWidget(self.profile_scroll)
|
||||||
|
|
||||||
|
root.addWidget(self._section_rule())
|
||||||
|
self.choice_title = QLabel()
|
||||||
|
self.choice_title.setObjectName("panelTitle")
|
||||||
|
root.addWidget(self.choice_title)
|
||||||
|
|
||||||
|
choice_form = QFormLayout()
|
||||||
|
choice_form.setLabelAlignment(Qt.AlignmentFlag.AlignRight)
|
||||||
|
self.calibration_label = QLabel()
|
||||||
|
self.calibration_label.setObjectName("fieldLabel")
|
||||||
|
self.calibration_combo = QComboBox()
|
||||||
|
self.calibration_combo.setObjectName("liveCalibrationCombo")
|
||||||
|
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)
|
||||||
|
choice_form.addRow(self.calibration_label, calibration_row)
|
||||||
|
|
||||||
|
self.device_label = QLabel()
|
||||||
|
self.device_label.setObjectName("fieldLabel")
|
||||||
|
self.device_edit = QLineEdit()
|
||||||
|
self.device_edit.setObjectName("liveDeviceEdit")
|
||||||
|
choice_form.addRow(self.device_label, self.device_edit)
|
||||||
|
self._choice_form = choice_form
|
||||||
|
root.addLayout(choice_form)
|
||||||
|
self.choice_note = ShrinkableLabel()
|
||||||
|
self.choice_note.setObjectName("hintText")
|
||||||
|
self.choice_note.setWordWrap(True)
|
||||||
|
root.addWidget(self.choice_note)
|
||||||
|
|
||||||
root.addWidget(self._section_rule())
|
root.addWidget(self._section_rule())
|
||||||
self.calib_title = QLabel()
|
self.calib_title = QLabel()
|
||||||
self.calib_title.setObjectName("panelTitle")
|
self.calib_title.setObjectName("panelTitle")
|
||||||
@@ -156,19 +200,21 @@ class SensorDialog(QDialog):
|
|||||||
self.btn_export.clicked.connect(self._on_export)
|
self.btn_export.clicked.connect(self._on_export)
|
||||||
self.btn_apply = QPushButton()
|
self.btn_apply = QPushButton()
|
||||||
self.btn_apply.clicked.connect(self._on_apply)
|
self.btn_apply.clicked.connect(self._on_apply)
|
||||||
self.btn_close = QPushButton()
|
self.btn_save = QPushButton()
|
||||||
self.btn_close.setProperty("compact", "true")
|
self.btn_save.setProperty("compact", "true")
|
||||||
self.btn_close.clicked.connect(self.close)
|
self.btn_save.clicked.connect(self._on_save)
|
||||||
actions.addWidget(self.btn_export)
|
actions.addWidget(self.btn_export)
|
||||||
actions.addWidget(self.btn_apply)
|
actions.addWidget(self.btn_apply)
|
||||||
actions.addStretch(1)
|
actions.addStretch(1)
|
||||||
actions.addWidget(self.btn_close)
|
actions.addWidget(self.btn_save)
|
||||||
root.addLayout(actions)
|
root.addLayout(actions)
|
||||||
|
|
||||||
# The picker's own changes change the calibration context, so the dialog
|
# The picker's own changes change the calibration context, so the dialog
|
||||||
# follows them while it is open.
|
# follows them while it is open.
|
||||||
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
|
self.picker.sensor_type_changed.connect(lambda _value: self.refresh())
|
||||||
self.picker.config_changed.connect(lambda _value: self.refresh())
|
self.picker.config_changed.connect(lambda _value: self.refresh())
|
||||||
|
self.calibration_combo.currentIndexChanged.connect(self._on_choice_changed)
|
||||||
|
self.device_edit.textChanged.connect(self._on_device_edited)
|
||||||
|
|
||||||
self.retranslate()
|
self.retranslate()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
@@ -183,17 +229,46 @@ class SensorDialog(QDialog):
|
|||||||
|
|
||||||
# ------------------------------------------------------------------ actions
|
# ------------------------------------------------------------------ actions
|
||||||
def _on_export(self) -> None:
|
def _on_export(self) -> None:
|
||||||
self._window.cal_panel.export_coefficients()
|
panel = getattr(self._window, "cal_panel", None)
|
||||||
|
if panel is not None and hasattr(panel, "export_coefficients"):
|
||||||
|
panel.export_coefficients()
|
||||||
|
export = str(getattr(self._window, "_last_calibration_export", "") or "")
|
||||||
|
if export:
|
||||||
|
self.select_calibration_file(Path(export))
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
def _on_apply(self) -> None:
|
def _on_apply(self) -> None:
|
||||||
self._window.cal_panel.apply_coefficients()
|
self._persist_choice()
|
||||||
|
apply = getattr(self._window, "apply_live_calibration_choice", None)
|
||||||
|
if callable(apply):
|
||||||
|
apply()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
|
def _on_save(self) -> None:
|
||||||
|
"""Keep the current sensor/calibration choice and dismiss the dialog."""
|
||||||
|
self._persist_choice()
|
||||||
|
self.close()
|
||||||
|
|
||||||
|
def _on_browse(self) -> None:
|
||||||
|
chosen, _filter = QFileDialog.getOpenFileName(
|
||||||
|
self,
|
||||||
|
t("video_analysis.calibration_browse"),
|
||||||
|
str(Path.cwd()),
|
||||||
|
t("video_analysis.calibration_filter"),
|
||||||
|
)
|
||||||
|
if chosen:
|
||||||
|
self.select_calibration_file(Path(chosen))
|
||||||
|
|
||||||
|
def select_calibration_file(self, path: Path) -> None:
|
||||||
|
"""Put a browsed (or just-exported) file in the combo and persist it."""
|
||||||
|
self._refresh_calibrations(custom=Path(path))
|
||||||
|
self._persist_choice()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ content
|
# ------------------------------------------------------------------ content
|
||||||
def refresh(self) -> None:
|
def refresh(self) -> None:
|
||||||
"""Re-read the shell's calibration state. Called on open and on change."""
|
"""Re-read the shell's calibration state. Called on open and on change."""
|
||||||
window = self._window
|
window = self._window
|
||||||
|
self._load_choice_into_controls()
|
||||||
version = str(getattr(window, "_active_calibration_id", "") or "")
|
version = str(getattr(window, "_active_calibration_id", "") or "")
|
||||||
self.version_label.setText(
|
self.version_label.setText(
|
||||||
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
|
f"{t('sensor.calib_version')}: {version or t('sensor.calib_none')}"
|
||||||
@@ -213,40 +288,209 @@ class SensorDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.btn_reveal.setEnabled(bool(config_ref))
|
self.btn_reveal.setEnabled(bool(config_ref))
|
||||||
self._refresh_profile()
|
self._refresh_profile()
|
||||||
|
self._sync_device_row()
|
||||||
|
self._refresh_choice_note()
|
||||||
|
|
||||||
export = str(getattr(window, "_last_calibration_export", "") or "")
|
export = str(getattr(window, "_last_calibration_export", "") or "")
|
||||||
if not export:
|
if not export:
|
||||||
self.export_label.setText(t("sensor.export_none"))
|
self.export_label.setText(t("sensor.export_none"))
|
||||||
self.identity_label.setText("")
|
self.identity_label.setText("")
|
||||||
self.btn_apply.setEnabled(False)
|
else:
|
||||||
return
|
path = Path(export)
|
||||||
path = Path(export)
|
self.export_label.setText(
|
||||||
self.export_label.setText(
|
t(
|
||||||
t(
|
"sensor.export_file",
|
||||||
"sensor.export_file",
|
name=path.name,
|
||||||
name=path.name,
|
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
|
||||||
size=f"{path.stat().st_size / 1024:.1f}" if path.is_file() else "?",
|
time=(
|
||||||
time=(
|
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
||||||
datetime.fromtimestamp(path.stat().st_mtime).strftime("%Y-%m-%d %H:%M")
|
if path.is_file()
|
||||||
if path.is_file()
|
else "?"
|
||||||
else "?"
|
),
|
||||||
),
|
)
|
||||||
)
|
)
|
||||||
)
|
identity = {}
|
||||||
identity = {}
|
read_identity = getattr(window, "calibration_identity", None)
|
||||||
read_identity = getattr(window, "calibration_identity", None)
|
if callable(read_identity):
|
||||||
if callable(read_identity):
|
identity = dict(read_identity(export) or {})
|
||||||
identity = dict(read_identity(export) or {})
|
self.identity_label.setText(
|
||||||
self.identity_label.setText(
|
t(
|
||||||
t(
|
"sensor.export_identity",
|
||||||
"sensor.export_identity",
|
sensor_type=identity.get("sensor_type") or "?",
|
||||||
sensor_type=identity.get("sensor_type") or "?",
|
sn=identity.get("sn") or "?",
|
||||||
sn=identity.get("sn") or "?",
|
config=Path(str(identity.get("config_ref") or "?")).name,
|
||||||
config=Path(str(identity.get("config_ref") or "?")).name,
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
self.btn_apply.setEnabled(True)
|
self.btn_apply.setEnabled(True)
|
||||||
|
|
||||||
|
def _entry(self):
|
||||||
|
read_entry = getattr(self.picker, "selected_entry", None)
|
||||||
|
return read_entry() if callable(read_entry) else None
|
||||||
|
|
||||||
|
def _default_calibration_path(self) -> Path | None:
|
||||||
|
entry = self._entry()
|
||||||
|
if entry is None or entry.profile is None:
|
||||||
|
return None
|
||||||
|
declared = getattr(entry.profile, "default_calibration", None)
|
||||||
|
if declared is not None and Path(declared).is_file():
|
||||||
|
return Path(declared)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _load_choice_into_controls(self) -> None:
|
||||||
|
read = getattr(self._window, "live_calibration_choice", None)
|
||||||
|
role, path, device_id = ROLE_NONE, None, ""
|
||||||
|
if callable(read):
|
||||||
|
role, path, device_id = read()
|
||||||
|
custom = Path(path) if role == ROLE_FILE and path else None
|
||||||
|
self._refresh_calibrations(custom=custom)
|
||||||
|
self._syncing = True
|
||||||
|
try:
|
||||||
|
if role == ROLE_FILE and custom is not None:
|
||||||
|
# already selected by _refresh_calibrations
|
||||||
|
pass
|
||||||
|
elif role == ROLE_DEFAULT and self._default_calibration_path() is not None:
|
||||||
|
index = self.calibration_combo.findData(ROLE_DEFAULT)
|
||||||
|
if index >= 0:
|
||||||
|
self.calibration_combo.setCurrentIndex(index)
|
||||||
|
else:
|
||||||
|
index = self.calibration_combo.findData(ROLE_NONE)
|
||||||
|
if index >= 0:
|
||||||
|
self.calibration_combo.setCurrentIndex(index)
|
||||||
|
if device_id:
|
||||||
|
self.device_edit.setText(str(device_id))
|
||||||
|
finally:
|
||||||
|
self._syncing = False
|
||||||
|
|
||||||
|
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
||||||
|
self.calibration_combo.blockSignals(True)
|
||||||
|
self.calibration_combo.clear()
|
||||||
|
default = self._default_calibration_path()
|
||||||
|
if default is not None:
|
||||||
|
self.calibration_combo.addItem(
|
||||||
|
t("video_analysis.calibration_default", name=default.name),
|
||||||
|
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)
|
||||||
|
elif self.calibration_combo.count():
|
||||||
|
# Prefer the profile default when one exists; otherwise raw force.
|
||||||
|
wanted = ROLE_DEFAULT if default is not None else ROLE_NONE
|
||||||
|
index = self.calibration_combo.findData(wanted)
|
||||||
|
self.calibration_combo.setCurrentIndex(max(index, 0))
|
||||||
|
self.calibration_combo.blockSignals(False)
|
||||||
|
self._sync_device_row()
|
||||||
|
|
||||||
|
def current_role(self) -> str:
|
||||||
|
return str(self.calibration_combo.currentData() or ROLE_NONE)
|
||||||
|
|
||||||
|
def current_calibration_path(self) -> Path | None:
|
||||||
|
role = self.current_role()
|
||||||
|
if role == ROLE_FILE:
|
||||||
|
return self._custom_calibration
|
||||||
|
if role == ROLE_NONE:
|
||||||
|
return None
|
||||||
|
return self._default_calibration_path()
|
||||||
|
|
||||||
|
def current_device_id(self) -> str | None:
|
||||||
|
if not self._is_finger() or self.current_role() != ROLE_FILE:
|
||||||
|
return None
|
||||||
|
serial = self.device_edit.text().strip()
|
||||||
|
return serial or None
|
||||||
|
|
||||||
|
def _is_finger(self) -> bool:
|
||||||
|
entry = self._entry()
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
return str(entry.product_family or "").strip().lower() == "finger"
|
||||||
|
|
||||||
|
def _persist_choice(self) -> None:
|
||||||
|
save = getattr(self._window, "set_live_calibration_choice", None)
|
||||||
|
if not callable(save):
|
||||||
|
return
|
||||||
|
save(
|
||||||
|
role=self.current_role(),
|
||||||
|
path=self.current_calibration_path(),
|
||||||
|
device_id=self.current_device_id(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_choice_changed(self) -> None:
|
||||||
|
if self._syncing:
|
||||||
|
return
|
||||||
|
self._sync_device_row()
|
||||||
|
self._refresh_choice_note()
|
||||||
|
self._refresh_profile()
|
||||||
|
self._persist_choice()
|
||||||
|
|
||||||
|
def _on_device_edited(self, _text: str) -> None:
|
||||||
|
if self._syncing:
|
||||||
|
return
|
||||||
|
self._refresh_choice_note()
|
||||||
|
self._persist_choice()
|
||||||
|
|
||||||
|
def _sync_device_row(self) -> None:
|
||||||
|
path = self.current_calibration_path() if self.current_role() == ROLE_FILE else None
|
||||||
|
visible = self._is_finger() and path is not None
|
||||||
|
self.device_label.setVisible(visible)
|
||||||
|
self.device_edit.setVisible(visible)
|
||||||
|
form = getattr(self, "_choice_form", None)
|
||||||
|
if form is not None and hasattr(form, "setRowVisible"):
|
||||||
|
form.setRowVisible(self.device_label, visible)
|
||||||
|
if not visible:
|
||||||
|
self._device_source = None
|
||||||
|
return
|
||||||
|
if path != self._device_source:
|
||||||
|
self._device_source = path
|
||||||
|
_sensor_type, sn = calibration_identity(path)
|
||||||
|
if sn:
|
||||||
|
self._syncing = True
|
||||||
|
try:
|
||||||
|
self.device_edit.setText(sn)
|
||||||
|
finally:
|
||||||
|
self._syncing = False
|
||||||
|
|
||||||
|
def choice_problem(self) -> str:
|
||||||
|
"""Why the current live choice must not be opened or applied; empty when usable."""
|
||||||
|
return self._device_problem()
|
||||||
|
|
||||||
|
def _device_problem(self) -> str:
|
||||||
|
path = self.current_calibration_path() if self.current_role() == ROLE_FILE else None
|
||||||
|
if not self._is_finger() or path is None:
|
||||||
|
return ""
|
||||||
|
serial = self.device_edit.text().strip()
|
||||||
|
if not serial:
|
||||||
|
return t("video_analysis.device_required")
|
||||||
|
sensor_type, sn = calibration_identity(path)
|
||||||
|
if sensor_type and sensor_type != "finger":
|
||||||
|
return t("video_analysis.device_family", family=sensor_type)
|
||||||
|
if not sn:
|
||||||
|
return t("video_analysis.device_missing_sn")
|
||||||
|
if sn != serial:
|
||||||
|
return t("video_analysis.device_mismatch", sn=sn)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _refresh_choice_note(self) -> None:
|
||||||
|
problem = self._device_problem()
|
||||||
|
if problem:
|
||||||
|
self.choice_note.setText(problem)
|
||||||
|
return
|
||||||
|
role = self.current_role()
|
||||||
|
path = self.current_calibration_path()
|
||||||
|
if role == ROLE_NONE or path is None:
|
||||||
|
self.choice_note.setText(t("sensor.live_calibration_raw_tip"))
|
||||||
|
elif role == ROLE_DEFAULT:
|
||||||
|
self.choice_note.setText(
|
||||||
|
t("sensor.live_calibration_default_tip", name=path.name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.choice_note.setText(
|
||||||
|
t("sensor.live_calibration_file_tip", name=path.name)
|
||||||
|
)
|
||||||
|
|
||||||
def _refresh_profile(self) -> None:
|
def _refresh_profile(self) -> None:
|
||||||
"""State the selected catalog entry, and the reason when it cannot be started.
|
"""State the selected catalog entry, and the reason when it cannot be started.
|
||||||
|
|
||||||
@@ -256,12 +500,19 @@ class SensorDialog(QDialog):
|
|||||||
sentence. It is read from the picker rather than re-derived here, so the window and the
|
sentence. It is read from the picker rather than re-derived here, so the window and the
|
||||||
dialog can never say two different things about one bundle.
|
dialog can never say two different things about one bundle.
|
||||||
"""
|
"""
|
||||||
read_entry = getattr(self.picker, "selected_entry", None)
|
entry = self._entry()
|
||||||
entry = read_entry() if callable(read_entry) else None
|
|
||||||
if entry is None:
|
if entry is None:
|
||||||
self.profile_label.setText("")
|
self.profile_label.setText("")
|
||||||
self.profile_label.setToolTip("")
|
self.profile_label.setToolTip("")
|
||||||
return
|
return
|
||||||
|
path = self.current_calibration_path()
|
||||||
|
role = self.current_role()
|
||||||
|
if role == ROLE_NONE or path is None:
|
||||||
|
calibration_line = t("sensor.catalog_calibration_none")
|
||||||
|
else:
|
||||||
|
calibration_line = t(
|
||||||
|
"sensor.catalog_calibration_selected", name=path.name
|
||||||
|
)
|
||||||
lines = [
|
lines = [
|
||||||
t("sensor.catalog_name", name=entry.name, folder=entry.folder),
|
t("sensor.catalog_name", name=entry.name, folder=entry.folder),
|
||||||
t(
|
t(
|
||||||
@@ -276,11 +527,7 @@ class SensorDialog(QDialog):
|
|||||||
state=entry.state,
|
state=entry.state,
|
||||||
note=t(_STATE_KEYS.get(entry.state, "sensor.catalog_state_unknown")),
|
note=t(_STATE_KEYS.get(entry.state, "sensor.catalog_state_unknown")),
|
||||||
),
|
),
|
||||||
(
|
calibration_line,
|
||||||
t("sensor.catalog_calibration_selected", name=entry.selected_calibration)
|
|
||||||
if entry.selected_calibration
|
|
||||||
else t("sensor.catalog_calibration_none")
|
|
||||||
),
|
|
||||||
]
|
]
|
||||||
if entry.duplicates:
|
if entry.duplicates:
|
||||||
lines.append(t("sensor.catalog_duplicate", refs=", ".join(entry.duplicates)))
|
lines.append(t("sensor.catalog_duplicate", refs=", ".join(entry.duplicates)))
|
||||||
@@ -300,14 +547,19 @@ class SensorDialog(QDialog):
|
|||||||
|
|
||||||
def retranslate(self) -> None:
|
def retranslate(self) -> None:
|
||||||
self.setWindowTitle(t("sensor.dialog_title"))
|
self.setWindowTitle(t("sensor.dialog_title"))
|
||||||
|
self.choice_title.setText(t("sensor.live_calibration_section"))
|
||||||
|
self.calibration_label.setText(t("video_analysis.calibration"))
|
||||||
|
self.device_label.setText(t("video_analysis.device"))
|
||||||
|
self.device_edit.setPlaceholderText(t("video_analysis.device_tip"))
|
||||||
|
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
||||||
self.calib_title.setText(t("sensor.calib_section"))
|
self.calib_title.setText(t("sensor.calib_section"))
|
||||||
self.export_title.setText(t("sensor.export_section"))
|
self.export_title.setText(t("sensor.export_section"))
|
||||||
self.btn_reveal.setText(t("menu.reveal_config"))
|
self.btn_reveal.setText(t("menu.reveal_config"))
|
||||||
self.btn_export.setText(t("cal.export"))
|
self.btn_export.setText(t("cal.export"))
|
||||||
self.btn_export.setToolTip(t("cal.export_tip"))
|
self.btn_export.setToolTip(t("cal.export_tip"))
|
||||||
self.btn_apply.setText(t("cal.apply"))
|
self.btn_apply.setText(t("cal.apply"))
|
||||||
self.btn_apply.setToolTip(t("cal.apply_tip"))
|
self.btn_apply.setToolTip(t("sensor.live_calibration_apply_tip"))
|
||||||
self.btn_close.setText(t("dialog.cancel"))
|
self.btn_save.setText(t("dialog.save"))
|
||||||
self.picker.retranslate()
|
self.picker.retranslate()
|
||||||
self.refresh()
|
self.refresh()
|
||||||
|
|
||||||
@@ -315,7 +567,5 @@ class SensorDialog(QDialog):
|
|||||||
self._theme = str(theme)
|
self._theme = str(theme)
|
||||||
|
|
||||||
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
|
def showEvent(self, event) -> None: # noqa: N802 - Qt naming
|
||||||
# Every open re-reads the shell: the device may have confirmed a
|
|
||||||
# calibration since the dialog was last shown.
|
|
||||||
self.refresh()
|
|
||||||
super().showEvent(event)
|
super().showEvent(event)
|
||||||
|
self.refresh()
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ reason it cannot be used is one line, both in the language the catalog already s
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections.abc import Callable
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from orisys_app_shared.analysis.bare_video import (
|
from orisys_app_shared.analysis.bare_video import (
|
||||||
@@ -40,6 +42,7 @@ from PySide6.QtWidgets import (
|
|||||||
QFormLayout,
|
QFormLayout,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QLabel,
|
QLabel,
|
||||||
|
QLineEdit,
|
||||||
QPushButton,
|
QPushButton,
|
||||||
QSpinBox,
|
QSpinBox,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
@@ -54,6 +57,25 @@ ROLE_NONE = "none"
|
|||||||
ROLE_FILE = "file"
|
ROLE_FILE = "file"
|
||||||
|
|
||||||
|
|
||||||
|
def calibration_identity(path: Path) -> tuple[str, str]:
|
||||||
|
"""``(sensor_type, sn)`` from a force-calibration file, or empty strings.
|
||||||
|
|
||||||
|
The SDK reads the same two places (``identity``, then ``meta``) when it checks
|
||||||
|
that a chosen file belongs to the device the operator named.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||||
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
|
return "", ""
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return "", ""
|
||||||
|
meta = data.get("meta") if isinstance(data.get("meta"), dict) else {}
|
||||||
|
identity = data.get("identity") if isinstance(data.get("identity"), dict) else {}
|
||||||
|
sensor_type = str(identity.get("sensor_type") or meta.get("sensor_type") or "")
|
||||||
|
sn = str(identity.get("sn") or meta.get("sn") or "")
|
||||||
|
return sensor_type, sn
|
||||||
|
|
||||||
|
|
||||||
class VideoAnalysisDialog(QDialog):
|
class VideoAnalysisDialog(QDialog):
|
||||||
"""Ask how one bare video should be analysed, and hand back the three answers."""
|
"""Ask how one bare video should be analysed, and hand back the three answers."""
|
||||||
|
|
||||||
@@ -68,8 +90,13 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
initial_calibration: Path | None = None,
|
initial_calibration: Path | None = None,
|
||||||
prefer_no_calibration: bool = False,
|
prefer_no_calibration: bool = False,
|
||||||
initial_reference_frame: int | None = None,
|
initial_reference_frame: int | None = None,
|
||||||
|
initial_device_id: str | None = None,
|
||||||
lock_profile: bool = False,
|
lock_profile: bool = False,
|
||||||
theme: str = "dark",
|
theme: str = "dark",
|
||||||
|
remembered_calibration: Callable[
|
||||||
|
[str], tuple[str, Path | None, str] | None
|
||||||
|
]
|
||||||
|
| None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(window)
|
super().__init__(window)
|
||||||
self._video = Path(video)
|
self._video = Path(video)
|
||||||
@@ -87,6 +114,12 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self._restore_reference = (
|
self._restore_reference = (
|
||||||
1 if initial_reference_frame is None else int(initial_reference_frame)
|
1 if initial_reference_frame is None else int(initial_reference_frame)
|
||||||
)
|
)
|
||||||
|
self._restore_device = str(initial_device_id or "")
|
||||||
|
#: ``(role, path, device_id)`` saved by the sensor dialog for a catalog ref,
|
||||||
|
#: or ``None`` when that bundle has no saved choice. Missing means "keep
|
||||||
|
#: this dialog's own default", which is not the same as an explicit raw-force choice.
|
||||||
|
self._remembered_calibration = remembered_calibration
|
||||||
|
self._device_source: Path | None = None
|
||||||
self._lock_profile = bool(lock_profile)
|
self._lock_profile = bool(lock_profile)
|
||||||
self.setObjectName("videoAnalysisDialog")
|
self.setObjectName("videoAnalysisDialog")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
@@ -145,6 +178,13 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
calibration_row.addWidget(self.btn_browse)
|
calibration_row.addWidget(self.btn_browse)
|
||||||
calibration_form.addRow(self.calibration_label, calibration_row)
|
calibration_form.addRow(self.calibration_label, calibration_row)
|
||||||
|
|
||||||
|
self.device_label = QLabel()
|
||||||
|
self.device_label.setObjectName("fieldLabel")
|
||||||
|
self.device_edit = QLineEdit()
|
||||||
|
self.device_edit.setObjectName("deviceEdit")
|
||||||
|
calibration_form.addRow(self.device_label, self.device_edit)
|
||||||
|
self._calibration_form = calibration_form
|
||||||
|
|
||||||
self.reference_label = QLabel()
|
self.reference_label = QLabel()
|
||||||
self.reference_label.setObjectName("fieldLabel")
|
self.reference_label.setObjectName("fieldLabel")
|
||||||
self.reference_spin = QSpinBox()
|
self.reference_spin = QSpinBox()
|
||||||
@@ -170,7 +210,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.buttons = QDialogButtonBox(
|
self.buttons = QDialogButtonBox(
|
||||||
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||||
)
|
)
|
||||||
self.buttons.accepted.connect(self.accept)
|
self.buttons.accepted.connect(self._on_accept)
|
||||||
self.buttons.rejected.connect(self.reject)
|
self.buttons.rejected.connect(self.reject)
|
||||||
root.addWidget(self.buttons)
|
root.addWidget(self.buttons)
|
||||||
|
|
||||||
@@ -185,6 +225,9 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
elif self._restore_calibration is not None:
|
elif self._restore_calibration is not None:
|
||||||
self.select_calibration(self._restore_calibration)
|
self.select_calibration(self._restore_calibration)
|
||||||
self.reference_spin.setValue(max(1, int(self._restore_reference)))
|
self.reference_spin.setValue(max(1, int(self._restore_reference)))
|
||||||
|
if self._restore_device:
|
||||||
|
self.device_edit.setText(self._restore_device)
|
||||||
|
self._sync_device_row()
|
||||||
|
|
||||||
# ------------------------------------------------------------------ selection
|
# ------------------------------------------------------------------ selection
|
||||||
@property
|
@property
|
||||||
@@ -231,6 +274,10 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
self.note_label.setText(t("video_analysis.blocked", reason=blocked))
|
self.note_label.setText(t("video_analysis.blocked", reason=blocked))
|
||||||
return None
|
return None
|
||||||
reference = int(self.reference_spin.value())
|
reference = int(self.reference_spin.value())
|
||||||
|
problem = self._device_problem()
|
||||||
|
if problem:
|
||||||
|
self.note_label.setText(problem)
|
||||||
|
return None
|
||||||
return BareVideoSetup(
|
return BareVideoSetup(
|
||||||
profile_ref=entry.ref,
|
profile_ref=entry.ref,
|
||||||
calibration=self._chosen_calibration(),
|
calibration=self._chosen_calibration(),
|
||||||
@@ -239,6 +286,7 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
|
# Saying so as ``None`` keeps a reset the pass cannot apply from being recorded
|
||||||
# as one that happened.
|
# as one that happened.
|
||||||
reference_frame=None if reference == 1 else reference,
|
reference_frame=None if reference == 1 else reference,
|
||||||
|
device_id=self._confirmed_device_id(),
|
||||||
)
|
)
|
||||||
|
|
||||||
# ------------------------------------------------------------------ content
|
# ------------------------------------------------------------------ content
|
||||||
@@ -261,6 +309,10 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setEnabled(not blocked)
|
||||||
self._refresh_time_label()
|
self._refresh_time_label()
|
||||||
|
device_id = self._apply_remembered_calibration()
|
||||||
|
self._sync_device_row()
|
||||||
|
if device_id:
|
||||||
|
self.device_edit.setText(device_id)
|
||||||
|
|
||||||
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
def _refresh_calibrations(self, *, custom: Path | None) -> None:
|
||||||
"""The calibration field: the profile's default, raw force, or a chosen file."""
|
"""The calibration field: the profile's default, raw force, or a chosen file."""
|
||||||
@@ -286,6 +338,33 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
self.calibration_combo.setCurrentIndex(self.calibration_combo.count() - 1)
|
||||||
self.calibration_combo.blockSignals(False)
|
self.calibration_combo.blockSignals(False)
|
||||||
|
self._sync_device_row()
|
||||||
|
|
||||||
|
def _apply_remembered_calibration(self) -> str:
|
||||||
|
"""Select the sensor dialog's saved choice for this profile.
|
||||||
|
|
||||||
|
Returns a device serial to put back after the row sync, or ``""`` when
|
||||||
|
there is nothing saved (the combo keeps the profile default or raw force).
|
||||||
|
"""
|
||||||
|
entry = self.entry()
|
||||||
|
memory = self._remembered_calibration
|
||||||
|
if entry is None or not callable(memory):
|
||||||
|
return ""
|
||||||
|
found = memory(entry.ref)
|
||||||
|
if found is None:
|
||||||
|
return ""
|
||||||
|
role, path, device_id = found
|
||||||
|
if role == ROLE_FILE and path is not None and Path(path).is_file():
|
||||||
|
self.select_calibration(Path(path))
|
||||||
|
return str(device_id or "")
|
||||||
|
if role == ROLE_NONE:
|
||||||
|
self.select_calibration(None)
|
||||||
|
return ""
|
||||||
|
if role == ROLE_DEFAULT:
|
||||||
|
index = self.calibration_combo.findData(ROLE_DEFAULT)
|
||||||
|
if index >= 0:
|
||||||
|
self.calibration_combo.setCurrentIndex(index)
|
||||||
|
return ""
|
||||||
|
|
||||||
def _chosen_calibration(self) -> Path | None:
|
def _chosen_calibration(self) -> Path | None:
|
||||||
role = str(self.calibration_combo.currentData() or "")
|
role = str(self.calibration_combo.currentData() or "")
|
||||||
@@ -308,6 +387,64 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
if chosen:
|
if chosen:
|
||||||
self._refresh_calibrations(custom=Path(chosen))
|
self._refresh_calibrations(custom=Path(chosen))
|
||||||
|
|
||||||
|
def _is_finger(self) -> bool:
|
||||||
|
entry = self.entry()
|
||||||
|
if entry is None:
|
||||||
|
return False
|
||||||
|
return str(entry.product_family or "").strip().lower() == "finger"
|
||||||
|
|
||||||
|
def _chosen_file(self) -> Path | None:
|
||||||
|
"""A file the operator browsed. The profile default is not one of these."""
|
||||||
|
if str(self.calibration_combo.currentData() or "") != ROLE_FILE:
|
||||||
|
return None
|
||||||
|
return self._custom_calibration
|
||||||
|
|
||||||
|
def _sync_device_row(self) -> None:
|
||||||
|
"""Show the device serial only when a Finger pass will load a browsed file."""
|
||||||
|
path = self._chosen_file() if self._is_finger() else None
|
||||||
|
visible = path is not None
|
||||||
|
self.device_label.setVisible(visible)
|
||||||
|
self.device_edit.setVisible(visible)
|
||||||
|
form = getattr(self, "_calibration_form", None)
|
||||||
|
if form is not None and hasattr(form, "setRowVisible"):
|
||||||
|
form.setRowVisible(self.device_label, visible)
|
||||||
|
if not visible:
|
||||||
|
self._device_source = None
|
||||||
|
return
|
||||||
|
if path != self._device_source:
|
||||||
|
self._device_source = path
|
||||||
|
_sensor_type, sn = calibration_identity(path)
|
||||||
|
if sn:
|
||||||
|
self.device_edit.setText(sn)
|
||||||
|
|
||||||
|
def _confirmed_device_id(self) -> str | None:
|
||||||
|
if self._chosen_file() is None or not self._is_finger():
|
||||||
|
return None
|
||||||
|
serial = self.device_edit.text().strip()
|
||||||
|
return serial or None
|
||||||
|
|
||||||
|
def _device_problem(self) -> str:
|
||||||
|
"""Why this Finger file cannot be applied, or "" when the serial matches."""
|
||||||
|
path = self._chosen_file()
|
||||||
|
if not self._is_finger() or path is None:
|
||||||
|
return ""
|
||||||
|
serial = self.device_edit.text().strip()
|
||||||
|
if not serial:
|
||||||
|
return t("video_analysis.device_required")
|
||||||
|
sensor_type, sn = calibration_identity(path)
|
||||||
|
if sensor_type and sensor_type != "finger":
|
||||||
|
return t("video_analysis.device_family", family=sensor_type)
|
||||||
|
if not sn:
|
||||||
|
return t("video_analysis.device_missing_sn")
|
||||||
|
if sn != serial:
|
||||||
|
return t("video_analysis.device_mismatch", sn=sn)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _on_accept(self) -> None:
|
||||||
|
if self.choice() is None:
|
||||||
|
return
|
||||||
|
self.accept()
|
||||||
|
|
||||||
def _refresh_time_label(self) -> None:
|
def _refresh_time_label(self) -> None:
|
||||||
fps = float(self._facts.fps) or 0.0
|
fps = float(self._facts.fps) or 0.0
|
||||||
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0
|
seconds = (int(self.reference_spin.value()) - 1) / fps if fps > 0 else 0.0
|
||||||
@@ -329,6 +466,8 @@ class VideoAnalysisDialog(QDialog):
|
|||||||
)
|
)
|
||||||
self.profile_label.setText(t("video_analysis.profile"))
|
self.profile_label.setText(t("video_analysis.profile"))
|
||||||
self.calibration_label.setText(t("video_analysis.calibration"))
|
self.calibration_label.setText(t("video_analysis.calibration"))
|
||||||
|
self.device_label.setText(t("video_analysis.device"))
|
||||||
|
self.device_edit.setPlaceholderText(t("video_analysis.device_tip"))
|
||||||
self.reference_label.setText(t("video_analysis.reference"))
|
self.reference_label.setText(t("video_analysis.reference"))
|
||||||
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
self.btn_browse.setText(t("video_analysis.calibration_browse"))
|
||||||
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
self.buttons.button(QDialogButtonBox.StandardButton.Ok).setText(t("dialog.ok"))
|
||||||
|
|||||||
@@ -736,3 +736,48 @@ def test_analysis_setup_reopens_the_prompt_after_the_first_pass(
|
|||||||
assert block is not None
|
assert block is not None
|
||||||
assert int(block["reference_frame"]) == 10
|
assert int(block["reference_frame"]) == 10
|
||||||
assert window.replay_ctl.available_result() is not None
|
assert window.replay_ctl.available_result() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_calibration_file_must_name_the_same_device(qt_app, tmp_path):
|
||||||
|
"""A browsed Finger file is accepted only when the device id matches its SN."""
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
clip = tmp_path / "clip.mp4"
|
||||||
|
writer = cv2.VideoWriter(str(clip), cv2.VideoWriter_fourcc(*"mp4v"), 10.0, (32, 24))
|
||||||
|
for _ in range(4):
|
||||||
|
writer.write(np.zeros((24, 32, 3), dtype=np.uint8))
|
||||||
|
writer.release()
|
||||||
|
facts = bare_video.probe(bare_video.resolve_video(clip))
|
||||||
|
catalog = scan_sensor_catalog()
|
||||||
|
finger = next(
|
||||||
|
(
|
||||||
|
entry
|
||||||
|
for entry in catalog.entries
|
||||||
|
if str(entry.product_family) == "finger"
|
||||||
|
and bare_video.setup_blocked_reason(entry) is None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
assert finger is not None, "the catalog has no startable Finger profile"
|
||||||
|
path = tmp_path / "DD02-14.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
dialog = VideoAnalysisDialog(video=clip, facts=facts, catalog=catalog)
|
||||||
|
try:
|
||||||
|
assert dialog.select_profile(finger.ref)
|
||||||
|
dialog.select_calibration(path)
|
||||||
|
assert not dialog.device_edit.isHidden()
|
||||||
|
assert dialog.device_edit.text() == "DD02-14"
|
||||||
|
chosen = dialog.choice()
|
||||||
|
assert chosen is not None
|
||||||
|
assert chosen.calibration == path
|
||||||
|
assert chosen.device_id == "DD02-14"
|
||||||
|
dialog.device_edit.setText("OTHER")
|
||||||
|
assert dialog.choice() is None
|
||||||
|
assert dialog.note_label.text() == t(
|
||||||
|
"video_analysis.device_mismatch", sn="DD02-14"
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
|||||||
@@ -0,0 +1,391 @@
|
|||||||
|
"""Live calibration choice in the sensor dialog: pick, remember, open, hot-apply."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from orisys_app_shared.analysis.bare_video import VideoFacts
|
||||||
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_app_shared.sensors import scan_sensor_catalog
|
||||||
|
from orisys_studio.ui import sensor_picker as picker_module
|
||||||
|
from orisys_studio.ui.sensor_dialog import ROLE_DEFAULT, ROLE_FILE, ROLE_NONE, SensorDialog
|
||||||
|
from orisys_studio.ui.sensor_picker import SensorPickerWidget
|
||||||
|
from orisys_studio.ui.video_analysis_dialog import VideoAnalysisDialog
|
||||||
|
from PySide6.QtWidgets import QWidget
|
||||||
|
|
||||||
|
CONFIG = {
|
||||||
|
"name": "fixture",
|
||||||
|
"camera": {
|
||||||
|
"resolution_raw": [640, 480],
|
||||||
|
"fps": 30,
|
||||||
|
"resolution_stitch": [100, 100],
|
||||||
|
},
|
||||||
|
"tracker": {"algorithm": "dis"},
|
||||||
|
"stitcher": {"n": [1, 1]},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write(path: Path, payload: object) -> None:
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _descriptor(profile_id: str, **overrides: object) -> dict[str, object]:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"profile_id": profile_id,
|
||||||
|
"display_name": "Mini lab",
|
||||||
|
"product_family": "mini",
|
||||||
|
"processing_backend": "single_camera",
|
||||||
|
"sensor_json": "sensor.json",
|
||||||
|
}
|
||||||
|
data.update(overrides)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class _Host(QWidget):
|
||||||
|
"""Window stand-in with the live-calibration prefs API the dialog expects."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.prefs: dict = {}
|
||||||
|
self.applied = 0
|
||||||
|
self._choices: dict[str, tuple[str, Path | None, str]] = {}
|
||||||
|
self.picker: SensorPickerWidget | None = None
|
||||||
|
self._dialog: SensorDialog | None = None
|
||||||
|
|
||||||
|
def _on_reveal_config(self) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def _prefs_key(self) -> str | None:
|
||||||
|
if self.picker is None:
|
||||||
|
return None
|
||||||
|
entry = self.picker.selected_entry()
|
||||||
|
return None if entry is None else entry.ref
|
||||||
|
|
||||||
|
def live_calibration_choice(self) -> tuple[str, Path | None, str]:
|
||||||
|
key = self._prefs_key()
|
||||||
|
if not key:
|
||||||
|
return ROLE_NONE, None, ""
|
||||||
|
return self._choices.get(key, (ROLE_NONE, None, ""))
|
||||||
|
|
||||||
|
def set_live_calibration_choice(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
role: str,
|
||||||
|
path: Path | str | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
key = self._prefs_key()
|
||||||
|
if not key:
|
||||||
|
return
|
||||||
|
file_path = Path(path) if role == ROLE_FILE and path else None
|
||||||
|
self._choices[key] = (role, file_path, str(device_id or ""))
|
||||||
|
store = self.prefs.setdefault("live_calibration", {})
|
||||||
|
payload: dict[str, str] = {"role": role}
|
||||||
|
if file_path is not None:
|
||||||
|
payload["path"] = str(file_path)
|
||||||
|
if device_id:
|
||||||
|
payload["device_id"] = str(device_id)
|
||||||
|
store[key] = payload
|
||||||
|
|
||||||
|
def apply_live_calibration_choice(self) -> None:
|
||||||
|
if self._dialog is not None and self._dialog.choice_problem():
|
||||||
|
return
|
||||||
|
self.applied += 1
|
||||||
|
|
||||||
|
|
||||||
|
def _make_picker(qt_app, tmp_path, monkeypatch, build):
|
||||||
|
root = tmp_path / "user"
|
||||||
|
build(root)
|
||||||
|
catalog = scan_sensor_catalog(
|
||||||
|
shipped_root=tmp_path / "no-shipped-root", user_root=root
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(picker_module, "scan_sensor_catalog", lambda: catalog)
|
||||||
|
return SensorPickerWidget()
|
||||||
|
|
||||||
|
|
||||||
|
def _dialog_for(picker) -> tuple[_Host, SensorDialog]:
|
||||||
|
host = _Host()
|
||||||
|
host.picker = picker
|
||||||
|
dialog = SensorDialog(window=host, picker=picker)
|
||||||
|
host._dialog = dialog
|
||||||
|
return host, dialog
|
||||||
|
|
||||||
|
|
||||||
|
def test_without_a_profile_default_the_combo_starts_on_raw_force(
|
||||||
|
qt_app, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
def build(root: Path) -> None:
|
||||||
|
_write(root / "mini-lab" / "sensor.json", CONFIG)
|
||||||
|
_write(root / "mini-lab" / "manifest.json", _descriptor("orisys.mini.lab-r1"))
|
||||||
|
|
||||||
|
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||||
|
picker.set_sensor_type("mini")
|
||||||
|
_host, dialog = _dialog_for(picker)
|
||||||
|
try:
|
||||||
|
assert dialog.calibration_combo.findData(ROLE_DEFAULT) < 0
|
||||||
|
assert dialog.current_role() == ROLE_NONE
|
||||||
|
assert dialog.current_calibration_path() is None
|
||||||
|
assert t("sensor.catalog_calibration_none") in dialog.profile_label.text()
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
picker.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_box_profile_default_appears_as_a_combo_row(qt_app):
|
||||||
|
"""The shipped box bundle declares ``force_calibration.json`` as its default."""
|
||||||
|
picker = SensorPickerWidget()
|
||||||
|
picker.set_sensor_type("box")
|
||||||
|
entry = picker.selected_entry()
|
||||||
|
assert entry is not None
|
||||||
|
assert entry.profile is not None
|
||||||
|
assert entry.profile.default_calibration is not None, (
|
||||||
|
"premise: shipped box must expose a profile default calibration"
|
||||||
|
)
|
||||||
|
_host, dialog = _dialog_for(picker)
|
||||||
|
try:
|
||||||
|
assert dialog.calibration_combo.findData(ROLE_DEFAULT) >= 0
|
||||||
|
assert dialog.calibration_combo.findData(ROLE_NONE) >= 0
|
||||||
|
assert dialog.current_role() == ROLE_NONE
|
||||||
|
dialog.calibration_combo.setCurrentIndex(
|
||||||
|
dialog.calibration_combo.findData(ROLE_DEFAULT)
|
||||||
|
)
|
||||||
|
path = dialog.current_calibration_path()
|
||||||
|
assert path is not None and path.name == "force_calibration.json"
|
||||||
|
assert t(
|
||||||
|
"sensor.catalog_calibration_selected", name="force_calibration.json"
|
||||||
|
) in dialog.profile_label.text()
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
picker.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_browsing_a_file_shows_its_name_and_persists_per_bundle(
|
||||||
|
qt_app, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
def build(root: Path) -> None:
|
||||||
|
for name, family, profile in (
|
||||||
|
("mini-a", "mini", "orisys.mini.a-r1"),
|
||||||
|
("finger-a", "finger", "orisys.finger.a-r1"),
|
||||||
|
):
|
||||||
|
folder = root / name
|
||||||
|
_write(folder / "sensor.json", CONFIG)
|
||||||
|
extras = {
|
||||||
|
"display_name": name,
|
||||||
|
"product_family": family,
|
||||||
|
"processing_backend": (
|
||||||
|
"uv_unroll" if family == "finger" else "single_camera"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
if family == "finger":
|
||||||
|
extras["computational_assets"] = {}
|
||||||
|
_write(folder / "manifest.json", _descriptor(profile, **extras))
|
||||||
|
|
||||||
|
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||||
|
host, dialog = _dialog_for(picker)
|
||||||
|
cal = tmp_path / "custom.json"
|
||||||
|
cal.write_text(
|
||||||
|
json.dumps({"meta": {"sensor_type": "mini", "sn": "X"}}), encoding="utf-8"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
picker.set_sensor_type("mini")
|
||||||
|
dialog.select_calibration_file(cal)
|
||||||
|
assert dialog.current_role() == ROLE_FILE
|
||||||
|
assert dialog.calibration_combo.currentText() == t(
|
||||||
|
"video_analysis.calibration_file", name="custom.json"
|
||||||
|
)
|
||||||
|
mini_key = picker.selected_entry().ref
|
||||||
|
assert host.prefs["live_calibration"][mini_key]["path"] == str(cal)
|
||||||
|
|
||||||
|
picker.set_sensor_type("finger")
|
||||||
|
dialog.refresh()
|
||||||
|
assert dialog.current_role() == ROLE_NONE
|
||||||
|
finger_key = picker.selected_entry().ref
|
||||||
|
assert finger_key != mini_key
|
||||||
|
assert ROLE_FILE != host.prefs.get("live_calibration", {}).get(
|
||||||
|
finger_key, {}
|
||||||
|
).get("role")
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
picker.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_file_with_a_mismatched_sn_is_not_a_valid_choice(
|
||||||
|
qt_app, tmp_path, monkeypatch
|
||||||
|
):
|
||||||
|
def build(root: Path) -> None:
|
||||||
|
folder = root / "finger-lab"
|
||||||
|
_write(folder / "sensor.json", CONFIG)
|
||||||
|
_write(
|
||||||
|
folder / "manifest.json",
|
||||||
|
_descriptor(
|
||||||
|
"orisys.finger.lab-r1",
|
||||||
|
display_name="Finger lab",
|
||||||
|
product_family="finger",
|
||||||
|
processing_backend="uv_unroll",
|
||||||
|
computational_assets={},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
picker = _make_picker(qt_app, tmp_path, monkeypatch, build)
|
||||||
|
picker.set_sensor_type("finger")
|
||||||
|
host, dialog = _dialog_for(picker)
|
||||||
|
cal = tmp_path / "DD02-14.json"
|
||||||
|
cal.write_text(
|
||||||
|
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
dialog.select_calibration_file(cal)
|
||||||
|
# Offscreen dialogs are not shown; ``isHidden`` is the visibility flag.
|
||||||
|
assert not dialog.device_edit.isHidden()
|
||||||
|
assert dialog.device_edit.text() == "DD02-14"
|
||||||
|
assert dialog.choice_problem() == ""
|
||||||
|
dialog.device_edit.setText("OTHER")
|
||||||
|
assert dialog.choice_problem() == t(
|
||||||
|
"video_analysis.device_mismatch", sn="DD02-14"
|
||||||
|
)
|
||||||
|
dialog.btn_apply.click()
|
||||||
|
assert host.applied == 0
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
picker.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_window_resolves_finger_file_choice_for_open(make_window, tmp_path):
|
||||||
|
window = make_window()
|
||||||
|
cal = tmp_path / "DD02-14.json"
|
||||||
|
cal.write_text(
|
||||||
|
json.dumps({"meta": {"sensor_type": "finger", "sn": "DD02-14"}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
finger = next(
|
||||||
|
(
|
||||||
|
entry
|
||||||
|
for entry in scan_sensor_catalog().entries
|
||||||
|
if str(entry.product_family) == "finger"
|
||||||
|
and entry.start_blocked_reason is None
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if finger is None:
|
||||||
|
pytest.skip("no startable Finger profile in the catalog")
|
||||||
|
window.sensor_picker.set_sensor_type("finger")
|
||||||
|
window.sensor_picker.set_config_name(finger.key)
|
||||||
|
window.set_live_calibration_choice(
|
||||||
|
role=ROLE_FILE, path=cal, device_id="DD02-14"
|
||||||
|
)
|
||||||
|
cal_path, device_id, problem = window._resolved_live_calibration()
|
||||||
|
assert problem == ""
|
||||||
|
assert cal_path == cal
|
||||||
|
assert device_id == "DD02-14"
|
||||||
|
|
||||||
|
window.set_live_calibration_choice(
|
||||||
|
role=ROLE_FILE, path=cal, device_id="OTHER"
|
||||||
|
)
|
||||||
|
_path, _device, problem = window._resolved_live_calibration()
|
||||||
|
assert problem == t("video_analysis.device_mismatch", sn="DD02-14")
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_while_running_queues_set_pending_calibration(make_window, tmp_path):
|
||||||
|
window = make_window()
|
||||||
|
cal = tmp_path / "force_calibration.json"
|
||||||
|
cal.write_text('{"schema_version": 2}', encoding="utf-8")
|
||||||
|
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
|
||||||
|
pending: list[str] = []
|
||||||
|
|
||||||
|
class _Worker:
|
||||||
|
def add_sink(self, _sink) -> None:
|
||||||
|
return
|
||||||
|
|
||||||
|
def set_pending_calibration(self, path) -> None:
|
||||||
|
pending.append(str(path))
|
||||||
|
|
||||||
|
worker = _Worker()
|
||||||
|
# Both slots filled so ``ensure_workers`` does not replace the shim.
|
||||||
|
window.session.set_worker("box", worker)
|
||||||
|
window.session.set_worker("finger", worker)
|
||||||
|
window.session.select_worker("box")
|
||||||
|
window.session.set_running(True)
|
||||||
|
assert window._running
|
||||||
|
assert window._active_worker is worker
|
||||||
|
|
||||||
|
window.apply_live_calibration_choice()
|
||||||
|
assert pending == [str(cal)]
|
||||||
|
|
||||||
|
pending.clear()
|
||||||
|
window.set_live_calibration_choice(role=ROLE_NONE)
|
||||||
|
window.apply_live_calibration_choice()
|
||||||
|
assert pending == []
|
||||||
|
assert window.statusBar().currentMessage() == t("sensor.live_calibration_remembered")
|
||||||
|
|
||||||
|
|
||||||
|
def _facts(path: Path) -> VideoFacts:
|
||||||
|
return VideoFacts(path=path, frame_count=4, fps=10.0, width=8, height=8)
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_prompt_defaults_to_the_saved_sensor_file(make_window, tmp_path):
|
||||||
|
"""The file saved from the sensor dialog is the analysis prompt's starting choice."""
|
||||||
|
window = make_window()
|
||||||
|
entry = window.sensor_picker.selected_entry()
|
||||||
|
assert entry is not None
|
||||||
|
assert window.live_calibration_for(entry.ref) is None
|
||||||
|
cal = tmp_path / "picked.json"
|
||||||
|
cal.write_text("{}", encoding="utf-8")
|
||||||
|
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
|
||||||
|
dialog = VideoAnalysisDialog(
|
||||||
|
video=tmp_path / "clip.mp4",
|
||||||
|
facts=_facts(tmp_path / "clip.mp4"),
|
||||||
|
catalog=scan_sensor_catalog(),
|
||||||
|
window=window,
|
||||||
|
initial_profile=entry.ref,
|
||||||
|
remembered_calibration=window.live_calibration_for,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert dialog.calibration_combo.currentData() == ROLE_FILE
|
||||||
|
assert dialog._chosen_calibration() == cal
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_prompt_keeps_its_default_until_a_choice_is_saved(
|
||||||
|
make_window, tmp_path
|
||||||
|
):
|
||||||
|
window = make_window()
|
||||||
|
dialog = VideoAnalysisDialog(
|
||||||
|
video=tmp_path / "clip.mp4",
|
||||||
|
facts=_facts(tmp_path / "clip.mp4"),
|
||||||
|
catalog=scan_sensor_catalog(),
|
||||||
|
window=window,
|
||||||
|
initial_profile="shipped/box",
|
||||||
|
remembered_calibration=window.live_calibration_for,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert dialog.calibration_combo.currentData() == ROLE_DEFAULT
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_reopened_analysis_setup_still_beats_the_saved_file(make_window, tmp_path):
|
||||||
|
window = make_window()
|
||||||
|
entry = window.sensor_picker.selected_entry()
|
||||||
|
assert entry is not None
|
||||||
|
cal = tmp_path / "picked.json"
|
||||||
|
cal.write_text("{}", encoding="utf-8")
|
||||||
|
window.set_live_calibration_choice(role=ROLE_FILE, path=cal)
|
||||||
|
dialog = VideoAnalysisDialog(
|
||||||
|
video=tmp_path / "clip.mp4",
|
||||||
|
facts=_facts(tmp_path / "clip.mp4"),
|
||||||
|
catalog=scan_sensor_catalog(),
|
||||||
|
window=window,
|
||||||
|
initial_profile=entry.ref,
|
||||||
|
prefer_no_calibration=True,
|
||||||
|
remembered_calibration=window.live_calibration_for,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
assert dialog.calibration_combo.currentData() == ROLE_NONE
|
||||||
|
finally:
|
||||||
|
dialog.deleteLater()
|
||||||
@@ -978,10 +978,45 @@ def test_the_controller_redraws_on_a_display_change_and_runs_no_pass(pass_root):
|
|||||||
assert session.threshold() == pytest.approx(0.5)
|
assert session.threshold() == pytest.approx(0.5)
|
||||||
# The frame is rebuilt from the same stored record, not from a new one.
|
# The frame is rebuilt from the same stored record, not from a new one.
|
||||||
assert session.frame(int(session.frame_ids[0])).frame_id == FIRST_FRAME_ID
|
assert session.frame(int(session.frame_ids[0])).frame_id == FIRST_FRAME_ID
|
||||||
|
assert host.notes == [], "a threshold change must not lecture about the grid"
|
||||||
finally:
|
finally:
|
||||||
session.reader.close()
|
session.reader.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_refused_grid_change_redraws_without_a_status_warning():
|
||||||
|
"""Moving max_arrows cannot be served; the frame is redrawn and nothing is said.
|
||||||
|
|
||||||
|
No evidence recording: the session is a stub whose ``serve_policy`` refuses, which
|
||||||
|
is exactly the path that used to push ``playback.analysis_grid_mismatch``.
|
||||||
|
"""
|
||||||
|
class _Reader:
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class _Session:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list = []
|
||||||
|
self.reader = _Reader()
|
||||||
|
|
||||||
|
def serve_policy(self, policy) -> bool:
|
||||||
|
self.calls.append(policy)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def close(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
controller, host, engine, parts = _controller()
|
||||||
|
session = _Session()
|
||||||
|
controller.results.open_session(session) # type: ignore[arg-type]
|
||||||
|
engine.use_analysis(session)
|
||||||
|
engine.redraws = 0
|
||||||
|
controller.on_display_settings(DisplaySettings(max_arrows=50))
|
||||||
|
assert engine.redraws == 1, "the refused policy still redraws the stored frame"
|
||||||
|
assert len(session.calls) == 1
|
||||||
|
assert host.notes == [], "a grid mismatch must not reach the status bar"
|
||||||
|
controller.results.close()
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------ analysis on open
|
# ------------------------------------------------------------ analysis on open
|
||||||
def test_a_running_session_does_not_hide_a_stored_result(pass_root):
|
def test_a_running_session_does_not_hide_a_stored_result(pass_root):
|
||||||
"""A result on disk is served while a session is open: reading it opens no chain.
|
"""A result on disk is served while a session is open: reading it opens no chain.
|
||||||
@@ -1527,17 +1562,18 @@ def test_the_nothing_to_draw_line_names_the_action_that_produces_a_result():
|
|||||||
assert seen["zh_CN"] != seen["en_US"]
|
assert seen["zh_CN"] != seen["en_US"]
|
||||||
|
|
||||||
|
|
||||||
def test_the_empty_depth_note_is_not_the_missing_depth_note():
|
def test_depth_unavailable_is_a_short_absent_note():
|
||||||
"""Both locales keep the two apart: a flat map is a reading, an absent one is not."""
|
"""Both locales say the frame has no depth, without explaining zero vs absent."""
|
||||||
from orisys_app_shared.i18n import set_locale
|
from orisys_app_shared.i18n import set_locale
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for locale in ("zh_CN", "en_US"):
|
for locale in ("zh_CN", "en_US"):
|
||||||
set_locale(locale)
|
set_locale(locale)
|
||||||
empty = t("panels.depth_empty", pixels=84000)
|
|
||||||
absent = t("panels.depth_unavailable")
|
absent = t("panels.depth_unavailable")
|
||||||
assert "84000" in empty or "84,000" in empty, empty
|
assert absent
|
||||||
assert empty != absent
|
assert "\n" not in absent, absent
|
||||||
|
scale = t("panels.depth_scale", low="0.000", high="1.000")
|
||||||
|
assert scale != absent
|
||||||
finally:
|
finally:
|
||||||
set_locale("zh_CN")
|
set_locale("zh_CN")
|
||||||
|
|
||||||
|
|||||||
@@ -345,9 +345,9 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
|
|||||||
|
|
||||||
# The depth view is the stored map, drawn through the window's own backend. This
|
# The depth view is the stored map, drawn through the window's own backend. This
|
||||||
# result's map is exactly zero on every frame it holds — the box chain's depth is
|
# result's map is exactly zero on every frame it holds — the box chain's depth is
|
||||||
# flat on this footage, which the panel has its own sentence for — and it is still a
|
# flat on this footage — and it is still a stored array of the result's own shape,
|
||||||
# stored array of the result's own shape, so the pixels are checked against it rather
|
# so the pixels are checked against it rather than against a flat placeholder.
|
||||||
# than against a flat placeholder.
|
# The colour-scale caption names the range only.
|
||||||
_set_mode(window, DEPTH)
|
_set_mode(window, DEPTH)
|
||||||
target = active_frame
|
target = active_frame
|
||||||
assert target.depth is not None and target.depth_nonzero == 0
|
assert target.depth is not None and target.depth_nonzero == 0
|
||||||
@@ -365,9 +365,9 @@ def test_the_operator_sees_the_cached_views_over_the_recording(
|
|||||||
"the depth view's pixels are not depth_map_to_bgr of the stored frame"
|
"the depth view's pixels are not depth_map_to_bgr of the stored frame"
|
||||||
)
|
)
|
||||||
assert not np.array_equal(shown, arrows_bgr), "the depth view drew the arrow view"
|
assert not np.array_equal(shown, arrows_bgr), "the depth view drew the arrow view"
|
||||||
assert t("panels.depth_empty", pixels=int(target.depth.size)) in (
|
assert window.arrow_panel.depth_scale_label.text() == (
|
||||||
window.arrow_panel.depth_scale_label.text()
|
window.frame_view.depth_scale_text(target)
|
||||||
), "an exactly-zero depth map is not stated as such"
|
), "the depth caption is not the colour-scale range"
|
||||||
|
|
||||||
# …and the recording's own picture is still reachable, at the same cursor.
|
# …and the recording's own picture is still reachable, at the same cursor.
|
||||||
_set_mode(window, RECORDED)
|
_set_mode(window, RECORDED)
|
||||||
|
|||||||
@@ -120,9 +120,9 @@ def test_2d_3d_flow_sample_points_match() -> None:
|
|||||||
composed = composer.compose(
|
composed = composer.compose(
|
||||||
assets, _depth_frame(assets, flow=flow), settings
|
assets, _depth_frame(assets, flow=flow), settings
|
||||||
)
|
)
|
||||||
arrows_3d = (
|
arrows = None if composed.flow_arrows is None else composed.flow_arrows.lengths
|
||||||
0 if composed.flow_arrows is None else int(composed.flow_arrows.origins.shape[0])
|
# Positive lengths are arrows. Negative lengths are the idle points.
|
||||||
)
|
arrows_3d = 0 if arrows is None else int(np.count_nonzero(arrows > 0.0))
|
||||||
# Exactly what the 2D canvas draws from: ``draw_arrow_display`` calls
|
# Exactly what the 2D canvas draws from: ``draw_arrow_display`` calls
|
||||||
# ``select_flow_samples`` with the policy the window passes it, and draws the
|
# ``select_flow_samples`` with the policy the window passes it, and draws the
|
||||||
# samples the flag marks active.
|
# samples the flag marks active.
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ def test_the_pane_holds_a_legend_and_no_settings(panel):
|
|||||||
panel.housing_check,
|
panel.housing_check,
|
||||||
panel.deform_check,
|
panel.deform_check,
|
||||||
panel.gain_spin,
|
panel.gain_spin,
|
||||||
|
panel.flow_threshold_spin,
|
||||||
panel.scale_combo,
|
panel.scale_combo,
|
||||||
panel.depth_adaptive_check,
|
panel.depth_adaptive_check,
|
||||||
panel.fps_combo,
|
panel.fps_combo,
|
||||||
@@ -106,6 +107,51 @@ def test_adaptive_depth_scale_round_trips_through_the_dialog(panel):
|
|||||||
assert panel.settings().depth_scale_adaptive is False
|
assert panel.settings().depth_scale_adaptive is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_arrow_threshold_round_trips_and_feeds_the_shared_policy(panel):
|
||||||
|
"""The threshold the dialog writes is the one both views sample with."""
|
||||||
|
from orisys_app_shared.scene3d.contracts import DisplaySettings
|
||||||
|
from orisys_app_shared.scene3d.visual_policy import policy_for_settings
|
||||||
|
|
||||||
|
panel.apply_settings(
|
||||||
|
DisplaySettings(flow_on_px=0.9, flow_grid_spacing=12, max_arrows=200)
|
||||||
|
)
|
||||||
|
assert panel.flow_threshold_spin.value() == pytest.approx(0.9)
|
||||||
|
settings = panel.settings()
|
||||||
|
assert settings.flow_on_px == pytest.approx(0.9)
|
||||||
|
assert settings.flow_off_px == pytest.approx(0.8)
|
||||||
|
assert settings.flow_grid_spacing == 12
|
||||||
|
assert settings.max_arrows == 200
|
||||||
|
# A colour-scale click must not wipe the held grid or the typed threshold.
|
||||||
|
panel.housing_check.setChecked(True)
|
||||||
|
again = panel.settings()
|
||||||
|
assert again.flow_on_px == pytest.approx(0.9)
|
||||||
|
assert again.flow_grid_spacing == 12
|
||||||
|
assert policy_for_settings(again).flow_on_px == pytest.approx(0.9)
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_legend_follows_the_shared_2d_span_immediately(panel):
|
||||||
|
"""The 3D colour-bar numbers are the 2D span, not a lagged GL automatic range."""
|
||||||
|
from orisys_app_shared.scene3d.contracts import FieldDescriptor, RenderStats
|
||||||
|
|
||||||
|
panel.set_descriptor(
|
||||||
|
FieldDescriptor("depth_response", "1", "approximate", False)
|
||||||
|
)
|
||||||
|
panel.set_render_stats(
|
||||||
|
RenderStats(scale_lower=0.0, scale_upper=0.0489, scale_mode="automatic")
|
||||||
|
)
|
||||||
|
panel.set_shared_depth_scale((0.0, 0.029))
|
||||||
|
assert panel.scale_lower_label.text() == "0.000"
|
||||||
|
assert panel.scale_upper_label.text() == "0.029"
|
||||||
|
|
||||||
|
|
||||||
|
def test_source_kind_analysis_uses_its_own_label(panel):
|
||||||
|
"""A stored result is not labelled as live acquisition."""
|
||||||
|
panel.set_diagnostics_visible(True)
|
||||||
|
panel.set_source_kind("analysis")
|
||||||
|
assert panel.source_label.text() == t("scene3d.source_analysis")
|
||||||
|
assert panel.source_label.toolTip() == t("scene3d.source_analysis_tip")
|
||||||
|
|
||||||
|
|
||||||
def test_the_pane_header_offers_the_two_actions(qt_app):
|
def test_the_pane_header_offers_the_two_actions(qt_app):
|
||||||
"""A gear and a view reset: the two things that belong to *looking*."""
|
"""A gear and a view reset: the two things that belong to *looking*."""
|
||||||
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
from orisys_studio.ui.mesh3d_panel import Mesh3dPanel
|
||||||
@@ -210,8 +256,8 @@ def test_the_diagnostics_switch_governs_what_the_pane_explains(panel):
|
|||||||
)
|
)
|
||||||
panel.set_diagnostics_visible(False)
|
panel.set_diagnostics_visible(False)
|
||||||
assert not panel.scale_bar.isHidden()
|
assert not panel.scale_bar.isHidden()
|
||||||
assert panel.scale_lower_label.text() == "0.25"
|
assert panel.scale_lower_label.text() == "0.250"
|
||||||
assert panel.scale_upper_label.text() == "2.5"
|
assert panel.scale_upper_label.text() == "2.500"
|
||||||
assert not panel.scale_lower_label.isHidden()
|
assert not panel.scale_lower_label.isHidden()
|
||||||
assert not panel.scale_upper_label.isHidden()
|
assert not panel.scale_upper_label.isHidden()
|
||||||
assert panel.legend_label.isHidden()
|
assert panel.legend_label.isHidden()
|
||||||
|
|||||||
@@ -1018,7 +1018,7 @@ def test_the_legend_shows_the_range_the_shader_actually_used(qt_app):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
legend = panel.legend_label.text()
|
legend = panel.legend_label.text()
|
||||||
assert "0.25" in legend and "7.5" in legend, legend
|
assert "0.250" in legend and "7.500" in legend, legend
|
||||||
assert t("scene3d.legend_auto") in legend, legend
|
assert t("scene3d.legend_auto") in legend, legend
|
||||||
assert panel.scale_bar.isVisible() or panel.scale_bar.parent() is not None
|
assert panel.scale_bar.isVisible() or panel.scale_bar.parent() is not None
|
||||||
assert "flow_magnitude" not in legend
|
assert "flow_magnitude" not in legend
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ def test_the_details_sit_directly_below_the_selector(mixed_picker):
|
|||||||
dialog = _details_dialog(mixed_picker)
|
dialog = _details_dialog(mixed_picker)
|
||||||
root = dialog.layout()
|
root = dialog.layout()
|
||||||
assert root.itemAt(0).layout() is dialog.picker_row, "the selector is not the first row"
|
assert root.itemAt(0).layout() is dialog.picker_row, "the selector is not the first row"
|
||||||
assert root.itemAt(1).widget() is dialog.profile_label, (
|
assert root.itemAt(1).widget() is dialog.profile_scroll, (
|
||||||
"the details are not directly under the selector"
|
"the details are not directly under the selector"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -51,13 +51,13 @@ EXPECTED_SHIPPED: dict[str, tuple[str, str, str, bool, str]] = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#: What an operator reads in the picker, one row per shipped bundle: the bundle's name, and
|
#: What an operator reads in the picker, one row per shipped bundle: the bundle's name, and
|
||||||
#: nothing else. ``finger`` declares ``Finger R01`` in its descriptor; the other three have no
|
#: nothing else. ``finger`` declares ``Finger`` in its descriptor; the other three have no
|
||||||
#: descriptor and are named by the folder they live in, as the SDK's built-in table words it.
|
#: descriptor and are named by the folder they live in, as the SDK's built-in table words it.
|
||||||
#: The origin, the calibration selection and the SDK's ``degraded`` verdict are not rows — they
|
#: The origin, the calibration selection and the SDK's ``degraded`` verdict are not rows — they
|
||||||
#: are read in the dialog's details surface, which sits under the selector.
|
#: are read in the dialog's details surface, which sits under the selector.
|
||||||
EXPECTED_ROWS: dict[str, str] = {
|
EXPECTED_ROWS: dict[str, str] = {
|
||||||
"box": "Box",
|
"box": "Box",
|
||||||
"finger": "Finger R01",
|
"finger": "Finger",
|
||||||
"finger-mems": "Finger (MEMS)",
|
"finger-mems": "Finger (MEMS)",
|
||||||
"mini": "Mini",
|
"mini": "Mini",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,10 @@ def test_depth_without_a_shared_span_still_measures_the_field() -> None:
|
|||||||
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
|
field = _field("depth_response", np.linspace(0.0, 9.0, 16).reshape(4, 4))
|
||||||
span = _field_scalar_range(
|
span = _field_scalar_range(
|
||||||
field,
|
field,
|
||||||
previous=None,
|
previous=(0.0, 2.0),
|
||||||
shared_depth_scale=None,
|
shared_depth_scale=None,
|
||||||
)
|
)
|
||||||
assert span is not None
|
assert span == (0.0, 9.0)
|
||||||
assert span[0] == 0.0
|
|
||||||
assert span[1] > 1.0
|
|
||||||
|
|
||||||
|
|
||||||
def test_a_degenerate_shared_span_falls_back_to_the_measurement() -> None:
|
def test_a_degenerate_shared_span_falls_back_to_the_measurement() -> None:
|
||||||
|
|||||||
@@ -1741,7 +1741,12 @@ def test_the_replay_view_loads_a_recording_and_scrubs_it(make_window, replay_rec
|
|||||||
assert panel.mode() == "follow"
|
assert panel.mode() == "follow"
|
||||||
assert window.toolbar.run_role() == "acquire", "the rail kept the transport"
|
assert window.toolbar.run_role() == "acquire", "the rail kept the transport"
|
||||||
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
assert window.toolbar.btn_run.text() == t("toolbar.run_label")
|
||||||
assert window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth")
|
assert window.arrow_panel.view_modes() == (
|
||||||
|
"arrows_pure",
|
||||||
|
"arrows_stitched",
|
||||||
|
"depth",
|
||||||
|
"depth_arrows",
|
||||||
|
)
|
||||||
assert "raw_0001" not in window.toolbar.source_summary.text(), (
|
assert "raw_0001" not in window.toolbar.source_summary.text(), (
|
||||||
"the replay's video is still named in the live view"
|
"the replay's video is still named in the live view"
|
||||||
)
|
)
|
||||||
@@ -2747,14 +2752,15 @@ def test_a_recording_without_a_video_says_it_cannot_be_played(make_window, recor
|
|||||||
# ------------------------------------------- selection readout and notices
|
# ------------------------------------------- selection readout and notices
|
||||||
|
|
||||||
|
|
||||||
def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
|
def test_a_zoomed_window_keeps_the_cursor_on_the_value_cards(make_window):
|
||||||
"""The per-channel line under the charts did not fit; the cards hold it now.
|
"""Zooming names the window in the strip; the cards stay on the playhead.
|
||||||
|
|
||||||
Selecting a window makes the big number that window's peak and the line under
|
Selecting a window used to put that window's peak and mean · σ on the cards.
|
||||||
it its mean · σ. The strip keeps only how big the selection is, and leaving
|
The big numbers are the sample under the cursor again; the strip keeps only
|
||||||
the selection puts the cursor readout back.
|
how big the selection is.
|
||||||
"""
|
"""
|
||||||
from orisys_app_shared.i18n import t
|
from orisys_app_shared.i18n import t
|
||||||
|
from orisys_studio.ui.force_plots import format_force
|
||||||
|
|
||||||
window, stuck = _running_window(make_window)
|
window, stuck = _running_window(make_window)
|
||||||
window.toolbar.units_combo.setCurrentIndex(0) # newton
|
window.toolbar.units_combo.setCurrentIndex(0) # newton
|
||||||
@@ -2768,6 +2774,7 @@ def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
|
|||||||
assert plots.stats_label.text() == ""
|
assert plots.stats_label.text() == ""
|
||||||
assert plots.value_details[0].text() == "", "a live reading carries a mean line"
|
assert plots.value_details[0].text() == "", "a live reading carries a mean line"
|
||||||
|
|
||||||
|
plots.set_cursor(2.0)
|
||||||
plots.set_window(1.0, 3.0)
|
plots.set_window(1.0, 3.0)
|
||||||
assert plots.mode() == "browse"
|
assert plots.mode() == "browse"
|
||||||
assert plots._selection is not None, "the window was not read as a selection"
|
assert plots._selection is not None, "the window was not read as a selection"
|
||||||
@@ -2777,17 +2784,17 @@ def test_a_selection_moves_its_statistics_onto_the_value_cards(make_window):
|
|||||||
assert t("chart.selection_summary", duration="1", samples=2)[:4] in summary or (
|
assert t("chart.selection_summary", duration="1", samples=2)[:4] in summary or (
|
||||||
"选区" in summary or "Selection" in summary
|
"选区" in summary or "Selection" in summary
|
||||||
), summary
|
), summary
|
||||||
# The numbers a reader cannot fit in the strip are on the cards instead.
|
assert "峰" not in summary and "均" not in summary, summary
|
||||||
expected_peak = float(plots._selection.peaks[0])
|
|
||||||
expected = f"{expected_peak:.2f}"
|
expected = format_force(
|
||||||
assert plots.value_labels[0].text() == expected, (
|
plots._display_values[0], plots.unit(), presentation=plots.presentation
|
||||||
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}"
|
)
|
||||||
|
assert plots.value_labels[0].text() == expected, (
|
||||||
|
f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}"
|
||||||
|
)
|
||||||
|
assert plots.value_details[0].text() == "", (
|
||||||
|
f"the card still carries a mean line: {plots.value_details[0].text()!r}"
|
||||||
)
|
)
|
||||||
detail = plots.value_details[0].text()
|
|
||||||
assert detail and detail != "", detail
|
|
||||||
assert f"{float(plots._selection.means[0]):.4g}" in detail, (detail, plots._selection.means[0])
|
|
||||||
assert "均" in detail or "mean" in detail, detail
|
|
||||||
assert plots.value_labels[0].toolTip(), "the peak is not explained anywhere"
|
|
||||||
|
|
||||||
# "Whole session" is the default fit, so it is not a selection.
|
# "Whole session" is the default fit, so it is not a selection.
|
||||||
plots.show_all()
|
plots.show_all()
|
||||||
|
|||||||
@@ -2775,9 +2775,8 @@ def main() -> int:
|
|||||||
plots.overview_curve.getData()[0].size > 0,
|
plots.overview_curve.getData()[0].size > 0,
|
||||||
"the whole-session overview strip is empty",
|
"the whole-session overview strip is empty",
|
||||||
)
|
)
|
||||||
# A window narrower than the session is a selection: its peak becomes the big
|
# A window narrower than the session is a selection in the strip only: the
|
||||||
# number on each card and its mean/σ the line under it, while the strip keeps
|
# cards stay on the sample under the cursor.
|
||||||
# only the size of the selection (the long per-channel line did not fit).
|
|
||||||
check(plots._selection is not None, "zooming in did not record a selection")
|
check(plots._selection is not None, "zooming in did not record a selection")
|
||||||
summary = plots.stats_label.text()
|
summary = plots.stats_label.text()
|
||||||
check(summary != "", "the strip does not state the size of the selection")
|
check(summary != "", "the strip does not state the size of the selection")
|
||||||
@@ -2789,34 +2788,25 @@ def main() -> int:
|
|||||||
"选区" in summary or "Selection" in summary,
|
"选区" in summary or "Selection" in summary,
|
||||||
f"the strip does not say that the numbers describe a selection: {summary!r}",
|
f"the strip does not say that the numbers describe a selection: {summary!r}",
|
||||||
)
|
)
|
||||||
expected_peak = float(plots._selection.peaks[0])
|
from orisys_studio.ui.force_plots import format_force
|
||||||
peak_text = (
|
|
||||||
f"{expected_peak:.3f}" if plots.unit() == "N" else f"{expected_peak:.1f}"
|
expected_cursor = format_force(
|
||||||
|
plots._display_values[0], plots.unit(), presentation=plots.presentation
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
plots.value_labels[0].text() == peak_text,
|
plots.value_labels[0].text() == expected_cursor,
|
||||||
f"the FN card is not the selection's peak: {plots.value_labels[0].text()!r}",
|
f"the FN card is not the cursor sample: {plots.value_labels[0].text()!r}",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
plots.value_details[0].text() != ""
|
plots.value_details[0].text() == "",
|
||||||
and ("均" in plots.value_details[0].text() or "mean" in plots.value_details[0].text()),
|
f"the card still carries a mean line: {plots.value_details[0].text()!r}",
|
||||||
f"the card does not state the selection's mean: "
|
|
||||||
f"{plots.value_details[0].text()!r}",
|
|
||||||
)
|
)
|
||||||
for card, label, detail in zip(
|
# Leaving the selection clears the strip; the cards stay a cursor readout.
|
||||||
plots.value_cards, plots.value_labels, plots.value_details
|
|
||||||
):
|
|
||||||
check(
|
|
||||||
detail.width() >= detail.sizeHint().width(),
|
|
||||||
f"the selection detail line is clipped: "
|
|
||||||
f"{detail.width()} < {detail.sizeHint().width()}",
|
|
||||||
)
|
|
||||||
# Leaving the selection hands the cards back to the cursor readout.
|
|
||||||
plots.follow_latest()
|
plots.follow_latest()
|
||||||
check(plots._selection is None, "the selection survived 'back to latest'")
|
check(plots._selection is None, "the selection survived 'back to latest'")
|
||||||
check(
|
check(
|
||||||
plots.value_details[0].text() == "",
|
plots.value_details[0].text() == "",
|
||||||
f"the mean line survived leaving the selection: "
|
f"a detail line appeared after leaving the selection: "
|
||||||
f"{plots.value_details[0].text()!r}",
|
f"{plots.value_details[0].text()!r}",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -3673,7 +3663,12 @@ def main() -> int:
|
|||||||
f"{replay_window.toolbar.source_summary.text()!r}",
|
f"{replay_window.toolbar.source_summary.text()!r}",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
replay_window.arrow_panel.view_modes() == ("arrows_pure", "arrows_stitched", "depth"),
|
replay_window.arrow_panel.view_modes() == (
|
||||||
|
"arrows_pure",
|
||||||
|
"arrows_stitched",
|
||||||
|
"depth",
|
||||||
|
"depth_arrows",
|
||||||
|
),
|
||||||
"the 2D panel did not return to its live views",
|
"the 2D panel did not return to its live views",
|
||||||
)
|
)
|
||||||
check(
|
check(
|
||||||
|
|||||||
@@ -235,6 +235,9 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._end_reason: str | None = None
|
self._end_reason: str | None = None
|
||||||
self._phase: str = "idle"
|
self._phase: str = "idle"
|
||||||
self._pending_calibration: str | None = None
|
self._pending_calibration: str | None = None
|
||||||
|
#: Path and device serial for the **next** ``start()`` (live open kwargs).
|
||||||
|
self._session_cal_path: str | None = None
|
||||||
|
self._session_device_id: str | None = None
|
||||||
#: A zeroing requested from another thread, applied by this one.
|
#: A zeroing requested from another thread, applied by this one.
|
||||||
self._pending_reset: bool = False
|
self._pending_reset: bool = False
|
||||||
self._mono_t0: float = 0.0
|
self._mono_t0: float = 0.0
|
||||||
@@ -305,6 +308,8 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
video_source: str | int,
|
video_source: str | int,
|
||||||
*,
|
*,
|
||||||
config_ref: str | Path,
|
config_ref: str | Path,
|
||||||
|
cal_path: str | Path | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
) -> FixedCameraMode:
|
) -> FixedCameraMode:
|
||||||
# Non-blocking: a previous session that is stuck opening must never hold
|
# Non-blocking: a previous session that is stuck opening must never hold
|
||||||
# the caller (the GUI thread) hostage.
|
# the caller (the GUI thread) hostage.
|
||||||
@@ -317,6 +322,12 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._mode = mode
|
self._mode = mode
|
||||||
self._video_source = video_source
|
self._video_source = video_source
|
||||||
self._export_dir = resolve_finger_export_dir(config_ref)
|
self._export_dir = resolve_finger_export_dir(config_ref)
|
||||||
|
self._session_cal_path = (
|
||||||
|
None if cal_path is None or not str(cal_path).strip() else str(cal_path)
|
||||||
|
)
|
||||||
|
self._session_device_id = (
|
||||||
|
None if device_id is None or not str(device_id).strip() else str(device_id)
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest = None
|
self._latest = None
|
||||||
self._end_reason = None
|
self._end_reason = None
|
||||||
@@ -564,6 +575,12 @@ class FingerAcquisitionWorker(RecordSinkMixin):
|
|||||||
}
|
}
|
||||||
if self._export_dir is not None:
|
if self._export_dir is not None:
|
||||||
open_kw["export_dir"] = str(self._export_dir)
|
open_kw["export_dir"] = str(self._export_dir)
|
||||||
|
if self._session_cal_path is not None:
|
||||||
|
# Same open path as the offline analysis pass: the operator's file,
|
||||||
|
# and the serial they confirmed when the file needs one.
|
||||||
|
open_kw["cal_path"] = self._session_cal_path
|
||||||
|
if self._session_device_id:
|
||||||
|
open_kw["device_id"] = self._session_device_id
|
||||||
runtime = FingerRuntime.open(**open_kw)
|
runtime = FingerRuntime.open(**open_kw)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._runtimes[session] = runtime
|
self._runtimes[session] = runtime
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ from orisys_app_shared.acquisition.models import ensure_bgr
|
|||||||
ARROW_PURE = "arrows_pure"
|
ARROW_PURE = "arrows_pure"
|
||||||
ARROW_STITCHED = "arrows_stitched"
|
ARROW_STITCHED = "arrows_stitched"
|
||||||
DEPTH = "depth"
|
DEPTH = "depth"
|
||||||
|
DEPTH_ARROWS = "depth_arrows"
|
||||||
RECORDED = "recorded"
|
RECORDED = "recorded"
|
||||||
|
|
||||||
DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH)
|
DERIVED_MODES = (ARROW_PURE, ARROW_STITCHED, DEPTH, DEPTH_ARROWS)
|
||||||
ALL_MODES = (*DERIVED_MODES, RECORDED)
|
ALL_MODES = (*DERIVED_MODES, RECORDED)
|
||||||
|
|
||||||
#: Where a frame came from. Only used for wording and for the recording; the modes
|
#: Where a frame came from. Only used for wording and for the recording; the modes
|
||||||
@@ -294,6 +295,8 @@ class ImagePlan:
|
|||||||
return "flow"
|
return "flow"
|
||||||
if wanted == DEPTH and not self.has_depth():
|
if wanted == DEPTH and not self.has_depth():
|
||||||
return "depth"
|
return "depth"
|
||||||
|
if wanted == DEPTH_ARROWS and not self.has_depth() and not self.has_flow():
|
||||||
|
return "depth"
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def has_flow(self) -> bool:
|
def has_flow(self) -> bool:
|
||||||
@@ -374,6 +377,52 @@ def plan_image(frame: ImageFrame) -> ImagePlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _orient_drawn(drawn: np.ndarray, turn: str) -> np.ndarray:
|
||||||
|
"""Apply a display orientation without dropping a draw-failure mark."""
|
||||||
|
diagnostic = getattr(drawn, "frame_diagnostic", None)
|
||||||
|
oriented = apply_orientation(drawn, turn)
|
||||||
|
if diagnostic is None:
|
||||||
|
return oriented
|
||||||
|
return carry_frame_diagnostic(oriented, diagnostic)
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_flow(
|
||||||
|
frame: ImageFrame,
|
||||||
|
base: np.ndarray,
|
||||||
|
*,
|
||||||
|
background: str,
|
||||||
|
theme: str,
|
||||||
|
draw_style: str,
|
||||||
|
domain_mask: np.ndarray | None,
|
||||||
|
arrow_policy,
|
||||||
|
canvas_bgr: Sequence[int] | None = None,
|
||||||
|
sdk_result: bool | None = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Arrows on ``base``, using the same painter the pure-arrow views use."""
|
||||||
|
if sdk_result is None:
|
||||||
|
box_style = not str(draw_style).lower().startswith("f")
|
||||||
|
sdk_result = (
|
||||||
|
box_style
|
||||||
|
and str(frame.source or "") in RESULT_SOURCES
|
||||||
|
and uses_sdk_box_grid(arrow_policy)
|
||||||
|
)
|
||||||
|
flow = frame.flow
|
||||||
|
if sdk_result and flow is not None:
|
||||||
|
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
|
||||||
|
return draw_arrow_display(
|
||||||
|
base,
|
||||||
|
flow,
|
||||||
|
background=background,
|
||||||
|
canvas_bgr=canvas_bgr,
|
||||||
|
draw_style=draw_style,
|
||||||
|
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
|
||||||
|
valid_mask=frame.valid_mask,
|
||||||
|
domain_mask=domain_mask,
|
||||||
|
colormap=flow_colormap(theme),
|
||||||
|
policy=arrow_policy,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def render_image(
|
def render_image(
|
||||||
frame: ImageFrame,
|
frame: ImageFrame,
|
||||||
plan: ImagePlan | None = None,
|
plan: ImagePlan | None = None,
|
||||||
@@ -418,59 +467,55 @@ def render_image(
|
|||||||
wanted = plan.resolve(mode)
|
wanted = plan.resolve(mode)
|
||||||
boundary = frame.valid_mask if domain_mask is None else domain_mask
|
boundary = frame.valid_mask if domain_mask is None else domain_mask
|
||||||
turn = normalise_orientation(orientation) if wanted != RECORDED else ORIENTATION_IDENTITY
|
turn = normalise_orientation(orientation) if wanted != RECORDED else ORIENTATION_IDENTITY
|
||||||
if wanted == DEPTH:
|
depth_image = None
|
||||||
# Never substitute another picture for a missing depth map: that made "no
|
if wanted in (DEPTH, DEPTH_ARROWS) and frame.depth_valid is not False:
|
||||||
# depth" look like a depth reading. The panel says so instead.
|
|
||||||
if frame.depth_valid is False:
|
|
||||||
return None
|
|
||||||
depth_image = depth_map_to_bgr(
|
depth_image = depth_map_to_bgr(
|
||||||
frame.depth,
|
frame.depth,
|
||||||
flow_colormap(theme),
|
flow_colormap(theme),
|
||||||
value_range=depth_range,
|
value_range=depth_range,
|
||||||
invalid_mask=boundary,
|
invalid_mask=boundary,
|
||||||
)
|
)
|
||||||
|
if wanted == DEPTH:
|
||||||
|
# Never substitute another picture for a missing depth map: that made "no
|
||||||
|
# depth" look like a depth reading. The panel says so instead.
|
||||||
if depth_image is None:
|
if depth_image is None:
|
||||||
return None
|
return None
|
||||||
return apply_orientation(depth_image, turn)
|
return apply_orientation(depth_image, turn)
|
||||||
|
if wanted == DEPTH_ARROWS:
|
||||||
|
if depth_image is None:
|
||||||
|
wanted = ARROW_PURE
|
||||||
|
elif frame.flow is None or frame.flow_valid is False:
|
||||||
|
return apply_orientation(depth_image, turn)
|
||||||
|
else:
|
||||||
|
drawn = _draw_flow(
|
||||||
|
frame,
|
||||||
|
depth_image,
|
||||||
|
background="captured",
|
||||||
|
theme=theme,
|
||||||
|
draw_style=draw_style,
|
||||||
|
domain_mask=domain_mask,
|
||||||
|
arrow_policy=arrow_policy,
|
||||||
|
)
|
||||||
|
return _orient_drawn(drawn, turn)
|
||||||
if frame.image_bgr is None:
|
if frame.image_bgr is None:
|
||||||
return None
|
return None
|
||||||
if wanted in (ARROW_PURE, ARROW_STITCHED):
|
if wanted in (ARROW_PURE, ARROW_STITCHED):
|
||||||
if frame.flow is None or frame.flow_valid is False:
|
if frame.flow is None or frame.flow_valid is False:
|
||||||
# The base picture with no arrows, and the plan's ``missing`` says why.
|
# The base picture with no arrows, and the plan's ``missing`` says why.
|
||||||
return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn)
|
return apply_orientation(ensure_bgr(np.asarray(frame.image_bgr)), turn)
|
||||||
# A recovered field is finite only at the cells the pass stored. The shared
|
# A recovered field is finite only at the cells the pass stored. ``_draw_flow``
|
||||||
# painter draws those cells; the SDK painter walks its own grid and finds
|
# fills a box result's gaps with zero and keeps the shared painter otherwise.
|
||||||
# nothing wherever that grid and the stored cells disagree. A box result whose
|
drawn = _draw_flow(
|
||||||
# grid *is* the SDK grid is the exception: fill the gaps with zero (the cells
|
frame,
|
||||||
# the painter does not read) and call the same ``draw_arrows`` live uses.
|
|
||||||
box_style = not str(draw_style).lower().startswith("f")
|
|
||||||
sdk_result = (
|
|
||||||
box_style
|
|
||||||
and str(frame.source or "") in RESULT_SOURCES
|
|
||||||
and uses_sdk_box_grid(arrow_policy)
|
|
||||||
)
|
|
||||||
flow = frame.flow
|
|
||||||
if sdk_result:
|
|
||||||
flow = np.nan_to_num(np.asarray(flow, dtype=np.float64), nan=0.0)
|
|
||||||
drawn = draw_arrow_display(
|
|
||||||
frame.image_bgr,
|
frame.image_bgr,
|
||||||
flow,
|
|
||||||
background="captured" if wanted == ARROW_STITCHED else "pure_color",
|
background="captured" if wanted == ARROW_STITCHED else "pure_color",
|
||||||
canvas_bgr=canvas_bgr,
|
theme=theme,
|
||||||
draw_style=draw_style,
|
draw_style=draw_style,
|
||||||
shared_painter=str(frame.source or "") in RESULT_SOURCES and not sdk_result,
|
canvas_bgr=canvas_bgr,
|
||||||
valid_mask=frame.valid_mask,
|
|
||||||
domain_mask=domain_mask,
|
domain_mask=domain_mask,
|
||||||
colormap=flow_colormap(theme),
|
arrow_policy=arrow_policy,
|
||||||
policy=arrow_policy,
|
|
||||||
)
|
)
|
||||||
# Orientation may rebuild the array; keep the draw failure on the result so the
|
return _orient_drawn(drawn, turn)
|
||||||
# panel can say so instead of showing a silent black canvas.
|
|
||||||
diagnostic = getattr(drawn, "frame_diagnostic", None)
|
|
||||||
oriented = apply_orientation(drawn, turn)
|
|
||||||
if diagnostic is None:
|
|
||||||
return oriented
|
|
||||||
return carry_frame_diagnostic(oriented, diagnostic)
|
|
||||||
# ``recorded``: the frame is the picture, with nothing drawn over it — and the
|
# ``recorded``: the frame is the picture, with nothing drawn over it — and the
|
||||||
# camera's own orientation, which no domain convention applies to.
|
# camera's own orientation, which no domain convention applies to.
|
||||||
return ensure_bgr(np.asarray(frame.image_bgr))
|
return ensure_bgr(np.asarray(frame.image_bgr))
|
||||||
|
|||||||
@@ -730,6 +730,8 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
self._end_reason: str | None = None
|
self._end_reason: str | None = None
|
||||||
self._phase: str = "idle"
|
self._phase: str = "idle"
|
||||||
self._pending_calibration: str | None = None
|
self._pending_calibration: str | None = None
|
||||||
|
#: Path for the **next** ``start()`` (overrides the bundle default when set).
|
||||||
|
self._session_cal_path: str | None = None
|
||||||
#: A zeroing requested from another thread, applied by this one.
|
#: A zeroing requested from another thread, applied by this one.
|
||||||
self._pending_reset: bool = False
|
self._pending_reset: bool = False
|
||||||
#: Monotonic origin for ``SdkPacket.mono_t``, reset per session.
|
#: Monotonic origin for ``SdkPacket.mono_t``, reset per session.
|
||||||
@@ -820,9 +822,12 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
video_source: str | int,
|
video_source: str | int,
|
||||||
*,
|
*,
|
||||||
config_ref: str | Path,
|
config_ref: str | Path,
|
||||||
|
cal_path: str | Path | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
) -> FixedCameraMode:
|
) -> FixedCameraMode:
|
||||||
# Non-blocking: a previous session that is stuck opening the device must
|
# Non-blocking: a previous session that is stuck opening the device must
|
||||||
# never hold the caller (the GUI thread) hostage.
|
# never hold the caller (the GUI thread) hostage.
|
||||||
|
del device_id # box/mini open path has no Finger-style serial check
|
||||||
self.stop()
|
self.stop()
|
||||||
previous = self._thread
|
previous = self._thread
|
||||||
ok, detail = orisys_importable()
|
ok, detail = orisys_importable()
|
||||||
@@ -831,6 +836,9 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
mode = load_fixed_mode(config_ref)
|
mode = load_fixed_mode(config_ref)
|
||||||
self._mode = mode
|
self._mode = mode
|
||||||
self._video_source = video_source
|
self._video_source = video_source
|
||||||
|
self._session_cal_path = (
|
||||||
|
None if cal_path is None or not str(cal_path).strip() else str(cal_path)
|
||||||
|
)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._latest = None
|
self._latest = None
|
||||||
self._end_reason = None
|
self._end_reason = None
|
||||||
@@ -1087,12 +1095,14 @@ class SdkAcquisitionWorker(RecordSinkMixin):
|
|||||||
is_file = _is_file_source(source)
|
is_file = _is_file_source(source)
|
||||||
try:
|
try:
|
||||||
self._status(f"Opening Orisys Sensor ({mode.label()})…")
|
self._status(f"Opening Orisys Sensor ({mode.label()})…")
|
||||||
sensor = orisys.Sensor(
|
open_kw: dict = {
|
||||||
source,
|
"config_name": mode.config_ref,
|
||||||
config_name=mode.config_ref,
|
"verbose": False,
|
||||||
verbose=False,
|
"backend": "auto",
|
||||||
backend="auto",
|
}
|
||||||
)
|
if self._session_cal_path is not None:
|
||||||
|
open_kw["cal_path"] = self._session_cal_path
|
||||||
|
sensor = orisys.Sensor(source, **open_kw)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._sensors[session] = sensor
|
self._sensors[session] = sensor
|
||||||
if self._stop.is_set() or session != self._session_id:
|
if self._stop.is_set() or session != self._session_id:
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ class BareVideoSetup:
|
|||||||
#: (the first frame it processes — which *is* a clean tracker, so the first frame is the
|
#: (the first frame it processes — which *is* a clean tracker, so the first frame is the
|
||||||
#: reference without a reset).
|
#: reference without a reset).
|
||||||
reference_frame: int | None = None
|
reference_frame: int | None = None
|
||||||
|
#: Serial the operator confirmed for ``calibration``. Empty when the pass uses the
|
||||||
|
#: bundle's own file or raw force.
|
||||||
|
device_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -346,6 +349,7 @@ def session_document(
|
|||||||
reference_frame: int | None,
|
reference_frame: int | None,
|
||||||
identity: str,
|
identity: str,
|
||||||
source_facts: dict[str, Any],
|
source_facts: dict[str, Any],
|
||||||
|
device_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""The ``session.json`` a bare video is analysed under.
|
"""The ``session.json`` a bare video is analysed under.
|
||||||
|
|
||||||
@@ -388,6 +392,7 @@ def session_document(
|
|||||||
"profile_ref": str(profile.ref),
|
"profile_ref": str(profile.ref),
|
||||||
"profile_id": str(profile.profile_id or ""),
|
"profile_id": str(profile.profile_id or ""),
|
||||||
"calibration": "" if calibration is None else str(Path(calibration).resolve()),
|
"calibration": "" if calibration is None else str(Path(calibration).resolve()),
|
||||||
|
"device_id": str(device_id or ""),
|
||||||
"reference_frame": None
|
"reference_frame": None
|
||||||
if reference_frame is None
|
if reference_frame is None
|
||||||
else int(reference_frame),
|
else int(reference_frame),
|
||||||
@@ -511,6 +516,7 @@ def build_workspace(
|
|||||||
identity, source_facts = video_identity(resolved)
|
identity, source_facts = video_identity(resolved)
|
||||||
calibration = None if setup is None else setup.calibration
|
calibration = None if setup is None else setup.calibration
|
||||||
reference_frame = None if setup is None else setup.reference_frame
|
reference_frame = None if setup is None else setup.reference_frame
|
||||||
|
device_id = None if setup is None else setup.device_id
|
||||||
if calibration is not None and not Path(calibration).is_file():
|
if calibration is not None and not Path(calibration).is_file():
|
||||||
raise BareVideoError(f"the chosen calibration {calibration} is not there")
|
raise BareVideoError(f"the chosen calibration {calibration} is not there")
|
||||||
frames = synthetic_frame_index(facts)
|
frames = synthetic_frame_index(facts)
|
||||||
@@ -522,6 +528,7 @@ def build_workspace(
|
|||||||
reference_frame=reference_frame,
|
reference_frame=reference_frame,
|
||||||
identity=identity,
|
identity=identity,
|
||||||
source_facts=source_facts,
|
source_facts=source_facts,
|
||||||
|
device_id=device_id,
|
||||||
)
|
)
|
||||||
directory = workspace_directory(identity, root=root)
|
directory = workspace_directory(identity, root=root)
|
||||||
directory.mkdir(parents=True, exist_ok=True)
|
directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
@@ -167,14 +167,18 @@ class ChainSpec:
|
|||||||
export_dir: Path | None = None
|
export_dir: Path | None = None
|
||||||
flip_source_vertical: bool = True
|
flip_source_vertical: bool = True
|
||||||
mirror: bool = False
|
mirror: bool = False
|
||||||
#: An explicit ``force_calibration.json`` for the box chain, or ``None`` for whatever
|
#: An explicit force-calibration file, or ``None`` for whatever the configuration's
|
||||||
#: the configuration's own bundle resolves. It is *not* part of :meth:`identity`,
|
#: own bundle resolves. It is *not* part of :meth:`identity`, deliberately: a recording
|
||||||
#: deliberately: a recording was taken under one calibration and the identity of *what
|
#: was taken under one calibration and the identity of *what it is* must not move when
|
||||||
#: it is* must not move when a different curve is tried against it. The calibration
|
#: a different curve is tried against it. The calibration half of the invalidation key
|
||||||
#: half of the invalidation key is the recording's own ``calibration_id`` (see
|
#: is the recording's own ``calibration_id`` (see
|
||||||
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
|
#: :meth:`AnalysisService.dependency_report`), which the generated workspace sets from
|
||||||
#: the chosen file's content.
|
#: the chosen file's content.
|
||||||
calibration: Path | None = None
|
calibration: Path | None = None
|
||||||
|
#: Device serial the operator confirmed for ``calibration``. Passed with ``cal_path``
|
||||||
|
#: so the SDK can reject a file whose ``sn`` is a different sensor. Not part of
|
||||||
|
#: :meth:`identity`: a mismatch fails the open, and a match does not change the file.
|
||||||
|
device_id: str | None = None
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
if self.sensor_type not in ("finger", "box"):
|
if self.sensor_type not in ("finger", "box"):
|
||||||
@@ -210,6 +214,7 @@ def chain_spec_for(
|
|||||||
config_ref: str | Path | None = None,
|
config_ref: str | Path | None = None,
|
||||||
export_dir: str | Path | None = None,
|
export_dir: str | Path | None = None,
|
||||||
calibration: str | Path | None = None,
|
calibration: str | Path | None = None,
|
||||||
|
device_id: str | None = None,
|
||||||
) -> ChainSpec:
|
) -> ChainSpec:
|
||||||
"""The chain a recording was made with, unless the caller names a different one.
|
"""The chain a recording was made with, unless the caller names a different one.
|
||||||
|
|
||||||
@@ -234,11 +239,13 @@ def chain_spec_for(
|
|||||||
bundle = Path(str(export_dir))
|
bundle = Path(str(export_dir))
|
||||||
elif kind == "finger" and resolved is not None:
|
elif kind == "finger" and resolved is not None:
|
||||||
bundle = resolve_finger_export_dir(resolved)
|
bundle = resolve_finger_export_dir(resolved)
|
||||||
|
serial = str(device_id or "").strip() or None
|
||||||
return ChainSpec(
|
return ChainSpec(
|
||||||
sensor_type=kind,
|
sensor_type=kind,
|
||||||
config_ref=resolved,
|
config_ref=resolved,
|
||||||
export_dir=bundle,
|
export_dir=bundle,
|
||||||
calibration=None if calibration is None else Path(str(calibration)),
|
calibration=None if calibration is None else Path(str(calibration)),
|
||||||
|
device_id=serial,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -296,15 +303,6 @@ class _OpenChain:
|
|||||||
if self.spec.sensor_type == "finger":
|
if self.spec.sensor_type == "finger":
|
||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
if self.spec.calibration is not None:
|
|
||||||
# The finger runtime opens its ``Sensor`` without a calibration path,
|
|
||||||
# so a chosen curve cannot be applied to this chain. Refusing is the
|
|
||||||
# only honest answer: a pass that ran anyway would store numbers from a
|
|
||||||
# different calibration than the one the caller asked for.
|
|
||||||
raise RuntimeError(
|
|
||||||
"the finger chain cannot take a chosen force calibration; it uses "
|
|
||||||
"the one the bundle resolves"
|
|
||||||
)
|
|
||||||
kwargs: dict[str, Any] = {
|
kwargs: dict[str, Any] = {
|
||||||
"video": str(self.video),
|
"video": str(self.video),
|
||||||
"verbose": False,
|
"verbose": False,
|
||||||
@@ -315,6 +313,13 @@ class _OpenChain:
|
|||||||
kwargs["config"] = str(self.spec.config_ref)
|
kwargs["config"] = str(self.spec.config_ref)
|
||||||
if self.spec.export_dir is not None:
|
if self.spec.export_dir is not None:
|
||||||
kwargs["export_dir"] = str(self.spec.export_dir)
|
kwargs["export_dir"] = str(self.spec.export_dir)
|
||||||
|
if self.spec.calibration is not None:
|
||||||
|
# ``cal_path`` is the file the operator chose. ``device_id`` is the
|
||||||
|
# serial they confirmed for this video; the SDK rejects the file when
|
||||||
|
# its ``sn`` is a different sensor.
|
||||||
|
kwargs["cal_path"] = str(self.spec.calibration)
|
||||||
|
if self.spec.device_id:
|
||||||
|
kwargs["device_id"] = str(self.spec.device_id)
|
||||||
self._runtime = FingerRuntime.open(**kwargs)
|
self._runtime = FingerRuntime.open(**kwargs)
|
||||||
self.sensor = self._runtime.tactile
|
self.sensor = self._runtime.tactile
|
||||||
self._read = self._runtime.read_source
|
self._read = self._runtime.read_source
|
||||||
|
|||||||
@@ -282,11 +282,10 @@ class SceneComposer:
|
|||||||
frame.flow_valid_mask,
|
frame.flow_valid_mask,
|
||||||
policy,
|
policy,
|
||||||
)
|
)
|
||||||
# The selector returns every candidate location plus an ``active`` flag;
|
# Inactive candidates are grey points; active ones are coloured arrows.
|
||||||
# this figure draws the active ones, which is what it drew before the
|
# Same samples the 2D canvas paints, including the quiet ones.
|
||||||
# split (the selector used to filter and this figure drew the lot).
|
|
||||||
flow_arrows, extra = build_flow_overlay(
|
flow_arrows, extra = build_flow_overlay(
|
||||||
samples.active_only(),
|
samples,
|
||||||
mapping,
|
mapping,
|
||||||
style=policy,
|
style=policy,
|
||||||
normal_offset_m=mesh_span_from_mapping(mapping) * 0.002,
|
normal_offset_m=mesh_span_from_mapping(mapping) * 0.002,
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ from orisys_app_shared.scene3d.contracts import (
|
|||||||
error,
|
error,
|
||||||
freeze,
|
freeze,
|
||||||
)
|
)
|
||||||
|
from orisys_app_shared.acquisition.arrows import (
|
||||||
|
FLOW_IDLE_HEX,
|
||||||
|
FLOW_MAGNITUDE_RANGE,
|
||||||
|
hex_to_bgr,
|
||||||
|
magnitude_to_ramp_index,
|
||||||
|
ramp_for_theme,
|
||||||
|
ramp_rgba_table,
|
||||||
|
)
|
||||||
from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy
|
from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy
|
||||||
|
|
||||||
ForceStrengthMode = Literal["log", "power", "linear"]
|
ForceStrengthMode = Literal["log", "power", "linear"]
|
||||||
@@ -43,8 +51,11 @@ class ContactArrowStyle:
|
|||||||
|
|
||||||
DEFAULT_CONTACT_ARROW_STYLE = ContactArrowStyle()
|
DEFAULT_CONTACT_ARROW_STYLE = ContactArrowStyle()
|
||||||
|
|
||||||
FLOW_TAIL_RGBA = (38 / 255.0, 235 / 255.0, 89 / 255.0, 0.85)
|
|
||||||
FLOW_HEAD_RGBA = (13 / 255.0, 115 / 255.0, 31 / 255.0, 0.95)
|
def _idle_rgba() -> np.ndarray:
|
||||||
|
"""The 2D idle-dot tint, as linear RGBA. Same hex, so a quiet sample matches."""
|
||||||
|
blue, green, red = hex_to_bgr(FLOW_IDLE_HEX)
|
||||||
|
return np.asarray((red, green, blue, 230), dtype=np.float32) / 255.0
|
||||||
|
|
||||||
|
|
||||||
def _empty_arrows(kind: str) -> ArrowInstances:
|
def _empty_arrows(kind: str) -> ArrowInstances:
|
||||||
@@ -190,6 +201,75 @@ def flow_arrow_shaft_radius_m(
|
|||||||
return float(np.clip(radius, span * low, span * high))
|
return float(np.clip(radius, span * low, span * high))
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_arrows(first: ArrowInstances, second: ArrowInstances) -> ArrowInstances:
|
||||||
|
"""One flow batch: coloured arrows, then the grey idle points."""
|
||||||
|
if int(first.origins.shape[0]) == 0:
|
||||||
|
return second
|
||||||
|
if int(second.origins.shape[0]) == 0:
|
||||||
|
return first
|
||||||
|
return ArrowInstances(
|
||||||
|
origins=freeze(np.vstack([first.origins, second.origins]), dtype=np.float32),
|
||||||
|
directions=freeze(
|
||||||
|
np.vstack([first.directions, second.directions]), dtype=np.float32
|
||||||
|
),
|
||||||
|
lengths=freeze(np.concatenate([first.lengths, second.lengths]), dtype=np.float32),
|
||||||
|
colors=freeze(np.vstack([first.colors, second.colors]), dtype=np.float32),
|
||||||
|
kind="flow",
|
||||||
|
shaft_radius_m=float(first.shaft_radius_m or second.shaft_radius_m),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _idle_flow_points(
|
||||||
|
samples: FlowSamples,
|
||||||
|
mapping: SurfaceMapping,
|
||||||
|
*,
|
||||||
|
normal_offset_m: float,
|
||||||
|
) -> ArrowInstances:
|
||||||
|
"""A fixed grey point at every candidate the threshold has not turned on.
|
||||||
|
|
||||||
|
A negative length is the mesh builder's mark for that point (see
|
||||||
|
:func:`arrow_instances_to_mesh`). The colour is the 2D idle dot, not the ramp.
|
||||||
|
"""
|
||||||
|
active = np.asarray(samples.active, dtype=bool)
|
||||||
|
if active.shape != samples.rows.shape or not np.any(~active):
|
||||||
|
return _empty_arrows("flow")
|
||||||
|
rows = np.asarray(samples.rows, dtype=np.int32)[~active]
|
||||||
|
cols = np.asarray(samples.cols, dtype=np.int32)[~active]
|
||||||
|
h, w = mapping.shape_hw
|
||||||
|
inside = (rows >= 0) & (rows < h) & (cols >= 0) & (cols < w)
|
||||||
|
if not np.any(inside):
|
||||||
|
return _empty_arrows("flow")
|
||||||
|
rows = rows[inside]
|
||||||
|
cols = cols[inside]
|
||||||
|
valid = np.asarray(mapping.valid, dtype=bool)
|
||||||
|
keep = valid[rows, cols]
|
||||||
|
if not np.any(keep):
|
||||||
|
return _empty_arrows("flow")
|
||||||
|
rows = rows[keep]
|
||||||
|
cols = cols[keep]
|
||||||
|
positions = np.asarray(mapping.positions_sensor_m, dtype=np.float64)
|
||||||
|
normals = np.asarray(mapping.normals_sensor, dtype=np.float64)
|
||||||
|
origins = positions[rows, cols].copy()
|
||||||
|
directions = normals[rows, cols].copy()
|
||||||
|
norms = np.linalg.norm(directions, axis=1)
|
||||||
|
bad = norms < 1e-8
|
||||||
|
directions[bad] = (0.0, 0.0, 1.0)
|
||||||
|
norms = np.where(bad, 1.0, norms)
|
||||||
|
directions = directions / norms[:, None]
|
||||||
|
if normal_offset_m:
|
||||||
|
origins = origins + normal_offset_m * directions
|
||||||
|
radius = flow_arrow_shaft_radius_m(mapping)
|
||||||
|
count = int(rows.size)
|
||||||
|
return ArrowInstances(
|
||||||
|
origins=freeze(origins, dtype=np.float32),
|
||||||
|
directions=freeze(directions, dtype=np.float32),
|
||||||
|
lengths=freeze(np.full(count, -radius, dtype=np.float32), dtype=np.float32),
|
||||||
|
colors=freeze(np.tile(_idle_rgba(), (count, 1)), dtype=np.float32),
|
||||||
|
kind="flow",
|
||||||
|
shaft_radius_m=radius,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def build_flow_overlay(
|
def build_flow_overlay(
|
||||||
samples: FlowSamples,
|
samples: FlowSamples,
|
||||||
mapping: SurfaceMapping,
|
mapping: SurfaceMapping,
|
||||||
@@ -197,7 +277,31 @@ def build_flow_overlay(
|
|||||||
style: VisualPolicy | None = None,
|
style: VisualPolicy | None = None,
|
||||||
normal_offset_m: float = 0.0,
|
normal_offset_m: float = 0.0,
|
||||||
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
|
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
|
||||||
"""Map shared UV flow samples onto the sensing surface."""
|
"""Map shared UV flow samples onto the sensing surface.
|
||||||
|
|
||||||
|
Inactive candidates are grey points. Active ones are arrows coloured by the
|
||||||
|
same magnitude ramp the 2D canvas uses.
|
||||||
|
"""
|
||||||
|
points = _idle_flow_points(
|
||||||
|
samples, mapping, normal_offset_m=normal_offset_m
|
||||||
|
)
|
||||||
|
arrows, diags = _active_flow_overlay(
|
||||||
|
samples.active_only(),
|
||||||
|
mapping,
|
||||||
|
style=style,
|
||||||
|
normal_offset_m=normal_offset_m,
|
||||||
|
)
|
||||||
|
return _merge_arrows(arrows, points), diags
|
||||||
|
|
||||||
|
|
||||||
|
def _active_flow_overlay(
|
||||||
|
samples: FlowSamples,
|
||||||
|
mapping: SurfaceMapping,
|
||||||
|
*,
|
||||||
|
style: VisualPolicy | None = None,
|
||||||
|
normal_offset_m: float = 0.0,
|
||||||
|
) -> tuple[ArrowInstances, tuple[Diagnostic, ...]]:
|
||||||
|
"""Arrows for samples that cleared the flow threshold."""
|
||||||
del style # lengths come from UV tip deltas projected into scene metres
|
del style # lengths come from UV tip deltas projected into scene metres
|
||||||
if samples.rows.size == 0:
|
if samples.rows.size == 0:
|
||||||
return _empty_arrows("flow"), ()
|
return _empty_arrows("flow"), ()
|
||||||
@@ -216,6 +320,7 @@ def build_flow_overlay(
|
|||||||
|
|
||||||
rows = np.asarray(samples.rows, dtype=np.int32)
|
rows = np.asarray(samples.rows, dtype=np.int32)
|
||||||
cols = np.asarray(samples.cols, dtype=np.int32)
|
cols = np.asarray(samples.cols, dtype=np.int32)
|
||||||
|
magnitude = np.asarray(samples.magnitude, dtype=np.float64)
|
||||||
end_x = np.clip(np.round(cols + samples.dx).astype(np.int32), 0, w - 1)
|
end_x = np.clip(np.round(cols + samples.dx).astype(np.int32), 0, w - 1)
|
||||||
end_y = np.clip(np.round(rows + samples.dy).astype(np.int32), 0, h - 1)
|
end_y = np.clip(np.round(rows + samples.dy).astype(np.int32), 0, h - 1)
|
||||||
|
|
||||||
@@ -232,6 +337,7 @@ def build_flow_overlay(
|
|||||||
|
|
||||||
rows = rows[inside]
|
rows = rows[inside]
|
||||||
cols = cols[inside]
|
cols = cols[inside]
|
||||||
|
magnitude = magnitude[inside]
|
||||||
end_x = end_x[inside]
|
end_x = end_x[inside]
|
||||||
end_y = end_y[inside]
|
end_y = end_y[inside]
|
||||||
origins = positions[rows, cols].copy()
|
origins = positions[rows, cols].copy()
|
||||||
@@ -248,13 +354,15 @@ def build_flow_overlay(
|
|||||||
origins = origins[keep]
|
origins = origins[keep]
|
||||||
vectors = vectors[keep]
|
vectors = vectors[keep]
|
||||||
lengths = lengths[keep]
|
lengths = lengths[keep]
|
||||||
|
magnitude = magnitude[keep]
|
||||||
directions = vectors / lengths[:, None]
|
directions = vectors / lengths[:, None]
|
||||||
|
|
||||||
colors = np.tile(np.asarray(FLOW_TAIL_RGBA, dtype=np.float32), (len(origins), 1))
|
# Same ramp index the 2D canvas asks for, so an active arrow is the same colour
|
||||||
# Stronger arrows tip toward the head colour.
|
# in both views. Quiet samples never reach here: they are the grey points.
|
||||||
t = np.clip(lengths / max(float(np.percentile(lengths, 95)), 1e-9), 0.0, 1.0)
|
index = magnitude_to_ramp_index(magnitude, value_range=FLOW_MAGNITUDE_RANGE)
|
||||||
head = np.asarray(FLOW_HEAD_RGBA, dtype=np.float32)
|
table = ramp_rgba_table(ramp_for_theme("dark")).reshape(-1, 4).astype(np.float32)
|
||||||
colors = colors * (1.0 - t)[:, None] + head * t[:, None]
|
colors = table[index] / 255.0
|
||||||
|
colors[:, 3] = 0.9
|
||||||
|
|
||||||
return (
|
return (
|
||||||
ArrowInstances(
|
ArrowInstances(
|
||||||
@@ -499,6 +607,35 @@ def _cylinder_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _point_template() -> tuple[np.ndarray, np.ndarray]:
|
||||||
|
"""Unit octahedron. A negative arrow length asks the mesh builder for one of these."""
|
||||||
|
vertices = np.asarray(
|
||||||
|
[
|
||||||
|
[1.0, 0.0, 0.0],
|
||||||
|
[-1.0, 0.0, 0.0],
|
||||||
|
[0.0, 1.0, 0.0],
|
||||||
|
[0.0, -1.0, 0.0],
|
||||||
|
[0.0, 0.0, 1.0],
|
||||||
|
[0.0, 0.0, -1.0],
|
||||||
|
],
|
||||||
|
dtype=np.float64,
|
||||||
|
)
|
||||||
|
triangles = np.asarray(
|
||||||
|
[
|
||||||
|
[0, 2, 4],
|
||||||
|
[2, 1, 4],
|
||||||
|
[1, 3, 4],
|
||||||
|
[3, 0, 4],
|
||||||
|
[2, 0, 5],
|
||||||
|
[1, 2, 5],
|
||||||
|
[3, 1, 5],
|
||||||
|
[0, 3, 5],
|
||||||
|
],
|
||||||
|
dtype=np.int32,
|
||||||
|
)
|
||||||
|
return vertices, triangles
|
||||||
|
|
||||||
|
|
||||||
def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
|
def _cone_template(resolution: int = 8) -> tuple[np.ndarray, np.ndarray]:
|
||||||
"""Unit-radius cone with apex at z=1 and base at z=0."""
|
"""Unit-radius cone with apex at z=1 and base at z=0."""
|
||||||
angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False)
|
angles = np.linspace(0.0, 2.0 * np.pi, resolution, endpoint=False)
|
||||||
@@ -534,6 +671,7 @@ def arrow_instances_to_mesh(
|
|||||||
|
|
||||||
cyl_v, cyl_t = _cylinder_template()
|
cyl_v, cyl_t = _cylinder_template()
|
||||||
cone_v, cone_t = _cone_template()
|
cone_v, cone_t = _cone_template()
|
||||||
|
point_v, point_t = _point_template()
|
||||||
origins = np.asarray(arrows.origins, dtype=np.float64)
|
origins = np.asarray(arrows.origins, dtype=np.float64)
|
||||||
directions = np.asarray(arrows.directions, dtype=np.float64)
|
directions = np.asarray(arrows.directions, dtype=np.float64)
|
||||||
lengths = np.asarray(arrows.lengths, dtype=np.float64)
|
lengths = np.asarray(arrows.lengths, dtype=np.float64)
|
||||||
@@ -544,7 +682,8 @@ def arrow_instances_to_mesh(
|
|||||||
else np.ones(count, dtype=np.float64)
|
else np.ones(count, dtype=np.float64)
|
||||||
)
|
)
|
||||||
|
|
||||||
mean_length = float(np.mean(lengths)) if count else 1.0
|
positive = lengths[lengths > 0.0]
|
||||||
|
mean_length = float(np.mean(positive)) if positive.size else 1.0
|
||||||
# An explicit stroke radius wins over the proportional one: see
|
# An explicit stroke radius wins over the proportional one: see
|
||||||
# ``flow_arrow_shaft_radius_m`` for why a flow arrow cannot use a fraction of its
|
# ``flow_arrow_shaft_radius_m`` for why a flow arrow cannot use a fraction of its
|
||||||
# own length. The contact arrow keeps the proportional template.
|
# own length. The contact arrow keeps the proportional template.
|
||||||
@@ -561,11 +700,20 @@ def arrow_instances_to_mesh(
|
|||||||
offset = 0
|
offset = 0
|
||||||
for i in range(count):
|
for i in range(count):
|
||||||
length = float(lengths[i])
|
length = float(lengths[i])
|
||||||
|
origin = origins[i]
|
||||||
|
radius = base_radius * float(max(thickness[i], 0.05))
|
||||||
|
if length < 0.0:
|
||||||
|
# Idle sample: a fixed grey point, not an arrow. ``length`` is unused;
|
||||||
|
# the radius is the same stroke the active arrows use.
|
||||||
|
verts = point_v * radius + origin
|
||||||
|
parts_v.append(verts)
|
||||||
|
parts_t.append(point_t + offset)
|
||||||
|
parts_c.append(np.tile(colors[i], (len(verts), 1)))
|
||||||
|
offset += len(verts)
|
||||||
|
continue
|
||||||
if length <= 1e-9:
|
if length <= 1e-9:
|
||||||
continue
|
continue
|
||||||
direction = directions[i]
|
direction = directions[i]
|
||||||
origin = origins[i]
|
|
||||||
radius = base_radius * float(max(thickness[i], 0.05))
|
|
||||||
rotation = _rotation_matrix_z_to(direction)
|
rotation = _rotation_matrix_z_to(direction)
|
||||||
# Tail: 75% cylinder, head: 25% cone.
|
# Tail: 75% cylinder, head: 25% cone.
|
||||||
tail_len = length * 0.75
|
tail_len = length * 0.75
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""A chosen Finger calibration is handed to the runtime, not refused before it opens."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from orisys_app_shared.analysis.service import ChainSpec, _OpenChain
|
||||||
|
|
||||||
|
|
||||||
|
class _Runtime:
|
||||||
|
tactile = object()
|
||||||
|
capture = None
|
||||||
|
opened: dict = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, **kwargs):
|
||||||
|
cls.opened = dict(kwargs)
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def read_source(self):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def process_frame(self, frame):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_chain_passes_the_chosen_file_and_device(monkeypatch, tmp_path):
|
||||||
|
import orisys.finger as finger
|
||||||
|
|
||||||
|
monkeypatch.setattr(finger, "FingerRuntime", _Runtime)
|
||||||
|
_Runtime.opened = {}
|
||||||
|
calibration = tmp_path / "DD02-14.json"
|
||||||
|
calibration.write_text("{}", encoding="utf-8")
|
||||||
|
video = tmp_path / "clip.mp4"
|
||||||
|
video.write_bytes(b"")
|
||||||
|
spec = ChainSpec(
|
||||||
|
sensor_type="finger",
|
||||||
|
calibration=calibration,
|
||||||
|
device_id="DD02-14",
|
||||||
|
)
|
||||||
|
_OpenChain(spec, video, tmp_path).open()
|
||||||
|
assert _Runtime.opened["cal_path"] == str(calibration)
|
||||||
|
assert _Runtime.opened["device_id"] == "DD02-14"
|
||||||
|
assert _Runtime.opened["video"] == str(video)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_finger_chain_without_a_file_does_not_invent_one(monkeypatch, tmp_path):
|
||||||
|
import orisys.finger as finger
|
||||||
|
|
||||||
|
monkeypatch.setattr(finger, "FingerRuntime", _Runtime)
|
||||||
|
_Runtime.opened = {}
|
||||||
|
video = tmp_path / "clip.mp4"
|
||||||
|
video.write_bytes(b"")
|
||||||
|
spec = ChainSpec(sensor_type="finger")
|
||||||
|
_OpenChain(spec, video, tmp_path).open()
|
||||||
|
assert "cal_path" not in _Runtime.opened
|
||||||
|
assert "device_id" not in _Runtime.opened
|
||||||
@@ -36,12 +36,22 @@ def test_a_live_frame_offers_the_derived_modes_and_needs_no_note():
|
|||||||
plan = backend.plan_image(
|
plan = backend.plan_image(
|
||||||
backend.ImageFrame(_image(), flow=_flow(), depth=np.ones((12, 16)))
|
backend.ImageFrame(_image(), flow=_flow(), depth=np.ones((12, 16)))
|
||||||
)
|
)
|
||||||
assert plan.available_modes == ("arrows_pure", "arrows_stitched", "depth")
|
assert plan.available_modes == ("arrows_pure", "arrows_stitched", "depth", "depth_arrows")
|
||||||
assert plan.default_mode == "arrows_pure"
|
assert plan.default_mode == "arrows_pure"
|
||||||
assert plan.missing == ()
|
assert plan.missing == ()
|
||||||
assert plan.note_key == ""
|
assert plan.note_key == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_depth_arrows_draws_the_flow_on_the_depth_picture():
|
||||||
|
depth = np.linspace(0.0, 1.0, 12 * 16, dtype=np.float32).reshape(12, 16)
|
||||||
|
frame = backend.ImageFrame(_image(), flow=_flow(), depth=depth)
|
||||||
|
plain = backend.render_image(frame, mode="depth", draw_style="finger")
|
||||||
|
over = backend.render_image(frame, mode="depth_arrows", draw_style="finger")
|
||||||
|
assert plain is not None and over is not None
|
||||||
|
assert over.shape == plain.shape
|
||||||
|
assert not np.array_equal(over, plain)
|
||||||
|
|
||||||
|
|
||||||
def test_a_recorded_frame_is_raw_and_says_so():
|
def test_a_recorded_frame_is_raw_and_says_so():
|
||||||
"""No flow and no depth: the frame *is* the picture, and the note explains it."""
|
"""No flow and no depth: the frame *is* the picture, and the note explains it."""
|
||||||
plan = backend.plan_image(backend.ImageFrame(_image(), source=backend.SOURCE_REPLAY))
|
plan = backend.plan_image(backend.ImageFrame(_image(), source=backend.SOURCE_REPLAY))
|
||||||
@@ -723,9 +733,12 @@ def test_hiding_the_idle_dots_leaves_the_3d_figure_where_it_was():
|
|||||||
f"{int(active.sum())} active of {samples.rows.size} candidates"
|
f"{int(active.sum())} active of {samples.rows.size} candidates"
|
||||||
)
|
)
|
||||||
assert overlay_on is not None and overlay_off is not None
|
assert overlay_on is not None and overlay_off is not None
|
||||||
assert overlay_on.origins.shape[0] == int(active.sum()), (
|
assert int(np.count_nonzero(overlay_on.lengths > 0.0)) == int(active.sum()), (
|
||||||
"the 3D overlay is not drawing the active samples the 2D canvas draws"
|
"the 3D overlay is not drawing the active samples the 2D canvas draws"
|
||||||
)
|
)
|
||||||
|
assert int(np.count_nonzero(overlay_on.lengths < 0.0)) > 0, (
|
||||||
|
"inactive samples are not drawn as grey points"
|
||||||
|
)
|
||||||
assert np.array_equal(overlay_on.origins, overlay_off.origins)
|
assert np.array_equal(overlay_on.origins, overlay_off.origins)
|
||||||
assert np.array_equal(overlay_on.directions, overlay_off.directions)
|
assert np.array_equal(overlay_on.directions, overlay_off.directions)
|
||||||
assert not np.array_equal(dots_on, dots_off), "the switch changed nothing to hide"
|
assert not np.array_equal(dots_on, dots_off), "the switch changed nothing to hide"
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
"""Live workers pass the operator's cal_path into FingerRuntime / Sensor.open."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
APP_ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
BOX_CONFIG = APP_ROOT / "apps" / "studio" / "config" / "sensors" / "box" / "sensor.json"
|
||||||
|
|
||||||
|
|
||||||
|
class _TrackingSensor:
|
||||||
|
opened: dict = {}
|
||||||
|
|
||||||
|
def __init__(self, source, **kwargs):
|
||||||
|
type(self).opened = {"source": source, **kwargs}
|
||||||
|
self.connected = True
|
||||||
|
|
||||||
|
def configure(self, **kwargs):
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def get_img(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
|
||||||
|
class _TrackingRuntime:
|
||||||
|
opened: dict = {}
|
||||||
|
tactile = types.SimpleNamespace(raw_sensor=None)
|
||||||
|
capture = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def open(cls, **kwargs):
|
||||||
|
cls.opened = dict(kwargs)
|
||||||
|
return cls()
|
||||||
|
|
||||||
|
def warmup(self, *, color=False):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _install_fake_orisys(*, sensor_cls=None, runtime_cls=None):
|
||||||
|
saved = {name: sys.modules.get(name) for name in ("orisys", "orisys.finger")}
|
||||||
|
module = types.ModuleType("orisys")
|
||||||
|
module.__version__ = "fake"
|
||||||
|
module.Sensor = sensor_cls or _TrackingSensor
|
||||||
|
finger = types.ModuleType("orisys.finger")
|
||||||
|
finger.FingerRuntime = runtime_cls or _TrackingRuntime
|
||||||
|
module.finger = finger
|
||||||
|
sys.modules["orisys"] = module
|
||||||
|
sys.modules["orisys.finger"] = finger
|
||||||
|
return saved
|
||||||
|
|
||||||
|
|
||||||
|
def _restore(saved) -> None:
|
||||||
|
for name, module in saved.items():
|
||||||
|
if module is None:
|
||||||
|
sys.modules.pop(name, None)
|
||||||
|
else:
|
||||||
|
sys.modules[name] = module
|
||||||
|
|
||||||
|
|
||||||
|
def _mode(config: Path, label: str = "fixture"):
|
||||||
|
return types.SimpleNamespace(
|
||||||
|
config_ref=str(config),
|
||||||
|
label=lambda: label,
|
||||||
|
fps=30.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sdk_worker_open_passes_cal_path(monkeypatch, tmp_path):
|
||||||
|
from orisys_app_shared.acquisition import sdk_worker as mod
|
||||||
|
|
||||||
|
saved = _install_fake_orisys(sensor_cls=_TrackingSensor)
|
||||||
|
_TrackingSensor.opened = {}
|
||||||
|
config = BOX_CONFIG if BOX_CONFIG.is_file() else tmp_path / "sensor.json"
|
||||||
|
if not config.is_file():
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
cal = tmp_path / "force_calibration.json"
|
||||||
|
cal.write_text("{}", encoding="utf-8")
|
||||||
|
mode = _mode(config, "box-fixture")
|
||||||
|
try:
|
||||||
|
monkeypatch.setattr(mod, "orisys_importable", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(mod, "loaded_calibration_version", lambda _s: "")
|
||||||
|
monkeypatch.setattr(mod, "loaded_path", lambda _s: None)
|
||||||
|
monkeypatch.setattr(mod, "load_fixed_mode", lambda _ref: mode)
|
||||||
|
worker = mod.SdkAcquisitionWorker()
|
||||||
|
# Set the session fields the open path reads — do not start a thread.
|
||||||
|
worker._mode = mode
|
||||||
|
worker._video_source = 0
|
||||||
|
worker._session_id = 1
|
||||||
|
worker._session_cal_path = str(cal)
|
||||||
|
worker._stop.clear()
|
||||||
|
opened = worker._open_session(1, None)
|
||||||
|
assert opened is not None
|
||||||
|
assert _TrackingSensor.opened.get("cal_path") == str(cal)
|
||||||
|
# ``start`` itself stores the path for that open.
|
||||||
|
worker2 = mod.SdkAcquisitionWorker()
|
||||||
|
monkeypatch.setattr(worker2, "stop", lambda: None)
|
||||||
|
monkeypatch.setattr(worker2, "_run", lambda *_a, **_k: None)
|
||||||
|
worker2.start(0, config_ref=config, cal_path=cal)
|
||||||
|
assert worker2._session_cal_path == str(cal)
|
||||||
|
finally:
|
||||||
|
_restore(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_finger_worker_open_passes_cal_path_and_device_id(monkeypatch, tmp_path):
|
||||||
|
from orisys_app_shared.acquisition import finger_worker as mod
|
||||||
|
|
||||||
|
saved = _install_fake_orisys(runtime_cls=_TrackingRuntime)
|
||||||
|
_TrackingRuntime.opened = {}
|
||||||
|
config = tmp_path / "sensor.json"
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
cal = tmp_path / "DD02-14.json"
|
||||||
|
cal.write_text("{}", encoding="utf-8")
|
||||||
|
mode = _mode(config, "finger-fixture")
|
||||||
|
try:
|
||||||
|
monkeypatch.setattr(mod, "orisys_importable", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(mod, "load_fixed_mode", lambda _ref: mode)
|
||||||
|
monkeypatch.setattr(mod, "resolve_finger_export_dir", lambda _ref: None)
|
||||||
|
monkeypatch.setattr(mod, "loaded_calibration_version", lambda _s: "")
|
||||||
|
monkeypatch.setattr(mod, "loaded_path", lambda _s: None)
|
||||||
|
import orisys.finger as finger_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(finger_mod, "FingerRuntime", _TrackingRuntime)
|
||||||
|
worker = mod.FingerAcquisitionWorker()
|
||||||
|
worker._mode = mode
|
||||||
|
worker._video_source = 0
|
||||||
|
worker._session_id = 1
|
||||||
|
worker._session_cal_path = str(cal)
|
||||||
|
worker._session_device_id = "DD02-14"
|
||||||
|
worker._export_dir = None
|
||||||
|
worker._stop.clear()
|
||||||
|
opened = worker._open_runtime(1, None)
|
||||||
|
assert opened is not None
|
||||||
|
assert _TrackingRuntime.opened.get("cal_path") == str(cal)
|
||||||
|
assert _TrackingRuntime.opened.get("device_id") == "DD02-14"
|
||||||
|
|
||||||
|
worker2 = mod.FingerAcquisitionWorker()
|
||||||
|
monkeypatch.setattr(worker2, "stop", lambda: None)
|
||||||
|
monkeypatch.setattr(worker2, "_run", lambda *_a, **_k: None)
|
||||||
|
worker2.start(
|
||||||
|
0, config_ref=config, cal_path=cal, device_id="DD02-14"
|
||||||
|
)
|
||||||
|
assert worker2._session_cal_path == str(cal)
|
||||||
|
assert worker2._session_device_id == "DD02-14"
|
||||||
|
finally:
|
||||||
|
_restore(saved)
|
||||||
|
|
||||||
|
|
||||||
|
def test_workers_omit_cal_path_when_none_is_chosen(monkeypatch, tmp_path):
|
||||||
|
from orisys_app_shared.acquisition import finger_worker as mod
|
||||||
|
|
||||||
|
saved = _install_fake_orisys(runtime_cls=_TrackingRuntime)
|
||||||
|
_TrackingRuntime.opened = {}
|
||||||
|
config = tmp_path / "sensor.json"
|
||||||
|
config.write_text("{}", encoding="utf-8")
|
||||||
|
mode = _mode(config, "finger-fixture")
|
||||||
|
try:
|
||||||
|
monkeypatch.setattr(mod, "orisys_importable", lambda: (True, ""))
|
||||||
|
monkeypatch.setattr(mod, "load_fixed_mode", lambda _ref: mode)
|
||||||
|
monkeypatch.setattr(mod, "resolve_finger_export_dir", lambda _ref: None)
|
||||||
|
monkeypatch.setattr(mod, "loaded_calibration_version", lambda _s: "")
|
||||||
|
monkeypatch.setattr(mod, "loaded_path", lambda _s: None)
|
||||||
|
import orisys.finger as finger_mod
|
||||||
|
|
||||||
|
monkeypatch.setattr(finger_mod, "FingerRuntime", _TrackingRuntime)
|
||||||
|
worker = mod.FingerAcquisitionWorker()
|
||||||
|
worker._mode = mode
|
||||||
|
worker._video_source = 0
|
||||||
|
worker._session_id = 1
|
||||||
|
worker._session_cal_path = None
|
||||||
|
worker._session_device_id = None
|
||||||
|
worker._export_dir = None
|
||||||
|
worker._stop.clear()
|
||||||
|
opened = worker._open_runtime(1, None)
|
||||||
|
assert opened is not None
|
||||||
|
assert "cal_path" not in _TrackingRuntime.opened
|
||||||
|
assert "device_id" not in _TrackingRuntime.opened
|
||||||
|
finally:
|
||||||
|
_restore(saved)
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
import pytest
|
||||||
from orisys_app_shared.scene3d.overlays import (
|
from orisys_app_shared.scene3d.overlays import (
|
||||||
arrow_instances_to_mesh,
|
arrow_instances_to_mesh,
|
||||||
build_flow_overlay,
|
build_flow_overlay,
|
||||||
@@ -10,7 +11,7 @@ from orisys_app_shared.scene3d.overlays import (
|
|||||||
force_visual_strength,
|
force_visual_strength,
|
||||||
)
|
)
|
||||||
from orisys_app_shared.scene3d.synthetic import poc_scene
|
from orisys_app_shared.scene3d.synthetic import poc_scene
|
||||||
from orisys_app_shared.scene3d.visual_policy import VisualPolicy, select_flow_samples
|
from orisys_app_shared.scene3d.visual_policy import FlowSamples, VisualPolicy, select_flow_samples
|
||||||
|
|
||||||
|
|
||||||
def test_force_visual_strength_is_capped() -> None:
|
def test_force_visual_strength_is_capped() -> None:
|
||||||
@@ -58,6 +59,30 @@ def test_flow_and_normal_overlays_on_poc_mapping() -> None:
|
|||||||
assert colors.shape[0] == positions.shape[0]
|
assert colors.shape[0] == positions.shape[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_inactive_sample_is_a_grey_point_and_an_active_one_is_an_arrow() -> None:
|
||||||
|
assets = poc_scene()
|
||||||
|
mapping = assets.surface_mapping
|
||||||
|
assert mapping is not None
|
||||||
|
samples = FlowSamples(
|
||||||
|
rows=np.asarray([1, 1], dtype=np.int32),
|
||||||
|
cols=np.asarray([1, 2], dtype=np.int32),
|
||||||
|
dx=np.asarray([0.8, 0.0]),
|
||||||
|
dy=np.asarray([0.0, 0.0]),
|
||||||
|
magnitude=np.asarray([1.6, 0.0]),
|
||||||
|
active=np.asarray([True, False]),
|
||||||
|
)
|
||||||
|
arrows, diags = build_flow_overlay(samples, mapping)
|
||||||
|
assert not diags
|
||||||
|
assert int(np.count_nonzero(arrows.lengths > 0.0)) == 1
|
||||||
|
assert int(np.count_nonzero(arrows.lengths < 0.0)) == 1
|
||||||
|
idle = arrows.colors[arrows.lengths < 0.0][0]
|
||||||
|
# Brand idle tint #8A93A6, not a ramp colour.
|
||||||
|
assert idle[0] == pytest.approx(0x8A / 255.0, abs=0.02)
|
||||||
|
assert idle[2] == pytest.approx(0xA6 / 255.0, abs=0.02)
|
||||||
|
positions, _triangles, colors = arrow_instances_to_mesh(arrows)
|
||||||
|
assert positions.shape[0] == colors.shape[0] > 0
|
||||||
|
|
||||||
|
|
||||||
def test_missing_contact_yields_no_arrow() -> None:
|
def test_missing_contact_yields_no_arrow() -> None:
|
||||||
assets = poc_scene()
|
assets = poc_scene()
|
||||||
mapping = assets.surface_mapping
|
mapping = assets.surface_mapping
|
||||||
|
|||||||
Reference in New Issue
Block a user