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 +43 -0
- core/__init__.py +30 -0
- core/app.py +162 -0
- core/cli.py +306 -0
- core/image_loader.py +500 -0
- core/models/__init__.py +18 -0
- core/models/stackmffv4_network.py +630 -0
- core/multi_focus_fusion.py +955 -0
- core/registration.py +1152 -0
- core/workers.py +894 -0
- fusion_methods/__init__.py +14 -0
- fusion_methods/dct.py +220 -0
- fusion_methods/dtcwt.py +211 -0
- fusion_methods/gff.py +264 -0
- fusion_methods/gfg_fgf.py +362 -0
- fusion_methods/stackmffv4.py +288 -0
- openfocus-1.28.dist-info/METADATA +210 -0
- openfocus-1.28.dist-info/RECORD +35 -0
- openfocus-1.28.dist-info/WHEEL +5 -0
- openfocus-1.28.dist-info/entry_points.txt +2 -0
- openfocus-1.28.dist-info/licenses/LICENSE +21 -0
- openfocus-1.28.dist-info/top_level.txt +5 -0
- utils/__init__.py +81 -0
- utils/align_cache.py +102 -0
- utils/image_utils.py +176 -0
- utils/logging_setup.py +63 -0
- utils/platform_utils.py +108 -0
- utils/project_file.py +220 -0
- utils/self_update.py +290 -0
- utils/settings_store.py +246 -0
- utils/ui_utils.py +151 -0
- utils/updater.py +177 -0
- utils/validators.py +224 -0
- weights/__init__.py +1 -0
- weights/stackmffv4.pth +0 -0
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:]))
|