Add files via upload
feat: initial public release (v0.1.0)
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
## [0.1.0] - 2025-08-25
|
||||
- Initial public release.
|
||||
- `KrakenFluxEmptyLatentImage` with preset strings + preview.
|
||||
- `KrakenUpscaleTileCalc` with `min_upscale_policy` (auto/off/1.5x/2.0x/custom), tile/overlap suggestions, preview.
|
||||
- `KrakenResolutionHelper` with exact-size guarantee and corrected `allow_upscale=False` behavior.
|
||||
- Front-end `web/kraken.js` previews for latent + tile calc.
|
||||
@@ -0,0 +1,14 @@
|
||||
# Contributing
|
||||
|
||||
## Dev setup
|
||||
- Place this repo under `ComfyUI/custom_nodes/ComfyUI-KrakenTools`.
|
||||
- Restart ComfyUI after changes to Python or `web/kraken.js`.
|
||||
|
||||
## Coding guidelines
|
||||
- Python: docstrings, type hints where helpful, deterministic behavior.
|
||||
- Avoid heavy deps; rely on `numpy` and `Pillow`.
|
||||
- Prefer tiling over giant tensors to be VRAM-friendly.
|
||||
|
||||
## Pull requests
|
||||
- One focused change per PR.
|
||||
- Update `CHANGELOG.md` when appropriate.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 The Kraken
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the “Software”), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
@@ -0,0 +1,61 @@
|
||||
# 🦑 ComfyUI-KrakenTools
|
||||
|
||||
High-utility nodes for ComfyUI with a focus on **Flux 1 Dev** workflows and **Ultimate SD Upscale** enhancement loops.
|
||||
|
||||
**Why this exists:** when your “final” resolution is relatively small (e.g., 736×1344 portraits), running *no* real upscale in Ultimate SD Upscale (USDU) often doesn’t add detail. The Kraken approach enforces a true enlarge pass (e.g., 1.5×–2×), then **downscales back** to your exact target with high-quality filtering — preserving micro-detail and cleaning seams.
|
||||
|
||||
## ✨ Nodes
|
||||
|
||||
- **🦑 Kraken Flux Empty Latent Image** (`KrakenFluxEmptyLatentImage`)
|
||||
- Builds a Flux-style latent and exposes canonical **preset** + **resolution** strings for downstream nodes.
|
||||
- Outputs a small UI preview so you always see the intended canvas size.
|
||||
|
||||
- **🦑 Kraken Upscale & Tile Calc** (`KrakenUpscaleTileCalc`)
|
||||
- Computes a uniform upscale factor to *cover* your target, aligns to model-friendly multiples, and suggests tile sizes + overlaps for USDU.
|
||||
- **New:** `min_upscale_policy` (auto/off/1.5x/2.0x/custom) guarantees USDU *actually* upscales, even when target==source.
|
||||
|
||||
- **🦑 Kraken Resolution Helper (Exact)** (`KrakenResolutionHelper`)
|
||||
- Exact-size finisher: **keep proportion** (contain + pad), **pad** (with anchors), **fill/crop** (cover), or **stretch**.
|
||||
- Downscales without cropping and **guarantees final WxH**. With `allow_upscale=False`, it will never enlarge in the final step.
|
||||
|
||||
## 🛠 Installation
|
||||
|
||||
1. Locate your ComfyUI `custom_nodes` directory.
|
||||
2. Clone or copy this repo into it:
|
||||
```bash
|
||||
cd /path/to/ComfyUI/custom_nodes
|
||||
git clone https://github.com/<your-user>/ComfyUI-KrakenTools.git
|
||||
```
|
||||
3. Restart ComfyUI.
|
||||
|
||||
### Requirements
|
||||
- ComfyUI (latest)
|
||||
- Python deps: `torch`, `numpy`, `Pillow` (bundled in ComfyUI)
|
||||
- Ultimate SD Upscale node pack (for USDU) installed separately
|
||||
|
||||
## 🚀 Usage (portrait example 736×1344)
|
||||
|
||||
1. **Flux latent** → add `KrakenFluxEmptyLatentImage` and pick your preset (e.g., 736×1344).
|
||||
Outputs: `LATENT`, `resolution` (e.g., "736 x 1344"), `preset_out` (e.g., "736x1344"), width/height, preview
|
||||
|
||||
2. **Upscale planning** → add `KrakenUpscaleTileCalc`
|
||||
- `resolution_in` ← latent.`resolution`
|
||||
- `preset_in` ← latent.`preset_out`
|
||||
- `min_upscale_policy` = **auto** (2× for small canvases), or force **"2.0x"**
|
||||
|
||||
3. **USDU** → add *Ultimate SD Upscale* (regular node)
|
||||
- `upscale_by` ← tile calc.`upscale_by`
|
||||
- Tile/overlap settings ← tile calc outputs
|
||||
- Denoise ~0.2–0.35
|
||||
|
||||
4. **Final sizing** → add `KrakenResolutionHelper`
|
||||
- `image` ← USDU output
|
||||
- `preset_in` ← tile calc.`target_resolution` (or latent.`preset_out`)
|
||||
- `mode` = **keep proportion**, `interpolation` = **lanczos**, `allow_upscale` = **False**
|
||||
- Output is **exact 736 × 1344** with extra micro-detail
|
||||
|
||||
## 🧭 Contributing
|
||||
See **CONTRIBUTING.md**.
|
||||
|
||||
## 🪪 License
|
||||
MIT © 2025 **The Kraken** — X: @KrakenUnbound · YouTube: /@KrakenUnbound
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
ComfyUI-KrakenTools
|
||||
MIT License © 2025 The Kraken (@KrakenUnbound)
|
||||
https://github.com/<your-user>/ComfyUI-KrakenTools
|
||||
"""
|
||||
|
||||
from .flux_latent import KrakenFluxEmptyLatentImage
|
||||
from .resolution_helper import KrakenResolutionHelper
|
||||
from .upscale_tile_calc import KrakenUpscaleTileCalc
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"KrakenFluxEmptyLatentImage": KrakenFluxEmptyLatentImage,
|
||||
"KrakenResolutionHelper": KrakenResolutionHelper,
|
||||
"KrakenUpscaleTileCalc": KrakenUpscaleTileCalc,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"KrakenFluxEmptyLatentImage": "🦑 Kraken Flux Empty Latent Image",
|
||||
"KrakenResolutionHelper": "🦑 Kraken Resolution Helper (Exact)",
|
||||
"KrakenUpscaleTileCalc": "🦑 Kraken Upscale & Tile Calc",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
WEB_DIRECTORY = "./web"
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Kraken Flux Empty Latent Image
|
||||
MIT License © 2025 The Kraken (@KrakenUnbound)
|
||||
This node produces a Flux-style empty latent with a reliable WxH preview and
|
||||
emits both a normalized `preset_out` string ("WxH") and a human `resolution` ("W x H").
|
||||
Designed for clean hand-offs into Upscale & Tile Calc and Resolution Helper.
|
||||
"""
|
||||
|
||||
import torch
|
||||
import math
|
||||
import os
|
||||
import time
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import folder_paths
|
||||
|
||||
counter = 0 # Global counter for unique filenames
|
||||
|
||||
class KrakenFluxEmptyLatentImage:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
megapixel_options = ["0.5", "0.75", "1.0", "1.25", "1.5", "1.75", "2.0", "2.25", "2.5", "2.75", "3.0", "Custom"]
|
||||
# Combined aspect_ratio presets with use-case resolutions
|
||||
aspect_options = [
|
||||
"Custom",
|
||||
"1:1 (Perfect Square)",
|
||||
"2:1 (Panorama Landscape)",
|
||||
"2:3 (Classic Portrait)",
|
||||
"3:4 (Golden Ratio)",
|
||||
"3:5 (Elegant Vertical)",
|
||||
"4:5 (Artistic Frame)",
|
||||
"5:7 (Balanced Portrait)",
|
||||
"5:8 (Tall Portrait)",
|
||||
"7:9 (Modern Portrait)",
|
||||
"9:16 (Slim Vertical)",
|
||||
"9:19 (Tall Slim)",
|
||||
"9:21 (Ultra Tall)",
|
||||
"9:32 (Skyline)",
|
||||
"3:2 (Golden Landscape)",
|
||||
"4:3 (Classic Landscape)",
|
||||
"5:3 (Wide Horizon)",
|
||||
"5:4 (Balanced Frame)",
|
||||
"7:5 (Elegant Landscape)",
|
||||
"8:5 (Cinematic View)",
|
||||
"9:7 (Artful Horizon)",
|
||||
"16:9 (Panorama)",
|
||||
"19:9 (Cinematic Ultrawide)",
|
||||
"21:9 (Epic Ultrawide)",
|
||||
"32:9 (Extreme Ultrawide)",
|
||||
"Screen: iPhone Wallpaper - 1170x2532 (9:16)",
|
||||
"Screen: iPhone Pro Wallpaper - 1290x2796 (9:19.5)",
|
||||
"Screen: Android HD Wallpaper - 1080x1920 (9:16)",
|
||||
"Screen: Android QHD Wallpaper - 1440x3200 (9:20)",
|
||||
"Screen: Desktop HD Wallpaper - 1920x1080 (16:9)",
|
||||
"Screen: Desktop QHD Wallpaper - 2560x1440 (16:9)",
|
||||
"Screen: Dual Monitor Wallpaper - 5120x1440 (32:9)",
|
||||
"Screen: 4K Monitor - 3840x2160 (16:9)",
|
||||
"Screen: Ultrawide QHD - 3440x1440 (21:9)",
|
||||
"Screen: Ultrawide 4K - 3840x1600 (12:5)",
|
||||
"Video: KlingAI Landscape - 1920x1080 (16:9)",
|
||||
"Video: KlingAI Portrait - 1080x1920 (9:16)",
|
||||
"Video: KlingAI Square - 1024x1024 (1:1)",
|
||||
"Video: KlingAI Wide - 1344x768 (3:2)",
|
||||
"Video: KlingAI Tall - 768x1344 (2:3)",
|
||||
"Social: Instagram Post Square - 1080x1080 (1:1)",
|
||||
"Social: Instagram Post Portrait - 1080x1350 (4:5)",
|
||||
"Social: Instagram Story - 1080x1920 (9:16)",
|
||||
"Social: YouTube Thumbnail - 1280x720 (16:9)",
|
||||
"Social: Facebook Post - 1200x1200 (1:1)",
|
||||
"Social: Facebook Banner - 1200x628 (1.91:1)",
|
||||
"Social: Twitter/X Post - 1200x675 (16:9)",
|
||||
"Social: LinkedIn Post - 1200x1200 (1:1)",
|
||||
"Advertising: Leaderboard Banner - 728x90 (8:1)",
|
||||
"Advertising: Medium Rectangle Banner - 300x250 (1.2:1)",
|
||||
"Advertising: Large Rectangle Banner - 336x280 (1.2:1)",
|
||||
"Advertising: Full Banner - 468x60 (7.8:1)",
|
||||
"Advertising: Wide Skyscraper Banner - 160x600 (1:3.75)",
|
||||
"Advertising: Half Page Banner - 300x600 (1:2)",
|
||||
"Advertising: Large Leaderboard Banner - 970x90 (10.8:1)",
|
||||
"Advertising: Square Banner - 250x250 (1:1)",
|
||||
"Print: Movie Poster - 8100x12000 (27x40in at 300 DPI)",
|
||||
"Print: Flyer Letter - 2550x3300 (8.5x11in at 300 DPI)",
|
||||
"Print: Flyer Legal - 2550x4200 (8.5x14in at 300 DPI)",
|
||||
"Print: Business Card - 1050x600 (3.5x2in at 300 DPI)",
|
||||
"Print: Poster A3 - 3508x4961 (11.7x16.5in at 300 DPI)",
|
||||
"Print: Poster A2 - 4961x7016 (16.5x23.4in at 300 DPI)",
|
||||
"Print: Poster A1 - 7016x9933 (23.4x33.1in at 300 DPI)",
|
||||
"Print: Poster A0 - 9933x14043 (33.1x46.8in at 300 DPI)"
|
||||
]
|
||||
divisible_options = ["Auto", 8, 16, 32, 64, 128, 256]
|
||||
return {
|
||||
"required": {
|
||||
"megapixel": (megapixel_options, {"default": "1.0"}),
|
||||
"preset": (aspect_options, {"default": "Custom"}),
|
||||
"use_custom_aspect": ("BOOLEAN", {"default": False}),
|
||||
"custom_aspect": ("STRING", {"default": "1:1"}),
|
||||
"width": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
|
||||
"height": ("INT", {"default": 1024, "min": 64, "max": 4096, "step": 8}),
|
||||
"divisible_by": (divisible_options, {"default": "Auto"}),
|
||||
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("LATENT", "INT", "INT", "STRING", "STRING")
|
||||
RETURN_NAMES = ("latent", "width_out", "height_out", "resolution", "preset_out")
|
||||
FUNCTION = "generate"
|
||||
CATEGORY = "kraken/latent"
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
def create_preview_image(self, width, height, goal_str, ratio_display):
|
||||
# Preview rendered at EXACT latent size so ComfyUI shows correct WxH at the bottom.
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def load_font(sz):
|
||||
for name in ("DejaVuSans.ttf", "Arial.ttf", "arial.ttf"):
|
||||
try:
|
||||
return ImageFont.truetype(name, sz)
|
||||
except Exception:
|
||||
continue
|
||||
return ImageFont.load_default()
|
||||
|
||||
def text_wh(draw, text, font):
|
||||
try:
|
||||
l, t, r, b = draw.textbbox((0, 0), text, font=font)
|
||||
return (r - l, b - t)
|
||||
except Exception:
|
||||
try:
|
||||
return font.getsize(text)
|
||||
except Exception:
|
||||
sz = getattr(font, "size", 18)
|
||||
return (int(len(text) * sz * 0.6), int(sz * 1.2))
|
||||
|
||||
w, h = int(width), int(height)
|
||||
image = Image.new('RGB', (w, h), (24, 24, 24))
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# Border
|
||||
draw.rectangle([0, 0, w - 1, h - 1], outline=(160, 160, 160), width=2)
|
||||
|
||||
# Font sizes relative to canvas
|
||||
base = max(20, int(min(w, h) * 0.14)) # big center size text
|
||||
sub = max(14, int(base * 0.60)) # info text
|
||||
font_main = load_font(base)
|
||||
font_sub = load_font(sub)
|
||||
|
||||
# Big centered latent size
|
||||
center_text = f"{w}x{h}"
|
||||
tw, th = text_wh(draw, center_text, font_main)
|
||||
cx, cy = w // 2, h // 2
|
||||
# outline for readability
|
||||
for off in ((-2,-2),(2,2),(-2,2),(2,-2)):
|
||||
draw.text((cx - tw//2 + off[0], cy - th//2 + off[1]), center_text, fill=(0,0,0), font=font_main)
|
||||
draw.text((cx - tw//2, cy - th//2), center_text, fill=(245,245,245), font=font_main)
|
||||
|
||||
# Top-left info block: show target if present; otherwise show ratio
|
||||
lines = []
|
||||
if goal_str:
|
||||
lines.append(f"target {goal_str}") # e.g., 1080x1920
|
||||
# Show ratio if it looks like A:B or matches known form
|
||||
if isinstance(ratio_display, str) and (':' in ratio_display or 'x' in ratio_display):
|
||||
lines.append(f"ratio {ratio_display}")
|
||||
|
||||
y = 10 # add more padding so it doesn't collide with border
|
||||
for line in lines:
|
||||
lw, lh = text_wh(draw, line, font_sub)
|
||||
pad = 5
|
||||
# dark backdrop
|
||||
draw.rectangle([10 - pad, y - pad, 10 + lw + pad, y + lh + pad], fill=(0,0,0))
|
||||
# draw text
|
||||
draw.text((10, y), line, fill=(220,220,220), font=font_sub)
|
||||
y += lh + 8
|
||||
|
||||
return image
|
||||
|
||||
|
||||
def generate(self, megapixel, preset, use_custom_aspect, custom_aspect, width, height, divisible_by, batch_size):
|
||||
ar = 1.0
|
||||
ratio_display = f"{width}x{height}"
|
||||
if use_custom_aspect:
|
||||
ratio_display = custom_aspect
|
||||
if ':' in custom_aspect:
|
||||
w_part, h_part = map(float, custom_aspect.split(":"))
|
||||
ar = w_part / h_part
|
||||
else:
|
||||
ar = float(custom_aspect)
|
||||
elif preset != "Custom":
|
||||
if " - " in preset:
|
||||
# Specific resolution preset
|
||||
size_str = preset.split(" - ")[1].split(" (")[0]
|
||||
width, height = map(int, size_str.split("x"))
|
||||
ar = width / height
|
||||
ratio_display = size_str # Use just WIDTHxHEIGHT for display
|
||||
else:
|
||||
# Ratio-based preset
|
||||
ratio_str = preset.split(" (")[0]
|
||||
w_part, h_part = map(float, ratio_str.split(":"))
|
||||
ar = w_part / h_part
|
||||
ratio_display = f"{w_part}:{h_part}"
|
||||
else:
|
||||
ar = width / height
|
||||
ratio_display = f"{width}x{height}"
|
||||
|
||||
if megapixel == "Custom":
|
||||
mp = (width * height) / 1_000_000
|
||||
use_calculated = False
|
||||
else:
|
||||
mp = float(megapixel)
|
||||
use_calculated = True
|
||||
|
||||
if use_calculated:
|
||||
base_width = math.sqrt(mp * 1_000_000 * ar)
|
||||
base_height = math.sqrt(mp * 1_000_000 / ar)
|
||||
else:
|
||||
base_width = width
|
||||
base_height = height
|
||||
|
||||
if divisible_by == "Auto":
|
||||
if mp > 1.5:
|
||||
divisible_by = 64
|
||||
elif mp > 0.75:
|
||||
divisible_by = 32
|
||||
else:
|
||||
divisible_by = 16
|
||||
else:
|
||||
divisible_by = int(divisible_by)
|
||||
|
||||
width = round(base_width / divisible_by) * divisible_by
|
||||
height = round(base_height / divisible_by) * divisible_by
|
||||
|
||||
if width % 8 != 0 or height % 8 != 0:
|
||||
width = (width // 8) * 8
|
||||
height = (height // 8) * 8
|
||||
|
||||
latent = torch.zeros([batch_size, 16, height // 8, width // 8])
|
||||
|
||||
resolution = f"{width} x {height}"
|
||||
# Determine preset_out: prefer explicit target size from preset label if present
|
||||
target_preset_str = None
|
||||
try:
|
||||
if preset != "Custom" and " - " in preset:
|
||||
target_preset_str = preset.split(" - ")[1].split(" (")[0]
|
||||
except Exception:
|
||||
target_preset_str = None
|
||||
preset_out = target_preset_str if target_preset_str else f"{width}x{height}" # WIDTHxHEIGHT string for downstream
|
||||
# Determine preset_out: prefer explicit target size from preset label if present
|
||||
|
||||
preview_img = self.create_preview_image(width, height, target_preset_str or '', ratio_display)
|
||||
|
||||
global counter
|
||||
counter += 1
|
||||
full_output_folder = folder_paths.get_temp_directory()
|
||||
current_time = time.time()
|
||||
file = f"kraken_preview_{current_time}_{counter:05}.png"
|
||||
file_path = os.path.join(full_output_folder, file)
|
||||
preview_img.save(file_path, compress_level=4)
|
||||
|
||||
images = [{"filename": file, "subfolder": "", "type": "temp"}]
|
||||
|
||||
return {
|
||||
"ui": {"images": images},
|
||||
"result": ({"samples": latent}, width, height, resolution, preset_out)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
"""
|
||||
Kraken Resolution Helper (Exact)
|
||||
MIT License © 2025 The Kraken (@KrakenUnbound)
|
||||
"""
|
||||
|
||||
import math, re
|
||||
from typing import Optional, Tuple
|
||||
import numpy as np, torch
|
||||
from PIL import Image as PILImage
|
||||
|
||||
def _parse_wxH(s: str) -> Optional[Tuple[int, int]]:
|
||||
if not s: return None
|
||||
s = str(s).strip().lower()
|
||||
m = re.match(r"^\s*(\d+)\s*[xX]\s*(\d+)\s*$", s)
|
||||
if not m: return None
|
||||
w, h = int(m.group(1)), int(m.group(2))
|
||||
if w <= 0 or h <= 0: return None
|
||||
return w, h
|
||||
|
||||
def _tensor_to_pil(img) -> PILImage.Image:
|
||||
if isinstance(img, torch.Tensor):
|
||||
t = img[0] if img.ndim == 4 else img
|
||||
t = t.clamp(0.0,1.0).detach().cpu()
|
||||
if t.shape[-1] == 1: t = t.repeat(1,1,3)
|
||||
if t.shape[-1] == 4: t = t[..., :3]
|
||||
arr = (t.numpy()*255.0+0.5).astype(np.uint8)
|
||||
return PILImage.fromarray(arr, mode="RGB")
|
||||
if isinstance(img, np.ndarray):
|
||||
a = img[0] if img.ndim == 4 else img
|
||||
a = np.clip(a,0.0,1.0).astype(np.float32)
|
||||
if a.ndim == 2: a = np.stack([a,a,a], axis=-1)
|
||||
if a.shape[-1] == 1: a = np.repeat(a,3,axis=-1)
|
||||
if a.shape[-1] == 4: a = a[..., :3]
|
||||
arr = (a*255.0+0.5).astype(np.uint8)
|
||||
return PILImage.fromarray(arr, mode="RGB")
|
||||
raise TypeError("Unsupported image type for _tensor_to_pil")
|
||||
|
||||
def _pil_to_tensor(img: PILImage.Image) -> torch.Tensor:
|
||||
arr = np.array(img).astype(np.float32)/255.0
|
||||
if arr.ndim == 2: arr = np.stack([arr,arr,arr], axis=-1)
|
||||
if arr.shape[-1] == 4: arr = arr[..., :3]
|
||||
return torch.from_numpy(arr).unsqueeze(0)
|
||||
|
||||
def _cover_scale(wi,hi,wt,ht): return max(wt/wi, ht/hi)
|
||||
def _contain_scale(wi,hi,wt,ht): return min(wt/wi, ht/hi)
|
||||
def _round_up(x,step): import math; return math.ceil(x/step)*step
|
||||
def _ceil_to_multiple(x,m): import math; return x if m<=1 else int(math.ceil(x/m)*m)
|
||||
|
||||
class KrakenResolutionHelper:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"mode": (["fill / crop","pad","keep proportion","stretch"], {"default": "keep proportion"}),
|
||||
"interpolation": (["lanczos","bicubic","bilinear","nearest"], {"default": "lanczos"}),
|
||||
"allow_upscale": ("BOOLEAN", {"default": False}),
|
||||
"preset_in": ("STRING", {"default": ""}),
|
||||
},
|
||||
"optional": {
|
||||
"target_resolution_in": ("STRING", {"default": ""}),
|
||||
"width": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"height": ("INT", {"default": 1024, "min": 1, "max": 8192, "step": 1}),
|
||||
"anchor_x": (["left","center","right"], {"default": "center"}),
|
||||
"anchor_y": (["top","center","bottom"], {"default": "center"}),
|
||||
"crop_margin_px": ("INT", {"default": 32, "min": 0, "max": 512, "step": 1}),
|
||||
"scale_step": ("FLOAT", {"default": 0.01, "min": 0.001, "max": 0.25, "step": 0.001}),
|
||||
"multiple_of": ("INT", {"default": 0, "min": 0, "max": 128, "step": 1}),
|
||||
},
|
||||
}
|
||||
RETURN_TYPES = ("IMAGE","STRING")
|
||||
RETURN_NAMES = ("image","size")
|
||||
FUNCTION = "apply"
|
||||
CATEGORY = "kraken/resize"
|
||||
|
||||
def _goal(self, preset_in, target_resolution_in, width, height):
|
||||
r = _parse_wxH(target_resolution_in) or _parse_wxH(preset_in)
|
||||
return r if r else (max(1,int(width)), max(1,int(height)))
|
||||
|
||||
def _anchor_offsets(self,big_w,big_h,small_w,small_h,ax,ay):
|
||||
off_x = 0 if ax=="left" else (big_w-small_w if ax=="right" else (big_w-small_w)//2)
|
||||
off_y = 0 if ay=="top" else (big_h-small_h if ay=="bottom" else (big_h-small_h)//2)
|
||||
return max(0,off_x), max(0,off_y)
|
||||
|
||||
def apply(self,image,mode="keep proportion",interpolation="lanczos",allow_upscale=False,
|
||||
preset_in="",target_resolution_in="",width=1024,height=1024,
|
||||
anchor_x="center",anchor_y="center",crop_margin_px=32,scale_step=0.01,multiple_of=0):
|
||||
|
||||
Wt,Ht = self._goal(preset_in, target_resolution_in, width, height)
|
||||
if isinstance(image, torch.Tensor) and image.ndim==4: _,Hi,Wi,_ = image.shape
|
||||
else: Hi,Wi = image.shape[0], image.shape[1]
|
||||
|
||||
pil = _tensor_to_pil(image)
|
||||
interp = {"lanczos":PILImage.LANCZOS,"bicubic":PILImage.BICUBIC,"bilinear":PILImage.BILINEAR,"nearest":PILImage.NEAREST}.get(interpolation,PILImage.LANCZOS)
|
||||
|
||||
if mode=="stretch":
|
||||
result = pil.resize((Wt,Ht), interp)
|
||||
elif mode=="keep proportion":
|
||||
s = _contain_scale(Wi,Hi,Wt,Ht)
|
||||
if s>1.0 and not allow_upscale: s=1.0
|
||||
new_w,new_h = max(1,int(Wi*s)), max(1,int(Hi*s))
|
||||
if multiple_of>1:
|
||||
new_w=_ceil_to_multiple(new_w,multiple_of); new_h=_ceil_to_multiple(new_h,multiple_of)
|
||||
if not allow_upscale:
|
||||
new_w=min(new_w,Wt); new_h=min(new_h,Ht)
|
||||
scaled = pil.resize((new_w,new_h), interp)
|
||||
canvas = PILImage.new("RGB", (Wt,Ht), (0,0,0))
|
||||
off_x,off_y = self._anchor_offsets(Wt,Ht,new_w,new_h,"center","center")
|
||||
canvas.paste(scaled,(off_x,off_y))
|
||||
result = canvas
|
||||
elif mode=="pad":
|
||||
s = _contain_scale(Wi,Hi,Wt,Ht)
|
||||
if s>1.0 and not allow_upscale: s=1.0
|
||||
new_w,new_h = max(1,int(Wi*s)), max(1,int(Hi*s))
|
||||
if multiple_of>1:
|
||||
new_w=_ceil_to_multiple(new_w,multiple_of); new_h=_ceil_to_multiple(new_h,multiple_of)
|
||||
if not allow_upscale:
|
||||
new_w=min(new_w,Wt); new_h=min(new_h,Ht)
|
||||
scaled = pil.resize((new_w,new_h), interp)
|
||||
canvas = PILImage.new("RGB", (Wt,Ht), (0,0,0))
|
||||
off_x,off_y = self._anchor_offsets(Wt,Ht,new_w,new_h,anchor_x,anchor_y)
|
||||
canvas.paste(scaled,(off_x,off_y))
|
||||
result = canvas
|
||||
else: # fill / crop
|
||||
margin=max(0,int(crop_margin_px))
|
||||
s_req=_cover_scale(Wi,Hi,Wt+2*margin,Ht+2*margin)
|
||||
s=_round_up(s_req,max(0.001,float(scale_step)))
|
||||
if s>1.0 and not allow_upscale: s=1.0
|
||||
new_w,max_w = max(Wt,int(Wi*s)), Wt
|
||||
new_h,max_h = max(Ht,int(Hi*s)), Ht
|
||||
if multiple_of>1:
|
||||
new_w=_ceil_to_multiple(new_w,multiple_of); new_h=_ceil_to_multiple(new_h,multiple_of)
|
||||
scaled = pil.resize((new_w,new_h), interp)
|
||||
left,top = self._anchor_offsets(new_w,new_h,Wt,Ht,anchor_x,anchor_y)
|
||||
result = scaled.crop((left,top,left+Wt,top+Ht))
|
||||
|
||||
out = _pil_to_tensor(result)
|
||||
return out, f"{result.width} x {result.height}"
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"KrakenResolutionHelper": KrakenResolutionHelper}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"KrakenResolutionHelper": "🦑 Kraken Resolution Helper (Exact)"}
|
||||
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Kraken Upscale & Tile Calc
|
||||
MIT License © 2025 The Kraken (@KrakenUnbound)
|
||||
"""
|
||||
import math, os, re, time, logging
|
||||
import numpy as np, torch
|
||||
from PIL import Image, ImageDraw
|
||||
import folder_paths
|
||||
|
||||
counter = 0
|
||||
|
||||
def _round_to(x, base): return int(round(x / base) * base)
|
||||
def _ceil_to(x, base): return int(math.ceil(x / base) * base)
|
||||
def _clamp(v, lo, hi): return max(lo, min(hi, v))
|
||||
def _blank_image_tensor(w=8, h=8): return torch.zeros((1, h, w, 3), dtype=torch.float32)
|
||||
|
||||
class KrakenUpscaleTileCalc:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"resolution_in": ("STRING", {"default": "1024 x 1024"})},
|
||||
"optional": {
|
||||
"preset_in": ("STRING", {"default": "Custom"}),
|
||||
"image": ("IMAGE",),
|
||||
"scale_step": ("FLOAT", {"default": 0.01, "min": 0.005, "max": 0.25, "step": 0.005}),
|
||||
"crop_margin_px": ("INT", {"default": 32, "min": 0, "max": 128, "step": 1}),
|
||||
"mask_blur": ("INT", {"default": 16, "min": 0, "max": 64, "step": 1}),
|
||||
"tile_padding": ("INT", {"default": 16, "min": 0, "max": 256, "step": 1}),
|
||||
"seam_fix_width": ("INT", {"default": 64, "min": 0, "max": 256, "step": 1}),
|
||||
"seam_fix_mask_blur": ("INT", {"default": 16, "min": 0, "max": 64, "step": 1}),
|
||||
"seam_fix_padding": ("INT", {"default": 32, "min": 0, "max": 256, "step": 1}),
|
||||
"min_upscale_policy": (["auto","off","1.5x","2.0x","custom"], {"default": "auto"}),
|
||||
"min_upscale_custom": ("FLOAT", {"default": 1.5, "min": 1.0, "max": 4.0, "step": 0.1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE","FLOAT","INT","INT","INT","INT","INT","INT","INT","STRING","STRING","STRING")
|
||||
RETURN_NAMES = ("image_out","upscale_by","tile_width","tile_height","mask_blur","tile_padding","seam_fix_width","seam_fix_mask_blur","seam_fix_padding","predicted_resolution","target_resolution","target_preset")
|
||||
FUNCTION = "calculate"
|
||||
CATEGORY = "kraken/upscale"
|
||||
|
||||
def _parse_resolution_ws(self, s):
|
||||
s = (s or "").lower().replace(" ", "")
|
||||
if "x" in s:
|
||||
try:
|
||||
w, h = s.split("x"); return (int(w), int(h))
|
||||
except Exception: return None
|
||||
return None
|
||||
|
||||
def _parse_resolution_from_label(self, label):
|
||||
s = str(label or ""); m = re.search(r"(\d+)\s*[xX]\s*(\d+)", s)
|
||||
return (int(m.group(1)), int(m.group(2))) if m else None
|
||||
|
||||
def _image_size(self, image, resolution_in):
|
||||
if image is not None:
|
||||
if isinstance(image, torch.Tensor):
|
||||
if image.ndim == 4: _, h, w, _ = image.shape
|
||||
else: h, w = image.shape[0], image.shape[1]
|
||||
return (w, h)
|
||||
if isinstance(image, np.ndarray):
|
||||
if image.ndim == 4: _, h, w, _ = image.shape
|
||||
else: h, w = image.shape[0], image.shape[1]
|
||||
return (w, h)
|
||||
return self._parse_resolution_ws(resolution_in) or (1024,1024)
|
||||
|
||||
def _choose_min_upscale(self, wi, hi, Wt, Ht, policy, custom):
|
||||
policy = (policy or "auto").lower()
|
||||
if policy == "off": return 1.0
|
||||
if policy == "1.5x": return 1.5
|
||||
if policy == "2.0x": return 2.0
|
||||
if policy == "custom":
|
||||
try: return max(1.0, min(4.0, float(custom)))
|
||||
except Exception: return 1.5
|
||||
src_mp = (wi*hi)/1_000_000.0; tgt_mp = (Wt*Ht)/1_000_000.0
|
||||
if max(Wt,Ht) <= 1024 or tgt_mp <= 1.0: return 2.0
|
||||
if src_mp <= 2.0: return 2.0
|
||||
return 1.5
|
||||
|
||||
def _make_preview(self, pred_w, pred_h, Wt, Ht):
|
||||
w, h = max(pred_w, Wt), max(pred_h, Ht)
|
||||
scale = max(1, min(512 / w, 512 / h))
|
||||
canvas = Image.new("RGB", (int(w*scale), int(h*scale)), (24,24,24))
|
||||
draw = ImageDraw.Draw(canvas)
|
||||
draw.rectangle([0,0,canvas.size[0]-1, canvas.size[1]-1], outline=(160,160,160), width=2)
|
||||
tw, th = int(Wt*scale), int(Ht*scale)
|
||||
x0, y0 = (canvas.size[0]-tw)//2, (canvas.size[1]-th)//2
|
||||
draw.rectangle([x0,y0,x0+tw-1,y0+th-1], outline=(0,200,120), width=2)
|
||||
draw.text((6,6), f"pred {pred_w}x{pred_h}", fill=(220,220,220))
|
||||
draw.text((6,24), f"target {Wt}x{Ht}", fill=(0,200,120))
|
||||
return canvas
|
||||
|
||||
def calculate(self, resolution_in, preset_in="Custom", image=None, scale_step=0.01, crop_margin_px=32,
|
||||
mask_blur=16, tile_padding=16, seam_fix_width=64, seam_fix_mask_blur=16, seam_fix_padding=32,
|
||||
min_upscale_policy="auto", min_upscale_custom=1.5):
|
||||
|
||||
wi, hi = self._image_size(image, resolution_in)
|
||||
tgt = self._parse_resolution_ws(preset_in) or self._parse_resolution_from_label(preset_in) or self._parse_resolution_ws(resolution_in) or (wi, hi)
|
||||
Wt, Ht = tgt
|
||||
|
||||
crop_margin_px = int(crop_margin_px); crop_margin_px = _clamp(crop_margin_px, 0, max(Wt,Ht)//10)
|
||||
Wm, Hm = Wt + 2*crop_margin_px, Ht + 2*crop_margin_px
|
||||
|
||||
required = max(Wm/wi, Hm/hi)
|
||||
s = max(1.0, round((required/scale_step)) * scale_step)
|
||||
|
||||
min_s = self._choose_min_upscale(wi, hi, Wt, Ht, min_upscale_policy, min_upscale_custom)
|
||||
if min_s > 1.0:
|
||||
s = max(s, round((min_s/scale_step)) * scale_step)
|
||||
s = round(float(s), 3)
|
||||
|
||||
pred_align = 8
|
||||
pred_w = _ceil_to(wi * s, pred_align)
|
||||
pred_h = _ceil_to(hi * s, pred_align)
|
||||
|
||||
bump_guard = 0
|
||||
while (pred_w < Wt or pred_h < Ht) and bump_guard < 4:
|
||||
s = round(s + scale_step, 3)
|
||||
pred_w = _ceil_to(wi * s, pred_align)
|
||||
pred_h = _ceil_to(hi * s, pred_align)
|
||||
bump_guard += 1
|
||||
|
||||
overlap = _clamp(int(round(min(pred_w, pred_h) * 0.015)), 32, 96)
|
||||
tile_w = _round_to(_clamp(pred_w // 2 + overlap, 512, 2048), 64)
|
||||
tile_h = _round_to(_clamp(pred_h // 2 + overlap, 512, 2048), 64)
|
||||
|
||||
predicted_resolution = f"{pred_w} x {pred_h}"
|
||||
target_resolution = f"{Wt} x {Ht}"
|
||||
target_preset = f"{Wt}x{Ht}"
|
||||
|
||||
global counter; counter += 1
|
||||
from folder_paths import get_temp_directory
|
||||
file = f"kraken_tilecalc_{time.time()}_{counter:05}.png"
|
||||
try:
|
||||
preview_img = self._make_preview(pred_w, pred_h, Wt, Ht)
|
||||
preview_img.save(os.path.join(get_temp_directory(), file), compress_level=4)
|
||||
images = [{"filename": file, "subfolder": "", "type": "temp"}]
|
||||
except Exception as e:
|
||||
logging.warning(f"Preview generation failed: {e}"); images = []
|
||||
|
||||
image_out = image if image is not None else _blank_image_tensor()
|
||||
return {"ui": {"images": images},
|
||||
"result": (image_out, s, tile_w, tile_h, mask_blur, tile_padding, seam_fix_width, seam_fix_mask_blur,
|
||||
seam_fix_padding, predicted_resolution, target_resolution, target_preset)}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { app } from "../../../scripts/app.js";
|
||||
|
||||
app.registerExtension({
|
||||
name: "kraken.tools",
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
|
||||
const attachPreview = () => {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined;
|
||||
const imgElement = document.createElement("img");
|
||||
imgElement.style = "width: 100%; height: auto; display: block; margin: 5px 0;";
|
||||
this.previewWidget = this.addDOMWidget("preview", "custom", imgElement, { serialize: false });
|
||||
return r;
|
||||
};
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted;
|
||||
nodeType.prototype.onExecuted = function (message) {
|
||||
if (onExecuted) onExecuted.apply(this, [message]);
|
||||
if (message && message.images && message.images.length > 0 && this.previewWidget) {
|
||||
const img = this.previewWidget.value;
|
||||
const { filename, subfolder, type } = message.images[0];
|
||||
const url = `/view?filename=${encodeURIComponent(filename)}&type=${encodeURIComponent(type || "temp")}&subfolder=${encodeURIComponent(subfolder || "")}&rand=${Math.random()}`;
|
||||
img.src = url;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
if (nodeData.name === "KrakenFluxEmptyLatentImage") attachPreview();
|
||||
if (nodeData.name === "KrakenUpscaleTileCalc") attachPreview();
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user