Spaces:
Sleeping
Sleeping
| # !pip -q install gradio fastapi uvicorn scikit-learn pyngrok scikit-learn qrcode[pil] | |
| # ========================= | |
| # Colab-ready single script | |
| # - Runs FastAPI + Gradio mounted app on Colab | |
| # - Uses sid in JSON payload (cookie may be unreliable in Colab/iframes) | |
| # ========================= | |
| # (1) Install deps (Colab only) | |
| import sys, os, threading, time | |
| if "google.colab" in sys.modules: | |
| pass | |
| # !pip -q install gradio fastapi uvicorn scikit-learn | |
| import threading | |
| from dataclasses import dataclass, field | |
| from typing import Dict, List, Tuple, Optional | |
| import numpy as np | |
| # Headless matplotlib | |
| import matplotlib | |
| matplotlib.use("Agg") | |
| import matplotlib.pyplot as plt # unused ok | |
| import gradio as gr | |
| from sklearn.model_selection import train_test_split | |
| from fastapi import FastAPI, Request | |
| from fastapi.responses import JSONResponse | |
| # ========================= | |
| # Config | |
| # ========================= | |
| class PreprocConfig: | |
| fs_target: float = 50.0 | |
| hp_alpha: float = 0.92 | |
| window_sec: float = 1.0 | |
| hop_sec: float = 0.2 | |
| CFG = PreprocConfig() | |
| DEFAULT_LABELS = ["idle", "shake", "flip"] | |
| # ========================= | |
| # Utilities | |
| # ========================= | |
| class GravityHighPass: | |
| """Estimate gravity via EMA and subtract it: a_dyn = a - g_est""" | |
| def __init__(self, alpha=0.92): | |
| self.alpha = float(alpha) | |
| self.g = np.zeros(3, dtype=np.float32) | |
| self.inited = False | |
| def reset(self): | |
| self.g[:] = 0 | |
| self.inited = False | |
| def step(self, a_xyz: np.ndarray) -> np.ndarray: | |
| a = a_xyz.astype(np.float32) | |
| if not self.inited: | |
| self.g = a.copy() | |
| self.inited = True | |
| self.g = self.alpha * self.g + (1 - self.alpha) * a | |
| return a - self.g | |
| def resample_linear(ts: np.ndarray, X: np.ndarray, fs_target: float): | |
| """Resample irregular timestamps to uniform grid using linear interpolation.""" | |
| if len(ts) < 2: | |
| return ts, X | |
| t0, t1 = float(ts[0]), float(ts[-1]) | |
| dt = 1.0 / fs_target | |
| t_new = np.arange(t0, t1, dt, dtype=np.float32) | |
| if len(t_new) < 2: | |
| return ts, X | |
| X_new = np.zeros((len(t_new), X.shape[1]), dtype=np.float32) | |
| for d in range(X.shape[1]): | |
| X_new[:, d] = np.interp(t_new, ts, X[:, d]) | |
| return t_new, X_new | |
| # ========================= | |
| # ESN Classifier (minimal) | |
| # ========================= | |
| class ESNClassifier: | |
| """ESN state features + ridge multi-class regression.""" | |
| def __init__(self, in_dim: int, res_size: int, spectral_radius: float, leak: float, ridge: float, seed: int = 0): | |
| self.in_dim = in_dim | |
| self.res_size = res_size | |
| self.spectral_radius = float(spectral_radius) | |
| self.leak = float(leak) | |
| self.ridge = float(ridge) | |
| self.seed = int(seed) | |
| rng = np.random.default_rng(self.seed) | |
| self.Win = (rng.uniform(-1, 1, size=(res_size, in_dim + 1)) * 0.5).astype(np.float32) | |
| W = rng.uniform(-1, 1, size=(res_size, res_size)).astype(np.float32) | |
| v = rng.normal(size=(res_size,)).astype(np.float32) | |
| for _ in range(30): | |
| v = W @ v | |
| v = v / (np.linalg.norm(v) + 1e-9) | |
| eig_approx = float(np.linalg.norm(W @ v) / (np.linalg.norm(v) + 1e-9)) | |
| W *= (self.spectral_radius / (eig_approx + 1e-9)) | |
| self.W = W | |
| self.x = np.zeros((res_size,), dtype=np.float32) | |
| self.Wout = None | |
| self.class_names: List[str] = [] | |
| def reset(self): | |
| self.x[:] = 0 | |
| def step(self, u: np.ndarray): | |
| u = u.astype(np.float32) | |
| aug = np.concatenate([np.array([1.0], np.float32), u], axis=0) | |
| pre = self.W @ self.x + self.Win @ aug | |
| x_new = np.tanh(pre) | |
| self.x = (1 - self.leak) * self.x + self.leak * x_new | |
| return self.x | |
| def fit(self, X_feat: np.ndarray, y: np.ndarray, class_names: List[str]): | |
| self.class_names = class_names | |
| n, f = X_feat.shape | |
| k = len(class_names) | |
| Y = np.zeros((n, k), dtype=np.float32) | |
| Y[np.arange(n), y] = 1.0 | |
| XtX = X_feat.T @ X_feat | |
| I = np.eye(f, dtype=np.float32) | |
| self.Wout = np.linalg.solve(XtX + self.ridge * I, X_feat.T @ Y).astype(np.float32) | |
| def predict_proba(self, feat: np.ndarray): | |
| logits = feat.astype(np.float32) @ self.Wout | |
| m = float(np.max(logits)) | |
| ex = np.exp(logits - m) | |
| return ex / (float(np.sum(ex)) + 1e-9) | |
| def make_window_feature(esn: ESNClassifier, X_seq: np.ndarray, mode: str = "last"): | |
| esn.reset() | |
| states = [] | |
| for t in range(len(X_seq)): | |
| st = esn.step(X_seq[t]) | |
| if mode == "mean": | |
| states.append(st.copy()) | |
| if mode == "mean" and len(states) > 0: | |
| s = np.mean(np.stack(states, axis=0), axis=0) | |
| else: | |
| s = esn.x.copy() | |
| u_mean = X_seq.mean(axis=0) | |
| u_std = X_seq.std(axis=0) | |
| feat = np.concatenate([np.array([1.0], np.float32), u_mean, u_std, s], axis=0) | |
| return feat | |
| # ========================= | |
| # Per-session state | |
| # ========================= | |
| class SessionState: | |
| stream_t: List[float] = field(default_factory=list) | |
| stream_a: List[List[float]] = field(default_factory=list) | |
| collecting: bool = False | |
| collect_label: str = "" | |
| collect_tmp_t: List[float] = field(default_factory=list) | |
| collect_tmp_a: List[List[float]] = field(default_factory=list) | |
| data: Dict[str, List[Dict[str, np.ndarray]]] = field(default_factory=dict) | |
| trained: bool = False | |
| train_cfg: Dict = field(default_factory=dict) | |
| pp_mean: Optional[np.ndarray] = None | |
| pp_std: Optional[np.ndarray] = None | |
| esn_model: Optional[ESNClassifier] = None | |
| infer_running: bool = False | |
| infer_last_label: str = "" | |
| infer_last_conf: float = 0.0 | |
| infer_pred_log: List[Tuple[float, str, float]] = field(default_factory=list) | |
| lock: threading.Lock = field(default_factory=threading.Lock) | |
| SESS: Dict[str, SessionState] = {} | |
| SESS_LOCK = threading.Lock() | |
| def _get_sid_from_request(request: Optional[gr.Request]) -> str: | |
| if request is None: | |
| # cookieなし → セッションが1つだけならそれを使う | |
| with SESS_LOCK: | |
| if len(SESS) == 1: | |
| return next(iter(SESS)) | |
| return "unknown" | |
| try: | |
| sid = request.cookies.get("sid", "") if request.cookies else "" | |
| if sid: | |
| return sid | |
| except Exception: | |
| pass | |
| # cookie取れない場合: セッションが1つだけならそれを使う | |
| with SESS_LOCK: | |
| if len(SESS) == 1: | |
| return next(iter(SESS)) | |
| return "unknown" | |
| def get_state(sid: str) -> SessionState: | |
| with SESS_LOCK: | |
| st = SESS.get(sid) | |
| if st is None: | |
| st = SessionState() | |
| SESS[sid] = st | |
| return st | |
| def reset_state(st: SessionState): | |
| st.stream_t = [] | |
| st.stream_a = [] | |
| st.collecting = False | |
| st.collect_label = "" | |
| st.collect_tmp_t = [] | |
| st.collect_tmp_a = [] | |
| st.data = {} | |
| st.trained = False | |
| st.train_cfg = {} | |
| st.pp_mean = None | |
| st.pp_std = None | |
| st.esn_model = None | |
| st.infer_running = False | |
| st.infer_last_label = "" | |
| st.infer_last_conf = 0.0 | |
| st.infer_pred_log = [] | |
| def counts_dict_for(st: SessionState): | |
| c = {k: len(v) for k, v in st.data.items()} | |
| c["TOTAL"] = int(sum(c.values())) | |
| return c | |
| def ui_status_for(st: SessionState): | |
| if not st.trained: | |
| return "<span style='font-size:18px;font-weight:700;color:#b00020'>MODEL: not trained</span>" | |
| return (f"<span style='font-size:18px;font-weight:700;color:#0b6b0b'>MODEL: trained</span> " | |
| f"<span style='font-size:12px;opacity:.85'>val_acc={st.train_cfg.get('val_acc',0):.3f}, " | |
| f"classes={st.train_cfg.get('classes',[])}</span>") | |
| def format_pred_log_md(st: SessionState, max_rows: int = 80): | |
| if len(st.infer_pred_log) == 0: | |
| return "(log empty)" | |
| rows = st.infer_pred_log[-max_rows:] | |
| md = ["| time(s) | label | conf |", "|---:|:---|---:|"] | |
| for t, lab, conf in rows: | |
| md.append(f"| {t:6.2f} | {lab} | {conf:.2f} |") | |
| return "\n".join(md) | |
| # ========================= | |
| # FastAPI endpoints | |
| # - prefer cookie sid; if missing, use payload sid | |
| # ========================= | |
| api = FastAPI() | |
| def _sid_from_fastapi(request: Request, payload: dict) -> str: | |
| try: | |
| sid = request.cookies.get("sid", "") or "" | |
| except Exception: | |
| sid = "" | |
| if sid: | |
| return sid | |
| sid2 = str(payload.get("sid", "") or "") | |
| return sid2 if sid2 else "unknown" | |
| async def ingest(request: Request): | |
| try: | |
| obj = await request.json() | |
| sid = _sid_from_fastapi(request, obj) | |
| samples = obj.get("samples", []) | |
| st = get_state(sid) | |
| with st.lock: | |
| for s in samples: | |
| t = float(s.get("t", 0.0)) | |
| ax = float(s.get("ax", 0.0)); ay = float(s.get("ay", 0.0)); az = float(s.get("az", 0.0)) | |
| st.stream_t.append(t) | |
| st.stream_a.append([ax, ay, az]) | |
| if st.collecting: | |
| st.collect_tmp_t.append(t) | |
| st.collect_tmp_a.append([ax, ay, az]) | |
| if len(st.stream_t) > 6000: | |
| st.stream_t = st.stream_t[-6000:] | |
| st.stream_a = st.stream_a[-6000:] | |
| return JSONResponse({"ok": True, "n": len(samples), "sid": sid, "stream_len": len(st.stream_t)}) | |
| except Exception as e: | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=400) | |
| async def reset_endpoint(request: Request): | |
| try: | |
| obj = await request.json() | |
| sid = _sid_from_fastapi(request, obj) | |
| st = get_state(sid) | |
| with st.lock: | |
| reset_state(st) | |
| return JSONResponse({"ok": True, "sid": sid}) | |
| except Exception as e: | |
| return JSONResponse({"ok": False, "error": str(e)}, status_code=400) | |
| # ========================= | |
| # Collect handlers | |
| # ========================= | |
| def collect_start(label: str, request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| with st.lock: | |
| st.collecting = True | |
| st.collect_label = label | |
| st.collect_tmp_t = [] | |
| st.collect_tmp_a = [] | |
| return f"収集中: {label}", gr.update(interactive=False), gr.update(interactive=True) | |
| def collect_stop_and_save(request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| with st.lock: | |
| st.collecting = False | |
| if len(st.collect_tmp_t) < 10: | |
| return ("収集停止(データが少なすぎるため未保存)", | |
| gr.update(interactive=True), gr.update(interactive=False), | |
| counts_dict_for(st)) | |
| ts = np.array(st.collect_tmp_t, dtype=np.float32) | |
| A = np.array(st.collect_tmp_a, dtype=np.float32) | |
| lab = st.collect_label | |
| st.data.setdefault(lab, []).append({"t": ts, "a": A}) | |
| msg = f"保存: label={lab}, samples={len(ts)}, total={len(st.data[lab])}" | |
| return (msg, gr.update(interactive=True), gr.update(interactive=False), | |
| counts_dict_for(st)) | |
| # ========================= | |
| # Training | |
| # ========================= | |
| def build_dataset(st: SessionState, window_sec: float, hop_sec: float, fs_target: float): | |
| class_names = sorted([k for k in st.data.keys() if k.strip()]) | |
| if len(class_names) < 2: | |
| return [], np.zeros((0,), np.int64), class_names | |
| seqs = [] | |
| ys = [] | |
| for lab_idx, lab in enumerate(class_names): | |
| for item in st.data[lab]: | |
| ts = item["t"].astype(np.float32) | |
| A = item["a"].astype(np.float32) | |
| ts2, A2 = resample_linear(ts, A, fs_target) | |
| hp = GravityHighPass(alpha=CFG.hp_alpha) | |
| Ad = np.stack([hp.step(A2[i]) for i in range(len(A2))], axis=0) | |
| an = np.linalg.norm(Ad, axis=1, keepdims=True) | |
| X = np.concatenate([Ad, an], axis=1) # (T, 4) | |
| win = int(round(window_sec * fs_target)) | |
| hop = int(round(hop_sec * fs_target)) | |
| if len(X) < win: | |
| continue | |
| for s in range(0, len(X) - win + 1, hop): | |
| seqs.append(X[s:s + win]) | |
| ys.append(lab_idx) | |
| return seqs, np.array(ys, dtype=np.int64), class_names | |
| def train_click(window_sec, hop_sec, fs_target, feat_mode, request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| window_sec = float(window_sec); hop_sec = float(hop_sec); fs_target = float(fs_target) | |
| with st.lock: | |
| seqs, y, class_names = build_dataset(st, window_sec, hop_sec, fs_target) | |
| if len(seqs) < 12 or len(class_names) < 2: | |
| return ui_status_for(st), {}, "データ不足(2ラベル以上、各数回〜推奨)", "" | |
| idx = np.arange(len(seqs)) | |
| try: | |
| tr_idx, va_idx = train_test_split(idx, test_size=0.25, random_state=0, stratify=y) | |
| except Exception: | |
| tr_idx, va_idx = train_test_split(idx, test_size=0.25, random_state=0) | |
| Xtr_all = np.concatenate([seqs[i] for i in tr_idx], axis=0) | |
| mu = Xtr_all.mean(axis=0, keepdims=True).astype(np.float32) | |
| sd = (Xtr_all.std(axis=0, keepdims=True) + 1e-6).astype(np.float32) | |
| def norm_seq(seg): | |
| return (seg - mu) / sd | |
| cand_res = [80, 120] | |
| cand_sr = [0.8, 1.0] | |
| cand_leak = [0.2, 0.5, 0.8] | |
| cand_ridge = [1e-3] | |
| best_acc = -1.0 | |
| best_pack = None | |
| logs = [] | |
| for res_size in cand_res: | |
| for sr in cand_sr: | |
| for leak in cand_leak: | |
| for ridge in cand_ridge: | |
| esn = ESNClassifier( | |
| in_dim=4, | |
| res_size=int(res_size), | |
| spectral_radius=float(sr), | |
| leak=float(leak), | |
| ridge=float(ridge), | |
| seed=0 | |
| ) | |
| Xtr = [] | |
| for i in tr_idx: | |
| feat = make_window_feature(esn, norm_seq(seqs[i]), mode=feat_mode) | |
| Xtr.append(feat) | |
| Xtr = np.stack(Xtr, axis=0).astype(np.float32) | |
| esn.fit(Xtr, y[tr_idx], class_names) | |
| correct = 0 | |
| for i in va_idx: | |
| feat = make_window_feature(esn, norm_seq(seqs[i]), mode=feat_mode) | |
| p = esn.predict_proba(feat) | |
| pred = int(np.argmax(p)) | |
| correct += (pred == int(y[i])) | |
| acc = correct / max(1, len(va_idx)) | |
| logs.append(f"res={res_size}, sr={sr}, leak={leak}, ridge={ridge} -> val_acc={acc:.3f}") | |
| if acc > best_acc: | |
| best_acc = acc | |
| best_pack = (int(res_size), float(sr), float(leak), float(ridge), esn) | |
| res_size, sr, leak, ridge, esn = best_pack | |
| with st.lock: | |
| st.trained = True | |
| st.pp_mean, st.pp_std = mu, sd | |
| st.esn_model = esn | |
| st.train_cfg = { | |
| "window_sec": window_sec, | |
| "hop_sec": hop_sec, | |
| "fs_target": fs_target, | |
| "mode": feat_mode, | |
| "res_size": res_size, | |
| "spectral_radius": sr, | |
| "leak": leak, | |
| "ridge": ridge, | |
| "val_acc": float(best_acc), | |
| "classes": class_names, | |
| } | |
| tail = "\n".join(logs[-12:]) | |
| return ui_status_for(st), st.train_cfg, f"学習完了: val_acc={best_acc:.3f}", tail | |
| # ========================= | |
| # Inference | |
| # ========================= | |
| def infer_step(st: SessionState): | |
| if (not st.trained) or (st.esn_model is None) or (st.pp_mean is None) or (st.pp_std is None): | |
| return "(not trained)", 0.0, {} | |
| fs = float(st.train_cfg["fs_target"]) | |
| win = int(round(float(st.train_cfg["window_sec"]) * fs)) | |
| if len(st.stream_t) < win + 2: | |
| return "(buffering)", 0.0, {} | |
| ts = np.array(st.stream_t, dtype=np.float32) | |
| A = np.array(st.stream_a, dtype=np.float32) | |
| t_end = ts[-1] | |
| t_start = max(ts[0], t_end - (float(st.train_cfg["window_sec"]) + 0.4)) | |
| m = ts >= t_start | |
| ts2, A2 = resample_linear(ts[m], A[m], fs) | |
| if len(ts2) < win: | |
| return "(buffering)", 0.0, {} | |
| A2 = A2[-win:] | |
| hp = GravityHighPass(alpha=CFG.hp_alpha) | |
| Ad = np.stack([hp.step(A2[i]) for i in range(len(A2))], axis=0) | |
| an = np.linalg.norm(Ad, axis=1, keepdims=True) | |
| X = np.concatenate([Ad, an], axis=1).astype(np.float32) | |
| Xn = (X - st.pp_mean) / st.pp_std | |
| feat = make_window_feature(st.esn_model, Xn, mode=st.train_cfg["mode"]) | |
| p = st.esn_model.predict_proba(feat) | |
| i = int(np.argmax(p)) | |
| conf = float(p[i]) | |
| lab = st.train_cfg["classes"][i] | |
| prev_lab = st.infer_last_label | |
| st.infer_last_label = lab | |
| st.infer_last_conf = conf | |
| if (lab != prev_lab) and (lab not in ["(buffering)", "(not trained)"]): | |
| st.infer_pred_log.append((float(t_end), lab, conf)) | |
| st.infer_pred_log = st.infer_pred_log[-500:] | |
| info = {"probs": {st.train_cfg["classes"][j]: float(p[j]) for j in range(len(p))}} | |
| return lab, conf, info | |
| def infer_start(request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| with st.lock: | |
| if not st.trained: | |
| return "学習してから推論してください", "<div style='font-size:24px;font-weight:800;opacity:.6'>-</div>", {}, ui_status_for(st) | |
| st.infer_running = True | |
| st.infer_pred_log = [] | |
| st.infer_last_label = "" | |
| st.infer_last_conf = 0.0 | |
| return "推論: ON", "<div style='font-size:24px;font-weight:800;opacity:.6'>-</div>", {}, ui_status_for(st) | |
| def infer_stop(request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| with st.lock: | |
| st.infer_running = False | |
| return "推論: OFF", "<div style='font-size:24px;font-weight:800;opacity:.6'>-</div>", {}, ui_status_for(st) | |
| def infer_tick(request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| with st.lock: | |
| if not st.infer_running: | |
| return gr.update(), gr.update(), gr.update() | |
| lab, conf, info = infer_step(st) | |
| stline = ui_status_for(st) | |
| pred_html = ( | |
| f"<div style='padding:10px 12px;border:1px solid #ddd;border-radius:12px;background:#fff'>" | |
| f"<div style='font-size:30px;font-weight:900;line-height:1.1'>{lab}</div>" | |
| f"<div style='font-size:13px;opacity:.85'>conf={conf:.2f}</div>" | |
| f"</div>" | |
| ) | |
| probs = info.get("probs", {}) | |
| return pred_html, probs, stline | |
| def chat_tick(request: gr.Request): | |
| sid = _get_sid_from_request(request) | |
| st = get_state(sid) | |
| with st.lock: | |
| if not st.infer_running: | |
| big = "<div style='font-size:22px;font-weight:800;opacity:.6'>推論がOFFです</div>" | |
| log_md = format_pred_log_md(st) | |
| return big, log_md | |
| big = ( | |
| f"<div style='padding:12px 14px;border:1px solid #ddd;border-radius:14px;background:#fff'>" | |
| f"<div style='font-size:34px;font-weight:900;line-height:1.05'>{st.infer_last_label or '-'}</div>" | |
| f"<div style='font-size:14px;opacity:.85'>conf={st.infer_last_conf:.2f}</div>" | |
| f"</div>" | |
| ) | |
| log_md = format_pred_log_md(st) | |
| return big, log_md | |
| # ========================= | |
| # JS UI + Boot | |
| # - sid is stored in localStorage and sent in JSON payload | |
| # ========================= | |
| # ── UI変更: SENSOR_UI をフェミニンデザインに全面リデザイン ── | |
| # パステル背景・中央寄せ・やわらかい角丸ボタン・透明感・余白多め | |
| SENSOR_UI = r""" | |
| <div class="sensor-hero"> | |
| <div class="sensor-hero__icon">☘</div> | |
| <div class="sensor-hero__title">Motion Sensor</div> | |
| <div class="sensor-hero__subtitle">スマホを振って、動きを学習させよう</div> | |
| <div class="sensor-hero__buttons"> | |
| <button id="btn_perm" class="hero-btn hero-btn--outline" type="button">PERMISSION</button> | |
| <button id="btn_start" class="hero-btn hero-btn--dark" type="button">START</button> | |
| <button id="btn_stop" class="hero-btn hero-btn--outline" type="button">STOP</button> | |
| <button id="btn_reset" class="hero-btn hero-btn--dark" type="button">RESET</button> | |
| </div> | |
| <div class="sensor-hero__status"> | |
| <span id="sensor_status">status: idle</span> | |
| </div> | |
| <div class="sensor-hero__share"> | |
| URL: <span id="share_url" class="mono"></span> | |
| </div> | |
| </div> | |
| """ | |
| JS_BOOT = r""" | |
| () => { | |
| const setStatus = (s) => { | |
| const el = document.getElementById('sensor_status'); | |
| if (el) el.textContent = 'status: ' + s; | |
| }; | |
| const setShareUrl = () => { | |
| const el = document.getElementById('share_url'); | |
| if (!el) return; | |
| el.textContent = window.location.href; | |
| }; | |
| const genSid = () => { | |
| if (crypto && crypto.randomUUID) return crypto.randomUUID(); | |
| const r = () => Math.floor(Math.random() * 1e9).toString(16); | |
| return `${Date.now().toString(16)}-${r()}-${r()}-${r()}`; | |
| }; | |
| const getSid = () => { | |
| try{ | |
| const k = "sid_v1"; | |
| let sid = localStorage.getItem(k); | |
| if(!sid){ sid = genSid(); localStorage.setItem(k, sid); } | |
| // cookieにもセット(Gradioのgr.Requestで読めるように) | |
| document.cookie = "sid=" + sid + "; path=/; SameSite=None; Secure"; | |
| return sid; | |
| }catch(e){ | |
| const fallback = genSid(); | |
| document.cookie = "sid=" + fallback + "; path=/; SameSite=None; Secure"; | |
| return fallback; | |
| } | |
| }; | |
| // Gradio root (subpath/iframeでも壊れにくい) | |
| const apiUrl = (path) => { | |
| const root = (window.gradio_config && window.gradio_config.root) ? window.gradio_config.root : ''; | |
| return `${root}${path}`; | |
| }; | |
| const sid = getSid(); | |
| let running=false, buf=[], t0=null, timer=null; | |
| let accel=null, dmHandler=null; | |
| const pushSample = (ax,ay,az) => { | |
| if(t0===null) t0=performance.now(); | |
| const t=(performance.now()-t0)/1000.0; | |
| buf.push({t, ax:ax||0, ay:ay||0, az:az||0}); | |
| if(buf.length>600) buf=buf.slice(-600); | |
| }; | |
| const postJson = async (path, payloadObj) => { | |
| try{ | |
| payloadObj = payloadObj || {}; | |
| payloadObj.sid = sid; // <-- include sid in body | |
| const res = await fetch(apiUrl(path), { | |
| method: 'POST', | |
| headers: {'Content-Type':'application/json'}, | |
| body: JSON.stringify(payloadObj), | |
| credentials: 'include' | |
| }); | |
| if(!res.ok){ | |
| setStatus(`ERR: ${path} http ${res.status}`); | |
| return false; | |
| } | |
| return true; | |
| }catch(e){ | |
| setStatus(`ERR: fetch(${path}) failed`); | |
| return false; | |
| } | |
| }; | |
| const flush = async () => { | |
| if(!running || buf.length===0) return; | |
| const samples = buf; | |
| buf = []; | |
| await postJson('/api/ingest', {samples}); | |
| }; | |
| const startDeviceMotion = () => { | |
| dmHandler = (e) => { | |
| if(!running) return; | |
| const acc = e.accelerationIncludingGravity || e.acceleration; | |
| if(!acc) return; | |
| pushSample(acc.x, acc.y, acc.z); | |
| }; | |
| window.addEventListener('devicemotion', dmHandler, {passive:true}); | |
| }; | |
| const startGeneric = () => { | |
| if(!('Accelerometer' in window)) return false; | |
| try{ | |
| accel = new Accelerometer({frequency: 50}); | |
| accel.addEventListener('reading', ()=>{ if(running) pushSample(accel.x, accel.y, accel.z); }, {passive:true}); | |
| accel.addEventListener('error', ()=>{ try{accel.stop();}catch(e){} accel=null; startDeviceMotion(); }); | |
| accel.start(); | |
| return true; | |
| }catch(e){ | |
| accel=null; | |
| return false; | |
| } | |
| }; | |
| const requestPerm = async () => { | |
| try{ | |
| if(typeof DeviceMotionEvent!=='undefined' && typeof DeviceMotionEvent.requestPermission==='function'){ | |
| const res = await DeviceMotionEvent.requestPermission(); | |
| setStatus('permission: '+res + ' / sid=' + sid.slice(0,8)); | |
| } else { | |
| setStatus('permission: not-needed / sid=' + sid.slice(0,8)); | |
| } | |
| }catch(e){ | |
| setStatus('permission error'); | |
| } | |
| }; | |
| const start = () => { | |
| if(running) return; | |
| running=true; buf=[]; t0=null; | |
| if(!startGeneric()) startDeviceMotion(); | |
| timer=setInterval(flush, 200); | |
| setStatus('running / sid=' + sid.slice(0,8)); | |
| }; | |
| const stop = () => { | |
| if(!running) return; | |
| running=false; | |
| if(accel){ try{accel.stop();}catch(e){} accel=null; } | |
| if(dmHandler){ window.removeEventListener('devicemotion', dmHandler); dmHandler=null; } | |
| if(timer){ clearInterval(timer); timer=null; } | |
| flush(); | |
| setStatus('stopped / sid=' + sid.slice(0,8)); | |
| }; | |
| const reset = async () => { | |
| stop(); | |
| await postJson('/api/reset', {}); | |
| setStatus('reset done / sid=' + sid.slice(0,8)); | |
| }; | |
| const bind = () => { | |
| const p=document.getElementById('btn_perm'); | |
| const s=document.getElementById('btn_start'); | |
| const x=document.getElementById('btn_stop'); | |
| const r=document.getElementById('btn_reset'); | |
| if(!p || !s || !x || !r){ setTimeout(bind, 300); return; } | |
| p.onclick=requestPerm; | |
| s.onclick=start; | |
| x.onclick=stop; | |
| r.onclick=reset; | |
| setStatus('ready / sid=' + sid.slice(0,8)); | |
| setShareUrl(); | |
| }; | |
| bind(); | |
| } | |
| """ | |
| # ── UI変更: CSS をフェミニン・パステルデザインに全面リデザイン ── | |
| # くすみピンク / ミント / ラベンダー / 低彩度 / 黒不使用 / 透明感 / 余白多め | |
| CSS = """ | |
| /* ======================================== | |
| グローバル: フェミニン・パステルテーマ | |
| ======================================== */ | |
| html { | |
| scroll-behavior: smooth !important; | |
| -webkit-overflow-scrolling: touch !important; | |
| } | |
| /* Gradio コンテナ: 淡いグラデーション背景 */ | |
| .gradio-container { | |
| background: linear-gradient(175deg, #fdf2f8 0%, #faf5ff 35%, #f0fdf4 70%, #fdf2f8 100%) !important; | |
| color: #1a1a1a !important; | |
| font-family: 'Inter', 'Hiragino Kaku Gothic ProN', 'Noto Sans JP', -apple-system, BlinkMacSystemFont, sans-serif !important; | |
| font-weight: 400 !important; | |
| max-width: 100% !important; | |
| padding: 0 !important; | |
| min-height: 100vh !important; | |
| } | |
| /* フッター非表示 */ | |
| footer { display: none !important; } | |
| /* ======================================== | |
| センサーヒーローセクション | |
| ======================================== */ | |
| .sensor-hero { | |
| min-height: auto; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| justify-content: center; | |
| text-align: center; | |
| padding: 32px 24px 24px 24px; | |
| background: linear-gradient(170deg, | |
| rgba(253,242,248,0.9) 0%, | |
| rgba(250,245,255,0.85) 40%, | |
| rgba(240,253,244,0.8) 100%); | |
| margin-bottom: 8px; | |
| } | |
| .sensor-hero__icon { | |
| font-size: 36px; | |
| margin-bottom: 16px; | |
| opacity: 0.6; | |
| filter: grayscale(30%); | |
| } | |
| .sensor-hero__title { | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', serif; | |
| font-size: clamp(30px, 8vw, 48px); | |
| font-weight: 400; | |
| letter-spacing: 0.08em; | |
| color: #1a1a1a; | |
| margin-bottom: 10px; | |
| line-height: 1.15; | |
| text-transform: uppercase; | |
| } | |
| .sensor-hero__subtitle { | |
| font-size: clamp(13px, 3.2vw, 16px); | |
| font-weight: 400; | |
| color: #333333; | |
| margin-bottom: 24px; | |
| letter-spacing: 0.03em; | |
| line-height: 1.6; | |
| } | |
| .sensor-hero__buttons { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 10px; | |
| justify-content: center; | |
| margin-bottom: 16px; | |
| max-width: 360px; | |
| } | |
| .sensor-hero__status { | |
| font-size: 12px; | |
| color: #333333; | |
| font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace; | |
| margin-bottom: 6px; | |
| letter-spacing: 0.02em; | |
| } | |
| .sensor-hero__share { | |
| font-size: 11px; | |
| color: #333333; | |
| word-break: break-all; | |
| max-width: 85vw; | |
| } | |
| .sensor-hero__share .mono { | |
| font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace; | |
| } | |
| /* ======================================== | |
| ヒーローボタン: VIEW MORE風 / セリフ体 / シャープ | |
| ======================================== */ | |
| .hero-btn { | |
| flex: 1 1 calc(50% - 5px); | |
| box-sizing: border-box; | |
| padding: 15px 10px; | |
| border-radius: 0; | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', 'YuMincho', serif; | |
| font-size: 13px; | |
| font-weight: 500; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| text-align: center; | |
| cursor: pointer; | |
| transition: all 0.3s ease; | |
| touch-action: manipulation; | |
| -webkit-tap-highlight-color: transparent; | |
| } | |
| /* 白背景 + 細線ボーダー(左のボタン) */ | |
| .hero-btn--outline { | |
| background: #ffffff; | |
| color: #555555; | |
| border: 1px solid #aaaaaa; | |
| } | |
| .hero-btn--outline:active { | |
| background: #f5f5f5; | |
| border-color: #888888; | |
| } | |
| /* ダーク背景(右のボタン) */ | |
| .hero-btn--dark { | |
| background: #3a3a3a; | |
| color: #d8d8d8; | |
| border: 1px solid #3a3a3a; | |
| } | |
| .hero-btn--dark:active { | |
| background: #4a4a4a; | |
| } | |
| /* ======================================== | |
| タブナビゲーション: ピル型パステル | |
| ======================================== */ | |
| div.tab-nav { | |
| background: rgba(255,255,255,0.8) !important; | |
| border: 1px solid rgba(0,0,0,0.06) !important; | |
| border-radius: 22px !important; | |
| padding: 5px !important; | |
| margin: 16px 16px 20px 16px !important; | |
| display: flex !important; | |
| justify-content: center !important; | |
| gap: 3px !important; | |
| box-shadow: 0 2px 12px rgba(107,91,123,0.06) !important; | |
| backdrop-filter: blur(8px) !important; | |
| -webkit-backdrop-filter: blur(8px) !important; | |
| } | |
| div.tab-nav button { | |
| background: transparent !important; | |
| color: #444444 !important; | |
| border: none !important; | |
| border-radius: 18px !important; | |
| padding: 10px 18px !important; | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', serif !important; | |
| font-size: 14px !important; | |
| font-weight: 500 !important; | |
| transition: all 0.25s ease !important; | |
| letter-spacing: 0.1em !important; | |
| } | |
| div.tab-nav button.selected { | |
| background: rgba(244,196,212,0.35) !important; | |
| color: #222222 !important; | |
| box-shadow: 0 1px 8px rgba(244,196,212,0.2) !important; | |
| } | |
| /* ======================================== | |
| タブコンテンツ: 二重フレーム(黒+グレーずらし) | |
| ======================================== */ | |
| .tabitem { | |
| background: transparent !important; | |
| border: none !important; | |
| } | |
| .tabitem > div { | |
| position: relative !important; | |
| background: #ffffff !important; | |
| border-radius: 0 !important; | |
| padding: 36px 24px !important; | |
| margin: 20px 22px 32px 22px !important; | |
| border: 1.25px solid #888888 !important; | |
| box-shadow: 10px 10px 0px 0px #c8c8c8 !important; | |
| backdrop-filter: none !important; | |
| -webkit-backdrop-filter: none !important; | |
| } | |
| /* ======================================== | |
| Gradioコンポーネントのスタイリング | |
| ======================================== */ | |
| /* ラベル */ | |
| label span, .label-wrap span { | |
| color: #222222 !important; | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', serif !important; | |
| font-weight: 500 !important; | |
| font-size: 14px !important; | |
| letter-spacing: 0.06em !important; | |
| } | |
| /* テキスト入力 / Dropdown */ | |
| input[type="text"], textarea, select { | |
| background: rgba(255,255,255,0.7) !important; | |
| border: 1px solid rgba(200,191,224,0.3) !important; | |
| border-radius: 16px !important; | |
| color: #1a1a1a !important; | |
| font-weight: 400 !important; | |
| } | |
| input[type="text"]:focus, textarea:focus { | |
| border-color: rgba(244,196,212,0.5) !important; | |
| box-shadow: 0 0 0 3px rgba(244,196,212,0.15) !important; | |
| outline: none !important; | |
| } | |
| /* Slider number input: 大人っぽく角ばった四角 */ | |
| input[type="number"] { | |
| background: #ffffff !important; | |
| border: 1.25px solid #888888 !important; | |
| border-radius: 0 !important; | |
| color: #1a1a1a !important; | |
| font-family: 'Cormorant Garamond', 'Georgia', serif !important; | |
| font-weight: 500 !important; | |
| font-size: 13px !important; | |
| letter-spacing: 0.05em !important; | |
| text-align: center !important; | |
| padding: 4px 6px !important; | |
| box-shadow: 3px 3px 0px 0px #c8c8c8 !important; | |
| outline: none !important; | |
| -moz-appearance: textfield !important; | |
| } | |
| input[type="number"]:focus { | |
| border-color: #555555 !important; | |
| box-shadow: 4px 4px 0px 0px #aaaaaa !important; | |
| outline: none !important; | |
| } | |
| input[type="number"]::-webkit-inner-spin-button, | |
| input[type="number"]::-webkit-outer-spin-button { | |
| -webkit-appearance: none !important; | |
| margin: 0 !important; | |
| } | |
| /* Dropdown: 全体リセット */ | |
| [data-testid="dropdown"] { | |
| background: transparent !important; | |
| border: none !important; | |
| box-shadow: none !important; | |
| border-radius: 0 !important; | |
| } | |
| [data-testid="dropdown"] > div, | |
| [data-testid="dropdown"] .wrap, | |
| [data-testid="dropdown"] .wrap-inner, | |
| [data-testid="dropdown"] .secondary-wrap, | |
| [data-testid="dropdown"] input, | |
| [data-testid="dropdown"] .multiselect { | |
| background: #ffffff !important; | |
| background-color: #ffffff !important; | |
| border: none !important; | |
| border-radius: 0 !important; | |
| box-shadow: none !important; | |
| outline: none !important; | |
| } | |
| /* 入力ラッパーのみ細い黒線で囲む */ | |
| [data-testid="dropdown"] .wrap, | |
| [data-testid="dropdown"] .secondary-wrap { | |
| border: 1px solid #1a1a1a !important; | |
| box-shadow: 3px 3px 0px 0px #cccccc !important; | |
| padding: 8px 10px !important; | |
| } | |
| /* 子要素の文字色 */ | |
| [data-testid="dropdown"] *:not(ul):not(ul *) { | |
| background: #ffffff !important; | |
| background-color: #ffffff !important; | |
| color: #1a1a1a !important; | |
| border-radius: 0 !important; | |
| } | |
| /* Dropdown選択肢リスト: 黒背景・白文字 */ | |
| ul.options, | |
| ul.options li, | |
| .options, | |
| .options .item, | |
| .secondary-wrap .item, | |
| .secondary-wrap ul li { | |
| background: #1a1a1a !important; | |
| color: #ffffff !important; | |
| border-radius: 0 !important; | |
| } | |
| ul.options li:hover, | |
| .options .item:hover, | |
| .secondary-wrap .item:hover { | |
| background: #333333 !important; | |
| color: #ffffff !important; | |
| } | |
| ul.options li.selected, | |
| .options .item.active { | |
| background: #555555 !important; | |
| color: #ffffff !important; | |
| } | |
| /* Slider: ラグジュアリー仕様 */ | |
| input[type="range"] { | |
| -webkit-appearance: none !important; | |
| appearance: none !important; | |
| height: 3px !important; | |
| background: linear-gradient(90deg, | |
| #e8c8d4 0%, | |
| #d4b8e0 40%, | |
| #b8d4e8 100%) !important; | |
| border-radius: 0 !important; | |
| outline: none !important; | |
| cursor: pointer !important; | |
| overflow: visible !important; | |
| margin: 12px 0 !important; | |
| } | |
| input[type="range"]::-webkit-slider-thumb { | |
| -webkit-appearance: none !important; | |
| appearance: none !important; | |
| width: 14px !important; | |
| height: 14px !important; | |
| background: #3a3a3a !important; | |
| border: 1.5px solid #888888 !important; | |
| border-radius: 0 !important; | |
| transform: rotate(45deg) !important; | |
| cursor: pointer !important; | |
| box-shadow: 2px 2px 4px rgba(0,0,0,0.2) !important; | |
| margin-top: -6px !important; | |
| position: relative !important; | |
| } | |
| input[type="range"]::-moz-range-thumb { | |
| width: 14px !important; | |
| height: 14px !important; | |
| background: #3a3a3a !important; | |
| border: 1.5px solid #888888 !important; | |
| border-radius: 0 !important; | |
| transform: rotate(45deg) !important; | |
| cursor: pointer !important; | |
| } | |
| input[type="range"]::-webkit-slider-runnable-track { | |
| height: 3px !important; | |
| background: linear-gradient(90deg, | |
| #e8c8d4 0%, | |
| #d4b8e0 40%, | |
| #b8d4e8 100%) !important; | |
| border-radius: 0 !important; | |
| overflow: visible !important; | |
| } | |
| /* Sliderブロック全体: 角ばったコンテナ */ | |
| [data-testid="slider"] { | |
| background: #fafafa !important; | |
| border: 1.25px solid #cccccc !important; | |
| border-radius: 0 !important; | |
| padding: 12px 14px 16px 14px !important; | |
| box-shadow: 3px 3px 0px 0px #d8d8d8 !important; | |
| overflow: visible !important; | |
| } | |
| [data-testid="slider"] > div, | |
| [data-testid="slider"] .wrap, | |
| [data-testid="slider"] .wrap-inner { | |
| overflow: visible !important; | |
| } | |
| [data-testid="slider"] .label-wrap span, | |
| [data-testid="slider"] label span { | |
| font-family: 'Cormorant Garamond', 'Georgia', serif !important; | |
| font-size: 11px !important; | |
| letter-spacing: 0.12em !important; | |
| text-transform: uppercase !important; | |
| color: #555555 !important; | |
| } | |
| /* sliderのrefreshボタン(リセットアイコン)を角ばりに */ | |
| [data-testid="slider"] button { | |
| border-radius: 0 !important; | |
| border: 1.25px solid #aaaaaa !important; | |
| background: #f0f0f0 !important; | |
| padding: 4px 6px !important; | |
| } | |
| /* Radio */ | |
| .gr-radio-row label, [data-testid="radio-group"] label { | |
| color: #222222 !important; | |
| font-weight: 400 !important; | |
| } | |
| /* JSON表示 */ | |
| .json-holder, [data-testid="json"] { | |
| background: rgba(255,255,255,0.5) !important; | |
| border-radius: 18px !important; | |
| border: 1px solid rgba(200,191,224,0.15) !important; | |
| } | |
| /* Textbox */ | |
| textarea { | |
| background: rgba(255,255,255,0.6) !important; | |
| color: #1a1a1a !important; | |
| border-radius: 16px !important; | |
| font-family: 'SF Mono', 'Fira Code', ui-monospace, monospace !important; | |
| font-size: 12px !important; | |
| } | |
| /* Markdown */ | |
| .prose, .markdown-text, .md { | |
| color: #1a1a1a !important; | |
| } | |
| .prose h2, .prose h3 { | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', serif !important; | |
| color: #222222 !important; | |
| font-weight: 400 !important; | |
| letter-spacing: 0.08em !important; | |
| } | |
| .prose table { | |
| color: #1a1a1a !important; | |
| } | |
| .prose table th { | |
| color: #222222 !important; | |
| font-weight: 500 !important; | |
| background: rgba(244,196,212,0.1) !important; | |
| } | |
| .prose table td { | |
| border-color: rgba(200,191,224,0.2) !important; | |
| } | |
| /* ======================================== | |
| Gradioボタン: VIEW MORE風 / セリフ体 / シャープ | |
| ======================================== */ | |
| button[class*="primary"], button[class*="secondary"], | |
| button.lg { | |
| border-radius: 0 !important; | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', 'YuMincho', serif !important; | |
| font-weight: 500 !important; | |
| font-size: 13px !important; | |
| letter-spacing: 0.18em !important; | |
| text-transform: uppercase !important; | |
| padding: 16px 28px !important; | |
| transition: all 0.3s ease !important; | |
| touch-action: manipulation !important; | |
| -webkit-tap-highlight-color: transparent !important; | |
| } | |
| /* Primary ボタン: ダーク背景 */ | |
| button[class*="primary"] { | |
| background: #3a3a3a !important; | |
| color: #d8d8d8 !important; | |
| border: 1px solid #3a3a3a !important; | |
| box-shadow: none !important; | |
| } | |
| button[class*="primary"]:hover { | |
| background: #4a4a4a !important; | |
| transform: none !important; | |
| box-shadow: none !important; | |
| } | |
| button[class*="primary"]:active { | |
| background: #555555 !important; | |
| } | |
| /* Secondary ボタン: 白背景 + 細線ボーダー */ | |
| button[class*="secondary"] { | |
| background: #ffffff !important; | |
| color: #555555 !important; | |
| border: 1px solid #aaaaaa !important; | |
| box-shadow: none !important; | |
| } | |
| button[class*="secondary"]:hover { | |
| background: #f5f5f5 !important; | |
| border-color: #888888 !important; | |
| transform: none !important; | |
| box-shadow: none !important; | |
| } | |
| button[class*="secondary"]:active { | |
| background: #eeeeee !important; | |
| } | |
| /* ======================================== | |
| セクションタイトル | |
| ======================================== */ | |
| .section-title { | |
| text-align: center !important; | |
| padding: 20px 16px 4px 16px !important; | |
| } | |
| .section-title h2 { | |
| font-family: 'Cormorant Garamond', 'Georgia', 'Times New Roman', serif !important; | |
| font-size: clamp(16px, 4vw, 22px) !important; | |
| font-weight: 400 !important; | |
| color: #222222 !important; | |
| letter-spacing: 0.15em !important; | |
| text-transform: uppercase !important; | |
| } | |
| /* ======================================== | |
| WORKFLOW以下: 背景を統一して視認性UP | |
| ======================================== */ | |
| .section-title, | |
| .section-title ~ * { | |
| background-color: #ffffff !important; | |
| } | |
| /* ======================================== | |
| レスポンシブ: PC = 中央固定幅 | |
| ======================================== */ | |
| @media (min-width: 768px) { | |
| .gradio-container > .main, | |
| .gradio-container > div > .main { | |
| max-width: 480px !important; | |
| margin: 0 auto !important; | |
| } | |
| .sensor-hero { | |
| min-height: auto; | |
| } | |
| .tabitem > div { | |
| margin: 20px auto 32px auto !important; | |
| max-width: 440px !important; | |
| } | |
| div.tab-nav { | |
| max-width: 440px !important; | |
| margin: 16px auto 20px auto !important; | |
| } | |
| } | |
| /* ======================================== | |
| スマホ特化: タッチ最適化 | |
| ======================================== */ | |
| @media (max-width: 767px) { | |
| .sensor-hero { | |
| min-height: auto; | |
| padding: 32px 20px 24px 20px; | |
| } | |
| .sensor-hero__buttons { | |
| width: 100%; | |
| max-width: 300px; | |
| } | |
| .hero-btn { | |
| flex: 1 1 calc(50% - 5px); | |
| min-width: 130px; | |
| padding: 14px 10px; | |
| font-size: 13px; | |
| } | |
| .section-title { | |
| padding: 24px 16px 4px 16px !important; | |
| } | |
| div.tab-nav { | |
| margin: 12px 12px 16px 12px !important; | |
| padding: 4px !important; | |
| } | |
| div.tab-nav button { | |
| padding: 9px 12px !important; | |
| font-size: 13px !important; | |
| } | |
| .tabitem > div { | |
| margin: 16px 14px 28px 14px !important; | |
| padding: 28px 16px !important; | |
| box-shadow: 8px 8px 0px 0px #c8c8c8 !important; | |
| } | |
| button[class*="primary"], button[class*="secondary"], | |
| button.lg { | |
| width: 100% !important; | |
| padding: 15px 20px !important; | |
| font-size: 15px !important; | |
| } | |
| label span, .label-wrap span { | |
| font-size: 13px !important; | |
| } | |
| input[type="text"], input[type="number"], textarea, select { | |
| font-size: 16px !important; | |
| } | |
| /* Rowを縦並びに */ | |
| .row, [class*="row"] { | |
| flex-direction: column !important; | |
| } | |
| } | |
| /* ======================================== | |
| アニメーション | |
| ======================================== */ | |
| .tabitem > div { | |
| animation: softFadeIn 0.5s ease forwards; | |
| } | |
| @keyframes softFadeIn { | |
| from { opacity: 0; transform: translateY(12px); } | |
| to { opacity: 1; transform: translateY(0); } | |
| } | |
| /* ======================================== | |
| スクロールバー: やわらかく | |
| ======================================== */ | |
| ::-webkit-scrollbar { | |
| width: 4px; | |
| } | |
| ::-webkit-scrollbar-track { | |
| background: transparent; | |
| } | |
| ::-webkit-scrollbar-thumb { | |
| background: rgba(200,191,224,0.3); | |
| border-radius: 4px; | |
| } | |
| /* ======================================== | |
| Gradio内部のpadding/border補正 | |
| ======================================== */ | |
| .block { | |
| border: none !important; | |
| background: transparent !important; | |
| padding: 0 !important; | |
| } | |
| .form { | |
| background: transparent !important; | |
| border: none !important; | |
| } | |
| .container { | |
| background: transparent !important; | |
| } | |
| .tabs { | |
| background: #ffffff !important; | |
| } | |
| /* ======================================== | |
| 全テキスト強制黒(最終手段) | |
| ボタン・ヒーロー系は除外 | |
| ======================================== */ | |
| body, body * { | |
| color: #1a1a1a !important; | |
| } | |
| /* ヒーローボタン: 色を個別に戻す */ | |
| .hero-btn--outline, | |
| .hero-btn--outline * { | |
| color: #555555 !important; | |
| } | |
| .hero-btn--dark, | |
| .hero-btn--dark * { | |
| color: #d8d8d8 !important; | |
| } | |
| /* Gradioボタン */ | |
| button[class*="primary"], | |
| button[class*="primary"] * { | |
| color: #d8d8d8 !important; | |
| } | |
| button[class*="secondary"], | |
| button[class*="secondary"] * { | |
| color: #555555 !important; | |
| } | |
| /* JSON表示の色 */ | |
| .json-holder *, | |
| [data-testid="json"] * { | |
| color: #1a1a1a !important; | |
| } | |
| """ | |
| # ── UI変更: HEAD メタタグ + セリフ体Webフォント読み込み ── | |
| HEAD = """ | |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover, user-scalable=no"> | |
| <meta name="theme-color" content="#fdf2f8"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="default"> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;500;600&display=swap" rel="stylesheet"> | |
| """ | |
| # ========================= | |
| # Gradio UI | |
| # ── UI変更: Blocks構造をフェミニン・パステルデザインに再構築 ── | |
| # ── 縦スクロール1カラム / 中央寄せ / 余白たっぷり ── | |
| # ========================= | |
| # Gradio 6ではcss/theme/headはmount_gradio_app()で指定 | |
| with gr.Blocks() as demo: | |
| # ── UI変更: ヒーローセクションをトップに配置 ── | |
| gr.HTML(SENSOR_UI) | |
| # ── UI変更: セクションタイトル(やわらかいフォント) ── | |
| gr.Markdown("## - W O R K F L O W -", elem_classes=["section-title"]) | |
| with gr.Tabs(): | |
| # ── UI変更: 収集タブ — 縦並び1カラム ── | |
| with gr.Tab("収集"): | |
| label = gr.Dropdown( | |
| choices=DEFAULT_LABELS, | |
| value=DEFAULT_LABELS[0], | |
| label="ラベル" | |
| ) | |
| btn_c_start = gr.Button("収集開始", size="lg") | |
| btn_c_stop = gr.Button("収集停止 → 保存", size="lg") | |
| collect_msg = gr.Markdown("-") | |
| counts_json = gr.JSON(value={"TOTAL": 0}, label="回数カウンタ") | |
| btn_c_start.click(collect_start, inputs=[label], outputs=[collect_msg, btn_c_start, btn_c_stop]) | |
| btn_c_stop.click(collect_stop_and_save, inputs=None, outputs=[collect_msg, btn_c_start, btn_c_stop, counts_json]) | |
| # ── UI変更: 学習タブ — スライダー縦並び ── | |
| with gr.Tab("学習"): | |
| stline = gr.Markdown("MODEL: not trained") | |
| window_sec = gr.Slider(0.6, 2.0, value=CFG.window_sec, step=0.1, label="window_sec") | |
| hop_sec = gr.Slider(0.1, 0.5, value=CFG.hop_sec, step=0.1, label="hop_sec") | |
| fs_target = gr.Slider(20, 100, value=CFG.fs_target, step=5, label="fs_target") | |
| feat_mode = gr.Radio(choices=["last", "mean"], value="last", label="state aggregation") | |
| btn_train = gr.Button("学習", variant="primary", size="lg") | |
| train_msg = gr.Markdown("-") | |
| train_cfg = gr.JSON(label="選ばれたハイパラ") | |
| tail_log = gr.Textbox(lines=6, label="ログ(末尾)") | |
| btn_train.click(train_click, inputs=[window_sec, hop_sec, fs_target, feat_mode], | |
| outputs=[stline, train_cfg, train_msg, tail_log]) | |
| # ── UI変更: 推論タブ — 予測表示中央 ── | |
| with gr.Tab("推論"): | |
| infer_state = gr.Markdown("推論: OFF") | |
| btn_i_start = gr.Button("推論開始", size="lg") | |
| btn_i_stop = gr.Button("推論停止", size="lg") | |
| stline2 = gr.Markdown("MODEL: not trained") | |
| pred_html = gr.HTML("<div style='font-size:24px;font-weight:800;opacity:.6'>-</div>") | |
| prob_json = gr.JSON(label="確信度(クラス別)") | |
| btn_i_start.click(infer_start, inputs=None, outputs=[infer_state, pred_html, prob_json, stline2]) | |
| btn_i_stop.click(infer_stop, inputs=None, outputs=[infer_state, pred_html, prob_json, stline2]) | |
| timer_inf = gr.Timer(value=CFG.hop_sec) | |
| timer_inf.tick(infer_tick, inputs=None, outputs=[pred_html, prob_json, stline2]) | |
| # ── UI変更: 対話タブ ── | |
| with gr.Tab("対話"): | |
| gr.Markdown("### 推論結果") | |
| chat_big = gr.HTML("<div style='font-size:22px;font-weight:800;opacity:.6'>推論がOFFです</div>") | |
| chat_log = gr.Markdown("(log empty)") | |
| timer_chat = gr.Timer(value=0.3) | |
| timer_chat.tick(chat_tick, inputs=None, outputs=[chat_big, chat_log]) | |
| demo.load(fn=None, inputs=None, outputs=None, js=JS_BOOT) | |
| # ========================= | |
| # Mount Gradio into FastAPI (SSR OFF) | |
| # ========================= | |
| # ── UI変更: Gradio 6ではcss/theme/head をmount_gradio_appに渡す ── | |
| app = gr.mount_gradio_app( | |
| api, demo, path="/", ssr_mode=False, | |
| css=CSS, | |
| head=HEAD, | |
| theme=gr.themes.Base( | |
| text_size=gr.themes.sizes.text_md, | |
| font=["Inter", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "sans-serif"], | |
| ).set( | |
| body_text_color="#1a1a1a", | |
| body_text_color_subdued="#333333", | |
| block_label_text_color="#222222", | |
| block_title_text_color="#1a1a1a", | |
| checkbox_label_text_color="#1a1a1a", | |
| table_text_color="#1a1a1a", | |
| link_text_color="#333333", | |
| color_accent_soft="#e8d5e0", | |
| input_background_fill="#ffffff", | |
| input_background_fill_dark="#ffffff", | |
| input_border_color="#1a1a1a", | |
| input_border_color_dark="#1a1a1a", | |
| ), | |
| ) | |
| # ========================= | |
| # Run on Colab (background thread) | |
| # ========================= | |
| def run_colab(server_port: int = 7860): | |
| import uvicorn | |
| config = uvicorn.Config(app, host="0.0.0.0", port=server_port, log_level="warning") | |
| server = uvicorn.Server(config) | |
| th = threading.Thread(target=server.run, daemon=True) | |
| th.start() | |
| time.sleep(1.0) | |
| # If in colab, show public URL via Gradio share too (simpler UX) | |
| # Note: We can't "launch" gradio separately because FastAPI+mount is already serving. | |
| # So: use Colab's port proxy link if available, otherwise open localhost in browser. | |
| try: | |
| from google.colab import output | |
| proxy_url = output.eval_js(f"google.colab.kernel.proxyPort({server_port})") | |
| print("Open this URL (PC):", proxy_url) | |
| print("Open the same URL on your smartphone to use accelerometer.") | |
| except Exception: | |
| print(f"Server running on http://127.0.0.1:{server_port} (Colab proxy unavailable here).") | |
| # Start | |
| if "google.colab" in sys.modules: | |
| run_colab(7860) | |
| else: | |
| # local python run (non-notebook) | |
| import uvicorn, pathlib | |
| _dir = pathlib.Path(__file__).parent | |
| _cert = _dir / "cert.pem" | |
| _key = _dir / "key.pem" | |
| if _cert.exists() and _key.exists(): | |
| print(f"HTTPS mode: https://192.168.11.7:7860/") | |
| uvicorn.run(app, host="0.0.0.0", port=7860, | |
| ssl_certfile=str(_cert), ssl_keyfile=str(_key)) | |
| else: | |
| print(f"HTTP mode (sensors may not work): http://0.0.0.0:7860/") | |
| uvicorn.run(app, host="0.0.0.0", port=7860) | |