yaojin17 commited on
Commit
c19d0b9
·
verified ·
1 Parent(s): 5aefb17

Update the demo viewers, click markers and examples

Browse files

WebGL viewers with a point-size control (default 5), click markers sized by the framed scene, example images kept in memory, and the CO3D car and couch examples removed.

README.md CHANGED
@@ -23,7 +23,7 @@ On first launch the Space downloads the checkpoint from [`uva-cv-lab/Click3R`](h
23
 
24
  ## Examples
25
 
26
- The demo has 25 example pairs. Errors are relative pose errors in degrees.
27
 
28
  **From the paper.** Bicycle (Figure 1, CO3D) and Railing (Figure 4, BlendedMVS). Their clicks are transcribed from the figures.
29
 
@@ -46,7 +46,6 @@ The demo has 25 example pairs. Errors are relative pose errors in degrees.
46
  | CO3D toy truck | 1 | 170.4° | 161.7° | 3.4° |
47
  | CO3D umbrella | 1 | 74.1° | 161.7° | 3.1° |
48
  | CO3D cake | 1 | 40.7° | 38.9° | 3.6° |
49
- | CO3D couch | 1 | 37.9° | 62.1° | 10.6° |
50
  | CO3D teddy bear | 2 | 124.1° | 127.3° | 2.6° |
51
  | CO3D handbag | 3 | 73.1° | 172.5° | 3.9° |
52
  | DTU scan 29 | 1 | 135.5° | 78.4° | 8.8° |
@@ -55,7 +54,6 @@ The demo has 25 example pairs. Errors are relative pose errors in degrees.
55
  | BlendedMVS houses | 1 | 75.1° | 178.8° | 6.1° |
56
  | BlendedMVS town | 2 | 93.1° | 133.0° | 2.8° |
57
  | BlendedMVS village | 1 | 65.3° | 177.6° | 0.6° |
58
- | CO3D car | 1 | 178.0° | 175.9° | 2.8° |
59
  | CO3D smartphone | 2 | 180.0° | 179.1° | 3.7° |
60
  | WildRGB-D flower pot | 1 | 67.7° | 72.4° | 8.0° |
61
  | ETH3D courtyard | 1 | 88.1° | 89.4° | 1.3° |
 
23
 
24
  ## Examples
25
 
26
+ The demo has 23 example pairs. Errors are relative pose errors in degrees.
27
 
28
  **From the paper.** Bicycle (Figure 1, CO3D) and Railing (Figure 4, BlendedMVS). Their clicks are transcribed from the figures.
29
 
 
46
  | CO3D toy truck | 1 | 170.4° | 161.7° | 3.4° |
47
  | CO3D umbrella | 1 | 74.1° | 161.7° | 3.1° |
48
  | CO3D cake | 1 | 40.7° | 38.9° | 3.6° |
 
49
  | CO3D teddy bear | 2 | 124.1° | 127.3° | 2.6° |
50
  | CO3D handbag | 3 | 73.1° | 172.5° | 3.9° |
51
  | DTU scan 29 | 1 | 135.5° | 78.4° | 8.8° |
 
54
  | BlendedMVS houses | 1 | 75.1° | 178.8° | 6.1° |
55
  | BlendedMVS town | 2 | 93.1° | 133.0° | 2.8° |
56
  | BlendedMVS village | 1 | 65.3° | 177.6° | 0.6° |
 
57
  | CO3D smartphone | 2 | 180.0° | 179.1° | 3.7° |
58
  | WildRGB-D flower pot | 1 | 67.7° | 72.4° | 8.0° |
59
  | ETH3D courtyard | 1 | 88.1° | 89.4° | 1.3° |
assets/examples/co3d_car/view1.png DELETED
Binary file (73 kB)
 
assets/examples/co3d_car/view2.png DELETED
Binary file (81.9 kB)
 
assets/examples/co3d_couch/view1.png DELETED
Binary file (63.8 kB)
 
assets/examples/co3d_couch/view2.png DELETED
Binary file (54.9 kB)
 
assets/examples/manifest.json CHANGED
@@ -1558,146 +1558,6 @@
1558
  "attribution": "CO3D dataset contributors / Meta",
1559
  "license_source": "https://ai.meta.com/datasets/co3d-downloads/"
1560
  },
