11# PyQGIS / PyQt imports
2-
2+ from osgeo import gdal
33from qgis .core import (
44 QgsRaster ,
55 QgsFields ,
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
1922from shapely .geometry import Point , MultiPoint , LineString , MultiLineString , Polygon , MultiPolygon
2023from shapely .wkb import loads as wkb_loads
2124import pandas as pd
2225import geopandas as gpd
2326import 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
2789def 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
103249def 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+
458696from qgis .core import NULL
459697from 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