diff --git a/js/histogram.js b/js/histogram.js new file mode 100644 index 0000000..7a709c6 --- /dev/null +++ b/js/histogram.js @@ -0,0 +1,305 @@ +import { app } from "../../../scripts/app.js"; + +const CANVAS_WIDTH = 400; +const CANVAS_HEIGHT = 200; +const COLORS = { + bg: "#1a1a1a", + grid: "#333333", + text: "#cccccc", + textDim: "#888888", + red: { fill: "rgba(220,50,50,0.4)", stroke: "rgba(220,50,50,0.8)", solid: "rgba(220,50,50,0.7)" }, + green: { fill: "rgba(50,200,50,0.4)", stroke: "rgba(50,200,50,0.8)", solid: "rgba(50,200,50,0.7)" }, + blue: { fill: "rgba(50,100,220,0.4)", stroke: "rgba(50,100,220,0.8)", solid: "rgba(50,100,220,0.7)" }, + alpha: { fill: "rgba(255,255,255,0.4)", stroke: "rgba(255,255,255,0.8)", solid: "rgba(255,255,255,0.7)" }, + luminance: { fill: "rgba(200,200,200,0.4)", stroke: "rgba(200,200,200,0.8)", solid: "rgba(200,200,200,0.7)" }, +}; +const MODES = ["unified", "rgb", "red", "green", "blue", "alpha"]; +const MODE_LABELS = { unified: "L", rgb: "RGB", red: "R", green: "G", blue: "B", alpha: "A" }; + +function drawHistogram(canvas, data, mode, useLog) { + const ctx = canvas.getContext("2d"); + const w = canvas.width; + const h = canvas.height; + + ctx.fillStyle = COLORS.bg; + ctx.fillRect(0, 0, w, h); + + if (!data) { + ctx.fillStyle = COLORS.textDim; + ctx.font = "12px monospace"; + ctx.textAlign = "center"; + ctx.fillText("No histogram data", w / 2, h / 2); + return; + } + + // Draw grid + ctx.strokeStyle = COLORS.grid; + ctx.lineWidth = 0.5; + for (let i = 1; i < 4; i++) { + const x = (w * i) / 4; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, h); + ctx.stroke(); + } + for (let i = 1; i < 4; i++) { + const y = (h * i) / 4; + ctx.beginPath(); + ctx.moveTo(0, y); + ctx.lineTo(w, y); + ctx.stroke(); + } + + // Determine which channels to draw + let channels = []; + if (mode === "unified") { + channels = [{ key: "luminance", colors: COLORS.luminance, solid: true }]; + } else if (mode === "rgb") { + channels = [ + { key: "red", colors: COLORS.red, solid: false }, + { key: "green", colors: COLORS.green, solid: false }, + { key: "blue", colors: COLORS.blue, solid: false }, + ]; + } else { + channels = [{ key: mode, colors: COLORS[mode], solid: true }]; + } + + // Find global max for scaling + let globalMax = 0; + for (const ch of channels) { + const counts = data[ch.key]; + if (!counts) continue; + for (const c of counts) { + const val = useLog ? Math.log1p(c) : c; + if (val > globalMax) globalMax = val; + } + } + if (globalMax === 0) return; + + // Draw each channel + for (const ch of channels) { + const counts = data[ch.key]; + if (!counts) continue; + + const binCount = counts.length; + const binWidth = w / binCount; + + ctx.fillStyle = ch.solid ? ch.colors.solid : ch.colors.fill; + ctx.strokeStyle = ch.colors.stroke; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, h); + + for (let i = 0; i < binCount; i++) { + const val = useLog ? Math.log1p(counts[i]) : counts[i]; + const barH = (val / globalMax) * h; + const x = i * binWidth; + ctx.lineTo(x, h - barH); + } + + ctx.lineTo(w, h); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } +} + +function formatStat(val) { + if (val === null || val === undefined) return "N/A"; + return val.toFixed(4); +} + +function createStatsSpan(className, text) { + const span = document.createElement("span"); + span.className = className; + span.textContent = text; + return span; +} + +app.registerExtension({ + name: "CocoTools.Histogram", + async beforeRegisterNodeDef(nodeType, nodeData) { + if (nodeData.name !== "HistogramNode") return; + + const onNodeCreated = nodeType.prototype.onNodeCreated; + const onExecuted = nodeType.prototype.onExecuted; + + nodeType.prototype.onNodeCreated = function () { + const result = onNodeCreated?.apply(this, arguments); + + this._histogramData = null; + this._histogramMode = "rgb"; + this._histogramLog = false; + + // Create container + const container = document.createElement("div"); + container.style.cssText = "display:flex;flex-direction:column;align-items:center;gap:4px;padding:4px;"; + + // Stats bar (top) — using safe DOM methods + const statsTop = document.createElement("div"); + statsTop.style.cssText = "width:100%;font:10px monospace;color:#ccc;display:flex;justify-content:space-between;padding:0 4px;"; + const rangeSpan = createStatsSpan("hist-range", "Range: --"); + const clipSpan = createStatsSpan("hist-clip", "Clip: --"); + statsTop.appendChild(rangeSpan); + statsTop.appendChild(clipSpan); + container.appendChild(statsTop); + + // Canvas + const canvas = document.createElement("canvas"); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + canvas.style.cssText = "width:100%;border-radius:3px;"; + container.appendChild(canvas); + + // Stats bar (bottom) — using safe DOM methods + const statsBottom = document.createElement("div"); + statsBottom.style.cssText = "width:100%;font:10px monospace;color:#888;padding:0 4px;line-height:1.4;"; + const statsLine1 = document.createElement("div"); + statsLine1.textContent = "Mean: -- StdDev: -- Median: --"; + const statsLine2 = document.createElement("div"); + statsLine2.textContent = "P5: -- P95: --"; + statsBottom.appendChild(statsLine1); + statsBottom.appendChild(statsLine2); + container.appendChild(statsBottom); + + // Mode buttons + const btnRow = document.createElement("div"); + btnRow.style.cssText = "display:flex;gap:3px;align-items:center;flex-wrap:wrap;"; + + for (const mode of MODES) { + const btn = document.createElement("button"); + btn.textContent = MODE_LABELS[mode] || mode; + btn.dataset.mode = mode; + btn.style.cssText = "padding:2px 6px;font:10px monospace;border:1px solid #555;background:#2a2a2a;color:#ccc;border-radius:3px;cursor:pointer;"; + btn.addEventListener("click", () => { + this._histogramMode = mode; + this._updateHistogram(); + }); + btnRow.appendChild(btn); + } + + // Separator + const sep = document.createElement("span"); + sep.style.cssText = "color:#555;margin:0 2px;"; + sep.textContent = "|"; + btnRow.appendChild(sep); + + // Log/Lin toggle + const logBtn = document.createElement("button"); + logBtn.textContent = "Lin"; + logBtn.dataset.role = "logToggle"; + logBtn.style.cssText = "padding:2px 6px;font:10px monospace;border:1px solid #555;background:#2a2a2a;color:#ccc;border-radius:3px;cursor:pointer;"; + logBtn.addEventListener("click", () => { + this._histogramLog = !this._histogramLog; + this._updateHistogram(); + }); + btnRow.appendChild(logBtn); + container.appendChild(btnRow); + + // Store references + this._histCanvas = canvas; + this._histRangeSpan = rangeSpan; + this._histClipSpan = clipSpan; + this._histStatsLine1 = statsLine1; + this._histStatsLine2 = statsLine2; + this._histBtnRow = btnRow; + + // Add as ComfyUI widget + const widget = this.addDOMWidget("histogram_display", "custom", container, { + serialize: false, + getMinHeight: () => CANVAS_HEIGHT + 80, + }); + widget.computeSize = () => [CANVAS_WIDTH, CANVAS_HEIGHT + 80]; + + this._updateHistogram = () => { + const data = this._histogramData; + drawHistogram(this._histCanvas, data, this._histogramMode, this._histogramLog); + + // Update button highlights + const buttons = this._histBtnRow.querySelectorAll("button[data-mode]"); + buttons.forEach((btn) => { + const isActive = btn.dataset.mode === this._histogramMode; + btn.style.background = isActive ? "#4a4a4a" : "#2a2a2a"; + btn.style.borderColor = isActive ? "#888" : "#555"; + }); + + // Update log toggle + const logToggle = this._histBtnRow.querySelector('[data-role="logToggle"]'); + if (logToggle) { + logToggle.textContent = this._histogramLog ? "Log" : "Lin"; + logToggle.style.background = this._histogramLog ? "#4a4a4a" : "#2a2a2a"; + } + + // Hide alpha button if no alpha channel + const alphaBtn = this._histBtnRow.querySelector('[data-mode="alpha"]'); + if (alphaBtn && data) { + alphaBtn.style.display = data.alpha ? "" : "none"; + } + + // Update stats + this._updateStats(); + }; + + this._updateStats = () => { + const data = this._histogramData; + if (!data || !data.stats) { + this._histRangeSpan.textContent = "Range: --"; + this._histClipSpan.textContent = "Clip: --"; + this._histStatsLine1.textContent = "Mean: -- StdDev: -- Median: --"; + this._histStatsLine2.textContent = "P5: -- P95: --"; + return; + } + + const info = data.image_info || {}; + const range = info.data_range || [0, 1]; + this._histRangeSpan.textContent = + "Range: [" + range[0].toFixed(4) + " - " + range[1].toFixed(4) + "] " + (info.width || "?") + "x" + (info.height || "?"); + + // Get stats for current mode + const mode = this._histogramMode; + let statKeys = []; + if (mode === "unified") statKeys = ["luminance"]; + else if (mode === "rgb") statKeys = ["red", "green", "blue"]; + else statKeys = [mode]; + + // Average clip across displayed channels + let clipLow = 0, clipHigh = 0, count = 0; + for (const key of statKeys) { + const s = data.stats[key]; + if (!s) continue; + clipLow += s.clip_low; + clipHigh += s.clip_high; + count++; + } + if (count > 0) { + clipLow /= count; + clipHigh /= count; + } + this._histClipSpan.textContent = "Clip: " + clipLow.toFixed(1) + "%/" + clipHigh.toFixed(1) + "%"; + + // Show first channel stats in detail + const primary = data.stats[statKeys[0]]; + if (primary) { + this._histStatsLine1.textContent = + "Mean: " + formatStat(primary.mean) + " StdDev: " + formatStat(primary.stddev) + " Median: " + formatStat(primary.median); + this._histStatsLine2.textContent = + "P5: " + formatStat(primary.p5) + " P95: " + formatStat(primary.p95); + } + }; + + // Initial draw + drawHistogram(canvas, null, "rgb", false); + + return result; + }; + + nodeType.prototype.onExecuted = function (message) { + if (onExecuted) onExecuted.apply(this, arguments); + + if (message && message.histogram_data && message.histogram_data.length > 0) { + this._histogramData = message.histogram_data[0]; + this._updateHistogram(); + } + }; + }, +}); diff --git a/js/index.js b/js/index.js index a2d4d75..f717d92 100644 --- a/js/index.js +++ b/js/index.js @@ -1,5 +1,6 @@ // CoCoTools_IO JavaScript Extensions Entry Point import "./saver.js"; import "./load_exr_layer_by_name.js"; +import "./histogram.js"; console.log("CoCoTools_IO extensions loaded successfully"); \ No newline at end of file