fractex2D_tuto / src /vectorize.py
Ayoub
feat: port FraXtex vectorization stage
fa7174b
Raw History Blame Contribute Delete
4.45 kB
"""Stage 2: vectorize a probability raster into fracture centerlines (GeoPackage).
Ported from FraXtex's `fraxtex_tools/vectorize.py`, which is canonical for this
pipeline (see CLAUDE.md). Kept line-comparable with the plugin; the only changes are
the QGIS parameter plumbing and the output path, which the caller supplies.
Expects the inference convention: **high values mean fracture**.
All distances are given in pixels and multiplied by the raster resolution, so the
same parameter values behave the same on a georeferenced raster and on a plain PNG
(where the resolution is 1 and the units are pixels).
"""
import geopandas as gpd
import numpy as np
import rasterio
import shapely
import skimage.filters as skf
import skimage.morphology as skmp
from rasterio import features
from shapely.affinity import affine_transform
# Keep in the same order as the UI labels.
THRESH_METHODS = ["mean", "adaptive", "otsu", "manual"]
SKEL_METHODS = ["zhang", "lee", "medial_axis"]
def vectorize(
raster_path,
output_path,
feedback,
thresh_method="mean",
manual_val=127,
min_size=142,
skel_method="zhang",
buffer_px=3,
min_branch=10,
simplify_px=10,
):
"""Threshold -> skeletonize -> polygonize -> centerline -> GeoPackage.
Returns `(output_path, feature_count)`.
"""
with rasterio.open(raster_path) as src:
arr = src.read(1)
crs = src.crs
transform = src.transform
res_x = abs(transform.a)
feedback.pushInfo(f"Shape: {arr.shape}, res: {res_x:.6f}")
# 1. Threshold
feedback.setProgress(5)
if thresh_method == "otsu":
threshold = skf.threshold_otsu(arr)
elif thresh_method == "adaptive":
threshold = skf.threshold_local(arr, block_size=51, method="gaussian")
elif thresh_method == "mean":
threshold = skf.threshold_mean(arr)
else:
threshold = manual_val
# An adaptive threshold is a whole array, so summarise rather than print it.
if np.ndim(threshold) == 0:
feedback.pushInfo(f"Method: {thresh_method}, threshold: {threshold:.3f}")
else:
feedback.pushInfo(
f"Method: {thresh_method}, per-pixel threshold "
f"(mean {float(np.mean(threshold)):.3f})"
)
binary = (arr > threshold).astype(np.uint8)
# 2. Remove small objects
feedback.setProgress(15)
binary = skmp.remove_small_objects(binary.astype(bool), min_size=min_size).astype(np.uint8)
# 3. Skeletonize
feedback.setProgress(25)
if skel_method == "zhang":
skel = skmp.skeletonize(binary, method="zhang")
elif skel_method == "lee":
skel = skmp.skeletonize(binary, method="lee")
else:
skel, _ = skmp.medial_axis(binary, return_distance=True)
skel = skel.astype(np.uint8)
# 4. Raster -> vector
feedback.setProgress(40)
polys = [shapely.geometry.shape(c) for c, v in features.shapes(skel, skel > 0.5)]
if not polys:
feedback.pushWarning(
"No fractures detected. Try a lower threshold or a smaller minimum "
"object size."
)
gpd.GeoDataFrame(geometry=[], crs=crs).to_file(output_path, driver="GPKG")
return output_path, 0
gdf = gpd.GeoDataFrame(geometry=polys, crs=crs)
# 5. Affine transform to map coordinates
aff = [transform.a, transform.b, transform.d, transform.e, transform.c, transform.f]
gdf["geometry"] = gdf["geometry"].apply(lambda g: affine_transform(g, aff))
# 6. Buffer -> dissolve -> explode
feedback.setProgress(55)
gdf["geometry"] = gdf.buffer(res_x * buffer_px, join_style="mitre")
gdf = gdf.dissolve().explode()
# 7. Centerline
feedback.setProgress(70)
try:
import pygeoops
gdf["geometry"] = pygeoops.centerline(
gdf["geometry"],
densify_distance=res_x,
min_branch_length=res_x * min_branch,
simplifytolerance=res_x,
)
except ImportError:
feedback.pushInfo("pygeoops not available, skipping centerline.")
# 8. Simplify
feedback.setProgress(85)
gdf = gdf.simplify(res_x * simplify_px, preserve_topology=False)
gdf = gdf[~gdf.is_empty]
gdf = gpd.GeoDataFrame(geometry=list(gdf), crs=crs)
feedback.setProgress(95)
gdf.to_file(output_path, driver="GPKG")
feedback.pushInfo(f"Vectorize done: {len(gdf)} features.")
feedback.setProgress(100)
return output_path, len(gdf)