1561
- {
1562
- "id": "co3d_couch",
1563
- "title": "CO3D couch \u00b7 one click pair",
1564
- "pair_id": "CO3D/couch/590_88849_175666/frame000133.jpg|frame000172.jpg",
1565
- "image1": "co3d_couch/view1.png",
1566
- "image2": "co3d_couch/view2.png",
1567
- "coordinate_space": "normalized 224x224 model crop",
1568
- "correspondences": [
1569
- {
1570
- "x1": 0.7410714285714286,
1571
- "y1": 0.07589285714285714,
1572
- "x2": 0.43251184054783415,
1573
- "y2": 0.16654603821890696
1574
- }
1575
- ],
1576
- "source": "Automatic example search, 2026-11-05",
1577
- "source_dataset": "CO3D",
1578
- "source_sequence": "couch/590_88849_175666",
1579
- "source_frames": [
1580
- "frame000133",
1581
- "frame000172"
1582
- ],
1583
- "suggested_click_source": "Ground-truth correspondences at corners visible in both views, chosen automatically; not human clicks",
1584
- "checkpoint": "prompt3r_trained_without_occlusion_best.pth",
1585
- "verification": "GPU replay passed, 2026-11-05; float32, symmetrized PnP metrics; prompt token offset 0.0; +-2 px click jitter stays below 20 degrees",
1586
- "K1": [
1587
- [
1588
- 371.4941101074219,
1589
- 0.0,
1590
- 111.0
1591
- ],
1592
- [
1593
- 0.0,
1594
- 371.4941101074219,
1595
- 111.82941436767578
1596
- ],
1597
- [
1598
- 0.0,
1599
- 0.0,
1600
- 1.0
1601
- ]
1602
- ],
1603
- "K2": [
1604
- [
1605
- 369.1937561035156,
1606
- 0.0,
1607
- 111.0
1608
- ],
1609
- [
1610
- 0.0,
1611
- 369.1937561035156,
1612
- 111.66470336914062
1613
- ],
1614
- [
1615
- 0.0,
1616
- 0.0,
1617
- 1.0
1618
- ]
1619
- ],
1620
- "img_size1": [
1621
- 224,
1622
- 224
1623
- ],
1624
- "img_size2": [
1625
- 224,
1626
- 224
1627
- ],
1628
- "pose1_c2w": [
1629
- [
1630
- 0.625900149345398,
1631
- 0.756680965423584,
1632
- -0.1888994425535202,
1633
- 1.830051302909851
1634
- ],
1635
- [
1636
- -0.5032391548156738,
1637
- 0.5768808126449585,
1638
- 0.6433963775634766,
1639
- -2.332740306854248
1640
- ],
1641
- [
1642
- 0.5958182215690613,
1643
- -0.30764031410217285,
1644
- 0.7418612241744995,
1645
- -0.779137372970581
1646
- ],
1647
- [
1648
- 0.0,
1649
- 0.0,
1650
- 0.0,
1651
- 1.0
1652
- ]
1653
- ],
1654
- "pose2_c2w": [
1655
- [
1656
- -0.9677540063858032,
1657
- 0.17060181498527527,
1658
- 0.18532992899417877,
1659
- 0.44929206371307373
1660
- ],
1661
- [
1662
- 0.1873876452445984,
1663
- -0.004104661755263805,
1664
- 0.9822774529457092,
1665
- -4.026187896728516
1666
- ],
1667
- [
1668
- 0.16833904385566711,
1669
- 0.9853315353393555,
1670
- -0.027996370568871498,
1671
- 2.496816873550415
1672
- ],
1673
- [
1674
- 0.0,
1675
- 0.0,
1676
- 0.0,
1677
- 1.0
1678
- ]
1679
- ],
1680
- "release_metrics": {
1681
- "pair_id": "CO3D/couch/590_88849_175666/frame000133.jpg|frame000172.jpg",
1682
- "num_clicks": 1,
1683
- "dust3r": {
1684
- "rotation_error_deg": 37.86177062988281,
1685
- "translation_error_deg": 23.619871139526367
1686
- },
1687
- "click3r_no_clicks": {
1688
- "rotation_error_deg": 62.11467361450195,
1689
- "translation_error_deg": 33.67499923706055
1690
- },
1691
- "click3r_clicks": {
1692
- "rotation_error_deg": 10.642072677612305,
1693
- "translation_error_deg": 3.9270527362823486
1694
- }
1695
- },
1696
- "release_checkpoint_sha256": "7e882702da087cb214a101a45fa6c8a098aaefc5ce952511076b518e09decc78",
1697
- "asset_license": "CC-BY-NC-4.0",
1698
- "attribution": "CO3D dataset contributors / Meta",
1699
- "license_source": "https://ai.meta.com/datasets/co3d-downloads/"
1700
- },
1701
  {
1702
  "id": "co3d_teddybear",
1703
  "title": "CO3D teddy bear \u00b7 two click pairs",
@@ -2878,146 +2738,6 @@
2878
  "attribution": "Yao Yao et al., BlendedMVS (CVPR 2020)",
2879
  "license_source": "https://github.com/YoYo000/BlendedMVS#license"
2880
  },
