simittag 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simittag-0.1.0/.gitignore +10 -0
- simittag-0.1.0/LICENSE +26 -0
- simittag-0.1.0/PKG-INFO +263 -0
- simittag-0.1.0/README.md +242 -0
- simittag-0.1.0/app.py +17 -0
- simittag-0.1.0/check.sh +28 -0
- simittag-0.1.0/marker/__init__.py +0 -0
- simittag-0.1.0/marker/generate.py +122 -0
- simittag-0.1.0/marker/svg.py +159 -0
- simittag-0.1.0/pyproject.toml +42 -0
- simittag-0.1.0/simittag/__init__.py +1 -0
- simittag-0.1.0/simittag/board.py +159 -0
- simittag-0.1.0/simittag/calibrate.py +129 -0
- simittag-0.1.0/simittag/cli.py +97 -0
- simittag-0.1.0/simittag/codec.py +215 -0
- simittag-0.1.0/simittag/detect.py +864 -0
- simittag-0.1.0/simittag/gf256.py +247 -0
- simittag-0.1.0/simittag/payload.py +170 -0
- simittag-0.1.0/simittag/pose.py +139 -0
- simittag-0.1.0/simittag/spec.py +216 -0
simittag-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
BSD 2-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Alfa Ozaltin
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice,
|
|
10
|
+
this list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
17
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
18
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
|
19
|
+
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
|
20
|
+
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
|
21
|
+
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
|
22
|
+
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
|
23
|
+
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
|
24
|
+
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
25
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
|
26
|
+
POSSIBILITY OF SUCH DAMAGE.
|
simittag-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: simittag
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Concentric-ring fiducial markers: detect, generate, calibrate
|
|
5
|
+
Project-URL: Homepage, https://github.com/alfaoz/simittag
|
|
6
|
+
Project-URL: Repository, https://github.com/alfaoz/simittag
|
|
7
|
+
Author: Alfa Ozaltin
|
|
8
|
+
License-Expression: BSD-2-Clause
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: computer-vision,fiducial,marker,pose-estimation,robotics
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Recognition
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Requires-Dist: numpy>=1.21
|
|
17
|
+
Requires-Dist: opencv-python-headless>=4.5
|
|
18
|
+
Provides-Extra: test
|
|
19
|
+
Requires-Dist: pytest; extra == 'test'
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
> [!IMPORTANT]
|
|
23
|
+
> Simittag is currently quite early and experimental. We would advise to not use simittag for crucial infrastructure.
|
|
24
|
+
|
|
25
|
+
Simittag
|
|
26
|
+
========
|
|
27
|
+
[](https://github.com/alfaoz/simittag/actions/workflows/ci.yml)
|
|
28
|
+
|
|
29
|
+
Simittag is a circular visual fiducial system. Each tag carries a data payload and can be used to estimate the full 6-DoF pose of the camera. This repository contains the Python reference implementation and a Rust port of the detector. The Rust port has no dependencies and also builds to WebAssembly.
|
|
30
|
+
|
|
31
|
+
Tags come in three variants. We recommend the M variant unless you have a specific reason to choose otherwise.
|
|
32
|
+
|
|
33
|
+
<img src="docs/images/variants.png" alt="The three Simittag variants" width="760">
|
|
34
|
+
|
|
35
|
+
Table of Contents
|
|
36
|
+
=================
|
|
37
|
+
- [Overview](#overview)
|
|
38
|
+
- [Choosing a Variant](#choosing-a-variant)
|
|
39
|
+
- [Install](#install)
|
|
40
|
+
- [Usage](#usage)
|
|
41
|
+
- [Generating Tags](#generating-tags)
|
|
42
|
+
- [Getting Started with the Detector](#getting-started-with-the-detector)
|
|
43
|
+
- [Python](#python)
|
|
44
|
+
- [Rust](#rust)
|
|
45
|
+
- [WebAssembly](#webassembly)
|
|
46
|
+
- [Payload Modes](#payload-modes)
|
|
47
|
+
- [Pose Estimation](#pose-estimation)
|
|
48
|
+
- [Lens Distortion](#lens-distortion)
|
|
49
|
+
- [Performance](#performance)
|
|
50
|
+
- [Detection Range](#detection-range)
|
|
51
|
+
- [Comparison with Other Fiducial Systems](#comparison-with-other-fiducial-systems)
|
|
52
|
+
- [Implementation Notes](#implementation-notes)
|
|
53
|
+
- [License](#license)
|
|
54
|
+
|
|
55
|
+
Overview
|
|
56
|
+
========
|
|
57
|
+
A Simittag is made of concentric rings. From the center out these are a black bullseye disk, a quiet ring, several rings of data cells, another quiet ring, and a black outer ring. The detector normally finds the outer ring and fits an ellipse to it. If partial occlusion breaks that contour but leaves the bullseye intact, the detector can recover the same projective geometry from the bullseye's known radius. The pose is recovered from that ellipse. The data cells are then sampled in grayscale and decoded with Reed-Solomon error correction. Cells the sampler is not confident about are passed to the decoder as erasures rather than guesses.
|
|
58
|
+
|
|
59
|
+
Tags may be black on white or inverted white on black, and both polarities can appear in the same frame. Detection results include an `inverted` boolean. The normal black-on-white frontend runs first; the inverted frontend runs only when candidate contrast indicates it is needed, so normal-only scenes keep the original fast path.
|
|
60
|
+
|
|
61
|
+
<img src="docs/images/anatomy.png" alt="The rings of a Simittag" width="680">
|
|
62
|
+
|
|
63
|
+
The detector never returns unverified data. Every payload is checked against a CRC before it is reported, and a tag that fails the check is simply not reported. There is also no temporal filtering. Every frame is detected on its own.
|
|
64
|
+
|
|
65
|
+
The innermost data ring holds a fixed synchronization pattern. The tag's rotation is recovered by correlating against this pattern once. The same mechanism lets the detector identify the variant automatically, because the wrong variant's pattern fails to correlate.
|
|
66
|
+
|
|
67
|
+
<img src="docs/images/detections.png" alt="Detector output on a test frame" width="760">
|
|
68
|
+
|
|
69
|
+
Detector output on a test frame. The ellipse, axes, and tilt are drawn from the recovered pose. The labels show the decoded payloads.
|
|
70
|
+
|
|
71
|
+
Choosing a Variant
|
|
72
|
+
==================
|
|
73
|
+
The three variants share the same ring layout. Detection and pose estimation are identical for all of them. They differ only in the data grid.
|
|
74
|
+
|
|
75
|
+
| Variant | Grid | Payload | Distinct IDs | Corrects |
|
|
76
|
+
|:-:|:-:|:-:|:-:|:-:|
|
|
77
|
+
| T | 3×16 | 1 byte | 256 | 1 error / 1 erasure |
|
|
78
|
+
| M | 4×24 | 4 bytes | 16.7 M | 2 errors / 3 erasures |
|
|
79
|
+
| D | 5×36 | 11 bytes | 2⁸⁸ | 3 errors / 5 erasures |
|
|
80
|
+
|
|
81
|
+
Some heuristics for choosing:
|
|
82
|
+
1. If you need maximum detection distance, or expect motion blur, use T. Its cells are the largest, so they survive the most degradation.
|
|
83
|
+
2. If an ID is not enough and you need text, namespaces, or coordinates, use D.
|
|
84
|
+
3. Otherwise use M.
|
|
85
|
+
|
|
86
|
+
The detector identifies the variant automatically. You can also pin it to a single variant, which is faster.
|
|
87
|
+
|
|
88
|
+
Install
|
|
89
|
+
=======
|
|
90
|
+
From PyPI:
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
pip install simittag
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
This gives you the detector, the marker generator, camera calibration, and the `simittag` command-line tool. The package depends on NumPy and headless OpenCV. If you need OpenCV's GUI functions, install `opencv-python` alongside it.
|
|
97
|
+
|
|
98
|
+
Prebuilt Rust CLI binaries for Linux (x86_64 and aarch64), macOS (Apple silicon), and Windows are attached to each GitHub release.
|
|
99
|
+
|
|
100
|
+
From source, the Python reference implementation requires NumPy and OpenCV:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
git clone https://github.com/alfaoz/simittag.git
|
|
104
|
+
cd simittag
|
|
105
|
+
pip install numpy opencv-python
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
The Rust port has no dependencies:
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
cd rust
|
|
112
|
+
cargo build --release
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
This builds the `simittag` command-line tool at `rust/target/release/simittag`.
|
|
116
|
+
|
|
117
|
+
For the WebAssembly build, see [WebAssembly](#webassembly) below.
|
|
118
|
+
|
|
119
|
+
Usage
|
|
120
|
+
=====
|
|
121
|
+
|
|
122
|
+
## Generating Tags
|
|
123
|
+
|
|
124
|
+
```
|
|
125
|
+
python -m marker.generate --variant M --id 0x1234 --out tag.png
|
|
126
|
+
python -m marker.generate --variant M --id 0x1234 --inverted --out inverted-tag.png
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
There is also a small command-line app (`simittag` when the package is installed, `python app.py` from a source checkout):
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
python app.py encode --id 12345 --out tag.png
|
|
133
|
+
python app.py encode --raw "hi" --out tag.png
|
|
134
|
+
python app.py decode photo.png
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
The SVG generator in `marker/svg.py` produces the same geometry as the raster generator. It is important to leave a white quiet zone around the outer ring when printing, preferably square, but not necessary for high performance.
|
|
138
|
+
|
|
139
|
+
## Getting Started with the Detector
|
|
140
|
+
|
|
141
|
+
### Python
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import cv2
|
|
145
|
+
from simittag import detect
|
|
146
|
+
from simittag.spec import DEFAULT
|
|
147
|
+
|
|
148
|
+
gray = cv2.imread("frame.png", cv2.IMREAD_GRAYSCALE)
|
|
149
|
+
results = detect.detect(gray, DEFAULT)
|
|
150
|
+
|
|
151
|
+
for r in results:
|
|
152
|
+
print(r["variant"], r["mode"], r["value"], r["center"],
|
|
153
|
+
r["tilt_deg"], r["inverted"])
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Decoding works without camera calibration. For a metrically correct pose, pass your camera intrinsics as a 3×3 matrix with `K=`.
|
|
157
|
+
|
|
158
|
+
### Rust
|
|
159
|
+
|
|
160
|
+
```
|
|
161
|
+
./rust/target/release/simittag detect frame.png
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
This prints one JSON line per decoded tag, with the payload, the pose, and the recovered ellipse. Two optional arguments pin the variant and set the assumed horizontal field of view: `simittag detect frame.png M 78`. Decoding is robust to the FOV guess; only the pose needs the real value.
|
|
165
|
+
|
|
166
|
+
You can also use the `simittag-core` crate as a library.
|
|
167
|
+
|
|
168
|
+
### WebAssembly
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
rust/build-wasm.sh
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
This builds two modules into `rust/dist/`. The `wasm/` module is single-threaded and uses SIMD. The `wasm-mt/` module is multi-threaded. Both expose a `detect` function that takes a grayscale buffer and the camera intrinsics and returns JSON.
|
|
175
|
+
|
|
176
|
+
The threaded build has unusual requirements. It needs nightly Rust, a rebuilt standard library, explicit shared-memory linker flags, and a cross-origin-isolated page to run on. All of this is documented in the build script. Read its comments before changing anything.
|
|
177
|
+
|
|
178
|
+
## Payload Modes
|
|
179
|
+
|
|
180
|
+
A T tag holds a single raw byte and nothing else. M and D tags start with a one-byte header that selects a mode:
|
|
181
|
+
|
|
182
|
+
* `ID` holds an unsigned integer. This is the default.
|
|
183
|
+
* `RAW` holds opaque bytes or short text.
|
|
184
|
+
* `TAGGED` holds a namespace byte and an ID, so independent deployments do not collide.
|
|
185
|
+
* `GEO` holds latitude, longitude, and altitude. It fits only in a D tag. A GEO tag knows its own position, so one detection tells the camera where it is in the world.
|
|
186
|
+
|
|
187
|
+
Payloads with an unknown mode decode as verified raw bytes. They are never misparsed. There is deliberately no URL mode.
|
|
188
|
+
|
|
189
|
+
## Pose Estimation
|
|
190
|
+
|
|
191
|
+
Every decoded detection includes the tag's pose. The translation is expressed in units of the tag's outer-ring radius. Multiply by the physical radius in meters to get metric translation. Tag size is measured across the outer edge of the black outer ring, as shown below.
|
|
192
|
+
|
|
193
|
+
<img src="docs/images/tag_size.png" alt="Where tag size is measured" width="520">
|
|
194
|
+
|
|
195
|
+
The camera frame has its origin at the camera center. The z-axis points out of the lens, x is to the right in the image, and y is down. This matches the ROS optical frame convention (REP-103), so the pose drops into a ROS pipeline without a frame conversion. The tag frame is centered on the tag. From the viewer's perspective, x is to the right, y is down, and z points into the tag surface.
|
|
196
|
+
|
|
197
|
+
An ellipse admits two pose interpretations. This is the circular counterpart of the planar pose ambiguity that square tags have. The detector evaluates both interpretations and picks the one confirmed by the decoded data grid. The two solutions converge as the tag becomes fronto-parallel, so the ambiguity is harmless exactly where it is hardest to distinguish.
|
|
198
|
+
|
|
199
|
+
Median pose accuracy on realistically degraded synthetic frames, variant M, tilts from 0 to 70 degrees: 0.01 to 0.03 degrees of tilt error, 0.07 degrees of full rotation error, 0.04% depth error, and about 0.6 px of center reprojection error.
|
|
200
|
+
|
|
201
|
+
Pose quality degrades before decoding does. Near the decode floor (tags 22 to 40 px across) the median tilt error grows to about 2 degrees, with a systematic underestimate of up to 3 degrees, because blur rounds the ellipse. If you decode at extreme range, trust the payload more than the tilt.
|
|
202
|
+
|
|
203
|
+
## Lens Distortion
|
|
204
|
+
|
|
205
|
+
The pose math assumes a pinhole camera. Under radial distortion an off-center circle does not project to an ellipse, and the pose becomes biased. The effect is worst with wide lenses and tags near the edge of the frame. Pass your distortion coefficients to correct for it:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
detect.detect(gray, DEFAULT, K=K, dist=(k1, k2, p1, p2, k3))
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
The frame is undistorted once, with cached maps. With a typical webcam lens and the tag near the frame edge, the uncorrected detector loses 20% of its decodes and misreads rotation by 9.5 degrees. The corrected detector matches the pinhole control.
|
|
212
|
+
|
|
213
|
+
Performance
|
|
214
|
+
===========
|
|
215
|
+
Timings for a 1280x1280 frame containing six tags, variant auto-detection on, measured on a modern ARM processor:
|
|
216
|
+
|
|
217
|
+
| Detector | Time |
|
|
218
|
+
|---|---:|
|
|
219
|
+
| Rust native (rayon) | ~9 ms |
|
|
220
|
+
| WASM, threaded (8 workers) | ~15 ms |
|
|
221
|
+
| WASM, single-thread + SIMD | ~35 ms |
|
|
222
|
+
| Python reference (OpenCV, 14 threads) | ~65 ms |
|
|
223
|
+
|
|
224
|
+
These timings are for normal black-on-white scenes. White-on-black or mixed-polarity scenes add a second threshold/contour pass; it is triggered from candidate contrast rather than paid on every frame.
|
|
225
|
+
|
|
226
|
+
Detection Range
|
|
227
|
+
===============
|
|
228
|
+
Measured with an A4-printed tag (175 mm outer diameter) on a 1080p, 60-degree-HFOV camera, under mild defocus, sensor noise, and JPEG compression, 20 random tags per cell. Range is the farthest distance with at least 90% decode.
|
|
229
|
+
|
|
230
|
+
| Variant | Range, facing (m) | Range, 25° tilt (m) | Decode floor (px) |
|
|
231
|
+
|---|---:|---:|---:|
|
|
232
|
+
| T | 14 | 12 | ~22 |
|
|
233
|
+
| M | 10 | 9 | ~30 |
|
|
234
|
+
| D | 7 | 7 | ~40 |
|
|
235
|
+
|
|
236
|
+
The decode floor is the smallest outer-ring diameter, in image pixels, that still decodes. Range scales linearly with print size and with camera resolution.
|
|
237
|
+
|
|
238
|
+
When a small candidate fails to decode, the detector deconvolves the tag patch (Wiener filter against an assumed Gaussian point-spread) and retries. At long range the limit is not finding the tag, since the outer ring is detected far past the decode floor. The limit is inter-symbol interference: defocus bleeds neighboring data cells into each other. Undoing the blur recovers the bits. The retry runs only after a failed decode on a small candidate, so it adds nothing to healthy frames, and every retry result still has to pass the sync, Reed-Solomon, CRC, and decode-verify gates.
|
|
239
|
+
|
|
240
|
+
Two accept gates guard the search. A sync-ring correlation gate filters non-tag grids before Reed-Solomon runs. After any successful decode, the observed grid is correlated against the re-encoded decoded pattern (a matched filter of the image against what was decoded) and the result is rejected below 0.73. In calibration, correct decodes scored at least 0.807. Across 600 procedurally generated ring-like clutter frames, CRC-valid wrong-decode candidates scored at most 0.673 and none passed the gate. This leaves a measured empty interval between false and correct candidates while preserving margin for degraded real tags.
|
|
241
|
+
|
|
242
|
+
Comparison with Other Fiducial Systems
|
|
243
|
+
======================================
|
|
244
|
+
We did some head-to-head testing against AprilTag (tag36h11, via `pupil-apriltags`) with identical print size, camera model, poses, and image degradation, at 15 degrees of tilt. Range is the farthest distance with at least 90% decode of an A4-printed tag:
|
|
245
|
+
|
|
246
|
+
| Camera width | Simittag T | Simittag M | Simittag D | AprilTag 36h11 |
|
|
247
|
+
|---|---:|---:|---:|---:|
|
|
248
|
+
| 1280 px | 8.5 m | 6.5 m | 5.0 m | 9.0 m |
|
|
249
|
+
| 1920 px | 13.0 m | 10.0 m | 8.0 m | 13.5 m |
|
|
250
|
+
|
|
251
|
+
DataMatrix and QR codes store more bytes in the same area, because squares tile and rings do not. They provide no pose.
|
|
252
|
+
|
|
253
|
+
Implementation Notes
|
|
254
|
+
====================
|
|
255
|
+
It's advised that the Rust detector is used in production. The Python package defines the correct behavior of the format and the detector, and exists for reference, experimentation, and regenerating the test fixtures.
|
|
256
|
+
|
|
257
|
+
The two implementations are held together by the fixtures in `fixtures/`: golden vectors and rendered frames exported from the Python reference, which the Rust port must reproduce exactly, bit-for-bit for the codec and imaging stages and to identical decode decisions on every frame. `./check.sh` builds everything and runs the full contract (Rust unit tests, all six parity gates, and the Python self-tests); CI runs the same script on every push.
|
|
258
|
+
|
|
259
|
+
License
|
|
260
|
+
=======
|
|
261
|
+
Simittag is licensed under the [BSD 2-Clause License](LICENSE). The Rust ellipse-fitting routine is derived from OpenCV; see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
|
|
262
|
+
|
|
263
|
+
The tag format is free for anyone to implement.
|
simittag-0.1.0/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
> [!IMPORTANT]
|
|
2
|
+
> Simittag is currently quite early and experimental. We would advise to not use simittag for crucial infrastructure.
|
|
3
|
+
|
|
4
|
+
Simittag
|
|
5
|
+
========
|
|
6
|
+
[](https://github.com/alfaoz/simittag/actions/workflows/ci.yml)
|
|
7
|
+
|
|
8
|
+
Simittag is a circular visual fiducial system. Each tag carries a data payload and can be used to estimate the full 6-DoF pose of the camera. This repository contains the Python reference implementation and a Rust port of the detector. The Rust port has no dependencies and also builds to WebAssembly.
|
|
9
|
+
|
|
10
|
+
Tags come in three variants. We recommend the M variant unless you have a specific reason to choose otherwise.
|
|
11
|
+
|
|
12
|
+
<img src="docs/images/variants.png" alt="The three Simittag variants" width="760">
|
|
13
|
+
|
|
14
|
+
Table of Contents
|
|
15
|
+
=================
|
|
16
|
+
- [Overview](#overview)
|
|
17
|
+
- [Choosing a Variant](#choosing-a-variant)
|
|
18
|
+
- [Install](#install)
|
|
19
|
+
- [Usage](#usage)
|
|
20
|
+
- [Generating Tags](#generating-tags)
|
|
21
|
+
- [Getting Started with the Detector](#getting-started-with-the-detector)
|
|
22
|
+
- [Python](#python)
|
|
23
|
+
- [Rust](#rust)
|
|
24
|
+
- [WebAssembly](#webassembly)
|
|
25
|
+
- [Payload Modes](#payload-modes)
|
|
26
|
+
- [Pose Estimation](#pose-estimation)
|
|
27
|
+
- [Lens Distortion](#lens-distortion)
|
|
28
|
+
- [Performance](#performance)
|
|
29
|
+
- [Detection Range](#detection-range)
|
|
30
|
+
- [Comparison with Other Fiducial Systems](#comparison-with-other-fiducial-systems)
|
|
31
|
+
- [Implementation Notes](#implementation-notes)
|
|
32
|
+
- [License](#license)
|
|
33
|
+
|
|
34
|
+
Overview
|
|
35
|
+
========
|
|
36
|
+
A Simittag is made of concentric rings. From the center out these are a black bullseye disk, a quiet ring, several rings of data cells, another quiet ring, and a black outer ring. The detector normally finds the outer ring and fits an ellipse to it. If partial occlusion breaks that contour but leaves the bullseye intact, the detector can recover the same projective geometry from the bullseye's known radius. The pose is recovered from that ellipse. The data cells are then sampled in grayscale and decoded with Reed-Solomon error correction. Cells the sampler is not confident about are passed to the decoder as erasures rather than guesses.
|
|
37
|
+
|
|
38
|
+
Tags may be black on white or inverted white on black, and both polarities can appear in the same frame. Detection results include an `inverted` boolean. The normal black-on-white frontend runs first; the inverted frontend runs only when candidate contrast indicates it is needed, so normal-only scenes keep the original fast path.
|
|
39
|
+
|
|
40
|
+
<img src="docs/images/anatomy.png" alt="The rings of a Simittag" width="680">
|
|
41
|
+
|
|
42
|
+
The detector never returns unverified data. Every payload is checked against a CRC before it is reported, and a tag that fails the check is simply not reported. There is also no temporal filtering. Every frame is detected on its own.
|
|
43
|
+
|
|
44
|
+
The innermost data ring holds a fixed synchronization pattern. The tag's rotation is recovered by correlating against this pattern once. The same mechanism lets the detector identify the variant automatically, because the wrong variant's pattern fails to correlate.
|
|
45
|
+
|
|
46
|
+
<img src="docs/images/detections.png" alt="Detector output on a test frame" width="760">
|
|
47
|
+
|
|
48
|
+
Detector output on a test frame. The ellipse, axes, and tilt are drawn from the recovered pose. The labels show the decoded payloads.
|
|
49
|
+
|
|
50
|
+
Choosing a Variant
|
|
51
|
+
==================
|
|
52
|
+
The three variants share the same ring layout. Detection and pose estimation are identical for all of them. They differ only in the data grid.
|
|
53
|
+
|
|
54
|
+
| Variant | Grid | Payload | Distinct IDs | Corrects |
|
|
55
|
+
|:-:|:-:|:-:|:-:|:-:|
|
|
56
|
+
| T | 3×16 | 1 byte | 256 | 1 error / 1 erasure |
|
|
57
|
+
| M | 4×24 | 4 bytes | 16.7 M | 2 errors / 3 erasures |
|
|
58
|
+
| D | 5×36 | 11 bytes | 2⁸⁸ | 3 errors / 5 erasures |
|
|
59
|
+
|
|
60
|
+
Some heuristics for choosing:
|
|
61
|
+
1. If you need maximum detection distance, or expect motion blur, use T. Its cells are the largest, so they survive the most degradation.
|
|
62
|
+
2. If an ID is not enough and you need text, namespaces, or coordinates, use D.
|
|
63
|
+
3. Otherwise use M.
|
|
64
|
+
|
|
65
|
+
The detector identifies the variant automatically. You can also pin it to a single variant, which is faster.
|
|
66
|
+
|
|
67
|
+
Install
|
|
68
|
+
=======
|
|
69
|
+
From PyPI:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
pip install simittag
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
This gives you the detector, the marker generator, camera calibration, and the `simittag` command-line tool. The package depends on NumPy and headless OpenCV. If you need OpenCV's GUI functions, install `opencv-python` alongside it.
|
|
76
|
+
|
|
77
|
+
Prebuilt Rust CLI binaries for Linux (x86_64 and aarch64), macOS (Apple silicon), and Windows are attached to each GitHub release.
|
|
78
|
+
|
|
79
|
+
From source, the Python reference implementation requires NumPy and OpenCV:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
git clone https://github.com/alfaoz/simittag.git
|
|
83
|
+
cd simittag
|
|
84
|
+
pip install numpy opencv-python
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The Rust port has no dependencies:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
cd rust
|
|
91
|
+
cargo build --release
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
This builds the `simittag` command-line tool at `rust/target/release/simittag`.
|
|
95
|
+
|
|
96
|
+
For the WebAssembly build, see [WebAssembly](#webassembly) below.
|
|
97
|
+
|
|
98
|
+
Usage
|
|
99
|
+
=====
|
|
100
|
+
|
|
101
|
+
## Generating Tags
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
python -m marker.generate --variant M --id 0x1234 --out tag.png
|
|
105
|
+
python -m marker.generate --variant M --id 0x1234 --inverted --out inverted-tag.png
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
There is also a small command-line app (`simittag` when the package is installed, `python app.py` from a source checkout):
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
python app.py encode --id 12345 --out tag.png
|
|
112
|
+
python app.py encode --raw "hi" --out tag.png
|
|
113
|
+
python app.py decode photo.png
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
The SVG generator in `marker/svg.py` produces the same geometry as the raster generator. It is important to leave a white quiet zone around the outer ring when printing, preferably square, but not necessary for high performance.
|
|
117
|
+
|
|
118
|
+
## Getting Started with the Detector
|
|
119
|
+
|
|
120
|
+
### Python
|
|
121
|
+
|
|
122
|
+
```python
|
|
123
|
+
import cv2
|
|
124
|
+
from simittag import detect
|
|
125
|
+
from simittag.spec import DEFAULT
|
|
126
|
+
|
|
127
|
+
gray = cv2.imread("frame.png", cv2.IMREAD_GRAYSCALE)
|
|
128
|
+
results = detect.detect(gray, DEFAULT)
|
|
129
|
+
|
|
130
|
+
for r in results:
|
|
131
|
+
print(r["variant"], r["mode"], r["value"], r["center"],
|
|
132
|
+
r["tilt_deg"], r["inverted"])
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Decoding works without camera calibration. For a metrically correct pose, pass your camera intrinsics as a 3×3 matrix with `K=`.
|
|
136
|
+
|
|
137
|
+
### Rust
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
./rust/target/release/simittag detect frame.png
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
This prints one JSON line per decoded tag, with the payload, the pose, and the recovered ellipse. Two optional arguments pin the variant and set the assumed horizontal field of view: `simittag detect frame.png M 78`. Decoding is robust to the FOV guess; only the pose needs the real value.
|
|
144
|
+
|
|
145
|
+
You can also use the `simittag-core` crate as a library.
|
|
146
|
+
|
|
147
|
+
### WebAssembly
|
|
148
|
+
|
|
149
|
+
```
|
|
150
|
+
rust/build-wasm.sh
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
This builds two modules into `rust/dist/`. The `wasm/` module is single-threaded and uses SIMD. The `wasm-mt/` module is multi-threaded. Both expose a `detect` function that takes a grayscale buffer and the camera intrinsics and returns JSON.
|
|
154
|
+
|
|
155
|
+
The threaded build has unusual requirements. It needs nightly Rust, a rebuilt standard library, explicit shared-memory linker flags, and a cross-origin-isolated page to run on. All of this is documented in the build script. Read its comments before changing anything.
|
|
156
|
+
|
|
157
|
+
## Payload Modes
|
|
158
|
+
|
|
159
|
+
A T tag holds a single raw byte and nothing else. M and D tags start with a one-byte header that selects a mode:
|
|
160
|
+
|
|
161
|
+
* `ID` holds an unsigned integer. This is the default.
|
|
162
|
+
* `RAW` holds opaque bytes or short text.
|
|
163
|
+
* `TAGGED` holds a namespace byte and an ID, so independent deployments do not collide.
|
|
164
|
+
* `GEO` holds latitude, longitude, and altitude. It fits only in a D tag. A GEO tag knows its own position, so one detection tells the camera where it is in the world.
|
|
165
|
+
|
|
166
|
+
Payloads with an unknown mode decode as verified raw bytes. They are never misparsed. There is deliberately no URL mode.
|
|
167
|
+
|
|
168
|
+
## Pose Estimation
|
|
169
|
+
|
|
170
|
+
Every decoded detection includes the tag's pose. The translation is expressed in units of the tag's outer-ring radius. Multiply by the physical radius in meters to get metric translation. Tag size is measured across the outer edge of the black outer ring, as shown below.
|
|
171
|
+
|
|
172
|
+
<img src="docs/images/tag_size.png" alt="Where tag size is measured" width="520">
|
|
173
|
+
|
|
174
|
+
The camera frame has its origin at the camera center. The z-axis points out of the lens, x is to the right in the image, and y is down. This matches the ROS optical frame convention (REP-103), so the pose drops into a ROS pipeline without a frame conversion. The tag frame is centered on the tag. From the viewer's perspective, x is to the right, y is down, and z points into the tag surface.
|
|
175
|
+
|
|
176
|
+
An ellipse admits two pose interpretations. This is the circular counterpart of the planar pose ambiguity that square tags have. The detector evaluates both interpretations and picks the one confirmed by the decoded data grid. The two solutions converge as the tag becomes fronto-parallel, so the ambiguity is harmless exactly where it is hardest to distinguish.
|
|
177
|
+
|
|
178
|
+
Median pose accuracy on realistically degraded synthetic frames, variant M, tilts from 0 to 70 degrees: 0.01 to 0.03 degrees of tilt error, 0.07 degrees of full rotation error, 0.04% depth error, and about 0.6 px of center reprojection error.
|
|
179
|
+
|
|
180
|
+
Pose quality degrades before decoding does. Near the decode floor (tags 22 to 40 px across) the median tilt error grows to about 2 degrees, with a systematic underestimate of up to 3 degrees, because blur rounds the ellipse. If you decode at extreme range, trust the payload more than the tilt.
|
|
181
|
+
|
|
182
|
+
## Lens Distortion
|
|
183
|
+
|
|
184
|
+
The pose math assumes a pinhole camera. Under radial distortion an off-center circle does not project to an ellipse, and the pose becomes biased. The effect is worst with wide lenses and tags near the edge of the frame. Pass your distortion coefficients to correct for it:
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
detect.detect(gray, DEFAULT, K=K, dist=(k1, k2, p1, p2, k3))
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
The frame is undistorted once, with cached maps. With a typical webcam lens and the tag near the frame edge, the uncorrected detector loses 20% of its decodes and misreads rotation by 9.5 degrees. The corrected detector matches the pinhole control.
|
|
191
|
+
|
|
192
|
+
Performance
|
|
193
|
+
===========
|
|
194
|
+
Timings for a 1280x1280 frame containing six tags, variant auto-detection on, measured on a modern ARM processor:
|
|
195
|
+
|
|
196
|
+
| Detector | Time |
|
|
197
|
+
|---|---:|
|
|
198
|
+
| Rust native (rayon) | ~9 ms |
|
|
199
|
+
| WASM, threaded (8 workers) | ~15 ms |
|
|
200
|
+
| WASM, single-thread + SIMD | ~35 ms |
|
|
201
|
+
| Python reference (OpenCV, 14 threads) | ~65 ms |
|
|
202
|
+
|
|
203
|
+
These timings are for normal black-on-white scenes. White-on-black or mixed-polarity scenes add a second threshold/contour pass; it is triggered from candidate contrast rather than paid on every frame.
|
|
204
|
+
|
|
205
|
+
Detection Range
|
|
206
|
+
===============
|
|
207
|
+
Measured with an A4-printed tag (175 mm outer diameter) on a 1080p, 60-degree-HFOV camera, under mild defocus, sensor noise, and JPEG compression, 20 random tags per cell. Range is the farthest distance with at least 90% decode.
|
|
208
|
+
|
|
209
|
+
| Variant | Range, facing (m) | Range, 25° tilt (m) | Decode floor (px) |
|
|
210
|
+
|---|---:|---:|---:|
|
|
211
|
+
| T | 14 | 12 | ~22 |
|
|
212
|
+
| M | 10 | 9 | ~30 |
|
|
213
|
+
| D | 7 | 7 | ~40 |
|
|
214
|
+
|
|
215
|
+
The decode floor is the smallest outer-ring diameter, in image pixels, that still decodes. Range scales linearly with print size and with camera resolution.
|
|
216
|
+
|
|
217
|
+
When a small candidate fails to decode, the detector deconvolves the tag patch (Wiener filter against an assumed Gaussian point-spread) and retries. At long range the limit is not finding the tag, since the outer ring is detected far past the decode floor. The limit is inter-symbol interference: defocus bleeds neighboring data cells into each other. Undoing the blur recovers the bits. The retry runs only after a failed decode on a small candidate, so it adds nothing to healthy frames, and every retry result still has to pass the sync, Reed-Solomon, CRC, and decode-verify gates.
|
|
218
|
+
|
|
219
|
+
Two accept gates guard the search. A sync-ring correlation gate filters non-tag grids before Reed-Solomon runs. After any successful decode, the observed grid is correlated against the re-encoded decoded pattern (a matched filter of the image against what was decoded) and the result is rejected below 0.73. In calibration, correct decodes scored at least 0.807. Across 600 procedurally generated ring-like clutter frames, CRC-valid wrong-decode candidates scored at most 0.673 and none passed the gate. This leaves a measured empty interval between false and correct candidates while preserving margin for degraded real tags.
|
|
220
|
+
|
|
221
|
+
Comparison with Other Fiducial Systems
|
|
222
|
+
======================================
|
|
223
|
+
We did some head-to-head testing against AprilTag (tag36h11, via `pupil-apriltags`) with identical print size, camera model, poses, and image degradation, at 15 degrees of tilt. Range is the farthest distance with at least 90% decode of an A4-printed tag:
|
|
224
|
+
|
|
225
|
+
| Camera width | Simittag T | Simittag M | Simittag D | AprilTag 36h11 |
|
|
226
|
+
|---|---:|---:|---:|---:|
|
|
227
|
+
| 1280 px | 8.5 m | 6.5 m | 5.0 m | 9.0 m |
|
|
228
|
+
| 1920 px | 13.0 m | 10.0 m | 8.0 m | 13.5 m |
|
|
229
|
+
|
|
230
|
+
DataMatrix and QR codes store more bytes in the same area, because squares tile and rings do not. They provide no pose.
|
|
231
|
+
|
|
232
|
+
Implementation Notes
|
|
233
|
+
====================
|
|
234
|
+
It's advised that the Rust detector is used in production. The Python package defines the correct behavior of the format and the detector, and exists for reference, experimentation, and regenerating the test fixtures.
|
|
235
|
+
|
|
236
|
+
The two implementations are held together by the fixtures in `fixtures/`: golden vectors and rendered frames exported from the Python reference, which the Rust port must reproduce exactly, bit-for-bit for the codec and imaging stages and to identical decode decisions on every frame. `./check.sh` builds everything and runs the full contract (Rust unit tests, all six parity gates, and the Python self-tests); CI runs the same script on every push.
|
|
237
|
+
|
|
238
|
+
License
|
|
239
|
+
=======
|
|
240
|
+
Simittag is licensed under the [BSD 2-Clause License](LICENSE). The Rust ellipse-fitting routine is derived from OpenCV; see [THIRD-PARTY-NOTICES.md](THIRD-PARTY-NOTICES.md).
|
|
241
|
+
|
|
242
|
+
The tag format is free for anyone to implement.
|
simittag-0.1.0/app.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Simittag app: encode a payload to a marker, or decode an image.
|
|
4
|
+
|
|
5
|
+
python app.py encode --id 12345 --out marker.png
|
|
6
|
+
python app.py encode --raw "hi" --out marker.png
|
|
7
|
+
python app.py decode image.png
|
|
8
|
+
python app.py calibrate img1.png img2.png ... --out intrinsics.json
|
|
9
|
+
python app.py decode image.png --intrinsics intrinsics.json
|
|
10
|
+
|
|
11
|
+
Thin shim around simittag.cli so the repo layout keeps working; the
|
|
12
|
+
installed package exposes the same interface as the `simittag` command.
|
|
13
|
+
"""
|
|
14
|
+
from simittag.cli import main
|
|
15
|
+
|
|
16
|
+
if __name__ == "__main__":
|
|
17
|
+
main()
|
simittag-0.1.0/check.sh
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Full verification: Rust build + unit tests + all six parity gates + Python
|
|
3
|
+
# reference self-tests. CI runs exactly this; run it locally before pushing.
|
|
4
|
+
set -e
|
|
5
|
+
cd "$(dirname "$0")"
|
|
6
|
+
PATH="$HOME/.cargo/bin:$PATH"
|
|
7
|
+
|
|
8
|
+
echo "== rust: build + unit tests =="
|
|
9
|
+
cargo build --release -p simittag-cli --manifest-path rust/Cargo.toml
|
|
10
|
+
cargo test --release -p simittag-core --manifest-path rust/Cargo.toml
|
|
11
|
+
|
|
12
|
+
echo "== rust: parity gates (fixtures are the contract) =="
|
|
13
|
+
B=rust/target/release/simittag
|
|
14
|
+
"$B" parity-spec fixtures/spec.json
|
|
15
|
+
"$B" parity-codec fixtures/codec.json
|
|
16
|
+
"$B" parity-geometry fixtures/geometry.json
|
|
17
|
+
"$B" parity-stages fixtures
|
|
18
|
+
"$B" parity-candidates fixtures
|
|
19
|
+
"$B" parity-detect fixtures
|
|
20
|
+
|
|
21
|
+
echo "== python reference self-tests =="
|
|
22
|
+
python3 -m simittag.gf256
|
|
23
|
+
python3 -m simittag.spec
|
|
24
|
+
python3 -m simittag.codec
|
|
25
|
+
python3 -m simittag.payload
|
|
26
|
+
python3 -m unittest discover -s tests -v
|
|
27
|
+
|
|
28
|
+
echo "ALL CHECKS PASSED"
|
|
File without changes
|