From dd4eb5cec8d36fe802aeb5d8fc7fa0ce751dfcfc Mon Sep 17 00:00:00 2001 From: The Kraken Date: Mon, 25 Aug 2025 14:15:10 -0700 Subject: [PATCH] Add files via upload feat: initial public release (v0.1.0) --- CHANGELOG.md | 8 ++ CONTRIBUTING.md | 14 +++ LICENSE | 21 ++++ README.md | 61 ++++++++++ __init__.py | 25 ++++ flux_latent.py | 266 +++++++++++++++++++++++++++++++++++++++++++ resolution_helper.py | 141 +++++++++++++++++++++++ upscale_tile_calc.py | 143 +++++++++++++++++++++++ web/kraken.js | 32 ++++++ 9 files changed, 711 insertions(+) create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 flux_latent.py create mode 100644 resolution_helper.py create mode 100644 upscale_tile_calc.py create mode 100644 web/kraken.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e9b9b71 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5e33e3d --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..b99c28f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c668eb --- /dev/null +++ b/README.md @@ -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//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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..6e76082 --- /dev/null +++ b/__init__.py @@ -0,0 +1,25 @@ +""" +ComfyUI-KrakenTools +MIT License Β© 2025 The Kraken (@KrakenUnbound) +https://github.com//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" diff --git a/flux_latent.py b/flux_latent.py new file mode 100644 index 0000000..d652c93 --- /dev/null +++ b/flux_latent.py @@ -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) + } \ No newline at end of file diff --git a/resolution_helper.py b/resolution_helper.py new file mode 100644 index 0000000..591a78a --- /dev/null +++ b/resolution_helper.py @@ -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)"} diff --git a/upscale_tile_calc.py b/upscale_tile_calc.py new file mode 100644 index 0000000..8eefeb8 --- /dev/null +++ b/upscale_tile_calc.py @@ -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)} diff --git a/web/kraken.js b/web/kraken.js new file mode 100644 index 0000000..931663b --- /dev/null +++ b/web/kraken.js @@ -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(); + }, +});