Gayanukaa commited on
Commit
fbeb30f
Β·
1 Parent(s): 7a9852b

Add Docker deployment with FastAPI backend

Browse files
Files changed (6) hide show
  1. Dockerfile +30 -0
  2. README.md +3 -3
  3. app.py +111 -0
  4. evaluator.py +134 -0
  5. requirements.txt +4 -0
  6. static/index.html +921 -0
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Install OpenJDK (for SPICE JAR)
4
+ RUN apt-get update && apt-get install -y --no-install-recommends \
5
+ default-jre-headless \
6
+ && rm -rf /var/lib/apt/lists/*
7
+
8
+ # HuggingFace Spaces requires non-root user with uid 1000
9
+ RUN useradd -m -u 1000 user
10
+
11
+ WORKDIR /app
12
+
13
+ COPY requirements.txt .
14
+ RUN pip install --no-cache-dir -r requirements.txt
15
+
16
+ # Pre-download NLTK data
17
+ RUN python -c "import nltk; nltk.download('wordnet', download_dir='/usr/share/nltk_data'); nltk.download('omw-1.4', download_dir='/usr/share/nltk_data')"
18
+
19
+ # Download SPICE-1.0 from HF dataset repo
20
+ RUN python -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='Gayanukaa/spice-1.0-jar', repo_type='dataset', local_dir='/app/SPICE-1.0')"
21
+
22
+ COPY --chown=user:user . .
23
+
24
+ USER user
25
+ ENV HOME=/home/user \
26
+ PATH=/home/user/.local/bin:$PATH
27
+
28
+ EXPOSE 7860
29
+
30
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,10 @@
1
  ---
2
  title: SPICE Evaluator
3
- emoji: πŸ“š
4
- colorFrom: green
5
  colorTo: pink
6
  sdk: docker
7
- pinned: false
8
  license: apache-2.0
9
  short_description: SPICE metric evaluation and visualization
10
  ---
 
1
  ---
2
  title: SPICE Evaluator
3
+ emoji: πŸ”¬
4
+ colorFrom: purple
5
  colorTo: pink
6
  sdk: docker
7
+ pinned: true
8
  license: apache-2.0
9
  short_description: SPICE metric evaluation and visualization
10
  ---
app.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import json
3
+ import logging
4
+ import queue
5
+ from contextlib import asynccontextmanager
6
+ from typing import Optional
7
+
8
+ from fastapi import FastAPI, HTTPException, Request
9
+ from fastapi.middleware.cors import CORSMiddleware
10
+ from fastapi.responses import StreamingResponse
11
+ from fastapi.staticfiles import StaticFiles
12
+ from pydantic import BaseModel
13
+
14
+ from evaluator import SpiceEvaluator
15
+
16
+ logging.basicConfig(
17
+ level=logging.INFO,
18
+ format="%(asctime)s [%(levelname)s] %(name)s - %(message)s",
19
+ )
20
+ logger = logging.getLogger(__name__)
21
+
22
+ evaluator: Optional[SpiceEvaluator] = None
23
+
24
+
25
+ @asynccontextmanager
26
+ async def lifespan(app: FastAPI):
27
+ global evaluator
28
+ logger.info("Initializing SpiceEvaluator...")
29
+ evaluator = SpiceEvaluator(cache_dir="/tmp/spice_cache")
30
+ logger.info("SpiceEvaluator ready.")
31
+ yield
32
+ logger.info("Shutting down.")
33
+
34
+
35
+ app = FastAPI(title="SPICE Evaluator", lifespan=lifespan)
36
+
37
+ app.add_middleware(
38
+ CORSMiddleware,
39
+ allow_origins=["*"],
40
+ allow_methods=["GET", "POST"],
41
+ allow_headers=["Content-Type"],
42
+ )
43
+
44
+
45
+ class EvaluateRequest(BaseModel):
46
+ candidate: str
47
+ references: list[str]
48
+
49
+
50
+ @app.get("/api/health")
51
+ def health():
52
+ return {"status": "ready" if evaluator else "loading"}
53
+
54
+
55
+ @app.post("/api/evaluate")
56
+ async def evaluate(req: EvaluateRequest):
57
+ if not evaluator:
58
+ raise HTTPException(503, "Evaluator not ready")
59
+
60
+ candidate = req.candidate.strip()
61
+ if not candidate:
62
+ raise HTTPException(400, "Candidate caption is empty")
63
+
64
+ references = [r.strip() for r in req.references if r.strip()]
65
+ if not references:
66
+ raise HTTPException(400, "At least one reference caption required")
67
+
68
+ log_queue: queue.Queue[str] = queue.Queue()
69
+
70
+ def log_fn(msg: str):
71
+ log_queue.put(msg)
72
+
73
+ async def event_stream():
74
+ loop = asyncio.get_event_loop()
75
+ future = loop.run_in_executor(
76
+ None, evaluator.evaluate, candidate, references, log_fn
77
+ )
78
+
79
+ while not future.done():
80
+ try:
81
+ msg = log_queue.get_nowait()
82
+ yield f"event: log\ndata: {msg}\n\n"
83
+ except queue.Empty:
84
+ await asyncio.sleep(0.1)
85
+
86
+ # Drain remaining logs
87
+ while not log_queue.empty():
88
+ msg = log_queue.get_nowait()
89
+ yield f"event: log\ndata: {msg}\n\n"
90
+
91
+ try:
92
+ result = future.result()
93
+ data = {
94
+ "spice_precision": result["spice_precision"],
95
+ "spice_recall": result["spice_recall"],
96
+ "spice_f1": result["spice_f1"],
97
+ "binary_precision": result["precision"],
98
+ "binary_recall": result["recall"],
99
+ "binary_f1": result["binary_f1"],
100
+ "test_tuples": result["test_tuples"],
101
+ "ref_tuples": result["ref_tuples"],
102
+ }
103
+ yield f"event: result\ndata: {json.dumps(data)}\n\n"
104
+ except Exception as e:
105
+ logger.exception("SPICE evaluation failed")
106
+ yield f"event: error\ndata: {str(e)}\n\n"
107
+
108
+ return StreamingResponse(event_stream(), media_type="text/event-stream")
109
+
110
+
111
+ app.mount("/", StaticFiles(directory="static", html=True), name="static")
evaluator.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import uuid
5
+
6
+ from nltk import download as nltk_download
7
+ from nltk.corpus import wordnet
8
+
9
+ try:
10
+ nltk_download("wordnet", quiet=True)
11
+ nltk_download("omw-1.4", quiet=True)
12
+ except Exception:
13
+ pass
14
+
15
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
16
+ SPICE_JAR = os.path.join(BASE_DIR, "SPICE-1.0", "spice-1.0.jar")
17
+
18
+
19
+ def _run_spice(input_json: str, output_json: str, detailed: bool, log_fn=None) -> dict:
20
+ if not os.path.isfile(SPICE_JAR):
21
+ raise FileNotFoundError(f"SPICE jar not found at {SPICE_JAR}")
22
+
23
+ cmd = ["java", "-Xmx2G", "-jar", SPICE_JAR, input_json]
24
+ if detailed:
25
+ cmd.append("-detailed")
26
+ cmd += ["-out", output_json, "-silent"]
27
+
28
+ if log_fn:
29
+ log_fn(f"> {' '.join(cmd)}")
30
+
31
+ proc = subprocess.Popen(
32
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True
33
+ )
34
+ for line in proc.stdout:
35
+ if log_fn:
36
+ log_fn(line.rstrip())
37
+ ret = proc.wait()
38
+ if ret != 0:
39
+ raise RuntimeError(f"SPICE exited {ret}")
40
+
41
+ with open(output_json, "r") as f:
42
+ results = json.load(f)
43
+ if not isinstance(results, list) or len(results) != 1:
44
+ raise ValueError(f"Unexpected SPICE output: {results}")
45
+ return results[0]
46
+
47
+
48
+ def _wordnet_match(w1: str, w2: str) -> bool:
49
+ if w1 == w2:
50
+ return True
51
+ syns1 = {l.name() for s in wordnet.synsets(w1) for l in s.lemmas()}
52
+ syns2 = {l.name() for s in wordnet.synsets(w2) for l in s.lemmas()}
53
+ return bool(syns1 & syns2)
54
+
55
+
56
+ def precision_recall_f1(hyp_tups: list, ref_tups: list) -> tuple:
57
+ matched, used = 0, set()
58
+ for h in hyp_tups:
59
+ for i, r in enumerate(ref_tups):
60
+ if i in used:
61
+ continue
62
+ if all(_wordnet_match(str(h[j]), str(r[j])) for j in range(len(h))):
63
+ matched += 1
64
+ used.add(i)
65
+ break
66
+ p = matched / len(hyp_tups) if hyp_tups else 0.0
67
+ r = matched / len(ref_tups) if ref_tups else 0.0
68
+ f1 = (2 * p * r / (p + r)) if (p + r) else 0.0
69
+ return p, r, f1
70
+
71
+
72
+ class SpiceEvaluator:
73
+ def __init__(self, cache_dir="/tmp/spice_cache"):
74
+ self.cache_dir = cache_dir if os.path.isabs(cache_dir) else os.path.join(BASE_DIR, cache_dir)
75
+ os.makedirs(self.cache_dir, exist_ok=True)
76
+
77
+ def evaluate(self, candidate: str, references: list, log_fn=None) -> dict:
78
+ request_id = uuid.uuid4().hex[:8]
79
+
80
+ if log_fn:
81
+ log_fn("Preparing input JSON...")
82
+ inp = os.path.join(self.cache_dir, f"inp_{request_id}.json")
83
+ with open(inp, "w") as f:
84
+ json.dump([{"image_id": 0, "test": candidate, "refs": references}], f)
85
+ if log_fn:
86
+ log_fn(f"Wrote input to {inp}")
87
+
88
+ outp = os.path.join(self.cache_dir, f"out_{request_id}.json")
89
+ if log_fn:
90
+ log_fn("Running SPICE...")
91
+ try:
92
+ result = _run_spice(inp, outp, detailed=True, log_fn=log_fn)
93
+ finally:
94
+ for path in (inp, outp):
95
+ try:
96
+ os.remove(path)
97
+ except OSError:
98
+ pass
99
+
100
+ if log_fn:
101
+ log_fn("SPICE completed")
102
+
103
+ sc = result["scores"]["All"]
104
+ sp_p = sc.get("pr", 0.0)
105
+ sp_r = sc.get("re", 0.0)
106
+ sp_f = sc.get("f", 0.0)
107
+ if log_fn:
108
+ log_fn(
109
+ f"SPICE -> Precision: {sp_p:.3f}, Recall: {sp_r:.3f}, F1: {sp_f:.3f}"
110
+ )
111
+
112
+ test_block = result.get("test_tuples", [])
113
+ ref_block = result.get("ref_tuples", [])
114
+ test_tups = [e["tuple"] for e in test_block]
115
+ ref_tups = [e["tuple"] for e in ref_block]
116
+ if log_fn:
117
+ log_fn(
118
+ f"Parsed {len(test_tups)} test tuples and {len(ref_tups)} reference tuples"
119
+ )
120
+
121
+ p, r, bf1 = precision_recall_f1(test_tups, ref_tups)
122
+ if log_fn:
123
+ log_fn(f"Tuple-level binary F1: {bf1:.3f}")
124
+
125
+ return {
126
+ "spice_precision": sp_p,
127
+ "spice_recall": sp_r,
128
+ "spice_f1": sp_f,
129
+ "precision": p,
130
+ "recall": r,
131
+ "binary_f1": bf1,
132
+ "test_tuples": test_tups,
133
+ "ref_tuples": ref_tups,
134
+ }
requirements.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.29.0
3
+ nltk>=3.8
4
+ huggingface_hub
static/index.html ADDED
@@ -0,0 +1,921 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>SPICE Evaluator</title>
7
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --primary: #4a6fa5;
11
+ --primary-light: #6b8fc5;
12
+ --accent: #e8913a;
13
+ --bg: #f5f7fa;
14
+ --card-bg: #ffffff;
15
+ --border: #e0e4ea;
16
+ --text: #2c3e50;
17
+ --text-dim: #7f8c9b;
18
+ --success: #27ae60;
19
+ --shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
20
+ --radius: 8px;
21
+ }
22
+
23
+ * {
24
+ margin: 0;
25
+ padding: 0;
26
+ box-sizing: border-box;
27
+ }
28
+
29
+ body {
30
+ background: var(--bg);
31
+ color: var(--text);
32
+ font-family: "Inter", "Segoe UI", system-ui, sans-serif;
33
+ min-height: 100vh;
34
+ line-height: 1.5;
35
+ }
36
+
37
+ /* ── Header ──────────────────────────────────────────── */
38
+ header {
39
+ background: var(--card-bg);
40
+ border-bottom: 1px solid var(--border);
41
+ padding: 16px 32px;
42
+ display: flex;
43
+ align-items: center;
44
+ justify-content: space-between;
45
+ box-shadow: var(--shadow);
46
+ }
47
+
48
+ header h1 {
49
+ font-size: 1.4rem;
50
+ font-weight: 700;
51
+ color: var(--primary);
52
+ }
53
+
54
+ header h1 span {
55
+ color: var(--accent);
56
+ }
57
+
58
+ .health-badge {
59
+ font-size: 0.8rem;
60
+ padding: 4px 12px;
61
+ border-radius: 20px;
62
+ font-weight: 500;
63
+ }
64
+
65
+ .health-badge.ready {
66
+ background: #e8f5e9;
67
+ color: var(--success);
68
+ }
69
+
70
+ .health-badge.loading {
71
+ background: #fff3e0;
72
+ color: #e65100;
73
+ }
74
+
75
+ /* ── Main layout ─────────────────────────────────────── */
76
+ .container {
77
+ max-width: 1200px;
78
+ margin: 0 auto;
79
+ padding: 24px;
80
+ }
81
+
82
+ /* ── Input section ───────────────────────────────────── */
83
+ .input-section {
84
+ background: var(--card-bg);
85
+ border-radius: var(--radius);
86
+ padding: 24px;
87
+ box-shadow: var(--shadow);
88
+ margin-bottom: 24px;
89
+ }
90
+
91
+ .input-section h2 {
92
+ font-size: 1rem;
93
+ font-weight: 600;
94
+ margin-bottom: 16px;
95
+ color: var(--primary);
96
+ }
97
+
98
+ .input-grid {
99
+ display: grid;
100
+ grid-template-columns: 1fr 1fr;
101
+ gap: 20px;
102
+ }
103
+
104
+ @media (max-width: 768px) {
105
+ .input-grid {
106
+ grid-template-columns: 1fr;
107
+ }
108
+ }
109
+
110
+ .input-group label {
111
+ display: block;
112
+ font-size: 0.85rem;
113
+ font-weight: 600;
114
+ margin-bottom: 6px;
115
+ color: var(--text);
116
+ }
117
+
118
+ .input-group input,
119
+ .input-group textarea {
120
+ width: 100%;
121
+ padding: 10px 12px;
122
+ border: 1px solid var(--border);
123
+ border-radius: 6px;
124
+ font-family: inherit;
125
+ font-size: 0.9rem;
126
+ color: var(--text);
127
+ background: var(--bg);
128
+ transition: border-color 0.2s;
129
+ resize: vertical;
130
+ }
131
+
132
+ .input-group input:focus,
133
+ .input-group textarea:focus {
134
+ outline: none;
135
+ border-color: var(--primary-light);
136
+ box-shadow: 0 0 0 3px rgba(74, 111, 165, 0.12);
137
+ }
138
+
139
+ .input-group textarea {
140
+ min-height: 120px;
141
+ }
142
+
143
+ /* ── Examples selector ─────────────────────────────── */
144
+ .examples-bar {
145
+ display: flex;
146
+ align-items: center;
147
+ gap: 12px;
148
+ margin-bottom: 16px;
149
+ flex-wrap: wrap;
150
+ }
151
+
152
+ .examples-bar label {
153
+ font-size: 0.85rem;
154
+ font-weight: 600;
155
+ color: var(--text);
156
+ white-space: nowrap;
157
+ }
158
+
159
+ .examples-bar select {
160
+ flex: 1;
161
+ min-width: 200px;
162
+ padding: 8px 12px;
163
+ border: 1px solid var(--border);
164
+ border-radius: 6px;
165
+ font-family: inherit;
166
+ font-size: 0.85rem;
167
+ color: var(--text);
168
+ background: var(--bg);
169
+ cursor: pointer;
170
+ transition: border-color 0.2s;
171
+ }
172
+
173
+ .examples-bar select:focus {
174
+ outline: none;
175
+ border-color: var(--primary-light);
176
+ box-shadow: 0 0 0 3px rgba(74, 111, 165, 0.12);
177
+ }
178
+
179
+ .btn-evaluate {
180
+ margin-top: 16px;
181
+ padding: 10px 28px;
182
+ background: var(--primary);
183
+ color: #fff;
184
+ border: none;
185
+ border-radius: 6px;
186
+ font-size: 0.9rem;
187
+ font-weight: 600;
188
+ cursor: pointer;
189
+ transition: background 0.2s;
190
+ display: inline-flex;
191
+ align-items: center;
192
+ gap: 8px;
193
+ }
194
+
195
+ .btn-evaluate:hover {
196
+ background: var(--primary-light);
197
+ }
198
+
199
+ .btn-evaluate:disabled {
200
+ background: var(--text-dim);
201
+ cursor: not-allowed;
202
+ }
203
+
204
+ .spinner {
205
+ width: 16px;
206
+ height: 16px;
207
+ border: 2px solid rgba(255, 255, 255, 0.3);
208
+ border-top-color: #fff;
209
+ border-radius: 50%;
210
+ animation: spin 0.6s linear infinite;
211
+ display: none;
212
+ }
213
+
214
+ @keyframes spin {
215
+ to {
216
+ transform: rotate(360deg);
217
+ }
218
+ }
219
+
220
+ /* ── Results ─────────────────────────────────────────── */
221
+ .results-section {
222
+ display: none;
223
+ margin-bottom: 24px;
224
+ }
225
+
226
+ .results-section.visible {
227
+ display: block;
228
+ }
229
+
230
+ .metrics-grid {
231
+ display: grid;
232
+ grid-template-columns: repeat(3, 1fr);
233
+ gap: 16px;
234
+ margin-bottom: 24px;
235
+ }
236
+
237
+ @media (max-width: 600px) {
238
+ .metrics-grid {
239
+ grid-template-columns: 1fr;
240
+ }
241
+ }
242
+
243
+ .metric-card {
244
+ background: var(--card-bg);
245
+ border-radius: var(--radius);
246
+ padding: 20px;
247
+ text-align: center;
248
+ box-shadow: var(--shadow);
249
+ border-top: 3px solid var(--primary);
250
+ }
251
+
252
+ .metric-card.highlight {
253
+ border-top-color: var(--accent);
254
+ }
255
+
256
+ .metric-card .label {
257
+ font-size: 0.8rem;
258
+ font-weight: 600;
259
+ color: var(--text-dim);
260
+ text-transform: uppercase;
261
+ letter-spacing: 0.5px;
262
+ margin-bottom: 4px;
263
+ }
264
+
265
+ .metric-card .value {
266
+ font-size: 2rem;
267
+ font-weight: 700;
268
+ color: var(--text);
269
+ }
270
+
271
+ /* ── Graphs ──────────────────────────────────────────── */
272
+ .graphs-section {
273
+ display: grid;
274
+ grid-template-columns: 1fr 1fr;
275
+ gap: 16px;
276
+ margin-bottom: 24px;
277
+ }
278
+
279
+ @media (max-width: 768px) {
280
+ .graphs-section {
281
+ grid-template-columns: 1fr;
282
+ }
283
+ }
284
+
285
+ .graph-card {
286
+ background: var(--card-bg);
287
+ border-radius: var(--radius);
288
+ box-shadow: var(--shadow);
289
+ overflow: hidden;
290
+ }
291
+
292
+ .graph-card .graph-header {
293
+ padding: 12px 16px;
294
+ font-size: 0.85rem;
295
+ font-weight: 600;
296
+ color: var(--primary);
297
+ border-bottom: 1px solid var(--border);
298
+ }
299
+
300
+ .graph-container {
301
+ height: 400px;
302
+ width: 100%;
303
+ }
304
+
305
+ .graph-empty {
306
+ display: flex;
307
+ align-items: center;
308
+ justify-content: center;
309
+ height: 400px;
310
+ color: var(--text-dim);
311
+ font-size: 0.9rem;
312
+ }
313
+
314
+ /* ── Collapsible sections ────────────────────────────── */
315
+ .collapsible {
316
+ background: var(--card-bg);
317
+ border-radius: var(--radius);
318
+ box-shadow: var(--shadow);
319
+ margin-bottom: 16px;
320
+ overflow: hidden;
321
+ }
322
+
323
+ .collapsible-header {
324
+ padding: 12px 16px;
325
+ font-size: 0.85rem;
326
+ font-weight: 600;
327
+ color: var(--primary);
328
+ cursor: pointer;
329
+ display: flex;
330
+ align-items: center;
331
+ justify-content: space-between;
332
+ user-select: none;
333
+ border-bottom: 1px solid transparent;
334
+ transition: border-color 0.2s;
335
+ }
336
+
337
+ .collapsible-header:hover {
338
+ background: var(--bg);
339
+ }
340
+
341
+ .collapsible-header .arrow {
342
+ transition: transform 0.2s;
343
+ font-size: 0.7rem;
344
+ color: var(--text-dim);
345
+ }
346
+
347
+ .collapsible.open .collapsible-header {
348
+ border-bottom-color: var(--border);
349
+ }
350
+
351
+ .collapsible.open .collapsible-header .arrow {
352
+ transform: rotate(90deg);
353
+ }
354
+
355
+ .collapsible-body {
356
+ display: none;
357
+ padding: 16px;
358
+ }
359
+
360
+ .collapsible.open .collapsible-body {
361
+ display: block;
362
+ }
363
+
364
+ .tuple-list {
365
+ font-family: "Fira Code", "Consolas", monospace;
366
+ font-size: 0.82rem;
367
+ line-height: 1.8;
368
+ color: var(--text);
369
+ white-space: pre-wrap;
370
+ background: var(--bg);
371
+ padding: 12px;
372
+ border-radius: 6px;
373
+ }
374
+
375
+ .log-output {
376
+ font-family: "Fira Code", "Consolas", monospace;
377
+ font-size: 0.8rem;
378
+ line-height: 1.7;
379
+ color: var(--text-dim);
380
+ white-space: pre-wrap;
381
+ background: #1e1e2e;
382
+ color: #cdd6f4;
383
+ padding: 12px;
384
+ border-radius: 6px;
385
+ max-height: 300px;
386
+ overflow-y: auto;
387
+ }
388
+
389
+ .about-body p {
390
+ margin-bottom: 10px;
391
+ font-size: 0.88rem;
392
+ color: var(--text);
393
+ line-height: 1.6;
394
+ }
395
+
396
+ .about-body a {
397
+ color: var(--primary);
398
+ text-decoration: none;
399
+ }
400
+
401
+ .about-body a:hover {
402
+ text-decoration: underline;
403
+ }
404
+
405
+ .about-links {
406
+ display: flex;
407
+ gap: 16px;
408
+ flex-wrap: wrap;
409
+ margin-top: 12px;
410
+ }
411
+
412
+ .about-links a {
413
+ padding: 6px 14px;
414
+ border: 1px solid var(--border);
415
+ border-radius: 6px;
416
+ font-size: 0.82rem;
417
+ font-weight: 500;
418
+ color: var(--primary);
419
+ transition: background 0.2s, border-color 0.2s;
420
+ }
421
+
422
+ .about-links a:hover {
423
+ background: var(--bg);
424
+ border-color: var(--primary-light);
425
+ text-decoration: none;
426
+ }
427
+
428
+ .error-banner {
429
+ background: #fde8e8;
430
+ color: #c0392b;
431
+ padding: 12px 16px;
432
+ border-radius: var(--radius);
433
+ margin-bottom: 16px;
434
+ font-size: 0.9rem;
435
+ display: none;
436
+ }
437
+
438
+ .error-banner.visible {
439
+ display: block;
440
+ }
441
+
442
+ footer {
443
+ text-align: center;
444
+ padding: 16px;
445
+ color: var(--text-dim);
446
+ font-size: 0.8rem;
447
+ }
448
+
449
+ footer a {
450
+ color: var(--primary);
451
+ text-decoration: none;
452
+ }
453
+
454
+ footer a:hover {
455
+ text-decoration: underline;
456
+ }
457
+ </style>
458
+ </head>
459
+ <body>
460
+ <header>
461
+ <h1>SPICE <span>Evaluator</span></h1>
462
+ <span id="healthBadge" class="health-badge loading">Checking...</span>
463
+ </header>
464
+
465
+ <div class="container">
466
+ <!-- Input Section -->
467
+ <div class="input-section">
468
+ <h2>Caption Inputs</h2>
469
+ <div class="examples-bar">
470
+ <label for="exampleSelect">Load Example:</label>
471
+ <select id="exampleSelect" onchange="loadExample()">
472
+ <option value="">-- Select an example --</option>
473
+ </select>
474
+ </div>
475
+ <div class="input-grid">
476
+ <div class="input-group">
477
+ <label for="candidate">Candidate Caption</label>
478
+ <input
479
+ type="text"
480
+ id="candidate"
481
+ value="a herd of giraffe standing next to each other"
482
+ />
483
+ </div>
484
+ <div class="input-group">
485
+ <label for="references">Reference Captions (one per line)</label>
486
+ <textarea id="references">a couple of giraffes that are walking around
487
+ a herd of giraffe standing on top of a dirt field.
488
+ Several smaller giraffes that are in an enclosure.
489
+ The giraffes are walking in different directions outside.
490
+ A giraffe standing next to three baby giraffes in a zoo exhibit.</textarea>
491
+ </div>
492
+ </div>
493
+ <button class="btn-evaluate" id="evalBtn" onclick="runEvaluation()">
494
+ <span class="spinner" id="spinner"></span>
495
+ Evaluate
496
+ </button>
497
+ </div>
498
+
499
+ <!-- Error -->
500
+ <div class="error-banner" id="errorBanner"></div>
501
+
502
+ <!-- Logs (above results, shown during evaluation) -->
503
+ <div class="collapsible" id="logsSection" style="display: none;">
504
+ <div class="collapsible-header" onclick="toggleCollapsible('logsSection')">
505
+ Evaluation Logs
506
+ <span class="arrow">&#9654;</span>
507
+ </div>
508
+ <div class="collapsible-body">
509
+ <div class="log-output" id="logOutput"></div>
510
+ </div>
511
+ </div>
512
+
513
+ <!-- Results -->
514
+ <div class="results-section" id="resultsSection">
515
+ <div class="metrics-grid">
516
+ <div class="metric-card highlight">
517
+ <div class="label">SPICE F1</div>
518
+ <div class="value" id="metricF1">-</div>
519
+ </div>
520
+ <div class="metric-card">
521
+ <div class="label">Precision</div>
522
+ <div class="value" id="metricPrec">-</div>
523
+ </div>
524
+ <div class="metric-card">
525
+ <div class="label">Recall</div>
526
+ <div class="value" id="metricRec">-</div>
527
+ </div>
528
+ </div>
529
+
530
+ <!-- Graphs -->
531
+ <div class="graphs-section">
532
+ <div class="graph-card">
533
+ <div class="graph-header">Candidate Scene Graph</div>
534
+ <div class="graph-container" id="candidateGraph"></div>
535
+ </div>
536
+ <div class="graph-card">
537
+ <div class="graph-header">Reference Scene Graph</div>
538
+ <div class="graph-container" id="referenceGraph"></div>
539
+ </div>
540
+ </div>
541
+
542
+ <!-- Tuples -->
543
+ <div class="collapsible" id="tuplesSection">
544
+ <div class="collapsible-header" onclick="toggleCollapsible('tuplesSection')">
545
+ Extracted Tuples
546
+ <span class="arrow">&#9654;</span>
547
+ </div>
548
+ <div class="collapsible-body">
549
+ <label style="font-size: 0.82rem; font-weight: 600; color: var(--text-dim); display: block; margin-bottom: 6px;">Candidate Tuples</label>
550
+ <div class="tuple-list" id="candidateTuples"></div>
551
+ <label style="font-size: 0.82rem; font-weight: 600; color: var(--text-dim); display: block; margin: 12px 0 6px;">Reference Tuples</label>
552
+ <div class="tuple-list" id="referenceTuples"></div>
553
+ </div>
554
+ </div>
555
+ </div>
556
+
557
+ <!-- About (always visible, collapsed) -->
558
+ <div class="collapsible open" id="aboutSection">
559
+ <div class="collapsible-header" onclick="toggleCollapsible('aboutSection')">
560
+ About This Project
561
+ <span class="arrow">&#9654;</span>
562
+ </div>
563
+ <div class="collapsible-body about-body">
564
+ <p>
565
+ <strong>SPICE Evaluator</strong> is an interactive tool for evaluating image captions using the
566
+ <a href="https://panderson.me/spice/" target="_blank">SPICE</a> (Semantic Propositional Image Caption Evaluation) metric.
567
+ It computes precision, recall, and F1 scores by comparing semantic scene graphs extracted from candidate and reference captions.
568
+ </p>
569
+ <p>
570
+ The evaluation pipeline invokes the official SPICE-1.0 Java implementation, which uses Stanford CoreNLP for dependency parsing
571
+ and scene graph extraction. Extracted tuples are matched using WordNet-based synonym matching.
572
+ </p>
573
+ <div class="about-links">
574
+ <a href="https://gayanukaa.com/projects/spice-evaluator" target="_blank">Project Page</a>
575
+ <a href="https://gayanukaa.com/blog/2025-05-29-understanding-spice-metric" target="_blank">Understanding SPICE - Blog Post</a>
576
+ <a href="https://github.com/Gayanukaa/SPICE-Evaluator" target="_blank">Source Code</a>
577
+ </div>
578
+ </div>
579
+ </div>
580
+
581
+ <footer>
582
+ Developed by <a href="https://gayanukaa.com" target="_blank">Gayanukaa</a>
583
+ &middot; Powered by <a href="https://panderson.me/spice/" target="_blank">SPICE</a>
584
+ </footer>
585
+ </div>
586
+
587
+ <script>
588
+ /* ── Example captions (MS COCO style) ──────────────── */
589
+ const EXAMPLES = [
590
+ {
591
+ name: "Giraffes in enclosure",
592
+ candidate: "a herd of giraffe standing next to each other",
593
+ references: [
594
+ "a couple of giraffes that are walking around",
595
+ "a herd of giraffe standing on top of a dirt field.",
596
+ "Several smaller giraffes that are in an enclosure.",
597
+ "The giraffes are walking in different directions outside.",
598
+ "A giraffe standing next to three baby giraffes in a zoo exhibit.",
599
+ ],
600
+ },
601
+ {
602
+ name: "Young girl on tennis court",
603
+ candidate: "a young girl standing on a tennis court holding a racket",
604
+ references: [
605
+ "A girl holding a tennis racket on a court.",
606
+ "A young woman in a white outfit plays tennis.",
607
+ "A girl standing on a tennis court with a racket in her hand.",
608
+ "A female tennis player preparing to serve the ball.",
609
+ "A young girl in white is playing a game of tennis.",
610
+ ],
611
+ },
612
+ {
613
+ name: "Dog catching frisbee",
614
+ candidate: "a brown dog catching a frisbee in a park",
615
+ references: [
616
+ "A dog leaps to catch a frisbee in a field.",
617
+ "A brown and white dog jumping in the air to catch a disc.",
618
+ "A dog is playing frisbee in a grassy park.",
619
+ "An active dog catches a flying disc outdoors.",
620
+ "A playful dog jumps to catch a frisbee on the grass.",
621
+ ],
622
+ },
623
+ {
624
+ name: "City bus at stop",
625
+ candidate: "a red double decker bus parked on the side of the road",
626
+ references: [
627
+ "A red bus is stopped at a bus stop on a city street.",
628
+ "A double decker bus pulling up to a stop.",
629
+ "A large red bus sitting on the side of a road.",
630
+ "A red double decker bus driving down a street.",
631
+ "A double decker red bus parked near a sidewalk.",
632
+ ],
633
+ },
634
+ {
635
+ name: "Pizza on table",
636
+ candidate: "a pizza sitting on top of a wooden table",
637
+ references: [
638
+ "A large cheese pizza on a table with a glass of beer.",
639
+ "A pizza with various toppings is on a plate.",
640
+ "A freshly made pizza sitting on a wooden board.",
641
+ "There is a pizza on the table ready to be eaten.",
642
+ "A delicious looking pizza placed on a wooden surface.",
643
+ ],
644
+ },
645
+ {
646
+ name: "Cat on laptop",
647
+ candidate: "a cat sitting on a laptop computer",
648
+ references: [
649
+ "A cat is lying on top of a laptop keyboard.",
650
+ "A grey cat sitting on an open laptop on a desk.",
651
+ "A cat resting on a computer keyboard.",
652
+ "There is a cat sitting on the laptop.",
653
+ "A furry cat has decided to sit on the laptop computer.",
654
+ ],
655
+ },
656
+ {
657
+ name: "Kitchen scene",
658
+ candidate: "a kitchen with a stove and a refrigerator",
659
+ references: [
660
+ "A small kitchen with white appliances and wooden cabinets.",
661
+ "A kitchen area with a stove, oven and refrigerator.",
662
+ "The kitchen has a stove, fridge and microwave.",
663
+ "A clean kitchen with modern appliances.",
664
+ "A kitchen with a white refrigerator and a stove top oven.",
665
+ ],
666
+ },
667
+ {
668
+ name: "Surfer on wave",
669
+ candidate: "a man riding a wave on a surfboard",
670
+ references: [
671
+ "A surfer is riding a large wave in the ocean.",
672
+ "A man on a surfboard riding a big wave.",
673
+ "A person surfing on a wave near the shore.",
674
+ "A surfer in a wetsuit rides a cresting wave.",
675
+ "A man is surfing on a wave in the sea.",
676
+ ],
677
+ },
678
+ ];
679
+
680
+ /* ── Populate examples dropdown ────────────────────── */
681
+ const exSelect = document.getElementById("exampleSelect");
682
+ EXAMPLES.forEach((ex, i) => {
683
+ const opt = document.createElement("option");
684
+ opt.value = i;
685
+ opt.textContent = ex.name;
686
+ exSelect.appendChild(opt);
687
+ });
688
+
689
+ function loadExample() {
690
+ const idx = exSelect.value;
691
+ if (idx === "") return;
692
+ const ex = EXAMPLES[idx];
693
+ document.getElementById("candidate").value = ex.candidate;
694
+ document.getElementById("references").value = ex.references.join("\n");
695
+ }
696
+
697
+ /* ── Health check ──────────────────────────────────── */
698
+ const badge = document.getElementById("healthBadge");
699
+
700
+ async function checkHealth() {
701
+ try {
702
+ const res = await fetch("/api/health");
703
+ const data = await res.json();
704
+ if (data.status === "ready") {
705
+ badge.textContent = "Ready";
706
+ badge.className = "health-badge ready";
707
+ return true;
708
+ }
709
+ } catch (_) {}
710
+ badge.textContent = "Loading...";
711
+ badge.className = "health-badge loading";
712
+ return false;
713
+ }
714
+
715
+ (async function pollHealth() {
716
+ if (await checkHealth()) return;
717
+ const id = setInterval(async () => {
718
+ if (await checkHealth()) clearInterval(id);
719
+ }, 2500);
720
+ })();
721
+
722
+ /* ── Collapsible toggle ────────────────────────────── */
723
+ function toggleCollapsible(id) {
724
+ document.getElementById(id).classList.toggle("open");
725
+ }
726
+
727
+ /* ── Graph rendering ───────────────────────────────── */
728
+ function renderGraph(containerId, tuples, nodeColor, edgeColor) {
729
+ const container = document.getElementById(containerId);
730
+ container.innerHTML = "";
731
+
732
+ if (!tuples || tuples.length === 0) {
733
+ container.innerHTML = '<div class="graph-empty">No tuples to visualize</div>';
734
+ return;
735
+ }
736
+
737
+ const nodesMap = new Map();
738
+ const edgesArr = [];
739
+ let nodeId = 0;
740
+
741
+ function getNodeId(label) {
742
+ if (!nodesMap.has(label)) {
743
+ nodesMap.set(label, nodeId++);
744
+ }
745
+ return nodesMap.get(label);
746
+ }
747
+
748
+ tuples.forEach((tpl) => {
749
+ if (tpl.length === 1) {
750
+ getNodeId(tpl[0]);
751
+ } else {
752
+ for (let i = 0; i < tpl.length - 1; i++) {
753
+ const fromId = getNodeId(tpl[i]);
754
+ const toId = getNodeId(tpl[i + 1]);
755
+ edgesArr.push({ from: fromId, to: toId, color: { color: edgeColor }, arrows: "to" });
756
+ }
757
+ }
758
+ });
759
+
760
+ const nodes = new vis.DataSet(
761
+ Array.from(nodesMap.entries()).map(([label, id]) => ({
762
+ id,
763
+ label,
764
+ color: { border: "#333", background: nodeColor },
765
+ font: { size: 13, color: "#2c3e50" },
766
+ size: 18,
767
+ borderWidth: 2,
768
+ }))
769
+ );
770
+
771
+ const edges = new vis.DataSet(edgesArr);
772
+
773
+ new vis.Network(
774
+ container,
775
+ { nodes, edges },
776
+ {
777
+ physics: {
778
+ barnesHut: {
779
+ gravitationalConstant: -8000,
780
+ centralGravity: 0.5,
781
+ springLength: 80,
782
+ springConstant: 0.1,
783
+ damping: 0.4,
784
+ avoidOverlap: 0.2,
785
+ },
786
+ },
787
+ edges: {
788
+ smooth: { type: "curvedCCW", roundness: 0.15 },
789
+ width: 2,
790
+ },
791
+ interaction: {
792
+ hover: true,
793
+ zoomView: true,
794
+ dragView: true,
795
+ },
796
+ }
797
+ );
798
+ }
799
+
800
+ /* ── Format tuples for display ─────────────────────── */
801
+ function formatTuples(tuples) {
802
+ if (!tuples || tuples.length === 0) return "(none)";
803
+ return tuples.map((t) => "(" + t.join(", ") + ")").join("\n");
804
+ }
805
+
806
+ /* ── Evaluation (SSE streaming) ────────────────────── */
807
+ async function runEvaluation() {
808
+ const btn = document.getElementById("evalBtn");
809
+ const spinner = document.getElementById("spinner");
810
+ const errorBanner = document.getElementById("errorBanner");
811
+ const resultsSection = document.getElementById("resultsSection");
812
+ const logsSection = document.getElementById("logsSection");
813
+ const logOutput = document.getElementById("logOutput");
814
+
815
+ const candidate = document.getElementById("candidate").value.trim();
816
+ const refsRaw = document.getElementById("references").value.trim();
817
+ const references = refsRaw
818
+ .split("\n")
819
+ .map((l) => l.trim())
820
+ .filter((l) => l.length > 0);
821
+
822
+ if (!candidate) {
823
+ showError("Please enter a candidate caption.");
824
+ return;
825
+ }
826
+ if (references.length === 0) {
827
+ showError("Please enter at least one reference caption.");
828
+ return;
829
+ }
830
+
831
+ // Reset UI
832
+ errorBanner.classList.remove("visible");
833
+ resultsSection.classList.remove("visible");
834
+ btn.disabled = true;
835
+ spinner.style.display = "inline-block";
836
+
837
+ // Show and expand logs
838
+ logOutput.textContent = "";
839
+ logsSection.style.display = "";
840
+ logsSection.classList.add("open");
841
+
842
+ try {
843
+ const res = await fetch("/api/evaluate", {
844
+ method: "POST",
845
+ headers: { "Content-Type": "application/json" },
846
+ body: JSON.stringify({ candidate, references }),
847
+ });
848
+
849
+ if (!res.ok) {
850
+ const err = await res.json().catch(() => ({}));
851
+ throw new Error(err.detail || `Server error ${res.status}`);
852
+ }
853
+
854
+ // Parse SSE stream
855
+ const reader = res.body.getReader();
856
+ const decoder = new TextDecoder();
857
+ let buffer = "";
858
+ let resultData = null;
859
+
860
+ while (true) {
861
+ const { done, value } = await reader.read();
862
+ if (done) break;
863
+
864
+ buffer += decoder.decode(value, { stream: true });
865
+ const lines = buffer.split("\n");
866
+ buffer = lines.pop(); // keep incomplete line in buffer
867
+
868
+ let eventType = null;
869
+ for (const line of lines) {
870
+ if (line.startsWith("event: ")) {
871
+ eventType = line.slice(7).trim();
872
+ } else if (line.startsWith("data: ")) {
873
+ const data = line.slice(6);
874
+ if (eventType === "log") {
875
+ logOutput.textContent += (logOutput.textContent ? "\n" : "") + data;
876
+ logOutput.scrollTop = logOutput.scrollHeight;
877
+ } else if (eventType === "result") {
878
+ resultData = JSON.parse(data);
879
+ } else if (eventType === "error") {
880
+ throw new Error(data);
881
+ }
882
+ eventType = null;
883
+ }
884
+ }
885
+ }
886
+
887
+ if (!resultData) {
888
+ throw new Error("No result received from server");
889
+ }
890
+
891
+ // Collapse logs and about, show results
892
+ logsSection.classList.remove("open");
893
+ document.getElementById("aboutSection").classList.remove("open");
894
+
895
+ document.getElementById("metricF1").textContent = resultData.spice_f1.toFixed(3);
896
+ document.getElementById("metricPrec").textContent = resultData.spice_precision.toFixed(3);
897
+ document.getElementById("metricRec").textContent = resultData.spice_recall.toFixed(3);
898
+
899
+ renderGraph("candidateGraph", resultData.test_tuples, "#a8d5f2", "#4a6fa5");
900
+ renderGraph("referenceGraph", resultData.ref_tuples, "#a8f0c6", "#27ae60");
901
+
902
+ document.getElementById("candidateTuples").textContent = formatTuples(resultData.test_tuples);
903
+ document.getElementById("referenceTuples").textContent = formatTuples(resultData.ref_tuples);
904
+
905
+ resultsSection.classList.add("visible");
906
+ } catch (e) {
907
+ showError(e.message);
908
+ } finally {
909
+ btn.disabled = false;
910
+ spinner.style.display = "none";
911
+ }
912
+ }
913
+
914
+ function showError(msg) {
915
+ const banner = document.getElementById("errorBanner");
916
+ banner.textContent = msg;
917
+ banner.classList.add("visible");
918
+ }
919
+ </script>
920
+ </body>
921
+ </html>