ImagePRO-Python 1.4.0__py3-none-any.whl → 1.5.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.
ImagePRO/__init__.py CHANGED
@@ -16,7 +16,7 @@ Features:
16
16
  - Real-time processing capabilities
17
17
  """
18
18
 
19
- __version__ = "1.4.0"
19
+ __version__ = "1.5.0"
20
20
  __author__ = "Parsa Safaie"
21
21
  __email__ = "parsasafaie.2568@proton.me"
22
22
 
@@ -23,6 +23,11 @@ TOTAL_LANDMARKS = 33
23
23
  DEFAULT_CONFIDENCE = 0.7
24
24
  LANDMARK_RADIUS = 3
25
25
  LANDMARK_COLOR = (0, 0, 255) # Red color for landmarks
26
+ # Backend-agnostic capture: the DSHOW backend only exists on Windows, so
27
+ # the default leaves the choice to OpenCV. Tests inject a fake through
28
+ # this hook.
29
+ DEFAULT_CAMERA_BACKEND = None
30
+
26
31
 
27
32
  def detect_body_pose(
28
33
  image: Image,
@@ -62,12 +67,13 @@ def detect_body_pose(
62
67
  if not isinstance(image, Image):
63
68
  raise TypeError("'image' must be an Image instance")
64
69
 
65
- if not isinstance(min_confidence, (int, float)) or not (0.0 <= min_confidence <= 1.0):
70
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
71
+ or not (0.0 <= min_confidence <= 1.0):
66
72
  raise ValueError("'min_confidence' must be between 0.0 and 1.0")
67
73
 
68
74
  if landmarks_idx is not None and (
69
75
  not isinstance(landmarks_idx, list)
70
- or not all(isinstance(i, int) for i in landmarks_idx)
76
+ or not all(isinstance(i, int) and not isinstance(i, bool) for i in landmarks_idx)
71
77
  ):
72
78
  raise TypeError("'landmarks_idx' must be a list of integers")
73
79
 
@@ -153,8 +159,8 @@ def detect_body_pose_live() -> None:
153
159
  'detection. Install it with: pip install "ImagePRO-Python[mediapipe]"'
154
160
  ) from err
155
161
 
156
- # Initialize camera
157
- cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
162
+ # Initialize camera (backend-agnostic; see DEFAULT_CAMERA_BACKEND)
163
+ cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
158
164
  if not cap.isOpened():
159
165
  raise RuntimeError("Cannot access webcam")
160
166
 
@@ -24,6 +24,10 @@ DEFAULT_MIN_CONFIDENCE = 0.7
24
24
  TOTAL_HAND_LANDMARKS = 21
25
25
  LANDMARK_RADIUS = 3
26
26
  LANDMARK_COLOR = (0, 0, 255) # Red color for landmarks
27
+ # Backend-agnostic capture: the DSHOW backend only exists on Windows, so
28
+ # the default leaves the choice to OpenCV. Tests inject a fake through
29
+ # this hook.
30
+ DEFAULT_CAMERA_BACKEND = None
27
31
 
28
32
 
29
33
  def detect_hands(
@@ -69,15 +73,16 @@ def detect_hands(
69
73
  if not isinstance(image, Image):
70
74
  raise TypeError("'image' must be an Image instance")
71
75
 
72
- if not isinstance(max_hands, int) or max_hands <= 0:
76
+ if not isinstance(max_hands, int) or isinstance(max_hands, bool) or max_hands <= 0:
73
77
  raise ValueError("'max_hands' must be positive")
74
78
 
75
- if not isinstance(min_confidence, (int, float)) or not (0.0 <= min_confidence <= 1.0):
79
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
80
+ or not (0.0 <= min_confidence <= 1.0):
76
81
  raise ValueError("'min_confidence' must be between 0.0 and 1.0")
77
82
 
78
83
  if landmarks_idx is not None and (
79
84
  not isinstance(landmarks_idx, list)
80
- or not all(isinstance(i, int) for i in landmarks_idx)
85
+ or not all(isinstance(i, int) and not isinstance(i, bool) for i in landmarks_idx)
81
86
  ):
82
87
  raise TypeError("'landmarks_idx' must be a list of integers")
83
88
 
@@ -174,10 +179,11 @@ def detect_hands_live(
174
179
  RuntimeError: If camera cannot be accessed
175
180
  """
176
181
  # Validate parameters
177
- if not isinstance(max_hands, int) or max_hands <= 0:
182
+ if not isinstance(max_hands, int) or isinstance(max_hands, bool) or max_hands <= 0:
178
183
  raise ValueError("'max_hands' must be positive")
179
184
 
180
- if not isinstance(min_confidence, (int, float)) or not (0.0 <= min_confidence <= 1.0):
185
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
186
+ or not (0.0 <= min_confidence <= 1.0):
181
187
  raise ValueError("'min_confidence' must be between 0.0 and 1.0")
182
188
 
183
189
  try:
@@ -188,8 +194,8 @@ def detect_hands_live(
188
194
  'tracking. Install it with: pip install "ImagePRO-Python[mediapipe]"'
189
195
  ) from err
190
196
 
191
- # Initialize camera
192
- cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
197
+ # Initialize camera (backend-agnostic; see DEFAULT_CAMERA_BACKEND)
198
+ cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
193
199
  if not cap.isOpened():
194
200
  raise RuntimeError("Cannot access webcam")
195
201
 
@@ -23,6 +23,10 @@ if TYPE_CHECKING: # mediapipe is imported lazily inside the functions
23
23
  DEFAULT_MIN_CONFIDENCE = 0.7
24
24
  DEFAULT_THRESHOLD = 0.2
25
25
  RIGHT_EYE_INDICES = [386, 374, 263, 362] # MediaPipe right eye landmarks
26
+ # Backend-agnostic capture: the DSHOW backend only exists on Windows, so
27
+ # the default leaves the choice to OpenCV. Tests inject a fake through
28
+ # this hook.
29
+ DEFAULT_CAMERA_BACKEND = None
26
30
 
27
31
 
28
32
  def analyze_eye_status(
@@ -62,7 +66,7 @@ def analyze_eye_status(
62
66
  if not isinstance(image, Image):
63
67
  raise TypeError("'image' must be an Image instance")
64
68
 
65
- if not isinstance(min_confidence, (int, float)):
69
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)):
66
70
  raise TypeError("'min_confidence' must be a number")
67
71
  if not 0 <= min_confidence <= 1:
68
72
  raise ValueError("'min_confidence' must be between 0 and 1")
