Snapshot.py 8.3 KB

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