openneodb 1.0.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.
- openneodb-1.0.0/.github/workflows/ci.yml +98 -0
- openneodb-1.0.0/.gitignore +5 -0
- openneodb-1.0.0/LICENSE +21 -0
- openneodb-1.0.0/PKG-INFO +133 -0
- openneodb-1.0.0/README.md +105 -0
- openneodb-1.0.0/neodb.py +11 -0
- openneodb-1.0.0/openneodb/__init__.py +34 -0
- openneodb-1.0.0/openneodb/__main__.py +3 -0
- openneodb-1.0.0/openneodb/_version.py +24 -0
- openneodb-1.0.0/openneodb/cityguide/README.md +70 -0
- openneodb-1.0.0/openneodb/cityguide/__init__.py +3 -0
- openneodb-1.0.0/openneodb/cityguide/reader.py +145 -0
- openneodb-1.0.0/openneodb/cli.py +132 -0
- openneodb-1.0.0/openneodb/db/README.md +95 -0
- openneodb-1.0.0/openneodb/db/__init__.py +20 -0
- openneodb-1.0.0/openneodb/db/model.py +47 -0
- openneodb-1.0.0/openneodb/db/writer.py +112 -0
- openneodb-1.0.0/openneodb/igo/README.md +44 -0
- openneodb-1.0.0/openneodb/igo/__init__.py +3 -0
- openneodb-1.0.0/openneodb/igo/reader.py +115 -0
- openneodb-1.0.0/openneodb/py.typed +0 -0
- openneodb-1.0.0/openneodb/rows.py +67 -0
- openneodb-1.0.0/openneodb.egg-info/PKG-INFO +133 -0
- openneodb-1.0.0/openneodb.egg-info/SOURCES.txt +32 -0
- openneodb-1.0.0/openneodb.egg-info/dependency_links.txt +1 -0
- openneodb-1.0.0/openneodb.egg-info/entry_points.txt +2 -0
- openneodb-1.0.0/openneodb.egg-info/scm_file_list.json +28 -0
- openneodb-1.0.0/openneodb.egg-info/scm_version.json +8 -0
- openneodb-1.0.0/openneodb.egg-info/top_level.txt +1 -0
- openneodb-1.0.0/pyproject.toml +47 -0
- openneodb-1.0.0/setup.cfg +4 -0
- openneodb-1.0.0/tests/test_cli.py +133 -0
- openneodb-1.0.0/tests/test_readers.py +161 -0
- openneodb-1.0.0/tests/test_writer.py +70 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
pull_request:
|
|
8
|
+
workflow_dispatch:
|
|
9
|
+
|
|
10
|
+
permissions:
|
|
11
|
+
contents: read
|
|
12
|
+
|
|
13
|
+
jobs:
|
|
14
|
+
test:
|
|
15
|
+
name: Tests (Python ${{ matrix.python-version }})
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
strategy:
|
|
18
|
+
fail-fast: false
|
|
19
|
+
matrix:
|
|
20
|
+
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-python@v5
|
|
24
|
+
with:
|
|
25
|
+
python-version: ${{ matrix.python-version }}
|
|
26
|
+
- name: Run tests
|
|
27
|
+
run: python -m unittest discover -s tests -v
|
|
28
|
+
|
|
29
|
+
build:
|
|
30
|
+
name: Build package
|
|
31
|
+
needs: test
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
steps:
|
|
34
|
+
- uses: actions/checkout@v4
|
|
35
|
+
with:
|
|
36
|
+
# setuptools-scm needs the full history and tags to compute the version.
|
|
37
|
+
fetch-depth: 0
|
|
38
|
+
- uses: actions/setup-python@v5
|
|
39
|
+
with:
|
|
40
|
+
python-version: "3.13"
|
|
41
|
+
- name: Build sdist and wheel
|
|
42
|
+
run: |
|
|
43
|
+
python -m pip install --upgrade build twine
|
|
44
|
+
python -m build
|
|
45
|
+
twine check dist/*
|
|
46
|
+
- name: Check version
|
|
47
|
+
run: |
|
|
48
|
+
python -m pip install dist/*.whl
|
|
49
|
+
version=$(python -c "import openneodb; print(openneodb.__version__)")
|
|
50
|
+
echo "Package version: $version"
|
|
51
|
+
echo "## openneodb $version" >> "$GITHUB_STEP_SUMMARY"
|
|
52
|
+
if [[ "$GITHUB_REF_TYPE" == "tag" && "$version" != "${GITHUB_REF_NAME#v}" ]]; then
|
|
53
|
+
echo "::error::Version $version does not match tag $GITHUB_REF_NAME"
|
|
54
|
+
exit 1
|
|
55
|
+
fi
|
|
56
|
+
neodb --help
|
|
57
|
+
- uses: actions/upload-artifact@v4
|
|
58
|
+
with:
|
|
59
|
+
name: dist
|
|
60
|
+
path: dist/
|
|
61
|
+
|
|
62
|
+
release:
|
|
63
|
+
name: GitHub Release
|
|
64
|
+
if: github.ref_type == 'tag'
|
|
65
|
+
needs: build
|
|
66
|
+
runs-on: ubuntu-latest
|
|
67
|
+
permissions:
|
|
68
|
+
contents: write
|
|
69
|
+
steps:
|
|
70
|
+
- uses: actions/download-artifact@v4
|
|
71
|
+
with:
|
|
72
|
+
name: dist
|
|
73
|
+
path: dist/
|
|
74
|
+
- name: Create release
|
|
75
|
+
env:
|
|
76
|
+
GH_TOKEN: ${{ github.token }}
|
|
77
|
+
run: >
|
|
78
|
+
gh release create "$GITHUB_REF_NAME" dist/*
|
|
79
|
+
--repo "$GITHUB_REPOSITORY"
|
|
80
|
+
--title "$GITHUB_REF_NAME"
|
|
81
|
+
--generate-notes
|
|
82
|
+
|
|
83
|
+
publish:
|
|
84
|
+
name: Publish to PyPI
|
|
85
|
+
if: github.ref_type == 'tag'
|
|
86
|
+
needs: build
|
|
87
|
+
runs-on: ubuntu-latest
|
|
88
|
+
environment:
|
|
89
|
+
name: pypi
|
|
90
|
+
url: https://pypi.org/project/openneodb/
|
|
91
|
+
permissions:
|
|
92
|
+
id-token: write
|
|
93
|
+
steps:
|
|
94
|
+
- uses: actions/download-artifact@v4
|
|
95
|
+
with:
|
|
96
|
+
name: dist
|
|
97
|
+
path: dist/
|
|
98
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
openneodb-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dmitry Titov
|
|
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.
|
openneodb-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: openneodb
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Конвертер баз камер CityGuide и iGo в формат Neoline / CityGuide and iGo to Neoline speed-camera database converter
|
|
5
|
+
Author: Dmitry Titov
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/korableg/openneodb
|
|
8
|
+
Project-URL: Source, https://github.com/korableg/openneodb
|
|
9
|
+
Project-URL: Issues, https://github.com/korableg/openneodb/issues
|
|
10
|
+
Project-URL: Author, https://titovde.ru
|
|
11
|
+
Keywords: neoline,speedcam,cityguide,igo,gps,radar-detector
|
|
12
|
+
Classifier: Development Status :: 5 - Production/Stable
|
|
13
|
+
Classifier: Environment :: Console
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: Natural Language :: Russian
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Topic :: Utilities
|
|
24
|
+
Requires-Python: >=3.10
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
License-File: LICENSE
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# openneodb
|
|
30
|
+
|
|
31
|
+
Конвертер баз камер CityGuide и iGoExt в бинарную базу для видеорегистратора
|
|
32
|
+
Neoline X-COP R9000c.
|
|
33
|
+
|
|
34
|
+
## Структура
|
|
35
|
+
|
|
36
|
+
Пакет `openneodb`:
|
|
37
|
+
|
|
38
|
+
- [`openneodb.cityguide`](openneodb/cityguide/README.md) — чтение CityGuide
|
|
39
|
+
Speedcam v2 (`.bkm`);
|
|
40
|
+
- [`openneodb.igo`](openneodb/igo/README.md) — чтение SpeedCamOnline iGoExt
|
|
41
|
+
(`.txt`);
|
|
42
|
+
- [`openneodb.db`](openneodb/db/README.md) — формирование базы Neoline
|
|
43
|
+
(`*_Baza_GPS.db`);
|
|
44
|
+
- `openneodb.rows` — общий разбор значений строк для readers;
|
|
45
|
+
- `openneodb.cli` — командная строка `neodb`, связывающая reader выбранного
|
|
46
|
+
формата с writer Neoline.
|
|
47
|
+
|
|
48
|
+
## Использование
|
|
49
|
+
|
|
50
|
+
```text
|
|
51
|
+
neodb igo [-i INPUT] [-o OUTPUT] [--date DDMMYY]
|
|
52
|
+
neodb cityguide [-i INPUT] [-o OUTPUT] [--date DDMMYY]
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Запуск на выбор, зависимостей нет:
|
|
56
|
+
|
|
57
|
+
- `pip install .` — появляется команда `neodb`;
|
|
58
|
+
- без установки — `python3 neodb.py ...`
|
|
59
|
+
- без установки из корня репозитория — `python3 -m openneodb ...`.
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
neodb igo -i cameras.txt -o X-COP_9000c_Baza_GPS.db
|
|
63
|
+
neodb cityguide -i SpeedCam.bkm -o X-COP_9000c_Baza_GPS.db --date 270926
|
|
64
|
+
cat cameras.txt | python3 -m openneodb igo > X-COP_9000c_Baza_GPS.db
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
По умолчанию используется локальная дата запуска в формате `DDMMYY`.
|
|
68
|
+
Внутреннее имя файла всегда `https://github.com/korableg/openneodb`.
|
|
69
|
+
|
|
70
|
+
Некорректная строка (пустой тип, нарушенная структура, неверное число,
|
|
71
|
+
значение вне диапазона) пропускается с предупреждением. Неизвестный непустой
|
|
72
|
+
тип преобразуется в базовый стационарный радар `0x06`. Ошибка всего файла
|
|
73
|
+
(кодировка, заголовок, CSV) или неверная `--date` завершает процесс
|
|
74
|
+
ненулевым кодом до записи результата. Файл `-o` заменяется атомарно: при
|
|
75
|
+
сбое прежняя база остаётся нетронутой.
|
|
76
|
+
|
|
77
|
+
## Использование как библиотеки
|
|
78
|
+
|
|
79
|
+
Все публичные классы доступны из корня пакета:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from openneodb import IGoReader, CityGuideReader, NeolineDBWriter
|
|
83
|
+
|
|
84
|
+
with open("cameras.txt", "rb") as source:
|
|
85
|
+
result = IGoReader().read(source)
|
|
86
|
+
|
|
87
|
+
for message in result.stats.warning_messages:
|
|
88
|
+
print(message)
|
|
89
|
+
|
|
90
|
+
data = NeolineDBWriter(date="270926").build(result.records)
|
|
91
|
+
with open("X-COP_9000c_Baza_GPS.db", "wb") as target:
|
|
92
|
+
target.write(data)
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
- `IGoReader().read(source)` и `CityGuideReader().read(source)` принимают
|
|
96
|
+
бинарный поток и возвращают `ReadResult`: кортеж `records` и статистику
|
|
97
|
+
`stats` (`read`, `skipped`, `fallback`, `warning_messages`). Библиотека
|
|
98
|
+
ничего не печатает и не пишет в лог.
|
|
99
|
+
- `NeolineDBWriter(date).build(records)` принимает любые `CameraRecord`
|
|
100
|
+
и возвращает готовую базу как `bytes`; дата — строка `DDMMYY`.
|
|
101
|
+
- Ошибки: `IGoFormatError`, `CityGuideFormatError` (весь входной файл
|
|
102
|
+
некорректен) и `NeolineDBError` (неверная дата, запись вне диапазона,
|
|
103
|
+
превышен размер базы). Все наследуют `ValueError`.
|
|
104
|
+
- Пакет содержит аннотации типов (`py.typed`), версия — `openneodb.__version__`.
|
|
105
|
+
|
|
106
|
+
### `CameraRecord`
|
|
107
|
+
|
|
108
|
+
Записи можно собирать самостоятельно. Поля — целые числа в единицах формата
|
|
109
|
+
Neoline, например координаты в градусах × 10000 с округлением вниз
|
|
110
|
+
(`55.75583` → `557558`). Значение каждого поля описано в
|
|
111
|
+
[структуре записи](https://github.com/korableg/openneodb/blob/main/openneodb/db/README.md#структура-записи).
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from openneodb import CameraRecord, NeolineDBWriter
|
|
115
|
+
|
|
116
|
+
camera = CameraRecord(
|
|
117
|
+
camera_type=0x06,
|
|
118
|
+
latitude=557558,
|
|
119
|
+
longitude=376173,
|
|
120
|
+
direction=90,
|
|
121
|
+
direction_type=1,
|
|
122
|
+
speed=60,
|
|
123
|
+
)
|
|
124
|
+
data = NeolineDBWriter(date="270926").build([camera])
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Проверка
|
|
128
|
+
|
|
129
|
+
Тесты используют только стандартную библиотеку Python:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
python3 -m unittest discover -s tests
|
|
133
|
+
```
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# openneodb
|
|
2
|
+
|
|
3
|
+
Конвертер баз камер CityGuide и iGoExt в бинарную базу для видеорегистратора
|
|
4
|
+
Neoline X-COP R9000c.
|
|
5
|
+
|
|
6
|
+
## Структура
|
|
7
|
+
|
|
8
|
+
Пакет `openneodb`:
|
|
9
|
+
|
|
10
|
+
- [`openneodb.cityguide`](openneodb/cityguide/README.md) — чтение CityGuide
|
|
11
|
+
Speedcam v2 (`.bkm`);
|
|
12
|
+
- [`openneodb.igo`](openneodb/igo/README.md) — чтение SpeedCamOnline iGoExt
|
|
13
|
+
(`.txt`);
|
|
14
|
+
- [`openneodb.db`](openneodb/db/README.md) — формирование базы Neoline
|
|
15
|
+
(`*_Baza_GPS.db`);
|
|
16
|
+
- `openneodb.rows` — общий разбор значений строк для readers;
|
|
17
|
+
- `openneodb.cli` — командная строка `neodb`, связывающая reader выбранного
|
|
18
|
+
формата с writer Neoline.
|
|
19
|
+
|
|
20
|
+
## Использование
|
|
21
|
+
|
|
22
|
+
```text
|
|
23
|
+
neodb igo [-i INPUT] [-o OUTPUT] [--date DDMMYY]
|
|
24
|
+
neodb cityguide [-i INPUT] [-o OUTPUT] [--date DDMMYY]
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Запуск на выбор, зависимостей нет:
|
|
28
|
+
|
|
29
|
+
- `pip install .` — появляется команда `neodb`;
|
|
30
|
+
- без установки — `python3 neodb.py ...`
|
|
31
|
+
- без установки из корня репозитория — `python3 -m openneodb ...`.
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
neodb igo -i cameras.txt -o X-COP_9000c_Baza_GPS.db
|
|
35
|
+
neodb cityguide -i SpeedCam.bkm -o X-COP_9000c_Baza_GPS.db --date 270926
|
|
36
|
+
cat cameras.txt | python3 -m openneodb igo > X-COP_9000c_Baza_GPS.db
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
По умолчанию используется локальная дата запуска в формате `DDMMYY`.
|
|
40
|
+
Внутреннее имя файла всегда `https://github.com/korableg/openneodb`.
|
|
41
|
+
|
|
42
|
+
Некорректная строка (пустой тип, нарушенная структура, неверное число,
|
|
43
|
+
значение вне диапазона) пропускается с предупреждением. Неизвестный непустой
|
|
44
|
+
тип преобразуется в базовый стационарный радар `0x06`. Ошибка всего файла
|
|
45
|
+
(кодировка, заголовок, CSV) или неверная `--date` завершает процесс
|
|
46
|
+
ненулевым кодом до записи результата. Файл `-o` заменяется атомарно: при
|
|
47
|
+
сбое прежняя база остаётся нетронутой.
|
|
48
|
+
|
|
49
|
+
## Использование как библиотеки
|
|
50
|
+
|
|
51
|
+
Все публичные классы доступны из корня пакета:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from openneodb import IGoReader, CityGuideReader, NeolineDBWriter
|
|
55
|
+
|
|
56
|
+
with open("cameras.txt", "rb") as source:
|
|
57
|
+
result = IGoReader().read(source)
|
|
58
|
+
|
|
59
|
+
for message in result.stats.warning_messages:
|
|
60
|
+
print(message)
|
|
61
|
+
|
|
62
|
+
data = NeolineDBWriter(date="270926").build(result.records)
|
|
63
|
+
with open("X-COP_9000c_Baza_GPS.db", "wb") as target:
|
|
64
|
+
target.write(data)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
- `IGoReader().read(source)` и `CityGuideReader().read(source)` принимают
|
|
68
|
+
бинарный поток и возвращают `ReadResult`: кортеж `records` и статистику
|
|
69
|
+
`stats` (`read`, `skipped`, `fallback`, `warning_messages`). Библиотека
|
|
70
|
+
ничего не печатает и не пишет в лог.
|
|
71
|
+
- `NeolineDBWriter(date).build(records)` принимает любые `CameraRecord`
|
|
72
|
+
и возвращает готовую базу как `bytes`; дата — строка `DDMMYY`.
|
|
73
|
+
- Ошибки: `IGoFormatError`, `CityGuideFormatError` (весь входной файл
|
|
74
|
+
некорректен) и `NeolineDBError` (неверная дата, запись вне диапазона,
|
|
75
|
+
превышен размер базы). Все наследуют `ValueError`.
|
|
76
|
+
- Пакет содержит аннотации типов (`py.typed`), версия — `openneodb.__version__`.
|
|
77
|
+
|
|
78
|
+
### `CameraRecord`
|
|
79
|
+
|
|
80
|
+
Записи можно собирать самостоятельно. Поля — целые числа в единицах формата
|
|
81
|
+
Neoline, например координаты в градусах × 10000 с округлением вниз
|
|
82
|
+
(`55.75583` → `557558`). Значение каждого поля описано в
|
|
83
|
+
[структуре записи](https://github.com/korableg/openneodb/blob/main/openneodb/db/README.md#структура-записи).
|
|
84
|
+
|
|
85
|
+
```python
|
|
86
|
+
from openneodb import CameraRecord, NeolineDBWriter
|
|
87
|
+
|
|
88
|
+
camera = CameraRecord(
|
|
89
|
+
camera_type=0x06,
|
|
90
|
+
latitude=557558,
|
|
91
|
+
longitude=376173,
|
|
92
|
+
direction=90,
|
|
93
|
+
direction_type=1,
|
|
94
|
+
speed=60,
|
|
95
|
+
)
|
|
96
|
+
data = NeolineDBWriter(date="270926").build([camera])
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Проверка
|
|
100
|
+
|
|
101
|
+
Тесты используют только стандартную библиотеку Python:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
python3 -m unittest discover -s tests
|
|
105
|
+
```
|
openneodb-1.0.0/neodb.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Run the converter without installation: python3 neodb.py igo -i ... -o ..."""
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
7
|
+
|
|
8
|
+
from openneodb.cli import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
from .cityguide import CityGuideFormatError, CityGuideReader
|
|
2
|
+
from .db import (
|
|
3
|
+
FALLBACK_CAMERA_TYPE,
|
|
4
|
+
MAX_ALERT_DISTANCE,
|
|
5
|
+
MIN_ALERT_DISTANCE,
|
|
6
|
+
CameraRecord,
|
|
7
|
+
NeolineDBError,
|
|
8
|
+
NeolineDBWriter,
|
|
9
|
+
ReaderStats,
|
|
10
|
+
ReadResult,
|
|
11
|
+
)
|
|
12
|
+
from .igo import IGoFormatError, IGoReader
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
from ._version import __version__
|
|
16
|
+
except ImportError:
|
|
17
|
+
# Running from a source checkout without installation.
|
|
18
|
+
__version__ = "0.0.0+unknown"
|
|
19
|
+
|
|
20
|
+
__all__ = (
|
|
21
|
+
"FALLBACK_CAMERA_TYPE",
|
|
22
|
+
"MAX_ALERT_DISTANCE",
|
|
23
|
+
"MIN_ALERT_DISTANCE",
|
|
24
|
+
"CameraRecord",
|
|
25
|
+
"CityGuideFormatError",
|
|
26
|
+
"CityGuideReader",
|
|
27
|
+
"IGoFormatError",
|
|
28
|
+
"IGoReader",
|
|
29
|
+
"NeolineDBError",
|
|
30
|
+
"NeolineDBWriter",
|
|
31
|
+
"ReaderStats",
|
|
32
|
+
"ReadResult",
|
|
33
|
+
"__version__",
|
|
34
|
+
)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '1.0.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 0, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = 'gc8a5cc44b'
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# CityGuide Speedcam v2
|
|
2
|
+
|
|
3
|
+
Каталог содержит `CityGuideReader`, который только читает CityGuide BKM и
|
|
4
|
+
возвращает нормализованные `CameraRecord`.
|
|
5
|
+
|
|
6
|
+
## Формат
|
|
7
|
+
|
|
8
|
+
Файл — текст в кодировке CP1251 с CRLF. Заголовок имеет фиксированный вид:
|
|
9
|
+
|
|
10
|
+
```text
|
|
11
|
+
2|Radars|1251
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Каждая следующая строка:
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
TYPE|ID|LAT|LON|TAG|VALUE|TAG|VALUE|...|
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
| Поле или тег | Значение |
|
|
21
|
+
| --- | --- |
|
|
22
|
+
| `TYPE` | тип объекта CityGuide |
|
|
23
|
+
| `ID` | идентификатор; при конвертации не используется |
|
|
24
|
+
| `LAT`, `LON` | координаты в градусах |
|
|
25
|
+
| `1709` | ограничение скорости, км/ч |
|
|
26
|
+
| `1713` | направленность CityGuide |
|
|
27
|
+
| `1705` | азимут камеры, `0`…`359` |
|
|
28
|
+
| `1706` | дистанция предупреждения в метрах |
|
|
29
|
+
|
|
30
|
+
Координаты сохраняются как `floor(value × 10000)`; поддерживаются только
|
|
31
|
+
северная широта `0`…`90` и восточная долгота `0`…`180`. Азимут камеры
|
|
32
|
+
переводится в направление движения добавлением 180° по модулю 360. Дистанция
|
|
33
|
+
делится на 10 с округлением вниз и приводится к пределам RevM60: больше
|
|
34
|
+
2000 м ограничивается 2000 м (прошивка читает `>200` декаметров как 500 м),
|
|
35
|
+
от 1 до 9 м поднимается до 100 м (`0` означает 500 м), оба случая — с
|
|
36
|
+
предупреждением.
|
|
37
|
+
|
|
38
|
+
Отсутствующие теги получают безопасные значения с предупреждением:
|
|
39
|
+
`1709 = 0`, `1713 = 0`, `1705` — направление `0` без поворота, `1706 = 0`.
|
|
40
|
+
Пустые строки игнорируются. Строка с пустым типом, нарушенной структурой,
|
|
41
|
+
некорректным числом или значением вне диапазона пропускается с
|
|
42
|
+
предупреждением.
|
|
43
|
+
|
|
44
|
+
## Преобразование типов
|
|
45
|
+
|
|
46
|
+
| CityGuide `TYPE` | Neoline |
|
|
47
|
+
| --- | --- |
|
|
48
|
+
| `18059`, `18951`, `18925` | `0x06` |
|
|
49
|
+
| `18952` | `0x01`, `flags = 0x02` |
|
|
50
|
+
| `18958`, `18950` | `0x4A` |
|
|
51
|
+
| любой другой непустой | `0x06` с предупреждением |
|
|
52
|
+
|
|
53
|
+
Направленность из тега `1713` преобразуется так:
|
|
54
|
+
|
|
55
|
+
| CityGuide | Neoline |
|
|
56
|
+
| --- | --- |
|
|
57
|
+
| `0` | `1`, одно направление |
|
|
58
|
+
| `1` | `1`, одно направление |
|
|
59
|
+
| `2` | `0`, все направления |
|
|
60
|
+
|
|
61
|
+
`0` и `1` описывают один контролируемый поток; встречное направление в
|
|
62
|
+
bkm — отдельная строка. По выгрузке 2026-09-19 записи с `0` и `1`
|
|
63
|
+
статистически неразличимы: встречная запись в радиусе ~50 м есть у 80 % и
|
|
64
|
+
78 %, совпадают распределения типов, скоростей и дистанций, вендор кодирует
|
|
65
|
+
обе как `direction_type = 1`, `tol = 0`. Вероятно, это режим съёмки
|
|
66
|
+
(«в лоб»/«в спину»), для Neoline не значимый. Прежнее прочтение `1` как
|
|
67
|
+
«оба направления» давало лишние оповещения на встречке.
|
|
68
|
+
|
|
69
|
+
Круговые камеры записываются по семантике RevM60: `direction_type = 0`, без
|
|
70
|
+
флага `0x20` и без принудительного допуска 5°.
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
from typing import BinaryIO
|
|
2
|
+
|
|
3
|
+
from ..db import (
|
|
4
|
+
MAX_ALERT_DISTANCE,
|
|
5
|
+
MIN_ALERT_DISTANCE,
|
|
6
|
+
CameraRecord,
|
|
7
|
+
ReaderStats,
|
|
8
|
+
ReadResult,
|
|
9
|
+
)
|
|
10
|
+
from ..rows import (
|
|
11
|
+
MAX_LATITUDE,
|
|
12
|
+
MAX_LONGITUDE,
|
|
13
|
+
RowError,
|
|
14
|
+
check_range,
|
|
15
|
+
map_camera_type,
|
|
16
|
+
optional_integer,
|
|
17
|
+
parse_coordinate,
|
|
18
|
+
parse_integer,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CityGuideFormatError(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class CityGuideReader:
|
|
27
|
+
HEADER = "2|Radars|1251"
|
|
28
|
+
TYPE_MAPPING = {
|
|
29
|
+
"18059": 0x06,
|
|
30
|
+
"18925": 0x06,
|
|
31
|
+
"18950": 0x4A,
|
|
32
|
+
"18951": 0x06,
|
|
33
|
+
"18952": 0x01,
|
|
34
|
+
"18958": 0x4A,
|
|
35
|
+
}
|
|
36
|
+
# 0 and 1 are both a single controlled direction (likely head-on versus
|
|
37
|
+
# rear shooting); opposite directions are separate rows. 2 is circular.
|
|
38
|
+
DIRECTION_TYPE_MAPPING = {0: 1, 1: 1, 2: 0}
|
|
39
|
+
|
|
40
|
+
def read(self, source: BinaryIO) -> ReadResult:
|
|
41
|
+
try:
|
|
42
|
+
text = source.read().decode("cp1251")
|
|
43
|
+
except UnicodeDecodeError as error:
|
|
44
|
+
raise CityGuideFormatError("CityGuide input must use CP1251") from error
|
|
45
|
+
lines = text.splitlines()
|
|
46
|
+
if not lines:
|
|
47
|
+
raise CityGuideFormatError("empty CityGuide input")
|
|
48
|
+
if lines[0] != self.HEADER:
|
|
49
|
+
raise CityGuideFormatError(
|
|
50
|
+
f"invalid CityGuide header {lines[0]!r}, expected {self.HEADER!r}"
|
|
51
|
+
)
|
|
52
|
+
records = []
|
|
53
|
+
stats = ReaderStats()
|
|
54
|
+
for line_number, line in enumerate(lines[1:], start=2):
|
|
55
|
+
if not line.strip():
|
|
56
|
+
continue
|
|
57
|
+
stats.read += 1
|
|
58
|
+
try:
|
|
59
|
+
records.append(self._record(line, line_number, stats))
|
|
60
|
+
except RowError as error:
|
|
61
|
+
stats.skip(line_number, str(error))
|
|
62
|
+
return ReadResult(tuple(records), stats)
|
|
63
|
+
|
|
64
|
+
def _record(self, line: str, line_number: int, stats: ReaderStats) -> CameraRecord:
|
|
65
|
+
parts = line.split("|")
|
|
66
|
+
if parts[-1] != "" or len(parts) < 5 or len(parts) % 2 == 0:
|
|
67
|
+
raise RowError("malformed CityGuide row")
|
|
68
|
+
source_type = parts[0].strip()
|
|
69
|
+
if not source_type:
|
|
70
|
+
raise RowError("blank camera type")
|
|
71
|
+
tags = self._tags(parts[4:-1], line_number, stats)
|
|
72
|
+
latitude = parse_coordinate(parts[2].strip(), "latitude", MAX_LATITUDE)
|
|
73
|
+
longitude = parse_coordinate(parts[3].strip(), "longitude", MAX_LONGITUDE)
|
|
74
|
+
speed = self._tag_integer(tags, "1709", 0, line_number, stats)
|
|
75
|
+
check_range(speed, "tag 1709", 0, 255)
|
|
76
|
+
source_direction_type = self._tag_integer(tags, "1713", 0, line_number, stats)
|
|
77
|
+
check_range(source_direction_type, "tag 1713", 0, 2)
|
|
78
|
+
direction = self._direction(tags, line_number, stats)
|
|
79
|
+
distance_metres = self._tag_integer(tags, "1706", 0, line_number, stats)
|
|
80
|
+
if distance_metres < 0:
|
|
81
|
+
raise RowError(f"tag 1706={distance_metres} must not be negative")
|
|
82
|
+
return CameraRecord(
|
|
83
|
+
camera_type=map_camera_type(
|
|
84
|
+
self.TYPE_MAPPING, source_type, "camera type", line_number, stats
|
|
85
|
+
),
|
|
86
|
+
latitude=latitude,
|
|
87
|
+
longitude=longitude,
|
|
88
|
+
direction=direction,
|
|
89
|
+
direction_type=self.DIRECTION_TYPE_MAPPING[source_direction_type],
|
|
90
|
+
speed=speed,
|
|
91
|
+
flags=0x02 if source_type == "18952" else 0,
|
|
92
|
+
distance=self._distance(distance_metres, line_number, stats),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def _direction(tags: dict[str, str], line_number: int, stats: ReaderStats) -> int:
|
|
97
|
+
value = tags.get("1705", "").strip()
|
|
98
|
+
if not value:
|
|
99
|
+
stats.warn(line_number, "missing tag 1705, using direction 0")
|
|
100
|
+
return 0
|
|
101
|
+
source_direction = parse_integer(value, "tag 1705")
|
|
102
|
+
check_range(source_direction, "tag 1705", 0, 359)
|
|
103
|
+
# Tag 1705 is the camera heading; Neoline stores the traffic heading.
|
|
104
|
+
return (source_direction + 180) % 360
|
|
105
|
+
|
|
106
|
+
@staticmethod
|
|
107
|
+
def _distance(metres: int, line_number: int, stats: ReaderStats) -> int:
|
|
108
|
+
distance = metres // 10
|
|
109
|
+
if metres and distance == 0:
|
|
110
|
+
# 0 means the 500 m default, not "closer than 10 m".
|
|
111
|
+
stats.warn(
|
|
112
|
+
line_number,
|
|
113
|
+
f"tag 1706={metres} raised to {MIN_ALERT_DISTANCE * 10} metres",
|
|
114
|
+
)
|
|
115
|
+
return MIN_ALERT_DISTANCE
|
|
116
|
+
if distance > MAX_ALERT_DISTANCE:
|
|
117
|
+
stats.warn(
|
|
118
|
+
line_number,
|
|
119
|
+
f"tag 1706={metres} clamped to {MAX_ALERT_DISTANCE * 10} metres",
|
|
120
|
+
)
|
|
121
|
+
return MAX_ALERT_DISTANCE
|
|
122
|
+
return distance
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def _tags(
|
|
126
|
+
fields: list[str], line_number: int, stats: ReaderStats
|
|
127
|
+
) -> dict[str, str]:
|
|
128
|
+
tags = {}
|
|
129
|
+
for tag, value in zip(fields[0::2], fields[1::2]):
|
|
130
|
+
if tag in tags:
|
|
131
|
+
stats.warn(line_number, f"duplicate tag {tag}, using last value")
|
|
132
|
+
tags[tag] = value
|
|
133
|
+
return tags
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def _tag_integer(
|
|
137
|
+
tags: dict[str, str],
|
|
138
|
+
tag: str,
|
|
139
|
+
default: int,
|
|
140
|
+
line_number: int,
|
|
141
|
+
stats: ReaderStats,
|
|
142
|
+
) -> int:
|
|
143
|
+
return optional_integer(
|
|
144
|
+
tags.get(tag, "").strip(), f"tag {tag}", default, line_number, stats
|
|
145
|
+
)
|