493 lines
19 KiB
Python
493 lines
19 KiB
Python
#!/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())
|