From 37e8b905eb8a3c9405b2694517c6c4570f353dab Mon Sep 17 00:00:00 2001 From: gabe-init Date: Sun, 25 May 2025 13:19:14 -0500 Subject: [PATCH] Initial commit - ComfyUI String Similarity Node - Advanced text comparison for ComfyUI - Multiple similarity algorithms - Support for semantic similarity - MIT License --- .gitignore | 44 +++++++++++ README.md | 131 +++++++++++++++++++++++++++++++++ __init__.py | 3 + node.py | 186 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 5 ++ 5 files changed, 369 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 __init__.py create mode 100644 node.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a542d71 --- /dev/null +++ b/.gitignore @@ -0,0 +1,44 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Environment +.env +.venv +env/ +venv/ +ENV/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Secrets +api_keys.json +secrets.json +*.key \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0817fe8 --- /dev/null +++ b/README.md @@ -0,0 +1,131 @@ +# ComfyUI String Similarity Node + +A comprehensive text comparison node for ComfyUI that provides multiple algorithms to measure string similarity, useful for OCR validation, text comparison, and quality assessment workflows. + +## Features + +- Multiple similarity algorithms in one node +- Detailed comparison metrics +- Support for both character-level and semantic similarity +- Useful for OCR accuracy assessment +- Compatible with ComfyUI's text processing pipeline + +## Supported Algorithms + +1. **Levenshtein Distance**: Edit distance between strings +2. **SequenceMatcher**: Python's built-in sequence matching +3. **Jaccard Similarity**: Set-based word comparison +4. **Cosine Similarity**: Vector space model comparison +5. **Word Error Rate (WER)**: Word-level accuracy metric +6. **Character Error Rate (CER)**: Character-level accuracy metric +7. **SentenceTransformer-MPNET**: Deep learning semantic similarity +8. **SentenceTransformer-MiniLM**: Lightweight semantic similarity + +## Installation + +1. Clone this repository into your ComfyUI custom_nodes folder: +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/yourusername/ComfyUI-String-Similarity +``` + +2. Install the required dependencies: +```bash +pip install -r requirements.txt +``` + +3. Restart ComfyUI + +## Usage + +1. Add the "String Similarity Node" to your workflow +2. Connect two text inputs (actual_text and ocr_text) +3. Select the comparison algorithm +4. The node outputs a formatted string with similarity metrics + +### Input Parameters + +- **actual_text**: The reference or ground truth text +- **ocr_text**: The text to compare (e.g., OCR output) +- **algorithm**: Choose from 8 different similarity algorithms + +### Output + +- **STRING**: Formatted result showing the similarity score and additional metrics + +## Algorithm Details + +### Distance-Based Metrics + +- **Levenshtein**: Minimum edits needed to transform one string to another +- **SequenceMatcher**: Ratio of matching subsequences +- **CER/WER**: Error rates at character and word levels + +### Similarity Metrics + +- **Jaccard**: Intersection over union of word sets +- **Cosine**: Angle between text vectors + +### Semantic Similarity + +- **SentenceTransformers**: Neural models that understand meaning + - MPNET: More accurate but slower + - MiniLM: Faster with good accuracy + +## Use Cases + +1. **OCR Quality Assessment**: Compare OCR output with ground truth +2. **Text Validation**: Check if generated text matches expected output +3. **Duplicate Detection**: Find similar text passages +4. **Content Matching**: Semantic similarity for paraphrases + +## Example Output + +``` +Actual Text: 'Hello World' +OCR Text: 'Helo World' + +Levenshtein Distance: 1 +Levenshtein Similarity: 0.91 +``` + +## Performance Notes + +- Simple algorithms (Levenshtein, Jaccard) are fast +- SentenceTransformers require model downloading on first use +- Models are cached after initial download + +## Troubleshooting + +- **Import errors**: Ensure all requirements are installed +- **Model download fails**: Check internet connection +- **Memory issues**: Use MiniLM for lower memory usage + +## Requirements + +- ComfyUI +- Python packages: Levenshtein, scikit-learn, numpy, sentence-transformers + +## License + +MIT License + +Copyright (c) 2024 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..170820d --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] + diff --git a/node.py b/node.py new file mode 100644 index 0000000..568c5a0 --- /dev/null +++ b/node.py @@ -0,0 +1,186 @@ +import Levenshtein +from difflib import SequenceMatcher +from sklearn.feature_extraction.text import CountVectorizer +from sklearn.metrics.pairwise import cosine_similarity +import ast +from sentence_transformers import SentenceTransformer +import numpy as np + +class StringSimilarity: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "actual_text": ("STRING", {"forceInput": True}), + "ocr_text": ("STRING", {"forceInput": True}), + "algorithm": (["Levenshtein", "SequenceMatcher", "Jaccard", "Cosine", "WER", "CER", "SentenceTransformer-MPNET", "SentenceTransformer-MiniLM"],), + }, + } + + RETURN_TYPES = ("STRING",) + FUNCTION = "compute_similarity" + CATEGORY = "Text Analysis" + + def __init__(self): + self.mpnet_model = None + self.minilm_model = None + + def compute_similarity(self, actual_text, ocr_text, algorithm): + actual_text = self.extract_string(actual_text) + ocr_text = self.extract_string(ocr_text) + + if not actual_text or not ocr_text: + return ("Error: Both actual_text and ocr_text must be provided and non-empty.",) + + try: + result = f"Actual Text: '{actual_text}'\nOCR Text: '{ocr_text}'\n\n" + + if algorithm == "Levenshtein": + distance, similarity = self.levenshtein_similarity(actual_text, ocr_text) + result += f"Levenshtein Distance: {distance}\nLevenshtein Similarity: {similarity:.2f}" + elif algorithm == "SequenceMatcher": + similarity = self.sequence_matcher_similarity(actual_text, ocr_text) + result += f"SequenceMatcher Similarity: {similarity:.2f}" + elif algorithm == "Jaccard": + similarity = self.jaccard_similarity(actual_text, ocr_text) + result += f"Jaccard Similarity: {similarity:.2f}" + elif algorithm == "Cosine": + similarity = self.cosine_similarity_calc(actual_text, ocr_text) + result += f"Cosine Similarity: {similarity:.2f}" + elif algorithm == "WER": + wer = self.word_error_rate(actual_text, ocr_text) + result += f"Word Error Rate: {wer:.2f}" + elif algorithm == "CER": + cer = self.character_error_rate(actual_text, ocr_text) + result += f"Character Error Rate: {cer:.2f}" + elif algorithm == "SentenceTransformer-MPNET": + similarity = self.sentence_transformer_similarity(actual_text, ocr_text, "all-mpnet-base-v2") + result += f"SentenceTransformer (MPNET) Similarity: {similarity:.2f}" + elif algorithm == "SentenceTransformer-MiniLM": + similarity = self.sentence_transformer_similarity(actual_text, ocr_text, "all-MiniLM-L6-v2") + result += f"SentenceTransformer (MiniLM) Similarity: {similarity:.2f}" + else: + return (f"Error: Invalid algorithm '{algorithm}' selected.",) + + return (result,) + + except Exception as e: + return (f"Error in computation: {str(e)}",) + + @staticmethod + def extract_string(input_text): + if isinstance(input_text, (list, tuple)): + return input_text[0] if input_text else "" + elif isinstance(input_text, str): + try: + parsed = ast.literal_eval(input_text) + if isinstance(parsed, (list, tuple)): + return parsed[0] if parsed else "" + else: + return input_text + except (ValueError, SyntaxError): + return input_text + else: + return str(input_text) + + @staticmethod + def levenshtein_similarity(str1, str2): + str1, str2 = str1.lower(), str2.lower() + distance = Levenshtein.distance(str1, str2) + max_length = max(len(str1), len(str2)) + similarity = 1 - (distance / max_length) if max_length > 0 else 1 + return distance, similarity + + @staticmethod + def sequence_matcher_similarity(str1, str2): + return SequenceMatcher(None, str1.lower(), str2.lower()).ratio() + + @staticmethod + def jaccard_similarity(str1, str2): + set1 = set(str1.lower().split()) + set2 = set(str2.lower().split()) + intersection = len(set1.intersection(set2)) + union = len(set1.union(set2)) + return intersection / union if union > 0 else 1 + + @staticmethod + def cosine_similarity_calc(str1, str2): + vectorizer = CountVectorizer().fit_transform([str1.lower(), str2.lower()]) + vectors = vectorizer.toarray() + dot_product = vectors[0].dot(vectors[1]) + norm_product = (vectors[0]**2).sum()**0.5 * (vectors[1]**2).sum()**0.5 + return dot_product / norm_product if norm_product > 0 else 1 + + @staticmethod + def word_error_rate(reference, hypothesis): + ref_words = reference.lower().split() + hyp_words = hypothesis.lower().split() + + d = np.zeros((len(ref_words) + 1) * (len(hyp_words) + 1), dtype=np.uint8).reshape((len(ref_words) + 1, len(hyp_words) + 1)) + for i in range(len(ref_words) + 1): + for j in range(len(hyp_words) + 1): + if i == 0: + d[0][j] = j + elif j == 0: + d[i][0] = i + + for i in range(1, len(ref_words) + 1): + for j in range(1, len(hyp_words) + 1): + if ref_words[i - 1] == hyp_words[j - 1]: + d[i][j] = d[i - 1][j - 1] + else: + substitution = d[i - 1][j - 1] + 1 + insertion = d[i][j - 1] + 1 + deletion = d[i - 1][j] + 1 + d[i][j] = min(substitution, insertion, deletion) + + return d[len(ref_words)][len(hyp_words)] / len(ref_words) + + @staticmethod + def character_error_rate(reference, hypothesis): + ref_chars = list(reference.lower()) + hyp_chars = list(hypothesis.lower()) + + d = np.zeros((len(ref_chars) + 1) * (len(hyp_chars) + 1), dtype=np.uint8).reshape((len(ref_chars) + 1, len(hyp_chars) + 1)) + for i in range(len(ref_chars) + 1): + for j in range(len(hyp_chars) + 1): + if i == 0: + d[0][j] = j + elif j == 0: + d[i][0] = i + + for i in range(1, len(ref_chars) + 1): + for j in range(1, len(hyp_chars) + 1): + if ref_chars[i - 1] == hyp_chars[j - 1]: + d[i][j] = d[i - 1][j - 1] + else: + substitution = d[i - 1][j - 1] + 1 + insertion = d[i][j - 1] + 1 + deletion = d[i - 1][j] + 1 + d[i][j] = min(substitution, insertion, deletion) + + return d[len(ref_chars)][len(hyp_chars)] / len(ref_chars) + + def sentence_transformer_similarity(self, str1, str2, model_name): + if model_name == "all-mpnet-base-v2": + if self.mpnet_model is None: + self.mpnet_model = SentenceTransformer(model_name) + model = self.mpnet_model + elif model_name == "all-MiniLM-L6-v2": + if self.minilm_model is None: + self.minilm_model = SentenceTransformer(model_name) + model = self.minilm_model + else: + raise ValueError(f"Unsupported model: {model_name}") + + embeddings = model.encode([str1, str2]) + return np.dot(embeddings[0], embeddings[1]) / (np.linalg.norm(embeddings[0]) * np.linalg.norm(embeddings[1])) + +NODE_CLASS_MAPPINGS = { + "StringSimilarity": StringSimilarity +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "StringSimilarity": "String Similarity Node" +} + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4827145 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Levenshtein +scikit-learn +numpy +sentence-transformers +difflib