Files
radar_system/python_app/gui/object_calibration_dialog.py
T
2026-10-05 17:39:40 +03:00

294 lines
15 KiB
Python

"""Live reflector measurements and target-phase diagnostics for MIMO calibration."""
from __future__ import annotations
import copy
import math
import numpy as np
import pyqtgraph as pg
from PyQt6.QtCore import Qt, pyqtSignal
from PyQt6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDoubleSpinBox, QFormLayout, QGroupBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QPushButton, QTableWidget, QTableWidgetItem,
QVBoxLayout, QWidget,
)
from python_app.models.dataset_model import SweepCollection
class ObjectCalibrationDialog(QDialog):
"""Collect full-matrix sweeps, then associate each group with measured XYZ."""
capture_requested = pyqtSignal()
calculate_requested = pyqtSignal()
cancel_requested = pyqtSignal()
def __init__(self, set_name: str, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("MIMO Object Calibration")
self.resize(1000, 850)
self._captures: list[dict] = []
self._config: dict | None = None
self._capture_pending = self._calculating = False
self._calibration: dict | None = None
layout = QVBoxLayout(self)
name_row = QHBoxLayout()
name_row.addWidget(QLabel("Set name", self))
self._set_name_input = QLineEdit(set_name, self)
name_row.addWidget(self._set_name_input)
layout.addLayout(name_row)
explanation = QLabel(
"Снимите данные, затем введите физически измеренные координаты центра отражателя: "
"X — поперёк, Y — высота над землёй, Z — вдоль земли. "
"Обычные S21-калибровка и выбранный референс уже применены; "
"существующая MIMO-поправка при съёмке отключена.", self,
)
explanation.setWordWrap(True)
layout.addWidget(explanation)
self._table = QTableWidget(0, 5, self)
self._table.setHorizontalHeaderLabels(["Набор", "Свипов", "X (м)", "Y (м)", "Z (м)"])
horizontal, vertical = self._table.horizontalHeader(), self._table.verticalHeader()
assert horizontal is not None and vertical is not None
horizontal.setSectionResizeMode(QHeaderView.ResizeMode.Stretch)
vertical.hide()
self._table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self._table.itemChanged.connect(self._measurements_changed)
layout.addWidget(self._table)
actions = QHBoxLayout()
self._capture_button = QPushButton("Снять данные", self)
self._capture_button.clicked.connect(self.capture_requested.emit)
self._remove_button = QPushButton("Удалить выбранный набор", self)
self._remove_button.clicked.connect(self._remove_capture)
actions.addWidget(self._capture_button)
actions.addWidget(self._remove_button)
actions.addStretch(1)
layout.addLayout(actions)
self._settings: dict[str, QDoubleSpinBox | QCheckBox | QComboBox] = {}
form = QFormLayout()
for key, title, default, low, high in (
("sphere_radius_m", "Радиус шара, м (0 — точка)", .16, 0, 100),
("antenna_height_m", "Высота начала координат антенн над землёй, м", .92, 0, 100),
("pitch_deg", "Наклон радара вниз, °", 10, -90, 90),
):
form.addRow(title, self._number(key, default, low, high))
layout.addLayout(form)
advanced = QGroupBox("Параметры расчёта", self)
advanced.setFlat(True)
advanced.setCheckable(True)
advanced.setChecked(False)
advanced_layout = QVBoxLayout(advanced)
advanced_content = QWidget(advanced)
advanced_form = QFormLayout(advanced_content)
for key, title, default, low, high in (
("f_min", "Нижняя частота оценки, МГц", 2000, 0, 100000),
("f_max", "Верхняя частота оценки, МГц", 6000, 0, 100000),
("gate_half_m", "Полуширина окна эха по пути, м", .15, .000001, 100),
("gate_taper_m", "Плавный край окна, м", .03, .000001, 100),
("coarse_range_search_m", "Грубый поиск по пути, ±м (0 — выкл.)", .6, 0, 100),
("refine_range_m", "Уточнение координат, ±м", .1, 0, 100),
):
advanced_form.addRow(title, self._number(key, default, low, high))
axes = QWidget(advanced_content)
axes_layout = QHBoxLayout(axes)
axes_layout.setContentsMargins(0, 0, 0, 0)
self._refine_axes = {}
for axis in "xyz":
checkbox = QCheckBox(axis.upper(), axes)
checkbox.setChecked(True)
checkbox.toggled.connect(self._measurements_changed)
self._refine_axes[axis] = checkbox
axes_layout.addWidget(checkbox)
advanced_form.addRow("Уточнять координаты", axes)
phase_model = QComboBox(advanced_content)
phase_model.addItems(["const", "linear", "poly3", "raw"])
phase_model.setCurrentText("linear")
phase_model.currentTextChanged.connect(self._measurements_changed)
self._settings["phase_model"] = phase_model
advanced_form.addRow("Модель фазы антенны", phase_model)
amplitude = QCheckBox("Выравнивать также амплитуды", advanced_content)
amplitude.toggled.connect(self._measurements_changed)
self._settings["apply_amplitude"] = amplitude
advanced_form.addRow(amplitude)
advanced_content.hide()
advanced.toggled.connect(advanced_content.setVisible)
advanced_layout.addWidget(advanced_content)
layout.addWidget(advanced)
self._target_combo = QComboBox(self)
self._target_combo.currentIndexChanged.connect(self._draw_phase_effect)
layout.addWidget(self._target_combo)
self._phase_plot = pg.PlotWidget(background="#0f141c", enableMenu=False)
self._phase_plot.setLabel("left", "Target phase relative to first pair", units="deg")
self._phase_plot.setLabel("bottom", "Tx / Rx pair")
self._phase_plot.showGrid(x=True, y=True, alpha=.2)
self._phase_plot.setMinimumHeight(220)
self._phase_plot.addLegend()
layout.addWidget(self._phase_plot)
self._quality_label = QLabel("Фазы вкладов пар появятся после расчёта.", self)
self._quality_label.setWordWrap(True)
layout.addWidget(self._quality_label)
self._status_label = QLabel("Готово к съёмке", self)
self._status_label.setWordWrap(True)
layout.addWidget(self._status_label)
footer = QHBoxLayout()
self._calculate_button = QPushButton("Рассчитать и сохранить", self)
self._calculate_button.clicked.connect(self.calculate_requested.emit)
self._cancel_button = QPushButton("Отменить операцию", self)
self._cancel_button.clicked.connect(self.cancel_requested.emit)
close_button = QPushButton("Закрыть", self)
close_button.clicked.connect(self.reject)
footer.addWidget(self._calculate_button)
footer.addWidget(self._cancel_button)
footer.addStretch(1)
footer.addWidget(close_button)
layout.addLayout(footer)
self._update_actions()
def _number(self, key: str, value: float, low: float, high: float) -> QDoubleSpinBox:
field = QDoubleSpinBox(self)
field.setDecimals(6)
field.setRange(low, high)
field.setSingleStep(100 if key in {"f_min", "f_max"} else .1 if key == "pitch_deg" else .01)
field.setValue(value)
field.valueChanged.connect(self._measurements_changed)
self._settings[key] = field
return field
def add_capture(self, collections: list[SweepCollection], config: dict) -> None:
"""Freeze a captured group before the operator enters its target coordinates."""
if not collections:
raise ValueError("Не получены свипы отражателя")
if self._config is not None and any(
config.get(key) != self._config.get(key) for key in ("radar", "gpr", "preprocess")
):
raise ValueError("Параметры радара, геометрия или препроцессинг изменились между наборами")
self._config = copy.deepcopy(config)
self._captures.append({"collections": copy.deepcopy(collections)})
row = self._table.rowCount()
self._table.insertRow(row)
for column, value in enumerate((str(row + 1), str(len(collections)), "", "", "")):
item = QTableWidgetItem(value)
if column < 2:
item.setFlags(item.flags() & ~Qt.ItemFlag.ItemIsEditable)
self._table.setItem(row, column, item)
self._table.setCurrentCell(row, 2)
self.set_status("Данные сняты. Введите координаты центра отражателя для нового набора.")
self._update_actions()
def calculation_inputs(self) -> tuple[list[dict], dict, dict]:
"""Validate XYZ after capture; expose a frozen recipe to the worker."""
if not self._captures or self._config is None:
raise ValueError("Сначала снимите данные отражателя")
groups = []
for row, capture in enumerate(self._captures):
try:
coordinates = []
for column in (2, 3, 4):
item = self._table.item(row, column)
assert item is not None
coordinates.append(float(item.text().strip()))
target = tuple(coordinates)
except ValueError as exc:
raise ValueError(f"Введите X, Y и Z центра отражателя для набора {row + 1}") from exc
if not all(math.isfinite(value) for value in target):
raise ValueError(f"Координаты набора {row + 1} должны быть конечными числами")
groups.append({**capture, "target_scene_m": target})
settings: dict[str, float | bool | str | tuple[str, ...] | tuple[int, ...]] = {
key: field.value() * (1e6 if key in {"f_min", "f_max"} else 1) if isinstance(field, QDoubleSpinBox) else
field.isChecked() if isinstance(field, QCheckBox) else field.currentText()
for key, field in self._settings.items()
}
settings["refine_axes"] = tuple(axis for axis, field in self._refine_axes.items() if field.isChecked())
traces = self._captures[0]["collections"][0].traces
settings["input_idx"] = tuple(sorted({trace.combo.input for trace in traces}))
settings["output_idx"] = tuple(sorted({trace.combo.output for trace in traces}))
return groups, copy.deepcopy(self._config), settings
def set_name(self) -> str:
return self._set_name_input.text().strip()
def set_capture_pending(self, pending: bool) -> None:
self._capture_pending = pending
self._update_actions()
def set_calculating(self, calculating: bool) -> None:
self._calculating = calculating
self._update_actions()
def set_status(self, message: str) -> None:
self._status_label.setText(message)
def show_calibration(self, calibration: dict) -> None:
self._calibration = calibration
self._target_combo.clear()
for index, target in enumerate(calibration.get("meta", {}).get("targets", [])):
xyz = target.get("nominal_scene_m", [])
self._target_combo.addItem(f"Набор {index + 1}: {xyz}")
self._draw_phase_effect()
def _draw_phase_effect(self, *_args) -> None:
self._phase_plot.clear()
if self._calibration is None:
return
diagnostics = self._calibration.get("meta", {}).get("diagnostics", [])
index = self._target_combo.currentIndex()
if not 0 <= index < len(diagnostics):
self._quality_label.setText("У этого набора нет диагностики фаз в точке цели.")
return
diagnostic = diagnostics[index]
phases = np.array([diagnostic["phase_before_deg"], diagnostic["phase_after_deg"]]).reshape(2, -1)
before, after = (phases - phases[0, 0] + 180) % 360 - 180
pairs = [(output, input_pos) for output in self._calibration["tx_output_pos"]
for input_pos in self._calibration["rx_input_pos"]]
xx = np.arange(len(pairs))
for values, color, label in ((before, "#ed7d31", "До"), (after, "#42c7a5", "После")):
self._phase_plot.plot(xx, values, pen=pg.mkPen(color, width=1.5), symbol="o",
symbolBrush=color, symbolPen=color, name=label)
step = max(1, len(pairs) // 16)
self._phase_plot.getAxis("bottom").setTicks([[
(k, f"o{pairs[k][0]}/i{pairs[k][1]}") for k in range(0, len(pairs), step)
]])
self._quality_label.setText(
f"Согласованность фаз в точке цели: {diagnostic['coherence_before']:.3f} "
f"→ {diagnostic['coherence_after']:.3f} (1 — все вклады в фазе)."
)
def _remove_capture(self) -> None:
row = self._table.currentRow()
if row >= 0:
self._captures.pop(row)
self._table.removeRow(row)
for index in range(self._table.rowCount()):
item = self._table.item(index, 0)
assert item is not None
item.setText(str(index + 1))
if not self._captures:
self._config = None
self._measurements_changed()
def _measurements_changed(self, *_args) -> None:
self._calibration = None
if hasattr(self, "_phase_plot"):
self._phase_plot.clear()
self._target_combo.clear()
self._quality_label.setText("Параметры изменены — требуется новый расчёт.")
def _update_actions(self) -> None:
busy = self._capture_pending or self._calculating
self._capture_button.setEnabled(not busy)
self._remove_button.setEnabled(not busy and bool(self._captures))
self._calculate_button.setEnabled(not busy and bool(self._captures))
self._cancel_button.setEnabled(busy)
self._table.setEnabled(not busy)
self._set_name_input.setEnabled(not busy)
for field in [*self._settings.values(), *self._refine_axes.values()]:
field.setEnabled(not busy)
def reject(self) -> None:
self.cancel_requested.emit()
super().reject()
def closeEvent(self, event) -> None: # noqa: N802
self.cancel_requested.emit()
super().closeEvent(event)