added motion trajectory simulation

This commit is contained in:
ykcheungab
2026-09-23 09:58:00 +08:00
parent f1bec62ca4
commit 3347d17a20
25 changed files with 5198 additions and 1762 deletions
+229 -58
View File
@@ -25,6 +25,7 @@ from __future__ import annotations
import argparse
import math
import math
import os
import sys
from pathlib import Path
@@ -41,8 +42,16 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
from PySide6.QtCore import QPoint, QRect, Qt # noqa: E402
from PySide6.QtGui import QColor, QFont, QPainter, QPen, QPixmap, QPolygon # noqa: E402
from PySide6.QtCore import QPoint, QPointF, QRect, Qt # noqa: E402
from PySide6.QtGui import ( # noqa: E402
QColor,
QFont,
QPainter,
QPainterPath,
QPen,
QPixmap,
QPolygon,
)
from PySide6.QtWidgets import QApplication # noqa: E402
# ---------------------------------------------------------------- geometry ---
@@ -157,7 +166,6 @@ STRINGS = {
"finger_initial_view": "初始视角",
"finger_reset": "复位视角",
"finger_textured_note": "UV 贴图:patch_texture_compute.png",
"finger_axes_note": "黄色为工具轴 · X/Y 为工作台,Z 为工具行程 · 绿=对齐,红=不可对齐",
"finger_bundle_summary": "1032 顶点 · 1974 三角面 · 15.00 × 7.50 × 15.81 mm · 0.018388 mm/单位",
"finger_flat_title": "展平图像(点击取点)",
"finger_pick_hint": "点击传感面上的像素即可取点;落在 UV 缝隙上的像素会被拒绝。",
@@ -166,21 +174,32 @@ STRINGS = {
["传感器 (mm)", "2.841, 3.512, 0.940", None],
["法向", "-0.18, -0.88, 0.43", None],
["平台 (mm)", "2.841, 0.940, -3.512", None],
["X / Y (mm)", "2.841, 0.940", None],
["yaw / pitch", "-78.42° / 23.10°", None],
["位姿 X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
["工作台移动 X / Y", "+2.841 -0.940 mm", None],
["对轴残差", "0.03° 可对轴", "#12b76a"],
["坐标系映射", "x→x, y→z, z→-y", None],
],
"finger_plate_label": "安装板厚 (mm)",
"finger_plate_value": "0.000",
"finger_tool_label": "刀具 X / Y",
"finger_tool_values": ["0.000", "0.000"],
"finger_sensor_xy_label": "传感器 X / Y",
"finger_sensor_xy_values": ["0.000", "0.000"],
"finger_sensor_zy_label": "传感器 Z / yaw",
"finger_sensor_zy_values": ["0.000", "0.000"],
"finger_preview_title": "三维预览",
"finger_preview_legend": "曲面颜色 = 对轴残差(绿为对正,红为对不上) · 黄色为 Z 轴",
"finger_point_cols": ["#", "像素 u, v", "sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X / Y", "Z", "yaw", "pitch"],
"finger_point_rows": [
["1", "0.35, 0.40", "2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841 / 0.940", "7.958", "-78.42", "23.10"],
["2", "0.62, 0.55", "5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113 / -1.870", "7.958", "12.05", "8.44"],
["3", "0.50, 0.72", "3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902 / 2.405", "7.958", "-90.00", "51.66"],
"finger_preview_checks": ["显示运动轴", "显示转台原点平面", "显示刀具路径"],
"finger_play": "播放动作",
"finger_preview_notes": [
"颜色含义:绿→红 = 表面对齐残差;红/绿/蓝三色轴 = 工作台 X/Y/Z(位于原点);橙点 = 工作台原点。",
"黄色直线 = 刀具轴(+Z,位置即刀具 X/Y);黄色方框 = 传感器安装基面(高度 = 传感器 Z,含安装板)。",
"青色线 = 规划路径;紫圈 = 转台当前姿态、浅紫圈 = 结束姿态,与固定方格网对比即看出整个变换;白点+蓝线 = 接触点及法向。",
],
"finger_point_cols": ["sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X", "Y", "Z", "yaw", "pitch", "残差"],
"finger_point_rows": [
["2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841", "0.940", "7.958", "-78.42", "23.10", "0.03"],
["5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113", "-1.870", "7.958", "12.05", "8.44", "0.02"],
["3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902", "2.405", "7.958", "-90.00", "51.66", "0.02"],
],
"finger_table_buttons": [("删除所选", False), ("上移", False), ("下移", False), ("清空标定点", False)],
"finger_bad_word": "撞 yaw 行程限位",
"finger_point_count": "3 个取点 · 展平 1024 × 1024 · 有效像素 72.9%",
"sensor_note": "设备与信号源、实时图像、力曲线",
@@ -284,7 +303,6 @@ STRINGS = {
"finger_initial_view": "Initial view",
"finger_reset": "Reset view",
"finger_textured_note": "UV texture: patch_texture_compute.png",
"finger_axes_note": "Yellow is the tool axis · X/Y are the table, Z the tool travel · green aligned, red not",
"finger_bundle_summary": "1032 vertices · 1974 triangles · 15.00 × 7.50 × 15.81 mm · 0.018388 mm per unit",
"finger_flat_title": "Flat image (click to pick)",
"finger_pick_hint": "Click a pixel on the sensing surface to pick a point; pixels in a UV gap are refused.",
@@ -293,21 +311,33 @@ STRINGS = {
["Sensor (mm)", "2.841, 3.512, 0.940", None],
["Normal", "-0.18, -0.88, 0.43", None],
["Platform (mm)", "2.841, 0.940, -3.512", None],
["X / Y (mm)", "2.841, 0.940", None],
["yaw / pitch", "-78.42° / 23.10°", None],
["Pose X / Y / yaw / pitch", "-2.841 0.940 -78.42 23.10", None],
["Table move X / Y", "+2.841 -0.940 mm", None],
["Residual", "0.03° can align", "#12b76a"],
["Frame map", "x→x, y→z, z→-y", None],
],
"finger_plate_label": "Mounting plate (mm)",
"finger_plate_value": "0.000",
"finger_tool_label": "Tool X / Y",
"finger_tool_values": ["0.000", "0.000"],
"finger_sensor_xy_label": "Sensor X / Y",
"finger_sensor_xy_values": ["0.000", "0.000"],
"finger_sensor_zy_label": "Sensor Z / yaw",
"finger_sensor_zy_values": ["0.000", "0.000"],
"finger_preview_title": "3D preview",
"finger_preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the Z axis",
"finger_point_cols": ["#", "pixel u, v", "sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X / Y", "Z", "yaw", "pitch"],
"finger_point_rows": [
["1", "0.35, 0.40", "2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841 / 0.940", "7.958", "-78.42", "23.10"],
["2", "0.62, 0.55", "5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113 / -1.870", "7.958", "12.05", "8.44"],
["3", "0.50, 0.72", "3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902 / 2.405", "7.958", "-90.00", "51.66"],
"finger_preview_checks": ["Show motion axes", "Show table origin plane", "Show tool path"],
"finger_play": "Play motion",
"finger_preview_notes": [
"What the colours mean: green→red = the surface's alignment residual; the red/green/blue triad is the table's X/Y/Z at the origin; the orange dot is the origin.",
"Yellow line = the tool axis (+Z, at the tool's own X/Y); the amber outline = the sensor base plane, at the sensor's Z offset (the plate).",
"Cyan line = the planned path; the violet ring is the table's pose now and the faint one where it finishes, against the fixed grid that is where it started; white dot with blue line = the contact and its normal.",
],
"finger_preview_legend": "Surface colour = alignment residual (green aligned, red not) · yellow is the Z axis",
"finger_point_cols": ["sensor x", "sensor y", "sensor z", "nx", "ny", "nz", "X", "Y", "Z", "yaw", "pitch", "residual"],
"finger_point_rows": [
["2.841", "3.512", "0.940", "-0.18", "-0.88", "0.43", "2.841", "0.940", "7.958", "-78.42", "23.10", "0.03"],
["5.113", "2.204", "-1.870", "0.21", "0.55", "-0.81", "5.113", "-1.870", "7.958", "12.05", "8.44", "0.02"],
["3.902", "6.118", "2.405", "0.55", "0.19", "0.81", "3.902", "2.405", "7.958", "-90.00", "51.66", "0.02"],
],
"finger_table_buttons": [("Remove selected", False), ("Move up", False), ("Move down", False), ("Clear points", False)],
"finger_bad_word": "hits yaw travel limit",
"finger_point_count": "3 picked points · flat 1024 × 1024 · 72.9% valid pixels",
"sensor_note": "Device and source, live image, force curves",
@@ -352,6 +382,28 @@ def _font(size: int = 11, *, bold: bool = False) -> QFont:
return font
def _arrowhead(painter: QPainter, path: QPainterPath, share: float, colour: QColor, size: int) -> None:
"""An arrowhead on ``path`` at ``share``, pointing the way the path runs.
The direction comes from two nearby points on the path rather than from
``angleAtPercent``: that angle is measured counter-clockwise from +x while Qt's
y axis grows downward, and every attempt to reconcile the two by hand has
produced an arrow pointing backwards.
"""
tip = path.pointAtPercent(share)
ahead = path.pointAtPercent(min(1.0, share + 0.01))
dx, dy = ahead.x() - tip.x(), ahead.y() - tip.y()
length = math.hypot(dx, dy) or 1.0
ux, uy = dx / length, dy / length
painter.setPen(QPen(colour, 2))
for sign in (1.0, -1.0):
barb = QPointF(
tip.x() - ux * size + sign * -uy * size * 0.6,
tip.y() - uy * size + sign * ux * size * 0.6,
)
painter.drawLine(tip, barb)
def _panel(painter: QPainter, rect: QRect, palette: Palette) -> QRect:
painter.fillRect(rect, palette.panel)
painter.setPen(QPen(palette.border, 1))
@@ -1008,45 +1060,141 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
_text(painter, QRect(inner_read.x(), row_y, label_w, 20), label, palette, size=9, muted=True)
_text(painter, QRect(inner_read.x() + label_w, row_y, inner_read.width() - label_w, 20), value, palette, size=9, colour=QColor(colour) if colour else None)
row_y += 20
_text(painter, QRect(inner_read.x(), inner_read.bottom() - 36, inner_read.width(), 18), s["finger_plate_label"], palette, size=9, muted=True)
_field(painter, QRect(inner_read.x(), inner_read.bottom() - 20, inner_read.width(), 20), s["finger_plate_value"], palette)
# The setup and tool fields: pairs of numbers, as in the app, where they are
# spin boxes two to a row because they get dialled in against the real machine.
pairs = (
("finger_tool_label", "finger_tool_values"),
("finger_sensor_xy_label", "finger_sensor_xy_values"),
("finger_sensor_zy_label", "finger_sensor_zy_values"),
)
pair_y = row_y + 4
for label_key, values_key in pairs:
_text(painter, QRect(inner_read.x(), pair_y, label_w, 20), s[label_key], palette, size=9, muted=True)
field_w = int((inner_read.width() - label_w - 6) / 2)
for index, value in enumerate(s[values_key][:2]):
_field(
painter,
QRect(inner_read.x() + label_w + index * (field_w + 6), pair_y, field_w, 20),
value,
palette,
)
pair_y += 22
# 3D preview: the tool axis and a normal with its residual.
# 3D preview: the machine's frame at the table origin, the sensor on it, and the
# planned path. Schematic, but the parts and their colours are the ones the real
# view draws — a proposal that disagrees with the app is worse than no proposal.
inner_preview = _panel(painter, preview, palette)
_text(painter, QRect(inner_preview.x(), inner_preview.y(), inner_preview.width(), 18), s["finger_preview_title"], palette, size=10, bold=True)
view = QRect(inner_preview.x(), inner_preview.y() + 22, inner_preview.width(), inner_preview.height() - 22 - 34)
checks_h = 20
play_h = 28
notes_h = 3 * 14 + 4
view = QRect(
inner_preview.x(),
inner_preview.y() + 22,
inner_preview.width(),
inner_preview.height() - 22 - checks_h - play_h - notes_h,
)
painter.fillRect(view, QColor("#12161c"))
painter.setPen(QPen(QColor("#2a313c"), 1))
for i in range(1, 4):
painter.drawLine(view.x() + i * view.width() // 4, view.y(), view.x() + i * view.width() // 4, view.bottom())
cx, cy = view.center().x(), view.center().y() + 40
axis = QPoint(cx, view.y() + 24)
painter.setPen(QPen(QColor("#f2c94c"), 3))
painter.drawLine(axis, QPoint(cx, cy))
painter.setBrush(QColor("#f2c94c"))
painter.drawEllipse(QPoint(cx, cy), 3, 3)
# The motion axes: the platform frame, drawn in the sensor frame.
cx, cy = view.center().x(), view.center().y() + int(view.height() * 0.12)
# The table origin's plane: a grid, not a filled surface.
half = int(view.width() * 0.30)
floor = QRect(cx - half, cy - int(half * 0.26), 2 * half, int(half * 0.52))
painter.setPen(QPen(QColor("#8c95ab"), 1))
for index in range(5):
fx = floor.x() + index * floor.width() // 4
fy = floor.y() + index * floor.height() // 4
painter.drawLine(fx, floor.y(), fx, floor.bottom())
painter.drawLine(floor.x(), fy, floor.right(), fy)
# The sensor: the sensing face as an arc, the shell below it as a wireframe-ish
# outline. Grey, because the surface carries the residual colour instead.
dome_w = int(view.width() * 0.34)
dome_h = int(view.height() * 0.34)
body = QRect(cx - dome_w // 2, cy - dome_h, dome_w, dome_h + int(dome_h * 0.45))
painter.setPen(QPen(QColor("#5b6273"), 1))
painter.setBrush(QColor("#26b847"))
painter.drawChord(QRect(body.x(), body.y(), body.width(), body.height() * 2 // 3), 0, 180 * 16)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(QRect(body.x(), body.y() + body.height() // 4, body.width(), body.height() // 2))
# The base plane, lifted off the origin by the sensor's z setup offset (the
# mounting plate), and the table's own frame: a circle at the pose it ends in,
# against the square grid that is where it started.
base = QRect(body.x() - 8, body.bottom() - 6, body.width() + 16, int(half * 0.30))
painter.setPen(QPen(QColor("#fab829"), 2))
painter.drawRect(base)
ring = QRect(0, 0, int(half * 1.5), int(half * 0.78))
ring.moveCenter(QPoint(cx + int(half * 0.30), cy + int(half * 0.05)))
painter.setPen(QPen(QColor("#8c6ff7"), 2))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawEllipse(ring)
painter.drawLine(ring.center().x() - ring.width() // 2, ring.center().y(),
ring.center().x() + ring.width() // 2, ring.center().y())
painter.drawLine(ring.center().x(), ring.center().y() - ring.height() // 2,
ring.center().x(), ring.center().y() + ring.height() // 2)
painter.setPen(QPen(QColor(140, 111, 247, 130), 2))
painter.drawEllipse(ring.adjusted(-14, -7, -14, -7))
# The planned path: the stages swing the contact, then the table slides it in.
contact = QPoint(body.x() + 10, body.y() + int(body.height() * 0.28))
landed = QPoint(cx + 6, cy - int(half * 0.30))
path = QPainterPath(contact)
path.quadTo(QPoint(contact.x() + int(half * 0.75), contact.y() - int(half * 0.35)), landed)
painter.setPen(QPen(QColor("#40d1f2"), 2))
painter.drawPath(path)
for share in (0.35, 0.7):
_arrowhead(painter, path, share, QColor("#ccf4ff"), 7)
painter.setBrush(QColor("#40d1f2"))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(landed, 5, 5)
# The table triad at the origin: the only full-strength one in the picture.
origin = QPoint(floor.center().x(), floor.center().y() + int(half * 0.12))
for colour, end in (
("#e65454", QPoint(cx - 96, cy + 18)),
("#61cc61", QPoint(cx + 30, cy + 92)),
("#5c8cf2", QPoint(cx + 88, cy - 26)),
("#e64c4c", QPoint(origin.x(), origin.y() + int(half * 0.62))),
("#59c761", QPoint(origin.x() + int(half * 0.85), origin.y())),
("#5787f2", QPoint(origin.x(), origin.y() - int(half * 0.42))),
):
painter.setPen(QPen(QColor(colour), 2))
painter.drawLine(QPoint(cx, cy), end)
painter.setPen(QPen(QColor("#12b76a"), 3))
painter.drawLine(QPoint(cx, cy), QPoint(cx + 26, cy - 96))
painter.setPen(QPen(QColor("#d92d20"), 3))
painter.drawLine(QPoint(cx, cy), QPoint(cx - 74, cy - 78))
painter.drawLine(origin, end)
painter.setBrush(QColor("#fa9933"))
painter.setPen(Qt.PenStyle.NoPen)
painter.drawEllipse(origin, 5, 5)
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(QColor("#4a5568"), 1))
painter.drawLine(QPoint(cx - 90, cy), QPoint(cx + 90, cy))
painter.setPen(QPen(QColor("#e65454"), 1))
painter.drawText(QPoint(cx - 108, cy + 24), "X")
painter.setPen(QPen(QColor("#61cc61"), 1))
painter.drawText(QPoint(cx + 34, cy + 100), "Y")
painter.setPen(QPen(QColor("#5c8cf2"), 1))
painter.drawText(QPoint(cx + 92, cy - 28), "Z")
_text(painter, QRect(inner_preview.x(), view.bottom() + 2, inner_preview.width(), 30), s["finger_axes_note"], palette, size=8, muted=True)
# The tool axis, through the point the tool touches.
painter.setPen(QPen(QColor("#f2e04c"), 2))
painter.drawLine(QPoint(landed.x(), view.y() + 16), QPoint(landed.x(), landed.y()))
_text(painter, QRect(landed.x() - 8, view.y() + 2, 90, 16), "+Z", palette, size=8, colour=QColor("#f2e04c"))
# The picked contact and its normal, and the point selected in the table.
painter.setPen(QPen(QColor("#59a6ff"), 2))
painter.drawLine(contact, QPoint(contact.x() - int(half * 0.30), contact.y() - int(half * 0.34)))
painter.setPen(QPen(QColor("#ffffff"), 2))
painter.setBrush(QColor("#ffffff"))
painter.drawEllipse(contact, 4, 4)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor("#eb2133"))
painter.drawEllipse(QPoint(contact.x() + 14, contact.y() + 4), 5, 5)
painter.setBrush(Qt.BrushStyle.NoBrush)
checks = s["finger_preview_checks"]
check_w = inner_preview.width() // max(1, len(checks))
for index, label in enumerate(checks):
box = QRect(inner_preview.x() + index * check_w, view.bottom() + 2, check_w, checks_h)
painter.setPen(QPen(palette.accent, 2))
painter.drawLine(box.x() + 2, box.y() + 9, box.x() + 5, box.y() + 13)
painter.drawLine(box.x() + 5, box.y() + 13, box.x() + 10, box.y() + 5)
_text(painter, QRect(box.x() + 15, box.y(), check_w - 15, checks_h), label, palette, size=9, muted=True)
play = QRect(inner_preview.x(), view.bottom() + checks_h + 2, 110, play_h - 4)
_button(painter, play, s["finger_play"], palette, primary=True)
notes_y = play.bottom() + 4
for text in s["finger_preview_notes"]:
_text(painter, QRect(inner_preview.x(), notes_y, inner_preview.width(), 14), text, palette, size=8, muted=True)
notes_y += 14
# Picked points.
table = QRect(inner.x(), y + body_h + 8, inner.width(), max(60, table_h))
@@ -1055,9 +1203,16 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
painter.drawRect(table.adjusted(0, 0, -1, -1))
header_h = 24
painter.fillRect(QRect(table.x(), table.y(), table.width(), header_h), palette.panel)
# Leading select column, then the data columns: the real table has a checkbox
# per row so points can be picked out, edited, reordered or removed.
select_w = 34
painter.setPen(QPen(palette.border, 1))
painter.drawLine(table.x() + select_w, table.y(), table.x() + select_w, table.bottom())
columns = s["finger_point_cols"]
widths = [int(table.width() * share) for share in (0.06, 0.09, 0.1, 0.09, 0.09, 0.07, 0.07, 0.07, 0.09, 0.09, 0.09, 0.09)]
x = table.x()
widths = [int((table.width() - select_w) * share)
for share in (0.09, 0.1, 0.09, 0.09, 0.08, 0.08, 0.08, 0.11,
0.07, 0.07, 0.07, 0.07)]
x = table.x() + select_w
for name, width in zip(columns, widths):
_text(painter, QRect(x, table.y(), width, header_h), name, palette, size=9, muted=True, align=Qt.AlignmentFlag.AlignCenter)
x += width
@@ -1065,12 +1220,28 @@ def draw_finger_tab(painter: QPainter, palette: Palette, s: dict, rect: QRect) -
row_y = table.y() + header_h + index * 26
if index % 2 == 0:
painter.fillRect(QRect(table.x() + 1, row_y, table.width() - 2, 25), palette.panel)
x = table.x()
# Tick the first row, as the selected point being tested.
box = QRect(table.x() + select_w // 2 - 6, row_y + 8, 12, 12)
painter.setPen(QPen(palette.muted, 1))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.drawRect(box)
if index == 0:
painter.setPen(QPen(palette.accent, 2))
painter.drawLine(box.x() + 2, box.y() + 6, box.x() + 5, box.y() + 10)
painter.drawLine(box.x() + 5, box.y() + 10, box.x() + 10, box.y() + 3)
x = table.x() + select_w
for value, width in zip(row, widths):
colour = palette.danger if value == s["finger_bad_word"] else None
_text(painter, QRect(x, row_y, width, 26), value, palette, size=10, align=Qt.AlignmentFlag.AlignCenter, colour=colour)
x += width
_text(painter, QRect(table.x() + 8, table.bottom() + 2, table.width(), 18), s["finger_point_count"], palette, size=9, muted=True)
buttons = s["finger_table_buttons"]
bx = table.x()
by = table.bottom() + 26
for label, primary in buttons:
width = 30 + 8 * len(label)
_button(painter, QRect(bx, by, width, 26), label, palette, primary=primary)
bx += width + 8
TAB_PAINTERS = {
+15
View File
@@ -30,6 +30,17 @@ if sys.platform == "win32" and "QT_QPA_FONTDIR" not in os.environ:
if _fonts.is_dir():
os.environ["QT_QPA_FONTDIR"] = str(_fonts)
# The report quotes labels, and half of them are Chinese. A Windows console
# defaults to a legacy code page that cannot encode them, so the run ended in a
# UnicodeEncodeError *after* every check had passed — the worst kind of noise.
for _stream in (sys.stdout, sys.stderr):
_reconfigure = getattr(_stream, "reconfigure", None)
if _reconfigure is not None:
try:
_reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError): # pragma: no cover - stream already detached
pass
RESOLUTIONS = ((1440, 900), (1920, 1080))
@@ -394,6 +405,10 @@ def main() -> int:
)
window.workspace.set_current_key("axes")
try:
window.poller.shutdown()
except Exception: # noqa: BLE001 - a shutdown must not mask the result
pass
window.close()
app.processEvents()
+492
View File
@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""Render the finger page's 3D preview to a PNG, using a real GL context.
python apps/autocal/tools/preview_snapshot.py --out shot.png
``tools/panel_snapshot.py`` renders the whole window with the *offscreen* Qt
platform, which cannot create an OpenGL context at all (``QOpenGLWidget is not
supported on this platform``), so the preview pane in those shots is empty. This
tool is the one that can photograph the 3D view, and it needs the real platform
plugin and a visible window: Qt only builds a QOpenGLWidget's framebuffer once the
widget has been shown and painted.
It therefore opens the window briefly, picks a point on the sensing surface, and
reads the framebuffer back. Options:
--points 3 pick several points, so the table and the red dot show
--play 0.5 run the motion simulation and capture it at 50%
--view initial reset the camera before the shot
--whole save the whole window instead of just the GL pane
"""
from __future__ import annotations
import argparse
import os
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[1]
for path in (APP_ROOT / "src", APP_ROOT.parents[1] / "shared" / "src"):
if str(path) not in sys.path:
sys.path.insert(0, str(path))
os.environ.setdefault("ORISYS_APP_ID", "autocal")
# The offscreen plugin cannot make a GL context, so drop it if the caller's
# environment set it (the test suite does).
if os.environ.get("QT_QPA_PLATFORM") == "offscreen":
del os.environ["QT_QPA_PLATFORM"]
# Force the native desktop GL backend. Qt's default choice on Windows has been seen
# to hand out a context that validates but rejects every command — a bare
# QOpenGLWidget with no application code fails the same way, with
# GL_INVALID_OPERATION on the first call after ``makeCurrent`` — and every render
# then comes out blank with no clue why. ``setdefault``, so an explicit choice in
# the environment still wins.
os.environ.setdefault("QT_OPENGL", "desktop")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--theme", default="dark", choices=("dark", "light"))
parser.add_argument("--language", default="zh_CN", choices=("zh_CN", "en_US"))
parser.add_argument("--size", default="1180x760", help="WIDTHxHEIGHT of the window")
parser.add_argument(
"--pane",
default="",
metavar="WIDTHxHEIGHT",
help="force the 3D pane to this size before the shot, for a readable view",
)
parser.add_argument("--out", default="preview.png")
parser.add_argument(
"--bundle",
default="",
help="bundle directory to load; defaults to the remembered preference",
)
parser.add_argument("--points", type=int, default=1, help="how many points to pick")
parser.add_argument(
"--setup",
default="",
metavar="X,Y,Z,YAW",
help="sensor mounting pose: mm in the table frame plus the yaw in degrees",
)
parser.add_argument(
"--tool",
default="",
metavar="X,Y",
help="tool position in the table frame, mm",
)
parser.add_argument(
"--play",
type=float,
default=None,
metavar="SHARE",
help="start the motion simulation and capture it after this share, 0..1",
)
parser.add_argument(
"--view",
default="",
choices=("", "initial"),
help="initial resets the camera before the shot",
)
parser.add_argument("--tab", default="finger", help="workspace tab to open first")
parser.add_argument(
"--whole", action="store_true", help="save the whole window, not just the pane"
)
parser.add_argument(
"--debug", action="store_true", help="report the GL state of the pane"
)
args = parser.parse_args()
from PySide6.QtWidgets import QApplication
from orisys_app_shared.i18n import set_locale
from orisys_app_shared.theme import build_qss
from orisys_autocal.preferences import load_preferences
from orisys_autocal.ui.pose_preview import configure_compatible_gl
from orisys_autocal.ui.window import AutoCalMainWindow
width, height = (int(part) for part in args.size.lower().split("x"))
set_locale(args.language)
app = QApplication.instance() or QApplication([])
app.setApplicationName("Orisys AutoCal")
# Must follow QApplication and precede the first GL surface.
configure_compatible_gl()
app.setStyleSheet(build_qss(args.theme))
prefs = load_preferences()
prefs["theme"] = args.theme
prefs["language"] = args.language
prefs["show_diagnostics"] = False
window = AutoCalMainWindow(prefs=prefs)
window.workspace.set_current_key(args.tab)
window.show()
# Resized *after* showing: the window restores its remembered geometry on
# construction, so a resize before ``show`` is silently overwritten and every
# shot comes out at whatever size the operator last used.
window.resize(width, height)
_settle(app, 0.5)
if args.bundle:
window.load_bundle(args.bundle)
_settle(app, 0.6)
panel = window.finger_panel
if panel.view.flat is None:
print("no bundle loaded; set finger_bundle_path in preferences", file=sys.stderr)
_finish(app, window)
return 1
if args.setup:
numbers = [float(part) for part in args.setup.split(",")]
while len(numbers) < 4:
numbers.append(0.0)
panel.set_sensor_setup(tuple(numbers[:3]), numbers[3])
_settle(app, 0.2)
if args.tool:
numbers = [float(part) for part in args.tool.split(",")]
while len(numbers) < 2:
numbers.append(0.0)
panel.set_tool_xy(numbers[0], numbers[1])
_settle(app, 0.2)
if args.pane:
pane_w, pane_h = (int(part) for part in args.pane.lower().split("x"))
panel.preview.setFixedSize(pane_w, pane_h)
_settle(app, 0.3)
if args.view == "initial":
panel.preview.reset_view()
for x, y in _pick_pixels(panel, max(1, args.points)):
panel.handle_pick(x, y)
_settle(app, 0.25)
if args.play is not None:
panel.preview.start_simulation()
_run_simulation_to(app, panel.preview, args.play)
_settle(app, 0.35)
# The pane is hidden for the read-back, and only for the read-back. A visible
# QOpenGLWidget is painted by the window system, which a session with no
# interactive desktop never does, so its framebuffer stays blank; hidden, Qt
# renders on demand for grabFramebuffer and the picture is real. Hiding it
# afterwards does not disturb the layout, so the corner is taken first.
from PySide6.QtCore import QPoint
corner = panel.preview.mapTo(window, QPoint(0, 0))
pane_size = panel.preview.size()
panel.preview.hide()
_settle(app, 0.25)
try:
gl_image = _grab_gl(app, panel.preview)
finally:
panel.preview.show()
panel.preview.resize(pane_size)
_settle(app, 0.2)
if args.debug:
context = panel.preview.context()
summary = panel.preview.trajectory_summary()
print(
f"debug: platform={app.platformName()} valid={panel.preview.isValid()} "
f"size={panel.preview.width()}x{panel.preview.height()} "
f"tab={window.workspace.current_key()} "
f"context={'yes' if context is not None else 'none'} "
f"problem={panel.preview.problem!r} "
f"image={'none' if gl_image is None else f'{gl_image.width()}x{gl_image.height()}'}",
file=sys.stderr,
)
print(
f"debug: trajectory={summary or 'empty'} "
f"show={panel.preview.show_trajectory} table={panel.preview.show_table} "
f"axes={panel.preview.show_axes} "
f"scene_radius={panel.preview.scene_radius():.2f}",
file=sys.stderr,
)
print(
f"debug: heights panel={panel.height()} preview={panel.preview.height()} "
f"point_table={panel.table.height()} window={window.height()}",
file=sys.stderr,
)
# Where the claims land on screen: the path's end must project onto the tool,
# and the contact onto both. Printed because "they look close in the picture"
# is exactly the judgement this tool exists to replace.
path = panel.preview.trajectory()
if path.size:
tool = panel.preview.tool_point()
gap = float(sum((a - b) ** 2 for a, b in zip(path[-1], tool)) ** 0.5)
screen = panel.preview.project(tool)
print(
f"debug: path end at {panel.preview.project(path[-1])} "
f"tool at {screen} offscreen {gap:.6f} mm",
file=sys.stderr,
)
# The projection says where the tool line *is* on screen; check the
# pixels, because a projection that disagrees with the render looks
# perfectly healthy on its own — that is how the labels drifted off
# their arrows and the scene sat off to one side without a word.
from orisys_autocal.ui.pose_preview import COLOUR
for label, point, colour in _landmarks(panel):
screen = panel.preview.project(point)
print(
f"debug: {label} at {screen} -> "
f"{_find_colour(gl_image, screen, colour)} matching pixels",
file=sys.stderr,
)
# The two markers must be the same point: the contact is what is solved for,
# the red dot is the row selected in the table, and they are the same pick.
contact = panel.preview._point_mm # noqa: SLF001
highlight = panel.preview._highlight_mm # noqa: SLF001
pose = panel.preview._pose # noqa: SLF001
stack = panel.preview.stack
print(
f"debug: share={panel.preview.motion_share()} "
f"simulating={panel.preview.simulating} "
f"elapsed={panel.preview._sim_elapsed:.0f} " # noqa: SLF001
f"pose={'none' if pose is None else pose.describe()}",
file=sys.stderr,
)
if contact is not None:
drawn = panel.preview._placed(contact) # noqa: SLF001
tool = panel.preview.tool_point()
print(
f"debug: drawn contact {[round(value, 4) for value in stack.mount @ drawn]} "
f"tool {[round(value, 4) for value in stack.mount @ tool]} "
f"gap {float(sum((a - b) ** 2 for a, b in zip(drawn, tool)) ** 0.5):.6f} mm",
file=sys.stderr,
)
if gl_image is None:
print(
f"the GL pane produced no framebuffer (valid={panel.preview.isValid()}, "
f"size={panel.preview.width()}x{panel.preview.height()}, "
f"backend={os.environ.get('QT_OPENGL', 'default')}, "
f"problem={panel.preview.problem!r})",
file=sys.stderr,
)
_finish(app, window)
return 1
if args.whole:
image = window.grab()
_paint_pane(image, corner, gl_image)
else:
image = gl_image
output = Path(args.out).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
if not image.save(str(output)):
print(f"could not write {output}", file=sys.stderr)
_finish(app, window)
return 1
print(
f"wrote {output} ({image.width()}x{image.height()}, pane={gl_image.width()}"
f"x{gl_image.height()}, theme={args.theme}, points={panel.table.rowCount()}, "
f"play={args.play})"
)
_finish(app, window)
return 0
def preview_place(panel, point):
"""A sensor point as the preview draws it right now."""
return panel.preview._placed(point) # noqa: SLF001
def _landmarks(panel):
"""``(name, mesh point, palette colour)`` for pixels that identify themselves.
Each pair is a point whose position is known from the model and an element drawn
there in a colour nothing else uses, so "is the projection telling the truth"
becomes a pixel count instead of an opinion.
"""
from orisys_autocal.sensor.frames import to_sensor
from orisys_autocal.ui.pose_preview import COLOUR
preview = panel.preview
frame = preview.table_frame()
tool = preview.tool_point()
direction = to_sensor(preview.stack.tool_axis_direction, preview.stack.axis_map)
length = float((direction[0] ** 2 + direction[1] ** 2 + direction[2] ** 2) ** 0.5) or 1.0
direction = direction / length
found = [
# Unoccluded by construction: the origin dot is drawn last with depth off.
("origin", frame["origin"], COLOUR["origin"]),
]
# The tool line is depth-tested, so parts of it are behind the body: try several
# heights along it and take the best, rather than insisting on one that happens
# to be visible in one particular pose.
for offset_mm in (12.0, 9.0, 6.0, 3.0):
found.append(
(
f"tool line +{offset_mm:.0f}mm",
tool + direction * offset_mm,
COLOUR["tool_axis"],
)
)
ring = preview.table_marker_points()
if ring.size:
found.append(("table marker", ring[0], COLOUR["table_marker"]))
return found
def _find_colour(image, screen, colour, radius: int = 6) -> int:
"""How many pixels near ``screen`` match ``colour``, within a small tolerance.
The check that ties the projection to the picture. Deliberately tolerant about
position (a few pixels) and strict about colour, because the colour is what says
*which* element is there: finding the tool-axis yellow near where the tool is
claimed to be is the difference between a working projection and a plausible one.
"""
if screen is None or image is None:
return -1
from PySide6.QtGui import QColor
wanted = QColor(
int(colour[0] * 255), int(colour[1] * 255), int(colour[2] * 255)
)
found = 0
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
x, y = int(round(screen[0])) + dx, int(round(screen[1])) + dy
if not (0 <= x < image.width() and 0 <= y < image.height()):
continue
pixel = image.pixelColor(x, y)
if (
abs(pixel.red() - wanted.red()) <= 24
and abs(pixel.green() - wanted.green()) <= 24
and abs(pixel.blue() - wanted.blue()) <= 24
):
found += 1
return found
def _finish(app, window) -> None:
"""Close the window, stopping the poller thread first.
Without the explicit shutdown Qt tears the thread down with the process and
prints "QThread: Destroyed while thread is still running", which reads like a
fault in the app rather than a tool that left in a hurry.
"""
try:
window.poller.shutdown()
except Exception: # noqa: BLE001 - a shutdown must not mask the result
pass
window.close()
app.processEvents()
def _grab_gl(app, preview, attempts: int = 60):
"""The GL pane's framebuffer, once it actually has something drawn in it.
``grabFramebuffer`` returns a valid but uniformly blank image when the widget
has not been painted yet, which is easy to mistake for a rendering bug — the
first version of this tool saved a white rectangle and the pane looked broken.
So it is called repeatedly until the pixels differ from one another.
"""
import time
grab = getattr(preview, "grabFramebuffer", None)
last = None
for _ in range(attempts):
app.processEvents()
preview.update()
app.processEvents()
if grab is not None:
try:
image = grab()
except Exception as error: # noqa: BLE001 - reported below
print(f"grabFramebuffer failed: {error}", file=sys.stderr)
break
if image is not None and not image.isNull():
last = image
if _has_content(image):
return image
time.sleep(0.05)
if last is not None and _has_content(last):
return last
# Fall back to a plain widget grab only when there is no GL context at all;
# a blank framebuffer is reported instead of saved as a picture of nothing.
if not preview.isValid():
return preview.grab()
return None
def _has_content(image) -> bool:
"""True when the image is not one flat colour, i.e. something was drawn."""
small = image.scaled(24, 24)
first = small.pixel(0, 0)
for y in range(small.height()):
for x in range(small.width()):
if small.pixel(x, y) != first:
return True
return False
def _paint_pane(image, corner, pane) -> None:
"""Draw the GL content into a whole-window grab, where Qt leaves it blank.
``QWidget.grab`` on a window containing a ``QOpenGLWidget`` does not composite
the GL surface, so the pane comes out empty. Painting the framebuffer in at the
widget's own position is what makes the page shot show what the operator sees.
"""
from PySide6.QtGui import QPainter
painter = QPainter(image)
try:
painter.drawImage(corner, pane)
finally:
painter.end()
def _pick_pixels(panel, count: int) -> list[tuple[int, int]]:
"""UV-flat pixels that land on the surface, spread around the island.
Sampled from the flat view rather than hard-coded, so the shot follows the
bundle instead of a coordinate that was right for one revision.
"""
view = panel.view
flat = view.flat
if flat is None:
return []
found: list[tuple[int, int]] = []
fractions = [(0.5, 0.5), (0.35, 0.45), (0.65, 0.55), (0.5, 0.3), (0.5, 0.7)]
for share_x, share_y in fractions:
if len(found) >= count:
break
x = int(flat.width * share_x)
y = int(flat.height * share_y)
if flat.sample(x, y) is not None:
found.append((x, y))
return found
def _settle(app, seconds: float) -> None:
import time
deadline = time.monotonic() + seconds
while time.monotonic() < deadline:
app.processEvents()
time.sleep(0.01)
def _run_simulation_to(app, preview, share: float) -> None:
"""Let the simulation run until ``share`` of it has played.
Driven off the widget's own clock — the animation is a QTimer, and stepping the
event loop is what advances it. Reading the elapsed time rather than sleeping a
fixed duration keeps the shot reproducible when the frame budget differs.
"""
import time
from orisys_autocal.ui.pose_preview import SIM_DURATION_MS
target = SIM_DURATION_MS * max(0.0, min(1.0, float(share))) / 1000.0
deadline = time.monotonic() + target + 5.0
while time.monotonic() < deadline:
app.processEvents()
if getattr(preview, "_sim_elapsed", 0.0) / 1000.0 >= target: # noqa: SLF001
break
time.sleep(0.005)
if __name__ == "__main__":
raise SystemExit(main())