ImagePRO-Python 1.3.1__tar.gz → 1.4.0__tar.gz

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.
Files changed (59) hide show
  1. {imagepro_python-1.3.1/src/ImagePRO_Python.egg-info → imagepro_python-1.4.0}/PKG-INFO +24 -1
  2. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/pyproject.toml +1 -1
  3. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/readme.md +23 -0
  4. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/__init__.py +5 -2
  5. imagepro_python-1.4.0/src/ImagePRO/pipeline/__init__.py +9 -0
  6. imagepro_python-1.4.0/src/ImagePRO/pipeline/pipeline.py +362 -0
  7. {imagepro_python-1.3.1 → imagepro_python-1.4.0/src/ImagePRO_Python.egg-info}/PKG-INFO +24 -1
  8. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO_Python.egg-info/SOURCES.txt +3 -0
  9. imagepro_python-1.4.0/tests/test_pipeline.py +357 -0
  10. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/LICENSE +0 -0
  11. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/setup.cfg +0 -0
  12. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/__init__.py +0 -0
  13. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/body_analysis/__init__.py +0 -0
  14. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/body_analysis/body_pose_estimation.py +0 -0
  15. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/body_analysis/hand_tracking.py +0 -0
  16. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/face_analysis/__init__.py +0 -0
  17. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/face_analysis/eye_status_analysis.py +0 -0
  18. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/face_analysis/face_comparison.py +0 -0
  19. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/face_analysis/face_detection.py +0 -0
  20. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/face_analysis/face_mesh_analysis.py +0 -0
  21. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/human_analysis/face_analysis/head_pose_estimation.py +0 -0
  22. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/object_analysis/__init__.py +0 -0
  23. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/object_analysis/object_detection.py +0 -0
  24. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/__init__.py +0 -0
  25. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/blur.py +0 -0
  26. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/contrast.py +0 -0
  27. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/crop.py +0 -0
  28. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/dataset_generator.py +0 -0
  29. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/grayscale.py +0 -0
  30. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/histogram.py +0 -0
  31. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/resize.py +0 -0
  32. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/rotate.py +0 -0
  33. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/pre_processing/sharpen.py +0 -0
  34. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/utils/__init__.py +0 -0
  35. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/utils/image.py +0 -0
  36. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO/utils/result.py +0 -0
  37. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO_Python.egg-info/dependency_links.txt +0 -0
  38. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO_Python.egg-info/requires.txt +0 -0
  39. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/src/ImagePRO_Python.egg-info/top_level.txt +0 -0
  40. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_blur.py +0 -0
  41. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_body_pose_estimation.py +0 -0
  42. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_contrast.py +0 -0
  43. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_crop.py +0 -0
  44. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_dataset_generator.py +0 -0
  45. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_eye_status_analysis.py +0 -0
  46. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_face_comparison.py +0 -0
  47. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_face_detection.py +0 -0
  48. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_face_mesh_analysis.py +0 -0
  49. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_grayscale.py +0 -0
  50. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_hand_tracking.py +0 -0
  51. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_head_pose_estimation.py +0 -0
  52. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_histogram.py +0 -0
  53. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_image.py +0 -0
  54. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_integration.py +0 -0
  55. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_object_detection.py +0 -0
  56. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_resize.py +0 -0
  57. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_result.py +0 -0
  58. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_rotate.py +0 -0
  59. {imagepro_python-1.3.1 → imagepro_python-1.4.0}/tests/test_sharpen.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ImagePRO-Python
3
- Version: 1.3.1
3
+ Version: 1.4.0
4
4
  Summary: Professional & modular image processing library with OpenCV, MediaPipe, and YOLO integration.
5
5
  Author-email: Parsa Safaie <parsasafaie.2568@proton.me>
6
6
  License: MIT
@@ -76,6 +76,11 @@ Whether you're working on computer vision pipelines, preprocessing images for AI
76
76
  - **YOLO Integration**: Multiple accuracy levels (nano to extra-large)
77
77
  - **Flexible Models**: Pre-trained or custom model support
78
78
 
79
+ ### **Pipelines**
80
+ - **Reusable Operation Chains**: Define a sequence of functions once, then run it on any input (Image, NumPy array, or file path)
81
+ - **Flexible Steps**: Any ImagePRO function or custom callable; per-step keyword arguments via `Step`
82
+ - **Pipeline Metadata**: Every result lists the executed steps and each step's parameters
83
+
79
84
  ## Installation
80
85
 
81
86
  ### From PyPI
@@ -154,6 +159,22 @@ object_detection_result = detect_objects(
154
159
  )
155
160
  print(f"Detected {len(object_detection_result.data)} objects")
156
161
  object_detection_result.save_as_img("detections.jpg")
