Snapshot.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. # Copyright (c) 2023 UltiMaker
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import numpy
  4. from typing import Optional
  5. from PyQt6 import QtCore
  6. from PyQt6.QtCore import QCoreApplication
  7. from PyQt6.QtGui import QImage
  8. from UM.Logger import Logger
  9. from cura.PreviewPass import PreviewPass
  10. from UM.Application import Application
  11. from UM.Math.AxisAlignedBox import AxisAlignedBox
  12. from UM.Math.Matrix import Matrix
  13. from UM.Math.Vector import Vector
  14. from UM.Scene.Camera import Camera
  15. from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
  16. from UM.Scene.SceneNode import SceneNode
  17. from UM.Qt.QtRenderer import QtRenderer
  18. class Snapshot:
  19. @staticmethod
  20. def getImageBoundaries(image: QImage):
  21. # Look at the resulting image to get a good crop.
  22. # Get the pixels as byte array
  23. pixel_array = image.bits().asarray(image.sizeInBytes())
  24. width, height = image.width(), image.height()
  25. # Convert to numpy array, assume it's 32 bit (it should always be)
  26. pixels = numpy.frombuffer(pixel_array, dtype=numpy.uint8).reshape([height, width, 4])
  27. # Find indices of non zero pixels
  28. nonzero_pixels = numpy.nonzero(pixels)
  29. min_y, min_x, min_a_ = numpy.amin(nonzero_pixels, axis=1) # type: ignore
  30. max_y, max_x, max_a_ = numpy.amax(nonzero_pixels, axis=1) # type: ignore
  31. return min_x, max_x, min_y, max_y
  32. @staticmethod
  33. def isometricSnapshot(width: int = 300, height: int = 300, *, node: Optional[SceneNode] = None) -> Optional[QImage]:
  34. """
  35. Create an isometric snapshot of the scene.
  36. :param width: width of the aspect ratio default 300
  37. :param height: height of the aspect ratio default 300
  38. :param node: node of the scene default is the root of the scene
  39. :return: None when there is no model on the build plate otherwise it will return an image
  40. """
  41. if node is None:
  42. root = Application.getInstance().getController().getScene().getRoot()
  43. # the direction the camera is looking at to create the isometric view
  44. iso_view_dir = Vector(-1, -1, -1).normalized()
  45. bounds = Snapshot.nodeBounds(node)
  46. if bounds is None:
  47. Logger.log("w", "There appears to be nothing to render")
  48. return None
  49. camera = Camera("snapshot")
  50. # find local x and y directional vectors of the camera
  51. tangent_space_x_direction = iso_view_dir.cross(Vector.Unit_Y).normalized()
  52. tangent_space_y_direction = tangent_space_x_direction.cross(iso_view_dir).normalized()
  53. # find extreme screen space coords of the scene
  54. x_points = [p.dot(tangent_space_x_direction) for p in bounds.points]
  55. y_points = [p.dot(tangent_space_y_direction) for p in bounds.points]
  56. min_x = min(x_points)
  57. max_x = max(x_points)
  58. min_y = min(y_points)
  59. max_y = max(y_points)
  60. camera_width = max_x - min_x
  61. camera_height = max_y - min_y
  62. if camera_width == 0 or camera_height == 0:
  63. Logger.log("w", "There appears to be nothing to render")
  64. return None
  65. # increase either width or height to match the aspect ratio of the image
  66. if camera_width / camera_height > width / height:
  67. camera_height = camera_width * height / width
  68. else:
  69. camera_width = camera_height * width / height
  70. # Configure camera for isometric view
  71. ortho_matrix = Matrix()
  72. ortho_matrix.setOrtho(
  73. -camera_width / 2,
  74. camera_width / 2,
  75. -camera_height / 2,
  76. camera_height / 2,
  77. -10000,
  78. 10000
  79. )
  80. camera.setPerspective(False)
  81. camera.setProjectionMatrix(ortho_matrix)
  82. camera.setPosition(bounds.center)
  83. camera.lookAt(bounds.center + iso_view_dir)
  84. # Render the scene
  85. renderer = QtRenderer()
  86. render_pass = PreviewPass(width, height, root=node)
  87. renderer.setViewportSize(width, height)
  88. renderer.setWindowSize(width, height)
  89. render_pass.setCamera(camera)
  90. renderer.addRenderPass(render_pass)
  91. renderer.beginRendering()
  92. renderer.render()
  93. return render_pass.getOutput()
  94. @staticmethod
  95. def nodeBounds(root_node: SceneNode) -> Optional[AxisAlignedBox]:
  96. axis_aligned_box = None
  97. for node in DepthFirstIterator(root_node):
  98. if not getattr(node, "_outside_buildarea", False):
  99. if node.callDecoration(
  100. "isSliceable") and node.getMeshData() and node.isVisible() and not node.callDecoration(
  101. "isNonThumbnailVisibleMesh"):
  102. if axis_aligned_box is None:
  103. axis_aligned_box = node.getBoundingBox()
  104. else:
  105. axis_aligned_box = axis_aligned_box + node.getBoundingBox()
  106. return axis_aligned_box
  107. @staticmethod
  108. def snapshot(width = 300, height = 300):
  109. """Return a QImage of the scene
  110. Uses PreviewPass that leaves out some elements Aspect ratio assumes a square
  111. :param width: width of the aspect ratio default 300
  112. :param height: height of the aspect ratio default 300
  113. :return: None when there is no model on the build plate otherwise it will return an image
  114. """
  115. scene = Application.getInstance().getController().getScene()
  116. active_camera = scene.getActiveCamera() or scene.findCamera("3d")
  117. render_width, render_height = (width, height) if active_camera is None else active_camera.getWindowSize()
  118. render_width = int(render_width)
  119. render_height = int(render_height)
  120. QCoreApplication.processEvents() # This ensures that the opengl context is correctly available
  121. preview_pass = PreviewPass(render_width, render_height)
  122. root = scene.getRoot()
  123. camera = Camera("snapshot", root)
  124. # determine zoom and look at
  125. bbox = Snapshot.nodeBounds(root)
  126. # If there is no bounding box, it means that there is no model in the buildplate
  127. if bbox is None:
  128. Logger.log("w", "Unable to create snapshot as we seem to have an empty buildplate")
  129. return None
  130. look_at = bbox.center
  131. # guessed size so the objects are hopefully big
  132. size = max(bbox.width, bbox.height, bbox.depth * 0.5)
  133. # Looking from this direction (x, y, z) in OGL coordinates
  134. looking_from_offset = Vector(-1, 1, 2)
  135. if size > 0:
  136. # determine the watch distance depending on the size
  137. looking_from_offset = looking_from_offset * size * 1.75
  138. camera.setPosition(look_at + looking_from_offset)
  139. camera.lookAt(look_at)
  140. satisfied = False
  141. size = None
  142. fovy = 30
  143. while not satisfied:
  144. if size is not None:
  145. satisfied = True # always be satisfied after second try
  146. projection_matrix = Matrix()
  147. # Somehow the aspect ratio is also influenced in reverse by the screen width/height
  148. # So you have to set it to render_width/render_height to get 1
  149. projection_matrix.setPerspective(fovy, render_width / render_height, 1, 500)
  150. camera.setProjectionMatrix(projection_matrix)
  151. preview_pass.setCamera(camera)
  152. preview_pass.render()
  153. pixel_output = preview_pass.getOutput()
  154. try:
  155. min_x, max_x, min_y, max_y = Snapshot.getImageBoundaries(pixel_output)
  156. except (ValueError, AttributeError):
  157. Logger.logException("w", "Failed to crop the snapshot!")
  158. return None
  159. size = max((max_x - min_x) / render_width, (max_y - min_y) / render_height)
  160. if size > 0.5 or satisfied:
  161. satisfied = True
  162. else:
  163. # make it big and allow for some empty space around
  164. fovy *= 0.5 # strangely enough this messes up the aspect ratio: fovy *= size * 1.1
  165. # make it a square
  166. if max_x - min_x >= max_y - min_y:
  167. # make y bigger
  168. min_y, max_y = int((max_y + min_y) / 2 - (max_x - min_x) / 2), int((max_y + min_y) / 2 + (max_x - min_x) / 2)
  169. else:
  170. # make x bigger
  171. min_x, max_x = int((max_x + min_x) / 2 - (max_y - min_y) / 2), int((max_x + min_x) / 2 + (max_y - min_y) / 2)
  172. cropped_image = pixel_output.copy(min_x, min_y, max_x - min_x, max_y - min_y)
  173. # Scale it to the correct size
  174. scaled_image = cropped_image.scaled(
  175. width, height,
  176. aspectRatioMode = QtCore.Qt.AspectRatioMode.IgnoreAspectRatio,
  177. transformMode = QtCore.Qt.TransformationMode.SmoothTransformation)
  178. return scaled_image