Browse Source

Add Image Fitter

This plugin will rescale an image to fit a rectangle defined by guides.
cinaeco 4 years ago
parent
commit
d5b429e17b

+ 8 - 0
image_fitter_docker.desktop

@@ -0,0 +1,8 @@
+[Desktop Entry]
+Type=Service
+ServiceTypes=Krita/PythonPlugin
+X-KDE-Library=image_fitter_docker
+X-Python-2-Compatible=false
+X-Krita-Manual=Manual.html
+Name=Image Fitter Docker
+Comment=Rescales an image proportionately to fit a rectangle defined by guides.

+ 29 - 0
image_fitter_docker/Manual.html

@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!DOCTYPE html>
+
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head><title>Image Fitter Documentation</title>
+</head>
+<body>
+<h3>Image Fitter Documentation</h3>
+
+<p>
+This extension allows a user to automatically rescale an image to fit a
+rectangle defined by horizontal and vertical guides.
+</p>
+
+<p>
+Users can choose whether to fit horizontally or vertically, when the aspect
+ratio of the image layer does not match that of the rectangle. The image will be
+centered on the rectangle.
+</p>
+
+<h3>Usage</h3>
+
+<p>
+Select a paint layer. Set up at least 4 guides to define a rectangle to be
+fitted to. Press the "Fit" button.
+</p>
+
+</body>
+</html>

+ 10 - 0
image_fitter_docker/__init__.py

@@ -0,0 +1,10 @@
+from krita import DockWidgetFactory, DockWidgetFactoryBase
+from .image_fitter_docker import ImageFitter
+
+DOCKER_ID = 'image_fitter'
+instance = Krita.instance()
+dock_widget_factory = DockWidgetFactory(DOCKER_ID,
+                                        DockWidgetFactoryBase.DockRight,
+                                        ImageFitter)
+
+instance.addDockWidgetFactory(dock_widget_factory)

+ 194 - 0
image_fitter_docker/image_fitter_docker.py

