Skip to content

Commit

Permalink
Merge pull request #253 from HumanSignal/fb-dia-1144/mensuration-demo…
Browse files Browse the repository at this point in the history
…-fixed

fix: DIA-1144: Mensuration demo fixed
  • Loading branch information
matt-bernstein authored Jun 26, 2024
2 parents a84e763 + 1200f07 commit 38db5e3
Show file tree
Hide file tree
Showing 5 changed files with 155 additions and 69 deletions.
26 changes: 18 additions & 8 deletions examples/mensuration_and_polling/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,26 @@ You will learn:

## Setup data

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:
Any georeferenced raster images can be used. For this demo, two images are used:

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).
### Grand Canyon satellite image

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:

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).
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.
3. Export to PNG:
```bash
convert data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff data/response.png
```

### Urban drone image

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.

## Load data into Label Studio

Create a new project and upload `data/response.png` to the project.
Create a new project and upload `data/response.png` and `666dbadcf1cf8e0001fb2f51_cropped.png` to the project.

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

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

<PolygonLabels name="label" toName="image"
strokeWidth="3" pointSize="small"
opacity="0.9">
<PolygonLabels name="label" toName="image" strokeWidth="3" pointSize="small" opacity="0.9">
<Label value="label_name" background="red"/>
</PolygonLabels>
<Text name="perimeter_km" value="Perimeter in km: $label_perimeter" editable="false" />
<Text name="area_km^2" value="Area in km^2: $label_area" editable="false" />
<Text name="perimeter_m" value="Perimeter in m: $perimeter_m" editable="false" />
<Text name="area_m^2" value="Area in m^2: $area_m2" editable="false" />
<Text name="length_m" value="Length in m: $major_axis_m" editable="false" />
<Text name="width_m" value="Width in m: $minor_axis_m" editable="false" />

</View>
```
Expand All @@ -65,6 +73,8 @@ pip install -r requirements.txt
python poll_for_tasks.py
```

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


## Create or edit a georeferenced polygon annotation

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
195 changes: 135 additions & 60 deletions examples/mensuration_and_polling/poll_for_tasks.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,40 @@
import time
import json
from datetime import datetime, timedelta, timezone
import numpy as np
import rasterio
from shapely.geometry import Polygon
from label_studio_sdk.client import LabelStudio
from label_studio_sdk import Client, Project
from label_studio_sdk.data_manager import Filters, Column, Operator, Type


def _filter_tasks(last_poll_time: datetime):
"""
Build filters for client to poll for tasks
"""
filters = Filters.create(
"and", # need 'and' instead of 'or' evfen if we had only 1 filter
[
# task updated since the last poll
Filters.item(
Column.updated_at,
Operator.GREATER_OR_EQUAL,
Type.Datetime,
Filters.value(last_poll_time),
),
# task has at least one annotation
Filters.item(
Column.total_annotations,
Operator.GREATER_OR_EQUAL,
Type.Number,
Filters.value(1),
),
],
)
return filters


