diff --git a/changedetectionio/content_fetchers/requests.py b/changedetectionio/content_fetchers/requests.py index 2ef9fe89..7bfd32c1 100644 --- a/changedetectionio/content_fetchers/requests.py +++ b/changedetectionio/content_fetchers/requests.py @@ -120,6 +120,12 @@ class fetcher(Fetcher): self.raw_content = r.content + # If the content is an image, set it as screenshot for SSIM/visual comparison + content_type = r.headers.get('content-type', '').lower() + if 'image/' in content_type: + self.screenshot = r.content + logger.debug(f"Image content detected ({content_type}), set as screenshot for comparison") + async def run(self, fetch_favicon=True, current_include_filters=None, diff --git a/changedetectionio/forms.py b/changedetectionio/forms.py index 71011ecc..6c055267 100644 --- a/changedetectionio/forms.py +++ b/changedetectionio/forms.py @@ -994,6 +994,16 @@ class globalSettingsApplicationForm(commonSettingsForm): global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()]) global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)]) ignore_whitespace = BooleanField('Ignore whitespace') + ssim_threshold = SelectField( + 'Default Screenshot Comparison Sensitivity', + choices=[ + ('0.75', 'Low sensitivity (only major changes)'), + ('0.85', 'Medium sensitivity (moderate changes)'), + ('0.96', 'High sensitivity (small changes)'), + ('0.999', 'Very high sensitivity (any change)') + ], + default='0.96' + ) password = SaltyPasswordField() pager_size = IntegerField('Pager size', render_kw={"style": "width: 5em;"}, diff --git a/changedetectionio/model/App.py b/changedetectionio/model/App.py index bad555a3..59421af2 100644 --- a/changedetectionio/model/App.py +++ b/changedetectionio/model/App.py @@ -46,6 +46,7 @@ class model(dict): 'global_subtractive_selectors': [], 'ignore_whitespace': True, 'ignore_status_codes': False, #@todo implement, as ternary. + 'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison 'notification_body': default_notification_body, 'notification_format': default_notification_format, 'notification_title': default_notification_title, diff --git a/changedetectionio/model/Watch.py b/changedetectionio/model/Watch.py index 410250bc..1ea5cdbb 100644 --- a/changedetectionio/model/Watch.py +++ b/changedetectionio/model/Watch.py @@ -303,6 +303,14 @@ class model(watch_base): with open(filepath, 'rb') as f: return(brotli.decompress(f.read()).decode('utf-8')) + # Check if binary file (image, PDF, etc.) vs text + binary_extensions = ('.png', '.jpg', '.jpeg', '.gif', '.webp', '.pdf', '.bin') + if any(filepath.endswith(ext) for ext in binary_extensions): + # Binary file - return raw bytes + with open(filepath, 'rb') as f: + return f.read() + + # Text file - decode to string with open(filepath, 'r', encoding='utf-8', errors='ignore') as f: return f.read() @@ -318,13 +326,34 @@ class model(watch_base): threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024)) skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False')) - # Decide on snapshot filename and destination path - if not skip_brotli and len(contents) > threshold: - snapshot_fname = f"{snapshot_id}.txt.br" - encoded_data = brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT) + # Auto-detect binary vs text data + if isinstance(contents, bytes): + # Binary data (screenshots, images, PDFs, etc.) + # Detect image format from magic bytes for proper extension + if contents[:8] == b'\x89PNG\r\n\x1a\n': + ext = 'png' + elif contents[:3] == b'\xff\xd8\xff': + ext = 'jpg' + elif contents[:6] in (b'GIF87a', b'GIF89a'): + ext = 'gif' + elif contents[:4] == b'RIFF' and contents[8:12] == b'WEBP': + ext = 'webp' + elif contents[:4] == b'%PDF': + ext = 'pdf' + else: + ext = 'bin' # Generic binary + + snapshot_fname = f"{snapshot_id}.{ext}" + encoded_data = contents # Already bytes, no encoding needed + logger.trace(f"Saving binary snapshot as {snapshot_fname} ({len(contents)} bytes)") else: - snapshot_fname = f"{snapshot_id}.txt" - encoded_data = contents.encode('utf-8') + # Text data (HTML, JSON, etc.) - apply brotli compression + if not skip_brotli and len(contents) > threshold: + snapshot_fname = f"{snapshot_id}.txt.br" + encoded_data = brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT) + else: + snapshot_fname = f"{snapshot_id}.txt" + encoded_data = contents.encode('utf-8') dest = os.path.join(self.watch_data_dir, snapshot_fname) diff --git a/changedetectionio/processors/__init__.py b/changedetectionio/processors/__init__.py index e74f2359..a6d71432 100644 --- a/changedetectionio/processors/__init__.py +++ b/changedetectionio/processors/__init__.py @@ -169,6 +169,66 @@ class difference_detection_processor(): # After init, call run_changedetection() which will do the actual change-detection + def get_extra_watch_config(self, filename): + """ + Read processor-specific JSON config file from watch data directory. + + Args: + filename: Name of JSON file (e.g., "visual_ssim_score.json") + + Returns: + dict: Parsed JSON data, or empty dict if file doesn't exist + """ + import json + import os + + watch = self.datastore.data['watching'].get(self.watch_uuid) + watch_data_dir = watch.watch_data_dir + + if not watch_data_dir: + return {} + + filepath = os.path.join(watch_data_dir, filename) + + if not os.path.isfile(filepath): + return {} + + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, IOError) as e: + logger.warning(f"Failed to read extra watch config {filename}: {e}") + return {} + + def update_extra_watch_config(self, filename, data): + """ + Write processor-specific JSON config file to watch data directory. + + Args: + filename: Name of JSON file (e.g., "visual_ssim_score.json") + data: Dictionary to serialize as JSON + """ + import json + import os + + watch = self.datastore.data['watching'].get(self.watch_uuid) + watch_data_dir = watch.watch_data_dir + + if not watch_data_dir: + logger.warning(f"Cannot save extra watch config {filename}: no watch_data_dir") + return + + # Ensure directory exists + watch.ensure_data_dir_exists() + + filepath = os.path.join(watch_data_dir, filename) + + try: + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) + except IOError as e: + logger.error(f"Failed to write extra watch config {filename}: {e}") + @abstractmethod def run_changedetection(self, watch): update_obj = {'last_notification_error': False, 'last_error': False} @@ -253,8 +313,20 @@ def available_processors(): processor_classes = find_processors() available = [] - for package, processor_class in processor_classes: - available.append((processor_class, package.name)) + for module, sub_package_name in processor_classes: + # Try to get the 'name' attribute from the processor module first + if hasattr(module, 'name'): + description = module.name + else: + # Fall back to processor_description from parent module's __init__.py + parent_module = get_parent_module(module) + if parent_module and hasattr(parent_module, 'processor_description'): + description = parent_module.processor_description + else: + # Final fallback to a readable name + description = sub_package_name.replace('_', ' ').title() + + available.append((sub_package_name, description)) return available diff --git a/changedetectionio/processors/image_ssim_diff/README.md b/changedetectionio/processors/image_ssim_diff/README.md new file mode 100644 index 00000000..a2d55595 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/README.md @@ -0,0 +1,356 @@ +# SSIM Image Comparison Processor + +Visual/screenshot change detection using Structural Similarity Index (SSIM) algorithm. + +## Current Features + +- **SSIM-based comparison**: Robust to antialiasing and minor rendering differences +- **MD5 pre-check**: Fast identical image detection before expensive SSIM calculation +- **Configurable sensitivity**: Low/Medium/High/Very High threshold levels +- **Three-panel diff view**: Previous | Current | Difference (with red highlights) +- **Direct image support**: Works with browser screenshots AND direct image URLs (JPEG, PNG, etc.) + +## Architecture + +The processor uses: +- `scikit-image` for SSIM calculation and image analysis +- `Pillow (PIL)` for image loading and manipulation +- `numpy` for array operations + +## How It Works + +1. **Fetch**: Screenshot captured via browser OR direct image URL fetched +2. **MD5 Check**: Quick hash comparison - if identical, skip SSIM (raises `checksumFromPreviousCheckWasTheSame`) +3. **SSIM Calculation**: Structural similarity between previous and current image +4. **Change Detection**: Score below threshold = change detected +5. **Visualization**: Generate diff image with red-highlighted changed regions + +## SSIM Score Interpretation + +- **1.0** = Identical images +- **0.95-1.0** = Very similar (minor differences, antialiasing) +- **0.90-0.95** = Noticeable differences +- **0.85-0.90** = Significant differences +- **<0.85** = Major differences + +## Available Libraries & Capabilities + +We have access to powerful image analysis capabilities through scikit-image and other libraries. Below are high-value features that could add real user value: + +### 1. 🚀 Perceptual Hashing (pHash) + +**What**: Ultra-fast similarity check based on overall image perception +**Speed**: Microseconds vs SSIM milliseconds +**Use Case**: Pre-screening filter before expensive SSIM +**Value**: "Probably unchanged" quick check for high-frequency monitoring + +**⚠️ IMPORTANT LIMITATIONS:** +- **Will MISS small text changes** (e.g., "Sale ends Monday" → "Sale ends Tuesday") +- Only detects major visual changes (layout, large regions, color shifts) +- Works by reducing image to ~8x8 pixels and comparing overall structure +- Small changes (<5% of image) are invisible to pHash + +**Good for detecting:** +- Layout/design changes +- Major content additions/removals +- Color scheme changes +- Page structure changes + +**Bad for detecting:** +- Text edits (few letters changed) +- Price changes in small text +- Date/timestamp updates +- Small icon changes + +```python +from skimage.feature import perceptual_hash +# Generate hash of image (reduces to 8x8 structure) +hash1 = perceptual_hash(image1) +hash2 = perceptual_hash(image2) +# Compare hashes (Hamming distance) +similarity = (hash1 == hash2).sum() / hash1.size +``` + +**Implementation Idea**: Use pHash only as negative filter: +- pHash identical → Definitely unchanged, skip SSIM ✓ +- pHash different → Run SSIM (might be false positive, need accurate check) +- **Never** skip SSIM based on "pHash similar" - could miss important changes! + +**Alternative: MD5 is better for our use case** +- Current MD5 check: Detects ANY change (including 1 pixel) +- pHash advantage: Would handle JPEG recompression, minor rendering differences +- But we care about those differences! So MD5 is actually more appropriate. + +--- + +### 2. 📍 Change Region Detection + +**What**: Identify WHICH regions changed, not just "something changed" +**Use Case**: Show users exactly what changed on the page +**Value**: Actionable insights - "Price changed in top-right" vs "Something changed somewhere" + +```python +from skimage import segmentation, measure +from skimage.filters import threshold_otsu + +# Get binary mask of changed regions from SSIM map +threshold = threshold_otsu(diff_map) +changed_regions = diff_map < threshold + +# Find connected regions +labels = measure.label(changed_regions) +regions = measure.regionprops(labels) + +# For each changed region, get bounding box +for region in regions: + minr, minc, maxr, maxc = region.bbox + # Draw rectangle on diff image +``` + +**Implementation Idea**: Add bounding boxes to diff visualization showing changed regions with size metrics + +--- + +### 3. 🔥 Structural Similarity Heatmap + +**What**: Per-pixel similarity map showing WHERE differences are +**Use Case**: Visual heatmap overlay - blue=same, red=different +**Value**: Intuitive visualization of change distribution + +```python +from skimage.metrics import structural_similarity as ssim +import matplotlib.pyplot as plt + +# Calculate SSIM with full output (we already do this!) +ssim_score, diff_map = ssim(img1, img2, full=True, ...) + +# Convert to heatmap +heatmap = plt.cm.RdYlBu(diff_map) # Red=different, Blue=same +``` + +**Implementation Idea**: Add heatmap view as alternative to red-highlight diff + +--- + +### 4. 🏗️ Edge/Structure Detection + +**What**: Compare page structure/layout vs content changes +**Use Case**: Detect layout changes separately from text updates +**Value**: "Alert on redesigns" vs "Ignore content updates" + +```python +from skimage import filters, feature + +# Extract edges/structure from both images +edges1 = filters.sobel(img1) +edges2 = filters.sobel(img2) + +# Compare structural similarity of edges only +structure_ssim = ssim(edges1, edges2, ...) +content_ssim = ssim(img1, img2, ...) + +# Separate alerts for structure vs content changes +``` + +**Implementation Idea**: Add "Structure-only" mode that compares layouts, ignoring text/content changes + +--- + +### 5. 🎨 Color Histogram Comparison + +**What**: Fast color distribution analysis +**Use Case**: Detect theme/branding changes, site redesigns +**Value**: "Color scheme changed" alert + +```python +from skimage import exposure + +# Compare color histograms +hist1 = exposure.histogram(img1) +hist2 = exposure.histogram(img2) + +# Calculate histogram similarity +histogram_diff = np.sum(np.abs(hist1 - hist2)) +``` + +**Implementation Idea**: Add "Color similarity" metric alongside SSIM score + +--- + +### 6. 🎯 Feature Matching + +**What**: Detect specific visual elements (logos, buttons, icons) +**Use Case**: "Alert if logo disappears" or "Track button position" +**Value**: Semantic change detection + +```python +from skimage.feature import ORB, match_descriptors + +# Detect features (corners, blobs, etc.) +detector = ORB() +keypoints1 = detector.detect_and_extract(img1) +keypoints2 = detector.detect_and_extract(img2) + +# Match features between images +matches = match_descriptors(keypoints1, keypoints2) + +# Check if specific features disappeared +``` + +**Implementation Idea**: Let users define "watch zones" for specific elements + +--- + +### 7. 📐 Region Properties & Metrics + +**What**: Measure changed region sizes, positions, shapes +**Use Case**: Quantify "how much changed" +**Value**: "Alert only if >10% of page changed" rules + +```python +from skimage import measure + +# Get properties of changed regions +regions = measure.regionprops(labeled_changes) + +for region in regions: + area = region.area + centroid = region.centroid + bbox = region.bbox + + # Calculate percentage of image changed + percent_changed = (area / total_pixels) * 100 +``` + +**Implementation Idea**: Add threshold options: +- "Alert if >X% of image changed" +- "Ignore changes smaller than X pixels" + +--- + +### 8. 🔲 Region Masking / Exclusion Zones + +**What**: Exclude specific regions from comparison +**Use Case**: Ignore ads, timestamps, dynamic content +**Value**: Reduce false positives from expected changes + +```python +# User defines mask regions (top banner, sidebar ads, etc.) +mask = np.ones_like(img1) +mask[0:100, :] = 0 # Ignore top 100 pixels (ads) +mask[:, -200:] = 0 # Ignore right 200 pixels (sidebar) + +# Apply mask to SSIM calculation +masked_ssim = ssim(img1, img2, mask=mask, ...) +``` + +**Implementation Idea**: Add UI for drawing exclusion zones on reference screenshot + +--- + +### 9. 🔄 Image Alignment + +**What**: Handle slight position shifts, scrolling, zoom changes +**Use Case**: Compare screenshots that aren't pixel-perfect aligned +**Value**: Robust to viewport changes, scrolling + +```python +from skimage.registration import phase_cross_correlation + +# Detect shift between images +shift, error, diffphase = phase_cross_correlation(img1, img2) + +# Align images before comparison +from scipy.ndimage import shift as nd_shift +img2_aligned = nd_shift(img2, shift) + +# Now compare aligned images +``` + +**Implementation Idea**: Auto-align screenshots before SSIM to handle scroll/zoom + +--- + +### 10. 🔍 Local Binary Patterns (Texture Analysis) + +**What**: Texture-based comparison +**Use Case**: Focus on content regions, ignore ad texture changes +**Value**: Distinguish between content and decorative elements + +```python +from skimage.feature import local_binary_pattern + +# Extract texture features +lbp1 = local_binary_pattern(img1, P=8, R=1) +lbp2 = local_binary_pattern(img2, P=8, R=1) + +# Compare texture patterns +texture_diff = ssim(lbp1, lbp2, ...) +``` + +**Implementation Idea**: "Content-aware" mode that focuses on text/content areas + +--- + +## Priority Features for Implementation + +Based on user value and implementation complexity: + +### High Priority +1. **Change Region Detection** - Show WHERE changes occurred with bounding boxes +2. **Region Masking** - Let users exclude zones (ads, timestamps, dynamic content) +3. **Region Size Thresholds** - "Alert only if >X% changed" + +### Medium Priority +4. **Heatmap Visualization** - Alternative diff view (better than red highlights) +5. **Color Histogram** - Quick color scheme change detection +6. **Image Alignment** - Handle scroll/zoom differences + +### Low Priority (Advanced) +7. **Structure-only Mode** - Layout change detection vs content +8. **Feature Matching** - Semantic element tracking ("did logo disappear?") +9. **Texture Analysis** - Content-aware comparison +10. **Perceptual Hash** - Fast pre-check (but MD5 is already fast and more accurate) + +### Not Recommended +- **Perceptual Hash as primary check** - Will miss small text changes that users care about +- Current MD5 + SSIM approach is better for monitoring use cases + +## Implementation Considerations + +- **Performance**: Some features (pHash) are faster, others (feature matching) slower than SSIM +- **Memory**: Keep cleanup patterns for GC (close PIL images, del arrays) +- **UI Complexity**: Balance power features with ease of use +- **False Positives**: More sensitivity = more alerts, need good defaults + +## Future Ideas + +- **OCR Integration**: Extract and compare text from screenshots +- **Object Detection**: Detect when specific objects appear/disappear +- **Machine Learning**: Learn what changes matter to the user +- **Anomaly Detection**: Detect unusual changes vs typical patterns + +--- + +## Technical Notes + +### Current Memory Management +- Explicit `.close()` on PIL Images +- `del` on numpy arrays after use +- BytesIO `.close()` after encoding +- Critical for long-running processes + +### SSIM Parameters +```python +ssim_score = ssim( + img1, img2, + channel_axis=-1, # RGB images (last dimension is color) + data_range=255, # 8-bit images + full=True # Return diff map for visualization +) +``` + +### Diff Image Generation +- Currently: JPEG quality=75 for smaller file sizes +- Red highlight: 50% blend with original image +- Could add configurable highlight colors or intensity diff --git a/changedetectionio/processors/image_ssim_diff/__init__.py b/changedetectionio/processors/image_ssim_diff/__init__.py new file mode 100644 index 00000000..574c4641 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/__init__.py @@ -0,0 +1,10 @@ +""" +Visual/screenshot change detection using SSIM (Structural Similarity Index). + +This processor compares screenshots using the SSIM algorithm, which is superior to +simple pixel comparison for detecting meaningful visual changes while ignoring +antialiasing, minor rendering differences, and other insignificant pixel variations. +""" + +processor_description = "Visual/screenshot change detection using SSIM (Structural Similarity Index)" +processor_name = "image_ssim_diff" diff --git a/changedetectionio/processors/image_ssim_diff/difference.py b/changedetectionio/processors/image_ssim_diff/difference.py new file mode 100644 index 00000000..f4ed27d9 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/difference.py @@ -0,0 +1,235 @@ +""" +Screenshot diff visualization for SSIM processor. + +Generates side-by-side comparison with red-highlighted differences. +""" + +import os +import json +import base64 +import io +from loguru import logger +from PIL import Image +import numpy as np +from skimage.metrics import structural_similarity as ssim + + +def calculate_ssim_with_map(img_bytes_from, img_bytes_to): + """ + Calculate SSIM and generate a difference map. + + Args: + img_bytes_from: Previous screenshot (bytes) + img_bytes_to: Current screenshot (bytes) + + Returns: + tuple: (ssim_score, diff_map) where diff_map is a 2D numpy array + """ + # Load images + img_from = Image.open(io.BytesIO(img_bytes_from)) + img_to = Image.open(io.BytesIO(img_bytes_to)) + + # Ensure images are the same size + if img_from.size != img_to.size: + img_from = img_from.resize(img_to.size, Image.Resampling.LANCZOS) + + # Convert to RGB + if img_from.mode != 'RGB': + img_from = img_from.convert('RGB') + if img_to.mode != 'RGB': + img_to = img_to.convert('RGB') + + # Convert to numpy arrays + arr_from = np.array(img_from) + arr_to = np.array(img_to) + + # Calculate SSIM with full output to get the diff map + ssim_score, diff_map = ssim( + arr_from, + arr_to, + channel_axis=-1, + data_range=255, + full=True + ) + + # Clean up images and arrays to free memory + img_from.close() + img_to.close() + del arr_from + del arr_to + + return float(ssim_score), diff_map + + +def generate_diff_image(img_bytes_from, img_bytes_to, diff_map, threshold=0.95): + """ + Generate a difference image highlighting changed regions in red. + + Args: + img_bytes_from: Previous screenshot (bytes) + img_bytes_to: Current screenshot (bytes) + diff_map: Per-pixel SSIM similarity map (2D or 3D numpy array, 0-1) + threshold: SSIM threshold for highlighting changes + + Returns: + bytes: PNG image with red highlights on changed pixels + """ + # Load current image as base + img_to = Image.open(io.BytesIO(img_bytes_to)) + + if img_to.mode != 'RGB': + img_to = img_to.convert('RGB') + + result_array = np.array(img_to) + + # If diff_map is 3D (one value per color channel), average to get 2D + if len(diff_map.shape) == 3: + diff_map = np.mean(diff_map, axis=2) + + # Create a mask for changed pixels (SSIM < threshold) + # Resize diff_map to match image dimensions if needed + if diff_map.shape != result_array.shape[:2]: + from skimage.transform import resize + diff_map = resize(diff_map, result_array.shape[:2], order=1, preserve_range=True) + + changed_mask = diff_map < threshold + + # Overlay semi-transparent red on changed pixels + # Blend the original pixel with red (50% opacity) + result_array[changed_mask] = ( + result_array[changed_mask] * 0.5 + np.array([255, 0, 0]) * 0.5 + ).astype(np.uint8) + + # Convert back to PIL Image + diff_img = Image.fromarray(result_array.astype(np.uint8)) + + # Save to bytes as JPEG with moderate quality to reduce file size + buf = io.BytesIO() + diff_img.save(buf, format='JPEG', quality=75, optimize=True) + diff_bytes = buf.getvalue() + + # Clean up to free memory + diff_img.close() + img_to.close() + del result_array + del diff_map + buf.close() + + return diff_bytes + + +def render(watch, datastore, request, url_for, render_template, flash, redirect): + """ + Render the screenshot comparison diff page. + + Args: + watch: Watch object + datastore: Datastore object + request: Flask request + url_for: Flask url_for function + render_template: Flask render_template function + flash: Flask flash function + redirect: Flask redirect function + + Returns: + Rendered template or redirect + """ + # Get version parameters (from_version, to_version) + versions = list(watch.history.keys()) + + if len(versions) < 2: + flash("Not enough history to compare. Need at least 2 snapshots.", "error") + return redirect(url_for('watchlist.index')) + + # Default: compare latest two versions + from_version = request.args.get('from_version', versions[-2] if len(versions) >= 2 else versions[0]) + to_version = request.args.get('to_version', versions[-1]) + + # Validate versions exist + if from_version not in versions: + from_version = versions[-2] if len(versions) >= 2 else versions[0] + if to_version not in versions: + to_version = versions[-1] + + # Load SSIM score data from JSON config + ssim_config_path = os.path.join(watch.watch_data_dir, "visual_ssim_score.json") + ssim_data = {} + if os.path.isfile(ssim_config_path): + try: + with open(ssim_config_path, 'r') as f: + ssim_data = json.load(f) + except Exception as e: + logger.warning(f"Failed to load SSIM data: {e}") + + # Load screenshots from history + try: + img_bytes_from = watch.get_history_snapshot(timestamp=from_version) + img_bytes_to = watch.get_history_snapshot(timestamp=to_version) + + # Convert to bytes if needed (should already be bytes for screenshots) + if isinstance(img_bytes_from, str): + img_bytes_from = img_bytes_from.encode('utf-8') + if isinstance(img_bytes_to, str): + img_bytes_to = img_bytes_to.encode('utf-8') + + except Exception as e: + logger.error(f"Failed to load screenshots: {e}") + flash(f"Failed to load screenshots: {e}", "error") + return redirect(url_for('watchlist.index')) + + # Calculate SSIM and generate difference map + try: + ssim_score, diff_map = calculate_ssim_with_map(img_bytes_from, img_bytes_to) + except Exception as e: + logger.error(f"Failed to calculate SSIM: {e}") + flash(f"Failed to calculate SSIM: {e}", "error") + return redirect(url_for('watchlist.index')) + + # Get threshold (per-watch or global) + threshold = watch.get('ssim_threshold') + if not threshold or threshold == '': + threshold = datastore.data['settings']['application'].get('ssim_threshold', '0.96') + + # Convert string to float + try: + threshold = float(threshold) + except (ValueError, TypeError): + logger.warning(f"Invalid SSIM threshold value '{threshold}', using default 0.96") + threshold = 0.96 + + # Generate difference image with red highlights + try: + diff_image_bytes = generate_diff_image(img_bytes_from, img_bytes_to, diff_map, threshold) + # Clean up diff_map after use + del diff_map + except Exception as e: + logger.error(f"Failed to generate diff image: {e}") + flash(f"Failed to generate diff image: {e}", "error") + return redirect(url_for('watchlist.index')) + + # Convert images to base64 for embedding in template + img_from_b64 = base64.b64encode(img_bytes_from).decode('utf-8') + img_to_b64 = base64.b64encode(img_bytes_to).decode('utf-8') + diff_image_b64 = base64.b64encode(diff_image_bytes).decode('utf-8') + + # Clean up large byte objects after base64 encoding + del img_bytes_from + del img_bytes_to + del diff_image_bytes + + # Render custom template + return render_template( + 'processors/image_ssim_diff/templates/diff.html', + watch=watch, + uuid=watch.get('uuid'), + img_from_b64=img_from_b64, + img_to_b64=img_to_b64, + diff_image_b64=diff_image_b64, + ssim_score=ssim_score, + ssim_data=ssim_data, # Full history for charts/visualization + threshold=threshold, + versions=versions, + from_version=from_version, + to_version=to_version, + percentage_different=(1 - ssim_score) * 100 + ) diff --git a/changedetectionio/processors/image_ssim_diff/forms.py b/changedetectionio/processors/image_ssim_diff/forms.py new file mode 100644 index 00000000..c1a25ea1 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/forms.py @@ -0,0 +1,45 @@ +""" +Configuration forms for SSIM screenshot comparison processor. +""" + +from wtforms import SelectField, validators +from changedetectionio.forms import processor_text_json_diff_form + + +class processor_settings_form(processor_text_json_diff_form): + """Form for SSIM processor settings.""" + + ssim_threshold = SelectField( + 'Screenshot Comparison Sensitivity', + choices=[ + ('', 'Use global default'), + ('0.75', 'Low sensitivity (only major changes)'), + ('0.85', 'Medium sensitivity (moderate changes)'), + ('0.96', 'High sensitivity (small changes)'), + ('0.999', 'Very high sensitivity (any change)') + ], + validators=[validators.Optional()], + default='' + ) + + def extra_tab_content(self): + """Tab label for processor-specific settings.""" + return 'Screenshot Comparison' + + def extra_form_content(self): + """Render processor-specific form fields.""" + return ''' + {% from '_helpers.html' import render_field %} +
+ ''' diff --git a/changedetectionio/processors/image_ssim_diff/processor.py b/changedetectionio/processors/image_ssim_diff/processor.py new file mode 100644 index 00000000..be5d2b65 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/processor.py @@ -0,0 +1,182 @@ +""" +Core SSIM-based screenshot comparison processor. + +This processor uses the Structural Similarity Index (SSIM) to detect visual changes +in screenshots while being robust to antialiasing and minor rendering differences. +""" + +import hashlib +import time +from loguru import logger +from changedetectionio.processors import difference_detection_processor +from changedetectionio.processors.exceptions import ProcessorException + +name = 'Visual/Screenshot change detection (SSIM)' +description = 'Compares screenshots using SSIM algorithm, robust to antialiasing and rendering differences' + + +class perform_site_check(difference_detection_processor): + """SSIM-based screenshot comparison processor.""" + + def run_changedetection(self, watch): + """ + Perform screenshot comparison using SSIM. + + Returns: + tuple: (changed_detected, update_obj, screenshot_bytes) + """ + from PIL import Image + import io + import numpy as np + from skimage.metrics import structural_similarity as ssim + + # Get the current screenshot + if not self.fetcher.screenshot: + raise ProcessorException( + message="No screenshot available. Ensure the watch is configured to use a real browser.", + url=watch.get('url') + ) + self.screenshot = self.fetcher.screenshot + + # Quick MD5 check - skip expensive SSIM if images are identical + from changedetectionio.content_fetchers.exceptions import checksumFromPreviousCheckWasTheSame + current_md5 = hashlib.md5(self.screenshot).hexdigest() + previous_md5 = watch.get('previous_md5') + if previous_md5 and current_md5 == previous_md5: + logger.debug(f"Screenshot MD5 unchanged ({current_md5}), skipping SSIM calculation") + raise checksumFromPreviousCheckWasTheSame() + + # Get threshold (per-watch or global) + threshold = watch.get('ssim_threshold') + if not threshold or threshold == '': + threshold = self.datastore.data['settings']['application'].get('ssim_threshold', '0.96') + + # Convert string to float + try: + threshold = float(threshold) + except (ValueError, TypeError): + logger.warning(f"Invalid SSIM threshold value '{threshold}', using default 0.96") + threshold = 0.96 + + # Convert current screenshot to PIL Image + try: + current_img = Image.open(io.BytesIO(self.screenshot)) + except Exception as e: + raise ProcessorException( + message=f"Failed to load current screenshot: {e}", + url=watch.get('url') + ) + + # Check if this is the first check (no previous history) + history_keys = list(watch.history.keys()) + if len(history_keys) == 0: + # First check - save baseline, no comparison + logger.info(f"First check for watch {watch.get('uuid')} - saving baseline screenshot") + + # Close the PIL image before returning + current_img.close() + del current_img + + update_obj = { + 'previous_md5': hashlib.md5(self.screenshot).hexdigest(), + 'last_error': False + } + + return False, update_obj, self.screenshot + + # Get previous screenshot from history + try: + previous_timestamp = history_keys[-1] + previous_screenshot_bytes = watch.get_history_snapshot(timestamp=previous_timestamp) + + # Screenshots are stored as PNG, so this should be bytes + if isinstance(previous_screenshot_bytes, str): + # If it's a string (shouldn't be for screenshots, but handle it) + previous_screenshot_bytes = previous_screenshot_bytes.encode('utf-8') + + previous_img = Image.open(io.BytesIO(previous_screenshot_bytes)) + except Exception as e: + logger.warning(f"Failed to load previous screenshot for comparison: {e}") + # Clean up current image before returning + if 'current_img' in locals(): + current_img.close() + del current_img + + # If we can't load previous, treat as first check + update_obj = { + 'previous_md5': hashlib.md5(self.screenshot).hexdigest(), + 'last_error': False + } + + return False, update_obj, self.screenshot + + # Convert images to numpy arrays for SSIM calculation + try: + # Ensure images are the same size + if current_img.size != previous_img.size: + logger.info(f"Resizing images to match: {previous_img.size} -> {current_img.size}") + previous_img = previous_img.resize(current_img.size, Image.Resampling.LANCZOS) + + # Convert to RGB if needed (handle RGBA, grayscale, etc.) + if current_img.mode != 'RGB': + current_img = current_img.convert('RGB') + if previous_img.mode != 'RGB': + previous_img = previous_img.convert('RGB') + + # Convert to numpy arrays + current_array = np.array(current_img) + previous_array = np.array(previous_img) + + # Calculate SSIM + # multichannel=True for RGB images (deprecated in favor of channel_axis) + # Use channel_axis=-1 for color images (last dimension is color channels) + ssim_score = ssim( + previous_array, + current_array, + channel_axis=-1, + data_range=255 + ) + + logger.info(f"SSIM score: {ssim_score:.4f}, threshold: {threshold}") + + # Explicitly close PIL images and delete arrays to free memory immediately + current_img.close() + previous_img.close() + del current_array + del previous_array + del previous_screenshot_bytes # Release the large bytes object + + except Exception as e: + logger.error(f"Failed to calculate SSIM: {e}") + # Ensure cleanup even on error + try: + if 'current_img' in locals(): + current_img.close() + if 'previous_img' in locals(): + previous_img.close() + if 'current_array' in locals(): + del current_array + if 'previous_array' in locals(): + del previous_array + except: + pass + raise ProcessorException( + message=f"SSIM calculation failed: {e}", + url=watch.get('url') + ) + + # Determine if change detected (lower SSIM = more different) + changed_detected = ssim_score < threshold + + # Return results + update_obj = { + 'previous_md5': hashlib.md5(self.screenshot).hexdigest(), + 'last_error': False + } + + if changed_detected: + logger.info(f"Change detected! SSIM score {ssim_score:.4f} < threshold {threshold}") + else: + logger.debug(f"No significant change. SSIM score {ssim_score:.4f} >= threshold {threshold}") + + return changed_detected, update_obj, self.screenshot diff --git a/changedetectionio/processors/image_ssim_diff/templates/diff.html b/changedetectionio/processors/image_ssim_diff/templates/diff.html new file mode 100644 index 00000000..89bd13f3 --- /dev/null +++ b/changedetectionio/processors/image_ssim_diff/templates/diff.html @@ -0,0 +1,219 @@ +{% extends 'base.html' %} +{% from '_helpers.html' import render_field, render_checkbox_field, render_button %} +{% block content %} + + + +Recent SSIM scores over time (last {{ ssim_data.history|length }} checks)
+| Timestamp | +SSIM Score | +Threshold | +Changed? | +
|---|---|---|---|
| {{ entry.timestamp|format_timestamp_timeago }} | +{{ "%.4f"|format(entry.ssim_score) }} | +{{ "%.4f"|format(entry.threshold) }} | ++ {% if entry.changed %} + Yes + {% else %} + No + {% endif %} + | +