someone-in-the-world Claude Sonnet 4.6 commited on
Commit
c4cb115
Β·
1 Parent(s): 19af080

Add inference logging to HuggingFace dataset

Browse files

Logs each swap (target, sources, result, indices, duration, success/error)
as a Parquet row to a private HF dataset via a daemon thread so it never
blocks the response. Prunes files older than LOG_MAX_DAYS and squashes repo
history once per day. Skips silently when HF_TOKEN / LOG_DATASET_REPO are
unset.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

app.py CHANGED
@@ -3,6 +3,7 @@ import spaces # must be first β€” ZeroGPU requires this before any CUDA package
3
  import os
4
  import gc
5
  import time
 
6
  import traceback
7
  import json
8
  import base64
@@ -16,6 +17,7 @@ from insightface.app import FaceAnalysis
16
  from io import BytesIO
17
  from PIL import Image
18
  from huggingface_hub import hf_hub_download
 
19
 
20
  # basicsr (gfpgan dep) imports this submodule which was removed in torchvision>=0.17
21
  import sys
@@ -214,6 +216,28 @@ def _enhance(cv2_img, enhancer):
214
  return enhanced
215
 
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # ── Public Gradio functions ────────────────────────────────────────────────────
218
 
219
  def detect_faces(target_b64):
@@ -279,12 +303,17 @@ def swap_faces(target_b64, selected_json, sources_json):
279
  duration = time.perf_counter() - t0
280
  print(f"[swap] done in {duration:.2f}s", flush=True)
281
 
 
 
282
  yield result_pil, ""
283
 
284
  except gr.Error:
285
  raise
286
  except Exception as e:
287
  print(f"[swap] ERROR: {e}\n{traceback.format_exc()}", flush=True)
 
 
 
288
  raise
289
  finally:
290
  gc.collect()
 
3
  import os
4
  import gc
5
  import time
6
+ import threading
7
  import traceback
8
  import json
9
  import base64
 
17
  from io import BytesIO
18
  from PIL import Image
19
  from huggingface_hub import hf_hub_download
20
+ from logging_utils import log_inference
21
 
22
  # basicsr (gfpgan dep) imports this submodule which was removed in torchvision>=0.17
23
  import sys
 
216
  return enhanced
217
 
218
 
219
+ # ── Logging helpers ────────────────────────────────────────────────────────────
220
+
221
+ def _collect_source_pils(selected_indices, sources):
222
+ pils = []
223
+ for idx in selected_indices:
224
+ src = sources.get(str(idx))
225
+ if src:
226
+ try:
227
+ pils.append(_b64_to_pil(src))
228
+ except Exception:
229
+ pass
230
+ return pils
231
+
232
+
233
+ def _spawn_log(target_pil, source_pils, result_pil, selected_indices, duration, success, error=""):
234
+ threading.Thread(
235
+ target=log_inference,
236
+ args=(target_pil, source_pils, result_pil, selected_indices, duration, success, error),
237
+ daemon=True,
238
+ ).start()
239
+
240
+
241
  # ── Public Gradio functions ────────────────────────────────────────────────────
242
 
243
  def detect_faces(target_b64):
 
303
  duration = time.perf_counter() - t0
304
  print(f"[swap] done in {duration:.2f}s", flush=True)
305
 
306
+ source_pils = _collect_source_pils(selected_indices, sources)
307
+ _spawn_log(target_pil, source_pils, result_pil, selected_indices, duration, True)
308
  yield result_pil, ""
309
 
310
  except gr.Error:
311
  raise
312
  except Exception as e:
313
  print(f"[swap] ERROR: {e}\n{traceback.format_exc()}", flush=True)
314
+ source_pils = _collect_source_pils(selected_indices, sources)
315
+ _spawn_log(target_pil, source_pils, None, selected_indices,
316
+ time.perf_counter() - t0, False, str(e))
317
  raise
318
  finally:
319
  gc.collect()
