Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: CI

on:
push:
branches: ["**"]
pull_request:
branches: ["**"]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install system deps (OpenCV)
run: sudo apt-get install -y libgl1

- name: Install dependencies
run: |
pip install -r requirements.txt
pip install pytest ruff black

- name: Lint (ruff)
run: ruff check .

- name: Format check (black)
run: black --check .

- name: Run tests
run: pytest tests/ -v --tb=short
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,10 @@
test_data/
__pycache__/

visualizer.py
visualizer.py

.DS_Store

targets_fake
targets_original/
output_yolo/
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,30 @@ python generate.py \
```bash
python3 visualize_labels.py
```
## 5. Testing
In order to test if generated data produces the right boxes we use the following files to test our outputs:
- tests/test_generate.py — unit tests for path loading and label file writing in the main pipeline
- tests/test_overlay_single.py — unit tests for the overlay logic including bbox math, normalization, color matching, and regression detection
- tests/test_smoke.py — end-to-end pipeline test using real fixture images to validate full output correctness
- tests/fixtures/ — tiny sample backgrounds and targets used by the smoke test
- pytest.ini — pytest configuration telling it to treat the repo root as the Python path
- conftest.py — empty file that marks the repo root as the pytest project root
- .github/workflows/ci.yml — GitHub Actions workflow that runs linting, formatting checks, and all tests on every push and pull request

Running tests by file:

``` bash
pytest tests/test_generate.py -v
pytest tests/test_overlay_single.py -v
pytest tests/test_smoke.py -v


```

Use the following command to run all of the tests at once:

``` bash
pytest tests/ -v
```

If successful it should say "30 passed in 2.04s," there are 30 tests total.
5 changes: 4 additions & 1 deletion compositor/augment.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import numpy as np
from PIL import Image, ImageEnhance


def augment(
image: Image.Image,
brightness_range: tuple = (0.5, 1.2),
Expand Down Expand Up @@ -30,7 +31,9 @@ def augment(

# Very slight Gaussian Blur
if random.random() < blur_prob:
k = random.choice(range(blur_kernel_range[0], blur_kernel_range[1] + 1, 2)) # must be odd
k = random.choice(
range(blur_kernel_range[0], blur_kernel_range[1] + 1, 2)
) # must be odd
img_np = cv2.GaussianBlur(img_np, (k, k), 0)

return Image.fromarray(img_np)
43 changes: 25 additions & 18 deletions compositor/overlay/overlay_multiple.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import numpy as np
from PIL import Image
import cv2
import os


def random_perspective_transform(img_np, max_offset=0.1):
Expand All @@ -12,15 +11,19 @@ def random_perspective_transform(img_np, max_offset=0.1):
dy = int(h * max_offset)

src = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
dst = np.float32([
[random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), h - random.randint(0, dy)],
[random.randint(0, dx), h - random.randint(0, dy)]
])
dst = np.float32(
[
[random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), h - random.randint(0, dy)],
[random.randint(0, dx), h - random.randint(0, dy)],
]
)

M = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(img_np, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(0,0,0,0))
warped = cv2.warpPerspective(
img_np, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0)
)
return warped


Expand All @@ -33,8 +36,8 @@ def match_hue_subtle(overlay_np, background_np):
ov_rgb = overlay_np[..., :3].astype(np.float32)
bg_rgb = background_np[..., :3].astype(np.float32)

ov_mean = ov_rgb.mean(axis=(0,1))
bg_mean = bg_rgb.mean(axis=(0,1))
ov_mean = ov_rgb.mean(axis=(0, 1))
bg_mean = bg_rgb.mean(axis=(0, 1))

ov_rgb += 0.05 * (bg_mean - ov_mean)
ov_rgb = np.clip(ov_rgb, 0, 255).astype(np.uint8)
Expand Down Expand Up @@ -107,12 +110,17 @@ def paste_multiple_overlay(background, real_overlays, fake_overlays, max_attempt
new_box = (x_min, y_min, new_w, new_h)

if not check_overlap(new_box, placed_boxes):
alpha = ov_np[..., 3] / 255.0 if ov_np.shape[2] == 4 else np.ones((new_h, new_w))
alpha = (
ov_np[..., 3] / 255.0
if ov_np.shape[2] == 4
else np.ones((new_h, new_w))
)

for c in range(3):
bg_np[y_min:y_min+new_h, x_min:x_min+new_w, c] = (
alpha * ov_np[..., c] +
(1 - alpha) * bg_np[y_min:y_min+new_h, x_min:x_min+new_w, c]
bg_np[y_min : y_min + new_h, x_min : x_min + new_w, c] = (
alpha * ov_np[..., c]
+ (1 - alpha)
* bg_np[y_min : y_min + new_h, x_min : x_min + new_w, c]
)

placed_boxes.append(new_box)
Expand All @@ -122,10 +130,9 @@ def paste_multiple_overlay(background, real_overlays, fake_overlays, max_attempt
w_norm = new_w / bg.width
h_norm = new_h / bg.height

labels.append({
"class_id": class_id,
"bbox": (x_center, y_center, w_norm, h_norm)
})
labels.append(
{"class_id": class_id, "bbox": (x_center, y_center, w_norm, h_norm)}
)

success = True
break
Expand Down
34 changes: 24 additions & 10 deletions compositor/overlay/overlay_single.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,36 @@
from PIL import Image
import cv2


def random_perspective_transform(img_np, max_offset=0.1):
h, w = img_np.shape[:2]
dx = int(w * max_offset)
dy = int(h * max_offset)

src = np.float32([[0, 0], [w, 0], [w, h], [0, h]])
dst = np.float32([
[random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), h - random.randint(0, dy)],
[random.randint(0, dx), h - random.randint(0, dy)]
])
dst = np.float32(
[
[random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), random.randint(0, dy)],
[w - random.randint(0, dx), h - random.randint(0, dy)],
[random.randint(0, dx), h - random.randint(0, dy)],
]
)
M = cv2.getPerspectiveTransform(src, dst)
warped = cv2.warpPerspective(img_np, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0))
warped = cv2.warpPerspective(
img_np, M, (w, h), borderMode=cv2.BORDER_CONSTANT, borderValue=(0, 0, 0, 0)
)
return warped

