feng-x commited on
Commit
1453a7c
·
verified ·
1 Parent(s): 53fb68e

Upload folder using huggingface_hub

Browse files
AGENTS.md CHANGED
@@ -212,8 +212,8 @@ For optimal results:
212
  ### Failure Modes (values of `fail_reason`)
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
- - `card_not_parallel` — card detected but `scale_confidence ≤ 0.9` (too much perspective)
216
- - `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.25` (camera held too far from the table; see `doc/report/framing_ratio_survey.md`)
217
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
218
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
219
  - `zone_localization_failed` — ring zone could not be derived
@@ -252,5 +252,5 @@ The result PNG is written alongside every JSON output. With `--debug`, the same
252
 
253
  - Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
254
  - Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
255
- - Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.9` is required (hard fail `card_not_parallel` otherwise).
256
  - Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
 
212
  ### Failure Modes (values of `fail_reason`)
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
+ - `card_not_parallel` — card detected but `scale_confidence ≤ 0.95` (too much perspective)
216
+ - `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.30` (camera held too far from the table; see `doc/report/framing_ratio_survey.md`)
217
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
218
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
219
  - `zone_localization_failed` — ring zone could not be derived
 
252
 
253
  - Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
254
  - Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
255
+ - Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.95` is required (hard fail `card_not_parallel` otherwise).
256
  - Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
CLAUDE.md CHANGED
@@ -212,8 +212,8 @@ For optimal results:
212
  ### Failure Modes (values of `fail_reason`)
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
- - `card_not_parallel` — card detected but `scale_confidence ≤ 0.9` (too much perspective)
216
- - `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.25` (camera held too far from the table; see `doc/report/framing_ratio_survey.md`)
217
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
218
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
219
  - `zone_localization_failed` — ring zone could not be derived
@@ -252,5 +252,5 @@ The result PNG is written alongside every JSON output. With `--debug`, the same
252
 
253
  - Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
254
  - Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
