pyarchinit-photogrammetry 0.1.0__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 (51) hide show
  1. pyarchinit_photogrammetry/__init__.py +30 -0
  2. pyarchinit_photogrammetry/__main__.py +3 -0
  3. pyarchinit_photogrammetry/ai/__init__.py +1 -0
  4. pyarchinit_photogrammetry/ai/classify.py +149 -0
  5. pyarchinit_photogrammetry/ai/sam.py +238 -0
  6. pyarchinit_photogrammetry/app.py +31 -0
  7. pyarchinit_photogrammetry/archaeo/__init__.py +1 -0
  8. pyarchinit_photogrammetry/archaeo/pottery.py +278 -0
  9. pyarchinit_photogrammetry/archaeo/sections.py +213 -0
  10. pyarchinit_photogrammetry/archaeo/stratigraphy.py +82 -0
  11. pyarchinit_photogrammetry/archaeo/terrain.py +126 -0
  12. pyarchinit_photogrammetry/bim/__init__.py +1 -0
  13. pyarchinit_photogrammetry/bim/ifc.py +143 -0
  14. pyarchinit_photogrammetry/cli.py +324 -0
  15. pyarchinit_photogrammetry/core/__init__.py +9 -0
  16. pyarchinit_photogrammetry/core/camera.py +169 -0
  17. pyarchinit_photogrammetry/core/chunk.py +434 -0
  18. pyarchinit_photogrammetry/core/crs.py +56 -0
  19. pyarchinit_photogrammetry/core/document.py +97 -0
  20. pyarchinit_photogrammetry/core/geometry.py +141 -0
  21. pyarchinit_photogrammetry/core/marker.py +77 -0
  22. pyarchinit_photogrammetry/core/pointcloud.py +219 -0
  23. pyarchinit_photogrammetry/core/progress.py +50 -0
  24. pyarchinit_photogrammetry/engine/__init__.py +1 -0
  25. pyarchinit_photogrammetry/engine/dem.py +179 -0
  26. pyarchinit_photogrammetry/engine/dense.py +378 -0
  27. pyarchinit_photogrammetry/engine/meshing.py +144 -0
  28. pyarchinit_photogrammetry/engine/ortho.py +157 -0
  29. pyarchinit_photogrammetry/engine/sfm.py +245 -0
  30. pyarchinit_photogrammetry/engine/texturing.py +177 -0
  31. pyarchinit_photogrammetry/engine/visibility.py +129 -0
  32. pyarchinit_photogrammetry/io/__init__.py +1 -0
  33. pyarchinit_photogrammetry/io/exif.py +59 -0
  34. pyarchinit_photogrammetry/io/exports.py +100 -0
  35. pyarchinit_photogrammetry/io/report.py +91 -0
  36. pyarchinit_photogrammetry/markers/__init__.py +1 -0
  37. pyarchinit_photogrammetry/markers/gcp.py +239 -0
  38. pyarchinit_photogrammetry/markers/targets.py +217 -0
  39. pyarchinit_photogrammetry/pyarchinit/__init__.py +8 -0
  40. pyarchinit_photogrammetry/pyarchinit/bridge.py +259 -0
  41. pyarchinit_photogrammetry/pyarchinit/config.py +92 -0
  42. pyarchinit_photogrammetry/pyarchinit/database.py +257 -0
  43. pyarchinit_photogrammetry/pyarchinit/media.py +193 -0
  44. pyarchinit_photogrammetry/testing/__init__.py +0 -0
  45. pyarchinit_photogrammetry/testing/synthetic.py +167 -0
  46. pyarchinit_photogrammetry-0.1.0.dist-info/METADATA +200 -0
  47. pyarchinit_photogrammetry-0.1.0.dist-info/RECORD +51 -0
  48. pyarchinit_photogrammetry-0.1.0.dist-info/WHEEL +5 -0
  49. pyarchinit_photogrammetry-0.1.0.dist-info/entry_points.txt +2 -0
  50. pyarchinit_photogrammetry-0.1.0.dist-info/licenses/LICENSE +339 -0
  51. pyarchinit_photogrammetry-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,30 @@
