diff --git a/angelo_nodes.py b/angelo_nodes.py index 33457b0..ab48ec3 100644 --- a/angelo_nodes.py +++ b/angelo_nodes.py @@ -842,6 +842,20 @@ _QUICK_REFINE_PROMPT_QWEN = ("lightly restore this old photo, remove dust and sc _QUICK_REFINE_DENOISE = 1.0 _QUICK_REFINE_REF = 1.0 +# Large Image Refine (the ▦ button): the canvas is divided into ~_LIR_BOX_MP +# boxes that EXACT-TILE it (no overlap), each refined through the Xtra-Fine +# pipeline (bit-exact latent compositing — no pixel feathering anywhere) in +# CHESS-PATTERN order: one colour first, then the other, so second-pass +# boxes refine with already-refined neighbours visible in their context +# pad and match them. Hard box masks (feather 0); seam defences are the +# shared source at this denoise, the ctx pad's cross-border visibility, +# and the chess ordering. +_LIR_PROMPT = "restore the image. make it clear and sharp." +_LIR_REF = 0.4 +_LIR_DENOISE = 0.55 +_LIR_CTX_PAD = 176 +_LIR_BOX_MP = 1.0 # target box area — the tweakable knob + # Tiled restore engine (2× Restore Upscale + big-canvas Quick Refine). # Working tile size + overlap in PIXELS: tiles are sampled at ~1MP no # matter how large the canvas is, so the latent fed to the model never @@ -1731,7 +1745,10 @@ class AngeloRefine: # user's next move (e.g. ✨, which auto-tiles when large). "upscale_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}), - + # ▦ Large Image Refine: the button bumps this. Chess-pattern + # Xtra-Fine boxes over the whole canvas (see _LIR_* constants). + # One history entry for the whole pass. Declared LAST. + "lir_seq": ("INT", {"default": 0, "min": 0, "max": 0x7FFFFFFF}), }, "optional": { # CLIP / text encoder for the Area Prompt. Optional — @@ -1840,6 +1857,7 @@ class AngeloRefine: quick_refine_seq=0, reference_strength=0.0, upscale_seq=0, + lir_seq=0, latent=None, clip=None, overrides=None, @@ -2031,6 +2049,7 @@ class AngeloRefine: "outpaint_accept_seq": outpaint_accept_seq, "quick_refine_seq": quick_refine_seq, "upscale_seq": upscale_seq, + "lir_seq": lir_seq, "fingerprint": incoming_fp, "sampler_seed_at_run": int(sampler_seed), "loaded_seq": loaded_seq, @@ -2109,6 +2128,7 @@ class AngeloRefine: "outpaint_accept_seq": outpaint_accept_seq, "quick_refine_seq": quick_refine_seq, "upscale_seq": upscale_seq, + "lir_seq": lir_seq, "fingerprint": incoming_fp, "loaded_seq": loaded_seq, # Source image (#3/#9): capture the base once, independent of @@ -2205,6 +2225,7 @@ class AngeloRefine: "outpaint_accept_seq": outpaint_accept_seq, "quick_refine_seq": quick_refine_seq, "upscale_seq": upscale_seq, + "lir_seq": lir_seq, # Preserve the wired-latent fingerprint + load marker so # the next run doesn't read the unchanged upstream as a # fresh latent and blow away the canvas we just committed. @@ -2462,6 +2483,7 @@ class AngeloRefine: "outpaint_accept_seq": outpaint_accept_seq, "quick_refine_seq": quick_refine_seq, "upscale_seq": upscale_seq, + "lir_seq": lir_seq, "fingerprint": state.get("fingerprint"), "loaded_seq": state.get("loaded_seq"), "sampler_seed_at_run": state.get("sampler_seed_at_run", int(sampler_seed)), @@ -2474,6 +2496,83 @@ class AngeloRefine: state = _STATE[node_id] current, current_pixels = up_latent, up_pixels + # ===== ▦ Large Image Refine: chess-pattern Xtra-Fine boxes ===== + # The whole canvas refined box by box through the Xtra-Fine + # pipeline — bit-exact latent compositing per box, no pixel + # feathering. Chess order so the second colour's boxes see + # already-refined neighbours in their context pad. One history + # entry for the whole pass (one Undo reverts it all). + new_lir = ( + inpainting_mode == "Refine" + and lir_seq > 0 + and lir_seq != state.get("lir_seq", -1) + ) + state["lir_seq"] = lir_seq + if new_lir: + if clip is not None: + tokens_l = clip.tokenize(_LIR_PROMPT) + lir_base = clip.encode_from_tokens_scheduled(tokens_l) + else: + lir_base = positive + lir_seed = int(seed) + callback = None if disable_live_preview else latent_preview.prepare_callback(model, steps) + disable_pbar = not comfy.utils.PROGRESS_BAR_ENABLED + lir_px = current_pixels if current_pixels is not None else _vae_decode(vae, current) + H_pix, W_pix = lir_px.shape[1], lir_px.shape[2] + nlh, nlw = current.shape[-2], current.shape[-1] + sx = nlw / W_pix + sy = nlh / H_pix + box_edge = math.sqrt(_LIR_BOX_MP * 1e6) + nx = max(1, round(W_pix / box_edge)) + ny = max(1, round(H_pix / box_edge)) + x_edges = [round(i * nlw / nx) for i in range(nx + 1)] + y_edges = [round(j * nlh / ny) for j in range(ny + 1)] + boxes = [(i, j, x_edges[i], y_edges[j], x_edges[i + 1], y_edges[j + 1]) + for j in range(ny) for i in range(nx)] + # Chess order: one colour, then the other. + order = ([b for b in boxes if (b[0] + b[1]) % 2 == 0] + + [b for b in boxes if (b[0] + b[1]) % 2 == 1]) + print(f"[Angelo large-refine] {W_pix}x{H_pix}: {nx}x{ny} boxes " + f"(~{_LIR_BOX_MP}MP target), chess order, ref={_LIR_REF}, " + f"denoise={_LIR_DENOISE}, pad={_LIR_CTX_PAD}px, feather 0") + work_lat, work_px = current, lir_px + for k, (bi, bj, x0, y0, x1, y1) in enumerate(order): + box_mask = torch.zeros((1, nlh, nlw), device=work_lat.device, dtype=torch.float32) + box_mask[0, y0:y1, x0:x1] = 1.0 # hard box — feather 0 + bw_px = (x1 - x0) / sx + bh_px = (y1 - y0) / sy + crop_mp = max(0.05, ((bw_px + 2 * _LIR_CTX_PAD) * (bh_px + 2 * _LIR_CTX_PAD)) / 1e6) + work_lat, fresh_px = _refine_with_fine_upscaling( + model=model, vae=vae, current=work_lat, + current_pixels=work_px, mask=box_mask, + scale_x=sx, scale_y=sy, + target_mp=crop_mp * 1.02, # force the crop path — never + # the whole-canvas shortcut + max_linear=8.0, resize_method="lanczos", + context_pad_pixel=_LIR_CTX_PAD, + inpainting_mode="Refine", + reference_strength=_LIR_REF, + seed=(lir_seed + k * 9973) & 0xFFFFFFFFFFFFFFFF, + steps=steps, cfg=cfg, sampler_name=sampler_name, scheduler=scheduler, + positive=lir_base, negative=negative, + denoise=_LIR_DENOISE, + callback=callback, disable_pbar=disable_pbar, + ov_guider=ov_guider, ov_sampler=ov_sampler, ov_sigmas=ov_sigmas, + ) + if fresh_px is None: + work_px = _vae_decode(vae, work_lat) + else: + work_px = fresh_px + state["history"].append((work_lat, work_px)) + if len(state["history"]) > _HISTORY_CAP: + state["history"] = state["history"][-_HISTORY_CAP:] + state["redo_stack"] = [] + state["vary_candidates"] = None + state["outpaint_pending"] = None + state["click_seq"] = click_seq + state["refine_seed_at_run"] = lir_seed + current, current_pixels = work_lat, work_px + # Has the user clicked since our last execution for this node? # Never treat a queue-hook click_seq bump as an edit in Outpaint # mode — the canvas there is a direction picker, not a mask tool. diff --git a/web/angelo.js b/web/angelo.js index 5ec1cdf..c9ff647 100644 --- a/web/angelo.js +++ b/web/angelo.js @@ -878,6 +878,19 @@ function attachPreviewCanvas(node) { quickRow.appendChild(upscaleBtn); node._AngeloUpscaleBtn = upscaleBtn; + const lirBtn = makeActionButton("▦ Large Image Refine", () => triggerLargeImageRefine(node), "quickfix"); + lirBtn.title = "Refine a LARGE canvas in one press: the image is divided into ~1MP boxes that " + + "exactly tile it, and each box runs an Xtra-Fine refine (ref 0.4, denoise 0.55, 176px " + + "context pad, hard edges) under the instruction \"restore the image. make it clear and " + + "sharp.\" — processed in a CHESS pattern, so the second half of the boxes refine with " + + "already-refined neighbours visible in their context and match them. Compositing is " + + "Xtra-Fine's bit-exact latent blend — no pixel feathering anywhere.\n\n" + + "The whole pass is ONE history entry (one Undo reverts it all); the Seed drives it " + + "(randomize + re-press = a fresh full pass). The natural partner of ⬆ 2× Pixel: enlarge " + + "first, then refine the detail in. Edit models + CLIP recommended; Refine mode only."; + quickRow.appendChild(lirBtn); + node._AngeloLirBtn = lirBtn; + // ===== OUTPAINT ROW: direction + amount (Outpaint mode only) ===== // Arrows extend the canvas in that direction; "All" pads every side // (zoom-out). The same action is available by clicking near an edge @@ -4025,6 +4038,22 @@ function triggerPixelUpscale(node) { queuePrompt(); } +// ▦ Large Image Refine: chess-pattern Xtra-Fine boxes over the whole +// canvas. Python owns the loop; the JS only bumps the seq. +function triggerLargeImageRefine(node) { + if (isAnySmartMode(node) || isOutpaintMode(node)) return; // dimmed there anyway + if (!node._AngeloImg) { + _angeloToast("Generate or load an image first"); + return; + } + const ws = findWidget(node, "lir_seq"); + if (!ws) return; + setWidget(ws, ((ws.value || 0) + 1) & 0x7FFFFFFF); + _angeloToast("▦ Large Image Refine — chess-pattern pass over the canvas…"); + dbg("queue large image refine", { lir_seq: ws.value }); + queuePrompt(); +} + // ===== Outpaint — directional canvas extension with review-before-commit ===== function isOutpaintMode(node) { @@ -4902,8 +4931,8 @@ function hideMechanicalWidgets(node) { "refine_reference", "reference_strength", // Quick Photo Refine — driven by the ✨ button "quick_refine_seq", - // ⬆ 2× Pixel — driven by the button - "upscale_seq", + // ⬆ 2× Pixel + ▦ Large Image Refine — driven by their buttons + "upscale_seq", "lir_seq", // Toolbar-driven (visible via the bar above the canvas) "persistent_mask", "area_prompt", "paint_mode", "fine_upscaling", "click_radius", "feather_radius", "denoise",