Skip to content

Instantly share code, notes, and snippets.

@josepablo-espinoza
Created April 12, 2024 15:45
Show Gist options
  • Save josepablo-espinoza/aa028689af9e74e6540815dacc147dad to your computer and use it in GitHub Desktop.
Save josepablo-espinoza/aa028689af9e74e6540815dacc147dad to your computer and use it in GitHub Desktop.
from krita import * # type: ignore
from time import time
from PyQt5.QtCore import QByteArray
'''
Pseudo Pixel Perfect (PPP) post-process for Krita:
It removes double pixels (in an L-shaped configuration)
and then thins it using the Zhang-Suen thinning algorithm,
aiming to recreate the feel of a pixel-perfect stabilization brush.
The script checks for opaque pixels in a selection
and processes them based on their neighbors
to remove doubles, thin the line, and remove doubles again.
TODO: Locate the checkNeighbors algorithm.
'''
'''
Install:
1. Save or download this script.
2. Place it in any preferred folder.
3. Name it e.g. "pseudo-pixel-perfect.py".
4. On Krita use ten script plugin to assing this script to a shortcut.
How to use:
1. Draw
2. Create a selection covering the entire drawing or specific areas you want to clean.
3. Press the shortcut key you set earlier to activate the script.
Warning:
This process is DESTRUCTIVE. While you can usually undo it, there are some edge cases where unexpected behavior may occur:
- Large areas (over 1000 pixels) and thick lines.
- Lines with transparency.
- Very thick lines (over 10 pixels).
- Unusual selections may cause undo to behave unexpectedly.
This are no problems in the scope of pixel art, non the less exercise caution.
'''
NAME = 'PPP'
VERSION = "1.0"
TRANSPARENT_BYTE = b'\x00\x00\x00\x00'
DOC = Krita.instance().activeDocument() # type: ignore
CURRENT_LAYER = DOC.activeNode()
'''
TODO: Investigate this algorithm; something appears incorrect.
Note: Potential issues may arise if pixels have transparency or are not fully opaque.
'''
def checkNeighbors(r, l, u, d, ru, rd, lu, ld):
t = TRANSPARENT_BYTE
#□□□
#□▣□
#□□□
if ((r == t and l == t and u == t and d == t and ru == t and rd == t and lu == t and ld == t)):
return True
#□□□
#□▣■
#□■□
elif r != t and l == t and u == t and d != t and ru == t and rd == t and lu == t and ld == t:
return True
#□□□
#■▣□
#□■□
elif l != t and r == t and u == t and d != t and ru == t and rd == t and lu == t and ld == t:
return True
#□■□
#□▣■
#□□□
elif r != t and l == t and d == t and u != t and ru == t and rd == t and lu == t and ld == t:
return True
#□■□
#■▣□
#□□□
elif l != t and r == t and d == t and u != t and ru == t and rd == t and lu == t and ld == t:
return True
#□■■
#■▣□
#■□□
elif l != t and r == t and d == t and u != t and ru != t and rd == t and lu == t and ld != t:
return True
#■□□
#■▣□
#□■■
elif l != t and r == t and u == t and d != t and rd != t and ru == t and ld == t and lu != t:
return True
#□□□
#■▣□
#□■■
elif l != t and r == t and u == t and d != t and rd != t and ru == t and ld == t and lu == t:
return True
#□□■
#□▣■
#□■□
elif r != t and l == t and u == t and d != t and ru != t and rd == t and lu == t:
return True
#□■■
#■▣□
#□□□
elif l != t and r == t and d == t and u != t and rd == t and ru != t and ld == t and lu == t:
return True
#■■□
#□▣■
#□□□
elif r != t and l == t and d == t and u != t and rd == t and ru == t and ld == t and lu != t:
return True
#□■□
#□▣■
#□□■
elif r != t and l == t and d == t and u != t and rd != t and ru == t and ld == t and lu == t:
return True
#□■□
#■▣□
#■□□
elif l != t and r == t and d == t and u != t and rd == t and ru == t and ld != t and lu == t:
return True
else:
return False
'''
Zhang-Suen thinning algorithm
https://web.archive.org/web/20160322113207/http://opencv-code.com/quick-tips/implementation-of-thinning-algorithm-in-opencv/
'''
def thinningIteration(selection, iter):
sh = selection.height()
sw = selection.width()
sx = selection.x()
sy = selection.y()
selection_qbytearray = selection.pixelData(sx,sy,sw,sh)
t = TRANSPARENT_BYTE
byte_ = QByteArray(TRANSPARENT_BYTE)
'''
The selection_qbytearray is a flat array,
whereas the selection is a 2D array.
The loop variable is used to keep both aligned.
'''
loop = 0
for j in range(sy, sy + sh):
for i in range(sx, sx + sw):
if selection_qbytearray.at(loop) == b'\xff' and CURRENT_LAYER.pixelData(i,j,1,1) != TRANSPARENT_BYTE:
# p2-9 follows a counter-clockwise order.
p6 = CURRENT_LAYER.pixelData(i+1, j+0, 1,1)#p6
p2 = CURRENT_LAYER.pixelData(i-1, j+0, 1,1)#p2
p8 = CURRENT_LAYER.pixelData(i+0, j-1, 1,1)#p8
p4 = CURRENT_LAYER.pixelData(i+0, j+1, 1,1)#p4
p7 = CURRENT_LAYER.pixelData(i+1, j-1, 1,1)#p7
p5 = CURRENT_LAYER.pixelData(i+1, j+1, 1,1)#p5
p9 = CURRENT_LAYER.pixelData(i-1, j-1, 1,1)#p9
p3 = CURRENT_LAYER.pixelData(i-1, j+1, 1,1)#p3
A = int(p2 ==t and p3 !=t) + int(p3 ==t and p4 !=t) + \
int(p4 ==t and p5 !=t) + int(p5 ==t and p6 !=t) + \
int(p6 ==t and p7 !=t) + int(p7 ==t and p8 !=t) + \
int(p8 ==t and p9 !=t) + int(p9 ==t and p2 !=t)
B = int(p2 !=t) + int(p3 !=t) + int(p4 !=t) + int(p5 !=t) + int(p6 !=t) + int(p7 !=t) + int(p8 !=t) + int(p9 !=t);
m1 = (int(p2 !=t) * int(p4 !=t) * int(p6 !=t)) if iter == 0 else (int(p2 !=t) * int(p4 !=t) * int(p8 !=t));
m2 = (int(p4 !=t) * int(p6 !=t) * int(p8 !=t)) if iter == 0 else (int(p2 !=t) * int(p6 !=t) * int(p8 !=t));
if (A == 1 and (B >= 2 and B <= 6) and m1 == 0 and m2 == 0):
CURRENT_LAYER.setPixelData(byte_, i, j, 1, 1)
loop += 1
'''
Zhang-Suen thinning algorithm typically employs
a do-while loop and checks against a mask.
For simplicity, two passes (iterations)
are usually sufficient.
Rerun if necessary.
'''
def thinning(selection):
thinningIteration(selection, 0)
thinningIteration(selection, 1)
'''
Loops through a selection. For each opaque pixel it checks its immediate neighbors.
Based on an algorithm, it identifies L-shaped arrangements
of pixels and breaks them by making the pixel transparent.
'''
def remove_doubles(selection):
sh = selection.height()
sw = selection.width()
sx = selection.x()
sy = selection.y()
selection_qbytearray = selection.pixelData(sx,sy,sw,sh)
byte_ = QByteArray(TRANSPARENT_BYTE)#transparent
'''
The selection_qbytearray is a flat array,
whereas the selection is a 2D array.
The loop variable is used to keep both aligned.
'''
loop = 0
for j in range(sy, sy + sh):
for i in range(sx, sx + sw):
if selection_qbytearray.at(loop) == b'\xff' and CURRENT_LAYER.pixelData(i,j,1,1) != TRANSPARENT_BYTE:
r = CURRENT_LAYER.pixelData(i+1, j+0, 1,1)#p6
l = CURRENT_LAYER.pixelData(i-1, j+0, 1,1)#p2
u = CURRENT_LAYER.pixelData(i+0, j-1, 1,1)#p8
d = CURRENT_LAYER.pixelData(i+0, j+1, 1,1)#p4
ru = CURRENT_LAYER.pixelData(i+1, j-1, 1,1)#p7
rd = CURRENT_LAYER.pixelData(i+1, j+1, 1,1)#p5
lu = CURRENT_LAYER.pixelData(i-1, j-1, 1,1)#p9
ld = CURRENT_LAYER.pixelData(i-1, j+1, 1,1)#p3
if checkNeighbors(r, l, u, d, ru, rd, lu, ld):
CURRENT_LAYER.setPixelData(byte_, i, j, 1, 1)
loop += 1
if not CURRENT_LAYER.locked() and CURRENT_LAYER.visible():
selection = DOC.selection()
if selection:
start = time()
remove_doubles(selection)
thinning(selection)
remove_doubles(selection)
DOC.refreshProjection()
DOC.waitForDone()
end = time()
Krita.instance().activeWindow().activeView().showFloatingMessage(f"{NAME} completed! Duration: {round(end-start)} seconds.", Krita.instance().icon("dialog-ok"), 2000, 1) # type: ignore
print(f"{NAME} completed! Duration: {round(end-start)} seconds.")
else:
QMessageBox.information(QWidget(), "", "Please select an area first. Note that the wider the selection, the slower the process will be. \n This process is destructive, and in edge cases, a backup is advisable before use.") # type: ignore
print("No area has been selected.!")
else:
QMessageBox.information(QWidget(), "", "The layer is either locked or invisible!") # type: ignore
print("The layer is either locked or invisible!")
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment