filter-frame-dedup 1.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4 @@
1
+ from filter_frame_dedup.filter import FilterFrameDedupConfig, FilterFrameDedup
2
+
3
+
4
+ __all__ = ["FilterFrameDedupConfig", "FilterFrameDedup"]
@@ -0,0 +1,265 @@
1
+ import logging, os, cv2, time
2
+ from openfilter.filter_runtime.filter import FilterConfig, Filter, Frame
3
+ from filter_frame_dedup.hash_processor import HashFrameProcessor
4
+ from filter_frame_dedup.ssim_processor import SSIMProcessor
5
+
6
+ __all__ = ["FilterFrameDedupConfig", "FilterFrameDedup"]
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ class FilterFrameDedupConfig(FilterConfig):
12
+ """
13
+ Configuration for the Frame Deduplication filter, loaded typically from environment variables.
14
+ """
15
+ hash_threshold: int = 5 # Threshold for hash difference
16
+ motion_threshold: int = 1200 # Threshold for motion detection
17
+ min_time_between_frames: float = 1.0 # Minimum time between saved frames
18
+ ssim_threshold: float = 0.90 # Threshold for SSIM comparison
19
+ roi: tuple | None # Region of interest (x, y, width, height) or None for full image
20
+ output_folder: str = "/output" # Output folder for saved frames
21
+ save_images: bool = True # Whether to save images to disk
22
+ debug: bool = False # Enable debug logging
23
+ forward_deduped_frames: bool = False # Forward deduplicated frames in a side channel
24
+ forward_upstream_data: bool = True # Forward data from upstream filters
25
+
26
+
27
+ class FilterFrameDedup(Filter):
28
+ """
29
+ A filter that:
30
+ 1) Detects duplicate frames using multiple methods (hash-based and SSIM)
31
+ 2) Saves only unique frames based on configurable thresholds
32
+ 3) Supports ROI-based processing (or full image when ROI is None)
33
+ 4) Maintains minimum time between saved frames
34
+ """
35
+
36
+ @classmethod
37
+ def normalize_config(cls, config: FilterFrameDedupConfig):
38
+ """
39
+ Convert environment variables to correct data types, apply checks, etc.
40
+ """
41
+ # Call parent class normalize_config first to handle sources/outputs parsing
42
+ config = super().normalize_config(config)
43
+
44
+ # Handle nested config structure
45
+ if isinstance(config, dict) and 'config' in config:
46
+ config = config['config']
47
+
48
+ # Convert string values to proper types before creating FilterFrameDedupConfig
49
+ if isinstance(config, dict):
50
+ # Convert numeric strings to proper types
51
+ if 'hash_threshold' in config and isinstance(config['hash_threshold'], str):
52
+ config['hash_threshold'] = int(config['hash_threshold'])
53
+ if 'motion_threshold' in config and isinstance(config['motion_threshold'], str):
54
+ config['motion_threshold'] = int(config['motion_threshold'])
55
+ if 'min_time_between_frames' in config and isinstance(config['min_time_between_frames'], str):
56
+ config['min_time_between_frames'] = float(config['min_time_between_frames'])
57
+ if 'ssim_threshold' in config and isinstance(config['ssim_threshold'], str):
58
+ config['ssim_threshold'] = float(config['ssim_threshold'])
59
+ if 'roi' in config and isinstance(config['roi'], str):
60
+ # Parse tuple string like "(100, 100, 200, 200)"
61
+ roi_str = config['roi'].strip('()')
62
+ config['roi'] = tuple(map(int, roi_str.split(', ')))
63
+
64
+ # Convert to FilterFrameDedupConfig
65
+ config = FilterFrameDedupConfig(**config)
66
+
67
+ # Validate debug mode
68
+ if isinstance(config.debug, str):
69
+ debug_str = config.debug.lower()
70
+ if debug_str in ['true', 'false']:
71
+ config.debug = debug_str == 'true'
72
+ else:
73
+ raise ValueError(f"Invalid debug mode: {config.debug}. It should be True or False.")
74
+ elif not isinstance(config.debug, bool):
75
+ raise ValueError(f"Invalid debug mode: {config.debug}. It should be True or False.")
76
+
77
+ # Validate forward_deduped_frames mode
78
+ if isinstance(config.forward_deduped_frames, str):
79
+ config.forward_deduped_frames = config.forward_deduped_frames.lower() == 'true'
80
+ elif not isinstance(config.forward_deduped_frames, bool):
81
+ raise ValueError(f"Invalid forward_deduped_frames mode: {config.forward_deduped_frames}. It should be True or False.")
82
+
83
+ # Validate forward_upstream_data mode
84
+ if isinstance(config.forward_upstream_data, str):
85
+ config.forward_upstream_data = config.forward_upstream_data.lower() == 'true'
86
+ elif not isinstance(config.forward_upstream_data, bool):
87
+ raise ValueError(f"Invalid forward_upstream_data mode: {config.forward_upstream_data}. It should be True or False.")
88
+
89
+ # Validate save_images mode
90
+ if isinstance(config.save_images, str):
91
+ config.save_images = config.save_images.lower() == 'true'
92
+ elif not isinstance(config.save_images, bool):
93
+ raise ValueError(f"Invalid save_images mode: {config.save_images}. It should be True or False.")
94
+
95
+ # Validate thresholds
96
+ if config.hash_threshold < 0:
97
+ raise ValueError("Hash threshold must be non-negative")
98
+ if config.motion_threshold < 0:
99
+ raise ValueError("Motion threshold must be non-negative")
100
+ if config.min_time_between_frames < 0:
101
+ raise ValueError("Minimum time between frames must be non-negative")
102
+ if not 0 <= config.ssim_threshold <= 1:
103
+ raise ValueError("SSIM threshold must be between 0 and 1")
104
+
105
+ # Validate ROI if provided
106
+ if config.roi is not None:
107
+ if len(config.roi) != 4:
108
+ raise ValueError("ROI must be a tuple of 4 values (x, y, width, height)")
109
+ x, y, w, h = config.roi
110
+ if w <= 0 or h <= 0:
111
+ raise ValueError("ROI width and height must be positive")
112
+
113
+ return config
114
+
115
+ def setup(self, config: FilterFrameDedupConfig):
116
+ """
117
+ Called once at the start of the filter's lifecycle.
118
+ """
119
+ logger.info("========= Setting up FilterFrameDedup =========")
120
+ self.config = config
121
+
122
+ # Initialize processors
123
+ self.hash_processor = HashFrameProcessor(config)
124
+ self.ssim_processor = SSIMProcessor(config)
125
+
126
+ # Initialize counters
127
+ self.processed_frame_count = 0
128
+ self.frame_count = 1
129
+
130
+ # Create output folder if it doesn't exist and save_images is enabled
131
+ if config.save_images and not os.path.exists(config.output_folder):
132
+ os.makedirs(config.output_folder)
133
+
134
+ logger.info(f"FilterFrameDedup setup completed. Config: {config.__dict__}")
135
+
136
+ def process(self, frames: dict[str, Frame]) -> dict[str, Frame]:
137
+ """
138
+ Process frames and determine if they should be saved based on motion detection and hash changes.
139
+
140
+ Args:
141
+ frames: Dictionary containing frames
142
+
143
+ Returns:
144
+ Updated frames dictionary with selected frame and optional side channels
145
+ """
146
+ # Get the main frame from the frames dictionary
147
+ main_frame = frames.get('main')
148
+ if main_frame is None or not main_frame.has_image:
149
+ if self.config.debug:
150
+ logger.info("No valid frame received")
151
+ return frames
152
+
153
+ # Increment the frame counter
154
+ self.processed_frame_count += 1
155
+ if self.config.debug:
156
+ logger.info(f"Processing frame {self.processed_frame_count}")
157
+
158
+ # Access the raw BGR image from the frame and create a copy for processing
159
+ processed_image = main_frame.rw_bgr.image.copy()
160
+
161
+ # Initialize output frames dictionary
162
+ output_frames = {}
163
+
164
+ # Always forward the main frame with the processed image first
165
+ # Create a new frame with the processed image to maintain the original frame data
166
+ processed_main_frame = Frame(
167
+ image=processed_image,
168
+ data=main_frame.data,
169
+ format='BGR'
170
+ )
171
+ self.frame_count += 1
172
+ output_frames['main'] = processed_main_frame
173
+
174
+ # Forward upstream data if enabled
175
+ if self.config.forward_upstream_data:
176
+ # Copy all non-main frames from upstream
177
+ for key, frame in frames.items():
178
+ if key != 'main':
179
+ output_frames[key] = frame
180
+
181
+ # First check if frame should be processed based on hash and motion
182
+ if self.hash_processor.should_process_frame(processed_image):
183
+ if self.config.debug:
184
+ logger.info("Frame passed hash/motion check")
185
+ # Then check if frame should be saved based on SSIM
186
+ if self.ssim_processor.should_save_frame(processed_image):
187
+ frame_path = None
188
+
189
+ # Save frame to disk only if save_images is enabled
190
+ if self.config.save_images:
191
+ frame_path = os.path.join(self.config.output_folder, f"frame_{self.frame_count:06d}.jpg")
192
+ lock_path = frame_path + '.lock'
193
+
194
+ try:
195
+ # Create lock file
196
+ with open(lock_path, 'x') as _:
197
+ # Write the processed image
198
+ cv2.imwrite(frame_path, processed_image)
199
+ # time.sleep(30) # for testing
200
+ finally:
201
+ # Always remove the lock file, even if writing fails
202
+ try:
203
+ os.remove(lock_path)
204
+ except:
205
+ pass
206
+
207
+ if self.config.debug:
208
+ logger.info(f"Saved frame to {frame_path}")
209
+ else:
210
+ if self.config.debug:
211
+ logger.info("Frame passed deduplication criteria but not saved (save_images=False)")
212
+
213
+ # Update the last saved time only when frame is actually saved
214
+ self.hash_processor.update_last_saved_time()
215
+
216
+ # Forward deduplicated frame in side channel if enabled
217
+ if self.config.forward_deduped_frames:
218
+ # Create a frame with the actual deduplicated image (the one that was saved)
219
+ # This creates an asynchronous channel that only contains frames that passed deduplication
220
+ deduped_frame = Frame(
221
+ image=processed_image, # Use the processed image that was saved
222
+ data=main_frame.data.copy() if main_frame.data else {},
223
+ format='BGR'
224
+ )
225
+ # Add metadata about the deduplication
226
+ if deduped_frame.data is None:
227
+ deduped_frame.data = {}
228
+ deduped_frame.data['deduped'] = True
229
+ deduped_frame.data['frame_number'] = self.frame_count - 1
230
+ if frame_path:
231
+ deduped_frame.data['saved_path'] = frame_path
232
+ else:
233
+ deduped_frame.data['saved_path'] = None
234
+ deduped_frame.data['original_frame_id'] = getattr(main_frame.data, 'id', None) if main_frame.data else None
235
+
236
+ output_frames['deduped'] = deduped_frame
237
+
238
+ if self.config.debug:
239
+ logger.info("Forwarded deduplicated frame in side channel")
240
+ else:
241
+ if self.config.debug:
242
+ logger.info("Skipping frame due to high SSIM score")
243
+ else:
244
+ if self.config.debug:
245
+ logger.info("Frame did not pass hash/motion check")
246
+
247
+ # Ensure main topic comes first in the output dictionary
248
+ if 'main' in output_frames:
249
+ main_frame = output_frames.pop('main')
250
+ return {'main': main_frame, **output_frames}
251
+
252
+ return output_frames
253
+
254
+ def shutdown(self):
255
+ """
256
+ Called once when the filter is shutting down.
257
+ """
258
+ logger.info("========= Shutting down FilterFrameDedup =========")
259
+ logger.info(f"Total frames processed: {self.processed_frame_count}")
260
+ logger.info(f"Total frames saved: {self.frame_count - 1}")
261
+ logger.info("FilterFrameDedup shutdown complete.")
262
+
263
+
264
+ if __name__ == "__main__":
265
+ FilterFrameDedup.run()
@@ -0,0 +1,164 @@
1
+ import cv2
2
+ import numpy as np
3
+ import time
4
+ from openfilter.filter_runtime.filter import FilterConfig
5
+
6
+
7
+ class HashFrameProcessor:
8
+ """
9
+ A class that handles hash-based frame processing and motion detection.
10
+ """
11
+ def __init__(self, config: FilterConfig):
12
+ self.config = config
13
+ self.prev_phash = None
14
+ self.prev_ahash = None
15
+ self.prev_dhash = None
16
+ self.prev_frame = None
17
+ self.last_saved_time = 0 # Initialize to 0 instead of current time
18
+
19
+ def extract_roi(self, image: np.ndarray) -> np.ndarray:
20
+ """
21
+ Extract the region of interest (ROI) from the image.
22
+ If ROI is None, returns the entire image.
23
+
24
+ Args:
25
+ image: Input image in BGR format
26
+
27
+ Returns:
28
+ Extracted ROI from the image or entire image if ROI is None
29
+ """
30
+ if self.config.roi is None:
31
+ return image
32
+ x, y, w, h = self.config.roi
33
+ return image[y:y+h, x:x+w]
34
+
35
+ def compute_phash(self, image: np.ndarray, hash_size: int = 8) -> np.ndarray:
36
+ """
37
+ Compute the perceptual hash (phash) of the image.
38
+
39
+ Args:
40
+ image: Input image in BGR format
41
+ hash_size: Size of the hash
42
+
43
+ Returns:
44
+ Computed phash of the image
45
+ """
46
+ roi = self.extract_roi(image)
47
+ gray_image = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
48
+ resized_image = cv2.resize(gray_image, (32, 32), interpolation=cv2.INTER_AREA)
49
+ dct_image = cv2.dct(np.float32(resized_image))
50
+ dct_low_freq = dct_image[:hash_size, :hash_size]
51
+ dct_mean = np.mean(dct_low_freq)
52
+ return (dct_low_freq > dct_mean).flatten()
53
+
54
+ def compute_ahash(self, image: np.ndarray, hash_size: int = 8) -> np.ndarray:
55
+ """
56
+ Compute the average hash (ahash) of the image.
57
+
58
+ Args:
59
+ image: Input image in BGR format
60
+ hash_size: Size of the hash
61
+
62
+ Returns:
63
+ Computed ahash of the image
64
+ """
65
+ roi = self.extract_roi(image)
66
+ gray_image = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
67
+ resized_image = cv2.resize(gray_image, (hash_size, hash_size), interpolation=cv2.INTER_AREA)
68
+ avg = resized_image.mean()
69
+ return (resized_image > avg).flatten()
70
+
71
+ def compute_dhash(self, image: np.ndarray, hash_size: int = 8) -> np.ndarray:
72
+ """
73
+ Compute the difference hash (dhash) of the image.
74
+
75
+ Args:
76
+ image: Input image in BGR format
77
+ hash_size: Size of the hash
78
+
79
+ Returns:
80
+ Computed dhash of the image
81
+ """
82
+ roi = self.extract_roi(image)
83
+ gray_image = cv2.cvtColor(roi, cv2.COLOR_BGR2GRAY)
84
+ resized_image = cv2.resize(gray_image, (hash_size + 1, hash_size), interpolation=cv2.INTER_AREA)
85
+ diff = resized_image[:, 1:] > resized_image[:, :-1]
86
+ return diff.flatten()
87
+
88
+ def is_motion_detected(self, prev_frame: np.ndarray, curr_frame: np.ndarray) -> bool:
89
+ """
90
+ Detect motion between two frames by calculating their absolute differences.
91
+
92
+ Args:
93
+ prev_frame: Previous frame in BGR format
94
+ curr_frame: Current frame in BGR format
95
+
96
+ Returns:
97
+ True if motion is detected, False otherwise
98
+ """
99
+ prev_roi = self.extract_roi(prev_frame)
100
+ curr_roi = self.extract_roi(curr_frame)
101
+ gray_prev = cv2.cvtColor(prev_roi, cv2.COLOR_BGR2GRAY)
102
+ gray_curr = cv2.cvtColor(curr_roi, cv2.COLOR_BGR2GRAY)
103
+ diff = cv2.absdiff(gray_prev, gray_curr)
104
+ _, thresh = cv2.threshold(diff, 25, 255, cv2.THRESH_BINARY)
105
+ non_zero_count = np.count_nonzero(thresh)
106
+ return non_zero_count > self.config.motion_threshold
107
+
108
+ def should_process_frame(self, image: np.ndarray) -> bool:
109
+ """
110
+ Determine if a frame should be processed based on hash changes and motion detection.
111
+
112
+ Args:
113
+ image: Current frame in BGR format
114
+
115
+ Returns:
116
+ True if frame should be processed, False otherwise
117
+ """
118
+ # Calculating hash values
119
+ phash = self.compute_phash(image)
120
+ ahash = self.compute_ahash(image)
121
+ dhash = self.compute_dhash(image)
122
+
123
+ # Check motion detection
124
+ motion_detected = self.prev_frame is None or self.is_motion_detected(self.prev_frame, image)
125
+
126
+ # Check if there are significant changes in hash values
127
+ hash_changed = (
128
+ self.prev_phash is None or
129
+ np.count_nonzero(self.prev_phash != phash) > self.config.hash_threshold or
130
+ np.count_nonzero(self.prev_ahash != ahash) > self.config.hash_threshold or
131
+ np.count_nonzero(self.prev_dhash != dhash) > self.config.hash_threshold
132
+ )
133
+
134
+ current_time = time.time()
135
+ time_elapsed = current_time - self.last_saved_time
136
+
137
+ # Debug logging
138
+ if self.config.debug:
139
+ print(f"Hash differences - pHash: {np.count_nonzero(self.prev_phash != phash) if self.prev_phash is not None else 'None'}, "
140
+ f"aHash: {np.count_nonzero(self.prev_ahash != ahash) if self.prev_ahash is not None else 'None'}, "
141
+ f"dHash: {np.count_nonzero(self.prev_dhash != dhash) if self.prev_dhash is not None else 'None'}")
142
+ print(f"Motion detected: {motion_detected}")
143
+ print(f"Hash changed: {hash_changed}")
144
+ print(f"Time elapsed since last save: {time_elapsed:.2f}s")
145
+ print(f"Should process: {(hash_changed or motion_detected) and (time_elapsed >= self.config.min_time_between_frames)}")
146
+
147
+ # Update previous values
148
+ self.prev_phash = phash
149
+ self.prev_ahash = ahash
150
+ self.prev_dhash = dhash
151
+ self.prev_frame = image
152
+
153
+ # For the first frame (when last_saved_time is 0), always process if there are changes
154
+ if self.last_saved_time == 0:
155
+ return hash_changed or motion_detected
156
+
157
+ return (hash_changed or motion_detected) and (time_elapsed >= self.config.min_time_between_frames)
158
+
159
+ def update_last_saved_time(self):
160
+ """
161
+ Update the last saved time when a frame is actually saved.
162
+ This should be called by the filter after successfully saving a frame.
163
+ """
164
+ self.last_saved_time = time.time()
@@ -0,0 +1,47 @@
1
+ import cv2
2
+ import numpy as np
3
+ from skimage.metrics import structural_similarity as ssim
4
+ from openfilter.filter_runtime.filter import FilterConfig
5
+
6
+
7
+ class SSIMProcessor:
8
+ """
9
+ A class that handles SSIM-based frame processing.
10
+ """
11
+ def __init__(self, config: FilterConfig):
12
+ self.config = config
13
+ self.prev_frame = None
14
+
15
+ def compute_ssim(self, frame1: np.ndarray, frame2: np.ndarray) -> float:
16
+ """
17
+ Compute the Structural Similarity Index (SSIM) between two frames.
18
+
19
+ Args:
20
+ frame1: First frame in BGR format
21
+ frame2: Second frame in BGR format
22
+
23
+ Returns:
24
+ SSIM score between the two frames
25
+ """
26
+ gray1 = cv2.cvtColor(frame1, cv2.COLOR_BGR2GRAY)
27
+ gray2 = cv2.cvtColor(frame2, cv2.COLOR_BGR2GRAY)
28
+ score, _ = ssim(gray1, gray2, full=True)
29
+ return score
30
+
31
+ def should_save_frame(self, image: np.ndarray) -> bool:
32
+ """
33
+ Determine if a frame should be saved based on SSIM comparison.
34
+
35
+ Args:
36
+ image: Current frame in BGR format
37
+
38
+ Returns:
39
+ True if frame should be saved, False otherwise
40
+ """
41
+ if self.prev_frame is None:
42
+ self.prev_frame = image
43
+ return True
44
+
45
+ ssim_score = self.compute_ssim(self.prev_frame, image)
46
+ self.prev_frame = image
47
+ return ssim_score <= self.config.ssim_threshold
@@ -0,0 +1,389 @@
1
+ Metadata-Version: 2.4
2
+ Name: filter_frame_dedup
3
+ Version: 1.1.1
4
+ License-Expression: Apache-2.0
5
+ Classifier: Programming Language :: Python :: 3
6
+ Classifier: Programming Language :: Python :: 3.10
7
+ Classifier: Programming Language :: Python :: 3.11
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Classifier: Programming Language :: Python :: 3.13
10
+ Requires-Python: <3.14,>=3.10
11
+ Description-Content-Type: text/markdown
12
+ License-File: LICENSE
13
+ Requires-Dist: scikit-image==0.25.2
14
+ Requires-Dist: openfilter[all]<0.2.0,>=0.1.0
15
+ Provides-Extra: dev
16
+ Requires-Dist: build==1.2.1; extra == "dev"
17
+ Requires-Dist: setuptools==72.2.0; extra == "dev"
18
+ Requires-Dist: twine<7,>=6.1.0; extra == "dev"
19
+ Requires-Dist: wheel==0.44.0; extra == "dev"
20
+ Requires-Dist: pytest==8.3.4; extra == "dev"
21
+ Requires-Dist: pytest-cov==6.0.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # FrameSelect
25
+
26
+ FrameSelect is a sophisticated OpenFilter component that intelligently reduces redundant frames in video streams. It uses multiple detection methods (hashing, motion analysis, and SSIM comparison) to identify and save only frames that represent significant visual changes, making it ideal for keyframe extraction, storage optimization, and intelligent video sampling.
27
+
28
+ ## Features
29
+
30
+ - **Multi-Method Detection**: Uses perceptual hashing (pHash, aHash, dHash), motion analysis, and SSIM comparison
31
+ - **Intelligent Filtering**: Configurable thresholds for fine-tuning sensitivity
32
+ - **Side Channel Support**: Forward deduplicated frames in separate channels (accessible via `localhost:8000/deduped`)
33
+ - **Upstream Data Forwarding**: Preserve metadata from upstream filters
34
+ - **ROI Processing**: Focus on specific regions of interest
35
+ - **Performance Optimized**: Lightweight processing with minimal overhead
36
+
37
+ ## Quick Start
38
+
39
+ ### Basic Usage
40
+
41
+ ```bash
42
+ # Set environment variables
43
+ export VIDEO_INPUT="../data/sample-video.mp4"
44
+ export OUTPUT_FOLDER="./output"
45
+ export HASH_THRESHOLD="5"
46
+ export MOTION_THRESHOLD="1200"
47
+
48
+ # Run the filter
49
+ python scripts/filter_usage.py
50
+ ```
51
+
52
+ ### Docker Usage
53
+
54
+ ```bash
55
+ # Build and run with Docker Compose
56
+ docker-compose up
57
+ ```
58
+
59
+ ## Configuration
60
+
61
+ ### Environment Variables
62
+
63
+ | Variable | Default | Description |
64
+ |----------|---------|-------------|
65
+ | `VIDEO_INPUT` | `../data/sample-video.mp4` | Input video file path |
66
+ | `OUTPUT_FOLDER` | `./output` | Directory to save deduplicated frames |
67
+ | `SAVE_IMAGES` | `true` | Whether to save images to disk |
68
+ | `HASH_THRESHOLD` | `5` | Minimum hash difference to consider unique |
69
+ | `MOTION_THRESHOLD` | `1200` | Minimum motion intensity threshold |
70
+ | `MIN_TIME_BETWEEN_FRAMES` | `1.0` | Minimum time between saved frames (seconds) |
71
+ | `SSIM_THRESHOLD` | `0.90` | SSIM score threshold (lower = more dissimilar) |
72
+ | `ROI` | `None` | Region of interest as `(x, y, width, height)` |
73
+ | `DEBUG` | `false` | Enable debug logging |
74
+ | `FORWARD_DEDUPED_FRAMES` | `false` | Forward deduplicated frames in side channel |
75
+ | `FORWARD_UPSTREAM_DATA` | `true` | Forward data from upstream filters |
76
+
77
+ ### Configuration Examples
78
+
79
+ #### High Sensitivity (Detailed Keyframes)
80
+ ```python
81
+ {
82
+ "hash_threshold": 3,
83
+ "motion_threshold": 800,
84
+ "min_time_between_frames": 0.5,
85
+ "ssim_threshold": 0.85,
86
+ "forward_deduped_frames": True
87
+ }
88
+ ```
89
+
90
+ #### Security Surveillance
91
+ ```python
92
+ {
93
+ "hash_threshold": 5,
94
+ "motion_threshold": 1200,
95
+ "min_time_between_frames": 2.0,
96
+ "ssim_threshold": 0.90,
97
+ "debug": True
98
+ }
99
+ ```
100
+
101
+ #### Storage Optimization
102
+ ```python
103
+ {
104
+ "hash_threshold": 10,
105
+ "motion_threshold": 2000,
106
+ "min_time_between_frames": 5.0,
107
+ "ssim_threshold": 0.95
108
+ }
109
+ ```
110
+
111
+ #### Detection-Only Mode (No Disk Saving)
112
+ ```python
113
+ {
114
+ "hash_threshold": 5,
115
+ "motion_threshold": 1200,
116
+ "min_time_between_frames": 1.0,
117
+ "ssim_threshold": 0.90,
118
+ "save_images": False,
119
+ "forward_deduped_frames": True
120
+ }
121
+ ```
122
+
123
+ ## Sample Pipelines
124
+
125
+ ### 1. Security Camera Keyframe Extraction
126
+
127
+ ```python
128
+ from openfilter import Filter
129
+
130
+ # Pipeline: VideoIn → FilterFrameDedup → Webvis
131
+ filters = [
132
+ Filter("VideoIn", {
133
+ "sources": "rtsp://security-camera.company.com:554/stream",
134
+ "outputs": "tcp://127.0.0.1:5550"
135
+ }),
136
+ Filter("FilterFrameDedup", {
137
+ "sources": "tcp://127.0.0.1:5550",
138
+ "outputs": "tcp://127.0.0.1:5551",
139
+ "hash_threshold": 5,
140
+ "motion_threshold": 1200,
141
+ "min_time_between_frames": 2.0,
142
+ "ssim_threshold": 0.90,
143
+ "output_folder": "/security_keyframes",
144
+ "forward_deduped_frames": True,
145
+ "debug": True
146
+ }),
147
+ Filter("Webvis", {
148
+ "sources": "tcp://127.0.0.1:5551",
149
+ "outputs": "tcp://127.0.0.1:8080"
150
+ })
151
+ ]
152
+
153
+ Filter.run_multi(filters, exit_time=3600.0) # 1 hour
154
+
155
+ # View results in Webvis:
156
+ # - http://localhost:8080/main (all processed frames)
157
+ # - http://localhost:8080/deduped (only saved keyframes)
158
+ ```
159
+
160
+ ### 2. Content Analysis with ROI
161
+
162
+ ```python
163
+ # Pipeline: VideoIn → FilterFrameDedup → FilterCrop → Webvis
164
+ filters = [
165
+ Filter("VideoIn", {
166
+ "sources": "file://content_video.mp4",
167
+ "outputs": "tcp://127.0.0.1:5550"
168
+ }),
169
+ Filter("FilterFrameDedup", {
170
+ "sources": "tcp://127.0.0.1:5550",
171
+ "outputs": "tcp://127.0.0.1:5551",
172
+ "hash_threshold": 3,
173
+ "motion_threshold": 800,
174
+ "min_time_between_frames": 0.5,
175
+ "ssim_threshold": 0.85,
176
+ "roi": (100, 100, 800, 600),
177
+ "output_folder": "/content_keyframes",
178
+ "forward_deduped_frames": True
179
+ }),
180
+ Filter("FilterCrop", {
181
+ "sources": "tcp://127.0.0.1:5551",
182
+ "outputs": "tcp://127.0.0.1:5552",
183
+ "polygon_points": "[[(100, 100), (700, 100), (700, 500), (100, 500)]]",
184
+ "output_prefix": "thumbnail_",
185
+ "topic_mode": "main_only"
186
+ }),
187
+ Filter("Webvis", {
188
+ "sources": "tcp://127.0.0.1:5552",
189
+ "outputs": "tcp://127.0.0.1:8080"
190
+ })
191
+ ]
192
+
193
+ Filter.run_multi(filters, exit_time=1800.0) # 30 minutes
194
+
195
+ # View results in Webvis:
196
+ # - http://localhost:8080/thumbnail_main (cropped frames)
197
+ # - http://localhost:8080/deduped (keyframes before cropping)
198
+ ```
199
+
200
+ ## Use Cases
201
+
202
+ ### 1. Security Surveillance
203
+ Extract meaningful keyframes from 24/7 security camera footage for efficient storage and review.
204
+
205
+ **Configuration:**
206
+ ```bash
207
+ export HASH_THRESHOLD="5"
208
+ export MOTION_THRESHOLD="1200"
209
+ export MIN_TIME_BETWEEN_FRAMES="2.0"
210
+ export FORWARD_DEDUPED_FRAMES="true"
211
+ ```
212
+
213
+ ### 2. Content Analysis
214
+ Extract keyframes from video content for automated thumbnail generation and scene analysis.
215
+
216
+ **Configuration:**
217
+ ```bash
218
+ export HASH_THRESHOLD="3"
219
+ export MOTION_THRESHOLD="800"
220
+ export MIN_TIME_BETWEEN_FRAMES="0.5"
221
+ export ROI="(100, 100, 800, 600)"
222
+ ```
223
+
224
+ ### 3. Live Stream Processing
225
+ Process live video streams with real-time deduplication and analytics.
226
+
227
+ **Configuration:**
228
+ ```bash
229
+ export HASH_THRESHOLD="4"
230
+ export MOTION_THRESHOLD="1000"
231
+ export MIN_TIME_BETWEEN_FRAMES="1.0"
232
+ export FORWARD_UPSTREAM_DATA="true"
233
+ export DEBUG="true"
234
+ ```
235
+
236
+ ### 4. Storage Optimization
237
+ Process large video files to extract only unique frames for storage optimization.
238
+
239
+ **Configuration:**
240
+ ```bash
241
+ export HASH_THRESHOLD="10"
242
+ export MOTION_THRESHOLD="2000"
243
+ export MIN_TIME_BETWEEN_FRAMES="5.0"
244
+ export SSIM_THRESHOLD="0.95"
245
+ ```
246
+
247
+ ## Side Channel: Deduplicated Frames
248
+
249
+ The filter supports a special **side channel** called `deduped` that contains only the frames that were actually saved. This channel is **asynchronous** - it only emits data when a frame meets all deduplication criteria and gets saved to disk.
250
+
251
+ ### Key Features:
252
+
253
+ - **Asynchronous Operation**: Only emits when frames are actually saved, not for every input frame
254
+ - **Webvis Visualization**: Access at `http://localhost:8000/deduped`
255
+ - **Rich Metadata**: Each frame includes deduplication status, frame number, and saved path
256
+ - **Real-time Monitoring**: Perfect for monitoring keyframe extraction
257
+
258
+ ### Channel Comparison:
259
+
260
+ | Channel | Content | Frequency | Webvis URL |
261
+ |---------|---------|-----------|------------|
262
+ | `main` | All processed frames | Every input frame | `localhost:8000/main` |
263
+ | `deduped` | Only saved frames | Only when saved | `localhost:8000/deduped` |
264
+
265
+ ### Enable Side Channel:
266
+
267
+ ```python
268
+ {
269
+ "forward_deduped_frames": True, # Enable side channel
270
+ "output_folder": "/keyframes"
271
+ }
272
+ ```
273
+
274
+ ## How It Works
275
+
276
+ The filter uses a multi-stage approach:
277
+
278
+ 1. **Hash Analysis**: Computes perceptual, average, and difference hashes
279
+ 2. **Motion Detection**: Analyzes pixel-level differences between frames
280
+ 3. **SSIM Comparison**: Uses Structural Similarity Index for detailed comparison
281
+ 4. **Frame Selection**: Saves frames that meet all criteria:
282
+ - Hash differences exceed threshold OR motion is detected
283
+ - SSIM score is below threshold
284
+ - Minimum time has elapsed since last save
285
+ 5. **Side Channel Output**: If enabled, forwards saved frames to `deduped` channel
286
+
287
+ ## Output
288
+
289
+ ### Saved Frames (when `save_images=True`)
290
+ Frames are saved to the specified directory with sequential naming:
291
+ ```
292
+ /output/
293
+ ├── frame_000001.jpg
294
+ ├── frame_000002.jpg
295
+ └── ...
296
+ ```
297
+
298
+ ### Detection-Only Mode (when `save_images=False`)
299
+ When `save_images=False`, the filter operates in detection-only mode:
300
+ - No files are written to disk
301
+ - Deduplication logic still runs and updates timing
302
+ - Side channels (`deduped`) still work and contain frames that would have been saved
303
+ - Useful for real-time processing without storage overhead
304
+
305
+ ### Side Channels
306
+ When `forward_deduped_frames` is enabled:
307
+ - **Main channel**: All processed frames (accessible via `localhost:8000/main`)
308
+ - **Deduped channel**: Only saved frames with metadata (accessible via `localhost:8000/deduped`)
309
+ - **Asynchronous**: Only emits when frames are actually saved
310
+ - **Rich Metadata**: Includes frame number, saved path, and deduplication status
311
+ - **Real-time Monitoring**: Perfect for monitoring keyframe extraction
312
+
313
+ ### Metadata
314
+ Deduplicated frames include:
315
+ - `deduped`: Boolean flag indicating frame was saved
316
+ - `frame_number`: Sequential frame number
317
+ - `saved_path`: Path to saved file
318
+ - `original_frame_id`: Original frame identifier
319
+
320
+ ## Debug Mode
321
+
322
+ Enable debug mode for detailed processing information:
323
+ ```bash
324
+ export DEBUG="true"
325
+ ```
326
+
327
+ Debug output shows:
328
+ - Hash differences between frames
329
+ - Motion detection results
330
+ - SSIM scores
331
+ - Frame acceptance/rejection decisions
332
+ - Timing information
333
+
334
+ ## Performance Tuning
335
+
336
+ ### Threshold Guidelines
337
+
338
+ | Use Case | Hash Threshold | Motion Threshold | SSIM Threshold | Time Between |
339
+ |----------|----------------|------------------|----------------|--------------|
340
+ | High Detail Keyframes | 3-4 | 800-1000 | 0.85-0.88 | 0.5-1.0s |
341
+ | Security Surveillance | 5-6 | 1200-1500 | 0.90-0.92 | 2.0-3.0s |
342
+ | Content Analysis | 4-5 | 1000-1200 | 0.88-0.90 | 1.0-2.0s |
343
+ | Storage Optimization | 8-10 | 2000+ | 0.95+ | 5.0s+ |
344
+
345
+ ### Performance Tips
346
+
347
+ - Use ROI to focus on important areas and reduce processing time
348
+ - Lower thresholds for detailed analysis, higher for storage optimization
349
+ - Enable `forward_deduped_frames` for side channel access to keyframes
350
+ - Use `debug` mode to tune parameters for your specific use case
351
+
352
+ ## Troubleshooting
353
+
354
+ ### Common Issues
355
+
356
+ **Too many saved frames:**
357
+ - Increase `ssim_threshold` (closer to 1.0)
358
+ - Increase `min_time_between_frames`
359
+ - Increase `hash_threshold`
360
+
361
+ **Too few saved frames:**
362
+ - Decrease `ssim_threshold` (closer to 0.0)
363
+ - Decrease `hash_threshold` and `motion_threshold`
364
+ - Decrease `min_time_between_frames`
365
+
366
+ **High CPU usage:**
367
+ - Increase `hash_threshold` and `motion_threshold`
368
+ - Use ROI to reduce processing area
369
+ - Increase `min_time_between_frames`
370
+
371
+ ## Requirements
372
+
373
+ Install dependencies:
374
+ ```bash
375
+ make run
376
+ ```
377
+
378
+ Or install manually:
379
+ ```bash
380
+ pip install -r requirements.txt
381
+ ```
382
+
383
+ ## Documentation
384
+
385
+ For more detailed information, configuration examples, and advanced usage scenarios, see the [comprehensive documentation](docs/overview.md).
386
+
387
+ ## License
388
+
389
+ See LICENSE file for details.
@@ -0,0 +1,9 @@
1
+ filter_frame_dedup/__init__.py,sha256=5nGHWwb-mYLO_8Ek6bXp6KPgxo2lOABOhfAPs_NkUJU,137
2
+ filter_frame_dedup/filter.py,sha256=z2y6KAl_oOIzzWrXxEPH0X9iF_xUc2fczoNG-a6nxZA,12759
3
+ filter_frame_dedup/hash_processor.py,sha256=rsRWrpXamE4rW1J6L9SnnT8_681xj4fq2lCW5RLmIDM,6299
4
+ filter_frame_dedup/ssim_processor.py,sha256=Q8W6V_xKs0jfaYaDHSne9igY9BrAdtnkE7COQv4dRBk,1421
5
+ filter_frame_dedup-1.1.1.dist-info/licenses/LICENSE,sha256=Hun-T5YKdxKbQ1lfbGCu8YiknxeFsTnWAuSWz6CKQAQ,11343
6
+ filter_frame_dedup-1.1.1.dist-info/METADATA,sha256=--ECaRVWMj8oy0KhJiiqNtBpuG4CeqkRPVOVWJLr6_k,11882
7
+ filter_frame_dedup-1.1.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
8
+ filter_frame_dedup-1.1.1.dist-info/top_level.txt,sha256=i5zhda0eEhLy1waCqjsILVaIXWtLtAlblt_Wx_-vAN4,19
9
+ filter_frame_dedup-1.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [2025] [Plainsight]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1 @@
1
+ filter_frame_dedup