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.
constants.py ADDED
@@ -0,0 +1,43 @@
1
+ APP_VERSION = "1.28"
2
+
3
+ WINDOW_WIDTH = 1600
4
+ WINDOW_HEIGHT = 950
5
+
6
+ TILE_BLOCK_SIZE = 1024
7
+ TILE_OVERLAP = 256
8
+ TILE_THRESHOLD = 2048
9
+ TILE_BLOCK_MIN = 64
10
+ TILE_BLOCK_MAX = 16384
11
+ TILE_OVERLAP_MAX = 4096
12
+ TILE_THRESHOLD_MIN = 256
13
+ TILE_THRESHOLD_MAX = 131072
14
+
15
+ REG_DOWNSCALE_WIDTH = 1024
16
+ HOMOGRAPHY_DOWNSCALE_WIDTH = 1600
17
+ ECC_DOWNSCALE_WIDTH = 1000
18
+
19
+ STATUS_UPDATE_INTERVAL_MS = 2000
20
+
21
+ BYTES_TO_GB = 1024 ** 3
22
+
23
+ FEATURE_MAX_KEYPOINTS = 200
24
+ FEATURE_MATCH_THRESHOLD = 0.01
25
+ FEATURE_RANSAC_REJECTS = 30
26
+ FEATURE_RANSAC_MAX_ITER = 3
27
+
28
+ MAGNIFIER_SIZE = 320
29
+ ANIMATION_DURATION_MS = 400
30
+
31
+ DEFAULT_THREAD_COUNT = 4
32
+ MIN_THREAD_COUNT = 1
33
+ MAX_THREAD_COUNT = 16
34
+
35
+ STACKMFFV4_BATCH_SIZE = 2
36
+ STACKMFFV4_BATCH_SIZE_MIN = 1
37
+ STACKMFFV4_BATCH_SIZE_MAX = 16
38
+
39
+ KERNEL_SIZE_MIN = 3
40
+ KERNEL_SIZE_MAX = 51
41
+ KERNEL_SIZE_DEFAULT_DCT = 7
42
+ KERNEL_SIZE_DEFAULT_GFF = 31
43
+ KERNEL_SIZE_DEFAULT_GFG = 7
core/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ Core modules for OpenFocus.
3
+
4
+ Modules:
5
+ - image_loader: Image stack loading and processing
6
+ - registration: Image registration algorithms
7
+ - multi_focus_fusion: Multi-focus image fusion orchestrator
8
+ - workers: Background thread workers for rendering
9
+ - models: Neural network models (StackMFF-V4)
10
+ """
11
+
12
+ from core.image_loader import ImageStackLoader
13
+ from core.multi_focus_fusion import MultiFocusFusion, is_stackmffv4_available
14
+
15
+ try: # GUI stack; workers (and app below) need PyQt6, the headless CLI
16
+ # package installs without it.
17
+ from core.workers import RenderWorker, ROIAlignmentWorker
18
+ from core.app import OpenFocusApplication, process_command_line_args
19
+ _HAS_QT = True
20
+ except ImportError: # pragma: no cover - headless openfocus package
21
+ RenderWorker = ROIAlignmentWorker = None
22
+ OpenFocusApplication = process_command_line_args = None
23
+ _HAS_QT = False
24
+
25
+ __all__ = [
26
+ 'ImageStackLoader',
27
+ 'MultiFocusFusion',
28
+ 'is_stackmffv4_available',
29
+ 'RenderWorker',
30
+ ]
core/app.py ADDED
@@ -0,0 +1,162 @@
1
+ """
2
+ Cross-platform file drag-and-drop support for OpenFocus.
3
+
4
+ Supports:
5
+ - macOS: Dock drag-and-drop via QFileOpenEvent
6
+ - Linux: Desktop file drag-and-drop via command-line arguments
7
+ - Windows: EXE/shortcut drag-and-drop via command-line arguments
8
+ """
9
+
10
+ import sys
11
+ import os
12
+ from typing import List, Optional
13
+ from urllib.parse import unquote
14
+
15
+ from PyQt6.QtWidgets import QApplication
16
+ from PyQt6.QtGui import QFileOpenEvent
17
+ from PyQt6.QtCore import QTimer, QUrl
18
+
19
+
20
+ class PlatformFileHandler:
21
+ """Normalizes and classifies file paths from different platform sources."""
22
+
23
+ @staticmethod
24
+ def normalize(path: str) -> str:
25
+ """Turn a file:// URI (or a mac-style /// path) into a local path.
26
+
27
+ QUrl.toLocalFile() does the platform-correct job: hand-stripping the
28
+ scheme leaves the URI's leading slash on Windows, so 'file:///C:/stack'
29
+ became '/C:/stack' and the file was never found. Percent-decoding also
30
+ belongs to URI syntax only — a plain command-line path may contain a
31
+ literal '%', so it must not be unquoted here.
32
+ """
33
+ if not path:
34
+ return ""
35
+
36
+ if path.startswith('file://'):
37
+ local = QUrl(path).toLocalFile()
38
+ if local:
39
+ return local
40
+ return '/' + unquote(path[7:]).lstrip('/') # malformed URI fallback
41
+
42
+ if path.startswith('///'):
43
+ # Extra leading slashes in front of an absolute POSIX path
44
+ return '/' + unquote(path).lstrip('/')
45
+
46
+ return path
47
+
48
+ @staticmethod
49
+ def is_video(path: str) -> bool:
50
+ from core.image_loader import ImageStackLoader
51
+ return os.path.splitext(path)[1].lower() in ImageStackLoader.SUPPORTED_VIDEO_FORMATS
52
+
53
+ @staticmethod
54
+ def is_image(path: str) -> bool:
55
+ from core.image_loader import ImageStackLoader
56
+ return os.path.splitext(path)[1].lower() in ImageStackLoader.SUPPORTED_FORMATS
57
+
58
+ @staticmethod
59
+ def classify(paths: List[str]) -> dict:
60
+ result = {'dirs': [], 'videos': [], 'images': [], 'other': []}
61
+ for p in paths:
62
+ normalized = PlatformFileHandler.normalize(p)
63
+ if not os.path.exists(normalized):
64
+ continue
65
+ if os.path.isdir(normalized):
66
+ result['dirs'].append(normalized)
67
+ elif PlatformFileHandler.is_video(normalized):
68
+ result['videos'].append(normalized)
69
+ elif PlatformFileHandler.is_image(normalized):
70
+ result['images'].append(normalized)
71
+ else:
72
+ result['other'].append(normalized)
73
+ return result
74
+
75
+
76
+ class OpenFocusApplication(QApplication):
77
+ """QApplication subclass handling file open events from platform sources."""
78
+
79
+ def __init__(self, *args, **kwargs):
80
+ super().__init__(*args, **kwargs)
81
+ self.main_window = None
82
+ self._pending: List[str] = []
83
+ self._ready = False
84
+
85
+ def set_main_window(self, window):
86
+ self.main_window = window
87
+ self._ready = True
88
+ if self._pending:
89
+ pending = self._pending.copy()
90
+ self._pending.clear()
91
+ QTimer.singleShot(100, lambda: self._process(pending))
92
+
93
+ def event(self, event) -> bool:
94
+ if event.type() == QFileOpenEvent.Type:
95
+ path = QFileOpenEvent(event).file()
96
+ if path:
97
+ if self._ready and self.main_window:
98
+ self._handle(path)
99
+ else:
100
+ self._pending.append(path)
101
+ return True
102
+ return super().event(event)
103
+
104
+ def _handle(self, path: str):
105
+ normalized = PlatformFileHandler.normalize(path)
106
+ if normalized:
107
+ self._process([normalized])
108
+
109
+ def _process(self, paths: List[str]):
110
+ if not self._ready or not self.main_window:
111
+ return
112
+ if not hasattr(self.main_window, 'source_manager'):
113
+ return
114
+
115
+ src = self.main_window.source_manager
116
+ c = PlatformFileHandler.classify(paths)
117
+ files = c['videos'] + c['images'] + c['other']
118
+
119
+ if len(c['dirs']) == 1 and not files:
120
+ src.load_image_stack_from_icon(c['dirs'][0])
121
+ elif c['dirs']:
122
+ src.load_multiple_folders_from_icon(c['dirs'])
123
+ elif len(c['videos']) == 1 and not c['images']:
124
+ src.load_video_stack_from_icon(c['videos'][0])
125
+ elif c['images']:
126
+ src.load_image_files_from_icon(c['images'])
127
+ else:
128
+ from utils import show_warning_box
129
+ from locales import trans
130
+ show_warning_box(
131
+ self.main_window,
132
+ trans.t("msg_no_valid_files_title"),
133
+ trans.t("msg_no_valid_files_text"),
134
+ )
135
+
136
+
137
+ def process_command_line_args(main_window, args: List[str]):
138
+ """Handle files passed via command-line (drag-to-EXE, desktop file %U, etc.)."""
139
+ if not args:
140
+ return
141
+
142
+ file_paths = [a for a in args if not a.startswith('-')]
143
+ if not file_paths:
144
+ return
145
+
146
+ def _delayed():
147
+ if not hasattr(main_window, 'source_manager'):
148
+ return
149
+ c = PlatformFileHandler.classify(file_paths)
150
+ src = main_window.source_manager
151
+ files = c['videos'] + c['images'] + c['other']
152
+
153
+ if len(c['dirs']) == 1 and not files:
154
+ src.load_image_stack_from_icon(c['dirs'][0])
155
+ elif c['dirs']:
156
+ src.load_multiple_folders_from_icon(c['dirs'])
157
+ elif len(c['videos']) == 1 and not c['images']:
158
+ src.load_video_stack_from_icon(c['videos'][0])
159
+ elif c['images']:
160
+ src.load_image_files_from_icon(c['images'])
161
+
162
+ QTimer.singleShot(100, _delayed)
core/cli.py ADDED
@@ -0,0 +1,306 @@
1
+ """Headless command-line interface for OpenFocus fusion.
2
+
3
+ Lets batch users and CI pipelines run registration + fusion without the GUI:
4
+
5
+ python main.py --input ./stack_folder --output ./out --method guided_filter
6
+ python main.py --input ./stackA ./stackB --output-dir ./results (batch)
7
+
8
+ Exit codes: 0 success, 1 processing error, 2 usage error.
9
+ """
10
+ import argparse
11
+ import os
12
+ import sys
13
+ import time
14
+ from typing import List, Optional, Tuple
15
+
16
+ VALID_METHODS = ["guided_filter", "dct", "dtcwt", "gfgfgf", "stackmffv4"]
17
+ VALID_ALIGN = ["none", "homography", "ecc", "both"]
18
+ VALID_FORMATS = [".png", ".jpg", ".webp", ".bmp", ".tif", ".tiff"]
19
+
20
+ EXIT_OK = 0
21
+ EXIT_ERROR = 1
22
+ EXIT_USAGE = 2
23
+
24
+
25
+ def build_parser() -> argparse.ArgumentParser:
26
+ parser = argparse.ArgumentParser(
27
+ prog="OpenFocus",
28
+ description="Multi-focus image fusion (headless mode).",
29
+ formatter_class=argparse.RawDescriptionHelpFormatter,
30
+ epilog=(
31
+ "examples:\n"
32
+ " python main.py --input ./stack --output ./result/fused.png\n"
33
+ " python main.py --input ./stack --output ./result/fused.png --method dtcwt --align ecc --threads 8\n"
34
+ " python main.py --input ./stackA ./stackB --output-dir ./results -m guided_filter\n"
35
+ " python main.py --input img1.jpg img2.jpg img3.jpg --output fused.png --method gfgfgf\n"
36
+ ),
37
+ )
38
+ parser.add_argument("--input", "-i", nargs="+", required=True,
39
+ help="Image folder(s), video file(s), or a list of image files")
40
+ parser.add_argument("--output", "-o", default=None,
41
+ help="Output file for a single stack (default: fused.png)")
42
+ parser.add_argument("--output-dir", "-d", default=None,
43
+ help="Batch mode: fuse every input folder, writing <folder>.<ext> here")
44
+ parser.add_argument("--method", "-m", default="guided_filter", choices=VALID_METHODS,
45
+ help="Fusion method (default: guided_filter)")
46
+ parser.add_argument("--align", "-a", default="none", choices=VALID_ALIGN,
47
+ help="Registration applied before fusion (default: none)")
48
+ parser.add_argument("--kernel", "-k", type=int, default=None,
49
+ help="Kernel size for guided_filter/dct/gfgfgf (forced odd)")
50
+ parser.add_argument("--threads", "-t", type=int, default=4,
51
+ help="Worker thread count (default: 4)")
52
+ parser.add_argument("--batch-size", type=int, default=2,
53
+ help="StackMFF-V4 tile batch size (default: 2)")
54
+ parser.add_argument("--cpu", action="store_true",
55
+ help="Force CPU even if CUDA/MPS is available")
56
+ parser.add_argument("--downscale", type=int, default=None,
57
+ help="Registration feature-detection downscale width")
58
+ parser.add_argument("--tile-size", type=int, default=None,
59
+ help="Tile block size for large images (default: auto)")
60
+ return parser
61
+
62
+
63
+ def _list_folder_images(folder: str, loader) -> List[str]:
64
+ return sorted(
65
+ os.path.join(folder, f) for f in os.listdir(folder)
66
+ if f.lower().endswith(tuple(loader.SUPPORTED_FORMATS))
67
+ )
68
+
69
+
70
+ def _resolve_stack(args, video_temp_root: str) -> Tuple[str, List[str]]:
71
+ """Return (source_label, image file paths) from folders, videos, or files.
72
+
73
+ Video frames are materialized as PNGs under video_temp_root (a
74
+ TemporaryDirectory managed by the caller) so downstream code can treat
75
+ every input as a plain file path.
76
+ """
77
+ from core.image_loader import ImageStackLoader
78
+
79
+ loader = ImageStackLoader()
80
+ inputs = [os.path.abspath(p) for p in args.input]
81
+ paths: List[str] = []
82
+
83
+ for entry in inputs:
84
+ if os.path.isdir(entry):
85
+ paths.extend(_list_folder_images(entry, loader))
86
+ elif loader.is_video_file(entry):
87
+ ok, message, frames, _names = loader.load_from_video(entry)
88
+ if not ok:
89
+ raise RuntimeError(f"Failed to read video {entry}: {message}")
90
+ import cv2
91
+ for i, frame in enumerate(frames):
92
+ frame_path = os.path.join(video_temp_root, f"video_{len(paths) + i:05d}.png")
93
+ cv2.imwrite(frame_path, frame)
94
+ paths.append(frame_path)
95
+ elif os.path.isfile(entry):
96
+ paths.append(entry)
97
+ else:
98
+ raise FileNotFoundError(f"Input not found: {entry}")
99
+
100
+ if len(paths) < 2:
101
+ raise RuntimeError("Need at least 2 images in the stack for fusion")
102
+ return ", ".join(args.input), paths
103
+
104
+
105
+ def _load_images(paths: List[str]):
106
+ import cv2
107
+ images = []
108
+ for p in paths:
109
+ img = cv2.imread(p, cv2.IMREAD_ANYDEPTH | cv2.IMREAD_COLOR)
110
+ if img is None:
111
+ raise RuntimeError(f"Failed to decode image: {p}")
112
+ images.append(img)
113
+ return images
114
+
115
+
116
+ def _normalize_kernel(kernel: Optional[int], default: int) -> int:
117
+ size = int(kernel) if kernel else default
118
+ if size % 2 == 0:
119
+ size = max(1, size - 1)
120
+ return max(1, size)
121
+
122
+
123
+ def _fuse_stack(images, args):
124
+ """Register and fuse one stack; returns the fused image."""
125
+ import cv2
126
+ from core.registration import ImageRegistration
127
+ from core.multi_focus_fusion import MultiFocusFusion
128
+ from utils.image_utils import normalize_fuse_input, quantize_fuse_output
129
+
130
+ # 16-bit sources are normalized to 0-1 float for the back-ends and
131
+ # quantized back to uint16 afterwards, preserving the extra depth.
132
+ images, is_16bit = normalize_fuse_input(images)
133
+
134
+ if args.align != "none":
135
+ t0 = time.time()
136
+ reg = ImageRegistration(method=args.align, downscale_width=args.downscale)
137
+ images = reg.process(images, output_path=None, thread_count=args.threads)
138
+ print(f"Registration ({args.align}) done in {time.time() - t0:.1f}s")
139
+
140
+ fusion_kwargs = dict(algorithm=args.method, use_gpu=not args.cpu)
141
+ if args.tile_size:
142
+ fusion_kwargs["tile_block_size"] = args.tile_size
143
+ if args.method == "stackmffv4":
144
+ fusion_kwargs["stackmffv4_batch_size"] = args.batch_size
145
+ fusion = MultiFocusFusion(**fusion_kwargs)
146
+ info = fusion.get_info()
147
+
148
+ t0 = time.time()
149
+ fuse_kwargs = dict(input_source=images, img_resize=None, thread_count=args.threads)
150
+ if args.method == "guided_filter":
151
+ fuse_kwargs["kernel_size"] = _normalize_kernel(args.kernel, 31)
152
+ elif args.method in ("dct", "gfgfgf"):
153
+ fuse_kwargs["kernel_size"] = _normalize_kernel(args.kernel, 7)
154
+ elif args.method == "stackmffv4":
155
+ fuse_kwargs["model_path"] = _find_model_path()
156
+
157
+ fused = fusion.fuse(**fuse_kwargs)
158
+ if fused is None:
159
+ raise RuntimeError("Fusion returned no result")
160
+ fused = quantize_fuse_output(fused, is_16bit)
161
+ print(f"Fusion ({args.method}, device={info['device']}) done in {time.time() - t0:.1f}s")
162
+ return fused
163
+
164
+
165
+ def _save(path: str, fused) -> None:
166
+ from utils.image_utils import imwrite_auto
167
+ out_dir = os.path.dirname(os.path.abspath(path))
168
+ os.makedirs(out_dir, exist_ok=True)
169
+ if not imwrite_auto(path, fused):
170
+ raise RuntimeError(f"Failed to write output: {path}")
171
+
172
+
173
+ def _run_batch(args) -> int:
174
+ """Batch mode: fuse every input folder into --output-dir."""
175
+ from core.image_loader import ImageStackLoader
176
+
177
+ loader = ImageStackLoader()
178
+ folders = [os.path.abspath(p) for p in args.input if os.path.isdir(p)]
179
+ skipped = [p for p in args.input if not os.path.isdir(os.path.abspath(p))]
180
+ for p in skipped:
181
+ print(f"warning: skipping non-folder input in batch mode: {p}", file=sys.stderr)
182
+ if not folders:
183
+ print("error: --output-dir requires image folder(s) as --input", file=sys.stderr)
184
+ return EXIT_USAGE
185
+ if args.output:
186
+ print(f"warning: --output is ignored in batch mode (using --output-dir)", file=sys.stderr)
187
+
188
+ # Duplicate basenames would silently overwrite each other
189
+ seen, duplicates = set(), False
190
+ for folder in folders:
191
+ name = os.path.basename(folder.rstrip("/\\")) or "stack"
192
+ if name in seen:
193
+ duplicates = True
194
+ print(f"error: duplicate folder name '{name}' would overwrite results; "
195
+ f"rename the folders or fuse them individually", file=sys.stderr)
196
+ seen.add(name)
197
+ if duplicates:
198
+ return EXIT_USAGE
199
+ os.makedirs(args.output_dir, exist_ok=True)
200
+
201
+ ok_count, failures = 0, []
202
+ for folder in folders:
203
+ name = os.path.basename(folder.rstrip("/\\")) or "stack"
204
+ out_path = os.path.join(args.output_dir, f"{name}{args._batch_ext}")
205
+ print(f"\n=== {name} ===")
206
+ try:
207
+ paths = _list_folder_images(folder, loader)
208
+ if len(paths) < 2:
209
+ raise RuntimeError(f"need at least 2 images, found {len(paths)}")
210
+ images = _load_images(paths)
211
+ fused = _fuse_stack(images, args)
212
+ _save(out_path, fused)
213
+ print(f"Saved: {out_path}")
214
+ ok_count += 1
215
+ except Exception as exc:
216
+ print(f"error: {name}: {exc}", file=sys.stderr)
217
+ failures.append(name)
218
+
219
+ print(f"\nBatch finished: {ok_count}/{len(folders)} stacks succeeded")
220
+ if failures:
221
+ print("failed: " + ", ".join(failures), file=sys.stderr)
222
+ return EXIT_ERROR
223
+ return EXIT_OK
224
+
225
+
226
+ def run_cli(argv: List[str]) -> int:
227
+ args = build_parser().parse_args(argv)
228
+
229
+ if args.threads < 1:
230
+ print("error: --threads must be >= 1", file=sys.stderr)
231
+ return EXIT_USAGE
232
+ if args.downscale is not None and args.downscale < 1:
233
+ print("error: --downscale must be >= 1", file=sys.stderr)
234
+ return EXIT_USAGE
235
+ if args.batch_size < 1:
236
+ print("error: --batch-size must be >= 1", file=sys.stderr)
237
+ return EXIT_USAGE
238
+
239
+ batch_mode = args.output_dir is not None
240
+ if batch_mode:
241
+ args._batch_ext = ".png"
242
+ else:
243
+ if not args.output:
244
+ print("error: one of --output / --output-dir is required", file=sys.stderr)
245
+ return EXIT_USAGE
246
+ ext = os.path.splitext(args.output)[1].lower()
247
+ if not ext or ext not in VALID_FORMATS:
248
+ print(f"error: unsupported output extension '{ext or '(none)'}'; "
249
+ f"expected one of {', '.join(VALID_FORMATS)}", file=sys.stderr)
250
+ return EXIT_USAGE
251
+
252
+ try:
253
+ import tempfile
254
+ import cv2
255
+
256
+ if batch_mode:
257
+ return _run_batch(args)
258
+
259
+ from core.multi_focus_fusion import MultiFocusFusion
260
+
261
+ # Video inputs are unpacked to a temp dir that is always cleaned up
262
+ with tempfile.TemporaryDirectory(prefix="openfocus_video_") as video_temp:
263
+ _src, paths = _resolve_stack(args, video_temp)
264
+ print(f"Loaded {len(paths)} images from {args.input[0]}")
265
+ images = _load_images(paths)
266
+ if paths:
267
+ from utils.image_utils import set_source_exif
268
+ from core.image_loader import _read_exif_bytes
269
+ set_source_exif(_read_exif_bytes(paths[0]))
270
+
271
+ fused = _fuse_stack(images, args)
272
+ if fused is None:
273
+ raise RuntimeError("Fusion returned no result")
274
+
275
+ _save(args.output, fused)
276
+ print(f"Saved: {args.output}")
277
+ return EXIT_OK
278
+ except KeyboardInterrupt:
279
+ print("interrupted", file=sys.stderr)
280
+ return EXIT_ERROR
281
+ except Exception as exc:
282
+ print(f"error: {exc}", file=sys.stderr)
283
+ return EXIT_ERROR
284
+
285
+
286
+ def _find_model_path() -> str:
287
+ """Locate stackmffv4.pth: repo checkout first, then the installed
288
+ weights package (pip-installed openfocus ships it as package data)."""
289
+ repo = os.path.join(
290
+ os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
291
+ "weights", "stackmffv4.pth")
292
+ if os.path.isfile(repo):
293
+ return repo
294
+ try:
295
+ import weights
296
+ shipped = os.path.join(os.path.dirname(weights.__file__), "stackmffv4.pth")
297
+ if os.path.isfile(shipped):
298
+ return shipped
299
+ except ImportError:
300
+ pass
301
+ return repo
302
+
303
+
304
+ def main() -> None:
305
+ """Console-script entry point (pip-installed ``openfocus`` command)."""
306
+ sys.exit(run_cli(sys.argv[1:]))