JonathanJMK commited on
Commit
c645013
·
verified ·
1 Parent(s): b65f0b5

Upload complete FALCON model and project documentation

Browse files
LICENSE-Llama-2 ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LLAMA 2 COMMUNITY LICENSE AGREEMENT
2
+ Llama 2 Version Release Date: July 18, 2023
3
+
4
+ "Agreement" means the terms and conditions for use, reproduction, distribution and
5
+ modification of the Llama Materials set forth herein.
6
+
7
+ "Documentation" means the specifications, manuals and documentation
8
+ accompanying Llama 2 distributed by Meta at ai.meta.com/resources/models-and-
9
+ libraries/llama-downloads/.
10
+
11
+ "Licensee" or "you" means you, or your employer or any other person or entity (if
12
+ you are entering into this Agreement on such person or entity's behalf), of the age
13
+ required under applicable laws, rules or regulations to provide legal consent and that
14
+ has legal authority to bind your employer or such other person or entity if you are
15
+ entering in this Agreement on their behalf.
16
+
17
+ "Llama 2" means the foundational large language models and software and
18
+ algorithms, including machine-learning model code, trained model weights,
19
+ inference-enabling code, training-enabling code, fine-tuning enabling code and other
20
+ elements of the foregoing distributed by Meta at ai.meta.com/resources/models-and-
21
+ libraries/llama-downloads/.
22
+
23
+ "Llama Materials" means, collectively, Meta's proprietary Llama 2 and
24
+ Documentation (and any portion thereof) made available under this Agreement.
25
+
26
+ "Meta" or "we" means Meta Platforms Ireland Limited (if you are located in or, if you
27
+ are an entity, your principal place of business is in the EEA or Switzerland) and Meta
28
+ Platforms, Inc. (if you are located outside of the EEA or Switzerland).
29
+
30
+ By clicking "I Accept" below or by using or distributing any portion or element of the
31
+ Llama Materials, you agree to be bound by this Agreement.
32
+
33
+ 1. License Rights and Redistribution.
34
+
35
+ a. Grant of Rights. You are granted a non-exclusive, worldwide, non-
36
+ transferable and royalty-free limited license under Meta's intellectual property or
37
+ other rights owned by Meta embodied in the Llama Materials to use, reproduce,
38
+ distribute, copy, create derivative works of, and make modifications to the Llama
39
+ Materials.
40
+
41
+ b. Redistribution and Use.
42
+
43
+ i. If you distribute or make the Llama Materials, or any derivative works
44
+ thereof, available to a third party, you shall provide a copy of this Agreement to such
45
+ third party.
46
+ ii. If you receive Llama Materials, or any derivative works thereof, from
47
+ a Licensee as part of an integrated end user product, then Section 2 of this
48
+ Agreement will not apply to you.
49
+
50
+ iii. You must retain in all copies of the Llama Materials that you
51
+ distribute the following attribution notice within a "Notice" text file distributed as a
52
+ part of such copies: "Llama 2 is licensed under the LLAMA 2 Community License,
53
+ Copyright (c) Meta Platforms, Inc. All Rights Reserved."
54
+
55
+ iv. Your use of the Llama Materials must comply with applicable laws
56
+ and regulations (including trade compliance laws and regulations) and adhere to the
57
+ Acceptable Use Policy for the Llama Materials (available at
58
+ https://ai.meta.com/llama/use-policy), which is hereby incorporated by reference into
59
+ this Agreement.
60
+
61
+ v. You will not use the Llama Materials or any output or results of the
62
+ Llama Materials to improve any other large language model (excluding Llama 2 or
63
+ derivative works thereof).
64
+
65
+ 2. Additional Commercial Terms. If, on the Llama 2 version release date, the
66
+ monthly active users of the products or services made available by or for Licensee,
67
+ or Licensee's affiliates, is greater than 700 million monthly active users in the
68
+ preceding calendar month, you must request a license from Meta, which Meta may
69
+ grant to you in its sole discretion, and you are not authorized to exercise any of the
70
+ rights under this Agreement unless or until Meta otherwise expressly grants you
71
+ such rights.
72
+
73
+ 3. Disclaimer of Warranty. UNLESS REQUIRED BY APPLICABLE LAW, THE
74
+ LLAMA MATERIALS AND ANY OUTPUT AND RESULTS THEREFROM ARE
75
+ PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY KIND,
76
+ EITHER EXPRESS OR IMPLIED, INCLUDING, WITHOUT LIMITATION, ANY
77
+ WARRANTIES OF TITLE, NON-INFRINGEMENT, MERCHANTABILITY, OR
78
+ FITNESS FOR A PARTICULAR PURPOSE. YOU ARE SOLELY RESPONSIBLE
79
+ FOR DETERMINING THE APPROPRIATENESS OF USING OR REDISTRIBUTING
80
+ THE LLAMA MATERIALS AND ASSUME ANY RISKS ASSOCIATED WITH YOUR
81
+ USE OF THE LLAMA MATERIALS AND ANY OUTPUT AND RESULTS.
82
+
83
+ 4. Limitation of Liability. IN NO EVENT WILL META OR ITS AFFILIATES BE
84
+ LIABLE UNDER ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, TORT,
85
+ NEGLIGENCE, PRODUCTS LIABILITY, OR OTHERWISE, ARISING OUT OF THIS
86
+ AGREEMENT, FOR ANY LOST PROFITS OR ANY INDIRECT, SPECIAL,
87
+ CONSEQUENTIAL, INCIDENTAL, EXEMPLARY OR PUNITIVE DAMAGES, EVEN
88
+ IF META OR ITS AFFILIATES HAVE BEEN ADVISED OF THE POSSIBILITY OF
89
+ ANY OF THE FOREGOING.
90
+
91
+ 5. Intellectual Property.
92
+
93
+ a. No trademark licenses are granted under this Agreement, and in
94
+ connection with the Llama Materials, neither Meta nor Licensee may use any name
95
+ or mark owned by or associated with the other or any of its affiliates, except as
96
+ required for reasonable and customary use in describing and redistributing the
97
+ Llama Materials.
98
+
99
+ b. Subject to Meta's ownership of Llama Materials and derivatives made by or
100
+ for Meta, with respect to any derivative works and modifications of the Llama
101
+ Materials that are made by you, as between you and Meta, you are and will be the
102
+ owner of such derivative works and modifications.
103
+
104
+ c. If you institute litigation or other proceedings against Meta or any entity
105
+ (including a cross-claim or counterclaim in a lawsuit) alleging that the Llama
106
+ Materials or Llama 2 outputs or results, or any portion of any of the foregoing,
107
+ constitutes an infringement of intellectual property or other rights owned or licensable
108
+ by you, then any licenses granted to you under this Agreement shall terminate as of
109
+ the date such litigation or claim is filed or instituted. You will indemnify and hold
110
+ harmless Meta from and against any claim by any third party arising out of or related
111
+ to your use or distribution of the Llama Materials.
112
+
113
+ 6. Term and Termination. The term of this Agreement will commence upon your
114
+ acceptance of this Agreement or access to the Llama Materials and will continue in
115
+ full force and effect until terminated in accordance with the terms and conditions
116
+ herein. Meta may terminate this Agreement if you are in breach of any term or
117
+ condition of this Agreement. Upon termination of this Agreement, you shall delete
118
+ and cease use of the Llama Materials. Sections 3, 4 and 7 shall survive the
119
+ termination of this Agreement.
120
+
121
+ 7. Governing Law and Jurisdiction. This Agreement will be governed and
122
+ construed under the laws of the State of California without regard to choice of law
123
+ principles, and the UN Convention on Contracts for the International Sale of Goods
124
+ does not apply to this Agreement. The courts of California shall have exclusive
125
+ jurisdiction of any dispute arising out of this Agreement.
LICENSE-code ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
Notice ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Llama 2 is licensed under the LLAMA 2 Community License, Copyright (c) Meta
2
+ Platforms, Inc. All Rights Reserved.
3
+
4
+ Vicuna-7B-v1.5: LMSYS, derived from Llama 2.
5
+ https://huggingface.co/lmsys/vicuna-7b-v1.5
6
+ License: LICENSE-Llama-2. Acceptable Use Policy:
7
+ https://huggingface.co/meta-llama/Llama-2-7b/blob/main/USE_POLICY.md
8
+
9
+ DINOv2: Meta Platforms, Inc. and affiliates, Apache-2.0.
10
+ https://github.com/facebookresearch/dinov2
11
+
12
+ RF-DETR segmentation 1.5.2: Roboflow, Apache-2.0.
13
+ https://github.com/roboflow/rf-detr/tree/1.5.2
14
+
15
+ FALCON code: Apache-2.0. See LICENSE-code for the Apache-2.0 license text.
16
+ The final model includes fine-tuned detector, adapter and LoRA weights;
17
+ upstream model licenses remain applicable.
README.md ADDED
@@ -0,0 +1,93 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ library_name: transformers
3
+ license: llama2
4
+ base_model:
5
+ - lmsys/vicuna-7b-v1.5
6
+ - facebook/dinov2-large
7
+ tags:
8
+ - falcon-x
9
+ - vision-language
10
+ - image-segmentation
11
+ - custom_code
12
+ language:
13
+ - en
14
+ ---
15
+
16
+ # FALCON
17
+
18
+ Functional Assembly and Language for Compositional Reasoning in X-ray.
19
+
20
+ [Paper](https://arxiv.org/abs/2606.25701) ·
21
+ [Project page](https://yonathan-kiflom.github.io/FALCON/page/) ·
22
+ [Code](https://github.com/yonathan-kiflom/FALCON)
23
+
24
+ This repository contains the completed Stage-3 model, including Vicuna-7B-v1.5,
25
+ DINOv2-L/14, the trained RF-DETR segmentation detector, multimodal adapters,
26
+ and unmerged LoRA weights.
27
+
28
+ ## Usage
29
+
30
+ Install the pinned dependencies from the code repository:
31
+
32
+ ```bash
33
+ pip install 'falcon-x[train] @ git+https://github.com/yonathan-kiflom/FALCON.git'
34
+ ```
35
+
36
+ Use `JonathanJMK/FALCON` or a local downloaded model directory. Authenticate
37
+ with `huggingface-cli login` when accessing the private repository. Review the
38
+ custom code before trusting it; pin a Hub commit with `revision=` for reproducible use.
39
+
40
+ ```python
41
+ from transformers import AutoModel
42
+
43
+ model = AutoModel.from_pretrained(
44
+ "JonathanJMK/FALCON", trust_remote_code=True,
45
+ ).to("cuda").eval()
46
+
47
+ result, masks = model.predict(
48
+ image="image.png", prompt="Describe the image.", max_new_tokens=256,
49
+ )
50
+ print(result["answer"])
51
+ ```
52
+
53
+ Use `predict_segmentation` for grounding prompts and `predict_panoptic` for
54
+ panoptic prompts. Tokenization, image preprocessing, and prompt formatting are
55
+ included. The model supports one device; automatic multi-device dispatch and
56
+ quantized loading are not supported. The saved per-component precision is
57
+ preserved; do not cast the entire model to half precision.
58
+
59
+ Evaluate using the same model:
60
+
61
+ ```bash
62
+ python -m falcon evaluate run --model JonathanJMK/FALCON \
63
+ --dataset /path/to/falcon-x --split test --tasks all \
64
+ --run-dir runs/falcon-evaluation --device cuda
65
+ ```
66
+
67
+ ## Scope and limitations
68
+
69
+ Trained on falcon-x for X-ray descriptions, questions, component
70
+ presence/completeness, instance grounding, and segmentation. Generated answers
71
+ and masks can be wrong; this is a research model, not a certified screening
72
+ system. Available structured heads are declared in `config.json`; untrained
73
+ risk and physical-link heads are not presented as predictions. Counterfactual
74
+ completeness does not establish real-world danger or physical connectivity.
75
+
76
+ The package preserves the trained architecture and LoRA for further research.
77
+ It is not an optimizer-state checkpoint, and loading with Transformers alone
78
+ does not make the repository's stage-training CLI a general fine-tuning tool.
79
+ Task metrics and evaluation limitations are documented in the code repository.
80
+
81
+ ## Licenses
82
+
83
+ Vicuna is derived from Llama 2 and retains the
84
+ [Llama 2 Community License](https://huggingface.co/meta-llama/Llama-2-7b/blob/main/LICENSE.txt)
85
+ and [Acceptable Use Policy](https://huggingface.co/meta-llama/Llama-2-7b/blob/main/USE_POLICY.md).
86
+ Llama 2 is licensed under the LLAMA 2 Community License, Copyright (c) Meta
87
+ Platforms, Inc. All Rights Reserved.
88
+
89
+ [DINOv2](https://github.com/facebookresearch/dinov2/blob/main/LICENSE) and
90
+ [RF-DETR segmentation 1.5.2](https://github.com/roboflow/rf-detr/blob/1.5.2/LICENSE)
91
+ use Apache-2.0. FALCON code is Apache-2.0 (`LICENSE-code`); this does not
92
+ relicense upstream model weights. Retain `LICENSE-Llama-2`, `LICENSE-code` and
93
+ `Notice` when redistributing the full package.
artifacts.py ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File output and panoptic image utilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ from collections.abc import Iterable
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def panoptic_ids_from_image(image):
14
+ """Decode integer or RGB(A) panoptic IDs without rescaling."""
15
+
16
+ import numpy as np
17
+
18
+ array = np.asarray(image)
19
+ if array.ndim == 2:
20
+ if not np.issubdtype(array.dtype, np.integer):
21
+ raise ValueError("panoptic map must contain integer IDs")
22
+ return array.astype(np.int64)
23
+ if array.ndim == 3 and array.shape[2] in (3, 4):
24
+ rgb = array[..., :3].astype(np.int64)
25
+ return rgb[..., 0] + 256 * rgb[..., 1] + 65536 * rgb[..., 2]
26
+ raise ValueError("panoptic PNG must be integer or RGB")
27
+
28
+
29
+ def external_output(
30
+ path: str | Path,
31
+ dataset_root: str | Path,
32
+ *,
33
+ protected_roots: Iterable[str | Path] = (),
34
+ ) -> Path:
35
+ """Keep generated files outside dataset and annotation directories."""
36
+
37
+ output = Path(path).expanduser().resolve()
38
+ requested_roots = (dataset_root, *tuple(protected_roots))
39
+ immutable_roots: list[Path] = []
40
+ for raw_root in requested_roots:
41
+ root = Path(raw_root).expanduser().resolve(strict=True)
42
+ if not root.is_dir():
43
+ raise ValueError(f"Protected input root is not a directory: {root}")
44
+ if root not in immutable_roots:
45
+ immutable_roots.append(root)
46
+ for root in immutable_roots:
47
+ if output == root or root in output.parents or output in root.parents:
48
+ raise ValueError(
49
+ "Derived output must be outside immutable input roots and not an ancestor of one"
50
+ )
51
+ return output
52
+
53
+
54
+ def atomic_json(path: str | Path, value: Any) -> None:
55
+ """Write a complete UTF-8 JSON document, then atomically replace its target."""
56
+ path = Path(path)
57
+ encoded = (
58
+ json.dumps(value, sort_keys=True, indent=2, ensure_ascii=False, allow_nan=False)
59
+ + "\n"
60
+ )
61
+ path.parent.mkdir(parents=True, exist_ok=True)
62
+ temporary = None
63
+ try:
64
+ with tempfile.NamedTemporaryFile(
65
+ mode="w",
66
+ encoding="utf-8",
67
+ dir=path.parent,
68
+ prefix=f".{path.name}.",
69
+ suffix=".tmp",
70
+ delete=False,
71
+ ) as stream:
72
+ temporary = Path(stream.name)
73
+ stream.write(encoded)
74
+ stream.flush()
75
+ os.fsync(stream.fileno())
76
+ os.replace(temporary, path)
77
+ finally:
78
+ if temporary is not None and temporary.exists():
79
+ temporary.unlink()
capabilities.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Explicit structured-head capabilities, independent of model dependencies.
2
+
3
+ Reference availability, training coverage and prediction availability are separate
4
+ facts. A capability records which trained heads may be used; it does not certify
5
+ accuracy. Missing checkpoint metadata must not be interpreted as all-enabled.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import math
11
+ from collections.abc import Iterable, Mapping
12
+ from dataclasses import dataclass
13
+ from typing import Any
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SafetyCapabilities:
18
+ risk: bool = True
19
+ presence: tuple[bool, bool, bool] = (True, True, True)
20
+ links: tuple[bool, bool, bool] = (True, True, True)
21
+
22
+ def __post_init__(self) -> None:
23
+ if not isinstance(self.risk, bool):
24
+ raise ValueError("risk capability must be boolean")
25
+ for name in ("presence", "links"):
26
+ values = getattr(self, name)
27
+ if (
28
+ not isinstance(values, tuple)
29
+ or len(values) != 3
30
+ or any(not isinstance(value, bool) for value in values)
31
+ ):
32
+ raise ValueError(f"{name} capabilities must be a tuple of three booleans")
33
+
34
+ @property
35
+ def token_indices(self) -> tuple[int, ...]:
36
+ flags = (self.risk, *self.presence, *self.links)
37
+ return tuple(index for index, enabled in enumerate(flags) if enabled)
38
+
39
+ def as_dict(self) -> dict[str, Any]:
40
+ return {"risk": self.risk, "presence": list(self.presence), "links": list(self.links)}
41
+
42
+ @classmethod
43
+ def unavailable(cls) -> SafetyCapabilities:
44
+ return cls(False, (False, False, False), (False, False, False))
45
+
46
+ @classmethod
47
+ def from_dict(cls, value: Mapping[str, Any]) -> SafetyCapabilities:
48
+ if not isinstance(value, Mapping) or set(value) != {"risk", "presence", "links"}:
49
+ raise ValueError("safety capabilities require exactly risk, presence, and links")
50
+ if not isinstance(value["presence"], list | tuple) or not isinstance(
51
+ value["links"], list | tuple
52
+ ):
53
+ raise ValueError("presence and links capabilities must be arrays")
54
+ return cls(value["risk"], tuple(value["presence"]), tuple(value["links"]))
55
+
56
+ def restrict(self, allowed: SafetyCapabilities) -> SafetyCapabilities:
57
+ """Ablations can disable capabilities, never enable untrained heads."""
58
+ return SafetyCapabilities(
59
+ self.risk and allowed.risk,
60
+ tuple(a and b for a, b in zip(self.presence, allowed.presence, strict=True)),
61
+ tuple(a and b for a, b in zip(self.links, allowed.links, strict=True)),
62
+ )
63
+
64
+
65
+ ALL_SAFETY_HEADS = SafetyCapabilities()
66
+ SSA_ABLATIONS = ("none", "no_ssa", "no_presence", "no_links", "no_risk")
67
+
68
+
69
+ def apply_ablation(capabilities: SafetyCapabilities, name: str) -> SafetyCapabilities:
70
+ """Disable named heads before token construction; never invent capability."""
71
+ if name not in SSA_ABLATIONS:
72
+ raise ValueError(f"unknown SSA ablation {name!r}")
73
+ mask = {
74
+ "none": ALL_SAFETY_HEADS,
75
+ "no_ssa": SafetyCapabilities.unavailable(),
76
+ "no_presence": SafetyCapabilities(True, (False, False, False), (True, True, True)),
77
+ "no_links": SafetyCapabilities(True, (True, True, True), (False, False, False)),
78
+ "no_risk": SafetyCapabilities(False, (True, True, True), (True, True, True)),
79
+ }[name]
80
+ return capabilities.restrict(mask)
81
+
82
+
83
+ def supervision_coverage(rows: Iterable[Mapping[str, Any]]) -> dict[str, Any]:
84
+ """Count authoritative labels per image, without substituting missing values.
85
+
86
+ Callers must provide unique training images and validated provenance. This
87
+ function validates numeric domains, not the authority of the source itself.
88
+ """
89
+ coverage = {"images": 0, "risk": 0, "presence": [0, 0, 0], "links": [0, 0, 0]}
90
+
91
+ def observed(value: Any, *, binary: bool = False) -> bool:
92
+ if value is None:
93
+ return False
94
+ if isinstance(value, bool) or not isinstance(value, int | float):
95
+ raise ValueError("structured labels must be numeric or null")
96
+ if not math.isfinite(value) or not 0 <= value <= 1:
97
+ raise ValueError("structured labels must be finite and in [0, 1]")
98
+ if binary and value not in (0, 1):
99
+ raise ValueError("presence labels must be binary")
100
+ return True
101
+
102
+ for row in rows:
103
+ if not isinstance(row, Mapping):
104
+ raise ValueError("structured targets must be objects")
105
+ coverage["images"] += 1
106
+ coverage["risk"] += int(observed(row.get("risk")))
107
+ for name in ("presence", "links"):
108
+ values = row.get(name, [None, None, None])
109
+ if not isinstance(values, list | tuple) or len(values) != 3:
110
+ raise ValueError(f"{name} targets must have three entries")
111
+ for index, value in enumerate(values):
112
+ coverage[name][index] += int(observed(value, binary=name == "presence"))
113
+ return coverage
114
+
115
+
116
+ def capabilities_from_coverage(coverage: Mapping[str, Any]) -> SafetyCapabilities:
117
+ """An observed label enables training, not a claim of calibrated predictions."""
118
+ for name in ("images", "risk"):
119
+ value = coverage.get(name)
120
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
121
+ raise ValueError(f"{name} coverage must be a nonnegative integer")
122
+ counts = []
123
+ for name in ("presence", "links"):
124
+ values = coverage.get(name)
125
+ if (
126
+ not isinstance(values, list | tuple)
127
+ or len(values) != 3
128
+ or any(
129
+ isinstance(value, bool) or not isinstance(value, int) or value < 0
130
+ for value in values
131
+ )
132
+ ):
133
+ raise ValueError(f"{name} coverage must contain three nonnegative integers")
134
+ counts.extend(values)
135
+ if any(value > coverage["images"] for value in [coverage["risk"], *counts]):
136
+ raise ValueError("label coverage cannot exceed training image count")
137
+ return SafetyCapabilities(
138
+ coverage["risk"] > 0,
139
+ tuple(value > 0 for value in coverage["presence"]),
140
+ tuple(value > 0 for value in coverage["links"]),
141
+ )
config.json ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "FalconModel"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "configuration_falcon.FalconConfig",
7
+ "AutoModel": "modeling_falcon.FalconModel"
8
+ },
9
+ "checkpoint_metadata": {
10
+ "checkpoint_format": "falcon-checkpoint-v1",
11
+ "config": {
12
+ "detector": {
13
+ "mask_threshold": 0.5,
14
+ "max_regions": 100,
15
+ "nms_threshold": 0.6,
16
+ "resolution": 768,
17
+ "score_threshold": 0.15,
18
+ "variant": "seg-2xlarge"
19
+ },
20
+ "experiment": {
21
+ "backbone_layout": "independent",
22
+ "name": "falcon-x",
23
+ "protocol": "dataset-v1",
24
+ "ssa_ablation": "none",
25
+ "world_size": null
26
+ },
27
+ "model": {
28
+ "component_order": [
29
+ "detonator",
30
+ "explosive",
31
+ "battery"
32
+ ],
33
+ "image_size": 448,
34
+ "language_model": "lmsys/vicuna-7b-v1.5",
35
+ "link_order": [
36
+ [
37
+ "battery",
38
+ "detonator"
39
+ ],
40
+ [
41
+ "battery",
42
+ "explosive"
43
+ ],
44
+ [
45
+ "detonator",
46
+ "explosive"
47
+ ]
48
+ ],
49
+ "lora_alpha": 32,
50
+ "lora_dropout": 0.05,
51
+ "lora_rank": 16,
52
+ "max_regions": 100,
53
+ "patch_size": 14,
54
+ "region_dim": 1024,
55
+ "roi_size": 4,
56
+ "vision_model": "facebook/dinov2-large"
57
+ },
58
+ "stages": {
59
+ "stage1": {
60
+ "batch_size": 1,
61
+ "encoder_learning_rate": 0.00015,
62
+ "epochs": 12,
63
+ "expanded_scales": false,
64
+ "gradient_accumulation": 16,
65
+ "learning_rate": 0.0001,
66
+ "lr_scheduler": "cosine",
67
+ "multi_scale": false,
68
+ "precision": "backend_mixed",
69
+ "tf32": true,
70
+ "warmup_epochs": 0.0,
71
+ "weight_decay": 0.0001
72
+ },
73
+ "stage2": {
74
+ "batch_size": 4,
75
+ "epoch_size": null,
76
+ "epochs": 1,
77
+ "gradient_accumulation": 4,
78
+ "learning_rate": 0.0001,
79
+ "link_loss_weight": 0.5,
80
+ "max_text_tokens": 256,
81
+ "precision": "bf16",
82
+ "presence_loss_weight": 0.5,
83
+ "risk_loss_weight": 1.0,
84
+ "sampling": "all",
85
+ "seed": 42,
86
+ "text_overflow_policy": "error",
87
+ "tf32": true,
88
+ "warmup_ratio": 0.03,
89
+ "weight_decay": 0.0
90
+ },
91
+ "stage3": {
92
+ "batch_size": 1,
93
+ "epoch_size": null,
94
+ "epochs": 1,
95
+ "gradient_accumulation": 16,
96
+ "learning_rate": 1e-05,
97
+ "link_loss_weight": 0.5,
98
+ "max_text_tokens": 256,
99
+ "precision": "bf16",
100
+ "presence_loss_weight": 0.5,
101
+ "risk_loss_weight": 1.0,
102
+ "sampling": "all",
103
+ "seed": 42,
104
+ "text_overflow_policy": "error",
105
+ "tf32": true,
106
+ "warmup_ratio": 0.03,
107
+ "weight_decay": 0.0
108
+ }
109
+ },
110
+ "training": {
111
+ "batch_size": 1,
112
+ "epoch_size": null,
113
+ "epochs": 1,
114
+ "gradient_accumulation": 16,
115
+ "learning_rate": 0.0001,
116
+ "link_loss_weight": 0.5,
117
+ "max_text_tokens": 256,
118
+ "precision": "bf16",
119
+ "presence_loss_weight": 0.5,
120
+ "risk_loss_weight": 1.0,
121
+ "sampling": "all",
122
+ "seed": 42,
123
+ "text_overflow_policy": "error",
124
+ "tf32": true,
125
+ "warmup_ratio": 0.03,
126
+ "weight_decay": 0.0
127
+ }
128
+ },
129
+ "dataset_categories": [
130
+ {
131
+ "id": 1,
132
+ "name": "detonator"
133
+ },
134
+ {
135
+ "id": 2,
136
+ "name": "explosive"
137
+ },
138
+ {
139
+ "id": 3,
140
+ "name": "battery"
141
+ }
142
+ ],
143
+ "diagnostic_training_reasons": [],
144
+ "grounding_coverage": {
145
+ "full_match": 177693,
146
+ "ground_truth_instances": 238792,
147
+ "language_supervision_masked": 1405,
148
+ "matched_instances": 237385,
149
+ "partial_match": 496,
150
+ "true_negative": 6,
151
+ "unmatched_positive": 909
152
+ },
153
+ "grounding_policy": {
154
+ "iou_threshold": 0.5,
155
+ "unmatched": "mask_language_loss",
156
+ "version": "falcon-grounding-match/cardinality-iou-v1"
157
+ },
158
+ "observed_supervision_coverage": {
159
+ "images": 34826,
160
+ "links": [
161
+ 0,
162
+ 0,
163
+ 0
164
+ ],
165
+ "presence": [
166
+ 34826,
167
+ 34826,
168
+ 34826
169
+ ],
170
+ "risk": 0
171
+ },
172
+ "official_result_eligible": true,
173
+ "precision": "bf16",
174
+ "require_observed_supervision": true,
175
+ "safety_capabilities": {
176
+ "links": [
177
+ false,
178
+ false,
179
+ false
180
+ ],
181
+ "presence": [
182
+ true,
183
+ true,
184
+ true
185
+ ],
186
+ "risk": false
187
+ },
188
+ "ssa_ablation": "none",
189
+ "stage": 3,
190
+ "structured_loss_recipe": {
191
+ "links": "sigmoid_l1",
192
+ "presence": "bce_with_logits_fp32",
193
+ "reduction": "mean_over_finite_enabled_targets",
194
+ "risk": "sigmoid_l1"
195
+ },
196
+ "supervision_capabilities": {
197
+ "links": [
198
+ false,
199
+ false,
200
+ false
201
+ ],
202
+ "presence": [
203
+ true,
204
+ true,
205
+ true
206
+ ],
207
+ "risk": false
208
+ },
209
+ "training_complete": true
210
+ },
211
+ "core_config": {
212
+ "image_size": 448,
213
+ "language_dim": 4096,
214
+ "link_loss_weight": 0.5,
215
+ "mask_probability_threshold": 0.5,
216
+ "max_regions": 100,
217
+ "max_text_tokens": 256,
218
+ "patch_size": 14,
219
+ "presence_loss_weight": 0.5,
220
+ "region_dim": 1024,
221
+ "risk_loss_weight": 1.0,
222
+ "roi_size": 4,
223
+ "safety_capabilities": {
224
+ "links": [
225
+ false,
226
+ false,
227
+ false
228
+ ],
229
+ "presence": [
230
+ true,
231
+ true,
232
+ true
233
+ ],
234
+ "risk": false
235
+ },
236
+ "text_overflow_policy": "error",
237
+ "vision_dim": 1024,
238
+ "vision_prefix_tokens": 1
239
+ },
240
+ "detector_config": {
241
+ "architecture": {
242
+ "amp": true,
243
+ "bbox_reparam": true,
244
+ "ca_nheads": 16,
245
+ "cls_loss_coef": 1.0,
246
+ "dec_layers": 6,
247
+ "dec_n_points": 2,
248
+ "encoder": "dinov2_windowed_small",
249
+ "gradient_checkpointing": false,
250
+ "group_detr": 13,
251
+ "hidden_dim": 256,
252
+ "ia_bce_loss": true,
253
+ "layer_norm": true,
254
+ "license": "Apache-2.0",
255
+ "lite_refpoint_refine": true,
256
+ "mask_downsample_ratio": 4,
257
+ "num_classes": 1,
258
+ "num_queries": 300,
259
+ "num_select": 300,
260
+ "num_windows": 2,
261
+ "out_feature_indexes": [
262
+ 3,
263
+ 6,
264
+ 9,
265
+ 12
266
+ ],
267
+ "patch_size": 12,
268
+ "positional_encoding_size": 64,
269
+ "projector_scale": [
270
+ "P4"
271
+ ],
272
+ "resolution": 768,
273
+ "sa_nheads": 8,
274
+ "segmentation_head": true,
275
+ "two_stage": true
276
+ },
277
+ "proposal_policy": {
278
+ "mask_threshold": 0.5,
279
+ "max_regions": 100,
280
+ "nms_threshold": 0.6,
281
+ "reject_empty_masks": true,
282
+ "score_threshold": 0.15,
283
+ "version": "falcon-proposals/v1"
284
+ },
285
+ "variant": "seg-2xlarge"
286
+ },
287
+ "image_processor_config": {
288
+ "_processor_class": null,
289
+ "crop_size": {
290
+ "height": 448,
291
+ "width": 448
292
+ },
293
+ "do_center_crop": true,
294
+ "do_convert_rgb": true,
295
+ "do_normalize": true,
296
+ "do_rescale": true,
297
+ "do_resize": true,
298
+ "image_mean": [
299
+ 0.485,
300
+ 0.456,
301
+ 0.406
302
+ ],
303
+ "image_processor_type": "BitImageProcessor",
304
+ "image_std": [
305
+ 0.229,
306
+ 0.224,
307
+ 0.225
308
+ ],
309
+ "resample": 3,
310
+ "rescale_factor": 0.00392156862745098,
311
+ "size": {
312
+ "height": 448,
313
+ "width": 448
314
+ }
315
+ },
316
+ "language_config": {
317
+ "_attn_implementation_autoset": true,
318
+ "add_cross_attention": false,
319
+ "architectures": [
320
+ "LlamaForCausalLM"
321
+ ],
322
+ "attention_bias": false,
323
+ "attention_dropout": 0.0,
324
+ "attn_implementation": "sdpa",
325
+ "bad_words_ids": null,
326
+ "begin_suppress_tokens": null,
327
+ "bos_token_id": 1,
328
+ "chunk_size_feed_forward": 0,
329
+ "cross_attention_hidden_size": null,
330
+ "decoder_start_token_id": null,
331
+ "diversity_penalty": 0.0,
332
+ "do_sample": false,
333
+ "early_stopping": false,
334
+ "encoder_no_repeat_ngram_size": 0,
335
+ "eos_token_id": 2,
336
+ "exponential_decay_length_penalty": null,
337
+ "finetuning_task": null,
338
+ "forced_bos_token_id": null,
339
+ "forced_eos_token_id": null,
340
+ "head_dim": 128,
341
+ "hidden_act": "silu",
342
+ "hidden_size": 4096,
343
+ "id2label": {
344
+ "0": "LABEL_0",
345
+ "1": "LABEL_1"
346
+ },
347
+ "initializer_range": 0.02,
348
+ "intermediate_size": 11008,
349
+ "is_decoder": false,
350
+ "is_encoder_decoder": false,
351
+ "label2id": {
352
+ "LABEL_0": 0,
353
+ "LABEL_1": 1
354
+ },
355
+ "length_penalty": 1.0,
356
+ "max_length": 20,
357
+ "max_position_embeddings": 4096,
358
+ "min_length": 0,
359
+ "mlp_bias": false,
360
+ "model_type": "llama",
361
+ "no_repeat_ngram_size": 0,
362
+ "num_attention_heads": 32,
363
+ "num_beam_groups": 1,
364
+ "num_beams": 1,
365
+ "num_hidden_layers": 32,
366
+ "num_key_value_heads": 32,
367
+ "num_return_sequences": 1,
368
+ "output_attentions": false,
369
+ "output_hidden_states": false,
370
+ "output_scores": false,
371
+ "pad_token_id": 0,
372
+ "prefix": null,
373
+ "pretraining_tp": 1,
374
+ "problem_type": null,
375
+ "pruned_heads": {},
376
+ "remove_invalid_values": false,
377
+ "repetition_penalty": 1.0,
378
+ "return_dict": true,
379
+ "return_dict_in_generate": false,
380
+ "rms_norm_eps": 1e-05,
381
+ "rope_scaling": null,
382
+ "rope_theta": 10000.0,
383
+ "sep_token_id": null,
384
+ "suppress_tokens": null,
385
+ "task_specific_params": null,
386
+ "temperature": 1.0,
387
+ "tf_legacy_loss": false,
388
+ "tie_encoder_decoder": false,
389
+ "tie_word_embeddings": false,
390
+ "tokenizer_class": null,
391
+ "top_k": 50,
392
+ "top_p": 1.0,
393
+ "torch_dtype": "bfloat16",
394
+ "torchscript": false,
395
+ "transformers_version": "4.49.0",
396
+ "typical_p": 1.0,
397
+ "use_bfloat16": false,
398
+ "use_cache": true,
399
+ "vocab_size": 32000
400
+ },
401
+ "language_generation_config": {
402
+ "_from_model_config": false,
403
+ "assistant_confidence_threshold": 0.4,
404
+ "assistant_early_exit": null,
405
+ "assistant_lookbehind": 10,
406
+ "bad_words_ids": null,
407
+ "begin_suppress_tokens": null,
408
+ "bos_token_id": 1,
409
+ "cache_config": null,
410
+ "cache_implementation": null,
411
+ "constraints": null,
412
+ "decoder_start_token_id": null,
413
+ "disable_compile": false,
414
+ "diversity_penalty": 0.0,
415
+ "do_sample": false,
416
+ "dola_layers": null,
417
+ "early_stopping": false,
418
+ "encoder_no_repeat_ngram_size": 0,
419
+ "encoder_repetition_penalty": 1.0,
420
+ "eos_token_id": 2,
421
+ "epsilon_cutoff": 0.0,
422
+ "eta_cutoff": 0.0,
423
+ "exponential_decay_length_penalty": null,
424
+ "force_words_ids": null,
425
+ "forced_bos_token_id": null,
426
+ "forced_decoder_ids": null,
427
+ "forced_eos_token_id": null,
428
+ "generation_kwargs": {},
429
+ "guidance_scale": null,
430
+ "is_assistant": false,
431
+ "length_penalty": 1.0,
432
+ "low_memory": null,
433
+ "max_length": 4096,
434
+ "max_matching_ngram_size": null,
435
+ "max_new_tokens": null,
436
+ "max_time": null,
437
+ "min_length": 0,
438
+ "min_new_tokens": null,
439
+ "min_p": null,
440
+ "no_repeat_ngram_size": 0,
441
+ "num_assistant_tokens": 20,
442
+ "num_assistant_tokens_schedule": "constant",
443
+ "num_beam_groups": 1,
444
+ "num_beams": 1,
445
+ "num_return_sequences": 1,
446
+ "output_attentions": false,
447
+ "output_hidden_states": false,
448
+ "output_logits": null,
449
+ "output_scores": false,
450
+ "pad_token_id": 0,
451
+ "penalty_alpha": null,
452
+ "prompt_lookup_num_tokens": null,
453
+ "remove_invalid_values": false,
454
+ "renormalize_logits": false,
455
+ "repetition_penalty": 1.0,
456
+ "return_dict_in_generate": false,
457
+ "return_legacy_cache": null,
458
+ "sequence_bias": null,
459
+ "stop_strings": null,
460
+ "suppress_tokens": null,
461
+ "target_lookbehind": 10,
462
+ "temperature": 0.9,
463
+ "token_healing": false,
464
+ "top_k": 50,
465
+ "top_p": 0.6,
466
+ "transformers_version": "4.49.0",
467
+ "typical_p": 1.0,
468
+ "use_cache": true,
469
+ "watermarking_config": null
470
+ },
471
+ "lora_config": {
472
+ "alpha": 32,
473
+ "dropout": 0.05,
474
+ "rank": 16,
475
+ "target_modules": [
476
+ "k_proj",
477
+ "o_proj",
478
+ "q_proj",
479
+ "v_proj"
480
+ ]
481
+ },
482
+ "model_type": "falcon_x",
483
+ "runtime_config": {
484
+ "detector": {
485
+ "mask_threshold": 0.5,
486
+ "max_regions": 100,
487
+ "nms_threshold": 0.6,
488
+ "resolution": 768,
489
+ "score_threshold": 0.15,
490
+ "variant": "seg-2xlarge"
491
+ },
492
+ "experiment": {
493
+ "backbone_layout": "independent",
494
+ "name": "falcon-x",
495
+ "protocol": "dataset-v1",
496
+ "ssa_ablation": "none",
497
+ "world_size": null
498
+ },
499
+ "model": {
500
+ "component_order": [
501
+ "detonator",
502
+ "explosive",
503
+ "battery"
504
+ ],
505
+ "image_size": 448,
506
+ "language_model": "lmsys/vicuna-7b-v1.5",
507
+ "link_order": [
508
+ [
509
+ "battery",
510
+ "detonator"
511
+ ],
512
+ [
513
+ "battery",
514
+ "explosive"
515
+ ],
516
+ [
517
+ "detonator",
518
+ "explosive"
519
+ ]
520
+ ],
521
+ "lora_alpha": 32,
522
+ "lora_dropout": 0.05,
523
+ "lora_rank": 16,
524
+ "max_regions": 100,
525
+ "patch_size": 14,
526
+ "region_dim": 1024,
527
+ "roi_size": 4,
528
+ "vision_model": "facebook/dinov2-large"
529
+ },
530
+ "stages": {
531
+ "stage1": {
532
+ "batch_size": 1,
533
+ "encoder_learning_rate": 0.00015,
534
+ "epochs": 12,
535
+ "expanded_scales": false,
536
+ "gradient_accumulation": 16,
537
+ "learning_rate": 0.0001,
538
+ "lr_scheduler": "cosine",
539
+ "multi_scale": false,
540
+ "precision": "backend_mixed",
541
+ "tf32": true,
542
+ "warmup_epochs": 0.0,
543
+ "weight_decay": 0.0001
544
+ },
545
+ "stage2": {
546
+ "batch_size": 4,
547
+ "epoch_size": null,
548
+ "epochs": 1,
549
+ "gradient_accumulation": 4,
550
+ "learning_rate": 0.0001,
551
+ "link_loss_weight": 0.5,
552
+ "max_text_tokens": 256,
553
+ "precision": "bf16",
554
+ "presence_loss_weight": 0.5,
555
+ "risk_loss_weight": 1.0,
556
+ "sampling": "all",
557
+ "seed": 42,
558
+ "text_overflow_policy": "error",
559
+ "tf32": true,
560
+ "warmup_ratio": 0.03,
561
+ "weight_decay": 0.0
562
+ },
563
+ "stage3": {
564
+ "batch_size": 1,
565
+ "epoch_size": null,
566
+ "epochs": 1,
567
+ "gradient_accumulation": 16,
568
+ "learning_rate": 1e-05,
569
+ "link_loss_weight": 0.5,
570
+ "max_text_tokens": 256,
571
+ "precision": "bf16",
572
+ "presence_loss_weight": 0.5,
573
+ "risk_loss_weight": 1.0,
574
+ "sampling": "all",
575
+ "seed": 42,
576
+ "text_overflow_policy": "error",
577
+ "tf32": true,
578
+ "warmup_ratio": 0.03,
579
+ "weight_decay": 0.0
580
+ }
581
+ },
582
+ "training": {
583
+ "batch_size": 1,
584
+ "epoch_size": null,
585
+ "epochs": 1,
586
+ "gradient_accumulation": 16,
587
+ "learning_rate": 0.0001,
588
+ "link_loss_weight": 0.5,
589
+ "max_text_tokens": 256,
590
+ "precision": "bf16",
591
+ "presence_loss_weight": 0.5,
592
+ "risk_loss_weight": 1.0,
593
+ "sampling": "all",
594
+ "seed": 42,
595
+ "text_overflow_policy": "error",
596
+ "tf32": true,
597
+ "warmup_ratio": 0.03,
598
+ "weight_decay": 0.0
599
+ }
600
+ },
601
+ "tie_word_embeddings": false,
602
+ "torch_dtype": "float32",
603
+ "transformers_version": "4.49.0",
604
+ "vision_config": {
605
+ "_attn_implementation_autoset": true,
606
+ "add_cross_attention": false,
607
+ "apply_layernorm": true,
608
+ "architectures": [
609
+ "Dinov2Model"
610
+ ],
611
+ "attention_probs_dropout_prob": 0.0,
612
+ "attn_implementation": "sdpa",
613
+ "bad_words_ids": null,
614
+ "begin_suppress_tokens": null,
615
+ "bos_token_id": null,
616
+ "chunk_size_feed_forward": 0,
617
+ "cross_attention_hidden_size": null,
618
+ "decoder_start_token_id": null,
619
+ "diversity_penalty": 0.0,
620
+ "do_sample": false,
621
+ "drop_path_rate": 0.0,
622
+ "early_stopping": false,
623
+ "encoder_no_repeat_ngram_size": 0,
624
+ "eos_token_id": null,
625
+ "exponential_decay_length_penalty": null,
626
+ "finetuning_task": null,
627
+ "forced_bos_token_id": null,
628
+ "forced_eos_token_id": null,
629
+ "hidden_act": "gelu",
630
+ "hidden_dropout_prob": 0.0,
631
+ "hidden_size": 1024,
632
+ "id2label": {
633
+ "0": "LABEL_0",
634
+ "1": "LABEL_1"
635
+ },
636
+ "image_size": 518,
637
+ "initializer_range": 0.02,
638
+ "is_decoder": false,
639
+ "is_encoder_decoder": false,
640
+ "label2id": {
641
+ "LABEL_0": 0,
642
+ "LABEL_1": 1
643
+ },
644
+ "layer_norm_eps": 1e-06,
645
+ "layerscale_value": 1.0,
646
+ "length_penalty": 1.0,
647
+ "max_length": 20,
648
+ "min_length": 0,
649
+ "mlp_ratio": 4,
650
+ "model_type": "dinov2",
651
+ "no_repeat_ngram_size": 0,
652
+ "num_attention_heads": 16,
653
+ "num_beam_groups": 1,
654
+ "num_beams": 1,
655
+ "num_channels": 3,
656
+ "num_hidden_layers": 24,
657
+ "num_return_sequences": 1,
658
+ "out_features": [
659
+ "stage24"
660
+ ],
661
+ "out_indices": [
662
+ 24
663
+ ],
664
+ "output_attentions": false,
665
+ "output_hidden_states": false,
666
+ "output_scores": false,
667
+ "pad_token_id": null,
668
+ "patch_size": 14,
669
+ "prefix": null,
670
+ "problem_type": null,
671
+ "pruned_heads": {},
672
+ "qkv_bias": true,
673
+ "remove_invalid_values": false,
674
+ "repetition_penalty": 1.0,
675
+ "reshape_hidden_states": true,
676
+ "return_dict": true,
677
+ "return_dict_in_generate": false,
678
+ "sep_token_id": null,
679
+ "stage_names": [
680
+ "stem",
681
+ "stage1",
682
+ "stage2",
683
+ "stage3",
684
+ "stage4",
685
+ "stage5",
686
+ "stage6",
687
+ "stage7",
688
+ "stage8",
689
+ "stage9",
690
+ "stage10",
691
+ "stage11",
692
+ "stage12",
693
+ "stage13",
694
+ "stage14",
695
+ "stage15",
696
+ "stage16",
697
+ "stage17",
698
+ "stage18",
699
+ "stage19",
700
+ "stage20",
701
+ "stage21",
702
+ "stage22",
703
+ "stage23",
704
+ "stage24"
705
+ ],
706
+ "suppress_tokens": null,
707
+ "task_specific_params": null,
708
+ "temperature": 1.0,
709
+ "tf_legacy_loss": false,
710
+ "tie_encoder_decoder": false,
711
+ "tie_word_embeddings": true,
712
+ "tokenizer_class": null,
713
+ "top_k": 50,
714
+ "top_p": 1.0,
715
+ "torch_dtype": "float32",
716
+ "torchscript": false,
717
+ "transformers_version": "4.49.0",
718
+ "typical_p": 1.0,
719
+ "use_bfloat16": false,
720
+ "use_mask_token": true,
721
+ "use_swiglu_ffn": false
722
+ }
723
+ }
config.py ADDED
@@ -0,0 +1,365 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from copy import deepcopy
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import yaml
9
+
10
+ from .capabilities import SSA_ABLATIONS
11
+
12
+ COMPONENT_ORDER = ("detonator", "explosive", "battery")
13
+ LINK_ORDER = (
14
+ ("battery", "detonator"),
15
+ ("battery", "explosive"),
16
+ ("detonator", "explosive"),
17
+ )
18
+ DETECTOR_VARIANTS = {
19
+ "seg-nano",
20
+ "seg-small",
21
+ "seg-medium",
22
+ "seg-large",
23
+ "seg-xlarge",
24
+ "seg-2xlarge",
25
+ }
26
+
27
+
28
+ DEFAULT_CONFIG: dict[str, Any] = {
29
+ "experiment": {
30
+ "name": "falcon-x",
31
+ "protocol": "dataset-v1",
32
+ "backbone_layout": "independent",
33
+ "world_size": None,
34
+ "ssa_ablation": "none",
35
+ },
36
+ "model": {
37
+ "vision_model": "facebook/dinov2-large",
38
+ "language_model": "lmsys/vicuna-7b-v1.5",
39
+ "image_size": 448,
40
+ "patch_size": 14,
41
+ "region_dim": 1024,
42
+ "roi_size": 4,
43
+ "max_regions": 100,
44
+ "component_order": list(COMPONENT_ORDER),
45
+ "link_order": [list(pair) for pair in LINK_ORDER],
46
+ "lora_rank": 16,
47
+ "lora_alpha": 32,
48
+ "lora_dropout": 0.05,
49
+ },
50
+ "detector": {
51
+ "variant": "seg-2xlarge",
52
+ "resolution": None,
53
+ "score_threshold": 0.15,
54
+ "nms_threshold": 0.6,
55
+ "mask_threshold": 0.5,
56
+ "max_regions": 100,
57
+ },
58
+ "training": {
59
+ "epochs": 1,
60
+ "batch_size": 1,
61
+ "gradient_accumulation": 16,
62
+ "learning_rate": 1e-4,
63
+ "weight_decay": 0.0,
64
+ "warmup_ratio": 0.03,
65
+ "max_text_tokens": 256,
66
+ "text_overflow_policy": "error",
67
+ "risk_loss_weight": 1.0,
68
+ "presence_loss_weight": 0.5,
69
+ "link_loss_weight": 0.5,
70
+ "seed": 42,
71
+ "precision": "bf16",
72
+ "tf32": True,
73
+ "sampling": "all",
74
+ "epoch_size": None,
75
+ },
76
+ }
77
+
78
+ # Per-device batches and gradient accumulation are configured separately.
79
+ DEFAULT_STAGES: dict[str, dict[str, Any]] = {
80
+ "stage1": {
81
+ "epochs": 12,
82
+ "batch_size": 1,
83
+ "gradient_accumulation": 16,
84
+ "learning_rate": 1e-4,
85
+ "encoder_learning_rate": 1.5e-4,
86
+ "weight_decay": 1e-4,
87
+ "lr_scheduler": "cosine",
88
+ "warmup_epochs": 0.0,
89
+ "multi_scale": False,
90
+ "expanded_scales": False,
91
+ "precision": "backend_mixed",
92
+ "tf32": True,
93
+ },
94
+ "stage2": deepcopy(DEFAULT_CONFIG["training"]),
95
+ "stage3": {
96
+ **deepcopy(DEFAULT_CONFIG["training"]),
97
+ "learning_rate": 1e-5,
98
+ "precision": "bf16",
99
+ },
100
+ }
101
+ DEFAULT_CONFIG["stages"] = deepcopy(DEFAULT_STAGES)
102
+
103
+
104
+ def _merge(
105
+ base: dict[str, Any], update: dict[str, Any], prefix: str = ""
106
+ ) -> dict[str, Any]:
107
+ if not isinstance(update, dict):
108
+ raise ValueError(f"{prefix or 'Configuration'} must be a mapping")
109
+ for key, value in update.items():
110
+ name = f"{prefix}.{key}" if prefix else key
111
+ if key not in base:
112
+ raise ValueError(f"Unknown configuration key: {name}")
113
+ if isinstance(value, dict) and isinstance(base.get(key), dict):
114
+ _merge(base[key], value, name)
115
+ else:
116
+ base[key] = value
117
+ return base
118
+
119
+
120
+ def _positive_integer(value: Any, name: str) -> None:
121
+ if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
122
+ raise ValueError(f"{name} must be a positive integer")
123
+
124
+
125
+ def _finite_number(value: Any, name: str, *, minimum: float = 0.0) -> float:
126
+ if isinstance(value, bool) or not isinstance(value, int | float):
127
+ raise ValueError(f"{name} must be a number")
128
+ number = float(value)
129
+ if not minimum <= number < float("inf"):
130
+ raise ValueError(f"{name} must be at least {minimum}")
131
+ return number
132
+
133
+
134
+ def validate_config(config: dict[str, Any]) -> None:
135
+ """Reject architecture drift and common experiment-configuration mistakes."""
136
+
137
+ for section in ("model", "detector", "training"):
138
+ if not isinstance(config.get(section), dict):
139
+ raise ValueError(f"{section} must be a mapping")
140
+ model = config["model"]
141
+ detector = config["detector"]
142
+ experiment = config.get("experiment", DEFAULT_CONFIG["experiment"])
143
+ if not isinstance(experiment, dict):
144
+ raise ValueError("experiment must be a mapping")
145
+ if not isinstance(experiment["name"], str) or not experiment["name"].strip():
146
+ raise ValueError("experiment.name must be a non-empty string")
147
+ if experiment["protocol"] not in ("dataset-v1", "paper-v2"):
148
+ raise ValueError("experiment.protocol must be dataset-v1 or paper-v2")
149
+ if experiment["backbone_layout"] != "independent":
150
+ raise ValueError("Only the independent-backbone implementation is available")
151
+ if experiment.get("ssa_ablation", "none") not in SSA_ABLATIONS:
152
+ raise ValueError(f"experiment.ssa_ablation must be one of {SSA_ABLATIONS}")
153
+ if experiment["world_size"] is not None:
154
+ _positive_integer(experiment["world_size"], "experiment.world_size")
155
+
156
+ for key in ("vision_model", "language_model"):
157
+ if not isinstance(model[key], str) or not model[key].strip():
158
+ raise ValueError(
159
+ f"model.{key} must be a non-empty path or model identifier"
160
+ )
161
+ for key in ("image_size", "patch_size", "region_dim", "roi_size", "max_regions"):
162
+ _positive_integer(model[key], f"model.{key}")
163
+ if model["image_size"] % (2 * model["patch_size"]):
164
+ raise ValueError("model.image_size must be divisible by twice model.patch_size")
165
+ if tuple(model["component_order"]) != COMPONENT_ORDER:
166
+ raise ValueError(f"model.component_order must be {list(COMPONENT_ORDER)!r}")
167
+ if tuple(tuple(pair) for pair in model["link_order"]) != LINK_ORDER:
168
+ raise ValueError(
169
+ f"model.link_order must be {[list(pair) for pair in LINK_ORDER]!r}"
170
+ )
171
+ _positive_integer(model["lora_rank"], "model.lora_rank")
172
+ _positive_integer(model["lora_alpha"], "model.lora_alpha")
173
+ dropout = _finite_number(model["lora_dropout"], "model.lora_dropout")
174
+ if dropout >= 1.0:
175
+ raise ValueError("model.lora_dropout must be smaller than 1")
176
+
177
+ if detector["variant"] not in DETECTOR_VARIANTS:
178
+ raise ValueError(
179
+ f"detector.variant must be one of {sorted(DETECTOR_VARIANTS)!r}"
180
+ )
181
+ for key in ("score_threshold", "nms_threshold", "mask_threshold"):
182
+ if key not in detector:
183
+ continue
184
+ value = _finite_number(detector[key], f"detector.{key}")
185
+ if value > 1.0:
186
+ raise ValueError(f"detector.{key} must not exceed 1")
187
+ _positive_integer(detector["max_regions"], "detector.max_regions")
188
+ if detector["max_regions"] > model["max_regions"]:
189
+ raise ValueError("detector.max_regions must not exceed model.max_regions")
190
+
191
+ resolution = detector.get("resolution")
192
+ if resolution is not None:
193
+ _positive_integer(resolution, "detector.resolution")
194
+ # Pinned segmentation variants use patch size 12. Nano uses one local
195
+ # window; the other supported variants use two. No model load is needed.
196
+ divisor = 12 if detector["variant"] == "seg-nano" else 24
197
+ if resolution % divisor:
198
+ raise ValueError(
199
+ f"detector.resolution must be divisible by {divisor} for pinned RF-DETR"
200
+ )
201
+
202
+ _validate_training(config["training"], "training")
203
+ stages = config.get("stages", {})
204
+ if not isinstance(stages, dict):
205
+ raise ValueError("stages must be a mapping")
206
+ for stage, values in stages.items():
207
+ if stage not in DEFAULT_STAGES:
208
+ raise ValueError(f"Unknown training stage: {stage}")
209
+ if not isinstance(values, dict):
210
+ raise ValueError(f"stages.{stage} must be a mapping")
211
+ if stage == "stage1":
212
+ for key in ("epochs", "batch_size", "gradient_accumulation"):
213
+ _positive_integer(values[key], f"stages.stage1.{key}")
214
+ for key in (
215
+ "learning_rate",
216
+ "encoder_learning_rate",
217
+ "weight_decay",
218
+ "warmup_epochs",
219
+ ):
220
+ _finite_number(values[key], f"stages.stage1.{key}")
221
+ if values["learning_rate"] == 0 or values["encoder_learning_rate"] == 0:
222
+ raise ValueError("Stage 1 learning rates must be positive")
223
+ if values["lr_scheduler"] not in ("cosine", "step"):
224
+ raise ValueError("stages.stage1.lr_scheduler must be cosine or step")
225
+ for key in ("multi_scale", "expanded_scales"):
226
+ if not isinstance(values[key], bool):
227
+ raise ValueError(f"stages.stage1.{key} must be boolean")
228
+ if values.get("precision") not in ("backend_mixed", "fp32"):
229
+ raise ValueError(
230
+ "stages.stage1.precision must be backend_mixed or fp32"
231
+ )
232
+ if not isinstance(values.get("tf32", True), bool):
233
+ raise ValueError("stages.stage1.tf32 must be boolean")
234
+ else:
235
+ _validate_training(values, f"stages.{stage}")
236
+
237
+
238
+ def _validate_precision(values: dict[str, Any], prefix: str) -> None:
239
+ if values.get("precision", "bf16") not in ("fp16", "bf16"):
240
+ raise ValueError(f"{prefix}.precision must be fp16 or bf16")
241
+ if not isinstance(values.get("tf32", True), bool):
242
+ raise ValueError(f"{prefix}.tf32 must be boolean")
243
+
244
+
245
+ def _validate_training(training: dict[str, Any], prefix: str) -> None:
246
+ for key in ("epochs", "batch_size", "gradient_accumulation", "max_text_tokens"):
247
+ _positive_integer(training[key], f"{prefix}.{key}")
248
+ if training["max_text_tokens"] < 3:
249
+ raise ValueError("training.max_text_tokens must be at least 3")
250
+ for key in (
251
+ "learning_rate",
252
+ "weight_decay",
253
+ "risk_loss_weight",
254
+ "presence_loss_weight",
255
+ "link_loss_weight",
256
+ ):
257
+ _finite_number(training[key], f"{prefix}.{key}")
258
+ if training["learning_rate"] == 0:
259
+ raise ValueError("training.learning_rate must be greater than 0")
260
+ warmup = _finite_number(training["warmup_ratio"], "training.warmup_ratio")
261
+ if warmup >= 1.0:
262
+ raise ValueError("training.warmup_ratio must be smaller than 1")
263
+ if isinstance(training["seed"], bool) or not isinstance(training["seed"], int):
264
+ raise ValueError("training.seed must be an integer")
265
+ _validate_precision(training, prefix)
266
+ if training.get("text_overflow_policy", "error") not in ("error", "truncate"):
267
+ raise ValueError(f"{prefix}.text_overflow_policy must be error or truncate")
268
+ if training.get("sampling", "all") not in ("all", "family_balanced"):
269
+ raise ValueError(f"{prefix}.sampling must be all or family_balanced")
270
+ if training.get("epoch_size") is not None:
271
+ _positive_integer(training["epoch_size"], f"{prefix}.epoch_size")
272
+
273
+
274
+ def load_config(path: str | Path | None = None) -> dict[str, Any]:
275
+ config = deepcopy(DEFAULT_CONFIG)
276
+ payload: dict[str, Any] = {}
277
+ if path is not None:
278
+ with Path(path).expanduser().open(encoding="utf-8") as handle:
279
+ payload = yaml.safe_load(handle)
280
+ if payload is None:
281
+ payload = {}
282
+ if not isinstance(payload, dict):
283
+ raise ValueError("Configuration root must be a mapping")
284
+ _merge(config, payload)
285
+ # Explicit legacy/common training options still apply to both multimodal
286
+ # stages. Explicit per-stage overrides win. Defaults, however, differ between
287
+ # Stage 2 and Stage 3; a shared default LR must not erase that distinction.
288
+ common = payload.get("training", {})
289
+ stages = payload.get("stages", {})
290
+ if not isinstance(common, dict) or not isinstance(stages, dict):
291
+ raise ValueError("training and stages must be mappings")
292
+ for key in ("stage2", "stage3"):
293
+ merged = _merge(deepcopy(DEFAULT_STAGES[key]), common, "training")
294
+ config["stages"][key] = _merge(merged, stages.get(key, {}), f"stages.{key}")
295
+ validate_config(config)
296
+ return config
297
+
298
+
299
+ def resolve_stage_config(config: dict[str, Any], stage: int) -> dict[str, Any]:
300
+ """Return a detached, fully resolved stage configuration.
301
+
302
+ The fallback preserves callers supplying the original configuration shape.
303
+ Stage-specific configuration takes precedence in newly loaded files.
304
+ """
305
+ if stage not in (1, 2, 3):
306
+ raise ValueError("Training stage must be 1, 2, or 3")
307
+ key = f"stage{stage}"
308
+ if key in config.get("stages", {}):
309
+ return deepcopy(config["stages"][key])
310
+ if stage == 1:
311
+ return deepcopy(DEFAULT_STAGES[key])
312
+ return deepcopy(config["training"])
313
+
314
+
315
+ def apply_stage_overrides(
316
+ config: Mapping[str, Any],
317
+ stage: int,
318
+ overrides: Mapping[str, Any],
319
+ ) -> dict[str, Any]:
320
+ """Apply stage overrides to both runtime and saved checkpoint configuration."""
321
+
322
+ if not isinstance(config, Mapping):
323
+ raise TypeError("config must be a mapping")
324
+ if stage not in (1, 2, 3):
325
+ raise ValueError("Training stage must be 1, 2, or 3")
326
+ if not isinstance(overrides, Mapping):
327
+ raise TypeError("stage overrides must be a mapping")
328
+
329
+ effective = deepcopy(dict(config))
330
+ validate_config(effective)
331
+ resolved = resolve_stage_config(effective, stage)
332
+ unknown = sorted(set(overrides).difference(resolved))
333
+ if unknown:
334
+ prefix = f"stages.stage{stage}"
335
+ raise ValueError(
336
+ "Unknown stage override"
337
+ + ("s" if len(unknown) != 1 else "")
338
+ + ": "
339
+ + ", ".join(f"{prefix}.{name}" for name in unknown)
340
+ )
341
+ resolved.update(deepcopy(dict(overrides)))
342
+ effective.setdefault("stages", {})[f"stage{stage}"] = resolved
343
+ validate_config(effective)
344
+ return effective
345
+
346
+
347
+ def paper_reproduction_issues(config: dict[str, Any]) -> list[str]:
348
+ """Report known eligibility blockers, not a claim of historical reproduction."""
349
+ issues = [
350
+ "Binary presence supervision uses BCE-with-logits instead of the paper's L1 loss."
351
+ ]
352
+ if (
353
+ config.get("experiment", {}).get("backbone_layout", "independent")
354
+ == "independent"
355
+ ):
356
+ issues.append(
357
+ "The shared-feature detector described in the paper is not implemented; "
358
+ "the independent RF-DETR/DINO topology is a documented deviation."
359
+ )
360
+ if config["detector"].get("resolution") != 448:
361
+ issues.append(
362
+ "The detector does not use the paper's stated 448-pixel resolution; "
363
+ "448 is incompatible with the pinned segmentation backend."
364
+ )
365
+ return issues
configuration_falcon.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configuration for the complete Hugging Face FALCON model."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from typing import Any
7
+
8
+ from transformers import PretrainedConfig
9
+
10
+ from .capabilities import (
11
+ SafetyCapabilities,
12
+ apply_ablation,
13
+ capabilities_from_coverage,
14
+ )
15
+ from .config import resolve_stage_config, validate_config
16
+
17
+
18
+ class FalconConfig(PretrainedConfig):
19
+ model_type = "falcon_x"
20
+ is_composition = True
21
+
22
+ def __init__(
23
+ self,
24
+ core_config: dict[str, Any] | None = None,
25
+ vision_config: dict[str, Any] | None = None,
26
+ language_config: dict[str, Any] | None = None,
27
+ detector_config: dict[str, Any] | None = None,
28
+ image_processor_config: dict[str, Any] | None = None,
29
+ lora_config: dict[str, Any] | None = None,
30
+ runtime_config: dict[str, Any] | None = None,
31
+ checkpoint_metadata: dict[str, Any] | None = None,
32
+ language_generation_config: dict[str, Any] | None = None,
33
+ **kwargs: Any,
34
+ ) -> None:
35
+ kwargs.setdefault("tie_word_embeddings", False)
36
+ super().__init__(**kwargs)
37
+ for name, value in (
38
+ ("core_config", core_config),
39
+ ("vision_config", vision_config),
40
+ ("language_config", language_config),
41
+ ("detector_config", detector_config),
42
+ ("image_processor_config", image_processor_config),
43
+ ("lora_config", lora_config),
44
+ ("runtime_config", runtime_config),
45
+ ("checkpoint_metadata", checkpoint_metadata),
46
+ ("language_generation_config", language_generation_config),
47
+ ):
48
+ if value is not None and not isinstance(value, dict):
49
+ raise TypeError(f"{name} must be a mapping")
50
+ setattr(self, name, deepcopy(value or {}))
51
+
52
+ def __getattr__(self, name: str) -> Any:
53
+ # Inference consumes these scalar fields on both native and Hub models.
54
+ core = self.__dict__.get("core_config", {})
55
+ if name in core:
56
+ return core[name]
57
+ raise AttributeError(name)
58
+
59
+ def validate(self) -> None:
60
+ """Reject incomplete, diagnostic, or inconsistent final-model metadata."""
61
+ for name in (
62
+ "core_config", "vision_config", "language_config", "detector_config",
63
+ "image_processor_config", "lora_config", "runtime_config",
64
+ "checkpoint_metadata",
65
+ ):
66
+ if not getattr(self, name):
67
+ raise ValueError(f"FALCON package requires {name}")
68
+ metadata = self.checkpoint_metadata
69
+ validate_config(self.runtime_config)
70
+ if metadata.get("stage") != 3 or metadata.get("training_complete") is not True:
71
+ raise ValueError("FALCON packages require a completed Stage 3 checkpoint")
72
+ if metadata.get("official_result_eligible") is False or metadata.get(
73
+ "diagnostic_training_reasons"
74
+ ):
75
+ raise ValueError("Diagnostic checkpoints cannot be packaged as final FALCON models")
76
+ if metadata.get("partial_initialization_allowed") or metadata.get(
77
+ "legacy_initialization_allowed"
78
+ ) or metadata.get("oracle_detector", "none") != "none":
79
+ raise ValueError("FALCON packages cannot use partial initialization or oracle proposals")
80
+ if metadata.get("ssa_ablation", "none") != "none" or self.runtime_config.get(
81
+ "experiment", {}
82
+ ).get("ssa_ablation", "none") != "none":
83
+ raise ValueError("Final FALCON packages cannot use SSA ablations")
84
+ capabilities = SafetyCapabilities.from_dict(metadata.get("safety_capabilities"))
85
+ core_capabilities = SafetyCapabilities.from_dict(
86
+ self.core_config.get("safety_capabilities")
87
+ )
88
+ if core_capabilities != capabilities:
89
+ raise ValueError("Core and checkpoint safety capabilities differ")
90
+ supervision = metadata.get("supervision_capabilities")
91
+ if supervision is not None and apply_ablation(
92
+ SafetyCapabilities.from_dict(supervision), "none"
93
+ ) != capabilities:
94
+ raise ValueError("Checkpoint capabilities disagree with supervision")
95
+ require_observed = metadata.get("require_observed_supervision", False)
96
+ if not isinstance(require_observed, bool):
97
+ raise ValueError("require_observed_supervision must be boolean")
98
+ observed = metadata.get("observed_supervision_coverage")
99
+ if require_observed and observed is None:
100
+ raise ValueError("Checkpoint lacks required observed supervision coverage")
101
+ if observed is not None and (
102
+ not isinstance(observed, dict)
103
+ or set(observed) != {"images", "risk", "presence", "links"}
104
+ ):
105
+ raise ValueError("Observed coverage requires images, risk, presence, and links")
106
+ if observed is not None and capabilities.restrict(
107
+ capabilities_from_coverage(observed)
108
+ ) != capabilities:
109
+ raise ValueError("Checkpoint enables a head without observed supervision")
110
+ if metadata.get("config") != self.runtime_config:
111
+ raise ValueError("Checkpoint and runtime configurations differ")
112
+ runtime_model = self.runtime_config["model"]
113
+ runtime_training = resolve_stage_config(self.runtime_config, 3)
114
+ for key in ("image_size", "patch_size", "region_dim", "roi_size", "max_regions"):
115
+ if self.core_config.get(key) != runtime_model[key]:
116
+ raise ValueError(f"Core {key} differs from the training configuration")
117
+ for key in (
118
+ "max_text_tokens", "text_overflow_policy", "risk_loss_weight",
119
+ "presence_loss_weight", "link_loss_weight",
120
+ ):
121
+ if self.core_config.get(key) != runtime_training[key]:
122
+ raise ValueError(f"Core {key} differs from the training configuration")
123
+ for key, runtime_key in (("rank", "lora_rank"), ("alpha", "lora_alpha"),
124
+ ("dropout", "lora_dropout")):
125
+ if self.lora_config.get(key) != runtime_model[runtime_key]:
126
+ raise ValueError(f"LoRA {key} differs from the training configuration")
127
+ runtime_detector = self.runtime_config["detector"]
128
+ if self.detector_config.get("variant") != runtime_detector["variant"]:
129
+ raise ValueError("Detector variant differs from the training configuration")
130
+ policy = self.detector_config.get("proposal_policy", {})
131
+ for key in ("score_threshold", "nms_threshold", "max_regions", "mask_threshold"):
132
+ if policy.get(key) != runtime_detector.get(key, 0.5 if key == "mask_threshold" else None):
133
+ raise ValueError(f"Detector {key} differs from the training configuration")
134
+ resolution = runtime_detector.get("resolution")
135
+ if resolution is not None and self.detector_config.get("architecture", {}).get(
136
+ "resolution"
137
+ ) != resolution:
138
+ raise ValueError("Detector resolution differs from the training configuration")
139
+ mask_threshold = self.core_config.get("mask_probability_threshold")
140
+ if isinstance(mask_threshold, bool) or not isinstance(mask_threshold, int | float) or not (
141
+ 0 <= mask_threshold <= 1
142
+ ):
143
+ raise ValueError("Core mask_probability_threshold must lie in [0, 1]")
144
+ for config_name, dimension in (
145
+ ("vision_config", "vision_dim"), ("language_config", "language_dim")
146
+ ):
147
+ backbone = getattr(self, config_name)
148
+ if not isinstance(backbone.get("model_type"), str):
149
+ raise ValueError(f"{config_name} requires its architecture model_type")
150
+ if backbone.get("hidden_size") != self.core_config.get(dimension):
151
+ raise ValueError(f"{config_name} hidden_size differs from {dimension}")
152
+
153
+
154
+ FalconConfig.register_for_auto_class("AutoConfig")
detector.py ADDED
@@ -0,0 +1,1048 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import fcntl
5
+ import json
6
+ import os
7
+ import random
8
+ import stat
9
+ import tempfile
10
+ from collections.abc import Iterator, Mapping
11
+ from contextlib import contextmanager
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from types import SimpleNamespace
15
+ from typing import Any
16
+
17
+ import numpy as np
18
+ import torch
19
+ from PIL import Image
20
+ from torch.nn import functional as F
21
+
22
+ from .artifacts import atomic_json, external_output
23
+
24
+ VARIANTS = ("seg-nano", "seg-small", "seg-medium", "seg-large", "seg-xlarge", "seg-2xlarge")
25
+ PROPOSAL_POLICY_VERSION = "falcon-proposals/v1"
26
+ # Pinned RF-DETR applies a strict ``scores > threshold`` filter. Passing the
27
+ # user cutoff there would drop equality-boundary proposals before Falcon's
28
+ # documented inclusive policy can inspect them.
29
+ BACKEND_SCORE_THRESHOLD = -float("inf")
30
+ STAGE1_LOCK_NAME = ".stage1.lock"
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class RFDETRVariantSpec:
35
+ """Architecture defaults from the pinned ``rfdetr==1.5.2`` package."""
36
+
37
+ resolution: int
38
+ patch_size: int
39
+ num_windows: int
40
+ decoder_layers: int
41
+ num_queries: int
42
+ num_select: int
43
+
44
+ @property
45
+ def input_divisor(self) -> int:
46
+ return self.patch_size * self.num_windows
47
+
48
+
49
+ VARIANT_SPECS: dict[str, RFDETRVariantSpec] = {
50
+ "seg-nano": RFDETRVariantSpec(312, 12, 1, 4, 100, 100),
51
+ "seg-small": RFDETRVariantSpec(384, 12, 2, 4, 100, 100),
52
+ "seg-medium": RFDETRVariantSpec(432, 12, 2, 5, 200, 200),
53
+ "seg-large": RFDETRVariantSpec(504, 12, 2, 5, 200, 200),
54
+ "seg-xlarge": RFDETRVariantSpec(624, 12, 2, 6, 300, 300),
55
+ "seg-2xlarge": RFDETRVariantSpec(768, 12, 2, 6, 300, 300),
56
+ }
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class RFDETRModelConfig:
61
+ """Falcon-owned RF-DETR model options validated before model construction."""
62
+
63
+ resolution: int | None = None
64
+ amp: bool | None = None
65
+
66
+ def __post_init__(self) -> None:
67
+ if self.amp is not None and not isinstance(self.amp, bool):
68
+ raise TypeError("RF-DETR amp must be boolean when specified")
69
+
70
+ def resolved_resolution(self, variant: str) -> int:
71
+ if variant not in VARIANT_SPECS:
72
+ raise ValueError(f"Unknown RF-DETR variant: {variant}")
73
+ resolution = (
74
+ VARIANT_SPECS[variant].resolution
75
+ if self.resolution is None
76
+ else self.resolution
77
+ )
78
+ if isinstance(resolution, bool) or not isinstance(resolution, int) or resolution < 1:
79
+ raise ValueError("RF-DETR resolution must be a positive integer")
80
+ divisor = VARIANT_SPECS[variant].input_divisor
81
+ if resolution % divisor:
82
+ raise ValueError(
83
+ f"RF-DETR {variant} resolution must be divisible by {divisor}; "
84
+ f"received {resolution}"
85
+ )
86
+ return resolution
87
+
88
+ def constructor_kwargs(self, variant: str) -> dict[str, Any]:
89
+ self.resolved_resolution(variant)
90
+ values: dict[str, Any] = {}
91
+ if self.resolution is not None:
92
+ values["resolution"] = self.resolution
93
+ if self.amp is not None:
94
+ values["amp"] = self.amp
95
+ return values
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class RFDETRBackendConfig:
100
+ """Effective architecture values read from a constructed pinned backend."""
101
+
102
+ variant: str
103
+ resolution: int
104
+ patch_size: int
105
+ num_windows: int
106
+ decoder_layers: int
107
+ num_queries: int
108
+ num_select: int
109
+ amp: bool
110
+
111
+
112
+ @dataclass(frozen=True)
113
+ class RFDETRTrainConfig:
114
+ """Typed Stage-1 settings passed explicitly through RF-DETR's public API."""
115
+
116
+ epochs: int = 12
117
+ batch_size: int = 1
118
+ gradient_accumulation: int = 16
119
+ workers: int = 4
120
+ seed: int = 42
121
+ learning_rate: float = 1e-4
122
+ encoder_learning_rate: float = 1.5e-4
123
+ weight_decay: float = 1e-4
124
+ lr_scheduler: str = "step"
125
+ warmup_epochs: float = 0.0
126
+ multi_scale: bool = True
127
+ expanded_scales: bool = True
128
+
129
+ def __post_init__(self) -> None:
130
+ for name in ("epochs", "batch_size", "gradient_accumulation", "workers"):
131
+ value = getattr(self, name)
132
+ if isinstance(value, bool) or not isinstance(value, int) or value < 1:
133
+ raise ValueError(f"{name} must be a positive integer")
134
+ if (
135
+ isinstance(self.seed, bool)
136
+ or not isinstance(self.seed, int)
137
+ or not 0 <= self.seed < 2**32
138
+ ):
139
+ raise ValueError("seed must be an unsigned 32-bit integer")
140
+ for name in ("learning_rate", "encoder_learning_rate"):
141
+ value = getattr(self, name)
142
+ if isinstance(value, bool) or not isinstance(value, int | float):
143
+ raise TypeError(f"{name} must be numeric")
144
+ if not 0.0 < float(value) < float("inf"):
145
+ raise ValueError(f"{name} must be finite and positive")
146
+ for name in ("weight_decay", "warmup_epochs"):
147
+ value = getattr(self, name)
148
+ if isinstance(value, bool) or not isinstance(value, int | float):
149
+ raise TypeError(f"{name} must be numeric")
150
+ if not 0.0 <= float(value) < float("inf"):
151
+ raise ValueError(f"{name} must be finite and nonnegative")
152
+ if self.lr_scheduler not in ("cosine", "step"):
153
+ raise ValueError("lr_scheduler must be 'cosine' or 'step'")
154
+ if not isinstance(self.multi_scale, bool) or not isinstance(self.expanded_scales, bool):
155
+ raise TypeError("multi_scale and expanded_scales must be boolean")
156
+
157
+ def backend_kwargs(self) -> dict[str, Any]:
158
+ return {
159
+ "epochs": self.epochs,
160
+ "batch_size": self.batch_size,
161
+ "grad_accum_steps": self.gradient_accumulation,
162
+ "num_workers": self.workers,
163
+ "seed": self.seed,
164
+ "lr": self.learning_rate,
165
+ "lr_encoder": self.encoder_learning_rate,
166
+ "weight_decay": self.weight_decay,
167
+ "lr_scheduler": self.lr_scheduler,
168
+ "warmup_epochs": self.warmup_epochs,
169
+ "multi_scale": self.multi_scale,
170
+ "expanded_scales": self.expanded_scales,
171
+ }
172
+
173
+
174
+ @dataclass(frozen=True)
175
+ class ProposalPolicy:
176
+ """One deterministic proposal-selection contract for train and inference."""
177
+
178
+ score_threshold: float = 0.15
179
+ nms_threshold: float = 0.6
180
+ max_regions: int = 100
181
+ mask_threshold: float = 0.5
182
+ reject_empty_masks: bool = True
183
+ version: str = PROPOSAL_POLICY_VERSION
184
+
185
+ def __post_init__(self) -> None:
186
+ for name in ("score_threshold", "nms_threshold", "mask_threshold"):
187
+ value = getattr(self, name)
188
+ if isinstance(value, bool) or not isinstance(value, int | float):
189
+ raise TypeError(f"{name} must be numeric")
190
+ if not 0.0 <= float(value) <= 1.0:
191
+ raise ValueError(f"{name} must lie in [0, 1]")
192
+ if isinstance(self.max_regions, bool) or not isinstance(self.max_regions, int):
193
+ raise TypeError("max_regions must be an integer")
194
+ if self.max_regions < 1:
195
+ raise ValueError("max_regions must be positive")
196
+ if not isinstance(self.reject_empty_masks, bool):
197
+ raise TypeError("reject_empty_masks must be boolean")
198
+ if not isinstance(self.version, str) or not self.version:
199
+ raise ValueError("proposal policy version must be a non-empty string")
200
+
201
+ def to_dict(self) -> dict[str, Any]:
202
+ """Return serializable proposal settings."""
203
+
204
+ return {
205
+ "version": self.version,
206
+ "backend_score_threshold": "-inf",
207
+ "score_threshold": float(self.score_threshold),
208
+ "nms_threshold": float(self.nms_threshold),
209
+ "max_regions": self.max_regions,
210
+ "mask_threshold": float(self.mask_threshold),
211
+ "mask_threshold_operator": ">=",
212
+ "reject_empty_masks": self.reject_empty_masks,
213
+ "ordering": "score_descending_then_backend_index",
214
+ "nms_tie_break": "lower_backend_index",
215
+ }
216
+
217
+ @staticmethod
218
+ def _stable_nms(boxes: torch.Tensor, threshold: float) -> torch.Tensor:
219
+ """NMS for pre-sorted boxes with an explicit lower-index tie break."""
220
+
221
+ remaining = torch.arange(len(boxes), dtype=torch.long)
222
+ kept = []
223
+ while remaining.numel():
224
+ current = remaining[0]
225
+ kept.append(current)
226
+ if remaining.numel() == 1:
227
+ break
228
+ rest = remaining[1:]
229
+ left_top = torch.maximum(boxes[current, :2], boxes[rest, :2])
230
+ right_bottom = torch.minimum(boxes[current, 2:], boxes[rest, 2:])
231
+ intersection = (right_bottom - left_top).clamp_min(0).prod(dim=1)
232
+ current_area = (boxes[current, 2:] - boxes[current, :2]).prod()
233
+ rest_area = (boxes[rest, 2:] - boxes[rest, :2]).prod(dim=1)
234
+ overlap = intersection / (current_area + rest_area - intersection).clamp_min(1e-12)
235
+ remaining = rest[overlap <= threshold]
236
+ return torch.stack(kept) if kept else torch.empty(0, dtype=torch.long)
237
+
238
+ def select(
239
+ self,
240
+ boxes: Any,
241
+ scores: Any,
242
+ masks: Any,
243
+ original_hw: tuple[int, int],
244
+ *,
245
+ mask_finite: Any | None = None,
246
+ ) -> DetectorOutput:
247
+ """Validate and select proposals without consulting ground truth."""
248
+
249
+ if (
250
+ not isinstance(original_hw, tuple)
251
+ or len(original_hw) != 2
252
+ or any(
253
+ isinstance(value, bool) or not isinstance(value, int) or value < 1
254
+ for value in original_hw
255
+ )
256
+ ):
257
+ raise ValueError("original_hw must contain positive integer height and width")
258
+ box_tensor = torch.as_tensor(boxes).detach().to("cpu", torch.float32)
259
+ score_tensor = torch.as_tensor(scores).detach().to("cpu", torch.float32)
260
+ mask_tensor = torch.as_tensor(masks).detach().to("cpu")
261
+ if box_tensor.ndim != 2 or box_tensor.shape[-1] != 4:
262
+ raise ValueError("boxes must have shape [proposals, 4]")
263
+ if score_tensor.ndim != 1 or score_tensor.shape[0] != box_tensor.shape[0]:
264
+ raise ValueError("scores must have shape [proposals]")
265
+ if mask_tensor.ndim != 3 or mask_tensor.shape[0] != box_tensor.shape[0]:
266
+ raise ValueError("masks must have shape [proposals, height, width]")
267
+ if tuple(mask_tensor.shape[-2:]) != original_hw:
268
+ raise ValueError("proposal masks must match original_hw")
269
+ if mask_tensor.dtype != torch.bool:
270
+ raise TypeError("ProposalPolicy.select requires already-binarized masks")
271
+ if mask_finite is None:
272
+ finite_masks = torch.ones(len(box_tensor), dtype=torch.bool)
273
+ else:
274
+ finite_masks = torch.as_tensor(mask_finite).detach().to("cpu", torch.bool)
275
+ if finite_masks.shape != (len(box_tensor),):
276
+ raise ValueError("mask_finite must have shape [proposals]")
277
+
278
+ height, width = original_hw
279
+ box_tensor = box_tensor.clone()
280
+ box_tensor[:, 0::2].clamp_(0, width)
281
+ box_tensor[:, 1::2].clamp_(0, height)
282
+ valid = torch.isfinite(box_tensor).all(dim=1) & torch.isfinite(score_tensor)
283
+ valid &= finite_masks & (score_tensor >= self.score_threshold)
284
+ valid &= (box_tensor[:, 2] > box_tensor[:, 0]) & (box_tensor[:, 3] > box_tensor[:, 1])
285
+ if self.reject_empty_masks:
286
+ valid &= mask_tensor.flatten(1).any(dim=1)
287
+
288
+ raw_indices = torch.arange(len(box_tensor), dtype=torch.long)
289
+ box_tensor = box_tensor[valid]
290
+ score_tensor = score_tensor[valid]
291
+ mask_tensor = mask_tensor[valid]
292
+ raw_indices = raw_indices[valid]
293
+ if not len(box_tensor):
294
+ return DetectorOutput(
295
+ box_tensor,
296
+ score_tensor,
297
+ mask_tensor,
298
+ original_hw,
299
+ raw_indices,
300
+ )
301
+
302
+ order = torch.argsort(score_tensor, descending=True, stable=True)
303
+ box_tensor = box_tensor[order]
304
+ score_tensor = score_tensor[order]
305
+ mask_tensor = mask_tensor[order]
306
+ raw_indices = raw_indices[order]
307
+ keep = self._stable_nms(box_tensor, float(self.nms_threshold))[: self.max_regions]
308
+ return DetectorOutput(
309
+ box_tensor[keep],
310
+ score_tensor[keep],
311
+ mask_tensor[keep],
312
+ original_hw,
313
+ raw_indices[keep],
314
+ )
315
+
316
+
317
+ @dataclass
318
+ class DetectorOutput:
319
+ """Post-threshold, post-NMS RF-DETR output in original-image coordinates."""
320
+
321
+ boxes_xyxy: torch.Tensor
322
+ scores: torch.Tensor
323
+ masks: torch.Tensor
324
+ original_hw: tuple[int, int]
325
+ proposal_ids: torch.Tensor | None = None
326
+
327
+ def to(self, device: torch.device | str) -> DetectorOutput:
328
+ return DetectorOutput(
329
+ self.boxes_xyxy.to(device),
330
+ self.scores.to(device),
331
+ self.masks.to(device),
332
+ self.original_hw,
333
+ None if self.proposal_ids is None else self.proposal_ids.to(device),
334
+ )
335
+
336
+
337
+ def _models() -> dict[str, type]:
338
+ # Albumentations otherwise performs a PyPI version check during RF-DETR import.
339
+ os.environ.setdefault("NO_ALBUMENTATIONS_UPDATE", "1")
340
+ try:
341
+ from rfdetr import (
342
+ RFDETRSeg2XLarge,
343
+ RFDETRSegLarge,
344
+ RFDETRSegMedium,
345
+ RFDETRSegNano,
346
+ RFDETRSegSmall,
347
+ RFDETRSegXLarge,
348
+ )
349
+ except ImportError as error:
350
+ raise ImportError(
351
+ "Install the pinned environment with `conda env create -f environment.yml`"
352
+ ) from error
353
+ return {
354
+ "seg-nano": RFDETRSegNano,
355
+ "seg-small": RFDETRSegSmall,
356
+ "seg-medium": RFDETRSegMedium,
357
+ "seg-large": RFDETRSegLarge,
358
+ "seg-xlarge": RFDETRSegXLarge,
359
+ "seg-2xlarge": RFDETRSeg2XLarge,
360
+ }
361
+
362
+
363
+ class RFDETRDetector:
364
+ """Frozen RF-DETR with a portable, weights-free construction path."""
365
+
366
+ def __init__(
367
+ self,
368
+ checkpoint: str | Path | None = None,
369
+ variant: str = "seg-2xlarge",
370
+ device: str = "cuda",
371
+ score_threshold: float | None = None,
372
+ nms_threshold: float | None = None,
373
+ max_regions: int | None = None,
374
+ mask_threshold: float | None = None,
375
+ resolution: int | None = None,
376
+ proposal_policy: ProposalPolicy | None = None,
377
+ model_config: RFDETRModelConfig | None = None,
378
+ model: Any | None = None,
379
+ ):
380
+ if variant not in VARIANTS:
381
+ raise ValueError(f"Unknown RF-DETR variant: {variant}")
382
+ if model_config is not None and resolution is not None:
383
+ if model_config.resolution != resolution:
384
+ raise ValueError("resolution conflicts with model_config.resolution")
385
+ resolved_model_config = model_config or RFDETRModelConfig(resolution=resolution)
386
+ # This validation is intentionally before _models() so an incompatible
387
+ # request cannot trigger RF-DETR construction or weight download.
388
+ resolved_model_config.resolved_resolution(variant)
389
+
390
+ legacy_values = {
391
+ "score_threshold": score_threshold,
392
+ "nms_threshold": nms_threshold,
393
+ "max_regions": max_regions,
394
+ "mask_threshold": mask_threshold,
395
+ }
396
+ if proposal_policy is None:
397
+ proposal_policy = ProposalPolicy(
398
+ score_threshold=0.15 if score_threshold is None else score_threshold,
399
+ nms_threshold=0.6 if nms_threshold is None else nms_threshold,
400
+ max_regions=100 if max_regions is None else max_regions,
401
+ mask_threshold=0.5 if mask_threshold is None else mask_threshold,
402
+ )
403
+ else:
404
+ if not isinstance(proposal_policy, ProposalPolicy):
405
+ raise TypeError("proposal_policy must be a ProposalPolicy")
406
+ for name, value in legacy_values.items():
407
+ if value is not None and value != getattr(proposal_policy, name):
408
+ raise ValueError(f"{name} conflicts with proposal_policy.{name}")
409
+ if model is None:
410
+ kwargs: dict[str, Any] = {"device": device}
411
+ if checkpoint is not None:
412
+ kwargs["pretrain_weights"] = str(Path(checkpoint).expanduser().resolve())
413
+ kwargs.update(resolved_model_config.constructor_kwargs(variant))
414
+ model = _models()[variant](**kwargs)
415
+ self.model = model
416
+ self.variant = variant
417
+ self.model_config = resolved_model_config
418
+ self.proposal_policy = proposal_policy
419
+ # Keep the original scalar attributes for downstream compatibility.
420
+ self.score_threshold = proposal_policy.score_threshold
421
+ self.nms_threshold = proposal_policy.nms_threshold
422
+ self.max_regions = proposal_policy.max_regions
423
+ self.mask_threshold = proposal_policy.mask_threshold
424
+ self.backend_config = self._backend_config(model, variant, resolved_model_config)
425
+
426
+ @property
427
+ def torch_model(self) -> torch.nn.Module:
428
+ """The module to register in a complete FALCON state dictionary."""
429
+
430
+ return self.model.model.model
431
+
432
+ def export_config(self) -> dict[str, Any]:
433
+ """Export architecture and proposal settings without machine-local paths."""
434
+
435
+ architecture = self.model.model_config.model_dump()
436
+ architecture.pop("pretrain_weights", None)
437
+ architecture.pop("device", None)
438
+ # RF-DETR may replace its classification head while loading a checkpoint
439
+ # without updating model_config.num_classes.
440
+ architecture["num_classes"] = int(self.torch_model.class_embed.bias.shape[0]) - 1
441
+ return {
442
+ "variant": self.variant,
443
+ "architecture": architecture,
444
+ "proposal_policy": asdict(self.proposal_policy),
445
+ }
446
+
447
+ @classmethod
448
+ def from_config(
449
+ cls,
450
+ detector_config: Mapping[str, Any],
451
+ device: str | torch.device = "cpu",
452
+ ) -> RFDETRDetector:
453
+ """Construct the pinned segmentation architecture without loading weights.
454
+
455
+ Unlike RF-DETR's training constructor, this also supports Transformers'
456
+ meta-device initialization for low-memory checkpoint loading.
457
+ """
458
+
459
+ if not isinstance(detector_config, Mapping) or set(detector_config) != {
460
+ "variant", "architecture", "proposal_policy"
461
+ }:
462
+ raise ValueError("Invalid portable RF-DETR configuration")
463
+ variant = detector_config["variant"]
464
+ if variant not in VARIANTS:
465
+ raise ValueError(f"Unknown RF-DETR variant: {variant}")
466
+ architecture = dict(detector_config["architecture"])
467
+ if "pretrain_weights" in architecture or "device" in architecture:
468
+ raise ValueError("Portable detector architecture cannot name weights or devices")
469
+ # In pinned RF-DETR, patch_size != 14 disables the otherwise implicit
470
+ # DINOv2 Hub initialization. All supported segmentation variants use 12.
471
+ if architecture.get("patch_size") != 12 or architecture.get("encoder") not in {
472
+ "dinov2_windowed_small", "dinov2_windowed_base"
473
+ } or architecture.get("segmentation_head") is not True:
474
+ raise ValueError("Unsupported portable RF-DETR segmentation architecture")
475
+ policy = ProposalPolicy(**detector_config["proposal_policy"])
476
+ model_options = RFDETRModelConfig(
477
+ resolution=architecture.get("resolution"), amp=architecture.get("amp")
478
+ )
479
+ model_options.resolved_resolution(variant)
480
+ model_class = _models()[variant]
481
+ from rfdetr.main import populate_args
482
+ from rfdetr.models.lwdetr import PostProcess, build_model
483
+
484
+ class ConfigOnlyModel(model_class):
485
+ def maybe_download_pretrain_weights(self):
486
+ # Both the detector and its DINO encoder come from FALCON's
487
+ # state dictionary; no external initialization is needed.
488
+ pass
489
+
490
+ def get_model(self, config):
491
+ args = populate_args(**config.model_dump())
492
+ # Accelerate intercepts parameter registration but RF-DETR also
493
+ # creates plain tensors for its initial classification bias.
494
+ # Keep those on the same initialization device as parameters.
495
+ initialization_device = torch.nn.Embedding(1, 1).weight.device
496
+ with torch.device(initialization_device):
497
+ module = build_model(args)
498
+ return SimpleNamespace(
499
+ args=args,
500
+ resolution=args.resolution,
501
+ model=module,
502
+ device=torch.device(device),
503
+ postprocess=PostProcess(num_select=args.num_select),
504
+ class_names=[],
505
+ )
506
+
507
+ backend = ConfigOnlyModel(**architecture, pretrain_weights=None, device="cpu")
508
+ detector = cls(
509
+ variant=variant,
510
+ model=backend,
511
+ model_config=model_options,
512
+ proposal_policy=policy,
513
+ )
514
+ detector.torch_model.requires_grad_(False)
515
+ if not next(detector.torch_model.parameters()).is_meta:
516
+ detector.to(device)
517
+ return detector.eval()
518
+
519
+ def to(self, device: str | torch.device) -> RFDETRDetector:
520
+ self.torch_model.to(device)
521
+ self.model.model.device = torch.device(device)
522
+ return self
523
+
524
+ def eval(self) -> RFDETRDetector:
525
+ self.torch_model.eval()
526
+ return self
527
+
528
+ @staticmethod
529
+ def _backend_config(
530
+ model: Any,
531
+ variant: str,
532
+ requested: RFDETRModelConfig,
533
+ ) -> RFDETRBackendConfig:
534
+ fallback = VARIANT_SPECS[variant]
535
+ value = getattr(model, "model_config", None)
536
+
537
+ def read(name: str, default: int) -> int:
538
+ item = getattr(value, name, default)
539
+ if isinstance(item, bool) or not isinstance(item, int) or item < 1:
540
+ raise RuntimeError(f"RF-DETR backend reported invalid {name}: {item!r}")
541
+ return item
542
+
543
+ amp = getattr(value, "amp", True if requested.amp is None else requested.amp)
544
+ if not isinstance(amp, bool):
545
+ raise RuntimeError(f"RF-DETR backend reported invalid amp: {amp!r}")
546
+
547
+ effective = RFDETRBackendConfig(
548
+ variant=variant,
549
+ resolution=read("resolution", requested.resolved_resolution(variant)),
550
+ patch_size=read("patch_size", fallback.patch_size),
551
+ num_windows=read("num_windows", fallback.num_windows),
552
+ decoder_layers=read("dec_layers", fallback.decoder_layers),
553
+ num_queries=read("num_queries", fallback.num_queries),
554
+ num_select=read("num_select", fallback.num_select),
555
+ amp=amp,
556
+ )
557
+ if effective.resolution % (effective.patch_size * effective.num_windows):
558
+ raise RuntimeError("constructed RF-DETR backend has an incompatible input resolution")
559
+ return effective
560
+
561
+ @staticmethod
562
+ def _image_hw(image: Any) -> tuple[int, int]:
563
+ if isinstance(image, str | Path):
564
+ with Image.open(image) as opened:
565
+ return int(opened.height), int(opened.width)
566
+ if isinstance(image, Image.Image):
567
+ return int(image.height), int(image.width)
568
+ shape = tuple(image.shape) if hasattr(image, "shape") else ()
569
+ if len(shape) == 2:
570
+ return int(shape[0]), int(shape[1])
571
+ if len(shape) != 3:
572
+ raise ValueError("RF-DETR input must be one PIL, NumPy, or tensor image")
573
+ if shape[0] in (1, 3, 4):
574
+ return int(shape[-2]), int(shape[-1])
575
+ return int(shape[0]), int(shape[1])
576
+
577
+ def _masks(
578
+ self,
579
+ value: Any,
580
+ *,
581
+ count: int,
582
+ original_hw: tuple[int, int],
583
+ ) -> tuple[torch.Tensor, torch.Tensor]:
584
+ if value is None:
585
+ if count:
586
+ raise RuntimeError("RF-DETR segmentation output did not contain masks")
587
+ return (
588
+ torch.empty((0, *original_hw), dtype=torch.bool),
589
+ torch.empty(0, dtype=torch.bool),
590
+ )
591
+ array = (
592
+ value.detach().cpu().numpy()
593
+ if isinstance(value, torch.Tensor)
594
+ else np.asarray(value)
595
+ )
596
+ if count == 0 and array.size == 0:
597
+ return (
598
+ torch.empty((0, *original_hw), dtype=torch.bool),
599
+ torch.empty(0, dtype=torch.bool),
600
+ )
601
+ if count == 1 and array.ndim == 2:
602
+ array = array[None]
603
+ if array.ndim != 3 or array.shape[0] != count:
604
+ raise RuntimeError("Invalid RF-DETR mask shape")
605
+ masks = torch.as_tensor(array)
606
+ if masks.dtype == torch.bool:
607
+ finite = torch.ones(count, dtype=torch.bool)
608
+ else:
609
+ masks = masks.to(dtype=torch.float32)
610
+ finite = torch.isfinite(masks).flatten(1).all(dim=1)
611
+ # ProposalPolicy v1 deliberately preserves the existing >= mask
612
+ # threshold semantics. Non-finite proposals are rejected later.
613
+ masks = masks >= self.mask_threshold
614
+ if tuple(masks.shape[-2:]) != original_hw:
615
+ masks = F.interpolate(
616
+ masks.to(dtype=torch.float32).unsqueeze(1),
617
+ size=original_hw,
618
+ mode="nearest",
619
+ ).squeeze(1) > 0.5
620
+ return masks, finite
621
+
622
+ @torch.inference_mode()
623
+ def __call__(self, image: Any) -> DetectorOutput:
624
+ # An enclosing PreTrainedModel may have moved the registered module.
625
+ # RF-DETR also caches the input device outside that module.
626
+ backend = getattr(self.model, "model", None)
627
+ module = getattr(backend, "model", None)
628
+ if isinstance(module, torch.nn.Module):
629
+ backend.device = next(module.parameters()).device
630
+ module.eval()
631
+ detection = self.model.predict(image, threshold=BACKEND_SCORE_THRESHOLD)
632
+ if isinstance(detection, list | tuple):
633
+ if len(detection) != 1:
634
+ raise RuntimeError("RFDETRDetector accepts exactly one image per call")
635
+ detection = detection[0]
636
+ original_hw = self._image_hw(image)
637
+ boxes = torch.as_tensor(detection.xyxy).detach().to("cpu", torch.float32).reshape(-1, 4)
638
+ confidence = detection.confidence
639
+ if confidence is None:
640
+ if len(boxes):
641
+ raise RuntimeError("RF-DETR output did not contain confidence scores")
642
+ scores = torch.empty(0, dtype=torch.float32)
643
+ else:
644
+ scores = torch.as_tensor(confidence).detach().to("cpu", torch.float32).reshape(-1)
645
+ masks, mask_finite = self._masks(
646
+ detection.mask,
647
+ count=len(boxes),
648
+ original_hw=original_hw,
649
+ )
650
+ if len(boxes) != len(scores):
651
+ raise RuntimeError("RF-DETR box and score counts differ")
652
+ return self.proposal_policy.select(
653
+ boxes,
654
+ scores,
655
+ masks,
656
+ original_hw,
657
+ mask_finite=mask_finite,
658
+ )
659
+
660
+
661
+ def _protect_stage1_output(output_root: Path, *, resume: bool) -> None:
662
+ if resume:
663
+ if not output_root.is_dir():
664
+ raise ValueError("Stage-1 resume requires its existing output directory")
665
+ extra_weights = [
666
+ path.name
667
+ for path in output_root.iterdir()
668
+ if path.suffix in {".pt", ".pth", ".safetensors"} and path.name != "last.pth"
669
+ ]
670
+ if extra_weights:
671
+ raise ValueError(
672
+ "Stage-1 resume expects only last.pth; move other weights out of the "
673
+ f"output directory first: {', '.join(sorted(extra_weights))}"
674
+ )
675
+ return
676
+ if output_root.exists():
677
+ if not output_root.is_dir():
678
+ raise ValueError("Stage-1 output path exists and is not a directory")
679
+ # Keep the lock inode stable across processes.
680
+ unexpected = next(
681
+ (entry for entry in output_root.iterdir() if entry.name != STAGE1_LOCK_NAME),
682
+ None,
683
+ )
684
+ if unexpected is not None:
685
+ raise ValueError(
686
+ "Stage-1 output directory is not empty; choose a new directory to avoid "
687
+ "overwriting prior run artifacts"
688
+ )
689
+
690
+
691
+ @contextmanager
692
+ def _stage1_output_lock(output_root: Path) -> Iterator[None]:
693
+ """Hold the cooperative Stage-1 output lock for the complete run lifetime."""
694
+
695
+ output_root.mkdir(parents=True, exist_ok=True)
696
+ lock_path = output_root / STAGE1_LOCK_NAME
697
+ flags = os.O_RDWR | os.O_CREAT
698
+ flags |= getattr(os, "O_CLOEXEC", 0)
699
+ flags |= getattr(os, "O_NOFOLLOW", 0)
700
+ try:
701
+ descriptor = os.open(lock_path, flags, 0o600)
702
+ except OSError as exc:
703
+ raise ValueError(f"cannot safely open Stage-1 output lock: {exc}") from exc
704
+ locked = False
705
+ try:
706
+ opened = os.fstat(descriptor)
707
+ linked = lock_path.lstat()
708
+ if (
709
+ not stat.S_ISREG(opened.st_mode)
710
+ or opened.st_dev != linked.st_dev
711
+ or opened.st_ino != linked.st_ino
712
+ or opened.st_nlink != 1
713
+ ):
714
+ raise ValueError("Stage-1 output lock is not a private regular file")
715
+ try:
716
+ fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
717
+ except BlockingIOError as exc:
718
+ raise ValueError(
719
+ "Stage-1 output is locked by another training process"
720
+ ) from exc
721
+ locked = True
722
+ yield
723
+ finally:
724
+ if locked:
725
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
726
+ os.close(descriptor)
727
+
728
+
729
+ def _expected_backend_config(
730
+ variant: str,
731
+ requested: RFDETRModelConfig,
732
+ ) -> RFDETRBackendConfig:
733
+ spec = VARIANT_SPECS[variant]
734
+ return RFDETRBackendConfig(
735
+ variant=variant,
736
+ resolution=requested.resolved_resolution(variant),
737
+ patch_size=spec.patch_size,
738
+ num_windows=spec.num_windows,
739
+ decoder_layers=spec.decoder_layers,
740
+ num_queries=spec.num_queries,
741
+ num_select=spec.num_select,
742
+ amp=True if requested.amp is None else requested.amp,
743
+ )
744
+
745
+
746
+ def _read_stage1_manifest(path: Path) -> dict[str, Any]:
747
+ try:
748
+ payload = json.loads(path.read_text(encoding="utf-8"))
749
+ except FileNotFoundError as exc:
750
+ raise ValueError("Stage-1 resume requires stage1_run.json in its output directory") from exc
751
+ except (OSError, json.JSONDecodeError) as exc:
752
+ raise ValueError(f"cannot read Stage-1 run manifest: {exc}") from exc
753
+ if not isinstance(payload, dict) or payload.get("stage") != 1:
754
+ raise ValueError("Stage-1 resume requires Stage-1 run metadata")
755
+ if not isinstance(payload.get("config"), dict):
756
+ raise ValueError("Stage-1 run metadata is missing its configuration")
757
+ return payload
758
+
759
+
760
+ def _validate_resume_checkpoint(
761
+ output_root: Path,
762
+ checkpoint: str | Path,
763
+ ) -> Path:
764
+ path = Path(checkpoint).expanduser().resolve(strict=True)
765
+ if not path.is_file() or path != output_root / "last.pth":
766
+ raise ValueError("Stage-1 --resume must point to last.pth inside its output directory")
767
+ return path
768
+
769
+
770
+ @contextmanager
771
+ def _rolling_checkpoint(
772
+ output_root: Path,
773
+ manifest_path: Path,
774
+ manifest: dict[str, Any],
775
+ ) -> Iterator[None]:
776
+ """Redirect RF-DETR 1.5.2 epoch saves to one atomic, resumable checkpoint."""
777
+
778
+ import rfdetr.main as backend
779
+
780
+ original_save = backend.save_on_master
781
+
782
+ def save_checkpoint(weights: Any, filename: Any, *args: Any, **kwargs: Any) -> None:
783
+ path = Path(filename)
784
+ if path.parent != output_root or not path.name.startswith("checkpoint"):
785
+ original_save(weights, filename, *args, **kwargs)
786
+ return
787
+ if path.name != "checkpoint.pth" or not backend.is_main_process():
788
+ return
789
+ temporary = None
790
+ try:
791
+ with tempfile.NamedTemporaryFile(dir=output_root, suffix=".tmp", delete=False) as stream:
792
+ temporary = Path(stream.name)
793
+ torch.save(weights, stream, *args, **kwargs)
794
+ stream.flush()
795
+ os.fsync(stream.fileno())
796
+ os.replace(temporary, output_root / "last.pth")
797
+ finally:
798
+ if temporary is not None:
799
+ temporary.unlink(missing_ok=True)
800
+ manifest["last_completed_epoch"] = weights["epoch"]
801
+ atomic_json(manifest_path, manifest)
802
+
803
+ # Epoch callbacks run after duplicate saves, so intercept the backend writer.
804
+ backend.save_on_master = save_checkpoint
805
+ try:
806
+ yield
807
+ finally:
808
+ backend.save_on_master = original_save
809
+
810
+
811
+ def _train_detector(
812
+ args: argparse.Namespace,
813
+ *,
814
+ data_root: Path,
815
+ output_root: Path,
816
+ ) -> None:
817
+ config_path = getattr(args, "config", None)
818
+ loaded_config = None
819
+ if config_path is None:
820
+ detector_values: dict[str, Any] = {}
821
+ stage_values: dict[str, Any] = {}
822
+ else:
823
+ from .config import (
824
+ apply_stage_overrides,
825
+ load_config,
826
+ resolve_stage_config,
827
+ validate_config,
828
+ )
829
+
830
+ loaded_config = load_config(config_path)
831
+ override_names = (
832
+ "epochs",
833
+ "batch_size",
834
+ "gradient_accumulation",
835
+ "learning_rate",
836
+ "encoder_learning_rate",
837
+ "weight_decay",
838
+ "lr_scheduler",
839
+ "warmup_epochs",
840
+ "multi_scale",
841
+ "expanded_scales",
842
+ "precision",
843
+ "tf32",
844
+ )
845
+ stage_overrides = {
846
+ name: getattr(args, name)
847
+ for name in override_names
848
+ if getattr(args, name, None) is not None
849
+ }
850
+ loaded_config = apply_stage_overrides(loaded_config, 1, stage_overrides)
851
+ if getattr(args, "variant", None) is not None:
852
+ loaded_config["detector"]["variant"] = args.variant
853
+ if getattr(args, "resolution", None) is not None:
854
+ loaded_config["detector"]["resolution"] = args.resolution
855
+ validate_config(loaded_config)
856
+ detector_values = loaded_config["detector"]
857
+ stage_values = resolve_stage_config(loaded_config, 1)
858
+
859
+ def option(name: str, fallback: Any, *, config_name: str | None = None) -> Any:
860
+ value = getattr(args, name, None)
861
+ if value is not None:
862
+ return value
863
+ return stage_values.get(config_name or name, fallback)
864
+
865
+ variant = getattr(args, "variant", None) or detector_values.get("variant", "seg-2xlarge")
866
+ resolution = getattr(args, "resolution", None)
867
+ if resolution is None:
868
+ resolution = detector_values.get("resolution")
869
+ precision = option("precision", "backend_mixed")
870
+ if precision not in ("backend_mixed", "fp32"):
871
+ raise ValueError("Stage-1 precision must be 'backend_mixed' or 'fp32'")
872
+ tf32 = option("tf32", True)
873
+ if not isinstance(tf32, bool):
874
+ raise TypeError("Stage-1 tf32 must be boolean")
875
+ model_options = RFDETRModelConfig(
876
+ resolution=resolution,
877
+ amp=precision != "fp32",
878
+ )
879
+ # Fail before importing/constructing RF-DETR, which can otherwise download
880
+ # weights as part of its constructor.
881
+ model_options.resolved_resolution(variant)
882
+ train_options = RFDETRTrainConfig(
883
+ epochs=option("epochs", 12),
884
+ batch_size=option("batch_size", 1),
885
+ gradient_accumulation=option("gradient_accumulation", 16),
886
+ workers=option("workers", 4, config_name="workers"),
887
+ seed=42 if loaded_config is None else loaded_config["training"]["seed"],
888
+ learning_rate=option("learning_rate", 1e-4),
889
+ encoder_learning_rate=option("encoder_learning_rate", 1.5e-4),
890
+ weight_decay=option("weight_decay", 1e-4),
891
+ lr_scheduler=option("lr_scheduler", "step"),
892
+ warmup_epochs=option("warmup_epochs", 0.0),
893
+ multi_scale=option("multi_scale", True),
894
+ expanded_scales=option("expanded_scales", True),
895
+ )
896
+
897
+ resume_argument = getattr(args, "resume", None)
898
+ weights_path = getattr(args, "weights", None)
899
+ resolved_weights = None
900
+ if weights_path and resume_argument is None:
901
+ resolved_weights = Path(weights_path).expanduser().resolve(strict=True)
902
+ if not resolved_weights.is_file():
903
+ raise ValueError("Stage-1 --weights must point to a checkpoint file")
904
+ expected_backend = _expected_backend_config(variant, model_options)
905
+ run_config = {
906
+ "data_dir": str(data_root),
907
+ "backend": asdict(expected_backend),
908
+ "training": asdict(train_options),
909
+ "precision": precision,
910
+ "tf32": tf32,
911
+ }
912
+ manifest_path = output_root / "stage1_run.json"
913
+ resume_checkpoint = None
914
+ if resume_argument is None:
915
+ manifest = {
916
+ "stage": 1,
917
+ "status": "initialized",
918
+ "config": run_config,
919
+ "initial_weights": None if resolved_weights is None else str(resolved_weights),
920
+ "checkpoint": "last.pth",
921
+ "last_completed_epoch": None,
922
+ }
923
+ atomic_json(manifest_path, manifest)
924
+ else:
925
+ manifest = _read_stage1_manifest(manifest_path)
926
+ if manifest["config"] != run_config:
927
+ raise ValueError(
928
+ "Stage-1 resume settings differ from stage1_run.json; use the original "
929
+ "dataset, model configuration, and training options"
930
+ )
931
+ resume_checkpoint = _validate_resume_checkpoint(output_root, resume_argument)
932
+ manifest.pop("error", None)
933
+
934
+ try:
935
+ model_cls = _models()[variant]
936
+ # RF-DETR imports torch before configuring its engine; set the requested
937
+ # backend policy after import and before constructing/training the model.
938
+ torch.backends.cuda.matmul.allow_tf32 = tf32
939
+ torch.backends.cudnn.allow_tf32 = tf32
940
+ kwargs: dict[str, Any] = {"device": args.device}
941
+ kwargs.update(model_options.constructor_kwargs(variant))
942
+ if resume_checkpoint is not None:
943
+ kwargs["pretrain_weights"] = None
944
+ elif resolved_weights is not None:
945
+ kwargs["pretrain_weights"] = str(resolved_weights)
946
+ random.seed(train_options.seed)
947
+ np.random.seed(train_options.seed)
948
+ torch.manual_seed(train_options.seed)
949
+ model = model_cls(**kwargs)
950
+ backend_config = RFDETRDetector._backend_config(model, variant, model_options)
951
+ if backend_config != expected_backend:
952
+ raise RuntimeError("RF-DETR backend disagrees with the requested configuration")
953
+ manifest["status"] = "running"
954
+ atomic_json(manifest_path, manifest)
955
+ backend_kwargs = {
956
+ "dataset_dir": str(data_root),
957
+ "output_dir": str(output_root),
958
+ **train_options.backend_kwargs(),
959
+ "device": args.device,
960
+ "tensorboard": False,
961
+ "wandb": False,
962
+ "run_test": False,
963
+ }
964
+ if resume_checkpoint is not None:
965
+ backend_kwargs["resume"] = str(resume_checkpoint)
966
+ # RF-DETR reinitializes the class head before its own training seed call.
967
+ random.seed(train_options.seed)
968
+ np.random.seed(train_options.seed)
969
+ torch.manual_seed(train_options.seed)
970
+ with _rolling_checkpoint(output_root, manifest_path, manifest):
971
+ model.train(**backend_kwargs)
972
+ if not (output_root / "last.pth").is_file():
973
+ raise RuntimeError("RF-DETR training finished without saving last.pth")
974
+ manifest["status"] = "completed"
975
+ atomic_json(manifest_path, manifest)
976
+ except BaseException as error:
977
+ manifest["status"] = "failed"
978
+ manifest["error"] = f"{type(error).__name__}: {error}"
979
+ try:
980
+ atomic_json(manifest_path, manifest)
981
+ except OSError:
982
+ pass
983
+ raise
984
+
985
+
986
+ def train_detector(args: argparse.Namespace) -> None:
987
+ """Train Stage 1 and retain one rolling last.pth checkpoint."""
988
+
989
+ data_root = Path(args.data_dir).expanduser().resolve(strict=True)
990
+ output_root = external_output(args.output_dir, data_root)
991
+ resume = getattr(args, "resume", None) is not None
992
+ _protect_stage1_output(output_root, resume=resume)
993
+ with _stage1_output_lock(output_root):
994
+ _protect_stage1_output(output_root, resume=resume)
995
+ _train_detector(args, data_root=data_root, output_root=output_root)
996
+
997
+
998
+ def build_parser() -> argparse.ArgumentParser:
999
+ parser = argparse.ArgumentParser(description="Train the frozen Falcon RF-DETR stage")
1000
+ parser.add_argument("--data-dir", required=True, help="RF-DETR COCO dataset root")
1001
+ parser.add_argument(
1002
+ "--output-dir",
1003
+ required=True,
1004
+ help="New/empty output, or the existing Stage-1 directory for resume",
1005
+ )
1006
+ parser.add_argument("--weights", help="RF-DETR segmentation weights")
1007
+ parser.add_argument(
1008
+ "--resume",
1009
+ help="Resume from OUTPUT_DIR/last.pth at the next epoch",
1010
+ )
1011
+ parser.add_argument("--config", help="Optional Falcon YAML; explicit CLI options win")
1012
+ parser.add_argument("--variant", choices=VARIANTS)
1013
+ parser.add_argument("--resolution", type=int)
1014
+ parser.add_argument("--device", default="cuda")
1015
+ parser.add_argument("--epochs", type=int)
1016
+ parser.add_argument("--batch-size", type=int)
1017
+ parser.add_argument("--gradient-accumulation", type=int)
1018
+ parser.add_argument("--workers", type=int)
1019
+ parser.add_argument("--learning-rate", type=float)
1020
+ parser.add_argument("--encoder-learning-rate", type=float)
1021
+ parser.add_argument("--weight-decay", type=float)
1022
+ parser.add_argument("--lr-scheduler", choices=("cosine", "step"))
1023
+ parser.add_argument("--warmup-epochs", type=float)
1024
+ parser.add_argument("--precision", choices=("backend_mixed", "fp32"))
1025
+ parser.add_argument(
1026
+ "--tf32",
1027
+ action=argparse.BooleanOptionalAction,
1028
+ default=None,
1029
+ )
1030
+ parser.add_argument(
1031
+ "--multi-scale",
1032
+ action=argparse.BooleanOptionalAction,
1033
+ default=None,
1034
+ )
1035
+ parser.add_argument(
1036
+ "--expanded-scales",
1037
+ action=argparse.BooleanOptionalAction,
1038
+ default=None,
1039
+ )
1040
+ return parser
1041
+
1042
+
1043
+ def main() -> None:
1044
+ train_detector(build_parser().parse_args())
1045
+
1046
+
1047
+ if __name__ == "__main__":
1048
+ main()
generation_config.json ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ {
2
+ "_from_model_config": true,
3
+ "transformers_version": "4.49.0"
4
+ }
grounding.py ADDED
@@ -0,0 +1,691 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Grounding targets and prediction resolution for Falcon region tokens.
2
+
3
+ Ground-truth masks are accepted only by :func:`match_proposals`, which is a
4
+ training/scoring diagnostic. Prediction resolvers consume only generated text
5
+ and the detector's already-selected proposals. This separation prevents a
6
+ ground-truth mask from changing the proposal set seen by the model.
7
+
8
+ The module intentionally keeps torch and scipy imports inside the matcher so
9
+ transport parsing and inference-result serialization remain lightweight.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import math
16
+ import re
17
+ from collections.abc import Collection, Iterable, Mapping, Sequence
18
+ from dataclasses import dataclass
19
+ from enum import Enum
20
+ from typing import Any
21
+
22
+ import numpy as np
23
+
24
+ SEG_MARKER = "<SEG>"
25
+ PANOPTIC_MARKER = "<PANOPTIC>"
26
+ TRANSPORT_VERSION = "falcon-region-transport/v1"
27
+ MATCHING_POLICY_VERSION = "falcon-grounding-match/cardinality-iou-v1"
28
+ _REGION = re.compile(r"region_([0-9]{3})\Z")
29
+
30
+
31
+ class GroundingTransportError(ValueError):
32
+ """Raised when generated grounding transport is malformed or ambiguous."""
33
+
34
+
35
+ class GroundingResolutionError(ValueError):
36
+ """Raised when valid transport cannot be resolved against detector output."""
37
+
38
+
39
+ class UnmatchedGroundingError(RuntimeError):
40
+ """Raised when a positive target lacks a complete detector assignment."""
41
+
42
+
43
+ class GroundingMatchStatus(str, Enum):
44
+ """Exhaustive distinction between negatives and positive match outcomes."""
45
+
46
+ TRUE_NEGATIVE = "true_negative"
47
+ FULL_MATCH = "full_match"
48
+ PARTIAL_MATCH = "partial_match"
49
+ UNMATCHED_POSITIVE = "unmatched_positive"
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class GroundTruthInstance:
54
+ """One immutable ground-truth instance supplied to the diagnostic matcher."""
55
+
56
+ instance_id: int | str
57
+ category_id: int
58
+ mask: Any
59
+
60
+ def __post_init__(self) -> None:
61
+ if isinstance(self.instance_id, bool) or not isinstance(self.instance_id, int | str):
62
+ raise TypeError("instance_id must be an integer or string")
63
+ if isinstance(self.instance_id, str) and not self.instance_id:
64
+ raise ValueError("instance_id must not be empty")
65
+ if isinstance(self.category_id, bool) or not isinstance(self.category_id, int):
66
+ raise TypeError("category_id must be an integer")
67
+ if self.category_id < 1:
68
+ raise ValueError("category_id must be positive")
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class ProposalMatch:
73
+ """One threshold-qualified one-to-one GT/proposal assignment."""
74
+
75
+ gt_index: int
76
+ proposal_index: int
77
+ iou: float
78
+ instance_id: int | str
79
+ category_id: int
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class GroundingMatch:
84
+ """Complete matching diagnostic; never conflates a miss with a negative."""
85
+
86
+ status: GroundingMatchStatus
87
+ ground_truth_count: int
88
+ proposal_count: int
89
+ matches: tuple[ProposalMatch, ...]
90
+ unmatched_gt_indices: tuple[int, ...]
91
+ unmatched_proposal_indices: tuple[int, ...]
92
+ policy_version: str = MATCHING_POLICY_VERSION
93
+
94
+ @property
95
+ def region_indices(self) -> tuple[int, ...]:
96
+ """Matched proposal indices in stable proposal order."""
97
+
98
+ return tuple(sorted(item.proposal_index for item in self.matches))
99
+
100
+ def require_transportable(self) -> None:
101
+ """Reject incomplete positive supervision before answer construction."""
102
+
103
+ if self.status in (
104
+ GroundingMatchStatus.PARTIAL_MATCH,
105
+ GroundingMatchStatus.UNMATCHED_POSITIVE,
106
+ ):
107
+ raise UnmatchedGroundingError(
108
+ f"cannot encode {self.status.value}: "
109
+ f"{len(self.unmatched_gt_indices)} of {self.ground_truth_count} targets unmatched"
110
+ )
111
+
112
+
113
+ @dataclass(frozen=True)
114
+ class SegmentationTransport:
115
+ """Parsed SEG transport. An empty tuple is an explicit valid negative."""
116
+
117
+ region_indices: tuple[int, ...]
118
+
119
+
120
+ @dataclass(frozen=True)
121
+ class PanopticTransportInstance:
122
+ region_index: int
123
+ category_id: int
124
+
125
+
126
+ @dataclass(frozen=True)
127
+ class PanopticTransport:
128
+ instances: tuple[PanopticTransportInstance, ...]
129
+
130
+
131
+ @dataclass(frozen=True)
132
+ class SegmentationPrediction:
133
+ """Resolved detector masks in generated region-reference order."""
134
+
135
+ region_indices: tuple[int, ...]
136
+ masks: np.ndarray
137
+
138
+
139
+ @dataclass(frozen=True)
140
+ class PanopticInstance:
141
+ """One generated class assignment resolved to a live detector proposal."""
142
+
143
+ region_index: int
144
+ category_id: int
145
+ score: float
146
+ box_xyxy: tuple[float, float, float, float]
147
+ mask: np.ndarray
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class PanopticPrediction:
152
+ """Instance predictions and their deterministic non-overlapping raster."""
153
+
154
+ instances: tuple[PanopticInstance, ...]
155
+ id_map: np.ndarray
156
+ segments_info: tuple[dict[str, Any], ...]
157
+
158
+
159
+ def _validate_max_regions(max_regions: int) -> None:
160
+ if isinstance(max_regions, bool) or not isinstance(max_regions, int) or max_regions < 1:
161
+ raise ValueError("max_regions must be a positive integer")
162
+ if max_regions > 1000:
163
+ raise ValueError("v1 region transport supports at most 1000 regions")
164
+
165
+
166
+ def _validate_region_index(index: Any, max_regions: int) -> int:
167
+ _validate_max_regions(max_regions)
168
+ if isinstance(index, bool) or not isinstance(index, int):
169
+ raise GroundingTransportError("region index must be an integer")
170
+ if not 0 <= index < max_regions:
171
+ raise GroundingTransportError(
172
+ f"region index {index} lies outside [0, {max_regions})"
173
+ )
174
+ return index
175
+
176
+
177
+ def _region_name(index: Any, max_regions: int) -> str:
178
+ return f"region_{_validate_region_index(index, max_regions):03d}"
179
+
180
+
181
+ def _parse_region_name(value: Any, max_regions: int) -> int:
182
+ if not isinstance(value, str):
183
+ raise GroundingTransportError("region reference must be a string")
184
+ match = _REGION.fullmatch(value)
185
+ if match is None:
186
+ raise GroundingTransportError(f"invalid region reference {value!r}")
187
+ return _validate_region_index(int(match.group(1)), max_regions)
188
+
189
+
190
+ def _payload(text: str, marker: str) -> Mapping[str, Any]:
191
+ if not isinstance(text, str):
192
+ raise GroundingTransportError("grounding transport must be text")
193
+ stripped = text.strip()
194
+ if not stripped.startswith(marker):
195
+ raise GroundingTransportError(f"prediction must begin with {marker}")
196
+ encoded = stripped[len(marker) :]
197
+ if not encoded:
198
+ raise GroundingTransportError(f"bare {marker} is not valid transport")
199
+ try:
200
+ value = json.loads(encoded)
201
+ except json.JSONDecodeError as exc:
202
+ raise GroundingTransportError(f"invalid JSON after {marker}") from exc
203
+ if not isinstance(value, Mapping):
204
+ raise GroundingTransportError(f"{marker} payload must be an object")
205
+ return value
206
+
207
+
208
+ def encode_seg_transport(
209
+ region_indices: Iterable[int],
210
+ *,
211
+ max_regions: int = 100,
212
+ ) -> str:
213
+ """Encode strict SEG transport; an empty iterable is a valid negative."""
214
+
215
+ indices = tuple(_validate_region_index(index, max_regions) for index in region_indices)
216
+ if len(indices) != len(set(indices)):
217
+ raise GroundingTransportError("SEG transport contains duplicate regions")
218
+ payload = {"regions": [_region_name(index, max_regions) for index in indices]}
219
+ return SEG_MARKER + json.dumps(payload, separators=(",", ":"))
220
+
221
+
222
+ def parse_seg_transport(text: str, *, max_regions: int = 100) -> SegmentationTransport:
223
+ """Parse exact SEG transport, rejecting a bare marker and extra fields."""
224
+
225
+ value = _payload(text, SEG_MARKER)
226
+ if set(value) != {"regions"}:
227
+ raise GroundingTransportError("SEG payload must contain only 'regions'")
228
+ raw = value["regions"]
229
+ if isinstance(raw, str | bytes) or not isinstance(raw, Sequence):
230
+ raise GroundingTransportError("SEG regions must be an array")
231
+ indices = tuple(_parse_region_name(item, max_regions) for item in raw)
232
+ if len(indices) != len(set(indices)):
233
+ raise GroundingTransportError("SEG transport contains duplicate regions")
234
+ return SegmentationTransport(indices)
235
+
236
+
237
+ def _category_set(category_ids: Collection[int]) -> frozenset[int]:
238
+ categories = frozenset(category_ids)
239
+ if not categories:
240
+ raise ValueError("category_ids must not be empty")
241
+ if any(isinstance(item, bool) or not isinstance(item, int) or item < 1 for item in categories):
242
+ raise ValueError("category_ids must contain positive integers")
243
+ return categories
244
+
245
+
246
+ def encode_panoptic_transport(
247
+ instances: Iterable[PanopticTransportInstance],
248
+ *,
249
+ category_ids: Collection[int],
250
+ max_regions: int = 100,
251
+ ) -> str:
252
+ """Encode region/category pairs without introducing detector class logits."""
253
+
254
+ allowed = _category_set(category_ids)
255
+ values = tuple(instances)
256
+ regions: set[int] = set()
257
+ encoded = []
258
+ for item in values:
259
+ if not isinstance(item, PanopticTransportInstance):
260
+ raise TypeError("instances must contain PanopticTransportInstance values")
261
+ region_index = _validate_region_index(item.region_index, max_regions)
262
+ if region_index in regions:
263
+ raise GroundingTransportError("PANOPTIC transport contains duplicate regions")
264
+ regions.add(region_index)
265
+ if item.category_id not in allowed:
266
+ raise GroundingTransportError(f"unknown category_id {item.category_id}")
267
+ encoded.append(
268
+ {"region": _region_name(region_index, max_regions), "category_id": item.category_id}
269
+ )
270
+ payload = {"instances": encoded}
271
+ return PANOPTIC_MARKER + json.dumps(payload, separators=(",", ":"))
272
+
273
+
274
+ def parse_panoptic_transport(
275
+ text: str,
276
+ *,
277
+ category_ids: Collection[int],
278
+ max_regions: int = 100,
279
+ ) -> PanopticTransport:
280
+ """Parse exact PANOPTIC transport using manifest-provided categories."""
281
+
282
+ allowed = _category_set(category_ids)
283
+ value = _payload(text, PANOPTIC_MARKER)
284
+ if set(value) != {"instances"}:
285
+ raise GroundingTransportError("PANOPTIC payload must contain only 'instances'")
286
+ raw = value["instances"]
287
+ if isinstance(raw, str | bytes) or not isinstance(raw, Sequence):
288
+ raise GroundingTransportError("PANOPTIC instances must be an array")
289
+
290
+ instances = []
291
+ regions: set[int] = set()
292
+ for position, entry in enumerate(raw):
293
+ if not isinstance(entry, Mapping) or set(entry) != {"region", "category_id"}:
294
+ raise GroundingTransportError(
295
+ f"PANOPTIC instances[{position}] must contain region and category_id"
296
+ )
297
+ region_index = _parse_region_name(entry["region"], max_regions)
298
+ if region_index in regions:
299
+ raise GroundingTransportError("PANOPTIC transport contains duplicate regions")
300
+ regions.add(region_index)
301
+ category_id = entry["category_id"]
302
+ if isinstance(category_id, bool) or not isinstance(category_id, int):
303
+ raise GroundingTransportError("PANOPTIC category_id must be an integer")
304
+ if category_id not in allowed:
305
+ raise GroundingTransportError(f"unknown category_id {category_id}")
306
+ instances.append(PanopticTransportInstance(region_index, category_id))
307
+ return PanopticTransport(tuple(instances))
308
+
309
+
310
+ def _binary_cpu_tensor(value: Any, name: str, *, dimensions: int) -> Any:
311
+ import torch
312
+
313
+ tensor = value.detach().to("cpu") if isinstance(value, torch.Tensor) else torch.as_tensor(value)
314
+ if tensor.ndim != dimensions:
315
+ raise ValueError(f"{name} must have {dimensions} dimensions")
316
+ if tensor.dtype != torch.bool:
317
+ if not torch.isfinite(tensor).all():
318
+ raise ValueError(f"{name} contains non-finite values")
319
+ if not torch.logical_or(tensor == 0, tensor == 1).all():
320
+ raise ValueError(f"{name} must be binary")
321
+ tensor = tensor.to(dtype=torch.bool)
322
+ return tensor
323
+
324
+
325
+ def _qualified_hungarian_assignment(
326
+ overlaps: np.ndarray,
327
+ threshold: float,
328
+ ) -> tuple[np.ndarray, np.ndarray]:
329
+ """Maximize qualified match count, then total IoU among equal-cardinality plans."""
330
+
331
+ from scipy.optimize import linear_sum_assignment
332
+
333
+ if overlaps.ndim != 2:
334
+ raise ValueError("overlaps must be a two-dimensional matrix")
335
+ assignment_size = min(overlaps.shape)
336
+ # A cardinality bonus larger than the greatest possible aggregate IoU
337
+ # difference makes a threshold-qualified edge the primary objective.
338
+ reward = (overlaps >= threshold) * (assignment_size + 1.0) + overlaps
339
+ return linear_sum_assignment(-reward)
340
+
341
+
342
+ def match_proposals(
343
+ ground_truth: Sequence[GroundTruthInstance],
344
+ proposal_masks: Any,
345
+ *,
346
+ iou_threshold: float = 0.5,
347
+ ) -> GroundingMatch:
348
+ """Hungarian mask-IoU diagnostic over an unchanged proposal set.
349
+
350
+ The returned status explicitly distinguishes an annotation-level negative
351
+ from a detector miss. No target box or mask is ever inserted into the
352
+ proposal set.
353
+ """
354
+
355
+ import torch
356
+ if isinstance(iou_threshold, bool) or not isinstance(iou_threshold, int | float):
357
+ raise TypeError("iou_threshold must be numeric")
358
+ threshold = float(iou_threshold)
359
+ if not math.isfinite(threshold) or not 0.0 <= threshold <= 1.0:
360
+ raise ValueError("iou_threshold must lie in [0, 1]")
361
+ if any(not isinstance(item, GroundTruthInstance) for item in ground_truth):
362
+ raise TypeError("ground_truth must contain GroundTruthInstance values")
363
+
364
+ proposals = _binary_cpu_tensor(proposal_masks, "proposal_masks", dimensions=3)
365
+ proposal_count = int(proposals.shape[0])
366
+ ground_truth_count = len(ground_truth)
367
+ if ground_truth_count == 0:
368
+ return GroundingMatch(
369
+ GroundingMatchStatus.TRUE_NEGATIVE,
370
+ 0,
371
+ proposal_count,
372
+ (),
373
+ (),
374
+ tuple(range(proposal_count)),
375
+ )
376
+
377
+ targets = torch.stack(
378
+ [_binary_cpu_tensor(item.mask, "ground-truth mask", dimensions=2) for item in ground_truth]
379
+ )
380
+ if tuple(targets.shape[-2:]) != tuple(proposals.shape[-2:]):
381
+ raise ValueError("ground-truth and proposal masks must share spatial dimensions")
382
+ if not targets.flatten(1).any(dim=1).all():
383
+ raise ValueError("ground-truth instance masks must not be empty")
384
+ if proposal_count == 0:
385
+ return GroundingMatch(
386
+ GroundingMatchStatus.UNMATCHED_POSITIVE,
387
+ ground_truth_count,
388
+ 0,
389
+ (),
390
+ tuple(range(ground_truth_count)),
391
+ (),
392
+ )
393
+
394
+ intersections = torch.logical_and(targets[:, None], proposals[None]).sum(dim=(-2, -1))
395
+ unions = torch.logical_or(targets[:, None], proposals[None]).sum(dim=(-2, -1))
396
+ overlaps = intersections.to(torch.float64) / unions.clamp_min(1).to(torch.float64)
397
+ rows, columns = _qualified_hungarian_assignment(overlaps.numpy(), threshold)
398
+
399
+ matches = []
400
+ for row, column in zip(rows.tolist(), columns.tolist(), strict=True):
401
+ iou = float(overlaps[row, column])
402
+ if iou >= threshold:
403
+ target = ground_truth[row]
404
+ matches.append(
405
+ ProposalMatch(row, column, iou, target.instance_id, target.category_id)
406
+ )
407
+ matches.sort(key=lambda item: item.gt_index)
408
+ matched_gt = {item.gt_index for item in matches}
409
+ matched_proposals = {item.proposal_index for item in matches}
410
+ unmatched_gt = tuple(index for index in range(ground_truth_count) if index not in matched_gt)
411
+ unmatched_proposals = tuple(
412
+ index for index in range(proposal_count) if index not in matched_proposals
413
+ )
414
+ if not matches:
415
+ status = GroundingMatchStatus.UNMATCHED_POSITIVE
416
+ elif unmatched_gt:
417
+ status = GroundingMatchStatus.PARTIAL_MATCH
418
+ else:
419
+ status = GroundingMatchStatus.FULL_MATCH
420
+ return GroundingMatch(
421
+ status,
422
+ ground_truth_count,
423
+ proposal_count,
424
+ tuple(matches),
425
+ unmatched_gt,
426
+ unmatched_proposals,
427
+ )
428
+
429
+
430
+ def encode_segmentation_match(match: GroundingMatch, *, max_regions: int = 100) -> str:
431
+ """Encode only a true negative or fully matched positive."""
432
+
433
+ match.require_transportable()
434
+ return encode_seg_transport(match.region_indices, max_regions=max_regions)
435
+
436
+
437
+ def encode_panoptic_match(
438
+ match: GroundingMatch,
439
+ *,
440
+ category_ids: Collection[int],
441
+ max_regions: int = 100,
442
+ ) -> str:
443
+ """Encode category assignments only after a complete diagnostic match."""
444
+
445
+ match.require_transportable()
446
+ instances = tuple(
447
+ PanopticTransportInstance(item.proposal_index, item.category_id)
448
+ for item in sorted(match.matches, key=lambda item: item.proposal_index)
449
+ )
450
+ return encode_panoptic_transport(
451
+ instances,
452
+ category_ids=category_ids,
453
+ max_regions=max_regions,
454
+ )
455
+
456
+
457
+ def _field(value: Any, name: str) -> Any:
458
+ return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
459
+
460
+
461
+ def _to_numpy(value: Any) -> np.ndarray:
462
+ if hasattr(value, "detach"):
463
+ value = value.detach()
464
+ if hasattr(value, "cpu"):
465
+ value = value.cpu()
466
+ if hasattr(value, "numpy"):
467
+ value = value.numpy()
468
+ return np.asarray(value)
469
+
470
+
471
+ def _detector_arrays(
472
+ detector_output: Any,
473
+ *,
474
+ batch_index: int = 0,
475
+ ) -> tuple[np.ndarray, np.ndarray, np.ndarray, int, int]:
476
+ """Return one detector image without changing proposal indices.
477
+
478
+ Both the public unbatched :class:`DetectorOutput` and the model's padded
479
+ ``DetectorBatch`` are accepted. A padded batch must use a contiguous valid
480
+ prefix: otherwise compressing it here would silently renumber the region
481
+ tokens generated by the language model.
482
+ """
483
+
484
+ if isinstance(batch_index, bool) or not isinstance(batch_index, int) or batch_index < 0:
485
+ raise GroundingResolutionError("batch_index must be a nonnegative integer")
486
+ boxes_value = _field(detector_output, "boxes_xyxy")
487
+ if boxes_value is None:
488
+ boxes_value = _field(detector_output, "boxes")
489
+ scores_value = _field(detector_output, "scores")
490
+ masks_value = _field(detector_output, "masks")
491
+ original_hw = _field(detector_output, "original_hw")
492
+ if original_hw is None:
493
+ original_hw = _field(detector_output, "image_sizes")
494
+ mask_sizes = _field(detector_output, "mask_sizes")
495
+ valid_value = _field(detector_output, "valid")
496
+ if any(item is None for item in (boxes_value, scores_value, masks_value, original_hw)):
497
+ raise GroundingResolutionError(
498
+ "detector output must provide boxes, scores, masks, and original image sizes"
499
+ )
500
+
501
+ boxes = _to_numpy(boxes_value)
502
+ scores = _to_numpy(scores_value)
503
+ masks = _to_numpy(masks_value)
504
+ original_sizes = _to_numpy(original_hw)
505
+ if boxes.ndim == 3:
506
+ batch_size, proposal_count = boxes.shape[:2]
507
+ if batch_index >= boxes.shape[0]:
508
+ raise GroundingResolutionError("batch_index is outside the detector batch")
509
+ if scores.ndim != 2 or scores.shape != boxes.shape[:2]:
510
+ raise GroundingResolutionError("batched detector scores must have shape [B, N]")
511
+ if masks.ndim != 4 or masks.shape[:2] != boxes.shape[:2]:
512
+ raise GroundingResolutionError("batched detector masks must have shape [B, N, H, W]")
513
+ if original_sizes.shape != (boxes.shape[0], 2):
514
+ raise GroundingResolutionError("image_sizes must have shape [B, 2]")
515
+ boxes = boxes[batch_index]
516
+ scores = scores[batch_index]
517
+ masks = masks[batch_index]
518
+ original_sizes = original_sizes[batch_index]
519
+ if mask_sizes is not None:
520
+ sizes = _to_numpy(mask_sizes)
521
+ if sizes.shape != (batch_size, 2):
522
+ raise GroundingResolutionError("mask_sizes must have shape [B, 2]")
523
+ mask_height, mask_width = (int(value) for value in sizes[batch_index])
524
+ if mask_height < 1 or mask_width < 1 or (
525
+ mask_height > masks.shape[-2] or mask_width > masks.shape[-1]
526
+ ):
527
+ raise GroundingResolutionError("mask_sizes exceed the padded mask canvas")
528
+ masks = masks[:, :mask_height, :mask_width]
529
+ if valid_value is not None:
530
+ valid = _to_numpy(valid_value)
531
+ if valid.shape != (batch_size, proposal_count):
532
+ raise GroundingResolutionError("detector valid mask must have shape [B, N]")
533
+ valid = valid[batch_index].astype(bool)
534
+ count = int(np.count_nonzero(valid))
535
+ if not np.array_equal(valid, np.arange(len(valid)) < count):
536
+ raise GroundingResolutionError(
537
+ "detector valid proposals must form a contiguous prefix"
538
+ )
539
+ boxes, scores, masks = boxes[:count], scores[:count], masks[:count]
540
+ elif batch_index != 0:
541
+ raise GroundingResolutionError("batch_index is outside the unbatched detector output")
542
+
543
+ if boxes.ndim != 2 or boxes.shape[-1] != 4:
544
+ raise GroundingResolutionError("detector boxes must have shape [proposals, 4]")
545
+ if scores.ndim != 1 or scores.shape[0] != boxes.shape[0]:
546
+ raise GroundingResolutionError("detector scores must have shape [proposals]")
547
+ if masks.ndim != 3 or masks.shape[0] != boxes.shape[0]:
548
+ raise GroundingResolutionError("detector masks must have shape [proposals, H, W]")
549
+ if not np.isfinite(boxes).all() or not np.isfinite(scores).all():
550
+ raise GroundingResolutionError("selected detector boxes/scores must be finite")
551
+ if masks.dtype != np.bool_:
552
+ if not np.isfinite(masks).all() or not np.logical_or(masks == 0, masks == 1).all():
553
+ raise GroundingResolutionError("selected detector masks must be binary")
554
+ masks = masks.astype(bool)
555
+ try:
556
+ height, width = (int(value) for value in original_sizes)
557
+ except (TypeError, ValueError) as exc:
558
+ raise GroundingResolutionError("original image size must contain height and width") from exc
559
+ if height < 1 or width < 1 or masks.shape[-2:] != (height, width):
560
+ raise GroundingResolutionError("detector masks do not match positive original_hw")
561
+ return boxes.astype(np.float32), scores.astype(np.float32), masks, height, width
562
+
563
+
564
+ def resolve_segmentation(
565
+ text: str,
566
+ detector_output: Any,
567
+ *,
568
+ max_regions: int = 100,
569
+ batch_index: int = 0,
570
+ ) -> SegmentationPrediction:
571
+ """Resolve generated SEG references without any ground-truth input."""
572
+
573
+ transport = parse_seg_transport(text, max_regions=max_regions)
574
+ _boxes, _scores, masks, height, width = _detector_arrays(
575
+ detector_output,
576
+ batch_index=batch_index,
577
+ )
578
+ for index in transport.region_indices:
579
+ if index >= len(masks):
580
+ raise GroundingResolutionError(f"generated region_{index:03d} was not proposed")
581
+ if not masks[index].any():
582
+ raise GroundingResolutionError(f"generated region_{index:03d} has an empty mask")
583
+ selected = (
584
+ np.stack([masks[index] for index in transport.region_indices]).astype(bool, copy=True)
585
+ if transport.region_indices
586
+ else np.empty((0, height, width), dtype=bool)
587
+ )
588
+ return SegmentationPrediction(transport.region_indices, selected)
589
+
590
+
591
+ def _mask_bbox(mask: np.ndarray) -> list[int]:
592
+ rows, columns = np.nonzero(mask)
593
+ x_min, x_max = int(columns.min()), int(columns.max())
594
+ y_min, y_max = int(rows.min()), int(rows.max())
595
+ return [x_min, y_min, x_max - x_min + 1, y_max - y_min + 1]
596
+
597
+
598
+ def resolve_panoptic(
599
+ text: str,
600
+ detector_output: Any,
601
+ *,
602
+ category_ids: Collection[int],
603
+ max_regions: int = 100,
604
+ batch_index: int = 0,
605
+ ) -> PanopticPrediction:
606
+ """Resolve generated classes and live proposal masks into panoptic output."""
607
+
608
+ transport = parse_panoptic_transport(
609
+ text,
610
+ category_ids=category_ids,
611
+ max_regions=max_regions,
612
+ )
613
+ boxes, scores, masks, height, width = _detector_arrays(
614
+ detector_output,
615
+ batch_index=batch_index,
616
+ )
617
+ instances = []
618
+ for item in transport.instances:
619
+ index = item.region_index
620
+ if index >= len(masks):
621
+ raise GroundingResolutionError(f"generated region_{index:03d} was not proposed")
622
+ if not masks[index].any():
623
+ raise GroundingResolutionError(f"generated region_{index:03d} has an empty mask")
624
+ instances.append(
625
+ PanopticInstance(
626
+ region_index=index,
627
+ category_id=item.category_id,
628
+ score=float(scores[index]),
629
+ box_xyxy=tuple(float(value) for value in boxes[index]),
630
+ mask=masks[index].astype(bool, copy=True),
631
+ )
632
+ )
633
+
634
+ priority = sorted(instances, key=lambda item: (-item.score, item.region_index))
635
+ id_map = np.zeros((height, width), dtype=np.int32)
636
+ segments = []
637
+ for item in priority:
638
+ visible = np.logical_and(item.mask, id_map == 0)
639
+ if not visible.any():
640
+ continue
641
+ segment_id = item.region_index + 1
642
+ id_map[visible] = segment_id
643
+ segments.append(
644
+ {
645
+ "id": segment_id,
646
+ "category_id": item.category_id,
647
+ "iscrowd": 0,
648
+ "area": int(visible.sum()),
649
+ "bbox": _mask_bbox(visible),
650
+ }
651
+ )
652
+ segments.sort(key=lambda item: item["id"])
653
+ return PanopticPrediction(tuple(instances), id_map, tuple(segments))
654
+
655
+
656
+ def select_panoptic_category(
657
+ prediction: PanopticPrediction, category_id: int
658
+ ) -> PanopticPrediction:
659
+ """Select a query category using predicted classes, retaining instance IDs."""
660
+
661
+ if isinstance(category_id, bool) or not isinstance(category_id, int) or category_id < 1:
662
+ raise ValueError("query category_id must be a positive integer")
663
+ segments = tuple(
664
+ segment for segment in prediction.segments_info if segment["category_id"] == category_id
665
+ )
666
+ identifiers = [segment["id"] for segment in segments]
667
+ selected = np.isin(prediction.id_map, identifiers)
668
+ return PanopticPrediction(
669
+ tuple(item for item in prediction.instances if item.category_id == category_id),
670
+ np.where(selected, prediction.id_map, 0),
671
+ segments,
672
+ )
673
+
674
+
675
+ def panoptic_id_map_to_rgb(id_map: Any) -> np.ndarray:
676
+ """Encode a nonnegative integer segment-ID map as lossless COCO RGB pixels."""
677
+
678
+ values = _to_numpy(id_map)
679
+ if values.ndim != 2 or not np.issubdtype(values.dtype, np.integer):
680
+ raise ValueError("id_map must be a two-dimensional integer array")
681
+ if np.any(values < 0) or np.any(values > 0xFFFFFF):
682
+ raise ValueError("panoptic IDs must lie in [0, 16777215]")
683
+ unsigned = values.astype(np.uint32, copy=False)
684
+ return np.stack(
685
+ (
686
+ unsigned % 256,
687
+ (unsigned // 256) % 256,
688
+ (unsigned // 65536) % 256,
689
+ ),
690
+ axis=-1,
691
+ ).astype(np.uint8)
model-00001-of-00004.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f24d222c5e8058ec64cde5553df8d507b276a009269e058953d6341fb53ca1bd
3
+ size 3989033704
model-00002-of-00004.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c309e09cb581b0a5a1f7025426c8a4126e49c1eadbdc127995b79cedba7fef06
3
+ size 3910313088
model-00003-of-00004.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a355618af4d84e2ad3e06bfa87d5ce12c44cdd2d888b673e26f1c929c1bd086c
3
+ size 3978488256
model-00004-of-00004.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9fd406feb1c2f96644553b25f844d2e77bf3675912a206adf468177fc839c9e0
3
+ size 3135479168
model.py ADDED
@@ -0,0 +1,990 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Clean Falcon model core with live, injectable perception backends.
2
+
3
+ The module deliberately contains no dataset paths, cache paths, or legacy Groma
4
+ imports. A live detector can be attached for one-process inference; tests and
5
+ training may pass a same-process in-memory :class:`DetectorBatch` to avoid
6
+ rerunning a frozen detector for every task on one image.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ import re
13
+ from collections.abc import Mapping, Sequence
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+ import torch
18
+ from torch import Tensor, nn
19
+
20
+ from .capabilities import ALL_SAFETY_HEADS, SafetyCapabilities
21
+ from .regions import MaskAwareRegionEncoder, PatchTokenAggregator
22
+ from .safety import SafetyOutput, StructuredSafetyAdapter
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class FalconConfig:
27
+ vision_dim: int
28
+ language_dim: int
29
+ region_dim: int = 1024
30
+ image_size: int = 448
31
+ patch_size: int = 14
32
+ vision_prefix_tokens: int = 1
33
+ roi_size: int = 4
34
+ max_regions: int = 100
35
+ max_text_tokens: int = 256
36
+ text_overflow_policy: str = "error"
37
+ mask_probability_threshold: float = 0.5
38
+ risk_loss_weight: float = 1.0
39
+ presence_loss_weight: float = 1.0
40
+ link_loss_weight: float = 1.0
41
+ safety_capabilities: SafetyCapabilities = SafetyCapabilities()
42
+
43
+ def __post_init__(self) -> None:
44
+ if self.vision_dim < 1 or self.language_dim < 1 or self.region_dim < 1:
45
+ raise ValueError("model dimensions must be positive")
46
+ if self.image_size < 1 or self.patch_size < 1 or self.roi_size < 1:
47
+ raise ValueError("image_size, patch_size, and roi_size must be positive")
48
+ if self.image_size % (2 * self.patch_size):
49
+ raise ValueError("image_size must be divisible by twice patch_size")
50
+ if self.vision_prefix_tokens < 0:
51
+ raise ValueError("vision_prefix_tokens cannot be negative")
52
+ if self.max_regions < 1:
53
+ raise ValueError("max_regions must be positive")
54
+ if self.max_text_tokens < 3:
55
+ raise ValueError("max_text_tokens must allow a prompt token, answer token, and EOS")
56
+ if self.text_overflow_policy not in ("error", "truncate"):
57
+ raise ValueError("text_overflow_policy must be error or truncate")
58
+ if not 0.0 <= self.mask_probability_threshold <= 1.0:
59
+ raise ValueError("mask_probability_threshold must lie in [0, 1]")
60
+ for value in (
61
+ self.risk_loss_weight,
62
+ self.presence_loss_weight,
63
+ self.link_loss_weight,
64
+ ):
65
+ if not math.isfinite(value) or value < 0:
66
+ raise ValueError("structured loss weights must be finite and nonnegative")
67
+
68
+
69
+ @dataclass
70
+ class DetectorBatch:
71
+ """Batched live-detector output.
72
+
73
+ Boxes are absolute ``xyxy`` coordinates in the original images. Masks have
74
+ shape ``[batch, proposals, height, width]`` and are explicitly declared as
75
+ logits or probabilities. ``valid`` marks padding; proposal order is owned by
76
+ the detector and must already be deterministic (normally score descending).
77
+ """
78
+
79
+ boxes: Tensor
80
+ masks: Tensor
81
+ valid: Tensor | None = None
82
+ scores: Tensor | None = None
83
+ image_sizes: Tensor | None = None
84
+ masks_are_logits: bool = False
85
+ mask_sizes: Tensor | None = None
86
+
87
+ def batched(self) -> DetectorBatch:
88
+ boxes = self.boxes.unsqueeze(0) if self.boxes.ndim == 2 else self.boxes
89
+ masks = self.masks.unsqueeze(0) if self.masks.ndim == 3 else self.masks
90
+ valid = self.valid
91
+ scores = self.scores
92
+ image_sizes = self.image_sizes
93
+ mask_sizes = self.mask_sizes
94
+ if valid is not None and valid.ndim == 1:
95
+ valid = valid.unsqueeze(0)
96
+ if scores is not None and scores.ndim == 1:
97
+ scores = scores.unsqueeze(0)
98
+ if image_sizes is not None and image_sizes.ndim == 1:
99
+ image_sizes = image_sizes.unsqueeze(0)
100
+ if mask_sizes is not None and mask_sizes.ndim == 1:
101
+ mask_sizes = mask_sizes.unsqueeze(0)
102
+ if boxes.ndim != 3 or boxes.shape[-1] != 4:
103
+ raise ValueError("detector boxes must have shape [batch, proposals, 4]")
104
+ if masks.ndim != 4 or masks.shape[:2] != boxes.shape[:2]:
105
+ raise ValueError("detector masks must have shape [batch, proposals, height, width]")
106
+ if valid is None:
107
+ valid = torch.ones(boxes.shape[:2], dtype=torch.bool, device=boxes.device)
108
+ elif valid.shape != boxes.shape[:2]:
109
+ raise ValueError("detector valid mask must have shape [batch, proposals]")
110
+ if scores is not None and scores.shape != boxes.shape[:2]:
111
+ raise ValueError("detector scores must have shape [batch, proposals]")
112
+ if image_sizes is not None and image_sizes.shape != (boxes.shape[0], 2):
113
+ raise ValueError("detector image_sizes must have shape [batch, 2]")
114
+ if mask_sizes is None:
115
+ mask_sizes = torch.tensor(
116
+ [list(masks.shape[-2:])] * boxes.shape[0], device=masks.device, dtype=torch.long
117
+ )
118
+ if mask_sizes.shape != (boxes.shape[0], 2) or bool((mask_sizes <= 0).any()):
119
+ raise ValueError("detector mask_sizes must have shape [batch, 2] and be positive")
120
+ if bool((mask_sizes > mask_sizes.new_tensor(masks.shape[-2:])).any()):
121
+ raise ValueError("mask_sizes cannot exceed the padded mask canvas")
122
+ return DetectorBatch(
123
+ boxes=boxes,
124
+ masks=masks,
125
+ valid=valid.to(dtype=torch.bool),
126
+ scores=scores,
127
+ image_sizes=image_sizes,
128
+ masks_are_logits=self.masks_are_logits,
129
+ mask_sizes=mask_sizes,
130
+ )
131
+
132
+ def to(self, device: torch.device) -> DetectorBatch:
133
+ return DetectorBatch(
134
+ boxes=self.boxes.to(device),
135
+ masks=self.masks.to(device),
136
+ valid=None if self.valid is None else self.valid.to(device),
137
+ scores=None if self.scores is None else self.scores.to(device),
138
+ image_sizes=None if self.image_sizes is None else self.image_sizes.to(device),
139
+ masks_are_logits=self.masks_are_logits,
140
+ mask_sizes=None if self.mask_sizes is None else self.mask_sizes.to(device),
141
+ )
142
+
143
+ @classmethod
144
+ def pack(cls, outputs: Sequence[DetectorBatch]) -> DetectorBatch:
145
+ """Pad proposals and canvases without resampling original instance masks."""
146
+ if not outputs:
147
+ raise ValueError("cannot pack an empty detector batch")
148
+ outputs = [output.batched() for output in outputs]
149
+ if any(output.boxes.shape[0] != 1 for output in outputs):
150
+ raise ValueError("pack expects single-image detector outputs")
151
+ if len(outputs) == 1:
152
+ return outputs[0]
153
+ first = outputs[0]
154
+ if any(output.masks_are_logits != first.masks_are_logits for output in outputs):
155
+ raise ValueError("all masks in a batch must use the same representation")
156
+ if any((output.scores is None) != (first.scores is None) for output in outputs):
157
+ raise ValueError("all outputs must consistently include proposal scores")
158
+ if any(output.image_sizes is None for output in outputs):
159
+ raise ValueError("original image sizes are required for a multi-image batch")
160
+ count = max(output.boxes.shape[1] for output in outputs)
161
+ height = max(output.masks.shape[-2] for output in outputs)
162
+ width = max(output.masks.shape[-1] for output in outputs)
163
+ boxes = first.boxes.new_zeros((len(outputs), count, 4))
164
+ masks = first.masks.new_zeros((len(outputs), count, height, width))
165
+ valid = torch.zeros((len(outputs), count), device=first.boxes.device, dtype=torch.bool)
166
+ scores = None if first.scores is None else first.scores.new_zeros((len(outputs), count))
167
+ for index, output in enumerate(outputs):
168
+ n, h, w = output.masks.shape[1:]
169
+ boxes[index, :n] = output.boxes[0].to(boxes)
170
+ masks[index, :n, :h, :w] = output.masks[0].to(masks)
171
+ valid[index, :n] = output.valid[0].to(valid)
172
+ if scores is not None:
173
+ scores[index, :n] = output.scores[0].to(scores)
174
+ return cls(
175
+ boxes,
176
+ masks,
177
+ valid,
178
+ scores,
179
+ torch.cat([output.image_sizes.to(boxes.device) for output in outputs]),
180
+ first.masks_are_logits,
181
+ torch.cat([output.mask_sizes.to(boxes.device) for output in outputs]),
182
+ )
183
+
184
+
185
+ @dataclass
186
+ class VisionFeatures:
187
+ """Frozen DINO patch tokens that may be reused for tasks on one image."""
188
+
189
+ patch_tokens: Tensor
190
+ grid_size: tuple[int, int]
191
+
192
+ def to(self, device: torch.device) -> VisionFeatures:
193
+ return VisionFeatures(self.patch_tokens.to(device), self.grid_size)
194
+
195
+
196
+ @dataclass
197
+ class PrefixBatch:
198
+ inputs_embeds: Tensor
199
+ attention_mask: Tensor
200
+ labels: Tensor | None
201
+ image_token_count: int
202
+ region_token_count: int
203
+ safety_token_count: int
204
+
205
+
206
+ @dataclass
207
+ class FalconEncoding:
208
+ prefix: PrefixBatch
209
+ safety: SafetyOutput
210
+ region_embeddings: Tensor
211
+ detector_output: DetectorBatch
212
+
213
+
214
+ @dataclass
215
+ class FalconOutput:
216
+ loss: Tensor | None
217
+ language_loss: Tensor | None
218
+ structured_losses: dict[str, Tensor]
219
+ language_output: Any
220
+ safety: SafetyOutput
221
+ region_embeddings: Tensor
222
+ detector_output: DetectorBatch
223
+ prefix: PrefixBatch
224
+
225
+
226
+ @dataclass
227
+ class ResolvedRegion:
228
+ reference: str
229
+ proposal_index: int
230
+ box_xyxy: Tensor
231
+ mask: Tensor
232
+ score: Tensor | None
233
+
234
+
235
+ _REGION_REFERENCE = re.compile(r"(?<![A-Za-z0-9_])region_(\d{3})(?![A-Za-z0-9_])")
236
+
237
+
238
+ def parse_region_references(text: str, *, max_regions: int = 100) -> tuple[int, ...]:
239
+ """Parse unique ``region_NNN`` references in first-occurrence order."""
240
+
241
+ found: list[int] = []
242
+ seen: set[int] = set()
243
+ for match in _REGION_REFERENCE.finditer(text):
244
+ index = int(match.group(1))
245
+ if index < max_regions and index not in seen:
246
+ found.append(index)
247
+ seen.add(index)
248
+ return tuple(found)
249
+
250
+
251
+ def _field(value: Any, name: str) -> Any:
252
+ if isinstance(value, Mapping):
253
+ return value.get(name)
254
+ return getattr(value, name, None)
255
+
256
+
257
+ class FalconModel(nn.Module):
258
+ """DINOv2 + live RF-DETR proposals + SSA + a Vicuna/Llama decoder."""
259
+
260
+ def __init__(
261
+ self,
262
+ config: FalconConfig,
263
+ *,
264
+ vision_encoder: nn.Module,
265
+ language_model: nn.Module,
266
+ detector: Any | None = None,
267
+ image_processor: Any | None = None,
268
+ tokenizer: Any | None = None,
269
+ ) -> None:
270
+ super().__init__()
271
+ self.config = config
272
+ self.vision_encoder = vision_encoder
273
+ self.language_model = language_model
274
+ self.detector = detector
275
+ self.image_processor = image_processor
276
+ self.tokenizer = tokenizer
277
+
278
+ self.patch_aggregator = PatchTokenAggregator(config.vision_dim, config.region_dim)
279
+ self.region_encoder = MaskAwareRegionEncoder(
280
+ config.region_dim,
281
+ config.region_dim,
282
+ roi_size=config.roi_size,
283
+ probability_threshold=config.mask_probability_threshold,
284
+ )
285
+ self.safety_adapter = StructuredSafetyAdapter(
286
+ config.region_dim, config.language_dim, capabilities=config.safety_capabilities
287
+ )
288
+ self.image_projection = nn.Linear(config.region_dim, config.language_dim)
289
+ self.region_projection = nn.Linear(config.region_dim, config.language_dim)
290
+ self.region_index_embedding = nn.Embedding(config.max_regions, config.language_dim)
291
+ self.training_stage = 2
292
+ self.set_training_stage(2)
293
+
294
+ @classmethod
295
+ def from_pretrained(
296
+ cls,
297
+ *,
298
+ vision_model_name_or_path: str,
299
+ language_model_name_or_path: str,
300
+ detector: Any | None = None,
301
+ region_dim: int = 1024,
302
+ image_size: int = 448,
303
+ patch_size: int = 14,
304
+ roi_size: int = 4,
305
+ max_regions: int = 100,
306
+ max_text_tokens: int = 256,
307
+ text_overflow_policy: str = "error",
308
+ risk_loss_weight: float = 1.0,
309
+ presence_loss_weight: float = 1.0,
310
+ link_loss_weight: float = 1.0,
311
+ safety_capabilities: SafetyCapabilities = ALL_SAFETY_HEADS,
312
+ vision_kwargs: Mapping[str, Any] | None = None,
313
+ language_kwargs: Mapping[str, Any] | None = None,
314
+ ) -> FalconModel:
315
+ """Construct the two pretrained encoders without hard-coded model paths."""
316
+
317
+ from transformers import (
318
+ AutoImageProcessor,
319
+ AutoModel,
320
+ AutoModelForCausalLM,
321
+ AutoTokenizer,
322
+ )
323
+
324
+ vision_encoder = AutoModel.from_pretrained(
325
+ vision_model_name_or_path,
326
+ **dict(vision_kwargs or {}),
327
+ )
328
+ language_model = AutoModelForCausalLM.from_pretrained(
329
+ language_model_name_or_path,
330
+ **dict(language_kwargs or {}),
331
+ )
332
+ image_processor = AutoImageProcessor.from_pretrained(
333
+ vision_model_name_or_path,
334
+ size={"height": image_size, "width": image_size},
335
+ crop_size={"height": image_size, "width": image_size},
336
+ use_fast=False,
337
+ )
338
+ tokenizer = AutoTokenizer.from_pretrained(language_model_name_or_path, use_fast=False)
339
+ if tokenizer.pad_token_id is None:
340
+ tokenizer.pad_token = tokenizer.eos_token
341
+
342
+ vision_dim = int(vision_encoder.config.hidden_size)
343
+ language_dim = int(language_model.config.hidden_size)
344
+ return cls(
345
+ FalconConfig(
346
+ vision_dim=vision_dim,
347
+ language_dim=language_dim,
348
+ region_dim=region_dim,
349
+ image_size=image_size,
350
+ patch_size=patch_size,
351
+ roi_size=roi_size,
352
+ max_regions=max_regions,
353
+ max_text_tokens=max_text_tokens,
354
+ text_overflow_policy=text_overflow_policy,
355
+ risk_loss_weight=risk_loss_weight,
356
+ presence_loss_weight=presence_loss_weight,
357
+ link_loss_weight=link_loss_weight,
358
+ safety_capabilities=safety_capabilities,
359
+ ),
360
+ vision_encoder=vision_encoder,
361
+ language_model=language_model,
362
+ detector=detector,
363
+ image_processor=image_processor,
364
+ tokenizer=tokenizer,
365
+ )
366
+
367
+ @property
368
+ def device(self) -> torch.device:
369
+ return next(self.patch_aggregator.parameters()).device
370
+
371
+ @staticmethod
372
+ def _as_image_batch(images: Any) -> list[Any]:
373
+ if isinstance(images, list | tuple):
374
+ return list(images)
375
+ if isinstance(images, Tensor) and images.ndim == 4:
376
+ return list(images.unbind(0))
377
+ if hasattr(images, "ndim") and images.ndim == 4:
378
+ return [images[index] for index in range(len(images))]
379
+ return [images]
380
+
381
+ @staticmethod
382
+ def _image_size(image: Any) -> tuple[int, int]:
383
+ # PIL exposes (width, height); NumPy and torch expose a shape.
384
+ if hasattr(image, "size") and isinstance(image.size, tuple):
385
+ width, height = image.size
386
+ return int(height), int(width)
387
+ shape = tuple(image.shape) if hasattr(image, "shape") else ()
388
+ if len(shape) < 2:
389
+ raise ValueError("cannot infer original image size")
390
+ if len(shape) == 2:
391
+ return int(shape[0]), int(shape[1])
392
+ if shape[0] in (1, 3, 4):
393
+ return int(shape[-2]), int(shape[-1])
394
+ return int(shape[0]), int(shape[1])
395
+
396
+ @staticmethod
397
+ def _normalise_text_batch(
398
+ texts: str | Sequence[str] | None,
399
+ batch_size: int,
400
+ name: str,
401
+ ) -> list[str] | None:
402
+ if texts is None:
403
+ return None
404
+ values = [texts] * batch_size if isinstance(texts, str) else list(texts)
405
+ if len(values) != batch_size:
406
+ raise ValueError(f"{name} must contain one string per image")
407
+ return values
408
+
409
+ def _process_images(self, images: list[Any]) -> Tensor:
410
+ if self.image_processor is None:
411
+ raise RuntimeError("an AutoImageProcessor-compatible object is required for raw images")
412
+ processed = self.image_processor(images=images, return_tensors="pt")
413
+ pixel_values = _field(processed, "pixel_values")
414
+ if pixel_values is None:
415
+ raise ValueError("image processor did not return pixel_values")
416
+ if tuple(pixel_values.shape[-2:]) != (self.config.image_size, self.config.image_size):
417
+ raise ValueError(
418
+ "image processor returned "
419
+ f"{tuple(pixel_values.shape[-2:])}; expected "
420
+ f"{self.config.image_size}x{self.config.image_size}"
421
+ )
422
+ return pixel_values.to(self.device)
423
+
424
+ def _encode_one_text(self, text: str, *, special_tokens: bool) -> list[int]:
425
+ if self.tokenizer is None:
426
+ raise RuntimeError("a Vicuna/Llama tokenizer is required for string prompts")
427
+ encoded = self.tokenizer(
428
+ text,
429
+ add_special_tokens=special_tokens,
430
+ return_attention_mask=False,
431
+ )
432
+ token_ids = _field(encoded, "input_ids")
433
+ if isinstance(token_ids, Tensor):
434
+ token_ids = token_ids.reshape(-1).tolist()
435
+ if token_ids and isinstance(token_ids[0], list):
436
+ token_ids = token_ids[0]
437
+ return list(token_ids)
438
+
439
+ def _answer_token_ids(self, answer: str) -> list[int]:
440
+ """Use the same nonempty-answer/EOS contract for preflight and training."""
441
+ eos_id = getattr(self.tokenizer, "eos_token_id", None)
442
+ if eos_id is None:
443
+ raise ValueError("the tokenizer must define eos_token_id for supervised answers")
444
+ answer_ids = self._encode_one_text(answer, special_tokens=False)
445
+ content = answer_ids[:-1] if answer_ids and answer_ids[-1] == eos_id else answer_ids
446
+ if not content:
447
+ raise ValueError("a supervised answer must contain at least one token")
448
+ return content + [eos_id]
449
+
450
+ def text_token_counts(self, prompt: str, answer: str | None = None) -> dict[str, int]:
451
+ """Count untruncated text on the CPU without running either backbone.
452
+
453
+ ``answer=None`` measures a query whose eventual proposal-dependent target
454
+ is unknown. It must not be interpreted as a full training-length bound.
455
+ """
456
+ prompt_count = len(self._encode_one_text(prompt, special_tokens=True))
457
+ answer_count = 0 if answer is None else len(self._answer_token_ids(answer))
458
+ return {
459
+ "prompt_tokens": prompt_count,
460
+ "answer_tokens": answer_count,
461
+ "total_tokens": prompt_count + answer_count,
462
+ }
463
+
464
+ def _tokenize(
465
+ self,
466
+ prompts: list[str],
467
+ answers: list[str] | None,
468
+ supervision: Sequence[bool] | None = None,
469
+ ) -> tuple[Tensor, Tensor, Tensor | None]:
470
+ sequences: list[list[int]] = []
471
+ label_sequences: list[list[int]] | None = [] if answers is not None else None
472
+ eos_id = getattr(self.tokenizer, "eos_token_id", None)
473
+
474
+ def truncate_prompt(token_ids: list[int], budget: int) -> list[int]:
475
+ if len(token_ids) <= budget:
476
+ return token_ids
477
+ if self.config.text_overflow_policy == "error":
478
+ raise ValueError(
479
+ f"Prompt needs {len(token_ids)} tokens but only {budget} fit; "
480
+ "increase max_text_tokens instead of silently removing query context"
481
+ )
482
+ if budget <= 0:
483
+ return []
484
+ if budget == 1:
485
+ return token_ids[:1]
486
+ # Retain BOS and the recent USER/ASSISTANT suffix.
487
+ return token_ids[:1] + token_ids[-(budget - 1) :]
488
+
489
+ for index, prompt in enumerate(prompts):
490
+ prompt_ids = self._encode_one_text(prompt, special_tokens=True)
491
+ if answers is None or (supervision is not None and not supervision[index]):
492
+ prompt_sequence = truncate_prompt(prompt_ids, self.config.max_text_tokens)
493
+ sequences.append(prompt_sequence)
494
+ if label_sequences is not None:
495
+ label_sequences.append([-100] * len(prompt_sequence))
496
+ continue
497
+ answer_ids = self._answer_token_ids(answers[index])
498
+ if (
499
+ self.config.text_overflow_policy == "error"
500
+ and len(prompt_ids) + len(answer_ids) > self.config.max_text_tokens
501
+ ):
502
+ raise ValueError(
503
+ f"Prompt+answer needs {len(prompt_ids) + len(answer_ids)} tokens "
504
+ f"({len(prompt_ids)} prompt, {len(answer_ids)} answer including EOS), "
505
+ f"exceeding max_text_tokens={self.config.max_text_tokens}; increase "
506
+ "the budget to preserve the full query and target"
507
+ )
508
+ answer_budget = self.config.max_text_tokens - 1
509
+ if len(answer_ids) > answer_budget:
510
+ answer_ids = answer_ids[: answer_budget - 1] + [eos_id]
511
+ prompt_budget = self.config.max_text_tokens - len(answer_ids)
512
+ prompt_ids = truncate_prompt(prompt_ids, prompt_budget)
513
+ sequences.append(prompt_ids + answer_ids)
514
+ assert label_sequences is not None
515
+ label_sequences.append([-100] * len(prompt_ids) + answer_ids)
516
+
517
+ pad_id = getattr(self.tokenizer, "pad_token_id", None)
518
+ if pad_id is None:
519
+ pad_id = eos_id if eos_id is not None else 0
520
+ max_length = max(len(sequence) for sequence in sequences)
521
+ input_ids = torch.full((len(sequences), max_length), pad_id, dtype=torch.long)
522
+ attention_mask = torch.zeros_like(input_ids)
523
+ labels = torch.full_like(input_ids, -100) if label_sequences is not None else None
524
+ for row, sequence in enumerate(sequences):
525
+ length = len(sequence)
526
+ input_ids[row, :length] = torch.as_tensor(sequence)
527
+ attention_mask[row, :length] = 1
528
+ if labels is not None and label_sequences is not None:
529
+ labels[row, :length] = torch.as_tensor(label_sequences[row])
530
+ return (
531
+ input_ids.to(self.device),
532
+ attention_mask.to(self.device),
533
+ (None if labels is None else labels.to(self.device)),
534
+ )
535
+
536
+ @staticmethod
537
+ def _coerce_detector_output(value: Any) -> DetectorBatch:
538
+ if isinstance(value, list | tuple):
539
+ return DetectorBatch.pack([FalconModel._coerce_detector_output(item) for item in value])
540
+ if isinstance(value, DetectorBatch):
541
+ return value.batched()
542
+ boxes = _field(value, "boxes")
543
+ if boxes is None:
544
+ boxes = _field(value, "boxes_xyxy")
545
+ masks = _field(value, "masks")
546
+ if boxes is None or masks is None:
547
+ raise TypeError("detector output must expose boxes/boxes_xyxy and masks")
548
+ image_sizes = _field(value, "image_sizes")
549
+ if image_sizes is None:
550
+ image_sizes = _field(value, "original_hw")
551
+ valid = _field(value, "valid")
552
+ scores = _field(value, "scores")
553
+ return DetectorBatch(
554
+ boxes=torch.as_tensor(boxes),
555
+ masks=torch.as_tensor(masks),
556
+ valid=None if valid is None else torch.as_tensor(valid),
557
+ scores=None if scores is None else torch.as_tensor(scores),
558
+ image_sizes=None if image_sizes is None else torch.as_tensor(image_sizes),
559
+ masks_are_logits=bool(_field(value, "masks_are_logits") or False),
560
+ mask_sizes=_field(value, "mask_sizes"),
561
+ ).batched()
562
+
563
+ def _detect(self, images: list[Any]) -> DetectorBatch:
564
+ if self.detector is None:
565
+ raise RuntimeError("attach a live detector or provide detector_outputs")
566
+ if isinstance(self.detector, nn.Module):
567
+ self.detector.eval()
568
+ with torch.no_grad():
569
+ outputs = [self._coerce_detector_output(self.detector(image)) for image in images]
570
+ for image, output in zip(images, outputs, strict=False):
571
+ if output.image_sizes is None:
572
+ output.image_sizes = torch.as_tensor([self._image_size(image)])
573
+ return DetectorBatch.pack(outputs)
574
+
575
+ def _extract_vision_features(self, pixel_values: Tensor) -> VisionFeatures:
576
+ self.vision_encoder.eval()
577
+ with torch.no_grad():
578
+ encoded = self.vision_encoder(pixel_values=pixel_values, return_dict=True)
579
+ hidden_state = _field(encoded, "last_hidden_state")
580
+ if hidden_state is None:
581
+ raise ValueError("vision encoder did not return last_hidden_state")
582
+ patch_tokens = hidden_state[:, self.config.vision_prefix_tokens :].detach()
583
+ pixel_height, pixel_width = pixel_values.shape[-2:]
584
+ grid_size = (
585
+ pixel_height // self.config.patch_size,
586
+ pixel_width // self.config.patch_size,
587
+ )
588
+ return VisionFeatures(patch_tokens, grid_size)
589
+
590
+ def extract_vision_features(self, images: Any) -> VisionFeatures:
591
+ """Run frozen DINO once for one or more raw images."""
592
+
593
+ image_batch = self._as_image_batch(images)
594
+ return self._extract_vision_features(self._process_images(image_batch))
595
+
596
+ def encode_vision_features(self, features: VisionFeatures) -> tuple[Tensor, Tensor]:
597
+ """Apply the trainable aggregation and image projection layers."""
598
+
599
+ features = features.to(self.device)
600
+ feature_map = self.patch_aggregator(features.patch_tokens, features.grid_size)
601
+ image_tokens = feature_map.flatten(start_dim=2).transpose(1, 2)
602
+ return feature_map, self.image_projection(image_tokens)
603
+
604
+ def encode_images(self, pixel_values: Tensor) -> tuple[Tensor, Tensor]:
605
+ """Return the aggregated feature map and projected DINO image tokens."""
606
+
607
+ return self.encode_vision_features(self._extract_vision_features(pixel_values))
608
+
609
+ def build_prefix(
610
+ self,
611
+ *,
612
+ image_tokens: Tensor,
613
+ region_tokens: Tensor,
614
+ region_valid: Tensor,
615
+ safety_tokens: Tensor,
616
+ input_ids: Tensor,
617
+ attention_mask: Tensor,
618
+ labels: Tensor | None,
619
+ ) -> PrefixBatch:
620
+ """Build ``[image | region | SSA | text]`` inputs for Vicuna/Llama."""
621
+
622
+ text_embeddings = self.language_model.get_input_embeddings()(input_ids)
623
+ dtype = text_embeddings.dtype
624
+ image_tokens = image_tokens.to(dtype=dtype)
625
+ region_tokens = region_tokens.to(dtype=dtype)
626
+ safety_tokens = safety_tokens.to(dtype=dtype)
627
+ inputs_embeds = torch.cat(
628
+ (image_tokens, region_tokens, safety_tokens, text_embeddings),
629
+ dim=1,
630
+ )
631
+
632
+ batch = input_ids.shape[0]
633
+ image_mask = torch.ones(
634
+ (batch, image_tokens.shape[1]),
635
+ dtype=attention_mask.dtype,
636
+ device=attention_mask.device,
637
+ )
638
+ safety_mask = torch.ones(
639
+ (batch, safety_tokens.shape[1]),
640
+ dtype=attention_mask.dtype,
641
+ device=attention_mask.device,
642
+ )
643
+ prefix_attention = torch.cat(
644
+ (image_mask, region_valid.to(attention_mask.dtype), safety_mask, attention_mask),
645
+ dim=1,
646
+ )
647
+ prefix_labels = None
648
+ if labels is not None:
649
+ ignored = torch.full(
650
+ (batch, image_tokens.shape[1] + region_tokens.shape[1] + safety_tokens.shape[1]),
651
+ -100,
652
+ dtype=labels.dtype,
653
+ device=labels.device,
654
+ )
655
+ prefix_labels = torch.cat((ignored, labels), dim=1)
656
+ return PrefixBatch(
657
+ inputs_embeds=inputs_embeds,
658
+ attention_mask=prefix_attention,
659
+ labels=prefix_labels,
660
+ image_token_count=image_tokens.shape[1],
661
+ region_token_count=region_tokens.shape[1],
662
+ safety_token_count=safety_tokens.shape[1],
663
+ )
664
+
665
+ def prepare_multimodal_inputs(
666
+ self,
667
+ *,
668
+ images: Any,
669
+ prompts: str | Sequence[str],
670
+ answers: str | Sequence[str] | None = None,
671
+ detector_outputs: DetectorBatch | Mapping[str, Any] | Any | None = None,
672
+ vision_features: VisionFeatures | None = None,
673
+ language_supervision: Tensor | Sequence[bool] | None = None,
674
+ ) -> FalconEncoding:
675
+ image_batch = self._as_image_batch(images)
676
+ prompts_batch = self._normalise_text_batch(prompts, len(image_batch), "prompts")
677
+ answers_batch = self._normalise_text_batch(answers, len(image_batch), "answers")
678
+ assert prompts_batch is not None
679
+
680
+ supervision = None
681
+ if language_supervision is not None:
682
+ supplied = torch.as_tensor(language_supervision)
683
+ if supplied.dtype != torch.bool or supplied.shape != (len(image_batch),):
684
+ raise ValueError("language_supervision must contain one boolean per example")
685
+ if answers_batch is None:
686
+ raise ValueError("language_supervision requires answer labels")
687
+ supervision = supplied.tolist()
688
+ input_ids, attention_mask, labels = self._tokenize(
689
+ prompts_batch, answers_batch, supervision
690
+ )
691
+ detected = (
692
+ self._detect(image_batch)
693
+ if detector_outputs is None
694
+ else self._coerce_detector_output(detector_outputs)
695
+ ).to(self.device)
696
+ if detected.image_sizes is None:
697
+ detected.image_sizes = torch.as_tensor(
698
+ [self._image_size(image) for image in image_batch],
699
+ device=self.device,
700
+ )
701
+ if detected.boxes.shape[0] != len(image_batch):
702
+ raise ValueError("detector output batch size does not match the image batch")
703
+
704
+ features = (
705
+ self.extract_vision_features(image_batch)
706
+ if vision_features is None
707
+ else vision_features.to(self.device)
708
+ )
709
+ if features.patch_tokens.shape[0] != len(image_batch):
710
+ raise ValueError("vision feature batch size does not match the image batch")
711
+ feature_map, image_tokens = self.encode_vision_features(features)
712
+ assert detected.valid is not None and detected.image_sizes is not None
713
+ region_embeddings = self.region_encoder(
714
+ feature_map,
715
+ detected.boxes,
716
+ detected.masks,
717
+ detected.image_sizes,
718
+ detected.valid,
719
+ masks_are_logits=detected.masks_are_logits,
720
+ mask_sizes=detected.mask_sizes,
721
+ )
722
+ safety = self.safety_adapter(region_embeddings, detected.valid)
723
+ region_tokens = self.region_projection(region_embeddings)
724
+ proposal_count = region_tokens.shape[1]
725
+ if proposal_count > self.config.max_regions:
726
+ raise ValueError(
727
+ f"detector returned {proposal_count} proposals; "
728
+ f"max_regions={self.config.max_regions}"
729
+ )
730
+ region_indices = torch.arange(proposal_count, device=self.device)
731
+ region_tokens = region_tokens + self.region_index_embedding(region_indices).unsqueeze(0)
732
+ prefix = self.build_prefix(
733
+ image_tokens=image_tokens,
734
+ region_tokens=region_tokens,
735
+ region_valid=detected.valid,
736
+ safety_tokens=safety.tokens,
737
+ input_ids=input_ids,
738
+ attention_mask=attention_mask,
739
+ labels=labels,
740
+ )
741
+ self._validate_context_budget(prefix)
742
+ return FalconEncoding(
743
+ prefix=prefix,
744
+ safety=safety,
745
+ region_embeddings=region_embeddings,
746
+ detector_output=detected,
747
+ )
748
+
749
+ def _validate_context_budget(self, prefix: PrefixBatch, *, additional_tokens: int = 0) -> None:
750
+ """Respect the loaded language model's declared positional context."""
751
+ language_config = getattr(self.language_model, "config", None)
752
+ limit = getattr(language_config, "max_position_embeddings", None)
753
+ if isinstance(limit, bool) or not isinstance(limit, int) or limit < 1:
754
+ return
755
+ needed = prefix.inputs_embeds.shape[1] + additional_tokens
756
+ if needed > limit:
757
+ raise ValueError(
758
+ f"Multimodal context needs {needed} positions including image/region/safety "
759
+ f"tokens and {additional_tokens} requested new tokens, but the language model "
760
+ f"declares max_position_embeddings={limit}; reduce the text/generation budget "
761
+ "or use a verified longer-context backbone"
762
+ )
763
+
764
+ def forward(
765
+ self,
766
+ *,
767
+ images: Any,
768
+ prompts: str | Sequence[str],
769
+ answers: str | Sequence[str] | None = None,
770
+ targets: Mapping[str, Any] | None = None,
771
+ detector_outputs: DetectorBatch | Mapping[str, Any] | Any | None = None,
772
+ vision_features: VisionFeatures | None = None,
773
+ language_supervision: Tensor | Sequence[bool] | None = None,
774
+ ) -> FalconOutput:
775
+ """Run raw images through live detection, DINOv2, SSA, and Vicuna/Llama."""
776
+
777
+ encoded = self.prepare_multimodal_inputs(
778
+ images=images,
779
+ prompts=prompts,
780
+ answers=answers,
781
+ detector_outputs=detector_outputs,
782
+ vision_features=vision_features,
783
+ language_supervision=language_supervision,
784
+ )
785
+ prefix = encoded.prefix
786
+ if language_supervision is not None:
787
+ supervision = torch.as_tensor(language_supervision, device=self.device)
788
+ if supervision.dtype != torch.bool or supervision.shape != (
789
+ prefix.inputs_embeds.shape[0],
790
+ ):
791
+ raise ValueError("language_supervision must contain one boolean per example")
792
+ if prefix.labels is None:
793
+ raise ValueError("language_supervision requires answer labels")
794
+ prefix.labels = prefix.labels.masked_fill(~supervision[:, None], -100)
795
+ # Causal-LM cross entropy over zero labels is NaN in common backends.
796
+ # A batch with no transportable targets still trains available SSA heads.
797
+ if not bool(supervision.any()):
798
+ prefix.labels = None
799
+ language_output = self.language_model(
800
+ inputs_embeds=prefix.inputs_embeds,
801
+ attention_mask=prefix.attention_mask,
802
+ labels=prefix.labels,
803
+ return_dict=True,
804
+ )
805
+ language_loss = _field(language_output, "loss")
806
+ structured_losses: dict[str, Tensor] = {}
807
+ total_loss = language_loss
808
+ if targets is not None:
809
+ missing = {"risk", "presence", "links"}.difference(targets)
810
+ if missing:
811
+ raise ValueError(f"structured targets are missing: {sorted(missing)}")
812
+ structured_losses = self.safety_adapter.loss(
813
+ encoded.safety,
814
+ risk=self._target_tensor(targets["risk"]),
815
+ presence=self._target_tensor(targets["presence"]),
816
+ links=self._target_tensor(targets["links"]),
817
+ )
818
+ structured_total = (
819
+ self.config.risk_loss_weight * structured_losses["risk"]
820
+ + self.config.presence_loss_weight * structured_losses["presence"]
821
+ + self.config.link_loss_weight * structured_losses["links"]
822
+ )
823
+ total_loss = structured_total if total_loss is None else total_loss + structured_total
824
+ if total_loss is not None and self.training and not total_loss.requires_grad:
825
+ # An ablation can disable every structured head while a detector miss
826
+ # masks all language targets. Keep a genuine differentiable zero,
827
+ # not an empty-mask label or NaN, for the distributed training step.
828
+ total_loss = total_loss + prefix.inputs_embeds.sum() * 0.0
829
+ return FalconOutput(
830
+ loss=total_loss,
831
+ language_loss=language_loss,
832
+ structured_losses=structured_losses,
833
+ language_output=language_output,
834
+ safety=encoded.safety,
835
+ region_embeddings=encoded.region_embeddings,
836
+ detector_output=encoded.detector_output,
837
+ prefix=prefix,
838
+ )
839
+
840
+ @torch.no_grad()
841
+ def generate(
842
+ self,
843
+ *,
844
+ images: Any,
845
+ prompts: str | Sequence[str],
846
+ detector_outputs: DetectorBatch | Mapping[str, Any] | Any | None = None,
847
+ vision_features: VisionFeatures | None = None,
848
+ **generation_kwargs: Any,
849
+ ) -> Tensor:
850
+ encoded = self.prepare_multimodal_inputs(
851
+ images=images,
852
+ prompts=prompts,
853
+ detector_outputs=detector_outputs,
854
+ vision_features=vision_features,
855
+ )
856
+ new_tokens = generation_kwargs.get("max_new_tokens")
857
+ if new_tokens is not None:
858
+ if isinstance(new_tokens, bool) or not isinstance(new_tokens, int) or new_tokens < 1:
859
+ raise ValueError("max_new_tokens must be a positive integer")
860
+ self._validate_context_budget(encoded.prefix, additional_tokens=new_tokens)
861
+ return self.language_model.generate(
862
+ inputs_embeds=encoded.prefix.inputs_embeds,
863
+ attention_mask=encoded.prefix.attention_mask,
864
+ **generation_kwargs,
865
+ )
866
+
867
+ def enable_lora(
868
+ self,
869
+ *,
870
+ rank: int = 16,
871
+ alpha: int = 32,
872
+ dropout: float = 0.05,
873
+ target_modules: Sequence[str] = ("q_proj", "k_proj", "v_proj", "o_proj"),
874
+ ) -> None:
875
+ """Attach PEFT LoRA adapters to Vicuna/Llama attention projections."""
876
+
877
+ from peft import LoraConfig, TaskType, get_peft_model
878
+
879
+ if any("lora_" in name for name, _ in self.language_model.named_parameters()):
880
+ raise RuntimeError("LoRA adapters are already attached")
881
+ lora_config = LoraConfig(
882
+ task_type=TaskType.CAUSAL_LM,
883
+ r=rank,
884
+ lora_alpha=alpha,
885
+ lora_dropout=dropout,
886
+ bias="none",
887
+ target_modules=list(target_modules),
888
+ )
889
+ self.language_model = get_peft_model(self.language_model, lora_config)
890
+ # Attaching PEFT makes adapters trainable by default. Preserve the
891
+ # current paper stage until the caller explicitly enters Stage 3.
892
+ self.set_training_stage(self.training_stage)
893
+
894
+ @staticmethod
895
+ def _set_module_trainable(module: nn.Module, trainable: bool) -> None:
896
+ for parameter in module.parameters():
897
+ parameter.requires_grad = trainable
898
+
899
+ def set_training_stage(self, stage: int) -> None:
900
+ """Apply paper freezing: Stage 2 projections+SSA; Stage 3 adds LoRA."""
901
+
902
+ if stage not in (2, 3):
903
+ raise ValueError("the multimodal model supports training stage 2 or 3")
904
+ for parameter in self.parameters():
905
+ parameter.requires_grad = False
906
+ for module in (
907
+ self.patch_aggregator,
908
+ self.region_encoder,
909
+ self.safety_adapter,
910
+ self.image_projection,
911
+ self.region_projection,
912
+ self.region_index_embedding,
913
+ ):
914
+ self._set_module_trainable(module, True)
915
+ self.safety_adapter.freeze_unavailable_heads()
916
+
917
+ if stage == 3:
918
+ lora_parameters = [
919
+ parameter
920
+ for name, parameter in self.language_model.named_parameters()
921
+ if "lora_" in name
922
+ ]
923
+ if not lora_parameters:
924
+ raise RuntimeError("Stage 3 requires enable_lora() before set_training_stage(3)")
925
+ for parameter in lora_parameters:
926
+ parameter.requires_grad = True
927
+ self.training_stage = stage
928
+ self.vision_encoder.eval()
929
+ if isinstance(self.detector, nn.Module):
930
+ self.detector.eval()
931
+ if stage == 2:
932
+ self.language_model.eval()
933
+ elif self.training:
934
+ self.language_model.train()
935
+
936
+ def trainable_parameter_names(self) -> tuple[str, ...]:
937
+ return tuple(name for name, parameter in self.named_parameters() if parameter.requires_grad)
938
+
939
+ def train(self, mode: bool = True) -> FalconModel:
940
+ super().train(mode)
941
+ self.vision_encoder.eval()
942
+ if isinstance(self.detector, nn.Module):
943
+ self.detector.eval()
944
+ if self.training_stage == 2:
945
+ self.language_model.eval()
946
+ return self
947
+
948
+ def _target_tensor(self, value: Any) -> Tensor:
949
+ """Convert nullable structured labels to tensors, representing null as NaN."""
950
+
951
+ def replace_nulls(item: Any) -> Any:
952
+ if item is None:
953
+ return float("nan")
954
+ if isinstance(item, list | tuple):
955
+ return [replace_nulls(child) for child in item]
956
+ return item
957
+
958
+ if isinstance(value, Tensor):
959
+ return value.to(self.device)
960
+ return torch.as_tensor(replace_nulls(value), dtype=torch.float32, device=self.device)
961
+
962
+ def resolve_region_references(
963
+ self,
964
+ text: str,
965
+ detector_output: DetectorBatch | Any,
966
+ *,
967
+ batch_index: int = 0,
968
+ ) -> tuple[ResolvedRegion, ...]:
969
+ """Resolve generated region labels to the exact proposal box and mask."""
970
+
971
+ detected = self._coerce_detector_output(detector_output)
972
+ if not 0 <= batch_index < detected.boxes.shape[0]:
973
+ raise IndexError("batch_index is outside the detector batch")
974
+ assert detected.valid is not None
975
+ resolved = []
976
+ for index in parse_region_references(text, max_regions=self.config.max_regions):
977
+ if index >= detected.boxes.shape[1] or not bool(detected.valid[batch_index, index]):
978
+ continue
979
+ score = None if detected.scores is None else detected.scores[batch_index, index]
980
+ height, width = (int(value) for value in detected.mask_sizes[batch_index])
981
+ resolved.append(
982
+ ResolvedRegion(
983
+ reference=f"region_{index:03d}",
984
+ proposal_index=index,
985
+ box_xyxy=detected.boxes[batch_index, index],
986
+ mask=detected.masks[batch_index, index, :height, :width],
987
+ score=score,
988
+ )
989
+ )
990
+ return tuple(resolved)
model.safetensors.index.json ADDED
The diff for this file is too large to render. See raw diff
 
modeling_falcon.py ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Self-contained Transformers wrapper around the FALCON architecture."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+ from dataclasses import asdict, dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import torch
11
+ from torch import nn
12
+ from transformers import AutoConfig, AutoModel, AutoModelForCausalLM, AutoTokenizer
13
+ from transformers import GenerationConfig, PreTrainedModel
14
+ from transformers.models.auto.image_processing_auto import get_image_processor_class_from_name
15
+ from transformers.utils import ModelOutput
16
+
17
+ # Transformers 4.49 copies only direct relative imports for local model folders.
18
+ from .artifacts import atomic_json as atomic_json
19
+ from .capabilities import SafetyCapabilities
20
+ from .config import load_config as load_config
21
+ from .configuration_falcon import FalconConfig
22
+ from .detector import RFDETRDetector
23
+ from .grounding import resolve_segmentation as resolve_segmentation
24
+ from .model import FalconConfig as CoreConfig
25
+ from .model import FalconModel as CoreModel
26
+ from .prediction import predict, predict_panoptic, predict_segmentation
27
+ from .regions import MaskAwareRegionEncoder as MaskAwareRegionEncoder
28
+ from .safety import StructuredSafetyAdapter as StructuredSafetyAdapter
29
+
30
+
31
+ def _portable(value: Any) -> Any:
32
+ if isinstance(value, dict):
33
+ return {
34
+ key: _portable(item)
35
+ for key, item in value.items()
36
+ if key not in {
37
+ "_name_or_path", "name_or_path", "auto_map", "custom_pipelines",
38
+ "base_model_name_or_path",
39
+ }
40
+ }
41
+ if isinstance(value, (list, tuple)):
42
+ return [_portable(item) for item in value]
43
+ return deepcopy(value)
44
+
45
+
46
+ def _backbone_config(values: dict[str, Any]) -> Any:
47
+ values = deepcopy(values)
48
+ model_type = values.pop("model_type")
49
+ return AutoConfig.for_model(model_type, **values)
50
+
51
+
52
+ def _dtype(value: str | torch.dtype | None) -> torch.dtype:
53
+ if isinstance(value, torch.dtype):
54
+ return value
55
+ if value not in (None, "float32", "float16", "bfloat16"):
56
+ raise ValueError(f"Unsupported backbone dtype: {value!r}")
57
+ return getattr(torch, value or "float32")
58
+
59
+
60
+ @dataclass
61
+ class FalconOutput(ModelOutput):
62
+ loss: Any = None
63
+ language_loss: Any = None
64
+ structured_losses: Any = None
65
+ language_output: Any = None
66
+ safety: Any = None
67
+ region_embeddings: Any = None
68
+ detector_output: Any = None
69
+ prefix: Any = None
70
+
71
+
72
+ class FalconModel(PreTrainedModel):
73
+ config_class = FalconConfig
74
+ base_model_prefix = "core"
75
+ main_input_name = "images"
76
+
77
+ def __init__(self, config: FalconConfig, *, core: CoreModel | None = None) -> None:
78
+ config.validate()
79
+ super().__init__(config)
80
+ if core is None:
81
+ core_values = deepcopy(config.core_config)
82
+ core_values["safety_capabilities"] = SafetyCapabilities.from_dict(
83
+ core_values["safety_capabilities"]
84
+ )
85
+ core_config = CoreConfig(**core_values)
86
+ vision = AutoModel.from_config(
87
+ _backbone_config(config.vision_config),
88
+ torch_dtype=_dtype(config.vision_config.get("torch_dtype")),
89
+ )
90
+ language = AutoModelForCausalLM.from_config(
91
+ _backbone_config(config.language_config),
92
+ torch_dtype=_dtype(config.language_config.get("torch_dtype")),
93
+ )
94
+ processor_values = deepcopy(config.image_processor_config)
95
+ processor_class = get_image_processor_class_from_name(
96
+ processor_values.pop("image_processor_type", "")
97
+ )
98
+ if processor_class is None:
99
+ raise ValueError("Unknown image_processor_type in FALCON package")
100
+ core = CoreModel(
101
+ core_config,
102
+ vision_encoder=vision,
103
+ language_model=language,
104
+ detector=RFDETRDetector.from_config(config.detector_config, device="cpu"),
105
+ image_processor=processor_class.from_dict(processor_values),
106
+ )
107
+ core.enable_lora(**config.lora_config)
108
+ core.set_training_stage(3)
109
+ if config.language_generation_config:
110
+ core.language_model.generation_config = GenerationConfig.from_dict(
111
+ config.language_generation_config
112
+ )
113
+ # This is an explicit saved generation policy, not a policy to
114
+ # regenerate later from the language architecture's defaults.
115
+ core.language_model.generation_config._from_model_config = False
116
+ if core.training_stage != 3:
117
+ raise ValueError("A Hugging Face FALCON model requires Stage 3 weights")
118
+ self.core = core
119
+ # RFDETRDetector is not an nn.Module. Register its actual backend module
120
+ # explicitly so saving, loading, and device movement include every weight.
121
+ self.detector_model = core.detector.torch_model
122
+ self.detector_model.requires_grad_(False).eval()
123
+ self.core.checkpoint_metadata = deepcopy(config.checkpoint_metadata)
124
+ self.core.runtime_config = deepcopy(config.runtime_config)
125
+ self.core.trained_safety_capabilities = core.config.safety_capabilities
126
+ self.core.inference_safety_capabilities = core.config.safety_capabilities
127
+ self.core.inference_ssa_ablation = "none"
128
+
129
+ def __getattr__(self, name: str) -> Any:
130
+ try:
131
+ return super().__getattr__(name)
132
+ except AttributeError:
133
+ core = self.__dict__.get("_modules", {}).get("core")
134
+ if core is not None and hasattr(core, name):
135
+ return getattr(core, name)
136
+ raise
137
+
138
+ @classmethod
139
+ def from_falcon(cls, core: CoreModel) -> FalconModel:
140
+ """Wrap loaded final weights without reallocating the frozen backbones."""
141
+ source = deepcopy(getattr(core, "checkpoint_metadata", {}))
142
+ if core.training_stage != 3 or source.get("training_complete") is not True:
143
+ raise ValueError("Export requires a completed Stage 3 model")
144
+ if source.get("partial_initialization_allowed") or source.get(
145
+ "legacy_initialization_allowed"
146
+ ) or source.get("oracle_detector", "none") != "none":
147
+ raise ValueError("Cannot export partial initialization or oracle models")
148
+ runtime = _portable(core.runtime_config)
149
+ runtime["model"]["vision_model"] = "facebook/dinov2-large"
150
+ runtime["model"]["language_model"] = "lmsys/vicuna-7b-v1.5"
151
+ runtime["experiment"]["name"] = "falcon-x"
152
+ retained = {
153
+ "stage", "training_complete", "checkpoint_format", "safety_capabilities",
154
+ "supervision_capabilities", "ssa_ablation", "require_observed_supervision",
155
+ "observed_supervision_coverage", "dataset_categories",
156
+ "diagnostic_training_reasons", "official_result_eligible", "precision",
157
+ "structured_loss_recipe", "grounding_policy", "grounding_coverage",
158
+ }
159
+ metadata = {key: _portable(value) for key, value in source.items() if key in retained}
160
+ metadata["stage"] = 3
161
+ metadata["config"] = runtime
162
+ vision_config = _portable(core.vision_encoder.config.to_dict())
163
+ language_config = _portable(core.language_model.config.to_dict())
164
+ for values, model in ((vision_config, core.vision_encoder),
165
+ (language_config, core.language_model)):
166
+ values["torch_dtype"] = str(next(model.parameters()).dtype).removeprefix("torch.")
167
+ values["attn_implementation"] = model.config._attn_implementation
168
+ adapters = core.language_model.peft_config
169
+ if set(adapters) != {"default"}:
170
+ raise ValueError("FALCON export requires exactly one default LoRA adapter")
171
+ lora = adapters["default"]
172
+ if lora.bias != "none" or lora.modules_to_save or lora.use_dora or lora.use_rslora:
173
+ raise ValueError("Unsupported LoRA variant in FALCON export")
174
+ config = FalconConfig(
175
+ core_config=asdict(core.config),
176
+ vision_config=vision_config,
177
+ language_config=language_config,
178
+ detector_config=core.detector.export_config(),
179
+ image_processor_config=_portable(core.image_processor.to_dict()),
180
+ lora_config={
181
+ "rank": lora.r, "alpha": lora.lora_alpha,
182
+ "dropout": lora.lora_dropout, "target_modules": sorted(lora.target_modules),
183
+ },
184
+ runtime_config=runtime,
185
+ checkpoint_metadata=metadata,
186
+ language_generation_config=_portable(core.language_model.generation_config.to_dict()),
187
+ )
188
+ return cls(config, core=core)
189
+
190
+ @classmethod
191
+ def from_pretrained(cls, pretrained_model_name_or_path: Any, *args: Any, **kwargs: Any):
192
+ if kwargs.pop("torch_dtype", None) not in (None, "auto"):
193
+ raise ValueError("FALCON preserves each component's saved dtype; omit torch_dtype")
194
+ for name in ("quantization_config", "load_in_8bit", "load_in_4bit", "ignore_mismatched_sizes"):
195
+ if kwargs.get(name):
196
+ raise ValueError(f"FALCON does not support {name}")
197
+ device = kwargs.pop("device", None)
198
+ device_map = kwargs.pop("device_map", None)
199
+ if device_map is not None:
200
+ if isinstance(device_map, dict) and set(device_map) == {""}:
201
+ mapped_device = device_map[""]
202
+ elif isinstance(device_map, str | torch.device | int) and device_map not in (
203
+ "auto", "balanced", "balanced_low_0", "sequential", "disk",
204
+ ):
205
+ mapped_device = device_map
206
+ else:
207
+ raise ValueError("FALCON supports one device; use device='cuda' or .to('cuda')")
208
+ if isinstance(mapped_device, int):
209
+ mapped_device = f"cuda:{mapped_device}"
210
+ if device is not None and torch.device(device) != torch.device(mapped_device):
211
+ raise ValueError("device and device_map disagree")
212
+ device = mapped_device
213
+ tokenizer_kwargs = {
214
+ name: kwargs[name] for name in (
215
+ "cache_dir", "force_download", "local_files_only", "token", "revision",
216
+ ) if name in kwargs
217
+ }
218
+ package_subfolder = kwargs.get("subfolder", "")
219
+ tokenizer_kwargs["subfolder"] = "/".join(
220
+ part for part in (package_subfolder, "tokenizer") if part
221
+ )
222
+ requested_info = kwargs.pop("output_loading_info", False)
223
+ model, info = super().from_pretrained(
224
+ pretrained_model_name_or_path, *args, output_loading_info=True, **kwargs
225
+ )
226
+ issues = {name: info[name] for name in (
227
+ "missing_keys", "unexpected_keys", "mismatched_keys", "error_msgs"
228
+ ) if info.get(name)}
229
+ if issues:
230
+ raise ValueError(f"Incomplete or incompatible FALCON weights: {issues}")
231
+ resolved_revision = getattr(model.config, "_commit_hash", None)
232
+ if resolved_revision:
233
+ tokenizer_kwargs["revision"] = resolved_revision
234
+ model.core.tokenizer = AutoTokenizer.from_pretrained(
235
+ pretrained_model_name_or_path, use_fast=False, trust_remote_code=False,
236
+ **tokenizer_kwargs,
237
+ )
238
+ if model.core.tokenizer.pad_token_id is None:
239
+ model.core.tokenizer.pad_token = model.core.tokenizer.eos_token
240
+ if device is not None:
241
+ model.to(device)
242
+ model.eval()
243
+ return (model, info) if requested_info else model
244
+
245
+ def save_pretrained(self, save_directory: str | Path, **kwargs: Any) -> None:
246
+ if kwargs.get("push_to_hub"):
247
+ raise ValueError("Save the complete folder first, then use push_to_hub() or upload_folder()")
248
+ if self.core.tokenizer is None:
249
+ raise ValueError("A complete FALCON package requires its tokenizer")
250
+ self.config.validate()
251
+ super().save_pretrained(save_directory, **kwargs)
252
+ self.core.tokenizer.save_pretrained(Path(save_directory) / "tokenizer")
253
+ self.core.image_processor.save_pretrained(save_directory)
254
+
255
+ def _apply(self, fn: Any, recurse: bool = True):
256
+ for dtype in (torch.float32, torch.bfloat16):
257
+ if fn(torch.empty(0, dtype=dtype)).dtype != dtype:
258
+ raise ValueError("FALCON uses mixed component dtypes; move devices without casting")
259
+ result = super()._apply(fn, recurse=recurse)
260
+ detector = self.__dict__.get("_modules", {}).get("detector_model")
261
+ if detector is not None:
262
+ self.core.detector.to(next(detector.parameters()).device)
263
+ return result
264
+
265
+ def train(self, mode: bool = True):
266
+ super().train(mode)
267
+ self.detector_model.eval()
268
+ return self
269
+
270
+ def get_input_embeddings(self) -> nn.Module:
271
+ return self.core.language_model.get_input_embeddings()
272
+
273
+ def get_output_embeddings(self) -> nn.Module:
274
+ return self.core.language_model.get_output_embeddings()
275
+
276
+ def forward(self, **kwargs: Any) -> FalconOutput:
277
+ return FalconOutput(**vars(self.core(**kwargs)))
278
+
279
+ def generate(self, **kwargs: Any):
280
+ return self.core.generate(**kwargs)
281
+
282
+ @torch.inference_mode()
283
+ def predict(self, image: Any, prompt: str, *, task: str = "text", **kwargs: Any):
284
+ methods = {
285
+ "text": predict, "segmentation": predict_segmentation, "panoptic": predict_panoptic,
286
+ }
287
+ if task not in methods:
288
+ raise ValueError("task must be text, segmentation, or panoptic")
289
+ return methods[task](self.core, image=image, prompt=prompt, **kwargs)
290
+
291
+ @torch.inference_mode()
292
+ def predict_segmentation(self, image: Any, prompt: str, **kwargs: Any):
293
+ return predict_segmentation(self.core, image=image, prompt=prompt, **kwargs)
294
+
295
+ @torch.inference_mode()
296
+ def predict_panoptic(self, image: Any, prompt: str, **kwargs: Any):
297
+ return predict_panoptic(self.core, image=image, prompt=prompt, **kwargs)
298
+
299
+
300
+ FalconModel.register_for_auto_class("AutoModel")
prediction.py ADDED
@@ -0,0 +1,340 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared live prediction for local and Hugging Face FALCON models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ from collections.abc import Collection, Mapping
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ import torch
13
+ from PIL import Image
14
+
15
+ from .capabilities import SafetyCapabilities
16
+ from .grounding import (
17
+ PanopticPrediction,
18
+ SegmentationPrediction,
19
+ resolve_panoptic,
20
+ resolve_segmentation,
21
+ select_panoptic_category,
22
+ )
23
+ from .model import FalconModel
24
+
25
+
26
+ def _candidate_relations_from_answer(text: str) -> list[dict[str, str]] | None:
27
+ """Extract only explicit observable geometry from a structured model answer.
28
+
29
+ Safety examples are trained to emit a compact JSON object. Risk and
30
+ physical links may be unavailable, so this parser never derives either
31
+ from geometry or component presence.
32
+ """
33
+
34
+ try:
35
+ payload = json.loads(text)
36
+ except (json.JSONDecodeError, TypeError):
37
+ return None
38
+ if not isinstance(payload, dict) or "candidate_relations" not in payload:
39
+ return None
40
+ raw = payload["candidate_relations"]
41
+ if not isinstance(raw, list):
42
+ raise ValueError("generated candidate_relations must be an array")
43
+ horizontal_values = {
44
+ "left_of",
45
+ "right_of",
46
+ "horizontally_aligned",
47
+ "left of",
48
+ "right of",
49
+ "horizontally aligned",
50
+ }
51
+ vertical_values = {"above", "below", "vertically_aligned", "vertically aligned"}
52
+ parsed: list[dict[str, str]] = []
53
+ for index, relation in enumerate(raw):
54
+ if not isinstance(relation, dict):
55
+ raise ValueError(
56
+ f"generated candidate_relations[{index}] must be an object"
57
+ )
58
+ if relation.get("connection_observed") not in (None, False):
59
+ raise ValueError(
60
+ f"generated candidate_relations[{index}] asserts an unavailable "
61
+ "physical connection"
62
+ )
63
+ source = relation.get("src_component")
64
+ target = relation.get("dst_component")
65
+ horizontal = relation.get("horizontal_relation")
66
+ vertical = relation.get("vertical_relation")
67
+ if not all(
68
+ isinstance(value, str) and value.strip() for value in (source, target)
69
+ ):
70
+ raise ValueError(
71
+ f"generated candidate_relations[{index}] requires component names"
72
+ )
73
+ if horizontal is not None and horizontal not in horizontal_values:
74
+ raise ValueError(
75
+ f"generated candidate_relations[{index}] has unsupported "
76
+ "horizontal geometry"
77
+ )
78
+ if vertical is not None and vertical not in vertical_values:
79
+ raise ValueError(
80
+ f"generated candidate_relations[{index}] has unsupported vertical geometry"
81
+ )
82
+ if horizontal is None and vertical is None:
83
+ raise ValueError(
84
+ f"generated candidate_relations[{index}] requires at least one "
85
+ "supported geometry axis"
86
+ )
87
+ parsed_relation = {
88
+ "src_component": source.strip().casefold(),
89
+ "dst_component": target.strip().casefold(),
90
+ }
91
+ if horizontal is not None:
92
+ parsed_relation["horizontal_relation"] = (
93
+ str(horizontal).replace(" ", "_").casefold()
94
+ )
95
+ if vertical is not None:
96
+ parsed_relation["vertical_relation"] = (
97
+ str(vertical).replace(" ", "_").casefold()
98
+ )
99
+ parsed.append(parsed_relation)
100
+ return parsed
101
+
102
+
103
+ def _safety_prediction(safety: Any) -> dict[str, Any]:
104
+ prediction = getattr(safety, "prediction", None)
105
+ if callable(prediction):
106
+ value = prediction(0)
107
+ if not isinstance(value, Mapping):
108
+ raise TypeError("SafetyOutput.prediction() must return a mapping")
109
+ return dict(value)
110
+
111
+ # Compatibility for lightweight callers/tests predating SafetyOutput's
112
+ # serializer. If capabilities are provided they are still honored; real
113
+ # checkpoint-backed models always take the method path above.
114
+ capabilities = getattr(safety, "capabilities", SafetyCapabilities())
115
+ if not isinstance(capabilities, SafetyCapabilities):
116
+ raise TypeError("safety.capabilities must be SafetyCapabilities")
117
+ risk = (
118
+ float(safety.risk_probability[0].detach().float().cpu())
119
+ if capabilities.risk
120
+ else None
121
+ )
122
+ presence = [
123
+ float(value.detach().float().cpu()) if enabled else None
124
+ for value, enabled in zip(
125
+ safety.presence_probabilities[0], capabilities.presence, strict=True
126
+ )
127
+ ]
128
+ links = [
129
+ float(value.detach().float().cpu()) if enabled else None
130
+ for value, enabled in zip(
131
+ safety.link_probabilities[0], capabilities.links, strict=True
132
+ )
133
+ ]
134
+ return {
135
+ "risk": risk,
136
+ "presence": presence,
137
+ "links": links,
138
+ "capabilities": capabilities.as_dict(),
139
+ }
140
+
141
+
142
+ def _generate(
143
+ model: FalconModel,
144
+ *,
145
+ image: Any,
146
+ prompt: str,
147
+ max_new_tokens: int = 128,
148
+ temperature: float = 0.0,
149
+ ) -> tuple[dict[str, Any], Any]:
150
+ """Run generation once and retain the exact selected detector proposals."""
151
+
152
+ if max_new_tokens < 1:
153
+ raise ValueError("max_new_tokens must be positive")
154
+ if temperature < 0:
155
+ raise ValueError("temperature cannot be negative")
156
+ if isinstance(image, str | os.PathLike):
157
+ # Transformers image processors consume decoded pixels, not path
158
+ # strings. Detach from the file handle once here so RF-DETR, DINO, and
159
+ # native-size bookkeeping all see the exact same RGB image object.
160
+ with Image.open(Path(image).expanduser()) as opened:
161
+ image = opened.convert("RGB").copy()
162
+ formatted_prompt = f"USER: {prompt}\nASSISTANT:"
163
+ encoded = model.prepare_multimodal_inputs(images=image, prompts=formatted_prompt)
164
+ # We call the language model directly in order to preserve the exact
165
+ # detector output for grounding resolution, so mirror FalconModel.generate's
166
+ # context check before allocating the generation cache.
167
+ model._validate_context_budget(
168
+ encoded.prefix,
169
+ additional_tokens=max_new_tokens,
170
+ )
171
+ generation_kwargs: dict[str, Any] = {
172
+ "max_new_tokens": max_new_tokens,
173
+ "do_sample": temperature > 0,
174
+ "eos_token_id": model.tokenizer.eos_token_id,
175
+ "pad_token_id": model.tokenizer.pad_token_id,
176
+ }
177
+ if temperature > 0:
178
+ generation_kwargs["temperature"] = temperature
179
+ generated = model.language_model.generate(
180
+ inputs_embeds=encoded.prefix.inputs_embeds,
181
+ attention_mask=encoded.prefix.attention_mask,
182
+ **generation_kwargs,
183
+ )
184
+ text = model.tokenizer.batch_decode(generated, skip_special_tokens=True)[0].strip()
185
+ candidate_relations = _candidate_relations_from_answer(text)
186
+ structured_safety = _safety_prediction(encoded.safety)
187
+ structured_safety["inference_ablation"] = {
188
+ "name": getattr(model, "inference_ssa_ablation", "none"),
189
+ "scope": "diagnostic",
190
+ "paper_trained_ablation_claim": False,
191
+ }
192
+ if candidate_relations is not None:
193
+ structured_safety["candidate_relations"] = candidate_relations
194
+ return {
195
+ "prompt": prompt,
196
+ "answer": text,
197
+ "safety": structured_safety,
198
+ }, encoded
199
+
200
+
201
+ def _resolved_region_payload(
202
+ model: FalconModel,
203
+ text: str,
204
+ detector_output: Any,
205
+ ) -> tuple[dict[str, Any], np.ndarray]:
206
+ resolved = model.resolve_region_references(text, detector_output)
207
+ region_ids = [region.proposal_index for region in resolved]
208
+ boxes = (
209
+ torch.stack([region.box_xyxy for region in resolved]).float().cpu()
210
+ if resolved
211
+ else torch.empty(0, 4)
212
+ )
213
+ scores = [
214
+ None if region.score is None else float(region.score.float().cpu())
215
+ for region in resolved
216
+ ]
217
+ if resolved:
218
+ binary_masks = []
219
+ for region in resolved:
220
+ mask = region.mask.detach()
221
+ if mask.dtype == torch.bool:
222
+ binary = mask
223
+ elif detector_output.masks_are_logits:
224
+ binary = mask >= 0.0
225
+ else:
226
+ binary = mask >= model.config.mask_probability_threshold
227
+ binary_masks.append(binary.to(dtype=torch.bool))
228
+ masks = torch.stack(binary_masks).cpu().numpy()
229
+ else:
230
+ image_sizes = getattr(detector_output, "image_sizes", None)
231
+ if image_sizes is None:
232
+ raise ValueError(
233
+ "detector output lacks native image size for empty prediction"
234
+ )
235
+ sizes = torch.as_tensor(image_sizes).detach().cpu()
236
+ if sizes.shape != (1, 2):
237
+ raise ValueError("single-image inference requires one detector image size")
238
+ height, width = (int(value) for value in sizes[0].tolist())
239
+ if height < 1 or width < 1:
240
+ raise ValueError("detector output contains an invalid native image size")
241
+ masks = np.empty((0, height, width), dtype=bool)
242
+ return {
243
+ "region_ids": region_ids,
244
+ "boxes_xyxy": boxes.tolist(),
245
+ "scores": scores,
246
+ }, masks
247
+
248
+
249
+ @torch.inference_mode()
250
+ def predict(
251
+ model: FalconModel,
252
+ *,
253
+ image: Any,
254
+ prompt: str,
255
+ max_new_tokens: int = 128,
256
+ temperature: float = 0.0,
257
+ ) -> tuple[dict[str, Any], np.ndarray]:
258
+ """Run one live detector→Falcon→LLM pass without any cache dependency."""
259
+
260
+ result, encoded = _generate(
261
+ model,
262
+ image=image,
263
+ prompt=prompt,
264
+ max_new_tokens=max_new_tokens,
265
+ temperature=temperature,
266
+ )
267
+ regions, masks = _resolved_region_payload(
268
+ model,
269
+ result["answer"],
270
+ encoded.detector_output,
271
+ )
272
+ result.update(regions)
273
+ return result, masks
274
+
275
+
276
+ @torch.inference_mode()
277
+ def predict_segmentation(
278
+ model: FalconModel,
279
+ *,
280
+ image: Any,
281
+ prompt: str,
282
+ max_new_tokens: int = 128,
283
+ temperature: float = 0.0,
284
+ ) -> tuple[dict[str, Any], SegmentationPrediction]:
285
+ """Generate and strictly resolve ``<SEG>`` transport against live proposals."""
286
+
287
+ result, encoded = _generate(
288
+ model,
289
+ image=image,
290
+ prompt=prompt,
291
+ max_new_tokens=max_new_tokens,
292
+ temperature=temperature,
293
+ )
294
+ prediction = resolve_segmentation(
295
+ result["answer"],
296
+ encoded.detector_output,
297
+ max_regions=model.config.max_regions,
298
+ )
299
+ result["region_ids"] = list(prediction.region_indices)
300
+ return result, prediction
301
+
302
+
303
+ @torch.inference_mode()
304
+ def predict_panoptic(
305
+ model: FalconModel,
306
+ *,
307
+ image: Any,
308
+ prompt: str,
309
+ category_ids: Collection[int],
310
+ query_category_id: int | None = None,
311
+ max_new_tokens: int = 128,
312
+ temperature: float = 0.0,
313
+ ) -> tuple[dict[str, Any], PanopticPrediction]:
314
+ """Generate panoptic instances, optionally selecting a queried predicted class."""
315
+
316
+ if query_category_id is not None and (
317
+ isinstance(query_category_id, bool)
318
+ or not isinstance(query_category_id, int)
319
+ or query_category_id not in category_ids
320
+ ):
321
+ raise ValueError("query_category_id must belong to the manifest category IDs")
322
+
323
+ result, encoded = _generate(
324
+ model,
325
+ image=image,
326
+ prompt=prompt,
327
+ max_new_tokens=max_new_tokens,
328
+ temperature=temperature,
329
+ )
330
+ prediction = resolve_panoptic(
331
+ result["answer"],
332
+ encoded.detector_output,
333
+ category_ids=category_ids,
334
+ max_regions=model.config.max_regions,
335
+ )
336
+ if query_category_id is not None:
337
+ prediction = select_panoptic_category(prediction, query_category_id)
338
+ result["region_ids"] = [instance.region_index for instance in prediction.instances]
339
+ result["segments_info"] = [dict(segment) for segment in prediction.segments_info]
340
+ return result, prediction
preprocessor_config.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "crop_size": {
3
+ "height": 448,
4
+ "width": 448
5
+ },
6
+ "do_center_crop": true,
7
+ "do_convert_rgb": true,
8
+ "do_normalize": true,
9
+ "do_rescale": true,
10
+ "do_resize": true,
11
+ "image_mean": [
12
+ 0.485,
13
+ 0.456,
14
+ 0.406
15
+ ],
16
+ "image_processor_type": "BitImageProcessor",
17
+ "image_std": [
18
+ 0.229,
19
+ 0.224,
20
+ 0.225
21
+ ],
22
+ "resample": 3,
23
+ "rescale_factor": 0.00392156862745098,
24
+ "size": {
25
+ "height": 448,
26
+ "width": 448
27
+ }
28
+ }
regions.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Patch aggregation and mask-aware region encoding for Falcon.
2
+
3
+ The detector contract used here is deliberately small: boxes are ``xyxy`` in
4
+ original-image coordinates and masks are either logits or probabilities. The
5
+ module never guesses which representation a mask uses and never applies a
6
+ sigmoid; it makes a deterministic binary mask with the declared threshold.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Sequence
12
+
13
+ import torch
14
+ from torch import Tensor, nn
15
+ from torch.nn import functional as F
16
+ from torchvision.ops import roi_align
17
+
18
+
19
+ class PatchTokenAggregator(nn.Module):
20
+ """Concatenate each non-overlapping 2x2 patch block, then project it.
21
+
22
+ DINOv2 emits patch tokens in row-major order. For a block, concatenation
23
+ order is top-left, top-right, bottom-left, bottom-right.
24
+ """
25
+
26
+ def __init__(self, input_dim: int, output_dim: int) -> None:
27
+ super().__init__()
28
+ self.input_dim = input_dim
29
+ self.output_dim = output_dim
30
+ self.projection = nn.Linear(4 * input_dim, output_dim)
31
+
32
+ def forward(self, patch_tokens: Tensor, grid_size: tuple[int, int]) -> Tensor:
33
+ if patch_tokens.ndim != 3:
34
+ raise ValueError("patch_tokens must have shape [batch, patches, channels]")
35
+ batch, count, channels = patch_tokens.shape
36
+ height, width = grid_size
37
+ if channels != self.input_dim:
38
+ raise ValueError(f"expected {self.input_dim} channels, received {channels}")
39
+ if count != height * width:
40
+ raise ValueError(f"{count} tokens cannot form the declared {height}x{width} grid")
41
+ if height % 2 or width % 2:
42
+ raise ValueError("the DINOv2 patch grid must be even for 2x2 aggregation")
43
+
44
+ grid = patch_tokens.reshape(batch, height // 2, 2, width // 2, 2, channels)
45
+ blocks = grid.permute(0, 1, 3, 2, 4, 5).reshape(
46
+ batch, height // 2, width // 2, 4 * channels
47
+ )
48
+ return self.projection(blocks).permute(0, 3, 1, 2).contiguous()
49
+
50
+
51
+ class MaskAwareRegionEncoder(nn.Module):
52
+ """Fuse ROI-aligned and binary-mask-pooled features by concatenation.
53
+
54
+ Invalid/padded proposals are returned as exact zero vectors. ``image_sizes``
55
+ contains ``(height, width)`` for each original detector image.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ feature_dim: int,
61
+ output_dim: int,
62
+ *,
63
+ roi_size: int = 4,
64
+ sampling_ratio: int = 2,
65
+ probability_threshold: float = 0.5,
66
+ ) -> None:
67
+ super().__init__()
68
+ if not 0.0 <= probability_threshold <= 1.0:
69
+ raise ValueError("probability_threshold must lie in [0, 1]")
70
+ self.feature_dim = feature_dim
71
+ self.output_dim = output_dim
72
+ self.roi_size = roi_size
73
+ self.sampling_ratio = sampling_ratio
74
+ self.probability_threshold = probability_threshold
75
+ self.projection = nn.Linear(2 * feature_dim, output_dim)
76
+
77
+ @staticmethod
78
+ def _validate_inputs(
79
+ feature_map: Tensor,
80
+ boxes: Tensor,
81
+ masks: Tensor,
82
+ image_sizes: Tensor,
83
+ valid: Tensor,
84
+ ) -> None:
85
+ if feature_map.ndim != 4:
86
+ raise ValueError("feature_map must have shape [batch, channels, height, width]")
87
+ if boxes.ndim != 3 or boxes.shape[-1] != 4:
88
+ raise ValueError("boxes must have shape [batch, proposals, 4]")
89
+ if masks.ndim != 4 or masks.shape[:2] != boxes.shape[:2]:
90
+ raise ValueError("masks must have shape [batch, proposals, height, width]")
91
+ if image_sizes.shape != (feature_map.shape[0], 2):
92
+ raise ValueError("image_sizes must have shape [batch, 2] in (height, width) order")
93
+ if valid.shape != boxes.shape[:2]:
94
+ raise ValueError("valid must have shape [batch, proposals]")
95
+ if boxes.shape[0] != feature_map.shape[0]:
96
+ raise ValueError("feature_map and detector outputs must have the same batch size")
97
+
98
+ def pool_components(
99
+ self,
100
+ feature_map: Tensor,
101
+ boxes: Tensor,
102
+ masks: Tensor,
103
+ image_sizes: Tensor,
104
+ valid: Tensor | None = None,
105
+ *,
106
+ masks_are_logits: bool = False,
107
+ mask_sizes: Tensor | None = None,
108
+ ) -> tuple[Tensor, Tensor, Tensor]:
109
+ """Return valid proposal indices, ROI vectors, and mask vectors."""
110
+
111
+ if valid is None:
112
+ valid = torch.ones(boxes.shape[:2], dtype=torch.bool, device=boxes.device)
113
+ else:
114
+ valid = valid.to(dtype=torch.bool)
115
+ self._validate_inputs(feature_map, boxes, masks, image_sizes, valid)
116
+
117
+ indices = valid.nonzero(as_tuple=False)
118
+ if indices.numel() == 0:
119
+ empty = feature_map.new_empty((0, self.feature_dim))
120
+ return indices, empty, empty
121
+
122
+ device = feature_map.device
123
+ dtype = feature_map.dtype
124
+ indices = indices.to(device=device)
125
+ boxes = boxes.to(device=device, dtype=dtype)
126
+ masks = masks.to(device=device)
127
+ image_sizes = image_sizes.to(device=device, dtype=dtype)
128
+
129
+ batch_ids, proposal_ids = indices.unbind(dim=1)
130
+ selected_boxes = boxes[batch_ids, proposal_ids].clone()
131
+ source_h = image_sizes[batch_ids, 0].clamp_min(1)
132
+ source_w = image_sizes[batch_ids, 1].clamp_min(1)
133
+ feature_h, feature_w = feature_map.shape[-2:]
134
+ selected_boxes[:, 0::2] *= feature_w / source_w[:, None]
135
+ selected_boxes[:, 1::2] *= feature_h / source_h[:, None]
136
+ rois = torch.cat((batch_ids.to(dtype=dtype).unsqueeze(1), selected_boxes), dim=1)
137
+
138
+ roi_features = roi_align(
139
+ feature_map,
140
+ rois,
141
+ output_size=(self.roi_size, self.roi_size),
142
+ spatial_scale=1.0,
143
+ sampling_ratio=self.sampling_ratio,
144
+ aligned=True,
145
+ )
146
+ roi_vectors = roi_features.mean(dim=(-2, -1))
147
+
148
+ threshold = 0.0 if masks_are_logits else self.probability_threshold
149
+ if mask_sizes is None:
150
+ mask_sizes = torch.tensor([list(masks.shape[-2:])] * feature_map.shape[0])
151
+ if mask_sizes.shape != (feature_map.shape[0], 2):
152
+ raise ValueError("mask_sizes must have shape [batch, 2]")
153
+ binary_masks = feature_map.new_zeros((len(indices), 1, feature_h, feature_w))
154
+ for batch_index in range(feature_map.shape[0]):
155
+ selected = batch_ids == batch_index
156
+ if not bool(selected.any()):
157
+ continue
158
+ height, width = (int(value) for value in mask_sizes[batch_index])
159
+ if not (0 < height <= masks.shape[-2] and 0 < width <= masks.shape[-1]):
160
+ raise ValueError("mask_sizes exceed the padded canvas or are nonpositive")
161
+ selected_masks = masks[batch_index, proposal_ids[selected], :height, :width].unsqueeze(
162
+ 1
163
+ )
164
+ binary = (
165
+ selected_masks
166
+ if selected_masks.dtype == torch.bool
167
+ else selected_masks >= threshold
168
+ )
169
+ binary_masks[selected] = F.interpolate(
170
+ binary.to(dtype=dtype), size=(feature_h, feature_w), mode="nearest"
171
+ ).to(dtype=dtype)
172
+ selected_features = feature_map.index_select(0, batch_ids)
173
+ denominator = binary_masks.sum(dim=(-2, -1)).clamp_min(1.0)
174
+ mask_vectors = (selected_features * binary_masks).sum(dim=(-2, -1)) / denominator
175
+ return indices, roi_vectors, mask_vectors
176
+
177
+ def forward(
178
+ self,
179
+ feature_map: Tensor,
180
+ boxes: Tensor,
181
+ masks: Tensor,
182
+ image_sizes: Tensor | Sequence[tuple[int, int]],
183
+ valid: Tensor | None = None,
184
+ *,
185
+ masks_are_logits: bool = False,
186
+ mask_sizes: Tensor | None = None,
187
+ ) -> Tensor:
188
+ if not isinstance(image_sizes, Tensor):
189
+ image_sizes = torch.as_tensor(image_sizes, device=feature_map.device)
190
+ if valid is None:
191
+ valid = torch.ones(boxes.shape[:2], dtype=torch.bool, device=boxes.device)
192
+ indices, roi_vectors, mask_vectors = self.pool_components(
193
+ feature_map,
194
+ boxes,
195
+ masks,
196
+ image_sizes,
197
+ valid,
198
+ masks_are_logits=masks_are_logits,
199
+ mask_sizes=mask_sizes,
200
+ )
201
+
202
+ batch, proposals = boxes.shape[:2]
203
+ flat_output = feature_map.new_zeros((batch * proposals, self.output_dim))
204
+ if indices.numel() == 0:
205
+ return flat_output.reshape(batch, proposals, self.output_dim)
206
+
207
+ fused = self.projection(torch.cat((roi_vectors, mask_vectors), dim=-1))
208
+ flat_indices = indices[:, 0] * proposals + indices[:, 1]
209
+ flat_output = flat_output.index_copy(0, flat_indices, fused)
210
+ return flat_output.reshape(batch, proposals, self.output_dim)
safety.py ADDED
@@ -0,0 +1,272 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Structured Safety Adapter (SSA) with binary presence supervision."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from dataclasses import dataclass
7
+
8
+ import torch
9
+ from torch import Tensor, nn
10
+ from torch.nn import functional as F
11
+
12
+ from .capabilities import ALL_SAFETY_HEADS, SafetyCapabilities
13
+
14
+ # These indices are the canonical Falcon-X annotation order. The paper's
15
+ # "main charge" is represented by the dataset label ``explosive``.
16
+ COMPONENT_TYPES = ("detonator", "explosive", "battery")
17
+ LINK_PAIRS = ((2, 0), (2, 1), (0, 1))
18
+ SAFETY_TOKEN_TYPES = (
19
+ "scene_risk",
20
+ "presence_detonator",
21
+ "presence_explosive",
22
+ "presence_battery",
23
+ "link_battery_detonator",
24
+ "link_battery_explosive",
25
+ "link_detonator_explosive",
26
+ )
27
+
28
+
29
+ @dataclass
30
+ class SafetyOutput:
31
+ slots: Tensor
32
+ attention: Tensor
33
+ presence_logits: Tensor
34
+ presence_probabilities: Tensor
35
+ link_logits: Tensor
36
+ link_probabilities: Tensor
37
+ risk_logit: Tensor
38
+ risk_probability: Tensor
39
+ tokens: Tensor
40
+ capabilities: SafetyCapabilities = SafetyCapabilities()
41
+
42
+ def prediction(self, batch_index: int = 0) -> dict:
43
+ """Serialize unavailable heads as null, never plausible-looking numbers."""
44
+ return {
45
+ "risk": float(self.risk_probability[batch_index].detach().cpu())
46
+ if self.capabilities.risk
47
+ else None,
48
+ "presence": [
49
+ float(value.detach().cpu()) if enabled else None
50
+ for value, enabled in zip(
51
+ self.presence_probabilities[batch_index],
52
+ self.capabilities.presence,
53
+ strict=True,
54
+ )
55
+ ],
56
+ "links": [
57
+ float(value.detach().cpu()) if enabled else None
58
+ for value, enabled in zip(
59
+ self.link_probabilities[batch_index], self.capabilities.links, strict=True
60
+ )
61
+ ],
62
+ "capabilities": self.capabilities.as_dict(),
63
+ }
64
+
65
+ @property
66
+ def scalar_values(self) -> Tensor:
67
+ """Values in the seven-token paper order: risk, presence, links."""
68
+
69
+ return torch.cat(
70
+ (
71
+ self.risk_probability.unsqueeze(-1),
72
+ self.presence_probabilities,
73
+ self.link_probabilities,
74
+ ),
75
+ dim=-1,
76
+ )
77
+
78
+
79
+ class StructuredSafetyAdapter(nn.Module):
80
+ """Map a variable proposal set to three typed component slots and seven tokens."""
81
+
82
+ def __init__(
83
+ self,
84
+ model_dim: int,
85
+ token_dim: int,
86
+ *,
87
+ head_hidden_dim: int | None = None,
88
+ capabilities: SafetyCapabilities = ALL_SAFETY_HEADS,
89
+ ) -> None:
90
+ super().__init__()
91
+ hidden_dim = head_hidden_dim or model_dim
92
+ self.model_dim = model_dim
93
+ self.token_dim = token_dim
94
+ self.capabilities = capabilities
95
+ self.component_queries = nn.Parameter(torch.empty(3, model_dim))
96
+ nn.init.normal_(self.component_queries, std=0.02)
97
+
98
+ self.presence_head = nn.Linear(model_dim, 1)
99
+ self.link_heads = nn.ModuleList(
100
+ [
101
+ nn.Sequential(
102
+ nn.Linear(2 * model_dim, hidden_dim),
103
+ nn.GELU(),
104
+ nn.Linear(hidden_dim, 1),
105
+ )
106
+ for _ in LINK_PAIRS
107
+ ]
108
+ )
109
+ self.risk_head = nn.Sequential(
110
+ nn.Linear(3 * model_dim + len(LINK_PAIRS), hidden_dim),
111
+ nn.GELU(),
112
+ nn.Linear(hidden_dim, 1),
113
+ )
114
+ self.value_projection = nn.Linear(1, token_dim)
115
+ self.type_embedding = nn.Embedding(len(SAFETY_TOKEN_TYPES), token_dim)
116
+ self.freeze_unavailable_heads()
117
+
118
+ def freeze_unavailable_heads(self) -> None:
119
+ """Reapply after a training-stage change has enabled the shared adapter."""
120
+ modules = [
121
+ (self.presence_head, any(self.capabilities.presence)),
122
+ (self.risk_head, self.capabilities.risk),
123
+ ]
124
+ modules.extend(zip(self.link_heads, self.capabilities.links, strict=True))
125
+ for module, enabled in modules:
126
+ if not enabled:
127
+ for parameter in module.parameters():
128
+ parameter.requires_grad = False
129
+
130
+ def _component_slots(
131
+ self,
132
+ region_embeddings: Tensor,
133
+ valid: Tensor,
134
+ ) -> tuple[Tensor, Tensor]:
135
+ batch, proposals, channels = region_embeddings.shape
136
+ if channels != self.model_dim:
137
+ raise ValueError(f"expected region dimension {self.model_dim}, received {channels}")
138
+ if valid.shape != (batch, proposals):
139
+ raise ValueError("valid must have shape [batch, proposals]")
140
+ if proposals == 0:
141
+ slots = region_embeddings.new_zeros((batch, 3, channels))
142
+ attention = region_embeddings.new_zeros((batch, 3, 0))
143
+ return slots, attention
144
+
145
+ scores = torch.einsum("cd,bnd->bcn", self.component_queries, region_embeddings)
146
+ scores = scores / math.sqrt(float(channels))
147
+ expanded_valid = valid[:, None, :].to(dtype=torch.bool)
148
+ scores = scores.masked_fill(~expanded_valid, torch.finfo(scores.dtype).min)
149
+ attention = scores.softmax(dim=-1) * expanded_valid.to(dtype=scores.dtype)
150
+ attention = attention / attention.sum(dim=-1, keepdim=True).clamp_min(1e-12)
151
+ slots = torch.einsum("bcn,bnd->bcd", attention, region_embeddings)
152
+ return slots, attention
153
+
154
+ def forward(
155
+ self,
156
+ region_embeddings: Tensor,
157
+ valid: Tensor | None = None,
158
+ ) -> SafetyOutput:
159
+ if region_embeddings.ndim != 3:
160
+ raise ValueError("region_embeddings must have shape [batch, proposals, channels]")
161
+ if valid is None:
162
+ valid = torch.ones(
163
+ region_embeddings.shape[:2],
164
+ dtype=torch.bool,
165
+ device=region_embeddings.device,
166
+ )
167
+ else:
168
+ valid = valid.to(device=region_embeddings.device, dtype=torch.bool)
169
+
170
+ slots, attention = self._component_slots(region_embeddings, valid)
171
+ presence_logits = (
172
+ self.presence_head(slots).squeeze(-1)
173
+ if any(self.capabilities.presence)
174
+ else slots.new_zeros(slots.shape[:2])
175
+ )
176
+ link_logits = torch.stack(
177
+ [
178
+ head(torch.cat((slots[:, left], slots[:, right]), dim=-1)).squeeze(-1)
179
+ if enabled
180
+ else slots.new_zeros(slots.shape[0])
181
+ for head, (left, right), enabled in zip(
182
+ self.link_heads, LINK_PAIRS, self.capabilities.links, strict=True
183
+ )
184
+ ],
185
+ dim=-1,
186
+ )
187
+ link_probabilities = link_logits.sigmoid() * slots.new_tensor(self.capabilities.links)
188
+
189
+ # Risk is explicitly conditioned on every slot and the three inferred links.
190
+ risk_input = torch.cat((slots.flatten(start_dim=1), link_probabilities), dim=-1)
191
+ risk_logit = (
192
+ self.risk_head(risk_input).squeeze(-1)
193
+ if self.capabilities.risk
194
+ else slots.new_zeros(slots.shape[0])
195
+ )
196
+ risk_probability = risk_logit.sigmoid() * int(self.capabilities.risk)
197
+ presence_probabilities = presence_logits.sigmoid() * slots.new_tensor(
198
+ self.capabilities.presence
199
+ )
200
+
201
+ scalar_values = torch.cat(
202
+ (
203
+ risk_probability.unsqueeze(-1),
204
+ presence_probabilities,
205
+ link_probabilities,
206
+ ),
207
+ dim=-1,
208
+ )
209
+ type_ids = torch.tensor(
210
+ self.capabilities.token_indices, device=slots.device, dtype=torch.long
211
+ )
212
+ tokens = self.value_projection(scalar_values[:, type_ids].unsqueeze(-1))
213
+ tokens = tokens + self.type_embedding(type_ids).unsqueeze(0)
214
+ return SafetyOutput(
215
+ slots=slots,
216
+ attention=attention,
217
+ presence_logits=presence_logits,
218
+ presence_probabilities=presence_probabilities,
219
+ link_logits=link_logits,
220
+ link_probabilities=link_probabilities,
221
+ risk_logit=risk_logit,
222
+ risk_probability=risk_probability,
223
+ tokens=tokens,
224
+ capabilities=self.capabilities,
225
+ )
226
+
227
+ @staticmethod
228
+ def loss(
229
+ output: SafetyOutput,
230
+ *,
231
+ risk: Tensor,
232
+ presence: Tensor,
233
+ links: Tensor,
234
+ ) -> dict[str, Tensor]:
235
+ """Stage-2/3 objective: presence BCE with logits and risk/link mean L1."""
236
+
237
+ risk_target = risk.to(output.risk_probability).reshape_as(output.risk_probability)
238
+ presence_logits = output.presence_logits.float()
239
+ presence_target = presence.to(
240
+ device=presence_logits.device, dtype=torch.float32
241
+ ).reshape_as(presence_logits)
242
+ link_target = links.to(output.link_probabilities).reshape_as(output.link_probabilities)
243
+ if not output.capabilities.risk:
244
+ risk_target = torch.full_like(risk_target, float("nan"))
245
+ presence_target = presence_target.masked_fill(
246
+ ~torch.tensor(output.capabilities.presence, device=presence_target.device), float("nan")
247
+ )
248
+ link_target = link_target.masked_fill(
249
+ ~torch.tensor(output.capabilities.links, device=link_target.device), float("nan")
250
+ )
251
+
252
+ def finite_l1(prediction: Tensor, target: Tensor) -> Tensor:
253
+ finite = torch.isfinite(target)
254
+ if finite.any():
255
+ return F.l1_loss(prediction[finite], target[finite])
256
+ # Keep a differentiable zero when a sample has no annotated links.
257
+ return prediction.sum() * 0.0
258
+
259
+ risk_loss = finite_l1(output.risk_probability, risk_target)
260
+ observed_presence = torch.isfinite(presence_target)
261
+ if observed_presence.any():
262
+ presence_loss = F.binary_cross_entropy_with_logits(
263
+ presence_logits[observed_presence], presence_target[observed_presence]
264
+ )
265
+ else:
266
+ presence_loss = presence_logits.sum() * 0.0
267
+ link_loss = finite_l1(output.link_probabilities, link_target)
268
+ return {
269
+ "risk": risk_loss,
270
+ "presence": presence_loss,
271
+ "links": link_loss,
272
+ }
tokenizer/special_tokens_map.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token": {
3
+ "content": "<s>",
4
+ "lstrip": false,
5
+ "normalized": false,
6
+ "rstrip": false,
7
+ "single_word": false
8
+ },
9
+ "eos_token": {
10
+ "content": "</s>",
11
+ "lstrip": false,
12
+ "normalized": false,
13
+ "rstrip": false,
14
+ "single_word": false
15
+ },
16
+ "pad_token": {
17
+ "content": "<unk>",
18
+ "lstrip": false,
19
+ "normalized": false,
20
+ "rstrip": false,
21
+ "single_word": false
22
+ },
23
+ "unk_token": {
24
+ "content": "<unk>",
25
+ "lstrip": false,
26
+ "normalized": false,
27
+ "rstrip": false,
28
+ "single_word": false
29
+ }
30
+ }
tokenizer/tokenizer.model ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e556afd44213b6bd1be2b850ebbbd98f5481437a8021afaf58ee7fb1818d347
3
+ size 499723
tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "add_bos_token": true,
3
+ "add_eos_token": false,
4
+ "add_prefix_space": true,
5
+ "added_tokens_decoder": {
6
+ "0": {
7
+ "content": "<unk>",
8
+ "lstrip": false,
9
+ "normalized": false,
10
+ "rstrip": false,
11
+ "single_word": false,
12
+ "special": true
13
+ },
14
+ "1": {
15
+ "content": "<s>",
16
+ "lstrip": false,
17
+ "normalized": false,
18
+ "rstrip": false,
19
+ "single_word": false,
20
+ "special": true
21
+ },
22
+ "2": {
23
+ "content": "</s>",
24
+ "lstrip": false,
25
+ "normalized": false,
26
+ "rstrip": false,
27
+ "single_word": false,
28
+ "special": true
29
+ }
30
+ },
31
+ "bos_token": "<s>",
32
+ "clean_up_tokenization_spaces": false,
33
+ "eos_token": "</s>",
34
+ "extra_special_tokens": {},
35
+ "legacy": false,
36
+ "model_max_length": 4096,
37
+ "pad_token": "<unk>",
38
+ "padding_side": "right",
39
+ "sp_model_kwargs": {},
40
+ "spaces_between_special_tokens": false,
41
+ "tokenizer_class": "LlamaTokenizer",
42
+ "unk_token": "<unk>",
43
+ "use_default_system_prompt": false
44
+ }