Upload square_crop.py
Browse files- square_crop.py +109 -0
square_crop.py
ADDED
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
#!/usr/bin/env python3
|
2 |
+
"""
|
3 |
+
square_crop.py
|
4 |
+
|
5 |
+
Crop each COCO bounding box to the smallest square that contains it.
|
6 |
+
|
7 |
+
Usage
|
8 |
+
-----
|
9 |
+
python square_crop.py --images_dir <IMG_DIR> \
|
10 |
+
--coco_json <COCO_JSON> \
|
11 |
+
--output_dir <OUT_DIR>
|
12 |
+
|
13 |
+
Dependencies
|
14 |
+
------------
|
15 |
+
pip install pillow pycocotools tqdm
|
16 |
+
"""
|
17 |
+
import argparse
|
18 |
+
import json
|
19 |
+
from pathlib import Path
|
20 |
+
|
21 |
+
from PIL import Image, ImageOps
|
22 |
+
from tqdm import tqdm
|
23 |
+
|
24 |
+
|
25 |
+
def parse_args():
|
26 |
+
parser = argparse.ArgumentParser(
|
27 |
+
description="Crop COCO bounding boxes to squares.")
|
28 |
+
parser.add_argument("--images_dir", required=True, type=Path,
|
29 |
+
help="Directory containing the source images.")
|
30 |
+
parser.add_argument("--coco_json", required=True, type=Path,
|
31 |
+
help="Path to the COCO annotation file (JSON).")
|
32 |
+
parser.add_argument("--output_dir", required=True, type=Path,
|
33 |
+
help="Directory to write the cropped images.")
|
34 |
+
parser.add_argument("--pad_color", default=0, type=int,
|
35 |
+
help="Gray-scale padding color 0–255 (default 0 = black).")
|
36 |
+
return parser.parse_args()
|
37 |
+
|
38 |
+
|
39 |
+
def load_coco(json_path):
|
40 |
+
with open(json_path, "r") as f:
|
41 |
+
coco = json.load(f)
|
42 |
+
id2fname = {img["id"]: img["file_name"] for img in coco["images"]}
|
43 |
+
return id2fname, coco["annotations"]
|
44 |
+
|
45 |
+
|
46 |
+
def square_from_bbox(x, y, w, h, img_w, img_h):
|
47 |
+
"""
|
48 |
+
Compute (left, top, side) of the smallest square fully containing the bbox.
|
49 |
+
The square is centred on the bbox; if it overflows the image, it is shifted
|
50 |
+
(but not resized) so it lies inside the image. Returns the final crop box
|
51 |
+
(left, top, side).
|
52 |
+
"""
|
53 |
+
side = max(w, h)
|
54 |
+
cx, cy = x + w / 2.0, y + h / 2.0
|
55 |
+
left = int(round(cx - side / 2.0))
|
56 |
+
top = int(round(cy - side / 2.0))
|
57 |
+
|
58 |
+
# Shift the square so it fits inside the image
|
59 |
+
left = max(0, min(left, img_w - side))
|
60 |
+
top = max(0, min(top, img_h - side))
|
61 |
+
return left, top, int(side)
|
62 |
+
|
63 |
+
|
64 |
+
def crop_annotation(img_path, ann, out_dir, pad_color=0):
|
65 |
+
with Image.open(img_path) as img:
|
66 |
+
img_w, img_h = img.size
|
67 |
+
x, y, w, h = ann["bbox"] # COCO bbox = [x, y, width, height]
|
68 |
+
left, top, side = square_from_bbox(x, y, w, h, img_w, img_h)
|
69 |
+
|
70 |
+
# Perform crop (may be smaller than 'side' at edges)
|
71 |
+
crop = img.crop((left, top, left + side, top + side))
|
72 |
+
|
73 |
+
# If we lost pixels at the edge, pad back to full square
|
74 |
+
if crop.size != (side, side):
|
75 |
+
delta_w = side - crop.size[0]
|
76 |
+
delta_h = side - crop.size[1]
|
77 |
+
padding = (0, 0, delta_w, delta_h) # (left, top, right, bottom)
|
78 |
+
crop = ImageOps.expand(crop, padding, fill=pad_color)
|
79 |
+
|
80 |
+
# Build output filename: <stem>_ann<id>.ext
|
81 |
+
stem = Path(img_path).stem
|
82 |
+
suffix = Path(img_path).suffix
|
83 |
+
out_name = f"{stem}_ann{ann['id']}{suffix}"
|
84 |
+
crop.save(out_dir / out_name)
|
85 |
+
|
86 |
+
|
87 |
+
def main():
|
88 |
+
args = parse_args()
|
89 |
+
args.output_dir.mkdir(parents=True, exist_ok=True)
|
90 |
+
|
91 |
+
id2fname, annotations = load_coco(args.coco_json)
|
92 |
+
|
93 |
+
# Group annotations by image for efficient loading
|
94 |
+
im2anns = {}
|
95 |
+
for ann in annotations:
|
96 |
+
im2anns.setdefault(ann["image_id"], []).append(ann)
|
97 |
+
|
98 |
+
for img_id, anns in tqdm(im2anns.items(), desc="Processing images"):
|
99 |
+
img_path = args.images_dir / id2fname[img_id]
|
100 |
+
if not img_path.is_file():
|
101 |
+
print(f"Warning: image {img_path} not found — skipping.")
|
102 |
+
continue
|
103 |
+
for ann in anns:
|
104 |
+
crop_annotation(img_path, ann, args.output_dir,
|
105 |
+
pad_color=args.pad_color)
|
106 |
+
|
107 |
+
|
108 |
+
if __name__ == "__main__":
|
109 |
+
main()
|