Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 71 additions & 10 deletions src/preppipe/util/imagepack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -1948,6 +1997,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
Expand All @@ -1964,7 +2014,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 中的某一项匹配,或是某一项的子节点
Expand All @@ -1974,16 +2026,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
Expand Down Expand Up @@ -2093,6 +2153,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))
Expand Down
53 changes: 53 additions & 0 deletions src/preppipe/util/psddump.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2023 PrepPipe's Contributors
# SPDX-License-Identifier: Apache-2.0

# 该文件用于分析 psd 文件的图层结构、显示相关信息以便后续编写图片包(ImagePack)的配置文件
# 可使用以下方式进行调用:
# python3 -m preppipe.util.psddump <psdfile> [<psdfile> ...]

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 <psdfile> [<psdfile> ...]")
return 1
for psdpath in args[1:]:
_dump_psd_info(psdpath)
return 0

if __name__ == "__main__":
sys.exit(main(sys.argv))
3 changes: 3 additions & 0 deletions src/preppipe_gui_pyside6/componentwidgets/maskinputwidget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
42 changes: 40 additions & 2 deletions src/preppipe_gui_pyside6/toolwidgets/imagepack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="差分选择",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -170,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)
Expand Down Expand Up @@ -240,11 +252,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():
Expand Down Expand Up @@ -430,14 +460,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):
Expand Down