pyPetrograph 0.0.5__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.
- pyPetrograph/__init__.py +280 -0
- pyPetrograph/align/__init__.py +38 -0
- pyPetrograph/align/embed.py +281 -0
- pyPetrograph/align/grains.py +22 -0
- pyPetrograph/align/io.py +77 -0
- pyPetrograph/align/register.py +210 -0
- pyPetrograph/align/scene.py +201 -0
- pyPetrograph/align/seg_qc_worker.py +137 -0
- pyPetrograph/align/seg_worker.py +198 -0
- pyPetrograph/align/structure.py +47 -0
- pyPetrograph/align/ui.py +470 -0
- pyPetrograph/common/__init__.py +1 -0
- pyPetrograph/common/constants.py +219 -0
- pyPetrograph/common/image_io.py +215 -0
- pyPetrograph/common/notebook_runners.py +609 -0
- pyPetrograph/common/paths.py +406 -0
- pyPetrograph/common/session.py +1075 -0
- pyPetrograph/common/ui.py +1127 -0
- pyPetrograph/fuse/__init__.py +39 -0
- pyPetrograph/fuse/extinction.py +130 -0
- pyPetrograph/fuse/preview.py +192 -0
- pyPetrograph/fuse/stack.py +311 -0
- pyPetrograph/fuse/tiles.py +174 -0
- pyPetrograph/image_processing/__init__.py +1 -0
- pyPetrograph/image_processing/brightness.py +56 -0
- pyPetrograph/image_processing/features.py +429 -0
- pyPetrograph/image_processing/fractions.py +109 -0
- pyPetrograph/image_processing/viz.py +167 -0
- pyPetrograph/labeling_ml/__init__.py +1 -0
- pyPetrograph/labeling_ml/model_io.py +20 -0
- pyPetrograph/labeling_ml/polygons.py +192 -0
- pyPetrograph/labeling_ml/predict.py +161 -0
- pyPetrograph/labeling_ml/train.py +362 -0
- pyPetrograph/mineral_map/__init__.py +63 -0
- pyPetrograph/mineral_map/counts.py +153 -0
- pyPetrograph/mineral_map/grains.py +580 -0
- pyPetrograph/mineral_map/legend.py +242 -0
- pyPetrograph/mineral_map/run.py +185 -0
- pyPetrograph/objects/__init__.py +91 -0
- pyPetrograph/objects/classify.py +205 -0
- pyPetrograph/objects/cluster.py +200 -0
- pyPetrograph/objects/montage_worker.py +117 -0
- pyPetrograph/objects/outlines.py +322 -0
- pyPetrograph/objects/qc.py +288 -0
- pyPetrograph/objects/qc_worker.py +192 -0
- pyPetrograph/objects/stats.py +303 -0
- pyPetrograph/objects/table.py +214 -0
- pyPetrograph/objects/tf_env.py +98 -0
- pyPetrograph/objects/unet_worker.py +416 -0
- pyPetrograph/objects/watershed.py +138 -0
- pypetrograph-0.0.5.dist-info/METADATA +98 -0
- pypetrograph-0.0.5.dist-info/RECORD +55 -0
- pypetrograph-0.0.5.dist-info/WHEEL +5 -0
- pypetrograph-0.0.5.dist-info/licenses/LICENSE +661 -0
- pypetrograph-0.0.5.dist-info/top_level.txt +1 -0
pyPetrograph/__init__.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
"""
|
|
2
|
+
pyPetrograph: RGB pixel labeling plus a multimodal object pipeline.
|
|
3
|
+
|
|
4
|
+
RGB notebook: wand/polygon labels → LightGBM class maps → area %.
|
|
5
|
+
Multimodal notebook: align layers → physics channels → grain/pore outlines →
|
|
6
|
+
object table → group/name → LightGBM names the rest → QFL / IGV / Folk & Ward.
|
|
7
|
+
Mineral-map notebook: flat-color mineral maps → legend snap → grains → per-class counts with uncertainty.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
__version__ = "0.0.5"
|
|
13
|
+
|
|
14
|
+
from pyPetrograph.common.constants import (
|
|
15
|
+
CACHE_SUBDIR,
|
|
16
|
+
DEFAULT_CLASSES,
|
|
17
|
+
DEFAULT_EMBED_FEATURE_TOGGLES,
|
|
18
|
+
DEFAULT_FEATURE_TOGGLES,
|
|
19
|
+
DEFAULT_FIGURE_DPI,
|
|
20
|
+
DEFAULT_METHOD,
|
|
21
|
+
DEFAULT_N_JOBS,
|
|
22
|
+
DEFAULT_PREVIEW_DPI,
|
|
23
|
+
DEFAULT_PREVIEW_TARGET_MP,
|
|
24
|
+
DEFAULT_SURE_PROBA,
|
|
25
|
+
DEFAULT_Y_TARGET,
|
|
26
|
+
LABELS_SUBDIR,
|
|
27
|
+
METHODS,
|
|
28
|
+
MODELS_SUBDIR,
|
|
29
|
+
PREDICTIONS_SUBDIR,
|
|
30
|
+
REQUIRED_PACKAGES,
|
|
31
|
+
SAM2_PACKAGES,
|
|
32
|
+
SEG_PACKAGES,
|
|
33
|
+
SUPPORTED_EXTENSIONS,
|
|
34
|
+
UNIVERSAL_MODEL_NAME,
|
|
35
|
+
)
|
|
36
|
+
from pyPetrograph.common.image_io import load_image
|
|
37
|
+
from pyPetrograph.common.notebook_runners import (
|
|
38
|
+
print_label_summary,
|
|
39
|
+
print_train_metrics,
|
|
40
|
+
run_feature_preview,
|
|
41
|
+
run_predict_cell,
|
|
42
|
+
run_scene_feature_preview,
|
|
43
|
+
run_train_cell,
|
|
44
|
+
)
|
|
45
|
+
from pyPetrograph.common.paths import (
|
|
46
|
+
check_and_install_packages,
|
|
47
|
+
cleanup_legacy_outputs,
|
|
48
|
+
get_output_dir,
|
|
49
|
+
relpath_display,
|
|
50
|
+
set_output_dir,
|
|
51
|
+
stem_paths,
|
|
52
|
+
)
|
|
53
|
+
from pyPetrograph.common.session import Session
|
|
54
|
+
from pyPetrograph.common.ui import (
|
|
55
|
+
build_ui,
|
|
56
|
+
launch_app,
|
|
57
|
+
select_images,
|
|
58
|
+
# private subprocess entrypoints re-exported for child processes
|
|
59
|
+
_run_label_gui_from_config,
|
|
60
|
+
_run_qt_picker_from_config,
|
|
61
|
+
launch_app_inplace,
|
|
62
|
+
)
|
|
63
|
+
from pyPetrograph.image_processing.brightness import normalize_brightness, relative_luminance
|
|
64
|
+
from pyPetrograph.image_processing.features import (
|
|
65
|
+
build_feature_stack,
|
|
66
|
+
downsample_to_approx_mp,
|
|
67
|
+
resolve_view_target_mp,
|
|
68
|
+
screen_view_target_mp,
|
|
69
|
+
)
|
|
70
|
+
from pyPetrograph.image_processing.fractions import compute_fractions, compute_fractions_uncertain
|
|
71
|
+
from pyPetrograph.labeling_ml.polygons import magic_wand_to_polygon
|
|
72
|
+
from pyPetrograph.labeling_ml.predict import predict_image, predict_image_proba
|
|
73
|
+
from pyPetrograph.align import (
|
|
74
|
+
Scene,
|
|
75
|
+
launch_align,
|
|
76
|
+
load_scene,
|
|
77
|
+
save_scene,
|
|
78
|
+
embed_scene,
|
|
79
|
+
stack_feature_slots,
|
|
80
|
+
slot_kind,
|
|
81
|
+
xpl_slot,
|
|
82
|
+
)
|
|
83
|
+
from pyPetrograph.fuse import (
|
|
84
|
+
fit_extinction,
|
|
85
|
+
phi_deg_from_cos_sin,
|
|
86
|
+
MIN_ANGLES,
|
|
87
|
+
ChannelStack,
|
|
88
|
+
DEFAULT_CHANNEL_TOGGLES,
|
|
89
|
+
build_channel_stack,
|
|
90
|
+
channel_set_id,
|
|
91
|
+
channels_paths,
|
|
92
|
+
save_channel_stack,
|
|
93
|
+
load_channel_stack,
|
|
94
|
+
iter_tiles,
|
|
95
|
+
apply_tiled,
|
|
96
|
+
axioscan_layers,
|
|
97
|
+
axioscan_scale_folder,
|
|
98
|
+
add_axioscan_layers,
|
|
99
|
+
run_channel_preview,
|
|
100
|
+
)
|
|
101
|
+
from pyPetrograph.objects import (
|
|
102
|
+
run_watershed,
|
|
103
|
+
watershed_labels,
|
|
104
|
+
init_grain_unet,
|
|
105
|
+
train_grain_unet,
|
|
106
|
+
predict_grain_unet,
|
|
107
|
+
grain_unet_path,
|
|
108
|
+
run_outlines,
|
|
109
|
+
compare_outlines,
|
|
110
|
+
launch_grain_qc,
|
|
111
|
+
launch_ppl_grain_qc,
|
|
112
|
+
load_grain_qc,
|
|
113
|
+
load_ppl_grain_qc,
|
|
114
|
+
grain_qc_paths,
|
|
115
|
+
load_grain_mask,
|
|
116
|
+
overlay_labels,
|
|
117
|
+
ensure_seg_weights,
|
|
118
|
+
seg_setup_hint,
|
|
119
|
+
build_object_table,
|
|
120
|
+
build_object_table_from_stack,
|
|
121
|
+
save_object_table,
|
|
122
|
+
load_object_table,
|
|
123
|
+
cluster_objects,
|
|
124
|
+
launch_montage_labeler,
|
|
125
|
+
load_object_labels,
|
|
126
|
+
apply_object_labels,
|
|
127
|
+
object_model_path,
|
|
128
|
+
train_object_classifier,
|
|
129
|
+
predict_object_classes,
|
|
130
|
+
save_classes,
|
|
131
|
+
load_classes,
|
|
132
|
+
compute_object_stats,
|
|
133
|
+
save_object_stats,
|
|
134
|
+
load_object_stats,
|
|
135
|
+
)
|
|
136
|
+
from pyPetrograph.mineral_map import (
|
|
137
|
+
read_legend_swatches,
|
|
138
|
+
legend_from_image,
|
|
139
|
+
check_legend,
|
|
140
|
+
snap_to_legend,
|
|
141
|
+
read_rgba,
|
|
142
|
+
absorb_specks,
|
|
143
|
+
label_grains,
|
|
144
|
+
grain_table,
|
|
145
|
+
grains_to_geojson,
|
|
146
|
+
class_rgb,
|
|
147
|
+
count_grains,
|
|
148
|
+
pivot_counts,
|
|
149
|
+
format_counts,
|
|
150
|
+
format_unlisted,
|
|
151
|
+
mineralmap_paths,
|
|
152
|
+
process_image,
|
|
153
|
+
process_folder,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
__all__ = [
|
|
157
|
+
"SUPPORTED_EXTENSIONS",
|
|
158
|
+
"DEFAULT_Y_TARGET",
|
|
159
|
+
"DEFAULT_N_JOBS",
|
|
160
|
+
"DEFAULT_METHOD",
|
|
161
|
+
"METHODS",
|
|
162
|
+
"DEFAULT_CLASSES",
|
|
163
|
+
"REQUIRED_PACKAGES",
|
|
164
|
+
"SEG_PACKAGES",
|
|
165
|
+
"SAM2_PACKAGES",
|
|
166
|
+
"MODELS_SUBDIR",
|
|
167
|
+
"PREDICTIONS_SUBDIR",
|
|
168
|
+
"LABELS_SUBDIR",
|
|
169
|
+
"CACHE_SUBDIR",
|
|
170
|
+
"UNIVERSAL_MODEL_NAME",
|
|
171
|
+
"stem_paths",
|
|
172
|
+
"set_output_dir",
|
|
173
|
+
"get_output_dir",
|
|
174
|
+
"relpath_display",
|
|
175
|
+
"cleanup_legacy_outputs",
|
|
176
|
+
"load_image",
|
|
177
|
+
"normalize_brightness",
|
|
178
|
+
"relative_luminance",
|
|
179
|
+
"DEFAULT_FEATURE_TOGGLES",
|
|
180
|
+
"DEFAULT_EMBED_FEATURE_TOGGLES",
|
|
181
|
+
"DEFAULT_PREVIEW_TARGET_MP",
|
|
182
|
+
"DEFAULT_PREVIEW_DPI",
|
|
183
|
+
"DEFAULT_FIGURE_DPI",
|
|
184
|
+
"check_and_install_packages",
|
|
185
|
+
"magic_wand_to_polygon",
|
|
186
|
+
"build_feature_stack",
|
|
187
|
+
"downsample_to_approx_mp",
|
|
188
|
+
"screen_view_target_mp",
|
|
189
|
+
"resolve_view_target_mp",
|
|
190
|
+
"Session",
|
|
191
|
+
"select_images",
|
|
192
|
+
"build_ui",
|
|
193
|
+
"launch_app",
|
|
194
|
+
"print_label_summary",
|
|
195
|
+
"run_feature_preview",
|
|
196
|
+
"run_scene_feature_preview",
|
|
197
|
+
"run_train_cell",
|
|
198
|
+
"print_train_metrics",
|
|
199
|
+
"run_predict_cell",
|
|
200
|
+
"predict_image",
|
|
201
|
+
"predict_image_proba",
|
|
202
|
+
"compute_fractions",
|
|
203
|
+
"compute_fractions_uncertain",
|
|
204
|
+
"__version__",
|
|
205
|
+
"DEFAULT_SURE_PROBA",
|
|
206
|
+
"Scene",
|
|
207
|
+
"launch_align",
|
|
208
|
+
"save_scene",
|
|
209
|
+
"load_scene",
|
|
210
|
+
"embed_scene",
|
|
211
|
+
"stack_feature_slots",
|
|
212
|
+
"launch_ppl_grain_qc",
|
|
213
|
+
"load_ppl_grain_qc",
|
|
214
|
+
"grain_qc_paths",
|
|
215
|
+
"load_grain_mask",
|
|
216
|
+
"seg_setup_hint",
|
|
217
|
+
"slot_kind",
|
|
218
|
+
"xpl_slot",
|
|
219
|
+
"fit_extinction",
|
|
220
|
+
"phi_deg_from_cos_sin",
|
|
221
|
+
"MIN_ANGLES",
|
|
222
|
+
"ChannelStack",
|
|
223
|
+
"DEFAULT_CHANNEL_TOGGLES",
|
|
224
|
+
"build_channel_stack",
|
|
225
|
+
"channel_set_id",
|
|
226
|
+
"channels_paths",
|
|
227
|
+
"save_channel_stack",
|
|
228
|
+
"load_channel_stack",
|
|
229
|
+
"iter_tiles",
|
|
230
|
+
"apply_tiled",
|
|
231
|
+
"axioscan_layers",
|
|
232
|
+
"axioscan_scale_folder",
|
|
233
|
+
"add_axioscan_layers",
|
|
234
|
+
"run_channel_preview",
|
|
235
|
+
"run_watershed",
|
|
236
|
+
"watershed_labels",
|
|
237
|
+
"init_grain_unet",
|
|
238
|
+
"train_grain_unet",
|
|
239
|
+
"predict_grain_unet",
|
|
240
|
+
"grain_unet_path",
|
|
241
|
+
"run_outlines",
|
|
242
|
+
"compare_outlines",
|
|
243
|
+
"launch_grain_qc",
|
|
244
|
+
"load_grain_qc",
|
|
245
|
+
"overlay_labels",
|
|
246
|
+
"ensure_seg_weights",
|
|
247
|
+
"build_object_table",
|
|
248
|
+
"build_object_table_from_stack",
|
|
249
|
+
"save_object_table",
|
|
250
|
+
"load_object_table",
|
|
251
|
+
"cluster_objects",
|
|
252
|
+
"launch_montage_labeler",
|
|
253
|
+
"load_object_labels",
|
|
254
|
+
"apply_object_labels",
|
|
255
|
+
"object_model_path",
|
|
256
|
+
"train_object_classifier",
|
|
257
|
+
"predict_object_classes",
|
|
258
|
+
"save_classes",
|
|
259
|
+
"load_classes",
|
|
260
|
+
"compute_object_stats",
|
|
261
|
+
"save_object_stats",
|
|
262
|
+
"load_object_stats",
|
|
263
|
+
"read_legend_swatches",
|
|
264
|
+
"legend_from_image",
|
|
265
|
+
"check_legend",
|
|
266
|
+
"snap_to_legend",
|
|
267
|
+
"read_rgba",
|
|
268
|
+
"absorb_specks",
|
|
269
|
+
"label_grains",
|
|
270
|
+
"grain_table",
|
|
271
|
+
"grains_to_geojson",
|
|
272
|
+
"class_rgb",
|
|
273
|
+
"count_grains",
|
|
274
|
+
"pivot_counts",
|
|
275
|
+
"format_counts",
|
|
276
|
+
"format_unlisted",
|
|
277
|
+
"mineralmap_paths",
|
|
278
|
+
"process_image",
|
|
279
|
+
"process_folder",
|
|
280
|
+
]
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Multi-layer lineup: register PPL / XPL / BSE onto one working grid."""
|
|
2
|
+
|
|
3
|
+
from pyPetrograph.align.embed import embed_scene, stack_feature_slots
|
|
4
|
+
from pyPetrograph.align.grains import (
|
|
5
|
+
grain_qc_paths,
|
|
6
|
+
launch_ppl_grain_qc,
|
|
7
|
+
load_grain_mask,
|
|
8
|
+
load_ppl_grain_qc,
|
|
9
|
+
overlay_labels,
|
|
10
|
+
seg_setup_hint,
|
|
11
|
+
)
|
|
12
|
+
from pyPetrograph.align.io import load_scene, save_scene
|
|
13
|
+
from pyPetrograph.align.register import auto_register, refine_register
|
|
14
|
+
from pyPetrograph.align.scene import Affine2D, Layer, Scene, slot_kind, xpl_slot
|
|
15
|
+
from pyPetrograph.align.structure import structure_map
|
|
16
|
+
from pyPetrograph.align.ui import launch_align
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"Affine2D",
|
|
20
|
+
"Layer",
|
|
21
|
+
"Scene",
|
|
22
|
+
"slot_kind",
|
|
23
|
+
"xpl_slot",
|
|
24
|
+
"structure_map",
|
|
25
|
+
"auto_register",
|
|
26
|
+
"refine_register",
|
|
27
|
+
"save_scene",
|
|
28
|
+
"load_scene",
|
|
29
|
+
"launch_align",
|
|
30
|
+
"embed_scene",
|
|
31
|
+
"stack_feature_slots",
|
|
32
|
+
"launch_ppl_grain_qc",
|
|
33
|
+
"load_ppl_grain_qc",
|
|
34
|
+
"grain_qc_paths",
|
|
35
|
+
"load_grain_mask",
|
|
36
|
+
"overlay_labels",
|
|
37
|
+
"seg_setup_hint",
|
|
38
|
+
]
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""Embedding field from an aligned Scene. Default C = small autoencoder; B = frozen net."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Dict, List, Optional, Tuple
|
|
5
|
+
|
|
6
|
+
import numpy as np
|
|
7
|
+
from sklearn.decomposition import PCA
|
|
8
|
+
from sklearn.neural_network import MLPRegressor
|
|
9
|
+
|
|
10
|
+
from pyPetrograph.align.scene import Scene
|
|
11
|
+
from pyPetrograph.common.constants import DEFAULT_EMBED_FEATURE_TOGGLES
|
|
12
|
+
from pyPetrograph.image_processing.features import build_feature_stack, resolve_feature_toggles
|
|
13
|
+
|
|
14
|
+
NAMED_SLOTS: Tuple[str, ...] = ("ppl", "xpl", "bse")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ordered_slots(scene: Scene) -> List[str]:
|
|
18
|
+
slots = [s for s in NAMED_SLOTS if s in scene.layers]
|
|
19
|
+
if scene.working_slot not in slots:
|
|
20
|
+
slots = [scene.working_slot] + slots
|
|
21
|
+
seen = set()
|
|
22
|
+
ordered: List[str] = []
|
|
23
|
+
for s in slots:
|
|
24
|
+
if s not in seen and s in scene.layers:
|
|
25
|
+
seen.add(s)
|
|
26
|
+
ordered.append(s)
|
|
27
|
+
return ordered
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def stack_named_slots(scene: Scene) -> Tuple[np.ndarray, np.ndarray, List[str]]:
|
|
31
|
+
"""
|
|
32
|
+
Working-grid RGB stack and per-slot coverage (legacy RGB-only helper).
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
stack : float32 HxWx(3*n_slots), RGB in [0, 1]
|
|
37
|
+
valid : bool HxWx n_slots — False where that measurement is missing (skip, not fake)
|
|
38
|
+
names : slot names in order
|
|
39
|
+
"""
|
|
40
|
+
h, w = scene.working_hw()
|
|
41
|
+
ordered = _ordered_slots(scene)
|
|
42
|
+
ch = np.zeros((h, w, 3 * len(ordered)), dtype=np.float32)
|
|
43
|
+
valid = np.zeros((h, w, len(ordered)), dtype=bool)
|
|
44
|
+
for i, slot in enumerate(ordered):
|
|
45
|
+
rgb, mask = scene.warp_layer(slot)
|
|
46
|
+
ch[:, :, i * 3 : (i + 1) * 3] = rgb.astype(np.float32) / 255.0
|
|
47
|
+
valid[:, :, i] = mask
|
|
48
|
+
ch[:, :, i * 3 : (i + 1) * 3][~mask] = 0.0
|
|
49
|
+
return ch, valid, ordered
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def stack_feature_slots(
|
|
53
|
+
scene: Scene,
|
|
54
|
+
*,
|
|
55
|
+
feature_toggles: Optional[Dict[str, bool]] = None,
|
|
56
|
+
) -> Tuple[np.ndarray, np.ndarray, List[str], List[str]]:
|
|
57
|
+
"""
|
|
58
|
+
Per-slot feature stacks on the working grid (shared with RGB notebook).
|
|
59
|
+
|
|
60
|
+
For each aligned slot, call ``build_feature_stack`` (Y-norm on RGB, then
|
|
61
|
+
toggled maps). Prefix channel names with ``ppl_``, ``xpl_``, ``bse_``.
|
|
62
|
+
Missing / out-of-window pixels are zeroed and marked invalid.
|
|
63
|
+
|
|
64
|
+
Returns
|
|
65
|
+
-------
|
|
66
|
+
stack : float32 HxWxC
|
|
67
|
+
valid : bool HxW — True if any slot covers the pixel
|
|
68
|
+
slot_names : slot order
|
|
69
|
+
input_names : channel names (e.g. ``ppl_r``, ``xpl_local_grad``)
|
|
70
|
+
"""
|
|
71
|
+
toggles = resolve_feature_toggles(
|
|
72
|
+
feature_toggles if feature_toggles is not None else DEFAULT_EMBED_FEATURE_TOGGLES
|
|
73
|
+
)
|
|
74
|
+
# Embed never invents GLCM without polygons — force off.
|
|
75
|
+
toggles["glcm"] = False
|
|
76
|
+
ordered = _ordered_slots(scene)
|
|
77
|
+
h, w = scene.working_hw()
|
|
78
|
+
chunks: List[np.ndarray] = []
|
|
79
|
+
input_names: List[str] = []
|
|
80
|
+
any_valid = np.zeros((h, w), dtype=bool)
|
|
81
|
+
for slot in ordered:
|
|
82
|
+
rgb, mask = scene.warp_layer(slot)
|
|
83
|
+
feat, names = build_feature_stack(
|
|
84
|
+
rgb,
|
|
85
|
+
toggles=toggles,
|
|
86
|
+
apply_norm=True,
|
|
87
|
+
polygons=None,
|
|
88
|
+
)
|
|
89
|
+
feat = np.asarray(feat, dtype=np.float32)
|
|
90
|
+
feat[~mask] = 0.0
|
|
91
|
+
chunks.append(feat)
|
|
92
|
+
input_names.extend([f"{slot}_{n}" for n in names])
|
|
93
|
+
any_valid |= mask
|
|
94
|
+
if not chunks:
|
|
95
|
+
raise RuntimeError("No layers in scene to embed.")
|
|
96
|
+
stack = np.concatenate(chunks, axis=-1).astype(np.float32)
|
|
97
|
+
return stack, any_valid, ordered, input_names
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _mlp_hidden(mlp: MLPRegressor, X: np.ndarray) -> np.ndarray:
|
|
101
|
+
a = X
|
|
102
|
+
# all hidden layers; last coefs_ map hidden → reconstruct
|
|
103
|
+
for i in range(len(mlp.coefs_) - 1):
|
|
104
|
+
a = np.maximum(0.0, a @ mlp.coefs_[i] + mlp.intercepts_[i])
|
|
105
|
+
return a.astype(np.float32)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def embed_C(
|
|
109
|
+
scene: Scene,
|
|
110
|
+
*,
|
|
111
|
+
hidden: int = 16,
|
|
112
|
+
max_pixels: int = 80_000,
|
|
113
|
+
random_state: int = 42,
|
|
114
|
+
feature_toggles: Optional[Dict[str, bool]] = None,
|
|
115
|
+
) -> Tuple[np.ndarray, Dict[str, object]]:
|
|
116
|
+
"""
|
|
117
|
+
Small autoencoder on per-slot feature stacks (CPU MLP).
|
|
118
|
+
|
|
119
|
+
Inputs come from ``build_feature_stack`` (shared with the RGB notebook).
|
|
120
|
+
Default toggles: r/g/b + std 7/21/63 + grad + LBP r=1/3/5 + Gabor 0/45/90
|
|
121
|
+
+ Hessian ridge + entropy 7/21/63; no Y, no GLCM.
|
|
122
|
+
|
|
123
|
+
Returns HxWxhidden float32 and a small info dict (includes ``input_names``).
|
|
124
|
+
"""
|
|
125
|
+
stack, any_valid, slot_names, input_names = stack_feature_slots(
|
|
126
|
+
scene, feature_toggles=feature_toggles
|
|
127
|
+
)
|
|
128
|
+
h, w, c = stack.shape
|
|
129
|
+
X = stack.reshape(-1, c)
|
|
130
|
+
idx = np.where(any_valid.ravel())[0]
|
|
131
|
+
if len(idx) == 0:
|
|
132
|
+
raise RuntimeError("No valid pixels to embed.")
|
|
133
|
+
rng = np.random.RandomState(random_state)
|
|
134
|
+
if len(idx) > max_pixels:
|
|
135
|
+
idx = rng.choice(idx, size=max_pixels, replace=False)
|
|
136
|
+
# Scale inputs to similar ranges so LBP codes and RGB share the MLP.
|
|
137
|
+
mu = X[idx].mean(axis=0)
|
|
138
|
+
sd = X[idx].std(axis=0)
|
|
139
|
+
sd = np.where(sd < 1e-6, 1.0, sd).astype(np.float32)
|
|
140
|
+
mu = mu.astype(np.float32)
|
|
141
|
+
|
|
142
|
+
def _z(block: np.ndarray) -> np.ndarray:
|
|
143
|
+
return (block - mu) / sd
|
|
144
|
+
|
|
145
|
+
mlp = MLPRegressor(
|
|
146
|
+
hidden_layer_sizes=(int(hidden),),
|
|
147
|
+
activation="relu",
|
|
148
|
+
solver="adam",
|
|
149
|
+
max_iter=80,
|
|
150
|
+
random_state=random_state,
|
|
151
|
+
verbose=False,
|
|
152
|
+
)
|
|
153
|
+
mlp.fit(_z(X[idx]), _z(X[idx]))
|
|
154
|
+
emb = np.zeros((h * w, int(hidden)), dtype=np.float32)
|
|
155
|
+
step = 200_000
|
|
156
|
+
for s in range(0, h * w, step):
|
|
157
|
+
e = min(h * w, s + step)
|
|
158
|
+
emb[s:e] = _mlp_hidden(mlp, _z(X[s:e]))
|
|
159
|
+
emb[~any_valid.ravel()] = 0.0
|
|
160
|
+
field = emb.reshape(h, w, int(hidden))
|
|
161
|
+
info: Dict[str, object] = {
|
|
162
|
+
"method": "C",
|
|
163
|
+
"slots": slot_names,
|
|
164
|
+
"hidden": int(hidden),
|
|
165
|
+
"input_names": input_names,
|
|
166
|
+
"n_input": int(c),
|
|
167
|
+
"feature_toggles": resolve_feature_toggles(
|
|
168
|
+
feature_toggles if feature_toggles is not None else DEFAULT_EMBED_FEATURE_TOGGLES
|
|
169
|
+
),
|
|
170
|
+
}
|
|
171
|
+
return field, info
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def embed_B(
|
|
175
|
+
scene: Scene,
|
|
176
|
+
*,
|
|
177
|
+
model_name: str = "dinov2_vits14",
|
|
178
|
+
feature_toggles: Optional[Dict[str, bool]] = None, # unused; RGB-net path
|
|
179
|
+
) -> Tuple[np.ndarray, Dict[str, object]]:
|
|
180
|
+
"""
|
|
181
|
+
Frozen pretrained net per available layer; skip missing pixels (no fake BSE).
|
|
182
|
+
|
|
183
|
+
Requires ``torch``. Each available RGB layer is encoded; votes are summed
|
|
184
|
+
only where that layer is valid, then L2-normalized. Output length is fixed.
|
|
185
|
+
``feature_toggles`` is accepted for API symmetry with ``embed_C`` but ignored
|
|
186
|
+
(DINOv2 expects RGB).
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
import torch
|
|
190
|
+
import torch.nn.functional as F
|
|
191
|
+
except ImportError as exc:
|
|
192
|
+
raise ImportError(
|
|
193
|
+
"embed_B needs PyTorch. In conda env work: pip install torch --index-url "
|
|
194
|
+
"https://download.pytorch.org/whl/cpu"
|
|
195
|
+
) from exc
|
|
196
|
+
|
|
197
|
+
stack, valid, names = stack_named_slots(scene)
|
|
198
|
+
h, w, _ = stack.shape
|
|
199
|
+
dinov2 = torch.hub.load("facebookresearch/dinov2", model_name, verbose=False)
|
|
200
|
+
dinov2.eval()
|
|
201
|
+
votes = None
|
|
202
|
+
weight = np.zeros((h, w), dtype=np.float32)
|
|
203
|
+
for i, slot in enumerate(names):
|
|
204
|
+
rgb = stack[:, :, i * 3 : (i + 1) * 3]
|
|
205
|
+
mask = valid[:, :, i]
|
|
206
|
+
if not mask.any():
|
|
207
|
+
continue
|
|
208
|
+
t = torch.from_numpy(np.transpose(rgb, (2, 0, 1))).unsqueeze(0).float()
|
|
209
|
+
# DINOv2 expects ImageNet-sized patches; interpolate to multiple of 14
|
|
210
|
+
ht = int(np.ceil(h / 14) * 14)
|
|
211
|
+
wt = int(np.ceil(w / 14) * 14)
|
|
212
|
+
t = F.interpolate(t, size=(ht, wt), mode="bilinear", align_corners=False)
|
|
213
|
+
mean = torch.tensor([0.485, 0.456, 0.406]).view(1, 3, 1, 1)
|
|
214
|
+
std = torch.tensor([0.229, 0.224, 0.225]).view(1, 3, 1, 1)
|
|
215
|
+
t = (t - mean) / std
|
|
216
|
+
with torch.no_grad():
|
|
217
|
+
out = dinov2.forward_features(t)
|
|
218
|
+
feat = out["x_norm_patchtokens"] # 1, N, D
|
|
219
|
+
d = feat.shape[-1]
|
|
220
|
+
gh, gw = ht // 14, wt // 14
|
|
221
|
+
fmap = feat.reshape(1, gh, gw, d).permute(0, 3, 1, 2)
|
|
222
|
+
fmap = F.interpolate(fmap, size=(h, w), mode="bilinear", align_corners=False)
|
|
223
|
+
arr = fmap[0].permute(1, 2, 0).cpu().numpy().astype(np.float32)
|
|
224
|
+
if votes is None:
|
|
225
|
+
votes = np.zeros((h, w, d), dtype=np.float32)
|
|
226
|
+
votes[mask] += arr[mask]
|
|
227
|
+
weight[mask] += 1.0
|
|
228
|
+
if votes is None:
|
|
229
|
+
raise RuntimeError("No valid pixels to embed.")
|
|
230
|
+
w3 = np.maximum(weight, 1e-6)[:, :, None]
|
|
231
|
+
field = votes / w3
|
|
232
|
+
nrm = np.linalg.norm(field, axis=-1, keepdims=True) + 1e-6
|
|
233
|
+
field = field / nrm
|
|
234
|
+
return field, {
|
|
235
|
+
"method": "B",
|
|
236
|
+
"slots": names,
|
|
237
|
+
"model": model_name,
|
|
238
|
+
"input_names": [f"{s}_rgb" for s in names],
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def embedding_to_rgb(field: np.ndarray, *, max_fit: int = 40_000) -> np.ndarray:
|
|
243
|
+
"""False-color view of an embedding field (first 3 PCA axes). Preview only."""
|
|
244
|
+
h, w, d = field.shape
|
|
245
|
+
x = np.asarray(field, dtype=np.float32).reshape(-1, d)
|
|
246
|
+
n = int(x.shape[0])
|
|
247
|
+
k = min(3, d)
|
|
248
|
+
rng = np.random.RandomState(0)
|
|
249
|
+
take = min(int(max_fit), n)
|
|
250
|
+
idx = rng.choice(n, size=take, replace=False) if n > take else np.arange(n)
|
|
251
|
+
pca = PCA(n_components=k, random_state=0)
|
|
252
|
+
try:
|
|
253
|
+
pca.set_params(svd_solver="covariance_eigh")
|
|
254
|
+
except ValueError:
|
|
255
|
+
pca.set_params(svd_solver="randomized")
|
|
256
|
+
pca.fit(x[idx])
|
|
257
|
+
vis = np.zeros((n, 3), dtype=np.float32)
|
|
258
|
+
step = 200_000
|
|
259
|
+
for s in range(0, n, step):
|
|
260
|
+
e = min(n, s + step)
|
|
261
|
+
vis[s:e, :k] = pca.transform(x[s:e]).astype(np.float32)
|
|
262
|
+
for c in range(k):
|
|
263
|
+
col = vis[:, c]
|
|
264
|
+
lo, hi = np.percentile(col, (2, 98))
|
|
265
|
+
if hi > lo:
|
|
266
|
+
vis[:, c] = np.clip((col - lo) / (hi - lo), 0, 1)
|
|
267
|
+
return np.clip(vis.reshape(h, w, 3) * 255.0, 0, 255).astype(np.uint8)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def embed_scene(scene: Scene, method: str = "C", **kwargs) -> Tuple[np.ndarray, np.ndarray, Dict]:
|
|
271
|
+
"""
|
|
272
|
+
Run embedding. ``method`` is ``C`` (default autoencoder) or ``B`` (frozen net).
|
|
273
|
+
|
|
274
|
+
Returns (field, rgb_view, info).
|
|
275
|
+
"""
|
|
276
|
+
m = str(method).upper()
|
|
277
|
+
if m == "B":
|
|
278
|
+
field, info = embed_B(scene, **kwargs)
|
|
279
|
+
else:
|
|
280
|
+
field, info = embed_C(scene, **kwargs)
|
|
281
|
+
return field, embedding_to_rgb(field), info
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""PPL GrainPlot aliases for ``SegmentEveryGrain_PPL.ipynb``.
|
|
2
|
+
|
|
3
|
+
Outlines live in ``pyPetrograph.objects.qc``. This module only re-exports the
|
|
4
|
+
names that notebook still imports.
|
|
5
|
+
"""
|
|
6
|
+
from pyPetrograph.objects.qc import (
|
|
7
|
+
grain_qc_paths,
|
|
8
|
+
launch_ppl_grain_qc,
|
|
9
|
+
load_grain_mask,
|
|
10
|
+
load_ppl_grain_qc,
|
|
11
|
+
overlay_labels,
|
|
12
|
+
seg_setup_hint,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"grain_qc_paths",
|
|
17
|
+
"launch_ppl_grain_qc",
|
|
18
|
+
"load_grain_mask",
|
|
19
|
+
"load_ppl_grain_qc",
|
|
20
|
+
"overlay_labels",
|
|
21
|
+
"seg_setup_hint",
|
|
22
|
+
]
|
pyPetrograph/align/io.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Save / reload lineup JSON under {image_folder}/align/."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Optional
|
|
7
|
+
|
|
8
|
+
from pyPetrograph.align.scene import Affine2D, Layer, Scene
|
|
9
|
+
from pyPetrograph.common.constants import ALIGN_SUBDIR, PathLike
|
|
10
|
+
from pyPetrograph.common.paths import get_output_dir
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def align_dir_for(image_path: PathLike) -> Path:
|
|
14
|
+
p = Path(image_path).resolve()
|
|
15
|
+
base = get_output_dir() or p.parent
|
|
16
|
+
d = base / ALIGN_SUBDIR
|
|
17
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
return d
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def default_json_path(scene: Scene) -> Path:
|
|
22
|
+
working = scene.working()
|
|
23
|
+
return align_dir_for(working.path) / f"{working.path.stem}_align.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def scene_to_dict(scene: Scene) -> Dict[str, Any]:
|
|
27
|
+
layers = []
|
|
28
|
+
for slot, layer in scene.layers.items():
|
|
29
|
+
item: Dict[str, Any] = {
|
|
30
|
+
"slot": slot,
|
|
31
|
+
"path": str(layer.path),
|
|
32
|
+
"transform": layer.transform.as_dict(),
|
|
33
|
+
}
|
|
34
|
+
if layer.angle_deg is not None:
|
|
35
|
+
item["angle_deg"] = float(layer.angle_deg)
|
|
36
|
+
layers.append(item)
|
|
37
|
+
return {
|
|
38
|
+
"version": 1,
|
|
39
|
+
"working_slot": scene.working_slot,
|
|
40
|
+
"point_tolerance_px": float(scene.point_tolerance_px),
|
|
41
|
+
"scale_range": [float(scene.scale_range[0]), float(scene.scale_range[1])],
|
|
42
|
+
"click_pairs": list(scene.click_pairs),
|
|
43
|
+
"layers": layers,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def save_scene(scene: Scene, path: Optional[PathLike] = None) -> Path:
|
|
48
|
+
out = Path(path) if path is not None else default_json_path(scene)
|
|
49
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
out.write_text(json.dumps(scene_to_dict(scene), indent=2), encoding="utf-8")
|
|
51
|
+
scene.json_path = out
|
|
52
|
+
return out
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_scene(path: PathLike) -> Scene:
|
|
56
|
+
p = Path(path)
|
|
57
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
58
|
+
scene = Scene(
|
|
59
|
+
working_slot=str(data.get("working_slot") or "ppl"),
|
|
60
|
+
point_tolerance_px=float(data.get("point_tolerance_px", 8.0)),
|
|
61
|
+
json_path=p,
|
|
62
|
+
)
|
|
63
|
+
sr = data.get("scale_range") or [0.5, 2.0]
|
|
64
|
+
scene.scale_range = (float(sr[0]), float(sr[1]))
|
|
65
|
+
scene.click_pairs = list(data.get("click_pairs") or [])
|
|
66
|
+
for item in data.get("layers") or []:
|
|
67
|
+
angle = item.get("angle_deg")
|
|
68
|
+
layer = Layer(
|
|
69
|
+
slot=str(item["slot"]),
|
|
70
|
+
path=Path(item["path"]),
|
|
71
|
+
transform=Affine2D.from_dict(item.get("transform") or {}),
|
|
72
|
+
angle_deg=None if angle is None else float(angle),
|
|
73
|
+
)
|
|
74
|
+
scene.layers[layer.slot] = layer
|
|
75
|
+
if scene.working_slot not in scene.layers and scene.layers:
|
|
76
|
+
scene.working_slot = next(iter(scene.layers))
|
|
77
|
+
return scene
|