Skip to content

Commit 3f6bb97

Browse files
committed
CI/CD implementations
1 parent 2da9eaf commit 3f6bb97

3 files changed

Lines changed: 289 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: SimCLR CI
2+
3+
on:
4+
push:
5+
branches: [ "main", "master" ]
6+
pull_request:
7+
branches: [ "main", "master" ]
8+
9+
jobs:
10+
build:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.8", "3.9", "3.10"]
15+
16+
steps:
17+
- uses: actions/checkout@v3
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v4
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
28+
pip install pytest flake8
29+
30+
- name: Lint with flake8
31+
run: |
32+
# stop the build if there are Python syntax errors or undefined names
33+
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics
34+
# exit-zero treats all errors as warnings. The GitHub editor is 127 chars wide
35+
flake8 . --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
36+
37+
- name: Test with pytest
38+
run: |
39+
pytest tests/

scripts/train_segmentation.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
import torch
2+
import torchvision
3+
from torchvision.models.detection import MaskRCNN
4+
from torchvision.models.detection.anchor_utils import AnchorGenerator
5+
from torchvision.models.detection.backbone_utils import resnet_fpn_backbone
6+
from torchvision.transforms import functional as F
7+
import os
8+
import numpy as np
9+
from PIL import Image
10+
import requests
11+
import zipfile
12+
from tqdm import tqdm
13+
import sys
14+
15+
# Add project root to path
16+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
17+
from src.model import SimCLR
18+
19+
DEVICE = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')
20+
21+
def download_sample_dataset(root):
22+
"""
23+
Downloads Penn-Fudan Database for Pedestrian Detection and Segmentation
24+
as a sample dataset to verify the pipeline.
25+
User should replace this with WHU Building Dataset or similar for Remote Sensing.
26+
"""
27+
url = "https://www.cis.upenn.edu/~jshi/ped_html/PennFudanPed.zip"
28+
target_dir = os.path.join(root, "PennFudanPed")
29+
30+
if os.path.exists(target_dir):
31+
print("Sample dataset already exists.")
32+
return target_dir
33+
34+
print(f"Downloading sample dataset from {url}...")
35+
zip_path = os.path.join(root, "PennFudanPed.zip")
36+
r = requests.get(url, stream=True)
37+
with open(zip_path, 'wb') as f:
38+
for chunk in r.iter_content(chunk_size=1024):
39+
if chunk:
40+
f.write(chunk)
41+
42+
print("Extracting...")
43+
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
44+
zip_ref.extractall(root)
45+
46+
return target_dir
47+
48+
class RemoteSensingDataset(torch.utils.data.Dataset):
49+
def __init__(self, root, transforms=None):
50+
self.root = root
51+
self.transforms = transforms
52+
# Setup for PennFudan structure. Modify for WHU/Standard format.
53+
self.imgs = list(sorted(os.listdir(os.path.join(root, "PNGImages"))))
54+
self.masks = list(sorted(os.listdir(os.path.join(root, "PedMasks"))))
55+
56+
def __getitem__(self, idx):
57+
# Load image and mask
58+
img_path = os.path.join(self.root, "PNGImages", self.imgs[idx])
59+
mask_path = os.path.join(self.root, "PedMasks", self.masks[idx])
60+
61+
img = Image.open(img_path).convert("RGB")
62+
# Mask is usually 1-channel, where each pixel value is the instance ID
63+
mask = Image.open(mask_path)
64+
mask = np.array(mask)
65+
66+
# Instance IDs
67+
obj_ids = np.unique(mask)
68+
# First ID is background, remove it
69+
obj_ids = obj_ids[1:]
70+
71+
masks = mask == obj_ids[:, None, None]
72+
73+
num_objs = len(obj_ids)
74+
boxes = []
75+
for i in range(num_objs):
76+
pos = np.where(masks[i])
77+
xmin = np.min(pos[1])
78+
xmax = np.max(pos[1])
79+
ymin = np.min(pos[0])
80+
ymax = np.max(pos[0])
81+
boxes.append([xmin, ymin, xmax, ymax])
82+
83+
boxes = torch.as_tensor(boxes, dtype=torch.float32)
84+
labels = torch.ones((num_objs,), dtype=torch.int64) # Single class (e.g., Building)
85+
masks = torch.as_tensor(masks, dtype=torch.uint8)
86+
87+
image_id = torch.tensor([idx])
88+
area = (boxes[:, 3] - boxes[:, 1]) * (boxes[:, 2] - boxes[:, 0])
89+
iscrowd = torch.zeros((num_objs,), dtype=torch.int64)
90+
91+
target = {}
92+
target["boxes"] = boxes
93+
target["labels"] = labels
94+
target["masks"] = masks
95+
target["image_id"] = image_id
96+
target["area"] = area
97+
target["iscrowd"] = iscrowd
98+
99+
if self.transforms is not None:
100+
img = F.to_tensor(img)
101+
# Add other transforms here if needed
102+
103+
return img, target
104+
105+
def __len__(self):
106+
return len(self.imgs)
107+
108+
def get_model_instance_segmentation(num_classes, pretrained_simclr_path=None):
109+
# 1. Create ResNet-101 FPN backbone
110+
# trainable_layers=3 means we train the top 3 blocks.
111+
backbone = resnet_fpn_backbone('resnet101', weights=None, trainable_layers=3)
112+
113+
# 2. Load SimCLR Weights
114+
if pretrained_simclr_path and os.path.exists(pretrained_simclr_path):
115+
print(f"Loading SimCLR weights from {pretrained_simclr_path} into Mask R-CNN backbone...")
116+
try:
117+
# SimCLR state dict keys: backbone.conv1.weight, backbone.layer1.0.conv1.weight...
118+
# FPN state dict keys: body.conv1.weight, body.layer1.0.conv1.weight...
119+
state_dict = torch.load(pretrained_simclr_path, map_location='cpu')
120+
121+
# If saved as full model/wrapper
122+
if 'state_dict' in state_dict:
123+
state_dict = state_dict['state_dict']
124+
125+
new_state_dict = {}
126+
for k, v in state_dict.items():
127+
if k.startswith('backbone.'):
128+
# Replace 'backbone.' with 'body.' to match FPN structure
129+
new_key = k.replace('backbone.', 'body.')
130+
# FPN backbone doesn't have 'fc' layer, so ignore it
131+
if 'fc' not in new_key:
132+
new_state_dict[new_key] = v
133+
134+
# Load 'body' weights
135+
# strict=False because FPN has extra layers (fpn_inner, fpn_layer) that are not in ResNet
136+
missing, unexpected = backbone.load_state_dict(new_state_dict, strict=False)
137+
print(f"Weights loaded. Missing (expected for FPN layers): {len(missing)}, Unexpected: {len(unexpected)}")
138+
except Exception as e:
139+
print(f"Failed to load SimCLR weights: {e}")
140+
print("Using random initialization for backbone.")
141+
142+
# 3. Create Mask R-CNN
143+
model = MaskRCNN(backbone, num_classes=num_classes)
144+
return model
145+
146+
def main():
147+
# Paths
148+
DATA_DIR = "./data"
149+
SIMCLR_PATH = "simclr_model_RN101.pth"
150+
151+
# 1. Prepare Data
152+
data_path = download_sample_dataset(DATA_DIR)
153+
dataset = RemoteSensingDataset(data_path)
154+
155+
# Split
156+
indices = torch.randperm(len(dataset)).tolist()
157+
dataset_train = torch.utils.data.Subset(dataset, indices[:-50])
158+
dataset_test = torch.utils.data.Subset(dataset, indices[-50:])
159+
160+
data_loader = torch.utils.data.DataLoader(
161+
dataset_train, batch_size=2, shuffle=True, num_workers=0, collate_fn=lambda x: tuple(zip(*x))
162+
)
163+
164+
# 2. Setup Model
165+
# num_classes = 2 (background + building/pedestrian)
166+
model = get_model_instance_segmentation(num_classes=2, pretrained_simclr_path=SIMCLR_PATH)
167+
model.to(DEVICE)
168+
169+
# 3. Optimization
170+
params = [p for p in model.parameters() if p.requires_grad]
171+
optimizer = torch.optim.SGD(params, lr=0.005, momentum=0.9, weight_decay=0.0005)
172+
lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)
173+
174+
# 4. Training Loop (Simple)
175+
num_epochs = 2 # Demo
176+
print("Starting Training...")
177+
178+
for epoch in range(num_epochs):
179+
model.train()
180+
i = 0
181+
for images, targets in tqdm(data_loader):
182+
images = list(image.to(DEVICE) for image in images)
183+
targets = [{k: v.to(DEVICE) for k, v in t.items()} for t in targets]
184+
185+
loss_dict = model(images, targets)
186+
losses = sum(loss for loss in loss_dict.values())
187+
188+
optimizer.zero_grad()
189+
losses.backward()
190+
optimizer.step()
191+
192+
if i % 10 == 0:
193+
print(f"Epoch {epoch}, Iter {i}, Loss: {losses.item():.4f}")
194+
i += 1
195+
196+
lr_scheduler.step()
197+
print(f"Epoch {epoch} complete.")
198+
199+
# Save
200+
torch.save(model.state_dict(), "maskrcnn_simclr_tuned.pth")
201+
print("Model saved to maskrcnn_simclr_tuned.pth")
202+
203+
if __name__ == "__main__":
204+
main()

