Initial commit

This commit is contained in:
Mustafa KURU
2026-05-03 23:47:54 +03:00
commit 1dac1749bf
74 changed files with 6697 additions and 0 deletions
View File
+25
View File
@@ -0,0 +1,25 @@
import os
from functools import wraps
from flask import Response, request
ML_API_TOKEN = os.environ.get("ML_API_TOKEN")
def token_required(f):
@wraps(f)
def check_authorization(*args, **kwargs):
if (
request.headers.get("Authorization") == f"Bearer {ML_API_TOKEN}"
or request.args.get("token") == ML_API_TOKEN
):
return f(*args, **kwargs)
return Response(status=401)
@wraps(f)
def passthru(*args, **kwargs):
return f(*args, **kwargs)
if ML_API_TOKEN:
return check_authorization
return passthru
View File
+254
View File
@@ -0,0 +1,254 @@
# pylint: disable=R, W0401, W0614, W0703
from ctypes import *
import random
import os
import cv2
import platform
from typing import List, Tuple
# C-structures from Darknet lib
class BOX(Structure):
_fields_ = [("x", c_float),
("y", c_float),
("w", c_float),
("h", c_float)]
class DETECTION(Structure):
_fields_ = [("bbox", BOX),
("classes", c_int),
("best_class_idx", c_int),
("prob", POINTER(c_float)),
("mask", POINTER(c_float)),
("objectness", c_float),
("sort_class", c_int),
("uc", POINTER(c_float)),
("points", c_int),
("embeddings", POINTER(c_float)),
("embedding_size", c_int),
("sim", c_float),
("track_id", c_int)]
class IMAGE(Structure):
_fields_ = [("w", c_int),
("h", c_int),
("c", c_int),
("data", POINTER(c_float))]
class METADATA(Structure):
_fields_ = [("classes", c_int),
("names", POINTER(c_char_p))]
class YoloNet:
"""Darknet-based detector implementation"""
net: c_void_p
meta: METADATA
def __init__(self, weight_path: str, meta_path: str, config_path: str, asked_to_use_gpu: bool):
if not os.path.exists(config_path):
raise ValueError("Invalid config path `"+os.path.abspath(config_path)+"`")
if not os.path.exists(weight_path):
raise ValueError("Invalid weight path `"+os.path.abspath(weight_path)+"`")
if not os.path.exists(meta_path):
raise ValueError("Invalid data file path `"+os.path.abspath(meta_path)+"`")
if not lib:
raise ImportError(f"Unable to load darknet module.")
if asked_to_use_gpu and not using_gpu:
raise Exception('I respectfully decline to load the net as I am asked to use GPU but the loaded darknet module does NOT have GPU support')
self.net = load_net_custom(config_path.encode("ascii"), weight_path.encode("ascii"), 0, 1) # batch size = 1
self.meta = load_meta(meta_path.encode("ascii"))
def detect(self, meta, image, alt_names, thresh=.5, hier_thresh=.5, nms=.45, debug=False) -> List[Tuple[str, float, Tuple[float, float, float, float]]]:
#pylint: disable= C0321
custom_image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
im, arr = array_to_image(custom_image) # you should comment line below: free_image(im)
if debug:
print("Loaded image")
num = c_int(0)
if debug:
print("Assigned num")
pnum = pointer(num)
if debug:
print("Assigned pnum")
predict_image(self.net, im)
if debug:
print("did prediction")
dets = get_network_boxes(self.net, custom_image.shape[1], custom_image.shape[0], thresh, hier_thresh, None, 0, pnum, 0) # OpenCV
if debug:
print("Got dets")
num = pnum[0]
if debug:
print("got zeroth index of pnum")
if nms:
do_nms_sort(dets, num, meta.classes, nms)
if debug:
print("did sort")
res = []
if debug:
print("about to range")
for j in range(num):
if debug:
print("Ranging on "+str(j)+" of "+str(num))
if debug:
print("Classes: "+str(meta), meta.classes, meta.names)
for i in range(meta.classes):
if debug:
print("Class-ranging on "+str(i)+" of "+str(meta.classes)+"= "+str(dets[j].prob[i]))
if dets[j].prob[i] > 0:
b = dets[j].bbox
if alt_names is None:
nameTag = meta.names[i]
else:
nameTag = alt_names[i]
if debug:
print("Got bbox", b)
print(nameTag)
print(dets[j].prob[i])
print((b.x, b.y, b.w, b.h))
res.append((nameTag, dets[j].prob[i], (b.x, b.y, b.w, b.h)))
if debug:
print("did range")
res = sorted(res, key=lambda x: -x[1])
if debug:
print("did sort")
free_detections(dets, num)
if debug:
print("freed detections")
return res
# Loads darknet shared library. May fail if some dependencies like OpenCV not installed
# libdarknet_gpu.so needs Cuda + Cudnn and other libraries in path, which may not exist
# For the such case, it will try to load libdarknet.so instead
lib = None
using_gpu = False
print('\n')
so_path = os.path.join('/darknet', "libdarknet_cpu.so")
lib = CDLL(so_path, RTLD_GLOBAL)
print(f" Darknet is now running on CPU.")
print('\n')
if lib:
lib.network_width.argtypes = [c_void_p]
lib.network_width.restype = c_int
lib.network_height.argtypes = [c_void_p]
lib.network_height.restype = c_int
predict = lib.network_predict
predict.argtypes = [c_void_p, POINTER(c_float)]
predict.restype = POINTER(c_float)
if using_gpu:
set_gpu = lib.cuda_set_device
set_gpu.argtypes = [c_int]
make_image = lib.make_image
make_image.argtypes = [c_int, c_int, c_int]
make_image.restype = IMAGE
get_network_boxes = lib.get_network_boxes
get_network_boxes.argtypes = [c_void_p, c_int, c_int, c_float, c_float, POINTER(c_int), c_int, POINTER(c_int), c_int]
get_network_boxes.restype = POINTER(DETECTION)
make_network_boxes = lib.make_network_boxes
make_network_boxes.argtypes = [c_void_p]
make_network_boxes.restype = POINTER(DETECTION)
free_detections = lib.free_detections
free_detections.argtypes = [POINTER(DETECTION), c_int]
free_ptrs = lib.free_ptrs
free_ptrs.argtypes = [POINTER(c_void_p), c_int]
network_predict = lib.network_predict
network_predict.argtypes = [c_void_p, POINTER(c_float)]
reset_rnn = lib.reset_rnn
reset_rnn.argtypes = [c_void_p]
load_net = lib.load_network
load_net.argtypes = [c_char_p, c_char_p, c_int]
load_net.restype = c_void_p
load_net_custom = lib.load_network_custom
load_net_custom.argtypes = [c_char_p, c_char_p, c_int, c_int]
load_net_custom.restype = c_void_p
do_nms_obj = lib.do_nms_obj
do_nms_obj.argtypes = [POINTER(DETECTION), c_int, c_int, c_float]
do_nms_sort = lib.do_nms_sort
do_nms_sort.argtypes = [POINTER(DETECTION), c_int, c_int, c_float]
free_image = lib.free_image
free_image.argtypes = [IMAGE]
letterbox_image = lib.letterbox_image
letterbox_image.argtypes = [IMAGE, c_int, c_int]
letterbox_image.restype = IMAGE
load_meta = lib.get_metadata
lib.get_metadata.argtypes = [c_char_p]
lib.get_metadata.restype = METADATA
load_image = lib.load_image_color
load_image.argtypes = [c_char_p, c_int, c_int]
load_image.restype = IMAGE
rgbgr_image = lib.rgbgr_image
rgbgr_image.argtypes = [IMAGE]
predict_image = lib.network_predict_image
predict_image.argtypes = [c_void_p, IMAGE]
predict_image.restype = POINTER(c_float)
def sample(probs):
s = sum(probs)
probs = [a/s for a in probs]
r = random.uniform(0, 1)
for i in range(len(probs)):
r = r - probs[i]
if r <= 0:
return i
return len(probs)-1
def c_array(ctype, values):
arr = (ctype*len(values))()
arr[:] = values
return arr
def array_to_image(arr):
import numpy as np
# need to return old values to avoid python freeing memory
arr = arr.transpose(2, 0, 1)
c = arr.shape[0]
h = arr.shape[1]
w = arr.shape[2]
arr = np.ascontiguousarray(arr.flat, dtype=np.float32) / 255.0
data = arr.ctypes.data_as(POINTER(c_float))
im = IMAGE(w, h, c, data)
return im, arr
def classify(net, meta, im):
global alt_names
out = predict_image(net, im)
res = []
for i in range(meta.classes):
if alt_names is None:
nameTag = meta.names[i]
else:
nameTag = alt_names[i]
res.append((nameTag, out[i]))
res = sorted(res, key=lambda x: -x[1])
return res
+95
View File
@@ -0,0 +1,95 @@
#!python3
# pylint: disable=R, W0401, W0614, W0703
from lib.meta import Meta
from os import environ, path
alt_names = None
darknet_ready = True
try:
from lib.darknet import YoloNet
except Exception as e:
print(f'Error during importing YoloNet! - {e}')
darknet_ready = False
onnx_ready = True
try:
from lib.onnx import OnnxNet
except Exception as e:
print(f'Error during importing OnnxNet! - {e}')
onnx_ready = False
def load_net(config_path, meta_path, weights_path=None):
def try_loading_net(net_config_priority):
for net_config in net_config_priority:
weights = net_config['weights_path']
use_gpu = net_config['use_gpu']
net_main = None
try:
print(f'----- Trying to load weights: {weights} - use_gpu = {use_gpu} -----')
if weights.endswith(".onnx"):
if not onnx_ready:
raise Exception('Not loading ONNX net due to previous import failure. Check earlier log for errors.')
net_main = OnnxNet(weights, meta_path, use_gpu)
elif weights.endswith(".darknet"):
if not darknet_ready:
raise Exception('Not loading darknet net due to previous import failure. Check earlier log for errors.')
net_main = YoloNet(weights, meta_path, config_path, use_gpu)
else:
raise Exception(f'Can not recognize net from weights file surfix: {weights}')
print('Succeeded!')
return net_main
except Exception as e:
print(f'Failed! - {e}')
raise Exception(f'Failed to load any net after trying: {net_config_priority}')
global alt_names # pylint: disable=W0603
model_dir = path.join(path.dirname(path.realpath(__file__)), '..', 'model')
use_gpu = environ.get('ML_USE_GPU', 'false').lower() in ('1', 'true', 'yes', 'on')
preferred_backend = environ.get('ML_MODEL_BACKEND', 'onnx').lower()
cpu_priority = [
dict(weights_path=path.join(model_dir, 'model-weights.onnx'), use_gpu=False),
dict(weights_path=path.join(model_dir, 'model-weights.darknet'), use_gpu=False),
]
gpu_priority = [
dict(weights_path=path.join(model_dir, 'model-weights.onnx'), use_gpu=True),
dict(weights_path=path.join(model_dir, 'model-weights.darknet'), use_gpu=True),
]
if preferred_backend == 'darknet':
cpu_priority.reverse()
gpu_priority.reverse()
net_config_priority = gpu_priority + cpu_priority if use_gpu else cpu_priority
if weights_path is not None:
net_config_priority = (
[dict(weights_path=weights_path, use_gpu=True), dict(weights_path=weights_path, use_gpu=False)]
if use_gpu
else [dict(weights_path=weights_path, use_gpu=False)]
)
net_main = try_loading_net(net_config_priority)
if alt_names is None:
# In Python 3, the metafile default access craps out on Windows (but not Linux)
# Read the names file and create a list to feed to detect
try:
meta = Meta(meta_path)
alt_names = meta.names
except Exception:
pass
return net_main
def detect(net, image, thresh=.5, hier_thresh=.5, nms=.45, debug=False):
return net.detect(net.meta, image, alt_names, thresh, hier_thresh, nms, debug)
+111
View File
@@ -0,0 +1,111 @@
from dataclasses import dataclass, asdict
from typing import Any, Dict, List, Tuple
@dataclass
class Box:
"""Detection rect"""
xc: float
yc: float
w: float
h: float
@classmethod
def from_tuple(cls, box: Tuple[float, float, float, float]) -> 'Box':
return Box(xc=float(box[0]), yc=float(box[1]), w=float(box[2]), h=float(box[3]))
def left(self) -> float:
return self.xc - self.w * 0.5
def right(self) -> float:
return self.xc + self.w * 0.5
def top(self) -> float:
return self.yc - self.h * 0.5
def bottom(self) -> float:
return self.yc + self.h * 0.5
def calc_iou(self, other: 'Box') -> float:
"""Calculates intersection over union ration which can be used to compare boxes"""
al = self.left()
ar = self.right()
at = self.top()
ab = self.bottom()
bl = other.left()
br = other.right()
bt = other.top()
bb = other.bottom()
i_l = max(al, bl)
i_r = min(ar, br)
i_t = max(at, bt)
i_b = min(ab, bb)
o_l = min(al, bl)
o_r = max(ar, br)
o_t = min(at, bt)
o_b = max(ab, bb)
i_w = i_r - i_l
i_h = i_b - i_t
o_w = o_r - o_l
o_h = o_b - o_t
o_a = o_w * o_h
if o_a <= 0.0:
return 0.0
return i_w * i_h / o_a
@dataclass
class Detection:
"""Detection result"""
name: str
confidence: float
box: Box
@classmethod
def from_tuple_list(cls, detections: List[Tuple[str, float, Tuple[float, float, float, float]]]) -> List['Detection']:
return [Detection.from_tuple(d) for d in detections]
@classmethod
def from_tuple(cls, detection: Tuple[str, float, Tuple[float, float, float, float]]) -> 'Detection':
box = Box.from_tuple(detection[2])
return Detection(detection[0], float(detection[1]), box)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Detection':
return Detection(data['name'], data['confidence'], Box(**data['box']))
def compare_detections(l1: List[Detection], l2: List[Detection], threshold: float = 0.4) -> bool:
"""Compares two lists of detections. Returns true if lists looks similar with some threshold"""
# Are there all boxes from l1 matching any in l2
for a in l1:
found = False
for b in l2:
iou = a.box.calc_iou(b.box)
if iou >= threshold:
found = True
break
if not found:
return False
# are there all boxes in l2 matching any in l1
# the list may differ and contain duplicates,
# that's why we need two checks
for b in l2:
found = False
for a in l1:
iou = a.box.calc_iou(b.box)
if iou >= threshold:
found = True
break
if not found:
return False
return True
+27
View File
@@ -0,0 +1,27 @@
from typing import List, Tuple
from dataclasses import dataclass, field
import os
import re
@dataclass
class Meta:
names: List[str] = field(default_factory=list)
def __init__(self, meta_path: str):
names = None
with open(meta_path) as f:
meta_contents = f.read()
match = re.search("names *= *(.*)$", meta_contents, re.IGNORECASE | re.MULTILINE)
if match:
names_path = match.group(1)
try:
if os.path.exists(names_path):
with open(names_path) as namesFH:
names_list = namesFH.read().strip().split("\n")
names = [x.strip() for x in names_list]
except TypeError:
pass
if names is None:
names = ['failure']
self.names = names
+132
View File
@@ -0,0 +1,132 @@
from typing import List, Tuple
import onnxruntime
import numpy as np
import cv2
import os
from lib.meta import Meta
class OnnxNet:
session: onnxruntime.InferenceSession
meta: Meta
def __init__(self, onnx_path: str, meta_path: str, use_gpu: bool):
providers = ['CUDAExecutionProvider'] if use_gpu else ['CPUExecutionProvider']
self.session = onnxruntime.InferenceSession(onnx_path, providers=providers)
self.meta = Meta(meta_path)
def detect(self, meta, image, alt_names, thresh=.5, hier_thresh=.5, nms=.45, debug=False) -> List[Tuple[str, float, Tuple[float, float, float, float]]]:
input_h = self.session.get_inputs()[0].shape[2]
input_w = self.session.get_inputs()[0].shape[3]
width = image.shape[1]
height = image.shape[0]
# Input
resized = cv2.resize(image, (input_w, input_h), interpolation=cv2.INTER_LINEAR)
img_in = cv2.cvtColor(resized, cv2.COLOR_BGR2RGB)
img_in = np.transpose(img_in, (2, 0, 1)).astype(np.float32)
img_in = np.expand_dims(img_in, axis=0)
img_in /= 255.0
input_name = self.session.get_inputs()[0].name
outputs = self.session.run(None, {input_name: img_in})
detections = post_processing(outputs, width, height, thresh, nms, meta.names)
return detections[0]
def nms_cpu(boxes, confs, nms_thresh=0.5, min_mode=False):
# print(boxes.shape)
x1 = boxes[:, 0]
y1 = boxes[:, 1]
x2 = boxes[:, 2]
y2 = boxes[:, 3]
areas = (x2 - x1) * (y2 - y1)
order = confs.argsort()[::-1]
keep = []
while order.size > 0:
idx_self = order[0]
idx_other = order[1:]
keep.append(idx_self)
xx1 = np.maximum(x1[idx_self], x1[idx_other])
yy1 = np.maximum(y1[idx_self], y1[idx_other])
xx2 = np.minimum(x2[idx_self], x2[idx_other])
yy2 = np.minimum(y2[idx_self], y2[idx_other])
w = np.maximum(0.0, xx2 - xx1)
h = np.maximum(0.0, yy2 - yy1)
inter = w * h
if min_mode:
over = inter / np.minimum(areas[order[0]], areas[order[1:]])
else:
over = inter / (areas[order[0]] + areas[order[1:]] - inter)
inds = np.where(over <= nms_thresh)[0]
order = order[inds + 1]
return np.array(keep)
def post_processing(output, width, height, conf_thresh, nms_thresh, names):
box_array = output[0]
confs = output[1]
if type(box_array).__name__ != 'ndarray':
box_array = box_array.cpu().detach().numpy()
confs = confs.cpu().detach().numpy()
num_classes = confs.shape[2]
# [batch, num, 4]
box_array = box_array[:, :, 0]
# [batch, num, num_classes] --> [batch, num]
max_conf = np.max(confs, axis=2)
max_id = np.argmax(confs, axis=2)
box_x1x1x2y2_to_xcycwh_scaled = lambda b: \
(
float(0.5 * width * (b[0] + b[2])),
float(0.5 * height * (b[1] + b[3])),
float(width * (b[2] - b[0])),
float(width * (b[3] - b[1]))
)
dets_batch = []
for i in range(box_array.shape[0]):
argwhere = max_conf[i] > conf_thresh
l_box_array = box_array[i, argwhere, :]
l_max_conf = max_conf[i, argwhere]
l_max_id = max_id[i, argwhere]
bboxes = []
# nms for each class
for j in range(num_classes):
cls_argwhere = l_max_id == j
ll_box_array = l_box_array[cls_argwhere, :]
ll_max_conf = l_max_conf[cls_argwhere]
ll_max_id = l_max_id[cls_argwhere]
keep = nms_cpu(ll_box_array, ll_max_conf, nms_thresh)
if (keep.size > 0):
ll_box_array = ll_box_array[keep, :]
ll_max_conf = ll_max_conf[keep]
ll_max_id = ll_max_id[keep]
for k in range(ll_box_array.shape[0]):
bboxes.append([ll_box_array[k, 0], ll_box_array[k, 1], ll_box_array[k, 2], ll_box_array[k, 3], ll_max_conf[k], ll_max_conf[k], ll_max_id[k]])
detections = [(names[b[6]], float(b[4]), box_x1x1x2y2_to_xcycwh_scaled((b[0], b[1], b[2], b[3]))) for b in bboxes]
dets_batch.append(detections)
return dets_batch
@@ -0,0 +1 @@
https://tsd-pub-static.s3.amazonaws.com/ml-models/model-weights-8be06cde4e.darknet
@@ -0,0 +1 @@
https://tsd-pub-static.s3.amazonaws.com/ml-models/model-weights-5a6b1be1fa.onnx
+258
View File
@@ -0,0 +1,258 @@
[net]
# Testing
batch=64
subdivisions=8
# Training
# batch=64
# subdivisions=8
height=416
width=416
channels=3
momentum=0.9
decay=0.0005
angle=0
saturation = 1.5
exposure = 1.5
hue=.1
learning_rate=0.001
burn_in=1000
max_batches = 50000
policy=steps
steps=40000,60000
scales=.1,.1
[convolutional]
batch_normalize=1
filters=32
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=64
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=128
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=64
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=128
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=256
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=128
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=256
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=512
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=256
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=256
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=3
stride=1
pad=1
activation=leaky
[maxpool]
size=2
stride=2
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=512
size=1
stride=1
pad=1
activation=leaky
[convolutional]
batch_normalize=1
filters=1024
size=3
stride=1
pad=1
activation=leaky
#######
[convolutional]
batch_normalize=1
size=3
stride=1
pad=1
filters=1024
activation=leaky
[convolutional]
batch_normalize=1
size=3
stride=1
pad=1
filters=1024
activation=leaky
[route]
layers=-9
[convolutional]
batch_normalize=1
size=1
stride=1
pad=1
filters=64
activation=leaky
[reorg3d]
stride=2
[route]
layers=-1,-4
[convolutional]
batch_normalize=1
size=3
stride=1
pad=1
filters=1024
activation=leaky
[convolutional]
size=1
stride=1
pad=1
filters=30
activation=linear
[region]
anchors = 1.3221, 1.73145, 3.19275, 4.00944, 5.05587, 8.09892, 9.47112, 4.84053, 11.2364, 10.0071
bias_match=1
classes=1
coords=4
num=5
softmax=1
jitter=.3
rescore=1
object_scale=5
noobject_scale=1
class_scale=1
coord_scale=1
absolute=1
thresh = .6
random=1
+2
View File
@@ -0,0 +1,2 @@
classes= 1
names = /app/model/names
+1
View File
@@ -0,0 +1 @@
failure
+6
View File
@@ -0,0 +1,6 @@
ipdb
flask>=1.0
redis==3.0.1
newrelic==4.12.0.113
requests==2.21.0
gunicorn==19.9.0
+275
View File
@@ -0,0 +1,275 @@
#!/usr/bin/env python
from __future__ import annotations
from collections import deque
from datetime import datetime, timezone
import logging
from os import environ, path
from time import perf_counter
from urllib.parse import urlsplit, urlunsplit
import cv2
import flask
from flask import Response, jsonify, request
import numpy as np
import requests
from auth import token_required
from lib.detection_model import detect, load_net
THRESH = float(environ.get("ML_DETECTION_BOX_THRESHOLD", "0.08"))
REQUEST_TIMEOUT = (
float(environ.get("ML_IMAGE_CONNECT_TIMEOUT", "2")),
float(environ.get("ML_IMAGE_READ_TIMEOUT", "10")),
)
MAX_RECENT_REQUESTS = int(environ.get("ML_RECENT_REQUESTS", "100"))
app = flask.Flask(__name__)
app.config["DEBUG"] = environ.get("DEBUG") == "True"
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
app.logger.setLevel(logging.INFO)
STARTED_AT = datetime.now(timezone.utc)
RECENT_REQUESTS: deque[dict] = deque(maxlen=MAX_RECENT_REQUESTS)
model_dir = path.join(path.dirname(path.realpath(__file__)), "model")
net_main = load_net(path.join(model_dir, "model.cfg"), path.join(model_dir, "model.meta"))
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _redact_url(raw_url: str | None) -> str | None:
if not raw_url:
return None
parsed = urlsplit(raw_url)
return urlunsplit((parsed.scheme, parsed.netloc, parsed.path, "", ""))
def _record_request(entry: dict) -> None:
stored_entry = dict(entry)
if "image_url" in stored_entry:
stored_entry["image_url"] = _redact_url(stored_entry.get("image_url"))
RECENT_REQUESTS.appendleft({"time": _now_iso(), **stored_entry})
app.logger.info(
"prediction status=%s detections=%s duration_ms=%s image_host=%s error=%s",
entry.get("status"),
entry.get("detections", 0),
entry.get("duration_ms"),
urlsplit(entry.get("image_url") or "").netloc,
entry.get("error"),
)
def _fetch_image(image_url: str) -> np.ndarray:
response = requests.get(image_url, stream=True, timeout=REQUEST_TIMEOUT)
response.raise_for_status()
img_array = np.array(bytearray(response.content), dtype=np.uint8)
image = cv2.imdecode(img_array, -1)
if image is None:
raise ValueError("image_decode_failed")
return image
def _status_payload() -> dict:
return {
"ok": net_main is not None,
"started_at": STARTED_AT.isoformat(),
"model": {
"classes": ["failure"],
"box_threshold": THRESH,
"backend": type(net_main).__name__ if net_main is not None else None,
"use_gpu": environ.get("ML_USE_GPU", "false"),
"model_backend_preference": environ.get("ML_MODEL_BACKEND", "onnx"),
},
"requests": {
"recent_count": len(RECENT_REQUESTS),
"max_recent": MAX_RECENT_REQUESTS,
},
}
@app.route("/", methods=["GET"])
def dashboard():
"""Render a small operational status page."""
status = _status_payload()
rows = "\n".join(
"<tr>"
f"<td>{entry['time']}</td>"
f"<td>{entry.get('status', '')}</td>"
f"<td>{entry.get('detections', 0)}</td>"
f"<td>{entry.get('duration_ms', '')}</td>"
f"<td>{entry.get('error') or ''}</td>"
f"<td>{_redact_url(entry.get('image_url')) or ''}</td>"
"</tr>"
for entry in list(RECENT_REQUESTS)[:20]
)
body = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Elegoo Spaghetti Detection ML Server</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 24px; color: #1f2933; }}
code {{ background: #eef2f7; padding: 2px 5px; border-radius: 4px; }}
table {{ border-collapse: collapse; width: 100%; margin-top: 16px; }}
th, td {{ border-bottom: 1px solid #d9e2ec; padding: 8px; text-align: left; font-size: 14px; }}
.ok {{ color: #137333; font-weight: 700; }}
.bad {{ color: #b3261e; font-weight: 700; }}
</style>
</head>
<body>
<h1>Elegoo Spaghetti Detection ML Server</h1>
<p>Status: <span class="{'ok' if status['ok'] else 'bad'}">{'ok' if status['ok'] else 'error'}</span></p>
<p>Backend: <code>{status['model']['backend']}</code> | GPU opt-in: <code>{status['model']['use_gpu']}</code> | Box threshold: <code>{status['model']['box_threshold']}</code></p>
<p>Health: <code>/hc/</code> | JSON status: <code>/api/status</code> | Token-protected logs: <code>/api/logs?token=&lt;token&gt;</code></p>
<h2>Recent Requests</h2>
<table>
<thead><tr><th>Time</th><th>Status</th><th>Detections</th><th>ms</th><th>Error</th><th>Image URL without token</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</body>
</html>"""
return Response(body, mimetype="text/html")
@app.route("/api/status", methods=["GET"])
def api_status():
"""Return JSON server status."""
return jsonify(_status_payload())
@app.route("/api/logs", methods=["GET"])
@token_required
def api_logs():
"""Return recent request logs."""
return jsonify({"requests": list(RECENT_REQUESTS)})
@app.route("/debug/image", methods=["GET"])
@token_required
def debug_image():
"""Check whether the server can fetch and decode an image URL."""
image_url = request.args.get("img")
if not image_url:
return jsonify({"ok": False, "error": "missing_image_url"}), 400
started = perf_counter()
try:
image = _fetch_image(image_url)
return jsonify(
{
"ok": True,
"duration_ms": round((perf_counter() - started) * 1000),
"shape": list(image.shape),
"image_url": _redact_url(image_url),
}
)
except requests.RequestException as err:
return jsonify({"ok": False, "error": "image_fetch_failed", "message": str(err)}), 502
except ValueError as err:
return jsonify({"ok": False, "error": str(err)}), 422
@app.route("/p/", methods=["GET"])
@token_required
def get_p():
"""Run prediction for the image URL in the img query parameter."""
image_url = request.args.get("img")
if not image_url:
_record_request({"status": 400, "error": "missing_image_url", "detections": 0})
return jsonify(
{
"detections": [],
"error": "missing_image_url",
"message": "Missing img query parameter.",
}
), 400
started = perf_counter()
try:
image = _fetch_image(image_url)
detections = detect(net_main, image, thresh=THRESH)
duration_ms = round((perf_counter() - started) * 1000)
_record_request(
{
"status": 200,
"detections": len(detections),
"duration_ms": duration_ms,
"image_url": image_url,
}
)
return jsonify({"detections": detections, "duration_ms": duration_ms})
except requests.RequestException as err:
duration_ms = round((perf_counter() - started) * 1000)
_record_request(
{
"status": 502,
"error": "image_fetch_failed",
"message": str(err),
"duration_ms": duration_ms,
"image_url": image_url,
"detections": 0,
}
)
return jsonify(
{
"detections": [],
"error": "image_fetch_failed",
"message": str(err),
}
), 502
except ValueError as err:
duration_ms = round((perf_counter() - started) * 1000)
_record_request(
{
"status": 422,
"error": str(err),
"duration_ms": duration_ms,
"image_url": image_url,
"detections": 0,
}
)
return jsonify(
{
"detections": [],
"error": str(err),
"message": "The image URL did not return a decodable image.",
}
), 422
except Exception as err:
duration_ms = round((perf_counter() - started) * 1000)
app.logger.exception("Unable to process image")
_record_request(
{
"status": 500,
"error": "prediction_failed",
"message": str(err),
"duration_ms": duration_ms,
"image_url": image_url,
"detections": 0,
}
)
return jsonify(
{
"detections": [],
"error": "prediction_failed",
"message": str(err),
}
), 500
@app.route("/hc/", methods=["GET"])
def health_check():
"""Health check for Home Assistant and Docker."""
if net_main is not None:
return "ok", 200
return "error", 503
if __name__ == "__main__":
app.run(host="0.0.0.0", port=3333, threaded=False)
+6
View File
@@ -0,0 +1,6 @@
import server
application = server.app
if __name__ == "__main__":
application.run()
@@ -0,0 +1,17 @@
#!/usr/bin/with-contenv bashio
declare ML_API_TOKEN
ML_API_TOKEN=$(bashio::config 'obico_api_secret')
export ML_API_TOKEN
export ML_USE_GPU=$(bashio::config 'use_gpu')
export GUNICORN_TIMEOUT=$(bashio::config 'gunicorn_timeout')
cd /app
FLASK_APP=server.py venv/bin/gunicorn \
--bind "0.0.0.0:3333" \
--workers "${GUNICORN_WORKERS:-1}" \
--timeout "${GUNICORN_TIMEOUT:-120}" \
--error-logfile - \
--log-level info \
wsgi:application