Initial release: Advanced Image Denoiser

This commit is contained in:
MONKEYFOREVER2
2026-03-01 10:18:33 +01:00
commit c2a84dddb3
5 changed files with 971 additions and 0 deletions
+38
View File
@@ -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
+25
View File
@@ -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"]
+542
View File
@@ -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 = `
<div class="den-header">
<div class="den-title">Advanced Image Denoiser</div>
<div class="den-subtitle">6 Algorithms • LAB Control • Sharpening</div>
</div>
<div class="den-methods" id="den-methods"></div>
<div class="den-info" id="den-info"></div>
<div class="den-section">Core Settings</div>
<div id="den-core"></div>
<div class="den-section" id="den-ch-section">Channel Control</div>
<div id="den-channels"></div>
<div class="den-section" id="den-method-section">Method Settings</div>
<div id="den-method-params"></div>
<div class="den-section">Blending</div>
<div id="den-blend"></div>
<div class="den-section">✨ Sharpening</div>
<div class="den-sharpen-modes" id="den-sharpen-modes"></div>
<div id="den-sharpen-params"></div>
`;
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 = `
<span class="den-slider-label den-tooltip" data-tip="${m.desc}">${m.label}</span>
<input type="range" class="den-slider${sliderColor}"
id="den-s-${name}" min="${m.min}" max="${m.max}" step="${m.step}" value="${m.def}">
<span class="den-slider-val" id="den-v-${name}">${valStr}${m.unit ? ' ' + m.unit : ''}</span>
`;
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 = `
<span class="den-info-badge${bc}">${m.badge}</span>
<span class="den-info-stars">${stars}</span>
<div class="den-info-desc">${m.desc}</div>
<div class="den-info-tip">💡 ${m.tip}</div>
`;
}
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);
},
});
+363
View File
@@ -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),)
+3
View File
@@ -0,0 +1,3 @@
opencv-python>=4.8.0
scikit-image>=0.21.0
numpy>=1.24.0