Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions dev/.env.docker-compose
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ DJANGO_INTERNAL_IPS=0.0.0.0/0
DJANGO_UVDAT_WEB_URL=http://localhost:8080/

VITE_API_ROOT=http://localhost:8000/

# Placeholder values for Huggingface integration
DJANGO_UVDAT_HF_TOKEN=changeme
DJANGO_UVDAT_HF_NAMESPACE=changeme
DJANGO_UVDAT_HF_ENDPOINT_NAMES=changeme
56 changes: 56 additions & 0 deletions uvdat/core/migrations/0027_project_region.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Generated by Django 6.0.7 on 2026-08-17 17:21

from __future__ import annotations

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("core", "0026_project_allow_unauthenticated"),
]

operations = [
migrations.RemoveConstraint(
model_name="region",
name="unique-source-region-name",
),
migrations.AddField(
model_name="region",
name="project",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="regions",
to="core.project",
),
),
migrations.AlterField(
model_name="region",
name="dataset",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="regions",
to="core.dataset",
),
),
migrations.AddConstraint(
model_name="region",
constraint=models.UniqueConstraint(
fields=("project", "dataset", "name"), name="unique-region-name"
),
),
migrations.AddConstraint(
model_name="region",
constraint=models.CheckConstraint(
condition=models.Q(
models.Q(("dataset__isnull", False), ("project__isnull", True)),
models.Q(("dataset__isnull", True), ("project__isnull", False)),
_connector="OR",
),
name="dataset_or_project_constraint",
),
),
]
8 changes: 7 additions & 1 deletion uvdat/core/models/querysets.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ def filter_by_projects(self, projects: models.QuerySet[Project]) -> Self:
if path is None:
return self.all()

query = models.Q(**{f"{path}__in": projects})
query = None
for p in path.split("|"):
q = models.Q(**{f"{p}__in": projects})
if query is None:
query = q
else:
query |= q
if allow_null:
query |= models.Q(**{f"{path}__isnull": True})
return self.filter(query).distinct()
23 changes: 19 additions & 4 deletions uvdat/core/models/regions.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

from .data import VectorFeature
from .dataset import Dataset
from .project import Project
from .querysets import ProjectQuerySet


Expand All @@ -13,17 +14,31 @@ class Region(models.Model):
vector_feature = models.ForeignKey(
VectorFeature, on_delete=models.CASCADE, related_name="regions", null=True
)
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="regions")
project = models.ForeignKey(
Project, on_delete=models.CASCADE, related_name="regions", null=True
)
dataset = models.ForeignKey(
Dataset, on_delete=models.CASCADE, related_name="regions", null=True
)
metadata = models.JSONField(blank=True, null=True)
boundary = geo_models.MultiPolygonField()

project_filter_path = "dataset__project"
project_filter_path = "project|dataset__project"
objects = ProjectQuerySet.as_manager()

class Meta:
constraints = [
# We enforce name uniqueness across datasets
models.UniqueConstraint(name="unique-source-region-name", fields=["dataset", "name"])
# We enforce name uniqueness across datasets and projects
models.UniqueConstraint(
name="unique-region-name", fields=["project", "dataset", "name"]
),
models.CheckConstraint(
condition=(
models.Q(dataset__isnull=False, project__isnull=True)
| models.Q(dataset__isnull=True, project__isnull=False)
),
name="dataset_or_project_constraint",
),
]

def __str__(self):
Expand Down
16 changes: 12 additions & 4 deletions uvdat/core/rest/regions.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
from __future__ import annotations

from rest_framework import mixins
from rest_framework.viewsets import GenericViewSet
from django.contrib.gis.geos import MultiPolygon, Polygon
from rest_framework.viewsets import ModelViewSet

from uvdat.core.models import Region
from uvdat.core.models import Project, Region

from .serializers import RegionSerializer


class RegionViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet):
class RegionViewSet(ModelViewSet):
queryset = Region.objects.all()
serializer_class = RegionSerializer

