mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-17 21:45:55 +00:00
Processor - image_ssim_diff
This commit is contained in:
@@ -120,6 +120,12 @@ class fetcher(Fetcher):
|
|||||||
|
|
||||||
self.raw_content = r.content
|
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,
|
async def run(self,
|
||||||
fetch_favicon=True,
|
fetch_favicon=True,
|
||||||
current_include_filters=None,
|
current_include_filters=None,
|
||||||
|
|||||||
@@ -994,6 +994,16 @@ class globalSettingsApplicationForm(commonSettingsForm):
|
|||||||
global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])
|
global_ignore_text = StringListField('Ignore Text', [ValidateListRegex()])
|
||||||
global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
|
global_subtractive_selectors = StringListField('Remove elements', [ValidateCSSJSONXPATHInput(allow_json=False)])
|
||||||
ignore_whitespace = BooleanField('Ignore whitespace')
|
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()
|
password = SaltyPasswordField()
|
||||||
pager_size = IntegerField('Pager size',
|
pager_size = IntegerField('Pager size',
|
||||||
render_kw={"style": "width: 5em;"},
|
render_kw={"style": "width: 5em;"},
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class model(dict):
|
|||||||
'global_subtractive_selectors': [],
|
'global_subtractive_selectors': [],
|
||||||
'ignore_whitespace': True,
|
'ignore_whitespace': True,
|
||||||
'ignore_status_codes': False, #@todo implement, as ternary.
|
'ignore_status_codes': False, #@todo implement, as ternary.
|
||||||
|
'ssim_threshold': '0.96', # Default SSIM threshold for screenshot comparison
|
||||||
'notification_body': default_notification_body,
|
'notification_body': default_notification_body,
|
||||||
'notification_format': default_notification_format,
|
'notification_format': default_notification_format,
|
||||||
'notification_title': default_notification_title,
|
'notification_title': default_notification_title,
|
||||||
|
|||||||
@@ -303,6 +303,14 @@ class model(watch_base):
|
|||||||
with open(filepath, 'rb') as f:
|
with open(filepath, 'rb') as f:
|
||||||
return(brotli.decompress(f.read()).decode('utf-8'))
|
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:
|
with open(filepath, 'r', encoding='utf-8', errors='ignore') as f:
|
||||||
return f.read()
|
return f.read()
|
||||||
|
|
||||||
@@ -318,13 +326,34 @@ class model(watch_base):
|
|||||||
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
threshold = int(os.getenv('SNAPSHOT_BROTLI_COMPRESSION_THRESHOLD', 1024))
|
||||||
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
skip_brotli = strtobool(os.getenv('DISABLE_BROTLI_TEXT_SNAPSHOT', 'False'))
|
||||||
|
|
||||||
# Decide on snapshot filename and destination path
|
# Auto-detect binary vs text data
|
||||||
if not skip_brotli and len(contents) > threshold:
|
if isinstance(contents, bytes):
|
||||||
snapshot_fname = f"{snapshot_id}.txt.br"
|
# Binary data (screenshots, images, PDFs, etc.)
|
||||||
encoded_data = brotli.compress(contents.encode('utf-8'), mode=brotli.MODE_TEXT)
|
# 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:
|
else:
|
||||||
snapshot_fname = f"{snapshot_id}.txt"
|
# Text data (HTML, JSON, etc.) - apply brotli compression
|
||||||
encoded_data = contents.encode('utf-8')
|
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)
|
dest = os.path.join(self.watch_data_dir, snapshot_fname)
|
||||||
|
|
||||||
|
|||||||
@@ -169,6 +169,66 @@ class difference_detection_processor():
|
|||||||
|
|
||||||
# After init, call run_changedetection() which will do the actual change-detection
|
# 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
|
@abstractmethod
|
||||||
def run_changedetection(self, watch):
|
def run_changedetection(self, watch):
|
||||||
update_obj = {'last_notification_error': False, 'last_error': False}
|
update_obj = {'last_notification_error': False, 'last_error': False}
|
||||||
@@ -253,8 +313,20 @@ def available_processors():
|
|||||||
processor_classes = find_processors()
|
processor_classes = find_processors()
|
||||||
|
|
||||||
available = []
|
available = []
|
||||||
for package, processor_class in processor_classes:
|
for module, sub_package_name in processor_classes:
|
||||||
available.append((processor_class, package.name))
|
# 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
|
return available
|
||||||
|
|
||||||
|
|||||||
356
changedetectionio/processors/image_ssim_diff/README.md
Normal file
356
changedetectionio/processors/image_ssim_diff/README.md
Normal 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
|
||||||
10
changedetectionio/processors/image_ssim_diff/__init__.py
Normal file
10
changedetectionio/processors/image_ssim_diff/__init__.py
Normal 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"
|
||||||
235
changedetectionio/processors/image_ssim_diff/difference.py
Normal file
235
changedetectionio/processors/image_ssim_diff/difference.py
Normal 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
|
||||||
|
)
|
||||||
45
changedetectionio/processors/image_ssim_diff/forms.py
Normal file
45
changedetectionio/processors/image_ssim_diff/forms.py
Normal 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>
|
||||||
|
'''
|
||||||
182
changedetectionio/processors/image_ssim_diff/processor.py
Normal file
182
changedetectionio/processors/image_ssim_diff/processor.py
Normal 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
|
||||||
219
changedetectionio/processors/image_ssim_diff/templates/diff.html
Normal file
219
changedetectionio/processors/image_ssim_diff/templates/diff.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user