Processor - image_ssim_diff

This commit is contained in:
dgtlmoon
2025-12-15 17:02:38 +01:00
parent a748a43224
commit 7225c056f9
11 changed files with 1173 additions and 8 deletions

View File

@@ -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,

View File

@@ -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;"},

View File

@@ -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,

View File

@@ -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,7 +326,28 @@ 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
# 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:
# 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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"

View File

@@ -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
)

View File

@@ -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 %}
<fieldset>
<legend>Screenshot Comparison Settings</legend>
<div class="pure-control-group">
{{ render_field(form.ssim_threshold) }}
<span class="pure-form-message-inline">
Controls how sensitive the screenshot comparison is to visual changes.<br>
Uses SSIM (Structural Similarity Index) which is robust to antialiasing and minor rendering differences.<br>
<strong>Higher sensitivity</strong> = detects smaller changes.<br>
Select "Use global default" to inherit the system-wide setting.
</span>
</div>
</fieldset>
'''

View File

@@ -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

View File

@@ -0,0 +1,219 @@
{% extends 'base.html' %}
{% from '_helpers.html' import render_field, render_checkbox_field, render_button %}
{% block content %}
<style>
.ssim-score {
padding: 1em;
background: #f5f5f5;
border-radius: 4px;
margin: 1em 0;
border: 1px solid #ddd;
}
.change-detected {
color: #d32f2f;
font-weight: bold;
}
.no-change {
color: #388e3c;
font-weight: bold;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 1.5em;
margin: 2em 0;
}
.screenshot-panel {
text-align: center;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
padding: 1em;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.screenshot-panel h3 {
margin: 0 0 1em 0;
font-size: 1.1em;
color: #333;
border-bottom: 2px solid #0078e7;
padding-bottom: 0.5em;
}
.screenshot-panel.diff h3 {
border-bottom-color: #d32f2f;
}
.screenshot-panel img {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.version-selector {
display: inline-block;
margin: 0 0.5em;
}
.version-selector label {
font-weight: bold;
margin-right: 0.5em;
}
@media (max-width: 1200px) {
.comparison-grid {
grid-template-columns: 1fr;
}
}
#settings {
background: #fff;
padding: 1.5em;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
margin-bottom: 2em;
}
.diff-fieldset {
border: none;
padding: 0;
margin: 0;
}
.edit-link {
float: right;
margin-top: -0.5em;
}
</style>
<div id="settings">
<a href="{{ url_for('ui.ui_edit.form_watch_edit', uuid=uuid, next='diff') }}" class="pure-button button-small edit-link">
<img style="height: 1em;display:inline-block;" src="{{url_for('static_content', group='images', filename='spread_the_word.svg')}}" alt="Edit" />
Edit Watch
</a>
<form class="pure-form" action="{{ url_for('ui.ui_diff.diff_history_page', uuid=uuid) }}" method="GET" id="diff-form">
<fieldset class="diff-fieldset">
<h2 style="margin-top: 0;">Screenshot Comparison (SSIM)</h2>
{% if versions|length >= 2 %}
<div class="version-selector">
<label for="diff-from-version">From:</label>
<select id="diff-from-version" name="from_version" class="needs-localtime" onchange="this.form.submit()">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== from_version %} selected="" {% endif %}>
{{ version }}
</option>
{%- endfor -%}
</select>
</div>
<div class="version-selector">
<label for="diff-to-version">To:</label>
<select id="diff-to-version" name="to_version" class="needs-localtime" onchange="this.form.submit()">
{%- for version in versions|reverse -%}
<option value="{{ version }}" {% if version== to_version %} selected="" {% endif %}>
{{ version }}
</option>
{%- endfor -%}
</select>
</div>
{% endif %}
<!-- SSIM score display -->
<div class="ssim-score">
<strong>SSIM Score:</strong> {{ "%.4f"|format(ssim_score) }}
({{ "%.2f"|format(percentage_different) }}% different)
<br>
<strong>Threshold:</strong> {{ "%.4f"|format(threshold) }}
<br>
{% if ssim_score < threshold %}
<span class="change-detected">⚠ Change Detected</span>
{% else %}
<span class="no-change">✓ No Significant Change</span>
{% endif %}
<br><br>
<small style="color: #666;">
<strong>About SSIM:</strong> Structural Similarity Index measures visual similarity between images.
A score of 1.0 means identical, lower scores indicate more differences.
This method is robust to antialiasing and minor rendering variations.
</small>
</div>
</fieldset>
</form>
</div>
<div id="screenshot-comparison">
<!-- Three-panel layout -->
<div class="comparison-grid">
<!-- Panel 1: From (Previous) -->
<div class="screenshot-panel">
<h3>Previous Snapshot</h3>
<div style="color: #666; font-size: 0.9em; margin-bottom: 1em;">
{{ from_version|format_timestamp_timeago }}
</div>
<img src="data:image/png;base64,{{ img_from_b64 }}" alt="Previous screenshot">
</div>
<!-- Panel 2: To (Current) -->
<div class="screenshot-panel">
<h3>Current Snapshot</h3>
<div style="color: #666; font-size: 0.9em; margin-bottom: 1em;">
{{ to_version|format_timestamp_timeago }}
</div>
<img src="data:image/png;base64,{{ img_to_b64 }}" alt="Current screenshot">
</div>
<!-- Panel 3: Difference (with red highlights) -->
<div class="screenshot-panel diff">
<h3>Difference Visualization</h3>
<div style="color: #d32f2f; font-size: 0.9em; margin-bottom: 1em; font-weight: bold;">
Red = Changed Pixels
</div>
<img src="data:image/png;base64,{{ diff_image_b64 }}" alt="Difference visualization with red highlights">
</div>
</div>
{% if ssim_data and ssim_data.get('history') and ssim_data.history|length > 1 %}
<div style="margin-top: 3em; padding: 1em; background: #fff; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);">
<h3>SSIM Score History</h3>
<p style="color: #666; font-size: 0.9em;">Recent SSIM scores over time (last {{ ssim_data.history|length }} checks)</p>
<div style="overflow-x: auto;">
<table class="pure-table pure-table-striped" style="width: 100%;">
<thead>
<tr>
<th>Timestamp</th>
<th>SSIM Score</th>
<th>Threshold</th>
<th>Changed?</th>
</tr>
</thead>
<tbody>
{% for entry in ssim_data.history|reverse %}
<tr>
<td>{{ entry.timestamp|format_timestamp_timeago }}</td>
<td>{{ "%.4f"|format(entry.ssim_score) }}</td>
<td>{{ "%.4f"|format(entry.threshold) }}</td>
<td>
{% if entry.changed %}
<span style="color: #d32f2f; font-weight: bold;">Yes</span>
{% else %}
<span style="color: #388e3c;">No</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
</div>
{% endblock %}