logging_utils.py ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ from io import BytesIO
4
+ from datetime import datetime, timezone, timedelta
5
+ from huggingface_hub import hf_hub_download
6
+
7
+ HF_TOKEN = os.environ.get("HF_TOKEN")
8
+ DATASET_REPO = os.environ.get("LOG_DATASET_REPO")
9
+ MAX_LOG_DAYS = int(os.environ.get("LOG_MAX_DAYS", "7"))
10
+
11
+
12
+ def _img_to_jpeg(img, quality=85):
13
+ if img is None:
14
+ return None
15
+ buf = BytesIO()
16
+ img.convert("RGB").save(buf, format="JPEG", quality=quality)
17
+ return buf.getvalue()
18
+
19
+
20
+ def _build_table(target_pil, source_pils, output_pil, selected_indices,
21
+ duration_seconds, success, error_message, now):
22
+ import json as _json
23
+ import pyarrow as pa
24
+
25
+ img_struct = pa.struct([("bytes", pa.binary()), ("path", pa.string())])
26
+ hf_meta = _json.dumps({"info": {"features": {
27
+ "timestamp": {"dtype": "float64", "_type": "Value"},
28
+ "target_image": {"_type": "Image"},
29
+ "source_images": {"feature": {"_type": "Image"}, "_type": "Sequence"},
30
+ "output_image": {"_type": "Image"},
31
+ "selected_indices": {"feature": {"dtype": "int32", "_type": "Value"}, "_type": "Sequence"},
32
+ "n_faces_swapped": {"dtype": "int32", "_type": "Value"},
33
+ "duration_seconds": {"dtype": "float32", "_type": "Value"},
34
+ "success": {"dtype": "bool", "_type": "Value"},
35
+ "error_message": {"dtype": "string", "_type": "Value"},
36
+ }}}).encode()
37
+ schema = pa.schema([
38
+ ("timestamp", pa.float64()),
39
+ ("target_image", img_struct),
40
+ ("source_images", pa.list_(img_struct)),
41
+ ("output_image", img_struct),
42
+ ("selected_indices", pa.list_(pa.int32())),
43
+ ("n_faces_swapped", pa.int32()),
44
+ ("duration_seconds", pa.float32()),
45
+ ("success", pa.bool_()),
46
+ ("error_message", pa.string()),
47
+ ], metadata={b"huggingface": hf_meta})
48
+
49
+ def _img(b):
50
+ return {"bytes": b, "path": None}
51
+
52
+ target_jpeg = _img_to_jpeg(target_pil)
53
+ source_jpegs = [_img_to_jpeg(img) for img in source_pils]
54
+ output_jpeg = _img_to_jpeg(output_pil)
55
+
56
+ return pa.table({
57
+ "timestamp": pa.array([now.timestamp()], type=pa.float64()),
58
+ "target_image": pa.array([_img(target_jpeg) if target_jpeg else None], type=img_struct),
59
+ "source_images": pa.array([[_img(b) for b in source_jpegs]], type=pa.list_(img_struct)),
60
+ "output_image": pa.array([_img(output_jpeg) if output_jpeg else None], type=img_struct),
61
+ "selected_indices": pa.array([[int(i) for i in selected_indices]], type=pa.list_(pa.int32())),
62
+ "n_faces_swapped": pa.array([len(selected_indices)], type=pa.int32()),
63
+ "duration_seconds": pa.array([float(duration_seconds)], type=pa.float32()),
64
+ "success": pa.array([bool(success)], type=pa.bool_()),
65
+ "error_message": pa.array([str(error_message)], type=pa.string()),
66
+ }, schema=schema)
67
+
68
+
69
+ def _upload_parquet(api, repo_id, table, path_in_repo):
70
+ import tempfile
71
+ import pyarrow.parquet as pq
72
+
73
+ tmp_path = None
74
+ try:
75
+ with tempfile.NamedTemporaryFile(suffix=".parquet", delete=False) as tmp:
76
+ tmp_path = tmp.name
77
+ pq.write_table(table, tmp_path)
78
+ print(f"[log] uploading {path_in_repo} ({os.path.getsize(tmp_path)//1024}KB)")
79
+ api.upload_file(
80
+ path_or_fileobj=tmp_path, path_in_repo=path_in_repo,
81
+ repo_id=repo_id, repo_type="dataset",
82
+ )
83
+ print(f"[log] upload done β€” {repo_id}/{path_in_repo}")
84
+ finally:
85
+ if tmp_path:
86
+ try:
87
+ os.unlink(tmp_path)
88
+ except Exception as e:
89
+ print(f"[log] failed to delete temp file {tmp_path}: {e}")
90
+
91
+
92
+ def _make_path(now, uid):
93
+ return f"data/{now.strftime('%Y-%m-%d-%H%M%S')}-{uid}.parquet"
94
+
95
+
96
+ def _file_date(path):
97
+ return os.path.basename(path)[:10]
98
+
99
+
100
+ def _maybe_squash_history(api, repo_id, now):
101
+ marker = "metadata/last_squash.txt"
102
+ today = now.strftime("%Y-%m-%d")
103
+ try:
104
+ try:
105
+ local = hf_hub_download(repo_id=repo_id, filename=marker,
106
+ repo_type="dataset", token=api.token)
107
+ if open(local).read().strip() == today:
108
+ return
109
+ except Exception as e:
110
+ print(f"[log] squash marker not found ({e}), proceeding with squash")
111
+
112
+ api.super_squash_history(repo_id=repo_id, repo_type="dataset")
113
+ print(f"[log] squashed history for {repo_id}")
114
+
115
+ api.upload_file(
116
+ path_or_fileobj=today.encode(), path_in_repo=marker,
117
+ repo_id=repo_id, repo_type="dataset",
118
+ )
119
+ print(f"[log] updated squash marker: {today}")
120
+ except Exception as e:
121
+ print(f"[log] squash warning: {e}")
122
+
123
+
124
+ def _prune_old_files(api, repo_id, keep_days, now):
125
+ if keep_days <= 0:
126
+ return
127
+ cutoff = (now - timedelta(days=keep_days)).strftime("%Y-%m-%d")
128
+ try:
129
+ to_delete = [
130
+ f.path
131
+ for f in api.list_repo_tree(repo_id, repo_type="dataset", path_in_repo="data")
132
+ if f.path.endswith(".parquet") and _file_date(f.path) < cutoff
133
+ ]
134
+ for path in to_delete:
135
+ api.delete_file(path_in_repo=path, repo_id=repo_id, repo_type="dataset")
136
+ print(f"[log] pruned: {path}")
137
+ if to_delete:
138
+ print(f"[log] pruned {len(to_delete)} old file(s)")
139
+ except Exception as e:
140
+ print(f"[log] prune warning: {e}")
141
+
142
+
143
+ def log_inference(target_pil, source_pils, output_pil, selected_indices,
144
+ duration_seconds, success, error_message=""):
145
+ import time as _time
146
+ _t0 = _time.perf_counter()
147
+ if not HF_TOKEN or not DATASET_REPO:
148
+ print(f"[log] skipped β€” HF_TOKEN={'set' if HF_TOKEN else 'missing'}, DATASET_REPO={'set' if DATASET_REPO else 'missing'}")
149
+ return
150
+ try:
151
+ from huggingface_hub import HfApi
152
+
153
+ now = datetime.now(timezone.utc)
154
+
155
+ _t1 = _time.perf_counter()
156
+ table = _build_table(target_pil, source_pils, output_pil, selected_indices,
157
+ duration_seconds, success, error_message, now)
158
+ print(f"[log] build_table: {_time.perf_counter() - _t1:.3f}s")
159
+
160
+ uid = uuid.uuid4().hex[:8]
161
+ path_in_repo = _make_path(now, uid)
162
+
163
+ _t2 = _time.perf_counter()
164
+ api = HfApi(token=HF_TOKEN)
165
+ api.create_repo(repo_id=DATASET_REPO, repo_type="dataset", private=True, exist_ok=True)
166
+ print(f"[log] create_repo: {_time.perf_counter() - _t2:.3f}s")
167
+
168
+ _t3 = _time.perf_counter()
169
+ _upload_parquet(api, DATASET_REPO, table, path_in_repo)
170
+ print(f"[log] upload_parquet: {_time.perf_counter() - _t3:.3f}s")
171
+
172
+ _t4 = _time.perf_counter()
173
+ _prune_old_files(api, DATASET_REPO, MAX_LOG_DAYS, now)
174
+ print(f"[log] prune_old_files: {_time.perf_counter() - _t4:.3f}s")
175
+
176
+ _t5 = _time.perf_counter()
177
+ _maybe_squash_history(api, DATASET_REPO, now)
178
+ print(f"[log] squash_history: {_time.perf_counter() - _t5:.3f}s")
179
+ except Exception as log_err:
180
+ import traceback as _tb
181
+ print(f"[log] WARNING: {log_err}\n{_tb.format_exc()}")
182
+ finally:
183
+ print(f"[log] log_inference total: {_time.perf_counter() - _t0:.3f}s")
pyproject.toml ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [project]
2
+ name = "selectable-face-swap"
3
+ version = "1.0.0"
4
+ requires-python = ">=3.10"
5
+ dependencies = [
6
+ "spaces",
7
+ "insightface",
8
+ "onnxruntime",
9
+ "gfpgan",
10
+ "gradio",
11
+ "huggingface_hub",
12
+ "numpy",
13
+ "opencv-python-headless",
14
+ "Pillow",
15
+ "pyarrow",
16
+ "torch",
17
+ "torchvision",
18
+ ]
19
+
20
+ [project.optional-dependencies]
21
+ dev = ["pytest"]
22
+
23
+ [tool.uv]
24
+ package = false
requirements.txt CHANGED
@@ -8,5 +8,6 @@ huggingface_hub
8
  numpy
