Files
Raykosan-ComfyUI_RaykoStudio/Rayko_Outpaint.py
T

301 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import base64
import io
import numpy as np
import server
import torch
import torch.nn.functional as F
from aiohttp import web
from PIL import Image
import time
import os
import folder_paths
print("\033[93m🦊\033[0m \033[93mRaykoStudio - RS Outpaint \033[92mLOADED\033[0m")
_GRID = 16
_MIN_DIM = 32
_DEFAULT_PAD_FACTOR = 0.3
_frame_cache = {}
_PENDING_DECISIONS = {}
def _tensor_hash(tensor):
try:
t = tensor[0] if tensor.dim() == 4 else tensor
arr = (t.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
return hash(arr.tobytes())
except Exception:
return None
def _tensor_to_jpeg(image_tensor):
arr = (image_tensor.cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
img = Image.fromarray(arr)
buf = io.BytesIO()
img.save(buf, format="JPEG", quality=85)
return buf.getvalue()
class RSOutpaint:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Source image."}),
"crop_state": ("STRING", {
"default": "",
"tooltip": "State: 'x,y,w,h,outW,outH,r,g,b'",
}),
},
"hidden": {
"unique_id": "UNIQUE_ID",
},
}
# ✅ ДОБАВЛЕН mask_image
RETURN_TYPES = ("IMAGE", "MASK", "IMAGE", "INT", "INT")
RETURN_NAMES = ("control_image", "control_mask", "mask_image", "width", "height")
FUNCTION = "outpaint_prep"
OUTPUT_NODE = True
CATEGORY = "🦊 RaykoStudio"
DESCRIPTION = "Interactive mask for outpaint with pause"
def outpaint_prep(self, image, crop_state, unique_id):
if image.dim() == 4:
src = image[0]
else:
src = image
src_h, src_w, _ = src.shape
if src_w < _MIN_DIM or src_h < _MIN_DIM:
raise ValueError(f"[RS Outpaint] Input image too small ({src_w}×{src_h}).")
unique_id = str(unique_id)
src_hash = _tensor_hash(src)
cached = _frame_cache.get(unique_id, {})
if cached.get("src_hash") != src_hash or cached.get("width") != src_w or cached.get("height") != src_h:
if unique_id in _frame_cache:
del _frame_cache[unique_id]
if unique_id in _PENDING_DECISIONS:
del _PENDING_DECISIONS[unique_id]
crop_state = ""
print(f"🦊 [RS Outpaint] New image detected for node {unique_id}, state reset")
if unique_id not in _PENDING_DECISIONS:
_PENDING_DECISIONS[unique_id] = {
"status": "pending",
"crop_state": crop_state
}
_frame_cache[unique_id] = {
"width": src_w,
"height": src_h,
"src_hash": src_hash,
"image": _tensor_to_jpeg(src),
}
print(f"🦊 [RS Outpaint] Waiting for approval for node {unique_id}")
try:
i = 255. * src.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
filename = f"rsoutpaint_{unique_id}.png"
subfolder = "rsoutpaint"
full_output_folder = os.path.join(folder_paths.get_temp_directory(), subfolder)
os.makedirs(full_output_folder, exist_ok=True)
img.save(os.path.join(full_output_folder, filename))
server.PromptServer.instance.send_sync("rs_outpaint.show", {
"node_id": unique_id,
"image_url": f"/view?filename={filename}&type=temp&subfolder={subfolder}",
"image_width": img.width,
"image_height": img.height
})
except Exception as e:
print(f"🦊 [RS Outpaint] Error saving preview: {e}")
while True:
state = _PENDING_DECISIONS.get(unique_id, {})
current_status = state.get("status", "pending")
current_crop_state = state.get("crop_state", crop_state)
if current_status == "approved":
crop_state = current_crop_state
if unique_id in _PENDING_DECISIONS:
del _PENDING_DECISIONS[unique_id]
print(f"🦊 [RS Outpaint] Approved! Continuing for node {unique_id}")
break
if current_status == "cancelled":
if unique_id in _PENDING_DECISIONS:
del _PENDING_DECISIONS[unique_id]
print(f"🦊 [RS Outpaint] Cancelled for node {unique_id}")
empty_img = torch.zeros((1, src_h, src_w, 3))
empty_mask = torch.ones((1, src_h, src_w))
return (empty_img, empty_mask, empty_img, 0, 0)
if current_status == "removed":
if unique_id in _PENDING_DECISIONS:
del _PENDING_DECISIONS[unique_id]
return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h)
if current_status == "rejected":
_PENDING_DECISIONS[unique_id]["status"] = "pending"
crop_state = current_crop_state
time.sleep(0.3)
crop_x = crop_y = crop_w = crop_h = 0
output_width = output_height = 0
use_crop_state = False
# ✅ ДЕФОЛТНЫЕ ЦВЕТА
color_r, color_g, color_b = 255, 0, 0 # Красный для маски
bg_r, bg_g, bg_b = 20, 20, 20 # Темный для фона
if crop_state:
try:
parts = [int(v) for v in str(crop_state).split(",")]
if len(parts) >= 4:
cx, cy, cw, ch = parts[:4]
if cw >= _GRID and ch >= _GRID:
crop_x, crop_y, crop_w, crop_h = cx, cy, cw, ch
use_crop_state = True
if len(parts) >= 6:
output_width, output_height = parts[4], parts[5]
# ✅ ПАРСИНГ ЦВЕТОВ (RGB)
if len(parts) >= 9:
color_r, color_g, color_b = parts[6], parts[7], parts[8]
# Если есть и цвета фона (для полноты, хотя используем 3 для маски)
if len(parts) >= 12:
bg_r, bg_g, bg_b = parts[9], parts[10], parts[11]
except ValueError:
pass
if not use_crop_state:
pad = max(_GRID, round(src_h * _DEFAULT_PAD_FACTOR / _GRID) * _GRID)
crop_x = 0
crop_y = -pad
crop_w = round(src_w / _GRID) * _GRID
crop_h = round((src_h + pad) / _GRID) * _GRID
eff_w = output_width if output_width >= _GRID else crop_w
eff_h = output_height if output_height >= _GRID else crop_h
_frame_cache[unique_id] = {
"width": src_w,
"height": src_h,
"src_hash": src_hash,
"image": _tensor_to_jpeg(src),
}
time.sleep(0.05)
src_np = src.cpu().numpy()
out = np.full((eff_h, eff_w, 3), 0.5, dtype=np.float32)
mask = np.ones((eff_h, eff_w), dtype=np.float32)
src_x_start = max(0, crop_x)
src_y_start = max(0, crop_y)
src_x_end = min(src_w, crop_x + crop_w)
src_y_end = min(src_h, crop_y + crop_h)
src_copy_w = max(0, src_x_end - src_x_start)
src_copy_h = max(0, src_y_end - src_y_start)
if src_copy_w > 0 and src_copy_h > 0:
crop_offset_x = (eff_w - crop_w) // 2
crop_offset_y = (eff_h - crop_h) // 2
img_offset_in_crop_x = max(0, -crop_x)
img_offset_in_crop_y = max(0, -crop_y)
final_dst_x = crop_offset_x + img_offset_in_crop_x
final_dst_y = crop_offset_y + img_offset_in_crop_y
copy_w = min(src_copy_w, eff_w - final_dst_x)
copy_h = min(src_copy_h, eff_h - final_dst_y)
if copy_w > 0 and copy_h > 0:
out[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w] = \
src_np[src_y_start:src_y_start + copy_h, src_x_start:src_x_start + copy_w]
mask[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w] = 0.0
control_image = torch.from_numpy(out).unsqueeze(0)
control_mask = torch.from_numpy(mask).unsqueeze(0)
# ✅ ГЕНЕРАЦИЯ MASK_IMAGE (RGB)
# control_mask shape: (1, H, W)
# Нам нужно (1, H, W, 3)
# Нормализуем цвета 0-255 -> 0.0-1.0
norm_color = torch.tensor([color_r/255.0, color_g/255.0, color_b/255.0]).view(1, 1, 1, 3)
norm_bg = torch.tensor([bg_r/255.0, bg_g/255.0, bg_b/255.0]).view(1, 1, 1, 3)
# Расширяем маску до (1, H, W, 1) для вещания
mask_exp = control_mask.unsqueeze(3)
# Формула: mask_color * mask_value + bg_color * (1 - mask_value)
mask_image = mask_exp * norm_color + (1.0 - mask_exp) * norm_bg
mask_image = torch.clamp(mask_image, 0.0, 1.0)
return (control_image, control_mask, mask_image, eff_w, eff_h)
NODE_CLASS_MAPPINGS = {"RSOutpaint": RSOutpaint}
NODE_DISPLAY_NAME_MAPPINGS = {"RSOutpaint": "🦊 RS Outpaint"}
if server.PromptServer:
@server.PromptServer.instance.routes.post("/rs_outpaint/decision")
async def rs_outpaint_decision(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
decision = data.get("decision")
crop_state = data.get("crop_state")
if node_id in _PENDING_DECISIONS:
if decision == "approve":
_PENDING_DECISIONS[node_id]["status"] = "approved"
if crop_state:
_PENDING_DECISIONS[node_id]["crop_state"] = crop_state
elif decision == "cancel":
_PENDING_DECISIONS[node_id]["status"] = "cancelled"
return web.Response(status=200, text="Decision recorded")
return web.Response(status=4404, text=f"Node {node_id} not waiting")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/cleanup")
async def rs_outpaint_cleanup(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
if node_id in _PENDING_DECISIONS:
del _PENDING_DECISIONS[node_id]
if node_id in _frame_cache:
del _frame_cache[node_id]
return web.Response(status=200, text="Cleanup recorded")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.get("/rs_outpaint/info")
async def rs_outpaint_info(request):
node_id = request.query.get("node_id", "")
cache_key = str(node_id).strip()
if not cache_key or cache_key not in _frame_cache:
return web.Response(status=404, text="No cached image data.")
cache = _frame_cache[cache_key]
return web.json_response({
"width": cache["width"],
"height": cache["height"],
"image": base64.b64encode(cache["image"]).decode(),
})
@server.PromptServer.instance.routes.get("/rs_outpaint/image")
async def rs_outpaint_image(request):
node_id = request.query.get("node_id", "")
cache_key = str(node_id).strip()
if not cache_key or cache_key not in _frame_cache:
return web.Response(status=404, text="No cached image data.")
cache = _frame_cache[cache_key]
return web.Response(body=cache["image"], content_type="image/jpeg")