255
- - Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.9` is required (hard fail `card_not_parallel` otherwise).
256
  - Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
 
212
  ### Failure Modes (values of `fail_reason`)
213
  - `hand_not_detected` — MediaPipe did not locate a hand
214
  - `card_not_detected` — classical or SAM card detector returned nothing
215
+ - `card_not_parallel` — card detected but `scale_confidence ≤ 0.95` (too much perspective)
216
+ - `card_too_small` — card detected but `longer_side_px / shorter_image_px < 0.30` (camera held too far from the table; see `doc/report/framing_ratio_survey.md`)
217
  - `finger_isolation_failed`, `finger_mask_too_small`, `contour_extraction_failed` — finger segmentation stages
218
  - `axis_estimation_failed` — landmarks missing or failed quality checks (NaN, collapsed, non-monotonic, below min length)
219
  - `zone_localization_failed` — ring zone could not be derived
 
252
 
253
  - Functions raise on malformed inputs; `measure_finger()` maps exceptions to structured `fail_reason` values in the output dict.
254
  - Realistic width range: 1.0–3.0 cm (typical 1.4–2.4 cm). Out-of-range widths log a warning but do not fail.
255
+ - Credit card aspect ratio tolerance: ±15% of 1.586. `scale_confidence > 0.95` is required (hard fail `card_not_parallel` otherwise).
256
  - Coordinate convention: OpenCV is `(row, col) = (y, x)`; most `src/geometry.py` helpers use `(x, y)`. Contours are `Nx2` in `(x, y)` format.
measure_finger.py CHANGED
@@ -65,9 +65,9 @@ def apply_calibration(raw_diameter_cm: float) -> float:
65
  # the camera is too far from the table: the card and finger occupy so few
66
  # pixels that edge refinement noise and calibration extrapolation both
67
  # start to dominate. See doc/report/framing_ratio_survey.md — calibration
68
- # was fit at ~0.48, production median is ~0.33; 0.25 flags the egregious
69
- # tail (~4% of historical uploads).
70
- MIN_CARD_LONG_SIDE_RATIO = 0.25
71
 
72
  # Type alias for finger selection
73
  FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
@@ -663,11 +663,11 @@ def measure_finger(
663
  )
664
  logger.info("scale: %.2f px/cm (confidence=%.2f)", px_per_cm, scale_confidence)
665
 
666
- view_angle_ok = scale_confidence > 0.9
667
  card_detected = True
668
 
669
  if not view_angle_ok:
670
- logger.warning("card not parallel to camera (scale_confidence=%.2f, required>0.9)",
671
  scale_confidence)
672
  _write_card_failure_viz(
673
  result_png_path, image_canonical, hand_data, card_result=card_result
@@ -1323,7 +1323,7 @@ def measure_multi_finger(
1323
  _write_card_failure_viz(result_png_path, image_canonical, hand_data)
1324
  return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1325
  px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
1326
- view_angle_ok = scale_confidence > 0.9
1327
  card_detected = True
1328
 
1329
  if not view_angle_ok:
 
65
  # the camera is too far from the table: the card and finger occupy so few
66
  # pixels that edge refinement noise and calibration extrapolation both
67
  # start to dominate. See doc/report/framing_ratio_survey.md — calibration
68
+ # was fit at ~0.48, production median is ~0.33; 0.30 flags the left tail
69
+ # (~25% of historical uploads cluster below this in kol_success).
70
+ MIN_CARD_LONG_SIDE_RATIO = 0.30
71
 
72
  # Type alias for finger selection
73
  FingerIndex = Literal["auto", "index", "middle", "ring", "pinky"]
 
663
  )
664
  logger.info("scale: %.2f px/cm (confidence=%.2f)", px_per_cm, scale_confidence)
665
 
666
+ view_angle_ok = scale_confidence > 0.95
667
  card_detected = True
668
 
669
  if not view_angle_ok:
670
+ logger.warning("card not parallel to camera (scale_confidence=%.2f, required>0.95)",
671
  scale_confidence)
672
  _write_card_failure_viz(
673
  result_png_path, image_canonical, hand_data, card_result=card_result
 
1323
  _write_card_failure_viz(result_png_path, image_canonical, hand_data)
1324
  return {"fail_reason": "card_not_detected", "per_finger": {}, "fingers_measured": 0, "fingers_succeeded": 0}
1325
  px_per_cm, scale_confidence = compute_scale_factor(card_result["corners"])
1326
+ view_angle_ok = scale_confidence > 0.95
1327
  card_detected = True
1328
 
1329
  if not view_angle_ok:
script/card_aspect_and_scale_survey.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Measure detected card aspect ratio and scale_confidence across an image dir.
3
+
4
+ Two metrics are captured per image (both come from the SAM-detected card's
5
+ 4 corner points after order_corners):
6
+
7
+ aspect_ratio = max(width_px, height_px) / min(width_px, height_px)
8
+ Compared to the true credit-card aspect 1.586.
9
+ scale_confidence = 1 - |px_per_cm_w - px_per_cm_h| / max(...)
10
+ Consistency between the two independent scale estimates
11
+ you get from the W and H sides; falls below 1.0 whenever
12
+ the detected aspect deviates from 1.586 (perspective tilt,
13
+ SAM mask error on one edge, non-standard card).
14
+
15
+ Runs the same SAM hand + SAM card pipeline as the web demo. Prints per-image
16
+ values and distribution summaries (n, min, p05/10/25/50/75/90/95, max,
17
+ mean, std, coarse histograms, success/failure counts).
18
+
19
+ Usage:
20
+ source .venv/bin/activate
21
+ python3 script/card_aspect_and_scale_survey.py input/kol_success
22
+ """
23
+ from __future__ import annotations
24
+
25
+ import argparse
26
+ import logging
27
+ import statistics as stats
28
+ import sys
29
+ import time
30
+ from pathlib import Path
31
+ from typing import List, Optional, Tuple
32
+
33
+ import cv2
34
+ import numpy as np
35
+
36
+ ROOT = Path(__file__).resolve().parents[1]
37
+ sys.path.insert(0, str(ROOT))
38
+
39
+ from src.card_detection import compute_scale_factor # noqa: E402
40
+ from src.finger_segmentation import segment_hand # noqa: E402
41
+ from src.sam_card_detection import ( # noqa: E402
42
+ detect_credit_card_sam_prompt,
43
+ suggest_card_seeds,
44
+ )
45
+
46
+ IMG_EXTS = {".jpg", ".jpeg", ".png"}
47
+ TRUE_ASPECT = 1.586 # ISO/IEC 7810 ID-1: 85.60 / 53.98
48
+
49
+
50
+ def _process_image(path: Path) -> Tuple[Optional[float], Optional[float], str]:
51
+ """Return (aspect_ratio, scale_confidence, status) for a single image."""
52
+ img = cv2.imread(str(path))
53
+ if img is None:
54
+ return None, None, "load_failed"
55
+
56
+ try:
57
+ hand_data = segment_hand(img)
58
+ except Exception as e:
59
+ return None, None, f"hand_error:{type(e).__name__}"
60
+ if hand_data is None:
61
+ return None, None, "hand_not_detected"
62
+
63
+ canonical = hand_data.get("canonical_image", img)
64
+ hand_mask = hand_data.get("mask")
65
+ landmarks = hand_data.get("landmarks")
66
+ if hand_mask is None or landmarks is None or len(landmarks) <= 9:
67
+ return None, None, "no_landmarks"
68
+
69
+ y_limit = int(round(landmarks[9, 1]))
70
+ seed_info = suggest_card_seeds(hand_mask, canonical.shape[:2], y_limit)
71
+ seeds = seed_info["kept"]
72
+ if not seeds:
73
+ return None, None, "no_seeds"
74
+
75
+ palm_c = np.mean(landmarks[[0, 5, 9, 13, 17], :2], axis=0)
76
+ negatives = [(int(round(palm_c[0])), int(round(palm_c[1])))]
77
+
78
+ try:
79
+ card = detect_credit_card_sam_prompt(
80
+ canonical,
81
+ seed_points=seeds,
82
+ negative_points=negatives,
83
+ hand_mask=hand_mask,
84
+ )
85
+ except Exception as e:
86
+ return None, None, f"card_error:{type(e).__name__}"
87
+ if card is None:
88
+ return None, None, "card_not_detected"
89
+
90
+ aspect = float(card["aspect_ratio"])
91
+ _, scale_conf = compute_scale_factor(card["corners"])
92
+ return aspect, float(scale_conf), "ok"
93
+
94
+
95
+ def _percentiles(values_sorted: List[float], p: float) -> float:
96
+ n = len(values_sorted)
97
+ if n == 1:
98
+ return values_sorted[0]
99
+ k = (n - 1) * p
100
+ lo = int(k)
101
+ hi = min(lo + 1, n - 1)
102
+ frac = k - lo
103
+ return values_sorted[lo] * (1 - frac) + values_sorted[hi] * frac
104
+
105
+
106
+ def _describe(values: List[float], label: str, *, fmt: str = "{:.3f}",
107
+ hist_lo: float = 0.0, hist_hi: float = 1.0,
108
+ hist_bin: float = 0.05) -> None:
109
+ if not values:
110
+ print(f"\n{label}: no successful measurements.")
111
+ return
112
+ vs = sorted(values)
113
+ n = len(vs)
114
+ print(f"\n=== {label} (n={n}) ===")
115
+ for tag, p in [("min", 0.0), ("p05", 0.05), ("p10", 0.10), ("p25", 0.25),
116
+ ("median", 0.50), ("p75", 0.75), ("p90", 0.90), ("p95", 0.95),
117
+ ("max", 1.0)]:
118
+ v = vs[0] if p == 0 else vs[-1] if p == 1 else _percentiles(vs, p)
119
+ print(f" {tag:6s} = " + fmt.format(v))
120
+ print(f" mean = " + fmt.format(stats.mean(vs)))
121
+ if n > 1:
122
+ print(f" std = " + fmt.format(stats.stdev(vs)))
123
+
124
+ # Coarse histogram
125
+ edges = []
126
+ e = hist_lo
127
+ while e <= hist_hi + 1e-9:
128
+ edges.append(round(e, 4))
129
+ e += hist_bin
130
+ counts = [0] * (len(edges) - 1)
131
+ under = over = 0
132
+ for v in vs:
133
+ if v < edges[0]:
134
+ under += 1
135
+ continue
136
+ placed = False
137
+ for i in range(len(edges) - 1):
138
+ if edges[i] <= v < edges[i + 1]:
139
+ counts[i] += 1
140
+ placed = True
141
+ break
142
+ if not placed:
143
+ over += 1
144
+ print(" histogram:")
145
+ if under:
146
+ print(f" <{fmt.format(edges[0])} : {under}")
147
+ for i, c in enumerate(counts):
148
+ if c == 0:
149
+ continue
150
+ bar = "#" * c
151
+ print(f" [{fmt.format(edges[i])},{fmt.format(edges[i+1])}) : {c:2d} {bar}")
152
+ if over:
153
+ print(f" >={fmt.format(edges[-1])} : {over}")
154
+
155
+
156
+ def main() -> int:
157
+ ap = argparse.ArgumentParser()
158
+ ap.add_argument("image_dir", type=Path)
159
+ ap.add_argument("--limit", type=int, default=None,
160
+ help="optional cap on number of images processed")
161
+ args = ap.parse_args()
162
+
163
+ logging.getLogger().setLevel(logging.ERROR)
164
+
165
+ if not args.image_dir.is_dir():
166
+ print(f"Not a directory: {args.image_dir}")
167
+ return 1
168
+
169
+ images = sorted(
170
+ p for p in args.image_dir.iterdir() if p.suffix.lower() in IMG_EXTS
171
+ )
172
+ if args.limit:
173
+ images = images[: args.limit]
174
+ if not images:
175
+ print(f"No images found in {args.image_dir}")
176
+ return 1
177
+
178
+ print(f"Processing {len(images)} images from {args.image_dir}")
179
+ print(f"{'#':>3} {'file':<60} {'aspect':>7} {'sc':>5} status")
180
+
181
+ aspects: List[float] = []
182
+ confs: List[float] = []
183
+ deltas: List[float] = [] # |aspect - 1.586| / 1.586
184
+ status_counts = {}
185
+ t_start = time.time()
186
+ for i, path in enumerate(images, 1):
187
+ t0 = time.time()
188
+ aspect, sc, status = _process_image(path)
189
+ dt = time.time() - t0
190
+ status_counts[status] = status_counts.get(status, 0) + 1
191
+ a_str = f"{aspect:.3f}" if aspect is not None else " - "
192
+ c_str = f"{sc:.3f}" if sc is not None else " - "
193
+ print(f"{i:>3} {path.name:<60} {a_str:>7} {c_str:>5} {status} ({dt:.1f}s)")
194
+ if aspect is not None:
195
+ aspects.append(aspect)
196
+ deltas.append(abs(aspect - TRUE_ASPECT) / TRUE_ASPECT)
197
+ if sc is not None:
198
+ confs.append(sc)
199
+
200
+ total_dt = time.time() - t_start
201
+ print(f"\nElapsed: {total_dt:.1f}s ({total_dt / max(1, len(images)):.1f}s/img)")
202
+ print(f"Status summary: {status_counts}")
203
+
204
+ _describe(aspects, label="aspect_ratio (true = 1.586)",
205
+ hist_lo=1.30, hist_hi=1.85, hist_bin=0.05)
206
+ _describe(deltas, label="|aspect - 1.586| / 1.586 (current gate ≤ 0.150)",
207
+ fmt="{:.4f}", hist_lo=0.0, hist_hi=0.20, hist_bin=0.02)
208
+ _describe(confs, label="scale_confidence (current gate > 0.90)",
209
+ hist_lo=0.80, hist_hi=1.00, hist_bin=0.02)
210
+ return 0
211
+
212
+
213
+ if __name__ == "__main__":
214
+ sys.exit(main())
src/sam_card_detection.py CHANGED
@@ -34,6 +34,13 @@ logger = logging.getLogger(__name__)
34
  MIN_RECTANGULARITY = 0.90 # mask_area / minAreaRect_area; card mask is near-perfect rectangle
35
  ASPECT_RATIO_TOLERANCE = 0.15 # fractional deviation from 1.586
36
  MAX_HAND_OVERLAP_RATIO = 0.20 # reject candidates that swallow the hand (background paper, tabletop)
 
 
 
 
 
 
 
37
  # SAM-specific upper bound on card area. Tighter than the shared
38
  # MAX_CARD_AREA_RATIO (0.5) because SAM happily returns whole-background
39
  # segments (ceilings, walls) as a single rectangular-ish mask when no card
@@ -91,6 +98,8 @@ def _score_card_mask(
91
  overlap = float(np.logical_and(hull_bool, hand_bool).sum())
92
  if overlap / hand_area > MAX_HAND_OVERLAP_RATIO:
93
  return None
 
 
94
 
95
  rect = cv2.minAreaRect(contour)
96
  box = cv2.boxPoints(rect)
 
34
  MIN_RECTANGULARITY = 0.90 # mask_area / minAreaRect_area; card mask is near-perfect rectangle
35
  ASPECT_RATIO_TOLERANCE = 0.15 # fractional deviation from 1.586
36
  MAX_HAND_OVERLAP_RATIO = 0.20 # reject candidates that swallow the hand (background paper, tabletop)
37
+ # Reject candidates whose convex hull is "fattened" by hand-shaped indentations.
38
+ # A real card mask is convex, so hull == mask and (hull \ mask) ∩ hand is ~0.
39
+ # When SAM segments a chunk of background (e.g. paper towel) bordered by the
40
+ # hand, the mask has a hand-shaped notch on one side; the hull closes that
41
+ # notch and adds hand pixels. Empirically: real-card winners measure 0.000,
42
+ # paper-towel false positives measure ~0.10+.
43
+ MAX_HULL_HAND_FILL_RATIO = 0.05
44
  # SAM-specific upper bound on card area. Tighter than the shared
45
  # MAX_CARD_AREA_RATIO (0.5) because SAM happily returns whole-background
46
  # segments (ceilings, walls) as a single rectangular-ish mask when no card
 
98
  overlap = float(np.logical_and(hull_bool, hand_bool).sum())
99
  if overlap / hand_area > MAX_HAND_OVERLAP_RATIO:
100
  return None
101
+ if mask_area > 0 and overlap / mask_area > MAX_HULL_HAND_FILL_RATIO:
102
+ return None
103
 
104
  rect = cv2.minAreaRect(contour)
105
  box = cv2.boxPoints(rect)
web_demo/templates/admin.html CHANGED
@@ -111,6 +111,7 @@
111
  <tr>
112
  <th>KOL</th>
113
  <th>Date</th>
 
114
  <th>Photo</th>
115
  <th>Index</th>
116
  <th>Middle</th>
@@ -121,7 +122,7 @@
121
  </tr>
122
  </thead>
123
  <tbody id="tableBody">
124
- <tr><td colspan="9" class="empty">Loading...</td></tr>
125
  </tbody>
126
  </table>
127
  </div>
@@ -199,6 +200,7 @@
199
  return `<tr data-id="${r.id}">