tests/test_model.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import torch
2+
import unittest
3+
import sys
4+
import os
5+
6+
# Add project root to path
7+
sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
8+
9+
from src.model import SimCLR
10+
from torchvision import models
11+
12+
class TestSimCLR(unittest.TestCase):
13+
def setUp(self):
14+
# Use a smaller backbone for speed in tests, or standard ResNet18
15+
self.backbone = models.resnet18(weights=None)
16+
self.feat_dim = 128
17+
self.model = SimCLR(backbone=self.backbone, tau=0.1, feat_dim=self.feat_dim)
18+
19+
def test_simclr_initialization(self):
20+
"""Test if SimCLR initializes correctly and replaces the fc layer."""
21+
self.assertIsInstance(self.model.backbone.fc, torch.nn.Identity)
22+
self.assertTrue(hasattr(self.model, 'projection_head'))
23+
24+
def test_forward_shape(self):
25+
"""Test the forward pass output shape (loss)."""
26+
batch_size = 4
27+
# Create dummy inputs (B, C, H, W)
28+
x1 = torch.randn(batch_size, 3, 224, 224)
29+
x2 = torch.randn(batch_size, 3, 224, 224)
30+
31+
# Forward pass returns a scalar loss
32+
loss = self.model(x1, x2)
33+
self.assertEqual(loss.dim(), 0)
34+
self.assertIsInstance(loss.item(), float)
35+
36+
def test_encode_shape(self):
37+
"""Test the encoder output shape."""
38+
batch_size = 4
39+
x = torch.randn(batch_size, 3, 224, 224)
40+
41+
# ResNet18 backbone output is 512
42+
features = self.model.encode(x)
43+
self.assertEqual(features.shape, (batch_size, 512))
44+
45+
if __name__ == '__main__':
46+
unittest.main()

0 commit comments

Comments
 (0)