Embed background images in mapping files

- встраивать исходное изображение подложки в JSON вместе с метаданными и контрольной суммой
- сохранять геометрию и параметры встроенной подложки при повторном редактировании
- показывать значение «Встроенная» в диалоге подложки до выбора замены
- загружать встроенное изображение непосредственно из JSON на холст
- защищать существующий файл схемы при ошибке чтения подложки
- расширить автоматические проверки до 161 сценария
This commit is contained in:
Данил Ярош 2026-09-08 15:21:33 +03:00
parent f2108e053f
commit 0d4552ef35
6 changed files with 256 additions and 26 deletions

View File

@ -126,6 +126,8 @@ python -m venv .venv
JSON схемы хранит `coordinate_unit` и `grid_step_mm`. При первом открытии старого файла без единицы координат приложение определяет её один раз и затем сохраняет явно. Сохранение выполняется через временный файл и атомарную замену, поэтому исходный JSON не повреждается при ошибке записи.
Если у схемы есть подложка, при сохранении её исходное PNG- или JPEG-изображение встраивается в секцию `background.embedded_image` внутри JSON вместе с именем, MIME-типом и контрольной суммой. После этого схема не зависит от внешнего файла, а размеры и параметры подложки сохраняются при редактировании и переносе JSON. Для уже встроенного и не заменённого изображения диалог `Подложка` показывает в поле пути значение `Встроенная`. Чтобы заменить его, нажмите `Выбрать` и укажите новый файл; новое изображение будет встроено при следующем сохранении схемы.
В `vm_map.h` используются физические адреса из GUI:
- `strip = line_id - 1`

View File