@@ -178,7 +182,7 @@ def analyze_eye_status_live(
178
182
  RuntimeError: If webcam cannot be accessed
179
183
  """
180
184
  # Validate inputs
181
- if not isinstance(min_confidence, (int, float)):
185
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)):
182
186
  raise TypeError("'min_confidence' must be a number")
183
187
  if not 0 <= min_confidence <= 1:
184
188
  raise ValueError("'min_confidence' must be between 0 and 1")
@@ -191,8 +195,8 @@ def analyze_eye_status_live(
191
195
  'analysis. Install it with: pip install "ImagePRO-Python[mediapipe]"'
192
196
  ) from err
193
197
 
194
- # Initialize webcam
195
- cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
198
+ # Initialize webcam (backend-agnostic; see DEFAULT_CAMERA_BACKEND)
199
+ cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
196
200
  if not cap.isOpened():
197
201
  raise RuntimeError("Failed to access webcam")
198
202
 
@@ -221,7 +225,8 @@ def analyze_eye_status_live(
221
225
  face_mesh_obj=face_mesh,
222
226
  threshold=threshold
223
227
  )
224
- status = "Open" if result.data else "Closed"
228
+ status = "No face" if result.data is None else \
229
+ ("Open" if result.data else "Closed")
225
230
  except (TypeError, ValueError):
226
231
  status = "No face"
227
232
 
@@ -10,8 +10,6 @@ _src_path = _file_path.parents[3] # Go up to src directory
10
10
  if str(_src_path) not in sys.path:
11
11
  sys.path.insert(0, str(_src_path))
12
12
 
13
- import os
14
-
15
13
  import cv2
16
14
  import numpy as np
17
15
 
@@ -36,7 +34,9 @@ def compare_faces(
36
34
  """Compare two face images to determine if they are the same person.
37
35
 
38
36
  Uses InsightFace's FaceAnalysis model to extract facial embeddings
39
- and compares them using cosine similarity.
37
+ and compares them using cosine similarity. Arrays are passed to the
38
+ model in memory; only path-based images are read from disk, so no
39
+ temporary files are written.
40
40
 
41
41
  Args:
42
42
  image_1: First image to compare.
@@ -57,7 +57,7 @@ def compare_faces(
57
57
 
58
58
  Raises:
59
59
  TypeError: If either image is not an Image instance
60
- FileNotFoundError: If image data cannot be loaded
60
+ FileNotFoundError: If a path-based image cannot be loaded
61
61
  """
62
62
  # Validate inputs
63
63
  if not isinstance(image_1, Image):
@@ -82,69 +82,62 @@ def compare_faces(
82
82
  )
83
83
  app.prepare(ctx_id=0) # Use CPU
84
84
 
85
- def save_temp_image(image: Image, temp_name: str) -> str:
86
- """Save image data to temporary file if needed."""
85
+ def load_rgb_image(image: Image) -> np.ndarray:
86
+ """Return the image as an RGB array, via disk only if required."""
87
87
  if image.source_type == 'path':
88
- return image.path
89
- temp_path = os.path.join(os.getcwd(), temp_name)
90
- cv2.imwrite(temp_path, image._data)
91
- return temp_path
92
-
93
- def load_rgb_image(path: str) -> np.ndarray | None:
94
- """Load image file and convert to RGB."""
95
- img = cv2.imread(str(path))
96
- return cv2.cvtColor(img, cv2.COLOR_BGR2RGB) if img is not None else None
97
-
98
- try:
99
- # Save images to disk if needed
100
- path_1 = save_temp_image(image_1, 'tmp1.jpg')
101
- path_2 = save_temp_image(image_2, 'tmp2.jpg')
102
-
103
- # Load and preprocess images
104
- img1 = load_rgb_image(path_1)
105
- img2 = load_rgb_image(path_2)
106
-
107
- if img1 is None or img2 is None:
108
- raise FileNotFoundError("Failed to load one or both images")
109
-
110
- # Detect faces
111
- faces1 = app.get(img1)
112
- faces2 = app.get(img2)
113
-
114
- # Validate detections
115
- if not faces1 or not faces2:
116
- return Result(
117
- image=None,
118
- data=None,
119
- meta={
120
- "source": (image_1, image_2),
121
- "operation": "compare_faces",
122
- "error": "No face detected in one or both images"
123
- }
124
- )
125
-
126
- # Extract embeddings
127
- emb1 = faces1[0].embedding
128
- emb2 = faces2[0].embedding
129
-
130
- # Calculate similarity
131
- similarity = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
132
- is_match = similarity > DEFAULT_SIMILARITY_THRESHOLD
133
-
88
+ # honor the declared colorspace when reading from disk
89
+ img = cv2.imread(str(image.path))
90
+ if img is None:
91
+ raise FileNotFoundError(f"Failed to load image from {image.path}")
92
+ if image.colorspace == "RGB":
93
+ return img
94
+ if image.colorspace == "GRAY":
95
+ return cv2.cvtColor(img, cv2.COLOR_GRAY2RGB)
96
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
97
+
98
+ # InsightFace accepts in-memory arrays; no temp file is written.
99
+ if image.colorspace == "RGB":
100
+ return image._data
101
+ if image.colorspace == "GRAY":
102
+ return cv2.cvtColor(image._data, cv2.COLOR_GRAY2RGB)
103
+ return cv2.cvtColor(image._data, cv2.COLOR_BGR2RGB)
104
+
105
+ img1 = load_rgb_image(image_1)
106
+ img2 = load_rgb_image(image_2)
107
+
108
+ # Detect faces
109
+ faces1 = app.get(img1)
110
+ faces2 = app.get(img2)
111
+
112
+ # Validate detections
113
+ if not faces1 or not faces2:
134
114
  return Result(
135
115
  image=None,
136
- data=is_match,
116
+ data=None,
137
117
  meta={
138
118
  "source": (image_1, image_2),
139
119
  "operation": "compare_faces",
140
- "similarity": float(similarity),
141
- "threshold": DEFAULT_SIMILARITY_THRESHOLD
120
+ "error": "No face detected in one or both images"
142
121
  }
143
122
  )
144
123
 
145
- finally:
146
- # Cleanup temporary files
147
- if image_1.source_type != 'path' and os.path.exists('tmp1.jpg'):
148
- os.remove('tmp1.jpg')
149
- if image_2.source_type != 'path' and os.path.exists('tmp2.jpg'):
150
- os.remove('tmp2.jpg')
124
+ # Extract embeddings
125
+ emb1 = faces1[0].embedding
126
+ emb2 = faces2[0].embedding
127
+
128
+ # Calculate similarity
129
+ similarity = np.dot(emb1, emb2) / (np.linalg.norm(emb1) * np.linalg.norm(emb2))
130
+ # Cast to a plain bool: np.dot yields np.bool_, which fails `is True`
131
+ # checks and serializes oddly in CSV/JSON output.
132
+ is_match = bool(similarity > DEFAULT_SIMILARITY_THRESHOLD)
133
+
134
+ return Result(
135
+ image=None,
136
+ data=is_match,
137
+ meta={
138
+ "source": (image_1, image_2),
139
+ "operation": "compare_faces",
140
+ "similarity": float(similarity),
141
+ "threshold": DEFAULT_SIMILARITY_THRESHOLD
142
+ }
143
+ )
@@ -72,10 +72,11 @@ def detect_faces(
72
72
  if not isinstance(image, Image):
73
73
  raise TypeError("'image' must be an Image instance")
74
74
 
75
- if not isinstance(max_faces, int) or max_faces <= 0:
75
+ if not isinstance(max_faces, int) or isinstance(max_faces, bool) or max_faces <= 0:
76
76
  raise ValueError("'max_faces' must be positive")
77
77
 
78
- if not isinstance(min_confidence, (int, float)) or not (0 <= min_confidence <= 1):
78
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
79
+ or not (0 <= min_confidence <= 1):
79
80
  raise ValueError("'min_confidence' must be between 0 and 1")
80
81
 
81
82
  # Get image dimensions
@@ -22,6 +22,10 @@ if TYPE_CHECKING: # mediapipe is imported lazily inside the functions
22
22
  DEFAULT_MAX_FACES = 1
23
23
  DEFAULT_MIN_CONFIDENCE = 0.7
24
24
  TOTAL_FACE_LANDMARKS = 468
25
+ # Backend-agnostic capture: the DSHOW backend only exists on Windows, so
26
+ # the default leaves the choice to OpenCV. Tests inject a fake through
27
+ # this hook.
28
+ DEFAULT_CAMERA_BACKEND = None
25
29
 
26
30
 
27
31
  def analyze_face_mesh(
@@ -38,7 +42,7 @@ def analyze_face_mesh(
38
42
  Can focus on specific landmarks or detect full 468-point mesh.
39
43
 
40
44
  Args:
41
- image: Input image to process.
45
+ image: Input image to process (BGR, RGB, or grayscale).
42
46
  max_faces: Maximum number of faces to detect.
43
47
  Must be positive.
44
48
  Default: 1
@@ -75,15 +79,16 @@ def analyze_face_mesh(
75
79
  if not isinstance(image, Image):
76
80
  raise TypeError("'image' must be an Image instance")
77
81
 
78
- if not isinstance(max_faces, int) or max_faces <= 0:
82
+ if not isinstance(max_faces, int) or isinstance(max_faces, bool) or max_faces <= 0:
79
83
  raise ValueError("'max_faces' must be positive")
80
84
 
81
- if not isinstance(min_confidence, (int, float)) or not (0 <= min_confidence <= 1):
85
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
86
+ or not (0 <= min_confidence <= 1):
82
87
  raise ValueError("'min_confidence' must be between 0 and 1")
83
88
 
84
89
  if landmarks_idx is not None and (
85
90
  not isinstance(landmarks_idx, list)
86
- or not all(isinstance(i, int) for i in landmarks_idx)
91
+ or not all(isinstance(i, int) and not isinstance(i, bool) for i in landmarks_idx)
87
92
  ):
88
93
  raise TypeError("'landmarks_idx' must be a list of integers")
89
94
 
@@ -110,9 +115,14 @@ def analyze_face_mesh(
110
115
  else:
111
116
  face_mesh = face_mesh_obj
112
117
 
113
- # Detect facial landmarks (cvtColor allocates a new RGB array)
114
- img_rgb = cv2.cvtColor(image._data, cv2.COLOR_BGR2RGB)
115
- results = face_mesh.process(img_rgb)
118
+ # MediaPipe expects RGB; grayscale input needs channels added back.
119
+ if image.colorspace == "GRAY":
120
+ model_input = cv2.cvtColor(image._data, cv2.COLOR_GRAY2RGB)
121
+ elif image.colorspace == "RGB":
122
+ model_input = image._data
123
+ else: # BGR is the default
124
+ model_input = cv2.cvtColor(image._data, cv2.COLOR_BGR2RGB)
125
+ results = face_mesh.process(model_input)
116
126
 
117
127
  # Handle no detections
118
128
  if not results.multi_face_landmarks:
@@ -214,8 +224,9 @@ def analyze_face_mesh_live(
214
224
  'analysis. Install it with: pip install "ImagePRO-Python[mediapipe]"'
215
225
  ) from err
216
226
 
217
- # Initialize camera
218
- cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
227
+ # Initialize camera (backend-agnostic; DEFAULT_CAMERA_BACKEND is a
228
+ # module-level hook tests can monkeypatch)
229
+ cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
219
230
  if not cap.isOpened():
220
231
  raise RuntimeError("Cannot access webcam")
221
232
 
@@ -23,6 +23,10 @@ if TYPE_CHECKING: # mediapipe is imported lazily inside the functions
23
23
  DEFAULT_MAX_FACES = 1
24
24
  DEFAULT_MIN_CONFIDENCE = 0.7
25
25
  HEAD_POSE_INDICES = [1, 152, 33, 263, 168] # nose_tip, chin, left_eye, right_eye, nasion
26
+ # Backend-agnostic capture: the DSHOW backend only exists on Windows, so
27
+ # the default leaves the choice to OpenCV. Tests inject a fake through
28
+ # this hook.
29
+ DEFAULT_CAMERA_BACKEND = None
26
30
 
27
31
 
28
32
  def estimate_head_pose(
@@ -37,6 +41,12 @@ def estimate_head_pose(
37
41
  Calculates approximate yaw and pitch angles based on relative
38
42
  positions of key facial landmarks (nose, eyes, chin).
39
43
 
44
+ The values are unscaled proportional scores, not degrees: each is the
45
+ normalized landmark asymmetry multiplied by an arbitrary constant of
46
+ 100. They are useful for thresholds and trends (e.g. detecting when a
47
+ face turns away), not for measuring absolute angles. Mirror-image
48
+ webcam feeds flip the sign of yaw.
49
+
40
50
  Args:
41
51
  image: Input image to process.
42
52
  max_faces: Maximum number of faces to analyze.
@@ -55,6 +65,7 @@ def estimate_head_pose(
55
65
  None if no faces detected
56
66
  - meta: Operation info and parameters
57
67
  Includes error info if detection fails
68
+ - image: None
58
69
 
59
70
  Raises:
60
71
  TypeError: If image is not an Image instance
@@ -64,10 +75,11 @@ def estimate_head_pose(
64
75
  if not isinstance(image, Image):
65
76
  raise TypeError("'image' must be an Image instance")
66
77
 
67
- if not isinstance(max_faces, int) or max_faces <= 0:
78
+ if not isinstance(max_faces, int) or isinstance(max_faces, bool) or max_faces <= 0:
68
79
  raise ValueError("'max_faces' must be positive")
69
80
 
70
- if not isinstance(min_confidence, (int, float)) or not (0 <= min_confidence <= 1):
81
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
82
+ or not (0 <= min_confidence <= 1):
71
83
  raise ValueError("'min_confidence' must be between 0 and 1")
72
84
 
73
85
  # Get face landmarks
@@ -120,7 +132,8 @@ def estimate_head_pose(
120
132
  }
121
133
  )
122
134
 
123
- # Calculate angles
135
+ # Calculate angles. The ×100 constant is arbitrary; the values are
136
+ # proportional scores for thresholding, not degrees.
124
137
  yaw = 100 * ((right_x - nasion_x) - (nasion_x - left_x)) # Horizontal rotation
125
138
  pitch = 100 * ((chin_y - nose_y) - (nose_y - nasion_y)) # Vertical rotation
126
139
  pose_data.append([face[0][0], yaw, pitch])
@@ -162,11 +175,11 @@ def estimate_head_pose_live(
162
175
  RuntimeError: If webcam cannot be accessed
163
176
  """
164
177
  # Validate inputs
165
- if not isinstance(max_faces, int):
178
+ if not isinstance(max_faces, int) or isinstance(max_faces, bool):
166
179
  raise TypeError("'max_faces' must be an integer")
167
180
  if max_faces <= 0:
168
181
  raise ValueError("'max_faces' must be positive")
169
- if not isinstance(min_confidence, (int, float)):
182
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)):
170
183
  raise TypeError("'min_confidence' must be a number")
