167 lines
5.6 KiB
Python
167 lines
5.6 KiB
Python
import io
|
|
import os
|
|
import struct
|
|
import numpy as np
|
|
from PIL import Image
|
|
import torch
|
|
|
|
try:
|
|
import folder_paths # type: ignore
|
|
except Exception:
|
|
folder_paths = None
|
|
|
|
CATEGORY = "single_image_deducker"
|
|
WATERMARK_SKIP_W_RATIO = 0.40
|
|
WATERMARK_SKIP_H_RATIO = 0.08
|
|
|
|
def _extract_payload_with_k(arr: np.ndarray, k: int) -> bytes:
|
|
h, w, c = arr.shape
|
|
skip_w = int(w * WATERMARK_SKIP_W_RATIO)
|
|
skip_h = int(h * WATERMARK_SKIP_H_RATIO)
|
|
mask2d = np.ones((h, w), dtype=bool)
|
|
if skip_w > 0 and skip_h > 0:
|
|
mask2d[:skip_h, :skip_w] = False
|
|
mask3d = np.repeat(mask2d[:, :, None], c, axis=2)
|
|
flat = arr.reshape(-1)
|
|
idxs = np.flatnonzero(mask3d.reshape(-1))
|
|
vals = (flat[idxs] & ((1 << k) - 1)).astype(np.uint8)
|
|
ub = np.unpackbits(vals, bitorder="big").reshape(-1, 8)[:, -k:]
|
|
bits = ub.reshape(-1)
|
|
if len(bits) < 32:
|
|
raise ValueError("Insufficient image data. 图像数据不足")
|
|
len_bits = bits[:32]
|
|
length_bytes = np.packbits(len_bits, bitorder="big").tobytes()
|
|
header_len = struct.unpack(">I", length_bytes)[0]
|
|
total_bits = 32 + header_len * 8
|
|
if header_len <= 0 or total_bits > len(bits):
|
|
raise ValueError("Payload length invalid. 载荷长度异常")
|
|
payload_bits = bits[32:32 + header_len * 8]
|
|
return np.packbits(payload_bits, bitorder="big").tobytes()
|
|
|
|
def _generate_key_stream(password: str, salt: bytes, length: int) -> bytes:
|
|
import hashlib
|
|
key_material = (password + salt.hex()).encode("utf-8")
|
|
out = bytearray()
|
|
counter = 0
|
|
while len(out) < length:
|
|
out.extend(hashlib.sha256(key_material + str(counter).encode("utf-8")).digest())
|
|
counter += 1
|
|
return bytes(out[:length])
|
|
|
|
def _parse_header(header: bytes, password: str):
|
|
idx = 0
|
|
if len(header) < 1:
|
|
raise ValueError("Header corrupted. 文件头损坏")
|
|
has_pwd = header[0] == 1
|
|
idx += 1
|
|
pwd_hash = b""
|
|
salt = b""
|
|
if has_pwd:
|
|
if len(header) < idx + 32 + 16:
|
|
raise ValueError("Header corrupted. 文件头损坏")
|
|
pwd_hash = header[idx:idx + 32]; idx += 32
|
|
salt = header[idx:idx + 16]; idx += 16
|
|
if len(header) < idx + 1:
|
|
raise ValueError("Header corrupted. 文件头损坏")
|
|
ext_len = header[idx]; idx += 1
|
|
if len(header) < idx + ext_len + 4:
|
|
raise ValueError("Header corrupted. 文件头损坏")
|
|
ext = header[idx:idx + ext_len].decode("utf-8", errors="ignore"); idx += ext_len
|
|
data_len = struct.unpack(">I", header[idx:idx + 4])[0]; idx += 4
|
|
data = header[idx:]
|
|
if len(data) != data_len:
|
|
raise ValueError("Data length mismatch. 数据长度不匹配")
|
|
if not has_pwd:
|
|
return data, ext
|
|
if not password:
|
|
raise ValueError("Password required. 需要密码")
|
|
import hashlib
|
|
check_hash = hashlib.sha256((password + salt.hex()).encode("utf-8")).digest()
|
|
if check_hash != pwd_hash:
|
|
raise ValueError("Wrong password. 密码错误")
|
|
ks = _generate_key_stream(password, salt, len(data))
|
|
plain = bytes(a ^ b for a, b in zip(data, ks))
|
|
return plain, ext
|
|
|
|
def _tensor_to_pil(image: torch.Tensor) -> Image.Image:
|
|
if image.dim() == 4:
|
|
image = image[0]
|
|
arrf = image.detach().cpu().numpy() * 255.0
|
|
arru = np.rint(np.clip(arrf, 0, 255)).astype(np.uint8)
|
|
if arru.ndim == 2:
|
|
arru = np.stack([arru, arru, arru], axis=-1)
|
|
return Image.fromarray(arru, mode="RGB")
|
|
if arru.shape[-1] == 3:
|
|
return Image.fromarray(arru, mode="RGB")
|
|
if arru.shape[-1] == 4:
|
|
return Image.fromarray(arru, mode="RGBA")
|
|
if arru.shape[-1] > 4:
|
|
return Image.fromarray(arru[..., :3], mode="RGB")
|
|
return Image.fromarray(np.repeat(arru[..., :1], 3, axis=-1), mode="RGB")
|
|
|
|
def _pil_to_tensor(image: Image.Image) -> torch.Tensor:
|
|
arr = np.array(image).astype(np.float32) / 255.0
|
|
return torch.from_numpy(arr)[None, ...]
|
|
|
|
def _is_image_ext(ext: str) -> bool:
|
|
return ext.lower().lstrip(".") in ("png", "jpg", "jpeg", "bmp", "webp")
|
|
|
|
|
|
class DuckDecodeToFileNode:
|
|
"""仅解码单张图片并输出为 IMAGE;解码结果不是图片则报错。"""
|
|
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE",),
|
|
},
|
|
"optional": {
|
|
"password": ("STRING", {"default": "", "multiline": False}),
|
|
},
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
RETURN_NAMES = ("image",)
|
|
FUNCTION = "decode"
|
|
CATEGORY = CATEGORY
|
|
|
|
def decode(self, image: torch.Tensor, password: str = ""):
|
|
pil = _tensor_to_pil(image)
|
|
arr = np.array(pil.convert("RGB")).astype(np.uint8)
|
|
header = None
|
|
raw = None
|
|
ext = None
|
|
last_err = None
|
|
for k in (2, 6, 8):
|
|
try:
|
|
header = _extract_payload_with_k(arr, k)
|
|
raw, ext = _parse_header(header, password)
|
|
break
|
|
except Exception as e:
|
|
last_err = e
|
|
continue
|
|
if raw is None:
|
|
raise last_err or RuntimeError("解析失败")
|
|
|
|
if not _is_image_ext(ext):
|
|
raise ValueError(
|
|
f"解码结果不是图片(扩展名: {ext}),本节点仅支持单张图片解码。"
|
|
)
|
|
|
|
try:
|
|
out_pil = Image.open(io.BytesIO(raw)).convert("RGB")
|
|
except Exception as e:
|
|
raise ValueError(f"解码得到的数据不是有效图片,无法解析: {e}")
|
|
|
|
img_tensor = _pil_to_tensor(out_pil)
|
|
return (img_tensor,)
|
|
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"DuckDecodeToFileNode": DuckDecodeToFileNode,
|
|
}
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"DuckDecodeToFileNode": "single_image_deducker",
|
|
}
|