filter-crop 0.1.0__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.
File without changes
filter_crop/filter.py ADDED
@@ -0,0 +1,509 @@
1
+ import logging
2
+ import cv2
3
+ import numpy as np
4
+ import ast
5
+ from openfilter.filter_runtime.filter import FilterConfig, Filter, Frame
6
+ from typing import List, Tuple, Dict, Any, Optional
7
+ import os
8
+
9
+ __all__ = ['FilterCropConfig', 'FilterCrop']
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ SKIP_OCR_FLAG = 'skip_ocr'
14
+ DEFAULT_CLASS_NAME = 'cropped'
15
+
16
+ class FilterCropConfig(FilterConfig):
17
+ """
18
+ Configuration class for the FilterCrop.
19
+
20
+ Attributes:
21
+ polygon_points (str): String representation of polygon coordinates for cropping.
22
+ Format: "[[(x1,y1), (x2,y2), ...]]"
23
+ mutate_original_frames (bool): If True, modify original frames instead of creating new ones.
24
+ output_prefix (Optional[str]): Prefix for output topics when not mutating original frames.
25
+
26
+ # Topic handling modes
27
+ topic_mode (str): How to handle multiple topics:
28
+ - "all": Process all topics
29
+ - "main_only": Only process 'main' topic
30
+ - "selected": Only process topics in 'topics' list
31
+ topics (List[str]): List of topics to process when topic_mode='selected'.
32
+
33
+ # Detection handling
34
+ detection_key (str): Key to look for in frame.data['meta'] for detections.
35
+ detection_class_field (str): Field name for detection class in metadata.
36
+ detection_roi_field (str): Field name for detection ROIs in metadata.
37
+
38
+ # Custom naming
39
+ custom_name (Optional[str]): Custom name for output when not using detection classes.
40
+
41
+ # For backward compatibility
42
+ crop_from_env (bool): Legacy parameter for environment-based configuration.
43
+ class_name (Optional[str]): Legacy parameter for custom class name.
44
+ cropped_frame_prefix (Optional[str]): Legacy parameter for output_prefix.
45
+ """
46
+ # Basic configuration
47
+ polygon_points: Optional[str] = None
48
+ mutate_original_frames: bool = False
49
+ output_prefix: Optional[str] = None
50
+
51
+ # Topic handling
52
+ topic_mode: str = "all"
53
+ topics: List[str] = ["main"]
54
+
55
+ # Detection handling
56
+ detection_key: str = "detections"
57
+ detection_class_field: str = "class"
58
+ detection_roi_field: str = "rois"
59
+
60
+ # Custom naming
61
+ custom_name: Optional[str] = None
62
+
63
+ # Legacy support
64
+ crop_from_env: bool = False
65
+ class_name: Optional[str] = None
66
+ cropped_frame_prefix: Optional[str] = None
67
+
68
+
69
+ class FilterCrop(Filter):
70
+ """
71
+ A filter that crops video frames based on polygon coordinates or detection ROIs.
72
+
73
+ This filter is designed to work within a video processing pipeline, supporting both real-time and batch processing.
74
+ It provides flexible cropping capabilities with multiple operation modes and output options.
75
+
76
+ Features:
77
+ 1. Multiple Operation Modes:
78
+ - Polygon Mode: Crop frames using predefined polygon coordinates
79
+ - Detection Mode: Crop frames based on detection ROIs with class labels
80
+ - Environment Mode: Configure cropping through environment variables
81
+
82
+ 2. Flexible Output Options:
83
+ - Create new frames for cropped regions
84
+ - Modify original frames in-place
85
+ - Support for labeled output topics
86
+ - Maintain original frame as "main" stream
87
+
88
+ 3. Configuration Options:
89
+ - Polygon coordinates can be provided as string or through environment
90
+ - Customizable output topic prefixes
91
+ - Configurable class names for labeled detections
92
+ - Customizable detection metadata keys for class and ROIs
93
+
94
+ 4. Topic Selection Modes:
95
+ - "all": Process all incoming topic frames
96
+ - "main_only": Only process the 'main' topic frame
97
+ - "selected": Only process frames from topics specified in configuration
98
+
99
+ 5. Frame Processing Behavior:
100
+ - For frames with detections: Creates separate output frames for each detection
101
+ - For frames without detections: Uses polygon-based cropping
102
+ - Sets appropriate metadata for downstream processing (e.g., SKIP_OCR_FLAG)
103
+
104
+ 6. Backward Compatibility:
105
+ - Supports legacy configuration via environment variables
106
+ - Maintains compatibility with previous crop filter implementations
107
+
108
+ Parameters:
109
+ polygon_points (str, optional): String representation of polygon coordinates for cropping
110
+ Format: "[[(x1,y1), (x2,y2), ...]]"
111
+ mutate_original_frames (bool): If True, modify original frames instead of creating new ones
112
+ output_prefix (str, optional): Prefix for output topics when not mutating original frames
113
+ topic_mode (str): How to handle multiple topics ("all", "main_only", or "selected")
114
+ topics (List[str]): List of topics to process when topic_mode='selected'
115
+ detection_key (str): Key to look for in frame.data['meta'] for detections
116
+ detection_class_field (str): Field name for detection class in metadata
117
+ detection_roi_field (str): Field name for detection ROIs in metadata
118
+ custom_name (str, optional): Custom name for output when not using detection classes
119
+ crop_from_env (bool): Legacy parameter for environment-based configuration
120
+ class_name (str, optional): Legacy parameter for custom class name
121
+ cropped_frame_prefix (str, optional): Legacy parameter for output_prefix
122
+
123
+ Example Configurations:
124
+
125
+ 1. Basic Polygon Crop:
126
+ ```python
127
+ config = FilterCropConfig(
128
+ polygon_points="[[(100, 100), (400, 100), (400, 300), (100, 300)]]",
129
+ output_prefix="cropped_",
130
+ topic_mode="all"
131
+ )
132
+ ```
133
+
134
+ 2. Detection-based Cropping:
135
+ ```python
136
+ config = FilterCropConfig(
137
+ detection_key="detections",
138
+ detection_class_field="class",
139
+ detection_roi_field="rois",
140
+ topic_mode="main_only"
141
+ )
142
+ ```
143
+
144
+ 3. In-place Modification:
145
+ ```python
146
+ config = FilterCropConfig(
147
+ polygon_points="[[(100, 100), (400, 100), (400, 300), (100, 300)]]",
148
+ mutate_original_frames=True
149
+ )
150
+ ```
151
+
152
+ 4. Topic-selective Processing:
153
+ ```python
154
+ config = FilterCropConfig(
155
+ polygon_points="[[(100, 100), (400, 100), (400, 300), (100, 300)]]",
156
+ topic_mode="selected",
157
+ topics=["camera1", "camera2"],
158
+ output_prefix="region_"
159
+ )
160
+ ```
161
+
162
+ 5. Custom Detection Fields:
163
+ ```python
164
+ config = FilterCropConfig(
165
+ detection_key="objects",
166
+ detection_class_field="label",
167
+ detection_roi_field="bounding_boxes",
168
+ topic_mode="all"
169
+ )
170
+ ```
171
+ """
172
+
173
+ @classmethod
174
+ def normalize_config(cls, config: FilterCropConfig) -> FilterCropConfig:
175
+ """
176
+ Normalize and validate the filter configuration.
177
+
178
+ This method:
179
+ 1. Loads configuration from environment variables if specified
180
+ 2. Converts polygon points from string to list format
181
+ 3. Validates polygon points and configuration parameters
182
+ 4. Ensures required parameters are present based on operation mode
183
+
184
+ Args:
185
+ config (FilterCropConfig): The configuration to normalize
186
+
187
+ Returns:
188
+ FilterCropConfig: The normalized configuration
189
+
190
+ Raises:
191
+ ValueError: If polygon points are invalid or required parameters are missing
192
+ """
193
+ config = FilterCropConfig(super().normalize_config(config))
194
+
195
+ # Handle legacy parameters
196
+ if config.cropped_frame_prefix and not config.output_prefix:
197
+ config.output_prefix = config.cropped_frame_prefix
198
+
199
+ env_mapping = {
200
+ "mutate_original_frames": bool,
201
+ "output_prefix": str,
202
+ "polygon_points": str,
203
+ "topic_mode": str,
204
+ "topics": list,
205
+ "detection_key": str,
206
+ "detection_class_field": str,
207
+ "detection_roi_field": str,
208
+ "custom_name": str,
209
+ "crop_from_env": bool,
210
+ "class_name": str,
211
+ }
212
+
213
+ for key, expected_type in env_mapping.items():
214
+ env_key = f"FILTER_{key.upper()}"
215
+ env_val = os.getenv(env_key)
216
+ if env_val is not None:
217
+ if expected_type is bool:
218
+ setattr(config, key, env_val.strip().lower() == "true")
219
+ else:
220
+ setattr(config, key, env_val.strip())
221
+
222
+ # Convert polygon_points from string to list if it is a string
223
+ if isinstance(config.polygon_points, str):
224
+ config.polygon_points = ast.literal_eval(config.polygon_points)
225
+ if isinstance(config.polygon_points[0], tuple):
226
+ config.polygon_points = [config.polygon_points]
227
+ if len(config.polygon_points[0]) < 3:
228
+ raise ValueError("Polygon must have at least three vertices.")
229
+ else:
230
+ # Assume polygon will be provided elsewhere or not used
231
+ config.polygon_points = None
232
+
233
+ # Only validate output_prefix if we're not using detections and not mutating frames
234
+ if not config.mutate_original_frames and not config.output_prefix and config.polygon_points is not None:
235
+ raise ValueError("output_prefix cannot be empty when mutate_original_frames is False and using polygon mode.")
236
+
237
+ return config
238
+
239
+ def setup(self, config: FilterCropConfig):
240
+ """
241
+ Initialize the filter with the given configuration.
242
+
243
+ This method:
244
+ 1. Sets up instance variables from the configuration
245
+ 2. Processes polygon points if provided
246
+ 3. Computes bounding box for cropping
247
+
248
+ Args:
249
+ config (FilterCropConfig): The configuration to use for setup
250
+ """
251
+ logger.info("===========================================")
252
+ logger.info(f"FilterPolygonCrop setup: {config}")
253
+ logger.info("===========================================")
254
+
255
+ self.mutate_original_frames = config.mutate_original_frames
256
+ self.output_prefix = config.output_prefix
257
+ self.polygon_points = config.polygon_points
258
+ self.topic_mode = config.topic_mode
259
+ self.topics = config.topics
260
+ self.detection_key = config.detection_key
261
+ self.detection_class_field = config.detection_class_field
262
+ self.detection_roi_field = config.detection_roi_field
263
+ self.custom_name = config.custom_name
264
+
265
+ # Legacy support
266
+ self.crop_from_env = config.crop_from_env
267
+ self.class_name = config.class_name
268
+
269
+ self.config = config
270
+
271
+ if config.polygon_points is None:
272
+ self.polygon_points = None
273
+ else:
274
+ # Parse the string into a list of points
275
+ if isinstance(config.polygon_points, str):
276
+ self.polygon_points = ast.literal_eval(config.polygon_points)
277
+ else:
278
+ self.polygon_points = config.polygon_points
279
+
280
+ # Convert to numpy array
281
+ self.polygon_points = np.array(self.polygon_points, dtype=np.int32)
282
+
283
+ # Compute bounding box around the polygon for crop dimensions
284
+ self.x, self.y, self.w, self.h = self.polygon_to_bbox(self.polygon_points)
285
+
286
+ logger.info("Setup Completed for Crop Filter")
287
+
288
+ def shutdown(self):
289
+ """Clean up resources when the filter is shut down."""
290
+ logger.info("Shutdown completed!")
291
+
292
+ def process(self, frames: Dict[str, Frame]) -> Dict[str, Frame]:
293
+ """Process incoming frames and apply cropping based on configuration.
294
+
295
+ The filter operates in three modes:
296
+ 1. Detection Mode: Uses detection ROIs to crop frames
297
+ 2. Polygon Mode: Uses predefined polygon coordinates to crop frames
298
+ 3. Environment Mode: Uses environment configuration for cropping
299
+
300
+ Args:
301
+ frames: Dictionary of input frames
302
+
303
+ Returns:
304
+ Dictionary of processed frames with appropriate keys
305
+ """
306
+ output_frames = {}
307
+
308
+ # Tag frames with their topics and identify frames with detections
309
+ for topic, frame in frames.items():
310
+ if 'meta' not in frame.data:
311
+ frame.data['meta'] = {}
312
+ frame.data['meta']['topic'] = topic
313
+
314
+ # Always return original frames if mutating
315
+ if self.mutate_original_frames:
316
+ output_frames[topic] = frame
317
+
318
+ # Filter frames based on topic_mode
319
+ filtered_frames = {}
320
+ for topic, frame in frames.items():
321
+ if self.topic_mode == "main_only":
322
+ if topic == "main":
323
+ filtered_frames[topic] = frame
324
+ # If no 'main' topic exists, use the first frame
325
+ elif 'main' not in frames and len(filtered_frames) == 0:
326
+ filtered_frames[topic] = frame
327
+ elif self.topic_mode == "selected":
328
+ if topic in self.topics:
329
+ filtered_frames[topic] = frame
330
+ else: # "all" mode
331
+ filtered_frames[topic] = frame
332
+
333
+ # Group frames by detection presence
334
+ detection_frames = {}
335
+ non_detection_frames = {}
336
+
337
+ for topic, frame in filtered_frames.items():
338
+ # Use configured metadata key to check for detections
339
+ has_detections = (
340
+ self.detection_key in frame.data.get('meta', {}) and
341
+ frame.data['meta'][self.detection_key]
342
+ )
343
+
344
+ if has_detections:
345
+ detection_frames[topic] = frame
346
+ else:
347
+ non_detection_frames[topic] = frame
348
+
349
+ # Process frames with detections
350
+ for topic, frame in detection_frames.items():
351
+ processed = self._process_detection_frame(frame)
352
+ output_frames.update(processed)
353
+
354
+ # Process frames without detections
355
+ for topic, frame in non_detection_frames.items():
356
+ processed = self._process_polygon_frame(frame)
357
+ output_frames.update(processed)
358
+
359
+ def sanitize_frame_data(frames: Dict[str, Frame]):
360
+ for frame in frames.values():
361
+ for k, v in list(frame.data.items()):
362
+ if isinstance(v, np.ndarray):
363
+ logger.warning(f"Removing unserializable key '{k}' from frame.data")
364
+ del frame.data[k]
365
+
366
+ sanitize_frame_data(output_frames)
367
+ return output_frames
368
+
369
+ def _process_detection_frame(self, frame: Frame) -> Dict[str, Frame]:
370
+ """Process a frame with detections."""
371
+ output_frames = {}
372
+ image = frame.rw_bgr.image
373
+ topic = frame.data.get('meta', {}).get('topic', 'main')
374
+
375
+ # Always create main frame first
376
+ if self.mutate_original_frames:
377
+ frame.data['meta'][SKIP_OCR_FLAG] = False
378
+ else:
379
+ main_frame = Frame(image, {**frame.data}, 'BGR')
380
+ main_frame.data['meta'][SKIP_OCR_FLAG] = False
381
+ output_frames['main'] = main_frame
382
+
383
+
384
+ # Get detections using configured key
385
+ detections = frame.data['meta'].get(self.detection_key, [])
386
+
387
+ if detections:
388
+ # Track class counts to generate unique keys
389
+ class_counts = {}
390
+
391
+ # Process each detection
392
+ for detection in detections:
393
+ # Use configured keys for class and ROI
394
+ class_name = detection.get(self.detection_class_field, DEFAULT_CLASS_NAME)
395
+ rois = detection.get(self.detection_roi_field, [])
396
+
397
+ for roi in rois:
398
+ # Update count for this class
399
+ count = class_counts.get(class_name, 0) + 1
400
+ class_counts[class_name] = count
401
+
402
+ # Create a unique key for this detection
403
+ unique_key = class_name if count == 1 else f"{class_name}_{count}"
404
+
405
+ # Crop the frame using the ROI coordinates
406
+ x1, y1, x2, y2 = roi
407
+ cropped_frame = image[y1:y2, x1:x2]
408
+
409
+ if self.mutate_original_frames:
410
+ frame = Frame(cropped_frame, {**frame.data}, 'BGR')
411
+ frame.data['meta'][SKIP_OCR_FLAG] = False
412
+ output_frames[topic] = frame
413
+ else:
414
+ # Create a new frame for the cropped image
415
+ crop_frame = Frame(cropped_frame, {**frame.data}, 'BGR')
416
+ crop_frame.data['meta'][SKIP_OCR_FLAG] = False
417
+ output_frames[unique_key] = crop_frame
418
+ else:
419
+ # No detections, just set SKIP_OCR_FLAG
420
+ frame.data['meta'][SKIP_OCR_FLAG] = True
421
+
422
+ return output_frames
423
+
424
+ def _process_polygon_frame(self, frame: Frame) -> Dict[str, Frame]:
425
+ """Process a frame without detections using polygon mode or environment mode."""
426
+ output_frames = {}
427
+ image = frame.rw_bgr.image
428
+ topic = frame.data.get('meta', {}).get('topic', 'main')
429
+
430
+ # Always create main frame first
431
+ if self.mutate_original_frames:
432
+ frame.data['meta'][SKIP_OCR_FLAG] = False
433
+ else:
434
+ main_frame = Frame(image, {**frame.data}, 'BGR')
435
+ main_frame.data['meta'][SKIP_OCR_FLAG] = False
436
+ output_frames['main'] = main_frame
437
+
438
+ # Handle environment mode (for legacy support)
439
+ if self.crop_from_env:
440
+ # Use class_name if specified, otherwise use DEFAULT_CLASS_NAME
441
+ class_name = self.class_name or DEFAULT_CLASS_NAME
442
+
443
+ # Apply output prefix if specified
444
+ if self.output_prefix:
445
+ output_key = f"{self.output_prefix}{class_name}"
446
+ else:
447
+ output_key = class_name
448
+
449
+ # Create a new frame with the original image
450
+ env_frame = Frame(image, {**frame.data}, 'BGR')
451
+ env_frame.data['meta'][SKIP_OCR_FLAG] = False
452
+ output_frames[output_key] = env_frame
453
+ return output_frames
454
+
455
+ # Handle polygon mode
456
+ if self.polygon_points is not None:
457
+ # Create mask from polygon
458
+ mask = np.zeros_like(image, dtype=np.uint8)
459
+ cv2.fillPoly(mask, [self.polygon_points], (255, 255, 255))
460
+
461
+ # Apply mask and crop
462
+ masked_frame = cv2.bitwise_and(image, mask)
463
+ cropped_frame = masked_frame[self.y:self.y + self.h, self.x:self.x + self.w]
464
+
465
+ if self.mutate_original_frames:
466
+ frame = Frame(cropped_frame, {**frame.data}, 'BGR')
467
+ frame.data['meta'][SKIP_OCR_FLAG] = False
468
+ output_frames[topic] = frame
469
+ else:
470
+ # Create a new frame with the cropped image
471
+ if self.custom_name:
472
+ output_key = self.custom_name
473
+ else:
474
+ output_key = topic
475
+
476
+ if self.output_prefix:
477
+ output_key = f"{self.output_prefix}{output_key}"
478
+
479
+ crop_frame = Frame(cropped_frame, {**frame.data}, 'BGR')
480
+ crop_frame.data['meta'][SKIP_OCR_FLAG] = False
481
+ output_frames[output_key] = crop_frame
482
+ else:
483
+ # No polygon points, just pass through the frame
484
+ frame.data['meta'][SKIP_OCR_FLAG] = True
485
+ output_frames[topic] = frame
486
+
487
+ return output_frames
488
+
489
+ @classmethod
490
+ def polygon_to_bbox(cls, polygon_points: List[Tuple[int, int]]) -> Tuple[int, int, int, int]:
491
+ """
492
+ Convert a list of polygon points to a bounding box.
493
+
494
+ This method:
495
+ 1. Computes the minimum bounding rectangle that contains all polygon points
496
+ 2. Returns the top-left corner coordinates and dimensions of the rectangle
497
+
498
+ Args:
499
+ polygon_points (List[Tuple[int, int]]): List of (x, y) coordinates defining the polygon
500
+
501
+ Returns:
502
+ Tuple[int, int, int, int]: (x, y, width, height) of the bounding box
503
+ """
504
+ x, y, w, h = cv2.boundingRect(polygon_points)
505
+ return x, y, w, h
506
+
507
+
508
+ if __name__ == '__main__':
509
+ FilterCrop.run()
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: filter_crop
3
+ Version: 0.1.0
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
+ Requires-Python: <3.13,>=3.10
10
+ Description-Content-Type: text/markdown
11
+ License-File: LICENSE
12
+ Requires-Dist: openfilter[all]==0.1.1
13
+ Provides-Extra: dev
14
+ Requires-Dist: build==1.2.1; extra == "dev"
15
+ Requires-Dist: setuptools==72.2.0; extra == "dev"
16
+ Requires-Dist: twine<7,>=6.1.0; extra == "dev"
17
+ Requires-Dist: wheel==0.44.0; extra == "dev"
18
+ Requires-Dist: pytest==8.3.4; extra == "dev"
19
+ Requires-Dist: pytest-cov==6.0.0; extra == "dev"
20
+ Dynamic: license-file
21
+
22
+ # 🪚 Crop Filter
23
+
24
+ **Crop Filter** is a modular [OpenFilter](https://github.com/PlainsightAI/openfilter)-based filter for extracting regions of interest (ROIs) from video frames using either detection metadata or predefined polygons.
25
+
26
+ It supports polygon-based cropping, detection-based cropping, and legacy environment-based configurations — all while integrating seamlessly into OpenFilter pipelines.
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/filter-crop.svg?style=flat-square)](https://pypi.org/project/filter-crop/)
29
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://github.com/PlainsightAI/filter-crop/blob/main/LICENSE)
30
+ ![Build Status](https://github.com/PlainsightAI/filter-crop/actions/workflows/ci.yaml/badge.svg)
31
+
32
+ ---
33
+
34
+ ## ✨ Features
35
+
36
+ - 🎯 Crop using polygon coordinates or detection bounding boxes
37
+ - 🔀 Supports in-place modification or creation of new cropped frames
38
+ - 🧠 Configurable via CLI args, code, or environment variables
39
+ - 🧩 Plug-and-play compatibility with [OpenFilter](https://github.com/PlainsightAI/openfilter)
40
+ - ⚙️ Supports topic selection modes and legacy configuration
41
+
42
+ ---
43
+
44
+ ## 📦 Installation
45
+
46
+ Install the latest version from PyPI:
47
+
48
+ ```bash
49
+ pip install filter-crop
50
+ ````
51
+
52
+ Or install from source:
53
+
54
+ ```bash
55
+ git clone https://github.com/PlainsightAI/filter-crop.git
56
+ cd filter-crop
57
+ make install
58
+ ```
59
+
60
+ ---
61
+
62
+ ## 🚀 Quick Start (CLI)
63
+
64
+ Use the OpenFilter CLI to run the Crop Filter in a processing pipeline:
65
+
66
+ ```bash
67
+ openfilter run \
68
+ - VideoIn --sources file://filter_example_video.mp4!loop \
69
+ - filter_crop.filter.FilterCrop \
70
+ --polygon_points "[[(0, 500), (0, 720), (1280, 720), (1280, 500)]]" \
71
+ --mutate_original_frames true \
72
+ --topic_mode main_only \
73
+ - Webvis
74
+ ```
75
+
76
+ Or simply:
77
+
78
+ ```bash
79
+ make run
80
+ ```
81
+
82
+ Then open [http://localhost:8000](http://localhost:8000) to view the output.
83
+
84
+ ---
85
+
86
+ ## 🧰 Using from PyPI
87
+
88
+ After installing with:
89
+
90
+ ```bash
91
+ pip install filter-crop
92
+ ```
93
+
94
+ You can use the Crop Filter directly in code:
95
+
96
+ ### Standalone usage
97
+
98
+ ```python
99
+ from filter_crop.filter import FilterCrop
100
+
101
+ if __name__ == "__main__":
102
+ FilterCrop.run()
103
+ ```
104
+
105
+ ### In a multi-filter OpenFilter pipeline
106
+
107
+ ```python
108
+ from openfilter.filter_runtime.filter import Filter
109
+ from openfilter.filter_runtime.filters.video_in import VideoIn
110
+ from openfilter.filter_runtime.filters.webvis import Webvis
111
+ from filter_crop.filter import FilterCrop
112
+
113
+ if __name__ == "__main__":
114
+ Filter.run_multi([
115
+ (VideoIn, dict(sources='file://example.mp4!loop')),
116
+ (FilterCrop, dict(
117
+ polygon_points=[[(100, 100), (400, 100), (400, 300), (100, 300)]],
118
+ output_prefix="crop_",
119
+ topic_mode="main_only"
120
+ )),
121
+ (Webvis, {}),
122
+ ])
123
+ ```
124
+
125
+ ---
126
+
127
+ ## 🧪 Testing
128
+
129
+ Run tests locally:
130
+
131
+ ```bash
132
+ make test
133
+ ```
134
+
135
+ Or run a specific file:
136
+
137
+ ```bash
138
+ pytest -v tests/test_filter_crop.py
139
+ ```
140
+
141
+ Tests cover:
142
+
143
+ * Polygon cropping
144
+ * Detection metadata
145
+ * ROI handling
146
+ * Edge cases (missing metadata, malformed inputs)
147
+
148
+ ---
149
+
150
+ ## 🔧 Example Configs
151
+
152
+ ### Polygon Crop
153
+
154
+ ```python
155
+ FilterCropConfig(
156
+ polygon_points="[[(100, 100), (400, 100), (400, 300), (100, 300)]]",
157
+ output_prefix="cropped_",
158
+ topic_mode="all"
159
+ )
160
+ ```
161
+
162
+ ### Detection-based Crop
163
+
164
+ ```python
165
+ FilterCropConfig(
166
+ detection_key="detections",
167
+ detection_class_field="class",
168
+ detection_roi_field="rois",
169
+ topic_mode="main_only"
170
+ )
171
+ ```
172
+
173
+ ### In-place Modification
174
+
175
+ ```python
176
+ FilterCropConfig(
177
+ polygon_points="[[(100, 100), (400, 100), (400, 300), (100, 300)]]",
178
+ mutate_original_frames=True
179
+ )
180
+ ```
181
+
182
+ ---
183
+
184
+ ## 🧩 How it Works
185
+
186
+ The filter supports three main cropping strategies:
187
+
188
+ | Mode | Input Needed | Output Behavior |
189
+ | ------------------ | --------------------------------- | ------------------------------------------ |
190
+ | Detection | Metadata with `rois` and `class` | Crops and emits one frame per detection |
191
+ | Polygon | List of polygon points | Applies polygon mask and crops the region |
192
+ | Env-based (legacy) | ENV vars like `FILTER_CLASS_NAME` | Emits a labeled copy of the original frame |
193
+
194
+ ---
195
+
196
+ ## 🤝 Contributing
197
+
198
+ We welcome PRs! Please read our [CONTRIBUTING.md](https://github.com/PlainsightAI/filter-crop/blob/main/CONTRIBUTING.md) for instructions.
199
+
200
+ **Highlights**:
201
+
202
+ * Format code with `black`
203
+ * Lint with `ruff`
204
+ * Use type hints on public methods
205
+ * Sign commits using DCO (`git commit -s`)
206
+ * Include tests when relevant
207
+
208
+ ---
209
+
210
+ ## 📄 License
211
+
212
+ Licensed under the [Apache 2.0 License](https://github.com/PlainsightAI/filter-crop/blob/main/LICENSE).
213
+
214
+ ---
215
+
216
+ ## 🙏 Acknowledgements
217
+
218
+ Thanks for using and improving the Crop Filter!
219
+ For issues or suggestions, [open a GitHub issue](https://github.com/PlainsightAI/filter-crop/issues/new/choose).
@@ -0,0 +1,7 @@
1
+ filter_crop/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ filter_crop/filter.py,sha256=naa5dFHHXW2LK5JGReqimH9DcnDQweI-iEKyLgwl7DE,20780
3
+ filter_crop-0.1.0.dist-info/licenses/LICENSE,sha256=Hun-T5YKdxKbQ1lfbGCu8YiknxeFsTnWAuSWz6CKQAQ,11343
4
+ filter_crop-0.1.0.dist-info/METADATA,sha256=PX3obkmhGyCL5DwwmuW0JVX-RGwVnazilHPCeVGzyqs,5561
5
+ filter_crop-0.1.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
6
+ filter_crop-0.1.0.dist-info/top_level.txt,sha256=F1XUSTXB-lp7PAMAUYvz2UCZsoO1ummLYhCOgRzEzzI,12
7
+ filter_crop-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.7.1)
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_crop