171
184
  if not 0 <= min_confidence <= 1:
172
185
  raise ValueError("'min_confidence' must be between 0 and 1")
@@ -179,8 +192,8 @@ def estimate_head_pose_live(
179
192
  'estimation. Install it with: pip install "ImagePRO-Python[mediapipe]"'
180
193
  ) from err
181
194
 
182
- # Initialize webcam
183
- cap = cv2.VideoCapture(0, cv2.CAP_DSHOW)
195
+ # Initialize webcam (backend-agnostic; see DEFAULT_CAMERA_BACKEND)
196
+ cap = cv2.VideoCapture(0, DEFAULT_CAMERA_BACKEND)
184
197
  if not cap.isOpened():
185
198
  raise RuntimeError("Failed to access webcam")
186
199
 
@@ -18,6 +18,7 @@ if TYPE_CHECKING: # ultralytics is imported lazily inside the function
18
18
 
19
19
  # Constants
20
20
  DEFAULT_ACCURACY_LEVEL = 1
21
+ DEFAULT_DEVICE = "cpu" # CUDA builds fail on older GPUs (sm_50 and lower)
21
22
  MODEL_MAPPING = {
22
23
  1: "yolo11n.pt",
23
24
  2: "yolo11s.pt",
@@ -32,7 +33,8 @@ def detect_objects(
32
33
  *,
33
34
  model: YOLO | None = None,
34
35
  accuracy_level: int = DEFAULT_ACCURACY_LEVEL,
35
- show_result: bool = False
36
+ show_result: bool = False,
37
+ device: str = DEFAULT_DEVICE
36
38
  ) -> Result:
37
39
  """Detect objects in an image using YOLO models.
38
40
 
@@ -52,6 +54,12 @@ def detect_objects(
52
54
  Default: 1
53
55
  show_result: Show detection visualization window.
54
56
  Default: False
57
+ device: Torch device for inference ("cpu", "cuda", "0", ...).
58
+ Defaults to "cpu" because CUDA wheels no longer support older
59
+ GPUs (compute capability below 5.0 is unsupported by cu12/cu13
60
+ builds) and fail with cudaErrorNoKernelImageForDevice; pass
61
+ "cuda" explicitly to use the GPU.
62
+ Default: "cpu"
55
63
 
56
64
  Returns:
57
65
  Result object with detections and metadata:
@@ -89,7 +97,7 @@ def detect_objects(
89
97
  model = YOLO(model=model_name)
90
98
 
91
99
  # Run inference (ultralytics does not mutate the input array)
92
- result = model(image._data)[0]
100
+ result = model(image._data, device=device)[0]
93
101
  boxes = result.boxes
94
102
 
95
103
  # Process detections
@@ -110,6 +118,7 @@ def detect_objects(
110
118
  meta={
111
119
  "source": image,
112
120
  "operation": "detect_objects",
113
- "model": model_name or "custom"
121
+ "model": model_name or "custom",
122
+ "device": device
114
123
  }
115
124
  )
@@ -136,7 +136,9 @@ class Pipeline:
136
136
 
137
137
  Operations can be passed as separate arguments or as a single
138
138
  list/tuple. Passing another Pipeline as an operation nests it: the
139
- inner pipeline runs as a single step.
139
+ inner pipeline runs as a single step. An empty pipeline is accepted
140
+ at construction time but `run()` raises ValueError until at least
141
+ one operation is added with `apply()`.
140
142
 
141
143
  Args:
142
144
  *operations (Callable | Step | list | tuple):
@@ -56,7 +56,10 @@ def apply_average_blur(
56
56
  if (
57
57
  not isinstance(kernel_size, tuple)
58
58
  or len(kernel_size) != 2
59
- or not all(isinstance(k, int) and k > 0 for k in kernel_size)
59
+ or not all(
60
+ isinstance(k, int) and not isinstance(k, bool) and k > 0
61
+ for k in kernel_size
62
+ )
60
63
  ):
61
64
  raise ValueError("'kernel_size' must be a tuple of two positive integers.")
62
65
 
@@ -107,7 +110,10 @@ def apply_gaussian_blur(
107
110
  if (
108
111
  not isinstance(kernel_size, tuple)
109
112
  or len(kernel_size) != 2
110
- or not all(isinstance(k, int) and k > 0 and k % 2 == 1 for k in kernel_size)
113
+ or not all(
114
+ isinstance(k, int) and not isinstance(k, bool) and k > 0 and k % 2 == 1
115
+ for k in kernel_size
116
+ )
111
117
  ):
112
118
  raise ValueError("'kernel_size' must be a tuple of two odd positive integers.")
113
119
 
@@ -156,7 +162,8 @@ def apply_median_blur(
156
162
  if not isinstance(image, Image):
157
163
  raise TypeError("'image' must be an Image instance.")
158
164
 
159
- if not isinstance(filter_size, int) or filter_size <= 1 or filter_size % 2 == 0:
165
+ if not isinstance(filter_size, int) or isinstance(filter_size, bool) \
166
+ or filter_size <= 1 or filter_size % 2 == 0:
160
167
  raise ValueError("'filter_size' must be an odd integer greater than 1.")
161
168
 
162
169
  # Apply median filtering
@@ -212,11 +219,11 @@ def apply_bilateral_blur(
212
219
  if not isinstance(image, Image):
213
220
  raise TypeError("'image' must be an Image instance.")
214
221
 
215
- if not isinstance(filter_size, int) or filter_size <= 0:
222
+ if not isinstance(filter_size, int) or isinstance(filter_size, bool) or filter_size <= 0:
216
223
  raise ValueError("'filter_size' must be a positive integer.")
217
- if not isinstance(sigma_color, (int, float)) or sigma_color <= 0:
224
+ if isinstance(sigma_color, bool) or not isinstance(sigma_color, (int, float)) or sigma_color <= 0:
218
225
  raise ValueError("'sigma_color' must be a positive number.")
219
- if not isinstance(sigma_space, (int, float)) or sigma_space <= 0:
226
+ if isinstance(sigma_space, bool) or not isinstance(sigma_space, (int, float)) or sigma_space <= 0:
220
227
  raise ValueError("'sigma_space' must be a positive number.")
221
228
 
222
229
  # Apply bilateral filter
@@ -29,7 +29,8 @@ def apply_clahe_contrast(
29
29
  """Enhance image contrast using CLAHE (adaptive histogram equalization).
30
30
 
31
31
  CLAHE applies histogram equalization on small regions for better local contrast.
32
- Works well for images with varying lighting conditions.
32
+ Works well for images with varying lighting conditions. Color images are
33
+ converted to grayscale first; the output is always single-channel.
33
34
 
34
35
  Args:
35
36
  image: Input image to enhance.
@@ -52,18 +53,23 @@ def apply_clahe_contrast(
52
53
  if not isinstance(image, Image):
53
54
  raise TypeError("'image' must be an Image instance")
54
55
 
55
- if not isinstance(clip_limit, (int, float)) or clip_limit <= 0:
56
+ if isinstance(clip_limit, bool) or not isinstance(clip_limit, (int, float)) \
57
+ or clip_limit <= 0:
56
58
  raise ValueError("'clip_limit' must be a positive number")
57
59
 
58
60
  if (
59
61
  not isinstance(tile_grid_size, tuple)
60
62
  or len(tile_grid_size) != 2
61
- or not all(isinstance(i, int) and i > 0 for i in tile_grid_size)
63
+ or not all(isinstance(i, int) and not isinstance(i, bool) and i > 0 for i in tile_grid_size)
62
64
  ):
63
65
  raise TypeError("'tile_grid_size' must be a tuple of two positive integers")
64
66
 
65
- # Convert and apply CLAHE (cvtColor returns a new array; input untouched)
66
- grayscale = cv2.cvtColor(image._data, cv2.COLOR_BGR2GRAY)
67
+ # CLAHE works on a single channel: grayscale BGR/RGB inputs, luma of
68
+ # color ones (channel weights are identical for BGR and RGB).
69
+ if image.colorspace == "GRAY":
70
+ grayscale = image._data
71
+ else:
72
+ grayscale = cv2.cvtColor(image._data, cv2.COLOR_BGR2GRAY)
67
73
  clahe = cv2.createCLAHE(clipLimit=clip_limit, tileGridSize=tile_grid_size)
68
74
  enhanced = clahe.apply(grayscale)
69
75
 
@@ -84,7 +90,8 @@ def apply_histogram_equalization(
84
90
  """Global histogram equalization for contrast enhancement.
85
91
 
86
92
  Normalizes image intensity for better overall contrast.
87
- Simple but may overamplify noise in some cases.
93
+ Simple but may overamplify noise in some cases. Color images are
94
+ converted to grayscale first; the output is always single-channel.
88
95
 
89
96
  Args:
90
97
  image: Input image to enhance.
@@ -101,8 +108,13 @@ def apply_histogram_equalization(
101
108
  if not isinstance(image, Image):
102
109
  raise TypeError("'image' must be an Image instance")
103
110
 
104
- # Convert and apply global histogram equalization
105
- grayscale = cv2.cvtColor(image._data, cv2.COLOR_BGR2GRAY)
111
+ # Global equalization works on a single channel: grayscale inputs pass
112
+ # through, color ones are converted first (luma weights are identical
113
+ # for BGR and RGB).
114
+ if image.colorspace == "GRAY":
115
+ grayscale = image._data
116
+ else:
117
+ grayscale = cv2.cvtColor(image._data, cv2.COLOR_BGR2GRAY)
106
118
  enhanced = cv2.equalizeHist(grayscale)
107
119
 
108
120
  return Result(
@@ -123,7 +135,8 @@ def apply_contrast_stretching(
123
135
  """Linear contrast stretching using alpha and beta.
124
136
 
125
137
  Applies the formula: new_pixel = alpha × pixel + beta
126
- Simple but effective for basic contrast adjustment.
138
+ Simple but effective for basic contrast adjustment. Color images are
139
+ converted to grayscale first; the output is always single-channel.
127
140
 
128
141
  Args:
129
142
  image: Input image to enhance.
@@ -146,14 +159,19 @@ def apply_contrast_stretching(
146
159
  if not isinstance(image, Image):
147
160
  raise TypeError("'image' must be an Image instance")
148
161
 
149
- if not isinstance(alpha, (int, float)) or alpha < 0:
162
+ if isinstance(alpha, bool) or not isinstance(alpha, (int, float)) or alpha < 0:
150
163
  raise ValueError("'alpha' must be a non-negative number")
151
164
 
152
- if not isinstance(beta, int) or not (0 <= beta <= 255):
165
+ if not isinstance(beta, int) or isinstance(beta, bool) or not (0 <= beta <= 255):
153
166
  raise ValueError("'beta' must be an integer between 0 and 255")
154
167
 
155
- # Convert and apply linear stretching
156
- grayscale = cv2.cvtColor(image._data, cv2.COLOR_BGR2GRAY)
168
+ # Linear stretching on a single channel: grayscale inputs pass through,
169
+ # color ones are converted first (luma weights are identical for BGR
170
+ # and RGB).
171
+ if image.colorspace == "GRAY":
172
+ grayscale = image._data
173
+ else:
174
+ grayscale = cv2.cvtColor(image._data, cv2.COLOR_BGR2GRAY)
157
175
  enhanced = cv2.convertScaleAbs(grayscale, alpha=alpha, beta=beta)
158
176
 
159
177
  return Result(
@@ -23,6 +23,7 @@ def crop_image(
23
23
 
24
24
  Extracts a rectangular region from the image using the specified coordinates.
25
25
  The cropped area must be within image bounds and have valid dimensions.
26
+ The crop is an independent copy; editing it never affects the source image.
26
27
 
27
28
  Args:
28
29
  image: Input image to crop.
@@ -47,7 +48,8 @@ def crop_image(
47
48
  not isinstance(start_point, tuple) or
48
49
  not isinstance(end_point, tuple) or
49
50
  len(start_point) != 2 or len(end_point) != 2 or
50
- not all(isinstance(c, int) for c in start_point + end_point)
51
+ not all(isinstance(c, int) and not isinstance(c, bool)
52
+ for c in start_point + end_point)
51
53
  ):
52
54
  raise TypeError("'start_point' and 'end_point' must be (x, y) tuples of integers")
53
55
 
@@ -65,8 +67,9 @@ def crop_image(
65
67
  if x2 > width or y2 > height:
66
68
  raise ValueError(f"Crop area exceeds image bounds ({width}x{height})")
67
69
 
68
- # Extract the region
69
- cropped = image._data[y1:y2, x1:x2]
70
+ # Extract the region as a copy so later in-place edits of the crop can
71
+ # never touch the source image (non-destructive contract)
72
+ cropped = image._data[y1:y2, x1:x2].copy()
70
73
 
71
74
  return Result(
72
75
  image=cropped,
@@ -24,6 +24,45 @@ DEFAULT_MIN_CONFIDENCE = 0.7
24
24
  DEFAULT_CAMERA_INDEX = 0
25
25
  DEFAULT_DELAY = 0.1
26
26
  DEFAULT_FACE_ID = "unknown"
27
+ MAX_EMPTY_FRAMES = 250 # Abandon capture after this many frames without a face
28
+
29
+
30
+ def _validate_parameters(
31
+ *,
32
+ num_images: int,
33
+ start_index: int,
34
+ min_confidence: float,
35
+ camera_index: int,
36
+ apply_resize: tuple[int, int] | bool,
37
+ delay: float
38
+ ) -> None:
39
+ """Validate all user-supplied parameters up front, before touching the camera."""
40
+ if not isinstance(num_images, int) or isinstance(num_images, bool) or num_images <= 0:
41
+ raise ValueError("'num_images' must be a positive integer")
42
+
43
+ if not isinstance(start_index, int) or isinstance(start_index, bool) or start_index < 0:
44
+ raise ValueError("'start_index' must be a non-negative integer")
45
+
46
+ if isinstance(min_confidence, bool) or not isinstance(min_confidence, (int, float)) \
47
+ or not (0 <= min_confidence <= 1):
48
+ raise ValueError("'min_confidence' must be between 0 and 1")
49
+
50
+ if not isinstance(camera_index, int) or isinstance(camera_index, bool) or camera_index < 0:
51
+ raise ValueError("'camera_index' must be a non-negative integer")
52
+
53
+ if apply_resize is not False and (
54
+ not isinstance(apply_resize, tuple)
55
+ or len(apply_resize) != 2
56
+ or not all(
57
+ isinstance(dim, int) and not isinstance(dim, bool) and dim > 0
58
+ for dim in apply_resize
59
+ )
60
+ ):
61
+ raise ValueError("'apply_resize' must be False or a (width, height) tuple "
62
+ "of two positive integers")
63
+
64
+ if isinstance(delay, bool) or not isinstance(delay, (int, float)) or delay < 0:
65
+ raise ValueError("'delay' must be a non-negative number")
27
66
 
28
67
 
29
68
  def capture_bulk_pictures(
@@ -43,12 +82,17 @@ def capture_bulk_pictures(
43
82
  ) -> None:
44
83
  """Generate a dataset by capturing faces from webcam.
45
84
 
46
- Captures frames from the webcam, detects faces, applies optional preprocessing,
47
- and saves cropped face images to disk. This function is useful for creating
48
- face recognition datasets with data augmentation.
85
+ Captures frames from the webcam, detects faces, applies optional
86
+ preprocessing, and saves cropped face images to disk. This function is
87
+ useful for creating face recognition datasets with data augmentation.
88
+
89
+ Saved crops go through this pipeline (each step optional):
90
+ median blur → laplacian sharpen → resize → grayscale → random rotate
49
91
 
50
- Processing pipeline (if enabled):
51
- median blur → laplacian sharpen → grayscale → resize → random rotate
92
+ Face detection always runs on the raw camera frame, so the capture loop
93
+ keeps working no matter which preprocessing flags are enabled. The loop
94
+ gives up after MAX_EMPTY_FRAMES consecutive frames without a usable
95
+ capture, instead of blocking forever when no face is in view.
52
96
 
53
97
  Args:
54
98
  folder_path: Base directory for dataset.
@@ -64,7 +108,7 @@ def capture_bulk_pictures(
64
108
  Default: 0
65
109
  apply_blur: Apply median blur (size=3).
66
110
  Default: False
67
- apply_grayscale: Convert to single-channel.
111
+ apply_grayscale: Convert crops to single-channel.
68
112
  Default: False
69
113
  apply_sharpen: Apply Laplacian (coef=1.0).
70
114
  Default: False
@@ -81,18 +125,19 @@ def capture_bulk_pictures(
81
125
  FileExistsError: If output folder exists
82
126
  RuntimeError: If camera access fails
83
127
  """
84
- # Validate numeric parameters to ensure safe operation
85
- if not isinstance(num_images, int) or num_images <= 0:
86
- raise ValueError("'num_images' must be a positive integer")
87
-
88
- if not isinstance(start_index, int) or start_index < 0:
89
- raise ValueError("'start_index' must be a non-negative integer")
90
-
91
- if not isinstance(min_confidence, (int, float)) or not (0 <= min_confidence <= 1):
92
- raise ValueError("'min_confidence' must be between 0 and 1")
128
+ # Validate everything up front, before creating folders or opening the
129
+ # camera, so an invalid argument never leaves partial state behind.
130
+ _validate_parameters(
131
+ num_images=num_images,
132
+ start_index=start_index,
133
+ min_confidence=min_confidence,
134
+ camera_index=camera_index,
135
+ apply_resize=apply_resize,
136
+ delay=delay
137
+ )
93
138
 
94
- if not isinstance(delay, (int, float)) or delay < 0:
95
- raise ValueError("'delay' must be a non-negative number")
139
+ if apply_rotate is not None and not isinstance(apply_rotate, bool):
140
+ raise TypeError("'apply_rotate' must be a boolean")
96
141
 
97
142
  try:
98
143
  import mediapipe as mp
@@ -127,12 +172,20 @@ def capture_bulk_pictures(
127
172
  )
128
173
 
129
174
  saved_count = 0
175
+ frames_without_face = 0
130
176
  try:
131
177
  while saved_count < num_images:
132
178
  # Capture frame
133
179
  success, frame = cap.read()
134
180
  if not success:
135
181
  print("Failed to capture frame, skipping...")
182
+ frames_without_face += 1
183
+ if frames_without_face >= MAX_EMPTY_FRAMES:
184
+ raise RuntimeError(
185
+ f"Camera stopped delivering frames after "
186
+ f"{frames_without_face} consecutive failures; "
187
+ f"saved {saved_count}/{num_images} images so far."
188
+ )
136
189
  continue
137
190
 
138
191
  # Apply preprocessing pipeline in sequence
@@ -153,12 +206,6 @@ def capture_bulk_pictures(
153
206
  coefficient=1.0
154
207
  ).image
155
208
 
156
- if apply_grayscale:
157
- # Convert to single-channel for reduced storage and faster processing
158
- processed = grayscale.convert_to_grayscale(
159
- image=Image.from_array(processed)
160
- ).image
161
-
162
209
  if apply_resize is not False:
163
210
  # Resize to consistent dimensions (e.g., 224x224 for ML models)
164
211
  processed = resize.resize_image(
@@ -166,6 +213,14 @@ def capture_bulk_pictures(
166
213
  new_size=apply_resize
167
214
  ).image
168
215
 
216
+ if apply_grayscale:
217
+ # Convert to single-channel for reduced storage and faster
218
+ # processing. Rotation then tags the intermediate as GRAY so
219
+ # the face detector can convert back to RGB correctly.
220
+ processed = grayscale.convert_to_grayscale(
221
+ image=Image.from_array(processed)
222
+ ).image
223
+
169
224
  if apply_rotate:
170
225
  # Apply random rotation with scaling for data augmentation
171
226
  # Rotation range: -45° to +45° with random scale factors
@@ -182,9 +237,12 @@ def capture_bulk_pictures(
182
237
  output_path = face_folder / filename
183
238
 
184
239
  try:
185
- # Detect face and crop to face region only
240
+ # Detect face and crop to face region only. The detector
241
+ # needs a color image, so pass the untouched camera frame
242
+ # (preprocessing here is augmentation for the saved crop,
243
+ # not a requirement of the detector).
186
244
  result = detect_faces(
187
- image=Image.from_array(processed),
245
+ image=Image.from_array(frame),
188
246
  max_faces=1,
189
247
  min_confidence=min_confidence,
190
248
  face_mesh_obj=face_mesh
@@ -193,13 +251,22 @@ def capture_bulk_pictures(
193
251
  # Save cropped face image
194
252
  result.save_as_img(str(output_path))
195
253
  saved_count += 1
254
+ frames_without_face = 0
196
255
 
197
256
  # Add delay between captures to allow subject movement
198
257
  if delay > 0:
199
258
  time.sleep(delay)
200
259
 
201
260
  except ValueError:
202
- # Skip frames with no detected faces (silent failure)
261
+ # Skip frames with no detected faces
262
+ frames_without_face += 1
263
+ if frames_without_face >= MAX_EMPTY_FRAMES:
264
+ print(
265
+ f"No face detected in {frames_without_face} consecutive "
266
+ f"frames; giving up with {saved_count}/{num_images} "
267
+ "images saved."
268
+ )
269
+ break
203
270
  continue
204
271
 
205
272
  finally:
@@ -23,9 +23,13 @@ def show_histogram(image: Image) -> Result:
23
23
  This function creates the histogram plot of the intensity distribution of
24
24
  an image. It handles both grayscale and color images, with automatic
25
25
  detection of image type. For color images, it plots a histogram for each
26
- color channel (BGR or RGB) with appropriate colors. The pyplot module is
27
- returned in ``data``; call ``matplotlib.pyplot.show()`` to display the
28
- figure.
26
+ color channel (BGR or RGB) with appropriate colors.
27
+
28
+ The figure is closed before returning, so batch calls do not accumulate
29
+ figures in memory. To display the plot interactively, call this function
30
+ with ``matplotlib.interactive(True)`` or re-create the figure with
31
+ ``plt.figure(1)`` right before ``plt.show()``: pyplot re-opens the most
32
+ recently closed figure number with its contents intact.
29
33
 
30
34
  Args:
31
35
  image (Image):
@@ -54,7 +58,7 @@ def show_histogram(image: Image) -> Result:
54
58
  channels = list(enumerate(colors))
55
59
  elif image.colorspace == "GRAY":
56
60
  channels = [(0, "black")]
57
- labels = None
61
+ labels = ("Intensity",)
58
62
  else:
59
63
  raise ValueError(f"Unknown colorspace: {image.colorspace}")
60
64
 
@@ -63,15 +67,13 @@ def show_histogram(image: Image) -> Result:
63
67
  hist = cv2.calcHist([image._data], [channel], None, [256], [0, 256])
64
68
  plt.plot(hist, color=color)
65
69
 
66
- if labels is not None:
67
- plt.title(f"Histogram of {image.colorspace} Channels")
68
- plt.legend(labels)
69
- plt.xlabel("Pixel Intensity")
70
- plt.ylabel("Frequency")
71
-
70
+ plt.title(f"Histogram of {image.colorspace} Channels")
71
+ plt.legend(labels)
72
+ plt.xlabel("Pixel Intensity")
73
+ plt.ylabel("Frequency")
72
74
  plt.xlim([0, 256])
73
75
 
74
- return Result(
76
+ result = Result(
75
77
  image=None,
76
78
  data=plt,
77
79
  meta={
@@ -79,3 +81,6 @@ def show_histogram(image: Image) -> Result:
79
81
  "operation": "show_histogram"
80
82
  }
81
83
  )
84
+ # Close the figure so repeated calls in batch jobs do not leak figures.
85
+ plt.close()
86
+ return result
@@ -46,7 +46,10 @@ def resize_image(
46
46
  if (
47
47
  not isinstance(new_size, tuple)
48
48
  or len(new_size) != 2
49
- or not all(isinstance(dim, int) and dim > 0 for dim in new_size)
49
+ or not all(
50
+ isinstance(dim, int) and not isinstance(dim, bool) and dim > 0
51
+ for dim in new_size
52
+ )
50
53
  ):
51
54
  raise ValueError("'new_size' must be a tuple of two positive integers")
52
55
 
@@ -136,9 +136,9 @@ def rotate_image_custom(
136
136
  """
137
137
  if not isinstance(image, Image):
138
138
  raise TypeError("'image' must be an Image instance")
139
- if not isinstance(angle, (int, float)):
139
+ if isinstance(angle, bool) or not isinstance(angle, (int, float)):
140
140
  raise TypeError("'angle' must be a number")
141
- if not isinstance(scale, (int, float)) or scale <= 0:
141
+ if isinstance(scale, bool) or not isinstance(scale, (int, float)) or scale <= 0:
142
142
  raise ValueError("'scale' must be a positive number")
143
143
 
144
144
  h, w = image.shape[:2]
@@ -38,7 +38,7 @@ def apply_laplacian_sharpening(
38
38
 
39
39
  Returns:
40
40
  Result object with sharpened image and metadata:
41
- - image: Sharpened image array
41
+ - image: Sharpened image array (same dtype as the input)
42
42
  - data: None
43
43
  - meta: Operation info and coefficient used
44
44
 
@@ -49,19 +49,19 @@ def apply_laplacian_sharpening(
49
49
  if not isinstance(image, Image):
50
50
  raise TypeError("'image' must be an Image instance")
51
51
 
52
- if not isinstance(coefficient, (int, float)) or coefficient < 0:
52
+ if isinstance(coefficient, bool) or not isinstance(coefficient, (int, float)) \
53
+ or coefficient < 0:
53
54
  raise ValueError("'coefficient' must be a non-negative number")
54
55
 
55
- # Apply Laplacian edge detection, then enhance edges while keeping
56
- # float arithmetic identical to: image + coefficient * |laplacian|
56
+ # Sharpen in float space: image + coefficient * |laplacian|, clipped
57
+ # once at the end. Casting the Laplacian to uint8 before combining
58
+ # would truncate edge responses above 255 and corrupt the output.
57
59
  laplacian = cv2.Laplacian(image._data, cv2.CV_64F)
58
60
  np.absolute(laplacian, out=laplacian)
59
- laplacian = laplacian.astype(np.uint8)
60
61
 
61
- sharpened = laplacian * coefficient
62
- sharpened += image._data
62
+ sharpened = image._data.astype(np.float64) + coefficient * laplacian
63
63
  np.clip(sharpened, 0, 255, out=sharpened)
64
- sharpened = sharpened.astype(np.uint8)
64
+ sharpened = sharpened.astype(image._data.dtype)
65
65
 
66
66
  return Result(
67
67
  image=sharpened,
@@ -90,7 +90,7 @@ def apply_unsharp_masking(
90
90
 
91
91
  Returns:
92
92
  Result object with sharpened image and metadata:
93
- - image: Sharpened image array
93
+ - image: Sharpened image array (same dtype as the input)
94
94
  - data: None
95
95
  - meta: Operation info and coefficient used
96
96
 
@@ -101,21 +101,20 @@ def apply_unsharp_masking(
101
101
  if not isinstance(image, Image):
102
102
  raise TypeError("'image' must be an Image instance")
103
103
 
104
- if not isinstance(coefficient, (int, float)) or coefficient < 0:
104
+ if isinstance(coefficient, bool) or not isinstance(coefficient, (int, float)) \
105
+ or coefficient < 0:
105
106
  raise ValueError("'coefficient' must be a non-negative number")
106
107
 
107
- # Create the mask from original vs blurred difference
108
+ # Unsharp masking: original + coefficient * (original - blurred),
109
+ # computed in float space and clipped once at the end. cv2.subtract
110
+ # saturates negative mask values and addWeighted on the saturated mask
111
+ # brightens flat regions instead of sharpening, so both are avoided.
108
112
  blurred = cv2.blur(image._data, DEFAULT_KERNEL_SIZE)
109
- mask = cv2.subtract(image._data, blurred)
110
-
111
- # Apply unsharp masking
112
- sharpened = cv2.addWeighted(
113
- image._data,
114
- 1 + coefficient,
115
- mask,
116
- -coefficient,
117
- 0
118
- )
113
+ mask = image._data.astype(np.float64) - blurred.astype(np.float64)
114
+
115
+ sharpened = image._data.astype(np.float64) + coefficient * mask
116
+ np.clip(sharpened, 0, 255, out=sharpened)
117
+ sharpened = sharpened.astype(image._data.dtype)
119
118
 
120
119
  return Result(
121
120
  image=sharpened,
ImagePRO/utils/image.py CHANGED
@@ -13,7 +13,7 @@ Colorspace = Literal["BGR", "RGB", "GRAY"]
13
13
  SourceType = Literal["path", "array"]
14
14
 
15
15
 
16
- @dataclass
16
+ @dataclass(frozen=True)
17
17
  class Image:
18
18
  """
19
19
  Lightweight, immutable image wrapper for ImagePRO.
@@ -72,11 +72,18 @@ class Image:
72
72
  """
73
73
  if not isinstance(path, (str, Path)):
74
74
  raise TypeError("'path' must be a string or pathlib.Path.")
75
- np_image = cv2.imread(str(path))
76
- if np_image is None:
77
- raise ValueError(f"Failed to load image from {path}")
78
75
  if colorspace not in ("BGR", "RGB", "GRAY"):
79
76
  raise ValueError("'colorspace' must be one of 'BGR', 'RGB', 'GRAY'.")
77
+
78
+ # cv2.imread always returns BGR; grayscale output requires the
79
+ # IMREAD_GRAYSCALE flag up front (post-hoc conversion of the BGR
80
+ # result would work numerically but this loads 1 channel directly).
81
+ imread_flag = cv2.IMREAD_GRAYSCALE if colorspace == "GRAY" else cv2.IMREAD_COLOR
82
+ np_image = cv2.imread(str(path), imread_flag)
83
+ if np_image is None:
84
+ raise ValueError(f"Failed to load image from {path}")
85
+ if colorspace == "RGB":
86
+ np_image = cv2.cvtColor(np_image, cv2.COLOR_BGR2RGB)
80
87
  return cls(_data=np_image, path=Path(path), colorspace=colorspace, source_type="path")
81
88
 
82
89
  @classmethod
ImagePRO/utils/result.py CHANGED
@@ -77,26 +77,28 @@ class Result:
77
77
 
78
78
  # Save single image
79
79
  if isinstance(self.image, np.ndarray):
80
+ if not out_path.suffix:
81
+ # OpenCV picks its writer from the file extension, so an
82
+ # extensionless path would fail in imwrite; default to .png.
83
+ out_path = out_path.with_name(f"{out_path.name}.png")
80
84
  ok = cv2.imwrite(str(out_path), self.image)
81
85
  if not ok:
82
86
  raise IOError(f"Failed to save image: {out_path}")
83
87
  return self
84
88
 
85
89
  # Save list of images with auto-suffixes
86
- base = str(out_path)
90
+ base = Path(out_path)
91
+ stem, suffix = base.stem, base.suffix
92
+ if not suffix:
93
+ # cv2.imwrite needs an extension to choose a writer.
94
+ suffix = ".png"
95
+ base = base.with_name(f"{stem}.png")
87
96
  for idx, img in enumerate(self.image):
88
97
  if idx == 0:
89
- path_i = base
98
+ path_i = str(base)
90
99
  else:
91
- # Suffix logic: .jpg → _{idx}.jpg, else _{idx} before extension
92
- if base.endswith(".jpg"):
93
- path_i = base.replace(".jpg", f"_{idx}.jpg")
94
- else:
95
- p = Path(base)
96
- if p.suffix:
97
- path_i = str(p.with_name(f"{p.stem}_{idx}{p.suffix}"))
98
- else:
99
- path_i = f"{base}_{idx}"
100
+ # Suffix logic: out.jpg -> out_1.jpg, out_2.jpg, ...
101
+ path_i = str(base.with_name(f"{stem}_{idx}{suffix}"))
100
102
  ok = cv2.imwrite(path_i, img)
101
103
  if not ok:
102
104
  raise IOError(f"Failed to save image: {path_i}")
@@ -115,7 +117,9 @@ class Result:
115
117
  path (str | Path):
116
118
  Output CSV file path.
117
119
  rows (Optional[list[list[Any]]], optional):
118
- Data to write as rows. If None, uses self.data. Defaults to None.
120
+ Data to write as rows. If None, uses self.data.
121
+ A 2D numpy.ndarray is also accepted (one CSV row per array
122
+ row). Defaults to None.
119
123
 
120
124
  Returns:
121
125
  Result: Self, for method chaining.
@@ -135,7 +139,10 @@ class Result:
135
139
  try:
136
140
  with out_path.open("w", newline="", encoding="utf-8") as f:
137
141
  writer = csv.writer(f)
138
- if isinstance(payload, (list, tuple)):
142
+ if isinstance(payload, np.ndarray):
143
+ # Write one CSV row per array row
144
+ writer.writerows(payload.tolist())
145
+ elif isinstance(payload, (list, tuple)):
139
146
  writer.writerows(flatten_rows(payload))
140
147
  else:
141
148
  writer.writerow([payload])
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ImagePRO-Python
3
- Version: 1.4.0
3
+ Version: 1.5.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
@@ -207,7 +207,7 @@ ImagePRO is built with a modular architecture designed for extensibility and mai
207
207
  - **Documentation**: Google-style docstrings for all functions
208
208
 
209
209
  ### Key Design Principles
210
- - **Non-Destructive Operations**: Operations never write into the input image; they return results in new `Result` objects (note: `crop_image` returns a view into the source array)
210
+ - **Non-Destructive Operations**: Operations never write into the input image; they return results in new `Result` objects
211
211
  - **Functional Style**: Stateless functions that can be easily composed
212
212
  - **Result Objects**: Unified return type containing image, data, and metadata
213
213
  - **Keyword Arguments**: All optional parameters use keyword-only syntax
@@ -0,0 +1,33 @@
1
+ ImagePRO/__init__.py,sha256=Rv5QiWX7e-OlR3DoLBaV5394xw6jwmarJrr2d0_RN50,1081
2
+ ImagePRO/human_analysis/__init__.py,sha256=uSU0lM1p1HM6TNGv6u0nPHWy9QY505v43lqTkhClddM,190
3
+ ImagePRO/human_analysis/body_analysis/__init__.py,sha256=uUWG5eOxLQjZoYddmNkRlF66l6vSVyJpUFz1wDddXkk,374
4
+ ImagePRO/human_analysis/body_analysis/body_pose_estimation.py,sha256=gLWM5N2cMUNxyRX-7_z_52MnE8J9JM_UgAPoUWPCFpY,6567
5
+ ImagePRO/human_analysis/body_analysis/hand_tracking.py,sha256=g9rRf2dXA8eaepa0DB7qY01rA2POlpMjtyl3iFXhfck,8011
6
+ ImagePRO/human_analysis/face_analysis/__init__.py,sha256=297IQuoO2Wb_PyigS_2NOuVnM7FpNPceu5jhJp6YdG0,604
7
+ ImagePRO/human_analysis/face_analysis/eye_status_analysis.py,sha256=0bfzRuD6qpdANS33s-2XY5udDXVvoV2mGCwQbT0x0Zc,8150
8
+ ImagePRO/human_analysis/face_analysis/face_comparison.py,sha256=MbQ91LNlA7ySepR2IGUGrkNjTp-L-lj2NZw3HZ4WO84,4671
9
+ ImagePRO/human_analysis/face_analysis/face_detection.py,sha256=kBZfHD79ls6tMZr6avVIXv8NpPyd1Dyq4iXpEeNwFM8,4090
10
+ ImagePRO/human_analysis/face_analysis/face_mesh_analysis.py,sha256=09tkJlzA2aSPDKEgf68O5OLEStP55W9JRb0M8z_P4mA,9369
11
+ ImagePRO/human_analysis/face_analysis/head_pose_estimation.py,sha256=wJryUR4B-uf6aMIdbZxe5894HcPmHmG-THyH376jG-Q,8542
12
+ ImagePRO/object_analysis/__init__.py,sha256=eVQpYlHO89RYKT0CHjPfJjjif5X_Jx3AtAUpR5m0Yms,292
13
+ ImagePRO/object_analysis/object_detection.py,sha256=X7LCJdKMBpBqA4gXDq3W_5AQnf_CA83jTww0Vdqtypo,3903
14
+ ImagePRO/pipeline/__init__.py,sha256=-32hbR492qwxutXau7EfnysXbhW8fKKMywkV0ejIMFE,301
15
+ ImagePRO/pipeline/pipeline.py,sha256=n-Y-FtQbIiXiYsP-RekmCZg4cKlylU_quWyrEGsnSQw,13953
16
+ ImagePRO/pre_processing/__init__.py,sha256=pw6-J5sdbkdBG4RlTxOzQKaspGnlPIGo3m_NXH2bVCE,559
17
+ ImagePRO/pre_processing/blur.py,sha256=RO4na6x3cm80nduFR91WEOasIN5RtiPCfqwwuorLivo,8220
18
+ ImagePRO/pre_processing/contrast.py,sha256=B0BweCI0NnDwd2JcVL6GPUVjF7NX_MKWhiovYsC47h4,6001
19
+ ImagePRO/pre_processing/crop.py,sha256=ByIe2whvqoaEZVRzCMW8HBP7fsoaQDPLQIHPT4NB3Lo,2649
20
+ ImagePRO/pre_processing/dataset_generator.py,sha256=w1CImp1dP0RWwblUP3pcD6v0MG0nr4-a3MWZu_7oPOE,10922
21
+ ImagePRO/pre_processing/grayscale.py,sha256=MRCsFRIaL45sYVHzhuNyZBXsP7PF-yorn0Wt7xONvpY,1781
22
+ ImagePRO/pre_processing/histogram.py,sha256=1Xr7pEL-3UvpiPEpaenPCO4ZSK6gOxmOUFuDcxWmtx4,2927
23
+ ImagePRO/pre_processing/resize.py,sha256=lHLA69cBUN25UNblmSJTk3iG6nKNF_51vQguCbPwxt4,1824
24
+ ImagePRO/pre_processing/rotate.py,sha256=HtbMCh7p_BY5n3tl974Iboyv1XRMhfORPlEBGjjGpCs,4228
25
+ ImagePRO/pre_processing/sharpen.py,sha256=y2dzaesjsKyonvobXCPOiFtD1H5y3_Hdi7GpX1v6jO4,4134
26
+ ImagePRO/utils/__init__.py,sha256=McVd-o9KtKOpAVYbLi19KxsatPPFFF-Qbt0gdgY3Ln8,170
27
+ ImagePRO/utils/image.py,sha256=_q34vEcP2-iJdJkiYFbc2obxyK5EPue-pJlrfV50Arc,4458
28
+ ImagePRO/utils/result.py,sha256=UnOLI8MSJIMCNrvMmhvJgxDXbGAMHmu9hgJ2gw64buQ,6577
29
+ imagepro_python-1.5.0.dist-info/licenses/LICENSE,sha256=sehkHN7dbe5umrINZJyJjIf9VTemZSgTfJlq6iIsbJQ,1069
30
+ imagepro_python-1.5.0.dist-info/METADATA,sha256=WUGiP3SF3DjQ0-Jl8unZDHx1yBonAdujVl3y_UTT5TE,11063
31
+ imagepro_python-1.5.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
+ imagepro_python-1.5.0.dist-info/top_level.txt,sha256=UOuPzsoWrVKXgUupOSI96shJ6wE8Fhz-8bm8oMlLC3s,9
33
+ imagepro_python-1.5.0.dist-info/RECORD,,
@@ -1,33 +0,0 @@
1
- ImagePRO/__init__.py,sha256=3B8cQH4zhV6EEAvVMKYMsJHok_hpGLTgu4QfP33DV08,1081
2
- ImagePRO/human_analysis/__init__.py,sha256=uSU0lM1p1HM6TNGv6u0nPHWy9QY505v43lqTkhClddM,190
3
- ImagePRO/human_analysis/body_analysis/__init__.py,sha256=uUWG5eOxLQjZoYddmNkRlF66l6vSVyJpUFz1wDddXkk,374
4
- ImagePRO/human_analysis/body_analysis/body_pose_estimation.py,sha256=yqEV18ZEZwV__Oqt1Hi72EGmbTNes-mrY068SBT9RbU,6245
5
- ImagePRO/human_analysis/body_analysis/hand_tracking.py,sha256=SpdIZNgvaHzINpWu7rgNawSHvuuawzFtQ7JiHHQ0fqA,7578
6
- ImagePRO/human_analysis/face_analysis/__init__.py,sha256=297IQuoO2Wb_PyigS_2NOuVnM7FpNPceu5jhJp6YdG0,604
7
- ImagePRO/human_analysis/face_analysis/eye_status_analysis.py,sha256=Q_EUNPpXoF7Vi-Kd0gURNxs9X3U5J4Yi3coL_6lkeb8,7773
8
- ImagePRO/human_analysis/face_analysis/face_comparison.py,sha256=lIr490PAf8R6C_Zhv-zBN1Un1EPiwxi4W7ahgkqXAVc,4724
9
- ImagePRO/human_analysis/face_analysis/face_detection.py,sha256=F3Bq_0rAcQklmNAu-JvxGBIxUnWmyM-SHuZawHXJeac,4009
10
- ImagePRO/human_analysis/face_analysis/face_mesh_analysis.py,sha256=rv08Mx_IBB-hjTh09bFaaPjaOm0tFbBs0brc4mE66mg,8723
11
- ImagePRO/human_analysis/face_analysis/head_pose_estimation.py,sha256=2_ZVpwavyIzCIH4x3d2bvCPvv-X5gpmSYxtk5jlgCYw,7688
12
- ImagePRO/object_analysis/__init__.py,sha256=eVQpYlHO89RYKT0CHjPfJjjif5X_Jx3AtAUpR5m0Yms,292
13
- ImagePRO/object_analysis/object_detection.py,sha256=1qPd3Xfo2Vi4JnBs5wD8-k3WR6ZkYNdT9tco2Ze1ICQ,3385
14
- ImagePRO/pipeline/__init__.py,sha256=-32hbR492qwxutXau7EfnysXbhW8fKKMywkV0ejIMFE,301
15
- ImagePRO/pipeline/pipeline.py,sha256=-CRWfWXB_lFdjrqg3gdyYFFvq0vZAednrJnod6b4WYA,13802
16
- ImagePRO/pre_processing/__init__.py,sha256=pw6-J5sdbkdBG4RlTxOzQKaspGnlPIGo3m_NXH2bVCE,559
17
- ImagePRO/pre_processing/blur.py,sha256=3zNTNpvLZPdrtRtBziB6X8ys8M7cIvyPDhojTqR63jo,7950
18
- ImagePRO/pre_processing/contrast.py,sha256=4UT78ggW1rh_iaEI8FObDB4kpEmRRFWjdJES60SnHDM,5064
19
- ImagePRO/pre_processing/crop.py,sha256=3Nwerzo57kx1sZPr9m355P-qf1NuZSZEA5iUn2Q1704,2406
20
- ImagePRO/pre_processing/dataset_generator.py,sha256=i7UM4o8hznFUfUgbYI6H01EkQJOqt4IsI9IrjzzN8gQ,7921
21
- ImagePRO/pre_processing/grayscale.py,sha256=MRCsFRIaL45sYVHzhuNyZBXsP7PF-yorn0Wt7xONvpY,1781
22
- ImagePRO/pre_processing/histogram.py,sha256=sNTWWZXr5VLpOrF_PTTJ6TD8yv1zR1l9cMyXHe7JFIM,2596
23
- ImagePRO/pre_processing/resize.py,sha256=Rnde8l_PXck5OWQBEMOHgLeW2YkTz9Xfr6fGmz0sdm8,1760
24
- ImagePRO/pre_processing/rotate.py,sha256=wQj3nQpN-ilM0HHwovN--UdGEJkPCY0BJ0zAdyBxv-o,4174
25
- ImagePRO/pre_processing/sharpen.py,sha256=hOkY7QhzgMp9LXQwt56AP4enn24ntXlmFLe57Bxssvw,3682
26
- ImagePRO/utils/__init__.py,sha256=McVd-o9KtKOpAVYbLi19KxsatPPFFF-Qbt0gdgY3Ln8,170
27
- ImagePRO/utils/image.py,sha256=Y64mD97rTiJb8_uNYlyG227VZodeb4U1aGezMkj7MnM,4025
28
- ImagePRO/utils/result.py,sha256=ToQaOmUlTtobJkC65BG0YO_6BkBleMBn-0Qydko7cZc,6138
29
- imagepro_python-1.4.0.dist-info/licenses/LICENSE,sha256=sehkHN7dbe5umrINZJyJjIf9VTemZSgTfJlq6iIsbJQ,1069
30
- imagepro_python-1.4.0.dist-info/METADATA,sha256=POgVUYD-3fKBCKxkjSgKTZqCt5BWjvtfDBqlrn7Qg9I,11121
31
- imagepro_python-1.4.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
32
- imagepro_python-1.4.0.dist-info/top_level.txt,sha256=UOuPzsoWrVKXgUupOSI96shJ6wE8Fhz-8bm8oMlLC3s,9
33
- imagepro_python-1.4.0.dist-info/RECORD,,