From 1471d04016ba490e70356643a5dfd9081da57f84 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 17:12:58 +0100 Subject: [PATCH 1/7] Update __init__.py --- __init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/__init__.py b/__init__.py index aa2842c..132f4a6 100644 --- a/__init__.py +++ b/__init__.py @@ -1,13 +1,13 @@ -from .sharp_node import SharpFrameSelector +from .sharp_node import SharpnessAnalyzer, SharpFrameSelector -# Map the class to a name ComfyUI recognizes NODE_CLASS_MAPPINGS = { + "SharpnessAnalyzer": SharpnessAnalyzer, "SharpFrameSelector": SharpFrameSelector } -# Map the internal name to a human-readable label in the menu NODE_DISPLAY_NAME_MAPPINGS = { - "SharpFrameSelector": "Sharp Frame Selector (Video)" + "SharpnessAnalyzer": "1. Sharpness Analyzer", + "SharpFrameSelector": "2. Sharp Frame Selector" } __all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] \ No newline at end of file -- 2.49.1 From 0df11447abb7f41bf7f12a2906aa868a5d2027b4 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 17:13:57 +0100 Subject: [PATCH 2/7] Update sharp_node.py --- sharp_node.py | 83 +++++++++++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/sharp_node.py b/sharp_node.py index b430dc7..a50cbb1 100644 --- a/sharp_node.py +++ b/sharp_node.py @@ -2,12 +2,43 @@ import torch import numpy as np import cv2 -class SharpFrameSelector: +# --- NODE 1: ANALYZER (Calculates the scores) --- +class SharpnessAnalyzer: @classmethod def INPUT_TYPES(s): return { "required": { "images": ("IMAGE",), + } + } + + RETURN_TYPES = ("SHARPNESS_SCORES",) + RETURN_NAMES = ("scores",) + FUNCTION = "analyze_sharpness" + CATEGORY = "SharpFrames" + + def analyze_sharpness(self, images): + print(f"[SharpAnalyzer] Calculating scores for {len(images)} frames...") + scores = [] + + # This loop is fast if 'images' are small (resized) + for i in range(len(images)): + img_np = (images[i].cpu().numpy() * 255).astype(np.uint8) + gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) + score = cv2.Laplacian(gray, cv2.CV_64F).var() + scores.append(score) + + # We pass the list of scores to the next node + return (scores,) + +# --- NODE 2: SELECTOR (Uses scores to filter high-res images) --- +class SharpFrameSelector: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "images": ("IMAGE",), # Connect High-Res images here + "scores": ("SHARPNESS_SCORES",), # Connect output of Analyzer here "selection_method": (["batched", "best_n"],), "batch_size": ("INT", {"default": 24, "min": 1, "max": 10000, "step": 1}), "num_frames": ("INT", {"default": 10, "min": 1, "max": 10000, "step": 1}), @@ -16,57 +47,37 @@ class SharpFrameSelector: RETURN_TYPES = ("IMAGE", "INT") RETURN_NAMES = ("selected_images", "count") - FUNCTION = "process_images" + FUNCTION = "select_frames" CATEGORY = "SharpFrames" - def process_images(self, images, selection_method, batch_size, num_frames): - # images is a Tensor: [Batch, Height, Width, Channels] (RGB, 0.0-1.0) - - total_input_frames = len(images) - print(f"[SharpSelector] Analyzing {total_input_frames} frames...") - - scores = [] - - # We must iterate to calculate score per frame - # OpenCV runs on CPU, so we must move frame-by-frame or batch-to-cpu - for i in range(total_input_frames): - # 1. Grab single frame, move to CPU, convert to numpy - # 2. Scale 0.0-1.0 to 0-255 - img_np = (images[i].cpu().numpy() * 255).astype(np.uint8) - - # 3. Convert RGB to Gray for Laplacian - gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) - - # 4. Calculate Variance of Laplacian - score = cv2.Laplacian(gray, cv2.CV_64F).var() - scores.append(score) + def select_frames(self, images, scores, selection_method, batch_size, num_frames): + # Validation + if len(images) != len(scores): + print(f"[SharpSelector] WARNING: Frame count mismatch! Images: {len(images)}, Scores: {len(scores)}") + # If mismatch (e.g. latent optimization), we truncate to the shorter length + min_len = min(len(images), len(scores)) + images = images[:min_len] + scores = scores[:min_len] selected_indices = [] - # --- SELECTION LOGIC --- + # --- SELECTION LOGIC (Same as before, but using pre-calculated scores) --- if selection_method == "batched": - # Best frame every N frames - for i in range(0, total_input_frames, batch_size): - chunk_end = min(i + batch_size, total_input_frames) + total_frames = len(scores) + for i in range(0, total_frames, batch_size): + chunk_end = min(i + batch_size, total_frames) chunk_scores = scores[i : chunk_end] - # argmax gives relative index (0 to batch_size), add 'i' for absolute + # Find best in batch best_in_chunk_idx = np.argmax(chunk_scores) selected_indices.append(i + best_in_chunk_idx) elif selection_method == "best_n": - # Top N sharpest frames globally, sorted by time - target_count = min(num_frames, total_input_frames) - - # argsort sorts low to high, we take the last N (highest scores) + target_count = min(num_frames, len(scores)) top_indices = np.argsort(scores)[-target_count:] - - # Sort indices to keep original video order selected_indices = sorted(top_indices) print(f"[SharpSelector] Selected {len(selected_indices)} frames.") - - # Filter the original GPU tensor using the selected indices result_images = images[selected_indices] return (result_images, len(selected_indices)) \ No newline at end of file -- 2.49.1 From d4b580445d095f8f903d6d6a5de126636aa7ca11 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 17:26:18 +0100 Subject: [PATCH 3/7] Update sharp_node.py --- sharp_node.py | 50 +++++++++++++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 9 deletions(-) diff --git a/sharp_node.py b/sharp_node.py index a50cbb1..8eba4bb 100644 --- a/sharp_node.py +++ b/sharp_node.py @@ -28,10 +28,9 @@ class SharpnessAnalyzer: score = cv2.Laplacian(gray, cv2.CV_64F).var() scores.append(score) - # We pass the list of scores to the next node return (scores,) -# --- NODE 2: SELECTOR (Uses scores to filter high-res images) --- +# --- NODE 2: SELECTOR (Filters High-Res images) --- class SharpFrameSelector: @classmethod def INPUT_TYPES(s): @@ -42,6 +41,8 @@ class SharpFrameSelector: "selection_method": (["batched", "best_n"],), "batch_size": ("INT", {"default": 24, "min": 1, "max": 10000, "step": 1}), "num_frames": ("INT", {"default": 10, "min": 1, "max": 10000, "step": 1}), + # NEW SETTING + "min_sharpness": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10000.0, "step": 0.1}), } } @@ -50,18 +51,17 @@ class SharpFrameSelector: FUNCTION = "select_frames" CATEGORY = "SharpFrames" - def select_frames(self, images, scores, selection_method, batch_size, num_frames): + def select_frames(self, images, scores, selection_method, batch_size, num_frames, min_sharpness): # Validation if len(images) != len(scores): print(f"[SharpSelector] WARNING: Frame count mismatch! Images: {len(images)}, Scores: {len(scores)}") - # If mismatch (e.g. latent optimization), we truncate to the shorter length min_len = min(len(images), len(scores)) images = images[:min_len] scores = scores[:min_len] selected_indices = [] - # --- SELECTION LOGIC (Same as before, but using pre-calculated scores) --- + # --- SELECTION LOGIC --- if selection_method == "batched": total_frames = len(scores) for i in range(0, total_frames, batch_size): @@ -70,14 +70,46 @@ class SharpFrameSelector: # Find best in batch best_in_chunk_idx = np.argmax(chunk_scores) - selected_indices.append(i + best_in_chunk_idx) + best_score = chunk_scores[best_in_chunk_idx] + + # Only keep if it passes the threshold + if best_score >= min_sharpness: + selected_indices.append(i + best_in_chunk_idx) elif selection_method == "best_n": - target_count = min(num_frames, len(scores)) - top_indices = np.argsort(scores)[-target_count:] - selected_indices = sorted(top_indices) + # 1. Filter out everything below threshold + valid_indices = [i for i, s in enumerate(scores) if s >= min_sharpness] + + # 2. Sort valid candidates by score (Low -> High) + # We use numpy array for easy indexing + valid_scores = np.array([scores[i] for i in valid_indices]) + + if len(valid_scores) > 0: + # How many can we take? + target_count = min(num_frames, len(valid_scores)) + + # Get indices of top N scores within the VALID list + top_local_indices = np.argsort(valid_scores)[-target_count:] + + # Map back to global indices + top_global_indices = [valid_indices[i] for i in top_local_indices] + + # Sort by time + selected_indices = sorted(top_global_indices) + else: + selected_indices = [] print(f"[SharpSelector] Selected {len(selected_indices)} frames.") + + # --- EMPTY RESULT SAFETY NET --- + if len(selected_indices) == 0: + print("[SharpSelector] Warning: No frames met criteria. Returning 1 black frame to prevent crash.") + # Create 1 black pixel frame with same dimensions as input + # This keeps the workflow alive + h, w = images[0].shape[0], images[0].shape[1] + empty_frame = torch.zeros((1, h, w, 3), dtype=images.dtype, device=images.device) + return (empty_frame, 0) + result_images = images[selected_indices] return (result_images, len(selected_indices)) \ No newline at end of file -- 2.49.1 From dfd12d84e119820b948d5b79c4442c0aef03a3ad Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 17:27:27 +0100 Subject: [PATCH 4/7] Update js/sharp_tooltips.js --- js/sharp_tooltips.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/js/sharp_tooltips.js b/js/sharp_tooltips.js index bc6884c..2410f23 100644 --- a/js/sharp_tooltips.js +++ b/js/sharp_tooltips.js @@ -5,14 +5,16 @@ app.registerExtension({ async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "SharpFrameSelector") { - // Define your tooltips here const tooltips = { - "selection_method": "Strategy:\n'batched' = 1 best frame per time slot (Good for video).\n'best_n' = Top N sharpest frames globally.", - "batch_size": "For 'batched' mode only.\nHow many frames to analyze at once.\nExample: 24fps video + batch 24 = 1 output frame per second.", - "num_frames": "For 'best_n' mode only.\nTotal number of frames you want to keep." + "selection_method": "Strategy:\n• 'batched': Best for video. Splits time into slots (Batch Size) and picks the winner.\n• 'best_n': Picks the absolute sharpest frames globally, ignoring time.", + + "batch_size": "For 'batched' mode only.\nDefines the size of the time slot.\nExample: 24fps video + batch 24 = 1 selected frame per second.", + + "num_frames": "For 'best_n' mode only.\nThe total quantity of frames you want to output.", + + "min_sharpness": "Threshold Filter.\nAny frame with a score lower than this is discarded immediately.\n\n⚠️ IMPORTANT: Scores depend on image size. \nIf you used the 'Sidechain' workflow (Resized Analyzer), scores will be much lower (e.g. 50 instead of 500)." }; - // Hook into the node creation to apply them const onNodeCreated = nodeType.prototype.onNodeCreated; nodeType.prototype.onNodeCreated = function () { onNodeCreated?.apply(this, arguments); -- 2.49.1 From 35f579035894188ceca3a07b584d290c502db895 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 18:10:27 +0100 Subject: [PATCH 5/7] buffer --- sharp_node.py | 71 ++++++++++++++++++--------------------------------- 1 file changed, 25 insertions(+), 46 deletions(-) diff --git a/sharp_node.py b/sharp_node.py index 8eba4bb..1bf33f6 100644 --- a/sharp_node.py +++ b/sharp_node.py @@ -2,16 +2,12 @@ import torch import numpy as np import cv2 -# --- NODE 1: ANALYZER (Calculates the scores) --- +# --- NODE 1: ANALYZER (Unchanged) --- class SharpnessAnalyzer: @classmethod def INPUT_TYPES(s): - return { - "required": { - "images": ("IMAGE",), - } - } - + return {"required": {"images": ("IMAGE",)}} + RETURN_TYPES = ("SHARPNESS_SCORES",) RETURN_NAMES = ("scores",) FUNCTION = "analyze_sharpness" @@ -20,28 +16,26 @@ class SharpnessAnalyzer: def analyze_sharpness(self, images): print(f"[SharpAnalyzer] Calculating scores for {len(images)} frames...") scores = [] - - # This loop is fast if 'images' are small (resized) for i in range(len(images)): img_np = (images[i].cpu().numpy() * 255).astype(np.uint8) gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY) score = cv2.Laplacian(gray, cv2.CV_64F).var() scores.append(score) - return (scores,) -# --- NODE 2: SELECTOR (Filters High-Res images) --- +# --- NODE 2: SELECTOR (Updated with Buffer) --- class SharpFrameSelector: @classmethod def INPUT_TYPES(s): return { "required": { - "images": ("IMAGE",), # Connect High-Res images here - "scores": ("SHARPNESS_SCORES",), # Connect output of Analyzer here + "images": ("IMAGE",), + "scores": ("SHARPNESS_SCORES",), "selection_method": (["batched", "best_n"],), "batch_size": ("INT", {"default": 24, "min": 1, "max": 10000, "step": 1}), + # NEW: Restored the buffer option + "batch_buffer": ("INT", {"default": 0, "min": 0, "max": 10000, "step": 1}), "num_frames": ("INT", {"default": 10, "min": 1, "max": 10000, "step": 1}), - # NEW SETTING "min_sharpness": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 10000.0, "step": 0.1}), } } @@ -51,65 +45,50 @@ class SharpFrameSelector: FUNCTION = "select_frames" CATEGORY = "SharpFrames" - def select_frames(self, images, scores, selection_method, batch_size, num_frames, min_sharpness): - # Validation + def select_frames(self, images, scores, selection_method, batch_size, batch_buffer, num_frames, min_sharpness): if len(images) != len(scores): - print(f"[SharpSelector] WARNING: Frame count mismatch! Images: {len(images)}, Scores: {len(scores)}") min_len = min(len(images), len(scores)) images = images[:min_len] scores = scores[:min_len] selected_indices = [] - # --- SELECTION LOGIC --- if selection_method == "batched": total_frames = len(scores) - for i in range(0, total_frames, batch_size): + + # THE FIX: Step includes the buffer size + # If batch=24 and buffer=2, we jump 26 frames each time + step_size = batch_size + batch_buffer + + for i in range(0, total_frames, step_size): + # The chunk is strictly the batch_size chunk_end = min(i + batch_size, total_frames) chunk_scores = scores[i : chunk_end] - # Find best in batch - best_in_chunk_idx = np.argmax(chunk_scores) - best_score = chunk_scores[best_in_chunk_idx] - - # Only keep if it passes the threshold - if best_score >= min_sharpness: - selected_indices.append(i + best_in_chunk_idx) + if len(chunk_scores) > 0: + best_in_chunk_idx = np.argmax(chunk_scores) + best_score = chunk_scores[best_in_chunk_idx] + + if best_score >= min_sharpness: + selected_indices.append(i + best_in_chunk_idx) elif selection_method == "best_n": - # 1. Filter out everything below threshold + # (Logic remains the same, buffer applies to Batched only) valid_indices = [i for i, s in enumerate(scores) if s >= min_sharpness] - - # 2. Sort valid candidates by score (Low -> High) - # We use numpy array for easy indexing valid_scores = np.array([scores[i] for i in valid_indices]) if len(valid_scores) > 0: - # How many can we take? target_count = min(num_frames, len(valid_scores)) - - # Get indices of top N scores within the VALID list top_local_indices = np.argsort(valid_scores)[-target_count:] - - # Map back to global indices top_global_indices = [valid_indices[i] for i in top_local_indices] - - # Sort by time selected_indices = sorted(top_global_indices) - else: - selected_indices = [] print(f"[SharpSelector] Selected {len(selected_indices)} frames.") - # --- EMPTY RESULT SAFETY NET --- if len(selected_indices) == 0: - print("[SharpSelector] Warning: No frames met criteria. Returning 1 black frame to prevent crash.") - # Create 1 black pixel frame with same dimensions as input - # This keeps the workflow alive h, w = images[0].shape[0], images[0].shape[1] - empty_frame = torch.zeros((1, h, w, 3), dtype=images.dtype, device=images.device) - return (empty_frame, 0) + empty = torch.zeros((1, h, w, 3), dtype=images.dtype, device=images.device) + return (empty, 0) result_images = images[selected_indices] - return (result_images, len(selected_indices)) \ No newline at end of file -- 2.49.1 From b37ac40cdbee99ec2d15fb879c0bc705e44b6d58 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 18:11:40 +0100 Subject: [PATCH 6/7] Update js/sharp_tooltips.js --- js/sharp_tooltips.js | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/js/sharp_tooltips.js b/js/sharp_tooltips.js index 2410f23..883ff98 100644 --- a/js/sharp_tooltips.js +++ b/js/sharp_tooltips.js @@ -4,15 +4,15 @@ app.registerExtension({ name: "SharpFrames.Tooltips", async beforeRegisterNodeDef(nodeType, nodeData, app) { if (nodeData.name === "SharpFrameSelector") { - const tooltips = { - "selection_method": "Strategy:\n• 'batched': Best for video. Splits time into slots (Batch Size) and picks the winner.\n• 'best_n': Picks the absolute sharpest frames globally, ignoring time.", - - "batch_size": "For 'batched' mode only.\nDefines the size of the time slot.\nExample: 24fps video + batch 24 = 1 selected frame per second.", - - "num_frames": "For 'best_n' mode only.\nThe total quantity of frames you want to output.", - - "min_sharpness": "Threshold Filter.\nAny frame with a score lower than this is discarded immediately.\n\n⚠️ IMPORTANT: Scores depend on image size. \nIf you used the 'Sidechain' workflow (Resized Analyzer), scores will be much lower (e.g. 50 instead of 500)." + // Must match Python INPUT_TYPES keys exactly + "selection_method": "Strategy:\n• 'batched': Best for video. Splits time into slots.\n• 'best_n': Global top sharpest frames.", + "batch_size": "For 'batched' mode.\nSize of the analysis window (in frames).", + "batch_buffer": "For 'batched' mode.\nFrames to skip AFTER each batch (dead zone).", + "num_frames": "For 'best_n' mode.\nTotal frames to output.", + "min_sharpness": "Threshold Filter.\nDiscard frames with score below this.\nNote: Scores are lower on resized images.", + "images": "Input High-Res images.", + "scores": "Input Sharpness Scores from Analyzer." }; const onNodeCreated = nodeType.prototype.onNodeCreated; @@ -23,6 +23,9 @@ app.registerExtension({ for (const w of this.widgets) { if (tooltips[w.name]) { w.tooltip = tooltips[w.name]; + // Force update for immediate feedback + w.options = w.options || {}; + w.options.tooltip = tooltips[w.name]; } } } -- 2.49.1 From 2e21da351bae8bdfdb53750e1fcc14cb3004aa31 Mon Sep 17 00:00:00 2001 From: Ethanfel Date: Sun, 18 Jan 2026 18:17:15 +0100 Subject: [PATCH 7/7] Update readme --- readme | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/readme b/readme index 62de164..0a22687 100644 --- a/readme +++ b/readme @@ -1,22 +1,26 @@ -# ComfyUI Sharp Frame Selector +# 🔪 ComfyUI Sharp Frame Selector -A custom node for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) that automatically filters video frames to select only the sharpest ones. +A suite of custom nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI) designed to intelligently extract the sharpest frames from video footage. -This is a ComfyUI implementation of the logic found in [sharp-frames](https://github.com/Reflct/sharp-frames-python). It calculates the Laplacian variance of each frame to determine focus quality and selects the best candidates based on your chosen strategy. +Based on the [sharp-frames](https://github.com/Reflct/sharp-frames-python) logic, this tool uses **Laplacian Variance** to score image clarity. It is optimized for high-resolution video processing using a **Sidechain Workflow** that saves massive amounts of RAM. -## Features +## ✨ Key Features -- **No external CLI tools required**: Runs entirely within ComfyUI using OpenCV. -- **Batched Selection**: Perfect for videos. Divides the timeline into chunks (e.g., every 1 second) and picks the single sharpest frame from that chunk. Ensures you never miss a scene. -- **Best-N Selection**: Simply picks the top N sharpest frames from the entire batch, regardless of when they occur. -- **GPU Efficient**: Keeps image data on the GPU where possible, only moving small batches to CPU for the sharpness calculation. +* **Sidechain Optimization:** Analyze lightweight 512px proxy images to control the selection of heavy 4K raw frames. +* **Batched Extraction:** Splits video into time slots (e.g., 1 second) and picks the single best frame from each slot. Perfect for ensuring action scenes are not missed. +* **Threshold Filtering:** Automatically discards frames that are too blurry, even if they are the "winner" of their batch. +* **Buffer Control:** Optional dead-zones between batches to reduce frame count or ensure temporal separation. -## Installation +--- -### Method 1: Manager (Recommended) -If this node is available in the ComfyUI Manager, search for "Sharp Frame Selector" and install. +## 🚀 Installation -### Method 2: Manual +### Option 1: ComfyUI Manager (Recommended) +1. Open ComfyUI Manager. +2. Search for **"Sharp Frame Selector"**. +3. Click **Install**. + +### Option 2: Manual Installation Clone this repository into your `custom_nodes` folder: ```bash -- 2.49.1