diff --git a/fvh3t/build.py b/fvh3t/build.py index 7e79ff4..c43032f 100755 --- a/fvh3t/build.py +++ b/fvh3t/build.py @@ -13,7 +13,7 @@ py_files = [fil for fil in glob.glob("**/*.py", recursive=True) if "test/" not in fil and "test\\" not in fil] locales = ["fi"] -profile = "default" +profile = "FVH-3T" ui_files = list(glob.glob("**/*.ui", recursive=True)) resources = list(glob.glob("**/*.qrc", recursive=True)) extra_dirs = ["resources"] diff --git a/fvh3t/core/gate_layer.py b/fvh3t/core/gate_layer.py index fe289e7..a903510 100644 --- a/fvh3t/core/gate_layer.py +++ b/fvh3t/core/gate_layer.py @@ -1,8 +1,9 @@ from __future__ import annotations -from qgis.core import QgsFeatureSource, QgsField, QgsVectorLayer, QgsWkbTypes +from qgis.core import QgsFeature, QgsFeatureSource, QgsField, QgsVectorLayer, QgsWkbTypes +from qgis.PyQt.QtCore import QMetaType, QVariant -from fvh3t.core.exceptions import InvalidLayerException +from fvh3t.core.exceptions import InvalidFeatureException, InvalidLayerException from fvh3t.core.gate import Gate @@ -51,7 +52,41 @@ def create_gates(self) -> None: def gates(self) -> tuple[Gate, ...]: return self.__gates - def is_field_valid(self, field_name: str, *, accepted_types: list[str]) -> bool: + def as_line_layer(self) -> QgsVectorLayer | None: + line_layer = QgsVectorLayer("LineString?crs=3067", "Line Layer", "memory") + + line_layer.startEditing() + + line_layer.addAttribute(QgsField("fid", QVariant.Int)) + line_layer.addAttribute(QgsField("counts_left", QVariant.Bool)) + line_layer.addAttribute(QgsField("counts_right", QVariant.Bool)) + line_layer.addAttribute(QgsField("trajectory_count", QVariant.Int)) + + fields = line_layer.fields() + + for i, gate in enumerate(self.__gates): + feature = QgsFeature(fields) + + feature.setAttributes( + [ + i, + gate.counts_left(), + gate.counts_right(), + gate.trajectory_count(), + ] + ) + feature.setGeometry(gate.geometry()) + + if not feature.isValid(): + raise InvalidFeatureException + + line_layer.addFeature(feature) + + line_layer.commitChanges() + + return line_layer + + def is_field_valid(self, field_name: str, *, accepted_types: list[QMetaType.Type]) -> bool: """ Check that a field 1) exists and 2) has an acceptable type. Leave type list empty to @@ -66,7 +101,7 @@ def is_field_valid(self, field_name: str, *, accepted_types: list[str]) -> bool: return True field: QgsField = self.__layer.fields().field(field_id) - field_type: str = field.displayType() + field_type: QMetaType.Type = field.type() return field_type in accepted_types @@ -86,11 +121,11 @@ def is_valid(self) -> bool: msg = "Layer has no features." raise InvalidLayerException(msg) - if not self.is_field_valid(self.__counts_left_field, accepted_types=["boolean"]): + if not self.is_field_valid(self.__counts_left_field, accepted_types=[QMetaType.Type.Bool]): msg = "Counts left field either not found or of incorrect type." raise InvalidLayerException(msg) - if not self.is_field_valid(self.__counts_right_field, accepted_types=["boolean"]): + if not self.is_field_valid(self.__counts_right_field, accepted_types=[QMetaType.Type.Bool]): msg = "Counts right field either not found or of incorrect type." raise InvalidLayerException(msg) diff --git a/fvh3t/core/trajectory_layer.py b/fvh3t/core/trajectory_layer.py index 2462939..19358d2 100644 --- a/fvh3t/core/trajectory_layer.py +++ b/fvh3t/core/trajectory_layer.py @@ -17,13 +17,27 @@ QgsVectorLayer, QgsWkbTypes, ) -from qgis.PyQt.QtCore import QVariant +from qgis.PyQt.QtCore import QMetaType, QVariant from fvh3t.core.exceptions import InvalidFeatureException, InvalidLayerException, InvalidTrajectoryException from fvh3t.core.trajectory import Trajectory, TrajectoryNode UNIX_TIMESTAMP_UNIT_THRESHOLD = 13 N_NODES_MIN = 2 +QT_NUMERIC_TYPES = [ + QMetaType.Type.Int, + QMetaType.Type.UInt, + QMetaType.Type.Double, + QMetaType.Type.Long, + QMetaType.Type.LongLong, + QMetaType.Type.ULong, + QMetaType.Type.ULongLong, + QMetaType.Type.Short, + QMetaType.Type.UShort, + QMetaType.Type.SChar, + QMetaType.Type.UChar, + QMetaType.Type.Float, +] def digits_in_timestamp_int(num: int): @@ -220,7 +234,7 @@ def as_line_layer(self) -> QgsVectorLayer | None: return line_layer - def is_field_valid(self, field_name: str, *, accepted_types: list[str]) -> bool: + def is_field_valid(self, field_name: str, *, accepted_types: list[QMetaType.Type]) -> bool: """ Check that a field 1) exists and 2) has an acceptable type. Leave type list empty to @@ -235,7 +249,7 @@ def is_field_valid(self, field_name: str, *, accepted_types: list[str]) -> bool: return True field: QgsField = self.__layer.fields().field(field_id) - field_type: str = field.displayType() + field_type: QMetaType.Type = field.type() return field_type in accepted_types @@ -259,19 +273,19 @@ def is_valid(self) -> bool: msg = "Id field either not found or of incorrect type." raise InvalidLayerException(msg) - if not self.is_field_valid(self.__timestamp_field, accepted_types=["integer", "double"]): + if not self.is_field_valid(self.__timestamp_field, accepted_types=QT_NUMERIC_TYPES): msg = "Timestamp field either not found or of incorrect type." raise InvalidLayerException(msg) - if not self.is_field_valid(self.__width_field, accepted_types=["integer", "double"]): + if not self.is_field_valid(self.__width_field, accepted_types=QT_NUMERIC_TYPES): msg = "Width field either not found or of incorrect type." raise InvalidLayerException(msg) - if not self.is_field_valid(self.__length_field, accepted_types=["integer", "double"]): + if not self.is_field_valid(self.__length_field, accepted_types=QT_NUMERIC_TYPES): msg = "Length field either not found or of incorrect type." raise InvalidLayerException(msg) - if not self.is_field_valid(self.__height_field, accepted_types=["integer", "double"]): + if not self.is_field_valid(self.__height_field, accepted_types=QT_NUMERIC_TYPES): msg = "Height field either not found or of incorrect type." raise InvalidLayerException(msg) diff --git a/fvh3t/fvh3t_processing/count_trajectories.py b/fvh3t/fvh3t_processing/count_trajectories.py new file mode 100644 index 0000000..2fd172e --- /dev/null +++ b/fvh3t/fvh3t_processing/count_trajectories.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from typing import Any + +from qgis.core import ( + QgsFeature, + QgsFeatureSink, + QgsProcessing, + QgsProcessingAlgorithm, + QgsProcessingContext, + QgsProcessingFeedback, + QgsProcessingParameterDateTime, + QgsProcessingParameterFeatureSink, + QgsProcessingParameterVectorLayer, + QgsUnitTypes, + QgsVectorLayer, +) +from qgis.PyQt.QtCore import QCoreApplication + +from fvh3t.core.gate_layer import GateLayer +from fvh3t.core.trajectory_layer import TrajectoryLayer + + +class CountTrajectories(QgsProcessingAlgorithm): + INPUT_POINTS = "INPUT_POINTS" + INPUT_LINES = "INPUT_LINES" + START_TIME = "START_TIME" + END_TIME = "END_TIME" + OUTPUT_GATES = "OUTPUT_GATES" + OUTPUT_TRAJECTORIES = "OUTPUT_TRAJECTORIES" + + def __init__(self) -> None: + super().__init__() + + self._name = "count_trajectories" + self._display_name = "Count trajectories" + + def tr(self, string) -> str: + return QCoreApplication.translate("Processing", string) + + def createInstance(self): # noqa N802 + return CountTrajectories() + + def name(self) -> str: + return self._name + + def displayName(self) -> str: # noqa N802 + return self.tr(self._display_name) + + def initAlgorithm(self, config=None): # noqa N802 + self.addParameter( + QgsProcessingParameterVectorLayer( + name=self.INPUT_POINTS, + description="Input point layer", + types=[QgsProcessing.TypeVectorPoint], + ) + ) + + self.addParameter( + QgsProcessingParameterVectorLayer( + name=self.INPUT_LINES, + description="Gates", + types=[QgsProcessing.TypeVectorLine], + ) + ) + + self.addParameter( + QgsProcessingParameterDateTime( + name=self.START_TIME, + description="Start time", + optional=True, + ) + ) + + self.addParameter( + QgsProcessingParameterDateTime( + name=self.END_TIME, + description="End time", + optional=True, + ) + ) + + self.addParameter( + QgsProcessingParameterFeatureSink( + name=self.OUTPUT_GATES, + description="Gates", + type=QgsProcessing.TypeVectorLine, + ) + ) + + self.addParameter( + QgsProcessingParameterFeatureSink( + name=self.OUTPUT_TRAJECTORIES, + description="Trajectories", + type=QgsProcessing.TypeVectorLine, + ) + ) + + def processAlgorithm( # noqa N802 + self, + parameters: dict[str, Any], + context: QgsProcessingContext, + feedback: QgsProcessingFeedback, + ) -> dict: + """ + Here is where the processing itself takes place. + """ + + # Initialize feedback if it is None + if feedback is None: + feedback = QgsProcessingFeedback() + + point_layer = self.parameterAsVectorLayer(parameters, self.INPUT_POINTS, context) + start_time = self.parameterAsDateTime(parameters, self.START_TIME, context) + end_time = self.parameterAsDateTime(parameters, self.END_TIME, context) + + ## CREATE TRAJECTORIES + + feedback.pushInfo(f"Original point layer has {point_layer.featureCount()} features.") + + # Get min and max timestamps from the data + timestamp_field_id = point_layer.fields().indexOf("timestamp") + min_timestamp, max_timestamp = point_layer.minimumAndMaximumValue(timestamp_field_id) + + if min_timestamp is None or max_timestamp is None: + msg = "No valid timestamps found in the point layer." + raise ValueError(msg) + + # Check if start and end times are empty. If yes, use min and max timestamps. If not, convert to unix time. + start_time_unix = start_time.toMSecsSinceEpoch() if start_time.isValid() else min_timestamp + end_time_unix = end_time.toMSecsSinceEpoch() if end_time.isValid() else max_timestamp + + # Check that the set start and end times are in data's range + if not (min_timestamp <= start_time_unix <= max_timestamp) or not ( + min_timestamp <= end_time_unix <= max_timestamp + ): + msg = "Set start and/or end timestamps are out of data's range." + raise ValueError(msg) + + # Prepare a memory layer for filtered points + fields = point_layer.fields() + filtered_layer = QgsVectorLayer("Point?crs=" + point_layer.crs().authid(), "Filtered points", "memory") + filtered_layer.dataProvider().addAttributes(fields) + filtered_layer.updateFields() + + id_count = {} + for feature in point_layer.getFeatures(): + timestamp = feature["timestamp"] + feature_id = feature["id"] + + # Filter features based on timestamp + if start_time_unix <= timestamp <= end_time_unix: + new_feature = QgsFeature(feature) + filtered_layer.dataProvider().addFeature(new_feature) + + # Count ids + if feature_id not in id_count: + id_count[feature_id] = 0 + id_count[feature_id] += 1 + + feedback.pushInfo(f"Filtered {filtered_layer.featureCount()} features based on timestamp range.") + + # Prepare another memory layer for features with non-unique id after filtering time + non_unique_layer = QgsVectorLayer( + "Point?crs=" + point_layer.crs().authid(), "Non-unique filtered points", "memory" + ) + non_unique_layer.dataProvider().addAttributes(fields) + non_unique_layer.updateFields() + + for feature in filtered_layer.getFeatures(): + feature_id = feature["id"] + # Add only features with non-unique id + if id_count.get(feature_id, 0) > 1: + new_feature = QgsFeature(feature) + non_unique_layer.dataProvider().addFeature(new_feature) + + feedback.pushInfo( + f"Final filtered point layer contains {non_unique_layer.featureCount()} features with non-unique IDs." + ) + + trajectory_layer = TrajectoryLayer( + non_unique_layer, + "id", + "timestamp", + "size_x", + "size_y", + "size_z", + QgsUnitTypes.TemporalUnit.TemporalMilliseconds, + ) + + exported_traj_layer = trajectory_layer.as_line_layer() + + if exported_traj_layer is None: + msg = "Trajectory layer is None." + raise ValueError(msg) + + (sink, traj_dest_id) = self.parameterAsSink( + parameters, + self.OUTPUT_TRAJECTORIES, + context, + exported_traj_layer.fields(), + exported_traj_layer.wkbType(), + exported_traj_layer.sourceCrs(), + ) + + for feature in exported_traj_layer.getFeatures(): + sink.addFeature(feature, QgsFeatureSink.FastInsert) + + # CREATE GATES + line_layer = self.parameterAsVectorLayer(parameters, self.INPUT_LINES, context) + + feedback.pushInfo(f"Line layer has {line_layer.featureCount()} features.") + + gate_layer = GateLayer(line_layer, "counts_left", "counts_right") + + gates = gate_layer.gates() + + for gate in gates: + gate.count_trajectories_from_layer(trajectory_layer) + + exported_gate_layer = gate_layer.as_line_layer() + + if exported_gate_layer is None: + msg = "Gate layer is None" + raise ValueError(msg) + + (sink, gate_dest_id) = self.parameterAsSink( + parameters, + self.OUTPUT_GATES, + context, + exported_gate_layer.fields(), + exported_gate_layer.wkbType(), + exported_gate_layer.sourceCrs(), + ) + + for feature in exported_gate_layer.getFeatures(): + sink.addFeature(feature, QgsFeatureSink.FastInsert) + + # Return the results of the algorithm. In this case our only result is + # the feature sink which contains the processed features, but some + # algorithms may return multiple feature sinks, calculated numeric + # statistics, etc. These should all be included in the returned + # dictionary, with keys matching the feature corresponding parameter + # or output names. + return {self.OUTPUT_TRAJECTORIES: traj_dest_id, self.OUTPUT_GATES: gate_dest_id} diff --git a/fvh3t/fvh3t_processing/processing_algorithm.py b/fvh3t/fvh3t_processing/processing_algorithm.py deleted file mode 100644 index acb5683..0000000 --- a/fvh3t/fvh3t_processing/processing_algorithm.py +++ /dev/null @@ -1,195 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from qgis import processing # noqa: TCH002 -from qgis.core import ( - QgsFeatureSink, - QgsProcessing, - QgsProcessingAlgorithm, - QgsProcessingContext, - QgsProcessingFeedback, - QgsProcessingParameterFeatureSink, - QgsProcessingParameterFeatureSource, -) -from qgis.PyQt.QtCore import QCoreApplication - - -class ProcessingAlgorithm(QgsProcessingAlgorithm): - """ - This is an example algorithm that takes a vector layer and - creates a new identical one. - - It is meant to be used as an example of how to create your own - algorithms and explain methods and variables used to do it. An - algorithm like this will be available in all elements, and there - is not need for additional work. - - All Processing algorithms should extend the QgsProcessingAlgorithm - class. - """ - - # Constants used to refer to parameters and outputs. They will be - # used when calling the algorithm from another algorithm, or when - # calling from the QGIS console. - - INPUT = "INPUT" - OUTPUT = "OUTPUT" - - def __init__(self) -> None: - super().__init__() - - self._name = "myprocessingalgorithm" - self._display_name = "My Processing Algorithm" - self._group_id = "" - self._group = "" - self._short_help_string = "" - - def tr(self, string) -> str: - """ - Returns a translatable string with the self.tr() function. - """ - return QCoreApplication.translate("Processing", string) - - def createInstance(self): # noqa N802 - return ProcessingAlgorithm() - - def name(self) -> str: - """ - Returns the algorithm name, used for identifying the algorithm. This - string should be fixed for the algorithm, and must not be localised. - The name should be unique within each provider. Names should contain - lowercase alphanumeric characters only and no spaces or other - formatting characters. - """ - return self._name - - def displayName(self) -> str: # noqa N802 - """ - Returns the translated algorithm name, which should be used for any - user-visible display of the algorithm name. - """ - return self.tr(self._display_name) - - def groupId(self) -> str: # noqa N802 - """ - Returns the unique ID of the group this algorithm belongs to. This - string should be fixed for the algorithm, and must not be localised. - The group id should be unique within each provider. Group id should - contain lowercase alphanumeric characters only and no spaces or other - formatting characters. - """ - return self._group_id - - def group(self) -> str: - """ - Returns the name of the group this algorithm belongs to. This string - should be localised. - """ - return self.tr(self._group) - - def shortHelpString(self) -> str: # noqa N802 - """ - Returns a localised short helper string for the algorithm. This string - should provide a basic description about what the algorithm does and the - parameters and outputs associated with it.. - """ - return self.tr(self._short_help_string) - - def initAlgorithm(self, config=None): # noqa N802 - """ - Here we define the inputs and output of the algorithm, along - with some other properties. - """ - - # We add the input vector features source. It can have any kind of - # geometry. - self.addParameter( - QgsProcessingParameterFeatureSource( - self.INPUT, - self.tr("Input layer"), - [QgsProcessing.TypeVectorAnyGeometry], - ) - ) - - # We add a feature sink in which to store our processed features (this - # usually takes the form of a newly created vector layer when the - # algorithm is run in QGIS). - self.addParameter(QgsProcessingParameterFeatureSink(self.OUTPUT, self.tr("Output layer"))) - - def processAlgorithm( # noqa N802 - self, - parameters: dict[str, Any], - context: QgsProcessingContext, - feedback: QgsProcessingFeedback, - ) -> dict: - """ - Here is where the processing itself takes place. - """ - - # Initialize feedback if it is None - if feedback is None: - feedback = QgsProcessingFeedback() - - # Retrieve the feature source and sink. The 'dest_id' variable is used - # to uniquely identify the feature sink, and must be included in the - # dictionary returned by the processAlgorithm function. - source = self.parameterAsSource(parameters, self.INPUT, context) - - (sink, dest_id) = self.parameterAsSink( - parameters, - self.OUTPUT, - context, - source.fields(), - source.wkbType(), - source.sourceCrs(), - ) - - # Send some information to the user - feedback.pushInfo(f"CRS is {source.sourceCrs().authid()}") - - # Compute the number of steps to display within the progress bar and - # get features from source - total = 100.0 / source.featureCount() if source.featureCount() else 0 - features = source.getFeatures() - - for current, feature in enumerate(features): - # Stop the algorithm if cancel button has been clicked - if feedback.isCanceled(): - break - - # Add a feature in the sink - sink.addFeature(feature, QgsFeatureSink.FastInsert) - - # Update the progress bar - feedback.setProgress(int(current * total)) - - # To run another Processing algorithm as part of this algorithm, you can use - # processing.run(...). Make sure you pass the current context and feedback - # to processing.run to ensure that all temporary layer outputs are available - # to the executed algorithm, and that the executed algorithm can send feedback - # reports to the user (and correctly handle cancellation and progress reports!) - if False: - _buffered_layer = processing.run( - "native:buffer", - { - "INPUT": dest_id, - "DISTANCE": 1.5, - "SEGMENTS": 5, - "END_CAP_STYLE": 0, - "JOIN_STYLE": 0, - "MITER_LIMIT": 2, - "DISSOLVE": False, - "OUTPUT": "memory:", - }, - context=context, - feedback=feedback, - )["OUTPUT"] - - # Return the results of the algorithm. In this case our only result is - # the feature sink which contains the processed features, but some - # algorithms may return multiple feature sinks, calculated numeric - # statistics, etc. These should all be included in the returned - # dictionary, with keys matching the feature corresponding parameter - # or output names. - return {self.OUTPUT: dest_id} diff --git a/fvh3t/fvh3t_processing/provider.py b/fvh3t/fvh3t_processing/traffic_trajectory_toolkit_provider.py similarity index 83% rename from fvh3t/fvh3t_processing/provider.py rename to fvh3t/fvh3t_processing/traffic_trajectory_toolkit_provider.py index 496abfe..7b3e518 100644 --- a/fvh3t/fvh3t_processing/provider.py +++ b/fvh3t/fvh3t_processing/traffic_trajectory_toolkit_provider.py @@ -1,14 +1,14 @@ from qgis.core import QgsProcessingProvider -from fvh3t.fvh3t_processing.processing_algorithm import ProcessingAlgorithm +from fvh3t.fvh3t_processing.count_trajectories import CountTrajectories class Provider(QgsProcessingProvider): def __init__(self) -> None: super().__init__() - self._id = "myprovider" - self._name = "My provider" + self._id = "traffic_trajectory_toolkit" + self._name = "Traffic trajectory toolkit" def id(self) -> str: """The ID of your plugin, used to identify the provider. @@ -40,5 +40,5 @@ def loadAlgorithms(self) -> None: # noqa N802 """ Adds individual processing algorithms to the provider. """ - alg = ProcessingAlgorithm() + alg = CountTrajectories() self.addAlgorithm(alg) diff --git a/fvh3t/plugin.py b/fvh3t/plugin.py index 1d58337..71ab609 100644 --- a/fvh3t/plugin.py +++ b/fvh3t/plugin.py @@ -8,7 +8,7 @@ from qgis.PyQt.QtWidgets import QAction, QWidget from qgis.utils import iface -from fvh3t.fvh3t_processing.provider import Provider +from fvh3t.fvh3t_processing.traffic_trajectory_toolkit_provider import Provider from fvh3t.qgis_plugin_tools.tools.custom_logging import setup_logger, teardown_logger from fvh3t.qgis_plugin_tools.tools.i18n import setup_translation from fvh3t.qgis_plugin_tools.tools.resources import plugin_name