yenslife commited on
Commit
429d013
·
1 Parent(s): dcd4485

FEAT: Add Jinja2 demo page

Browse files

新增 /demo 路由與 Jinja2 前端頁面,可上傳圖片並顯示模型預測結果。\n保留既有 /predict JSON API,並將 jinja2 加入直接依賴。

Files changed (4) hide show
  1. app.py +81 -14
  2. pyproject.toml +1 -0
  3. templates/demo.html +205 -0
  4. uv.lock +2 -0
app.py CHANGED
@@ -1,11 +1,18 @@
 
1
  from contextlib import asynccontextmanager
2
  from io import BytesIO
 
3
 
4
- from fastapi import FastAPI, File, HTTPException, UploadFile
 
 
5
  from PIL import Image, UnidentifiedImageError
6
 
7
  from model_service import MODEL_PATH, get_model_service
8
 
 
 
 
9
 
10
  @asynccontextmanager
11
  async def lifespan(_: FastAPI):
@@ -22,22 +29,21 @@ app = FastAPI(
22
  )
23
 
24
 
25
- @app.get("/")
26
- def root():
27
- return {
28
- "message": "Presence Detection API",
29
- "docs": "/docs",
30
- "model_path": str(MODEL_PATH.name),
 
 
 
31
  }
 
 
32
 
33
 
34
- @app.get("/health")
35
- def health():
36
- return {"status": "ok", "model_loaded": True}
37
-
38
-
39
- @app.post("/predict")
40
- async def predict(file: UploadFile = File(...)):
41
  if not file.content_type or not file.content_type.startswith("image/"):
42
  raise HTTPException(status_code=400, detail="Uploaded file must be an image.")
43
 
@@ -53,4 +59,65 @@ async def predict(file: UploadFile = File(...)):
53
  result = get_model_service().predict_image(image)
54
  result["filename"] = file.filename
55
  result["content_type"] = file.content_type
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  return result
 
1
+ import base64
2
  from contextlib import asynccontextmanager
3
  from io import BytesIO
4
+ from pathlib import Path
5
 
6
+ from fastapi import FastAPI, File, HTTPException, Request, UploadFile
7
+ from fastapi.responses import HTMLResponse
8
+ from fastapi.templating import Jinja2Templates
9
  from PIL import Image, UnidentifiedImageError
10
 
11
  from model_service import MODEL_PATH, get_model_service
12
 
13
+ BASE_DIR = Path(__file__).resolve().parent
14
+ templates = Jinja2Templates(directory=str(BASE_DIR / "templates"))
15
+
16
 
17
  @asynccontextmanager
18
  async def lifespan(_: FastAPI):
 
29
  )
30
 
31
 
32
+ def _build_demo_context(**overrides):
33
+ context = {
34
+ "image_data_url": None,
35
+ "result_label": "Normal",
36
+ "result_label_zh": "正常",
37
+ "class_label": "-",
38
+ "confidence": "-",
39
+ "acc": "-",
40
+ "error": None,
41
  }
42
+ context.update(overrides)
43
+ return context
44
 
45
 
46
+ async def _predict_upload(file: UploadFile) -> tuple[dict, bytes]:
 
 
 
 
 
 
47
  if not file.content_type or not file.content_type.startswith("image/"):
48
  raise HTTPException(status_code=400, detail="Uploaded file must be an image.")
49
 
 
59
  result = get_model_service().predict_image(image)
60
  result["filename"] = file.filename
61
  result["content_type"] = file.content_type
62
+ return result, data
63
+
64
+
65
+ @app.get("/")
66
+ def root():
67
+ return {
68
+ "message": "Presence Detection API",
69
+ "docs": "/docs",
70
+ "model_path": str(MODEL_PATH.name),
71
+ }
72
+
73
+
74
+ @app.get("/health")
75
+ def health():
76
+ return {"status": "ok", "model_loaded": True}
77
+
78
+
79
+ @app.get("/demo", response_class=HTMLResponse)
80
+ def demo_page(request: Request):
81
+ return templates.TemplateResponse(
82
+ request,
83
+ "demo.html",
84
+ _build_demo_context(),
85
+ )
86
+
87
+
88
+ @app.post("/demo", response_class=HTMLResponse)
89
+ async def demo_predict(request: Request, file: UploadFile = File(...)):
90
+ try:
91
+ result, data = await _predict_upload(file)
92
+ except HTTPException as exc:
93
+ return templates.TemplateResponse(
94
+ request,
95
+ "demo.html",
96
+ _build_demo_context(error=exc.detail),
97
+ status_code=exc.status_code,
98
+ )
99
+
100
+ pred_label = result["label"]
101
+ pred_conf = result["probabilities"][pred_label]
102
+ image_data_url = (
103
+ f"data:{result['content_type']};base64,"
104
+ f"{base64.b64encode(data).decode('ascii')}"
105
+ )
106
+ return templates.TemplateResponse(
107
+ request,
108
+ "demo.html",
109
+ _build_demo_context(
110
+ image_data_url=image_data_url,
111
+ result_label=pred_label,
112
+ result_label_zh="有人" if pred_label == "person" else "正常",
113
+ class_label=pred_label,
114
+ confidence=f"{pred_conf * 100:.2f}%",
115
+ acc=f"{pred_conf * 100:.2f}%",
116
+ ),
117
+ )
118
+
119
+
120
+ @app.post("/predict")
121
+ async def predict(file: UploadFile = File(...)):
122
+ result, _ = await _predict_upload(file)
123
  return result
