signa-core 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.
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Patrick Leiverkus
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: signa-core
3
+ Version: 0.1.0
4
+ Summary: Reusable, GUI-free ArUco marker detection core for the Signa/Evidentia pipeline (OpenCV).
5
+ Project-URL: Repository, https://github.com/leiverkus/signa
6
+ Author-email: Patrick Leiverkus <leiverkus@gmail.com>
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: archaeology,aruco,evidentia,marker-detection,opencv,photogrammetry
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Scientific/Engineering :: Image Recognition
15
+ Requires-Python: >=3.10
16
+ Requires-Dist: numpy>=1.24
17
+ Requires-Dist: opencv-contrib-python-headless>=4.7
18
+ Description-Content-Type: text/markdown
19
+
20
+ # signa-core
21
+
22
+ **Reusable, GUI-free ArUco marker detection for the Evidentia pipeline.**
23
+
24
+ `signa-core` is the pure OpenCV/numpy detection layer extracted from the
25
+ [Signa WebODM plugin](../README.md). It detects ArUco markers in images and
26
+ returns their ids, corners and centers — leaving the *interpretation* to the
27
+ caller:
28
+
29
+ - **Signa** (WebODM plugin) matches markers against surveyed coordinates → `gcp_list.txt`.
30
+ - **Mensura** matches markers against their known physical size → metric scale constraints.
31
+
32
+ Both share this one detection implementation instead of duplicating it (or
33
+ depending on `find-gcp`).
34
+
35
+ ## Install / use
36
+
37
+ ```bash
38
+ pip install signa-core # or: pip install -e signa-core
39
+ ```
40
+
41
+ ```python
42
+ from signa_core import detect_markers
43
+
44
+ # dict_id 1 = DICT_4X4_100 (see DICT_CHOICES)
45
+ result = detect_markers(["img1.jpg", "img2.jpg"], dict_id=1, minrate=0.01, ignore=0.33)
46
+ # {'img1.jpg': [{'id': 7, 'corners': [[x, y], ...4], 'center': (cx, cy)}, ...], ...}
47
+ ```
48
+
49
+ ## Licence
50
+
51
+ **MIT** — `signa-core` is the permissive, reusable core. The surrounding
52
+ **Signa WebODM plugin is AGPL-3.0** (WebODM-forced). This split mirrors the
53
+ Itinera pattern (MIT core + copyleft plugin).
54
+
55
+ ## Note on the Signa worker
56
+
57
+ WebODM runs Signa's GCP detection by serializing a single self-contained
58
+ function into a Celery worker, so the plugin keeps its own inlined copy of the
59
+ detection primitive for that path. `signa-core` is the canonical, importable
60
+ version for all non-worker consumers; the two are kept behaviourally identical.
61
+
62
+ ## Releasing (PyPI)
63
+
64
+ Published via **PyPI Trusted Publishing** (OIDC) — no API token is stored. See
65
+ [`.github/workflows/publish-signa-core.yml`](../.github/workflows/publish-signa-core.yml)
66
+ and its header for the one-time pypi.org publisher setup. To release:
67
+
68
+ ```bash
69
+ # bump version in pyproject.toml, then:
70
+ git tag signa-core-v0.1.0 && git push origin signa-core-v0.1.0
71
+ ```
72
+
73
+ The workflow runs the tests, builds the sdist + wheel, and publishes them.
@@ -0,0 +1,54 @@
1
+ # signa-core
2
+
3
+ **Reusable, GUI-free ArUco marker detection for the Evidentia pipeline.**
4
+
5
+ `signa-core` is the pure OpenCV/numpy detection layer extracted from the
6
+ [Signa WebODM plugin](../README.md). It detects ArUco markers in images and
7
+ returns their ids, corners and centers — leaving the *interpretation* to the
8
+ caller:
9
+
10
+ - **Signa** (WebODM plugin) matches markers against surveyed coordinates → `gcp_list.txt`.
11
+ - **Mensura** matches markers against their known physical size → metric scale constraints.
12
+
13
+ Both share this one detection implementation instead of duplicating it (or
14
+ depending on `find-gcp`).
15
+
16
+ ## Install / use
17
+
18
+ ```bash
19
+ pip install signa-core # or: pip install -e signa-core
20
+ ```
21
+
22
+ ```python
23
+ from signa_core import detect_markers
24
+
25
+ # dict_id 1 = DICT_4X4_100 (see DICT_CHOICES)
26
+ result = detect_markers(["img1.jpg", "img2.jpg"], dict_id=1, minrate=0.01, ignore=0.33)
27
+ # {'img1.jpg': [{'id': 7, 'corners': [[x, y], ...4], 'center': (cx, cy)}, ...], ...}
28
+ ```
29
+
30
+ ## Licence
31
+
32
+ **MIT** — `signa-core` is the permissive, reusable core. The surrounding
33
+ **Signa WebODM plugin is AGPL-3.0** (WebODM-forced). This split mirrors the
34
+ Itinera pattern (MIT core + copyleft plugin).
35
+
36
+ ## Note on the Signa worker
37
+
38
+ WebODM runs Signa's GCP detection by serializing a single self-contained
39
+ function into a Celery worker, so the plugin keeps its own inlined copy of the
40
+ detection primitive for that path. `signa-core` is the canonical, importable
41
+ version for all non-worker consumers; the two are kept behaviourally identical.
42
+
43
+ ## Releasing (PyPI)
44
+
45
+ Published via **PyPI Trusted Publishing** (OIDC) — no API token is stored. See
46
+ [`.github/workflows/publish-signa-core.yml`](../.github/workflows/publish-signa-core.yml)
47
+ and its header for the one-time pypi.org publisher setup. To release:
48
+
49
+ ```bash
50
+ # bump version in pyproject.toml, then:
51
+ git tag signa-core-v0.1.0 && git push origin signa-core-v0.1.0
52
+ ```
53
+
54
+ The workflow runs the tests, builds the sdist + wheel, and publishes them.
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "signa-core"
7
+ version = "0.1.0"
8
+ description = "Reusable, GUI-free ArUco marker detection core for the Signa/Evidentia pipeline (OpenCV)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "Patrick Leiverkus", email = "leiverkus@gmail.com" },
15
+ ]
16
+ keywords = ["aruco", "opencv", "marker-detection", "photogrammetry", "archaeology", "evidentia"]
17
+ classifiers = [
18
+ "Development Status :: 3 - Alpha",
19
+ "Intended Audience :: Science/Research",
20
+ "License :: OSI Approved :: MIT License",
21
+ "Programming Language :: Python :: 3",
22
+ "Topic :: Scientific/Engineering :: Image Recognition",
23
+ ]
24
+ dependencies = [
25
+ "numpy>=1.24",
26
+ "opencv-contrib-python-headless>=4.7",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/leiverkus/signa"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["signa_core"]
@@ -0,0 +1,30 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """signa-core — reusable ArUco marker detection for the Evidentia pipeline.
3
+
4
+ GUI-/WebODM-free OpenCV detection shared by the Signa WebODM plugin (GCP
5
+ detection) and Mensura (metric scale calibration).
6
+ """
7
+ from .detect import corner_center, detect_markers, prepare_image
8
+ from .dictionaries import (
9
+ DICT_CHOICES,
10
+ VALID_DICTS,
11
+ load_aruco,
12
+ make_detector,
13
+ make_dictionary,
14
+ make_parameters,
15
+ )
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "detect_markers",
21
+ "corner_center",
22
+ "prepare_image",
23
+ "make_dictionary",
24
+ "make_parameters",
25
+ "make_detector",
26
+ "load_aruco",
27
+ "DICT_CHOICES",
28
+ "VALID_DICTS",
29
+ "__version__",
30
+ ]
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Reusable ArUco marker detection (GUI-free, OpenCV).
3
+
4
+ The function mirrors the detection behaviour of the Signa WebODM plugin's
5
+ self-contained worker detector, but as a normal importable module so consumers
6
+ like Mensura (scale calibration) can reuse it. It returns per-image marker
7
+ detections (id, corners, center) and leaves the *interpretation* of those
8
+ markers — GCP matching (Signa) vs. metric scaling (Mensura) — to the caller.
9
+ """
10
+ import os
11
+
12
+ from .dictionaries import load_aruco, make_detector, make_dictionary, make_parameters
13
+
14
+
15
+ def prepare_image(frame, cv2, enhance_contrast=True):
16
+ """BGR frame -> grayscale, with an optional conservative equalization pass."""
17
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
18
+ if enhance_contrast and hasattr(cv2, "equalizeHist"):
19
+ return cv2.equalizeHist(gray)
20
+ return gray
21
+
22
+
23
+ def corner_center(corner_set):
24
+ """Mean of a marker's four corners as integer pixel coordinates."""
25
+ import numpy as np
26
+ pts = np.asarray(corner_set, dtype=float).reshape(-1, 2)
27
+ return (
28
+ int(round(float(np.mean(pts[:, 0])))),
29
+ int(round(float(np.mean(pts[:, 1])))),
30
+ )
31
+
32
+
33
+ def detect_markers(image_paths, *, dict_id=1, minrate=0.01, ignore=0.33, adjust=True):
34
+ """Detect ArUco markers across one or more images.
35
+
36
+ Parameters mirror the Signa detector: ``dict_id`` selects the ArUco
37
+ dictionary (see ``DICT_CHOICES``), ``minrate`` is the minimum marker
38
+ perimeter rate, ``ignore`` the ignored-margin-per-cell, ``adjust`` toggles
39
+ grayscale equalization.
40
+
41
+ Returns ``{image_basename: detections}`` where ``detections`` is a list of
42
+ ``{'id': int, 'corners': [[x, y], ...4], 'center': (x, y)}`` — or ``None``
43
+ for an unreadable image.
44
+ """
45
+ cv2, aruco = load_aruco()
46
+ dictionary = make_dictionary(dict_id, aruco)
47
+ detect = make_detector(dictionary, make_parameters(minrate, ignore, aruco), aruco)
48
+
49
+ results = {}
50
+ for path in image_paths:
51
+ frame = cv2.imread(path)
52
+ base = os.path.basename(path)
53
+ if frame is None:
54
+ results[base] = None
55
+ continue
56
+ gray = prepare_image(frame, cv2, adjust)
57
+ corners, ids, _ = detect(gray)
58
+ dets = []
59
+ if ids is not None:
60
+ for i in range(len(ids)):
61
+ marker_id = int(ids[i][0])
62
+ pts = corners[i].reshape(-1, 2)
63
+ dets.append({
64
+ "id": marker_id,
65
+ "corners": pts.tolist(),
66
+ "center": corner_center(corners[i]),
67
+ })
68
+ results[base] = dets
69
+ return results
@@ -0,0 +1,89 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """ArUco dictionary and detector-parameter helpers (OpenCV-version compatible).
3
+
4
+ Pure OpenCV/numpy — no WebODM/Django. These are the shared, reusable detection
5
+ primitives extracted from the Signa WebODM plugin's self-contained worker
6
+ detector, so other consumers (e.g. Mensura's scale calibration) can reuse the
7
+ exact same detection behaviour without duplicating it.
8
+
9
+ The OpenCV ArUco API changed across versions (4.6 vs 4.7+); every helper here
10
+ handles both the legacy free-function form and the newer object-oriented form.
11
+ """
12
+
13
+ # Single source of truth for the offered ArUco dictionaries, as (value, label)
14
+ # pairs. Ids 0..20 are OpenCV's predefined dictionaries; 99 is a legacy custom
15
+ # 3x3 dictionary built with extendDictionary for existing Signa marker sheets.
16
+ DICT_CHOICES = [
17
+ ("0", "0 — DICT_4X4_50"),
18
+ ("1", "1 — DICT_4X4_100"),
19
+ ("2", "2 — DICT_4X4_250"),
20
+ ("3", "3 — DICT_4X4_1000"),
21
+ ("4", "4 — DICT_5X5_50"),
22
+ ("5", "5 — DICT_5X5_100"),
23
+ ("6", "6 — DICT_5X5_250"),
24
+ ("7", "7 — DICT_5X5_1000"),
25
+ ("8", "8 — DICT_6X6_50"),
26
+ ("9", "9 — DICT_6X6_100"),
27
+ ("10", "10 — DICT_6X6_250"),
28
+ ("11", "11 — DICT_6X6_1000"),
29
+ ("12", "12 — DICT_7X7_50"),
30
+ ("13", "13 — DICT_7X7_100"),
31
+ ("14", "14 — DICT_7X7_250"),
32
+ ("15", "15 — DICT_7X7_1000"),
33
+ ("16", "16 — DICT_ARUCO_ORIGINAL"),
34
+ ("17", "17 — DICT_APRILTAG_16h5"),
35
+ ("18", "18 — DICT_APRILTAG_25h9"),
36
+ ("19", "19 — DICT_APRILTAG_36h10"),
37
+ ("20", "20 — DICT_APRILTAG_36h11"),
38
+ ("99", "99 — legacy custom 3×3"),
39
+ ]
40
+
41
+ VALID_DICTS = {int(value) for value, _label in DICT_CHOICES}
42
+
43
+
44
+ def load_aruco():
45
+ """Return ``(cv2, cv2.aruco)`` or raise a clear error if aruco is missing."""
46
+ import cv2 # noqa: PLC0415 — lazy: keeps import optional for non-detect consumers
47
+ try:
48
+ from cv2 import aruco
49
+ except ImportError as exc: # pragma: no cover
50
+ raise ImportError(
51
+ "OpenCV with the ArUco module (cv2.aruco) is required — install "
52
+ "opencv-contrib-python(-headless)."
53
+ ) from exc
54
+ return cv2, aruco
55
+
56
+
57
+ def make_dictionary(dict_id, aruco=None):
58
+ if aruco is None:
59
+ _, aruco = load_aruco()
60
+ dict_id = int(dict_id)
61
+ if dict_id == 99:
62
+ if hasattr(aruco, "extendDictionary"):
63
+ return aruco.extendDictionary(32, 3)
64
+ return aruco.Dictionary_create(32, 3)
65
+ if hasattr(aruco, "getPredefinedDictionary"):
66
+ return aruco.getPredefinedDictionary(dict_id)
67
+ return aruco.Dictionary_get(dict_id)
68
+
69
+
70
+ def make_parameters(minrate, ignore, aruco=None):
71
+ if aruco is None:
72
+ _, aruco = load_aruco()
73
+ if hasattr(aruco, "DetectorParameters"):
74
+ params = aruco.DetectorParameters()
75
+ else:
76
+ params = aruco.DetectorParameters_create()
77
+ params.minMarkerPerimeterRate = float(minrate)
78
+ params.perspectiveRemoveIgnoredMarginPerCell = float(ignore)
79
+ return params
80
+
81
+
82
+ def make_detector(dictionary, params, aruco=None):
83
+ """Return ``detect(gray) -> (corners, ids, rejected)`` across OpenCV versions."""
84
+ if aruco is None:
85
+ _, aruco = load_aruco()
86
+ if hasattr(aruco, "ArucoDetector"):
87
+ detector = aruco.ArucoDetector(dictionary, params)
88
+ return lambda gray: detector.detectMarkers(gray)
89
+ return lambda gray: aruco.detectMarkers(gray, dictionary, parameters=params)
@@ -0,0 +1,44 @@
1
+ # SPDX-License-Identifier: MIT
2
+ """Synthetic round-trip: render an ArUco marker, then detect it back."""
3
+ import numpy as np
4
+ import pytest
5
+
6
+ cv2 = pytest.importorskip("cv2")
7
+ aruco = pytest.importorskip("cv2.aruco")
8
+
9
+ from signa_core import detect_markers, make_dictionary # noqa: E402
10
+
11
+
12
+ def _render_marker(dict_id, marker_id, side=240, pad=80):
13
+ d = make_dictionary(dict_id, aruco)
14
+ if hasattr(aruco, "generateImageMarker"):
15
+ marker = aruco.generateImageMarker(d, marker_id, side)
16
+ else: # older OpenCV
17
+ marker = aruco.drawMarker(d, marker_id, side)
18
+ canvas = np.full((side + 2 * pad, side + 2 * pad), 255, dtype=np.uint8)
19
+ canvas[pad:pad + side, pad:pad + side] = marker
20
+ return cv2.cvtColor(canvas, cv2.COLOR_GRAY2BGR)
21
+
22
+
23
+ def test_detect_synthetic_marker(tmp_path):
24
+ img_path = tmp_path / "marker.png"
25
+ cv2.imwrite(str(img_path), _render_marker(dict_id=1, marker_id=7))
26
+
27
+ result = detect_markers([str(img_path)], dict_id=1, minrate=0.01, ignore=0.33)
28
+ dets = result["marker.png"]
29
+
30
+ assert dets, "no markers detected"
31
+ ids = {d["id"] for d in dets}
32
+ assert 7 in ids
33
+ det = next(d for d in dets if d["id"] == 7)
34
+ assert len(det["corners"]) == 4
35
+ cx, cy = det["center"]
36
+ # marker is centered on the canvas (side 240, pad 80 -> center at 200,200)
37
+ assert abs(cx - 200) <= 5 and abs(cy - 200) <= 5
38
+
39
+
40
+ def test_unreadable_image_is_none(tmp_path):
41
+ bad = tmp_path / "nope.png"
42
+ bad.write_bytes(b"not an image")
43
+ result = detect_markers([str(bad)], dict_id=1)
44
+ assert result["nope.png"] is None