openfocus 1.28__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.
core/image_loader.py ADDED
@@ -0,0 +1,500 @@
1
+ """
2
+ 图像栈加载器模块
3
+ 负责从文件夹中加载图像栈并生成缩略图
4
+ """
5
+
6
+ import os
7
+ import cv2
8
+ import numpy as np
9
+ from datetime import datetime
10
+ from typing import List, Tuple, Optional, Dict, Any
11
+
12
+ try:
13
+ from PIL import Image as PILImage, ImageOps
14
+ PIL_AVAILABLE = True
15
+ except ImportError:
16
+ PIL_AVAILABLE = False
17
+
18
+ try:
19
+ from pillow_heif import register_heif_opener
20
+ register_heif_opener()
21
+ HEIF_AVAILABLE = True
22
+ except ImportError:
23
+ HEIF_AVAILABLE = False
24
+
25
+ from utils.image_utils import to_display_uint8
26
+
27
+
28
+ def _read_exif_bytes(path: str) -> bytes:
29
+ """Raw EXIF block of an image (b"") when absent or unreadable."""
30
+ if not PIL_AVAILABLE:
31
+ return b""
32
+ try:
33
+ ext = os.path.splitext(path)[1].lower()
34
+ if ext not in ('.jpg', '.jpeg', '.tif', '.tiff', '.png'):
35
+ return b""
36
+ with PILImage.open(path) as pil_img:
37
+ return pil_img.info.get('exif', b"") or b""
38
+ except Exception:
39
+ return b""
40
+
41
+
42
+ # EXIF Orientation tag values that need a pixel transform (1 = already OK).
43
+ _EXIF_TRANSFORMS = {
44
+ 2: lambda a: a[:, ::-1], # mirror horizontal
45
+ 3: lambda a: np.rot90(a, 2), # rotate 180
46
+ 4: lambda a: a[::-1, :], # mirror vertical
47
+ 5: lambda a: np.rot90(a[:, ::-1], 1), # transpose
48
+ 6: lambda a: np.rot90(a, -1), # rotate 90 CW
49
+ 7: lambda a: np.rot90(a[::-1, :], 1), # anti-transpose
50
+ 8: lambda a: np.rot90(a, 1), # rotate 90 CCW
51
+ }
52
+
53
+
54
+ def apply_exif_orientation(path: str, img: np.ndarray) -> np.ndarray:
55
+ """Rotate/flip a decoded image according to its EXIF Orientation tag.
56
+
57
+ Cameras store sensor-native orientation and record how to display it in
58
+ EXIF; OpenCV ignores that tag, so portrait shots load sideways without
59
+ this correction. Returns the image unchanged when Pillow is missing or
60
+ the tag is absent/neutral.
61
+ """
62
+ if not PIL_AVAILABLE:
63
+ return img
64
+ try:
65
+ ext = os.path.splitext(path)[1].lower()
66
+ if ext not in ('.jpg', '.jpeg', '.tif', '.tiff'):
67
+ return img
68
+ with PILImage.open(path) as pil_img:
69
+ orientation = pil_img.getexif().get(274, 1)
70
+ transform = _EXIF_TRANSFORMS.get(int(orientation))
71
+ if transform is not None:
72
+ return np.ascontiguousarray(transform(img))
73
+ except Exception as e:
74
+ print(f"EXIF orientation check failed for {path}: {e}")
75
+ return img
76
+
77
+
78
+ def _read_via_pil(path: str) -> Optional[np.ndarray]:
79
+ """Decode a file OpenCV cannot (HEIC/HEIF) through Pillow.
80
+
81
+ Returns a 3-channel BGR array like cv2.imdecode(IMREAD_COLOR), or None when
82
+ Pillow is missing or the file is unreadable, so callers count it as a
83
+ failed file instead of raising.
84
+ """
85
+ if not PIL_AVAILABLE:
86
+ return None
87
+ try:
88
+ with PILImage.open(path) as pil_img:
89
+ if pil_img.mode in ('I', 'I;16', 'I;16B'):
90
+ arr = np.array(pil_img).astype(np.uint16)
91
+ else:
92
+ arr = np.array(pil_img.convert('RGB'))
93
+ if arr.ndim == 2:
94
+ return cv2.cvtColor(np.ascontiguousarray(arr), cv2.COLOR_GRAY2BGR)
95
+ if arr.shape[2] == 4:
96
+ return cv2.cvtColor(arr, cv2.COLOR_RGBA2BGR)
97
+ return cv2.cvtColor(arr, cv2.COLOR_RGB2BGR)
98
+ except Exception as e:
99
+ print(f"Unsupported image for PIL fallback {path}: {e}")
100
+ return None
101
+
102
+
103
+ class ImageStackLoader:
104
+ """图像栈加载器"""
105
+
106
+ SUPPORTED_FORMATS = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.tif'}
107
+ if HEIF_AVAILABLE:
108
+ SUPPORTED_FORMATS |= {'.heic', '.heif'}
109
+ SUPPORTED_VIDEO_FORMATS = {'.mp4', '.avi', '.mov', '.mkv', '.wmv', '.flv', '.webm'}
110
+
111
+ def __init__(self):
112
+ self.image_paths = []
113
+ self.images = []
114
+ self.thumbnail_size = (600, 400)
115
+
116
+ def load_from_folder(self, folder_path: str, scale_factor: float = 1.0) -> Tuple[bool, str, List[np.ndarray], List[str]]:
117
+ if not os.path.isdir(folder_path):
118
+ return False, "Selected path is not a valid directory", [], []
119
+
120
+ image_files = []
121
+ for filename in os.listdir(folder_path):
122
+ ext = os.path.splitext(filename)[1].lower()
123
+ if ext in self.SUPPORTED_FORMATS:
124
+ full_path = os.path.join(folder_path, filename)
125
+ image_files.append((filename, full_path))
126
+
127
+ if not image_files:
128
+ return False, "No supported image files found in the folder", [], []
129
+
130
+ image_files.sort(key=lambda x: x[0])
131
+
132
+ loaded_images = []
133
+ filenames = []
134
+ failed_count = 0
135
+ self.source_exif = b""
136
+
137
+ for filename, full_path in image_files:
138
+ try:
139
+ img = cv2.imdecode(np.fromfile(full_path, dtype=np.uint8),
140
+ cv2.IMREAD_ANYDEPTH | cv2.IMREAD_COLOR)
141
+ if img is None:
142
+ # OpenCV ships no HEIC/HEIF decoder: fall back to Pillow
143
+ img = _read_via_pil(full_path)
144
+ if img is not None:
145
+ if not self.source_exif:
146
+ self.source_exif = _read_exif_bytes(full_path)
147
+ img = apply_exif_orientation(full_path, img)
148
+ if scale_factor != 1.0 and 0 < scale_factor < 1.0:
149
+ width = int(img.shape[1] * scale_factor)
150
+ height = int(img.shape[0] * scale_factor)
151
+ img = cv2.resize(img, (width, height), interpolation=cv2.INTER_AREA)
152
+ loaded_images.append(img)
153
+ filenames.append(filename)
154
+ else:
155
+ failed_count += 1
156
+ except Exception as e:
157
+ failed_count += 1
158
+ print(f"Failed to load image {filename}: {e}")
159
+
160
+ if not loaded_images:
161
+ return False, "Could not load any image files", [], []
162
+
163
+ self.images = loaded_images
164
+ self.image_paths = [f[1] for f in image_files[:len(loaded_images)]]
165
+
166
+ message = f"Loaded {len(loaded_images)} image(s)"
167
+ if failed_count > 0:
168
+ message += f" (failed: {failed_count})"
169
+
170
+ return True, message, loaded_images, filenames
171
+
172
+ def load_from_video(self, video_path: str, scale_factor: float = 1.0) -> Tuple[bool, str, List[np.ndarray], List[str]]:
173
+ if not os.path.isfile(video_path):
174
+ return False, "Video file does not exist", [], []
175
+
176
+ ext = os.path.splitext(video_path)[1].lower()
177
+ if ext not in self.SUPPORTED_VIDEO_FORMATS:
178
+ return False, f"Unsupported video format: {ext}", [], []
179
+
180
+ cap = cv2.VideoCapture(video_path)
181
+ if not cap.isOpened():
182
+ return False, "Failed to open video file", [], []
183
+
184
+ loaded_images = []
185
+ filenames = []
186
+ frame_index = 0
187
+ video_name = os.path.splitext(os.path.basename(video_path))[0]
188
+
189
+ try:
190
+ total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
191
+
192
+ while True:
193
+ ret, frame = cap.read()
194
+ if not ret:
195
+ break
196
+
197
+ if scale_factor != 1.0 and 0 < scale_factor < 1.0:
198
+ width = int(frame.shape[1] * scale_factor)
199
+ height = int(frame.shape[0] * scale_factor)
200
+ frame = cv2.resize(frame, (width, height), interpolation=cv2.INTER_AREA)
201
+
202
+ loaded_images.append(frame)
203
+ filenames.append(f"{video_name}_frame_{frame_index:04d}.png")
204
+ frame_index += 1
205
+ finally:
206
+ cap.release()
207
+
208
+ if not loaded_images:
209
+ return False, "No frames could be extracted from the video", [], []
210
+
211
+ self.images = loaded_images
212
+ self.image_paths = [video_path] * len(loaded_images)
213
+
214
+ message = f"Loaded {len(loaded_images)} frame(s) from video"
215
+ if total_frames > 0 and len(loaded_images) < total_frames:
216
+ message += f" (expected: {total_frames})"
217
+
218
+ return True, message, loaded_images, filenames
219
+
220
+ def is_video_file(self, filepath: str) -> bool:
221
+ ext = os.path.splitext(filepath)[1].lower()
222
+ return ext in self.SUPPORTED_VIDEO_FORMATS
223
+
224
+ def load_from_filepaths(self, filepaths: list[str], scale_factor: float = 1.0) -> Tuple[bool, str, List[np.ndarray], List[str]]:
225
+ if not filepaths:
226
+ return False, "No file paths provided", [], []
227
+
228
+ loaded_images = []
229
+ filenames = []
230
+ failed_count = 0
231
+
232
+ for full_path in filepaths:
233
+ try:
234
+ if not os.path.exists(full_path):
235
+ failed_count += 1
236
+ continue
237
+
238
+ img = cv2.imdecode(np.fromfile(full_path, dtype=np.uint8),
239
+ cv2.IMREAD_ANYDEPTH | cv2.IMREAD_COLOR)
240
+ if img is None:
241
+ # OpenCV ships no HEIC/HEIF decoder: fall back to Pillow
242
+ img = _read_via_pil(full_path)
243
+ if img is not None:
244
+ img = apply_exif_orientation(full_path, img)
245
+ if img is None:
246
+ failed_count += 1
247
+ continue
248
+
249
+ if scale_factor != 1.0 and 0 < scale_factor < 1.0:
250
+ width = int(img.shape[1] * scale_factor)
251
+ height = int(img.shape[0] * scale_factor)
252
+ img = cv2.resize(img, (width, height), interpolation=cv2.INTER_AREA)
253
+
254
+ loaded_images.append(img)
255
+ filenames.append(os.path.basename(full_path))
256
+ except Exception as e:
257
+ failed_count += 1
258
+ print(f"Failed to load image {full_path}: {e}")
259
+
260
+ if not loaded_images:
261
+ return False, "Could not load any image files", [], []
262
+
263
+ self.images = loaded_images
264
+ self.image_paths = list(filepaths[:len(loaded_images)])
265
+
266
+ message = f"Loaded {len(loaded_images)} image(s)"
267
+ if failed_count > 0:
268
+ message += f" (failed: {failed_count})"
269
+
270
+ return True, message, loaded_images, filenames
271
+
272
+ def create_pixmaps(self, images: List[np.ndarray], max_size: Tuple[int, int] = (800, 600)) -> list:
273
+ pixmaps = []
274
+ for img in images:
275
+ pixmap = self._cv_to_pixmap(img, max_size)
276
+ pixmaps.append(pixmap)
277
+ return pixmaps
278
+
279
+ def create_thumbnails(self, images: List[np.ndarray], thumb_size: int = 40) -> list:
280
+ thumbnails = []
281
+ for img in images:
282
+ h, w = img.shape[:2]
283
+ if h <= 0 or w <= 0:
284
+ raise ValueError(f"Invalid image dimensions: {w}x{h}")
285
+ scale = thumb_size / max(h, w)
286
+ new_w = int(w * scale)
287
+ new_h = int(h * scale)
288
+
289
+ resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
290
+ pixmap = self._cv_to_pixmap(to_display_uint8(resized), (thumb_size, thumb_size))
291
+ thumbnails.append(pixmap)
292
+ return thumbnails
293
+
294
+ def _cv_to_pixmap(self, cv_img: np.ndarray, max_size: Optional[Tuple[int, int]] = None):
295
+ # Imported lazily: the headless PyPI package uses this module for
296
+ # decoding without PyQt6 installed.
297
+ from PyQt6.QtGui import QPixmap, QImage
298
+ rgb_img = cv2.cvtColor(to_display_uint8(cv_img), cv2.COLOR_BGR2RGB)
299
+
300
+ if max_size is not None:
301
+ h, w = rgb_img.shape[:2]
302
+ max_w, max_h = max_size
303
+
304
+ scale = min(max_w / w, max_h / h)
305
+ if scale < 1.0:
306
+ new_w = int(w * scale)
307
+ new_h = int(h * scale)
308
+ rgb_img = cv2.resize(rgb_img, (new_w, new_h), interpolation=cv2.INTER_AREA)
309
+
310
+ h, w, ch = rgb_img.shape
311
+ bytes_per_line = ch * w
312
+
313
+ q_img = QImage(rgb_img.data, w, h, bytes_per_line, QImage.Format.Format_RGB888)
314
+
315
+ return QPixmap.fromImage(q_img)
316
+
317
+ def get_image_info(self, index: int) -> dict:
318
+ if not self.images or index < 0 or index >= len(self.images):
319
+ return {}
320
+
321
+ img = self.images[index]
322
+ path = self.image_paths[index] if index < len(self.image_paths) else ""
323
+
324
+ return {
325
+ 'index': index,
326
+ 'path': path,
327
+ 'filename': os.path.basename(path) if path else "",
328
+ 'shape': img.shape,
329
+ 'size': f"{img.shape[1]}x{img.shape[0]}",
330
+ 'channels': img.shape[2] if len(img.shape) > 2 else 1,
331
+ 'dtype': img.dtype
332
+ }
333
+
334
+ def get_image_timestamp(self, filepath: str) -> Optional[float]:
335
+ if not PIL_AVAILABLE:
336
+ return None
337
+
338
+ try:
339
+ with PILImage.open(filepath) as img:
340
+ exif_data = img._getexif()
341
+ if exif_data is None:
342
+ return None
343
+
344
+ date_time_original = None
345
+ for tag_id, value in exif_data.items():
346
+ tag_name = PILImage.ExifTags.TAGS.get(tag_id, str(tag_id))
347
+ if tag_name == 'DateTimeOriginal':
348
+ date_time_original = value
349
+ break
350
+
351
+ if date_time_original:
352
+ dt = datetime.strptime(date_time_original, '%Y:%m:%d %H:%M:%S')
353
+ return dt.timestamp()
354
+ except Exception as e:
355
+ pass
356
+
357
+ return None
358
+
359
+ def load_images_with_timestamps(
360
+ self,
361
+ folder_path: str,
362
+ sort_by: str = 'timestamp'
363
+ ) -> Tuple[bool, str, List[Tuple[str, np.ndarray, Optional[float]]], List[str]]:
364
+ if not os.path.isdir(folder_path):
365
+ return False, "Selected path is not a valid directory", [], []
366
+
367
+ image_files = []
368
+ for filename in os.listdir(folder_path):
369
+ ext = os.path.splitext(filename)[1].lower()
370
+ if ext in self.SUPPORTED_FORMATS:
371
+ full_path = os.path.join(folder_path, filename)
372
+ image_files.append((filename, full_path))
373
+
374
+ if not image_files:
375
+ return False, "No supported image files found in the folder", [], []
376
+
377
+ loaded_data = []
378
+ failed_count = 0
379
+
380
+ for filename, full_path in image_files:
381
+ try:
382
+ img = cv2.imdecode(np.fromfile(full_path, dtype=np.uint8),
383
+ cv2.IMREAD_ANYDEPTH | cv2.IMREAD_COLOR)
384
+ if img is None:
385
+ # OpenCV ships no HEIC/HEIF decoder: fall back to Pillow
386
+ img = _read_via_pil(full_path)
387
+ if img is not None:
388
+ img = apply_exif_orientation(full_path, img)
389
+ timestamp = self.get_image_timestamp(full_path)
390
+ loaded_data.append((filename, full_path, img, timestamp))
391
+ else:
392
+ failed_count += 1
393
+ except Exception as e:
394
+ failed_count += 1
395
+ print(f"Failed to load image {filename}: {e}")
396
+
397
+ if not loaded_data:
398
+ return False, "Could not load any image files", [], []
399
+
400
+ def get_sort_key(item):
401
+ if sort_by == 'timestamp':
402
+ ts = item[3]
403
+ if ts is not None:
404
+ return ts
405
+ return float('inf')
406
+ return item[0]
407
+
408
+ loaded_data.sort(key=get_sort_key)
409
+
410
+ successful_count = len(loaded_data)
411
+ message = f"Loaded {successful_count} image(s)"
412
+ if failed_count > 0:
413
+ message += f" (failed: {failed_count})"
414
+
415
+ result = [(item[1], item[2], item[3]) for item in loaded_data]
416
+ filenames = [item[0] for item in loaded_data]
417
+
418
+ return True, message, result, filenames
419
+
420
+ def split_by_count(
421
+ self,
422
+ images_with_times: List[Tuple[str, np.ndarray, Optional[float]]],
423
+ count_per_stack: int
424
+ ) -> List[List[Tuple[str, np.ndarray, Optional[float]]]]:
425
+ if count_per_stack <= 0:
426
+ count_per_stack = 1
427
+
428
+ stacks = []
429
+ for i in range(0, len(images_with_times), count_per_stack):
430
+ stack = images_with_times[i:i + count_per_stack]
431
+ if stack:
432
+ stacks.append(stack)
433
+
434
+ return stacks
435
+
436
+ def split_by_time_threshold(
437
+ self,
438
+ images_with_times: list,
439
+ threshold_seconds: float
440
+ ) -> list:
441
+ if threshold_seconds <= 0:
442
+ threshold_seconds = 1.0
443
+
444
+ stacks = []
445
+ current_stack = []
446
+
447
+ for i, (path, img, timestamp) in enumerate(images_with_times):
448
+ if i == 0:
449
+ current_stack.append((path, img, timestamp))
450
+ continue
451
+
452
+ if timestamp is None:
453
+ current_stack.append((path, img, timestamp))
454
+ continue
455
+
456
+ prev_timestamp = images_with_times[i - 1][2]
457
+ if prev_timestamp is None:
458
+ current_stack.append((path, img, timestamp))
459
+ continue
460
+
461
+ time_diff = timestamp - prev_timestamp
462
+ if time_diff > threshold_seconds:
463
+ if current_stack:
464
+ stacks.append(current_stack)
465
+ current_stack = [(path, img, timestamp)]
466
+ else:
467
+ current_stack.append((path, img, timestamp))
468
+
469
+ if current_stack:
470
+ stacks.append(current_stack)
471
+
472
+ return stacks
473
+
474
+ def get_stack_info(
475
+ self,
476
+ stacks: List[List[Tuple[str, np.ndarray, Optional[float]]]]
477
+ ) -> List[Dict[str, Any]]:
478
+ info_list = []
479
+ for i, stack in enumerate(stacks):
480
+ count = len(stack)
481
+ first_img = stack[0][1]
482
+ height, width = first_img.shape[:2]
483
+
484
+ timestamps = [item[2] for item in stack if item[2] is not None]
485
+ if timestamps:
486
+ first_time = datetime.fromtimestamp(min(timestamps)).strftime('%H:%M:%S')
487
+ last_time = datetime.fromtimestamp(max(timestamps)).strftime('%H:%M:%S')
488
+ time_range = f"{first_time}-{last_time}"
489
+ else:
490
+ time_range = "Unknown"
491
+
492
+ info_list.append({
493
+ 'stack_index': i,
494
+ 'name': f"Stack {i + 1}",
495
+ 'count': count,
496
+ 'resolution': f"{width}x{height}",
497
+ 'time_range': time_range
498
+ })
499
+
500
+ return info_list
@@ -0,0 +1,18 @@
1
+ """
2
+ Neural network models for OpenFocus.
3
+
4
+ Modules:
5
+ - stackmffv4_network: StackMFF-V4 multi-focus fusion neural network
6
+ """
7
+
8
+ from core.models.stackmffv4_network import (
9
+ StackMFF_V4,
10
+ LV_UNet,
11
+ lv_unet,
12
+ )
13
+
14
+ __all__ = [
15
+ 'StackMFF_V4',
16
+ 'LV_UNet',
17
+ 'lv_unet',
18
+ ]