Spaces:
Running on Zero
Running on Zero
Update the demo viewers, click markers and examples
Browse filesWebGL 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 +1 -3
- assets/examples/co3d_car/view1.png +0 -0
- assets/examples/co3d_car/view2.png +0 -0
- assets/examples/co3d_couch/view1.png +0 -0
- assets/examples/co3d_couch/view2.png +0 -0
- assets/examples/manifest.json +0 -280
- click3r/demo/app.py +100 -160
- click3r/demo/display.py +124 -0
- click3r/demo/viewer.js +131 -0
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
|
| 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 |
-
|
| 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
|
| 997 |
-
"""
|
| 998 |
-
|
| 999 |
for node in scene.graph.nodes_geometry:
|
| 1000 |
transform, name = scene.graph[node]
|
| 1001 |
-
|
| 1002 |
-
if len(vertices) >
|
| 1003 |
-
|
| 1004 |
-
|
| 1005 |
-
|
| 1006 |
-
|
| 1007 |
-
|
| 1008 |
-
|
| 1009 |
-
|
| 1010 |
-
|
| 1011 |
-
|
| 1012 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1013 |
|
| 1014 |
|
| 1015 |
def frame_model_for_viewer(model_path):
|
| 1016 |
-
"""Drop far outliers
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
|
| 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 |
-
|
| 1498 |
-
|
| 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.
|
| 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 |
-
|
| 1541 |
-
|
| 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.
|
| 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 |
-
|
| 1611 |
-
|
| 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 |
-
|
| 1809 |
-
|
| 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.\
|
| 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 |
-
|
| 2966 |
-
|
| 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(
|
|
|
|
|
|
|
| 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 =
|
| 3087 |
-
label="DUSt3R",
|
|
|
|
| 3088 |
)
|
| 3089 |
-
prompted =
|
| 3090 |
-
results = gr.Textbox(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|