def perform_create(self, serializer):
coords = self.request.data.get("boundary")
project_id = self.request.data.get("project_id")
serializer.save(
project=Project.objects.get(id=project_id),
boundary=MultiPolygon(*[Polygon(*poly) for poly in coords]),
)
5 changes: 5 additions & 0 deletions uvdat/core/rest/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ class Meta:


class RegionSerializer(serializers.ModelSerializer):
boundary = serializers.SerializerMethodField("get_boundary")

def get_boundary(self, obj):
return obj.boundary.coords

class Meta:
model = Region
fields = "__all__"
Expand Down
74 changes: 70 additions & 4 deletions uvdat/core/tasks/analytics/imagery_ask_qwen.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
from __future__ import annotations

import base64
import io

from celery import shared_task
from django.conf import settings
from django.contrib.gis.geos import Polygon
from django_large_image import utilities
import large_image
import numpy as np
from PIL import Image
from rasterio.features import rasterize
from rasterio.transform import from_bounds
from shapely import wkt

from uvdat.core.models import RasterData, TaskResult
from uvdat.core.models import RasterData, Region, TaskResult

from .analysis_type import AnalysisInputError, AnalysisTask, AnalysisType

MODEL_CARD_URL = "https://huggingface.co/unsloth/Qwen3.5-9B-GGUF"
SYSTEM_PROMPT = (
"You are an urban planning and geospatial analysis expert specializing in "
"land use patterns, hydrology, transportation networks, and municipal policy. "
"Masked aerial imagery will be provided; ignore transparent areas of the image. "
"Analyze the provided imagery to answer the user's question. In your answer, "
"assume that the user is also a geospatial analyst with the same expertise."
)
Expand All @@ -40,6 +48,7 @@ def __init__(self):
"imagery": "RasterData",
"text_prompt": "string",
"max_tokens": "number",
"region": "Region",
}
self.output_types = {
"response": "markdown",
Expand All @@ -60,6 +69,7 @@ def get_input_options(self):
"imagery": RasterData.objects.filter(dataset__category="imagery"),
"text_prompt": [],
"max_tokens": [TOKEN_RANGE],
"region": Region.objects.all(),
}

def validate_inputs(self, inputs):
Expand All @@ -80,6 +90,11 @@ def validate_inputs(self, inputs):
if max_tokens < TOKEN_RANGE["min"] or max_tokens > TOKEN_RANGE["max"]:
err_msg = f"max_tokens must be between {TOKEN_RANGE['min']} and {TOKEN_RANGE['max']}."
raise AnalysisInputError(err_msg)
try:
Region.objects.get(id=inputs.get("region"))
except Region.DoesNotExist as e:
err_msg = "Region does not exist."
raise AnalysisInputError(err_msg) from e

def run_task(self, *, project, **inputs):
text_prompt = inputs.get("text_prompt")
Expand All @@ -106,15 +121,66 @@ def imagery_ask_qwen(result_id):
)

result = TaskResult.objects.get(id=result_id)
if any(
setting == "changeme"
for setting in [
settings.UVDAT_HF_ENDPOINT_NAMES,
settings.UVDAT_HF_NAMESPACE,
settings.UVDAT_HF_TOKEN,
]
):
result.write_outputs({"response": "Huggingface configuration not set; not running task."})
return

imagery = RasterData.objects.get(id=result.inputs.get("imagery"))
text_prompt = result.inputs.get("text_prompt")
max_tokens = int(result.inputs.get("max_tokens"))
region = Region.objects.get(id=result.inputs.get("region"))
(xmin, ymin, xmax, ymax) = region.boundary.extent

