File size: 47,617 Bytes
ecd4085 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 | #!/usr/bin/env python3
# ADOBE CONFIDENTIAL
# Copyright 2026 Adobe
# All Rights Reserved.
# NOTICE: All information contained herein is, and remains
# the property of Adobe and its suppliers, if any. The intellectual
# and technical concepts contained herein are proprietary to Adobe
# and its suppliers and are protected by all applicable intellectual
# property laws, including trade secret and copyright laws.
# Dissemination of this information or reproduction of this material
# is strictly forbidden unless prior written permission is obtained
# from Adobe.
"""TPIPS embedding demo as a Gradio ZeroGPU app.
ZeroGPU only supports the Gradio SDK, and it exposes a GPU *only* for the
duration of a function wrapped with ``@spaces.GPU`` (allocated on demand, freed
right after). That rules out the Flask + SSE + persistent-worker design of the
non-ZeroGPU demo, so this file rebuilds the same two experiences natively:
* **Image pair** - one reference image compared against any number of candidate
images across an editable list of aspects, shown as grouped similarity bars.
* **Video** - cosine similarity of every sampled frame against a chosen
reference frame, over an editable list of aspects, shown as a line plot.
Per-session isolation is handled by ``gr.State`` (Gradio keeps one copy per
browser session automatically). All GPU compute funnels through
``embed_images`` so ZeroGPU only holds the accelerator while embeddings run.
"""
from __future__ import annotations
import asyncio
import base64
import html as _html
import io
import json
import math
import os
import uuid
from typing import Any
# Gradio's queue creates its asyncio locks via ``asyncio.get_event_loop()`` at
# construction time. On Python 3.12+ that call raises ``RuntimeError`` when no
# loop is running in the main thread, so Gradio silently stores ``None`` locks
# and every queued request 500s with "NoneType ... asynchronous context
# manager". Ensure a loop exists up front; no-op on older Pythons and on the
# Space's default interpreter.
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
import gradio as gr
import torch
import torch.nn.functional as F
from huggingface_hub import snapshot_download
from PIL import Image
# Compatibility shim for a gradio_client bug: its JSON-schema-to-type converter
# assumes every subschema is a dict, but newer pydantic emits boolean subschemas
# (e.g. ``"additionalProperties": false``) for common components (Image, Files,
# Gallery, Video). Without this, ``get_api_info()`` raises "argument of type
# 'bool' is not a container" and the root page 500s on load. Guarded so it is a
# no-op if a future gradio_client renames the internal or fixes the bug.
try:
import gradio_client.utils as _gc_utils
_orig_schema_to_type = _gc_utils._json_schema_to_python_type
def _schema_to_type(schema, defs=None):
if isinstance(schema, bool):
return "Any" if schema else "None"
return _orig_schema_to_type(schema, defs)
_gc_utils._json_schema_to_python_type = _schema_to_type
except Exception: # pragma: no cover - defensive: never block startup on the shim
pass
# ``spaces`` provides the ZeroGPU allocator on the Space. Keep a no-op fallback
# so the same file still runs locally (e.g. on CPU) where it is not installed.
try:
import spaces
gpu = spaces.GPU
except Exception: # pragma: no cover - only exercised off-Space
def gpu(*args, **kwargs):
if len(args) == 1 and callable(args[0]) and not kwargs:
return args[0]
def decorator(function):
return function
return decorator
from tpips import load_model
DEFAULT_FACTOR = "overall"
# Chips offered in the UI; users may also type any custom aspect.
PRESET_FACTORS = [DEFAULT_FACTOR, "lighting", "color palette", "composition", "camera angle"]
# Similarity axis range shared by the bar plot and the video line plot.
BAR_MIN, BAR_MAX = 0.4, 1.0
MAX_VIDEO_SECONDS = float(os.environ.get("TPIPS_MAX_VIDEO_SECONDS", "10"))
# ZeroGPU allocates the accelerator per call with a bounded duration, so cap the
# number of sampled frames instead of decoding at the native frame rate.
MAX_VIDEO_FRAMES = int(os.environ.get("TPIPS_MAX_VIDEO_FRAMES", "60"))
# Images per forward pass. Small keeps peak GPU memory bounded (raise on big GPUs
# like ZeroGPU's H200 for speed; lower if you still OOM on a small card).
EMBED_BATCH = int(os.environ.get("TPIPS_EMBED_BATCH", "8"))
GPU_SECONDS_PER_BATCH = int(os.environ.get("TPIPS_GPU_SECONDS_PER_BATCH", "10"))
MODEL_PATH = os.environ.get("TPIPS_MODEL_PATH", "sywang/TPIPS-Embed-Qwen3VL-8B")
CACHE_DIR = os.environ.get("HF_HOME")
# Keep the active checkpoint on local disk so ZeroGPU can pack its memory-mapped
# tensors quickly. CACHE_DIR still routes the separately loaded backbone through
# the persistent Hugging Face cache mounted at /data.
LOCAL_CHECKPOINT_CACHE = "/home/user/.cache/huggingface"
# On ZeroGPU the ``spaces`` runtime patches CUDA so a cuda placement here is
# resolved inside the GPU worker; set TPIPS_DEVICE=cpu for local CPU testing.
DEVICE = os.environ.get("TPIPS_DEVICE", "cuda")
print(f"Loading TPIPS embedding model '{MODEL_PATH}' for device '{DEVICE}' ...", flush=True)
checkpoint_path = snapshot_download(
repo_id=MODEL_PATH,
cache_dir=LOCAL_CHECKPOINT_CACHE,
)
model = load_model("embedding", checkpoint_path, device=DEVICE, cache_dir=CACHE_DIR)
def gpu_duration(images: list[Image.Image], factor: str) -> int:
"""Reserve ten GPU seconds per embedding batch, capped at 50 seconds."""
del factor
batches = max(1, math.ceil(len(images) / EMBED_BATCH))
return min(50, GPU_SECONDS_PER_BATCH * batches)
def zero_gpu_identity_status(request: gr.Request) -> str:
"""Report whether the Hub forwarded a ZeroGPU request identity."""
headers = {
str(key).lower(): str(value)
for key, value in dict(getattr(request, "headers", {})).items()
}
if headers.get("x-ip-token"):
return (
"✅ **ZeroGPU identity forwarding detected.** Hugging Face will apply "
"the caller's account tier when scheduling GPU work."
)
return (
"⚠️ **ZeroGPU identity forwarding not detected.** Sign in on Hugging Face "
"and use the app through its public Space page to use account quota."
)
@gpu(duration=gpu_duration)
def embed_images(images: list[Image.Image], factor: str) -> torch.Tensor:
"""Return L2-normalized embeddings for ``images`` under ``factor``.
This is the only GPU entry point; ZeroGPU holds the accelerator for the
length of this call and releases it on return.
Images are embedded in small sub-batches so peak GPU memory stays bounded
regardless of how many candidates/frames are scored at once (embedding all
60 video frames in one forward pass OOMs an 8B model on a 24 GB card).
"""
outputs: list[torch.Tensor] = []
with torch.inference_mode():
for start in range(0, len(images), EMBED_BATCH):
chunk = images[start : start + EMBED_BATCH]
result = model.embed(chunk, factor=factor, normalized=True)
tensor = result.detach() if isinstance(result, torch.Tensor) else torch.as_tensor(result)
outputs.append(F.normalize(tensor.float().cpu(), dim=-1))
return torch.cat(outputs, dim=0)
def normalize_factors(factors: Any) -> list[str]:
"""Clean, de-duplicate, and cap the aspect list, falling back to 'overall'."""
if isinstance(factors, str):
factors = [factors]
cleaned: list[str] = []
for raw in factors or []:
name = " ".join(str(raw).strip().split())[:120]
if name and name not in cleaned:
cleaned.append(name)
return cleaned or [DEFAULT_FACTOR]
# --------------------------------------------------------------------------- #
# Image pair tab
#
# Faithful to the local demo: a reference plus any number of candidate cards in
# a scrollable row, each candidate showing one green->red gradient bar per aspect
# (the bar width maps the 0.4..1.0 similarity range). Scores recompute
# automatically whenever the reference, candidates, or aspects change.
# --------------------------------------------------------------------------- #
def _bar_row(name: str, value: float | None) -> str:
label = _html.escape(name)
if value is None:
right = "<span class='tp-status'>—</span>"
else:
pct = max(0.0, min(1.0, (value - BAR_MIN) / (BAR_MAX - BAR_MIN)))
red = round(255 * (1 - pct))
green = round(255 * pct)
right = (
f"<div class='tp-bar-bg'><div class='tp-bar-fill' "
f"style='width:{pct * 100:.1f}%;background:rgb({red},{green},0)'></div></div>"
f"<span class='tp-score-val'>{value:.3f}</span>"
)
return (
f"<div class='tp-score-line'><span class='tp-score-name' title='{label}'>"
f"{label}</span>{right}</div>"
)
def candidate_bars_html(scores: dict[str, float], aspects: list[str]) -> str:
if not aspects:
return "<div class='tp-empty'>No aspects yet.</div>"
rows = "".join(_bar_row(a, (scores or {}).get(a)) for a in aspects)
return f"<div class='tp-cand-scores'>{rows}</div>"
def recompute_image_scores(
ref_path: str | None,
candidates: list[dict[str, Any]] | None,
aspects: list[str] | None,
) -> list[dict[str, Any]]:
"""Return candidates with each one's per-aspect cosine similarity filled in."""
candidates = [dict(c, scores={}) for c in (candidates or [])]
if not ref_path or not candidates or not aspects:
return candidates
ref = Image.open(ref_path).convert("RGB")
imgs = [Image.open(c["path"]).convert("RGB") for c in candidates]
for factor in aspects:
embeddings = embed_images([ref] + imgs, factor)
sims = F.cosine_similarity(
embeddings[0:1].expand_as(embeddings[1:]), embeddings[1:]
).tolist()
for c, value in zip(candidates, sims):
c["scores"][factor] = round(value, 4)
return candidates
# --------------------------------------------------------------------------- #
# Video tab
#
# Faithful to the local demo: the GPU embeds every sampled frame once per aspect;
# from those (L2-normalized) embeddings we build the full N x N cosine matrix per
# aspect and ship it to the browser with small frame previews. The interactive
# viewer (dual reference/candidate frames, play button, ref+frame sliders, a
# Chart.js curve with a moving scrubber, and live per-aspect bars with show/hide)
# then runs entirely client-side: moving the reference frame just indexes a row of
# the cached matrix, so it never touches the GPU (mirrors the local demo).
# --------------------------------------------------------------------------- #
def extract_video_frames(path: str) -> tuple[list[Image.Image], list[float], float]:
"""Evenly sample up to ``MAX_VIDEO_FRAMES`` frames within the time limit."""
try:
from moviepy import VideoFileClip
except ImportError: # moviepy 1.x compatibility
from moviepy.editor import VideoFileClip
clip = VideoFileClip(path)
try:
fps = float(clip.fps or 0.0)
duration = float(clip.duration or 0.0)
if fps <= 0 or duration <= 0:
raise ValueError("video has no readable frames or duration")
processed = min(duration, MAX_VIDEO_SECONDS)
native = max(1, int(math.ceil(processed * fps - 1e-9)))
count = min(native, MAX_VIDEO_FRAMES)
# Sample within the processed window; keep true timestamps for the x axis.
step = processed / count
timestamps = [round(index * step, 3) for index in range(count)]
frames = [
Image.fromarray(clip.get_frame(min(ts, processed)).astype("uint8")).convert("RGB")
for ts in timestamps
]
return frames, timestamps, duration
finally:
clip.close()
def _frame_data_uri(image: Image.Image, max_dim: int = 360, quality: int = 72) -> str:
"""Small JPEG data URI so the whole frame strip travels in one JSON payload."""
width, height = image.size
scale = min(1.0, max_dim / max(width, height))
if scale < 1.0:
image = image.resize((max(1, round(width * scale)), max(1, round(height * scale))))
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=quality)
return "data:image/jpeg;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
def _empty_video_state() -> dict[str, Any]:
return {"loaded": False, "frames": [], "matrices": {}, "nonce": 0}
def build_video_payload(state: dict[str, Any] | None, aspects: list[str]) -> str:
"""Serialize the client-side viewer's data: previews + per-aspect NxN cosine."""
if not state or not state.get("loaded"):
return json.dumps({"loaded": False, "nonce": (state or {}).get("nonce", 0)})
matrices = state.get("matrices", {})
ordered = [a for a in aspects if a in matrices]
state["nonce"] = int(state.get("nonce", 0)) + 1
return json.dumps(
{
"loaded": True,
"nonce": state["nonce"],
"n_frames": len(state["frames"]),
"fps": state["fps"],
"processed_duration": state["processed"],
"original_duration": state["duration"],
"notification": state.get("notification", ""),
"frames": state["previews"],
"timestamps": state["timestamps"],
"factors": ordered,
"matrices": {a: matrices[a] for a in ordered},
}
)
def load_video_state(path: str | None):
"""Upload handler: extract frames (no GPU) and reset per-aspect embeddings."""
if not path:
state = _empty_video_state()
return state, build_video_payload(state, [])
frames, timestamps, duration = extract_video_frames(path)
processed = min(duration, MAX_VIDEO_SECONDS)
note = ""
if duration > MAX_VIDEO_SECONDS + 1e-6:
note = (
f"⚠️ Only the first {MAX_VIDEO_SECONDS:g}s were analyzed "
f"(processed {processed:.2f}s of {duration:.2f}s). "
)
state = {
"loaded": True,
"frames": frames,
"previews": [_frame_data_uri(f) for f in frames],
"timestamps": timestamps,
"fps": len(frames) / max(processed, 1e-6),
"processed": processed,
"duration": duration,
"notification": note.strip(),
"matrices": {},
"nonce": 0,
}
return state, build_video_payload(state, [])
def score_video_state(state: dict[str, Any] | None, aspects: Any):
"""Embed every frame once per aspect (GPU) and cache the full NxN cosine matrix.
Reference-frame moves happen client-side by indexing a row of the matrix, so
this only runs the model when a brand-new aspect is added.
"""
if not state or not state.get("loaded"):
state = state or _empty_video_state()
return state, build_video_payload(state, [])
aspects = normalize_factors(aspects)
frames = state["frames"]
matrices = state.setdefault("matrices", {})
for aspect in aspects:
if aspect not in matrices:
embeddings = embed_images(frames, aspect) # (N, D), L2-normalized
cosine = (embeddings @ embeddings.T).clamp_(-1.0, 1.0)
matrices[aspect] = [[round(v, 4) for v in row] for row in cosine.tolist()]
return state, build_video_payload(state, aspects)
CUSTOM_CSS = """
.tp-compare-row { flex-wrap: nowrap !important; overflow-x: auto !important; gap: 14px; padding-bottom: 10px; align-items: flex-start !important; }
.tp-card { min-width: 300px !important; max-width: 340px !important; flex: 0 0 auto !important; }
.tp-head { font-weight: 600; margin: 2px 0 6px; font-size: 0.95rem; }
.tp-cand-scores { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.tp-score-line { display: flex; align-items: center; gap: 8px; }
.tp-score-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.86rem; opacity: 0.8; }
.tp-bar-bg { flex: 1.6; height: 12px; background: rgba(128,128,128,0.25); border-radius: 3px; overflow: hidden; }
.tp-bar-fill { height: 100%; width: 0; border-radius: 3px; transition: width .3s ease, background .3s ease; }
.tp-score-val { width: 48px; text-align: right; font-variant-numeric: tabular-nums; font-size: 0.86rem; }
.tp-status { opacity: 0.5; }
.tp-empty { opacity: 0.6; font-size: 0.85rem; padding: 8px 0; }
/* aspects sidebar */
.tp-aspect-add { align-items: center !important; gap: 6px !important; }
.tp-chip-row { flex-wrap: wrap !important; gap: 6px !important; }
.tp-chip { flex: 0 0 auto !important; min-width: 0 !important; }
.tp-chip button, button.tp-chip { padding: 3px 12px !important; font-size: 0.78rem !important; border-radius: 14px !important; min-width: 0 !important; }
.tp-aspect-row { align-items: center !important; gap: 8px !important; margin-bottom: 4px !important; flex-wrap: nowrap !important; }
.tp-aspect-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; padding: 4px 2px; }
.tp-x { flex: 0 0 auto !important; }
.tp-x button, button.tp-x { min-width: 34px !important; padding: 2px 6px !important; }
/* video viewer (client-side, mirrors demo.html) */
.tpv-main { display: flex; flex-direction: row; gap: 18px; }
.tpv-left { flex: 1; min-width: 0; }
.tpv-right { width: 260px; flex-shrink: 0; }
.tpv-frames-row { display: flex; gap: 14px; }
.tpv-frame-box { flex: 1; min-width: 0; }
.tpv-frame-box h4 { margin: 0 0 6px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.tpv-slot { position: relative; height: 300px; border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; overflow: hidden; background: rgba(128,128,128,0.08); display: flex; align-items: center; justify-content: center; }
.tpv-slot img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; }
.tpv-empty-slot { color: rgba(128,128,128,0.9); font-size: 0.85rem; padding: 12px; text-align: center; }
.tpv-frame-time { z-index: 2; position: absolute; right: 8px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: rgba(15,28,24,0.85); color: #fff; font-family: monospace; font-size: 0.72rem; }
.tpv-play { z-index: 2; position: absolute; left: 10px; bottom: 10px; width: 42px; height: 42px; border-radius: 50%; border: none; cursor: pointer; background: #2563eb; color: #fff; display: flex; align-items: center; justify-content: center; box-shadow: 0 2px 10px rgba(0,0,0,.35); }
.tpv-play:hover { opacity: .9; }
.tpv-slider-row { display: flex; align-items: center; gap: 10px; margin: 8px 0 4px; }
.tpv-slider-row .tpv-lbl { width: 40px; flex-shrink: 0; opacity: .7; font-size: 0.8rem; }
.tpv-slider-row input[type=range] { flex: 1; cursor: pointer; }
.tpv-frame-label { font-family: monospace; font-size: 0.8rem; white-space: nowrap; min-width: 70px; text-align: right; opacity: .8; }
.tpv-frame-label.ref { color: #2563eb; opacity: 1; }
.tpv-meta { opacity: .65; font-size: 0.78rem; margin: 6px 0 4px; }
.tpv-chart-wrap { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; padding: 12px 14px; margin-top: 10px; }
.tpv-chart-wrap h4 { margin: 0 0 8px; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.tpv-chart-holder { position: relative; height: 250px; }
.tpv-factors-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.tpv-factors-head h4 { margin: 0; font-size: 0.8rem; text-transform: uppercase; letter-spacing: .05em; opacity: .7; }
.tpv-btn-sm { border: 1px solid rgba(128,128,128,0.4); background: transparent; color: inherit; border-radius: 6px; padding: 3px 8px; font-size: 0.7rem; cursor: pointer; margin-left: 6px; }
.tpv-btn-sm:hover { border-color: #2563eb; }
.tpv-hint { opacity: .6; font-size: 0.75rem; margin-bottom: 12px; }
.tpv-factor-item { border: 1px solid rgba(128,128,128,0.3); border-radius: 8px; padding: 10px 11px; margin-bottom: 8px; }
.tpv-factor-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
.tpv-fname-wrap { display: flex; align-items: center; gap: 6px; min-width: 0; flex: 1; }
.tpv-fname { font-size: 0.85rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.tpv-eye { background: none; border: none; cursor: pointer; padding: 0; opacity: .75; display: flex; align-items: center; }
.tpv-eye.off { opacity: .3; }
.tpv-cosine-row { display: flex; align-items: center; gap: 8px; }
.tpv-bar-bg { flex: 1; height: 12px; background: rgba(128,128,128,0.25); border-radius: 3px; overflow: hidden; }
.tpv-bar-fill { height: 100%; width: 0; border-radius: 3px; transition: width .12s, background .12s; }
.tpv-score-val { width: 46px; text-align: right; font-variant-numeric: tabular-nums; font-size: 0.8rem; }
.tpv-empty { opacity: .6; font-size: 0.82rem; padding: 10px 0; }
@media (max-width: 900px) { .tpv-main { flex-direction: column; } .tpv-right { width: 100%; } }
"""
# Static skeleton for the video viewer. Rendered once as trusted HTML (gr.HTML
# does not sanitize developer content); every element carries a stable id so the
# controller in VIDEO_HEAD can drive it from the JSON payload. No inline scripts
# (gr.HTML would strip them) - all behaviour lives in VIDEO_HEAD.
VIDEO_VIEWER_HTML = """
<div class="tpv-main">
<div class="tpv-left">
<div class="tpv-frames-row">
<div class="tpv-frame-box">
<h4 id="tpv-ref-title">Reference frame</h4>
<div class="tpv-slot">
<div class="tpv-empty-slot" id="tpv-ref-empty">Upload a video and score an aspect to begin.</div>
<img id="tpv-ref-img" alt="Reference frame" hidden>
<span class="tpv-frame-time" id="tpv-ref-time" hidden>0.00s</span>
</div>
<div class="tpv-slider-row" id="tpv-ref-slider-row" hidden>
<span class="tpv-lbl">Ref</span>
<input type="range" id="tpv-ref-slider" min="0" max="0" step="1" value="0">
<span class="tpv-frame-label ref" id="tpv-ref-label">0.00s</span>
</div>
</div>
<div class="tpv-frame-box">
<h4 id="tpv-cur-title">Candidate frame</h4>
<div class="tpv-slot">
<div class="tpv-empty-slot" id="tpv-cur-empty">The candidate frame plays here.</div>
<img id="tpv-cur-img" alt="Candidate frame" hidden>
<button class="tpv-play" id="tpv-play" title="Play / pause" hidden></button>
<span class="tpv-frame-time" id="tpv-cur-time" hidden>0.00s</span>
</div>
<div class="tpv-slider-row" id="tpv-frame-slider-row" hidden>
<span class="tpv-lbl">Frame</span>
<input type="range" id="tpv-frame-slider" min="0" max="0" step="1" value="0">
<span class="tpv-frame-label" id="tpv-frame-label">0.00s</span>
</div>
</div>
</div>
<div class="tpv-meta" id="tpv-meta" hidden></div>
<div class="tpv-chart-wrap" id="tpv-chart-wrap" hidden>
<h4>Cosine similarity over video time</h4>
<div class="tpv-chart-holder"><canvas id="tpv-chart"></canvas></div>
</div>
</div>
<div class="tpv-right">
<div class="tpv-factors-head">
<h4>Scored aspects</h4>
<div><button class="tpv-btn-sm" id="tpv-show-all">Show all</button><button class="tpv-btn-sm" id="tpv-hide-all">Hide all</button></div>
</div>
<div class="tpv-hint">Bars track the candidate frame — drag the frame slider or hit play. Moving the reference re-reads cached scores (no model rerun).</div>
<div id="tpv-factors"><div class="tpv-empty">Score an aspect to see per-aspect curves.</div></div>
</div>
</div>
"""
# Chart.js + the client-side controller for the video viewer. Injected into
# <head>; ``window.tpvInit(payload)`` is invoked from the payload textbox's
# .change (see the Video tab wiring) each time the server ships new scores.
VIDEO_HEAD = """
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
<script>
(function(){
if (window.__tpvReady) return;
window.__tpvReady = true;
var COLORS=['#2563eb','#059669','#dc2626','#d97706','#7c3aed','#db2777','#0891b2','#65a30d','#ca8a04','#e11d48'];
var color=function(i){return COLORS[i%COLORS.length];};
var BAR_MIN=0.4, BAR_MAX=1.0;
var barPct=function(v){return Math.max(0,Math.min(1,(v-BAR_MIN)/(BAR_MAX-BAR_MIN)));};
var EYE_OPEN='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>';
var EYE_CLOSED='<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>';
var ICON_PLAY='<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>';
var ICON_PAUSE='<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>';
var $=function(id){return document.getElementById(id);};
function esc(s){var n=document.createElement('span');n.textContent=String(s);return n.innerHTML;}
var VP=null, refIdx=0, curIdx=0, playing=false, raf=null, chart=null, wired=false;
var hidden={};
function clamp(){ if(!VP)return; var m=VP.n_frames-1; if(refIdx>m)refIdx=m; if(refIdx<0)refIdx=0; if(curIdx>m)curIdx=m; if(curIdx<0)curIdx=0; }
function curveFor(f){ return (VP&&VP.matrices[f])?VP.matrices[f][refIdx]:null; }
function showFrames(){
if(!VP||!VP.frames.length)return; clamp();
if($('tpv-ref-img'))$('tpv-ref-img').src=VP.frames[refIdx];
if($('tpv-cur-img'))$('tpv-cur-img').src=VP.frames[curIdx];
if($('tpv-ref-time'))$('tpv-ref-time').textContent=VP.timestamps[refIdx].toFixed(2)+'s';
if($('tpv-cur-time'))$('tpv-cur-time').textContent=VP.timestamps[curIdx].toFixed(2)+'s';
if($('tpv-ref-label'))$('tpv-ref-label').textContent=VP.timestamps[refIdx].toFixed(2)+'s';
if($('tpv-frame-label'))$('tpv-frame-label').textContent=VP.timestamps[curIdx].toFixed(2)+'s';
if($('tpv-ref-title'))$('tpv-ref-title').textContent='Reference frame \\u00b7 '+refIdx;
if($('tpv-cur-title'))$('tpv-cur-title').textContent='Candidate frame \\u00b7 '+curIdx;
}
function updateBars(){
if(!VP)return;
var items=document.querySelectorAll('#tpv-factors .tpv-factor-item');
for(var k=0;k<items.length;k++){
var item=items[k], f=item.getAttribute('data-factor'), curve=curveFor(f);
var bar=item.querySelector('.tpv-bar-fill'), val=item.querySelector('.tpv-score-val');
if(!curve||curve[curIdx]===undefined)continue;
var vv=curve[curIdx],pct=barPct(vv),r=Math.round(255*(1-pct)),g=Math.round(255*pct);
if(bar){bar.style.width=(pct*100).toFixed(1)+'%';bar.style.background='rgb('+r+','+g+',0)';}
if(val)val.textContent=vv.toFixed(3);
}
}
function playIcon(){var b=$('tpv-play');if(b)b.innerHTML=playing?ICON_PAUSE:ICON_PLAY;}
function stopPlay(){playing=false;if(raf)cancelAnimationFrame(raf);raf=null;playIcon();}
function startPlay(){
if(!VP||VP.n_frames<2)return; playing=true; playIcon();
var max=VP.n_frames-1, fps=Math.max(1,VP.fps||12);
var pos=curIdx>=max?0:curIdx, last=performance.now();
var tick=function(now){
if(!playing)return;
pos+=(now-last)/1000*fps; last=now; if(pos>max)pos=0;
curIdx=Math.min(max,Math.round(pos));
var fs=$('tpv-frame-slider'); if(fs)fs.value=curIdx;
showFrames(); updateBars();
if(chart){chart._scrub=curIdx;chart.update('none');}
raf=requestAnimationFrame(tick);
};
raf=requestAnimationFrame(tick);
}
function togglePlay(){ playing?stopPlay():startPlay(); }
var scrubberPlugin={id:'tpvscrub',afterDraw:function(c){
var idx=c._scrub; if(idx===null||idx===undefined)return; var pt=null;
for(var i=0;i<c.data.datasets.length;i++){var m=c.getDatasetMeta(i);if(!m.hidden&&m.data[idx]){pt=m.data[idx];break;}}
if(!pt)return; var ctx=c.ctx, top=c.chartArea.top, bottom=c.chartArea.bottom;
ctx.save();ctx.strokeStyle='rgba(120,120,120,0.7)';ctx.lineWidth=1;ctx.setLineDash([4,3]);
ctx.beginPath();ctx.moveTo(pt.x,top);ctx.lineTo(pt.x,bottom);ctx.stroke();ctx.restore();
}};
function buildChart(){
if(!window.Chart)return; var cv=$('tpv-chart'); if(!cv)return;
if(chart){chart.destroy();chart=null;}
chart=new Chart(cv.getContext('2d'),{type:'line',data:{datasets:[]},
options:{animation:false,responsive:true,maintainAspectRatio:false,interaction:{mode:'index',intersect:false},
scales:{x:{type:'linear',min:0,title:{display:true,text:'Time (s)'},ticks:{callback:function(v){return (+v).toFixed(1);}}},
y:{min:BAR_MIN,max:BAR_MAX,title:{display:true,text:'Cosine similarity'}}},
plugins:{legend:{labels:{boxWidth:12,font:{size:11}},onClick:function(e,item,legend){
Chart.defaults.plugins.legend.onClick(e,item,legend);
var ci=legend.chart, di=ci.data.datasets.findIndex(function(d){return d.label===item.text;});
if(di<0)return; hidden[item.text]=!ci.isDatasetVisible(di); renderFactors();
}}}},
plugins:[scrubberPlugin]});
chart._scrub=curIdx;
}
function syncChart(){
if(!chart||!VP)return;
chart.options.scales.x.max=Math.max(VP.processed_duration,0.001);
chart.data.datasets=VP.factors.map(function(f,i){
var curve=curveFor(f); if(!curve)return null;
return {label:f,data:curve.map(function(y,j){return {x:VP.timestamps[j],y:y};}),
borderColor:color(i),backgroundColor:'transparent',borderWidth:1.5,pointRadius:0,tension:0.25,hidden:hidden[f]===true};
}).filter(Boolean);
chart._scrub=curIdx; chart.update('none');
}
function renderFactors(){
var host=$('tpv-factors'); if(!host)return;
if(!VP||!VP.factors.length){host.innerHTML='<div class="tpv-empty">Score an aspect to see per-aspect curves.</div>';return;}
host.innerHTML=VP.factors.map(function(f,i){
var c=color(i), vis=hidden[f]!==true;
return '<div class="tpv-factor-item" data-factor="'+esc(f)+'">'+
'<div class="tpv-factor-head"><span class="tpv-fname-wrap">'+
'<button class="tpv-eye '+(vis?'':'off')+'" data-eye="'+esc(f)+'">'+(vis?EYE_OPEN:EYE_CLOSED)+'</button>'+
'<span class="tpv-fname" style="color:'+c+'">'+esc(f)+'</span></span></div>'+
'<div class="tpv-cosine-row"><div class="tpv-bar-bg"><div class="tpv-bar-fill"></div></div><span class="tpv-score-val">—</span></div></div>';
}).join('');
if(chart)syncChart();
updateBars();
}
function setVis(loaded){
['tpv-ref-img','tpv-cur-img','tpv-ref-time','tpv-cur-time','tpv-play','tpv-ref-slider-row','tpv-frame-slider-row','tpv-meta','tpv-chart-wrap'].forEach(function(id){var el=$(id);if(el)el.hidden=!loaded;});
['tpv-ref-empty','tpv-cur-empty'].forEach(function(id){var el=$(id);if(el)el.style.display=loaded?'none':'';});
}
function wire(){
if(wired)return; wired=true;
document.addEventListener('click',function(e){
var t=e.target; if(!t.closest)return;
if(t.closest('#tpv-play')){togglePlay();return;}
if(t.closest('#tpv-show-all')){if(VP)VP.factors.forEach(function(f){hidden[f]=false;});renderFactors();return;}
if(t.closest('#tpv-hide-all')){if(VP)VP.factors.forEach(function(f){hidden[f]=true;});renderFactors();return;}
var eye=t.closest('[data-eye]'); if(eye){var f=eye.getAttribute('data-eye');hidden[f]=hidden[f]!==true;renderFactors();return;}
});
document.addEventListener('input',function(e){
if(e.target.id==='tpv-frame-slider'){stopPlay();curIdx=+e.target.value;showFrames();updateBars();if(chart){chart._scrub=curIdx;chart.update('none');}}
else if(e.target.id==='tpv-ref-slider'){refIdx=+e.target.value;showFrames();syncChart();updateBars();}
});
}
window.tpvInit=function(payloadStr){
try{
wire();
var p; try{p=JSON.parse(payloadStr||'{}');}catch(_){p={};}
if(!p||!p.loaded){VP=null;setVis(false);var h=$('tpv-factors');if(h)h.innerHTML='<div class="tpv-empty">Score an aspect to see per-aspect curves.</div>';return;}
VP=p; setVis(true); clamp();
var rs=$('tpv-ref-slider'), fs=$('tpv-frame-slider');
if(rs){rs.max=VP.n_frames-1;rs.value=refIdx;}
if(fs){fs.max=VP.n_frames-1;fs.value=curIdx;}
if($('tpv-meta'))$('tpv-meta').textContent=VP.n_frames+' frames \\u00b7 '+VP.fps.toFixed(2)+' fps \\u00b7 analyzed '+VP.processed_duration.toFixed(2)+'s of '+VP.original_duration.toFixed(2)+'s'+(VP.notification?(' \\u00b7 '+VP.notification):'');
if(!chart)buildChart();
playIcon(); showFrames(); renderFactors(); syncChart(); updateBars();
}catch(err){console.error('tpvInit',err);}
};
var tries=0;
var waitChart=function(){ if(window.Chart){ if(VP&&!chart){buildChart();syncChart();} } else if(tries++<80){ setTimeout(waitChart,150); } };
waitChart();
})();
</script>
"""
# --------------------------------------------------------------------------- #
# UI
# --------------------------------------------------------------------------- #
with gr.Blocks(
title="TPIPS interactive demo", theme=gr.themes.Soft(), css=CUSTOM_CSS, head=VIDEO_HEAD
) as demo:
gr.Markdown(
"# TPIPS interactive demo\n"
"Embedding-based perceptual similarity for image pairs and short videos. "
"Pick or type any aspect to score against. "
"More details on our [project page](https://peterwang512.github.io/TPIPS)."
)
identity_status = gr.Markdown()
demo.load(zero_gpu_identity_status, outputs=identity_status)
# Per-session image-tab state (Gradio keeps one copy per browser session).
img_ref_state = gr.State(None) # reference image filepath
img_cands_state = gr.State([]) # list of {"id", "path", "scores": {aspect: value}}
img_aspects_state = gr.State([DEFAULT_FACTOR])
# Per-session video-tab state (frames, previews, cached per-aspect NxN cosine).
video_state = gr.State(_empty_video_state())
video_aspects_state = gr.State([DEFAULT_FACTOR])
with gr.Tabs():
with gr.Tab("Image Pair"):
gr.Markdown(
"Upload a **reference** and add any number of **candidates**. Each "
"candidate shows a green→red similarity bar per aspect; bars update "
"automatically as you add candidates or aspects."
)
with gr.Row():
with gr.Column(scale=3):
# Reference + candidates + add tile all live inside one render so
# they are flex siblings of a single horizontally scrollable row.
@gr.render(inputs=[img_ref_state, img_cands_state, img_aspects_state])
def render_compare(ref_path, cands, aspects):
with gr.Row(elem_classes="tp-compare-row"):
with gr.Column(min_width=300, elem_classes="tp-card"):
gr.HTML("<div class='tp-head'>Reference Image</div>")
ref_img = gr.Image(
value=ref_path,
type="filepath",
sources=["upload", "clipboard"],
height=280,
show_label=False,
)
def _set_reference(path, cs, asp):
return path, recompute_image_scores(path, cs, asp)
ref_img.change(
_set_reference,
[ref_img, img_cands_state, img_aspects_state],
[img_ref_state, img_cands_state],
)
for index, cand in enumerate(cands):
with gr.Column(min_width=300, elem_classes="tp-card"):
gr.HTML(f"<div class='tp-head'>Candidate {index + 1}</div>")
gr.Image(
value=cand["path"],
interactive=False,
height=280,
show_label=False,
)
gr.HTML(candidate_bars_html(cand.get("scores", {}), aspects))
remove_btn = gr.Button("✕ Remove", size="sm")
def _remove(cs, asp, ref, cid=cand["id"]):
cs = [c for c in cs if c["id"] != cid]
return recompute_image_scores(ref, cs, asp)
remove_btn.click(
_remove,
[img_cands_state, img_aspects_state, img_ref_state],
img_cands_state,
)
with gr.Column(min_width=260, elem_classes="tp-card"):
gr.HTML("<div class='tp-head'> </div>")
add_btn = gr.UploadButton(
"+ Add candidate",
file_count="multiple",
file_types=["image"],
)
def _add(files, cs, asp, ref):
paths = [getattr(f, "name", f) for f in (files or [])]
cs = list(cs) + [
{"id": uuid.uuid4().hex, "path": p, "scores": {}}
for p in paths
]
return recompute_image_scores(ref, cs, asp)
add_btn.upload(
_add,
[add_btn, img_cands_state, img_aspects_state, img_ref_state],
img_cands_state,
)
with gr.Column(scale=1, min_width=240):
gr.Markdown("### Comparison aspects")
with gr.Row(elem_classes="tp-aspect-add"):
aspect_box = gr.Textbox(
placeholder="e.g. material texture",
show_label=False,
container=False,
scale=4,
max_lines=1,
)
aspect_add = gr.Button("Add", scale=1, size="sm")
with gr.Row(elem_classes="tp-chip-row"):
chip_btns = [
gr.Button(p, size="sm", elem_classes="tp-chip") for p in PRESET_FACTORS
]
@gr.render(inputs=[img_aspects_state])
def render_aspects(aspects):
if not aspects:
gr.HTML("<div class='tp-empty'>Add an aspect to compare.</div>")
for name in aspects:
with gr.Row(elem_classes="tp-aspect-row"):
gr.HTML(f"<div class='tp-aspect-name'>{_html.escape(name)}</div>")
remove_aspect = gr.Button(
"✕", size="sm", scale=0, min_width=34, elem_classes="tp-x"
)
def _remove_aspect(asp, cs, ref, target=name):
asp = [a for a in asp if a != target]
return asp, recompute_image_scores(ref, cs, asp)
remove_aspect.click(
_remove_aspect,
[img_aspects_state, img_cands_state, img_ref_state],
[img_aspects_state, img_cands_state],
)
def _add_aspect(text, asp, cs, ref):
name = " ".join(str(text or "").strip().split())[:120]
asp = list(asp)
if name and name not in asp:
asp = asp + [name]
return "", asp, recompute_image_scores(ref, cs, asp)
aspect_add.click(
_add_aspect,
[aspect_box, img_aspects_state, img_cands_state, img_ref_state],
[aspect_box, img_aspects_state, img_cands_state],
)
aspect_box.submit(
_add_aspect,
[aspect_box, img_aspects_state, img_cands_state, img_ref_state],
[aspect_box, img_aspects_state, img_cands_state],
)
for chip, preset in zip(chip_btns, PRESET_FACTORS):
def _add_chip(asp, cs, ref, name=preset):
asp = list(asp)
if name not in asp:
asp = asp + [name]
return asp, recompute_image_scores(ref, cs, asp)
chip.click(
_add_chip,
[img_aspects_state, img_cands_state, img_ref_state],
[img_aspects_state, img_cands_state],
)
with gr.Tab("Video"):
gr.Markdown(
f"Upload a clip (first {MAX_VIDEO_SECONDS:g}s analyzed), pick a reference "
"frame, then play the candidate — the curve shows how similar every frame "
"is to the reference along each aspect, and the bars update live as it plays."
)
# Hidden channel: server writes the JSON payload here, and its .change
# forwards it to the client-side controller (window.tpvInit). Declared
# early because the aspect-remove buttons rendered below target it.
video_payload = gr.Textbox(visible=False, elem_id="tpv-payload")
with gr.Row():
with gr.Column(scale=3):
video_input = gr.Video(
label=f"Video (first {MAX_VIDEO_SECONDS:g}s analyzed)", height=220
)
with gr.Column(scale=1, min_width=240):
gr.Markdown("### Video aspects")
with gr.Row(elem_classes="tp-aspect-add"):
v_aspect_box = gr.Textbox(
placeholder="e.g. camera motion",
show_label=False,
container=False,
scale=4,
max_lines=1,
)
v_aspect_add = gr.Button("Add", scale=1, size="sm")
with gr.Row(elem_classes="tp-chip-row"):
v_chip_btns = [
gr.Button(p, size="sm", elem_classes="tp-chip") for p in PRESET_FACTORS
]
@gr.render(inputs=[video_aspects_state])
def render_video_aspects(aspects):
if not aspects:
gr.HTML("<div class='tp-empty'>Add an aspect to embed frames.</div>")
for name in aspects:
with gr.Row(elem_classes="tp-aspect-row"):
gr.HTML(
f"<div class='tp-aspect-name'>{_html.escape(name)}</div>"
)
v_remove = gr.Button(
"✕", size="sm", scale=0, min_width=34, elem_classes="tp-x"
)
def _remove_v_aspect(asp, st, target=name):
asp = [a for a in asp if a != target]
st, payload = score_video_state(st, asp)
return asp, st, payload
v_remove.click(
_remove_v_aspect,
[video_aspects_state, video_state],
[video_aspects_state, video_state, video_payload],
)
# The interactive viewer (static skeleton; behaviour is in VIDEO_HEAD).
gr.HTML(VIDEO_VIEWER_HTML)
video_payload.change(
None, video_payload, None, js="(p) => { if (window.tpvInit) window.tpvInit(p); }"
)
# Upload -> extract frames (no GPU) -> auto-score the current aspects.
video_input.change(
load_video_state, [video_input], [video_state, video_payload]
).then(
score_video_state,
[video_state, video_aspects_state],
[video_state, video_payload],
)
def _add_v_aspect(text, asp, st):
name = " ".join(str(text or "").strip().split())[:120]
asp = list(asp)
if name and name not in asp:
asp = asp + [name]
st, payload = score_video_state(st, asp)
return "", asp, st, payload
v_aspect_add.click(
_add_v_aspect,
[v_aspect_box, video_aspects_state, video_state],
[v_aspect_box, video_aspects_state, video_state, video_payload],
)
v_aspect_box.submit(
_add_v_aspect,
[v_aspect_box, video_aspects_state, video_state],
[v_aspect_box, video_aspects_state, video_state, video_payload],
)
for v_chip, v_preset in zip(v_chip_btns, PRESET_FACTORS):
def _add_v_chip(asp, st, name=v_preset):
asp = list(asp)
if name not in asp:
asp = asp + [name]
st, payload = score_video_state(st, asp)
return asp, st, payload
v_chip.click(
_add_v_chip,
[video_aspects_state, video_state],
[video_aspects_state, video_state, video_payload],
)
if __name__ == "__main__":
demo.queue(max_size=32).launch(
# Bind all interfaces by default (required on the Space); override with
# GRADIO_SERVER_NAME=127.0.0.1 for local testing on hosts where
# ``localhost`` resolves only to IPv6 and Gradio's self-check would fail.
server_name=os.environ.get("GRADIO_SERVER_NAME", "0.0.0.0"),
server_port=int(os.environ.get("PORT", 7860)),
)
|