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.
- pyarchinit_photogrammetry/__init__.py +30 -0
- pyarchinit_photogrammetry/__main__.py +3 -0
- pyarchinit_photogrammetry/ai/__init__.py +1 -0
- pyarchinit_photogrammetry/ai/classify.py +149 -0
- pyarchinit_photogrammetry/ai/sam.py +238 -0
- pyarchinit_photogrammetry/app.py +31 -0
- pyarchinit_photogrammetry/archaeo/__init__.py +1 -0
- pyarchinit_photogrammetry/archaeo/pottery.py +278 -0
- pyarchinit_photogrammetry/archaeo/sections.py +213 -0
- pyarchinit_photogrammetry/archaeo/stratigraphy.py +82 -0
- pyarchinit_photogrammetry/archaeo/terrain.py +126 -0
- pyarchinit_photogrammetry/bim/__init__.py +1 -0
- pyarchinit_photogrammetry/bim/ifc.py +143 -0
- pyarchinit_photogrammetry/cli.py +324 -0
- pyarchinit_photogrammetry/core/__init__.py +9 -0
- pyarchinit_photogrammetry/core/camera.py +169 -0
- pyarchinit_photogrammetry/core/chunk.py +434 -0
- pyarchinit_photogrammetry/core/crs.py +56 -0
- pyarchinit_photogrammetry/core/document.py +97 -0
- pyarchinit_photogrammetry/core/geometry.py +141 -0
- pyarchinit_photogrammetry/core/marker.py +77 -0
- pyarchinit_photogrammetry/core/pointcloud.py +219 -0
- pyarchinit_photogrammetry/core/progress.py +50 -0
- pyarchinit_photogrammetry/engine/__init__.py +1 -0
- pyarchinit_photogrammetry/engine/dem.py +179 -0
- pyarchinit_photogrammetry/engine/dense.py +378 -0
- pyarchinit_photogrammetry/engine/meshing.py +144 -0
- pyarchinit_photogrammetry/engine/ortho.py +157 -0
- pyarchinit_photogrammetry/engine/sfm.py +245 -0
- pyarchinit_photogrammetry/engine/texturing.py +177 -0
- pyarchinit_photogrammetry/engine/visibility.py +129 -0
- pyarchinit_photogrammetry/io/__init__.py +1 -0
- pyarchinit_photogrammetry/io/exif.py +59 -0
- pyarchinit_photogrammetry/io/exports.py +100 -0
- pyarchinit_photogrammetry/io/report.py +91 -0
- pyarchinit_photogrammetry/markers/__init__.py +1 -0
- pyarchinit_photogrammetry/markers/gcp.py +239 -0
- pyarchinit_photogrammetry/markers/targets.py +217 -0
- pyarchinit_photogrammetry/pyarchinit/__init__.py +8 -0
- pyarchinit_photogrammetry/pyarchinit/bridge.py +259 -0
- pyarchinit_photogrammetry/pyarchinit/config.py +92 -0
- pyarchinit_photogrammetry/pyarchinit/database.py +257 -0
- pyarchinit_photogrammetry/pyarchinit/media.py +193 -0
- pyarchinit_photogrammetry/testing/__init__.py +0 -0
- pyarchinit_photogrammetry/testing/synthetic.py +167 -0
- pyarchinit_photogrammetry-0.1.0.dist-info/METADATA +200 -0
- pyarchinit_photogrammetry-0.1.0.dist-info/RECORD +51 -0
- pyarchinit_photogrammetry-0.1.0.dist-info/WHEEL +5 -0
- pyarchinit_photogrammetry-0.1.0.dist-info/entry_points.txt +2 -0
- pyarchinit_photogrammetry-0.1.0.dist-info/licenses/LICENSE +339 -0
- 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 @@
|
|
|
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()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|