Snapshot.py 9.3 KB

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