Skip to content

Commit 916abc4

Browse files
authored
Merge pull request #457 from OpenGeoscience/task-region-input
Add region input to crop imagery for Ask Qwen task
2 parents 879ed1e + 706d9e1 commit 916abc4

19 files changed

Lines changed: 609 additions & 129 deletions

dev/.env.docker-compose

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,8 @@ DJANGO_INTERNAL_IPS=0.0.0.0/0
1313
DJANGO_UVDAT_WEB_URL=http://localhost:8080/
1414

1515
VITE_API_ROOT=http://localhost:8000/
16+
17+
# Placeholder values for Huggingface integration
18+
DJANGO_UVDAT_HF_TOKEN=changeme
19+
DJANGO_UVDAT_HF_NAMESPACE=changeme
20+
DJANGO_UVDAT_HF_ENDPOINT_NAMES=changeme
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# Generated by Django 6.0.7 on 2026-08-17 17:21
2+
3+
from __future__ import annotations
4+
5+
from django.db import migrations, models
6+
import django.db.models.deletion
7+
8+
9+
class Migration(migrations.Migration):
10+
dependencies = [
11+
("core", "0026_project_allow_unauthenticated"),
12+
]
13+
14+
operations = [
15+
migrations.RemoveConstraint(
16+
model_name="region",
17+
name="unique-source-region-name",
18+
),
19+
migrations.AddField(
20+
model_name="region",
21+
name="project",
22+
field=models.ForeignKey(
23+
null=True,
24+
on_delete=django.db.models.deletion.CASCADE,
25+
related_name="regions",
26+
to="core.project",
27+
),
28+
),
29+
migrations.AlterField(
30+
model_name="region",
31+
name="dataset",
32+
field=models.ForeignKey(
33+
null=True,
34+
on_delete=django.db.models.deletion.CASCADE,
35+
related_name="regions",
36+
to="core.dataset",
37+
),
38+
),
39+
migrations.AddConstraint(
40+
model_name="region",
41+
constraint=models.UniqueConstraint(
42+
fields=("project", "dataset", "name"), name="unique-region-name"
43+
),
44+
),
45+
migrations.AddConstraint(
46+
model_name="region",
47+
constraint=models.CheckConstraint(
48+
condition=models.Q(
49+
models.Q(("dataset__isnull", False), ("project__isnull", True)),
50+
models.Q(("dataset__isnull", True), ("project__isnull", False)),
51+
_connector="OR",
52+
),
53+
name="dataset_or_project_constraint",
54+
),
55+
),
56+
]

uvdat/core/models/querysets.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,13 @@ def filter_by_projects(self, projects: models.QuerySet[Project]) -> Self:
3232
if path is None:
3333
return self.all()
3434

35-
query = models.Q(**{f"{path}__in": projects})
35+
query = None
36+
for p in path.split("|"):
37+
q = models.Q(**{f"{p}__in": projects})
38+
if query is None:
39+
query = q
40+
else:
41+
query |= q
3642
if allow_null:
3743
query |= models.Q(**{f"{path}__isnull": True})
3844
return self.filter(query).distinct()

uvdat/core/models/regions.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
from .data import VectorFeature
77
from .dataset import Dataset
8+
from .project import Project
89
from .querysets import ProjectQuerySet
910

1011

@@ -13,17 +14,31 @@ class Region(models.Model):
1314
vector_feature = models.ForeignKey(
1415
VectorFeature, on_delete=models.CASCADE, related_name="regions", null=True
1516
)
16-
dataset = models.ForeignKey(Dataset, on_delete=models.CASCADE, related_name="regions")
17+
project = models.ForeignKey(
18+
Project, on_delete=models.CASCADE, related_name="regions", null=True
19+
)
20+
dataset = models.ForeignKey(
21+
Dataset, on_delete=models.CASCADE, related_name="regions", null=True
22+
)
1723
metadata = models.JSONField(blank=True, null=True)
1824
boundary = geo_models.MultiPolygonField()
1925

20-
project_filter_path = "dataset__project"
26+
project_filter_path = "project|dataset__project"
2127
objects = ProjectQuerySet.as_manager()
2228

2329
class Meta:
2430
constraints = [
25-
# We enforce name uniqueness across datasets
26-
models.UniqueConstraint(name="unique-source-region-name", fields=["dataset", "name"])
31+
# We enforce name uniqueness across datasets and projects
32+
models.UniqueConstraint(
33+
name="unique-region-name", fields=["project", "dataset", "name"]
34+
),
35+
models.CheckConstraint(
36+
condition=(
37+
models.Q(dataset__isnull=False, project__isnull=True)
38+
| models.Q(dataset__isnull=True, project__isnull=False)
39+
),
40+
name="dataset_or_project_constraint",
41+
),
2742
]
2843

2944
def __str__(self):

uvdat/core/rest/regions.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,21 @@
11
from __future__ import annotations
22

3-
from rest_framework import mixins
4-
from rest_framework.viewsets import GenericViewSet
3+
from django.contrib.gis.geos import MultiPolygon, Polygon
4+
from rest_framework.viewsets import ModelViewSet
55

6-
from uvdat.core.models import Region
6+
from uvdat.core.models import Project, Region
77

88
from .serializers import RegionSerializer
99

1010

11-
class RegionViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet):
11+
class RegionViewSet(ModelViewSet):
1212
queryset = Region.objects.all()
1313
serializer_class = RegionSerializer
14+
15+
def perform_create(self, serializer):
16+
coords = self.request.data.get("boundary")
17+
project_id = self.request.data.get("project_id")
18+
serializer.save(
19+
project=Project.objects.get(id=project_id),
20+
boundary=MultiPolygon(*[Polygon(*poly) for poly in coords]),
21+
)

