dmtschulz commited on
Commit
a33081a
·
1 Parent(s): 62b7a95

Initial push

Browse files
Files changed (9) hide show
  1. .gitignore +5 -0
  2. Dockerfile +15 -0
  3. README.md +52 -13
  4. app.py +76 -0
  5. app/main.py +95 -0
  6. data/sample_mnist.png +0 -0
  7. docker-compose.yml +12 -0
  8. requirements.txt +14 -0
  9. src/train.py +125 -0
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pth
3
+ data/MNIST
4
+ models/
5
+ .DS_Store
Dockerfile ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dockerfile
2
+
3
+ FROM python:3.10-slim
4
+
5
+ # Install dependencies
6
+ RUN apt-get update && apt-get install -y git && rm -rf /var/lib/apt/lists/*
7
+
8
+ WORKDIR /app
9
+
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ COPY . .
14
+
15
+ CMD ["python", "app.py"]
README.md CHANGED
@@ -1,13 +1,52 @@
1
- ---
2
- title: Anomaly Detection As A Service
3
- emoji: 📈
4
- colorFrom: green
5
- colorTo: indigo
6
- sdk: gradio
7
- sdk_version: 5.33.2
8
- app_file: app.py
9
- pinned: false
10
- short_description: Anomaly Detection on MNIST
11
- ---
12
-
13
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 🧠 Anomaly Detection as a Service
2
+
3
+ A minimalistic anomaly detection microservice powered by an autoencoder trained on MNIST-style grayscale images.
4
+ This project provides both a backend API and a Gradio web interface to analyze images and identify abnormal patterns.
5
+
6
+ ## 🚀 Features
7
+
8
+ - 🧪 **FastAPI** backend for anomaly score prediction
9
+ - 🎛️ **Gradio** frontend with drag-and-drop image testing
10
+ - 🔥 Visual output includes anomaly score, reconstruction, and heatmap
11
+ - ⚙️ Pretrained autoencoder (trained on MNIST digit-style grayscale images)
12
+ - 🧰 Easily extendable to new datasets and model architectures
13
+
14
+
15
+ ## ⚙️ Setup
16
+
17
+ 1. Install dependencies:
18
+ ```
19
+ pip install -r requirements.txt
20
+ ```
21
+ 2. (Optional) Train the model from scratch:
22
+ ```
23
+ python src/train.py
24
+ ```
25
+ By default, the model will be saved to `models/autoencoder_mnist.pth`.
26
+
27
+ ## 🧪 Run the API Server (FastAPI)
28
+ ```
29
+ uvicorn app.main:app --reload
30
+ ```
31
+
32
+ This starts the API at http://127.0.0.1:8000
33
+
34
+ You can test it using Swagger UI:
35
+ http://127.0.0.1:8000/docs
36
+
37
+ ## 🎛️ Run the Gradio Frontend
38
+ ```
39
+ python app.py
40
+ ```
41
+
42
+ This launches an interactive browser interface for:
43
+
44
+ - Uploading images
45
+ - Viewing anomaly scores
46
+ - Seeing reconstructions and heatmaps side-by-side
47
+
48
+ ## 📝 Notes
49
+ - Input images must be grayscale, 28×28 pixels.
50
+ - To adapt to a custom dataset, modify src/train.py accordingly and retrain.
51
+ - The project is meant for educational/demo purposes and not intended for production deployment.
52
+
app.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gradio as gr
2
+ import torch
3
+ from torchvision import transforms as T
4
+ from PIL import Image
5
+ import numpy as np
6
+ import base64
7
+ from io import BytesIO
8
+ import matplotlib.pyplot as plt
9
+ from src.train import load_model, loss_fn
10
+
11
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
12
+ model = load_model("./models/autoencoder_mnist.pth")
13
+ model.eval()
14
+
15
+ transform = T.Compose([
16
+ T.Grayscale(num_output_channels=1),
17
+ T.Resize((28, 28)),
18
+ T.ToTensor()
19
+ ])
20
+
21
+ def predict(image):
22
+ original = image.convert("L")
23
+ tensor = transform(original).unsqueeze(0).to(DEVICE)
24
+ original_resized = original.resize((200, 200), Image.NEAREST)
25
+
26
+ with torch.no_grad():
27
+ decoded = model(tensor)
28
+ score = loss_fn(decoded, tensor).item()
29
+ diff = (decoded - tensor).squeeze().cpu().numpy() ** 2
30
+ reconstructed = decoded.squeeze().cpu().numpy()
31
+
32
+ # Anomaly interpretation
33
+ if score > 0.02:
34
+ verdict = "🚨 Anomaly detected!"
35
+ else:
36
+ verdict = "✅ Everything looks normal."
37
+
38
+ # Create heatmap
39
+ fig, ax = plt.subplots()
40
+ ax.imshow(diff, cmap="hot")
41
+ ax.axis("off")
42
+ buf1 = BytesIO()
43
+ plt.savefig(buf1, format="png", bbox_inches="tight", pad_inches=0)
44
+ plt.close(fig)
45
+ buf1.seek(0)
46
+ heatmap = Image.open(buf1)
47
+
48
+ # Reconstructed image
49
+ fig2, ax2 = plt.subplots()
50
+ ax2.imshow(reconstructed, cmap="gray")
51
+ ax2.axis("off")
52
+ buf2 = BytesIO()
53
+ plt.savefig(buf2, format="png", bbox_inches="tight", pad_inches=0)
54
+ plt.close(fig2)
55
+ buf2.seek(0)
56
+ reconstructed_img = Image.open(buf2)
57
+
58
+ return f"{score:.5f}", verdict, original_resized, reconstructed_img, heatmap
59
+
60
+
61
+ iface = gr.Interface(
62
+ fn=predict,
63
+ inputs=gr.Image(type="pil", label="Upload MNIST-style Image"),
64
+ outputs=[
65
+ gr.Textbox(label="Anomaly Score"),
66
+ gr.Textbox(label="Verdict"),
67
+ gr.Image(label="Original", height=200),
68
+ gr.Image(label="Reconstruction", height=200),
69
+ gr.Image(label="Heatmap", height=200),
70
+ ],
71
+ allow_flagging="never",
72
+ title="🧠 Anomaly Detection",
73
+ description="Upload a grayscale image (28x28) to get anomaly score."
74
+ )
75
+
76
+ iface.launch(iface.launch(server_name="0.0.0.0", server_port=7860))
app/main.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # app/main.py
2
+
3
+ import os
4
+ from fastapi import FastAPI, UploadFile, HTTPException
5
+ from fastapi.responses import JSONResponse
6
+ from PIL import Image
7
+ import torch
8
+ from torchvision import transforms as T
9
+ from src.train import load_model, loss_fn # Load the model and loss function from src/train.py
10
+ import io
11
+ from io import BytesIO
12
+
13
+ import base64
14
+ import matplotlib.pyplot as plt
15
+
16
+ import logging
17
+ log = logging.getLogger(__name__)
18
+ logging.basicConfig(level=logging.INFO)
19
+
20
+ from huggingface_hub import hf_hub_download
21
+
22
+ app = FastAPI()
23
+
24
+ # Device configuration
25
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
26
+
27
+ # Load the model once
28
+ MODEL_PATH = hf_hub_download(repo_id="dmtschulz/anomaly-detection-model", filename="autoencoder_mnist.pth")
29
+
30
+ log.info("Model loaded. App is ready.")
31
+
32
+ model = load_model(MODEL_PATH)
33
+ model.eval()
34
+
35
+ # Transformations
36
+ transform = T.Compose([
37
+ T.Grayscale(num_output_channels=1),
38
+ T.Resize((28, 28)),
39
+ T.ToTensor()
40
+ ])
41
+
42
+ @app.get("/health")
43
+ def health():
44
+ return {"status": "ok"}
45
+
46
+ @app.post("/predict", summary="Predict anomaly score from image")
47
+ async def predict(file: UploadFile):
48
+ try:
49
+ contents = await file.read()
50
+ image = Image.open(io.BytesIO(contents)).convert("L")
51
+ image = transform(image)
52
+ image = image.unsqueeze(0).to(DEVICE)
53
+
54
+ with torch.no_grad():
55
+ decoded = model(image)
56
+ score = loss_fn(decoded, image).item()
57
+ diff = (decoded - image).squeeze().cpu().numpy() ** 2
58
+
59
+ # Create heatmap image
60
+ fig, ax = plt.subplots()
61
+ ax.imshow(diff, cmap="hot")
62
+ ax.axis("off")
63
+ buf = BytesIO()
64
+ plt.savefig(buf, format="png", bbox_inches="tight", pad_inches=0)
65
+ plt.close(fig)
66
+ buf.seek(0)
67
+
68
+ # Encode to base64
69
+ heatmap_b64 = base64.b64encode(buf.read()).decode("utf-8")
70
+
71
+ # Create reconstructed image
72
+ decoded_img = decoded.squeeze().cpu().numpy()
73
+
74
+ # Visualize the decoded image as png
75
+ fig2, ax2 = plt.subplots()
76
+ ax2.imshow(decoded_img, cmap="gray")
77
+ ax2.axis("off")
78
+ buf2 = BytesIO()
79
+ plt.savefig(buf2, format="png", bbox_inches="tight", pad_inches=0)
80
+ plt.close(fig2)
81
+ buf2.seek(0)
82
+
83
+ # Decode to base64
84
+ decoded_b64 = base64.b64encode(buf2.read()).decode("utf-8")
85
+
86
+ # Return everything as JSON
87
+ return JSONResponse(content={
88
+ "anomaly_score": score,
89
+ "heatmap": heatmap_b64,
90
+ "decoded_image": decoded_b64
91
+ })
92
+
93
+ except Exception as e:
94
+ raise HTTPException(status_code=500, detail=f"Unexpected error: {str(e)}")
95
+
data/sample_mnist.png ADDED
docker-compose.yml ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ version: "3.8"
2
+
3
+ services:
4
+ frontend:
5
+ build: .
6
+ ports:
7
+ - "7860:7860"
8
+ volumes:
9
+ - .:/app
10
+ environment:
11
+ - PYTHONUNBUFFERED=1
12
+ restart: unless-stopped
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch>=2.0.0
2
+ fastapi
3
+ gradio
4
+ numpy
5
+ pandas
6
+ requests
7
+ uvicorn
8
+ pillow
9
+ matplotlib
10
+ plotly
11
+ scikit-learn
12
+ tqdm
13
+ python-multipart
14
+ torchvision
src/train.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # src/train.py
2
+
3
+ import os
4
+ import torch
5
+ import numpy as np
6
+ from tqdm import tqdm
7
+ from torch import nn, optim
8
+ from torch.utils.data import DataLoader, Subset
9
+ from torchvision import transforms as T
10
+ from torchvision.datasets import MNIST
11
+ from torchvision.utils import save_image
12
+
13
+ import logging
14
+
15
+ logging.basicConfig(level=logging.INFO)
16
+ log = logging.getLogger(__name__)
17
+
18
+ # Device configuration
19
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
20
+ log.info("Using device: %s", DEVICE)
21
+
22
+ # Constants
23
+ DATA_ROOT = "./data"
24
+ MODEL_DIR = "./models"
25
+
26
+ os.makedirs(DATA_ROOT, exist_ok=True)
27
+ os.makedirs(MODEL_DIR, exist_ok=True)
28
+
29
+ # Autoencoder Model
30
+ class Autoencoder(nn.Module):
31
+ def __init__(self, h: int = 32):
32
+ super().__init__()
33
+ self.encoder = nn.Sequential(
34
+ nn.Flatten(),
35
+ nn.Linear(784, 12000),
36
+ nn.Dropout(0.2),
37
+ nn.LeakyReLU(),
38
+ nn.Linear(12000, h),
39
+ nn.LeakyReLU(),
40
+ )
41
+ self.decoder = nn.Sequential(
42
+ nn.Linear(h, 12000),
43
+ nn.Dropout(0.2),
44
+ nn.LeakyReLU(),
45
+ nn.Linear(12000, 784),
46
+ nn.Sigmoid(),
47
+ )
48
+
49
+ def forward(self, x: torch.Tensor):
50
+ x = x.view(x.size(0), -1) # (N, 784)
51
+ encoded = self.encoder(x)
52
+ decoded = self.decoder(encoded)
53
+ decoded = decoded.view(-1, 1, 28, 28)
54
+ return decoded
55
+
56
+ # Loss function
57
+ loss_fn = nn.MSELoss()
58
+
59
+ # Train function
60
+ def train(loader, h=32, epochs=5, save_path=None):
61
+ model = Autoencoder(h).to(DEVICE)
62
+ optimizer = optim.Adam(model.parameters(), lr=0.001)
63
+
64
+ for epoch in range(epochs):
65
+ log.info("Epoch %d", epoch + 1)
66
+ pbar = tqdm(loader)
67
+ for batch, _ in pbar:
68
+ batch = batch.to(DEVICE)
69
+ optimizer.zero_grad()
70
+ prediction = model(batch)
71
+ loss = loss_fn(prediction, batch)
72
+ loss.backward()
73
+ optimizer.step()
74
+ pbar.set_description(f"Loss: {loss.item():.4f}")
75
+
76
+ if save_path:
77
+ save_model(model, save_path)
78
+
79
+ return model
80
+
81
+ # Save model
82
+ def save_model(model, path):
83
+ torch.save(model.state_dict(), path)
84
+ log.info("Model saved to %s", path)
85
+
86
+
87
+ # Load model
88
+ def load_model(path, h=32):
89
+ model = Autoencoder(h).to(DEVICE)
90
+ model.load_state_dict(torch.load(path, map_location=DEVICE))
91
+ model.to(DEVICE)
92
+ model.eval()
93
+ log.info("Model loaded from %s", path)
94
+ return model
95
+
96
+ # Get DataLoaders
97
+ def get_data_loaders(batch_size=128, anomaly_class_threshold=None):
98
+ transform = T.ToTensor()
99
+ train_dataset = MNIST(DATA_ROOT, train=True, download=True, transform=transform)
100
+ test_dataset = MNIST(DATA_ROOT, train=False, download=True, transform=transform)
101
+
102
+ if anomaly_class_threshold is not None:
103
+ indices = np.where(train_dataset.targets < anomaly_class_threshold)[0]
104
+ train_dataset = Subset(train_dataset, indices)
105
+
106
+ train_loader = DataLoader(train_dataset, batch_size=batch_size, shuffle=True)
107
+ test_loader = DataLoader(test_dataset, batch_size=batch_size)
108
+
109
+ img, _ = test_dataset[6]
110
+ save_image(img, "data/sample_mnist.png")
111
+
112
+ return train_loader, test_loader
113
+
114
+ # Predict Anomaly Score
115
+ def predict_anomaly_score(model, x):
116
+ model.eval()
117
+ with torch.no_grad():
118
+ reconstruction = model(x.to(DEVICE))
119
+ score = torch.mean((x - reconstruction.cpu()) ** 2, dim=(1, 2, 3))
120
+ return score.cpu().numpy()
121
+
122
+ # Main for getting data and testing the model
123
+ if __name__ == "__main__":
124
+ train_loader, test_loader = get_data_loaders()
125
+ model = train(train_loader, h=32, epochs=5, save_path=f"{MODEL_DIR}/autoencoder_mnist.pth")