Compare commits

...
11 Commits
Author SHA1 Message Date
marvin ee56726fb3 added protfolio network 2026-09-25 07:32:39 +02:00
marvin d556aa3f2f fixed config 2026-09-25 06:29:49 +02:00
marvin 314d33c18f added gitea network to caddy 2026-09-25 06:23:03 +02:00
marvinandClaude Sonnet 5 d634573dd6 Add .dockerignore files for api and caddy
Both use an allowlist so only the files each Dockerfile copies are sent
to the build context. For caddy this keeps the TLS certificates and keys
in data/ and config/ out of the context; for api it skips training code,
datasets, saved models and caches.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:03:26 +02:00
marvinandClaude Sonnet 5 121d8a8ab4 Make upload limit and Caddy settings configurable via env vars
- API: read the upload size limit from MAX_UPLOAD_MEGABYTES (default 15)
  and keep MAX_UPLOAD_BYTES as the byte value used by /predict
- Caddy: read the ACME email and request body limit from CADDY_EMAIL and
  CADDY_MAX_BODY (default 16MB, ~1 MB above the API limit)
- compose: pass the new variables to caddy_proxy and cnn_api

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 19:01:42 +02:00
marvin 79db918564 changed server limits 2026-09-24 11:20:41 +02:00
marvin 3d5de2eca0 added env variables for server limits 2026-09-24 11:15:20 +02:00
marvin 7372c203b9 changed text 2026-09-24 06:01:09 +02:00
marvin e266baaeb9 changes face detection fps 2026-09-24 05:57:57 +02:00
marvin b36a950f85 changed compression quality 2026-09-24 05:53:31 +02:00
marvin 291159a819 added compression for images on client side 2026-09-24 05:49:28 +02:00
7 changed files with 130 additions and 24 deletions
+13
View File
@@ -0,0 +1,13 @@
# Allowlist: the Dockerfile only copies these three things, so nothing
# else (training code, datasets, saved models, caches) is sent to the daemon.
*
!requirements.txt
!src/production
!build_models
# Re-exclude junk inside the allowed paths
**/__pycache__
**/*.pyc
**/*.bat
**/.env
**/*.env
+39 -15
View File
@@ -38,17 +38,22 @@ IMAGE_SIZE_CNN = 64
IMAGE_SIZE_YOLO = 64
# ---- Resource limits (the server is weak, keep everything small and bounded) ----
TORCH_THREADS = int(os.getenv("TORCH_THREADS", "1"))
MAX_PENDING_INFERENCES = int(os.getenv("MAX_PENDING_INFERENCES", "2")) # running + waiting
MAX_UPLOAD_BYTES = 5 * 1024 * 1024
# 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_MIN_INTERVAL = 1 / 2.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
torch.set_num_threads(TORCH_THREADS)
Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS
origins = [
@@ -165,8 +170,14 @@ class RateLimiter:
return True
predict_limiter = RateLimiter(limit=10, window=60)
review_limiter = RateLimiter(limit=5, window=60)
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):
@@ -180,15 +191,28 @@ class Busy(Exception):
pass
class InferenceGate:
"""One worker thread runs all inference. At most `max_pending` jobs
(running + waiting) are admitted; everything else is rejected at once
instead of queueing up and eating memory."""
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)
def __init__(self, max_pending: int):
self.max_pending = max_pending
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=1, thread_name_prefix="inference")
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:
@@ -200,7 +224,7 @@ class InferenceGate:
self.pending -= 1
gate = InferenceGate(MAX_PENDING_INFERENCES)
gate = InferenceGate(PARALLEL_INFERENCES, QUEUED_INFERENCES)
class InvalidImage(Exception):
+5
View File
@@ -0,0 +1,5 @@
# Allowlist: only the Caddyfile is copied into the image.
# data/ and config/ hold TLS certificates and private keys at runtime
# (mounted as volumes) and must never end up in the build context.
*
!Caddyfile
+7 -2
View File
@@ -1,5 +1,5 @@
{
email marvin.krausser277@gmail.com
email {$CADDY_EMAIL:marvin.krausser277@gmail.com}
}
(common) {
@@ -25,7 +25,7 @@ marvinkrausser.com {
api.marvinkrausser.com {
import common
request_body {
max_size 10MB
max_size {$CADDY_MAX_BODY_API:16MB}
}
reverse_proxy cnn_api:8000
}
@@ -34,3 +34,8 @@ portfolio.marvinkrausser.com {
import common
reverse_proxy portfolio_website:8081
}
git.marvinkrausser.com {
import common
reverse_proxy gitea:3000
}
+23 -1
View File
@@ -7,6 +7,10 @@ services:
- "80:80"
- "443:443"
restart: unless-stopped
environment:
- CADDY_EMAIL=marvin.krausser277@gmail.com
# Keep about 1 MB above the API's MAX_UPLOAD_MEGABYTES (multipart overhead).
- CADDY_MAX_BODY_API=16MB
volumes:
- ./caddy/data:/data/caddy
- ./caddy/config:/config/caddy
@@ -15,6 +19,8 @@ services:
- cnn_website
networks:
- cnn_network
- gitea_network
- portfolio_network
pull_policy: never
container_name: caddy_proxy
@@ -32,6 +38,18 @@ services:
- cnn_network
pull_policy: never
container_name: cnn_api
environment:
- PARALLEL_INFERENCES=4
- QUEUED_INFERENCES=4
- TORCH_THREADS=1
- FACE_MAX_FPS=5.5
- PREDICT_RATE_LIMIT=10
- PREDICT_RATE_WINDOW=60
- REVIEW_RATE_LIMIT=5
- REVIEW_RATE_WINDOW=60
- MAX_UPLOAD_MEGABYTES=15
cpus: "5"
mem_limit: 3g
cnn_website:
@@ -48,4 +66,8 @@ services:
networks:
cnn_network:
name: cnn_network
name: cnn_network
gitea_network:
name: gitea_network
portfolio_network:
name: portfolio_network
+36 -3
View File
@@ -1,6 +1,38 @@
import { useState, useRef } from 'react';
import styles from './Bird_CNN.module.css';
const MAX_SIDE = 1024;
const JPEG_QUALITY = 0.9;
// Shrinks the photo before upload. The model only uses 64x64 pixels, so this
// keeps uploads small (~100-300 KB) without affecting the result. Falls back
// to the original file if the browser can't decode or encode it.
async function downscale(file) {
try {
const bitmap = await createImageBitmap(file, { imageOrientation: 'from-image' });
const scale = Math.min(1, MAX_SIDE / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = document.createElement('canvas');
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff'; // transparent PNGs would turn black as JPEG
ctx.fillRect(0, 0, w, h);
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
const blob = await new Promise((resolve) =>
canvas.toBlob(resolve, 'image/jpeg', JPEG_QUALITY));
// Keep the original if re-encoding failed or somehow made it bigger.
return blob && blob.size < file.size ? blob : file;
} catch {
return file;
}
}
function Bird_CNN() {
const apiUrl = process.env.NODE_ENV === "development"
? "https://api.marvinkrausser.com"
@@ -50,12 +82,13 @@ function Bird_CNN() {
scrollRefClassifiction.current.scrollIntoView({ behavior: "smooth" });
const formData = new FormData();
formData.append("file", file);
setLoading(true);
try {
const upload = await downscale(file);
const formData = new FormData();
formData.append("file", upload, "image.jpg");
const response = await fetch(`${apiUrl}/predict`, {
method: "POST",
body: formData,
+7 -3
View File
@@ -1,6 +1,10 @@
import styles from './Object_Detection.module.css';
import { useRef, useEffect, useState } from 'react'
const FRAME_QUALITY = 0.7;
const FRAMES_PER_SECOND = 5;
function Object_Detection() {
const videoRef = useRef(null);
const canvasRefBBox = useRef(null);
@@ -66,7 +70,7 @@ function Object_Detection() {
} catch (e) {
setError(true);
}
}, "image/jpeg", 1);
}, "image/jpeg", FRAME_QUALITY);
};
const printDefault = () => {
@@ -123,7 +127,7 @@ function Object_Detection() {
if (!canvas) return;
ctx.clearRect(0, 0, canvas.width, canvas.height);
sendImageIntervall.current = setInterval(sendImage, 1000 / 2);
sendImageIntervall.current = setInterval(sendImage, 1000 / FRAMES_PER_SECOND);
}
socket.onmessage = (event) => {
drawBBox(JSON.parse(event.data).bboxes);
@@ -176,7 +180,7 @@ function Object_Detection() {
<h1 className='site-headline'>Face Detection</h1>
<div className={styles["text-container-introduction"]}>
<p>
The model's performance has been limited to 2 FPS due to low-end server hardware constraints.
The model's performance has been limited to 5 FPS due to low-end server hardware constraints.
</p>
<p>
The webcam video is transmitted to a server for processing. The server does not store, train on, or use the data for any purpose other than face detection.