Upload folder using huggingface_hub
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +1 -0
- .gitignore +6 -0
- LICENSE.md +408 -0
- README.md +66 -0
- REPRODUCE.md +121 -0
- Writeup.pdf +3 -0
- checkpoint.pt +3 -0
- checkpoint_8192.pt +3 -0
- configs/base.json +39 -0
- edge_classifier.py +208 -0
- experiments/ptv3/README.md +32 -0
- experiments/ptv3/checkpoint_ptv3_8k.pt +3 -0
- experiments/ptv3/model_with_ptv3.py +698 -0
- experiments/ptv3/ptv3_code/__init__.py +0 -0
- experiments/ptv3/ptv3_code/encoder_wrapper.py +174 -0
- experiments/ptv3/ptv3_code/model.py +982 -0
- experiments/ptv3/ptv3_code/serialization/__init__.py +8 -0
- experiments/ptv3/ptv3_code/serialization/default.py +59 -0
- experiments/ptv3/ptv3_code/serialization/hilbert.py +303 -0
- experiments/ptv3/ptv3_code/serialization/z_order.py +126 -0
- experiments/ptv3/train_args.json +62 -0
- pnet_class_2026.pth +3 -0
- requirements.txt +12 -0
- s23dr_2026_example/__init__.py +0 -0
- s23dr_2026_example/attention.py +141 -0
- s23dr_2026_example/bad_samples.txt +156 -0
- s23dr_2026_example/cache_scenes.py +282 -0
- s23dr_2026_example/color_mappings.py +183 -0
- s23dr_2026_example/data.py +230 -0
- s23dr_2026_example/losses.py +215 -0
- s23dr_2026_example/make_sampled_cache.py +159 -0
- s23dr_2026_example/model.py +519 -0
- s23dr_2026_example/point_fusion.py +554 -0
- s23dr_2026_example/postprocess_v2.py +39 -0
- s23dr_2026_example/segment_postprocess.py +77 -0
- s23dr_2026_example/sinkhorn.py +126 -0
- s23dr_2026_example/tokenizer.py +88 -0
- s23dr_2026_example/train.py +530 -0
- s23dr_2026_example/varifold.py +53 -0
- s23dr_2026_example/wire_varifold_kernels.py +168 -0
- script.py +432 -0
- solution.py +1210 -0
- training/edge_patch.py +237 -0
- training/fast_pointnet_class.py +317 -0
- training/gen_edge_dataset.py +394 -0
- training/gen_routing_dataset.py +338 -0
- training/gen_sampled_16384.py +84 -0
- training/gen_vertex_dataset.py +408 -0
- training/hc_helpers.py +31 -0
- training/local_dataset.py +92 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
Writeup.pdf filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.pyc
|
| 3 |
+
*.pyo
|
| 4 |
+
.DS_Store
|
| 5 |
+
*.egg-info/
|
| 6 |
+
.ipynb_checkpoints/
|
LICENSE.md
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Attribution-NonCommercial 4.0 International
|
| 2 |
+
|
| 3 |
+
=======================================================================
|
| 4 |
+
|
| 5 |
+
Creative Commons Corporation ("Creative Commons") is not a law firm and
|
| 6 |
+
does not provide legal services or legal advice. Distribution of
|
| 7 |
+
Creative Commons public licenses does not create a lawyer-client or
|
| 8 |
+
other relationship. Creative Commons makes its licenses and related
|
| 9 |
+
information available on an "as-is" basis. Creative Commons gives no
|
| 10 |
+
warranties regarding its licenses, any material licensed under their
|
| 11 |
+
terms and conditions, or any related information. Creative Commons
|
| 12 |
+
disclaims all liability for damages resulting from their use to the
|
| 13 |
+
fullest extent possible.
|
| 14 |
+
|
| 15 |
+
Using Creative Commons Public Licenses
|
| 16 |
+
|
| 17 |
+
Creative Commons public licenses provide a standard set of terms and
|
| 18 |
+
conditions that creators and other rights holders may use to share
|
| 19 |
+
original works of authorship and other material subject to copyright
|
| 20 |
+
and certain other rights specified in the public license below. The
|
| 21 |
+
following considerations are for informational purposes only, are not
|
| 22 |
+
exhaustive, and do not form part of our licenses.
|
| 23 |
+
|
| 24 |
+
Considerations for licensors: Our public licenses are
|
| 25 |
+
intended for use by those authorized to give the public
|
| 26 |
+
permission to use material in ways otherwise restricted by
|
| 27 |
+
copyright and certain other rights. Our licenses are
|
| 28 |
+
irrevocable. Licensors should read and understand the terms
|
| 29 |
+
and conditions of the license they choose before applying it.
|
| 30 |
+
Licensors should also secure all rights necessary before
|
| 31 |
+
applying our licenses so that the public can reuse the
|
| 32 |
+
material as expected. Licensors should clearly mark any
|
| 33 |
+
material not subject to the license. This includes other CC-
|
| 34 |
+
licensed material, or material used under an exception or
|
| 35 |
+
limitation to copyright. More considerations for licensors:
|
| 36 |
+
wiki.creativecommons.org/Considerations_for_licensors
|
| 37 |
+
|
| 38 |
+
Considerations for the public: By using one of our public
|
| 39 |
+
licenses, a licensor grants the public permission to use the
|
| 40 |
+
licensed material under specified terms and conditions. If
|
| 41 |
+
the licensor's permission is not necessary for any reason--for
|
| 42 |
+
example, because of any applicable exception or limitation to
|
| 43 |
+
copyright--then that use is not regulated by the license. Our
|
| 44 |
+
licenses grant only permissions under copyright and certain
|
| 45 |
+
other rights that a licensor has authority to grant. Use of
|
| 46 |
+
the licensed material may still be restricted for other
|
| 47 |
+
reasons, including because others have copyright or other
|
| 48 |
+
rights in the material. A licensor may make special requests,
|
| 49 |
+
such as asking that all changes be marked or described.
|
| 50 |
+
Although not required by our licenses, you are encouraged to
|
| 51 |
+
respect those requests where reasonable. More considerations
|
| 52 |
+
for the public:
|
| 53 |
+
wiki.creativecommons.org/Considerations_for_licensees
|
| 54 |
+
|
| 55 |
+
=======================================================================
|
| 56 |
+
|
| 57 |
+
Creative Commons Attribution-NonCommercial 4.0 International Public
|
| 58 |
+
License
|
| 59 |
+
|
| 60 |
+
By exercising the Licensed Rights (defined below), You accept and agree
|
| 61 |
+
to be bound by the terms and conditions of this Creative Commons
|
| 62 |
+
Attribution-NonCommercial 4.0 International Public License ("Public
|
| 63 |
+
License"). To the extent this Public License may be interpreted as a
|
| 64 |
+
contract, You are granted the Licensed Rights in consideration of Your
|
| 65 |
+
acceptance of these terms and conditions, and the Licensor grants You
|
| 66 |
+
such rights in consideration of benefits the Licensor receives from
|
| 67 |
+
making the Licensed Material available under these terms and
|
| 68 |
+
conditions.
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
Section 1 -- Definitions.
|
| 72 |
+
|
| 73 |
+
a. Adapted Material means material subject to Copyright and Similar
|
| 74 |
+
Rights that is derived from or based upon the Licensed Material
|
| 75 |
+
and in which the Licensed Material is translated, altered,
|
| 76 |
+
arranged, transformed, or otherwise modified in a manner requiring
|
| 77 |
+
permission under the Copyright and Similar Rights held by the
|
| 78 |
+
Licensor. For purposes of this Public License, where the Licensed
|
| 79 |
+
Material is a musical work, performance, or sound recording,
|
| 80 |
+
Adapted Material is always produced where the Licensed Material is
|
| 81 |
+
synched in timed relation with a moving image.
|
| 82 |
+
|
| 83 |
+
b. Adapter's License means the license You apply to Your Copyright
|
| 84 |
+
and Similar Rights in Your contributions to Adapted Material in
|
| 85 |
+
accordance with the terms and conditions of this Public License.
|
| 86 |
+
|
| 87 |
+
c. Copyright and Similar Rights means copyright and/or similar rights
|
| 88 |
+
closely related to copyright including, without limitation,
|
| 89 |
+
performance, broadcast, sound recording, and Sui Generis Database
|
| 90 |
+
Rights, without regard to how the rights are labeled or
|
| 91 |
+
categorized. For purposes of this Public License, the rights
|
| 92 |
+
specified in Section 2(b)(1)-(2) are not Copyright and Similar
|
| 93 |
+
Rights.
|
| 94 |
+
d. Effective Technological Measures means those measures that, in the
|
| 95 |
+
absence of proper authority, may not be circumvented under laws
|
| 96 |
+
fulfilling obligations under Article 11 of the WIPO Copyright
|
| 97 |
+
Treaty adopted on December 20, 1996, and/or similar international
|
| 98 |
+
agreements.
|
| 99 |
+
|
| 100 |
+
e. Exceptions and Limitations means fair use, fair dealing, and/or
|
| 101 |
+
any other exception or limitation to Copyright and Similar Rights
|
| 102 |
+
that applies to Your use of the Licensed Material.
|
| 103 |
+
|
| 104 |
+
f. Licensed Material means the artistic or literary work, database,
|
| 105 |
+
or other material to which the Licensor applied this Public
|
| 106 |
+
License.
|
| 107 |
+
|
| 108 |
+
g. Licensed Rights means the rights granted to You subject to the
|
| 109 |
+
terms and conditions of this Public License, which are limited to
|
| 110 |
+
all Copyright and Similar Rights that apply to Your use of the
|
| 111 |
+
Licensed Material and that the Licensor has authority to license.
|
| 112 |
+
|
| 113 |
+
h. Licensor means the individual(s) or entity(ies) granting rights
|
| 114 |
+
under this Public License.
|
| 115 |
+
|
| 116 |
+
i. NonCommercial means not primarily intended for or directed towards
|
| 117 |
+
commercial advantage or monetary compensation. For purposes of
|
| 118 |
+
this Public License, the exchange of the Licensed Material for
|
| 119 |
+
other material subject to Copyright and Similar Rights by digital
|
| 120 |
+
file-sharing or similar means is NonCommercial provided there is
|
| 121 |
+
no payment of monetary compensation in connection with the
|
| 122 |
+
exchange.
|
| 123 |
+
|
| 124 |
+
j. Share means to provide material to the public by any means or
|
| 125 |
+
process that requires permission under the Licensed Rights, such
|
| 126 |
+
as reproduction, public display, public performance, distribution,
|
| 127 |
+
dissemination, communication, or importation, and to make material
|
| 128 |
+
available to the public including in ways that members of the
|
| 129 |
+
public may access the material from a place and at a time
|
| 130 |
+
individually chosen by them.
|
| 131 |
+
|
| 132 |
+
k. Sui Generis Database Rights means rights other than copyright
|
| 133 |
+
resulting from Directive 96/9/EC of the European Parliament and of
|
| 134 |
+
the Council of 11 March 1996 on the legal protection of databases,
|
| 135 |
+
as amended and/or succeeded, as well as other essentially
|
| 136 |
+
equivalent rights anywhere in the world.
|
| 137 |
+
|
| 138 |
+
l. You means the individual or entity exercising the Licensed Rights
|
| 139 |
+
under this Public License. Your has a corresponding meaning.
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
Section 2 -- Scope.
|
| 143 |
+
|
| 144 |
+
a. License grant.
|
| 145 |
+
|
| 146 |
+
1. Subject to the terms and conditions of this Public License,
|
| 147 |
+
the Licensor hereby grants You a worldwide, royalty-free,
|
| 148 |
+
non-sublicensable, non-exclusive, irrevocable license to
|
| 149 |
+
exercise the Licensed Rights in the Licensed Material to:
|
| 150 |
+
|
| 151 |
+
a. reproduce and Share the Licensed Material, in whole or
|
| 152 |
+
in part, for NonCommercial purposes only; and
|
| 153 |
+
|
| 154 |
+
b. produce, reproduce, and Share Adapted Material for
|
| 155 |
+
NonCommercial purposes only.
|
| 156 |
+
|
| 157 |
+
2. Exceptions and Limitations. For the avoidance of doubt, where
|
| 158 |
+
Exceptions and Limitations apply to Your use, this Public
|
| 159 |
+
License does not apply, and You do not need to comply with
|
| 160 |
+
its terms and conditions.
|
| 161 |
+
|
| 162 |
+
3. Term. The term of this Public License is specified in Section
|
| 163 |
+
6(a).
|
| 164 |
+
|
| 165 |
+
4. Media and formats; technical modifications allowed. The
|
| 166 |
+
Licensor authorizes You to exercise the Licensed Rights in
|
| 167 |
+
all media and formats whether now known or hereafter created,
|
| 168 |
+
and to make technical modifications necessary to do so. The
|
| 169 |
+
Licensor waives and/or agrees not to assert any right or
|
| 170 |
+
authority to forbid You from making technical modifications
|
| 171 |
+
necessary to exercise the Licensed Rights, including
|
| 172 |
+
technical modifications necessary to circumvent Effective
|
| 173 |
+
Technological Measures. For purposes of this Public License,
|
| 174 |
+
simply making modifications authorized by this Section 2(a)
|
| 175 |
+
(4) never produces Adapted Material.
|
| 176 |
+
|
| 177 |
+
5. Downstream recipients.
|
| 178 |
+
|
| 179 |
+
a. Offer from the Licensor -- Licensed Material. Every
|
| 180 |
+
recipient of the Licensed Material automatically
|
| 181 |
+
receives an offer from the Licensor to exercise the
|
| 182 |
+
Licensed Rights under the terms and conditions of this
|
| 183 |
+
Public License.
|
| 184 |
+
|
| 185 |
+
b. No downstream restrictions. You may not offer or impose
|
| 186 |
+
any additional or different terms or conditions on, or
|
| 187 |
+
apply any Effective Technological Measures to, the
|
| 188 |
+
Licensed Material if doing so restricts exercise of the
|
| 189 |
+
Licensed Rights by any recipient of the Licensed
|
| 190 |
+
Material.
|
| 191 |
+
|
| 192 |
+
6. No endorsement. Nothing in this Public License constitutes or
|
| 193 |
+
may be construed as permission to assert or imply that You
|
| 194 |
+
are, or that Your use of the Licensed Material is, connected
|
| 195 |
+
with, or sponsored, endorsed, or granted official status by,
|
| 196 |
+
the Licensor or others designated to receive attribution as
|
| 197 |
+
provided in Section 3(a)(1)(A)(i).
|
| 198 |
+
|
| 199 |
+
b. Other rights.
|
| 200 |
+
|
| 201 |
+
1. Moral rights, such as the right of integrity, are not
|
| 202 |
+
licensed under this Public License, nor are publicity,
|
| 203 |
+
privacy, and/or other similar personality rights; however, to
|
| 204 |
+
the extent possible, the Licensor waives and/or agrees not to
|
| 205 |
+
assert any such rights held by the Licensor to the limited
|
| 206 |
+
extent necessary to allow You to exercise the Licensed
|
| 207 |
+
Rights, but not otherwise.
|
| 208 |
+
|
| 209 |
+
2. Patent and trademark rights are not licensed under this
|
| 210 |
+
Public License.
|
| 211 |
+
|
| 212 |
+
3. To the extent possible, the Licensor waives any right to
|
| 213 |
+
collect royalties from You for the exercise of the Licensed
|
| 214 |
+
Rights, whether directly or through a collecting society
|
| 215 |
+
under any voluntary or waivable statutory or compulsory
|
| 216 |
+
licensing scheme. In all other cases the Licensor expressly
|
| 217 |
+
reserves any right to collect such royalties, including when
|
| 218 |
+
the Licensed Material is used other than for NonCommercial
|
| 219 |
+
purposes.
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
Section 3 -- License Conditions.
|
| 223 |
+
|
| 224 |
+
Your exercise of the Licensed Rights is expressly made subject to the
|
| 225 |
+
following conditions.
|
| 226 |
+
|
| 227 |
+
a. Attribution.
|
| 228 |
+
|
| 229 |
+
1. If You Share the Licensed Material (including in modified
|
| 230 |
+
form), You must:
|
| 231 |
+
|
| 232 |
+
a. retain the following if it is supplied by the Licensor
|
| 233 |
+
with the Licensed Material:
|
| 234 |
+
|
| 235 |
+
i. identification of the creator(s) of the Licensed
|
| 236 |
+
Material and any others designated to receive
|
| 237 |
+
attribution, in any reasonable manner requested by
|
| 238 |
+
the Licensor (including by pseudonym if
|
| 239 |
+
designated);
|
| 240 |
+
|
| 241 |
+
ii. a copyright notice;
|
| 242 |
+
|
| 243 |
+
iii. a notice that refers to this Public License;
|
| 244 |
+
|
| 245 |
+
iv. a notice that refers to the disclaimer of
|
| 246 |
+
warranties;
|
| 247 |
+
|
| 248 |
+
v. a URI or hyperlink to the Licensed Material to the
|
| 249 |
+
extent reasonably practicable;
|
| 250 |
+
|
| 251 |
+
b. indicate if You modified the Licensed Material and
|
| 252 |
+
retain an indication of any previous modifications; and
|
| 253 |
+
|
| 254 |
+
c. indicate the Licensed Material is licensed under this
|
| 255 |
+
Public License, and include the text of, or the URI or
|
| 256 |
+
hyperlink to, this Public License.
|
| 257 |
+
|
| 258 |
+
2. You may satisfy the conditions in Section 3(a)(1) in any
|
| 259 |
+
reasonable manner based on the medium, means, and context in
|
| 260 |
+
which You Share the Licensed Material. For example, it may be
|
| 261 |
+
reasonable to satisfy the conditions by providing a URI or
|
| 262 |
+
hyperlink to a resource that includes the required
|
| 263 |
+
information.
|
| 264 |
+
|
| 265 |
+
3. If requested by the Licensor, You must remove any of the
|
| 266 |
+
information required by Section 3(a)(1)(A) to the extent
|
| 267 |
+
reasonably practicable.
|
| 268 |
+
|
| 269 |
+
4. If You Share Adapted Material You produce, the Adapter's
|
| 270 |
+
License You apply must not prevent recipients of the Adapted
|
| 271 |
+
Material from complying with this Public License.
|
| 272 |
+
|
| 273 |
+
|
| 274 |
+
Section 4 -- Sui Generis Database Rights.
|
| 275 |
+
|
| 276 |
+
Where the Licensed Rights include Sui Generis Database Rights that
|
| 277 |
+
apply to Your use of the Licensed Material:
|
| 278 |
+
|
| 279 |
+
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
|
| 280 |
+
to extract, reuse, reproduce, and Share all or a substantial
|
| 281 |
+
portion of the contents of the database for NonCommercial purposes
|
| 282 |
+
only;
|
| 283 |
+
|
| 284 |
+
b. if You include all or a substantial portion of the database
|
| 285 |
+
contents in a database in which You have Sui Generis Database
|
| 286 |
+
Rights, then the database in which You have Sui Generis Database
|
| 287 |
+
Rights (but not its individual contents) is Adapted Material; and
|
| 288 |
+
|
| 289 |
+
c. You must comply with the conditions in Section 3(a) if You Share
|
| 290 |
+
all or a substantial portion of the contents of the database.
|
| 291 |
+
|
| 292 |
+
For the avoidance of doubt, this Section 4 supplements and does not
|
| 293 |
+
replace Your obligations under this Public License where the Licensed
|
| 294 |
+
Rights include other Copyright and Similar Rights.
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
|
| 298 |
+
|
| 299 |
+
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
|
| 300 |
+
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
|
| 301 |
+
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
|
| 302 |
+
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
|
| 303 |
+
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
|
| 304 |
+
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
|
| 305 |
+
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
|
| 306 |
+
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
|
| 307 |
+
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
|
| 308 |
+
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
|
| 309 |
+
|
| 310 |
+
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
|
| 311 |
+
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
|
| 312 |
+
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
|
| 313 |
+
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
|
| 314 |
+
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
|
| 315 |
+
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
|
| 316 |
+
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
|
| 317 |
+
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
|
| 318 |
+
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
|
| 319 |
+
|
| 320 |
+
c. The disclaimer of warranties and limitation of liability provided
|
| 321 |
+
above shall be interpreted in a manner that, to the extent
|
| 322 |
+
possible, most closely approximates an absolute disclaimer and
|
| 323 |
+
waiver of all liability.
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
Section 6 -- Term and Termination.
|
| 327 |
+
|
| 328 |
+
a. This Public License applies for the term of the Copyright and
|
| 329 |
+
Similar Rights licensed here. However, if You fail to comply with
|
| 330 |
+
this Public License, then Your rights under this Public License
|
| 331 |
+
terminate automatically.
|
| 332 |
+
|
| 333 |
+
b. Where Your right to use the Licensed Material has terminated under
|
| 334 |
+
Section 6(a), it reinstates:
|
| 335 |
+
|
| 336 |
+
1. automatically as of the date the violation is cured, provided
|
| 337 |
+
it is cured within 30 days of Your discovery of the
|
| 338 |
+
violation; or
|
| 339 |
+
|
| 340 |
+
2. upon express reinstatement by the Licensor.
|
| 341 |
+
|
| 342 |
+
For the avoidance of doubt, this Section 6(b) does not affect any
|
| 343 |
+
right the Licensor may have to seek remedies for Your violations
|
| 344 |
+
of this Public License.
|
| 345 |
+
|
| 346 |
+
c. For the avoidance of doubt, the Licensor may also offer the
|
| 347 |
+
Licensed Material under separate terms or conditions or stop
|
| 348 |
+
distributing the Licensed Material at any time; however, doing so
|
| 349 |
+
will not terminate this Public License.
|
| 350 |
+
|
| 351 |
+
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
|
| 352 |
+
License.
|
| 353 |
+
|
| 354 |
+
|
| 355 |
+
Section 7 -- Other Terms and Conditions.
|
| 356 |
+
|
| 357 |
+
a. The Licensor shall not be bound by any additional or different
|
| 358 |
+
terms or conditions communicated by You unless expressly agreed.
|
| 359 |
+
|
| 360 |
+
b. Any arrangements, understandings, or agreements regarding the
|
| 361 |
+
Licensed Material not stated herein are separate from and
|
| 362 |
+
independent of the terms and conditions of this Public License.
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
Section 8 -- Interpretation.
|
| 366 |
+
|
| 367 |
+
a. For the avoidance of doubt, this Public License does not, and
|
| 368 |
+
shall not be interpreted to, reduce, limit, restrict, or impose
|
| 369 |
+
conditions on any use of the Licensed Material that could lawfully
|
| 370 |
+
be made without permission under this Public License.
|
| 371 |
+
|
| 372 |
+
b. To the extent possible, if any provision of this Public License is
|
| 373 |
+
deemed unenforceable, it shall be automatically reformed to the
|
| 374 |
+
minimum extent necessary to make it enforceable. If the provision
|
| 375 |
+
cannot be reformed, it shall be severed from this Public License
|
| 376 |
+
without affecting the enforceability of the remaining terms and
|
| 377 |
+
conditions.
|
| 378 |
+
|
| 379 |
+
c. No term or condition of this Public License will be waived and no
|
| 380 |
+
failure to comply consented to unless expressly agreed to by the
|
| 381 |
+
Licensor.
|
| 382 |
+
|
| 383 |
+
d. Nothing in this Public License constitutes or may be interpreted
|
| 384 |
+
as a limitation upon, or waiver of, any privileges and immunities
|
| 385 |
+
that apply to the Licensor or You, including from the legal
|
| 386 |
+
processes of any jurisdiction or authority.
|
| 387 |
+
|
| 388 |
+
=======================================================================
|
| 389 |
+
|
| 390 |
+
Creative Commons is not a party to its public
|
| 391 |
+
licenses. Notwithstanding, Creative Commons may elect to apply one of
|
| 392 |
+
its public licenses to material it publishes and in those instances
|
| 393 |
+
will be considered the “Licensor.” The text of the Creative Commons
|
| 394 |
+
public licenses is dedicated to the public domain under the CC0 Public
|
| 395 |
+
Domain Dedication. Except for the limited purpose of indicating that
|
| 396 |
+
material is shared under a Creative Commons public license or as
|
| 397 |
+
otherwise permitted by the Creative Commons policies published at
|
| 398 |
+
creativecommons.org/policies, Creative Commons does not authorize the
|
| 399 |
+
use of the trademark "Creative Commons" or any other trademark or logo
|
| 400 |
+
of Creative Commons without its prior written consent including,
|
| 401 |
+
without limitation, in connection with any unauthorized modifications
|
| 402 |
+
to any of its public licenses or any other arrangements,
|
| 403 |
+
understandings, or agreements concerning use of licensed material. For
|
| 404 |
+
the avoidance of doubt, this paragraph does not form part of the
|
| 405 |
+
public licenses.
|
| 406 |
+
|
| 407 |
+
Creative Commons may be contacted at creativecommons.org.
|
| 408 |
+
|
README.md
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# S23DR 2026 — Iterating on the Baseline (5th place)
|
| 2 |
+
|
| 3 |
+
Solution for the [S23DR 2026](https://huggingface.co/spaces/usm3d/S23DR2026)
|
| 4 |
+
structured 3D wireframe reconstruction challenge. Private leaderboard:
|
| 5 |
+
**0.5388 HSS, 5th place**, with a single 8.85M-parameter Perceiver.
|
| 6 |
+
|
| 7 |
+
The submitted entry is the **raw 8k Perceiver**: a learned segment model over a
|
| 8 |
+
fused, priority-sampled COLMAP/depth point cloud, with no hand-crafted
|
| 9 |
+
post-processing. The repository also contains the hand-crafted pipeline and the
|
| 10 |
+
classifier-gated hybrid that we used earlier in the competition, and the
|
| 11 |
+
training/repro scripts behind them. See the accompanying write-up for the full
|
| 12 |
+
account.
|
| 13 |
+
|
| 14 |
+
## Run inference
|
| 15 |
+
|
| 16 |
+
```bash
|
| 17 |
+
pip install -r requirements.txt
|
| 18 |
+
python script.py
|
| 19 |
+
```
|
| 20 |
+
|
| 21 |
+
The challenge harness provides `params.json`, downloads the dataset, runs
|
| 22 |
+
`script.py`, and reads the resulting `submission.json`
|
| 23 |
+
(`{order_id, wf_vertices, wf_edges}` per scene). `script.py` loads
|
| 24 |
+
`checkpoint_8192.pt` and runs the raw 8k model (`CONF_THRESH=0.5`, no seam,
|
| 25 |
+
no augments).
|
| 26 |
+
|
| 27 |
+
## Layout
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
script.py raw 8k inference (the submitted entry)
|
| 31 |
+
Writeup.pdf method write-up (full account of the solution)
|
| 32 |
+
checkpoint_8192.pt 8k Perceiver weights (the 5th-place model)
|
| 33 |
+
checkpoint.pt organizers' 4k Perceiver (curriculum start point)
|
| 34 |
+
solution.py hand-crafted geometric pipeline
|
| 35 |
+
edge_classifier.py PointNet edge classifier (hybrid augment)
|
| 36 |
+
vertex_refiner.py PointNet vertex classifier (hybrid augment)
|
| 37 |
+
pnet_class_2026.pth edge classifier weights
|
| 38 |
+
vertex_refiner.pth vertex classifier weights
|
| 39 |
+
s23dr_2026_example/ model + training package (Perceiver, tokenizer, losses, train.py)
|
| 40 |
+
configs/ training config (base.json)
|
| 41 |
+
REPRODUCE.md recipe for the resolution curriculum (2k -> 4k -> 8k)
|
| 42 |
+
training/ data-generation and training scripts (see below)
|
| 43 |
+
experiments/ptv3/ the Point Transformer V3 encoder experiment
|
| 44 |
+
(negative result; trained model + logs + code)
|
| 45 |
+
```
|
| 46 |
+
|
| 47 |
+
## Reproducing
|
| 48 |
+
|
| 49 |
+
**The 8k model.** Follow `REPRODUCE.md`: train the Perceiver from scratch at
|
| 50 |
+
2048 points, then fine-tune at 4096 and 8192 points on the organizers' released
|
| 51 |
+
sampled point clouds. Our contribution is the 4k→8k stage; the released 4k
|
| 52 |
+
checkpoint (`checkpoint.pt`) is the starting point.
|
| 53 |
+
|
| 54 |
+
**Classifier augments.** `training/gen_edge_dataset.py` and
|
| 55 |
+
`training/gen_vertex_dataset.py` build the per-candidate patch datasets from the
|
| 56 |
+
hand-crafted pipeline's predictions; `training/train_edge_classifier_2026.py`
|
| 57 |
+
and `training/train_vertex_refiner_2026.py` train the PointNet classifiers.
|
| 58 |
+
|
| 59 |
+
**Router (negative result).** `training/train_routing_gbt.py` with
|
| 60 |
+
`training/oracle_sources_{train,validation}.json` reproduces the gradient-boosted
|
| 61 |
+
per-scene router; it recovered only 4.5% of the per-scene oracle ceiling.
|
| 62 |
+
|
| 63 |
+
Some scripts under `training/` contain absolute paths from the original training
|
| 64 |
+
environment and expect the repository root on `PYTHONPATH`
|
| 65 |
+
(e.g. `PYTHONPATH=. python training/train_edge_classifier_2026.py`); adapt the
|
| 66 |
+
paths to your setup.
|
REPRODUCE.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Reproducing the 8k Perceiver (private HSS 0.5388, 5th place)
|
| 2 |
+
|
| 3 |
+
The submitted entry is `checkpoint_8192.pt`, the raw 8k Perceiver. It is the end
|
| 4 |
+
of a resolution curriculum: the organizers' 2048 → 4096 baseline produces
|
| 5 |
+
`checkpoint.pt`, which we then fine-tune at 8192 points to produce
|
| 6 |
+
`checkpoint_8192.pt`. The architecture is identical at every stage — only the
|
| 7 |
+
input point budget grows.
|
| 8 |
+
|
| 9 |
+
The Perceiver training code is the organizers' package, bundled here under
|
| 10 |
+
`s23dr_2026_example/` (`train.py`, `tokenizer.py`, `model.py`, `losses.py`).
|
| 11 |
+
|
| 12 |
+
## Inference
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
pip install -r requirements.txt
|
| 16 |
+
python script.py
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
`script.py` loads `checkpoint_8192.pt`, fuses and priority-samples each scene to
|
| 20 |
+
8192 points (6144 COLMAP + 2048 depth), runs the Perceiver, and writes
|
| 21 |
+
`submission.json` (`{order_id, wf_vertices, wf_edges}` per scene). No seam, no
|
| 22 |
+
augments (`CONF_THRESH=0.5`).
|
| 23 |
+
|
| 24 |
+
## Architecture
|
| 25 |
+
|
| 26 |
+
Every stage shares one config (`configs/base.json`):
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
Perceiver: hidden=256, ff=1024, latent_tokens=256, latent_layers=7
|
| 30 |
+
encoder_layers=4, decoder_layers=3, cross_attn_interval=4
|
| 31 |
+
num_heads=4, kv_heads_cross=2, kv_heads_self=2
|
| 32 |
+
qk_norm=True (L2), rms_norm=True, dropout=0.1
|
| 33 |
+
segments=64, segment_param=midpoint_dir_len, segment_conf=True
|
| 34 |
+
behind_emb_dim=8, vote_features=True, activation=gelu
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
## Curriculum
|
| 38 |
+
|
| 39 |
+
The Perceiver is trained by a staged resolution curriculum: from scratch at
|
| 40 |
+
2048 points, then fine-tuned at 4096 and 8192. Each resolution step adds 45k
|
| 41 |
+
fine-tuning steps, the last 20k a linear cooldown, at a gentle learning rate
|
| 42 |
+
(3e-5) that preserves the lower-resolution representations. Stages A and B are
|
| 43 |
+
the organizers' baseline; stage C is ours. The generic invocation is:
|
| 44 |
+
|
| 45 |
+
```bash
|
| 46 |
+
python -m s23dr_2026_example.train \
|
| 47 |
+
--cache-dir hf://usm3d/s23dr-2026-sampled_<N>:train \
|
| 48 |
+
--seq-len <N> [--resume <previous_checkpoint>] --aug-rotate --aug-flip
|
| 49 |
+
```
|
| 50 |
+
|
| 51 |
+
### Stage A — 2048, from scratch *(organizers)*
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
Data: sampled_2048_v2:train
|
| 55 |
+
Steps: 0 -> 125,000 LR: 3e-4, warmup 10,000 Batch: 32
|
| 56 |
+
Loss: sinkhorn (eps=0.1, iters=20, dustbin=0.3) + conf (weight 0.1)
|
| 57 |
+
Seed: 353
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Trains the Perceiver from random init. The 2048 budget keeps the train/val gap
|
| 61 |
+
low; training directly at high resolution overfits. **Public test HSS 0.4273.**
|
| 62 |
+
|
| 63 |
+
### Stage B — 4096 fine-tune + cooldown *(organizers)* → `checkpoint.pt`
|
| 64 |
+
|
| 65 |
+
```
|
| 66 |
+
Resume: Stage A Data: sampled_4096_v2:train
|
| 67 |
+
Steps: 125,000 -> 170,000 (45k; last 20k linear cooldown)
|
| 68 |
+
LR: 3e-5 Batch: 64
|
| 69 |
+
```
|
| 70 |
+
|
| 71 |
+
Switches the input to 4096 points; the gentle LR adapts without disturbing the
|
| 72 |
+
learned representation (LR > 1e-4 forgets it). The result is the organizers'
|
| 73 |
+
released 4k checkpoint, `checkpoint.pt` — **public test HSS 0.4470**.
|
| 74 |
+
|
| 75 |
+
### Stage C — 8192 fine-tune + cooldown *(ours)* → `checkpoint_8192.pt`
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
Resume: checkpoint.pt Data: organizers' released 8k samples
|
| 79 |
+
Input: 8192 points = 6144 COLMAP + 2048 depth
|
| 80 |
+
Steps: 170,000 -> 215,000 (45k; last 20k linear cooldown)
|
| 81 |
+
LR: 3e-5 Batch: 64
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
Resumes the 4k checkpoint and continues the same gentle fine-tune at 8192 points.
|
| 85 |
+
This is the only stage we contribute, and the single largest gain in the system:
|
| 86 |
+
doubling the input from 4096 to 8192 points lifts the raw model from 0.4470 to
|
| 87 |
+
**0.5004** public HSS (a +0.053 jump, larger than the entire hand-crafted hybrid
|
| 88 |
+
contributes at 4k). Retuning the confidence threshold from 0.5 to 0.65 reaches
|
| 89 |
+
our public best of 0.5009; the submitted entry uses 0.5. This is
|
| 90 |
+
`checkpoint_8192.pt` — **public test HSS 0.5004, private 0.5388 (5th place)**.
|
| 91 |
+
|
| 92 |
+
## Data
|
| 93 |
+
|
| 94 |
+
The organizers publish pre-sampled point clouds (`sampled_2048` / `sampled_4096`
|
| 95 |
+
/ `sampled_8192`) on the Hugging Face Hub; the curriculum above trains directly
|
| 96 |
+
on them. `training/gen_sampled_16384.py` regenerates a sampled set at an
|
| 97 |
+
arbitrary `--seq-len` from the organizers' `cached_full_pcd_v2` cache, in the
|
| 98 |
+
same format — we used it to try a 16384 budget (see below).
|
| 99 |
+
|
| 100 |
+
## Negative results (kept for the record, not on the inference path)
|
| 101 |
+
|
| 102 |
+
- **16384 resolution** regressed relative to 8192. The generator is
|
| 103 |
+
`training/gen_sampled_16384.py`; the model was not shipped.
|
| 104 |
+
- **Per-scene router** (`training/train_routing_gbt.py` with
|
| 105 |
+
`training/oracle_sources_{train,validation}.json`): a gradient-boosted router
|
| 106 |
+
choosing 4k / 8k / hand-crafted per scene recovered only ~4.5% of the
|
| 107 |
+
per-scene oracle ceiling.
|
| 108 |
+
- **Point Transformer V3 encoder** (`experiments/ptv3/`): a stronger encoder,
|
| 109 |
+
too slow for the T4 2-hour budget. Trained checkpoint, logs, and code are
|
| 110 |
+
archived there.
|
| 111 |
+
|
| 112 |
+
## Evaluation sets
|
| 113 |
+
|
| 114 |
+
Two splits matter for the numbers above:
|
| 115 |
+
|
| 116 |
+
- **Public test** — the competition harness scores `submission.json` against the
|
| 117 |
+
hidden test set and posts to the leaderboard. Every HSS in this document
|
| 118 |
+
(4k 0.4470, 8k 0.5004, private 0.5388) is a public/private test number.
|
| 119 |
+
- **Dev val** — the tail of the published training set, used during development
|
| 120 |
+
to pick checkpoints. We do not quote dev-val numbers here, since the
|
| 121 |
+
leaderboard scores are the ones that decide the entry.
|
Writeup.pdf
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:8854aca9ffbd1a6e6d1a97f95641f99b0cd352eeacc617b26abbe04583797065
|
| 3 |
+
size 189408
|
checkpoint.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1296423a1a2e603ba55860d8ef8fa3a861764a7bbc3de96b776fca59cf5b11ab
|
| 3 |
+
size 106429791
|
checkpoint_8192.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5f7283074ed44634770d1b5d7724cb08dac429df03d218cdfa0dcc72e75278bc
|
| 3 |
+
size 106429599
|
configs/base.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"arch": "perceiver",
|
| 3 |
+
"segments": 64,
|
| 4 |
+
"hidden": 256,
|
| 5 |
+
"ff": 1024,
|
| 6 |
+
"num_heads": 4,
|
| 7 |
+
"kv_heads_cross": 2,
|
| 8 |
+
"kv_heads_self": 2,
|
| 9 |
+
"latent_tokens": 256,
|
| 10 |
+
"latent_layers": 7,
|
| 11 |
+
"decoder_layers": 3,
|
| 12 |
+
"cross_attn_interval": 4,
|
| 13 |
+
"encoder_layers": 4,
|
| 14 |
+
"behind_emb_dim": 8,
|
| 15 |
+
"dropout": 0.1,
|
| 16 |
+
"activation": "gelu",
|
| 17 |
+
"rms_norm": true,
|
| 18 |
+
"qk_norm": true,
|
| 19 |
+
"qk_norm_type": "l2",
|
| 20 |
+
"segment_param": "midpoint_dir_len",
|
| 21 |
+
"segment_conf": true,
|
| 22 |
+
"vote_features": true,
|
| 23 |
+
|
| 24 |
+
"adam_betas": "0.9,0.95",
|
| 25 |
+
"weight_decay": 0.01,
|
| 26 |
+
"warmup": 10000,
|
| 27 |
+
"varifold_weight": 0.0,
|
| 28 |
+
"sinkhorn_weight": 1.0,
|
| 29 |
+
"sinkhorn_eps": 0.1,
|
| 30 |
+
"sinkhorn_iters": 20,
|
| 31 |
+
"sinkhorn_dustbin": 0.3,
|
| 32 |
+
"conf_weight": 0.1,
|
| 33 |
+
"conf_mode": "sinkhorn",
|
| 34 |
+
"conf_head_wd": 0.1,
|
| 35 |
+
|
| 36 |
+
"aug_rotate": true,
|
| 37 |
+
"aug_flip": true,
|
| 38 |
+
"seed": 353
|
| 39 |
+
}
|
edge_classifier.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Edge classifier and classifier-gated edge augmentation.
|
| 2 |
+
|
| 3 |
+
Contains:
|
| 4 |
+
- ClassificationPointNet: PointNet binary classifier on 6D cylindrical patches
|
| 5 |
+
- colmap_points_xyz_rgb / build_edge_patch_6d: patch builder
|
| 6 |
+
- score_edges_batched: per-sample batched inference
|
| 7 |
+
- augment_hybrid_with_filtered_hc: import high-confidence handcrafted edges
|
| 8 |
+
"""
|
| 9 |
+
import numpy as np
|
| 10 |
+
import torch
|
| 11 |
+
import torch.nn as nn
|
| 12 |
+
import torch.nn.functional as F
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# Cylinder patch geometry (must match the training-time patch generation).
|
| 16 |
+
CYL_RADIUS = 0.5
|
| 17 |
+
CYL_EXT = 0.25 # extension at each end
|
| 18 |
+
MAX_PATCH_POINTS = 1024
|
| 19 |
+
AUGMENT_DEDUP_RADIUS = 0.3
|
| 20 |
+
AUGMENT_THRESHOLD = 0.55
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class ClassificationPointNet(nn.Module):
|
| 24 |
+
"""PointNet binary classifier on 6D point cloud patches (xyz + rgb)."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, input_dim=6, max_points=1024):
|
| 27 |
+
super().__init__()
|
| 28 |
+
self.max_points = max_points
|
| 29 |
+
self.conv1 = nn.Conv1d(input_dim, 64, 1)
|
| 30 |
+
self.conv2 = nn.Conv1d(64, 128, 1)
|
| 31 |
+
self.conv3 = nn.Conv1d(128, 256, 1)
|
| 32 |
+
self.conv4 = nn.Conv1d(256, 512, 1)
|
| 33 |
+
self.conv5 = nn.Conv1d(512, 1024, 1)
|
| 34 |
+
self.conv6 = nn.Conv1d(1024, 2048, 1)
|
| 35 |
+
self.fc1 = nn.Linear(2048, 1024)
|
| 36 |
+
self.fc2 = nn.Linear(1024, 512)
|
| 37 |
+
self.fc3 = nn.Linear(512, 256)
|
| 38 |
+
self.fc4 = nn.Linear(256, 128)
|
| 39 |
+
self.fc5 = nn.Linear(128, 64)
|
| 40 |
+
self.fc6 = nn.Linear(64, 1)
|
| 41 |
+
self.bn1 = nn.BatchNorm1d(64)
|
| 42 |
+
self.bn2 = nn.BatchNorm1d(128)
|
| 43 |
+
self.bn3 = nn.BatchNorm1d(256)
|
| 44 |
+
self.bn4 = nn.BatchNorm1d(512)
|
| 45 |
+
self.bn5 = nn.BatchNorm1d(1024)
|
| 46 |
+
self.bn6 = nn.BatchNorm1d(2048)
|
| 47 |
+
self.dropout1 = nn.Dropout(0.3)
|
| 48 |
+
self.dropout2 = nn.Dropout(0.4)
|
| 49 |
+
self.dropout3 = nn.Dropout(0.5)
|
| 50 |
+
self.dropout4 = nn.Dropout(0.4)
|
| 51 |
+
self.dropout5 = nn.Dropout(0.3)
|
| 52 |
+
|
| 53 |
+
def forward(self, x):
|
| 54 |
+
# x: (B, 6, max_points)
|
| 55 |
+
x1 = F.relu(self.bn1(self.conv1(x)))
|
| 56 |
+
x2 = F.relu(self.bn2(self.conv2(x1)))
|
| 57 |
+
x3 = F.relu(self.bn3(self.conv3(x2)))
|
| 58 |
+
x4 = F.relu(self.bn4(self.conv4(x3)))
|
| 59 |
+
x5 = F.relu(self.bn5(self.conv5(x4)))
|
| 60 |
+
x6 = F.relu(self.bn6(self.conv6(x5)))
|
| 61 |
+
g = torch.max(x6, 2)[0]
|
| 62 |
+
x = F.relu(self.fc1(g)); x = self.dropout1(x)
|
| 63 |
+
x = F.relu(self.fc2(x)); x = self.dropout2(x)
|
| 64 |
+
x = F.relu(self.fc3(x)); x = self.dropout3(x)
|
| 65 |
+
x = F.relu(self.fc4(x)); x = self.dropout4(x)
|
| 66 |
+
x = F.relu(self.fc5(x)); x = self.dropout5(x)
|
| 67 |
+
return self.fc6(x) # (B, 1) logits
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def load_pnet_class(model_path, device=None):
|
| 71 |
+
"""Load a trained ClassificationPointNet checkpoint."""
|
| 72 |
+
if device is None:
|
| 73 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 74 |
+
model = ClassificationPointNet(input_dim=6, max_points=MAX_PATCH_POINTS)
|
| 75 |
+
ckpt = torch.load(model_path, map_location=device, weights_only=False)
|
| 76 |
+
model.load_state_dict(ckpt['model_state_dict'])
|
| 77 |
+
model.to(device).eval()
|
| 78 |
+
return model
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def colmap_points_xyz_rgb(colmap_rec):
|
| 82 |
+
"""Return (xyz, rgb_normalized_0_1) for all COLMAP points."""
|
| 83 |
+
xyz_list, rgb_list = [], []
|
| 84 |
+
for _, p3D in colmap_rec.points3D.items():
|
| 85 |
+
xyz_list.append(p3D.xyz)
|
| 86 |
+
rgb_list.append(p3D.color / 255.0)
|
| 87 |
+
if not xyz_list:
|
| 88 |
+
return np.empty((0, 3)), np.empty((0, 3))
|
| 89 |
+
return np.array(xyz_list), np.array(rgb_list)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def build_edge_patch_6d(u_xyz, v_xyz, colmap_xyz, colmap_rgb):
|
| 93 |
+
"""6D cylinder patch around edge (u, v). None if too sparse."""
|
| 94 |
+
line = v_xyz - u_xyz
|
| 95 |
+
L = float(np.linalg.norm(line))
|
| 96 |
+
if L < 1e-6:
|
| 97 |
+
return None
|
| 98 |
+
direction = line / L
|
| 99 |
+
ext_start = u_xyz - CYL_EXT * direction
|
| 100 |
+
ext_L = L + 2 * CYL_EXT
|
| 101 |
+
rel = colmap_xyz - ext_start[np.newaxis, :]
|
| 102 |
+
proj = rel @ direction
|
| 103 |
+
in_bounds = (proj >= 0) & (proj <= ext_L)
|
| 104 |
+
closest = ext_start[np.newaxis, :] + proj[:, np.newaxis] * direction[np.newaxis, :]
|
| 105 |
+
perp = np.linalg.norm(colmap_xyz - closest, axis=1)
|
| 106 |
+
in_cyl = in_bounds & (perp <= CYL_RADIUS)
|
| 107 |
+
if int(in_cyl.sum()) <= 10:
|
| 108 |
+
return None
|
| 109 |
+
midpoint = (u_xyz + v_xyz) / 2
|
| 110 |
+
pts_centered = colmap_xyz[in_cyl] - midpoint
|
| 111 |
+
rgb_signed = colmap_rgb[in_cyl] * 2.0 - 1.0
|
| 112 |
+
return np.hstack([pts_centered, rgb_signed])
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def _pad_or_sample_patch(patch_6d, max_pts=MAX_PATCH_POINTS, rng=None):
|
| 116 |
+
if rng is None:
|
| 117 |
+
rng = np.random
|
| 118 |
+
n = patch_6d.shape[0]
|
| 119 |
+
if n >= max_pts:
|
| 120 |
+
idx = rng.choice(n, max_pts, replace=False)
|
| 121 |
+
return patch_6d[idx]
|
| 122 |
+
out = np.zeros((max_pts, 6), dtype=np.float32)
|
| 123 |
+
out[:n] = patch_6d
|
| 124 |
+
return out
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def score_edges_batched(model, device, vertices, edges, colmap_xyz, colmap_rgb,
|
| 128 |
+
rng=None, batch_size=32):
|
| 129 |
+
"""Score every edge in `edges` (returns list of floats, or None per failed patch)."""
|
| 130 |
+
if rng is None:
|
| 131 |
+
rng = np.random.RandomState(0)
|
| 132 |
+
n = len(edges)
|
| 133 |
+
scores = [None] * n
|
| 134 |
+
if n == 0 or len(vertices) == 0 or len(colmap_xyz) == 0:
|
| 135 |
+
return scores
|
| 136 |
+
|
| 137 |
+
patches, indices = [], []
|
| 138 |
+
for i, (u, v) in enumerate(edges):
|
| 139 |
+
u_xyz = vertices[int(u)]
|
| 140 |
+
v_xyz = vertices[int(v)]
|
| 141 |
+
raw = build_edge_patch_6d(u_xyz, v_xyz, colmap_xyz, colmap_rgb)
|
| 142 |
+
if raw is None:
|
| 143 |
+
continue
|
| 144 |
+
patches.append(_pad_or_sample_patch(raw, rng=rng).astype(np.float32))
|
| 145 |
+
indices.append(i)
|
| 146 |
+
|
| 147 |
+
if not patches:
|
| 148 |
+
return scores
|
| 149 |
+
|
| 150 |
+
batch = np.stack(patches, axis=0).transpose(0, 2, 1) # (N, 6, 1024)
|
| 151 |
+
with torch.no_grad():
|
| 152 |
+
for start in range(0, len(batch), batch_size):
|
| 153 |
+
end = min(start + batch_size, len(batch))
|
| 154 |
+
x = torch.from_numpy(batch[start:end]).to(device)
|
| 155 |
+
logits = model(x).squeeze(-1)
|
| 156 |
+
probs = torch.sigmoid(logits).cpu().numpy().reshape(-1)
|
| 157 |
+
for j, p in enumerate(probs):
|
| 158 |
+
scores[indices[start + j]] = float(p)
|
| 159 |
+
return scores
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def augment_hybrid_with_filtered_hc(h_v, h_e, user_v, user_e, scores,
|
| 163 |
+
thresh=AUGMENT_THRESHOLD,
|
| 164 |
+
dedup_radius=AUGMENT_DEDUP_RADIUS):
|
| 165 |
+
"""Add handcrafted edges scoring above thresh into the hybrid (v, e)."""
|
| 166 |
+
h_v = np.asarray(h_v, dtype=np.float32)
|
| 167 |
+
user_v = np.asarray(user_v, dtype=np.float32)
|
| 168 |
+
h_e_list = [(int(a), int(b)) for a, b in h_e]
|
| 169 |
+
if scores is None or len(user_e) == 0 or len(user_v) == 0:
|
| 170 |
+
return h_v, h_e_list
|
| 171 |
+
|
| 172 |
+
kept_pairs = [
|
| 173 |
+
(int(u), int(v)) for (u, v), s in zip(user_e, scores)
|
| 174 |
+
if s is not None and s > thresh
|
| 175 |
+
]
|
| 176 |
+
if not kept_pairs:
|
| 177 |
+
return h_v, h_e_list
|
| 178 |
+
|
| 179 |
+
needed_hc_idx = sorted({u for u, _ in kept_pairs} | {v for _, v in kept_pairs})
|
| 180 |
+
new_v_list, user_to_combined = [], {}
|
| 181 |
+
for u_idx in needed_hc_idx:
|
| 182 |
+
pos = user_v[u_idx]
|
| 183 |
+
if len(h_v) > 0:
|
| 184 |
+
d = np.linalg.norm(h_v - pos, axis=1)
|
| 185 |
+
best = int(np.argmin(d))
|
| 186 |
+
if d[best] <= dedup_radius:
|
| 187 |
+
user_to_combined[u_idx] = best
|
| 188 |
+
continue
|
| 189 |
+
user_to_combined[u_idx] = len(h_v) + len(new_v_list)
|
| 190 |
+
new_v_list.append(pos)
|
| 191 |
+
|
| 192 |
+
if new_v_list:
|
| 193 |
+
combined_v = np.concatenate([h_v, np.stack(new_v_list, axis=0)], axis=0)
|
| 194 |
+
else:
|
| 195 |
+
combined_v = h_v
|
| 196 |
+
|
| 197 |
+
existing = {(min(a, b), max(a, b)) for a, b in h_e_list}
|
| 198 |
+
new_edges = []
|
| 199 |
+
for u, v in kept_pairs:
|
| 200 |
+
a, b = user_to_combined[u], user_to_combined[v]
|
| 201 |
+
if a == b:
|
| 202 |
+
continue
|
| 203 |
+
key = (min(a, b), max(a, b))
|
| 204 |
+
if key in existing:
|
| 205 |
+
continue
|
| 206 |
+
existing.add(key)
|
| 207 |
+
new_edges.append((a, b))
|
| 208 |
+
return combined_v, h_e_list + new_edges
|
experiments/ptv3/README.md
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Point Transformer V3 encoder (negative result)
|
| 2 |
+
|
| 3 |
+
This is the Point Transformer V3 encoder experiment from the write-up. We
|
| 4 |
+
replaced the Perceiver encoder with a Point Transformer V3 encoder (per-point
|
| 5 |
+
features, no latent bottleneck) to test whether a stronger encoder would scale
|
| 6 |
+
past the 8k Perceiver.
|
| 7 |
+
|
| 8 |
+
**Outcome.** Trained from scratch at 8k for 200k steps, it plateaued at ~0.323
|
| 9 |
+
local HSS, below the curriculum-trained Perceiver (~0.357), and ran at roughly
|
| 10 |
+
6 s/sample on an A5000 — over the two-hour T4 evaluation budget. It was never
|
| 11 |
+
submitted. This folder is provided as confirmation that the experiment was run.
|
| 12 |
+
|
| 13 |
+
## Contents
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
checkpoint_ptv3_8k.pt trained weights (200k steps at 8k)
|
| 17 |
+
train_args.json training configuration
|
| 18 |
+
ptv3_code/ the PT v3 encoder (adapted from the Pointcept release)
|
| 19 |
+
and the [B,T,*] <-> flat adapter (encoder_wrapper.py)
|
| 20 |
+
model_with_ptv3.py EdgeDepthSegmentsModel with the arch="ptv3" branch
|
| 21 |
+
(drop-in replacement for s23dr_2026_example/model.py)
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
## Notes
|
| 25 |
+
|
| 26 |
+
- Extra dependencies beyond the main `requirements.txt`:
|
| 27 |
+
`spconv-cu121`, `torch-scatter`, `addict` (and optionally `flash-attn`).
|
| 28 |
+
- To run it, place `ptv3_code/` as `s23dr_2026_example/ptv3/`, use
|
| 29 |
+
`model_with_ptv3.py` as `s23dr_2026_example/model.py`, and load the checkpoint
|
| 30 |
+
with `arch="ptv3"`. Inference must use fp32 (spconv does not tune fp16 kernels
|
| 31 |
+
reliably on these GPUs).
|
| 32 |
+
- The PT v3 encoder is adapted from the authors' Pointcept release.
|
experiments/ptv3/checkpoint_ptv3_8k.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:317879f9efd320246dafa65132a55669ec39691bbcbf5ab2f9e24f94767b6ce1
|
| 3 |
+
size 461100495
|
experiments/ptv3/model_with_ptv3.py
ADDED
|
@@ -0,0 +1,698 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Perceiver-based transformer for 3D roof wireframe prediction.
|
| 3 |
+
|
| 4 |
+
Architecture overview:
|
| 5 |
+
|
| 6 |
+
Input tokens [B, T, D]
|
| 7 |
+
|
|
| 8 |
+
v
|
| 9 |
+
input_proj: Linear -> GELU -> Linear -> LayerNorm => [B, T, hidden]
|
| 10 |
+
|
|
| 11 |
+
v
|
| 12 |
+
Perceiver latent bottleneck (N PerceiverLatentLayers):
|
| 13 |
+
Learnable latent embeddings [L, hidden] are broadcast to batch.
|
| 14 |
+
Each layer: cross-attn(latents <- tokens) -> self-attn(latents) -> FFN
|
| 15 |
+
Output: latents [B, L, hidden]
|
| 16 |
+
|
|
| 17 |
+
v
|
| 18 |
+
Segment decoder (M SegmentDecoderLayers):
|
| 19 |
+
Learnable query embeddings [S, hidden] are broadcast to batch.
|
| 20 |
+
Each layer: cross-attn(queries <- latents) -> self-attn(queries) -> FFN
|
| 21 |
+
Output: queries [B, S, hidden]
|
| 22 |
+
|
|
| 23 |
+
v
|
| 24 |
+
segment_head: Linear -> 6D -> (midpoint, half_vector)
|
| 25 |
+
+ query_offsets (learnable per-query bias)
|
| 26 |
+
endpoints = midpoint +/- half_vector -> [B, S, 2, 3]
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
|
| 32 |
+
from .attention import MultiHeadSDPA, FeedForward
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Building blocks
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
class AttnResidual(nn.Module):
|
| 40 |
+
"""Pre-norm attention + residual + dropout."""
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
d_model: int,
|
| 45 |
+
num_heads: int,
|
| 46 |
+
dropout: float = 0.0,
|
| 47 |
+
kv_heads: int | None = None,
|
| 48 |
+
norm_class=None,
|
| 49 |
+
qk_norm: bool = False,
|
| 50 |
+
qk_norm_type: str = "l2",
|
| 51 |
+
):
|
| 52 |
+
super().__init__()
|
| 53 |
+
norm_class = norm_class or nn.LayerNorm
|
| 54 |
+
self.norm = norm_class(d_model)
|
| 55 |
+
self.attn = MultiHeadSDPA(d_model, num_heads, kv_heads=kv_heads, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 56 |
+
self.drop = nn.Dropout(dropout)
|
| 57 |
+
|
| 58 |
+
def forward(
|
| 59 |
+
self,
|
| 60 |
+
x: torch.Tensor,
|
| 61 |
+
memory: torch.Tensor,
|
| 62 |
+
memory_key_padding_mask: torch.Tensor | None = None,
|
| 63 |
+
) -> torch.Tensor:
|
| 64 |
+
res = x
|
| 65 |
+
x = self.norm(x)
|
| 66 |
+
x = self.attn(x, memory, key_padding_mask=memory_key_padding_mask)
|
| 67 |
+
return res + self.drop(x)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class FFNResidual(nn.Module):
|
| 71 |
+
"""Pre-norm feed-forward + residual + dropout."""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
d_model: int,
|
| 76 |
+
dim_ff: int,
|
| 77 |
+
dropout: float = 0.0,
|
| 78 |
+
activation: str = "gelu",
|
| 79 |
+
norm_class=None,
|
| 80 |
+
):
|
| 81 |
+
super().__init__()
|
| 82 |
+
norm_class = norm_class or nn.LayerNorm
|
| 83 |
+
self.norm = norm_class(d_model)
|
| 84 |
+
self.ffn = FeedForward(d_model, dim_ff, activation=activation)
|
| 85 |
+
self.drop = nn.Dropout(dropout)
|
| 86 |
+
|
| 87 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 88 |
+
res = x
|
| 89 |
+
x = self.norm(x)
|
| 90 |
+
x = self.ffn(x)
|
| 91 |
+
return res + self.drop(x)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Perceiver encoder layer
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
class PerceiverLatentLayer(nn.Module):
|
| 99 |
+
"""Single Perceiver latent layer.
|
| 100 |
+
|
| 101 |
+
If use_cross=True: cross-attn(latents <- points) -> self-attn -> FFN
|
| 102 |
+
If use_cross=False: self-attn -> FFN (saves compute in deep stacks)
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
def __init__(
|
| 106 |
+
self,
|
| 107 |
+
d_model: int,
|
| 108 |
+
num_heads: int,
|
| 109 |
+
dim_ff: int,
|
| 110 |
+
dropout: float = 0.0,
|
| 111 |
+
activation: str = "gelu",
|
| 112 |
+
kv_heads_cross: int | None = None,
|
| 113 |
+
kv_heads_self: int | None = None,
|
| 114 |
+
use_cross: bool = True,
|
| 115 |
+
norm_class=None,
|
| 116 |
+
qk_norm: bool = False,
|
| 117 |
+
qk_norm_type: str = "l2",
|
| 118 |
+
):
|
| 119 |
+
super().__init__()
|
| 120 |
+
self.use_cross = use_cross
|
| 121 |
+
if use_cross:
|
| 122 |
+
self.cross = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 123 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_self, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 124 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 125 |
+
|
| 126 |
+
def forward(
|
| 127 |
+
self,
|
| 128 |
+
latents: torch.Tensor,
|
| 129 |
+
points: torch.Tensor,
|
| 130 |
+
points_key_padding_mask: torch.Tensor | None = None,
|
| 131 |
+
) -> torch.Tensor:
|
| 132 |
+
if self.use_cross:
|
| 133 |
+
latents = self.cross(latents, points, memory_key_padding_mask=points_key_padding_mask)
|
| 134 |
+
latents = self.self_attn(latents, latents)
|
| 135 |
+
latents = self.ffn(latents)
|
| 136 |
+
return latents
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Segment decoder layer
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
class SegmentDecoderLayer(nn.Module):
|
| 144 |
+
"""Single segment decoder layer.
|
| 145 |
+
|
| 146 |
+
cross-attn(queries <- latents) -> [cross-attn(queries <- inputs)] -> self-attn(queries) -> FFN
|
| 147 |
+
|
| 148 |
+
If input_xattn=True, adds a second cross-attention that attends directly
|
| 149 |
+
to the projected input tokens (bypassing the latent bottleneck). This gives
|
| 150 |
+
queries access to fine-grained point-level detail for vertex precision.
|
| 151 |
+
"""
|
| 152 |
+
|
| 153 |
+
def __init__(
|
| 154 |
+
self,
|
| 155 |
+
d_model: int,
|
| 156 |
+
num_heads: int,
|
| 157 |
+
dim_ff: int,
|
| 158 |
+
dropout: float = 0.0,
|
| 159 |
+
activation: str = "gelu",
|
| 160 |
+
kv_heads_cross: int | None = None,
|
| 161 |
+
kv_heads_self: int | None = None,
|
| 162 |
+
norm_class=None,
|
| 163 |
+
input_xattn: bool = False,
|
| 164 |
+
qk_norm: bool = False,
|
| 165 |
+
qk_norm_type: str = "l2",
|
| 166 |
+
):
|
| 167 |
+
super().__init__()
|
| 168 |
+
self.cross = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 169 |
+
self.input_xattn = input_xattn
|
| 170 |
+
if input_xattn:
|
| 171 |
+
self.cross_input = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 172 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_self, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 173 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 174 |
+
|
| 175 |
+
def forward(
|
| 176 |
+
self,
|
| 177 |
+
queries: torch.Tensor,
|
| 178 |
+
latents: torch.Tensor,
|
| 179 |
+
src: torch.Tensor | None = None,
|
| 180 |
+
src_key_padding_mask: torch.Tensor | None = None,
|
| 181 |
+
) -> torch.Tensor:
|
| 182 |
+
queries = self.cross(queries, latents)
|
| 183 |
+
if self.input_xattn and src is not None:
|
| 184 |
+
queries = self.cross_input(queries, src, memory_key_padding_mask=src_key_padding_mask)
|
| 185 |
+
queries = self.self_attn(queries, queries)
|
| 186 |
+
queries = self.ffn(queries)
|
| 187 |
+
return queries
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Full model
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
class TokenTransformerSegments(nn.Module):
|
| 195 |
+
"""Perceiver transformer that predicts 3D roof wireframe segments.
|
| 196 |
+
|
| 197 |
+
Takes point-cloud tokens and outputs segment endpoints as [B, S, 2, 3]
|
| 198 |
+
where S is the number of segments and each segment has two 3D endpoints.
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
segments: Number of predicted segments (S).
|
| 202 |
+
in_dim: Dimensionality of input tokens.
|
| 203 |
+
hidden: Internal hidden dimension throughout the model.
|
| 204 |
+
num_heads: Number of attention heads.
|
| 205 |
+
kv_heads_cross: Grouped-query heads for cross-attention (None = standard MHA).
|
| 206 |
+
kv_heads_self: Grouped-query heads for self-attention (None = standard MHA).
|
| 207 |
+
dim_feedforward: FFN intermediate dimension.
|
| 208 |
+
dropout: Dropout rate applied after attention and FFN.
|
| 209 |
+
latent_tokens: Number of learnable latent embeddings (L) in the bottleneck.
|
| 210 |
+
latent_layers: Number of PerceiverLatentLayers (N).
|
| 211 |
+
decoder_layers: Number of SegmentDecoderLayers (M).
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def __init__(
|
| 215 |
+
self,
|
| 216 |
+
segments: int = 32,
|
| 217 |
+
in_dim: int = 128,
|
| 218 |
+
hidden: int = 128,
|
| 219 |
+
num_heads: int = 4,
|
| 220 |
+
kv_heads_cross: int | None = 2,
|
| 221 |
+
kv_heads_self: int | None = 0,
|
| 222 |
+
dim_feedforward: int = 256,
|
| 223 |
+
dropout: float = 0.01,
|
| 224 |
+
latent_tokens: int = 64,
|
| 225 |
+
latent_layers: int = 2,
|
| 226 |
+
decoder_layers: int = 2,
|
| 227 |
+
cross_attn_interval: int = 1,
|
| 228 |
+
norm_class=None,
|
| 229 |
+
activation: str = "gelu",
|
| 230 |
+
segment_conf: bool = False,
|
| 231 |
+
pre_encoder_layers: int = 0,
|
| 232 |
+
segment_param: str = "midpoint_halfvec",
|
| 233 |
+
length_floor: float = 0.0,
|
| 234 |
+
decoder_input_xattn: bool = False,
|
| 235 |
+
qk_norm: bool = False,
|
| 236 |
+
qk_norm_type: str = "l2",
|
| 237 |
+
):
|
| 238 |
+
super().__init__()
|
| 239 |
+
self.segments = segments
|
| 240 |
+
self.out_vertices = segments * 2
|
| 241 |
+
self.segment_param = segment_param
|
| 242 |
+
self.decoder_input_xattn = decoder_input_xattn
|
| 243 |
+
norm_class = norm_class or nn.LayerNorm
|
| 244 |
+
|
| 245 |
+
# Treat 0 as "use standard MHA"
|
| 246 |
+
if kv_heads_cross is not None and kv_heads_cross <= 0:
|
| 247 |
+
kv_heads_cross = None
|
| 248 |
+
if kv_heads_self is not None and kv_heads_self <= 0:
|
| 249 |
+
kv_heads_self = None
|
| 250 |
+
|
| 251 |
+
# -- Input projection --
|
| 252 |
+
self.input_proj = nn.Sequential(
|
| 253 |
+
nn.Linear(in_dim, dim_feedforward),
|
| 254 |
+
nn.GELU(),
|
| 255 |
+
nn.Linear(dim_feedforward, hidden),
|
| 256 |
+
norm_class(hidden),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# -- Optional pre-encoder: self-attention on full token sequence --
|
| 260 |
+
if pre_encoder_layers > 0:
|
| 261 |
+
self.pre_encoder = nn.ModuleList([
|
| 262 |
+
SelfAttentionEncoderLayer(
|
| 263 |
+
d_model=hidden,
|
| 264 |
+
num_heads=num_heads,
|
| 265 |
+
dim_ff=dim_feedforward,
|
| 266 |
+
dropout=dropout,
|
| 267 |
+
activation=activation,
|
| 268 |
+
kv_heads=kv_heads_self,
|
| 269 |
+
norm_class=norm_class,
|
| 270 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 271 |
+
)
|
| 272 |
+
for _ in range(pre_encoder_layers)
|
| 273 |
+
])
|
| 274 |
+
else:
|
| 275 |
+
self.pre_encoder = None
|
| 276 |
+
|
| 277 |
+
# -- Perceiver latent bottleneck --
|
| 278 |
+
self.latent_embed = nn.Embedding(latent_tokens, hidden)
|
| 279 |
+
N = latent_layers
|
| 280 |
+
self.latent_layers = nn.ModuleList([
|
| 281 |
+
PerceiverLatentLayer(
|
| 282 |
+
d_model=hidden,
|
| 283 |
+
num_heads=num_heads,
|
| 284 |
+
dim_ff=dim_feedforward,
|
| 285 |
+
dropout=dropout,
|
| 286 |
+
activation=activation,
|
| 287 |
+
kv_heads_cross=kv_heads_cross,
|
| 288 |
+
kv_heads_self=kv_heads_self,
|
| 289 |
+
use_cross=(i == 0) or (i == N - 1) or (i % cross_attn_interval == 0),
|
| 290 |
+
norm_class=norm_class,
|
| 291 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 292 |
+
)
|
| 293 |
+
for i in range(N)
|
| 294 |
+
])
|
| 295 |
+
|
| 296 |
+
# -- Segment decoder --
|
| 297 |
+
self.query_embed = nn.Embedding(segments, hidden)
|
| 298 |
+
self.decoder_layers = nn.ModuleList([
|
| 299 |
+
SegmentDecoderLayer(
|
| 300 |
+
d_model=hidden,
|
| 301 |
+
num_heads=num_heads,
|
| 302 |
+
dim_ff=dim_feedforward,
|
| 303 |
+
dropout=dropout,
|
| 304 |
+
activation=activation,
|
| 305 |
+
kv_heads_cross=kv_heads_cross,
|
| 306 |
+
kv_heads_self=kv_heads_self,
|
| 307 |
+
norm_class=norm_class,
|
| 308 |
+
input_xattn=decoder_input_xattn,
|
| 309 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 310 |
+
)
|
| 311 |
+
for _ in range(decoder_layers)
|
| 312 |
+
])
|
| 313 |
+
|
| 314 |
+
# -- Output head --
|
| 315 |
+
if segment_param == "midpoint_dir_len":
|
| 316 |
+
self.segment_head = nn.Linear(hidden, 7) # mid(3) + dir(3) + len(1)
|
| 317 |
+
else:
|
| 318 |
+
self.segment_head = nn.Linear(hidden, 6) # mid(3) + half(3)
|
| 319 |
+
self.query_offsets = nn.Parameter(torch.zeros(segments, 2, 3))
|
| 320 |
+
|
| 321 |
+
nn.init.trunc_normal_(self.segment_head.weight, mean=0.0, std=1e-3)
|
| 322 |
+
if self.segment_head.bias is not None:
|
| 323 |
+
nn.init.zeros_(self.segment_head.bias)
|
| 324 |
+
if segment_param == "midpoint_dir_len":
|
| 325 |
+
# softplus(0.5) * 0.1 ≈ 0.097 default length in normalized space
|
| 326 |
+
self.segment_head.bias.data[6] = 0.5
|
| 327 |
+
nn.init.normal_(self.query_offsets, mean=0.0, std=0.05)
|
| 328 |
+
|
| 329 |
+
# -- Optional confidence head --
|
| 330 |
+
self.segment_conf = segment_conf
|
| 331 |
+
if segment_conf:
|
| 332 |
+
self.conf_head = nn.Linear(hidden, 1)
|
| 333 |
+
nn.init.zeros_(self.conf_head.bias)
|
| 334 |
+
|
| 335 |
+
def forward(
|
| 336 |
+
self,
|
| 337 |
+
tokens: torch.Tensor,
|
| 338 |
+
mask: torch.Tensor | None = None,
|
| 339 |
+
) -> dict[str, torch.Tensor | list]:
|
| 340 |
+
"""
|
| 341 |
+
Args:
|
| 342 |
+
tokens: Input point-cloud tokens [B, T, in_dim].
|
| 343 |
+
mask: Boolean validity mask [B, T]. True = valid token.
|
| 344 |
+
|
| 345 |
+
Returns:
|
| 346 |
+
Dict with keys:
|
| 347 |
+
"vertices": [B, S*2, 3] flattened endpoints.
|
| 348 |
+
"segments": [B, S, 2, 3] segment endpoints.
|
| 349 |
+
"edges": Per-batch list of (start, end) index pairs into vertices.
|
| 350 |
+
"conf": [B, S] logits (only if segment_conf=True).
|
| 351 |
+
"""
|
| 352 |
+
B = tokens.shape[0]
|
| 353 |
+
|
| 354 |
+
# Project input tokens
|
| 355 |
+
src = self.input_proj(tokens) # [B, T, hidden]
|
| 356 |
+
|
| 357 |
+
# Padding mask (True where padded) for cross-attention
|
| 358 |
+
pad_mask = ~mask.bool() if mask is not None else None
|
| 359 |
+
|
| 360 |
+
# Optional pre-encoder: self-attention on full token sequence
|
| 361 |
+
if self.pre_encoder is not None:
|
| 362 |
+
for layer in self.pre_encoder:
|
| 363 |
+
src = layer(src, key_padding_mask=pad_mask)
|
| 364 |
+
|
| 365 |
+
# Perceiver latent bottleneck
|
| 366 |
+
latents = self.latent_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 367 |
+
for layer in self.latent_layers:
|
| 368 |
+
latents = layer(latents, src, points_key_padding_mask=pad_mask)
|
| 369 |
+
|
| 370 |
+
# Segment decoder
|
| 371 |
+
queries = self.query_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 372 |
+
for layer in self.decoder_layers:
|
| 373 |
+
queries = layer(queries, latents,
|
| 374 |
+
src=src if self.decoder_input_xattn else None,
|
| 375 |
+
src_key_padding_mask=pad_mask if self.decoder_input_xattn else None)
|
| 376 |
+
|
| 377 |
+
# Predict segments -> endpoints
|
| 378 |
+
if self.segment_param == "midpoint_dir_len":
|
| 379 |
+
raw = self.segment_head(queries) # [B, S, 7]
|
| 380 |
+
mid = raw[:, :, :3] + self.query_offsets[:, 0, :].unsqueeze(0)
|
| 381 |
+
direction = torch.nn.functional.normalize(raw[:, :, 3:6], dim=-1)
|
| 382 |
+
length = torch.nn.functional.softplus(raw[:, :, 6:7]) * 0.1
|
| 383 |
+
half = direction * length * 0.5
|
| 384 |
+
else:
|
| 385 |
+
raw = self.segment_head(queries).view(B, self.segments, 2, 3)
|
| 386 |
+
raw = raw + self.query_offsets.unsqueeze(0)
|
| 387 |
+
mid, half = raw[:, :, 0], raw[:, :, 1]
|
| 388 |
+
seg_params = torch.stack([mid - half, mid + half], dim=2)
|
| 389 |
+
|
| 390 |
+
vertices = seg_params.reshape(B, self.out_vertices, 3)
|
| 391 |
+
edges = [[(2 * i, 2 * i + 1) for i in range(self.segments)] for _ in range(B)]
|
| 392 |
+
|
| 393 |
+
out = {"vertices": vertices, "segments": seg_params, "edges": edges,
|
| 394 |
+
"src": src, "pad_mask": pad_mask, "queries": queries}
|
| 395 |
+
if self.segment_conf:
|
| 396 |
+
out["conf"] = self.conf_head(queries).squeeze(-1) # [B, S]
|
| 397 |
+
return out
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# ---------------------------------------------------------------------------
|
| 401 |
+
# PT v3 segmenter: same decoder + heads as TokenTransformerSegments but uses
|
| 402 |
+
# PointTransformerV3 (per-point features, no Perceiver bottleneck) as encoder.
|
| 403 |
+
# ---------------------------------------------------------------------------
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
class TokenPTv3Segments(nn.Module):
|
| 407 |
+
"""PT v3 encoder + DETR-style segment decoder.
|
| 408 |
+
|
| 409 |
+
Differs from TokenTransformerSegments only in the encoder: instead of an
|
| 410 |
+
input projection + Perceiver latent bottleneck, this uses PointTransformerV3
|
| 411 |
+
to produce per-point features [B, T, hidden] (no compression). The decoder,
|
| 412 |
+
output heads, and segment parameterization are identical.
|
| 413 |
+
|
| 414 |
+
The first 3 dims of the input `tokens` tensor are assumed to be xyz_norm
|
| 415 |
+
(per data.build_tokens / tokenizer order).
|
| 416 |
+
"""
|
| 417 |
+
|
| 418 |
+
def __init__(
|
| 419 |
+
self,
|
| 420 |
+
segments: int = 64,
|
| 421 |
+
in_dim: int = 95,
|
| 422 |
+
hidden: int = 256,
|
| 423 |
+
num_heads: int = 4,
|
| 424 |
+
kv_heads_cross: int | None = 2,
|
| 425 |
+
kv_heads_self: int | None = 2,
|
| 426 |
+
dim_feedforward: int = 1024,
|
| 427 |
+
dropout: float = 0.1,
|
| 428 |
+
decoder_layers: int = 3,
|
| 429 |
+
norm_class=None,
|
| 430 |
+
activation: str = "gelu",
|
| 431 |
+
segment_conf: bool = True,
|
| 432 |
+
segment_param: str = "midpoint_dir_len",
|
| 433 |
+
length_floor: float = 0.0,
|
| 434 |
+
decoder_input_xattn: bool = False,
|
| 435 |
+
qk_norm: bool = True,
|
| 436 |
+
qk_norm_type: str = "l2",
|
| 437 |
+
# PT v3 hyperparams (subset of PTv3Encoder kwargs)
|
| 438 |
+
ptv3_grid_size: float = 0.005,
|
| 439 |
+
ptv3_enc_channels: tuple = (32, 64, 128, 256, 256),
|
| 440 |
+
ptv3_enc_depths: tuple = (2, 2, 2, 6, 2),
|
| 441 |
+
ptv3_enc_num_head: tuple = (2, 4, 8, 16, 16),
|
| 442 |
+
ptv3_dec_channels: tuple = (256, 64, 128, 256),
|
| 443 |
+
ptv3_dec_depths: tuple = (2, 2, 2, 2),
|
| 444 |
+
ptv3_dec_num_head: tuple = (4, 4, 8, 16),
|
| 445 |
+
ptv3_stride: tuple = (2, 2, 2, 2),
|
| 446 |
+
ptv3_enable_flash: bool = True,
|
| 447 |
+
ptv3_drop_path: float = 0.3,
|
| 448 |
+
):
|
| 449 |
+
super().__init__()
|
| 450 |
+
from .ptv3.encoder_wrapper import PTv3Encoder
|
| 451 |
+
|
| 452 |
+
self.segments = segments
|
| 453 |
+
self.out_vertices = segments * 2
|
| 454 |
+
self.segment_param = segment_param
|
| 455 |
+
self.decoder_input_xattn = decoder_input_xattn
|
| 456 |
+
norm_class = norm_class or nn.LayerNorm
|
| 457 |
+
if kv_heads_cross is not None and kv_heads_cross <= 0:
|
| 458 |
+
kv_heads_cross = None
|
| 459 |
+
if kv_heads_self is not None and kv_heads_self <= 0:
|
| 460 |
+
kv_heads_self = None
|
| 461 |
+
|
| 462 |
+
# PT v3 encoder. in_channels = full token feature dim. coord is sliced
|
| 463 |
+
# from the first 3 dims of tokens at forward time.
|
| 464 |
+
self.ptv3_encoder = PTv3Encoder(
|
| 465 |
+
in_channels=in_dim,
|
| 466 |
+
hidden=hidden,
|
| 467 |
+
grid_size=ptv3_grid_size,
|
| 468 |
+
enc_channels=ptv3_enc_channels,
|
| 469 |
+
enc_depths=ptv3_enc_depths,
|
| 470 |
+
enc_num_head=ptv3_enc_num_head,
|
| 471 |
+
dec_channels=ptv3_dec_channels,
|
| 472 |
+
dec_depths=ptv3_dec_depths,
|
| 473 |
+
dec_num_head=ptv3_dec_num_head,
|
| 474 |
+
stride=ptv3_stride,
|
| 475 |
+
enable_flash=ptv3_enable_flash,
|
| 476 |
+
drop_path=ptv3_drop_path,
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
# DETR-style decoder (same as TokenTransformerSegments)
|
| 480 |
+
self.query_embed = nn.Embedding(segments, hidden)
|
| 481 |
+
self.decoder_layers = nn.ModuleList([
|
| 482 |
+
SegmentDecoderLayer(
|
| 483 |
+
d_model=hidden,
|
| 484 |
+
num_heads=num_heads,
|
| 485 |
+
dim_ff=dim_feedforward,
|
| 486 |
+
dropout=dropout,
|
| 487 |
+
activation=activation,
|
| 488 |
+
kv_heads_cross=kv_heads_cross,
|
| 489 |
+
kv_heads_self=kv_heads_self,
|
| 490 |
+
norm_class=norm_class,
|
| 491 |
+
input_xattn=decoder_input_xattn,
|
| 492 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 493 |
+
)
|
| 494 |
+
for _ in range(decoder_layers)
|
| 495 |
+
])
|
| 496 |
+
|
| 497 |
+
# Output heads (identical to TokenTransformerSegments)
|
| 498 |
+
if segment_param == "midpoint_dir_len":
|
| 499 |
+
self.segment_head = nn.Linear(hidden, 7)
|
| 500 |
+
else:
|
| 501 |
+
self.segment_head = nn.Linear(hidden, 6)
|
| 502 |
+
self.query_offsets = nn.Parameter(torch.zeros(segments, 2, 3))
|
| 503 |
+
nn.init.trunc_normal_(self.segment_head.weight, mean=0.0, std=1e-3)
|
| 504 |
+
if self.segment_head.bias is not None:
|
| 505 |
+
nn.init.zeros_(self.segment_head.bias)
|
| 506 |
+
if segment_param == "midpoint_dir_len":
|
| 507 |
+
self.segment_head.bias.data[6] = 0.5
|
| 508 |
+
nn.init.normal_(self.query_offsets, mean=0.0, std=0.05)
|
| 509 |
+
|
| 510 |
+
self.segment_conf = segment_conf
|
| 511 |
+
if segment_conf:
|
| 512 |
+
self.conf_head = nn.Linear(hidden, 1)
|
| 513 |
+
nn.init.zeros_(self.conf_head.bias)
|
| 514 |
+
|
| 515 |
+
def forward(self, tokens: torch.Tensor, mask: torch.Tensor):
|
| 516 |
+
"""
|
| 517 |
+
Args:
|
| 518 |
+
tokens: [B, T, in_dim] full per-point features. First 3 dims = xyz_norm.
|
| 519 |
+
mask: [B, T] boolean validity.
|
| 520 |
+
|
| 521 |
+
Returns same dict as TokenTransformerSegments.forward.
|
| 522 |
+
"""
|
| 523 |
+
B = tokens.shape[0]
|
| 524 |
+
coord = tokens[..., :3] # [B, T, 3]
|
| 525 |
+
# PT v3 encoder returns per-point features at original token resolution.
|
| 526 |
+
src = self.ptv3_encoder(coord, tokens, mask=mask) # [B, T, hidden]
|
| 527 |
+
|
| 528 |
+
pad_mask = ~mask.bool() if mask is not None else None
|
| 529 |
+
|
| 530 |
+
# DETR-style decoder cross-attends to per-point features.
|
| 531 |
+
queries = self.query_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 532 |
+
for layer in self.decoder_layers:
|
| 533 |
+
queries = layer(queries, src,
|
| 534 |
+
src=src if self.decoder_input_xattn else None,
|
| 535 |
+
src_key_padding_mask=pad_mask if self.decoder_input_xattn else None)
|
| 536 |
+
|
| 537 |
+
# Predict segments -> endpoints (identical to TokenTransformerSegments)
|
| 538 |
+
if self.segment_param == "midpoint_dir_len":
|
| 539 |
+
raw = self.segment_head(queries)
|
| 540 |
+
mid = raw[:, :, :3] + self.query_offsets[:, 0, :].unsqueeze(0)
|
| 541 |
+
direction = torch.nn.functional.normalize(raw[:, :, 3:6], dim=-1)
|
| 542 |
+
length = torch.nn.functional.softplus(raw[:, :, 6:7]) * 0.1
|
| 543 |
+
half = direction * length * 0.5
|
| 544 |
+
else:
|
| 545 |
+
raw = self.segment_head(queries).view(B, self.segments, 2, 3)
|
| 546 |
+
raw = raw + self.query_offsets.unsqueeze(0)
|
| 547 |
+
mid, half = raw[:, :, 0], raw[:, :, 1]
|
| 548 |
+
seg_params = torch.stack([mid - half, mid + half], dim=2)
|
| 549 |
+
|
| 550 |
+
vertices = seg_params.reshape(B, self.out_vertices, 3)
|
| 551 |
+
edges = [[(2 * i, 2 * i + 1) for i in range(self.segments)] for _ in range(B)]
|
| 552 |
+
|
| 553 |
+
out = {"vertices": vertices, "segments": seg_params, "edges": edges,
|
| 554 |
+
"src": src, "pad_mask": pad_mask, "queries": queries}
|
| 555 |
+
if self.segment_conf:
|
| 556 |
+
out["conf"] = self.conf_head(queries).squeeze(-1)
|
| 557 |
+
return out
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
# ---------------------------------------------------------------------------
|
| 561 |
+
# Encoder-only layer (self-attention on full token sequence)
|
| 562 |
+
# ---------------------------------------------------------------------------
|
| 563 |
+
|
| 564 |
+
class SelfAttentionEncoderLayer(nn.Module):
|
| 565 |
+
"""Single self-attention layer: self-attn(tokens) -> FFN."""
|
| 566 |
+
|
| 567 |
+
def __init__(
|
| 568 |
+
self,
|
| 569 |
+
d_model: int,
|
| 570 |
+
num_heads: int,
|
| 571 |
+
dim_ff: int,
|
| 572 |
+
dropout: float = 0.0,
|
| 573 |
+
activation: str = "gelu",
|
| 574 |
+
kv_heads: int | None = None,
|
| 575 |
+
norm_class=None,
|
| 576 |
+
qk_norm: bool = False,
|
| 577 |
+
qk_norm_type: str = "l2",
|
| 578 |
+
):
|
| 579 |
+
super().__init__()
|
| 580 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 581 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 582 |
+
|
| 583 |
+
def forward(self, x: torch.Tensor, key_padding_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 584 |
+
x = self.self_attn(x, x, memory_key_padding_mask=key_padding_mask)
|
| 585 |
+
x = self.ffn(x)
|
| 586 |
+
return x
|
| 587 |
+
|
| 588 |
+
|
| 589 |
+
# ---------------------------------------------------------------------------
|
| 590 |
+
# End-to-end model: tokenizer embeddings + perceiver
|
| 591 |
+
# ---------------------------------------------------------------------------
|
| 592 |
+
|
| 593 |
+
class EdgeDepthSegmentsModel(nn.Module):
|
| 594 |
+
"""Tokenizer embeddings + transformer for 3D roof wireframes.
|
| 595 |
+
|
| 596 |
+
Supports two architectures via the `arch` parameter:
|
| 597 |
+
- "perceiver": Perceiver latent bottleneck (default, O(L*T) attention)
|
| 598 |
+
- "transformer": Standard self-attention encoder (O(T^2) attention)
|
| 599 |
+
|
| 600 |
+
Both share the same decoder, output head, and tokenizer.
|
| 601 |
+
"""
|
| 602 |
+
|
| 603 |
+
def __init__(
|
| 604 |
+
self,
|
| 605 |
+
seq_cfg,
|
| 606 |
+
segments: int = 32,
|
| 607 |
+
hidden: int = 128,
|
| 608 |
+
num_heads: int = 4,
|
| 609 |
+
kv_heads_cross: int | None = 2,
|
| 610 |
+
kv_heads_self: int | None = 0,
|
| 611 |
+
dim_feedforward: int = 256,
|
| 612 |
+
dropout: float = 0.1,
|
| 613 |
+
latent_tokens: int = 64,
|
| 614 |
+
latent_layers: int = 1,
|
| 615 |
+
decoder_layers: int = 2,
|
| 616 |
+
label_emb_dim: int = 16,
|
| 617 |
+
src_emb_dim: int = 2,
|
| 618 |
+
behind_emb_dim: int = 8,
|
| 619 |
+
fourier_seed: int = 0,
|
| 620 |
+
cross_attn_interval: int = 1,
|
| 621 |
+
norm_class=None,
|
| 622 |
+
activation: str = "gelu",
|
| 623 |
+
segment_conf: bool = False,
|
| 624 |
+
use_vote_features: bool = False,
|
| 625 |
+
arch: str = "perceiver",
|
| 626 |
+
encoder_layers: int = 4,
|
| 627 |
+
pre_encoder_layers: int = 0,
|
| 628 |
+
segment_param: str = "midpoint_halfvec",
|
| 629 |
+
length_floor: float = 0.0,
|
| 630 |
+
decoder_input_xattn: bool = False,
|
| 631 |
+
qk_norm: bool = False,
|
| 632 |
+
qk_norm_type: str = "l2",
|
| 633 |
+
learnable_fourier: bool = False,
|
| 634 |
+
):
|
| 635 |
+
super().__init__()
|
| 636 |
+
self.seq_cfg = seq_cfg
|
| 637 |
+
|
| 638 |
+
from .tokenizer import EdgeDepthSequenceBuilder
|
| 639 |
+
self.tokenizer = EdgeDepthSequenceBuilder(
|
| 640 |
+
seq_cfg,
|
| 641 |
+
label_emb_dim=label_emb_dim,
|
| 642 |
+
src_emb_dim=src_emb_dim,
|
| 643 |
+
behind_emb_dim=behind_emb_dim,
|
| 644 |
+
fourier_seed=fourier_seed,
|
| 645 |
+
use_vote_features=use_vote_features,
|
| 646 |
+
learnable_fourier=learnable_fourier,
|
| 647 |
+
)
|
| 648 |
+
|
| 649 |
+
if arch == "transformer":
|
| 650 |
+
raise ValueError(
|
| 651 |
+
"arch='transformer' is no longer supported. "
|
| 652 |
+
"TransformerSegments has been removed; use arch='perceiver'.")
|
| 653 |
+
elif arch == "ptv3":
|
| 654 |
+
self.segmenter = TokenPTv3Segments(
|
| 655 |
+
segments=segments,
|
| 656 |
+
in_dim=self.tokenizer.out_dim,
|
| 657 |
+
hidden=hidden,
|
| 658 |
+
num_heads=num_heads,
|
| 659 |
+
kv_heads_cross=kv_heads_cross,
|
| 660 |
+
kv_heads_self=kv_heads_self,
|
| 661 |
+
dim_feedforward=dim_feedforward,
|
| 662 |
+
dropout=dropout,
|
| 663 |
+
decoder_layers=decoder_layers,
|
| 664 |
+
norm_class=norm_class,
|
| 665 |
+
activation=activation,
|
| 666 |
+
segment_conf=segment_conf,
|
| 667 |
+
segment_param=segment_param,
|
| 668 |
+
length_floor=length_floor,
|
| 669 |
+
decoder_input_xattn=decoder_input_xattn,
|
| 670 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 671 |
+
)
|
| 672 |
+
else:
|
| 673 |
+
self.segmenter = TokenTransformerSegments(
|
| 674 |
+
segments=segments,
|
| 675 |
+
in_dim=self.tokenizer.out_dim,
|
| 676 |
+
hidden=hidden,
|
| 677 |
+
num_heads=num_heads,
|
| 678 |
+
kv_heads_cross=kv_heads_cross,
|
| 679 |
+
kv_heads_self=kv_heads_self,
|
| 680 |
+
dim_feedforward=dim_feedforward,
|
| 681 |
+
dropout=dropout,
|
| 682 |
+
latent_tokens=latent_tokens,
|
| 683 |
+
latent_layers=latent_layers,
|
| 684 |
+
decoder_layers=decoder_layers,
|
| 685 |
+
cross_attn_interval=cross_attn_interval,
|
| 686 |
+
norm_class=norm_class,
|
| 687 |
+
activation=activation,
|
| 688 |
+
segment_conf=segment_conf,
|
| 689 |
+
pre_encoder_layers=pre_encoder_layers,
|
| 690 |
+
segment_param=segment_param,
|
| 691 |
+
length_floor=length_floor,
|
| 692 |
+
decoder_input_xattn=decoder_input_xattn,
|
| 693 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 694 |
+
)
|
| 695 |
+
|
| 696 |
+
def forward_tokens(self, tokens: torch.Tensor, mask: torch.Tensor):
|
| 697 |
+
"""Run the segmenter on pre-built token tensors."""
|
| 698 |
+
return self.segmenter(tokens, mask)
|
experiments/ptv3/ptv3_code/__init__.py
ADDED
|
File without changes
|
experiments/ptv3/ptv3_code/encoder_wrapper.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapter that lets PointTransformerV3 plug into our existing model pipeline.
|
| 2 |
+
|
| 3 |
+
Bridges our [B, T, ·] batched format with PT v3's flat [Σ T_i, ·] + batch-indices
|
| 4 |
+
format. Handles voxel deduplication via inverse mapping so output point count
|
| 5 |
+
matches input exactly, even when multiple input points fall in the same voxel.
|
| 6 |
+
|
| 7 |
+
Drop-in replacement for the Perceiver latent bottleneck:
|
| 8 |
+
- Input: per-token features [B, T, in_dim] and coord [B, T, 3]
|
| 9 |
+
- Output: per-point features [B, T, hidden_out]
|
| 10 |
+
- Output then fed to the existing DETR-style segment decoder (cross-attn over T tokens).
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
import torch.nn as nn
|
| 16 |
+
|
| 17 |
+
from .model import PointTransformerV3
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class PTv3Encoder(nn.Module):
|
| 21 |
+
"""Wrap PointTransformerV3 in a [B, T, ·] interface."""
|
| 22 |
+
|
| 23 |
+
def __init__(
|
| 24 |
+
self,
|
| 25 |
+
in_channels: int,
|
| 26 |
+
hidden: int = 256,
|
| 27 |
+
grid_size: float = 0.005,
|
| 28 |
+
enc_channels: tuple = (32, 64, 128, 256, 256),
|
| 29 |
+
enc_depths: tuple = (2, 2, 2, 6, 2),
|
| 30 |
+
enc_num_head: tuple = (2, 4, 8, 16, 16),
|
| 31 |
+
enc_patch_size: tuple = (1024, 1024, 1024, 1024, 1024),
|
| 32 |
+
dec_channels: tuple = (256, 64, 128, 256),
|
| 33 |
+
dec_depths: tuple = (2, 2, 2, 2),
|
| 34 |
+
dec_num_head: tuple = (4, 4, 8, 16),
|
| 35 |
+
dec_patch_size: tuple = (1024, 1024, 1024, 1024),
|
| 36 |
+
stride: tuple = (2, 2, 2, 2),
|
| 37 |
+
order: tuple = ("z", "hilbert"),
|
| 38 |
+
enable_flash: bool = False,
|
| 39 |
+
shuffle_orders: bool = True,
|
| 40 |
+
drop_path: float = 0.3,
|
| 41 |
+
):
|
| 42 |
+
super().__init__()
|
| 43 |
+
if dec_channels[0] != hidden:
|
| 44 |
+
raise ValueError(
|
| 45 |
+
f"dec_channels[0]={dec_channels[0]} must equal hidden={hidden} "
|
| 46 |
+
f"so PT v3 output dim matches the rest of the model."
|
| 47 |
+
)
|
| 48 |
+
self.hidden = hidden
|
| 49 |
+
self.grid_size = grid_size
|
| 50 |
+
|
| 51 |
+
# Gracefully fall back to standard attention if flash_attn isn't installed.
|
| 52 |
+
# Same weights work in both modes (mathematically equivalent).
|
| 53 |
+
if enable_flash:
|
| 54 |
+
try:
|
| 55 |
+
import flash_attn # noqa: F401
|
| 56 |
+
except ImportError:
|
| 57 |
+
enable_flash = False
|
| 58 |
+
|
| 59 |
+
self.ptv3 = PointTransformerV3(
|
| 60 |
+
in_channels=in_channels,
|
| 61 |
+
order=order,
|
| 62 |
+
stride=stride,
|
| 63 |
+
enc_depths=enc_depths,
|
| 64 |
+
enc_channels=enc_channels,
|
| 65 |
+
enc_num_head=enc_num_head,
|
| 66 |
+
enc_patch_size=enc_patch_size,
|
| 67 |
+
dec_depths=dec_depths,
|
| 68 |
+
dec_channels=list(dec_channels),
|
| 69 |
+
dec_num_head=dec_num_head,
|
| 70 |
+
dec_patch_size=dec_patch_size,
|
| 71 |
+
drop_path=drop_path,
|
| 72 |
+
enable_flash=enable_flash,
|
| 73 |
+
shuffle_orders=shuffle_orders,
|
| 74 |
+
cls_mode=False,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
def forward(self, coord: torch.Tensor, feat: torch.Tensor,
|
| 78 |
+
mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 79 |
+
"""
|
| 80 |
+
Args:
|
| 81 |
+
coord: [B, T, 3] xyz (normalized to roughly [-1, 1])
|
| 82 |
+
feat: [B, T, in_channels] per-token features
|
| 83 |
+
mask: [B, T] bool; True=valid. If None, all valid.
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
[B, T, hidden] per-point features. Invalid positions are zeroed.
|
| 87 |
+
"""
|
| 88 |
+
from addict import Dict
|
| 89 |
+
|
| 90 |
+
B, T, _ = coord.shape
|
| 91 |
+
device = coord.device
|
| 92 |
+
|
| 93 |
+
if mask is None:
|
| 94 |
+
valid = torch.ones(B, T, dtype=torch.bool, device=device)
|
| 95 |
+
else:
|
| 96 |
+
valid = mask.bool()
|
| 97 |
+
|
| 98 |
+
flat_mask = valid.reshape(-1) # [B*T]
|
| 99 |
+
coord_flat = coord.reshape(-1, 3)[flat_mask] # [N_valid, 3]
|
| 100 |
+
feat_flat = feat.reshape(-1, feat.shape[-1])[flat_mask] # [N_valid, in_channels]
|
| 101 |
+
batch_per_point = torch.arange(B, device=device).repeat_interleave(T)
|
| 102 |
+
batch = batch_per_point[flat_mask] # [N_valid]
|
| 103 |
+
N_valid = coord_flat.shape[0]
|
| 104 |
+
|
| 105 |
+
# Deduplicate (batch, voxel) cells. Multiple input points falling in the
|
| 106 |
+
# same voxel get merged into one PT v3 token; we map the output back to
|
| 107 |
+
# all original points via the inverse mapping.
|
| 108 |
+
coord_min = coord_flat.min(dim=0).values
|
| 109 |
+
grid_coord = torch.div(
|
| 110 |
+
coord_flat - coord_min, self.grid_size, rounding_mode="trunc"
|
| 111 |
+
).long() # [N_valid, 3]
|
| 112 |
+
gmax = grid_coord.max(dim=0).values + 1 # [3]
|
| 113 |
+
stride_y = gmax[2].item()
|
| 114 |
+
stride_x = gmax[1].item() * stride_y
|
| 115 |
+
stride_b = gmax[0].item() * stride_x
|
| 116 |
+
|
| 117 |
+
voxel_id = (
|
| 118 |
+
batch * stride_b
|
| 119 |
+
+ grid_coord[:, 0] * stride_x
|
| 120 |
+
+ grid_coord[:, 1] * stride_y
|
| 121 |
+
+ grid_coord[:, 2]
|
| 122 |
+
)
|
| 123 |
+
unique_ids, inverse_idx = torch.unique(voxel_id, return_inverse=True)
|
| 124 |
+
N_unique = unique_ids.shape[0]
|
| 125 |
+
|
| 126 |
+
# For each unique cell, pick its first appearance to define the cell's coord/feat.
|
| 127 |
+
# (PT v3 will internally rebuild structure, this is just initialization.)
|
| 128 |
+
perm = torch.argsort(inverse_idx, stable=True)
|
| 129 |
+
first_in_unique = torch.empty(N_unique, dtype=torch.long, device=device)
|
| 130 |
+
# scatter: for each i in perm in order, last write wins; but with stable sort
|
| 131 |
+
# the first occurrence has lowest position index, so we need to write in reverse.
|
| 132 |
+
first_in_unique.scatter_(
|
| 133 |
+
0, inverse_idx[perm].flip(0), perm.flip(0)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
coord_u = coord_flat[first_in_unique] # [N_unique, 3]
|
| 137 |
+
feat_u = feat_flat[first_in_unique].contiguous() # [N_unique, in_channels]
|
| 138 |
+
batch_u = batch[first_in_unique].contiguous() # [N_unique]
|
| 139 |
+
# Sort by batch (PT v3 expects ascending batch)
|
| 140 |
+
sort_perm = torch.argsort(batch_u, stable=True)
|
| 141 |
+
coord_u = coord_u[sort_perm].contiguous()
|
| 142 |
+
feat_u = feat_u[sort_perm].contiguous()
|
| 143 |
+
batch_u = batch_u[sort_perm].contiguous()
|
| 144 |
+
# We need inverse mapping that says: for each original point, where is its unique-cell after sort?
|
| 145 |
+
inv_sort = torch.empty_like(sort_perm)
|
| 146 |
+
inv_sort[sort_perm] = torch.arange(N_unique, device=device)
|
| 147 |
+
sorted_unique_idx_for_orig = inv_sort[inverse_idx] # [N_valid]
|
| 148 |
+
|
| 149 |
+
# Build the Point dict and run PT v3.
|
| 150 |
+
data_dict = Dict(
|
| 151 |
+
coord=coord_u,
|
| 152 |
+
feat=feat_u,
|
| 153 |
+
batch=batch_u,
|
| 154 |
+
grid_size=self.grid_size,
|
| 155 |
+
)
|
| 156 |
+
out_point = self.ptv3(data_dict)
|
| 157 |
+
out_feat_unique = out_point.feat # [N_unique_out, hidden]
|
| 158 |
+
|
| 159 |
+
# PT v3 with cls_mode=False restores original (unique-cell) point count, so
|
| 160 |
+
# N_unique_out == N_unique.
|
| 161 |
+
if out_feat_unique.shape[0] != N_unique:
|
| 162 |
+
raise RuntimeError(
|
| 163 |
+
f"PT v3 output count {out_feat_unique.shape[0]} != input unique count {N_unique}. "
|
| 164 |
+
f"Did you set cls_mode=False?"
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Map unique-cell features back to all original valid points (duplicates share features).
|
| 168 |
+
per_point_feat = out_feat_unique[sorted_unique_idx_for_orig] # [N_valid, hidden]
|
| 169 |
+
|
| 170 |
+
# Scatter back into [B*T, hidden] then reshape to [B, T, hidden]
|
| 171 |
+
out = torch.zeros(B * T, self.hidden,
|
| 172 |
+
device=device, dtype=per_point_feat.dtype)
|
| 173 |
+
out[flat_mask] = per_point_feat
|
| 174 |
+
return out.reshape(B, T, self.hidden)
|
experiments/ptv3/ptv3_code/model.py
ADDED
|
@@ -0,0 +1,982 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Point Transformer - V3 Mode1
|
| 3 |
+
Pointcept detached version
|
| 4 |
+
|
| 5 |
+
Author: Xiaoyang Wu (xiaoyang.wu.cs@gmail.com)
|
| 6 |
+
Please cite our work if the code is helpful to you.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
from functools import partial
|
| 11 |
+
from addict import Dict
|
| 12 |
+
import math
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
import spconv.pytorch as spconv
|
| 16 |
+
import torch_scatter
|
| 17 |
+
from timm.models.layers import DropPath
|
| 18 |
+
from collections import OrderedDict
|
| 19 |
+
|
| 20 |
+
try:
|
| 21 |
+
import flash_attn
|
| 22 |
+
except ImportError:
|
| 23 |
+
flash_attn = None
|
| 24 |
+
|
| 25 |
+
from .serialization import encode
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@torch.inference_mode()
|
| 29 |
+
def offset2bincount(offset):
|
| 30 |
+
return torch.diff(
|
| 31 |
+
offset, prepend=torch.tensor([0], device=offset.device, dtype=torch.long)
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@torch.inference_mode()
|
| 36 |
+
def offset2batch(offset):
|
| 37 |
+
bincount = offset2bincount(offset)
|
| 38 |
+
return torch.arange(
|
| 39 |
+
len(bincount), device=offset.device, dtype=torch.long
|
| 40 |
+
).repeat_interleave(bincount)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
@torch.inference_mode()
|
| 44 |
+
def batch2offset(batch):
|
| 45 |
+
return torch.cumsum(batch.bincount(), dim=0).long()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class Point(Dict):
|
| 49 |
+
"""
|
| 50 |
+
Point Structure of Pointcept
|
| 51 |
+
|
| 52 |
+
A Point (point cloud) in Pointcept is a dictionary that contains various properties of
|
| 53 |
+
a batched point cloud. The property with the following names have a specific definition
|
| 54 |
+
as follows:
|
| 55 |
+
|
| 56 |
+
- "coord": original coordinate of point cloud;
|
| 57 |
+
- "grid_coord": grid coordinate for specific grid size (related to GridSampling);
|
| 58 |
+
Point also support the following optional attributes:
|
| 59 |
+
- "offset": if not exist, initialized as batch size is 1;
|
| 60 |
+
- "batch": if not exist, initialized as batch size is 1;
|
| 61 |
+
- "feat": feature of point cloud, default input of model;
|
| 62 |
+
- "grid_size": Grid size of point cloud (related to GridSampling);
|
| 63 |
+
(related to Serialization)
|
| 64 |
+
- "serialized_depth": depth of serialization, 2 ** depth * grid_size describe the maximum of point cloud range;
|
| 65 |
+
- "serialized_code": a list of serialization codes;
|
| 66 |
+
- "serialized_order": a list of serialization order determined by code;
|
| 67 |
+
- "serialized_inverse": a list of inverse mapping determined by code;
|
| 68 |
+
(related to Sparsify: SpConv)
|
| 69 |
+
- "sparse_shape": Sparse shape for Sparse Conv Tensor;
|
| 70 |
+
- "sparse_conv_feat": SparseConvTensor init with information provide by Point;
|
| 71 |
+
"""
|
| 72 |
+
|
| 73 |
+
def __init__(self, *args, **kwargs):
|
| 74 |
+
super().__init__(*args, **kwargs)
|
| 75 |
+
# If one of "offset" or "batch" do not exist, generate by the existing one
|
| 76 |
+
if "batch" not in self.keys() and "offset" in self.keys():
|
| 77 |
+
self["batch"] = offset2batch(self.offset)
|
| 78 |
+
elif "offset" not in self.keys() and "batch" in self.keys():
|
| 79 |
+
self["offset"] = batch2offset(self.batch)
|
| 80 |
+
|
| 81 |
+
def serialization(self, order="z", depth=None, shuffle_orders=False):
|
| 82 |
+
"""
|
| 83 |
+
Point Cloud Serialization
|
| 84 |
+
|
| 85 |
+
relay on ["grid_coord" or "coord" + "grid_size", "batch", "feat"]
|
| 86 |
+
"""
|
| 87 |
+
assert "batch" in self.keys()
|
| 88 |
+
if "grid_coord" not in self.keys():
|
| 89 |
+
# if you don't want to operate GridSampling in data augmentation,
|
| 90 |
+
# please add the following augmentation into your pipline:
|
| 91 |
+
# dict(type="Copy", keys_dict={"grid_size": 0.01}),
|
| 92 |
+
# (adjust `grid_size` to what your want)
|
| 93 |
+
assert {"grid_size", "coord"}.issubset(self.keys())
|
| 94 |
+
self["grid_coord"] = torch.div(
|
| 95 |
+
self.coord - self.coord.min(0)[0], self.grid_size, rounding_mode="trunc"
|
| 96 |
+
).int()
|
| 97 |
+
|
| 98 |
+
if depth is None:
|
| 99 |
+
# Adaptive measure the depth of serialization cube (length = 2 ^ depth)
|
| 100 |
+
depth = int(self.grid_coord.max()).bit_length()
|
| 101 |
+
self["serialized_depth"] = depth
|
| 102 |
+
# Maximum bit length for serialization code is 63 (int64)
|
| 103 |
+
assert depth * 3 + len(self.offset).bit_length() <= 63
|
| 104 |
+
# Here we follow OCNN and set the depth limitation to 16 (48bit) for the point position.
|
| 105 |
+
# Although depth is limited to less than 16, we can encode a 655.36^3 (2^16 * 0.01) meter^3
|
| 106 |
+
# cube with a grid size of 0.01 meter. We consider it is enough for the current stage.
|
| 107 |
+
# We can unlock the limitation by optimizing the z-order encoding function if necessary.
|
| 108 |
+
assert depth <= 16
|
| 109 |
+
|
| 110 |
+
# The serialization codes are arranged as following structures:
|
| 111 |
+
# [Order1 ([n]),
|
| 112 |
+
# Order2 ([n]),
|
| 113 |
+
# ...
|
| 114 |
+
# OrderN ([n])] (k, n)
|
| 115 |
+
code = [
|
| 116 |
+
encode(self.grid_coord, self.batch, depth, order=order_) for order_ in order
|
| 117 |
+
]
|
| 118 |
+
code = torch.stack(code)
|
| 119 |
+
order = torch.argsort(code)
|
| 120 |
+
inverse = torch.zeros_like(order).scatter_(
|
| 121 |
+
dim=1,
|
| 122 |
+
index=order,
|
| 123 |
+
src=torch.arange(0, code.shape[1], device=order.device).repeat(
|
| 124 |
+
code.shape[0], 1
|
| 125 |
+
),
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
if shuffle_orders:
|
| 129 |
+
perm = torch.randperm(code.shape[0])
|
| 130 |
+
code = code[perm]
|
| 131 |
+
order = order[perm]
|
| 132 |
+
inverse = inverse[perm]
|
| 133 |
+
|
| 134 |
+
self["serialized_code"] = code
|
| 135 |
+
self["serialized_order"] = order
|
| 136 |
+
self["serialized_inverse"] = inverse
|
| 137 |
+
|
| 138 |
+
def sparsify(self, pad=96):
|
| 139 |
+
"""
|
| 140 |
+
Point Cloud Serialization
|
| 141 |
+
|
| 142 |
+
Point cloud is sparse, here we use "sparsify" to specifically refer to
|
| 143 |
+
preparing "spconv.SparseConvTensor" for SpConv.
|
| 144 |
+
|
| 145 |
+
relay on ["grid_coord" or "coord" + "grid_size", "batch", "feat"]
|
| 146 |
+
|
| 147 |
+
pad: padding sparse for sparse shape.
|
| 148 |
+
"""
|
| 149 |
+
assert {"feat", "batch"}.issubset(self.keys())
|
| 150 |
+
if "grid_coord" not in self.keys():
|
| 151 |
+
# if you don't want to operate GridSampling in data augmentation,
|
| 152 |
+
# please add the following augmentation into your pipline:
|
| 153 |
+
# dict(type="Copy", keys_dict={"grid_size": 0.01}),
|
| 154 |
+
# (adjust `grid_size` to what your want)
|
| 155 |
+
assert {"grid_size", "coord"}.issubset(self.keys())
|
| 156 |
+
self["grid_coord"] = torch.div(
|
| 157 |
+
self.coord - self.coord.min(0)[0], self.grid_size, rounding_mode="trunc"
|
| 158 |
+
).int()
|
| 159 |
+
if "sparse_shape" in self.keys():
|
| 160 |
+
sparse_shape = self.sparse_shape
|
| 161 |
+
else:
|
| 162 |
+
sparse_shape = torch.add(
|
| 163 |
+
torch.max(self.grid_coord, dim=0).values, pad
|
| 164 |
+
).tolist()
|
| 165 |
+
sparse_conv_feat = spconv.SparseConvTensor(
|
| 166 |
+
features=self.feat,
|
| 167 |
+
indices=torch.cat(
|
| 168 |
+
[self.batch.unsqueeze(-1).int(), self.grid_coord.int()], dim=1
|
| 169 |
+
).contiguous(),
|
| 170 |
+
spatial_shape=sparse_shape,
|
| 171 |
+
batch_size=self.batch[-1].tolist() + 1,
|
| 172 |
+
)
|
| 173 |
+
self["sparse_shape"] = sparse_shape
|
| 174 |
+
self["sparse_conv_feat"] = sparse_conv_feat
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
class PointModule(nn.Module):
|
| 178 |
+
r"""PointModule
|
| 179 |
+
placeholder, all module subclass from this will take Point in PointSequential.
|
| 180 |
+
"""
|
| 181 |
+
|
| 182 |
+
def __init__(self, *args, **kwargs):
|
| 183 |
+
super().__init__(*args, **kwargs)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class PointSequential(PointModule):
|
| 187 |
+
r"""A sequential container.
|
| 188 |
+
Modules will be added to it in the order they are passed in the constructor.
|
| 189 |
+
Alternatively, an ordered dict of modules can also be passed in.
|
| 190 |
+
"""
|
| 191 |
+
|
| 192 |
+
def __init__(self, *args, **kwargs):
|
| 193 |
+
super().__init__()
|
| 194 |
+
if len(args) == 1 and isinstance(args[0], OrderedDict):
|
| 195 |
+
for key, module in args[0].items():
|
| 196 |
+
self.add_module(key, module)
|
| 197 |
+
else:
|
| 198 |
+
for idx, module in enumerate(args):
|
| 199 |
+
self.add_module(str(idx), module)
|
| 200 |
+
for name, module in kwargs.items():
|
| 201 |
+
if sys.version_info < (3, 6):
|
| 202 |
+
raise ValueError("kwargs only supported in py36+")
|
| 203 |
+
if name in self._modules:
|
| 204 |
+
raise ValueError("name exists.")
|
| 205 |
+
self.add_module(name, module)
|
| 206 |
+
|
| 207 |
+
def __getitem__(self, idx):
|
| 208 |
+
if not (-len(self) <= idx < len(self)):
|
| 209 |
+
raise IndexError("index {} is out of range".format(idx))
|
| 210 |
+
if idx < 0:
|
| 211 |
+
idx += len(self)
|
| 212 |
+
it = iter(self._modules.values())
|
| 213 |
+
for i in range(idx):
|
| 214 |
+
next(it)
|
| 215 |
+
return next(it)
|
| 216 |
+
|
| 217 |
+
def __len__(self):
|
| 218 |
+
return len(self._modules)
|
| 219 |
+
|
| 220 |
+
def add(self, module, name=None):
|
| 221 |
+
if name is None:
|
| 222 |
+
name = str(len(self._modules))
|
| 223 |
+
if name in self._modules:
|
| 224 |
+
raise KeyError("name exists")
|
| 225 |
+
self.add_module(name, module)
|
| 226 |
+
|
| 227 |
+
def forward(self, input):
|
| 228 |
+
for k, module in self._modules.items():
|
| 229 |
+
# Point module
|
| 230 |
+
if isinstance(module, PointModule):
|
| 231 |
+
input = module(input)
|
| 232 |
+
# Spconv module
|
| 233 |
+
elif spconv.modules.is_spconv_module(module):
|
| 234 |
+
if isinstance(input, Point):
|
| 235 |
+
input.sparse_conv_feat = module(input.sparse_conv_feat)
|
| 236 |
+
input.feat = input.sparse_conv_feat.features
|
| 237 |
+
else:
|
| 238 |
+
input = module(input)
|
| 239 |
+
# PyTorch module
|
| 240 |
+
else:
|
| 241 |
+
if isinstance(input, Point):
|
| 242 |
+
input.feat = module(input.feat)
|
| 243 |
+
if "sparse_conv_feat" in input.keys():
|
| 244 |
+
input.sparse_conv_feat = input.sparse_conv_feat.replace_feature(
|
| 245 |
+
input.feat
|
| 246 |
+
)
|
| 247 |
+
elif isinstance(input, spconv.SparseConvTensor):
|
| 248 |
+
if input.indices.shape[0] != 0:
|
| 249 |
+
input = input.replace_feature(module(input.features))
|
| 250 |
+
else:
|
| 251 |
+
input = module(input)
|
| 252 |
+
return input
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
class PDNorm(PointModule):
|
| 256 |
+
def __init__(
|
| 257 |
+
self,
|
| 258 |
+
num_features,
|
| 259 |
+
norm_layer,
|
| 260 |
+
context_channels=256,
|
| 261 |
+
conditions=("ScanNet", "S3DIS", "Structured3D"),
|
| 262 |
+
decouple=True,
|
| 263 |
+
adaptive=False,
|
| 264 |
+
):
|
| 265 |
+
super().__init__()
|
| 266 |
+
self.conditions = conditions
|
| 267 |
+
self.decouple = decouple
|
| 268 |
+
self.adaptive = adaptive
|
| 269 |
+
if self.decouple:
|
| 270 |
+
self.norm = nn.ModuleList([norm_layer(num_features) for _ in conditions])
|
| 271 |
+
else:
|
| 272 |
+
self.norm = norm_layer
|
| 273 |
+
if self.adaptive:
|
| 274 |
+
self.modulation = nn.Sequential(
|
| 275 |
+
nn.SiLU(), nn.Linear(context_channels, 2 * num_features, bias=True)
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
def forward(self, point):
|
| 279 |
+
assert {"feat", "condition"}.issubset(point.keys())
|
| 280 |
+
if isinstance(point.condition, str):
|
| 281 |
+
condition = point.condition
|
| 282 |
+
else:
|
| 283 |
+
condition = point.condition[0]
|
| 284 |
+
if self.decouple:
|
| 285 |
+
assert condition in self.conditions
|
| 286 |
+
norm = self.norm[self.conditions.index(condition)]
|
| 287 |
+
else:
|
| 288 |
+
norm = self.norm
|
| 289 |
+
point.feat = norm(point.feat)
|
| 290 |
+
if self.adaptive:
|
| 291 |
+
assert "context" in point.keys()
|
| 292 |
+
shift, scale = self.modulation(point.context).chunk(2, dim=1)
|
| 293 |
+
point.feat = point.feat * (1.0 + scale) + shift
|
| 294 |
+
return point
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
class RPE(torch.nn.Module):
|
| 298 |
+
def __init__(self, patch_size, num_heads):
|
| 299 |
+
super().__init__()
|
| 300 |
+
self.patch_size = patch_size
|
| 301 |
+
self.num_heads = num_heads
|
| 302 |
+
self.pos_bnd = int((4 * patch_size) ** (1 / 3) * 2)
|
| 303 |
+
self.rpe_num = 2 * self.pos_bnd + 1
|
| 304 |
+
self.rpe_table = torch.nn.Parameter(torch.zeros(3 * self.rpe_num, num_heads))
|
| 305 |
+
torch.nn.init.trunc_normal_(self.rpe_table, std=0.02)
|
| 306 |
+
|
| 307 |
+
def forward(self, coord):
|
| 308 |
+
idx = (
|
| 309 |
+
coord.clamp(-self.pos_bnd, self.pos_bnd) # clamp into bnd
|
| 310 |
+
+ self.pos_bnd # relative position to positive index
|
| 311 |
+
+ torch.arange(3, device=coord.device) * self.rpe_num # x, y, z stride
|
| 312 |
+
)
|
| 313 |
+
out = self.rpe_table.index_select(0, idx.reshape(-1))
|
| 314 |
+
out = out.view(idx.shape + (-1,)).sum(3)
|
| 315 |
+
out = out.permute(0, 3, 1, 2) # (N, K, K, H) -> (N, H, K, K)
|
| 316 |
+
return out
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
class SerializedAttention(PointModule):
|
| 320 |
+
def __init__(
|
| 321 |
+
self,
|
| 322 |
+
channels,
|
| 323 |
+
num_heads,
|
| 324 |
+
patch_size,
|
| 325 |
+
qkv_bias=True,
|
| 326 |
+
qk_scale=None,
|
| 327 |
+
attn_drop=0.0,
|
| 328 |
+
proj_drop=0.0,
|
| 329 |
+
order_index=0,
|
| 330 |
+
enable_rpe=False,
|
| 331 |
+
enable_flash=True,
|
| 332 |
+
upcast_attention=True,
|
| 333 |
+
upcast_softmax=True,
|
| 334 |
+
):
|
| 335 |
+
super().__init__()
|
| 336 |
+
assert channels % num_heads == 0
|
| 337 |
+
self.channels = channels
|
| 338 |
+
self.num_heads = num_heads
|
| 339 |
+
self.scale = qk_scale or (channels // num_heads) ** -0.5
|
| 340 |
+
self.order_index = order_index
|
| 341 |
+
self.upcast_attention = upcast_attention
|
| 342 |
+
self.upcast_softmax = upcast_softmax
|
| 343 |
+
self.enable_rpe = enable_rpe
|
| 344 |
+
self.enable_flash = enable_flash
|
| 345 |
+
if enable_flash:
|
| 346 |
+
assert (
|
| 347 |
+
enable_rpe is False
|
| 348 |
+
), "Set enable_rpe to False when enable Flash Attention"
|
| 349 |
+
assert (
|
| 350 |
+
upcast_attention is False
|
| 351 |
+
), "Set upcast_attention to False when enable Flash Attention"
|
| 352 |
+
assert (
|
| 353 |
+
upcast_softmax is False
|
| 354 |
+
), "Set upcast_softmax to False when enable Flash Attention"
|
| 355 |
+
assert flash_attn is not None, "Make sure flash_attn is installed."
|
| 356 |
+
self.patch_size = patch_size
|
| 357 |
+
self.attn_drop = attn_drop
|
| 358 |
+
else:
|
| 359 |
+
# when disable flash attention, we still don't want to use mask
|
| 360 |
+
# consequently, patch size will auto set to the
|
| 361 |
+
# min number of patch_size_max and number of points
|
| 362 |
+
self.patch_size_max = patch_size
|
| 363 |
+
self.patch_size = 0
|
| 364 |
+
self.attn_drop = torch.nn.Dropout(attn_drop)
|
| 365 |
+
|
| 366 |
+
self.qkv = torch.nn.Linear(channels, channels * 3, bias=qkv_bias)
|
| 367 |
+
self.proj = torch.nn.Linear(channels, channels)
|
| 368 |
+
self.proj_drop = torch.nn.Dropout(proj_drop)
|
| 369 |
+
self.softmax = torch.nn.Softmax(dim=-1)
|
| 370 |
+
self.rpe = RPE(patch_size, num_heads) if self.enable_rpe else None
|
| 371 |
+
|
| 372 |
+
@torch.no_grad()
|
| 373 |
+
def get_rel_pos(self, point, order):
|
| 374 |
+
K = self.patch_size
|
| 375 |
+
rel_pos_key = f"rel_pos_{self.order_index}"
|
| 376 |
+
if rel_pos_key not in point.keys():
|
| 377 |
+
grid_coord = point.grid_coord[order]
|
| 378 |
+
grid_coord = grid_coord.reshape(-1, K, 3)
|
| 379 |
+
point[rel_pos_key] = grid_coord.unsqueeze(2) - grid_coord.unsqueeze(1)
|
| 380 |
+
return point[rel_pos_key]
|
| 381 |
+
|
| 382 |
+
@torch.no_grad()
|
| 383 |
+
def get_padding_and_inverse(self, point):
|
| 384 |
+
pad_key = "pad"
|
| 385 |
+
unpad_key = "unpad"
|
| 386 |
+
cu_seqlens_key = "cu_seqlens_key"
|
| 387 |
+
if (
|
| 388 |
+
pad_key not in point.keys()
|
| 389 |
+
or unpad_key not in point.keys()
|
| 390 |
+
or cu_seqlens_key not in point.keys()
|
| 391 |
+
):
|
| 392 |
+
offset = point.offset
|
| 393 |
+
bincount = offset2bincount(offset)
|
| 394 |
+
bincount_pad = (
|
| 395 |
+
torch.div(
|
| 396 |
+
bincount + self.patch_size - 1,
|
| 397 |
+
self.patch_size,
|
| 398 |
+
rounding_mode="trunc",
|
| 399 |
+
)
|
| 400 |
+
* self.patch_size
|
| 401 |
+
)
|
| 402 |
+
# only pad point when num of points larger than patch_size
|
| 403 |
+
mask_pad = bincount > self.patch_size
|
| 404 |
+
bincount_pad = ~mask_pad * bincount + mask_pad * bincount_pad
|
| 405 |
+
_offset = nn.functional.pad(offset, (1, 0))
|
| 406 |
+
_offset_pad = nn.functional.pad(torch.cumsum(bincount_pad, dim=0), (1, 0))
|
| 407 |
+
pad = torch.arange(_offset_pad[-1], device=offset.device)
|
| 408 |
+
unpad = torch.arange(_offset[-1], device=offset.device)
|
| 409 |
+
cu_seqlens = []
|
| 410 |
+
for i in range(len(offset)):
|
| 411 |
+
unpad[_offset[i] : _offset[i + 1]] += _offset_pad[i] - _offset[i]
|
| 412 |
+
if bincount[i] != bincount_pad[i]:
|
| 413 |
+
pad[
|
| 414 |
+
_offset_pad[i + 1]
|
| 415 |
+
- self.patch_size
|
| 416 |
+
+ (bincount[i] % self.patch_size) : _offset_pad[i + 1]
|
| 417 |
+
] = pad[
|
| 418 |
+
_offset_pad[i + 1]
|
| 419 |
+
- 2 * self.patch_size
|
| 420 |
+
+ (bincount[i] % self.patch_size) : _offset_pad[i + 1]
|
| 421 |
+
- self.patch_size
|
| 422 |
+
]
|
| 423 |
+
pad[_offset_pad[i] : _offset_pad[i + 1]] -= _offset_pad[i] - _offset[i]
|
| 424 |
+
cu_seqlens.append(
|
| 425 |
+
torch.arange(
|
| 426 |
+
_offset_pad[i],
|
| 427 |
+
_offset_pad[i + 1],
|
| 428 |
+
step=self.patch_size,
|
| 429 |
+
dtype=torch.int32,
|
| 430 |
+
device=offset.device,
|
| 431 |
+
)
|
| 432 |
+
)
|
| 433 |
+
point[pad_key] = pad
|
| 434 |
+
point[unpad_key] = unpad
|
| 435 |
+
point[cu_seqlens_key] = nn.functional.pad(
|
| 436 |
+
torch.concat(cu_seqlens), (0, 1), value=_offset_pad[-1]
|
| 437 |
+
)
|
| 438 |
+
return point[pad_key], point[unpad_key], point[cu_seqlens_key]
|
| 439 |
+
|
| 440 |
+
def forward(self, point):
|
| 441 |
+
if not self.enable_flash:
|
| 442 |
+
self.patch_size = min(
|
| 443 |
+
offset2bincount(point.offset).min().tolist(), self.patch_size_max
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
H = self.num_heads
|
| 447 |
+
K = self.patch_size
|
| 448 |
+
C = self.channels
|
| 449 |
+
|
| 450 |
+
pad, unpad, cu_seqlens = self.get_padding_and_inverse(point)
|
| 451 |
+
|
| 452 |
+
order = point.serialized_order[self.order_index][pad]
|
| 453 |
+
inverse = unpad[point.serialized_inverse[self.order_index]]
|
| 454 |
+
|
| 455 |
+
# padding and reshape feat and batch for serialized point patch
|
| 456 |
+
qkv = self.qkv(point.feat)[order]
|
| 457 |
+
|
| 458 |
+
if not self.enable_flash:
|
| 459 |
+
# encode and reshape qkv: (N', K, 3, H, C') => (3, N', H, K, C')
|
| 460 |
+
q, k, v = (
|
| 461 |
+
qkv.reshape(-1, K, 3, H, C // H).permute(2, 0, 3, 1, 4).unbind(dim=0)
|
| 462 |
+
)
|
| 463 |
+
# attn
|
| 464 |
+
if self.upcast_attention:
|
| 465 |
+
q = q.float()
|
| 466 |
+
k = k.float()
|
| 467 |
+
attn = (q * self.scale) @ k.transpose(-2, -1) # (N', H, K, K)
|
| 468 |
+
if self.enable_rpe:
|
| 469 |
+
attn = attn + self.rpe(self.get_rel_pos(point, order))
|
| 470 |
+
if self.upcast_softmax:
|
| 471 |
+
attn = attn.float()
|
| 472 |
+
attn = self.softmax(attn)
|
| 473 |
+
attn = self.attn_drop(attn).to(qkv.dtype)
|
| 474 |
+
feat = (attn @ v).transpose(1, 2).reshape(-1, C)
|
| 475 |
+
else:
|
| 476 |
+
feat = flash_attn.flash_attn_varlen_qkvpacked_func(
|
| 477 |
+
qkv.half().reshape(-1, 3, H, C // H),
|
| 478 |
+
cu_seqlens,
|
| 479 |
+
max_seqlen=self.patch_size,
|
| 480 |
+
dropout_p=self.attn_drop if self.training else 0,
|
| 481 |
+
softmax_scale=self.scale,
|
| 482 |
+
).reshape(-1, C)
|
| 483 |
+
feat = feat.to(qkv.dtype)
|
| 484 |
+
feat = feat[inverse]
|
| 485 |
+
|
| 486 |
+
# ffn
|
| 487 |
+
feat = self.proj(feat)
|
| 488 |
+
feat = self.proj_drop(feat)
|
| 489 |
+
point.feat = feat
|
| 490 |
+
return point
|
| 491 |
+
|
| 492 |
+
|
| 493 |
+
class MLP(nn.Module):
|
| 494 |
+
def __init__(
|
| 495 |
+
self,
|
| 496 |
+
in_channels,
|
| 497 |
+
hidden_channels=None,
|
| 498 |
+
out_channels=None,
|
| 499 |
+
act_layer=nn.GELU,
|
| 500 |
+
drop=0.0,
|
| 501 |
+
):
|
| 502 |
+
super().__init__()
|
| 503 |
+
out_channels = out_channels or in_channels
|
| 504 |
+
hidden_channels = hidden_channels or in_channels
|
| 505 |
+
self.fc1 = nn.Linear(in_channels, hidden_channels)
|
| 506 |
+
self.act = act_layer()
|
| 507 |
+
self.fc2 = nn.Linear(hidden_channels, out_channels)
|
| 508 |
+
self.drop = nn.Dropout(drop)
|
| 509 |
+
|
| 510 |
+
def forward(self, x):
|
| 511 |
+
x = self.fc1(x)
|
| 512 |
+
x = self.act(x)
|
| 513 |
+
x = self.drop(x)
|
| 514 |
+
x = self.fc2(x)
|
| 515 |
+
x = self.drop(x)
|
| 516 |
+
return x
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
class Block(PointModule):
|
| 520 |
+
def __init__(
|
| 521 |
+
self,
|
| 522 |
+
channels,
|
| 523 |
+
num_heads,
|
| 524 |
+
patch_size=48,
|
| 525 |
+
mlp_ratio=4.0,
|
| 526 |
+
qkv_bias=True,
|
| 527 |
+
qk_scale=None,
|
| 528 |
+
attn_drop=0.0,
|
| 529 |
+
proj_drop=0.0,
|
| 530 |
+
drop_path=0.0,
|
| 531 |
+
norm_layer=nn.LayerNorm,
|
| 532 |
+
act_layer=nn.GELU,
|
| 533 |
+
pre_norm=True,
|
| 534 |
+
order_index=0,
|
| 535 |
+
cpe_indice_key=None,
|
| 536 |
+
enable_rpe=False,
|
| 537 |
+
enable_flash=True,
|
| 538 |
+
upcast_attention=True,
|
| 539 |
+
upcast_softmax=True,
|
| 540 |
+
):
|
| 541 |
+
super().__init__()
|
| 542 |
+
self.channels = channels
|
| 543 |
+
self.pre_norm = pre_norm
|
| 544 |
+
|
| 545 |
+
self.cpe = PointSequential(
|
| 546 |
+
spconv.SubMConv3d(
|
| 547 |
+
channels,
|
| 548 |
+
channels,
|
| 549 |
+
kernel_size=3,
|
| 550 |
+
bias=True,
|
| 551 |
+
indice_key=cpe_indice_key,
|
| 552 |
+
),
|
| 553 |
+
nn.Linear(channels, channels),
|
| 554 |
+
norm_layer(channels),
|
| 555 |
+
)
|
| 556 |
+
|
| 557 |
+
self.norm1 = PointSequential(norm_layer(channels))
|
| 558 |
+
self.attn = SerializedAttention(
|
| 559 |
+
channels=channels,
|
| 560 |
+
patch_size=patch_size,
|
| 561 |
+
num_heads=num_heads,
|
| 562 |
+
qkv_bias=qkv_bias,
|
| 563 |
+
qk_scale=qk_scale,
|
| 564 |
+
attn_drop=attn_drop,
|
| 565 |
+
proj_drop=proj_drop,
|
| 566 |
+
order_index=order_index,
|
| 567 |
+
enable_rpe=enable_rpe,
|
| 568 |
+
enable_flash=enable_flash,
|
| 569 |
+
upcast_attention=upcast_attention,
|
| 570 |
+
upcast_softmax=upcast_softmax,
|
| 571 |
+
)
|
| 572 |
+
self.norm2 = PointSequential(norm_layer(channels))
|
| 573 |
+
self.mlp = PointSequential(
|
| 574 |
+
MLP(
|
| 575 |
+
in_channels=channels,
|
| 576 |
+
hidden_channels=int(channels * mlp_ratio),
|
| 577 |
+
out_channels=channels,
|
| 578 |
+
act_layer=act_layer,
|
| 579 |
+
drop=proj_drop,
|
| 580 |
+
)
|
| 581 |
+
)
|
| 582 |
+
self.drop_path = PointSequential(
|
| 583 |
+
DropPath(drop_path) if drop_path > 0.0 else nn.Identity()
|
| 584 |
+
)
|
| 585 |
+
|
| 586 |
+
def forward(self, point: Point):
|
| 587 |
+
shortcut = point.feat
|
| 588 |
+
point = self.cpe(point)
|
| 589 |
+
point.feat = shortcut + point.feat
|
| 590 |
+
shortcut = point.feat
|
| 591 |
+
if self.pre_norm:
|
| 592 |
+
point = self.norm1(point)
|
| 593 |
+
point = self.drop_path(self.attn(point))
|
| 594 |
+
point.feat = shortcut + point.feat
|
| 595 |
+
if not self.pre_norm:
|
| 596 |
+
point = self.norm1(point)
|
| 597 |
+
|
| 598 |
+
shortcut = point.feat
|
| 599 |
+
if self.pre_norm:
|
| 600 |
+
point = self.norm2(point)
|
| 601 |
+
point = self.drop_path(self.mlp(point))
|
| 602 |
+
point.feat = shortcut + point.feat
|
| 603 |
+
if not self.pre_norm:
|
| 604 |
+
point = self.norm2(point)
|
| 605 |
+
point.sparse_conv_feat = point.sparse_conv_feat.replace_feature(point.feat)
|
| 606 |
+
return point
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
class SerializedPooling(PointModule):
|
| 610 |
+
def __init__(
|
| 611 |
+
self,
|
| 612 |
+
in_channels,
|
| 613 |
+
out_channels,
|
| 614 |
+
stride=2,
|
| 615 |
+
norm_layer=None,
|
| 616 |
+
act_layer=None,
|
| 617 |
+
reduce="max",
|
| 618 |
+
shuffle_orders=True,
|
| 619 |
+
traceable=True, # record parent and cluster
|
| 620 |
+
):
|
| 621 |
+
super().__init__()
|
| 622 |
+
self.in_channels = in_channels
|
| 623 |
+
self.out_channels = out_channels
|
| 624 |
+
|
| 625 |
+
assert stride == 2 ** (math.ceil(stride) - 1).bit_length() # 2, 4, 8
|
| 626 |
+
# TODO: add support to grid pool (any stride)
|
| 627 |
+
self.stride = stride
|
| 628 |
+
assert reduce in ["sum", "mean", "min", "max"]
|
| 629 |
+
self.reduce = reduce
|
| 630 |
+
self.shuffle_orders = shuffle_orders
|
| 631 |
+
self.traceable = traceable
|
| 632 |
+
|
| 633 |
+
self.proj = nn.Linear(in_channels, out_channels)
|
| 634 |
+
if norm_layer is not None:
|
| 635 |
+
self.norm = PointSequential(norm_layer(out_channels))
|
| 636 |
+
if act_layer is not None:
|
| 637 |
+
self.act = PointSequential(act_layer())
|
| 638 |
+
|
| 639 |
+
def forward(self, point: Point):
|
| 640 |
+
pooling_depth = (math.ceil(self.stride) - 1).bit_length()
|
| 641 |
+
if pooling_depth > point.serialized_depth:
|
| 642 |
+
pooling_depth = 0
|
| 643 |
+
assert {
|
| 644 |
+
"serialized_code",
|
| 645 |
+
"serialized_order",
|
| 646 |
+
"serialized_inverse",
|
| 647 |
+
"serialized_depth",
|
| 648 |
+
}.issubset(
|
| 649 |
+
point.keys()
|
| 650 |
+
), "Run point.serialization() point cloud before SerializedPooling"
|
| 651 |
+
|
| 652 |
+
code = point.serialized_code >> pooling_depth * 3
|
| 653 |
+
code_, cluster, counts = torch.unique(
|
| 654 |
+
code[0],
|
| 655 |
+
sorted=True,
|
| 656 |
+
return_inverse=True,
|
| 657 |
+
return_counts=True,
|
| 658 |
+
)
|
| 659 |
+
# indices of point sorted by cluster, for torch_scatter.segment_csr
|
| 660 |
+
_, indices = torch.sort(cluster)
|
| 661 |
+
# index pointer for sorted point, for torch_scatter.segment_csr
|
| 662 |
+
idx_ptr = torch.cat([counts.new_zeros(1), torch.cumsum(counts, dim=0)])
|
| 663 |
+
# head_indices of each cluster, for reduce attr e.g. code, batch
|
| 664 |
+
head_indices = indices[idx_ptr[:-1]]
|
| 665 |
+
# generate down code, order, inverse
|
| 666 |
+
code = code[:, head_indices]
|
| 667 |
+
order = torch.argsort(code)
|
| 668 |
+
inverse = torch.zeros_like(order).scatter_(
|
| 669 |
+
dim=1,
|
| 670 |
+
index=order,
|
| 671 |
+
src=torch.arange(0, code.shape[1], device=order.device).repeat(
|
| 672 |
+
code.shape[0], 1
|
| 673 |
+
),
|
| 674 |
+
)
|
| 675 |
+
|
| 676 |
+
if self.shuffle_orders:
|
| 677 |
+
perm = torch.randperm(code.shape[0])
|
| 678 |
+
code = code[perm]
|
| 679 |
+
order = order[perm]
|
| 680 |
+
inverse = inverse[perm]
|
| 681 |
+
|
| 682 |
+
# collect information
|
| 683 |
+
point_dict = Dict(
|
| 684 |
+
feat=torch_scatter.segment_csr(
|
| 685 |
+
self.proj(point.feat)[indices], idx_ptr, reduce=self.reduce
|
| 686 |
+
),
|
| 687 |
+
coord=torch_scatter.segment_csr(
|
| 688 |
+
point.coord[indices], idx_ptr, reduce="mean"
|
| 689 |
+
),
|
| 690 |
+
grid_coord=point.grid_coord[head_indices] >> pooling_depth,
|
| 691 |
+
serialized_code=code,
|
| 692 |
+
serialized_order=order,
|
| 693 |
+
serialized_inverse=inverse,
|
| 694 |
+
serialized_depth=point.serialized_depth - pooling_depth,
|
| 695 |
+
batch=point.batch[head_indices],
|
| 696 |
+
)
|
| 697 |
+
|
| 698 |
+
if "condition" in point.keys():
|
| 699 |
+
point_dict["condition"] = point.condition
|
| 700 |
+
if "context" in point.keys():
|
| 701 |
+
point_dict["context"] = point.context
|
| 702 |
+
|
| 703 |
+
if self.traceable:
|
| 704 |
+
point_dict["pooling_inverse"] = cluster
|
| 705 |
+
point_dict["pooling_parent"] = point
|
| 706 |
+
point = Point(point_dict)
|
| 707 |
+
if self.norm is not None:
|
| 708 |
+
point = self.norm(point)
|
| 709 |
+
if self.act is not None:
|
| 710 |
+
point = self.act(point)
|
| 711 |
+
point.sparsify()
|
| 712 |
+
return point
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
class SerializedUnpooling(PointModule):
|
| 716 |
+
def __init__(
|
| 717 |
+
self,
|
| 718 |
+
in_channels,
|
| 719 |
+
skip_channels,
|
| 720 |
+
out_channels,
|
| 721 |
+
norm_layer=None,
|
| 722 |
+
act_layer=None,
|
| 723 |
+
traceable=False, # record parent and cluster
|
| 724 |
+
):
|
| 725 |
+
super().__init__()
|
| 726 |
+
self.proj = PointSequential(nn.Linear(in_channels, out_channels))
|
| 727 |
+
self.proj_skip = PointSequential(nn.Linear(skip_channels, out_channels))
|
| 728 |
+
|
| 729 |
+
if norm_layer is not None:
|
| 730 |
+
self.proj.add(norm_layer(out_channels))
|
| 731 |
+
self.proj_skip.add(norm_layer(out_channels))
|
| 732 |
+
|
| 733 |
+
if act_layer is not None:
|
| 734 |
+
self.proj.add(act_layer())
|
| 735 |
+
self.proj_skip.add(act_layer())
|
| 736 |
+
|
| 737 |
+
self.traceable = traceable
|
| 738 |
+
|
| 739 |
+
def forward(self, point):
|
| 740 |
+
assert "pooling_parent" in point.keys()
|
| 741 |
+
assert "pooling_inverse" in point.keys()
|
| 742 |
+
parent = point.pop("pooling_parent")
|
| 743 |
+
inverse = point.pop("pooling_inverse")
|
| 744 |
+
point = self.proj(point)
|
| 745 |
+
parent = self.proj_skip(parent)
|
| 746 |
+
parent.feat = parent.feat + point.feat[inverse]
|
| 747 |
+
|
| 748 |
+
if self.traceable:
|
| 749 |
+
parent["unpooling_parent"] = point
|
| 750 |
+
return parent
|
| 751 |
+
|
| 752 |
+
|
| 753 |
+
class Embedding(PointModule):
|
| 754 |
+
def __init__(
|
| 755 |
+
self,
|
| 756 |
+
in_channels,
|
| 757 |
+
embed_channels,
|
| 758 |
+
norm_layer=None,
|
| 759 |
+
act_layer=None,
|
| 760 |
+
):
|
| 761 |
+
super().__init__()
|
| 762 |
+
self.in_channels = in_channels
|
| 763 |
+
self.embed_channels = embed_channels
|
| 764 |
+
|
| 765 |
+
# TODO: check remove spconv
|
| 766 |
+
self.stem = PointSequential(
|
| 767 |
+
conv=spconv.SubMConv3d(
|
| 768 |
+
in_channels,
|
| 769 |
+
embed_channels,
|
| 770 |
+
kernel_size=5,
|
| 771 |
+
padding=1,
|
| 772 |
+
bias=False,
|
| 773 |
+
indice_key="stem",
|
| 774 |
+
)
|
| 775 |
+
)
|
| 776 |
+
if norm_layer is not None:
|
| 777 |
+
self.stem.add(norm_layer(embed_channels), name="norm")
|
| 778 |
+
if act_layer is not None:
|
| 779 |
+
self.stem.add(act_layer(), name="act")
|
| 780 |
+
|
| 781 |
+
def forward(self, point: Point):
|
| 782 |
+
point = self.stem(point)
|
| 783 |
+
return point
|
| 784 |
+
|
| 785 |
+
|
| 786 |
+
class PointTransformerV3(PointModule):
|
| 787 |
+
def __init__(
|
| 788 |
+
self,
|
| 789 |
+
in_channels=6,
|
| 790 |
+
order=("z", "z-trans", "hilbert", "hilbert-trans"),
|
| 791 |
+
stride=(2, 2, 2, 2),
|
| 792 |
+
enc_depths=(2, 2, 2, 6, 2),
|
| 793 |
+
enc_channels=(32, 64, 128, 256, 512),
|
| 794 |
+
enc_num_head=(2, 4, 8, 16, 32),
|
| 795 |
+
enc_patch_size=(1024, 1024, 1024, 1024, 1024),
|
| 796 |
+
dec_depths=(2, 2, 2, 2),
|
| 797 |
+
dec_channels=(64, 64, 128, 256),
|
| 798 |
+
dec_num_head=(4, 4, 8, 16),
|
| 799 |
+
dec_patch_size=(1024, 1024, 1024, 1024),
|
| 800 |
+
mlp_ratio=4,
|
| 801 |
+
qkv_bias=True,
|
| 802 |
+
qk_scale=None,
|
| 803 |
+
attn_drop=0.0,
|
| 804 |
+
proj_drop=0.0,
|
| 805 |
+
drop_path=0.3,
|
| 806 |
+
pre_norm=True,
|
| 807 |
+
shuffle_orders=True,
|
| 808 |
+
enable_rpe=False,
|
| 809 |
+
enable_flash=True,
|
| 810 |
+
upcast_attention=False,
|
| 811 |
+
upcast_softmax=False,
|
| 812 |
+
cls_mode=False,
|
| 813 |
+
pdnorm_bn=False,
|
| 814 |
+
pdnorm_ln=False,
|
| 815 |
+
pdnorm_decouple=True,
|
| 816 |
+
pdnorm_adaptive=False,
|
| 817 |
+
pdnorm_affine=True,
|
| 818 |
+
pdnorm_conditions=("ScanNet", "S3DIS", "Structured3D"),
|
| 819 |
+
):
|
| 820 |
+
super().__init__()
|
| 821 |
+
self.num_stages = len(enc_depths)
|
| 822 |
+
self.order = [order] if isinstance(order, str) else order
|
| 823 |
+
self.cls_mode = cls_mode
|
| 824 |
+
self.shuffle_orders = shuffle_orders
|
| 825 |
+
|
| 826 |
+
assert self.num_stages == len(stride) + 1
|
| 827 |
+
assert self.num_stages == len(enc_depths)
|
| 828 |
+
assert self.num_stages == len(enc_channels)
|
| 829 |
+
assert self.num_stages == len(enc_num_head)
|
| 830 |
+
assert self.num_stages == len(enc_patch_size)
|
| 831 |
+
assert self.cls_mode or self.num_stages == len(dec_depths) + 1
|
| 832 |
+
assert self.cls_mode or self.num_stages == len(dec_channels) + 1
|
| 833 |
+
assert self.cls_mode or self.num_stages == len(dec_num_head) + 1
|
| 834 |
+
assert self.cls_mode or self.num_stages == len(dec_patch_size) + 1
|
| 835 |
+
|
| 836 |
+
# norm layers
|
| 837 |
+
if pdnorm_bn:
|
| 838 |
+
bn_layer = partial(
|
| 839 |
+
PDNorm,
|
| 840 |
+
norm_layer=partial(
|
| 841 |
+
nn.BatchNorm1d, eps=1e-3, momentum=0.01, affine=pdnorm_affine
|
| 842 |
+
),
|
| 843 |
+
conditions=pdnorm_conditions,
|
| 844 |
+
decouple=pdnorm_decouple,
|
| 845 |
+
adaptive=pdnorm_adaptive,
|
| 846 |
+
)
|
| 847 |
+
else:
|
| 848 |
+
bn_layer = partial(nn.BatchNorm1d, eps=1e-3, momentum=0.01)
|
| 849 |
+
if pdnorm_ln:
|
| 850 |
+
ln_layer = partial(
|
| 851 |
+
PDNorm,
|
| 852 |
+
norm_layer=partial(nn.LayerNorm, elementwise_affine=pdnorm_affine),
|
| 853 |
+
conditions=pdnorm_conditions,
|
| 854 |
+
decouple=pdnorm_decouple,
|
| 855 |
+
adaptive=pdnorm_adaptive,
|
| 856 |
+
)
|
| 857 |
+
else:
|
| 858 |
+
ln_layer = nn.LayerNorm
|
| 859 |
+
# activation layers
|
| 860 |
+
act_layer = nn.GELU
|
| 861 |
+
|
| 862 |
+
self.embedding = Embedding(
|
| 863 |
+
in_channels=in_channels,
|
| 864 |
+
embed_channels=enc_channels[0],
|
| 865 |
+
norm_layer=bn_layer,
|
| 866 |
+
act_layer=act_layer,
|
| 867 |
+
)
|
| 868 |
+
|
| 869 |
+
# encoder
|
| 870 |
+
enc_drop_path = [
|
| 871 |
+
x.item() for x in torch.linspace(0, drop_path, sum(enc_depths))
|
| 872 |
+
]
|
| 873 |
+
self.enc = PointSequential()
|
| 874 |
+
for s in range(self.num_stages):
|
| 875 |
+
enc_drop_path_ = enc_drop_path[
|
| 876 |
+
sum(enc_depths[:s]) : sum(enc_depths[: s + 1])
|
| 877 |
+
]
|
| 878 |
+
enc = PointSequential()
|
| 879 |
+
if s > 0:
|
| 880 |
+
enc.add(
|
| 881 |
+
SerializedPooling(
|
| 882 |
+
in_channels=enc_channels[s - 1],
|
| 883 |
+
out_channels=enc_channels[s],
|
| 884 |
+
stride=stride[s - 1],
|
| 885 |
+
norm_layer=bn_layer,
|
| 886 |
+
act_layer=act_layer,
|
| 887 |
+
),
|
| 888 |
+
name="down",
|
| 889 |
+
)
|
| 890 |
+
for i in range(enc_depths[s]):
|
| 891 |
+
enc.add(
|
| 892 |
+
Block(
|
| 893 |
+
channels=enc_channels[s],
|
| 894 |
+
num_heads=enc_num_head[s],
|
| 895 |
+
patch_size=enc_patch_size[s],
|
| 896 |
+
mlp_ratio=mlp_ratio,
|
| 897 |
+
qkv_bias=qkv_bias,
|
| 898 |
+
qk_scale=qk_scale,
|
| 899 |
+
attn_drop=attn_drop,
|
| 900 |
+
proj_drop=proj_drop,
|
| 901 |
+
drop_path=enc_drop_path_[i],
|
| 902 |
+
norm_layer=ln_layer,
|
| 903 |
+
act_layer=act_layer,
|
| 904 |
+
pre_norm=pre_norm,
|
| 905 |
+
order_index=i % len(self.order),
|
| 906 |
+
cpe_indice_key=f"stage{s}",
|
| 907 |
+
enable_rpe=enable_rpe,
|
| 908 |
+
enable_flash=enable_flash,
|
| 909 |
+
upcast_attention=upcast_attention,
|
| 910 |
+
upcast_softmax=upcast_softmax,
|
| 911 |
+
),
|
| 912 |
+
name=f"block{i}",
|
| 913 |
+
)
|
| 914 |
+
if len(enc) != 0:
|
| 915 |
+
self.enc.add(module=enc, name=f"enc{s}")
|
| 916 |
+
|
| 917 |
+
# decoder
|
| 918 |
+
if not self.cls_mode:
|
| 919 |
+
dec_drop_path = [
|
| 920 |
+
x.item() for x in torch.linspace(0, drop_path, sum(dec_depths))
|
| 921 |
+
]
|
| 922 |
+
self.dec = PointSequential()
|
| 923 |
+
dec_channels = list(dec_channels) + [enc_channels[-1]]
|
| 924 |
+
for s in reversed(range(self.num_stages - 1)):
|
| 925 |
+
dec_drop_path_ = dec_drop_path[
|
| 926 |
+
sum(dec_depths[:s]) : sum(dec_depths[: s + 1])
|
| 927 |
+
]
|
| 928 |
+
dec_drop_path_.reverse()
|
| 929 |
+
dec = PointSequential()
|
| 930 |
+
dec.add(
|
| 931 |
+
SerializedUnpooling(
|
| 932 |
+
in_channels=dec_channels[s + 1],
|
| 933 |
+
skip_channels=enc_channels[s],
|
| 934 |
+
out_channels=dec_channels[s],
|
| 935 |
+
norm_layer=bn_layer,
|
| 936 |
+
act_layer=act_layer,
|
| 937 |
+
),
|
| 938 |
+
name="up",
|
| 939 |
+
)
|
| 940 |
+
for i in range(dec_depths[s]):
|
| 941 |
+
dec.add(
|
| 942 |
+
Block(
|
| 943 |
+
channels=dec_channels[s],
|
| 944 |
+
num_heads=dec_num_head[s],
|
| 945 |
+
patch_size=dec_patch_size[s],
|
| 946 |
+
mlp_ratio=mlp_ratio,
|
| 947 |
+
qkv_bias=qkv_bias,
|
| 948 |
+
qk_scale=qk_scale,
|
| 949 |
+
attn_drop=attn_drop,
|
| 950 |
+
proj_drop=proj_drop,
|
| 951 |
+
drop_path=dec_drop_path_[i],
|
| 952 |
+
norm_layer=ln_layer,
|
| 953 |
+
act_layer=act_layer,
|
| 954 |
+
pre_norm=pre_norm,
|
| 955 |
+
order_index=i % len(self.order),
|
| 956 |
+
cpe_indice_key=f"stage{s}",
|
| 957 |
+
enable_rpe=enable_rpe,
|
| 958 |
+
enable_flash=enable_flash,
|
| 959 |
+
upcast_attention=upcast_attention,
|
| 960 |
+
upcast_softmax=upcast_softmax,
|
| 961 |
+
),
|
| 962 |
+
name=f"block{i}",
|
| 963 |
+
)
|
| 964 |
+
self.dec.add(module=dec, name=f"dec{s}")
|
| 965 |
+
|
| 966 |
+
def forward(self, data_dict):
|
| 967 |
+
"""
|
| 968 |
+
A data_dict is a dictionary containing properties of a batched point cloud.
|
| 969 |
+
It should contain the following properties for PTv3:
|
| 970 |
+
1. "feat": feature of point cloud
|
| 971 |
+
2. "grid_coord": discrete coordinate after grid sampling (voxelization) or "coord" + "grid_size"
|
| 972 |
+
3. "offset" or "batch": https://github.com/Pointcept/Pointcept?tab=readme-ov-file#offset
|
| 973 |
+
"""
|
| 974 |
+
point = Point(data_dict)
|
| 975 |
+
point.serialization(order=self.order, shuffle_orders=self.shuffle_orders)
|
| 976 |
+
point.sparsify()
|
| 977 |
+
|
| 978 |
+
point = self.embedding(point)
|
| 979 |
+
point = self.enc(point)
|
| 980 |
+
if not self.cls_mode:
|
| 981 |
+
point = self.dec(point)
|
| 982 |
+
return point
|
experiments/ptv3/ptv3_code/serialization/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .default import (
|
| 2 |
+
encode,
|
| 3 |
+
decode,
|
| 4 |
+
z_order_encode,
|
| 5 |
+
z_order_decode,
|
| 6 |
+
hilbert_encode,
|
| 7 |
+
hilbert_decode,
|
| 8 |
+
)
|
experiments/ptv3/ptv3_code/serialization/default.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
from .z_order import xyz2key as z_order_encode_
|
| 3 |
+
from .z_order import key2xyz as z_order_decode_
|
| 4 |
+
from .hilbert import encode as hilbert_encode_
|
| 5 |
+
from .hilbert import decode as hilbert_decode_
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
@torch.inference_mode()
|
| 9 |
+
def encode(grid_coord, batch=None, depth=16, order="z"):
|
| 10 |
+
assert order in {"z", "z-trans", "hilbert", "hilbert-trans"}
|
| 11 |
+
if order == "z":
|
| 12 |
+
code = z_order_encode(grid_coord, depth=depth)
|
| 13 |
+
elif order == "z-trans":
|
| 14 |
+
code = z_order_encode(grid_coord[:, [1, 0, 2]], depth=depth)
|
| 15 |
+
elif order == "hilbert":
|
| 16 |
+
code = hilbert_encode(grid_coord, depth=depth)
|
| 17 |
+
elif order == "hilbert-trans":
|
| 18 |
+
code = hilbert_encode(grid_coord[:, [1, 0, 2]], depth=depth)
|
| 19 |
+
else:
|
| 20 |
+
raise NotImplementedError
|
| 21 |
+
if batch is not None:
|
| 22 |
+
batch = batch.long()
|
| 23 |
+
code = batch << depth * 3 | code
|
| 24 |
+
return code
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@torch.inference_mode()
|
| 28 |
+
def decode(code, depth=16, order="z"):
|
| 29 |
+
assert order in {"z", "hilbert"}
|
| 30 |
+
batch = code >> depth * 3
|
| 31 |
+
code = code & ((1 << depth * 3) - 1)
|
| 32 |
+
if order == "z":
|
| 33 |
+
grid_coord = z_order_decode(code, depth=depth)
|
| 34 |
+
elif order == "hilbert":
|
| 35 |
+
grid_coord = hilbert_decode(code, depth=depth)
|
| 36 |
+
else:
|
| 37 |
+
raise NotImplementedError
|
| 38 |
+
return grid_coord, batch
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def z_order_encode(grid_coord: torch.Tensor, depth: int = 16):
|
| 42 |
+
x, y, z = grid_coord[:, 0].long(), grid_coord[:, 1].long(), grid_coord[:, 2].long()
|
| 43 |
+
# we block the support to batch, maintain batched code in Point class
|
| 44 |
+
code = z_order_encode_(x, y, z, b=None, depth=depth)
|
| 45 |
+
return code
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def z_order_decode(code: torch.Tensor, depth):
|
| 49 |
+
x, y, z = z_order_decode_(code, depth=depth)
|
| 50 |
+
grid_coord = torch.stack([x, y, z], dim=-1) # (N, 3)
|
| 51 |
+
return grid_coord
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def hilbert_encode(grid_coord: torch.Tensor, depth: int = 16):
|
| 55 |
+
return hilbert_encode_(grid_coord, num_dims=3, num_bits=depth)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def hilbert_decode(code: torch.Tensor, depth: int = 16):
|
| 59 |
+
return hilbert_decode_(code, num_dims=3, num_bits=depth)
|
experiments/ptv3/ptv3_code/serialization/hilbert.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Hilbert Order
|
| 3 |
+
Modified from https://github.com/PrincetonLIPS/numpy-hilbert-curve
|
| 4 |
+
|
| 5 |
+
Author: Xiaoyang Wu (xiaoyang.wu.cs@gmail.com), Kaixin Xu
|
| 6 |
+
Please cite our work if the code is helpful to you.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def right_shift(binary, k=1, axis=-1):
|
| 13 |
+
"""Right shift an array of binary values.
|
| 14 |
+
|
| 15 |
+
Parameters:
|
| 16 |
+
-----------
|
| 17 |
+
binary: An ndarray of binary values.
|
| 18 |
+
|
| 19 |
+
k: The number of bits to shift. Default 1.
|
| 20 |
+
|
| 21 |
+
axis: The axis along which to shift. Default -1.
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
--------
|
| 25 |
+
Returns an ndarray with zero prepended and the ends truncated, along
|
| 26 |
+
whatever axis was specified."""
|
| 27 |
+
|
| 28 |
+
# If we're shifting the whole thing, just return zeros.
|
| 29 |
+
if binary.shape[axis] <= k:
|
| 30 |
+
return torch.zeros_like(binary)
|
| 31 |
+
|
| 32 |
+
# Determine the padding pattern.
|
| 33 |
+
# padding = [(0,0)] * len(binary.shape)
|
| 34 |
+
# padding[axis] = (k,0)
|
| 35 |
+
|
| 36 |
+
# Determine the slicing pattern to eliminate just the last one.
|
| 37 |
+
slicing = [slice(None)] * len(binary.shape)
|
| 38 |
+
slicing[axis] = slice(None, -k)
|
| 39 |
+
shifted = torch.nn.functional.pad(
|
| 40 |
+
binary[tuple(slicing)], (k, 0), mode="constant", value=0
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
return shifted
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def binary2gray(binary, axis=-1):
|
| 47 |
+
"""Convert an array of binary values into Gray codes.
|
| 48 |
+
|
| 49 |
+
This uses the classic X ^ (X >> 1) trick to compute the Gray code.
|
| 50 |
+
|
| 51 |
+
Parameters:
|
| 52 |
+
-----------
|
| 53 |
+
binary: An ndarray of binary values.
|
| 54 |
+
|
| 55 |
+
axis: The axis along which to compute the gray code. Default=-1.
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
--------
|
| 59 |
+
Returns an ndarray of Gray codes.
|
| 60 |
+
"""
|
| 61 |
+
shifted = right_shift(binary, axis=axis)
|
| 62 |
+
|
| 63 |
+
# Do the X ^ (X >> 1) trick.
|
| 64 |
+
gray = torch.logical_xor(binary, shifted)
|
| 65 |
+
|
| 66 |
+
return gray
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def gray2binary(gray, axis=-1):
|
| 70 |
+
"""Convert an array of Gray codes back into binary values.
|
| 71 |
+
|
| 72 |
+
Parameters:
|
| 73 |
+
-----------
|
| 74 |
+
gray: An ndarray of gray codes.
|
| 75 |
+
|
| 76 |
+
axis: The axis along which to perform Gray decoding. Default=-1.
|
| 77 |
+
|
| 78 |
+
Returns:
|
| 79 |
+
--------
|
| 80 |
+
Returns an ndarray of binary values.
|
| 81 |
+
"""
|
| 82 |
+
|
| 83 |
+
# Loop the log2(bits) number of times necessary, with shift and xor.
|
| 84 |
+
shift = 2 ** (torch.Tensor([gray.shape[axis]]).log2().ceil().int() - 1)
|
| 85 |
+
while shift > 0:
|
| 86 |
+
gray = torch.logical_xor(gray, right_shift(gray, shift))
|
| 87 |
+
shift = torch.div(shift, 2, rounding_mode="floor")
|
| 88 |
+
return gray
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def encode(locs, num_dims, num_bits):
|
| 92 |
+
"""Decode an array of locations in a hypercube into a Hilbert integer.
|
| 93 |
+
|
| 94 |
+
This is a vectorized-ish version of the Hilbert curve implementation by John
|
| 95 |
+
Skilling as described in:
|
| 96 |
+
|
| 97 |
+
Skilling, J. (2004, April). Programming the Hilbert curve. In AIP Conference
|
| 98 |
+
Proceedings (Vol. 707, No. 1, pp. 381-387). American Institute of Physics.
|
| 99 |
+
|
| 100 |
+
Params:
|
| 101 |
+
-------
|
| 102 |
+
locs - An ndarray of locations in a hypercube of num_dims dimensions, in
|
| 103 |
+
which each dimension runs from 0 to 2**num_bits-1. The shape can
|
| 104 |
+
be arbitrary, as long as the last dimension of the same has size
|
| 105 |
+
num_dims.
|
| 106 |
+
|
| 107 |
+
num_dims - The dimensionality of the hypercube. Integer.
|
| 108 |
+
|
| 109 |
+
num_bits - The number of bits for each dimension. Integer.
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
--------
|
| 113 |
+
The output is an ndarray of uint64 integers with the same shape as the
|
| 114 |
+
input, excluding the last dimension, which needs to be num_dims.
|
| 115 |
+
"""
|
| 116 |
+
|
| 117 |
+
# Keep around the original shape for later.
|
| 118 |
+
orig_shape = locs.shape
|
| 119 |
+
bitpack_mask = 1 << torch.arange(0, 8).to(locs.device)
|
| 120 |
+
bitpack_mask_rev = bitpack_mask.flip(-1)
|
| 121 |
+
|
| 122 |
+
if orig_shape[-1] != num_dims:
|
| 123 |
+
raise ValueError(
|
| 124 |
+
"""
|
| 125 |
+
The shape of locs was surprising in that the last dimension was of size
|
| 126 |
+
%d, but num_dims=%d. These need to be equal.
|
| 127 |
+
"""
|
| 128 |
+
% (orig_shape[-1], num_dims)
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
if num_dims * num_bits > 63:
|
| 132 |
+
raise ValueError(
|
| 133 |
+
"""
|
| 134 |
+
num_dims=%d and num_bits=%d for %d bits total, which can't be encoded
|
| 135 |
+
into a int64. Are you sure you need that many points on your Hilbert
|
| 136 |
+
curve?
|
| 137 |
+
"""
|
| 138 |
+
% (num_dims, num_bits, num_dims * num_bits)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# Treat the location integers as 64-bit unsigned and then split them up into
|
| 142 |
+
# a sequence of uint8s. Preserve the association by dimension.
|
| 143 |
+
locs_uint8 = locs.long().view(torch.uint8).reshape((-1, num_dims, 8)).flip(-1)
|
| 144 |
+
|
| 145 |
+
# Now turn these into bits and truncate to num_bits.
|
| 146 |
+
gray = (
|
| 147 |
+
locs_uint8.unsqueeze(-1)
|
| 148 |
+
.bitwise_and(bitpack_mask_rev)
|
| 149 |
+
.ne(0)
|
| 150 |
+
.byte()
|
| 151 |
+
.flatten(-2, -1)[..., -num_bits:]
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# Run the decoding process the other way.
|
| 155 |
+
# Iterate forwards through the bits.
|
| 156 |
+
for bit in range(0, num_bits):
|
| 157 |
+
# Iterate forwards through the dimensions.
|
| 158 |
+
for dim in range(0, num_dims):
|
| 159 |
+
# Identify which ones have this bit active.
|
| 160 |
+
mask = gray[:, dim, bit]
|
| 161 |
+
|
| 162 |
+
# Where this bit is on, invert the 0 dimension for lower bits.
|
| 163 |
+
gray[:, 0, bit + 1 :] = torch.logical_xor(
|
| 164 |
+
gray[:, 0, bit + 1 :], mask[:, None]
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
# Where the bit is off, exchange the lower bits with the 0 dimension.
|
| 168 |
+
to_flip = torch.logical_and(
|
| 169 |
+
torch.logical_not(mask[:, None]).repeat(1, gray.shape[2] - bit - 1),
|
| 170 |
+
torch.logical_xor(gray[:, 0, bit + 1 :], gray[:, dim, bit + 1 :]),
|
| 171 |
+
)
|
| 172 |
+
gray[:, dim, bit + 1 :] = torch.logical_xor(
|
| 173 |
+
gray[:, dim, bit + 1 :], to_flip
|
| 174 |
+
)
|
| 175 |
+
gray[:, 0, bit + 1 :] = torch.logical_xor(gray[:, 0, bit + 1 :], to_flip)
|
| 176 |
+
|
| 177 |
+
# Now flatten out.
|
| 178 |
+
gray = gray.swapaxes(1, 2).reshape((-1, num_bits * num_dims))
|
| 179 |
+
|
| 180 |
+
# Convert Gray back to binary.
|
| 181 |
+
hh_bin = gray2binary(gray)
|
| 182 |
+
|
| 183 |
+
# Pad back out to 64 bits.
|
| 184 |
+
extra_dims = 64 - num_bits * num_dims
|
| 185 |
+
padded = torch.nn.functional.pad(hh_bin, (extra_dims, 0), "constant", 0)
|
| 186 |
+
|
| 187 |
+
# Convert binary values into uint8s.
|
| 188 |
+
hh_uint8 = (
|
| 189 |
+
(padded.flip(-1).reshape((-1, 8, 8)) * bitpack_mask)
|
| 190 |
+
.sum(2)
|
| 191 |
+
.squeeze()
|
| 192 |
+
.type(torch.uint8)
|
| 193 |
+
)
|
| 194 |
+
|
| 195 |
+
# Convert uint8s into uint64s.
|
| 196 |
+
hh_uint64 = hh_uint8.view(torch.int64).squeeze()
|
| 197 |
+
|
| 198 |
+
return hh_uint64
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
def decode(hilberts, num_dims, num_bits):
|
| 202 |
+
"""Decode an array of Hilbert integers into locations in a hypercube.
|
| 203 |
+
|
| 204 |
+
This is a vectorized-ish version of the Hilbert curve implementation by John
|
| 205 |
+
Skilling as described in:
|
| 206 |
+
|
| 207 |
+
Skilling, J. (2004, April). Programming the Hilbert curve. In AIP Conference
|
| 208 |
+
Proceedings (Vol. 707, No. 1, pp. 381-387). American Institute of Physics.
|
| 209 |
+
|
| 210 |
+
Params:
|
| 211 |
+
-------
|
| 212 |
+
hilberts - An ndarray of Hilbert integers. Must be an integer dtype and
|
| 213 |
+
cannot have fewer bits than num_dims * num_bits.
|
| 214 |
+
|
| 215 |
+
num_dims - The dimensionality of the hypercube. Integer.
|
| 216 |
+
|
| 217 |
+
num_bits - The number of bits for each dimension. Integer.
|
| 218 |
+
|
| 219 |
+
Returns:
|
| 220 |
+
--------
|
| 221 |
+
The output is an ndarray of unsigned integers with the same shape as hilberts
|
| 222 |
+
but with an additional dimension of size num_dims.
|
| 223 |
+
"""
|
| 224 |
+
|
| 225 |
+
if num_dims * num_bits > 64:
|
| 226 |
+
raise ValueError(
|
| 227 |
+
"""
|
| 228 |
+
num_dims=%d and num_bits=%d for %d bits total, which can't be encoded
|
| 229 |
+
into a uint64. Are you sure you need that many points on your Hilbert
|
| 230 |
+
curve?
|
| 231 |
+
"""
|
| 232 |
+
% (num_dims, num_bits)
|
| 233 |
+
)
|
| 234 |
+
|
| 235 |
+
# Handle the case where we got handed a naked integer.
|
| 236 |
+
hilberts = torch.atleast_1d(hilberts)
|
| 237 |
+
|
| 238 |
+
# Keep around the shape for later.
|
| 239 |
+
orig_shape = hilberts.shape
|
| 240 |
+
bitpack_mask = 2 ** torch.arange(0, 8).to(hilberts.device)
|
| 241 |
+
bitpack_mask_rev = bitpack_mask.flip(-1)
|
| 242 |
+
|
| 243 |
+
# Treat each of the hilberts as a s equence of eight uint8.
|
| 244 |
+
# This treats all of the inputs as uint64 and makes things uniform.
|
| 245 |
+
hh_uint8 = (
|
| 246 |
+
hilberts.ravel().type(torch.int64).view(torch.uint8).reshape((-1, 8)).flip(-1)
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
# Turn these lists of uints into lists of bits and then truncate to the size
|
| 250 |
+
# we actually need for using Skilling's procedure.
|
| 251 |
+
hh_bits = (
|
| 252 |
+
hh_uint8.unsqueeze(-1)
|
| 253 |
+
.bitwise_and(bitpack_mask_rev)
|
| 254 |
+
.ne(0)
|
| 255 |
+
.byte()
|
| 256 |
+
.flatten(-2, -1)[:, -num_dims * num_bits :]
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# Take the sequence of bits and Gray-code it.
|
| 260 |
+
gray = binary2gray(hh_bits)
|
| 261 |
+
|
| 262 |
+
# There has got to be a better way to do this.
|
| 263 |
+
# I could index them differently, but the eventual packbits likes it this way.
|
| 264 |
+
gray = gray.reshape((-1, num_bits, num_dims)).swapaxes(1, 2)
|
| 265 |
+
|
| 266 |
+
# Iterate backwards through the bits.
|
| 267 |
+
for bit in range(num_bits - 1, -1, -1):
|
| 268 |
+
# Iterate backwards through the dimensions.
|
| 269 |
+
for dim in range(num_dims - 1, -1, -1):
|
| 270 |
+
# Identify which ones have this bit active.
|
| 271 |
+
mask = gray[:, dim, bit]
|
| 272 |
+
|
| 273 |
+
# Where this bit is on, invert the 0 dimension for lower bits.
|
| 274 |
+
gray[:, 0, bit + 1 :] = torch.logical_xor(
|
| 275 |
+
gray[:, 0, bit + 1 :], mask[:, None]
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
# Where the bit is off, exchange the lower bits with the 0 dimension.
|
| 279 |
+
to_flip = torch.logical_and(
|
| 280 |
+
torch.logical_not(mask[:, None]),
|
| 281 |
+
torch.logical_xor(gray[:, 0, bit + 1 :], gray[:, dim, bit + 1 :]),
|
| 282 |
+
)
|
| 283 |
+
gray[:, dim, bit + 1 :] = torch.logical_xor(
|
| 284 |
+
gray[:, dim, bit + 1 :], to_flip
|
| 285 |
+
)
|
| 286 |
+
gray[:, 0, bit + 1 :] = torch.logical_xor(gray[:, 0, bit + 1 :], to_flip)
|
| 287 |
+
|
| 288 |
+
# Pad back out to 64 bits.
|
| 289 |
+
extra_dims = 64 - num_bits
|
| 290 |
+
padded = torch.nn.functional.pad(gray, (extra_dims, 0), "constant", 0)
|
| 291 |
+
|
| 292 |
+
# Now chop these up into blocks of 8.
|
| 293 |
+
locs_chopped = padded.flip(-1).reshape((-1, num_dims, 8, 8))
|
| 294 |
+
|
| 295 |
+
# Take those blocks and turn them unto uint8s.
|
| 296 |
+
# from IPython import embed; embed()
|
| 297 |
+
locs_uint8 = (locs_chopped * bitpack_mask).sum(3).squeeze().type(torch.uint8)
|
| 298 |
+
|
| 299 |
+
# Finally, treat these as uint64s.
|
| 300 |
+
flat_locs = locs_uint8.view(torch.int64)
|
| 301 |
+
|
| 302 |
+
# Return them in the expected shape.
|
| 303 |
+
return flat_locs.reshape((*orig_shape, num_dims))
|
experiments/ptv3/ptv3_code/serialization/z_order.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --------------------------------------------------------
|
| 2 |
+
# Octree-based Sparse Convolutional Neural Networks
|
| 3 |
+
# Copyright (c) 2022 Peng-Shuai Wang <wangps@hotmail.com>
|
| 4 |
+
# Licensed under The MIT License [see LICENSE for details]
|
| 5 |
+
# Written by Peng-Shuai Wang
|
| 6 |
+
# --------------------------------------------------------
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from typing import Optional, Union
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class KeyLUT:
|
| 13 |
+
def __init__(self):
|
| 14 |
+
r256 = torch.arange(256, dtype=torch.int64)
|
| 15 |
+
r512 = torch.arange(512, dtype=torch.int64)
|
| 16 |
+
zero = torch.zeros(256, dtype=torch.int64)
|
| 17 |
+
device = torch.device("cpu")
|
| 18 |
+
|
| 19 |
+
self._encode = {
|
| 20 |
+
device: (
|
| 21 |
+
self.xyz2key(r256, zero, zero, 8),
|
| 22 |
+
self.xyz2key(zero, r256, zero, 8),
|
| 23 |
+
self.xyz2key(zero, zero, r256, 8),
|
| 24 |
+
)
|
| 25 |
+
}
|
| 26 |
+
self._decode = {device: self.key2xyz(r512, 9)}
|
| 27 |
+
|
| 28 |
+
def encode_lut(self, device=torch.device("cpu")):
|
| 29 |
+
if device not in self._encode:
|
| 30 |
+
cpu = torch.device("cpu")
|
| 31 |
+
self._encode[device] = tuple(e.to(device) for e in self._encode[cpu])
|
| 32 |
+
return self._encode[device]
|
| 33 |
+
|
| 34 |
+
def decode_lut(self, device=torch.device("cpu")):
|
| 35 |
+
if device not in self._decode:
|
| 36 |
+
cpu = torch.device("cpu")
|
| 37 |
+
self._decode[device] = tuple(e.to(device) for e in self._decode[cpu])
|
| 38 |
+
return self._decode[device]
|
| 39 |
+
|
| 40 |
+
def xyz2key(self, x, y, z, depth):
|
| 41 |
+
key = torch.zeros_like(x)
|
| 42 |
+
for i in range(depth):
|
| 43 |
+
mask = 1 << i
|
| 44 |
+
key = (
|
| 45 |
+
key
|
| 46 |
+
| ((x & mask) << (2 * i + 2))
|
| 47 |
+
| ((y & mask) << (2 * i + 1))
|
| 48 |
+
| ((z & mask) << (2 * i + 0))
|
| 49 |
+
)
|
| 50 |
+
return key
|
| 51 |
+
|
| 52 |
+
def key2xyz(self, key, depth):
|
| 53 |
+
x = torch.zeros_like(key)
|
| 54 |
+
y = torch.zeros_like(key)
|
| 55 |
+
z = torch.zeros_like(key)
|
| 56 |
+
for i in range(depth):
|
| 57 |
+
x = x | ((key & (1 << (3 * i + 2))) >> (2 * i + 2))
|
| 58 |
+
y = y | ((key & (1 << (3 * i + 1))) >> (2 * i + 1))
|
| 59 |
+
z = z | ((key & (1 << (3 * i + 0))) >> (2 * i + 0))
|
| 60 |
+
return x, y, z
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
_key_lut = KeyLUT()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def xyz2key(
|
| 67 |
+
x: torch.Tensor,
|
| 68 |
+
y: torch.Tensor,
|
| 69 |
+
z: torch.Tensor,
|
| 70 |
+
b: Optional[Union[torch.Tensor, int]] = None,
|
| 71 |
+
depth: int = 16,
|
| 72 |
+
):
|
| 73 |
+
r"""Encodes :attr:`x`, :attr:`y`, :attr:`z` coordinates to the shuffled keys
|
| 74 |
+
based on pre-computed look up tables. The speed of this function is much
|
| 75 |
+
faster than the method based on for-loop.
|
| 76 |
+
|
| 77 |
+
Args:
|
| 78 |
+
x (torch.Tensor): The x coordinate.
|
| 79 |
+
y (torch.Tensor): The y coordinate.
|
| 80 |
+
z (torch.Tensor): The z coordinate.
|
| 81 |
+
b (torch.Tensor or int): The batch index of the coordinates, and should be
|
| 82 |
+
smaller than 32768. If :attr:`b` is :obj:`torch.Tensor`, the size of
|
| 83 |
+
:attr:`b` must be the same as :attr:`x`, :attr:`y`, and :attr:`z`.
|
| 84 |
+
depth (int): The depth of the shuffled key, and must be smaller than 17 (< 17).
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
EX, EY, EZ = _key_lut.encode_lut(x.device)
|
| 88 |
+
x, y, z = x.long(), y.long(), z.long()
|
| 89 |
+
|
| 90 |
+
mask = 255 if depth > 8 else (1 << depth) - 1
|
| 91 |
+
key = EX[x & mask] | EY[y & mask] | EZ[z & mask]
|
| 92 |
+
if depth > 8:
|
| 93 |
+
mask = (1 << (depth - 8)) - 1
|
| 94 |
+
key16 = EX[(x >> 8) & mask] | EY[(y >> 8) & mask] | EZ[(z >> 8) & mask]
|
| 95 |
+
key = key16 << 24 | key
|
| 96 |
+
|
| 97 |
+
if b is not None:
|
| 98 |
+
b = b.long()
|
| 99 |
+
key = b << 48 | key
|
| 100 |
+
|
| 101 |
+
return key
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def key2xyz(key: torch.Tensor, depth: int = 16):
|
| 105 |
+
r"""Decodes the shuffled key to :attr:`x`, :attr:`y`, :attr:`z` coordinates
|
| 106 |
+
and the batch index based on pre-computed look up tables.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
key (torch.Tensor): The shuffled key.
|
| 110 |
+
depth (int): The depth of the shuffled key, and must be smaller than 17 (< 17).
|
| 111 |
+
"""
|
| 112 |
+
|
| 113 |
+
DX, DY, DZ = _key_lut.decode_lut(key.device)
|
| 114 |
+
x, y, z = torch.zeros_like(key), torch.zeros_like(key), torch.zeros_like(key)
|
| 115 |
+
|
| 116 |
+
b = key >> 48
|
| 117 |
+
key = key & ((1 << 48) - 1)
|
| 118 |
+
|
| 119 |
+
n = (depth + 2) // 3
|
| 120 |
+
for i in range(n):
|
| 121 |
+
k = key >> (i * 9) & 511
|
| 122 |
+
x = x | (DX[k] << (i * 3))
|
| 123 |
+
y = y | (DY[k] << (i * 3))
|
| 124 |
+
z = z | (DZ[k] << (i * 3))
|
| 125 |
+
|
| 126 |
+
return x, y, z, b
|
experiments/ptv3/train_args.json
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"activation": "gelu",
|
| 3 |
+
"adam_betas": "0.9,0.95",
|
| 4 |
+
"arch": "ptv3",
|
| 5 |
+
"aug_drop": 0.0,
|
| 6 |
+
"aug_flip": true,
|
| 7 |
+
"aug_jitter": 0.0,
|
| 8 |
+
"aug_rotate": true,
|
| 9 |
+
"batch_size": 32,
|
| 10 |
+
"behind_emb_dim": 8,
|
| 11 |
+
"cache_dir": "hf://usm3d/s23dr-2026-sampled_8192_v3:train",
|
| 12 |
+
"conf_clamp_min": null,
|
| 13 |
+
"conf_head_wd": 0.1,
|
| 14 |
+
"conf_mode": "sinkhorn",
|
| 15 |
+
"conf_weight": 0.1,
|
| 16 |
+
"cooldown_start": 195000,
|
| 17 |
+
"cooldown_steps": 5000,
|
| 18 |
+
"cosine_decay": false,
|
| 19 |
+
"cpu": false,
|
| 20 |
+
"cross_attn_interval": 4,
|
| 21 |
+
"decoder_input_xattn": false,
|
| 22 |
+
"decoder_layers": 3,
|
| 23 |
+
"deterministic": false,
|
| 24 |
+
"dropout": 0.1,
|
| 25 |
+
"ema_decay": 0.0,
|
| 26 |
+
"encoder_layers": 4,
|
| 27 |
+
"endpoint_warmup": 0,
|
| 28 |
+
"endpoint_weight": 0.1,
|
| 29 |
+
"ff": 1024,
|
| 30 |
+
"hidden": 256,
|
| 31 |
+
"kv_heads_cross": 2,
|
| 32 |
+
"kv_heads_self": 2,
|
| 33 |
+
"latent_layers": 7,
|
| 34 |
+
"latent_tokens": 256,
|
| 35 |
+
"learnable_fourier": false,
|
| 36 |
+
"length_floor": 0.0,
|
| 37 |
+
"lr": 0.00015,
|
| 38 |
+
"num_heads": 4,
|
| 39 |
+
"out_dir": "runs/step3b_8192",
|
| 40 |
+
"pre_encoder_layers": 0,
|
| 41 |
+
"qk_norm": true,
|
| 42 |
+
"qk_norm_type": "l2",
|
| 43 |
+
"rms_norm": true,
|
| 44 |
+
"seed": 353,
|
| 45 |
+
"segment_conf": true,
|
| 46 |
+
"segment_param": "midpoint_dir_len",
|
| 47 |
+
"segments": 64,
|
| 48 |
+
"seq_len": 8192,
|
| 49 |
+
"sinkhorn_dustbin": 0.3,
|
| 50 |
+
"sinkhorn_eps": 0.1,
|
| 51 |
+
"sinkhorn_eps_schedule": "none",
|
| 52 |
+
"sinkhorn_eps_start": null,
|
| 53 |
+
"sinkhorn_iters": 20,
|
| 54 |
+
"sinkhorn_weight": 1.0,
|
| 55 |
+
"steps": 200000,
|
| 56 |
+
"val_cache_dir": "",
|
| 57 |
+
"varifold_cross_only": false,
|
| 58 |
+
"varifold_weight": 0.0,
|
| 59 |
+
"vote_features": true,
|
| 60 |
+
"warmup": 1000,
|
| 61 |
+
"weight_decay": 0.01
|
| 62 |
+
}
|
pnet_class_2026.pth
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f0bab151af7133e4da4e00734c12145d7dcb882297fa5172a6b7ba737dc130b6
|
| 3 |
+
size 22456481
|
requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
numpy
|
| 2 |
+
torch
|
| 3 |
+
tqdm
|
| 4 |
+
datasets
|
| 5 |
+
huggingface_hub
|
| 6 |
+
opencv-python
|
| 7 |
+
scipy
|
| 8 |
+
scikit-learn
|
| 9 |
+
pillow
|
| 10 |
+
pycolmap>0.6
|
| 11 |
+
trimesh
|
| 12 |
+
hoho2025
|
s23dr_2026_example/__init__.py
ADDED
|
File without changes
|
s23dr_2026_example/attention.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# custom_transformer.py
|
| 2 |
+
import torch
|
| 3 |
+
import torch.nn as nn
|
| 4 |
+
import torch.nn.functional as F
|
| 5 |
+
|
| 6 |
+
# =============================================================================
|
| 7 |
+
# Core Efficient Multihead Attention using Scaled Dot Product Attention (SDPA)
|
| 8 |
+
# =============================================================================
|
| 9 |
+
|
| 10 |
+
class MultiHeadSDPA(nn.Module):
|
| 11 |
+
"""
|
| 12 |
+
Multi-head cross-attention using torch.nn.functional.scaled_dot_product_attention
|
| 13 |
+
without causal masking. Suitable for set inputs and cross-attention.
|
| 14 |
+
|
| 15 |
+
If qk_norm=True, L2-normalizes Q and K per-head before the dot product,
|
| 16 |
+
then scales by a learned per-head temperature (log_scale). This caps logit
|
| 17 |
+
magnitude to [-1, +1] * exp(log_scale), preventing attention entropy
|
| 18 |
+
collapse at large head_dim.
|
| 19 |
+
"""
|
| 20 |
+
def __init__(self, d_model: int, num_heads: int, kv_heads: int = None,
|
| 21 |
+
qk_norm: bool = False, qk_norm_type: str = "l2"):
|
| 22 |
+
super().__init__()
|
| 23 |
+
assert d_model % num_heads == 0, "d_model must be divisible by num_heads"
|
| 24 |
+
self.d_model = d_model
|
| 25 |
+
self.num_heads = num_heads
|
| 26 |
+
self.kv_heads = kv_heads or num_heads
|
| 27 |
+
assert self.num_heads % self.kv_heads == 0, "kv_heads must divide num_heads"
|
| 28 |
+
|
| 29 |
+
self.head_dim = d_model // num_heads
|
| 30 |
+
self.qk_norm = qk_norm
|
| 31 |
+
self.qk_norm_type = qk_norm_type
|
| 32 |
+
|
| 33 |
+
# Input projection layers
|
| 34 |
+
self.q_proj = nn.Linear(d_model, d_model, bias=False)
|
| 35 |
+
self.k_proj = nn.Linear(d_model, self.kv_heads * self.head_dim, bias=False)
|
| 36 |
+
self.v_proj = nn.Linear(d_model, self.kv_heads * self.head_dim, bias=False)
|
| 37 |
+
|
| 38 |
+
# Output projection
|
| 39 |
+
self.out_proj = nn.Linear(d_model, d_model, bias=False)
|
| 40 |
+
nn.init.zeros_(self.out_proj.weight)
|
| 41 |
+
|
| 42 |
+
if qk_norm:
|
| 43 |
+
import math
|
| 44 |
+
if qk_norm_type == "rms":
|
| 45 |
+
# Standard QK-norm (Qwen3/Gemma3 style): RMSNorm on Q and K,
|
| 46 |
+
# no learned temperature. SDPA's 1/sqrt(d) scaling is sufficient
|
| 47 |
+
# because RMSNorm preserves the expected logit variance.
|
| 48 |
+
pass # no extra parameters needed
|
| 49 |
+
else:
|
| 50 |
+
# L2 + learned temperature (nGPT/ViT-22B style):
|
| 51 |
+
# L2 projects to unit sphere, needs learned scale to compensate.
|
| 52 |
+
self.log_scale = nn.Parameter(
|
| 53 |
+
torch.full((num_heads,), math.log(math.sqrt(self.head_dim))))
|
| 54 |
+
|
| 55 |
+
def forward(
|
| 56 |
+
self,
|
| 57 |
+
query: torch.Tensor,
|
| 58 |
+
key: torch.Tensor,
|
| 59 |
+
key_padding_mask: torch.Tensor | None = None,
|
| 60 |
+
) -> torch.Tensor:
|
| 61 |
+
# Project
|
| 62 |
+
q = self.q_proj(query)
|
| 63 |
+
k = self.k_proj(key)
|
| 64 |
+
v = self.v_proj(key)
|
| 65 |
+
|
| 66 |
+
B, Tq, _ = q.shape
|
| 67 |
+
_, Tk, _ = k.shape
|
| 68 |
+
|
| 69 |
+
q = q.view(B, Tq, self.num_heads, self.head_dim).transpose(1, 2)
|
| 70 |
+
k = k.view(B, Tk, self.kv_heads, self.head_dim).transpose(1, 2)
|
| 71 |
+
v = v.view(B, Tk, self.kv_heads, self.head_dim).transpose(1, 2)
|
| 72 |
+
|
| 73 |
+
if self.kv_heads != self.num_heads:
|
| 74 |
+
repeat = self.num_heads // self.kv_heads
|
| 75 |
+
k = k.repeat_interleave(repeat, dim=1)
|
| 76 |
+
v = v.repeat_interleave(repeat, dim=1)
|
| 77 |
+
|
| 78 |
+
if self.qk_norm:
|
| 79 |
+
if self.qk_norm_type == "rms":
|
| 80 |
+
# RMSNorm (Qwen3/Gemma3 style): no learned temperature needed.
|
| 81 |
+
# After RMSNorm, logit variance matches standard SDPA naturally.
|
| 82 |
+
q = q * torch.rsqrt(q.square().mean(dim=-1, keepdim=True) + 1e-6)
|
| 83 |
+
k = k * torch.rsqrt(k.square().mean(dim=-1, keepdim=True) + 1e-6)
|
| 84 |
+
attn_mask = None
|
| 85 |
+
if key_padding_mask is not None:
|
| 86 |
+
attn_mask = ~key_padding_mask[:, None, None, :].to(dtype=torch.bool)
|
| 87 |
+
attn_out = F.scaled_dot_product_attention(
|
| 88 |
+
q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False,
|
| 89 |
+
)
|
| 90 |
+
else:
|
| 91 |
+
# L2 + learned temperature (nGPT/ViT-22B style)
|
| 92 |
+
q = F.normalize(q, dim=-1)
|
| 93 |
+
k = F.normalize(k, dim=-1)
|
| 94 |
+
scale = self.log_scale.exp().view(1, -1, 1, 1)
|
| 95 |
+
q = q * scale
|
| 96 |
+
attn_mask = None
|
| 97 |
+
if key_padding_mask is not None:
|
| 98 |
+
attn_mask = ~key_padding_mask[:, None, None, :].to(dtype=torch.bool)
|
| 99 |
+
attn_out = F.scaled_dot_product_attention(
|
| 100 |
+
q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False,
|
| 101 |
+
scale=1.0,
|
| 102 |
+
)
|
| 103 |
+
else:
|
| 104 |
+
attn_mask = None
|
| 105 |
+
if key_padding_mask is not None:
|
| 106 |
+
attn_mask = ~key_padding_mask[:, None, None, :].to(dtype=torch.bool)
|
| 107 |
+
attn_out = F.scaled_dot_product_attention(
|
| 108 |
+
q, k, v, attn_mask=attn_mask, dropout_p=0.0, is_causal=False
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
attn_out = attn_out.transpose(1, 2).reshape(B, Tq, self.d_model)
|
| 112 |
+
return self.out_proj(attn_out)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# =============================================================================
|
| 116 |
+
# Transformer Feed-Forward Block
|
| 117 |
+
# =============================================================================
|
| 118 |
+
|
| 119 |
+
def _get_activation(name: str):
|
| 120 |
+
"""Look up activation function by name. Supports 'relu_sq' for ReLU^2."""
|
| 121 |
+
if name == "relu_sq":
|
| 122 |
+
return lambda x: F.relu(x).square()
|
| 123 |
+
return getattr(F, name)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class FeedForward(nn.Module):
|
| 127 |
+
"""
|
| 128 |
+
Position-wise MLP block: linear -> activation -> linear.
|
| 129 |
+
Supports 'gelu', 'relu', 'relu_sq', etc.
|
| 130 |
+
"""
|
| 131 |
+
def __init__(self, d_model: int, dim_ff: int, activation: str = "gelu"):
|
| 132 |
+
super().__init__()
|
| 133 |
+
self.linear1 = nn.Linear(d_model, dim_ff)
|
| 134 |
+
self.linear2 = nn.Linear(dim_ff, d_model)
|
| 135 |
+
nn.init.zeros_(self.linear2.weight)
|
| 136 |
+
nn.init.zeros_(self.linear2.bias)
|
| 137 |
+
self.activation = _get_activation(activation)
|
| 138 |
+
|
| 139 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 140 |
+
x = self.linear1(x)
|
| 141 |
+
return self.linear2(self.activation(x))
|
s23dr_2026_example/bad_samples.txt
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
14b1872e960
|
| 2 |
+
1807ef90db4
|
| 3 |
+
180e6a67e87
|
| 4 |
+
1ad5c6bd31f
|
| 5 |
+
1c3f939ad93
|
| 6 |
+
1ede4c0d52f
|
| 7 |
+
214f17d9cc4
|
| 8 |
+
22256d88df9
|
| 9 |
+
24a92a8de6d
|
| 10 |
+
24b4e984bad
|
| 11 |
+
2565978cf53
|
| 12 |
+
2a71f1a2072
|
| 13 |
+
2d44c1fade6
|
| 14 |
+
2ebed43823a
|
| 15 |
+
33982551420
|
| 16 |
+
3b480496f82
|
| 17 |
+
412a2bdf7a4
|
| 18 |
+
44343bbabbb
|
| 19 |
+
4a0b3f04cbd
|
| 20 |
+
4a7fa170826
|
| 21 |
+
4b7dc027214
|
| 22 |
+
4e0dc2c9b18
|
| 23 |
+
5172a516c8b
|
| 24 |
+
529e8f15cd2
|
| 25 |
+
56fc6f6f163
|
| 26 |
+
575963ce814
|
| 27 |
+
578ec40a278
|
| 28 |
+
5a0c07c575a
|
| 29 |
+
5d521223c26
|
| 30 |
+
6148b5c9461
|
| 31 |
+
631eb6d7c03
|
| 32 |
+
655a14f8a75
|
| 33 |
+
66502d7ee6f
|
| 34 |
+
6da76fc6687
|
| 35 |
+
777eaaad0ca
|
| 36 |
+
7a4e2909d68
|
| 37 |
+
7c5c9baf483
|
| 38 |
+
80806dfd75e
|
| 39 |
+
81a4ead431d
|
| 40 |
+
833152dd554
|
| 41 |
+
85797868c0f
|
| 42 |
+
86460ad8181
|
| 43 |
+
86783a6bee4
|
| 44 |
+
95193322d7a
|
| 45 |
+
99a9d056200
|
| 46 |
+
9b1d4eeaab9
|
| 47 |
+
9ff759f2e4c
|
| 48 |
+
acbd243da16
|
| 49 |
+
b9b275710c0
|
| 50 |
+
beceaa9bb7c
|
| 51 |
+
c243d079286
|
| 52 |
+
c5c7337d2cb
|
| 53 |
+
cdf6f2d3b35
|
| 54 |
+
cfe370f1c87
|
| 55 |
+
d4a72aea80c
|
| 56 |
+
d655f066cd3
|
| 57 |
+
d79e8d9455c
|
| 58 |
+
d7d6c5be76e
|
| 59 |
+
dc30ae4b93b
|
| 60 |
+
de9495f7ca3
|
| 61 |
+
e1901819c72
|
| 62 |
+
e1d88c1a6b1
|
| 63 |
+
e5d3eb0a617
|
| 64 |
+
ec11d3cdcf6
|
| 65 |
+
ecb21fad0ad
|
| 66 |
+
ee55d8c6493
|
| 67 |
+
ee7e6d4dee1
|
| 68 |
+
008052054aa
|
| 69 |
+
03ecb7d3cf3
|
| 70 |
+
0555a655534
|
| 71 |
+
099cad230c6
|
| 72 |
+
0d061ae23f0
|
| 73 |
+
10741a421c0
|
| 74 |
+
110d5e407b9
|
| 75 |
+
128a7fb415a
|
| 76 |
+
13177736b26
|
| 77 |
+
1635d73bf7d
|
| 78 |
+
18a760de9ea
|
| 79 |
+
18d90d03e95
|
| 80 |
+
209627a5c1a
|
| 81 |
+
21e3cd4b7b8
|
| 82 |
+
22f5499200d
|
| 83 |
+
266eb64de68
|
| 84 |
+
269235f770b
|
| 85 |
+
2758490e558
|
| 86 |
+
2a203cf5d35
|
| 87 |
+
2a878ec47ab
|
| 88 |
+
2cb43eb2201
|
| 89 |
+
393298e282b
|
| 90 |
+
395abe6aac7
|
| 91 |
+
3d19c7a4ca3
|
| 92 |
+
44e2b719b1e
|
| 93 |
+
45039819fcc
|
| 94 |
+
4cb4ff01619
|
| 95 |
+
4e5eb5712fa
|
| 96 |
+
4e988765a6d
|
| 97 |
+
5077bf42714
|
| 98 |
+
55ed69b2622
|
| 99 |
+
5ae3b651a37
|
| 100 |
+
5ca1edeed4c
|
| 101 |
+
5daa76b1c7f
|
| 102 |
+
5fdd11dfae5
|
| 103 |
+
6078cf180c2
|
| 104 |
+
6682b309e9c
|
| 105 |
+
6c02d2038c0
|
| 106 |
+
71c595506c8
|
| 107 |
+
73c8f960c18
|
| 108 |
+
74ccc8fd057
|
| 109 |
+
7a34156a798
|
| 110 |
+
7ac7af9f59c
|
| 111 |
+
7f2ec0ea179
|
| 112 |
+
823b837b36c
|
| 113 |
+
82d7600f9a3
|
| 114 |
+
848161a2900
|
| 115 |
+
88cedf129eb
|
| 116 |
+
8dec106b6a6
|
| 117 |
+
8e335d08ca4
|
| 118 |
+
8ecf7c58193
|
| 119 |
+
8fa55008beb
|
| 120 |
+
90e09de2301
|
| 121 |
+
9197acc0b9d
|
| 122 |
+
954c25e876c
|
| 123 |
+
98517d5563d
|
| 124 |
+
99e717a0148
|
| 125 |
+
9a0c0635bd7
|
| 126 |
+
9ad436b7b3d
|
| 127 |
+
9be351cbf14
|
| 128 |
+
9e2a2e51798
|
| 129 |
+
a84a7ea9220
|
| 130 |
+
aa8cb84d3eb
|
| 131 |
+
b07977292da
|
| 132 |
+
b3e33456f0b
|
| 133 |
+
b7823de373e
|
| 134 |
+
bac379382d9
|
| 135 |
+
bd2d9bf67a3
|
| 136 |
+
c14584a84cd
|
| 137 |
+
c497170c970
|
| 138 |
+
cd8e767612b
|
| 139 |
+
d17917bb279
|
| 140 |
+
d42b9d432a9
|
| 141 |
+
d53d8857a85
|
| 142 |
+
d6808cf3d98
|
| 143 |
+
d6f509d1dd9
|
| 144 |
+
d7abd08e643
|
| 145 |
+
d83493bf974
|
| 146 |
+
d87293651ee
|
| 147 |
+
da9d4ac9e8e
|
| 148 |
+
daa1702791a
|
| 149 |
+
dcb12411c14
|
| 150 |
+
de9ab9cdd5b
|
| 151 |
+
df906c58a3c
|
| 152 |
+
e3870649eb5
|
| 153 |
+
ea90aed9b98
|
| 154 |
+
ecaa81b9711
|
| 155 |
+
efc1238665b
|
| 156 |
+
c5a65219daf
|
s23dr_2026_example/cache_scenes.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Cache compact scenes from HoHo22k shards to training-ready .pt files.
|
| 3 |
+
|
| 4 |
+
Streams samples from the public `usm3d/hoho22k_2026_trainval` dataset, runs
|
| 5 |
+
`build_compact_scene` (see point_fusion.py), precomputes priority group_id
|
| 6 |
+
and semantic class_id, and saves one .pt per scene.
|
| 7 |
+
|
| 8 |
+
Stage 1 of the dataset pipeline. See make_sampled_cache.py for stage 2.
|
| 9 |
+
|
| 10 |
+
Usage:
|
| 11 |
+
python -m s23dr_2026_example.cache_scenes --out-dir cache/full --split train
|
| 12 |
+
python -m s23dr_2026_example.cache_scenes --out-dir cache/full_val --split validation
|
| 13 |
+
|
| 14 |
+
Cache format per .pt file:
|
| 15 |
+
xyz: float32 [P, 3] all points in world space
|
| 16 |
+
source: uint8 [P] 0=colmap, 1=depth
|
| 17 |
+
group_id: int8 [P] priority tier 0-4, -1=excluded
|
| 18 |
+
class_id: uint8 [P] one-hot class index (0-12)
|
| 19 |
+
behind_gest_id: int16 [P] behind-gestalt id (-1 if none)
|
| 20 |
+
visible_src: uint8 [P] 1=gestalt, 2=ade
|
| 21 |
+
visible_id: int16 [P] class id within space
|
| 22 |
+
n_views_voted: uint8 [P] number of views that voted
|
| 23 |
+
vote_frac: float32 [P] fraction of votes
|
| 24 |
+
center: float32 [3] smart normalization center
|
| 25 |
+
scale: float32 scalar smart normalization scale
|
| 26 |
+
gt_vertices: float32 [V, 3] ground truth wireframe vertices
|
| 27 |
+
gt_edges: int32 [E, 2] ground truth wireframe edge indices
|
| 28 |
+
"""
|
| 29 |
+
from __future__ import annotations
|
| 30 |
+
|
| 31 |
+
import argparse
|
| 32 |
+
import time
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import torch
|
| 37 |
+
|
| 38 |
+
from .point_fusion import (
|
| 39 |
+
FuserConfig, build_compact_scene,
|
| 40 |
+
GEST_ID_TO_NAME, ADE_ID_TO_NAME, NUM_GEST,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# ---------------------------------------------------------------------------
|
| 44 |
+
# Semantic class encoding: 11 structural + 1 other_house + 1 non_house = 13
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
|
| 47 |
+
# Each structural gestalt class gets its own one-hot bit.
|
| 48 |
+
STRUCTURAL_CLASSES = (
|
| 49 |
+
"apex", "eave_end_point", "flashing_end_point", # point classes (tier 0)
|
| 50 |
+
"rake", "ridge", "eave", "hip", "valley", # roof edges (tier 1)
|
| 51 |
+
"flashing", "step_flashing",
|
| 52 |
+
"roof", # roof face (tier 2)
|
| 53 |
+
)
|
| 54 |
+
# Index 11 = other house part (door, window, siding, etc.)
|
| 55 |
+
# Index 12 = non-house / ADE / unlabeled
|
| 56 |
+
NUM_SEMANTIC_CLASSES = len(STRUCTURAL_CLASSES) + 2 # 13
|
| 57 |
+
|
| 58 |
+
# Priority tiers (same as tokenizer.py)
|
| 59 |
+
_GEST_NAME_TO_ID = {n: i for i, n in enumerate(GEST_ID_TO_NAME)}
|
| 60 |
+
_POINT_IDS = {_GEST_NAME_TO_ID[n] for n in ("apex", "eave_end_point", "flashing_end_point") if n in _GEST_NAME_TO_ID}
|
| 61 |
+
_EDGE_IDS = {_GEST_NAME_TO_ID[n] for n in ("rake", "ridge", "eave", "hip", "valley", "flashing", "step_flashing") if n in _GEST_NAME_TO_ID}
|
| 62 |
+
_FACE_IDS = {_GEST_NAME_TO_ID[n] for n in ("roof",) if n in _GEST_NAME_TO_ID}
|
| 63 |
+
_HOUSE_IDS = {_GEST_NAME_TO_ID[n] for n in (
|
| 64 |
+
"apex", "eave_end_point", "flashing_end_point",
|
| 65 |
+
"rake", "ridge", "eave", "hip", "valley", "flashing", "step_flashing",
|
| 66 |
+
"roof", "door", "garage", "window", "shutter", "fascia", "soffit",
|
| 67 |
+
"horizontal_siding", "vertical_siding", "brick", "concrete",
|
| 68 |
+
"other_wall", "trim", "post", "ground_line",
|
| 69 |
+
) if n in _GEST_NAME_TO_ID}
|
| 70 |
+
|
| 71 |
+
_ADE_NAME_TO_ID = {n.lower(): i for i, n in enumerate(ADE_ID_TO_NAME)}
|
| 72 |
+
_ADE_HOUSE_IDS = {_ADE_NAME_TO_ID[n] for n in ("building;edifice", "house", "wall", "windowpane;window", "door;double;door") if n in _ADE_NAME_TO_ID}
|
| 73 |
+
|
| 74 |
+
_UNCLS_ID = _GEST_NAME_TO_ID.get("unclassified", -1)
|
| 75 |
+
|
| 76 |
+
# Map structural gestalt names to one-hot index
|
| 77 |
+
_STRUCTURAL_ONEHOT = {}
|
| 78 |
+
for idx, name in enumerate(STRUCTURAL_CLASSES):
|
| 79 |
+
gid = _GEST_NAME_TO_ID.get(name)
|
| 80 |
+
if gid is not None:
|
| 81 |
+
_STRUCTURAL_ONEHOT[gid] = idx
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def _compute_group_and_class(visible_src, visible_id, behind_id, source):
|
| 85 |
+
"""Compute priority group_id and semantic class_id per point (vectorized).
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
visible_src: uint8 [P] -- 0=unlabeled, 1=gestalt, 2=ade
|
| 89 |
+
visible_id: int16 [P] -- class id within gestalt or ade space
|
| 90 |
+
behind_id: int16 [P] -- behind-gestalt id (-1 if none)
|
| 91 |
+
source: uint8 [P] -- 0=colmap, 1=depth
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
group_id: int8 [P] -- priority tier 0-4, -1 for excluded (unclassified)
|
| 95 |
+
class_id: uint8 [P] -- one-hot class index 0-12
|
| 96 |
+
"""
|
| 97 |
+
P = len(visible_src)
|
| 98 |
+
vsrc = visible_src.astype(np.int32)
|
| 99 |
+
vid = visible_id.astype(np.int32)
|
| 100 |
+
bid = behind_id.astype(np.int32)
|
| 101 |
+
|
| 102 |
+
# Effective gestalt id: prefer visible gestalt, fall back to behind
|
| 103 |
+
gest_id = np.full(P, -1, dtype=np.int32)
|
| 104 |
+
has_vis_gest = (vsrc == 1) & (vid >= 0)
|
| 105 |
+
has_behind = (bid >= 0) & ~has_vis_gest
|
| 106 |
+
gest_id[has_vis_gest] = vid[has_vis_gest]
|
| 107 |
+
gest_id[has_behind] = bid[has_behind]
|
| 108 |
+
|
| 109 |
+
# Exclude unclassified points
|
| 110 |
+
if _UNCLS_ID >= 0:
|
| 111 |
+
is_uncls = ((vsrc == 1) & (vid == _UNCLS_ID)) | (bid == _UNCLS_ID)
|
| 112 |
+
gest_id[is_uncls] = -1 # force excluded
|
| 113 |
+
|
| 114 |
+
# Build lookup arrays for gestalt id -> group and gestalt id -> class
|
| 115 |
+
max_gid = NUM_GEST
|
| 116 |
+
gid_to_group = np.full(max_gid, 4, dtype=np.int8) # default: tier 4
|
| 117 |
+
gid_to_class = np.full(max_gid, NUM_SEMANTIC_CLASSES - 1, dtype=np.uint8) # default: non-house
|
| 118 |
+
|
| 119 |
+
for gid in _POINT_IDS:
|
| 120 |
+
gid_to_group[gid] = 0
|
| 121 |
+
for gid in _EDGE_IDS:
|
| 122 |
+
gid_to_group[gid] = 1
|
| 123 |
+
for gid in _FACE_IDS:
|
| 124 |
+
gid_to_group[gid] = 2
|
| 125 |
+
for gid in _HOUSE_IDS - _POINT_IDS - _EDGE_IDS - _FACE_IDS:
|
| 126 |
+
gid_to_group[gid] = 3
|
| 127 |
+
for gid, onehot_idx in _STRUCTURAL_ONEHOT.items():
|
| 128 |
+
gid_to_class[gid] = onehot_idx
|
| 129 |
+
for gid in _HOUSE_IDS - set(_STRUCTURAL_ONEHOT.keys()):
|
| 130 |
+
gid_to_class[gid] = len(STRUCTURAL_CLASSES) # other_house
|
| 131 |
+
|
| 132 |
+
# Apply lookup for points with valid gestalt ids
|
| 133 |
+
has_gest = gest_id >= 0
|
| 134 |
+
group_id = np.full(P, 4, dtype=np.int8) # default: tier 4
|
| 135 |
+
class_id = np.full(P, NUM_SEMANTIC_CLASSES - 1, dtype=np.uint8) # default: non-house
|
| 136 |
+
|
| 137 |
+
group_id[has_gest] = gid_to_group[gest_id[has_gest]]
|
| 138 |
+
class_id[has_gest] = gid_to_class[gest_id[has_gest]]
|
| 139 |
+
|
| 140 |
+
# ADE house points (no gestalt) get tier 3 + class_id = other_house
|
| 141 |
+
ade_house_arr = np.array(sorted(_ADE_HOUSE_IDS), dtype=np.int32)
|
| 142 |
+
is_ade_house = ~has_gest & (vsrc == 2) & (vid >= 0) & np.isin(vid, ade_house_arr)
|
| 143 |
+
group_id[is_ade_house] = 3
|
| 144 |
+
class_id[is_ade_house] = len(STRUCTURAL_CLASSES) # other_house (index 11)
|
| 145 |
+
|
| 146 |
+
# Mark excluded points (unclassified) as -1
|
| 147 |
+
if _UNCLS_ID >= 0:
|
| 148 |
+
group_id[is_uncls] = -1
|
| 149 |
+
class_id[is_uncls] = NUM_SEMANTIC_CLASSES - 1
|
| 150 |
+
|
| 151 |
+
return group_id, class_id
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def _compute_smart_center_scale(xyz, source, mad_k=2.5, percentile=95.0,
|
| 155 |
+
max_points=8000):
|
| 156 |
+
"""Compute normalization center and scale from depth points with MAD filter."""
|
| 157 |
+
depth_mask = source == 1
|
| 158 |
+
ref = xyz[depth_mask] if depth_mask.any() else xyz
|
| 159 |
+
if ref.shape[0] == 0:
|
| 160 |
+
center = xyz.mean(axis=0)
|
| 161 |
+
scale = max(np.linalg.norm(xyz - center, axis=1).max(), 1e-6)
|
| 162 |
+
return center.astype(np.float32), np.float32(scale)
|
| 163 |
+
|
| 164 |
+
if ref.shape[0] > max_points:
|
| 165 |
+
idx = np.random.choice(ref.shape[0], max_points, replace=False)
|
| 166 |
+
ref = ref[idx]
|
| 167 |
+
|
| 168 |
+
center0 = np.median(ref, axis=0)
|
| 169 |
+
dist = np.linalg.norm(ref - center0, axis=1)
|
| 170 |
+
med = np.median(dist)
|
| 171 |
+
mad = max(np.median(np.abs(dist - med)), 1e-6)
|
| 172 |
+
inliers = dist <= (med + mad_k * mad)
|
| 173 |
+
if inliers.any():
|
| 174 |
+
ref = ref[inliers]
|
| 175 |
+
|
| 176 |
+
# Percentile bounding box
|
| 177 |
+
lo_f = (100.0 - percentile) * 0.5 / 100.0
|
| 178 |
+
sorted_v = np.sort(ref, axis=0)
|
| 179 |
+
n = sorted_v.shape[0]
|
| 180 |
+
lo_idx = max(0, min(n - 1, int(lo_f * (n - 1))))
|
| 181 |
+
hi_idx = max(0, min(n - 1, int((1.0 - lo_f) * (n - 1))))
|
| 182 |
+
low = sorted_v[lo_idx]
|
| 183 |
+
high = sorted_v[hi_idx]
|
| 184 |
+
|
| 185 |
+
center = 0.5 * (low + high)
|
| 186 |
+
scale = max(np.sqrt(((high - low) ** 2).sum()), 1e-6)
|
| 187 |
+
return center.astype(np.float32), np.float32(scale)
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Dataset pipeline stage 1: raw HF sample -> cached .pt
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
def _process_one(sample, cfg):
|
| 195 |
+
"""Fuse a single HF sample into a cache dict. Returns (order_id, dict) or None."""
|
| 196 |
+
rng = np.random.RandomState()
|
| 197 |
+
|
| 198 |
+
n_edges = len(sample.get("wf_edges", []))
|
| 199 |
+
if n_edges == 0 or n_edges > 64:
|
| 200 |
+
return None
|
| 201 |
+
|
| 202 |
+
scene = build_compact_scene(sample, cfg, rng=rng)
|
| 203 |
+
if scene is None:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
gt_v = scene.get("gt_vertices")
|
| 207 |
+
gt_e = scene.get("gt_edges")
|
| 208 |
+
if gt_v is None or gt_e is None or len(gt_e) == 0:
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
xyz = scene["xyz"]
|
| 212 |
+
source = scene["source"]
|
| 213 |
+
group_id, class_id = _compute_group_and_class(
|
| 214 |
+
scene["visible_src"], scene["visible_id"], scene["behind_gest_id"], source)
|
| 215 |
+
center, scale = _compute_smart_center_scale(xyz, source)
|
| 216 |
+
|
| 217 |
+
gt_edge_classes = np.asarray(sample["wf_classifications"], dtype=np.int64)
|
| 218 |
+
return sample["order_id"], {
|
| 219 |
+
"xyz": xyz.astype(np.float32),
|
| 220 |
+
"source": source.astype(np.uint8),
|
| 221 |
+
"group_id": group_id,
|
| 222 |
+
"class_id": class_id,
|
| 223 |
+
"behind_gest_id": scene["behind_gest_id"].astype(np.int16),
|
| 224 |
+
"visible_src": scene["visible_src"].astype(np.uint8),
|
| 225 |
+
"visible_id": scene["visible_id"].astype(np.int16),
|
| 226 |
+
"n_views_voted": scene["n_views_voted"],
|
| 227 |
+
"vote_frac": scene["vote_frac"],
|
| 228 |
+
"center": center,
|
| 229 |
+
"scale": scale,
|
| 230 |
+
"gt_vertices": gt_v.astype(np.float32),
|
| 231 |
+
"gt_edges": gt_e.astype(np.int32),
|
| 232 |
+
"gt_edge_classes": gt_edge_classes,
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def main():
|
| 237 |
+
p = argparse.ArgumentParser(description="Stage 1: HoHo22k -> cached .pt files")
|
| 238 |
+
p.add_argument("--out-dir", required=True, help="Output directory for .pt files")
|
| 239 |
+
p.add_argument("--split", default="train", choices=["train", "validation"])
|
| 240 |
+
p.add_argument("--limit", type=int, default=0, help="Stop after N samples (0 = all)")
|
| 241 |
+
p.add_argument("--depth-per-view", type=int, default=8000)
|
| 242 |
+
p.add_argument("--skip-existing", action="store_true")
|
| 243 |
+
args = p.parse_args()
|
| 244 |
+
|
| 245 |
+
out_dir = Path(args.out_dir)
|
| 246 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 247 |
+
existing = {p.stem for p in out_dir.glob("*.pt")} if args.skip_existing else set()
|
| 248 |
+
|
| 249 |
+
from datasets import load_dataset
|
| 250 |
+
print(f"Streaming usm3d/hoho22k_2026_trainval split={args.split}...")
|
| 251 |
+
ds = load_dataset("usm3d/hoho22k_2026_trainval",
|
| 252 |
+
streaming=True, trust_remote_code=True, split=args.split)
|
| 253 |
+
|
| 254 |
+
cfg = FuserConfig(depth_points_per_view=args.depth_per_view)
|
| 255 |
+
saved, skipped = 0, 0
|
| 256 |
+
t0 = time.perf_counter()
|
| 257 |
+
for i, sample in enumerate(ds):
|
| 258 |
+
if args.limit > 0 and i >= args.limit:
|
| 259 |
+
break
|
| 260 |
+
oid = sample["order_id"]
|
| 261 |
+
if oid in existing:
|
| 262 |
+
skipped += 1
|
| 263 |
+
continue
|
| 264 |
+
result = _process_one(sample, cfg)
|
| 265 |
+
if result is None:
|
| 266 |
+
skipped += 1
|
| 267 |
+
continue
|
| 268 |
+
order_id, data = result
|
| 269 |
+
torch.save(data, out_dir / f"{order_id}.pt")
|
| 270 |
+
saved += 1
|
| 271 |
+
if saved % 100 == 0:
|
| 272 |
+
rate = saved / (time.perf_counter() - t0)
|
| 273 |
+
print(f" saved {saved} (skipped {skipped}) [{rate:.1f}/s]")
|
| 274 |
+
|
| 275 |
+
elapsed = time.perf_counter() - t0
|
| 276 |
+
print(f"Done. Saved {saved}, skipped {skipped} in {elapsed:.0f}s.")
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
if __name__ == "__main__":
|
| 280 |
+
main()
|
| 281 |
+
|
| 282 |
+
|
s23dr_2026_example/color_mappings.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gestalt_color_mapping = {
|
| 2 |
+
"unclassified": (215, 62, 138),
|
| 3 |
+
"apex": (235, 88, 48),
|
| 4 |
+
"eave_end_point": (248, 130, 228),
|
| 5 |
+
"flashing_end_point": (71, 11, 161),
|
| 6 |
+
"ridge": (214, 251, 248),
|
| 7 |
+
"rake": (13, 94, 47),
|
| 8 |
+
"eave": (54, 243, 63),
|
| 9 |
+
"post": (187, 123, 236),
|
| 10 |
+
"ground_line": (136, 206, 14),
|
| 11 |
+
"flashing": (162, 162, 32),
|
| 12 |
+
"step_flashing": (169, 255, 219),
|
| 13 |
+
"hip": (8, 89, 52),
|
| 14 |
+
"valley": (85, 27, 65),
|
| 15 |
+
"roof": (215, 232, 179),
|
| 16 |
+
"door": (110, 52, 23),
|
| 17 |
+
"garage": (50, 233, 171),
|
| 18 |
+
"window": (230, 249, 40),
|
| 19 |
+
"shutter": (122, 4, 233),
|
| 20 |
+
"fascia": (95, 230, 240),
|
| 21 |
+
"soffit": (2, 102, 197),
|
| 22 |
+
"horizontal_siding": (131, 88, 59),
|
| 23 |
+
"vertical_siding": (110, 187, 198),
|
| 24 |
+
"brick": (171, 252, 7),
|
| 25 |
+
"concrete": (32, 47, 246),
|
| 26 |
+
"other_wall": (112, 61, 240),
|
| 27 |
+
"trim": (151, 206, 58),
|
| 28 |
+
"unknown": (127, 127, 127),
|
| 29 |
+
"transition_line": (0,0,0),
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
ade20k_color_mapping = {
|
| 33 |
+
'wall': (120, 120, 120),
|
| 34 |
+
'building;edifice': (180, 120, 120),
|
| 35 |
+
'sky': (6, 230, 230),
|
| 36 |
+
'floor;flooring': (80, 50, 50),
|
| 37 |
+
'tree': (4, 200, 3),
|
| 38 |
+
'ceiling': (120, 120, 80),
|
| 39 |
+
'road;route': (140, 140, 140),
|
| 40 |
+
'bed': (204, 5, 255),
|
| 41 |
+
'windowpane;window': (230, 230, 230),
|
| 42 |
+
'grass': (4, 250, 7),
|
| 43 |
+
'cabinet': (224, 5, 255),
|
| 44 |
+
'sidewalk;pavement': (235, 255, 7),
|
| 45 |
+
'person;individual;someone;somebody;mortal;soul': (150, 5, 61),
|
| 46 |
+
'earth;ground': (120, 120, 70),
|
| 47 |
+
'door;double;door': (8, 255, 51),
|
| 48 |
+
'table': (255, 6, 82),
|
| 49 |
+
'mountain;mount': (143, 255, 140),
|
| 50 |
+
'plant;flora;plant;life': (204, 255, 4),
|
| 51 |
+
'curtain;drape;drapery;mantle;pall': (255, 51, 7),
|
| 52 |
+
'chair': (204, 70, 3),
|
| 53 |
+
'car;auto;automobile;machine;motorcar': (0, 102, 200),
|
| 54 |
+
'water': (61, 230, 250),
|
| 55 |
+
'painting;picture': (255, 6, 51),
|
| 56 |
+
'sofa;couch;lounge': (11, 102, 255),
|
| 57 |
+
'shelf': (255, 7, 71),
|
| 58 |
+
'house': (255, 9, 224),
|
| 59 |
+
'sea': (9, 7, 230),
|
| 60 |
+
'mirror': (220, 220, 220),
|
| 61 |
+
'rug;carpet;carpeting': (255, 9, 92),
|
| 62 |
+
'field': (112, 9, 255),
|
| 63 |
+
'armchair': (8, 255, 214),
|
| 64 |
+
'seat': (7, 255, 224),
|
| 65 |
+
'fence;fencing': (255, 184, 6),
|
| 66 |
+
'desk': (10, 255, 71),
|
| 67 |
+
'rock;stone': (255, 41, 10),
|
| 68 |
+
'wardrobe;closet;press': (7, 255, 255),
|
| 69 |
+
'lamp': (224, 255, 8),
|
| 70 |
+
'bathtub;bathing;tub;bath;tub': (102, 8, 255),
|
| 71 |
+
'railing;rail': (255, 61, 6),
|
| 72 |
+
'cushion': (255, 194, 7),
|
| 73 |
+
'base;pedestal;stand': (255, 122, 8),
|
| 74 |
+
'box': (0, 255, 20),
|
| 75 |
+
'column;pillar': (255, 8, 41),
|
| 76 |
+
'signboard;sign': (255, 5, 153),
|
| 77 |
+
'chest;of;drawers;chest;bureau;dresser': (6, 51, 255),
|
| 78 |
+
'counter': (235, 12, 255),
|
| 79 |
+
'sand': (160, 150, 20),
|
| 80 |
+
'sink': (0, 163, 255),
|
| 81 |
+
'skyscraper': (140, 140, 140),
|
| 82 |
+
'fireplace;hearth;open;fireplace': (250, 10, 15),
|
| 83 |
+
'refrigerator;icebox': (20, 255, 0),
|
| 84 |
+
'grandstand;covered;stand': (31, 255, 0),
|
| 85 |
+
'path': (255, 31, 0),
|
| 86 |
+
'stairs;steps': (255, 224, 0),
|
| 87 |
+
'runway': (153, 255, 0),
|
| 88 |
+
'case;display;case;showcase;vitrine': (0, 0, 255),
|
| 89 |
+
'pool;table;billiard;table;snooker;table': (255, 71, 0),
|
| 90 |
+
'pillow': (0, 235, 255),
|
| 91 |
+
'screen;door;screen': (0, 173, 255),
|
| 92 |
+
'stairway;staircase': (31, 0, 255),
|
| 93 |
+
'river': (11, 200, 200),
|
| 94 |
+
'bridge;span': (255 ,82, 0),
|
| 95 |
+
'bookcase': (0, 255, 245),
|
| 96 |
+
'blind;screen': (0, 61, 255),
|
| 97 |
+
'coffee;table;cocktail;table': (0, 255, 112),
|
| 98 |
+
'toilet;can;commode;crapper;pot;potty;stool;throne': (0, 255, 133),
|
| 99 |
+
'flower': (255, 0, 0),
|
| 100 |
+
'book': (255, 163, 0),
|
| 101 |
+
'hill': (255, 102, 0),
|
| 102 |
+
'bench': (194, 255, 0),
|
| 103 |
+
'countertop': (0, 143, 255),
|
| 104 |
+
'stove;kitchen;stove;range;kitchen;range;cooking;stove': (51, 255, 0),
|
| 105 |
+
'palm;palm;tree': (0, 82, 255),
|
| 106 |
+
'kitchen;island': (0, 255, 41),
|
| 107 |
+
'computer;computing;machine;computing;device;data;processor;electronic;computer;information;processing;system': (0, 255, 173),
|
| 108 |
+
'swivel;chair': (10, 0, 255),
|
| 109 |
+
'boat': (173, 255, 0),
|
| 110 |
+
'bar': (0, 255, 153),
|
| 111 |
+
'arcade;machine': (255, 92, 0),
|
| 112 |
+
'hovel;hut;hutch;shack;shanty': (255, 0, 255),
|
| 113 |
+
'bus;autobus;coach;charabanc;double-decker;jitney;motorbus;motorcoach;omnibus;passenger;vehicle': (255, 0, 245),
|
| 114 |
+
'towel': (255, 0, 102),
|
| 115 |
+
'light;light;source': (255, 173, 0),
|
| 116 |
+
'truck;motortruck': (255, 0, 20),
|
| 117 |
+
'tower': (255, 184, 184),
|
| 118 |
+
'chandelier;pendant;pendent': (0, 31, 255),
|
| 119 |
+
'awning;sunshade;sunblind': (0, 255, 61),
|
| 120 |
+
'streetlight;street;lamp': (0, 71, 255),
|
| 121 |
+
'booth;cubicle;stall;kiosk': (255, 0, 204),
|
| 122 |
+
'television;television;receiver;television;set;tv;tv;set;idiot;box;boob;tube;telly;goggle;box': (0, 255, 194),
|
| 123 |
+
'airplane;aeroplane;plane': (0, 255, 82),
|
| 124 |
+
'dirt;track': (0, 10, 255),
|
| 125 |
+
'apparel;wearing;apparel;dress;clothes': (0, 112, 255),
|
| 126 |
+
'pole': (51, 0, 255),
|
| 127 |
+
'land;ground;soil': (0, 194, 255),
|
| 128 |
+
'bannister;banister;balustrade;balusters;handrail': (0, 122, 255),
|
| 129 |
+
'escalator;moving;staircase;moving;stairway': (0, 255, 163),
|
| 130 |
+
'ottoman;pouf;pouffe;puff;hassock': (255, 153, 0),
|
| 131 |
+
'bottle': (0, 255, 10),
|
| 132 |
+
'buffet;counter;sideboard': (255, 112, 0),
|
| 133 |
+
'poster;posting;placard;notice;bill;card': (143, 255, 0),
|
| 134 |
+
'stage': (82, 0, 255),
|
| 135 |
+
'van': (163, 255, 0),
|
| 136 |
+
'ship': (255, 235, 0),
|
| 137 |
+
'fountain': (8, 184, 170),
|
| 138 |
+
'conveyer;belt;conveyor;belt;conveyer;conveyor;transporter': (133, 0, 255),
|
| 139 |
+
'canopy': (0, 255, 92),
|
| 140 |
+
'washer;automatic;washer;washing;machine': (184, 0, 255),
|
| 141 |
+
'plaything;toy': (255, 0, 31),
|
| 142 |
+
'swimming;pool;swimming;bath;natatorium': (0, 184, 255),
|
| 143 |
+
'stool': (0, 214, 255),
|
| 144 |
+
'barrel;cask': (255, 0, 112),
|
| 145 |
+
'basket;handbasket': (92, 255, 0),
|
| 146 |
+
'waterfall;falls': (0, 224, 255),
|
| 147 |
+
'tent;collapsible;shelter': (112, 224, 255),
|
| 148 |
+
'bag': (70, 184, 160),
|
| 149 |
+
'minibike;motorbike': (163, 0, 255),
|
| 150 |
+
'cradle': (153, 0, 255),
|
| 151 |
+
'oven': (71, 255, 0),
|
| 152 |
+
'ball': (255, 0, 163),
|
| 153 |
+
'food;solid;food': (255, 204, 0),
|
| 154 |
+
'step;stair': (255, 0, 143),
|
| 155 |
+
'tank;storage;tank': (0, 255, 235),
|
| 156 |
+
'trade;name;brand;name;brand;marque': (133, 255, 0),
|
| 157 |
+
'microwave;microwave;oven': (255, 0, 235),
|
| 158 |
+
'pot;flowerpot': (245, 0, 255),
|
| 159 |
+
'animal;animate;being;beast;brute;creature;fauna': (255, 0, 122),
|
| 160 |
+
'bicycle;bike;wheel;cycle': (255, 245, 0),
|
| 161 |
+
'lake': (10, 190, 212),
|
| 162 |
+
'dishwasher;dish;washer;dishwashing;machine': (214, 255, 0),
|
| 163 |
+
'screen;silver;screen;projection;screen': (0, 204, 255),
|
| 164 |
+
'blanket;cover': (20, 0, 255),
|
| 165 |
+
'sculpture': (255, 255, 0),
|
| 166 |
+
'hood;exhaust;hood': (0, 153, 255),
|
| 167 |
+
'sconce': (0, 41, 255),
|
| 168 |
+
'vase': (0, 255, 204),
|
| 169 |
+
'traffic;light;traffic;signal;stoplight': (41, 0, 255),
|
| 170 |
+
'tray': (41, 255, 0),
|
| 171 |
+
'ashcan;trash;can;garbage;can;wastebin;ash;bin;ash-bin;ashbin;dustbin;trash;barrel;trash;bin': (173, 0, 255),
|
| 172 |
+
'fan': (0, 245, 255),
|
| 173 |
+
'pier;wharf;wharfage;dock': (71, 0, 255),
|
| 174 |
+
'crt;screen': (122, 0, 255),
|
| 175 |
+
'plate': (0, 255, 184),
|
| 176 |
+
'monitor;monitoring;device': (0, 92, 255),
|
| 177 |
+
'bulletin;board;notice;board': (184, 255, 0),
|
| 178 |
+
'shower': (0, 133, 255),
|
| 179 |
+
'radiator': (255, 214, 0),
|
| 180 |
+
'glass;drinking;glass': (25, 194, 194),
|
| 181 |
+
'clock': (102, 255, 0),
|
| 182 |
+
'flag': (92, 0, 255),
|
| 183 |
+
}
|
s23dr_2026_example/data.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data loading for pre-sampled HF datasets.
|
| 2 |
+
|
| 3 |
+
Expects pre-sampled npz blobs with xyz_norm (not full PCD).
|
| 4 |
+
Supports both 2048-point and 4096-point datasets.
|
| 5 |
+
Use make_sampled_cache.py to produce these from full point clouds.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
import torch
|
| 13 |
+
|
| 14 |
+
from .tokenizer import EdgeDepthSequenceConfig
|
| 15 |
+
|
| 16 |
+
# Default token budget (for 2048-point datasets; 4096 uses 3072/1024)
|
| 17 |
+
SEQ_LEN = 2048
|
| 18 |
+
COLMAP_POINTS = 1536
|
| 19 |
+
DEPTH_POINTS = 512
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
# Datasets
|
| 24 |
+
# ---------------------------------------------------------------------------
|
| 25 |
+
|
| 26 |
+
def _load_bad_sample_ids():
|
| 27 |
+
"""Load the set of known-bad sample IDs (misaligned GT, extreme scale)."""
|
| 28 |
+
bad_file = Path(__file__).parent / "bad_samples.txt"
|
| 29 |
+
if not bad_file.exists():
|
| 30 |
+
return set()
|
| 31 |
+
return set(line.strip() for line in bad_file.read_text().splitlines() if line.strip())
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class HFCachedDataset(torch.utils.data.Dataset):
|
| 35 |
+
"""Load pre-sampled HuggingFace dataset into memory."""
|
| 36 |
+
|
| 37 |
+
def __init__(self, hf_dataset, aug_rotate=False, aug_jitter=0.0,
|
| 38 |
+
aug_drop=0.0, aug_flip=False):
|
| 39 |
+
import io as _io
|
| 40 |
+
bad_ids = _load_bad_sample_ids()
|
| 41 |
+
print(f"Pre-decoding {len(hf_dataset)} samples into memory...")
|
| 42 |
+
self.samples = []
|
| 43 |
+
self.order_ids = []
|
| 44 |
+
n_skipped = 0
|
| 45 |
+
for i, sample in enumerate(hf_dataset):
|
| 46 |
+
if sample["order_id"] in bad_ids:
|
| 47 |
+
n_skipped += 1
|
| 48 |
+
continue
|
| 49 |
+
d = dict(np.load(_io.BytesIO(sample["data"])))
|
| 50 |
+
if "xyz_norm" not in d:
|
| 51 |
+
raise ValueError(
|
| 52 |
+
f"Sample {sample['order_id']} missing 'xyz_norm' -- this looks like "
|
| 53 |
+
f"a full PCD dataset, not pre-sampled. Use make_sampled_cache.py first.")
|
| 54 |
+
self.samples.append(d)
|
| 55 |
+
self.order_ids.append(sample["order_id"])
|
| 56 |
+
if (i + 1) % 2000 == 0:
|
| 57 |
+
print(f" {i+1}/{len(hf_dataset)}...")
|
| 58 |
+
print(f" Done. {len(self.samples)} samples in memory"
|
| 59 |
+
f" ({n_skipped} bad samples filtered).")
|
| 60 |
+
self.aug_rotate = aug_rotate
|
| 61 |
+
self.aug_jitter = aug_jitter
|
| 62 |
+
self.aug_drop = aug_drop
|
| 63 |
+
self.aug_flip = aug_flip
|
| 64 |
+
|
| 65 |
+
def __len__(self):
|
| 66 |
+
return len(self.samples)
|
| 67 |
+
|
| 68 |
+
def __getitem__(self, idx):
|
| 69 |
+
out = _process_sample(self.samples[idx], self.aug_rotate,
|
| 70 |
+
self.aug_jitter, self.aug_drop, self.aug_flip)
|
| 71 |
+
out["sample_id"] = self.order_ids[idx]
|
| 72 |
+
return out
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _process_sample(d, aug_rotate, aug_jitter=0.0, aug_drop=0.0, aug_flip=False):
|
| 76 |
+
"""Process a pre-sampled npz dict into training tensors.
|
| 77 |
+
|
| 78 |
+
Args:
|
| 79 |
+
aug_rotate: random yaw rotation
|
| 80 |
+
aug_jitter: std of Gaussian noise added to point positions (0=disabled)
|
| 81 |
+
aug_drop: fraction of points to randomly drop (0=disabled)
|
| 82 |
+
aug_flip: random mirror along X axis (50% chance)
|
| 83 |
+
"""
|
| 84 |
+
xyz_norm = d["xyz_norm"].copy()
|
| 85 |
+
gt_seg = d["gt_segments"].copy()
|
| 86 |
+
mask = d["mask"].copy()
|
| 87 |
+
|
| 88 |
+
if aug_rotate:
|
| 89 |
+
theta = np.random.rand() * 2 * np.pi
|
| 90 |
+
cos_t, sin_t = np.cos(theta), np.sin(theta)
|
| 91 |
+
x, z = xyz_norm[:, 0].copy(), xyz_norm[:, 2].copy()
|
| 92 |
+
xyz_norm[:, 0] = x * cos_t - z * sin_t
|
| 93 |
+
xyz_norm[:, 2] = x * sin_t + z * cos_t
|
| 94 |
+
for ep in range(2):
|
| 95 |
+
sx, sz = gt_seg[:, ep, 0].copy(), gt_seg[:, ep, 2].copy()
|
| 96 |
+
gt_seg[:, ep, 0] = sx * cos_t - sz * sin_t
|
| 97 |
+
gt_seg[:, ep, 2] = sx * sin_t + sz * cos_t
|
| 98 |
+
|
| 99 |
+
if aug_flip and np.random.rand() < 0.5:
|
| 100 |
+
xyz_norm[:, 0] = -xyz_norm[:, 0]
|
| 101 |
+
gt_seg[:, :, 0] = -gt_seg[:, :, 0]
|
| 102 |
+
|
| 103 |
+
if aug_jitter > 0:
|
| 104 |
+
valid = mask.astype(bool)
|
| 105 |
+
xyz_norm[valid] += np.random.randn(valid.sum(), 3).astype(np.float32) * aug_jitter
|
| 106 |
+
|
| 107 |
+
if aug_drop > 0:
|
| 108 |
+
valid_idx = np.where(mask)[0]
|
| 109 |
+
n_drop = int(len(valid_idx) * aug_drop)
|
| 110 |
+
if n_drop > 0:
|
| 111 |
+
drop_idx = np.random.choice(valid_idx, n_drop, replace=False)
|
| 112 |
+
mask[drop_idx] = False
|
| 113 |
+
|
| 114 |
+
result = {
|
| 115 |
+
"xyz_norm": torch.as_tensor(xyz_norm, dtype=torch.float32),
|
| 116 |
+
"class_id": torch.as_tensor(d["class_id"], dtype=torch.long),
|
| 117 |
+
"source": torch.as_tensor(d["source"], dtype=torch.long),
|
| 118 |
+
"mask": torch.as_tensor(mask),
|
| 119 |
+
"gt_segments": torch.as_tensor(gt_seg, dtype=torch.float32),
|
| 120 |
+
"scale": torch.tensor(float(d["scale"]), dtype=torch.float32),
|
| 121 |
+
"center": torch.as_tensor(d["center"], dtype=torch.float32),
|
| 122 |
+
"gt_vertices": d["gt_vertices"],
|
| 123 |
+
"gt_edges": d["gt_edges"],
|
| 124 |
+
"visible_src": torch.as_tensor(d["visible_src"], dtype=torch.long),
|
| 125 |
+
"visible_id": torch.as_tensor(d["visible_id"], dtype=torch.long),
|
| 126 |
+
}
|
| 127 |
+
if "behind" in d:
|
| 128 |
+
result["behind"] = torch.as_tensor(
|
| 129 |
+
np.clip(np.asarray(d["behind"], dtype=np.int16), 0, None), dtype=torch.long)
|
| 130 |
+
if "n_views_voted" in d:
|
| 131 |
+
result["n_views_voted"] = torch.as_tensor(d["n_views_voted"], dtype=torch.float32)
|
| 132 |
+
if "vote_frac" in d:
|
| 133 |
+
result["vote_frac"] = torch.as_tensor(d["vote_frac"], dtype=torch.float32)
|
| 134 |
+
return result
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ---------------------------------------------------------------------------
|
| 138 |
+
# Collation + DataLoader
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
|
| 141 |
+
def collate(batch):
|
| 142 |
+
"""Stack samples into batched tensors."""
|
| 143 |
+
out = {
|
| 144 |
+
"xyz_norm": torch.stack([d["xyz_norm"] for d in batch]),
|
| 145 |
+
"class_id": torch.stack([d["class_id"] for d in batch]),
|
| 146 |
+
"source": torch.stack([d["source"] for d in batch]),
|
| 147 |
+
"mask": torch.stack([d["mask"] for d in batch]),
|
| 148 |
+
"gt_segments": [d["gt_segments"] for d in batch],
|
| 149 |
+
"scales": torch.stack([d["scale"] for d in batch]),
|
| 150 |
+
"meta": batch,
|
| 151 |
+
}
|
| 152 |
+
# Optional fields: check ALL samples, not just batch[0].
|
| 153 |
+
# If any sample has it, all must have it (no mixed data versions).
|
| 154 |
+
for field in ("behind", "n_views_voted", "vote_frac"):
|
| 155 |
+
if any(field in d for d in batch):
|
| 156 |
+
missing = [i for i, d in enumerate(batch) if field not in d]
|
| 157 |
+
if missing:
|
| 158 |
+
raise KeyError(
|
| 159 |
+
f"Field '{field}' present in some batch samples but missing in "
|
| 160 |
+
f"{len(missing)}/{len(batch)}. Mixed data versions in cache?")
|
| 161 |
+
out[field] = torch.stack([d[field] for d in batch])
|
| 162 |
+
return out
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def build_loader(cache_dir, batch_size, aug_rotate=False, aug_jitter=0.0,
|
| 166 |
+
aug_drop=0.0, aug_flip=False):
|
| 167 |
+
"""Create a DataLoader from HF dataset.
|
| 168 |
+
|
| 169 |
+
cache_dir should be 'hf://repo/name:split' format.
|
| 170 |
+
"""
|
| 171 |
+
if cache_dir.startswith("local://"):
|
| 172 |
+
from datasets import load_from_disk
|
| 173 |
+
hf_ds = load_from_disk(cache_dir[len("local://"):])
|
| 174 |
+
elif cache_dir.startswith("hf://"):
|
| 175 |
+
parts = cache_dir[5:].split(":")
|
| 176 |
+
repo = parts[0]
|
| 177 |
+
split = parts[1] if len(parts) > 1 else "train"
|
| 178 |
+
from datasets import load_dataset
|
| 179 |
+
hf_ds = load_dataset(repo, split=split)
|
| 180 |
+
else:
|
| 181 |
+
raise ValueError(
|
| 182 |
+
f"cache_dir must be 'hf://repo:split' or 'local:///path', got: {cache_dir}")
|
| 183 |
+
ds = HFCachedDataset(hf_ds, aug_rotate=aug_rotate, aug_jitter=aug_jitter,
|
| 184 |
+
aug_drop=aug_drop, aug_flip=aug_flip)
|
| 185 |
+
loader = torch.utils.data.DataLoader(
|
| 186 |
+
ds, batch_size=batch_size, shuffle=True,
|
| 187 |
+
num_workers=0, collate_fn=collate,
|
| 188 |
+
)
|
| 189 |
+
print(f"Dataset: {len(ds)} scenes, batch_size={batch_size}")
|
| 190 |
+
return loader
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
# Token building (GPU)
|
| 195 |
+
# ---------------------------------------------------------------------------
|
| 196 |
+
|
| 197 |
+
def build_tokens(batch, model, device):
|
| 198 |
+
"""Apply Fourier features + learned embeddings on GPU."""
|
| 199 |
+
xyz = batch["xyz_norm"].to(device)
|
| 200 |
+
cid = batch["class_id"].to(device)
|
| 201 |
+
src = batch["source"].to(device)
|
| 202 |
+
masks = batch["mask"].to(device)
|
| 203 |
+
gt = [g.to(device) for g in batch["gt_segments"]]
|
| 204 |
+
scales = batch["scales"]
|
| 205 |
+
|
| 206 |
+
B, T, _ = xyz.shape
|
| 207 |
+
tok = model.tokenizer
|
| 208 |
+
fourier = tok.pos_enc(xyz.reshape(-1, 3)).reshape(B, T, -1) \
|
| 209 |
+
if tok.pos_enc is not None else xyz.new_zeros(B, T, 0)
|
| 210 |
+
parts = [xyz, fourier, tok.label_emb(cid), tok.src_emb(src.clamp(0, 1))]
|
| 211 |
+
if tok.behind_emb_dim > 0:
|
| 212 |
+
if "behind" in batch:
|
| 213 |
+
beh = batch["behind"].to(device)
|
| 214 |
+
else:
|
| 215 |
+
# Data doesn't have behind -- use zeros (embed index 0).
|
| 216 |
+
# This is intentional for eval on old data; for training,
|
| 217 |
+
# fail fast by requiring the field (checked in _process_sample).
|
| 218 |
+
beh = xyz.new_zeros(B, T, dtype=torch.long)
|
| 219 |
+
parts.append(tok.behind_emb(beh))
|
| 220 |
+
if tok.use_vote_features:
|
| 221 |
+
if "n_views_voted" not in batch or "vote_frac" not in batch:
|
| 222 |
+
raise KeyError(
|
| 223 |
+
"Model expects vote features (--vote-features) but data is missing "
|
| 224 |
+
"'n_views_voted'/'vote_frac'. Use v2 dataset or regenerate cache.")
|
| 225 |
+
# Normalize to ~zero mean, unit variance (dataset stats: nv~2.7+/-1.0, vf~0.5+/-0.25)
|
| 226 |
+
nv = ((batch["n_views_voted"].to(device).float() - 2.7) / 1.0).unsqueeze(-1)
|
| 227 |
+
vf = ((batch["vote_frac"].to(device).float() - 0.5) / 0.25).unsqueeze(-1)
|
| 228 |
+
parts.extend([nv, vf])
|
| 229 |
+
tokens = torch.cat(parts, dim=-1)
|
| 230 |
+
return tokens, masks, gt, scales, batch["meta"]
|
s23dr_2026_example/losses.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loss computation for wireframe prediction."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import torch
|
| 5 |
+
|
| 6 |
+
from .varifold import varifold_loss_batch
|
| 7 |
+
from .sinkhorn import batched_sinkhorn_loss
|
| 8 |
+
|
| 9 |
+
# Varifold config
|
| 10 |
+
VARIANT = "simpson3"
|
| 11 |
+
SIGMAS = [0.5, 1.0, 2.0] # meters (divided by per-scene scale at runtime)
|
| 12 |
+
ALPHAS = [0.2, 0.6, 0.2]
|
| 13 |
+
LEN_POW = 1.0
|
| 14 |
+
VARIFOLD_CROSS_ONLY = False # Set to True to drop self-energy (avoids O(S^2) blowup)
|
| 15 |
+
|
| 16 |
+
# Sinkhorn config (note: near-zero gradients at eps=0.05, effectively disabled)
|
| 17 |
+
SINKHORN_EPS = 0.05
|
| 18 |
+
SINKHORN_ITERS = 10
|
| 19 |
+
|
| 20 |
+
# Sinkhorn dustbin cost: controls the OT "not matching" penalty.
|
| 21 |
+
# Like tau, this is an OT behavior parameter, NOT a physical distance.
|
| 22 |
+
# Must be comparable to typical matching costs in normalized space (~0.1).
|
| 23 |
+
# Do NOT divide by scale.
|
| 24 |
+
SINKHORN_DUSTBIN = 0.1
|
| 25 |
+
|
| 26 |
+
MAX_GT = 64 # fixed pad size for compile-friendly shapes
|
| 27 |
+
|
| 28 |
+
# Precomputed constants (created once on first call)
|
| 29 |
+
_loss_constants = {}
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _get_loss_constants(device, dtype):
|
| 33 |
+
key = (device, dtype)
|
| 34 |
+
if key not in _loss_constants:
|
| 35 |
+
_loss_constants[key] = {
|
| 36 |
+
"sigmas": torch.tensor(SIGMAS, device=device, dtype=dtype),
|
| 37 |
+
"alphas": torch.tensor(ALPHAS, device=device, dtype=dtype),
|
| 38 |
+
}
|
| 39 |
+
return _loss_constants[key]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def pad_gt_fixed(gt_list, device, dtype):
|
| 43 |
+
"""Pad GT segments to fixed MAX_GT for compile-friendly shapes."""
|
| 44 |
+
B = len(gt_list)
|
| 45 |
+
gt_pad = torch.zeros((B, MAX_GT, 2, 3), device=device, dtype=dtype)
|
| 46 |
+
gt_mask = torch.zeros((B, MAX_GT), device=device, dtype=torch.bool)
|
| 47 |
+
gt_lengths = torch.zeros(B, device=device, dtype=dtype)
|
| 48 |
+
for i, g in enumerate(gt_list):
|
| 49 |
+
n = g.shape[0]
|
| 50 |
+
if n > 0:
|
| 51 |
+
gt_pad[i, :n] = g
|
| 52 |
+
gt_mask[i, :n] = True
|
| 53 |
+
gt_lengths[i] = torch.linalg.norm(g[:, 1] - g[:, 0], dim=-1).sum()
|
| 54 |
+
return gt_pad, gt_mask, gt_lengths
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _loss_inner(pred_segments, gt_pad, gt_mask, gt_lengths, scales,
|
| 58 |
+
sigmas, alphas, varifold_w):
|
| 59 |
+
"""Pure tensor loss -- no Python control flow, no boolean indexing."""
|
| 60 |
+
has_gt = (gt_lengths > 0).float()
|
| 61 |
+
|
| 62 |
+
sigmas_eff = sigmas / scales[:, None]
|
| 63 |
+
loss_batch = varifold_loss_batch(
|
| 64 |
+
pred_segments, gt_pad, gt_mask=gt_mask,
|
| 65 |
+
variant=VARIANT, sigmas=sigmas_eff, alpha=alphas, len_pow=LEN_POW,
|
| 66 |
+
cross_only=VARIFOLD_CROSS_ONLY,
|
| 67 |
+
)
|
| 68 |
+
v = loss_batch / gt_lengths.clamp(min=1.0)
|
| 69 |
+
v = (v * has_gt).sum() / has_gt.sum().clamp(min=1.0)
|
| 70 |
+
|
| 71 |
+
total = varifold_w * v
|
| 72 |
+
return total, v
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# Will be replaced with compiled version on CUDA
|
| 76 |
+
_loss_fn = _loss_inner
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def compute_loss(pred_segments, gt_list, scales, device,
|
| 80 |
+
varifold_w, sinkhorn_w,
|
| 81 |
+
endpoint_w=0.0,
|
| 82 |
+
conf_logits=None, conf_weight=0.0, conf_mode="sinkhorn",
|
| 83 |
+
sinkhorn_eps=None, sinkhorn_iters=None,
|
| 84 |
+
sinkhorn_dustbin=None, conf_clamp_min=None):
|
| 85 |
+
"""Combined loss with fixed-size GT padding.
|
| 86 |
+
|
| 87 |
+
conf_mode: "sinkhorn" = conf-weighted sinkhorn, "sinkhorn_detach" = detached conf.
|
| 88 |
+
"""
|
| 89 |
+
if conf_logits is not None and conf_clamp_min is not None:
|
| 90 |
+
conf_logits = conf_logits.clamp(min=conf_clamp_min)
|
| 91 |
+
gt_pad, gt_mask, gt_lengths = pad_gt_fixed(gt_list, device, pred_segments.dtype)
|
| 92 |
+
c = _get_loss_constants(device, pred_segments.dtype)
|
| 93 |
+
|
| 94 |
+
total, v = _loss_fn(
|
| 95 |
+
pred_segments, gt_pad, gt_mask, gt_lengths, scales,
|
| 96 |
+
c["sigmas"], c["alphas"], varifold_w)
|
| 97 |
+
|
| 98 |
+
terms = {}
|
| 99 |
+
if varifold_w > 0:
|
| 100 |
+
terms["varifold"] = v.detach()
|
| 101 |
+
|
| 102 |
+
if sinkhorn_w > 0:
|
| 103 |
+
has_gt = (gt_lengths > 0).float()
|
| 104 |
+
if conf_logits is not None and conf_mode == "sinkhorn":
|
| 105 |
+
pred_mass = torch.sigmoid(conf_logits)
|
| 106 |
+
elif conf_logits is not None and conf_mode == "sinkhorn_detach":
|
| 107 |
+
pred_mass = torch.sigmoid(conf_logits.detach())
|
| 108 |
+
else:
|
| 109 |
+
pred_mass = None
|
| 110 |
+
eps = sinkhorn_eps if sinkhorn_eps is not None else SINKHORN_EPS
|
| 111 |
+
iters = sinkhorn_iters if sinkhorn_iters is not None else SINKHORN_ITERS
|
| 112 |
+
dustbin = sinkhorn_dustbin if sinkhorn_dustbin is not None else SINKHORN_DUSTBIN
|
| 113 |
+
S = pred_segments.shape[1]
|
| 114 |
+
sink_per = batched_sinkhorn_loss(
|
| 115 |
+
pred_segments, gt_pad, gt_mask,
|
| 116 |
+
eps, iters, dustbin,
|
| 117 |
+
pred_mass=pred_mass,
|
| 118 |
+
) / (gt_lengths.clamp(min=1.0) * S)
|
| 119 |
+
s = (sink_per * has_gt).sum() / has_gt.sum().clamp(min=1.0)
|
| 120 |
+
total = total + sinkhorn_w * s
|
| 121 |
+
terms["sinkhorn"] = s.detach()
|
| 122 |
+
|
| 123 |
+
if conf_logits is not None and conf_weight > 0:
|
| 124 |
+
if conf_mode in ("sinkhorn", "sinkhorn_detach"):
|
| 125 |
+
conf_w = torch.sigmoid(conf_logits)
|
| 126 |
+
S = conf_logits.shape[1]
|
| 127 |
+
gt_counts = gt_mask.sum(dim=1).float()
|
| 128 |
+
conf_sum = conf_w.sum(dim=1)
|
| 129 |
+
reg = (((conf_sum - gt_counts) / S) ** 2).mean()
|
| 130 |
+
total = total + conf_weight * reg
|
| 131 |
+
terms["conf_reg"] = reg.detach()
|
| 132 |
+
else:
|
| 133 |
+
raise ValueError(f"Unknown conf_mode: {conf_mode}")
|
| 134 |
+
|
| 135 |
+
if endpoint_w > 0:
|
| 136 |
+
has_gt = (gt_lengths > 0).float()
|
| 137 |
+
eps_ep = sinkhorn_eps if sinkhorn_eps is not None else SINKHORN_EPS
|
| 138 |
+
iters_ep = sinkhorn_iters if sinkhorn_iters is not None else SINKHORN_ITERS
|
| 139 |
+
dustbin_ep = sinkhorn_dustbin if sinkhorn_dustbin is not None else SINKHORN_DUSTBIN
|
| 140 |
+
B, S = pred_segments.shape[:2]
|
| 141 |
+
M = gt_pad.shape[1]
|
| 142 |
+
|
| 143 |
+
# Compute hard assignment via sinkhorn (detached -- matching is not trained)
|
| 144 |
+
with torch.no_grad():
|
| 145 |
+
pred_mass_ep = torch.sigmoid(conf_logits) if conf_logits is not None else None
|
| 146 |
+
sink_loss_for_assign = batched_sinkhorn_loss(
|
| 147 |
+
pred_segments, gt_pad, gt_mask, eps_ep, iters_ep, dustbin_ep,
|
| 148 |
+
pred_mass=pred_mass_ep)
|
| 149 |
+
p0, p1 = pred_segments[:, :, 0], pred_segments[:, :, 1]
|
| 150 |
+
g0, g1 = gt_pad[:, :, 0], gt_pad[:, :, 1]
|
| 151 |
+
mid_p, half_p = 0.5 * (p0 + p1), 0.5 * (p1 - p0)
|
| 152 |
+
mid_g, half_g = 0.5 * (g0 + g1), 0.5 * (g1 - g0)
|
| 153 |
+
d_mid = torch.linalg.norm(mid_p.unsqueeze(2) - mid_g.unsqueeze(1), dim=-1)
|
| 154 |
+
len_p = torch.linalg.norm(half_p, dim=-1, keepdim=True).clamp(min=1e-6)
|
| 155 |
+
len_g = torch.linalg.norm(half_g, dim=-1, keepdim=True).clamp(min=1e-6)
|
| 156 |
+
dir_p, dir_g = half_p / len_p, half_g / len_g
|
| 157 |
+
cos_a = (dir_p.unsqueeze(2) * dir_g.unsqueeze(1)).sum(dim=-1)
|
| 158 |
+
d_dir = 1.0 - cos_a.abs()
|
| 159 |
+
d_len = (len_p.unsqueeze(2) - len_g.unsqueeze(1)).squeeze(-1).abs()
|
| 160 |
+
cost = d_mid + d_dir + d_len
|
| 161 |
+
dc = torch.as_tensor(dustbin_ep, device=cost.device, dtype=cost.dtype)
|
| 162 |
+
cost = torch.where(gt_mask.unsqueeze(1), cost, dc * 10.0)
|
| 163 |
+
cost_pad = dc.expand(B, S + 1, M + 1).clone()
|
| 164 |
+
cost_pad[:, :S, :M] = cost
|
| 165 |
+
cost_pad[:, -1, -1] = 0.0
|
| 166 |
+
gt_counts = gt_mask.sum(dim=1).float()
|
| 167 |
+
if pred_mass_ep is not None:
|
| 168 |
+
pm = pred_mass_ep.clamp(min=0.0)
|
| 169 |
+
a = torch.cat([pm, (gt_counts - pm.sum(1)).clamp(min=0).unsqueeze(1)], dim=1)
|
| 170 |
+
b_val = torch.zeros(B, M + 1, device=cost.device, dtype=cost.dtype)
|
| 171 |
+
b_val[:, :M] = gt_mask.float()
|
| 172 |
+
b_val[:, -1] = (pm.sum(1) - gt_counts).clamp(min=0)
|
| 173 |
+
else:
|
| 174 |
+
n = float(S)
|
| 175 |
+
denom = n + gt_counts
|
| 176 |
+
a = (1.0 / denom).unsqueeze(1).expand(B, S + 1).clone()
|
| 177 |
+
a[:, -1] = gt_counts / denom
|
| 178 |
+
b_val = (1.0 / denom).unsqueeze(1).expand(B, M + 1).clone()
|
| 179 |
+
b_val[:, -1] = n / denom
|
| 180 |
+
b_val[:, :M] = b_val[:, :M] * gt_mask.float()
|
| 181 |
+
log_a = torch.log(a + 1e-9)
|
| 182 |
+
log_b = torch.log(b_val + 1e-9)
|
| 183 |
+
log_k = -cost_pad / eps_ep
|
| 184 |
+
log_u = torch.zeros_like(a)
|
| 185 |
+
log_v = torch.zeros_like(b_val)
|
| 186 |
+
for _ in range(iters_ep):
|
| 187 |
+
log_u = log_a - torch.logsumexp(log_k + log_v.unsqueeze(1), dim=2)
|
| 188 |
+
log_v = log_b - torch.logsumexp(log_k + log_u.unsqueeze(2), dim=1)
|
| 189 |
+
transport = torch.exp(log_u.unsqueeze(2) + log_v.unsqueeze(1) + log_k)
|
| 190 |
+
assignment = transport[:, :S, :M+1].argmax(dim=2)
|
| 191 |
+
assignment[assignment >= M] = -1
|
| 192 |
+
|
| 193 |
+
# Everything below is WITH gradients (assignment is detached but pred_segments is live)
|
| 194 |
+
matched = (assignment >= 0) # [B, S]
|
| 195 |
+
n_matched = matched.float().sum().clamp(min=1.0)
|
| 196 |
+
assign_safe = assignment.clamp(min=0)
|
| 197 |
+
gt_matched = gt_pad[
|
| 198 |
+
torch.arange(B, device=device)[:, None].expand(B, S),
|
| 199 |
+
assign_safe] # [B, S, 2, 3]
|
| 200 |
+
|
| 201 |
+
# Symmetric endpoint distance
|
| 202 |
+
ref_ep1 = pred_segments[:, :, 0]
|
| 203 |
+
ref_ep2 = pred_segments[:, :, 1]
|
| 204 |
+
gt_ep1 = gt_matched[:, :, 0]
|
| 205 |
+
gt_ep2 = gt_matched[:, :, 1]
|
| 206 |
+
dist_fwd = (ref_ep1 - gt_ep1).norm(dim=-1) + (ref_ep2 - gt_ep2).norm(dim=-1)
|
| 207 |
+
dist_rev = (ref_ep1 - gt_ep2).norm(dim=-1) + (ref_ep2 - gt_ep1).norm(dim=-1)
|
| 208 |
+
ep_dist = torch.min(dist_fwd, dist_rev)
|
| 209 |
+
|
| 210 |
+
# Normalize by GT total length * S (same scale as sinkhorn)
|
| 211 |
+
ep_loss = (ep_dist * matched.float()).sum() / n_matched
|
| 212 |
+
total = total + endpoint_w * ep_loss
|
| 213 |
+
terms["endpoint"] = ep_loss.detach()
|
| 214 |
+
|
| 215 |
+
return total, terms
|
s23dr_2026_example/make_sampled_cache.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""Stage 2: priority-sample cached .pt scenes into fixed-size .npz files.
|
| 3 |
+
|
| 4 |
+
Reads the per-scene .pt files produced by cache_scenes.py, priority-samples
|
| 5 |
+
a fixed number of points (2048 or 4096), normalizes, and writes one .npz per
|
| 6 |
+
scene (~50KB at 2048, ~100KB at 4096).
|
| 7 |
+
|
| 8 |
+
A fixed seed is used so every scene gets one deterministic sample -- no
|
| 9 |
+
per-epoch sampling augmentation, every epoch sees the same points.
|
| 10 |
+
|
| 11 |
+
Usage:
|
| 12 |
+
python -m s23dr_2026_example.make_sampled_cache \\
|
| 13 |
+
--in-dir cache/full --out-dir cache/sampled_2048 --seq-len 2048
|
| 14 |
+
python -m s23dr_2026_example.make_sampled_cache \\
|
| 15 |
+
--in-dir cache/full --out-dir cache/sampled_4096 --seq-len 4096
|
| 16 |
+
|
| 17 |
+
The 3:1 colmap:depth quota ratio is fixed: at seq_len=2048 that's
|
| 18 |
+
colmap=1536/depth=512; at seq_len=4096 that's colmap=3072/depth=1024.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import time
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# Priority sampling (same logic as train.py)
|
| 31 |
+
def _priority_sample(source, group_id, seq_len, colmap_quota, depth_quota):
|
| 32 |
+
def pick(src_id, quota):
|
| 33 |
+
base = source == src_id
|
| 34 |
+
picked, remaining = [], quota
|
| 35 |
+
for tier in range(5):
|
| 36 |
+
if remaining <= 0:
|
| 37 |
+
break
|
| 38 |
+
pool = np.where(base & (group_id == tier))[0]
|
| 39 |
+
if len(pool) == 0:
|
| 40 |
+
continue
|
| 41 |
+
np.random.shuffle(pool)
|
| 42 |
+
take = min(remaining, len(pool))
|
| 43 |
+
picked.append(pool[:take])
|
| 44 |
+
remaining -= take
|
| 45 |
+
if remaining > 0:
|
| 46 |
+
pool = np.where(base & (group_id >= 0))[0]
|
| 47 |
+
if len(pool) > 0:
|
| 48 |
+
np.random.shuffle(pool)
|
| 49 |
+
picked.append(pool[:min(remaining, len(pool))])
|
| 50 |
+
remaining -= min(remaining, len(pool))
|
| 51 |
+
return np.concatenate(picked) if picked else np.array([], dtype=np.int64), remaining
|
| 52 |
+
|
| 53 |
+
idx_c, rem_c = pick(0, colmap_quota)
|
| 54 |
+
idx_d, rem_d = pick(1, depth_quota)
|
| 55 |
+
|
| 56 |
+
if rem_c > 0:
|
| 57 |
+
extra = np.setdiff1d(np.where((source == 1) & (group_id >= 0))[0], idx_d)
|
| 58 |
+
np.random.shuffle(extra)
|
| 59 |
+
idx_d = np.concatenate([idx_d, extra[:rem_c]])
|
| 60 |
+
if rem_d > 0:
|
| 61 |
+
extra = np.setdiff1d(np.where((source == 0) & (group_id >= 0))[0], idx_c)
|
| 62 |
+
np.random.shuffle(extra)
|
| 63 |
+
idx_c = np.concatenate([idx_c, extra[:rem_d]])
|
| 64 |
+
|
| 65 |
+
indices = np.concatenate([idx_c, idx_d])
|
| 66 |
+
num_valid = len(indices)
|
| 67 |
+
if num_valid < seq_len:
|
| 68 |
+
if num_valid == 0:
|
| 69 |
+
return np.zeros(seq_len, dtype=np.int64), np.zeros(seq_len, dtype=bool)
|
| 70 |
+
indices = np.concatenate([indices, np.full(seq_len - num_valid, indices[-1])])
|
| 71 |
+
mask = np.zeros(seq_len, dtype=bool)
|
| 72 |
+
mask[:num_valid] = True
|
| 73 |
+
return indices[:seq_len], mask
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _process_sample(d, seq_len, colmap_q, depth_q):
|
| 77 |
+
"""Sample and normalize one cached scene dict into a small npz-ready dict."""
|
| 78 |
+
xyz = np.asarray(d["xyz"], np.float32)
|
| 79 |
+
source = np.asarray(d["source"], np.uint8)
|
| 80 |
+
group_id = np.asarray(d["group_id"], np.int8)
|
| 81 |
+
class_id = np.asarray(d["class_id"], np.uint8)
|
| 82 |
+
vis_src = np.asarray(d["visible_src"], np.uint8)
|
| 83 |
+
vis_id = np.asarray(d["visible_id"], np.int16)
|
| 84 |
+
center = np.asarray(d["center"], np.float32)
|
| 85 |
+
scale = float(d["scale"])
|
| 86 |
+
gt_v = np.asarray(d["gt_vertices"], np.float32)
|
| 87 |
+
gt_e = np.asarray(d["gt_edges"], np.int32)
|
| 88 |
+
|
| 89 |
+
indices, mask = _priority_sample(source, group_id, seq_len, colmap_q, depth_q)
|
| 90 |
+
xyz_norm = ((xyz[indices] - center) / scale).astype(np.float32)
|
| 91 |
+
gt_seg = np.stack([gt_v[gt_e[:, 0]], gt_v[gt_e[:, 1]]], axis=1)
|
| 92 |
+
gt_seg_norm = ((gt_seg - center) / scale).astype(np.float32)
|
| 93 |
+
|
| 94 |
+
result = {
|
| 95 |
+
"xyz_norm": xyz_norm,
|
| 96 |
+
"class_id": class_id[indices].astype(np.uint8),
|
| 97 |
+
"source": source[indices].astype(np.uint8),
|
| 98 |
+
"mask": mask,
|
| 99 |
+
"gt_segments": gt_seg_norm,
|
| 100 |
+
"scale": np.float32(scale),
|
| 101 |
+
"center": center,
|
| 102 |
+
"gt_vertices": gt_v,
|
| 103 |
+
"gt_edges": gt_e,
|
| 104 |
+
"visible_src": vis_src[indices].astype(np.uint8),
|
| 105 |
+
"visible_id": vis_id[indices].astype(np.int16),
|
| 106 |
+
}
|
| 107 |
+
if "behind_gest_id" in d:
|
| 108 |
+
result["behind"] = np.asarray(d["behind_gest_id"], np.int16)[indices]
|
| 109 |
+
if "n_views_voted" in d:
|
| 110 |
+
result["n_views_voted"] = np.asarray(d["n_views_voted"], np.uint8)[indices]
|
| 111 |
+
if "vote_frac" in d:
|
| 112 |
+
result["vote_frac"] = np.asarray(d["vote_frac"], np.float32)[indices]
|
| 113 |
+
if "gt_edge_classes" in d:
|
| 114 |
+
result["gt_edge_classes"] = np.asarray(d["gt_edge_classes"], np.int64)
|
| 115 |
+
return result
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def main():
|
| 119 |
+
p = argparse.ArgumentParser(description="Stage 2: cached .pt -> sampled .npz")
|
| 120 |
+
p.add_argument("--in-dir", required=True, help="Directory of .pt files from cache_scenes.py")
|
| 121 |
+
p.add_argument("--out-dir", required=True, help="Output directory for .npz files")
|
| 122 |
+
p.add_argument("--seq-len", type=int, default=2048, help="Points per sample (2048 or 4096)")
|
| 123 |
+
p.add_argument("--seed", type=int, default=7)
|
| 124 |
+
args = p.parse_args()
|
| 125 |
+
|
| 126 |
+
colmap_q = args.seq_len * 3 // 4
|
| 127 |
+
depth_q = args.seq_len - colmap_q
|
| 128 |
+
print(f"seq_len={args.seq_len} colmap={colmap_q} depth={depth_q}")
|
| 129 |
+
|
| 130 |
+
out_dir = Path(args.out_dir)
|
| 131 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 132 |
+
np.random.seed(args.seed)
|
| 133 |
+
|
| 134 |
+
files = sorted(Path(args.in_dir).glob("*.pt"))
|
| 135 |
+
print(f"Found {len(files)} .pt files in {args.in_dir}")
|
| 136 |
+
|
| 137 |
+
done = 0
|
| 138 |
+
t0 = time.perf_counter()
|
| 139 |
+
for f in files:
|
| 140 |
+
out_f = out_dir / (f.stem + ".npz")
|
| 141 |
+
if out_f.exists():
|
| 142 |
+
done += 1
|
| 143 |
+
continue
|
| 144 |
+
d = torch.load(f, weights_only=False)
|
| 145 |
+
result = _process_sample(d, args.seq_len, colmap_q, depth_q)
|
| 146 |
+
np.savez(out_f, **result)
|
| 147 |
+
done += 1
|
| 148 |
+
if done % 2000 == 0:
|
| 149 |
+
rate = done / (time.perf_counter() - t0)
|
| 150 |
+
print(f" {done}/{len(files)} [{rate:.0f}/s]")
|
| 151 |
+
|
| 152 |
+
elapsed = time.perf_counter() - t0
|
| 153 |
+
print(f"Done. {done} files in {elapsed:.0f}s -> {out_dir}")
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
if __name__ == "__main__":
|
| 157 |
+
main()
|
| 158 |
+
|
| 159 |
+
|
s23dr_2026_example/model.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Perceiver-based transformer for 3D roof wireframe prediction.
|
| 3 |
+
|
| 4 |
+
Architecture overview:
|
| 5 |
+
|
| 6 |
+
Input tokens [B, T, D]
|
| 7 |
+
|
|
| 8 |
+
v
|
| 9 |
+
input_proj: Linear -> GELU -> Linear -> LayerNorm => [B, T, hidden]
|
| 10 |
+
|
|
| 11 |
+
v
|
| 12 |
+
Perceiver latent bottleneck (N PerceiverLatentLayers):
|
| 13 |
+
Learnable latent embeddings [L, hidden] are broadcast to batch.
|
| 14 |
+
Each layer: cross-attn(latents <- tokens) -> self-attn(latents) -> FFN
|
| 15 |
+
Output: latents [B, L, hidden]
|
| 16 |
+
|
|
| 17 |
+
v
|
| 18 |
+
Segment decoder (M SegmentDecoderLayers):
|
| 19 |
+
Learnable query embeddings [S, hidden] are broadcast to batch.
|
| 20 |
+
Each layer: cross-attn(queries <- latents) -> self-attn(queries) -> FFN
|
| 21 |
+
Output: queries [B, S, hidden]
|
| 22 |
+
|
|
| 23 |
+
v
|
| 24 |
+
segment_head: Linear -> 6D -> (midpoint, half_vector)
|
| 25 |
+
+ query_offsets (learnable per-query bias)
|
| 26 |
+
endpoints = midpoint +/- half_vector -> [B, S, 2, 3]
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
import torch.nn as nn
|
| 31 |
+
|
| 32 |
+
from .attention import MultiHeadSDPA, FeedForward
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Building blocks
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
class AttnResidual(nn.Module):
|
| 40 |
+
"""Pre-norm attention + residual + dropout."""
|
| 41 |
+
|
| 42 |
+
def __init__(
|
| 43 |
+
self,
|
| 44 |
+
d_model: int,
|
| 45 |
+
num_heads: int,
|
| 46 |
+
dropout: float = 0.0,
|
| 47 |
+
kv_heads: int | None = None,
|
| 48 |
+
norm_class=None,
|
| 49 |
+
qk_norm: bool = False,
|
| 50 |
+
qk_norm_type: str = "l2",
|
| 51 |
+
):
|
| 52 |
+
super().__init__()
|
| 53 |
+
norm_class = norm_class or nn.LayerNorm
|
| 54 |
+
self.norm = norm_class(d_model)
|
| 55 |
+
self.attn = MultiHeadSDPA(d_model, num_heads, kv_heads=kv_heads, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 56 |
+
self.drop = nn.Dropout(dropout)
|
| 57 |
+
|
| 58 |
+
def forward(
|
| 59 |
+
self,
|
| 60 |
+
x: torch.Tensor,
|
| 61 |
+
memory: torch.Tensor,
|
| 62 |
+
memory_key_padding_mask: torch.Tensor | None = None,
|
| 63 |
+
) -> torch.Tensor:
|
| 64 |
+
res = x
|
| 65 |
+
x = self.norm(x)
|
| 66 |
+
x = self.attn(x, memory, key_padding_mask=memory_key_padding_mask)
|
| 67 |
+
return res + self.drop(x)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class FFNResidual(nn.Module):
|
| 71 |
+
"""Pre-norm feed-forward + residual + dropout."""
|
| 72 |
+
|
| 73 |
+
def __init__(
|
| 74 |
+
self,
|
| 75 |
+
d_model: int,
|
| 76 |
+
dim_ff: int,
|
| 77 |
+
dropout: float = 0.0,
|
| 78 |
+
activation: str = "gelu",
|
| 79 |
+
norm_class=None,
|
| 80 |
+
):
|
| 81 |
+
super().__init__()
|
| 82 |
+
norm_class = norm_class or nn.LayerNorm
|
| 83 |
+
self.norm = norm_class(d_model)
|
| 84 |
+
self.ffn = FeedForward(d_model, dim_ff, activation=activation)
|
| 85 |
+
self.drop = nn.Dropout(dropout)
|
| 86 |
+
|
| 87 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 88 |
+
res = x
|
| 89 |
+
x = self.norm(x)
|
| 90 |
+
x = self.ffn(x)
|
| 91 |
+
return res + self.drop(x)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
# ---------------------------------------------------------------------------
|
| 95 |
+
# Perceiver encoder layer
|
| 96 |
+
# ---------------------------------------------------------------------------
|
| 97 |
+
|
| 98 |
+
class PerceiverLatentLayer(nn.Module):
|
| 99 |
+
"""Single Perceiver latent layer.
|
| 100 |
+
|
| 101 |
+
If use_cross=True: cross-attn(latents <- points) -> self-attn -> FFN
|
| 102 |
+
If use_cross=False: self-attn -> FFN (saves compute in deep stacks)
|
| 103 |
+
"""
|
| 104 |
+
|
| 105 |
+
def __init__(
|
| 106 |
+
self,
|
| 107 |
+
d_model: int,
|
| 108 |
+
num_heads: int,
|
| 109 |
+
dim_ff: int,
|
| 110 |
+
dropout: float = 0.0,
|
| 111 |
+
activation: str = "gelu",
|
| 112 |
+
kv_heads_cross: int | None = None,
|
| 113 |
+
kv_heads_self: int | None = None,
|
| 114 |
+
use_cross: bool = True,
|
| 115 |
+
norm_class=None,
|
| 116 |
+
qk_norm: bool = False,
|
| 117 |
+
qk_norm_type: str = "l2",
|
| 118 |
+
):
|
| 119 |
+
super().__init__()
|
| 120 |
+
self.use_cross = use_cross
|
| 121 |
+
if use_cross:
|
| 122 |
+
self.cross = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 123 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_self, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 124 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 125 |
+
|
| 126 |
+
def forward(
|
| 127 |
+
self,
|
| 128 |
+
latents: torch.Tensor,
|
| 129 |
+
points: torch.Tensor,
|
| 130 |
+
points_key_padding_mask: torch.Tensor | None = None,
|
| 131 |
+
) -> torch.Tensor:
|
| 132 |
+
if self.use_cross:
|
| 133 |
+
latents = self.cross(latents, points, memory_key_padding_mask=points_key_padding_mask)
|
| 134 |
+
latents = self.self_attn(latents, latents)
|
| 135 |
+
latents = self.ffn(latents)
|
| 136 |
+
return latents
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
# ---------------------------------------------------------------------------
|
| 140 |
+
# Segment decoder layer
|
| 141 |
+
# ---------------------------------------------------------------------------
|
| 142 |
+
|
| 143 |
+
class SegmentDecoderLayer(nn.Module):
|
| 144 |
+
"""Single segment decoder layer.
|
| 145 |
+
|
| 146 |
+
cross-attn(queries <- latents) -> [cross-attn(queries <- inputs)] -> self-attn(queries) -> FFN
|
| 147 |
+
|
| 148 |
+
If input_xattn=True, adds a second cross-attention that attends directly
|
| 149 |
+
to the projected input tokens (bypassing the latent bottleneck). This gives
|
| 150 |
+
queries access to fine-grained point-level detail for vertex precision.
|
| 151 |
+
"""
|
| 152 |
+
|
| 153 |
+
def __init__(
|
| 154 |
+
self,
|
| 155 |
+
d_model: int,
|
| 156 |
+
num_heads: int,
|
| 157 |
+
dim_ff: int,
|
| 158 |
+
dropout: float = 0.0,
|
| 159 |
+
activation: str = "gelu",
|
| 160 |
+
kv_heads_cross: int | None = None,
|
| 161 |
+
kv_heads_self: int | None = None,
|
| 162 |
+
norm_class=None,
|
| 163 |
+
input_xattn: bool = False,
|
| 164 |
+
qk_norm: bool = False,
|
| 165 |
+
qk_norm_type: str = "l2",
|
| 166 |
+
):
|
| 167 |
+
super().__init__()
|
| 168 |
+
self.cross = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 169 |
+
self.input_xattn = input_xattn
|
| 170 |
+
if input_xattn:
|
| 171 |
+
self.cross_input = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_cross, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 172 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads_self, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 173 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 174 |
+
|
| 175 |
+
def forward(
|
| 176 |
+
self,
|
| 177 |
+
queries: torch.Tensor,
|
| 178 |
+
latents: torch.Tensor,
|
| 179 |
+
src: torch.Tensor | None = None,
|
| 180 |
+
src_key_padding_mask: torch.Tensor | None = None,
|
| 181 |
+
) -> torch.Tensor:
|
| 182 |
+
queries = self.cross(queries, latents)
|
| 183 |
+
if self.input_xattn and src is not None:
|
| 184 |
+
queries = self.cross_input(queries, src, memory_key_padding_mask=src_key_padding_mask)
|
| 185 |
+
queries = self.self_attn(queries, queries)
|
| 186 |
+
queries = self.ffn(queries)
|
| 187 |
+
return queries
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
# ---------------------------------------------------------------------------
|
| 191 |
+
# Full model
|
| 192 |
+
# ---------------------------------------------------------------------------
|
| 193 |
+
|
| 194 |
+
class TokenTransformerSegments(nn.Module):
|
| 195 |
+
"""Perceiver transformer that predicts 3D roof wireframe segments.
|
| 196 |
+
|
| 197 |
+
Takes point-cloud tokens and outputs segment endpoints as [B, S, 2, 3]
|
| 198 |
+
where S is the number of segments and each segment has two 3D endpoints.
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
segments: Number of predicted segments (S).
|
| 202 |
+
in_dim: Dimensionality of input tokens.
|
| 203 |
+
hidden: Internal hidden dimension throughout the model.
|
| 204 |
+
num_heads: Number of attention heads.
|
| 205 |
+
kv_heads_cross: Grouped-query heads for cross-attention (None = standard MHA).
|
| 206 |
+
kv_heads_self: Grouped-query heads for self-attention (None = standard MHA).
|
| 207 |
+
dim_feedforward: FFN intermediate dimension.
|
| 208 |
+
dropout: Dropout rate applied after attention and FFN.
|
| 209 |
+
latent_tokens: Number of learnable latent embeddings (L) in the bottleneck.
|
| 210 |
+
latent_layers: Number of PerceiverLatentLayers (N).
|
| 211 |
+
decoder_layers: Number of SegmentDecoderLayers (M).
|
| 212 |
+
"""
|
| 213 |
+
|
| 214 |
+
def __init__(
|
| 215 |
+
self,
|
| 216 |
+
segments: int = 32,
|
| 217 |
+
in_dim: int = 128,
|
| 218 |
+
hidden: int = 128,
|
| 219 |
+
num_heads: int = 4,
|
| 220 |
+
kv_heads_cross: int | None = 2,
|
| 221 |
+
kv_heads_self: int | None = 0,
|
| 222 |
+
dim_feedforward: int = 256,
|
| 223 |
+
dropout: float = 0.01,
|
| 224 |
+
latent_tokens: int = 64,
|
| 225 |
+
latent_layers: int = 2,
|
| 226 |
+
decoder_layers: int = 2,
|
| 227 |
+
cross_attn_interval: int = 1,
|
| 228 |
+
norm_class=None,
|
| 229 |
+
activation: str = "gelu",
|
| 230 |
+
segment_conf: bool = False,
|
| 231 |
+
pre_encoder_layers: int = 0,
|
| 232 |
+
segment_param: str = "midpoint_halfvec",
|
| 233 |
+
length_floor: float = 0.0,
|
| 234 |
+
decoder_input_xattn: bool = False,
|
| 235 |
+
qk_norm: bool = False,
|
| 236 |
+
qk_norm_type: str = "l2",
|
| 237 |
+
):
|
| 238 |
+
super().__init__()
|
| 239 |
+
self.segments = segments
|
| 240 |
+
self.out_vertices = segments * 2
|
| 241 |
+
self.segment_param = segment_param
|
| 242 |
+
self.decoder_input_xattn = decoder_input_xattn
|
| 243 |
+
norm_class = norm_class or nn.LayerNorm
|
| 244 |
+
|
| 245 |
+
# Treat 0 as "use standard MHA"
|
| 246 |
+
if kv_heads_cross is not None and kv_heads_cross <= 0:
|
| 247 |
+
kv_heads_cross = None
|
| 248 |
+
if kv_heads_self is not None and kv_heads_self <= 0:
|
| 249 |
+
kv_heads_self = None
|
| 250 |
+
|
| 251 |
+
# -- Input projection --
|
| 252 |
+
self.input_proj = nn.Sequential(
|
| 253 |
+
nn.Linear(in_dim, dim_feedforward),
|
| 254 |
+
nn.GELU(),
|
| 255 |
+
nn.Linear(dim_feedforward, hidden),
|
| 256 |
+
norm_class(hidden),
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
# -- Optional pre-encoder: self-attention on full token sequence --
|
| 260 |
+
if pre_encoder_layers > 0:
|
| 261 |
+
self.pre_encoder = nn.ModuleList([
|
| 262 |
+
SelfAttentionEncoderLayer(
|
| 263 |
+
d_model=hidden,
|
| 264 |
+
num_heads=num_heads,
|
| 265 |
+
dim_ff=dim_feedforward,
|
| 266 |
+
dropout=dropout,
|
| 267 |
+
activation=activation,
|
| 268 |
+
kv_heads=kv_heads_self,
|
| 269 |
+
norm_class=norm_class,
|
| 270 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 271 |
+
)
|
| 272 |
+
for _ in range(pre_encoder_layers)
|
| 273 |
+
])
|
| 274 |
+
else:
|
| 275 |
+
self.pre_encoder = None
|
| 276 |
+
|
| 277 |
+
# -- Perceiver latent bottleneck --
|
| 278 |
+
self.latent_embed = nn.Embedding(latent_tokens, hidden)
|
| 279 |
+
N = latent_layers
|
| 280 |
+
self.latent_layers = nn.ModuleList([
|
| 281 |
+
PerceiverLatentLayer(
|
| 282 |
+
d_model=hidden,
|
| 283 |
+
num_heads=num_heads,
|
| 284 |
+
dim_ff=dim_feedforward,
|
| 285 |
+
dropout=dropout,
|
| 286 |
+
activation=activation,
|
| 287 |
+
kv_heads_cross=kv_heads_cross,
|
| 288 |
+
kv_heads_self=kv_heads_self,
|
| 289 |
+
use_cross=(i == 0) or (i == N - 1) or (i % cross_attn_interval == 0),
|
| 290 |
+
norm_class=norm_class,
|
| 291 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 292 |
+
)
|
| 293 |
+
for i in range(N)
|
| 294 |
+
])
|
| 295 |
+
|
| 296 |
+
# -- Segment decoder --
|
| 297 |
+
self.query_embed = nn.Embedding(segments, hidden)
|
| 298 |
+
self.decoder_layers = nn.ModuleList([
|
| 299 |
+
SegmentDecoderLayer(
|
| 300 |
+
d_model=hidden,
|
| 301 |
+
num_heads=num_heads,
|
| 302 |
+
dim_ff=dim_feedforward,
|
| 303 |
+
dropout=dropout,
|
| 304 |
+
activation=activation,
|
| 305 |
+
kv_heads_cross=kv_heads_cross,
|
| 306 |
+
kv_heads_self=kv_heads_self,
|
| 307 |
+
norm_class=norm_class,
|
| 308 |
+
input_xattn=decoder_input_xattn,
|
| 309 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 310 |
+
)
|
| 311 |
+
for _ in range(decoder_layers)
|
| 312 |
+
])
|
| 313 |
+
|
| 314 |
+
# -- Output head --
|
| 315 |
+
if segment_param == "midpoint_dir_len":
|
| 316 |
+
self.segment_head = nn.Linear(hidden, 7) # mid(3) + dir(3) + len(1)
|
| 317 |
+
else:
|
| 318 |
+
self.segment_head = nn.Linear(hidden, 6) # mid(3) + half(3)
|
| 319 |
+
self.query_offsets = nn.Parameter(torch.zeros(segments, 2, 3))
|
| 320 |
+
|
| 321 |
+
nn.init.trunc_normal_(self.segment_head.weight, mean=0.0, std=1e-3)
|
| 322 |
+
if self.segment_head.bias is not None:
|
| 323 |
+
nn.init.zeros_(self.segment_head.bias)
|
| 324 |
+
if segment_param == "midpoint_dir_len":
|
| 325 |
+
# softplus(0.5) * 0.1 ~= 0.097 default length in normalized space
|
| 326 |
+
self.segment_head.bias.data[6] = 0.5
|
| 327 |
+
nn.init.normal_(self.query_offsets, mean=0.0, std=0.05)
|
| 328 |
+
|
| 329 |
+
# -- Optional confidence head --
|
| 330 |
+
self.segment_conf = segment_conf
|
| 331 |
+
if segment_conf:
|
| 332 |
+
self.conf_head = nn.Linear(hidden, 1)
|
| 333 |
+
nn.init.zeros_(self.conf_head.bias)
|
| 334 |
+
|
| 335 |
+
def forward(
|
| 336 |
+
self,
|
| 337 |
+
tokens: torch.Tensor,
|
| 338 |
+
mask: torch.Tensor | None = None,
|
| 339 |
+
) -> dict[str, torch.Tensor | list]:
|
| 340 |
+
"""
|
| 341 |
+
Args:
|
| 342 |
+
tokens: Input point-cloud tokens [B, T, in_dim].
|
| 343 |
+
mask: Boolean validity mask [B, T]. True = valid token.
|
| 344 |
+
|
| 345 |
+
Returns:
|
| 346 |
+
Dict with keys:
|
| 347 |
+
"vertices": [B, S*2, 3] flattened endpoints.
|
| 348 |
+
"segments": [B, S, 2, 3] segment endpoints.
|
| 349 |
+
"edges": Per-batch list of (start, end) index pairs into vertices.
|
| 350 |
+
"conf": [B, S] logits (only if segment_conf=True).
|
| 351 |
+
"""
|
| 352 |
+
B = tokens.shape[0]
|
| 353 |
+
|
| 354 |
+
# Project input tokens
|
| 355 |
+
src = self.input_proj(tokens) # [B, T, hidden]
|
| 356 |
+
|
| 357 |
+
# Padding mask (True where padded) for cross-attention
|
| 358 |
+
pad_mask = ~mask.bool() if mask is not None else None
|
| 359 |
+
|
| 360 |
+
# Optional pre-encoder: self-attention on full token sequence
|
| 361 |
+
if self.pre_encoder is not None:
|
| 362 |
+
for layer in self.pre_encoder:
|
| 363 |
+
src = layer(src, key_padding_mask=pad_mask)
|
| 364 |
+
|
| 365 |
+
# Perceiver latent bottleneck
|
| 366 |
+
latents = self.latent_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 367 |
+
for layer in self.latent_layers:
|
| 368 |
+
latents = layer(latents, src, points_key_padding_mask=pad_mask)
|
| 369 |
+
|
| 370 |
+
# Segment decoder
|
| 371 |
+
queries = self.query_embed.weight.unsqueeze(0).expand(B, -1, -1)
|
| 372 |
+
for layer in self.decoder_layers:
|
| 373 |
+
queries = layer(queries, latents,
|
| 374 |
+
src=src if self.decoder_input_xattn else None,
|
| 375 |
+
src_key_padding_mask=pad_mask if self.decoder_input_xattn else None)
|
| 376 |
+
|
| 377 |
+
# Predict segments -> endpoints
|
| 378 |
+
if self.segment_param == "midpoint_dir_len":
|
| 379 |
+
raw = self.segment_head(queries) # [B, S, 7]
|
| 380 |
+
mid = raw[:, :, :3] + self.query_offsets[:, 0, :].unsqueeze(0)
|
| 381 |
+
direction = torch.nn.functional.normalize(raw[:, :, 3:6], dim=-1)
|
| 382 |
+
length = torch.nn.functional.softplus(raw[:, :, 6:7]) * 0.1
|
| 383 |
+
half = direction * length * 0.5
|
| 384 |
+
else:
|
| 385 |
+
raw = self.segment_head(queries).view(B, self.segments, 2, 3)
|
| 386 |
+
raw = raw + self.query_offsets.unsqueeze(0)
|
| 387 |
+
mid, half = raw[:, :, 0], raw[:, :, 1]
|
| 388 |
+
seg_params = torch.stack([mid - half, mid + half], dim=2)
|
| 389 |
+
|
| 390 |
+
vertices = seg_params.reshape(B, self.out_vertices, 3)
|
| 391 |
+
edges = [[(2 * i, 2 * i + 1) for i in range(self.segments)] for _ in range(B)]
|
| 392 |
+
|
| 393 |
+
out = {"vertices": vertices, "segments": seg_params, "edges": edges,
|
| 394 |
+
"src": src, "pad_mask": pad_mask, "queries": queries}
|
| 395 |
+
if self.segment_conf:
|
| 396 |
+
out["conf"] = self.conf_head(queries).squeeze(-1) # [B, S]
|
| 397 |
+
return out
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
# ---------------------------------------------------------------------------
|
| 401 |
+
# Encoder-only layer (self-attention on full token sequence)
|
| 402 |
+
# ---------------------------------------------------------------------------
|
| 403 |
+
|
| 404 |
+
class SelfAttentionEncoderLayer(nn.Module):
|
| 405 |
+
"""Single self-attention layer: self-attn(tokens) -> FFN."""
|
| 406 |
+
|
| 407 |
+
def __init__(
|
| 408 |
+
self,
|
| 409 |
+
d_model: int,
|
| 410 |
+
num_heads: int,
|
| 411 |
+
dim_ff: int,
|
| 412 |
+
dropout: float = 0.0,
|
| 413 |
+
activation: str = "gelu",
|
| 414 |
+
kv_heads: int | None = None,
|
| 415 |
+
norm_class=None,
|
| 416 |
+
qk_norm: bool = False,
|
| 417 |
+
qk_norm_type: str = "l2",
|
| 418 |
+
):
|
| 419 |
+
super().__init__()
|
| 420 |
+
self.self_attn = AttnResidual(d_model, num_heads, dropout, kv_heads=kv_heads, norm_class=norm_class, qk_norm=qk_norm, qk_norm_type=qk_norm_type)
|
| 421 |
+
self.ffn = FFNResidual(d_model, dim_ff, dropout, activation=activation, norm_class=norm_class)
|
| 422 |
+
|
| 423 |
+
def forward(self, x: torch.Tensor, key_padding_mask: torch.Tensor | None = None) -> torch.Tensor:
|
| 424 |
+
x = self.self_attn(x, x, memory_key_padding_mask=key_padding_mask)
|
| 425 |
+
x = self.ffn(x)
|
| 426 |
+
return x
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
# ---------------------------------------------------------------------------
|
| 430 |
+
# End-to-end model: tokenizer embeddings + perceiver
|
| 431 |
+
# ---------------------------------------------------------------------------
|
| 432 |
+
|
| 433 |
+
class EdgeDepthSegmentsModel(nn.Module):
|
| 434 |
+
"""Tokenizer embeddings + transformer for 3D roof wireframes.
|
| 435 |
+
|
| 436 |
+
Supports two architectures via the `arch` parameter:
|
| 437 |
+
- "perceiver": Perceiver latent bottleneck (default, O(L*T) attention)
|
| 438 |
+
- "transformer": Standard self-attention encoder (O(T^2) attention)
|
| 439 |
+
|
| 440 |
+
Both share the same decoder, output head, and tokenizer.
|
| 441 |
+
"""
|
| 442 |
+
|
| 443 |
+
def __init__(
|
| 444 |
+
self,
|
| 445 |
+
seq_cfg,
|
| 446 |
+
segments: int = 32,
|
| 447 |
+
hidden: int = 128,
|
| 448 |
+
num_heads: int = 4,
|
| 449 |
+
kv_heads_cross: int | None = 2,
|
| 450 |
+
kv_heads_self: int | None = 0,
|
| 451 |
+
dim_feedforward: int = 256,
|
| 452 |
+
dropout: float = 0.1,
|
| 453 |
+
latent_tokens: int = 64,
|
| 454 |
+
latent_layers: int = 1,
|
| 455 |
+
decoder_layers: int = 2,
|
| 456 |
+
label_emb_dim: int = 16,
|
| 457 |
+
src_emb_dim: int = 2,
|
| 458 |
+
behind_emb_dim: int = 8,
|
| 459 |
+
fourier_seed: int = 0,
|
| 460 |
+
cross_attn_interval: int = 1,
|
| 461 |
+
norm_class=None,
|
| 462 |
+
activation: str = "gelu",
|
| 463 |
+
segment_conf: bool = False,
|
| 464 |
+
use_vote_features: bool = False,
|
| 465 |
+
arch: str = "perceiver",
|
| 466 |
+
encoder_layers: int = 4,
|
| 467 |
+
pre_encoder_layers: int = 0,
|
| 468 |
+
segment_param: str = "midpoint_halfvec",
|
| 469 |
+
length_floor: float = 0.0,
|
| 470 |
+
decoder_input_xattn: bool = False,
|
| 471 |
+
qk_norm: bool = False,
|
| 472 |
+
qk_norm_type: str = "l2",
|
| 473 |
+
learnable_fourier: bool = False,
|
| 474 |
+
):
|
| 475 |
+
super().__init__()
|
| 476 |
+
self.seq_cfg = seq_cfg
|
| 477 |
+
|
| 478 |
+
from .tokenizer import EdgeDepthSequenceBuilder
|
| 479 |
+
self.tokenizer = EdgeDepthSequenceBuilder(
|
| 480 |
+
seq_cfg,
|
| 481 |
+
label_emb_dim=label_emb_dim,
|
| 482 |
+
src_emb_dim=src_emb_dim,
|
| 483 |
+
behind_emb_dim=behind_emb_dim,
|
| 484 |
+
fourier_seed=fourier_seed,
|
| 485 |
+
use_vote_features=use_vote_features,
|
| 486 |
+
learnable_fourier=learnable_fourier,
|
| 487 |
+
)
|
| 488 |
+
|
| 489 |
+
if arch == "transformer":
|
| 490 |
+
raise ValueError(
|
| 491 |
+
"arch='transformer' is no longer supported. "
|
| 492 |
+
"TransformerSegments has been removed; use arch='perceiver'.")
|
| 493 |
+
else:
|
| 494 |
+
self.segmenter = TokenTransformerSegments(
|
| 495 |
+
segments=segments,
|
| 496 |
+
in_dim=self.tokenizer.out_dim,
|
| 497 |
+
hidden=hidden,
|
| 498 |
+
num_heads=num_heads,
|
| 499 |
+
kv_heads_cross=kv_heads_cross,
|
| 500 |
+
kv_heads_self=kv_heads_self,
|
| 501 |
+
dim_feedforward=dim_feedforward,
|
| 502 |
+
dropout=dropout,
|
| 503 |
+
latent_tokens=latent_tokens,
|
| 504 |
+
latent_layers=latent_layers,
|
| 505 |
+
decoder_layers=decoder_layers,
|
| 506 |
+
cross_attn_interval=cross_attn_interval,
|
| 507 |
+
norm_class=norm_class,
|
| 508 |
+
activation=activation,
|
| 509 |
+
segment_conf=segment_conf,
|
| 510 |
+
pre_encoder_layers=pre_encoder_layers,
|
| 511 |
+
segment_param=segment_param,
|
| 512 |
+
length_floor=length_floor,
|
| 513 |
+
decoder_input_xattn=decoder_input_xattn,
|
| 514 |
+
qk_norm=qk_norm, qk_norm_type=qk_norm_type,
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
def forward_tokens(self, tokens: torch.Tensor, mask: torch.Tensor):
|
| 518 |
+
"""Run the segmenter on pre-built token tensors."""
|
| 519 |
+
return self.segmenter(tokens, mask)
|
s23dr_2026_example/point_fusion.py
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
point_fusion.py
|
| 3 |
+
|
| 4 |
+
Simplified semantic point fusion for the 2026 dataset format.
|
| 5 |
+
|
| 6 |
+
Takes per-view (ADE segmap, Gestalt segmap, depth) + sparse COLMAP point cloud
|
| 7 |
+
from the usm3d/hoho22k_2026_trainval dataset and builds a compact, house-centric
|
| 8 |
+
semantic point representation suitable for downstream wireframe prediction.
|
| 9 |
+
|
| 10 |
+
Key differences from the 2025 pipeline:
|
| 11 |
+
- COLMAP is a ZIP of text files (cameras.txt, images.txt, points3D.txt)
|
| 12 |
+
- Depth is millimeter I;16 PNG (depth_scale=0.001 converts to meters)
|
| 13 |
+
- Views flagged with pose_only_in_colmap=True have zeroed K/R/t and must be
|
| 14 |
+
skipped for depth unprojection and projection
|
| 15 |
+
- Images arrive as PIL Images, not byte arrays
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import zipfile
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from io import BytesIO
|
| 23 |
+
from typing import Dict, List, Optional, Tuple
|
| 24 |
+
|
| 25 |
+
import cv2
|
| 26 |
+
import numpy as np
|
| 27 |
+
from scipy.stats import mode as scipy_mode
|
| 28 |
+
|
| 29 |
+
from .color_mappings import ade20k_color_mapping, gestalt_color_mapping
|
| 30 |
+
|
| 31 |
+
# ---------------------------------------------------------------------------
|
| 32 |
+
# Color packing helpers
|
| 33 |
+
# ---------------------------------------------------------------------------
|
| 34 |
+
|
| 35 |
+
def _pack_rgb_u32(rgb: np.ndarray) -> np.ndarray:
|
| 36 |
+
"""Pack uint8 RGB (..., 3) into uint32 codes."""
|
| 37 |
+
rgb = rgb.astype(np.uint32, copy=False)
|
| 38 |
+
return (rgb[..., 0] << 16) | (rgb[..., 1] << 8) | rgb[..., 2]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _build_rgbcode_maps(color_mapping):
|
| 42 |
+
"""Return (rgbcode_to_id, id_to_name) for a color mapping dict."""
|
| 43 |
+
names = list(color_mapping.keys())
|
| 44 |
+
rgbs = np.array([color_mapping[n] for n in names], dtype=np.uint8)
|
| 45 |
+
codes = _pack_rgb_u32(rgbs.reshape(-1, 1, 3)).reshape(-1)
|
| 46 |
+
rgbcode_to_id = {int(c): i for i, c in enumerate(codes)}
|
| 47 |
+
return rgbcode_to_id, names
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _name_to_packed_rgb(name, mapping):
|
| 51 |
+
"""Case-insensitive lookup returning a packed RGB code, or None."""
|
| 52 |
+
for key in mapping:
|
| 53 |
+
if key.lower() == name.lower():
|
| 54 |
+
rgb = np.array(mapping[key], np.uint8).reshape(1, 1, 3)
|
| 55 |
+
return int(_pack_rgb_u32(rgb).reshape(()))
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
# ---------------------------------------------------------------------------
|
| 59 |
+
# Label mapping constants
|
| 60 |
+
# ---------------------------------------------------------------------------
|
| 61 |
+
|
| 62 |
+
ADE_RGBCODE_TO_ID, ADE_ID_TO_NAME = _build_rgbcode_maps(ade20k_color_mapping)
|
| 63 |
+
GEST_RGBCODE_TO_ID, GEST_ID_TO_NAME = _build_rgbcode_maps(gestalt_color_mapping)
|
| 64 |
+
NUM_ADE = len(ADE_ID_TO_NAME)
|
| 65 |
+
NUM_GEST = len(GEST_ID_TO_NAME)
|
| 66 |
+
|
| 67 |
+
GEST_INVALID_NAMES = ("unclassified", "unknown", "transition_line")
|
| 68 |
+
GEST_INVALID_CODES = set(
|
| 69 |
+
int(_pack_rgb_u32(np.array(gestalt_color_mapping[n], np.uint8).reshape(1, 1, 3)).reshape(()))
|
| 70 |
+
for n in GEST_INVALID_NAMES if n in gestalt_color_mapping
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# ADE classes whose surfaces are "see-through" for label fusion: when a point
|
| 74 |
+
# projects onto one of these, we use the Gestalt label behind it instead.
|
| 75 |
+
ADE_TRANSPARENT_NAMES = (
|
| 76 |
+
"wall", "building;edifice", "floor;flooring", "ceiling",
|
| 77 |
+
"windowpane;window", "door;double;door", "house", "skyscraper",
|
| 78 |
+
"screen;door;screen", "blind;screen", "hovel;hut;hutch;shack;shanty",
|
| 79 |
+
"tower", "booth;cubicle;stall;kiosk",
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
# ADE classes kept as "occluders/add-ons" when overlapping the house silhouette.
|
| 83 |
+
ADE_OCCLUDER_ALLOWLIST_NAMES = (
|
| 84 |
+
"tree", "person;individual;someone;somebody;mortal;soul",
|
| 85 |
+
"car;auto;automobile;machine;motorcar", "truck;motortruck", "van",
|
| 86 |
+
"fence;fencing", "railing;rail",
|
| 87 |
+
"bannister;banister;balustrade;balusters;handrail",
|
| 88 |
+
"stairs;steps", "stairway;staircase", "step;stair", "pole",
|
| 89 |
+
"streetlight;street;lamp", "signboard;sign", "awning;sunshade;sunblind",
|
| 90 |
+
"plant;flora;plant;life", "pot;flowerpot",
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Precomputed arrays for the default name lists (avoids re-lookup every call).
|
| 94 |
+
_DEFAULT_ADE_TRANSPARENT_CODES = np.array(
|
| 95 |
+
[c for n in ADE_TRANSPARENT_NAMES
|
| 96 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None],
|
| 97 |
+
dtype=np.uint32,
|
| 98 |
+
)
|
| 99 |
+
_DEFAULT_ADE_OCCLUDER_IDS = np.array(
|
| 100 |
+
sorted({ADE_RGBCODE_TO_ID[c]
|
| 101 |
+
for n in ADE_OCCLUDER_ALLOWLIST_NAMES
|
| 102 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None
|
| 103 |
+
and c in ADE_RGBCODE_TO_ID}),
|
| 104 |
+
dtype=np.int32,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# ---------------------------------------------------------------------------
|
| 108 |
+
# Config
|
| 109 |
+
# ---------------------------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
@dataclass(frozen=True)
|
| 112 |
+
class FuserConfig:
|
| 113 |
+
"""Simplified fusion configuration (no depth calibration fields)."""
|
| 114 |
+
depth_points_per_view: int = 20_000 # depth samples per view
|
| 115 |
+
depth_scale: float = 0.001 # mm -> meters
|
| 116 |
+
depth_clip_percentile: float = 99.5 # drop extreme outliers
|
| 117 |
+
house_mask_dilate_px: int = 5 # dilate gestalt mask
|
| 118 |
+
min_support_views: int = 1 # min views for a kept point
|
| 119 |
+
ade_transparent_classes: Tuple[str, ...] = ADE_TRANSPARENT_NAMES
|
| 120 |
+
ade_occluder_allowlist: Tuple[str, ...] = ADE_OCCLUDER_ALLOWLIST_NAMES
|
| 121 |
+
|
| 122 |
+
# ---------------------------------------------------------------------------
|
| 123 |
+
# Geometry: projection + depth unprojection
|
| 124 |
+
# ---------------------------------------------------------------------------
|
| 125 |
+
|
| 126 |
+
def project_world_points(points_world, K, R, t):
|
| 127 |
+
"""Project (N,3) world points to pixel (u,v) with validity mask."""
|
| 128 |
+
pts = points_world.astype(np.float32, copy=False)
|
| 129 |
+
cam = (R @ pts.T + t).T # (N, 3)
|
| 130 |
+
z = cam[:, 2]
|
| 131 |
+
valid = z > 1e-6
|
| 132 |
+
inv_z = np.zeros_like(z)
|
| 133 |
+
inv_z[valid] = 1.0 / z[valid]
|
| 134 |
+
x = cam[:, 0] * inv_z
|
| 135 |
+
y = cam[:, 1] * inv_z
|
| 136 |
+
u = K[0, 0] * x + K[0, 2]
|
| 137 |
+
v = K[1, 1] * y + K[1, 2]
|
| 138 |
+
return u, v, valid
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def unproject_depth_to_world(depth, K, R, t, num_points, sample_mask=None, rng=None):
|
| 142 |
+
"""Convert a depth map + camera params to (M, 3) world points, M <= num_points."""
|
| 143 |
+
if rng is None:
|
| 144 |
+
rng = np.random.default_rng()
|
| 145 |
+
d = np.asarray(depth, dtype=np.float32)
|
| 146 |
+
if d.ndim != 2:
|
| 147 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 148 |
+
|
| 149 |
+
valid = np.isfinite(d) & (d > 1e-6)
|
| 150 |
+
if sample_mask is not None:
|
| 151 |
+
mask = np.asarray(sample_mask, dtype=bool)
|
| 152 |
+
if mask.shape != d.shape:
|
| 153 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 154 |
+
valid &= mask
|
| 155 |
+
|
| 156 |
+
ys, xs = np.where(valid)
|
| 157 |
+
if ys.size == 0:
|
| 158 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 159 |
+
|
| 160 |
+
idx = rng.choice(ys.size, size=min(num_points, ys.size), replace=False)
|
| 161 |
+
y = ys[idx].astype(np.float32)
|
| 162 |
+
x = xs[idx].astype(np.float32)
|
| 163 |
+
z = d[ys[idx], xs[idx]].astype(np.float32)
|
| 164 |
+
|
| 165 |
+
fx, fy, cx, cy = K[0, 0], K[1, 1], K[0, 2], K[1, 2]
|
| 166 |
+
cam_pts = np.stack([(x - cx) * z / fx, (y - cy) * z / fy, z], axis=0)
|
| 167 |
+
# cam = R * world + t => world = R^T * (cam - t)
|
| 168 |
+
world = (R.T @ (cam_pts - t)).T
|
| 169 |
+
return world.astype(np.float32, copy=False)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def clean_depth(depth, clip_percentile):
|
| 173 |
+
"""Clip extreme depth values."""
|
| 174 |
+
d = np.asarray(depth, dtype=np.float32)
|
| 175 |
+
d = np.where(np.isfinite(d), d, 0.0)
|
| 176 |
+
d[d <= 0] = 0.0
|
| 177 |
+
if clip_percentile is not None and clip_percentile > 0 and np.any(d > 0):
|
| 178 |
+
hi = float(np.percentile(d[d > 0], clip_percentile))
|
| 179 |
+
d = np.clip(d, 0.0, hi)
|
| 180 |
+
return d
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
def dilate_mask(mask, radius_px):
|
| 184 |
+
"""Binary dilation via cv2. mask: (H, W) bool."""
|
| 185 |
+
if radius_px <= 0:
|
| 186 |
+
return mask
|
| 187 |
+
k = 2 * radius_px + 1
|
| 188 |
+
kernel = np.ones((k, k), np.uint8)
|
| 189 |
+
return cv2.dilate(mask.astype(np.uint8), kernel) > 0
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# COLMAP extraction (2026 format)
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
def extract_colmap_points_2026(sample):
|
| 196 |
+
"""Extract (N, 3) float32 COLMAP world points from a 2026-format sample.
|
| 197 |
+
|
| 198 |
+
sample['colmap'] must be a ZIP archive containing points3D.txt.
|
| 199 |
+
Fails fast if that file is missing (it is always present in the 2026 format).
|
| 200 |
+
"""
|
| 201 |
+
colmap_blob = sample.get("colmap")
|
| 202 |
+
if colmap_blob is None:
|
| 203 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 204 |
+
if not isinstance(colmap_blob, (bytes, bytearray, memoryview)):
|
| 205 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 206 |
+
|
| 207 |
+
try:
|
| 208 |
+
with zipfile.ZipFile(BytesIO(colmap_blob)) as zf:
|
| 209 |
+
if "points3D.txt" not in set(zf.namelist()):
|
| 210 |
+
raise FileNotFoundError(
|
| 211 |
+
"COLMAP ZIP is missing points3D.txt -- "
|
| 212 |
+
"this is required in the 2026 dataset format")
|
| 213 |
+
with zf.open("points3D.txt") as f:
|
| 214 |
+
text = f.read().decode("utf-8", errors="ignore")
|
| 215 |
+
# Format: POINT3D_ID X Y Z R G B ERROR TRACK[]
|
| 216 |
+
# Filter comment/blank lines, parse columns 1-3 (X,Y,Z)
|
| 217 |
+
from io import StringIO
|
| 218 |
+
clean = "\n".join(l for l in text.split("\n") if l and not l.startswith("#"))
|
| 219 |
+
if not clean:
|
| 220 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 221 |
+
return np.loadtxt(StringIO(clean), dtype=np.float32, usecols=(1, 2, 3))
|
| 222 |
+
except zipfile.BadZipFile:
|
| 223 |
+
pass
|
| 224 |
+
return np.zeros((0, 3), dtype=np.float32)
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# Label helpers
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
def _codes_from_image(img):
|
| 231 |
+
"""Convert a PIL Image or numpy array to a (H, W) uint32 packed-RGB map."""
|
| 232 |
+
arr = np.asarray(img)
|
| 233 |
+
if arr.ndim == 2:
|
| 234 |
+
arr = np.stack([arr, arr, arr], axis=-1)
|
| 235 |
+
arr = arr[..., :3]
|
| 236 |
+
if arr.dtype != np.uint8:
|
| 237 |
+
arr = np.clip(arr, 0, 255).astype(np.uint8)
|
| 238 |
+
return _pack_rgb_u32(arr)
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def _row_majority(values):
|
| 242 |
+
"""Row-wise majority vote on (P, V) int array; -1 means "no vote".
|
| 243 |
+
Returns (P,) with the most frequent non-negative value per row, or -1.
|
| 244 |
+
|
| 245 |
+
Masks -1 entries before voting so that abstentions don't outvote
|
| 246 |
+
actual labels (which happens when a point is visible in only 1-2 views).
|
| 247 |
+
"""
|
| 248 |
+
P, V = values.shape
|
| 249 |
+
result = np.full(P, -1, dtype=values.dtype)
|
| 250 |
+
|
| 251 |
+
# For each row, find the most frequent non-negative value.
|
| 252 |
+
# Vectorized approach: flatten valid entries per row using argmax on counts.
|
| 253 |
+
# Since values are typically small non-negative ints (0-200), we can use
|
| 254 |
+
# a simple max-of-first-valid approach for speed when V is small.
|
| 255 |
+
for vi in range(V):
|
| 256 |
+
# For rows still unset, take the first valid vote
|
| 257 |
+
col = values[:, vi]
|
| 258 |
+
unset = result == -1
|
| 259 |
+
has_val = col >= 0
|
| 260 |
+
update = unset & has_val
|
| 261 |
+
result[update] = col[update]
|
| 262 |
+
|
| 263 |
+
# Now refine: if a row has multiple different valid votes, pick the mode.
|
| 264 |
+
# Check if any row has conflicting votes across views.
|
| 265 |
+
has_any = np.any(values >= 0, axis=1)
|
| 266 |
+
n_valid = np.sum(values >= 0, axis=1)
|
| 267 |
+
needs_vote = has_any & (n_valid > 1)
|
| 268 |
+
|
| 269 |
+
if np.any(needs_vote):
|
| 270 |
+
for i in np.where(needs_vote)[0]:
|
| 271 |
+
valid = values[i][values[i] >= 0]
|
| 272 |
+
# Use numpy bincount for speed (values are small non-neg ints)
|
| 273 |
+
counts = np.bincount(valid.astype(np.intp))
|
| 274 |
+
result[i] = counts.argmax()
|
| 275 |
+
|
| 276 |
+
return result
|
| 277 |
+
|
| 278 |
+
# ---------------------------------------------------------------------------
|
| 279 |
+
# Semantic fusion: house-centric, occluder-aware
|
| 280 |
+
# ---------------------------------------------------------------------------
|
| 281 |
+
|
| 282 |
+
def _fuse_labels_for_points(
|
| 283 |
+
points_world, Ks, Rs, ts, ade_images, gestalt_images,
|
| 284 |
+
ade_transparent_codes, ade_occluder_allowed_ids,
|
| 285 |
+
min_support_views, valid_view_mask=None,
|
| 286 |
+
):
|
| 287 |
+
"""Multi-view semantic label fusion with majority voting.
|
| 288 |
+
|
| 289 |
+
For each 3D point, project into every valid view:
|
| 290 |
+
- ADE "envelope" class -> use the Gestalt label behind it.
|
| 291 |
+
- ADE non-envelope -> keep if on the occluder allowlist.
|
| 292 |
+
Then majority-vote across views.
|
| 293 |
+
|
| 294 |
+
Returns dict: keep, visible_src, visible_id, behind_gest_id, support
|
| 295 |
+
"""
|
| 296 |
+
P = points_world.shape[0]
|
| 297 |
+
V = min(len(Ks), len(Rs), len(ts), len(ade_images), len(gestalt_images))
|
| 298 |
+
empty = {
|
| 299 |
+
"keep": np.zeros(P, dtype=bool),
|
| 300 |
+
"visible_src": np.zeros(P, np.uint8),
|
| 301 |
+
"visible_id": np.full(P, -1, np.int16),
|
| 302 |
+
"behind_gest_id": np.full(P, -1, np.int16),
|
| 303 |
+
"support": np.zeros(P, np.uint8),
|
| 304 |
+
}
|
| 305 |
+
if P == 0 or V == 0:
|
| 306 |
+
return empty
|
| 307 |
+
|
| 308 |
+
# Per-view labels. src: 1=gestalt, 2=ade; -1 = no contribution.
|
| 309 |
+
visible_src_pv = np.full((P, V), -1, dtype=np.int8)
|
| 310 |
+
visible_id_pv = np.full((P, V), -1, dtype=np.int32)
|
| 311 |
+
behind_id_pv = np.full((P, V), -1, dtype=np.int32)
|
| 312 |
+
support = np.zeros(P, dtype=np.int32)
|
| 313 |
+
|
| 314 |
+
ade_allowed_set = set(ade_occluder_allowed_ids.tolist())
|
| 315 |
+
ade_transparent_u32 = ade_transparent_codes.astype(np.uint32, copy=False)
|
| 316 |
+
gest_invalid_arr = np.array(list(GEST_INVALID_CODES), dtype=np.uint32)
|
| 317 |
+
|
| 318 |
+
for vi in range(V):
|
| 319 |
+
if valid_view_mask is not None and not valid_view_mask[vi]:
|
| 320 |
+
continue
|
| 321 |
+
|
| 322 |
+
K = np.asarray(Ks[vi], np.float32)
|
| 323 |
+
R = np.asarray(Rs[vi], np.float32)
|
| 324 |
+
t = np.asarray(ts[vi], np.float32).reshape(3, 1)
|
| 325 |
+
|
| 326 |
+
ade_codes_img = _codes_from_image(ade_images[vi])
|
| 327 |
+
gest_codes_img = _codes_from_image(gestalt_images[vi])
|
| 328 |
+
H, W = ade_codes_img.shape
|
| 329 |
+
|
| 330 |
+
u, v, valid = project_world_points(points_world, K, R, t)
|
| 331 |
+
in_img = valid & (u >= 0) & (u < W) & (v >= 0) & (v < H)
|
| 332 |
+
if not np.any(in_img):
|
| 333 |
+
continue
|
| 334 |
+
|
| 335 |
+
ui = np.clip(np.round(u[in_img]).astype(np.int32), 0, W - 1)
|
| 336 |
+
vi_pix = np.clip(np.round(v[in_img]).astype(np.int32), 0, H - 1)
|
| 337 |
+
ade_codes = ade_codes_img[vi_pix, ui]
|
| 338 |
+
gest_codes = gest_codes_img[vi_pix, ui]
|
| 339 |
+
|
| 340 |
+
in_house = ~np.isin(gest_codes, gest_invalid_arr)
|
| 341 |
+
if not np.any(in_house):
|
| 342 |
+
continue
|
| 343 |
+
|
| 344 |
+
idx = np.where(in_img)[0][in_house]
|
| 345 |
+
ade_codes_h = ade_codes[in_house]
|
| 346 |
+
gest_codes_h = gest_codes[in_house]
|
| 347 |
+
|
| 348 |
+
behind_local = np.array(
|
| 349 |
+
[GEST_RGBCODE_TO_ID.get(int(c), -1) for c in gest_codes_h],
|
| 350 |
+
dtype=np.int32)
|
| 351 |
+
behind_id_pv[idx, vi] = behind_local
|
| 352 |
+
|
| 353 |
+
ade_is_transparent = np.isin(ade_codes_h, ade_transparent_u32)
|
| 354 |
+
|
| 355 |
+
# Case A: ADE is envelope -- use Gestalt label.
|
| 356 |
+
mask_a = ade_is_transparent & (behind_local >= 0)
|
| 357 |
+
if np.any(mask_a):
|
| 358 |
+
visible_src_pv[idx[mask_a], vi] = 1
|
| 359 |
+
visible_id_pv[idx[mask_a], vi] = behind_local[mask_a]
|
| 360 |
+
|
| 361 |
+
# Case B: ADE is non-envelope -- use ADE label (allowlist-filtered).
|
| 362 |
+
mask_b = ~ade_is_transparent
|
| 363 |
+
if np.any(mask_b):
|
| 364 |
+
ade_local = np.array(
|
| 365 |
+
[ADE_RGBCODE_TO_ID.get(int(c), -1) for c in ade_codes_h[mask_b]],
|
| 366 |
+
dtype=np.int32)
|
| 367 |
+
on_allowlist = np.array(
|
| 368 |
+
[int(a) in ade_allowed_set for a in ade_local], dtype=bool
|
| 369 |
+
) & (ade_local >= 0)
|
| 370 |
+
if np.any(on_allowlist):
|
| 371 |
+
visible_src_pv[idx[mask_b][on_allowlist], vi] = 2
|
| 372 |
+
visible_id_pv[idx[mask_b][on_allowlist], vi] = ade_local[on_allowlist]
|
| 373 |
+
|
| 374 |
+
support[idx] += 1
|
| 375 |
+
|
| 376 |
+
# ---- Aggregate across views via majority vote ----
|
| 377 |
+
keep = (support >= min_support_views) & np.any(visible_src_pv >= 0, axis=1)
|
| 378 |
+
|
| 379 |
+
# Combine (src, id) into a single key for voting, then split back.
|
| 380 |
+
# src in {1,2} and id in [0, ~150], so stride=100k avoids collisions.
|
| 381 |
+
VIS_STRIDE = 100_000
|
| 382 |
+
vis_key = np.where(
|
| 383 |
+
visible_src_pv >= 0,
|
| 384 |
+
visible_src_pv.astype(np.int64) * VIS_STRIDE + visible_id_pv.astype(np.int64),
|
| 385 |
+
-1)
|
| 386 |
+
voted_key = _row_majority(vis_key)
|
| 387 |
+
voted_behind = _row_majority(behind_id_pv)
|
| 388 |
+
|
| 389 |
+
final_src = np.zeros(P, dtype=np.uint8)
|
| 390 |
+
final_id = np.full(P, -1, dtype=np.int16)
|
| 391 |
+
ok = voted_key >= 0
|
| 392 |
+
if np.any(ok):
|
| 393 |
+
final_src[ok] = (voted_key[ok] // VIS_STRIDE).astype(np.uint8)
|
| 394 |
+
final_id[ok] = (voted_key[ok] % VIS_STRIDE).astype(np.int16)
|
| 395 |
+
|
| 396 |
+
# ---- Vote confidence metadata ----
|
| 397 |
+
n_views_voted = np.sum(visible_src_pv >= 0, axis=1).astype(np.uint8)
|
| 398 |
+
|
| 399 |
+
# Fraction of voting views that agreed with the majority label
|
| 400 |
+
vote_frac = np.zeros(P, dtype=np.float32)
|
| 401 |
+
if np.any(ok):
|
| 402 |
+
for i in np.where(ok)[0]:
|
| 403 |
+
votes = vis_key[i][vis_key[i] >= 0]
|
| 404 |
+
if len(votes) > 0:
|
| 405 |
+
vote_frac[i] = (votes == voted_key[i]).sum() / len(votes)
|
| 406 |
+
|
| 407 |
+
return {
|
| 408 |
+
"keep": keep,
|
| 409 |
+
"visible_src": final_src,
|
| 410 |
+
"visible_id": final_id,
|
| 411 |
+
"behind_gest_id": voted_behind.astype(np.int16),
|
| 412 |
+
"support": support.astype(np.uint8),
|
| 413 |
+
"n_views_voted": n_views_voted,
|
| 414 |
+
"vote_frac": vote_frac,
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
# ---------------------------------------------------------------------------
|
| 418 |
+
# Compact scene builder (2026 dataset format)
|
| 419 |
+
# ---------------------------------------------------------------------------
|
| 420 |
+
|
| 421 |
+
def _resolve_ade_codes(cfg):
|
| 422 |
+
"""Return (transparent_codes, occluder_ids) for the given config.
|
| 423 |
+
Uses precomputed module-level arrays when the config has default names.
|
| 424 |
+
"""
|
| 425 |
+
if cfg.ade_transparent_classes == ADE_TRANSPARENT_NAMES:
|
| 426 |
+
transparent = _DEFAULT_ADE_TRANSPARENT_CODES
|
| 427 |
+
else:
|
| 428 |
+
transparent = np.array(
|
| 429 |
+
[c for n in cfg.ade_transparent_classes
|
| 430 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None],
|
| 431 |
+
dtype=np.uint32)
|
| 432 |
+
|
| 433 |
+
if cfg.ade_occluder_allowlist == ADE_OCCLUDER_ALLOWLIST_NAMES:
|
| 434 |
+
occluder_ids = _DEFAULT_ADE_OCCLUDER_IDS
|
| 435 |
+
else:
|
| 436 |
+
occluder_ids = np.array(
|
| 437 |
+
sorted({ADE_RGBCODE_TO_ID[c]
|
| 438 |
+
for n in cfg.ade_occluder_allowlist
|
| 439 |
+
if (c := _name_to_packed_rgb(n, ade20k_color_mapping)) is not None
|
| 440 |
+
and c in ADE_RGBCODE_TO_ID}),
|
| 441 |
+
dtype=np.int32)
|
| 442 |
+
return transparent, occluder_ids
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _parse_gt_array(sample, key, dtype, expected_cols):
|
| 446 |
+
"""Parse an optional ground-truth array from the sample dict."""
|
| 447 |
+
raw = sample.get(key)
|
| 448 |
+
if raw is None:
|
| 449 |
+
return None
|
| 450 |
+
arr = np.asarray(raw, dtype=dtype)
|
| 451 |
+
if arr.ndim == 2 and arr.shape[1] == expected_cols:
|
| 452 |
+
return arr
|
| 453 |
+
return None
|
| 454 |
+
|
| 455 |
+
|
| 456 |
+
def build_compact_scene(sample, cfg, rng):
|
| 457 |
+
"""Build a compact semantic point representation from a HuggingFace sample.
|
| 458 |
+
|
| 459 |
+
Expected sample keys: K, R, t, ade, gestalt, depth, colmap,
|
| 460 |
+
pose_only_in_colmap, wf_vertices (opt), wf_edges (opt), __key__ (opt).
|
| 461 |
+
|
| 462 |
+
Returns dict (xyz, source, visible_src, visible_id, behind_gest_id,
|
| 463 |
+
gt_vertices, gt_edges, sample_id) or None if no points survive fusion.
|
| 464 |
+
"""
|
| 465 |
+
Ks = sample.get("K") or []
|
| 466 |
+
Rs = sample.get("R") or []
|
| 467 |
+
ts = sample.get("t") or []
|
| 468 |
+
ade_imgs = sample.get("ade") or []
|
| 469 |
+
gest_imgs = sample.get("gestalt") or []
|
| 470 |
+
depths = sample.get("depth") or []
|
| 471 |
+
pose_flags = sample.get("pose_only_in_colmap") or []
|
| 472 |
+
|
| 473 |
+
V = min(len(Ks), len(Rs), len(ts), len(ade_imgs), len(gest_imgs))
|
| 474 |
+
if V == 0:
|
| 475 |
+
return None
|
| 476 |
+
|
| 477 |
+
valid_view = [not (vi < len(pose_flags) and pose_flags[vi]) for vi in range(V)]
|
| 478 |
+
if not any(valid_view):
|
| 479 |
+
return None
|
| 480 |
+
|
| 481 |
+
# ---- COLMAP points ----
|
| 482 |
+
colmap_pts = extract_colmap_points_2026(sample)
|
| 483 |
+
|
| 484 |
+
# ---- Precompute house masks (from Gestalt), optionally dilated ----
|
| 485 |
+
gest_invalid_arr = np.array(list(GEST_INVALID_CODES), dtype=np.uint32)
|
| 486 |
+
house_masks = []
|
| 487 |
+
for vi in range(V):
|
| 488 |
+
if not valid_view[vi]:
|
| 489 |
+
house_masks.append(None)
|
| 490 |
+
continue
|
| 491 |
+
mask = ~np.isin(_codes_from_image(gest_imgs[vi]), gest_invalid_arr)
|
| 492 |
+
if cfg.house_mask_dilate_px > 0:
|
| 493 |
+
mask = dilate_mask(mask, cfg.house_mask_dilate_px)
|
| 494 |
+
house_masks.append(mask)
|
| 495 |
+
|
| 496 |
+
# ---- Sample depth points per view ----
|
| 497 |
+
depth_points_all = []
|
| 498 |
+
for vi in range(min(V, len(depths))):
|
| 499 |
+
if not valid_view[vi] or depths[vi] is None:
|
| 500 |
+
continue
|
| 501 |
+
d = clean_depth(
|
| 502 |
+
np.asarray(depths[vi], dtype=np.float32) * cfg.depth_scale,
|
| 503 |
+
cfg.depth_clip_percentile)
|
| 504 |
+
pts = unproject_depth_to_world(
|
| 505 |
+
depth=d,
|
| 506 |
+
K=np.asarray(Ks[vi], np.float32),
|
| 507 |
+
R=np.asarray(Rs[vi], np.float32),
|
| 508 |
+
t=np.asarray(ts[vi], np.float32).reshape(3, 1),
|
| 509 |
+
num_points=cfg.depth_points_per_view,
|
| 510 |
+
sample_mask=house_masks[vi], rng=rng)
|
| 511 |
+
if pts.shape[0]:
|
| 512 |
+
depth_points_all.append(pts)
|
| 513 |
+
|
| 514 |
+
# ---- Combine COLMAP + depth points ----
|
| 515 |
+
pts_list, src_list = [], []
|
| 516 |
+
if colmap_pts.shape[0]:
|
| 517 |
+
pts_list.append(colmap_pts)
|
| 518 |
+
src_list.append(np.zeros(colmap_pts.shape[0], dtype=np.uint8)) # 0=colmap
|
| 519 |
+
if depth_points_all:
|
| 520 |
+
all_depth = np.concatenate(depth_points_all, axis=0)
|
| 521 |
+
pts_list.append(all_depth)
|
| 522 |
+
src_list.append(np.ones(all_depth.shape[0], dtype=np.uint8)) # 1=depth
|
| 523 |
+
if not pts_list:
|
| 524 |
+
return None
|
| 525 |
+
|
| 526 |
+
points_world = np.concatenate(pts_list, axis=0).astype(np.float32, copy=False)
|
| 527 |
+
point_source = np.concatenate(src_list, axis=0).astype(np.uint8, copy=False)
|
| 528 |
+
|
| 529 |
+
# ---- Fuse semantic labels ----
|
| 530 |
+
ade_transparent_arr, ade_allow_ids = _resolve_ade_codes(cfg)
|
| 531 |
+
fused = _fuse_labels_for_points(
|
| 532 |
+
points_world=points_world, Ks=Ks, Rs=Rs, ts=ts,
|
| 533 |
+
ade_images=ade_imgs, gestalt_images=gest_imgs,
|
| 534 |
+
ade_transparent_codes=ade_transparent_arr,
|
| 535 |
+
ade_occluder_allowed_ids=ade_allow_ids,
|
| 536 |
+
min_support_views=cfg.min_support_views,
|
| 537 |
+
valid_view_mask=valid_view)
|
| 538 |
+
|
| 539 |
+
keep = fused["keep"]
|
| 540 |
+
if not np.any(keep):
|
| 541 |
+
return None
|
| 542 |
+
|
| 543 |
+
return {
|
| 544 |
+
"xyz": points_world[keep],
|
| 545 |
+
"source": point_source[keep], # 0=colmap, 1=monodepth
|
| 546 |
+
"visible_src": fused["visible_src"][keep], # 1=gestalt, 2=ade
|
| 547 |
+
"visible_id": fused["visible_id"][keep],
|
| 548 |
+
"behind_gest_id": fused["behind_gest_id"][keep],
|
| 549 |
+
"n_views_voted": fused["n_views_voted"][keep],
|
| 550 |
+
"vote_frac": fused["vote_frac"][keep],
|
| 551 |
+
"gt_vertices": _parse_gt_array(sample, "wf_vertices", np.float32, 3),
|
| 552 |
+
"gt_edges": _parse_gt_array(sample, "wf_edges", np.int64, 2),
|
| 553 |
+
"sample_id": sample.get("__key__", None),
|
| 554 |
+
}
|
s23dr_2026_example/postprocess_v2.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Post-processing functions for segment predictions."""
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def snap_to_point_cloud(vertices, xyz, class_id, snap_radius=0.5,
|
| 6 |
+
target_classes=None):
|
| 7 |
+
"""Snap vertices to nearby point cloud clusters of specific semantic classes."""
|
| 8 |
+
if target_classes is None:
|
| 9 |
+
target_classes = [1, 2] # apex, eave_end_point
|
| 10 |
+
|
| 11 |
+
snapped = vertices.copy()
|
| 12 |
+
mask = np.isin(class_id, target_classes)
|
| 13 |
+
|
| 14 |
+
if mask.sum() < 2:
|
| 15 |
+
return snapped
|
| 16 |
+
|
| 17 |
+
target_pts = xyz[mask]
|
| 18 |
+
|
| 19 |
+
for i, v in enumerate(vertices):
|
| 20 |
+
dists = np.linalg.norm(target_pts - v, axis=-1)
|
| 21 |
+
close = dists < snap_radius
|
| 22 |
+
if close.sum() >= 2:
|
| 23 |
+
snapped[i] = target_pts[close].mean(axis=0)
|
| 24 |
+
|
| 25 |
+
return snapped
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def snap_horizontal(vertices, edges, max_slope=0.05):
|
| 29 |
+
"""Snap near-horizontal edges to be exactly horizontal."""
|
| 30 |
+
verts = vertices.copy()
|
| 31 |
+
for a, b in edges:
|
| 32 |
+
a, b = int(a), int(b)
|
| 33 |
+
dy = abs(verts[a, 1] - verts[b, 1])
|
| 34 |
+
dxz = np.sqrt((verts[a, 0] - verts[b, 0])**2 + (verts[a, 2] - verts[b, 2])**2)
|
| 35 |
+
if dxz > 0.1 and dy / dxz < max_slope:
|
| 36 |
+
avg_y = 0.5 * (verts[a, 1] + verts[b, 1])
|
| 37 |
+
verts[a, 1] = avg_y
|
| 38 |
+
verts[b, 1] = avg_y
|
| 39 |
+
return verts
|
s23dr_2026_example/segment_postprocess.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def merge_vertices_iterative(vertices: np.ndarray, edges: np.ndarray,
|
| 7 |
+
start: float = 0.15, end: float = 0.6,
|
| 8 |
+
n_iters: int = 5):
|
| 9 |
+
"""Iterative merge: start with tight threshold, gradually widen.
|
| 10 |
+
|
| 11 |
+
Avoids the worst transitive chaining effects of a single wide threshold.
|
| 12 |
+
Each pass merges only the closest pairs first, establishing stable cluster
|
| 13 |
+
centers before wider merges pull in more distant endpoints.
|
| 14 |
+
|
| 15 |
+
+0.004 HSS / +0.007 F1 over single-pass merge(0.4) on 1024 val samples.
|
| 16 |
+
"""
|
| 17 |
+
pv, pe = vertices, edges
|
| 18 |
+
for t in np.linspace(start, end, n_iters):
|
| 19 |
+
pv, pe = merge_vertices(pv, pe, t)
|
| 20 |
+
return pv, pe
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def merge_vertices(vertices: np.ndarray, edges: np.ndarray, thresh: float):
|
| 24 |
+
verts = np.asarray(vertices, dtype=np.float32)
|
| 25 |
+
edges = np.asarray(edges, dtype=np.int64)
|
| 26 |
+
if verts.size == 0 or edges.size == 0:
|
| 27 |
+
return verts, edges
|
| 28 |
+
|
| 29 |
+
n = verts.shape[0]
|
| 30 |
+
parent = np.arange(n, dtype=np.int64)
|
| 31 |
+
|
| 32 |
+
def find(i):
|
| 33 |
+
while parent[i] != i:
|
| 34 |
+
parent[i] = parent[parent[i]]
|
| 35 |
+
i = parent[i]
|
| 36 |
+
return i
|
| 37 |
+
|
| 38 |
+
def union(i, j):
|
| 39 |
+
ri = find(i)
|
| 40 |
+
rj = find(j)
|
| 41 |
+
if ri != rj:
|
| 42 |
+
parent[rj] = ri
|
| 43 |
+
|
| 44 |
+
for i in range(n):
|
| 45 |
+
vi = verts[i]
|
| 46 |
+
for j in range(i + 1, n):
|
| 47 |
+
if np.linalg.norm(vi - verts[j]) <= thresh:
|
| 48 |
+
union(i, j)
|
| 49 |
+
|
| 50 |
+
clusters = {}
|
| 51 |
+
for i in range(n):
|
| 52 |
+
root = find(i)
|
| 53 |
+
clusters.setdefault(root, []).append(i)
|
| 54 |
+
|
| 55 |
+
new_vertices = []
|
| 56 |
+
mapping = {}
|
| 57 |
+
for new_idx, idxs in enumerate(clusters.values()):
|
| 58 |
+
pts = verts[idxs]
|
| 59 |
+
center = pts.mean(axis=0)
|
| 60 |
+
new_vertices.append(center)
|
| 61 |
+
for i in idxs:
|
| 62 |
+
mapping[i] = new_idx
|
| 63 |
+
|
| 64 |
+
new_edges = []
|
| 65 |
+
seen = set()
|
| 66 |
+
for a, b in edges:
|
| 67 |
+
na = mapping.get(int(a), int(a))
|
| 68 |
+
nb = mapping.get(int(b), int(b))
|
| 69 |
+
if na == nb:
|
| 70 |
+
continue
|
| 71 |
+
key = (na, nb) if na <= nb else (nb, na)
|
| 72 |
+
if key in seen:
|
| 73 |
+
continue
|
| 74 |
+
seen.add(key)
|
| 75 |
+
new_edges.append([na, nb])
|
| 76 |
+
|
| 77 |
+
return np.asarray(new_vertices, dtype=np.float32), np.asarray(new_edges, dtype=np.int64)
|
s23dr_2026_example/sinkhorn.py
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sinkhorn optimal transport loss for segment matching.
|
| 2 |
+
|
| 3 |
+
Note: at eps=0.05, sinkhorn gradients are near-zero (~1e-7 norm) for
|
| 4 |
+
typical matrix sizes. The loss value is tracked but does not meaningfully
|
| 5 |
+
train the model. Default sinkhorn_weight=0.0. See worklog.md for details.
|
| 6 |
+
|
| 7 |
+
Future: schedule eps from large (1.0) to small (0.05) during training
|
| 8 |
+
to get useful gradients early and precise matching late.
|
| 9 |
+
"""
|
| 10 |
+
import torch
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def batched_sinkhorn_loss(
|
| 14 |
+
pred_segments: torch.Tensor,
|
| 15 |
+
gt_pad: torch.Tensor,
|
| 16 |
+
gt_mask: torch.Tensor,
|
| 17 |
+
eps: float,
|
| 18 |
+
iters: int,
|
| 19 |
+
dustbin_cost: float | torch.Tensor,
|
| 20 |
+
pred_mass: torch.Tensor | None = None,
|
| 21 |
+
) -> torch.Tensor:
|
| 22 |
+
"""Batched sinkhorn segment matching loss.
|
| 23 |
+
|
| 24 |
+
Args:
|
| 25 |
+
pred_segments: [B, S, 2, 3] predicted segments
|
| 26 |
+
gt_pad: [B, M, 2, 3] padded GT segments
|
| 27 |
+
gt_mask: [B, M] bool mask (True = valid GT segment)
|
| 28 |
+
eps: sinkhorn regularization
|
| 29 |
+
iters: sinkhorn iterations
|
| 30 |
+
dustbin_cost: cost for unmatched segments (scalar or [B])
|
| 31 |
+
pred_mass: [B, S] per-segment mass weights (e.g. sigmoid(conf)).
|
| 32 |
+
If None, uniform masses are used.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
[B] per-sample sinkhorn transport cost
|
| 36 |
+
"""
|
| 37 |
+
B, S, _, _ = pred_segments.shape
|
| 38 |
+
M = gt_pad.shape[1]
|
| 39 |
+
|
| 40 |
+
# Allow per-sample dustbin cost
|
| 41 |
+
dc = torch.as_tensor(dustbin_cost, device=pred_segments.device, dtype=pred_segments.dtype)
|
| 42 |
+
if dc.dim() == 0:
|
| 43 |
+
dc = dc.expand(B)
|
| 44 |
+
|
| 45 |
+
# Compute cost matrices [B, S, M] in midpoint-halfvec space.
|
| 46 |
+
# Decouples position from direction: mid gradient is pure position,
|
| 47 |
+
# half gradient is pure direction/length. Sign-invariance on half
|
| 48 |
+
# handles segment direction ambiguity cleanly.
|
| 49 |
+
p0 = pred_segments[:, :, 0] # [B, S, 3]
|
| 50 |
+
p1 = pred_segments[:, :, 1] # [B, S, 3]
|
| 51 |
+
g0 = gt_pad[:, :, 0] # [B, M, 3]
|
| 52 |
+
g1 = gt_pad[:, :, 1] # [B, M, 3]
|
| 53 |
+
|
| 54 |
+
mid_pred = 0.5 * (p0 + p1) # [B, S, 3]
|
| 55 |
+
half_pred = 0.5 * (p1 - p0) # [B, S, 3]
|
| 56 |
+
mid_gt = 0.5 * (g0 + g1) # [B, M, 3]
|
| 57 |
+
half_gt = 0.5 * (g1 - g0) # [B, M, 3]
|
| 58 |
+
|
| 59 |
+
# Midpoint distance [B, S, M]
|
| 60 |
+
d_mid = torch.linalg.norm(
|
| 61 |
+
mid_pred.unsqueeze(2) - mid_gt.unsqueeze(1), dim=-1)
|
| 62 |
+
|
| 63 |
+
# Decoupled direction + length distance (sign-invariant for direction ambiguity)
|
| 64 |
+
len_pred = torch.linalg.norm(half_pred, dim=-1, keepdim=True).clamp(min=1e-6) # [B, S, 1]
|
| 65 |
+
len_gt = torch.linalg.norm(half_gt, dim=-1, keepdim=True).clamp(min=1e-6) # [B, M, 1]
|
| 66 |
+
dir_pred = half_pred / len_pred # [B, S, 3]
|
| 67 |
+
dir_gt = half_gt / len_gt # [B, M, 3]
|
| 68 |
+
|
| 69 |
+
# Direction distance: 1 - |cos(angle)|, sign-invariant [B, S, M]
|
| 70 |
+
cos_angle = (dir_pred.unsqueeze(2) * dir_gt.unsqueeze(1)).sum(dim=-1) # [B, S, M]
|
| 71 |
+
d_dir = 1.0 - cos_angle.abs()
|
| 72 |
+
|
| 73 |
+
# Length distance [B, S, M]
|
| 74 |
+
d_len = (len_pred.unsqueeze(2) - len_gt.unsqueeze(1)).squeeze(-1).abs()
|
| 75 |
+
|
| 76 |
+
cost = d_mid + d_dir + d_len # [B, S, M]
|
| 77 |
+
|
| 78 |
+
# Mask invalid GT segments with high cost so they go to dustbin
|
| 79 |
+
cost = torch.where(gt_mask.unsqueeze(1), cost, dc[:, None, None] * 10.0)
|
| 80 |
+
|
| 81 |
+
# Pad with dustbin row and column: [B, S+1, M+1]
|
| 82 |
+
cost_pad = dc[:, None, None].expand(B, S + 1, M + 1).clone()
|
| 83 |
+
cost_pad[:, :S, :M] = cost
|
| 84 |
+
cost_pad[:, -1, -1] = 0.0
|
| 85 |
+
|
| 86 |
+
# Masses
|
| 87 |
+
gt_counts = gt_mask.sum(dim=1).float() # [B]
|
| 88 |
+
|
| 89 |
+
if pred_mass is not None:
|
| 90 |
+
# Confidence-weighted masses (matches learned_v2 approach).
|
| 91 |
+
# sigmoid(conf) gives per-segment mass; dustbin masses balance the totals.
|
| 92 |
+
# No normalization -- sum(a) == sum(b) == max(sum_pred, sum_gt).
|
| 93 |
+
pm = pred_mass.clamp(min=0.0) # [B, S]
|
| 94 |
+
sum_pred = pm.sum(dim=1) # [B]
|
| 95 |
+
sum_gt = gt_counts # [B]
|
| 96 |
+
pred_dustbin = (sum_gt - sum_pred).clamp(min=0.0) # [B]
|
| 97 |
+
gt_dustbin = (sum_pred - sum_gt).clamp(min=0.0) # [B]
|
| 98 |
+
a = torch.cat([pm, pred_dustbin.unsqueeze(1)], dim=1) # [B, S+1]
|
| 99 |
+
b_val = torch.zeros(B, M + 1, device=cost.device, dtype=cost.dtype)
|
| 100 |
+
b_val[:, :M] = gt_mask.float() # 1.0 per valid GT segment
|
| 101 |
+
b_val[:, -1] = gt_dustbin
|
| 102 |
+
else:
|
| 103 |
+
# Uniform masses (normalized)
|
| 104 |
+
n = float(S)
|
| 105 |
+
denom = n + gt_counts # [B]
|
| 106 |
+
a = (1.0 / denom).unsqueeze(1).expand(B, S + 1).clone() # [B, S+1]
|
| 107 |
+
a[:, -1] = gt_counts / denom
|
| 108 |
+
b_val = (1.0 / denom).unsqueeze(1).expand(B, M + 1).clone() # [B, M+1]
|
| 109 |
+
b_val[:, -1] = n / denom
|
| 110 |
+
# Zero out mass for invalid GT
|
| 111 |
+
b_val[:, :M] = b_val[:, :M] * gt_mask.float()
|
| 112 |
+
|
| 113 |
+
# Log-domain sinkhorn
|
| 114 |
+
log_a = torch.log(a + 1e-9)
|
| 115 |
+
log_b = torch.log(b_val + 1e-9)
|
| 116 |
+
log_k = -cost_pad / eps
|
| 117 |
+
|
| 118 |
+
log_u = torch.zeros_like(a)
|
| 119 |
+
log_v = torch.zeros_like(b_val)
|
| 120 |
+
|
| 121 |
+
for _ in range(iters):
|
| 122 |
+
log_u = log_a - torch.logsumexp(log_k + log_v.unsqueeze(1), dim=2)
|
| 123 |
+
log_v = log_b - torch.logsumexp(log_k + log_u.unsqueeze(2), dim=1)
|
| 124 |
+
|
| 125 |
+
transport = torch.exp(log_u.unsqueeze(2) + log_v.unsqueeze(1) + log_k)
|
| 126 |
+
return (transport * cost_pad).sum(dim=(1, 2)) # [B]
|
s23dr_2026_example/tokenizer.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tokenizer: learned embeddings + Fourier features for the point cloud tokens.
|
| 2 |
+
|
| 3 |
+
The EdgeDepthSequenceBuilder holds the learned embedding tables (label, source,
|
| 4 |
+
behind) and the random Fourier positional encoding. At training time,
|
| 5 |
+
build_tokens() in data.py applies these to pre-sampled point indices on GPU.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Tuple
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn as nn
|
| 15 |
+
|
| 16 |
+
from .point_fusion import NUM_ADE, NUM_GEST
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
# -- Config --
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class EdgeDepthSequenceConfig:
|
| 23 |
+
seq_len: int = 2048
|
| 24 |
+
colmap_points: int = 1280
|
| 25 |
+
depth_points: int = 768
|
| 26 |
+
use_fourier: bool = True
|
| 27 |
+
fourier_dim: int = 32
|
| 28 |
+
fourier_scale: float = 10.0
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# -- Fourier positional encoding --
|
| 32 |
+
|
| 33 |
+
class FourierFeatures(nn.Module):
|
| 34 |
+
def __init__(self, in_dim: int = 3, fourier_dim: int = 64,
|
| 35 |
+
scale: float = 10.0, seed: int = 0,
|
| 36 |
+
learnable: bool = False):
|
| 37 |
+
super().__init__()
|
| 38 |
+
gen = torch.Generator()
|
| 39 |
+
gen.manual_seed(seed)
|
| 40 |
+
B = torch.randn(fourier_dim, in_dim, generator=gen) * scale
|
| 41 |
+
if learnable:
|
| 42 |
+
self.B = nn.Parameter(B)
|
| 43 |
+
else:
|
| 44 |
+
self.register_buffer("B", B, persistent=True)
|
| 45 |
+
|
| 46 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 47 |
+
proj = (2.0 * np.pi) * (x @ self.B.t())
|
| 48 |
+
return torch.cat([torch.sin(proj), torch.cos(proj)], dim=-1)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# -- Sequence builder (holds embeddings) --
|
| 52 |
+
|
| 53 |
+
class EdgeDepthSequenceBuilder(nn.Module):
|
| 54 |
+
"""Holds learned embeddings for point cloud tokenization.
|
| 55 |
+
|
| 56 |
+
Used by the model at training time: build_tokens() calls
|
| 57 |
+
self.label_emb(class_id), self.src_emb(source), etc.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(self, cfg: EdgeDepthSequenceConfig, label_emb_dim: int = 16,
|
| 61 |
+
src_emb_dim: int = 2, behind_emb_dim: int = 8,
|
| 62 |
+
fourier_seed: int = 0, use_vote_features: bool = False,
|
| 63 |
+
learnable_fourier: bool = False):
|
| 64 |
+
super().__init__()
|
| 65 |
+
self.cfg = cfg
|
| 66 |
+
|
| 67 |
+
self.num_labels = 13 # 11 structural + other_house + non_house
|
| 68 |
+
self.label_emb = nn.Embedding(self.num_labels, label_emb_dim)
|
| 69 |
+
self.src_emb = nn.Embedding(2, src_emb_dim)
|
| 70 |
+
self.behind_emb_dim = behind_emb_dim
|
| 71 |
+
if behind_emb_dim > 0:
|
| 72 |
+
self.behind_emb = nn.Embedding(NUM_GEST + 1, behind_emb_dim)
|
| 73 |
+
|
| 74 |
+
# Fourier positional encoding
|
| 75 |
+
if cfg.use_fourier:
|
| 76 |
+
self.pos_enc = FourierFeatures(
|
| 77 |
+
in_dim=3, fourier_dim=cfg.fourier_dim,
|
| 78 |
+
scale=cfg.fourier_scale, seed=fourier_seed,
|
| 79 |
+
learnable=learnable_fourier,
|
| 80 |
+
)
|
| 81 |
+
pos_dim = 3 + 2 * cfg.fourier_dim
|
| 82 |
+
else:
|
| 83 |
+
self.pos_enc = None
|
| 84 |
+
pos_dim = 3
|
| 85 |
+
|
| 86 |
+
vote_dim = 2 if use_vote_features else 0 # n_views_voted + vote_frac
|
| 87 |
+
self.use_vote_features = use_vote_features
|
| 88 |
+
self.out_dim = pos_dim + label_emb_dim + src_emb_dim + behind_emb_dim + vote_dim
|
s23dr_2026_example/train.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Training script for S23DR 2026.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python -m s23dr_2026_example.train --cache-dir hf://usm3d/s23dr-2026-sampled_2048_v2:train --steps 80000 --aug-rotate
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import sys
|
| 11 |
+
from pathlib import Path as _Path
|
| 12 |
+
if __package__ is None or __package__ == "":
|
| 13 |
+
_here = _Path(__file__).resolve().parent
|
| 14 |
+
if str(_here.parent) not in sys.path:
|
| 15 |
+
sys.path.insert(0, str(_here.parent))
|
| 16 |
+
__package__ = _here.name
|
| 17 |
+
|
| 18 |
+
import argparse
|
| 19 |
+
import gc
|
| 20 |
+
import json
|
| 21 |
+
import math
|
| 22 |
+
import subprocess
|
| 23 |
+
import time
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
|
| 26 |
+
import numpy as np
|
| 27 |
+
import torch
|
| 28 |
+
|
| 29 |
+
from .tokenizer import EdgeDepthSequenceConfig
|
| 30 |
+
from .model import EdgeDepthSegmentsModel
|
| 31 |
+
from .data import build_loader, build_tokens
|
| 32 |
+
from .losses import compute_loss, _loss_inner
|
| 33 |
+
|
| 34 |
+
# Re-export for eval scripts
|
| 35 |
+
from .data import HFCachedDataset, collate as _collate # noqa: F401
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Main
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
def main():
|
| 43 |
+
p = argparse.ArgumentParser(description="S23DR 2026 training")
|
| 44 |
+
p.add_argument("--cache-dir", default=None, help="HF dataset path (hf://repo:split)")
|
| 45 |
+
p.add_argument("--val-cache-dir", default="", help="Separate cache for validation")
|
| 46 |
+
p.add_argument("--seq-len", type=int, default=2048,
|
| 47 |
+
help="Input sequence length (2048 or 4096, must match dataset)")
|
| 48 |
+
p.add_argument("--arch", choices=["perceiver", "transformer"], default="perceiver",
|
| 49 |
+
help="perceiver=latent bottleneck, transformer=full self-attention encoder")
|
| 50 |
+
p.add_argument("--segments", type=int, default=32)
|
| 51 |
+
p.add_argument("--hidden", type=int, default=128)
|
| 52 |
+
p.add_argument("--ff", type=int, default=512)
|
| 53 |
+
p.add_argument("--latent-tokens", type=int, default=128)
|
| 54 |
+
p.add_argument("--latent-layers", type=int, default=7)
|
| 55 |
+
p.add_argument("--encoder-layers", type=int, default=4,
|
| 56 |
+
help="Encoder layers (transformer arch only)")
|
| 57 |
+
p.add_argument("--pre-encoder-layers", type=int, default=0,
|
| 58 |
+
help="Self-attn layers on full token sequence before perceiver bottleneck")
|
| 59 |
+
p.add_argument("--decoder-layers", type=int, default=3)
|
| 60 |
+
p.add_argument("--decoder-input-xattn", action="store_true",
|
| 61 |
+
help="Add cross-attention from segment queries to input tokens in each decoder layer")
|
| 62 |
+
p.add_argument("--qk-norm", action="store_true",
|
| 63 |
+
help="Normalize Q and K per-head with learned temperature (stabilizes wide models)")
|
| 64 |
+
p.add_argument("--qk-norm-type", choices=["l2", "rms"], default="l2",
|
| 65 |
+
help="QK-norm type: l2 (unit sphere) or rms (RMSNorm, preserves magnitudes)")
|
| 66 |
+
p.add_argument("--learnable-fourier", action="store_true",
|
| 67 |
+
help="Make Fourier positional encoding learnable (vs fixed random)")
|
| 68 |
+
p.add_argument("--num-heads", type=int, default=4, help="Attention heads")
|
| 69 |
+
p.add_argument("--kv-heads-cross", type=int, default=2,
|
| 70 |
+
help="KV heads for cross-attention (GQA; 0 = standard MHA)")
|
| 71 |
+
p.add_argument("--kv-heads-self", type=int, default=2,
|
| 72 |
+
help="KV heads for self-attention (GQA; 0 = standard MHA)")
|
| 73 |
+
p.add_argument("--cross-attn-interval", type=int, default=4,
|
| 74 |
+
help="Perceiver cross-attention frequency (every N latent layers)")
|
| 75 |
+
p.add_argument("--dropout", type=float, default=0.1)
|
| 76 |
+
p.add_argument("--weight-decay", type=float, default=0.01, help="AdamW weight decay")
|
| 77 |
+
p.add_argument("--steps", type=int, default=5000)
|
| 78 |
+
p.add_argument("--batch-size", type=int, default=32)
|
| 79 |
+
p.add_argument("--lr", type=float, default=3e-4)
|
| 80 |
+
p.add_argument("--adam-betas", default="0.9,0.95", help="AdamW beta1,beta2")
|
| 81 |
+
p.add_argument("--warmup", type=int, default=200, help="LR warmup steps")
|
| 82 |
+
p.add_argument("--cosine-decay", action="store_true",
|
| 83 |
+
help="Cosine decay LR after warmup (to lr*0.01 at end)")
|
| 84 |
+
p.add_argument("--cooldown-start", type=int, default=0,
|
| 85 |
+
help="Step to begin linear cooldown to lr*0.01 (0=disabled, constant LR after warmup)")
|
| 86 |
+
p.add_argument("--cooldown-steps", type=int, default=0,
|
| 87 |
+
help="Number of steps for linear cooldown (0=no cooldown)")
|
| 88 |
+
p.add_argument("--seed", type=int, default=7)
|
| 89 |
+
p.add_argument("--deterministic", action="store_true",
|
| 90 |
+
help="Force deterministic mode (disables torch.compile, slower but bit-reproducible)")
|
| 91 |
+
p.add_argument("--varifold-weight", type=float, default=0.0)
|
| 92 |
+
p.add_argument("--varifold-cross-only", action="store_true",
|
| 93 |
+
help="Drop varifold self-energy (avoids O(S^2) spike, sinkhorn handles repulsion)")
|
| 94 |
+
p.add_argument("--sinkhorn-weight", type=float, default=1.0)
|
| 95 |
+
p.add_argument("--sinkhorn-eps", type=float, default=0.1,
|
| 96 |
+
help="Sinkhorn regularization (larger = softer matching, stronger gradients)")
|
| 97 |
+
p.add_argument("--sinkhorn-eps-start", type=float, default=None,
|
| 98 |
+
help="Starting eps for epsilon annealing (anneals to --sinkhorn-eps). None=no annealing.")
|
| 99 |
+
p.add_argument("--sinkhorn-eps-schedule", choices=["linear", "sqrt", "none"], default="none",
|
| 100 |
+
help="Eps annealing schedule: linear, sqrt, or none (default: no annealing)")
|
| 101 |
+
p.add_argument("--sinkhorn-iters", type=int, default=20,
|
| 102 |
+
help="Sinkhorn iterations")
|
| 103 |
+
p.add_argument("--sinkhorn-dustbin", type=float, default=0.3,
|
| 104 |
+
help="Sinkhorn dustbin cost in normalized space")
|
| 105 |
+
p.add_argument("--endpoint-weight", type=float, default=0.0,
|
| 106 |
+
help="Weight for endpoint distance loss (sinkhorn-matched, symmetric)")
|
| 107 |
+
p.add_argument("--endpoint-warmup", type=int, default=0,
|
| 108 |
+
help="Steps to linearly warm up endpoint weight from 0 (0=instant)")
|
| 109 |
+
p.add_argument("--aug-rotate", action="store_true")
|
| 110 |
+
p.add_argument("--aug-jitter", type=float, default=0.0,
|
| 111 |
+
help="Point position jitter std in normalized space (0=disabled, try 0.005)")
|
| 112 |
+
p.add_argument("--aug-drop", type=float, default=0.0,
|
| 113 |
+
help="Fraction of points to randomly drop (0=disabled, try 0.1)")
|
| 114 |
+
p.add_argument("--aug-flip", action="store_true",
|
| 115 |
+
help="Random mirror along X axis (50%% chance)")
|
| 116 |
+
p.add_argument("--rms-norm", action="store_true", default=True,
|
| 117 |
+
help="Use RMSNorm (default). Use --no-rms-norm for LayerNorm")
|
| 118 |
+
p.add_argument("--no-rms-norm", dest="rms_norm", action="store_false")
|
| 119 |
+
p.add_argument("--activation", default="gelu", help="FFN activation: gelu, relu, relu_sq")
|
| 120 |
+
p.add_argument("--behind-emb-dim", type=int, default=8,
|
| 121 |
+
help="Behind-gestalt embedding dim (0 to disable)")
|
| 122 |
+
p.add_argument("--vote-features", action="store_true",
|
| 123 |
+
help="Add n_views_voted + vote_frac as raw token features (requires v2 data)")
|
| 124 |
+
p.add_argument("--segment-param", choices=["midpoint_halfvec", "midpoint_dir_len"],
|
| 125 |
+
default="midpoint_halfvec",
|
| 126 |
+
help="Output parameterization: halfvec (default) or decoupled direction+length")
|
| 127 |
+
p.add_argument("--length-floor", type=float, default=0.0,
|
| 128 |
+
help="Minimum segment length for midpoint_dir_len (0=no floor)")
|
| 129 |
+
p.add_argument("--segment-conf", action="store_true",
|
| 130 |
+
help="Add per-segment confidence head (use with --conf-thresh at eval)")
|
| 131 |
+
p.add_argument("--conf-weight", type=float, default=0.0,
|
| 132 |
+
help="Weight for confidence loss (requires --segment-conf)")
|
| 133 |
+
p.add_argument("--conf-mode", choices=["sinkhorn", "sinkhorn_detach"], default="sinkhorn",
|
| 134 |
+
help="Confidence training: 'match'=BCE, 'sinkhorn'=OT mass, 'sinkhorn_detach'=OT mass (detached)")
|
| 135 |
+
p.add_argument("--conf-clamp-min", type=float, default=None,
|
| 136 |
+
help="Clamp conf logits to this minimum before sigmoid (e.g., -5)")
|
| 137 |
+
p.add_argument("--conf-head-wd", type=float, default=None,
|
| 138 |
+
help="Separate weight decay for conf head (default: same as other params)")
|
| 139 |
+
p.add_argument("--ema-decay", type=float, default=0.0,
|
| 140 |
+
help="EMA decay rate (0=disabled, try 0.9999). Saves EMA weights in checkpoints.")
|
| 141 |
+
p.add_argument("--out-dir", default=str(_Path(__file__).resolve().parent / "runs"))
|
| 142 |
+
p.add_argument("--resume", default="")
|
| 143 |
+
p.add_argument("--cpu", action="store_true")
|
| 144 |
+
p.add_argument("--args-from", default=None,
|
| 145 |
+
help="Load defaults from a run's args.json (CLI flags override)")
|
| 146 |
+
|
| 147 |
+
# If --args-from is specified, load defaults from that JSON file first,
|
| 148 |
+
# then let CLI flags override.
|
| 149 |
+
raw_args = p.parse_args()
|
| 150 |
+
if raw_args.args_from is not None:
|
| 151 |
+
import json as _json
|
| 152 |
+
args_path = _Path(raw_args.args_from)
|
| 153 |
+
if not args_path.exists():
|
| 154 |
+
raise FileNotFoundError(f"--args-from file not found: {args_path}")
|
| 155 |
+
saved = _json.loads(args_path.read_text())
|
| 156 |
+
valid_dests = {a.dest for a in p._actions}
|
| 157 |
+
defaults = {}
|
| 158 |
+
for k, v in saved.items():
|
| 159 |
+
if k in valid_dests and k != "args_from":
|
| 160 |
+
defaults[k] = v
|
| 161 |
+
p.set_defaults(**defaults)
|
| 162 |
+
args = p.parse_args()
|
| 163 |
+
print(f"Loaded defaults from {args_path} (CLI flags override)")
|
| 164 |
+
else:
|
| 165 |
+
args = raw_args
|
| 166 |
+
|
| 167 |
+
# Validate required args
|
| 168 |
+
if not args.cache_dir:
|
| 169 |
+
p.error("--cache-dir is required (either directly or via --args-from)")
|
| 170 |
+
|
| 171 |
+
# Validate arg compatibility
|
| 172 |
+
if args.arch == "transformer":
|
| 173 |
+
perceiver_only = []
|
| 174 |
+
if args.latent_tokens != 128:
|
| 175 |
+
perceiver_only.append(f"--latent-tokens={args.latent_tokens}")
|
| 176 |
+
if args.latent_layers != 7:
|
| 177 |
+
perceiver_only.append(f"--latent-layers={args.latent_layers}")
|
| 178 |
+
if args.pre_encoder_layers != 0:
|
| 179 |
+
perceiver_only.append(f"--pre-encoder-layers={args.pre_encoder_layers}")
|
| 180 |
+
if args.cross_attn_interval != 4:
|
| 181 |
+
perceiver_only.append(f"--cross-attn-interval={args.cross_attn_interval}")
|
| 182 |
+
if perceiver_only:
|
| 183 |
+
raise ValueError(
|
| 184 |
+
f"Args {', '.join(perceiver_only)} have no effect with --arch transformer. "
|
| 185 |
+
f"Use --arch perceiver or remove them.")
|
| 186 |
+
if args.conf_weight > 0 and not args.segment_conf:
|
| 187 |
+
raise ValueError("--conf-weight requires --segment-conf")
|
| 188 |
+
if args.conf_mode in ("sinkhorn", "sinkhorn_detach") and args.sinkhorn_weight == 0:
|
| 189 |
+
raise ValueError("--conf-mode sinkhorn requires --sinkhorn-weight > 0")
|
| 190 |
+
if args.cosine_decay and args.cooldown_start > 0:
|
| 191 |
+
raise ValueError("--cosine-decay and --cooldown-start are mutually exclusive")
|
| 192 |
+
|
| 193 |
+
device = torch.device("cpu" if args.cpu else ("cuda" if torch.cuda.is_available() else "cpu"))
|
| 194 |
+
print(f"Device: {device}")
|
| 195 |
+
torch.manual_seed(args.seed)
|
| 196 |
+
np.random.seed(args.seed)
|
| 197 |
+
|
| 198 |
+
# Output
|
| 199 |
+
import hashlib, os
|
| 200 |
+
args_hash = hashlib.md5(json.dumps(vars(args), sort_keys=True).encode()).hexdigest()[:4]
|
| 201 |
+
run_tag = time.strftime("%Y%m%d_%H%M%S") + f"_{args_hash}_{os.getpid() % 10000:04d}"
|
| 202 |
+
out_dir = Path(args.out_dir) / run_tag
|
| 203 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 204 |
+
(out_dir / "checkpoints").mkdir(exist_ok=True)
|
| 205 |
+
|
| 206 |
+
# Tee stdout/stderr to run dir
|
| 207 |
+
import sys as _sys
|
| 208 |
+
_log_path = out_dir / "train.log"
|
| 209 |
+
class _Tee:
|
| 210 |
+
def __init__(self, path, stream):
|
| 211 |
+
self._file = open(path, "a")
|
| 212 |
+
self._stream = stream
|
| 213 |
+
def write(self, data):
|
| 214 |
+
self._stream.write(data)
|
| 215 |
+
self._file.write(data)
|
| 216 |
+
self._file.flush()
|
| 217 |
+
def flush(self):
|
| 218 |
+
self._stream.flush()
|
| 219 |
+
self._file.flush()
|
| 220 |
+
_sys.stdout = _Tee(_log_path, _sys.stdout)
|
| 221 |
+
_sys.stderr = _Tee(_log_path, _sys.stderr)
|
| 222 |
+
|
| 223 |
+
git_sha = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True,
|
| 224 |
+
cwd=str(_Path(__file__).parent)).stdout.strip()
|
| 225 |
+
git_dirty = subprocess.run(["git", "diff", "--quiet"], capture_output=True,
|
| 226 |
+
cwd=str(_Path(__file__).parent)).returncode != 0
|
| 227 |
+
run_info = {**vars(args), "git_sha": git_sha, "git_dirty": git_dirty}
|
| 228 |
+
(out_dir / "args.json").write_text(json.dumps(run_info, indent=2, sort_keys=True) + "\n")
|
| 229 |
+
|
| 230 |
+
# Set varifold cross-only mode before compile
|
| 231 |
+
if args.varifold_cross_only:
|
| 232 |
+
from . import losses as L
|
| 233 |
+
L.VARIFOLD_CROSS_ONLY = True
|
| 234 |
+
print("Varifold: cross-only mode (no self-energy)")
|
| 235 |
+
|
| 236 |
+
# Model
|
| 237 |
+
seq_len = args.seq_len
|
| 238 |
+
norm_class = torch.nn.RMSNorm if args.rms_norm else None
|
| 239 |
+
seq_cfg = EdgeDepthSequenceConfig(seq_len=seq_len)
|
| 240 |
+
model = EdgeDepthSegmentsModel(
|
| 241 |
+
seq_cfg=seq_cfg, segments=args.segments, hidden=args.hidden,
|
| 242 |
+
num_heads=args.num_heads, kv_heads_cross=args.kv_heads_cross,
|
| 243 |
+
kv_heads_self=args.kv_heads_self,
|
| 244 |
+
dim_feedforward=args.ff, dropout=args.dropout,
|
| 245 |
+
latent_tokens=args.latent_tokens, latent_layers=args.latent_layers,
|
| 246 |
+
decoder_layers=args.decoder_layers, cross_attn_interval=args.cross_attn_interval,
|
| 247 |
+
norm_class=norm_class, activation=args.activation,
|
| 248 |
+
segment_conf=args.segment_conf,
|
| 249 |
+
segment_param=args.segment_param,
|
| 250 |
+
length_floor=args.length_floor,
|
| 251 |
+
arch=args.arch, encoder_layers=args.encoder_layers,
|
| 252 |
+
pre_encoder_layers=args.pre_encoder_layers,
|
| 253 |
+
behind_emb_dim=args.behind_emb_dim,
|
| 254 |
+
use_vote_features=args.vote_features,
|
| 255 |
+
decoder_input_xattn=args.decoder_input_xattn,
|
| 256 |
+
qk_norm=args.qk_norm,
|
| 257 |
+
qk_norm_type=args.qk_norm_type,
|
| 258 |
+
learnable_fourier=args.learnable_fourier,
|
| 259 |
+
).to(device)
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
from torchinfo import summary
|
| 263 |
+
summary(model.segmenter,
|
| 264 |
+
input_data=[torch.zeros(1, seq_len, model.tokenizer.out_dim, device=device),
|
| 265 |
+
torch.ones(1, seq_len, device=device, dtype=torch.bool)],
|
| 266 |
+
col_names=("input_size", "output_size", "num_params"), verbose=1)
|
| 267 |
+
except ImportError:
|
| 268 |
+
pass
|
| 269 |
+
print(f"Total params: {sum(p.numel() for p in model.parameters()):,}")
|
| 270 |
+
|
| 271 |
+
# Compile (skip in deterministic mode for bit-reproducibility)
|
| 272 |
+
torch.set_float32_matmul_precision("high")
|
| 273 |
+
if args.deterministic:
|
| 274 |
+
torch.use_deterministic_algorithms(True)
|
| 275 |
+
torch.backends.cudnn.deterministic = True
|
| 276 |
+
torch.backends.cudnn.benchmark = False
|
| 277 |
+
import os
|
| 278 |
+
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":16:8")
|
| 279 |
+
print("Deterministic mode: no torch.compile, bit-reproducible but ~3x slower")
|
| 280 |
+
elif device.type == "cuda":
|
| 281 |
+
model.segmenter = torch.compile(model.segmenter, mode="reduce-overhead", fullgraph=True)
|
| 282 |
+
from . import losses as L
|
| 283 |
+
L._loss_fn = torch.compile(_loss_inner, mode="reduce-overhead", fullgraph=True)
|
| 284 |
+
print("Compiled model + loss (reduce-overhead, fullgraph)")
|
| 285 |
+
|
| 286 |
+
# EMA
|
| 287 |
+
ema_model = None
|
| 288 |
+
if args.ema_decay > 0:
|
| 289 |
+
from copy import deepcopy
|
| 290 |
+
ema_model = deepcopy(model).eval()
|
| 291 |
+
for p_ema in ema_model.parameters():
|
| 292 |
+
p_ema.requires_grad_(False)
|
| 293 |
+
print(f"EMA enabled (decay={args.ema_decay})")
|
| 294 |
+
|
| 295 |
+
# Resume
|
| 296 |
+
start_step = 0
|
| 297 |
+
if args.resume:
|
| 298 |
+
ckpt = torch.load(args.resume, map_location=device, weights_only=False)
|
| 299 |
+
try:
|
| 300 |
+
model.load_state_dict(ckpt["model"])
|
| 301 |
+
except RuntimeError:
|
| 302 |
+
state = {k.replace("segmenter._orig_mod.", "segmenter."): v
|
| 303 |
+
for k, v in ckpt["model"].items()}
|
| 304 |
+
model.load_state_dict(state)
|
| 305 |
+
start_step = ckpt.get("step", 0)
|
| 306 |
+
print(f"Resumed from {args.resume} at step {start_step}")
|
| 307 |
+
|
| 308 |
+
betas = tuple(float(x) for x in args.adam_betas.split(","))
|
| 309 |
+
|
| 310 |
+
# Optimizer: AdamW with optional separate conf_head weight decay
|
| 311 |
+
conf_wd = args.conf_head_wd if args.conf_head_wd is not None else args.weight_decay
|
| 312 |
+
if args.conf_head_wd is not None:
|
| 313 |
+
conf_decay_params = []
|
| 314 |
+
other_params = []
|
| 315 |
+
for name, param in model.named_parameters():
|
| 316 |
+
if not param.requires_grad:
|
| 317 |
+
continue
|
| 318 |
+
if 'conf_head' in name:
|
| 319 |
+
conf_decay_params.append(param)
|
| 320 |
+
else:
|
| 321 |
+
other_params.append(param)
|
| 322 |
+
param_groups = [
|
| 323 |
+
{"params": other_params, "weight_decay": args.weight_decay},
|
| 324 |
+
{"params": conf_decay_params, "weight_decay": conf_wd},
|
| 325 |
+
]
|
| 326 |
+
print(f"Conf head WD: {conf_wd} ({len(conf_decay_params)} params)")
|
| 327 |
+
else:
|
| 328 |
+
param_groups = model.parameters()
|
| 329 |
+
|
| 330 |
+
opt = torch.optim.AdamW(param_groups, lr=args.lr, weight_decay=args.weight_decay,
|
| 331 |
+
betas=betas)
|
| 332 |
+
if args.resume and "optimizer" in ckpt:
|
| 333 |
+
opt.load_state_dict(ckpt["optimizer"])
|
| 334 |
+
|
| 335 |
+
# Data
|
| 336 |
+
torch.manual_seed(args.seed + 7919)
|
| 337 |
+
np.random.seed(args.seed + 7919)
|
| 338 |
+
train_loader = build_loader(args.cache_dir, args.batch_size, aug_rotate=args.aug_rotate,
|
| 339 |
+
aug_jitter=args.aug_jitter, aug_drop=args.aug_drop,
|
| 340 |
+
aug_flip=args.aug_flip)
|
| 341 |
+
val_loader = build_loader(args.val_cache_dir, args.batch_size) if args.val_cache_dir else None
|
| 342 |
+
data_iter = iter(train_loader)
|
| 343 |
+
|
| 344 |
+
# Intervals
|
| 345 |
+
log_int = max(1, min(50, args.steps // 20))
|
| 346 |
+
ckpt_int = 5000
|
| 347 |
+
val_int = ckpt_int if val_loader else 0
|
| 348 |
+
|
| 349 |
+
# Training loop
|
| 350 |
+
global_step = start_step
|
| 351 |
+
loss_ema, loss_sq_ema = 0.0, 0.0
|
| 352 |
+
t_start = time.perf_counter()
|
| 353 |
+
|
| 354 |
+
print(f"Training for {args.steps} steps | {args.segments}seg "
|
| 355 |
+
f"{args.hidden}h {args.latent_tokens}x{args.latent_layers}L "
|
| 356 |
+
f"{args.decoder_layers}D")
|
| 357 |
+
|
| 358 |
+
# Pre-fetch first batch
|
| 359 |
+
try:
|
| 360 |
+
next_batch = next(data_iter)
|
| 361 |
+
except StopIteration:
|
| 362 |
+
data_iter = iter(train_loader)
|
| 363 |
+
next_batch = next(data_iter)
|
| 364 |
+
|
| 365 |
+
# Freeze GC after setup to eliminate stalls during training
|
| 366 |
+
gc.collect()
|
| 367 |
+
gc.freeze()
|
| 368 |
+
gc.disable()
|
| 369 |
+
|
| 370 |
+
amp_ctx = torch.autocast(device_type='cuda', dtype=torch.bfloat16,
|
| 371 |
+
enabled=(device.type == 'cuda'))
|
| 372 |
+
|
| 373 |
+
while global_step < args.steps:
|
| 374 |
+
tokens, masks, gt_list, scales, meta = build_tokens(next_batch, model, device)
|
| 375 |
+
|
| 376 |
+
# Epsilon annealing
|
| 377 |
+
if args.sinkhorn_eps_start is not None and args.sinkhorn_eps_start != args.sinkhorn_eps:
|
| 378 |
+
if args.sinkhorn_eps_schedule == "sqrt":
|
| 379 |
+
ratio_sq = (args.sinkhorn_eps_start / args.sinkhorn_eps) ** 2
|
| 380 |
+
t0 = max(args.steps * 0.8 / max(ratio_sq - 1, 1e-6), 1.0)
|
| 381 |
+
current_eps = args.sinkhorn_eps_start / math.sqrt(1 + global_step / t0)
|
| 382 |
+
current_eps = max(current_eps, args.sinkhorn_eps)
|
| 383 |
+
else:
|
| 384 |
+
frac = min(global_step / max(args.steps * 0.8, 1), 1.0)
|
| 385 |
+
current_eps = args.sinkhorn_eps_start + frac * (args.sinkhorn_eps - args.sinkhorn_eps_start)
|
| 386 |
+
else:
|
| 387 |
+
current_eps = args.sinkhorn_eps
|
| 388 |
+
|
| 389 |
+
with amp_ctx:
|
| 390 |
+
out = model.forward_tokens(tokens, masks)
|
| 391 |
+
pred = out["segments"]
|
| 392 |
+
conf = out.get("conf")
|
| 393 |
+
|
| 394 |
+
# Endpoint weight warmup
|
| 395 |
+
if args.endpoint_warmup > 0 and global_step < args.endpoint_warmup:
|
| 396 |
+
current_ep_w = args.endpoint_weight * global_step / args.endpoint_warmup
|
| 397 |
+
else:
|
| 398 |
+
current_ep_w = args.endpoint_weight
|
| 399 |
+
|
| 400 |
+
loss, terms = compute_loss(pred, gt_list, scales.to(device), device,
|
| 401 |
+
args.varifold_weight, args.sinkhorn_weight,
|
| 402 |
+
endpoint_w=current_ep_w,
|
| 403 |
+
conf_logits=conf, conf_weight=args.conf_weight,
|
| 404 |
+
conf_mode=args.conf_mode,
|
| 405 |
+
sinkhorn_eps=current_eps,
|
| 406 |
+
sinkhorn_iters=args.sinkhorn_iters,
|
| 407 |
+
sinkhorn_dustbin=args.sinkhorn_dustbin,
|
| 408 |
+
conf_clamp_min=args.conf_clamp_min)
|
| 409 |
+
|
| 410 |
+
loss_val = loss.item()
|
| 411 |
+
# Adaptive loss spike detection
|
| 412 |
+
if global_step < 100:
|
| 413 |
+
loss_ema = loss_val if global_step == start_step else 0.9 * loss_ema + 0.1 * loss_val
|
| 414 |
+
loss_sq_ema = loss_val**2 if global_step == start_step else 0.9 * loss_sq_ema + 0.1 * loss_val**2
|
| 415 |
+
else:
|
| 416 |
+
loss_ema = 0.99 * loss_ema + 0.01 * loss_val
|
| 417 |
+
loss_sq_ema = 0.99 * loss_sq_ema + 0.01 * loss_val**2
|
| 418 |
+
loss_std = max(math.sqrt(max(loss_sq_ema - loss_ema**2, 0)), 1e-6)
|
| 419 |
+
spike_thresh = loss_ema + 5 * loss_std
|
| 420 |
+
|
| 421 |
+
# Skip on total loss spike or NaN
|
| 422 |
+
if not math.isfinite(loss_val) or loss_val > max(spike_thresh, 0.5):
|
| 423 |
+
sample_ids = [m.get("sample_id", "?") for m in meta]
|
| 424 |
+
skip_reason = f"loss={loss_val:.2f} > thresh={spike_thresh:.2f}"
|
| 425 |
+
print(f"Step {global_step}: {skip_reason}, skipping (samples: {sample_ids[:3]})")
|
| 426 |
+
with open(out_dir / "skipped_samples.jsonl", "a") as f:
|
| 427 |
+
f.write(json.dumps({"step": global_step, "reason": skip_reason,
|
| 428 |
+
"samples": sample_ids}) + "\n")
|
| 429 |
+
try:
|
| 430 |
+
next_batch = next(data_iter)
|
| 431 |
+
except StopIteration:
|
| 432 |
+
data_iter = iter(train_loader)
|
| 433 |
+
next_batch = next(data_iter)
|
| 434 |
+
continue
|
| 435 |
+
|
| 436 |
+
opt.zero_grad()
|
| 437 |
+
loss.backward()
|
| 438 |
+
|
| 439 |
+
# Fetch next batch while GPU finishes backward
|
| 440 |
+
try:
|
| 441 |
+
next_batch = next(data_iter)
|
| 442 |
+
except StopIteration:
|
| 443 |
+
data_iter = iter(train_loader)
|
| 444 |
+
next_batch = next(data_iter)
|
| 445 |
+
|
| 446 |
+
torch.nn.utils.clip_grad_norm_(model.parameters(), 0.5)
|
| 447 |
+
|
| 448 |
+
# LR schedule: warmup -> constant -> optional cooldown or cosine
|
| 449 |
+
if global_step < args.warmup:
|
| 450 |
+
lr = args.lr * (global_step + 1) / max(1, args.warmup)
|
| 451 |
+
elif args.cosine_decay:
|
| 452 |
+
progress = (global_step - args.warmup) / max(1, args.steps - args.warmup)
|
| 453 |
+
lr = args.lr * (0.01 + 0.99 * 0.5 * (1 + math.cos(math.pi * progress)))
|
| 454 |
+
elif args.cooldown_start > 0 and global_step >= args.cooldown_start:
|
| 455 |
+
progress = (global_step - args.cooldown_start) / max(1, args.cooldown_steps)
|
| 456 |
+
lr = args.lr * max(0.01, 1.0 - 0.99 * min(1.0, progress))
|
| 457 |
+
else:
|
| 458 |
+
lr = args.lr
|
| 459 |
+
for pg in opt.param_groups:
|
| 460 |
+
pg["lr"] = lr
|
| 461 |
+
opt.step()
|
| 462 |
+
global_step += 1
|
| 463 |
+
|
| 464 |
+
# EMA update
|
| 465 |
+
if ema_model is not None:
|
| 466 |
+
decay = args.ema_decay
|
| 467 |
+
with torch.no_grad():
|
| 468 |
+
for p_ema, p_model in zip(ema_model.parameters(), model.parameters()):
|
| 469 |
+
p_ema.lerp_(p_model, 1.0 - decay)
|
| 470 |
+
|
| 471 |
+
# Log
|
| 472 |
+
entry = {"step": global_step, "ts": time.time(), "loss": loss.item(), "lr": lr}
|
| 473 |
+
entry.update({k: v.item() for k, v in terms.items()})
|
| 474 |
+
if global_step % log_int == 0:
|
| 475 |
+
grad_norm = sum(p.grad.norm().item()**2 for p in model.parameters()
|
| 476 |
+
if p.grad is not None) ** 0.5
|
| 477 |
+
entry["grad_norm"] = grad_norm
|
| 478 |
+
|
| 479 |
+
if global_step % log_int == 0:
|
| 480 |
+
ms = (time.perf_counter() - t_start) / log_int * 1000
|
| 481 |
+
t_start = time.perf_counter()
|
| 482 |
+
t_str = " ".join(f"{k}={v:.4f}" for k, v in terms.items())
|
| 483 |
+
print(f"[{global_step}/{args.steps}] loss={loss.item():.4f} {t_str} "
|
| 484 |
+
f"lr={lr:.2e} gnorm={entry.get('grad_norm', 0):.3f} [{ms:.0f}ms/step]")
|
| 485 |
+
|
| 486 |
+
if val_int > 0 and global_step % val_int == 0:
|
| 487 |
+
try:
|
| 488 |
+
vl_list = []
|
| 489 |
+
with torch.no_grad(), amp_ctx:
|
| 490 |
+
for vb in val_loader:
|
| 491 |
+
vt, vm, vg, vs, _ = build_tokens(vb, model, device)
|
| 492 |
+
vo = model.forward_tokens(vt, vm)
|
| 493 |
+
vl, _ = compute_loss(vo["segments"], vg, vs.to(device), device,
|
| 494 |
+
args.varifold_weight, args.sinkhorn_weight)
|
| 495 |
+
if math.isfinite(vl.item()):
|
| 496 |
+
vl_list.append(vl.item())
|
| 497 |
+
if vl_list:
|
| 498 |
+
val_loss = float(np.mean(vl_list))
|
| 499 |
+
print(f" val_loss={val_loss:.4f}")
|
| 500 |
+
entry["val_loss"] = val_loss
|
| 501 |
+
except Exception as e:
|
| 502 |
+
print(f" val eval failed: {e}")
|
| 503 |
+
|
| 504 |
+
# Write log entry
|
| 505 |
+
with open(out_dir / "history.jsonl", "a") as f:
|
| 506 |
+
f.write(json.dumps(entry) + "\n")
|
| 507 |
+
|
| 508 |
+
if global_step % ckpt_int == 0:
|
| 509 |
+
try:
|
| 510 |
+
gc.enable(); gc.collect(); gc.freeze(); gc.disable()
|
| 511 |
+
torch.cuda.empty_cache()
|
| 512 |
+
save_dict = {"step": global_step, "model": model.state_dict(),
|
| 513 |
+
"optimizer": opt.state_dict(), "args": vars(args)}
|
| 514 |
+
if ema_model is not None:
|
| 515 |
+
save_dict["ema_model"] = ema_model.state_dict()
|
| 516 |
+
torch.save(save_dict, out_dir / "checkpoints" / f"step{global_step:06d}.pt")
|
| 517 |
+
except Exception as e:
|
| 518 |
+
print(f" checkpoint save failed: {e}")
|
| 519 |
+
|
| 520 |
+
# Final save
|
| 521 |
+
save_dict = {"step": global_step, "model": model.state_dict(),
|
| 522 |
+
"optimizer": opt.state_dict(), "args": vars(args)}
|
| 523 |
+
if ema_model is not None:
|
| 524 |
+
save_dict["ema_model"] = ema_model.state_dict()
|
| 525 |
+
torch.save(save_dict, out_dir / "checkpoints" / "final.pt")
|
| 526 |
+
print(f"Done. {global_step} steps. Output: {out_dir}")
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
if __name__ == "__main__":
|
| 530 |
+
main()
|
s23dr_2026_example/varifold.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
from .wire_varifold_kernels import (
|
| 4 |
+
loss_simpson3_batch,
|
| 5 |
+
loss_simpson3_mix_batch,
|
| 6 |
+
)
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def segments_to_vertices_edges(segments: torch.Tensor):
|
| 10 |
+
segs = torch.as_tensor(segments, dtype=torch.float32)
|
| 11 |
+
vertices = segs.reshape(-1, 3)
|
| 12 |
+
edges = [(2 * i, 2 * i + 1) for i in range(segs.shape[0])]
|
| 13 |
+
return vertices, edges
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def varifold_loss_batch(
|
| 17 |
+
pred_segments: torch.Tensor,
|
| 18 |
+
gt_segments: torch.Tensor,
|
| 19 |
+
*,
|
| 20 |
+
sigma: float = 0.1,
|
| 21 |
+
variant: str = "semi_lobatto3",
|
| 22 |
+
t_nodes01: torch.Tensor | None = None,
|
| 23 |
+
t_w: torch.Tensor | None = None,
|
| 24 |
+
sigmas: torch.Tensor | None = None,
|
| 25 |
+
alpha: torch.Tensor | None = None,
|
| 26 |
+
normalize_alpha: bool = True,
|
| 27 |
+
len_pow: float | None = None,
|
| 28 |
+
gt_mask: torch.Tensor | None = None,
|
| 29 |
+
pred_weights: torch.Tensor | None = None,
|
| 30 |
+
cross_only: bool = False,
|
| 31 |
+
) -> torch.Tensor:
|
| 32 |
+
if pred_segments.dim() != 4 or gt_segments.dim() != 4:
|
| 33 |
+
raise ValueError("pred_segments and gt_segments must be (B, N, 2, 3)")
|
| 34 |
+
p_pred, q_pred = pred_segments[:, :, 0], pred_segments[:, :, 1]
|
| 35 |
+
p_gt, q_gt = gt_segments[:, :, 0], gt_segments[:, :, 1]
|
| 36 |
+
|
| 37 |
+
w_gt = None
|
| 38 |
+
if gt_mask is not None:
|
| 39 |
+
w_gt = gt_mask.to(device=pred_segments.device, dtype=pred_segments.dtype)
|
| 40 |
+
|
| 41 |
+
w_pred = None
|
| 42 |
+
if pred_weights is not None:
|
| 43 |
+
w_pred = pred_weights.to(device=pred_segments.device, dtype=pred_segments.dtype)
|
| 44 |
+
|
| 45 |
+
if variant != "simpson3":
|
| 46 |
+
raise ValueError(
|
| 47 |
+
f"Unsupported varifold variant: {variant!r}. "
|
| 48 |
+
f"Only 'simpson3' is supported in batch mode.")
|
| 49 |
+
if sigmas is not None or alpha is not None:
|
| 50 |
+
if sigmas is None or alpha is None:
|
| 51 |
+
raise ValueError("sigmas and alpha are required for simpson3 mix")
|
| 52 |
+
return loss_simpson3_mix_batch(p_pred, q_pred, p_gt, q_gt, sigmas, alpha, w_gt=w_gt, w_pred=w_pred, normalize_alpha=normalize_alpha, cross_only=cross_only)
|
| 53 |
+
return loss_simpson3_batch(p_pred, q_pred, p_gt, q_gt, sigma, w_gt=w_gt, w_pred=w_pred)
|
s23dr_2026_example/wire_varifold_kernels.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
|
| 3 |
+
# -----------------------------
|
| 4 |
+
# Helpers
|
| 5 |
+
# -----------------------------
|
| 6 |
+
def segment_geom(p: torch.Tensor, q: torch.Tensor, eps: float = 1e-9):
|
| 7 |
+
"""
|
| 8 |
+
p,q: (...,3)
|
| 9 |
+
returns d, a, ell, u:
|
| 10 |
+
d = q - p
|
| 11 |
+
a = ||d||^2
|
| 12 |
+
ell = sqrt(a + eps^2)
|
| 13 |
+
u = d / ell
|
| 14 |
+
"""
|
| 15 |
+
d = q - p
|
| 16 |
+
a = (d * d).sum(dim=-1)
|
| 17 |
+
eps_val = eps
|
| 18 |
+
if p.dtype in (torch.float16, torch.bfloat16):
|
| 19 |
+
eps_val = max(eps, float(torch.finfo(p.dtype).eps))
|
| 20 |
+
ell = torch.sqrt(a + eps_val * eps_val)
|
| 21 |
+
u = d / ell.unsqueeze(-1)
|
| 22 |
+
return d, a, ell, u
|
| 23 |
+
|
| 24 |
+
def sample_points(p: torch.Tensor, q: torch.Tensor, nodes01: torch.Tensor):
|
| 25 |
+
# (...,3) + (K,) -> (...,K,3)
|
| 26 |
+
d = q - p
|
| 27 |
+
nodes = nodes01.to(device=p.device, dtype=p.dtype)
|
| 28 |
+
shape = [1] * (p.dim() - 1) + [nodes.shape[0], 1]
|
| 29 |
+
nodes = nodes.view(*shape)
|
| 30 |
+
return p.unsqueeze(-2) + nodes * d.unsqueeze(-2)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
# Fixed Lobatto-3 / Simpson nodes+weights on [0,1]
|
| 34 |
+
LOBATTO3_NODES = torch.tensor([0.0, 0.5, 1.0])
|
| 35 |
+
# LOBATTO3_W = torch.tensor([1.0/6.0, 4.0/6.0, 1.0/6.0])
|
| 36 |
+
LOBATTO3_W = torch.tensor([1/3, 1/3, 1/3])
|
| 37 |
+
LOBATTO3_W2 = LOBATTO3_W[:, None] * LOBATTO3_W[None, :] # (3,3)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _prepare_mix_weights(sigmas, alpha, device, dtype, normalize_alpha: bool):
|
| 41 |
+
sigmas_t = torch.as_tensor(sigmas, device=device, dtype=dtype).clamp_min(1e-6)
|
| 42 |
+
alpha_t = torch.as_tensor(alpha, device=device, dtype=dtype)
|
| 43 |
+
if normalize_alpha:
|
| 44 |
+
alpha_t = alpha_t / alpha_t.sum().clamp_min(1e-12)
|
| 45 |
+
return sigmas_t, alpha_t
|
| 46 |
+
|
| 47 |
+
# -----------------------------
|
| 48 |
+
# Simpson-3 on both segments (3x3 product rule)
|
| 49 |
+
# -----------------------------
|
| 50 |
+
def _prep_weight(w, n: int, b: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor | None:
|
| 51 |
+
if w is None:
|
| 52 |
+
return None
|
| 53 |
+
w = torch.as_tensor(w, device=device, dtype=dtype)
|
| 54 |
+
if w.dim() == 1:
|
| 55 |
+
if w.shape[0] != n:
|
| 56 |
+
raise ValueError(f"weight length {w.shape[0]} != {n}")
|
| 57 |
+
w = w.unsqueeze(0).expand(b, -1)
|
| 58 |
+
elif w.dim() == 2:
|
| 59 |
+
if w.shape[0] != b or w.shape[1] != n:
|
| 60 |
+
raise ValueError(f"weight shape {tuple(w.shape)} != ({b}, {n})")
|
| 61 |
+
else:
|
| 62 |
+
raise ValueError("weights must be 1D or 2D")
|
| 63 |
+
return w
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def cross_simpson3(
|
| 67 |
+
pA,
|
| 68 |
+
qA,
|
| 69 |
+
pB,
|
| 70 |
+
qB,
|
| 71 |
+
sigma: float | torch.Tensor,
|
| 72 |
+
wA: torch.Tensor | None = None,
|
| 73 |
+
wB: torch.Tensor | None = None,
|
| 74 |
+
):
|
| 75 |
+
device, dtype = pA.device, pA.dtype
|
| 76 |
+
batched = pA.dim() == 3
|
| 77 |
+
if not batched:
|
| 78 |
+
pA = pA.unsqueeze(0)
|
| 79 |
+
qA = qA.unsqueeze(0)
|
| 80 |
+
pB = pB.unsqueeze(0)
|
| 81 |
+
qB = qB.unsqueeze(0)
|
| 82 |
+
nodes = LOBATTO3_NODES.to(device=device, dtype=dtype)
|
| 83 |
+
w2 = LOBATTO3_W2.to(device=device, dtype=dtype)
|
| 84 |
+
|
| 85 |
+
bsz, nA, _ = pA.shape
|
| 86 |
+
nB = pB.shape[1]
|
| 87 |
+
wA = _prep_weight(wA, nA, bsz, device, dtype)
|
| 88 |
+
wB = _prep_weight(wB, nB, bsz, device, dtype)
|
| 89 |
+
|
| 90 |
+
_, _, ellA, uA = segment_geom(pA, qA)
|
| 91 |
+
_, _, ellB, uB = segment_geom(pB, qB)
|
| 92 |
+
|
| 93 |
+
XA = sample_points(pA, qA, nodes) # (B,N,3,3)
|
| 94 |
+
YB = sample_points(pB, qB, nodes) # (B,M,3,3)
|
| 95 |
+
|
| 96 |
+
# angular + length factors: (N,M)
|
| 97 |
+
ang = torch.matmul(uA, uB.transpose(-1, -2)).pow(2)
|
| 98 |
+
lenfac = ellA[:, :, None] * ellB[:, None, :]
|
| 99 |
+
if wA is not None or wB is not None:
|
| 100 |
+
if wA is None:
|
| 101 |
+
wA = torch.ones((bsz, nA), device=device, dtype=dtype)
|
| 102 |
+
if wB is None:
|
| 103 |
+
wB = torch.ones((bsz, nB), device=device, dtype=dtype)
|
| 104 |
+
lenfac = lenfac * (wA[:, :, None] * wB[:, None, :])
|
| 105 |
+
|
| 106 |
+
# spatial: build (N,M,3,3) kernel via broadcasting
|
| 107 |
+
diff = XA[:, :, None, :, None, :] - YB[:, None, :, None, :, :] # (B,N,M,3,3,3)
|
| 108 |
+
r2 = (diff * diff).sum(dim=-1) # (B,N,M,3,3)
|
| 109 |
+
sigma_t = torch.as_tensor(sigma, device=device, dtype=dtype)
|
| 110 |
+
if sigma_t.ndim == 0:
|
| 111 |
+
inv2s2 = 1.0 / (2.0 * sigma_t * sigma_t)
|
| 112 |
+
else:
|
| 113 |
+
if sigma_t.shape[0] != bsz:
|
| 114 |
+
raise ValueError(f"sigma batch {sigma_t.shape[0]} != {bsz}")
|
| 115 |
+
inv2s2 = (1.0 / (2.0 * sigma_t * sigma_t)).view(bsz, 1, 1, 1, 1)
|
| 116 |
+
K = torch.exp(-r2 * inv2s2) # (B,N,M,3,3)
|
| 117 |
+
|
| 118 |
+
spatial = (K * w2).sum(dim=-1).sum(dim=-1) # (B,N,M)
|
| 119 |
+
out = (ang * lenfac * spatial).sum(dim=-1).sum(dim=-1) # (B,)
|
| 120 |
+
return out[0] if not batched else out
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# -----------------------------
|
| 124 |
+
# Batch losses
|
| 125 |
+
# -----------------------------
|
| 126 |
+
|
| 127 |
+
def loss_simpson3_batch(
|
| 128 |
+
p_pred: torch.Tensor,
|
| 129 |
+
q_pred: torch.Tensor,
|
| 130 |
+
p_gt: torch.Tensor,
|
| 131 |
+
q_gt: torch.Tensor,
|
| 132 |
+
sigma: float | torch.Tensor,
|
| 133 |
+
w_gt: torch.Tensor | None = None,
|
| 134 |
+
w_pred: torch.Tensor | None = None,
|
| 135 |
+
cross_only: bool = False,
|
| 136 |
+
) -> torch.Tensor:
|
| 137 |
+
cross = cross_simpson3(p_pred, q_pred, p_gt, q_gt, sigma, wA=w_pred, wB=w_gt)
|
| 138 |
+
if cross_only:
|
| 139 |
+
# No self-energy: avoids O(S^2) blowup, sinkhorn handles repulsion
|
| 140 |
+
return -2.0 * cross
|
| 141 |
+
s_pred = cross_simpson3(p_pred, q_pred, p_pred, q_pred, sigma, wA=w_pred, wB=w_pred)
|
| 142 |
+
return s_pred - 2.0 * cross
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def loss_simpson3_mix_batch(
|
| 146 |
+
p_pred: torch.Tensor,
|
| 147 |
+
q_pred: torch.Tensor,
|
| 148 |
+
p_gt: torch.Tensor,
|
| 149 |
+
q_gt: torch.Tensor,
|
| 150 |
+
sigmas,
|
| 151 |
+
alpha,
|
| 152 |
+
w_gt: torch.Tensor | None = None,
|
| 153 |
+
w_pred: torch.Tensor | None = None,
|
| 154 |
+
normalize_alpha: bool = True,
|
| 155 |
+
cross_only: bool = False,
|
| 156 |
+
) -> torch.Tensor:
|
| 157 |
+
device, dtype = p_pred.device, p_pred.dtype
|
| 158 |
+
sigmas_t = torch.as_tensor(sigmas, device=device, dtype=dtype).clamp_min(1e-6)
|
| 159 |
+
alpha_t = torch.as_tensor(alpha, device=device, dtype=dtype)
|
| 160 |
+
if normalize_alpha:
|
| 161 |
+
alpha_t = alpha_t / alpha_t.sum().clamp_min(1e-12)
|
| 162 |
+
if sigmas_t.ndim == 1:
|
| 163 |
+
losses = [loss_simpson3_batch(p_pred, q_pred, p_gt, q_gt, s, w_gt=w_gt, w_pred=w_pred, cross_only=cross_only) for s in sigmas_t]
|
| 164 |
+
return (torch.stack(losses, dim=0) * alpha_t[:, None]).sum(dim=0)
|
| 165 |
+
if sigmas_t.ndim == 2:
|
| 166 |
+
losses = [loss_simpson3_batch(p_pred, q_pred, p_gt, q_gt, sigmas_t[:, i], w_gt=w_gt, w_pred=w_pred, cross_only=cross_only) for i in range(sigmas_t.shape[1])]
|
| 167 |
+
return (torch.stack(losses, dim=0) * alpha_t[:, None]).sum(dim=0)
|
| 168 |
+
raise ValueError("sigmas must be 1D or 2D for batch loss")
|
script.py
ADDED
|
@@ -0,0 +1,432 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""S23DR 2026 submission: learned wireframe prediction from fused point clouds.
|
| 2 |
+
|
| 3 |
+
Pipeline: raw sample -> point fusion -> priority sampling -> model -> post-process -> wireframe
|
| 4 |
+
"""
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from tqdm import tqdm
|
| 7 |
+
import json
|
| 8 |
+
import os
|
| 9 |
+
import sys
|
| 10 |
+
import time
|
| 11 |
+
|
| 12 |
+
import numpy as np
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def empty_solution():
|
| 17 |
+
return np.zeros((2, 3)), [(0, 1)]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
# ---------------------------------------------------------------------------
|
| 21 |
+
# Point fusion + sampling (from cache_scenes.py / make_sampled_cache.py)
|
| 22 |
+
# ---------------------------------------------------------------------------
|
| 23 |
+
|
| 24 |
+
# Add our package to path
|
| 25 |
+
SCRIPT_DIR = Path(__file__).resolve().parent
|
| 26 |
+
sys.path.insert(0, str(SCRIPT_DIR))
|
| 27 |
+
|
| 28 |
+
from s23dr_2026_example.point_fusion import build_compact_scene, FuserConfig
|
| 29 |
+
from s23dr_2026_example.cache_scenes import (
|
| 30 |
+
_compute_group_and_class, _compute_smart_center_scale,
|
| 31 |
+
)
|
| 32 |
+
from s23dr_2026_example.make_sampled_cache import _priority_sample
|
| 33 |
+
|
| 34 |
+
# Tokenizer / model imports
|
| 35 |
+
from s23dr_2026_example.tokenizer import EdgeDepthSequenceConfig
|
| 36 |
+
from s23dr_2026_example.model import EdgeDepthSegmentsModel
|
| 37 |
+
from s23dr_2026_example.segment_postprocess import merge_vertices_iterative
|
| 38 |
+
from s23dr_2026_example.varifold import segments_to_vertices_edges
|
| 39 |
+
from s23dr_2026_example.postprocess_v2 import snap_to_point_cloud, snap_horizontal
|
| 40 |
+
from solution import predict_wireframe, read_colmap_rec
|
| 41 |
+
import edge_classifier as ec
|
| 42 |
+
import vertex_refiner as vr
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def snap_to_handcrafted(learned_v, learned_e, craft_v, snap_radius=1.0, colmap_guard_thresh=2.0):
|
| 46 |
+
"""Move each learned vertex to the nearest handcrafted vertex if within snap_radius.
|
| 47 |
+
|
| 48 |
+
Skips entirely if the median nearest-neighbour distance from handcrafted vertices to
|
| 49 |
+
learned vertices exceeds colmap_guard_thresh -- indicates a COLMAP frame misalignment
|
| 50 |
+
where the handcrafted pipeline produced vertices in the wrong coordinate frame.
|
| 51 |
+
"""
|
| 52 |
+
from scipy.spatial.distance import cdist
|
| 53 |
+
|
| 54 |
+
if craft_v is None or len(craft_v) == 0 or len(learned_v) == 0:
|
| 55 |
+
return learned_v, list(learned_e)
|
| 56 |
+
|
| 57 |
+
dists = cdist(learned_v, craft_v) # (N_learned, N_craft)
|
| 58 |
+
|
| 59 |
+
median_craft_to_learned = np.median(np.min(dists, axis=0))
|
| 60 |
+
if median_craft_to_learned > colmap_guard_thresh:
|
| 61 |
+
print(f" [COLMAP guard] skipping snap -- median craft->learned dist {median_craft_to_learned:.2f}m")
|
| 62 |
+
return learned_v, list(learned_e)
|
| 63 |
+
|
| 64 |
+
# 1-to-1 greedy matching: sort candidate pairs by distance, each craft vertex used at most once
|
| 65 |
+
snapped_v = learned_v.copy()
|
| 66 |
+
used_learned = set()
|
| 67 |
+
used_craft = set()
|
| 68 |
+
rows, cols = np.where(dists <= snap_radius)
|
| 69 |
+
if len(rows) > 0:
|
| 70 |
+
order = np.argsort(dists[rows, cols])
|
| 71 |
+
for idx in order:
|
| 72 |
+
i, j = int(rows[idx]), int(cols[idx])
|
| 73 |
+
if i not in used_learned and j not in used_craft:
|
| 74 |
+
# Blend the learned vertex toward the handcrafted one (alpha=0.7).
|
| 75 |
+
snapped_v[i] = 0.3 * snapped_v[i] + 0.7 * craft_v[j]
|
| 76 |
+
used_learned.add(i)
|
| 77 |
+
used_craft.add(j)
|
| 78 |
+
|
| 79 |
+
seen = set()
|
| 80 |
+
new_edges = []
|
| 81 |
+
for u, v in learned_e:
|
| 82 |
+
if u == v:
|
| 83 |
+
continue
|
| 84 |
+
key = tuple(sorted((u, v)))
|
| 85 |
+
if key not in seen:
|
| 86 |
+
seen.add(key)
|
| 87 |
+
new_edges.append((u, v))
|
| 88 |
+
|
| 89 |
+
return snapped_v, new_edges
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
SEQ_LEN = 8192
|
| 93 |
+
COLMAP_QUOTA = 6144
|
| 94 |
+
DEPTH_QUOTA = 2048
|
| 95 |
+
CONF_THRESH = 0.5 # segment-confidence filter threshold
|
| 96 |
+
MERGE_END = 0.6 # iterative vertex-merge end threshold
|
| 97 |
+
SNAP_RADIUS = 0.5 # point-cloud snap radius (metres)
|
| 98 |
+
|
| 99 |
+
# Classifier-gated handcrafted augmentation. Disabled for the raw model entry;
|
| 100 |
+
# set AUGMENT_ENABLED / VERTEX_AUGMENT_ENABLED to True to enable the hybrid.
|
| 101 |
+
AUGMENT_ENABLED = False
|
| 102 |
+
AUGMENT_THRESHOLD = 0.55
|
| 103 |
+
EDGE_CLASSIFIER_PATH = SCRIPT_DIR / "pnet_class_2026.pth"
|
| 104 |
+
|
| 105 |
+
VERTEX_AUGMENT_ENABLED = False
|
| 106 |
+
VERTEX_AUGMENT_THRESHOLD = 0.55
|
| 107 |
+
VERTEX_REFINER_PATH = SCRIPT_DIR / "vertex_refiner.pth"
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def fuse_and_sample(sample, cfg, rng):
|
| 111 |
+
"""Run point fusion + priority sampling on a raw dataset sample.
|
| 112 |
+
|
| 113 |
+
Returns a dict with xyz_norm, class_id, source, mask, center, scale, etc.
|
| 114 |
+
ready for model inference. Returns None if fusion fails.
|
| 115 |
+
"""
|
| 116 |
+
try:
|
| 117 |
+
scene = build_compact_scene(sample, cfg, rng)
|
| 118 |
+
except Exception as e:
|
| 119 |
+
print(f" Fusion failed: {e}")
|
| 120 |
+
return None
|
| 121 |
+
|
| 122 |
+
xyz = scene["xyz"]
|
| 123 |
+
source = scene["source"]
|
| 124 |
+
|
| 125 |
+
if len(xyz) < 10:
|
| 126 |
+
return None
|
| 127 |
+
|
| 128 |
+
# Compute group_id and class_id (same as cache_scenes.py)
|
| 129 |
+
behind_id = scene.get("behind_gest_id", np.full(len(xyz), -1, dtype=np.int16))
|
| 130 |
+
group_id, class_id = _compute_group_and_class(
|
| 131 |
+
scene["visible_src"], scene["visible_id"], behind_id, source)
|
| 132 |
+
|
| 133 |
+
# Normalize
|
| 134 |
+
center, scale = _compute_smart_center_scale(xyz, source)
|
| 135 |
+
|
| 136 |
+
# Priority sample
|
| 137 |
+
indices, mask = _priority_sample(source, group_id, SEQ_LEN, COLMAP_QUOTA, DEPTH_QUOTA)
|
| 138 |
+
|
| 139 |
+
xyz_norm = (xyz[indices] - center) / scale
|
| 140 |
+
|
| 141 |
+
result = {
|
| 142 |
+
"xyz_norm": xyz_norm.astype(np.float32),
|
| 143 |
+
"class_id": class_id[indices].astype(np.int64),
|
| 144 |
+
"source": source[indices].astype(np.int64),
|
| 145 |
+
"mask": mask,
|
| 146 |
+
"center": center.astype(np.float32),
|
| 147 |
+
"scale": np.float32(scale),
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
# Optional fields
|
| 151 |
+
if "behind_gest_id" in scene:
|
| 152 |
+
behind = np.clip(scene["behind_gest_id"][indices].astype(np.int16), 0, None)
|
| 153 |
+
result["behind"] = behind.astype(np.int64)
|
| 154 |
+
if "n_views_voted" in scene:
|
| 155 |
+
result["n_views_voted"] = scene["n_views_voted"][indices].astype(np.float32)
|
| 156 |
+
if "vote_frac" in scene:
|
| 157 |
+
result["vote_frac"] = scene["vote_frac"][indices].astype(np.float32)
|
| 158 |
+
|
| 159 |
+
# Visible src/id for snap post-processing
|
| 160 |
+
result["visible_src"] = scene["visible_src"][indices].astype(np.int64)
|
| 161 |
+
result["visible_id"] = scene["visible_id"][indices].astype(np.int64)
|
| 162 |
+
|
| 163 |
+
return result
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def load_model(checkpoint_path, device):
|
| 167 |
+
"""Load model from checkpoint."""
|
| 168 |
+
ckpt = torch.load(checkpoint_path, map_location=device, weights_only=False)
|
| 169 |
+
args = ckpt.get("args", {})
|
| 170 |
+
|
| 171 |
+
norm_class = torch.nn.RMSNorm if args.get("rms_norm") else None
|
| 172 |
+
seq_cfg = EdgeDepthSequenceConfig(
|
| 173 |
+
seq_len=SEQ_LEN, colmap_points=COLMAP_QUOTA, depth_points=DEPTH_QUOTA)
|
| 174 |
+
|
| 175 |
+
model = EdgeDepthSegmentsModel(
|
| 176 |
+
seq_cfg=seq_cfg,
|
| 177 |
+
segments=args.get("segments", 64),
|
| 178 |
+
hidden=args.get("hidden", 256),
|
| 179 |
+
num_heads=args.get("num_heads", 4),
|
| 180 |
+
kv_heads_cross=args.get("kv_heads_cross", 2),
|
| 181 |
+
kv_heads_self=args.get("kv_heads_self", 2),
|
| 182 |
+
dim_feedforward=args.get("ff", 1024),
|
| 183 |
+
dropout=args.get("dropout", 0.1),
|
| 184 |
+
latent_tokens=args.get("latent_tokens", 256),
|
| 185 |
+
latent_layers=args.get("latent_layers", 7),
|
| 186 |
+
decoder_layers=args.get("decoder_layers", 3),
|
| 187 |
+
cross_attn_interval=args.get("cross_attn_interval", 4),
|
| 188 |
+
norm_class=norm_class,
|
| 189 |
+
activation=args.get("activation", "gelu"),
|
| 190 |
+
segment_conf=args.get("segment_conf", True),
|
| 191 |
+
behind_emb_dim=args.get("behind_emb_dim", 8),
|
| 192 |
+
use_vote_features=args.get("vote_features", True),
|
| 193 |
+
arch=args.get("arch", "perceiver"),
|
| 194 |
+
encoder_layers=args.get("encoder_layers", 4),
|
| 195 |
+
pre_encoder_layers=args.get("pre_encoder_layers", 0),
|
| 196 |
+
segment_param=args.get("segment_param", "midpoint_dir_len"),
|
| 197 |
+
qk_norm=args.get("qk_norm", True),
|
| 198 |
+
).to(device)
|
| 199 |
+
|
| 200 |
+
# Handle torch.compile _orig_mod prefix
|
| 201 |
+
state = ckpt["model"]
|
| 202 |
+
fixed = {k.replace("segmenter._orig_mod.", "segmenter."): v
|
| 203 |
+
for k, v in state.items()}
|
| 204 |
+
model.load_state_dict(fixed, strict=True)
|
| 205 |
+
model.eval()
|
| 206 |
+
return model
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
def build_tokens_single(sample_dict, model, device):
|
| 210 |
+
"""Build token tensor for a single sample (no DataLoader)."""
|
| 211 |
+
xyz = torch.as_tensor(sample_dict["xyz_norm"], dtype=torch.float32).unsqueeze(0).to(device)
|
| 212 |
+
cid = torch.as_tensor(sample_dict["class_id"], dtype=torch.long).unsqueeze(0).to(device)
|
| 213 |
+
src = torch.as_tensor(sample_dict["source"], dtype=torch.long).unsqueeze(0).to(device)
|
| 214 |
+
masks = torch.as_tensor(sample_dict["mask"], dtype=torch.bool).unsqueeze(0).to(device)
|
| 215 |
+
|
| 216 |
+
B, T, _ = xyz.shape
|
| 217 |
+
tok = model.tokenizer
|
| 218 |
+
fourier = tok.pos_enc(xyz.reshape(-1, 3)).reshape(B, T, -1) \
|
| 219 |
+
if tok.pos_enc is not None else xyz.new_zeros(B, T, 0)
|
| 220 |
+
parts = [xyz, fourier, tok.label_emb(cid), tok.src_emb(src.clamp(0, 1))]
|
| 221 |
+
|
| 222 |
+
if tok.behind_emb_dim > 0:
|
| 223 |
+
if "behind" in sample_dict:
|
| 224 |
+
beh = torch.as_tensor(sample_dict["behind"], dtype=torch.long).unsqueeze(0).to(device)
|
| 225 |
+
else:
|
| 226 |
+
beh = xyz.new_zeros(B, T, dtype=torch.long)
|
| 227 |
+
parts.append(tok.behind_emb(beh))
|
| 228 |
+
|
| 229 |
+
if tok.use_vote_features:
|
| 230 |
+
if "n_views_voted" in sample_dict and "vote_frac" in sample_dict:
|
| 231 |
+
nv = ((torch.as_tensor(sample_dict["n_views_voted"], dtype=torch.float32).unsqueeze(0).to(device) - 2.7) / 1.0).unsqueeze(-1)
|
| 232 |
+
vf = ((torch.as_tensor(sample_dict["vote_frac"], dtype=torch.float32).unsqueeze(0).to(device) - 0.5) / 0.25).unsqueeze(-1)
|
| 233 |
+
parts.extend([nv, vf])
|
| 234 |
+
else:
|
| 235 |
+
parts.extend([xyz.new_zeros(B, T, 1), xyz.new_zeros(B, T, 1)])
|
| 236 |
+
|
| 237 |
+
tokens = torch.cat(parts, dim=-1)
|
| 238 |
+
return tokens, masks
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
def predict_sample(sample_dict, model, device, return_stats=False):
|
| 242 |
+
"""Run model inference + post-processing on a fused sample.
|
| 243 |
+
|
| 244 |
+
Returns (vertices, edges) in world space.
|
| 245 |
+
If return_stats=True, also returns a dict of confidence diagnostics.
|
| 246 |
+
"""
|
| 247 |
+
tokens, masks = build_tokens_single(sample_dict, model, device)
|
| 248 |
+
scale = float(sample_dict["scale"])
|
| 249 |
+
center = sample_dict["center"]
|
| 250 |
+
|
| 251 |
+
with torch.no_grad(), torch.autocast(device_type='cuda', dtype=torch.float16,
|
| 252 |
+
enabled=(device.type == 'cuda')):
|
| 253 |
+
out = model.forward_tokens(tokens, masks)
|
| 254 |
+
|
| 255 |
+
segs = out["segments"][0].float().cpu()
|
| 256 |
+
conf = torch.sigmoid(out["conf"][0].float()).cpu().numpy() if "conf" in out else None
|
| 257 |
+
|
| 258 |
+
stats = {
|
| 259 |
+
'n_segs_total': int(len(segs)),
|
| 260 |
+
'n_segs_kept': 0,
|
| 261 |
+
'mean_conf_all': float(conf.mean()) if conf is not None else None,
|
| 262 |
+
'mean_conf_kept': None,
|
| 263 |
+
'max_conf': float(conf.max()) if conf is not None else None,
|
| 264 |
+
'top5_conf_mean': float(np.sort(conf)[-5:].mean()) if conf is not None and len(conf) >= 5 else None,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
# Confidence filter
|
| 268 |
+
if conf is not None:
|
| 269 |
+
keep = conf > CONF_THRESH
|
| 270 |
+
kept_conf = conf[keep]
|
| 271 |
+
stats['n_segs_kept'] = int(keep.sum())
|
| 272 |
+
if keep.sum() > 0:
|
| 273 |
+
stats['mean_conf_kept'] = float(kept_conf.mean())
|
| 274 |
+
segs = segs[keep]
|
| 275 |
+
if len(segs) < 1:
|
| 276 |
+
if return_stats:
|
| 277 |
+
return *empty_solution(), stats
|
| 278 |
+
return empty_solution()
|
| 279 |
+
|
| 280 |
+
# To world space
|
| 281 |
+
segs_world = segs.numpy() * scale + center
|
| 282 |
+
|
| 283 |
+
# Vertices + edges from segments
|
| 284 |
+
pv, pe = segments_to_vertices_edges(torch.tensor(segs_world))
|
| 285 |
+
pv, pe = pv.numpy(), np.array(pe, dtype=np.int32)
|
| 286 |
+
|
| 287 |
+
# Merge
|
| 288 |
+
pv, pe = merge_vertices_iterative(pv, pe, end=MERGE_END)
|
| 289 |
+
|
| 290 |
+
# Snap to point cloud
|
| 291 |
+
xyz_norm = sample_dict["xyz_norm"]
|
| 292 |
+
mask = sample_dict["mask"]
|
| 293 |
+
cid = sample_dict["class_id"]
|
| 294 |
+
xyz_world = xyz_norm[mask] * scale + center
|
| 295 |
+
cid_valid = cid[mask]
|
| 296 |
+
pv = snap_to_point_cloud(pv, xyz_world, cid_valid, snap_radius=SNAP_RADIUS)
|
| 297 |
+
|
| 298 |
+
# Horizontal snap
|
| 299 |
+
pv = snap_horizontal(pv, pe)
|
| 300 |
+
|
| 301 |
+
if len(pv) < 2 or len(pe) < 1:
|
| 302 |
+
if return_stats:
|
| 303 |
+
return *empty_solution(), stats
|
| 304 |
+
return empty_solution()
|
| 305 |
+
|
| 306 |
+
edges = [(int(a), int(b)) for a, b in pe]
|
| 307 |
+
if return_stats:
|
| 308 |
+
return pv, edges, stats
|
| 309 |
+
return pv, edges
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
# ---------------------------------------------------------------------------
|
| 313 |
+
# Main
|
| 314 |
+
# ---------------------------------------------------------------------------
|
| 315 |
+
|
| 316 |
+
if __name__ == "__main__":
|
| 317 |
+
t_start = time.time()
|
| 318 |
+
|
| 319 |
+
# Load params
|
| 320 |
+
param_path = Path("params.json")
|
| 321 |
+
with param_path.open() as f:
|
| 322 |
+
params = json.load(f)
|
| 323 |
+
print(f"Competition: {params.get('competition_id', '?')}")
|
| 324 |
+
print(f"Dataset: {params.get('dataset', '?')}")
|
| 325 |
+
|
| 326 |
+
# Load test data
|
| 327 |
+
data_path = Path("/tmp/data")
|
| 328 |
+
if not data_path.exists():
|
| 329 |
+
from huggingface_hub import snapshot_download
|
| 330 |
+
snapshot_download(
|
| 331 |
+
repo_id=params["dataset"],
|
| 332 |
+
local_dir="/tmp/data",
|
| 333 |
+
repo_type="dataset",
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
from datasets import load_dataset
|
| 337 |
+
data_files = {
|
| 338 |
+
"validation": [str(p) for p in data_path.rglob("*public*/**/*.tar")],
|
| 339 |
+
"test": [str(p) for p in data_path.rglob("*private*/**/*.tar")],
|
| 340 |
+
}
|
| 341 |
+
print(f"Data files: {data_files}")
|
| 342 |
+
dataset = load_dataset(
|
| 343 |
+
str(data_path / "hoho22k_2026_test_x_anon.py"),
|
| 344 |
+
data_files=data_files,
|
| 345 |
+
trust_remote_code=True,
|
| 346 |
+
writer_batch_size=100,
|
| 347 |
+
)
|
| 348 |
+
print(f"Loaded: {dataset}")
|
| 349 |
+
|
| 350 |
+
# Load model
|
| 351 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 352 |
+
print(f"Device: {device}")
|
| 353 |
+
checkpoint_path = SCRIPT_DIR / "checkpoint_8192.pt"
|
| 354 |
+
model = load_model(checkpoint_path, device)
|
| 355 |
+
print(f"Model loaded: {sum(p.numel() for p in model.parameters()):,} params")
|
| 356 |
+
|
| 357 |
+
# Edge classifier (None if disabled or missing)
|
| 358 |
+
edge_model = None
|
| 359 |
+
if AUGMENT_ENABLED:
|
| 360 |
+
if EDGE_CLASSIFIER_PATH.exists():
|
| 361 |
+
try:
|
| 362 |
+
edge_model = ec.load_pnet_class(str(EDGE_CLASSIFIER_PATH), device=device)
|
| 363 |
+
print(f"Edge classifier loaded: "
|
| 364 |
+
f"{sum(p.numel() for p in edge_model.parameters()):,} params "
|
| 365 |
+
f"(augment threshold {AUGMENT_THRESHOLD})")
|
| 366 |
+
except Exception as e:
|
| 367 |
+
print(f" Edge classifier load failed, augment disabled: {e}")
|
| 368 |
+
else:
|
| 369 |
+
print(f" Edge classifier checkpoint not found at {EDGE_CLASSIFIER_PATH}; augment disabled")
|
| 370 |
+
|
| 371 |
+
# Vertex classifier (None if disabled or missing)
|
| 372 |
+
vertex_model = None
|
| 373 |
+
if VERTEX_AUGMENT_ENABLED:
|
| 374 |
+
if VERTEX_REFINER_PATH.exists():
|
| 375 |
+
try:
|
| 376 |
+
vertex_model = vr.load_vertex_model(str(VERTEX_REFINER_PATH), device=device)
|
| 377 |
+
print(f"Vertex classifier loaded: "
|
| 378 |
+
f"{sum(p.numel() for p in vertex_model.parameters()):,} params "
|
| 379 |
+
f"(augment threshold {VERTEX_AUGMENT_THRESHOLD})")
|
| 380 |
+
except Exception as e:
|
| 381 |
+
print(f" Vertex classifier load failed, augment disabled: {e}")
|
| 382 |
+
else:
|
| 383 |
+
print(f" Vertex classifier checkpoint not found at {VERTEX_REFINER_PATH}; augment disabled")
|
| 384 |
+
|
| 385 |
+
# Point fusion config
|
| 386 |
+
cfg = FuserConfig()
|
| 387 |
+
rng = np.random.RandomState(2718)
|
| 388 |
+
aug_rng = np.random.RandomState(31415)
|
| 389 |
+
|
| 390 |
+
# Process all samples
|
| 391 |
+
solution = []
|
| 392 |
+
total_samples = sum(len(dataset[s]) for s in dataset)
|
| 393 |
+
processed = 0
|
| 394 |
+
|
| 395 |
+
for subset_name in dataset:
|
| 396 |
+
print(f"\nProcessing {subset_name} ({len(dataset[subset_name])} samples)...")
|
| 397 |
+
|
| 398 |
+
for sample in tqdm(dataset[subset_name], desc=subset_name):
|
| 399 |
+
order_id = sample["order_id"]
|
| 400 |
+
|
| 401 |
+
# Fuse + sample (learned pipeline)
|
| 402 |
+
fused = fuse_and_sample(sample, cfg, rng)
|
| 403 |
+
if fused is None:
|
| 404 |
+
pred_v, pred_e = empty_solution()
|
| 405 |
+
else:
|
| 406 |
+
try:
|
| 407 |
+
pred_v, pred_e = predict_sample(fused, model, device)
|
| 408 |
+
except Exception as e:
|
| 409 |
+
print(f" Predict failed for {order_id}: {e}")
|
| 410 |
+
pred_v, pred_e = empty_solution()
|
| 411 |
+
|
| 412 |
+
solution.append({
|
| 413 |
+
"order_id": order_id,
|
| 414 |
+
"wf_vertices": pred_v.tolist() if isinstance(pred_v, np.ndarray) else pred_v,
|
| 415 |
+
"wf_edges": [(int(a), int(b)) for a, b in pred_e],
|
| 416 |
+
})
|
| 417 |
+
processed += 1
|
| 418 |
+
|
| 419 |
+
if processed % 50 == 0:
|
| 420 |
+
elapsed = time.time() - t_start
|
| 421 |
+
rate = elapsed / processed
|
| 422 |
+
remaining = (total_samples - processed) * rate
|
| 423 |
+
print(f" [{processed}/{total_samples}] "
|
| 424 |
+
f"{elapsed:.0f}s elapsed, ~{remaining:.0f}s remaining")
|
| 425 |
+
|
| 426 |
+
# Save
|
| 427 |
+
with open("submission.json", "w") as f:
|
| 428 |
+
json.dump(solution, f)
|
| 429 |
+
|
| 430 |
+
elapsed = time.time() - t_start
|
| 431 |
+
print(f"\nDone. {processed} samples in {elapsed:.0f}s ({elapsed/max(processed,1):.1f}s/sample)")
|
| 432 |
+
print(f"Saved submission.json ({len(solution)} entries)")
|
solution.py
ADDED
|
@@ -0,0 +1,1210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import io
|
| 2 |
+
import tempfile
|
| 3 |
+
import zipfile
|
| 4 |
+
from collections import defaultdict
|
| 5 |
+
from typing import Tuple, List, Dict
|
| 6 |
+
import cv2
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pycolmap
|
| 9 |
+
from PIL import Image as PImage
|
| 10 |
+
from scipy.spatial.distance import cdist
|
| 11 |
+
from sklearn.cluster import DBSCAN
|
| 12 |
+
|
| 13 |
+
from hoho2025.color_mappings import ade20k_color_mapping, gestalt_color_mapping
|
| 14 |
+
|
| 15 |
+
def empty_solution():
|
| 16 |
+
'''Return a minimal valid solution, i.e. 2 vertices and 1 edge.'''
|
| 17 |
+
return np.zeros((2,3)), [(0, 1)]
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def read_colmap_rec(colmap_data):
|
| 21 |
+
with tempfile.TemporaryDirectory() as tmpdir:
|
| 22 |
+
with zipfile.ZipFile(io.BytesIO(colmap_data), "r") as zf:
|
| 23 |
+
zf.extractall(tmpdir)
|
| 24 |
+
rec = pycolmap.Reconstruction(tmpdir)
|
| 25 |
+
return rec
|
| 26 |
+
|
| 27 |
+
def _cam_matrix_from_image(img):
|
| 28 |
+
"""Safely extracts R and t from any pycolmap version."""
|
| 29 |
+
cfW = img.cam_from_world
|
| 30 |
+
if callable(cfW):
|
| 31 |
+
cfW = cfW()
|
| 32 |
+
try:
|
| 33 |
+
R = cfW.rotation.matrix()
|
| 34 |
+
t = cfW.translation
|
| 35 |
+
except AttributeError:
|
| 36 |
+
M = np.array(cfW.matrix())
|
| 37 |
+
R, t = M[:, :3], M[:, 3]
|
| 38 |
+
return np.array(R, dtype=np.float64), np.array(t, dtype=np.float64)
|
| 39 |
+
|
| 40 |
+
def convert_entry_to_human_readable(entry):
|
| 41 |
+
out = {}
|
| 42 |
+
for k, v in entry.items():
|
| 43 |
+
if 'colmap' in k and k != 'pose_only_in_colmap':
|
| 44 |
+
out['colmap_binary'] = read_colmap_rec(v)
|
| 45 |
+
elif k in ['wf_vertices', 'wf_edges', 'K', 'R', 't', 'depth']:
|
| 46 |
+
try:
|
| 47 |
+
out[k] = np.array(v)
|
| 48 |
+
except ValueError as e:
|
| 49 |
+
if "inhomogeneous" in str(e):
|
| 50 |
+
out[k] = v
|
| 51 |
+
else:
|
| 52 |
+
raise e
|
| 53 |
+
else:
|
| 54 |
+
out[k] = v
|
| 55 |
+
out['__key__'] = entry.get('order_id', 'unknown_id')
|
| 56 |
+
return out
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def get_house_mask(ade20k_seg):
|
| 60 |
+
"""
|
| 61 |
+
Get a mask of the house in the ADE20K segmentation map.
|
| 62 |
+
"""
|
| 63 |
+
house_classes_ade20k = [
|
| 64 |
+
'wall',
|
| 65 |
+
'house',
|
| 66 |
+
'building;edifice',
|
| 67 |
+
'door;double;door',
|
| 68 |
+
'windowpane;window',
|
| 69 |
+
]
|
| 70 |
+
np_seg = np.array(ade20k_seg)
|
| 71 |
+
full_mask = np.zeros(np_seg.shape[:2], dtype=np.uint8)
|
| 72 |
+
for c in house_classes_ade20k:
|
| 73 |
+
color = np.array(ade20k_color_mapping[c])
|
| 74 |
+
mask = cv2.inRange(np_seg, color-0.5, color+0.5)
|
| 75 |
+
full_mask = np.logical_or(full_mask, mask)
|
| 76 |
+
return full_mask
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def point_to_segment_dist(pt, seg_p1, seg_p2):
|
| 80 |
+
"""
|
| 81 |
+
Computes the Euclidean distance from pt to the line segment p1->p2.
|
| 82 |
+
pt, seg_p1, seg_p2: (x, y) as np.ndarray
|
| 83 |
+
"""
|
| 84 |
+
if np.allclose(seg_p1, seg_p2):
|
| 85 |
+
return np.linalg.norm(pt - seg_p1)
|
| 86 |
+
seg_vec = seg_p2 - seg_p1
|
| 87 |
+
pt_vec = pt - seg_p1
|
| 88 |
+
seg_len2 = seg_vec.dot(seg_vec)
|
| 89 |
+
t = max(0, min(1, pt_vec.dot(seg_vec)/seg_len2))
|
| 90 |
+
proj = seg_p1 + t*seg_vec
|
| 91 |
+
return np.linalg.norm(pt - proj)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def project_and_filter_colmap_points(
|
| 95 |
+
colmap_image: pycolmap.Image,
|
| 96 |
+
colmap_points3D: Dict[int, 'pycolmap.Point3D'],
|
| 97 |
+
gest_seg_np: np.ndarray,
|
| 98 |
+
target_seg_colors: Dict[str, np.ndarray],
|
| 99 |
+
img_height: int,
|
| 100 |
+
img_width: int,
|
| 101 |
+
patch_size: int = 25
|
| 102 |
+
) -> Dict[str, List[np.ndarray]]:
|
| 103 |
+
"""
|
| 104 |
+
Project COLMAP 3D points to 2D and filter them based on Gestalt segmentation.
|
| 105 |
+
|
| 106 |
+
Returns:
|
| 107 |
+
Dict mapping class names to lists of 2D points that fall in those segmentation regions.
|
| 108 |
+
"""
|
| 109 |
+
projected_points_by_class = {}
|
| 110 |
+
|
| 111 |
+
for point2D in colmap_image.points2D:
|
| 112 |
+
if point2D.has_point3D():
|
| 113 |
+
u, v = point2D.xy[0], point2D.xy[1]
|
| 114 |
+
|
| 115 |
+
if 0 <= u < img_width and 0 <= v < img_height:
|
| 116 |
+
half_patch = patch_size // 2
|
| 117 |
+
|
| 118 |
+
v_start = max(0, int(round(v)) - half_patch)
|
| 119 |
+
v_end = min(img_height, int(round(v)) + half_patch + 1)
|
| 120 |
+
u_start = max(0, int(round(u)) - half_patch)
|
| 121 |
+
u_end = min(img_width, int(round(u)) + half_patch + 1)
|
| 122 |
+
|
| 123 |
+
seg_color_patch = gest_seg_np[v_start:v_end, u_start:u_end]
|
| 124 |
+
|
| 125 |
+
for class_name, target_color in target_seg_colors.items():
|
| 126 |
+
patch_matches = np.any(np.all(np.abs(seg_color_patch - target_color) <= 1.0, axis=-1))
|
| 127 |
+
if patch_matches:
|
| 128 |
+
if class_name not in projected_points_by_class:
|
| 129 |
+
projected_points_by_class[class_name] = []
|
| 130 |
+
projected_points_by_class[class_name].append(np.array([u, v]))
|
| 131 |
+
|
| 132 |
+
return projected_points_by_class
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def cluster_projected_points_to_vertices(
|
| 136 |
+
projected_points: List[np.ndarray],
|
| 137 |
+
eps: float,
|
| 138 |
+
min_samples: int
|
| 139 |
+
) -> List[np.ndarray]:
|
| 140 |
+
"""
|
| 141 |
+
Cluster projected 2D points using DBSCAN to find vertex candidates.
|
| 142 |
+
|
| 143 |
+
Returns:
|
| 144 |
+
List of cluster centroids as vertex locations.
|
| 145 |
+
"""
|
| 146 |
+
if len(projected_points) < min_samples:
|
| 147 |
+
return []
|
| 148 |
+
|
| 149 |
+
X = np.array(projected_points)
|
| 150 |
+
|
| 151 |
+
db = DBSCAN(eps=eps, min_samples=min_samples)
|
| 152 |
+
labels = db.fit_predict(X)
|
| 153 |
+
|
| 154 |
+
vertex_centroids = []
|
| 155 |
+
unique_labels = set(labels)
|
| 156 |
+
if -1 in unique_labels:
|
| 157 |
+
unique_labels.remove(-1)
|
| 158 |
+
|
| 159 |
+
for label in unique_labels:
|
| 160 |
+
class_member_mask = (labels == label)
|
| 161 |
+
cluster_points = X[class_member_mask]
|
| 162 |
+
centroid = np.mean(cluster_points, axis=0)
|
| 163 |
+
vertex_centroids.append(centroid)
|
| 164 |
+
|
| 165 |
+
return vertex_centroids
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def detect_point_class(
|
| 169 |
+
gest_seg_np: np.ndarray,
|
| 170 |
+
class_name: str,
|
| 171 |
+
gestalt_color_mapping: Dict[str, Tuple[int, int, int]]
|
| 172 |
+
) -> List[Dict[str, any]]:
|
| 173 |
+
"""
|
| 174 |
+
Detect point-like features (vertices) for a given class in the gestalt segmentation.
|
| 175 |
+
|
| 176 |
+
Args:
|
| 177 |
+
gest_seg_np: Gestalt segmentation image as numpy array
|
| 178 |
+
class_name: Name of the class to detect (e.g., 'apex', 'eave_end_point')
|
| 179 |
+
gestalt_color_mapping: Dictionary mapping class names to RGB colors
|
| 180 |
+
|
| 181 |
+
Returns:
|
| 182 |
+
List of vertex dictionaries with 'xy' coordinates and 'type'
|
| 183 |
+
"""
|
| 184 |
+
vertices = []
|
| 185 |
+
|
| 186 |
+
if class_name not in gestalt_color_mapping:
|
| 187 |
+
return vertices
|
| 188 |
+
|
| 189 |
+
class_color = np.array(gestalt_color_mapping[class_name])
|
| 190 |
+
class_mask = cv2.inRange(gest_seg_np, class_color-0.5, class_color+0.5)
|
| 191 |
+
|
| 192 |
+
if class_mask.sum() > 0:
|
| 193 |
+
output = cv2.connectedComponentsWithStats(class_mask, 8, cv2.CV_32S)
|
| 194 |
+
(numLabels, labels, stats, centroids) = output
|
| 195 |
+
stats, centroids = stats[1:], centroids[1:]
|
| 196 |
+
|
| 197 |
+
for i in range(numLabels-1):
|
| 198 |
+
vert = {"xy": centroids[i], "type": class_name}
|
| 199 |
+
vertices.append(vert)
|
| 200 |
+
|
| 201 |
+
return vertices
|
| 202 |
+
|
| 203 |
+
def verify_edge_mask(pt1, pt2, semantic_mask, min_overlap=0.4):
|
| 204 |
+
"""
|
| 205 |
+
Draws a line between two points and verifies that it physically lies on top of
|
| 206 |
+
the neural network's semantic mask. Kills lines that cross empty space.
|
| 207 |
+
"""
|
| 208 |
+
canvas = np.zeros_like(semantic_mask)
|
| 209 |
+
|
| 210 |
+
pt1_int = (int(round(pt1[0])), int(round(pt1[1])))
|
| 211 |
+
pt2_int = (int(round(pt2[0])), int(round(pt2[1])))
|
| 212 |
+
cv2.line(canvas, pt1_int, pt2_int, 255, 3)
|
| 213 |
+
|
| 214 |
+
line_pixels = cv2.countNonZero(canvas)
|
| 215 |
+
if line_pixels == 0:
|
| 216 |
+
return False
|
| 217 |
+
|
| 218 |
+
overlap = cv2.bitwise_and(canvas, semantic_mask)
|
| 219 |
+
overlap_pixels = cv2.countNonZero(overlap)
|
| 220 |
+
|
| 221 |
+
return (overlap_pixels / line_pixels) >= min_overlap
|
| 222 |
+
|
| 223 |
+
def get_vertices_and_edges_from_segmentation(
|
| 224 |
+
gest_seg_np: np.ndarray,
|
| 225 |
+
point_class_names: List[str],
|
| 226 |
+
edge_class_names: List[str],
|
| 227 |
+
colmap_image: pycolmap.Image = None,
|
| 228 |
+
colmap_points3D: Dict[int, 'pycolmap.Point3D'] = None,
|
| 229 |
+
edge_th: float = 25.0,
|
| 230 |
+
min_3d_points_for_vertex: int = 1,
|
| 231 |
+
vertex_cluster_eps: float = 5.0,
|
| 232 |
+
use_colmap_for_vertices: bool = True,
|
| 233 |
+
patch_size: int = 25
|
| 234 |
+
) -> Tuple[List[dict], List[Tuple[int, int]]]:
|
| 235 |
+
"""
|
| 236 |
+
Identify apex and eave-end vertices, then detect lines for eave/ridge/rake/valley.
|
| 237 |
+
Now enhanced with COLMAP 3D point projection and DBSCAN clustering for vertex detection.
|
| 238 |
+
"""
|
| 239 |
+
if not isinstance(gest_seg_np, np.ndarray):
|
| 240 |
+
gest_seg_np = np.array(gest_seg_np)
|
| 241 |
+
|
| 242 |
+
vertices = []
|
| 243 |
+
H, W = gest_seg_np.shape[:2]
|
| 244 |
+
|
| 245 |
+
colmap_width = colmap_image.camera.width if colmap_image is not None else None
|
| 246 |
+
colmap_height = colmap_image.camera.height if colmap_image is not None else None
|
| 247 |
+
|
| 248 |
+
if colmap_width != W or colmap_height != H:
|
| 249 |
+
print(f"Warning: colmap image size {colmap_width}x{colmap_height} does not match gestalt segmentation size {W}x{H}")
|
| 250 |
+
|
| 251 |
+
if use_colmap_for_vertices and colmap_image is not None and colmap_points3D is not None:
|
| 252 |
+
try:
|
| 253 |
+
target_seg_colors = {}
|
| 254 |
+
for class_name in point_class_names:
|
| 255 |
+
if class_name in gestalt_color_mapping:
|
| 256 |
+
target_seg_colors[class_name] = np.array(gestalt_color_mapping[class_name])
|
| 257 |
+
|
| 258 |
+
projected_points_by_class = project_and_filter_colmap_points(
|
| 259 |
+
colmap_image, colmap_points3D, gest_seg_np, target_seg_colors, H, W, patch_size=patch_size
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
for class_name in point_class_names:
|
| 263 |
+
if class_name in projected_points_by_class:
|
| 264 |
+
points_for_class = projected_points_by_class[class_name]
|
| 265 |
+
class_centroids = cluster_projected_points_to_vertices(
|
| 266 |
+
points_for_class, eps=vertex_cluster_eps, min_samples=min_3d_points_for_vertex
|
| 267 |
+
)
|
| 268 |
+
|
| 269 |
+
for centroid in class_centroids:
|
| 270 |
+
vert = {"xy": centroid, "type": class_name}
|
| 271 |
+
vertices.append(vert)
|
| 272 |
+
|
| 273 |
+
print(f"Found {len(vertices)} vertices using COLMAP projection and clustering")
|
| 274 |
+
except Exception as e:
|
| 275 |
+
print(f"Error using COLMAP for vertex detection: {e}")
|
| 276 |
+
vertices = []
|
| 277 |
+
|
| 278 |
+
if len(vertices) < 2:
|
| 279 |
+
print("Using fallback method for vertex detection")
|
| 280 |
+
vertices = []
|
| 281 |
+
|
| 282 |
+
for class_name in point_class_names:
|
| 283 |
+
point_vertices = detect_point_class(gest_seg_np, class_name, gestalt_color_mapping)
|
| 284 |
+
vertices.extend(point_vertices)
|
| 285 |
+
|
| 286 |
+
structural_pts = []
|
| 287 |
+
structural_idx_map = []
|
| 288 |
+
for idx, v in enumerate(vertices):
|
| 289 |
+
structural_pts.append(v['xy'])
|
| 290 |
+
structural_idx_map.append(idx)
|
| 291 |
+
structural_pts = np.array(structural_pts)
|
| 292 |
+
|
| 293 |
+
connections = []
|
| 294 |
+
for edge_class in edge_class_names:
|
| 295 |
+
if edge_class in ['ridge']:
|
| 296 |
+
allowed_types = ['apex']
|
| 297 |
+
elif edge_class in ['eave', 'flashing', 'step_flashing']:
|
| 298 |
+
allowed_types = ['eave_end_point', 'flashing_end_point']
|
| 299 |
+
else:
|
| 300 |
+
allowed_types = ['apex', 'eave_end_point', 'flashing_end_point']
|
| 301 |
+
|
| 302 |
+
allowed_pts = []
|
| 303 |
+
allowed_idx_map = []
|
| 304 |
+
for orig_idx, v in enumerate(vertices):
|
| 305 |
+
if v['type'] in allowed_types:
|
| 306 |
+
allowed_pts.append(v['xy'])
|
| 307 |
+
allowed_idx_map.append(orig_idx)
|
| 308 |
+
|
| 309 |
+
allowed_pts = np.array(allowed_pts)
|
| 310 |
+
if len(allowed_pts) < 2:
|
| 311 |
+
continue
|
| 312 |
+
|
| 313 |
+
edge_color = np.array(gestalt_color_mapping[edge_class])
|
| 314 |
+
mask_raw = cv2.inRange(gest_seg_np, edge_color-0.5, edge_color+0.5)
|
| 315 |
+
kernel = np.ones((5, 5), np.uint8)
|
| 316 |
+
mask = cv2.morphologyEx(mask_raw, cv2.MORPH_CLOSE, kernel)
|
| 317 |
+
if mask.sum() == 0:
|
| 318 |
+
continue
|
| 319 |
+
|
| 320 |
+
output = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S)
|
| 321 |
+
(numLabels, labels, stats, centroids) = output
|
| 322 |
+
stats, centroids = stats[1:], centroids[1:]
|
| 323 |
+
label_indices = range(1, numLabels)
|
| 324 |
+
|
| 325 |
+
for lbl in label_indices:
|
| 326 |
+
mask_i = np.zeros_like(mask)
|
| 327 |
+
mask_i[labels == lbl] = 255
|
| 328 |
+
|
| 329 |
+
lines = cv2.HoughLinesP(mask_i, rho=1, theta=np.pi/180, threshold=15, minLineLength=8, maxLineGap=20)
|
| 330 |
+
|
| 331 |
+
if lines is None:
|
| 332 |
+
continue
|
| 333 |
+
|
| 334 |
+
for line in lines:
|
| 335 |
+
x1, y1, x2, y2 = line[0]
|
| 336 |
+
p1 = np.array([x1, y1], dtype=np.float32)
|
| 337 |
+
p2 = np.array([x2, y2], dtype=np.float32)
|
| 338 |
+
|
| 339 |
+
if len(allowed_pts) < 2:
|
| 340 |
+
continue
|
| 341 |
+
|
| 342 |
+
dists = np.array([
|
| 343 |
+
point_to_segment_dist(allowed_pts[i], p1, p2)
|
| 344 |
+
for i in range(len(allowed_pts))
|
| 345 |
+
])
|
| 346 |
+
|
| 347 |
+
near_mask = (dists <= edge_th)
|
| 348 |
+
near_indices = np.where(near_mask)[0]
|
| 349 |
+
if len(near_indices) < 2:
|
| 350 |
+
continue
|
| 351 |
+
|
| 352 |
+
for i in range(len(near_indices)):
|
| 353 |
+
for j in range(i+1, len(near_indices)):
|
| 354 |
+
idx_a = near_indices[i]
|
| 355 |
+
idx_b = near_indices[j]
|
| 356 |
+
|
| 357 |
+
vA = allowed_idx_map[idx_a]
|
| 358 |
+
vB = allowed_idx_map[idx_b]
|
| 359 |
+
|
| 360 |
+
conn = tuple(sorted((vA, vB)))
|
| 361 |
+
if conn not in connections:
|
| 362 |
+
is_valid_edge = verify_edge_mask(allowed_pts[idx_a], allowed_pts[idx_b], mask, min_overlap=0.3)
|
| 363 |
+
|
| 364 |
+
if is_valid_edge:
|
| 365 |
+
connections.append(conn)
|
| 366 |
+
|
| 367 |
+
return vertices, connections
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def get_uv_depth(vertices: List[dict],
|
| 371 |
+
depth_fitted: np.ndarray,
|
| 372 |
+
sparse_depth: np.ndarray,
|
| 373 |
+
search_radius: int = 10) -> Tuple[np.ndarray, np.ndarray]:
|
| 374 |
+
"""For each vertex return its (u, v) and a depth value.
|
| 375 |
+
|
| 376 |
+
Uses the nearest valid sparse-depth pixel within search_radius of the vertex;
|
| 377 |
+
falls back to the dense depth_fitted value when no sparse depth is available.
|
| 378 |
+
"""
|
| 379 |
+
uv = np.array([vert['xy'] for vert in vertices], dtype=np.float32)
|
| 380 |
+
|
| 381 |
+
uv_int = np.round(uv).astype(np.int32)
|
| 382 |
+
H, W = depth_fitted.shape[:2]
|
| 383 |
+
uv_int[:, 0] = np.clip(uv_int[:, 0], 0, W - 1)
|
| 384 |
+
uv_int[:, 1] = np.clip(uv_int[:, 1], 0, H - 1)
|
| 385 |
+
|
| 386 |
+
vertex_depth = np.zeros(len(vertices), dtype=np.float32)
|
| 387 |
+
dense_count = 0
|
| 388 |
+
|
| 389 |
+
for i, (x_i, y_i) in enumerate(uv_int):
|
| 390 |
+
x0 = max(0, x_i - search_radius)
|
| 391 |
+
x1 = min(W, x_i + search_radius + 1)
|
| 392 |
+
y0 = max(0, y_i - search_radius)
|
| 393 |
+
y1 = min(H, y_i + search_radius + 1)
|
| 394 |
+
|
| 395 |
+
region = sparse_depth[y0:y1, x0:x1]
|
| 396 |
+
valid_y, valid_x = np.where(region > 0)
|
| 397 |
+
|
| 398 |
+
if valid_y.size > 0:
|
| 399 |
+
global_x = x0 + valid_x
|
| 400 |
+
global_y = y0 + valid_y
|
| 401 |
+
dist_sq = (global_x - x_i)**2 + (global_y - y_i)**2
|
| 402 |
+
min_idx = np.argmin(dist_sq)
|
| 403 |
+
vertex_depth[i] = region[valid_y[min_idx], valid_x[min_idx]]
|
| 404 |
+
else:
|
| 405 |
+
vertex_depth[i] = depth_fitted[y_i, x_i]
|
| 406 |
+
dense_count += 1
|
| 407 |
+
return uv, vertex_depth
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def project_vertices_to_3d(uv: np.ndarray, depth_vert: np.ndarray, col_img: pycolmap.Image, colmap_rec: pycolmap.Reconstruction) -> np.ndarray:
|
| 412 |
+
xy_local = np.ones((len(uv), 3))
|
| 413 |
+
|
| 414 |
+
try:
|
| 415 |
+
K = col_img.camera.calibration_matrix()
|
| 416 |
+
except AttributeError:
|
| 417 |
+
K = colmap_rec.cameras[col_img.camera_id].calibration_matrix()
|
| 418 |
+
|
| 419 |
+
xy_local[:, 0] = (uv[:, 0] - K[0, 2]) / K[0, 0]
|
| 420 |
+
xy_local[:, 1] = (uv[:, 1] - K[1, 2]) / K[1, 1]
|
| 421 |
+
vertices_3d_local = xy_local * depth_vert[...,None]
|
| 422 |
+
|
| 423 |
+
R, t = _cam_matrix_from_image(col_img)
|
| 424 |
+
world_to_cam = np.eye(4)
|
| 425 |
+
world_to_cam[:3, :3] = R
|
| 426 |
+
world_to_cam[:3, 3] = t
|
| 427 |
+
cam_to_world = np.linalg.inv(world_to_cam)
|
| 428 |
+
|
| 429 |
+
vertices_3d_homogeneous = cv2.convertPointsToHomogeneous(vertices_3d_local)
|
| 430 |
+
vertices_3d = cv2.transform(vertices_3d_homogeneous, cam_to_world)
|
| 431 |
+
vertices_3d = cv2.convertPointsFromHomogeneous(vertices_3d).reshape(-1, 3)
|
| 432 |
+
return vertices_3d
|
| 433 |
+
|
| 434 |
+
|
| 435 |
+
def create_3d_wireframe_single_image(vertices: List[dict],
|
| 436 |
+
connections: List[Tuple[int, int]],
|
| 437 |
+
depth: PImage.Image,
|
| 438 |
+
colmap_rec: pycolmap.Reconstruction,
|
| 439 |
+
img_id: str,
|
| 440 |
+
ade_seg: PImage.Image) -> np.ndarray:
|
| 441 |
+
"""Lift one image view's 2D vertices to 3D world coordinates.
|
| 442 |
+
|
| 443 |
+
Fits the dense depth to the sparse COLMAP depth, reads a depth per vertex,
|
| 444 |
+
and back-projects. Returns an empty (0, 3) array if there is no sparse depth.
|
| 445 |
+
"""
|
| 446 |
+
if (len(vertices) < 2) or (len(connections) < 1):
|
| 447 |
+
print(f'Warning: create_3d_wireframe_single_image called with insufficient vertices/connections for image {img_id}')
|
| 448 |
+
return np.empty((0, 3))
|
| 449 |
+
|
| 450 |
+
depth_fitted, depth_sparse, found_sparse, col_img = get_fitted_dense_depth(
|
| 451 |
+
depth, colmap_rec, img_id, ade_seg
|
| 452 |
+
)
|
| 453 |
+
if not found_sparse or col_img is None:
|
| 454 |
+
return np.empty((0, 3))
|
| 455 |
+
|
| 456 |
+
uv, depth_vert = get_uv_depth(vertices, depth_fitted, depth_sparse, search_radius=25)
|
| 457 |
+
vertices_3d = project_vertices_to_3d(uv, depth_vert, col_img, colmap_rec)
|
| 458 |
+
return vertices_3d
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
def merge_vertices_3d(vert_edge_per_image, point_class_names: List[str], th=0.5):
|
| 462 |
+
'''Merge vertices that are close in 3D space and of the same type.'''
|
| 463 |
+
all_3d_vertices = []
|
| 464 |
+
connections_3d = []
|
| 465 |
+
all_indexes = []
|
| 466 |
+
cur_start = 0
|
| 467 |
+
types = []
|
| 468 |
+
|
| 469 |
+
type_to_id = {class_name: idx for idx, class_name in enumerate(point_class_names)}
|
| 470 |
+
|
| 471 |
+
for cimg_idx, (vertices, connections, vertices_3d) in vert_edge_per_image.items():
|
| 472 |
+
vertex_type_ids = []
|
| 473 |
+
for v in vertices:
|
| 474 |
+
vertex_type = v['type']
|
| 475 |
+
type_id = type_to_id.get(vertex_type, -1)
|
| 476 |
+
vertex_type_ids.append(type_id)
|
| 477 |
+
|
| 478 |
+
types += vertex_type_ids
|
| 479 |
+
all_3d_vertices.append(vertices_3d)
|
| 480 |
+
connections_3d+=[(x+cur_start,y+cur_start) for (x,y) in connections]
|
| 481 |
+
cur_start+=len(vertices_3d)
|
| 482 |
+
all_3d_vertices = np.concatenate(all_3d_vertices, axis=0)
|
| 483 |
+
|
| 484 |
+
distmat = cdist(all_3d_vertices, all_3d_vertices)
|
| 485 |
+
types = np.array(types).reshape(-1,1)
|
| 486 |
+
same_types = cdist(types, types)
|
| 487 |
+
|
| 488 |
+
# Merge vertices that are both close in space and of the same type.
|
| 489 |
+
mask_to_merge = (distmat <= th) & (same_types==0)
|
| 490 |
+
new_vertices = []
|
| 491 |
+
new_connections = []
|
| 492 |
+
|
| 493 |
+
to_merge = sorted(list(set([tuple(a.nonzero()[0].tolist()) for a in mask_to_merge])))
|
| 494 |
+
|
| 495 |
+
# Transitive grouping: union overlapping merge-sets into connected groups.
|
| 496 |
+
to_merge_final = defaultdict(list)
|
| 497 |
+
for i in range(len(all_3d_vertices)):
|
| 498 |
+
for j in to_merge:
|
| 499 |
+
if i in j:
|
| 500 |
+
to_merge_final[i]+=j
|
| 501 |
+
|
| 502 |
+
for k, v in to_merge_final.items():
|
| 503 |
+
to_merge_final[k] = list(set(v))
|
| 504 |
+
|
| 505 |
+
already_there = set()
|
| 506 |
+
merged = []
|
| 507 |
+
for k, v in to_merge_final.items():
|
| 508 |
+
if k in already_there:
|
| 509 |
+
continue
|
| 510 |
+
merged.append(v)
|
| 511 |
+
for vv in v:
|
| 512 |
+
already_there.add(vv)
|
| 513 |
+
|
| 514 |
+
old_idx_to_new = {}
|
| 515 |
+
count=0
|
| 516 |
+
for idxs in merged:
|
| 517 |
+
new_vertices.append(all_3d_vertices[idxs].mean(axis=0))
|
| 518 |
+
for idx in idxs:
|
| 519 |
+
old_idx_to_new[idx] = count
|
| 520 |
+
count +=1
|
| 521 |
+
new_vertices=np.array(new_vertices)
|
| 522 |
+
|
| 523 |
+
for conn in connections_3d:
|
| 524 |
+
new_con = sorted((old_idx_to_new[conn[0]], old_idx_to_new[conn[1]]))
|
| 525 |
+
if new_con[0] == new_con[1]:
|
| 526 |
+
continue
|
| 527 |
+
if new_con not in new_connections:
|
| 528 |
+
new_connections.append(new_con)
|
| 529 |
+
return new_vertices, new_connections
|
| 530 |
+
|
| 531 |
+
|
| 532 |
+
def prune_not_connected(all_3d_vertices, connections_3d, keep_largest=True):
|
| 533 |
+
"""
|
| 534 |
+
Prune vertices not connected to anything. If keep_largest=True, also
|
| 535 |
+
keep only the largest connected component in the graph.
|
| 536 |
+
"""
|
| 537 |
+
if len(all_3d_vertices) == 0:
|
| 538 |
+
return np.empty((0, 3)), []
|
| 539 |
+
|
| 540 |
+
adj = defaultdict(set)
|
| 541 |
+
for (i, j) in connections_3d:
|
| 542 |
+
adj[i].add(j)
|
| 543 |
+
adj[j].add(i)
|
| 544 |
+
|
| 545 |
+
used_idxs = set()
|
| 546 |
+
for (i, j) in connections_3d:
|
| 547 |
+
used_idxs.add(i)
|
| 548 |
+
used_idxs.add(j)
|
| 549 |
+
|
| 550 |
+
if not used_idxs:
|
| 551 |
+
return np.empty((0,3)), []
|
| 552 |
+
|
| 553 |
+
# If we only want to remove truly isolated points, but keep multiple subgraphs:
|
| 554 |
+
if not keep_largest:
|
| 555 |
+
new_map = {}
|
| 556 |
+
used_list = sorted(list(used_idxs))
|
| 557 |
+
for new_id, old_id in enumerate(used_list):
|
| 558 |
+
new_map[old_id] = new_id
|
| 559 |
+
new_vertices = np.array([all_3d_vertices[old_id] for old_id in used_list])
|
| 560 |
+
new_conns = []
|
| 561 |
+
for (i, j) in connections_3d:
|
| 562 |
+
if i in used_idxs and j in used_idxs:
|
| 563 |
+
new_conns.append((new_map[i], new_map[j]))
|
| 564 |
+
return new_vertices, new_conns
|
| 565 |
+
|
| 566 |
+
# Otherwise find the largest connected component:
|
| 567 |
+
visited = set()
|
| 568 |
+
def bfs(start):
|
| 569 |
+
queue = [start]
|
| 570 |
+
comp = []
|
| 571 |
+
visited.add(start)
|
| 572 |
+
while queue:
|
| 573 |
+
cur = queue.pop()
|
| 574 |
+
comp.append(cur)
|
| 575 |
+
for neigh in adj[cur]:
|
| 576 |
+
if neigh not in visited:
|
| 577 |
+
visited.add(neigh)
|
| 578 |
+
queue.append(neigh)
|
| 579 |
+
return comp
|
| 580 |
+
|
| 581 |
+
comps = []
|
| 582 |
+
for idx in used_idxs:
|
| 583 |
+
if idx not in visited:
|
| 584 |
+
c = bfs(idx)
|
| 585 |
+
comps.append(c)
|
| 586 |
+
|
| 587 |
+
comps.sort(key=lambda c: len(c), reverse=True)
|
| 588 |
+
largest = comps[0] if len(comps)>0 else []
|
| 589 |
+
|
| 590 |
+
new_map = {}
|
| 591 |
+
for new_id, old_id in enumerate(largest):
|
| 592 |
+
new_map[old_id] = new_id
|
| 593 |
+
|
| 594 |
+
new_vertices = np.array([all_3d_vertices[old_id] for old_id in largest])
|
| 595 |
+
new_conns = []
|
| 596 |
+
for (i, j) in connections_3d:
|
| 597 |
+
if i in largest and j in largest:
|
| 598 |
+
new_conns.append((new_map[i], new_map[j]))
|
| 599 |
+
|
| 600 |
+
new_conns = list(set([tuple(sorted(c)) for c in new_conns]))
|
| 601 |
+
return new_vertices, new_conns
|
| 602 |
+
|
| 603 |
+
def get_sparse_depth(colmap_rec, img_id_substring, depth):
|
| 604 |
+
H, W = depth.shape
|
| 605 |
+
found_img = None
|
| 606 |
+
for img_id_c, col_img in colmap_rec.images.items():
|
| 607 |
+
if img_id_substring in col_img.name:
|
| 608 |
+
found_img = col_img
|
| 609 |
+
break
|
| 610 |
+
if found_img is None:
|
| 611 |
+
return np.zeros((H, W), dtype=np.float32), False, None
|
| 612 |
+
|
| 613 |
+
points_xyz = []
|
| 614 |
+
for pid, p3D in colmap_rec.points3D.items():
|
| 615 |
+
if found_img.has_point3D(pid):
|
| 616 |
+
points_xyz.append(p3D.xyz)
|
| 617 |
+
if not points_xyz:
|
| 618 |
+
return np.zeros((H, W), dtype=np.float32), False, found_img
|
| 619 |
+
|
| 620 |
+
points_xyz = np.array(points_xyz)
|
| 621 |
+
uv = []
|
| 622 |
+
z_vals = []
|
| 623 |
+
|
| 624 |
+
cam = colmap_rec.cameras[found_img.camera_id]
|
| 625 |
+
R, t = _cam_matrix_from_image(found_img)
|
| 626 |
+
K = cam.calibration_matrix()
|
| 627 |
+
|
| 628 |
+
for xyz in points_xyz:
|
| 629 |
+
p_cam = R @ np.asarray(xyz, dtype=np.float64) + t
|
| 630 |
+
if p_cam[2] > 0:
|
| 631 |
+
u = p_cam[0] / p_cam[2] * K[0, 0] + K[0, 2]
|
| 632 |
+
v = p_cam[1] / p_cam[2] * K[1, 1] + K[1, 2]
|
| 633 |
+
u_i, v_i = int(round(u)), int(round(v))
|
| 634 |
+
if 0 <= u_i < W and 0 <= v_i < H:
|
| 635 |
+
uv.append((u_i, v_i))
|
| 636 |
+
z_vals.append(p_cam[2])
|
| 637 |
+
|
| 638 |
+
uv = np.array(uv, dtype=int)
|
| 639 |
+
z_vals = np.array(z_vals)
|
| 640 |
+
|
| 641 |
+
depth_out = np.zeros((H, W), dtype=np.float32)
|
| 642 |
+
if len(uv) > 0:
|
| 643 |
+
depth_out[uv[:,1], uv[:,0]] = z_vals
|
| 644 |
+
|
| 645 |
+
return depth_out, True, found_img
|
| 646 |
+
|
| 647 |
+
|
| 648 |
+
def fit_scale_robust_median(depth, sparse_depth, validity_mask=None):
|
| 649 |
+
"""
|
| 650 |
+
Fit a scale factor to the depth map using the median of the ratio of sparse to dense depth.
|
| 651 |
+
"""
|
| 652 |
+
if validity_mask is None:
|
| 653 |
+
mask = (sparse_depth != 0)
|
| 654 |
+
else:
|
| 655 |
+
mask = (sparse_depth != 0) & validity_mask
|
| 656 |
+
mask = mask & (depth <50) & (sparse_depth <50)
|
| 657 |
+
X = depth[mask]
|
| 658 |
+
Y = sparse_depth[mask]
|
| 659 |
+
alpha =np.median(Y/X)
|
| 660 |
+
depth_fitted = alpha * depth
|
| 661 |
+
return alpha, depth_fitted
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
def get_fitted_dense_depth(depth, colmap_rec, img_id, ade20k_seg):
|
| 665 |
+
"""Scale the dense depth to align with the sparse COLMAP depth.
|
| 666 |
+
|
| 667 |
+
Reads sparse depth from COLMAP, fits a scale factor using only points inside
|
| 668 |
+
the ADE20k house mask, and returns the scaled dense depth and the sparse
|
| 669 |
+
depth. found_sparse is False when no sparse depth is available for the image.
|
| 670 |
+
"""
|
| 671 |
+
depth_np = np.array(depth) / 1000. # mm to meters
|
| 672 |
+
depth_sparse, found_sparse, col_img = get_sparse_depth(colmap_rec, img_id, depth_np)
|
| 673 |
+
|
| 674 |
+
if not found_sparse:
|
| 675 |
+
print(f'No sparse depth found for image {img_id}')
|
| 676 |
+
return depth_np, np.zeros_like(depth_np), False, None
|
| 677 |
+
|
| 678 |
+
house_mask = get_house_mask(ade20k_seg)
|
| 679 |
+
k, depth_fitted = fit_scale_robust_median(depth_np, depth_sparse, validity_mask=house_mask)
|
| 680 |
+
print(f"Fitted depth scale k={k:.4f} for image {img_id}")
|
| 681 |
+
return depth_fitted, depth_sparse, True, col_img
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def precompute_overlapping_views(
|
| 685 |
+
colmap_reconstruction: pycolmap.Reconstruction,
|
| 686 |
+
min_shared_points: int = 10
|
| 687 |
+
) -> Dict[int, List[pycolmap.Image]]:
|
| 688 |
+
"""Map each image to the images it shares at least min_shared_points 3D points with.
|
| 689 |
+
|
| 690 |
+
Returns a dict image_id -> list of overlapping images (excluding self).
|
| 691 |
+
"""
|
| 692 |
+
print("Pre-computing overlapping views...")
|
| 693 |
+
|
| 694 |
+
image_3d_points = {}
|
| 695 |
+
for img_id, image in colmap_reconstruction.images.items():
|
| 696 |
+
points_3d = set()
|
| 697 |
+
for point2D in image.points2D:
|
| 698 |
+
if point2D.has_point3D():
|
| 699 |
+
points_3d.add(point2D.point3D_id)
|
| 700 |
+
image_3d_points[img_id] = points_3d
|
| 701 |
+
|
| 702 |
+
overlapping_views = {}
|
| 703 |
+
total_pairs = 0
|
| 704 |
+
overlapping_pairs = 0
|
| 705 |
+
|
| 706 |
+
for img_id_1, image_1 in colmap_reconstruction.images.items():
|
| 707 |
+
overlapping_views[img_id_1] = []
|
| 708 |
+
points_1 = image_3d_points[img_id_1]
|
| 709 |
+
|
| 710 |
+
if len(points_1) == 0:
|
| 711 |
+
continue
|
| 712 |
+
|
| 713 |
+
for img_id_2, image_2 in colmap_reconstruction.images.items():
|
| 714 |
+
if img_id_1 >= img_id_2:
|
| 715 |
+
continue
|
| 716 |
+
|
| 717 |
+
total_pairs += 1
|
| 718 |
+
points_2 = image_3d_points[img_id_2]
|
| 719 |
+
|
| 720 |
+
shared_points = points_1.intersection(points_2)
|
| 721 |
+
if len(shared_points) >= min_shared_points:
|
| 722 |
+
overlapping_pairs += 1
|
| 723 |
+
overlapping_views[img_id_1].append(image_2)
|
| 724 |
+
if img_id_2 not in overlapping_views:
|
| 725 |
+
overlapping_views[img_id_2] = []
|
| 726 |
+
overlapping_views[img_id_2].append(image_1)
|
| 727 |
+
|
| 728 |
+
print(f" Found {overlapping_pairs}/{total_pairs} overlapping pairs")
|
| 729 |
+
avg_overlaps = np.mean([len(overlaps) for overlaps in overlapping_views.values()]) if overlapping_views else 0
|
| 730 |
+
print(f" Average overlaps per image: {avg_overlaps:.1f}")
|
| 731 |
+
|
| 732 |
+
return overlapping_views
|
| 733 |
+
|
| 734 |
+
|
| 735 |
+
def check_3d_point_multi_view_consistency(
|
| 736 |
+
point_3d: np.ndarray,
|
| 737 |
+
original_vertex_type: str,
|
| 738 |
+
current_colmap_image: pycolmap.Image,
|
| 739 |
+
precomputed_overlaps: Dict[int, List[pycolmap.Image]],
|
| 740 |
+
image_data_map: Dict[str, np.ndarray],
|
| 741 |
+
gestalt_color_mapping: Dict[str, tuple],
|
| 742 |
+
min_consistent_views: int = 2,
|
| 743 |
+
projection_patch_size: int = 5,
|
| 744 |
+
debug: bool = False
|
| 745 |
+
) -> bool:
|
| 746 |
+
"""Check whether a 3D vertex is consistent across the views that see it.
|
| 747 |
+
|
| 748 |
+
Projects the point into each overlapping view and checks that the gestalt
|
| 749 |
+
segmentation around the projection matches the vertex's class. Returns True
|
| 750 |
+
if at least min_consistent_views agree, and also True when there are too few
|
| 751 |
+
overlapping views to verify.
|
| 752 |
+
"""
|
| 753 |
+
if original_vertex_type not in gestalt_color_mapping:
|
| 754 |
+
if debug:
|
| 755 |
+
print(f" Vertex type {original_vertex_type} not in color mapping")
|
| 756 |
+
return False
|
| 757 |
+
|
| 758 |
+
target_color = np.array(gestalt_color_mapping[original_vertex_type])
|
| 759 |
+
|
| 760 |
+
overlapping_views = precomputed_overlaps.get(current_colmap_image.image_id, [])
|
| 761 |
+
|
| 762 |
+
if debug:
|
| 763 |
+
print(f" Found {len(overlapping_views)} overlapping views for vertex type {original_vertex_type}")
|
| 764 |
+
|
| 765 |
+
if len(overlapping_views) < min_consistent_views:
|
| 766 |
+
if debug:
|
| 767 |
+
print(f" Not enough overlapping views ({len(overlapping_views)} < {min_consistent_views}), accepting point")
|
| 768 |
+
return True # Accept when we cannot verify (too few overlapping views).
|
| 769 |
+
|
| 770 |
+
consistent_view_count = 0
|
| 771 |
+
total_checked_views = 0
|
| 772 |
+
half_patch = projection_patch_size // 2
|
| 773 |
+
|
| 774 |
+
for other_image in overlapping_views:
|
| 775 |
+
try:
|
| 776 |
+
total_checked_views += 1
|
| 777 |
+
projection = other_image.project_point(point_3d)
|
| 778 |
+
if projection is None:
|
| 779 |
+
if debug:
|
| 780 |
+
print(f" View {other_image.name}: projection failed (behind camera)")
|
| 781 |
+
continue
|
| 782 |
+
|
| 783 |
+
u, v = projection
|
| 784 |
+
|
| 785 |
+
img_width = other_image.camera.width
|
| 786 |
+
img_height = other_image.camera.height
|
| 787 |
+
if not (0 <= u < img_width and 0 <= v < img_height):
|
| 788 |
+
if debug:
|
| 789 |
+
print(f" View {other_image.name}: projection out of bounds ({u:.1f}, {v:.1f})")
|
| 790 |
+
continue
|
| 791 |
+
|
| 792 |
+
other_gest_seg_np = None
|
| 793 |
+
for img_name, gest_seg_np in image_data_map.items():
|
| 794 |
+
if img_name in other_image.name or other_image.name in img_name:
|
| 795 |
+
other_gest_seg_np = gest_seg_np
|
| 796 |
+
break
|
| 797 |
+
|
| 798 |
+
if other_gest_seg_np is None:
|
| 799 |
+
if debug:
|
| 800 |
+
print(f" View {other_image.name}: no segmentation data found")
|
| 801 |
+
continue
|
| 802 |
+
|
| 803 |
+
seg_h, seg_w = other_gest_seg_np.shape[:2]
|
| 804 |
+
|
| 805 |
+
# Rescale the projection if the segmentation resolution differs from the camera.
|
| 806 |
+
if seg_w != img_width or seg_h != img_height:
|
| 807 |
+
u_seg = int(round(u * seg_w / img_width))
|
| 808 |
+
v_seg = int(round(v * seg_h / img_height))
|
| 809 |
+
else:
|
| 810 |
+
u_seg, v_seg = int(round(u)), int(round(v))
|
| 811 |
+
|
| 812 |
+
v_start = max(0, v_seg - half_patch)
|
| 813 |
+
v_end = min(seg_h, v_seg + half_patch + 1)
|
| 814 |
+
u_start = max(0, u_seg - half_patch)
|
| 815 |
+
u_end = min(seg_w, u_seg + half_patch + 1)
|
| 816 |
+
|
| 817 |
+
seg_color_patch = other_gest_seg_np[v_start:v_end, u_start:u_end]
|
| 818 |
+
|
| 819 |
+
if seg_color_patch.size > 0:
|
| 820 |
+
patch_matches = np.any(np.all(np.abs(seg_color_patch - target_color) <= 1.0, axis=-1))
|
| 821 |
+
if patch_matches:
|
| 822 |
+
consistent_view_count += 1
|
| 823 |
+
if debug:
|
| 824 |
+
print(f" View {other_image.name}: MATCH at ({u:.1f}, {v:.1f}) -> ({u_seg}, {v_seg})")
|
| 825 |
+
else:
|
| 826 |
+
if debug:
|
| 827 |
+
unique_colors = np.unique(seg_color_patch.reshape(-1, 3), axis=0)
|
| 828 |
+
print(f" View {other_image.name}: no match at ({u:.1f}, {v:.1f}) -> ({u_seg}, {v_seg}), colors: {unique_colors[:3]}")
|
| 829 |
+
|
| 830 |
+
except Exception as e:
|
| 831 |
+
if debug:
|
| 832 |
+
print(f" View {other_image.name}: exception {e}")
|
| 833 |
+
continue
|
| 834 |
+
|
| 835 |
+
result = consistent_view_count >= min_consistent_views
|
| 836 |
+
if debug:
|
| 837 |
+
print(f" Result: {consistent_view_count}/{total_checked_views} consistent views, required: {min_consistent_views}, accepted: {result}")
|
| 838 |
+
|
| 839 |
+
return result
|
| 840 |
+
|
| 841 |
+
|
| 842 |
+
def filter_vertices_by_multi_view_consistency(
|
| 843 |
+
vert_edge_per_image: Dict[int, Tuple[List[dict], List[Tuple[int, int]], np.ndarray]],
|
| 844 |
+
colmap_reconstruction: pycolmap.Reconstruction,
|
| 845 |
+
gestalt_segmentations: List[PImage.Image],
|
| 846 |
+
image_ids: List[str],
|
| 847 |
+
gestalt_color_mapping: Dict[str, tuple],
|
| 848 |
+
depth_size_per_image: List[Tuple[int, int]], # [(W, H), ...] for each image
|
| 849 |
+
min_consistent_views: int = 2,
|
| 850 |
+
min_shared_points_for_overlap: int = 10,
|
| 851 |
+
projection_patch_size: int = 25
|
| 852 |
+
) -> Dict[int, Tuple[List[dict], List[Tuple[int, int]], np.ndarray]]:
|
| 853 |
+
"""Drop 3D vertices that are not multi-view consistent and remap edges to the survivors."""
|
| 854 |
+
precomputed_overlaps = precompute_overlapping_views(
|
| 855 |
+
colmap_reconstruction, min_shared_points_for_overlap
|
| 856 |
+
)
|
| 857 |
+
|
| 858 |
+
image_data_map = {}
|
| 859 |
+
for i, (gest_seg, img_id, (w, h)) in enumerate(zip(gestalt_segmentations, image_ids, depth_size_per_image)):
|
| 860 |
+
gest_seg_resized = gest_seg.resize((w, h))
|
| 861 |
+
gest_seg_np = np.array(gest_seg_resized).astype(np.uint8)
|
| 862 |
+
image_data_map[img_id] = gest_seg_np
|
| 863 |
+
|
| 864 |
+
filtered_vert_edge_per_image = {}
|
| 865 |
+
|
| 866 |
+
for img_idx, (orig_2d_verts, orig_2d_conns, v3d_candidates) in vert_edge_per_image.items():
|
| 867 |
+
if len(v3d_candidates) == 0:
|
| 868 |
+
filtered_vert_edge_per_image[img_idx] = ([], [], np.empty((0, 3)))
|
| 869 |
+
continue
|
| 870 |
+
|
| 871 |
+
current_img_id = image_ids[img_idx]
|
| 872 |
+
current_colmap_img = None
|
| 873 |
+
for colmap_img_id, colmap_img in colmap_reconstruction.images.items():
|
| 874 |
+
if current_img_id in colmap_img.name:
|
| 875 |
+
current_colmap_img = colmap_img
|
| 876 |
+
break
|
| 877 |
+
|
| 878 |
+
if current_colmap_img is None:
|
| 879 |
+
filtered_vert_edge_per_image[img_idx] = (orig_2d_verts, orig_2d_conns, v3d_candidates)
|
| 880 |
+
continue
|
| 881 |
+
|
| 882 |
+
kept_v3d = []
|
| 883 |
+
kept_orig_2d_verts_indices = []
|
| 884 |
+
|
| 885 |
+
for j, p_3d in enumerate(v3d_candidates):
|
| 886 |
+
if j >= len(orig_2d_verts):
|
| 887 |
+
continue
|
| 888 |
+
|
| 889 |
+
original_vertex_type = orig_2d_verts[j]['type']
|
| 890 |
+
|
| 891 |
+
is_consistent = check_3d_point_multi_view_consistency(
|
| 892 |
+
p_3d, original_vertex_type, current_colmap_img, precomputed_overlaps,
|
| 893 |
+
image_data_map, gestalt_color_mapping, min_consistent_views,
|
| 894 |
+
projection_patch_size=projection_patch_size,
|
| 895 |
+
debug=False
|
| 896 |
+
)
|
| 897 |
+
|
| 898 |
+
if is_consistent:
|
| 899 |
+
kept_v3d.append(p_3d)
|
| 900 |
+
kept_orig_2d_verts_indices.append(j)
|
| 901 |
+
|
| 902 |
+
if len(kept_v3d) == 0:
|
| 903 |
+
filtered_vert_edge_per_image[img_idx] = ([], [], np.empty((0, 3)))
|
| 904 |
+
continue
|
| 905 |
+
|
| 906 |
+
new_orig_2d_verts = [orig_2d_verts[j] for j in kept_orig_2d_verts_indices]
|
| 907 |
+
|
| 908 |
+
old_idx_to_new_idx = {old_idx: new_idx for new_idx, old_idx in enumerate(kept_orig_2d_verts_indices)}
|
| 909 |
+
new_orig_2d_conns = []
|
| 910 |
+
|
| 911 |
+
for (u, v) in orig_2d_conns:
|
| 912 |
+
if u in old_idx_to_new_idx and v in old_idx_to_new_idx:
|
| 913 |
+
new_u = old_idx_to_new_idx[u]
|
| 914 |
+
new_v = old_idx_to_new_idx[v]
|
| 915 |
+
new_orig_2d_conns.append((new_u, new_v))
|
| 916 |
+
|
| 917 |
+
filtered_vert_edge_per_image[img_idx] = (
|
| 918 |
+
new_orig_2d_verts,
|
| 919 |
+
new_orig_2d_conns,
|
| 920 |
+
np.array(kept_v3d)
|
| 921 |
+
)
|
| 922 |
+
|
| 923 |
+
return filtered_vert_edge_per_image
|
| 924 |
+
|
| 925 |
+
|
| 926 |
+
def recover_edges_after_vertex_filtering(
|
| 927 |
+
filtered_vert_edge_per_image: Dict[int, Tuple[List[dict], List[Tuple[int, int]], np.ndarray]],
|
| 928 |
+
good_entry: dict,
|
| 929 |
+
edge_class_names: List[str],
|
| 930 |
+
edge_th: float = 25.0
|
| 931 |
+
) -> Dict[int, Tuple[List[dict], List[Tuple[int, int]], np.ndarray]]:
|
| 932 |
+
"""Re-detect edges between the surviving vertices after vertex filtering, using the
|
| 933 |
+
same semantic rulebook and line-of-sight verification as the initial edge detection."""
|
| 934 |
+
recovered_vert_edge_per_image = {}
|
| 935 |
+
total_new_edges = 0
|
| 936 |
+
total_original_edges = 0
|
| 937 |
+
|
| 938 |
+
for img_idx, (filtered_2d_verts, filtered_2d_conns, filtered_v3d) in filtered_vert_edge_per_image.items():
|
| 939 |
+
total_original_edges += len(filtered_2d_conns)
|
| 940 |
+
|
| 941 |
+
if len(filtered_2d_verts) < 2:
|
| 942 |
+
recovered_vert_edge_per_image[img_idx] = (filtered_2d_verts, filtered_2d_conns, filtered_v3d)
|
| 943 |
+
continue
|
| 944 |
+
|
| 945 |
+
try:
|
| 946 |
+
gest = good_entry['gestalt'][img_idx]
|
| 947 |
+
depth = good_entry['depth'][img_idx]
|
| 948 |
+
depth_size = (np.array(depth).shape[1], np.array(depth).shape[0]) # W, H
|
| 949 |
+
gest_seg = gest.resize(depth_size)
|
| 950 |
+
gest_seg_np = np.array(gest_seg).astype(np.uint8)
|
| 951 |
+
except (IndexError, KeyError):
|
| 952 |
+
recovered_vert_edge_per_image[img_idx] = (filtered_2d_verts, filtered_2d_conns, filtered_v3d)
|
| 953 |
+
continue
|
| 954 |
+
|
| 955 |
+
structural_pts = np.array([v['xy'] for v in filtered_2d_verts])
|
| 956 |
+
structural_idx_map = list(range(len(filtered_2d_verts)))
|
| 957 |
+
|
| 958 |
+
new_connections = []
|
| 959 |
+
for edge_class in edge_class_names:
|
| 960 |
+
|
| 961 |
+
# --- 1. THE SEMANTIC RULEBOOK ---
|
| 962 |
+
if edge_class in ['ridge']:
|
| 963 |
+
allowed_types = ['apex']
|
| 964 |
+
elif edge_class in ['eave', 'flashing', 'step_flashing']:
|
| 965 |
+
allowed_types = ['eave_end_point', 'flashing_end_point']
|
| 966 |
+
else: # rake, valley, hip, transition_line
|
| 967 |
+
allowed_types = ['apex', 'eave_end_point', 'flashing_end_point']
|
| 968 |
+
|
| 969 |
+
allowed_pts = []
|
| 970 |
+
allowed_idx_map = []
|
| 971 |
+
for orig_idx, v in enumerate(filtered_2d_verts):
|
| 972 |
+
if v['type'] in allowed_types:
|
| 973 |
+
allowed_pts.append(v['xy'])
|
| 974 |
+
allowed_idx_map.append(orig_idx)
|
| 975 |
+
|
| 976 |
+
allowed_pts = np.array(allowed_pts)
|
| 977 |
+
if len(allowed_pts) < 2:
|
| 978 |
+
continue
|
| 979 |
+
# --------------------------------
|
| 980 |
+
|
| 981 |
+
edge_color = np.array(gestalt_color_mapping[edge_class])
|
| 982 |
+
mask_raw = cv2.inRange(gest_seg_np, edge_color-0.5, edge_color+0.5)
|
| 983 |
+
kernel = np.ones((5, 5), np.uint8)
|
| 984 |
+
mask = cv2.morphologyEx(mask_raw, cv2.MORPH_CLOSE, kernel)
|
| 985 |
+
if mask.sum() == 0:
|
| 986 |
+
continue
|
| 987 |
+
|
| 988 |
+
output = cv2.connectedComponentsWithStats(mask, 8, cv2.CV_32S)
|
| 989 |
+
(numLabels, labels, stats, centroids) = output
|
| 990 |
+
stats, centroids = stats[1:], centroids[1:]
|
| 991 |
+
label_indices = range(1, numLabels)
|
| 992 |
+
|
| 993 |
+
for lbl in label_indices:
|
| 994 |
+
mask_i = np.zeros_like(mask)
|
| 995 |
+
mask_i[labels == lbl] = 255
|
| 996 |
+
|
| 997 |
+
# HoughLinesP for discrete line segments
|
| 998 |
+
lines = cv2.HoughLinesP(mask_i, rho=1, theta=np.pi/180, threshold=15, minLineLength=8, maxLineGap=20)
|
| 999 |
+
|
| 1000 |
+
if lines is None:
|
| 1001 |
+
continue
|
| 1002 |
+
|
| 1003 |
+
for line in lines:
|
| 1004 |
+
x1, y1, x2, y2 = line[0]
|
| 1005 |
+
p1 = np.array([x1, y1], dtype=np.float32)
|
| 1006 |
+
p2 = np.array([x2, y2], dtype=np.float32)
|
| 1007 |
+
|
| 1008 |
+
if len(allowed_pts) < 2:
|
| 1009 |
+
continue
|
| 1010 |
+
|
| 1011 |
+
# Distance check using ONLY the semantically allowed points
|
| 1012 |
+
dists = np.array([
|
| 1013 |
+
point_to_segment_dist(allowed_pts[i], p1, p2)
|
| 1014 |
+
for i in range(len(allowed_pts))
|
| 1015 |
+
])
|
| 1016 |
+
|
| 1017 |
+
near_mask = (dists <= edge_th)
|
| 1018 |
+
near_indices = np.where(near_mask)[0]
|
| 1019 |
+
if len(near_indices) < 2:
|
| 1020 |
+
continue
|
| 1021 |
+
|
| 1022 |
+
# --- 2. CONNECTIVITY WITH LINE-OF-SIGHT VERIFICATION ---
|
| 1023 |
+
for i in range(len(near_indices)):
|
| 1024 |
+
for j in range(i+1, len(near_indices)):
|
| 1025 |
+
idx_a = near_indices[i]
|
| 1026 |
+
idx_b = near_indices[j]
|
| 1027 |
+
|
| 1028 |
+
vA = allowed_idx_map[idx_a]
|
| 1029 |
+
vB = allowed_idx_map[idx_b]
|
| 1030 |
+
|
| 1031 |
+
conn = tuple(sorted((vA, vB)))
|
| 1032 |
+
if conn not in new_connections:
|
| 1033 |
+
# THE ULTIMATE SPIDERWEB KILLER:
|
| 1034 |
+
# Verify that the line between these two corners actually exists in the mask!
|
| 1035 |
+
is_valid_edge = verify_edge_mask(allowed_pts[idx_a], allowed_pts[idx_b], mask, min_overlap=0.3)
|
| 1036 |
+
|
| 1037 |
+
if is_valid_edge:
|
| 1038 |
+
new_connections.append(conn)
|
| 1039 |
+
|
| 1040 |
+
total_new_edges += len(new_connections)
|
| 1041 |
+
recovered_vert_edge_per_image[img_idx] = (filtered_2d_verts, new_connections, filtered_v3d)
|
| 1042 |
+
|
| 1043 |
+
print(f" Edge recovery details: {total_original_edges} -> {total_new_edges} edges across all images")
|
| 1044 |
+
return recovered_vert_edge_per_image
|
| 1045 |
+
|
| 1046 |
+
def merge_collinear_edges(vertices: np.ndarray, edges: List[Tuple[int, int]], cos_threshold: float = -0.98) -> Tuple[np.ndarray, List[Tuple[int, int]]]:
|
| 1047 |
+
"""
|
| 1048 |
+
Finds degree-2 vertices that form a straight line and merges their edges.
|
| 1049 |
+
cos_threshold of -0.98 corresponds to ~170 degrees.
|
| 1050 |
+
"""
|
| 1051 |
+
if len(edges) == 0:
|
| 1052 |
+
return vertices, edges
|
| 1053 |
+
|
| 1054 |
+
adj = defaultdict(set)
|
| 1055 |
+
for u, v in edges:
|
| 1056 |
+
adj[u].add(v)
|
| 1057 |
+
adj[v].add(u)
|
| 1058 |
+
|
| 1059 |
+
edges_set = set([tuple(sorted((u, v))) for u, v in edges])
|
| 1060 |
+
|
| 1061 |
+
merged_something = True
|
| 1062 |
+
while merged_something:
|
| 1063 |
+
merged_something = False
|
| 1064 |
+
|
| 1065 |
+
for b in list(adj.keys()):
|
| 1066 |
+
neighbors = list(adj[b])
|
| 1067 |
+
if len(neighbors) == 2:
|
| 1068 |
+
a, c = neighbors
|
| 1069 |
+
|
| 1070 |
+
vec1 = vertices[a] - vertices[b]
|
| 1071 |
+
vec2 = vertices[c] - vertices[b]
|
| 1072 |
+
|
| 1073 |
+
norm1 = np.linalg.norm(vec1)
|
| 1074 |
+
norm2 = np.linalg.norm(vec2)
|
| 1075 |
+
|
| 1076 |
+
if norm1 > 1e-5 and norm2 > 1e-5:
|
| 1077 |
+
cos_sim = np.dot(vec1, vec2) / (norm1 * norm2)
|
| 1078 |
+
|
| 1079 |
+
if cos_sim < cos_threshold:
|
| 1080 |
+
e1 = tuple(sorted((a, b)))
|
| 1081 |
+
e2 = tuple(sorted((b, c)))
|
| 1082 |
+
|
| 1083 |
+
if e1 in edges_set: edges_set.remove(e1)
|
| 1084 |
+
if e2 in edges_set: edges_set.remove(e2)
|
| 1085 |
+
|
| 1086 |
+
new_edge = tuple(sorted((a, c)))
|
| 1087 |
+
edges_set.add(new_edge)
|
| 1088 |
+
|
| 1089 |
+
adj[a].remove(b)
|
| 1090 |
+
adj[c].remove(b)
|
| 1091 |
+
adj[a].add(c)
|
| 1092 |
+
adj[c].add(a)
|
| 1093 |
+
del adj[b]
|
| 1094 |
+
|
| 1095 |
+
merged_something = True
|
| 1096 |
+
break
|
| 1097 |
+
|
| 1098 |
+
new_edges = list(edges_set)
|
| 1099 |
+
return vertices, new_edges
|
| 1100 |
+
|
| 1101 |
+
|
| 1102 |
+
def predict_wireframe(entry) -> Tuple[np.ndarray, List[int]]:
|
| 1103 |
+
"""Predict the 3D wireframe (vertices and edges) from a dataset entry."""
|
| 1104 |
+
good_entry = convert_entry_to_human_readable(entry)
|
| 1105 |
+
vert_edge_per_image = {}
|
| 1106 |
+
|
| 1107 |
+
depth_sizes = []
|
| 1108 |
+
colmap_rec = good_entry.get('colmap', good_entry.get('colmap_binary'))
|
| 1109 |
+
|
| 1110 |
+
for i, (gest, depth, K, R, t, img_id, ade_seg) in enumerate(zip(good_entry['gestalt'],
|
| 1111 |
+
good_entry['depth'],
|
| 1112 |
+
good_entry['K'],
|
| 1113 |
+
good_entry['R'],
|
| 1114 |
+
good_entry['t'],
|
| 1115 |
+
good_entry['image_ids'],
|
| 1116 |
+
good_entry['ade']
|
| 1117 |
+
)):
|
| 1118 |
+
|
| 1119 |
+
K = np.array(K)
|
| 1120 |
+
R = np.array(R)
|
| 1121 |
+
t = np.array(t)
|
| 1122 |
+
depth_size = (np.array(depth).shape[1], np.array(depth).shape[0]) # W, H
|
| 1123 |
+
depth_sizes.append(depth_size)
|
| 1124 |
+
|
| 1125 |
+
# resize() can place pixels at the wrong positions; see
|
| 1126 |
+
# https://numpy.org/doc/stable/reference/generated/numpy.ndarray.resize.html
|
| 1127 |
+
gest_seg = gest.resize(depth_size)
|
| 1128 |
+
gest_seg_np = np.array(gest_seg).astype(np.uint8)
|
| 1129 |
+
|
| 1130 |
+
# Match this image to its COLMAP entry by name (as in get_sparse_depth).
|
| 1131 |
+
found_colmap_img = None
|
| 1132 |
+
for img_id_c, col_img in colmap_rec.images.items():
|
| 1133 |
+
if img_id in col_img.name:
|
| 1134 |
+
found_colmap_img = col_img
|
| 1135 |
+
break
|
| 1136 |
+
|
| 1137 |
+
point_class_names = ["apex", "eave_end_point", "flashing_end_point"]
|
| 1138 |
+
edge_class_names = ["eave", "ridge", "rake", "valley", "hip", "flashing", "step_flashing", "transition_line"]
|
| 1139 |
+
vertices, connections = get_vertices_and_edges_from_segmentation(
|
| 1140 |
+
gest_seg_np,
|
| 1141 |
+
point_class_names,
|
| 1142 |
+
edge_class_names,
|
| 1143 |
+
colmap_image=found_colmap_img,
|
| 1144 |
+
colmap_points3D=colmap_rec.points3D,
|
| 1145 |
+
edge_th=25.0,
|
| 1146 |
+
min_3d_points_for_vertex=1,
|
| 1147 |
+
vertex_cluster_eps=25.0,
|
| 1148 |
+
use_colmap_for_vertices=False,
|
| 1149 |
+
patch_size=25
|
| 1150 |
+
)
|
| 1151 |
+
|
| 1152 |
+
if (len(vertices) < 2) or (len(connections) < 1):
|
| 1153 |
+
print(f'Not enough vertices or connections found in image {i}, skipping.')
|
| 1154 |
+
vert_edge_per_image[i] = [], [], np.empty((0, 3))
|
| 1155 |
+
continue
|
| 1156 |
+
|
| 1157 |
+
vertices_3d = create_3d_wireframe_single_image(
|
| 1158 |
+
vertices, connections, depth, colmap_rec, img_id, ade_seg
|
| 1159 |
+
)
|
| 1160 |
+
vert_edge_per_image[i] = vertices, connections, vertices_3d
|
| 1161 |
+
|
| 1162 |
+
print("Applying multi-view consistency filtering...")
|
| 1163 |
+
|
| 1164 |
+
total_vertices_before_filtering = sum(len(v3d) for _, _, v3d in vert_edge_per_image.values())
|
| 1165 |
+
print(f"Total vertices before filtering: {total_vertices_before_filtering}")
|
| 1166 |
+
|
| 1167 |
+
filtered_vert_edge_per_image = filter_vertices_by_multi_view_consistency(
|
| 1168 |
+
vert_edge_per_image,
|
| 1169 |
+
colmap_rec,
|
| 1170 |
+
good_entry['gestalt'],
|
| 1171 |
+
good_entry['image_ids'],
|
| 1172 |
+
gestalt_color_mapping,
|
| 1173 |
+
depth_sizes,
|
| 1174 |
+
min_consistent_views=1,
|
| 1175 |
+
min_shared_points_for_overlap=3,
|
| 1176 |
+
projection_patch_size=30
|
| 1177 |
+
)
|
| 1178 |
+
|
| 1179 |
+
total_vertices_before = sum(len(v3d) for _, _, v3d in vert_edge_per_image.values())
|
| 1180 |
+
total_vertices_after = sum(len(v3d) for _, _, v3d in filtered_vert_edge_per_image.values())
|
| 1181 |
+
print(f"Multi-view filtering: {total_vertices_before} -> {total_vertices_after} vertices")
|
| 1182 |
+
print(f"Filtering removed {total_vertices_before - total_vertices_after} vertices ({100*(total_vertices_before - total_vertices_after)/max(total_vertices_before,1):.1f}%)")
|
| 1183 |
+
|
| 1184 |
+
print("Recovering edges between filtered vertices...")
|
| 1185 |
+
edges_before_recovery = sum(len(conns) for _, conns, _ in filtered_vert_edge_per_image.values())
|
| 1186 |
+
|
| 1187 |
+
edge_class_names = ["eave", "ridge", "rake", "valley", "hip", "flashing", "step_flashing", "transition_line"]
|
| 1188 |
+
recovered_vert_edge_per_image = recover_edges_after_vertex_filtering(
|
| 1189 |
+
filtered_vert_edge_per_image,
|
| 1190 |
+
good_entry,
|
| 1191 |
+
edge_class_names,
|
| 1192 |
+
edge_th=25.0
|
| 1193 |
+
)
|
| 1194 |
+
|
| 1195 |
+
edges_after_recovery = sum(len(conns) for _, conns, _ in recovered_vert_edge_per_image.values())
|
| 1196 |
+
print(f"Edge recovery: {edges_before_recovery} -> {edges_after_recovery} edges")
|
| 1197 |
+
|
| 1198 |
+
all_3d_vertices, connections_3d = merge_vertices_3d(recovered_vert_edge_per_image, point_class_names, 0.7)
|
| 1199 |
+
all_3d_vertices_clean, connections_3d_clean = prune_not_connected(all_3d_vertices, connections_3d, keep_largest=False)
|
| 1200 |
+
|
| 1201 |
+
# Merge fragmented collinear segments into continuous edges, then drop any
|
| 1202 |
+
# vertices orphaned by the merge.
|
| 1203 |
+
all_3d_vertices_clean, connections_3d_clean = merge_collinear_edges(all_3d_vertices_clean, connections_3d_clean, cos_threshold=-0.98)
|
| 1204 |
+
all_3d_vertices_clean, connections_3d_clean = prune_not_connected(all_3d_vertices_clean, connections_3d_clean, keep_largest=False)
|
| 1205 |
+
|
| 1206 |
+
if (len(all_3d_vertices_clean) < 2) or len(connections_3d_clean) < 1:
|
| 1207 |
+
print (f'Not enough vertices or connections in the 3D vertices')
|
| 1208 |
+
return empty_solution()
|
| 1209 |
+
|
| 1210 |
+
return all_3d_vertices_clean, connections_3d_clean
|
training/edge_patch.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""6D cylindrical edge-patch builder and an edge-classifier evaluation check.
|
| 2 |
+
|
| 3 |
+
Provides:
|
| 4 |
+
- colmap_points_xyz_rgb / build_edge_patch_6d: build the 6D (xyz + RGB)
|
| 5 |
+
cylindrical patch around an edge from COLMAP points (radius 0.5m,
|
| 6 |
+
+0.25m extension at each end). Imported by the dataset generators.
|
| 7 |
+
|
| 8 |
+
Run as a script to evaluate an edge classifier on a few samples: it scores
|
| 9 |
+
each handcrafted edge, splits edges by whether they are ground-truth-positive
|
| 10 |
+
(both endpoints near connected GT vertices), and compares the score
|
| 11 |
+
distributions.
|
| 12 |
+
"""
|
| 13 |
+
import os
|
| 14 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
|
| 15 |
+
|
| 16 |
+
import sys
|
| 17 |
+
import time
|
| 18 |
+
import numpy as np
|
| 19 |
+
import torch
|
| 20 |
+
from datasets import load_dataset
|
| 21 |
+
from scipy.spatial.distance import cdist
|
| 22 |
+
|
| 23 |
+
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 24 |
+
sys.path.insert(0, CURRENT_DIR)
|
| 25 |
+
|
| 26 |
+
import hc_helpers as hc
|
| 27 |
+
from fast_pointnet_class import (
|
| 28 |
+
load_pointnet_model as load_pnet_class,
|
| 29 |
+
predict_class_from_patch,
|
| 30 |
+
)
|
| 31 |
+
from hoho2025.example_solutions import read_colmap_rec
|
| 32 |
+
|
| 33 |
+
NUM_TRIALS = 3
|
| 34 |
+
CYL_RADIUS = 0.5
|
| 35 |
+
CYL_EXT = 0.25 # extension at each end
|
| 36 |
+
GT_VERTEX_THRESH = 0.5 # how close a vertex must be to a GT vertex to count
|
| 37 |
+
# as "matched" (= HSS vert_thresh)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def colmap_points_xyz_rgb(colmap_rec):
|
| 41 |
+
"""Return (xyz, rgb_normalized_0_1) for all COLMAP points."""
|
| 42 |
+
xyz_list, rgb_list = [], []
|
| 43 |
+
for pid, p3D in colmap_rec.points3D.items():
|
| 44 |
+
xyz_list.append(p3D.xyz)
|
| 45 |
+
rgb_list.append(p3D.color / 255.0)
|
| 46 |
+
if not xyz_list:
|
| 47 |
+
return np.empty((0, 3)), np.empty((0, 3))
|
| 48 |
+
return np.array(xyz_list), np.array(rgb_list)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_edge_patch_6d(u_xyz, v_xyz, colmap_xyz, colmap_rgb):
|
| 52 |
+
"""
|
| 53 |
+
Returns patch dict {'patch_6d': (M, 6)} or None if cylinder too sparse.
|
| 54 |
+
"""
|
| 55 |
+
line = v_xyz - u_xyz
|
| 56 |
+
L = float(np.linalg.norm(line))
|
| 57 |
+
if L < 1e-6:
|
| 58 |
+
return None
|
| 59 |
+
direction = line / L
|
| 60 |
+
|
| 61 |
+
ext_start = u_xyz - CYL_EXT * direction
|
| 62 |
+
ext_L = L + 2 * CYL_EXT
|
| 63 |
+
|
| 64 |
+
rel = colmap_xyz - ext_start[np.newaxis, :]
|
| 65 |
+
proj = rel @ direction
|
| 66 |
+
in_bounds = (proj >= 0) & (proj <= ext_L)
|
| 67 |
+
|
| 68 |
+
closest = ext_start[np.newaxis, :] + proj[:, np.newaxis] * direction[np.newaxis, :]
|
| 69 |
+
perp = np.linalg.norm(colmap_xyz - closest, axis=1)
|
| 70 |
+
in_cyl = in_bounds & (perp <= CYL_RADIUS)
|
| 71 |
+
|
| 72 |
+
if int(in_cyl.sum()) <= 10:
|
| 73 |
+
return None
|
| 74 |
+
|
| 75 |
+
midpoint = (u_xyz + v_xyz) / 2
|
| 76 |
+
pts_centered = colmap_xyz[in_cyl] - midpoint
|
| 77 |
+
rgb_signed = colmap_rgb[in_cyl] * 2.0 - 1.0 # to [-1, 1]
|
| 78 |
+
patch_6d = np.hstack([pts_centered, rgb_signed])
|
| 79 |
+
return {'patch_6d': patch_6d}
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def label_user_edges(user_v, user_e, gt_v, gt_e, thresh=GT_VERTEX_THRESH):
|
| 83 |
+
"""For each user edge, return True if it matches a GT edge.
|
| 84 |
+
|
| 85 |
+
Match := both endpoints have a nearest GT vertex within `thresh`,
|
| 86 |
+
AND those GT vertices are connected in the GT edge set.
|
| 87 |
+
"""
|
| 88 |
+
if len(gt_v) == 0 or len(user_v) == 0:
|
| 89 |
+
return [None] * len(user_e)
|
| 90 |
+
|
| 91 |
+
d = cdist(user_v, gt_v)
|
| 92 |
+
user_to_gt = {}
|
| 93 |
+
for i in range(len(user_v)):
|
| 94 |
+
j = int(np.argmin(d[i]))
|
| 95 |
+
if d[i, j] < thresh:
|
| 96 |
+
user_to_gt[i] = j
|
| 97 |
+
|
| 98 |
+
gt_set = set()
|
| 99 |
+
for a, b in gt_e:
|
| 100 |
+
gt_set.add((int(min(a, b)), int(max(a, b))))
|
| 101 |
+
|
| 102 |
+
out = []
|
| 103 |
+
for u, v in user_e:
|
| 104 |
+
gu = user_to_gt.get(int(u))
|
| 105 |
+
gv = user_to_gt.get(int(v))
|
| 106 |
+
if gu is None or gv is None or gu == gv:
|
| 107 |
+
out.append(False)
|
| 108 |
+
continue
|
| 109 |
+
key = (min(gu, gv), max(gu, gv))
|
| 110 |
+
out.append(key in gt_set)
|
| 111 |
+
return out
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def smoke_test_one(sample, model, device):
|
| 115 |
+
order_id = sample['order_id']
|
| 116 |
+
print(f"\n=== {order_id} ===")
|
| 117 |
+
t0 = time.time()
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
with hc.suppress_stdout():
|
| 121 |
+
user_v, user_e = hc.hc_predict(sample, {})
|
| 122 |
+
except Exception as e:
|
| 123 |
+
print(f" user pipeline crashed: {e}")
|
| 124 |
+
return []
|
| 125 |
+
if len(user_v) == 0 or len(user_e) == 0:
|
| 126 |
+
print(" user pipeline empty")
|
| 127 |
+
return []
|
| 128 |
+
print(f" user: {len(user_v)} vertices, {len(user_e)} edges ({time.time()-t0:.1f}s)")
|
| 129 |
+
|
| 130 |
+
try:
|
| 131 |
+
colmap_rec = read_colmap_rec(sample['colmap'])
|
| 132 |
+
except Exception as e:
|
| 133 |
+
print(f" colmap parse crashed: {e}")
|
| 134 |
+
return []
|
| 135 |
+
|
| 136 |
+
cm_xyz, cm_rgb = colmap_points_xyz_rgb(colmap_rec)
|
| 137 |
+
print(f" colmap: {len(cm_xyz)} points")
|
| 138 |
+
if len(cm_xyz) == 0:
|
| 139 |
+
return []
|
| 140 |
+
|
| 141 |
+
gt_v = np.array(sample['wf_vertices']) if sample.get('wf_vertices') else np.empty((0, 3))
|
| 142 |
+
gt_e = [(int(a), int(b)) for a, b in sample.get('wf_edges', [])]
|
| 143 |
+
labels = label_user_edges(user_v, user_e, gt_v, gt_e)
|
| 144 |
+
|
| 145 |
+
results = []
|
| 146 |
+
skipped_sparse = 0
|
| 147 |
+
for (u_idx, v_idx), gt_match in zip(user_e, labels):
|
| 148 |
+
u_xyz = np.asarray(user_v[int(u_idx)])
|
| 149 |
+
v_xyz = np.asarray(user_v[int(v_idx)])
|
| 150 |
+
patch = build_edge_patch_6d(u_xyz, v_xyz, cm_xyz, cm_rgb)
|
| 151 |
+
if patch is None:
|
| 152 |
+
skipped_sparse += 1
|
| 153 |
+
continue
|
| 154 |
+
try:
|
| 155 |
+
cls_label, score = predict_class_from_patch(model, patch, device=device)
|
| 156 |
+
except Exception as e:
|
| 157 |
+
print(f" inference crashed: {e}")
|
| 158 |
+
continue
|
| 159 |
+
results.append({
|
| 160 |
+
'order_id': order_id,
|
| 161 |
+
'u': int(u_idx),
|
| 162 |
+
'v': int(v_idx),
|
| 163 |
+
'edge_length': float(np.linalg.norm(v_xyz - u_xyz)),
|
| 164 |
+
'patch_n_pts': int(patch['patch_6d'].shape[0]),
|
| 165 |
+
'pred_label': int(cls_label) if cls_label is not None else None,
|
| 166 |
+
'score': float(score),
|
| 167 |
+
'gt_match': bool(gt_match) if gt_match is not None else None,
|
| 168 |
+
})
|
| 169 |
+
|
| 170 |
+
if skipped_sparse:
|
| 171 |
+
print(f" {skipped_sparse} edges skipped (cylinder too sparse)")
|
| 172 |
+
n_gt_pos = sum(1 for r in results if r['gt_match'])
|
| 173 |
+
n_gt_neg = sum(1 for r in results if r['gt_match'] is False)
|
| 174 |
+
print(f" scored {len(results)} edges: {n_gt_pos} GT-positive, "
|
| 175 |
+
f"{n_gt_neg} GT-negative")
|
| 176 |
+
return results
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def main():
|
| 180 |
+
print("Loading pnet_class.pth...")
|
| 181 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 182 |
+
model = load_pnet_class(
|
| 183 |
+
os.path.join(CURRENT_DIR, '..', 'pnet_class_2026.pth'), device=device)
|
| 184 |
+
print(f" loaded on {device}")
|
| 185 |
+
|
| 186 |
+
print("\nStreaming validation...")
|
| 187 |
+
ds = load_dataset(
|
| 188 |
+
"usm3d/hoho22k_2026_trainval", split="validation",
|
| 189 |
+
streaming=True, trust_remote_code=True,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
all_rows = []
|
| 193 |
+
for i, sample in enumerate(ds):
|
| 194 |
+
if i >= NUM_TRIALS:
|
| 195 |
+
break
|
| 196 |
+
all_rows.extend(smoke_test_one(sample, model, device))
|
| 197 |
+
|
| 198 |
+
if not all_rows:
|
| 199 |
+
print("\nNo results -- every sample failed.")
|
| 200 |
+
return
|
| 201 |
+
|
| 202 |
+
scores = np.array([r['score'] for r in all_rows])
|
| 203 |
+
pos_scores = np.array([r['score'] for r in all_rows if r['gt_match']])
|
| 204 |
+
neg_scores = np.array([r['score'] for r in all_rows if r['gt_match'] is False])
|
| 205 |
+
|
| 206 |
+
print(f"\n=== Aggregate over {len(all_rows)} edges "
|
| 207 |
+
f"({len({r['order_id'] for r in all_rows})} samples) ===")
|
| 208 |
+
|
| 209 |
+
print(f"\nScore distribution (all {len(scores)} edges):")
|
| 210 |
+
print(f" mean={scores.mean():.3f} median={np.median(scores):.3f} "
|
| 211 |
+
f"std={scores.std():.3f} min={scores.min():.3f} max={scores.max():.3f}")
|
| 212 |
+
print(f" fraction <0.05: {(scores < 0.05).mean()*100:.0f}% "
|
| 213 |
+
f">0.65 (paper): {(scores > 0.65).mean()*100:.0f}% "
|
| 214 |
+
f">0.99: {(scores > 0.99).mean()*100:.0f}%")
|
| 215 |
+
|
| 216 |
+
if len(pos_scores) and len(neg_scores):
|
| 217 |
+
diff = float(pos_scores.mean() - neg_scores.mean())
|
| 218 |
+
print(f"\nGT-positive ({len(pos_scores)}): "
|
| 219 |
+
f"mean={pos_scores.mean():.3f} median={np.median(pos_scores):.3f} "
|
| 220 |
+
f"std={pos_scores.std():.3f}")
|
| 221 |
+
print(f"GT-negative ({len(neg_scores)}): "
|
| 222 |
+
f"mean={neg_scores.mean():.3f} median={np.median(neg_scores):.3f} "
|
| 223 |
+
f"std={neg_scores.std():.3f}")
|
| 224 |
+
print(f"Mean delta (pos-neg): {diff:+.3f}")
|
| 225 |
+
|
| 226 |
+
# Quick AUC via ranking
|
| 227 |
+
all_pairs = sorted([(s, 1) for s in pos_scores] + [(s, 0) for s in neg_scores])
|
| 228 |
+
n_pos, n_neg = len(pos_scores), len(neg_scores)
|
| 229 |
+
rank_sum_pos = sum(rank+1 for rank, (_, lab) in enumerate(all_pairs) if lab == 1)
|
| 230 |
+
auc = (rank_sum_pos - n_pos*(n_pos+1)/2) / (n_pos * n_neg) if n_pos and n_neg else 0
|
| 231 |
+
print(f"AUC (pos > neg): {auc:.3f}")
|
| 232 |
+
else:
|
| 233 |
+
print("\nMissing pos or neg group -- can't compute discrimination.")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
if __name__ == "__main__":
|
| 237 |
+
main()
|
training/fast_pointnet_class.py
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PointNet binary classifier over 6D (xyz+rgb) point-cloud patches: model,
|
| 2 |
+
dataset, training loop, and a single-patch predictor."""
|
| 3 |
+
import os
|
| 4 |
+
import torch
|
| 5 |
+
import torch.nn as nn
|
| 6 |
+
import torch.nn.functional as F
|
| 7 |
+
import numpy as np
|
| 8 |
+
import pickle
|
| 9 |
+
from torch.utils.data import Dataset, DataLoader
|
| 10 |
+
from typing import List, Dict, Tuple, Optional
|
| 11 |
+
import json
|
| 12 |
+
|
| 13 |
+
class ClassificationPointNet(nn.Module):
|
| 14 |
+
"""
|
| 15 |
+
PointNet implementation for binary classification from 6D point cloud patches.
|
| 16 |
+
Takes 6D point clouds (x,y,z,r,g,b) and predicts binary classification (edge/not edge).
|
| 17 |
+
"""
|
| 18 |
+
def __init__(self, input_dim=6, max_points=1024):
|
| 19 |
+
super(ClassificationPointNet, self).__init__()
|
| 20 |
+
self.max_points = max_points
|
| 21 |
+
|
| 22 |
+
# Point-wise feature extraction.
|
| 23 |
+
self.conv1 = nn.Conv1d(input_dim, 64, 1)
|
| 24 |
+
self.conv2 = nn.Conv1d(64, 128, 1)
|
| 25 |
+
self.conv3 = nn.Conv1d(128, 256, 1)
|
| 26 |
+
self.conv4 = nn.Conv1d(256, 512, 1)
|
| 27 |
+
self.conv5 = nn.Conv1d(512, 1024, 1)
|
| 28 |
+
self.conv6 = nn.Conv1d(1024, 2048, 1)
|
| 29 |
+
|
| 30 |
+
# Classification head.
|
| 31 |
+
self.fc1 = nn.Linear(2048, 1024)
|
| 32 |
+
self.fc2 = nn.Linear(1024, 512)
|
| 33 |
+
self.fc3 = nn.Linear(512, 256)
|
| 34 |
+
self.fc4 = nn.Linear(256, 128)
|
| 35 |
+
self.fc5 = nn.Linear(128, 64)
|
| 36 |
+
self.fc6 = nn.Linear(64, 1)
|
| 37 |
+
|
| 38 |
+
self.bn1 = nn.BatchNorm1d(64)
|
| 39 |
+
self.bn2 = nn.BatchNorm1d(128)
|
| 40 |
+
self.bn3 = nn.BatchNorm1d(256)
|
| 41 |
+
self.bn4 = nn.BatchNorm1d(512)
|
| 42 |
+
self.bn5 = nn.BatchNorm1d(1024)
|
| 43 |
+
self.bn6 = nn.BatchNorm1d(2048)
|
| 44 |
+
|
| 45 |
+
self.dropout1 = nn.Dropout(0.3)
|
| 46 |
+
self.dropout2 = nn.Dropout(0.4)
|
| 47 |
+
self.dropout3 = nn.Dropout(0.5)
|
| 48 |
+
self.dropout4 = nn.Dropout(0.4)
|
| 49 |
+
self.dropout5 = nn.Dropout(0.3)
|
| 50 |
+
|
| 51 |
+
def forward(self, x):
|
| 52 |
+
"""x: (B, input_dim, max_points) -> (B, 1) logits."""
|
| 53 |
+
batch_size = x.size(0)
|
| 54 |
+
|
| 55 |
+
x1 = F.relu(self.bn1(self.conv1(x)))
|
| 56 |
+
x2 = F.relu(self.bn2(self.conv2(x1)))
|
| 57 |
+
x3 = F.relu(self.bn3(self.conv3(x2)))
|
| 58 |
+
x4 = F.relu(self.bn4(self.conv4(x3)))
|
| 59 |
+
x5 = F.relu(self.bn5(self.conv5(x4)))
|
| 60 |
+
x6 = F.relu(self.bn6(self.conv6(x5)))
|
| 61 |
+
|
| 62 |
+
global_features = torch.max(x6, 2)[0] # (B, 2048)
|
| 63 |
+
|
| 64 |
+
x = F.relu(self.fc1(global_features))
|
| 65 |
+
x = self.dropout1(x)
|
| 66 |
+
x = F.relu(self.fc2(x))
|
| 67 |
+
x = self.dropout2(x)
|
| 68 |
+
x = F.relu(self.fc3(x))
|
| 69 |
+
x = self.dropout3(x)
|
| 70 |
+
x = F.relu(self.fc4(x))
|
| 71 |
+
x = self.dropout4(x)
|
| 72 |
+
x = F.relu(self.fc5(x))
|
| 73 |
+
x = self.dropout5(x)
|
| 74 |
+
classification = self.fc6(x)
|
| 75 |
+
|
| 76 |
+
return classification
|
| 77 |
+
|
| 78 |
+
class PatchClassificationDataset(Dataset):
|
| 79 |
+
"""Loads saved .pkl patches for PointNet classification training."""
|
| 80 |
+
|
| 81 |
+
def __init__(self, dataset_dir: str, max_points: int = 1024, augment: bool = True):
|
| 82 |
+
self.dataset_dir = dataset_dir
|
| 83 |
+
self.max_points = max_points
|
| 84 |
+
self.augment = augment
|
| 85 |
+
|
| 86 |
+
self.patch_files = []
|
| 87 |
+
for file in os.listdir(dataset_dir):
|
| 88 |
+
if file.endswith('.pkl'):
|
| 89 |
+
self.patch_files.append(os.path.join(dataset_dir, file))
|
| 90 |
+
|
| 91 |
+
print(f"Found {len(self.patch_files)} patch files in {dataset_dir}")
|
| 92 |
+
|
| 93 |
+
def __len__(self):
|
| 94 |
+
return len(self.patch_files)
|
| 95 |
+
|
| 96 |
+
def __getitem__(self, idx):
|
| 97 |
+
"""Returns (patch (6, max_points), label scalar, valid_mask (max_points,))."""
|
| 98 |
+
patch_file = self.patch_files[idx]
|
| 99 |
+
|
| 100 |
+
with open(patch_file, 'rb') as f:
|
| 101 |
+
patch_info = pickle.load(f)
|
| 102 |
+
|
| 103 |
+
patch_6d = patch_info['patch_6d'] # (N, 6)
|
| 104 |
+
label = patch_info.get('label', 0)
|
| 105 |
+
|
| 106 |
+
num_points = patch_6d.shape[0]
|
| 107 |
+
|
| 108 |
+
if num_points >= self.max_points:
|
| 109 |
+
indices = np.random.choice(num_points, self.max_points, replace=False)
|
| 110 |
+
patch_sampled = patch_6d[indices]
|
| 111 |
+
valid_mask = np.ones(self.max_points, dtype=bool)
|
| 112 |
+
else:
|
| 113 |
+
patch_sampled = np.zeros((self.max_points, 6))
|
| 114 |
+
patch_sampled[:num_points] = patch_6d
|
| 115 |
+
valid_mask = np.zeros(self.max_points, dtype=bool)
|
| 116 |
+
valid_mask[:num_points] = True
|
| 117 |
+
|
| 118 |
+
if self.augment:
|
| 119 |
+
patch_sampled = self._augment_patch(patch_sampled, valid_mask)
|
| 120 |
+
|
| 121 |
+
# conv1d wants channels first.
|
| 122 |
+
patch_tensor = torch.from_numpy(patch_sampled.T).float() # (6, max_points)
|
| 123 |
+
label_tensor = torch.tensor(label, dtype=torch.float32)
|
| 124 |
+
valid_mask_tensor = torch.from_numpy(valid_mask)
|
| 125 |
+
|
| 126 |
+
return patch_tensor, label_tensor, valid_mask_tensor
|
| 127 |
+
|
| 128 |
+
def _augment_patch(self, patch, valid_mask):
|
| 129 |
+
"""Random z-rotation, jitter, and scale on the xyz channels."""
|
| 130 |
+
valid_points = patch[valid_mask]
|
| 131 |
+
|
| 132 |
+
if len(valid_points) == 0:
|
| 133 |
+
return patch
|
| 134 |
+
|
| 135 |
+
angle = np.random.uniform(0, 2 * np.pi)
|
| 136 |
+
cos_angle = np.cos(angle)
|
| 137 |
+
sin_angle = np.sin(angle)
|
| 138 |
+
rotation_matrix = np.array([
|
| 139 |
+
[cos_angle, -sin_angle, 0],
|
| 140 |
+
[sin_angle, cos_angle, 0],
|
| 141 |
+
[0, 0, 1]
|
| 142 |
+
])
|
| 143 |
+
valid_points[:, :3] = valid_points[:, :3] @ rotation_matrix.T
|
| 144 |
+
|
| 145 |
+
noise = np.random.normal(0, 0.01, valid_points[:, :3].shape)
|
| 146 |
+
valid_points[:, :3] += noise
|
| 147 |
+
|
| 148 |
+
scale = np.random.uniform(0.9, 1.1)
|
| 149 |
+
valid_points[:, :3] *= scale
|
| 150 |
+
|
| 151 |
+
patch[valid_mask] = valid_points
|
| 152 |
+
return patch
|
| 153 |
+
|
| 154 |
+
def save_patches_dataset(patches: List[Dict], dataset_dir: str, entry_id: str):
|
| 155 |
+
"""Pickle each patch to dataset_dir as {entry_id}_patch_{i}.pkl (skips existing)."""
|
| 156 |
+
os.makedirs(dataset_dir, exist_ok=True)
|
| 157 |
+
|
| 158 |
+
for i, patch in enumerate(patches):
|
| 159 |
+
filename = f"{entry_id}_patch_{i}.pkl"
|
| 160 |
+
filepath = os.path.join(dataset_dir, filename)
|
| 161 |
+
if os.path.exists(filepath):
|
| 162 |
+
continue
|
| 163 |
+
with open(filepath, 'wb') as f:
|
| 164 |
+
pickle.dump(patch, f)
|
| 165 |
+
|
| 166 |
+
print(f"Saved {len(patches)} patches for entry {entry_id}")
|
| 167 |
+
|
| 168 |
+
def collate_fn(batch):
|
| 169 |
+
"""Drop samples with no valid points; return None if the whole batch is empty."""
|
| 170 |
+
valid_batch = []
|
| 171 |
+
for patch_data, label, valid_mask in batch:
|
| 172 |
+
if valid_mask.sum() > 0:
|
| 173 |
+
valid_batch.append((patch_data, label, valid_mask))
|
| 174 |
+
|
| 175 |
+
if len(valid_batch) == 0:
|
| 176 |
+
return None
|
| 177 |
+
|
| 178 |
+
patch_data = torch.stack([item[0] for item in valid_batch])
|
| 179 |
+
labels = torch.stack([item[1] for item in valid_batch])
|
| 180 |
+
valid_masks = torch.stack([item[2] for item in valid_batch])
|
| 181 |
+
|
| 182 |
+
return patch_data, labels, valid_masks
|
| 183 |
+
|
| 184 |
+
def init_weights(m):
|
| 185 |
+
if isinstance(m, nn.Conv1d):
|
| 186 |
+
nn.init.xavier_uniform_(m.weight)
|
| 187 |
+
if m.bias is not None:
|
| 188 |
+
nn.init.zeros_(m.bias)
|
| 189 |
+
elif isinstance(m, nn.Linear):
|
| 190 |
+
nn.init.xavier_uniform_(m.weight)
|
| 191 |
+
if m.bias is not None:
|
| 192 |
+
nn.init.zeros_(m.bias)
|
| 193 |
+
elif isinstance(m, nn.BatchNorm1d):
|
| 194 |
+
nn.init.ones_(m.weight)
|
| 195 |
+
nn.init.zeros_(m.bias)
|
| 196 |
+
|
| 197 |
+
def train_pointnet(dataset_dir: str, model_save_path: str, epochs: int = 100, batch_size: int = 32,
|
| 198 |
+
lr: float = 0.001):
|
| 199 |
+
"""Train ClassificationPointNet on the pickled patches in dataset_dir."""
|
| 200 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 201 |
+
print(f"Training on device: {device}")
|
| 202 |
+
|
| 203 |
+
dataset = PatchClassificationDataset(dataset_dir, max_points=1024, augment=True)
|
| 204 |
+
print(f"Dataset loaded with {len(dataset)} samples")
|
| 205 |
+
|
| 206 |
+
dataloader = DataLoader(dataset, batch_size=batch_size, shuffle=True, num_workers=8,
|
| 207 |
+
collate_fn=collate_fn, drop_last=True)
|
| 208 |
+
|
| 209 |
+
model = ClassificationPointNet(input_dim=6, max_points=1024)
|
| 210 |
+
model.apply(init_weights)
|
| 211 |
+
model.to(device)
|
| 212 |
+
|
| 213 |
+
criterion = nn.BCEWithLogitsLoss()
|
| 214 |
+
optimizer = torch.optim.Adam(model.parameters(), lr=lr, weight_decay=1e-4)
|
| 215 |
+
scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=30, gamma=0.5)
|
| 216 |
+
|
| 217 |
+
model.train()
|
| 218 |
+
for epoch in range(epochs):
|
| 219 |
+
total_loss = 0.0
|
| 220 |
+
correct = 0
|
| 221 |
+
total = 0
|
| 222 |
+
num_batches = 0
|
| 223 |
+
|
| 224 |
+
for batch_idx, batch_data in enumerate(dataloader):
|
| 225 |
+
if batch_data is None:
|
| 226 |
+
continue
|
| 227 |
+
|
| 228 |
+
patch_data, labels, valid_masks = batch_data
|
| 229 |
+
patch_data = patch_data.to(device)
|
| 230 |
+
labels = labels.to(device).unsqueeze(1)
|
| 231 |
+
|
| 232 |
+
optimizer.zero_grad()
|
| 233 |
+
outputs = model(patch_data)
|
| 234 |
+
loss = criterion(outputs, labels)
|
| 235 |
+
loss.backward()
|
| 236 |
+
optimizer.step()
|
| 237 |
+
|
| 238 |
+
total_loss += loss.item()
|
| 239 |
+
predicted = (torch.sigmoid(outputs) > 0.5).float()
|
| 240 |
+
total += labels.size(0)
|
| 241 |
+
correct += (predicted == labels).sum().item()
|
| 242 |
+
num_batches += 1
|
| 243 |
+
|
| 244 |
+
if batch_idx % 50 == 0:
|
| 245 |
+
print(f"Epoch {epoch+1}/{epochs}, Batch {batch_idx}, "
|
| 246 |
+
f"Loss: {loss.item():.6f}, "
|
| 247 |
+
f"Accuracy: {100 * correct / total:.2f}%")
|
| 248 |
+
|
| 249 |
+
avg_loss = total_loss / num_batches if num_batches > 0 else 0
|
| 250 |
+
accuracy = 100 * correct / total if total > 0 else 0
|
| 251 |
+
|
| 252 |
+
print(f"Epoch {epoch+1}/{epochs} completed, "
|
| 253 |
+
f"Avg Loss: {avg_loss:.6f}, "
|
| 254 |
+
f"Accuracy: {accuracy:.2f}%")
|
| 255 |
+
|
| 256 |
+
scheduler.step()
|
| 257 |
+
|
| 258 |
+
checkpoint_path = model_save_path.replace('.pth', f'_epoch_{epoch+1}.pth')
|
| 259 |
+
torch.save({
|
| 260 |
+
'model_state_dict': model.state_dict(),
|
| 261 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 262 |
+
'epoch': epoch + 1,
|
| 263 |
+
'loss': avg_loss,
|
| 264 |
+
'accuracy': accuracy,
|
| 265 |
+
}, checkpoint_path)
|
| 266 |
+
|
| 267 |
+
torch.save({
|
| 268 |
+
'model_state_dict': model.state_dict(),
|
| 269 |
+
'optimizer_state_dict': optimizer.state_dict(),
|
| 270 |
+
'epoch': epochs,
|
| 271 |
+
}, model_save_path)
|
| 272 |
+
|
| 273 |
+
print(f"Model saved to {model_save_path}")
|
| 274 |
+
return model
|
| 275 |
+
|
| 276 |
+
def load_pointnet_model(model_path: str, device: torch.device = None) -> ClassificationPointNet:
|
| 277 |
+
"""Load a trained ClassificationPointNet in eval mode."""
|
| 278 |
+
if device is None:
|
| 279 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 280 |
+
|
| 281 |
+
model = ClassificationPointNet(input_dim=6, max_points=1024)
|
| 282 |
+
|
| 283 |
+
checkpoint = torch.load(model_path, map_location=device)
|
| 284 |
+
model.load_state_dict(checkpoint['model_state_dict'])
|
| 285 |
+
|
| 286 |
+
model.to(device)
|
| 287 |
+
model.eval()
|
| 288 |
+
|
| 289 |
+
return model
|
| 290 |
+
|
| 291 |
+
def predict_class_from_patch(model: ClassificationPointNet, patch: Dict, device: torch.device = None) -> Tuple[int, float]:
|
| 292 |
+
"""Score one patch (dict with 'patch_6d'). Returns (predicted_class, probability)."""
|
| 293 |
+
if device is None:
|
| 294 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 295 |
+
|
| 296 |
+
patch_6d = patch['patch_6d'] # (N, 6)
|
| 297 |
+
|
| 298 |
+
max_points = 1024
|
| 299 |
+
num_points = patch_6d.shape[0]
|
| 300 |
+
|
| 301 |
+
if num_points >= max_points:
|
| 302 |
+
indices = np.random.choice(num_points, max_points, replace=False)
|
| 303 |
+
patch_sampled = patch_6d[indices]
|
| 304 |
+
else:
|
| 305 |
+
patch_sampled = np.zeros((max_points, 6))
|
| 306 |
+
patch_sampled[:num_points] = patch_6d
|
| 307 |
+
|
| 308 |
+
patch_tensor = torch.from_numpy(patch_sampled.T).float().unsqueeze(0) # (1, 6, max_points)
|
| 309 |
+
patch_tensor = patch_tensor.to(device)
|
| 310 |
+
|
| 311 |
+
with torch.no_grad():
|
| 312 |
+
outputs = model(patch_tensor)
|
| 313 |
+
probability = torch.sigmoid(outputs).item()
|
| 314 |
+
predicted_class = int(probability > 0.5)
|
| 315 |
+
|
| 316 |
+
return predicted_class, probability
|
| 317 |
+
|
training/gen_edge_dataset.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate HSS-aligned edge classifier training data from 2026 train split.
|
| 2 |
+
|
| 3 |
+
Per training sample:
|
| 4 |
+
1. Run user's solution.predict_wireframe -> (user_v, user_e)
|
| 5 |
+
2. Read COLMAP points from sample['colmap']
|
| 6 |
+
3. Compute HSS_full = hss(user_v, user_e, gt_v, gt_e)
|
| 7 |
+
4. For each edge e_i in user_e:
|
| 8 |
+
Compute HSS_minus_i = hss(user_v, user_e - {e_i}, gt_v, gt_e)
|
| 9 |
+
label = 1 if HSS_full > HSS_minus_i + EPS else 0
|
| 10 |
+
(label = 1 means "removing this edge hurt HSS, so it contributes")
|
| 11 |
+
Build 6D cylinder patch (1024 x [xyz, rgb])
|
| 12 |
+
5. Save (patch, label) per edge
|
| 13 |
+
|
| 14 |
+
Output: a directory of .npz files, one per sample, each containing arrays:
|
| 15 |
+
patches: (N_edges, 1024, 6) float32 -- point cloud cylinders
|
| 16 |
+
labels: (N_edges,) uint8 -- binary HSS-aligned labels
|
| 17 |
+
edge_meta: (N_edges, 4) int32 -- (sample_index, edge_idx, n_points, padded?)
|
| 18 |
+
|
| 19 |
+
Plus a manifest .json with sample_id -> file path mapping.
|
| 20 |
+
Resumable: skips samples whose output file already exists.
|
| 21 |
+
"""
|
| 22 |
+
import os
|
| 23 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
|
| 24 |
+
|
| 25 |
+
import argparse
|
| 26 |
+
import json
|
| 27 |
+
import sys
|
| 28 |
+
import time
|
| 29 |
+
from collections import Counter
|
| 30 |
+
|
| 31 |
+
import numpy as np
|
| 32 |
+
import torch
|
| 33 |
+
from datasets import load_dataset
|
| 34 |
+
|
| 35 |
+
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 36 |
+
sys.path.insert(0, CURRENT_DIR)
|
| 37 |
+
|
| 38 |
+
import hc_helpers as hc
|
| 39 |
+
from hoho2025.example_solutions import read_colmap_rec
|
| 40 |
+
from hoho2025.metric_helper import hss as hss_fn
|
| 41 |
+
from edge_patch import build_edge_patch_6d, colmap_points_xyz_rgb
|
| 42 |
+
|
| 43 |
+
EPS = 1e-6 # numerical tolerance for HSS comparisons
|
| 44 |
+
MAX_PATCH_POINTS = 1024
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def hss_value(v, e, gt_v, gt_e, vert_thresh=0.5, edge_thresh=0.5):
|
| 48 |
+
"""Compute HSS scalar; return 0 on failure."""
|
| 49 |
+
try:
|
| 50 |
+
s = hss_fn(np.asarray(v),
|
| 51 |
+
[(int(u), int(w)) for u, w in e],
|
| 52 |
+
gt_v, gt_e,
|
| 53 |
+
vert_thresh=vert_thresh, edge_thresh=edge_thresh)
|
| 54 |
+
return float(s.hss)
|
| 55 |
+
except Exception:
|
| 56 |
+
return 0.0
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def compute_per_edge_labels(user_v, user_e, gt_v, gt_e):
|
| 60 |
+
"""Ablation: per-edge HSS contribution.
|
| 61 |
+
|
| 62 |
+
label[i] = 1 if removing edge i decreases HSS, else 0.
|
| 63 |
+
"""
|
| 64 |
+
if len(user_e) == 0:
|
| 65 |
+
return np.zeros((0,), dtype=np.uint8)
|
| 66 |
+
full = hss_value(user_v, user_e, gt_v, gt_e)
|
| 67 |
+
labels = np.zeros(len(user_e), dtype=np.uint8)
|
| 68 |
+
for i in range(len(user_e)):
|
| 69 |
+
# Skip edge i
|
| 70 |
+
e_minus = user_e[:i] + user_e[i + 1:]
|
| 71 |
+
without = hss_value(user_v, e_minus, gt_v, gt_e)
|
| 72 |
+
labels[i] = 1 if full > without + EPS else 0
|
| 73 |
+
return labels
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def pad_or_sample_patch(patch_6d, max_pts=MAX_PATCH_POINTS, rng=None):
|
| 77 |
+
"""Pad with zeros or random-sample to exactly max_pts points."""
|
| 78 |
+
if rng is None:
|
| 79 |
+
rng = np.random
|
| 80 |
+
n = patch_6d.shape[0]
|
| 81 |
+
if n >= max_pts:
|
| 82 |
+
idx = rng.choice(n, max_pts, replace=False)
|
| 83 |
+
return patch_6d[idx], False # not padded
|
| 84 |
+
# Pad with zeros
|
| 85 |
+
out = np.zeros((max_pts, 6), dtype=np.float32)
|
| 86 |
+
out[:n] = patch_6d
|
| 87 |
+
return out, True # padded
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def process_sample(sample, rng):
|
| 91 |
+
"""Process one sample. Returns (patches, labels, edge_meta) or None."""
|
| 92 |
+
order_id = sample['order_id']
|
| 93 |
+
|
| 94 |
+
try:
|
| 95 |
+
with hc.suppress_stdout():
|
| 96 |
+
user_v, user_e = hc.hc_predict(sample, {})
|
| 97 |
+
user_v = np.asarray(user_v, dtype=np.float32)
|
| 98 |
+
user_e = [(int(u), int(w)) for u, w in user_e]
|
| 99 |
+
except Exception as e:
|
| 100 |
+
return None, f"hc_predict crashed: {e}"
|
| 101 |
+
|
| 102 |
+
if len(user_v) == 0 or len(user_e) == 0:
|
| 103 |
+
return None, "empty handcrafted output"
|
| 104 |
+
|
| 105 |
+
try:
|
| 106 |
+
colmap_rec = read_colmap_rec(sample['colmap'])
|
| 107 |
+
cm_xyz, cm_rgb = colmap_points_xyz_rgb(colmap_rec)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
return None, f"colmap parse crashed: {e}"
|
| 110 |
+
if len(cm_xyz) == 0:
|
| 111 |
+
return None, "empty colmap"
|
| 112 |
+
|
| 113 |
+
# GT
|
| 114 |
+
gt_v = sample.get('wf_vertices')
|
| 115 |
+
gt_e = sample.get('wf_edges')
|
| 116 |
+
if gt_v is None or gt_e is None:
|
| 117 |
+
return None, "no GT in sample"
|
| 118 |
+
gt_v = np.asarray(gt_v, dtype=np.float32)
|
| 119 |
+
gt_e = [(int(u), int(w)) for u, w in gt_e]
|
| 120 |
+
|
| 121 |
+
# Compute per-edge HSS-aligned labels
|
| 122 |
+
labels = compute_per_edge_labels(user_v, user_e, gt_v, gt_e)
|
| 123 |
+
|
| 124 |
+
# Build per-edge patches
|
| 125 |
+
patches = np.zeros((len(user_e), MAX_PATCH_POINTS, 6), dtype=np.float32)
|
| 126 |
+
edge_meta = np.zeros((len(user_e), 4), dtype=np.int32)
|
| 127 |
+
valid_count = 0
|
| 128 |
+
valid_indices = []
|
| 129 |
+
|
| 130 |
+
for i, (u, v) in enumerate(user_e):
|
| 131 |
+
u_xyz = user_v[int(u)]
|
| 132 |
+
v_xyz = user_v[int(v)]
|
| 133 |
+
patch = build_edge_patch_6d(u_xyz, v_xyz, cm_xyz, cm_rgb)
|
| 134 |
+
if patch is None:
|
| 135 |
+
continue
|
| 136 |
+
patch_6d = patch['patch_6d'].astype(np.float32)
|
| 137 |
+
n_pts_raw = patch_6d.shape[0]
|
| 138 |
+
sampled, padded = pad_or_sample_patch(patch_6d, rng=rng)
|
| 139 |
+
patches[valid_count] = sampled
|
| 140 |
+
edge_meta[valid_count] = [i, int(u), int(v), n_pts_raw]
|
| 141 |
+
valid_indices.append(i)
|
| 142 |
+
valid_count += 1
|
| 143 |
+
|
| 144 |
+
if valid_count == 0:
|
| 145 |
+
return None, "no valid patches built"
|
| 146 |
+
|
| 147 |
+
return {
|
| 148 |
+
'patches': patches[:valid_count],
|
| 149 |
+
'labels': labels[valid_indices],
|
| 150 |
+
'edge_meta': edge_meta[:valid_count],
|
| 151 |
+
'order_id': order_id,
|
| 152 |
+
'n_edges_total': len(user_e),
|
| 153 |
+
'n_patches_valid': valid_count,
|
| 154 |
+
}, None
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _worker_entry(sample, seed):
|
| 158 |
+
"""Top-level worker function for ProcessPoolExecutor (must be picklable)."""
|
| 159 |
+
rng = np.random.RandomState(seed)
|
| 160 |
+
return process_sample(sample, rng)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
def main():
|
| 164 |
+
parser = argparse.ArgumentParser()
|
| 165 |
+
parser.add_argument('--out-dir', required=True, help='Directory for .npz files')
|
| 166 |
+
parser.add_argument('--split', default='train',
|
| 167 |
+
help='HF split: train (default) or validation')
|
| 168 |
+
parser.add_argument('--dataset', default='usm3d/hoho22k_2026_trainval:train',
|
| 169 |
+
help='HF dataset spec. Default uses the trainval dataset '
|
| 170 |
+
'train split (avoids dill pickling bug on Python 3.14 '
|
| 171 |
+
'with the sampled_v2 dataset). For the canonical '
|
| 172 |
+
'training cache, pass --dataset usm3d/s23dr-2026-sampled_4096_v2:train')
|
| 173 |
+
parser.add_argument('--max-samples', type=int, default=None,
|
| 174 |
+
help='Limit samples (for sanity runs)')
|
| 175 |
+
parser.add_argument('--seed', type=int, default=2718)
|
| 176 |
+
parser.add_argument('--workers', type=int, default=1,
|
| 177 |
+
help='Parallel worker processes (1=single-process; '
|
| 178 |
+
'set to ~ncpu for large runs).')
|
| 179 |
+
parser.add_argument('--in-flight', type=int, default=None,
|
| 180 |
+
help='Max in-flight tasks (default workers*3). Higher uses more RAM.')
|
| 181 |
+
args = parser.parse_args()
|
| 182 |
+
|
| 183 |
+
os.makedirs(args.out_dir, exist_ok=True)
|
| 184 |
+
|
| 185 |
+
# Resolve dataset spec
|
| 186 |
+
if ':' in args.dataset:
|
| 187 |
+
repo, hf_split = args.dataset.split(':')
|
| 188 |
+
else:
|
| 189 |
+
repo, hf_split = args.dataset, args.split
|
| 190 |
+
|
| 191 |
+
print(f"Loading dataset: {repo} split={hf_split}")
|
| 192 |
+
ds = load_dataset(repo, split=hf_split, streaming=True, trust_remote_code=True)
|
| 193 |
+
|
| 194 |
+
rng = np.random.RandomState(args.seed)
|
| 195 |
+
|
| 196 |
+
manifest_path = os.path.join(args.out_dir, 'manifest.json')
|
| 197 |
+
if os.path.exists(manifest_path):
|
| 198 |
+
with open(manifest_path) as f:
|
| 199 |
+
manifest = json.load(f)
|
| 200 |
+
else:
|
| 201 |
+
manifest = {'samples': {}, 'config': {
|
| 202 |
+
'dataset': args.dataset,
|
| 203 |
+
'max_samples': args.max_samples,
|
| 204 |
+
'seed': args.seed,
|
| 205 |
+
'max_patch_points': MAX_PATCH_POINTS,
|
| 206 |
+
'workers': args.workers,
|
| 207 |
+
}}
|
| 208 |
+
|
| 209 |
+
n_done = len(manifest['samples'])
|
| 210 |
+
print(f"Resuming with {n_done} samples already done; workers={args.workers}")
|
| 211 |
+
|
| 212 |
+
# Mutable counters (closed over by handle_result)
|
| 213 |
+
state = {
|
| 214 |
+
'total_patches': 0,
|
| 215 |
+
'total_pos': 0,
|
| 216 |
+
'total_neg': 0,
|
| 217 |
+
'total_skipped': 0,
|
| 218 |
+
'seen_count': n_done,
|
| 219 |
+
't_start': time.time(),
|
| 220 |
+
}
|
| 221 |
+
crash_reasons = Counter()
|
| 222 |
+
|
| 223 |
+
def handle_result(result, err, order_id):
|
| 224 |
+
if result is None:
|
| 225 |
+
crash_reasons[err] += 1
|
| 226 |
+
manifest['samples'][order_id] = {'error': err}
|
| 227 |
+
state['total_skipped'] += 1
|
| 228 |
+
else:
|
| 229 |
+
out_path = os.path.join(args.out_dir, f'{order_id}.npz')
|
| 230 |
+
np.savez_compressed(out_path,
|
| 231 |
+
patches=result['patches'],
|
| 232 |
+
labels=result['labels'],
|
| 233 |
+
edge_meta=result['edge_meta'])
|
| 234 |
+
manifest['samples'][order_id] = {
|
| 235 |
+
'path': f'{order_id}.npz',
|
| 236 |
+
'n_patches': result['n_patches_valid'],
|
| 237 |
+
'n_edges_total': result['n_edges_total'],
|
| 238 |
+
}
|
| 239 |
+
n_pos = int(result['labels'].sum())
|
| 240 |
+
n_neg = int(len(result['labels']) - n_pos)
|
| 241 |
+
state['total_patches'] += result['n_patches_valid']
|
| 242 |
+
state['total_pos'] += n_pos
|
| 243 |
+
state['total_neg'] += n_neg
|
| 244 |
+
|
| 245 |
+
state['seen_count'] += 1
|
| 246 |
+
|
| 247 |
+
if state['seen_count'] % 50 == 0:
|
| 248 |
+
with open(manifest_path, 'w') as f:
|
| 249 |
+
json.dump(manifest, f)
|
| 250 |
+
|
| 251 |
+
if state['seen_count'] % 25 == 0:
|
| 252 |
+
elapsed = time.time() - state['t_start']
|
| 253 |
+
rate = (state['seen_count'] - n_done) / max(elapsed, 1e-6)
|
| 254 |
+
pos_frac = state['total_pos'] / max(state['total_patches'], 1)
|
| 255 |
+
print(f"[{state['seen_count']}] order={order_id} "
|
| 256 |
+
f"rate={rate:.2f}/s patches={state['total_patches']} "
|
| 257 |
+
f"pos={state['total_pos']} neg={state['total_neg']} "
|
| 258 |
+
f"pos_frac={pos_frac:.3f} skipped={state['total_skipped']}")
|
| 259 |
+
|
| 260 |
+
target = args.max_samples if args.max_samples else float('inf')
|
| 261 |
+
|
| 262 |
+
MAX_RETRIES = 8
|
| 263 |
+
BACKOFF = 10
|
| 264 |
+
|
| 265 |
+
if args.workers <= 1:
|
| 266 |
+
# ---- Single-process path ----
|
| 267 |
+
attempts = 0
|
| 268 |
+
stream_done = False
|
| 269 |
+
while not stream_done and state['seen_count'] < target:
|
| 270 |
+
if attempts >= MAX_RETRIES:
|
| 271 |
+
print(f"[STREAM] giving up after {attempts} attempts")
|
| 272 |
+
break
|
| 273 |
+
attempts += 1
|
| 274 |
+
if attempts > 1:
|
| 275 |
+
print(f"[STREAM] retry {attempts}/{MAX_RETRIES} after {BACKOFF}s")
|
| 276 |
+
time.sleep(BACKOFF)
|
| 277 |
+
try:
|
| 278 |
+
for sample in ds:
|
| 279 |
+
if state['seen_count'] >= target:
|
| 280 |
+
break
|
| 281 |
+
order_id = sample['order_id']
|
| 282 |
+
if order_id in manifest['samples']:
|
| 283 |
+
continue
|
| 284 |
+
result, err = process_sample(sample, rng)
|
| 285 |
+
handle_result(result, err, order_id)
|
| 286 |
+
attempts = 0
|
| 287 |
+
except Exception as e:
|
| 288 |
+
# Catch broadly: HF/webdataset streaming can raise OSError,
|
| 289 |
+
# IOError, tarfile.ReadError, EOFError, etc. on shard blips.
|
| 290 |
+
# MAX_RETRIES bounds attempts, so a real bug still surfaces.
|
| 291 |
+
print(f"[STREAM ERROR] {type(e).__name__}: {str(e)[:200]}")
|
| 292 |
+
continue
|
| 293 |
+
else:
|
| 294 |
+
stream_done = True
|
| 295 |
+
break
|
| 296 |
+
else:
|
| 297 |
+
# ---- Parallel path ----
|
| 298 |
+
from concurrent.futures import (
|
| 299 |
+
ProcessPoolExecutor, wait, FIRST_COMPLETED, as_completed,
|
| 300 |
+
)
|
| 301 |
+
import multiprocessing as mp
|
| 302 |
+
max_inflight = args.in_flight or (args.workers * 3)
|
| 303 |
+
# Force 'spawn' start method: 'fork' (Linux default) deadlocks when
|
| 304 |
+
# torch/OMP threads have been initialized in the parent.
|
| 305 |
+
ctx = mp.get_context('spawn')
|
| 306 |
+
print(f"Parallel mode: workers={args.workers}, in_flight={max_inflight}, start=spawn")
|
| 307 |
+
executor = ProcessPoolExecutor(max_workers=args.workers, mp_context=ctx)
|
| 308 |
+
pending = {} # future -> order_id
|
| 309 |
+
|
| 310 |
+
def drain_one():
|
| 311 |
+
done, _ = wait(list(pending.keys()), return_when=FIRST_COMPLETED)
|
| 312 |
+
for f in done:
|
| 313 |
+
oid = pending.pop(f)
|
| 314 |
+
try:
|
| 315 |
+
result, err = f.result()
|
| 316 |
+
except Exception as e:
|
| 317 |
+
result, err = None, f"worker crashed: {type(e).__name__}: {e}"
|
| 318 |
+
handle_result(result, err, oid)
|
| 319 |
+
|
| 320 |
+
attempts = 0
|
| 321 |
+
stream_done = False
|
| 322 |
+
try:
|
| 323 |
+
while not stream_done and (state['seen_count'] + len(pending)) < target:
|
| 324 |
+
if attempts >= MAX_RETRIES:
|
| 325 |
+
print(f"[STREAM] giving up after {attempts} attempts")
|
| 326 |
+
break
|
| 327 |
+
attempts += 1
|
| 328 |
+
if attempts > 1:
|
| 329 |
+
print(f"[STREAM] retry {attempts}/{MAX_RETRIES} after {BACKOFF}s")
|
| 330 |
+
time.sleep(BACKOFF)
|
| 331 |
+
try:
|
| 332 |
+
for sample in ds:
|
| 333 |
+
if (state['seen_count'] + len(pending)) >= target:
|
| 334 |
+
break
|
| 335 |
+
order_id = sample['order_id']
|
| 336 |
+
if order_id in manifest['samples']:
|
| 337 |
+
continue
|
| 338 |
+
# Throttle: wait for a worker to free up
|
| 339 |
+
while len(pending) >= max_inflight:
|
| 340 |
+
drain_one()
|
| 341 |
+
seed = int(rng.randint(0, 2**31 - 1))
|
| 342 |
+
fut = executor.submit(_worker_entry, sample, seed)
|
| 343 |
+
pending[fut] = order_id
|
| 344 |
+
attempts = 0
|
| 345 |
+
except Exception as e:
|
| 346 |
+
# Catch broadly: HF/webdataset streaming can raise OSError,
|
| 347 |
+
# IOError, tarfile.ReadError, EOFError, etc. on shard blips.
|
| 348 |
+
# MAX_RETRIES bounds attempts, so a real bug still surfaces.
|
| 349 |
+
print(f"[STREAM ERROR] {type(e).__name__}: {str(e)[:200]}")
|
| 350 |
+
continue
|
| 351 |
+
else:
|
| 352 |
+
stream_done = True
|
| 353 |
+
break
|
| 354 |
+
|
| 355 |
+
# Drain remaining pending tasks
|
| 356 |
+
for f in as_completed(list(pending.keys())):
|
| 357 |
+
oid = pending.pop(f)
|
| 358 |
+
try:
|
| 359 |
+
result, err = f.result()
|
| 360 |
+
except Exception as e:
|
| 361 |
+
result, err = None, f"worker crashed: {type(e).__name__}: {e}"
|
| 362 |
+
handle_result(result, err, oid)
|
| 363 |
+
finally:
|
| 364 |
+
executor.shutdown(wait=True)
|
| 365 |
+
|
| 366 |
+
# Final save
|
| 367 |
+
with open(manifest_path, 'w') as f:
|
| 368 |
+
json.dump(manifest, f)
|
| 369 |
+
|
| 370 |
+
# Summary
|
| 371 |
+
print("\n========== Dataset gen summary ==========")
|
| 372 |
+
print(f"Samples processed: {len(manifest['samples'])}")
|
| 373 |
+
print(f" Successful: {sum(1 for v in manifest['samples'].values() if 'path' in v)}")
|
| 374 |
+
print(f" Skipped: {state['total_skipped']}")
|
| 375 |
+
print(f" Crash reasons: {dict(crash_reasons.most_common(5))}")
|
| 376 |
+
print(f"\nTotal patches: {state['total_patches']}")
|
| 377 |
+
print(f" Positive labels: {state['total_pos']} "
|
| 378 |
+
f"({100*state['total_pos']/max(state['total_patches'],1):.1f}%)")
|
| 379 |
+
print(f" Negative labels: {state['total_neg']} "
|
| 380 |
+
f"({100*state['total_neg']/max(state['total_patches'],1):.1f}%)")
|
| 381 |
+
if state['total_patches']:
|
| 382 |
+
avg = state['total_patches'] / max(state['seen_count'], 1)
|
| 383 |
+
print(f"\nAvg patches/sample: {avg:.1f}")
|
| 384 |
+
per_patch_size = MAX_PATCH_POINTS * 6 * 4 # bytes
|
| 385 |
+
print(f"Disk estimate: "
|
| 386 |
+
f"{state['total_patches'] * per_patch_size / 1e9:.2f} GB uncompressed")
|
| 387 |
+
|
| 388 |
+
print(f"\nElapsed: {time.time()-state['t_start']:.0f}s")
|
| 389 |
+
print(f"Output: {args.out_dir}")
|
| 390 |
+
print(f"Manifest: {manifest_path}")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
if __name__ == '__main__':
|
| 394 |
+
main()
|
training/gen_routing_dataset.py
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate routing dataset for GBM training.
|
| 2 |
+
|
| 3 |
+
Runs both pipelines per sample and collects rich per-sample features
|
| 4 |
+
for a GBM that predicts which model wins (4096+seam vs 8192+noseam).
|
| 5 |
+
|
| 6 |
+
Features collected:
|
| 7 |
+
Structural: n_hc, n_hc_edges, n_colmap, n_images
|
| 8 |
+
Seam diagnostics: seam_fired, seam_med_dist, seam_max_dist, n_snapped
|
| 9 |
+
Geometry: hc_spread_xy, hc_spread_z, colmap_spread_xy, colmap_spread_z
|
| 10 |
+
ML output: n_segs_4096, n_segs_8192 (surviving after conf>0.5)
|
| 11 |
+
Label: delta = sc_8192noseam - sc_4096seam
|
| 12 |
+
|
| 13 |
+
Saves to a JSON file incrementally (safe to interrupt and resume). Requires the
|
| 14 |
+
competition dataset (set the S23DR_DATASET environment variable to its path).
|
| 15 |
+
|
| 16 |
+
Usage:
|
| 17 |
+
python3 gen_routing_dataset.py --ckpt-new path/to/8k_checkpoint.pt --n 1000
|
| 18 |
+
"""
|
| 19 |
+
import argparse
|
| 20 |
+
import contextlib
|
| 21 |
+
import io
|
| 22 |
+
import json
|
| 23 |
+
import os
|
| 24 |
+
import sys
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
from tqdm import tqdm
|
| 28 |
+
|
| 29 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
|
| 30 |
+
|
| 31 |
+
# Resolve modules relative to this repository.
|
| 32 |
+
TRAIN_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 33 |
+
REPO_ROOT = os.path.abspath(os.path.join(TRAIN_DIR, '..'))
|
| 34 |
+
DATASET_DIR = os.environ.get('S23DR_DATASET', 'dataset') # competition dataset tars
|
| 35 |
+
|
| 36 |
+
sys.path.insert(0, TRAIN_DIR) # local_dataset
|
| 37 |
+
sys.path.insert(0, REPO_ROOT) # script, solution, edge_classifier, vertex_refiner
|
| 38 |
+
|
| 39 |
+
import script as s
|
| 40 |
+
import solution as sol
|
| 41 |
+
import edge_classifier as ec
|
| 42 |
+
import vertex_refiner as vr
|
| 43 |
+
from local_dataset import iter_split
|
| 44 |
+
from hoho2025.metric_helper import hss
|
| 45 |
+
from s23dr_2026_example.tokenizer import EdgeDepthSequenceConfig
|
| 46 |
+
from s23dr_2026_example.model import EdgeDepthSegmentsModel
|
| 47 |
+
|
| 48 |
+
EDGE_AUG_THR = 0.55
|
| 49 |
+
VERT_AUG_THR = 0.55
|
| 50 |
+
SEAM_RADIUS = 1.1
|
| 51 |
+
SEAM_GUARD = 2.0
|
| 52 |
+
CONF_THRESH = 0.5
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@contextlib.contextmanager
|
| 56 |
+
def suppress_stdout():
|
| 57 |
+
saved = sys.stdout; sys.stdout = io.StringIO()
|
| 58 |
+
try: yield
|
| 59 |
+
finally: sys.stdout = saved
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def load_8192_model(ckpt_path, device):
|
| 63 |
+
ckpt = torch.load(ckpt_path, map_location=device, weights_only=False)
|
| 64 |
+
args = ckpt.get('args', {})
|
| 65 |
+
norm_class = torch.nn.RMSNorm if args.get('rms_norm', True) else None
|
| 66 |
+
model = EdgeDepthSegmentsModel(
|
| 67 |
+
seq_cfg=EdgeDepthSequenceConfig(seq_len=8192, colmap_points=6144, depth_points=2048),
|
| 68 |
+
segments=args.get('segments', 64), hidden=args.get('hidden', 256),
|
| 69 |
+
num_heads=args.get('num_heads', 4), kv_heads_cross=args.get('kv_heads_cross', 2),
|
| 70 |
+
kv_heads_self=args.get('kv_heads_self', 2), dim_feedforward=args.get('ff', 1024),
|
| 71 |
+
dropout=0.0, latent_tokens=args.get('latent_tokens', 256),
|
| 72 |
+
latent_layers=args.get('latent_layers', 7), decoder_layers=args.get('decoder_layers', 3),
|
| 73 |
+
cross_attn_interval=args.get('cross_attn_interval', 4), norm_class=norm_class,
|
| 74 |
+
segment_conf=args.get('segment_conf', True),
|
| 75 |
+
segment_param=args.get('segment_param', 'midpoint_dir_len'),
|
| 76 |
+
behind_emb_dim=args.get('behind_emb_dim', 8),
|
| 77 |
+
use_vote_features=args.get('vote_features', True),
|
| 78 |
+
qk_norm=args.get('qk_norm', True), qk_norm_type=args.get('qk_norm_type', 'l2'),
|
| 79 |
+
).to(device)
|
| 80 |
+
state = {k.replace('segmenter._orig_mod.', 'segmenter.'): v for k, v in ckpt['model'].items()}
|
| 81 |
+
model.load_state_dict(state)
|
| 82 |
+
model.eval()
|
| 83 |
+
return model
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def get_conf_segs(fused, model, device):
|
| 87 |
+
"""Forward pass only -- return (n_conf_segs, mean_conf, min_conf)."""
|
| 88 |
+
tokens, masks = s.build_tokens_single(fused, model, device)
|
| 89 |
+
with torch.no_grad(), torch.autocast('cuda', torch.float16, enabled=(device.type == 'cuda')):
|
| 90 |
+
out = model.forward_tokens(tokens, masks)
|
| 91 |
+
if 'conf' in out:
|
| 92 |
+
conf = torch.sigmoid(out['conf'][0].float()).cpu().numpy()
|
| 93 |
+
above = conf > CONF_THRESH
|
| 94 |
+
n = int(above.sum())
|
| 95 |
+
mean_c = float(conf.mean())
|
| 96 |
+
mean_c_above = float(conf[above].mean()) if n > 0 else 0.0
|
| 97 |
+
return n, mean_c, mean_c_above
|
| 98 |
+
return 64, 1.0, 1.0
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def snap_to_hc_with_diagnostics(ml_v, ml_e, hc_v):
|
| 102 |
+
"""Like snap_to_hc but also returns diagnostics."""
|
| 103 |
+
from scipy.spatial.distance import cdist
|
| 104 |
+
diag = {'fired': False, 'med_dist': float('nan'),
|
| 105 |
+
'max_dist': float('nan'), 'n_snapped': 0}
|
| 106 |
+
if hc_v is None or len(hc_v) == 0 or len(ml_v) == 0:
|
| 107 |
+
return ml_v, list(ml_e), diag
|
| 108 |
+
dists = cdist(ml_v, hc_v)
|
| 109 |
+
min_dists = np.min(dists, axis=0)
|
| 110 |
+
diag['med_dist'] = float(np.median(min_dists))
|
| 111 |
+
diag['max_dist'] = float(np.max(min_dists))
|
| 112 |
+
if diag['med_dist'] > SEAM_GUARD:
|
| 113 |
+
return ml_v, list(ml_e), diag
|
| 114 |
+
diag['fired'] = True
|
| 115 |
+
snapped = ml_v.copy()
|
| 116 |
+
used_l, used_c = set(), set()
|
| 117 |
+
rows, cols = np.where(dists <= SEAM_RADIUS)
|
| 118 |
+
if len(rows):
|
| 119 |
+
for k in np.argsort(dists[rows, cols]):
|
| 120 |
+
i, j = int(rows[k]), int(cols[k])
|
| 121 |
+
if i not in used_l and j not in used_c:
|
| 122 |
+
snapped[i] = 0.3 * snapped[i] + 0.7 * hc_v[j]
|
| 123 |
+
used_l.add(i); used_c.add(j)
|
| 124 |
+
diag['n_snapped'] += 1
|
| 125 |
+
seen, new_e = set(), []
|
| 126 |
+
for u, v in ml_e:
|
| 127 |
+
if u == v: continue
|
| 128 |
+
key = tuple(sorted((u, v)))
|
| 129 |
+
if key not in seen:
|
| 130 |
+
seen.add(key); new_e.append((u, v))
|
| 131 |
+
return snapped, new_e, diag
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def run_augments(pv, pe, hc_v, hc_e, cm_xyz, cm_rgb, edge_model, vertex_model, device, rng):
|
| 135 |
+
pv = np.asarray(pv)
|
| 136 |
+
if edge_model is not None and hc_e and len(cm_xyz) > 0 and len(hc_v) > 0:
|
| 137 |
+
try:
|
| 138 |
+
e_sc = ec.score_edges_batched(edge_model, device, hc_v, hc_e, cm_xyz, cm_rgb, rng=rng)
|
| 139 |
+
pv, pe = ec.augment_hybrid_with_filtered_hc(pv, pe, hc_v, hc_e, e_sc, thresh=EDGE_AUG_THR)
|
| 140 |
+
pv = np.asarray(pv)
|
| 141 |
+
except Exception:
|
| 142 |
+
pass
|
| 143 |
+
if vertex_model is not None and len(cm_xyz) > 0 and len(hc_v) > 0:
|
| 144 |
+
try:
|
| 145 |
+
v_sc = vr.score_vertices_batched(vertex_model, device, hc_v, cm_xyz, cm_rgb, rng=rng)
|
| 146 |
+
pv, pe = vr.augment_hybrid_with_filtered_hc_vertices(pv, pe, hc_v, v_sc, threshold=VERT_AUG_THR)
|
| 147 |
+
pv = np.asarray(pv)
|
| 148 |
+
except Exception:
|
| 149 |
+
pass
|
| 150 |
+
return pv, [(int(a), int(b)) for a, b in pe]
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def score_hss(pv, pe, gt_v, gt_e):
|
| 154 |
+
try:
|
| 155 |
+
m = hss(np.asarray(pv).tolist(), [(int(a), int(b)) for a, b in pe],
|
| 156 |
+
gt_v, gt_e, vert_thresh=0.5, edge_thresh=0.5)
|
| 157 |
+
return float(m.hss)
|
| 158 |
+
except Exception:
|
| 159 |
+
return 0.0
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
def geometry_stats(pts):
|
| 163 |
+
"""Return spread stats for a point cloud (N,3)."""
|
| 164 |
+
if len(pts) < 2:
|
| 165 |
+
return {'spread_xy': 0.0, 'spread_z': 0.0, 'n': len(pts)}
|
| 166 |
+
xy_std = float(np.std(pts[:, :2]))
|
| 167 |
+
z_std = float(np.std(pts[:, 2]))
|
| 168 |
+
return {'spread_xy': xy_std, 'spread_z': z_std, 'n': len(pts)}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def main():
|
| 172 |
+
p = argparse.ArgumentParser()
|
| 173 |
+
p.add_argument('--ckpt-new', required=True)
|
| 174 |
+
p.add_argument('--ckpt-old', default=f'{REPO_ROOT}/checkpoint.pt')
|
| 175 |
+
p.add_argument('--split', default='validation')
|
| 176 |
+
p.add_argument('--n', type=int, default=1000)
|
| 177 |
+
p.add_argument('--out', default=f'{REPO_ROOT}/routing_dataset.json')
|
| 178 |
+
args = p.parse_args()
|
| 179 |
+
|
| 180 |
+
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
|
| 181 |
+
print(f"Device: {device}")
|
| 182 |
+
|
| 183 |
+
model_old = s.load_model(args.ckpt_old, device)
|
| 184 |
+
model_new = load_8192_model(args.ckpt_new, device)
|
| 185 |
+
edge_model = ec.load_pnet_class(f'{REPO_ROOT}/pnet_class_2026.pth', device=device)
|
| 186 |
+
vertex_model = vr.load_vertex_model(f'{REPO_ROOT}/vertex_refiner.pth', device=device)
|
| 187 |
+
print(f"Models loaded. Collecting {args.n} samples from '{args.split}' split.")
|
| 188 |
+
print(f"Output: {args.out}")
|
| 189 |
+
|
| 190 |
+
# Resume from existing file
|
| 191 |
+
records = []
|
| 192 |
+
seen_ids = set()
|
| 193 |
+
if os.path.exists(args.out):
|
| 194 |
+
with open(args.out) as f:
|
| 195 |
+
records = json.load(f).get('records', [])
|
| 196 |
+
seen_ids = {r['order_id'] for r in records}
|
| 197 |
+
print(f"Resuming: {len(seen_ids)} already done.")
|
| 198 |
+
|
| 199 |
+
rng = np.random.RandomState(31415)
|
| 200 |
+
|
| 201 |
+
pbar = tqdm(total=args.n, initial=len(records))
|
| 202 |
+
for sample in iter_split(DATASET_DIR, args.split):
|
| 203 |
+
if len(records) >= args.n:
|
| 204 |
+
break
|
| 205 |
+
oid = sample.get('order_id', '')
|
| 206 |
+
if oid in seen_ids:
|
| 207 |
+
continue
|
| 208 |
+
gt_v_raw = sample.get('wf_vertices')
|
| 209 |
+
if not gt_v_raw:
|
| 210 |
+
continue
|
| 211 |
+
gt_v_l = np.asarray(gt_v_raw, dtype=np.float32).tolist()
|
| 212 |
+
gt_e = [(int(a), int(b)) for a, b in sample['wf_edges']]
|
| 213 |
+
|
| 214 |
+
n_images = len(sample.get('image_ids', []))
|
| 215 |
+
|
| 216 |
+
# HC
|
| 217 |
+
try:
|
| 218 |
+
with suppress_stdout():
|
| 219 |
+
hc_v_raw, hc_e_raw = sol.predict_wireframe(sample)
|
| 220 |
+
hc_v = np.asarray(hc_v_raw, dtype=np.float32)
|
| 221 |
+
hc_e = [(int(a), int(b)) for a, b in hc_e_raw]
|
| 222 |
+
except Exception:
|
| 223 |
+
hc_v, hc_e = np.empty((0, 3)), []
|
| 224 |
+
hc_geo = geometry_stats(hc_v)
|
| 225 |
+
|
| 226 |
+
# COLMAP
|
| 227 |
+
cm_xyz, cm_rgb = np.empty((0, 3)), np.empty((0, 3))
|
| 228 |
+
try:
|
| 229 |
+
rec = sol.read_colmap_rec(sample.get('colmap') or sample.get('colmap_binary'))
|
| 230 |
+
cm_xyz, cm_rgb = ec.colmap_points_xyz_rgb(rec)
|
| 231 |
+
except Exception:
|
| 232 |
+
pass
|
| 233 |
+
cm_geo = geometry_stats(cm_xyz)
|
| 234 |
+
|
| 235 |
+
# --- 4096 + seam + augments ---
|
| 236 |
+
s.SEQ_LEN = 4096; s.COLMAP_QUOTA = 3072; s.DEPTH_QUOTA = 1024
|
| 237 |
+
f4 = s.fuse_and_sample(sample, s.FuserConfig(), rng)
|
| 238 |
+
sc_old = 0.0
|
| 239 |
+
n4, mean_conf4, mean_conf4_above = 0, 0.0, 0.0
|
| 240 |
+
seam_diag = {'fired': False, 'med_dist': float('nan'),
|
| 241 |
+
'max_dist': float('nan'), 'n_snapped': 0}
|
| 242 |
+
if f4 is not None:
|
| 243 |
+
n4, mean_conf4, mean_conf4_above = get_conf_segs(f4, model_old, device)
|
| 244 |
+
pv, pe = s.predict_sample(f4, model_old, device)
|
| 245 |
+
pv, pe, seam_diag = snap_to_hc_with_diagnostics(np.asarray(pv), pe, hc_v)
|
| 246 |
+
pv, pe = run_augments(pv, pe, hc_v, hc_e, cm_xyz, cm_rgb,
|
| 247 |
+
edge_model, vertex_model, device, rng)
|
| 248 |
+
sc_old = score_hss(pv, pe, gt_v_l, gt_e)
|
| 249 |
+
|
| 250 |
+
# --- 8192 + NO seam + augments ---
|
| 251 |
+
s.SEQ_LEN = 8192; s.COLMAP_QUOTA = 6144; s.DEPTH_QUOTA = 2048
|
| 252 |
+
f8 = s.fuse_and_sample(sample, s.FuserConfig(), rng)
|
| 253 |
+
sc_new = 0.0
|
| 254 |
+
n8, mean_conf8, mean_conf8_above = 0, 0.0, 0.0
|
| 255 |
+
if f8 is not None:
|
| 256 |
+
n8, mean_conf8, mean_conf8_above = get_conf_segs(f8, model_new, device)
|
| 257 |
+
pv, pe = s.predict_sample(f8, model_new, device)
|
| 258 |
+
pv, pe = run_augments(np.asarray(pv), pe, hc_v, hc_e, cm_xyz, cm_rgb,
|
| 259 |
+
edge_model, vertex_model, device, rng)
|
| 260 |
+
sc_new = score_hss(pv, pe, gt_v_l, gt_e)
|
| 261 |
+
|
| 262 |
+
s.SEQ_LEN = 4096; s.COLMAP_QUOTA = 3072; s.DEPTH_QUOTA = 1024
|
| 263 |
+
|
| 264 |
+
rec = {
|
| 265 |
+
'order_id': oid,
|
| 266 |
+
'sc_old': sc_old,
|
| 267 |
+
'sc_new': sc_new,
|
| 268 |
+
'delta': sc_new - sc_old,
|
| 269 |
+
'label': 1 if sc_new > sc_old + 1e-4 else 0, # 1=use 8192, 0=use 4096
|
| 270 |
+
# structural
|
| 271 |
+
'n_hc': hc_geo['n'],
|
| 272 |
+
'n_hc_edges': len(hc_e),
|
| 273 |
+
'hc_spread_xy': hc_geo['spread_xy'],
|
| 274 |
+
'hc_spread_z': hc_geo['spread_z'],
|
| 275 |
+
'n_colmap': cm_geo['n'],
|
| 276 |
+
'colmap_spread_xy': cm_geo['spread_xy'],
|
| 277 |
+
'colmap_spread_z': cm_geo['spread_z'],
|
| 278 |
+
'n_images': n_images,
|
| 279 |
+
# seam diagnostics
|
| 280 |
+
'seam_fired': int(seam_diag['fired']),
|
| 281 |
+
'seam_med_dist': seam_diag['med_dist'] if not np.isnan(seam_diag['med_dist']) else -1.0,
|
| 282 |
+
'seam_max_dist': seam_diag['max_dist'] if not np.isnan(seam_diag['max_dist']) else -1.0,
|
| 283 |
+
'n_snapped': seam_diag['n_snapped'],
|
| 284 |
+
'snap_rate': seam_diag['n_snapped'] / max(n4, 1),
|
| 285 |
+
# ML confidence
|
| 286 |
+
'n_segs_4096': n4,
|
| 287 |
+
'mean_conf_4096': mean_conf4,
|
| 288 |
+
'mean_conf_4096_above': mean_conf4_above,
|
| 289 |
+
'n_segs_8192': n8,
|
| 290 |
+
'mean_conf_8192': mean_conf8,
|
| 291 |
+
'mean_conf_8192_above': mean_conf8_above,
|
| 292 |
+
'conf_delta': mean_conf8 - mean_conf4,
|
| 293 |
+
'n_segs_delta': n8 - n4,
|
| 294 |
+
}
|
| 295 |
+
records.append(rec)
|
| 296 |
+
seen_ids.add(oid)
|
| 297 |
+
|
| 298 |
+
winner = '8192' if rec['delta'] > 1e-4 else ('4096' if rec['delta'] < -1e-4 else 'tie')
|
| 299 |
+
sc_arr = np.array([r['sc_old'] for r in records])
|
| 300 |
+
tqdm.write(f"[{len(records):4d}] 4096={sc_old:.3f} 8192={sc_new:.3f} "
|
| 301 |
+
f"delta={rec['delta']:+.3f} hc={hc_geo['n']:3d} "
|
| 302 |
+
f"seam={'Y' if seam_diag['fired'] else 'N'}({seam_diag['n_snapped']}) "
|
| 303 |
+
f"snp={seam_diag['med_dist']:.2f} winner={winner} "
|
| 304 |
+
f"run={sc_arr.mean():.4f}")
|
| 305 |
+
|
| 306 |
+
# Save every 20
|
| 307 |
+
if len(records) % 20 == 0:
|
| 308 |
+
with open(args.out, 'w') as f:
|
| 309 |
+
json.dump({'records': records}, f)
|
| 310 |
+
|
| 311 |
+
pbar.update(1)
|
| 312 |
+
|
| 313 |
+
pbar.close()
|
| 314 |
+
with open(args.out, 'w') as f:
|
| 315 |
+
json.dump({'records': records}, f)
|
| 316 |
+
|
| 317 |
+
sc_old_arr = np.array([r['sc_old'] for r in records])
|
| 318 |
+
sc_new_arr = np.array([r['sc_new'] for r in records])
|
| 319 |
+
delta_arr = np.array([r['delta'] for r in records])
|
| 320 |
+
print(f"\nDone. {len(records)} records saved to {args.out}")
|
| 321 |
+
print(f"4096+seam: mean={sc_old_arr.mean():.4f}")
|
| 322 |
+
print(f"8192+ns: mean={sc_new_arr.mean():.4f}")
|
| 323 |
+
print(f"8192 wins: {(delta_arr > 1e-4).sum()} / {len(records)}")
|
| 324 |
+
print(f"\nFeature correlations with delta:")
|
| 325 |
+
feat_keys = ['n_hc', 'n_hc_edges', 'hc_spread_xy', 'hc_spread_z',
|
| 326 |
+
'n_colmap', 'colmap_spread_xy', 'n_images',
|
| 327 |
+
'seam_fired', 'seam_med_dist', 'n_snapped', 'snap_rate',
|
| 328 |
+
'n_segs_4096', 'mean_conf_4096', 'n_segs_8192', 'mean_conf_8192', 'conf_delta']
|
| 329 |
+
for k in feat_keys:
|
| 330 |
+
vals = np.array([r[k] for r in records], dtype=float)
|
| 331 |
+
finite = np.isfinite(vals)
|
| 332 |
+
if finite.sum() > 10:
|
| 333 |
+
corr = float(np.corrcoef(vals[finite], delta_arr[finite])[0, 1])
|
| 334 |
+
print(f" {k:30s}: {corr:+.3f}")
|
| 335 |
+
|
| 336 |
+
|
| 337 |
+
if __name__ == '__main__':
|
| 338 |
+
main()
|
training/gen_sampled_16384.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate a seq_len=16384 sampled dataset from cached_full_pcd_v2.
|
| 2 |
+
|
| 3 |
+
Streams usm3d/s23dr-2026-cached_full_pcd_v2:train from HuggingFace, applies
|
| 4 |
+
the priority sampler at seq_len=16384, and saves a local HF dataset
|
| 5 |
+
(same format as the organizers' sampled_8192_v3: order_id + npz bytes).
|
| 6 |
+
|
| 7 |
+
Usage:
|
| 8 |
+
python3 gen_sampled_16384.py --out sampled_16384
|
| 9 |
+
"""
|
| 10 |
+
import argparse
|
| 11 |
+
import io
|
| 12 |
+
import os
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
|
| 16 |
+
import numpy as np
|
| 17 |
+
|
| 18 |
+
REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..'))
|
| 19 |
+
sys.path.insert(0, REPO_ROOT)
|
| 20 |
+
from s23dr_2026_example.make_sampled_cache import _process_sample, _priority_sample # noqa
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def main():
|
| 24 |
+
p = argparse.ArgumentParser()
|
| 25 |
+
p.add_argument('--seq-len', type=int, default=16384)
|
| 26 |
+
p.add_argument('--out', default='sampled_16384')
|
| 27 |
+
p.add_argument('--seed', type=int, default=7)
|
| 28 |
+
p.add_argument('--n', type=int, default=0, help='0=all samples')
|
| 29 |
+
args = p.parse_args()
|
| 30 |
+
|
| 31 |
+
colmap_q = args.seq_len * 3 // 4
|
| 32 |
+
depth_q = args.seq_len - colmap_q
|
| 33 |
+
print(f"seq_len={args.seq_len} colmap={colmap_q} depth={depth_q}")
|
| 34 |
+
print(f"output: {args.out}")
|
| 35 |
+
|
| 36 |
+
np.random.seed(args.seed)
|
| 37 |
+
|
| 38 |
+
from datasets import load_dataset, Dataset
|
| 39 |
+
print("Loading cached_full_pcd_v2 (streaming)...")
|
| 40 |
+
src = load_dataset('usm3d/s23dr-2026-cached_full_pcd_v2', split='train', streaming=True)
|
| 41 |
+
|
| 42 |
+
records = []
|
| 43 |
+
t0 = time.perf_counter()
|
| 44 |
+
n_done = 0
|
| 45 |
+
n_err = 0
|
| 46 |
+
|
| 47 |
+
for raw in src:
|
| 48 |
+
if args.n > 0 and n_done >= args.n:
|
| 49 |
+
break
|
| 50 |
+
|
| 51 |
+
oid = raw['order_id']
|
| 52 |
+
try:
|
| 53 |
+
d = dict(np.load(io.BytesIO(raw['data'])))
|
| 54 |
+
result = _process_sample(d, args.seq_len, colmap_q, depth_q)
|
| 55 |
+
|
| 56 |
+
buf = io.BytesIO()
|
| 57 |
+
np.savez_compressed(buf, **result)
|
| 58 |
+
records.append({'order_id': oid, 'data': buf.getvalue()})
|
| 59 |
+
n_done += 1
|
| 60 |
+
except Exception as e:
|
| 61 |
+
print(f" SKIP {oid}: {e}")
|
| 62 |
+
n_err += 1
|
| 63 |
+
continue
|
| 64 |
+
|
| 65 |
+
if n_done % 1000 == 0:
|
| 66 |
+
elapsed = time.perf_counter() - t0
|
| 67 |
+
rate = n_done / elapsed
|
| 68 |
+
remaining = (17500 - n_done) / rate if rate > 0 else 0
|
| 69 |
+
print(f" {n_done} done ({rate:.1f}/s ~{remaining/60:.1f} min remaining)")
|
| 70 |
+
|
| 71 |
+
elapsed = time.perf_counter() - t0
|
| 72 |
+
print(f"\nProcessed {n_done} samples ({n_err} errors) in {elapsed:.0f}s")
|
| 73 |
+
|
| 74 |
+
print(f"Saving HF dataset to {args.out} ...")
|
| 75 |
+
ds = Dataset.from_list(records)
|
| 76 |
+
ds.save_to_disk(args.out)
|
| 77 |
+
print(f"Done. {len(ds)} records saved to {args.out}")
|
| 78 |
+
print(f"\nFine-tune on this dataset with "
|
| 79 |
+
f"s23dr_2026_example.train --cache-dir local://{args.out} "
|
| 80 |
+
f"--seq-len {args.seq_len}")
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
if __name__ == '__main__':
|
| 84 |
+
main()
|
training/gen_vertex_dataset.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate HSS-aligned vertex refiner training data from 2026 train split.
|
| 2 |
+
|
| 3 |
+
Per training sample:
|
| 4 |
+
1. Run user's solution.predict_wireframe -> (user_v, user_e) [HC pipeline output]
|
| 5 |
+
2. Read COLMAP points from sample['colmap']
|
| 6 |
+
3. For each vertex u in user_v:
|
| 7 |
+
- Build 6D sphere patch (1024 x [xyz_centered, rgb]) around u
|
| 8 |
+
- Class label = 1 if GT vertex within HSS_VERT_THRESH (50cm), else 0
|
| 9 |
+
- Regression delta = (nearest_gt_v - u) when class=1, else zero
|
| 10 |
+
- gt_distance = nearest GT vertex distance (scalar) -- useful for analysis
|
| 11 |
+
4. Save (patches, class_labels, regression_deltas, gt_distances, vertex_meta) per sample.
|
| 12 |
+
|
| 13 |
+
Candidates come from the same distribution as gen_edge_dataset.py (the
|
| 14 |
+
handcrafted pipeline's outputs).
|
| 15 |
+
|
| 16 |
+
Output: a directory of .npz files, one per sample, each containing arrays:
|
| 17 |
+
patches: (N_vertices, 1024, 6) float32 -- sphere point clouds
|
| 18 |
+
class_labels: (N_vertices,) uint8 -- HSS-aligned binary
|
| 19 |
+
regression_deltas: (N_vertices, 3) float32 -- vector to nearest GT
|
| 20 |
+
gt_distances: (N_vertices,) float32 -- scalar distance to GT
|
| 21 |
+
vertex_meta: (N_vertices, 3) int32 -- (orig_idx, n_pts_raw, padded?)
|
| 22 |
+
|
| 23 |
+
Plus a manifest .json with sample_id -> file path mapping.
|
| 24 |
+
Resumable via manifest: skips samples already recorded.
|
| 25 |
+
"""
|
| 26 |
+
import os
|
| 27 |
+
os.environ['KMP_DUPLICATE_LIB_OK'] = 'True'
|
| 28 |
+
|
| 29 |
+
import argparse
|
| 30 |
+
import json
|
| 31 |
+
import sys
|
| 32 |
+
import time
|
| 33 |
+
from collections import Counter
|
| 34 |
+
|
| 35 |
+
import numpy as np
|
| 36 |
+
import torch # noqa: F401 -- imported to trigger CUDA-aware init in worker
|
| 37 |
+
from datasets import load_dataset
|
| 38 |
+
|
| 39 |
+
CURRENT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 40 |
+
sys.path.insert(0, CURRENT_DIR)
|
| 41 |
+
|
| 42 |
+
import hc_helpers as hc
|
| 43 |
+
from hoho2025.example_solutions import read_colmap_rec
|
| 44 |
+
from edge_patch import colmap_points_xyz_rgb
|
| 45 |
+
|
| 46 |
+
HSS_VERT_THRESH = 0.5 # HSS vertex TP criterion (meters)
|
| 47 |
+
MAX_PATCH_POINTS = 1024
|
| 48 |
+
SPHERE_RADIUS = 1.0 # meters: feature support around each vertex
|
| 49 |
+
MIN_PATCH_POINTS = 10 # skip patches with <= this many COLMAP points
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def build_vertex_patch_6d(v_xyz, colmap_xyz, colmap_rgb, radius=SPHERE_RADIUS):
|
| 53 |
+
"""Sphere of COLMAP points around vertex v_xyz.
|
| 54 |
+
|
| 55 |
+
Returns (M, 6) array of [xyz_relative_to_v, rgb_signed_to_-1_+1] or None
|
| 56 |
+
if too sparse to be informative.
|
| 57 |
+
"""
|
| 58 |
+
rel = colmap_xyz - v_xyz[np.newaxis, :]
|
| 59 |
+
dist = np.linalg.norm(rel, axis=1)
|
| 60 |
+
in_sphere = dist <= radius
|
| 61 |
+
n_in = int(in_sphere.sum())
|
| 62 |
+
if n_in <= MIN_PATCH_POINTS:
|
| 63 |
+
return None
|
| 64 |
+
pts_centered = rel[in_sphere]
|
| 65 |
+
rgb_signed = colmap_rgb[in_sphere] * 2.0 - 1.0
|
| 66 |
+
return np.hstack([pts_centered, rgb_signed])
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def compute_vertex_labels(user_v, gt_v, vert_thresh=HSS_VERT_THRESH):
|
| 70 |
+
"""Vectorized per-vertex labels.
|
| 71 |
+
|
| 72 |
+
Returns
|
| 73 |
+
class_labels: (N,) uint8 -- 1 if nearest GT is within vert_thresh
|
| 74 |
+
regression_deltas: (N, 3) float32 -- gt_nearest - user_v (zeros if no GT in scene)
|
| 75 |
+
gt_distances: (N,) float32 -- scalar distance to nearest GT (inf if no GT)
|
| 76 |
+
"""
|
| 77 |
+
n = len(user_v)
|
| 78 |
+
if n == 0 or len(gt_v) == 0:
|
| 79 |
+
return (np.zeros(n, dtype=np.uint8),
|
| 80 |
+
np.zeros((n, 3), dtype=np.float32),
|
| 81 |
+
np.full(n, np.inf, dtype=np.float32))
|
| 82 |
+
# (N, G) distance matrix
|
| 83 |
+
diffs = user_v[:, np.newaxis, :] - gt_v[np.newaxis, :, :]
|
| 84 |
+
dists = np.linalg.norm(diffs, axis=2)
|
| 85 |
+
j = np.argmin(dists, axis=1)
|
| 86 |
+
gt_dists = dists[np.arange(n), j].astype(np.float32)
|
| 87 |
+
reg_deltas = (gt_v[j] - user_v).astype(np.float32)
|
| 88 |
+
class_labels = (gt_dists <= vert_thresh).astype(np.uint8)
|
| 89 |
+
return class_labels, reg_deltas, gt_dists
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def pad_or_sample_patch(patch_6d, max_pts=MAX_PATCH_POINTS, rng=None):
|
| 93 |
+
"""Pad with zeros or random-sample to exactly max_pts points."""
|
| 94 |
+
if rng is None:
|
| 95 |
+
rng = np.random
|
| 96 |
+
n = patch_6d.shape[0]
|
| 97 |
+
if n >= max_pts:
|
| 98 |
+
idx = rng.choice(n, max_pts, replace=False)
|
| 99 |
+
return patch_6d[idx], False
|
| 100 |
+
out = np.zeros((max_pts, 6), dtype=np.float32)
|
| 101 |
+
out[:n] = patch_6d
|
| 102 |
+
return out, True
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def process_sample(sample, rng):
|
| 106 |
+
"""One sample -> dict of arrays or (None, err_str)."""
|
| 107 |
+
order_id = sample['order_id']
|
| 108 |
+
|
| 109 |
+
# User's HC pipeline (same call as gen_edge_dataset.py -- same distribution).
|
| 110 |
+
try:
|
| 111 |
+
with hc.suppress_stdout():
|
| 112 |
+
user_v, _user_e = hc.hc_predict(sample, {})
|
| 113 |
+
user_v = np.asarray(user_v, dtype=np.float32)
|
| 114 |
+
except Exception as e:
|
| 115 |
+
return None, f"hc_predict crashed: {e}"
|
| 116 |
+
|
| 117 |
+
if len(user_v) == 0:
|
| 118 |
+
return None, "empty handcrafted vertices"
|
| 119 |
+
|
| 120 |
+
# COLMAP point cloud.
|
| 121 |
+
try:
|
| 122 |
+
colmap_rec = read_colmap_rec(sample['colmap'])
|
| 123 |
+
cm_xyz, cm_rgb = colmap_points_xyz_rgb(colmap_rec)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
return None, f"colmap parse crashed: {e}"
|
| 126 |
+
if len(cm_xyz) == 0:
|
| 127 |
+
return None, "empty colmap"
|
| 128 |
+
|
| 129 |
+
# GT vertices (for HSS-aligned labels).
|
| 130 |
+
gt_v_raw = sample.get('wf_vertices')
|
| 131 |
+
if gt_v_raw is None:
|
| 132 |
+
return None, "no GT in sample"
|
| 133 |
+
gt_v = np.asarray(gt_v_raw, dtype=np.float32)
|
| 134 |
+
|
| 135 |
+
# Per-vertex labels (vectorized).
|
| 136 |
+
class_labels_all, reg_deltas_all, gt_dists_all = compute_vertex_labels(user_v, gt_v)
|
| 137 |
+
|
| 138 |
+
# Per-vertex sphere patches.
|
| 139 |
+
n = len(user_v)
|
| 140 |
+
patches = np.zeros((n, MAX_PATCH_POINTS, 6), dtype=np.float32)
|
| 141 |
+
vertex_meta = np.zeros((n, 3), dtype=np.int32) # (orig_idx, n_pts_raw, padded_flag)
|
| 142 |
+
valid_indices = []
|
| 143 |
+
valid_count = 0
|
| 144 |
+
for i in range(n):
|
| 145 |
+
v_xyz = user_v[i]
|
| 146 |
+
raw = build_vertex_patch_6d(v_xyz, cm_xyz, cm_rgb)
|
| 147 |
+
if raw is None:
|
| 148 |
+
continue
|
| 149 |
+
n_pts_raw = raw.shape[0]
|
| 150 |
+
sampled, padded = pad_or_sample_patch(raw, rng=rng)
|
| 151 |
+
patches[valid_count] = sampled
|
| 152 |
+
vertex_meta[valid_count] = [i, n_pts_raw, int(padded)]
|
| 153 |
+
valid_indices.append(i)
|
| 154 |
+
valid_count += 1
|
| 155 |
+
|
| 156 |
+
if valid_count == 0:
|
| 157 |
+
return None, "no valid patches built"
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
'patches': patches[:valid_count],
|
| 161 |
+
'class_labels': class_labels_all[valid_indices],
|
| 162 |
+
'regression_deltas': reg_deltas_all[valid_indices],
|
| 163 |
+
'gt_distances': gt_dists_all[valid_indices],
|
| 164 |
+
'vertex_meta': vertex_meta[:valid_count],
|
| 165 |
+
'order_id': order_id,
|
| 166 |
+
'n_vertices_total': n,
|
| 167 |
+
'n_patches_valid': valid_count,
|
| 168 |
+
}, None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def _worker_entry(sample, seed):
|
| 172 |
+
"""Top-level worker function for ProcessPoolExecutor (must be picklable)."""
|
| 173 |
+
rng = np.random.RandomState(seed)
|
| 174 |
+
return process_sample(sample, rng)
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def main():
|
| 178 |
+
parser = argparse.ArgumentParser()
|
| 179 |
+
parser.add_argument('--out-dir', required=True, help='Directory for .npz files')
|
| 180 |
+
parser.add_argument('--split', default='train',
|
| 181 |
+
help='HF split: train (default) or validation')
|
| 182 |
+
parser.add_argument('--dataset', default='usm3d/hoho22k_2026_trainval:train',
|
| 183 |
+
help='HF dataset spec. Default uses the trainval dataset '
|
| 184 |
+
'train split.')
|
| 185 |
+
parser.add_argument('--max-samples', type=int, default=None,
|
| 186 |
+
help='Limit samples (for sanity runs)')
|
| 187 |
+
parser.add_argument('--seed', type=int, default=2718)
|
| 188 |
+
parser.add_argument('--workers', type=int, default=1,
|
| 189 |
+
help='Parallel worker processes (1=single-process).')
|
| 190 |
+
parser.add_argument('--in-flight', type=int, default=None,
|
| 191 |
+
help='Max in-flight tasks (default workers*3).')
|
| 192 |
+
args = parser.parse_args()
|
| 193 |
+
|
| 194 |
+
os.makedirs(args.out_dir, exist_ok=True)
|
| 195 |
+
|
| 196 |
+
if ':' in args.dataset:
|
| 197 |
+
repo, hf_split = args.dataset.split(':')
|
| 198 |
+
else:
|
| 199 |
+
repo, hf_split = args.dataset, args.split
|
| 200 |
+
|
| 201 |
+
print(f"Loading dataset: {repo} split={hf_split}")
|
| 202 |
+
ds = load_dataset(repo, split=hf_split, streaming=True, trust_remote_code=True)
|
| 203 |
+
|
| 204 |
+
rng = np.random.RandomState(args.seed)
|
| 205 |
+
|
| 206 |
+
manifest_path = os.path.join(args.out_dir, 'manifest.json')
|
| 207 |
+
if os.path.exists(manifest_path):
|
| 208 |
+
with open(manifest_path) as f:
|
| 209 |
+
manifest = json.load(f)
|
| 210 |
+
else:
|
| 211 |
+
manifest = {'samples': {}, 'config': {
|
| 212 |
+
'dataset': args.dataset,
|
| 213 |
+
'max_samples': args.max_samples,
|
| 214 |
+
'seed': args.seed,
|
| 215 |
+
'max_patch_points': MAX_PATCH_POINTS,
|
| 216 |
+
'sphere_radius': SPHERE_RADIUS,
|
| 217 |
+
'hss_vert_thresh': HSS_VERT_THRESH,
|
| 218 |
+
'workers': args.workers,
|
| 219 |
+
}}
|
| 220 |
+
|
| 221 |
+
n_done = len(manifest['samples'])
|
| 222 |
+
print(f"Resuming with {n_done} samples already done; workers={args.workers}")
|
| 223 |
+
|
| 224 |
+
state = {
|
| 225 |
+
'total_patches': 0,
|
| 226 |
+
'total_pos_class': 0,
|
| 227 |
+
'total_neg_class': 0,
|
| 228 |
+
'sum_delta_mag_pos': 0.0, # for mean |delta| on real vertices
|
| 229 |
+
'total_skipped': 0,
|
| 230 |
+
'seen_count': n_done,
|
| 231 |
+
't_start': time.time(),
|
| 232 |
+
}
|
| 233 |
+
crash_reasons = Counter()
|
| 234 |
+
|
| 235 |
+
def handle_result(result, err, order_id):
|
| 236 |
+
if result is None:
|
| 237 |
+
crash_reasons[err] += 1
|
| 238 |
+
manifest['samples'][order_id] = {'error': err}
|
| 239 |
+
state['total_skipped'] += 1
|
| 240 |
+
else:
|
| 241 |
+
out_path = os.path.join(args.out_dir, f'{order_id}.npz')
|
| 242 |
+
np.savez_compressed(out_path,
|
| 243 |
+
patches=result['patches'],
|
| 244 |
+
class_labels=result['class_labels'],
|
| 245 |
+
regression_deltas=result['regression_deltas'],
|
| 246 |
+
gt_distances=result['gt_distances'],
|
| 247 |
+
vertex_meta=result['vertex_meta'])
|
| 248 |
+
manifest['samples'][order_id] = {
|
| 249 |
+
'path': f'{order_id}.npz',
|
| 250 |
+
'n_patches': result['n_patches_valid'],
|
| 251 |
+
'n_vertices_total': result['n_vertices_total'],
|
| 252 |
+
'n_class_pos': int(result['class_labels'].sum()),
|
| 253 |
+
}
|
| 254 |
+
n_pos = int(result['class_labels'].sum())
|
| 255 |
+
n_neg = int(len(result['class_labels']) - n_pos)
|
| 256 |
+
state['total_patches'] += result['n_patches_valid']
|
| 257 |
+
state['total_pos_class'] += n_pos
|
| 258 |
+
state['total_neg_class'] += n_neg
|
| 259 |
+
if n_pos > 0:
|
| 260 |
+
deltas = result['regression_deltas'][result['class_labels'] == 1]
|
| 261 |
+
state['sum_delta_mag_pos'] += float(np.linalg.norm(deltas, axis=1).sum())
|
| 262 |
+
|
| 263 |
+
state['seen_count'] += 1
|
| 264 |
+
if state['seen_count'] % 50 == 0:
|
| 265 |
+
with open(manifest_path, 'w') as f:
|
| 266 |
+
json.dump(manifest, f)
|
| 267 |
+
if state['seen_count'] % 25 == 0:
|
| 268 |
+
elapsed = time.time() - state['t_start']
|
| 269 |
+
rate = (state['seen_count'] - n_done) / max(elapsed, 1e-6)
|
| 270 |
+
pos_frac = state['total_pos_class'] / max(state['total_patches'], 1)
|
| 271 |
+
mean_delta_pos = (state['sum_delta_mag_pos']
|
| 272 |
+
/ max(state['total_pos_class'], 1))
|
| 273 |
+
print(f"[{state['seen_count']}] order={order_id} "
|
| 274 |
+
f"rate={rate:.2f}/s patches={state['total_patches']} "
|
| 275 |
+
f"pos={state['total_pos_class']} neg={state['total_neg_class']} "
|
| 276 |
+
f"pos_frac={pos_frac:.3f} mean|delta|_pos={mean_delta_pos:.3f}m "
|
| 277 |
+
f"skipped={state['total_skipped']}")
|
| 278 |
+
|
| 279 |
+
target = args.max_samples if args.max_samples else float('inf')
|
| 280 |
+
|
| 281 |
+
MAX_RETRIES = 8
|
| 282 |
+
BACKOFF = 10
|
| 283 |
+
|
| 284 |
+
if args.workers <= 1:
|
| 285 |
+
# ---- Single-process path ----
|
| 286 |
+
attempts = 0
|
| 287 |
+
stream_done = False
|
| 288 |
+
while not stream_done and state['seen_count'] < target:
|
| 289 |
+
if attempts >= MAX_RETRIES:
|
| 290 |
+
print(f"[STREAM] giving up after {attempts} attempts")
|
| 291 |
+
break
|
| 292 |
+
attempts += 1
|
| 293 |
+
if attempts > 1:
|
| 294 |
+
print(f"[STREAM] retry {attempts}/{MAX_RETRIES} after {BACKOFF}s")
|
| 295 |
+
time.sleep(BACKOFF)
|
| 296 |
+
try:
|
| 297 |
+
for sample in ds:
|
| 298 |
+
if state['seen_count'] >= target:
|
| 299 |
+
break
|
| 300 |
+
order_id = sample['order_id']
|
| 301 |
+
if order_id in manifest['samples']:
|
| 302 |
+
continue
|
| 303 |
+
result, err = process_sample(sample, rng)
|
| 304 |
+
handle_result(result, err, order_id)
|
| 305 |
+
attempts = 0
|
| 306 |
+
except Exception as e:
|
| 307 |
+
print(f"[STREAM ERROR] {type(e).__name__}: {str(e)[:200]}")
|
| 308 |
+
continue
|
| 309 |
+
else:
|
| 310 |
+
stream_done = True
|
| 311 |
+
break
|
| 312 |
+
else:
|
| 313 |
+
# ---- Parallel path ----
|
| 314 |
+
from concurrent.futures import (
|
| 315 |
+
ProcessPoolExecutor, wait, FIRST_COMPLETED, as_completed,
|
| 316 |
+
)
|
| 317 |
+
import multiprocessing as mp
|
| 318 |
+
max_inflight = args.in_flight or (args.workers * 3)
|
| 319 |
+
# Force 'spawn' start method: 'fork' deadlocks with torch+OMP on Linux.
|
| 320 |
+
ctx = mp.get_context('spawn')
|
| 321 |
+
print(f"Parallel mode: workers={args.workers}, in_flight={max_inflight}, start=spawn")
|
| 322 |
+
executor = ProcessPoolExecutor(max_workers=args.workers, mp_context=ctx)
|
| 323 |
+
pending = {} # future -> order_id
|
| 324 |
+
|
| 325 |
+
def drain_one():
|
| 326 |
+
done, _ = wait(list(pending.keys()), return_when=FIRST_COMPLETED)
|
| 327 |
+
for f in done:
|
| 328 |
+
oid = pending.pop(f)
|
| 329 |
+
try:
|
| 330 |
+
result, err = f.result()
|
| 331 |
+
except Exception as e:
|
| 332 |
+
result, err = None, f"worker crashed: {type(e).__name__}: {e}"
|
| 333 |
+
handle_result(result, err, oid)
|
| 334 |
+
|
| 335 |
+
attempts = 0
|
| 336 |
+
stream_done = False
|
| 337 |
+
try:
|
| 338 |
+
while not stream_done and (state['seen_count'] + len(pending)) < target:
|
| 339 |
+
if attempts >= MAX_RETRIES:
|
| 340 |
+
print(f"[STREAM] giving up after {attempts} attempts")
|
| 341 |
+
break
|
| 342 |
+
attempts += 1
|
| 343 |
+
if attempts > 1:
|
| 344 |
+
print(f"[STREAM] retry {attempts}/{MAX_RETRIES} after {BACKOFF}s")
|
| 345 |
+
time.sleep(BACKOFF)
|
| 346 |
+
try:
|
| 347 |
+
for sample in ds:
|
| 348 |
+
if (state['seen_count'] + len(pending)) >= target:
|
| 349 |
+
break
|
| 350 |
+
order_id = sample['order_id']
|
| 351 |
+
if order_id in manifest['samples']:
|
| 352 |
+
continue
|
| 353 |
+
while len(pending) >= max_inflight:
|
| 354 |
+
drain_one()
|
| 355 |
+
seed = int(rng.randint(0, 2**31 - 1))
|
| 356 |
+
fut = executor.submit(_worker_entry, sample, seed)
|
| 357 |
+
pending[fut] = order_id
|
| 358 |
+
attempts = 0
|
| 359 |
+
except Exception as e:
|
| 360 |
+
print(f"[STREAM ERROR] {type(e).__name__}: {str(e)[:200]}")
|
| 361 |
+
continue
|
| 362 |
+
else:
|
| 363 |
+
stream_done = True
|
| 364 |
+
break
|
| 365 |
+
|
| 366 |
+
for f in as_completed(list(pending.keys())):
|
| 367 |
+
oid = pending.pop(f)
|
| 368 |
+
try:
|
| 369 |
+
result, err = f.result()
|
| 370 |
+
except Exception as e:
|
| 371 |
+
result, err = None, f"worker crashed: {type(e).__name__}: {e}"
|
| 372 |
+
handle_result(result, err, oid)
|
| 373 |
+
finally:
|
| 374 |
+
executor.shutdown(wait=True)
|
| 375 |
+
|
| 376 |
+
with open(manifest_path, 'w') as f:
|
| 377 |
+
json.dump(manifest, f)
|
| 378 |
+
|
| 379 |
+
# Summary
|
| 380 |
+
print("\n========== Vertex dataset gen summary ==========")
|
| 381 |
+
print(f"Samples processed: {len(manifest['samples'])}")
|
| 382 |
+
print(f" Successful: {sum(1 for v in manifest['samples'].values() if 'path' in v)}")
|
| 383 |
+
print(f" Skipped: {state['total_skipped']}")
|
| 384 |
+
print(f" Crash reasons: {dict(crash_reasons.most_common(5))}")
|
| 385 |
+
print(f"\nTotal vertex patches: {state['total_patches']}")
|
| 386 |
+
print(f" Class positive (within {HSS_VERT_THRESH}m of GT): "
|
| 387 |
+
f"{state['total_pos_class']} "
|
| 388 |
+
f"({100*state['total_pos_class']/max(state['total_patches'],1):.1f}%)")
|
| 389 |
+
print(f" Class negative: {state['total_neg_class']} "
|
| 390 |
+
f"({100*state['total_neg_class']/max(state['total_patches'],1):.1f}%)")
|
| 391 |
+
if state['total_pos_class']:
|
| 392 |
+
mean_delta_pos = (state['sum_delta_mag_pos']
|
| 393 |
+
/ max(state['total_pos_class'], 1))
|
| 394 |
+
print(f" Mean |delta| on positives: {mean_delta_pos:.3f}m "
|
| 395 |
+
f"(if small, refinement has little to do)")
|
| 396 |
+
if state['total_patches']:
|
| 397 |
+
avg = state['total_patches'] / max(state['seen_count'], 1)
|
| 398 |
+
print(f"\nAvg patches/sample: {avg:.1f}")
|
| 399 |
+
per_patch_size = MAX_PATCH_POINTS * 6 * 4
|
| 400 |
+
print(f"Disk estimate: "
|
| 401 |
+
f"{state['total_patches'] * per_patch_size / 1e9:.2f} GB uncompressed")
|
| 402 |
+
print(f"\nElapsed: {time.time()-state['t_start']:.0f}s")
|
| 403 |
+
print(f"Output: {args.out_dir}")
|
| 404 |
+
print(f"Manifest: {manifest_path}")
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
if __name__ == '__main__':
|
| 408 |
+
main()
|
training/hc_helpers.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Handcrafted-prediction helpers used by the dataset generators.
|
| 2 |
+
|
| 3 |
+
Runs the handcrafted wireframe pipeline (solution.predict_wireframe) with
|
| 4 |
+
stdout suppressed and returns cleaned (vertices, edges).
|
| 5 |
+
"""
|
| 6 |
+
import contextlib
|
| 7 |
+
import io
|
| 8 |
+
import sys
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from solution import predict_wireframe as my_predict
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
@contextlib.contextmanager
|
| 16 |
+
def suppress_stdout():
|
| 17 |
+
saved = sys.stdout
|
| 18 |
+
sys.stdout = io.StringIO()
|
| 19 |
+
try:
|
| 20 |
+
yield
|
| 21 |
+
finally:
|
| 22 |
+
sys.stdout = saved
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def hc_predict(sample, override=None):
|
| 26 |
+
"""Run the handcrafted pipeline on a sample, returning (vertices, edges)."""
|
| 27 |
+
with suppress_stdout():
|
| 28 |
+
v, e = my_predict(sample)
|
| 29 |
+
v = np.asarray(v)
|
| 30 |
+
e = [(int(u), int(w)) for u, w in e]
|
| 31 |
+
return v, e
|
training/local_dataset.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Read the locally downloaded hoho22k_2026 webdataset tar files directly.
|
| 2 |
+
|
| 3 |
+
Bypasses the datasets library entirely -- no version conflicts, no network.
|
| 4 |
+
Each tar file contains per-scene entries:
|
| 5 |
+
{order_id}.{field}_{image_id}.npy -- per-image numpy arrays (K, R, t, pose_only_in_colmap)
|
| 6 |
+
{order_id}.{ade|depth|gestalt}_{image_id}.png -- per-image PIL images
|
| 7 |
+
{order_id}.colmap.zip -- COLMAP reconstruction bytes
|
| 8 |
+
{order_id}.wf_vertices.npy -- (N, 3) float32
|
| 9 |
+
{order_id}.wf_edges.npy -- (M, 2) int64
|
| 10 |
+
{order_id}.wf_classifications.npy -- (M,) int64
|
| 11 |
+
|
| 12 |
+
Returns sample dicts compatible with predict_wireframe / convert_entry_to_human_readable.
|
| 13 |
+
"""
|
| 14 |
+
import glob
|
| 15 |
+
import io
|
| 16 |
+
import tarfile
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
from PIL import Image
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
VECTOR_FIELDS = {'K', 'R', 't', 'pose_only_in_colmap'}
|
| 23 |
+
IMAGE_FIELDS = {'ade', 'depth', 'gestalt'}
|
| 24 |
+
GLOBAL_FIELDS = {'colmap', 'wf_vertices', 'wf_edges', 'wf_classifications'}
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _assemble_scene(order_id, raw):
|
| 28 |
+
"""raw: {field_key_with_ext: bytes}"""
|
| 29 |
+
# Collect image_ids from any per-image field
|
| 30 |
+
image_ids = set()
|
| 31 |
+
for key in raw:
|
| 32 |
+
for prefix in [f'{f}_' for f in VECTOR_FIELDS | IMAGE_FIELDS]:
|
| 33 |
+
if key.startswith(prefix):
|
| 34 |
+
img_id = key[len(prefix):]
|
| 35 |
+
img_id = img_id.rsplit('.', 1)[0] # strip extension
|
| 36 |
+
image_ids.add(img_id)
|
| 37 |
+
image_ids = sorted(image_ids)
|
| 38 |
+
|
| 39 |
+
sample = {'order_id': order_id, 'image_ids': image_ids}
|
| 40 |
+
for f in VECTOR_FIELDS | IMAGE_FIELDS:
|
| 41 |
+
sample[f] = []
|
| 42 |
+
|
| 43 |
+
for img_id in image_ids:
|
| 44 |
+
for field in VECTOR_FIELDS:
|
| 45 |
+
key = f'{field}_{img_id}.npy'
|
| 46 |
+
if key in raw:
|
| 47 |
+
sample[field].append(np.load(io.BytesIO(raw[key])))
|
| 48 |
+
for field in IMAGE_FIELDS:
|
| 49 |
+
key = f'{field}_{img_id}.png'
|
| 50 |
+
if key in raw:
|
| 51 |
+
sample[field].append(Image.open(io.BytesIO(raw[key])).copy())
|
| 52 |
+
|
| 53 |
+
# Global fields
|
| 54 |
+
if 'colmap.zip' in raw:
|
| 55 |
+
sample['colmap'] = raw['colmap.zip']
|
| 56 |
+
for field in ('wf_vertices', 'wf_edges', 'wf_classifications'):
|
| 57 |
+
key = f'{field}.npy'
|
| 58 |
+
if key in raw:
|
| 59 |
+
sample[field] = np.load(io.BytesIO(raw[key])).tolist()
|
| 60 |
+
|
| 61 |
+
return sample
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def iter_tar(tar_path):
|
| 65 |
+
"""Yield assembled scene dicts from a single tar file."""
|
| 66 |
+
scenes = {}
|
| 67 |
+
with tarfile.open(tar_path, 'r') as tf:
|
| 68 |
+
for member in tf.getmembers():
|
| 69 |
+
if not member.isfile():
|
| 70 |
+
continue
|
| 71 |
+
name = member.name
|
| 72 |
+
dot = name.index('.')
|
| 73 |
+
order_id = name[:dot]
|
| 74 |
+
field_key = name[dot + 1:]
|
| 75 |
+
f = tf.extractfile(member)
|
| 76 |
+
if f is None:
|
| 77 |
+
continue
|
| 78 |
+
if order_id not in scenes:
|
| 79 |
+
scenes[order_id] = {}
|
| 80 |
+
scenes[order_id][field_key] = f.read()
|
| 81 |
+
|
| 82 |
+
for order_id, raw in scenes.items():
|
| 83 |
+
yield _assemble_scene(order_id, raw)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def iter_split(dataset_dir, split='validation'):
|
| 87 |
+
"""Yield all scenes from a dataset split, sorted by tar file name."""
|
| 88 |
+
tars = sorted(glob.glob(f'{dataset_dir}/data/{split}/*.tar'))
|
| 89 |
+
if not tars:
|
| 90 |
+
raise FileNotFoundError(f'No tar files found in {dataset_dir}/data/{split}/')
|
| 91 |
+
for tar_path in tars:
|
| 92 |
+
yield from iter_tar(tar_path)
|