Skip to content

Commit d2d2925

Browse files
Processing/thickness calculator (#23)
* feat: thickness calculator tool * feat: raster and dataframe handling * fix: remove unused QgsSettings * fix: updated tearDownClass * fix: add pass statement * update sampler tests * update sampler --------- Co-authored-by: Noelle Cheng <noelle.cheng@monash.edu>
1 parent 5dbcfca commit d2d2925

6 files changed

Lines changed: 438 additions & 106 deletions

File tree

m2l/main/vectorLayerWrapper.py

Lines changed: 297 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# PyQGIS / PyQt imports
2-
2+
from osgeo import gdal
33
from qgis.core import (
44
QgsRaster,
55
QgsFields,
@@ -12,17 +12,79 @@
1212
QgsProcessingException,
1313
QgsPoint,
1414
QgsPointXY,
15+
QgsProject,
16+
QgsCoordinateTransform,
17+
QgsRasterLayer
1518
)
1619

17-
from qgis.PyQt.QtCore import QVariant, QDateTime, QVariant
18-
20+
from qgis.PyQt.QtCore import QVariant, QDateTime
21+
from qgis import processing
1922
from shapely.geometry import Point, MultiPoint, LineString, MultiLineString, Polygon, MultiPolygon
2023
from shapely.wkb import loads as wkb_loads
2124
import pandas as pd
2225
import geopandas as gpd
2326
import numpy as np
24-
27+
import tempfile
28+
import os
29+
30+
def qgsRasterToGdalDataset(rlayer: QgsRasterLayer):
31+
"""
32+
Convert a QgsRasterLayer to an osgeo.gdal.Dataset (read-only).
33+
If the raster is non-file-based (e.g. WMS/WCS/virtual), we create a temp GeoTIFF via gdal:translate.
34+
Returns a gdal.Dataset or None.
35+
"""
36+
if rlayer is None or not rlayer.isValid():
37+
return None
38+
39+
# Try direct open on file-backed layers
40+
candidates = []
41+
try:
42+
candidates.append(rlayer.source())
43+
except Exception:
44+
pass
45+
try:
46+
if rlayer.dataProvider():
47+
candidates.append(rlayer.dataProvider().dataSourceUri())
48+
except Exception:
49+
pass
2550

51+
tried = set()
52+
for uri in candidates:
53+
if not uri:
54+
continue
55+
if uri in tried:
56+
continue
57+
tried.add(uri)
58+
59+
# Strip QGIS pipe options: "path.tif|layername=..." → "path.tif"
60+
base_uri = uri.split("|")[0]
61+
62+
# Some providers store “SUBDATASET:” URIs; gdal.OpenEx can usually handle them directly.
63+
ds = gdal.OpenEx(base_uri, gdal.OF_RASTER | gdal.OF_READONLY)
64+
if ds is not None:
65+
return ds
66+
67+
# If we’re here, it’s likely non-file-backed. Export to a temp GeoTIFF.
68+
tmpdir = tempfile.gettempdir()
69+
tmp_path = os.path.join(tmpdir, f"m2l_dtm_{rlayer.id()}.tif")
70+
71+
# Use GDAL Translate via QGIS processing (avoids CRS pitfalls)
72+
processing.run(
73+
"gdal:translate",
74+
{
75+
"INPUT": rlayer, # QGIS accepts the layer object here
76+
"TARGET_CRS": None,
77+
"NODATA": None,
78+
"COPY_SUBDATASETS": False,
79+
"OPTIONS": "",
80+
"EXTRA": "",
81+
"DATA_TYPE": 0, # Use input data type
82+
"OUTPUT": tmp_path,
83+
}
84+
)
85+
86+
ds = gdal.OpenEx(tmp_path, gdal.OF_RASTER | gdal.OF_READONLY)
87+
return ds
2688

2789
def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame:
2890
if layer is None:
@@ -42,63 +104,147 @@ def qgsLayerToGeoDataFrame(layer) -> gpd.GeoDataFrame:
42104
data[f.name()].append(str(feature[f.name()]))
43105
else:
44106
data[f.name()].append(feature[f.name()])
45-
return gpd.GeoDataFrame(data, crs=layer.crs().authid())
46-
47-
def qgsLayerToDataFrame(layer, dtm) -> pd.DataFrame:
48-
"""Convert a vector layer to a pandas DataFrame
49-
samples the geometry using either points or the vertices of the lines
50-
51-
:param layer: _description_
52-
:type layer: _type_
53-
:param dtm: Digital Terrain Model to evaluate Z values
54-
:type dtm: _type_ or None
55-
:return: the dataframe object
56-
:rtype: pd.DataFrame
107+
return gpd.GeoDataFrame(data, crs=layer.sourceCrs().authid())
108+
109+
def qgsLayerToDataFrame(src, dtm=None) -> pd.DataFrame:
57110
"""
58-
if layer is None:
111+
Convert a vector layer or processing feature source to a pandas DataFrame.
112+
Samples geometry using points or vertices of lines/polygons.
113+
Optionally samples Z from a DTM raster.
114+
115+
:param src: QgsVectorLayer or QgsProcessingFeatureSource
116+
:param dtm: QgsRasterLayer or None
117+
:return: pd.DataFrame with columns: X, Y, Z, and all layer fields
118+
"""
119+
120+
if src is None:
59121
return None
60-
fields = layer.fields()
61-
data = {}
62-
data['X'] = []
63-
data['Y'] = []
64-
data['Z'] = []
65-
66-
for field in fields:
67-
data[field.name()] = []
68-
for feature in layer.getFeatures():
69-
geom = feature.geometry()
70-
points = []
71-
if geom.isMultipart():
72-
if geom.type() == QgsWkbTypes.PointGeometry:
73-
points = geom.asMultiPoint()
74-
elif geom.type() == QgsWkbTypes.LineGeometry:
122+
123+
# --- Resolve fields and source CRS (works for both layer and feature source) ---
124+
fields = src.fields() if hasattr(src, "fields") else None
125+
if fields is None:
126+
# Fallback: take fields from first feature if needed
127+
feat_iter = src.getFeatures()
128+
try:
129+
first = next(feat_iter)
130+
except StopIteration:
131+
return pd.DataFrame(columns=["X", "Y", "Z"])
132+
fields = first.fields()
133+
# Rewind iterator by building a new one
134+
feats = [first] + list(src.getFeatures())
135+
else:
136+
feats = src.getFeatures()
137+
138+
# Get source CRS
139+
if hasattr(src, "crs"):
140+
src_crs = src.crs()
141+
elif hasattr(src, "sourceCrs"):
142+
src_crs = src.sourceCrs()
143+
else:
144+
src_crs = None
145+
146+
# --- Prepare optional transform to DTM CRS for sampling ---
147+
to_dtm = None
148+
if dtm is not None and src_crs is not None and dtm.crs().isValid() and src_crs.isValid():
149+
if src_crs != dtm.crs():
150+
to_dtm = QgsCoordinateTransform(src_crs, dtm.crs(), QgsProject.instance())
151+
152+
# --- Helper: sample Z from DTM (returns float or -9999) ---
153+
def sample_dtm_xy(x, y):
154+
if dtm is None:
155+
return 0.0
156+
# Transform coordinate if needed
157+
if to_dtm is not None:
158+
try:
159+
from qgis.core import QgsPointXY
160+
x, y = to_dtm.transform(QgsPointXY(x, y))
161+
except Exception:
162+
return -9999.0
163+
from qgis.core import QgsPointXY
164+
ident = dtm.dataProvider().identify(QgsPointXY(x, y), QgsRaster.IdentifyFormatValue)
165+
if not ident.isValid():
166+
return -9999.0
167+
res = ident.results()
168+
if not res:
169+
return -9999.0
170+
# take first band value (band keys are 1-based)
171+
try:
172+
# Prefer band 1 if present
173+
return float(res.get(1, next(iter(res.values()))))
174+
except Exception:
175+
return -9999.0
176+
177+
# --- Geometry -> list of vertices (QgsPoint or QgsPointXY) ---
178+
def vertices_from_geometry(geom):
179+
if geom is None or geom.isEmpty():
180+
return []
181+
gtype = QgsWkbTypes.geometryType(geom.wkbType())
182+
is_multi = QgsWkbTypes.isMultiType(geom.wkbType())
183+
184+
if gtype == QgsWkbTypes.PointGeometry:
185+
if is_multi:
186+
return list(geom.asMultiPoint())
187+
else:
188+
return [geom.asPoint()]
189+
190+
elif gtype == QgsWkbTypes.LineGeometry:
191+
pts = []
192+
if is_multi:
75193
for line in geom.asMultiPolyline():
76-
points.extend(line)
77-
# points = geom.asMultiPolyline()[0]
78-
else:
79-
if geom.type() == QgsWkbTypes.PointGeometry:
80-
points = [geom.asPoint()]
81-
elif geom.type() == QgsWkbTypes.LineGeometry:
82-
points = geom.asPolyline()
83-
84-
for p in points:
85-
data['X'].append(p.x())
86-
data['Y'].append(p.y())
87-
if dtm is not None:
88-
# Replace with your coordinates
89-
90-
# Extract the value at the point
91-
z_value = dtm.dataProvider().identify(p, QgsRaster.IdentifyFormatValue)
92-
if z_value.isValid():
93-
z_value = z_value.results()[1]
94-
else:
95-
z_value = -9999
96-
data['Z'].append(z_value)
97-
if dtm is None:
98-
data['Z'].append(0)
99-
for field in fields:
100-
data[field.name()].append(feature[field.name()])
101-
return pd.DataFrame(data)
194+
pts.extend(line)
195+
else:
196+
pts.extend(geom.asPolyline())
197+
return pts
198+
199+
elif gtype == QgsWkbTypes.PolygonGeometry:
200+
pts = []
201+
if is_multi:
202+
mpoly = geom.asMultiPolygon()
203+
for poly in mpoly:
204+
for ring in poly: # exterior + interior rings
205+
pts.extend(ring)
206+
else:
207+
poly = geom.asPolygon()
208+
for ring in poly:
209+
pts.extend(ring)
210+
return pts
211+
212+
# Other geometry types not handled
213+
return []
214+
215+
# --- Build rows safely (one dict per sampled point) ---
216+
rows = []
217+
field_names = [f.name() for f in fields]
218+
219+
for f in feats:
220+
geom = f.geometry()
221+
pts = vertices_from_geometry(geom)
222+
223+
if not pts:
224+
# If you want to keep attribute rows even when no vertices: uncomment below
225+
# row = {name: f[name] for name in field_names}
226+
# row.update({"X": None, "Y": None, "Z": None})
227+
# rows.append(row)
228+
continue
229+
230+
# Cache attributes once per feature and reuse for each sampled point
231+
base_attrs = {name: f[name] for name in field_names}
232+
233+
for p in pts:
234+
# QgsPoint vs QgsPointXY both have x()/y()
235+
x, y = float(p.x()), float(p.y())
236+
z = sample_dtm_xy(x, y)
237+
238+
row = {"X": x, "Y": y, "Z": z}
239+
row.update(base_attrs)
240+
rows.append(row)
241+
242+
# Create DataFrame; if empty, return with expected columns
243+
if not rows:
244+
cols = ["X", "Y", "Z"] + field_names
245+
return pd.DataFrame(columns=cols)
246+
247+
return pd.DataFrame.from_records(rows)
102248

103249
def GeoDataFrameToQgsLayer(qgs_algorithm, geodataframe, parameters, context, output_key, feedback=None):
104250
"""
@@ -455,6 +601,98 @@ def dataframeToQgsLayer(
455601
feedback.setProgress(100)
456602
return sink, sink_id
457603

604+
def matrixToDict(matrix, headers=("minx", "miny", "maxx", "maxy")) -> dict:
605+
"""
606+
Convert a QgsProcessingParameterMatrix value to a dict with float values.
607+
Accepts: [[minx,miny,maxx,maxy]] or [minx,miny,maxx,maxy].
608+
Raises a clear error if an enum index (int) was passed by mistake.
609+
"""
610+
# Guard: common mistake → using parameterAsEnum
611+
if isinstance(matrix, int):
612+
raise QgsProcessingException(
613+
"Bounding Box was read with parameterAsEnum (got an int). "
614+
"Use parameterAsMatrix for QgsProcessingParameterMatrix."
615+
)
616+
617+
if matrix is None:
618+
raise QgsProcessingException("Bounding box matrix is None.")
619+
620+
# Allow empty string from settings/defaults
621+
if isinstance(matrix, str) and not matrix.strip():
622+
raise QgsProcessingException("Bounding box matrix is empty.")
623+
624+
# Accept single-row matrix or flat list
625+
if isinstance(matrix, (list, tuple)):
626+
if matrix and isinstance(matrix[0], (list, tuple)):
627+
row = matrix[0]
628+
else:
629+
row = matrix
630+
else:
631+
# last resort: try comma-separated string "minx,miny,maxx,maxy"
632+
if isinstance(matrix, str) and "," in matrix:
633+
row = [v.strip() for v in matrix.split(",")]
634+
else:
635+
raise QgsProcessingException(f"Unrecognized bounding box value: {type(matrix)}")
636+
637+
if len(row) < 4:
638+
raise QgsProcessingException(f"Bounding box needs 4 numbers, got {len(row)}: {row}")
639+
640+
def _to_float(v):
641+
if isinstance(v, str):
642+
v = v.strip()
643+
return float(v)
644+
645+
vals = list(map(_to_float, row[:4]))
646+
bbox = dict(zip(headers, vals))
647+
648+
if not (bbox["minx"] < bbox["maxx"] and bbox["miny"] < bbox["maxy"]):
649+
raise QgsProcessingException(f"Invalid bounding box: {bbox} (expect minx<maxx and miny<maxy)")
650+
651+
return bbox
652+
653+
def dataframeToQgsTable(self, df, parameters, context, feedback, param_name):
654+
if df is None or df.empty:
655+
raise QgsProcessingException("Empty DataFrame.")
656+
657+
# 1) Field schema
658+
fields = QgsFields()
659+
def to_qvariant_type(s):
660+
if pd.api.types.is_bool_dtype(s): return QVariant.Bool
661+
if pd.api.types.is_integer_dtype(s): return QVariant.LongLong
662+
if pd.api.types.is_float_dtype(s): return QVariant.Double
663+
return QVariant.String
664+
665+
for col in df.columns:
666+
fields.append(QgsField(str(col), to_qvariant_type(df[col])))
667+
668+
# 2) CRS: use project CRS if available, otherwise empty CRS
669+
crs = context.project().crs() if context and context.project() else QgsCoordinateReferenceSystem()
670+
671+
sink, dest_id = self.parameterAsSink(
672+
parameters, param_name, context,
673+
fields, QgsWkbTypes.NoGeometry, crs
674+
)
675+
if sink is None:
676+
raise QgsProcessingException("Canot create output table (sink=None).")
677+
678+
# 3) Write features
679+
for _, row in df.iterrows():
680+
f = QgsFeature(fields)
681+
attrs = []
682+
for col in df.columns:
683+
v = row[col]
684+
# Convert numpy scalars to native Python types
685+
if isinstance(v, (np.generic,)):
686+
v = v.item()
687+
# NaN/NaT → None
688+
if pd.isna(v):
689+
v = None
690+
attrs.append(v)
691+
f.setAttributes(attrs)
692+
sink.addFeature(f)
693+
694+
return sink, dest_id
695+
458696
from qgis.core import NULL
459697
from PyQt5.QtCore import QVariant
460698

@@ -486,3 +724,4 @@ def qvariantToFloat(f, field_name):
486724
return float(val)
487725
except Exception:
488726
return None
727+

0 commit comments

Comments
 (0)