2881
- {
2882
- "id": "co3d_car",
2883
- "title": "CO3D car \u00b7 one click pair",
2884
- "pair_id": "CO3D/car/590_88856_176024/frame000124.jpg|frame000181.jpg",
2885
- "image1": "co3d_car/view1.png",
2886
- "image2": "co3d_car/view2.png",
2887
- "coordinate_space": "normalized 224x224 model crop",
2888
- "correspondences": [
2889
- {
2890
- "x1": 0.9553571428571429,
2891
- "y1": 0.46875,
2892
- "x2": 0.2703523976462228,
2893
- "y2": 0.5817365646362305
2894
- }
2895
- ],
2896
- "source": "Automatic example search, 2026-11-05; chosen by the authors",
2897
- "source_dataset": "CO3D",
2898
- "source_sequence": "car/590_88856_176024",
2899
- "source_frames": [
2900
- "frame000124",
2901
- "frame000181"
2902
- ],
2903
- "suggested_click_source": "Ground-truth correspondences at corners visible in both views, chosen automatically; not human clicks",
2904
- "checkpoint": "prompt3r_trained_without_occlusion_best.pth",
2905
- "verification": "GPU replay passed, 2026-11-05; float32, symmetrized PnP metrics; prompt token offset 0.0; +-2 px click jitter stays below 20 degrees",
2906
- "K1": [
2907
- [
2908
- 380.17340087890625,
2909
- 0.0,
2910
- 111.65774536132812
2911
- ],
2912
- [
2913
- 0.0,
2914
- 380.17340087890625,
2915
- 111.15774536132812
2916
- ],
2917
- [
2918
- 0.0,
2919
- 0.0,
2920
- 1.0
2921
- ]
2922
- ],
2923
- "K2": [
2924
- [
2925
- 379.39739990234375,
2926
- 0.0,
2927
- 111.5
2928
- ],
2929
- [
2930
- 0.0,
2931
- 379.39739990234375,
2932
- 111.15774536132812
2933
- ],
2934
- [
2935
- 0.0,
2936
- 0.0,
2937
- 1.0
2938
- ]
2939
- ],
2940
- "img_size1": [
2941
- 224,
2942
- 224
2943
- ],
2944
- "img_size2": [
2945
- 224,
2946
- 224
2947
- ],
2948
- "pose1_c2w": [
2949
- [
2950
- -0.7206673622131348,
2951
- -0.051977626979351044,
2952
- 0.6913297772407532,
2953
- -4.461040019989014
2954
- ],
2955
- [
2956
- 0.18991735577583313,
2957
- 0.9442387819290161,
2958
- 0.2689693570137024,
2959
- -1.0833290815353394
2960
- ],
2961
- [
2962
- -0.6667607426643372,
2963
- 0.32513293623924255,
2964
- -0.6706106066703796,
2965
- 3.9138236045837402
2966
- ],
2967
- [
2968
- 0.0,
2969
- 0.0,
2970
- 0.0,
2971
- 1.0
2972
- ]
2973
- ],
2974
- "pose2_c2w": [
2975
- [
2976
- 0.9098760485649109,
2977
- -0.038672517985105515,
2978
- 0.4130738377571106,
2979
- -2.6405954360961914
2980
- ],
2981
- [
2982
- 0.04088924825191498,
2983
- 0.9991576075553894,
2984
- 0.0034758830443024635,
2985
- 0.6790323257446289
2986
- ],
2987
- [
2988
- -0.41286030411720276,
2989
- 0.013727656565606594,
2990
- 0.9106909036636353,
2991
- -6.602734565734863
2992
- ],
2993
- [
2994
- 0.0,
2995
- 0.0,
2996
- 0.0,
2997
- 1.0
2998
- ]
2999
- ],
3000
- "release_metrics": {
3001
- "pair_id": "CO3D/car/590_88856_176024/frame000124.jpg|frame000181.jpg",
3002
- "num_clicks": 1,
3003
- "dust3r": {
3004
- "rotation_error_deg": 177.95828247070312,
3005
- "translation_error_deg": 77.63292694091797
3006
- },
3007
- "click3r_no_clicks": {
3008
- "rotation_error_deg": 175.9438018798828,
3009
- "translation_error_deg": 77.01041412353516
3010
- },
3011
- "click3r_clicks": {
3012
- "rotation_error_deg": 2.8129944801330566,
3013
- "translation_error_deg": 0.18974563479423523
3014
- }
3015
- },
3016
- "release_checkpoint_sha256": "7e882702da087cb214a101a45fa6c8a098aaefc5ce952511076b518e09decc78",
3017
- "asset_license": "CC-BY-NC-4.0",
3018
- "attribution": "CO3D dataset contributors / Meta",
3019
- "license_source": "https://ai.meta.com/datasets/co3d-downloads/"
3020
- },
3021
  {
3022
  "id": "co3d_smartphone",
3023
  "title": "CO3D smartphone \u00b7 two click pairs",
 
1558
  "attribution": "CO3D dataset contributors / Meta",
1559
  "license_source": "https://ai.meta.com/datasets/co3d-downloads/"
1560
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1561
  {
1562
  "id": "co3d_teddybear",
1563
  "title": "CO3D teddy bear \u00b7 two click pairs",
 
2738
  "attribution": "Yao Yao et al., BlendedMVS (CVPR 2020)",
2739
  "license_source": "https://github.com/YoYo000/BlendedMVS#license"
2740
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2741
  {
2742
  "id": "co3d_smartphone",
2743
  "title": "CO3D smartphone \u00b7 two click pairs",
click3r/demo/app.py CHANGED
@@ -60,13 +60,15 @@ from click3r.demo.session import (
60
  session_edit as session_edit,
61
  session_scope as session_scope,
62
  )
 
63
  from click3r.demo.views import prepare_model_view
64
  from click3r.demo.results import ReconstructionResult
65
 
66
  DEBUG = False
67
  DISPLAY_LONG_SIDE = 640 # Pixels; small inputs would otherwise be hard to click.
68
- CLICK_MARKER_POINTS = 150 # Scene points inside a 3D click marker.
69
  VIEWER_FOV = 0.8 # Radians; vertical field of view of Gradio's 3D viewer.
 
70
  # Optional comparison models are imported lazily by their load functions.
71
  VGGT_MODEL_NAME = "facebook/VGGT-1B"
72
  PI3_MODEL_NAME = "yyfz233/Pi3X"
@@ -993,27 +995,39 @@ def sample_world_points_at_prompts(
993
  return None, None
994
 
995
 
996
- def click_marker_radius(scene, clicks):
997
- """Marker radius from the point density at the clicks, unaffected by far outliers."""
998
- points = []
999
  for node in scene.graph.nodes_geometry:
1000
  transform, name = scene.graph[node]
1001
- vertices = np.asarray(scene.geometry[name].vertices)
1002
- if len(vertices) > CLICK_MARKER_POINTS:
1003
- points.append(trimesh.transform_points(vertices, transform))
1004
- points = np.concatenate(points)
1005
- return np.median(
1006
- [
1007
- np.partition(np.linalg.norm(points - click, axis=1), CLICK_MARKER_POINTS)[
1008
- CLICK_MARKER_POINTS
1009
- ]
1010
- for click in clicks
1011
- ]
1012
- )
 
 
 
 
 
 
 
 
 
 
 
 
1013
 
1014
 
1015
  def frame_model_for_viewer(model_path):
1016
- """Drop far outliers from a display model and return its viewer update.
1017
 
1018
  The viewer aims at the center of the model's bounding box from 1.1 times its
1019
  diagonal, so a few far points leave the scene small and off center. Removing
@@ -1022,17 +1036,8 @@ def frame_model_for_viewer(model_path):
1022
  if not model_path:
1023
  return model_path
1024
  scene = trimesh.load(model_path)
1025
- clouds = {}
1026
- for node in scene.graph.nodes_geometry:
1027
- transform, name = scene.graph[node]
1028
- geometry = scene.geometry[name]
1029
- if isinstance(geometry, trimesh.PointCloud) or len(geometry.vertices) > 5000:
1030
- clouds[name] = trimesh.transform_points(
1031
- np.asarray(geometry.vertices), transform
1032
- )
1033
- points = np.concatenate(list(clouds.values()))
1034
- center = np.median(points, axis=0)
1035
- limit = 2 * np.percentile(np.linalg.norm(points - center, axis=1), 90)
1036
  kept = []
1037
  for name, vertices in clouds.items():
1038
  keep = np.linalg.norm(vertices - center, axis=1) <= limit
@@ -1045,12 +1050,15 @@ def frame_model_for_viewer(model_path):
1045
  else:
1046
  geometry.update_vertices(keep)
1047
  kept.append(vertices[keep])
1048
- scene.export(model_path)
1049
  target = scene.bounds.mean(axis=0)
1050
  radius = np.percentile(
1051
  np.linalg.norm(np.concatenate(kept) - target, axis=1), 75
1052
  ) / np.tan(VIEWER_FOV / 2)
1053
- return gr.update(value=model_path, camera_position=(None, None, float(radius)))
 
 
 
 
1054
 
1055
 
1056
  def overlay_correspondences_on_model(
@@ -1082,8 +1090,7 @@ def overlay_correspondences_on_model(
1082
  radius = click_marker_radius(
1083
  base_scene
1084
  if isinstance(base_scene, trimesh.Scene)
1085
- else trimesh.Scene(base_scene),
1086
- np.concatenate([sampled_pts1, sampled_pts2]),
1087
  )
1088
 
1089
  debug_log(f"Sphere radius: {radius:.4f}")
@@ -1317,95 +1324,11 @@ def display_size(image):
1317
  return round(image.width * scale), round(image.height * scale)
1318
 
1319
 
1320
- def add_crop_region_overlay(image, target_size=224):
1321
- """
1322
- Add semi-transparent overlay to show regions that will be cropped out.
1323
- Only the center square region (short_side × short_side) is valid for prompts.
1324
-
1325
- Args:
1326
- image: PIL Image
1327
- target_size: Target size for DUSt3R (default 224)
1328
-
1329
- Returns:
1330
- PIL Image with overlay
1331
- """
1332
  if image is None:
1333
  return None
1334
-
1335
- img_copy = image.resize(display_size(image), Image.LANCZOS)
1336
- W, H = img_copy.size
1337
-
1338
- # Calculate resize and crop according to DUSt3R's logic
1339
- S = max(W, H)
1340
- long_edge_size = round(target_size * max(W / H, H / W))
1341
- W_resized = round(W * long_edge_size / S)
1342
- H_resized = round(H * long_edge_size / S)
1343
-
1344
- # Center crop coordinates
1345
- cx, cy = W_resized // 2, H_resized // 2
1346
- half = min(cx, cy)
1347
- crop_x_start = cx - half
1348
- crop_y_start = cy - half
1349
- crop_x_end = cx + half
1350
- crop_y_end = cy + half
1351
-
1352
- # Scale back to original image coordinates
1353
- scale_x = W / W_resized
1354
- scale_y = H / H_resized
1355
-
1356
- crop_x_start_orig = int(crop_x_start * scale_x)
1357
- crop_y_start_orig = int(crop_y_start * scale_y)
1358
- crop_x_end_orig = int(crop_x_end * scale_x)
1359
- crop_y_end_orig = int(crop_y_end * scale_y)
1360
-
1361
- # Create semi-transparent overlay
1362
- overlay = Image.new("RGBA", img_copy.size, (0, 0, 0, 0))
1363
- draw = ImageDraw.Draw(overlay)
1364
-
1365
- # Draw semi-transparent gray rectangles for invalid regions
1366
- mask_color = (128, 128, 128, 120) # Gray with 47% opacity
1367
-
1368
- # Top region (if any)
1369
- if crop_y_start_orig > 0:
1370
- draw.rectangle([0, 0, W, crop_y_start_orig], fill=mask_color)
1371
-
1372
- # Bottom region (if any)
1373
- if crop_y_end_orig < H:
1374
- draw.rectangle([0, crop_y_end_orig, W, H], fill=mask_color)
1375
-
1376
- # Left region (if any)
1377
- if crop_x_start_orig > 0:
1378
- draw.rectangle(
1379
- [0, crop_y_start_orig, crop_x_start_orig, crop_y_end_orig], fill=mask_color
1380
- )
1381
-
1382
- # Right region (if any)
1383
- if crop_x_end_orig < W:
1384
- draw.rectangle(
1385
- [crop_x_end_orig, crop_y_start_orig, W, crop_y_end_orig], fill=mask_color
1386
- )
1387
-
1388
- # Draw border around valid region
1389
- border_color = (255, 165, 0, 180) # Orange with transparency
1390
- draw.rectangle(
1391
- [
1392
- crop_x_start_orig,
1393
- crop_y_start_orig,
1394
- crop_x_end_orig - 1,
1395
- crop_y_end_orig - 1,
1396
- ],
1397
- outline=border_color,
1398
- width=3,
1399
- )
1400
-
1401
- # Convert base image to RGBA and composite
1402
- if img_copy.mode != "RGBA":
1403
- img_copy = img_copy.convert("RGBA")
1404
-
1405
- img_with_overlay = Image.alpha_composite(img_copy, overlay)
1406
-
1407
- # Convert back to RGB
1408
- return img_with_overlay.convert("RGB")
1409
 
1410
 
1411
  def add_click_marker(image, x_norm, y_norm, color="red", radius=8, pair_id=None):
@@ -1463,6 +1386,18 @@ def add_click_marker(image, x_norm, y_norm, color="red", radius=8, pair_id=None)
1463
  return image
1464
 
1465
 
 
 
 
 
 
 
 
 
 
 
 
 
1466
  def update_images_display(files):
1467
  """Update the displayed images when files are uploaded."""
1468
 
@@ -1478,10 +1413,8 @@ def update_images_display(files):
1478
  return None, None, "Please upload exactly two images."
1479
 
1480
  try:
1481
- # Apply the EXIF orientation of phone photos, as DUSt3R's load_images does.
1482
  _session()["uploaded_images"] = [
1483
- ImageOps.exif_transpose(Image.open(getattr(f, "name", f))).convert("RGB")
1484
- for f in files[:2]
1485
  ]
1486
  _session()["current_image_pair"] = [
1487
  _session()["uploaded_images"][0],
@@ -1494,14 +1427,13 @@ def update_images_display(files):
1494
  f"Loaded {len(_session().uploaded_images)} images, sizes: {[img.size for img in _session().uploaded_images]}"
1495
  )
1496
 
1497
- # Add crop region overlay to show valid prompt area
1498
- img1_display = add_crop_region_overlay(_session()["current_image_pair"][0])
1499
- img2_display = add_crop_region_overlay(_session()["current_image_pair"][1])
1500
 
1501
  return (
1502
  img1_display,
1503
  img2_display,
1504
- "Click matching points in both images. Choose points inside the orange crop border.",
1505
  )
1506
  except Exception as e:
1507
  debug_log(f"Error loading images: {e}")
@@ -1537,15 +1469,13 @@ def augment_second_image():
1537
 
1538
  debug_log(f"Augmentation toggled: {_session().image_augmented}")
1539
 
1540
- # Add crop region overlay
1541
- img1_display = add_crop_region_overlay(_session()["current_image_pair"][0])
1542
- img2_display = add_crop_region_overlay(_session()["current_image_pair"][1])
1543
 
1544
  return (
1545
  img1_display,
1546
  img2_display,
1547
- f"{status}\nCorrespondences cleared. Click new points.\n"
1548
- "⚠️ Gray overlay = invalid prompt region, Orange border = valid area",
1549
  )
1550
  except Exception as e:
1551
  debug_log(f"Error in augment_second_image: {e}")
@@ -1607,9 +1537,8 @@ def add_correspondence_point(x_norm, y_norm, image_idx):
1607
  pairs.append(((p1["x"], p1["y"]), (p2["x"], p2["y"])))
1608
 
1609
  # Create marked versions of images
1610
- # First add crop region overlay to show valid prompt area
1611
- img1_marked = add_crop_region_overlay(_session()["current_image_pair"][0])
1612
- img2_marked = add_crop_region_overlay(_session()["current_image_pair"][1])
1613
 
1614
  # Add markers to image 1 - each pair gets same color
1615
  for i, point in enumerate(img1_points):
@@ -1640,8 +1569,6 @@ def add_correspondence_point(x_norm, y_norm, image_idx):
1640
  # Generate status text
1641
  aug_status = " [FLIPPED 180°]" if _session()["image_augmented"] else ""
1642
  status_text = "Matching colors mark correspondence pairs\n"
1643
- status_text += "⚠️ Gray overlay = invalid prompt region (will be cropped)\n"
1644
- status_text += " Orange border = valid prompt area\n"
1645
  status_text += f"Image 2 status: {aug_status}\n"
1646
  status_text += f"Total clicks: {len(_session().correspondence_points)}\n"
1647
  status_text += f"Image 1 points: {len(img1_points)}\n"
@@ -1801,20 +1728,15 @@ def clear_correspondences():
1801
 
1802
  if _session()["current_image_pair"][0] is not None:
1803
  aug_status = " [FLIPPED 180°]" if _session()["image_augmented"] else ""
1804
- debug_log(
1805
- "Cleared correspondences, returning original images with crop overlay"
1806
- )
1807
 
1808
- # Add crop region overlay
1809
- img1_display = add_crop_region_overlay(_session()["current_image_pair"][0])
1810
- img2_display = add_crop_region_overlay(_session()["current_image_pair"][1])
1811
 
1812
  return (
1813
  img1_display,
1814
  img2_display,
1815
- f"Cleared all correspondence points.\n"
1816
- f"⚠️ Gray overlay = invalid prompt region, Orange border = valid area\n"
1817
- f"Image 2: {aug_status}",
1818
  )
1819
  else:
1820
  return None, None, "No images loaded."
@@ -2962,8 +2884,8 @@ def load_examples(manifest_path):
2962
  for record in records:
2963
  for key in ("image1", "image2"):
2964
  record[key] = str((manifest_path.parent / record[key]).resolve())
2965
- if not Path(record[key]).is_file():
2966
- raise FileNotFoundError(record[key])
2967
  return records
2968
 
2969
 
@@ -3057,37 +2979,51 @@ def create_interface(
3057
  gr.Markdown(
3058
  "# Click3R\nChoose an example or upload two images. Reconstruct first, then click matching points in the two images and reconstruct again. Matching colors identify each correspondence."
3059
  )
3060
- with gr.Row():
3061
  example_picker = gr.Dropdown(
3062
  choices=[(e["title"], e["id"]) for e in examples],
3063
  label="Example pair",
3064
  value=examples[0]["id"] if examples else None,
3065
  )
 
 
 
 
 
 
 
3066
  suggested_button = gr.Button("Add suggested clicks")
3067
- files = gr.File(
3068
- label="Upload your image pair",
3069
- file_count="multiple",
3070
- file_types=["image"],
3071
- type="filepath",
3072
- )
3073
  with gr.Row():
3074
  left = gr.Image(label="Image 1", type="pil", interactive=False)
3075
  right = gr.Image(label="Image 2", type="pil", interactive=False)
3076
- status = gr.Textbox(label="Correspondences", interactive=False, lines=4)
 
 
3077
  with gr.Row():
3078
  clear = gr.Button("Clear clicks")
3079
  run = gr.Button("Reconstruct", variant="primary")
3080
  with gr.Accordion("Display settings", open=False):
3081
  confidence = gr.Slider(0, 99, value=10, label="Confidence percentile")
3082
  camera_size = gr.Slider(0.05, 0.5, value=0.2, label="Camera size")
 
3083
  pointcloud = gr.Checkbox(value=True, label="Point cloud")
3084
  overlay = gr.Checkbox(value=True, label="Show clicks in 3D")
 
3085
  with gr.Row():
3086
- original = gr.Model3D(
3087
- label="DUSt3R", height=560, visible=original_model is not None
 
3088
  )
3089
- prompted = gr.Model3D(label="Click3R", height=560)
3090
- results = gr.Textbox(label="Reconstruction", interactive=False, lines=4)
 
 
 
 
 
 
 
 
3091
  outputs = [left, right, status, state]
3092
  edit_queue = dict(concurrency_limit=1, concurrency_id="session_edits")
3093
  # Only user changes count: an upload clears the picker without reloading.
@@ -3131,7 +3067,11 @@ def main():
3131
  "--examples", default=str(Path(REPO_ROOT) / "assets/examples/manifest.json")
3132
  )
3133
  parser.add_argument("--server-name", default="127.0.0.1")
3134
- parser.add_argument("--server-port", type=int, default=7860)
 
 
 
 
3135
  parser.add_argument("--share", action="store_true")
3136
  parser.add_argument("--debug", action="store_true")
3137
  args = parser.parse_args()
 
60
  session_edit as session_edit,
61
  session_scope as session_scope,
62
  )
63
+ from click3r.demo.display import ReconstructionViewer, point_size_control
64
  from click3r.demo.views import prepare_model_view
65
  from click3r.demo.results import ReconstructionResult
66
 
67
  DEBUG = False
68
  DISPLAY_LONG_SIDE = 640 # Pixels; small inputs would otherwise be hard to click.
69
+ CLICK_MARKER_SCALE = 0.03 # 3D click marker radius per unit of framed scene size.
70
  VIEWER_FOV = 0.8 # Radians; vertical field of view of Gradio's 3D viewer.
71
+ EXAMPLE_IMAGES = {} # Decoded example inputs by resolved path, filled by load_examples.
72
  # Optional comparison models are imported lazily by their load functions.
73
  VGGT_MODEL_NAME = "facebook/VGGT-1B"
74
  PI3_MODEL_NAME = "yyfz233/Pi3X"
 
995
  return None, None
996
 
997
 
998
+ def scene_clouds(scene):
999
+ """World-frame vertices of the reconstructed points or mesh, by geometry name."""
1000
+ clouds = {}
1001
  for node in scene.graph.nodes_geometry:
1002
  transform, name = scene.graph[node]
1003
+ geometry = scene.geometry[name]
1004
+ if isinstance(geometry, trimesh.PointCloud) or len(geometry.vertices) > 5000:
1005
+ clouds[name] = trimesh.transform_points(
1006
+ np.asarray(geometry.vertices), transform
1007
+ )
1008
+ return clouds
1009
+
1010
+
1011
+ def inlier_bounds(points):
1012
+ """Median point and the distance beyond which display points count as outliers."""
1013
+ center = np.median(points, axis=0)
1014
+ return center, 2 * np.percentile(np.linalg.norm(points - center, axis=1), 90)
1015
+
1016
+
1017
+ def click_marker_radius(scene):
1018
+ """Marker radius as a fixed share of the scene size the viewer frames.
1019
+
1020
+ Sizing by the point density at the clicks made markers large wherever a model
1021
+ reconstructed the clicked surface sparsely, so views and examples differed.
1022
+ """
1023
+ points = np.concatenate(list(scene_clouds(scene).values()))
1024
+ center, limit = inlier_bounds(points)
1025
+ distance = np.linalg.norm(points - center, axis=1)
1026
+ return CLICK_MARKER_SCALE * np.percentile(distance[distance <= limit], 75)
1027
 
1028
 
1029
  def frame_model_for_viewer(model_path):
1030
+ """Drop far outliers and embed the initial camera orbit in the display GLB.
1031
 
1032
  The viewer aims at the center of the model's bounding box from 1.1 times its
1033
  diagonal, so a few far points leave the scene small and off center. Removing
 
1036
  if not model_path:
1037
  return model_path
1038
  scene = trimesh.load(model_path)
1039
+ clouds = scene_clouds(scene)
1040
+ center, limit = inlier_bounds(np.concatenate(list(clouds.values())))
 
 
 
 
 
 
 
 
 
1041
  kept = []
1042
  for name, vertices in clouds.items():
1043
  keep = np.linalg.norm(vertices - center, axis=1) <= limit
 
1050
  else:
1051
  geometry.update_vertices(keep)
1052
  kept.append(vertices[keep])
 
1053
  target = scene.bounds.mean(axis=0)
1054
  radius = np.percentile(
1055
  np.linalg.norm(np.concatenate(kept) - target, axis=1), 75
1056
  ) / np.tan(VIEWER_FOV / 2)
1057
+ # GLB scene extras survive Gradio's file-cache normalization. Custom fields
1058
+ # added to FileData itself are discarded by that normalization.
1059
+ scene.metadata["camera_position"] = [None, None, float(radius)]
1060
+ scene.export(model_path)
1061
+ return model_path
1062
 
1063
 
1064
  def overlay_correspondences_on_model(
 
1090
  radius = click_marker_radius(
1091
  base_scene
1092
  if isinstance(base_scene, trimesh.Scene)
1093
+ else trimesh.Scene(base_scene)
 
1094
  )
1095
 
1096
  debug_log(f"Sphere radius: {radius:.4f}")
 
1324
  return round(image.width * scale), round(image.height * scale)
1325
 
1326
 
1327
+ def prepare_display_image(image):
1328
+ """Resize an input for clicking without adding a crop border or shading."""
 
 
 
 
 
 
 
 
 
 
1329
  if image is None:
1330
  return None
1331
+ return image.resize(display_size(image), Image.LANCZOS)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1332
 
1333
 
1334
  def add_click_marker(image, x_norm, y_norm, color="red", radius=8, pair_id=None):
 
1386
  return image
1387
 
1388
 
1389
+ def read_input_image(path):
1390
+ """Apply the EXIF orientation of phone photos, as DUSt3R's load_images does."""
1391
+ return ImageOps.exif_transpose(Image.open(path)).convert("RGB")
1392
+
1393
+
1394
+ def open_input_image(path):
1395
+ """An input image, from memory for the bundled examples."""
1396
+ if path in EXAMPLE_IMAGES:
1397
+ return EXAMPLE_IMAGES[path].copy()
1398
+ return read_input_image(path)
1399
+
1400
+
1401
  def update_images_display(files):
1402
  """Update the displayed images when files are uploaded."""
1403
 
 
1413
  return None, None, "Please upload exactly two images."
1414
 
1415
  try:
 
1416
  _session()["uploaded_images"] = [
1417
+ open_input_image(getattr(f, "name", f)) for f in files[:2]
 
1418
  ]
1419
  _session()["current_image_pair"] = [
1420
  _session()["uploaded_images"][0],
 
1427
  f"Loaded {len(_session().uploaded_images)} images, sizes: {[img.size for img in _session().uploaded_images]}"
1428
  )
1429
 
1430
+ img1_display = prepare_display_image(_session()["current_image_pair"][0])
1431
+ img2_display = prepare_display_image(_session()["current_image_pair"][1])
 
1432
 
1433
  return (
1434
  img1_display,
1435
  img2_display,
1436
+ "Click matching points in both images.",
1437
  )
1438
  except Exception as e:
1439
  debug_log(f"Error loading images: {e}")
 
1469
 
1470
  debug_log(f"Augmentation toggled: {_session().image_augmented}")
1471
 
1472
+ img1_display = prepare_display_image(_session()["current_image_pair"][0])
1473
+ img2_display = prepare_display_image(_session()["current_image_pair"][1])
 
1474
 
1475
  return (
1476
  img1_display,
1477
  img2_display,
1478
+ f"{status}\nCorrespondences cleared. Click new points.",
 
1479
  )
1480
  except Exception as e:
1481
  debug_log(f"Error in augment_second_image: {e}")
 
1537
  pairs.append(((p1["x"], p1["y"]), (p2["x"], p2["y"])))
1538
 
1539
  # Create marked versions of images
1540
+ img1_marked = prepare_display_image(_session()["current_image_pair"][0])
1541
+ img2_marked = prepare_display_image(_session()["current_image_pair"][1])
 
1542
 
1543
  # Add markers to image 1 - each pair gets same color
1544
  for i, point in enumerate(img1_points):
 
1569
  # Generate status text
1570
  aug_status = " [FLIPPED 180°]" if _session()["image_augmented"] else ""
1571
  status_text = "Matching colors mark correspondence pairs\n"
 
 
1572
  status_text += f"Image 2 status: {aug_status}\n"
1573
  status_text += f"Total clicks: {len(_session().correspondence_points)}\n"
1574
  status_text += f"Image 1 points: {len(img1_points)}\n"
 
1728
 
1729
  if _session()["current_image_pair"][0] is not None:
1730
  aug_status = " [FLIPPED 180°]" if _session()["image_augmented"] else ""
1731
+ debug_log("Cleared correspondences, returning unmarked images")
 
 
1732
 
1733
+ img1_display = prepare_display_image(_session()["current_image_pair"][0])
1734
+ img2_display = prepare_display_image(_session()["current_image_pair"][1])
 
1735
 
1736
  return (
1737
  img1_display,
1738
  img2_display,
1739
+ f"Cleared all correspondence points.\nImage 2: {aug_status}",
 
 
1740
  )
1741
  else:
1742
  return None, None, "No images loaded."
 
2884
  for record in records:
2885
  for key in ("image1", "image2"):
2886
  record[key] = str((manifest_path.parent / record[key]).resolve())
2887
+ # Decode once at startup; reopening files per selection is slow on network disks.
2888
+ EXAMPLE_IMAGES[record[key]] = read_input_image(record[key])
2889
  return records
2890
 
2891
 
 
2979
  gr.Markdown(
2980
  "# Click3R\nChoose an example or upload two images. Reconstruct first, then click matching points in the two images and reconstruct again. Matching colors identify each correspondence."
2981
  )
2982
+ with gr.Row(equal_height=True):
2983
  example_picker = gr.Dropdown(
2984
  choices=[(e["title"], e["id"]) for e in examples],
2985
  label="Example pair",
2986
  value=examples[0]["id"] if examples else None,
2987
  )
2988
+ files = gr.File(
2989
+ label="Upload your image pair",
2990
+ file_count="multiple",
2991
+ file_types=["image"],
2992
+ type="filepath",
2993
+ )
2994
+ with gr.Row():
2995
  suggested_button = gr.Button("Add suggested clicks")
 
 
 
 
 
 
2996
  with gr.Row():
2997
  left = gr.Image(label="Image 1", type="pil", interactive=False)
2998
  right = gr.Image(label="Image 2", type="pil", interactive=False)
2999
+ status = gr.Textbox(
3000
+ label="Correspondences", interactive=False, lines=4, visible=False
3001
+ )
3002
  with gr.Row():
3003
  clear = gr.Button("Clear clicks")
3004
  run = gr.Button("Reconstruct", variant="primary")
3005
  with gr.Accordion("Display settings", open=False):
3006
  confidence = gr.Slider(0, 99, value=10, label="Confidence percentile")
3007
  camera_size = gr.Slider(0.05, 0.5, value=0.2, label="Camera size")
3008
+ point_size_control()
3009
  pointcloud = gr.Checkbox(value=True, label="Point cloud")
3010
  overlay = gr.Checkbox(value=True, label="Show clicks in 3D")
3011
+ show_info = gr.Checkbox(value=False, label="Show information panels")
3012
  with gr.Row():
3013
+ original = ReconstructionViewer(
3014
+ label="DUSt3R",
3015
+ visible=original_model is not None,
3016
  )
3017
+ prompted = ReconstructionViewer(label="Click3R")
3018
+ results = gr.Textbox(
3019
+ label="Reconstruction", interactive=False, lines=4, visible=False
3020
+ )
3021
+ show_info.change(
3022
+ lambda visible: (gr.update(visible=visible), gr.update(visible=visible)),
3023
+ [show_info],
3024
+ [status, results],
3025
+ queue=False,
3026
+ )
3027
  outputs = [left, right, status, state]
3028
  edit_queue = dict(concurrency_limit=1, concurrency_id="session_edits")
3029
  # Only user changes count: an upload clears the picker without reloading.
 
3067
  "--examples", default=str(Path(REPO_ROOT) / "assets/examples/manifest.json")
3068
  )
3069
  parser.add_argument("--server-name", default="127.0.0.1")
3070
+ parser.add_argument(
3071
+ "--server-port",
3072
+ type=int,
3073
+ help="Default: the first free port from 7860",
3074
+ )
3075
  parser.add_argument("--share", action="store_true")
3076
  parser.add_argument("--debug", action="store_true")
3077
  args = parser.parse_args()
click3r/demo/display.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reconstruction viewers and display controls for the interactive demo."""
2
+
3
+ from functools import lru_cache
4
+ import json
5
+ from pathlib import Path
6
+ import re
7
+
8
+ import gradio as gr
9
+ from gradio.data_classes import FileData
10
+
11
+ DEFAULT_POINT_SIZE = 5 # Pixels; size 1 leaves visible gaps between points.
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def viewer_javascript():
16
+ """Use the Babylon viewer distributed with the pinned Gradio installation."""
17
+ assets = Path(gr.__file__).parent / "templates/frontend/assets"
18
+ library = next(
19
+ asset
20
+ for asset in assets.glob("lib-*.js")
21
+ if "CreateViewerForCanvas" in asset.read_text()
22
+ )
23
+ dependencies = re.findall(r'from["\']\./([^"\']+\.js)["\']', library.read_text())
24
+ return (
25
+ Path(__file__)
26
+ .with_name("viewer.js")
27
+ .read_text()
28
+ .replace("__VIEWER_LIBRARY__", json.dumps(library.name))
29
+ .replace("__VIEWER_DEPENDENCIES__", json.dumps(dependencies))
30
+ .replace("__DEFAULT_POINT_SIZE__", json.dumps(DEFAULT_POINT_SIZE))
31
+ )
32
+
33
+
34
+ class ReconstructionViewer(gr.HTML):
35
+ """A downloadable GLB viewer with explicit WebGL point-size support.
36
+
37
+ Babylon's default engine selection can choose WebGPU, whose point primitives
38
+ have fixed size. Use its public viewer API with WebGL and retain Gradio's
39
+ FileData processing and the framing metadata embedded in the GLB.
40
+ """
41
+
42
+ data_model = FileData
43
+
44
+ def __init__(self, *, label, visible=True, **props):
45
+ super().__init__(
46
+ value=None,
47
+ label=label,
48
+ visible=visible,
49
+ scale=1,
50
+ min_width=160,
51
+ elem_classes="click3r-reconstruction",
52
+ html_template="""
53
+ <div class="viewer">
54
+ <div class="toolbar">
55
+ <span class="title">${label}</span>
56
+ <button class="reset" type="button" aria-label="Reset view">Reset view</button>
57
+ <a class="download" aria-label="Download reconstruction" download>Download</a>
58
+ <button class="fullscreen" type="button" aria-label="Fullscreen">Fullscreen</button>
59
+ </div>
60
+ <canvas aria-label="${label} reconstruction"></canvas>
61
+ <div class="notice" role="status">Run reconstruction to view the scene.</div>
62
+ </div>
63
+ """,
64
+ css_template="""
65
+ .viewer { position: relative; height: 560px; overflow: hidden;
66
+ border: 1px solid var(--border-color-primary);
67
+ border-radius: var(--block-radius); background: white; }
68
+ .viewer:fullscreen { height: 100vh; }
69
+ canvas { display: block; width: 100%; height: 100%; touch-action: none; }
70
+ .toolbar { position: absolute; top: 0; left: 0; right: 0; z-index: 1;
71
+ display: flex; align-items: center; gap: 8px; padding: 5px 8px;
72
+ background: var(--background-fill-primary); font-size: 12px; }
73
+ .title { flex: 1; }
74
+ button, a { cursor: pointer; color: var(--body-text-color);
75
+ background: transparent; border: 0; padding: 2px;
76
+ font: inherit; text-decoration: none; }
77
+ a:not([href]) { pointer-events: none; opacity: 0.5; }
78
+ .notice { position: absolute; inset: 40px 16px 16px; display: grid;
79
+ place-items: center; text-align: center; pointer-events: none;
80
+ color: var(--body-text-color-subdued); }
81
+ .notice[hidden] { display: none; }
82
+ """,
83
+ js_on_load=viewer_javascript(),
84
+ **props,
85
+ )
86
+
87
+ def preprocess(self, payload):
88
+ return payload.path if payload is not None else None
89
+
90
+ def postprocess(self, value):
91
+ if value is None:
92
+ return None
93
+ return FileData(path=str(value), orig_name=Path(value).name)
94
+
95
+ def api_info(self):
96
+ return gr.components.Component.api_info(self)
97
+
98
+
99
+ def point_size_control():
100
+ """Update both WebGL viewers without rerunning reconstruction."""
101
+ slider = gr.Slider(
102
+ 1,
103
+ 10,
104
+ value=DEFAULT_POINT_SIZE,
105
+ step=0.5,
106
+ label="Point size (px)",
107
+ info="Larger points fill visual gaps in both 3D views.",
108
+ )
109
+ controller = gr.HTML(
110
+ value=DEFAULT_POINT_SIZE,
111
+ html_template="",
112
+ js_on_load="""
113
+ function update() {
114
+ document.getElementById("click3r-point-size").dataset.size = props.value;
115
+ document.dispatchEvent(new CustomEvent("click3r:point-size", {detail: props.value}));
116
+ }
117
+ watch("value", update);
118
+ update();
119
+ """,
120
+ elem_id="click3r-point-size",
121
+ visible="hidden",
122
+ )
123
+ slider.input(None, [slider], [controller], js="(size) => size", queue=False)
124
+ return slider
click3r/demo/viewer.js ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // WebGPU point primitives have fixed size. Request WebGL through Babylon's
2
+ // public viewer API so the point-size control works in Chromium and Arc too.
3
+ const root = (window.gradio_config.root || location.origin).replace(/\/$/, "");
4
+ // Fetch dependencies together rather than discovering them over serial requests.
5
+ const preloaded = new Set(Array.from(
6
+ document.querySelectorAll('link[rel="modulepreload"]'), link => link.href,
7
+ ));
8
+ for (const name of __VIEWER_DEPENDENCIES__) {
9
+ const url = `${root}/assets/${name}`;
10
+ if (preloaded.has(url)) continue;
11
+ const link = document.createElement("link");
12
+ link.rel = "modulepreload";
13
+ link.href = url;
14
+ document.head.appendChild(link);
15
+ preloaded.add(url);
16
+ }
17
+ const library = import(`${root}/assets/` + __VIEWER_LIBRARY__);
18
+ const canvas = element.querySelector("canvas");
19
+ const notice = element.querySelector(".notice");
20
+ const download = element.querySelector(".download");
21
+ let details;
22
+ let pointSize = Number(document.getElementById("click3r-point-size")?.dataset.size || __DEFAULT_POINT_SIZE__);
23
+ let version = 0;
24
+ let loadedURL;
25
+ let initialCamera;
26
+ let disposed = false;
27
+
28
+ function applyPointSize() {
29
+ if (!details) return;
30
+ for (const material of details.scene.materials) {
31
+ if (material.pointsCloud) material.pointSize = pointSize;
32
+ }
33
+ // Schedule a complete frame through the viewer, including begin/end-frame.
34
+ details.markSceneMutated();
35
+ }
36
+
37
+ function applyCamera(position) {
38
+ if (!details || !position) return;
39
+ const camera = details.camera;
40
+ if (position[0] != null) camera.alpha = position[0] * Math.PI / 180;
41
+ if (position[1] != null) camera.beta = position[1] * Math.PI / 180;
42
+ if (position[2] != null) camera.radius = position[2];
43
+ // Match Gradio's orbit controls.
44
+ camera.lowerRadiusLimit = 0.1;
45
+ camera.wheelPrecision = 250 / camera.radius;
46
+ camera.panningSensibility = 10000 / camera.radius;
47
+ camera.storeState();
48
+ details.markSceneMutated();
49
+ }
50
+
51
+ async function cameraPosition(blob) {
52
+ const header = new DataView(await blob.slice(0, 20).arrayBuffer());
53
+ if (header.getUint32(0, true) !== 0x46546c67) return null;
54
+ const length = header.getUint32(12, true);
55
+ const gltf = JSON.parse(await blob.slice(20, 20 + length).text());
56
+ return gltf.scenes?.[gltf.scene || 0]?.extras?.camera_position;
57
+ }
58
+
59
+ // Initialize while the user selects images; avoid first-use renderer startup.
60
+ const ready = library.then(async ({CreateViewerForCanvas}) => {
61
+ const viewer = await CreateViewerForCanvas(canvas, {
62
+ engine: "WebGL",
63
+ clearColor: [1, 1, 1, 1],
64
+ useRightHandedSystem: true,
65
+ cameraAutoOrbit: {enabled: false},
66
+ onInitialized: value => { details = value; },
67
+ });
68
+ return viewer;
69
+ });
70
+
71
+ async function showModel() {
72
+ const current = ++version;
73
+ const file = props.value;
74
+ if (!file) {
75
+ canvas.style.visibility = "hidden";
76
+ notice.hidden = false;
77
+ notice.textContent = "Run reconstruction to view the scene.";
78
+ download.removeAttribute("href");
79
+ return;
80
+ }
81
+ const url = file.url;
82
+ try {
83
+ const viewer = await ready;
84
+ if (disposed || current !== version) return;
85
+ if (url !== loadedURL) {
86
+ notice.hidden = false;
87
+ notice.textContent = "Loading reconstruction…";
88
+ const response = await fetch(url);
89
+ if (!response.ok) throw new Error(`Model download failed: ${response.status}`);
90
+ const blob = await response.blob();
91
+ if (disposed || current !== version) return;
92
+ initialCamera = await cameraPosition(blob);
93
+ await viewer.loadModel(new File([blob], file.orig_name || "reconstruction.glb"));
94
+ if (disposed || current !== version) return;
95
+ loadedURL = url;
96
+ }
97
+ canvas.style.visibility = "visible";
98
+ notice.hidden = true;
99
+ download.href = url;
100
+ download.download = file.orig_name || "reconstruction.glb";
101
+ applyPointSize();
102
+ applyCamera(initialCamera);
103
+ } catch (error) {
104
+ notice.hidden = false;
105
+ notice.textContent = "The 3D view could not load. Please try reconstructing again.";
106
+ console.error("Reconstruction viewer:", error);
107
+ }
108
+ }
109
+
110
+ function updatePointSize(event) {
111
+ pointSize = Number(event.detail);
112
+ applyPointSize();
113
+ }
114
+ document.addEventListener("click3r:point-size", updatePointSize);
115
+ element.querySelector(".reset").addEventListener("click", () => {
116
+ details?.camera.restoreState();
117
+ details?.markSceneMutated();
118
+ });
119
+ element.querySelector(".fullscreen").addEventListener("click", () => {
120
+ if (document.fullscreenElement) void document.exitFullscreen();
121
+ else void element.querySelector(".viewer").requestFullscreen();
122
+ });
123
+ watch("value", () => { void showModel(); });
124
+ void showModel();
125
+ const cleanup = setInterval(() => {
126
+ if (element.isConnected) return;
127
+ disposed = true;
128
+ clearInterval(cleanup);
129
+ document.removeEventListener("click3r:point-size", updatePointSize);
130
+ void ready.then(viewer => { viewer.dispose(); });
131
+ }, 1000);