mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2025-12-22 16:05:51 +00:00
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
231 lines
10 KiB
Python
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
|
|
|