result.write_status("Encoding imagery...")
result.write_status("Cropping and encoding imagery...")
imagery_path = utilities.field_file_to_local_path(imagery.cloud_optimized_geotiff)
src = large_image.open(imagery_path)
thumbnail_bytes, _ = src.getThumbnail(THUMBNAIL_SIZE, THUMBNAIL_SIZE, encoding="PNG")
thumbnail_b64 = base64.b64encode(thumbnail_bytes).decode("utf-8")
src_bounds = src.getBounds()
if not region.boundary.intersects(
Polygon.from_bbox(
(
src_bounds.get("xmin"),
src_bounds.get("ymin"),
src_bounds.get("xmax"),
src_bounds.get("ymax"),
)
)
):
result.write_outputs(
{"response": "Selected region does not intersect imagery; not running task."}
)
return

(xmin, ymin, xmax, ymax) = (
max(xmin, src_bounds.get("xmin")),
max(ymin, src_bounds.get("ymin")),
min(xmax, src_bounds.get("xmax")),
min(ymax, src_bounds.get("ymax")),
)
thumbnail, _ = src.getRegion(
region={"left": xmin, "right": xmax, "top": ymax, "bottom": ymin, "units": "EPSG:4326"},
output={"maxWidth": THUMBNAIL_SIZE, "maxHeight": THUMBNAIL_SIZE},
format="numpy",
)
height, width, _ = thumbnail.shape
mask = rasterize(
[wkt.loads(region.boundary.wkt)],
out_shape=(height, width),
transform=from_bounds(xmin, ymin, xmax, ymax, width, height),
fill=0,
default_value=1,
dtype="uint8",
).astype(bool)
masked = np.where(mask[:, :, np.newaxis], thumbnail, 0)
byte_stream = io.BytesIO()
Image.fromarray(masked).save(byte_stream, format="PNG")
thumbnail_b64 = base64.b64encode(byte_stream.getvalue()).decode("utf-8")
thumbnail_uri = f"data:image/jpeg;base64,{thumbnail_b64}"

result.write_status("Starting inference endpoint...")
Expand Down
2 changes: 1 addition & 1 deletion uvdat/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
router.register(r"layer-styles", LayerStyleViewSet, basename="layer-styles")
router.register(r"rasters", RasterDataViewSet, basename="rasters")
router.register(r"vectors", VectorDataViewSet, basename="vectors")
router.register(r"source-regions", RegionViewSet, basename="source-regions")
router.register(r"regions", RegionViewSet, basename="regions")
router.register(r"networks", NetworkViewSet, basename="networks")
router.register(r"basemaps", BasemapViewSet, basename="basemaps")
router.register(r"analytics", AnalyticsViewSet, basename="analytics")
Expand Down
1 change: 1 addition & 0 deletions web/eslint.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export default defineConfigWithVueTs(
"vue/valid-v-slot": ["error", { allowModifiers: true }],
// `any` is used everywhere and will be difficult to eliminate
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/ban-ts-comment": "off",
},
},

Expand Down
18 changes: 18 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@
"proj4": "2.21.0",
"sortablejs": "1.15.7",
"vue": "3.5.41",
"terra-draw": "^1.32.3",
"terra-draw-maplibre-gl-adapter": "^1.4.1",
"vue-chartjs": "5.3.4",
"vue-maplibre-compare": "1.0.27",
"vue-markdown-render": "2.3.1",
Expand Down
9 changes: 9 additions & 0 deletions web/src/api/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type {
Colormap,
Basemap,
ViewState,
Region,
} from "@/types";

export async function getUsers(): Promise<User[]> {
Expand Down Expand Up @@ -321,3 +322,11 @@ export async function createViewState(viewState: ViewState): Promise<any> {
export async function deleteViewState(viewState: ViewState): Promise<any> {
return (await apiClient.delete(`view-states/${viewState.id}/`)).data;
}

export async function getRegion(regionId: number): Promise<Region> {
return (await apiClient.get(`regions/${regionId}/`)).data;
}

export async function createRegion(region: Region): Promise<Region> {
return (await apiClient.post("regions/", region)).data;
}
Loading