@ -24,13 +24,21 @@ except ImportError:
class BackgroundDialog(QDialog):
EMBEDDED_PATH_LABEL = "Встроенная"
def __init__(self, background: BackgroundImage, parent=None) -> None: # noqa: ANN001
super().__init__(parent)
self.setWindowTitle("Подложка")
self.setMinimumWidth(460)
self.path = QLineEdit(background.path)
self._source_background = background
self._keep_embedded_image = background.has_embedded_image()
self.path = QLineEdit(self.EMBEDDED_PATH_LABEL if self._keep_embedded_image else background.path)
self.path.setPlaceholderText("PNG или JPEG")
self.path.setReadOnly(self._keep_embedded_image)
if self._keep_embedded_image:
source_name = background.embedded_image_name or "изображение"
self.path.setToolTip(f"{source_name} встроено в JSON схемы")
browse = QPushButton("Выбрать")
browse.clicked.connect(self._browse)
path_row = QWidget()
@ -69,14 +77,19 @@ class BackgroundDialog(QDialog):
form.addRow(buttons)
def background(self) -> BackgroundImage:
embedded = self._source_background if self._keep_embedded_image else BackgroundImage()
return BackgroundImage(
path=self.path.text().strip(),
path="" if self._keep_embedded_image else self.path.text().strip(),
offset_x=self.offset_x.value(),
offset_y=self.offset_y.value(),
scale_x_percent=self.scale_x.value(),
scale_y_percent=self.scale_y.value(),
opacity_percent=self.opacity.value(),
visible=self.visible.isChecked(),
embedded_image_base64=embedded.embedded_image_base64,
embedded_image_mime_type=embedded.embedded_image_mime_type,
embedded_image_name=embedded.embedded_image_name,
embedded_image_sha256=embedded.embedded_image_sha256,
)
def _reset_scale(self) -> None:
@ -93,7 +106,13 @@ class BackgroundDialog(QDialog):
"Изображения (*.png *.jpg *.jpeg);;Все файлы (*.*)",
)
if selected:
self.path.setText(selected)
self._set_external_path(selected)
def _set_external_path(self, path: str) -> None:
self._keep_embedded_image = False
self.path.setReadOnly(False)
self.path.setToolTip("")
self.path.setText(path)
@staticmethod
def _spin(minimum: float, maximum: float, value: float, suffix: str) -> QDoubleSpinBox:

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import math
from pathlib import Path
from PySide6.QtCore import QPointF, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtCore import QByteArray, QBuffer, QIODevice, QPointF, QRectF, QSize, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QBrush, QImage, QImageReader, QPainter, QPainterPath, QPen, QPixmap, QTransform
from PySide6.QtWidgets import (
QApplication,
@ -93,7 +93,7 @@ class LedCanvas(QGraphicsView):
self._background_handles: dict[str, QGraphicsRectItem] = {}
self._background_edit_mode = False
self._background_drag: dict | None = None
self._background_pixmap_cache_key: tuple[str, int | None] | None = None
self._background_pixmap_cache_key: tuple[str, str | int | None] | None = None
self._background_pixmap_cache: QPixmap | None = None
self._background_loaded = False
self._direction_arrows_visible = True
@ -532,10 +532,7 @@ class LedCanvas(QGraphicsView):
self._remove_background_items()
if self.document is None or not self.document.background.is_enabled():
return
image_path = Path(self.document.background.path)
if not image_path.is_absolute() and self.document.path is not None:
image_path = self.document.path.parent / image_path
pixmap = self._load_background_pixmap(image_path)
pixmap = self._load_background_pixmap(self.document.background)
if pixmap.isNull():
self._background_loaded = False
return
@ -555,16 +552,32 @@ class LedCanvas(QGraphicsView):
self._background_handles.clear()
self._background_loaded = False
def _load_background_pixmap(self, path: Path) -> QPixmap:
cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None)
def _load_background_pixmap(self, background: BackgroundImage) -> QPixmap:
buffer: QBuffer | None = None
if background.has_embedded_image():
image_bytes = background.embedded_image_bytes()
cache_key = ("embedded", background.embedded_image_sha256 or len(image_bytes))
buffer = QBuffer()
buffer.setData(QByteArray(image_bytes))
buffer.open(QIODevice.OpenModeFlag.ReadOnly)
reader = QImageReader(buffer)
else:
path = Path(background.path)
if not path.is_absolute() and self.document is not None and self.document.path is not None:
path = self.document.path.parent / path
cache_key = (str(path), path.stat().st_mtime_ns if path.exists() else None)
reader = QImageReader(str(path))
if self._background_pixmap_cache_key == cache_key and self._background_pixmap_cache is not None:
if buffer is not None:
buffer.close()
return self._background_pixmap_cache
reader = QImageReader(str(path))
size = reader.size()
if size.isValid() and max(size.width(), size.height()) > MAX_BACKGROUND_PIXMAP_SIDE:
factor = MAX_BACKGROUND_PIXMAP_SIDE / max(size.width(), size.height())
reader.setScaledSize(QSize(max(1, int(size.width() * factor)), max(1, int(size.height() * factor))))
image = reader.read()
if buffer is not None:
buffer.close()
pixmap = QPixmap.fromImage(image) if not image.isNull() else QPixmap()
self._background_pixmap_cache_key = cache_key
self._background_pixmap_cache = pixmap

View File

@ -968,15 +968,16 @@ class MainWindow(QMainWindow):
if res != BackgroundDialog.Accepted:
return
background = dialog.background()
if not background.path:
return
image_path = Path(background.path)
if not image_path.is_absolute() and self.document.path is not None:
image_path = self.document.path.parent / image_path
reader = QImageReader(str(image_path))
if not reader.canRead():
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
if not background.is_enabled():
return
if not background.has_embedded_image():
image_path = Path(background.path)
if not image_path.is_absolute() and self.document.path is not None:
image_path = self.document.path.parent / image_path
reader = QImageReader(str(image_path))
if not reader.canRead():
show_warning(self, "Подложка не загружена", "Не удалось загрузить изображение подложки. Выберите PNG или JPEG файл.")
return
if background.to_payload() == self.document.background.to_payload():
self.status.setText("Подложка не изменилась.")
return

