Files
orisys-app/apps/autocal/tools/preview_snapshot.py
T
2026-09-23 09:58:00 +08:00

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())