1
+ """pyarchinit-photogrammetry: open-source photogrammetry for archaeology and BIM.
2
+
3
+ Quick start::
4
+
5
+ import pyarchinit_photogrammetry as ppg
6
+
7
+ doc = ppg.Document()
8
+ chunk = doc.add_chunk("Saggio 1")
9
+ chunk.add_photos(["foto/"])
10
+ chunk.detect_markers("aruco_4x4_50")
11
+ chunk.match_photos(quality="high")
12
+ chunk.align_cameras()
13
+ chunk.import_reference("gcp.csv", crs="EPSG:3004")
14
+ chunk.update_transform()
15
+ chunk.build_depth_maps(quality="medium")
16
+ chunk.build_point_cloud()
17
+ chunk.build_model()
18
+ chunk.build_dem()
19
+ chunk.build_orthomosaic()
20
+ doc.save("scavo.ppg")
21
+ """
22
+ from .app import app
23
+ from .core import (ARCHAEO_CLASSES, ASPRS_CLASSES, Camera, Chunk, Document, Marker, PointCloud, Projection,
24
+ Reference, ScaleBar, Sensor)
25
+ from .core.progress import Cancelled, Progress, print_progress
26
+
27
+ __version__ = "0.1.0"
28
+
29
+ __all__ = ["app", "Document", "Chunk", "Camera", "Sensor", "Marker", "Projection", "Reference", "ScaleBar",
30
+ "PointCloud", "ASPRS_CLASSES", "ARCHAEO_CLASSES", "Progress", "Cancelled", "print_progress", "__version__"]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,149 @@
1
+ """Point cloud classification (ASPRS codes + archaeological classes 64-71).
2
+
3
+ Geometric/radiometric rules that need no training data:
4
+
5
+ * noise statistical outlier removal -> 7
6
+ * ground progressive morphological filter -> 2
7
+ * vegetation excess-green index + height above ground -> 3 / 4 / 5
8
+ * structures above ground, near-vertical planar surfaces -> 64 (masonry)
9
+
10
+ Classes can also be assigned from stratigraphic-unit polygons or from image
11
+ masks (SAM 2.1, see :mod:`pyarchinit_photogrammetry.ai.sam`).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import numpy as np
16
+
17
+ from ..core.pointcloud import ARCHAEO_CLASSES, PointCloud
18
+
19
+ NOISE, GROUND, LOW_VEG, MED_VEG, HIGH_VEG, BUILDING = 7, 2, 3, 4, 5, 6
20
+ MASONRY = 64
21
+
22
+
23
+ def noise_mask(xyz: np.ndarray, k: int = 8, std_ratio: float = 3.0) -> np.ndarray:
24
+ from scipy.spatial import cKDTree
25
+
26
+ d, _ = cKDTree(xyz).query(xyz, k=k + 1)
27
+ mean = d[:, 1:].mean(1)
28
+ return mean > mean.mean() + std_ratio * mean.std()
29
+
30
+
31
+ def ground_mask(xyz: np.ndarray, cell: float = 0.05, max_window: float = 1.0, slope: float = 0.3,
32
+ dh0: float = 0.02, dh_max: float = 0.5) -> tuple[np.ndarray, np.ndarray]:
33
+ """Progressive morphological filter (Zhang et al. 2003) on a minimum-elevation grid.
34
+
35
+ Returns ``(is_ground, height_above_ground)``.
36
+ """
37
+ from scipy import ndimage
38
+
39
+ from ..engine.dem import grid_points
40
+
41
+ area = max(np.ptp(xyz[:, 0]) * np.ptp(xyz[:, 1]), 1e-9)
42
+ cell = max(cell, float(np.sqrt(area / 16e6))) # keep the grid below ~16 M cells
43
+ zmin, (x0, y1), (h, w) = grid_points(xyz, cell, reducer="min")
44
+ surf = np.where(np.isfinite(zmin), zmin, np.nanmax(zmin))
45
+ filled = ndimage.grey_closing(surf, size=3)
46
+ surf = np.where(np.isfinite(zmin), surf, filled)
47
+ ground_surf = surf.copy()
48
+ win = 3
49
+ dh = dh0
50
+ prev = win
51
+ while win * cell <= max_window:
52
+ opened = ndimage.grey_opening(ground_surf, size=(win, win))
53
+ nonground = (ground_surf - opened) > dh
54
+ ground_surf = np.where(nonground, opened, ground_surf)
55
+ dh = min(dh_max, slope * (win - prev) * cell + dh0)
56
+ prev = win
57
+ win = 2 * win - 1
58
+ col = np.clip(((xyz[:, 0] - x0) / cell).astype(int), 0, w - 1)
59
+ row = np.clip(((y1 - xyz[:, 1]) / cell).astype(int), 0, h - 1)
60
+ hag = xyz[:, 2] - ground_surf[row, col]
61
+ return hag <= max(dh0 * 1.5, 0.03), hag
62
+
63
+
64
+ def excess_green(rgb: np.ndarray) -> np.ndarray:
65
+ c = rgb.astype(np.float64)
66
+ s = c.sum(1) + 1e-9
67
+ r, g, b = c[:, 0] / s, c[:, 1] / s, c[:, 2] / s
68
+ return 2 * g - r - b
69
+
70
+
71
+ def normals_verticality(xyz: np.ndarray, k: int = 12) -> np.ndarray:
72
+ """|n_z| of PCA normals (0 = vertical surface, 1 = horizontal)."""
73
+ from scipy.spatial import cKDTree
74
+
75
+ _, idx = cKDTree(xyz).query(xyz, k=k)
76
+ nb = xyz[idx] - xyz[idx].mean(1, keepdims=True)
77
+ cov = np.einsum("nki,nkj->nij", nb, nb)
78
+ _, vecs = np.linalg.eigh(cov)
79
+ return np.abs(vecs[:, :, 0][:, 2])
80
+
81
+
82
+ def classify(pc_world: PointCloud, cell: float = 0.05, vegetation: bool = True, structures: bool = True,
83
+ structure_min_height: float = 0.15, exg_threshold: float = 0.08) -> PointCloud:
84
+ """Classify a point cloud expressed in world coordinates (Z up). Returns a copy."""
85
+ out = pc_world.subset(np.ones(len(pc_world), bool))
86
+ cls = np.ones(len(out), np.uint8)
87
+ noise = noise_mask(out.xyz)
88
+ cls[noise] = NOISE
89
+ good = ~noise
90
+ is_ground, hag = ground_mask(out.xyz[good], cell=cell)
91
+ g_idx = np.nonzero(good)[0]
92
+ cls[g_idx[is_ground]] = GROUND
93
+ above = np.zeros(len(out), bool)
94
+ above[g_idx[~is_ground]] = True
95
+ full_hag = np.zeros(len(out))
96
+ full_hag[g_idx] = hag
97
+ if vegetation and out.rgb is not None:
98
+ veg = above & (excess_green(out.rgb) > exg_threshold)
99
+ cls[veg & (full_hag < 0.5)] = LOW_VEG
100
+ cls[veg & (full_hag >= 0.5) & (full_hag < 2.0)] = MED_VEG
101
+ cls[veg & (full_hag >= 2.0)] = HIGH_VEG
102
+ above &= ~veg
103
+ if structures and above.any():
104
+ # solid, non-green masses standing above the ground surface (walls, pillars, collapses)
105
+ cand = np.nonzero(above & (full_hag >= structure_min_height))[0]
106
+ if len(cand) > 20:
107
+ cls[cand] = MASONRY
108
+ out.classification = cls
109
+ out.labels = {**{c: n for c, n in ARCHAEO_CLASSES.items()}, **out.labels}
110
+ return out
111
+
112
+
113
+ def classify_chunk(chunk, **kwargs) -> PointCloud:
114
+ """Classify the chunk dense cloud in place (world frame used for "up")."""
115
+ from ..engine.dem import ensure_level
116
+
117
+ ensure_level(chunk)
118
+ pc = chunk.point_cloud
119
+ if pc is None:
120
+ raise RuntimeError("build the point cloud first")
121
+ world = pc.transformed(chunk.transform)
122
+ classified = classify(world, **kwargs)
123
+ pc.classification = classified.classification
124
+ chunk.point_cloud = pc
125
+ chunk.meta.setdefault("classes", {}).update({str(k): v for k, v in classified.labels.items()})
126
+ return pc
127
+
128
+
129
+ def assign_class_by_polygons(chunk, units, class_code: int | None = None, base_code: int = 100) -> dict[str, int]:
130
+ """Give points inside each unit polygon (plan view) a class. Without ``class_code``
131
+ each unit gets its own code (``base_code``, ``base_code + 1``...). Returns US -> code."""
132
+ from shapely import contains_xy
133
+
134
+ pc = chunk.point_cloud
135
+ world = chunk.to_world(pc.xyz)
136
+ mapping = {}
137
+ for i, u in enumerate(units):
138
+ code = class_code if class_code is not None else base_code + i
139
+ inside = contains_xy(u.geometry, world[:, 0], world[:, 1])
140
+ pc.classification[inside] = code
141
+ mapping[str(u.us)] = code
142
+ chunk.meta.setdefault("classes", {})[str(code)] = f"{u.tipo} {u.us}"
143
+ chunk.point_cloud = pc
144
+ return mapping
145
+
146
+
147
+ def class_statistics(pc: PointCloud) -> dict[int, int]:
148
+ codes, counts = np.unique(pc.classification, return_counts=True)
149
+ return {int(c): int(n) for c, n in zip(codes, counts)}
@@ -0,0 +1,238 @@
1
+ """Segment Anything 2.1 (SAM 2.1) for orthomosaics, photos and point clouds.
2
+
3
+ SAM 2.1 is optional: ``pip install torch sam2`` (or the ``[sam]`` extra). Weights
4
+ are downloaded from the Hugging Face hub on first use (``facebook/sam2.1-hiera-*``)
5
+ or loaded from a local checkpoint. Apple Silicon (MPS), CUDA and CPU are supported.
6
+
7
+ Any object with the same three methods (``segment_points``, ``segment_box``,
8
+ ``segment_auto``) can replace :class:`Sam2Segmenter`; this keeps the pipeline
9
+ testable and lets other models (e.g. YOLOE, SAM 3) plug in.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ from pathlib import Path
14
+ from typing import Protocol
15
+
16
+ import numpy as np
17
+
18
+ from ..archaeo.stratigraphy import UnitPolygon, mask_to_polygons, orthomosaic_window
19
+
20
+ MODELS = {
21
+ "tiny": "facebook/sam2.1-hiera-tiny",
22
+ "small": "facebook/sam2.1-hiera-small",
23
+ "base_plus": "facebook/sam2.1-hiera-base-plus",
24
+ "large": "facebook/sam2.1-hiera-large",
25
+ }
26
+
27
+
28
+ class Segmenter(Protocol):
29
+ def segment_points(self, image: np.ndarray, points: np.ndarray, labels: np.ndarray) -> np.ndarray: ...
30
+
31
+ def segment_box(self, image: np.ndarray, box: np.ndarray) -> np.ndarray: ...
32
+
33
+ def segment_auto(self, image: np.ndarray) -> list[np.ndarray]: ...
34
+
35
+
36
+ def best_device() -> str:
37
+ import torch
38
+
39
+ if torch.cuda.is_available():
40
+ return "cuda"
41
+ if getattr(torch.backends, "mps", None) and torch.backends.mps.is_available():
42
+ return "mps"
43
+ return "cpu"
44
+
45
+
46
+ class Sam2Segmenter:
47
+ """Thin wrapper around ``sam2`` image predictor and automatic mask generator."""
48
+
49
+ def __init__(self, model: str = "small", checkpoint: str | Path | None = None, config: str | None = None,
50
+ device: str | None = None):
51
+ try:
52
+ import torch # noqa: F401
53
+ from sam2.sam2_image_predictor import SAM2ImagePredictor
54
+ except ImportError as e:
55
+ raise ImportError("SAM 2.1 needs PyTorch and sam2: pip install 'pyarchinit-photogrammetry[sam]'") from e
56
+ self.device = device or best_device()
57
+ if checkpoint:
58
+ from sam2.build_sam import build_sam2
59
+
60
+ cfg = config or f"configs/sam2.1/sam2.1_hiera_{ {'tiny': 't', 'small': 's', 'base_plus': 'b+', 'large': 'l'}[model] }.yaml"
61
+ self._model = build_sam2(cfg, str(checkpoint), device=self.device)
62
+ self.predictor = SAM2ImagePredictor(self._model)
63
+ else:
64
+ self.predictor = SAM2ImagePredictor.from_pretrained(MODELS.get(model, model), device=self.device)
65
+ self._model = self.predictor.model
66
+ self._image_id = None
67
+
68
+ def _set(self, image: np.ndarray) -> None:
69
+ key = (image.shape, int(image[::97, ::97].sum()))
70
+ if key != self._image_id:
71
+ self.predictor.set_image(image)
72
+ self._image_id = key
73
+
74
+ def segment_points(self, image, points, labels):
75
+ import torch
76
+
77
+ self._set(image)
78
+ with torch.inference_mode():
79
+ masks, scores, _ = self.predictor.predict(point_coords=np.asarray(points, float),
80
+ point_labels=np.asarray(labels, int), multimask_output=True)
81
+ return masks[int(np.argmax(scores))].astype(bool)
82
+
83
+ def segment_box(self, image, box):
84
+ import torch
85
+
86
+ self._set(image)
87
+ with torch.inference_mode():
88
+ masks, scores, _ = self.predictor.predict(box=np.asarray(box, float)[None], multimask_output=False)
89
+ return masks[0].astype(bool)
90
+
91
+ def segment_auto(self, image, points_per_side: int = 32, min_area: int = 500):
92
+ import torch
93
+ from sam2.automatic_mask_generator import SAM2AutomaticMaskGenerator
94
+
95
+ gen = SAM2AutomaticMaskGenerator(self._model, points_per_side=points_per_side, min_mask_region_area=min_area)
96
+ with torch.inference_mode():
97
+ res = gen.generate(image)
98
+ return [r["segmentation"].astype(bool) for r in sorted(res, key=lambda r: -r["area"])]
99
+
100
+
101
+ # ------------------------------------------------------------ orthomosaic
102
+ def _world_to_pixel(transform, xy: np.ndarray) -> np.ndarray:
103
+ """World XY -> fractional (col, row) for north-up rasters (pixel corners on integers)."""
104
+ xy = np.asarray(xy, float)
105
+ return np.column_stack([(xy[:, 0] - transform.c) / transform.a, (xy[:, 1] - transform.f) / transform.e])
106
+
107
+
108
+ def segment_orthomosaic(chunk, segmenter: Segmenter, points=None, labels=None, box=None, window: int = 2048,
109
+ simplify: float | None = None, us: str | None = None, area: str | None = None) -> UnitPolygon:
110
+ """Segment one unit on the orthomosaic from world-coordinate prompts.
111
+
112
+ ``points``: (N,2) world XY with ``labels`` (1 = inside, 0 = outside); or ``box`` =
113
+ (xmin, ymin, xmax, ymax) world. Only a window around the prompts is processed.
114
+ """
115
+ from rasterio.windows import Window
116
+ from rasterio.windows import transform as win_transform
117
+
118
+ import rasterio
119
+
120
+ path = chunk.orthomosaic_path
121
+ if path is None:
122
+ raise RuntimeError("build the orthomosaic first")
123
+ with rasterio.open(path) as src:
124
+ prompt_xy = np.asarray(points if points is not None else np.reshape(box, (2, 2)), float)
125
+ px = _world_to_pixel(src.transform, prompt_xy)
126
+ c0 = int(np.clip(px[:, 0].mean() - window / 2, 0, max(src.width - window, 0)))
127
+ r0 = int(np.clip(px[:, 1].mean() - window / 2, 0, max(src.height - window, 0)))
128
+ w = Window(c0, r0, min(window, src.width), min(window, src.height))
129
+ img = np.moveaxis(src.read([1, 2, 3], window=w), 0, -1).copy()
130
+ wtr = win_transform(w, src.transform)
131
+ res = src.res[0]
132
+ local = px - [c0, r0] - 0.5 # corner-based -> pixel-centre coordinates expected by SAM
133
+ if points is not None:
134
+ mask = segmenter.segment_points(img, local, np.ones(len(local)) if labels is None else labels)
135
+ else:
136
+ mask = segmenter.segment_box(img, local.ravel())
137
+ geom = mask_to_polygons(mask, wtr, simplify=simplify if simplify is not None else 2 * res,
138
+ min_area=(10 * res) ** 2)
139
+ return UnitPolygon(geom, us=us, area=area)
140
+
141
+
142
+ def auto_units(chunk, segmenter: Segmenter, max_side: int = 2048, simplify: float | None = None,
143
+ min_area_m2: float = 0.01) -> list[UnitPolygon]:
144
+ """Automatic segmentation of the whole orthomosaic into candidate units (unlabelled)."""
145
+ import cv2
146
+ from affine import Affine
147
+
148
+ img, alpha, tr, _ = orthomosaic_window(chunk.orthomosaic_path)
149
+ f = max(1.0, max(img.shape[:2]) / max_side)
150
+ small = cv2.resize(img, (int(img.shape[1] / f), int(img.shape[0] / f)), interpolation=cv2.INTER_AREA) if f > 1 else img
151
+ sx, sy = img.shape[1] / small.shape[1], img.shape[0] / small.shape[0]
152
+ tr_small = tr @ Affine.scale(sx, sy)
153
+ res = abs(tr.a) * max(sx, sy)
154
+ units = []
155
+ a_small = cv2.resize(alpha, (small.shape[1], small.shape[0]), interpolation=cv2.INTER_NEAREST) > 0
156
+ for m in segmenter.segment_auto(small):
157
+ m = m & a_small
158
+ if m.sum() * res * res < min_area_m2:
159
+ continue
160
+ geom = mask_to_polygons(m, tr_small, simplify=simplify if simplify is not None else 2 * res)
161
+ if not geom.is_empty:
162
+ units.append(UnitPolygon(geom))
163
+ return units
164
+
165
+
166
+ # ------------------------------------------------------------- photos -> cloud
167
+ def points_in_masks(chunk, masks: dict[int, np.ndarray], min_votes: float = 0.5) -> np.ndarray:
168
+ """Boolean selection of dense points falling inside photo masks (see :func:`classify_from_masks`)."""
169
+ from ..engine.visibility import project_valid, raycaster_for_chunk
170
+
171
+ pc = chunk.point_cloud
172
+ if pc is None:
173
+ raise RuntimeError("build the point cloud first")
174
+ rc = raycaster_for_chunk(chunk)
175
+ inside = np.zeros(len(pc), np.int32)
176
+ seen = np.zeros(len(pc), np.int32)
177
+ for key, mask in masks.items():
178
+ cam = chunk.camera(key)
179
+ px, _, ok = project_valid(chunk, cam, pc.xyz, margin=1)
180
+ idx = np.nonzero(ok)[0]
181
+ if rc is not None and len(idx):
182
+ idx = idx[rc.visible_from(cam.center, pc.xyz[idx], rel_tol=5e-3)]
183
+ cols = np.clip((px[idx, 0] - 0.5).round().astype(int), 0, mask.shape[1] - 1) # COLMAP -> array index
184
+ rows = np.clip((px[idx, 1] - 0.5).round().astype(int), 0, mask.shape[0] - 1)
185
+ seen[idx] += 1
186
+ inside[idx[mask[rows, cols]]] += 1
187
+ return (seen > 0) & (inside >= np.maximum(1, np.ceil(min_votes * seen)))
188
+
189
+
190
+ def classify_from_masks(chunk, masks: dict[int, np.ndarray], class_code: int, min_votes: float = 0.5,
191
+ label: str | None = None) -> int:
192
+ """Assign ``class_code`` to dense points that fall inside the photo masks.
193
+
194
+ ``masks`` maps camera key -> boolean mask (image size). A point is labelled when,
195
+ among the photos where it is visible, at least ``min_votes`` (fraction) put it
196
+ inside the mask. Visibility uses the mesh when available. Returns the count.
197
+ """
198
+ hit = points_in_masks(chunk, masks, min_votes)
199
+ pc = chunk.point_cloud
200
+ pc.classification[hit] = class_code
201
+ chunk.point_cloud = pc
202
+ if label:
203
+ chunk.meta.setdefault("classes", {})[str(class_code)] = label
204
+ return int(hit.sum())
205
+
206
+
207
+ def segment_photo_and_classify(chunk, segmenter: Segmenter, camera, points_px, class_code: int,
208
+ labels=None, propagate: bool = True, label: str | None = None) -> int:
209
+ """Click on one photo -> SAM mask -> classify the corresponding dense points.
210
+
211
+ ``points_px`` are pixel coordinates in the photo (COLMAP convention). With
212
+ ``propagate=True`` the selection is reprojected into the other photos that see
213
+ it, segmented there with box prompts, and all masks vote together.
214
+ """
215
+ import cv2
216
+
217
+ from ..engine.visibility import project_valid
218
+
219
+ img = cv2.cvtColor(cv2.imread(str(camera.path), cv2.IMREAD_COLOR | cv2.IMREAD_IGNORE_ORIENTATION), cv2.COLOR_BGR2RGB)
220
+ pts = np.asarray(points_px, float) - 0.5 # COLMAP -> array coordinates for SAM
221
+ mask = segmenter.segment_points(img, pts, np.ones(len(pts)) if labels is None else labels)
222
+ masks = {camera.key: mask}
223
+ if propagate:
224
+ sel = points_in_masks(chunk, masks)
225
+ if sel.any():
226
+ xyz = chunk.point_cloud.xyz[sel]
227
+ for cam in chunk.aligned_cameras:
228
+ if cam.key == camera.key:
229
+ continue
230
+ px, _, ok = project_valid(chunk, cam, xyz)
231
+ if ok.sum() < 30:
232
+ continue
233
+ p = px[ok] - 0.5
234
+ box = np.r_[np.percentile(p, 1, axis=0), np.percentile(p, 99, axis=0)]
235
+ other = cv2.cvtColor(cv2.imread(str(cam.path), cv2.IMREAD_COLOR | cv2.IMREAD_IGNORE_ORIENTATION),
236
+ cv2.COLOR_BGR2RGB)
237
+ masks[cam.key] = segmenter.segment_box(other, box)
238
+ return classify_from_masks(chunk, masks, class_code, label=label)
@@ -0,0 +1,31 @@
1
+ """Application state shared by scripts, the command line and the GUI.
2
+
3
+ Scripts can work on the open project through ``app.document`` exactly as the
4
+ GUI does::
5
+
6
+ import pyarchinit_photogrammetry as ppg
7
+ chunk = ppg.app.document.chunk
8
+ chunk.build_dem(resolution=0.01)
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from .core.document import Document
13
+ from .core.progress import ProgressCallback, print_progress
14
+
15
+
16
+ class Application:
17
+ def __init__(self):
18
+ self.document = Document()
19
+ self.progress: ProgressCallback | None = print_progress
20
+ self.settings: dict = {}
21
+
22
+ def open_document(self, path) -> Document:
23
+ self.document = Document.open(path)
24
+ return self.document
25
+
26
+ def new_document(self) -> Document:
27
+ self.document = Document()
28
+ return self.document
29
+
30
+
31
+ app = Application()