-
Notifications
You must be signed in to change notification settings - Fork 76
Expand file tree
/
Copy pathhuaban.py
More file actions
2295 lines (2036 loc) · 91 KB
/
huaban.py
File metadata and controls
2295 lines (2036 loc) · 91 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QFrame, QGridLayout, QMenu, QFileDialog, QInputDialog
from PySide6.QtGui import QPainter, QPixmap, QPen, QColor, QMouseEvent, QPainterPath, QBrush, QIcon, QCursor
from PySide6.QtCore import Qt, QPoint, QSize, QRect, Signal
class DoubleClickLabel(QLabel):
"""支持双击编辑的标签"""
double_clicked = Signal()
def __init__(self, text='', parent=None):
super().__init__(text, parent)
self.setCursor(Qt.PointingHandCursor)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton:
self.double_clicked.emit()
super().mouseDoubleClickEvent(event)
class HuabanCanvas(QWidget):
# 选区右键触发信号(携带矩形区域)
magic_generate_requested = Signal(QRect)
# 智能生成信号(携带矩形区域和遮罩)
smart_generate_requested = Signal(QRect, object) # (rect, mask)
# 智能修改信号(携带矩形区域和遮罩)
smart_edit_requested = Signal(QRect, object) # (rect, mask)
# 智能区域修改信号(携带矩形区域)
smart_region_edit_requested = Signal(QRect) # (rect)
# 智能替换信号(携带矩形区域和遮罩)
smart_replace_requested = Signal(QRect, object) # (rect, mask)
# 画板尺寸改变信号
size_changed = Signal(int, int) # (width, height)
# 请求自适应缩放信号
auto_fit_requested = Signal()
# 上传到参考图信号
upload_to_ref_requested = Signal(QPixmap)
"""
简易画板:支持画笔与橡皮擦。
- 红色背景作为面板区域视觉参照。
- 采用 QPixmap 作为缓冲,鼠标拖拽绘制。
"""
def __init__(self, parent=None):
super().__init__(parent)
# 语言代码与翻译表(用于默认图层名称)
self._lang_code = 'zh'
self._i18n = self._get_i18n(self._lang_code)
# 允许更小尺寸,便于预设比例缩放
self.setMinimumSize(200, 200)
# 画板背景改为白色
self.bg_color = QColor(255, 255, 255)
# 绘制层(笔刷/橡皮擦)单独存储,便于与图层叠加控制
self.paint_layer = QPixmap(1200, 800)
self.paint_layer.fill(Qt.transparent)
# 图层列表:每项为 dict {pix:QPixmap, x:int, y:int, enabled:bool, name:str}
self.layers: list[dict] = []
# 图层唯一ID序列
self._layer_seq: int = 0
# 当前激活的绘制图层ID(None 表示未选择,回退到 paint_layer)
self._active_layer_id: int | None = None
self.last_pos: QPoint | None = None
# 工具:none | brush | eraser | move | rect_select | magic_wand | bucket | eyedropper | auto_select
self.tool = 'none'
# 默认使用黑色,避免在白色背景上不可见
self.brush_color = QColor(0, 0, 0)
self.brush_size = 10
self.eraser_size = 20
# 选择状态与移动状态
self._rect_select_active = False
self._sel_rect_start: QPoint | None = None
self._sel_rect_current: QPoint | None = None
self.selection_rect: QRect | None = None
self.selection_mask = None # 可选:QImage (Alpha8),由魔术棒生成
self._move_last_pos: QPoint | None = None
# 变换/缩放:句柄尺寸与拖拽状态
self._transform_handle_size: int = 8
self._resize_handle: str | None = None # tl,tr,bl,br,l,r,t,b
self._resize_start_rect: QRect | None = None
self._resize_start_pix: QPixmap | None = None
self._resize_start_pos: QPoint | None = None
self._resize_keep_ratio: bool = False
# 接受拖拽(从缩略图、系统或其它程序拖入图片/文件)
self.setAcceptDrops(True)
# 初始使用系统光标
self._last_cursor_kind = 'none'
# 历史栈用于撤销
self._history: list[dict] = []
self._history_limit: int = 30
# 允许接收键盘事件
self.setFocusPolicy(Qt.StrongFocus)
# 高级魔法生成标记
self._adv_magic_pending: bool = False
# 缩放比例(用于显示缩放,不影响实际像素)
self._zoom_scale: float = 1.0
def set_tool(self, tool: str):
self.tool = tool
# 工具切换时更新光标显示
self._update_cursor()
def paintEvent(self, event):
p = QPainter(self)
# 应用缩放变换
if self._zoom_scale != 1.0:
p.scale(self._zoom_scale, self._zoom_scale)
p.fillRect(QRect(0, 0, self.paint_layer.width(), self.paint_layer.height()), self.bg_color)
# 先画绘制层
p.drawPixmap(0, 0, self.paint_layer)
# 再叠加启用的图层
for layer in self.layers:
if layer.get('enabled', True):
p.drawPixmap(layer.get('x', 0), layer.get('y', 0), layer['pix'])
# 绘制自动选区的绿色半透明遮罩(人物轮廓高亮)
if self.selection_mask is not None:
from PySide6.QtGui import QImage
# 创建绿色半透明覆盖层
w = len(self.selection_mask[0]) if self.selection_mask else 0
h = len(self.selection_mask) if self.selection_mask else 0
if w > 0 and h > 0:
# 创建一个图像来存储遮罩
overlay_img = QImage(w, h, QImage.Format_ARGB32)
overlay_img.fill(Qt.transparent)
# 将遮罩转换为图像(批量处理更高效)
green_color = QColor(52, 168, 83, 100).rgba() # 绿色,alpha=100
for y in range(h):
for x in range(w):
if self.selection_mask[y][x]: # 如果该像素在选区内
overlay_img.setPixel(x, y, green_color)
# 转换为 QPixmap 并绘制
overlay = QPixmap.fromImage(overlay_img)
p.drawPixmap(0, 0, overlay)
# 在移动工具下,为当前激活图层绘制蓝色变换框与句柄
if self.tool == 'move' and self._active_layer_id is not None:
al = self._get_active_layer()
if al is not None:
rect = QRect(al.get('x', 0), al.get('y', 0), al['pix'].width(), al['pix'].height())
pen = QPen(QColor('#34A853'), 1, Qt.SolidLine)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
p.drawRect(rect)
# 画8个句柄(角点与边中点)
hs = self._transform_handle_size
handles = self._calc_handle_rects(rect, hs)
p.setBrush(QBrush(QColor('#34A853')))
for r in handles.values():
p.drawRect(r)
# 绘制矩形选择的绿色边缘(满足"自动产生绿色边缘"的需求)
if self.selection_rect is not None and self.tool != 'auto_select':
# 检查是否处于智能区域修改模式
smart_region_mode = getattr(self, '_smart_region_edit_mode', False)
if smart_region_mode and self.tool == 'rect_select':
# 智能区域修改模式:绘制半透明绿色遮罩
p.fillRect(self.selection_rect, QColor(52, 199, 89, 100)) # 绿色半透明填充
# 绘制绿色边框
pen = QPen(QColor('#34c759'), 2, Qt.SolidLine)
p.setPen(pen)
p.setBrush(Qt.NoBrush)
p.drawRect(self.selection_rect)
p.end()
def mousePressEvent(self, event: QMouseEvent):
if event.button() != Qt.LeftButton:
return
pos = self._scale_pos(event.position().toPoint())
if self.tool in ('brush', 'eraser'):
# 开始一笔前记录历史
self._push_history()
self.last_pos = pos
self._draw_to(pos)
elif self.tool == 'move':
# 若点中变换句柄,进入缩放模式;否则移动
al = self._get_active_layer()
if al is not None:
rect = QRect(al.get('x', 0), al.get('y', 0), al['pix'].width(), al['pix'].height())
handle = self._hit_test_handle(pos, rect, self._transform_handle_size)
if handle is not None:
self._push_history()
self._resize_handle = handle
self._resize_start_rect = QRect(rect)
self._resize_start_pix = al['pix'].copy()
self._resize_start_pos = pos
return
# 开始移动前记录历史
self._push_history()
self._move_last_pos = pos
elif self.tool == 'rect_select':
self._rect_select_active = True
self._sel_rect_start = pos
self._sel_rect_current = pos
self.selection_rect = QRect(pos, QSize(1, 1))
self.selection_mask = None # 清除自动选区遮罩
self.update()
elif self.tool == 'magic_wand':
self.selection_mask = None # 清除自动选区遮罩
self._wand_select(pos)
self.update()
elif self.tool == 'auto_select':
self._auto_select(pos)
self.update()
elif self.tool == 'bucket':
# 填充前记录历史
self._push_history()
self._bucket_fill(pos)
self.update()
elif self.tool == 'eyedropper':
color = self._sample_color(pos)
if color is not None:
self.brush_color = color
self.update()
def mouseMoveEvent(self, event: QMouseEvent):
pos = self._scale_pos(event.position().toPoint())
if self.tool in ('brush', 'eraser'):
if self.last_pos is not None:
self._draw_line(self.last_pos, pos)
self.last_pos = pos
elif self.tool == 'move':
# 缩放优先
if self._resize_handle is not None and self._active_layer_id is not None:
self._perform_resize_drag(pos)
self.update()
elif self._move_last_pos is not None and self._active_layer_id is not None:
dx = pos.x() - self._move_last_pos.x()
dy = pos.y() - self._move_last_pos.y()
for layer in self.layers:
if layer.get('id') == self._active_layer_id:
layer['x'] = layer.get('x', 0) + dx
layer['y'] = layer.get('y', 0) + dy
break
self._move_last_pos = pos
self.update()
# 根据悬停位置更新光标(句柄显示方向)
if self._active_layer_id is not None:
al = self._get_active_layer()
if al is not None:
rect = QRect(al.get('x', 0), al.get('y', 0), al['pix'].width(), al['pix'].height())
self._update_move_cursor(pos, rect)
elif self.tool == 'rect_select' and self._rect_select_active and self._sel_rect_start is not None:
self._sel_rect_current = pos
x0, y0 = self._sel_rect_start.x(), self._sel_rect_start.y()
x1, y1 = pos.x(), pos.y()
self.selection_rect = QRect(min(x0, x1), min(y0, y1), abs(x1 - x0), abs(y1 - y0))
self.update()
# 跟随鼠标更新光标(考虑画笔/橡皮大小可能变化)
if self.tool in ('brush', 'eraser'):
self._update_cursor()
def mouseReleaseEvent(self, event: QMouseEvent):
self.last_pos = None
if self.tool == 'move':
self._move_last_pos = None
self._resize_handle = None
self._resize_start_rect = None
self._resize_start_pix = None
self._resize_start_pos = None
if self.tool == 'rect_select':
self._rect_select_active = False
def enterEvent(self, event):
# 进入画板区域时,按当前工具显示自定义光标
self._update_cursor()
super().enterEvent(event)
def leaveEvent(self, event):
# 离开画板区域时恢复系统光标
self.unsetCursor()
self._last_cursor_kind = 'none'
super().leaveEvent(event)
def dragEnterEvent(self, event):
md = event.mimeData()
if md.hasUrls() or md.hasImage():
event.acceptProposedAction()
else:
event.ignore()
def dropEvent(self, event):
md = event.mimeData()
# 优先处理本地文件路径
if md.hasUrls():
for url in md.urls():
path = url.toLocalFile()
if path:
self.add_image_layer(path)
event.acceptProposedAction()
return
# 其次尝试图像数据
if md.hasImage():
img = md.imageData()
try:
if isinstance(img, QPixmap):
self.add_image_layer(img)
else:
from PySide6.QtGui import QImage
if isinstance(img, QImage):
self.add_image_layer(QPixmap.fromImage(img))
except Exception:
pass
event.acceptProposedAction()
def contextMenuEvent(self, event):
# 右键菜单:在画板区域提供"清空画板",并在满足条件时提供"魔法生成"和"翻面"
pos = event.pos()
menu = QMenu(self)
act_clear = menu.addAction('清空画板')
act_magic = None
act_magic_adv = None
act_smart = None # 智能生成
act_smart_edit = None # 智能修改
act_smart_replace = None # 智能替换
act_smart_region_edit = None # 智能区域修改
act_flip_h = None
act_flip_v = None
rect = self.selection_rect
# 检查是否处于智能区域修改模式
smart_region_mode = getattr(self, '_smart_region_edit_mode', False)
# 根据工具类型显示不同的菜单项
if self.tool == 'auto_select' and rect is not None and rect.contains(pos) and self.selection_mask is not None:
# 自动选区工具:显示智能生成、智能修改和智能替换
act_smart = menu.addAction('智能生成')
act_smart_edit = menu.addAction('智能修改')
act_smart_replace = menu.addAction('智能替换')
elif self.tool in ('rect_select', 'magic_wand') and rect is not None and rect.contains(pos):
# 检查是否处于智能区域修改模式
if smart_region_mode and self.tool == 'rect_select':
# 智能区域修改模式:显示智能区域修改选项
act_smart_region_edit = menu.addAction('智能区域修改')
else:
# 普通模式:矩形选区和魔术棒显示魔法生成
act_magic = menu.addAction('魔法生成')
act_magic_adv = menu.addAction('高级魔法生成')
# 如果有激活的图层,添加翻转选项
if self._active_layer_id is not None:
menu.addSeparator()
act_flip_h = menu.addAction('水平翻转')
act_flip_v = menu.addAction('垂直翻转')
menu.addSeparator()
act_upload_ref = menu.addAction('上传到参考图')
chosen = menu.exec(event.globalPos())
if chosen is act_clear:
try:
# 将当前状态推入历史,支持撤销
self._push_history()
# 清空绘制层与所有图层、选区状态
self.paint_layer.fill(Qt.transparent)
self.layers = []
self._active_layer_id = None
self.selection_rect = None
self.selection_mask = None
self.update()
except Exception:
pass
elif chosen == act_upload_ref:
try:
img = self._composite_to_image()
if not img.isNull():
self.upload_to_ref_requested.emit(QPixmap.fromImage(img))
except Exception:
pass
elif act_magic is not None and chosen is act_magic:
try:
self._adv_magic_pending = False
self.magic_generate_requested.emit(rect)
except Exception:
pass
elif act_magic_adv is not None and chosen is act_magic_adv:
try:
self._adv_magic_pending = True
self.magic_generate_requested.emit(rect)
except Exception:
pass
elif act_smart is not None and chosen is act_smart:
try:
# 发射智能生成信号,传递选区矩形和遮罩
self.smart_generate_requested.emit(rect, self.selection_mask)
except Exception:
pass
elif act_smart_edit is not None and chosen is act_smart_edit:
try:
# 发射智能修改信号,传递选区矩形和遮罩
self.smart_edit_requested.emit(rect, self.selection_mask)
except Exception:
pass
elif act_smart_replace is not None and chosen is act_smart_replace:
try:
# 发射智能替换信号,传递选区矩形和遮罩
self.smart_replace_requested.emit(rect, self.selection_mask)
except Exception:
pass
elif act_smart_region_edit is not None and chosen is act_smart_region_edit:
try:
# 发射智能区域修改信号,传递选区矩形
self.smart_region_edit_requested.emit(rect)
except Exception:
pass
elif act_flip_h is not None and chosen is act_flip_h:
try:
self._flip_active_layer(horizontal=True)
except Exception:
pass
elif act_flip_v is not None and chosen is act_flip_v:
try:
self._flip_active_layer(horizontal=False)
except Exception:
pass
def _draw_to(self, pos: QPoint):
# 优先将绘制输出到当前选中的图层,并考虑图层偏移
target, offx, offy = self._get_active_target()
# 将鼠标坐标转换为图层局部坐标,避免偏移
local = QPoint(pos.x() - offx, pos.y() - offy)
painter = QPainter(target)
if self.tool == 'eraser':
painter.setCompositionMode(QPainter.CompositionMode_Clear)
pen = QPen(Qt.transparent, self.eraser_size, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
else:
pen = QPen(self.brush_color, self.brush_size, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
painter.setPen(pen)
painter.drawPoint(local)
painter.end()
self.update()
def _draw_line(self, p1: QPoint, p2: QPoint):
# 优先将绘制输出到当前选中的图层,并考虑图层偏移
target, offx, offy = self._get_active_target()
local1 = QPoint(p1.x() - offx, p1.y() - offy)
local2 = QPoint(p2.x() - offx, p2.y() - offy)
painter = QPainter(target)
if self.tool == 'eraser':
painter.setCompositionMode(QPainter.CompositionMode_Clear)
pen = QPen(Qt.transparent, self.eraser_size, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
else:
pen = QPen(self.brush_color, self.brush_size, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)
painter.setPen(pen)
painter.drawLine(local1, local2)
painter.end()
self.update()
def _update_cursor(self):
"""根据当前工具与尺寸更新光标图案,确保锚点为鼠标中心。"""
kind = self.tool
if kind == 'none':
if self._last_cursor_kind != 'none':
self.unsetCursor()
self._last_cursor_kind = 'none'
return
if kind in ('move',):
# 默认移动光标;具体方向在 mouseMoveEvent 中依据句柄命中更新
self.setCursor(Qt.SizeAllCursor)
self._last_cursor_kind = 'move'
return
if kind in ('rect_select', 'magic_wand', 'bucket', 'eyedropper', 'auto_select'):
self.setCursor(Qt.CrossCursor)
self._last_cursor_kind = kind
return
# brush/eraser 自定义光标
size = max(8, self.brush_size if kind == 'brush' else self.eraser_size)
pm_size = size + 6
pix = QPixmap(pm_size, pm_size)
pix.fill(Qt.transparent)
p = QPainter(pix)
p.setRenderHint(QPainter.Antialiasing, True)
center = pm_size // 2
radius = size // 2
if kind == 'eraser':
p.setPen(QPen(QColor('#ffffff'), 2))
p.setBrush(Qt.NoBrush)
side = size
rect_top_left = QPoint(center - side // 2, center - side // 2)
p.drawRect(rect_top_left.x(), rect_top_left.y(), side, side)
else:
p.setPen(QPen(QColor('#31A8FF'), 2))
p.setBrush(Qt.NoBrush)
p.drawEllipse(QPoint(center, center), radius, radius)
p.setPen(QPen(QColor('#ffffff'), 1))
p.drawLine(center - 2, center, center + 2, center)
p.drawLine(center, center - 2, center, center + 2)
p.end()
cursor = QCursor(pix, center, center)
self.setCursor(cursor)
self._last_cursor_kind = kind
def keyPressEvent(self, event):
# Ctrl+Z 撤销
if event.modifiers() & Qt.ControlModifier and event.key() == Qt.Key_Z:
self.undo()
event.accept()
return
# Shift 等比缩放
if event.key() == Qt.Key_Shift:
self._resize_keep_ratio = True
super().keyPressEvent(event)
def keyReleaseEvent(self, event):
if event.key() == Qt.Key_Shift:
self._resize_keep_ratio = False
super().keyReleaseEvent(event)
# ---------- 变换/缩放辅助 ----------
def _get_active_layer(self) -> dict | None:
if self._active_layer_id is None:
return None
for layer in self.layers:
if layer.get('id') == self._active_layer_id:
return layer
return None
def _calc_handle_rects(self, rect: QRect, size: int) -> dict:
hs = max(6, int(size))
half = hs // 2
cx = rect.center().x()
cy = rect.center().y()
points = {
'tl': QPoint(rect.left(), rect.top()),
'tr': QPoint(rect.right(), rect.top()),
'bl': QPoint(rect.left(), rect.bottom()),
'br': QPoint(rect.right(), rect.bottom()),
'l': QPoint(rect.left(), cy),
'r': QPoint(rect.right(), cy),
't': QPoint(cx, rect.top()),
'b': QPoint(cx, rect.bottom()),
}
rects = {}
for k, pt in points.items():
rects[k] = QRect(pt.x() - half, pt.y() - half, hs, hs)
return rects
def _hit_test_handle(self, pos: QPoint, rect: QRect, size: int) -> str | None:
for k, r in self._calc_handle_rects(rect, size).items():
if r.contains(pos):
return k
return None
def _update_move_cursor(self, pos: QPoint, rect: QRect):
h = self._hit_test_handle(pos, rect, self._transform_handle_size)
if h in ('tl', 'br'):
self.setCursor(Qt.SizeFDiagCursor)
elif h in ('tr', 'bl'):
self.setCursor(Qt.SizeBDiagCursor)
elif h in ('l', 'r'):
self.setCursor(Qt.SizeHorCursor)
elif h in ('t', 'b'):
self.setCursor(Qt.SizeVerCursor)
else:
self.setCursor(Qt.SizeAllCursor)
def _perform_resize_drag(self, pos: QPoint):
al = self._get_active_layer()
if al is None or self._resize_handle is None or self._resize_start_rect is None or self._resize_start_pix is None:
return
start = self._resize_start_rect
dx = pos.x() - (self._resize_start_pos.x() if self._resize_start_pos else pos.x())
dy = pos.y() - (self._resize_start_pos.y() if self._resize_start_pos else pos.y())
# 根据句柄计算新的宽高(默认拖拽方向增量)
new_w = start.width()
new_h = start.height()
handle = self._resize_handle
if handle in ('br',):
new_w = max(10, start.width() + dx)
new_h = max(10, start.height() + dy)
new_x = start.left()
new_y = start.top()
elif handle in ('tl',):
new_w = max(10, start.width() - dx)
new_h = max(10, start.height() - dy)
new_x = start.right() - new_w
new_y = start.bottom() - new_h
elif handle in ('tr',):
new_w = max(10, start.width() + dx)
new_h = max(10, start.height() - dy)
new_x = start.left()
new_y = start.bottom() - new_h
elif handle in ('bl',):
new_w = max(10, start.width() - dx)
new_h = max(10, start.height() + dy)
new_x = start.right() - new_w
new_y = start.top()
elif handle == 'l':
new_w = max(10, start.width() - dx)
new_h = start.height()
new_x = start.right() - new_w
new_y = start.top()
elif handle == 'r':
new_w = max(10, start.width() + dx)
new_h = start.height()
new_x = start.left()
new_y = start.top()
elif handle == 't':
new_w = start.width()
new_h = max(10, start.height() - dy)
new_x = start.left()
new_y = start.bottom() - new_h
elif handle == 'b':
new_w = start.width()
new_h = max(10, start.height() + dy)
new_x = start.left()
new_y = start.top()
else:
return
# 等比缩放
if self._resize_keep_ratio and start.height() > 0:
ratio = start.width() / start.height()
# 依据主方向调整(取更大的改变量)
if handle in ('l','r','tl','tr','bl','br'):
new_h = max(10, int(new_w / ratio))
# 修正锚点对应的 y
if handle in ('tl','tr'):
new_y = start.bottom() - new_h
elif handle in ('bl','br'):
new_y = start.top()
else:
new_w = max(10, int(new_h * ratio))
if handle in ('t'):
new_x = start.left()
elif handle in ('b'):
new_x = start.left()
# 真正缩放像素并更新位置
scaled = self._resize_start_pix.scaled(new_w, new_h, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
al['pix'] = scaled
al['x'] = new_x
al['y'] = new_y
def resize_canvas(self, width: int, height: int):
"""调整画板尺寸,保留已有内容到新尺寸"""
width = max(100, int(width))
height = max(100, int(height))
old_w, old_h = self.paint_layer.width(), self.paint_layer.height()
new_paint = QPixmap(width, height)
new_paint.fill(Qt.transparent)
p = QPainter(new_paint)
# 将旧绘制层按目标大小缩放填充,避免改变比例导致内容只占左上角
try:
scaled_paint = self.paint_layer.scaled(width, height, Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
p.drawPixmap(0, 0, scaled_paint)
except Exception:
p.drawPixmap(0, 0, self.paint_layer)
p.end()
self.paint_layer = new_paint
# 缩放每个图层以适配新尺寸(保持居中位置逻辑)
for layer in self.layers:
src_orig = layer.get('orig') or layer.get('pix')
kind = layer.get('kind', 'image')
if kind == 'blank':
scaled = QPixmap(width, height)
scaled.fill(Qt.transparent)
layer['pix'] = scaled
layer['x'] = 0
layer['y'] = 0
else:
if src_orig.width() <= width and src_orig.height() <= height:
scaled = src_orig
else:
scaled = src_orig.scaled(width, height, Qt.KeepAspectRatio, Qt.SmoothTransformation)
layer['pix'] = scaled
layer['x'] = (width - scaled.width()) // 2
layer['y'] = (height - scaled.height()) // 2
# 根据缩放比例调整显示尺寸
scaled_w = int(width * self._zoom_scale)
scaled_h = int(height * self._zoom_scale)
self.setFixedSize(scaled_w, scaled_h)
self.update()
# 发射尺寸改变信号
try:
self.size_changed.emit(width, height)
except Exception:
pass
def add_image_layer(self, img: QPixmap | str, name: str | None = None) -> int | None:
"""添加图像为图层,缩放图片以适应画板尺寸。支持路径或 QPixmap。返回图层ID。"""
# 添加图层前记录历史
self._push_history()
src = QPixmap(img) if isinstance(img, str) else img
if src.isNull():
return None
img_w, img_h = src.width(), src.height()
canvas_w, canvas_h = self.paint_layer.width(), self.paint_layer.height()
try:
print(f'[画板] 上传图片:{img_w}x{img_h},画板尺寸:{canvas_w}x{canvas_h}', flush=True)
except Exception:
pass
# 计算图片如何适应画板(保持宽高比,缩放到画板内)
if img_w != canvas_w or img_h != canvas_h:
# 计算缩放比例(保持宽高比,适应画板)
scale_w = canvas_w / img_w if img_w > 0 else 1.0
scale_h = canvas_h / img_h if img_h > 0 else 1.0
scale = min(scale_w, scale_h) # 取较小的比例,确保图片完全显示在画板内
# 缩放图片
new_w = int(img_w * scale)
new_h = int(img_h * scale)
scaled = src.scaled(new_w, new_h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
# 居中放置
x = (canvas_w - new_w) // 2
y = (canvas_h - new_h) // 2
try:
print(f'[画板] 图片缩放至:{new_w}x{new_h},位置:({x}, {y})', flush=True)
except Exception:
pass
else:
# 尺寸相同,不需要缩放
scaled = src
x = 0
y = 0
lid = self._layer_seq
self._layer_seq += 1
self.layers.append({'id': lid, 'pix': scaled, 'orig': src, 'x': x, 'y': y, 'enabled': True, 'name': name or self._i18n.get('layer', '图层'), 'kind': 'image', 'show_thumb': True})
self.update()
# 触发自适应缩放显示
try:
if hasattr(self, 'auto_fit_requested'):
self.auto_fit_requested.emit()
except Exception:
pass
return lid
def add_image_layer_at(self, img: QPixmap, name: str | None, x: int, y: int, show_thumb: bool = True) -> int | None:
src = img
if src is None or src.isNull():
return None
lid = self._layer_seq
self._layer_seq += 1
self.layers.append({'id': lid, 'pix': src, 'orig': src, 'x': int(x), 'y': int(y), 'enabled': True, 'name': name or self._i18n.get('layer', '图层'), 'kind': 'image', 'show_thumb': bool(show_thumb)})
self.update()
return lid
def add_blank_layer(self, name: str | None = None) -> int:
# 添加空白层前记录历史
self._push_history()
w, h = self.paint_layer.width(), self.paint_layer.height()
pix = QPixmap(w, h)
pix.fill(Qt.transparent)
lid = self._layer_seq
self._layer_seq += 1
self.layers.append({'id': lid, 'pix': pix, 'x': 0, 'y': 0, 'enabled': True, 'name': name or self._i18n.get('blank_layer', '空白层'), 'kind': 'blank'})
# 默认将新建图层设为当前绘制目标
self._active_layer_id = lid
self.update()
return lid
def remove_layer(self, index: int):
if 0 <= index < len(self.layers):
self._push_history()
del self.layers[index]
self.update()
def remove_layer_by_id(self, lid: int):
for i, layer in enumerate(self.layers):
if layer.get('id') == lid:
self._push_history()
del self.layers[i]
self.update()
return
def set_layer_enabled(self, index: int, enabled: bool):
if 0 <= index < len(self.layers):
self._push_history()
self.layers[index]['enabled'] = enabled
self.update()
def set_layer_enabled_by_id(self, lid: int, enabled: bool):
for layer in self.layers:
if layer.get('id') == lid:
self._push_history()
layer['enabled'] = enabled
self.update()
return
def reorder_layers(self, new_order_indices: list[int]):
if len(new_order_indices) != len(self.layers):
return
self._push_history()
self.layers = [self.layers[i] for i in new_order_indices]
self.update()
def reorder_layers_by_ids(self, ids: list[int]):
if len(ids) != len(self.layers):
return
self._push_history()
id_to_layer = {layer.get('id'): layer for layer in self.layers}
self.layers = [id_to_layer[i] for i in ids if i in id_to_layer]
self.update()
def set_active_layer_by_id(self, lid: int):
"""设置当前绘制图层为指定ID。"""
for layer in self.layers:
if layer.get('id') == lid:
self._active_layer_id = lid
return
def _get_active_target(self):
"""返回(绘制目标pixmap, 目标偏移x, 目标偏移y)。选中图层则返回其pix与位置,否则返回paint_layer与(0,0)。"""
if self._active_layer_id is not None:
for layer in self.layers:
if layer.get('id') == self._active_layer_id:
return layer['pix'], layer.get('x', 0), layer.get('y', 0)
return self.paint_layer, 0, 0
# ---------- 取样、填充与选择 ----------
def _composite_to_image(self) -> 'QImage':
from PySide6.QtGui import QImage
w, h = self.paint_layer.width(), self.paint_layer.height()
img = QImage(w, h, QImage.Format_ARGB32)
img.fill(Qt.transparent)
p = QPainter(img)
p.drawPixmap(0, 0, self.paint_layer)
for layer in self.layers:
if layer.get('enabled', True):
p.drawPixmap(layer.get('x', 0), layer.get('y', 0), layer['pix'])
p.end()
return img
def _sample_color(self, pos: QPoint) -> QColor | None:
from PySide6.QtGui import QImage
img = self._composite_to_image()
x, y = pos.x(), pos.y()
if 0 <= x < img.width() and 0 <= y < img.height():
c = QColor(img.pixel(x, y))
return c
return None
def _bucket_fill(self, pos: QPoint, tolerance: int = 20):
target_pix, offx, offy = self._get_active_target()
if target_pix is self.paint_layer:
# 没有选择图层则直接在绘制层上填充
offx = 0
offy = 0
x, y = pos.x() - offx, pos.y() - offy
if x < 0 or y < 0 or x >= target_pix.width() or y >= target_pix.height():
return
from PySide6.QtGui import QImage
img = target_pix.toImage().convertToFormat(QImage.Format_ARGB32)
seed = QColor(img.pixel(x, y))
new = self.brush_color
if seed == new:
return
w, h = img.width(), img.height()
visited = [[False]*w for _ in range(h)]
def similar(c1: QColor, c2: QColor) -> bool:
return (abs(c1.red() - c2.red()) <= tolerance and
abs(c1.green() - c2.green()) <= tolerance and
abs(c1.blue() - c2.blue()) <= tolerance and
abs(c1.alpha() - c2.alpha()) <= tolerance)
from collections import deque
q = deque()
q.append((x, y))
visited[y][x] = True
new_rgba = new.rgba()
while q:
cx, cy = q.popleft()
img.setPixel(cx, cy, new_rgba)
for nx, ny in ((cx-1, cy), (cx+1, cy), (cx, cy-1), (cx, cy+1)):
if 0 <= nx < w and 0 <= ny < h and not visited[ny][nx]:
if similar(QColor(img.pixel(nx, ny)), seed):
visited[ny][nx] = True
q.append((nx, ny))
target_pix.convertFromImage(img)
def _wand_select(self, pos: QPoint, tolerance: int = 20):
from PySide6.QtGui import QImage
img = self._composite_to_image()
x, y = pos.x(), pos.y()
if x < 0 or y < 0 or x >= img.width() or y >= img.height():
return
seed = QColor(img.pixel(x, y))
w, h = img.width(), img.height()
visited = [[False]*w for _ in range(h)]
from collections import deque
def similar(c1: QColor, c2: QColor) -> bool:
return (abs(c1.red() - c2.red()) <= tolerance and
abs(c1.green() - c2.green()) <= tolerance and
abs(c1.blue() - c2.blue()) <= tolerance and
abs(c1.alpha() - c2.alpha()) <= tolerance)
q = deque()
q.append((x, y))
visited[y][x] = True
minx = maxx = x
miny = maxy = y
while q:
cx, cy = q.popleft()
minx = min(minx, cx); maxx = max(maxx, cx)
miny = min(miny, cy); maxy = max(maxy, cy)
for nx, ny in ((cx-1, cy), (cx+1, cy), (cx, cy-1), (cx, cy+1)):
if 0 <= nx < w and 0 <= ny < h and not visited[ny][nx]:
if similar(QColor(img.pixel(nx, ny)), seed):
visited[ny][nx] = True
q.append((nx, ny))
# 只记录边界矩形作为可视反馈(快速实现)
self.selection_rect = QRect(minx, miny, maxx - minx + 1, maxy - miny + 1)
def _auto_select(self, pos: QPoint):
"""
自动选区工具:使用 BiRefNet AI 模型识别整张图,然后通过洪水填充提取点击位置的单个对象
点击切换选区状态:第一次点击显示绿色半透明,再次点击清除选区
Args:
pos: 用户点击的位置
"""
try:
from PySide6.QtGui import QImage
from PySide6.QtWidgets import QMessageBox, QApplication
# 如果已经有选区,点击任意位置清除选区
if self.selection_mask is not None:
self.selection_mask = None
self.selection_rect = None
self.update()
return
# 显示处理提示
QApplication.setOverrideCursor(Qt.WaitCursor)
# 获取画板合成图
img = self._composite_to_image()
composite_pixmap = QPixmap.fromImage(img)
# 转换为 PIL Image 供 BiRefNet 使用
try:
from PIL import Image
import io
# QPixmap -> QImage -> bytes -> PIL.Image
qimg = composite_pixmap.toImage().convertToFormat(QImage.Format_RGB888)
buffer = qimg.bits().tobytes()
pil_img = Image.frombytes('RGB', (qimg.width(), qimg.height()), buffer)
except Exception as e:
QApplication.restoreOverrideCursor()
QMessageBox.warning(self, '格式转换失败', f'无法转换图像格式:{e}')
return
# 调用 BiRefNet 模型获取遮罩
try:
self._debug('[自动选区] 调用 BiRefNet 模型识别人物...')
from tools.birefnet_runner import remove_bg_birefnet
# 获取抠图结果(带 alpha 通道)
result_pil = remove_bg_birefnet(pil_img)
if result_pil is None:
QApplication.restoreOverrideCursor()
QMessageBox.warning(self, '识别失败', 'BiRefNet 模型处理失败')
return
self._debug('[自动选区] BiRefNet 处理完成')
except ImportError as e:
QApplication.restoreOverrideCursor()
QMessageBox.warning(
self,
'模型缺失',
f'无法导入 BiRefNet 模块,自动选区功能不可用。\n\n'
f'错误:{e}\n\n'
f'请确保已安装必要的依赖:\n'
f'- torch\n'
f'- torchvision\n'
f'- PIL\n'
f'- transformers'
)
return
except Exception as e:
QApplication.restoreOverrideCursor()
QMessageBox.warning(self, '处理失败', f'BiRefNet 处理失败:{e}')
return
# 将 PIL Image 的 alpha 通道转换为遮罩
try:
import numpy as np
from collections import deque
# 获取 alpha 通道
if result_pil.mode == 'RGBA':
alpha_channel = np.array(result_pil.split()[3])
else:
# 如果没有 alpha 通道,转换为灰度图
alpha_channel = np.array(result_pil.convert('L'))