Spaces:
Running on Zero
Running on Zero
Upload 13 files
Browse files- .dockerignore +9 -0
- .gitignore +10 -0
- Dockerfile +23 -0
- README.md +199 -13
- README_HF_SPACE.md +37 -0
- app/__init__.py +0 -0
- app/main.py +140 -0
- data/train/PUT_IMAGES_HERE.txt +1 -0
- data/val/PUT_IMAGES_HERE.txt +1 -0
- requirements.txt +11 -0
- training/classes.txt +10 -0
- training/convert_coco_categories.py +19 -0
- training/train.py +280 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__
|
| 2 |
+
.git
|
| 3 |
+
.venv
|
| 4 |
+
venv
|
| 5 |
+
data
|
| 6 |
+
training
|
| 7 |
+
outputs
|
| 8 |
+
checkpoints
|
| 9 |
+
*.pyc
|
.gitignore
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.venv/
|
| 4 |
+
venv/
|
| 5 |
+
.env
|
| 6 |
+
model/
|
| 7 |
+
outputs/
|
| 8 |
+
checkpoints/
|
| 9 |
+
data/*/images/*
|
| 10 |
+
data/*/annotations.json
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PIP_NO_CACHE_DIR=1
|
| 5 |
+
ENV PORT=7860
|
| 6 |
+
|
| 7 |
+
WORKDIR /app
|
| 8 |
+
|
| 9 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 10 |
+
libglib2.0-0 \
|
| 11 |
+
libgl1 \
|
| 12 |
+
libgomp1 \
|
| 13 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 14 |
+
|
| 15 |
+
COPY requirements.txt .
|
| 16 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 17 |
+
|
| 18 |
+
COPY app ./app
|
| 19 |
+
COPY model ./model
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,13 +1,199 @@
|
|
| 1 |
-
-
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
-
|
| 12 |
-
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Ice Cream Freezer Counter — RT-DETR + Hugging Face
|
| 2 |
+
|
| 3 |
+
A complete object-detection project for counting individual ice creams in freezer photos and identifying each product type.
|
| 4 |
+
|
| 5 |
+
**No YOLO is used.** The detector is Hugging Face Transformers' RT-DETR (`PekingU/rtdetr_r50vd`), fine-tuned on your own annotated freezer images.
|
| 6 |
+
|
| 7 |
+
## What it does
|
| 8 |
+
|
| 9 |
+
Upload a freezer photo:
|
| 10 |
+
|
| 11 |
+
- detects every visible ice cream
|
| 12 |
+
- classifies each detected item (for example `cornetto`, `magnum`, `cone`, `cup`)
|
| 13 |
+
- counts items per class
|
| 14 |
+
- returns bounding boxes and confidence scores
|
| 15 |
+
- optionally returns an annotated image
|
| 16 |
+
|
| 17 |
+
Example response:
|
| 18 |
+
|
| 19 |
+
```json
|
| 20 |
+
{
|
| 21 |
+
"total": 17,
|
| 22 |
+
"counts": {
|
| 23 |
+
"cornetto": 7,
|
| 24 |
+
"magnum": 5,
|
| 25 |
+
"cone": 3,
|
| 26 |
+
"cup": 2
|
| 27 |
+
},
|
| 28 |
+
"detections": [
|
| 29 |
+
{
|
| 30 |
+
"class": "cornetto",
|
| 31 |
+
"confidence": 0.96,
|
| 32 |
+
"box": [120, 85, 210, 310]
|
| 33 |
+
}
|
| 34 |
+
]
|
| 35 |
+
}
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## 1. Define your classes
|
| 39 |
+
|
| 40 |
+
Edit `training/classes.txt`.
|
| 41 |
+
|
| 42 |
+
Example:
|
| 43 |
+
|
| 44 |
+
```text
|
| 45 |
+
cornetto
|
| 46 |
+
magnum
|
| 47 |
+
cone
|
| 48 |
+
cup
|
| 49 |
+
sandwich
|
| 50 |
+
stick
|
| 51 |
+
other
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Use the exact product names you want returned by the API. If "correto" is your actual product spelling, use `correto`; otherwise use the correct brand/product name.
|
| 55 |
+
|
| 56 |
+
## 2. Annotate your images
|
| 57 |
+
|
| 58 |
+
Use an annotation tool such as CVAT, Label Studio, Roboflow, or another COCO-compatible tool.
|
| 59 |
+
|
| 60 |
+
Annotate **each individual ice cream**, not just the freezer shelf.
|
| 61 |
+
|
| 62 |
+
Export in **COCO object-detection format**.
|
| 63 |
+
|
| 64 |
+
Expected layout:
|
| 65 |
+
|
| 66 |
+
```text
|
| 67 |
+
data/
|
| 68 |
+
train/
|
| 69 |
+
images/
|
| 70 |
+
0001.jpg
|
| 71 |
+
0002.jpg
|
| 72 |
+
annotations.json
|
| 73 |
+
val/
|
| 74 |
+
images/
|
| 75 |
+
1001.jpg
|
| 76 |
+
1002.jpg
|
| 77 |
+
annotations.json
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
The `annotations.json` files must be COCO detection JSON.
|
| 81 |
+
|
| 82 |
+
Important:
|
| 83 |
+
- draw one bounding box around each visible product
|
| 84 |
+
- assign the correct product class
|
| 85 |
+
- include partially visible products if you want them counted
|
| 86 |
+
- keep train and validation images separate
|
| 87 |
+
- avoid putting near-duplicate photos in both sets
|
| 88 |
+
|
| 89 |
+
A useful starting target is 500–2,000+ annotated images. More variation is usually more valuable than many almost-identical photos.
|
| 90 |
+
|
| 91 |
+
## 3. Install
|
| 92 |
+
|
| 93 |
+
Python 3.10+ is recommended.
|
| 94 |
+
|
| 95 |
+
```bash
|
| 96 |
+
pip install -r requirements.txt
|
| 97 |
+
```
|
| 98 |
+
|
| 99 |
+
For NVIDIA GPU training, install the appropriate PyTorch build for your CUDA version first, following the official PyTorch instructions.
|
| 100 |
+
|
| 101 |
+
## 4. Train
|
| 102 |
+
|
| 103 |
+
Make sure the dataset is in the structure above, then:
|
| 104 |
+
|
| 105 |
+
```bash
|
| 106 |
+
python training/train.py \
|
| 107 |
+
--train-dir data/train \
|
| 108 |
+
--val-dir data/val \
|
| 109 |
+
--output-dir model \
|
| 110 |
+
--epochs 30 \
|
| 111 |
+
--batch-size 2 \
|
| 112 |
+
--learning-rate 1e-5
|
| 113 |
+
```
|
| 114 |
+
|
| 115 |
+
For a small GPU, use `--batch-size 1`.
|
| 116 |
+
|
| 117 |
+
The script starts from `PekingU/rtdetr_r50vd` and replaces the classification head with your product classes.
|
| 118 |
+
|
| 119 |
+
At the end, `model/` contains the fine-tuned Hugging Face model and processor.
|
| 120 |
+
|
| 121 |
+
## 5. Test locally
|
| 122 |
+
|
| 123 |
+
```bash
|
| 124 |
+
python -m uvicorn app.main:app --host 0.0.0.0 --port 7860
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
Open:
|
| 128 |
+
|
| 129 |
+
```text
|
| 130 |
+
http://localhost:7860/docs
|
| 131 |
+
```
|
| 132 |
+
|
| 133 |
+
Use `POST /predict` to upload an image.
|
| 134 |
+
|
| 135 |
+
Or from Python:
|
| 136 |
+
|
| 137 |
+
```python
|
| 138 |
+
import requests
|
| 139 |
+
|
| 140 |
+
with open("freezer.jpg", "rb") as f:
|
| 141 |
+
r = requests.post(
|
| 142 |
+
"http://localhost:7860/predict",
|
| 143 |
+
files={"file": ("freezer.jpg", f, "image/jpeg")}
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
print(r.json())
|
| 147 |
+
```
|
| 148 |
+
|
| 149 |
+
## 6. Environment variables
|
| 150 |
+
|
| 151 |
+
The API supports:
|
| 152 |
+
|
| 153 |
+
```text
|
| 154 |
+
MODEL_ID=./model
|
| 155 |
+
CONFIDENCE_THRESHOLD=0.35
|
| 156 |
+
MAX_IMAGE_MB=15
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
If the model is stored on the Hugging Face Hub instead of the local filesystem:
|
| 160 |
+
|
| 161 |
+
```text
|
| 162 |
+
MODEL_ID=your-username/your-icecream-rtdetr
|
| 163 |
+
HF_TOKEN=hf_...
|
| 164 |
+
```
|
| 165 |
+
|
| 166 |
+
## 7. Deploy to Hugging Face
|
| 167 |
+
|
| 168 |
+
Create a Hugging Face **Docker Space** and upload this repository.
|
| 169 |
+
|
| 170 |
+
The included `Dockerfile` listens on port `7860`.
|
| 171 |
+
|
| 172 |
+
For a private model, add a Space secret named:
|
| 173 |
+
|
| 174 |
+
```text
|
| 175 |
+
HF_TOKEN
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
and set:
|
| 179 |
+
|
| 180 |
+
```text
|
| 181 |
+
MODEL_ID=your-username/your-icecream-rtdetr
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
If the model is public, `HF_TOKEN` is not required.
|
| 185 |
+
|
| 186 |
+
## Accuracy notes
|
| 187 |
+
|
| 188 |
+
Counting accuracy depends heavily on the training data. Freezer images have difficult cases: occlusion, reflections, small objects, similar packaging, tilted products, and products stacked behind each other.
|
| 189 |
+
|
| 190 |
+
Start with a confidence threshold around `0.35–0.50` and tune it on validation photos.
|
| 191 |
+
|
| 192 |
+
If two products are touching or one is heavily hidden, the detector can still miss or merge them. Add examples of those exact situations to the training set.
|
| 193 |
+
|
| 194 |
+
## Why RT-DETR?
|
| 195 |
+
|
| 196 |
+
RT-DETR is a transformer-based object detector available in Hugging Face Transformers. It produces object boxes and class labels and is suitable for instance counting. This project deliberately does not use YOLO.
|
| 197 |
+
|
| 198 |
+
Hugging Face reference:
|
| 199 |
+
https://huggingface.co/docs/transformers/model_doc/rt_detr
|
README_HF_SPACE.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Space configuration
|
| 2 |
+
|
| 3 |
+
Create a **Docker Space** and upload the project files.
|
| 4 |
+
|
| 5 |
+
The Space should contain:
|
| 6 |
+
|
| 7 |
+
```text
|
| 8 |
+
Dockerfile
|
| 9 |
+
requirements.txt
|
| 10 |
+
app/
|
| 11 |
+
model/
|
| 12 |
+
```
|
| 13 |
+
|
| 14 |
+
For a model stored on the Hugging Face Hub, you can instead omit the local `model/` directory and set the Space variables:
|
| 15 |
+
|
| 16 |
+
```text
|
| 17 |
+
MODEL_ID=YOUR_USERNAME/YOUR_MODEL_REPO
|
| 18 |
+
HF_TOKEN=hf_your_token_if_private
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
The API starts on port 7860.
|
| 22 |
+
|
| 23 |
+
Endpoints:
|
| 24 |
+
|
| 25 |
+
- `GET /health`
|
| 26 |
+
- `POST /predict`
|
| 27 |
+
- `GET /docs`
|
| 28 |
+
|
| 29 |
+
Example:
|
| 30 |
+
|
| 31 |
+
```bash
|
| 32 |
+
curl -X POST \
|
| 33 |
+
-F "file=@freezer.jpg" \
|
| 34 |
+
"https://YOUR-SPACE-NAME.hf.space/predict?return_image=true"
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
The `annotated_image_base64` field contains the annotated result image.
|
app/__init__.py
ADDED
|
File without changes
|
app/main.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import io
|
| 3 |
+
import os
|
| 4 |
+
from collections import Counter
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
from fastapi import FastAPI, File, HTTPException, UploadFile
|
| 8 |
+
from fastapi.responses import JSONResponse
|
| 9 |
+
from PIL import Image, ImageDraw, ImageFont
|
| 10 |
+
from transformers import RTDetrForObjectDetection, RTDetrImageProcessor
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
MODEL_ID = os.getenv("MODEL_ID", "./model")
|
| 14 |
+
CONFIDENCE_THRESHOLD = float(os.getenv("CONFIDENCE_THRESHOLD", "0.35"))
|
| 15 |
+
MAX_IMAGE_MB = int(os.getenv("MAX_IMAGE_MB", "15"))
|
| 16 |
+
|
| 17 |
+
DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 18 |
+
|
| 19 |
+
processor = RTDetrImageProcessor.from_pretrained(MODEL_ID)
|
| 20 |
+
model = RTDetrForObjectDetection.from_pretrained(MODEL_ID)
|
| 21 |
+
model.to(DEVICE)
|
| 22 |
+
model.eval()
|
| 23 |
+
|
| 24 |
+
app = FastAPI(
|
| 25 |
+
title="Ice Cream Counter API",
|
| 26 |
+
version="1.0.0",
|
| 27 |
+
description="RT-DETR ice cream product detector and counter. No YOLO.",
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@app.get("/health")
|
| 32 |
+
def health():
|
| 33 |
+
return {
|
| 34 |
+
"status": "ok",
|
| 35 |
+
"model": MODEL_ID,
|
| 36 |
+
"device": str(DEVICE),
|
| 37 |
+
"threshold": CONFIDENCE_THRESHOLD,
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def annotate(image, detections):
|
| 42 |
+
image = image.copy()
|
| 43 |
+
draw = ImageDraw.Draw(image)
|
| 44 |
+
|
| 45 |
+
try:
|
| 46 |
+
font = ImageFont.truetype("DejaVuSans.ttf", 18)
|
| 47 |
+
except Exception:
|
| 48 |
+
font = ImageFont.load_default()
|
| 49 |
+
|
| 50 |
+
for d in detections:
|
| 51 |
+
x1, y1, x2, y2 = d["box"]
|
| 52 |
+
label = f'{d["class"]} {d["confidence"]:.2f}'
|
| 53 |
+
draw.rectangle([x1, y1, x2, y2], outline="red", width=3)
|
| 54 |
+
|
| 55 |
+
bbox = draw.textbbox((x1, y1), label, font=font)
|
| 56 |
+
draw.rectangle(bbox, fill="red")
|
| 57 |
+
draw.text((x1, y1), label, fill="white", font=font)
|
| 58 |
+
|
| 59 |
+
return image
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@app.post("/predict")
|
| 63 |
+
async def predict(
|
| 64 |
+
file: UploadFile = File(...),
|
| 65 |
+
return_image: bool = True,
|
| 66 |
+
):
|
| 67 |
+
if not file.content_type or not file.content_type.startswith("image/"):
|
| 68 |
+
raise HTTPException(400, "Upload a JPG, PNG, WEBP, or other image file.")
|
| 69 |
+
|
| 70 |
+
raw = await file.read()
|
| 71 |
+
|
| 72 |
+
if len(raw) > MAX_IMAGE_MB * 1024 * 1024:
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
413,
|
| 75 |
+
f"Image is too large. Maximum is {MAX_IMAGE_MB} MB.",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
try:
|
| 79 |
+
image = Image.open(io.BytesIO(raw)).convert("RGB")
|
| 80 |
+
except Exception as exc:
|
| 81 |
+
raise HTTPException(400, f"Could not read image: {exc}")
|
| 82 |
+
|
| 83 |
+
inputs = processor(images=image, return_tensors="pt")
|
| 84 |
+
inputs = {
|
| 85 |
+
k: v.to(DEVICE) if torch.is_tensor(v) else v
|
| 86 |
+
for k, v in inputs.items()
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
with torch.inference_mode():
|
| 90 |
+
outputs = model(**inputs)
|
| 91 |
+
|
| 92 |
+
target_sizes = torch.tensor(
|
| 93 |
+
[[image.height, image.width]],
|
| 94 |
+
device=DEVICE,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
result = processor.post_process_object_detection(
|
| 98 |
+
outputs,
|
| 99 |
+
threshold=CONFIDENCE_THRESHOLD,
|
| 100 |
+
target_sizes=target_sizes,
|
| 101 |
+
)[0]
|
| 102 |
+
|
| 103 |
+
detections = []
|
| 104 |
+
counts = Counter()
|
| 105 |
+
|
| 106 |
+
for score, label, box in zip(
|
| 107 |
+
result["scores"],
|
| 108 |
+
result["labels"],
|
| 109 |
+
result["boxes"],
|
| 110 |
+
):
|
| 111 |
+
score_value = float(score.item())
|
| 112 |
+
label_id = int(label.item())
|
| 113 |
+
class_name = model.config.id2label[label_id]
|
| 114 |
+
|
| 115 |
+
coords = [round(float(x), 2) for x in box.tolist()]
|
| 116 |
+
|
| 117 |
+
detections.append(
|
| 118 |
+
{
|
| 119 |
+
"class": class_name,
|
| 120 |
+
"confidence": round(score_value, 4),
|
| 121 |
+
"box": coords,
|
| 122 |
+
}
|
| 123 |
+
)
|
| 124 |
+
counts[class_name] += 1
|
| 125 |
+
|
| 126 |
+
response = {
|
| 127 |
+
"total": len(detections),
|
| 128 |
+
"counts": dict(sorted(counts.items())),
|
| 129 |
+
"detections": detections,
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
if return_image:
|
| 133 |
+
annotated = annotate(image, detections)
|
| 134 |
+
buf = io.BytesIO()
|
| 135 |
+
annotated.save(buf, format="JPEG", quality=90)
|
| 136 |
+
response["annotated_image_base64"] = base64.b64encode(
|
| 137 |
+
buf.getvalue()
|
| 138 |
+
).decode("ascii")
|
| 139 |
+
|
| 140 |
+
return JSONResponse(response)
|
data/train/PUT_IMAGES_HERE.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Put training images in this directory's images/ subdirectory and COCO annotations in annotations.json.
|
data/val/PUT_IMAGES_HERE.txt
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
Put validation images in this directory's images/ subdirectory and COCO annotations in annotations.json.
|
requirements.txt
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
torch>=2.3
|
| 2 |
+
torchvision>=0.18
|
| 3 |
+
transformers>=4.50
|
| 4 |
+
huggingface_hub>=0.25
|
| 5 |
+
Pillow>=10.0
|
| 6 |
+
fastapi>=0.115
|
| 7 |
+
uvicorn[standard]>=0.30
|
| 8 |
+
python-multipart>=0.0.9
|
| 9 |
+
numpy>=1.26
|
| 10 |
+
tqdm>=4.66
|
| 11 |
+
pycocotools>=2.0.8
|
training/classes.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Carnavalita
|
| 2 |
+
Kimo-COno
|
| 3 |
+
Squizz
|
| 4 |
+
Oreo
|
| 5 |
+
Moro
|
| 6 |
+
Dulce
|
| 7 |
+
KitKat
|
| 8 |
+
Cadbury
|
| 9 |
+
Mega
|
| 10 |
+
other
|
training/convert_coco_categories.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Optional helper.
|
| 3 |
+
|
| 4 |
+
Your COCO exporter should already contain the same categories in train and val.
|
| 5 |
+
This script prints the category IDs/names so you can compare them with classes.txt.
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python training/convert_coco_categories.py data/train/annotations.json
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import json
|
| 12 |
+
import sys
|
| 13 |
+
|
| 14 |
+
if len(sys.argv) != 2:
|
| 15 |
+
raise SystemExit("Usage: python training/convert_coco_categories.py annotations.json")
|
| 16 |
+
|
| 17 |
+
data = json.load(open(sys.argv[1], "r", encoding="utf-8"))
|
| 18 |
+
for cat in sorted(data["categories"], key=lambda x: x["id"]):
|
| 19 |
+
print(f'{cat["id"]}: {cat["name"]}')
|
training/train.py
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import torch
|
| 6 |
+
from PIL import Image
|
| 7 |
+
from torch.utils.data import Dataset, DataLoader
|
| 8 |
+
from tqdm import tqdm
|
| 9 |
+
from transformers import RTDetrImageProcessor, RTDetrForObjectDetection
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
BASE_MODEL = "PekingU/rtdetr_r50vd"
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def load_classes(path):
|
| 16 |
+
classes = [x.strip() for x in Path(path).read_text().splitlines() if x.strip()]
|
| 17 |
+
if not classes:
|
| 18 |
+
raise ValueError("classes.txt is empty")
|
| 19 |
+
return classes
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class COCODetectionDataset(Dataset):
|
| 23 |
+
def __init__(self, image_dir, annotation_file, processor):
|
| 24 |
+
self.image_dir = Path(image_dir)
|
| 25 |
+
self.processor = processor
|
| 26 |
+
|
| 27 |
+
coco = json.loads(Path(annotation_file).read_text())
|
| 28 |
+
self.images = {x["id"]: x for x in coco["images"]}
|
| 29 |
+
|
| 30 |
+
# The training model needs contiguous labels 0..N-1.
|
| 31 |
+
# categories.json is converted here from the COCO category IDs.
|
| 32 |
+
categories = sorted(coco["categories"], key=lambda x: x["id"])
|
| 33 |
+
self.category_id_to_label = {
|
| 34 |
+
cat["id"]: i for i, cat in enumerate(categories)
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
anns_by_image = {}
|
| 38 |
+
for ann in coco["annotations"]:
|
| 39 |
+
if ann.get("iscrowd", 0):
|
| 40 |
+
continue
|
| 41 |
+
anns_by_image.setdefault(ann["image_id"], []).append(ann)
|
| 42 |
+
|
| 43 |
+
self.records = []
|
| 44 |
+
for image_id, info in self.images.items():
|
| 45 |
+
self.records.append(
|
| 46 |
+
{
|
| 47 |
+
"image_id": image_id,
|
| 48 |
+
"file_name": info["file_name"],
|
| 49 |
+
"width": info["width"],
|
| 50 |
+
"height": info["height"],
|
| 51 |
+
"annotations": anns_by_image.get(image_id, []),
|
| 52 |
+
}
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def __len__(self):
|
| 56 |
+
return len(self.records)
|
| 57 |
+
|
| 58 |
+
def __getitem__(self, idx):
|
| 59 |
+
record = self.records[idx]
|
| 60 |
+
image_path = self.image_dir / record["file_name"]
|
| 61 |
+
image = Image.open(image_path).convert("RGB")
|
| 62 |
+
|
| 63 |
+
annotations = []
|
| 64 |
+
for ann in record["annotations"]:
|
| 65 |
+
x, y, w, h = ann["bbox"]
|
| 66 |
+
if w <= 0 or h <= 0:
|
| 67 |
+
continue
|
| 68 |
+
|
| 69 |
+
label = self.category_id_to_label[ann["category_id"]]
|
| 70 |
+
annotations.append(
|
| 71 |
+
{
|
| 72 |
+
"id": ann["id"],
|
| 73 |
+
"image_id": record["image_id"],
|
| 74 |
+
"category_id": label,
|
| 75 |
+
"bbox": [x, y, w, h],
|
| 76 |
+
"area": float(ann.get("area", w * h)),
|
| 77 |
+
"iscrowd": 0,
|
| 78 |
+
}
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
target = {
|
| 82 |
+
"image_id": record["image_id"],
|
| 83 |
+
"annotations": annotations,
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
encoded = self.processor(
|
| 87 |
+
images=image,
|
| 88 |
+
annotations=target,
|
| 89 |
+
return_tensors="pt",
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Remove batch dimension. DataLoader will create it.
|
| 93 |
+
encoded["pixel_values"] = encoded["pixel_values"].squeeze(0)
|
| 94 |
+
if "pixel_mask" in encoded:
|
| 95 |
+
encoded["pixel_mask"] = encoded["pixel_mask"].squeeze(0)
|
| 96 |
+
encoded["labels"] = encoded["labels"][0]
|
| 97 |
+
return encoded
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def collate_fn(batch):
|
| 101 |
+
pixel_values = torch.stack([x["pixel_values"] for x in batch])
|
| 102 |
+
pixel_mask = None
|
| 103 |
+
if "pixel_mask" in batch[0]:
|
| 104 |
+
pixel_mask = torch.stack([x["pixel_mask"] for x in batch])
|
| 105 |
+
|
| 106 |
+
labels = [x["labels"] for x in batch]
|
| 107 |
+
result = {"pixel_values": pixel_values, "labels": labels}
|
| 108 |
+
if pixel_mask is not None:
|
| 109 |
+
result["pixel_mask"] = pixel_mask
|
| 110 |
+
return result
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def evaluate(model, loader, device):
|
| 114 |
+
model.eval()
|
| 115 |
+
total = 0.0
|
| 116 |
+
count = 0
|
| 117 |
+
with torch.no_grad():
|
| 118 |
+
for batch in loader:
|
| 119 |
+
batch = {
|
| 120 |
+
k: (v.to(device) if torch.is_tensor(v) else v)
|
| 121 |
+
for k, v in batch.items()
|
| 122 |
+
}
|
| 123 |
+
out = model(**batch)
|
| 124 |
+
total += float(out.loss.item())
|
| 125 |
+
count += 1
|
| 126 |
+
model.train()
|
| 127 |
+
return total / max(count, 1)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def main():
|
| 131 |
+
parser = argparse.ArgumentParser()
|
| 132 |
+
parser.add_argument("--train-dir", default="data/train")
|
| 133 |
+
parser.add_argument("--val-dir", default="data/val")
|
| 134 |
+
parser.add_argument("--classes", default="training/classes.txt")
|
| 135 |
+
parser.add_argument("--output-dir", default="model")
|
| 136 |
+
parser.add_argument("--epochs", type=int, default=30)
|
| 137 |
+
parser.add_argument("--batch-size", type=int, default=2)
|
| 138 |
+
parser.add_argument("--learning-rate", type=float, default=1e-5)
|
| 139 |
+
parser.add_argument("--weight-decay", type=float, default=1e-4)
|
| 140 |
+
parser.add_argument("--num-workers", type=int, default=2)
|
| 141 |
+
parser.add_argument("--grad-accumulation", type=int, default=1)
|
| 142 |
+
parser.add_argument("--resume", default=None)
|
| 143 |
+
args = parser.parse_args()
|
| 144 |
+
|
| 145 |
+
classes = load_classes(args.classes)
|
| 146 |
+
label2id = {name: i for i, name in enumerate(classes)}
|
| 147 |
+
id2label = {i: name for i, name in enumerate(classes)}
|
| 148 |
+
|
| 149 |
+
train_ann = Path(args.train_dir) / "annotations.json"
|
| 150 |
+
val_ann = Path(args.val_dir) / "annotations.json"
|
| 151 |
+
|
| 152 |
+
processor = RTDetrImageProcessor.from_pretrained(BASE_MODEL)
|
| 153 |
+
|
| 154 |
+
train_ds = COCODetectionDataset(
|
| 155 |
+
Path(args.train_dir) / "images",
|
| 156 |
+
train_ann,
|
| 157 |
+
processor,
|
| 158 |
+
)
|
| 159 |
+
val_ds = COCODetectionDataset(
|
| 160 |
+
Path(args.val_dir) / "images",
|
| 161 |
+
val_ann,
|
| 162 |
+
processor,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Make sure both datasets use exactly the class list supplied by the user.
|
| 166 |
+
train_categories = len(train_ds.category_id_to_label)
|
| 167 |
+
val_categories = len(val_ds.category_id_to_label)
|
| 168 |
+
if train_categories != len(classes) or val_categories != len(classes):
|
| 169 |
+
raise ValueError(
|
| 170 |
+
"The number of COCO categories does not match classes.txt. "
|
| 171 |
+
f"classes.txt={len(classes)}, train={train_categories}, val={val_categories}"
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
model = RTDetrForObjectDetection.from_pretrained(
|
| 175 |
+
BASE_MODEL,
|
| 176 |
+
num_labels=len(classes),
|
| 177 |
+
id2label=id2label,
|
| 178 |
+
label2id=label2id,
|
| 179 |
+
ignore_mismatched_sizes=True,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 183 |
+
model.to(device)
|
| 184 |
+
|
| 185 |
+
train_loader = DataLoader(
|
| 186 |
+
train_ds,
|
| 187 |
+
batch_size=args.batch_size,
|
| 188 |
+
shuffle=True,
|
| 189 |
+
num_workers=args.num_workers,
|
| 190 |
+
collate_fn=collate_fn,
|
| 191 |
+
pin_memory=torch.cuda.is_available(),
|
| 192 |
+
)
|
| 193 |
+
val_loader = DataLoader(
|
| 194 |
+
val_ds,
|
| 195 |
+
batch_size=args.batch_size,
|
| 196 |
+
shuffle=False,
|
| 197 |
+
num_workers=args.num_workers,
|
| 198 |
+
collate_fn=collate_fn,
|
| 199 |
+
pin_memory=torch.cuda.is_available(),
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
optimizer = torch.optim.AdamW(
|
| 203 |
+
model.parameters(),
|
| 204 |
+
lr=args.learning_rate,
|
| 205 |
+
weight_decay=args.weight_decay,
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
scaler = torch.amp.GradScaler("cuda", enabled=torch.cuda.is_available())
|
| 209 |
+
start_epoch = 0
|
| 210 |
+
|
| 211 |
+
if args.resume:
|
| 212 |
+
checkpoint = torch.load(args.resume, map_location=device)
|
| 213 |
+
model.load_state_dict(checkpoint["model"])
|
| 214 |
+
optimizer.load_state_dict(checkpoint["optimizer"])
|
| 215 |
+
start_epoch = checkpoint["epoch"] + 1
|
| 216 |
+
|
| 217 |
+
output_dir = Path(args.output_dir)
|
| 218 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 219 |
+
|
| 220 |
+
best_val = float("inf")
|
| 221 |
+
|
| 222 |
+
for epoch in range(start_epoch, args.epochs):
|
| 223 |
+
model.train()
|
| 224 |
+
optimizer.zero_grad(set_to_none=True)
|
| 225 |
+
running = 0.0
|
| 226 |
+
|
| 227 |
+
pbar = tqdm(train_loader, desc=f"Epoch {epoch + 1}/{args.epochs}")
|
| 228 |
+
for step, batch in enumerate(pbar):
|
| 229 |
+
batch = {
|
| 230 |
+
k: (v.to(device) if torch.is_tensor(v) else v)
|
| 231 |
+
for k, v in batch.items()
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
with torch.amp.autocast(
|
| 235 |
+
device_type="cuda",
|
| 236 |
+
enabled=torch.cuda.is_available(),
|
| 237 |
+
):
|
| 238 |
+
out = model(**batch)
|
| 239 |
+
loss = out.loss / args.grad_accumulation
|
| 240 |
+
|
| 241 |
+
scaler.scale(loss).backward()
|
| 242 |
+
|
| 243 |
+
if (step + 1) % args.grad_accumulation == 0:
|
| 244 |
+
scaler.step(optimizer)
|
| 245 |
+
scaler.update()
|
| 246 |
+
optimizer.zero_grad(set_to_none=True)
|
| 247 |
+
|
| 248 |
+
running += float(loss.item()) * args.grad_accumulation
|
| 249 |
+
pbar.set_postfix(loss=f"{running / (step + 1):.4f}")
|
| 250 |
+
|
| 251 |
+
val_loss = evaluate(model, val_loader, device)
|
| 252 |
+
print(f"epoch={epoch + 1} validation_loss={val_loss:.4f}")
|
| 253 |
+
|
| 254 |
+
checkpoint = {
|
| 255 |
+
"epoch": epoch,
|
| 256 |
+
"model": model.state_dict(),
|
| 257 |
+
"optimizer": optimizer.state_dict(),
|
| 258 |
+
}
|
| 259 |
+
torch.save(checkpoint, output_dir / "last_checkpoint.pt")
|
| 260 |
+
|
| 261 |
+
if val_loss < best_val:
|
| 262 |
+
best_val = val_loss
|
| 263 |
+
model.save_pretrained(output_dir)
|
| 264 |
+
processor.save_pretrained(output_dir)
|
| 265 |
+
(output_dir / "classes.json").write_text(
|
| 266 |
+
json.dumps(
|
| 267 |
+
{"id2label": id2label, "label2id": label2id},
|
| 268 |
+
indent=2,
|
| 269 |
+
)
|
| 270 |
+
)
|
| 271 |
+
print(f"Saved best model to {output_dir}")
|
| 272 |
+
|
| 273 |
+
# Always save the final model as well.
|
| 274 |
+
model.save_pretrained(output_dir)
|
| 275 |
+
processor.save_pretrained(output_dir)
|
| 276 |
+
print(f"Final model saved to {output_dir}")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
main()
|