Шапка обновлена
- Обновил исходник и бинарник.
- Добавил подгрузку карт Google через браузер. Яндекс карт нет и не будет
- Чуть оптимизирова интерфейс, из окон можно копировать только через контекстное меню (ПКМ)
Старый код
#!/usr/bin/env python3
import sys
import os
import re
import hashlib
import threading
import requests
import tempfile
import matplotlib.patheffects as pe
from io import BytesIO
from urllib.parse import urljoin
from matplotlib.ticker import FormatStrFormatter
from PIL import Image
import imagehash
from bs4 import BeautifulSoup
from geopy.distance import geodesic
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QTextEdit, QFileDialog,
QDoubleSpinBox, QSpinBox, QMessageBox, QDialog, QTabWidget, QProgressBar,
QVBoxLayout
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QTextCursor, QKeyEvent
from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
HOME = os.path.expanduser("~")
# === Ввод с отправкой по Enter ===
class SubmitTextEdit(QTextEdit):
submitted = Signal()
def keyPressEvent(self, event: QKeyEvent):
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
if event.modifiers() == Qt.NoModifier:
# Отправляем координаты по Enter
self.submitted.emit()
event.accept()
return
elif event.modifiers() & Qt.ShiftModifier:
super().keyPressEvent(event)
return
super().keyPressEvent(event)
# === MapDialog ===
from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.ticker import FormatStrFormatter
import matplotlib.patheffects as pe
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QFileDialog, QMessageBox
from PySide6.QtGui import QGuiApplication
from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
from matplotlib.backend_bases import MouseButton
import matplotlib.patheffects as pe
from PySide6.QtWidgets import QDialog, QVBoxLayout, QPushButton, QFileDialog, QMessageBox
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
class MapDialog(QDialog):
def __init__(self, parent, coords, distances, threshold_spinbox):
super().__init__(parent)
self.setWindowTitle("Карта координат")
self.resize(750, 560)
self.coords = coords
self.distances = distances
self.threshold = threshold_spinbox # общий спинбокс порога
# для pan/zoom matplotlib
self._pan_active = False
self._orig_xlim = None
self._orig_ylim = None
self._press_xy = None
self.osm_html = "" # HTML для сохранения в файл
self._init_ui()
self.threshold.valueChanged.connect(self._redraw_all)
def _init_ui(self):
layout = QVBoxLayout(self)
fig = Figure()
self.ax = fig.add_subplot(111)
self.ax.set_xlabel("Широта")
self.ax.set_ylabel("Долгота")
self.canvas = FigureCanvas(fig)
self.canvas.setFocusPolicy(Qt.ClickFocus)
self.canvas.setFocus()
layout.addWidget(self.canvas)
self.save_btn = QPushButton("Сохранить карту (OSM HTML)")
self.save_btn.clicked.connect(self._save_osm_as_html_and_show_path)
layout.addWidget(self.save_btn)
self.canvas.mpl_connect("button_press_event", self._on_press)
self.canvas.mpl_connect("button_release_event", self._on_release)
self.canvas.mpl_connect("motion_notify_event", self._on_motion)
self.canvas.mpl_connect("scroll_event", self._on_scroll)
self._redraw_all()
def _redraw_all(self):
self._draw_mpl()
self._build_osm_html()
def _draw_mpl(self):
ax = self.ax
ax.clear()
thr = float(self.threshold.value())
reds = {i for (i, j), d in self.distances.items() if d < thr} | {
j for (i, j), d in self.distances.items() if d < thr
}
for idx, (lat, lon) in enumerate(self.coords, 1):
is_red = (idx - 1) in reds
color = "red" if is_red else "blue"
ax.scatter([lon], [lat], s=110, c=color, edgecolors="white",
linewidths=1.8, zorder=3)
ax.text(lon, lat, str(idx),
ha="center", va="center",
fontsize=9, fontweight="bold", color="white", zorder=4,
path_effects=[pe.withStroke(linewidth=2.2, foreground="black")])
for (i, j), d in self.distances.items():
if d < thr:
x1, y1 = self.coords[i][1], self.coords[i][0]
x2, y2 = self.coords[j][1], self.coords[j][0]
ax.plot([x1, x2], [y1, y2], "-", color="red", linewidth=1.6, zorder=2)
mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
ax.text(mx, my, f"{d:.1f} м",
fontsize=9, fontweight="bold", color="black", zorder=5,
path_effects=[pe.withStroke(linewidth=3.0, foreground="white")])
if self.coords:
lats, lons = zip(*self.coords)
pad_lat = max((max(lats) - min(lats)) * 0.1, 0.01)
pad_lon = max((max(lons) - min(lons)) * 0.1, 0.01)
ax.set_xlim(min(lons) - pad_lon, max(lons) + pad_lon)
ax.set_ylim(min(lats) - pad_lat, max(lats) + pad_lat)
self._orig_xlim = ax.get_xlim()
self._orig_ylim = ax.get_ylim()
self.canvas.draw_idle()
def _on_press(self, event):
if event.inaxes is None:
return
if event.button in (MouseButton.LEFT, 1) and event.xdata is not None:
self._pan_active = True
self._orig_xlim = self.ax.get_xlim()
self._orig_ylim = self.ax.get_ylim()
self._press_xy = (event.x, event.y)
def _on_release(self, event):
self._pan_active = False
self._press_xy = None
def _on_motion(self, event):
if not self._pan_active or event.x is None or event.y is None:
return
if event.inaxes is None:
return
dx_pix = event.x - self._press_xy[0]
dy_pix = event.y - self._press_xy[1]
w_pix, h_pix = self.canvas.get_width_height()
x0, x1 = self._orig_xlim
y0, y1 = self._orig_ylim
dx_data = -dx_pix * (x1 - x0) / w_pix
dy_data = -dy_pix * (y1 - y0) / h_pix
self.ax.set_xlim(x0 + dx_data, x1 + dx_data)
self.ax.set_ylim(y0 + dy_data, y1 + dy_data)
self.canvas.draw_idle()
def _on_scroll(self, event):
# зум только внутри осей
if event.inaxes is None or event.xdata is None or event.ydata is None:
return
base_scale = 1.2
# up -> приближение, down -> отдаление
if hasattr(event, "step") and event.step != 0:
zoom_in = event.step > 0
else:
zoom_in = getattr(event, "button", "") == "up"
scale = (1 / base_scale) if zoom_in else base_scale
xdata, ydata = event.xdata, event.ydata
x0, x1 = self.ax.get_xlim()
y0, y1 = self.ax.get_ylim()
new_w = (x1 - x0) * scale
new_h = (y1 - y0) * scale
# сохраняем точку под курсором
relx = (xdata - x0) / (x1 - x0)
rely = (ydata - y0) / (y1 - y0)
self.ax.set_xlim(xdata - relx * new_w, xdata + (1 - relx) * new_w)
self.ax.set_ylim(ydata - rely * new_h, ydata + (1 - rely) * new_h)
# ВАЖНО: если панорамирование активно, обновляем baseline
if self._pan_active:
self._orig_xlim = self.ax.get_xlim()
self._orig_ylim = self.ax.get_ylim()
self._press_xy = (event.x, event.y) # перехватываем текущую позицию мыши
self.canvas.draw_idle()
def _build_osm_html(self):
"""Генерирует HTML с точками, связями и линейкой (кнопки Линейка/Очистить)."""
if not self.coords:
self.osm_html = "<html><body><h3 style='font-family:sans-serif'>Нет точек</h3></body></html>"
return
lat_center = sum(lat for lat, _ in self.coords) / len(self.coords)
lon_center = sum(lon for _, lon in self.coords) / len(self.coords)
thr = float(self.threshold.value())
# Сегменты короче порога
red_lines = []
for (i, j), d in self.distances.items():
if d < thr:
red_lines.append((self.coords[i], self.coords[j], d))
# JS-массивы
markers_js = ",".join(
f"[{lat:.8f},{lon:.8f},{idx}]"
for idx, (lat, lon) in enumerate(self.coords, 1)
)
segments_js = ",".join(
f"[[{a[0]:.8f},{a[1]:.8f}],[{b[0]:.8f},{b[1]:.8f}],{dist_m:.1f}]"
for (a, b, dist_m) in red_lines
)
# Чтобы не мучиться с экранированием {z}/{x}/{y} в f-string, соберём URL в JS
tile_url_js = "'https://tile.openstreetmap.org/' + '{z}' + '/' + '{x}' + '/' + '{y}' + '.png'"
self.osm_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body, #map {{ height: 100%; margin: 0; }}
.num {{
color: white; font-weight: bold; font-size: 10px;
text-shadow: 0 0 2px black, 1px 1px 2px black, -1px -1px 2px black;
}}
.dist {{
color: black; font-weight: bold; font-size: 12px;
text-shadow: -1px -1px 0 white, 1px -1px 0 white,
-1px 1px 0 white, 1px 1px 0 white, 0 0 3px white;
}}
.leaflet-control.custom-control button {{
background: white; border: 1px solid #888; padding: 4px 8px; margin: 2px;
cursor: pointer; font-size: 12px; border-radius: 4px;
}}
.leaflet-control.custom-control button.active {{ background: #cce5ff; }}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
<body>
<div id="map"></div>
<script>
var map = L.map('map').setView([{lat_center:.8f}, {lon_center:.8f}], 13);
var tileUrl = {tile_url_js};
L.tileLayer(tileUrl, {{
maxZoom: 19,
attribution: '© OpenStreetMap contributors'
}}).addTo(map);
var markers = [{markers_js}];
var segments = [{segments_js}];
var bounds = [];
// Точки из Python
markers.forEach(function(m) {{
var lat = m[0], lon = m[1], idx = m[2];
var color = 'blue';
for (var s of segments) {{
if ((s[0][0]==lat && s[0][1]==lon) || (s[1][0]==lat && s[1][1]==lon)) {{
color = 'red'; break;
}}
}}
var iconHtml = '<div style="width:18px;height:18px;border-radius:50%;background-color:'+color+';display:flex;align-items:center;justify-content:center;border:2px solid white;">'
+ '<span class="num">'+idx+'</span>'
+ '</div>';
var icon = L.divIcon({{className:'', html: iconHtml, iconSize:[18,18], iconAnchor:[9,9]}});
L.marker([lat, lon], {{icon: icon}}).addTo(map);
bounds.push([lat, lon]);
}});
// Линии из Python и их подписи
segments.forEach(function(seg) {{
var p1 = seg[0], p2 = seg[1], dist = seg[2];
L.polyline([p1, p2], {{color: 'red', weight: 2}}).addTo(map);
var mid = [(p1[0]+p2[0])/2.0, (p1[1]+p2[1])/2.0];
var labelHtml = '<div class="dist">'+dist+' м</div>';
L.marker(mid, {{ icon: L.divIcon({{className:'', html: labelHtml}}), interactive: false }}).addTo(map);
}});
if (bounds.length > 0) {{
map.fitBounds(L.latLngBounds(bounds).pad(0.2));
}}
// ---------- ЛИНЕЙКА ----------
var measureEnabled = false;
var measurePoints = []; // L.Marker[]
var measureLines = []; // L.Polyline[]
var measureLabels = []; // L.Marker с divIcon
function toggleMeasure(btn) {{
measureEnabled = !measureEnabled;
if (btn) btn.classList.toggle('active', measureEnabled);
}}
function clearMeasure() {{
measurePoints.forEach(function(p) {{ map.removeLayer(p); }});
measureLines.forEach(function(l) {{ map.removeLayer(l); }});
measureLabels.forEach(function(lbl) {{ map.removeLayer(lbl); }});
measurePoints = [];
measureLines = [];
measureLabels = [];
}}
// Панель управления
var Ctrl = L.Control.extend({{
onAdd: function(map) {{
var div = L.DomUtil.create('div', 'leaflet-control custom-control');
var btnR = L.DomUtil.create('button', '', div);
btnR.textContent = 'Линейка';
btnR.onclick = function(e) {{
L.DomEvent.stop(e);
toggleMeasure(btnR);
}};
var btnC = L.DomUtil.create('button', '', div);
btnC.textContent = 'Очистить';
btnC.onclick = function(e) {{
L.DomEvent.stop(e);
clearMeasure();
}};
L.DomEvent.disableClickPropagation(div);
return div;
}},
onRemove: function(map) {{}}
}});
(new Ctrl({{position:'topright'}})).addTo(map);
// Клики по карте в режиме линейки: ставим маркер, соединяем с предыдущим, выводим длину
map.on('click', function(e) {{
if (!measureEnabled) return;
var m = L.marker(e.latlng).addTo(map);
measurePoints.push(m);
if (measurePoints.length % 2 === 0) {{
var p1 = measurePoints[measurePoints.length-2].getLatLng();
var p2 = measurePoints[measurePoints.length-1].getLatLng();
var dist = map.distance(p1, p2).toFixed(1);
var line = L.polyline([p1, p2], {{color: 'green', weight: 2, dashArray: '5,5'}}).addTo(map);
measureLines.push(line);
var mid = L.latLng((p1.lat+p2.lat)/2, (p1.lng+p2.lng)/2);
var lbl = L.marker(mid, {{
icon: L.divIcon({{className:'', html:'<div class="dist">'+dist+' м</div>'}}),
interactive: false
}}).addTo(map);
measureLabels.push(lbl);
}}
}});
</script>
</body>
</html>"""
def _save_osm_as_html_and_show_path(self):
if not self.osm_html:
QMessageBox.information(self, "Нет данных", "Нет карты для сохранения.")
return
default_name = "map_osm.html"
path, _ = QFileDialog.getSaveFileName(self, "Сохранить OSM как HTML", default_name, "HTML (*.html)")
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.osm_html)
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить файл:\n{e}")
return
QGuiApplication.clipboard().setText(path)
QMessageBox.information(self, "Готово",
f"HTML сохранён.\nПуть скопирован в буфер обмена:\n\n{path}\n\n"
f"Откройте его в браузере вручную.")
# === CoordinateHelperTab ===
class CoordinateHelperTab(QWidget):
def __init__(self):
super().__init__()
self.coords = []
self.distances = {}
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Координаты (шир, долг):"))
# --- Поле для массового ввода с отправкой по Enter ---
self.entry = SubmitTextEdit()
self.entry.setPlaceholderText("Вставьте несколько координат (каждая строка — пара lat, lon).\nПримеры:\n55.75, 37.62\n55.751244 37.618423\n+55.75 -37.62")
self.entry.setFixedHeight(80)
self.entry.submitted.connect(self._add_bulk)
layout.addWidget(self.entry)
btns = QHBoxLayout()
imp = QPushButton("Импорт координат .txt")
imp.clicked.connect(self._import)
btns.addWidget(imp)
layout.addLayout(btns)
thr = QHBoxLayout()
thr.addWidget(QLabel("Порог (м):"))
self.threshold = QDoubleSpinBox()
self.threshold.setRange(1, 10000)
self.threshold.setValue(25)
thr.addWidget(self.threshold)
layout.addLayout(thr)
self.lst = QListWidget()
self.lst.setSelectionMode(QListWidget.ExtendedSelection)
layout.addWidget(self.lst)
self.lst.keyPressEvent = self._list_key_event
bot = QHBoxLayout()
mbtn = QPushButton("Открыть карту")
mbtn.clicked.connect(self._show_map)
bot.addWidget(mbtn)
ebtn = QPushButton("Экспорт GPX")
ebtn.clicked.connect(self._export)
bot.addWidget(ebtn)
layout.addLayout(bot)
# ——— Парсинг пачки координат из текста
@staticmethod
def _parse_pairs_from_text(text: str):
pairs = []
# Берём все float/целые с возможными знаками
nums = re.findall(r"[-+]?(?:\d*\.\d+|\d+)", text)
vals = [float(n) for n in nums]
if len(vals) < 2:
return pairs
# Группируем попарно: lat, lon
for i in range(0, len(vals) - 1, 2):
lat, lon = vals[i], vals[i + 1]
pairs.append((lat, lon))
return pairs
def _add_bulk(self):
text = self.entry.toPlainText().strip()
if not text:
QMessageBox.information(self, "Пусто", "Вставьте координаты.")
return
pairs = self._parse_pairs_from_text(text)
if not pairs:
QMessageBox.critical(self, "Ошибка", "Не удалось распознать пары координат.")
return
self.coords.extend(pairs)
self.entry.clear()
self._refresh()
def _import(self):
self.coords.clear()
fn, _ = QFileDialog.getOpenFileName(self, "Файл", HOME, "*.txt")
if fn:
with open(fn, encoding="utf-8") as f:
content = f.read()
pairs = self._parse_pairs_from_text(content)
if not pairs:
QMessageBox.critical(self, "Ошибка", "В файле не найдены координаты.")
return
self.coords.extend(pairs)
self._refresh()
def _list_key_event(self, event):
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
rows = sorted([self.lst.row(it) for it in self.lst.selectedItems()], reverse=True)
for r in rows:
del self.coords[r]
self._refresh()
else:
super(QListWidget, self.lst).keyPressEvent(event)
def _recalc_distances(self):
self.distances.clear()
for i in range(len(self.coords)):
for j in range(i + 1, len(self.coords)):
d = geodesic(self.coords[i], self.coords[j]).m
self.distances[(i, j)] = d
def _refresh(self):
self.lst.clear()
self._recalc_distances()
for idx, (la, lo) in enumerate(self.coords, 1):
self.lst.addItem(f"{idx}. {la}, {lo}")
def _show_map(self):
if self.coords:
self._recalc_distances()
dlg = MapDialog(self, self.coords, self.distances, self.threshold)
dlg.exec()
else:
QMessageBox.information(self, "Пусто", "Нет координат для показа.")
def _export(self):
fn, _ = QFileDialog.getSaveFileName(self, "GPX", HOME, "*.gpx")
if fn:
with open(fn, "w", encoding="utf-8") as f:
f.write('<?xml version="1.0"?><gpx>')
for i, (la, lo) in enumerate(self.coords, 1):
f.write(f'<wpt lat="{la}" lon="{lo}"><name>#{i}</name></wpt>')
f.write("</gpx>")
# === PostimgParserTab — без изменений логики ===
class PostimgParserTab(QWidget):
append_signal = Signal(str)
update_hash_status = Signal(int, int)
update_download_status = Signal(int, int)
def __init__(self):
super().__init__()
self.all_images = []
self.direct_map = {}
self.hashes = {}
self._init_ui()
self.append_signal.connect(self._append_output)
self.update_hash_status.connect(self._show_hash_checked)
self.update_download_status.connect(self._show_download)
def _init_ui(self):
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Postimg.cc ссылки:"))
self.input_text = QTextEdit()
self.input_text.setFixedHeight(60)
layout.addWidget(self.input_text)
btns1 = QHBoxLayout()
self.parse_btn = QPushButton("🔍 Анализ")
self.parse_btn.clicked.connect(self._on_parse)
btns1.addWidget(self.parse_btn)
self.download_btn = QPushButton("💾 Скачать все")
self.download_btn.clicked.connect(self._on_download_all)
self.download_btn.setEnabled(False)
btns1.addWidget(self.download_btn)
layout.addLayout(btns1)
btns2 = QHBoxLayout()
imp = QPushButton("📂 Импорт ссылок")
imp.clicked.connect(self._on_import)
btns2.addWidget(imp)
cmp = QPushButton("📂 Сравнить")
cmp.clicked.connect(self._on_compare_photos)
btns2.addWidget(cmp)
layout.addLayout(btns2)
hthr = QHBoxLayout()
hthr.addWidget(QLabel("Порог хеша (бит):"))
self.hash_threshold = QSpinBox()
self.hash_threshold.setRange(0, 64)
self.hash_threshold.setValue(5)
hthr.addWidget(self.hash_threshold)
layout.addLayout(hthr)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(0)
layout.addWidget(self.progress)
layout.addWidget(QLabel("Результат:"))
self.output = QTextEdit()
self.output.setReadOnly(True)
layout.addWidget(self.output)
def _append_output(self, text):
self.output.append(text)
def _show_hash_checked(self, count, total):
self._status("Хэш проверен", f"{count}/{total}")
self.progress.setValue(int(count / total * 100))
def _show_download(self, done, total):
self._status("Скачано", f"{done}/{total}")
def _status(self, prefix, text):
doc = self.output.document()
for ln in range(doc.blockCount()):
block = doc.findBlockByLineNumber(ln)
if block.text().startswith(prefix):
cursor = self.output.textCursor()
cursor.setPosition(block.position())
cursor.select(QTextCursor.LineUnderCursor)
cursor.removeSelectedText()
cursor.insertText(f"{prefix} {text}")
return
self.output.append(f"{prefix} {text}")
def _on_import(self):
self.output.clear()
fn, _ = QFileDialog.getOpenFileName(self, "TXT", HOME, "*.txt")
if fn:
with open(fn, "r", encoding="utf-8") as f:
self.input_text.setPlainText(f.read())
def _on_parse(self):
self.output.clear()
self.download_btn.setEnabled(False)
self.progress.setValue(0)
threading.Thread(target=self._parse_and_check, daemon=True).start()
def _parse_and_check(self):
sess = requests.Session()
sess.headers.update({"User-Agent": "Mozilla/5.0"})
urls = [u.strip() for u in self.input_text.toPlainText().splitlines() if u.strip()]
if not urls:
self.append_signal.emit("Введите ссылку")
return
self.all_images.clear()
self.direct_map.clear()
self.hashes.clear()
self.append_signal.emit("=== Анализ дубликатов — поиск исходных ссылок ===")
for url in urls:
if not url.startswith("https://postimg.cc/"):
self.append_signal.emit(f"Пропущено: {url}")
continue
try:
r = sess.get(url, timeout=10)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
pages = ([urljoin(r.url, a["href"]) for a in soup.select("div#thumb-list a.img[href]")]
if "gallery" in url else [url])
for page in pages:
r2 = sess.get(page, timeout=10)
r2.raise_for_status()
s2 = BeautifulSoup(r2.text, "html.parser")
img = s2.find("img", id="main-image") or next(
(t for t in s2.find_all("img", src=True) if "i.postimg.cc" in t["src"]), None)
if img:
link = urljoin(r2.url, img["src"])
self.direct_map[link] = page
self.all_images.append(link)
except Exception:
self.append_signal.emit(f"Ошибка парсинга: {url}")
total = len(self.all_images)
if total == 0:
self.append_signal.emit("Ничего не найдено.")
return
self.append_signal.emit("\n=== Исходные ссылки ===")
for idx, link in enumerate(self.all_images, 1):
self.append_signal.emit(f"{idx}. {self.direct_map[link]}")
for idx, img_url in enumerate(self.all_images, 1):
try:
data = sess.get(img_url, timeout=10).content
h = imagehash.average_hash(Image.open(BytesIO(data)))
self.hashes[img_url] = h
self.update_hash_status.emit(idx, total)
except Exception:
self.append_signal.emit(f"Ошибка при получении хеша: {img_url}")
thr = self.hash_threshold.value()
dup_count = 0
visited = set()
self.append_signal.emit("\n=== Дубликаты (hamming <= " + str(thr) + ") ===")
for i in range(total):
for j in range(i + 1, total):
u1, u2 = self.all_images[i], self.all_images[j]
h1, h2 = self.hashes.get(u1), self.hashes.get(u2)
if h1 is not None and h2 is not None and (h1 - h2) <= thr and (u1, u2) not in visited:
dup_count += 1
self.append_signal.emit(f"{dup_count}. Дубликаты:")
self.append_signal.emit(f" - {self.direct_map[u1]}")
self.append_signal.emit(f" - {self.direct_map[u2]}")
self.append_signal.emit("")
visited.add((u1, u2))
if dup_count == 0:
self.append_signal.emit("Совпадений не найдено.")
self.append_signal.emit("=== Анализ завершен ===")
self.download_btn.setEnabled(True)
def _on_download_all(self):
self.output.clear()
folder = QFileDialog.getExistingDirectory(self, "Папка")
if not folder:
return
total = len(self.all_images)
for idx, url in enumerate(self.all_images, 1):
try:
data = requests.get(url, timeout=15).content
ext = url.split('.')[-1].split('?')[0][:4]
fname = os.path.join(folder, f"{idx}.{ext}")
with open(fname, "wb") as f:
f.write(data)
self.update_download_status.emit(idx, total)
except Exception as e:
self.append_signal.emit(f"Ошибка скачивания: {url}\n{e}")
def _on_compare_photos(self):
self.output.clear()
fns, _ = QFileDialog.getOpenFileNames(self, "Выберите фото", HOME, "Images (*.jpg *.png)")
if len(fns) < 2:
QMessageBox.information(self, "Внимание", "Нужно минимум 2 фото")
return
self.append_signal.emit("\n=== Сравнение фото ===")
groups = {}
for p in fns:
try:
key = hashlib.md5(open(p, "rb").read()).hexdigest()
groups.setdefault(key, []).append(os.path.basename(p))
except Exception:
pass
dup_count = 0
for dup in groups.values():
if len(dup) > 1:
dup_count += 1
self.append_signal.emit(f"{dup_count}. Найдены совпадения:")
for name in dup:
self.append_signal.emit(f" - {name}")
self.append_signal.emit("")
if dup_count == 0:
self.append_signal.emit("Совпадений не найдено.")
# === MainWindow ===
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("EVSEGN Cord Helper")
self.resize(420, 520)
tabs = QTabWidget()
tabs.addTab(CoordinateHelperTab(), "Координаты")
tabs.addTab(PostimgParserTab(), "Фото")
self.setCentralWidget(tabs)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
Новый код уже ждет в шапке