Fix RmlUi colors and map content

- Prevent RmlUi shader from second convertion to SRGB format
- Doox Empire regions
- ESH regions
- Kartilia regions
- Country colors are changed
This commit is contained in:
legatt 2026-10-14 17:20:55 +03:00
parent 8c41f36e80
commit f1380dcef5
6 changed files with 494 additions and 221 deletions

View File

@ -16,8 +16,8 @@
"irtazlin": "#7F7B00",
"akvaria": "#D8D10A",
"retorria": "#A5A008",
"north_rachia": "#BFB909",
"south_rachia": "#E5DE0B",
"north-rachia": "#BFB909",
"south-rachia": "#E5DE0B",
"tirandia": "#B2AC08",
"sverlopa": "#FFF719",
"strogskia": "#F2EA0C",
@ -46,12 +46,73 @@
"paradizna": "#90B9F5",
"dalovburg": "#8A9AD8",
"koberec": "#59BBFA",
"kichka_menrosh": "#4EDFFE",
"kichka-menrosh": "#4EDFFE",
"chenec": "#44CAFC",
"ishkivat": "#58DDF3",
"samac": "#57B8F7",
"shkadozhiv": "#66D6DD",
"zaknishce": "#5ACBE5",
"saranchet": "#51DFF1",
"nizhnegorsk": "#2AEAFE"
"egrinda": "#EED459",
"chron": "#69BAFA",
"right-ribnica": "#57E2E6",
"headwaters": "#FDF651",
"drakat": "#FBC64B",
"doroshchegar": "#FCDA73",
"kontsk": "#ACCABE",
"karabshet": "#D7EBCF",
"port-belfa": "#B9E2C8",
"priozersk": "#90ECBC",
"zallesk": "#D2F69F",
"zelenov": "#2AEAFE",
"kzhechen": "#5AE4D9",
"nizhnesibursk": "#90F5D9",
"elnino": "#AE7ECE",
"labina": "#A5B0D7",
"gynska-sobota": "#31F8F0",
"sarabutolgo-gyt": "#6F9AF7",
"bakly": "#FB6B9C",
"hodeska": "#EE9185",
"zagorie": "#CE6FD8",
"sinitsyno": "#81E2FD",
"barkino": "#E47F93",
"tral": "#E58D92",
"enterba": "#F17B89",
"yuglave": "#FE5D9D",
"marak": "#E8868A",
"bakhansk": "#EE689F",
"castle-ulviun": "#F043C2",
"tornfkills": "#F3C246",
"newpillia": "#D98F96",
"dokbog": "#FAEF3F",
"pitiwos": "#D653D6",
"palave": "#FA27ED",
"orosbund": "#FBA78E",
"translat-edi": "#F96BE3",
"bardaslot": "#F151BB",
"prekaris": "#CE7FB1",
"fardont": "#EBC35A",
"steel-frontier": "#F67F95",
"doox-island": "#D2B58D",
"gigafacility": "#DD8497",
"praeter": "#DF81A0",
"dvadechreta": "#D893E1",
"kechukmagna": "#D76DDD",
"ksiplovkur": "#EA80AF",
"rahadon": "#FE97A4",
"erashdipidipibund": "#FE888E",
"labanav": "#DE68C2",
"redriver": "#ED2BEA",
"dookland": "#F2F7A9",
"bergsland": "#F5B352",
"bargental": "#81C1ED",
"bretesse": "#40F0C9",
"bloydorp": "#5EB7E0",
"karlsborg": "#46BBF6",
"gekelen": "#59C3F5",
"kilborg": "#969EF7",
"paulkerhen": "#A0BDCD",
"tordheim": "#57C0FE",
"sankt-mikhel": "#6A9DF9",
"nederborn": "#22EDFC"
}

View File

@ -3,7 +3,7 @@
"subjects": [
{
"tag": "UCB",
"color": "#F7EF00",
"color": "#E5EA5D",
"regions": [
"dineria",
"minderia",
@ -22,20 +22,21 @@
"irtazlin",
"akvaria",
"retorria",
"north_rachia",
"south_rachia",
"north-rachia",
"south-rachia",
"tirandia",
"sverlopa",
"strogskia",
"fillirand",
"mgahvatia",
"glednar",
"kima"
"kima",
"egrinda"
]
},
{
"tag": "FSC",
"color": "#AB5177",
"color": "#D88CC9",
"regions": [
"alende",
"pepene",
@ -46,36 +47,110 @@
"scarna",
"dabreg",
"scenra",
"prakirol",
"illeton"
"prakirol"
]
},
{
"tag": "TRP",
"color": "#F77D93",
"color": "#C8ABCC",
"regions": [
"barlakir",
"assental",
"castle-viktor",
"sector-kaya",
"castle-avens"
"castle-avens",
"castle-ulviun"
]
},
{
"tag": "SFL",
"color": "#392986",
"tag": "ISH",
"color": "#EADCC5",
"regions": [
"langat",
"illeton",
"nanec",
"paradizna",
"dalovburg",
"koberec",
"kichka_menrosh",
"kichka-menrosh",
"chenec",
"ishkivat",
"samac",
"shkadozhiv",
"zaknishce",
"saranchet"
"saranchet",
"chron",
"right-ribnica",
"headwaters",
"drakat",
"doroshchegar",
"kontsk",
"karabshet",
"port-belfa",
"priozersk",
"zallesk",
"zelenov",
"kzhechen",
"nizhnesibursk",
"elnino",
"labina",
"gynska-sobota",
"sarabutolgo-gyt",
"bakly",
"hodeska",
"zagorie",
"sinitsyno",
"barkino",
"tral",
"enterba",
"yuglave",
"marak",
"bakhansk"
]
},
{
"tag": "IID",
"color": "#AD3832",
"regions": [
"tornfkills",
"newpillia",
"dokbog",
"pitiwos",
"palave",
"orosbund",
"translat-edi",
"bardaslot",
"prekaris",
"fardont",
"steel-frontier",
"doox-island",
"gigafacility",
"praeter",
"dvadechreta",
"kechukmagna",
"ksiplovkur",
"rahadon",
"erashdipidipibund",
"labanav",
"redriver",
"dookland",
"bergsland"
]
},
{
"tag": "KAR",
"color": "#A4B5D8",
"regions": [
"bargental",
"bretesse",
"bloydorp",
"karlsborg",
"gekelen",
"kilborg",
"paulkerhen",
"tordheim",
"sankt-mikhel",
"nederborn"
]
}
]

