Files
changedetection.io/changedetectionio/processors/image_ssim_diff/processor.py
dgtlmoon 53e2e94a3f
Some checks failed
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Build distribution 📦 (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Test the built package works basically. (push) Has been cancelled
Publish Python 🐍distribution 📦 to PyPI and TestPyPI / Publish Python 🐍 distribution 📦 to PyPI (push) Has been cancelled
ChangeDetection.io App Test / lint-code (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-10 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-11 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-12 (push) Has been cancelled
ChangeDetection.io App Test / test-application-3-13 (push) Has been cancelled
Async non block fixes
2025-12-20 13:03:28 +01:00

231 lines
10 KiB
Python

"""
Core fast screenshot comparison processor.
Uses OpenCV with subprocess isolation for high-performance, low-memory
image processing. All operations run in isolated subprocesses for complete
memory cleanup and stability.
"""
import hashlib
import os
import time
from loguru import logger
from changedetectionio import strtobool
from changedetectionio.processors import difference_detection_processor, SCREENSHOT_FORMAT_PNG
from changedetectionio.processors.exceptions import ProcessorException
from . import DEFAULT_COMPARISON_THRESHOLD_OPENCV, CROPPED_IMAGE_TEMPLATE_FILENAME
# All image operations now use OpenCV via isolated_opencv subprocess handler
# Template matching temporarily disabled pending OpenCV implementation
name = 'Visual / Image screenshot change detection'
description = 'Compares screenshots using fast OpenCV algorithm, 10-100x faster than SSIM'
processor_weight = 2
list_badge_text = "Visual"
class perform_site_check(difference_detection_processor):
"""Fast screenshot comparison processor using OpenCV."""
# Override to use PNG format for better image comparison (JPEG compression creates noise)
#screenshot_format = SCREENSHOT_FORMAT_PNG
def run_changedetection(self, watch):
"""
Perform screenshot comparison using OpenCV subprocess handler.
Returns:
tuple: (changed_detected, update_obj, screenshot_bytes)
"""
now = time.time()
# 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
self.xpath_data = self.fetcher.xpath_data
# Quick MD5 check - skip expensive comparison 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 comparison")
raise checksumFromPreviousCheckWasTheSame()
# Get threshold (per-watch > global > env default)
threshold = watch.get('comparison_threshold')
if not threshold or threshold == '':
threshold = self.datastore.data['settings']['application'].get('comparison_threshold', DEFAULT_COMPARISON_THRESHOLD_OPENCV)
# Convert string to appropriate type
try:
threshold = float(threshold)
except (ValueError, TypeError):
logger.warning(f"Invalid threshold value '{threshold}', using default")
threshold = 30.0
# Check if bounding box is set (for drawn area mode)
# Read from processor-specific config JSON file (named after processor)
crop_region = None
# Automatically use the processor name from watch config as filename
processor_name = watch.get('processor', 'default')
config_filename = f'{processor_name}.json'
processor_config = self.get_extra_watch_config(config_filename) if self.get_extra_watch_config(config_filename) else {}
bounding_box = processor_config.get('bounding_box') if processor_config else None
# Template matching for tracking content movement
template_matching_enabled = processor_config.get('auto_track_region', False)
if bounding_box:
try:
# Parse bounding box: "x,y,width,height"
parts = [int(p.strip()) for p in bounding_box.split(',')]
if len(parts) == 4:
x, y, width, height = parts
# Crop uses (left, top, right, bottom)
crop_region = (max(0, x), max(0, y), x + width, y + height)
logger.info(f"Bounding box enabled: cropping to region {crop_region} (x={x}, y={y}, w={width}, h={height})")
else:
logger.warning(f"Invalid bounding box format: {bounding_box} (expected 4 values)")
except Exception as e:
logger.warning(f"Failed to parse bounding box '{bounding_box}': {e}")
# If no bounding box, check if visual selector (include_filters) is set for region-based comparison
if not crop_region:
include_filters = watch.get('include_filters', [])
if include_filters and len(include_filters) > 0:
# Get the first filter to use for cropping
first_filter = include_filters[0].strip()
if first_filter and self.xpath_data:
try:
import json
# xpath_data is JSON string from browser
xpath_data_obj = json.loads(self.xpath_data) if isinstance(self.xpath_data, str) else self.xpath_data
# Find the bounding box for the first filter
for element in xpath_data_obj.get('size_pos', []):
# Match the filter with the element's xpath
if element.get('xpath') == first_filter and element.get('highlight_as_custom_filter'):
# Found the element - extract crop coordinates
left = element.get('left', 0)
top = element.get('top', 0)
width = element.get('width', 0)
height = element.get('height', 0)
# Crop uses (left, top, right, bottom)
crop_region = (max(0, left), max(0, top), left + width, top + height)
logger.info(f"Visual selector enabled: cropping to region {crop_region} for filter: {first_filter}")
break
except Exception as e:
logger.warning(f"Failed to parse xpath_data for visual selector: {e}")
# Store original crop region for template matching
original_crop_region = crop_region
# 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")
# LibVIPS uses automatic reference counting - no explicit cleanup needed
update_obj = {
'previous_md5': hashlib.md5(self.screenshot).hexdigest(),
'last_error': False
}
logger.trace(f"Processed in {time.time() - now:.3f}s")
return False, update_obj, self.screenshot
# Get previous screenshot bytes from history
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')
# Template matching is temporarily disabled pending OpenCV implementation
# crop_region calculated above will be used as-is
# Perform comparison in isolated subprocess to prevent memory leaks
try:
from .image_handler import isolated_opencv as process_screenshot_handler
# Get blur sigma and min change percentage
blur_sigma = float(os.getenv("OPENCV_BLUR_SIGMA", "0.8"))
min_change_percentage = float(os.getenv("OPENCV_MIN_CHANGE_PERCENT", "0.1"))
logger.debug(f"Starting isolated subprocess comparison (crop_region={crop_region})")
# Compare using isolated subprocess with OpenCV (async-safe to avoid blocking event loop)
# Pass raw bytes and crop region - subprocess handles all image operations
import asyncio
import threading
# Async-safe wrapper: runs coroutine in new thread with its own event loop
# This prevents blocking the async update worker's event loop
def run_async_in_thread():
return asyncio.run(
process_screenshot_handler.compare_images_isolated(
previous_screenshot_bytes,
self.screenshot,
threshold,
blur_sigma,
min_change_percentage,
crop_region # Pass crop region for isolated cropping
)
)
# Run in thread to avoid blocking event loop when called from async update worker
result_container = [None]
exception_container = [None]
def thread_target():
try:
result_container[0] = run_async_in_thread()
except Exception as e:
exception_container[0] = e
thread = threading.Thread(target=thread_target)
thread.start()
thread.join(timeout=60)
if exception_container[0]:
raise exception_container[0]
changed_detected, change_score = result_container[0]
logger.debug(f"Isolated subprocess comparison completed: changed={changed_detected}, score={change_score:.2f}")
logger.info(f"{process_screenshot_handler.IMPLEMENTATION_NAME}: {change_score:.2f}% pixels changed, threshold: {threshold:.0f}")
except Exception as e:
logger.error(f"Failed to compare screenshots: {e}")
logger.trace(f"Processed in {time.time() - now:.3f}s")
raise ProcessorException(
message=f"Screenshot comparison failed: {e}",
url=watch.get('url')
)
# Return results
update_obj = {
'previous_md5': hashlib.md5(self.screenshot).hexdigest(),
'last_error': False
}
if changed_detected:
logger.info(f"Change detected using OpenCV! Score: {change_score:.2f}")
else:
logger.debug(f"No significant change using OpenCV. Score: {change_score:.2f}")
logger.trace(f"Processed in {time.time() - now:.3f}s")
return changed_detected, update_obj, self.screenshot