MirilAI commited on
Commit
774f63b
·
verified ·
1 Parent(s): 856bc1e

Stage Miril-DroneVLM-2B-2 release

Browse files
.gitattributes CHANGED
@@ -33,3 +33,6 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ assets/qualitative_examples.png filter=lfs diff=lfs merge=lfs -text
37
+ technical_report.pdf filter=lfs diff=lfs merge=lfs -text
38
+ tokenizer.json filter=lfs diff=lfs merge=lfs -text
LICENSE 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,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Miril-DroneVLM-2B-2
2
+ Copyright 2026 Miril
3
+
4
+ Miril-DroneVLM-2B-2 is an aerial vision-language model derived from
5
+ google/gemma-4-E2B-it by Google DeepMind.
6
+
7
+ The base model google/gemma-4-E2B-it is published by Google DeepMind
8
+ under the Apache License, Version 2.0.
9
+
10
+ Miril modifications adapt the base model for aerial scene understanding,
11
+ drone visual question answering, typed JSON routing, and structured
12
+ image-relative coordinate outputs.
13
+
14
+ This distribution is not endorsed by or affiliated with Google,
15
+ Google DeepMind, or the Gemma authors.
README.md ADDED
@@ -0,0 +1,571 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ base_model: google/gemma-4-E2B-it
4
+ library_name: transformers
5
+ pipeline_tag: image-text-to-text
6
+ language:
7
+ - en
8
+ tags:
9
+ - aerial-vlm
10
+ - drone
11
+ - uav
12
+ - vision-language-model
13
+ - aerial-imagery
14
+ - scene-understanding
15
+ - visual-question-answering
16
+ - visual-grounding
17
+ - robotics
18
+ - edge-ai
19
+ - structured-output
20
+ - open-weights
21
+ - miril
22
+ ---
23
+
24
+ # Miril-DroneVLM-2B-2
25
+
26
+ ### Drones can talk.
27
+
28
+ **Enabling drones to reason and talk about their environment.**
29
+
30
+ Ask a drone what it sees. Get an answer software can use.
31
+
32
+ Miril-DroneVLM-2B-2 is an open-weight, 2B-class aerial vision-language model from [Miril.ai](https://miril.ai). Give it an overhead image and an ordinary English question. It returns one of four machine-readable JSON responses: a whole-scene caption, a factual answer, a landing or delivery location, or a point on one visible target.
33
+
34
+ A drone camera can collect more imagery than a person can inspect frame by frame. This model explores a practical interface for that stream: ask what is present, what is happening, where an object is, or which part of the frame deserves review, then pass the typed result to an operator interface or separately validated application.
35
+
36
+ The target applications are civilian: delivery, first response, infrastructure inspection, agriculture, construction, mapping, and disaster recovery. Miril-DroneVLM-2B-2 is an edge-oriented research model for testing how those systems can become easier to supervise and integrate. It is a perception component, not a flight controller.
37
+
38
+ Try the image and video demo:
39
+
40
+ **[Miril-DroneVLM-2B-2-Demo](https://huggingface.co/spaces/MirilAI/Miril-DroneVLM-2B-2-Demo)**
41
+
42
+ ## Four Questions, Four Usable Results
43
+
44
+ ![Caption, answer, pointing, and landing examples](./assets/qualitative_examples.png)
45
+
46
+ These are deterministic release-checkpoint MLX 8-bit outputs on attributed Pexels images
47
+ outside the training corpus. Each panel shows the ordinary user question, the
48
+ parsed response, and any drawable model coordinate. The examples were manually
49
+ screened for presentation and are not an accuracy sample; the complete
50
+ held-out results appear below.
51
+
52
+ ## One Question In, One Typed Result Out
53
+
54
+ The user does **not** choose a schema or append formatting instructions. The user asks a normal question:
55
+
56
+ ```text
57
+ What am I looking at?
58
+ ```
59
+
60
+ ```text
61
+ How many light vehicles are visible?
62
+ ```
63
+
64
+ ```text
65
+ Choose a place to lower this parcel.
66
+ ```
67
+
68
+ ```text
69
+ Track the white car.
70
+ ```
71
+
72
+ The application supplies the fixed system contract from [`router_contract.py`](./router_contract.py). The model interprets the requested operation and returns exactly one bare JSON object.
73
+
74
+ The public runtime contract is `typed_json_router_v2`. Pin that contract file with the model revision when integrating the model into another application.
75
+
76
+ | Requested result | JSON `type` | Secondary key | Downstream use |
77
+ | --- | --- | --- | --- |
78
+ | General read of the whole image | `caption` | none | Display or index a scene description |
79
+ | Fact, count, presence, appearance, hazard, or verbal explanation | `answer` | none | Display or consume a textual answer |
80
+ | Choose one landing or delivery area | `location` | `intent: landing` or `delivery` | Review a selected point or broad direction |
81
+ | Locate, mark, or track one target | `pointing` | `action: locate`, `point`, or `track` | Draw a point or initialize downstream tracking |
82
+
83
+ This separation is the point of the release. The response identifies its own type, so downstream code can dispatch deterministically instead of trying to classify free-form prose after generation.
84
+
85
+ ## Prompting In Plain English
86
+
87
+ Prompt for the result you need, not for the JSON structure:
88
+
89
+ | What you want | Natural examples | Response |
90
+ | --- | --- | --- |
91
+ | A read of the complete frame | “What am I looking at?”, “Describe the whole scene.” | `caption` |
92
+ | A fact or explanation | “Any buses?”, “How many people?”, “What should I avoid here?” | `answer` |
93
+ | A candidate landing or delivery area | “Where could I land?”, “Pick a clear place for this parcel.” | `location` |
94
+ | One visible target | “Where is the white car?”, “Mark the storage tank.”, “Track that truck.” | `pointing` |
95
+
96
+ These are intent families, not a keyword list. For example, “Where are the cars concentrated?” asks for a verbal answer, while “Where is the white car?” asks for one point.
97
+
98
+ ### `caption`
99
+
100
+ Use a general request for the whole frame:
101
+
102
+ ```text
103
+ What am I looking at?
104
+ ```
105
+
106
+ ```json
107
+ {
108
+ "type": "caption",
109
+ "caption": "From above, one built-up structure anchors the upper-left corner of a tidy property, bordered by asphalt, bright grass, driveways, and trimmed greenery."
110
+ }
111
+ ```
112
+
113
+ ### `answer`
114
+
115
+ Use a question whose result should be words rather than one image point. This includes counts, object presence, colors, scene type, relative position, risks, suitability, and areas to avoid.
116
+
117
+ ```text
118
+ Give the visible tally for utility poles.
119
+ ```
120
+
121
+ ```json
122
+ {
123
+ "type": "answer",
124
+ "answer": "Six utility poles can be seen around the scene."
125
+ }
126
+ ```
127
+
128
+ `Where` does not automatically mean pointing. “Where are the vehicles concentrated?” asks for a verbal description and routes to `answer`. “Where is the white car?” asks for one concrete image point and routes to `pointing`.
129
+
130
+ ### `location`
131
+
132
+ Use this only when asking the model to choose an area for landing a drone or placing a delivery. A location response may contain a precise point, a broad directional cue, or no point at all. Its status tells the application which interpretation is valid.
133
+
134
+ ```text
135
+ Find a usable parcel drop location, if the view contains one.
136
+ ```
137
+
138
+ A broad directional result can look like this:
139
+
140
+ ```json
141
+ {
142
+ "type": "location",
143
+ "intent": "delivery",
144
+ "caption": "Coarse grid direction only, not a recommended landing or delivery spot. General safest-looking region: the open bare area on the right side of the road, away from vegetation and the building.",
145
+ "coordinate_system": "gemma_relative_0_1000_yx",
146
+ "point_2d": [500, 750],
147
+ "point_semantics": "representative_point",
148
+ "pointing_mode": "coarse_grid_direction",
149
+ "status": "coarse_direction",
150
+ "x": 750,
151
+ "y": 500
152
+ }
153
+ ```
154
+
155
+ That output identifies a direction in the frame. It does **not** identify an approved parcel drop or landing spot.
156
+
157
+ ### `pointing`
158
+
159
+ Use this when one concrete visible point is the requested result.
160
+
161
+ ```text
162
+ Where in the image is the center of the white-roofed structure?
163
+ ```
164
+
165
+ ```json
166
+ {
167
+ "type": "pointing",
168
+ "action": "locate",
169
+ "caption": "The white-roofed structure is centered in the lower-right.",
170
+ "coordinate_system": "gemma_relative_0_1000_yx",
171
+ "point_2d": [936, 956],
172
+ "point_semantics": "specific_point",
173
+ "pointing_mode": "precise_point",
174
+ "status": "target_found",
175
+ "x": 956,
176
+ "y": 936
177
+ }
178
+ ```
179
+
180
+ The action follows the user’s intent:
181
+
182
+ | User intent | Typical wording | `action` |
183
+ | --- | --- | --- |
184
+ | Find or look at one target | “Where is…”, “Find…”, “Look at…” | `locate` |
185
+ | Put a marker on one target | “Point at…”, “Mark…”, “Highlight…” | `point` |
186
+ | Supply the first point for temporal tracking | “Track…”, “Follow…”, “Lock on…” | `track` |
187
+
188
+ The model provides only the initial image point for `track`. Your application performs tracking across later video frames. The included live demo starts from a 60 x 60 region in the processed model frame, maps that region back to source-video pixels, and removes the marker if optical flow fails or any edge of the tracked box touches the frame.
189
+
190
+ ## Coordinate Contract
191
+
192
+ Spatial responses use `gemma_relative_0_1000_yx`:
193
+
194
+ - `x=0` is the left edge and `x=1000` is the right edge.
195
+ - `y=0` is the top edge and `y=1000` is the bottom edge.
196
+ - `point_2d` is `[y, x]`.
197
+ - `x` and `y` repeat the same values for simpler downstream access.
198
+
199
+ There are three visual outcomes. They are deliberately different because a broad cue, a grounded point, and an abstention must not look interchangeable in an operator interface.
200
+
201
+ ### Precise point
202
+
203
+ `pointing_mode: precise_point` is drawable only when the response is schema-valid and uses `status: target_found`. It represents one image-grounded point. A UI may draw a compact reticle there. For a `track` request it may also initialize a tracker.
204
+
205
+ ### Coarse direction
206
+
207
+ `pointing_mode: coarse_grid_direction` is allowed only for `location`. It uses a 3 x 3 grid where both coordinates are exactly `250`, `500`, or `750`.
208
+
209
+ Treat it as “look in this part of the frame,” not “land here.” Draw a broad region cue, never a precision reticle. Do not initialize a tracker or send it to a controller as a target.
210
+
211
+ ### No point
212
+
213
+ `pointing_mode: none`, invalid JSON, an invalid schema, or a non-target status means there is no drawable point. Coordinate fields must be null. If a malformed response combines a non-target status with coordinates, ignore those coordinates.
214
+
215
+ Interpret `status` by response type:
216
+
217
+ | Response | Status | Meaning |
218
+ | --- | --- | --- |
219
+ | `location` | `target_found` | One image-grounded landing or delivery candidate was selected |
220
+ | `location` | `coarse_direction` | Broad 3 x 3 directional context only |
221
+ | `location` | `no_safe_area` | No usable area should be marked |
222
+ | `location` | `unknown` | The image does not support a reliable judgment |
223
+ | `pointing` | `target_found` | One requested target was grounded |
224
+ | `pointing` | `no_target` | The requested object is absent |
225
+ | `pointing` | `no_matching_target` | Objects may be present, but none satisfy the requested relation |
226
+ | `pointing` | `ambiguous_target` | More than one target fits and the request does not identify one |
227
+ | `pointing` | `unknown` | The image does not support a reliable point |
228
+
229
+ The included validator enforces these rules before returning a dispatch-safe payload. Applications should still retain the original model text for audit rather than silently repairing it.
230
+
231
+ ## Mixed Requests
232
+
233
+ The fixed router applies this precedence:
234
+
235
+ 1. A request to choose a landing or delivery area routes to `location`.
236
+ 2. Otherwise, an explicit request for one concrete point routes to `pointing`.
237
+ 3. Otherwise, a factual or verbal question routes to `answer`.
238
+ 4. Otherwise, a whole-scene read routes to `caption`.
239
+
240
+ If several indistinguishable targets are named, the model should preserve the requested pointing action but return `ambiguous_target` with null coordinates instead of choosing arbitrarily. If different pointing actions are mixed for one target, `track` takes priority over `point`, and `point` takes priority over `locate`.
241
+
242
+ ## Loader Compatibility
243
+
244
+ Use `transformers>=5.12.1`. Gemma 4 E2B declares 35 language layers and 20 shared-KV layers. Layers 15 through 34 reuse key/value states, so a correct export does not contain separate `k_proj`, `v_proj`, or `k_norm` tensors for those shared layers. Release validation checks this layout directly and rejects both genuinely missing owner tensors and redundant shared-layer tensors.
245
+
246
+ The retained [shared-KV export audit](./shared_kv_export_report.json) records
247
+ the expected and observed tensor owners for this exact artifact.
248
+
249
+ ## Quickstart
250
+
251
+ Download the small runtime helpers from this repository, install their dependencies, and ask a plain-English question:
252
+
253
+ ```bash
254
+ hf download MirilAI/Miril-DroneVLM-2B-2 inference.py router_contract.py requirements.txt --local-dir miril-drone-runtime
255
+ python -m pip install -r miril-drone-runtime/requirements.txt
256
+ python miril-drone-runtime/inference.py \
257
+ --image drone_frame.jpg \
258
+ --prompt "Where is the white car?"
259
+ ```
260
+
261
+ The script:
262
+
263
+ - sends the exact fixed router prompt as a system message,
264
+ - sends the image and ordinary English question as the user message,
265
+ - decodes deterministically,
266
+ - requires valid bare JSON,
267
+ - validates the complete response schema,
268
+ - refuses to dispatch or draw malformed coordinates.
269
+
270
+ For direct integration, import `TYPED_JSON_ROUTER_SYSTEM_PROMPT`, `typed_response_errors`, and `drawable_point` from [`router_contract.py`](./router_contract.py).
271
+
272
+ ### Experimental spoken question
273
+
274
+ The inherited Gemma audio path can be exercised with the same image and router contract. This checkpoint was fine-tuned with typed questions, so use the published typed-versus-spoken benchmark to judge the modality gap rather than assuming parity. Install `ffmpeg`, then run:
275
+
276
+ ```bash
277
+ python miril-drone-runtime/inference.py \
278
+ --image drone_frame.jpg \
279
+ --audio spoken_question.mp3 \
280
+ --audio-transcript "Where is the white car?"
281
+ ```
282
+
283
+ `--audio-transcript` is optional provenance for the returned audit record. It is never supplied to the model; the user turn contains the image and decoded audio waveform only.
284
+
285
+ ## Image And Video Overlays
286
+
287
+ The same black-side-panel presentation used by the demo is included as runnable code. Labels are bold green, every response is shown beside the question that produced it, and the panel is half the image width.
288
+
289
+ ```bash
290
+ hf download MirilAI/Miril-DroneVLM-2B-2 \
291
+ inference.py router_contract.py image_overlay.py video_overlay.py requirements.txt \
292
+ --local-dir miril-drone-runtime
293
+ ```
294
+
295
+ Image:
296
+
297
+ ```bash
298
+ python miril-drone-runtime/image_overlay.py \
299
+ --image drone_frame.jpg \
300
+ --output overlay.jpg \
301
+ --prompt "What am I looking at?" \
302
+ --prompt "Are any people or light vehicles visible?" \
303
+ --prompt "Where is the white car?"
304
+ ```
305
+
306
+ Video, with one inference pass every five seconds:
307
+
308
+ ```bash
309
+ python miril-drone-runtime/video_overlay.py \
310
+ --input drone_video.mp4 \
311
+ --output drone_video_overlay.mp4 \
312
+ --interval-seconds 5 \
313
+ --crop center-square \
314
+ --crop-size 1000 \
315
+ --prompt "What am I looking at?" \
316
+ --prompt "Track the white car."
317
+ ```
318
+
319
+ The renderer preserves the source frame rate, reveals each answer over 12 frames without changing its font size during the reveal, tracks valid precise points between inference calls, hides failed or edge-adjacent tracks, and never tracks a coarse direction or non-target response.
320
+
321
+ ## Evaluation
322
+
323
+ Release evaluation has three complementary scopes:
324
+
325
+ 1. **Complete held-out validation:** every V2 release artifact is evaluated on every accepted validation request. This is the primary quantization-regression check for merged BF16, CUDA bnb8, CUDA bnb4, MLX 8-bit, and MLX 4-bit.
326
+ 2. **Independent route benchmark:** a separately authored held-out benchmark balances captioning, factual answers, landing selection, delivery selection, object location, visual pointing, and tracker initialization. It supports controlled V1/V2 and cross-runtime comparison without reusing training questions.
327
+ 3. **Cleaned held-out audit:** every unchanged release artifact is evaluated after a stricter review removes pointing targets that are too small under the model's actual image-preprocessing contract. Validation and test are complete, and strict-cleaned and coverage-matched views are reported separately.
328
+
329
+ All scopes check route selection, strict JSON, schema validity, text overlap, status and mode agreement, null-coordinate discipline, coarse-grid agreement, and precise-coordinate recovery. No single aggregate can show all of those behaviors, so the release reports them separately. Publication requires complete prediction coverage and the exact case-file hash for every reported artifact.
330
+
331
+ <!-- MIRIL_RELEASE_METRICS_START -->
332
+ ![Generation comparison](./assets/generation_comparison.svg)
333
+
334
+ ### Generation comparison
335
+
336
+ | Metric | V1 merged BF16 | Merged BF16 |
337
+ |---|---:|---:|
338
+ | Valid JSON | 92.0% | 100.0% |
339
+ | Schema valid | 21.8% | 96.1% |
340
+ | Route accuracy | 21.8% | 94.8% |
341
+ | Caption / answer F1 | 26.2% | 38.2% |
342
+ | Spatial status | 2.9% | 79.2% |
343
+ | Precise target retained | 7.5% | 50.2% |
344
+ | Point within 100 | 3.9% | 33.4% |
345
+ | Coarse direction exact | 0.0% | 35.0% |
346
+ | No-target discipline | 43.9% | 93.4% |
347
+
348
+ ![Deployment comparison](./assets/deployment_comparison.svg)
349
+
350
+ ### Deployment comparison
351
+
352
+ | Metric | Merged BF16 | CUDA bnb8 | CUDA bnb4 | MLX 8-bit | MLX 4-bit |
353
+ |---|---:|---:|---:|---:|---:|
354
+ | Valid JSON | 100.0% | 99.1% | 96.7% | 100.0% | 100.0% |
355
+ | Schema valid | 96.1% | 93.6% | 92.1% | 94.6% | 55.8% |
356
+ | Route accuracy | 94.8% | 91.6% | 91.2% | 93.4% | 56.9% |
357
+ | Caption / answer F1 | 38.2% | 19.6% | 37.2% | 37.3% | 35.1% |
358
+ | Spatial status | 79.2% | 63.4% | 69.7% | 77.9% | 77.3% |
359
+ | Precise target retained | 50.2% | 42.4% | 53.1% | 51.2% | 46.9% |
360
+ | Point within 100 | 33.4% | 8.0% | 29.7% | 31.0% | 25.1% |
361
+ | Coarse direction exact | 35.0% | 9.3% | 30.2% | 32.1% | 27.6% |
362
+ | No-target discipline | 93.4% | 89.6% | 87.5% | 92.6% | 91.6% |
363
+
364
+ ### Complete held-out validation
365
+
366
+ ![Complete validation comparison](./assets/full_validation_comparison.svg)
367
+
368
+ | Metric | Merged BF16 | CUDA bnb8 | CUDA bnb4 | MLX 8-bit | MLX 4-bit |
369
+ |---|---:|---:|---:|---:|---:|
370
+ | Valid JSON | 99.9% | 99.7% | 99.0% | 100.0% | 100.0% |
371
+ | Schema valid | 99.9% | 99.7% | 98.7% | 100.0% | 53.0% |
372
+ | Route accuracy | 99.9% | 99.6% | 98.6% | 100.0% | 53.2% |
373
+ | Caption / answer F1 | 38.6% | 23.0% | 37.2% | 37.8% | 35.6% |
374
+ | Spatial status | 86.1% | 70.1% | 79.8% | 85.3% | 82.8% |
375
+ | Precise target retained | 71.7% | 60.7% | 73.6% | 71.2% | 68.8% |
376
+ | Point within 100 | 38.3% | 9.2% | 34.0% | 33.6% | 27.8% |
377
+ | Coarse direction exact | 34.4% | 11.3% | 31.2% | 33.0% | 25.8% |
378
+ | No-target discipline | 96.3% | 93.3% | 92.4% | 95.6% | 95.5% |
379
+
380
+ ### Cleaned held-out deployment audit
381
+
382
+ ![Cleaned test comparison](./assets/cleaned_test_comparison.svg)
383
+
384
+ After training, a stricter held-out audit removed pointing rows whose targets fall below the model-visible size threshold, then ran every release artifact on the complete revised validation and test splits. Strict-cleaned rows use only accepted evidence. Coverage-matched rows add evidence-preserving questions on the same held-out images to restore the earlier route and pointing action/status mix; they do not recreate the earlier object-class histogram.
385
+
386
+ #### Final held-out test
387
+
388
+ ##### Strict-cleaned evidence
389
+
390
+ | Metric | Merged BF16 - Strict cleaned test | CUDA bnb8 - Strict cleaned test | CUDA bnb4 - Strict cleaned test | MLX 8-bit - Strict cleaned test | MLX 4-bit - Strict cleaned test |
391
+ |---|---:|---:|---:|---:|---:|
392
+ | Valid JSON | 99.8% | 99.6% | 89.3% | 100.0% | 100.0% |
393
+ | Schema valid | 99.8% | 99.6% | 89.2% | 100.0% | 62.8% |
394
+ | Route accuracy | 99.8% | 99.5% | 89.1% | 100.0% | 63.3% |
395
+ | Reference text F1 | 60.9% | 47.4% | 53.5% | 60.0% | 57.2% |
396
+ | Spatial status | 88.0% | 76.7% | 72.0% | 86.1% | 83.8% |
397
+ | Coordinate quality | 89.7% | 84.0% | 78.9% | 88.4% | 86.1% |
398
+ | Precise target retained | 65.2% | 52.6% | 60.3% | 64.8% | 61.3% |
399
+ | Point within 100 | 42.6% | 9.4% | 34.3% | 39.5% | 31.4% |
400
+ | No-target discipline | 96.5% | 97.1% | 85.1% | 95.1% | 93.4% |
401
+
402
+ ##### Coverage-matched evidence
403
+
404
+ | Metric | Merged BF16 - Coverage-matched test | CUDA bnb8 - Coverage-matched test | CUDA bnb4 - Coverage-matched test | MLX 8-bit - Coverage-matched test | MLX 4-bit - Coverage-matched test |
405
+ |---|---:|---:|---:|---:|---:|
406
+ | Valid JSON | 99.9% | 99.6% | 89.4% | 100.0% | 100.0% |
407
+ | Schema valid | 99.9% | 99.5% | 89.3% | 100.0% | 50.2% |
408
+ | Route accuracy | 99.9% | 99.5% | 89.1% | 100.0% | 50.5% |
409
+ | Reference text F1 | 64.3% | 50.3% | 56.6% | 63.4% | 60.6% |
410
+ | Spatial status | 84.2% | 64.1% | 70.8% | 82.5% | 79.0% |
411
+ | Coordinate quality | 84.9% | 77.6% | 74.7% | 83.6% | 80.9% |
412
+ | Precise target retained | 63.5% | 49.9% | 60.0% | 62.8% | 59.7% |
413
+ | Point within 100 | 42.8% | 7.8% | 34.4% | 39.2% | 31.4% |
414
+ | No-target discipline | 96.4% | 97.6% | 84.1% | 95.2% | 93.1% |
415
+
416
+ #### Validation
417
+
418
+ ##### Strict-cleaned evidence
419
+
420
+ | Metric | Merged BF16 - Strict cleaned validation | CUDA bnb8 - Strict cleaned validation | CUDA bnb4 - Strict cleaned validation | MLX 8-bit - Strict cleaned validation | MLX 4-bit - Strict cleaned validation |
421
+ |---|---:|---:|---:|---:|---:|
422
+ | Valid JSON | 99.9% | 99.6% | 96.4% | 100.0% | 100.0% |
423
+ | Schema valid | 99.9% | 99.5% | 96.1% | 100.0% | 64.3% |
424
+ | Route accuracy | 99.9% | 99.5% | 96.0% | 100.0% | 64.6% |
425
+ | Reference text F1 | 59.9% | 48.1% | 56.8% | 59.0% | 56.7% |
426
+ | Spatial status | 89.6% | 80.1% | 80.2% | 88.5% | 86.3% |
427
+ | Coordinate quality | 89.8% | 84.9% | 85.5% | 88.9% | 87.4% |
428
+ | Precise target retained | 65.8% | 55.7% | 67.8% | 65.8% | 62.9% |
429
+ | Point within 100 | 39.3% | 9.6% | 34.6% | 35.0% | 28.6% |
430
+ | No-target discipline | 96.7% | 97.2% | 91.8% | 96.1% | 95.2% |
431
+
432
+ ##### Coverage-matched evidence
433
+
434
+ | Metric | Merged BF16 - Coverage-matched validation | CUDA bnb8 - Coverage-matched validation | CUDA bnb4 - Coverage-matched validation | MLX 8-bit - Coverage-matched validation | MLX 4-bit - Coverage-matched validation |
435
+ |---|---:|---:|---:|---:|---:|
436
+ | Valid JSON | 99.9% | 99.6% | 96.3% | 100.0% | 100.0% |
437
+ | Schema valid | 99.9% | 99.5% | 96.0% | 100.0% | 53.0% |
438
+ | Route accuracy | 99.9% | 99.4% | 95.9% | 100.0% | 53.2% |
439
+ | Reference text F1 | 62.6% | 50.3% | 59.4% | 61.8% | 59.5% |
440
+ | Spatial status | 84.5% | 66.7% | 76.6% | 83.5% | 80.7% |
441
+ | Coordinate quality | 84.5% | 78.7% | 80.1% | 83.4% | 81.7% |
442
+ | Precise target retained | 60.5% | 49.7% | 62.1% | 60.7% | 58.8% |
443
+ | Point within 100 | 38.4% | 8.1% | 34.0% | 33.9% | 28.0% |
444
+ | No-target discipline | 96.1% | 97.4% | 90.0% | 95.5% | 94.1% |
445
+
446
+ Validation supports comparison and model selection; test is the final held-out report. These automated scores measure contract and reference agreement, not flight safety.
447
+
448
+ ### Experimental spoken-query input
449
+
450
+ ![Typed versus spoken input](./assets/spoken_input_comparison.svg)
451
+
452
+ | Metric | Typed question | Spoken question |
453
+ |---|---:|---:|
454
+ | Valid JSON | 99.9% | 100.0% |
455
+ | Schema valid | 99.9% | 98.1% |
456
+ | Route accuracy | 99.9% | 97.6% |
457
+ | Caption / answer F1 | 39.3% | 39.2% |
458
+ | Spatial status | 71.3% | 65.3% |
459
+ | Precise target retained | 50.5% | 39.3% |
460
+ | Point within 100 | 36.3% | 31.9% |
461
+ | Coarse direction exact | 34.7% | 34.3% |
462
+ | No-target discipline | 88.2% | 88.2% |
463
+
464
+ The image, ordinary-English wording, accepted JSON target, route, and case identity are matched; only the question modality changes. This checkpoint was trained on typed questions, so spoken input is an experimental zero-shot capability boundary rather than parity-tested speech support. A future generation will train directly on paired image-plus-audio questions.
465
+
466
+ The independent benchmark rows use identical cases. Higher is better. Reference-overlap and coordinate scores are regression signals, not physical-world safety evidence.
467
+ <!-- MIRIL_RELEASE_METRICS_END -->
468
+
469
+ These automated metrics are regression signals, not proof of physical-world safety. Aerial reasoning and pointing still require human review and scenario-specific testing.
470
+
471
+ ## Technical Report
472
+
473
+ The methods report explains how detector annotations became image-question-JSON supervision, why the four response envelopes exist, how observed model failures became preference data, and how the evidence gate prevents partial evaluations from becoming release claims:
474
+
475
+ **[Miril-DroneVLM-2B-2: Compiling Detector Annotations into a Typed Aerial VLM](./technical_report.pdf)**
476
+
477
+ The report treats data design as the central engineering contribution and clearly separates verified dataset properties from model-quality and operational-safety claims.
478
+
479
+ ## Citation
480
+
481
+ ```bibtex
482
+ @techreport{sturges2026mirildrone2b2,
483
+ title = {Miril-DroneVLM-2B-2: Compiling Detector Annotations into a Typed Aerial VLM},
484
+ author = {Sturges, Stephan},
485
+ institution = {Miril.ai},
486
+ year = {2026},
487
+ url = {https://huggingface.co/MirilAI/Miril-DroneVLM-2B-2/blob/main/technical_report.pdf}
488
+ }
489
+ ```
490
+
491
+ GPT-5.5 and GPT-5.6 Sol contributed to the research, engineering, analysis, and editing. Stephan Sturges directed the work and accepts responsibility for the report and release.
492
+
493
+ ## Deployment Variants
494
+
495
+ - [Miril-DroneVLM-2B-2-bnb8](https://huggingface.co/MirilAI/Miril-DroneVLM-2B-2-bnb8): 8-bit CUDA deployment.
496
+ - [Miril-DroneVLM-2B-2-bnb4](https://huggingface.co/MirilAI/Miril-DroneVLM-2B-2-bnb4): 4-bit CUDA deployment.
497
+ - [Miril-DroneVLM-2B-2-MLX-8bit](https://huggingface.co/MirilAI/Miril-DroneVLM-2B-2-MLX-8bit): 8-bit Apple Silicon deployment.
498
+ - [Miril-DroneVLM-2B-2-MLX-4bit](https://huggingface.co/MirilAI/Miril-DroneVLM-2B-2-MLX-4bit): compact Apple Silicon deployment with a documented strict-schema routing regression.
499
+
500
+ Variant cards report artifact size, recommended deployment memory, and
501
+ complete same-case benchmark deltas.
502
+
503
+ Treat those measured deltas as authoritative. The CUDA 8-bit and 4-bit
504
+ packages use different bitsandbytes algorithms, so bit width alone does not
505
+ rank their multimodal fidelity.
506
+
507
+ The MLX 4-bit artifact remains available as a measured compact variant, not as an equivalent replacement for MLX 8-bit. On the complete balanced independent benchmark it preserves valid JSON at 100.0% after rounding but drops to 55.8% exact schema validity and 56.9% route accuracy, predominantly because pointing responses use `intent` where the contract requires `action`. The variant card contains the route-level evidence, operational warning, and the mixed 4/8-bit experiment planned for generation three.
508
+
509
+ ## WALDO Lineage
510
+
511
+ Miril-DroneVLM-2B-2 is a separate VLM, but its aerial vocabulary grows from the [WALDO30 overhead detector](https://huggingface.co/StephanST/WALDO30). The two systems are complementary: WALDO supplies fast detection boxes; Miril-DroneVLM-2B-2 supplies language reasoning and typed responses.
512
+
513
+ During dataset construction, WALDO annotations are treated as authoritative evidence for class presence, counts, and geometry after audit. A broad class name is not treated as proof of a narrower noun: an `UPole` box may contain a utility pole, antenna, dish, mast, or related fixture, and a `Boat` box may contain different kinds of watercraft. Narrow language is used only when reviewed image evidence supports it. This preserves the geometric value of the detector labels without asking the model to memorize invented detail.
514
+
515
+ | WALDO class | What the class covers |
516
+ | --- | --- |
517
+ | `LightVehicle` | Cars, sedans, rickshaws, vans, and similar small personal road vehicles. Trucks and buses remain separate classes. |
518
+ | `Person` | People and human figures visible from above. |
519
+ | `Building` | Buildings and roofed structures, including sheds, shacks, pergolas, canopies, sunshades, and tents when supported by the image. |
520
+ | `UPole` | Utility and telegraph poles, power-line fixtures, lamp posts, street fixtures, satellite dishes, antennas, and thin truss structures. |
521
+ | `Boat` | Boats and watercraft such as ships, canoes, kayaks, and sailboats; surfboards may also occur in the source ontology. |
522
+ | `Bike` | Bicycles, motorbikes, mopeds, scooters, and other two-wheeled vehicles. |
523
+ | `Container` | Storage and shipping containers. |
524
+ | `Truck` | Trucks, lorries, transport vehicles, semitrailers, articulated trucks, flatbeds, and larger industrial road vehicles. |
525
+ | `Gastank` | Storage tanks, including liquid-storage and cylindrical tanks. |
526
+ | `Digger` | Diggers, excavators, tractors when explicitly supported, and other construction vehicles or equipment. |
527
+ | `Solarpanels` | Individual solar panels and panel arrays. |
528
+ | `Bus` | Buses and bus-like passenger vehicles. |
529
+
530
+ The VLM receives no hidden detector boxes or object-count metadata during inference. It sees only the image, fixed router system message, and user question.
531
+
532
+ ## Model Scope
533
+
534
+ This checkpoint is derived from `google/gemma-4-E2B-it` and distributed as merged weights. Its aerial fine-tuning used drone-view images with typed English questions and the typed JSON contract above.
535
+
536
+ The inherited Gemma architecture also accepts audio. We evaluate image-plus-spoken-question inference separately from matched typed questions and report that result as an experimental zero-shot capability, not as speech-training parity. Native temporal video understanding is not part of this release contract: the video demo samples individual frames and uses OpenCV between model calls.
537
+
538
+ ## Limits And Safety
539
+
540
+ Miril-DroneVLM-2B-2 is a perception and reasoning model. It is not a flight controller, certified detect-and-avoid system, landing certifier, or standalone safety system.
541
+
542
+ It can miss small people or objects, confuse similar classes, misread scale, return an incorrect answer, select an unsuitable region, or produce invalid JSON. Performance varies with altitude, compression, motion blur, weather, camera angle, and domain shift.
543
+
544
+ For physical deployment:
545
+
546
+ - validate the exact camera, altitude range, geography, and operating conditions,
547
+ - independently check terrain, obstacles, people, vehicles, trees, wires, weather, and maps,
548
+ - keep geofencing and vehicle constraints outside the model,
549
+ - reject invalid JSON and non-target coordinates,
550
+ - treat coarse directions as review cues only,
551
+ - keep a human or separately validated planner in the decision loop,
552
+ - log prompts, outputs, validation failures, and downstream actions.
553
+
554
+ ## Intended Use
555
+
556
+ Good research and prototyping fits include:
557
+
558
+ - searching and summarizing aerial imagery for human review,
559
+ - inspection triage for infrastructure, agriculture, construction, and mapping,
560
+ - first-response and disaster-recovery situational awareness,
561
+ - delivery or landing-area pre-screening with explicit abstention,
562
+ - object localization and tracker initialization under application control, and
563
+ - typed-output experiments for physical AI.
564
+
565
+ Do not use the model for direct unsupervised flight control, certified safety decisions, identifying people, or safety-critical operation without independent sensing and validation.
566
+
567
+ ## License
568
+
569
+ Apache License 2.0. See [`LICENSE`](./LICENSE) and [`NOTICE`](./NOTICE).
570
+
571
+ This model is derived from `google/gemma-4-E2B-it`. Review the upstream model documentation and applicable terms before deployment.
assets/cleaned_test_comparison.svg ADDED
assets/deployment_comparison.svg ADDED
assets/full_validation_comparison.svg ADDED
assets/generation_comparison.svg ADDED
assets/qualitative_examples.png ADDED

Git LFS Details

  • SHA256: 07a1c1e1429046512bf030954a6cb0bc44cdc1da060dedde69fab2196bb51ddb
  • Pointer size: 132 Bytes
  • Size of remote file: 5.81 MB
assets/spoken_input_comparison.svg ADDED
chat_template.jinja ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {#
2
+ Template: Google Gemma 4 Canonical Chat Template
3
+ Author: Google Gemma Engineering Team
4
+ Published: 2026-07-09
5
+ Context: Fixed tool-calling loops, turn closures, and thinking content-ordering.
6
+ #}
7
+ {%- macro format_parameters(properties, required, filter_keys=false) -%}
8
+ {%- set standard_keys = ['description', 'type', 'properties', 'required', 'nullable'] -%}
9
+ {%- set ns = namespace(found_first=false) -%}
10
+ {%- for key, value in properties | dictsort -%}
11
+ {%- set add_comma = false -%}
12
+ {%- if not filter_keys or key not in standard_keys -%}
13
+ {%- if ns.found_first %},{% endif -%}
14
+ {%- set ns.found_first = true -%}
15
+ {{ key }}:{
16
+ {%- if value['description'] -%}
17
+ description:<|"|>{{ value['description'] }}<|"|>
18
+ {%- set add_comma = true -%}
19
+ {%- endif -%}
20
+ {%- if value['type'] | upper == 'STRING' -%}
21
+ {%- if value['enum'] -%}
22
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
23
+ enum:{{ format_argument(value['enum']) }}
24
+ {%- endif -%}
25
+ {%- elif value['type'] | upper == 'ARRAY' -%}
26
+ {%- if value['items'] is mapping and value['items'] -%}
27
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
28
+ items:{
29
+ {%- set ns_items = namespace(found_first=false) -%}
30
+ {%- for item_key, item_value in value['items'] | dictsort -%}
31
+ {%- if item_value is not none -%}
32
+ {%- if ns_items.found_first %},{% endif -%}
33
+ {%- set ns_items.found_first = true -%}
34
+ {%- if item_key == 'properties' -%}
35
+ properties:{
36
+ {%- if item_value is mapping -%}
37
+ {{- format_parameters(item_value, value['items']['required'] | default([])) -}}
38
+ {%- endif -%}
39
+ }
40
+ {%- elif item_key == 'required' -%}
41
+ required:[
42
+ {%- for req_item in item_value -%}
43
+ <|"|>{{- req_item -}}<|"|>
44
+ {%- if not loop.last %},{% endif -%}
45
+ {%- endfor -%}
46
+ ]
47
+ {%- elif item_key == 'type' -%}
48
+ {%- if item_value is string -%}
49
+ type:{{ format_argument(item_value | upper) }}
50
+ {%- else -%}
51
+ type:{{ format_argument(item_value | map('upper') | list) }}
52
+ {%- endif -%}
53
+ {%- else -%}
54
+ {{ item_key }}:{{ format_argument(item_value) }}
55
+ {%- endif -%}
56
+ {%- endif -%}
57
+ {%- endfor -%}
58
+ }
59
+ {%- endif -%}
60
+ {%- endif -%}
61
+ {%- if value['nullable'] %}
62
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
63
+ nullable:true
64
+ {%- endif -%}
65
+ {%- if value['type'] | upper == 'OBJECT' -%}
66
+ {%- if value['properties'] is defined and value['properties'] is mapping -%}
67
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
68
+ properties:{
69
+ {{- format_parameters(value['properties'], value['required'] | default([])) -}}
70
+ }
71
+ {%- elif value is mapping -%}
72
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
73
+ properties:{
74
+ {{- format_parameters(value, value['required'] | default([]), filter_keys=true) -}}
75
+ }
76
+ {%- endif -%}
77
+ {%- if value['required'] -%}
78
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
79
+ required:[
80
+ {%- for item in value['required'] | default([]) -%}
81
+ <|"|>{{- item -}}<|"|>
82
+ {%- if not loop.last %},{% endif -%}
83
+ {%- endfor -%}
84
+ ]
85
+ {%- endif -%}
86
+ {%- endif -%}
87
+ {%- if add_comma %},{%- else -%} {%- set add_comma = true -%} {% endif -%}
88
+ type:<|"|>{{ value['type'] | upper }}<|"|>}
89
+ {%- endif -%}
90
+ {%- endfor -%}
91
+ {%- endmacro -%}
92
+ {%- macro format_function_declaration(tool_data) -%}
93
+ declaration:{{- tool_data['function']['name'] -}}{description:<|"|>{{- tool_data['function']['description'] -}}<|"|>
94
+ {%- set params = tool_data['function']['parameters'] -%}
95
+ {%- if params -%}
96
+ ,parameters:{
97
+ {%- if params['properties'] -%}
98
+ properties:{ {{- format_parameters(params['properties'], params['required']) -}} },
99
+ {%- endif -%}
100
+ {%- if params['required'] -%}
101
+ required:[
102
+ {%- for item in params['required'] -%}
103
+ <|"|>{{- item -}}<|"|>
104
+ {{- ',' if not loop.last -}}
105
+ {%- endfor -%}
106
+ ],
107
+ {%- endif -%}
108
+ {%- if params['type'] -%}
109
+ type:<|"|>{{- params['type'] | upper -}}<|"|>}
110
+ {%- endif -%}
111
+ {%- endif -%}
112
+ {%- if 'response' in tool_data['function'] -%}
113
+ {%- set response_declaration = tool_data['function']['response'] -%}
114
+ ,response:{
115
+ {%- if response_declaration['description'] -%}
116
+ description:<|"|>{{- response_declaration['description'] -}}<|"|>,
117
+ {%- endif -%}
118
+ {%- if response_declaration['type'] | upper == 'OBJECT' -%}
119
+ type:<|"|>{{- response_declaration['type'] | upper -}}<|"|>}
120
+ {%- endif -%}
121
+ {%- endif -%}
122
+ }
123
+ {%- endmacro -%}
124
+ {%- macro format_argument(argument, escape_keys=True) -%}
125
+ {%- if argument is none -%}
126
+ {{- 'null' -}}
127
+ {%- elif argument is string -%}
128
+ {{- '<|"|>' + argument + '<|"|>' -}}
129
+ {%- elif argument is boolean -%}
130
+ {{- 'true' if argument else 'false' -}}
131
+ {%- elif argument is mapping -%}
132
+ {{- '{' -}}
133
+ {%- set ns = namespace(found_first=false) -%}
134
+ {%- for key, value in argument | dictsort -%}
135
+ {%- if ns.found_first %},{% endif -%}
136
+ {%- set ns.found_first = true -%}
137
+ {%- if escape_keys -%}
138
+ {{- '<|"|>' + key + '<|"|>' -}}
139
+ {%- else -%}
140
+ {{- key -}}
141
+ {%- endif -%}
142
+ :{{- format_argument(value, escape_keys=escape_keys) -}}
143
+ {%- endfor -%}
144
+ {{- '}' -}}
145
+ {%- elif argument is sequence -%}
146
+ {{- '[' -}}
147
+ {%- for item in argument -%}
148
+ {{- format_argument(item, escape_keys=escape_keys) -}}
149
+ {%- if not loop.last %},{% endif -%}
150
+ {%- endfor -%}
151
+ {{- ']' -}}
152
+ {%- else -%}
153
+ {{- argument -}}
154
+ {%- endif -%}
155
+ {%- endmacro -%}
156
+ {%- macro strip_thinking(text) -%}
157
+ {%- set ns = namespace(result='') -%}
158
+ {%- for part in text.split('<channel|>') -%}
159
+ {%- if '<|channel>' in part -%}
160
+ {%- set ns.result = ns.result + part.split('<|channel>')[0] -%}
161
+ {%- else -%}
162
+ {%- set ns.result = ns.result + part -%}
163
+ {%- endif -%}
164
+ {%- endfor -%}
165
+ {{- ns.result | trim -}}
166
+ {%- endmacro -%}
167
+
168
+ {%- macro format_tool_response_block(tool_name, response) -%}
169
+ {{- '<|tool_response>' -}}
170
+ {%- if response is mapping -%}
171
+ {{- 'response:' + tool_name + '{' -}}
172
+ {%- for key, value in response | dictsort -%}
173
+ {{- key -}}:{{- format_argument(value, escape_keys=False) -}}
174
+ {%- if not loop.last %},{% endif -%}
175
+ {%- endfor -%}
176
+ {{- '}' -}}
177
+ {%- else -%}
178
+ {{- 'response:' + tool_name + '{value:' + format_argument(response, escape_keys=False) + '}' -}}
179
+ {%- endif -%}
180
+ {{- '<tool_response|>' -}}
181
+ {%- endmacro -%}
182
+
183
+ {#- ===== SETUP ===== -#}
184
+ {%- set ns = namespace(prev_message_type=None, prev_non_tool_role=None) -%}
185
+ {%- set loop_messages = messages -%}
186
+ {%- set enable_thinking = enable_thinking | default(false) -%}
187
+ {%- set preserve_thinking = preserve_thinking | default(false) -%}
188
+ {{- bos_token -}}
189
+ {#- Handle System/Tool Definitions Block -#}
190
+ {%- if enable_thinking or tools or (messages and messages[0]['role'] in ['system', 'developer']) -%}
191
+ {{- '<|turn>system\n' -}}
192
+ {#- Inject Thinking token at the very top of the FIRST system turn -#}
193
+ {%- if enable_thinking -%}
194
+ {{- '<|think|>\n' -}}
195
+ {%- set ns.prev_message_type = 'think' -%}
196
+ {%- endif -%}
197
+ {%- if messages and messages[0]['role'] in ['system', 'developer'] -%}
198
+ {%- if messages[0]['content'] is string -%}
199
+ {{- messages[0]['content'] | trim -}}
200
+ {%- elif messages[0]['content'] is sequence -%}
201
+ {%- for item in messages[0]['content'] -%}
202
+ {{- item['text'] | trim + ' '-}}
203
+ {%- endfor -%}
204
+ {%- endif -%}
205
+ {%- set loop_messages = messages[1:] -%}
206
+ {%- endif -%}
207
+ {%- if tools -%}
208
+ {%- for tool in tools %}
209
+ {{- '<|tool>' -}}
210
+ {{- format_function_declaration(tool) | trim -}}
211
+ {{- '<tool|>' -}}
212
+ {%- endfor %}
213
+ {%- set ns.prev_message_type = 'tool' -%}
214
+ {%- endif -%}
215
+ {{- '<turn|>\n' -}}
216
+ {%- endif %}
217
+
218
+ {#- Pre-scan: find last user message index for reasoning guard -#}
219
+ {%- set ns_turn = namespace(last_user_idx=-1) -%}
220
+ {%- for i in range(loop_messages | length) -%}
221
+ {%- if loop_messages[i]['role'] == 'user' -%}
222
+ {%- set ns_turn.last_user_idx = i -%}
223
+ {%- endif -%}
224
+ {%- endfor -%}
225
+
226
+ {#- Loop through messages -#}
227
+ {%- for message in loop_messages -%}
228
+ {%- if message['role'] != 'tool' -%}
229
+ {%- set ns.prev_message_type = None -%}
230
+ {%- set role = 'model' if message['role'] == 'assistant' else message['role'] -%}
231
+ {#- Detect continuation using tracked state — O(1) instead of O(n) backward scan -#}
232
+ {%- set continue_same_model_turn = (role == 'model' and ns.prev_non_tool_role == 'assistant') -%}
233
+ {%- if not continue_same_model_turn -%}
234
+ {{- '<|turn>' + role + '\n' }}
235
+ {%- endif -%}
236
+
237
+ {#- Render reasoning/reasoning_content as thinking channel -#}
238
+ {%- set thinking_text = message.get('reasoning') or message.get('reasoning_content') -%}
239
+ {%- set thinking_gate = (loop.index0 > ns_turn.last_user_idx) or (preserve_thinking and message.get('tool_calls')) -%}
240
+ {%- if thinking_text and thinking_gate -%}
241
+ {{- '<|channel>thought\n' + thinking_text + '\n<channel|>' -}}
242
+ {%- endif -%}
243
+
244
+ {%- if message.get('tool_calls') -%}
245
+ {%- for tool_call in message.get('tool_calls') -%}
246
+ {%- set function = tool_call['function'] -%}
247
+ {{- '<|tool_call>call:' + function['name'] + '{' -}}
248
+ {%- if function['arguments'] is mapping -%}
249
+ {%- set ns_args = namespace(found_first=false) -%}
250
+ {%- for key, value in function['arguments'] | dictsort -%}
251
+ {%- if ns_args.found_first %},{% endif -%}
252
+ {%- set ns_args.found_first = true -%}
253
+ {{- key -}}:{{- format_argument(value, escape_keys=False) -}}
254
+ {%- endfor -%}
255
+ {%- elif function['arguments'] is none -%}
256
+ {%- else -%}
257
+ {{- raise_exception(
258
+ "chat_template: tool_calls[].function.arguments must be a "
259
+ "JSON object (mapping), not a string. Deserialize arguments "
260
+ "before passing to the template."
261
+ ) -}}
262
+ {%- endif -%}
263
+ {{- '}<tool_call|>' -}}
264
+ {%- endfor -%}
265
+ {%- set ns.prev_message_type = 'tool_call' -%}
266
+ {%- endif -%}
267
+
268
+ {%- set ns_tr_out = namespace(flag=false) -%}
269
+ {%- if message.get('tool_responses') -%}
270
+ {#- Legacy: tool_responses embedded on the assistant message (Google/Gemma native) -#}
271
+ {%- for tool_response in message.get('tool_responses') -%}
272
+ {{- format_tool_response_block(tool_response['name'] | default('unknown', true), tool_response['response']) -}}
273
+ {%- set ns_tr_out.flag = true -%}
274
+ {%- set ns.prev_message_type = 'tool_response' -%}
275
+ {%- endfor -%}
276
+ {%- elif message.get('tool_calls') -%}
277
+ {#- OpenAI Chat Completions: forward-scan consecutive role:tool messages -#}
278
+ {%- set ns_tool_scan = namespace(stopped=false) -%}
279
+ {%- for k in range(loop.index0 + 1, loop_messages | length) -%}
280
+ {%- if ns_tool_scan.stopped -%}
281
+ {%- elif loop_messages[k]['role'] != 'tool' -%}
282
+ {%- set ns_tool_scan.stopped = true -%}
283
+ {%- else -%}
284
+ {%- set follow = loop_messages[k] -%}
285
+ {#- Resolve tool_call_id to function name -#}
286
+ {%- set ns_tname = namespace(name=follow.get('name') or 'unknown') -%}
287
+ {%- for tc in message.get('tool_calls') -%}
288
+ {%- if tc.get('id') == follow.get('tool_call_id') -%}
289
+ {%- set ns_tname.name = tc['function']['name'] -%}
290
+ {%- endif -%}
291
+ {%- endfor -%}
292
+ {#- Handle content as string or content-parts array -#}
293
+ {%- set tool_body = follow.get('content') -%}
294
+ {%- if tool_body is string -%}
295
+ {{- format_tool_response_block(ns_tname.name, tool_body) -}}
296
+ {%- elif tool_body is sequence and tool_body is not string -%}
297
+ {%- set ns_txt = namespace(s='') -%}
298
+ {%- for part in tool_body -%}
299
+ {%- if part.get('type') == 'text' -%}
300
+ {%- set ns_txt.s = ns_txt.s + (part.get('text') | default('')) -%}
301
+ {%- endif -%}
302
+ {%- endfor -%}
303
+ {{- format_tool_response_block(ns_tname.name, ns_txt.s) -}}
304
+ {%- for part in tool_body -%}
305
+ {%- if part.get('type') in ['image', 'image_url'] -%}
306
+ {{- '<|image|>' -}}
307
+ {%- elif part.get('type') in ['audio', 'input_audio'] -%}
308
+ {{- '<|audio|>' -}}
309
+ {%- elif part.get('type') == 'video' -%}
310
+ {{- '<|video|>' -}}
311
+ {%- endif -%}
312
+ {%- endfor -%}
313
+ {%- else -%}
314
+ {{- format_tool_response_block(ns_tname.name, tool_body) -}}
315
+ {%- endif -%}
316
+ {%- set ns_tr_out.flag = true -%}
317
+ {%- set ns.prev_message_type = 'tool_response' -%}
318
+ {%- endif -%}
319
+ {%- endfor -%}
320
+ {%- endif -%}
321
+
322
+ {%- set captured_content -%}
323
+ {%- if message.get('content') is string -%}
324
+ {%- if role == 'model' -%}
325
+ {{- strip_thinking(message['content']) -}}
326
+ {%- else -%}
327
+ {{- message['content'] | trim -}}
328
+ {%- endif -%}
329
+ {%- elif message.get('content') is sequence -%}
330
+ {%- for item in message['content'] -%}
331
+ {%- if item.get('type') == 'text' -%}
332
+ {%- if role == 'model' -%}
333
+ {{- strip_thinking(item['text']) -}}
334
+ {%- else -%}
335
+ {{- item['text'] | trim -}}
336
+ {%- endif -%}
337
+ {%- elif item.get('type') in ['image', 'image_url'] -%}
338
+ {{- '<|image|>' -}}
339
+ {%- elif item.get('type') in ['audio', 'input_audio'] -%}
340
+ {{- '<|audio|>' -}}
341
+ {%- elif item.get('type') == 'video' -%}
342
+ {{- '<|video|>' -}}
343
+ {%- endif -%}
344
+ {%- endfor -%}
345
+ {%- endif -%}
346
+ {%- endset -%}
347
+
348
+ {{- captured_content -}}
349
+ {%- set has_content = captured_content | trim | length > 0 -%}
350
+
351
+ {#- Forward-scan: find next non-tool message role for continuation detection -#}
352
+ {%- set next_nt = namespace(role=None, found=false) -%}
353
+ {%- for j in range(loop.index0 + 1, loop_messages | length) -%}
354
+ {%- if not next_nt.found -%}
355
+ {%- if loop_messages[j]['role'] != 'tool' -%}
356
+ {%- set next_nt.role = loop_messages[j]['role'] -%}
357
+ {%- set next_nt.found = true -%}
358
+ {%- endif -%}
359
+ {%- endif -%}
360
+ {%- endfor -%}
361
+
362
+ {%- set continues_into_next = (
363
+ role == 'model'
364
+ and next_nt.role == 'assistant'
365
+ and (not message.get('tool_calls') or ns_tr_out.flag)
366
+ ) -%}
367
+
368
+ {%- if ns.prev_message_type == 'tool_call' and not ns_tr_out.flag -%}
369
+ {{- '<|tool_response>' -}}
370
+ {%- elif continues_into_next -%}
371
+ {%- elif not (ns_tr_out.flag and not has_content and not next_nt.found) -%}
372
+ {{- '<turn|>\n' -}}
373
+ {%- endif -%}
374
+
375
+ {#- Track previous non-tool role for next iteration (avoids O(n) backward scan) -#}
376
+ {%- set ns.prev_non_tool_role = message['role'] -%}
377
+ {%- endif -%}
378
+ {%- endfor -%}
379
+
380
+ {%- if add_generation_prompt -%}
381
+ {%- if ns.prev_message_type != 'tool_response' and ns.prev_message_type != 'tool_call' -%}
382
+ {{- '<|turn>model\n' -}}
383
+ {%- elif ns.prev_message_type == 'tool_response' and enable_thinking -%}
384
+ {{- '<|channel>thought\n' -}}
385
+ {%- endif -%}
386
+ {%- endif -%}
config.json ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "Gemma4ForConditionalGeneration"
4
+ ],
5
+ "audio_config": {
6
+ "_name_or_path": "",
7
+ "architectures": null,
8
+ "attention_chunk_size": 12,
9
+ "attention_context_left": 13,
10
+ "attention_context_right": 0,
11
+ "attention_invalid_logits_value": -1000000000.0,
12
+ "attention_logit_cap": 50.0,
13
+ "chunk_size_feed_forward": 0,
14
+ "conv_kernel_size": 5,
15
+ "dtype": "bfloat16",
16
+ "gradient_clipping": 10000000000.0,
17
+ "hidden_act": "silu",
18
+ "hidden_size": 1024,
19
+ "id2label": {
20
+ "0": "LABEL_0",
21
+ "1": "LABEL_1"
22
+ },
23
+ "initializer_range": 0.02,
24
+ "is_encoder_decoder": false,
25
+ "label2id": {
26
+ "LABEL_0": 0,
27
+ "LABEL_1": 1
28
+ },
29
+ "model_type": "gemma4_audio",
30
+ "num_attention_heads": 8,
31
+ "num_hidden_layers": 12,
32
+ "output_attentions": false,
33
+ "output_hidden_states": false,
34
+ "output_proj_dims": 1536,
35
+ "problem_type": null,
36
+ "residual_weight": 0.5,
37
+ "return_dict": true,
38
+ "rms_norm_eps": 1e-06,
39
+ "subsampling_conv_channels": [
40
+ 128,
41
+ 32
42
+ ],
43
+ "use_clipped_linears": true
44
+ },
45
+ "audio_token_id": 258881,
46
+ "boa_token_id": 256000,
47
+ "boi_token_id": 255999,
48
+ "dtype": "bfloat16",
49
+ "eoa_token_id": 258883,
50
+ "eoa_token_index": 258883,
51
+ "eoi_token_id": 258882,
52
+ "eos_token_id": [
53
+ 1,
54
+ 106
55
+ ],
56
+ "image_token_id": 258880,
57
+ "initializer_range": 0.02,
58
+ "model_type": "gemma4",
59
+ "text_config": {
60
+ "attention_bias": false,
61
+ "attention_dropout": 0.0,
62
+ "attention_k_eq_v": false,
63
+ "bos_token_id": 2,
64
+ "dtype": "bfloat16",
65
+ "enable_moe_block": false,
66
+ "eos_token_id": 1,
67
+ "expert_intermediate_size": null,
68
+ "final_logit_softcapping": 30.0,
69
+ "global_head_dim": 512,
70
+ "head_dim": 256,
71
+ "hidden_activation": "gelu_pytorch_tanh",
72
+ "hidden_size": 1536,
73
+ "hidden_size_per_layer_input": 256,
74
+ "initializer_range": 0.02,
75
+ "intermediate_size": 6144,
76
+ "layer_types": [
77
+ "sliding_attention",
78
+ "sliding_attention",
79
+ "sliding_attention",
80
+ "sliding_attention",
81
+ "full_attention",
82
+ "sliding_attention",
83
+ "sliding_attention",
84
+ "sliding_attention",
85
+ "sliding_attention",
86
+ "full_attention",
87
+ "sliding_attention",
88
+ "sliding_attention",
89
+ "sliding_attention",
90
+ "sliding_attention",
91
+ "full_attention",
92
+ "sliding_attention",
93
+ "sliding_attention",
94
+ "sliding_attention",
95
+ "sliding_attention",
96
+ "full_attention",
97
+ "sliding_attention",
98
+ "sliding_attention",
99
+ "sliding_attention",
100
+ "sliding_attention",
101
+ "full_attention",
102
+ "sliding_attention",
103
+ "sliding_attention",
104
+ "sliding_attention",
105
+ "sliding_attention",
106
+ "full_attention",
107
+ "sliding_attention",
108
+ "sliding_attention",
109
+ "sliding_attention",
110
+ "sliding_attention",
111
+ "full_attention"
112
+ ],
113
+ "max_position_embeddings": 131072,
114
+ "model_type": "gemma4_text",
115
+ "moe_intermediate_size": null,
116
+ "num_attention_heads": 8,
117
+ "num_experts": null,
118
+ "num_global_key_value_heads": null,
119
+ "num_hidden_layers": 35,
120
+ "num_key_value_heads": 1,
121
+ "num_kv_shared_layers": 20,
122
+ "pad_token_id": 0,
123
+ "rms_norm_eps": 1e-06,
124
+ "rope_parameters": {
125
+ "full_attention": {
126
+ "partial_rotary_factor": 0.25,
127
+ "rope_theta": 1000000.0,
128
+ "rope_type": "proportional"
129
+ },
130
+ "sliding_attention": {
131
+ "rope_theta": 10000.0,
132
+ "rope_type": "default"
133
+ }
134
+ },
135
+ "sliding_window": 512,
136
+ "tie_word_embeddings": true,
137
+ "top_k_experts": null,
138
+ "use_bidirectional_attention": null,
139
+ "use_cache": true,
140
+ "use_double_wide_mlp": true,
141
+ "vocab_size": 262144,
142
+ "vocab_size_per_layer_input": 262144
143
+ },
144
+ "tie_word_embeddings": true,
145
+ "transformers_version": "5.12.1",
146
+ "video_token_id": 258884,
147
+ "vision_config": {
148
+ "_name_or_path": "",
149
+ "architectures": null,
150
+ "attention_bias": false,
151
+ "attention_dropout": 0.0,
152
+ "chunk_size_feed_forward": 0,
153
+ "default_output_length": 280,
154
+ "dtype": "bfloat16",
155
+ "global_head_dim": 64,
156
+ "head_dim": 64,
157
+ "hidden_activation": "gelu_pytorch_tanh",
158
+ "hidden_size": 768,
159
+ "id2label": {
160
+ "0": "LABEL_0",
161
+ "1": "LABEL_1"
162
+ },
163
+ "initializer_range": 0.02,
164
+ "intermediate_size": 3072,
165
+ "is_encoder_decoder": false,
166
+ "label2id": {
167
+ "LABEL_0": 0,
168
+ "LABEL_1": 1
169
+ },
170
+ "max_position_embeddings": 131072,
171
+ "model_type": "gemma4_vision",
172
+ "num_attention_heads": 12,
173
+ "num_hidden_layers": 16,
174
+ "num_key_value_heads": 12,
175
+ "output_attentions": false,
176
+ "output_hidden_states": false,
177
+ "patch_size": 16,
178
+ "pooling_kernel_size": 3,
179
+ "position_embedding_size": 10240,
180
+ "problem_type": null,
181
+ "return_dict": true,
182
+ "rms_norm_eps": 1e-06,
183
+ "rope_parameters": {
184
+ "rope_theta": 100.0,
185
+ "rope_type": "default"
186
+ },
187
+ "standardize": false,
188
+ "use_clipped_linears": true
189
+ },
190
+ "vision_soft_tokens_per_image": 280
191
+ }
generation_config.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "bos_token_id": 2,
3
+ "do_sample": true,
4
+ "eos_token_id": [
5
+ 1,
6
+ 106,
7
+ 50
8
+ ],
9
+ "pad_token_id": 0,
10
+ "temperature": 1.0,
11
+ "top_k": 64,
12
+ "top_p": 0.95,
13
+ "transformers_version": "5.12.1"
14
+ }
image_overlay.py ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render Miril-DroneVLM-2B-2 responses beside one image."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from PIL import Image
11
+
12
+ from inference import load_model
13
+ from video_overlay import find_visual_payload, infer_responses, initial_center, render
14
+
15
+
16
+ def parse_args() -> argparse.Namespace:
17
+ parser = argparse.ArgumentParser()
18
+ parser.add_argument("--image", required=True)
19
+ parser.add_argument("--output", required=True)
20
+ parser.add_argument("--prompt", action="append", required=True)
21
+ parser.add_argument("--model-id", default="MirilAI/Miril-DroneVLM-2B-2")
22
+ parser.add_argument("--processor-id")
23
+ parser.add_argument("--max-new-tokens", type=int, default=384)
24
+ parser.add_argument("--load-4bit", action="store_true")
25
+ return parser.parse_args()
26
+
27
+
28
+ def main() -> int:
29
+ args = parse_args()
30
+ prompts = [prompt.strip() for prompt in args.prompt if prompt.strip()][:3]
31
+ if not prompts:
32
+ raise ValueError("at least one non-empty --prompt is required")
33
+ model, processor = load_model(
34
+ args.model_id,
35
+ args.processor_id or args.model_id,
36
+ args.load_4bit,
37
+ )
38
+ image = Image.open(args.image).convert("RGB")
39
+ responses = infer_responses(
40
+ model,
41
+ processor,
42
+ image,
43
+ prompts,
44
+ args.max_new_tokens,
45
+ )
46
+ precise, coarse = find_visual_payload(responses)
47
+ center = initial_center(precise, image.width, image.height)
48
+ rendered = render(
49
+ image,
50
+ responses,
51
+ tracked_center=center,
52
+ precise_payload=precise,
53
+ coarse_payload=coarse,
54
+ reveal_index=12,
55
+ )
56
+ output = Path(args.output)
57
+ output.parent.mkdir(parents=True, exist_ok=True)
58
+ rendered.save(output)
59
+ output.with_suffix(output.suffix + ".json").write_text(
60
+ json.dumps(
61
+ {
62
+ "model_id": args.model_id,
63
+ "image": args.image,
64
+ "output": str(output),
65
+ "responses": [
66
+ {
67
+ "prompt": response.prompt,
68
+ "payload": response.payload,
69
+ "validation_errors": response.errors,
70
+ }
71
+ for response in responses
72
+ ],
73
+ },
74
+ indent=2,
75
+ ensure_ascii=False,
76
+ )
77
+ + "\n",
78
+ encoding="utf-8",
79
+ )
80
+ return 0
81
+
82
+
83
+ if __name__ == "__main__":
84
+ raise SystemExit(main())
inference.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Single-image inference with strict Miril-DroneVLM-2B-2 response validation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import subprocess
9
+ import sys
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from PIL import Image
14
+
15
+ from router_contract import (
16
+ TYPED_JSON_ROUTER_SYSTEM_PROMPT,
17
+ drawable_point,
18
+ typed_response_errors,
19
+ )
20
+
21
+
22
+ def parse_args() -> argparse.Namespace:
23
+ parser = argparse.ArgumentParser()
24
+ parser.add_argument("--image", required=True)
25
+ query = parser.add_mutually_exclusive_group(required=True)
26
+ query.add_argument("--prompt")
27
+ query.add_argument("--audio", help="Experimental spoken-question audio file.")
28
+ parser.add_argument(
29
+ "--audio-transcript",
30
+ help="Optional audit/display transcript; it is not supplied to the model.",
31
+ )
32
+ parser.add_argument("--audio-sample-rate", type=int, default=16000)
33
+ parser.add_argument("--max-audio-seconds", type=float, default=30.0)
34
+ parser.add_argument("--model-id", default="MirilAI/Miril-DroneVLM-2B-2")
35
+ parser.add_argument("--processor-id")
36
+ parser.add_argument("--max-new-tokens", type=int, default=384)
37
+ parser.add_argument("--load-4bit", action="store_true")
38
+ parser.add_argument("--output-json")
39
+ return parser.parse_args()
40
+
41
+
42
+ def load_audio_mono_float32(
43
+ path: str | Path,
44
+ *,
45
+ sample_rate: int = 16000,
46
+ max_seconds: float = 30.0,
47
+ ) -> Any:
48
+ import numpy as np
49
+
50
+ audio_path = Path(path).expanduser()
51
+ if not audio_path.is_file():
52
+ raise FileNotFoundError(f"audio file does not exist: {audio_path}")
53
+ if sample_rate <= 0 or max_seconds <= 0:
54
+ raise ValueError("audio sample rate and maximum duration must be positive")
55
+ process = subprocess.run(
56
+ [
57
+ "ffmpeg",
58
+ "-hide_banner",
59
+ "-loglevel",
60
+ "error",
61
+ "-i",
62
+ str(audio_path),
63
+ "-f",
64
+ "f32le",
65
+ "-acodec",
66
+ "pcm_f32le",
67
+ "-ac",
68
+ "1",
69
+ "-ar",
70
+ str(sample_rate),
71
+ "pipe:1",
72
+ ],
73
+ check=True,
74
+ stdout=subprocess.PIPE,
75
+ )
76
+ waveform = np.frombuffer(process.stdout, dtype="<f4").copy()
77
+ if waveform.size == 0:
78
+ raise ValueError(f"audio file decoded to zero samples: {audio_path}")
79
+ max_samples = int(round(sample_rate * max_seconds))
80
+ if waveform.size > max_samples:
81
+ raise ValueError(
82
+ f"audio exceeds --max-audio-seconds ({waveform.size / sample_rate:.2f}s)"
83
+ )
84
+ return waveform
85
+
86
+
87
+ def parse_bare_json(text: str) -> tuple[dict[str, Any] | None, list[str]]:
88
+ stripped = text.strip()
89
+ try:
90
+ payload = json.loads(stripped)
91
+ except Exception as exc:
92
+ return None, [f"invalid bare JSON: {exc}"]
93
+ if not isinstance(payload, dict):
94
+ return None, ["response must be a JSON object"]
95
+ errors = typed_response_errors(payload)
96
+ return (payload if not errors else None), errors
97
+
98
+
99
+ def dispatch_summary(payload: dict[str, Any]) -> dict[str, Any]:
100
+ response_type = payload["type"]
101
+ summary: dict[str, Any] = {
102
+ "type": response_type,
103
+ "dispatch_key": response_type,
104
+ "text": payload.get("caption")
105
+ if response_type != "answer"
106
+ else payload["answer"],
107
+ "drawable": False,
108
+ "trackable": False,
109
+ }
110
+ if response_type == "location":
111
+ summary["dispatch_key"] = f"location:{payload['intent']}"
112
+ elif response_type == "pointing":
113
+ summary["dispatch_key"] = f"pointing:{payload['action']}"
114
+
115
+ point = drawable_point(payload)
116
+ if point is not None:
117
+ x, y, visual_mode = point
118
+ summary.update(
119
+ {
120
+ "drawable": True,
121
+ "visual_mode": visual_mode,
122
+ "normalized_xy": [x, y],
123
+ "trackable": visual_mode == "precise_target",
124
+ }
125
+ )
126
+ return summary
127
+
128
+
129
+ def load_model(model_id: str, processor_id: str, load_4bit: bool) -> tuple[Any, Any]:
130
+ import torch
131
+ import transformers
132
+ from transformers import AutoProcessor
133
+
134
+ processor = AutoProcessor.from_pretrained(processor_id)
135
+ model_class = None
136
+ for class_name in (
137
+ "AutoModelForMultimodalLM",
138
+ "AutoModelForImageTextToText",
139
+ "AutoModelForVision2Seq",
140
+ ):
141
+ model_class = getattr(transformers, class_name, None)
142
+ if model_class is not None:
143
+ break
144
+ if model_class is None:
145
+ raise RuntimeError(
146
+ "Installed transformers has no supported multimodal auto-model class"
147
+ )
148
+
149
+ model_kwargs: dict[str, Any] = {
150
+ "device_map": "auto",
151
+ "dtype": torch.bfloat16 if torch.cuda.is_available() else torch.float32,
152
+ }
153
+ if load_4bit:
154
+ from transformers import BitsAndBytesConfig
155
+
156
+ model_kwargs["quantization_config"] = BitsAndBytesConfig(
157
+ load_in_4bit=True,
158
+ bnb_4bit_quant_type="nf4",
159
+ bnb_4bit_compute_dtype=torch.bfloat16,
160
+ )
161
+ model = model_class.from_pretrained(model_id, **model_kwargs)
162
+ model.eval()
163
+ return model, processor
164
+
165
+
166
+ def generate(
167
+ model: Any,
168
+ processor: Any,
169
+ image: Image.Image,
170
+ prompt: str | None,
171
+ *,
172
+ audio_waveform: Any | None = None,
173
+ max_new_tokens: int,
174
+ ) -> str:
175
+ import torch
176
+
177
+ if (prompt is None) == (audio_waveform is None):
178
+ raise ValueError("provide exactly one of prompt or audio_waveform")
179
+ user_content: list[dict[str, Any]] = [{"type": "image", "image": image}]
180
+ if audio_waveform is not None:
181
+ user_content.append({"type": "audio", "audio": audio_waveform})
182
+ else:
183
+ user_content.append({"type": "text", "text": prompt})
184
+ messages = [
185
+ {
186
+ "role": "system",
187
+ "content": [{"type": "text", "text": TYPED_JSON_ROUTER_SYSTEM_PROMPT}],
188
+ },
189
+ {
190
+ "role": "user",
191
+ "content": user_content,
192
+ },
193
+ ]
194
+ rendered = processor.apply_chat_template(
195
+ messages,
196
+ tokenize=False,
197
+ add_generation_prompt=True,
198
+ )
199
+ processor_kwargs: dict[str, Any] = {
200
+ "text": [rendered],
201
+ "images": [[image]],
202
+ "return_tensors": "pt",
203
+ "padding": True,
204
+ }
205
+ if audio_waveform is not None:
206
+ processor_kwargs["audio"] = [audio_waveform]
207
+ inputs = processor(**processor_kwargs)
208
+ device = next(model.parameters()).device
209
+ inputs = inputs.to(device)
210
+ with torch.no_grad():
211
+ output = model.generate(
212
+ **inputs,
213
+ max_new_tokens=max_new_tokens,
214
+ do_sample=False,
215
+ num_beams=1,
216
+ )
217
+ generated = output[:, inputs["input_ids"].shape[-1] :]
218
+ return processor.batch_decode(generated, skip_special_tokens=True)[0].strip()
219
+
220
+
221
+ def main() -> int:
222
+ args = parse_args()
223
+ if args.audio_transcript and not args.audio:
224
+ raise ValueError("--audio-transcript requires --audio")
225
+ processor_id = args.processor_id or args.model_id
226
+ model, processor = load_model(args.model_id, processor_id, args.load_4bit)
227
+ image = Image.open(args.image).convert("RGB")
228
+ audio_waveform = (
229
+ load_audio_mono_float32(
230
+ args.audio,
231
+ sample_rate=args.audio_sample_rate,
232
+ max_seconds=args.max_audio_seconds,
233
+ )
234
+ if args.audio
235
+ else None
236
+ )
237
+ raw = generate(
238
+ model,
239
+ processor,
240
+ image,
241
+ args.prompt,
242
+ audio_waveform=audio_waveform,
243
+ max_new_tokens=args.max_new_tokens,
244
+ )
245
+ payload, errors = parse_bare_json(raw)
246
+ result = {
247
+ "ok": payload is not None,
248
+ "model_id": args.model_id,
249
+ "input_modality": "image_audio" if args.audio else "image_text",
250
+ "prompt": args.prompt,
251
+ "audio_path": str(Path(args.audio).expanduser()) if args.audio else None,
252
+ "audio_transcript_for_audit_only": args.audio_transcript,
253
+ "raw": raw,
254
+ "payload": payload,
255
+ "validation_errors": errors,
256
+ "dispatch": dispatch_summary(payload) if payload is not None else None,
257
+ }
258
+ rendered = json.dumps(result, indent=2, ensure_ascii=False)
259
+ print(rendered)
260
+ if args.output_json:
261
+ output_path = Path(args.output_json)
262
+ output_path.parent.mkdir(parents=True, exist_ok=True)
263
+ output_path.write_text(rendered + "\n", encoding="utf-8")
264
+ if errors:
265
+ print(
266
+ "Response rejected; do not dispatch or draw coordinates.", file=sys.stderr
267
+ )
268
+ return 2
269
+ return 0
270
+
271
+
272
+ if __name__ == "__main__":
273
+ raise SystemExit(main())
mlx_inference.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Apple Silicon inference for Miril-DroneVLM-2B-2 MLX variants."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ from pathlib import Path
9
+
10
+ from inference import dispatch_summary, parse_bare_json
11
+ from router_contract import TYPED_JSON_ROUTER_SYSTEM_PROMPT
12
+
13
+
14
+ def parse_args() -> argparse.Namespace:
15
+ parser = argparse.ArgumentParser()
16
+ parser.add_argument("--image", required=True)
17
+ parser.add_argument("--prompt", required=True)
18
+ parser.add_argument("--model-id", default="MirilAI/Miril-DroneVLM-2B-2-MLX-4bit")
19
+ parser.add_argument("--max-tokens", type=int, default=384)
20
+ parser.add_argument("--output-json")
21
+ return parser.parse_args()
22
+
23
+
24
+ def main() -> int:
25
+ from mlx_vlm import generate, load
26
+
27
+ args = parse_args()
28
+ model, processor = load(args.model_id, strict=True)
29
+ messages = [
30
+ {
31
+ "role": "system",
32
+ "content": [{"type": "text", "text": TYPED_JSON_ROUTER_SYSTEM_PROMPT}],
33
+ },
34
+ {
35
+ "role": "user",
36
+ "content": [
37
+ {"type": "image"},
38
+ {"type": "text", "text": args.prompt},
39
+ ],
40
+ },
41
+ ]
42
+ rendered_prompt = processor.apply_chat_template(
43
+ messages, add_generation_prompt=True
44
+ )
45
+ generated = generate(
46
+ model,
47
+ processor,
48
+ prompt=rendered_prompt,
49
+ image=args.image,
50
+ max_tokens=args.max_tokens,
51
+ temperature=0.0,
52
+ verbose=False,
53
+ )
54
+ raw = getattr(generated, "text", str(generated)).strip()
55
+ payload, errors = parse_bare_json(raw)
56
+ result = {
57
+ "ok": payload is not None,
58
+ "model_id": args.model_id,
59
+ "prompt": args.prompt,
60
+ "raw": raw,
61
+ "payload": payload,
62
+ "validation_errors": errors,
63
+ "dispatch": dispatch_summary(payload) if payload is not None else None,
64
+ }
65
+ rendered = json.dumps(result, indent=2, ensure_ascii=False)
66
+ print(rendered)
67
+ if args.output_json:
68
+ output_path = Path(args.output_json)
69
+ output_path.parent.mkdir(parents=True, exist_ok=True)
70
+ output_path.write_text(rendered + "\n", encoding="utf-8")
71
+ return 0 if not errors else 2
72
+
73
+
74
+ if __name__ == "__main__":
75
+ raise SystemExit(main())
model-00001-of-00003.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:03e6df4f0e3b2c20a0234ca1911f5095407f8040ebb6a76d04dfa34104b05430
3
+ size 1422130776
model-00002-of-00003.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:18d62274c80f49cc4ce4eee3234f641b97e80d75128af376173153c97ac12f37
3
+ size 4987105346
model-00003-of-00003.safetensors ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3a76df230ed93d375a03bed2549eadb1ac498c478414e4f04486157482299800
3
+ size 3799615716
model.safetensors.index.json ADDED
The diff for this file is too large to render. See raw diff
 
processor_config.json ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "audio_ms_per_token": 40,
3
+ "audio_seq_length": 750,
4
+ "feature_extractor": {
5
+ "dither": 0.0,
6
+ "feature_extractor_type": "Gemma4AudioFeatureExtractor",
7
+ "feature_size": 128,
8
+ "fft_length": 512,
9
+ "fft_overdrive": false,
10
+ "frame_length": 320,
11
+ "hop_length": 160,
12
+ "input_scale_factor": 1.0,
13
+ "max_frequency": 8000.0,
14
+ "mel_floor": 0.001,
15
+ "min_frequency": 0.0,
16
+ "padding_side": "right",
17
+ "padding_value": 0.0,
18
+ "per_bin_mean": null,
19
+ "per_bin_stddev": null,
20
+ "preemphasis": 0.0,
21
+ "preemphasis_htk_flavor": true,
22
+ "return_attention_mask": true,
23
+ "sampling_rate": 16000
24
+ },
25
+ "image_processor": {
26
+ "do_convert_rgb": true,
27
+ "do_normalize": false,
28
+ "do_rescale": true,
29
+ "do_resize": true,
30
+ "image_mean": [
31
+ 0.0,
32
+ 0.0,
33
+ 0.0
34
+ ],
35
+ "image_processor_type": "Gemma4ImageProcessor",
36
+ "image_seq_length": 280,
37
+ "image_std": [
38
+ 1.0,
39
+ 1.0,
40
+ 1.0
41
+ ],
42
+ "max_soft_tokens": 280,
43
+ "patch_size": 16,
44
+ "pooling_kernel_size": 3,
45
+ "resample": 3,
46
+ "rescale_factor": 0.00392156862745098
47
+ },
48
+ "image_seq_length": 280,
49
+ "processor_class": "Gemma4Processor",
50
+ "video_processor": {
51
+ "do_convert_rgb": true,
52
+ "do_normalize": true,
53
+ "do_rescale": true,
54
+ "do_resize": true,
55
+ "do_sample_frames": true,
56
+ "image_mean": [
57
+ 0.0,
58
+ 0.0,
59
+ 0.0
60
+ ],
61
+ "image_std": [
62
+ 1.0,
63
+ 1.0,
64
+ 1.0
65
+ ],
66
+ "max_soft_tokens": 70,
67
+ "num_frames": 32,
68
+ "patch_size": 16,
69
+ "pooling_kernel_size": 3,
70
+ "resample": 3,
71
+ "rescale_factor": 0.00392156862745098,
72
+ "return_metadata": false,
73
+ "video_processor_type": "Gemma4VideoProcessor"
74
+ }
75
+ }
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ accelerate
2
+ bitsandbytes
3
+ huggingface_hub
4
+ numpy
5
+ opencv-python-headless
6
+ pillow
7
+ torch
8
+ torchvision
9
+ transformers>=5.12.1
router_contract.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Immutable public inference contract for Miril-DroneVLM-2B-2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ TYPED_RESPONSE_CONTRACT = "typed_json_router_v2"
8
+
9
+ TYPED_JSON_ROUTER_SYSTEM_PROMPT = (
10
+ "You are a visual assistant for overhead and drone imagery. Inspect the image, understand the "
11
+ "user's ordinary English request, and return exactly one valid bare JSON object. Never use "
12
+ "Markdown or add text outside the JSON object. Choose the response type from the result the user "
13
+ "is asking for, not from one keyword in isolation. "
14
+ '(1) Use {"type":"caption","caption":string} when the user wants a general description, '
15
+ "summary, caption, or open-ended read of the whole image. "
16
+ '(2) Use {"type":"answer","answer":string} when the requested result is words rather than one '
17
+ "image point. This includes counts, presence, appearance, color, activity, scene classification, "
18
+ "hazards, suitability, explanations, and prose descriptions of where a group or feature is. A "
19
+ "question about whether a named area is safe is an answer; a request to choose an area is a "
20
+ "location. A question containing 'where' is still an answer when it asks for a relative or general "
21
+ "location in words. "
22
+ '(3) Use type "location" with intent "landing" or "delivery" only when the user asks you to '
23
+ "choose an area for landing a drone or placing a delivery. Landing or delivery intent takes "
24
+ "priority even if the request also says show, mark, point, or pick. "
25
+ '(4) Use type "pointing" only when the user asks for one concrete image point. The point may '
26
+ "identify one visible object or instance, or it may be an explicitly requested representative "
27
+ "point such as the center of one visible group. Set action to "
28
+ '"locate" for find/where/show-location requests, "point" for '
29
+ 'point/mark/highlight/select/pin requests, or "track" for track/follow/watch/keep-on/lock-on '
30
+ "requests. Look-at and focus-on requests for one target use locate. The "
31
+ "model supplies an initial point for track requests; a downstream application performs temporal "
32
+ "tracking. Requests about where several objects are concentrated or how two things are positioned "
33
+ "use type answer unless the user explicitly asks for one representative center point. If a point, "
34
+ "locate, or track request names multiple or ambiguous instances without identifying one instance "
35
+ "or one representative point, preserve the requested pointing action but return status "
36
+ "ambiguous_target with null "
37
+ "coordinates rather than choosing one arbitrarily. "
38
+ "If one request mixes output types, use this precedence: choose location for landing or delivery "
39
+ "selection; otherwise choose pointing when one concrete point is explicitly requested; otherwise "
40
+ "choose answer for a factual or verbal result; otherwise choose caption for a whole-scene read. "
41
+ "If the user genuinely asks for both a landing selection and a delivery selection, use the "
42
+ "selection requested first. If one target is assigned more than one pointing action, use track "
43
+ "before point and point before locate. If pointing actions name different targets, use that same "
44
+ "action precedence but return ambiguous_target with null coordinates rather than choosing one "
45
+ "target arbitrarily. "
46
+ "Do not invent extra keys to combine unrelated tasks. "
47
+ "Every location or pointing object must contain exactly type, its intent or action, caption, "
48
+ "coordinate_system, point_2d, point_semantics, pointing_mode, status, x, and y. Coordinates use "
49
+ '"gemma_relative_0_1000_yx": every non-null coordinate is a number from 0 through 1000, '
50
+ "point_2d is [y,x], and x and y repeat those values. Use "
51
+ 'pointing_mode "precise_point" only for an image-grounded point. Use point_semantics '
52
+ '"specific_point" for a concrete target and "representative_point" only when the point represents '
53
+ "a broader visible target. A location response alone may use "
54
+ '"coarse_grid_direction" with status "coarse_direction" for a broad directional cue; x and y '
55
+ "must each be exactly 250, 500, or 750. A coarse cue is never a landing point, delivery point, "
56
+ "object point, or tracking target. When no reliable target or "
57
+ 'direction applies, use pointing_mode "none", point_semantics "not_applicable", and null '
58
+ "coordinate_system, point_2d, x, and y. For location, use target_found for a selected precise "
59
+ "point, no_safe_area when no usable area exists, coarse_direction only for the broad grid cue "
60
+ "described above, or unknown when the image is insufficient. For pointing, use target_found "
61
+ "for one grounded target, no_target when the requested object is absent, no_matching_target when "
62
+ "a requested relation has no match, ambiguous_target when one target cannot be selected, or "
63
+ "unknown when the image is insufficient. Do not omit required keys or add extra keys."
64
+ )
65
+
66
+ COORDINATE_SYSTEM = "gemma_relative_0_1000_yx"
67
+ GRID_VALUES = {250.0, 500.0, 750.0}
68
+ LOCATION_INTENTS = {"landing", "delivery"}
69
+ POINTING_ACTIONS = {"locate", "point", "track"}
70
+ LOCATION_STATUSES = {"target_found", "coarse_direction", "no_safe_area", "unknown"}
71
+ POINTING_STATUSES = {
72
+ "target_found",
73
+ "no_target",
74
+ "no_matching_target",
75
+ "ambiguous_target",
76
+ "unknown",
77
+ }
78
+
79
+
80
+ def typed_response_errors(payload: Any) -> list[str]:
81
+ """Return contract violations; an empty list means the payload is dispatch-safe."""
82
+
83
+ if not isinstance(payload, dict):
84
+ return ["response must be a JSON object"]
85
+ response_type = payload.get("type")
86
+ if response_type == "caption":
87
+ return _text_response_errors(payload, text_key="caption")
88
+ if response_type == "answer":
89
+ return _text_response_errors(payload, text_key="answer")
90
+ if response_type == "location":
91
+ return _spatial_response_errors(
92
+ payload,
93
+ discriminator_key="intent",
94
+ discriminator_values=LOCATION_INTENTS,
95
+ statuses=LOCATION_STATUSES,
96
+ allow_coarse=True,
97
+ )
98
+ if response_type == "pointing":
99
+ return _spatial_response_errors(
100
+ payload,
101
+ discriminator_key="action",
102
+ discriminator_values=POINTING_ACTIONS,
103
+ statuses=POINTING_STATUSES,
104
+ allow_coarse=False,
105
+ )
106
+ return ["type must be caption, answer, location, or pointing"]
107
+
108
+
109
+ def _text_response_errors(payload: dict[str, Any], *, text_key: str) -> list[str]:
110
+ required = {"type", text_key}
111
+ errors: list[str] = []
112
+ if set(payload) != required:
113
+ errors.append(f"expected exactly {sorted(required)}, got {sorted(payload)}")
114
+ value = payload.get(text_key)
115
+ if not isinstance(value, str) or not value.strip():
116
+ errors.append(f"{text_key} must be a non-empty string")
117
+ return errors
118
+
119
+
120
+ def _spatial_response_errors(
121
+ payload: dict[str, Any],
122
+ *,
123
+ discriminator_key: str,
124
+ discriminator_values: set[str],
125
+ statuses: set[str],
126
+ allow_coarse: bool,
127
+ ) -> list[str]:
128
+ required = {
129
+ "type",
130
+ discriminator_key,
131
+ "caption",
132
+ "coordinate_system",
133
+ "point_2d",
134
+ "point_semantics",
135
+ "pointing_mode",
136
+ "status",
137
+ "x",
138
+ "y",
139
+ }
140
+ errors: list[str] = []
141
+ if set(payload) != required:
142
+ errors.append(f"expected exactly {sorted(required)}, got {sorted(payload)}")
143
+ if payload.get(discriminator_key) not in discriminator_values:
144
+ errors.append(
145
+ f"{discriminator_key} must be one of {sorted(discriminator_values)}"
146
+ )
147
+ if not isinstance(payload.get("caption"), str) or not payload["caption"].strip():
148
+ errors.append("caption must be a non-empty string")
149
+ if payload.get("status") not in statuses:
150
+ errors.append(f"status must be one of {sorted(statuses)}")
151
+
152
+ mode = payload.get("pointing_mode")
153
+ if mode == "precise_point":
154
+ if payload.get("status") != "target_found":
155
+ errors.append("precise_point requires status target_found")
156
+ if payload.get("point_semantics") not in {
157
+ "specific_point",
158
+ "representative_point",
159
+ }:
160
+ errors.append(
161
+ "precise_point requires specific_point or representative_point semantics"
162
+ )
163
+ errors.extend(_coordinate_errors(payload))
164
+ elif mode == "coarse_grid_direction":
165
+ if not allow_coarse:
166
+ errors.append("pointing responses cannot use coarse_grid_direction")
167
+ if payload.get("status") != "coarse_direction":
168
+ errors.append("coarse_grid_direction requires status coarse_direction")
169
+ if payload.get("point_semantics") != "representative_point":
170
+ errors.append(
171
+ "coarse_grid_direction requires representative_point semantics"
172
+ )
173
+ errors.extend(_coordinate_errors(payload))
174
+ if _complete_coordinates(payload) and (
175
+ float(payload["x"]) not in GRID_VALUES
176
+ or float(payload["y"]) not in GRID_VALUES
177
+ ):
178
+ errors.append("coarse coordinates must use the 250/500/750 grid")
179
+ elif mode == "none":
180
+ if payload.get("point_semantics") != "not_applicable":
181
+ errors.append("none requires not_applicable semantics")
182
+ if not _null_coordinates(payload):
183
+ errors.append("none requires null coordinate_system, point_2d, x, and y")
184
+ if payload.get("status") in {"target_found", "coarse_direction"}:
185
+ errors.append("none cannot use a target-bearing status")
186
+ else:
187
+ allowed = ["precise_point", "none"]
188
+ if allow_coarse:
189
+ allowed.insert(1, "coarse_grid_direction")
190
+ errors.append(f"pointing_mode must be one of {allowed}")
191
+ return errors
192
+
193
+
194
+ def _coordinate_errors(payload: dict[str, Any]) -> list[str]:
195
+ errors: list[str] = []
196
+ if payload.get("coordinate_system") != COORDINATE_SYSTEM:
197
+ errors.append(f"coordinate_system must be {COORDINATE_SYSTEM}")
198
+ point = payload.get("point_2d")
199
+ x = payload.get("x")
200
+ y = payload.get("y")
201
+ if not _coordinate(x) or not _coordinate(y):
202
+ errors.append("x and y must be numbers from 0 through 1000")
203
+ if (
204
+ not isinstance(point, list)
205
+ or len(point) != 2
206
+ or not all(_coordinate(v) for v in point)
207
+ ):
208
+ errors.append("point_2d must be [y, x] with values from 0 through 1000")
209
+ elif (
210
+ _coordinate(x)
211
+ and _coordinate(y)
212
+ and (float(point[0]) != float(y) or float(point[1]) != float(x))
213
+ ):
214
+ errors.append("point_2d must exactly duplicate [y, x]")
215
+ return errors
216
+
217
+
218
+ def _coordinate(value: Any) -> bool:
219
+ return (
220
+ isinstance(value, (int, float))
221
+ and not isinstance(value, bool)
222
+ and 0 <= value <= 1000
223
+ )
224
+
225
+
226
+ def _complete_coordinates(payload: dict[str, Any]) -> bool:
227
+ point = payload.get("point_2d")
228
+ return (
229
+ payload.get("coordinate_system") == COORDINATE_SYSTEM
230
+ and _coordinate(payload.get("x"))
231
+ and _coordinate(payload.get("y"))
232
+ and isinstance(point, list)
233
+ and len(point) == 2
234
+ and all(_coordinate(value) for value in point)
235
+ and float(point[0]) == float(payload["y"])
236
+ and float(point[1]) == float(payload["x"])
237
+ )
238
+
239
+
240
+ def _null_coordinates(payload: dict[str, Any]) -> bool:
241
+ return all(
242
+ payload.get(key) is None for key in ("coordinate_system", "point_2d", "x", "y")
243
+ )
244
+
245
+
246
+ def drawable_point(payload: Any) -> tuple[float, float, str] | None:
247
+ """Return normalized x/y plus visual mode only for a safe drawable response."""
248
+
249
+ if typed_response_errors(payload):
250
+ return None
251
+ if payload.get("type") not in {"location", "pointing"}:
252
+ return None
253
+ if payload["pointing_mode"] == "precise_point":
254
+ return float(payload["x"]), float(payload["y"]), "precise_target"
255
+ if payload["pointing_mode"] == "coarse_grid_direction":
256
+ return float(payload["x"]), float(payload["y"]), "coarse_direction"
257
+ return None
shared_kv_export_report.json ADDED
@@ -0,0 +1,458 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "applicable": true,
3
+ "artifact": "hf_merged",
4
+ "checks": [
5
+ {
6
+ "expected_layer_ids": [
7
+ 0,
8
+ 1,
9
+ 2,
10
+ 3,
11
+ 4,
12
+ 5,
13
+ 6,
14
+ 7,
15
+ 8,
16
+ 9,
17
+ 10,
18
+ 11,
19
+ 12,
20
+ 13,
21
+ 14,
22
+ 15,
23
+ 16,
24
+ 17,
25
+ 18,
26
+ 19,
27
+ 20,
28
+ 21,
29
+ 22,
30
+ 23,
31
+ 24,
32
+ 25,
33
+ 26,
34
+ 27,
35
+ 28,
36
+ 29,
37
+ 30,
38
+ 31,
39
+ 32,
40
+ 33,
41
+ 34
42
+ ],
43
+ "missing_layer_ids": [],
44
+ "name": "language.q_proj",
45
+ "observed_layer_ids": [
46
+ 0,
47
+ 1,
48
+ 2,
49
+ 3,
50
+ 4,
51
+ 5,
52
+ 6,
53
+ 7,
54
+ 8,
55
+ 9,
56
+ 10,
57
+ 11,
58
+ 12,
59
+ 13,
60
+ 14,
61
+ 15,
62
+ 16,
63
+ 17,
64
+ 18,
65
+ 19,
66
+ 20,
67
+ 21,
68
+ 22,
69
+ 23,
70
+ 24,
71
+ 25,
72
+ 26,
73
+ 27,
74
+ 28,
75
+ 29,
76
+ 30,
77
+ 31,
78
+ 32,
79
+ 33,
80
+ 34
81
+ ],
82
+ "ok": true,
83
+ "unexpected_layer_ids": []
84
+ },
85
+ {
86
+ "expected_layer_ids": [
87
+ 0,
88
+ 1,
89
+ 2,
90
+ 3,
91
+ 4,
92
+ 5,
93
+ 6,
94
+ 7,
95
+ 8,
96
+ 9,
97
+ 10,
98
+ 11,
99
+ 12,
100
+ 13,
101
+ 14,
102
+ 15,
103
+ 16,
104
+ 17,
105
+ 18,
106
+ 19,
107
+ 20,
108
+ 21,
109
+ 22,
110
+ 23,
111
+ 24,
112
+ 25,
113
+ 26,
114
+ 27,
115
+ 28,
116
+ 29,
117
+ 30,
118
+ 31,
119
+ 32,
120
+ 33,
121
+ 34
122
+ ],
123
+ "missing_layer_ids": [],
124
+ "name": "language.o_proj",
125
+ "observed_layer_ids": [
126
+ 0,
127
+ 1,
128
+ 2,
129
+ 3,
130
+ 4,
131
+ 5,
132
+ 6,
133
+ 7,
134
+ 8,
135
+ 9,
136
+ 10,
137
+ 11,
138
+ 12,
139
+ 13,
140
+ 14,
141
+ 15,
142
+ 16,
143
+ 17,
144
+ 18,
145
+ 19,
146
+ 20,
147
+ 21,
148
+ 22,
149
+ 23,
150
+ 24,
151
+ 25,
152
+ 26,
153
+ 27,
154
+ 28,
155
+ 29,
156
+ 30,
157
+ 31,
158
+ 32,
159
+ 33,
160
+ 34
161
+ ],
162
+ "ok": true,
163
+ "unexpected_layer_ids": []
164
+ },
165
+ {
166
+ "expected_layer_ids": [
167
+ 0,
168
+ 1,
169
+ 2,
170
+ 3,
171
+ 4,
172
+ 5,
173
+ 6,
174
+ 7,
175
+ 8,
176
+ 9,
177
+ 10,
178
+ 11,
179
+ 12,
180
+ 13,
181
+ 14,
182
+ 15,
183
+ 16,
184
+ 17,
185
+ 18,
186
+ 19,
187
+ 20,
188
+ 21,
189
+ 22,
190
+ 23,
191
+ 24,
192
+ 25,
193
+ 26,
194
+ 27,
195
+ 28,
196
+ 29,
197
+ 30,
198
+ 31,
199
+ 32,
200
+ 33,
201
+ 34
202
+ ],
203
+ "missing_layer_ids": [],
204
+ "name": "language.q_norm",
205
+ "observed_layer_ids": [
206
+ 0,
207
+ 1,
208
+ 2,
209
+ 3,
210
+ 4,
211
+ 5,
212
+ 6,
213
+ 7,
214
+ 8,
215
+ 9,
216
+ 10,
217
+ 11,
218
+ 12,
219
+ 13,
220
+ 14,
221
+ 15,
222
+ 16,
223
+ 17,
224
+ 18,
225
+ 19,
226
+ 20,
227
+ 21,
228
+ 22,
229
+ 23,
230
+ 24,
231
+ 25,
232
+ 26,
233
+ 27,
234
+ 28,
235
+ 29,
236
+ 30,
237
+ 31,
238
+ 32,
239
+ 33,
240
+ 34
241
+ ],
242
+ "ok": true,
243
+ "unexpected_layer_ids": []
244
+ },
245
+ {
246
+ "expected_layer_ids": [
247
+ 0,
248
+ 1,
249
+ 2,
250
+ 3,
251
+ 4,
252
+ 5,
253
+ 6,
254
+ 7,
255
+ 8,
256
+ 9,
257
+ 10,
258
+ 11,
259
+ 12,
260
+ 13,
261
+ 14
262
+ ],
263
+ "missing_layer_ids": [],
264
+ "name": "language.k_proj",
265
+ "observed_layer_ids": [
266
+ 0,
267
+ 1,
268
+ 2,
269
+ 3,
270
+ 4,
271
+ 5,
272
+ 6,
273
+ 7,
274
+ 8,
275
+ 9,
276
+ 10,
277
+ 11,
278
+ 12,
279
+ 13,
280
+ 14
281
+ ],
282
+ "ok": true,
283
+ "unexpected_layer_ids": []
284
+ },
285
+ {
286
+ "expected_layer_ids": [
287
+ 0,
288
+ 1,
289
+ 2,
290
+ 3,
291
+ 4,
292
+ 5,
293
+ 6,
294
+ 7,
295
+ 8,
296
+ 9,
297
+ 10,
298
+ 11,
299
+ 12,
300
+ 13,
301
+ 14
302
+ ],
303
+ "missing_layer_ids": [],
304
+ "name": "language.v_proj",
305
+ "observed_layer_ids": [
306
+ 0,
307
+ 1,
308
+ 2,
309
+ 3,
310
+ 4,
311
+ 5,
312
+ 6,
313
+ 7,
314
+ 8,
315
+ 9,
316
+ 10,
317
+ 11,
318
+ 12,
319
+ 13,
320
+ 14
321
+ ],
322
+ "ok": true,
323
+ "unexpected_layer_ids": []
324
+ },
325
+ {
326
+ "expected_layer_ids": [
327
+ 0,
328
+ 1,
329
+ 2,
330
+ 3,
331
+ 4,
332
+ 5,
333
+ 6,
334
+ 7,
335
+ 8,
336
+ 9,
337
+ 10,
338
+ 11,
339
+ 12,
340
+ 13,
341
+ 14
342
+ ],
343
+ "missing_layer_ids": [],
344
+ "name": "language.k_norm",
345
+ "observed_layer_ids": [
346
+ 0,
347
+ 1,
348
+ 2,
349
+ 3,
350
+ 4,
351
+ 5,
352
+ 6,
353
+ 7,
354
+ 8,
355
+ 9,
356
+ 10,
357
+ 11,
358
+ 12,
359
+ 13,
360
+ 14
361
+ ],
362
+ "ok": true,
363
+ "unexpected_layer_ids": []
364
+ },
365
+ {
366
+ "expected_layer_ids": [
367
+ 0,
368
+ 1,
369
+ 2,
370
+ 3,
371
+ 4,
372
+ 5,
373
+ 6,
374
+ 7,
375
+ 8,
376
+ 9,
377
+ 10,
378
+ 11,
379
+ 12,
380
+ 13,
381
+ 14,
382
+ 15
383
+ ],
384
+ "missing_layer_ids": [],
385
+ "name": "vision.k_norm",
386
+ "observed_layer_ids": [
387
+ 0,
388
+ 1,
389
+ 2,
390
+ 3,
391
+ 4,
392
+ 5,
393
+ 6,
394
+ 7,
395
+ 8,
396
+ 9,
397
+ 10,
398
+ 11,
399
+ 12,
400
+ 13,
401
+ 14,
402
+ 15
403
+ ],
404
+ "ok": true,
405
+ "unexpected_layer_ids": []
406
+ }
407
+ ],
408
+ "errors": [],
409
+ "expected_language_k_norm_count": 15,
410
+ "expected_vision_k_norm_count": 16,
411
+ "explanation": "Gemma 4 serializes K/V projections and k_norm only for the leading non-shared language layers. Later language layers reuse shared K/V states and must not be treated as missing tensors.",
412
+ "kv_owner_layer_ids": [
413
+ 0,
414
+ 1,
415
+ 2,
416
+ 3,
417
+ 4,
418
+ 5,
419
+ 6,
420
+ 7,
421
+ 8,
422
+ 9,
423
+ 10,
424
+ 11,
425
+ 12,
426
+ 13,
427
+ 14
428
+ ],
429
+ "kv_shared_layer_ids": [
430
+ 15,
431
+ 16,
432
+ 17,
433
+ 18,
434
+ 19,
435
+ 20,
436
+ 21,
437
+ 22,
438
+ 23,
439
+ 24,
440
+ 25,
441
+ 26,
442
+ 27,
443
+ 28,
444
+ 29,
445
+ 30,
446
+ 31,
447
+ 32,
448
+ 33,
449
+ 34
450
+ ],
451
+ "model_type": "gemma4",
452
+ "num_hidden_layers": 35,
453
+ "num_kv_shared_layers": 20,
454
+ "ok": true,
455
+ "schema_version": 1,
456
+ "serialized_tensor_count": 1951,
457
+ "serialized_weight_source": "model.safetensors.index.json"
458
+ }
technical_report.pdf ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2314e40d060b4386ad1d396a2bf92e313167bbf1250cdd81bd829a2c8d620848
3
+ size 1266065
tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cc8d3a0ce36466ccc1278bf987df5f71db1719b9ca6b4118264f45cb627bfe0f
3
+ size 32169626
tokenizer_config.json ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "audio_token": "<|audio|>",
3
+ "backend": "tokenizers",
4
+ "boa_token": "<|audio>",
5
+ "boi_token": "<|image>",
6
+ "bos_token": "<bos>",
7
+ "eoa_token": "<audio|>",
8
+ "eoc_token": "<channel|>",
9
+ "eoi_token": "<image|>",
10
+ "eos_token": "<eos>",
11
+ "eot_token": "<turn|>",
12
+ "escape_token": "<|\"|>",
13
+ "etc_token": "<tool_call|>",
14
+ "etd_token": "<tool|>",
15
+ "etr_token": "<tool_response|>",
16
+ "extra_special_tokens": [
17
+ "<|video|>"
18
+ ],
19
+ "image_token": "<|image|>",
20
+ "is_local": false,
21
+ "local_files_only": false,
22
+ "mask_token": "<mask>",
23
+ "model_max_length": 1000000000000000019884624838656,
24
+ "model_specific_special_tokens": {
25
+ "audio_token": "<|audio|>",
26
+ "boa_token": "<|audio>",
27
+ "boi_token": "<|image>",
28
+ "eoa_token": "<audio|>",
29
+ "eoc_token": "<channel|>",
30
+ "eoi_token": "<image|>",
31
+ "eot_token": "<turn|>",
32
+ "escape_token": "<|\"|>",
33
+ "etc_token": "<tool_call|>",
34
+ "etd_token": "<tool|>",
35
+ "etr_token": "<tool_response|>",
36
+ "image_token": "<|image|>",
37
+ "soc_token": "<|channel>",
38
+ "sot_token": "<|turn>",
39
+ "stc_token": "<|tool_call>",
40
+ "std_token": "<|tool>",
41
+ "str_token": "<|tool_response>",
42
+ "think_token": "<|think|>"
43
+ },
44
+ "pad_token": "<pad>",
45
+ "padding_side": "left",
46
+ "processor_class": "Gemma4Processor",
47
+ "response_schema": {
48
+ "properties": {
49
+ "content": {
50
+ "type": "string"
51
+ },
52
+ "role": {
53
+ "const": "assistant"
54
+ },
55
+ "thinking": {
56
+ "type": "string"
57
+ },
58
+ "tool_calls": {
59
+ "items": {
60
+ "properties": {
61
+ "function": {
62
+ "properties": {
63
+ "arguments": {
64
+ "additionalProperties": {},
65
+ "type": "object",
66
+ "x-parser": "gemma4-tool-call"
67
+ },
68
+ "name": {
69
+ "type": "string"
70
+ }
71
+ },
72
+ "type": "object",
73
+ "x-regex": "call\\:(?P<name>\\w+)(?P<arguments>\\{.*\\})"
74
+ },
75
+ "type": {
76
+ "const": "function"
77
+ }
78
+ },
79
+ "type": "object"
80
+ },
81
+ "type": "array",
82
+ "x-regex-iterator": "<\\|tool_call>(.*?)<tool_call\\|>"
83
+ }
84
+ },
85
+ "type": "object",
86
+ "x-regex": "(\\<\\|channel\\>thought\\n(?P<thinking>.*?)\\<channel\\|\\>)?(?P<tool_calls>\\<\\|tool_call\\>.*\\<tool_call\\|\\>)?(?P<content>(?:(?!\\<turn\\|\\>)(?!\\<\\|tool_response\\>).)+)?(?:\\<turn\\|\\>|\\<\\|tool_response\\>)?"
87
+ },
88
+ "response_template": {
89
+ "defaults": {
90
+ "role": "assistant"
91
+ },
92
+ "fields": {
93
+ "content": {
94
+ "close": [
95
+ "<turn|>",
96
+ "<|tool_response>",
97
+ "<eos>"
98
+ ],
99
+ "content": "text"
100
+ },
101
+ "thinking": {
102
+ "close": "<channel|>",
103
+ "content": "text",
104
+ "open": "<|channel>thought\n"
105
+ },
106
+ "tool_calls": {
107
+ "close": "<tool_call|>",
108
+ "content": "json",
109
+ "content_args": {
110
+ "string_delims": [
111
+ [
112
+ "<|\"|>",
113
+ "<|\"|>"
114
+ ]
115
+ ],
116
+ "unquoted_keys": true
117
+ },
118
+ "open_pattern": "<\\|tool_call>call:(?P<name>\\w+)",
119
+ "repeats": true,
120
+ "transform": {
121
+ "function": {
122
+ "arguments": "{content}",
123
+ "name": "{name}"
124
+ },
125
+ "type": "function"
126
+ }
127
+ }
128
+ },
129
+ "start_anchor": [
130
+ "<|turn>model\n",
131
+ "<tool_response|>"
132
+ ]
133
+ },
134
+ "soc_token": "<|channel>",
135
+ "sot_token": "<|turn>",
136
+ "stc_token": "<|tool_call>",
137
+ "std_token": "<|tool>",
138
+ "str_token": "<|tool_response>",
139
+ "think_token": "<|think|>",
140
+ "tokenizer_class": "GemmaTokenizer",
141
+ "unk_token": "<unk>"
142
+ }
video_overlay.py ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Render Miril-DroneVLM-2B-2 JSON over a video with safe pointing and OpenCV tracking."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import shutil
9
+ import subprocess
10
+ import tempfile
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ import cv2
16
+ import numpy as np
17
+ from PIL import Image, ImageDraw, ImageFont
18
+
19
+ from inference import generate, load_model, parse_bare_json
20
+ from router_contract import drawable_point
21
+
22
+
23
+ GREEN = (42, 255, 128)
24
+ WHITE = (232, 238, 235)
25
+ PANEL = (7, 10, 11)
26
+
27
+
28
+ @dataclass
29
+ class Response:
30
+ prompt: str
31
+ label: str
32
+ text: str
33
+ payload: dict[str, Any] | None
34
+ errors: list[str]
35
+
36
+
37
+ def parse_args() -> argparse.Namespace:
38
+ parser = argparse.ArgumentParser()
39
+ parser.add_argument("--input", required=True)
40
+ parser.add_argument("--output", required=True)
41
+ parser.add_argument("--prompt", action="append", required=True)
42
+ parser.add_argument("--model-id", default="MirilAI/Miril-DroneVLM-2B-2")
43
+ parser.add_argument("--processor-id")
44
+ parser.add_argument("--interval-seconds", type=float, default=5.0)
45
+ parser.add_argument(
46
+ "--crop", choices=("native", "center-square"), default="center-square"
47
+ )
48
+ parser.add_argument("--crop-size", type=int, default=1000)
49
+ parser.add_argument("--max-seconds", type=float, default=0.0)
50
+ parser.add_argument("--max-new-tokens", type=int, default=384)
51
+ parser.add_argument("--load-4bit", action="store_true")
52
+ return parser.parse_args()
53
+
54
+
55
+ def process_frame(frame: np.ndarray, crop: str, crop_size: int) -> Image.Image:
56
+ image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
57
+ if crop == "native":
58
+ return image
59
+ width, height = image.size
60
+ side = min(width, height)
61
+ left = (width - side) // 2
62
+ top = (height - side) // 2
63
+ image = image.crop((left, top, left + side, top + side))
64
+ return image.resize((crop_size, crop_size), Image.Resampling.LANCZOS)
65
+
66
+
67
+ def response_label(payload: dict[str, Any] | None, index: int) -> str:
68
+ if not payload:
69
+ return f"Prompt {index}"
70
+ response_type = payload.get("type")
71
+ if response_type == "caption":
72
+ return "Scene"
73
+ if response_type == "answer":
74
+ return "Answer"
75
+ if response_type == "location":
76
+ return f"Location/{payload.get('intent', 'unknown')}"
77
+ if response_type == "pointing":
78
+ return f"Pointing/{payload.get('action', 'unknown')}"
79
+ return f"Prompt {index}"
80
+
81
+
82
+ def response_text(raw: str, payload: dict[str, Any] | None, errors: list[str]) -> str:
83
+ if errors or payload is None:
84
+ return f"REJECTED: {'; '.join(errors) or 'invalid JSON'}"
85
+ response_type = payload["type"]
86
+ if response_type == "caption":
87
+ return payload["caption"]
88
+ if response_type == "answer":
89
+ return payload["answer"]
90
+ point = drawable_point(payload)
91
+ if point is None:
92
+ return f"{payload['status']} / no point: {payload['caption']}"
93
+ x, y, mode = point
94
+ mode_text = "precise target" if mode == "precise_target" else "broad direction only"
95
+ return f"{payload['status']} / {mode_text} / yx=[{y:.0f}, {x:.0f}]: {payload['caption']}"
96
+
97
+
98
+ def infer_responses(
99
+ model: Any,
100
+ processor: Any,
101
+ image: Image.Image,
102
+ prompts: list[str],
103
+ max_new_tokens: int,
104
+ ) -> list[Response]:
105
+ responses: list[Response] = []
106
+ for index, prompt in enumerate(prompts, start=1):
107
+ raw = generate(
108
+ model,
109
+ processor,
110
+ image,
111
+ prompt,
112
+ max_new_tokens=max_new_tokens,
113
+ )
114
+ payload, errors = parse_bare_json(raw)
115
+ responses.append(
116
+ Response(
117
+ prompt=prompt,
118
+ label=response_label(payload, index),
119
+ text=response_text(raw, payload, errors),
120
+ payload=payload,
121
+ errors=errors,
122
+ )
123
+ )
124
+ return responses
125
+
126
+
127
+ def find_visual_payload(
128
+ responses: list[Response],
129
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
130
+ precise = None
131
+ coarse = None
132
+ for response in responses:
133
+ point = drawable_point(response.payload)
134
+ if point is None:
135
+ continue
136
+ if point[2] == "precise_target" and precise is None:
137
+ precise = response.payload
138
+ elif point[2] == "coarse_direction" and coarse is None:
139
+ coarse = response.payload
140
+ return precise, coarse
141
+
142
+
143
+ def font(size: int, bold: bool = False) -> ImageFont.ImageFont:
144
+ names = (
145
+ "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"
146
+ if bold
147
+ else "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
148
+ "/Library/Fonts/Arial Bold.ttf" if bold else "/Library/Fonts/Arial.ttf",
149
+ )
150
+ for name in names:
151
+ try:
152
+ return ImageFont.truetype(name, size)
153
+ except Exception:
154
+ pass
155
+ return ImageFont.load_default()
156
+
157
+
158
+ def wrap(
159
+ draw: ImageDraw.ImageDraw, text: str, face: ImageFont.ImageFont, width: int
160
+ ) -> list[str]:
161
+ words = text.split()
162
+ if not words:
163
+ return [""]
164
+ lines: list[str] = []
165
+ current = words[0]
166
+ for word in words[1:]:
167
+ candidate = f"{current} {word}"
168
+ if draw.textbbox((0, 0), candidate, font=face)[2] <= width:
169
+ current = candidate
170
+ else:
171
+ lines.append(current)
172
+ current = word
173
+ lines.append(current)
174
+ return lines
175
+
176
+
177
+ def visible_text(text: str, reveal_index: int) -> str:
178
+ if reveal_index >= 12:
179
+ return text
180
+ words = text.split()
181
+ keep = max(1, (len(words) * (reveal_index + 1) + 11) // 12)
182
+ return " ".join(words[:keep])
183
+
184
+
185
+ def layout_lines(
186
+ draw: ImageDraw.ImageDraw,
187
+ responses: list[Response],
188
+ panel_width: int,
189
+ height: int,
190
+ ) -> tuple[ImageFont.ImageFont, ImageFont.ImageFont, int]:
191
+ available_width = panel_width - 36
192
+ for size in range(19, 8, -1):
193
+ regular = font(size)
194
+ bold = font(size, bold=True)
195
+ line_height = size + 6
196
+ line_count = 0
197
+ for response in responses:
198
+ line_count += len(
199
+ wrap(
200
+ draw,
201
+ f"{response.label} query: {response.prompt}",
202
+ regular,
203
+ available_width,
204
+ )
205
+ )
206
+ line_count += len(
207
+ wrap(
208
+ draw, f"{response.label}: {response.text}", regular, available_width
209
+ )
210
+ )
211
+ line_count += 1
212
+ if line_count * line_height <= height - 70:
213
+ return regular, bold, line_height
214
+ return font(9), font(9, bold=True), 15
215
+
216
+
217
+ def draw_marker(
218
+ draw: ImageDraw.ImageDraw,
219
+ payload: dict[str, Any],
220
+ width: int,
221
+ height: int,
222
+ *,
223
+ tracked_center: tuple[float, float] | None,
224
+ ) -> None:
225
+ point = drawable_point(payload)
226
+ if point is None:
227
+ return
228
+ x, y, mode = point
229
+ px = int(round(x / 1000.0 * width))
230
+ py = int(round(y / 1000.0 * height))
231
+ if mode == "precise_target" and tracked_center is not None:
232
+ px, py = map(lambda value: int(round(value)), tracked_center)
233
+ if mode == "coarse_direction":
234
+ radius = max(40, min(width, height) // 11)
235
+ draw.ellipse(
236
+ (px - radius, py - radius, px + radius, py + radius),
237
+ fill=(0, 230, 120, 34),
238
+ outline=(0, 230, 120, 225),
239
+ width=3,
240
+ )
241
+ return
242
+ half = 25
243
+ radius = 31
244
+ draw.ellipse(
245
+ (px - radius, py - radius, px + radius, py + radius),
246
+ fill=(0, 230, 120, 40),
247
+ outline=(0, 230, 120, 230),
248
+ width=2,
249
+ )
250
+ draw.rectangle(
251
+ (px - half, py - half, px + half, py + half), outline=(0, 230, 120), width=2
252
+ )
253
+ draw.line((px - 10, py, px + 10, py), fill=(0, 230, 120), width=2)
254
+ draw.line((px, py - 10, px, py + 10), fill=(0, 230, 120), width=2)
255
+
256
+
257
+ def render(
258
+ image: Image.Image,
259
+ responses: list[Response],
260
+ *,
261
+ tracked_center: tuple[float, float] | None,
262
+ precise_payload: dict[str, Any] | None,
263
+ coarse_payload: dict[str, Any] | None,
264
+ reveal_index: int,
265
+ ) -> Image.Image:
266
+ width, height = image.size
267
+ panel_width = width // 2
268
+ canvas = Image.new("RGB", (width + panel_width, height), PANEL)
269
+ layer = Image.new("RGBA", image.size, (0, 0, 0, 0))
270
+ marker_draw = ImageDraw.Draw(layer)
271
+ if precise_payload is not None and tracked_center is not None:
272
+ draw_marker(
273
+ marker_draw, precise_payload, width, height, tracked_center=tracked_center
274
+ )
275
+ elif coarse_payload is not None and reveal_index < 12:
276
+ draw_marker(marker_draw, coarse_payload, width, height, tracked_center=None)
277
+ canvas.paste(
278
+ Image.alpha_composite(image.convert("RGBA"), layer).convert("RGB"), (0, 0)
279
+ )
280
+
281
+ draw = ImageDraw.Draw(canvas)
282
+ regular, bold, line_height = layout_lines(draw, responses, panel_width, height)
283
+ max_width = panel_width - 36
284
+ y = 18
285
+ overflow = False
286
+ for response in responses:
287
+ blocks = (
288
+ (f"{response.label} query:", response.prompt),
289
+ (f"{response.label}:", visible_text(response.text, reveal_index)),
290
+ )
291
+ for prefix, body in blocks:
292
+ first = True
293
+ for line in wrap(draw, f"{prefix} {body}", regular, max_width):
294
+ if y + line_height > height - 48:
295
+ draw.text((width + 18, y), "...", fill=WHITE, font=regular)
296
+ overflow = True
297
+ break
298
+ if first and line.startswith(prefix):
299
+ draw.text((width + 18, y), prefix, fill=GREEN, font=bold)
300
+ prefix_width = draw.textbbox((0, 0), prefix, font=bold)[2]
301
+ draw.text(
302
+ (width + 22 + prefix_width, y),
303
+ line[len(prefix) :].lstrip(),
304
+ fill=WHITE,
305
+ font=regular,
306
+ )
307
+ else:
308
+ draw.text((width + 18, y), line, fill=WHITE, font=regular)
309
+ first = False
310
+ y += line_height
311
+ if overflow:
312
+ break
313
+ if overflow:
314
+ break
315
+ y += line_height
316
+ brand = "Miril-DroneVLM-2B-2 / miril.ai"
317
+ small = font(max(10, min(15, height // 55)))
318
+ brand_width = draw.textbbox((0, 0), brand, font=small)[2]
319
+ draw.text(
320
+ (width + panel_width - brand_width - 18, height - 30),
321
+ brand,
322
+ fill=WHITE,
323
+ font=small,
324
+ )
325
+ return canvas
326
+
327
+
328
+ def initial_center(
329
+ payload: dict[str, Any] | None, width: int, height: int
330
+ ) -> tuple[float, float] | None:
331
+ point = drawable_point(payload)
332
+ if point is None or point[2] != "precise_target":
333
+ return None
334
+ center = (point[0] / 1000.0 * width, point[1] / 1000.0 * height)
335
+ return center if inside_margin(center, width, height) else None
336
+
337
+
338
+ def inside_margin(
339
+ center: tuple[float, float], width: int, height: int, margin: int = 50
340
+ ) -> bool:
341
+ return margin < center[0] < width - margin and margin < center[1] < height - margin
342
+
343
+
344
+ def track_center(
345
+ previous: Image.Image,
346
+ current: Image.Image,
347
+ center: tuple[float, float],
348
+ ) -> tuple[float, float] | None:
349
+ width, height = current.size
350
+ if not inside_margin(center, width, height):
351
+ return None
352
+ half = 50
353
+ left = int(round(center[0])) - half
354
+ top = int(round(center[1])) - half
355
+ right = left + 100
356
+ bottom = top + 100
357
+ if left < 0 or top < 0 or right > width or bottom > height:
358
+ return None
359
+ points = np.asarray(
360
+ [
361
+ [x, y]
362
+ for y in np.linspace(top + 12, bottom - 12, 4)
363
+ for x in np.linspace(left + 12, right - 12, 4)
364
+ ],
365
+ dtype=np.float32,
366
+ ).reshape(-1, 1, 2)
367
+ previous_gray = cv2.cvtColor(np.asarray(previous), cv2.COLOR_RGB2GRAY)
368
+ current_gray = cv2.cvtColor(np.asarray(current), cv2.COLOR_RGB2GRAY)
369
+ moved, status, _ = cv2.calcOpticalFlowPyrLK(
370
+ previous_gray, current_gray, points, None
371
+ )
372
+ if moved is None or status is None:
373
+ return None
374
+ mask = status.reshape(-1) == 1
375
+ if int(mask.sum()) < 4:
376
+ return None
377
+ delta = np.median(moved[mask].reshape(-1, 2) - points[mask].reshape(-1, 2), axis=0)
378
+ updated = (float(center[0] + delta[0]), float(center[1] + delta[1]))
379
+ return updated if inside_margin(updated, width, height) else None
380
+
381
+
382
+ def mux_h264(silent_path: Path, source_path: Path, output_path: Path) -> None:
383
+ ffmpeg = shutil.which("ffmpeg")
384
+ if ffmpeg is None:
385
+ shutil.move(silent_path, output_path)
386
+ return
387
+ subprocess.run(
388
+ [
389
+ ffmpeg,
390
+ "-y",
391
+ "-i",
392
+ str(silent_path),
393
+ "-i",
394
+ str(source_path),
395
+ "-map",
396
+ "0:v:0",
397
+ "-map",
398
+ "1:a?",
399
+ "-c:v",
400
+ "libx264",
401
+ "-crf",
402
+ "20",
403
+ "-preset",
404
+ "medium",
405
+ "-c:a",
406
+ "aac",
407
+ "-shortest",
408
+ str(output_path),
409
+ ],
410
+ check=True,
411
+ stdout=subprocess.DEVNULL,
412
+ stderr=subprocess.DEVNULL,
413
+ )
414
+
415
+
416
+ def main() -> int:
417
+ args = parse_args()
418
+ prompts = [prompt.strip() for prompt in args.prompt if prompt.strip()][:3]
419
+ if not prompts:
420
+ raise ValueError("at least one non-empty --prompt is required")
421
+ if args.interval_seconds < 1 or args.interval_seconds > 10:
422
+ raise ValueError("--interval-seconds must be from 1 through 10")
423
+ source_path = Path(args.input)
424
+ output_path = Path(args.output)
425
+ output_path.parent.mkdir(parents=True, exist_ok=True)
426
+ model, processor = load_model(
427
+ args.model_id,
428
+ args.processor_id or args.model_id,
429
+ args.load_4bit,
430
+ )
431
+
432
+ capture = cv2.VideoCapture(str(source_path))
433
+ if not capture.isOpened():
434
+ raise ValueError(f"could not open {source_path}")
435
+ fps = float(capture.get(cv2.CAP_PROP_FPS) or 24.0)
436
+ max_frames = int(args.max_seconds * fps) if args.max_seconds > 0 else None
437
+ interval_frames = max(1, int(round(args.interval_seconds * fps)))
438
+ temporary_dir = Path(tempfile.mkdtemp(prefix="miril_drone_overlay_"))
439
+ silent_path = temporary_dir / "silent.mp4"
440
+ writer: Any = None
441
+ previous: Image.Image | None = None
442
+ responses: list[Response] = []
443
+ precise_payload = None
444
+ coarse_payload = None
445
+ center = None
446
+ segment_index = 0
447
+ frame_index = 0
448
+ sidecar_samples: list[dict[str, Any]] = []
449
+
450
+ while max_frames is None or frame_index < max_frames:
451
+ ok, frame = capture.read()
452
+ if not ok:
453
+ break
454
+ image = process_frame(frame, args.crop, args.crop_size)
455
+ if frame_index % interval_frames == 0 or not responses:
456
+ responses = infer_responses(
457
+ model,
458
+ processor,
459
+ image,
460
+ prompts,
461
+ args.max_new_tokens,
462
+ )
463
+ precise_payload, coarse_payload = find_visual_payload(responses)
464
+ center = initial_center(precise_payload, image.width, image.height)
465
+ segment_index = 0
466
+ sidecar_samples.append(
467
+ {
468
+ "timestamp_s": round(frame_index / fps, 3),
469
+ "responses": [
470
+ {
471
+ "prompt": response.prompt,
472
+ "payload": response.payload,
473
+ "validation_errors": response.errors,
474
+ }
475
+ for response in responses
476
+ ],
477
+ }
478
+ )
479
+ elif center is not None and previous is not None:
480
+ center = track_center(previous, image, center)
481
+
482
+ rendered = render(
483
+ image,
484
+ responses,
485
+ tracked_center=center,
486
+ precise_payload=precise_payload,
487
+ coarse_payload=coarse_payload,
488
+ reveal_index=segment_index,
489
+ )
490
+ if writer is None:
491
+ writer = cv2.VideoWriter(
492
+ str(silent_path),
493
+ cv2.VideoWriter_fourcc(*"mp4v"),
494
+ fps,
495
+ rendered.size,
496
+ )
497
+ writer.write(cv2.cvtColor(np.asarray(rendered), cv2.COLOR_RGB2BGR))
498
+ previous = image
499
+ segment_index += 1
500
+ frame_index += 1
501
+
502
+ capture.release()
503
+ if writer is not None:
504
+ writer.release()
505
+ if frame_index == 0:
506
+ raise ValueError("no frames were rendered")
507
+ mux_h264(silent_path, source_path, output_path)
508
+ sidecar = {
509
+ "model_id": args.model_id,
510
+ "input": str(source_path),
511
+ "output": str(output_path),
512
+ "prompts": prompts,
513
+ "interval_seconds": args.interval_seconds,
514
+ "source_fps": fps,
515
+ "rendered_frames": frame_index,
516
+ "tracking_box_px": 100,
517
+ "edge_hide_margin_px": 50,
518
+ "samples": sidecar_samples,
519
+ }
520
+ output_path.with_suffix(output_path.suffix + ".json").write_text(
521
+ json.dumps(sidecar, indent=2, ensure_ascii=False) + "\n",
522
+ encoding="utf-8",
523
+ )
524
+ shutil.rmtree(temporary_dir, ignore_errors=True)
525
+ return 0
526
+
527
+
528
+ if __name__ == "__main__":
529
+ raise SystemExit(main())