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