Files
CoCoandClaude Opus 4.6 62b62d2a96 chore: add ruff config and fix 573 linting issues
- Add ruff configuration to pyproject.toml (py310, 120 char lines)
- Fix trailing whitespace (W291/W293), modernize type annotations (UP006/UP045)
- Remove unused imports (F401), simplify dict iteration (SIM118)
- Use f-string conversion flags (RUF010), modernize exception aliases (UP024)
- Replace try/except pass with contextlib.suppress (SIM105)
- Prefix unused variables with underscore (F841/RUF059)
- Convert lambdas to def statements (E731)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:06:33 +00:00

148 lines
5.1 KiB
Python

import numpy as np
import logging
try:
from ..utils.debug_utils import setup_logging
setup_logging()
except ImportError:
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
from ..utils.debug_utils import debug_log
except ImportError:
def debug_log(logger, level, simple_msg, verbose_msg=None, **kwargs):
getattr(logger, level.lower())(simple_msg)
from comfy_api.latest import io
class HistogramNode(io.ComfyNode):
"""Computes and displays histogram data from IMAGE tensors."""
@classmethod
def define_schema(cls):
return io.Schema(
node_id="HistogramNode",
display_name="CoCo Histogram",
description="Displays an interactive histogram of image tonal distribution with per-channel statistics.",
category="COCO Tools/Analysis",
is_output_node=True,
inputs=[
io.Image.Input("image", tooltip="Image tensor to analyze"),
io.Int.Input("num_bins", default=256, min=32, max=1024, tooltip="Number of histogram bins"),
],
outputs=[],
)
@classmethod
def execute(cls, image, num_bins=256) -> io.NodeOutput:
try:
frame = image[0].cpu().numpy()
height, width, channels = frame.shape
original_channels = channels
if channels < 3:
debug_log(logger, "warning", f"Histogram requires at least 3 channels, got {channels}")
frame = np.repeat(frame, 3 // channels + 1, axis=2)[:, :, :3]
channels = 3
data_min = float(frame.min())
data_max = float(frame.max())
bin_range = (min(data_min, 0.0), max(data_max, 1.0))
bit_depth = _estimate_bit_depth(frame)
result = {
"image_info": {
"width": width,
"height": height,
"channels": original_channels,
"batch_size": int(image.shape[0]),
"data_range": [round(data_min, 6), round(data_max, 6)],
"bit_depth": bit_depth,
}
}
channel_names = ["red", "green", "blue"]
if channels == 4:
channel_names.append("alpha")
stats = {}
for i, name in enumerate(channel_names):
ch = frame[:, :, i].ravel()
counts, bin_edges = np.histogram(ch, bins=num_bins, range=bin_range)
result[name] = counts.tolist()
stats[name] = _compute_channel_stats(ch)
result["bins"] = [round(float(b), 6) for b in bin_edges]
lum = 0.2126 * frame[:, :, 0] + 0.7152 * frame[:, :, 1] + 0.0722 * frame[:, :, 2]
lum_flat = lum.ravel()
lum_counts, _ = np.histogram(lum_flat, bins=num_bins, range=bin_range)
result["luminance"] = lum_counts.tolist()
stats["luminance"] = _compute_channel_stats(lum_flat)
if channels < 4:
result["alpha"] = None
stats["alpha"] = None
result["stats"] = stats
debug_log(logger, "debug",
f"Histogram computed: {width}x{height}, {channels}ch, range=[{data_min:.4f}, {data_max:.4f}]")
return io.NodeOutput(ui={"histogram_data": [result]})
except Exception as e:
debug_log(logger, "error", "Histogram computation failed", f"Error computing histogram: {e!s}")
raise
@classmethod
def fingerprint_inputs(cls, **kwargs):
return float("NaN")
def _estimate_bit_depth(frame):
"""Estimate the source bit depth of a normalized float32 image tensor.
Samples pixel values and checks whether they snap to discrete levels
consistent with 8-bit (256), 16-bit (65536), or continuous 32-bit float data.
"""
flat = frame[:, :, 0].ravel()
max_samples = 100000
if len(flat) > max_samples:
indices = np.linspace(0, len(flat) - 1, max_samples, dtype=int)
flat = flat[indices]
# Check 8-bit: values should be multiples of 1/255
scaled_8 = flat * 255.0
residuals_8 = np.abs(scaled_8 - np.round(scaled_8))
if np.max(residuals_8) < 0.01:
return 8
# Check 16-bit: values should be multiples of 1/65535
scaled_16 = flat * 65535.0
residuals_16 = np.abs(scaled_16 - np.round(scaled_16))
if np.max(residuals_16) < 0.5:
return 16
return 32
def _compute_channel_stats(channel_data):
"""Compute statistics for a single channel."""
total_pixels = len(channel_data)
return {
"min": round(float(np.min(channel_data)), 6),
"max": round(float(np.max(channel_data)), 6),
"mean": round(float(np.mean(channel_data)), 6),
"median": round(float(np.median(channel_data)), 6),
"stddev": round(float(np.std(channel_data)), 6),
"p5": round(float(np.percentile(channel_data, 5)), 6),
"p95": round(float(np.percentile(channel_data, 95)), 6),
"clip_low": round(float(np.sum(channel_data <= 0.0) / total_pixels * 100), 2),
"clip_high": round(float(np.sum(channel_data >= 1.0) / total_pixels * 100), 2),
}