pyproject.toml CHANGED
@@ -18,4 +18,5 @@ dependencies = [
18
  "torchvision>=0.25.0",
19
  "tqdm>=4.67.3",
20
  "ultralytics>=8.4.17",
 
21
  ]
 
18
  "torchvision>=0.25.0",
19
  "tqdm>=4.67.3",
20
  "ultralytics>=8.4.17",
21
+ "jinja2>=3.1.6",
22
  ]
templates/demo.html ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="zh-Hant">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Presence Detection Demo</title>
7
+ <style>
8
+ :root {
9
+ --panel-bg: #dbdbdb;
10
+ --box-bg: #bcd1e5;
11
+ --box-bg-strong: #b6cbe0;
12
+ --line: #6f6f6f;
13
+ --text: #4f4f4f;
14
+ }
15
+
16
+ * {
17
+ box-sizing: border-box;
18
+ }
19
+
20
+ body {
21
+ margin: 0;
22
+ min-height: 100vh;
23
+ display: grid;
24
+ place-items: center;
25
+ background: #f2f2f2;
26
+ color: var(--text);
27
+ font-family: "Noto Sans TC", "PingFang TC", "Microsoft JhengHei", sans-serif;
28
+ }
29
+
30
+ .panel {
31
+ width: min(92vw, 680px);
32
+ min-height: 520px;
33
+ background: var(--panel-bg);
34
+ border: 2px solid var(--line);
35
+ padding: 52px 38px;
36
+ display: flex;
37
+ flex-direction: column;
38
+ gap: 34px;
39
+ }
40
+
41
+ .top-row {
42
+ display: grid;
43
+ grid-template-columns: minmax(220px, 1fr) 1fr;
44
+ gap: 34px;
45
+ align-items: center;
46
+ }
47
+
48
+ .upload-wrap {
49
+ display: flex;
50
+ flex-direction: column;
51
+ gap: 10px;
52
+ }
53
+
54
+ .upload-box {
55
+ width: 100%;
56
+ aspect-ratio: 1.25 / 1;
57
+ border: 2px solid var(--line);
58
+ background: var(--box-bg);
59
+ color: #111;
60
+ display: grid;
61
+ place-items: center;
62
+ text-align: center;
63
+ cursor: pointer;
64
+ overflow: hidden;
65
+ position: relative;
66
+ font-size: 22px;
67
+ }
68
+
69
+ .upload-box img {
70
+ width: 100%;
71
+ height: 100%;
72
+ object-fit: contain;
73
+ background: #fff;
74
+ }
75
+
76
+ .upload-hint {
77
+ font-size: 13px;
78
+ color: #666;
79
+ text-align: center;
80
+ }
81
+
82
+ .stats {
83
+ font-size: clamp(20px, 2.6vw, 30px);
84
+ line-height: 1.35;
85
+ letter-spacing: 0.2px;
86
+ }
87
+
88
+ .stats .error {
89
+ margin-top: 14px;
90
+ color: #a22;
91
+ font-size: 14px;
92
+ line-height: 1.4;
93
+ word-break: break-word;
94
+ }
95
+
96
+ .actions {
97
+ margin-top: 6px;
98
+ display: grid;
99
+ place-items: center;
100
+ gap: 14px;
101
+ }
102
+
103
+ .result-pill {
104
+ width: min(100%, 320px);
105
+ height: 88px;
106
+ border: 2px solid var(--line);
107
+ background: var(--box-bg-strong);
108
+ display: grid;
109
+ place-items: center;
110
+ font-size: clamp(22px, 4.5vw, 36px);
111
+ color: #4c4c4c;
112
+ }
113
+
114
+ .submit-btn {
115
+ width: min(100%, 320px);
116
+ padding: 12px 16px;
117
+ border: 2px solid var(--line);
118
+ background: #e9edf1;
119
+ color: #333;
120
+ font-size: 16px;
121
+ cursor: pointer;
122
+ }
123
+
124
+ .submit-btn:hover {
125
+ background: #f1f4f7;
126
+ }
127
+
128
+ .file-input {
129
+ display: none;
130
+ }
131
+
132
+ @media (max-width: 640px) {
133
+ .panel {
134
+ min-height: auto;
135
+ padding: 24px 18px;
136
+ gap: 22px;
137
+ }
138
+
139
+ .top-row {
140
+ grid-template-columns: 1fr;
141
+ gap: 18px;
142
+ }
143
+
144
+ .upload-box {
145
+ font-size: 18px;
146
+ }
147
+
148
+ .stats {
149
+ font-size: 22px;
150
+ }
151
+ }
152
+ </style>
153
+ </head>
154
+ <body>
155
+ <form class="panel" action="/demo" method="post" enctype="multipart/form-data">
156
+ <div class="top-row">
157
+ <div class="upload-wrap">
158
+ <label class="upload-box" for="fileInput" id="uploadBox">
159
+ {% if image_data_url %}
160
+ <img src="{{ image_data_url }}" alt="Input image preview" id="previewImage">
161
+ {% else %}
162
+ <span id="placeholderText">Input Image</span>
163
+ <img src="" alt="Input image preview" id="previewImage" style="display:none;">
164
+ {% endif %}
165
+ </label>
166
+ <input class="file-input" id="fileInput" type="file" name="file" accept="image/*" required>
167
+ <div class="upload-hint">點擊圖片區塊選擇檔案</div>
168
+ </div>
169
+
170
+ <div class="stats">
171
+ <div>Class: {{ class_label }}</div>
172
+ <div>Confidence: {{ confidence }}</div>
173
+ <div>Acc: {{ acc }}</div>
174
+ {% if error %}
175
+ <div class="error">{{ error }}</div>
176
+ {% endif %}
177
+ </div>
178
+ </div>
179
+
180
+ <div class="actions">
181
+ <div class="result-pill">{{ result_label_zh }}</div>
182
+ <button class="submit-btn" type="submit">開始預測</button>
183
+ </div>
184
+ </form>
185
+
186
+ <script>
187
+ const fileInput = document.getElementById("fileInput");
188
+ const previewImage = document.getElementById("previewImage");
189
+ const placeholderText = document.getElementById("placeholderText");
190
+
191
+ fileInput.addEventListener("change", (event) => {
192
+ const file = event.target.files?.[0];
193
+ if (!file) return;
194
+
195
+ const reader = new FileReader();
196
+ reader.onload = () => {
197
+ if (placeholderText) placeholderText.style.display = "none";
198
+ previewImage.src = reader.result;
199
+ previewImage.style.display = "block";
200
+ };
201
+ reader.readAsDataURL(file);
202
+ });
203
+ </script>
204
+ </body>
205
+ </html>
uv.lock CHANGED
@@ -1234,6 +1234,7 @@ version = "0.1.0"
1234
  source = { virtual = "." }
