VitalyVorobyev commited on
Commit
dd85fb6
·
1 Parent(s): 0e49d67

1st version

Browse files
.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __pycache__
2
+ models
README.md CHANGED
@@ -12,3 +12,63 @@ short_description: Minimal feature-detection with Classical and Deep Learning
12
  ---
13
 
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
  ---
13
 
14
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
15
+
16
+ # FeatureLab Mini — Classic & DL Detectors
17
+
18
+ FeatureLab now exposes a production-friendly layout: FastAPI serves the detector runtime over HTTP/WebSocket while Gradio rides on top for internal demos.
19
+
20
+ ## Runtime Overview
21
+ ```
22
+ FastAPI (/v1/detect/*) <-- shared numpy/CV runtime --> Gradio UI (/)
23
+ ```
24
+ - **Classical path**: Canny, Harris, Probabilistic Hough, contour-based ellipse fitting.
25
+ - **Deep path**: ONNX models (HED, SuperPoint, SOLD2, etc.) auto-loaded from `./models`.
26
+ - **Responses**: base64 PNG overlays, rich feature metadata, timings, model info.
27
+
28
+ ## Run locally
29
+ ```bash
30
+ python -m venv .venv && source .venv/bin/activate
31
+ pip install -r requirements.txt
32
+ python app.py # FastAPI + Gradio on http://localhost:7860
33
+ ```
34
+
35
+ ## HTTP API
36
+ - `POST /v1/detect/edges|corners|lines|ellipses`
37
+ - Body:
38
+ ```json
39
+ {
40
+ "image": "<base64 png/jpeg>",
41
+ "params": { "canny_low": 50, "canny_high": 150, "...": "..." },
42
+ "mode": "classical|dl|both",
43
+ "compare": false,
44
+ "dl_model": "hed.onnx"
45
+ }
46
+ ```
47
+ - Response:
48
+ ```json
49
+ {
50
+ "overlay": "<png base64>",
51
+ "overlays": { "classical": "...", "dl": "..." },
52
+ "features": { "classical": {...}, "dl": {...} },
53
+ "timings": { "classical": 7.2, "dl": 18.5, "total": 25.7 },
54
+ "fps_estimate": 38.9,
55
+ "model": { "name": "opencv-classical", "version": "4.10.0" },
56
+ "models": { "classical": {...}, "dl": {...} }
57
+ }
58
+ ```
59
+ - Multipart uploads: `POST /v1/detect/<detector>/upload` with `file`, optional `params` (JSON string), `mode`, `compare`, `dl_model`.
60
+
61
+ ## WebSocket API
62
+ - Connect to `/v1/detect/stream`.
63
+ - Send JSON payloads with the same shape as HTTP.
64
+ - Receive the detection response for each frame — suitable for webcam or live sources.
65
+
66
+ ## Gradio Demo
67
+ - Still bundled for quick experiments (webcam capture, parameter sliders).
68
+ - Fully decoupled: the UI calls the same runtime, so React/Tauri front-ends can swap in later without touching detector code.
69
+
70
+ ## Deploying
71
+ - Hugging Face Spaces (Gradio) still works — FastAPI runs inside the Space process.
72
+ - For container/desktop targets, run `uvicorn app:app` or embed the FastAPI router into your existing service.
73
+
74
+ GPU/Core ML acceleration (ONNX) is optional; drop models into `./models` to enable DL paths. Continuous upgrades toward Core ML / PyTorch backends can reuse the same API surface.
app.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Thin entrypoint for local runs.
3
+ Imports the FastAPI app from the backend package and runs it.
4
+ """
5
+
6
+ import os
7
+ import uvicorn
8
+
9
+ from backend.py.app.main import app # noqa: F401
10
+
11
+
12
+ if __name__ == "__main__":
13
+ host = os.getenv("HOST", "127.0.0.1")
14
+ port = int(os.getenv("PORT", "7862"))
15
+ uvicorn.run("app:app", host=host, port=port, reload=False)
backend/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/api/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/api/v1/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/api/v1/detect.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from typing import Any, Dict, Optional
3
+
4
+ import cv2
5
+ import numpy as np
6
+ from fastapi import APIRouter, File, Form, HTTPException, UploadFile, WebSocket, WebSocketDisconnect
7
+
8
+ from ...models.schemas import DetectionParams, DetectionRequest, DetectionResponse
9
+ from ...services.runtime_adapter import DetectionResult, run_detection
10
+ from ...utils.image_io import decode_base64_image, encode_png_base64
11
+
12
+
13
+ router = APIRouter(prefix="/v1/detect", tags=["detection"])
14
+
15
+ DETECTOR_KEYS: Dict[str, str] = {
16
+ "edges": "Edges (Canny)",
17
+ "corners": "Corners (Harris)",
18
+ "lines": "Lines (Hough)",
19
+ "ellipses": "Ellipses (Contours + fitEllipse)",
20
+ }
21
+
22
+ ALLOWED_MODES = {"classical", "dl", "both"}
23
+
24
+
25
+ def _detector_label(key: str) -> str:
26
+ if key not in DETECTOR_KEYS:
27
+ raise HTTPException(status_code=404, detail=f"Unknown detector '{key}'.")
28
+ return DETECTOR_KEYS[key]
29
+
30
+
31
+ def _resolve_mode(mode: str, compare: bool) -> str:
32
+ if compare:
33
+ return "both"
34
+ if mode not in ALLOWED_MODES:
35
+ return "classical"
36
+ return mode
37
+
38
+
39
+ def _choose_primary(mode: str, overlays: Dict[str, str]) -> Optional[str]:
40
+ if mode == "dl" and "dl" in overlays:
41
+ return "dl"
42
+ if mode == "classical" and "classical" in overlays:
43
+ return "classical"
44
+ if mode == "both":
45
+ if "classical" in overlays:
46
+ return "classical"
47
+ if "dl" in overlays:
48
+ return "dl"
49
+ return next(iter(overlays.keys()), None)
50
+
51
+
52
+ def _format_result(result: DetectionResult, mode: str) -> DetectionResponse:
53
+ overlays_encoded: Dict[str, Optional[str]] = {}
54
+ for path, image in result.overlays.items():
55
+ if image is None:
56
+ overlays_encoded[path] = None
57
+ continue
58
+ overlays_encoded[path] = encode_png_base64(image)
59
+
60
+ primary = _choose_primary(mode, {k: v for k, v in overlays_encoded.items() if v})
61
+ model_info = result.models.get(primary or "classical", result.models.get("classical", {}))
62
+
63
+ return DetectionResponse(
64
+ overlay=overlays_encoded.get(primary) if primary else None,
65
+ overlays=overlays_encoded,
66
+ features=result.features,
67
+ timings=result.timings_ms,
68
+ fps_estimate=result.fps_estimate,
69
+ model=model_info,
70
+ models=result.models,
71
+ )
72
+
73
+
74
+ def _json_params(params: Optional[DetectionParams]) -> Optional[Dict[str, Any]]:
75
+ if params is None:
76
+ return None
77
+ return params.dict(exclude_none=True)
78
+
79
+
80
+ @router.post("/edges", response_model=DetectionResponse)
81
+ async def detect_edges(payload: DetectionRequest):
82
+ try:
83
+ image = decode_base64_image(payload.image)
84
+ except Exception as exc:
85
+ raise HTTPException(status_code=400, detail=f"Invalid image payload: {exc}")
86
+ runtime_mode = _resolve_mode(payload.mode, payload.compare)
87
+ result = run_detection(
88
+ image,
89
+ _detector_label("edges"),
90
+ params=_json_params(payload.params),
91
+ mode=runtime_mode,
92
+ dl_choice=payload.dl_model.strip() if payload.dl_model else None,
93
+ )
94
+ return _format_result(result, runtime_mode)
95
+
96
+
97
+ @router.post("/corners", response_model=DetectionResponse)
98
+ async def detect_corners(payload: DetectionRequest):
99
+ try:
100
+ image = decode_base64_image(payload.image)
101
+ except Exception as exc:
102
+ raise HTTPException(status_code=400, detail=f"Invalid image payload: {exc}")
103
+ runtime_mode = _resolve_mode(payload.mode, payload.compare)
104
+ result = run_detection(
105
+ image,
106
+ _detector_label("corners"),
107
+ params=_json_params(payload.params),
108
+ mode=runtime_mode,
109
+ dl_choice=payload.dl_model.strip() if payload.dl_model else None,
110
+ )
111
+ return _format_result(result, runtime_mode)
112
+
113
+
114
+ @router.post("/lines", response_model=DetectionResponse)
115
+ async def detect_lines(payload: DetectionRequest):
116
+ try:
117
+ image = decode_base64_image(payload.image)
118
+ except Exception as exc:
119
+ raise HTTPException(status_code=400, detail=f"Invalid image payload: {exc}")
120
+ runtime_mode = _resolve_mode(payload.mode, payload.compare)
121
+ result = run_detection(
122
+ image,
123
+ _detector_label("lines"),
124
+ params=_json_params(payload.params),
125
+ mode=runtime_mode,
126
+ dl_choice=payload.dl_model.strip() if payload.dl_model else None,
127
+ )
128
+ return _format_result(result, runtime_mode)
129
+
130
+
131
+ @router.post("/ellipses", response_model=DetectionResponse)
132
+ async def detect_ellipses(payload: DetectionRequest):
133
+ try:
134
+ image = decode_base64_image(payload.image)
135
+ except Exception as exc:
136
+ raise HTTPException(status_code=400, detail=f"Invalid image payload: {exc}")
137
+ runtime_mode = _resolve_mode(payload.mode, payload.compare)
138
+ result = run_detection(
139
+ image,
140
+ _detector_label("ellipses"),
141
+ params=_json_params(payload.params),
142
+ mode=runtime_mode,
143
+ dl_choice=payload.dl_model.strip() if payload.dl_model else None,
144
+ )
145
+ return _format_result(result, runtime_mode)
146
+
147
+
148
+ async def _handle_upload(
149
+ detector_key: str,
150
+ file: UploadFile,
151
+ params: Optional[str],
152
+ mode: str,
153
+ compare: bool,
154
+ dl_model: Optional[str],
155
+ ) -> DetectionResponse:
156
+ content = await file.read()
157
+ array = np.frombuffer(content, dtype=np.uint8)
158
+ decoded = cv2.imdecode(array, cv2.IMREAD_COLOR)
159
+ if decoded is None:
160
+ raise HTTPException(status_code=400, detail="Unable to decode uploaded image.")
161
+ image = cv2.cvtColor(decoded, cv2.COLOR_BGR2RGB)
162
+ params_dict: Optional[Dict[str, Any]] = None
163
+ if params:
164
+ try:
165
+ params_dict = json.loads(params)
166
+ if not isinstance(params_dict, dict):
167
+ raise ValueError("params JSON must decode to an object.")
168
+ except ValueError as exc:
169
+ raise HTTPException(status_code=400, detail=f"Invalid params: {exc}")
170
+ runtime_mode = _resolve_mode(mode, compare)
171
+ result = run_detection(
172
+ image,
173
+ _detector_label(detector_key),
174
+ params=params_dict,
175
+ mode=runtime_mode,
176
+ dl_choice=dl_model.strip() if dl_model else None,
177
+ )
178
+ return _format_result(result, runtime_mode)
179
+
180
+
181
+ def _upload_endpoint(detector_key: str):
182
+ async def endpoint(
183
+ file: UploadFile = File(...),
184
+ params: Optional[str] = Form(None),
185
+ mode: str = Form("classical"),
186
+ compare: bool = Form(False),
187
+ dl_model: Optional[str] = Form(None),
188
+ ):
189
+ return await _handle_upload(detector_key, file, params, mode, compare, dl_model)
190
+
191
+ return endpoint
192
+
193
+
194
+ router.add_api_route(
195
+ "/edges/upload", _upload_endpoint("edges"), methods=["POST"], response_model=DetectionResponse
196
+ )
197
+ router.add_api_route(
198
+ "/corners/upload", _upload_endpoint("corners"), methods=["POST"], response_model=DetectionResponse
199
+ )
200
+ router.add_api_route(
201
+ "/lines/upload", _upload_endpoint("lines"), methods=["POST"], response_model=DetectionResponse
202
+ )
203
+ router.add_api_route(
204
+ "/ellipses/upload", _upload_endpoint("ellipses"), methods=["POST"], response_model=DetectionResponse
205
+ )
206
+
207
+
208
+ @router.websocket("/stream")
209
+ async def detection_stream(websocket: WebSocket):
210
+ await websocket.accept()
211
+ await websocket.send_json({"ready": True})
212
+ try:
213
+ while True:
214
+ message = await websocket.receive_text()
215
+ try:
216
+ payload = json.loads(message)
217
+ except json.JSONDecodeError:
218
+ await websocket.send_json({"error": "Invalid JSON payload."})
219
+ continue
220
+
221
+ detector_key = payload.get("detector")
222
+ if detector_key not in DETECTOR_KEYS:
223
+ await websocket.send_json({"error": "Unknown detector key."})
224
+ continue
225
+
226
+ image_b64 = payload.get("image")
227
+ if not image_b64:
228
+ await websocket.send_json({"error": "Missing 'image' field."})
229
+ continue
230
+
231
+ try:
232
+ image = decode_base64_image(image_b64)
233
+ except Exception as exc:
234
+ await websocket.send_json({"error": f"Invalid image payload: {exc}"})
235
+ continue
236
+
237
+ params = payload.get("params")
238
+ if params is not None and not isinstance(params, dict):
239
+ await websocket.send_json({"error": "'params' must be an object."})
240
+ continue
241
+
242
+ mode = payload.get("mode", "classical")
243
+ compare = bool(payload.get("compare", False))
244
+ dl_model = payload.get("dl_model") or payload.get("model")
245
+
246
+ runtime_mode = _resolve_mode(mode, compare)
247
+ try:
248
+ result = run_detection(
249
+ image,
250
+ _detector_label(detector_key),
251
+ params=params,
252
+ mode=runtime_mode,
253
+ dl_choice=dl_model.strip() if dl_model else None,
254
+ )
255
+ except Exception as exc: # pragma: no cover
256
+ await websocket.send_json({"error": str(exc)})
257
+ continue
258
+
259
+ await websocket.send_json(_format_result(result, runtime_mode).dict())
260
+ except WebSocketDisconnect:
261
+ return
262
+
backend/py/app/gradio_demo/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/gradio_demo/ui.py ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Optional, Tuple
2
+
3
+ import gradio as gr
4
+ import numpy as np
5
+
6
+ from ..inference.dl import DL_MODELS
7
+ from ..services.runtime_adapter import DEFAULT_PARAMS, run_detection
8
+
9
+
10
+ TITLE = "FeatureLab Mini — Classic vs DL"
11
+ DESC = (
12
+ "Minimal feature-detection demo with Classical and Deep Learning paths, now backed by a shared runtime."
13
+ )
14
+
15
+
16
+ def _gradio_runtime(
17
+ image: Optional[np.ndarray],
18
+ detector: str,
19
+ compare: bool,
20
+ dl_choice: str,
21
+ canny_low: int,
22
+ canny_high: int,
23
+ harris_k: float,
24
+ harris_block: int,
25
+ harris_ksize: int,
26
+ hough_thresh: int,
27
+ hough_min_len: int,
28
+ hough_max_gap: int,
29
+ ellipse_min_area: int,
30
+ max_ellipses: int,
31
+ ) -> Tuple[Optional[np.ndarray], Optional[np.ndarray], Dict[str, Any]]:
32
+ if image is None:
33
+ return None, None, {"info": "No image provided."}
34
+
35
+ params = {
36
+ "canny_low": int(canny_low),
37
+ "canny_high": int(canny_high),
38
+ "harris_k": float(harris_k),
39
+ "harris_block": int(harris_block),
40
+ "harris_ksize": int(harris_ksize),
41
+ "hough_thresh": int(hough_thresh),
42
+ "hough_min_len": int(hough_min_len),
43
+ "hough_max_gap": int(hough_max_gap),
44
+ "ellipse_min_area": int(ellipse_min_area),
45
+ "max_ellipses": int(max_ellipses),
46
+ }
47
+
48
+ mode = "both" if compare else "classical"
49
+ dl_model = dl_choice.strip() or None
50
+ result = run_detection(image, detector, params=params, mode=mode, dl_choice=dl_model)
51
+
52
+ classical_img = result.overlays.get("classical")
53
+ dl_img = result.overlays.get("dl")
54
+ meta = {
55
+ "timings_ms": result.timings_ms,
56
+ "fps_estimate": result.fps_estimate,
57
+ "features": result.features,
58
+ "models": result.models,
59
+ }
60
+ return classical_img, dl_img, meta
61
+
62
+
63
+ def build_demo() -> gr.Blocks:
64
+ defaults = dict(DEFAULT_PARAMS)
65
+
66
+ with gr.Blocks(title=TITLE) as demo:
67
+ gr.Markdown(f"# {TITLE}\n{DESC}")
68
+
69
+ with gr.Row():
70
+ with gr.Column(scale=1):
71
+ in_img = gr.Image(
72
+ type="numpy",
73
+ label="Input image (Upload / Clipboard / Webcam)",
74
+ )
75
+
76
+ detector = gr.Radio(
77
+ [
78
+ "Edges (Canny)",
79
+ "Corners (Harris)",
80
+ "Lines (Hough)",
81
+ "Ellipses (Contours + fitEllipse)",
82
+ ],
83
+ value="Edges (Canny)",
84
+ label="Detector",
85
+ )
86
+
87
+ compare = gr.Checkbox(value=True, label="Compare Classical vs DL side-by-side")
88
+ dl_choice = gr.Textbox(value="", label="DL model filename (optional, in ./models)")
89
+
90
+ with gr.Accordion("Parameters", open=False):
91
+ canny_low = gr.Slider(0, 255, value=defaults["canny_low"], step=1, label="Canny low threshold")
92
+ canny_high = gr.Slider(0, 255, value=defaults["canny_high"], step=1, label="Canny high threshold")
93
+ harris_k = gr.Slider(0.02, 0.15, value=defaults["harris_k"], step=0.005, label="Harris k")
94
+ harris_block = gr.Slider(2, 8, value=defaults["harris_block"], step=1, label="Harris blockSize")
95
+ harris_ksize = gr.Slider(3, 7, value=defaults["harris_ksize"], step=2, label="Harris ksize (odd)")
96
+ hough_thresh = gr.Slider(1, 200, value=defaults["hough_thresh"], step=1, label="Hough threshold")
97
+ hough_min_len = gr.Slider(1, 300, value=defaults["hough_min_len"], step=1, label="Hough minLineLength")
98
+ hough_max_gap = gr.Slider(0, 50, value=defaults["hough_max_gap"], step=1, label="Hough maxLineGap")
99
+ ellipse_min_area = gr.Slider(10, 50000, value=defaults["ellipse_min_area"], step=10, label="Ellipse min area (px^2)")
100
+ max_ellipses = gr.Slider(1, 20, value=defaults["max_ellipses"], step=1, label="Max ellipses")
101
+
102
+ with gr.Accordion("DL Models (how to enable)", open=False):
103
+ dl_lines = "\n".join(
104
+ f"- **{det}**: {', '.join(models) if models else 'n/a'}" for det, models in DL_MODELS.items()
105
+ )
106
+ gr.Markdown(
107
+ f"""
108
+ Place ONNX files in `./models` (create the folder next to the repo root).
109
+
110
+ **Expected filenames (defaults):**
111
+ {dl_lines}
112
+
113
+ Backends use onnxruntime with CoreML (if available) or CPU provider.
114
+ """
115
+ )
116
+
117
+ run_btn = gr.Button("Run", variant="primary")
118
+
119
+ with gr.Column(scale=1):
120
+ with gr.Row():
121
+ out_img_classical = gr.Image(type="numpy", label="Classical Overlay", interactive=False)
122
+ out_img_dl = gr.Image(type="numpy", label="DL Overlay", interactive=False)
123
+ meta_json = gr.JSON(label="Timings / Metadata")
124
+
125
+ run_btn.click(
126
+ fn=_gradio_runtime,
127
+ inputs=[
128
+ in_img,
129
+ detector,
130
+ compare,
131
+ dl_choice,
132
+ canny_low,
133
+ canny_high,
134
+ harris_k,
135
+ harris_block,
136
+ harris_ksize,
137
+ hough_thresh,
138
+ hough_min_len,
139
+ hough_max_gap,
140
+ ellipse_min_area,
141
+ max_ellipses,
142
+ ],
143
+ outputs=[out_img_classical, out_img_dl, meta_json],
144
+ )
145
+
146
+ # Auto-run whenever a new image is captured or uploaded
147
+ in_img.change(
148
+ fn=_gradio_runtime,
149
+ inputs=[
150
+ in_img,
151
+ detector,
152
+ compare,
153
+ dl_choice,
154
+ canny_low,
155
+ canny_high,
156
+ harris_k,
157
+ harris_block,
158
+ harris_ksize,
159
+ hough_thresh,
160
+ hough_min_len,
161
+ hough_max_gap,
162
+ ellipse_min_area,
163
+ max_ellipses,
164
+ ],
165
+ outputs=[out_img_classical, out_img_dl, meta_json],
166
+ )
167
+
168
+ return demo
backend/py/app/inference/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/inference/classical.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Tuple
2
+
3
+ import cv2
4
+ import numpy as np
5
+
6
+ from .common import to_bgr, to_rgb
7
+
8
+
9
+ def detect_classical(
10
+ image: np.ndarray,
11
+ detector: str,
12
+ canny_low: int,
13
+ canny_high: int,
14
+ harris_k: float,
15
+ harris_block: int,
16
+ harris_ksize: int,
17
+ hough_thresh: int,
18
+ hough_min_len: int,
19
+ hough_max_gap: int,
20
+ ellipse_min_area: int,
21
+ max_ellipses: int,
22
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
23
+ bgr = to_bgr(image)
24
+ gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
25
+
26
+ overlay = bgr.copy()
27
+ meta: Dict[str, Any] = {"path": "classical"}
28
+
29
+ if detector == "Edges (Canny)":
30
+ edges = cv2.Canny(gray, canny_low, canny_high, L2gradient=True)
31
+ overlay[edges > 0] = (0, 255, 0)
32
+ meta["num_edge_pixels"] = int(np.count_nonzero(edges))
33
+
34
+ elif detector == "Corners (Harris)":
35
+ gray32 = np.float32(gray)
36
+ dst = cv2.cornerHarris(gray32, blockSize=harris_block, ksize=harris_ksize, k=harris_k)
37
+ dst = cv2.dilate(dst, None)
38
+ thresh = 0.01 * dst.max() if dst.max() > 0 else 0.0
39
+ corners = np.argwhere(dst > thresh)
40
+ for (y, x) in corners:
41
+ cv2.circle(overlay, (int(x), int(y)), 2, (0, 255, 255), -1)
42
+ meta["num_corners"] = int(len(corners))
43
+
44
+ elif detector == "Lines (Hough)":
45
+ edges = cv2.Canny(gray, canny_low, canny_high, L2gradient=True)
46
+ lines = cv2.HoughLinesP(edges, rho=1, theta=np.pi / 180, threshold=hough_thresh,
47
+ minLineLength=hough_min_len, maxLineGap=hough_max_gap)
48
+ n = 0
49
+ if lines is not None:
50
+ for l in lines:
51
+ x1, y1, x2, y2 = l[0]
52
+ cv2.line(overlay, (x1, y1), (x2, y2), (255, 128, 0), 2)
53
+ n = len(lines)
54
+ meta["num_lines"] = int(n)
55
+
56
+ elif detector == "Ellipses (Contours + fitEllipse)":
57
+ edges = cv2.Canny(gray, canny_low, canny_high, L2gradient=True)
58
+ contours, _ = cv2.findContours(edges, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
59
+ ellipses = []
60
+ for cnt in contours:
61
+ if len(cnt) < 5:
62
+ continue
63
+ try:
64
+ (cx, cy), (MA, ma), angle = cv2.fitEllipse(cnt)
65
+ area = float(np.pi * (MA / 2) * (ma / 2))
66
+ if area >= ellipse_min_area:
67
+ ellipses.append(((cx, cy), (MA, ma), angle, area))
68
+ except cv2.error:
69
+ continue
70
+ ellipses.sort(key=lambda e: e[3], reverse=True)
71
+ kept = []
72
+ for e in ellipses:
73
+ if len(kept) >= max_ellipses:
74
+ break
75
+ (cx, cy), (MA, ma), angle, area = e
76
+ if all((cx - kx) ** 2 + (cy - ky) ** 2 > 100 for ((kx, ky), _, _, _) in kept):
77
+ kept.append(e)
78
+ for (cx, cy), (MA, ma), angle, area in kept:
79
+ cv2.ellipse(overlay, ((int(cx), int(cy)), (int(MA), int(ma)), float(angle)), (0, 200, 255), 2)
80
+ cv2.circle(overlay, (int(cx), int(cy)), 2, (0, 200, 255), -1)
81
+ meta["num_ellipses"] = int(len(kept))
82
+
83
+ else:
84
+ meta["error"] = f"Unknown detector: {detector}"
85
+
86
+ return to_rgb(overlay), meta
87
+
backend/py/app/inference/common.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cv2
2
+ import numpy as np
3
+
4
+
5
+ def to_bgr(img: np.ndarray) -> np.ndarray:
6
+ if img.ndim == 2:
7
+ return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
8
+ if img.shape[2] == 4:
9
+ return cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
10
+ return cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
11
+
12
+
13
+ def to_rgb(img: np.ndarray) -> np.ndarray:
14
+ return cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
15
+
backend/py/app/inference/dl.py ADDED
@@ -0,0 +1,171 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any, Dict, Optional, Tuple
3
+
4
+ import cv2
5
+ import numpy as np
6
+
7
+ from .common import to_bgr, to_rgb
8
+
9
+ try:
10
+ import onnxruntime as ort # type: ignore
11
+ except Exception: # pragma: no cover
12
+ ort = None # type: ignore
13
+
14
+
15
+ MODEL_DIR = os.path.join(os.getcwd(), "models")
16
+
17
+ DL_MODELS = {
18
+ "Edges (Canny)": ["hed.onnx", "dexined.onnx"],
19
+ "Corners (Harris)": ["superpoint.onnx"],
20
+ "Lines (Hough)": ["sold2.onnx", "hawp.onnx"],
21
+ "Ellipses (Contours + fitEllipse)": ["ellipse_head.onnx"],
22
+ }
23
+
24
+
25
+ def _find_model(detector: str, choice_name: Optional[str]) -> Optional[str]:
26
+ if choice_name:
27
+ p = os.path.join(MODEL_DIR, choice_name)
28
+ return p if os.path.isfile(p) else None
29
+ for fname in DL_MODELS.get(detector, []):
30
+ p = os.path.join(MODEL_DIR, fname)
31
+ if os.path.isfile(p):
32
+ return p
33
+ return None
34
+
35
+
36
+ def _load_session(path: str):
37
+ if ort is None:
38
+ raise RuntimeError("onnxruntime not installed. `pip install onnxruntime`.")
39
+ providers = ["CoreMLExecutionProvider", "CPUExecutionProvider"] if "darwin" in os.sys.platform else ["CPUExecutionProvider"]
40
+ try:
41
+ return ort.InferenceSession(path, providers=providers)
42
+ except Exception as e:
43
+ raise RuntimeError(f"Failed to load ONNX model '{path}': {e}")
44
+
45
+
46
+ def detect_dl(
47
+ image: np.ndarray,
48
+ detector: str,
49
+ model_choice: Optional[str],
50
+ ) -> Tuple[np.ndarray, Dict[str, Any]]:
51
+ bgr = to_bgr(image)
52
+ rgb = to_rgb(bgr)
53
+ h, w = rgb.shape[:2]
54
+ meta: Dict[str, Any] = {"path": "dl"}
55
+
56
+ model_path = _find_model(detector, model_choice)
57
+ if model_path is None:
58
+ meta["warning"] = (
59
+ f"No ONNX model found for '{detector}'. Place a model in ./models."
60
+ f" Expected one of: {DL_MODELS.get(detector, [])}"
61
+ )
62
+ return rgb, meta
63
+
64
+ meta["model_path"] = model_path
65
+
66
+ try:
67
+ sess = _load_session(model_path)
68
+ except Exception as e:
69
+ meta["error"] = str(e)
70
+ return rgb, meta
71
+
72
+ input_name = sess.get_inputs()[0].name
73
+ in_shape = sess.get_inputs()[0].shape # e.g., [1,3,H,W] or dynamic
74
+ target_h, target_w = None, None
75
+ if len(in_shape) == 4:
76
+ target_h = in_shape[2] if isinstance(in_shape[2], int) and in_shape[2] > 0 else 512
77
+ target_w = in_shape[3] if isinstance(in_shape[3], int) and in_shape[3] > 0 else 512
78
+ else:
79
+ target_h, target_w = 512, 512
80
+
81
+ img_resized = cv2.resize(rgb, (target_w, target_h), interpolation=cv2.INTER_AREA)
82
+ x = img_resized.astype(np.float32) / 255.0
83
+ if x.ndim == 2:
84
+ x = np.expand_dims(x, axis=-1)
85
+ if x.shape[2] == 1:
86
+ x = np.repeat(x, 3, axis=2)
87
+ x = np.transpose(x, (2, 0, 1))[None, ...] # NCHW
88
+
89
+ try:
90
+ outputs = sess.run(None, {input_name: x})
91
+ except Exception as e:
92
+ meta["error"] = f"ONNX inference failed: {e}"
93
+ return rgb, meta
94
+
95
+ overlay = rgb.copy()
96
+ if detector == "Edges (Canny)":
97
+ pred = outputs[0]
98
+ if pred.ndim == 4:
99
+ prob = pred[0, 0]
100
+ prob = (prob - prob.min()) / (prob.max() - prob.min() + 1e-8)
101
+ edges = (prob > 0.5).astype(np.uint8) * 255
102
+ edges = cv2.resize(edges, (w, h), interpolation=cv2.INTER_NEAREST)
103
+ bgr2 = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
104
+ bgr2[edges > 0] = (0, 255, 0)
105
+ overlay = cv2.cvtColor(bgr2, cv2.COLOR_BGR2RGB)
106
+ meta["edge_prob_mean"] = float(prob.mean())
107
+ else:
108
+ meta["warning"] = "Unexpected model output shape for edges."
109
+
110
+ elif detector == "Corners (Harris)":
111
+ pred = outputs[0]
112
+ if pred.ndim == 4:
113
+ heat = pred[0, 0]
114
+ heat = (heat - heat.min()) / (heat.max() - heat.min() + 1e-8)
115
+ heat = cv2.resize(heat, (w, h), interpolation=cv2.INTER_CUBIC)
116
+ ys, xs = np.where(heat > 0.5)
117
+ overlay = rgb.copy()
118
+ for (y, x_) in zip(ys.tolist(), xs.tolist()):
119
+ cv2.circle(overlay, (int(x_), int(y)), 2, (0, 255, 255), -1)
120
+ meta["num_corners"] = int(len(xs))
121
+ else:
122
+ meta["warning"] = "Unexpected model output shape for corners."
123
+
124
+ elif detector == "Lines (Hough)":
125
+ pred = outputs[0]
126
+ if pred.ndim == 4:
127
+ heat = pred[0, 0]
128
+ heat = (heat - heat.min()) / (heat.max() - heat.min() + 1e-8)
129
+ mask = (heat > 0.5).astype(np.uint8) * 255
130
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
131
+ lines = cv2.HoughLinesP(mask, 1, np.pi/180, 50, minLineLength=30, maxLineGap=5)
132
+ overlay = rgb.copy()
133
+ n = 0
134
+ if lines is not None:
135
+ for l in lines:
136
+ x1, y1, x2, y2 = l[0]
137
+ cv2.line(overlay, (x1, y1), (x2, y2), (255, 128, 0), 2)
138
+ n = len(lines)
139
+ meta["num_lines"] = int(n)
140
+ else:
141
+ meta["warning"] = "Unexpected model output for lines."
142
+
143
+ elif detector == "Ellipses (Contours + fitEllipse)":
144
+ pred = outputs[0]
145
+ if pred.ndim == 4:
146
+ heat = pred[0, 0]
147
+ heat = (heat - heat.min()) / (heat.max() - heat.min() + 1e-8)
148
+ mask = (heat > 0.5).astype(np.uint8) * 255
149
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
150
+ contours, _ = cv2.findContours(mask, cv2.RETR_LIST, cv2.CHAIN_APPROX_NONE)
151
+ count = 0
152
+ for cnt in contours:
153
+ if len(cnt) < 5:
154
+ continue
155
+ try:
156
+ (cx, cy), (MA, ma), angle = cv2.fitEllipse(cnt)
157
+ area = float(np.pi * (MA / 2) * (ma / 2))
158
+ if area >= 300:
159
+ cv2.ellipse(overlay, ((int(cx), int(cy)), (int(MA), int(ma)), float(angle)), (0, 200, 255), 2)
160
+ count += 1
161
+ except cv2.error:
162
+ continue
163
+ meta["num_ellipses"] = int(count)
164
+ else:
165
+ meta["warning"] = "Unexpected model output for ellipses."
166
+
167
+ else:
168
+ meta["error"] = f"Unknown detector: {detector}"
169
+
170
+ return overlay, meta
171
+
backend/py/app/main.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import Any
3
+
4
+ import gradio as gr
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ from .api.v1.detect import router as detect_router
9
+ from .gradio_demo.ui import build_demo
10
+
11
+
12
+ def create_app() -> FastAPI:
13
+ app = FastAPI(title="FeatureLab Runtime", version="0.3.0")
14
+
15
+ app.add_middleware(
16
+ CORSMiddleware,
17
+ allow_origins=["*"],
18
+ allow_credentials=False,
19
+ allow_methods=["*"],
20
+ allow_headers=["*"],
21
+ )
22
+
23
+ app.include_router(detect_router)
24
+
25
+ demo = build_demo()
26
+ gr.mount_gradio_app(app, demo, path="/")
27
+
28
+ @app.get("/health")
29
+ async def healthcheck() -> Any:
30
+ return {"status": "ok"}
31
+
32
+ return app
33
+
34
+
35
+ app = create_app()
36
+
backend/py/app/services/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/services/runtime_adapter.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import time
3
+ from dataclasses import dataclass, field
4
+ from typing import Any, Dict, Literal, Optional
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+ from ..inference.classical import detect_classical
10
+ from ..inference.dl import DL_MODELS, detect_dl
11
+
12
+
13
+ DEFAULT_PARAMS: Dict[str, Any] = {
14
+ "canny_low": 50,
15
+ "canny_high": 150,
16
+ "harris_k": 0.05,
17
+ "harris_block": 2,
18
+ "harris_ksize": 3,
19
+ "hough_thresh": 50,
20
+ "hough_min_len": 30,
21
+ "hough_max_gap": 5,
22
+ "ellipse_min_area": 300,
23
+ "max_ellipses": 5,
24
+ }
25
+
26
+ PARAM_TYPES: Dict[str, Any] = {
27
+ "canny_low": int,
28
+ "canny_high": int,
29
+ "harris_k": float,
30
+ "harris_block": int,
31
+ "harris_ksize": int,
32
+ "hough_thresh": int,
33
+ "hough_min_len": int,
34
+ "hough_max_gap": int,
35
+ "ellipse_min_area": int,
36
+ "max_ellipses": int,
37
+ }
38
+
39
+ CLASSICAL_MODEL_INFO = {"name": "opencv-classical", "version": cv2.__version__}
40
+
41
+ try:
42
+ import onnxruntime as ort # type: ignore
43
+ except Exception: # pragma: no cover
44
+ ort = None # type: ignore
45
+
46
+ DL_MODEL_INFO = {
47
+ "name": "onnxruntime" if ort is not None else "onnxruntime-missing",
48
+ "version": getattr(ort, "__version__", "unknown"),
49
+ }
50
+
51
+
52
+ def merge_params(params: Optional[Dict[str, Any]]) -> Dict[str, Any]:
53
+ merged = DEFAULT_PARAMS.copy()
54
+ if params:
55
+ for key, value in params.items():
56
+ if value is None or key not in DEFAULT_PARAMS:
57
+ continue
58
+ caster = PARAM_TYPES.get(key, lambda x: x)
59
+ try:
60
+ merged[key] = caster(value)
61
+ except (TypeError, ValueError):
62
+ continue
63
+ return merged
64
+
65
+
66
+ @dataclass
67
+ class DetectionResult:
68
+ overlays: Dict[str, np.ndarray] = field(default_factory=dict)
69
+ features: Dict[str, Dict[str, Any]] = field(default_factory=dict)
70
+ timings_ms: Dict[str, float] = field(default_factory=dict)
71
+ fps_estimate: Optional[float] = None
72
+ models: Dict[str, Dict[str, Any]] = field(default_factory=dict)
73
+
74
+
75
+ def run_detection(
76
+ image: np.ndarray,
77
+ detector: str,
78
+ params: Optional[Dict[str, Any]] = None,
79
+ mode: Literal["classical", "dl", "both"] = "classical",
80
+ dl_choice: Optional[str] = None,
81
+ ) -> DetectionResult:
82
+ merged = merge_params(params)
83
+ overlays: Dict[str, np.ndarray] = {}
84
+ features: Dict[str, Dict[str, Any]] = {}
85
+ timings: Dict[str, float] = {}
86
+ models: Dict[str, Dict[str, Any]] = {}
87
+
88
+ execute_classical = mode in ("classical", "both")
89
+ execute_dl = mode in ("dl", "both")
90
+
91
+ total_ms = 0.0
92
+
93
+ if execute_classical:
94
+ t0 = time.perf_counter()
95
+ classical_img, classical_meta = detect_classical(
96
+ image,
97
+ detector,
98
+ merged["canny_low"],
99
+ merged["canny_high"],
100
+ merged["harris_k"],
101
+ merged["harris_block"],
102
+ merged["harris_ksize"],
103
+ merged["hough_thresh"],
104
+ merged["hough_min_len"],
105
+ merged["hough_max_gap"],
106
+ merged["ellipse_min_area"],
107
+ merged["max_ellipses"],
108
+ )
109
+ t_ms = (time.perf_counter() - t0) * 1000.0
110
+ overlays["classical"] = classical_img
111
+ features["classical"] = classical_meta
112
+ timings["classical"] = round(t_ms, 2)
113
+ models["classical"] = CLASSICAL_MODEL_INFO
114
+ total_ms += t_ms
115
+
116
+ if execute_dl:
117
+ t0 = time.perf_counter()
118
+ dl_img, dl_meta = detect_dl(image, detector, dl_choice)
119
+ t_ms = (time.perf_counter() - t0) * 1000.0
120
+ overlays["dl"] = dl_img
121
+ features["dl"] = dl_meta
122
+ timings["dl"] = round(t_ms, 2)
123
+ model_name = (
124
+ os.path.basename(dl_meta["model_path"]) if "model_path" in dl_meta else DL_MODEL_INFO["name"]
125
+ )
126
+ models["dl"] = {"name": model_name, "version": DL_MODEL_INFO["version"]}
127
+ total_ms += t_ms
128
+
129
+ timings["total"] = round(total_ms, 2)
130
+ fps = round(1000.0 / total_ms, 2) if total_ms > 0 else None
131
+
132
+ return DetectionResult(
133
+ overlays=overlays,
134
+ features=features,
135
+ timings_ms=timings,
136
+ fps_estimate=fps,
137
+ models=models,
138
+ )
139
+
140
+
141
+ __all__ = [
142
+ "DetectionResult",
143
+ "DEFAULT_PARAMS",
144
+ "DL_MODELS",
145
+ "merge_params",
146
+ "run_detection",
147
+ ]
148
+
backend/py/app/utils/__init__.py ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ __all__ = []
2
+
backend/py/app/utils/image_io.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from typing import Optional
3
+
4
+ import cv2
5
+ import numpy as np
6
+
7
+
8
+ def encode_png_base64(image: np.ndarray) -> str:
9
+ bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
10
+ success, buf = cv2.imencode('.png', bgr)
11
+ if not success:
12
+ raise RuntimeError('Failed to encode image to PNG.')
13
+ return base64.b64encode(buf.tobytes()).decode('ascii')
14
+
15
+
16
+ def decode_base64_image(data: str) -> np.ndarray:
17
+ # Support data URLs: data:image/png;base64,....
18
+ if ',' in data and data.strip().startswith('data:'):
19
+ data = data.split(',', 1)[1]
20
+ binary = base64.b64decode(data)
21
+ arr = np.frombuffer(binary, dtype=np.uint8)
22
+ image = cv2.imdecode(arr, cv2.IMREAD_COLOR)
23
+ if image is None:
24
+ raise ValueError('Unable to decode image.')
25
+ return cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
26
+
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=4.44.0
2
+ opencv-python-headless>=4.10.0.84
3
+ numpy>=1.26.0
4
+ fastapi>=0.112.0
5
+ uvicorn>=0.30.0
6
+ python-multipart>=0.0.9