def poll_for_completed_tasks_new(
ls: LabelStudio, project_id: int, freq_sec: int
) -> list:
Expand All @@ -16,22 +43,15 @@ def poll_for_completed_tasks_new(
Uses label_studio_sdk >= 1.0.0
"""
while True:
print("polling")
last_poll_time = datetime.now(timezone.utc) - timedelta(seconds=freq_sec)
filters = Filters.create(
"and", # need 'and' instead of 'or', even though this is only one filter
[
Filters.item(
Column.updated_at,
Operator.GREATER_OR_EQUAL,
Type.Datetime,
Filters.value(last_poll_time),
)
],
)
filters = _filter_tasks(last_poll_time)
tasks = ls.tasks.list(
project=project_id,
query=json.dumps({"filters": filters}),
# can't use fields='all' because of maybe-nonexistent task fields
fields="all",
# fields=['image', 'annotations'],
)
yield from tasks
time.sleep(freq_seconds)
Expand All @@ -45,87 +65,142 @@ def poll_for_completed_tasks_old(project: Project, freq_seconds: int) -> list:
while True:
print("polling")
last_poll_time = datetime.now(timezone.utc) - timedelta(seconds=freq_seconds)
filters = Filters.create(
"and", # need 'and' instead of 'or', even though this is only one filter
[
Filters.item(
Column.updated_at,
Operator.GREATER_OR_EQUAL,
Type.Datetime,
Filters.value(last_poll_time),
)
],
)
filters = _filter_tasks(last_poll_time)
tasks = project.get_tasks(filters=filters)
yield from tasks
time.sleep(freq_seconds)


def perimeter_and_area(source_img_path, annot):
def calculate_distances(source_img_path, annot):
"""
Calculate the perimeter and area of the polygon annotation in geographic coordinates given by the georeferenced TIFF source image the polygon was drawn on.
Calculate properties of the polygon annotation in geographic coordinates given by the georeferenced TIFF source image the polygon was drawn on.
"""
width = annot["result"][0]["original_width"]
height = annot["result"][0]["original_height"]
points = annot["result"][0]["value"]["points"]
try:
width = annot["result"][0]["original_width"]
height = annot["result"][0]["original_height"]
points = annot["result"][0]["value"]["points"]

# convert relative coordinates to pixel coordinates
points_pxl = [(x / 100 * width, y / 100 * height) for x, y in points]
# convert relative coordinates to pixel coordinates
points_pxl = [(x / 100 * width, y / 100 * height) for x, y in points]

with rasterio.open(source_img_path) as src:
# convert pixel coordinates to geographic coordinates
points_geo = [src.transform * (x, y) for x, y in points_pxl]
with rasterio.open(source_img_path) as src:
# convert pixel coordinates to geographic coordinates
points_geo = [src.transform * (x, y) for x, y in points_pxl]

# use Shapely to create a polygon
poly = Polygon(points_geo)
# use Shapely to create a polygon
poly = Polygon(points_geo)

# assume the image CRS is in meters
perimeter_m = poly.length
area_m = poly.area
# assume the image CRS is in meters
perimeter_m = poly.length
area_m2 = poly.area

perimeter_km = perimeter_m / 1e3
area_km = area_m / 1e6
oriented_bbox = poly.minimum_rotated_rectangle
coords = np.array(oriented_bbox.exterior.coords)
side_lengths = ((coords[1:] - coords[:-1]) ** 2).sum(axis=1) ** 0.5
major_axis_m = max(side_lengths)
minor_axis_m = min(side_lengths)

return perimeter_km, area_km
return {
"perimeter_m": perimeter_m,
"area_m2": area_m2,
"major_axis_m": major_axis_m,
"minor_axis_m": minor_axis_m,
}
# guard against incomplete polygons
except (ValueError, IndexError):
print("no valid polygon in annotation")
return {
"perimeter_m": 0,
"area_m2": 0,
"major_axis_m": 0,
"minor_axis_m": 0,
}


def _bugfix_task_columns_old(project):
"""
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.
"""
# look for tasks with missing values
old_tasks = project.get_tasks()
default_values = {
"perimeter_m": 0,
"area_m2": 0,
"major_axis_m": 0,
"minor_axis_m": 0,
}
tasks_to_recreate = []
for task in old_tasks:
for k in default_values:
if k not in task["data"]:
tasks_to_recreate.append(task)
break
# instead of updating tasks, need to delete and recreate tasks
if tasks_to_recreate:
_ = project.delete_tasks(task_ids=[task["id"] for task in tasks_to_recreate])
new_task_data = [
{
"data": {
**default_values,
"image": task["data"]["image"],
}
}
for task in tasks_to_recreate
]
_ = project.import_tasks(new_task_data)


if __name__ == "__main__":
url = "http://localhost:8080"
api_key = "cca56ca8fc0d511a87bbc63f5857b9a7a8f14c23"
project_id = 4
project_id = 5

# poll frequency
freq_seconds = 1
use_new_sdk = True

# new sdk is waiting on: https://github.com/HumanSignal/label-studio/pull/6012
use_new_sdk = False

def _lookup_source_image_path(annotated_image_path: str) -> str:
"""
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.
"""
if annotated_image_path.endswith("response.png"):
return "data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff"
elif annotated_image_path.endswith("cropped.png"):
return "data/oam/666dbadcf1cf8e0001fb2f51_cropped.tif"
else:
print("unknown annotated image path ", annotated_image_path)
return annotated_image_path

if use_new_sdk:
# new SDK client (version >= 1.0.0)
ls = LabelStudio(base_url=url, api_key=api_key)
for task in poll_for_completed_tasks_new(ls, project_id, freq_seconds):
# assume that the most recent annotation is the one that was updated
# can check annot['updated_at'] to confirm
# can check annot['updated_at'] and annot['created_at'] to confirm
annot = task.annotations[0]
# 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)
perimeter, area = perimeter_and_area(
"data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff", annot
)
ls.tasks.update(
id=task.id,
data={**task.data, "label_perimeter": perimeter, "label_area": area},
)
source_image_path = _lookup_source_image_path(task.data["image"])
distances = calculate_distances(source_image_path, annot)
new_data = task.data
new_data.update(distances)
ls.tasks.update(id=task.id, data=new_data)
print("updated task", task.id)
else:
# old SDK client (version < 1.0.0)
client = Client(url=url, api_key=api_key)
client.check_connection()
project = client.get_project(project_id)

_bugfix_task_columns_old(project)

for task in poll_for_completed_tasks_old(project, freq_seconds):
# assume that the most recent annotation is the one that was updated
# can check annot['updated_at'] to confirm
# can check annot['updated_at'] and annot['created_at'] to confirm
annot = task["annotations"][0]
# 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)
perimeter, area = perimeter_and_area(
"data/e9b9661bcbd97b67f45364aafd82f9d6/response.tiff", annot
)
project.update_task(
task_id=task["id"],
data={**task["data"], "label_perimeter": perimeter, "label_area": area},
)
source_image_path = _lookup_source_image_path(task["data"]["image"])
distances = calculate_distances(source_image_path, annot)
new_data = task["data"]
new_data.update(distances)
project.update_task(task_id=task["id"], data=new_data)
print("updated task", task["id"])
3 changes: 2 additions & 1 deletion examples/mensuration_and_polling/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
label_studio_sdk~=1.0.1
label_studio_sdk~=1.0.2
numpy~=1.24.3
rasterio~=1.3.10
sentinelhub~=3.10.2
Shapely~=2.0.4

0 comments on commit 38db5e3

Please sign in to comment.