From c2a84dddb3547833995cebb5dd2bd923a67bba2b Mon Sep 17 00:00:00 2001 From: MONKEYFOREVER2 Date: Sun, 1 Mar 2026 10:18:33 +0100 Subject: [PATCH] Initial release: Advanced Image Denoiser --- README.md | 38 +++ __init__.py | 25 ++ js/denoiser_widgets.js | 542 +++++++++++++++++++++++++++++++++++++++++ nodes.py | 363 +++++++++++++++++++++++++++ requirements.txt | 3 + 5 files changed, 971 insertions(+) create mode 100644 README.md create mode 100644 __init__.py create mode 100644 js/denoiser_widgets.js create mode 100644 nodes.py create mode 100644 requirements.txt diff --git a/README.md b/README.md new file mode 100644 index 0000000..31c354b --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# ๐Ÿงน Advanced Image Denoiser โ€” ComfyUI Custom Node + +A premium ComfyUI custom node for image denoising with **6 algorithms**, a **dark neon-themed UI**, and **separate luminance/chrominance control**. + +## Installation + +1. Copy this folder into `ComfyUI/custom_nodes/comfyui-advanced-denoiser/` +2. Install dependencies: `pip install -r requirements.txt` +3. Restart ComfyUI โ€” find the node under **image โ†’ denoising** + +## Methods + +| Method | Best For | Rating | +|--------|----------|--------| +| โšก **Auto-Blend** | Overall best results (NLM + Bilateral combo) | โ˜…โ˜…โ˜…โ˜…โ˜… | +| ๐Ÿ” **NLM** | Photos with grain/noise | โ˜…โ˜…โ˜…โ˜…โ˜† | +| ๐ŸŽฏ **Bilateral** | Portraits, sharp edges | โ˜…โ˜…โ˜…โ˜…โ˜† | +| ใ€ฐ๏ธ **Wavelet** | Subtle, fine-grained noise | โ˜…โ˜…โ˜…โ˜…โ˜† | +| โ˜๏ธ **Gaussian** | Quick, light smoothing | โ˜…โ˜…โ˜…โ˜†โ˜† | +| โšก **Median** | Salt-and-pepper artifacts | โ˜…โ˜…โ˜…โ˜†โ˜† | + +## Quick Start + +1. Start with **Auto-Blend** at `strength = 0.3`, `preserve_detail = 0.7` +2. For photos with grain, try **NLM** at `strength = 0.4โ€“0.6` +3. For portraits, use **Bilateral** with higher `preserve_detail` +4. Use `blend_original = 0.1โ€“0.3` for the most natural results + +## Requirements + +- ComfyUI (any recent version) +- `opencv-python >= 4.8.0` +- `scikit-image >= 0.21.0` (optional โ€” wavelet falls back to Gaussian) +- `numpy >= 1.24.0` + +## License + +MIT diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..f86301c --- /dev/null +++ b/__init__.py @@ -0,0 +1,25 @@ +""" +ComfyUI Advanced Image Denoiser +================================ +A custom node providing multiple advanced denoising algorithms with +fine-grained control over strength, detail preservation, and luminance/ +chrominance channels. + +Install: + Copy this folder into ComfyUI/custom_nodes/ + pip install -r requirements.txt +""" + +from .nodes import AdvancedImageDenoiser + +NODE_CLASS_MAPPINGS = { + "AdvancedImageDenoiser": AdvancedImageDenoiser, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "AdvancedImageDenoiser": "๐Ÿงน Advanced Image Denoiser", +} + +WEB_DIRECTORY = "./js" + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"] diff --git a/js/denoiser_widgets.js b/js/denoiser_widgets.js new file mode 100644 index 0000000..9365481 --- /dev/null +++ b/js/denoiser_widgets.js @@ -0,0 +1,542 @@ +/** + * ๐Ÿงน Advanced Image Denoiser โ€” Premium Custom UI + * ================================================ + * Dark-themed, neon-styled DOM widget with method tabs, + * descriptions, styled sliders, and dynamic visibility. + */ +import { app } from "../../scripts/app.js"; + +/* โ”€โ”€ Constants โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +const NODE_MIN_WIDTH = 420; +const NODE_TITLE_HEIGHT = 70; // approximate title + input/output row height + +const METHODS = { + auto_blend: { + icon: "โšก", label: "Auto-Blend", rating: 5, badge: "โ˜… RECOMMENDED", + desc: "Intelligently combines Non-Local Means + Bilateral for the best of both worlds. Balances noise removal with edge preservation automatically.", + tip: "Start here! Adjust Preserve Detail to control the NLM โ†” Bilateral mix." + }, + non_local_means: { + icon: "๐Ÿ”", label: "NLM", rating: 4, badge: "BEST FOR PHOTOS", + desc: "Searches for similar patches across the image and averages them. Excellent at preserving fine textures while removing photographic grain.", + tip: "Increase Search Window for better quality (slower). Increase Patch Size for smoother results." + }, + bilateral: { + icon: "๐ŸŽฏ", label: "Bilateral", rating: 4, badge: "EDGE-PRESERVING", + desc: "Smooths flat areas while keeping sharp edges crisp. Perfect for portraits, architecture, and any image where edge definition matters.", + tip: "Sigma Color controls how much color variation is smoothed. Sigma Spatial controls the smoothing radius." + }, + wavelet: { + icon: "ใ€ฐ๏ธ", label: "Wavelet", rating: 4, badge: "SUBTLE & PRECISE", + desc: "Separates the image into frequency layers and removes noise from each band independently. Very gentle and precise โ€” ideal for fine grain.", + tip: "Increase Wavelet Level for coarser denoising. Requires scikit-image (falls back to Gaussian if missing)." + }, + gaussian: { + icon: "โ˜๏ธ", label: "Gaussian", rating: 3, badge: "QUICK & SIMPLE", + desc: "Classic Gaussian blur that softens everything evenly. Fast and effective for very light noise or as a gentle smoothing pass.", + tip: "Keep Strength low (0.1โ€“0.3) for subtle smoothing. Use Preserve Detail to recover sharpness." + }, + median: { + icon: "โšก", label: "Median", rating: 3, badge: "ARTIFACT REMOVAL", + desc: "Replaces each pixel with the median of its neighborhood. Specialized for removing salt-and-pepper noise and digital compression artifacts.", + tip: "Most effective for impulse noise (random bright/dark pixels). Less effective for Gaussian noise." + }, +}; + +const METHOD_WIDGETS = { + auto_blend: ["color_strength", "patch_size", "search_window", "sigma_spatial", "sigma_color", "blend_original"], + non_local_means: ["luminance_strength", "color_strength", "patch_size", "search_window", "blend_original"], + bilateral: ["luminance_strength", "color_strength", "sigma_spatial", "sigma_color", "blend_original"], + wavelet: ["wavelet_level", "blend_original"], + gaussian: ["luminance_strength", "color_strength", "blend_original"], + median: ["luminance_strength", "color_strength", "blend_original"], +}; + +const SLIDER_META = { + strength: { label: "Strength", min: 0, max: 1, step: 0.01, def: 0.50, unit: "", desc: "Overall denoising power. Higher = more smoothing." }, + preserve_detail: { label: "Preserve Detail", min: 0, max: 1, step: 0.01, def: 0.70, unit: "", desc: "Blends sharp detail from original back in. Higher = more texture kept." }, + luminance_strength: { label: "Luminance", min: 0, max: 1, step: 0.01, def: 0.50, unit: "", desc: "Brightness channel denoising. Controls grain removal." }, + color_strength: { label: "Chrominance", min: 0, max: 1, step: 0.01, def: 0.50, unit: "", desc: "Color channel denoising. Removes color noise / splotches." }, + patch_size: { label: "Patch Size", min: 3, max: 15, step: 2, def: 7, unit: "px", desc: "Comparison patch size. Larger = smoother but slower." }, + search_window: { label: "Search Window", min: 7, max: 35, step: 2, def: 21, unit: "px", desc: "Area searched for similar patches. Larger = better quality." }, + sigma_spatial: { label: "Sigma Spatial", min: 1, max: 300, step: 1, def: 75, unit: "", desc: "Spatial smoothing radius. Higher = smoother over larger areas." }, + sigma_color: { label: "Sigma Color", min: 1, max: 300, step: 1, def: 75, unit: "", desc: "Color similarity threshold. Higher = more colors blended." }, + wavelet_level: { label: "Wavelet Levels", min: 1, max: 5, step: 1, def: 2, unit: "", desc: "Decomposition depth. More levels = coarser denoising." }, + blend_original: { label: "Blend Original", min: 0, max: 1, step: 0.01, def: 0.00, unit: "", desc: "Mix denoised with original for subtle, natural results." }, + sharpen_amount: { label: "Amount", min: 0, max: 1, step: 0.01, def: 0.00, unit: "", desc: "Sharpening intensity. Higher = stronger edge enhancement." }, + sharpen_radius: { label: "Radius", min: 0.05, max: 1, step: 0.01, def: 0.30, unit: "", desc: "Detail scale to sharpen. Low = fine micro-detail, High = broader edges." }, +}; + +const SHARPEN_MODES = { + off: { icon: "โŠ˜", label: "Off" }, + unsharp_mask: { icon: "๐Ÿ”ช", label: "Unsharp" }, + high_pass: { icon: "๐Ÿ”ฌ", label: "High-Pass" }, + luminance_only: { icon: "๐Ÿ’ก", label: "Luma Only" }, +}; + +const ALL_OPTIONAL = Object.keys(SLIDER_META).filter(k => k !== "strength" && k !== "preserve_detail" && k !== "sharpen_amount" && k !== "sharpen_radius"); + +/* โ”€โ”€ CSS injection โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +function injectCSS() { + if (document.getElementById("den-styles")) return; + const style = document.createElement("style"); + style.id = "den-styles"; + style.textContent = ` +.den-root { + font-family: 'Segoe UI', system-ui, -apple-system, sans-serif; + background: #0c0c1a; + border-radius: 8px; + padding: 12px; + color: #d0d0e0; + user-select: none; + width: 100%; + box-sizing: border-box; + overflow: hidden; +} +.den-header { + text-align: center; + padding: 4px 0 8px; + border-bottom: 1px solid rgba(180, 77, 255, 0.2); + margin-bottom: 10px; +} +.den-title { + font-size: 15px; font-weight: 700; letter-spacing: 1.5px; + text-transform: uppercase; + background: linear-gradient(135deg, #b44dff, #00ff88); + -webkit-background-clip: text; -webkit-text-fill-color: transparent; + background-clip: text; margin: 0; +} +.den-subtitle { + font-size: 9px; color: #666; letter-spacing: 2px; + text-transform: uppercase; margin-top: 2px; +} +.den-methods { + display: flex; flex-wrap: wrap; gap: 4px; + justify-content: center; margin-bottom: 10px; +} +.den-method-btn { + padding: 5px 10px; + border: 1px solid rgba(180, 77, 255, 0.25); + border-radius: 16px; + background: rgba(180, 77, 255, 0.06); + color: #999; cursor: pointer; + font-size: 10px; font-weight: 500; + transition: all 0.25s ease; outline: none; white-space: nowrap; +} +.den-method-btn:hover { + background: rgba(180, 77, 255, 0.15); + color: #ccc; border-color: rgba(180, 77, 255, 0.5); +} +.den-method-btn.active { + background: rgba(180, 77, 255, 0.25); + border-color: #b44dff; color: #fff; + box-shadow: 0 0 12px rgba(180, 77, 255, 0.3), inset 0 0 8px rgba(180, 77, 255, 0.1); +} +.den-method-btn.recommended { border-color: rgba(0, 255, 136, 0.5); } +.den-method-btn.recommended.active { + border-color: #00ff88; + box-shadow: 0 0 14px rgba(0, 255, 136, 0.3), inset 0 0 8px rgba(0, 255, 136, 0.1); +} +.den-info { + background: rgba(180, 77, 255, 0.06); + border: 1px solid rgba(180, 77, 255, 0.15); + border-radius: 8px; padding: 8px 10px; + margin-bottom: 10px; font-size: 10px; line-height: 1.4; +} +.den-info-badge { + display: inline-block; font-size: 8px; font-weight: 700; + letter-spacing: 1px; padding: 2px 6px; border-radius: 4px; + background: rgba(180, 77, 255, 0.25); color: #b44dff; margin-bottom: 4px; +} +.den-info-badge.recommended { background: rgba(0, 255, 136, 0.2); color: #00ff88; } +.den-info-desc { color: #aaa; margin: 4px 0; } +.den-info-tip { color: #666; font-size: 9px; font-style: italic; margin-top: 4px; } +.den-info-stars { color: #b44dff; font-size: 11px; letter-spacing: 1px; } +.den-section { + display: flex; align-items: center; gap: 6px; + margin: 10px 0 6px; font-size: 9px; font-weight: 600; + letter-spacing: 1.5px; text-transform: uppercase; color: #555; +} +.den-section::before, .den-section::after { + content: ""; flex: 1; height: 1px; + background: linear-gradient(90deg, transparent, rgba(180,77,255,0.2), transparent); +} +.den-slider-row { + display: flex; align-items: center; gap: 8px; margin: 5px 0; + transition: opacity 0.3s, max-height 0.3s; overflow: hidden; + max-height: 30px; +} +.den-slider-row.hidden { opacity: 0; max-height: 0; margin: 0; pointer-events: none; } +.den-slider-label { + min-width: 80px; font-size: 10px; color: #888; + text-align: right; cursor: help; +} +.den-slider-label:hover { color: #b44dff; } +.den-slider { + -webkit-appearance: none; appearance: none; + flex: 1; height: 5px; border-radius: 3px; + background: linear-gradient(90deg, #1a1030, #3d1d6e); + outline: none; cursor: pointer; +} +.den-slider:hover { background: linear-gradient(90deg, #1a1030, #5a2ea0); } +.den-slider::-webkit-slider-thumb { + -webkit-appearance: none; width: 14px; height: 14px; + border-radius: 50%; + background: radial-gradient(circle, #fff 30%, #b44dff 100%); + border: none; cursor: grab; + box-shadow: 0 0 8px rgba(180, 77, 255, 0.5); +} +.den-slider::-webkit-slider-thumb:hover { + box-shadow: 0 0 14px rgba(180, 77, 255, 0.8); +} +.den-slider::-moz-range-thumb { + width: 14px; height: 14px; border-radius: 50%; + background: radial-gradient(circle, #fff 30%, #b44dff 100%); + border: none; cursor: grab; + box-shadow: 0 0 8px rgba(180, 77, 255, 0.5); +} +.den-slider::-moz-range-track { + height: 5px; border-radius: 3px; + background: linear-gradient(90deg, #1a1030, #3d1d6e); +} +.den-slider-val { + min-width: 42px; text-align: right; font-size: 10px; + font-family: 'Consolas','Monaco',monospace; + color: #b44dff; font-weight: 600; +} +.den-tooltip { position: relative; } +.den-tooltip::after { + content: attr(data-tip); + position: absolute; bottom: 120%; left: 50%; + transform: translateX(-50%); + background: #1a1a2e; color: #aaa; + border: 1px solid rgba(180,77,255,0.3); + padding: 4px 8px; border-radius: 6px; + font-size: 9px; max-width: 220px; + white-space: normal; pointer-events: none; + opacity: 0; transition: opacity 0.2s; z-index: 999; line-height: 1.3; +} +.den-tooltip:hover::after { opacity: 1; } +.den-slider.green { background: linear-gradient(90deg, #0a1a10, #0d4a2a); } +.den-slider.green:hover { background: linear-gradient(90deg, #0a1a10, #1a6040); } +.den-slider.green::-webkit-slider-thumb { + background: radial-gradient(circle, #fff 30%, #00ff88 100%); + box-shadow: 0 0 8px rgba(0, 255, 136, 0.5); +} +.den-slider-row.green .den-slider-val { color: #00ff88; } +.den-slider.orange { background: linear-gradient(90deg, #1a1008, #6e3d1d); } +.den-slider.orange:hover { background: linear-gradient(90deg, #1a1008, #a05a2e); } +.den-slider.orange::-webkit-slider-thumb { + background: radial-gradient(circle, #fff 30%, #ff9f43 100%); + box-shadow: 0 0 8px rgba(255, 159, 67, 0.5); +} +.den-slider-row.orange .den-slider-val { color: #ff9f43; } +.den-sharpen-modes { + display: flex; gap: 4px; justify-content: center; margin-bottom: 6px; +} +.den-sharpen-btn { + padding: 4px 9px; border: 1px solid rgba(255, 159, 67, 0.25); + border-radius: 14px; background: rgba(255, 159, 67, 0.06); + color: #999; cursor: pointer; font-size: 10px; font-weight: 500; + transition: all 0.25s ease; outline: none; white-space: nowrap; +} +.den-sharpen-btn:hover { + background: rgba(255, 159, 67, 0.15); color: #ccc; + border-color: rgba(255, 159, 67, 0.5); +} +.den-sharpen-btn.active { + background: rgba(255, 159, 67, 0.25); border-color: #ff9f43; + color: #fff; box-shadow: 0 0 10px rgba(255, 159, 67, 0.3); +} +.den-sharpen-btn.off-mode.active { + background: rgba(100,100,100,0.15); border-color: #666; + color: #888; box-shadow: none; +} +`; + document.head.appendChild(style); +} + +/* โ”€โ”€ Build DOM โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +function buildUI() { + const el = document.createElement("div"); + el.className = "den-root"; + el.innerHTML = ` +
+
Advanced Image Denoiser
+
6 Algorithms โ€ข LAB Control โ€ข Sharpening
+
+
+
+
Core Settings
+
+
Channel Control
+
+
Method Settings
+
+
Blending
+
+
โœจ Sharpening
+
+
+ `; + + const mc = el.querySelector("#den-methods"); + for (const [key, m] of Object.entries(METHODS)) { + const btn = document.createElement("button"); + btn.className = "den-method-btn" + (key === "auto_blend" ? " recommended" : ""); + btn.dataset.method = key; + btn.textContent = `${m.icon} ${m.label}`; + mc.appendChild(btn); + } + + el.querySelector("#den-core").appendChild(createSliderRow("strength")); + el.querySelector("#den-core").appendChild(createSliderRow("preserve_detail")); + + const ch = el.querySelector("#den-channels"); + ch.appendChild(createSliderRow("luminance_strength")); + ch.appendChild(createSliderRow("color_strength")); + + const mp = el.querySelector("#den-method-params"); + for (const n of ["patch_size", "search_window", "sigma_spatial", "sigma_color", "wavelet_level"]) + mp.appendChild(createSliderRow(n)); + + el.querySelector("#den-blend").appendChild(createSliderRow("blend_original", true)); + + /* Sharpen mode buttons */ + const smc = el.querySelector("#den-sharpen-modes"); + for (const [key, m] of Object.entries(SHARPEN_MODES)) { + const btn = document.createElement("button"); + btn.className = "den-sharpen-btn" + (key === "off" ? " off-mode active" : ""); + btn.dataset.smode = key; + btn.textContent = `${m.icon} ${m.label}`; + smc.appendChild(btn); + } + + /* Sharpen sliders */ + const sp = el.querySelector("#den-sharpen-params"); + sp.appendChild(createSliderRow("sharpen_amount", false, true)); + sp.appendChild(createSliderRow("sharpen_radius", false, true)); + + return el; +} + +function createSliderRow(name, green = false, orange = false) { + const m = SLIDER_META[name]; + const row = document.createElement("div"); + const colorClass = orange ? " orange" : green ? " green" : ""; + row.className = "den-slider-row" + colorClass; + row.id = "den-row-" + name; + const isInt = Number.isInteger(m.step) && Number.isInteger(m.min); + const valStr = isInt ? String(m.def) : m.def.toFixed(2); + const sliderColor = orange ? ' orange' : green ? ' green' : ''; + row.innerHTML = ` + ${m.label} + + ${valStr}${m.unit ? ' ' + m.unit : ''} + `; + return row; +} + +function formatVal(name, val) { + const m = SLIDER_META[name]; + const isInt = Number.isInteger(m.step) && Number.isInteger(m.min); + const s = isInt ? String(Math.round(val)) : parseFloat(val).toFixed(2); + return m.unit ? s + " " + m.unit : s; +} + +function updateInfoBox(el, method) { + const m = METHODS[method]; + const stars = "โ˜…".repeat(m.rating) + "โ˜†".repeat(5 - m.rating); + const bc = method === "auto_blend" ? " recommended" : ""; + el.querySelector("#den-info").innerHTML = ` + ${m.badge} + ${stars} +
${m.desc}
+
๐Ÿ’ก ${m.tip}
+ `; +} + +function updateVisibility(el, method) { + const visible = new Set(METHOD_WIDGETS[method] || []); + for (const name of ALL_OPTIONAL) { + const row = el.querySelector("#den-row-" + name); + if (row) row.classList.toggle("hidden", !visible.has(name)); + } + const chSec = el.querySelector("#den-ch-section"); + if (chSec) chSec.style.display = (visible.has("luminance_strength") || visible.has("color_strength")) ? "" : "none"; + const mSec = el.querySelector("#den-method-section"); + if (mSec) mSec.style.display = ["patch_size", "search_window", "sigma_spatial", "sigma_color", "wavelet_level"].some(n => visible.has(n)) ? "" : "none"; +} + +function getWidget(node, name) { + return node.widgets?.find(w => w.name === name); +} + +/* โ”€โ”€ Aggressively hide a default ComfyUI widget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +function hideDefaultWidget(w) { + if (!w) return; + w.hidden = true; + w.type = "hidden"; + w.computeSize = () => [0, -4]; + // Prevent any drawing + w.draw = function () { }; + w.mouse = function () { return false; }; +} + +/* โ”€โ”€ Calculate proper node size based on visible DOM content โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +function calcNodeSize(el, node) { + // Count visible slider rows + const visibleRows = el.querySelectorAll(".den-slider-row:not(.hidden)").length; + const visibleSections = el.querySelectorAll(".den-section:not([style*='display: none'])").length; + + // Approximate heights + const headerH = 55; // header + subtitle + const methodBtnsH = 35; // method tab buttons + const infoBoxH = 80; // info panel + const sectionH = 24; // section divider + const sliderH = 26; // each slider row + const paddingH = 30; // root padding + gaps + + const domH = headerH + methodBtnsH + infoBoxH + + (visibleSections * sectionH) + + (visibleRows * sliderH) + + paddingH; + + const totalH = NODE_TITLE_HEIGHT + domH + 10; + const currentW = Math.max(node.size?.[0] || 0, NODE_MIN_WIDTH); + + return [currentW, totalH]; +} + +/* โ”€โ”€ Extension โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ +app.registerExtension({ + name: "AdvancedImageDenoiser.PremiumUI", + + nodeCreated(node) { + if (node.comfyClass !== "AdvancedImageDenoiser") return; + injectCSS(); + + const paramNames = ["method", "strength", "preserve_detail", + "luminance_strength", "color_strength", "patch_size", + "search_window", "sigma_spatial", "sigma_color", + "wavelet_level", "blend_original", + "sharpen_mode", "sharpen_amount", "sharpen_radius"]; + + /* Aggressively hide all default parameter widgets */ + for (const name of paramNames) { + hideDefaultWidget(getWidget(node, name)); + } + + /* Build custom UI */ + const el = buildUI(); + + /* Add DOM widget โ€” serialize:false so it doesn't conflict with hidden widgets */ + node.addDOMWidget("denoiser_ui", "customwidget", el, { + serialize: false, + }); + + /* Force minimum width */ + if (!node.size || node.size[0] < NODE_MIN_WIDTH) { + node.size = [NODE_MIN_WIDTH, 600]; + } + + /* โ”€โ”€ Sync: sliders โ†’ hidden widgets โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + for (const name of Object.keys(SLIDER_META)) { + const slider = el.querySelector("#den-s-" + name); + if (!slider) continue; + slider.addEventListener("input", () => { + const w = getWidget(node, name); + if (w) { w.value = parseFloat(slider.value); if (w.callback) w.callback(w.value); } + const ve = el.querySelector("#den-v-" + name); + if (ve) ve.textContent = formatVal(name, slider.value); + }); + } + + /* โ”€โ”€ Method buttons โ†’ hidden widget + UI update โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + const methodBtns = el.querySelectorAll(".den-method-btn"); + methodBtns.forEach(btn => { + btn.addEventListener("click", () => { + const method = btn.dataset.method; + const w = getWidget(node, "method"); + if (w) { w.value = method; if (w.callback) w.callback(method); } + methodBtns.forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + updateInfoBox(el, method); + updateVisibility(el, method); + + /* Resize: preserve current width, only adjust height */ + requestAnimationFrame(() => { + const newSize = calcNodeSize(el, node); + node.setSize(newSize); + node.setDirtyCanvas(true, true); + }); + }); + }); + + /* โ”€โ”€ Sharpen mode buttons โ†’ hidden widget โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + const sharpenBtns = el.querySelectorAll(".den-sharpen-btn"); + function updateSharpenVisibility(mode) { + const paramsEl = el.querySelector("#den-sharpen-params"); + if (paramsEl) paramsEl.style.display = (mode === "off") ? "none" : ""; + } + sharpenBtns.forEach(btn => { + btn.addEventListener("click", () => { + const mode = btn.dataset.smode; + const w = getWidget(node, "sharpen_mode"); + if (w) { w.value = mode; if (w.callback) w.callback(mode); } + sharpenBtns.forEach(b => b.classList.remove("active")); + btn.classList.add("active"); + updateSharpenVisibility(mode); + requestAnimationFrame(() => { + const newSize = calcNodeSize(el, node); + node.setSize(newSize); + node.setDirtyCanvas(true, true); + }); + }); + }); + + /* โ”€โ”€ Sync: hidden widgets โ†’ DOM (workflow load) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ */ + function syncFromWidgets() { + for (const name of Object.keys(SLIDER_META)) { + const w = getWidget(node, name); + const slider = el.querySelector("#den-s-" + name); + const ve = el.querySelector("#den-v-" + name); + if (w && slider) { + slider.value = w.value; + if (ve) ve.textContent = formatVal(name, w.value); + } + } + const mw = getWidget(node, "method"); + const cur = mw?.value || "auto_blend"; + methodBtns.forEach(b => b.classList.toggle("active", b.dataset.method === cur)); + updateInfoBox(el, cur); + updateVisibility(el, cur); + /* Sync sharpen mode */ + const sw = getWidget(node, "sharpen_mode"); + const curS = sw?.value || "off"; + sharpenBtns.forEach(b => b.classList.toggle("active", b.dataset.smode === curS)); + updateSharpenVisibility(curS); + } + + /* Hook onConfigure (workflow load) */ + const origConfigure = node.onConfigure; + node.onConfigure = function (info) { + origConfigure?.apply(this, arguments); + setTimeout(() => { + syncFromWidgets(); + const sz = calcNodeSize(el, node); + node.setSize(sz); + node.setDirtyCanvas?.(true, true); + }, 80); + }; + + /* Initial sync + size */ + setTimeout(() => { + syncFromWidgets(); + const sz = calcNodeSize(el, node); + node.setSize(sz); + node.setDirtyCanvas?.(true, true); + }, 150); + }, +}); diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..a516082 --- /dev/null +++ b/nodes.py @@ -0,0 +1,363 @@ +""" +Advanced Image Denoiser โ€” ComfyUI Custom Node +============================================== +6 denoising algorithms with LAB-space luminance/chrominance control +and high-frequency detail preservation. +""" + +import numpy as np +import torch +import cv2 + + +# โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def tensor_to_numpy(tensor): + """ComfyUI IMAGE [B,H,W,C] float32 0โ€“1 โ†’ numpy [H,W,C] uint8 RGB.""" + return np.clip(tensor[0].cpu().numpy() * 255.0, 0, 255).astype(np.uint8) + + +def numpy_to_tensor(img): + """numpy [H,W,C] uint8 โ†’ ComfyUI IMAGE [1,H,W,C] float32 0โ€“1.""" + return torch.from_numpy(img.astype(np.float32) / 255.0).unsqueeze(0) + + +def ensure_odd(n, minimum=1): + """Clamp to >= minimum and ensure odd.""" + n = max(minimum, int(n)) + return n if n % 2 == 1 else n + 1 + + +def restore_detail(original, denoised, amount): + """Blend high-frequency detail from original back into denoised result.""" + if amount <= 0.0: + return denoised + orig_f = original.astype(np.float32) + den_f = denoised.astype(np.float32) + blur = cv2.GaussianBlur(orig_f, (0, 0), sigmaX=3.0) + detail = orig_f - blur + return np.clip(den_f + detail * amount, 0, 255).astype(np.uint8) + + +# โ”€โ”€ Per-Channel Denoising (for LAB split) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def denoise_channel(ch, method, strength, **p): + """Denoise a single grayscale channel.""" + if strength <= 0.01: + return ch.copy() + + if method == "nlm": + h = max(1.0, strength * 30.0) + tw = ensure_odd(p.get("patch_size", 7), 3) + sw = ensure_odd(p.get("search_window", 21), 7) + return cv2.fastNlMeansDenoising( + ch, None, h=h, templateWindowSize=tw, searchWindowSize=sw + ) + + elif method == "bilateral": + d = max(1, int(5 + strength * 20)) + sc = p.get("sigma_color", 75.0) + ss = p.get("sigma_spatial", 75.0) + return cv2.bilateralFilter(ch, d, sc, ss) + + elif method == "gaussian": + ks = ensure_odd(int(strength * 12), 1) + sigma = max(0.1, strength * 5.0) + return cv2.GaussianBlur(ch, (ks, ks), sigmaX=sigma) + + elif method == "median": + ks = ensure_odd(int(strength * 10), 3) + ks = min(ks, 31) + return cv2.medianBlur(ch, ks) + + return ch.copy() + + +def denoise_lab_split(img_bgr, method, lum_str, chroma_str, **params): + """Denoise in LAB space with separate luminance/chrominance strength.""" + lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB) + L, A, B = cv2.split(lab) + + L = denoise_channel(L, method, lum_str, **params) + A = denoise_channel(A, method, chroma_str, **params) + B = denoise_channel(B, method, chroma_str, **params) + + return cv2.cvtColor(cv2.merge([L, A, B]), cv2.COLOR_LAB2BGR) + + +# โ”€โ”€ Full-Image Denoise Methods โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def denoise_nlm_color(img_bgr, lum_str, chroma_str, patch_size, search_window): + """Non-Local Means with native luminance/chrominance control. + Uses positional args for OpenCV 4.13+ compatibility.""" + h = max(1.0, lum_str * 30.0) + h_c = max(1.0, chroma_str * 30.0) + tw = ensure_odd(patch_size, 3) + sw = ensure_odd(search_window, 7) + # Positional args: src, dst, h, hForColorComponents, templateWindowSize, searchWindowSize + return cv2.fastNlMeansDenoisingColored(img_bgr, None, h, h_c, tw, sw) + + +def denoise_bilateral_full(img_bgr, strength, sigma_spatial, sigma_color): + """Bilateral filter on full BGR image.""" + d = max(1, int(5 + strength * 20)) + return cv2.bilateralFilter(img_bgr, d, sigma_color, sigma_spatial) + + +def denoise_wavelet_full(img_bgr, strength, wavelet_level): + """Wavelet denoising (requires scikit-image; falls back to Gaussian).""" + try: + from skimage.restoration import denoise_wavelet as _sk_wav + from skimage import img_as_float, img_as_ubyte + + rgb_f = img_as_float(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)) + sigma = max(0.001, strength * 0.3) + + out = _sk_wav( + rgb_f, method="BayesShrink", mode="soft", + wavelet_levels=wavelet_level, sigma=sigma, + channel_axis=-1, rescale_sigma=True + ) + return cv2.cvtColor(img_as_ubyte(np.clip(out, 0, 1)), cv2.COLOR_RGB2BGR) + + except ImportError: + print("[AdvancedDenoiser] scikit-image not available โ€” Gaussian fallback") + ks = ensure_odd(int(strength * 12), 1) + return cv2.GaussianBlur(img_bgr, (ks, ks), sigmaX=max(0.1, strength * 5.0)) + + +def denoise_gaussian_full(img_bgr, strength): + """Simple Gaussian blur.""" + if strength <= 0.01: + return img_bgr.copy() + ks = ensure_odd(int(strength * 12), 1) + return cv2.GaussianBlur(img_bgr, (ks, ks), sigmaX=max(0.1, strength * 5.0)) + + +def denoise_median_full(img_bgr, strength): + """Median filter for impulse noise.""" + if strength <= 0.01: + return img_bgr.copy() + ks = ensure_odd(int(strength * 10), 3) + ks = min(ks, 31) + return cv2.medianBlur(img_bgr, ks) + + +def denoise_auto_blend(img_bgr, strength, lum_str, chroma_str, + preserve_detail, patch_size, search_window, + sigma_spatial, sigma_color): + """Smart NLM + Bilateral blend weighted by preserve_detail.""" + nlm = denoise_nlm_color(img_bgr, lum_str, chroma_str, patch_size, search_window) + bilateral = denoise_lab_split( + img_bgr, "bilateral", lum_str, chroma_str, + sigma_spatial=sigma_spatial, sigma_color=sigma_color + ) + a = preserve_detail # 0 โ†’ full NLM, 1 โ†’ full bilateral + return cv2.addWeighted(nlm, 1.0 - a, bilateral, a, 0) + + +# โ”€โ”€ Sharpening โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +def sharpen_unsharp_mask(img_bgr, amount, radius): + """Classic unsharp mask sharpening. + amount: strength 0-1, radius: blur kernel sigma.""" + if amount <= 0.01: + return img_bgr + sigma = max(0.5, radius * 3.0) + blurred = cv2.GaussianBlur(img_bgr.astype(np.float32), (0, 0), sigmaX=sigma) + sharpened = img_bgr.astype(np.float32) + amount * 2.0 * (img_bgr.astype(np.float32) - blurred) + return np.clip(sharpened, 0, 255).astype(np.uint8) + + +def sharpen_high_pass(img_bgr, amount, radius): + """High-pass filter sharpening โ€” great for fine micro-detail. + Extracts high frequencies and adds them back.""" + if amount <= 0.01: + return img_bgr + sigma = max(0.5, radius * 5.0) + img_f = img_bgr.astype(np.float32) + low_pass = cv2.GaussianBlur(img_f, (0, 0), sigmaX=sigma) + high_pass = img_f - low_pass + result = img_f + high_pass * amount * 3.0 + return np.clip(result, 0, 255).astype(np.uint8) + + +def sharpen_luminance_only(img_bgr, amount, radius): + """Sharpen only the luminance channel (LAB L) to avoid color fringing.""" + if amount <= 0.01: + return img_bgr + lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB) + L, A, B = cv2.split(lab) + sigma = max(0.5, radius * 3.0) + L_f = L.astype(np.float32) + blurred = cv2.GaussianBlur(L_f, (0, 0), sigmaX=sigma) + L_sharp = L_f + amount * 2.0 * (L_f - blurred) + L_out = np.clip(L_sharp, 0, 255).astype(np.uint8) + return cv2.cvtColor(cv2.merge([L_out, A, B]), cv2.COLOR_LAB2BGR) + + +def apply_sharpening(img_bgr, sharpen_mode, sharpen_amount, sharpen_radius): + """Dispatch to the correct sharpening method.""" + if sharpen_mode == "off" or sharpen_amount <= 0.01: + return img_bgr + if sharpen_mode == "unsharp_mask": + return sharpen_unsharp_mask(img_bgr, sharpen_amount, sharpen_radius) + elif sharpen_mode == "high_pass": + return sharpen_high_pass(img_bgr, sharpen_amount, sharpen_radius) + elif sharpen_mode == "luminance_only": + return sharpen_luminance_only(img_bgr, sharpen_amount, sharpen_radius) + return img_bgr + + +# โ”€โ”€ ComfyUI Node โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + +class AdvancedImageDenoiser: + """๐Ÿงน Advanced Image Denoiser""" + + METHODS = [ + "auto_blend", + "non_local_means", + "bilateral", + "wavelet", + "gaussian", + "median", + ] + + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "image": ("IMAGE",), + "method": (cls.METHODS, {"default": "auto_blend"}), + "strength": ("FLOAT", { + "default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + }), + "preserve_detail": ("FLOAT", { + "default": 0.70, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + }), + }, + "optional": { + "luminance_strength": ("FLOAT", { + "default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + }), + "color_strength": ("FLOAT", { + "default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + }), + "patch_size": ("INT", { + "default": 7, "min": 3, "max": 15, "step": 2, + }), + "search_window": ("INT", { + "default": 21, "min": 7, "max": 35, "step": 2, + }), + "sigma_spatial": ("FLOAT", { + "default": 75.0, "min": 1.0, "max": 300.0, "step": 1.0, + }), + "sigma_color": ("FLOAT", { + "default": 75.0, "min": 1.0, "max": 300.0, "step": 1.0, + }), + "wavelet_level": ("INT", { + "default": 2, "min": 1, "max": 5, "step": 1, + }), + "blend_original": ("FLOAT", { + "default": 0.00, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + }), + "sharpen_mode": (["off", "unsharp_mask", "high_pass", "luminance_only"], { + "default": "off", + }), + "sharpen_amount": ("FLOAT", { + "default": 0.00, "min": 0.0, "max": 1.0, "step": 0.01, + "display": "slider", + }), + "sharpen_radius": ("FLOAT", { + "default": 0.30, "min": 0.05, "max": 1.0, "step": 0.01, + "display": "slider", + }), + }, + } + + RETURN_TYPES = ("IMAGE",) + RETURN_NAMES = ("image",) + FUNCTION = "denoise" + CATEGORY = "image/denoising" + + DESCRIPTION = ( + "Advanced image denoising with 6 algorithms, separate luminance/" + "chrominance control, detail preservation, and fine-detail sharpening." + ) + + def denoise(self, image, method, strength, preserve_detail, + luminance_strength=0.5, color_strength=0.5, + patch_size=7, search_window=21, + sigma_spatial=75.0, sigma_color=75.0, + wavelet_level=2, blend_original=0.0, + sharpen_mode="off", sharpen_amount=0.0, + sharpen_radius=0.3): + """Process each image in the batch.""" + + results = [] + for i in range(image.shape[0]): + img_np = tensor_to_numpy(image[i:i + 1]) # [H,W,C] RGB u8 + img_bgr = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR) + original = img_bgr.copy() + + # โ”€โ”€ dispatch โ”€โ”€ + if method == "non_local_means": + # NLM natively supports separate lum/chroma via h params + out = denoise_nlm_color( + img_bgr, luminance_strength, color_strength, + patch_size, search_window + ) + + elif method == "bilateral": + # Per-channel LAB split for separate lum/chroma control + out = denoise_lab_split( + img_bgr, "bilateral", luminance_strength, color_strength, + sigma_spatial=sigma_spatial, sigma_color=sigma_color + ) + + elif method == "wavelet": + out = denoise_wavelet_full(img_bgr, strength, wavelet_level) + + elif method == "gaussian": + out = denoise_lab_split( + img_bgr, "gaussian", luminance_strength, color_strength + ) + + elif method == "median": + out = denoise_lab_split( + img_bgr, "median", luminance_strength, color_strength + ) + + elif method == "auto_blend": + out = denoise_auto_blend( + img_bgr, strength, luminance_strength, color_strength, + preserve_detail, patch_size, search_window, + sigma_spatial, sigma_color + ) + else: + out = img_bgr + + # โ”€โ”€ detail preservation โ”€โ”€ + if preserve_detail > 0.0 and method != "auto_blend": + out = restore_detail(original, out, preserve_detail) + + # โ”€โ”€ blend with original โ”€โ”€ + if blend_original > 0.0: + out = cv2.addWeighted( + out, 1.0 - blend_original, original, blend_original, 0 + ) + + # โ”€โ”€ sharpening (post-denoise) โ”€โ”€ + if sharpen_mode != "off" and sharpen_amount > 0.01: + out = apply_sharpening(out, sharpen_mode, sharpen_amount, sharpen_radius) + + # โ”€โ”€ back to RGB tensor โ”€โ”€ + out_rgb = cv2.cvtColor(out, cv2.COLOR_BGR2RGB) + results.append(numpy_to_tensor(out_rgb)) + + return (torch.cat(results, dim=0),) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..76a8fb9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +opencv-python>=4.8.0 +scikit-image>=0.21.0 +numpy>=1.24.0