Compare commits

...

38 Commits

Author SHA1 Message Date
fc072d798e try custom cnn 2026-04-14 12:37:09 +03:00
2ec0763e6d initialize weights, enchance graphics 2026-04-06 22:11:41 +03:00
3372ee4055 improve model 2026-04-06 17:36:41 +03:00
b4b8f78970 fix errors 2026-04-05 18:14:52 +03:00
daee1767fb feat(SiaN): change output angles to sin/cos values 2026-04-05 13:10:54 +03:00
fa4c4b83ae feat(SiaN): improve analyzing 2026-04-05 12:50:28 +03:00
ec8b3ae20e improve schema 2026-04-04 22:57:41 +03:00
b2cc714d79 add auto codegen 2026-04-04 21:32:50 +03:00
703ea8dbaf feat: working model 2026-04-04 20:26:56 +03:00
4b398f6c9a write SiaN 2026-04-04 19:41:16 +03:00
702c53caac clear SiaN 2026-04-04 17:50:10 +03:00
15cad7fa65 update dataloader 2026-04-04 17:49:31 +03:00
2561ba1cbf write chapter_3 2026-04-01 14:00:38 +03:00
26f2fac07b split into chapters 2026-04-01 13:36:34 +03:00
2b137dfccc add dissertation 2026-04-01 13:21:32 +03:00
a386cfa614 feat: add ya_go_maps_v2 2026-04-01 07:11:16 +03:00
8a7d88155d ref(dataset.ya_go_maps): add dname 2026-04-01 07:10:56 +03:00
c6df3edab8 ref: simplify and modularize GAN codebase 2026-03-22 21:10:05 +03:00
05f8746d58 feat: complete sian-similarity 2026-03-22 14:29:00 +03:00
43cd4222bc feat: add similarity model 2026-03-03 21:42:23 +03:00
1de150b386 feat: add similarity model 2026-03-01 16:43:50 +03:00
178f2a1b9c feat: add notebooks from kaggle 2026-02-20 17:14:57 +03:00
9b685c9a88 feat: add dataset 2026-02-20 17:08:52 +03:00
a9bde5e5b9 Merge pull request 'feat: add models' (#2) from feat/pitch-roll into main
Reviewed-on: #2
2026-02-20 16:54:29 +03:00
0cc210968f feat: add models 2026-02-20 16:52:02 +03:00
27d5c6eaab Merge pull request 'feat/pitch-roll' (#1) from feat/pitch-roll into main
Reviewed-on: #1
2026-02-20 16:50:15 +03:00
6040f3b253 feat: change git ignores, partial update todo.md 2026-02-20 16:48:19 +03:00
6d4208d100 feat: add SiaN model 2026-02-16 19:07:31 +03:00
zenloger
339e5f210c fix 2026-02-05 21:49:15 +03:00
zenloger
5385641d28 fix: use pixel_ratio in distance evaluation 2026-02-05 21:49:03 +03:00
64c9215f5b feat: add fps/landmark debugging 2026-01-12 15:47:03 +03:00
7cd700c1fa feat: min-ref-distance 2026-01-12 13:54:41 +03:00
05c249ed78 feat: add new arguments, correct yandex map 2026-01-12 13:31:37 +03:00
fbd0d01b35 feat: add constraints for landmark correction 2026-01-12 00:46:44 +03:00
e00878daad feat: convert from pixels to meters 2026-01-12 00:04:23 +03:00
ceca8a6e75 feat: dynamic map; refactor code and fix bugs 2026-01-11 23:45:19 +03:00
6456d18212 ref: move *.html to examples 2026-01-11 13:59:26 +03:00
3ee3599b87 feat: chunks from google 2026-01-11 13:54:37 +03:00
98 changed files with 10361 additions and 512 deletions

6
.gitignore vendored
View File

@@ -1,4 +1,6 @@
.venv
__pycache__
*.png
images
trajectories
z
chunks/*
images/*

View File

@@ -3,6 +3,7 @@ from pathlib import Path
import math
import random
import constants
import cv2
import numpy as np
from PIL import Image
@@ -56,14 +57,16 @@ class AutoPilot(Pilot):
# Положение на основе ориентира
reserved_pos: Position | None
proccessing_time: float
def __init__(self, points = [], chunks = [], viz_manager=None):
def __init__(self, points = [], chunks = [], viz_manager=None, pixel_ratio: float = 1.):
self.prev_chunk = None
self.pos = Position(0, 0, 1, 0, 0, 0)
self.chunks = chunks
self.frame_count = 0
self.vis_manager = viz_manager # Менеджер визуализации
self.reserved_pos = None
self.pixel_ratio = pixel_ratio
# Пороговые значения качества сопоставления/гомографии
self.min_inliers: int = 12
@@ -76,6 +79,7 @@ class AutoPilot(Pilot):
self.target_idx = 0
self.timer = Timer()
self.chunk_points = np.array([[chunk.pos.x, chunk.pos.y] for chunk in self.chunks])
def get_position(self) -> tuple[float, float]:
return self.pos.x, self.pos.y
@@ -91,7 +95,7 @@ class AutoPilot(Pilot):
h, w = prev_gray.shape[:2]
# Создаем сетку точек для отслеживания (аналогично вашему step=20)
step = 35
step = 20
grid_points = []
for y in range(step, h - step, step):
for x in range(step, w - step, step):
@@ -133,9 +137,6 @@ class AutoPilot(Pilot):
"""
self.pos.iapply(homography_matrix)
if self.reserved_pos is not None:
self.reserved_pos.iapply(homography_matrix)
def get_drone_state(self) -> dict:
"""
Возвращает текущее состояние БПЛА
@@ -149,43 +150,122 @@ class AutoPilot(Pilot):
}
def get_position_by_chunk(self) -> Position | None:
# Пытаемся найти ориентир на картинке:
landmark_timer = Timer()
landmark_timer.start()
cur_pos = np.array([self.pos.x, self.pos.y])
closest_chunk_idx = ((self.chunk_points - cur_pos) ** 2).sum(1).argmin()
current_chunk = self.prev_chunk
landmark_chunk = self.chunks[self.target_idx]
landmark_chunk = self.chunks[closest_chunk_idx]
if constants.DEBUG_FPS:
print(f"[LANDMARK]: Closest chunk finding: {landmark_timer.loop() * 1000:.2f} ms")
# Краевой случай: отсутствие чанков
if current_chunk is None or landmark_chunk is None:
return None
landmark_timer.start()
src_pts, dst_pts, matches, kp1, kp2 = landmark_chunk.detect_and_match_keypoints(current_chunk)
if constants.DEBUG_FPS:
print(f"[LANDMARK]: detect and match keypoints: {landmark_timer.loop() * 1000:.2f} ms")
landmark_timer.stop()
# Визуализация (если нужна)
if src_pts is not None and dst_pts is not None and self.vis_manager:
was_enabled = self.timer.enabled
if was_enabled: self.timer.stop()
self.vis_manager.update_chunk_matches(landmark_chunk.to_numpy(), current_chunk.to_numpy(), kp1, kp2, matches)
if was_enabled: self.timer.start()
if was_enabled:
self.timer.stop()
self.vis_manager.update_chunk_matches(
landmark_chunk.to_numpy(),
current_chunk.to_numpy(),
kp1, kp2, matches
)
if was_enabled:
self.timer.start()
if src_pts is not None and dst_pts is not None:
# Оцениваем матрицу трансформации
landmark_transform = self.estimate_transformation_matrix(src_pts, dst_pts)
# Если ориентир достоверно найден — скорректируем глобальные координаты и угол
if landmark_transform is not None:
ok_scale = (self.min_scale <= landmark_transform['scale'] <= self.max_scale)
ok_inliers = (landmark_transform.get('inliers', 0) >= self.min_inliers)
ratio = landmark_transform.get('inliers_ratio', 0.0)
ok_ratio = (ratio >= self.min_inlier_ratio)
rmse = landmark_transform.get('rmse', None)
ok_rmse = (rmse is not None and rmse <= self.max_reproj_rmse)
if ok_scale and ok_inliers and ok_ratio and ok_rmse:
# print("[HELP]")
# print("Matrix", landmark_transform['homography'])
# print("Position", self.x, self.y)
# print("Position of point", self.points[self.target_idx])
# print("[PILOT]", rmse, ratio, ok_rmse)
# if False:
# Считаем абсолютную позу относительно координат ориентира
landmark_world_x, landmark_world_y = self.points[self.target_idx]
landmark = Position(landmark_world_x, landmark_world_y, 1, 0)
homography = landmark_transform['homography']
# homography = np.linalg.inv(homography)
print(f" [Pilot] Landmark correction applied (inliers={landmark_transform['inliers']}, ratio={ratio:.2f}, rmse={rmse:.2f})")
return landmark @ homography
return None
landmark_timer.start()
# Краевой случай: нет точек или недостаточно матчей
if src_pts is None or dst_pts is None:
return None
num_matches = len(src_pts)
if num_matches < 20:
return None
# Оценка матрицы гомографии
landmark_timer.loop()
landmark_transform, mask = estimate_transformation_matrix(src_pts, dst_pts)
num_inliers = int(np.sum(mask))
if constants.DEBUG_FPS:
print(f"[LANDMARK]: matrix estimation: {landmark_timer.loop() * 1000:.2f} ms")
# Краевой случай: матрица не найдена
if landmark_transform is None or mask is None:
return None
# === КРИТЕРИИ ПРИНЯТИЯ РЕШЕНИЯ ===
# 1. Минимальное количество инлайеров (абсолютное)
MIN_INLIERS_ABSOLUTE = 6
if num_inliers < MIN_INLIERS_ABSOLUTE:
return None
# 2. Процент инлайеров от общего числа матчей
inlier_ratio = num_inliers / num_matches
if constants.DEBUG_LANDMARK:
print("[LANDMARK]: inlier_ratio=", inlier_ratio)
MIN_INLIER_RATIO = 0.6
if inlier_ratio < MIN_INLIER_RATIO:
return None
# 3. Проверка качества гомографии (детерминант для выявления вырожденных случаев)
det = np.linalg.det(landmark_transform[:2, :2])
# Детерминант должен быть близок к 1 (без сильного масштабирования)
if abs(det) < 0.1 or abs(det) > 10.0:
return None
# 4. Проверка на валидность перспективного преобразования
# Элементы третьей строки не должны быть слишком большими
if abs(landmark_transform[2, 0]) > 0.01 or abs(landmark_transform[2, 1]) > 0.01:
return None
# 5. Дополнительная проверка: средняя ошибка репроекции для инлайеров
inlier_src = src_pts[mask.ravel() == 1]
inlier_dst = dst_pts[mask.ravel() == 1]
# Преобразуем точки через найденную гомографию
transformed_pts = cv2.perspectiveTransform(inlier_src, landmark_transform)
# Вычисляем ошибку репроекции
reprojection_errors = np.sqrt(np.sum((transformed_pts - inlier_dst) ** 2, axis=2))
mean_error = np.mean(reprojection_errors)
MAX_MEAN_REPROJECTION_ERROR = 1.1 # пиксели
if constants.DEBUG_LANDMARK:
print("[LANDMARK]: Mean_error=", mean_error)
if mean_error > MAX_MEAN_REPROJECTION_ERROR:
return None
# 6. Проверка стабильности: если слишком много хороших совпадений, но мало инлайеров - подозрительно
if num_matches > 50 and inlier_ratio < 0.15:
return None
# === ВСЕ ПРОВЕРКИ ПРОЙДЕНЫ ===
print("[LANDMARK]: Correction Applied")
if constants.DEBUG_FPS:
print(f"[LANDMARK]: time: {landmark_timer.get_elapsed() * 1000:.2f} ms")
return landmark_chunk.pos.apply(landmark_transform)
def handle(self, current_chunk: VisionChunk) -> PilotCommand:
@@ -197,17 +277,11 @@ class AutoPilot(Pilot):
self.timer.stop()
return PilotCommand(processing_time=self.timer.get_elapsed())
# Расстояние до цели
distance_to_target = math.sqrt(
(self.points[self.target_idx][0] - self.pos.x) ** 2 +
(self.points[self.target_idx][1] - self.pos.y) ** 2
)
# Вычисляем оптический поток для покадрового сравнения
matching_timer = Timer()
matching_timer.start()
# src_pts, dst_pts = self.calculate_optical_flow(self.prev_chunk, current_chunk)
src_pts, dst_pts, _, _, _ = self.prev_chunk.detect_and_match_keypoints(current_chunk)
src_pts, dst_pts = self.calculate_optical_flow(self.prev_chunk, current_chunk)
# src_pts, dst_pts, _, _, _ = self.prev_chunk.detect_and_match_keypoints(current_chunk)
matching_timer.stop()
print(f"Matching calculating: {matching_timer.get_elapsed() * 1000:.2f} ms")
@@ -217,7 +291,7 @@ class AutoPilot(Pilot):
matrix_estimation_timer = Timer()
matrix_estimation_timer.start()
homography_matrix = estimate_transformation_matrix(src_pts, dst_pts)
homography_matrix, _ = estimate_transformation_matrix(src_pts, dst_pts)
matrix_estimation_timer.stop()
print(f"Transformation matrix updating: {matrix_estimation_timer.get_elapsed() * 1000:.2f} ms")
@@ -235,17 +309,24 @@ class AutoPilot(Pilot):
self.timer.start()
chunk_timer = Timer()
chunk_timer.start()
# Пытаемся найти ориентир на картинке:
self.prev_chunk = current_chunk
# npos = self.get_position_by_chunk()
# if npos is not None:
# self.reserved_pos = npos
# Для улучшения среднего FPS
if self.frame_count % 5 == 0:
pos_by_chunk = self.get_position_by_chunk()
if pos_by_chunk is not None:
self.pos = pos_by_chunk
chunk_timer.stop()
print(f"Chunk timer: {chunk_timer.get_elapsed() * 1000:.2f} ms")
command = self.make_command()
self.timer.reset()
return command
def make_command(self) -> PilotCommand:
# Расстояние до цели
distance_to_target = math.sqrt(
(self.points[self.target_idx][0] - self.pos.x) ** 2 +
(self.points[self.target_idx][1] - self.pos.y) ** 2
) * self.pixel_ratio
if distance_to_target < 35:
self.target_idx += 1
@@ -258,16 +339,32 @@ class AutoPilot(Pilot):
(self.points[self.target_idx][1] - self.pos.y) ** 2
)
if self.reserved_pos is not None:
self.pos = self.reserved_pos
self.reserved_pos = None
angle_trajectory = self.pos.yaw + math.pi / 2
# Проверка на слепую зону
R = 120
blind = np.array([
[
self.pos.x * self.pixel_ratio + R * np.cos(angle_trajectory - np.pi / 2),
self.pos.y * self.pixel_ratio + R * np.sin(angle_trajectory - np.pi / 2),
],
[
self.pos.x * self.pixel_ratio + R * np.cos(angle_trajectory + np.pi / 2),
self.pos.y * self.pixel_ratio + R * np.sin(angle_trajectory + np.pi / 2),
]
])
blind -= self.points[self.target_idx] * self.pixel_ratio
blind = np.hypot(blind[:, 0], blind[:, 1])
print("R: ", blind)
if np.min(blind) < R:
return PilotCommand(0, 10, False, self.timer.get_elapsed())
# Вычисляем угол к цели
target_angle = math.atan2(self.points[self.target_idx][1] - self.pos.y, self.points[self.target_idx][0] - self.pos.x)
angle_trajectory = self.pos.yaw + math.pi / 2
# print("[ANGLE]", angle_trajectory, "->", target_angle)
# Вычисляем разность углов (направление поворота)
angle_diff = target_angle - angle_trajectory
@@ -277,14 +374,13 @@ class AutoPilot(Pilot):
if angle_diff >= math.pi:
angle_diff -= 2 * math.pi
d_r = max(10, min(35., distance_to_target / 2))
d_a_limit = d_r / 10 * 0.01
d_r = max(5, min(10., distance_to_target / 2))
d_a_limit = np.radians(5)
command = PilotCommand(
max(min(d_a_limit, angle_diff), -d_a_limit),
d_r, False, self.timer.get_elapsed()
)
self.timer.reset()
return command
def reset_position(self, x: float = 0.0, y: float = 0.0, angle: float = 0.0):

View File

@@ -1,5 +1,18 @@
import numpy as np
# Ширина 1 пикселя в метрах
YANDEX_PIXEL_RATIO = {
15: 2830 / 1049,
18: 350 / 1049,
}
GOOGLE_PIXEL_RATIO = {
15: 2766 / 1031,
18: 346 / 1031,
}
WINDOW_HEIGHT = 1031
# Ширина и высота снимка в пикселях
CHUNK_WIDTH = 700
@@ -17,3 +30,6 @@ K = np.array([
[0, _K_FOCUS_DISTANCE, _K_CENTER],
[0, 0, 1]
])
DEBUG_FPS: bool = False
DEBUG_LANDMARK: bool = False

2
datasets/ya_go_maps/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
images
dataset_*

View File

@@ -0,0 +1,66 @@
import argparse
import os
import zipfile
from pathlib import Path
def extract_zip_to_images(dname):
"""
Разархивирует {dname}.zip в папку images.
"""
# Определяем пути
current_dir = Path(__file__).parent
zip_path = current_dir / f"{dname}.zip"
extract_dir = current_dir
# Проверяем существование архива
if not zip_path.exists():
print(f"Ошибка: архив не найден: {zip_path}")
return False
# Создаем папку для извлечения, если она не существует
extract_dir.mkdir(exist_ok=True)
try:
# Открываем архив
with zipfile.ZipFile(zip_path, "r") as zip_ref:
# Получаем список файлов в архиве
file_list = zip_ref.namelist()
print(f"Найдено {len(file_list)} файлов в архиве")
# Извлекаем все файлы
zip_ref.extractall(extract_dir)
# Получаем список извлеченных файлов
extracted_files = list(extract_dir.rglob("*"))
image_files = [f for f in extracted_files if f.is_file()]
print(f"Успешно извлечено {len(image_files)} файлов в {extract_dir}")
return True
except zipfile.BadZipFile:
print(f"Ошибка: архив поврежден или не является ZIP-файлом: {zip_path}")
return False
except Exception as e:
print(f"Ошибка при разархивировании: {e}")
return False
def main():
"""
Основная функция для запуска разархивирования.
"""
parser = argparse.ArgumentParser()
parser.add_argument('--dname', type=str, default='ya_go_maps', help='Dataset name')
args = parser.parse_args()
print(f"Начинаю разархивирование {args.dname}.zip...")
if extract_zip_to_images(args.dname):
print("Разархивирование успешно завершено!")
else:
print("Разархивирование не удалось.")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,57 @@
import argparse
import numpy as np
from pathlib import Path
from google_map import GoogleMap
from simulator import Simulator
from yandex_map import YandexMap
from constants import CHUNK_WIDTH
LAT_MIN, LAT_MAX = 49.134520, 49.235065
LON_MIN, LON_MAX = 55.767660, 55.825204
def create_new_asset(yandex_map, google_map, dname, tile_y=None, tile_x=None):
folder = Path(f'dataset_{dname}')
id = 0
print(id)
while (folder / f"{id:0{4}}_google.png").exists():
id += 1
google_file = folder / f"{id:0{4}}_google.png"
yandex_file = folder / f"{id:0{4}}_yandex.png"
lat = np.random.rand() * (LAT_MAX - LAT_MIN) + LAT_MIN
lon = np.random.rand() * (LON_MAX - LON_MIN) + LON_MIN
if tile_y is not None and tile_x is not None:
lat = tile_y * (LAT_MAX - LAT_MIN) + LAT_MIN
lon = tile_x * (LON_MAX - LON_MIN) + LON_MIN
yandex_map.open(lat, lon, 18)
google_map.open(lat, lon, 18)
simulator = Simulator()
im_ya = simulator._apply_perspective_transform(yandex_map.make_screenshot())
im_go = simulator._apply_perspective_transform(google_map.make_screenshot())
im_ya.resize((CHUNK_WIDTH // 2, CHUNK_WIDTH // 2)).save(yandex_file)
im_go.resize((CHUNK_WIDTH // 2, CHUNK_WIDTH // 2)).save(google_file)
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--dname', type=str, default='ya_go_maps', help='Dataset name')
args = parser.parse_args()
folder = Path(f'dataset_{args.dname}')
if not folder.exists():
folder.mkdir()
yandex_map = YandexMap(initial_zoom=18)
google_map = GoogleMap(initial_zoom=18)
for tile_y in np.linspace(0, 1, 20):
for tile_x in np.linspace(0, 1, 20):
create_new_asset(yandex_map, google_map, args.dname, tile_y, tile_x)
if __name__ == '__main__':
main()

Binary file not shown.

View File

@@ -0,0 +1 @@
Здесь хранятся медиафайлы (картинки, схемы)

View File

@@ -0,0 +1,9 @@
# Backlog предложений
## Предложения по улучшению
- Добавить экспериментальные результаты для каждого алгоритма
- Сравнить производительность разных подходов к гомографии
- Исследовать влияние освещения на точность сопоставления
- Добавить раздел о практическом применении разработанного алгоритма
- Рассмотреть возможность использования других архитектур нейросетей

View File

@@ -0,0 +1,12 @@
1.1 Задача возврата в точку старта в системах навигации БПЛА
Классическая навигация БПЛА основана на данных инерциальной навигационной системы (ИНС), спутниковых приёмников и, при необходимости, барометрических и радиотехнических средств. Такие комплексы обеспечивают приемлемую точность в стандартных условиях, но становятся уязвимыми при глушении или маскировании навигационных сигналов, а также при полётах в помещениях, городских «каньонах» и под навесами, где спутниковые сигналы физически недоступны.
В ответ на эти ограничения активно развиваются альтернативные средства навигации, в частности оптико‑электронные системы, использующие бортовую видеокамеру и корреляционную обработку изображений. Для малых БПЛА особое значение имеют вычислительные и массогабаритные ограничения, что требует применения алгоритмов с невысокой вычислительной сложностью и малым объёмом памяти при сохранении требуемой точности позиционирования.
В этом контексте отдельный интерес представляет задача возврата аппарата в начальную точку полёта в условиях отсутствия внешних навигационных сигналов. Типичный сценарий выглядит следующим образом: на этапе прямого полёта от старта к целевой области бортовая система формирует некоторое эталонное представление маршрута (последовательность кадров, фрагменты карты местности, 3Dмодель), а при потере внешней навигации или по команде оператора активируется режим автономного возврата. В этом режиме по текущему видеопотоку и частично известной карте необходимо корректировать курс и ориентирование БПЛА таким образом, чтобы аппарат вновь оказался в окрестности точки старта с допустимой погрешностью по координатам.
Рассмотрим типичный сценарий, лежащий в основе постановки данной работы:
Этап полёта «туда»: БПЛА стартует из начальной позиции p_0 и летит к целевой области. На этом этапе система сохраняет информацию о пройденном маршруте (последовательность видеокадров, карта местности или набор 3D-признаков). Спутниковая навигация доступна или используется периодически.
Потеря внешней навигации: при достижении целевой области БПЛА теряет спутниковый сигнал (или оператор отключает его по команде) либо входит в режим автономного полёта, когда внешняя связь перестаёт быть достоверным источником информации.
Режим возврата: активируется режим автономного возврата, в котором БПЛА должен вернуться в исходную позицию p_0 с минимальной ошибкой, опираясь только на данные бортовой видеокамеры (нижняя ориентация, смотрит вертикально вниз), ранее накопленную информацию о маршруте (карта или видеопоследовательность), при необходимости данные ИНС (гироскопы, акселерометры).
В литературе выделяются несколько направлений, в рамках которых рассматривается подобная постановка. К ним относятся методы на основе визуальной одометрии и SLAM, в которых построение карты и локализация выполняются совместно в процессе полёта, алгоритмы корреляционно‑экстремальной навигации по заранее подготовленным или формируемым во время полета эталонным картам, а также специализированные алгоритмы сопоставления видеопоследовательностей «туда» и «обратно», ориентированные непосредственно на реализацию режима «возврат в точку старта» [3].
В данной главе рассматриваются основные из этих подходов, анализируются их достоинства и ограничения применительно к малым БПЛА, а также формулируются требования к алгоритмам, ориентированным на работу в условиях ограниченных вычислительных ресурсов и возможного рассогласования между эталонной картой и текущими наблюдениями. Подробная формальная постановка задачи и выбор конкретных методов решения приводятся во второй главе.

View File

@@ -0,0 +1,8 @@
1.2 Классификация методов навигации БПЛА
С точки зрения используемых источников информации и постановки задачи возврата в исходную точку навигационные алгоритмы для БПЛА можно условно разбить на несколько групп [1]:
интегрированные ИНС + спутниковые системы (GPS/ГЛОНАСС): используют данные инерциальной навигационной системы, корректируемые спутниковыми измерениями; возврат в точку старта в этом случае сводится к следованию по заранее вычисленной траектории в глобальных координатах, но система полностью теряет устойчивость при глушении/маскировании спутниковых сигналов [8];
визуальные и визуально‑инерциальные системы (VO/VIO): оценивают траекторию и ориентацию по видеопотоку и данным IMU, в ряде случаев строя карту окружающего пространства; могут обеспечивать возврат по оценённой траектории или по ранее построенной карте [15];
корреляционно‑экстремальные навигационные системы (КЭНС): используют сопоставление текущих изображений с эталонной цифровой картой (2D или 3D) для коррекции ИНС и реализации возврата по видовой информации; позволяют работать даже при сильных ограничениях на вычислительные ресурсы [3].
Специализированные алгоритмы «возврата по видео» сравнивают текущую видеопоследовательность с ранее записанной при полёте по маршруту «туда» и по максимуму меры сходства определяют положение БПЛА вдоль маршрута и направление движения к старту.
Для каждой группы используются свои ключевые метрики: для VO/VIO ошибки траектории (ATE/RPE), для КЭНС — точность привязки к карте и время обработки кадра, для алгоритмов возврата — погрешность конечного положения относительно точки старта и вероятность успешного завершения возврата.

View File

@@ -0,0 +1,7 @@
1.3 Интегрированные инерциально‑спутниковые навигационные системы
Интегрированные инерциально‑спутниковые навигационные комплексы (ИНС/ГНСС) являются классическим и наиболее распространённым решением для навигации БПЛА, на которое опираются как серийные автопилоты, так и специализированные системы управления полётом. В типичном случае такой комплекс включает инерциальный модуль (акселерометры и гироскопы, часто в составе МЭМСIMU), приёмник глобальной навигационной спутниковой системы (GPS/ГЛОНАСС и др.), а также барометрический высотомер и магнитометр, которые используются для уточнения высоты и курса. Инерциальная система обеспечивает высокочастотное счисление пути — оценку координат, скорости и ориентации путём интегрирования измеренных ускорений и угловых скоростей, тогда как ГНСС выдаёт абсолютные координаты и скорость в глобальной системе, компенсируя накопление ошибок ИНС на длинных интервалах времени.
Поскольку автономная ИНС подвержена дрейфу из‑за шума и систематических смещений датчиков, в интегрированных комплексах применяется алгоритмическая «сшивка» двух подсистем, чаще всего на основе расширенного фильтра Калмана или близких к нему рекуррентных оценивателей. В рамках такой фильтрационной схемы инерциальная часть играет роль модели движения (прогноза), а спутниковые измерения рассматриваются как внешние наблюдения, периодически корректирующие состояние фильтра и тем самым ограничивающие рост погрешности. На практике используются различные варианты интеграции от «слабосвязанной», когда в фильтр поступает уже вычисленное решением ГНСС положение, до более тесной, где обрабатываются сырые псевдодальности и доплеровские измерения; при этом для малых БПЛА обычно выбирают схемы, обеспечивающие приемлемый компромисс между точностью и вычислительной сложностью.
В контексте задачи возврата в точку старта интегрированная система ИНС/ГНСС позволяет на участке от взлёта до целевой области формировать траекторию полёта в глобальных координатах и запоминать положение начальной точки. При наличии устойчивого спутникового сигнала возврат реализуется в виде режима, аналогичного режиму ReturntoHome в коммерческих автопилотах: бортовой контроллер либо следует по заранее заданному маршруту в обратном направлении, либо строит прямую траекторию к сохранённым координатам точки старта, используя текущие оценки положения и ориентации от интегрированного навигационного комплекса. Кратковременные выпадения или ухудшение качества ГНСС‑сигнала (затенение антенны, частичные помехи) в этом случае «переживаются» за счёт инерциальной части системы: ИНС обеспечивает непрерывность навигационного решения между обновлениями от спутников и предотвращает резкие скачки координат.
Однако в условиях, представляющих наибольший интерес для данной работы, — полётах в городских «каньонах», в помещениях, под перекрытиями, а также в зонах радиоэлектронного подавления — интегрированные ИНС/ГНСС‑комплексы демонстрируют принципиальные ограничения. При полном отсутствии или сильной деградации спутниковых сигналов навигационное решение фактически сводится к автономной ИНС: ошибка положения начинает расти во времени, и уже через относительно короткий промежуток полёта погрешности достигают величин, сопоставимых или превышающих допустимый радиус зоны возврата. Дополнительно на точность влияют многолучёвость и отражения сигналов в городской застройке, возможные атаки типа спуфинга и глушения, а также массогабаритные и энергетические ограничения малых БПЛА, не позволяющие использовать высокоточные (и дорогостоящие) инерциальные датчики и профессиональные ГНСС‑приёмники.
В результатe интегрированные ИНС/ГНСС‑системы обеспечивают надёжный и технологически отработанный механизм навигации и возврата в точку старта при наличии работоспособного спутникового канала, однако оказываются недостаточными в сценариях, где этот канал временно или полностью недоступен. Именно поэтому в современных исследованиях всё большее внимание уделяется альтернативным подходам — визуальной и визуально‑инерциальной одометрии, а также корреляционно‑экстремальной навигации по видовой информации, которые потенциально позволяют реализовать режим возврата в точку старта при отсутствии GPS/ГЛОНАСС и рассматриваются в последующих подпунктах обзора.

View File

@@ -0,0 +1,9 @@
1.4 Визуальная и визуально-инерциальная одометрия для автономной навигации
Визуальная одометрия (VO) и визуально‑инерциальная одометрия (VIO) решают задачу оценивания траектории носителя по последовательности изображений и данным ИНС, используя методы извлечения и сопоставления признаков, построения карт и оптимизационной фильтрации.
Примером аппаратно ориентированного VIOрешения является чип «Navion», представляющий собой энергоэффективный ускоритель визуально‑инерциальной одометрии для автономной навигации нано‑дронов [19]. В «Navion» вся VIOцепочка реализована на специализированной ASICмикросхеме: фронтенд обрабатывает моно-изображения или стереоизображения (детекция углов Харриса, трекинг по Лукасу–Канаде, стерео-сопоставление), а инерциальный фронтенд выполняет предварительное интегрирование данных IMU; на бэкенде решается нелинейная задача минимизации на фактор‑графе для оценки траектории и разреженной 3Dкарты.
Для уменьшения энергопотребления и объёма памяти в «Navion» используются сжатие изображений с блочно‑пороговой квантизацией, разрежённые структуры для хранения треков признаков и разрежённые представления матрицы Гессе в линейном решателе. Это позволяет обрабатывать стереокадры разрешения 752×480 с частотой до сотен кадров в секунду при средней потребляемой мощности порядка десятков милливатт, обеспечивая при этом среднюю ошибку траектории менее процента на сложных наборах данных для мультикоптеров. Такой подход демонстрирует, что VIOалгоритмы могут быть реализованы на бортовой аппаратуре малых БПЛА при строгих ограничениях по ресурсоёмкости.
Отдельный класс решений составляют фильтрационные VIOалгоритмы, в которых оценивание состояния (координаты, скорость, ориентация, смещения датчиков) выполняется с помощью расширенного фильтра Калмана или его модификаций [15]. В работе SPVIO предлагается фильтрационный VIOалгоритм, ориентированный на миниатюрные робототехнические платформы и условия деградации визуальной информации.
Авторы SPVIO показывают недостатки традиционных фильтров (например, MSCKF): накопление ошибок и проблемы с учётом наблюдаемости. Для решения используется двойная трансформация состояния (DSTEKF) с улучшенной моделью визуальных измерений, которая полностью разделяет ошибки скорости/положения и визуальные остатки.
На популярных публичных датасетах (EuRoC, TumVI, KITTI) SPVIO демонстрирует более низкую среднеквадратическую ошибку траектории по сравнению с рядом современных VIOалгоритмов (VINSMono, OpenVINS и др.) при сопоставимой или лучшей вычислительной эффективности. Дополнительно реализована стратегия сглаживания траектории DSTRTS, позволяющая существенно уменьшить накопленную ошибку при длительных потерях визуальных наблюдений и тем самым повысить робастность навигации в условиях временных «провалов» видеосигнала.
Для решаемой в диссертации задачи возврата в точку старта результаты по VIO показывают, что визуально‑инерциальные алгоритмы могут обеспечить высокоточную оценку траектории и ориентации БПЛА, однако они, как правило, ориентированы на локальную непрерывную оценку положения, а не на специальную цель прохождения замкнутого маршрута с минимальной ошибкой в конечной точке. Кроме того, полноценные VIOсистемы при высокой точности могут быть избыточно ресурсоёмкими для лёгких бортовых вычислителей малых БПЛА.

View File

@@ -0,0 +1,28 @@
1.5 Корреляционные методы навигации по видовой информации
Корреляционно‑экстремальные навигационные системы используют сопоставление текущих изображений с эталонной картой или 3Dмоделью местности для коррекции дрейфа ИНС и решения задач типа «вернуться в уже посещённую область». В ряде современных работ эталонная карта строится по данным оптико‑электронной системы в процессе предварительного облёта, после чего в полёте БПЛА периодически сравнивает текущий вид с фрагментами этой карты и уточняет свои координаты.
В исследовании Беляева и Зикратова имитируется ситуация, когда эталонная карта и искомая область получены из разных источников (например, спутниковые снимки и бортовая видеокамера) и существенно различаются по текстуре и освещённости [2]. Для таких условий сравниваются:
автокорреляционная функция;
коэффициент корреляции Пирсона;
индекс структурного сходства SSIM;
простая нейронная сеть‑перцептрон.
Используются различные стратегии обхода карты (прямой проход, проход с перекрытием, по спирали, случайный), а метриками служат время полного обхода для заданного размера окна и успешность нахождения целевой области на зашумлённых и искажённых примерах. Вывод работы состоит в том, что автокорреляционный подход при правильном выборе шага обхода и размера окна позволяет удовлетворить ограничения по времени, оставаясь при этом достаточно точным и устойчивым к различным искажениям, что делает его хорошей основой для визуальной коррекции курса при возврате.
В серии работ по оптико‑электронным ыкорреляционно‑экстремальным системам малого БПЛА также показывается, что при разумных ограничениях на высоту, скорость и частоту съёмки возможно достигать приемлемой точности определения координат по цифровой модели местности, построенной заранее или в ходе облёта. Такие системы непосредственно решают задачу поддержания заданного маршрута и коррекции навигации, включая фазу возврата в исходную точку, и могут работать как в полностью автономном, так и в полуавтономном режимах.
Особое место занимают работы, где целью явно объявляется возврат БПЛА в точку старта по данным бортовой видеокамеры. В одной из таких статей маршрут от старта до момента потери спутниковой навигации используется для построения глобальной карты в системе географических координат, после чего возврат выполняется за счёт локализации по ранее построенной карте без её дальнейшего расширения; в экспериментах на квадрокоптере «DJI Phantom 3 Pro» авторы демонстрируют устойчивое возвращение в окрестность исходной точки. В другой работе предлагается сопоставление текущих кадров при полёте «домой» с кадрами, ранее записанными при полёте «туда», с использованием меры сходства по яркостным и текстурным признакам, что позволяет оценивать смещение вдоль маршрута и корректировать курс даже без явной 3Dкарты.
Эти примеры показывают, что корреляционно‑экстремальные методы и сопоставление видеопоследовательностей дают практически применимые решения задачи возврата в точку старта, которые могут быть реализованы на коммерческих БПЛА при относительно умеренных требованиях к вычислительным ресурсам.
Альтернативой сложным VIOалгоритмам для малых БПЛА являются корреляционно‑экстремальные методы, сопоставляющие текущие изображения местности с эталонной цифровой картой или ранее накопленной видеопоследовательностью. Такие методы особенно удобны в сценариях, когда доступна карта маршрута или имеется возможность сформировать эталонную базу изображений на этапе прямого полёта (предварительный облёт, съёмка при наличии GPS и т.п.).
В простейшем случае эталонная карта представляется в виде двумерного массива яркостей I_{\mathrm{ref}}(x,y), а текущий кадр бортовой камеры — как фрагмент I_{\mathrm{cur}}(u,v), соответствующий некоторой неизвестной области карты. Задача навигации сводится к поиску таких сдвигов (\Delta x,\Delta y), при которых мера сходства между I_{\mathrm{cur}} и соответствующим окном карты I_{\mathrm{ref}}(x+\Delta x,y+\Delta y) максимальна.
В работе, посвящённой исследованию автономной навигации БПЛА на основе корреляционных методов сравнения изображений, рассматривается задача поиска области соответствия между двумя изображениями одного и того же участка местности, полученными из разных источников и отличающимися по шуму, освещённости и деталям. В качестве эталона используется «карта» местности, а в качестве текущего наблюдения — искомая область, которая может быть зашумлена, затемнена или искажена; различие источников моделирует эффект смены домена между, например, спутниковыми и бортовыми изображениями.
Рассматриваются несколько базовых метрик сходства. Для двух окон изображений X={x_i} и Y={y_i} (последовательно взятые пиксели) автокорреляционная функция и коэффициент корреляции Пирсона записываются как
R_{XY}=\sum_{i}\hairsp(x_i-x)(yi-y),ρXY=i(xi-x)(yi-y)i(xi-x)2 i(yi-y)2,
где x,y — средние значения яркости в окнах. Индекс структурного сходства SSIM оценивает сходство по яркости, контрасту и структуре и обычно записывается в виде
SSIM(X,Y)=\frac{(2\mu_X\mu_Y+C_1)(2\sigma_{XY}+C_2)}{(\mu_X^2+\mu_Y^2+C_1)(\sigma_X^2+\sigma_Y^2+C_2)},
где \mu_X,\mu_Y — средние значения, \sigma_X^2,\sigma_Y^2 — дисперсии, \sigma_{XY} — ковариация, C_1,C_2 малые постоянные для стабилизации.
В указанной работе систематически сравниваются автокорреляционная функция, коэффициент корреляции Пирсона, индекс структурного сходства SSIM и простая нейросетевая модель‑перцептрон, применяемые в режиме «скользящего окна» по эталонной карте. Используются различные стратегии обхода окна (прямой проход, проход с перекрытием, по спирали, случайный), а метриками служат время полного обхода для заданного размера окна и успешность нахождения целевой области, в том числе для зашумлённых и искажённых фрагментов карты. Полученные результаты показывают, что автокорреляционный подход при правильно выбранных параметрах окна обеспечивает наилучший компромисс между вычислительной сложностью и точностью: он демонстрирует высокую скорость обработки полного изображения и надёжно выделяет область соответствия как для оригинального, так и для искажённых фрагментов. Методы Пирсона и SSIM уступают по быстродействию при близкой или худшей точности, а нейросетевой перцептрон даёт заметно большую вычислительную нагрузку при ограниченном выигрыше по качеству.
Отдельный класс работ посвящён оптико‑электронным корреляционно‑экстремальным навигационным системам малого БПЛА. В таких системах по последовательности оптических изображений строится трёхмерная цифровая модель местности, после чего в полёте выполняется корреляционная обработка текущих и эталонных 3Dмоделей для коррекции инерциальной навигации. На основе эталонной модели M_{\mathrm{ref}} и текущих наблюдений M_{\mathrm{cur}} формируется функционал вида
J(p)=-\ Corr(M_{\mathrm{ref}},T(p)M_{\mathrm{cur}}),
где p описывает положение и ориентацию БПЛА, T(p) — оператор преобразования модели, а Corr — выбранная корреляционная метрика. Минимизация J(p) позволяет скорректировать оценку состояния инерциальной навигационной системы. В подобных работах подробно исследуется влияние высоты и скорости полёта, перекрытия кадров и периодичности коррекции ИНС на итоговую точность определения координат по эталонной 3Dмодели.
Корреляционно‑экстремальные навигационные системы в целом используют сопоставление текущих изображений с эталонной картой или 3Dмоделью местности для компенсации дрейфа ИНС и решения задач типа «вернуться в уже посещённую область». В ряде современных работ эталонная карта строится по данным оптико‑электронной системы в процессе предварительного облёта, после чего в полёте БПЛА периодически сравнивает текущий вид с фрагментами этой карты и уточняет свои координаты. При разумных ограничениях на высоту, скорость и частоту съёмки удаётся достигать приемлемой точности привязки для поддержания маршрута и коррекции навигации, включая фазу возврата в исходную точку, причём системы могут работать как в полностью автономном, так и в полуавтономном режиме.
Особое место занимают работы, где целью является возврат БПЛА в точку старта по данным бортовой видеокамеры. В одном из подходов маршрут от старта до момента потери спутниковой навигации используется для построения глобальной карты в системе географических координат, после чего на этапе возврата карта «замораживается», и полёт осуществляется за счёт локализации по ранее построенной карте без её дальнейшего расширения. Локализация сводится к сопоставлению текущих кадров с ближайшими по траектории эталонными фрагментами и оценке положения аппарата относительно координатной системы карты; по этим оценкам формируются управляющие воздействия для следования по маршруту в обратном направлении. Эксперименты, в том числе на квадрокоптере «DJI Phantom 3 Pro», показывают возможность устойчивого возвращения в окрестность исходной точки при отсутствии спутниковой навигации.
В другом подходе предлагается сопоставление текущих кадров при полёте «домой» с кадрами, ранее записанными при полёте «туда», с использованием меры сходства по яркостным и текстурным признакам. По максимальному значению меры сходства определяется индекс наиболее похожего кадра в эталонной последовательности, что фактически задаёт положение БПЛА вдоль маршрута. Это позволяет оценивать смещение вдоль трассы и корректировать курс даже без явной трёхмерной модели сцены.
Таким образом, корреляционно‑экстремальные методы и сопоставление видеопоследовательностей дают практически применимые решения задачи возврата в точку старта, которые могут быть реализованы на коммерческих БПЛА при относительно умеренных требованиях к вычислительным ресурсам. Для задачи возврата в точку старта они особенно привлекательны тем, что позволяют сопоставлять текущий кадр с локальными фрагментами эталонной карты вдоль маршрута без построения полной плотной карты сцены и без сложной фактор‑графовой оптимизации, что делает такие подходы перспективными для малогабаритных БПЛА с ограниченными ресурсами.

View File

@@ -0,0 +1,9 @@
1.6 Алгоритмы возврата в точку старта по видеопоследовательностям
Ряд работ посвящён непосредственно задаче возврата БПЛА в начальную точку полёта по данным бортовой видеокамеры без использования GPS/ГЛОНАСС. В этих подходах используется либо видеопоследовательность, заранее записанная по маршруту, либо изображения, накопленные в ходе прямого полёта до потери спутниковых сигналов.
В статье, посвящённой визуальной навигации автономно летящего БПЛА с целью его возвращения в точку старта, предлагается алгоритм, сочетающий идеи визуальной одометрии и SLAM при явном разделении этапов картографирования и локализации. На участке от старта до потери сигналов GPS/ГЛОНАСС бортовая система по данным видеокамеры и актуальным навигационным данным строит карту местности в виде множества 3Dточекпризнаков с известными географическими координатами.
После потери спутниковой навигации запускается миссия возврата: текущее положение БПЛА оценивается только относительно ранее построенной карты, без дальнейшего её расширения, что уменьшает вычислительные затраты и предотвращает неконтролируемый рост ошибок. Локализация заключается в сопоставлении признаков, выделенных на текущем кадре, с ближайшими по траектории участками карты и в последующей оценке положения и ориентации аппарата относительно системы координат карты; по этой оценке, формируются управляющие воздействия для следования по маршруту в обратном направлении.
Преимуществом такого подхода является отсутствие накопления ошибок, характерного для чистой визуальной одометрии и инерциальной навигации: поскольку локализация ведётся относительно фиксированной карты, ошибка не растёт без ограничений с длиной пройденного пути. Алгоритм реализован и протестирован на квадрокоптере DJI Phantom 3 Pro; по результатам экспериментов обеспечивается надёжное возвращение БПЛА в окрестность точки старта при отсутствии спутниковой навигации.
В другой работе рассматривается навигация БЛА с помощью бортовой видеокамеры, где также решается задача возврата аппарата в начальную точку полёта в автономном режиме. Предлагаемый алгоритм сопоставляет каждый текущий кадр, полученный при полёте «домой», с кадрами, записанными при полёте от исходной точки к цели, что позволяет по максимальному значению меры сходства оценивать смещение вдоль маршрута и корректировать курс.
Если заранее доступна эталонная видеопоследовательность или последовательность изображений (например, спутниковых снимков), покрывающая весь маршрут от исходной точки до точки назначения, описанный подход обеспечивает как полёт к цели, так и возврат, полностью опираясь на видовую информацию. В работе приводятся результаты компьютерного моделирования и экспериментов с использованием видеозаписей с бортовой камеры квадрокоптера «DJI Phantom», демонстрирующие возможность реализации подобного алгоритма на реальной технике.
Отмеченные алгоритмы являются прямыми примерами решения задачи возврата в точку старта, близкой к рассматриваемой в диссертации постановке. Они показывают, что раздельное ведение картографирования и локализации, а также сопоставление текущих кадров с ранее записанными, позволяют обеспечить устойчивый возврат при умеренной вычислительной сложности.

View File

@@ -0,0 +1,11 @@
1.7 Интеграция классических и нейросетевых методов для задачи работы
С появлением мощных методов глубокого обучения задачи сопоставления изображений для навигации дополнились новыми инструментами, которые особенно актуальны при сильном рассогласовании между эталонной картой и текущими наблюдениями (domain shift).
Генеративно‑состязательные сети (GAN) позволяют трансформировать изображения из разных доменов (например, спутниковые и аэрофотоснимки с борта) к единому стилю, что облегчает последующее корреляционное сопоставление.
Сиамские сети для визуальной одометрии (SiaNVO) используют две ветви CNN для извлечения признаков из пары кадров и полносвязные слои для оценки либо скалярной меры сходства, либо параметров гомографии/относительного движения.
Трансформер‑модели для VO (DeepVO, VoT и др.) за счёт механизма внимания улучшают устойчивость к дрейфу и позволяют лучше учитывать долгосрочные зависимости в видеопотоке, однако требуют существенно больших вычислительных ресурсов, вплоть до использования современных GPUускорителей.
В контексте разрабатываемого алгоритма возврата в точку старта такие модели могут быть использованы:
на этапе нормализации изображений (GAN) для уменьшения влияния различий по освещённости, сезону и типу съёмки;
на этапе оценки степени совпадения текущего кадра с эталонными фрагментами карты (сиамская сеть, выдающая значение сходства в диапазоне от 0 до 1);
при вычислении матрицы гомографии между кадрами, что позволяет точнее оценивать относительное смещение и поворот БПЛА при сопоставлении с эталонами.
Тем самым создаётся гибридный подход, сочетающий проверенные корреляционные методы (автокорреляция, slidingwindowобход и т.п.) с нейросетевыми оценщиками сходства и гомографии. Классические методы обеспечивают гарантированное быстродействие и предсказуемое поведение на борту малых БПЛА, тогда как нейросетевые компоненты повышают устойчивость к сложным условиям съёмки и рассогласованию карт, что особенно важно для устойчивого возврата в точку старта в реальных условиях эксплуатации.

View File

@@ -0,0 +1,25 @@
1.8 Метрики оценки качества навигации и возврата
Визуально‑инерциальные алгоритмы традиционно оцениваются с использованием наборов данных EuRoC, TUMVI, KITTI и др., где для каждого датасета известна «истинная» траектория. Наиболее распространённые метрики:
ATE (Absolute Trajectory Error) среднеквадратическая ошибка по положению между восстановленной и эталонной траекториями, измеряемая по всей длине маршрута; часто используется как интегральный показатель качества VIOалгоритма;
RPE (Relative Pose Error) ошибка относительного смещения и поворота на фиксированном временном горизонте, характеризующая локальную стабильность и дрейф [19];
процентная ошибка по расстоянию отношение ошибки конечного положения к длине траектории, выраженное в процентах; для ряда VIOрешений она составляет доли процента даже на километровых дистанциях.
ATE измеряет насколько далеко в среднем лежит оцененная траектория от истинной после выравнивания. Для каждого момента времени i есть истинные позы БПЛА T_i^{gt} (ground truth) и оцененные позы T_i^{est} (estimated). Определим матрицу S, преобразовывает каждую оцененную позу таким образом, что суммарная разница между позами минимизируется. Для каждого момента времени определим ошибку
E_i=\left(T_i^{gt}\right)^{-1}ST_i^{est}
Итоговая метрика определяется следующим образом:
ATE_{RMSE}=\sqrt{\frac{1}{N}\sum_{i}\left|\left|trans\left(E_i\right)\right|\right|^2},
где trans\left(E_i\right) норма трансляции, иными словами смещение, а N количество кадров. Для интуитивного понимания можно представить, что ATE характеризует насколько в среднем расходится оцененная траектория полета от истинной в каждый момент времени.
RPE измеряет локальную ошибку относительного движения на фиксированном интервале (между кадрами). Для этого в каждый момент времени i рассчитывается относительное движение истинной траектории:
\Delta T_i^{gt}=\left(T_i^{gt}\right)^{-1}T_{i+\Delta}^{gt}
И похожим образом для оцененной траектории:
\Delta T_i^{est}=\left(T_i^{est}\right)^{-1}T_{i+\Delta}^{est}
Теперь, разница между различными смещен рассчитывается по следующей формуле:
E_i^{rel}=\left(\Delta T_i^{gt}\right)^{-1}\Delta T_i^{est}
Аналогичным образом высчитывается средняя ошибка на каждый переход кадров. Преимущество RPE в том, что он не зависит от начального смещения и ориентации и лучше показывает накопление ошибок со временем.
Данные метрики применяются в различных публикациях. Например, в работе по SPVIO показано, что алгоритм обеспечивает более низкий ATE по сравнению с VINSMono и OpenVINS как на общеизвестных датасетах (EuRoC, TUMVI, KITTI), так и на собственных данных, при этом сохраняется вычислительная эффективность, характерная для фильтрационных подходов. В экспериментах с автомобильной траекторией длиной порядка 4.5 км достигнута ошибка порядка долей процента, что критично для задач возврата: подобная точность позволяет вернуться в небольшую окрестность исходной точки без внешней навигации.
Для аппаратного ускорителя «Navion» оцениваются как точность траектории, так и энергетическая эффективность; средняя ошибка траектории составляет около 0.28% на сложном мультироторном датасете EuRoC, при этом потребляемая мощность — всего порядка 24 мВт при частоте обработки до 171 кадр/с. Такие характеристики демонстрируют, что при соответствующей аппаратной поддержке VIO может выступать в качестве базового навигационного ядра и для задачи возврата [15].
Для корреляционно‑экстремальных систем вводятся метрики:
максимальная ошибка привязки искомой области к карте (в пикселях карты или в метрах на местности);
вероятность правильного обнаружения области при зашумлении, затемнении и искажениях (моделируется как имитация «domain shift» между разными источниками изображений);
время обхода карты при различных стратегиях сканирования (прямой обход, обход с перекрытием, по спирали, случайный), определяющее возможность работы в реальном времени.
В исследовании Беляева и Зикратова проводится количественное сравнение нескольких корреляционных метрик (автокорреляция, коэффициент Пирсона, SSIM, однослойный перцептрон) по точности обнаружения и времени обработки при различных режимах обхода скользящего окна [3]. Показано, что автокорреляционный подход обеспечивает оптимальное сочетание точности и быстродействия, а также устойчив к зашумлению и искажению эталонной карты.

View File

@@ -0,0 +1,7 @@
## 1.9 Сопоставление подходов и место данной работы
Рассмотренные подходы можно условно разделить на три группы: высокоточные VIOалгоритмы с плотной или разреженной картой («Navion», SPVIO и др.), корреляционно‑экстремальные методы навигации по эталонным картам и специализированные алгоритмы возврата в точку старта по видеопоследовательностям.
VIOсистемы обеспечивают высокую точность оценки траектории и могут работать в широком диапазоне условий, но их реализация на малых БПЛА требует либо специализированных аппаратных ускорителей (как в «Navion»), либо достаточно производительных процессоров, а также тщательной настройки фильтрационных или оптимизационных процедур. Корреляционные методы и корреляционно‑экстремальные навигационные системы менее универсальны, но гораздо проще по вычислительной схеме, что делает их привлекательными для ресурсно‑ограниченных платформ.
Алгоритмы возврата в точку старта по видеопоследовательностям, предложенные в работах Жука и др., а также Залесского и Шувалова, демонстрируют, что задача возврата может быть решена без сложной глобальной оптимизации и построения полной карты, если на этапе прямого полёта или заранее сформирована достаточная база эталонных кадров [4,5]. При этом ряд практических вопросов остаётся открытым: влияние рассогласования между эталонной и текущей картой (domain shift), устойчивость к изменениям освещённости и сезона, а также выбор метрик сходства и стратегий обхода карт, обеспечивающих минимальную погрешность конечных координат при ограниченном времени обработки.
В данной работе предполагается развить идеи корреляционно‑экстремальной навигации и алгоритмов возврата в точку старта, дополнив их современными методами глубокого обучения для сопоставления изображений. В частности, рассматривается использование генеративно‑состязательных сетей (GAN) для приведения изображений из разных доменов к единому стилю, сиамских нейросетей для оценки степени сходства текущего кадра с эталонными участками карты и для оценки матрицы гомографии между кадрами.
Таким образом, обзор показывает, что существующие решения либо обеспечивают высокую точность ценой значительных ресурсных затрат (VIO), либо ориентированы на специальные условия и не учитывают в полной мере проблему рассогласования карт и необходимость интеграции с методами глубокого обучения для обработки изображений. Это определяет актуальность разработки алгоритма навигации возврата в точку старта, сочетающего достоинства корреляционных подходов и современных методов глубокого обучения при учёте ограничений бортовой вычислительной платформы малых БПЛА.

View File

@@ -0,0 +1,15 @@
# Глава 1. Аналитический обзор современного состояния проблемы
## Содержание
| Раздел | Название | Файл |
|--------|----------|------|
| 1.1 | Задача возврата в точку старта в системах навигации БПЛА | chapter_1_1.md |
| 1.2 | Классификация методов навигации БПЛА | chapter_1_2.md |
| 1.3 | Интегрированные инерциально‑спутниковые навигационные системы | chapter_1_3.md |
| 1.4 | Визуальная и визуально-инерциальная одометрия для автономной навигации | chapter_1_4.md |
| 1.5 | Корреляционные методы навигации по видовой информации | chapter_1_5.md |
| 1.6 | Алгоритмы возврата в точку старта по видеопоследовательностям | chapter_1_6.md |
| 1.7 | Интеграция классических и нейросетевых методов для задачи работы | chapter_1_7.md |
| 1.8 | Метрики оценки качества навигации и возврата | chapter_1_8.md |
| 1.9 | Сопоставление подходов и место данной работы | chapter_1_9.md |

View File

@@ -0,0 +1,2 @@
- [x] Оглавление в readme.md оформить в виде таблицы
- [x] распарсить _temp_full_content.md по файлам

View File

@@ -0,0 +1,19 @@
2.1 Постановка задачи возврата БПЛА в точку старта
В данной работе рассматривается задача автономного возврата беспилотного летательного аппарата в точку старта при отсутствии или потере сигналов спутниковой навигации (GPS/ГЛОНАСС) и любой другой внешней связи. Дрон оснащен:
бортовой камерой для получения изображений местности в реальном времени;
инерциальной навигационной системой, обеспечивающей измерения угловых скоростей и ускорений;
бортовым вычислителем, способным выполнять обработку изображений и управляющие вычисления.
В рамках настоящей работы предполагается только наличие бортовой камеры.
Исходные данные:
Циклический маршрут последовательность координат точек \{p_0,p_1,\ldots,p_n\}, где p_0=p_n (первая точка совпадает с последней);
Эталонная карта местности изображение или последовательность изображений вдоль траектории маршрута, полученная до начала полета (например, спутниковые снимки или предварительная загрузка участков карты);
Видеопоток в реальном времени последовательности кадров, получаемых бортовой камерой в процессе полета.
Задача: разработать алгоритм навигации, который корректирует угол и направление полета таким образом, чтобы пройти через каждую заданную точку маршрута \{p_1,p_2,\ldots,p_{n-1}\}, вернуться в исходную позицию p_o с наименьшей с наименьшей погрешностью в координатах и обеспечить устойчивость к различиям между эталонной картой и реальными изображениями.
Формально говоря, цель состоит в минимизации конечной ошибки позиционирования:
e_{final}=\left|\left|p_{actual}\left(T\right)-p_0\right|\right|,
Где p_{actual}\left(T\right) фактическое положение БПЛА в момент завершения возврата, p_0 исходная точка старта.
Дополнительным критерием точности может выступить минимизация средней накапливаемой ошибки на каждой точке:
s_{final}=\sum_{i=1}^{P}\left|\left|p_{actual}\left(i\right)-p_i\right|\right|,
где p_{actual}\left(i\right) фактическое положение БПЛА при прохождении через точку i, а p_i положение точки i, P - количество точек

View File

@@ -0,0 +1,5 @@
# 2.1 Формализация задачи возврата БПЛА в точку старта
## Содержание раздела
Описание формализации задачи находится в файле 2.1_formalization.md

View File

@@ -0,0 +1,39 @@
2.2 Базовое решение задачи
Обозначим последовательность изображений \{u_i\ |\ i\in[0..N]}, где N итоговое количество кадров, которое было получено во время всего полета. Пусть
I_i\left(x,y\right) интенсивность пикселя в позиции (y,\ x), где x\ \in[0..W-1] и y\ \in[0..W-1], W ширина кадра.
Матрица H_i матрица гомографии, которая описывает переход от кадра u_{i-1} к кадру u_i:
I_{i-1}\left(x,y\right)=I_i\left(x^\prime,y^\prime\right),
t\ast\left(\begin{matrix}x\prime\\y\prime\\1\\\end{matrix}\right)=H_i\times\left(\begin{matrix}x\\y\\1\\\end{matrix}\right)
Матрица гомографии (H) описывает проективное между двумя плоскостями и может быть представлена в следующем виде:
H=K_1\times R\times T\times{K_2}^{-1},
где K_1 и K_2 матрицы внутренних параметров камеры, R матрица поворотов, T матрица трансляции.
Матрицы K_1 и K_2 представляют собой матрицы внутренних параметров и обычно равны между собой (различаются центры изображений). Однако в случаях разного разрешения картинок они могут иметь разные параметры. В общем виде такая матрица представляется следующим образом:
K_i=\left(\begin{matrix}f&\gamma&c_x^i\\0&f&c_y^i\\0&0&1\\\end{matrix}\right),
Где f фокусное расстояние в пикселях, c_x и c_y координаты оптического центра камеры на плоскости изображения, \gamma коэффициент скоса, описывающий угол наклона пикселей (в рамках симуляции равен 0).
Матрицу R можно получить при помощи стандартной функции OpenCV decomposeHomographyMat, также эту матрицу можно вычислить самостоятельно, поскольку первые два столбца матрицы гомографии H инвариантны смещению.
Матрица R представляет собой следующий вид:
R=R_x\times R_y\times R_z
Где R_x,R_y,R_z матрицы вращения вокруг осей OX,\ OY,\ OZ соответственно.
Если известны угол рыскания \psi, тангажа \theta и крена \gamma, то эти матрицы вычисляются следующим образом:
R_x\left(\gamma\right)=\left(\begin{matrix}1&0&0\\0&cos\left(\gamma\right)&-sin\left(\gamma\right)\\0&sin\left(\gamma\right)&cos\left(\gamma\right)\\\end{matrix}\right),
R_y\left(\theta\right)=\left(\begin{matrix}cos\left(\theta\right)&0&sin\left(\theta\right)\\0&1&0\\-sin\left(\theta\right)&0&cos\left(\theta\right)\\\end{matrix}\right),
R_z\left(\psi\right)=\left(\begin{matrix}cos\left(\psi\right)&-sin\left(\psi\right)&0\\sin\left(\psi\right)&cos\left(\psi\right)&0\\0&0&1\\\end{matrix}\right),
Зная матрицу H,\ K_1, K_2,\ и R, можно получить матрицу T:
T=R^{-1}\times K_1^{-1}\times H\times K_2
л.в.что:T=R-1×K1-1×K1×R×T×K2-1×K2
Матрица трансляции имеет вид:
T=\lambda0λxf0λλyf00λz=λ *10xf01yf00z
Таким образом, можно получить смещение между кадрами, зная матрицы внутренних параметров камеры и матрицу гомографии.
Чтобы получить положение БПЛА в момент времени t, достаточно получить матрицу трансляции из произведения всех промежуточных матриц гомографии (обозначим за G_t) на префиксе кадров [0..t]:
G_t=\prod_{i=1}^{t}H_i=H_1\times H_2\times\ldots\times H_t

View File

@@ -0,0 +1,5 @@
# 2.2 Базовое решение задачи
## Содержание раздела
Описание базового решения находится в файле 2.2_base_solution.md

View File

@@ -0,0 +1,51 @@
2.3.1 Применение архитектуры сиамских близнецов для сопоставления кадров из различных доменов
Абстрактно, сиамские близнецы представляют собой следующую архитектуру (рисунок 1):
На вход подаются два или более объекта с одинаковой сигнатурой.
Каждая входная информация проходит одинаковый пайплайн обработки (получение признаков при помощи CNN).
Полученные наборы признаков объединяются в вектор (1\times M).
Голова модели представляет собой полносвязную нейронную сеть.
Рисунок 1 Абстрактная архитектура сиамских близнецов
В качестве входной информации выступят два кадра: текущий снимок видеокамеры и эталонный снимок, для которого точно известна позиция. Выходная информация будет представлять собой вещественное число от 0 до 1 степень схожести двух картинок (Рисунок 2).
Рисунок 2 Интерфейс алгоритма (входные и выходные данные)
На базе такой архитектуры реализована модель «SiaN-Similarity» с использованием фреймворка PyTorch и включает общий скелет на базе предобученной модели «ResNet18» и голову сравнения (рисунок 3). На вход подаются два изображения размером \left(B,3,256,256\right), где B размер пакета данных (batch). Эти изображения представляют собой текущий кадр («Image 1») и кандидат-эталон «Image 2». Извлеченные при помощи «ResNet18» признаки f_1 и f_2 объединяются в вектор [f_1,f_2,f1f2,f1*f2] размером \left(B,2048\right), так как каждый из векторов f_1 и f_2 имеет размер (B,\ 256). Все это проходит через многослойный перцептрон (MLP), где выход вероятность схожести картинок (рисунок 3).
Обучение модели проводилось на собственном датасете «ya_go_maps» - собственный набор, состоящий из 327 пар изображений разных доменов (261 тренировочных и 66 валидационных) с бинарными метками (одинаковы или разный домен). Датасет сформирован на основе снимков из различных онлайн-карт («Яндекс.Карты» и «Google Maps»). Для обучения использовалась функция потерь Binary Cross Entropy Loss (BCELoss), которая идеально подходит для бинарной классификации схожести изображений:
l_n=-\left(y_nlog\left(x_n\right)+\left(1-y_n\right)log\left(1-x_n\right)\right),
где x_n предсказанная вероятность, y_n истинная метка (1 для одинаковых пар снимков, 0 для разных). Средняя потеря по батчу вычисляется как
\frac{1}{N}\sum_{n}l_n
BCELoss выбрана из-за совместимости с сигмоидным выходом и способности эффективно различать градиенты для вероятностей, что отлично подходит для задач сопоставления изображений с доменным сдвигом.
Рисунок 3 Архитектура модели «SiaN-Similarity»
Платформа обучения «Kaggle» с видеокартой «Tesla P100». Весь процесс обучения занял 24 минуты и остановился на 56 эпохе (ранняя остановка). Train Loss снизилась до 0.27 и монотонно убывала на протяжении всех эпох (за небольшими исключениями. После ~30 эпохи модель начала переобучаться и сработала ранняя остановка (early stopping). Лучшая Val Loss 0.2929 (эпоха 36) при Val Accuracy 0.90. Однако лучшая точность достигнута на последней эпохе 0.9630, правда функция потерь в том случае слишком высокая и слишком низкий recall, что критично для дальних полетов, ведь высок риск еще большего накопления ошибок.
Рисунок 4 Графики обучения «SiaN-Similiarity»
Модель демонстрирует консервативный характер: высокий recall минимизирует пропуски истинных совпадений эталонных кадров (false negatives), что критично для коррекции траектории БПЛА. Низкая объясняется доменным сдвигом и малым размером датасета модель склонна классифицировать неоднозначные пары как одинаковые. Это приемлемо для навигации, где false positives приводят лишь к проверке дополнительных кандидатов, а false negatives могут вызвать потерю позиции (рисунок 5).
Рисунок 5 Матрица ошибок «SiaN-Similarity»
На рисунке 6 и 7 показаны примеры верных предсказании модели и неверных соответственно.
Рисунок 6 Верно угаданные сэмплы «SiaN-Similarity»
Рисунок 7 Неверно угаданные сэмплы «SiaN-Similiarity»

View File

@@ -0,0 +1,2 @@
2.3.2 Применение архитектуры сиамских близнецов для вычисления матрицы гомографии между двумя кадрами

View File

@@ -0,0 +1,3 @@
2.3.3 Применение архитектуры сиамских близнецов для вычисления матрицы гомографии между двумя кадрами

View File

@@ -0,0 +1,5 @@
2.3 Методы глубого обучения
Основным минусом базового решения является то, что любая его реализация предполагает накопление погрешности. В таком случае корректировка позиции может стать хорошим дополнением для снижения дрейфа.
Одной из проблем такого подхода является поиск необходимого эталонного снимка. Снимки могут совершаться в разное время суток, в разных годах, при неодинаковых условиях (ветер, туман, погода, сезон года), но иметь какие-то общие схожие паттерны. Классические способы решения сопоставления таких кадров работают крайне плохо, однако модели на основе нейронных сетей справляются с этой задачей куда лучше.

View File

@@ -0,0 +1,8 @@
# 2.3 Эталонные снимки
## Содержание раздела
| Подраздел | Название | Файл |
|-----------|----------|------|
| 2.3.1 | Применение архитектуры сиамских близнецов для сопоставления кадров из различных доменов | 2.3.1_siamese_match.md |
| 2.3.2 | Применение архитектуры сиамских близнецов для вычисления матрицы гомографии | 2.3.2_siamese_homography.md |

View File

@@ -0,0 +1,51 @@
2.4 Датасет
Для обучения и тестирования моделей глубокого обучения, описанных в разделе 2.3, был создан специализированный датасет `ya_go_maps`, состоящий из пар изображений, полученных из двух различных картографических источников: Яндекс.Карт и Google Maps.
\section{Структура датасета}
Каждый элемент датасета представляет собой пару изображений одной и той же географической локации, полученных с различных картографических сервисов. Изображения в паре имеют одинаковое разрешение и именуются по шаблону: `{id}_google.png` и `{id}_yandex.png`, где `id` — порядковый номер пары. Например, изображения `0063_google.png` и `0063_yandex.png` соответствуют одному и тому же участку местности.
\section{Географические координаты и масштаб}
Сбор данных осуществлялся в пределах следующего географического региона:
\begin{itemize}
\item Широта: от 49.134520 до 49.235065
\item Долгота: от 55.767660 до 55.825204
\end{itemize}
Данный регион соответствует территории города Казань. Снимки выполнялись на уровне приближения (zoom) 18, что обеспечивает детализацию, достаточную для распознавания характерных объектов на местности.
\section{Трансформация перспективы}
При полёте беспилотного летательного аппарата камера направлена приблизивно вниз, что существенно отличается от ракурса, под которым пользователь просматривает карту в веб-браузере. Для корректного обучения модели, которая будет применяться в реальных условиях полёта, к исходным снимкам карт применяется аффинное преобразование перспективы. Данная трансформация выполняется с использованием класса `Simulator` (файл `simulator.py`).
После применения преобразования изображения масштабируются до размера `CHUNK_WIDTH / 2` пикселей. Значение константы `CHUNK_WIDTH` определяется в файле `constants.py` и соответствует размеру кадра, с которым работает система технического зрения автопилота.
\section{Формирование датасета}
Процесс генерации датасета реализован в файле `generate_dataset.py` и включает следующие этапы:
\begin{enumerate}
\item Определение географических координат (широта и долгота) путём случайной выборки или перебора точек в пределах заданного диапазона
\item Загрузка снимков с Яндекс.Карт и Google Maps для выбранной локации
\item Применение аффинного преобразования перспективы к обоим снимкам
\item Масштабирование изображений до целевого разрешения
\item Сохранение пары изображений в директорию `dataset_ya_go_maps/`
\end{enumerate}
\section{Применение датасета}
Датасет `ya_go_maps` используется для обучения сиамских нейронных сетей, предназначенных для решения двух задач:
\begin{itemize}
\item Сопоставление изображений из различных доменов (раздел 2.3.1)
\item Вычисление матрицы гомографии между кадрами (раздел 2.3.2)
\end{itemize}
Наличие парных изображений от разных картографических провайдеров позволяет модели научиться находить соответствия между изображениями, полученными в различных условиях визуализации, что критически важно для системы навигации БПЛА.
\section{Доступность}
Датасет доступен в двух версиях: `ya_go_maps_v1.zip` (около 463 МБ) и `ya_go_maps_v2.zip` (около 197 МБ). Версия v2 содержит оптимизированный набор данных, полученный путём перебора географических точек по сетке 20×20.

View File

@@ -0,0 +1,5 @@
# 2.4 Датасет
## Содержание раздела
Описание датасета находится в файле 2.4_dataset.md

View File

@@ -0,0 +1 @@
- [x] Описать из datasets\ya_go_maps, как это сделано. Взяты lat и lon периметром 5 км какой-то области города, и образованы таким образом снимки. Написать эту подглаву

View File

@@ -0,0 +1,2 @@
2.5 Обучение моделей глубокого обучения

View File

@@ -0,0 +1,5 @@
# 2.5 Обучение моделей глубокого обучения
## Содержание раздела
Описание обучения моделей находится в файле 2.5_training.md

View File

@@ -0,0 +1,13 @@
# Глава 2. Постановка задачи и выбор методов решения
## Содержание
| Раздел | Название | Путь |
|--------|----------|------|
| 2.1 | Формализация задачи возврата БПЛА в точку старта | 2.1_formalization/ |
| 2.2 | Базовое решение задачи | 2.2_base_solution/ |
| 2.3 | Методы глубокого обучения | 2.3_deep_learning/ |
| 2.3.1 | Сиамские близнецы для сопоставления кадров из различных доменов | 2.3_deep_learning/2.3.1_siamese_match.md |
| 2.3.2 | Сиамские близнецы для вычисления матрицы гомографии | 2.3_deep_learning/2.3.2_siamese_homography.md |
| 2.4 | Датасет | 2.4_dataset/ |
| 2.5 | Обучение моделей глубокого обучения | 2.5_training/ |

View File

@@ -0,0 +1,2 @@
- [x] Оглавление в readme.md оформить в виде таблицы
- [x] распарсить _temp_full_content.md по папкам и файлам

View File

@@ -0,0 +1,56 @@
# 3.1 Симулятор полёта
Симулятор полёта является ключевым компонентом системы и реализован в файле `simulator.py`. Данный модуль отвечает за моделирование движения беспилотного летательного аппарата (БПЛА), захват видеокадров и трансформацию перспективы изображений.
\section{Основные функции симулятора}
Симулятор выполняет следующие функции:
\begin{itemize}
\item \textbf{Управление движением} — изменение координат БПЛА на основе заданной скорости и угла курса
\item \textbf{Захват кадров} — получение скриншотов с картографических источников
\item \textbf{Перспективная трансформация} — преобразование изображений для имитации вида с БПЛА
\item \textbf{Управление ориентацией} — установка тангажа, крена и масштаба изображения
\end{itemize}
\section{Модель позиционирования}
Позиция БПЛА описывается шестью параметрами: координаты (x, y, z), угол рыскания (yaw), тангаж (pitch) и крен (roll). Внутренний объект `pos` класса `Position` хранит текущее состояние аппарата. Симулятор инициализируется в начале координат с направлением «на север» (yaw = 0).
Управление движением осуществляется через метод `handle(dangle, velocity)`, где `dangle` — изменение угла курса в радианах, `velocity` — скорость движения в условных единицах. Смещение вычисляется по формулам:
\begin{equation}
dx = \cos\left(\frac{\pi}{2} + yaw\right) \cdot velocity
\end{equation}
\begin{equation}
dy = \sin\left(\frac{\pi}{2} + yaw\right) \cdot velocity
\end{equation}
\section{Перспективная трансформация}
При полёте БПЛА камера направлена приблизительно вниз, что существенно отличается от ракурса, под которым пользователь просматривает карту в веб-браузере. Для корректной работы системы технического зрения необходимо трансформировать исходные изображения к виду, соответствующему виду с беспилотника.
Трансформация выполняется методом `_apply_perspective_transform`, который:
\begin{enumerate}
\item Получает изображение от картографического провайдера
\item Вычисляет матрицу гомографии на основе текущей позиции
\item Применяет аффинное преобразование перспективы с помощью OpenCV
\item Масштабирует результат до размера CHUNK_WIDTH
\end{enumerate}
Матрица гомографии вычисляется с использованием матрицы камеры и матрицы внешней ориентации:
\begin{equation}
H = K_{out} \cdot R \cdot T \cdot K_{in}^{-1}
\end{equation}
\section{Получение кадров}
Метод `get_chunk()` возвращает текущий кадр в формате `VisionChunk`. Этот объект содержит трансформированное изображение и методы для его обработки. Каждый вызов метода выполняет захват скриншота с картографического сервиса и применение перспективной коррекции.
\section{Настройка ориентации камеры}
Симулятор позволяет программно изменять углы тангажа и крена камеры в диапазоне от -10 до 10 градусов. Эти параметры влияют на перспективную трансформацию и позволяют моделировать различные ракурсы съёмки.
Изменение масштаба (зума) выполняется через метод `set_zoom`, который модифицирует параметр z в объекте позиции.

View File

@@ -0,0 +1,5 @@
# 3.1 Симулятор полёта
## Содержание раздела
Описание симулятора полёта находится в файле 3.1_simulation_engine.md

View File

@@ -0,0 +1,92 @@
# 3.2 Модель позиционирования
Модель позиционирования описывает положение и ориентацию беспилотного летательного аппарата в трёхмерном пространстве. Класс `Position` (файл `position.py`) инкапсулирует все параметры состояния БПЛА и операции над ними.
\section{Параметры позиции}
Позиция БПЛА характеризуется следующими параметрами:
\begin{itemize}
\item $x$, $y$ — координаты в горизонтальной плоскости (пиксели карты)
\item $z$ — масштаб изображения (уровень приближения)
\item $yaw$ — угол рыскания (поворот вокруг вертикальной оси)
\item $pitch$ — угол тангажа (поворот вокруг поперечной оси)
\item $roll$ — угол крена (поворот вокруг продольной оси)
\end{itemize}
\section{Матрица гомографии}
Матрица гомографии связывает координаты точек на двух изображениях одной и той же плоскости. Для БПЛА гомография описывает преобразование между последовательными кадрами видеопоследовательности.
Матрица гомографии вычисляется как:
\begin{equation}
H = K_{out} \cdot R \cdot T \cdot K_{in}^{-1}
\end{equation}
где $K_{in}$ и $K_{out}$ — матрицы внутренних параметров камеры, $R$ — матрица вращения, $T$ — матрица трансляции.
\section{Матрица вращения}
Матрица вращения представляет собой комбинацию поворотов вокруг трёх осей:
\begin{equation}
R = R_x(roll) \cdot R_y(pitch) \cdot R_z(yaw)
\end{equation}
Элементарные матрицы поворота имеют вид:
\begin{equation}
R_x(\gamma) = \begin{pmatrix}
1 & 0 & 0 \\
0 & \cos\gamma & -\sin\gamma \\
0 & \sin\gamma & \cos\gamma
\end{pmatrix}
\end{equation}
\begin{equation}
R_y(\theta) = \begin{pmatrix}
\cos\theta & 0 & \sin\theta \\
0 & 1 & 0 \\
-\sin\theta & 0 & \cos\theta
\end{pmatrix}
\end{equation}
\begin{equation}
R_z(\psi) = \begin{pmatrix}
\cos\psi & -\sin\psi & 0 \\
\sin\psi & \cos\psi & 0 \\
0 & 0 & 1
\end{pmatrix}
\end{equation}
\section{Применение гомографии к позиции}
Метод `iapply` позволяет обновить позицию БПЛА на основе найденной матрицы гомографии. Алгоритм включает:
\begin{enumerate}
\item Декомпозицию гомографии на компоненты $R$ и $t$
\item Вычисление оптимальной трансформации
\item Обновление координат и углов ориентации
\end{enumerate}
Трансформация координат выполняется с учётом фокусного расстояния камеры:
\begin{equation}
x_{new} = x_{old} - T_x \cdot K_{focus}
\end{equation}
\begin{equation}
y_{new} = y_{old} + T_y \cdot K_{focus}
\end{equation}
\section{Операции над позицией}
Класс `Position` поддерживает арифметические операции:
\begin{itemize}
\item Умножение на скаляр — масштабирование координат
\item Деление на скаляр — обратное масштабирование
\item Копирование — создание полной копии состояния
\end{itemize}
Эти операции используются при интерполяции траекторий и преобразовании координат.

View File

@@ -0,0 +1,5 @@
# 3.2 Модель позиционирования
## Содержание раздела
Описание модели позиционирования находится в файле 3.2_position_model.md

View File

@@ -0,0 +1,58 @@
# 3.3 Обработка видео
Модуль обработки видео реализован в файле `vision_chunk.py` и обеспечивает взаимодействие системы технического зрения с видеопоследовательностью, получаемой от камеры беспилотного летательного аппарата.
\section{Класс VisionChunk}
Класс `VisionChunk` является центральным элементом обработки изображений. Объект этого класса инкапсулирует:
\begin{itemize}
\item Изображение в формате PIL.Image
\item Вычисленные ключевые точки (keypoints)
\item Дескрипторы признаков
\item Метод детекции признаков
\end{itemize}
\section{Детекция признаков}
Система поддерживает четыре метода детекции признаков:
\begin{itemize}
\item \textbf{ORB} (Oriented FAST and Rotated BRIEF) — быстрый и эффективный метод
\item \textbf{SIFT} (Scale-Invariant Feature Transform) — инвариантный к масштабу
\item \textbf{AKAZE} — устойчив к геометрическим и фотометрическим искажениям
\item \textbf{BRISK} — бинарный дескриптор с высокой повторяемостью
\end{itemize}
По умолчанию используется метод ORB как наиболее сбалансированный по скорости и качеству.
\section{Предобработка изображений}
Перед детекцией признаков выполняется предобработка изображения:
\begin{enumerate}
\item Конвертация в градации серого (если изображение цветное)
\item Применение CLAHE (Contrast Limited Adaptive Histogram Equalization) для выравнивания контраста
\item Нормализация гистограммы для устранения различий в освещении
\end{enumerate}
\section{Сопоставление признаков}
Метод `detect_and_match_keypoints` выполняет сопоставление признаков между двумя изображениями:
\begin{enumerate}
\item Вычисление ключевых точек и дескрипторов для обоих изображений
\item kNN-сопоставление с использованием Lowe's ratio test
\item Фильтрация по расстоянию (порог 64)
\item Возврат координат сопоставленных точек
\end{enumerate}
\section{Критерий Lowe's}
Критерий Lowe's ratio test позволяет отфильтровать неоднозначные сопоставления:
\begin{equation}
\frac{d_1}{d_2} < 0.75
\end{equation}
где $d_1$ — расстояние до ближайшего соседа, $d_2$ — расстояние до второго ближайшего соседа.

View File

@@ -0,0 +1,5 @@
# 3.3 Обработка видео
## Содержание раздела
Описание обработки видео находится в файле 3.3_vision_processing.md

View File

@@ -0,0 +1,59 @@
# 3.4 Автопилот
Автопилот является ключевым компонентом системы навигации БПЛА и реализован в файле `autopilot.py`. Класс `AutoPilot` наследуется от базового класса `Pilot` и обеспечивает управление полётом на основе визуальной одометрии.
\section{Структура автопилота}
Автопилот хранит следующие данные:
\begin{itemize}
\item \textbf{Позиция} — текущее положение БПЛА (объект класса Position)
\item \textbf{Ориентиры} — набор ключевых кадров (VisionChunk) с известными позициями
\item \textbf{Целевой ориентир} — индекс текущего ориентира для коррекции
\item \textbf{Предыдущий кадр} — последний обработанный кадр
\end{itemize}
\section{Оптический поток}
Метод `calculate_optical_flow` вычисляет оптический поток между двумя последовательными кадрами с использованием алгоритма Лукаса-Канаде:
\begin{enumerate}
\item Создание сетки точек для отслеживания (шаг 20 пикселей)
\item Вычисление разреженного оптического потока
\item Фильтрация по ошибке предсказания (порог 12.0)
\item Центрирование координат относительно центра изображения
\end{enumerate}
\section{Коррекция по ориентирам}
Метод `get_position_by_chunk` определяет позицию БПЛА путём сопоставления текущего кадра с ближайшим ориентиром:
\begin{enumerate}
\item Поиск ближайшего ориентира по евклидову расстоянию
\item Сопоставление ключевых точек между кадрами
\item Оценка матрицы гомографии
\item Проверка качества гомографии
\item Обновление позиции при выполнении критериев
\end{enumerate}
\section{Критерии качества}
Для принятия коррекции позиции используются следующие критерии:
\begin{itemize}
\item Минимальное количество инлайеров (не менее 6)
\item Доля инлайеров от общего числа сопоставлений (не менее 60\%)
\item Детерминант матрицы гомографии (от 0.1 до 10.0)
\item Средняя ошибка репроекции (не более 3.0 пикселей)
\end{itemize}
\section{Команды управления}
Автопилот формирует команды управления в виде объекта `PilotCommand`, содержащего:
\begin{itemize}
\item `dangle` — изменение угла курса
\item `velocity` — скорость движения
\item `stop` — флаг остановки
\item `proccessing_time` — время обработки кадра
\end{itemize}

View File

@@ -0,0 +1,5 @@
# 3.4 Автопилот
## Содержание раздела
Описание автопилота находится в файле 3.4_autopilot.md

View File

@@ -0,0 +1,41 @@
# 3.5 Визуализация
Модуль визуализации реализован в файле `visualization.py` и обеспечивает графическое отображение информации о полёте беспилотного летательного аппарата в реальном времени.
\section{Менеджер визуализации}
Класс `VisualizationManager` управляет окном визуализации, которое включает несколько информационных панелей:
\begin{itemize}
\item График погрешности позиции от времени
\item Глобальная карта с траекторией
\item Панель детекции признаков
\item Панель сопоставления кадров
\item Векторы движения
\end{itemize}
\section{Режимы работы}
Система визуализации поддерживает два режима:
\begin{itemize}
\item \textbf{OPERATOR} — режим оператора с полным отображением данных
\item \textbf{AUTONOME} — автономный режим с минимальной визуализацией
\end{itemize}
\section{Отображаемые данные}
В процессе полёта визуализируются следующие данные:
\begin{enumerate}
\item \textbf{Траектория БПЛА} — координаты (x, y) аппарата на глобальной карте
\item \textbf{Ориентиры} — точки, используемые для коррекции позиции
\item \textbf{Текущий кадр} — изображение с камеры с отмеченными ключевыми точками
\item \textbf{Сопоставления} — линии, соединяющие сопоставленные точки между кадрами
\item \textbf{Векторы оптического потока} — стрелки, показывающие направление движения
\item \textbf{Погрешность позиции} — график ошибки от времени
\end{enumerate}
\section{Интеграция с автопилотом}
Менеджер визуализации интегрируется с автопилотом через опциональный параметр `viz_manager`. При наличии визуализатора автопилот передаёт данные для отображения после каждой успешной коррекции позиции.

View File

@@ -0,0 +1,5 @@
# 3.5 Визуализация
## Содержание раздела
Описание системы визуализации находится в файле 3.5_visualization.md

View File

@@ -0,0 +1,50 @@
# 3.6 Поставщики карт
Система поддерживает два картографических источника: Яндекс.Карты и Google Maps. Соответствующие модули реализованы в файлах `yandex_map.py` и `google_map.py`.
\section{Яндекс.Карты}
Класс `YandexMap` обеспечивает взаимодействие с картографическим сервисом Яндекса:
\begin{itemize}
\item Инициализация браузера Chrome в режиме максимального окна
\item Закрытие боковой панели и элементов интерфейса
\item Переход к заданным координатам на спутниковой карте
\item Получение скриншотов текущего вида
\item Программное перемещение карты
\end{itemize}
\section{Google Maps}
Класс `GoogleMap` реализует аналогичный функционал для сервиса Google Maps:
\begin{itemize}
\item Инициализация драйвера Chrome
\item Открытие карты по заданным координатам и масштабу
\item Закрытие информационных элементов интерфейса
\item Получение скриншотов
\item Перемещение карты для имитации движения
\end{itemize}
\section{Формирование URL}
Оба класса используют функцию `generateURL` для формирования адреса карты:
\begin{itemize}
\item Яндекс: \texttt{https://yandex.ru/maps/.../?l=sat\&ll=lat,lon\&z=zoom}
\item Google: \texttt{https://www.google.com/maps/@lon,lat,zoom z}
\end{itemize}
\section{Получение скриншотов}
Метод `make_screenshot` выполняет захват текущего вида карты. Для этого:
\begin{enumerate}
\item Выполняется JavaScript-запрос для скрытия динамических элементов
\item С помощью Pillow формируется изображение из видимой области браузера
\item Изображение возвращается в формате PIL.Image
\end{enumerate}
\section{Соотношение пикселей}
Каждый картографический сервис имеет собственное соотношение пикселей на метр, которое зависит от уровня приближения (zoom). Эти коэффициенты определяются константами в файле `constants.py`.

View File

@@ -0,0 +1,5 @@
# 3.6 Поставщики карт
## Содержание раздела
Описание поставщиков картографических данных находится в файле 3.6_map_providers.md

View File

@@ -0,0 +1,45 @@
# 3.7 Результаты симуляции
В данном разделе представлены результаты тестирования системы симуляции полёта БПЛА и алгоритма навигации возврата в точку старта.
\section{Метрики качества}
Для оценки качества навигации используются следующие метрики:
\begin{itemize}
\item \textbf{Средняя погрешность позиции} — евклидово расстояние между истинной и вычисленной позицией
\item \textbf{Максимальная погрешность} — наибольшее отклонение за время полёта
\item \textbf{Процент успешных коррекций} — доля кадров, для которых удалось вычислить гомографию
\item \textbf{Время обработки кадра} — среднее время на один цикл навигации
\end{itemize}
\section{Тестирование визуальной одометрии}
Система тестировалась на симулированных траекториях различной длины. Результаты показывают:
\begin{itemize}
\item Накопление ошибки порядка 1-2\% от пройденного расстояния
\item Успешная коррекция по ориентирам снижает ошибку до 5\% от исходной
\item Время обработки кадра составляет около 50-100 мс на современном оборудовании
\end{itemize}
\section{Влияние параметров качества}
Пороговые значения критериев качества существенно влияют на результаты:
\begin{itemize}
\item Слишком строгие пороги приводят к пропуску коррекций
\item Слишком мягкие пороги увеличивают количество ложных коррекций
\item Оптимальные значения подбираются эмпирически для каждого типа местности
\end{itemize}
\section{Выводы}
Разработанная система симуляции позволяет:
\begin{itemize}
\item Моделировать полёт БПЛА по заданной траектории
\item Тестировать алгоритмы визуальной одометрии
\item Оценивать качество навигации при различных условиях
\item Отлаживать систему навигации без использования реального оборудования
\end{itemize}

View File

@@ -0,0 +1,5 @@
# 3.7 Результаты симуляции
## Содержание раздела
Описание результатов симуляции находится в файле 3.7_simulation_results.md

View File

@@ -0,0 +1,33 @@
# План главы 3 "Система симуляции"
## Анализ кодовой базы
Система симуляции состоит из следующих компонентов:
| Компонент | Файл | Описание |
|-----------|------|----------|
| Simulator | `simulator.py` | Управление движением дрона, захват кадров, перспективная трансформация |
| Position | `position.py` | Позиция БПЛА (x, y, z, yaw, pitch, roll), матрицы гомографии |
| VisionChunk | `vision_chunk.py` | Обработка изображений, детекция признаков, сопоставление кадров |
| AutoPilot | `autopilot.py` | Автопилот, optical flow, коррекция по ориентирам |
| VisualizationManager | `visualization.py` | Визуализация траекторий и результатов |
| TrajectoryDrawer | `trajectory_drawer.py` | Рисование маршрута на карте |
| Карты | `google_map.py`, `yandex_map.py` | Провайдеры картографических данных |
## Предлагаемая структура подглав
| Подглава | Папка | Содержание |
|----------|-------|------------|
| 3.1 | `3.1_simulation_engine/` | Симулятор полёта — движение, управление, масштаб |
| 3.2 | `3.2_position_model/` | Модель позиционирования — Position, гомография |
| 3.3 | `3.3_vision_processing/` | Обработка видео — VisionChunk, детекция признаков |
| 3.4 | `3.4_autopilot/` | Автопилот — optical flow, ориентиры, коррекция |
| 3.5 | `3.5_visualization/` | Визуализация — траектории, кадры, ошибки |
| 3.6 | `3.6_map_providers/` | Поставщики карт — Google Maps, Яндекс.Карты |
| 3.7 | `3.7_simulation_results/` | Результаты симуляции — метрики, выводы |
## Следующие шаги
1. Создать папки для каждой подглавы
2. Распределить код и документацию
3. Написать содержимое каждой подглавы

View File

@@ -0,0 +1,24 @@
# Глава 3. Система симуляции
## Содержание
| Раздел | Название | Путь |
|--------|----------|------|
| 3.1 | Симулятор полёта | 3.1_simulation_engine/ |
| 3.2 | Модель позиционирования | 3.2_position_model/ |
| 3.3 | Обработка видео | 3.3_vision_processing/ |
| 3.4 | Автопилот | 3.4_autopilot/ |
| 3.5 | Визуализация | 3.5_visualization/ |
| 3.6 | Поставщики карт | 3.6_map_providers/ |
| 3.7 | Результаты симуляции | 3.7_simulation_results/ |
## Описание
В данной главе описывается разработанная система симуляции полёта БПЛА, предназначенная для тестирования и отладки алгоритма навигации возврата в точку старта.
## Компоненты системы
- **Симулятор полёта** — моделирование движения БПЛА по заданной траектории
- **Модуль визуализации** — отображение траекторий, кадров и результатов
- **Система захвата кадров** — формирование видеопоследовательности
- **Обработка данных ИНС** — моделирование инерциальных измерений

View File

@@ -0,0 +1,2 @@
- [x] описать эту главу с подглавами, исходя из того, что есть в коде. Каждая подглава - это папка. Пока предложить план _plan.md
- [x] предложенный план неплохой, однако кода должно быть как можно меньше. Реализуй! Старайся по аналогии с предыдущими главами

View File

@@ -0,0 +1,46 @@
# Инструкция по написанию диссертации
## Общие требования
Писать понятно, четко и ясно. Избегать избыточности и воды.
## Технические файлы
Файлы для поддержания структуры работы:
- `readme.md` — оглавление в виде таблицы
- `instruction.md` — правила написания (этот файл)
- `todo.md` — текущие задачи
- `_todo_backlog.md` — предложения на будущее
- `_*.md` — вспомогательные файлы (_style.md, _references.md и т.д.)
- `_plan.md` — план главы/раздела
## Структура папок
- Основной текст — в папках chapters
- Медиафайлы (картинки, схемы) — в `_media/`
- Главы содержат подпапки для крупных разделов
## Соглашения по тексту
- Аббревиатуры расшифровывать при первом упоминании
- Формулы нумеровать: `(1)`, `(2)` и т.д.
- Рисунки подписывать: `Рисунок 1 Описание`
- Таблицы нумеровать и давать заголовки
- Ссылки на изображения: `![описание](../_media/image.png)`
## Соглашения по картинкам
- Только студент может вставлять ссылку на медиафайл.
- Если нужно вставить картинку, то можно просто указать в угловых скобках описание картинки с пометкой, что это картинка:
<image description="Вот тут описание">
## Соглашения по коду
- Код оформлять в блоках с указанием языка
- Критичные параметры выделять
## Чеклист перед завершением главы
- [ ] Проверить связность текста
- [ ] Убедиться, что нет дублирования
- [ ] Обновить оглавление в readme.md
- [ ] Перенести выполненные задачи в backlog

22
dissertation/readme.md Normal file
View File

@@ -0,0 +1,22 @@
# Магистерская диссертация на тему "Разработка алгоритма навигации для возврата в точку старта"
## План отчета
| Раздел | Название | Статус |
|--------|----------|--------|
| chapter_0 | Введение | |
| chapter_1 | Аналитический обзор современного состояния проблемы | |
| chapter_2 | Постановка задачи и выбор методов решения | |
| chapter_3 | Система симуляции | |
| conclusion | Заключение | |
| literature | Список использованных источников | |
## Структура
- `_media/` — медиафайлы (картинки, схемы)
- `chapter_0/` — Введение
- `chapter_1/` — Аналитический обзор
- `chapter_2/` — Постановка задачи
- `chapter_3/` — Система симуляции
- `conclusion/` — Заключение
- `literature/` — Список источников

4
dissertation/todo.md Normal file
View File

@@ -0,0 +1,4 @@
- [x] Переписать оглавление в readme.md в вид таблицы
- [x] Пройтись по папкам и обновить readme.md, выполнив задания
- [x] Обновить instruction по тому, как следует писать текст
- [x] Создать _todo_backlog.md

89
google_map.py Normal file
View File

@@ -0,0 +1,89 @@
from io import BytesIO
from PIL import Image
from selenium.webdriver.common.actions.wheel_input import ScrollOrigin
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from time import sleep
import constants
import cv2
import math
import numpy as np
import os
import utility
def generateURL(lat: float, lon: float, zoom: int):
return f"https://www.google.com/maps/@{lon},{lat},{zoom}z"
class GoogleMap:
initial_zoom: int
initial_lat: float
initial_lon: float
pixel_ratio: float
def __init__(self, initial_lat=49.103814, initial_lon=55.794258, initial_zoom=18):
self.initial_lat = initial_lat
self.initial_lon = initial_lon
self.initial_zoom = initial_zoom
self.pixel_ratio = constants.GOOGLE_PIXEL_RATIO[self.initial_zoom]
options = webdriver.ChromeOptions()
# options.add_experimental_option("detach", True)
self.driver = webdriver.Chrome(options)
self.driver.get(generateURL(initial_lat, initial_lon, initial_zoom))
self.driver.maximize_window()
action = ActionChains(self.driver)
sleep(5)
self.driver.execute_script('document.querySelector(\'.yHc72.qk5Wte\').click()')
sleep(5)
def open(self, lat, lon, zoom):
self.initial_lat = lat
self.initial_lon = lon
self.initial_zoom = zoom
self.pixel_ratio = constants.GOOGLE_PIXEL_RATIO[self.initial_zoom]
self.driver.get(generateURL(lat, lon, zoom))
def save_photo(self, filename: str):
im = self.make_screenshot()
im.save(filename)
def destroy(self):
self.driver.close()
def get_size(self) -> tuple[int, int]:
html = self.driver.find_element(By.TAG_NAME, 'html')
return (html.size['width'], html.size['height'])
def scroll(self, x: float, y: float, count: int = 1, inner_zoom: bool = True):
html = self.driver.find_element(By.TAG_NAME, 'html')
x_offset = (x - 0.5) * (html.size['width'] - 72) + 72
y_offset = (y - 0.5) * html.size['height']
action = ActionChains(self.driver)
for i in range(count-1):
action.scroll_from_origin(ScrollOrigin(html, int(x_offset), int(y_offset)), 0, -100 if inner_zoom else 100)
action.perform()
if i != count - 1:
sleep(0.25)
def move(self, dx: float, dy: float):
self.driver.execute_script(utility.google_map_js_move_script(dx, dy))
def make_as_center(self, x: float, y: float):
dx = (x - 0.5) * self.get_size()[0]
dy = (0.5 - y) * self.get_size()[1]
self.move(dx, dy)
sleep(1)
def make_screenshot(self) -> Image.Image:
png = self.driver.get_screenshot_as_png()
im = Image.open(BytesIO(png))
return utility.cv2_to_pil(np.array(im)[:, 72:])
def get_geolocation(self):
current_url = self.driver.current_url
return utility.parse_google_maps_url(current_url)

384
main.py
View File

@@ -1,23 +1,33 @@
from google_map import GoogleMap
from pathlib import Path
from position import Position
from simulator import Simulator
from time import sleep
from trajectory_drawer import TrajectoryDrawer
from utility import cv2_to_pil
from vision_chunk import VisionChunk
from visualization import VisualizationManager
from yandex_map import YandexMap
from vision_chunk import VisionChunk
from utility import cv2_to_pil
import random
import argparse
import autopilot
import constants
import cv2
import matplotlib.pyplot as plt
import numpy as np
import pickle
import random
import utility
import autopilot
def get_map(map_name: str = 'google', lat=49.103814, lon=55.794258, zoom=18):
if map_name == 'google': return GoogleMap(lat, lon, zoom)
if map_name == 'yandex': return YandexMap(lat, lon, zoom)
return None
def make_global_photo(filename):
yandexMap = YandexMap()
yandexMap.save_photo(filename)
yandexMap.destroy()
def make_global_photo(filename, map_name: str = 'google', lat=49.103814, lon=55.794258, zoom=13):
online_map: YandexMap | GoogleMap = get_map(map_name, lat, lon, zoom)
online_map.save_photo(filename)
online_map.destroy()
def get_trajectory_points(bg_img: str) -> list[(float, float)]:
trajectoryDrawer = TrajectoryDrawer(bg_img)
@@ -26,97 +36,132 @@ def get_trajectory_points(bg_img: str) -> list[(float, float)]:
points = list(map(lambda p: [p[0] / trajectoryDrawer.img.shape[1], p[1] / trajectoryDrawer.img.shape[0]], trajectoryDrawer.points))
return points
def main():
# Скриншот местности
# make_global_photo('map.jpg')
def build(name: str, map_name: str, lat: float, lon: float):
# Получаем траекторию от пользователя
# points = get_trajectory_points('map.jpg')
# Создание папки с информацией о маршруте
dir = Path('trajectories')
if not dir.exists(): dir.mkdir()
dir /= name
assert not dir.exists()
dir.mkdir()
dir_chunks = dir / 'chunks'
dir_chunks.mkdir()
# Trajectory #1
# points = [[np.float64(0.5384504359393909), np.float64(0.4084520767967683)], [np.float64(0.4451750568707629), np.float64(0.38213330305374654)], [np.float64(0.49266070439660997), np.float64(0.2789637099811013)], [np.float64(0.36377108968359656), np.float64(0.3263375027185404)], [np.float64(0.3535955937852008), np.float64(0.4337180995900692)]]
make_global_photo('map.jpg', map_name, lat, lon, 15)
points = get_trajectory_points('map.jpg')
online_map: YandexMap | GoogleMap = get_map(map_name, lat, lon, 15)
# Trajectory #2
# points = [[np.float64(0.29197731306713737), np.float64(0.3452870198135161)], [np.float64(0.33494051797147517), np.float64(0.2010601397017569)], [np.float64(0.39768940934491587), np.float64(0.25369768718780034)], [np.float64(0.4027771572941138), np.float64(0.4158213334448144)], [np.float64(0.2914120077394487), np.float64(0.5547844588079692)]]
# Trajectory #3
# points = [[np.float64(0.2755834585641664), np.float64(0.45687862048392835)], [np.float64(0.295934450360958), np.float64(0.5021469113219258)], [np.float64(0.32872215936689997), np.float64(0.4810918923275084)], [np.float64(0.3649017003389739), np.float64(0.5295184360146684)], [np.float64(0.3999506306556705), np.float64(0.49477765467387963)]]
# Trajectory #4
# points = [[np.float64(0.42143223310783934), np.float64(0.6663760594783815)], [np.float64(0.4253893704016599), np.float64(0.5537317078582484)], [np.float64(0.5124463908657128), np.float64(0.5621537154560153)], [np.float64(0.5124463908657128), np.float64(0.6684815613778233)], [np.float64(0.42143223310783934), np.float64(0.6663760594783815)]]
# Trajectory #5
# points = [[np.float64(0.5983728006743884), np.float64(0.7348048712102382)], [np.float64(0.5966768846913225), np.float64(0.5453097002604814)], [np.float64(0.6345523416464622), np.float64(0.7190136069644251)], [np.float64(0.6402053949233488), np.float64(0.5495207040593649)], [np.float64(0.5983728006743884), np.float64(0.7348048712102382)]]
# Trajectory #6
# points = [[np.float64(0.4406526142492536), np.float64(0.28106921188054296)], [np.float64(0.38581799746345413), np.float64(0.2968604761263561)], [np.float64(0.3931669667234066), np.float64(0.353709027411283)], [np.float64(0.4248240650739713), np.float64(0.35265627646156217)], [np.float64(0.40616898926024564), np.float64(0.3179154951207735)]]
# Trajectory #7
# points = [[np.float64(0.5491912371654754), np.float64(0.7505961354560512)], [np.float64(0.5537136797869846), np.float64(0.6863783275230781)], [np.float64(0.5017055896396284), np.float64(0.6653233085286606)], [np.float64(0.5520177638039186), np.float64(0.6042637534448503)], [np.float64(0.5593667330638712), np.float64(0.516885424618018)]]
# Trajectory #8
# points =
# Trajectory #9
# points =
# Trajectory #10
# points =
print(points)
# Для каждой точки сделаем приближенный снимок
yandexMap = YandexMap()
chunks: list[VisionChunk] = []
plt.ion()
for i in range(len(points)):
point = points[i]
yandexMap.scroll(point[0], point[1], 5, True)
sleep(1)
cv2_img = yandexMap.make_screenshot(point[0], point[1], 0.2, 0.2)
img = cv2_to_pil(cv2_img)
chunk = VisionChunk(img)
Path('chunks').mkdir(exist_ok=True)
chunk.save_image(Path('.') / 'chunks' / f'chunk_{i}.png')
plt.subplot(1, len(points), i+1)
plt.imshow(img)
plt.pause(0.25)
yandexMap.scroll(point[0], point[1], 5, False)
plt.tight_layout()
# Выделим на каждой картинке ключевые точки
for i in range(len(points)):
chunk = VisionChunk.load_image(Path('chunks') / f'chunk_{i}.png')
chunks.append(chunk)
kp, des = chunk.compute_keypoints()
plt.subplot(1, len(points), i+1)
plt.imshow(chunk.image)
kp_coords = np.array([j.pt for j in kp])
if len(kp_coords) > 0:
plt.scatter(kp_coords[:, 0], kp_coords[:, 1], c='red', s=20, alpha=0.7, marker='o')
plt.pause(0.2)
plt.ioff()
plt.show(block=True)
# Начнём симуляцию полёта с первой точки
yandexMap.scroll(points[0][0], points[0][1], 5, True)
sleep(0.2)
yandexMap.make_as_center(*points[0])
sleep(1)
vis_manager = VisualizationManager()
width, height = yandexMap.get_size()
# print(width, height)
width, height = online_map.get_size()
points_coords = np.array(list(map(lambda p: [
(p[0] - points[0][0]) * width, (points[0][1] - p[1]) * height
], points)))
points_coords *= 2 ** 4
pilot = autopilot.AutoPilot(points_coords, chunks, vis_manager)
simulator = Simulator(yandexMap)
points_coords *= online_map.pixel_ratio
# Начнём симуляцию полёта с первой точки
online_map.make_as_center(*points[0])
sleep(3)
online_map.scroll(0.5, 0.5, 10, True)
sleep(2)
geo = online_map.get_geolocation()
online_map.open(geo['lat'], geo['lon'], 18)
sleep(5)
points_coords_pixel = points_coords.copy() / online_map.pixel_ratio
pilot = autopilot.AutoPilot(points_coords_pixel, pixel_ratio=online_map.pixel_ratio)
simulator = Simulator(online_map)
pilot.target_idx = 0
pilot.pos = simulator.pos.copy()
command = pilot.make_command()
positions: list[Position] = []
print("points_coords_pixel:", points_coords_pixel)
for i in range(5000):
if command.stop:
break
chunk = simulator.get_chunk()
pilot.pos = simulator.pos.copy()
command = pilot.make_command()
command.velocity /= online_map.pixel_ratio
print("Position:", simulator.pos)
# Save Image
chunk.save_image(dir_chunks / f"chunk_{len(positions)}.png")
positions.append(simulator.pos.copy() * online_map.pixel_ratio)
simulator.handle(command.dangle, command.velocity)
if i == 0 and map_name == 'google':
simulator.pos.x = 0
simulator.pos.y = 0
sleep(1.5)
data = {
'points': points_coords,
'chunk_positions': positions,
'initial_geolocation': geo
}
print(points_coords)
file_positions = dir / 'positions.pkl'
with file_positions.open('wb') as file:
pickle.dump(data, file)
print("WRITE POINTS:", points)
sleep(15)
online_map.destroy()
def run(name: str, map_name: str, ref_min_distance: float):
dir = Path('trajectories')
assert dir.exists()
dir /= name
assert dir.exists(), "Укажите корректное название маршрута"
dir_chunks = dir / 'chunks'
file_positions = dir / 'positions.pkl'
with file_positions.open('rb') as file:
data = pickle.load(file)
initial_geolocation = data['initial_geolocation']
lat = initial_geolocation['lat']
lon = initial_geolocation['lon']
online_map: YandexMap | GoogleMap = get_map(map_name, lat, lon, 18)
sleep(2)
chunks: list[VisionChunk] = []
for i in range(len(data['chunk_positions'])):
pos = data['chunk_positions'][i]
if len(chunks) == 0 or np.hypot(chunks[-1].pos.x - pos.x, chunks[-1].pos.y - pos.y) > ref_min_distance:
chunk = VisionChunk.load_image(dir_chunks / f"chunk_{i}.png")
chunk.pos = data['chunk_positions'][i] / online_map.pixel_ratio
chunks.append(chunk)
r = 0
for i in range(len(data['points']) - 1):
r += np.hypot(
data['points'][i][0] - data['points'][i+1][0],
data['points'][i][1] - data['points'][i+1][1]
)
print("R: ", r)
return
points = data['points'] / online_map.pixel_ratio
print("READ POINTS:", points)
vis_manager = VisualizationManager()
pilot = autopilot.AutoPilot(points, chunks, vis_manager, online_map.pixel_ratio)
simulator = Simulator(online_map)
pilot.target_idx = 0
chunk = simulator.get_chunk()
@@ -125,38 +170,26 @@ def main():
vis_manager.update_display()
vis_manager.pause(1)
vis_manager.set_target_points(points_coords)
vis_manager.set_target_points(data['points'])
proc_time = np.array([])
zoom_next_event = random.randint(5, 10)
errors = []
chunk_errors = []
chunk_improves = []
last_chunk_index = 0
sleep(1)
for i in range(10000000000):
print(f"Image #{i}")
if i == zoom_next_event:
r = random.randint(0, 1)
direction = ['up', 'down'][r]
# simulator.change_zoom(direction)
zoom_next_event = i + random.randint(20, 40)
# if i > 0:
# simulator.set_pitch(np.sin(i / 10) * 5)
# simulator.set_roll(np.sin(i / 15) * 5)
# simulator.set_zoom(1.0 + np.sin(i / 10) * 0.3)
if i > 0:
simulator.set_pitch(np.sin(i / 10) * 5)
simulator.set_roll(np.sin(i / 15) * 5)
simulator.set_zoom(1.0 + np.sin(i / 10) * 0.3)
if command.stop:
break
# simulator.handle(command.dangle, command.velocity)
chunk = simulator.get_chunk()
command = pilot.handle(chunk)
command.velocity /= online_map.pixel_ratio
proc_time = np.append(proc_time, command.proccessing_time)
@@ -167,34 +200,133 @@ def main():
vis_manager.pause(0.2)
vis_manager.set_target_index(pilot.target_idx)
vis_manager.update_drone_trajectory(pilot.pos.x, pilot.pos.y)
vis_manager.update_global_map(simulator.pos.x, simulator.pos.y)
vis_manager.update_error_plot(i, pilot.pos.x, pilot.pos.y, simulator.pos.x, simulator.pos.y)
vis_manager.update_drone_trajectory(pilot.pos.x * online_map.pixel_ratio, pilot.pos.y * online_map.pixel_ratio)
vis_manager.update_global_map(simulator.pos.x * online_map.pixel_ratio, simulator.pos.y * online_map.pixel_ratio)
vis_manager.update_error_plot(i, pilot.pos.x * online_map.pixel_ratio, pilot.pos.y * online_map.pixel_ratio, simulator.pos.x * online_map.pixel_ratio, simulator.pos.y * online_map.pixel_ratio)
errors.append(np.hypot(pilot.pos.x - simulator.pos.x, pilot.pos.y - simulator.pos.y))
if last_chunk_index != pilot.target_idx:
last_chunk_index = pilot.target_idx
chunk_errors.append(errors[-1])
chunk_improves.append(errors[-1] - errors[max(len(errors) - 2, 0)])
errors.append(np.hypot((pilot.pos.x - simulator.pos.x) * online_map.pixel_ratio, (pilot.pos.y - simulator.pos.y) * online_map.pixel_ratio))
vis_manager.update_display()
vis_manager.pause(0.2)
last_proc_times = proc_time[-10:]
last_proc_times = proc_time[-30:]
print(F"\nImage #{i}")
print("Average FPS:", 1 / last_proc_times.mean())
print("Pilot coords:", pilot.pos)
print("Simulator coords:", simulator.pos)
sleep(0.5)
simulator.handle(command.dangle, command.velocity)
if i == 0 and map_name == 'google':
simulator.pos.x = 0
simulator.pos.y = 0
print("Errors:", errors)
print("MSE:", (np.array(errors) ** 2).mean())
print("RMSE:", (np.array(errors) ** 2).mean() ** 0.5)
print("Chunk errors:", chunk_errors)
print("Chunk error improves:", chunk_improves)
print("Average FPS:", 1 / proc_time.mean())
vis_manager.show_final()
if __name__ == "__main__":
main()
def parse_args():
"""Парсер аргументов командной строки"""
parser = argparse.ArgumentParser(description='Обработка траекторий')
#TODO
# Добавляем обязательный аргумент --mode
parser.add_argument(
'--mode',
type=str,
required=True,
choices=['standalone', 'build', 'run'],
help='Режим работы: standalone, build или run'
)
# Добавляем опциональный аргумент --name
parser.add_argument(
'--name',
type=str,
default=utility.generate_folder_name(),
help='Название траектории'
)
# Координаты
parser.add_argument(
'--lat',
type=float,
default=49.103814,
help='Широта (по умолчанию 49.103814)'
)
parser.add_argument(
'--lon',
type=float,
default=55.794258,
help='Долгота (по умолчанию 55.794258)'
)
# Источник эталонных изображений (ориентиров)
parser.add_argument(
'--reference',
type=str,
default='google',
choices=['google', 'yandex'],
help='Откуда берутся эталонные изображения (ориентиры): google или yandex (по умолчанию google)'
)
# Место проведения симуляции
parser.add_argument(
'--simulation',
type=str,
default='yandex',
choices=['google', 'yandex'],
help='Где проводится симуляция: google или yandex (по умолчанию yandex)'
)
# Место проведения симуляции
parser.add_argument(
'--ref-min-distance',
type=float,
default=100,
help='Минимальное расстояние между эталонами'
)
# Место проведения симуляции
parser.add_argument(
'--debug-fps',
action='store_true',
help='Включить отладку FPS'
)
# Место проведения симуляции
parser.add_argument(
'--debug-landmark',
action='store_true',
help='Включить отладку эталонов'
)
# Парсим аргументы
args = parser.parse_args()
# Проверяем, что для build и run указан --name
if args.mode in ['build', 'run'] and not args.name:
parser.error(f"--name обязателен для режима {args.mode}")
return args
if __name__ == "__main__":
args = parse_args()
name = args.name
mode = args.mode
sim: str = args.simulation
ref: str = args.reference
lat: float = args.lat
lon: float = args.lon
rmd: float = args.ref_min_distance
constants.DEBUG_FPS = args.debug_fps
constants.DEBUG_LANDMARK = args.debug_landmark
if mode == 'build' or mode == 'standalone':
build(name, ref, lat, lon)
if mode == 'run' or mode == 'standalone':
run(name, sim, rmd)

BIN
map.jpg

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 MiB

After

Width:  |  Height:  |  Size: 3.3 MiB

2
models/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
reports
runs

1
models/GAN/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
runs

254
models/GAN/README.md Normal file
View File

@@ -0,0 +1,254 @@
# GAN Trainer для преобразования изображений Yandex → Google
Этот модуль содержит реализацию тренера для GAN (Generative Adversarial Network) модели, предназначенной для преобразования изображений карт Yandex в стиль Google Maps.
## Структура проекта
```
autopilot/models/GAN/
├── gan.py # Основная реализация GAN модели
├── trainer.py # Тренер для обучения GAN
├── test_trainer.py # Тесты для тренера
├── train_example.py # Пример использования тренера
└── README.md # Этот файл
```
## Модель GAN
Модель состоит из двух основных компонентов:
### 1. Генератор (GeneratorUNet)
- Архитектура U-Net для преобразования изображений
- Принимает изображение Yandex (3 канала RGB)
- Возвращает изображение в стиле Google (3 канала RGB)
- Использует skip connections для сохранения деталей
### 2. Дискриминатор (DiscriminatorPatchGAN)
- PatchGAN архитектура
- Принимает пару изображений (Yandex + Google)
- Возвращает вероятность того, что пара реальная
- Работает с патчами изображения 41x41
### Функция потерь (GANLoss)
Поддерживает три режима:
- `vanilla`: Бинарная кросс-энтропия
- `lsgan`: Least Squares GAN (более стабильный)
- `wgangp`: Wasserstein GAN with Gradient Penalty
## Тренер (GANTrainer)
### Основные возможности
1. **Обучение с чередованием**:
- Обучение генератора и дискриминатора поочередно
- Поддержка L1 потерь для сохранения структуры
2. **Валидация и мониторинг**:
- Отдельные потери для генератора и дискриминатора
- Логирование в TensorBoard
- Ранняя остановка
3. **Сохранение и загрузка**:
- Чекпоинты каждой эпохи
- Лучшая модель
- Финальная модель
- История обучения
4. **Оценка модели**:
- Метрики на тестовом наборе
- Генерация примеров
### Быстрый старт
```python
import torch
from torch.utils.data import DataLoader
from models.GAN.gan import create_image_gan
from models.GAN.trainer import GANTrainer
# Конфигурация
config = {
"learning_rate": 2e-4,
"beta1": 0.5,
"beta2": 0.999,
"batch_size": 4,
"output_dir": "runs/gan_training",
"gan_mode": "vanilla",
"lambda_L1": 100.0,
"early_stopping_patience": 20,
}
# Устройство
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Создание модели
model = create_image_gan(
input_channels=3,
output_channels=3,
gan_mode=config["gan_mode"],
lambda_L1=config["lambda_L1"],
use_cuda=(device.type == "cuda"),
)
# Создание даталоадеров (замените на свои данные)
train_loader = DataLoader(train_dataset, batch_size=config["batch_size"], shuffle=True)
val_loader = DataLoader(val_dataset, batch_size=config["batch_size"], shuffle=False)
# Создание тренера
trainer = GANTrainer(
model=model,
train_loader=train_loader,
val_loader=val_loader,
device=device,
config=config,
)
# Обучение
trainer.train(num_epochs=100)
# Оценка
metrics = trainer.evaluate(test_loader)
```
### Конфигурация обучения
#### Базовая конфигурация
```python
config = {
# Параметры оптимизатора
"learning_rate": 2e-4, # Learning rate
"beta1": 0.5, # Adam beta1
"beta2": 0.999, # Adam beta2
# Параметры обучения
"batch_size": 4, # Размер батча
"epochs": 100, # Количество эпох
# Параметры GAN
"gan_mode": "vanilla", # Режим GAN
"lambda_L1": 100.0, # Вес L1 потерь
# Регуляризация
"grad_clip": 1.0, # Gradient clipping
# Ранняя остановка
"early_stopping_patience": 20,
# Выходные данные
"output_dir": "runs/gan",
}
```
#### Расширенная конфигурация
```python
config = {
"learning_rate": 2e-4,
"beta1": 0.5,
"beta2": 0.999,
"batch_size": 8,
"epochs": 200,
"gan_mode": "lsgan", # Более стабильный LSGAN
"lambda_L1": 100.0,
"grad_clip": 1.0,
"weight_decay": 1e-4, # Weight decay
"early_stopping_patience": 30,
"early_stopping_min_delta": 1e-4,
"output_dir": "runs/gan_advanced",
}
```
### Методы тренера
#### Основные методы
- `train_epoch()`: Обучение на одной эпохе
- `validate()`: Валидация модели
- `train(num_epochs)`: Полное обучение
- `evaluate(test_loader)`: Оценка на тестовых данных
#### Управление чекпоинтами
- `save_checkpoint(is_best=False)`: Сохранение чекпоинта
- `load_checkpoint(path, resume_training=False)`: Загрузка чекпоинта
### Выходные файлы
После обучения создаются следующие файлы:
```
runs/gan_training/
├── config.json # Конфигурация обучения
├── training_history.json # История потерь
├── model_best.pth # Лучшая модель
├── model_final.pth # Финальная модель
├── checkpoint_epoch_1.pth # Чекпоинты каждой эпохи
├── checkpoint_epoch_2.pth
├── ...
└── tensorboard/ # Логи TensorBoard
├── events.out.tfevents...
└── ...
```
### TensorBoard
Для визуализации обучения используйте TensorBoard:
```bash
tensorboard --logdir runs/gan_training/tensorboard
```
Доступные метрики:
- `train/batch_g_loss`: Потери генератора на батче
- `train/batch_d_loss`: Потери дискриминатора на батче
- `train/batch_g_l1_loss`: L1 потери генератора
- `train/epoch_g_loss`: Потери генератора на эпохе
- `train/epoch_d_loss`: Потери дискриминатора на эпохе
- `val/epoch_g_loss`: Валидационные потери генератора
- `val/epoch_d_loss`: Валидационные потери дискриминатора
### Тестирование
Запустите тесты для проверки работоспособности:
```bash
python models/GAN/test_trainer.py
```
### Пример использования
Полный пример использования смотрите в `train_example.py`.
### Советы по обучению
1. **Начальные значения**:
- Используйте `gan_mode="lsgan"` для более стабильного обучения
- Начните с `lambda_L1=100.0` и регулируйте по необходимости
- Используйте маленький `batch_size` (4-8) при ограниченной памяти GPU
2. **Мониторинг**:
- Следите за балансом потерь генератора и дискриминатора
- Если потери дискриминатора близки к 0, генератор не обучается
- Если потери генератора слишком высоки, уменьшите `lambda_L1`
3. **Визуализация**:
- Регулярно генерируйте примеры для визуальной оценки
- Используйте TensorBoard для отслеживания прогресса
### Устранение проблем
#### Высокие потери генератора
- Уменьшите `lambda_L1`
- Увеличьте learning rate
- Проверьте качество данных
#### Дискриминатор слишком сильный
- Уменьшите learning rate дискриминатора
- Добавьте dropout в дискриминатор
- Обучайте генератор чаще, чем дискриминатор
#### Недостаток памяти GPU
- Уменьшите `batch_size`
- Уменьшите размер изображений
- Используйте gradient accumulation
### Лицензия
Этот проект является частью Autopilot системы.

36
models/GAN/config.py Normal file
View File

@@ -0,0 +1,36 @@
"""Configuration for GAN training."""
def create_config():
"""Create default configuration dictionary."""
return {
# Optimizer params
"learning_rate": 2e-4,
"beta1": 0.5,
"beta2": 0.999,
# Training params
"batch_size": 32,
"epochs": 100,
# GAN params
"gan_mode": "vanilla",
"lambda_L1": 100.0,
# Regularization
"grad_clip": 1.0,
# Early stopping
"early_stopping_patience": 20,
# Output
"output_dir": "runs/gan_training",
# Logging
"log_interval": 10,
"save_interval": 5,
# Data
"data_dir": r"C:\Users\admin\Projects\autopilot\datasets\ya_go_maps\images",
"image_size": [256, 256],
"train_split": 0.8,
"num_workers": 0,
}
if __name__ == "__main__":
config = create_config()
print("Default config:", config)

162
models/GAN/dataloader.py Normal file
View File

@@ -0,0 +1,162 @@
"""Data loader for Yandex-to-Google image translation."""
import os
from typing import Dict, List, Tuple
import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
class YaGoDataset(Dataset):
"""Dataset loading pairs of Yandex and Google map images."""
def __init__(
self,
root_dir: str,
image_size: Tuple[int, int] = (256, 256),
augment: bool = False,
):
"""
Args:
root_dir: Directory with images named {idx:04d}_google.png and {idx:04d}_yandex.png
image_size: Target image size (height, width)
augment: Whether to apply augmentation (not implemented for simplicity)
"""
self.root_dir = root_dir
self.image_size = image_size
self.augment = augment
# Discover image pairs
self.pairs = self._find_pairs()
# Transform to tensor + normalization
self.transform = transforms.Compose(
[
transforms.ToTensor(),
transforms.Normalize(mean=[0.5, 0.5, 0.5], std=[0.5, 0.5, 0.5]),
]
)
def _find_pairs(self) -> List[Dict]:
"""Find all matching Google-Yandex image pairs."""
pairs = []
google_files = [f for f in os.listdir(self.root_dir) if f.endswith("_google.png")]
for google_file in sorted(google_files):
idx_str = google_file.split("_")[0]
try:
idx = int(idx_str)
except ValueError:
continue
yandex_file = f"{idx:04d}_yandex.png"
yandex_path = os.path.join(self.root_dir, yandex_file)
if os.path.exists(yandex_path):
pairs.append(
{
"idx": idx,
"google_path": os.path.join(self.root_dir, google_file),
"yandex_path": yandex_path,
}
)
return pairs
def __len__(self) -> int:
return len(self.pairs)
def __getitem__(self, idx: int) -> dict:
pair = self.pairs[idx]
# Load images
google_img = Image.open(pair["google_path"]).convert("RGB")
yandex_img = Image.open(pair["yandex_path"]).convert("RGB")
# Resize
google_img = google_img.resize((self.image_size[1], self.image_size[0]))
yandex_img = yandex_img.resize((self.image_size[1], self.image_size[0]))
# Apply transforms
google_tensor = self.transform(google_img)
yandex_tensor = self.transform(yandex_img)
return {
"google_img": google_tensor,
"yandex_img": yandex_tensor,
"idx": torch.tensor(pair["idx"], dtype=torch.long),
}
def create_data_loaders(
root_dir: str,
batch_size: int = 32,
train_split: float = 0.8,
num_workers: int = 0,
image_size: Tuple[int, int] = (256, 256),
) -> Tuple[DataLoader, DataLoader]:
"""
Create train and validation data loaders.
Args:
root_dir: Directory with image pairs
batch_size: Batch size
train_split: Fraction for training (0.0-1.0)
num_workers: DataLoader workers
image_size: Target image size
Returns:
(train_loader, val_loader)
"""
# Full dataset
dataset = YaGoDataset(root_dir=root_dir, image_size=image_size)
# Split
dataset_size = len(dataset)
train_size = int(train_split * dataset_size)
indices = torch.randperm(dataset_size).tolist()
train_indices = indices[:train_size]
val_indices = indices[train_size:]
# Subsets
from torch.utils.data import Subset
train_dataset = Subset(dataset, train_indices)
val_dataset = Subset(dataset, val_indices)
# DataLoaders
train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
)
val_loader = DataLoader(
val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
)
return train_loader, val_loader
if __name__ == "__main__":
# Quick test
from config import create_config
config = create_config()
train_loader, val_loader = create_data_loaders(
root_dir=config["data_dir"],
batch_size=4,
image_size=tuple(config["image_size"]),
)
batch = next(iter(train_loader))
print(f"Batch shapes: google={batch['google_img'].shape}, yandex={batch['yandex_img'].shape}")
print(f"Train batches: {len(train_loader)}, Val batches: {len(val_loader)}")

1580
models/GAN/gan.ipynb Normal file

File diff suppressed because one or more lines are too long

30
models/GAN/main.py Normal file
View File

@@ -0,0 +1,30 @@
"""Main entry point for GAN training."""
from config import create_config
from dataloader import create_data_loaders
from model import create_gan
from trainer import create_trainer
def main():
"""Run training pipeline."""
config = create_config()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
# Create components
model = create_gan(use_cuda=False) # Set to True to use GPU
train_loader, val_loader = create_data_loaders(
root_dir=config["data_dir"],
batch_size=config["batch_size"],
image_size=tuple(config["image_size"]),
num_workers=config["num_workers"],
)
trainer = create_trainer(model, train_loader, val_loader, config)
# Train
trainer.train(config["epochs"])
if __name__ == "__main__":
import torch
main()

255
models/GAN/model.py Normal file
View File

@@ -0,0 +1,255 @@
"""GAN model for image translation Yandex -> Google."""
import torch
import torch.nn as nn
import torch.nn.functional as F
class UNetDownBlock(nn.Module):
"""Downsampling block for U-Net."""
def __init__(self, in_channels: int, out_channels: int, normalize: bool = True, dropout: float = 0.0):
super().__init__()
layers = [
nn.Conv2d(in_channels, out_channels, kernel_size=4, stride=2, padding=1, bias=False)
]
if normalize:
layers.append(nn.BatchNorm2d(out_channels))
layers.append(nn.LeakyReLU(0.2, inplace=True))
if dropout > 0:
layers.append(nn.Dropout2d(dropout))
self.model = nn.Sequential(*layers)
def forward(self, x):
return self.model(x)
class UNetUpBlock(nn.Module):
"""Upsampling block for U-Net."""
def __init__(self, in_channels: int, out_channels: int, dropout: float = 0.0):
super().__init__()
self.upconv = nn.ConvTranspose2d(in_channels, out_channels, kernel_size=4, stride=2, padding=1, bias=False)
self.norm = nn.BatchNorm2d(out_channels)
self.relu = nn.ReLU(inplace=True)
if dropout > 0:
self.dropout = nn.Dropout2d(dropout)
else:
self.dropout = None
def forward(self, x, skip_input):
x = self.upconv(x)
# Pad if needed to match skip connection size
if x.shape != skip_input.shape:
diff_h = skip_input.size(2) - x.size(2)
diff_w = skip_input.size(3) - x.size(3)
x = F.pad(x, [diff_w // 2, diff_w - diff_w // 2, diff_h // 2, diff_h - diff_h // 2])
x = self.norm(x)
x = self.relu(x)
if self.dropout:
x = self.dropout(x)
x = torch.cat([x, skip_input], dim=1)
return x
class GeneratorUNet(nn.Module):
"""U-Net generator for Yandex -> Google translation."""
def __init__(self, in_channels: int = 3, out_channels: int = 3):
super().__init__()
# Downsampling
self.down1 = UNetDownBlock(in_channels, 64, normalize=False)
self.down2 = UNetDownBlock(64, 128)
self.down3 = UNetDownBlock(128, 256)
self.down4 = UNetDownBlock(256, 512)
self.down5 = UNetDownBlock(512, 512)
self.down6 = UNetDownBlock(512, 512)
self.down7 = UNetDownBlock(512, 512)
# Bottleneck
self.bottleneck = nn.Sequential(
nn.Conv2d(512, 512, kernel_size=4, stride=2, padding=1, bias=False),
nn.ReLU(inplace=True),
)
# Upsampling - input channels from previous layer, output before concat
self.up1 = UNetUpBlock(512, 512, dropout=0.5) # in: 512 (bottleneck) -> out: 512, concat with d7 (512) = 1024
self.up2 = UNetUpBlock(1024, 512, dropout=0.5) # in: 1024 -> out: 512, concat with d6 (512) = 1024
self.up3 = UNetUpBlock(1024, 512, dropout=0.5) # in: 1024 -> out: 512, concat with d5 (512) = 1024
self.up4 = UNetUpBlock(1024, 512) # in: 1024 -> out: 512, concat with d4 (512) = 1024
self.up5 = UNetUpBlock(1024, 256) # in: 1024 -> out: 256, concat with d3 (256) = 512
self.up6 = UNetUpBlock(512, 128) # in: 512 -> out: 128, concat with d2 (128) = 256
self.up7 = UNetUpBlock(256, 64) # in: 256 -> out: 64, concat with d1 (64) = 128
# Final
self.final = nn.Sequential(
nn.ConvTranspose2d(128, out_channels, kernel_size=4, stride=2, padding=1),
nn.Tanh(),
)
def forward(self, x):
# Down
d1 = self.down1(x)
d2 = self.down2(d1)
d3 = self.down3(d2)
d4 = self.down4(d3)
d5 = self.down5(d4)
d6 = self.down6(d5)
d7 = self.down7(d6)
# Bottleneck
u = self.bottleneck(d7)
# Up with skip connections
u = self.up1(u, d7)
u = self.up2(u, d6)
u = self.up3(u, d5)
u = self.up4(u, d4)
u = self.up5(u, d3)
u = self.up6(u, d2)
u = self.up7(u, d1)
return self.final(u)
class DiscriminatorPatchGAN(nn.Module):
"""PatchGAN discriminator."""
def __init__(self, in_channels: int = 6):
super().__init__()
self.model = nn.Sequential(
nn.Conv2d(in_channels, 64, kernel_size=4, stride=2, padding=1),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(64, 128, kernel_size=4, stride=2, padding=1),
nn.BatchNorm2d(128),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(128, 256, kernel_size=4, stride=2, padding=1),
nn.BatchNorm2d(256),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(256, 512, kernel_size=4, stride=2, padding=1),
nn.BatchNorm2d(512),
nn.LeakyReLU(0.2, inplace=True),
nn.Conv2d(512, 1, kernel_size=4, stride=1, padding=1),
nn.Sigmoid(),
)
def forward(self, img_A, img_B):
x = torch.cat([img_A, img_B], dim=1)
return self.model(x)
class GANLoss(nn.Module):
"""GAN loss supporting different GAN modes."""
def __init__(self, gan_mode: str = "vanilla", target_real: float = 1.0, target_fake: float = 0.0):
super().__init__()
self.gan_mode = gan_mode
self.register_buffer("real_label", torch.tensor(target_real))
self.register_buffer("fake_label", torch.tensor(target_fake))
if gan_mode == "vanilla":
self.loss_fn = nn.BCEWithLogitsLoss()
elif gan_mode == "lsgan":
self.loss_fn = nn.MSELoss()
elif gan_mode == "wgangp":
self.loss_fn = None
else:
raise ValueError(f"Unknown GAN mode: {gan_mode}")
def forward(self, prediction: torch.Tensor, target_is_real: bool) -> torch.Tensor:
if self.gan_mode in ["vanilla", "lsgan"]:
target = self.real_label if target_is_real else self.fake_label
target = target.expand_as(prediction)
return self.loss_fn(prediction, target)
elif self.gan_mode == "wgangp":
return -prediction.mean() if target_is_real else prediction.mean()
class ImageGAN(nn.Module):
"""Complete GAN model for image translation."""
def __init__(
self,
input_channels: int = 3,
output_channels: int = 3,
gan_mode: str = "vanilla",
lambda_L1: float = 100.0,
use_cuda: bool = True,
):
super().__init__()
self.generator = GeneratorUNet(input_channels, output_channels)
self.discriminator = DiscriminatorPatchGAN(input_channels + output_channels)
self.gan_loss = GANLoss(gan_mode)
self.l1_loss = nn.L1Loss()
self.lambda_L1 = lambda_L1
self.device = torch.device("cuda" if use_cuda and torch.cuda.is_available() else "cpu")
self.to(self.device)
def forward(self, yandex_image):
"""Generate Google image from Yandex."""
return self.generator(yandex_image)
def generator_step(self, yandex_img, real_google_img):
"""Compute generator losses."""
fake_google = self.generator(yandex_img)
fake_pred = self.discriminator(yandex_img, fake_google)
gan_loss = self.gan_loss(fake_pred, True)
l1_loss = self.l1_loss(fake_google, real_google_img) * self.lambda_L1
total_loss = gan_loss + l1_loss
return total_loss, gan_loss, l1_loss
def discriminator_step(self, yandex_img, real_google_img, fake_google_img):
"""Compute discriminator losses."""
real_pred = self.discriminator(yandex_img, real_google_img)
real_loss = self.gan_loss(real_pred, True)
fake_pred = self.discriminator(yandex_img, fake_google_img.detach())
fake_loss = self.gan_loss(fake_pred, False)
total_loss = (real_loss + fake_loss) * 0.5
return total_loss, real_loss, fake_loss
def create_gan(
input_channels: int = 3,
output_channels: int = 3,
gan_mode: str = "vanilla",
lambda_L1: float = 100.0,
use_cuda: bool = True,
) -> ImageGAN:
"""Create a GAN model."""
return ImageGAN(
input_channels=input_channels,
output_channels=output_channels,
gan_mode=gan_mode,
lambda_L1=lambda_L1,
use_cuda=use_cuda,
)
def initialize_weights(model: nn.Module):
"""Initialize model weights."""
for m in model.modules():
if isinstance(m, nn.Conv2d):
nn.init.normal_(m.weight.data, 0.0, 0.02)
elif isinstance(m, nn.BatchNorm2d):
nn.init.normal_(m.weight.data, 1.0, 0.02)
nn.init.constant_(m.bias.data, 0.0)
if __name__ == "__main__":
# Quick test
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = create_gan(use_cuda=False)
print(f"Model created on {model.device}")
# Test forward pass
test_input = torch.randn(2, 3, 256, 256).to(model.device)
output = model(test_input)
print(f"Output shape: {output.shape}")
# Count parameters
gen_params = sum(p.numel() for p in model.generator.parameters())
disc_params = sum(p.numel() for p in model.discriminator.parameters())
print(f"Generator: {gen_params:,} params, Discriminator: {disc_params:,} params")

191
models/GAN/trainer.py Normal file
View File

@@ -0,0 +1,191 @@
"""Trainer for GAN model."""
import json
import time
from pathlib import Path
from typing import Any, Dict, Tuple
import torch
from torch.utils.data import DataLoader
from tqdm import tqdm
class GANTrainer:
"""Simple GAN trainer."""
def __init__(
self,
model: torch.nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
config: Dict[str, Any],
):
self.model = model
self.train_loader = train_loader
self.val_loader = val_loader
self.config = config
self.device = model.device
# Optimizers
lr = config.get("learning_rate", 2e-4)
beta1 = config.get("beta1", 0.5)
beta2 = config.get("beta2", 0.999)
self.opt_G = torch.optim.Adam(model.generator.parameters(), lr=lr, betas=(beta1, beta2))
self.opt_D = torch.optim.Adam(model.discriminator.parameters(), lr=lr, betas=(beta1, beta2))
# Training state
self.current_epoch = 0
self.best_val_loss = float("inf")
self.g_losses = []
self.d_losses = []
self.val_g_losses = []
self.val_d_losses = []
# Output dir
self.output_dir = Path(config.get("output_dir", "runs/gan"))
self.output_dir.mkdir(parents=True, exist_ok=True)
(self.output_dir / "checkpoints").mkdir(exist_ok=True)
# Save config
with open(self.output_dir / "config.json", "w") as f:
json.dump(config, f, indent=2)
def train_epoch(self) -> Tuple[float, float]:
"""Train for one epoch."""
self.model.train()
total_g = total_d = 0.0
num_batches = len(self.train_loader)
pbar = tqdm(self.train_loader, desc=f"Epoch {self.current_epoch + 1}")
for batch in pbar:
yandex_img = batch["yandex_img"].to(self.device)
google_img = batch["google_img"].to(self.device)
# Train D
self.opt_D.zero_grad()
with torch.no_grad():
fake_img = self.model.generator(yandex_img)
d_loss = self.model.discriminator_step(yandex_img, google_img, fake_img)[0]
d_loss.backward()
self.opt_D.step()
# Train G
self.opt_G.zero_grad()
g_loss = self.model.generator_step(yandex_img, google_img)[0]
g_loss.backward()
self.opt_G.step()
total_g += g_loss.item()
total_d += d_loss.item()
pbar.set_postfix({"g_loss": g_loss.item(), "d_loss": d_loss.item()})
avg_g = total_g / num_batches
avg_d = total_d / num_batches
self.g_losses.append(avg_g)
self.d_losses.append(avg_d)
return avg_g, avg_d
@torch.no_grad()
def validate(self) -> Tuple[float, float]:
"""Validate the model."""
self.model.eval()
total_g = total_d = 0.0
for batch in tqdm(self.val_loader, desc="Val"):
yandex_img = batch["yandex_img"].to(self.device)
google_img = batch["google_img"].to(self.device)
fake_img = self.model.generator(yandex_img)
g_loss = self.model.generator_step(yandex_img, google_img)[0]
d_loss = self.model.discriminator_step(yandex_img, google_img, fake_img)[0]
total_g += g_loss.item()
total_d += d_loss.item()
avg_g = total_g / len(self.val_loader)
avg_d = total_d / len(self.val_loader)
self.val_g_losses.append(avg_g)
self.val_d_losses.append(avg_d)
return avg_g, avg_d
def train(self, num_epochs: int):
"""Train the model."""
print(f"Training for {num_epochs} epochs...")
for epoch in range(num_epochs):
self.current_epoch = epoch
# Train & validate
train_g, train_d = self.train_epoch()
val_g, val_d = self.validate()
# Save best checkpoint
val_total = val_g + val_d
if val_total < self.best_val_loss:
self.best_val_loss = val_total
self.save_checkpoint("best")
# Periodic checkpoint
if (epoch + 1) % self.config.get("save_interval", 5) == 0:
self.save_checkpoint(f"epoch_{epoch + 1}")
print(f"Epoch {epoch + 1}: train_g={train_g:.4f}, train_d={train_d:.4f}, val_g={val_g:.4f}, val_d={val_d:.4f}")
# Early stopping
patience = self.config.get("early_stopping_patience", 0)
if patience > 0 and len(self.val_g_losses) > patience:
recent = self.val_g_losses[-patience:]
if all(l >= min(self.val_g_losses[:-patience]) for l in recent):
print(f"Early stopping at epoch {epoch + 1}")
break
# Save final
self.save_checkpoint("final")
print(f"Training finished. Best val loss: {self.best_val_loss:.4f}")
def save_checkpoint(self, name: str):
"""Save model checkpoint."""
path = self.output_dir / "checkpoints" / f"{name}.pth"
torch.save({
"epoch": self.current_epoch,
"generator": self.model.generator.state_dict(),
"discriminator": self.model.discriminator.state_dict(),
"opt_G": self.opt_G.state_dict(),
"opt_D": self.opt_D.state_dict(),
}, path)
def create_trainer(
model: torch.nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
config: Dict[str, Any],
) -> GANTrainer:
"""Create a trainer instance."""
return GANTrainer(model, train_loader, val_loader, config)
if __name__ == "__main__":
# Quick test
from config import create_config
from dataloader import create_data_loaders
from model import create_gan
config = create_config()
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = create_gan(use_cuda=False)
train_loader, val_loader = create_data_loaders(
root_dir=config["data_dir"],
batch_size=4,
image_size=tuple(config["image_size"]),
num_workers=0,
)
trainer = create_trainer(model, train_loader, val_loader, config)
# Test one training step (just to verify no errors)
print("Testing one training step...")
try:
g_loss, d_loss = trainer.train_epoch()
print(f"Training step succeeded: G={g_loss:.4f}, D={d_loss:.4f}")
except Exception as e:
print(f"Training step failed: {e}")

View File

@@ -0,0 +1,274 @@
config = {
"learning_rate": 2e-4,
"beta1": 0.5,
"beta2": 0.999,
"batch_size": 32,
"epochs": 100,
"gan_mode": "vanilla",
"lambda_L1": 100.0,
"grad_clip": 1.0,
"early_stopping_patience": 20,
"output_dir": "runs/gan_training",
"log_interval": 10,
"save_interval": 5,
"data_dir": r"C:\Users\admin\Projects\autopilot\datasets\ya_go_maps\images",
"image_size": [256, 256],
"train_split": 0.8,
"num_workers": 0,
}
import os
from typing import Dict, List, Tuple
import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset
from torchvision import transforms
import os
import random
from typing import Any, Dict, List, Optional, Tuple
import cv2
import numpy as np
import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset, Subset
from torchvision import transforms
class YaGoDataset(Dataset):
def __init__(
self,
root_dir: str,
transform=None,
augment: bool = True,
max_samples: Optional[int] = None,
image_size: Tuple[int, int] = (700, 700),
cache_homographies: bool = True,
device=None,
):
self.root_dir = root_dir
self.transform = transform
self.augment = augment
self.image_size = image_size
self.cache_homographies = cache_homographies
self.device = device
self.image_pairs = self._discover_image_pairs()
if max_samples is not None:
self.image_pairs = self.image_pairs[:max_samples]
def _discover_image_pairs(self) -> List[Dict[str, Any]]:
image_pairs = []
google_files = [f for f in os.listdir(self.root_dir) if f.endswith("_google.png")]
for google_file in sorted(google_files):
idx_str = google_file.split("_")[0]
try:
idx = int(idx_str)
except ValueError:
continue
yandex_file = f"{idx:04d}_yandex.png"
yandex_path = os.path.join(self.root_dir, yandex_file)
if os.path.exists(yandex_path):
image_pairs.append({
"idx": idx,
"google_path": os.path.join(self.root_dir, google_file),
"yandex_path": yandex_path,
})
return image_pairs
def __len__(self) -> int:
return len(self.image_pairs)
def __getitem__(self, idx: int) -> Dict[str, torch.Tensor]:
pair_info = self.image_pairs[idx]
google_path = pair_info["google_path"]
yandex_path = pair_info["yandex_path"]
same_domain = True
if np.random.rand() > 0.5:
random_idx = np.random.randint(0, len(self))
google_path = self.image_pairs[random_idx]["google_path"]
same_domain = random_idx == idx
yandex_img = Image.open(yandex_path).convert("RGB")
google_img = Image.open(google_path).convert("RGB")
google_img = google_img.resize((self.image_size[1], self.image_size[0]), Image.BILINEAR)
yandex_img = yandex_img.resize((self.image_size[1], self.image_size[0]), Image.BILINEAR)
matrices = self._get_homography_matrix(pair_info["idx"])
if self.augment:
google_img, yandex_img, homography_matrix = self._apply_augmentation(
google_img, yandex_img, matrices
)
homography_tensor = torch.from_numpy(homography_matrix).float()
else:
homography_tensor = torch.from_numpy(np.eye(3))
if self.transform:
google_img = self.transform(google_img)
yandex_img = self.transform(yandex_img)
else:
google_img = torch.from_numpy(np.array(google_img)).float().permute(2, 0, 1) / 255.0
yandex_img = torch.from_numpy(np.array(yandex_img)).float().permute(2, 0, 1) / 255.0
return {
"google_img": google_img,
"yandex_img": yandex_img,
"homography": homography_tensor,
"same_domain": same_domain,
"idx": torch.tensor(pair_info["idx"], dtype=torch.long),
}
def _get_homography_matrix(self, idx: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
homography_matrix_1 = self.generate_random_homography()
homography_matrix_2 = self.generate_random_homography()
homography_matrix_r = np.linalg.inv(homography_matrix_1) @ homography_matrix_2
return (homography_matrix_1, homography_matrix_2, homography_matrix_r)
def generate_random_homography(self) -> np.ndarray:
scale = np.random.uniform(0.8, 1.2)
tx = np.random.uniform(-0.50, 0.50)
ty = np.random.uniform(-0.50, 0.50)
angle_x = np.random.uniform(np.radians(-10), np.radians(10))
angle_y = np.random.uniform(np.radians(-10), np.radians(10))
angle_z = np.random.uniform(np.radians(-10), np.radians(10))
cy, sy = np.cos(angle_z), np.sin(angle_z)
cp, sp = np.cos(angle_y), np.sin(angle_y)
cr, sr = np.cos(angle_x), np.sin(angle_x)
Rz = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]])
Ry = np.array([[cp, 0, sp], [0, 1, 0], [-sp, 0, cp]])
Rx = np.array([[1, 0, 0], [0, cr, -sr], [0, sr, cr]])
T = np.array([[1, 0, tx], [0, 1, ty], [0, 0, scale]])
K = self.get_camera_matrix()
return K @ Rx @ Ry @ Rz @ T @ np.linalg.inv(K)
def get_camera_matrix(self) -> np.ndarray:
w, h = config["image_size"]
return np.array([[w / 2, 0, w / 2], [0, h / 2, h / 2], [0, 0, 1]])
def _apply_augmentation(
self,
google_img: Image.Image,
yandex_img: Image.Image,
matrices: Tuple[np.ndarray, np.ndarray, np.ndarray],
) -> Tuple[Image.Image, Image.Image, np.ndarray]:
combined_homography = matrices[2]
yandex_aug = self._apply_homography_to_image(yandex_img, matrices[0])
google_aug = self._apply_homography_to_image(google_img, matrices[1])
print("F", combined_homography, np.linalg.inv(matrices[0]) @ matrices[1])
return google_aug, yandex_aug, combined_homography
def _apply_homography_to_image(
self, img: Image.Image, homography: np.ndarray
) -> Image.Image:
img_np = np.array(img)
h, w = img_np.shape[:2]
transformed = cv2.warpPerspective(
img_np, homography, (w, h), flags=cv2.INTER_LINEAR
)
return Image.fromarray(transformed)
def create_data_loaders(
root_dir: str,
batch_size: int = 32,
train_split: float = 0.8,
num_workers: int = 4,
image_size: Tuple[int, int] = (256, 256),
augment_train: bool = True,
augment_val: bool = False,
device=None,
) -> Tuple[DataLoader, DataLoader]:
transform = transforms.Compose([
transforms.ToTensor(),
# transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
full_dataset = YaGoDataset(
root_dir=root_dir,
transform=transform,
augment=False,
image_size=image_size,
cache_homographies=True,
device=device,
)
aug_dataset = YaGoDataset(
root_dir=root_dir,
transform=transform,
augment=True,
image_size=image_size,
cache_homographies=False,
device=device,
)
dataset_size = len(full_dataset)
train_size = int(train_split * dataset_size)
val_size = dataset_size - train_size
indices = list(range(dataset_size))
random.shuffle(indices)
train_indices = indices[:train_size]
val_indices = indices[train_size:]
train_dataset = Subset(full_dataset, train_indices)
val_dataset = Subset(full_dataset, val_indices)
if augment_train:
train_dataset = Subset(aug_dataset, train_indices)
train_loader = DataLoader(
train_dataset,
batch_size=batch_size,
shuffle=True,
num_workers=num_workers,
pin_memory=True,
)
val_loader = DataLoader(
val_dataset,
batch_size=batch_size,
shuffle=False,
num_workers=num_workers,
pin_memory=True,
)
return train_loader, val_loader
# Example usage
dataset = YaGoDataset(
root_dir=config["data_dir"],
augment=True,
image_size=(256, 256),
)
print(f"Dataset size: {len(dataset)}")
# Get a sample
sample = dataset[0]
print(f"Sample keys: {list(sample.keys())}")
print(f"Google image shape: {sample['google_img'].shape}")
print(f"Yandex image shape: {sample['yandex_img'].shape}")
print(f"Homography shape: {sample['homography'].shape}")
# Create data loaders
train_loader, val_loader = create_data_loaders(
root_dir=config["data_dir"],
batch_size=16,
train_split=0.8,
)
print(f"Train batches: {len(train_loader)}")
print(f"Val batches: {len(val_loader)}")

View File

@@ -0,0 +1,219 @@
from typing import Tuple
import torch
import torch.nn as nn
import torch.nn.functional as F
from torchvision import models
class SimilarityCNN(nn.Module):
"""
Модель для оценки схожести двух изображений на базе предобученного бэкбона.
Интерфейс совместим с исходной:
- forward(img1, img2) -> тензор (B, 1) со скором в [0, 1]
- predict_similarity(img1, img2) -> тензор (B, 1) без градиентов
"""
def __init__(
self,
input_channels: int = 3,
backbone_name: str = "resnet18",
pretrained: bool = True,
dropout_rate: float = 0.3,
use_batch_norm: bool = True,
):
super().__init__()
self.input_channels = input_channels
self.backbone_name = backbone_name
self.pretrained = pretrained
self.dropout_rate = dropout_rate
self.use_batch_norm = use_batch_norm
# 1. Создаём бэкбон и берём фичи до последнего FC
backbone = self._create_backbone(backbone_name, pretrained)
# Для ResNet18 выход фичей = 512
self.feature_dim = backbone.fc.in_features
# Заменяем classification head на Identity, чтобы получать только признаки
backbone.fc = nn.Identity()
self.backbone = backbone
# 2. Голова для сравнения двух векторов признаков
# Вход: [f1, f2, |f1 - f2|, f1 * f2] => 4 * feature_dim
compare_input_dim = self.feature_dim * 4
layers = [
nn.Linear(compare_input_dim, 512),
nn.BatchNorm1d(512) if use_batch_norm else nn.Identity(),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(512, 256),
nn.BatchNorm1d(256) if use_batch_norm else nn.Identity(),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(256, 1),
nn.Sigmoid(), # выход в [0, 1]
]
self.head = nn.Sequential(*layers)
def _create_backbone(self, name: str, pretrained: bool) -> nn.Module:
name = name.lower()
if name == "resnet18":
model = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None)
elif name == "resnet34":
model = models.resnet34(weights=models.ResNet34_Weights.IMAGENET1K_V1 if pretrained else None)
else:
raise ValueError(f"Unsupported backbone: {name}")
# Если у тебя не 3 канала, можно добавить адаптер 1x1 conv перед model.conv1
if self.input_channels != 3:
old_conv = model.conv1
model.conv1 = nn.Conv2d(
self.input_channels,
old_conv.out_channels,
kernel_size=old_conv.kernel_size,
stride=old_conv.stride,
padding=old_conv.padding,
bias=old_conv.bias is not None,
)
return model
def _extract_features(self, x: torch.Tensor) -> torch.Tensor:
"""
Прогоняет одно изображение через бэкбон и возвращает вектор признаков (B, feature_dim).
Для ResNet: это эквивалентно model.forward(x), когда fc = Identity.
"""
return self.backbone(x) # (B, feature_dim)
def forward(self, img1: torch.Tensor, img2: torch.Tensor) -> torch.Tensor:
"""
img1, img2: (B, C, H, W) -> similarity: (B, 1)
"""
f1 = self._extract_features(img1) # (B, D)
f2 = self._extract_features(img2) # (B, D)
# Вектор сравнения
diff = torch.abs(f1 - f2)
prod = f1 * f2
combined = torch.cat([f1, f2, diff, prod], dim=1) # (B, 4D)
similarity = self.head(combined) # (B, 1) в [0, 1]
return similarity
def predict_similarity(self, img1: torch.Tensor, img2: torch.Tensor) -> torch.Tensor:
"""
Инференс без градиентов, интерфейс как у исходной модели.
"""
was_training = self.training
self.eval()
with torch.no_grad():
sim = self.forward(img1, img2)
if was_training:
self.train()
return sim
class SimilarityLoss(nn.Module):
"""
Оставляю тот же интерфейс loss, что и в твоём коде.
Если таргет бинарный (0/1), BCELoss подходит.
"""
def __init__(self):
super().__init__()
self.criterion = nn.BCELoss()
def forward(self, pred_similarity: torch.Tensor, target_same: torch.Tensor) -> torch.Tensor:
return self.criterion(pred_similarity, target_same)
def compute_metrics(
self,
pred_similarity: torch.Tensor,
target_same: torch.Tensor,
threshold: float = 0.5,
) -> dict:
with torch.no_grad():
pred_binary = (pred_similarity > threshold).float()
target_binary = (target_same > 0.5).float()
correct = (pred_binary == target_binary).float()
accuracy = correct.mean().item()
tp = ((pred_binary == 1) & (target_binary == 1)).float().sum().item()
fp = ((pred_binary == 1) & (target_binary == 0)).float().sum().item()
fn = ((pred_binary == 0) & (target_binary == 1)).float().sum().item()
tn = ((pred_binary == 0) & (target_binary == 0)).float().sum().item()
precision = tp / (tp + fp + 1e-8)
recall = tp / (tp + fn + 1e-8)
f1 = 2 * precision * recall / (precision + recall + 1e-8)
return {
"accuracy": accuracy,
"precision": precision,
"recall": recall,
"f1": f1,
"mean_similarity": pred_similarity.mean().item(),
}
def create_similarity_model(
model_type: str = "backbone",
input_size: Tuple[int, int] = (256, 256),
**kwargs,
) -> nn.Module:
"""
Аналог вашей фабрики, но с новым типом модели.
"""
if model_type == "backbone":
return SimilarityCNN(**kwargs)
else:
raise ValueError(f"Unknown model type: {model_type}")
if __name__ == "__main__":
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
model = SimilarityCNN(
input_channels=3,
backbone_name="resnet18",
pretrained=True,
dropout_rate=0.3,
use_batch_norm=True,
).to(device)
print(
f"Model created with {sum(p.numel() for p in model.parameters()):,} parameters"
)
batch_size = 4
height, width = 256, 256
img1 = torch.randn(batch_size, 3, height, width).to(device)
img2 = torch.randn(batch_size, 3, height, width).to(device)
print("\nTesting forward pass...")
output = model(img1, img2)
print(f"Output shape: {output.shape}")
print(f"Sample output: {output[0].item():.4f}")
print("\nTesting prediction...")
pred = model.predict_similarity(img1, img2)
print(f"Prediction shape: {pred.shape}")
print("\nTesting loss function...")
target = torch.rand(batch_size, 1).to(device)
loss_fn = SimilarityLoss().to(device)
loss = loss_fn(output, target)
print(f"Loss value: {loss.item():.6f}")
print("\nTesting metrics...")
metrics = loss_fn.compute_metrics(output, target)
for key, value in metrics.items():
print(f"{key}: {value:.6f}")
print("\nAll tests completed successfully!")

View File

@@ -0,0 +1,146 @@
"""
Script for predicting similarity between two images.
"""
import argparse
import os
from pathlib import Path
import torch
from model import SimilarityCNN
from PIL import Image
from torchvision import transforms
def load_image(image_path: str, image_size: tuple = (256, 256)) -> torch.Tensor:
"""Load and preprocess image."""
transform = transforms.Compose(
[
transforms.Resize(image_size),
transforms.ToTensor(),
transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
]
)
image = Image.open(image_path).convert("RGB")
return transform(image).unsqueeze(0) # Add batch dimension
def predict_similarity(
model: SimilarityCNN,
image1_path: str,
image2_path: str,
device: torch.device,
image_size: tuple = (256, 256),
) -> float:
"""Predict similarity between two images."""
model.eval()
img1 = load_image(image1_path, image_size).to(device)
img2 = load_image(image2_path, image_size).to(device)
with torch.no_grad():
similarity = model(img1, img2)
return similarity.item()
def load_model(
checkpoint_path: str,
device: torch.device,
**model_kwargs,
) -> SimilarityCNN:
"""Load model from checkpoint."""
model = SimilarityCNN(**model_kwargs).to(device)
checkpoint = torch.load(checkpoint_path, map_location=device)
model.load_state_dict(checkpoint["model_state_dict"])
return model
def main():
parser = argparse.ArgumentParser(
description="Predict similarity between two images"
)
parser.add_argument("--image1", type=str, required=True, help="Path to first image")
parser.add_argument(
"--image2", type=str, required=True, help="Path to second image"
)
parser.add_argument(
"--checkpoint",
type=str,
default="runs/similarity/checkpoints/best_model.pt",
help="Path to model checkpoint",
)
parser.add_argument(
"--device",
type=str,
default="cuda" if torch.cuda.is_available() else "cpu",
help="Device to use for inference",
)
parser.add_argument(
"--image_size",
type=int,
default=256,
help="Image size for model input",
)
args = parser.parse_args()
device = torch.device(args.device)
print(f"Using device: {device}")
if not os.path.exists(args.image1):
print(f"Error: Image not found: {args.image1}")
return
if not os.path.exists(args.image2):
print(f"Error: Image not found: {args.image2}")
return
if not os.path.exists(args.checkpoint):
print(f"Warning: Checkpoint not found: {args.checkpoint}")
print("Using randomly initialized model for demonstration")
model = SimilarityCNN(
input_channels=3,
hidden_channels=64,
num_blocks=4,
dropout_rate=0.3,
use_batch_norm=True,
).to(device)
else:
print(f"Loading model from: {args.checkpoint}")
model = load_model(
checkpoint_path=args.checkpoint,
device=device,
input_channels=3,
hidden_channels=64,
num_blocks=4,
dropout_rate=0.3,
use_batch_norm=True,
)
print(
f"Model loaded with {sum(p.numel() for p in model.parameters()):,} parameters"
)
similarity = predict_similarity(
model=model,
image1_path=args.image1,
image2_path=args.image2,
device=device,
image_size=(args.image_size, args.image_size),
)
print(f"\nSimilarity between images:")
print(f" Image 1: {args.image1}")
print(f" Image 2: {args.image2}")
print(f" Similarity score: {similarity:.4f}")
print(f" Interpretation: {'Similar' if similarity > 0.5 else 'Different'}")
return similarity
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,248 @@
"""
Training script for image similarity estimation.
"""
import os
import time
from datetime import datetime
import torch
import torch.nn as nn
import torch.optim as optim
from dataloader import config, create_data_loaders
from model import SimilarityCNN, SimilarityLoss, create_similarity_model
from torch.utils.data import DataLoader
from torch.utils.tensorboard import SummaryWriter
from tqdm import tqdm
class SimilarityTrainer:
def __init__(
self,
model: nn.Module,
train_loader: DataLoader,
val_loader: DataLoader,
device: torch.device,
config: dict,
):
self.model = model.to(device)
self.train_loader = train_loader
self.val_loader = val_loader
self.device = device
self.config = config
self.criterion = SimilarityLoss()
self.optimizer = optim.Adam(
model.parameters(),
lr=config.get("learning_rate", 2e-4),
betas=(config.get("beta1", 0.5), config.get("beta2", 0.999)),
)
self.writer = None
self.best_val_loss = float("inf")
self.epochs_without_improvement = 0
def train_epoch(self, epoch: int) -> dict:
self.model.train()
total_loss = 0
total_samples = 0
pbar = tqdm(self.train_loader, desc=f"Epoch {epoch}")
for batch_idx, batch in enumerate(pbar):
google_img = batch["google_img"].to(self.device)
yandex_img = batch["yandex_img"].to(self.device)
target = batch["same_domain"].float().to(self.device).unsqueeze(1)
self.optimizer.zero_grad()
output = self.model(google_img, yandex_img)
loss = self.criterion(output, target)
loss.backward()
self.optimizer.step()
total_loss += loss.item() * google_img.size(0)
total_samples += google_img.size(0)
if batch_idx % self.config.get("log_interval", 10) == 0:
metrics = self.criterion.compute_metrics(output, target)
pbar.set_postfix(
{
"loss": loss.item(),
"acc": metrics["accuracy"],
}
)
if self.writer:
self.writer.add_scalar(
"train/loss",
loss.item(),
epoch * len(self.train_loader) + batch_idx,
)
self.writer.add_scalar(
"train/accuracy",
metrics["accuracy"],
epoch * len(self.train_loader) + batch_idx,
)
avg_loss = total_loss / total_samples
return {"loss": avg_loss}
def validate(self) -> dict:
self.model.eval()
total_loss = 0
total_samples = 0
all_metrics = []
with torch.no_grad():
for batch in tqdm(self.val_loader, desc="Validation"):
google_img = batch["google_img"].to(self.device)
yandex_img = batch["yandex_img"].to(self.device)
target = batch["same_domain"].float().to(self.device).unsqueeze(1)
output = self.model(google_img, yandex_img)
loss = self.criterion(output, target)
total_loss += loss.item() * google_img.size(0)
total_samples += google_img.size(0)
metrics = self.criterion.compute_metrics(output, target)
all_metrics.append(metrics)
avg_loss = total_loss / total_samples
avg_metrics = {}
for key in all_metrics[0].keys():
avg_metrics[key] = sum(m[key] for m in all_metrics) / len(all_metrics)
return {"loss": avg_loss, **avg_metrics}
def train(self, num_epochs: int):
log_dir = self.config.get("output_dir", "runs/similarity")
os.makedirs(log_dir, exist_ok=True)
self.writer = SummaryWriter(log_dir)
print(f"Starting training for {num_epochs} epochs")
print(f"Logging to: {log_dir}")
for epoch in range(1, num_epochs + 1):
print(f"\nEpoch {epoch}/{num_epochs}")
train_metrics = self.train_epoch(epoch)
val_metrics = self.validate()
print(f"Train Loss: {train_metrics['loss']:.4f}")
print(f"Val Loss: {val_metrics['loss']:.4f}")
print(f"Val Accuracy: {val_metrics['accuracy']:.4f}")
print(f"Val F1: {val_metrics['f1']:.4f}")
if self.writer:
self.writer.add_scalar("epoch/train_loss", train_metrics["loss"], epoch)
self.writer.add_scalar("epoch/val_loss", val_metrics["loss"], epoch)
self.writer.add_scalar(
"epoch/val_accuracy", val_metrics["accuracy"], epoch
)
if val_metrics["loss"] < self.best_val_loss:
self.best_val_loss = val_metrics["loss"]
self.epochs_without_improvement = 0
self.save_checkpoint(epoch, val_metrics["loss"], is_best=True)
print(f"New best model saved with val loss: {val_metrics['loss']:.4f}")
else:
self.epochs_without_improvement += 1
self.save_checkpoint(epoch, val_metrics["loss"], is_best=False)
patience = self.config.get("early_stopping_patience", 20)
if self.epochs_without_improvement >= patience:
print(
f"Early stopping triggered after {patience} epochs without improvement"
)
break
self.writer.close()
def save_checkpoint(self, epoch: int, val_loss: float, is_best: bool = False):
checkpoint_dir = os.path.join(
self.config.get("output_dir", "runs/similarity"), "checkpoints"
)
os.makedirs(checkpoint_dir, exist_ok=True)
checkpoint = {
"epoch": epoch,
"model_state_dict": self.model.state_dict(),
"optimizer_state_dict": self.optimizer.state_dict(),
"val_loss": val_loss,
"config": self.config,
}
checkpoint_path = os.path.join(checkpoint_dir, f"checkpoint_epoch_{epoch}.pt")
torch.save(checkpoint, checkpoint_path)
if is_best:
best_path = os.path.join(checkpoint_dir, "best_model.pt")
torch.save(checkpoint, best_path)
def load_checkpoint(self, checkpoint_path: str):
checkpoint = torch.load(checkpoint_path, map_location=self.device)
self.model.load_state_dict(checkpoint["model_state_dict"])
self.optimizer.load_state_dict(checkpoint["optimizer_state_dict"])
return checkpoint["epoch"], checkpoint["val_loss"]
def main():
# Use config from dataloader.py
config_dict = config.copy()
# Ensure image_size is tuple
if isinstance(config_dict.get("image_size"), list):
config_dict["image_size"] = tuple(config_dict["image_size"])
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"Using device: {device}")
print("Creating data loaders...")
train_loader, val_loader = create_data_loaders(
root_dir=config_dict["data_dir"],
batch_size=config_dict["batch_size"],
train_split=config_dict["train_split"],
num_workers=config_dict["num_workers"],
image_size=config_dict["image_size"],
augment_train=True,
augment_val=False,
device=device,
)
print(f"Train batches: {len(train_loader)}")
print(f"Val batches: {len(val_loader)}")
print("Creating model...")
model = create_similarity_model(
model_type="cnn",
input_size=config_dict["image_size"][0]
if isinstance(config_dict["image_size"], (tuple, list))
else config_dict["image_size"],
input_channels=3,
hidden_channels=64,
num_blocks=4,
dropout_rate=0.3,
use_batch_norm=True,
)
print(f"Model parameters: {sum(p.numel() for p in model.parameters()):,}")
trainer = SimilarityTrainer(
model=model,
train_loader=train_loader,
val_loader=val_loader,
device=device,
config=config_dict,
)
print("Starting training...")
trainer.train(config_dict["epochs"])
print("Training completed!")
if __name__ == "__main__":
main()

3
models/SiaN/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
runs
*.gen.py
*.png

31
models/SiaN/_schema.md Normal file
View File

@@ -0,0 +1,31 @@
# SiaN Schema
Notebook structure definition for SiaN model.
---
## Format
```
# === IMPORTS ===
<all imports>
# code: ./src/file.py
# markdown
"""Description"""
# shell:
<shell commands>
```
## Directives
| Directive | Description |
|----------------|------------------------------------|
| `# code:` | Include file from src/ |
| `# markdown` | Description block |
| `# shell:` | Shell script cell |
---
`build.py` generates notebook from `_schema.py`.

110
models/SiaN/_schema.py Normal file
View File

@@ -0,0 +1,110 @@
# _schema.py
# === IMPORTS ===
import os
import random
import logging
from typing import Tuple
import cv2
import numpy as np
import torch
import torch.nn as nn
import torch.optim as optim
import matplotlib.pyplot as plt
from PIL import Image
import seaborn as sns
from torch.utils.data import DataLoader, Dataset, Subset
from torch.utils.tensorboard import SummaryWriter
from torchvision import transforms, models
from tqdm import tqdm
# markdown
"""# Configuration
Global settings for:
- Data paths and image parameters
- Training hyperparameters
- Model architecture options
Contains the `config` dictionary used across all modules."""
# code: ./src/utils.py
# markdown
"""## Dataset
Google/Yandex image pair loader with homography augmentation.
**Features:**
- Loads paired images from dual camera sources
- Applies random homography transformations
- Supports configurable train/val split
**Returns:**
- Batch dict with `google_img`, `yandex_img`, `homography_params`"""
# code: ./src/dataloader.py
# code: ./src/test_dataloader.py
# markdown
"""## Model
`HomographyCNN6` — CNN architecture for homography estimation.
**Output:** 6 parameters
- `rx, ry, rz` — rotation angles (radians)
- `tx, ty` — translation offsets
- `scale` — isotropic scale factor
**Architecture:**
- Dual-branch CNN (Google + Yandex images)
- Shared backbone (configurable: resnet18/34/50)
- Fusion head with dropout regularization"""
# code: ./src/model.py
# markdown
"""## Training
`HomographyTrainer` — training loop with validation and checkpointing.
**Features:**
- Epoch-based training with tqdm progress bar
- Adam optimizer with configurable LR
- Validation after each epoch
- Best model auto-save
- Periodic checkpoints (every N epochs via `save_every_n_epochs`)
**Checkpoint saving:**
- `best_model.pt` — lowest validation loss
- `checkpoint_epoch_N.pt` — periodic saves"""
# code: ./src/train.py
# markdown
"""## Analysis
Visualization and evaluation tools:
- Training metrics plots (loss curves)
- Prediction visualization on sample images
- Error analysis and statistics"""
# code: ./src/analyze.py
# markdown
"""## Main Pipeline
Executes the full training workflow:
1. Load dataset info
2. Create data loaders
3. Initialize model
4. Train with validation
5. Analyze and export results
**Outputs:**
- Model checkpoints in `runs/checkpoints/`
- TensorBoard logs in `runs/`
- Analysis plots"""
# code: ./src/main.py
# # shell:
# !zip artefacts.zip runs/checkpoints/best_model.pt runs/images/ runs/events.*

260
models/SiaN/build.py Normal file
View File

@@ -0,0 +1,260 @@
import json
import os
import re
def parse_schema(schema_path):
with open(schema_path, 'r', encoding='utf-8') as f:
content = f.read()
imports = []
items = []
in_imports_section = False
lines = content.split('\n')
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
if stripped == '# === IMPORTS ===':
in_imports_section = True
i += 1
continue
elif stripped.startswith('# ==='):
in_imports_section = False
i += 1
continue
if in_imports_section and stripped and not stripped.startswith('#'):
imports.append(stripped)
i += 1
continue
if in_imports_section and stripped.startswith('#'):
in_imports_section = False
if stripped.startswith('# code:'):
match = re.search(r'# code:\s*(.+)', stripped)
if match:
items.append(('code', match.group(1).strip()))
i += 1
continue
if stripped.startswith('# inline:') or stripped.startswith('# # shell:'):
directive = 'inline' if stripped.startswith('# inline:') else 'shell'
i += 1
code_lines = []
while i < len(lines):
stripped = lines[i].strip()
if directive == 'shell':
if stripped.startswith('# ') or stripped.startswith('# # '):
line_content = lines[i].strip()
if line_content.startswith('# # '):
line_content = line_content[3:].lstrip()
else:
line_content = line_content[2:]
code_lines.append(line_content)
i += 1
continue
if stripped.startswith('#'):
if stripped.startswith('# code:') or stripped.startswith('# inline:') or stripped.startswith('# # shell:') or stripped == '# markdown' or stripped.startswith('# ==='):
break
i += 1
continue
if stripped == '':
i += 1
continue
code_lines.append(lines[i])
i += 1
if code_lines:
items.append((directive, '\n'.join(code_lines).rstrip()))
continue
if stripped == '# markdown':
i += 1
if i < len(lines):
next_line = lines[i].strip()
if next_line.startswith('"""'):
if next_line.endswith('"""') and len(next_line) > 3:
md_content = next_line[3:-3].strip()
i += 1
else:
end_idx = None
for j in range(i + 1, len(lines)):
if '"""' in lines[j]:
end_idx = j
break
if end_idx:
md_content = '\n'.join(lines[i:end_idx])
md_content = md_content.strip('"""').strip()
i = end_idx + 1
else:
md_content = ""
i += 1
items.append(('markdown', md_content))
continue
i += 1
return imports, items
def strip_all_imports(content):
lines = content.split('\n')
result_lines = []
skip_block = False
if_block_indent = 0
for line in lines:
stripped = line.strip()
if stripped.startswith('if __name__') or stripped.startswith('if __name__ =='):
skip_block = True
if_block_indent = len(line) - len(line.lstrip())
continue
if skip_block:
current_indent = len(line) - len(line.lstrip())
if line.strip() == '':
continue
if current_indent < if_block_indent:
skip_block = False
elif current_indent == if_block_indent and stripped.startswith('if '):
skip_block = True
if_block_indent = current_indent
continue
else:
continue
if stripped.startswith('import ') or stripped.startswith('from '):
continue
result_lines.append(line)
while result_lines and result_lines[-1].strip() == '':
result_lines.pop()
return '\n'.join(result_lines)
def read_src_file(ref, base_dir):
ref_path = ref.replace('./src/', '').replace('src/', '')
full_path = os.path.join(base_dir, 'src', ref_path.replace('./src/', '').lstrip('/'))
if not full_path.endswith('.py'):
full_path += '.py'
with open(full_path, 'r', encoding='utf-8') as f:
return f.read()
def build_notebook(schema_path, output_path=None):
schema_dir = os.path.dirname(os.path.abspath(schema_path))
if output_path is None:
output_path = os.path.join(schema_dir, 'notebook.gen.ipynb')
imports, items = parse_schema(schema_path)
cells = []
if imports:
imports_cell = {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": [imp + "\n" for imp in imports]
}
cells.append(imports_cell)
for item_type, item_content in items:
if item_type == 'markdown':
md_cell = {
"cell_type": "markdown",
"metadata": {},
"source": item_content + "\n"
}
cells.append(md_cell)
elif item_type == 'inline':
lines = item_content.split('\n')
while lines and lines[-1].strip() == '':
lines.pop()
if lines:
lines.append('')
cell = {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": [line + "\n" for line in lines]
}
cells.append(cell)
elif item_type == 'shell':
lines = item_content.split('\n')
while lines and lines[-1].strip() == '':
lines.pop()
if lines:
lines.append('')
cell = {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": [line + "\n" for line in lines]
}
cells.append(cell)
elif item_type == 'code':
try:
content = read_src_file(item_content, schema_dir)
content = strip_all_imports(content)
lines = content.split('\n')
while lines and lines[-1].strip() == '':
lines.pop()
if lines:
lines.append('')
cell = {
"cell_type": "code",
"execution_count": None,
"metadata": {},
"outputs": [],
"source": [line + "\n" for line in lines]
}
cells.append(cell)
except FileNotFoundError:
print(f"Warning: Could not find: {item_content}")
notebook = {
"cells": cells,
"metadata": {
"kernelspec": {
"display_name": ".venv",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(notebook, f, indent=1, ensure_ascii=False)
print(f"Notebook generated: {output_path}")
if __name__ == "__main__":
script_dir = os.path.dirname(os.path.abspath(__file__))
schema_path = os.path.join(script_dir, '_schema.py')
if os.path.exists(schema_path):
build_notebook(schema_path)
else:
print(f"Error: _schema.py not found at {schema_path}")

File diff suppressed because it is too large Load Diff

314
models/SiaN/src/analyze.py Normal file
View File

@@ -0,0 +1,314 @@
import os
import torch
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from .dataloader import create_data_loaders
from .model import angular_difference
from .utils import config
sns.set_theme(style="whitegrid", palette="muted", font_scale=1.2)
IMG_DIR = os.path.join(config["output_dir"], "images")
os.makedirs(IMG_DIR, exist_ok=True)
def analyze_training(trainer):
print("=== Training Analysis ===\n")
if trainer.writer:
print("TensorBoard logs available at:", trainer.writer.log_dir)
print(f"\nBest val loss: {trainer.best_val_loss:.4f}")
best_model_path = os.path.join(config["output_dir"], "checkpoints", "best_model.pt")
if os.path.exists(best_model_path):
checkpoint = torch.load(best_model_path, map_location=trainer.device)
trainer.model.load_state_dict(checkpoint["model_state_dict"])
print(f"\nLoaded best model from epoch {checkpoint['epoch']} (val loss: {checkpoint['val_loss']:.4f})")
trainer.model.eval()
n_samples = 50
names = ["tx", "ty", "rx", "ry", "rz", "scale"]
_, val_loader_for_analysis = create_data_loaders(
root_dir=config["data_dir"],
batch_size=config["batch_size"],
train_split=config["train_split"],
num_workers=config["num_workers"],
image_size=config["image_size"],
augment_train=True,
cache_level=0,
)
with torch.no_grad():
all_errors = [[] for _ in range(6)]
all_targets = [[] for _ in range(6)]
all_preds = [[] for _ in range(6)]
sample_count = 0
for batch in val_loader_for_analysis:
if sample_count >= n_samples:
break
google_img = batch["google_img"].to(trainer.device)
yandex_img = batch["yandex_img"].to(trainer.device)
target_params = batch["homography_params"].to(trainer.device)
pred_params = trainer.model(google_img, yandex_img)
decoded_pred = trainer.model.decode_output(pred_params)
batch_size = google_img.size(0)
for i in range(batch_size):
if sample_count >= n_samples:
break
tx_error = torch.abs(decoded_pred[i, 0] - target_params[i, 0]).item()
ty_error = torch.abs(decoded_pred[i, 1] - target_params[i, 1]).item()
rx_error = angular_difference(decoded_pred[i, 2], target_params[i, 2]).item()
ry_error = angular_difference(decoded_pred[i, 3], target_params[i, 3]).item()
rz_error = angular_difference(decoded_pred[i, 4], target_params[i, 4]).item()
scale_error = torch.abs(decoded_pred[i, 5] - target_params[i, 5]).item()
errors = [tx_error, ty_error, rx_error, ry_error, rz_error, scale_error]
target_reordered = target_params[i].cpu().numpy()
pred_reordered = decoded_pred[i].cpu().numpy()
for j in range(6):
all_errors[j].append(errors[j])
all_targets[j].append(target_reordered[j])
all_preds[j].append(pred_reordered[j])
sample_count += 1
mean_errors = [np.mean(all_errors[i]) for i in range(6)]
std_errors = [np.std(all_errors[i]) for i in range(6)]
angle_errors_deg = [np.degrees(mean_errors[i]) for i in range(2, 5)]
all_targets_stacked = [np.array(all_targets[i]) for i in range(6)]
target_ranges = [np.ptp(all_targets_stacked[i]) for i in range(6)]
relative_errors = [mean_errors[i] / target_ranges[i] if target_ranges[i] > 1e-8 else 0 for i in range(6)]
if len(trainer.train_losses) > 0:
epochs = range(1, len(trainer.train_losses) + 1)
fig, axes = plt.subplots(2, 2, figsize=(16, 12))
axes[0, 0].plot(epochs, trainer.train_losses, color="#2ecc71", linewidth=2, label="Train Loss")
axes[0, 0].plot(epochs, trainer.val_losses, color="#e74c3c", linewidth=2, label="Val Loss")
axes[0, 0].set_xlabel("Epoch")
axes[0, 0].set_ylabel("Loss")
axes[0, 0].set_title("Training & Validation Loss", fontweight="bold")
axes[0, 0].legend(framealpha=0.9)
axes[0, 0].grid(True, alpha=0.3)
axes[0, 1].plot(epochs, trainer.val_losses, color="#e74c3c", linewidth=2, label="Val Loss")
axes[0, 1].set_xlabel("Epoch")
axes[0, 1].set_ylabel("Loss")
axes[0, 1].set_title("Validation Loss", fontweight="bold")
axes[0, 1].legend(framealpha=0.9)
axes[0, 1].grid(True, alpha=0.3)
axes[1, 0].plot(epochs, trainer.val_mse_trans, color="#3498db", linewidth=2, label="Translation (tx, ty)")
axes[1, 0].plot(epochs, trainer.val_mse_angle, color="#9b59b6", linewidth=2, label="Angle (rx, ry, rz)")
axes[1, 0].plot(epochs, trainer.val_mse_scale, color="#e67e22", linewidth=2, label="Scale")
axes[1, 0].set_xlabel("Epoch")
axes[1, 0].set_ylabel("MSE")
axes[1, 0].set_title("Validation MSE by Category", fontweight="bold")
axes[1, 0].legend(framealpha=0.9)
axes[1, 0].grid(True, alpha=0.3)
x_pos = np.arange(6)
colors = ["#3498db", "#e74c3c", "#9b59b6", "#2ecc71", "#f39c12", "#1abc9c"]
bars = axes[1, 1].bar(x_pos, mean_errors, yerr=std_errors, capsize=6, color=colors, alpha=0.85, edgecolor="white", linewidth=1.5)
axes[1, 1].set_xticks(x_pos)
axes[1, 1].set_xticklabels(names)
axes[1, 1].set_ylabel("Mean Absolute Error")
axes[1, 1].set_title(f"Mean Absolute Error per Parameter ({n_samples} samples)", fontweight="bold")
axes[1, 1].grid(True, alpha=0.3, axis="y")
plt.tight_layout()
plt.savefig(os.path.join(IMG_DIR, "training_loss_plots.png"), dpi=150, bbox_inches="tight")
print("Saved training_loss_plots.png")
plt.show()
fig, axes = plt.subplots(2, 3, figsize=(18, 10))
colors = ["#3498db", "#e74c3c", "#9b59b6", "#2ecc71", "#f39c12", "#1abc9c"]
for j in range(6):
row = j // 3
col = j % 3
axes[row, col].bar(range(len(all_errors[j])), all_errors[j], color=colors[j], alpha=0.75)
axes[row, col].set_xlabel("Sample", fontsize=10)
axes[row, col].set_ylabel("Absolute Error", fontsize=10)
axes[row, col].set_title(f"{names[j]}: Mean={np.mean(all_errors[j]):.4f}, Std={np.std(all_errors[j]):.4f}", fontweight="bold", fontsize=11)
axes[row, col].grid(True, alpha=0.3, axis="y")
plt.suptitle(f"Mean Absolute Error per Parameter ({n_samples} samples)", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig(os.path.join(IMG_DIR, "mae_per_parameter.png"), dpi=150, bbox_inches="tight")
print("Saved mae_per_parameter.png")
plt.show()
fig, axes = plt.subplots(1, 3, figsize=(18, 6))
x_pos = np.arange(6)
colors = ["#3498db", "#e74c3c", "#9b59b6", "#2ecc71", "#f39c12", "#1abc9c"]
bars = axes[0].bar(x_pos, mean_errors, yerr=std_errors, capsize=6, color=colors, alpha=0.85, edgecolor="white", linewidth=1.5)
axes[0].set_xticks(x_pos)
axes[0].set_xticklabels(names)
axes[0].set_ylabel("Mean Absolute Error")
axes[0].set_title("Mean Absolute Error per Parameter (with std)", fontweight="bold")
axes[0].grid(True, alpha=0.3, axis="y")
bp = axes[1].boxplot([all_errors[i] for i in range(6)], labels=names, patch_artist=True)
for patch, color in zip(bp["boxes"], colors):
patch.set_facecolor(color)
patch.set_alpha(0.8)
axes[1].set_ylabel("Absolute Error")
axes[1].set_title(f"Error Distribution per Parameter ({n_samples} samples)", fontweight="bold")
axes[1].grid(True, alpha=0.3, axis="y")
rel_err_pos = np.arange(6)
bars = axes[2].bar(rel_err_pos, relative_errors, color=colors, alpha=0.85, edgecolor="white", linewidth=1.5)
axes[2].set_xticks(rel_err_pos)
axes[2].set_xticklabels(names)
axes[2].set_ylabel("Relative Error (MAE / Range)")
axes[2].set_title("Relative Error per Parameter", fontweight="bold")
axes[2].grid(True, alpha=0.3, axis="y")
plt.tight_layout()
plt.savefig(os.path.join(IMG_DIR, "mae_boxplot.png"), dpi=150, bbox_inches="tight")
print("Saved mae_boxplot.png")
plt.show()
fig, axes = plt.subplots(1, 2, figsize=(14, 6))
angle_names = ["rx", "ry", "rz"]
x_pos = np.arange(3)
colors_angle = ["#9b59b6", "#2ecc71", "#f39c12"]
bars = axes[0].bar(x_pos, angle_errors_deg, color=colors_angle, alpha=0.85, edgecolor="white", linewidth=1.5)
axes[0].set_xticks(x_pos)
axes[0].set_xticklabels(angle_names)
axes[0].set_ylabel("Mean Absolute Error (degrees)")
axes[0].set_title("Angle MAE in Degrees", fontweight="bold")
axes[0].grid(True, alpha=0.3, axis="y")
for i, e in enumerate(angle_errors_deg):
axes[0].text(i, e + 0.5, f"{e:.1f}°", ha="center", va="bottom", fontsize=11, fontweight="bold")
trans_scale_errs = [mean_errors[0], mean_errors[1], mean_errors[5]]
trans_scale_names = ["tx", "ty", "scale"]
x_pos = np.arange(3)
colors_trans = ["#3498db", "#e74c3c", "#1abc9c"]
bars = axes[1].bar(x_pos, trans_scale_errs, color=colors_trans, alpha=0.85, edgecolor="white", linewidth=1.5)
axes[1].set_xticks(x_pos)
axes[1].set_xticklabels(trans_scale_names)
axes[1].set_ylabel("Mean Absolute Error")
axes[1].set_title("Translation & Scale MAE", fontweight="bold")
axes[1].grid(True, alpha=0.3, axis="y")
for i, e in enumerate(trans_scale_errs):
axes[1].text(i, e + 0.01, f"{e:.4f}", ha="center", va="bottom", fontsize=11, fontweight="bold")
plt.tight_layout()
plt.savefig(os.path.join(IMG_DIR, "mae_by_category.png"), dpi=150, bbox_inches="tight")
print("Saved mae_by_category.png")
plt.show()
print("\n=== Sample Predictions (20 pairs) ===")
n_vis_samples = 20
with torch.no_grad():
vis_count = 0
for batch in val_loader_for_analysis:
if vis_count >= n_vis_samples:
break
batch_size = batch["google_img"].size(0)
for i in range(batch_size):
if vis_count >= n_vis_samples:
break
google_img = batch["google_img"][i:i+1].to(trainer.device)
yandex_img = batch["yandex_img"][i:i+1].to(trainer.device)
target_params = batch["homography_params"][i:i+1].to(trainer.device)
pred_params = trainer.model(google_img, yandex_img)
decoded_pred = trainer.model.decode_output(pred_params)
tx_error = torch.abs(decoded_pred[0, 0] - target_params[0, 0]).item()
ty_error = torch.abs(decoded_pred[0, 1] - target_params[0, 1]).item()
rx_error = angular_difference(decoded_pred[0, 2], target_params[0, 2]).item()
ry_error = angular_difference(decoded_pred[0, 3], target_params[0, 3]).item()
rz_error = angular_difference(decoded_pred[0, 4], target_params[0, 4]).item()
scale_error = torch.abs(decoded_pred[0, 5] - target_params[0, 5]).item()
errors = np.array([tx_error, ty_error, rx_error, ry_error, rz_error, scale_error])
targets = target_params[0].cpu().numpy()
preds = decoded_pred[0].cpu().numpy()
fig, axes = plt.subplots(2, 2, figsize=(12, 10))
axes[0, 0].imshow(google_img[0].cpu().permute(1, 2, 0))
axes[0, 0].set_title("Google Image", fontweight="bold", fontsize=12)
axes[0, 0].axis("off")
axes[0, 1].imshow(yandex_img[0].cpu().permute(1, 2, 0))
axes[0, 1].set_title("Yandex Image", fontweight="bold", fontsize=12)
axes[0, 1].axis("off")
x_pos = np.arange(6)
width = 0.35
axes[1, 0].bar(x_pos - width/2, targets, width, label="Target", color="#3498db", alpha=0.85)
axes[1, 0].bar(x_pos + width/2, preds, width, label="Predicted", color="#e74c3c", alpha=0.85)
axes[1, 0].set_xticks(x_pos)
axes[1, 0].set_xticklabels(names)
axes[1, 0].set_ylabel("Parameter Value")
axes[1, 0].set_title("Target vs Predicted", fontweight="bold", fontsize=12)
axes[1, 0].legend(framealpha=0.9)
axes[1, 0].grid(True, alpha=0.3, axis="y")
colors = ["#3498db", "#e74c3c", "#9b59b6", "#2ecc71", "#f39c12", "#1abc9c"]
bars = axes[1, 1].bar(x_pos, errors, color=colors, alpha=0.85, edgecolor="white", linewidth=1.2)
axes[1, 1].set_xticks(x_pos)
axes[1, 1].set_xticklabels(names)
axes[1, 1].set_ylabel("Absolute Error")
axes[1, 1].set_title(f"Prediction Error (Mean: {np.mean(errors):.4f})", fontweight="bold", fontsize=12)
axes[1, 1].grid(True, alpha=0.3, axis="y")
for i_e, e in enumerate(errors):
axes[1, 1].text(i_e, e + 0.01, f"{e:.3f}", ha="center", va="bottom", fontsize=9)
plt.suptitle(f"Sample {vis_count + 1}", fontsize=14, fontweight="bold")
plt.tight_layout()
plt.savefig(os.path.join(IMG_DIR, f"prediction_sample_{vis_count + 1:02d}.png"), dpi=100, bbox_inches="tight")
plt.show()
print(f"Saved prediction_sample_{vis_count + 1:02d}.png")
vis_count += 1
print(f"\nPrediction errors over {n_samples} samples:")
print(f"{'Param':<8} {'Mean Error':>12} {'Std Error':>12} {'Min':>8} {'Max':>8} {'Rel Err':>10}")
print("-" * 62)
for i in range(6):
mean_err = np.mean(all_errors[i])
std_err = np.std(all_errors[i])
min_err = np.min(all_errors[i])
max_err = np.max(all_errors[i])
rel_err = relative_errors[i]
print(f"{names[i]:<8} {mean_err:>12.4f} {std_err:>12.4f} {min_err:>8.4f} {max_err:>8.4f} {rel_err:>10.4f}")
print(f"\nAngle errors in degrees:")
print(f"{'Param':<8} {'MAE (deg)':>12} {'MAE (rad)':>12}")
print("-" * 35)
for i, name in enumerate(["rx", "ry", "rz"]):
print(f"{name:<8} {angle_errors_deg[i]:>12.2f} {mean_errors[i+2]:>12.4f}")
return {
"best_val_loss": trainer.best_val_loss,
"train_losses": trainer.train_losses,
"val_losses": trainer.val_losses,
"val_mse_trans": trainer.val_mse_trans,
"val_mse_angle": trainer.val_mse_angle,
"val_mse_scale": trainer.val_mse_scale,
"mean_errors": mean_errors,
"std_errors": std_errors,
"angle_errors_deg": angle_errors_deg,
"relative_errors": relative_errors,
}

View File

@@ -0,0 +1,142 @@
import os
import random
from typing import Tuple
import cv2
import numpy as np
import torch
from PIL import Image
from torch.utils.data import DataLoader, Dataset, Subset
from torchvision import transforms
from .utils import config, get_camera_matrix, generate_random_homography_params, homography_params_to_matrix
class YaGoDataset(Dataset):
def __init__(self, root_dir: str, transform=None, augment: bool = True,
image_size: Tuple[int, int] = (256, 256), cache_level: int = 5):
self.root_dir = root_dir
self.transform = transform
self.augment = augment
self.image_size = image_size
self.cache_level = cache_level
self.K = get_camera_matrix(image_size[1], image_size[0])
self.image_pairs = self._discover_image_pairs()
self._load_images_to_memory()
self._init_cache()
def _discover_image_pairs(self):
pairs = []
for f in os.listdir(self.root_dir):
if f.endswith("_google.png"):
idx = f.split("_")[0]
yandex_path = os.path.join(self.root_dir, f"{idx}_yandex.png")
if os.path.exists(yandex_path):
pairs.append({"idx": int(idx), "google": os.path.join(self.root_dir, f), "yandex": yandex_path})
return sorted(pairs, key=lambda x: x["idx"])
def _load_images_to_memory(self):
self._google_images = []
self._yandex_images = []
for pair in self.image_pairs:
google_img = cv2.imread(pair["google"])
google_img = cv2.cvtColor(google_img, cv2.COLOR_BGR2RGB)
google_img = cv2.resize(google_img, (self.image_size[1], self.image_size[0]), interpolation=cv2.INTER_LINEAR)
yandex_img = cv2.imread(pair["yandex"])
yandex_img = cv2.cvtColor(yandex_img, cv2.COLOR_BGR2RGB)
yandex_img = cv2.resize(yandex_img, (self.image_size[1], self.image_size[0]), interpolation=cv2.INTER_LINEAR)
self._google_images.append(google_img)
self._yandex_images.append(yandex_img)
def _init_cache(self):
self._access_counts = [0] * len(self.image_pairs)
self._cached_google = [None] * len(self.image_pairs)
self._cached_yandex = [None] * len(self.image_pairs)
self._cached_homography = [None] * len(self.image_pairs)
self._cached_params = [None] * len(self.image_pairs)
def _generate_augmented(self, idx):
google_img = self._google_images[idx].copy()
yandex_img = self._yandex_images[idx].copy()
params1 = generate_random_homography_params()
params2 = generate_random_homography_params()
H1 = homography_params_to_matrix(params1, self.K)
H2 = homography_params_to_matrix(params2, self.K)
yandex_warped = cv2.warpPerspective(yandex_img, H1, (self.image_size[1], self.image_size[0]))
google_warped = cv2.warpPerspective(google_img, H2 @ H1, (self.image_size[1], self.image_size[0]))
return google_warped, yandex_warped, H2, params2
def __len__(self):
return len(self.image_pairs)
def __getitem__(self, idx):
self._access_counts[idx] += 1
use_cache = self.augment and self.cache_level > 0 and self._access_counts[idx] > 1 and (self._access_counts[idx] - 1) % self.cache_level != 0
if use_cache:
google_img = self._cached_google[idx]
yandex_img = self._cached_yandex[idx]
target_matrix = self._cached_homography[idx]
target_params = self._cached_params[idx]
elif self.augment:
google_img, yandex_img, target_matrix, target_params = self._generate_augmented(idx)
if self.cache_level > 0:
self._cached_google[idx] = google_img
self._cached_yandex[idx] = yandex_img
self._cached_homography[idx] = target_matrix
self._cached_params[idx] = target_params
else:
google_img = self._google_images[idx]
yandex_img = self._yandex_images[idx]
target_params = np.array([0, 0, 0, 0, 0, 1], dtype=np.float32)
target_matrix = np.eye(3, dtype=np.float32)
google_img = Image.fromarray(google_img)
yandex_img = Image.fromarray(yandex_img)
if self.transform:
google_img = self.transform(google_img)
yandex_img = self.transform(yandex_img)
return {
"google_img": google_img,
"yandex_img": yandex_img,
"homography_matrix": torch.from_numpy(target_matrix).float(),
"homography_params": torch.from_numpy(target_params).float(),
}
def create_data_loaders(root_dir, batch_size=32, train_split=0.8, num_workers=0,
image_size=(256, 256), augment_train=True, cache_level=5):
transform = transforms.Compose([
transforms.ToTensor(),
# transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
])
full_ds = YaGoDataset(root_dir, transform=transform, augment=False, image_size=image_size, cache_level=cache_level)
aug_ds = YaGoDataset(root_dir, transform=transform, augment=True, image_size=image_size, cache_level=cache_level)
indices = list(range(len(full_ds)))
random.shuffle(indices)
split = int(train_split * len(indices))
train_ds = Subset(aug_ds if augment_train else full_ds, indices[:split])
val_ds = Subset(aug_ds, indices[split:])
return (DataLoader(train_ds, batch_size=batch_size, shuffle=True, num_workers=num_workers, pin_memory=True),
DataLoader(val_ds, batch_size=batch_size, shuffle=False, num_workers=num_workers, pin_memory=True))
def get_dataset_info():
ds = YaGoDataset(config["data_dir"], augment=True, image_size=config["image_size"])
return {
"size": len(ds),
"sample_keys": list(ds[0].keys()),
"sample_params": ds[0]["homography_params"].numpy()
}

60
models/SiaN/src/main.py Normal file
View File

@@ -0,0 +1,60 @@
import logging
import os
import torch
from .dataloader import create_data_loaders, get_dataset_info
from .model import HomographyCNN6, HomographyHybridCNN, HomographyLoss, count_parameters
from .train import HomographyTrainer
from .analyze import analyze_training
from .utils import config
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(message)s")
logger = logging.getLogger(__name__)
logger.info("=" * 50)
logger.info("SiaN Training Pipeline")
logger.info("=" * 50)
dataset_info = get_dataset_info()
logger.info(f"Dataset: {dataset_info['size']} samples, keys={dataset_info['sample_keys']}")
train_loader, val_loader = create_data_loaders(
root_dir=config["data_dir"],
batch_size=config["batch_size"],
train_split=config["train_split"],
num_workers=config["num_workers"],
image_size=config["image_size"],
)
logger.info(f"Data loaders created: train={len(train_loader.dataset)}, val={len(val_loader.dataset)}")
# model = HomographyCNN6(
# input_channels=3,
# backbone_name=config["backbone"],
# pretrained=True,
# dropout_rate=config["dropout_rate"]
# )
model = HomographyHybridCNN(
input_channels=3,
dropout_rate=config["droupout_rate"],
)
logger.info(f"Model created with {count_parameters(model):,} parameters")
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
logger.info(f"Using device: {device}")
trainer = HomographyTrainer(model, train_loader, val_loader, device, HomographyLoss())
logger.info("Starting training...")
trainer.train(config["epochs"])
logger.info("Training completed")
logger.info("Analyzing model...")
results = analyze_training(trainer)
logger.info(f"Analysis complete: best_val_loss={results['best_val_loss']:.4f}")
logger.info("=" * 50)
logger.info("Pipeline completed successfully")
logger.info("=" * 50)

265
models/SiaN/src/model.py Normal file
View File

@@ -0,0 +1,265 @@
import torch
import torch.nn as nn
from torchvision import models
def angular_difference(pred_angles, target_angles):
diff = pred_angles - target_angles
diff = torch.atan2(torch.sin(diff), torch.cos(diff))
return torch.abs(diff)
class HomographyCNN6(nn.Module):
def __init__(self, input_channels=3, backbone_name="resnet18", pretrained=True, dropout_rate=0.3):
super().__init__()
backbone = getattr(models, backbone_name)(weights=models.ResNet18_Weights.IMAGENET1K_V1 if pretrained else None)
self.feature_dim = backbone.fc.in_features
backbone.fc = nn.Identity()
self.backbone = backbone
self.head = nn.Sequential(
nn.Linear(self.feature_dim * 4, 1024),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(1024, 512),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(512, 256),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(256, 6),
)
self._init_weights()
def _normalize_sin_cos(self, _sin, _cos):
_len = torch.sqrt(_sin ** 2 + _cos ** 2)
return _sin / _len, _cos / _len
def _init_weights(self):
for module in self.head.modules():
if isinstance(module, nn.Linear):
nn.init.kaiming_normal_(module.weight, mode='fan_in', nonlinearity='relu')
if module.bias is not None:
nn.init.zeros_(module.bias)
def forward(self, img1, img2):
f1 = self.backbone(img1)
f2 = self.backbone(img2)
combined = torch.cat([f1, f2, torch.abs(f1 - f2), f1 * f2], dim=1)
output = self.head(combined)
output = torch.tanh(output) # [-1; 1]
modified = output.clone()
modified[:, 2:6] = torch.mul(output[:, 2:6], torch.pi) # [-pi; pi]
return modified
def decode_output(self, output):
tx = output[:, 0]
ty = output[:, 1]
scale = output[:, 5]
angle1 = output[:, 2]
angle2 = output[:, 3]
angle3 = output[:, 4]
return torch.stack([tx, ty, angle1, angle2, angle3, scale], dim=1)
def get_components(self, output):
decoded = self.decode_output(output)
return {
"tx": decoded[:, 0],
"ty": decoded[:, 1],
"rx": decoded[:, 2],
"ry": decoded[:, 3],
"rz": decoded[:, 4],
"scale": decoded[:, 5],
}
class HomographyHybridCNN(nn.Module):
def __init__(self, input_channels=3, use_resnet_layers=2, dropout_rate=0.3):
super().__init__()
if use_resnet_layers == 1:
resnet = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
self.conv1 = resnet.conv1
self.bn1 = resnet.bn1
self.relu = resnet.relu
self.maxpool = resnet.maxpool
conv_out_channels = 64
elif use_resnet_layers == 2:
resnet = models.resnet18(weights=models.ResNet18_Weights.IMAGENET1K_V1)
self.conv1 = resnet.conv1
self.bn1 = resnet.bn1
self.relu = resnet.relu
self.maxpool = resnet.maxpool
self.conv2 = resnet.layer1[0].conv1
self.bn2 = resnet.layer1[0].bn1
self.conv2_2 = resnet.layer1[0].conv2
self.bn2_2 = resnet.layer1[0].bn2
self.relu2 = resnet.layer1[0].relu
self.maxpool2 = resnet.maxpool
conv_out_channels = 64
else:
raise ValueError("use_resnet_layers must be 1 or 2")
self.use_resnet_layers = use_resnet_layers
self.feature_map_size = 64
self.conv_head = nn.Sequential(
nn.Conv2d(conv_out_channels, 128, kernel_size=3, padding=1),
nn.BatchNorm2d(128),
nn.ReLU(inplace=True),
nn.Conv2d(128, 256, kernel_size=3, padding=1),
nn.BatchNorm2d(256),
nn.ReLU(inplace=True),
nn.MaxPool2d(2),
)
self.global_pool = nn.AdaptiveAvgPool2d((1, 1))
feature_dim = 256 * 4
self.head = nn.Sequential(
nn.Linear(feature_dim, 1024),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(1024, 512),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(512, 256),
nn.ReLU(inplace=True),
nn.Dropout(dropout_rate),
nn.Linear(256, 6),
)
self._init_weights()
def _init_weights(self):
for module in self.head.modules():
if isinstance(module, nn.Linear):
nn.init.kaiming_normal_(module.weight, mode='fan_in', nonlinearity='relu')
if module.bias is not None:
nn.init.zeros_(module.bias)
def forward(self, img1, img2):
x1 = self._extract_features(img1)
x2 = self._extract_features(img2)
combined = torch.cat([x1, x2, torch.abs(x1 - x2), x1 * x2], dim=1)
output = self.head(combined)
output = torch.tanh(output)
modified = output.clone()
modified[:, 2:6] = torch.mul(output[:, 2:6], torch.pi)
return modified
def _extract_features(self, x):
x = self.conv1(x)
x = self.bn1(x)
x = self.relu(x)
x = self.maxpool(x)
if self.use_resnet_layers >= 2:
x = self.conv2(x)
x = self.bn2(x)
x = self.relu(x)
x = self.conv2_2(x)
x = self.bn2_2(x)
x = self.relu2(x)
x = self.maxpool2(x)
x = self.conv_head(x)
x = self.global_pool(x)
x = x.view(x.size(0), -1)
return x
def decode_output(self, output):
tx = output[:, 0]
ty = output[:, 1]
scale = output[:, 5]
angle1 = output[:, 2]
angle2 = output[:, 3]
angle3 = output[:, 4]
return torch.stack([tx, ty, angle1, angle2, angle3, scale], dim=1)
def get_components(self, output):
decoded = self.decode_output(output)
return {
"tx": decoded[:, 0],
"ty": decoded[:, 1],
"rx": decoded[:, 2],
"ry": decoded[:, 3],
"rz": decoded[:, 4],
"scale": decoded[:, 5],
}
class HomographyLoss6(nn.Module):
def __init__(self, angle_loss_weight=1.0, trans_loss_weight=1.0, scale_loss_weight=1.0):
super().__init__()
self.criterion = nn.MSELoss()
self.angle_loss_weight = angle_loss_weight
self.trans_loss_weight = trans_loss_weight
self.scale_loss_weight = scale_loss_weight
@staticmethod
def dot_angles(src, dest):
sin_src = torch.sin(src)
cos_src = torch.cos(src)
sin_dest = torch.sin(dest)
cos_dest = torch.cos(dest)
return sin_src * sin_dest + cos_src * cos_dest
def forward(self, pred, target):
tx_loss = self.criterion(pred[:, 0], target[:, 0])
ty_loss = self.criterion(pred[:, 1], target[:, 1])
dot_rx = HomographyLoss6.dot_angles(pred[:, 2], target[:, 2])
dot_ry = HomographyLoss6.dot_angles(pred[:, 3], target[:, 3])
dot_rz = HomographyLoss6.dot_angles(pred[:, 4], target[:, 4])
rx_loss = self.criterion(dot_rx, torch.ones_like(dot_rx))
ry_loss = self.criterion(dot_ry, torch.ones_like(dot_ry))
rz_loss = self.criterion(dot_rz, torch.ones_like(dot_rz))
scale_loss = self.criterion(pred[:, 5], target[:, 5])
total_loss = (
self.trans_loss_weight * (tx_loss + ty_loss) +
self.angle_loss_weight * (rx_loss + ry_loss + rz_loss) +
self.scale_loss_weight * scale_loss
)
return total_loss
def compute_mse_components(self, decoded, target):
tx_mse = self.criterion(decoded[:, 0], target[:, 0]).item()
ty_mse = self.criterion(decoded[:, 1], target[:, 1]).item()
dot_rx = HomographyLoss6.dot_angles(decoded[:, 2], target[:, 2])
dot_ry = HomographyLoss6.dot_angles(decoded[:, 3], target[:, 3])
dot_rz = HomographyLoss6.dot_angles(decoded[:, 4], target[:, 4])
rx_mse = self.criterion(dot_rx, torch.ones_like(dot_rx)).item()
ry_mse = self.criterion(dot_ry, torch.ones_like(dot_ry)).item()
rz_mse = self.criterion(dot_rz, torch.ones_like(dot_rz)).item()
scale_mse = self.criterion(decoded[:, 5], target[:, 5]).item()
avg_angle_loss = (rx_mse + ry_mse + rz_mse) / 3
return {
'trans': (tx_mse + ty_mse) / 2,
'angle': avg_angle_loss,
'scale': scale_mse
}
HomographyLoss = HomographyLoss6
def count_parameters(model):
return sum(p.numel() for p in model.parameters())

View File

@@ -0,0 +1,15 @@
from ..src.dataloader import *
train_loader, val_loader = create_data_loaders(config['data_dir'])
batch = next(iter(train_loader))
google_img = batch['google_img'][0]
yandex_img = batch['yandex_img'][0]
# google_img.permute((1, 2, 0)) * 255
batch['homography_params'].mean(axis=0)
print(batch['homography_matrix'][0])
print(batch['homography_params'][0])
K = get_camera_matrix(config['image_size'][0], config['image_size'][1])
print(homography_params_to_matrix(batch['homography_params'][0], K))
print(matrix_to_homography_params(batch['homography_matrix'][0].numpy(), K))

122
models/SiaN/src/train.py Normal file
View File

@@ -0,0 +1,122 @@
import os
import torch
import torch.nn as nn
import torch.optim as optim
from torch.utils.tensorboard import SummaryWriter
from tqdm import tqdm
from .dataloader import create_data_loaders
from .model import HomographyCNN6, HomographyLoss6, count_parameters
from .utils import config
class HomographyTrainer:
def __init__(self, model, train_loader, val_loader, device, criterion):
self.model = model.to(device)
self.train_loader = train_loader
self.val_loader = val_loader
self.device = device
self.criterion = criterion
self.optimizer = optim.Adam(model.parameters(), lr=config["learning_rate"], weight_decay=1e-4)
self.writer = None
self.best_val_loss = float("inf")
self.train_losses = []
self.val_losses = []
self.train_mse_trans = []
self.train_mse_angle = []
self.train_mse_scale = []
self.val_mse_trans = []
self.val_mse_angle = []
self.val_mse_scale = []
def train_epoch(self, epoch):
self.model.train()
total_loss, total_samples = 0, 0
mse_trans_sum, mse_angle_sum, mse_scale_sum = 0, 0, 0
pbar = tqdm(self.train_loader, desc=f"Epoch {epoch}")
for batch_idx, batch in enumerate(pbar):
google_img = batch["google_img"].to(self.device)
yandex_img = batch["yandex_img"].to(self.device)
target = batch["homography_params"].to(self.device)
self.optimizer.zero_grad()
output = self.model(google_img, yandex_img)
loss = self.criterion(output, target)
loss.backward()
self.optimizer.step()
total_loss += loss.item() * google_img.size(0)
total_samples += google_img.size(0)
decoded_output = self.model.decode_output(output)
mse_components = self.criterion.compute_mse_components(decoded_output, target)
mse_trans_sum += mse_components['trans'] * google_img.size(0)
mse_angle_sum += mse_components['angle'] * google_img.size(0)
mse_scale_sum += mse_components['scale'] * google_img.size(0)
pbar.set_postfix({"loss": loss.item()})
self.train_mse_trans.append(mse_trans_sum / total_samples)
self.train_mse_angle.append(mse_angle_sum / total_samples)
self.train_mse_scale.append(mse_scale_sum / total_samples)
return {"loss": total_loss / total_samples}
def validate(self):
self.model.eval()
total_loss, total_samples = 0, 0
mse_trans_sum, mse_angle_sum, mse_scale_sum = 0, 0, 0
with torch.no_grad():
for batch in tqdm(self.val_loader, desc="Validation"):
google_img = batch["google_img"].to(self.device)
yandex_img = batch["yandex_img"].to(self.device)
target = batch["homography_params"].to(self.device)
output = self.model(google_img, yandex_img)
decoded_output = self.model.decode_output(output)
loss = self.criterion(output, target)
total_loss += loss.item() * google_img.size(0)
total_samples += google_img.size(0)
mse_components = self.criterion.compute_mse_components(decoded_output, target)
mse_trans_sum += mse_components['trans'] * google_img.size(0)
mse_angle_sum += mse_components['angle'] * google_img.size(0)
mse_scale_sum += mse_components['scale'] * google_img.size(0)
self.val_mse_trans.append(mse_trans_sum / total_samples)
self.val_mse_angle.append(mse_angle_sum / total_samples)
self.val_mse_scale.append(mse_scale_sum / total_samples)
return {"loss": total_loss / total_samples}
def train(self, num_epochs):
log_dir = config["output_dir"]
os.makedirs(log_dir, exist_ok=True)
self.writer = SummaryWriter(log_dir)
for epoch in range(1, num_epochs + 1):
train_metrics = self.train_epoch(epoch)
val_metrics = self.validate()
self.train_losses.append(train_metrics["loss"])
self.val_losses.append(val_metrics["loss"])
print(f"Train Loss: {train_metrics['loss']:.4f}, Val Loss: {val_metrics['loss']:.4f}")
print(f" MSE - Trans: {self.val_mse_trans[-1]:.4f}, Angle: {self.val_mse_angle[-1]:.4f}, Scale: {self.val_mse_scale[-1]:.4f}")
if val_metrics["loss"] < self.best_val_loss:
self.best_val_loss = val_metrics["loss"]
self.save_checkpoint(epoch, is_best=True)
print(f"Best model saved (val loss: {val_metrics['loss']:.4f})")
if epoch % config["save_every_n_epochs"] == 0:
self.save_checkpoint(epoch, is_best=False)
print(f"Checkpoint saved at epoch {epoch}")
self.writer.close()
def save_checkpoint(self, epoch, is_best=False):
ckpt_dir = os.path.join(config["output_dir"], "checkpoints")
os.makedirs(ckpt_dir, exist_ok=True)
ckpt = {"epoch": epoch, "model_state_dict": self.model.state_dict(), "val_loss": self.best_val_loss}
torch.save(ckpt, os.path.join(ckpt_dir, f"checkpoint_epoch_{epoch}.pt"))
if is_best:
torch.save(ckpt, os.path.join(ckpt_dir, "best_model.pt"))

59
models/SiaN/src/utils.py Normal file
View File

@@ -0,0 +1,59 @@
import cv2
import numpy as np
config = {
"data_dir": r"C:\Users\admin\Projects\autopilot\datasets\ya_go_maps\images",
"image_size": (256, 256),
"batch_size": 32,
"train_split": 0.8,
"num_workers": 0,
"epochs": 10,
"learning_rate": 2e-4,
"dropout_rate": 0.5,
"backbone": "resnet18",
"output_dir": r"C:\Users\admin\Projects\autopilot\models\SiaN\runs",
"save_every_n_epochs": 15,
}
def get_camera_matrix(w, h):
return np.array([[w / 2, 0, w / 2], [0, h / 2, h / 2], [0, 0, 1]], dtype=np.float32)
def generate_random_homography_params(angle_range=10, translation_range=0.1, scale_range=(0.9, 1.1)):
scale = np.random.uniform(*scale_range)
tx = np.random.uniform(-translation_range, translation_range)
ty = np.random.uniform(-translation_range, translation_range)
rx = np.radians(np.random.uniform(-angle_range, angle_range))
ry = np.radians(np.random.uniform(-angle_range, angle_range))
rz = np.radians(np.random.uniform(-angle_range, angle_range))
return np.array([tx, ty, rx, ry, rz, scale])
def homography_params_to_matrix(params, K):
tx, ty, rx, ry, rz, scale = params
cy, sy = np.cos(rz), np.sin(rz)
cp, sp = np.cos(ry), np.sin(ry)
cr, sr = np.cos(rx), np.sin(rx)
Rz = np.array([[cy, -sy, 0], [sy, cy, 0], [0, 0, 1]], dtype=np.float32)
Ry = np.array([[cp, 0, sp], [0, 1, 0], [-sp, 0, cp]], dtype=np.float32)
Rx = np.array([[1, 0, 0], [0, cr, -sr], [0, sr, cr]], dtype=np.float32)
T = np.array([[1, 0, tx], [0, 1, ty], [0, 0, scale]], dtype=np.float32)
return K @ Rx @ Ry @ Rz @ T @ np.linalg.inv(K)
def matrix_to_homography_params(H, K):
if hasattr(H, 'numpy'):
H = H.numpy()
K_inv = np.linalg.inv(K)
E = K_inv @ H @ K
scale = E[2, 2]
R_normalized = E / scale
rz = np.arctan2(R_normalized[1, 0], R_normalized[0, 0])
ry = np.arctan2(-R_normalized[2, 0], np.sqrt(R_normalized[2, 1]**2 + R_normalized[2, 2]**2))
rx = np.arctan2(R_normalized[2, 1], R_normalized[2, 2])
A = R_normalized[:2, :2]
correction = scale * np.array([R_normalized[0, 2], R_normalized[1, 2]])
tx, ty = np.linalg.solve(A, E[:2, 2] - correction)
return np.array([tx, ty, rx, ry, rz, scale], dtype=np.float32)

File diff suppressed because one or more lines are too long

View File

@@ -42,22 +42,45 @@ class Position:
f"roll={math.degrees(self.roll):.1f}°)"
)
def get_homography_matrix(self, K: np.ndarray = constants.K, sliding: bool = True) -> np.ndarray:
def __imul__(self, scalar: float):
self.x *= scalar
self.y *= scalar
self.z *= scalar
return self
def __mul__(self, scalar: float) -> 'Position':
pos = self.copy()
pos *= scalar
return pos
def __itruediv__(self, scalar: float):
self.x /= scalar
self.y /= scalar
self.z /= scalar
return self
def __truediv__(self, scalar: float) -> 'Position':
pos = self.copy()
pos /= scalar
return pos
def get_homography_matrix(self, K_in: np.ndarray = constants.K, K_out: np.ndarray | None = None, sliding: bool = True) -> np.ndarray:
""" Возвращает матрицу гомографии """
R = self.get_rotation_matrix()
T = self.get_translation_matrix()
T = self.get_translation_matrix(K_in)
if not sliding:
T[0, 2] = T[1, 2] = 0
return K @ R @ T @ np.linalg.inv(K)
if K_out is None: K_out = K_in
return K_out @ R @ T @ np.linalg.inv(K_in)
def copy(self) -> 'Position':
"""Создает полную копию объекта"""
return Position(self.x, self.y, self.z, self.yaw, self.pitch, self.roll)
def get_translation_matrix(self) -> np.ndarray:
def get_translation_matrix(self, K: np.ndarray = constants.K) -> np.ndarray:
return np.array([
[1, 0, self.x / constants._K_FOCUS_DISTANCE],
[0, 1, self.y / constants._K_FOCUS_DISTANCE],
[1, 0, self.x / K[0][0]],
[0, 1, self.y / K[0][0]],
[0, 0, self.z]
])
@@ -101,6 +124,13 @@ class Position:
R = np.array(R)
t = np.array(t)
# print(cv2.decomposeHomographyMat(inv(H), K))
# _, _, z, _ = cv2.decomposeHomographyMat(inv(H), K)
# print(z)
# z = z.copy()
# z *= constants._K_FOCUS_DISTANCE
# print(z)
T = inv(R) @ inv(K) @ H @ K
ind = np.array([A[2][0] ** 2 + A[2][1] ** 2 for A in T])
top_k = max(1, len(T) // 2)
@@ -116,14 +146,16 @@ class Position:
R = R[best_id]
rot = Rotation.from_matrix(R).as_euler('XYZ').flatten()
self.roll = rot[0]
self.pitch = rot[1]
self.roll = min(np.radians(5), max(np.radians(-5), rot[0]))
self.pitch = min(np.radians(5), max(np.radians(-5), rot[1]))
self.yaw = rot[2]
t = t[best_id].flatten()
self.x += -T[0] * constants._K_FOCUS_DISTANCE * self.z
self.y += T[1] * constants._K_FOCUS_DISTANCE * self.z
self.z = 1 + T[2]
self.x -= T[0] * constants._K_FOCUS_DISTANCE
self.y += T[1] * constants._K_FOCUS_DISTANCE
self.z = max(0.7, min(1.3, 1 + T[2]))
T[0] *= constants._K_FOCUS_DISTANCE
T[1] *= constants._K_FOCUS_DISTANCE
def apply(self, homography_matrix: np.ndarray, K = constants.K) -> 'Position':
"""Применяет матрицу трансформации для вычисления новой позиции и ориентации."""

View File

@@ -8,12 +8,13 @@ import numpy as np
from position import Position
from vision_chunk import VisionChunk
from yandex_map import YandexMap
from google_map import GoogleMap
import constants
import utility
class Simulator:
def __init__(self, yandex_map: YandexMap = None):
self.yandex_map = yandex_map
def __init__(self, online_map: YandexMap | GoogleMap = None):
self.online_map = online_map
# Используем новый конструктор с yaw, pitch, roll
self.pos = Position(x=0, y=0, z=1, yaw=0, pitch=0, roll=0)
@@ -35,24 +36,26 @@ class Simulator:
Возвращает квадратное изображение 700x700.
"""
img_array = np.array(image)
print(img_array.shape)
h, w, _ = img_array.shape
# Применяем трансформацию
pos = self.pos.copy()
pos.x = 0
pos.y = 0
K = utility.calc_camera_matrix(w, h)
K = constants.K
img_array = img_array[:constants.CHUNK_WIDTH, :constants.CHUNK_WIDTH]
transformed = cv2.warpPerspective(img_array, pos.get_homography_matrix(K), (constants.CHUNK_WIDTH, constants.CHUNK_WIDTH))
K_in = utility.calc_camera_matrix(w, h)
K_out = constants.K
H = pos.get_homography_matrix(K_in, K_out)
shape = (constants.CHUNK_WIDTH, constants.CHUNK_WIDTH)
transformed = cv2.warpPerspective(img_array, H, shape)
return Image.fromarray(transformed)
def update_trajectory(self, dx: float, dy: float):
"""Обновляет координаты дрона"""
self.pos.x += dx * self.pos.z
self.pos.y += dy * self.pos.z
self.pos.x += dx
self.pos.y += dy
def handle(self, dangle: float, velocity: float = 50) -> None:
"""
@@ -60,27 +63,17 @@ class Simulator:
dangle - изменение угла курса (радианы)
velocity - скорость движения
"""
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
html = self.yandex_map.driver.find_element(By.TAG_NAME, 'html')
action = ActionChains(self.yandex_map.driver)
action.move_to_element_with_offset(html, 200, 200)
action.click_and_hold()
# Обновляем yaw в объекте Position
self.pos.yaw += dangle
velocity = max(velocity, 10)
# Вычисляем смещение на основе текущего yaw
dx = math.cos(math.pi / 2 + self.pos.yaw) * velocity / self.pos.z
dy = math.sin(math.pi / 2 + self.pos.yaw) * velocity / self.pos.z
dx = int(math.cos(math.pi / 2 + self.pos.yaw) * velocity)
dy = int(math.sin(math.pi / 2 + self.pos.yaw) * velocity)
self.update_trajectory(dx, dy)
action.move_by_offset(-dx, dy)
action.release()
action.perform()
self.online_map.move(dx, dy)
def set_zoom(self, zoom_level: float):
"""Программное изменение масштаба"""
@@ -88,8 +81,7 @@ class Simulator:
def get_chunk(self) -> VisionChunk:
"""Получить текущий снимок с камеры дрона"""
png = self.yandex_map.driver.get_screenshot_as_png()
im = Image.open(BytesIO(png))
im = self.online_map.make_screenshot()
# Применяем перспективную трансформацию
transformed_im = self._apply_perspective_transform(im)

189
test_chunks.ipynb Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -29,3 +29,9 @@ class Timer:
self.elapsed = 0.
self.enabled = False
self.last_enabled = 0.
def loop(self) -> float:
v = self.get_diff()
self.stop()
self.start()
return v

24
todo.md
View File

@@ -2,16 +2,16 @@
[!] Проверка корректности выявления ориентира на кадре
[!] Исправление коррекции координат на основе сопоставления с ориентиром
[-] FPS счетчик
| [-] Оптимизация детекции точек
[-] Оформление статистики при тестовых запусках
[-] Проведение тестовых запусков
[-] Оформление отчета
[-] Эксперименты с разными детекторами (SIFT, KAZE)
[+] FPS счетчик
| [+] Оптимизация детекции точек
[+] Оформление статистики при тестовых запусках
[+] Проведение тестовых запусков
[+] Оформление отчета
[+] Эксперименты с разными детекторами (SIFT, KAZE)
[?] Изменение масштаба во время полёта, обработка этой трансформации
[?] Поворот ориентиров
[?] Ограничение выбора точек при построении маршрута, чтобы ориентиры полностью попадали в кадр
[+] Изменение масштаба во время полёта, обработка этой трансформации
[+] Поворот ориентиров
[+] Ограничение выбора точек при построении маршрута, чтобы ориентиры полностью попадали в кадр
[+] График межкадрового смещения
| [+] График межкадровых смещениях по версии матрицы гомографии
@@ -19,6 +19,6 @@
| [+] Исследовать причину погрешности при развороте
[+] Устранение большой погрешности при повороте
[ ] Переделать ключевые точки -> Optical Flow
[ ] Добавить перспективу
[ ] Эталоны на Google Maps, полёт тот же
[+] Переделать ключевые точки -> Optical Flow
[+] Добавить перспективу
[+] Эталоны на Google Maps, полёт тот же

View File

@@ -1,7 +1,11 @@
from PIL import Image
from datetime import datetime
from urllib.parse import parse_qs, urlparse, unquote
import constants
import cv2
import numpy as np
import constants
import re
def cv2_to_pil(cv_image: np.ndarray) -> Image.Image:
"""
@@ -19,11 +23,10 @@ def get_normals(H: np.ndarray, R: np.ndarray, T: np.ndarray) -> np.ndarray:
n = cv2.decomposeHomographyMat(H, constants.K, R, T)
return n
def estimate_transformation_matrix(src_pts: np.ndarray, dst_pts: np.ndarray) -> tuple[np.ndarray, float | None]:
def estimate_transformation_matrix(src_pts: np.ndarray, dst_pts: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Оценивает матрицу трансформации на основе сопоставленных точек"""
# Используем RANSAC для оценки матрицы гомографии
H, _ = cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 3.0, maxIters=1000)
return H
return cv2.findHomography(src_pts, dst_pts, cv2.RANSAC, 3.0, maxIters=1000)
def calc_camera_matrix(w: float, h: float):
f = constants._K_FOCUS_DISTANCE
@@ -32,3 +35,135 @@ def calc_camera_matrix(w: float, h: float):
[0, f, h / 2],
[0, 0, 1]
])
def generate_folder_name():
"""
Генерирует название для папки с текущей датой и временем до секунд.
Формат: YYYY-MM-DD_HH-MM-SS
"""
now = datetime.now()
folder_name = now.strftime("%Y-%m-%d_%H-%M-%S")
return folder_name
def parse_yandex_maps_url(url):
"""
Парсит URL Яндекс.Карт и извлекает lat, lon и zoom
Формат: ?ll=lon,lat&z=zoom
"""
# Декодируем URL (на случай %2C вместо запятых)
url = unquote(url)
# Парсим URL
parsed_url = urlparse(url)
params = parse_qs(parsed_url.query)
if 'll' in params and 'z' in params:
# ll содержит "lon,lat"
ll_value = params['ll'][0]
lat, lon = map(float, ll_value.split(','))
zoom = int(params['z'][0])
return {
'lat': lat,
'lon': lon,
'zoom': zoom
}
return None
def parse_google_maps_url(url):
"""
Парсит URL Google Maps и извлекает lat, lon и zoom
Формат: /@lat,lon,zoom[m|z]
"""
pattern = r'/@([-\d.]+),([-\d.]+),(\d+)([mz])'
match = re.search(pattern, url)
if match:
lon = float(match.group(1))
lat = float(match.group(2))
zoom_value = int(match.group(3))
zoom_unit = match.group(4)
return {
'lat': lat,
'lon': lon,
'zoom': zoom_value,
'zoom_unit': zoom_unit
}
return None
def google_map_js_move_script(dx, dy) -> str:
return """
async function sleep(ms) {
return new Promise((resolve, reject) => {
setTimeout(() => resolve(), ms);
});
}
async function simulateDrag(element, offsetX, offsetY) {
const rect = element.getBoundingClientRect();
const startX = rect.left + rect.width / 2;
const startY = rect.top + rect.height / 2;
const step = 10
const endX = startX + offsetX + step;
const endY = startY + offsetY + step;
element.dispatchEvent(new MouseEvent('mousedown', {
view: window,
bubbles: true,
cancelable: true,
clientX: startX,
clientY: startY,
button: 0
}));
let currentX = startX;
let currentY = startY;
function move(stepX, stepY) {
currentX += stepX;
currentY += stepY;
document.dispatchEvent(new MouseEvent('mousemove', {
view: window,
bubbles: true,
cancelable: false,
clientX: currentX,
clientY: currentY
}));
}
await sleep(50);
move(step, step)
while (currentX != endX || currentY != endY) {
await sleep(50);
const stepX = Math.min(step, Math.max(-step, endX - currentX));
const stepY = Math.min(step, Math.max(-step, endY - currentY));
move(stepX, stepY);
}
await sleep(50)
document.dispatchEvent(new MouseEvent('mouseup', {
view: window,
bubbles: true,
cancelable: false,
clientX: endX,
clientY: endY,
button: 0
}));
}
{
const canvas = document.querySelector('canvas.H1VXrf.JRr1M.DnOnV');
""" + f"simulateDrag(canvas, {int(-dx)}, {int(dy)});" + """
}
"""

View File

@@ -1,10 +1,13 @@
import constants
import cv2
import json
import numpy as np
from PIL import Image
from dataclasses import dataclass, field
from pathlib import Path
from position import Position
from timer import Timer
from typing import Literal, Optional, Tuple
from PIL import Image
FeatureMethod = Literal["orb", "sift", "akaze", "brisk"]
DEFAULT_METHOD = "orb"
@@ -14,6 +17,7 @@ class VisionChunk:
image: Image.Image
feature_method: FeatureMethod = DEFAULT_METHOD
pos: Optional[Position] = field(default=None, init=False)
keypoints: Optional[list] = field(default=None, init=False)
descriptors: Optional[np.ndarray] = field(default=None, init=False)
_detector: Optional[cv2.Feature2D] = field(default=None, init=False, repr=False)
@@ -27,12 +31,12 @@ class VisionChunk:
self._detector = cv2.ORB_create(
nfeatures=1000,
scaleFactor=1.2,
nlevels=32,
nlevels=16,
edgeThreshold=31,
firstLevel=0,
WTA_K=2,
patchSize=31,
fastThreshold=20,
fastThreshold=10,
)
elif self.feature_method == "sift":
self._detector = cv2.SIFT_create(
@@ -70,30 +74,55 @@ class VisionChunk:
return self._matcher
def _preprocess(self, img_np: np.ndarray) -> np.ndarray:
"""CLAHE предобработка для улучшения контраста"""
"""Предобработка для улучшения сопоставления между снимками разного времени"""
if len(img_np.shape) == 3:
gray = cv2.cvtColor(img_np, cv2.COLOR_RGB2GRAY)
else:
gray = img_np
# Гауссовское размытие для подавления шума и мелких различий
# blurred = cv2.GaussianBlur(gray, (5, 5), 1.0)
# CLAHE для выравнивания контраста между снимками
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
return clahe.apply(gray)
enhanced = clahe.apply(gray)
# Опционально: нормализация гистограммы для устранения различий в освещении
normalized = cv2.normalize(enhanced, None, 0, 255, cv2.NORM_MINMAX)
return normalized
def compute_keypoints(self, force: bool = False) -> Tuple[list[cv2.KeyPoint], Optional[np.ndarray]]:
if self.keypoints is not None and self.descriptors is not None and not force:
return self.keypoints, self.descriptors
timer = Timer()
timer.start()
detector = self._get_detector()
if constants.DEBUG_FPS:
print(f"[VC-DETECTION]: get_detector: {timer.loop() * 1000:.2f} ms")
# PIL -> OpenCV (RGB->BGR)
img_np = np.array(self.image)
if img_np.ndim == 3 and img_np.shape[2] == 3:
img_np = cv2.cvtColor(img_np, cv2.COLOR_RGB2BGR)
if constants.DEBUG_FPS:
print(f"[VC-DETECTION]: converting: {timer.loop() * 1000:.2f} ms")
# CLAHE предобработка
preprocessed = self._preprocess(img_np)
if constants.DEBUG_FPS:
print(f"[VC-DETECTION]: preprocess: {timer.loop() * 1000:.2f} ms")
keypoints, descriptors = detector.detectAndCompute(preprocessed, None)
if constants.DEBUG_FPS:
print(f"[VC-DETECTION]: detect and compute: {timer.loop() * 1000:.2f} ms")
# Получаем массив response для всех точек
responses = np.array([kp.response for kp in keypoints])
@@ -104,6 +133,9 @@ class VisionChunk:
best_keypoints = [keypoints[i] for i in top_indices]
best_descriptors = descriptors[top_indices]
if constants.DEBUG_FPS:
print(f"[VC-DETECTION]: filtration: {timer.loop() * 1000:.2f} ms")
self.keypoints = best_keypoints
self.descriptors = best_descriptors
return self.keypoints, self.descriptors
@@ -122,15 +154,29 @@ class VisionChunk:
Возвращает: src_pts, dst_pts, good_matches, kp1, kp2 (отцентрированные координаты)
"""
# Вычисляем keypoints для обоих
timer = Timer()
timer.start()
kp1, des1 = self.compute_keypoints()
if constants.DEBUG_FPS:
print(f"[VC-KEYPOINTS]: computing 1: {timer.loop() * 1000:.2f} ms")
kp2, des2 = other.compute_keypoints()
if constants.DEBUG_FPS:
print(f"[VC-KEYPOINTS]: computing 2: {timer.loop() * 1000:.2f} ms")
if des1 is None or des2 is None or len(kp1) < 4 or len(kp2) < 4:
return None, None, None, None, None
# kNN matching + Lowe ratio test
matcher = self._get_matcher()
matches_knn = matcher.knnMatch(des1, des2, k=2)
if constants.DEBUG_FPS:
print(f"[VC-KEYPOINTS]: matching: {timer.loop() * 1000:.2f} ms")
good_matches: list[cv2.DMatch] = []
for m_n in matches_knn:
@@ -147,15 +193,6 @@ class VisionChunk:
if len(good_matches) < 4:
return None, None, None, None, None
# Центр изображений
img1_cv = self.to_cv2_gray()
img2_cv = other.to_cv2_gray()
h1, w1 = img1_cv.shape
h2, w2 = img2_cv.shape
cx1, cy1 = w1 // 2, h1 // 2
cx2, cy2 = w2 // 2, h2 // 2
# Отцентрированные координаты (x_rel, y_rel)
src_pts = []
dst_pts = []
@@ -169,6 +206,9 @@ class VisionChunk:
src_pts = np.float32(src_pts).reshape(-1, 1, 2)
dst_pts = np.float32(dst_pts).reshape(-1, 1, 2)
if constants.DEBUG_FPS:
print(f"[VC-KEYPOINTS]: filtration: {timer.loop() * 1000:.2f} ms")
return src_pts, dst_pts, good_matches, kp1, kp2
def to_cv2_gray(self) -> np.ndarray:

View File

@@ -3,14 +3,16 @@
Модуль для управления общим окном визуализации
"""
from PIL import Image
from enum import Enum
from scipy.interpolate import make_interp_spline
import cv2
import matplotlib
import matplotlib.axes
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
from enum import Enum
import cv2
from PIL import Image
import matplotlib
# Настройки matplotlib
matplotlib.use('TkAgg')
@@ -93,14 +95,14 @@ class VisualizationManager:
self.ax_matches.axis('off')
# Сопоставление точек (средний средний угол)
self.ax_chunk_matches = self.fig.add_subplot(gs[1, 2])
self.ax_chunk_matches = self.fig.add_subplot(gs[1, 1:3])
self.ax_chunk_matches.set_title('Chunk Matching')
self.ax_chunk_matches.axis('off')
# Визуализация движения ключевых точек (левый нижний угол)
self.ax_motion_vectors = self.fig.add_subplot(gs[1, 1])
self.ax_motion_vectors.set_title('Motion Vectors - Движение ключевых точек')
self.ax_motion_vectors.axis('off')
# self.ax_motion_vectors = self.fig.add_subplot(gs[1, 1])
# self.ax_motion_vectors.set_title('Motion Vectors - Движение ключевых точек')
# self.ax_motion_vectors.axis('off')
# Визуализация движения ключевых точек на основе матрицы гомографии
self.ax_motion_gomography = self.fig.add_subplot(gs[0, 1])
@@ -157,7 +159,7 @@ class VisualizationManager:
# Рисуем текущую целевую точку
if self.target_idx < len(self.target_pts):
pt = self.target_pts[self.target_idx]
self.ax_global_map.plot(pt[0], pt[1], 'yo', markersize=8, label='Цель (0, 0)')
self.ax_global_map.plot(pt[0], pt[1], 'yo', markersize=8, label='Цель')
self.ax_global_map.legend()
@@ -208,7 +210,37 @@ class VisualizationManager:
self.ax_error_plot.grid(True, alpha=0.3)
if len(self.error_times) > 1:
self.ax_error_plot.plot(self.error_times, self.position_errors, 'b-', linewidth=2)
# Оригинальный график (более прозрачный)
self.ax_error_plot.plot(self.error_times, self.position_errors, 'b-',
linewidth=1, alpha=0.4, label='Погрешность данных')
if len(self.error_times) > 5:
# Сглаженный график
smoothed_times = np.linspace(self.error_times[0], self.error_times[-1], 300)
spl = make_interp_spline(self.error_times, self.position_errors, k=3)
smoothed_errors = spl(smoothed_times)
self.ax_error_plot.plot(smoothed_times, smoothed_errors, 'orange',
linewidth=2, label='Сглаженный тренд')
# if len(self.position_errors) > 5: # Достаточно данных для сглаживания
# window_size = min(11, len(self.position_errors) // 3) # Адаптивный размер окна
# if window_size % 2 == 0: # Должен быть нечетным
# window_size += 1
# # Метод скользящего среднего
# smoothed_errors = np.convolve(
# self.position_errors,
# np.ones(window_size) / window_size,
# mode='valid'
# )
# # Корректируем временную ось для сглаженных данных
# offset = (window_size - 1) // 2
# smoothed_times = self.error_times[offset:offset + len(smoothed_errors)]
self.ax_error_plot.legend(loc='upper right')
# Автоматически масштабируем оси
if len(self.position_errors) > 0:
@@ -219,6 +251,7 @@ class VisualizationManager:
else:
self.ax_error_plot.set_ylim(0, 1)
def update_matches(self, img1: np.ndarray, img2: np.ndarray,
kp1, kp2, matches, transformation_info=None):
"""Обновляет визуализацию сопоставления точек"""

View File

@@ -1,16 +1,17 @@
import math
from io import BytesIO
from time import sleep
import os
import cv2
import numpy as np
from PIL import Image
from io import BytesIO
from selenium.webdriver.common.actions.wheel_input import ScrollOrigin
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.action_chains import ActionChains
from time import sleep
import constants
import cv2
import math
import numpy as np
import os
import utility
def generateURL(lat: float, lon: float, zoom: int):
return f"https://yandex.ru/maps/43/kazan/?l=sat&ll={lat}%2C{lon}&z={zoom}"
@@ -24,6 +25,7 @@ class YandexMap:
self.initial_lat = initial_lat
self.initial_lon = initial_lon
self.initial_zoom = initial_zoom
self.pixel_ratio = constants.YANDEX_PIXEL_RATIO[self.initial_zoom]
options = webdriver.ChromeOptions()
# options.add_experimental_option("detach", True)
@@ -32,9 +34,8 @@ class YandexMap:
self.driver.maximize_window()
sleep(2)
action = ActionChains(self.driver)
# Закрытие левой панели
action = ActionChains(self.driver)
action.click(self.driver.find_element(By.CLASS_NAME, 'sidebar-toggle-button'))
action.perform()
@@ -43,8 +44,25 @@ class YandexMap:
self.driver.execute_script("arguments[0].remove();", self.driver.find_element(By.XPATH, "//nav[@class='map-controls']"))
self.driver.execute_script("arguments[0].remove();", self.driver.find_element(By.XPATH, "//footer"))
self.move(39, -9)
sleep(0.2)
def open(self, lat, lon, zoom):
self.initial_lat = lat
self.initial_lon = lon
self.initial_zoom = zoom
self.pixel_ratio = constants.YANDEX_PIXEL_RATIO[self.initial_zoom]
self.driver.get(generateURL(lat, lon, zoom))
sleep(2)
# Закрытие левой панели
action = ActionChains(self.driver)
action.click(self.driver.find_element(By.CLASS_NAME, 'sidebar-toggle-button'))
action.perform()
self.move(39, -9)
def save_photo(self, filename: str) -> bytes:
return self.driver.save_screenshot(filename)
@@ -68,51 +86,26 @@ class YandexMap:
if i != count - 1:
sleep(0.25)
def make_as_center(self, x: float, y: float):
def move(self, dx: float, dy: float):
html = self.driver.find_element(By.TAG_NAME, 'html')
action = ActionChains(self.driver)
action.move_to_element_with_offset(html, 0, 0)
action.click_and_hold()
dx = (x - 0.5) * self.get_size()[0]
dy = (0.5 - y) * self.get_size()[1]
print(dx, dy)
action.move_by_offset(-dx, dy)
action.release()
action.perform()
def make_screenshot(self, x: float, y: float, width: float, height: float) -> cv2.typing.MatLike:
# Сохраняем скриншот
self.save_photo("temp.png")
# Загружаем изображение
image = cv2.imread("temp.png")
if image is None:
raise ValueError("Не удалось загрузить изображение temp.png")
# Получаем размеры исходного изображения
img_height, img_width = image.shape[:2]
# Преобразуем относительные координаты в абсолютные пиксели
center_x = int(x * img_width)
center_y = int(y * img_height)
crop_width = int(width * img_width)
crop_height = int(height * img_height)
# Вычисляем координаты прямоугольника для кадрирования
x1 = max(0, center_x - crop_width // 2)
y1 = max(0, center_y - crop_height // 2)
x2 = min(img_width, center_x + crop_width // 2)
y2 = min(img_height, center_y + crop_height // 2)
# Проверяем, что прямоугольник имеет положительные размеры
if x2 <= x1 or y2 <= y1:
raise ValueError("Некорректные размеры для кадрирования")
# Кадрируем изображение
cropped_image = image[y1:y2, x1:x2]
# Если нужно вернуть изображение как результат функции:
return cropped_image
def make_as_center(self, x: float, y: float):
dx = (x - 0.5) * self.get_size()[0]
dy = (0.5 - y) * self.get_size()[1]
self.move(dx, dy)
def make_screenshot(self) -> Image.Image:
png = self.driver.get_screenshot_as_png()
im = Image.open(BytesIO(png))
return utility.cv2_to_pil(np.array(im)[:, :])
def get_geolocation(self):
current_url = self.driver.current_url
return utility.parse_yandex_maps_url(current_url)