456 lines
17 KiB
Python
456 lines
17 KiB
Python
import os
|
|
import zlib
|
|
import torch
|
|
import math
|
|
import numba
|
|
import numpy as np
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
|
|
current_dir = os.path.dirname(os.path.realpath(__file__))
|
|
|
|
def tensor2pil(image):
|
|
batch_count = image.size(0) if len(image.shape) > 3 else 1
|
|
if batch_count > 1:
|
|
out = []
|
|
for i in range(batch_count):
|
|
out.extend(tensor2pil(image[i]))
|
|
return out
|
|
return [Image.fromarray(np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))]
|
|
|
|
def pil2tensor(image):
|
|
if isinstance(image, list):
|
|
return torch.cat([pil2tensor(img) for img in image], dim=0)
|
|
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
def array2tensor(image):
|
|
if isinstance(image, list):
|
|
return torch.cat([array2tensor(img) for img in image], dim=0)
|
|
return torch.from_numpy(np.round(image).astype(np.float32) / 255.0).unsqueeze(0)
|
|
|
|
def number_hash(data, length=8):
|
|
crc = zlib.crc32(str(data).encode())
|
|
return str(crc % (10 ** length)).zfill(length)
|
|
|
|
def get_passwd(password: str):
|
|
if not (isinstance(password, str) and len(password) == 4 and password.isdigit()):
|
|
raise ValueError("Password must be a 4-digit numeric string!")
|
|
step = int(password[0:2])
|
|
v = int(password[2:3])
|
|
h = int(password[3:4])
|
|
return max(1, step), v, h
|
|
|
|
def add_padding(original_data, original_width, original_height, extra_cols, extra_rows):
|
|
new_width, new_height = original_width + extra_cols, original_height + extra_rows
|
|
padded_data = np.zeros((new_height, new_width, 4), dtype=np.uint8)
|
|
padded_data[:original_height, :original_width] = original_data
|
|
if extra_cols > 0:
|
|
last_col = original_data[:, -1, :]
|
|
padded_data[:original_height, original_width:] = last_col[:, np.newaxis, :]
|
|
if extra_rows > 0:
|
|
last_row = padded_data[original_height - 1, :, :]
|
|
padded_data[original_height:, :] = last_row[np.newaxis, :, :]
|
|
return padded_data
|
|
|
|
def crop_image_data(image_data, remove_columns, remove_rows):
|
|
original_height, original_width, _ = image_data.shape
|
|
return image_data[:original_height - remove_rows, :original_width - remove_columns, :]
|
|
|
|
@numba.njit(cache=True)
|
|
def _generate2d_numba(x, y, ax, ay, bx, by, coordinates, i):
|
|
"""Numba 优化的希尔伯特/吉尔伯特曲线生成器递归助手。"""
|
|
w = abs(ax + ay)
|
|
h = abs(bx + by)
|
|
|
|
dax = int(np.sign(ax)); day = int(np.sign(ay))
|
|
dbx = int(np.sign(bx)); dby = int(np.sign(by))
|
|
|
|
if h == 1:
|
|
for _ in range(w):
|
|
coordinates[i] = (x, y); i += 1; x += dax; y += day
|
|
return i
|
|
if w == 1:
|
|
for _ in range(h):
|
|
coordinates[i] = (x, y); i += 1; x += dbx; y += dby
|
|
return i
|
|
|
|
ax2, ay2 = ax // 2, ay // 2
|
|
bx2, by2 = bx // 2, by // 2
|
|
w2, h2 = abs(ax2 + ay2), abs(bx2 + by2)
|
|
|
|
if 2 * w > 3 * h:
|
|
if (w2 % 2) and (w > 2): ax2 += dax; ay2 += day
|
|
i = _generate2d_numba(x, y, ax2, ay2, bx, by, coordinates, i)
|
|
i = _generate2d_numba(x + ax2, y + ay2, ax - ax2, ay - ay2, bx, by, coordinates, i)
|
|
else:
|
|
if (h2 % 2) and (h > 2): bx2 += dbx; by2 += dby
|
|
i = _generate2d_numba(x, y, bx2, by2, ax2, ay2, coordinates, i)
|
|
i = _generate2d_numba(x + bx2, y + by2, ax, ay, bx - bx2, by - by2, coordinates, i)
|
|
i = _generate2d_numba(x + (ax - dax) + (bx2 - dbx), y + (ay - day) + (by2 - dby), -bx2, -by2, -(ax - ax2), -(ay - ay2), coordinates, i)
|
|
return i
|
|
|
|
def gilbert2d(width, height):
|
|
"""Numba 加速的吉尔伯特曲线路径计算。"""
|
|
coordinates = np.empty((width * height, 2), dtype=np.int64)
|
|
if width >= height:
|
|
_generate2d_numba(0, 0, width, 0, 0, height, coordinates, 0)
|
|
else:
|
|
_generate2d_numba(0, 0, 0, height, width, 0, coordinates, 0)
|
|
return coordinates
|
|
|
|
@numba.njit(cache=True)
|
|
def calculate_final_mapping(initial_map, steps):
|
|
"""Numba 加速的双倍周期映射置换计算。"""
|
|
size = len(initial_map)
|
|
final_map = np.arange(size, dtype=np.int64)
|
|
current_power_map = initial_map
|
|
while steps > 0:
|
|
if steps % 2 == 1:
|
|
final_map = current_power_map[final_map]
|
|
current_power_map = current_power_map[current_power_map]
|
|
steps //= 2
|
|
return final_map
|
|
|
|
|
|
# ==================== 辅助映射函数 ====================
|
|
|
|
def get_scrambling_map(width, height, step):
|
|
"""根据给定的高度、宽度和步长计算正向加密索引映射表。"""
|
|
total_pixels = width * height
|
|
curve_np = gilbert2d(width, height)
|
|
old_indices = curve_np[:, 1] * width + curve_np[:, 0]
|
|
|
|
offset_float = ((math.sqrt(5) - 1) / 2) * total_pixels
|
|
offset = int(math.floor(offset_float + 0.5))
|
|
|
|
new_pos_indices_on_curve = (np.arange(total_pixels, dtype=np.int64) + offset) % total_pixels
|
|
|
|
initial_map = np.empty_like(old_indices)
|
|
initial_map[old_indices[new_pos_indices_on_curve]] = old_indices
|
|
|
|
return calculate_final_mapping(initial_map, step)
|
|
|
|
|
|
# ==================== PIL 绘图辅助方法 ====================
|
|
|
|
def draw_text_banner(width, text, height=32, font_names=None):
|
|
"""在32px高的白底区域绘制自适应大小的居中单色字体。"""
|
|
|
|
banner = Image.new("RGBA", (width, height), (255, 255, 255, 255))
|
|
draw = ImageDraw.Draw(banner)
|
|
|
|
font_size = 25
|
|
font = ImageFont.truetype(os.path.join(current_dir, "font", "simsun_mini.ttf"), font_size)
|
|
|
|
if font is not None:
|
|
while font_size > 8:
|
|
try:
|
|
text_w = draw.textbbox((0, 0), text, font=font)[2]
|
|
except AttributeError:
|
|
text_w = draw.textsize(text, font=font)[0]
|
|
|
|
if text_w <= width - 10:
|
|
break
|
|
font_size -= 1
|
|
try:
|
|
font = ImageFont.truetype(font.path, font_size)
|
|
except Exception:
|
|
break
|
|
else:
|
|
font = ImageFont.load_default()
|
|
|
|
try:
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_w, text_h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
except AttributeError:
|
|
text_w, text_h = draw.textsize(text, font=font) if font else draw.textsize(text)
|
|
|
|
x = (width - text_w) // 2
|
|
y = (height - text_h) // 2
|
|
draw.text((x, y), text, fill=(0, 0, 0, 255), font=font)
|
|
return banner
|
|
|
|
|
|
# ==================== V3 加解密实现 ====================
|
|
|
|
def encrypt_data_v3(img_data, password, is_chinese):
|
|
step, v, h = get_passwd(password)
|
|
height, width, _ = img_data.shape
|
|
|
|
active_width = width
|
|
active_height = height + 64
|
|
|
|
# 1. 绘制顶部 Banner 并拼接原图
|
|
banner_text = "Use Tomato Crypt for decryption! => https://dfqtphx.netlify.app/"
|
|
if is_chinese:
|
|
banner_text = "[兼容模式] 请使用大番茄解码 => https://dfqtphx.netlify.app/"
|
|
banner_img = draw_text_banner(active_width, banner_text, 32)
|
|
|
|
active_canvas = Image.new("RGBA", (active_width, active_height), (255, 255, 255, 255))
|
|
active_canvas.paste(banner_img, (0, 0))
|
|
active_canvas.paste(Image.fromarray(img_data, 'RGBA'), (0, 32))
|
|
|
|
active_data = np.array(active_canvas)
|
|
active_flat = active_data.reshape(-1, 4)
|
|
|
|
# 2. 获取正向乱序映射
|
|
final_map = get_scrambling_map(active_width, active_height, step)
|
|
|
|
# 3. 像素自愈备份(提取即将落入底部色块毁灭区的像素,存入原图 Y: height+32 的 32px 避难所中)
|
|
start_idx = (active_height - 32) * active_width
|
|
backup_start = (height + 32) * active_width
|
|
active_flat[backup_start : backup_start + 32 * active_width] = active_flat[final_map[start_idx : start_idx + 32 * active_width]]
|
|
|
|
# 4. 执行核心置乱加密
|
|
scrambled_flat_data = active_flat[final_map]
|
|
scrambled_data = scrambled_flat_data.reshape(active_height, active_width, 4)
|
|
|
|
# 5. 添加边缘填充补齐 (v, h)
|
|
padded_data = add_padding(scrambled_data, active_width, active_height, v, h)
|
|
final_height, final_width, _ = padded_data.shape
|
|
|
|
# 6. 绘制定位识别条和尺寸元数据条形码(Y: height + 32 开始)
|
|
# 6.1 绘制 8 像素黑色隔离带
|
|
padded_data[height + 32 : height + 40, :, :3] = 0
|
|
padded_data[height + 32 : height + 40, :, 3] = 255
|
|
|
|
# 6.2 转换宽高为 16 位二进制作为载荷
|
|
w_bits = f"{width:016b}"
|
|
h_bits = f"{height:016b}"
|
|
payload = w_bits + h_bits
|
|
|
|
TOTAL_BLOCKS = 34
|
|
block_width = final_width / TOTAL_BLOCKS
|
|
|
|
for i in range(TOTAL_BLOCKS):
|
|
x_start = int(math.floor(i * block_width))
|
|
x_end = int(math.ceil((i + 1) * block_width))
|
|
|
|
if i == 0:
|
|
color = [255, 0, 0, 255] # 首块红色
|
|
elif i == TOTAL_BLOCKS - 1:
|
|
color = [0, 0, 255, 255] # 尾块蓝色
|
|
else:
|
|
bit = payload[i - 1]
|
|
color = [255, 255, 255, 255] if bit == '1' else [0, 0, 0, 255]
|
|
|
|
padded_data[height + 40 : height + 64, x_start:x_end, :3] = color[:3]
|
|
padded_data[height + 40 : height + 64, x_start:x_end, 3] = color[3]
|
|
|
|
return padded_data
|
|
|
|
|
|
def decrypt_data_v3(img_data, password, is_chinese):
|
|
step, v, h = get_passwd(password)
|
|
img_height, img_width, _ = img_data.shape
|
|
|
|
# 1. 尝试检测底部像素识别条
|
|
sample_y = max(0, img_height - 2)
|
|
line = img_data[sample_y, :, :]
|
|
|
|
TOTAL_BLOCKS = 34
|
|
block_width = img_width / TOTAL_BLOCKS
|
|
|
|
# 检测红蓝边界锚点
|
|
idx_red = int(math.floor(0.5 * block_width))
|
|
r_red, g_red, b_red, _ = line[idx_red]
|
|
is_red = (int(r_red) > int(g_red) + 40) and (int(r_red) > int(b_red) + 40)
|
|
|
|
idx_blue = int(math.floor((TOTAL_BLOCKS - 0.5) * block_width))
|
|
r_blue, g_blue, b_blue, _ = line[idx_blue]
|
|
is_blue = (int(b_blue) > int(r_blue) + 40) and (int(b_blue) > int(g_blue) + 40)
|
|
|
|
has_barcode = False
|
|
decoded_w, decoded_h = 0, 0
|
|
|
|
if is_red and is_blue:
|
|
payload_bits = ""
|
|
for i in range(1, 33):
|
|
sample_x = int(math.floor((i + 0.5) * block_width))
|
|
r, g, b, _ = line[sample_x]
|
|
luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
payload_bits += '1' if luminance > 127 else '0'
|
|
|
|
decoded_w = int(payload_bits[0:16], 2)
|
|
decoded_h = int(payload_bits[16:32], 2)
|
|
|
|
is_size_reasonable = (10 < decoded_w <= 16384) and (10 < decoded_h <= 16384)
|
|
if is_size_reasonable:
|
|
has_barcode = True
|
|
|
|
# 如果未检测到符合 V3 标准的条形码,则自动降级到 Legacy 模式解密
|
|
if not has_barcode:
|
|
return decrypt_data(img_data, password)
|
|
|
|
# 计算图像是否被外部平台二次压缩或放缩
|
|
scale_percent = round((img_width / (decoded_w + (v if v is not None else 0))) * 100)
|
|
is_resized = (scale_percent != 100)
|
|
|
|
target_w = decoded_w + (v if v is not None else 0)
|
|
target_h = decoded_h + 64 + (h if h is not None else 0)
|
|
|
|
# 2. 如果尺寸改变,重采样还原到精确像素点
|
|
if is_resized:
|
|
pil_temp = Image.fromarray(img_data, 'RGBA')
|
|
pil_temp = pil_temp.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
|
scaled_data = np.array(pil_temp)
|
|
else:
|
|
scaled_data = img_data
|
|
|
|
# 3. 裁剪保留非填充的核心加密区
|
|
cropped_active_data = scaled_data[0 : decoded_h + 64, 0 : decoded_w, :]
|
|
|
|
# 4. 执行反向像素置乱映射
|
|
active_height = decoded_h + 64
|
|
active_width = decoded_w
|
|
total_pixels = active_width * active_height
|
|
data_flat = cropped_active_data.reshape(-1, 4)
|
|
|
|
curve_np = gilbert2d(active_width, active_height)
|
|
old_indices = curve_np[:, 1] * active_width + curve_np[:, 0]
|
|
|
|
offset_float = ((math.sqrt(5) - 1) / 2) * total_pixels
|
|
offset = int(math.floor(offset_float + 0.5))
|
|
new_pos_indices_on_curve = (np.arange(total_pixels, dtype=np.int64) + offset) % total_pixels
|
|
|
|
initial_map = np.empty_like(old_indices)
|
|
initial_map[old_indices] = old_indices[new_pos_indices_on_curve]
|
|
|
|
final_map = calculate_final_mapping(initial_map, step)
|
|
unscrambled_flat_data = data_flat[final_map]
|
|
|
|
decrypted_active = unscrambled_flat_data.reshape(active_height, active_width, 4)
|
|
|
|
# 5. 自愈重组:恢复被底部覆盖损坏的像素
|
|
F_e = get_scrambling_map(decoded_w, decoded_h + 64, step)
|
|
start_idx = (decoded_h + 32) * decoded_w
|
|
dec_flat = decrypted_active.reshape(-1, 4)
|
|
|
|
damaged_indices = F_e[start_idx : start_idx + 32 * decoded_w]
|
|
backup_indices = np.arange(start_idx, start_idx + 32 * decoded_w)
|
|
|
|
dec_flat[damaged_indices] = dec_flat[backup_indices]
|
|
|
|
# 6. 双向裁剪重建
|
|
if is_resized:
|
|
warn_text = "Input image isn't at its original resolution. Use the original image for better results!"
|
|
if is_chinese:
|
|
warn_text = "输入图像尺寸曾被缩小或放大, 请使用全分辨率原图进行解码效果更佳!"
|
|
warn_banner = draw_text_banner(decoded_w, warn_text, 32)
|
|
|
|
final_canvas = Image.new("RGBA", (decoded_w, decoded_h + 32), (255, 255, 255, 255))
|
|
final_canvas.paste(warn_banner, (0, 0))
|
|
|
|
restored_img_np = decrypted_active[32 : decoded_h + 32, :, :]
|
|
final_canvas.paste(Image.fromarray(restored_img_np, 'RGBA'), (0, 32))
|
|
return np.array(final_canvas)
|
|
else:
|
|
# 无需警告直接裁剪返回
|
|
return decrypted_active[32 : decoded_h + 32, :, :]
|
|
|
|
|
|
# ==================== ComfyUI 包装集成 ====================
|
|
|
|
def process_pil_image(mode: str, pil_image: Image.Image, password: str, version: str = "v3", language: str = "en") -> Image.Image:
|
|
img_rgba = pil_image.convert("RGBA")
|
|
img_data = np.array(img_rgba)
|
|
is_chinese = language.startswith("zh")
|
|
|
|
if mode == 'encrypt':
|
|
if version == 'v3':
|
|
processed_data = encrypt_data_v3(img_data, password, is_chinese)
|
|
else:
|
|
processed_data = encrypt_data(img_data, password, is_chinese)
|
|
elif mode == 'decrypt':
|
|
# 解密自动检测版本,优先使用支持条形码与自愈功能的 V3 反向恢复
|
|
processed_data = decrypt_data_v3(img_data, password, is_chinese)
|
|
else:
|
|
raise ValueError("Mode must be 'encrypt' or 'decrypt'")
|
|
|
|
result_img = Image.fromarray(processed_data, 'RGBA')
|
|
return result_img
|
|
|
|
def encrypt_data(img_data, password):
|
|
step, v, h = get_passwd(password)
|
|
height, width, _ = img_data.shape
|
|
data = img_data.reshape(-1, 4)
|
|
total_pixels = width * height
|
|
|
|
# Now calls the fast, Numba-compiled gilbert2d
|
|
curve_np = gilbert2d(width, height)
|
|
old_indices = curve_np[:, 1] * width + curve_np[:, 0]
|
|
|
|
offset_float = ((math.sqrt(5) - 1) / 2) * total_pixels
|
|
offset = int(math.floor(offset_float + 0.5))
|
|
|
|
new_pos_indices_on_curve = (np.arange(total_pixels, dtype=np.int64) + offset) % total_pixels
|
|
|
|
initial_map = np.empty_like(old_indices)
|
|
initial_map[old_indices[new_pos_indices_on_curve]] = old_indices
|
|
|
|
# Now calls the fast, Numba-compiled mapping calculation
|
|
final_map = calculate_final_mapping(initial_map, step)
|
|
scrambled_flat_data = data[final_map]
|
|
|
|
scrambled_data = scrambled_flat_data.reshape(height, width, 4)
|
|
padded_data = add_padding(scrambled_data, width, height, v, h)
|
|
return padded_data
|
|
|
|
def decrypt_data(img_data, password):
|
|
step, v, h = get_passwd(password)
|
|
|
|
cropped_data = crop_image_data(img_data, v, h)
|
|
height, width, _ = cropped_data.shape
|
|
data = cropped_data.reshape(-1, 4)
|
|
total_pixels = width * height
|
|
|
|
curve_np = gilbert2d(width, height)
|
|
old_indices = curve_np[:, 1] * width + curve_np[:, 0]
|
|
|
|
offset_float = ((math.sqrt(5) - 1) / 2) * total_pixels
|
|
offset = int(math.floor(offset_float + 0.5))
|
|
|
|
new_pos_indices_on_curve = (np.arange(total_pixels, dtype=np.int64) + offset) % total_pixels
|
|
|
|
initial_map = np.empty_like(old_indices)
|
|
initial_map[old_indices] = old_indices[new_pos_indices_on_curve]
|
|
|
|
final_map = calculate_final_mapping(initial_map, step)
|
|
unscrambled_flat_data = data[final_map]
|
|
|
|
unscrambled_data = unscrambled_flat_data.reshape(height, width, 4)
|
|
return unscrambled_data
|
|
|
|
class TomatoCrypt:
|
|
@classmethod
|
|
def INPUT_TYPES(s):
|
|
return {
|
|
"required": {
|
|
"image": ("IMAGE", ),
|
|
"mode": (["encrypt", "decrypt"], {"default": "encrypt"}),
|
|
"version": (["v3", "legacy"], {"default": "v3"}),
|
|
"password": ("STRING", ),
|
|
"language_code": ("STRING", {"default": "en"}),
|
|
}
|
|
}
|
|
|
|
RETURN_TYPES = ("IMAGE",)
|
|
FUNCTION = "main"
|
|
CATEGORY = "image"
|
|
|
|
def main(self, image, mode, version, password, language_code):
|
|
results = []
|
|
images = tensor2pil(image)
|
|
password = password if password != "" else "0000"
|
|
for img in images:
|
|
results.append(process_pil_image(mode, img, password, version, language_code))
|
|
return (array2tensor(results), )
|
|
|
|
NODE_CLASS_MAPPINGS = {
|
|
"TomatoCrypt": TomatoCrypt
|
|
}
|
|
|
|
NODE_DISPLAY_NAME_MAPPINGS = {
|
|
"TomatoCrypt": "Tomato Crypt"
|
|
}
|
|
|
|
WEB_DIRECTORY = "./web" |