donquicode commited on
Commit
f2e158e
·
verified ·
1 Parent(s): d19a3e7

Upload folder using huggingface_hub

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +1 -0
  2. .gitignore +6 -0
  3. LICENSE.md +408 -0
  4. README.md +66 -0
  5. REPRODUCE.md +121 -0
  6. Writeup.pdf +3 -0
  7. checkpoint.pt +3 -0
  8. checkpoint_8192.pt +3 -0
  9. configs/base.json +39 -0
  10. edge_classifier.py +208 -0
  11. experiments/ptv3/README.md +32 -0
  12. experiments/ptv3/checkpoint_ptv3_8k.pt +3 -0
  13. experiments/ptv3/model_with_ptv3.py +698 -0
  14. experiments/ptv3/ptv3_code/__init__.py +0 -0
  15. experiments/ptv3/ptv3_code/encoder_wrapper.py +174 -0
  16. experiments/ptv3/ptv3_code/model.py +982 -0
  17. experiments/ptv3/ptv3_code/serialization/__init__.py +8 -0
  18. experiments/ptv3/ptv3_code/serialization/default.py +59 -0
  19. experiments/ptv3/ptv3_code/serialization/hilbert.py +303 -0
  20. experiments/ptv3/ptv3_code/serialization/z_order.py +126 -0
  21. experiments/ptv3/train_args.json +62 -0
  22. pnet_class_2026.pth +3 -0
  23. requirements.txt +12 -0
  24. s23dr_2026_example/__init__.py +0 -0
  25. s23dr_2026_example/attention.py +141 -0
  26. s23dr_2026_example/bad_samples.txt +156 -0
  27. s23dr_2026_example/cache_scenes.py +282 -0
  28. s23dr_2026_example/color_mappings.py +183 -0
  29. s23dr_2026_example/data.py +230 -0
  30. s23dr_2026_example/losses.py +215 -0
  31. s23dr_2026_example/make_sampled_cache.py +159 -0
  32. s23dr_2026_example/model.py +519 -0
  33. s23dr_2026_example/point_fusion.py +554 -0
  34. s23dr_2026_example/postprocess_v2.py +39 -0
  35. s23dr_2026_example/segment_postprocess.py +77 -0
  36. s23dr_2026_example/sinkhorn.py +126 -0
  37. s23dr_2026_example/tokenizer.py +88 -0
  38. s23dr_2026_example/train.py +530 -0
  39. s23dr_2026_example/varifold.py +53 -0
  40. s23dr_2026_example/wire_varifold_kernels.py +168 -0
  41. script.py +432 -0
  42. solution.py +1210 -0
  43. training/edge_patch.py +237 -0
  44. training/fast_pointnet_class.py +317 -0
  45. training/gen_edge_dataset.py +394 -0
  46. training/gen_routing_dataset.py +338 -0
  47. training/gen_sampled_16384.py +84 -0
  48. training/gen_vertex_dataset.py +408 -0
  49. training/hc_helpers.py +31 -0
  50. 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)