Skip to content

Instantly share code, notes, and snippets.

@Glidias
Last active October 7, 2025 05:46
Show Gist options
  • Save Glidias/bfdbd56e297b3d69a1d5a02b1d923c2d to your computer and use it in GitHub Desktop.
Save Glidias/bfdbd56e297b3d69a1d5a02b1d923c2d to your computer and use it in GitHub Desktop.
Points Editor script for Krita
from krita import Krita
from PyQt5.QtWidgets import (
QDialog, QVBoxLayout, QHBoxLayout, QPushButton,
QLabel, QMessageBox, QRadioButton, QFileDialog, QScrollArea, QApplication
)
from PyQt5.QtGui import QPixmap, QPainter, QPen, QColor, QFont, QImage
from PyQt5.QtCore import Qt, QPoint, QEvent
import json
import os
class PointsCanvas(QLabel):
def __init__(self, parent=None):
super().__init__(parent)
self.points = []
self.dragging_index = None
self.setAlignment(Qt.AlignTop | Qt.AlignLeft)
self.setStyleSheet("background-color: #111; border: 1px solid #444;")
self.setMouseTracking(True)
self.dialog_ref = parent
self.zoom_factor = 1.0
self.original_pixmap = None
self.grabGesture(Qt.PinchGesture) # Enable pinch gesture for macOS
def set_document_and_thumbnail_size(self, doc_width, doc_height, thumb_width, thumb_height):
self.doc_width = doc_width
self.doc_height = doc_height
self.thumb_width = thumb_width
self.thumb_height = thumb_height
self.base_scale_x = thumb_width / doc_width if doc_width > 0 else 1.0
self.base_scale_y = thumb_height / doc_height if doc_height > 0 else 1.0
def set_background_pixmap(self, pixmap):
self.original_pixmap = pixmap
self.apply_zoom()
def apply_zoom(self):
if self.original_pixmap:
scaled = self.original_pixmap.scaled(
int(self.original_pixmap.width() * self.zoom_factor),
int(self.original_pixmap.height() * self.zoom_factor),
Qt.KeepAspectRatio,
Qt.SmoothTransformation
)
self.setPixmap(scaled)
self.resize(scaled.size())
# mousewheel event
def wheelEvent(self, event):
# check if shift key modifier is pressed
if event.modifiers() & Qt.ShiftModifier:
delta = event.angleDelta().y()
if delta > 0:
new_zoom = self.zoom_factor * 1.1
else:
new_zoom = self.zoom_factor / 1.1
if 0.1 <= new_zoom <= 10.0:
self.zoom_factor = new_zoom
self.apply_zoom()
event.accept()
def screen_to_doc(self, screen_x, screen_y):
current_scale_x = self.base_scale_x * self.zoom_factor
current_scale_y = self.base_scale_y * self.zoom_factor
doc_x = int(screen_x / current_scale_x) if current_scale_x > 0 else screen_x
doc_y = int(screen_y / current_scale_y) if current_scale_y > 0 else screen_y
doc_x = max(0, min(doc_x, self.doc_width - 1))
doc_y = max(0, min(doc_y, self.doc_height - 1))
return doc_x, doc_y
def doc_to_screen(self, doc_x, doc_y):
current_scale_x = self.base_scale_x * self.zoom_factor
current_scale_y = self.base_scale_y * self.zoom_factor
screen_x = int(doc_x * current_scale_x)
screen_y = int(doc_y * current_scale_y)
return screen_x, screen_y
def add_point(self, screen_x, screen_y, point_type):
doc_x, doc_y = self.screen_to_doc(screen_x, screen_y)
same_type = [p for p in self.points if p['type'] == point_type]
index = len(same_type)
self.points.append({'x': doc_x, 'y': doc_y, 'type': point_type, 'index': index})
self.update()
def clear_points(self):
self.points = []
self.update()
def get_points_for_export(self):
pos = [{'x': p['x'], 'y': p['y']} for p in self.points if p['type'] == 'pos']
neg = [{'x': p['x'], 'y': p['y']} for p in self.points if p['type'] == 'neg']
return pos, neg
def load_points_from_export(self, pos_points, neg_points):
self.points = []
for p in pos_points:
self.points.append({'x': p['x'], 'y': p['y'], 'type': 'pos', 'index': len([x for x in self.points if x['type'] == 'pos'])})
for p in neg_points:
self.points.append({'x': p['x'], 'y': p['y'], 'type': 'neg', 'index': len([x for x in self.points if x['type'] == 'neg'])})
self._reindex()
self.update()
def _reindex(self):
pos_idx = 0
neg_idx = 0
for p in self.points:
if p['type'] == 'pos':
p['index'] = pos_idx
pos_idx += 1
else:
p['index'] = neg_idx
neg_idx += 1
def get_point_at(self, screen_x, screen_y, tol=12):
doc_x, doc_y = self.screen_to_doc(screen_x, screen_y)
current_scale_x = self.base_scale_x * self.zoom_factor
doc_tol = tol / current_scale_x if current_scale_x > 0 else tol
for i, p in enumerate(self.points):
dx = p['x'] - doc_x
dy = p['y'] - doc_y
if dx*dx + dy*dy <= doc_tol*doc_tol:
return i
return None
def delete_point_and_pair(self, idx):
if idx < 0 or idx >= len(self.points):
return
target = self.points[idx]
t_idx = target['index']
t_type = target['type']
pair_i = None
for i, p in enumerate(self.points):
if p['index'] == t_idx and p['type'] != t_type:
pair_i = i
break
to_del = sorted([idx, pair_i] if pair_i is not None else [idx], reverse=True)
for i in to_del:
del self.points[i]
self._reindex()
self.update()
def mousePressEvent(self, event):
if event.button() != Qt.LeftButton:
return
screen_x, screen_y = event.x(), event.y()
pt_idx = self.get_point_at(screen_x, screen_y)
modifiers = event.modifiers()
if modifiers & Qt.ControlModifier:
if pt_idx is not None:
self.delete_point_and_pair(pt_idx)
# OPPOSITE: Shift only
elif modifiers == Qt.ShiftModifier:
dialog = self.dialog_ref
if dialog and hasattr(dialog, 'current_mode'):
opposite = 'neg' if dialog.current_mode == 'pos' else 'pos'
if pt_idx is None:
self.add_point(screen_x, screen_y, opposite)
# REGULAR: No modifiers or other combinations
else:
if pt_idx is not None:
self.dragging_index = pt_idx
else:
dialog = self.dialog_ref
if dialog and hasattr(dialog, 'current_mode'):
self.add_point(screen_x, screen_y, dialog.current_mode)
self.update()
def mouseMoveEvent(self, event):
if self.dragging_index is not None and self.dragging_index < len(self.points):
screen_x, screen_y = event.x(), event.y()
doc_x, doc_y = self.screen_to_doc(screen_x, screen_y)
self.points[self.dragging_index]['x'] = doc_x
self.points[self.dragging_index]['y'] = doc_y
self.update()
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.dragging_index = None
def event(self, event):
if event.type() == QEvent.Gesture:
return self.gestureEvent(event)
return super().event(event)
def gestureEvent(self, event):
pinch = event.gesture(Qt.PinchGesture)
if pinch:
scale_factor = pinch.scaleFactor()
if abs(scale_factor - 1.0) > 0.01:
new_zoom = self.zoom_factor * scale_factor
if 0.1 <= new_zoom <= 10.0:
self.zoom_factor = new_zoom
self.apply_zoom()
return True
return False
def paintEvent(self, event):
super().paintEvent(event)
if not self.points:
return
painter = QPainter(self)
painter.setRenderHint(QPainter.Antialiasing)
font_size = max(8, int(10 * self.zoom_factor))
font = QFont("Arial", font_size, QFont.Bold)
painter.setFont(font)
for p in self.points:
screen_x, screen_y = self.doc_to_screen(p['x'], p['y'])
color = QColor(0, 255, 0) if p['type'] == 'pos' else QColor(255, 0, 0)
pen_width = max(1, int(2 * self.zoom_factor))
pen = QPen(color, pen_width)
painter.setPen(pen)
radius = max(3, int(7 * self.zoom_factor))
painter.drawEllipse(QPoint(screen_x, screen_y), radius, radius)
text = str(p['index'])
text_x = screen_x + int(12 * self.zoom_factor)
text_y = screen_y + int(5 * self.zoom_factor)
# White outline
painter.setPen(QColor(255, 255, 255))
offsets = [(-1,-1), (-1,0), (-1,1), (0,-1), (0,1), (1,-1), (1,0), (1,1)]
for dx, dy in offsets:
painter.drawText(text_x + dx, text_y + dy, text)
# Colored fill
fill_color = QColor(0, 80, 0) if p['type'] == 'pos' else QColor(80, 0, 0)
painter.setPen(fill_color)
painter.drawText(text_x, text_y, text)
painter.end()
class PointsEditorDialog(QDialog):
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Points Editor")
self.resize(800, 700)
self.current_mode = 'pos'
self.last_save_path = None
self.result_points = None
self.user_initiated_close = False
self.setup_ui()
self.load_background()
if self.auto_load():
print("✅ Auto-loaded previously saved points.")
def setup_ui(self):
main_layout = QVBoxLayout()
control_layout = QHBoxLayout()
self.radio_pos = QRadioButton("Positive (Green)")
self.radio_neg = QRadioButton("Negative (Red)")
self.radio_pos.setChecked(True)
self.radio_pos.toggled.connect(lambda: setattr(self, 'current_mode', 'pos'))
self.radio_neg.toggled.connect(lambda: setattr(self, 'current_mode', 'neg'))
save_btn = QPushButton("Save Points...")
load_btn = QPushButton("Load Points...")
clear_btn = QPushButton("Clear All")
self.ok_btn = QPushButton("OK")
self.close_btn = QPushButton("Close")
save_btn.clicked.connect(self.save_points)
load_btn.clicked.connect(self.load_points)
clear_btn.clicked.connect(lambda: self.canvas.clear_points())
self.ok_btn.clicked.connect(self.on_ok_clicked)
self.close_btn.clicked.connect(self.on_close_clicked)
control_layout.addWidget(self.radio_pos)
control_layout.addWidget(self.radio_neg)
control_layout.addWidget(save_btn)
control_layout.addWidget(load_btn)
control_layout.addWidget(clear_btn)
control_layout.addStretch()
control_layout.addWidget(self.ok_btn)
control_layout.addWidget(self.close_btn)
main_layout.addLayout(control_layout)
self.canvas = PointsCanvas()
self.canvas.dialog_ref = self
scroll_area = QScrollArea()
scroll_area.setWidgetResizable(True)
scroll_area.setWidget(self.canvas)
scroll_area.setStyleSheet("QScrollArea { border: none; }")
main_layout.addWidget(scroll_area)
self.setLayout(main_layout)
def load_background(self):
try:
app = Krita.instance()
doc = app.activeDocument()
if not doc:
QMessageBox.warning(self, "No Document", "Open an image first.")
return
orig_w, orig_h = doc.width(), doc.height()
thumb_qimage = doc.projection(0, 0, orig_w, orig_h)
if thumb_qimage.isNull():
raise Exception("Thumbnail is null")
thumb_w = thumb_qimage.width()
thumb_h = thumb_qimage.height()
thumb_qimage = thumb_qimage.convertToFormat(QImage.Format_RGBA8888)
pixmap = QPixmap.fromImage(thumb_qimage)
self.canvas.set_background_pixmap(pixmap)
self.canvas.set_document_and_thumbnail_size(orig_w, orig_h, thumb_w, thumb_h)
except Exception as e:
QMessageBox.critical(self, "Error", f"Background load failed:\n{str(e)}")
def get_autosave_path(self):
try:
doc = Krita.instance().activeDocument()
if not doc or not doc.fileName():
return os.path.expanduser("~/krita_points_autosave.json")
doc_path = doc.fileName()
return doc_path + ".points.json"
except:
return os.path.expanduser("~/krita_points_autosave.json")
def auto_save(self):
try:
pos_pts, neg_pts = self.canvas.get_points_for_export()
if not pos_pts and not neg_pts:
return
autosave_path = self.get_autosave_path()
data = {
"coordinates_positive": pos_pts,
"coordinates_negative": neg_pts,
"auto_saved": True,
"document": Krita.instance().activeDocument().fileName() if Krita.instance().activeDocument() else "unsaved"
}
with open(autosave_path, 'w') as f:
json.dump(data, f)
except:
pass
def auto_load(self):
try:
autosave_path = self.get_autosave_path()
if os.path.exists(autosave_path):
with open(autosave_path, 'r') as f:
data = json.load(f)
if data.get("auto_saved"):
pos_pts = data.get("coordinates_positive", [])
neg_pts = data.get("coordinates_negative", [])
self.canvas.load_points_from_export(pos_pts, neg_pts)
return True
except:
pass
return False
def on_ok_clicked(self):
self.user_initiated_close = True
self.result_points = self.canvas.get_points_for_export()
self.auto_save()
self.accept()
def on_close_clicked(self):
self.user_initiated_close = True
self.result_points = None
self.auto_save()
self.reject()
def closeEvent(self, event):
if not self.user_initiated_close:
self.result_points = None
super().closeEvent(event)
def get_result(self):
return self.result_points
def save_points(self):
pos_pts, neg_pts = self.canvas.get_points_for_export()
data = {
"coordinates_positive": pos_pts,
"coordinates_negative": neg_pts
}
filename, _ = QFileDialog.getSaveFileName(
self, "Save Points", self.last_save_path or "", "JSON Files (*.json)"
)
if not filename:
return
if not filename.endswith(".json"):
filename += ".json"
try:
with open(filename, 'w') as f:
json.dump(data, f)
self.last_save_path = filename
self.auto_save()
# QMessageBox.information(self, "Saved", f"Saved {len(pos_pts)} positive and {len(neg_pts)} negative points.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to save:\n{str(e)}")
def load_points(self):
filename, _ = QFileDialog.getOpenFileName(
self, "Load Points", self.last_save_path or "", "JSON Files (*.json)"
)
if not filename:
return
try:
with open(filename, 'r') as f:
data = json.load(f)
pos_pts = data.get("coordinates_positive", [])
neg_pts = data.get("coordinates_negative", [])
self.canvas.load_points_from_export(pos_pts, neg_pts)
self.last_save_path = filename
self.auto_save()
# QMessageBox.information(self, "Loaded", f"Loaded {len(pos_pts)} positive and {len(neg_pts)} negative points.")
except Exception as e:
QMessageBox.critical(self, "Error", f"Failed to load:\n{str(e)}")
# === RUN IN SCRIPTER ===
# """
app = Krita.instance()
if not app.activeWindow():
QMessageBox.critical(None, "Error", "No Krita window!")
else:
dialog = PointsEditorDialog(app.activeWindow().qwindow())
result = dialog.exec_()
points = dialog.get_result()
if points is not None:
pos_points, neg_points = points
print(f"✅ OK clicked! Positive points: {pos_points}")
print(f"✅ OK clicked! Negative points: {neg_points}")
print(json)
else:
if result == QDialog.Rejected:
print("CloseOperation: Close button clicked (auto-saved, no points returned)")
else:
print("CloseOperation: X button clicked (no auto-save, no points returned)")
# """
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment