Skip to content

Commit

Permalink
feat: print stats and implement "runs" parameter
Browse files Browse the repository at this point in the history
  • Loading branch information
mmoollllee committed Sep 24, 2024
1 parent f697c6e commit 4ab7fbc
Show file tree
Hide file tree
Showing 3 changed files with 169 additions and 104 deletions.
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ Otherwise use `tiled` field of detections where `1` means detection is not split
- Overlap tiles by `padding` value (in pixels)
- Omit labels at image's borders if the don't reach in the image by `threshold` value (in pixels)
4. If `save_empty` is set, tiles without detections will be kept, if not omited.
5. If `runs` is > 1: repeat those steps n times and keep those with least detections being split by tileing.

<img src="screenshot.png">

Expand Down Expand Up @@ -47,7 +48,8 @@ make_tiles(
padding=20, # Overlap tiles by given value (default: 32),
threshold=0.15, # Omit labels at the edged if smaller than given percentage (default: 0.15),
save_empty=False # Keep tiles without labels (default: False),
test=False # Run Tiling only for 5 samples and make destination dataset non-persistent
test=False # Run Tiling only for 5 samples and make destination dataset non-persistent,
runs=1 # repeat n times and keep only those with least detections being split by tileing.
)
```

Expand Down
267 changes: 165 additions & 102 deletions __init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,17 +45,19 @@ def image_resize(image, width = None, height = None, inter = cv2.INTER_AREA):
return resized


def save_tile(padded_image, y, x, tile_size, padding, name, id, ext, output_dir):
def save_tile(padded_image, run, y, x, tile_size, padding, name, id, ext, output_dir):
tiled = padded_image[
y*tile_size:(y+1)*tile_size+padding, #0 : 960
x*tile_size:(x+1)*tile_size+padding #0 : 960
]
tile_name = name.replace(ext, f'_{id}_{y}_{x}{ext}')
tile_name = name.replace(ext, f'_{id}_{run}_{y}_{x}{ext}')
tile_path = os.path.join(output_dir, tile_name)
cv2.imwrite(tile_path, tiled)
return tile_path

def tile(image, tags, name, id, detections, output_dir, target: Dataset, save_empty=False, tile_size=960, padding=0, threshold=0.15, labels_field="ground_truth"):

def tile(image, tags, name, id, detections, output_dir, target: Dataset, save_empty=False, tile_size=960, padding=0, threshold=0.15, labels_field="ground_truth", runs=1):

ext = os.path.splitext(name)[-1]
height, width, channels = image.shape
tile_size = tile_size - padding #928px
Expand All @@ -74,101 +76,136 @@ def tile(image, tags, name, id, detections, output_dir, target: Dataset, save_em
padded_width = tile_size * num_tiles_x + padding #2.816px
padded_height = tile_size * num_tiles_y + padding #2.816px

# Randomize position on padded area
padded_x = random.randint(0, padded_width - width) # 0 - 316px
padded_y = random.randint(0, padded_height - height) # 0 - 942px

color = (144,144,144)
padded_image = np.full((padded_height,padded_width, channels), color, dtype=np.uint8)
padded_image[padded_y:padded_y+height, padded_x:padded_x+width] = image

# rescale coordinates with padding
detections[['x1']] = detections[['x1']] + padded_x
detections[['y1']] = detections[['y1']] + padded_y

# convert bounding boxes to shapely polygons.
boxes = []
for row in detections.iterrows():
i = row[1]
x1 = i['x1']
x2 = i['x1'] + i['w']
y1 = (padded_height - i['y1']) - i['h']
y2 = padded_height - i['y1']
boxes.append((i['detection'], Polygon([(x1, y1), (x2, y1), (x2, y2), (x1, y2)])))

counter = 0
for y in range(num_tiles_y):
for x in range(num_tiles_x):
# Coordinates of the tile
tile_x1 = x*tile_size #0, 928, 1856
tile_x2 = ((x+1)*tile_size) + padding #960, 1888, 2.816,...
tile_y1 = padded_height - (y*tile_size) #2.816, 1.888, 960
tile_y2 = (padded_height - (y+1)*tile_size) - padding #1856, 928, 0
tile_pol = Polygon([(tile_x1, tile_y1), (tile_x2, tile_y1), (tile_x2, tile_y2), (tile_x1, tile_y2)])

imsaved = False
tile_detections = []

for box in boxes:
bbox = box[1]
if tile_pol.intersects(bbox):
inter = tile_pol.intersection(bbox)
intersection = inter.area / bbox.area

# Remove box if intersection is below threshold
if intersection < threshold:
continue

# hacky copy of detection with all attributes, tags,...
detection = box[0].fancy_repr(class_name=None,exclude_fields=['id'])[12:-1]
detection = eval(detection)
detection = fo.Detection(**detection)

# add informations about if the detection was intersecting with the tiles boundaries
detection.set_field("tiled", intersection)
if intersection < 1:
detection.tags.append("tiled")

if not imsaved:
tile_path = save_tile(padded_image, y, x, tile_size, padding, name, id, ext, output_dir)

runs_no = range(runs)
runs = []

for run in runs_no:
# keep track of intersections
runs.append({
"intersections": [],
"samples": [],
"below_threshold": 0
})

# Randomize position on padded area
padded_x = random.randint(0, padded_width - width) # 0 - 316px
padded_y = random.randint(0, padded_height - height) # 0 - 942px

padded_image = np.full((padded_height,padded_width, channels), color, dtype=np.uint8)
padded_image[padded_y:padded_y+height, padded_x:padded_x+width] = image

# rescale coordinates with padding
temp_detections = detections.copy()
temp_detections[['x1']] = detections[['x1']] + padded_x
temp_detections[['y1']] = detections[['y1']] + padded_y

# convert bounding boxes to shapely polygons.
boxes = []
for row in temp_detections.iterrows():
i = row[1]
x1 = i['x1']
x2 = i['x1'] + i['w']
y1 = (padded_height - i['y1']) - i['h']
y2 = padded_height - i['y1']
boxes.append((i['detection'], Polygon([(x1, y1), (x2, y1), (x2, y2), (x1, y2)])))

counter = 0
for y in range(num_tiles_y):
for x in range(num_tiles_x):
# Coordinates of the tile
tile_x1 = x*tile_size #0, 928, 1856
tile_x2 = ((x+1)*tile_size) + padding #960, 1888, 2.816,...
tile_y1 = padded_height - (y*tile_size) #2.816, 1.888, 960
tile_y2 = (padded_height - (y+1)*tile_size) - padding #1856, 928, 0
tile_pol = Polygon([(tile_x1, tile_y1), (tile_x2, tile_y1), (tile_x2, tile_y2), (tile_x1, tile_y2)])

imsaved = False
tile_detections = []

for box in boxes:
bbox = box[1]
if tile_pol.intersects(bbox):
inter = tile_pol.intersection(bbox)
intersection = inter.area / bbox.area

# Remove box if intersection is below threshold
if intersection < threshold:
runs[run]["below_threshold"] += 1
continue

# hacky copy of detection with all attributes, tags,...
detection = box[0].fancy_repr(class_name=None,exclude_fields=['id'])[12:-1]
detection = eval(detection)
detection = fo.Detection(**detection)

# add informations about if the detection was intersecting with the tiles boundaries
runs[run]["intersections"].append(intersection)
detection.set_field("tiled", intersection)
if intersection < 1:
detection.tags.append("tiled")

tile_path = save_tile(padded_image, run, y, x, tile_size, padding, name, id, ext, output_dir)
imsaved = True

# get smallest rectangular polygon that contains the intersection
new_box = inter.envelope

# get central point for the new bounding box
centre = new_box.centroid

# get coordinates of polygon vertices
box_x, box_y = new_box.exterior.coords.xy

# get bounding box width and height normalized to tile size
new_width = (max(box_x) - min(box_x)) / (tile_size+padding)
new_height = (max(box_y) - min(box_y)) / (tile_size+padding)

# we have to normalize central x and invert y for yolo format
new_x = (centre.coords.xy[0][0] - tile_x1) / (tile_size+padding) - (new_width/2)
new_y = (tile_y1 - centre.coords.xy[1][0]) / (tile_size+padding) - (new_height/2)

counter += 1

detection.bounding_box = [new_x, new_y, new_width, new_height]

tile_detections.append(detection)

if not imsaved and save_empty:
tile_path = save_tile(padded_image, y, x, tile_size, padding, name, id, ext, output_dir)
imsaved = True

# get smallest rectangular polygon that contains the intersection
new_box = inter.envelope

# get central point for the new bounding box
centre = new_box.centroid

# get coordinates of polygon vertices
box_x, box_y = new_box.exterior.coords.xy

# get bounding box width and height normalized to tile size
new_width = (max(box_x) - min(box_x)) / (tile_size+padding)
new_height = (max(box_y) - min(box_y)) / (tile_size+padding)

# we have to normalize central x and invert y for yolo format
new_x = (centre.coords.xy[0][0] - tile_x1) / (tile_size+padding) - (new_width/2)
new_y = (tile_y1 - centre.coords.xy[1][0]) / (tile_size+padding) - (new_height/2)

counter += 1

detection.bounding_box = [new_x, new_y, new_width, new_height]

tile_detections.append(detection)

if not imsaved and save_empty:
tile_path = save_tile(padded_image, run, y, x, tile_size, padding, name, id, ext, output_dir)
imsaved = True

if imsaved:
sample = fo.Sample(filepath=tile_path)
if len(tile_detections) > 0:
sample[labels_field] = fo.Detections(
detections=tile_detections
)
for tag in tags:
sample.tags.append(tag)

runs[run]["samples"].append(sample)

runs[run]["intersections"] = sum(runs[run]["intersections"]) / len(runs[run]["intersections"])

best_run = max(range(len(runs)), key=lambda index: runs[index]['intersections'])
best_run = runs.pop(best_run)

if imsaved:
sample = fo.Sample(filepath=tile_path)
if len(tile_detections) > 0:
sample[labels_field] = fo.Detections(
detections=tile_detections
)
for tag in tags:
sample.tags.append(tag)
target.add_sample(sample)
# add best samples
for sample in best_run["samples"]:
target.add_sample(sample)

# delete the rest
for run in runs:
for sample in run["samples"]:
os.remove(sample.filepath)

return {
"below_threshold": best_run["below_threshold"],
"tiles_created": len(best_run["samples"]),
"intersection": best_run['intersections']
}


################################################################
Expand Down Expand Up @@ -221,15 +258,22 @@ def execute(self, ctx):
tile_size = ctx.params.get("tile_size")
padding = ctx.params.get("padding")
threshold = ctx.params.get("threshold")
runs = ctx.params.get("runs")

print(f"Samples found: {len(view)}")

stats = {
"below_threshold": 0,
"tiles_created": 0,
"intersections": []
}

for sample in view:
filepath = sample['filepath']
print(filepath)
print("🌆 "+filepath)

if labels_field and not save_empty and not sample[labels_field]:
print(f" No {labels_field} detections found: Skiping")
print(f" 🧐 No {labels_field} detections found: Skiping")
continue

image = cv2.imread(filepath)
Expand All @@ -238,13 +282,30 @@ def execute(self, ctx):
image = image_resize(image, width=resize)

if labels_field and sample[labels_field]:
print(" Tiling...")
labels = sample[labels_field].detections
else:
print(f" No {labels_field} detections found: Tiling anyway.")
print(f" 🪚 No {labels_field} detections found")
labels = None

tile(image, sample.tags, os.path.basename(filepath), sample['id'], labels, output_dir, target=tiled_dataset, tile_size=tile_size, padding=padding, threshold=threshold, save_empty=save_empty, labels_field=labels_field)
stat = tile(image, sample.tags, os.path.basename(filepath), sample['id'], labels, output_dir, target=tiled_dataset, tile_size=tile_size, padding=padding, threshold=threshold, save_empty=save_empty, labels_field=labels_field, runs=runs)


stats["below_threshold"] += stat["below_threshold"]
stats["tiles_created"] += stat["tiles_created"]
stats["intersections"].append(stat["intersection"])
print(" 🪚 " + str(stat["tiles_created"]) + " tiles. ⌀ " + str(round(stat['intersection'],3)) + " intersection. " + str(stat["below_threshold"]) + " det below threshold.")

samples_total = len(view)
tiles_per_sample = str(round(stats["tiles_created"]/samples_total, 1))
below_threshold_per_sample = str(round(stats["below_threshold"]/samples_total, 1))
tiles_created = str(stats['tiles_created'])
below_threshold = str(stats["below_threshold"])
avg_intersection = round(sum(stats["intersections"]) / samples_total, 3)

print(f"✅ Done with {samples_total} samples.")
print(f" Created {tiles_per_sample} tiles/sample (total: {tiles_created})")
print(f" Lost {below_threshold_per_sample} detections/sample below threshold({str(threshold)}) (total: {below_threshold})")
print(f" Average intersection {avg_intersection}")

def __call__(
self,
Expand All @@ -257,7 +318,8 @@ def __call__(
padding: int = 32,
threshold: float = 0.15,
save_empty: bool = False,
test: bool = False
test: bool = False,
runs: int = 1
):
ctx = dict(view=sample_collection.view())
params = dict(
Expand All @@ -270,7 +332,8 @@ def __call__(
padding=padding,
threshold=threshold,
save_empty=save_empty,
test=test
test=test,
runs=runs
)
return foo.execute_operator(self.uri, ctx, params=params)

Expand Down
2 changes: 1 addition & 1 deletion fiftyone.yml
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
fiftyone:
version: ">=0.23.5"
name: "@mmoollllee/tile"
version: "0.0.1"
version: "0.0.2"
description: "Tile your images to squares (e.g. 960x960 pixels) in FiftyOne directly."
url: "https://github.com/mmoollllee/fiftyone-tile"
operators:
Expand Down

0 comments on commit 4ab7fbc

Please sign in to comment.