1235
  dependencies = [
1236
  { name = "fastapi" },
 
1237
  { name = "matplotlib" },
1238
  { name = "numpy" },
1239
  { name = "opencv-python" },
@@ -1251,6 +1252,7 @@ dependencies = [
1251
  [package.metadata]
1252
  requires-dist = [
1253
  { name = "fastapi", specifier = ">=0.133.1" },
 
1254
  { name = "matplotlib", specifier = ">=3.10.8" },
1255
  { name = "numpy", specifier = ">=2.4.2" },
1256
  { name = "opencv-python", specifier = ">=4.13.0.92" },
 
1234
  source = { virtual = "." }
1235
  dependencies = [
1236
  { name = "fastapi" },
1237
+ { name = "jinja2" },
1238
  { name = "matplotlib" },
1239
  { name = "numpy" },
1240
  { name = "opencv-python" },
 
1252
  [package.metadata]
1253
  requires-dist = [
1254
  { name = "fastapi", specifier = ">=0.133.1" },
1255
+ { name = "jinja2", specifier = ">=3.1.6" },
1256
  { name = "matplotlib", specifier = ">=3.10.8" },
1257
  { name = "numpy", specifier = ">=2.4.2" },
1258
  { name = "opencv-python", specifier = ">=4.13.0.92" },