From 5157652fc9cdd7d0167f9860c967b50c6a65a656 Mon Sep 17 00:00:00 2001 From: jtrue Date: Sat, 23 Aug 2025 19:32:32 -0400 Subject: [PATCH] Add files via upload --- __init__.py | 45 ++++ js/index.js | 231 +++++++++++++++++++++ py/__init__.py | 1 + py/__pycache__/rect_crop.cpython-311.pyc | Bin 0 -> 3901 bytes py/__pycache__/rect_fill.cpython-311.pyc | Bin 0 -> 8026 bytes py/__pycache__/rect_mask.cpython-311.pyc | Bin 0 -> 9001 bytes py/__pycache__/rect_select.cpython-311.pyc | Bin 0 -> 3010 bytes py/rect_crop.py | 65 ++++++ py/rect_fill.py | 126 +++++++++++ py/rect_mask.py | 141 +++++++++++++ py/rect_select.py | 47 +++++ 11 files changed, 656 insertions(+) create mode 100644 __init__.py create mode 100644 js/index.js create mode 100644 py/__init__.py create mode 100644 py/__pycache__/rect_crop.cpython-311.pyc create mode 100644 py/__pycache__/rect_fill.cpython-311.pyc create mode 100644 py/__pycache__/rect_mask.cpython-311.pyc create mode 100644 py/__pycache__/rect_select.cpython-311.pyc create mode 100644 py/rect_crop.py create mode 100644 py/rect_fill.py create mode 100644 py/rect_mask.py create mode 100644 py/rect_select.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..3e604a6 --- /dev/null +++ b/__init__.py @@ -0,0 +1,45 @@ +""" +@title: Rect +@nickname: Rect +@description: Rectangle selection and utilities for ComfyUI (modular). +""" +import os, sys, pkgutil, importlib, logging +import nodes + +PACK_KEY = "ComfyUI-Rect" # used for front-end assets + +_PACK_DIR = os.path.dirname(os.path.realpath(__file__)) +_PY_DIR = os.path.join(_PACK_DIR, "py") +_JS_DIR = os.path.join(_PACK_DIR, "js") + +# Make ./py importable for dynamic module loading +if _PY_DIR not in sys.path: + sys.path.append(_PY_DIR) + +NODE_CLASS_MAPPINGS = {} +NODE_DISPLAY_NAME_MAPPINGS = {} + +def _merge_module(mod): + added = [] + if hasattr(mod, "NODE_CLASS_MAPPINGS"): + NODE_CLASS_MAPPINGS.update(mod.NODE_CLASS_MAPPINGS) + added.extend(mod.NODE_CLASS_MAPPINGS.keys()) + if hasattr(mod, "NODE_DISPLAY_NAME_MAPPINGS"): + NODE_DISPLAY_NAME_MAPPINGS.update(mod.NODE_DISPLAY_NAME_MAPPINGS) + logging.info(f"[Rect] loaded {mod.__name__}: {', '.join(added) or 'no nodes'}") + +# Auto-load all .py files in ./py (except those starting with "_") +for _, modname, ispkg in pkgutil.iter_modules([_PY_DIR]): + if ispkg or modname.startswith("_"): + continue + try: + mod = importlib.import_module(modname) + _merge_module(mod) + except Exception as e: + logging.exception(f"[Rect] failed to load '{modname}': {e}") + +# Serve front-end JS for this pack +if os.path.isdir(_JS_DIR): + nodes.EXTENSION_WEB_DIRS[PACK_KEY] = _JS_DIR + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/js/index.js b/js/index.js new file mode 100644 index 0000000..d35b74c --- /dev/null +++ b/js/index.js @@ -0,0 +1,231 @@ +// ComfyUI-Rect front-end (Rect / Select) — marching ants, image-required, auto-prefill +import { app } from "../../scripts/app.js"; + +function setWidget(node, name, value) { + const w = node.widgets?.find(w => w.name === name); + if (w) { + w.value = value; + node.onWidgetChanged?.(name, value, w); + } + node.properties[name] = value; + node.setDirtyCanvas?.(true, true); +} + +function toast(text, ms = 1800) { + const div = document.createElement("div"); + Object.assign(div.style, { + position: "fixed", right: "16px", bottom: "16px", + background: "rgba(20,20,20,.9)", color: "#eee", + padding: "10px 12px", borderRadius: "10px", + zIndex: 10000, font: "12px/1.3 system-ui, sans-serif", + boxShadow: "0 6px 16px rgba(0,0,0,.35)" + }); + div.textContent = text; + document.body.appendChild(div); + setTimeout(() => div.remove(), ms); +} + +function upstreamFilenameFromImageInput(node) { + const idx = node.inputs?.findIndex(i => i.name === "image"); + if (idx == null || idx < 0) return null; + const linkId = node.inputs[idx]?.link; + if (!linkId) return null; + const link = app.graph.links?.[linkId]; + const upstream = link ? app.graph._nodes_by_id?.[link.origin_id] : null; + const w = upstream?.widgets?.find(w => w.name === "image" && typeof w.value === "string" && w.value.length); + return w ? String(w.value) : null; +} + +function buildInputViewURL(nameOrPath) { + let p = String(nameOrPath).replace(/\\/g, "/"); + const parts = p.split("/"); + const file = parts.pop(); + const subfolder = parts.join("/"); + const ext = (file.split(".").pop() || "png").toLowerCase(); + const format = ext === "jpg" || ext === "jpeg" ? "jpeg" : "png"; + const u = new URL(`${location.origin}/view`); + u.searchParams.set("type", "input"); + u.searchParams.set("filename", file); + u.searchParams.set("subfolder", subfolder); + u.searchParams.set("format", format); + return u.toString(); +} + +function openRectSelect(node) { + const upstreamName = upstreamFilenameFromImageInput(node); + if (!upstreamName) { toast("Rect / Select: connect an image to the 'image' input."); return; } + const upstreamURL = buildInputViewURL(upstreamName); + + // Overlay & modal + const overlay = document.createElement("div"); + Object.assign(overlay.style, { + position: "fixed", inset: "0", background: "rgba(0,0,0,0.6)", + zIndex: 9999, display: "flex", alignItems: "center", justifyContent: "center" + }); + + const modal = document.createElement("div"); + Object.assign(modal.style, { + background: "#111", color: "#eee", padding: "16px", borderRadius: "12px", + width: "min(92vw, 1100px)", maxHeight: "90vh", + display: "grid", gridTemplateRows: "auto 1fr auto", gap: "12px", + boxShadow: "0 10px 30px rgba(0,0,0,0.5)" + }); + overlay.appendChild(modal); + + // Header + const header = document.createElement("div"); + Object.assign(header.style, { display: "flex", justifyContent: "space-between", alignItems: "center" }); + header.innerHTML = `
Rect / Select
`; + const closeBtn = document.createElement("button"); + closeBtn.textContent = "Close"; + closeBtn.onclick = () => document.body.removeChild(overlay); + header.appendChild(closeBtn); + modal.appendChild(header); + + // Canvas area + const area = document.createElement("div"); + Object.assign(area.style, { overflow: "auto", background: "#222", padding: "8px" }); + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + area.appendChild(canvas); + modal.appendChild(area); + + // Footer (coords left, Apply right) + const footer = document.createElement("div"); + Object.assign(footer.style, { display: "flex", gap: "12px", alignItems: "center", flexWrap: "wrap" }); + + const coords = document.createElement("div"); + Object.assign(coords.style, { color: "#aaa", fontStyle: "italic", fontSize: "12px", minHeight: "1em" }); + + const spacer = document.createElement("div"); + spacer.style.flex = "1"; + + const applyBtn = document.createElement("button"); + applyBtn.textContent = "Apply Rect"; + applyBtn.disabled = true; + Object.assign(applyBtn.style, { padding: "10px 14px", fontWeight: "600", borderRadius: "8px", border: "none", cursor: "pointer" }); + + footer.append(coords, spacer, applyBtn); + modal.appendChild(footer); + + // State + let img = new Image(), imgLoaded = false; + let dragging = false, sx = 0, sy = 0, cx = 0, cy = 0; + let antsOffset = 0; + + // Helpers + function clampRect(x, y, w, h, W, H) { + x = Math.max(0, Math.min(x, W)); + y = Math.max(0, Math.min(y, H)); + w = Math.max(1, Math.min(w, W)); + h = Math.max(1, Math.min(h, H)); + if (x + w > W) w = Math.max(1, W - x); + if (y + h > H) h = Math.max(1, H - y); + return [x, y, w, h]; + } + + function setSelectionFromNode() { + if (!imgLoaded) return; + const W = img.naturalWidth, H = img.naturalHeight; + let nx = Number(node.properties?.x ?? 0); + let ny = Number(node.properties?.y ?? 0); + let nw = Number(node.properties?.w ?? Math.floor(W / 2)); + let nh = Number(node.properties?.h ?? Math.floor(H / 2)); + [nx, ny, nw, nh] = clampRect(nx, ny, nw, nh, W, H); + const sxScale = canvas.width / W; + const syScale = canvas.height / H; + sx = Math.round(nx * sxScale); sy = Math.round(ny * syScale); + cx = Math.round((nx + nw) * sxScale); cy = Math.round((ny + nh) * syScale); + draw(); + } + + function fitToViewport() { + if (!imgLoaded) return; + const maxW = Math.min(window.innerWidth * 0.88, 1600); + const maxH = Math.min(window.innerHeight * 0.65, 900); + const scale = Math.min(maxW / img.naturalWidth, maxH / img.naturalHeight, 1); + canvas.width = Math.max(2, Math.floor(img.naturalWidth * scale)); + canvas.height = Math.max(2, Math.floor(img.naturalHeight * scale)); + setSelectionFromNode(); + } + + function draw() { + ctx.fillStyle = "#333"; ctx.fillRect(0, 0, canvas.width, canvas.height); + if (imgLoaded) ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + const x = Math.min(sx, cx), y = Math.min(sy, cy); + const w = Math.abs(cx - sx), h = Math.abs(cy - sy); + if (w > 0 && h > 0) { + const seg = 8; + ctx.lineWidth = 2; + ctx.setLineDash([seg, seg]); + ctx.lineDashOffset = -antsOffset; ctx.strokeStyle = "#fff"; ctx.strokeRect(x, y, w, h); + ctx.lineDashOffset = seg - antsOffset; ctx.strokeStyle = "#000"; ctx.strokeRect(x, y, w, h); + coords.textContent = `x=${x}, y=${y}, w=${w}, h=${h}`; + } else { + coords.textContent = imgLoaded ? "Drag to draw a rectangle." : ""; + } + } + + function animate() { + antsOffset = (antsOffset + 1) % 16; + draw(); + if (document.body.contains(overlay)) requestAnimationFrame(animate); + } + + function loadFromURL(url) { + img = new Image(); + img.crossOrigin = "anonymous"; + img.onload = () => { imgLoaded = true; applyBtn.disabled = false; fitToViewport(); }; + img.onerror = () => { toast("Rect / Select: could not load upstream image."); }; + img.src = url; + } + + // Interactions + canvas.addEventListener("mousedown", (e) => { + if (!imgLoaded) return; + const r = canvas.getBoundingClientRect(); + sx = cx = e.clientX - r.left; sy = cy = e.clientY - r.top; dragging = true; draw(); + }); + canvas.addEventListener("mousemove", (e) => { + if (!dragging || !imgLoaded) return; + const r = canvas.getBoundingClientRect(); + cx = e.clientX - r.left; cy = e.clientY - r.top; draw(); + }); + window.addEventListener("mouseup", () => { if (dragging) { dragging = false; draw(); } }); + window.addEventListener("resize", fitToViewport); + + applyBtn.onclick = () => { + if (!imgLoaded) return; + const scaleX = img.naturalWidth / canvas.width; + const scaleY = img.naturalHeight / canvas.height; + const x = Math.round(Math.min(sx, cx) * scaleX); + const y = Math.round(Math.min(sy, cy) * scaleY); + const w = Math.round(Math.abs(cx - sx) * scaleX); + const h = Math.round(Math.abs(cy - sy) * scaleY); + if (w < 1 || h < 1) { toast("Draw a rectangle first."); return; } + setWidget(node, "x", x); setWidget(node, "y", y); + setWidget(node, "w", w); setWidget(node, "h", h); + setTimeout(() => document.body.contains(overlay) && document.body.removeChild(overlay), 250); + }; + + document.body.appendChild(overlay); + requestAnimationFrame(animate); + loadFromURL(upstreamURL); +} + +// Register: attach button to RectSelect nodes +app.registerExtension({ + name: "ComfyUI-Rect", + nodeCreated(node) { + if (node?.comfyClass !== "RectSelect") return; + if (node.widgets?.some(w => w.__rect_btn)) return; + const btn = node.addWidget("button", "Open Rect / Select", "open", () => openRectSelect(node)); + btn.__rect_btn = true; + + node.properties ??= {}; + node.properties.x ??= 0; node.properties.y ??= 0; + node.properties.w ??= 256; node.properties.h ??= 256; + + console.log("[Rect] button attached to node", node.id); + }, +}); diff --git a/py/__init__.py b/py/__init__.py new file mode 100644 index 0000000..0f8c0e6 --- /dev/null +++ b/py/__init__.py @@ -0,0 +1 @@ +# (intentionally empty) diff --git a/py/__pycache__/rect_crop.cpython-311.pyc b/py/__pycache__/rect_crop.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2fc34fab51c508ee477553b1adb0a3065736bb42 GIT binary patch literal 3901 zcmb^z-%lgQdB*$0KZy+n0|%V!J0RyOAwq4U3!EzXzyaDj!(m7TXKCenmtYFEnOz6Y zT`KEztxS~aNOTcSn@En-Qm&Uur#|H|it-28ksqv;kWQ-ZftSkBwbCh1{btuTm=gz5 zGqd~c>^C#tkD2e+-&$I12-+V;{=PhJLFixfr{37w;Mq6~c9Dogyowf)Xu5|MvB&^4 zna~6h*?UOj_Dl#pg4bw^jL1n$951pWC-U&+L>xwb^C6uM#c*vI{c!Ly@IG4s>@HG{ z&8bLfP()~@F>dBX@+vA&3NN%hz&vVTMf@9`0{NL@Z6J|~R;UDe6HVRqA=(2u9yMe+ zbq90WjwMqVXrD_i_Ds1><#MWA+{@$=d3qP2Lwdo7^h^$^qKEVh3H|k(4iS6_7zFb3 znk_EJ6S5Lb#3YSVk|egQ@pDo_P7;j~<7*ldPbeBEFGo|-L#%P}wdi}2OeuPQh>VRy z=4FYs6^f5s9T`CAlUfk|J!Vt$ux0 ziYbxQod|)i5F^RdQ0k6m7j#~N9M4Fk4H(j!l=R{)5>Me)-F<@Ry;|mC^E* z(V}~_=otNfcDi#{pMgjy##2~p^k{Xf{}UY4E>c;rPTksMUIkw<#>6Co6&?2o5i7?m zU1?Y@0$XdgSfI#cz_*)yrTK5`ytMHN+T1y4<0iS8Q{^_Dpv|i2Ou0?xq|Kb5&8hfI z+Kh@+teRA2i3KlVL%K&iWc7~$=sl7K@G}~-7Tp98PmmUv0*}F)8os094Gk}A_*WX9 zl<9J>-bgD9wRR!48eK~Xx;-x?iEh*6B1};d@;?D&(G#;J7r6U=_WhmKo#@A&9Z$vC zoy#0p?76@no0 zNRjOzZV)>LZ=>k}7yh1&0N6zmO&pLmBu2#d&>rB9!ZV}_6Ql`dkr&xTv&b!4L>^L% zRW!pjSTqZb0At|zP052sFx`z@I(-p~CXo@@&rETeZn%(UA<}v}P9%{=rm35OiJ%{A z>}+ss&Tk@glg833c!&Va#8zb^ZW(uEp9;;)&k1u2Gr@4Cz0QB=7?uWE*#{ttsyu4x ztaM+lbor_r)BQ>nQNVC6TNQCun|?IpGAt!d%9SDM1jqrbW5*mCHNwKb;pBFawaGzM zQ$Y_Au)nEMt(zUY+3dHN&ijZu zu-=YyQ0#RqrK&iN{#F|NnomP^D%AgLO|&%M)LpFN!m;9{z7E1T43B(8wAM<79u@pa zM87wIw5-Z!U=@rR=$tV&W|!Eba$w7vm^ddFTa8N>1jLuqKUSas{mv#G?J8c;i%hD` zpV8+h0zT+$U83IV2$3#WVZ>M}FS$SgJ$TYXK?i`$K-~{VywYY0{6`YKdQCE9lq6o= z$q6+LLhm5sJKB&=$mtZVH(-yx%nRjF=dib4;`gkA}u zIuLr{n(f`_YFY{sk|c!QIe7(u-=?v$w7R566*5TCx;jUi=t}g`i9P_DnU*mrNt7vJ zBR5c}#~$(%z-1hD(T5;fyb2ikBM1NxYgpdef8cWOjPFi{`vFg|K*XZCtpiR+iVW!h7UUY z`@a2KkMZN-$I-7^OC6)-j?rx!ouc{Usk1vj@YjyN^p>1MW#n-+;m)e5m zwqVf_eDY%PR`K-Y1A1RCv=)ZHaOcy7=sw=}J!A^f{w>;zy+dWs8@Y+xM5U$e{=x?f z+qXZwoezB1(pPNhGxkRd_|sTESQswcDy)AIF1!hnLeEc^J+IM?PpKTCuRFf&fe$>T zw&`-)bkQ;W#MPP06fNB{Ej{mEcU*U(Z=9{yFEig{H5f(v6K;bJ0noSVal45$5 zjva!qo{p~8X6#T2$nu(`EGI?c2U412HaIsw8!|Fa<1ZAr84PQdcjiN5b5qlh+QtHN z!HMbF1>IN(wGSi9&;UccbwG_!o&}I?^a|&yns^Q}6M*j-#JAqNZ6I8z{bKe;#oY(M z*`A%OI6JbFRkoA6P(?2SgB|6Ru~Sp^zTu@A+Rq!lLXXApAG$&+B4$gtw zL9;q=S+S>z8o)D%L6;Z7_Z0V^qlp=ex-J#27Co;Ok-Kv7%Kq!czNg_Jd3(L0-yD1jSHdwz>W2yU8{We5mfFW8qq`q zji^szS`*R`Xqp_+5{TA(ibg1!1gN960QEEl(4ax55v}_a(fUUkgdW4KmJuVZXN+ND z#6;_913b+%(T`e;EBLiPM637rgm7p;S{(EC^L@vq=LS-~H=g7=Wc%@)lHf+uUhb)9SqbKxi#3r0hXOvPr{(1ffT zV4~a%E0c71S|-EMm`rgK!CB@DLZ-sg!EuJe80-Hu+H-VtkYiYGbTT;2aHHPd(Vm&< zvFn4qfve#tJ#&={%+9c};71W={l#b~&c$Y?1JM~8CajG$cMrW^&vhWOyg(H#H z+3T`7psW?(!t)FZyshvOcMB>>w5)fgn;v=|)QI|KN#DGRwA8V@*>UU0!jYR@i(Nv) z3-hj=dDpVLJRQG#Bh!BOW6|9#xtqTvv%V+io)J&{;=V3vUsulEm9upHUuL>fo!
KfVrBjg&sVU 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 literal 0 HcmV?d00001 diff --git a/py/__pycache__/rect_mask.cpython-311.pyc b/py/__pycache__/rect_mask.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dd300f72421ec0b3823977b88d0069bd7a79cbbc GIT binary patch literal 9001 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 literal 0 HcmV?d00001 diff --git a/py/__pycache__/rect_select.cpython-311.pyc b/py/__pycache__/rect_select.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b5f3bb1c7c15ff8e201677c9533ac4879527add GIT binary patch literal 3010 zcmbtV-ER{|5a0Xs#ZGXH;{XYum^LX!DiA1;5QJofG*|`a!;UIki<9fUG!Fhq_G}>O zxpJ#KI29E}3c^T1@>7~NRN}Eu{TrMrs;rZcD)lMqi&6T-Q)kbKV~50T)xEp9-PxU; zot^p3eBaVyM$rDe^5fKb5urcmV5jlhovj{ z>vfU5h6;3tciIYY)~a00+%t9{A0?ZrOr)V{dIFwK(eNi_@)JR-ShT>sG z7m^7Qp3+6X5?2#M=WryZbCGyb7u2cHjN)Q+ArcFHp{R7nuD5pwuLdVng{Z;lP)t#S zZclJ95u2Qw@W>A$ah!Od$}4rvWEG7UBE z!F@xGOf9xCTW1$rucdyUG}}OuYbeQSNMq;Wsx|%|jSWQX^?PZ&hUUQ+do^l|#u;+L z0dj)I>?23@F&e9JlRRLI^BQ;})bB1%=VGCUKq7I{LK&jYJk*&voq3=$Q#$jh&fHY# z$}XdkR_H>2hohm`j7;O690yJ_eCjViAiyO{*8QFO^kQ!I$>pWXFD~Y13(n{L%l+%t z?hR}Ay415F^<>=tZCS2&@&2Rxi-#T^s!G^22-TG?TZ+8-sbQ2 zrDY*PD_N6QmxwZ(iVy|Ur02Qq9v;#;kJrDQL3I;WCPS%ck~l!yCIX;V!ayKaV32l7 zj!<%x5}K}mK(G@Rj;fX9xg7u=@92bI_TL#D^3AvJT6VUEPxn&q0?DAVh+2-6j=fiE z?_9OL>MfqQL|@0{{l@lJW5p9!=j40uxR&yCiO=iPqEsx{?qi(JkyIQ+!8LVhkk%N(DM{$mr$KE`>Ip)Kq{XzUHaSwL0fYrj)J!V? zo;3;}qXhx(X*SKJ`LwXJ3Ny=mTGFJQ@uUE*WW6qaKXR>3h)!;|C;u;vfdcn~h+G+pZl0%*2&(vnV+Ye|cSI zU6SQElwVoa&9WRz;8c|Mn`L=66^d@pSRl`+YD`H^B{2B_PNX-F5BVp?y_L|bj=b*M zLq1*lc)~mA_ly7v2HpOl;gRt>hJB240`i7K zqHW=`3gakh>&x6K+0Oy7wqoH3pU_V%p27N)8`kz>Igv8_N4@cj5v%`hFo$Y(HN__R`5S Qt0Tn|*NUj4@*0N!4f_3h2><{9 literal 0 HcmV?d00001 diff --git a/py/rect_crop.py b/py/rect_crop.py new file mode 100644 index 0000000..9b0ffb4 --- /dev/null +++ b/py/rect_crop.py @@ -0,0 +1,65 @@ +# RectCrop node (display: "Rect / Crop") +# Crops an IMAGE to the given RECT (x,y,w,h in pixels). +import torch + +def _image_size(image): + if isinstance(image, torch.Tensor): + if image.dim() == 4: # [B,H,W,C] + return int(image.shape[2]), int(image.shape[1]) + if image.dim() == 3: # [H,W,C] + return int(image.shape[1]), int(image.shape[0]) + return 512, 512 + +def _clamp_rect_for_crop(x, y, w, h, W, H): + # Clamp top-left *inside* the image so slicing never returns empty. + if W <= 0 or H <= 0: + return 0, 0, 1, 1 + x = max(0, min(int(x), W - 1)) + y = max(0, min(int(y), H - 1)) + # Width/height must fit within the remaining bounds from (x,y) + w = max(1, min(int(w), W - x)) + h = max(1, min(int(h), H - y)) + return x, y, w, h + +class RectCrop: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "rect": ("RECT",), # {"x":int,"y":int,"w":int,"h":int} + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "run" + CATEGORY = "Rect" + + def run(self, image, rect): + # Extract rect safely + try: + x = int(rect.get("x", 0)) + y = int(rect.get("y", 0)) + w = int(rect.get("w", 1)) + h = int(rect.get("h", 1)) + except Exception: + x, y, w, h = 0, 0, 1, 1 + + W, H = _image_size(image) + x, y, w, h = _clamp_rect_for_crop(x, y, w, h, W, H) + + if not isinstance(image, torch.Tensor): + raise ValueError("RectCrop: expected torch.Tensor IMAGE") + + if image.dim() == 4: # [B,H,W,C] + cropped = image[:, y:y+h, x:x+w, :] + elif image.dim() == 3: # [H,W,C] + cropped = image[y:y+h, x:x+w, :] + else: + raise ValueError(f"RectCrop: unsupported IMAGE dims {image.shape}") + + return (cropped,) + +NODE_CLASS_MAPPINGS = {"RectCrop": RectCrop} +NODE_DISPLAY_NAME_MAPPINGS = {"RectCrop": "Rect / Crop"} diff --git a/py/rect_fill.py b/py/rect_fill.py new file mode 100644 index 0000000..b51f3f5 --- /dev/null +++ b/py/rect_fill.py @@ -0,0 +1,126 @@ +# Rect / Fill — fill or outline a RECT region on IMAGE with color & opacity (optional feather) +import torch +import torch.nn.functional as F + +def _image_size(image): + if isinstance(image, torch.Tensor): + if image.dim() == 4: # [B,H,W,C] + return int(image.shape[2]), int(image.shape[1]) + if image.dim() == 3: # [H,W,C] + return int(image.shape[1]), int(image.shape[0]) + return 512, 512 + +def _clamp_rect(x, y, w, h, W, H): + x = max(0, min(int(x), max(0, W - 1))) + y = max(0, min(int(y), max(0, H - 1))) + w = max(1, min(int(w), W - x)) + h = max(1, min(int(h), H - y)) + return x, y, w, h + +def _gaussian_kernel1d(radius, sigma, device): + xs = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32) + k = torch.exp(-(xs**2) / (2 * sigma * sigma)) + k /= k.sum().clamp_min(1e-8) + return k + +def _gaussian_blur(mask, radius): + if radius < 1: + return mask + B, H, W = mask.shape + device = mask.device + sigma = max(0.5, radius / 2.5) + k1d = _gaussian_kernel1d(radius, sigma, device) + x = mask.unsqueeze(1) # [B,1,H,W] + kh = k1d.view(1, 1, 1, -1) + kv = k1d.view(1, 1, -1, 1) + x = F.pad(x, (radius, radius, 0, 0), mode="reflect") + x = F.conv2d(x, kh) + x = F.pad(x, (0, 0, radius, radius), mode="reflect") + x = F.conv2d(x, kv) + return x.squeeze(1).clamp(0.0, 1.0) + +class RectFill: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "rect": ("RECT",), + "r": ("INT", {"default": 255, "min": 0, "max": 255}), + "g": ("INT", {"default": 0, "min": 0, "max": 255}), + "b": ("INT", {"default": 0, "min": 0, "max": 255}), + "opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0}), + "mode": ("STRING", {"default": "fill", "choices": ["fill", "outline"]}), + "thickness": ("INT", {"default": 4, "min": 1, "max": 1024}), + "feather": ("INT", {"default": 0, "min": 0, "max": 256}), + } + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "run" + CATEGORY = "Rect" + + def run(self, image, rect, r, g, b, opacity, mode, thickness, feather): + if not isinstance(image, torch.Tensor): + raise ValueError("RectFill: expected torch.Tensor IMAGE") + + # Parse rect + try: + x = int(rect.get("x", 0)); y = int(rect.get("y", 0)) + w = int(rect.get("w", 1)); h = int(rect.get("h", 1)) + except Exception: + x, y, w, h = 0, 0, 1, 1 + + # Shapes + if image.dim() == 4: + B, H, W, C = int(image.shape[0]), int(image.shape[1]), int(image.shape[2]), int(image.shape[3]) + img = image + elif image.dim() == 3: + B, H, W, C = 1, int(image.shape[0]), int(image.shape[1]), int(image.shape[2]) + img = image.unsqueeze(0) # [1,H,W,C] + else: + raise ValueError(f"RectFill: unsupported IMAGE dims {tuple(image.shape)}") + + device = img.device + x, y, w, h = _clamp_rect(x, y, w, h, W, H) + + # Build alpha mask in [B,H,W] + alpha = torch.zeros((B, H, W), device=device, dtype=torch.float32) + + if mode == "fill": + alpha[:, y:y+h, x:x+w] = 1.0 + else: # outline + # Outer rect + alpha[:, y:y+h, x:x+w] = 1.0 + # Inner rect to subtract + inner_w = max(0, w - 2 * thickness) + inner_h = max(0, h - 2 * thickness) + if inner_w > 0 and inner_h > 0: + ix = x + thickness + iy = y + thickness + alpha[:, iy:iy+inner_h, ix:ix+inner_w] = 0.0 + + # Feather (Gaussian blur) + if feather > 0: + alpha = _gaussian_blur(alpha, int(feather)) + + # Apply opacity + alpha = (alpha * float(opacity)).clamp(0.0, 1.0) + + # Color tensor [B,1,1,3] in 0..1 + color = torch.tensor([r, g, b], device=device, dtype=torch.float32) / 255.0 + color = color.view(1, 1, 1, 3).expand(B, 1, 1, 3) + + # Blend: out = alpha*color + (1-alpha)*img + alpha4 = alpha.unsqueeze(-1) # [B,H,W,1] + out = (alpha4 * color) + ((1.0 - alpha4) * img) + out = out.clamp(0.0, 1.0) + + if image.dim() == 3: + out = out.squeeze(0) + + return (out,) + +NODE_CLASS_MAPPINGS = {"RectFill": RectFill} +NODE_DISPLAY_NAME_MAPPINGS = {"RectFill": "Rect / Fill"} diff --git a/py/rect_mask.py b/py/rect_mask.py new file mode 100644 index 0000000..29d073c --- /dev/null +++ b/py/rect_mask.py @@ -0,0 +1,141 @@ +# Rect / Mask — build a MASK from a RECT, with optional feather/invert/combine +import math +import torch +import torch.nn.functional as F + +def _image_size(image): + if isinstance(image, torch.Tensor): + if image.dim() == 4: # [B,H,W,C] + return int(image.shape[2]), int(image.shape[1]) + if image.dim() == 3: # [H,W,C] + return int(image.shape[1]), int(image.shape[0]) + return 512, 512 + +def _clamp_rect(x, y, w, h, W, H): + x = max(0, min(int(x), max(0, W - 1))) + y = max(0, min(int(y), max(0, H - 1))) + w = max(1, min(int(w), W - x)) + h = max(1, min(int(h), H - y)) + return x, y, w, h + +def _ensure_mask_shape(mask, B, H, W, device): + # Accept [H,W], [B,H,W], or [B,1,H,1] quirky shapes from some packs + if mask is None: + return None + if mask.dim() == 2: + mask = mask.unsqueeze(0) # [1,H,W] + if mask.dim() == 4 and mask.shape[1] == 1 and mask.shape[3] == 1: + mask = mask[:, 0, :, :] # [B,H,W] + if mask.dim() != 3: + raise ValueError(f"RectMask: unsupported mask shape {tuple(mask.shape)}") + # Broadcast or clamp batch as needed + if mask.shape[0] == 1 and B > 1: + mask = mask.expand(B, H, W).clone() + elif mask.shape[0] != B: + # If sizes mismatch, just take first and broadcast + mask = mask[:1].expand(B, H, W).clone() + # Resize if spatial size mismatches + if (mask.shape[1] != H) or (mask.shape[2] != W): + mask = F.interpolate(mask.unsqueeze(1), size=(H, W), mode="bilinear", align_corners=False).squeeze(1) + return mask.to(device=device, dtype=torch.float32).clamp(0.0, 1.0) + +def _gaussian_kernel1d(radius, sigma, device): + # radius: pixels; kernel size = 2*radius+1 + xs = torch.arange(-radius, radius + 1, device=device, dtype=torch.float32) + k = torch.exp(-(xs**2) / (2 * sigma * sigma)) + k /= k.sum().clamp_min(1e-8) + return k + +def _gaussian_blur(mask, radius): + # mask: [B,H,W], radius >= 1 + if radius < 1: + return mask + B, H, W = mask.shape + device = mask.device + sigma = max(0.5, radius / 2.5) + k1d = _gaussian_kernel1d(radius, sigma, device) + # separable blur: first horizontal, then vertical + x = mask.unsqueeze(1) # [B,1,H,W] + kh = k1d.view(1, 1, 1, -1) + kv = k1d.view(1, 1, -1, 1) + x = F.pad(x, (radius, radius, 0, 0), mode="reflect") + x = F.conv2d(x, kh) + x = F.pad(x, (0, 0, radius, radius), mode="reflect") + x = F.conv2d(x, kv) + return x.squeeze(1).clamp(0.0, 1.0) + +class RectMask: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "rect": ("RECT",), # {"x","y","w","h"} + "feather": ("INT", {"default": 0, "min": 0, "max": 256}), + "invert": ("BOOLEAN", {"default": False}), + "combine": ("STRING", {"default": "replace", + "choices": ["replace", "union", "intersect", "subtract", "multiply"]}), + }, + "optional": { + "existing_mask": ("MASK",), + } + } + + RETURN_TYPES = ("MASK",) + RETURN_NAMES = ("mask",) + FUNCTION = "run" + CATEGORY = "Rect" + + def run(self, image, rect, feather, invert, combine, existing_mask=None): + if not isinstance(image, torch.Tensor): + raise ValueError("RectMask: expected torch.Tensor IMAGE") + + # Parse rect + try: + x = int(rect.get("x", 0)); y = int(rect.get("y", 0)) + w = int(rect.get("w", 1)); h = int(rect.get("h", 1)) + except Exception: + x, y, w, h = 0, 0, 1, 1 + + # Get sizes and clamp rect + if image.dim() == 4: + B, H, W, C = int(image.shape[0]), int(image.shape[1]), int(image.shape[2]), int(image.shape[3]) + elif image.dim() == 3: + B, H, W, C = 1, int(image.shape[0]), int(image.shape[1]), int(image.shape[2]) + else: + raise ValueError(f"RectMask: unsupported IMAGE dims {tuple(image.shape)}") + + device = image.device + x, y, w, h = _clamp_rect(x, y, w, h, W, H) + + # Build binary rect mask + mask = torch.zeros((B, H, W), device=device, dtype=torch.float32) + mask[:, y:y+h, x:x+w] = 1.0 + + # Feather (Gaussian) + if feather > 0: + radius = int(feather) + mask = _gaussian_blur(mask, radius) + + # Invert + if invert: + mask = 1.0 - mask + + # Combine with existing_mask + if existing_mask is not None: + em = _ensure_mask_shape(existing_mask, B, H, W, device) + if combine == "replace": + mask = mask + elif combine == "union": + mask = torch.maximum(em, mask) + elif combine == "intersect": + mask = torch.minimum(em, mask) + elif combine == "subtract": + mask = (em - mask).clamp(0.0, 1.0) + elif combine == "multiply": + mask = (em * mask).clamp(0.0, 1.0) + + return (mask,) + +NODE_CLASS_MAPPINGS = {"RectMask": RectMask} +NODE_DISPLAY_NAME_MAPPINGS = {"RectMask": "Rect / Mask"} diff --git a/py/rect_select.py b/py/rect_select.py new file mode 100644 index 0000000..93bb336 --- /dev/null +++ b/py/rect_select.py @@ -0,0 +1,47 @@ +# RectSelect node (display: "Rect / Select") +import torch + +def _image_size(image): + if isinstance(image, torch.Tensor): + if image.dim() == 4: # [B,H,W,C] + return int(image.shape[2]), int(image.shape[1]) + if image.dim() == 3: # [H,W,C] + return int(image.shape[1]), int(image.shape[0]) + return 512, 512 + +def _clamp_rect(x, y, w, h, W, H): + x = max(0, min(int(x), max(0, W))) + y = max(0, min(int(y), max(0, H))) + w = max(1, int(w)) + h = max(1, int(h)) + if x + w > W: w = max(1, W - x) + if y + h > H: h = max(1, H - y) + return x, y, w, h + +class RectSelect: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "x": ("INT", {"default": 0, "min": 0}), + "y": ("INT", {"default": 0, "min": 0}), + "w": ("INT", {"default": 256, "min": 1}), + "h": ("INT", {"default": 256, "min": 1}), + } + } + + # Output a RECT object and the four ints (compat with existing crop nodes) + RETURN_TYPES = ("RECT", "INT", "INT", "INT", "INT") + RETURN_NAMES = ("rect", "x", "y", "w", "h") + FUNCTION = "run" + CATEGORY = "Rect" + + def run(self, image, x, y, w, h): + W, H = _image_size(image) + x, y, w, h = _clamp_rect(x, y, w, h, W, H) + rect = {"x": x, "y": y, "w": w, "h": h} + return (rect, x, y, w, h) + +NODE_CLASS_MAPPINGS = {"RectSelect": RectSelect} +NODE_DISPLAY_NAME_MAPPINGS = {"RectSelect": "Rect / Select"}