Coverage for paintmaskanimator/canvas_transform_geometry.py: 63%

229 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""Free-transform geometry for PaintCanvas: handles, drag state and preview. 

2 

3Split out of ``canvas.py`` as a mixin, and a companion to 

4``canvas_transform_mask.py`` (which owns the mask side). These methods answer the 

5geometric questions of a transform: where the handles and rotation grip are, what 

6polygon the transform currently covers, how a drag updates the corner points, and 

7how the source image is projected -- including the reduced-size proxy used while 

8dragging. They run against a live ``PaintCanvas``. 

9""" 

10import math 

11import numpy as np 

12from PySide6.QtCore import QPointF, QRectF, Qt 

13from PySide6.QtGui import QImage, QPainter, QPolygonF 

14from .i18n import tr 

15from .constants import TP_MASK_PROXY_MAX_DIMENSION 

16from ._canvas_members import CanvasMembers 

17from . import geometry 

18from .logging_setup import get_logger 

19 

20log = get_logger(__name__) 

21 

22 

23class TransformGeometryMixin(CanvasMembers): 

24 @staticmethod 

25 def _regular_grid_points(rect, cols, rows=None): 

26 return geometry.regular_grid_points(rect, cols, rows) 

27 

28 

29 def nearest_transform_handle(self, point): 

30 if not self.transform_active or not self.transform_points: 30 ↛ 31line 30 didn't jump to line 31 because the condition on line 30 was never true

31 return -1 

32 radius = 14 / max(self.zoom, 0.01) 

33 distances = [ 

34 (handle.x() - point.x()) ** 2 + (handle.y() - point.y()) ** 2 

35 for handle in self.transform_points 

36 ] 

37 index = int(np.argmin(distances)) 

38 return index if distances[index] <= radius * radius else -1 

39 

40 def transform_center(self): 

41 if not self.transform_points: 41 ↛ 42line 41 didn't jump to line 42 because the condition on line 41 was never true

42 return QPointF() 

43 return QPointF( 

44 sum(point.x() for point in self.transform_points) / len(self.transform_points), 

45 sum(point.y() for point in self.transform_points) / len(self.transform_points), 

46 ) 

47 

48 def transform_rotation_handle(self): 

49 if not self.transform_points: 49 ↛ 50line 49 didn't jump to line 50 because the condition on line 49 was never true

50 return None 

51 if self.transform_mode == "mesh": 51 ↛ 52line 51 didn't jump to line 52 because the condition on line 51 was never true

52 cols = max(2, int(getattr(self, "transform_mesh_cols", 4))) 

53 if len(self.transform_points) < cols: 

54 return None 

55 p0, p1 = self.transform_points[0], self.transform_points[cols - 1] 

56 elif len(self.transform_points) == 4: 56 ↛ 59line 56 didn't jump to line 59 because the condition on line 56 was always true

57 p0, p1 = self.transform_points[0], self.transform_points[1] 

58 else: 

59 return None 

60 midpoint = QPointF((p0.x() + p1.x()) / 2.0, (p0.y() + p1.y()) / 2.0) 

61 center = self.transform_center() 

62 dx, dy = midpoint.x() - center.x(), midpoint.y() - center.y() 

63 length = math.hypot(dx, dy) or 1.0 

64 distance = 36.0 / max(self.zoom, 0.01) 

65 return QPointF( 

66 midpoint.x() + dx / length * distance, 

67 midpoint.y() + dy / length * distance, 

68 ) 

69 

70 def transform_outer_polygon(self): 

71 if not self.transform_points: 71 ↛ 72line 71 didn't jump to line 72 because the condition on line 71 was never true

72 return QPolygonF() 

73 if self.transform_mode != "mesh": 

74 return QPolygonF(self.transform_points[:4]) 

75 cols = max(2, int(getattr(self, "transform_mesh_cols", 4))) 

76 rows = max(2, int(getattr(self, "transform_mesh_rows", 4))) 

77 points = self.transform_points 

78 if len(points) != cols * rows: 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

79 return QPolygonF() 

80 subdivisions = 8 

81 outline = [] 

82 

83 for step in range((cols - 1) * subdivisions + 1): 

84 outline.append( 

85 self._mesh_curve_point( 

86 points, cols, rows, 

87 step / float(subdivisions), 0.0, 

88 ) 

89 ) 

90 for step in range(1, (rows - 1) * subdivisions + 1): 

91 outline.append( 

92 self._mesh_curve_point( 

93 points, cols, rows, 

94 float(cols - 1), 

95 step / float(subdivisions), 

96 ) 

97 ) 

98 for step in range( 

99 (cols - 1) * subdivisions - 1, -1, -1 

100 ): 

101 outline.append( 

102 self._mesh_curve_point( 

103 points, cols, rows, 

104 step / float(subdivisions), 

105 float(rows - 1), 

106 ) 

107 ) 

108 for step in range( 

109 (rows - 1) * subdivisions - 1, 0, -1 

110 ): 

111 outline.append( 

112 self._mesh_curve_point( 

113 points, cols, rows, 

114 0.0, 

115 step / float(subdivisions), 

116 ) 

117 ) 

118 return QPolygonF(outline) 

119 

120 

121 def begin_transform_drag(self, point): 

122 if not self.transform_active: 122 ↛ 123line 122 didn't jump to line 123 because the condition on line 122 was never true

123 return False 

124 from .undo_entries import TransformUndo 

125 self._transform_drag_history = TransformUndo.capture(self) 

126 rotation_handle = self.transform_rotation_handle() 

127 radius = 16 / max(self.zoom, 0.01) 

128 if rotation_handle is not None: 128 ↛ 138line 128 didn't jump to line 138 because the condition on line 128 was always true

129 if ( 129 ↛ 134line 129 didn't jump to line 134 because the condition on line 129 was never true

130 (rotation_handle.x() - point.x()) ** 2 

131 + (rotation_handle.y() - point.y()) ** 2 

132 <= radius * radius 

133 ): 

134 self.transform_drag_kind = ("rotate", -1) 

135 self.transform_drag_start = QPointF(point) 

136 self.transform_drag_points = [QPointF(p) for p in self.transform_points] 

137 return True 

138 handle = self.nearest_transform_handle(point) 

139 if handle >= 0: 

140 self.transform_drag_kind = ("handle", handle) 

141 self.transform_drag_start = QPointF(point) 

142 self.transform_drag_points = [QPointF(p) for p in self.transform_points] 

143 self.transform_handle = handle 

144 return True 

145 if self.transform_outer_polygon().containsPoint( 145 ↛ 152line 145 didn't jump to line 152 because the condition on line 145 was always true

146 point, Qt.FillRule.OddEvenFill 

147 ): 

148 self.transform_drag_kind = ("move", -1) 

149 self.transform_drag_start = QPointF(point) 

150 self.transform_drag_points = [QPointF(p) for p in self.transform_points] 

151 return True 

152 self.transform_drag_kind = None 

153 return False 

154 

155 def update_transform_drag(self, point): 

156 if not self.transform_drag_kind: 156 ↛ 157line 156 didn't jump to line 157 because the condition on line 156 was never true

157 return 

158 kind, index = self.transform_drag_kind 

159 start_points = self.transform_drag_points 

160 if kind == "move": 

161 delta = point - self.transform_drag_start 

162 self.transform_points = [p + delta for p in start_points] 

163 elif kind == "rotate": 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true

164 center = QPointF( 

165 sum(p.x() for p in start_points) / len(start_points), 

166 sum(p.y() for p in start_points) / len(start_points), 

167 ) 

168 a0 = math.atan2( 

169 self.transform_drag_start.y() - center.y(), 

170 self.transform_drag_start.x() - center.x(), 

171 ) 

172 a1 = math.atan2(point.y() - center.y(), point.x() - center.x()) 

173 angle = a1 - a0 

174 cs, sn = math.cos(angle), math.sin(angle) 

175 self.transform_points = [ 

176 QPointF( 

177 center.x() + (p.x() - center.x()) * cs - (p.y() - center.y()) * sn, 

178 center.y() + (p.x() - center.x()) * sn + (p.y() - center.y()) * cs, 

179 ) 

180 for p in start_points 

181 ] 

182 elif kind == "handle": 182 ↛ 202line 182 didn't jump to line 202 because the condition on line 182 was always true

183 if self.transform_mode in ("free", "mesh"): 183 ↛ 186line 183 didn't jump to line 186 because the condition on line 183 was always true

184 self.transform_points[index] = QPointF(point) 

185 else: 

186 opposite_index = (index + 2) % 4 

187 opposite = start_points[opposite_index] 

188 original = start_points[index] - opposite 

189 current = point - opposite 

190 denominator = original.x() ** 2 + original.y() ** 2 

191 scale = ( 

192 (current.x() * original.x() + current.y() * original.y()) 

193 / denominator 

194 if denominator > 1e-8 else 1.0 

195 ) 

196 if abs(scale) < 0.02: 

197 scale = 0.02 if scale >= 0 else -0.02 

198 self.transform_points = [ 

199 opposite + (p - opposite) * scale 

200 for p in start_points 

201 ] 

202 self._invalidate_tp_preview_cache() 

203 self.update() 

204 

205 def end_transform_drag(self): 

206 before = getattr(self, "_transform_drag_history", None) 

207 if self.transform_drag_kind and before is not None: 207 ↛ 209line 207 didn't jump to line 209 because the condition on line 207 was always true

208 self._record_transform_history(before) 

209 self._transform_drag_history = None 

210 self.transform_drag_kind = None 

211 self.transform_handle = -1 

212 self.transform_drag_points = [] 

213 if self.transform_quality_active: 

214 self.request_quality_preview_counter( 

215 tr("変形後のクオリティプレビューを生成しています") 

216 ) 

217 self.update() 

218 

219 

220 @staticmethod 

221 def _quad_homography(source_points, target_points): 

222 return geometry.quad_homography(source_points, target_points) 

223 

224 

225 # Pure QImage/NumPy/PIL conversions now live in imaging.py; these wrappers 

226 # keep the existing call sites (self._.../cls._...) working unchanged. 

227 def _project_transform_source( 

228 self, source, target_width, target_height, smooth=False 

229 ): 

230 canvas_rect = QRectF(0, 0, target_width, target_height) 

231 if self.transform_mode == "mesh": 231 ↛ 232line 231 didn't jump to line 232 because the condition on line 231 was never true

232 return ( 

233 self._mesh_preview_image( 

234 source, target_width, target_height, smooth=smooth 

235 ), 

236 canvas_rect, 

237 ) 

238 if len(self.transform_points) != 4: 238 ↛ 239line 238 didn't jump to line 239 because the condition on line 238 was never true

239 return None, canvas_rect 

240 width, height = source.width(), source.height() 

241 if width <= 0 or height <= 0: 241 ↛ 242line 241 didn't jump to line 242 because the condition on line 241 was never true

242 return None, canvas_rect 

243 source_quad = [ 

244 QPointF(0, 0), QPointF(width, 0), 

245 QPointF(width, height), QPointF(0, height), 

246 ] 

247 transform = self._quad_homography(source_quad, self.transform_points) 

248 preview = QImage( 

249 target_width, 

250 target_height, 

251 QImage.Format.Format_ARGB32_Premultiplied, 

252 ) 

253 preview.fill(Qt.GlobalColor.transparent) 

254 painter = QPainter(preview) 

255 painter.setRenderHint( 

256 QPainter.RenderHint.SmoothPixmapTransform, False 

257 ) 

258 painter.setRenderHint(QPainter.RenderHint.Antialiasing, False) 

259 painter.setTransform(transform) 

260 painter.drawImage(0, 0, source) 

261 painter.end() 

262 return preview, canvas_rect 

263 

264 def _transform_is_reducing(self, source=None): 

265 """変形のどこかに縮小があり、補間が必要かを返す。""" 

266 source = source if source is not None else self.transform_source 

267 if source is None or source.isNull() or not self.transform_points: 

268 return False 

269 

270 if self.transform_mode == "mesh": 

271 cols = max(2, int(getattr(self, "transform_mesh_cols", 4))) 

272 rows = max(2, int(getattr(self, "transform_mesh_rows", 4))) 

273 reference = self._mesh_reference_grid(cols, rows) 

274 if ( 

275 len(self.transform_points) != cols * rows 

276 or len(reference) != cols * rows 

277 ): 

278 return False 

279 pairs = [] 

280 for row in range(rows): 

281 for col in range(cols - 1): 

282 index = row * cols + col 

283 pairs.append((index, index + 1)) 

284 for row in range(rows - 1): 

285 for col in range(cols): 

286 index = row * cols + col 

287 pairs.append((index, index + cols)) 

288 for first, second in pairs: 

289 current = math.hypot( 

290 self.transform_points[second].x() 

291 - self.transform_points[first].x(), 

292 self.transform_points[second].y() 

293 - self.transform_points[first].y(), 

294 ) 

295 original = math.hypot( 

296 reference[second].x() - reference[first].x(), 

297 reference[second].y() - reference[first].y(), 

298 ) 

299 if original > 1e-8 and current < original * 0.9999: 

300 return True 

301 return False 

302 

303 if len(self.transform_points) != 4: 

304 return False 

305 source_width = max(1.0, float(source.width())) 

306 source_height = max(1.0, float(source.height())) 

307 p0, p1, p2, p3 = self.transform_points[:4] 

308 horizontal_edges = ( 

309 math.hypot(p1.x() - p0.x(), p1.y() - p0.y()), 

310 math.hypot(p2.x() - p3.x(), p2.y() - p3.y()), 

311 ) 

312 vertical_edges = ( 

313 math.hypot(p3.x() - p0.x(), p3.y() - p0.y()), 

314 math.hypot(p2.x() - p1.x(), p2.y() - p1.y()), 

315 ) 

316 return ( 

317 min(horizontal_edges) < source_width * 0.9999 

318 or min(vertical_edges) < source_height * 0.9999 

319 ) 

320 

321 

322 def _proxy_transform_preview_image( 

323 self, 

324 source, 

325 target_width, 

326 target_height, 

327 quality, 

328 progress_callback=None, 

329 ): 

330 """大画像の表示用変形を縮小座標で生成し、UIの負荷を抑える。""" 

331 scale = min( 

332 1.0, 

333 TP_MASK_PROXY_MAX_DIMENSION 

334 / float(max(1, target_width, target_height)), 

335 ) 

336 proxy_width = max(1, int(round(target_width * scale))) 

337 proxy_height = max(1, int(round(target_height * scale))) 

338 scale_x = proxy_width / float(max(1, target_width)) 

339 scale_y = proxy_height / float(max(1, target_height)) 

340 source_width = max(1, int(round(source.width() * scale_x))) 

341 source_height = max(1, int(round(source.height() * scale_y))) 

342 proxy_source_key = ( 

343 int(source.cacheKey()), 

344 source_width, 

345 source_height, 

346 ) 

347 if ( 347 ↛ 360line 347 didn't jump to line 360 because the condition on line 347 was always true

348 self._tp_proxy_source_key != proxy_source_key 

349 or self._tp_proxy_source_image is None 

350 or self._tp_proxy_source_image.isNull() 

351 ): 

352 self._tp_proxy_source_image = source.scaled( 

353 source_width, 

354 source_height, 

355 Qt.AspectRatioMode.IgnoreAspectRatio, 

356 Qt.TransformationMode.FastTransformation, 

357 ) 

358 self._tp_proxy_source_key = proxy_source_key 

359 

360 proxy_source = self._tp_proxy_source_image 

361 original_source = self.transform_source 

362 original_points = self.transform_points 

363 original_source_rect = self.transform_source_rect 

364 original_proxy_rendering = self._tp_proxy_rendering 

365 self.transform_source = proxy_source 

366 self.transform_points = [ 

367 QPointF(point.x() * scale_x, point.y() * scale_y) 

368 for point in original_points 

369 ] 

370 if original_source_rect is not None: 370 ↛ 377line 370 didn't jump to line 377 because the condition on line 370 was always true

371 self.transform_source_rect = QRectF( 

372 original_source_rect.x() * scale_x, 

373 original_source_rect.y() * scale_y, 

374 original_source_rect.width() * scale_x, 

375 original_source_rect.height() * scale_y, 

376 ) 

377 self._tp_proxy_rendering = True 

378 try: 

379 if quality: 379 ↛ 386line 379 didn't jump to line 386 because the condition on line 379 was always true

380 return self._tp_mask_preview_image( 

381 proxy_source, 

382 proxy_width, 

383 proxy_height, 

384 progress_callback=progress_callback, 

385 ) 

386 return self._project_transform_source( 

387 proxy_source, 

388 proxy_width, 

389 proxy_height, 

390 smooth=False, 

391 ) 

392 finally: 

393 self.transform_source = original_source 

394 self.transform_points = original_points 

395 self.transform_source_rect = original_source_rect 

396 self._tp_proxy_rendering = original_proxy_rendering 

397 

398 def transform_preview_image( 

399 self, 

400 source_image=None, 

401 target_width=None, 

402 target_height=None, 

403 quality=None, 

404 preview_only=False, 

405 progress_callback=None, 

406 ): 

407 source = source_image if source_image is not None else self.transform_source 

408 if not self.transform_active or source is None: 408 ↛ 409line 408 didn't jump to line 409 because the condition on line 408 was never true

409 return None, QRectF() 

410 target_width = int( 

411 target_width if target_width is not None else self.active_layer.image.width() 

412 ) 

413 target_height = int( 

414 target_height if target_height is not None else self.active_layer.image.height() 

415 ) 

416 if quality is None: 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true

417 quality = bool(getattr(self, "transform_quality_active", False)) 

418 if ( 

419 preview_only 

420 and self._tp_uses_proxy(target_width, target_height) 

421 ): 

422 return self._proxy_transform_preview_image( 

423 source, 

424 target_width, 

425 target_height, 

426 bool(quality), 

427 progress_callback=progress_callback, 

428 ) 

429 if quality: 

430 return self._tp_mask_preview_image( 

431 source, 

432 target_width, 

433 target_height, 

434 progress_callback=progress_callback, 

435 ) 

436 return self._project_transform_source( 

437 source, 

438 target_width, 

439 target_height, 

440 smooth=False, 

441 )