Skip to content

Commit e2713d7

Browse files
authored
Image Chip Generator (#396)
1 parent 8a316e2 commit e2713d7

File tree

6 files changed

+372
-195
lines changed

6 files changed

+372
-195
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ All notable changes to the [Nucleus Python Client](https://github.com/scaleapi/n
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.16.8](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.16.8) - 2023-11-13
9+
10+
### Added
11+
12+
- Added `dataset.items_and_annotation_chip_generator()` functionality to generate chips of images in s3 or locally.
13+
- Added `query` parameter for `dataset.items_and_annotation_generator()` to filter dataset items.
14+
815
## [0.16.7](https://github.com/scaleapi/nucleus-python-client/releases/tag/v0.16.7) - 2023-11-03
916

1017
### Added

nucleus/chip_utils.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
"""Shared stateless utility function library for chipping images"""
2+
3+
import io
4+
import json
5+
import os
6+
from itertools import product
7+
from typing import Dict, List
8+
9+
import boto3
10+
import numpy as np
11+
from botocore.exceptions import ClientError
12+
from PIL import Image
13+
14+
from .constants import (
15+
ANNOTATION_LOCATION_KEY,
16+
BOX_TYPE,
17+
GEOMETRY_KEY,
18+
HEIGHT_KEY,
19+
IMAGE_LOCATION_KEY,
20+
LABEL_KEY,
21+
TYPE_KEY,
22+
WIDTH_KEY,
23+
X_KEY,
24+
Y_KEY,
25+
)
26+
27+
28+
def split_s3_bucket_key(s3_path: str):
29+
s3_bucket, s3_key = s3_path.split("//", 1)[-1].split("/", 1)
30+
return s3_bucket, s3_key
31+
32+
33+
def fetch_image(s3_url: str):
34+
s3_bucket, s3_key = split_s3_bucket_key(s3_url)
35+
image = Image.open(
36+
boto3.resource("s3").Bucket(s3_bucket).Object(s3_key).get()["Body"]
37+
)
38+
return image
39+
40+
41+
def fetch_chip(ref_id: str):
42+
"""
43+
Fetches the locations of the image and its corresponding annotations.
44+
45+
This function checks if the reference ID starts with "s3" to determine if the
46+
image and annotations are stored on S3, otherwise it checks the local filesystem.
47+
If the image or annotations do not exist, it returns None for their locations.
48+
49+
Args:
50+
ref_id (str): The reference ID for the image and annotations.
51+
52+
Returns:
53+
A tuple containing the location of the image and the annotations.
54+
If either is not found, None is returned in their place.
55+
"""
56+
image_loc = None
57+
annotation_loc = None
58+
if ref_id.startswith("s3"):
59+
s3_bucket, s3_key = split_s3_bucket_key(ref_id)
60+
try:
61+
boto3.resource("s3").Bucket(s3_bucket).Object(
62+
s3_key + ".jpeg"
63+
).load()
64+
image_loc = ref_id + ".jpeg"
65+
except ClientError:
66+
return None, None
67+
try:
68+
boto3.resource("s3").Bucket(s3_bucket).Object(
69+
s3_key + ".json"
70+
).load()
71+
annotation_loc = ref_id + ".json"
72+
except ClientError:
73+
return image_loc, None
74+
else:
75+
if os.path.exists(ref_id + ".jpeg"):
76+
image_loc = ref_id + ".jpeg"
77+
if os.path.exists(ref_id + ".json"):
78+
annotation_loc = ref_id + ".json"
79+
return image_loc, annotation_loc
80+
81+
82+
def write_chip(
83+
ref_id: str, image: Image.Image, annotations: List[Dict[str, str]]
84+
):
85+
if ref_id.startswith("s3"):
86+
s3_bucket, s3_key = split_s3_bucket_key(ref_id)
87+
byteio = io.BytesIO()
88+
image.save(byteio, format="jpeg")
89+
byteio.seek(0)
90+
boto3.resource("s3").Bucket(s3_bucket).Object(
91+
s3_key + ".jpeg"
92+
).upload_fileobj(byteio)
93+
annotation_loc = None
94+
if len(annotations) > 0:
95+
boto3.resource("s3").Bucket(s3_bucket).Object(
96+
s3_key + ".json"
97+
).put(
98+
Body=json.dumps(annotations, ensure_ascii=False).encode(
99+
"UTF-8"
100+
),
101+
ContentType="application/json",
102+
)
103+
annotation_loc = ref_id + ".json"
104+
return ref_id + ".jpeg", annotation_loc
105+
else:
106+
dirs = ref_id.rsplit("/", 1)[0]
107+
os.makedirs(dirs, exist_ok=True)
108+
image_loc = ref_id + ".jpeg"
109+
annotation_loc = None
110+
image.save(image_loc)
111+
if len(annotations) > 0:
112+
annotation_loc = ref_id + ".json"
113+
with open(annotation_loc, "w", encoding="utf-8") as f:
114+
json.dump(annotations, f, ensure_ascii=False)
115+
return image_loc, annotation_loc
116+
117+
118+
def generate_offsets(w: int, h: int, chip_size: int, stride_size: int):
119+
xs = np.arange(0, w - stride_size, chip_size - stride_size)
120+
ys = np.arange(0, h - stride_size, chip_size - stride_size)
121+
if len(xs) > 1:
122+
xs = np.round(xs * (w - chip_size) / xs[-1]).astype(int)
123+
if len(ys) > 1:
124+
ys = np.round(ys * (h - chip_size) / ys[-1]).astype(int)
125+
yield from product(ys, xs)
126+
127+
128+
def chip_annotations(data, x0: int, y0: int, x1: int, y1: int):
129+
"""
130+
Adjusts the annotations to fit within the chip defined by the rectangle
131+
with top-left corner (x0, y0) and bottom-right corner (x1, y1).
132+
133+
Parameters:
134+
data: List of annotation dictionaries to be adjusted.
135+
x0: The x-coordinate of the top-left corner of the chip.
136+
y0: The y-coordinate of the top-left corner of the chip.
137+
x1: The x-coordinate of the bottom-right corner of the chip.
138+
y1: The y-coordinate of the bottom-right corner of the chip.
139+
140+
Returns:
141+
A list of adjusted annotation dictionaries that fit within the chip.
142+
"""
143+
annotations = []
144+
X_1_KEY = "x1"
145+
Y_1_KEY = "y1"
146+
for annotation in data:
147+
geometry = annotation[GEOMETRY_KEY].copy()
148+
geometry[X_1_KEY] = geometry[X_KEY] + geometry[WIDTH_KEY]
149+
geometry[Y_1_KEY] = geometry[Y_KEY] + geometry[HEIGHT_KEY]
150+
geometry[X_KEY] = max(min(geometry[X_KEY], x1), x0) - x0
151+
geometry[X_1_KEY] = max(min(geometry[X_1_KEY], x1), x0) - x0
152+
geometry[Y_KEY] = max(min(geometry[Y_KEY], y1), y0) - y0
153+
geometry[Y_1_KEY] = max(min(geometry[Y_1_KEY], y1), y0) - y0
154+
geometry[WIDTH_KEY] = geometry[X_1_KEY] - geometry[X_KEY]
155+
geometry[HEIGHT_KEY] = geometry[Y_1_KEY] - geometry[Y_KEY]
156+
geometry["area"] = geometry[WIDTH_KEY] * geometry[HEIGHT_KEY]
157+
if geometry["area"] > 0:
158+
annotations.append(
159+
{
160+
LABEL_KEY: annotation[LABEL_KEY],
161+
TYPE_KEY: BOX_TYPE,
162+
GEOMETRY_KEY: {
163+
X_KEY: geometry[X_KEY],
164+
Y_KEY: geometry[Y_KEY],
165+
WIDTH_KEY: geometry[WIDTH_KEY],
166+
HEIGHT_KEY: geometry[HEIGHT_KEY],
167+
},
168+
}
169+
)
170+
return annotations
171+
172+
173+
def process_chip(chip_arg):
174+
(
175+
offset,
176+
chip_size,
177+
w,
178+
h,
179+
item_ref_id,
180+
cache_directory,
181+
image,
182+
annotations,
183+
) = chip_arg
184+
x0, y0 = map(int, offset)
185+
x1 = min(x0 + chip_size, w)
186+
y1 = min(y0 + chip_size, h)
187+
ref_id = f"{cache_directory}/{item_ref_id}_{x0}_{y0}_{x1}_{y1}"
188+
chipped_image_loc, chipped_annotation_loc = fetch_chip(ref_id)
189+
if chipped_image_loc:
190+
return {
191+
IMAGE_LOCATION_KEY: chipped_image_loc,
192+
ANNOTATION_LOCATION_KEY: chipped_annotation_loc,
193+
}
194+
chipped_image = image.crop((x0, y0, x1, y1))
195+
chipped_annotations = chip_annotations(annotations, x0, y0, x1, y1)
196+
chipped_image_loc, chipped_annotation_loc = write_chip(
197+
ref_id, chipped_image, chipped_annotations
198+
)
199+
return {
200+
IMAGE_LOCATION_KEY: chipped_image_loc,
201+
ANNOTATION_LOCATION_KEY: chipped_annotation_loc,
202+
}

nucleus/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@
7171
IMAGE_KEY = "image"
7272
IMAGE_LOCATION_KEY = "image_location"
7373
IMAGE_URL_KEY = "image_url"
74+
PROCESSED_URL_KEY = "processed_url"
75+
ANNOTATION_LOCATION_KEY = "annotation_location"
7476
INDEX_KEY = "index"
7577
INDEX_ID_KEY = "index_id"
7678
INDEX_CONTINUOUS_ENABLE_KEY = "enable"

nucleus/dataset.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import datetime
22
import os
33
from enum import Enum
4+
from multiprocessing import Pool
45
from typing import (
56
TYPE_CHECKING,
67
Any,
@@ -17,6 +18,7 @@
1718

1819
from nucleus.annotation_uploader import AnnotationUploader, PredictionUploader
1920
from nucleus.async_job import AsyncJob, EmbeddingsExportJob
21+
from nucleus.chip_utils import fetch_image, generate_offsets, process_chip
2022
from nucleus.embedding_index import EmbeddingIndex
2123
from nucleus.evaluation_match import EvaluationMatch
2224
from nucleus.prediction import from_json as prediction_from_json
@@ -36,6 +38,7 @@
3638
ANNOTATIONS_KEY,
3739
AUTOTAG_SCORE_THRESHOLD,
3840
BACKFILL_JOB_KEY,
41+
BOX_TYPE,
3942
DATASET_ID_KEY,
4043
DATASET_IS_SCENE_KEY,
4144
DATASET_ITEM_IDS_KEY,
@@ -46,13 +49,16 @@
4649
EXPORT_FOR_TRAINING_KEY,
4750
EXPORTED_ROWS,
4851
FRAME_RATE_KEY,
52+
ITEM_KEY,
4953
ITEMS_KEY,
5054
JOB_REQ_LIMIT,
5155
KEEP_HISTORY_KEY,
5256
MAX_ES_PAGE_SIZE,
5357
MESSAGE_KEY,
5458
NAME_KEY,
5559
OBJECT_IDS_KEY,
60+
PROCESSED_URL_KEY,
61+
REFERENCE_ID_KEY,
5662
REFERENCE_IDS_KEY,
5763
REQUEST_ID_KEY,
5864
SCENE_IDS_KEY,
@@ -1413,9 +1419,13 @@ def items_and_annotations(
14131419

14141420
def items_and_annotation_generator(
14151421
self,
1422+
query: Optional[str] = None,
14161423
) -> Iterable[Dict[str, Union[DatasetItem, Dict[str, List[Annotation]]]]]:
14171424
"""Provides a generator of all DatasetItems and Annotations in the dataset.
14181425
1426+
Args:
1427+
query: Structured query compatible with the `Nucleus query language <https://nucleus.scale.com/docs/query-language-reference>`_.
1428+
14191429
Returns:
14201430
Generator where each element is a dict containing the DatasetItem
14211431
and all of its associated Annotations, grouped by type.
@@ -1439,11 +1449,72 @@ def items_and_annotation_generator(
14391449
endpoint=f"dataset/{self.id}/exportForTrainingPage",
14401450
result_key=EXPORT_FOR_TRAINING_KEY,
14411451
page_size=10000, # max ES page size
1452+
query=query,
14421453
)
14431454
for data in json_generator:
14441455
for ia in convert_export_payload([data], has_predictions=False):
14451456
yield ia
14461457

1458+
def items_and_annotation_chip_generator(
1459+
self,
1460+
chip_size: int,
1461+
stride_size: int,
1462+
cache_directory: str,
1463+
query: Optional[str] = None,
1464+
) -> Iterable[Dict[str, str]]:
1465+
"""Provides a generator of chips for all DatasetItems and BoxAnnotations in the dataset.
1466+
1467+
A chip is an image created by tiling a source image.
1468+
1469+
Args:
1470+
chip_size: The size of the image chip
1471+
stride_size: The distance to move when creating the next image chip.
1472+
When stride is equal to chip size, there will be no overlap.
1473+
When stride is equal to half the chip size, there will be 50 percent overlap.
1474+
cache_directory: The s3 or local directory to store the image and annotations of a chip.
1475+
s3 directories must be in the format s3://s3-bucket/s3-key
1476+
query: Structured query compatible with the `Nucleus query language <https://nucleus.scale.com/docs/query-language-reference>`_.
1477+
1478+
Returns:
1479+
Generator where each element is a dict containing the location of the image chip (jpeg) and its annotations (json).
1480+
::
1481+
1482+
Iterable[{
1483+
"image_location": str,
1484+
"annotation_location": str
1485+
}]
1486+
"""
1487+
json_generator = paginate_generator(
1488+
client=self._client,
1489+
endpoint=f"dataset/{self.id}/exportForTrainingPage",
1490+
result_key=EXPORT_FOR_TRAINING_KEY,
1491+
page_size=10000, # max ES page size
1492+
query=query,
1493+
chip=True,
1494+
)
1495+
for item in json_generator:
1496+
image = fetch_image(item[ITEM_KEY][PROCESSED_URL_KEY])
1497+
w, h = image.size
1498+
annotations = item[BOX_TYPE]
1499+
item_ref_id = item[ITEM_KEY][REFERENCE_ID_KEY]
1500+
offsets = generate_offsets(w, h, chip_size, stride_size)
1501+
with Pool() as pool:
1502+
chip_args = [
1503+
(
1504+
offset,
1505+
chip_size,
1506+
w,
1507+
h,
1508+
item_ref_id,
1509+
cache_directory,
1510+
image,
1511+
annotations,
1512+
)
1513+
for offset in offsets
1514+
]
1515+
for chip_result in pool.imap(process_chip, chip_args):
1516+
yield chip_result
1517+
14471518
def export_embeddings(
14481519
self,
14491520
asynchronous: bool = True,

0 commit comments

Comments
 (0)