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.
Files changed (55) hide show
  1. pyPetrograph/__init__.py +280 -0
  2. pyPetrograph/align/__init__.py +38 -0
  3. pyPetrograph/align/embed.py +281 -0
  4. pyPetrograph/align/grains.py +22 -0
  5. pyPetrograph/align/io.py +77 -0
  6. pyPetrograph/align/register.py +210 -0
  7. pyPetrograph/align/scene.py +201 -0
  8. pyPetrograph/align/seg_qc_worker.py +137 -0
  9. pyPetrograph/align/seg_worker.py +198 -0
  10. pyPetrograph/align/structure.py +47 -0
  11. pyPetrograph/align/ui.py +470 -0
  12. pyPetrograph/common/__init__.py +1 -0
  13. pyPetrograph/common/constants.py +219 -0
  14. pyPetrograph/common/image_io.py +215 -0
  15. pyPetrograph/common/notebook_runners.py +609 -0
  16. pyPetrograph/common/paths.py +406 -0
  17. pyPetrograph/common/session.py +1075 -0
  18. pyPetrograph/common/ui.py +1127 -0
  19. pyPetrograph/fuse/__init__.py +39 -0
  20. pyPetrograph/fuse/extinction.py +130 -0
  21. pyPetrograph/fuse/preview.py +192 -0
  22. pyPetrograph/fuse/stack.py +311 -0
  23. pyPetrograph/fuse/tiles.py +174 -0
  24. pyPetrograph/image_processing/__init__.py +1 -0
  25. pyPetrograph/image_processing/brightness.py +56 -0
  26. pyPetrograph/image_processing/features.py +429 -0
  27. pyPetrograph/image_processing/fractions.py +109 -0
  28. pyPetrograph/image_processing/viz.py +167 -0
  29. pyPetrograph/labeling_ml/__init__.py +1 -0
  30. pyPetrograph/labeling_ml/model_io.py +20 -0
  31. pyPetrograph/labeling_ml/polygons.py +192 -0
  32. pyPetrograph/labeling_ml/predict.py +161 -0
  33. pyPetrograph/labeling_ml/train.py +362 -0
  34. pyPetrograph/mineral_map/__init__.py +63 -0
  35. pyPetrograph/mineral_map/counts.py +153 -0
  36. pyPetrograph/mineral_map/grains.py +580 -0
  37. pyPetrograph/mineral_map/legend.py +242 -0
  38. pyPetrograph/mineral_map/run.py +185 -0
  39. pyPetrograph/objects/__init__.py +91 -0
  40. pyPetrograph/objects/classify.py +205 -0
  41. pyPetrograph/objects/cluster.py +200 -0
  42. pyPetrograph/objects/montage_worker.py +117 -0
  43. pyPetrograph/objects/outlines.py +322 -0
  44. pyPetrograph/objects/qc.py +288 -0
  45. pyPetrograph/objects/qc_worker.py +192 -0
  46. pyPetrograph/objects/stats.py +303 -0
  47. pyPetrograph/objects/table.py +214 -0
  48. pyPetrograph/objects/tf_env.py +98 -0
  49. pyPetrograph/objects/unet_worker.py +416 -0
  50. pyPetrograph/objects/watershed.py +138 -0
  51. pypetrograph-0.0.5.dist-info/METADATA +98 -0
  52. pypetrograph-0.0.5.dist-info/RECORD +55 -0
  53. pypetrograph-0.0.5.dist-info/WHEEL +5 -0
  54. pypetrograph-0.0.5.dist-info/licenses/LICENSE +661 -0
  55. pypetrograph-0.0.5.dist-info/top_level.txt +1 -0
@@ -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
+ ]
@@ -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