View File

@ -1,12 +1,16 @@
from __future__ import annotations
import base64
import binascii
import hashlib
import json
import math
import mimetypes
import os
import re
import tempfile
from collections import deque
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any
@ -200,11 +204,33 @@ class BackgroundImage:
scale_y_percent: float = 100.0
opacity_percent: float = 100.0
visible: bool = True
embedded_image_base64: str = ""
embedded_image_mime_type: str = ""
embedded_image_name: str = ""
embedded_image_sha256: str = ""
@classmethod
def from_payload(cls, payload) -> "BackgroundImage": # noqa: ANN001
if not isinstance(payload, dict):
return cls()
embedded = payload.get("embedded_image")
embedded_data = ""
embedded_mime_type = ""
embedded_name = ""
embedded_sha256 = ""
if isinstance(embedded, dict):
candidate_data = str(embedded.get("data") or "")
try:
decoded = base64.b64decode(candidate_data, validate=True) if candidate_data else b""
except (binascii.Error, ValueError, TypeError):
decoded = b""
candidate_sha256 = str(embedded.get("sha256") or "").strip().lower()
actual_sha256 = hashlib.sha256(decoded).hexdigest() if decoded else ""
if decoded and (not candidate_sha256 or candidate_sha256 == actual_sha256):
embedded_data = candidate_data
embedded_mime_type = str(embedded.get("mime_type") or "application/octet-stream")
embedded_name = Path(str(embedded.get("name") or "background")).name
embedded_sha256 = actual_sha256
scale = _optional_float(payload.get("scale_percent"))
scale_x = _optional_float(payload.get("scale_x_percent"))
scale_y = _optional_float(payload.get("scale_y_percent"))
@ -223,10 +249,14 @@ class BackgroundImage:
scale_y_percent=max(1.0, float(scale_y)),
opacity_percent=min(100.0, max(0.0, 100.0 if opacity is None else float(opacity))),
visible=bool(payload.get("visible", True)),
embedded_image_base64=embedded_data,
embedded_image_mime_type=embedded_mime_type,
embedded_image_name=embedded_name,
embedded_image_sha256=embedded_sha256,
)
def to_payload(self) -> dict[str, bool | float | str]:
return {
def to_payload(self) -> dict[str, Any]:
payload: dict[str, Any] = {
"path": self.path,
"offset_x": self.offset_x,
"offset_y": self.offset_y,
@ -235,9 +265,48 @@ class BackgroundImage:
"opacity_percent": self.opacity_percent,
"visible": self.visible,
}
if self.has_embedded_image():
payload["embedded_image"] = {
"encoding": "base64",
"mime_type": self.embedded_image_mime_type or "application/octet-stream",
"name": self.embedded_image_name or "background",
"sha256": self.embedded_image_sha256 or hashlib.sha256(self.embedded_image_bytes()).hexdigest(),
"data": self.embedded_image_base64,
}
return payload
def is_enabled(self) -> bool:
return bool(self.path)
return bool(self.path) or self.has_embedded_image()
def has_embedded_image(self) -> bool:
return bool(self.embedded_image_base64)
def embedded_image_bytes(self) -> bytes:
if not self.embedded_image_base64:
return b""
try:
return base64.b64decode(self.embedded_image_base64, validate=True)
except (binascii.Error, ValueError, TypeError):
return b""
def with_embedded_image(self, base_dir: Path | None = None) -> "BackgroundImage":
if self.has_embedded_image() or not self.path:
return self
image_path = Path(self.path)
if not image_path.is_absolute() and base_dir is not None:
image_path = base_dir / image_path
image_bytes = image_path.read_bytes()
if not image_bytes:
raise ValueError("Файл подложки пуст и не может быть встроен в схему.")
mime_type = mimetypes.guess_type(image_path.name)[0] or "application/octet-stream"
return replace(
self,
path="",
embedded_image_base64=base64.b64encode(image_bytes).decode("ascii"),
embedded_image_mime_type=mime_type,
embedded_image_name=image_path.name,
embedded_image_sha256=hashlib.sha256(image_bytes).hexdigest(),
)
class MappingDocument:
@ -397,7 +466,9 @@ class MappingDocument:
def save(self, path: str | Path) -> None:
path = Path(path)
self.reindex_all()
payload = self.to_payload()
source_base_dir = self.path.parent if self.path is not None else path.parent
background_for_save = self.background.with_embedded_image(source_base_dir)
payload = self.to_payload(background=background_for_save)
serialized = json.dumps(payload, ensure_ascii=False, indent=2, allow_nan=False)
if json.loads(serialized) != payload:
raise ValueError("Не удалось без потерь сериализовать схему.")
@ -422,6 +493,7 @@ class MappingDocument:
if temporary_path is not None and temporary_path.exists():
temporary_path.unlink()
self.path = path
self.background = background_for_save
def to_payload(
self,
@ -429,6 +501,7 @@ class MappingDocument:
point_payloads: list[dict[str, Any]] | None = None,
lines_payload: list[dict[str, Any]] | None = None,
collection_name: str | None = None,
background: BackgroundImage | None = None,
) -> dict[str, Any]:
payload = dict(self.extra_payload)
payload[collection_name or self.collection_name] = (
@ -439,7 +512,7 @@ class MappingDocument:
payload["unfolding"] = self.describe_unfolding().to_payload()
payload["coordinate_unit"] = self._coordinate_unit_for_save()
payload["grid_step_mm"] = self.grid_step_mm
payload["background"] = self.background.to_payload()
payload["background"] = (self.background if background is None else background).to_payload()
payload["lines"] = self.lines_to_payload() if lines_payload is None else lines_payload
return payload

View File

@ -1,5 +1,7 @@
from __future__ import annotations
import base64
import hashlib
import json
import importlib
import gc
@ -147,7 +149,10 @@ def main() -> None:
test_save_preserves_unknown_source_fields,
test_ordered_json_preserves_document_and_remaps_uids,
test_background_load_save_roundtrip,
test_background_image_is_embedded_and_survives_source_changes,
test_background_embedding_failure_keeps_document_and_target_unchanged,
test_background_dialog_preserves_non_uniform_scale,
test_background_dialog_marks_and_preserves_embedded_image,
test_canvas_background_layer_and_geometry,
test_canvas_background_zero_offset_uses_scheme_center,
test_canvas_background_edit_move_and_resize_helpers,
@ -1620,6 +1625,11 @@ def test_background_load_save_roundtrip() -> None:
}
source = Path(temp_dir) / "map.json"
source.write_text(json.dumps(payload), encoding="utf-8")
(Path(temp_dir) / "drawing.png").write_bytes(
base64.b64decode(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
)
)
doc = MappingDocument()
doc.load(source)
assert doc.background.path == "drawing.png"
@ -1652,6 +1662,81 @@ def test_background_load_save_roundtrip() -> None:
assert reloaded.background.offset_y == 0
def test_background_image_is_embedded_and_survives_source_changes() -> None:
if LedCanvas is None or QApplication is None or QImage is None:
return
app = QApplication.instance() or QApplication([])
with temp_document_path() as path, tempfile.TemporaryDirectory() as temp_dir:
image_path = Path(temp_dir) / "background.png"
image = QImage(20, 10, QImage.Format.Format_ARGB32)
image.fill(QColor("#123456"))
assert image.save(str(image_path))
original_bytes = image_path.read_bytes()
doc = MappingDocument()
doc.load(path)
doc.background = BackgroundImage(str(image_path), 12.5, -7.25, 200.0, 300.0, 45.0, True)
saved = Path(temp_dir) / "embedded-map.json"
doc.save(saved)
payload = json.loads(saved.read_text(encoding="utf-8"))
background_payload = payload["background"]
embedded_payload = background_payload["embedded_image"]
assert background_payload["path"] == ""
assert embedded_payload["encoding"] == "base64"
assert embedded_payload["mime_type"] == "image/png"
assert embedded_payload["name"] == "background.png"
assert embedded_payload["sha256"] == hashlib.sha256(original_bytes).hexdigest()
assert base64.b64decode(embedded_payload["data"], validate=True) == original_bytes
assert doc.background.has_embedded_image()
assert doc.background.path == ""
image_path.unlink()
first_embedded_data = doc.background.embedded_image_base64
doc.points[0].x += 1000.0
doc.save(saved)
assert doc.background.embedded_image_base64 == first_embedded_data
reloaded = MappingDocument()
reloaded.load(saved)
assert reloaded.background.to_payload() == doc.background.to_payload()
canvas = LedCanvas()
canvas.set_document(reloaded)
assert canvas.background_loaded()
assert canvas._background_item is not None
assert canvas._background_item.pixmap().width() == 20
assert canvas._background_item.pixmap().height() == 10
rect = canvas._background_scene_rect()
assert rect is not None
assert rect.width() == 40.0
assert rect.height() == 30.0
canvas.close()
def test_background_embedding_failure_keeps_document_and_target_unchanged() -> None:
with temp_document_path() as source_path, tempfile.TemporaryDirectory() as temp_dir:
doc = MappingDocument()
doc.load(source_path)
missing_path = Path(temp_dir) / "missing-background.png"
original_background = BackgroundImage(path=str(missing_path), offset_x=12.0, scale_x_percent=150.0)
doc.background = original_background
target = Path(temp_dir) / "existing-map.json"
original_target = '{"preserve": true}\n'
target.write_text(original_target, encoding="utf-8")
try:
doc.save(target)
except FileNotFoundError:
pass
else:
raise AssertionError("Сохранение должно завершаться ошибкой, если файл подложки недоступен.")
assert target.read_text(encoding="utf-8") == original_target
assert doc.path == source_path
assert doc.background is original_background
assert not doc.background.has_embedded_image()
def test_background_dialog_preserves_non_uniform_scale() -> None:
if QApplication is None or BackgroundDialog is None:
return
@ -1665,6 +1750,43 @@ def test_background_dialog_preserves_non_uniform_scale() -> None:
dialog.close()
def test_background_dialog_marks_and_preserves_embedded_image() -> None:
if QApplication is None or BackgroundDialog is None:
return
app = QApplication.instance() or QApplication([])
image_bytes = b"embedded-background-image"
encoded = base64.b64encode(image_bytes).decode("ascii")
background = BackgroundImage(
path="",
offset_x=1.0,
offset_y=2.0,
scale_x_percent=80.0,
scale_y_percent=125.0,
opacity_percent=60.0,
visible=True,
embedded_image_base64=encoded,
embedded_image_mime_type="image/png",
embedded_image_name="source.png",
embedded_image_sha256=hashlib.sha256(image_bytes).hexdigest(),
)
dialog = BackgroundDialog(background)
assert dialog.path.text() == "Встроенная"
assert dialog.path.isReadOnly()
dialog.scale_x.setValue(90.0)
updated = dialog.background()
assert updated.path == ""
assert updated.scale_x_percent == 90.0
assert updated.embedded_image_base64 == encoded
assert updated.embedded_image_sha256 == background.embedded_image_sha256
dialog._set_external_path("replacement.jpg")
replacement = dialog.background()
assert not dialog.path.isReadOnly()
assert replacement.path == "replacement.jpg"
assert not replacement.has_embedded_image()
dialog.close()
def test_canvas_background_layer_and_geometry() -> None:
if LedCanvas is None or QApplication is None or QImage is None:
return