200
  <td><strong>${r.kol_name || "-"}</strong></td>
201
  <td>${fmtDate(r.created_at)}</td>
 
202
  <td>${photoThumb}</td>
203
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
204
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
@@ -216,7 +218,7 @@
216
  if (currentPage < 1) currentPage = 1;
217
  if (total === 0) {
218
  countLabel.textContent = "0 records";
219
- tbody.innerHTML = '<tr><td colspan="9" class="empty">No measurements yet</td></tr>';
220
  } else {
221
  const start = (currentPage - 1) * PAGE_SIZE;
222
  const slice = allRows.slice(start, start + PAGE_SIZE);
@@ -241,7 +243,7 @@
241
  currentPage = 1;
242
  renderPage();
243
  } catch (e) {
244
- tbody.innerHTML = `<tr><td colspan="9" class="empty">Error loading data: ${e.message}</td></tr>`;
245
  }
246
  };
247
 
 
111
  <tr>
112
  <th>KOL</th>
113
  <th>Date</th>
114
+ <th>Model</th>
115
  <th>Photo</th>
116
  <th>Index</th>
117
  <th>Middle</th>
 
122
  </tr>
123
  </thead>
124
  <tbody id="tableBody">
125
+ <tr><td colspan="10" class="empty">Loading...</td></tr>
126
  </tbody>
127
  </table>
128
  </div>
 
200
  return `<tr data-id="${r.id}">
201
  <td><strong>${r.kol_name || "-"}</strong></td>
202
  <td>${fmtDate(r.created_at)}</td>
203
+ <td>${r.ring_model || "-"}</td>
204
  <td>${photoThumb}</td>
205
  <td class="finger-cell">${fmtFinger(pf, "index")}</td>
206
  <td class="finger-cell">${fmtFinger(pf, "middle")}</td>
 
218
  if (currentPage < 1) currentPage = 1;
219
  if (total === 0) {
220
  countLabel.textContent = "0 records";
221
+ tbody.innerHTML = '<tr><td colspan="10" class="empty">No measurements yet</td></tr>';
222
  } else {
223
  const start = (currentPage - 1) * PAGE_SIZE;
224
  const slice = allRows.slice(start, start + PAGE_SIZE);
 
243
  currentPage = 1;
244
  renderPage();
245
  } catch (e) {
246
+ tbody.innerHTML = `<tr><td colspan="10" class="empty">Error loading data: ${e.message}</td></tr>`;
247
  }
248
  };
249