162
+
163
+ # Chain operations into a reusable pipeline
164
+ from ImagePRO.pipeline import Pipeline, Step
165
+ from ImagePRO.pre_processing.grayscale import convert_to_grayscale
166
+ from ImagePRO.pre_processing.resize import resize_image
167
+
168
+ pipeline = Pipeline([
169
+ Step(apply_gaussian_blur, kernel_size=(7, 7)),
170
+ convert_to_grayscale,
171
+ Step(resize_image, new_size=(800, 600)),
172
+ ])
173
+
174
+ # Run it on any input: an Image, a NumPy array, or a file path
175
+ pipeline_result = pipeline.run("person_and_objects.jpg")
176
+ pipeline_result.save_as_img("processed_output.jpg")
177
+ print(pipeline_result.meta["steps"]) # Executed step names
157
178
  ```
158
179
 
159
180
  > **Note**: These are basic examples. Each module contains many more functions with extensive customization options. Explore the module-specific README files for detailed documentation.
@@ -167,6 +188,7 @@ Each module includes comprehensive documentation with detailed examples:
167
188
  - [Face Analysis](src/ImagePRO/human_analysis/face_analysis/README.md): Face mesh, pose estimation, eye status, comparison
168
189
  - [Body Analysis](src/ImagePRO/human_analysis/body_analysis/README.md): Body pose and hand tracking
169
190
  - **[Object Analysis](src/ImagePRO/object_analysis/README.md)**: YOLO-based object detection
191
+ - **[Pipeline](src/ImagePRO/pipeline/README.md)**: Reusable chains of image processing operations
170
192
  - **[Utils](src/ImagePRO/utils/README.md)**: Shared utilities and I/O handling
171
193
 
172
194
  For detailed project structure and development guidelines, see [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md).
@@ -178,6 +200,7 @@ ImagePRO is built with a modular architecture designed for extensibility and mai
178
200
  - **Clean Separation of Concerns**: Each module handles a specific domain
179
201
  - **Consistent API Patterns**: All functions follow the same input/output conventions
180
202
  - **Shared Utilities**: Common `Image` and `Result` classes for unified I/O
203
+ - **Reusable Pipelines**: Chain any operations into a `Pipeline` and run it on any input
181
204
  - **Lazy Optional Imports**: Heavy AI dependencies (MediaPipe, Ultralytics, InsightFace) are only imported inside the functions that use them, keeping `import ImagePRO` fast and independent of installed extras
182
205
  - **Professional Error Handling**: Comprehensive validation with clear error messages
183
206
  - **Type Safety**: Full type hints throughout the codebase
@@ -7,7 +7,7 @@ where = ["src"]
7
7
 
8
8
  [project]
9
9
  name = "ImagePRO-Python"
10
- version = "1.3.1"
10
+ version = "1.4.0"
11
11
  description = "Professional & modular image processing library with OpenCV, MediaPipe, and YOLO integration."
12
12
  readme = "readme.md"
13
13
  requires-python = ">=3.9,<3.13"
@@ -27,6 +27,11 @@ Whether you're working on computer vision pipelines, preprocessing images for AI
27
27
  - **YOLO Integration**: Multiple accuracy levels (nano to extra-large)
28
28
  - **Flexible Models**: Pre-trained or custom model support
29
29
 
30
+ ### **Pipelines**
31
+ - **Reusable Operation Chains**: Define a sequence of functions once, then run it on any input (Image, NumPy array, or file path)
32
+ - **Flexible Steps**: Any ImagePRO function or custom callable; per-step keyword arguments via `Step`
33
+ - **Pipeline Metadata**: Every result lists the executed steps and each step's parameters
34
+
30
35
  ## Installation
31
36
 
32
37
  ### From PyPI
@@ -105,6 +110,22 @@ object_detection_result = detect_objects(
105
110
  )
106
111
  print(f"Detected {len(object_detection_result.data)} objects")
107
112
  object_detection_result.save_as_img("detections.jpg")
113
+
114
+ # Chain operations into a reusable pipeline
115
+ from ImagePRO.pipeline import Pipeline, Step
116
+ from ImagePRO.pre_processing.grayscale import convert_to_grayscale
117
+ from ImagePRO.pre_processing.resize import resize_image
118
+
119
+ pipeline = Pipeline([
120
+ Step(apply_gaussian_blur, kernel_size=(7, 7)),
121
+ convert_to_grayscale,
122
+ Step(resize_image, new_size=(800, 600)),
123
+ ])
124
+
125
+ # Run it on any input: an Image, a NumPy array, or a file path
126
+ pipeline_result = pipeline.run("person_and_objects.jpg")
127
+ pipeline_result.save_as_img("processed_output.jpg")
128
+ print(pipeline_result.meta["steps"]) # Executed step names
108
129
  ```
109
130
 
110
131
  > **Note**: These are basic examples. Each module contains many more functions with extensive customization options. Explore the module-specific README files for detailed documentation.
@@ -118,6 +139,7 @@ Each module includes comprehensive documentation with detailed examples:
118
139
  - [Face Analysis](src/ImagePRO/human_analysis/face_analysis/README.md): Face mesh, pose estimation, eye status, comparison
119
140
  - [Body Analysis](src/ImagePRO/human_analysis/body_analysis/README.md): Body pose and hand tracking
120
141
  - **[Object Analysis](src/ImagePRO/object_analysis/README.md)**: YOLO-based object detection
142
+ - **[Pipeline](src/ImagePRO/pipeline/README.md)**: Reusable chains of image processing operations
121
143
  - **[Utils](src/ImagePRO/utils/README.md)**: Shared utilities and I/O handling
122
144
 
123
145
  For detailed project structure and development guidelines, see [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md).
@@ -129,6 +151,7 @@ ImagePRO is built with a modular architecture designed for extensibility and mai
129
151
  - **Clean Separation of Concerns**: Each module handles a specific domain
130
152
  - **Consistent API Patterns**: All functions follow the same input/output conventions
131
153
  - **Shared Utilities**: Common `Image` and `Result` classes for unified I/O
154
+ - **Reusable Pipelines**: Chain any operations into a `Pipeline` and run it on any input
132
155
  - **Lazy Optional Imports**: Heavy AI dependencies (MediaPipe, Ultralytics, InsightFace) are only imported inside the functions that use them, keeping `import ImagePRO` fast and independent of installed extras
133
156
  - **Professional Error Handling**: Comprehensive validation with clear error messages
134
157
  - **Type Safety**: Full type hints throughout the codebase
@@ -12,10 +12,11 @@ Features:
12
12
  - Pre-processing: resize, crop, rotate, blur, sharpen, contrast enhancement
13
13
  - Human analysis: face mesh, pose estimation, hand tracking
14
14
  - Object detection with YOLO models
15
+ - Reusable pipelines for chaining operations
15
16
  - Real-time processing capabilities
