Repository: gee-community/qgis-earthengine-plugin
Pull request #240: Feature/add image collection
Author: login: zacdezgeo
Created: 2025-03-07T22:53:53Z | Merged: 2025-03-24T21:32:04Z

## What I Changed
- Added a new form to add image collections with support for multiple filters (string and numeric).
- Refactored filter handling using a dictionary-based approach for cleaner code.
- Enhanced UI with dynamic filter rows and tooltips.
- Added unit tests to cover different filter types.

## How to Test It
- Open the form to add image collections.
- Test adding various filters (e.g., `CLOUD_COVER < 10`, `PROCESSING_LEVEL == 'L2SP'`).
- Check if the filters are applied correctly and the layers load as expected.

## Other Notes
- Closes #201

Linked issue #201: GUI Functionality: Add GEE Image Collection to Map
add image collection to map, includes additional optional filters 
Linked issue comments: user: login: zacdezgeo
created_at: 2025-02-12T23:47:33Z
body: @aSullivan-geo @KMarkert; would it be possible for you to share examples of image collections we would like to add? Would be visualizing a mosaic of the ImageCollection? Should we impose some constraints on what a user can request (i.e. a limit on the number of images that can be mosaic)? 
author_association: COLLABORATOR
user: login: gena
created_at: 2025-02-21T00:20:52Z
body: Maybe check any of examples in Chapter 4 https://www.eefabook.org/go-to-the-book.html or any of the Use Cases in the book. Other interesting examples would be Dynamic World https://dynamicworld.app/ and some wether, like https://www.linkedin.com/posts/gena_weather-forecast-for-florida-by-gfs-hurricane-activity-7249843809633603584-iIwq/?utm_source=share&utm_medium=member_desktop for example.
author_association: COLLABORATOR
user: login: zacdezgeo
created_at: 2025-03-05T19:38:14Z
body: @gena; just following up on some initial research from your the links you provided. Went through the example in [chapter 4 ](https://docs.google.com/document/d/11oo1TuvXyEvReoYLDeTcoh16rEN90I8Bf5qp0KxVu7U/edit?tab=t.0). I found this example and text:

```javascript
var L5EVIimages = efficientFilteredSet.map(makeLandsat5EVI);
print('Verifying that the .map gives back the same number of images: ',    L5EVIimages.size());
print(L5EVIimages);
Map.addLayer(L5EVIimages, {}, 'L5EVIimages', 1, 1);
```
>After entering and executing this code, you will see a grayscale image. If you look closely at the edges of the image, you might spot other images drawn behind it in a way that looks somewhat like a stack of papers on a table. This is the drawing of the </span><span style="font-size:13pt;font-family:Inconsolata,monospace;color:#7f1fa2;background-color:#f1efee;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">ImageCollection</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> made from the </span><span style="font-size:13pt;font-family:Inconsolata,monospace;color:#000000;background-color:#f1efee;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">makeLandsat5EVI</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> function. You can select the </span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:700;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Inspector</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> panel and click on one of the grayscale pixels to view the values of the entire </span><span style="font-size:13pt;font-family:Inconsolata,monospace;color:#7f1fa2;background-color:#f1efee;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">ImageCollection</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">. After clicking on a pixel, look for the </span><span style="font-size:13pt;font-family:Inconsolata,monospace;color:#000000;background-color:#f1efee;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">Series</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> tag by opening and closing the list of items. When you open that tag, you will see a chart of the EVI values at that pixel, created by mapping the </span><span style="font-size:13pt;font-family:Inconsolata,monospace;color:#000000;background-color:#f1efee;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">makeLandsat5EVI</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;"> function over the filtered </span><span style="font-size:13pt;font-family:Inconsolata,monospace;color:#7f1fa2;background-color:#f1efee;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">ImageCollection</span><span style="font-size:12pt;font-family:Arial,sans-serif;color:#000000;background-color:transparent;font-weight:400;font-style:normal;font-variant:normal;text-decoration:none;vertical-align:baseline;white-space:pre;white-space:pre-wrap;">.</span></b>

I'm having some trouble interpreting what that would mean in the context of calling `Map.addLayer` in the plugin. Would we want to create a separate raster layer for each item, or should we implicitly call it a `reduce` function to get a single greyscale image? I think that we should opt for the reduce option. A follow-up question is whether it is safe to use a median operation for the reducer. I think it's a safe assumption, personally, as power users can run their own logic if they choose to. 

Also, I'd like to point out that each collection can have different attributes, and it may be slightly more complex to fetch these attributes and fill the form dynamically. We can implement this, but we may start with a text box approach where the user is expected to provide the filters.
author_association: COLLABORATOR
user: login: zacdezgeo
created_at: 2025-03-11T16:14:42Z
body: We need to add some basic visualization options and use a mosaic instead of a reducer function.
author_association: COLLABORATOR
user: login: gena
created_at: 2025-03-11T16:31:55Z
body: Options to add to this dialog:

* Select how images are composed: MEAN, MIN, MAX, MEDIAN, STDEV, PERCENTILE (provide the list of values varying between 0 and 100)

* Visualization: min, max, band_names (mapped to R, G, B). When there is just 1 band - show the option to specify a color palette
  
author_association: COLLABORATOR
user: login: gena
created_at: 2025-03-11T16:43:36Z
body: For composing, maybe COUNT as well, also see this for COUNT and PERCENTILE: https://link.springer.com/chapter/10.1007/978-3-031-26588-4_13

Here is a code snippet showing how to generate a 3-month 20% percentile composite: https://code.earthengine.google.com/ad3d44d50b7d6e41f287c31708f6d81a

```javascript
var date = ee.Date(new Date()) // now

var image = ee.ImageCollection('COPERNICUS/S2_HARMONIZED')
  .select(['B12', 'B8', 'B3'])
  .filterDate(date.advance(-3, 'months'), date)
  .reduce(ee.Reducer.percentile([20]))
  
Map.addLayer(image, { min: 500, max: 3500, gamma: 1.4 })

```

author_association: COLLABORATOR

Diff:
diff --git a/ee_plugin/Map.py b/ee_plugin/Map.py
index 09e4938e..280dd352 100644
--- a/ee_plugin/Map.py
+++ b/ee_plugin/Map.py
@@ -38,7 +38,8 @@ def addLayer(eeObject, visParams=None, name=None, shown=True, opacity=1.0):
         >>> from ee_plugin import Map
         >>> Map.addLayer(.....)
     """
-    utils.add_or_update_ee_layer(eeObject, visParams, name, shown, opacity)
+    layer = utils.add_or_update_ee_layer(eeObject, visParams, name, shown, opacity)
+    return layer
 
 
 def centerObject(feature, zoom=None):
diff --git a/ee_plugin/ee_plugin.py b/ee_plugin/ee_plugin.py
index f4d5157c..df9b671f 100644
--- a/ee_plugin/ee_plugin.py
+++ b/ee_plugin/ee_plugin.py
@@ -13,8 +13,8 @@
 from typing import cast
 
 import requests  # type: ignore
-from qgis import gui
-from qgis.core import QgsProject
+from qgis import gui, processing
+from qgis.core import QgsProject, QgsApplication
 from qgis.PyQt import QtWidgets
 from qgis.PyQt.QtCore import QCoreApplication, QSettings, QTranslator, qVersion, Qt
 from qgis.PyQt.QtGui import QIcon
@@ -22,7 +22,8 @@
 
 from . import provider, config, ee_auth, utils, logging
 from .ui import menus
-from .ui.forms import add_feature_collection, add_ee_image
+from .ui.forms import add_feature_collection
+from .processing.processing_provider import EEProcessingProvider
 
 
 PLUGIN_DIR = os.path.dirname(__file__)
@@ -100,6 +101,10 @@ def tr(self, message):
 
     def initGui(self):
         """Initialize the plugin GUI."""
+
+        self.provider = EEProcessingProvider(icon=icon("earth-engine.svg"))
+        QgsApplication.processingRegistry().addProvider(self.provider)
+
         # Build actions
         ee_user_guide_action = QtWidgets.QAction(
             icon=icon("earth-engine.svg"),
@@ -126,10 +131,23 @@ def initGui(self):
                 accepted=add_feature_collection.callback
             ),
         )
+
+        def open_add_ee_image_dialog():
+            processing.execAlgorithmDialog("ee:add_ee_image")
+
         add_ee_image_button = QtWidgets.QAction(
             text=self.tr("Add Image"),
             parent=self.iface.mainWindow(),
-            triggered=lambda: add_ee_image.form(accepted=add_ee_image.callback),
+            triggered=open_add_ee_image_dialog,
+        )
+
+        def open_add_image_collection_dialog():
+            processing.execAlgorithmDialog("ee:add_image_collection")
+
+        add_image_collection_button = QtWidgets.QAction(
+            text=self.tr("Add Image Collection"),
+            parent=self.iface.mainWindow(),
+            triggered=open_add_image_collection_dialog,
         )
 
         # Initialize plugin menu
@@ -170,6 +188,7 @@ def initGui(self):
                         subitems=[
                             menus.Action(action=add_fc_button),
                             menus.Action(action=add_ee_image_button),
+                            menus.Action(action=add_image_collection_button),
                         ],
                     ),
                 ],
@@ -185,6 +204,8 @@ def unload(self):
         if self.toolButton:
             self.toolButton.deleteLater()
 
+        QgsApplication.processingRegistry().removeProvider(self.provider)
+
         logging.teardown_logger()
 
     @property
diff --git a/ee_plugin/processing/__init__.py b/ee_plugin/processing/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/ee_plugin/processing/add_ee_image.py b/ee_plugin/processing/add_ee_image.py
new file mode 100644
index 00000000..fcac4d1a
--- /dev/null
+++ b/ee_plugin/processing/add_ee_image.py
@@ -0,0 +1,112 @@
+import json
+import logging
+from typing import Any, Dict, Optional
+
+import ee
+from qgis.core import (
+    QgsProcessingAlgorithm,
+    QgsProcessingParameterString,
+    QgsProcessingException,
+    QgsProcessingContext,
+    QgsProcessingFeedback,
+    QgsProcessingOutputRasterLayer,
+    QgsProcessingOutputString,
+)
+
+from ..Map import addLayer
+
+logger = logging.getLogger(__name__)
+
+
+class AddEEImageAlgorithm(QgsProcessingAlgorithm):
+    def initAlgorithm(self, config: Optional[Dict[str, Any]] = None) -> None:
+        self.addParameter(
+            QgsProcessingParameterString(
+                "IMAGE_ID",
+                "GEE Image ID",
+                optional=False,
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterString(
+                "VIZ_PARAMS",
+                "Visualization Parameters (JSON)",
+                optional=True,
+            )
+        )
+        self.addOutput(QgsProcessingOutputRasterLayer("OUTPUT", "EE Image"))
+        self.addOutput(QgsProcessingOutputString("LAYER_NAME", "Layer Name"))
+
+    def processAlgorithm(
+        self,
+        parameters: Dict[str, Any],
+        context: QgsProcessingContext,
+        feedback: QgsProcessingFeedback,
+    ) -> Dict[str, Any]:
+        image_id = self.parameterAsString(parameters, "IMAGE_ID", context)
+        viz_params_raw = self.parameterAsString(parameters, "VIZ_PARAMS", context)
+        logger.info(
+            f"[DEBUG] Parameters received: IMAGE_ID={image_id}, VIZ_PARAMS={viz_params_raw}"
+        )
+
+        if not image_id:
+            raise QgsProcessingException("Image ID is required.")
+
+        try:
+            asset_info = ee.data.getAsset(image_id)
+            asset_type = asset_info.get("type", "")
+
+            if asset_type == "IMAGE":
+                ee_object = ee.Image(image_id)
+            else:
+                raise QgsProcessingException(f"Unsupported asset type: {asset_type}")
+
+            if viz_params_raw:
+                try:
+                    viz_params = json.loads(viz_params_raw.replace("'", '"'))
+                except json.JSONDecodeError:
+                    raise QgsProcessingException(
+                        "Invalid JSON format in visualization parameters."
+                    )
+            else:
+                viz_params = {}
+
+            layer = addLayer(ee_object, viz_params, image_id)
+
+            if not layer:
+                raise QgsProcessingException("Failed to add EE layer to map.")
+
+            return {"OUTPUT": layer.id(), "LAYER_NAME": layer.name()}
+
+        except ee.EEException as e:
+            raise QgsProcessingException(f"Earth Engine Error: {str(e)}")
+
+        except Exception as e:
+            raise QgsProcessingException(f"Unexpected error: {str(e)}")
+
+    def name(self) -> str:
+        return "add_ee_image"
+
+    def displayName(self) -> str:
+        return "Add EE Image"
+
+    def group(self) -> str:
+        return "Add Layer"
+
+    def groupId(self) -> str:
+        return "gee"
+
+    def shortHelpString(self) -> str:
+        return (
+            "<b>Add EE Image</b><br><br>"
+            "Loads a Google Earth Engine image into QGIS.<br><br>"
+            "<b>Example Image ID:</b><br>"
+            "<code>USGS/SRTMGL1_003</code><br><br>"
+            "<b>Example Visualization Parameters (JSON):</b><br>"
+            '<pre>{"min": 0, "max": 3000, "palette": ["#000000", "#ffffff"]}</pre>'
+            "<b>Earth Engine Data Catalog:</b><br>"
+            "<a href='https://developers.google.com/earth-engine/datasets'>https://developers.google.com/earth-engine/datasets</a>"
+        )
+
+    def createInstance(self) -> "AddEEImageAlgorithm":
+        return AddEEImageAlgorithm()
diff --git a/ee_plugin/processing/add_image_collection.py b/ee_plugin/processing/add_image_collection.py
new file mode 100644
index 00000000..f5275955
--- /dev/null
+++ b/ee_plugin/processing/add_image_collection.py
@@ -0,0 +1,222 @@
+from typing import List
+import json
+import ee
+from qgis.core import (
+    QgsProcessingAlgorithm,
+    QgsProcessingParameterString,
+    QgsProcessingParameterEnum,
+    QgsProcessingParameterDateTime,
+    QgsProcessingParameterNumber,
+    QgsProcessingOutputRasterLayer,
+    QgsProcessingOutputString,
+    QgsProcessingParameterExtent,
+)
+from .. import Map
+
+filter_functions = {
+    "==": {"operator": ee.Filter.eq, "symbol": "=="},
+    "!=": {"operator": ee.Filter.neq, "symbol": "!="},
+    "<": {"operator": ee.Filter.lt, "symbol": "<"},
+    ">": {"operator": ee.Filter.gt, "symbol": ">"},
+    "<=": {"operator": ee.Filter.lte, "symbol": "<="},
+    ">=": {"operator": ee.Filter.gte, "symbol": ">="},
+}
+
+
+class AddImageCollectionAlgorithm(QgsProcessingAlgorithm):
+    """
+    Algorithm to add an Image Collection to the map from GEE.
+    """
+
+    def name(self):
+        return "add_image_collection"
+
+    def displayName(self):
+        return "Add Image Collection"
+
+    def group(self) -> str:
+        return "Add Layer"
+
+    def groupId(self) -> str:
+        return "gee"
+
+    def shortHelpString(self):
+        return """
+        <html>
+        <b>Add Image Collection</b><br>
+        This algorithm adds an Earth Engine Image Collection to the map with the specified compositing method and visualization parameters.<br>
+        You can filter the collection by properties, dates, or geographic extent.<br>
+
+        <h3>Parameters:</h3>
+        <ul>
+            <li><b>Image Collection ID:</b> The Earth Engine Image Collection ID to add to the map.</li>
+            <li><b>Filters:</b> The filter type to apply to the Image Collection. Example: "cloud_coverage:&lt;:10;sun_elevation:&gt;:0" for non-cloudy daytime Landsat images.</li>
+            <li><b>Start date for filtering:</b> The start date for filtering the Image Collection.</li>
+            <li><b>End date for filtering:</b> The end date for filtering the Image Collection.</li>
+            <li><b>Compositing Method:</b> The compositing method to use for the Image Collection.</li>
+            <li><b>Percentile Value:</b> The percentile value to use for the 'Percentile' compositing method if selected.</li>
+            <li><b>Visualization Parameters:</b> JSON string for visualization parameters.</li>
+        </ul>
+
+        <b>Earth Engine Data Catalog:</b><br>
+            <a href='https://developers.google.com/earth-engine/datasets'>https://developers.google.com/earth-engine/datasets</a>
+        </html>
+        """
+
+    def initAlgorithm(self, config):
+        # Define parameters
+        self.addParameter(
+            QgsProcessingParameterString(
+                "image_collection_id", "Earth Engine Image Collection ID"
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterExtent("extent", "Extent", optional=True)
+        )
+        self.addParameter(
+            QgsProcessingParameterString(
+                "filters",
+                "Filter Expression",
+                "Enter filters as property_0:operator_0:value_0;property_1:operator_1:value_1",
+                optional=True,
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterDateTime(
+                "start_date", "Start date for filtering", optional=True
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterDateTime(
+                "end_date", "End date for filtering", optional=True
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterEnum(
+                "compositing_method",
+                "Compositing Method",
+                options=["Mosaic", "Mean", "Max", "Min", "Median", "Percentile"],
+                optional=False,
+                defaultValue=0,
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterNumber(
+                "percentile_value", "Percentile Value", optional=True
+            )
+        )
+        self.addParameter(
+            QgsProcessingParameterString(
+                "viz_params",
+                "Visualization Parameters (JSON)",
+                "Enter JSON for visualization parameters",
+                optional=True,
+            )
+        )
+
+        self.addOutput(QgsProcessingOutputRasterLayer("OUTPUT", "EE Image"))
+        self.addOutput(QgsProcessingOutputString("LAYER_NAME", "Layer Name"))
+
+    def createInstance(self):
+        return AddImageCollectionAlgorithm()
+
+    def _get_filters(self, filters: str) -> List[str]:
+        filters = filters.split(";")
+        parsed_filters = []
+        for f in filters:
+            f = f.split(":")
+            if len(f) == 3 and f[1] in filter_functions:
+                parsed_filters.append(f)
+            else:
+                raise ValueError(f"Invalid filter format: {f}")
+
+        return parsed_filters
+
+    def processAlgorithm(self, parameters, context, feedback):
+        image_collection_id = parameters["image_collection_id"]
+        filters = parameters["filters"]
+        start_date = parameters["start_date"]
+        end_date = parameters["end_date"]
+        extent = parameters["extent"]  # This is the extent parameter as a string
+        compositing_method = parameters["compositing_method"]
+        percentile_value = (
+            int(parameters["percentile_value"])
+            if parameters["percentile_value"]
+            else None
+        )
+        viz_params = parameters.get("viz_params", None)
+
+        # Initialize Earth Engine ImageCollection
+        ic = ee.ImageCollection(image_collection_id)
+
+        # Apply date filter if provided
+        if start_date and end_date:
+            start_date_str = start_date.toString("yyyy-MM-dd")
+            end_date_str = end_date.toString("yyyy-MM-dd")
+            ic = ic.filter(
+                ee.Filter.date(ee.Date(start_date_str), ee.Date(end_date_str))
+            )
+
+        # If extent is provided, convert it to a QgsRectangle and then to ee.Geometry
+        if extent:
+            # Extract coordinates from the string (example: '-125.414732328,-120.326515344,48.801746245,52.242572656 [EPSG:4326]')
+            extent = extent.split(" ")[0]  # Take just the coordinates part
+            coords = extent.split(",")  # Split into individual coordinates
+            min_lon, max_lon, min_lat, max_lat = map(float, coords)  # Convert to float
+
+            # Create the Earth Engine geometry
+            ee_extent = ee.Geometry.Rectangle([min_lon, min_lat, max_lon, max_lat])
+            ic = ic.filterBounds(ee_extent)
+
+        # Apply the filters if provided
+        if filters:
+            filters = self._get_filters(filters)
+
+            for filter in filters:
+                filter_property, filter_operator, filter_value = filter
+                filter_func = filter_functions.get(
+                    f"{filter_operator} ({filter_property})"
+                )
+                if filter_func:
+                    ic = ic.filter(
+                        filter_func["operator"](filter_property, filter_value)
+                    )
+
+        # Apply compositing logic
+        compositing_dict = {
+            "Mean": ic.mean(),
+            "Max": ic.max(),
+            "Min": ic.min(),
+            "Median": ic.median(),
+            "Percentile": ic.reduce(ee.Reducer.percentile([percentile_value])),
+        }
+
+        ic = compositing_dict.get(compositing_method, ic.mosaic())
+
+        # Add the image collection to the map
+        if compositing_method == "Percentile":
+            if percentile_value is None:
+                raise ValueError(
+                    "Percentile value is required for 'Percentile' method."
+                )
+            if percentile_value < 0 or percentile_value > 100:
+                raise ValueError("Percentile value must be between 0 and 100.")
+
+        if viz_params:
+            try:
+                viz_params = json.loads(viz_params)  # Parse the JSON string
+            except json.JSONDecodeError:
+                raise ValueError("Invalid JSON for visualization parameters.")
+        else:
+            viz_params = {}
+
+        if compositing_method == "Percentile":
+            name = (
+                f"IC: {image_collection_id} ({compositing_method} {percentile_value}%)"
+            )
+        else:
+            name = f"IC: {image_collection_id} ({compositing_method})"
+
+        layer = Map.addLayer(ic, viz_params, name)
+
+        return {"OUTPUT": layer, "LAYER_NAME": layer.name()}
diff --git a/ee_plugin/processing/processing_provider.py b/ee_plugin/processing/processing_provider.py
new file mode 100644
index 00000000..83c82844
--- /dev/null
+++ b/ee_plugin/processing/processing_provider.py
@@ -0,0 +1,32 @@
+import logging
+
+from qgis.PyQt.QtGui import QIcon
+from qgis.core import QgsProcessingProvider
+
+from .add_ee_image import AddEEImageAlgorithm
+from .add_image_collection import AddImageCollectionAlgorithm
+
+
+logger = logging.getLogger(__name__)
+
+
+class EEProcessingProvider(QgsProcessingProvider):
+    def __init__(self, icon: QIcon, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self._icon = icon
+
+    def loadAlgorithms(self):
+        self.addAlgorithm(AddEEImageAlgorithm())
+        self.addAlgorithm(AddImageCollectionAlgorithm())
+
+    def id(self):
+        return "ee"
+
+    def name(self):
+        return "GEE"
+
+    def longName(self):
+        return "Google Earth Engine Plugin"
+
+    def icon(self) -> QIcon:
+        return self._icon
diff --git a/ee_plugin/ui/forms/add_ee_image.py b/ee_plugin/ui/forms/add_ee_image.py
deleted file mode 100644
index dd084622..00000000
--- a/ee_plugin/ui/forms/add_ee_image.py
+++ /dev/null
@@ -1,103 +0,0 @@
-import logging
-import json
-from typing import Callable, Optional
-
-import ee
-from qgis.PyQt import QtWidgets
-
-from ...Map import addLayer
-from ..widgets import build_form_group_box, build_vbox_dialog
-from ..utils import call_func_with_values
-from ...utils import translate as _
-
-logger = logging.getLogger(__name__)
-
-
-def form(accepted: Optional[Callable] = None, **dialog_kwargs) -> QtWidgets.QDialog:
-    """Display a dialog to add a GEE dataset to the QGIS map."""
-    dialog = build_vbox_dialog(
-        windowTitle=_("Add Google Earth Engine Image"),
-        widgets=[
-            build_form_group_box(
-                title=_("Source"),
-                rows=[
-                    (
-                        QtWidgets.QLabel(
-                            text="<br />".join(
-                                [
-                                    _("GEE Image Name"),
-                                    "e.g. <code>COPERNICUS/S2, USGS/SRTMGL1_003</code>",
-                                ]
-                            ),
-                            toolTip="Provide the full Earth Engine ID.",
-                        ),
-                        QtWidgets.QLineEdit(objectName="image_id"),
-                    )
-                ],
-            ),
-            build_form_group_box(
-                title="Visualization Parameters (JSON)",
-                collapsable=True,
-                collapsed=True,
-                rows=[
-                    (
-                        QtWidgets.QLabel(
-                            text="Enter JSON for visualization parameters",
-                            toolTip="Example: {'min': 0, 'max': 4000, 'palette': ['006633', 'E5FFCC', '662A00']}",
-                        ),
-                        QtWidgets.QTextEdit(
-                            objectName="viz_params",
-                            placeholderText='{"min": 0, "max": 4000, "palette": ["006633", "E5FFCC", "662A00"]}',
-                        ),
-                    )
-                ],
-            ),
-        ],
-        **dialog_kwargs,
-    )
-
-    if accepted:
-        dialog.accepted.connect(lambda: call_func_with_values(accepted, dialog))
-    return dialog
-
-
-def callback(image_id: str = None, viz_params: dict = None):
-    """Fetch and add the selected Earth Engine dataset to the map with user-defined visualization parameters."""
-    if not image_id:
-        logger.error("Image ID is required.")
-        return
-
-    try:
-        # Get asset metadata
-        asset_info = ee.data.getAsset(image_id)
-        asset_type = asset_info.get("type", "")
-
-        if asset_type == "IMAGE":
-            ee_object = ee.Image(image_id)
-        else:
-            raise ValueError(f"Unsupported asset type: {asset_type}")
-
-        # Get visualization parameters from user as JSON
-        if not viz_params:
-            viz_params = {}
-        else:
-            try:
-                viz_params = json.loads(viz_params.replace("'", '"'))
-            except json.JSONDecodeError:
-                raise ValueError("Invalid JSON format in visualization parameters.")
-
-        # Add the dataset to QGIS map
-        addLayer(ee_object, viz_params, image_id)
-
-        logger.info(
-            f"Successfully added {image_id} to the map with custom visualization."
-        )
-
-    except ee.EEException as e:
-        logger.exception(f"Earth Engine Error: {str(e)}")
-
-    except ValueError as e:
-        logger.exception(str(e))
-
-    except Exception as e:
-        logger.exception(f"Unexpected error: {str(e)}")
diff --git a/ee_plugin/ui/widget_parsers.py b/ee_plugin/ui/widget_parsers.py
index 4a014152..ac4e9013 100644
--- a/ee_plugin/ui/widget_parsers.py
+++ b/ee_plugin/ui/widget_parsers.py
@@ -1,9 +1,20 @@
 from typing import Optional
 
-from qgis.PyQt.QtWidgets import QCheckBox, QDateEdit, QDialog, QLineEdit, QTextEdit
+from qgis.PyQt.QtWidgets import (
+    QCheckBox,
+    QDateEdit,
+    QDialog,
+    QLineEdit,
+    QTextEdit,
+    QComboBox,
+    QDoubleSpinBox,
+    QSpinBox,
+)
 from qgis.core import QgsCoordinateReferenceSystem, QgsCoordinateTransform, QgsProject
 from qgis.gui import QgsColorButton, QgsDateEdit, QgsExtentGroupBox
 
+from ee_plugin.ui.widgets import LabeledSlider
+
 
 def qgs_extent_to_bbox(
     w: QgsExtentGroupBox,
@@ -51,6 +62,10 @@ def get_dialog_values(dialog: QDialog) -> dict:
         QCheckBox: lambda w: w.isChecked(),
         QgsColorButton: lambda w: w.color().name(),
         QgsExtentGroupBox: qgs_extent_to_bbox,
+        QComboBox: lambda w: w.currentText(),
+        QSpinBox: lambda w: w.value(),
+        QDoubleSpinBox: lambda w: w.value(),
+        LabeledSlider: lambda w: w.get_value(),
     }
     values = {}
     for cls, formatter in parsers.items():
diff --git a/ee_plugin/ui/widgets.py b/ee_plugin/ui/widgets.py
index 11d29d47..2b911793 100644
--- a/ee_plugin/ui/widgets.py
+++ b/ee_plugin/ui/widgets.py
@@ -10,11 +10,167 @@
     QGroupBox,
     QLayout,
     QVBoxLayout,
+    QHBoxLayout,
+    QLineEdit,
+    QComboBox,
     QWidget,
+    QPushButton,
+    QSlider,
+    QLabel,
 )
 from qgis.gui import QgsCollapsibleGroupBox
 
 
+class LabeledSlider(QWidget):
+    """
+    A labeled slider widget that displays the selected value.
+
+    Args:
+        min_value (int): The minimum value of the slider.
+        max_value (int): The maximum value of the slider.
+        default_value (int): The default starting value.
+        object_name (str): The object name for the widget.
+        visible (bool): Whether the widget should be visible initially.
+    """
+
+    def __init__(
+        self,
+        min_value: int = 0,
+        max_value: int = 100,
+        default_value: int = 50,
+        object_name="slider",
+        label: str = None,
+        visible: bool = False,
+    ):
+        super().__init__()
+        self.setObjectName(object_name)
+
+        # Main layout
+        main_layout = QVBoxLayout(self)
+
+        # Top label showing the current value
+        self.label = QLabel()
+        self.initial_label = label or "Value"
+        self.update_label(default_value)
+        main_layout.addWidget(self.label)
+
+        # Slider setup
+        self.slider = QSlider(QtCore.Qt.Horizontal)
+        self.slider.setRange(min_value, max_value)
+        self.slider.setValue(default_value)
+
+        # Min/Max labels beside the slider
+        min_label = QLabel(str(min_value))
+        max_label = QLabel(str(max_value))
+
+        # Layout for slider with min/max
+        slider_layout = QHBoxLayout()
+        slider_layout.addWidget(min_label)
+        slider_layout.addWidget(self.slider)
+        slider_layout.addWidget(max_label)
+
+        main_layout.addLayout(slider_layout)
+
+        # Update value label when slider moves
+        self.slider.valueChanged.connect(self.update_label)
+
+        # Initial visibility
+        self.setVisible(visible)
+
+    def update_label(self, value):
+        """Update label text with the current slider value."""
+        self.label.setText(f"{self.initial_label}: {value}")
+
+    def set_visibility(self, visible: bool):
+        """Toggle visibility of the widget."""
+        self.setVisible(visible)
+        self.label.setVisible(visible)
+
+    def get_value(self) -> int:
+        """Get the current value of the slider."""
+        return self.slider.value()
+
+    def set_value(self, value: int):
+        """Set the current value of the slider."""
+        self.slider.setValue(value)
+
+
+def create_filter_widget() -> QWidget:
+    filter_widget = QWidget()
+    filter_widget.setObjectName("filter_widget")
+    layout = QVBoxLayout()
+    filter_widget.setLayout(layout)
+
+    # Use a simple global counter for unique filter IDs
+    global filter_count
+    filter_count = 0
+
+    def add_filter():
+        global filter_count
+        row = QHBoxLayout()
+
+        # Create unique object names using filter_count
+        name_input = QLineEdit()
+        name_input.setPlaceholderText("Property Name")
+        name_input.setToolTip(
+            "Enter the property name to filter by (e.g., CLOUD_COVER)."
+        )
+        name_input.setObjectName(f"filter_name_{filter_count}")
+        row.addWidget(name_input)
+
+        operator_combo = QComboBox()
+        operator_combo.addItems(
+            [
+                "Equals (==)",
+                "Not Equals (!=)",
+                "Less Than (<)",
+                "Greater Than (>)",
+                "Less Than or Equal (<=)",
+                "Greater Than or Equal (>=)",
+            ]
+        )
+        operator_combo.setToolTip("Choose the operator for filtering.")
+        operator_combo.setObjectName(f"filter_operator_{filter_count}")
+        row.addWidget(operator_combo)
+
+        value_input = QLineEdit()
+        value_input.setPlaceholderText("Value")
+        value_input.setToolTip(
+            "Enter the value to filter by (can be numeric or string)."
+        )
+        value_input.setObjectName(f"filter_value_{filter_count}")
+        row.addWidget(value_input)
+
+        remove_button = QPushButton("Remove")
+        remove_button.setToolTip("Remove this filter.")
+        remove_button.clicked.connect(lambda: remove_filter(row))
+        row.addWidget(remove_button)
+
+        layout.addLayout(row)
+
+        # Increment global counter for the next filter
+        filter_count += 1
+
+    def remove_filter(row):
+        # Remove all widgets in the row
+        for i in reversed(range(row.count())):
+            widget = row.itemAt(i).widget()
+            if widget:
+                widget.deleteLater()
+        layout.removeItem(row)
+
+    add_filter_button = QPushButton("Add Filter")
+    add_filter_button.setObjectName("add_filter_button")
+    add_filter_button.setToolTip("Click to add a new property filter.")
+    add_filter_button.clicked.connect(add_filter)
+    layout.addWidget(add_filter_button)
+
+    # Initialize with one filter row
+    add_filter()
+
+    return filter_widget
+
+
 def DefaultNullQgsDateEdit(
     *, date: Optional[QtCore.QDate] = None, displayFormat="yyyy-MM-dd", **kwargs
 ) -> gui.QgsDateEdit:
diff --git a/ee_plugin/utils.py b/ee_plugin/utils.py
index ccc02b8b..8d37525f 100644
--- a/ee_plugin/utils.py
+++ b/ee_plugin/utils.py
@@ -70,6 +70,11 @@ def add_or_update_ee_layer(
     if isinstance(eeObject, ee.Geometry):
         return add_or_update_ee_vector_layer(eeObject, name, shown, opacity)
 
+    if isinstance(eeObject, ee.ImageCollection):
+        # TODO: is median a fair assumption?
+        reduce_image = eeObject.reduce(ee.Reducer.median())
+        return add_or_update_ee_raster_layer(reduce_image, name, vis_params, shown)
+
     raise TypeError("Unsupported EE object type")
 
 
@@ -118,11 +123,6 @@ def add_ee_image_layer(
     if opacity is not None and layer.renderer():
         layer.renderer().setOpacity(opacity)
 
-    if shown is not None:
-        qgis_instance.layerTreeRoot().findLayer(layer.id()).setItemVisibilityChecked(
-            shown
-        )
-
     return layer
 
 
diff --git a/test/test_add_ee_image.py b/test/test_add_ee_image.py
new file mode 100644
index 00000000..6806595c
--- /dev/null
+++ b/test/test_add_ee_image.py
@@ -0,0 +1,69 @@
+from qgis.core import (
+    QgsProcessingContext,
+    QgsProcessingFeedback,
+)
+from ee_plugin.processing.add_ee_image import AddEEImageAlgorithm
+
+
+def run_algorithm_with_params(params):
+    alg = AddEEImageAlgorithm()
+    alg.initAlgorithm()
+    context = QgsProcessingContext()
+    feedback = QgsProcessingFeedback()
+    return alg.processAlgorithm(params, context, feedback)
+
+
+def test_add_gee_layer_algorithm(clean_qgis_iface):
+    params = {
+        "IMAGE_ID": "USGS/SRTMGL1_003",
+        "VIZ_PARAMS": '{"min": 0, "max": 3000, "palette": ["#000000", "#ffffff"]}',
+    }
+    run_algorithm_with_params(params)
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "USGS/SRTMGL1_003"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_load_gee_layer_srtm(clean_qgis_iface):
+    params = {
+        "IMAGE_ID": "USGS/SRTMGL1_003",
+        "VIZ_PARAMS": '{"min": 0, "max": 4000, "palette": ["006633", "E5FFCC", "662A00"]}',
+    }
+    run_algorithm_with_params(params)
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "USGS/SRTMGL1_003"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_converting_viz_params_json(clean_qgis_iface):
+    params = {
+        "IMAGE_ID": "USGS/SRTMGL1_003",
+        "VIZ_PARAMS": "{'min': 0, 'max': 4000, 'palette': ['006633', 'E5FFCC', '662A00']}",
+    }
+    run_algorithm_with_params(params)
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "USGS/SRTMGL1_003"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_invalid_viz_params(clean_qgis_iface):
+    params = {
+        "IMAGE_ID": "USGS/SRTMGL1_003",
+        "VIZ_PARAMS": "not a valid JSON string",
+    }
+    try:
+        run_algorithm_with_params(params)
+    except Exception:
+        pass
+    assert len(clean_qgis_iface.mapCanvas().layers()) == 0
+
+
+def test_empty_viz_params(clean_qgis_iface):
+    params = {
+        "IMAGE_ID": "USGS/SRTMGL1_003",
+        "VIZ_PARAMS": "",
+    }
+    run_algorithm_with_params(params)
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "USGS/SRTMGL1_003"
+    assert layer.dataProvider().name() == "EE"
diff --git a/test/test_add_image_collection.py b/test/test_add_image_collection.py
new file mode 100644
index 00000000..cf45e45b
--- /dev/null
+++ b/test/test_add_image_collection.py
@@ -0,0 +1,20 @@
+import ee
+from qgis.core import QgsMapLayer
+
+from ee_plugin import Map
+
+
+def test_add_landsat_cloud_cover(clean_qgis_iface):
+    collection = (
+        ee.ImageCollection("LANDSAT/LC09/C02/T1_L2")
+        .filterDate("2021-01-01", "2021-12-31")
+        .filter(ee.Filter.lt("CLOUD_COVER", 10))
+    )
+    Map.addLayer(collection, {}, "LANDSAT_2021")
+    layers = clean_qgis_iface.mapCanvas().layers()
+    assert len(layers) == 1
+    assert layers[0]
+    layer = layers[0]
+    assert layer.name() == "LANDSAT_2021"
+    assert layer.type() == QgsMapLayer.RasterLayer
+    assert layer.providerType() == "EE"
diff --git a/test/test_form_add_ee_image.py b/test/test_form_add_ee_image.py
deleted file mode 100644
index d3897428..00000000
--- a/test/test_form_add_ee_image.py
+++ /dev/null
@@ -1,81 +0,0 @@
-from qgis.PyQt import QtWidgets
-
-from ee_plugin.ui.utils import get_dialog_values
-from ee_plugin.ui.forms.add_ee_image import form, callback
-
-
-def test_add_gee_layer_dialog(clean_qgis_iface):
-    dialog = form()
-    dialog.findChild(QtWidgets.QLineEdit, "image_id").setText("COPERNICUS/S2")
-
-    dialog.findChild(QtWidgets.QTextEdit, "viz_params").setText(
-        '{"min": 0, "max": 4000, "palette": ["006633", "E5FFCC", "662A00"]}'
-    )
-
-    assert get_dialog_values(dialog) == {
-        "image_id": "COPERNICUS/S2",
-        "viz_params": '{"min": 0, "max": 4000, "palette": ["006633", "E5FFCC", "662A00"]}',
-    }
-
-
-def test_load_gee_layer_srtm(clean_qgis_iface):
-    dialog = form()
-    dialog.findChild(QtWidgets.QLineEdit, "image_id").setText("USGS/SRTMGL1_003")
-
-    dialog.findChild(QtWidgets.QTextEdit, "viz_params").setText(
-        '{"min": 0, "max": 4000, "palette": ["006633", "E5FFCC", "662A00"]}'
-    )
-
-    assert get_dialog_values(dialog) == {
-        "image_id": "USGS/SRTMGL1_003",
-        "viz_params": '{"min": 0, "max": 4000, "palette": ["006633", "E5FFCC", "662A00"]}',
-    }
-
-    callback(**get_dialog_values(dialog))
-
-    assert len(clean_qgis_iface.mapCanvas().layers()) == 1
-    assert clean_qgis_iface.mapCanvas().layers()[0].name() == "USGS/SRTMGL1_003"
-    assert clean_qgis_iface.mapCanvas().layers()[0].dataProvider().name() == "EE"
-
-
-def test_converting_viz_params_json(clean_qgis_iface):
-    dialog = form()
-    dialog.findChild(QtWidgets.QLineEdit, "image_id").setText("USGS/SRTMGL1_003")
-
-    # single quotes should get replaced to double quotes
-    # by _load_gee_layer, so dialog still has single quotes
-    dialog.findChild(QtWidgets.QTextEdit, "viz_params").setText(
-        "{'min': 0, 'max': 4000, 'palette': ['006633', 'E5FFCC', '662A00']}"
-    )
-
-    callback(**get_dialog_values(dialog))
-
-    assert len(clean_qgis_iface.mapCanvas().layers()) == 1
-    assert clean_qgis_iface.mapCanvas().layers()[0].name() == "USGS/SRTMGL1_003"
-    assert clean_qgis_iface.mapCanvas().layers()[0].dataProvider().name() == "EE"
-
-
-def test_invalid_viz_params(clean_qgis_iface):
-    dialog = form()
-    dialog.findChild(QtWidgets.QLineEdit, "image_id").setText("USGS/SRTMGL1_003")
-
-    dialog.findChild(QtWidgets.QTextEdit, "viz_params").setText(
-        "not a valid JSON string"
-    )
-
-    callback(**get_dialog_values(dialog))
-
-    assert len(clean_qgis_iface.mapCanvas().layers()) == 0
-
-
-def test_empty_viz_params(clean_qgis_iface):
-    dialog = form()
-    dialog.findChild(QtWidgets.QLineEdit, "image_id").setText("USGS/SRTMGL1_003")
-
-    dialog.findChild(QtWidgets.QTextEdit, "viz_params").setText("")
-
-    callback(**get_dialog_values(dialog))
-
-    assert len(clean_qgis_iface.mapCanvas().layers()) == 1
-    assert clean_qgis_iface.mapCanvas().layers()[0].name() == "USGS/SRTMGL1_003"
-    assert clean_qgis_iface.mapCanvas().layers()[0].dataProvider().name() == "EE"
diff --git a/test/test_form_add_image_collection.py b/test/test_form_add_image_collection.py
new file mode 100644
index 00000000..c6d57112
--- /dev/null
+++ b/test/test_form_add_image_collection.py
@@ -0,0 +1,175 @@
+from qgis.PyQt.QtCore import QDateTime
+from qgis.core import QgsProcessingContext, QgsProcessingFeedback
+
+from ee_plugin.processing.add_image_collection import AddImageCollectionAlgorithm
+
+
+def run_algorithm_with_params(params):
+    alg = AddImageCollectionAlgorithm()
+    alg.initAlgorithm(config=None)
+    context = QgsProcessingContext()
+    feedback = QgsProcessingFeedback()
+    return alg.processAlgorithm(params, context, feedback)
+
+
+def test_add_image_collection_algorithm_with_filters(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)  # Pass QDateTime object
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)  # Pass QDateTime object
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": "CLOUD_COVER:<:10;SUN_ELEVATION:>:0",
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Mosaic",
+        "percentile_value": None,
+    }
+
+    # Run the algorithm with parameters
+    run_algorithm_with_params(params)
+
+    # Validate that the layer was added to the map
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "IC: LANDSAT/LC09/C02/T1_L2 (Mosaic)"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_add_image_collection_algorithm_multiple_filters(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": "CLOUD_COVER:<:10;SUN_ELEVATION:>:0",
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Mosaic",
+        "percentile_value": None,
+    }
+
+    # Run the algorithm with parameters
+    run_algorithm_with_params(params)
+
+    # Validate that the layer was added to the map
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "IC: LANDSAT/LC09/C02/T1_L2 (Mosaic)"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_invalid_filters(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": "INVALID_FILTER",
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Mosaic",
+        "percentile_value": None,
+    }
+
+    # Run the algorithm with invalid filters
+    try:
+        run_algorithm_with_params(params)
+    except Exception:
+        pass
+
+    # Verify no layer is added to the map
+    assert len(clean_qgis_iface.mapCanvas().layers()) == 0
+
+
+def test_add_image_collection_algorithm_empty_filters(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": None,
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Mosaic",
+        "percentile_value": None,
+    }
+
+    # Run the algorithm with empty filters
+    run_algorithm_with_params(params)
+
+    # Validate that the layer was added to the map
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "IC: LANDSAT/LC09/C02/T1_L2 (Mosaic)"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_add_image_collection_algorithm_percentile_compositing(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": "CLOUD_COVER:<:10",
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Percentile",
+        "percentile_value": 90,
+    }
+
+    # Run the algorithm with percentile compositing method
+    run_algorithm_with_params(params)
+
+    # Validate that the layer was added to the map with the correct compositing method
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "IC: LANDSAT/LC09/C02/T1_L2 (Percentile 90%)"
+    assert layer.dataProvider().name() == "EE"
+
+
+def test_add_image_collection_algorithm_invalid_json_viz_params(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": "CLOUD_COVER:<:10",
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Mosaic",
+        "percentile_value": None,
+        "viz_params": "not a valid JSON string",
+    }
+
+    try:
+        run_algorithm_with_params(params)
+    except Exception:
+        pass
+
+    # Verify no layer is added to the map
+    assert len(clean_qgis_iface.mapCanvas().layers()) == 0
+
+
+def test_empty_viz_params(clean_qgis_iface):
+    start_date = QDateTime(2021, 1, 1, 12, 0, 0)
+    end_date = QDateTime(2021, 12, 31, 12, 0, 0)
+
+    params = {
+        "image_collection_id": "LANDSAT/LC09/C02/T1_L2",
+        "filters": "CLOUD_COVER:<:10",
+        "start_date": start_date,
+        "end_date": end_date,
+        "extent": None,
+        "compositing_method": "Mosaic",
+        "percentile_value": None,
+        "viz_params": "",
+    }
+
+    run_algorithm_with_params(params)
+
+    # Validate that the layer was added to the map
+    layer = clean_qgis_iface.mapCanvas().layers()[0]
+    assert layer.name() == "IC: LANDSAT/LC09/C02/T1_L2 (Mosaic)"
+    assert layer.dataProvider().name() == "EE"


Reviews:
user: login: alukach
body: Generally, looks great!  Added a note about chaining errors, but that's being pedantic and it's not a critical change.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-03-18T19:38:09Z

Review comments:
diff_hunk: @@ -0,0 +1,278 @@
+import json
+import logging
+from typing import Optional, Callable
+
+import ee
+from qgis import gui
+from qgis.PyQt import QtWidgets
+
+from .. import widgets, utils as ui_utils
+from ... import Map
+from ...utils import translate as _
+
+logger = logging.getLogger(__name__)
+
+
+def form(
+    accepted: Optional[Callable] = None,
+    **dialog_kwargs,
+) -> QtWidgets.QDialog:
+    """Add a GEE Image Collection to the map."""
+    composite_combo_box = QtWidgets.QComboBox(
+        objectName="compositing_method",
+        toolTip="Choose a compositing method.",
+    )
+    composite_combo_box.addItems(
+        ["Mosaic", "Mean", "Max", "Min", "Median", "Percentile"]
+    )
+
+    percentile_slider = widgets.LabeledSlider(
+        min_value=0,
+        max_value=100,
+        default_value=50,
+        object_name="percentile_value",
+        label="Percentile Value",
+        visible=False,  # Initially hidden
+    )
+
+    composite_group = widgets.build_form_group_box(
+        title="Compositing Options",
+        collapsable=True,
+        collapsed=True,
+        rows=[
+            (
+                QtWidgets.QLabel(
+                    text="Compositing Method",
+                    toolTip="Select how to composite the Image Collection (e.g., Mosaic, Mean, Max, Min, Median, Percentile).",
+                ),
+                composite_combo_box,
+            ),
+        ],
+    )
+
+    def update_percentile_visibility():
+        """Show the percentile slider only when 'Percentile' is selected"""
+        is_percentile = composite_combo_box.currentText() == "Percentile"
+        if (
+            composite_group.findChild(QtWidgets.QWidget, "percentile_value") is None
+            and is_percentile
+        ):
+            composite_group.layout().addRow(percentile_slider)
+
+        percentile_slider.update_label(percentile_slider.slider.value())
+        percentile_slider.set_visibility(is_percentile)
+
+    # Connect signal to update visibility
+    composite_combo_box.currentIndexChanged.connect(update_percentile_visibility)
+
+    dialog = widgets.build_vbox_dialog(
+        windowTitle=_("Add Image Collection"),
+        widgets=[
+            widgets.build_form_group_box(
+                title=_("Source"),
+                rows=[
+                    (
+                        QtWidgets.QLabel(
+                            toolTip=_("The Earth Engine Image Collection ID."),
+                            text="<br />".join(
+                                [
+                                    _("Image Collection ID"),
+                                    "e.g. <code>LANDSAT/LC09/C02/T1_L2</code>",
+                                ]
+                            ),
+                        ),
+                        QtWidgets.QLineEdit(
+                            objectName="image_collection_id",
+                            toolTip=_(
+                                "Enter the ID of the Earth Engine Image Collection (e.g., LANDSAT/LC09/C02/T1_L2)."
+                            ),
+                        ),
+                    ),
+                ],
+            ),
+            widgets.build_form_group_box(
+                title=_("Filter by Properties"),
+                collapsable=True,
+                collapsed=True,
+                rows=[
+                    (
+                        QtWidgets.QLabel(
+                            text=_("Filters:"),
+                            toolTip=_("Add multiple filters by clicking 'Add Filter'."),
+                        ),
+                        widgets.create_filter_widget(),  # Updated: Ensure object name is set
path: ee_plugin/ui/forms/add_image_collection.py
user: login: alukach
body: Not sure what we should do with this comment...  is it worth keeping in the codebase?
created_at: 2025-03-18T19:32:58Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,278 @@
+import json
+import logging
+from typing import Optional, Callable
+
+import ee
+from qgis import gui
+from qgis.PyQt import QtWidgets
+
+from .. import widgets, utils as ui_utils
+from ... import Map
+from ...utils import translate as _
+
+logger = logging.getLogger(__name__)
+
+
+def form(
+    accepted: Optional[Callable] = None,
+    **dialog_kwargs,
+) -> QtWidgets.QDialog:
+    """Add a GEE Image Collection to the map."""
+    composite_combo_box = QtWidgets.QComboBox(
+        objectName="compositing_method",
+        toolTip="Choose a compositing method.",
+    )
+    composite_combo_box.addItems(
+        ["Mosaic", "Mean", "Max", "Min", "Median", "Percentile"]
+    )
+
+    percentile_slider = widgets.LabeledSlider(
+        min_value=0,
+        max_value=100,
+        default_value=50,
+        object_name="percentile_value",
+        label="Percentile Value",
+        visible=False,  # Initially hidden
+    )
+
+    composite_group = widgets.build_form_group_box(
+        title="Compositing Options",
+        collapsable=True,
+        collapsed=True,
+        rows=[
+            (
+                QtWidgets.QLabel(
+                    text="Compositing Method",
+                    toolTip="Select how to composite the Image Collection (e.g., Mosaic, Mean, Max, Min, Median, Percentile).",
+                ),
+                composite_combo_box,
+            ),
+        ],
+    )
+
+    def update_percentile_visibility():
+        """Show the percentile slider only when 'Percentile' is selected"""
+        is_percentile = composite_combo_box.currentText() == "Percentile"
+        if (
+            composite_group.findChild(QtWidgets.QWidget, "percentile_value") is None
+            and is_percentile
+        ):
+            composite_group.layout().addRow(percentile_slider)
+
+        percentile_slider.update_label(percentile_slider.slider.value())
+        percentile_slider.set_visibility(is_percentile)
+
+    # Connect signal to update visibility
+    composite_combo_box.currentIndexChanged.connect(update_percentile_visibility)
+
+    dialog = widgets.build_vbox_dialog(
+        windowTitle=_("Add Image Collection"),
+        widgets=[
+            widgets.build_form_group_box(
+                title=_("Source"),
+                rows=[
+                    (
+                        QtWidgets.QLabel(
+                            toolTip=_("The Earth Engine Image Collection ID."),
+                            text="<br />".join(
+                                [
+                                    _("Image Collection ID"),
+                                    "e.g. <code>LANDSAT/LC09/C02/T1_L2</code>",
+                                ]
+                            ),
+                        ),
+                        QtWidgets.QLineEdit(
+                            objectName="image_collection_id",
+                            toolTip=_(
+                                "Enter the ID of the Earth Engine Image Collection (e.g., LANDSAT/LC09/C02/T1_L2)."
+                            ),
+                        ),
+                    ),
+                ],
+            ),
+            widgets.build_form_group_box(
+                title=_("Filter by Properties"),
+                collapsable=True,
+                collapsed=True,
+                rows=[
+                    (
+                        QtWidgets.QLabel(
+                            text=_("Filters:"),
+                            toolTip=_("Add multiple filters by clicking 'Add Filter'."),
+                        ),
+                        widgets.create_filter_widget(),  # Updated: Ensure object name is set
+                    ),
+                ],
+            ),
+            widgets.build_form_group_box(
+                title=_("Filter by Dates"),
+                collapsable=True,
+                collapsed=True,
+                rows=[
+                    (
+                        "Start",
+                        widgets.DefaultNullQgsDateEdit(
+                            objectName="start_date",
+                            toolTip=_(
+                                "Select the start date for filtering the image collection."
+                            ),
+                        ),
+                    ),
+                    (
+                        "End",
+                        widgets.DefaultNullQgsDateEdit(
+                            objectName="end_date",
+                            toolTip=_(
+                                "Select the end date for filtering the image collection."
+                            ),
+                        ),
+                    ),
+                ],
+            ),
+            gui.QgsExtentGroupBox(
+                objectName="extent",
+                title=_("Filter by Coordinates"),
+                collapsed=True,
+                toolTip=_(
+                    "Specify the geographic extent to filter the image collection."
+                ),
+            ),
+            widgets.build_form_group_box(
+                title="Visualization Parameters (JSON)",
+                collapsable=True,
+                collapsed=True,
+                rows=[
+                    (
+                        QtWidgets.QLabel(
+                            text="Enter JSON for visualization parameters",
+                            toolTip="Example: {'bands':['SR_B4','SR_B3','SR_B2'],'min':0,'max':30000,'gamma':1.3}",
+                        ),
+                        QtWidgets.QTextEdit(
+                            objectName="viz_params",
+                            placeholderText="{'bands':['SR_B4','SR_B3','SR_B2'],'min':0,'max':30000,'gamma':1.3}",
+                        ),
+                    )
+                ],
+            ),
+            composite_group,
+        ],
+        **dialog_kwargs,
+    )
+
+    composite_combo_box.setCurrentIndex(0)  # Default to Mosaic
+
+    # If a callback function is passed, call it with the values from the dialog
+    if accepted:
+        dialog.accepted.connect(
+            lambda: ui_utils.call_func_with_values(accepted, dialog)
+        )
+    return dialog
+
+
+def callback(
+    image_collection_id: str,
+    start_date: Optional[str],
+    end_date: Optional[str],
+    extent: Optional[tuple[float, float, float, float]],
+    filters: Optional[list] = None,  # New parameter for multiple filters
+    viz_params: Optional[dict] = None,
+    compositing_method: Optional[str] = "Mosaic",
+    percentile_value: Optional[int] = None,
+):
+    """
+    Loads and optionally filters an ImageCollection, then adds it to the map.
+
+    Args:
+        image_collection_id (str): The Earth Engine ImageCollection ID.
+        start_date (str, optional): Start date (YYYY-MM-DD) for filtering.
+        end_date (str, optional): End date (YYYY-MM-DD) for filtering.
+        extent (ee.Geometry, optional): Geometry to filter (or clip) the ImageCollection.
+        filters (list, optional): List of property filters to apply.
+
+    Returns:
+        ee.ImageCollection: The filtered ImageCollection.
+    """
+    ic = ee.ImageCollection(image_collection_id)
+
+    if filters:
+        # Define a dictionary to map operators to corresponding ee.Filter functions
+        filter_functions = {
+            "Equals (==)": ee.Filter.eq,
+            "Not Equals (!=)": ee.Filter.neq,
+            "Less Than (<)": ee.Filter.lt,
+            "Greater Than (>)": ee.Filter.gt,
+            "Less Than or Equal (<=)": ee.Filter.lte,
+            "Greater Than or Equal (>=)": ee.Filter.gte,
+        }
+
+        for f in filters:
+            filter_name, filter_operator, filter_value = f
+            # Convert filter_value to float if it's a valid number, otherwise keep as string
+            filter_value = (
+                float(filter_value)
+                if filter_value.replace(".", "", 1).isdigit()
+                else filter_value
+            )
+            filter_func = filter_functions.get(filter_operator)
+            if filter_func:
+                ic = ic.filter(filter_func(filter_name, filter_value))
+            else:
+                # Log an error if the filter operator is invalid
+                # somewhat hot take:
+                # do not add ImageCollection to the map
+                # if the filter operator is invalid, could lead to unexpected results for the user
+                logger.error(f"Invalid filter operator: {filter}.")
+                return
+
+    # Apply date filter if specified
+    if start_date and end_date:
+        ic = ic.filter(ee.Filter.date(ee.Date(start_date), ee.Date(end_date)))
+
+    # Apply spatial filter if extent is specified
+    if extent:
+        ic = ic.filterBounds(ee.Geometry.Rectangle(extent))
+
+    # Get visualization parameters from user as JSON
+    if not viz_params:
+        viz_params = {}
+    elif isinstance(viz_params, str):  # Ensure it's a string before replacing quotes
+        try:
+            viz_params = json.loads(viz_params.replace("'", '"'))
+        except json.JSONDecodeError as e:
+            logger.error(f"Invalid JSON format in visualization parameters: {str(e)}")
+            raise json.JSONDecodeError(
+                "Invalid JSON format in visualization parameters",
+                viz_params,  # The problematic string
+                0,  # Position of error (use actual error position if available)
+            )
path: ee_plugin/ui/forms/add_image_collection.py
user: login: alukach
body: When recasting errors like this, it's best to instead [_chain_ errors](https://docs.python.org/3/tutorial/errors.html#exception-chaining):

```suggestion
            raise BadInput(
                "Invalid JSON format in visualization parameters",
                viz_params,  # The problematic string
                0,  # Position of error (use actual error position if available)
            ) from e
```

This way, we can coerce an error to something that we control (e.g. `BadInput`) and handle however we want, or let it raise and the stack trace will demonstrate what caused the error.
created_at: 2025-03-18T19:37:27Z
author_association: COLLABORATOR

PR comments:
user: login: zacdezgeo
created_at: 2025-03-13T18:41:43Z
body: Added the compositing options and visualization params input:
https://github.com/user-attachments/assets/36de3b24-4129-4f91-9cce-b8fe5e0646c8

cc: @aSullivan-geo @gena @KMarkert 


author_association: COLLABORATOR
user: login: aSullivan-geo
created_at: 2025-03-14T21:10:39Z
body: That is really slick @zacdezgeo !

@gena I think this covers most of what you are asking for? It would be great to test it on a few other datasets, but if its stable that seems like a great addition. I'll add it to the backlog of the ArcGIS toolbox too. 

Other datasets to try - 

- [Dynamic world](https://developers.google.com/earth-engine/datasets/catalog/GOOGLE_DYNAMICWORLD_V1)
- [MethaneSat](https://developers.google.com/earth-engine/datasets/catalog/projects_edf-methanesat-ee_assets_public-preview_L3concentration)
- [Weather Next](https://developers.google.com/earth-engine/datasets/catalog/projects_gcp-public-data-weathernext_assets_126478713_1_0#bands)
- [Rubber probability](https://developers.google.com/earth-engine/datasets/catalog/projects_forestdatapartnership_assets_rubber_model_2024a)
- [JRC global forest V2](https://developers.google.com/earth-engine/datasets/catalog/JRC_GFC2020_V2)

Main reason why I chose these was that they are new, not "traditional" imagery data like Landsat, and may be a little complex


author_association: COLLABORATOR
user: login: gena
created_at: 2025-03-14T21:25:04Z
body: Yes, looks great!

I'd probably hide that percentiles textbox unless the method is percentile, or maybe even put a 0-100 slider there. But we can improve it later.

I did not check the code, but something to test is if saving/opening the project persists these layers (it should).
author_association: COLLABORATOR
user: login: zacdezgeo
created_at: 2025-03-15T17:50:42Z
body: @gena; I reported a bug on layer persistence here: https://github.com/gee-community/qgis-earthengine-plugin/issues/238. It wasn't for `ImageCollection`. I'll test more cases and document there.
author_association: COLLABORATOR
user: login: zacdezgeo
created_at: 2025-03-15T17:51:28Z
body: > Other datasets to try -
> 
> * [Dynamic world](https://developers.google.com/earth-engine/datasets/catalog/GOOGLE_DYNAMICWORLD_V1)
> * [MethaneSat](https://developers.google.com/earth-engine/datasets/catalog/projects_edf-methanesat-ee_assets_public-preview_L3concentration)
> * [Weather Next](https://developers.google.com/earth-engine/datasets/catalog/projects_gcp-public-data-weathernext_assets_126478713_1_0#bands)
> * [Rubber probability](https://developers.google.com/earth-engine/datasets/catalog/projects_forestdatapartnership_assets_rubber_model_2024a)
> * [JRC global forest V2](https://developers.google.com/earth-engine/datasets/catalog/JRC_GFC2020_V2)

@aSullivan-geo; I can make some automated test cases for those as well.
author_association: COLLABORATOR
user: login: zacdezgeo
created_at: 2025-03-17T20:10:29Z
body: @aSullivan-geo @gena; I made the percentile slider only visible when the compositing method for percentile is selected. Also added min and max to the slider labels. Here's an update for the other datasets:
- Unit test added for Dynamic World and tested within the GUI
- For MethaneSat, I don't seem to have access to it: `ee.ee_exception.EEException: ImageCollection.load: ImageCollection asset 'projects/edf-methanesat-ee/assets/public-preview/L3concentration' not found (does not exist or caller does not have access).`
- Same for WeatherNext
- Same for rubber probability
- Unit test added for JRC Global Forest


author_association: COLLABORATOR
user: login: zacdezgeo
created_at: 2025-03-23T23:52:33Z
body: Converted to draft to refactor based on work in https://github.com/gee-community/qgis-earthengine-plugin/pull/243
author_association: COLLABORATOR