Compare commits

...

7 Commits

Author SHA1 Message Date
ykcheungab 091d0fda35 三维未激活采样显示灰点,二维增加深度叠箭头。
分析对话框默认使用传感器对话框保存的标定,关闭按钮改为保存。框选曲线时数值卡片仍显示光标读数。相关单测通过。
2026-10-08 17:04:30 +08:00
ykcheungab 3d2fb0c4eb 让直播标定文件从传感器对话框进入打开链路。
对话框可选配置默认、原始力或浏览文件,并按配置条目写入 prefs;点运行时把 cal_path(Finger 含 device_id)交给 worker,采集中则热更新。对话框与打开链路单测通过。
2026-10-08 16:37:26 +08:00
ykcheungab 19ae1f4968 三维显示与二维共用深度色标,配置名改为 Finger
箭头阈值写入设置;去掉网格告警。三维色标改为立刻采用二维量程(不再 P5/P95 缓变)。选择器显示 Finger。已用 pane UI、shared-scale 与 catalog 单测验证。
2026-10-08 16:10:58 +08:00
ykcheungab f2d7b3e83c 三维设置加箭头阈值,静默处理网格冲突
对话框写回 flow_on_px(二维三维共用),去掉 analysis_grid_mismatch 告警,缩短 UV/形变等工程腔文案并认 source=analysis。已用 pane UI 与 controller 单测验证。
2026-10-08 16:01:15 +08:00
ykcheungab f2bbab76a6 缩短 Studio 叠字与重复说明,色标只保留量程
去掉 depth_empty 与无用选区峰值文案,并修正「视图 → 分析视频」。已用 locale 单测与 depth_scale_text 核对;含证据录制的 wiring 用例本机跳过。
2026-10-08 15:41:30 +08:00
ykcheungab 48624dec99 让 Finger 视频分析使用所选的力标定文件。
分析链不再在打开前拒绝标定,而是把文件作为 cal_path 交给 FingerRuntime,并用操作者确认的设备编号核对文件里的 SN。标定传递测试与对话框 SN 校验通过。
2026-10-08 15:12:08 +08:00
ykcheungab 7afd3169fd 把 Studio 2D 画面与深度色标抽到 FrameView。
深度闩、域掩码与 _show_image_frame 离开主窗口;ImageSink 方法名保留为转发。朝向与深度闩测试、以及 monkeypatch render_image 的画面测试通过。
2026-10-08 10:39:40 +08:00
39 changed files with 2324 additions and 596 deletions
+43 -30
View File
@@ -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",
+43 -30
View File
@@ -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",
+1 -1
View File
@@ -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. |
+2 -2
View File
@@ -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" }
] ]
} }
], ],
+217 -219
View File
@@ -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
+279
View File
@@ -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,
+74 -21
View File
@@ -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(
+18 -84
View File
@@ -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}", duration=f"{stats.duration_s:.2f}",
samples=stats.samples, samples=stats.samples,
) )
) self.stats_label.setText(summary)
self.stats_label.setToolTip(self._selection_tooltip(stats)) self.stats_label.setToolTip(summary)
self._render_value_cards() self._readout_at_cursor()
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}",
samples=stats.samples,
detail=" ".join(parts),
)
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):
+276 -26
View File
@@ -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,13 +288,14 @@ 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(
@@ -247,6 +323,174 @@ class SensorDialog(QDialog):
) )
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()
+41 -5
View File
@@ -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")
+6 -6
View File
@@ -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)
+3 -3
View File
@@ -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.
+48 -2
View File
@@ -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()
+1 -1
View File
@@ -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",
} }
+2 -4
View File
@@ -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:
+23 -16
View File
@@ -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()
+18 -23
View File
@@ -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,
+159 -11
View File
@@ -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
+15 -2
View File
@@ -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"
+186
View File
@@ -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)
+26 -1
View File
@@ -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