Files
Raykosan-ComfyUI_RaykoStudio/Rayko_Outpaint.py
T

522 lines
21 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.
# SPDX-License-Identifier: Apache-2.0
# Copyright 2025-2026 Raykosan (RaykoStudio)
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import io
import json
import numpy as np
import server
import torch
from aiohttp import web
from PIL import Image
import time
import os
import folder_paths
_GRID = 16
_MIN_DIM = 32
_DEFAULT_PAD_FACTOR = 0.3
_frame_cache = {}
_PENDING_DECISIONS = {}
_SAVED_CROP_PRESETS = {}
_APPROVED_CROPS = {}
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
PRESET_DIR = os.path.join(CURRENT_DIR, "presets")
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()
def _adapt_crop_to_new_size(crop_state, new_w, new_h):
if not crop_state:
return None
try:
parts = [int(v) for v in str(crop_state).split(",")]
if len(parts) < 4:
return None
cx, cy, cw, ch = parts[0], parts[1], parts[2], parts[3]
cx = max(-new_w, min(cx, new_w - _GRID))
cy = max(-new_h, min(cy, new_h - _GRID))
cw = max(_GRID, min(cw, new_w * 2))
ch = max(_GRID, min(ch, new_h * 2))
out_w = parts[4] if len(parts) >= 5 else 0
out_h = parts[5] if len(parts) >= 6 else 0
colors = parts[6:9] if len(parts) >= 9 else [255, 0, 0]
bg_colors = parts[9:12] if len(parts) >= 12 else [20, 20, 20]
return f"{cx},{cy},{cw},{ch},{out_w},{out_h},{','.join(map(str, colors))},{','.join(map(str, bg_colors))}"
except Exception:
return None
class RSOutpaint:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE", {"tooltip": "Source image."}),
"crop_state": ("STRING", {"default": "", "tooltip": "Internal state", "hidden": True}),
},
"hidden": {"unique_id": "UNIQUE_ID"},
}
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 batch preset support"
@classmethod
def IS_CHANGED(cls, image, crop_state="", unique_id=None, **kwargs):
return float("nan")
def outpaint_prep(self, image, crop_state, unique_id):
src = image[0] if image.dim() == 4 else image
src_h, src_w, _ = src.shape
if src_w < _MIN_DIM or src_h < _MIN_DIM:
raise ValueError(f"Input image too small ({src_w}×{src_h}).")
unique_id = str(unique_id)
try:
src_hash = _tensor_hash(src)
cached = _frame_cache.get(unique_id, {})
is_new_image = (cached.get("src_hash") != src_hash or
cached.get("width") != src_w or
cached.get("height") != src_h)
saved_preset = _SAVED_CROP_PRESETS.get(unique_id, {})
batch_mode_active = saved_preset.get("active", False)
batch_mode_type = saved_preset.get("mode", "single")
auto_apply = False
if batch_mode_active and saved_preset.get("crop_state"):
if batch_mode_type == "multi":
auto_apply = True
elif batch_mode_type == "single" and not is_new_image:
auto_apply = True
if not auto_apply:
approved_crop = _APPROVED_CROPS.get(unique_id, {}).get(src_hash)
if approved_crop and not is_new_image:
crop_state = approved_crop
auto_apply = True
if auto_apply:
if not batch_mode_active:
pass
else:
crop_state = _adapt_crop_to_new_size(saved_preset["crop_state"], src_w, src_h)
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,
"is_new_image": is_new_image,
"src_hash": src_hash,
"auto_approved": True,
"applied_crop_state": crop_state
})
except Exception:
pass
else:
if is_new_image:
if unique_id in _frame_cache:
del _frame_cache[unique_id]
crop_state = ""
_frame_cache[unique_id] = {
"width": src_w,
"height": src_h,
"src_hash": src_hash,
"image": _tensor_to_jpeg(src)
}
if unique_id not in _PENDING_DECISIONS:
_PENDING_DECISIONS[unique_id] = {
"status": "pending",
"crop_state": crop_state,
"last_heartbeat": time.time()
}
else:
_PENDING_DECISIONS[unique_id]["status"] = "pending"
_PENDING_DECISIONS[unique_id]["crop_state"] = crop_state
_PENDING_DECISIONS[unique_id]["last_heartbeat"] = time.time()
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,
"is_new_image": is_new_image,
"src_hash": src_hash
})
except Exception:
pass
while True:
state = _PENDING_DECISIONS.get(unique_id, {})
current_status = state.get("status", "pending")
current_crop_state = state.get("crop_state", crop_state)
last_hb = state.get("last_heartbeat", time.time())
if current_status == "approved":
crop_state = current_crop_state
if unique_id not in _APPROVED_CROPS:
_APPROVED_CROPS[unique_id] = {}
_APPROVED_CROPS[unique_id][src_hash] = crop_state
break
elif current_status == "cancelled":
if unique_id in _APPROVED_CROPS and src_hash:
_APPROVED_CROPS[unique_id].pop(src_hash, None)
if not _APPROVED_CROPS[unique_id]:
del _APPROVED_CROPS[unique_id]
return (torch.zeros((1, src_h, src_w, 3)), torch.ones((1, src_h, src_w)), torch.zeros((1, src_h, src_w, 3)), 0, 0)
elif current_status == "removed":
return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h)
elif time.time() - last_hb > 10.0:
return (image, torch.ones((1, src_h, src_w)), image, src_w, src_h)
elif current_status == "rejected":
_PENDING_DECISIONS[unique_id]["status"] = "pending"
crop_state = current_crop_state
time.sleep(0.2)
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 > 0 and ch > 0:
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]
if len(parts) >= 9:
color_r, color_g, color_b = parts[6], parts[7], parts[8]
if len(parts) >= 12:
bg_r, bg_g, bg_b = parts[9], parts[10], parts[11]
except ValueError:
pass
if use_crop_state and (output_width == 0 or output_height == 0):
output_width = crop_w
output_height = crop_h
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
eff_w = max(eff_w, _GRID)
eff_h = max(eff_h, _GRID)
if eff_w < 32:
eff_w = src_w
if eff_h < 32:
eff_h = src_h
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
final_dst_x = max(0, min(final_dst_x, eff_w - 1))
final_dst_y = max(0, min(final_dst_y, eff_h - 1))
copy_w = max(0, min(src_copy_w, eff_w - final_dst_x))
copy_h = max(0, min(src_copy_h, eff_h - final_dst_y))
if copy_w > 0 and copy_h > 0:
src_slice = src_np[src_y_start:src_y_start + copy_h, src_x_start:src_x_start + copy_w]
dst_slice = out[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w]
if src_slice.shape == dst_slice.shape:
dst_slice[:] = src_slice
mask[final_dst_y:final_dst_y + copy_h, final_dst_x:final_dst_x + copy_w] = 0.0
else:
min_h = min(src_slice.shape[0], dst_slice.shape[0])
min_w = min(src_slice.shape[1], dst_slice.shape[1])
if min_h > 0 and min_w > 0:
dst_slice[:min_h, :min_w] = src_slice[:min_h, :min_w]
mask[final_dst_y:final_dst_y + min_h, final_dst_x:final_dst_x + min_w] = 0.0
control_image = torch.from_numpy(out).unsqueeze(0)
control_mask = torch.from_numpy(mask).unsqueeze(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)
mask_exp = control_mask.unsqueeze(3)
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)
finally:
if unique_id in _PENDING_DECISIONS:
del _PENDING_DECISIONS[unique_id]
NODE_CLASS_MAPPINGS = {"RSOutpaint": RSOutpaint}
NODE_DISPLAY_NAME_MAPPINGS = {"RSOutpaint": "🦊 RS Outpaint"}
@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")
batch_mode = data.get("batch_mode", False)
batch_type = data.get("batch_type", "single")
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
_PENDING_DECISIONS[node_id]["batch_mode"] = batch_mode
if batch_mode and crop_state:
_SAVED_CROP_PRESETS[node_id] = {
"crop_state": crop_state,
"active": True,
"mode": batch_type
}
elif decision == "cancel":
_PENDING_DECISIONS[node_id]["status"] = "cancelled"
if node_id in _SAVED_CROP_PRESETS:
del _SAVED_CROP_PRESETS[node_id]
return web.Response(status=200, text="OK")
return web.Response(status=404, text="Not waiting")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/unapprove")
async def rs_outpaint_unapprove(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
src_hash = data.get("src_hash")
if node_id in _APPROVED_CROPS and src_hash:
_APPROVED_CROPS[node_id].pop(src_hash, None)
if not _APPROVED_CROPS[node_id]:
del _APPROVED_CROPS[node_id]
return web.Response(status=200, text="OK")
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:
_PENDING_DECISIONS[node_id]["status"] = "removed"
_PENDING_DECISIONS[node_id]["last_heartbeat"] = time.time()
if node_id in _APPROVED_CROPS:
del _APPROVED_CROPS[node_id]
if node_id in _frame_cache:
del _frame_cache[node_id]
if node_id in _SAVED_CROP_PRESETS:
del _SAVED_CROP_PRESETS[node_id]
return web.Response(status=200, text="OK")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/heartbeat")
async def rs_outpaint_heartbeat(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
if node_id in _PENDING_DECISIONS:
_PENDING_DECISIONS[node_id]["last_heartbeat"] = time.time()
return web.Response(status=200, text="OK")
return web.Response(status=404, text="Not found")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/batch_toggle")
async def rs_outpaint_batch_toggle(request):
try:
data = await request.json()
node_id = str(data.get("node_id"))
batch_type = data.get("batch_type", "single")
enabled = data.get("enabled", True)
if node_id in _SAVED_CROP_PRESETS:
_SAVED_CROP_PRESETS[node_id]["active"] = enabled
_SAVED_CROP_PRESETS[node_id]["mode"] = batch_type
elif enabled and node_id in _PENDING_DECISIONS:
crop_state = _PENDING_DECISIONS[node_id].get("crop_state")
if crop_state:
_SAVED_CROP_PRESETS[node_id] = {
"crop_state": crop_state,
"active": True,
"mode": batch_type
}
return web.Response(status=200, text="OK")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/clear_preset")
async def rs_outpaint_clear_preset(request):
"""Очищает только батч-пресет. Используется в обычном режиме."""
try:
data = await request.json()
node_id = str(data.get("node_id"))
if node_id in _SAVED_CROP_PRESETS:
del _SAVED_CROP_PRESETS[node_id]
return web.Response(status=200, text="OK")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/clear_all")
async def rs_outpaint_clear_all(request):
"""Очищает батч-пресет и кэш утверждённых кропов. Используется после завершения батча."""
try:
data = await request.json()
node_id = str(data.get("node_id"))
if node_id in _SAVED_CROP_PRESETS:
del _SAVED_CROP_PRESETS[node_id]
if node_id in _APPROVED_CROPS:
del _APPROVED_CROPS[node_id]
return web.Response(status=200, text="OK")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/save_preset")
async def rs_outpaint_save_preset(request):
try:
data = await request.json()
name = data.get("name", "").strip()
if not name:
return web.Response(status=400, text="Name required")
name = "".join(c for c in name if c.isalnum() or c in " _-").strip()
if not name:
return web.Response(status=400, text="Invalid name")
os.makedirs(PRESET_DIR, exist_ok=True)
filepath = os.path.join(PRESET_DIR, f"{name}.json")
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data.get("preset_data", {}), f, indent=2)
return web.Response(status=200, text="OK")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/list_presets")
async def rs_outpaint_list_presets(request):
try:
presets = []
if os.path.exists(PRESET_DIR):
presets = [f[:-5] for f in os.listdir(PRESET_DIR) if f.endswith('.json')]
return web.json_response(presets)
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/load_preset")
async def rs_outpaint_load_preset(request):
try:
data = await request.json()
name = data.get("name")
filepath = os.path.join(PRESET_DIR, f"{name}.json")
if os.path.exists(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
return web.json_response(json.load(f))
return web.Response(status=404, text="Preset not found")
except Exception as e:
return web.Response(status=500, text=str(e))
@server.PromptServer.instance.routes.post("/rs_outpaint/delete_preset")
async def rs_outpaint_delete_preset(request):
try:
data = await request.json()
name = data.get("name")
if not name:
return web.Response(status=400, text="Name required")
filepath = os.path.join(PRESET_DIR, f"{name}.json")
if os.path.exists(filepath):
os.remove(filepath)
return web.Response(status=200, text="OK")
return web.Response(status=404, text="Preset not found")
except Exception as e:
return web.Response(status=500, text=str(e))