image_fitter_docker.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. from krita import DockWidget
  2. from PyQt5.QtWidgets import QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QButtonGroup, QRadioButton, QComboBox, QPushButton, QCheckBox
  3. from PyQt5.QtCore import QPointF
  4. from math import ceil, floor
  5. DOCKER_TITLE = 'Image Fitter'
  6. class ImageFitter(DockWidget):
  7. def __init__(self):
  8. super().__init__()
  9. self.setWindowTitle(DOCKER_TITLE)
  10. widget = QWidget()
  11. layout = QVBoxLayout()
  12. widget.setLayout(layout)
  13. layout.addSpacing(10)
  14. layout.addWidget(QLabel('Fit an image to a rectangle defined by guides.'))
  15. # Perform scaling or just centering? Many options below should be in the
  16. # same button group to be disabled en-masse.
  17. self.scalingCheck = QCheckBox('Scaling')
  18. self.scalingCheck.setChecked(False)
  19. layout.addWidget(self.scalingCheck)
  20. self.aspectCheck = QCheckBox('Keep Aspect Ratio')
  21. self.aspectCheck.setChecked(True)
  22. layout.addWidget(self.aspectCheck)
  23. # Scale image to fit horizontal or vertical rectangle bounds.
  24. self.matchMode = QComboBox()
  25. modes = ['Horizontal', 'Vertical']
  26. self.matchMode.addItems(modes)
  27. row0 = QHBoxLayout()
  28. row0.addWidget(QLabel('Scaling Match Mode:'))
  29. row0.addWidget(self.matchMode)
  30. layout.addLayout(row0)
  31. # Scaling strategy.
  32. # 'Box' is 'Nearest Neighbor'.
  33. # https://api.kde.org/appscomplete-api/krita-apidocs/libs/image/html/kis__filter__strategy_8h_source.html#l00083
  34. self.stratInput = QComboBox()
  35. strategies = ['Bicubic', 'Box', 'Bilinear', 'Lancoz3', 'Bell', 'BSpline', 'Mitchell', 'Hermite']
  36. self.stratInput.addItems(strategies)
  37. row1 = QHBoxLayout()
  38. row1.addWidget(QLabel('Scaling Strategy:'))
  39. row1.addWidget(self.stratInput)
  40. layout.addLayout(row1)
  41. # Button!
  42. layout.addSpacing(20)
  43. goButton = QPushButton("Fit")
  44. goButton.setIcon( Krita.instance().icon('animation_play') )
  45. layout.addWidget(goButton)
  46. # Add a stretch to prevent the rest of the content from stretching.
  47. layout.addStretch()
  48. # Add widget to the docker.
  49. self.setWidget(widget)
  50. # Hook up the action to the button.
  51. goButton.clicked.connect( self.fitImageLoop )
  52. # notifies when views are added or removed
  53. # 'pass' means do not do anything
  54. def canvasChanged(self, canvas):
  55. pass
  56. ##########
  57. # Slots
  58. ##########
  59. def fitImageLoop(self, e):
  60. self.doc = Krita.instance().activeDocument()
  61. # Get the selected layer(s).
  62. w = Krita.instance().activeWindow()
  63. v = w.activeView()
  64. layers = v.selectedNodes()
  65. didNotRun = True
  66. for layer in layers:
  67. if layer.type() == 'paintlayer':
  68. self.fitImage(layer)
  69. didNotRun = False
  70. if layer.type() == 'vectorlayer' and not self.scalingCheck.isChecked():
  71. self.fitImage(layer)
  72. didNotRun = False
  73. if didNotRun:
  74. dialog = QDialog()
  75. dialog.setWindowTitle("Paint Layer Required")
  76. layout = QVBoxLayout()
  77. layout.addWidget(QLabel('Image Fitter only works on paint layers. Please select one.'))
  78. dialog.setLayout(layout)
  79. dialog.exec_()
  80. else:
  81. # Refresh the view, or changes will not be immediately reflected.
  82. self.doc.refreshProjection()
  83. # Actually fits the image.
  84. def fitImage(self, layer):
  85. # Get centres and bounds for the layer and the target rectangle.
  86. bds = layer.bounds()
  87. layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
  88. tbds = self.getTargetBounds(layerCentre)
  89. targetCentre = self.getCentre(tbds['left'], tbds['right'], tbds['top'], tbds['bottom'])
  90. # Handle Scaling. Don't scale if not needed.
  91. # Repeated scaling does weird things to images.
  92. if self.scalingCheck.isChecked():
  93. doScaling = False
  94. targetWidth = tbds['right'] - tbds['left']
  95. targetHeight = tbds['bottom'] - tbds['top']
  96. layerWidth = bds.right() - bds.left()
  97. layerHeight = bds.bottom() - bds.top()
  98. # Deal with aspect ratio. Simply adjust target dimensions.
  99. if self.aspectCheck.isChecked():
  100. aspectRatio = layerWidth / layerHeight
  101. mode = self.matchMode.currentIndex()
  102. if mode == 0: # fit horizontally
  103. targetHeight = targetWidth / aspectRatio
  104. if layerHeight != targetHeight:
  105. doScaling = True
  106. else: # fit vertically
  107. targetWidth = targetHeight * aspectRatio
  108. if layerWidth != targetWidth:
  109. doScaling = True
  110. else:
  111. if layerWidth != targetWidth or layerHeight != targetHeight:
  112. doScaling = True
  113. # Actual scaling.
  114. if doScaling:
  115. origin = QPointF(bds.left(), bds.top())
  116. layer.scaleNode(origin, targetWidth, targetHeight, self.stratInput.currentText())
  117. # Refresh bounds and centre, which are now changed.
  118. bds = layer.bounds()
  119. layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
  120. # Round centre coordinates so that odd centres tend towards top-left.
  121. # Rule that was worked out:
  122. # - target values are always rounded down.
  123. # - layer values are rounded up if target is even, down if odd.
  124. for axis in ['x', 'y']:
  125. if layerCentre[axis].is_integer() == False and targetCentre[axis].is_integer():
  126. layerCentre[axis] = ceil(layerCentre[axis])
  127. else:
  128. layerCentre[axis] = floor(layerCentre[axis])
  129. targetCentre[axis] = floor(targetCentre[axis])
  130. # Reposition.
  131. # The layer move() function works according to the "actual" position of
  132. # the layer, which starts off as the top corner of the image (0, 0).
  133. # So, instead of giving move() an absolute position, we have to
  134. # translate the layer's position by applying the difference between the
  135. # actual layer bounds and the target destination.
  136. # mid = getLayerCentre(layer) # Bounds may have changed, if scaling.
  137. diffX = layerCentre['x'] - targetCentre['x']
  138. diffY = layerCentre['y'] - targetCentre['y']
  139. layerPos = layer.position()
  140. layer.move(layerPos.x() - diffX, layerPos.y() - diffY)
  141. def getCentre(self, left, right, top, bottom):
  142. return {'x': (left + right)/2, 'y': (top + bottom)/2}
  143. def getTargetBounds(self, layerCentre):
  144. # Horizontal guides provide the Vertical y-axis points and vice versa.
  145. # Also include the edges of the image for the rectangle.
  146. vPoints = self.doc.horizontalGuides()
  147. hPoints = self.doc.verticalGuides()
  148. vPoints.insert(0, 0)
  149. vPoints.append(self.doc.height())
  150. hPoints.insert(0, 0)
  151. hPoints.append(self.doc.width())
  152. # Sanitize points. Guide values are floats, and may contain unexpected
  153. # extra fractional values.
  154. vPoints = [round(v) for v in vPoints]
  155. vPoints.sort()
  156. hPoints = [round(h) for h in hPoints]
  157. hPoints.sort()
  158. # Determine the boundaries most relevant to the layer
  159. bounds = {}
  160. prevV = 0
  161. for v in vPoints:
  162. if v >= layerCentre['y']:
  163. bounds['top'] = prevV
  164. bounds['bottom'] = v
  165. break
  166. prevV = v
  167. prevH = 0
  168. for h in hPoints:
  169. if h >= layerCentre['x']:
  170. bounds['left'] = prevH
  171. bounds['right'] = h
  172. break
  173. prevH = h
  174. return bounds