uvdat/core/rest/serializers.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,11 @@ class Meta:
233233

234234

235235
class RegionSerializer(serializers.ModelSerializer):
236+
boundary = serializers.SerializerMethodField("get_boundary")
237+
238+
def get_boundary(self, obj):
239+
return obj.boundary.coords
240+
236241
class Meta:
237242
model = Region
238243
fields = "__all__"

uvdat/core/tasks/analytics/imagery_ask_qwen.py

Lines changed: 70 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
from __future__ import annotations
22

33
import base64
4+
import io
45

56
from celery import shared_task
67
from django.conf import settings
8+
from django.contrib.gis.geos import Polygon
79
from django_large_image import utilities
810
import large_image
11+
import numpy as np
12+
from PIL import Image
13+
from rasterio.features import rasterize
14+
from rasterio.transform import from_bounds
15+
from shapely import wkt
916

10-
from uvdat.core.models import RasterData, TaskResult
17+
from uvdat.core.models import RasterData, Region, TaskResult
1118

1219
from .analysis_type import AnalysisInputError, AnalysisTask, AnalysisType
1320

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

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

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

108123
result = TaskResult.objects.get(id=result_id)
124+
if any(
125+
setting == "changeme"
126+
for setting in [
127+
settings.UVDAT_HF_ENDPOINT_NAMES,
128+
settings.UVDAT_HF_NAMESPACE,
129+
settings.UVDAT_HF_TOKEN,
130+
]
131+
):
132+
result.write_outputs({"response": "Huggingface configuration not set; not running task."})
133+
return
134+
109135
imagery = RasterData.objects.get(id=result.inputs.get("imagery"))
110136
text_prompt = result.inputs.get("text_prompt")
111137
max_tokens = int(result.inputs.get("max_tokens"))
138+
region = Region.objects.get(id=result.inputs.get("region"))
139+
(xmin, ymin, xmax, ymax) = region.boundary.extent
112140

113-
result.write_status("Encoding imagery...")
141+
result.write_status("Cropping and encoding imagery...")
114142
imagery_path = utilities.field_file_to_local_path(imagery.cloud_optimized_geotiff)
115143
src = large_image.open(imagery_path)
116-
thumbnail_bytes, _ = src.getThumbnail(THUMBNAIL_SIZE, THUMBNAIL_SIZE, encoding="PNG")
117-
thumbnail_b64 = base64.b64encode(thumbnail_bytes).decode("utf-8")
144+
src_bounds = src.getBounds()
145+
if not region.boundary.intersects(
146+
Polygon.from_bbox(
147+
(
148+
src_bounds.get("xmin"),
149+
src_bounds.get("ymin"),
150+
src_bounds.get("xmax"),
151+
src_bounds.get("ymax"),
152+
)
153+
)
154+
):
155+
result.write_outputs(
156+
{"response": "Selected region does not intersect imagery; not running task."}
157+
)
158+
return
159+
160+
(xmin, ymin, xmax, ymax) = (
161+
max(xmin, src_bounds.get("xmin")),
162+
max(ymin, src_bounds.get("ymin")),
163+
min(xmax, src_bounds.get("xmax")),
164+
min(ymax, src_bounds.get("ymax")),
165+
)
166+
thumbnail, _ = src.getRegion(
167+
region={"left": xmin, "right": xmax, "top": ymax, "bottom": ymin, "units": "EPSG:4326"},
168+
output={"maxWidth": THUMBNAIL_SIZE, "maxHeight": THUMBNAIL_SIZE},
169+
format="numpy",
170+
)
171+
height, width, _ = thumbnail.shape
172+
mask = rasterize(
173+
[wkt.loads(region.boundary.wkt)],
174+
out_shape=(height, width),
175+
transform=from_bounds(xmin, ymin, xmax, ymax, width, height),
176+
fill=0,
177+
default_value=1,
178+
dtype="uint8",
179+
).astype(bool)
180+
masked = np.where(mask[:, :, np.newaxis], thumbnail, 0)
181+
byte_stream = io.BytesIO()
182+
Image.fromarray(masked).save(byte_stream, format="PNG")
183+
thumbnail_b64 = base64.b64encode(byte_stream.getvalue()).decode("utf-8")
118184
thumbnail_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
119185

120186
result.write_status("Starting inference endpoint...")

uvdat/urls.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
router.register(r"layer-styles", LayerStyleViewSet, basename="layer-styles")
4848
router.register(r"rasters", RasterDataViewSet, basename="rasters")
4949
router.register(r"vectors", VectorDataViewSet, basename="vectors")
50-
router.register(r"source-regions", RegionViewSet, basename="source-regions")
50+
router.register(r"regions", RegionViewSet, basename="regions")
5151
router.register(r"networks", NetworkViewSet, basename="networks")
5252
router.register(r"basemaps", BasemapViewSet, basename="basemaps")
5353
router.register(r"analytics", AnalyticsViewSet, basename="analytics")

web/eslint.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export default defineConfigWithVueTs(
2323
"vue/valid-v-slot": ["error", { allowModifiers: true }],
2424
// `any` is used everywhere and will be difficult to eliminate
2525
"@typescript-eslint/no-explicit-any": "off",
26+
"@typescript-eslint/ban-ts-comment": "off",
2627
},
2728
},
2829

web/package-lock.json

Lines changed: 18 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)