View File

@ -1,4 +1,4 @@
#version 330
#version 450
#extension GL_ARB_separate_shader_objects : enable
#extension GL_ARB_shading_language_420pack : enable
@ -9,7 +9,28 @@ layout (location = 1) in vec4 vColor;
layout (location = 0) out vec4 outColor;
void main() {
vec4 texColor = texture(Texture, vUV);
outColor = vColor * texColor;
// RmlUi works in sRGB with premultiplied alpha: vertex colours from the styles and generated textures
// (font glyphs) are premultiplied sRGB values. The swapchain is sRGB, so the GPU expects linear output
// and encodes it to sRGB on write; without conversion colours would be encoded twice and look washed out.
vec3 srgbToLinear(vec3 c) {
return mix(c / 12.92, pow((c + 0.055) / 1.055, vec3(2.4)), step(vec3(0.04045), c));
}
vec3 linearToSrgb(vec3 c) {
return mix(c * 12.92, 1.055 * pow(c, vec3(1.0 / 2.4)) - 0.055, step(vec3(0.0031308), c));
}
void main() {
// Textures are uploaded as R8G8B8A8_SRGB, so sampling has already decoded them to linear;
// bring them back to the sRGB values RmlUi generated before combining with the vertex colour.
vec4 texColor = texture(Texture, vUV);
texColor.rgb = linearToSrgb(texColor.rgb);
vec4 color = vColor * texColor; // premultiplied sRGB
if (color.a <= 0.0) discard;
// The pipeline blends with SRC_ALPHA / ONE_MINUS_SRC_ALPHA, so output straight (non-premultiplied) linear colour.
vec3 straight = clamp(color.rgb / color.a, 0.0, 1.0);
outColor = vec4(srgbToLinear(straight), color.a);
}

View File

@ -1,3 +1,3 @@
(@@GQ?HKÔł÷ďÔăČp«QwE\ľů!÷}“U,ł ł×9)†Y\fö˙ęňfŃŘÝĺf¸żĹĚf¬˛ Ąf“™‡Św
K@@gQHKÔłĺę])ÜăČpŘŚÉYTľů!Č«Ěe4 $¸ęÜĹ1iD¸.ő.­82±Ľ<C4BD>ÁÜѤµŘŮTfö˙ęňfŃŘÝĺf¸żĹĚf¬˛ Ąf“™‡Św
盘噷w{敊v{
ŃŘw Ą ążw Ţ嬲w÷˙ ęňw ö˙ÎŘwŰĺęňw´}ďę0ďw×_Íň0ęw ńžŞůŽwŻÓvoí¦w{ú¨Đ±zčÁ|wÎEďękűwç‹ýȉ˝­‰Ýwü¤iőą<C591>wŘšŠú»YwţßNüĘDwóÝX÷¸WwÝÖfĺËZńßQ
ŃŘw Ą ążw Ţ嬲w÷˙ ęňw ö˙ÎŘwŰĺęňYÔîw´}ďę0ďw×_Íň0ęw ńžŞůŽwŻÓvoí¦w{ú¨Đ±zwÎEďękűwç‹ýȉ˝w­‰ÝÂCđwµűŇčÁ|wü¤iőą<C591>wŘšŠú»YwţßNüĘDwóÝX÷¸WwÝÖfĺËZwńßQúşiwćâWQöýwKĆűsÚüwľĘ¬Ďë×wČ⹼ě<C4BD>wźöŇţę*wŮäZŮő<C5AE>wÎ~®×°Ąwđř1÷šowśkű…‘îwŘoÎýâ<C3BD>w“ä’Ťĺw‰{ńť]ţwІčźhîwFÂó–ŹŮw?ďúÖSÖwí'úާűwăků»Qńw±ÎZĂëw•öŤµŇw—„Ý <C39D>ßwá“ŘÝm×w݀椗ţwŽ<77>ţÂhŢwę+í©÷ňRłőwíÁ<C3AD>Éđ@wŕ·^ö»FwőĂY÷ž–wÍ˝ ţŔWwůťjüí"

Binary file not shown.

476
nudge.py
View File

@ -1,65 +1,49 @@
# -*- coding: utf-8 -*-
# -- coding: utf-8 --
"""
Region Mapper — большая цветовая карта -> JSON {region: "#RRGGBB"}.
Region Mapper — работа с двумя JSON: регионы (цвета) и страны (группы).
Зависимости: pip install PyQt5 Pillow
Запуск: python nudge.py map.bmp
"""
import sys
import os
import json
import math
from PyQt5.QtCore import Qt, QPoint, QPointF, QRect, QRectF, QTimer, pyqtSignal
from PyQt5.QtGui import QImage, QPixmap, QPainter, QColor, QPen, QKeySequence
from PyQt5.QtWidgets import (
QApplication, QMainWindow, QWidget, QHBoxLayout, QVBoxLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QListWidgetItem,
QSplitter, QFileDialog, QMessageBox, QInputDialog, QShortcut,
QFormLayout
)
from PIL import Image
Image.MAX_IMAGE_PIXELS = None
# ----------------------------------------------------------------------
# Утилиты
# ----------------------------------------------------------------------
def rgb_to_hex(rgb):
return "#{:02X}{:02X}{:02X}".format(*rgb)
def hex_to_rgb(s):
s = s.lstrip("#")
return int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
def load_map(path):
"""Возвращает (QImage, buffer). buffer нужно держать живым во втором случае."""
# Штатный загрузчик Qt: нет лимита Pillow, нет лишних копий памяти.
img = QImage(path)
if not img.isNull():
return img, None
# Запасной путь через Pillow.
with Image.open(path) as f:
pil = f.convert("RGB")
w, h = pil.size
raw = pil.tobytes()
del pil
# ВАЖНО: этот конструктор QImage НЕ копирует данные.
# Буфер raw обязан жить столько же, сколько живёт QImage.
img = QImage(raw, w, h, w * 3, QImage.Format_RGB888)
if img.isNull():
raise RuntimeError("Не удалось декодировать изображение")
return img, raw
def make_overview(img, max_side=4096):
"""Маленькая копия для режима «вся карта на экране»."""
w, h = img.width(), img.height()
k = min(1.0, max_side / float(max(w, h)))
if k >= 1.0:
@ -69,35 +53,31 @@ def make_overview(img, max_side=4096):
Qt.IgnoreAspectRatio, Qt.SmoothTransformation)
return QPixmap.fromImage(small)
# ----------------------------------------------------------------------
# Просмотр карты
# ----------------------------------------------------------------------
class MapViewer(QWidget):
pixelPicked = pyqtSignal(int, int, tuple) # x, y, (r, g, b)
pixelPicked = pyqtSignal(int, int, tuple)
zoomChanged = pyqtSignal(float)
cursorMoved = pyqtSignal(int, int)
LOD_SWITCH = 0.25 # ниже этого масштаба рисуем уменьшенную копию
LOD_SWITCH = 0.25
MAX_SCALE = 64.0
def __init__(self, image, raw_buffer, parent=None):
super().__init__(parent)
self.image = image
self._raw = raw_buffer # держим буфер Pillow живым
self._raw = raw_buffer
self.W = image.width()
self.H = image.height()
print("Размер карты: {} x {} пикселей".format(self.W, self.H))
print("Создаю уменьшенную копию...")
self.overview = make_overview(image)
self.scale = 1.0 # экранных пикселей на пиксель карты
self.offset = QPointF(0, 0) # куда смещён левый верхний угол карты
self.scale = 1.0
self.offset = QPointF(0, 0)
self._fit_scale = 1.0
self._fitted = False
self._panning = False
self._pan_last = QPoint()
self._press_pos = None
@ -109,24 +89,18 @@ class MapViewer(QWidget):
self.setFocusPolicy(Qt.StrongFocus)
self.setMinimumSize(200, 120)
# ---------- координаты ----------
def _to_image(self, pos):
x = math.floor((pos.x() - self.offset.x()) / self.scale)
y = math.floor((pos.y() - self.offset.y()) / self.scale)
return x, y
# ---------- масштаб ----------
def fitToWindow(self):
vw, vh = self.width(), self.height()
if vw < 2 or vh < 2:
return
if vw < 2 or vh < 2: return
s = min(vw / self.W, vh / self.H)
self._fit_scale = s
self.scale = s
self.offset = QPointF((vw - self.W * s) / 2.0,
(vh - self.H * s) / 2.0)
self.offset = QPointF((vw - self.W * s) / 2.0, (vh - self.H * s) / 2.0)
self._fitted = True
self.update()
self.zoomChanged.emit(s)
@ -142,61 +116,46 @@ class MapViewer(QWidget):
def _zoom_at(self, point, new_scale):
new_scale = max(self._fit_scale * 0.25, min(self.MAX_SCALE, new_scale))
# Точка карты под курсором должна остаться под курсором.
ix = (point.x() - self.offset.x()) / self.scale
iy = (point.y() - self.offset.y()) / self.scale
self.scale = new_scale
self.offset = QPointF(point.x() - ix * new_scale,
point.y() - iy * new_scale)
self.offset = QPointF(point.x() - ix * new_scale, point.y() - iy * new_scale)
self.update()
self.zoomChanged.emit(new_scale)
def resizeEvent(self, event):
super().resizeEvent(event)
if not self._fitted:
self.fitToWindow()
# ---------- отрисовка ----------
if not self._fitted: self.fitToWindow()
def paintEvent(self, event):
p = QPainter(self)
p.fillRect(self.rect(), QColor(45, 45, 48))
p.setRenderHint(QPainter.SmoothPixmapTransform, self.scale < 1.0)
s = self.scale
ox, oy = self.offset.x(), self.offset.y()
if s < self.LOD_SWITCH:
# Далеко: рисуем маленькую копию, Qt сам обрежет по виджету.
target = QRectF(ox, oy, self.W * s, self.H * s)
p.drawPixmap(target, self.overview,
QRectF(0, 0, self.overview.width(), self.overview.height()))
p.drawPixmap(target, self.overview, QRectF(0, 0, self.overview.width(), self.overview.height()))
else:
# Близко: берём из полного разрешения ТОЛЬКО видимый прямоугольник.
x0 = max(0, int(math.floor(-ox / s)))
y0 = max(0, int(math.floor(-oy / s)))
x1 = min(self.W, int(math.ceil((self.width() - ox) / s)))
y1 = min(self.H, int(math.ceil((self.height() - oy) / s)))
if x1 > x0 and y1 > y0:
src = QRect(x0, y0, x1 - x0, y1 - y0)
target = QRectF(ox + x0 * s, oy + y0 * s,
(x1 - x0) * s, (y1 - y0) * s)
target = QRectF(ox + x0 * s, oy + y0 * s, (x1 - x0) * s, (y1 - y0) * s)
p.drawImage(target, self.image, QRectF(src))
# Рамка карты
p.setPen(QPen(QColor(95, 95, 95), 1))
p.drawRect(QRectF(ox, oy, self.W * s, self.H * s))
# Метка последнего выбранного пикселя
if self.marker is not None and s >= 4:
mx, my = self.marker
p.setPen(QPen(QColor(255, 255, 255), 1))
p.drawRect(QRectF(ox + mx * s, oy + my * s, s, s))
p.end()
# ---------- мышь ----------
def wheelEvent(self, event):
d = event.angleDelta().y()
if d:
@ -217,31 +176,25 @@ class MapViewer(QWidget):
if self._panning:
d = event.pos() - self._pan_last
self._pan_last = event.pos()
self.offset = QPointF(self.offset.x() + d.x(),
self.offset.y() + d.y())
self.offset = QPointF(self.offset.x() + d.x(), self.offset.y() + d.y())
self.update()
elif self._press_pos is not None:
d = event.pos() - self._press_pos
if d.manhattanLength() > 4:
self._dragging = True
if d.manhattanLength() > 4: self._dragging = True
if self._dragging:
self.offset = QPointF(self._press_offset.x() + d.x(),
self._press_offset.y() + d.y())
self.offset = QPointF(self._press_offset.x() + d.x(), self._press_offset.y() + d.y())
self.update()
else:
x, y = self._to_image(event.pos())
if 0 <= x < self.W and 0 <= y < self.H:
self.cursorMoved.emit(x, y)
else:
self.cursorMoved.emit(-1, -1)
if 0 <= x < self.W and 0 <= y < self.H: self.cursorMoved.emit(x, y)
else: self.cursorMoved.emit(-1, -1)
def mouseReleaseEvent(self, event):
if event.button() in (Qt.RightButton, Qt.MiddleButton):
self._panning = False
self.setCursor(Qt.ArrowCursor)
elif event.button() == Qt.LeftButton and self._press_pos is not None:
if not self._dragging:
self._pick(event.pos())
if not self._dragging: self._pick(event.pos())
self._press_pos = None
self._dragging = False
@ -254,49 +207,43 @@ class MapViewer(QWidget):
self.pixelPicked.emit(x, y, (c.red(), c.green(), c.blue()))
def keyPressEvent(self, event):
if event.key() in (Qt.Key_0, Qt.Key_F):
self.fitToWindow()
elif event.key() == Qt.Key_1:
self.actualSize()
elif event.key() in (Qt.Key_Plus, Qt.Key_Equal):
self.zoomIn()
elif event.key() == Qt.Key_Minus:
self.zoomOut()
else:
super().keyPressEvent(event)
if event.key() in (Qt.Key_0, Qt.Key_F): self.fitToWindow()
elif event.key() == Qt.Key_1: self.actualSize()
elif event.key() in (Qt.Key_Plus, Qt.Key_Equal): self.zoomIn()
elif event.key() == Qt.Key_Minus: self.zoomOut()
else: super().keyPressEvent(event)
# ----------------------------------------------------------------------
# Окно
# ----------------------------------------------------------------------
class MainWindow(QMainWindow):
def __init__(self, path):
super().__init__()
self.setWindowTitle("Region Mapper — " + os.path.basename(path))
self.resize(1400, 850)
# Обычный dict: в Python 3.7+ сохраняет порядок вставки.
# --- Файл 1: Регионы (плоский словарь) ---
self.mapping = {} # name -> "#RRGGBB"
self.by_color = {} # (r, g, b) -> name
# --- Файл 2: Страны (вложенная структура) ---
self.subjects_list = [] # список тегов для сохранения порядка
self.subjects = {} # tag -> {"color": str, "regions": list}
self.current_rgb = None
self._shown_once = False
image, raw = load_map(path)
self.viewer = MapViewer(image, raw)
self.viewer.pixelPicked.connect(self.on_pixel)
self.viewer.zoomChanged.connect(
lambda s: self.zoom_label.setText("Масштаб: {:.4f}×".format(s)))
self.viewer.cursorMoved.connect(
lambda x, y: self.coord_label.setText("X: {} Y: {}".format(x, y)))
self.viewer.zoomChanged.connect(lambda s: self.zoom_label.setText("Масштаб: {:.4f}×".format(s)))
self.viewer.cursorMoved.connect(lambda x, y: self.coord_label.setText("X: {} Y: {}".format(x, y)))
self._build_ui()
def showEvent(self, event):
super().showEvent(event)
if not self._shown_once:
self._shown_once = True
# Финальный фит уже после того, как layout раздал размеры.
QTimer.singleShot(0, self.viewer.fitToWindow)
def _build_ui(self):
@ -306,7 +253,7 @@ class MainWindow(QMainWindow):
root.setContentsMargins(6, 6, 6, 6)
splitter = QSplitter(Qt.Horizontal)
# --- слева: тулбар + карта ---
# --- слева: карта ---
left = QWidget()
lv = QVBoxLayout(left)
lv.setContentsMargins(0, 0, 0, 0)
@ -325,12 +272,13 @@ class MainWindow(QMainWindow):
lv.addWidget(self.viewer, 1)
splitter.addWidget(left)
# --- справа: панель разметки ---
# --- справа: панель управления ---
panel = QWidget()
pv = QVBoxLayout(panel)
pv.setContentsMargins(8, 8, 8, 8)
pv.setSpacing(6)
# Блок текущего пикселя
pv.addWidget(QLabel("Текущий пиксель:"))
row = QHBoxLayout()
self.swatch = QLabel()
@ -342,8 +290,9 @@ class MainWindow(QMainWindow):
row.addWidget(self.color_info, 1)
pv.addLayout(row)
# ================= БЛОК РЕГИОНОВ (ФАЙЛ 1) =================
pv.addSpacing(10)
pv.addWidget(QLabel("Название региона:"))
pv.addWidget(QLabel("1. Регионы (Название и цвет):"))
self.name_input = QLineEdit()
self.name_input.setPlaceholderText("Например: pepene")
self.name_input.returnPressed.connect(self.commit)
@ -353,11 +302,8 @@ class MainWindow(QMainWindow):
btn.clicked.connect(self.commit)
pv.addWidget(btn)
pv.addSpacing(12)
pv.addWidget(QLabel("Регионы (порядок = порядок в JSON):"))
self.list_widget = QListWidget()
self.list_widget.itemDoubleClicked.connect(
lambda it: self.rename_region(it.data(Qt.UserRole)))
self.list_widget.itemDoubleClicked.connect(lambda it: self.rename_region(it.data(Qt.UserRole)))
pv.addWidget(self.list_widget, 1)
row2 = QHBoxLayout()
@ -369,54 +315,107 @@ class MainWindow(QMainWindow):
row2.addWidget(b2)
pv.addLayout(row2)
# ================= БЛОК СТРАН (ФАЙЛ 2) =================
pv.addSpacing(12)
b_open = QPushButton("Открыть существующий JSON")
b_open.clicked.connect(self.load_json)
pv.addWidget(b_open)
pv.addWidget(QLabel("2. Страны (Теги и группы регионов):"))
b_save = QPushButton("Сохранить JSON [Ctrl+S]")
b_save.setStyleSheet("background:#2e7d32; color:white; "
"font-weight:bold; padding:6px;")
b_save.clicked.connect(self.save_json)
pv.addWidget(b_save)
subj_form = QFormLayout()
subj_form.setContentsMargins(0, 0, 0, 0)
subj_form.setSpacing(4)
self.subject_tag_input = QLineEdit()
self.subject_tag_input.setPlaceholderText("Тег (например, UCB)")
subj_form.addRow("Тег:", self.subject_tag_input)
self.subject_color_input = QLineEdit()
self.subject_color_input.setPlaceholderText("Цвет (например, #E5EA5D)")
subj_form.addRow("Цвет:", self.subject_color_input)
pv.addLayout(subj_form)
btn_use_color = QPushButton("Взять цвет текущего пикселя")
btn_use_color.clicked.connect(self.use_current_color_for_subject)
pv.addWidget(btn_use_color)
btn_save_subject = QPushButton("Создать / Обновить страну")
btn_save_subject.clicked.connect(self.commit_subject)
pv.addWidget(btn_save_subject)
self.subject_list = QListWidget()
self.subject_list.currentItemChanged.connect(self.on_subject_selected)
pv.addWidget(self.subject_list)
pv.addWidget(QLabel("Регионы в выбранной стране:"))
self.subject_regions_list = QListWidget()
pv.addWidget(self.subject_regions_list)
row_subj = QHBoxLayout()
b_rem_reg = QPushButton("Удалить из страны")
b_rem_reg.clicked.connect(self.remove_region_from_subject)
b_del_subj = QPushButton("Удалить страну")
b_del_subj.clicked.connect(self.delete_subject)
row_subj.addWidget(b_rem_reg)
row_subj.addWidget(b_del_subj)
pv.addLayout(row_subj)
# ================= КНОПКИ ФАЙЛОВ =================
pv.addSpacing(12)
pv.addWidget(QLabel("--- Файл регионов (JSON 1) ---"))
row_files1 = QHBoxLayout()
b_open_reg = QPushButton("Открыть регионы")
b_open_reg.clicked.connect(self.load_regions_json)
b_save_reg = QPushButton("Сохранить регионы")
b_save_reg.setStyleSheet("background:#1565c0; color:white; font-weight:bold; padding:4px;")
b_save_reg.clicked.connect(self.save_regions_json)
row_files1.addWidget(b_open_reg)
row_files1.addWidget(b_save_reg)
pv.addLayout(row_files1)
pv.addSpacing(6)
pv.addWidget(QLabel("--- Файл стран (JSON 2) ---"))
row_files2 = QHBoxLayout()
b_open_subj = QPushButton("Открыть страны")
b_open_subj.clicked.connect(self.load_subjects_json)
b_save_subj = QPushButton("Сохранить страны")
b_save_subj.setStyleSheet("background:#2e7d32; color:white; font-weight:bold; padding:4px;")
b_save_subj.clicked.connect(self.save_subjects_json)
row_files2.addWidget(b_open_subj)
row_files2.addWidget(b_save_subj)
pv.addLayout(row_files2)
splitter.addWidget(panel)
splitter.setStretchFactor(0, 1)
splitter.setStretchFactor(1, 0)
splitter.setSizes([1000, 330])
splitter.setSizes([1000, 500])
root.addWidget(splitter)
self.coord_label = QLabel("X: — Y: —")
self.statusBar().addPermanentWidget(self.coord_label)
self.statusBar().showMessage(
"ЛКМ — выбрать пиксель; ЛКМ-драг / ПКМ — панорама; "
"колесо — зум; 0 — вписать; 1 — 1:1")
QShortcut(QKeySequence("Ctrl+S"), self, self.save_json)
# ---------- выбор пикселя ----------
self.statusBar().showMessage("ЛКМ — пиксель; ПКМ/ЛКМ-драг — панорама; колесо — зум")
# ---------- Логика пикселя и регионов ----------
def on_pixel(self, x, y, rgb):
self.current_rgb = rgb
hexv = rgb_to_hex(rgb)
self.swatch.setStyleSheet("background:{}; border:1px solid #555;".format(hexv))
text = "{}\nRGB ({}, {}, {})\nПиксель ({}, {})".format(
hexv, rgb[0], rgb[1], rgb[2], x, y)
text = "{}\nRGB ({}, {}, {})\nПиксель ({}, {})".format(hexv, rgb[0], rgb[1], rgb[2], x, y)
owner = self.by_color.get(rgb)
if owner:
text += "\nУже назначен: " + owner
self.name_input.setText(owner) # сразу готово к правке
self.name_input.setText(owner)
self._select_in_list(owner)
else:
self.name_input.clear()
self.color_info.setText(text)
self.name_input.setFocus()
self.name_input.selectAll()
# ---------- запись / правка маппинга ----------
# Автодобавление в выбранную страну при клике
current_subject_item = self.subject_list.currentItem()
if current_subject_item:
tag = current_subject_item.data(Qt.UserRole)
region_name = self.by_color.get(rgb)
if region_name:
self.add_region_to_subject(tag, region_name)
def commit(self):
name = self.name_input.text().strip()
@ -426,37 +425,34 @@ class MainWindow(QMainWindow):
if self.current_rgb is None:
QMessageBox.warning(self, "Цвет", "Сначала кликните по пикселю на карте.")
return
rgb = self.current_rgb
hexv = rgb_to_hex(rgb)
# Цвет уже занят другим регионом?
owner = self.by_color.get(rgb)
if owner is not None and owner != name:
ret = QMessageBox.question(
self, "Цвет занят",
"Цвет {} уже назначен региону «{}».\n"
"Переназначить его региону «{}»?".format(hexv, owner, name),
ret = QMessageBox.question(self, "Цвет занят",
"Цвет {} уже назначен региону «{}».\nПереназначить его региону «{}»?".format(hexv, owner, name),
QMessageBox.Yes | QMessageBox.No, QMessageBox.No)
if ret != QMessageBox.Yes:
return
if ret != QMessageBox.Yes: return
self.mapping.pop(owner, None)
self.by_color.pop(rgb, None)
# Освобождаем старый цвет, если регион редактируется.
if name in self.mapping:
old_rgb = hex_to_rgb(self.mapping[name])
if self.by_color.get(old_rgb) == name:
self.by_color.pop(old_rgb, None)
# Присваивание СУЩЕСТВУЮЩЕМУ ключу не меняет его позицию в dict.
self.mapping[name] = hexv
self.by_color[rgb] = name
self._rebuild_list(select=name)
self.name_input.clear()
self.name_input.setFocus()
current_subject_item = self.subject_list.currentItem()
if current_subject_item:
tag = current_subject_item.data(Qt.UserRole)
self.add_region_to_subject(tag, name)
def _rebuild_list(self, select=None):
self.list_widget.clear()
for i, (name, hexv) in enumerate(self.mapping.items(), 1):
@ -465,11 +461,9 @@ class MainWindow(QMainWindow):
it.setBackground(QColor(hexv))
c = QColor(hexv)
bright = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
it.setForeground(QColor(20, 20, 20) if bright > 140
else QColor(245, 245, 245))
it.setForeground(QColor(20, 20, 20) if bright > 140 else QColor(245, 245, 245))
self.list_widget.addItem(it)
if select is not None:
self._select_in_list(select)
if select is not None: self._select_in_list(select)
def _select_in_list(self, name):
for i in range(self.list_widget.count()):
@ -480,22 +474,23 @@ class MainWindow(QMainWindow):
def rename_region(self, name=None):
if name is None:
it = self.list_widget.currentItem()
if it is None:
return
if it is None: return
name = it.data(Qt.UserRole)
new, ok = QInputDialog.getText(self, "Переименовать регион",
"Новое название:", QLineEdit.Normal, name)
if not ok:
return
new, ok = QInputDialog.getText(self, "Переименовать регион", "Новое название:", QLineEdit.Normal, name)
if not ok: return
new = new.strip()
if not new or new == name:
return
if not new or new == name: return
if new in self.mapping:
QMessageBox.warning(self, "Дубликат", "Регион с таким названием уже есть.")
return
# Пересобираем dict, сохраняя позицию переименованного элемента.
# Обновляем имя во всех странах
for tag in self.subjects_list:
regions = self.subjects[tag]["regions"]
if name in regions:
idx = regions.index(name)
regions[idx] = new
reordered = {}
for k, v in self.mapping.items():
reordered[new if k == name else k] = v
@ -505,60 +500,141 @@ class MainWindow(QMainWindow):
self.by_color.pop(rgb, None)
self.by_color[rgb] = new
self._rebuild_list(select=new)
if self.subject_list.currentItem(): self._update_subject_regions()
def delete_region(self):
it = self.list_widget.currentItem()
if it is None:
return
if it is None: return
name = it.data(Qt.UserRole)
hexv = self.mapping.pop(name, None)
if hexv is not None:
rgb = hex_to_rgb(hexv)
if self.by_color.get(rgb) == name:
self.by_color.pop(rgb, None)
if self.by_color.get(rgb) == name: self.by_color.pop(rgb, None)
# Удаляем из всех стран
for tag in self.subjects_list:
regions = self.subjects[tag]["regions"]
if name in regions: regions.remove(name)
self._rebuild_list()
if self.subject_list.currentItem(): self._update_subject_regions()
# ---------- JSON ----------
# ---------- Логика Стран ----------
def use_current_color_for_subject(self):
if self.current_rgb:
self.subject_color_input.setText(rgb_to_hex(self.current_rgb))
else:
QMessageBox.information(self, "Цвет", "Сначала кликните по пикселю на карте.")
def save_json(self):
def commit_subject(self):
tag = self.subject_tag_input.text().strip()
color = self.subject_color_input.text().strip().upper()
if not tag:
QMessageBox.warning(self, "Тег", "Введите тег страны.")
return
if not color.startswith("#") or len(color) != 7:
QMessageBox.warning(self, "Цвет", "Введите корректный HEX цвет (например, #E5EA5D).")
return
if tag in self.subjects:
self.subjects[tag]["color"] = color
else:
self.subjects_list.append(tag)
self.subjects[tag] = {"color": color, "regions": []}
self._rebuild_subject_list(select=tag)
self.subject_tag_input.clear()
self.subject_color_input.clear()
def _rebuild_subject_list(self, select=None):
self.subject_list.clear()
for tag in self.subjects_list:
data = self.subjects[tag]
color = data["color"]
text = "{} ({}) [{} рег.]".format(tag, color, len(data["regions"]))
it = QListWidgetItem(text)
it.setData(Qt.UserRole, tag)
it.setBackground(QColor(color))
c = QColor(color)
bright = 0.299 * c.red() + 0.587 * c.green() + 0.114 * c.blue()
it.setForeground(QColor(20, 20, 20) if bright > 140 else QColor(245, 245, 245))
self.subject_list.addItem(it)
if select:
for i in range(self.subject_list.count()):
if self.subject_list.item(i).data(Qt.UserRole) == select:
self.subject_list.setCurrentRow(i)
break
self._update_subject_regions()
def on_subject_selected(self, current, previous):
self._update_subject_regions()
def _update_subject_regions(self):
self.subject_regions_list.clear()
item = self.subject_list.currentItem()
if not item: return
tag = item.data(Qt.UserRole)
regions = self.subjects[tag]["regions"]
for r in regions:
self.subject_regions_list.addItem(r)
def add_region_to_subject(self, tag, region_name):
if tag not in self.subjects: return
if region_name not in self.subjects[tag]["regions"]:
self.subjects[tag]["regions"].append(region_name)
if self.subject_list.currentItem() and self.subject_list.currentItem().data(Qt.UserRole) == tag:
self._update_subject_regions()
self._rebuild_subject_list(select=tag)
def remove_region_from_subject(self):
item = self.subject_list.currentItem()
reg_item = self.subject_regions_list.currentItem()
if not item or not reg_item: return
tag = item.data(Qt.UserRole)
region_name = reg_item.text()
if region_name in self.subjects[tag]["regions"]:
self.subjects[tag]["regions"].remove(region_name)
self._update_subject_regions()
self._rebuild_subject_list(select=tag)
def delete_subject(self):
item = self.subject_list.currentItem()
if not item: return
tag = item.data(Qt.UserRole)
if tag in self.subjects_list: self.subjects_list.remove(tag)
if tag in self.subjects: del self.subjects[tag]
self._rebuild_subject_list()
# ---------- Работа с ФАЙЛОМ 1 (Регионы) ----------
def save_regions_json(self):
if not self.mapping:
QMessageBox.information(self, "Пусто", "Сначала добавьте регион.")
return
path, _ = QFileDialog.getSaveFileName(self, "Сохранить маппинг",
"regions.json", "JSON (*.json)")
if not path:
QMessageBox.information(self, "Пусто", "Сначала добавьте регионы.")
return
path, _ = QFileDialog.getSaveFileName(self, "Сохранить регионы", "regions.json", "JSON (*.json)")
if not path: return
with open(path, "w", encoding="utf-8") as f:
json.dump(self.mapping, f, ensure_ascii=False, indent=2)
f.write("\n")
self.statusBar().showMessage(
"Сохранено регионов: {} → {}".format(len(self.mapping), path), 8000)
self.statusBar().showMessage("Сохранено регионов: {} → {}".format(len(self.mapping), path), 8000)
def load_json(self):
path, _ = QFileDialog.getOpenFileName(self, "Открыть маппинг",
"", "JSON (*.json)")
if not path:
return
def load_regions_json(self):
path, _ = QFileDialog.getOpenFileName(self, "Открыть регионы", "", "JSON (*.json)")
if not path: return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if not isinstance(data, dict):
raise ValueError("Ожидался JSON-объект")
if not isinstance(data, dict): raise ValueError("Ожидался JSON-объект (словарь)")
mapping, by_color = {}, {}
for name, value in data.items():
if not isinstance(value, str):
continue
v = value.strip()
if len(v) != 7 or not v.startswith("#"):
continue
try:
rgb = hex_to_rgb(v)
except ValueError:
continue
if not isinstance(value, str): continue
v = value.strip().upper()
if len(v) != 7 or not v.startswith("#"): continue
try: rgb = hex_to_rgb(v)
except ValueError: continue
name = str(name)
if rgb in by_color:
continue
if rgb in by_color: continue
mapping[name] = rgb_to_hex(rgb)
by_color[rgb] = name
except Exception as e:
@ -570,35 +646,75 @@ class MainWindow(QMainWindow):
self._rebuild_list()
self.statusBar().showMessage("Загружено регионов: {}".format(len(mapping)), 5000)
# ---------- Работа с ФАЙЛОМ 2 (Страны) ----------
def save_subjects_json(self):
if not self.subjects_list:
QMessageBox.information(self, "Пусто", "Сначала добавьте страны.")
return
path, _ = QFileDialog.getSaveFileName(self, "Сохранить страны", "subjects.json", "JSON (*.json)")
if not path: return
data = {"content": {"subjects": []}}
for tag in self.subjects_list:
subj = self.subjects[tag]
data["content"]["subjects"].append({
"tag": tag,
"color": subj["color"],
"regions": subj["regions"]
})
with open(path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
f.write("\n")
self.statusBar().showMessage("Сохранено стран: {} → {}".format(len(self.subjects_list), path), 8000)
def load_subjects_json(self):
path, _ = QFileDialog.getOpenFileName(self, "Открыть страны", "", "JSON (*.json)")
if not path: return
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
if "content" not in data or "subjects" not in data["content"]:
raise ValueError("Ожидался формат с 'content' -> 'subjects'")
self.subjects_list = []
self.subjects = {}
for subj in data["content"]["subjects"]:
tag = str(subj.get("tag", ""))
color = str(subj.get("color", "")).strip().upper()
regions = [str(r) for r in subj.get("regions", [])]
if tag and color.startswith("#") and len(color) == 7:
self.subjects_list.append(tag)
self.subjects[tag] = {"color": color, "regions": regions}
except Exception as e:
QMessageBox.critical(self, "Ошибка чтения", str(e))
return
self._rebuild_subject_list()
self.statusBar().showMessage("Загружено стран: {}".format(len(self.subjects_list)), 5000)
# ----------------------------------------------------------------------
def main():
app = QApplication(sys.argv)
app.setApplicationName("Region Mapper")
if len(sys.argv) > 1:
path = sys.argv[1]
else:
path, _ = QFileDialog.getOpenFileName(
None, "Открыть карту", "",
"Изображения (*.bmp *.png *.jpg *.jpeg *.tif *.tiff);;Все файлы (*)")
if not path:
return
if not path: return
if not os.path.isfile(path):
QMessageBox.critical(None, "Файл не найден", path)
return
try:
win = MainWindow(path)
except Exception as exc:
QMessageBox.critical(None, "Не удалось открыть карту", repr(exc))
raise
win.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()