refactor api

This commit is contained in:
2026-09-25 19:42:47 +02:00
parent e856c1fa43
commit 8755142c97
49 changed files with 1652 additions and 1829 deletions
+7 -3
View File
@@ -1,9 +1,13 @@
# Allowlist: the Dockerfile only copies these three things, so nothing # Allowlist: only what the server needs is sent to the Docker daemon, so
# else (training code, datasets, saved models, caches) is sent to the daemon. # training code, datasets, saved models and caches stay out of the image.
* *
!requirements.txt !requirements.txt
!src/production !start_server.sh
!build_models !build_models
!src/__init__.py
!src/common
!src/models
!src/production
# Re-exclude junk inside the allowed paths # Re-exclude junk inside the allowed paths
**/__pycache__ **/__pycache__
+4 -1
View File
@@ -12,8 +12,11 @@ COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN pip install --no-cache-dir -r requirements.txt
COPY src/production/. . # Only the code the server needs (see .dockerignore): src/production plus the
# shared src/models and src/common packages.
COPY src ./src
COPY build_models ./build_models COPY build_models ./build_models
COPY start_server.sh .
RUN chmod +x start_server.sh RUN chmod +x start_server.sh
-152
View File
@@ -1,152 +0,0 @@
import os
import torch
import torch.nn as nn
import torch.nn.functional as F
from tqdm import tqdm
class SeparableConvolution(nn.Module):
def __init__(self, c_in, c_out, kernel_size):
super().__init__()
self.depthwise = nn.Conv2d(c_in, c_in, kernel_size, groups=c_in, padding=kernel_size//2)
self.bn1 = nn.BatchNorm2d(c_in)
self.pointwise = nn.Conv2d(c_in, c_out, kernel_size=1)
self.bn2 = nn.BatchNorm2d(c_out)
def forward(self, x):
x = self.depthwise(x)
x = self.bn1(x)
x = F.relu(x)
x = self.pointwise(x)
x = self.bn2(x)
x = F.relu(x)
return x
class SkipBlock(nn.Module):
def __init__(self, c_in, c_out, kernel_size=3):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(c_in, c_out, kernel_size, padding=kernel_size//2),
nn.BatchNorm2d(c_out),
nn.ReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size//2),
nn.BatchNorm2d(c_out),
nn.ReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size//2),
nn.BatchNorm2d(c_out),
nn.ReLU(inplace=True)
)
self.conv_skip = nn.Sequential(
nn.Conv2d(c_in, c_out, 1),
nn.BatchNorm2d(c_out),
nn.ReLU(inplace=True)
)
def forward(self, x):
return(F.relu(self.conv_skip(x) + self.conv(x), inplace=True))
class Bird_CNN(nn.Module):
def __init__(self, c_in, c_hidden, c_out):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.BatchNorm2d(c_hidden),
nn.ReLU(inplace=True),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
nn.Conv2d(c_hidden*2, c_hidden*4, kernel_size=3, padding=1),
nn.ReLU(inplace=True),
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(c_hidden*4, c_out),
nn.Dropout(0.3)
)
def forward(self, x):
return self.model(x)
def trainCNN(model, optimizer, loss_module, train_data_loader, validation_data_loader, device, num_epochs, SAVE_PATH, save=False):
best_val = 0
for epoch in range(num_epochs):
############
# Training #
############
model.train()
true_preds, count = 0, 0
for data_inputs, classes in tqdm(train_data_loader, desc=f"Train Epoch {epoch+1}", leave=False):
data_inputs = data_inputs.to(device)
classes = classes.to(device)
preds = model(data_inputs)
loss = loss_module(preds, classes)
optimizer.zero_grad()
loss.backward()
optimizer.step()
true_preds += (preds.argmax(dim=1) == classes).sum().item()
count += data_inputs.size(0)
train_acc = true_preds / count
torch.cuda.empty_cache()
##############
# Validation #
##############
model.eval()
true_preds, count = 0, 0
for data_inputs, classes in tqdm(validation_data_loader, desc=f"Validate Epoch {epoch+1}", leave=False):
with torch.no_grad():
data_inputs = data_inputs.to(device)
classes = classes.to(device)
preds = model(data_inputs)
loss = loss_module(preds, classes)
true_preds += (preds.argmax(dim=1) == classes).sum().item()
count += data_inputs.size(0)
val_acc = true_preds / count
if(save and best_val < val_acc):
best_val = val_acc
save_dir = os.path.join(SAVE_PATH, "bird_cnn")
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, f"bird_cnn")
torch.save(model.state_dict(), save_path)
print(f"epoch: {epoch+1} | train accuracy: {int(train_acc * 1000) / 10}% | validation accuracy: {int(val_acc * 1000) / 10}%")
torch.cuda.empty_cache()
def sample(model, img, device, SAVE_PATH, model_name="bird_cnn", folder="bird_cnn"):
with torch.no_grad():
full_path = os.path.join(SAVE_PATH, folder, model_name)
state_dict = torch.load(full_path, weights_only=False)
model.load_state_dict(state_dict)
model.eval()
img = img.to(device)
pred = model(img)
probs = F.softmax(pred, dim=1)
return(torch.max(probs, dim=1))
+33
View File
@@ -0,0 +1,33 @@
from dataclasses import dataclass
@dataclass
class Config:
"""Settings for the bird classifier. Paths are relative to api/.
Every field can be overridden on the command line, e.g. --epochs 20."""
# Data
data_dir: str = "data/CUB_200_2011/images" # ImageFolder layout: one folder per class
train_fraction: float = 0.8
image_size: int = 128
# Model
num_classes: int = 200
c_hidden: int = 16
# Training
epochs: int = 500
batch_size: int = 64
learning_rate: float = 1e-3
weight_decay: float = 1e-4
num_workers: int = 0
device: str = "auto" # "auto", "cpu", "cuda:0", ...
# Output
save_dir: str = "saved_models"
model_name: str = "bird_cnn"
save: bool = True
# predict task
image: str = "test1.jpg"
checkpoint: str = "" # empty = <save_dir>/<model_name>/<model_name>
+87
View File
@@ -0,0 +1,87 @@
"""Bird classifier tasks. Run with: python -m src.main bird.<task>"""
import torch
import torch.nn as nn
import torch.nn.functional as F
from PIL import Image
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
from src.bird_cnn.config import Config
from src.common.checkpoints import checkpoint_path, load_checkpoint
from src.common.data import split_dataset
from src.common.device import get_device
from src.common.training import fit
from src.common.visualize import show_batch
from src.models.bird_cnn import BirdSpecies, Bird_CNN
def eval_transform(cfg: Config):
return transforms.Compose([
transforms.Resize(cfg.image_size),
transforms.CenterCrop(cfg.image_size),
transforms.ToTensor()
])
def train_transform(cfg: Config):
return transforms.Compose([
transforms.RandomAffine(degrees=35, translate=(0.2, 0.2)),
transforms.RandomHorizontalFlip(p=0.5),
transforms.Resize(cfg.image_size),
transforms.CenterCrop(cfg.image_size),
transforms.ToTensor()
])
def build_loaders(cfg: Config):
dataset = datasets.ImageFolder(cfg.data_dir)
train_ds, val_ds = split_dataset(dataset, cfg.train_fraction, train_transform(cfg), eval_transform(cfg))
train_loader = DataLoader(train_ds, batch_size=cfg.batch_size, shuffle=True, num_workers=cfg.num_workers)
val_loader = DataLoader(val_ds, batch_size=cfg.batch_size, shuffle=False, num_workers=cfg.num_workers)
return train_loader, val_loader
def build_model(cfg: Config):
return Bird_CNN(c_in=3, c_hidden=cfg.c_hidden, c_out=cfg.num_classes)
def train(cfg: Config):
"""Train the classifier; saves the model with the best validation accuracy."""
device = get_device(cfg.device)
train_loader, val_loader = build_loaders(cfg)
model = build_model(cfg).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=cfg.learning_rate, weight_decay=cfg.weight_decay)
fit(model, nn.CrossEntropyLoss(), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.model_name, save=cfg.save,
monitor="acc", track_accuracy=True)
def view_data(cfg: Config):
"""Show a few augmented training images with their labels."""
train_loader, _ = build_loaders(cfg)
show_batch(train_loader)
def predict(cfg: Config):
"""Classify one image (--image) with a saved model."""
device = get_device(cfg.device)
path = cfg.checkpoint or checkpoint_path(cfg.save_dir, cfg.model_name)
model = load_checkpoint(build_model(cfg), path, device)
image = eval_transform(cfg)(Image.open(cfg.image).convert("RGB")).unsqueeze(0).to(device)
with torch.inference_mode():
confidence, pred = torch.max(F.softmax(model(image), dim=1), dim=1)
cls = pred.item()
name = BirdSpecies(cls).name if cfg.num_classes == len(BirdSpecies) else f"class {cls}"
print(f"Species: {name} | Confidence: {confidence.item():.2f}")
TASKS = {
"train": train,
"view-data": view_data,
"predict": predict,
}
-72
View File
@@ -1,72 +0,0 @@
from enum import Enum
import torch
import torch.nn as nn
import torchvision
from PIL import Image
from torchvision import datasets, transforms
from torch.utils.data import DataLoader, random_split
from src.bird_cnn.bird_cnn import Bird_CNN, sample, trainCNN
from src.util import TransformedSubset, visualizeData
SAVE_PATH = "../saved_models"
IMAGE_SIZE = 128
class bird_species(Enum):
Common_Kingfisher = 0
Common_Myna = 1
House_Crow = 2
Indian_Peacock = 3
Indian_Pitta = 4
Ruddy_Shelduck = 5
Sarus_Crane = 6
transform_augemnt = transforms.Compose([
transforms.RandomAffine(
degrees=35, # no rotation
translate=(0.2, 0.2) # shift up to 20% horizontally/vertically
),
transforms.RandomHorizontalFlip(p=0.5),
transforms.Resize(IMAGE_SIZE),
transforms.CenterCrop(IMAGE_SIZE),
transforms.ToTensor()
])
transform = transforms.Compose([
transforms.Resize(IMAGE_SIZE),
transforms.CenterCrop(IMAGE_SIZE),
transforms.ToTensor()
])
dataset = datasets.ImageFolder("data/CUB_200_2011/images")
train_size = int(0.8 * len(dataset))
val_size = len(dataset) - train_size
train_subset, val_subset = random_split(dataset, [train_size, val_size])
train_dataset = TransformedSubset(train_subset, transform_augemnt)
val_dataset = TransformedSubset(val_subset, transform)
train_loader = DataLoader(train_dataset, batch_size=64, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=64, shuffle=False)
device = torch.device("cpu") if not torch.cuda.is_available() else torch.device("cuda:0")
print("Using device", device)
model = Bird_CNN(c_in=3, c_hidden=16, c_out=200)
model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
loss_module = nn.CrossEntropyLoss()
trainCNN(model, optimizer, loss_module, train_loader, val_loader, device, 500, SAVE_PATH=SAVE_PATH, save=True)
exit()
image_path = "test1.jpg"
image = Image.open(image_path).convert("RGB")
image = transform(image)
image = image.unsqueeze(0)
confidence, pred = sample(model=model, img=image, device=device, SAVE_PATH=SAVE_PATH)
print(f"Species: {bird_species(pred.item()).name} | Confidence: {int(confidence.item()*100)/100}")
View File
+64
View File
@@ -0,0 +1,64 @@
"""Bounding box helpers. Boxes are [xmin, ymin, xmax, ymax] unless noted.
Used by training and by the production server, so keep this free of
training-only dependencies."""
def iou(boxA, boxB):
xA = max(boxA[0], boxB[0])
yA = max(boxA[1], boxB[1])
xB = min(boxA[2], boxB[2])
yB = min(boxA[3], boxB[3])
inter_area = max(0, xB - xA) * max(0, yB - yA)
boxA_area = (boxA[2] - boxA[0]) * (boxA[3] - boxA[1])
boxB_area = (boxB[2] - boxB[0]) * (boxB[3] - boxB[1])
union = boxA_area + boxB_area - inter_area
return inter_area / union if union > 0 else 0
def nms(bboxes, iou_threshold=0.1):
"""bboxes: [confidence, xmin, ymin, xmax, ymax]. Returns boxes without
the confidence, highest confidence first."""
bboxes = sorted(bboxes, key=lambda x: x[0], reverse=True)
keep = []
while bboxes:
best = bboxes.pop(0)
keep.append(best[1:5])
bboxes = [box for box in bboxes if iou(best[1:5], box[1:5]) < iou_threshold]
return keep
def xy_center_to_edges(xcenter, ycenter, width, height):
width = max(width, 1)
height = max(height, 1)
x = xcenter - (width / 2)
y = ycenter - (height / 2)
return [x, y, x + width, y + height]
def cell_to_image_coords(x, y, img_w, img_h, S, cell_i, cell_j):
"""Grid-cell-relative position -> image position."""
return x + (img_w / S) * cell_i, y + (img_h / S) * cell_j
def image_to_cell_coords(x, y, img_w, img_h, S, cell_i, cell_j):
"""Image position -> grid-cell-relative position."""
return x - (img_w / S) * cell_i, y - (img_h / S) * cell_j
def is_center_in_grid_cell(x, y, img_w, img_h, S, cell_i, cell_j):
gt_cell_i = min(int(x / (img_w / S)), S - 1)
gt_cell_j = min(int(y / (img_h / S)), S - 1)
return (gt_cell_i == cell_i) and (gt_cell_j == cell_j)
def scale_box(box, scale_w, scale_h):
return [int(box[0] * scale_w), int(box[1] * scale_h), int(box[2] * scale_w), int(box[3] * scale_h)]
+21
View File
@@ -0,0 +1,21 @@
from pathlib import Path
import torch
def checkpoint_path(save_dir: str | Path, model_name: str) -> Path:
# Layout kept from the original code: <save_dir>/<name>/<name>
return Path(save_dir) / model_name / model_name
def save_checkpoint(model: torch.nn.Module, save_dir: str | Path, model_name: str) -> Path:
path = checkpoint_path(save_dir, model_name)
path.parent.mkdir(parents=True, exist_ok=True)
torch.save(model.state_dict(), path)
return path
def load_checkpoint(model: torch.nn.Module, path: str | Path, device: torch.device) -> torch.nn.Module:
state_dict = torch.load(path, map_location=device, weights_only=True)
model.load_state_dict(state_dict)
return model.to(device).eval()
+35
View File
@@ -0,0 +1,35 @@
import torch
from torch.utils.data import Dataset, random_split
class TransformedSubset(Dataset):
"""Applies a transform to a Subset, so train and validation splits of the
same dataset can use different transforms."""
def __init__(self, subset, transform=None):
self.subset = subset
self.transform = transform
def __getitem__(self, idx):
x, y = self.subset[idx]
if self.transform:
x = self.transform(x)
return x, y
def __len__(self):
return len(self.subset)
def split_dataset(dataset, train_fraction: float, train_transform, val_transform):
train_size = int(train_fraction * len(dataset))
val_size = len(dataset) - train_size
train_subset, val_subset = random_split(dataset, [train_size, val_size])
return (
TransformedSubset(train_subset, train_transform),
TransformedSubset(val_subset, val_transform),
)
def collate_as_tuples(batch):
"""For detection datasets whose targets are dicts of different sizes."""
return tuple(zip(*batch))
+10
View File
@@ -0,0 +1,10 @@
import torch
def get_device(name: str = "auto") -> torch.device:
""""auto" picks CUDA when available, otherwise CPU."""
if name == "auto":
name = "cuda:0" if torch.cuda.is_available() else "cpu"
device = torch.device(name)
print("Using device", device)
return device
+108
View File
@@ -0,0 +1,108 @@
"""One training loop for all models.
Each epoch trains, validates, prints the metrics and saves the model when the
monitored metric improves. `prepare_batch` turns a loader batch into
(inputs, targets); the default just moves both to the device."""
import math
import time
import torch
from torch.utils.data import DataLoader
from tqdm import tqdm
from src.common.checkpoints import save_checkpoint
def _default_prepare(batch, device):
inputs, targets = batch
return inputs.to(device), targets.to(device)
def _run_epoch(model, loss_fn, loader, device, prepare_batch, track_accuracy, optimizer=None, desc=""):
training = optimizer is not None
model.train(training)
loss_sum, correct, count = 0.0, 0, 0
with torch.set_grad_enabled(training):
for batch in tqdm(loader, desc=desc, leave=False):
inputs, targets = prepare_batch(batch, device)
preds = model(inputs)
loss = loss_fn(preds, targets)
if training:
optimizer.zero_grad()
loss.backward()
optimizer.step()
loss_sum += loss.item()
count += inputs.size(0)
if track_accuracy:
correct += (preds.argmax(dim=1) == targets).sum().item()
if torch.cuda.is_available():
torch.cuda.empty_cache()
metrics = {"loss": loss_sum / max(count, 1)}
if track_accuracy:
metrics["acc"] = correct / max(count, 1)
return metrics
def fit(
model,
loss_fn,
optimizer,
train_loader,
val_loader,
device,
epochs: int,
save_dir: str,
model_name: str,
save: bool = True,
monitor: str = "loss", # "loss" (lower is better) or "acc" (higher is better)
track_accuracy: bool = False, # for classifiers
prepare_batch=_default_prepare,
):
best = math.inf if monitor == "loss" else -math.inf
for epoch in range(1, epochs + 1):
train = _run_epoch(model, loss_fn, train_loader, device, prepare_batch, track_accuracy,
optimizer=optimizer, desc=f"Train {epoch}")
val = _run_epoch(model, loss_fn, val_loader, device, prepare_batch, track_accuracy,
desc=f"Validate {epoch}")
improved = val[monitor] < best if monitor == "loss" else val[monitor] > best
if improved:
best = val[monitor]
if save:
save_checkpoint(model, save_dir, model_name)
# Loss is the summed batch loss per sample, shown x1000.
parts = [f"epoch: {epoch}"]
if track_accuracy:
parts.append(f"train acc: {train['acc'] * 100:.1f}% | val acc: {val['acc'] * 100:.1f}%")
parts.append(f"train loss: {train['loss'] * 1000:.2f} | val loss: {val['loss'] * 1000:.2f}")
if improved and save:
parts.append("saved")
print(" | ".join(parts))
return best
def benchmark_num_workers(dataset, model, device, batch_size=64, rounds=2):
"""Prints how long `rounds` passes over the dataset take for different
DataLoader num_workers values, to pick the fastest one."""
import multiprocessing as mp
model.eval()
for num_workers in range(0, mp.cpu_count(), 2):
loader = DataLoader(dataset, shuffle=True, num_workers=num_workers,
batch_size=batch_size, pin_memory=True)
start = time.time()
with torch.no_grad():
for _ in range(rounds):
for images, _ in tqdm(loader, leave=False):
model(images.to(device))
print(f"num_workers={num_workers}: {time.time() - start:.0f} seconds")
+26
View File
@@ -0,0 +1,26 @@
"""Plot helpers for local experiments (matplotlib is not installed in the
production image, so it is imported lazily)."""
def show_image(image, title=None):
"""image: tensor [C, H, W]."""
from matplotlib import pyplot as plt
plt.figure(figsize=(3, 3))
plt.imshow(image.permute(1, 2, 0))
if title:
plt.title(title)
plt.axis("off")
plt.show()
def show_batch(loader, count=4):
from matplotlib import pyplot as plt
images, labels = next(iter(loader))
for i in range(min(count, len(images))):
plt.figure(figsize=(3, 3))
plt.imshow(images[i].permute(1, 2, 0))
plt.title(f"Label: {labels[i].item()}")
plt.axis("off")
plt.show()
+91 -3
View File
@@ -1,8 +1,96 @@
from src.yolo.train_yolo_faces import train_yolo """One entrypoint for everything in api/. Run it from the api/ folder:
python -m src.main list all tasks
python -m src.main yolo.train run a task with its defaults
python -m src.main yolo.train --epochs 20 --batch-size 32
python -m src.main yolo.train --help show every option of a task
Defaults live in src/<group>/config.py; edit them there or override them per
run with flags. To start from an IDE "Run" button without arguments, set
DEFAULT_TASK (and optionally DEFAULT_ARGS) below.
"""
import argparse
from dataclasses import fields
import importlib
import sys
# Used when the script is started without arguments.
DEFAULT_TASK = None # e.g. "yolo.train"
DEFAULT_ARGS = [] # e.g. ["--epochs", "20"]
# group name -> package that contains config.py (Config) and tasks.py (TASKS).
# Packages are only imported when one of their tasks is used, so a missing
# training dependency does not break the others.
GROUPS = {
"bird": "src.bird_cnn",
"yolo": "src.yolo",
"rcnn": "src.r_cnn",
"server": "src.production",
}
def main(): def load_group(group: str):
train_yolo() package = GROUPS[group]
config_cls = importlib.import_module(f"{package}.config").Config
tasks = importlib.import_module(f"{package}.tasks").TASKS
return config_cls, tasks
def first_line(text):
return (text or "").strip().splitlines()[0] if text and text.strip() else ""
def list_tasks():
print(__doc__)
print("Tasks:")
for group in GROUPS:
try:
_, tasks = load_group(group)
except ImportError as e:
print(f" {group}.* (unavailable: {e})")
continue
for name, fn in tasks.items():
print(f" {group + '.' + name:<28} {first_line(fn.__doc__)}")
def build_parser(task: str, config_cls, fn):
parser = argparse.ArgumentParser(
prog=f"python -m src.main {task}",
description=(fn.__doc__ or "").strip(),
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
)
for f in fields(config_cls):
flag = "--" + f.name.replace("_", "-")
if f.type is bool:
parser.add_argument(flag, dest=f.name, action=argparse.BooleanOptionalAction, default=f.default)
else:
parser.add_argument(flag, dest=f.name, type=f.type, default=f.default, metavar=f.type.__name__.upper())
return parser
def main(argv=None):
argv = sys.argv[1:] if argv is None else argv
if not argv and DEFAULT_TASK:
argv = [DEFAULT_TASK, *DEFAULT_ARGS]
if not argv or argv[0] in ("-h", "--help", "list"):
list_tasks()
return
task, *rest = argv
group, _, name = task.partition(".")
if group not in GROUPS:
sys.exit(f"Unknown group '{group}'. Groups: {', '.join(GROUPS)}")
config_cls, tasks = load_group(group)
if name not in tasks:
sys.exit(f"Unknown task '{task}'. Tasks in {group}: {', '.join(tasks)}")
fn = tasks[name]
args = build_parser(task, config_cls, fn).parse_args(rest)
cfg = config_cls(**vars(args))
fn(cfg)
if __name__ == "__main__": if __name__ == "__main__":
main() main()
View File
@@ -1,24 +1,22 @@
"""Bird classifier. Shared by training and the production server; the layer
names must not change or saved weights stop loading."""
from enum import Enum
import torch.nn as nn import torch.nn as nn
import torch.nn.functional as F import torch.nn.functional as F
class SeparableConvolution(nn.Module):
def __init__(self, c_in, c_out, kernel_size):
super().__init__()
self.depthwise = nn.Conv2d(c_in, c_in, kernel_size, groups=c_in, padding=kernel_size//2)
self.bn1 = nn.BatchNorm2d(c_in)
self.pointwise = nn.Conv2d(c_in, c_out, kernel_size=1)
self.bn2 = nn.BatchNorm2d(c_out)
def forward(self, x): class BirdSpecies(Enum):
x = self.depthwise(x) """Classes of the deployed 7-species model (build_models/bird_cnn)."""
x = self.bn1(x) Common_Kingfisher = 0
x = F.relu(x) Common_Myna = 1
House_Crow = 2
Indian_Peacock = 3
Indian_Pitta = 4
Ruddy_Shelduck = 5
Sarus_Crane = 6
x = self.pointwise(x)
x = self.bn2(x)
x = F.relu(x)
return x
class SkipBlock(nn.Module): class SkipBlock(nn.Module):
def __init__(self, c_in, c_out, kernel_size=3): def __init__(self, c_in, c_out, kernel_size=3):
@@ -41,7 +39,7 @@ class SkipBlock(nn.Module):
) )
def forward(self, x): def forward(self, x):
return(F.relu(self.conv_skip(x) + self.conv(x), inplace=True)) return F.relu(self.conv_skip(x) + self.conv(x), inplace=True)
class Bird_CNN(nn.Module): class Bird_CNN(nn.Module):
+36
View File
@@ -0,0 +1,36 @@
import torch.nn as nn
class ObjectDetectionCNN(nn.Module):
"""Small crop classifier used by the R-CNN style experiments
(selective search proposals -> classify each crop)."""
def __init__(self, c_in, c_hidden, c_out, layers):
super().__init__()
self.model = nn.ModuleList()
self.model.append(nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.BatchNorm2d(c_hidden),
nn.ReLU(inplace=True)
))
for _ in range(layers - 1):
self.model.append(nn.Sequential(
nn.Conv2d(c_hidden, c_hidden, kernel_size=3, padding=1),
nn.BatchNorm2d(c_hidden),
nn.ReLU(inplace=True)
))
self.model.append(nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(c_hidden, c_out),
nn.Dropout(0.3)
))
def forward(self, x):
for layer in self.model:
x = layer(x)
return x
+128
View File
@@ -0,0 +1,128 @@
"""YOLO-style single-shot face detector. Shared by training and the
production server; the layer names must not change or saved weights stop
loading."""
import torch
import torch.nn as nn
import torch.nn.functional as F
from src.common.boxes import cell_to_image_coords, nms, xy_center_to_edges
class SkipBlock(nn.Module):
def __init__(self, c_in, c_out, kernel_size=3):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(c_in, c_out, kernel_size, padding=kernel_size // 2),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out),
nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size // 2),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out),
nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size // 2),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out),
nn.LeakyReLU(inplace=True)
)
self.conv_skip = nn.Sequential(
nn.Conv2d(c_in, c_out, 1),
nn.GroupNorm(num_groups=c_out // 8, num_channels=c_out),
nn.LeakyReLU(inplace=True)
)
def forward(self, x):
# F.dropout defaults to training=True, so this stays active in eval
# mode too. Kept as-is because the saved models were trained this way.
return F.dropout(F.leaky_relu(self.conv_skip(x) + self.conv(x), inplace=True), p=0.3)
class Yolo_model(nn.Module):
"""Output: [batch, grid, grid, boxes*5 + labels] with per cell
(x, y, w, h, confidence, classes...).
size_activation decides how w/h are produced:
"exp" - exp(x), used by the deployed build_models/face_detection_yolo
"sigmoid" - sigmoid(x), what the training code switched to later
"""
def __init__(self, c_in, boxes, grid, labels, c_hidden=16, size_activation="sigmoid"):
super().__init__()
if size_activation not in ("exp", "sigmoid"):
raise ValueError(f"size_activation must be 'exp' or 'sigmoid', got {size_activation!r}")
self.size_activation = size_activation
self.model = nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden // 8, num_channels=c_hidden),
nn.LeakyReLU(inplace=True),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden * 2),
SkipBlock(c_in=c_hidden * 2, c_out=c_hidden * 2),
SkipBlock(c_in=c_hidden * 2, c_out=c_hidden * 2),
SkipBlock(c_in=c_hidden * 2, c_out=c_hidden * 2),
SkipBlock(c_in=c_hidden * 2, c_out=c_hidden * 4),
SkipBlock(c_in=c_hidden * 4, c_out=c_hidden * 4),
SkipBlock(c_in=c_hidden * 4, c_out=c_hidden * 4),
SkipBlock(c_in=c_hidden * 4, c_out=c_hidden * 4),
nn.Conv2d(c_hidden * 4, c_hidden * 8, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden // 2, num_channels=c_hidden * 8),
nn.LeakyReLU(inplace=True),
nn.Dropout(0.3),
nn.AdaptiveAvgPool2d((grid, grid)),
nn.Conv2d(c_hidden * 8, boxes * 5 + labels, kernel_size=1)
)
def forward(self, x):
x = self.model(x).permute(0, 2, 3, 1)
if self.size_activation == "sigmoid":
return F.sigmoid(x)
center = F.sigmoid(x[..., :2])
size = torch.exp(x[..., 2:4])
conf_class = F.sigmoid(x[..., 4:])
return torch.cat([center, size, conf_class], dim=3)
def convert_prediction(label, image, threshold=0.9):
"""Turns one model output (or target) [grid, grid, 5 + classes] into boxes
in image pixels. Returns (boxes after NMS, cells with an object, cells
without one); the cell lists are only used for visualisation."""
image = image.clone().detach()
label = label.clone().detach()
image_size = image.shape[1]
grid_number = label.shape[0]
grid_size = image_size / grid_number
boxes_to_draw = []
grids_to_draw_obj = []
grids_to_draw_noobj = []
for x in range(label.shape[0]):
for y in range(label.shape[1]):
cell = [x * grid_size, y * grid_size, (x + 1) * grid_size, (y + 1) * grid_size]
if label[x, y, 4].item() < threshold:
grids_to_draw_noobj.append(cell)
continue
grids_to_draw_obj.append(cell)
boxx = label[x, y, 0] * (image_size / grid_number)
boxy = label[x, y, 1] * (image_size / grid_number)
boxw = label[x, y, 2] * image_size
boxh = label[x, y, 3] * image_size
boxx, boxy = cell_to_image_coords(x=boxx, y=boxy, img_w=image_size, img_h=image_size,
S=grid_number, cell_i=x, cell_j=y)
boxes_to_draw.append([label[x, y, 4]] + xy_center_to_edges(boxx, boxy, boxw, boxh))
boxes_to_draw = nms(boxes_to_draw)
return boxes_to_draw, grids_to_draw_obj, grids_to_draw_noobj
View File
+41
View File
@@ -0,0 +1,41 @@
"""FastAPI app. Start with: uvicorn src.production.app:app (from api/),
or: python -m src.main server.run"""
from contextlib import asynccontextmanager
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from src.production import predict, reviews, settings
from src.production.inference import load_models
logger = logging.getLogger("server")
@asynccontextmanager
async def lifespan(app: FastAPI):
logging.basicConfig(level=logging.INFO)
if not settings.API_KEY:
logger.warning("API_KEY is not set: GET/DELETE /review are disabled")
load_models()
reviews.init_db()
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.ORIGINS,
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
app.include_router(predict.router)
app.include_router(reviews.router)
@app.get("/health")
def health():
return {"status": "ok"}
+13
View File
@@ -0,0 +1,13 @@
from dataclasses import dataclass
@dataclass
class Config:
"""How to start the API server locally. Server limits (rate limits,
parallel inferences, ...) are in src/production/settings.py and are set
with environment variables."""
host: str = "127.0.0.1"
port: int = 8000
reload: bool = True
ws_max_size: int = 1024 * 1024 # largest websocket message in bytes
+85
View File
@@ -0,0 +1,85 @@
"""Loading the models and running one prediction. These functions are
blocking and run on the InferenceGate worker threads."""
import io
from PIL import Image
import torch
import torch.nn.functional as F
from torchvision import transforms
from src.common.boxes import scale_box
from src.common.checkpoints import load_checkpoint
from src.models.bird_cnn import BirdSpecies, Bird_CNN
from src.models.yolo import Yolo_model, convert_prediction
from src.production import settings
Image.MAX_IMAGE_PIXELS = settings.MAX_IMAGE_PIXELS
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
models = {}
transform_bird = transforms.Compose([
transforms.Resize(settings.BIRD_IMAGE_SIZE),
transforms.CenterCrop(settings.BIRD_IMAGE_SIZE),
transforms.ToTensor()
])
transform_face = transforms.Compose([
transforms.Resize((settings.FACE_IMAGE_SIZE, settings.FACE_IMAGE_SIZE)),
transforms.ToTensor()
])
def load_models():
bird = Bird_CNN(c_in=3, c_hidden=settings.BIRD_C_HIDDEN, c_out=len(BirdSpecies))
models["bird"] = load_checkpoint(bird, settings.BUILD_PATH / settings.BIRD_MODEL_FILE, device)
face = Yolo_model(c_in=3, boxes=1, grid=settings.FACE_GRID, labels=1, c_hidden=settings.FACE_C_HIDDEN,
size_activation=settings.FACE_SIZE_ACTIVATION)
models["face"] = load_checkpoint(face, settings.BUILD_PATH / settings.FACE_MODEL_FILE, device)
class InvalidImage(Exception):
pass
def open_image(data: bytes) -> tuple[Image.Image, tuple[int, int]]:
"""Open lazily, reject oversized images before decoding, decode JPEGs at
reduced scale. Returns the image and its ORIGINAL (width, height)."""
try:
image = Image.open(io.BytesIO(data))
original_size = image.size
if original_size[0] * original_size[1] > settings.MAX_IMAGE_PIXELS:
raise InvalidImage("Image too large")
image.draft("RGB", settings.DECODE_DRAFT_SIZE)
return image.convert("RGB"), original_size
except InvalidImage:
raise
except Exception as e: # PIL raises many different types for bad data
raise InvalidImage("Not a valid image") from e
def classify_bird(data: bytes) -> dict:
image, _ = open_image(data)
tensor = transform_bird(image).unsqueeze(0).to(device)
with torch.inference_mode():
probs = F.softmax(models["bird"](tensor), dim=1)
confidence, cls = torch.max(probs, dim=1)
return {
"class": BirdSpecies(cls.item()).name,
"confidence": confidence.item()
}
def detect_faces(data: bytes) -> list:
image, (W, H) = open_image(data)
scale_w = W / settings.FACE_IMAGE_SIZE
scale_h = H / settings.FACE_IMAGE_SIZE
tensor = transform_face(image).unsqueeze(0).to(device)
with torch.inference_mode():
pred = models["face"](tensor)
bboxes, _, _ = convert_prediction(pred.squeeze(0), tensor.squeeze(0), threshold=settings.FACE_THRESHOLD)
return [scale_box(b, scale_w, scale_h) for b in bboxes]
+95
View File
@@ -0,0 +1,95 @@
"""Overload protection: per-IP rate limits and the inference thread pool."""
import asyncio
from collections import defaultdict, deque
from concurrent.futures import ThreadPoolExecutor
import time
from fastapi import HTTPException, Request, WebSocket
import torch
from src.production import settings
def client_ip(conn: Request | WebSocket) -> str:
# Caddy is the only proxy in front of this container and appends the real
# client address as the last X-Forwarded-For entry.
forwarded = conn.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[-1].strip()
return conn.client.host if conn.client else "unknown"
class RateLimiter:
"""Sliding-window limiter per key (in memory, single process)."""
def __init__(self, limit: int, window: float):
self.limit = limit
self.window = window
self.hits = defaultdict(deque)
def allow(self, key: str) -> bool:
now = time.monotonic()
if len(self.hits) > 10_000:
self.hits = defaultdict(deque, {
k: v for k, v in self.hits.items() if v and now - v[-1] < self.window
})
q = self.hits[key]
while q and now - q[0] >= self.window:
q.popleft()
if len(q) >= self.limit:
return False
q.append(now)
return True
predict_limiter = RateLimiter(settings.PREDICT_RATE_LIMIT, settings.PREDICT_RATE_WINDOW)
review_limiter = RateLimiter(settings.REVIEW_RATE_LIMIT, settings.REVIEW_RATE_WINDOW)
def rate_limit(limiter: RateLimiter):
"""FastAPI dependency that answers 429 when the client is over the limit."""
def dependency(request: Request):
if not limiter.allow(client_ip(request)):
raise HTTPException(status_code=429, detail="Too many requests", headers={"Retry-After": "60"})
return dependency
class Busy(Exception):
pass
def init_inference_thread():
# Must run inside each worker thread: with OpenMP the thread count is a
# per-thread setting, so setting it once in the main thread is not enough.
torch.set_num_threads(settings.TORCH_THREADS)
class InferenceGate:
"""Runs predictions on `parallel` worker threads. At most
`parallel + queued` jobs (running + waiting) are admitted; everything
else is rejected at once instead of queueing up and eating memory.
The models are in eval mode under torch.inference_mode(), so several
threads can safely run forward passes on the same model object."""
def __init__(self, parallel: int, queued: int):
self.max_pending = parallel + queued
self.pending = 0
self.executor = ThreadPoolExecutor(
max_workers=parallel,
thread_name_prefix="inference",
initializer=init_inference_thread,
)
async def run(self, fn, *args):
if self.pending >= self.max_pending:
raise Busy
self.pending += 1
try:
return await asyncio.get_running_loop().run_in_executor(self.executor, fn, *args)
finally:
self.pending -= 1
gate = InferenceGate(settings.PARALLEL_INFERENCES, settings.QUEUED_INFERENCES)
+97
View File
@@ -0,0 +1,97 @@
"""Routes: bird classification (POST /predict) and face detection
(websocket /predict_face)."""
from collections import defaultdict
import logging
import time
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
from src.production import settings
from src.production.inference import InvalidImage, classify_bird, detect_faces
from src.production.limits import Busy, client_ip, gate, predict_limiter, rate_limit
logger = logging.getLogger("server")
router = APIRouter()
async def read_limited(file: UploadFile, limit: int) -> bytes:
data = bytearray()
while chunk := await file.read(64 * 1024):
data += chunk
if len(data) > limit:
raise HTTPException(status_code=413, detail="File too large")
return bytes(data)
@router.post("/predict", dependencies=[Depends(rate_limit(predict_limiter))])
async def predict(file: UploadFile = File(...)):
data = await read_limited(file, settings.MAX_UPLOAD_BYTES)
try:
return await gate.run(classify_bird, data)
except Busy:
raise HTTPException(status_code=503, detail="Server busy, try again shortly", headers={"Retry-After": "5"})
except InvalidImage as e:
raise HTTPException(status_code=400, detail=str(e))
active_websockets = defaultdict(int)
@router.websocket("/predict_face")
async def predict_face(websocket: WebSocket):
ip = client_ip(websocket)
# CORS does not apply to websockets, so check the origin ourselves.
if websocket.headers.get("origin") not in settings.ORIGINS:
await websocket.close(code=1008)
return
if (sum(active_websockets.values()) >= settings.MAX_WEBSOCKETS
or active_websockets[ip] >= settings.MAX_WEBSOCKETS_PER_IP):
await websocket.close(code=1013) # try again later
return
active_websockets[ip] += 1
try:
await websocket.accept()
last = 0.0
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
data = message.get("bytes")
if data is None or len(data) > settings.MAX_FRAME_BYTES:
await websocket.close(code=1009 if data else 1003)
break
# Drop frames that arrive too fast or while the server is busy
# instead of queueing them.
now = time.monotonic()
if now - last < settings.FACE_MIN_INTERVAL:
continue
last = now
try:
boxes = await gate.run(detect_faces, data)
except Busy:
continue
except InvalidImage:
await websocket.close(code=1003)
break
await websocket.send_json({"bboxes": boxes})
except WebSocketDisconnect:
pass
except Exception:
logger.exception("predict_face failed")
try:
await websocket.close(code=1011)
except Exception:
pass
finally:
active_websockets[ip] -= 1
if active_websockets[ip] <= 0:
del active_websockets[ip]
+84
View File
@@ -0,0 +1,84 @@
"""Routes and storage for reviews (SQLite)."""
from contextlib import closing
from datetime import datetime, timezone
import secrets
import sqlite3
from fastapi import APIRouter, Depends, Header, HTTPException, Query
from pydantic import BaseModel, Field
from src.production import settings
from src.production.limits import rate_limit, review_limiter
router = APIRouter()
def init_db():
settings.DATABASE_DIR.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(settings.DATABASE)) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
website TEXT NOT NULL,
rating INTEGER NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.commit()
def connect_db() -> sqlite3.Connection:
conn = sqlite3.connect(settings.DATABASE, timeout=5)
conn.row_factory = sqlite3.Row
return conn
def get_api_key(authorization: str = Header(None)):
if not settings.API_KEY or not authorization or not secrets.compare_digest(
authorization.encode(), f"Bearer {settings.API_KEY}".encode()
):
raise HTTPException(status_code=401, detail="Unauthorized")
return authorization
class Review(BaseModel):
website: str = Field(min_length=1, max_length=100)
rating: int = Field(ge=1, le=5)
text: str = Field(max_length=1000)
# The client also sends a "date"; it is ignored, the server sets the time.
@router.get("/review")
def get_reviews(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
auth=Depends(get_api_key),
):
with closing(connect_db()) as conn:
rows = conn.execute(
"SELECT * FROM reviews ORDER BY id DESC LIMIT ? OFFSET ?", (limit, offset)
).fetchall()
return {"data": [dict(row) for row in rows]}
# Public on purpose: no credentials needed, only rate limited per IP.
@router.post("/review", dependencies=[Depends(rate_limit(review_limiter))])
def post_review(review: Review):
created_at = datetime.now(timezone.utc).isoformat()
with closing(connect_db()) as conn:
conn.execute(
"INSERT INTO reviews (website, rating, text, created_at) VALUES (?, ?, ?, ?)",
(review.website, review.rating, review.text.strip(), created_at),
)
conn.commit()
return {"status": "ok"}
@router.delete("/review")
def delete_review(auth=Depends(get_api_key)):
with closing(connect_db()) as conn:
conn.execute("DELETE FROM reviews")
conn.commit()
return {"status": "ok"}
-429
View File
@@ -1,429 +0,0 @@
import asyncio
from collections import defaultdict, deque
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager, closing
from datetime import datetime, timezone
from enum import Enum
import io
import logging
import os
from pathlib import Path
import secrets
import sqlite3
import time
from dotenv import load_dotenv
from fastapi import Depends, FastAPI, File, Header, HTTPException, Query, Request, UploadFile, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from PIL import Image
from pydantic import BaseModel, Field
import torch
import torch.nn.functional as F
from torchvision import transforms
from yolo_model_production import convert_prediction, Yolo_model
from bird_cnn_production import Bird_CNN
logger = logging.getLogger("server")
BASE_DIR = Path(__file__).resolve().parent
BUILD_PATH = Path(os.getenv("BUILD_PATH", BASE_DIR / "build_models"))
DATABASE_DIR = Path(os.getenv("DATABASE_DIR", BASE_DIR / "database"))
DATABASE = DATABASE_DIR / "reviews.db"
load_dotenv(DATABASE_DIR / ".env")
API_KEY = os.getenv("API_KEY") # if missing, protected endpoints reject every request
IMAGE_SIZE_CNN = 64
IMAGE_SIZE_YOLO = 64
# ---- Resource limits (the server is weak, keep everything small and bounded) ----
# Number of predictions (bird or face) that may run at the same time.
PARALLEL_INFERENCES = max(1, int(os.getenv("PARALLEL_INFERENCES", "1")))
# Extra requests allowed to wait for a free slot; anything beyond is rejected.
QUEUED_INFERENCES = max(0, int(os.getenv("QUEUED_INFERENCES", "1")))
# PyTorch threads used by EACH running prediction. Total CPU use is roughly
# PARALLEL_INFERENCES * TORCH_THREADS, so keep the product <= your CPU cores.
TORCH_THREADS = max(1, int(os.getenv("TORCH_THREADS", "1")))
MAX_UPLOAD_BYTES = int(max(1, float(os.getenv("MAX_UPLOAD_MEGABYTES", "15"))) * 1024 * 1024)
MAX_FRAME_BYTES = 1 * 1024 * 1024
MAX_IMAGE_PIXELS = 20_000_000
MAX_WEBSOCKETS = 3
MAX_WEBSOCKETS_PER_IP = 1
FACE_MAX_FPS = float(os.getenv("FACE_MAX_FPS", "5.5")) # per websocket connection
FACE_MIN_INTERVAL = 1 / FACE_MAX_FPS
DECODE_DRAFT_SIZE = (256, 256) # JPEG decodes at reduced scale, still larger than the 64px model input
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
origins = [
"https://marvinkrausser.com",
]
class BirdSpecies(Enum):
Common_Kingfisher = 0
Common_Myna = 1
House_Crow = 2
Indian_Peacock = 3
Indian_Pitta = 4
Ruddy_Shelduck = 5
Sarus_Crane = 6
transform_bird = transforms.Compose([
transforms.Resize(IMAGE_SIZE_CNN),
transforms.CenterCrop(IMAGE_SIZE_CNN),
transforms.ToTensor()
])
transform_face = transforms.Compose([
transforms.Resize((IMAGE_SIZE_YOLO, IMAGE_SIZE_YOLO)),
transforms.ToTensor()
])
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
models = {}
def load_models():
model_bird = Bird_CNN(c_in=3, c_hidden=16, c_out=7)
model_bird.load_state_dict(torch.load(BUILD_PATH / "bird_cnn", map_location=device, weights_only=True))
model_bird.to(device).eval()
model_face = Yolo_model(c_in=3, boxes=1, grid=6, labels=1, c_hidden=16)
model_face.load_state_dict(torch.load(BUILD_PATH / "face_detection_yolo", map_location=device, weights_only=True))
model_face.to(device).eval()
models["bird"] = model_bird
models["face"] = model_face
def init_db():
DATABASE_DIR.mkdir(parents=True, exist_ok=True)
with closing(sqlite3.connect(DATABASE)) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
website TEXT NOT NULL,
rating INTEGER NOT NULL,
text TEXT NOT NULL,
created_at TEXT NOT NULL
)
""")
conn.commit()
@asynccontextmanager
async def lifespan(app: FastAPI):
logging.basicConfig(level=logging.INFO)
if not API_KEY:
logger.warning("API_KEY is not set: GET/DELETE /review are disabled")
load_models()
init_db()
yield
app = FastAPI(lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_methods=["GET", "POST", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
)
# --------------------------------------------------------------------------
# Overload protection
# --------------------------------------------------------------------------
def client_ip(conn: Request | WebSocket) -> str:
# Caddy is the only proxy in front of this container and appends the real
# client address as the last X-Forwarded-For entry.
forwarded = conn.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[-1].strip()
return conn.client.host if conn.client else "unknown"
class RateLimiter:
"""Sliding-window limiter per key (in memory, single process)."""
def __init__(self, limit: int, window: float):
self.limit = limit
self.window = window
self.hits = defaultdict(deque)
def allow(self, key: str) -> bool:
now = time.monotonic()
if len(self.hits) > 10_000:
self.hits = defaultdict(deque, {
k: v for k, v in self.hits.items() if v and now - v[-1] < self.window
})
q = self.hits[key]
while q and now - q[0] >= self.window:
q.popleft()
if len(q) >= self.limit:
return False
q.append(now)
return True
predict_limiter = RateLimiter(
limit=max(1, int(os.getenv("PREDICT_RATE_LIMIT", "10"))),
window=max(1.0, float(os.getenv("PREDICT_RATE_WINDOW", "60"))),
)
review_limiter = RateLimiter(
limit=max(1, int(os.getenv("REVIEW_RATE_LIMIT", "5"))),
window=max(1.0, float(os.getenv("REVIEW_RATE_WINDOW", "60"))),
)
def rate_limit(limiter: RateLimiter):
def dependency(request: Request):
if not limiter.allow(client_ip(request)):
raise HTTPException(status_code=429, detail="Too many requests", headers={"Retry-After": "60"})
return dependency
class Busy(Exception):
pass
def init_inference_thread():
# Must run inside each worker thread: with OpenMP the thread count is a
# per-thread setting, so setting it once in the main thread is not enough.
torch.set_num_threads(TORCH_THREADS)
class InferenceGate:
"""Runs predictions on `parallel` worker threads. At most
`parallel + queued` jobs (running + waiting) are admitted; everything
else is rejected at once instead of queueing up and eating memory.
The models are in eval mode under torch.inference_mode(), so several
threads can safely run forward passes on the same model object."""
def __init__(self, parallel: int, queued: int):
self.max_pending = parallel + queued
self.pending = 0
self.executor = ThreadPoolExecutor(
max_workers=parallel,
thread_name_prefix="inference",
initializer=init_inference_thread,
)
async def run(self, fn, *args):
if self.pending >= self.max_pending:
raise Busy
self.pending += 1
try:
return await asyncio.get_running_loop().run_in_executor(self.executor, fn, *args)
finally:
self.pending -= 1
gate = InferenceGate(PARALLEL_INFERENCES, QUEUED_INFERENCES)
class InvalidImage(Exception):
pass
def open_image(data: bytes) -> tuple[Image.Image, tuple[int, int]]:
"""Open lazily, reject oversized images before decoding, decode JPEGs at
reduced scale. Returns the image and its ORIGINAL (width, height)."""
try:
image = Image.open(io.BytesIO(data))
original_size = image.size
if original_size[0] * original_size[1] > MAX_IMAGE_PIXELS:
raise InvalidImage("Image too large")
image.draft("RGB", DECODE_DRAFT_SIZE)
return image.convert("RGB"), original_size
except InvalidImage:
raise
except Exception as e: # PIL raises many different types for bad data
raise InvalidImage("Not a valid image") from e
# --------------------------------------------------------------------------
# Bird classification
# --------------------------------------------------------------------------
def classify_bird(data: bytes) -> dict:
image, _ = open_image(data)
tensor = transform_bird(image).unsqueeze(0).to(device)
with torch.inference_mode():
probs = F.softmax(models["bird"](tensor), dim=1)
confidence, cls = torch.max(probs, dim=1)
return {
"class": BirdSpecies(cls.item()).name,
"confidence": confidence.item()
}
async def read_limited(file: UploadFile, limit: int) -> bytes:
data = bytearray()
while chunk := await file.read(64 * 1024):
data += chunk
if len(data) > limit:
raise HTTPException(status_code=413, detail="File too large")
return bytes(data)
@app.post("/predict", dependencies=[Depends(rate_limit(predict_limiter))])
async def predict(file: UploadFile = File(...)):
data = await read_limited(file, MAX_UPLOAD_BYTES)
try:
return await gate.run(classify_bird, data)
except Busy:
raise HTTPException(status_code=503, detail="Server busy, try again shortly", headers={"Retry-After": "5"})
except InvalidImage as e:
raise HTTPException(status_code=400, detail=str(e))
# --------------------------------------------------------------------------
# Face detection (websocket)
# --------------------------------------------------------------------------
def detect_faces(data: bytes) -> list:
image, (W, H) = open_image(data)
scale_w = W / IMAGE_SIZE_YOLO
scale_h = H / IMAGE_SIZE_YOLO
tensor = transform_face(image).unsqueeze(0).to(device)
with torch.inference_mode():
pred = models["face"](tensor)
bboxes, _, _ = convert_prediction(pred.squeeze(0), tensor.squeeze(0), threshold=0.9)
return [
[int(b[0] * scale_w), int(b[1] * scale_h), int(b[2] * scale_w), int(b[3] * scale_h)]
for b in bboxes
]
active_websockets = defaultdict(int)
@app.websocket("/predict_face")
async def predict_face(websocket: WebSocket):
ip = client_ip(websocket)
# CORS does not apply to websockets, so check the origin ourselves.
if websocket.headers.get("origin") not in origins:
await websocket.close(code=1008)
return
if (sum(active_websockets.values()) >= MAX_WEBSOCKETS
or active_websockets[ip] >= MAX_WEBSOCKETS_PER_IP):
await websocket.close(code=1013) # try again later
return
active_websockets[ip] += 1
try:
await websocket.accept()
last = 0.0
while True:
message = await websocket.receive()
if message["type"] == "websocket.disconnect":
break
data = message.get("bytes")
if data is None or len(data) > MAX_FRAME_BYTES:
await websocket.close(code=1009 if data else 1003)
break
# Drop frames that arrive too fast or while the server is busy
# instead of queueing them.
now = time.monotonic()
if now - last < FACE_MIN_INTERVAL:
continue
last = now
try:
boxes = await gate.run(detect_faces, data)
except Busy:
continue
except InvalidImage:
await websocket.close(code=1003)
break
await websocket.send_json({"bboxes": boxes})
except WebSocketDisconnect:
pass
except Exception:
logger.exception("predict_face failed")
try:
await websocket.close(code=1011)
except Exception:
pass
finally:
active_websockets[ip] -= 1
if active_websockets[ip] <= 0:
del active_websockets[ip]
# --------------------------------------------------------------------------
# Reviews
# --------------------------------------------------------------------------
def get_api_key(authorization: str = Header(None)):
if not API_KEY or not authorization or not secrets.compare_digest(
authorization.encode(), f"Bearer {API_KEY}".encode()
):
raise HTTPException(status_code=401, detail="Unauthorized")
return authorization
class Review(BaseModel):
website: str = Field(min_length=1, max_length=100)
rating: int = Field(ge=1, le=5)
text: str = Field(max_length=1000)
# The client also sends a "date"; it is ignored, the server sets the time.
def connect_db() -> sqlite3.Connection:
conn = sqlite3.connect(DATABASE, timeout=5)
conn.row_factory = sqlite3.Row
return conn
@app.get("/review")
def get_reviews(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
auth=Depends(get_api_key),
):
with closing(connect_db()) as conn:
rows = conn.execute(
"SELECT * FROM reviews ORDER BY id DESC LIMIT ? OFFSET ?", (limit, offset)
).fetchall()
return {"data": [dict(row) for row in rows]}
# Public on purpose: no credentials needed, only rate limited per IP.
@app.post("/review", dependencies=[Depends(rate_limit(review_limiter))])
def post_review(review: Review):
created_at = datetime.now(timezone.utc).isoformat()
with closing(connect_db()) as conn:
conn.execute(
"INSERT INTO reviews (website, rating, text, created_at) VALUES (?, ?, ?, ?)",
(review.website, review.rating, review.text.strip(), created_at),
)
conn.commit()
return {"status": "ok"}
@app.delete("/review")
def delete_review(auth=Depends(get_api_key)):
with closing(connect_db()) as conn:
conn.execute("DELETE FROM reviews")
conn.commit()
return {"status": "ok"}
@app.get("/health")
def health():
return {"status": "ok"}
+57
View File
@@ -0,0 +1,57 @@
"""All server settings in one place. Values marked (env) can be set with an
environment variable of the same name, e.g. in compose.yaml."""
import os
from pathlib import Path
from dotenv import load_dotenv
API_ROOT = Path(__file__).resolve().parents[2] # api/ locally, /app in Docker
BUILD_PATH = Path(os.getenv("BUILD_PATH", API_ROOT / "build_models")) # (env)
DATABASE_DIR = Path(os.getenv("DATABASE_DIR", API_ROOT / "database")) # (env)
DATABASE = DATABASE_DIR / "reviews.db"
load_dotenv(DATABASE_DIR / ".env")
API_KEY = os.getenv("API_KEY") # (env) if missing, protected endpoints reject every request
ORIGINS = [
"https://marvinkrausser.com",
]
# ---- Models ----
BIRD_MODEL_FILE = "bird_cnn"
BIRD_IMAGE_SIZE = 64
BIRD_C_HIDDEN = 16
FACE_MODEL_FILE = "face_detection_yolo"
FACE_IMAGE_SIZE = 64
FACE_GRID = 6
FACE_C_HIDDEN = 16
FACE_SIZE_ACTIVATION = "exp" # must match how the deployed weights were trained
FACE_THRESHOLD = 0.9
# ---- Resource limits (the server is weak, keep everything small and bounded) ----
# Number of predictions (bird or face) that may run at the same time. (env)
PARALLEL_INFERENCES = max(1, int(os.getenv("PARALLEL_INFERENCES", "1")))
# Extra requests allowed to wait for a free slot; anything beyond is rejected. (env)
QUEUED_INFERENCES = max(0, int(os.getenv("QUEUED_INFERENCES", "1")))
# PyTorch threads used by EACH running prediction. Total CPU use is roughly
# PARALLEL_INFERENCES * TORCH_THREADS, so keep the product <= your CPU cores. (env)
TORCH_THREADS = max(1, int(os.getenv("TORCH_THREADS", "1")))
MAX_UPLOAD_BYTES = int(max(1, float(os.getenv("MAX_UPLOAD_MEGABYTES", "15"))) * 1024 * 1024) # (env)
MAX_FRAME_BYTES = 1 * 1024 * 1024
MAX_IMAGE_PIXELS = 20_000_000
DECODE_DRAFT_SIZE = (256, 256) # JPEG decodes at reduced scale, still larger than the 64px model input
MAX_WEBSOCKETS = 3
MAX_WEBSOCKETS_PER_IP = 1
FACE_MAX_FPS = float(os.getenv("FACE_MAX_FPS", "5.5")) # (env) per websocket connection
FACE_MIN_INTERVAL = 1 / FACE_MAX_FPS
# ---- Rate limits: at most LIMIT requests per WINDOW seconds per IP ----
PREDICT_RATE_LIMIT = max(1, int(os.getenv("PREDICT_RATE_LIMIT", "10"))) # (env)
PREDICT_RATE_WINDOW = max(1.0, float(os.getenv("PREDICT_RATE_WINDOW", "60"))) # (env)
REVIEW_RATE_LIMIT = max(1, int(os.getenv("REVIEW_RATE_LIMIT", "5"))) # (env)
REVIEW_RATE_WINDOW = max(1.0, float(os.getenv("REVIEW_RATE_WINDOW", "60"))) # (env)
-1
View File
@@ -1 +0,0 @@
python -m uvicorn server:app --ws-max-size 1048576 --reload
-1
View File
@@ -1 +0,0 @@
uvicorn server:app --host 0.0.0.0 --port 8000 --ws-max-size 1048576
+16
View File
@@ -0,0 +1,16 @@
"""API server tasks. Run with: python -m src.main server.<task>"""
from src.production.config import Config
def run(cfg: Config):
"""Start the API server with uvicorn."""
import uvicorn
uvicorn.run("src.production.app:app", host=cfg.host, port=cfg.port,
reload=cfg.reload, ws_max_size=cfg.ws_max_size)
TASKS = {
"run": run,
}
-163
View File
@@ -1,163 +0,0 @@
import torch
import torch.nn as nn
import torch.nn.functional as F
def convert_prediction(label, image, threshold=0.9):
image = image.clone().detach()
label = label.clone().detach()
image_size = image.shape[1]
grid_number = label.shape[0]
grid_size = image_size / grid_number
boxes_to_draw = []
grids_to_draw_obj = []
grids_to_draw_noobj = []
for x in range(label.shape[0]):
for y in range(label.shape[1]):
if label[x, y, 4].item() < threshold:
grids_to_draw_noobj.append([x*grid_size, y*grid_size, (x+1)*grid_size, (y+1)*grid_size]) #xmin, ymin, xmax, ymax
continue
grids_to_draw_obj.append([x*grid_size, y*grid_size, (x+1)*grid_size, (y+1)*grid_size]) #xmin, ymin, xmax, ymax
boxx = label[x, y, 0] * (image_size / grid_number)
boxy = label[x, y, 1] * (image_size / grid_number)
boxw = label[x, y, 2] * image_size
boxh = label[x, y, 3] * image_size
boxx, boxy = turn_image_centered(x=boxx, y=boxy, img_w=image_size, img_h=image_size, S=grid_number, cell_i=x, cell_j=y)
boxes_to_draw.append([label[x, y, 4]] + xy_center_to_edges(boxx, boxy, boxw, boxh)) #xmin, ymin, xmax, ymax
boxes_to_draw = nms(boxes_to_draw)
return boxes_to_draw, grids_to_draw_obj, grids_to_draw_noobj
def turn_image_centered(x, y, img_w, img_h, S, cell_i, cell_j):
cell_w = img_w / S
cell_h = img_h / S
cell_border_w = cell_w * cell_i
cell_border_h = cell_h * cell_j
return x + cell_border_w, y + cell_border_h
def xy_center_to_edges(xcenter, ycenter, width, height):
width = max(width, 1)
height = max(height, 1)
x = xcenter - (width / 2)
y = ycenter - (height / 2)
return [x, y, x + width, y + height]
def iou(boxA, boxB):
xA = max(boxA[0], boxB[0])
yA = max(boxA[1], boxB[1])
xB = min(boxA[2], boxB[2])
yB = min(boxA[3], boxB[3])
inter_area = max(0, xB - xA) * max(0, yB - yA)
boxA_area = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1])
boxB_area = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1])
union = boxA_area + boxB_area - inter_area
return inter_area / union if union > 0 else 0
def nms(bboxes, iou_threshold=0.1):
bboxes = sorted(bboxes, key=lambda x: x[0], reverse=True)
keep = []
while bboxes:
best = bboxes.pop(0)
keep.append(best[1:5])
bboxes = [
box for box in bboxes
if iou(best[1:5], box[1:5]) < iou_threshold
]
return keep
class Yolo_Conv_Block(nn.Module):
def __init__(self, c_in, c_hidden, c_out, kernel_size):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(in_channels=c_in, out_channels=c_hidden, kernel_size=kernel_size, padding=kernel_size//2),
nn.BatchNorm2d(c_hidden),
nn.LeakyReLU(),
nn.Conv2d(in_channels=c_hidden, out_channels=c_out, kernel_size=1)
)
def forward(self, x):
return self.model(x)
class SkipBlock(nn.Module):
def __init__(self, c_in, c_out, kernel_size=3):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(c_in, c_out, kernel_size, padding=kernel_size//2),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size//2),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size//2),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True)
)
self.conv_skip = nn.Sequential(
nn.Conv2d(c_in, c_out, 1),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True)
)
def forward(self, x):
return(F.dropout(F.leaky_relu(self.conv_skip(x) + self.conv(x), inplace=True), p=0.3))
class Yolo_model(nn.Module):
def __init__(self, c_in, boxes, grid, labels, c_hidden=16):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden//8, num_channels=c_hidden),
nn.LeakyReLU(inplace=True),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*4),
SkipBlock(c_in=c_hidden*4, c_out=c_hidden*4),
SkipBlock(c_in=c_hidden*4, c_out=c_hidden*4),
SkipBlock(c_in=c_hidden*4, c_out=c_hidden*4),
nn.Conv2d(c_hidden*4, c_hidden*8, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden//2, num_channels=c_hidden*8),
nn.LeakyReLU(inplace=True),
nn.Dropout(0.3),
nn.AdaptiveAvgPool2d((grid, grid)),
nn.Conv2d(c_hidden*8, boxes*5 + labels, kernel_size=1)
)
def forward(self, x):
x = self.model(x).permute(0, 2, 3, 1)
center = F.sigmoid(x[..., :2])
size = torch.exp(x[..., 2:4])
conf_class = F.sigmoid(x[..., 4:])
return torch.cat([center, size, conf_class], dim=3)
+49
View File
@@ -0,0 +1,49 @@
from dataclasses import dataclass
@dataclass
class Config:
"""Settings for the R-CNN style experiments (classify crops, then run
selective search on a full image). Paths are relative to api/.
Every field can be overridden on the command line, e.g. --epochs 20."""
# Datasets
faces_raw_dir: str = "data/faces/train" # COCO: source for create-dataset
faces_processed_dir: str = "data/faces_processed" # ImageFolder: face/ and background/
football_train_dir: str = "data/football/train" # COCO
football_val_dir: str = "data/football/valid" # COCO
annotation_file: str = "_annotations.coco.json"
# Crops
crop_size: int = 64
padding: int = 40 # football: extra pixels around each box/proposal
backgrounds_per_image: int = 10 # create-dataset: background crops per image
train_fraction: float = 0.8
# Model
c_hidden: int = 32
layers: int = 10
num_classes: int = 2
# Training
epochs: int = 200
batch_size: int = 256 # faces (pre-cropped images)
football_batch_size: int = 2 # football (full images, cropped on the fly)
learning_rate: float = 1e-3
weight_decay: float = 1e-4
background_weight: float = 1 / 3 # class weight of "background" in the faces loss
device: str = "auto"
# Output
save_dir: str = "saved_models"
faces_model_name: str = "face_detection"
football_model_name: str = "object_detection"
save: bool = True
# detect tasks: proposals smaller than min_size or larger than max_size are ignored
image: str = "" # detect-faces: image to run on
min_size: int = 5
max_size: int = 600
min_confidence: float = 0.97
football_max_size: int = 100
football_min_confidence: float = 0.8
@@ -1,120 +0,0 @@
import random
from pathlib import Path
import torch
import torchvision.transforms.functional as TF
from torchvision.transforms import ToPILImage, ToTensor
from torch.utils.data import DataLoader
from tqdm import tqdm
from src.r_cnn.cocoDetectionDataset import CocoDetectionDataset
from src.util import iou
SAVE_PATH = "./saved_models"
def get_transform():
return ToTensor()
dataset = CocoDetectionDataset(
image_dir="data/faces/train",
annotation_path="data/faces/train/_annotations.coco.json",
transforms=get_transform()
)
processing_loader = DataLoader(dataset, batch_size=1, shuffle=False, collate_fn=lambda x: tuple(zip(*x)))
def random_crop(W, H, sizeX=64, sizeY =64):
x = random.randint(0, max(W - sizeX, 0))
y = random.randint(0, max(H - sizeY, 0))
return x, y, x + sizeX, y + sizeY
def box_inside(inner_box, outer_box):
return (
inner_box[0] >= outer_box[0] and
inner_box[1] >= outer_box[1] and
inner_box[2] <= outer_box[2] and
inner_box[3] <= outer_box[3]
)
def is_background(crop, gt_boxes, threshold=0.0):
for box in gt_boxes:
if iou(crop, box) > threshold:
return False
return True
def get_background_crop(image, gt_boxes, max_trials=100):
C, H, W = image.shape
for _ in range(max_trials):
x1, y1, x2, y2 = random_crop(W, H, sizeX=random.randint(50, 600), sizeY=random.randint(50, 600))
crop_box = (x1, y1, x2, y2)
if is_background(crop_box, gt_boxes, threshold=0.0):
crop = image[:, y1:y2, x1:x2]
return crop
return None
def create_background_tensor(amount, dataset, labels, boxes, image):
for _ in range(amount):
background = get_background_crop(image=image, gt_boxes=boxes.to(torch.int64))
if background is None:
break
background = TF.resize(background, [64, 64], antialias=True)
dataset.append(background)
labels.append(torch.tensor(0))
return dataset, labels
def create_stack(images, annotations, PADDING):
croped_images = []
labels = []
for i in range(len(images)):
image = images[i]
annotation = annotations[i]
_, h, w = image.shape
for box, label in tuple(zip(annotation["boxes"], annotation["labels"])):
box = box.to(torch.int64)
box_copy = []
box_copy.append(max(box[0]-PADDING, 0))
box_copy.append(max(box[1]-PADDING, 0))
box_copy.append(min(box[2]+PADDING, w-1))
box_copy.append(min(box[3]+PADDING, h-1))
if box_copy[1] == box_copy[3] or box_copy[0] == box_copy[2]:
continue
croped_image = image[: , box_copy[1]:box_copy[3], box_copy[0]:box_copy[2]]
croped_image = TF.resize(croped_image, [64, 64], antialias=True)
croped_images.append(croped_image)
labels.append(label)
croped_images, labels = create_background_tensor(amount=10, dataset=croped_images, labels=labels, boxes=annotation["boxes"], image=image)
return croped_images, labels
def create_croped_dataset():
to_pil = ToPILImage()
out_path = Path("data/faces_processed/face")
out_path.mkdir(parents=True, exist_ok=True)
out_path = Path("data/faces_processed/background")
out_path.mkdir(parents=True, exist_ok=True)
count_image = 0
count_background = 0
for images, annotations in tqdm(processing_loader):
croped_images, labels = create_stack(images, annotations, PADDING=0)
for i in range(len(croped_images)):
pil_img = to_pil(croped_images[i])
if labels[i] == 1:
pil_img.save(f"data/faces_processed/face/{count_image}.jpg")
count_image += 1
else:
pil_img.save(f"data/faces_processed/background/{count_background}.jpg")
count_background += 1
+59
View File
@@ -0,0 +1,59 @@
"""Cutting object and background crops out of detection images."""
import random
import torch
import torchvision.transforms.functional as TF
from src.common.boxes import iou
def random_crop(W, H, sizeX=64, sizeY=64):
x = random.randint(0, max(W - sizeX, 0))
y = random.randint(0, max(H - sizeY, 0))
return x, y, x + sizeX, y + sizeY
def is_background(crop, gt_boxes, threshold=0.0):
return all(iou(crop, box) <= threshold for box in gt_boxes)
def get_background_crop(image, gt_boxes, max_trials=100, iou_threshold=0.0):
"""A random crop that overlaps no ground-truth box, or None."""
_, H, W = image.shape
for _ in range(max_trials):
x1, y1, x2, y2 = random_crop(W, H, sizeX=random.randint(50, 600), sizeY=random.randint(50, 600))
if is_background((x1, y1, x2, y2), gt_boxes, threshold=iou_threshold):
return image[:, y1:y2, x1:x2]
return None
def create_stack(images, annotations, padding, crop_size=64, backgrounds_per_image=0):
"""Crops every annotated box (label from the annotation) plus
`backgrounds_per_image` background crops (label 0) per image."""
crops, labels = [], []
for image, annotation in zip(images, annotations):
_, h, w = image.shape
for box, label in zip(annotation["boxes"], annotation["labels"]):
box = box.to(torch.int64)
x1 = max(box[0] - padding, 0)
y1 = max(box[1] - padding, 0)
x2 = min(box[2] + padding, w - 1)
y2 = min(box[3] + padding, h - 1)
if x1 == x2 or y1 == y2: # zero-size box, cannot be resized
continue
crops.append(TF.resize(image[:, y1:y2, x1:x2], [crop_size, crop_size], antialias=True))
labels.append(label)
for _ in range(backgrounds_per_image):
background = get_background_crop(image, annotation["boxes"].to(torch.int64))
if background is None:
break
crops.append(TF.resize(background, [crop_size, crop_size], antialias=True))
labels.append(torch.tensor(0))
return crops, labels
+58
View File
@@ -0,0 +1,58 @@
"""Selective search + crop classifier on a full image."""
import cv2
import torch
import torch.nn.functional as F
import torchvision.transforms.functional as TF
from tqdm import tqdm
def resize_keep_aspect(img, target_w, target_h):
h, w = img.shape[:2]
scale = min(target_w / w, target_h / h)
new_w, new_h = int(w * scale), int(h * scale)
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
return resized, w / new_w, h / new_h
def detect(model, image, device, padding=0, crop_size=64, min_size=5, max_size=600, min_confidence=0.8):
"""image: tensor [C, H, W] in 0..1. Shows the image, then the detections.
Needs opencv-contrib-python (cv2.ximgproc)."""
if not hasattr(cv2, "ximgproc"):
raise RuntimeError("Selective search needs opencv-contrib-python (cv2.ximgproc)")
_, H, W = image.shape
image_numpy = (image.permute(1, 2, 0).cpu().numpy() * 255).astype("uint8")
image_numpy = cv2.cvtColor(image_numpy, cv2.COLOR_BGR2RGB)
resized, scale_w, scale_h = resize_keep_aspect(image_numpy, W, H)
cv2.imshow("", resized)
cv2.waitKey(0)
ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation()
ss.setBaseImage(resized)
ss.switchToSelectiveSearchFast()
predictions = []
with torch.inference_mode():
for (x, y, w, h) in tqdm(ss.process()):
x, y, w, h = int(x * scale_w), int(y * scale_h), int(w * scale_w), int(h * scale_h)
if w < min_size or h < min_size or w > max_size or h > max_size:
continue
x1, y1 = max(x - padding, 0), max(y - padding, 0)
x2, y2 = min(x + w + padding, W - 1), min(y + h + padding, H - 1)
crop = TF.resize(image[:, y1:y2, x1:x2], [crop_size, crop_size], antialias=True)
probs = F.softmax(model(crop.unsqueeze(0).to(device)), dim=1)
confidence, cls = torch.max(probs, dim=1)
if cls.item() == 1 and confidence.item() > min_confidence:
predictions.append((confidence.item(), (x, y, w, h)))
print(f"{len(predictions)} detections")
draw = image_numpy.copy()
for _, (x, y, w, h) in sorted(predictions, reverse=True):
cv2.rectangle(draw, (x, y), (x + w, y + h), (255, 0, 0), 1)
cv2.imshow("", draw)
cv2.waitKey(0)
-315
View File
@@ -1,315 +0,0 @@
import os
import random
import cv2
import torch
import torch.nn as nn
import torch.nn.functional as F
import torchvision.transforms.functional as TF
from tqdm import tqdm
from src.util import iou, visualizeImage
class ObjectDetectionCNN(nn.Module):
def __init__(self, c_in, c_hidden, c_out, layers):
super().__init__()
self.model = nn.ModuleList()
self.model.append(nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.BatchNorm2d(c_hidden),
nn.ReLU(inplace=True)
))
for _ in range(layers-1):
self.model.append(nn.Sequential(
nn.Conv2d(c_hidden, c_hidden, kernel_size=3, padding=1),
nn.BatchNorm2d(c_hidden),
nn.ReLU(inplace=True)
))
self.model.append(nn.Sequential(
nn.AdaptiveAvgPool2d((1, 1)),
nn.Flatten(),
nn.Linear(c_hidden, c_out),
nn.Dropout(0.3)
))
def forward(self, x):
for layer in self.model:
x = layer(x)
return x
def random_crop(W, H, sizeX=64, sizeY =64):
x = random.randint(0, max(W - sizeX, 0))
y = random.randint(0, max(H - sizeY, 0))
return x, y, x + sizeX, y + sizeY
def is_background(crop, gt_boxes, threshold=0.0):
for box in gt_boxes:
if iou(crop, box) > threshold:
return False
return True
def get_background_crop(image, gt_boxes, max_trials=100):
C, H, W = image.shape
for _ in range(max_trials):
x1, y1, x2, y2 = random_crop(W, H, sizeX=random.randint(50, 600), sizeY=random.randint(50, 600))
crop_box = (x1, y1, x2, y2)
if is_background(crop_box, gt_boxes, threshold=0.1):
crop = image[:, y1:y2, x1:x2]
return crop
return None
def create_background_tensor(amount, dataset, labels, boxes, image):
for _ in range(amount):
background = get_background_crop(image=image, gt_boxes=boxes.to(torch.int64))
if background is not None:
background = TF.resize(background, [64, 64], antialias=True)
dataset.append(background)
labels.append(torch.tensor(0))
else:
print("Background not found")
return dataset, labels
def create_stack(images, annotations, PADDING):
croped_images = []
labels = []
for i in range(len(images)):
image = images[i]
annotation = annotations[i]
_, h, w = image.shape
for box, label in tuple(zip(annotation["boxes"], annotation["labels"])):
box = box.to(torch.int64)
box_copy = []
box_copy.append(max(box[0]-PADDING, 0))
box_copy.append(max(box[1]-PADDING, 0))
box_copy.append(min(box[2]+PADDING, w-1))
box_copy.append(min(box[3]+PADDING, h-1))
croped_image = image[: , box_copy[1]:box_copy[3], box_copy[0]:box_copy[2]]
#visualizeImage(croped_image)
croped_image = TF.resize(croped_image, [64, 64], antialias=True)
#visualizeImage(croped_image)
croped_images.append(croped_image)
labels.append(label)
#croped_images, labels = create_background_tensor(amount=len(croped_images), dataset=croped_images, labels=labels, boxes=annotation["boxes"], image=image)
return croped_images, labels
def train(model, loss_module, train_loader, val_loader, optimizer, SAVE_PATH, model_name, saving=True, PADDING=20, device="cpu"):
best_val = torch.finfo(torch.float32).max
for epoch in range(200):
############
# Training #
############
model.train()
true_preds, count, lossCount = 0, 0, 0.
for images, annotations in tqdm(train_loader, desc=f"Train", leave=False):
croped_images, labels = create_stack(images, annotations, PADDING)
croped_images = torch.stack(croped_images).to(device)
labels = torch.stack(labels).to(device)
prediction = model(croped_images)
loss = loss_module(prediction, labels)
lossCount += loss.sum().item()
optimizer.zero_grad()
loss.backward()
optimizer.step()
true_preds += (prediction.argmax(dim=1) == labels).sum().item()
count += croped_images.size(0)
train_acc = true_preds / count
train_loss = lossCount / count
torch.cuda.empty_cache()
##############
# Validation #
##############
model.eval()
true_preds, count, lossCount = 0, 0, 0.
for images, annotations in tqdm(val_loader, desc=f"Test", leave=False):
with torch.no_grad():
croped_images, labels = create_stack(images, annotations, PADDING)
croped_images = torch.stack(croped_images).to(device)
labels = torch.stack(labels).to(device)
prediction = model(croped_images)
loss = loss_module(prediction, labels)
lossCount += loss.sum().item()
true_preds += (prediction.argmax(dim=1) == labels).sum().item()
count += croped_images.size(0)
val_acc = true_preds / count
val_loss = lossCount / count
if(saving and best_val > val_loss):
best_val = val_loss
save_dir = os.path.join(SAVE_PATH, model_name)
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, model_name)
torch.save(model.state_dict(), save_path)
print(f"epoch: {epoch+1} | train accuracy: {int(train_acc * 1000) / 10}% | validation accuracy: {int(val_acc * 1000) / 10}% | train loss: {int(train_loss * 1000) / 100} | val loss: {int(val_loss * 1000) / 100}")
torch.cuda.empty_cache()
return best_val
def trainNormalDataset(model, loss_module, train_loader, val_loader, optimizer, SAVE_PATH, model_name, saving=True, device="cpu"):
best_val = torch.finfo(torch.float32).max
for epoch in range(200):
############
# Training #
############
model.train()
true_preds, count, lossCount = 0, 0, 0.
for images, labels in tqdm(train_loader, desc=f"Train", leave=False):
images = images.to(device)
labels = labels.to(device)
prediction = model(images)
loss = loss_module(prediction, labels)
lossCount += loss.sum().item()
optimizer.zero_grad()
loss.backward()
optimizer.step()
true_preds += (prediction.argmax(dim=1) == labels).sum().item()
count += images.size(0)
train_acc = true_preds / count
train_loss = lossCount / count
torch.cuda.empty_cache()
##############
# Validation #
##############
model.eval()
true_preds, count, lossCount = 0, 0, 0.
for images, labels in tqdm(val_loader, desc=f"Test", leave=False):
with torch.no_grad():
images = images.to(device)
labels = labels.to(device)
prediction = model(images)
loss = loss_module(prediction, labels)
lossCount += loss.sum().item()
true_preds += (prediction.argmax(dim=1) == labels).sum().item()
count += images.size(0)
val_acc = true_preds / count
val_loss = lossCount / count
if(saving and best_val > val_loss):
best_val = val_loss
save_dir = os.path.join(SAVE_PATH, model_name)
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, model_name)
torch.save(model.state_dict(), save_path)
print(f"epoch: {epoch+1} | train accuracy: {int(train_acc * 1000) / 10}% | validation accuracy: {int(val_acc * 1000) / 10}% | train loss: {int(train_loss * 1000) / 100} | val loss: {int(val_loss * 1000) / 100}")
torch.cuda.empty_cache()
return best_val
def resize_keep_aspect(img, target_w, target_h):
h, w = img.shape[:2]
scale = min(target_w / w, target_h / h)
new_w = int(w * scale)
new_h = int(h * scale)
resized = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_AREA)
return resized, w / new_w, h / new_h
def map_box_to_original(box, scale_x, scale_y):
x1, y1, x2, y2 = box
return int(x1 * scale_x), int(y1 * scale_y), int(x2 * scale_x), int(y2 * scale_y)
def eval(model, image, BUILD_PATH, device, PADDING = 0, minSize=5, maxSize=600, minConf=0.8):
_, H, W = image.shape
model.load_state_dict(torch.load(BUILD_PATH, map_location=torch.device(device)))
model.to(device)
model.eval()
image_numpy = image.permute(1, 2, 0).cpu().numpy()
image_numpy = (image_numpy*255).astype("uint8")
image_numpy = cv2.cvtColor(image_numpy, cv2.COLOR_BGR2RGB)
image_numpy_copy, resizedW, resizedH = resize_keep_aspect(image_numpy, W, H)
cv2.imshow("", image_numpy_copy)
cv2.waitKey(0)
ss = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation()
ss.setBaseImage(image_numpy_copy)
ss.switchToSelectiveSearchFast()
rects = ss.process()
draw = image_numpy.copy()
predictions = []
for (x, y, w, h) in tqdm(rects):
x, y, w, h = map_box_to_original((x, y, w, h), resizedW, resizedH)
if w < minSize or h < minSize or w > maxSize or h > maxSize:
continue
x1 = max(x-PADDING, 0)
y1 = max(y-PADDING, 0)
x2 = min(x+w+PADDING, W-1)
y2 = min(y+h+PADDING, H-1)
crop = image[:, y1:y2, x1:x2]
crop = TF.resize(crop, [64, 64], antialias=True)
crop = crop.unsqueeze(0).to(device)
pred = model(crop)
probs = F.softmax(pred, dim=1)
confidence, cls = torch.max(probs, dim=1)
if cls.item() == 1 and confidence.item() > minConf:
predictions.append((confidence.item(), cls.item(), (x, y, w, h)))
predictions = sorted(predictions, key=lambda x: x[0], reverse=True)
print(len(predictions))
for conf, cls, (x, y, w, h) in predictions[:]:
cv2.rectangle(draw, (x, y), (x + w, y + h), (255, 0, 0), 1)
cv2.imshow("", draw)
cv2.waitKey(0)
-50
View File
@@ -1,50 +0,0 @@
import os
from torch import tensor
import torch
from torch.utils.data import DataLoader
from torchvision.transforms import ToTensor
import torch.nn as nn
from src.r_cnn.cocoDetectionDataset import CocoDetectionDataset
from src.r_cnn.r_cnn import ObjectDetectionCNN, eval, train
def get_transform():
return ToTensor()
def train_cnn_test():
SAVE_PATH = "./saved_models"
PADDING = 20
train_dataset = CocoDetectionDataset(
image_dir="data/football/train",
annotation_path="data/football/train/_annotations.coco.json",
transforms=get_transform()
)
val_dataset = CocoDetectionDataset(
image_dir="data/football/valid",
annotation_path="data/football/valid/_annotations.coco.json",
transforms=get_transform()
)
train_loader = DataLoader(train_dataset, batch_size=2, shuffle=True, collate_fn=lambda x: tuple(zip(*x)))
val_loader = DataLoader(val_dataset, batch_size=2, shuffle=True, collate_fn=lambda x: tuple(zip(*x)))
device = torch.device("cpu") if not torch.cuda.is_available() else torch.device("cuda:0")
print("Using device", device)
model = ObjectDetectionCNN(c_in=3, c_hidden=32, c_out=2, layers=10)
model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
loss_module = nn.CrossEntropyLoss()
#train(model=model, loss_module=loss_module, train_loader=train_loader, val_loader=val_loader,
# optimizer=optimizer, SAVE_PATH=SAVE_PATH, saving=True, PADDING=40, device=device)
#exit()
image = next(iter(val_loader))[0][0]
eval(model=model, image=image, BUILD_PATH=os.path.join(SAVE_PATH, "object_detection", "object_detection"),
device=device, PADDING=40, minSize=5, maxSize=100, minConf=0.8)
+126
View File
@@ -0,0 +1,126 @@
"""R-CNN style experiments. Run with: python -m src.main rcnn.<task>"""
import os
from pathlib import Path
import torch
import torch.nn as nn
from torch.utils.data import DataLoader
from torchvision import datasets, transforms
from torchvision.io import read_image
from torchvision.transforms import ToPILImage, ToTensor
from tqdm import tqdm
from src.common.checkpoints import checkpoint_path, load_checkpoint
from src.common.data import collate_as_tuples, split_dataset
from src.common.device import get_device
from src.common.training import fit
from src.models.r_cnn import ObjectDetectionCNN
from src.r_cnn.config import Config
from src.r_cnn.crops import create_stack
from src.r_cnn.dataset import CocoDetectionDataset
from src.r_cnn.detect import detect
def build_model(cfg: Config):
return ObjectDetectionCNN(c_in=3, c_hidden=cfg.c_hidden, c_out=cfg.num_classes, layers=cfg.layers)
def coco_dataset(cfg: Config, folder: str):
return CocoDetectionDataset(image_dir=folder,
annotation_path=os.path.join(folder, cfg.annotation_file),
transforms=ToTensor())
def create_dataset(cfg: Config):
"""Cut face and background crops out of --faces-raw-dir (for train-faces)."""
loader = DataLoader(coco_dataset(cfg, cfg.faces_raw_dir), batch_size=1, shuffle=False,
collate_fn=collate_as_tuples)
to_pil = ToPILImage()
out = Path(cfg.faces_processed_dir)
(out / "face").mkdir(parents=True, exist_ok=True)
(out / "background").mkdir(parents=True, exist_ok=True)
counts = {"face": 0, "background": 0}
for images, annotations in tqdm(loader):
crops, labels = create_stack(images, annotations, padding=0, crop_size=cfg.crop_size,
backgrounds_per_image=cfg.backgrounds_per_image)
for crop, label in zip(crops, labels):
kind = "face" if label == 1 else "background"
to_pil(crop).save(out / kind / f"{counts[kind]}.jpg")
counts[kind] += 1
print(f"Saved {counts['face']} faces and {counts['background']} backgrounds to {out}")
def train_faces(cfg: Config):
"""Train the face/background crop classifier on --faces-processed-dir."""
device = get_device(cfg.device)
base = [transforms.Resize(cfg.crop_size), transforms.CenterCrop(cfg.crop_size), transforms.ToTensor()]
augment = [transforms.RandomHorizontalFlip(p=0.5), transforms.RandomRotation(degrees=15)] + base
train_ds, val_ds = split_dataset(datasets.ImageFolder(cfg.faces_processed_dir), cfg.train_fraction,
transforms.Compose(augment), transforms.Compose(base))
train_loader = DataLoader(train_ds, batch_size=cfg.batch_size, shuffle=True)
val_loader = DataLoader(val_ds, batch_size=cfg.batch_size, shuffle=False)
model = build_model(cfg).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=cfg.learning_rate, weight_decay=cfg.weight_decay)
# ImageFolder sorts classes alphabetically: 0 = background, 1 = face
weights = torch.tensor([cfg.background_weight, 1.0], device=device)
fit(model, nn.CrossEntropyLoss(weight=weights), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.faces_model_name, save=cfg.save,
monitor="loss", track_accuracy=True)
def detect_faces(cfg: Config):
"""Run selective search + the face classifier on --image."""
if not cfg.image:
raise SystemExit("Pass the image to run on with --image path/to/image.jpg")
device = get_device(cfg.device)
model = load_checkpoint(build_model(cfg), checkpoint_path(cfg.save_dir, cfg.faces_model_name), device)
image = read_image(cfg.image).float() / 255.0
detect(model, image, device, crop_size=cfg.crop_size, min_size=cfg.min_size,
max_size=cfg.max_size, min_confidence=cfg.min_confidence)
def train_football(cfg: Config):
"""Train the crop classifier on the football COCO dataset (crops on the fly)."""
device = get_device(cfg.device)
train_loader = DataLoader(coco_dataset(cfg, cfg.football_train_dir), batch_size=cfg.football_batch_size,
shuffle=True, collate_fn=collate_as_tuples)
val_loader = DataLoader(coco_dataset(cfg, cfg.football_val_dir), batch_size=cfg.football_batch_size,
shuffle=True, collate_fn=collate_as_tuples)
def prepare_batch(batch, device):
images, annotations = batch
crops, labels = create_stack(images, annotations, padding=cfg.padding, crop_size=cfg.crop_size)
return torch.stack(crops).to(device), torch.stack(labels).to(device)
model = build_model(cfg).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=cfg.learning_rate, weight_decay=cfg.weight_decay)
fit(model, nn.CrossEntropyLoss(), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.football_model_name, save=cfg.save,
monitor="loss", track_accuracy=True, prepare_batch=prepare_batch)
def detect_football(cfg: Config):
"""Run selective search + the football classifier on a validation image."""
device = get_device(cfg.device)
model = load_checkpoint(build_model(cfg), checkpoint_path(cfg.save_dir, cfg.football_model_name), device)
loader = DataLoader(coco_dataset(cfg, cfg.football_val_dir), batch_size=1, shuffle=True,
collate_fn=collate_as_tuples)
image = next(iter(loader))[0][0]
detect(model, image, device, padding=cfg.padding, crop_size=cfg.crop_size, min_size=cfg.min_size,
max_size=cfg.football_max_size, min_confidence=cfg.football_min_confidence)
TASKS = {
"create-dataset": create_dataset,
"train-faces": train_faces,
"detect-faces": detect_faces,
"train-football": train_football,
"detect-football": detect_football,
}
@@ -1,69 +0,0 @@
import os
import torch
import torch.nn as nn
from torchvision import datasets, transforms
from torchvision.io import read_image
from torch.utils.data import DataLoader, WeightedRandomSampler, random_split
from tqdm import tqdm
from src.r_cnn.r_cnn import ObjectDetectionCNN, eval, trainNormalDataset
from src.util import TransformedSubset, visualizeImage
SAVE_PATH = "./saved_models"
PADDING = 20
IMAGE_SIZE = 64
transform = transforms.Compose([
transforms.Resize(IMAGE_SIZE),
transforms.CenterCrop(IMAGE_SIZE),
transforms.ToTensor()
])
transform_augemnt = transforms.Compose([
transforms.RandomHorizontalFlip(p=0.5),
transforms.RandomRotation(degrees=15),
transforms.Resize(IMAGE_SIZE),
transforms.CenterCrop(IMAGE_SIZE),
transforms.ToTensor()
])
dataset = datasets.ImageFolder("data/faces_processed")
weights = [1/3, 1]
sampler = WeightedRandomSampler(
weights=weights,
num_samples=len(dataset),
replacement=True
)
train_size = int(0.8 * len(dataset))
val_size = len(dataset) - train_size
train_subset, val_subset = random_split(dataset, [train_size, val_size])
train_dataset = TransformedSubset(train_subset, transform_augemnt)
val_dataset = TransformedSubset(val_subset, transform)
train_loader = DataLoader(train_dataset, batch_size=256, shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=256, shuffle=False)
device = torch.device("cpu") if not torch.cuda.is_available() else torch.device("cuda:0")
print("Using device", device)
model = ObjectDetectionCNN(c_in=3, c_hidden=32, c_out=2, layers=10)
model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
loss_module = nn.CrossEntropyLoss(weight=torch.tensor([1/3, 1], device=device, dtype=torch.float32))
#trainNormalDataset(model=model, loss_module=loss_module, train_loader=train_loader, val_loader=val_loader,
# optimizer=optimizer, SAVE_PATH=SAVE_PATH, saving=True, device=device, model_name="face_detection")
#exit()
image = read_image("data/faces/train/_url-http_3A_2F_2Fdingyue-ws-126-net_2F2023_2F0201_2F6a17ca80j00rpd7yx00bmd000dw00gop_jpg.rf.Setza3JikTSzG04c0Fd1.jpg")
image = image.float() / 255.0
visualizeImage(image)
eval(model=model, image=image, BUILD_PATH=os.path.join(SAVE_PATH, "face_detection", "face_detection"), device=device, minConf=0.97)
-74
View File
@@ -1,74 +0,0 @@
from matplotlib import pyplot as plt
import torch
from tqdm import tqdm
from torch.utils.data import DataLoader
import time
import multiprocessing as mp
class TransformedSubset(torch.utils.data.Dataset):
def __init__(self, subset, transform=None):
self.subset = subset
self.transform = transform
def __getitem__(self, idx):
x, y = self.subset[idx]
if self.transform:
x = self.transform(x)
return x, y
def __len__(self):
return len(self.subset)
def visualizeData(dataset):
images, labels = next(iter(dataset))
for i in range(4):
img = images[i]
img = img.permute(1, 2, 0)
plt.figure(figsize=(3,3))
plt.imshow(img)
plt.title(f"Label: {labels[i].item()}")
plt.axis("off")
plt.show()
def visualizeImage(image):
image = image.permute(1, 2, 0)
plt.figure(figsize=(3,3))
plt.imshow(image)
plt.axis("off")
plt.show()
def iou(boxA, boxB):
xA = max(boxA[0], boxB[0])
yA = max(boxA[1], boxB[1])
xB = min(boxA[2], boxB[2])
yB = min(boxA[3], boxB[3])
inter_area = max(0, xB - xA) * max(0, yB - yA)
boxA_area = (boxA[2]-boxA[0]) * (boxA[3]-boxA[1])
boxB_area = (boxB[2]-boxB[0]) * (boxB[3]-boxB[1])
union = boxA_area + boxB_area - inter_area
return inter_area / union if union > 0 else 0
def test_workers_speed(dataset, model):
device = next(model.parameters()).device
for num_workers in range(0, mp.cpu_count(), 2):
train_loader = DataLoader(dataset,shuffle=True,num_workers=num_workers,batch_size=64,pin_memory=True)
start = time.time()
for _ in range(2):
for images, _ in tqdm(train_loader, leave=False):
images = images.to(device)
_ = model(images)
end = time.time()
print("Finish with:{} seconds, num_workers={}".format(int(end - start), num_workers))
+41
View File
@@ -0,0 +1,41 @@
from dataclasses import dataclass
@dataclass
class Config:
"""Settings for the YOLO face detector. Paths are relative to api/.
Every field can be overridden on the command line, e.g. --batch-size 32."""
# Data (COCO format: images + _annotations.coco.json in each folder)
train_dir: str = "data/faces_scenery/train"
val_dir: str = "data/faces_scenery/test"
annotation_file: str = "_annotations.coco.json"
augment_train: bool = True
augment_val: bool = True # the original code augmented validation too
image_size: int = 64
grid: int = 6
# Model
c_hidden: int = 16
boxes: int = 1
labels: int = 1
size_activation: str = "sigmoid" # "sigmoid" (current training) or "exp" (deployed model)
# Training
epochs: int = 500
batch_size: int = 64
learning_rate: float = 1e-3
weight_decay: float = 1e-4
train_workers: int = 2
val_workers: int = 0
device: str = "auto" # "auto", "cpu", "cuda:0", ...
# Output
save_dir: str = "saved_models"
model_name: str = "face_detection_yolo"
save: bool = True
# sample / webcam tasks
checkpoint: str = "" # empty = <save_dir>/<model_name>/<model_name>
threshold: float = 0.95 # confidence needed to draw a box
camera: int = 0 # webcam index
@@ -1,24 +1,14 @@
import math
import os import os
import cv2 import cv2
import torch import torch
from PIL import Image
from torch.utils.data import Dataset from torch.utils.data import Dataset
from pycocotools.coco import COCO from pycocotools.coco import COCO
import numpy as np import numpy as np
from torchvision import transforms from torchvision import transforms
import albumentations as A import albumentations as A
from src.common.boxes import image_to_cell_coords, is_center_in_grid_cell
def is_center_in_grid_cell(x, y, img_w, img_h, S, cell_i, cell_j):
cell_w = img_w / S
cell_h = img_h / S
# find which cell the center belongs to
gt_cell_i = min(int(x / cell_w), S - 1)
gt_cell_j = min(int(y / cell_h), S - 1)
return (gt_cell_i == cell_i) and (gt_cell_j == cell_j)
def one_hot(index, num_classes): def one_hot(index, num_classes):
if index < 0 or index >= num_classes: if index < 0 or index >= num_classes:
@@ -28,18 +18,6 @@ def one_hot(index, num_classes):
encoding[index] = 1 encoding[index] = 1
return encoding return encoding
def turn_grid_centered(x, y, img_w, img_h, S, cell_i, cell_j):
cell_w = img_w / S
cell_h = img_h / S
cell_border_w = cell_w * cell_i
cell_border_h = cell_h * cell_j
return x - cell_border_w, y - cell_border_h
def flip_bbox_horizontal(box, image_width):
x, y, w, h = box
return (image_width - x - w, y, w, h)
class YoloDataset(Dataset): class YoloDataset(Dataset):
def __init__(self, image_dir, annotation_path, img_size=64, grid = 9, transform=False): def __init__(self, image_dir, annotation_path, img_size=64, grid = 9, transform=False):
@@ -135,7 +113,7 @@ class YoloDataset(Dataset):
img_h=self.img_size, S=self.grid, cell_i=x, cell_j=y): img_h=self.img_size, S=self.grid, cell_i=x, cell_j=y):
class_one_hot = one_hot(int(label), self.num_classes) class_one_hot = one_hot(int(label), self.num_classes)
x_grid_centered, y_grid_centered = turn_grid_centered(x=x_center, y=y_center, img_w=self.img_size, x_grid_centered, y_grid_centered = image_to_cell_coords(x=x_center, y=y_center, img_w=self.img_size,
img_h=self.img_size, S=self.grid, cell_i=x, cell_j=y) img_h=self.img_size, S=self.grid, cell_i=x, cell_j=y)
+149
View File
@@ -0,0 +1,149 @@
"""YOLO face detector tasks. Run with: python -m src.main yolo.<task>"""
import os
import time
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader
from torchvision.utils import draw_bounding_boxes
from src.common.checkpoints import checkpoint_path, load_checkpoint
from src.common.device import get_device
from src.common.training import benchmark_num_workers, fit
from src.common.visualize import show_image
from src.models.yolo import Yolo_model, convert_prediction
from src.yolo.config import Config
from src.yolo.dataset import YoloDataset
from src.yolo.loss import YoloLoss
def build_dataset(cfg: Config, folder: str, augment: bool):
return YoloDataset(
image_dir=folder,
annotation_path=os.path.join(folder, cfg.annotation_file),
img_size=cfg.image_size,
transform=augment,
grid=cfg.grid,
)
def build_model(cfg: Config):
return Yolo_model(c_in=3, boxes=cfg.boxes, grid=cfg.grid, labels=cfg.labels,
c_hidden=cfg.c_hidden, size_activation=cfg.size_activation)
def load_trained(cfg: Config, device):
path = cfg.checkpoint or checkpoint_path(cfg.save_dir, cfg.model_name)
return load_checkpoint(build_model(cfg), path, device)
def draw_prediction(label, image, threshold):
"""Red: detected boxes, blue: cells with an object, green: empty cells."""
boxes, cells_obj, cells_noobj = convert_prediction(label, image, threshold)
if len(boxes) == 0:
print("no boxes")
image = draw_bounding_boxes(image, torch.tensor(cells_noobj), colors=(0, 255, 0))
image = draw_bounding_boxes(image, torch.tensor(cells_obj), colors=(0, 0, 255))
image = draw_bounding_boxes(image, torch.tensor(boxes), colors=(255, 0, 0))
show_image(image)
def train(cfg: Config):
"""Train the detector; saves the model with the lowest validation loss."""
device = get_device(cfg.device)
train_loader = DataLoader(build_dataset(cfg, cfg.train_dir, cfg.augment_train),
batch_size=cfg.batch_size, shuffle=True, num_workers=cfg.train_workers)
val_loader = DataLoader(build_dataset(cfg, cfg.val_dir, cfg.augment_val),
batch_size=cfg.batch_size, shuffle=False, num_workers=cfg.val_workers)
model = build_model(cfg).to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=cfg.learning_rate, weight_decay=cfg.weight_decay)
fit(model, YoloLoss(), optimizer, train_loader, val_loader, device,
epochs=cfg.epochs, save_dir=cfg.save_dir, model_name=cfg.model_name, save=cfg.save,
monitor="loss")
def view_data(cfg: Config):
"""Show validation images with their ground-truth boxes, one at a time."""
dataset = build_dataset(cfg, cfg.val_dir, cfg.augment_val)
for images, labels in DataLoader(dataset, batch_size=1, shuffle=True):
draw_prediction(labels[0], images[0], cfg.threshold)
def sample(cfg: Config):
"""Show the saved model's predictions on validation images."""
device = get_device(cfg.device)
model = load_trained(cfg, device)
dataset = build_dataset(cfg, cfg.val_dir, cfg.augment_val)
for images, _ in DataLoader(dataset, batch_size=cfg.batch_size, shuffle=False):
with torch.inference_mode():
start = time.perf_counter()
predictions = model(images.to(device)).cpu()
print(f"Inference: {time.perf_counter() - start:.6f} seconds")
for image, prediction in zip(images, predictions):
draw_prediction(prediction, image, cfg.threshold)
def benchmark_workers(cfg: Config):
"""Time DataLoader num_workers values to pick the fastest."""
device = get_device(cfg.device)
dataset = build_dataset(cfg, cfg.train_dir, cfg.augment_train)
benchmark_num_workers(dataset, build_model(cfg).to(device), device, batch_size=cfg.batch_size)
def webcam(cfg: Config):
"""Live detection on the webcam (--camera). Press q to quit."""
import cv2
device = get_device(cfg.device)
model = load_trained(cfg, device)
cap = cv2.VideoCapture(cfg.camera)
if not cap.isOpened():
raise RuntimeError("Could not open webcam")
def draw(frame, boxes, scale_w, scale_h, color, thickness):
for b in boxes:
cv2.rectangle(frame, (int(b[0] * scale_w), int(b[1] * scale_h)),
(int(b[2] * scale_w), int(b[3] * scale_h)), color, thickness)
try:
while True:
ok, frame = cap.read()
if not ok:
break
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = torch.from_numpy(rgb).permute(2, 0, 1).float() / 255.0
_, H, W = image.shape
scale_w, scale_h = W / cfg.image_size, H / cfg.image_size
image = F.interpolate(image.unsqueeze(0), size=(cfg.image_size, cfg.image_size),
mode="bilinear", align_corners=True)
with torch.inference_mode():
prediction = model(image.to(device)).cpu()
boxes, cells_obj, cells_noobj = convert_prediction(prediction.squeeze(0), image.squeeze(0),
threshold=cfg.threshold)
draw(frame, cells_noobj, scale_w, scale_h, (0, 255, 0), 1)
draw(frame, cells_obj, scale_w, scale_h, (255, 0, 0), 1)
draw(frame, boxes, scale_w, scale_h, (0, 0, 255), 4)
cv2.imshow("Webcam", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
finally:
cap.release()
cv2.destroyAllWindows()
TASKS = {
"train": train,
"view-data": view_data,
"sample": sample,
"benchmark-workers": benchmark_workers,
"webcam": webcam,
}
-160
View File
@@ -1,160 +0,0 @@
import os
import cv2
from tqdm import tqdm
import torch
import torch.nn.functional as F
from torch.utils.data import DataLoader, random_split
from torchvision import datasets
from torchvision.utils import draw_bounding_boxes
from src.util import TransformedSubset, test_workers_speed, visualizeImage
from src.production.yolo_model_production import convert_prediction
from src.yolo.yolo_dataset import YoloDataset
from src.yolo.yolo_loss import YoloLoss
from src.yolo.yolo_model import Yolo_model, sample, train
def view_data(dataset):
dataloader = DataLoader(dataset=dataset, batch_size=1, shuffle=True)
for images, labels in iter(dataloader):
for batch in range(images.shape[0]):
image = images[batch]
label = labels[batch]
visualize_boxes(label, image)
def sample_data(dataloader, model, device, SAVE_PATH):
for images, labels in iter(dataloader):
predictions = sample(model, images, device, SAVE_PATH)
for batch in range(predictions.shape[0]):
image = images[batch]
prediction = predictions[batch]
visualize_boxes(prediction, image)
def visualize_boxes(label, image, threshold=0.95):
boxes_to_draw, grids_to_draw_obj, grids_to_draw_noobj = convert_prediction(label, image, threshold)
if len(boxes_to_draw) == 0:
print("no labels")
boxes_to_draw = torch.tensor(boxes_to_draw)
grids_to_draw_noobj = torch.tensor(grids_to_draw_noobj)
grids_to_draw_obj = torch.tensor(grids_to_draw_obj)
image = draw_bounding_boxes(image, grids_to_draw_noobj, colors=(0, 255, 0))
image = draw_bounding_boxes(image, grids_to_draw_obj, colors=(0, 0, 255))
image = draw_bounding_boxes(image, boxes_to_draw, colors=(255, 0, 0))
visualizeImage(image)
def use_webcam(grid, img_size):
# 0 = default webcam
cap = cv2.VideoCapture(0)
if not cap.isOpened():
raise Exception("Could not open webcam")
model = Yolo_model(c_in=3, boxes=1, grid=grid, labels=1)
state_dict = torch.load(os.path.join("saved_models", "face_detection_yolo", "face_detection_yolo"), weights_only=False)
model.load_state_dict(state_dict)
model.eval()
while True:
ret, frame = cap.read()
if not ret:
break
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
image = torch.from_numpy(rgb).permute(2, 0, 1).float()
C, H, W = image.shape
scale_w = W / img_size
scale_h = H / img_size
image = image / 255.0
image = image.unsqueeze(0)
image = F.interpolate(image, size=(img_size, img_size), mode="bilinear", align_corners=True) #different modes?
prediction = model(image)
bboxes, grid_ob, grid_noob = convert_prediction(prediction.squeeze(0), image.squeeze(0), threshold=0.9)
for bbox in grid_noob:
xmin = int(bbox[0] * scale_w)
ymin = int(bbox[1] * scale_h)
xmax = int(bbox[2] * scale_w)
ymax = int(bbox[3] * scale_h)
cv2.rectangle(frame, (xmin, ymin), (xmax, ymax), (0, 255, 0), 1)
for bbox in grid_ob:
xmin = int(bbox[0] * scale_w)
ymin = int(bbox[1] * scale_h)
xmax = int(bbox[2] * scale_w)
ymax = int(bbox[3] * scale_h)
cv2.rectangle(frame, (xmin, ymin), (xmax, ymax), (255, 0, 0), 1)
for bbox in bboxes:
xmin = int(bbox[0] * scale_w)
ymin = int(bbox[1] * scale_h)
xmax = int(bbox[2] * scale_w)
ymax = int(bbox[3] * scale_h)
cv2.rectangle(frame, (xmin, ymin), (xmax, ymax), (0, 0, 255), 4)
cv2.imshow("Webcam", frame)
if cv2.waitKey(1) & 0xFF == ord("q"):
break
cap.release()
cv2.destroyAllWindows()
def train_yolo():
SAVE_PATH = "./saved_models"
IMAGE_SIZE = 64
GRID = 6
BATCH_SIZE = 64
dataset = YoloDataset(
image_dir="data/faces_scenery/train",
annotation_path="data/faces_scenery/train/_annotations.coco.json",
img_size=IMAGE_SIZE,
transform=True,
grid=GRID
)
dataset_valid = YoloDataset(
image_dir="data/faces_scenery/test",
annotation_path="data/faces_scenery/test/_annotations.coco.json",
img_size=IMAGE_SIZE,
transform=True,
grid=GRID
)
train_loader = DataLoader(dataset, batch_size=BATCH_SIZE, shuffle=True, num_workers=2)
val_loader = DataLoader(dataset_valid, batch_size=BATCH_SIZE, shuffle=False, num_workers=0)
device = torch.device("cpu") if not torch.cuda.is_available() else torch.device("cuda:0")
print("Using device", device)
model = Yolo_model(c_in=3, boxes=1, grid=GRID, labels=1)
model.to(device)
optimizer = torch.optim.Adam(model.parameters(), lr=1e-3, weight_decay=1e-4)
loss_module = YoloLoss()
view_data(dataset_valid)
exit()
#sample_data(dataloader=val_loader, model=model, device=device, SAVE_PATH=SAVE_PATH)
#exit()
#test_workers_speed(dataset, model)
#exit()
#use_webcam(GRID, IMAGE_SIZE)
#exit()
train(model=model, loss_module=loss_module, train_loader=train_loader, val_loader=val_loader,
optimizer=optimizer, SAVE_PATH=SAVE_PATH, saving=True, model_name="face_detection_yolo")
-162
View File
@@ -1,162 +0,0 @@
import os
import time
import torch
from tqdm import tqdm
import torch.nn as nn
import torch.nn.functional as F
class Yolo_Conv_Block(nn.Module):
def __init__(self, c_in, c_hidden, c_out, kernel_size):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(in_channels=c_in, out_channels=c_hidden, kernel_size=kernel_size, padding=kernel_size//2),
nn.BatchNorm2d(c_hidden),
nn.LeakyReLU(),
nn.Conv2d(in_channels=c_hidden, out_channels=c_out, kernel_size=1)
)
def forward(self, x):
return self.model(x)
class SkipBlock(nn.Module):
def __init__(self, c_in, c_out, kernel_size=3):
super().__init__()
self.conv = nn.Sequential(
nn.Conv2d(c_in, c_out, kernel_size, padding=kernel_size//2),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size//2),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True),
nn.Conv2d(c_out, c_out, kernel_size, padding=kernel_size//2),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True)
)
self.conv_skip = nn.Sequential(
nn.Conv2d(c_in, c_out, 1),
nn.GroupNorm(num_groups=c_out//8, num_channels=c_out),
nn.LeakyReLU(inplace=True)
)
def forward(self, x):
return(F.dropout(F.leaky_relu(self.conv_skip(x) + self.conv(x), inplace=True), p=0.3))
class Yolo_model(nn.Module):
def __init__(self, c_in, boxes, grid, labels, c_hidden=16):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(c_in, c_hidden, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden//8, num_channels=c_hidden),
nn.LeakyReLU(inplace=True),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden),
SkipBlock(c_in=c_hidden, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*2),
SkipBlock(c_in=c_hidden*2, c_out=c_hidden*4),
SkipBlock(c_in=c_hidden*4, c_out=c_hidden*4),
SkipBlock(c_in=c_hidden*4, c_out=c_hidden*4),
SkipBlock(c_in=c_hidden*4, c_out=c_hidden*4),
nn.Conv2d(c_hidden*4, c_hidden*8, kernel_size=3, padding=1),
nn.GroupNorm(num_groups=c_hidden//2, num_channels=c_hidden*8),
nn.LeakyReLU(inplace=True),
nn.Dropout(0.3),
nn.AdaptiveAvgPool2d((grid, grid)),
nn.Conv2d(c_hidden*8, boxes*5 + labels, kernel_size=1)
)
def forward(self, x):
x = self.model(x).permute(0, 2, 3, 1)
return F.sigmoid(x)
def train(model, loss_module, train_loader, val_loader, optimizer, SAVE_PATH, model_name, saving=True):
best_val = torch.finfo(torch.float32).max
device = next(model.parameters()).device
for epoch in range(500):
############
# Training #
############
model.train()
count, lossCount = 0, 0.
for images, labels in tqdm(train_loader, desc=f"Train", leave=False):
images = images.to(device)
labels = labels.to(device)
prediction = model(images)
loss = loss_module(prediction, labels)
lossCount += loss.item()
optimizer.zero_grad()
loss.backward()
optimizer.step()
count += images.size(0)
train_loss = lossCount / count
torch.cuda.empty_cache()
##############
# Validation #
##############
model.eval()
count, lossCount = 0, 0.
for images, labels in tqdm(val_loader, desc=f"Test", leave=False):
with torch.no_grad():
images = images.to(device)
labels = labels.to(device)
prediction = model(images)
loss = loss_module(prediction, labels)
lossCount += loss.item()
count += images.size(0)
val_loss = lossCount / count
if(saving and best_val > val_loss):
best_val = val_loss
save_dir = os.path.join(SAVE_PATH, model_name)
os.makedirs(save_dir, exist_ok=True)
save_path = os.path.join(save_dir, model_name)
torch.save(model.state_dict(), save_path)
print(f"epoch: {epoch+1} | train loss: {int(train_loss * 100000) / 100} | val loss: {int(val_loss * 100000) / 100}")
torch.cuda.empty_cache()
return best_val
def sample(model, img, device, SAVE_PATH, model_name="face_detection_yolo", folder="face_detection_yolo"):
with torch.no_grad():
full_path = os.path.join(SAVE_PATH, folder, model_name)
state_dict = torch.load(full_path, weights_only=False)
model.load_state_dict(state_dict)
model.eval()
img = img.to(device)
start = time.perf_counter()
pred = model(img)
end = time.perf_counter()
print(f"Elapsed: {end - start:.6f} seconds")
return pred
+1
View File
@@ -0,0 +1 @@
python -m src.main server.run
+1
View File
@@ -0,0 +1 @@
uvicorn src.production.app:app --host 0.0.0.0 --port 8000 --ws-max-size 1048576