Spaces:
Running
Running
Upload folder using huggingface_hub
Browse files- web_demo/app.py +48 -11
- web_demo/static/app.js +18 -15
- web_demo/static/styles.css +25 -0
- web_demo/templates/index.html +18 -0
web_demo/app.py
CHANGED
|
@@ -10,10 +10,12 @@ import csv
|
|
| 10 |
import io
|
| 11 |
import json
|
| 12 |
import os
|
|
|
|
| 13 |
import sys
|
| 14 |
import uuid
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import Dict, Any
|
| 17 |
|
| 18 |
import cv2
|
| 19 |
import numpy as np
|
|
@@ -43,6 +45,19 @@ def _allowed_file(filename: str) -> bool:
|
|
| 43 |
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
| 44 |
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
class _NumpyEncoder(json.JSONEncoder):
|
| 47 |
"""Handle numpy types that aren't natively JSON serializable."""
|
| 48 |
def default(self, obj):
|
|
@@ -86,7 +101,13 @@ def _save_json(path: Path, data: Dict[str, Any]) -> None:
|
|
| 86 |
|
| 87 |
@app.route("/")
|
| 88 |
def index():
|
| 89 |
-
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
@app.route("/results/<path:filename>")
|
|
@@ -117,9 +138,9 @@ def api_measure():
|
|
| 117 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 118 |
if ring_model not in VALID_RING_MODELS:
|
| 119 |
ring_model = DEFAULT_RING_MODEL
|
| 120 |
-
run_id =
|
| 121 |
-
|
| 122 |
-
upload_name = f"{
|
| 123 |
upload_path = UPLOAD_DIR / upload_name
|
| 124 |
upload_path.parent.mkdir(parents=True, exist_ok=True)
|
| 125 |
file.save(upload_path)
|
|
@@ -136,6 +157,8 @@ def api_measure():
|
|
| 136 |
kol_name=kol_name,
|
| 137 |
upload_path=upload_path,
|
| 138 |
upload_name=upload_name,
|
|
|
|
|
|
|
| 139 |
)
|
| 140 |
|
| 141 |
return _run_measurement(
|
|
@@ -146,6 +169,8 @@ def api_measure():
|
|
| 146 |
kol_name=kol_name,
|
| 147 |
upload_path=upload_path,
|
| 148 |
upload_name=upload_name,
|
|
|
|
|
|
|
| 149 |
)
|
| 150 |
|
| 151 |
|
|
@@ -164,12 +189,16 @@ def api_measure_default():
|
|
| 164 |
if image is None:
|
| 165 |
return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
|
| 166 |
|
|
|
|
|
|
|
| 167 |
if mode == "multi":
|
| 168 |
return _run_multi_measurement(
|
| 169 |
image=image,
|
| 170 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 171 |
ring_model=ring_model,
|
| 172 |
kol_name=kol_name,
|
|
|
|
|
|
|
| 173 |
)
|
| 174 |
|
| 175 |
return _run_measurement(
|
|
@@ -178,6 +207,8 @@ def api_measure_default():
|
|
| 178 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 179 |
ring_model=ring_model,
|
| 180 |
kol_name=kol_name,
|
|
|
|
|
|
|
| 181 |
)
|
| 182 |
|
| 183 |
|
|
@@ -189,10 +220,13 @@ def _run_measurement(
|
|
| 189 |
kol_name: str = "",
|
| 190 |
upload_path: Path = None,
|
| 191 |
upload_name: str = "",
|
|
|
|
|
|
|
| 192 |
):
|
| 193 |
-
|
|
|
|
| 194 |
|
| 195 |
-
result_png_name = f"{
|
| 196 |
result_png_path = RESULTS_DIR / result_png_name
|
| 197 |
|
| 198 |
result = measure_finger(
|
|
@@ -217,7 +251,7 @@ def _run_measurement(
|
|
| 217 |
|
| 218 |
result = _numpy_safe(result)
|
| 219 |
|
| 220 |
-
result_json_name = f"{
|
| 221 |
result_json_path = RESULTS_DIR / result_json_name
|
| 222 |
_save_json(result_json_path, result)
|
| 223 |
|
|
@@ -266,11 +300,14 @@ def _run_multi_measurement(
|
|
| 266 |
kol_name: str = "",
|
| 267 |
upload_path: Path = None,
|
| 268 |
upload_name: str = "",
|
|
|
|
|
|
|
| 269 |
):
|
| 270 |
"""Run multi-finger measurement pipeline."""
|
| 271 |
-
|
|
|
|
| 272 |
|
| 273 |
-
result_png_name = f"{
|
| 274 |
result_png_path = RESULTS_DIR / result_png_name
|
| 275 |
|
| 276 |
result = measure_multi_finger(
|
|
@@ -307,7 +344,7 @@ def _run_multi_measurement(
|
|
| 307 |
if ai_reason:
|
| 308 |
result["ai_explanation"] = ai_reason
|
| 309 |
|
| 310 |
-
result_json_name = f"{
|
| 311 |
result_json_path = RESULTS_DIR / result_json_name
|
| 312 |
_save_json(result_json_path, result)
|
| 313 |
|
|
|
|
| 10 |
import io
|
| 11 |
import json
|
| 12 |
import os
|
| 13 |
+
import re
|
| 14 |
import sys
|
| 15 |
import uuid
|
| 16 |
+
from datetime import datetime
|
| 17 |
from pathlib import Path
|
| 18 |
+
from typing import Dict, Any, Tuple
|
| 19 |
|
| 20 |
import cv2
|
| 21 |
import numpy as np
|
|
|
|
| 45 |
return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
|
| 46 |
|
| 47 |
|
| 48 |
+
def _slugify(name: str) -> str:
|
| 49 |
+
slug = re.sub(r"[^a-zA-Z0-9]+", "-", name or "").strip("-").lower()
|
| 50 |
+
return slug or "anon"
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _make_base_name(kol_name: str) -> Tuple[str, str]:
|
| 54 |
+
"""Return (base_name, run_id). base_name = '{slug}_{timestamp}_{shortid}'."""
|
| 55 |
+
run_id = uuid.uuid4().hex[:8]
|
| 56 |
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
| 57 |
+
base_name = f"{_slugify(kol_name)}_{timestamp}_{run_id}"
|
| 58 |
+
return base_name, run_id
|
| 59 |
+
|
| 60 |
+
|
| 61 |
class _NumpyEncoder(json.JSONEncoder):
|
| 62 |
"""Handle numpy types that aren't natively JSON serializable."""
|
| 63 |
def default(self, obj):
|
|
|
|
| 101 |
|
| 102 |
@app.route("/")
|
| 103 |
def index():
|
| 104 |
+
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=False)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@app.route("/dev")
|
| 108 |
+
@app.route("/debug")
|
| 109 |
+
def index_dev():
|
| 110 |
+
return render_template("index.html", default_sample_url=DEFAULT_SAMPLE_URL, dev_mode=True)
|
| 111 |
|
| 112 |
|
| 113 |
@app.route("/results/<path:filename>")
|
|
|
|
| 138 |
ring_model = request.form.get("ring_model", DEFAULT_RING_MODEL)
|
| 139 |
if ring_model not in VALID_RING_MODELS:
|
| 140 |
ring_model = DEFAULT_RING_MODEL
|
| 141 |
+
base_name, run_id = _make_base_name(kol_name)
|
| 142 |
+
suffix = Path(secure_filename(file.filename)).suffix.lower() or ".jpg"
|
| 143 |
+
upload_name = f"{base_name}{suffix}"
|
| 144 |
upload_path = UPLOAD_DIR / upload_name
|
| 145 |
upload_path.parent.mkdir(parents=True, exist_ok=True)
|
| 146 |
file.save(upload_path)
|
|
|
|
| 157 |
kol_name=kol_name,
|
| 158 |
upload_path=upload_path,
|
| 159 |
upload_name=upload_name,
|
| 160 |
+
base_name=base_name,
|
| 161 |
+
run_id=run_id,
|
| 162 |
)
|
| 163 |
|
| 164 |
return _run_measurement(
|
|
|
|
| 169 |
kol_name=kol_name,
|
| 170 |
upload_path=upload_path,
|
| 171 |
upload_name=upload_name,
|
| 172 |
+
base_name=base_name,
|
| 173 |
+
run_id=run_id,
|
| 174 |
)
|
| 175 |
|
| 176 |
|
|
|
|
| 189 |
if image is None:
|
| 190 |
return jsonify({"success": False, "error": "Failed to load default sample image"}), 500
|
| 191 |
|
| 192 |
+
base_name, run_id = _make_base_name(kol_name or "sample")
|
| 193 |
+
|
| 194 |
if mode == "multi":
|
| 195 |
return _run_multi_measurement(
|
| 196 |
image=image,
|
| 197 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 198 |
ring_model=ring_model,
|
| 199 |
kol_name=kol_name,
|
| 200 |
+
base_name=base_name,
|
| 201 |
+
run_id=run_id,
|
| 202 |
)
|
| 203 |
|
| 204 |
return _run_measurement(
|
|
|
|
| 207 |
input_image_url=DEFAULT_SAMPLE_URL,
|
| 208 |
ring_model=ring_model,
|
| 209 |
kol_name=kol_name,
|
| 210 |
+
base_name=base_name,
|
| 211 |
+
run_id=run_id,
|
| 212 |
)
|
| 213 |
|
| 214 |
|
|
|
|
| 220 |
kol_name: str = "",
|
| 221 |
upload_path: Path = None,
|
| 222 |
upload_name: str = "",
|
| 223 |
+
base_name: str = "",
|
| 224 |
+
run_id: str = "",
|
| 225 |
):
|
| 226 |
+
if not base_name:
|
| 227 |
+
base_name, run_id = _make_base_name(kol_name)
|
| 228 |
|
| 229 |
+
result_png_name = f"{base_name}_result.png"
|
| 230 |
result_png_path = RESULTS_DIR / result_png_name
|
| 231 |
|
| 232 |
result = measure_finger(
|
|
|
|
| 251 |
|
| 252 |
result = _numpy_safe(result)
|
| 253 |
|
| 254 |
+
result_json_name = f"{base_name}_result.json"
|
| 255 |
result_json_path = RESULTS_DIR / result_json_name
|
| 256 |
_save_json(result_json_path, result)
|
| 257 |
|
|
|
|
| 300 |
kol_name: str = "",
|
| 301 |
upload_path: Path = None,
|
| 302 |
upload_name: str = "",
|
| 303 |
+
base_name: str = "",
|
| 304 |
+
run_id: str = "",
|
| 305 |
):
|
| 306 |
"""Run multi-finger measurement pipeline."""
|
| 307 |
+
if not base_name:
|
| 308 |
+
base_name, run_id = _make_base_name(kol_name)
|
| 309 |
|
| 310 |
+
result_png_name = f"{base_name}_result.png"
|
| 311 |
result_png_path = RESULTS_DIR / result_png_name
|
| 312 |
|
| 313 |
result = measure_multi_finger(
|
|
|
|
| 344 |
if ai_reason:
|
| 345 |
result["ai_explanation"] = ai_reason
|
| 346 |
|
| 347 |
+
result_json_name = f"{base_name}_result.json"
|
| 348 |
result_json_path = RESULTS_DIR / result_json_name
|
| 349 |
_save_json(result_json_path, result)
|
| 350 |
|
web_demo/static/app.js
CHANGED
|
@@ -15,13 +15,13 @@ const fingerBreakdown = document.getElementById("fingerBreakdown");
|
|
| 15 |
const defaultSampleUrl = window.DEFAULT_SAMPLE_URL || "";
|
| 16 |
const failReasonMessageMap = {
|
| 17 |
card_not_detected:
|
| 18 |
-
"Credit card not detected. Place
|
| 19 |
card_not_parallel:
|
| 20 |
"Card is not parallel to the camera. Keep your phone directly above and parallel to the card.",
|
| 21 |
card_near_edge:
|
| 22 |
"Card appears cropped. Place the entire card within the photo frame.",
|
| 23 |
hand_not_detected:
|
| 24 |
-
"Hand not detected.
|
| 25 |
finger_isolation_failed:
|
| 26 |
"Could not isolate the selected finger. Keep one target finger extended and separated.",
|
| 27 |
finger_not_fully_visible:
|
|
@@ -66,23 +66,24 @@ const formatFailReasonStatus = (failReason) => {
|
|
| 66 |
}
|
| 67 |
|
| 68 |
if (failReason.startsWith("quality_score_low_")) {
|
| 69 |
-
return
|
| 70 |
}
|
| 71 |
|
| 72 |
if (failReason.startsWith("consistency_low_")) {
|
| 73 |
-
return
|
| 74 |
}
|
| 75 |
|
| 76 |
const friendlyMessage = failReasonMessageMap[failReason];
|
| 77 |
if (friendlyMessage) {
|
| 78 |
-
return
|
| 79 |
}
|
| 80 |
|
| 81 |
-
return
|
| 82 |
};
|
| 83 |
|
| 84 |
-
const setStatus = (text) => {
|
| 85 |
statusText.textContent = text;
|
|
|
|
| 86 |
};
|
| 87 |
|
| 88 |
const showImage = (imgEl, frameEl, url) => {
|
|
@@ -103,16 +104,18 @@ const RING_MODEL_LABELS = { gen: "Gen1/Gen2", air: "Air" };
|
|
| 103 |
const kolNameInput = document.getElementById("kolNameInput");
|
| 104 |
|
| 105 |
const buildMeasureSettings = () => {
|
| 106 |
-
const fingerSelect = form.querySelector('
|
| 107 |
const aiToggle = document.getElementById("aiExplainToggle");
|
| 108 |
-
const mode = modeSelect ? modeSelect.value : "
|
| 109 |
const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
|
|
|
|
|
|
|
| 110 |
return {
|
| 111 |
finger_index: fingerSelect ? fingerSelect.value : "index",
|
| 112 |
edge_method: "sobel",
|
| 113 |
mode: mode,
|
| 114 |
ring_model: ringModel,
|
| 115 |
-
ai_explain:
|
| 116 |
kol_name: kolNameInput ? kolNameInput.value.trim() : "",
|
| 117 |
};
|
| 118 |
};
|
|
@@ -205,7 +208,7 @@ const renderSingleResult = (result) => {
|
|
| 205 |
</div>`;
|
| 206 |
|
| 207 |
const diamMm = result.finger_outer_diameter_cm ? (result.finger_outer_diameter_cm * 10).toFixed(1) : "—";
|
| 208 |
-
const fingerSelect = form.querySelector('
|
| 209 |
const fingerName = fingerSelect ? fingerSelect.value : "finger";
|
| 210 |
const capitalName = fingerName.charAt(0).toUpperCase() + fingerName.slice(1);
|
| 211 |
let html = `<div class="finger-cards">
|
|
@@ -252,7 +255,7 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
|
|
| 252 |
|
| 253 |
if (!response.ok) {
|
| 254 |
const error = await response.json();
|
| 255 |
-
setStatus(error.error || "Measurement failed");
|
| 256 |
return;
|
| 257 |
}
|
| 258 |
|
|
@@ -273,10 +276,10 @@ const runMeasurement = async (endpoint, formData, inputUrlFallback = "") => {
|
|
| 273 |
setStatus("Measurement complete. Results updated.");
|
| 274 |
} else {
|
| 275 |
const failReason = data?.result?.fail_reason;
|
| 276 |
-
setStatus(formatFailReasonStatus(failReason));
|
| 277 |
}
|
| 278 |
} catch (error) {
|
| 279 |
-
setStatus("Network error. Please retry.");
|
| 280 |
}
|
| 281 |
};
|
| 282 |
|
|
@@ -310,7 +313,7 @@ form.addEventListener("submit", async (event) => {
|
|
| 310 |
|
| 311 |
const settings = buildMeasureSettings();
|
| 312 |
if (!settings.kol_name) {
|
| 313 |
-
setStatus("Please enter your Name / ID before measuring.");
|
| 314 |
kolNameInput.focus();
|
| 315 |
return;
|
| 316 |
}
|
|
|
|
| 15 |
const defaultSampleUrl = window.DEFAULT_SAMPLE_URL || "";
|
| 16 |
const failReasonMessageMap = {
|
| 17 |
card_not_detected:
|
| 18 |
+
"Credit card not detected. Place the card beside your hand on a plain, white background (e.g. a sheet of paper), and turn on your phone's flash.",
|
| 19 |
card_not_parallel:
|
| 20 |
"Card is not parallel to the camera. Keep your phone directly above and parallel to the card.",
|
| 21 |
card_near_edge:
|
| 22 |
"Card appears cropped. Place the entire card within the photo frame.",
|
| 23 |
hand_not_detected:
|
| 24 |
+
"Hand not detected. Place your hand flat on a plain, white background (e.g. a sheet of paper), and spread your fingers naturally.",
|
| 25 |
finger_isolation_failed:
|
| 26 |
"Could not isolate the selected finger. Keep one target finger extended and separated.",
|
| 27 |
finger_not_fully_visible:
|
|
|
|
| 66 |
}
|
| 67 |
|
| 68 |
if (failReason.startsWith("quality_score_low_")) {
|
| 69 |
+
return "Low edge quality detected. Turn on flash and retake.";
|
| 70 |
}
|
| 71 |
|
| 72 |
if (failReason.startsWith("consistency_low_")) {
|
| 73 |
+
return "Edge detection was inconsistent. Keep phone parallel to table and retry.";
|
| 74 |
}
|
| 75 |
|
| 76 |
const friendlyMessage = failReasonMessageMap[failReason];
|
| 77 |
if (friendlyMessage) {
|
| 78 |
+
return friendlyMessage;
|
| 79 |
}
|
| 80 |
|
| 81 |
+
return "Measurement failed. Please retake the photo and try again.";
|
| 82 |
};
|
| 83 |
|
| 84 |
+
const setStatus = (text, { error = false } = {}) => {
|
| 85 |
statusText.textContent = text;
|
| 86 |
+
statusText.classList.toggle("error", error);
|
| 87 |
};
|
| 88 |
|
| 89 |
const showImage = (imgEl, frameEl, url) => {
|
|
|
|
| 104 |
const kolNameInput = document.getElementById("kolNameInput");
|
| 105 |
|
| 106 |
const buildMeasureSettings = () => {
|
| 107 |
+
const fingerSelect = form.querySelector('[name="finger_index"]');
|
| 108 |
const aiToggle = document.getElementById("aiExplainToggle");
|
| 109 |
+
const mode = modeSelect ? modeSelect.value : "multi";
|
| 110 |
const ringModel = ringModelSelect ? ringModelSelect.value : "gen";
|
| 111 |
+
// Hidden inputs (non-dev mode) have no `checked` property — treat as on.
|
| 112 |
+
const aiOn = aiToggle ? (aiToggle.type === "checkbox" ? aiToggle.checked : true) : false;
|
| 113 |
return {
|
| 114 |
finger_index: fingerSelect ? fingerSelect.value : "index",
|
| 115 |
edge_method: "sobel",
|
| 116 |
mode: mode,
|
| 117 |
ring_model: ringModel,
|
| 118 |
+
ai_explain: aiOn ? "1" : "0",
|
| 119 |
kol_name: kolNameInput ? kolNameInput.value.trim() : "",
|
| 120 |
};
|
| 121 |
};
|
|
|
|
| 208 |
</div>`;
|
| 209 |
|
| 210 |
const diamMm = result.finger_outer_diameter_cm ? (result.finger_outer_diameter_cm * 10).toFixed(1) : "—";
|
| 211 |
+
const fingerSelect = form.querySelector('[name="finger_index"]');
|
| 212 |
const fingerName = fingerSelect ? fingerSelect.value : "finger";
|
| 213 |
const capitalName = fingerName.charAt(0).toUpperCase() + fingerName.slice(1);
|
| 214 |
let html = `<div class="finger-cards">
|
|
|
|
| 255 |
|
| 256 |
if (!response.ok) {
|
| 257 |
const error = await response.json();
|
| 258 |
+
setStatus(error.error || "Measurement failed", { error: true });
|
| 259 |
return;
|
| 260 |
}
|
| 261 |
|
|
|
|
| 276 |
setStatus("Measurement complete. Results updated.");
|
| 277 |
} else {
|
| 278 |
const failReason = data?.result?.fail_reason;
|
| 279 |
+
setStatus(formatFailReasonStatus(failReason), { error: true });
|
| 280 |
}
|
| 281 |
} catch (error) {
|
| 282 |
+
setStatus("Network error. Please retry.", { error: true });
|
| 283 |
}
|
| 284 |
};
|
| 285 |
|
|
|
|
| 313 |
|
| 314 |
const settings = buildMeasureSettings();
|
| 315 |
if (!settings.kol_name) {
|
| 316 |
+
setStatus("Please enter your Name / ID before measuring.", { error: true });
|
| 317 |
kolNameInput.focus();
|
| 318 |
return;
|
| 319 |
}
|
web_demo/static/styles.css
CHANGED
|
@@ -133,6 +133,26 @@ body {
|
|
| 133 |
color: var(--ink-soft);
|
| 134 |
}
|
| 135 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
.controls {
|
| 137 |
display: grid;
|
| 138 |
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
@@ -183,6 +203,11 @@ select,
|
|
| 183 |
color: var(--ink-soft);
|
| 184 |
}
|
| 185 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
.content {
|
| 187 |
position: relative;
|
| 188 |
z-index: 1;
|
|
|
|
| 133 |
color: var(--ink-soft);
|
| 134 |
}
|
| 135 |
|
| 136 |
+
.capture-tips {
|
| 137 |
+
margin: 14px 0 0;
|
| 138 |
+
padding: 12px 16px 12px 30px;
|
| 139 |
+
list-style: disc;
|
| 140 |
+
background: rgba(191, 58, 43, 0.06);
|
| 141 |
+
border-left: 3px solid rgba(191, 58, 43, 0.55);
|
| 142 |
+
border-radius: 8px;
|
| 143 |
+
font-size: 0.85rem;
|
| 144 |
+
color: var(--ink-soft);
|
| 145 |
+
line-height: 1.5;
|
| 146 |
+
}
|
| 147 |
+
|
| 148 |
+
.capture-tips li + li {
|
| 149 |
+
margin-top: 4px;
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.capture-tips strong {
|
| 153 |
+
color: var(--ink);
|
| 154 |
+
}
|
| 155 |
+
|
| 156 |
.controls {
|
| 157 |
display: grid;
|
| 158 |
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
|
|
|
| 203 |
color: var(--ink-soft);
|
| 204 |
}
|
| 205 |
|
| 206 |
+
.status.error {
|
| 207 |
+
color: #c0271b;
|
| 208 |
+
font-weight: 600;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
.content {
|
| 212 |
position: relative;
|
| 213 |
z-index: 1;
|
web_demo/templates/index.html
CHANGED
|
@@ -26,6 +26,13 @@
|
|
| 26 |
<span class="file-hint">JPG / PNG supported · 1080p or higher recommended</span>
|
| 27 |
</label>
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
<div class="controls">
|
| 30 |
<label>
|
| 31 |
<span>Name / ID</span>
|
|
@@ -38,6 +45,7 @@
|
|
| 38 |
<option value="air">Air</option>
|
| 39 |
</select>
|
| 40 |
</label>
|
|
|
|
| 41 |
<label>
|
| 42 |
<span>Mode</span>
|
| 43 |
<select name="mode" id="modeSelect">
|
|
@@ -62,6 +70,11 @@
|
|
| 62 |
<span class="toggle-hint">Uses OpenAI tokens</span>
|
| 63 |
</div>
|
| 64 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
</div>
|
| 66 |
|
| 67 |
<button class="primary" type="submit">Start Measurement</button>
|
|
@@ -102,6 +115,7 @@
|
|
| 102 |
</div>
|
| 103 |
</div>
|
| 104 |
|
|
|
|
| 105 |
<div class="panel">
|
| 106 |
<div class="panel-head">
|
| 107 |
<h2>JSON Output</h2>
|
|
@@ -109,6 +123,10 @@
|
|
| 109 |
</div>
|
| 110 |
<pre id="jsonOutput">{}</pre>
|
| 111 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</section>
|
| 113 |
</main>
|
| 114 |
|
|
|
|
| 26 |
<span class="file-hint">JPG / PNG supported · 1080p or higher recommended</span>
|
| 27 |
</label>
|
| 28 |
|
| 29 |
+
<ul class="capture-tips">
|
| 30 |
+
<li><strong>Turn on your phone's flash</strong> — even in daylight, it sharpens the finger edges.</li>
|
| 31 |
+
<li>Place a card flat next to your hand on a <strong>plain white background</strong> (a sheet of paper works great).</li>
|
| 32 |
+
<li>Hold the phone <strong>directly above</strong> your hand, keeping it parallel to the table.</li>
|
| 33 |
+
<li><strong>Spread your fingers</strong> naturally and keep your whole hand inside the frame.</li>
|
| 34 |
+
</ul>
|
| 35 |
+
|
| 36 |
<div class="controls">
|
| 37 |
<label>
|
| 38 |
<span>Name / ID</span>
|
|
|
|
| 45 |
<option value="air">Air</option>
|
| 46 |
</select>
|
| 47 |
</label>
|
| 48 |
+
{% if dev_mode %}
|
| 49 |
<label>
|
| 50 |
<span>Mode</span>
|
| 51 |
<select name="mode" id="modeSelect">
|
|
|
|
| 70 |
<span class="toggle-hint">Uses OpenAI tokens</span>
|
| 71 |
</div>
|
| 72 |
</label>
|
| 73 |
+
{% else %}
|
| 74 |
+
<input type="hidden" name="mode" id="modeSelect" value="multi" />
|
| 75 |
+
<input type="hidden" name="finger_index" value="index" />
|
| 76 |
+
<input type="hidden" id="aiExplainToggle" value="on" />
|
| 77 |
+
{% endif %}
|
| 78 |
</div>
|
| 79 |
|
| 80 |
<button class="primary" type="submit">Start Measurement</button>
|
|
|
|
| 115 |
</div>
|
| 116 |
</div>
|
| 117 |
|
| 118 |
+
{% if dev_mode %}
|
| 119 |
<div class="panel">
|
| 120 |
<div class="panel-head">
|
| 121 |
<h2>JSON Output</h2>
|
|
|
|
| 123 |
</div>
|
| 124 |
<pre id="jsonOutput">{}</pre>
|
| 125 |
</div>
|
| 126 |
+
{% else %}
|
| 127 |
+
<pre id="jsonOutput" hidden>{}</pre>
|
| 128 |
+
<a id="jsonLink" hidden href="#"></a>
|
| 129 |
+
{% endif %}
|
| 130 |
</section>
|
| 131 |
</main>
|
| 132 |
|