diff --git a/apps/common/migrations/0003_alter_announcement_firebase_id.py b/apps/common/migrations/0003_alter_announcement_firebase_id.py new file mode 100644 index 00000000..69940894 --- /dev/null +++ b/apps/common/migrations/0003_alter_announcement_firebase_id.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.5 on 2026-02-17 12:03 + +import ulid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='announcement', + name='firebase_id', + field=models.CharField(default=ulid.ULID, max_length=36, unique=True), + ), + ] diff --git a/apps/common/models.py b/apps/common/models.py index ee613770..28e01367 100644 --- a/apps/common/models.py +++ b/apps/common/models.py @@ -139,7 +139,8 @@ class FirebasePushResource(Model): # NOTE: We should not directly use old_id. This is ID reference to old system old_id = models.CharField[str | None, str | None](max_length=30, db_index=True, null=True, blank=True) - firebase_id = models.CharField[str, str](max_length=30, unique=True, default=ULID) + # NOTE: Panoramax uses UUIDv4 (length 36) for identifiers, which we use as firebase_id for street project tasks + firebase_id = models.CharField[str, str](max_length=36, unique=True, default=ULID) firebase_push_status: int | None = IntegerChoicesField( # type: ignore[reportAssignmentType] choices_enum=FirebasePushStatusEnum, diff --git a/apps/contributor/migrations/0003_alter_contributorteam_firebase_id_and_more.py b/apps/contributor/migrations/0003_alter_contributorteam_firebase_id_and_more.py new file mode 100644 index 00000000..1cdc8994 --- /dev/null +++ b/apps/contributor/migrations/0003_alter_contributorteam_firebase_id_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.5 on 2026-02-17 12:03 + +import ulid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contributor', '0002_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='contributorteam', + name='firebase_id', + field=models.CharField(default=ulid.ULID, max_length=36, unique=True), + ), + migrations.AlterField( + model_name='contributorusergroup', + name='firebase_id', + field=models.CharField(default=ulid.ULID, max_length=36, unique=True), + ), + ] diff --git a/apps/project/graphql/inputs/project_types/street.py b/apps/project/graphql/inputs/project_types/street.py index 1090cfe9..ad535baf 100644 --- a/apps/project/graphql/inputs/project_types/street.py +++ b/apps/project/graphql/inputs/project_types/street.py @@ -1,10 +1,15 @@ import strawberry from project_types.street import project as street_project +from utils.geo.street_image_provider.models import StreetImageProvider -@strawberry.experimental.pydantic.input(model=street_project.StreetMapillaryImageFilters, all_fields=True) -class StreetMapillaryImageFiltersInput: ... +@strawberry.experimental.pydantic.input(model=street_project.StreetImageFilters, all_fields=True) +class StreetImageFiltersInput: ... + + +@strawberry.experimental.pydantic.input(model=StreetImageProvider, all_fields=True) +class StreetImageProviderInput: ... @strawberry.experimental.pydantic.input(model=street_project.StreetProjectProperty, all_fields=True) diff --git a/apps/project/graphql/types/project_types/street.py b/apps/project/graphql/types/project_types/street.py index 9b350cf7..67b920c3 100644 --- a/apps/project/graphql/types/project_types/street.py +++ b/apps/project/graphql/types/project_types/street.py @@ -1,10 +1,15 @@ import strawberry from project_types.street import project as street_project +from utils.geo.street_image_provider.models import StreetImageProvider as StreetImageProviderModel -@strawberry.experimental.pydantic.type(model=street_project.StreetMapillaryImageFilters, all_fields=True) -class StreetMapillaryImageFilters: ... +@strawberry.experimental.pydantic.type(model=street_project.StreetImageFilters, all_fields=True) +class StreetImageFilters: ... + + +@strawberry.experimental.pydantic.type(model=StreetImageProviderModel, all_fields=True) +class StreetImageProvider: ... @strawberry.experimental.pydantic.type(model=street_project.StreetProjectProperty, all_fields=True) diff --git a/apps/project/migrations/0011_alter_projecttask_firebase_id.py b/apps/project/migrations/0011_alter_projecttask_firebase_id.py new file mode 100644 index 00000000..3565ac35 --- /dev/null +++ b/apps/project/migrations/0011_alter_projecttask_firebase_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-11-12 15:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0010_alter_projecttask_unique_together'), + ] + + operations = [ + migrations.AlterField( + model_name='projecttask', + name='firebase_id', + field=models.CharField(max_length=36), + ), + ] diff --git a/apps/project/migrations/0012_alter_organization_firebase_id_and_more.py b/apps/project/migrations/0012_alter_organization_firebase_id_and_more.py new file mode 100644 index 00000000..c1ecf950 --- /dev/null +++ b/apps/project/migrations/0012_alter_organization_firebase_id_and_more.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.5 on 2026-02-17 12:03 + +import ulid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0011_alter_projecttask_firebase_id'), + ] + + operations = [ + migrations.AlterField( + model_name='organization', + name='firebase_id', + field=models.CharField(default=ulid.ULID, max_length=36, unique=True), + ), + migrations.AlterField( + model_name='project', + name='firebase_id', + field=models.CharField(default=ulid.ULID, max_length=36, unique=True), + ), + ] diff --git a/apps/project/migrations/0013_backfill_image_provider_for_street_projects.py b/apps/project/migrations/0013_backfill_image_provider_for_street_projects.py new file mode 100644 index 00000000..06dcbc8e --- /dev/null +++ b/apps/project/migrations/0013_backfill_image_provider_for_street_projects.py @@ -0,0 +1,32 @@ +# Generated by Django 5.2.5 on 2026-04-28 14:28 + +from django.db import migrations + +def backfill_image_provider(apps, schema_editor): + Project = apps.get_model('project', 'Project') + + for project in Project._default_manager.all(): + if project.project_type != 7: # STREET type + continue + + if project.project_type_specifics is None: + project.project_type_specifics = {} + + if 'imageProvider' not in project.project_type_specifics: + project.project_type_specifics['imageProvider'] = { + 'name': 'MAPILLARY' + } + project.save(update_fields=['project_type_specifics']) + +def reverse_backfill(apps, schema_editor): + pass + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0012_alter_organization_firebase_id_and_more'), + ] + + operations = [ + migrations.RunPython(backfill_image_provider, reverse_backfill), + ] diff --git a/apps/project/migrations/0014_migrate_pano_only_for_street_projects.py b/apps/project/migrations/0014_migrate_pano_only_for_street_projects.py new file mode 100644 index 00000000..97a70c46 --- /dev/null +++ b/apps/project/migrations/0014_migrate_pano_only_for_street_projects.py @@ -0,0 +1,75 @@ +# Generated by Django 5.2.5 on 2026-04-28 14:59 + +import logging + +from django.db import migrations + +logger = logging.getLogger(__name__) + + +def migrate_is_pano_to_pano_only(apps, schema_editor): + """ + Migrate isPano field to panoOnly in mapillaryImageFilters + for all STREET-type projects to conform with updated schema. + """ + Project = apps.get_model('project', 'Project') + + migrated_count = 0 + + for project in Project._default_manager.all(): + if project.project_type != 7: # STREET type + continue + + if project.project_type_specifics is None: + continue + + if 'mapillaryImageFilters' in project.project_type_specifics: + filters = project.project_type_specifics['mapillaryImageFilters'] + if isinstance(filters, dict) and 'isPano' in filters: + logger.info( + f"Migrating isPano to panoOnly for project {project.id} ({project.name})" + ) + filters['panoOnly'] = filters.pop('isPano') + project.save(update_fields=['project_type_specifics']) + migrated_count += 1 + + logger.info(f"Field migration completed: {migrated_count} projects migrated isPano to panoOnly") + + +def reverse_pano_migration(apps, schema_editor): + """ + Revert panoOnly back to isPano for compatibility. + """ + Project = apps.get_model('project', 'Project') + + reverted_count = 0 + + for project in Project._default_manager.all(): + if project.project_type != 7: # STREET type + continue + + if project.project_type_specifics is None: + continue + + if 'mapillaryImageFilters' in project.project_type_specifics: + filters = project.project_type_specifics['mapillaryImageFilters'] + if isinstance(filters, dict) and 'panoOnly' in filters: + logger.info( + f"Reverting panoOnly to isPano for project {project.id} ({project.name})" + ) + filters['isPano'] = filters.pop('panoOnly') + project.save(update_fields=['project_type_specifics']) + reverted_count += 1 + + logger.info(f"Reverse migration completed: {reverted_count} projects reverted panoOnly to isPano") + + +class Migration(migrations.Migration): + + dependencies = [ + ('project', '0013_backfill_image_provider_for_street_projects'), + ] + + operations = [ + migrations.RunPython(migrate_is_pano_to_pano_only, reverse_pano_migration), + ] diff --git a/apps/project/models.py b/apps/project/models.py index 44aa399c..479f4ae2 100644 --- a/apps/project/models.py +++ b/apps/project/models.py @@ -671,7 +671,7 @@ class ProjectTask(FirebasePushResource): in the context of a larger mapping or data collection project. """ - firebase_id = models.CharField[str, str](max_length=30) + firebase_id = models.CharField[str, str](max_length=36) task_group: ProjectTaskGroup = models.ForeignKey[ProjectTaskGroup, ProjectTaskGroup]( # type: ignore[reportAssignmentType] ProjectTaskGroup, diff --git a/apps/project/tests/e2e_create_street_project_test.py b/apps/project/tests/e2e_create_street_project_test.py index 65d93957..0d32451d 100644 --- a/apps/project/tests/e2e_create_street_project_test.py +++ b/apps/project/tests/e2e_create_street_project_test.py @@ -572,6 +572,12 @@ def _test_project(self, filename: str): sanitized_tasks_actual = project_tasks_fb_data sanitized_tasks_expected = test_data["expected_project_tasks_data"] + for tasks in sanitized_tasks_expected.values(): + for task in tasks: + task_id = task.get("taskId") + if isinstance(task_id, str) and task_id.isdigit(): + task["taskId"] = int(task_id) + assert sanitized_tasks_actual == sanitized_tasks_expected, ( "Differences found between expected and actual tasks on project in firebase." ) diff --git a/apps/project/tests/mutation_test.py b/apps/project/tests/mutation_test.py index f406ac4a..21a2d214 100644 --- a/apps/project/tests/mutation_test.py +++ b/apps/project/tests/mutation_test.py @@ -1652,7 +1652,7 @@ def test_project_street(self, mock_requests): # type: ignore[reportMissingParam ], }, "mapillaryImageFilters": { - "isPano": True, + "panoOnly": True, "creatorId": None, "organizationId": None, "startTime": None, @@ -1660,6 +1660,9 @@ def test_project_street(self, mock_requests): # type: ignore[reportMissingParam "randomizeOrder": False, "samplingThreshold": None, }, + "imageProvider": { + "name": "MAPILLARY", + }, }, }, } diff --git a/assets b/assets index 432bbd5a..f6c4516f 160000 --- a/assets +++ b/assets @@ -1 +1 @@ -Subproject commit 432bbd5a38ac5bc13bb3f8e2f30e36ccfc3cc41c +Subproject commit f6c4516f2b2cda6c03e1b0c1db5bf2981d06686e diff --git a/firebase b/firebase index 8b0d6833..ffcb9839 160000 --- a/firebase +++ b/firebase @@ -1 +1 @@ -Subproject commit 8b0d6833579d182dab7d7ea1689e6b5c83d124b7 +Subproject commit ffcb983933af2ae76027b6323a40613d06a21bc6 diff --git a/main/config.py b/main/config.py index af2fc428..907c99a2 100644 --- a/main/config.py +++ b/main/config.py @@ -44,6 +44,7 @@ class Config: # NOTE: We get mapillary data from mapillary MAPILLARY_API_LINK = "https://tiles.mapillary.com/maps/vtp/mly1_computed_public/2/" MAPILLARY_API_KEY = typing.cast("str", settings.MAPILLARY_API_KEY) + PANORAMAX_API_LINK = "https://api.panoramax.xyz/" FIREBASE_HELPER = typing.cast("FirebaseHelper", settings.FIREBASE_HELPER) FIREBASE_EMULATOR_USE = typing.cast("str | None", settings.FIREBASE_EMULATOR_USE) diff --git a/main/graphql/enums.py b/main/graphql/enums.py index a992c7ab..6f3a285e 100644 --- a/main/graphql/enums.py +++ b/main/graphql/enums.py @@ -11,10 +11,12 @@ from project_types.validate.project import ValidateObjectSourceTypeEnum from project_types.validate_image.project import ValidateImageSourceTypeEnum from utils.geo.raster_tile_server.config import RasterTileServerNameEnum +from utils.geo.street_image_provider.models import StreetImageProviderNameEnum from utils.geo.vector_tile_server.config import VectorTileServerNameEnum ENUM_TO_STRAWBERRY_ENUMS: list[type] = [ RasterTileServerNameEnum, + StreetImageProviderNameEnum, VectorTileServerNameEnum, ValidateObjectSourceTypeEnum, ValidateImageSourceTypeEnum, diff --git a/project_types/street/api_calls.py b/project_types/street/api_calls.py index 2b1128fd..c6a44983 100644 --- a/project_types/street/api_calls.py +++ b/project_types/street/api_calls.py @@ -2,7 +2,7 @@ from collections.abc import Hashable from concurrent.futures import ProcessPoolExecutor from functools import partial -from typing import Any +from typing import Any, assert_never from warnings import deprecated import mercantile # type: ignore[reportMissingTypeStubs] @@ -25,6 +25,7 @@ from main.logging import log_extra_response from project_types.base.project import ValidationException from utils.common import Grouping +from utils.geo.street_image_provider.models import StreetImageProvider, StreetImageProviderNameEnum from utils.spatial_sampling import spatial_sampling logger = logging.getLogger(__name__) @@ -37,6 +38,10 @@ class StreetException(Exception): pass +class StreetConnectionError(StreetException): + pass + + def create_tiles( *, polygon: ShapelyBaseGeometry, @@ -109,6 +114,7 @@ def coordinate_download( *, polygon: ShapelyBaseGeometry, level: int, + provider: StreetImageProvider, kwargs: dict[str, Any], ) -> pd.DataFrame: tiles = create_tiles( @@ -118,13 +124,18 @@ def coordinate_download( if tiles.empty: return pd.DataFrame() - logger.info("Images will be queried in roughly %s requests from mapillary", len(tiles.index)) + logger.info( + "Images will be queried in roughly %s requests from %s", + len(tiles.index), + getattr(provider.name, "value", "unknown provider"), + ) downloaded_metadata: list[pd.DataFrame] = [] for i, row in enumerate(tiles.to_dict(orient="records")): df = download_and_process_tile( row=row, polygon=polygon, + provider=provider, kwargs=kwargs, ) if df is not None and not df.empty: @@ -168,6 +179,7 @@ def download_and_process_tile( *, row: dict[Hashable, Any] | pd.Series, polygon: ShapelyBaseGeometry, + provider: StreetImageProvider, kwargs: dict[str, Any], attempt_limit: int = 3, ) -> pd.DataFrame | None: @@ -175,21 +187,46 @@ def download_and_process_tile( x = row["x"] y = row["y"] - url = f"{Config.MAPILLARY_API_LINK}{z}/{x}/{y}?access_token={Config.MAPILLARY_API_KEY}" + if provider.name == StreetImageProviderNameEnum.MAPILLARY: + base = Config.MAPILLARY_API_LINK.rstrip("/") + url = f"{base}/{z}/{x}/{y}?access_token={Config.MAPILLARY_API_KEY}" + elif provider.name in (StreetImageProviderNameEnum.PANORAMAX, StreetImageProviderNameEnum.PANORAMAX_CUSTOM): + base = (provider.url or Config.PANORAMAX_API_LINK).rstrip("/") + url = f"{base}/api/map/{z}/{x}/{y}.mvt" + else: + assert_never(provider.name) for _ in range(attempt_limit): try: - data = get_mapillary_data(url, x, y, z) + data = get_street_image_data(url, x, y, z) if data.isna().all() is False or data.empty is False: data = data[data["geometry"].apply(lambda point: point.within(polygon))] + if provider.name in (StreetImageProviderNameEnum.PANORAMAX, StreetImageProviderNameEnum.PANORAMAX_CUSTOM): + data = data.rename( + columns={ + "account_id": "creator_id", + "ts": "captured_at", + "type": "is_pano", + "first_sequence": "sequence_id", + }, + ) + data["is_pano"] = data["is_pano"].eq("equirectangular") + if "captured_at" in data.columns: + data["captured_at"] = pd.to_datetime( + data["captured_at"], + format="mixed", + errors="coerce", + ).dt.tz_localize(None) + if provider.name == StreetImageProviderNameEnum.MAPILLARY and "captured_at" in data.columns: + data["captured_at"] = pd.to_datetime(data["captured_at"], unit="ms") target_columns = [ "id", "geometry", "captured_at", "is_pano", "compass_angle", - "sequence", + "sequence_id", "organization_id", ] for col in target_columns: @@ -200,9 +237,14 @@ def download_and_process_tile( return data return None + except requests.exceptions.ConnectionError as e: + raise StreetConnectionError( + f"Could not connect to the image provider API. Please check that the URL is correct and accessible: '{url}'", + ) from e except StreetException: logger.warning( - "Error while fetching Mapillary data for tile %s/%s/%s", + "Error while fetching %s data for tile %s/%s/%s", + getattr(provider.name, "value", "unknown provider"), z, x, y, @@ -210,7 +252,7 @@ def download_and_process_tile( return None -def get_mapillary_data( +def get_street_image_data( url: str, x: int, y: int, @@ -219,7 +261,8 @@ def get_mapillary_data( response = requests.get(url, timeout=100) if response.status_code != 200: logger.warning( - "Mapillary API request failed", + "API request at %s failed", + url, extra=log_extra_response(response=response), ) raise StreetException @@ -245,7 +288,7 @@ def get_mapillary_data( def filter_results( results_df: pd.DataFrame, creator_id: int | None = None, - is_pano: bool | None = None, + pano_only: bool | None = None, organization_id: int | None = None, start_time: str | None = None, end_time: str | None = None, @@ -253,27 +296,27 @@ def filter_results( df = results_df.copy() if creator_id is not None: if df["creator_id"].isna().all(): - logger.info("No Mapillary Feature in the AoI has a 'creator_id' value.") + logger.info("No feature in the AoI has a 'creator_id' value.") return None df = df[df["creator_id"] == creator_id] - if is_pano is not None: + if pano_only is True: if df["is_pano"].isna().all(): - logger.info("No Mapillary Feature in the AoI has a 'is_pano' value.") + logger.info("No feature in the AoI has a 'is_pano' value.") return None - df = df[df["is_pano"] == is_pano] + df = df[df["is_pano"]] if organization_id is not None: if df["organization_id"].isna().all(): logger.info( - "No Mapillary Feature in the AoI has an 'organization_id' value.", + "No feature in the AoI has an 'organization_id' value.", ) return None df = df[df["organization_id"] == organization_id] if start_time is not None: if df["captured_at"].isna().all(): - logger.info("No Mapillary Feature in the AoI has a 'captured_at' value.") + logger.info("No feature in the AoI has a 'captured_at' value.") return None df = filter_by_timerange(df, start_time, end_time) @@ -281,7 +324,6 @@ def filter_results( def filter_by_timerange(df: pd.DataFrame, start_time: str, end_time: str | None = None) -> pd.DataFrame: - df["captured_at"] = pd.to_datetime(df["captured_at"], unit="ms") converted_start_time = pd.to_datetime(start_time).tz_localize(None) converted_end_time = pd.to_datetime(end_time).tz_localize(None) if end_time else pd.Timestamp.now().tz_localize(None) return df[(df["captured_at"] >= converted_start_time) & (df["captured_at"] <= converted_end_time)] @@ -290,38 +332,45 @@ def filter_by_timerange(df: pd.DataFrame, start_time: str, end_time: str | None def get_image_metadata( *, aoi_geojson: dict[str, Any], - level: int = 14, - is_pano: bool | None = None, + pano_only: bool | None = None, creator_id: str | None = None, organization_id: str | None = None, start_time: str | None = None, end_time: str | None = None, randomize_order: bool = False, sampling_threshold: int | None = None, + provider: StreetImageProvider | None = None, ) -> Grouping[StreetFeature]: kwargs = { - "is_pano": is_pano, + "pano_only": pano_only, "creator_id": creator_id, "organization_id": organization_id, "start_time": start_time, "end_time": end_time, } aoi_polygon = geojson_to_polygon(aoi_geojson) + + if provider is None: + provider = StreetImageProvider( + name=StreetImageProviderNameEnum.MAPILLARY, + ) + downloaded_metadata = coordinate_download( polygon=aoi_polygon, - level=level, + level=provider.tile_level, + provider=provider, kwargs=kwargs, ) if downloaded_metadata.empty or downloaded_metadata.isna().all() is True: raise ValidationException( - "No Mapillary features found in the area of interest with the provided filters.", - ) - if sampling_threshold is not None: - downloaded_metadata = spatial_sampling( - df=downloaded_metadata, - interval_length=sampling_threshold, + "No features found in the area of interest with the provided filters.", ) + downloaded_metadata = spatial_sampling( + df=downloaded_metadata, + interval_length=sampling_threshold, + ) + if randomize_order is True: downloaded_metadata = downloaded_metadata.sample(frac=1).reset_index(drop=True) diff --git a/project_types/street/project.py b/project_types/street/project.py index d03cc827..ac92fb4d 100644 --- a/project_types/street/project.py +++ b/project_types/street/project.py @@ -21,17 +21,18 @@ ) from main.bulk_managers import BulkCreateManager from project_types.base import project as base_project -from project_types.street.api_calls import StreetFeature, get_image_metadata +from project_types.street.api_calls import StreetConnectionError, StreetFeature, get_image_metadata from utils import fields as custom_fields from utils.asset_types.models import AoiGeometryAssetProperty from utils.common import Grouping, create_json_dump from utils.custom_options.models import CustomOption +from utils.geo.street_image_provider.models import StreetImageProvider, StreetImageProviderNameEnum logger = logging.getLogger(__name__) -class StreetMapillaryImageFilters(BaseModel): - is_pano: custom_fields.PydanticBool | None = None +class StreetImageFilters(BaseModel): + pano_only: custom_fields.PydanticBool | None = None creator_id: custom_fields.PydanticLongText | None = None organization_id: custom_fields.PydanticLongText | None = None start_time: custom_fields.PydanticDate | None = None @@ -43,7 +44,8 @@ class StreetMapillaryImageFilters(BaseModel): class StreetProjectProperty(base_project.BaseProjectProperty): aoi_geometry: custom_fields.PydanticId custom_options: list[CustomOption] | None = None - mapillary_image_filters: StreetMapillaryImageFilters + mapillary_image_filters: StreetImageFilters + image_provider: StreetImageProvider class StreetTaskGroupProperty(base_project.BaseProjectTaskGroupProperty): ... @@ -100,16 +102,22 @@ def validate(self) -> Grouping[StreetFeature]: mapillary_image_filters = self.project_type_specifics.mapillary_image_filters - return get_image_metadata( - aoi_geojson=aoi_geojson, - is_pano=mapillary_image_filters.is_pano, - creator_id=mapillary_image_filters.creator_id, - organization_id=mapillary_image_filters.organization_id, - start_time=mapillary_image_filters.start_time, - end_time=mapillary_image_filters.end_time, - randomize_order=mapillary_image_filters.randomize_order, - sampling_threshold=mapillary_image_filters.sampling_threshold, - ) + provider = self.project_type_specifics.image_provider + + try: + return get_image_metadata( + aoi_geojson=aoi_geojson, + pano_only=mapillary_image_filters.pano_only, + creator_id=mapillary_image_filters.creator_id, + organization_id=mapillary_image_filters.organization_id, + start_time=mapillary_image_filters.start_time, + end_time=mapillary_image_filters.end_time, + randomize_order=mapillary_image_filters.randomize_order, + sampling_threshold=mapillary_image_filters.sampling_threshold, + provider=provider, + ) + except StreetConnectionError as e: + raise base_project.ValidationException(str(e)) from e @typing.override def create_tasks( @@ -231,9 +239,13 @@ def compress_tasks_on_firebase(self) -> bool: @typing.override def get_task_specifics_for_firebase(self, task: ProjectTask): assert task.geometry is not None, "Task geometry must not be None" + image_provider = self.project_type_specifics.image_provider + task_id: int | str = task.firebase_id + if image_provider and image_provider.name == StreetImageProviderNameEnum.MAPILLARY: + task_id = int(task.firebase_id) + return firebase_models.FbMappingTaskStreetCreateOnlyInput( - # XXX: converting this to int for backwards compatibility - taskId=int(task.firebase_id), + taskId=task_id, groupId=task.task_group.firebase_id, ) @@ -246,6 +258,7 @@ def get_group_specifics_for_firebase(self, group: ProjectTaskGroup): @typing.override def get_project_specifics_for_firebase(self): custom_opts = self.project_type_specifics.custom_options + image_provider = self.project_type_specifics.image_provider number_of_groups = ProjectTaskGroup.objects.filter(project=self.project).count() return firebase_models.FbProjectStreetCreateOnlyInput( numberOfGroups=number_of_groups, @@ -270,4 +283,10 @@ def get_project_specifics_for_firebase(self): ] if custom_opts is not None else None, + imageProvider=firebase_models.FbObjImageProvider( + name=image_provider.name.value, + url=image_provider.url or None, + ) + if image_provider + else None, ) diff --git a/project_types/street/tests/api_calls_test.py b/project_types/street/tests/api_calls_test.py index 74259ef5..a9078ee4 100644 --- a/project_types/street/tests/api_calls_test.py +++ b/project_types/street/tests/api_calls_test.py @@ -20,6 +20,7 @@ geojson_to_polygon, get_image_metadata, ) +from utils.geo.street_image_provider.models import StreetImageProvider, StreetImageProviderNameEnum if typing.TYPE_CHECKING: from collections.abc import Hashable @@ -31,6 +32,7 @@ class TestTileGroupingFunctions(unittest.TestCase): empty_polygon: Polygon # type: ignore[reportUninitializedInstanceVariable] empty_geometry: GeometryCollection # type: ignore[reportUninitializedInstanceVariable] row: pd.Series # type: ignore[reportUninitializedInstanceVariable] + provider: StreetImageProvider # type: ignore[reportUninitializedInstanceVariable] @typing.override @classmethod @@ -41,6 +43,7 @@ def setUpClass(cls): with Path(Config.BASE_DIR, "assets/fixtures/mapillary_response.csv").open(encoding="utf-8") as file: df = pd.read_csv(file) df["geometry"] = df["geometry"].apply(wkt.loads) # type: ignore[reportArgumentType] + df["captured_at"] = pd.to_datetime(df["captured_at"], unit="ms") cls.fixture_df = df @typing.override @@ -50,6 +53,9 @@ def setUp(self): self.empty_polygon = Polygon() self.empty_geometry = GeometryCollection() self.row = pd.Series({"x": 1, "y": 1, "z": self.level}) + self.provider = StreetImageProvider( + name=StreetImageProviderNameEnum.MAPILLARY, + ) def test_create_tiles_with_valid_polygon(self): tiles = create_tiles(polygon=self.test_polygon, level=self.level) @@ -176,22 +182,75 @@ def test_download_and_process_tile_success(self, mock_get, mock_vt2geojson): # polygon = wkt.loads("POLYGON ((-1 -1, -1 1, 1 1, 1 -1, -1 -1))") - result = download_and_process_tile(row=row, polygon=polygon, kwargs={}) + result = download_and_process_tile(row=row, polygon=polygon, kwargs={}, provider=self.provider) assert result is not None assert len(result) == 1 assert result["geometry"][0].wkt == "POINT (0 0)" + @patch("project_types.street.api_calls.get_street_image_data") + def test_download_and_process_tile_panoramax(self, mock_get_street_image_data): + mock_get_street_image_data.return_value = pd.DataFrame( + [ + { + "geometry": wkt.loads("POINT (0 0)"), + "account_id": 1, + "ts": "2026-02-24T10:00:00Z", + "type": "equirectangular", + "first_sequence": 42, + }, + ], + ) + + row: dict[Hashable, typing.Any] = {"x": 1, "y": 1, "z": 14} + polygon = wkt.loads("POLYGON ((-1 -1, -1 1, 1 1, 1 -1, -1 -1))") + + provider = StreetImageProvider( + name=StreetImageProviderNameEnum.PANORAMAX, + url=None, + ) + + result = download_and_process_tile( + row=row, + polygon=polygon, + kwargs={}, + provider=provider, + ) + + assert result is not None + assert "creator_id" in result.columns + assert "captured_at" in result.columns + assert pd.api.types.is_datetime64_ns_dtype(result["captured_at"]) + assert result["captured_at"].iloc[0] == pd.to_datetime("2026-02-24T10:00:00Z").tz_localize(None) + assert "is_pano" in result.columns + assert result["is_pano"].iloc[0] + + @patch("project_types.street.api_calls.get_street_image_data") + def test_download_and_process_tile_returns_none(self, mock_get_street_image_data): + mock_get_street_image_data.return_value = pd.DataFrame() + + row: dict[Hashable, typing.Any] = {"x": 1, "y": 1, "z": 14} + polygon = wkt.loads("POLYGON ((-1 -1, -1 1, 1 1, 1 -1, -1 -1))") + + result = download_and_process_tile( + row=row, + polygon=polygon, + provider=self.provider, + kwargs={}, + ) + + assert result is None + @patch("project_types.street.api_calls.requests.get") def test_download_and_process_tile_failure(self, mock_get): # type: ignore[reportMissingParameterType] mock_response = MagicMock() mock_response.status_code = 500 mock_get.return_value = mock_response - result = download_and_process_tile(row=self.row, polygon=self.test_polygon, kwargs={}) + result = download_and_process_tile(row=self.row, polygon=self.test_polygon, kwargs={}, provider=self.provider) assert result is None - @patch("project_types.street.api_calls.get_mapillary_data") - def test_download_and_process_tile_spatial_filtering(self, mock_get_mapillary_data): # type: ignore[reportMissingParameterType] + @patch("project_types.street.api_calls.get_street_image_data") + def test_download_and_process_tile_spatial_filtering(self, mock_get_street_image_data): # type: ignore[reportMissingParameterType] inside_points = [ (0.2, 0.2), (0.5, 0.5), @@ -209,9 +268,9 @@ def test_download_and_process_tile_spatial_filtering(self, mock_get_mapillary_da for x, y in points ] - mock_get_mapillary_data.return_value = pd.DataFrame(data) + mock_get_street_image_data.return_value = pd.DataFrame(data) - metadata = download_and_process_tile(row=self.row, polygon=self.test_polygon, kwargs={}) + metadata = download_and_process_tile(row=self.row, polygon=self.test_polygon, kwargs={}, provider=self.provider) assert metadata is not None metadata = metadata.drop_duplicates() assert len(metadata) == len(inside_points) @@ -220,8 +279,20 @@ def test_download_and_process_tile_spatial_filtering(self, mock_get_mapillary_da def test_coordinate_download_with_failures(self, mock_download_and_process_tile): # type: ignore[reportMissingParameterType] mock_download_and_process_tile.return_value = pd.DataFrame() - metadata = coordinate_download(polygon=self.test_polygon, level=self.level, kwargs={}) + metadata = coordinate_download(polygon=self.test_polygon, level=self.level, kwargs={}, provider=self.provider) + + assert metadata.empty + + @patch("project_types.street.api_calls.create_tiles") + def test_coordinate_download_no_tiles(self, mock_create_tiles): + mock_create_tiles.return_value = pd.DataFrame() + metadata = coordinate_download( + polygon=Polygon(), + level=0, + kwargs={}, + provider=self.provider, + ) assert metadata.empty def test_filter_within_time_range(self): @@ -252,14 +323,14 @@ def test_filter_default(self): assert len(filtered_df) == len(self.fixture_df) def test_filter_pano_true(self): - filtered_df = filter_results(self.fixture_df, is_pano=True) + filtered_df = filter_results(self.fixture_df, pano_only=True) assert filtered_df is not None assert len(filtered_df) == 3 def test_filter_pano_false(self): - filtered_df = filter_results(self.fixture_df, is_pano=False) + filtered_df = filter_results(self.fixture_df, pano_only=False) assert filtered_df is not None - assert len(filtered_df) == 3 + assert len(filtered_df) == len(self.fixture_df) def test_filter_organization_id(self): filtered_df = filter_results(self.fixture_df, organization_id=1) @@ -271,6 +342,18 @@ def test_filter_creator_id(self): assert filtered_df is not None assert len(filtered_df) == 3 + def test_filter_results_no_creator_id(self): + df = pd.DataFrame( + { + "creator_id": [None, None], + "is_pano": [True, False], + "organization_id": [1, 2], + "captured_at": pd.to_datetime(["2026-02-24", "2026-02-25"]), + }, + ) + filtered_df = filter_results(df, creator_id=123) + assert filtered_df is None + def test_filter_time_range(self): start_time = "2016-01-20 00:00:00" end_time = "2022-01-21 23:59:59" @@ -283,9 +366,9 @@ def test_filter_time_range(self): assert len(filtered_df) == 3 def test_filter_no_rows_after_filter(self): - filtered_df = filter_results(self.fixture_df, is_pano="False") # type: ignore[reportArgumentType] + filtered_df = filter_results(self.fixture_df, pano_only="False") # type: ignore[reportArgumentType] assert filtered_df is not None - assert filtered_df.empty + assert len(filtered_df) == len(self.fixture_df) def test_filter_missing_columns(self): columns_to_check = [ @@ -297,10 +380,13 @@ def test_filter_missing_columns(self): df_copy = self.fixture_df.copy() df_copy[column] = None - if column == "captured_at": - column = "start_time" # noqa: PLW2901 + filter_key = column + if column == "is_pano": + filter_key = "pano_only" + elif column == "captured_at": + filter_key = "start_time" - result = filter_results(df_copy, **{column: True}) # type: ignore[reportArgumentType] + result = filter_results(df_copy, **{filter_key: True}) # type: ignore[reportArgumentType] assert result is None @patch("project_types.street.api_calls.coordinate_download") @@ -327,7 +413,14 @@ def test_get_image_metadata_size_restriction( mock_coordinate_download, # type: ignore[reportMissingParameterType] mock_filter_results, # type: ignore[reportMissingParameterType] ): - mock_df = pd.DataFrame({"id": range(1, 100002), "geometry": range(1, 100002)}) + mock_df = pd.DataFrame( + { + "id": range(1, 100002), + "geometry": range(1, 100002), + "captured_at": pd.NaT, + "sequence_id": 1, + }, + ) mock_coordinate_download.return_value = mock_df with pytest.raises(ValidationException): get_image_metadata(aoi_geojson=self.fixture_data) @@ -338,6 +431,8 @@ def test_get_image_metadata_drop_duplicates(self, mock_coordinate_download): # { "id": [1, 2, 2, 3, 4, 4, 5], "geometry": ["a", "b", "b", "c", "d", "d", "e"], + "sequence_id": [1, 1, 1, 1, 1, 1, 1], + "captured_at": pd.to_datetime(["2020-01-01"] * 7), }, ) mock_coordinate_download.return_value = test_df diff --git a/project_types/street/tutorial.py b/project_types/street/tutorial.py index cb72a87e..944a4423 100644 --- a/project_types/street/tutorial.py +++ b/project_types/street/tutorial.py @@ -52,6 +52,7 @@ def get_group_specifics_for_firebase(self): @typing.override def get_tutorial_specifics_for_firebase(self): custom_opts = self.project_type_specifics.custom_options + image_provider = self.project_type_specifics.image_provider projectType = ProjectTypeEnum.STREET.value assert projectType == 7, "Project Street should be 7" @@ -79,4 +80,10 @@ def get_tutorial_specifics_for_firebase(self): ] if custom_opts is not None else None, + imageProvider=firebase_models.FbObjImageProvider( + name=image_provider.name.value, + url=image_provider.url or None, + ) + if image_provider + else None, ) diff --git a/schema.graphql b/schema.graphql index c66dc63d..cd6b7a71 100644 --- a/schema.graphql +++ b/schema.graphql @@ -37,6 +37,7 @@ type AppEnumCollection { ProjectStatusEnum: [AppEnumCollectionProjectStatusEnum!]! ProjectTypeEnum: [AppEnumCollectionProjectTypeEnum!]! RasterTileServerNameEnum: [AppEnumCollectionRasterTileServerNameEnum!]! + StreetImageProviderNameEnum: [AppEnumCollectionStreetImageProviderNameEnum!]! TutorialAssetInputTypeEnum: [AppEnumCollectionTutorialAssetInputTypeEnum!]! TutorialInformationPageBlockTypeEnum: [AppEnumCollectionTutorialInformationPageBlockTypeEnum!]! TutorialStatusEnum: [AppEnumCollectionTutorialStatusEnum!]! @@ -120,6 +121,11 @@ type AppEnumCollectionRasterTileServerNameEnum { label: String! } +type AppEnumCollectionStreetImageProviderNameEnum { + key: StreetImageProviderNameEnum! + label: String! +} + type AppEnumCollectionTutorialAssetInputTypeEnum { key: TutorialAssetInputTypeEnum! label: String! @@ -2147,38 +2153,56 @@ input StrFilterLookup { startsWith: String } -type StreetMapillaryImageFilters { +type StreetImageFilters { creatorId: String endTime: String - isPano: Boolean organizationId: String + panoOnly: Boolean randomizeOrder: Boolean! samplingThreshold: Int startTime: String } -input StreetMapillaryImageFiltersInput { +input StreetImageFiltersInput { creatorId: String = null endTime: String = null - isPano: Boolean = null organizationId: String = null + panoOnly: Boolean = null randomizeOrder: Boolean! = false samplingThreshold: Int = null startTime: String = null } +type StreetImageProvider { + name: StreetImageProviderNameEnum! + url: String +} + +input StreetImageProviderInput { + name: StreetImageProviderNameEnum! + url: String = null +} + +enum StreetImageProviderNameEnum { + MAPILLARY + PANORAMAX + PANORAMAX_CUSTOM +} + input StreetProjectPropertyInput { """Numeric value as string""" aoiGeometry: String! customOptions: [CustomOptionInput!] = null - mapillaryImageFilters: StreetMapillaryImageFiltersInput! + imageProvider: StreetImageProviderInput! + mapillaryImageFilters: StreetImageFiltersInput! } type StreetProjectPropertyType { """Numeric value as string""" aoiGeometry: String! customOptions: [ProjectCustomOption!] - mapillaryImageFilters: StreetMapillaryImageFilters! + imageProvider: StreetImageProvider! + mapillaryImageFilters: StreetImageFilters! } input StreetTutorialTaskPropertyInput { diff --git a/utils/geo/street_image_provider/__init__.py b/utils/geo/street_image_provider/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/utils/geo/street_image_provider/models.py b/utils/geo/street_image_provider/models.py new file mode 100644 index 00000000..91ca89a7 --- /dev/null +++ b/utils/geo/street_image_provider/models.py @@ -0,0 +1,34 @@ +from typing import assert_never + +from django.db import models +from pydantic import BaseModel, field_validator + +from utils import fields as custom_fields + + +class StreetImageProviderNameEnum(models.TextChoices): + MAPILLARY = "mapillary", "Mapillary" + PANORAMAX = "panoramax", "Panoramax (Metacatalog API)" + PANORAMAX_CUSTOM = "panoramax_custom", "Panoramax (Custom API URL)" + + +class StreetImageProvider(BaseModel): + name: StreetImageProviderNameEnum + url: custom_fields.PydanticUrl | None = None + + @field_validator("url", mode="after") + @classmethod + def url_only_for_custom_panoramax(cls, v, info): + if v is not None and info.data.get("name") != StreetImageProviderNameEnum.PANORAMAX_CUSTOM: + raise ValueError("url field is only allowed for PANORAMAX_CUSTOM provider") + if v is None and info.data.get("name") == StreetImageProviderNameEnum.PANORAMAX_CUSTOM: + raise ValueError("url field is required for PANORAMAX_CUSTOM provider") + return v + + @property + def tile_level(self) -> int: + if self.name == StreetImageProviderNameEnum.MAPILLARY: + return 14 + if self.name == StreetImageProviderNameEnum.PANORAMAX or self.name == StreetImageProviderNameEnum.PANORAMAX_CUSTOM: + return 15 + assert_never(self.name) diff --git a/utils/spatial_sampling.py b/utils/spatial_sampling.py index 11173bdf..daa448e5 100644 --- a/utils/spatial_sampling.py +++ b/utils/spatial_sampling.py @@ -1,6 +1,7 @@ import numpy as np import pandas as pd from numpy.typing import NDArray +from shapely.geometry import Point def distance_on_sphere( @@ -108,13 +109,13 @@ def filter_points(df: pd.DataFrame, threshold_distance: float) -> pd.DataFrame: def spatial_sampling( *, df: pd.DataFrame, - interval_length: float, + interval_length: int | None, ): """Calculate spacing between points in a GeoDataFrame. Args: df (pandas.DataFrame): DataFrame containing points with timestamps. - interval_length (float): Interval length for filtering points in kms. + interval_length (int): Interval length for filtering points in m. Returns: geopandas.GeoDataFrame: Filtered GeoDataFrame containing selected points. @@ -130,10 +131,10 @@ def spatial_sampling( return df df["long"] = df["geometry"].apply( - lambda geom: geom.x if geom.geom_type == "Point" else None, + lambda geom: geom.x if isinstance(geom, Point) else None, ) df["lat"] = df["geometry"].apply( - lambda geom: geom.y if geom.geom_type == "Point" else None, + lambda geom: geom.y if isinstance(geom, Point) else None, ) sorted_df = df.sort_values(by=["captured_at"]) @@ -144,7 +145,7 @@ def spatial_sampling( sequence_df = sorted_df[sorted_df["sequence_id"] == sequence] if interval_length: - sequence_df = filter_points(sequence_df, interval_length) + sequence_df = filter_points(sequence_df, interval_length / 1000) if "is_pano" in sequence_df.columns: # below line prevents FutureWarning # (https://stackoverflow.com/questions/73800841/add-series-as-a-new-row-into-dataframe-triggers-futurewarning) diff --git a/utils/tests/spatial_sampling_test.py b/utils/tests/spatial_sampling_test.py index 6b522915..c3d74941 100644 --- a/utils/tests/spatial_sampling_test.py +++ b/utils/tests/spatial_sampling_test.py @@ -49,12 +49,12 @@ def test_filter_points(self): df["geometry"] = df["geometry"].apply(wkt.loads) # type: ignore[reportArgumentType] df["long"] = df["geometry"].apply( - lambda geom: geom.x if geom.geom_type == "Point" else None, + lambda geom: geom.x if isinstance(geom, Point) else None, ) df["lat"] = df["geometry"].apply( - lambda geom: geom.y if geom.geom_type == "Point" else None, + lambda geom: geom.y if isinstance(geom, Point) else None, ) - threshold_distance = 100 + threshold_distance = 200 filtered_df = filter_points(df, threshold_distance) assert isinstance(filtered_df, pd.DataFrame) @@ -74,13 +74,13 @@ def test_spatial_sampling_ordering(self): df = pd.DataFrame(data) df["geometry"] = df["geometry"].apply(wkt.loads) # type: ignore[reportArgumentType] - interval_length = 0.1 + interval_length = 100 filtered_gdf = spatial_sampling(df=df, interval_length=interval_length) assert filtered_gdf["captured_at"].is_monotonic_decreasing def test_spatial_sampling_with_sequence(self): - threshold_distance = 0.01 + threshold_distance = 10 filtered_df = spatial_sampling(df=self.fixture_df, interval_length=threshold_distance) assert isinstance(filtered_df, pd.DataFrame) assert len(filtered_df) < len(self.fixture_df)