16
17
  """
17
18
 
18
- __version__ = "1.3.1"
19
+ __version__ = "1.4.0"
19
20
  __author__ = "Parsa Safaie"
20
21
  __email__ = "parsasafaie.2568@proton.me"
21
22
 
@@ -24,10 +25,12 @@ from . import utils
24
25
  from . import pre_processing
25
26
  from . import human_analysis
26
27
  from . import object_analysis
28
+ from . import pipeline
27
29
 
28
30
  __all__ = [
29
31
  "utils",
30
32
  "pre_processing",
31
33
  "human_analysis",
32
- "object_analysis"
34
+ "object_analysis",
35
+ "pipeline"
33
36
  ]
@@ -0,0 +1,9 @@
1
+ # Pipeline Module
2
+ # Provides a reusable, ordered chain of image processing operations
3
+ #
4
+ # Uses only base dependencies (numpy); all operations handed to a Pipeline
5
+ # may come from any module, including those with optional backends.
6
+
7
+ from .pipeline import Pipeline, Step
8
+
9
+ __all__ = ["Pipeline", "Step"]
@@ -0,0 +1,362 @@
1
+ from __future__ import annotations
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Iterator
6
+
7
+ # Add src directory to path for absolute imports
8
+ _file_path = Path(__file__).resolve()
9
+ _src_path = _file_path.parents[2] # Go up to src directory
10
+ if str(_src_path) not in sys.path:
11
+ sys.path.insert(0, str(_src_path))
12
+
13
+ import numpy as np
14
+
15
+ from ImagePRO.utils.image import Image
16
+ from ImagePRO.utils.result import Result
17
+
18
+
19
+ class Step:
20
+ """
21
+ A single operation in a Pipeline, with optional keyword arguments.
22
+
23
+ Wraps any callable that accepts an Image as its first positional argument
24
+ and returns a Result (or a numpy.ndarray, treated as the output image).
25
+ Keyword arguments given to the constructor are forwarded to the wrapped
26
+ function on every call, so a Step fully describes one stage of a pipeline.
27
+
28
+ Example:
29
+ >>> from ImagePRO.pre_processing.blur import apply_gaussian_blur
30
+ >>> step = Step(apply_gaussian_blur, kernel_size=(7, 7))
31
+ >>> result = step(image) # Same as apply_gaussian_blur(image, kernel_size=(7, 7))
32
+
33
+ Attributes:
34
+ function (Callable):
35
+ The wrapped function to execute.
36
+ kwargs (dict[str, Any]):
37
+ Keyword-only arguments forwarded to the function on each call.
38
+ """
39
+
40
+ def __init__(self, function: Callable[..., Result], **kwargs: Any) -> None:
41
+ """
42
+ Create a Step from a function and its keyword arguments.
43
+
44
+ Args:
45
+ function (Callable):
46
+ Function to wrap. Must be callable and accept an Image as its
47
+ first positional argument.
48
+ **kwargs (Any):
49
+ Keyword-only arguments forwarded to the function on every call.
50
+
51
+ Raises:
52
+ TypeError: If function is not callable.
53
+ """
54
+ if not callable(function):
55
+ raise TypeError("'function' must be a callable.")
56
+ self.function = function
57
+ self.kwargs: dict[str, Any] = dict(kwargs)
58
+
59
+ @property
60
+ def name(self) -> str:
61
+ """
62
+ Human-readable name of the wrapped function, used in pipeline metadata.
63
+
64
+ Returns:
65
+ str: The function's __name__ when available, otherwise the name
66
+ of its type (e.g. 'partial' for functools.partial objects).
67
+ """
68
+ return getattr(self.function, "__name__", type(self.function).__name__)
69
+
70
+ def __call__(self, image: Image) -> Result:
71
+ """
72
+ Execute the wrapped function on an image.
73
+
74
+ Args:
75
+ image (Image):
76
+ Input image passed to the wrapped function.
77
+
78
+ Returns:
79
+ Result: Whatever the wrapped function returns.
80
+
81
+ Raises:
82
+ TypeError: If image is not an Image instance (raised by the
83
+ wrapped function's own validation).
84
+ ValueError: If wrapped function arguments are invalid (raised by
85
+ the wrapped function's own validation).
86
+ """
87
+ return self.function(image, **self.kwargs)
88
+
89
+ def __repr__(self) -> str:
90
+ """
91
+ Return a readable representation of the step.
92
+
93
+ Returns:
94
+ str: Representation including the function name and arguments.
95
+ """
96
+ args = ", ".join(f"{key}={value!r}" for key, value in self.kwargs.items())
97
+ return f"Step({self.name}{', ' + args if args else ''})"
98
+
99
+
100
+ class Pipeline:
101
+ """
102
+ A reusable, ordered chain of image processing operations.
103
+
104
+ A Pipeline is defined once from a sequence of functions and can then be
105
+ run on any input as many times as needed. Each operation receives an
106
+ Image and its output image is automatically wrapped and fed to the next
107
+ operation; the final Result is returned with pipeline metadata attached.
108
+
109
+ Operations are the standard ImagePRO functions (which take an Image and
110
+ return a Result) or any custom callable following the same convention.
111
+ A custom function may also return a plain numpy.ndarray, which is treated
112
+ as the output image. Per-operation keyword arguments are configured with
113
+ Step, or fluently via apply().
114
+
115
+ Pipelines are immutable: apply() returns a new Pipeline and leaves the
116
+ original untouched, and run() never mutates the pipeline, so a single
117
+ Pipeline instance is safe to reuse across many inputs.
118
+
119
+ Example:
120
+ >>> from ImagePRO.pre_processing.blur import apply_gaussian_blur
121
+ >>> from ImagePRO.pre_processing.grayscale import convert_to_grayscale
122
+ >>> from ImagePRO.pre_processing.resize import resize_image
123
+ >>>
124
+ >>> pipeline = Pipeline([
125
+ ... Step(apply_gaussian_blur, kernel_size=(7, 7)),
126
+ ... convert_to_grayscale,
127
+ ... Step(resize_image, new_size=(800, 600)),
128
+ ... ])
129
+ >>> result = pipeline.run("photo.jpg") # Any Image, ndarray or path
130
+ >>> result.save_as_img("processed.jpg")
131
+ """
132
+
133
+ def __init__(self, *operations: Any) -> None:
134
+ """
135
+ Create a Pipeline from one or more operations.
136
+
137
+ Operations can be passed as separate arguments or as a single
138
+ list/tuple. Passing another Pipeline as an operation nests it: the
139
+ inner pipeline runs as a single step.
140
+
141
+ Args:
142
+ *operations (Callable | Step | list | tuple):
143
+ Functions to execute in order. Each element must be callable;
144
+ Step instances carry their keyword arguments. A single
145
+ list/tuple of operations is also accepted.
146
+
147
+ Raises:
148
+ TypeError: If any operation is not callable.
149
+ """
150
+ if len(operations) == 1 and isinstance(operations[0], (list, tuple)):
151
+ operations = tuple(operations[0])
152
+
153
+ steps: list[Step] = []
154
+ for operation in operations:
155
+ if not callable(operation):
156
+ raise TypeError(
157
+ "All pipeline operations must be callable; got "
158
+ f"{type(operation).__name__}. Wrap keyword arguments with "
159
+ "Step, e.g. Step(resize_image, new_size=(800, 600))."
160
+ )
161
+ steps.append(operation if isinstance(operation, Step) else Step(operation))
162
+ self._operations = steps
163
+
164
+ @property
165
+ def operations(self) -> tuple[Step, ...]:
166
+ """
167
+ The ordered steps of this pipeline.
168
+
169
+ Returns:
170
+ tuple[Step, ...]: Step objects in execution order.
171
+ """
172
+ return tuple(self._operations)
173
+
174
+ def apply(self, function: Callable[..., Result], **kwargs: Any) -> Pipeline:
175
+ """
176
+ Return a new Pipeline with one more operation appended.
177
+
178
+ The current pipeline is not modified, following ImagePRO's
179
+ non-destructive design principle.
180
+
181
+ Args:
182
+ function (Callable):
183
+ Function to append. Must be callable and accept an Image as
184
+ its first positional argument.
185
+ **kwargs (Any):
186
+ Keyword-only arguments forwarded to the function on every run.
187
+
188
+ Returns:
189
+ Pipeline: New pipeline containing all existing steps plus this one.
190
+
191
+ Raises:
192
+ TypeError: If function is not callable.
193
+
194
+ Example:
195
+ >>> pipeline = Pipeline(apply_gaussian_blur).apply(
196
+ ... resize_image, new_size=(800, 600)
197
+ ... )
198
+ """
199
+ return Pipeline(*self._operations, Step(function, **kwargs))
200
+
201
+ def run(self, image: Image | np.ndarray | str | Path) -> Result:
202
+ """
203
+ Execute all pipeline operations in order on the given input.
204
+
205
+ The input is first normalized to an Image (file paths are loaded and
206
+ NumPy arrays are wrapped, assuming BGR for 3-channel and GRAY for 2D
207
+ arrays). Each operation then receives an Image built from the previous
208
+ operation's output; 2D intermediates are tagged as GRAY and
209
+ multi-channel ones keep the incoming colorspace.
210
+
211
+ The returned Result carries the final operation's image and data. Its
212
+ metadata contains the pipeline information (operation, steps,
213
+ step_meta, source) merged over the final operation's own metadata.
214
+
215
+ Args:
216
+ image (Image | np.ndarray | str | Path):
217
+ Input to process. Can be an Image instance, a NumPy array
218
+ (BGR for 3-channel, GRAY for 2D), or a path to an image file.
219
+
220
+ Returns:
221
+ Result: Result object of the final operation.
222
+ - image (np.ndarray | list[np.ndarray] | None): Final output image(s)
223
+ - data (Any): Structured data of the final operation
224
+ - meta (dict): Final operation metadata plus pipeline info:
225
+ 'operation' ("pipeline"), 'steps' (list of step names),
226
+ 'step_meta' (per-step metadata list), 'source' (input Image)
227
+
228
+ Raises:
229
+ TypeError: If image is not an Image, ndarray, or path.
230
+ TypeError: If an operation returns an unsupported type.
231
+ ValueError: If the pipeline has no operations.
232
+ ValueError: If a non-final operation returns no image, so the
233
+ chain cannot continue.
234
+ TypeError/ValueError: As raised by the operations themselves for
235
+ invalid inputs or arguments.
236
+ """
237
+ # Normalize input to an Image (cheap validation before any step runs)
238
+ if isinstance(image, Image):
239
+ current = image
240
+ elif isinstance(image, np.ndarray):
241
+ colorspace = "GRAY" if image.ndim == 2 else "BGR"
242
+ current = Image.from_array(image, colorspace=colorspace)
243
+ elif isinstance(image, (str, Path)):
244
+ current = Image.from_path(image)
245
+ else:
246
+ raise TypeError(
247
+ "'image' must be an Image instance, a numpy.ndarray, or a "
248
+ "file path (str or pathlib.Path)."
249
+ )
250
+
251
+ if not self._operations:
252
+ raise ValueError(
253
+ "Cannot run an empty pipeline. Add operations at construction "
254
+ "or with apply()."
255
+ )
256
+
257
+ source = current
258
+ step_names: list[str] = []
259
+ step_metas: list[dict[str, Any]] = []
260
+ final_image: Any = None
261
+ final_data: Any = None
262
+ final_meta: dict[str, Any] = {}
263
+
264
+ for index, step in enumerate(self._operations):
265
+ is_last = index == len(self._operations) - 1
266
+ output = step(current)
267
+
268
+ # Normalize the step output (Result is the convention; a plain
269
+ # ndarray from custom functions is accepted as the output image)
270
+ if isinstance(output, Result):
271
+ final_image = output.image
272
+ final_data = output.data
273
+ final_meta = dict(output.meta)
274
+ elif isinstance(output, np.ndarray):
275
+ final_image = output
276
+ final_data = None
277
+ final_meta = {}
278
+ else:
279
+ raise TypeError(
280
+ f"Step {index} ('{step.name}') returned "
281
+ f"{type(output).__name__}; pipeline operations must "
282
+ "return a Result object or a numpy.ndarray."
283
+ )
284
+
285
+ step_names.append(step.name)
286
+ step_metas.append(final_meta)
287
+
288
+ if is_last:
289
+ break
290
+
291
+ # Prepare the input of the next operation
292
+ if final_image is None:
293
+ raise ValueError(
294
+ f"Step {index} ('{step.name}') returned no image, so the "
295
+ "pipeline cannot continue. Only the final step of a "
296
+ "pipeline may return a result without an image."
297
+ )
298
+ if not isinstance(final_image, np.ndarray):
299
+ raise ValueError(
300
+ f"Step {index} ('{step.name}') did not return a single "
301
+ "image array, so the pipeline cannot continue. Only the "
302
+ "final step of a pipeline may return multi-image results."
303
+ )
304
+
305
+ colorspace = "GRAY" if final_image.ndim == 2 else current.colorspace
306
+ current = Image.from_array(final_image, colorspace=colorspace)
307
+
308
+ meta = dict(final_meta)
309
+ meta.update({
310
+ "source": source,
311
+ "operation": "pipeline",
312
+ "steps": step_names,
313
+ "step_meta": step_metas,
314
+ })
315
+ return Result(image=final_image, data=final_data, meta=meta)
316
+
317
+ def __call__(self, image: Image | np.ndarray | str | Path) -> Result:
318
+ """
319
+ Run the pipeline on an input; shorthand for run().
320
+
321
+ Args:
322
+ image (Image | np.ndarray | str | Path):
323
+ Input to process, as accepted by run().
324
+
325
+ Returns:
326
+ Result: Result object of the final operation.
327
+
328
+ Raises:
329
+ TypeError: If image is not an Image, ndarray, or path.
330
+ ValueError: If the pipeline has no operations or a non-final
331
+ operation returns no image.
332
+ """
333
+ return self.run(image)
334
+
335
+ def __len__(self) -> int:
336
+ """
337
+ Return the number of operations in the pipeline.
338
+
339
+ Returns:
340
+ int: Number of steps.
341
+ """
342
+ return len(self._operations)
343
+
344
+ def __iter__(self) -> Iterator[Step]:
345
+ """
346
+ Iterate over the pipeline's steps in execution order.
347
+
348
+ Returns:
349
+ Iterator[Step]: Iterator of Step objects.
350
+ """
351
+ return iter(self._operations)
352
+
353
+ def __repr__(self) -> str:
354
+ """
355
+ Return a readable representation of the pipeline.
356
+
357
+ Returns:
358
+ str: Representation listing the step names in order.
359
+ """
360
+ if not self._operations:
361
+ return "Pipeline(<empty>)"
362
+ return f"Pipeline({' -> '.join(step.name for step in self._operations)})"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ImagePRO-Python
3
- Version: 1.3.1
3
+ Version: 1.4.0
4
4
  Summary: Professional & modular image processing library with OpenCV, MediaPipe, and YOLO integration.
5
5
  Author-email: Parsa Safaie <parsasafaie.2568@proton.me>
6
6
  License: MIT
@@ -76,6 +76,11 @@ Whether you're working on computer vision pipelines, preprocessing images for AI
76
76
  - **YOLO Integration**: Multiple accuracy levels (nano to extra-large)
77
77
  - **Flexible Models**: Pre-trained or custom model support
78
78
 
79
+ ### **Pipelines**
80
+ - **Reusable Operation Chains**: Define a sequence of functions once, then run it on any input (Image, NumPy array, or file path)
81
+ - **Flexible Steps**: Any ImagePRO function or custom callable; per-step keyword arguments via `Step`
82
+ - **Pipeline Metadata**: Every result lists the executed steps and each step's parameters
83
+
79
84
  ## Installation
80
85
 
81
86
  ### From PyPI
@@ -154,6 +159,22 @@ object_detection_result = detect_objects(
154
159
  )
155
160
  print(f"Detected {len(object_detection_result.data)} objects")
156
161
  object_detection_result.save_as_img("detections.jpg")
162
+
163
+ # Chain operations into a reusable pipeline
164
+ from ImagePRO.pipeline import Pipeline, Step
165
+ from ImagePRO.pre_processing.grayscale import convert_to_grayscale
166
+ from ImagePRO.pre_processing.resize import resize_image
167
+
168
+ pipeline = Pipeline([
169
+ Step(apply_gaussian_blur, kernel_size=(7, 7)),
170
+ convert_to_grayscale,
171
+ Step(resize_image, new_size=(800, 600)),
172
+ ])
173
+
174
+ # Run it on any input: an Image, a NumPy array, or a file path
175
+ pipeline_result = pipeline.run("person_and_objects.jpg")
176
+ pipeline_result.save_as_img("processed_output.jpg")
177
+ print(pipeline_result.meta["steps"]) # Executed step names
157
178
  ```
158
179
 
159
180
  > **Note**: These are basic examples. Each module contains many more functions with extensive customization options. Explore the module-specific README files for detailed documentation.
@@ -167,6 +188,7 @@ Each module includes comprehensive documentation with detailed examples:
167
188
  - [Face Analysis](src/ImagePRO/human_analysis/face_analysis/README.md): Face mesh, pose estimation, eye status, comparison
168
189
  - [Body Analysis](src/ImagePRO/human_analysis/body_analysis/README.md): Body pose and hand tracking
169
190
  - **[Object Analysis](src/ImagePRO/object_analysis/README.md)**: YOLO-based object detection
191
+ - **[Pipeline](src/ImagePRO/pipeline/README.md)**: Reusable chains of image processing operations
170
192
  - **[Utils](src/ImagePRO/utils/README.md)**: Shared utilities and I/O handling
171
193
 
172
194
  For detailed project structure and development guidelines, see [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md).
@@ -178,6 +200,7 @@ ImagePRO is built with a modular architecture designed for extensibility and mai
178
200
  - **Clean Separation of Concerns**: Each module handles a specific domain
179
201
  - **Consistent API Patterns**: All functions follow the same input/output conventions
180
202
  - **Shared Utilities**: Common `Image` and `Result` classes for unified I/O
203
+ - **Reusable Pipelines**: Chain any operations into a `Pipeline` and run it on any input
181
204
  - **Lazy Optional Imports**: Heavy AI dependencies (MediaPipe, Ultralytics, InsightFace) are only imported inside the functions that use them, keeping `import ImagePRO` fast and independent of installed extras
182
205
  - **Professional Error Handling**: Comprehensive validation with clear error messages
183
206
  - **Type Safety**: Full type hints throughout the codebase
@@ -14,6 +14,8 @@ src/ImagePRO/human_analysis/face_analysis/face_mesh_analysis.py
14
14
  src/ImagePRO/human_analysis/face_analysis/head_pose_estimation.py
15
15
  src/ImagePRO/object_analysis/__init__.py
16
16
  src/ImagePRO/object_analysis/object_detection.py
17
+ src/ImagePRO/pipeline/__init__.py
18
+ src/ImagePRO/pipeline/pipeline.py
17
19
  src/ImagePRO/pre_processing/__init__.py
18
20
  src/ImagePRO/pre_processing/blur.py
19
21
  src/ImagePRO/pre_processing/contrast.py
@@ -48,6 +50,7 @@ tests/test_histogram.py
48
50
  tests/test_image.py
49
51
  tests/test_integration.py
50
52
  tests/test_object_detection.py
53
+ tests/test_pipeline.py
51
54
  tests/test_resize.py
52
55
  tests/test_result.py
53
56
  tests/test_rotate.py
@@ -0,0 +1,357 @@
1
+ """Unit tests for ImagePRO.pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from functools import partial
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ from ImagePRO.pipeline import Pipeline, Step
11
+ from ImagePRO.pre_processing.blur import apply_gaussian_blur
12
+ from ImagePRO.pre_processing.grayscale import convert_to_grayscale
13
+ from ImagePRO.pre_processing.resize import resize_image
14
+ from ImagePRO.utils.image import Image
15
+ from ImagePRO.utils.result import Result
16
+
17
+
18
+ @pytest.fixture
19
+ def multiply_function():
20
+ """Custom operation following the Image -> Result convention."""
21
+
22
+ def function(image, *, scale=1):
23
+ output = image._data if scale == 1 else np.clip(image._data * scale, 0, 255).astype(np.uint8)
24
+ return Result(image=output, meta={"operation": "multiply", "scale": scale})
25
+
26
+ return function
27
+
28
+
29
+ @pytest.fixture
30
+ def colorspace_recorder():
31
+ """Custom operation recording the colorspace of every Image received."""
32
+ seen = []
33
+
34
+ def function(image):
35
+ seen.append(image.colorspace)
36
+ return Result(image=image._data)
37
+
38
+ function.seen = seen
39
+ return function
40
+
41
+
42
+ class TestStep:
43
+ def test_forwards_kwargs_to_function(self, sample_bgr_image):
44
+ step = Step(apply_gaussian_blur, kernel_size=(9, 9))
45
+ result = step(sample_bgr_image)
46
+ assert result.meta["kernel_size"] == (9, 9)
47
+
48
+ def test_works_without_kwargs(self, sample_bgr_image):
49
+ step = Step(apply_gaussian_blur)
50
+ result = step(sample_bgr_image)
51
+ assert result.meta["kernel_size"] == (5, 5)
52
+
53
+ def test_name_property(self):
54
+ assert Step(apply_gaussian_blur).name == "apply_gaussian_blur"
55
+
56
+ def test_name_falls_back_to_type_name(self):
57
+ step = Step(partial(apply_gaussian_blur, kernel_size=(3, 3)))
58
+ assert step.name == "partial"
59
+
60
+ def test_non_callable_raises(self):
61
+ with pytest.raises(TypeError):
62
+ Step("not callable")
63
+
64
+ def test_repr_shows_name_and_kwargs(self):
65
+ step = Step(apply_gaussian_blur, kernel_size=(7, 7))
66
+ assert "apply_gaussian_blur" in repr(step)
67
+ assert "kernel_size=(7, 7)" in repr(step)
68
+
69
+
70
+ class TestConstruction:
71
+ def test_from_list(self):
72
+ pipeline = Pipeline([apply_gaussian_blur, convert_to_grayscale])
73
+ assert len(pipeline) == 2
74
+
75
+ def test_from_tuple(self):
76
+ pipeline = Pipeline((apply_gaussian_blur, convert_to_grayscale))
77
+ assert len(pipeline) == 2
78
+
79
+ def test_from_varargs(self):
80
+ pipeline = Pipeline(apply_gaussian_blur, convert_to_grayscale)
81
+ assert len(pipeline) == 2
82
+
83
+ def test_from_single_callable(self):
84
+ pipeline = Pipeline(apply_gaussian_blur)
85
+ assert len(pipeline) == 1
86
+
87
+ def test_from_single_step(self):
88
+ pipeline = Pipeline(Step(apply_gaussian_blur, kernel_size=(7, 7)))
89
+ assert len(pipeline) == 1
90
+ assert pipeline.operations[0].kwargs == {"kernel_size": (7, 7)}
91
+
92
+ def test_mixed_callables_and_steps(self):
93
+ pipeline = Pipeline(
94
+ Step(apply_gaussian_blur, kernel_size=(3, 3)),
95
+ convert_to_grayscale,
96
+ )
97
+ assert len(pipeline) == 2
98
+ assert isinstance(pipeline.operations[0], Step)
99
+ assert isinstance(pipeline.operations[1], Step)
100
+
101
+ def test_empty_pipeline_is_allowed(self):
102
+ assert len(Pipeline()) == 0
103
+
104
+ def test_plain_callables_are_wrapped_in_steps(self):
105
+ pipeline = Pipeline(apply_gaussian_blur)
106
+ assert isinstance(pipeline.operations[0], Step)
107
+ assert pipeline.operations[0].function is apply_gaussian_blur
108
+
109
+ def test_operations_property_returns_tuple(self):
110
+ pipeline = Pipeline(apply_gaussian_blur, convert_to_grayscale)
111
+ assert isinstance(pipeline.operations, tuple)
112
+ assert [step.name for step in pipeline.operations] == [
113
+ "apply_gaussian_blur",
114
+ "convert_to_grayscale",
115
+ ]
116
+
117
+ @pytest.mark.parametrize("bad", [42, "blur", None, 3.14])
118
+ def test_non_callable_operation_raises(self, bad):
119
+ with pytest.raises(TypeError):
120
+ Pipeline(bad)
121
+
122
+ def test_non_callable_inside_list_raises(self):
123
+ with pytest.raises(TypeError):
124
+ Pipeline([apply_gaussian_blur, 42])
125
+
126
+
127
+ class TestApply:
128
+ def test_returns_new_pipeline(self):
129
+ original = Pipeline(apply_gaussian_blur)
130
+ extended = original.apply(convert_to_grayscale)
131
+ assert extended is not original
132
+ assert len(original) == 1
133
+ assert len(extended) == 2
134
+
135
+ def test_is_chainable(self):
136
+ pipeline = (
137
+ Pipeline()
138
+ .apply(apply_gaussian_blur, kernel_size=(3, 3))
139
+ .apply(convert_to_grayscale)
140
+ .apply(resize_image, new_size=(16, 12))
141
+ )
142
+ assert len(pipeline) == 3
143
+ assert [step.name for step in pipeline] == [
144
+ "apply_gaussian_blur",
145
+ "convert_to_grayscale",
146
+ "resize_image",
147
+ ]
148
+
149
+ def test_forwards_kwargs(self, sample_bgr_image):
150
+ pipeline = Pipeline().apply(apply_gaussian_blur, kernel_size=(9, 9))
151
+ result = pipeline.run(sample_bgr_image)
152
+ assert result.meta["step_meta"][0]["kernel_size"] == (9, 9)
153
+
154
+ def test_non_callable_raises(self):
155
+ with pytest.raises(TypeError):
156
+ Pipeline().apply(42)
157
+
158
+
159
+ class TestRun:
160
+ def test_single_step_returns_result(self, sample_bgr_image):
161
+ result = Pipeline(apply_gaussian_blur).run(sample_bgr_image)
162
+ assert isinstance(result, Result)
163
+ assert result.image.shape == sample_bgr_image.shape
164
+ assert result.image.dtype == np.uint8
165
+
166
+ def test_multi_step_matches_manual_application(self, sample_bgr_image):
167
+ pipeline = Pipeline([
168
+ apply_gaussian_blur,
169
+ convert_to_grayscale,
170
+ Step(resize_image, new_size=(16, 12)),
171
+ ])
172
+ result = pipeline.run(sample_bgr_image)
173
+
174
+ blurred = apply_gaussian_blur(image=sample_bgr_image).image
175
+ gray = convert_to_grayscale(
176
+ image=Image.from_array(blurred, colorspace="BGR")
177
+ ).image
178
+ resized = resize_image(
179
+ image=Image.from_array(gray, colorspace="GRAY"), new_size=(16, 12)
180
+ ).image
181
+ assert np.array_equal(result.image, resized)
182
+
183
+ def test_meta_contents(self, sample_bgr_image):
184
+ pipeline = Pipeline([
185
+ Step(apply_gaussian_blur, kernel_size=(7, 7)),
186
+ convert_to_grayscale,
187
+ ])
188
+ result = pipeline.run(sample_bgr_image)
189
+ assert result.meta["operation"] == "pipeline"
190
+ assert result.meta["steps"] == ["apply_gaussian_blur", "convert_to_grayscale"]
191
+ assert len(result.meta["step_meta"]) == 2
192
+ assert result.meta["step_meta"][0]["kernel_size"] == (7, 7)
193
+ assert result.meta["step_meta"][1]["operation"] == "convert_to_grayscale"
194
+ assert result.meta["source"] is sample_bgr_image
195
+
196
+ def test_final_step_meta_is_merged(self, sample_bgr_image):
197
+ result = Pipeline(
198
+ Step(apply_gaussian_blur, kernel_size=(7, 7))
199
+ ).run(sample_bgr_image)
200
+ # Pipeline keys win over the final step's own metadata
201
+ assert result.meta["operation"] == "pipeline"
202
+ assert result.meta["source"] is sample_bgr_image
203
+ # The final step's own keys survive the merge
204
+ assert result.meta["kernel_size"] == (7, 7)
205
+
206
+ def test_input_as_ndarray(self, sample_bgr_array):
207
+ result = Pipeline(apply_gaussian_blur).run(sample_bgr_array)
208
+ assert result.image.shape == sample_bgr_array.shape
209
+
210
+ def test_input_as_2d_ndarray_is_treated_as_gray(self, sample_gray_image):
211
+ result = Pipeline(apply_gaussian_blur).run(sample_gray_image._data)
212
+ assert result.meta["source"].colorspace == "GRAY"
213
+
214
+ def test_input_as_path(self, image_file):
215
+ result = Pipeline(convert_to_grayscale).run(image_file)
216
+ assert result.image.ndim == 2
217
+
218
+ def test_input_as_str_path(self, image_file):
219
+ result = Pipeline(convert_to_grayscale).run(str(image_file))
220
+ assert result.image.ndim == 2
221
+
222
+ def test_invalid_input_type_raises(self, sample_bgr_image):
223
+ pipeline = Pipeline(apply_gaussian_blur)
224
+ with pytest.raises(TypeError):
225
+ pipeline.run(123)
226
+ with pytest.raises(TypeError):
227
+ pipeline.run(None)
228
+ with pytest.raises(TypeError):
229
+ pipeline.run([sample_bgr_image])
230
+
231
+ def test_empty_pipeline_raises(self, sample_bgr_image):
232
+ with pytest.raises(ValueError):
233
+ Pipeline().run(sample_bgr_image)
234
+
235
+ def test_pipeline_is_reusable_across_inputs(self, multiply_function):
236
+ pipeline = Pipeline(multiply_function)
237
+ first = pipeline.run(Image.from_array(np.full((8, 8, 3), 10, np.uint8)))
238
+ second = pipeline.run(Image.from_array(np.full((12, 6, 3), 200, np.uint8)))
239
+ assert len(pipeline) == 1
240
+ assert first.image.shape == (8, 8, 3)
241
+ assert second.image.shape == (12, 6, 3)
242
+
243
+ def test_reruns_do_not_interfere(self, sample_bgr_image):
244
+ pipeline = Pipeline(apply_gaussian_blur)
245
+ first = pipeline.run(sample_bgr_image)
246
+ second = pipeline.run(sample_bgr_image)
247
+ assert np.array_equal(first.image, second.image)
248
+ assert first.image is not second.image
249
+
250
+ def test_custom_operation_returning_ndarray(self, sample_bgr_image):
251
+ def double(image):
252
+ return np.clip(image._data * 2, 0, 255).astype(np.uint8)
253
+
254
+ result = Pipeline(double).run(sample_bgr_image)
255
+ assert isinstance(result, Result)
256
+ assert result.meta["steps"] == ["double"]
257
+ expected = np.clip(sample_bgr_image._data * 2, 0, 255).astype(np.uint8)
258
+ assert np.array_equal(result.image, expected)
259
+
260
+ def test_unsupported_operation_return_raises(self, sample_bgr_image):
261
+ def bad_operation(image):
262
+ return "not an image or result"
263
+
264
+ with pytest.raises(TypeError):
265
+ Pipeline(bad_operation).run(sample_bgr_image)
266
+
267
+ def test_non_final_step_without_image_raises(self, sample_bgr_image):
268
+ def data_only(image):
269
+ return Result(image=None, data=[1, 2, 3])
270
+
271
+ pipeline = Pipeline(data_only, apply_gaussian_blur)
272
+ with pytest.raises(ValueError):
273
+ pipeline.run(sample_bgr_image)
274
+
275
+ def test_final_step_may_omit_image(self, sample_bgr_image):
276
+ def data_only(image):
277
+ return Result(image=None, data=[1, 2, 3])
278
+
279
+ result = Pipeline(data_only).run(sample_bgr_image)
280
+ assert result.image is None
281
+ assert result.data == [1, 2, 3]
282
+ assert result.meta["steps"] == ["data_only"]
283
+
284
+ def test_final_step_may_return_image_list(self, sample_bgr_image):
285
+ def two_images(image):
286
+ return Result(image=[image._data, image._data.copy()])
287
+
288
+ result = Pipeline(two_images).run(sample_bgr_image)
289
+ assert isinstance(result.image, list)
290
+ assert len(result.image) == 2
291
+
292
+ def test_non_final_step_with_image_list_raises(self, sample_bgr_image):
293
+ def two_images(image):
294
+ return Result(image=[image._data, image._data.copy()])
295
+
296
+ pipeline = Pipeline(two_images, apply_gaussian_blur)
297
+ with pytest.raises(ValueError):
298
+ pipeline.run(sample_bgr_image)
299
+
300
+ def test_colorspace_propagates_through_chain(
301
+ self, sample_bgr_image, colorspace_recorder
302
+ ):
303
+ Pipeline(convert_to_grayscale, colorspace_recorder).run(sample_bgr_image)
304
+ assert colorspace_recorder.seen == ["GRAY"]
305
+
306
+ def test_bgr_colorspace_propagates(self, sample_bgr_image, colorspace_recorder):
307
+ Pipeline(apply_gaussian_blur, colorspace_recorder).run(sample_bgr_image)
308
+ assert colorspace_recorder.seen == ["BGR"]
309
+
310
+ def test_operation_validation_errors_propagate(self, sample_gray_image):
311
+ # convert_to_grayscale refuses already-gray images; the pipeline
312
+ # must surface that error unchanged.
313
+ with pytest.raises(ValueError):
314
+ Pipeline(convert_to_grayscale).run(sample_gray_image)
315
+
316
+ def test_nested_pipeline_runs_as_single_step(self, sample_bgr_image):
317
+ inner = Pipeline(apply_gaussian_blur)
318
+ outer = Pipeline(inner, convert_to_grayscale)
319
+ result = outer.run(sample_bgr_image)
320
+ assert result.image.ndim == 2
321
+ assert result.meta["steps"][0] == "Pipeline"
322
+
323
+ def test_source_meta_is_original_input(self, image_file):
324
+ image = Image.from_path(image_file)
325
+ result = Pipeline(apply_gaussian_blur).run(image)
326
+ assert result.meta["source"] is image
327
+
328
+
329
+ class TestCall:
330
+ def test_call_is_shorthand_for_run(self, sample_bgr_image):
331
+ pipeline = Pipeline(apply_gaussian_blur)
332
+ by_call = pipeline(sample_bgr_image)
333
+ by_run = pipeline.run(sample_bgr_image)
334
+ assert np.array_equal(by_call.image, by_run.image)
335
+ assert by_call.meta["operation"] == "pipeline"
336
+
337
+
338
+ class TestDunders:
339
+ def test_len(self):
340
+ assert len(Pipeline()) == 0
341
+ assert len(Pipeline(apply_gaussian_blur, convert_to_grayscale)) == 2
342
+
343
+ def test_iter_yields_steps(self):
344
+ pipeline = Pipeline(apply_gaussian_blur, convert_to_grayscale)
345
+ steps = list(pipeline)
346
+ assert all(isinstance(step, Step) for step in steps)
347
+ assert [step.name for step in steps] == [
348
+ "apply_gaussian_blur",
349
+ "convert_to_grayscale",
350
+ ]
351
+
352
+ def test_repr_lists_step_names(self):
353
+ pipeline = Pipeline(apply_gaussian_blur, convert_to_grayscale)
354
+ assert repr(pipeline) == "Pipeline(apply_gaussian_blur -> convert_to_grayscale)"
355
+
356
+ def test_repr_of_empty_pipeline(self):
357
+ assert repr(Pipeline()) == "Pipeline(<empty>)"
File without changes