@@ -0,0 +1,194 @@
+from krita import DockWidget
+from PyQt5.QtWidgets import QWidget, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QButtonGroup, QRadioButton, QComboBox, QPushButton, QCheckBox
+from PyQt5.QtCore import QPointF
+from math import ceil, floor
+
+DOCKER_TITLE = 'Image Fitter'
+
+class ImageFitter(DockWidget):
+
+    def __init__(self):
+        super().__init__()
+        self.setWindowTitle(DOCKER_TITLE)
+        widget = QWidget()
+        layout = QVBoxLayout()
+        widget.setLayout(layout)
+
+        layout.addSpacing(10)
+        layout.addWidget(QLabel('Fit an image to a rectangle defined by guides.'))
+
+        # Perform scaling or just centering? Many options below should be in the
+        # same button group to be disabled en-masse.
+        self.scalingCheck = QCheckBox('Scaling')
+        self.scalingCheck.setChecked(False)
+        layout.addWidget(self.scalingCheck)
+
+        self.aspectCheck = QCheckBox('Keep Aspect Ratio')
+        self.aspectCheck.setChecked(True)
+        layout.addWidget(self.aspectCheck)
+
+        # Scale image to fit horizontal or vertical rectangle bounds.
+        self.matchMode = QComboBox()
+        modes = ['Horizontal', 'Vertical']
+        self.matchMode.addItems(modes)
+        row0 = QHBoxLayout()
+        row0.addWidget(QLabel('Scaling Match Mode:'))
+        row0.addWidget(self.matchMode)
+        layout.addLayout(row0)
+
+        # Scaling strategy.
+        # 'Box' is 'Nearest Neighbor'.
+        # https://api.kde.org/appscomplete-api/krita-apidocs/libs/image/html/kis__filter__strategy_8h_source.html#l00083
+        self.stratInput = QComboBox()
+        strategies = ['Box', 'Bicubic', 'Bilinear', 'Lancoz3', 'Bell', 'BSpline', 'Mitchell', 'Hermite']
+        self.stratInput.addItems(strategies)
+        row1 = QHBoxLayout()
+        row1.addWidget(QLabel('Scaling Strategy:'))
+        row1.addWidget(self.stratInput)
+        layout.addLayout(row1)
+
+        # Button!
+        layout.addSpacing(20)
+        goButton = QPushButton("Fit")
+        goButton.setIcon( Krita.instance().icon('animation_play') )
+        layout.addWidget(goButton)
+
+        # Add a stretch to prevent the rest of the content from stretching.
+        layout.addStretch()
+
+        # Add widget to the docker.
+        self.setWidget(widget)
+
+        # Hook up the action to the button.
+        goButton.clicked.connect( self.fitImage )
+
+
+    # notifies when views are added or removed
+    # 'pass' means do not do anything
+    def canvasChanged(self, canvas):
+        pass
+
+
+    ##########
+    # Slots
+    ##########
+
+    # Actually fits the image.
+    def fitImage(self, e):
+
+        # Get the current layer.
+        doc = Krita.instance().activeDocument()
+        layer = doc.activeNode()
+        if layer.type() != 'paintlayer':
+            dialog = QDialog()
+            dialog.setWindowTitle("Paint Layer Required")
+            layout = QVBoxLayout()
+            layout.addWidget(QLabel('Page slicer only works on paint layers. Please select one.'))
+            dialog.setLayout(layout)
+            dialog.exec_()
+            return
+
+        # Get centres and bounds for the layer and the target rectangle.
+        bds = layer.bounds()
+        layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
+        tbds = self.getTargetBounds(doc, layerCentre)
+        targetCentre = self.getCentre(tbds['left'], tbds['right'], tbds['top'], tbds['bottom'])
+
+        # Handle Scaling. Don't scale if not needed.
+        # Repeated scaling does weird things to images.
+        if self.scalingCheck.checkState():
+            doScaling = False
+            targetWidth = tbds['right'] - 1 - tbds['left']
+            targetHeight = tbds['bottom'] - 1 - tbds['top']
+            layerWidth = bds.right() - bds.left()
+            layerHeight = bds.bottom() - bds.top()
+            # Deal with aspect ratio. Simply adjust target dimensions.
+            if self.aspectCheck.checkState():
+                aspectRatio = layerWidth / layerHeight
+                mode = self.matchMode.currentIndex()
+                if mode == 0: # fit horizontally
+                    targetHeight = targetWidth / aspectRatio
+                    if layerHeight != targetHeight:
+                        doScaling = True
+                else: # fit vertically
+                    targetWidth = targetHeight * aspectRatio
+                    if layerWidth != targetWidth:
+                        doScaling = True
+            else:
+                if layerWidth != targetWidth or layerHeight != targetHeight:
+                    doScaling = True
+            # Actual scaling.
+            if doScaling:
+                origin = QPointF(bds.left(), bds.top())
+                layer.scaleNode(origin, targetWidth, targetHeight, self.stratInput.currentText())
+                # Refresh bounds and centre, which are now changed.
+                bds = layer.bounds()
+                layerCentre = self.getCentre(bds.left(), bds.right() + 1, bds.top(), bds.bottom() + 1)
+
+        # Round centre coordinates so that odd centres tend towards top-left.
+        # Rule that was worked out:
+        # - target values are always rounded down.
+        # - layer values are rounded up if target is even, down if odd.
+        for axis in ['x', 'y']:
+            if layerCentre[axis].is_integer() == False and targetCentre[axis].is_integer():
+                layerCentre[axis] = ceil(layerCentre[axis])
+            else:
+                layerCentre[axis] = floor(layerCentre[axis])
+            targetCentre[axis] = floor(targetCentre[axis])
+
+        # Reposition.
+        # The layer move() function works according to the "actual" position of
+        # the layer, which starts off as  the top corner of the image (0, 0).
+        # So, instead of giving move() an absolute position, we have to
+        # translate the layer's position by applying the difference between the
+        # actual layer bounds and the target destination.
+        # mid = getLayerCentre(layer) # Bounds may have changed, if scaling.
+        diffX = layerCentre['x'] - targetCentre['x']
+        diffY = layerCentre['y'] - targetCentre['y']
+        layerPos = layer.position()
+        layer.move(layerPos.x() - diffX, layerPos.y() - diffY)
+
+        # Refresh the view, or the moved will not be immediately reflected.
+        doc.refreshProjection()
+
+
+    def getCentre(self, left, right, top, bottom):
+        return {'x': (left + right)/2, 'y': (top + bottom)/2}
+
+
+    def getTargetBounds(self, doc, layerCentre):
+        # Horizontal guides provide the Vertical y-axis points and vice versa.
+        # Also include the edges of the image for the rectangle.
+        vPoints = doc.horizontalGuides()
+        hPoints = doc.verticalGuides()
+        vPoints.insert(0, 0)
+        vPoints.append(doc.height())
+        hPoints.insert(0, 0)
+        hPoints.append(doc.width())
+
+        # Sanitize points. Guide values are floats, and may contain unexpected
+        # extra fractional values.
+        vPoints = [round(v) for v in vPoints]
+        vPoints.sort()
+        hPoints = [round(h) for h in hPoints]
+        hPoints.sort()
+
+        # Determine the boundaries most relevant to the layer
+        bounds = {}
+        prevV = 0
+        for v in vPoints:
+            if v >= layerCentre['y']:
+                bounds['top'] = prevV
+                bounds['bottom'] = v
+                break
+            prevV = v
+
+        prevH = 0
+        for h in hPoints:
+            if h >= layerCentre['x']:
+                bounds['left'] = prevH
+                bounds['right'] = h
+                break
+            prevH = h
+
+        return bounds