def paste_single_overlay(background: Image.Image, overlay: Image.Image, class_id: int,
scale_range=(0.2, 0.6), max_attempts=20, color_match_strength=0.05):

def paste_single_overlay(
background: Image.Image,
overlay: Image.Image,
class_id: int,
scale_range=(0.2, 0.6),
max_attempts=20,
color_match_strength=0.05,
):
"""
Paste a single target image onto a background with:
- slight perspective warp
Expand Down Expand Up @@ -53,7 +65,9 @@ def paste_single_overlay(background: Image.Image, overlay: Image.Image, class_id
y_min = random.randint(0, bg_h - ov.height)

# --- Subtle color matching ---
bg_crop = bg.crop((x_min, y_min, x_min + ov.width, y_min + ov.height)).convert("RGB")
bg_crop = bg.crop((x_min, y_min, x_min + ov.width, y_min + ov.height)).convert(
"RGB"
)
ov_rgb = ov.convert("RGB")

bg_np = np.array(bg_crop).astype(np.float32)
Expand Down
7 changes: 3 additions & 4 deletions compositor/overlay/overlay_torpedo.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,9 @@ def main(images_dir, yolo_dir, output_dir, output_yolo_dir):
box_x_px = x * w_img
box_y_px = y * h_img

left = box_x_px - box_w_px / 2
top = box_y_px - box_h_px / 2
right = box_x_px + box_w_px / 2
bottom = box_y_px + box_h_px / 2
left = box_x_px - box_w_px / 2
top = box_y_px - box_h_px / 2
right = box_x_px + box_w_px / 2

ring_center_x = (left + right) / 2
ring_center_y = top + box_h_px * 0.75
Expand Down
Empty file added conftest.py
Empty file.
41 changes: 31 additions & 10 deletions generate.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,28 @@
import random
from compositor.overlay.overlay_single import paste_single_overlay

#LazyLoading: Loads paths instead of all the images at once so that the system doesn't crash
# useful because strings hold less memory than

# LazyLoading: Loads paths instead of all the images at once so that the system doesn't crash
# useful because strings hold less memory than
def load_paths_from_folder(folder):
paths = []
for f in os.listdir(folder):
if f.lower().endswith((".png", ".jpg", ".jpeg")):
paths.append(os.path.join(folder, f)) #attached any file path that is an image within the folder.
if f.lower().endswith((".png", ".jpg", ".jpeg")):
paths.append(
os.path.join(folder, f)
) # attached any file path that is an image within the folder.
return paths

def main(backgrounds_dir, real_targets_dir, fake_targets_dir, output_img_dir, output_yolo_dir, max_attempts, num_backgrounds=None):

def main(
backgrounds_dir,
real_targets_dir,
fake_targets_dir,
output_img_dir,
output_yolo_dir,
max_attempts,
num_backgrounds=None,
):
os.makedirs(output_img_dir, exist_ok=True)
os.makedirs(output_yolo_dir, exist_ok=True)

Expand All @@ -24,15 +36,18 @@ def main(backgrounds_dir, real_targets_dir, fake_targets_dir, output_img_dir, ou
background_paths = background_paths[:num_backgrounds]

for i, bg_path in enumerate(background_paths):
#chooses a random index from the target images to use on each background
# chooses a random index from the target images to use on each background
target_idx = random.randint(0, len(real_target_paths) - 1)
class_id = target_idx + 1

#Adjusted opening method to use paths instead of the actual image.
# Adjusted opening method to use paths instead of the actual image.
with Image.open(bg_path) as bg:
with Image.open(real_target_paths[target_idx]) as target:
composite, labels = paste_single_overlay(
bg.copy(), target.copy(), class_id=class_id, max_attempts=max_attempts
bg.copy(),
target.copy(),
class_id=class_id,
max_attempts=max_attempts,
)

img_name = f"img_{i:05d}.jpg"
Expand All @@ -41,6 +56,11 @@ def main(backgrounds_dir, real_targets_dir, fake_targets_dir, output_img_dir, ou
img_path = os.path.join(output_img_dir, img_name)
yolo_path = os.path.join(output_yolo_dir, label_name)

if composite is None or labels is None:
print(f"Warning: placement failed for {img_name}, saving empty label")
open(yolo_path, "w").close()
continue

composite.save(img_path, quality=95)

bbox = labels.get("bbox")
Expand All @@ -55,6 +75,7 @@ def main(backgrounds_dir, real_targets_dir, fake_targets_dir, output_img_dir, ou

print(f"Generated {img_name} + {label_name}")


if __name__ == "__main__":
parser = argparse.ArgumentParser()

Expand All @@ -75,5 +96,5 @@ def main(backgrounds_dir, real_targets_dir, fake_targets_dir, output_img_dir, ou
args.output_img_dir,
args.output_yolo_dir,
args.max_attempts,
args.num_backgrounds
)
args.num_backgrounds,
)
Loading
Loading