9
  opencv-python-headless
10
  Pillow
 
11
  torch
12
  torchvision
 
8
  numpy
9
  opencv-python-headless
10
  Pillow
11
+ pyarrow
12
  torch
13
  torchvision
tests/test_app.py CHANGED
@@ -319,7 +319,7 @@ class TestSwapFacesValidation:
319
 
320
  sources = json.dumps({"0": small_rgb_b64})
321
  with (
322
- patch.object(app, "_make_gpu_face_analyzer", return_value=fake_analyzer),
323
  patch.object(app, "_make_swapper", return_value=fake_swapper),
324
  patch.object(app, "_make_enhancer", return_value=fake_enhancer),
325
  patch.object(app, "_spawn_log"),
 
319
 
320
  sources = json.dumps({"0": small_rgb_b64})
321
  with (
322
+ patch.object(app, "_make_face_analyzer", return_value=fake_analyzer),
323
  patch.object(app, "_make_swapper", return_value=fake_swapper),
324
  patch.object(app, "_make_enhancer", return_value=fake_enhancer),
325
  patch.object(app, "_spawn_log"),
tests/test_logging_utils.py ADDED
@@ -0,0 +1,133 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from datetime import datetime, timezone, timedelta
3
+ from types import SimpleNamespace
4
+ from unittest.mock import MagicMock
5
+
6
+ sys.path.insert(0, ".")
7
+ from logging_utils import _prune_old_files, _file_date, _make_path, _maybe_squash_history
8
+
9
+ NOW = datetime(2026, 5, 17, 12, 0, 0, tzinfo=timezone.utc)
10
+ KEEP_DAYS = 7
11
+
12
+
13
+ def _make_api(paths):
14
+ api = MagicMock()
15
+ api.list_repo_tree.return_value = [SimpleNamespace(path=p) for p in paths]
16
+ return api
17
+
18
+
19
+ def _file(days_ago):
20
+ return _make_path(NOW - timedelta(days=days_ago), "abcd1234")
21
+
22
+
23
+ def _deleted_paths(api):
24
+ return {c.kwargs["path_in_repo"] for c in api.delete_file.call_args_list}
25
+
26
+
27
+ def test_file_date_matches_make_path():
28
+ """_file_date(_make_path(now, uid)) must return now's date."""
29
+ import uuid
30
+ path = _make_path(NOW, uuid.uuid4().hex[:8])
31
+ assert _file_date(path) == NOW.strftime("%Y-%m-%d")
32
+
33
+
34
+ def test_old_files_are_deleted():
35
+ api = _make_api([_file(8), _file(10)])
36
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
37
+ assert _deleted_paths(api) == {_file(8), _file(10)}
38
+
39
+
40
+ def test_recent_files_are_kept():
41
+ api = _make_api([_file(6), _file(1), _file(0)])
42
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
43
+ api.delete_file.assert_not_called()
44
+
45
+
46
+ def test_boundary_exactly_keep_days_old_is_kept():
47
+ api = _make_api([_file(KEEP_DAYS)])
48
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
49
+ api.delete_file.assert_not_called()
50
+
51
+
52
+ def test_one_day_past_boundary_is_deleted():
53
+ api = _make_api([_file(KEEP_DAYS + 1)])
54
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
55
+ assert _deleted_paths(api) == {_file(KEEP_DAYS + 1)}
56
+
57
+
58
+ def test_only_old_files_deleted_in_mixed_set():
59
+ api = _make_api([_file(8), _file(6), _file(10), _file(0)])
60
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
61
+ assert _deleted_paths(api) == {_file(8), _file(10)}
62
+
63
+
64
+ def test_empty_directory_does_nothing():
65
+ api = _make_api([])
66
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
67
+ api.delete_file.assert_not_called()
68
+
69
+
70
+ def test_keep_days_zero_skips_pruning():
71
+ api = _make_api([_file(8)])
72
+ _prune_old_files(api, "org/repo", keep_days=0, now=NOW)
73
+ api.list_repo_tree.assert_not_called()
74
+ api.delete_file.assert_not_called()
75
+
76
+
77
+ def test_non_parquet_files_are_ignored():
78
+ api = _make_api(["data/2020-01-01-README.md", _file(8)])
79
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW)
80
+ assert _deleted_paths(api) == {_file(8)}
81
+
82
+
83
+ def test_api_error_does_not_raise():
84
+ api = MagicMock()
85
+ api.list_repo_tree.side_effect = RuntimeError("network error")
86
+ _prune_old_files(api, "org/repo", KEEP_DAYS, NOW) # must not raise
87
+
88
+
89
+ # ── _maybe_squash_history ────────────────────────────────────────────────────
90
+
91
+ def _squash_api():
92
+ api = MagicMock()
93
+ api.token = "tok"
94
+ return api
95
+
96
+
97
+ def test_squash_runs_when_no_marker(monkeypatch):
98
+ monkeypatch.setattr(
99
+ "logging_utils.hf_hub_download",
100
+ MagicMock(side_effect=FileNotFoundError("no marker")),
101
+ )
102
+ api = _squash_api()
103
+ _maybe_squash_history(api, "org/repo", NOW)
104
+ api.super_squash_history.assert_called_once()
105
+ api.upload_file.assert_called_once()
106
+
107
+
108
+ def test_squash_skipped_when_marker_is_today(monkeypatch, tmp_path):
109
+ marker = tmp_path / "last_squash.txt"
110
+ marker.write_text(NOW.strftime("%Y-%m-%d"))
111
+ monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
112
+ api = _squash_api()
113
+ _maybe_squash_history(api, "org/repo", NOW)
114
+ api.super_squash_history.assert_not_called()
115
+
116
+
117
+ def test_squash_runs_when_marker_is_yesterday(monkeypatch, tmp_path):
118
+ marker = tmp_path / "last_squash.txt"
119
+ marker.write_text((NOW - timedelta(days=1)).strftime("%Y-%m-%d"))
120
+ monkeypatch.setattr("logging_utils.hf_hub_download", MagicMock(return_value=str(marker)))
121
+ api = _squash_api()
122
+ _maybe_squash_history(api, "org/repo", NOW)
123
+ api.super_squash_history.assert_called_once()
124
+
125
+
126
+ def test_squash_error_does_not_raise(monkeypatch):
127
+ monkeypatch.setattr(
128
+ "logging_utils.hf_hub_download",
129
+ MagicMock(side_effect=FileNotFoundError("no marker")),
130
+ )
131
+ api = _squash_api()
132
+ api.super_squash_history.side_effect = RuntimeError("squash failed")
133
+ _maybe_squash_history(api, "org/repo", NOW) # must not raise