Skip to content

Commit 38db5e3

Browse files
Merge pull request #253 from HumanSignal/fb-dia-1144/mensuration-demo-fixed
fix: DIA-1144: Mensuration demo fixed
2 parents a84e763 + 1200f07 commit 38db5e3

File tree

5 files changed

+155
-69
lines changed

5 files changed

+155
-69
lines changed

examples/mensuration_and_polling/README.md

+18-8
Original file line numberDiff line numberDiff line change
@@ -16,18 +16,26 @@ You will learn:
1616

1717
## Setup data
1818

19-
Any georeferenced raster can be used. For this demo, a georeferenced TIFF image of the Grand Canyon and a corresponding PNG image that can be loaded into Label Studio (TIFF is not supported in Label Studio) was generated using the following steps:
19+
Any georeferenced raster images can be used. For this demo, two images are used:
2020

21-
1. Sign up at sentinel-hub.com for a trial account for access to [Sentinel-2](https://en.wikipedia.org/wiki/Sentinel-2) satellite images on demand. These have a resolution of 10 GSD (10 meters per pixel).
21+
### Grand Canyon satellite image
22+
23+
a georeferenced TIFF image of the Grand Canyon and a corresponding PNG image that can be loaded into Label Studio (TIFF is not supported in Label Studio) was generated using the following steps:
24+
25+
1. Sign up at sentinel-hub.com for a trial account for access to [Sentinel-2](https://en.wikipedia.org/wiki/Sentinel-2) satellite images on demand. These have a resolution of 10m GSD (10 meters per pixel).
2226
2. Use the included `grab_georeferenced_image.py` with your credentials to download the image in a [UTM coordinate system](https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system), where each pixel is a fixed area instead of a fixed fraction of longitude and latitude. This makes the image "square" with respect to the ground.
2327
3. Export to PNG:
2428
```bash
2529
convert data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff data/response.png
2630
```
2731

32+
### Urban drone image
33+
34+
A UAV image of a city block was downloaded from [OpenAerialMap](https://openaerialmap.org/) and cropped to a reasonable size using [QGIS](https://www.qgis.org/en/site/). It has a resolution of 20cm GSD, so it started out as a huge file. Then it was exported to PNG in the same way.
35+
2836
## Load data into Label Studio
2937

30-
Create a new project and upload `data/response.png` to the project.
38+
Create a new project and upload `data/response.png` and `666dbadcf1cf8e0001fb2f51_cropped.png` to the project.
3139

3240
Add a label config for image segmentation, slightly modified from the default template:
3341

@@ -37,13 +45,13 @@ Add a label config for image segmentation, slightly modified from the default te
3745
<Header value="Select label and click the image to start"/>
3846
<Image name="image" value="$image" zoom="true"/>
3947

40-
<PolygonLabels name="label" toName="image"
41-
strokeWidth="3" pointSize="small"
42-
opacity="0.9">
48+
<PolygonLabels name="label" toName="image" strokeWidth="3" pointSize="small" opacity="0.9">
4349
<Label value="label_name" background="red"/>
4450
</PolygonLabels>
45-
<Text name="perimeter_km" value="Perimeter in km: $label_perimeter" editable="false" />
46-
<Text name="area_km^2" value="Area in km^2: $label_area" editable="false" />
51+
<Text name="perimeter_m" value="Perimeter in m: $perimeter_m" editable="false" />
52+
<Text name="area_m^2" value="Area in m^2: $area_m2" editable="false" />
53+
<Text name="length_m" value="Length in m: $major_axis_m" editable="false" />
54+
<Text name="width_m" value="Width in m: $minor_axis_m" editable="false" />
4755

4856
</View>
4957
```
@@ -65,6 +73,8 @@ pip install -r requirements.txt
6573
python poll_for_tasks.py
6674
```
6775

76+
Refresh the page between starting the background task and creating new annotations to ensure that it started correctly.
77+
6878

6979
## Create or edit a georeferenced polygon annotation
7080

Loading
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,40 @@
11
import time
22
import json
33
from datetime import datetime, timedelta, timezone
4+
import numpy as np
45
import rasterio
56
from shapely.geometry import Polygon
67
from label_studio_sdk.client import LabelStudio
78
from label_studio_sdk import Client, Project
89
from label_studio_sdk.data_manager import Filters, Column, Operator, Type
910

1011

12+
def _filter_tasks(last_poll_time: datetime):
13+
"""
14+
Build filters for client to poll for tasks
15+
"""
16+
filters = Filters.create(
17+
"and", # need 'and' instead of 'or' evfen if we had only 1 filter
18+
[
19+
# task updated since the last poll
20+
Filters.item(
21+
Column.updated_at,
22+
Operator.GREATER_OR_EQUAL,
23+
Type.Datetime,
24+
Filters.value(last_poll_time),
25+
),
26+
# task has at least one annotation
27+
Filters.item(
28+
Column.total_annotations,
29+
Operator.GREATER_OR_EQUAL,
30+
Type.Number,
31+
Filters.value(1),
32+
),
33+
],
34+
)
35+
return filters
36+
37+
1138
def poll_for_completed_tasks_new(
1239
ls: LabelStudio, project_id: int, freq_sec: int
1340
) -> list:
@@ -16,22 +43,15 @@ def poll_for_completed_tasks_new(
1643
Uses label_studio_sdk >= 1.0.0
1744
"""
1845
while True:
46+
print("polling")
1947
last_poll_time = datetime.now(timezone.utc) - timedelta(seconds=freq_sec)
20-
filters = Filters.create(
21-
"and", # need 'and' instead of 'or', even though this is only one filter
22-
[
23-
Filters.item(
24-
Column.updated_at,
25-
Operator.GREATER_OR_EQUAL,
26-
Type.Datetime,
27-
Filters.value(last_poll_time),
28-
)
29-
],
30-
)
48+
filters = _filter_tasks(last_poll_time)
3149
tasks = ls.tasks.list(
3250
project=project_id,
3351
query=json.dumps({"filters": filters}),
52+
# can't use fields='all' because of maybe-nonexistent task fields
3453
fields="all",
54+
# fields=['image', 'annotations'],
3555
)
3656
yield from tasks
3757
time.sleep(freq_seconds)
@@ -45,87 +65,142 @@ def poll_for_completed_tasks_old(project: Project, freq_seconds: int) -> list:
4565
while True:
4666
print("polling")
4767
last_poll_time = datetime.now(timezone.utc) - timedelta(seconds=freq_seconds)
48-
filters = Filters.create(
49-
"and", # need 'and' instead of 'or', even though this is only one filter
50-
[
51-
Filters.item(
52-
Column.updated_at,
53-
Operator.GREATER_OR_EQUAL,
54-
Type.Datetime,
55-
Filters.value(last_poll_time),
56-
)
57-
],
58-
)
68+
filters = _filter_tasks(last_poll_time)
5969
tasks = project.get_tasks(filters=filters)
6070
yield from tasks
6171
time.sleep(freq_seconds)
6272

6373

64-
def perimeter_and_area(source_img_path, annot):
74+
def calculate_distances(source_img_path, annot):
6575
"""
66-
Calculate the perimeter and area of the polygon annotation in geographic coordinates given by the georeferenced TIFF source image the polygon was drawn on.
76+
Calculate properties of the polygon annotation in geographic coordinates given by the georeferenced TIFF source image the polygon was drawn on.
6777
"""
68-
width = annot["result"][0]["original_width"]
69-
height = annot["result"][0]["original_height"]
70-
points = annot["result"][0]["value"]["points"]
78+
try:
79+
width = annot["result"][0]["original_width"]
80+
height = annot["result"][0]["original_height"]
81+
points = annot["result"][0]["value"]["points"]
7182

72-
# convert relative coordinates to pixel coordinates
73-
points_pxl = [(x / 100 * width, y / 100 * height) for x, y in points]
83+
# convert relative coordinates to pixel coordinates
84+
points_pxl = [(x / 100 * width, y / 100 * height) for x, y in points]
7485

75-
with rasterio.open(source_img_path) as src:
76-
# convert pixel coordinates to geographic coordinates
77-
points_geo = [src.transform * (x, y) for x, y in points_pxl]
86+
with rasterio.open(source_img_path) as src:
87+
# convert pixel coordinates to geographic coordinates
88+
points_geo = [src.transform * (x, y) for x, y in points_pxl]
7889

79-
# use Shapely to create a polygon
80-
poly = Polygon(points_geo)
90+
# use Shapely to create a polygon
91+
poly = Polygon(points_geo)
8192

82-
# assume the image CRS is in meters
83-
perimeter_m = poly.length
84-
area_m = poly.area
93+
# assume the image CRS is in meters
94+
perimeter_m = poly.length
95+
area_m2 = poly.area
8596

86-
perimeter_km = perimeter_m / 1e3
87-
area_km = area_m / 1e6
97+
oriented_bbox = poly.minimum_rotated_rectangle
98+
coords = np.array(oriented_bbox.exterior.coords)
99+
side_lengths = ((coords[1:] - coords[:-1]) ** 2).sum(axis=1) ** 0.5
100+
major_axis_m = max(side_lengths)
101+
minor_axis_m = min(side_lengths)
88102

89-
return perimeter_km, area_km
103+
return {
104+
"perimeter_m": perimeter_m,
105+
"area_m2": area_m2,
106+
"major_axis_m": major_axis_m,
107+
"minor_axis_m": minor_axis_m,
108+
}
109+
# guard against incomplete polygons
110+
except (ValueError, IndexError):
111+
print("no valid polygon in annotation")
112+
return {
113+
"perimeter_m": 0,
114+
"area_m2": 0,
115+
"major_axis_m": 0,
116+
"minor_axis_m": 0,
117+
}
118+
119+
120+
def _bugfix_task_columns_old(project):
121+
"""
122+
Using the old SDK client, due to our workaround providing extra task columns and uploading single images instead of full task objects, PATCH /api/tasks/<id> requests will not complete correctly until the task has those columns populated.
123+
"""
124+
# look for tasks with missing values
125+
old_tasks = project.get_tasks()
126+
default_values = {
127+
"perimeter_m": 0,
128+
"area_m2": 0,
129+
"major_axis_m": 0,
130+
"minor_axis_m": 0,
131+
}
132+
tasks_to_recreate = []
133+
for task in old_tasks:
134+
for k in default_values:
135+
if k not in task["data"]:
136+
tasks_to_recreate.append(task)
137+
break
138+
# instead of updating tasks, need to delete and recreate tasks
139+
if tasks_to_recreate:
140+
_ = project.delete_tasks(task_ids=[task["id"] for task in tasks_to_recreate])
141+
new_task_data = [
142+
{
143+
"data": {
144+
**default_values,
145+
"image": task["data"]["image"],
146+
}
147+
}
148+
for task in tasks_to_recreate
149+
]
150+
_ = project.import_tasks(new_task_data)
90151

91152

92153
if __name__ == "__main__":
93154
url = "http://localhost:8080"
94155
api_key = "cca56ca8fc0d511a87bbc63f5857b9a7a8f14c23"
95-
project_id = 4
156+
project_id = 5
157+
158+
# poll frequency
96159
freq_seconds = 1
97-
use_new_sdk = True
160+
161+
# new sdk is waiting on: https://github.com/HumanSignal/label-studio/pull/6012
162+
use_new_sdk = False
163+
164+
def _lookup_source_image_path(annotated_image_path: str) -> str:
165+
"""
166+
In a real project this lookup should be another column in the task. For ease of demoing the project by simply uploading images, it's a hacky function.
167+
"""
168+
if annotated_image_path.endswith("response.png"):
169+
return "data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff"
170+
elif annotated_image_path.endswith("cropped.png"):
171+
return "data/oam/666dbadcf1cf8e0001fb2f51_cropped.tif"
172+
else:
173+
print("unknown annotated image path ", annotated_image_path)
174+
return annotated_image_path
175+
98176
if use_new_sdk:
99177
# new SDK client (version >= 1.0.0)
100178
ls = LabelStudio(base_url=url, api_key=api_key)
101179
for task in poll_for_completed_tasks_new(ls, project_id, freq_seconds):
102180
# assume that the most recent annotation is the one that was updated
103-
# can check annot['updated_at'] to confirm
181+
# can check annot['updated_at'] and annot['created_at'] to confirm
104182
annot = task.annotations[0]
105-
# currently, we only have one source image, but in general need to build a mapping between the task image (PNG) and the source image (TIFF)
106-
perimeter, area = perimeter_and_area(
107-
"data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff", annot
108-
)
109-
ls.tasks.update(
110-
id=task.id,
111-
data={**task.data, "label_perimeter": perimeter, "label_area": area},
112-
)
183+
source_image_path = _lookup_source_image_path(task.data["image"])
184+
distances = calculate_distances(source_image_path, annot)
185+
new_data = task.data
186+
new_data.update(distances)
187+
ls.tasks.update(id=task.id, data=new_data)
113188
print("updated task", task.id)
114189
else:
115190
# old SDK client (version < 1.0.0)
116191
client = Client(url=url, api_key=api_key)
117192
client.check_connection()
118193
project = client.get_project(project_id)
194+
195+
_bugfix_task_columns_old(project)
196+
119197
for task in poll_for_completed_tasks_old(project, freq_seconds):
120198
# assume that the most recent annotation is the one that was updated
121-
# can check annot['updated_at'] to confirm
199+
# can check annot['updated_at'] and annot['created_at'] to confirm
122200
annot = task["annotations"][0]
123-
# currently, we only have one source image, but in general need to build a mapping between the task image (PNG) and the source image (TIFF)
124-
perimeter, area = perimeter_and_area(
125-
"data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff", annot
126-
)
127-
project.update_task(
128-
task_id=task["id"],
129-
data={**task["data"], "label_perimeter": perimeter, "label_area": area},
130-
)
201+
source_image_path = _lookup_source_image_path(task["data"]["image"])
202+
distances = calculate_distances(source_image_path, annot)
203+
new_data = task["data"]
204+
new_data.update(distances)
205+
project.update_task(task_id=task["id"], data=new_data)
131206
print("updated task", task["id"])
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
label_studio_sdk~=1.0.1
1+
label_studio_sdk~=1.0.2
2+
numpy~=1.24.3
23
rasterio~=1.3.10
34
sentinelhub~=3.10.2
45
Shapely~=2.0.4

0 commit comments

Comments
 (0)