#!/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())