rails49-r49 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.
- rails49_r49-0.1.0/.gitignore +6 -0
- rails49_r49-0.1.0/LICENSE +21 -0
- rails49_r49-0.1.0/PKG-INFO +82 -0
- rails49_r49-0.1.0/README.md +71 -0
- rails49_r49-0.1.0/pyproject.toml +47 -0
- rails49_r49-0.1.0/src/r49/__init__.py +76 -0
- rails49_r49-0.1.0/src/r49/archive.py +84 -0
- rails49_r49-0.1.0/src/r49/calibration_fit.py +135 -0
- rails49_r49-0.1.0/src/r49/data.json +12 -0
- rails49_r49-0.1.0/src/r49/data.py +25 -0
- rails49_r49-0.1.0/src/r49/errors.py +52 -0
- rails49_r49-0.1.0/src/r49/manifest.py +290 -0
- rails49_r49-0.1.0/src/r49/py.typed +0 -0
- rails49_r49-0.1.0/src/r49/vocabulary.py +43 -0
- rails49_r49-0.1.0/tests/test_build.py +25 -0
- rails49_r49-0.1.0/tests/test_conformance.py +114 -0
- rails49_r49-0.1.0/tests/test_reader.py +189 -0
- rails49_r49-0.1.0/uv.lock +555 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bernhard Boser
|
|
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,82 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: rails49-r49
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Read .r49 layout archives
|
|
5
|
+
Project-URL: Repository, https://github.com/rails49/r49
|
|
6
|
+
Project-URL: Issues, https://github.com/rails49/r49/issues
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Requires-Dist: pydantic>=2.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# rails49-r49
|
|
13
|
+
|
|
14
|
+
Reads `.r49` railroad layout archives in Python: a zip holding a `manifest.json`
|
|
15
|
+
plus the layout photographs it describes. The format is defined in
|
|
16
|
+
[FORMAT.md](https://github.com/rails49/r49/blob/main/FORMAT.md); this package is a
|
|
17
|
+
reader of it, and agrees with the TypeScript package
|
|
18
|
+
[`@rails49/r49`](https://github.com/rails49/r49/tree/main/packages/r49) on every
|
|
19
|
+
[conformance case](https://github.com/rails49/r49/tree/main/conformance).
|
|
20
|
+
|
|
21
|
+
Read-only: it loads, validates and fits archives, and never writes one.
|
|
22
|
+
|
|
23
|
+
## Interface
|
|
24
|
+
|
|
25
|
+
See
|
|
26
|
+
[`src/r49/__init__.py`](https://github.com/rails49/r49/blob/main/packages/r49-py/src/r49/__init__.py),
|
|
27
|
+
the package's public surface.
|
|
28
|
+
Anything not exported there is internal and may change without notice.
|
|
29
|
+
|
|
30
|
+
The scale table and the vocabulary are read from the TypeScript package's
|
|
31
|
+
[`data.json`](https://github.com/rails49/r49/blob/main/packages/r49/src/data.json),
|
|
32
|
+
linked into the package as `src/r49/data.json`; no value of them is declared
|
|
33
|
+
in code. The sdist holds a copy of the file in place of the link.
|
|
34
|
+
|
|
35
|
+
## Example
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
import r49
|
|
39
|
+
|
|
40
|
+
archive = r49.load("layout.r49") # a path, or the archive's bytes
|
|
41
|
+
manifest = archive.manifest
|
|
42
|
+
print(manifest.layout.name, manifest.layout.scale)
|
|
43
|
+
|
|
44
|
+
result = r49.calibration_fit(manifest)
|
|
45
|
+
print(result.dpt, result.residual) # None where the fit cannot answer
|
|
46
|
+
for left_out in result.left_out:
|
|
47
|
+
print(left_out.index, left_out.reason)
|
|
48
|
+
|
|
49
|
+
jpeg = archive.image(manifest.images[0].filename)
|
|
50
|
+
for mismatch in archive.image_mismatches(): # empty when the images match
|
|
51
|
+
print(mismatch.rule, mismatch.filename)
|
|
52
|
+
|
|
53
|
+
r49.is_known_class("stock.loco.steam") # True
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
A refusal is an `r49.R49Error` whose `rule` is the format rule id broken, such
|
|
57
|
+
as `manifest/unknown-key`. The id is the contract; the message is not. An
|
|
58
|
+
archive whose images and manifest disagree still loads; `image_mismatches()`
|
|
59
|
+
lists each disagreement under `images/missing` or `images/orphan`.
|
|
60
|
+
`r49.parse_manifest` reads a bare `manifest.json`, which is exempt from the zip
|
|
61
|
+
and image rules.
|
|
62
|
+
|
|
63
|
+
A car label's `class` is `class_` in Python; the manifest key stays `class`.
|
|
64
|
+
|
|
65
|
+
## Build and test
|
|
66
|
+
|
|
67
|
+
Managed with [uv](https://docs.astral.sh/uv/):
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
uv sync --locked
|
|
71
|
+
uv run pytest # the conformance cases and the reader's own tests
|
|
72
|
+
uv run ruff check .
|
|
73
|
+
uv run black --check .
|
|
74
|
+
uv run pyright
|
|
75
|
+
uv build # the sdist, then the wheel from it
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Installed with `pip install rails49-r49`.
|
|
79
|
+
|
|
80
|
+
## Licence
|
|
81
|
+
|
|
82
|
+
[MIT](https://github.com/rails49/r49/blob/main/LICENSE-MIT).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# rails49-r49
|
|
2
|
+
|
|
3
|
+
Reads `.r49` railroad layout archives in Python: a zip holding a `manifest.json`
|
|
4
|
+
plus the layout photographs it describes. The format is defined in
|
|
5
|
+
[FORMAT.md](https://github.com/rails49/r49/blob/main/FORMAT.md); this package is a
|
|
6
|
+
reader of it, and agrees with the TypeScript package
|
|
7
|
+
[`@rails49/r49`](https://github.com/rails49/r49/tree/main/packages/r49) on every
|
|
8
|
+
[conformance case](https://github.com/rails49/r49/tree/main/conformance).
|
|
9
|
+
|
|
10
|
+
Read-only: it loads, validates and fits archives, and never writes one.
|
|
11
|
+
|
|
12
|
+
## Interface
|
|
13
|
+
|
|
14
|
+
See
|
|
15
|
+
[`src/r49/__init__.py`](https://github.com/rails49/r49/blob/main/packages/r49-py/src/r49/__init__.py),
|
|
16
|
+
the package's public surface.
|
|
17
|
+
Anything not exported there is internal and may change without notice.
|
|
18
|
+
|
|
19
|
+
The scale table and the vocabulary are read from the TypeScript package's
|
|
20
|
+
[`data.json`](https://github.com/rails49/r49/blob/main/packages/r49/src/data.json),
|
|
21
|
+
linked into the package as `src/r49/data.json`; no value of them is declared
|
|
22
|
+
in code. The sdist holds a copy of the file in place of the link.
|
|
23
|
+
|
|
24
|
+
## Example
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import r49
|
|
28
|
+
|
|
29
|
+
archive = r49.load("layout.r49") # a path, or the archive's bytes
|
|
30
|
+
manifest = archive.manifest
|
|
31
|
+
print(manifest.layout.name, manifest.layout.scale)
|
|
32
|
+
|
|
33
|
+
result = r49.calibration_fit(manifest)
|
|
34
|
+
print(result.dpt, result.residual) # None where the fit cannot answer
|
|
35
|
+
for left_out in result.left_out:
|
|
36
|
+
print(left_out.index, left_out.reason)
|
|
37
|
+
|
|
38
|
+
jpeg = archive.image(manifest.images[0].filename)
|
|
39
|
+
for mismatch in archive.image_mismatches(): # empty when the images match
|
|
40
|
+
print(mismatch.rule, mismatch.filename)
|
|
41
|
+
|
|
42
|
+
r49.is_known_class("stock.loco.steam") # True
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
A refusal is an `r49.R49Error` whose `rule` is the format rule id broken, such
|
|
46
|
+
as `manifest/unknown-key`. The id is the contract; the message is not. An
|
|
47
|
+
archive whose images and manifest disagree still loads; `image_mismatches()`
|
|
48
|
+
lists each disagreement under `images/missing` or `images/orphan`.
|
|
49
|
+
`r49.parse_manifest` reads a bare `manifest.json`, which is exempt from the zip
|
|
50
|
+
and image rules.
|
|
51
|
+
|
|
52
|
+
A car label's `class` is `class_` in Python; the manifest key stays `class`.
|
|
53
|
+
|
|
54
|
+
## Build and test
|
|
55
|
+
|
|
56
|
+
Managed with [uv](https://docs.astral.sh/uv/):
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
uv sync --locked
|
|
60
|
+
uv run pytest # the conformance cases and the reader's own tests
|
|
61
|
+
uv run ruff check .
|
|
62
|
+
uv run black --check .
|
|
63
|
+
uv run pyright
|
|
64
|
+
uv build # the sdist, then the wheel from it
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Installed with `pip install rails49-r49`.
|
|
68
|
+
|
|
69
|
+
## Licence
|
|
70
|
+
|
|
71
|
+
[MIT](https://github.com/rails49/r49/blob/main/LICENSE-MIT).
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "rails49-r49"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Read .r49 layout archives"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
dependencies = ["pydantic>=2.11"]
|
|
9
|
+
|
|
10
|
+
[project.urls]
|
|
11
|
+
Repository = "https://github.com/rails49/r49"
|
|
12
|
+
Issues = "https://github.com/rails49/r49/issues"
|
|
13
|
+
|
|
14
|
+
[dependency-groups]
|
|
15
|
+
dev = ["black>=25.1", "pyright>=1.1.400", "pytest>=8.3", "ruff>=0.11"]
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["hatchling"]
|
|
19
|
+
build-backend = "hatchling.build"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["src/r49"]
|
|
23
|
+
|
|
24
|
+
# The sdist holds a copy of ../r49/src/data.json in place of the link, and a
|
|
25
|
+
# copy of the repository's LICENSE-MIT, which the wheel built from it lists as
|
|
26
|
+
# its license file.
|
|
27
|
+
[tool.hatch.build.targets.sdist]
|
|
28
|
+
exclude = ["src/r49/data.json"]
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.sdist.force-include]
|
|
31
|
+
"../r49/src/data.json" = "src/r49/data.json"
|
|
32
|
+
"../../LICENSE-MIT" = "LICENSE"
|
|
33
|
+
|
|
34
|
+
[tool.black]
|
|
35
|
+
line-length = 100
|
|
36
|
+
|
|
37
|
+
[tool.ruff]
|
|
38
|
+
line-length = 100
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "I", "UP", "B"]
|
|
42
|
+
|
|
43
|
+
[tool.pyright]
|
|
44
|
+
include = ["src", "tests"]
|
|
45
|
+
typeCheckingMode = "strict"
|
|
46
|
+
venvPath = "."
|
|
47
|
+
venv = ".venv"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Read ``.r49`` layout archives.
|
|
2
|
+
|
|
3
|
+
This module is the package's interface; anything not exported here is internal.
|
|
4
|
+
FORMAT.md defines the format, and the conformance cases are how this reader
|
|
5
|
+
shows it agrees with the TypeScript package, @rails49/r49.
|
|
6
|
+
|
|
7
|
+
Read-only: nothing here writes an archive.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .archive import R49Archive, load
|
|
11
|
+
from .calibration_fit import (
|
|
12
|
+
CalibrationFit,
|
|
13
|
+
CalibrationPair,
|
|
14
|
+
LeftOutPoint,
|
|
15
|
+
LeftOutReason,
|
|
16
|
+
calibration_fit,
|
|
17
|
+
)
|
|
18
|
+
from .errors import FORMAT_RULES, FormatRule, ImageMismatch, R49Error
|
|
19
|
+
from .manifest import (
|
|
20
|
+
MANIFEST_VERSION,
|
|
21
|
+
VALID_SCALES,
|
|
22
|
+
Calibration,
|
|
23
|
+
Camera,
|
|
24
|
+
CarLabel,
|
|
25
|
+
Image,
|
|
26
|
+
Layout,
|
|
27
|
+
Manifest,
|
|
28
|
+
Point,
|
|
29
|
+
Provenance,
|
|
30
|
+
ReferencePoint,
|
|
31
|
+
Resolution,
|
|
32
|
+
Scale,
|
|
33
|
+
Sensor,
|
|
34
|
+
WorldPoint,
|
|
35
|
+
parse_manifest,
|
|
36
|
+
)
|
|
37
|
+
from .vocabulary import (
|
|
38
|
+
VocabularyNode,
|
|
39
|
+
is_known_class,
|
|
40
|
+
is_segment_prefix,
|
|
41
|
+
vocabulary_subtypes,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"FORMAT_RULES",
|
|
46
|
+
"MANIFEST_VERSION",
|
|
47
|
+
"VALID_SCALES",
|
|
48
|
+
"Calibration",
|
|
49
|
+
"CalibrationFit",
|
|
50
|
+
"CalibrationPair",
|
|
51
|
+
"Camera",
|
|
52
|
+
"CarLabel",
|
|
53
|
+
"FormatRule",
|
|
54
|
+
"Image",
|
|
55
|
+
"ImageMismatch",
|
|
56
|
+
"Layout",
|
|
57
|
+
"LeftOutPoint",
|
|
58
|
+
"LeftOutReason",
|
|
59
|
+
"Manifest",
|
|
60
|
+
"Point",
|
|
61
|
+
"Provenance",
|
|
62
|
+
"R49Archive",
|
|
63
|
+
"R49Error",
|
|
64
|
+
"ReferencePoint",
|
|
65
|
+
"Resolution",
|
|
66
|
+
"Scale",
|
|
67
|
+
"Sensor",
|
|
68
|
+
"VocabularyNode",
|
|
69
|
+
"WorldPoint",
|
|
70
|
+
"calibration_fit",
|
|
71
|
+
"is_known_class",
|
|
72
|
+
"is_segment_prefix",
|
|
73
|
+
"load",
|
|
74
|
+
"parse_manifest",
|
|
75
|
+
"vocabulary_subtypes",
|
|
76
|
+
]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Loading a ``.r49`` archive: a zip of ``manifest.json`` and the images it names."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import os
|
|
7
|
+
import zipfile
|
|
8
|
+
import zlib
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .errors import ImageMismatch, R49Error
|
|
12
|
+
from .manifest import Manifest, parse_manifest
|
|
13
|
+
|
|
14
|
+
MANIFEST_FILE = "manifest.json"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class R49Archive:
|
|
18
|
+
"""A loaded archive, read-only. Get one from :func:`load`."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, manifest: Manifest, files: dict[str, bytes]) -> None:
|
|
21
|
+
self._manifest = manifest
|
|
22
|
+
self._files = files
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def manifest(self) -> Manifest:
|
|
26
|
+
"""The validated manifest."""
|
|
27
|
+
return self._manifest
|
|
28
|
+
|
|
29
|
+
def entries(self) -> list[str]:
|
|
30
|
+
"""Every file in the zip, ``manifest.json`` included, in zip order.
|
|
31
|
+
Directory entries are left out."""
|
|
32
|
+
return list(self._files)
|
|
33
|
+
|
|
34
|
+
def image(self, filename: str) -> bytes | None:
|
|
35
|
+
"""The bytes of the file at ``filename``, or None when the zip has none."""
|
|
36
|
+
return self._files.get(filename)
|
|
37
|
+
|
|
38
|
+
def image_mismatches(self) -> list[ImageMismatch]:
|
|
39
|
+
"""Every way the manifest and the zip disagree about which images this
|
|
40
|
+
archive holds: ``images/missing`` for each image entry whose file the
|
|
41
|
+
zip lacks, in manifest order, then ``images/orphan`` for each file
|
|
42
|
+
other than ``manifest.json`` that no image names, in zip order. Empty
|
|
43
|
+
for an archive that follows both rules.
|
|
44
|
+
|
|
45
|
+
Reported, not raised: :func:`load` accepts an archive with either."""
|
|
46
|
+
named = [image.filename for image in self._manifest.images]
|
|
47
|
+
missing = [
|
|
48
|
+
ImageMismatch("images/missing", name) for name in named if name not in self._files
|
|
49
|
+
]
|
|
50
|
+
orphan = [
|
|
51
|
+
ImageMismatch("images/orphan", path)
|
|
52
|
+
for path in self._files
|
|
53
|
+
if path != MANIFEST_FILE and path not in named
|
|
54
|
+
]
|
|
55
|
+
return missing + orphan
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load(source: bytes | str | os.PathLike[str]) -> R49Archive:
|
|
59
|
+
"""Load an archive from its bytes or from a path, applying every format rule
|
|
60
|
+
but the two image rules, which :meth:`R49Archive.image_mismatches` reports.
|
|
61
|
+
|
|
62
|
+
:raises R49Error: naming the format rule the archive breaks.
|
|
63
|
+
:raises OSError: if a path cannot be read; that is no verdict on an archive.
|
|
64
|
+
"""
|
|
65
|
+
data = source if isinstance(source, bytes) else Path(source).read_bytes()
|
|
66
|
+
if not data:
|
|
67
|
+
raise R49Error("archive/empty", "Cannot load .r49 archive: data is empty")
|
|
68
|
+
files = _read_zip(data)
|
|
69
|
+
|
|
70
|
+
if MANIFEST_FILE not in files:
|
|
71
|
+
raise R49Error("archive/no-manifest", "manifest.json not found in archive")
|
|
72
|
+
return R49Archive(parse_manifest(files[MANIFEST_FILE]), files)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_zip(data: bytes) -> dict[str, bytes]:
|
|
76
|
+
"""Every file in the zip by path. Read whole on load, so that a zip that
|
|
77
|
+
cannot be read is refused here rather than when an image is asked for."""
|
|
78
|
+
try:
|
|
79
|
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
|
80
|
+
return {info.filename: zf.read(info) for info in zf.infolist() if not info.is_dir()}
|
|
81
|
+
except (zipfile.BadZipFile, zlib.error, EOFError, NotImplementedError, RuntimeError) as err:
|
|
82
|
+
raise R49Error(
|
|
83
|
+
"archive/not-zip", f"Failed to load .r49 archive (size: {len(data)} bytes): {err}"
|
|
84
|
+
) from None
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""The calibration fit, as FORMAT.md § The calibration fit defines it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
from .data import SCALE_TO_RATIO, STANDARD_GAUGE
|
|
10
|
+
from .manifest import Calibration, Manifest
|
|
11
|
+
|
|
12
|
+
LeftOutReason = Literal["off-reference-plane", "above-camera", "no-partner"]
|
|
13
|
+
"""Why a reference point is not in the fit, in FORMAT.md's order:
|
|
14
|
+
|
|
15
|
+
* ``off-reference-plane``: there is no camera height and the point's ``z`` is
|
|
16
|
+
not the reference height.
|
|
17
|
+
* ``above-camera``: the point's ``z`` is the camera height or more.
|
|
18
|
+
* ``no-partner``: no other point still in the fit has the same ``z``.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class LeftOutPoint:
|
|
24
|
+
"""A reference point the fit does not use."""
|
|
25
|
+
|
|
26
|
+
index: int
|
|
27
|
+
"""The point's index in ``calibration.points``."""
|
|
28
|
+
reason: LeftOutReason
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
CalibrationPair = tuple[int, int]
|
|
32
|
+
"""A pair the fit uses, by the indexes of its two points in
|
|
33
|
+
``calibration.points``, the lower first."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class CalibrationFit:
|
|
38
|
+
"""What a fit reports, together."""
|
|
39
|
+
|
|
40
|
+
dpt: float | None
|
|
41
|
+
"""DPT, or None where it does not resolve: no pair with a nonzero separation,
|
|
42
|
+
or a camera height at or below the reference height."""
|
|
43
|
+
residual: float | None
|
|
44
|
+
"""The RMS residual in image pixels, or None with fewer than two pairs."""
|
|
45
|
+
pairs: tuple[CalibrationPair, ...]
|
|
46
|
+
"""The pairs used."""
|
|
47
|
+
left_out: tuple[LeftOutPoint, ...]
|
|
48
|
+
"""The points left out, by index, each with the first reason that applies."""
|
|
49
|
+
camera_height_fits: bool | None
|
|
50
|
+
"""None without a camera height; otherwise whether it is above every
|
|
51
|
+
reference point's ``z``. False is no format error, but the DPT should not
|
|
52
|
+
be trusted."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def calibration_fit(manifest: Manifest) -> CalibrationFit:
|
|
56
|
+
"""Fit the layout's scale through its reference points.
|
|
57
|
+
|
|
58
|
+
``d_mm²`` weighting trusts long baselines more; heights compare exactly,
|
|
59
|
+
because they are typed by hand.
|
|
60
|
+
"""
|
|
61
|
+
calibration = manifest.layout.calibration
|
|
62
|
+
h = calibration.camera_height_mm
|
|
63
|
+
z_ref = calibration.reference_height_mm or 0.0
|
|
64
|
+
|
|
65
|
+
left_out, pairs = _select(calibration)
|
|
66
|
+
separations = [_separations(calibration, i, j) for i, j in pairs]
|
|
67
|
+
|
|
68
|
+
numerator = sum(d_px * d_mm for d_px, d_mm in separations)
|
|
69
|
+
denominator = sum(d_mm * d_mm for _, d_mm in separations)
|
|
70
|
+
scale = numerator / denominator if denominator != 0 else None
|
|
71
|
+
|
|
72
|
+
dpt: float | None = None
|
|
73
|
+
if scale is not None and (h is None or h > z_ref):
|
|
74
|
+
# Without a camera height the fit already sits on the reference plane;
|
|
75
|
+
# with one it is at layout zero, and this lifts it to the track.
|
|
76
|
+
at_reference = scale if h is None else scale * h / (h - z_ref)
|
|
77
|
+
dpt = at_reference * STANDARD_GAUGE / SCALE_TO_RATIO[manifest.layout.scale]
|
|
78
|
+
|
|
79
|
+
residual: float | None = None
|
|
80
|
+
if scale is not None and len(separations) >= 2:
|
|
81
|
+
residual = math.sqrt(
|
|
82
|
+
sum((d_px - scale * d_mm) ** 2 for d_px, d_mm in separations) / len(separations)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
fits: bool | None = None
|
|
86
|
+
if h is not None:
|
|
87
|
+
fits = all(left.reason != "above-camera" for left in left_out)
|
|
88
|
+
|
|
89
|
+
return CalibrationFit(
|
|
90
|
+
dpt=dpt,
|
|
91
|
+
residual=residual,
|
|
92
|
+
pairs=tuple(pairs),
|
|
93
|
+
left_out=tuple(left_out),
|
|
94
|
+
camera_height_fits=fits,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _select(calibration: Calibration) -> tuple[list[LeftOutPoint], list[CalibrationPair]]:
|
|
99
|
+
"""The points left out and the pairs: every unordered pair of the rest that
|
|
100
|
+
shares a ``z``."""
|
|
101
|
+
h = calibration.camera_height_mm
|
|
102
|
+
z_ref = calibration.reference_height_mm or 0.0
|
|
103
|
+
points = calibration.points
|
|
104
|
+
|
|
105
|
+
left_out: dict[int, LeftOutReason] = {}
|
|
106
|
+
for index, point in enumerate(points):
|
|
107
|
+
if h is None and point.world.z != z_ref:
|
|
108
|
+
left_out[index] = "off-reference-plane"
|
|
109
|
+
elif h is not None and point.world.z >= h:
|
|
110
|
+
left_out[index] = "above-camera"
|
|
111
|
+
|
|
112
|
+
kept = [index for index in range(len(points)) if index not in left_out]
|
|
113
|
+
pairs = [
|
|
114
|
+
(i, j)
|
|
115
|
+
for n, i in enumerate(kept)
|
|
116
|
+
for j in kept[n + 1 :]
|
|
117
|
+
if points[i].world.z == points[j].world.z
|
|
118
|
+
]
|
|
119
|
+
paired = {index for pair in pairs for index in pair}
|
|
120
|
+
for index in kept:
|
|
121
|
+
if index not in paired:
|
|
122
|
+
left_out[index] = "no-partner"
|
|
123
|
+
|
|
124
|
+
return [LeftOutPoint(index, left_out[index]) for index in sorted(left_out)], pairs
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _separations(calibration: Calibration, i: int, j: int) -> tuple[float, float]:
|
|
128
|
+
"""A pair's pixel separation, scaled to layout zero when the camera height
|
|
129
|
+
is known, and its separation on the layout in mm."""
|
|
130
|
+
a, b = calibration.points[i], calibration.points[j]
|
|
131
|
+
h = calibration.camera_height_mm
|
|
132
|
+
d_px = math.hypot(a.px.x - b.px.x, a.px.y - b.px.y)
|
|
133
|
+
if h is not None:
|
|
134
|
+
d_px *= (h - a.world.z) / h
|
|
135
|
+
return d_px, math.hypot(a.world.x - b.world.x, a.world.y - b.world.y)
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"standard_gauge": 1435.0,
|
|
3
|
+
"scale_to_ratio": { "G": 25, "O": 48, "S": 64, "HO": 87, "T": 120, "N": 160, "Z": 220 },
|
|
4
|
+
"vocabulary": {
|
|
5
|
+
"stock": {
|
|
6
|
+
"loco": { "steam": {}, "diesel": {}, "electric": {} },
|
|
7
|
+
"passenger": {},
|
|
8
|
+
"freight": {}
|
|
9
|
+
}
|
|
10
|
+
},
|
|
11
|
+
"recommended_dpt": 20
|
|
12
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""The format's tables, read from ``data.json``.
|
|
2
|
+
|
|
3
|
+
The file is the TypeScript package's own (``packages/r49/src/data.json``, linked
|
|
4
|
+
here), so both readers compute with the same numbers. FORMAT.md § Scales and
|
|
5
|
+
gauge and § The vocabulary state them in prose. No value is restated in code.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from importlib.resources import files
|
|
12
|
+
from typing import Any, Final
|
|
13
|
+
|
|
14
|
+
_DATA: Final[dict[str, Any]] = json.loads(
|
|
15
|
+
files(__package__).joinpath("data.json").read_text(encoding="utf-8")
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
STANDARD_GAUGE: Final[float] = _DATA["standard_gauge"]
|
|
19
|
+
"""The prototype's standard gauge in mm."""
|
|
20
|
+
|
|
21
|
+
SCALE_TO_RATIO: Final[dict[str, float]] = _DATA["scale_to_ratio"]
|
|
22
|
+
"""Scale name to reduction ratio (HO is 1:87)."""
|
|
23
|
+
|
|
24
|
+
VOCABULARY: Final[dict[str, Any]] = _DATA["vocabulary"]
|
|
25
|
+
"""The vocabulary: what a label's ``class`` should match."""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""The one error this package raises for an archive that breaks a format rule,
|
|
2
|
+
and the image mismatches an archive reports instead of raising."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Final, Literal, get_args
|
|
8
|
+
|
|
9
|
+
FormatRule = Literal[
|
|
10
|
+
"archive/empty",
|
|
11
|
+
"archive/not-zip",
|
|
12
|
+
"archive/no-manifest",
|
|
13
|
+
"manifest/not-json",
|
|
14
|
+
"manifest/version",
|
|
15
|
+
"manifest/unknown-key",
|
|
16
|
+
"manifest/duplicate-id",
|
|
17
|
+
"manifest/provenance",
|
|
18
|
+
"manifest/schema",
|
|
19
|
+
"images/missing",
|
|
20
|
+
"images/orphan",
|
|
21
|
+
]
|
|
22
|
+
"""A format rule id, as FORMAT.md § Format rules lists them."""
|
|
23
|
+
|
|
24
|
+
FORMAT_RULES: Final[tuple[FormatRule, ...]] = get_args(FormatRule)
|
|
25
|
+
"""Every format rule id, in FORMAT.md's order.
|
|
26
|
+
|
|
27
|
+
The id is the contract; the wording of a message is not. A caller that needs to
|
|
28
|
+
tell one refusal from another switches on :attr:`R49Error.rule`, never on the
|
|
29
|
+
text.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class R49Error(Exception):
|
|
34
|
+
"""An archive or manifest broke a format rule. Anything else raised is a bug
|
|
35
|
+
or a misuse, not a verdict on the archive."""
|
|
36
|
+
|
|
37
|
+
rule: FormatRule
|
|
38
|
+
"""The format rule the archive or manifest broke."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, rule: FormatRule, message: str) -> None:
|
|
41
|
+
super().__init__(message)
|
|
42
|
+
self.rule = rule
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ImageMismatch:
|
|
47
|
+
"""One way the manifest and the zip disagree about which images an archive
|
|
48
|
+
holds. Reported by :meth:`R49Archive.image_mismatches`, never raised."""
|
|
49
|
+
|
|
50
|
+
rule: Literal["images/missing", "images/orphan"]
|
|
51
|
+
filename: str
|
|
52
|
+
"""The manifest filename, or the zip entry, the disagreement is about."""
|