From 5ef4a8248adec16243ad38246076eebef37a8ad0 Mon Sep 17 00:00:00 2001 From: Shengjie Xu Date: Wed, 29 Oct 2025 23:09:35 +0800 Subject: [PATCH 1/3] [Core] Add util.psddump command line tool to help analyzing psd layer nesting when writing imagepack config --- src/preppipe/util/imagepack.py | 18 ++++++++++-- src/preppipe/util/psddump.py | 53 ++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 src/preppipe/util/psddump.py diff --git a/src/preppipe/util/imagepack.py b/src/preppipe/util/imagepack.py index 8a79112..52fe8c0 100644 --- a/src/preppipe/util/imagepack.py +++ b/src/preppipe/util/imagepack.py @@ -1948,6 +1948,7 @@ def try_add_combinations(parts_list : tuple[str, ...]): def _get_psd_composite_image_from_layers(psd : psd_tools.PSDImage, converted_layers : list[list[str]]) -> PIL.Image.Image: converted_layers_used : list[bool] = [False] * len(converted_layers) actual_layers : list[tuple[str, typing.Any]] = [] # <名字,图层对象> + debug_layerlog : list[str] = [] def layer_filter(layer): nonlocal actual_layers nonlocal converted_layers_used @@ -1964,7 +1965,9 @@ def layer_filter(layer): for candidate_target in converted_layers: minlen = min(len(cur_layer), len(candidate_target)) if cur_layer[:minlen] == candidate_target[:minlen]: + debug_layerlog.append(f"{cur_layer}: matched prefix with group layer {'/'.join(candidate_target)}") return True + debug_layerlog.append(f"{cur_layer}: no match") return False else: # 如果是具体图层,判断 cur_layer 是否与 converted_layers 中的某一项匹配,或是某一项的子节点 @@ -1974,16 +1977,24 @@ def layer_filter(layer): if cur_layer[:len(candidate_target)] == candidate_target: actual_layers.append(('/'.join(cur_layer), layer)) converted_layers_used[index] = True + debug_layerlog.append(f"{cur_layer}: matched: {'/'.join(candidate_target)}") return True + debug_layerlog.append(f"{cur_layer}: no match") return False composite = psd.composite(ignore_preview=True, force=True, layer_filter=layer_filter) if len(actual_layers) == 0: - raise PPInternalError("No layers matched in export") + print("\n".join(debug_layerlog)) + # raise PPInternalError("No layers matched in export") + print("No layers matched in export") if not all(converted_layers_used): unused_layers = [converted_layers[i] for i in range(len(converted_layers)) if not converted_layers_used[i]] - raise PPInternalError("Some layers were not found: " + str(unused_layers)) + print("\n".join(debug_layerlog)) + #raise PPInternalError("Some layers were not found: " + str(unused_layers)) + print("Some layers were not found: " + str(unused_layers)) if composite is None or composite.getbbox() is None: - raise PPInternalError("Empty image in export (something went wrong?)") + #raise PPInternalError("Empty image in export (something went wrong?)") + print("Empty image in export (something went wrong?)") + composite = PIL.Image.new("RGBA", (psd.width, psd.height), (0,0,0,0)) return composite @staticmethod @@ -2093,6 +2104,7 @@ def _get_psd_composite_image_from_diff(psd : psd_tools.PSDImage, info : dict) -> def _unpack_psd_to_directory(psdpath : str, destdir : str, exports : dict) -> None: psd = psd_tools.PSDImage.open(psdpath) for filename, info in exports.items(): + print(f"Extracting: {filename}") if isinstance(info, dict): if len(info) != 1: raise PPInternalError("Invalid export info in PSD exports: expecting a dict with exactly one key but got " + str(info)) diff --git a/src/preppipe/util/psddump.py b/src/preppipe/util/psddump.py new file mode 100644 index 0000000..4940056 --- /dev/null +++ b/src/preppipe/util/psddump.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: 2023 PrepPipe's Contributors +# SPDX-License-Identifier: Apache-2.0 + +# 该文件用于分析 psd 文件的图层结构、显示相关信息以便后续编写图片包(ImagePack)的配置文件 +# 可使用以下方式进行调用: +# python3 -m preppipe.util.psddump [ ...] + +import sys +import psd_tools +import psd_tools.api.layers + +def _dump_psd_info(psdpath : str): + psd = psd_tools.PSDImage.open(psdpath) + print(f"{psdpath}: {psd.width}x{psd.height}") + def visit(stack : list[str], layer : psd_tools.api.layers.Layer) -> None: + fullname = '/'.join(stack + [layer.name]) + size_info = f"{layer.width}x{layer.height} @ ({layer.left},{layer.top})" + layer_kind : str = "U" + additional_info = [] + is_expected_blendmode = False + if isinstance(layer, psd_tools.api.layers.Group): + layer_kind = "G" + is_expected_blendmode = layer.blend_mode == psd_tools.api.layers.BlendMode.PASS_THROUGH + elif isinstance(layer, psd_tools.api.layers.PixelLayer): + layer_kind = "P" + is_expected_blendmode = layer.blend_mode == psd_tools.api.layers.BlendMode.NORMAL + elif isinstance(layer, psd_tools.api.layers.ShapeLayer): + layer_kind = "S" + prefix = f"[{layer_kind}]" + (" " if layer.visible else "[H]") + if not is_expected_blendmode: + additional_info.append(f"BlendMode={layer.blend_mode.name}") + if layer.clipping: + additional_info.append("Clipping") + print(f"{prefix} \"{fullname}\": {size_info} " + ", ".join(additional_info)) + if isinstance(layer, psd_tools.api.layers.Group) and len(layer) > 0: + stack.append(layer.name) + for child in layer: + visit(stack, child) + stack.pop() + layer_stack : list[str] = [] + for layer in psd: + visit(layer_stack, layer) + +def main(args : list[str]) -> int: + if len(args) < 2: + print("Usage: psddump.py [ ...]") + return 1 + for psdpath in args[1:]: + _dump_psd_info(psdpath) + return 0 + +if __name__ == "__main__": + sys.exit(main(sys.argv)) From b37f0086072777cd60c45f4c61a696e642b8e86d Mon Sep 17 00:00:00 2001 From: Shengjie Xu Date: Sat, 15 Nov 2025 01:19:08 +0800 Subject: [PATCH 2/3] [UI] Now ImagePack viewer caches intermediate composition results to make expression changes faster --- src/preppipe/util/imagepack.py | 63 ++++++++++++++++--- .../toolwidgets/imagepack.py | 41 +++++++++++- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/src/preppipe/util/imagepack.py b/src/preppipe/util/imagepack.py index 52fe8c0..bea3ca7 100644 --- a/src/preppipe/util/imagepack.py +++ b/src/preppipe/util/imagepack.py @@ -94,6 +94,20 @@ def create_untrusted(path : str): return ImageWrapper(image=image, path=path) return ImageWrapper(image=image) +@dataclasses.dataclass +class ImageCompositionCache: + # 在 UI 预览中,我们可能会频繁切换组合中的某几层(比如面部表情相关图层) + # 如果每次 get_composed_image 都重新合成的话会非常慢 + # 因此我们用这个类来缓存部分图层的组合结果 + # 下标递增是从底层到顶层的部分组合结果(即 0 只是最底层, 1是最底层+次底层,以此类推) + layers : list[tuple[int, PIL.Image.Image | None]] = dataclasses.field(default_factory=list) # (layer index, composed image) + # 当用于角色立绘时,我们一般不会动基底的图层,所以基底图层的中间状态不用保留,到其他可变部分时再开始缓存 + min_cached_layer : int = 0 + + def clear(self): + self.layers.clear() + self.min_cached_layer = 0 + @AssetClassDecl("imagepack") @ToolClassDecl("imagepack") class ImagePack(NamedAssetClassBase): @@ -436,14 +450,14 @@ def read_layer_group(prefix, group_name): if "metadata" in manifest: self.opaque_metadata = ImagePack.recover_metadata_from_dict_serialized(manifest["metadata"]) - def get_composed_image(self, index : int) -> ImageWrapper: + def get_composed_image(self, index : int, composition_cache: ImageCompositionCache | None = None) -> ImageWrapper: # 仅使用单个组合标号来获取 if not self.is_imagedata_loaded(): raise PPInternalError("Cannot compose images without loading the data") layer_indices = self.composites[index].layers - return self.get_composed_image_lower(layer_indices) + return self.get_composed_image_lower(layer_indices, composition_cache=composition_cache) - def get_composed_image_lower(self, layer_indices : list[int]) -> ImageWrapper: + def get_composed_image_lower(self, layer_indices : list[int], composition_cache: ImageCompositionCache | None = None) -> ImageWrapper: # 指定图层组合,可以是原来没有的图层 if not self.is_imagedata_loaded(): raise PPInternalError("Cannot compose images without loading the data") @@ -455,13 +469,48 @@ def get_composed_image_lower(self, layer_indices : list[int]) -> ImageWrapper: if curlayer.offset_x == 0 and curlayer.offset_y == 0 and curlayer.width == self.width and curlayer.height == self.height: return curlayer.patch - result = PIL.Image.new("RGBA", (self.width, self.height)) + # start = time.time() + result = None + if composition_cache is not None and len(composition_cache.layers) > 0: + layer_indices = layer_indices.copy() + isAllMatch = True + for i in range(min(len(composition_cache.layers), len(layer_indices))): + if layer_indices[i] != composition_cache.layers[i][0]: + isAllMatch = False + composition_cache.layers = composition_cache.layers[:i] + layer_indices = layer_indices[i:] + result = composition_cache.layers[-1][1] + if result is None: + raise PPInternalError("Cached composed image is None (min cached layer index too large?)") + break + if isAllMatch: + if len(layer_indices) <= len(composition_cache.layers): + # 完全命中缓存 + result = composition_cache.layers[len(layer_indices)-1][1] + if result is None: + raise PPInternalError("Cached composed image is None (min cached layer index too large?)") + return ImageWrapper(image=result) + else: + # 已绘制的部分层数不够 + layer_indices = layer_indices[len(composition_cache.layers):] + result = composition_cache.layers[-1][1] + if result is None: + raise PPInternalError("Cached composed image is None (min cached layer index too large?)") + + if result is None: + result = PIL.Image.new("RGBA", (self.width, self.height)) for li in layer_indices: layer = self.layers[li] - extended_patch = PIL.Image.new("RGBA", (self.width, self.height)) - extended_patch.paste(layer.patch.get(), (layer.offset_x, layer.offset_y)) - result = PIL.Image.alpha_composite(result, extended_patch) + cur = result.crop((layer.offset_x, layer.offset_y, layer.offset_x + layer.width, layer.offset_y + layer.height)) + cur = PIL.Image.alpha_composite(cur, layer.patch.get()) + if composition_cache is not None and li >= composition_cache.min_cached_layer: + result = result.copy() + result.paste(cur, (layer.offset_x, layer.offset_y)) + if composition_cache is not None: + composition_cache.layers.append((li, result if li >= composition_cache.min_cached_layer else None)) + # end = time.time() + # print(f"get_composed_image_lower: {end-start} s") return ImageWrapper(image=result) @staticmethod diff --git a/src/preppipe_gui_pyside6/toolwidgets/imagepack.py b/src/preppipe_gui_pyside6/toolwidgets/imagepack.py index 66de1fd..35f01f0 100644 --- a/src/preppipe_gui_pyside6/toolwidgets/imagepack.py +++ b/src/preppipe_gui_pyside6/toolwidgets/imagepack.py @@ -88,6 +88,14 @@ def getChildTools(cls, packid : str | None = None, category_kind : ImagePackDesc # 虽然目前只有立绘视图使用但是我们都会提供 layer_code_to_index : dict[str, int] + # 用于加速立绘视图 + composition_cache : dict[int, ImageCompositionCache] + # 第一个不是基底的图层的下标 + # 切换基底时,我们只需保存到比这个值小的最大的图层的缓存 + composition_cache_min_volatile_layer : int + # 每组基底的 ImageCompositionCache.min_cached_layer + composition_cache_min_cached_layers : dict[int, int] + _tr_composition_selection = TR_gui_tool_imagepack.tr("composition_selection", en="Composition Selection", zh_cn="差分选择", @@ -120,6 +128,9 @@ def __init__(self, parent: QWidget): self.composites_reverse_dict = None self.composite_code_to_index = None self.layer_code_to_index = {} + self.composition_cache = {} + self.composition_cache_min_volatile_layer = 0 + self.composition_cache_min_cached_layers = {} _tr_no_mask = TR_gui_tool_imagepack.tr("no_mask", en="This image pack contains no customizable regions.", @@ -240,11 +251,29 @@ def setData(self, packid : str | None = None, category_kind : ImagePackDescripto base_checkbox.setChecked(True) base_checkbox.setEnabled(False) self.bind_text(base_checkbox.setText, preset_kind_base_tr) + base_layers = set() base_combo = QComboBox() for k, v in base_options.items(): base_combo.addItem(k, v) + for layer in v: + base_layers.add(layer) grid_widgets.append((base_checkbox, base_combo, None)) self.charactersprite_layer_base_combobox = base_combo + base_combo.currentIndexChanged.connect(self.handleCharacterCompositionChange_ByLayer) + for layerindex in range(len(self.pack.layers)): + layercode = ImagePackDescriptor.get_layer_code(self.pack.layers[layerindex].basename) + if layercode not in base_layers: + # 找到了第一个不属于基底的图层 + self.composition_cache_min_volatile_layer = layerindex + # 我们需要找到每组基底中在该图层之下的最上层,缓存从这一层开始 + for base_index, data in enumerate(base_options.values()): + cur_min_cached_layer = 0 + for layercode in data: + layerindex = self.layer_code_to_index[layercode] + if layerindex < self.composition_cache_min_volatile_layer and layerindex > cur_min_cached_layer: + cur_min_cached_layer = layerindex + self.composition_cache_min_cached_layers[base_index] = cur_min_cached_layer + break # 然后是各种差分 parts = charactersprite_gen["parts"] for kind_enum, kind_tr in ImagePack.PRESET_YAMLGEN_PARTS_KIND_PRESETS.values(): @@ -430,14 +459,22 @@ def updateCurrentPack(self): self.current_pack = self.pack else: self.current_pack = self.pack.fork_applying_mask(self.current_mask_params, enable_parallelization=True) + self.composition_cache.clear() QMetaObject.invokeMethod(self, "updateCurrentImage", Qt.QueuedConnection) @Slot() def updateCurrentImage(self): + composition_cache = None + if self.charactersprite_layer_base_combobox is not None: + base_index = self.charactersprite_layer_base_combobox.currentIndex() + composition_cache = self.composition_cache.get(base_index, None) + if composition_cache is None: + composition_cache = ImageCompositionCache(min_cached_layer=self.composition_cache_min_cached_layers.get(base_index, 0)) + self.composition_cache[base_index] = composition_cache if isinstance(self.current_index, list): - img = self.current_pack.get_composed_image_lower(self.current_index) + img = self.current_pack.get_composed_image_lower(self.current_index, composition_cache=composition_cache) else: - img = self.current_pack.get_composed_image(self.current_index) + img = self.current_pack.get_composed_image(self.current_index, composition_cache=composition_cache) self.set_image(img) def set_image(self, image: PIL.Image.Image | ImageWrapper): From add07d3c981c2d73bea95e2c299b223017f5be2c Mon Sep 17 00:00:00 2001 From: Shengjie Xu Date: Sun, 16 Nov 2025 10:43:34 +0800 Subject: [PATCH 3/3] [UI] Now ImagePack viewer properly set default colors for masks --- src/preppipe_gui_pyside6/componentwidgets/maskinputwidget.py | 3 +++ src/preppipe_gui_pyside6/toolwidgets/imagepack.py | 1 + 2 files changed, 4 insertions(+) diff --git a/src/preppipe_gui_pyside6/componentwidgets/maskinputwidget.py b/src/preppipe_gui_pyside6/componentwidgets/maskinputwidget.py index f43a31c..c5d3eed 100644 --- a/src/preppipe_gui_pyside6/componentwidgets/maskinputwidget.py +++ b/src/preppipe_gui_pyside6/componentwidgets/maskinputwidget.py @@ -178,6 +178,9 @@ def setIsColorOnly(self, is_color_only: bool): def getIsColorOnly(self) -> bool: return self.is_color_only + def setDefaultColor(self, color : Color): + self.last_color = QColor(color.r, color.g, color.b) + def showMenu(self): menu = QMenu(self) action_color = menu.addAction(self._tr_set_color_fill.get()) diff --git a/src/preppipe_gui_pyside6/toolwidgets/imagepack.py b/src/preppipe_gui_pyside6/toolwidgets/imagepack.py index 35f01f0..4d8e888 100644 --- a/src/preppipe_gui_pyside6/toolwidgets/imagepack.py +++ b/src/preppipe_gui_pyside6/toolwidgets/imagepack.py @@ -181,6 +181,7 @@ def setData(self, packid : str | None = None, category_kind : ImagePackDescripto nameLabel = QLabel(trname.get()) self.bind_text(nameLabel.setText, trname) inputWidget = MaskInputWidget() + inputWidget.setDefaultColor(self.pack.masks[index].mask_color) match mask.get_param_type(): case ImagePackDescriptor.MaskParamType.IMAGE: inputWidget.setIsColorOnly(False)