Coverage for paintmaskanimator/canvas_image_import.py: 60%
196 statements
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""Raster-image import and drag-and-drop for :class:`PaintCanvas`.
3Split out of ``canvas.py`` as a mixin. These methods decode image files
4(Qt reader with a Pillow fallback), place them onto layers, run the optional
5color-reduction pipeline, and handle file drag-and-drop onto the canvas. They
6run against a live ``PaintCanvas`` instance and reuse its frame/layer state and
7the color-reduction delegating methods that remain on the widget.
8"""
9import numpy as np
10from pathlib import Path
11from PySide6.QtCore import QPoint
12from PySide6.QtGui import QImage, QImageReader, QPainter
13from .i18n import tr
14from .optional_deps import PILImage
15from .constants import MAX_IMAGE_DIMENSION, MAX_SINGLE_IMAGE_PIXELS, OUTSIDE_MARGIN
16from .errors import OperationError
17from ._canvas_members import CanvasMembers
18from . import color_ops, constants, imaging, timesheet_file
19from .models import Layer
20from .utils import blank_image, natural_path_key
21from .logging_setup import get_logger
23log = get_logger(__name__)
26class ImageImportMixin(CanvasMembers):
27 """Image-file import pipeline + canvas drag-and-drop event handlers."""
29 def _read_image_file(self, path):
30 path = str(path)
31 image = QImage()
32 errors = []
33 reader = QImageReader(path)
34 reader.setAutoTransform(False)
35 if hasattr(reader, "setDecideFormatFromContent"): 35 ↛ 37line 35 didn't jump to line 37 because the condition on line 35 was always true
36 reader.setDecideFormatFromContent(True)
37 declared_size = reader.size()
38 if declared_size.isValid():
39 declared_width = int(declared_size.width())
40 declared_height = int(declared_size.height())
41 if ( 41 ↛ 48line 41 didn't jump to line 48 because the condition on line 41 was never true
42 declared_width < 1
43 or declared_height < 1
44 or declared_width > MAX_IMAGE_DIMENSION
45 or declared_height > MAX_IMAGE_DIMENSION
46 or declared_width * declared_height > MAX_SINGLE_IMAGE_PIXELS
47 ):
48 return None, (
49 tr("画像サイズが上限を超えています。\n{width} × {height}px").format(width=declared_width, height=declared_height)
50 )
51 image = reader.read()
52 if image.isNull():
53 errors.append("Qt: " + (reader.errorString() or tr("画像データを解釈できませんでした")))
54 if image.isNull() and PILImage is not None:
55 try:
56 with PILImage.open(path) as pil:
57 pil_width, pil_height = map(int, pil.size)
58 if (
59 pil_width < 1
60 or pil_height < 1
61 or pil_width > MAX_IMAGE_DIMENSION
62 or pil_height > MAX_IMAGE_DIMENSION
63 or pil_width * pil_height > MAX_SINGLE_IMAGE_PIXELS
64 ):
65 raise OperationError(
66 tr("画像サイズが上限を超えています。 ({width} × {height}px)").format(width=pil_width, height=pil_height)
67 )
68 pil.load()
69 pil = pil.convert("RGBA")
70 raw = pil.tobytes("raw", "RGBA")
71 converted = QImage(raw, pil.width, pil.height, pil.width * 4, QImage.Format.Format_RGBA8888)
72 image = converted.copy()
73 except (OSError, ValueError, TypeError, MemoryError, RuntimeError) as exc:
74 # Pillow raises a wide, loosely-documented set on undecodable
75 # or oversized images; record it and fall back to the Qt reader.
76 log.info("Pillow decode of %s failed: %s", path, exc)
77 errors.append(f"Pillow: {exc}")
78 if image.isNull():
79 suffix = Path(path).suffix.lower() or tr("拡張子なし")
80 return None, tr("形式: {suffix}\n").format(suffix=suffix) + "\n".join(errors)
81 return image.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied), ""
83 @staticmethod
84 def _natural_path_key(*args, **kwargs):
85 return natural_path_key(*args, **kwargs)
87 def _place_imported_image(self, image, target):
88 x = OUTSIDE_MARGIN + (constants.CANVAS_WIDTH - image.width()) // 2
89 y = OUTSIDE_MARGIN + (constants.CANVAS_HEIGHT - image.height()) // 2
90 painter = QPainter(target)
91 painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, False)
92 painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_Source)
93 painter.drawImage(QPoint(x, y), image)
94 painter.end()
96 def _make_white_transparent(self, image):
97 """Convert exact #FFFFFF pixels to fully transparent before placement."""
98 if image is None or image.isNull(): 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 return image
100 rgba = image.convertToFormat(QImage.Format.Format_RGBA8888)
101 width, height = rgba.width(), rgba.height()
102 if width <= 0 or height <= 0: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true
103 return rgba
105 ptr = imaging.qimage_buffer(rgba)
106 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
107 (height, rgba.bytesPerLine())
108 )
109 pixels = rows[:, :width * 4].reshape((height, width, 4))
110 white = (
111 (pixels[:, :, 0] == 255)
112 & (pixels[:, :, 1] == 255)
113 & (pixels[:, :, 2] == 255)
114 & (pixels[:, :, 3] != 0)
115 )
116 pixels[:, :, 3][white] = 0
117 return rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
119 def make_border_background_transparent(self, image, tolerance=24):
120 """外周からつながる明色背景を透明にする(Issue #10)。
122 JPEG は圧縮で背景が 254,255,253 のようにばらつくため、完全一致
123 #FFFFFF では落とせない。しきい値で拾い、外周からつながる範囲に
124 限ることで、絵の中の白(目のハイライトなど)は残す。
126 背景と見なせる色が見つからなければ、元の画像をそのまま返す。
127 """
128 if image is None or image.isNull(): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 return image
130 background_rgb = color_ops.detect_opaque_border_background(image)
131 if background_rgb is None:
132 return image
134 rgba = image.convertToFormat(QImage.Format.Format_RGBA8888)
135 width, height = rgba.width(), rgba.height()
136 if width <= 0 or height <= 0: 136 ↛ 137line 136 didn't jump to line 137 because the condition on line 136 was never true
137 return image
138 ptr = imaging.qimage_buffer(rgba)
139 rows = np.frombuffer(ptr, dtype=np.uint8).reshape(
140 (height, rgba.bytesPerLine())
141 )
142 pixels = rows[:, :width * 4].reshape((height, width, 4))
144 difference = (
145 pixels[:, :, :3].astype(np.int32)
146 - np.asarray(background_rgb, dtype=np.int32)
147 )
148 near = np.sum(difference * difference, axis=2) <= (
149 int(tolerance) * int(tolerance) * 3
150 )
151 starts = [
152 (int(x), 0) for x in np.flatnonzero(near[0, :])
153 ]
154 if height > 1: 154 ↛ 156line 154 didn't jump to line 156 because the condition on line 154 was always true
155 starts.extend((int(x), height - 1) for x in np.flatnonzero(near[-1, :]))
156 if width > 1: 156 ↛ 161line 156 didn't jump to line 161 because the condition on line 156 was always true
157 starts.extend((0, int(y)) for y in np.flatnonzero(near[:, 0]))
158 starts.extend(
159 (width - 1, int(y)) for y in np.flatnonzero(near[:, -1])
160 )
161 if not starts: 161 ↛ 162line 161 didn't jump to line 162 because the condition on line 161 was never true
162 return image
164 outside = imaging.scanline_connected_region_numpy(near, starts)
165 if not outside.any(): 165 ↛ 166line 165 didn't jump to line 166 because the condition on line 165 was never true
166 return image
167 pixels[:, :, 3][outside] = 0
168 return rgba.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)
170 def import_image(self, path, color_reduction=None, draft=False):
171 return self.import_image_sequence(
172 [path],
173 color_reduction=color_reduction,
174 draft=draft,
175 )
177 def import_image_sequence(
178 self,
179 paths,
180 progress_callback=None,
181 color_reduction=None,
182 layer_name=None,
183 draft=False,
184 ):
185 paths = sorted([str(path) for path in paths], key=self._natural_path_key)
186 if not paths: 186 ↛ 187line 186 didn't jump to line 187 because the condition on line 186 was never true
187 return False, tr("画像ファイルがありません。")
188 decoded = []
189 for index, path in enumerate(paths, 1):
190 if progress_callback: 190 ↛ 191line 190 didn't jump to line 191 because the condition on line 190 was never true
191 progress_callback(index - 1, len(paths), tr("{Path} を読み込んでいます").format(Path=Path(path).name))
192 image, error = self._read_image_file(path)
193 if image is None: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true
194 return False, f"{Path(path).name}\n{error}"
195 if draft: 195 ↛ 198line 195 didn't jump to line 198 because the condition on line 195 was never true
196 # 変換なし読み込み: 色数削減も白透明化も行わず、
197 # 読み込んだ画素をそのまま下書きレイヤーへ配置する。
198 pass
199 elif color_reduction: 199 ↛ 200line 199 didn't jump to line 200 because the condition on line 199 was never true
200 palette = color_reduction.get("palette")
201 extraction_mode = color_reduction.get(
202 "extraction_mode",
203 "surface",
204 )
205 line_extraction = extraction_mode == "line"
206 reduction_background = (
207 (255, 255, 255)
208 if line_extraction
209 else color_reduction.get("background_rgb")
210 )
211 alpha_threshold = int(
212 color_reduction.get(
213 "alpha_threshold",
214 128,
215 )
216 )
217 tone_curve_points = color_reduction.get(
218 "tone_curve_points",
219 [(0.0, 0.0), (1.0, 1.0)],
220 )
221 if tone_curve_points:
222 image = self.apply_tone_curve(
223 image,
224 tone_curve_points=tone_curve_points,
225 background_rgb=reduction_background,
226 )
227 if palette is not None:
228 if progress_callback:
229 progress_callback(
230 index - 1,
231 len(paths),
232 tr("{Path} の元画像へトーンカーブを適用し、共通パレットで2値化しています").format(Path=Path(path).name),
233 )
234 image = self.apply_color_reduction_palette(
235 image,
236 palette,
237 alpha_threshold=alpha_threshold,
238 opaque_background=(
239 True
240 if line_extraction
241 else bool(
242 color_reduction.get(
243 "opaque_background",
244 False,
245 )
246 )
247 ),
248 background_rgb=reduction_background,
249 tone_curve_points=[
250 (0.0, 0.0),
251 (1.0, 1.0),
252 ],
253 extraction_mode=extraction_mode,
254 )
255 elif Path(path).suffix.lower() in (".jpg", ".jpeg"):
256 # JPEGは圧縮で背景の白がばらつくため、完全一致では落とせない。
257 # しきい値で拾い、外周からつながる範囲だけを透明にする。
258 # 絵の中の白(目のハイライトなど)は残る(Issue #10)。
259 image = self.make_border_background_transparent(image)
260 else:
261 image = self._make_white_transparent(image)
262 decoded.append((path, image))
263 if progress_callback: 263 ↛ 264line 263 didn't jump to line 264 because the condition on line 263 was never true
264 progress_callback(len(paths), len(paths), tr("画像の配置を準備しています"))
265 self.push_doc_undo()
266 start_frame = self.current_frame
267 self._ensure_frame_count(start_frame + len(decoded))
268 name = str(layer_name).strip() if layer_name is not None else ""
269 if not name: 269 ↛ 271line 269 didn't jump to line 271 because the condition on line 269 was always true
270 name = Path(decoded[0][0]).stem or f"Image {len(self.layers)}"
271 insert_index = len(self.frames[0].layers)
272 for frame in self.frames:
273 frame.layers.append(Layer(name, blank_image(), is_draft=bool(draft)))
274 for offset, (_, image) in enumerate(decoded):
275 if progress_callback: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true
276 progress_callback(offset, len(decoded), tr("{value} / {len} 枚を配置しています").format(value=offset + 1, len=len(decoded)))
277 layer = self.frames[start_frame + offset].layers[insert_index]
278 self._place_imported_image(image, layer.image)
279 layer.has_content = True
280 layer.exposure = 1
281 layer.sequence_number = offset + 1
282 self._sequence_source_bank = [
283 self.frames[start_frame + offset]
284 .layers[insert_index].image.copy()
285 for offset in range(len(decoded))
286 ]
287 self._sequence_source_bank_layer_index = int(insert_index)
288 self._sequence_source_bank_layer_name = str(name)
289 self.active_layer_index = insert_index
290 self.current_frame = start_frame
291 self.changed.emit()
292 self.selectionChanged.emit()
293 self.update()
294 if progress_callback: 294 ↛ 295line 294 didn't jump to line 295 because the condition on line 294 was never true
295 progress_callback(len(decoded), len(decoded), tr("読み込み完了"))
296 return True, ""
298 def dragEnterEvent(self,e):
299 urls=e.mimeData().urls() if e.mimeData().hasUrls() else []
300 valid=any(Path(u.toLocalFile()).suffix.lower() in (".pman",".clip",*timesheet_file.SUFFIXES,".jpg",".jpeg",".png",".tga") for u in urls)
301 if valid:e.acceptProposedAction()
302 else:e.ignore()
304 def dragMoveEvent(self,e):
305 e.acceptProposedAction()
307 def dropEvent(self,e):
308 project_paths=[]
309 clip_paths=[]
310 remap_paths=[]
311 paths=[]
312 for u in e.mimeData().urls():
313 path=u.toLocalFile()
314 suffix=Path(path).suffix.lower()
315 if suffix==".pman":
316 project_paths.append(path)
317 elif suffix==".clip":
318 clip_paths.append(path)
319 elif suffix in timesheet_file.SUFFIXES:
320 remap_paths.append(path)
321 elif suffix in (".jpg",".jpeg",".png",".tga"):
322 paths.append(path)
323 if project_paths:
324 self.projectDropped.emit(project_paths[0])
325 elif clip_paths:
326 self.clipAnimationDropped.emit(clip_paths[0])
327 elif remap_paths:
328 self.timeRemapDropped.emit(remap_paths[0])
329 elif paths:
330 self.imagesDropped.emit(paths)
331 e.acceptProposedAction()