Release v0.7.5 portal-only refactor
This commit is contained in:
parent
7787c9f247
commit
b1943c2f4b
14 changed files with 751 additions and 2118 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -9,6 +9,5 @@ wheels/
|
||||||
# Virtual environments
|
# Virtual environments
|
||||||
.venv
|
.venv
|
||||||
|
|
||||||
kwin_capture_screen
|
|
||||||
portal_capture_frame
|
portal_capture_frame
|
||||||
*.moc
|
*.moc
|
||||||
|
|
|
||||||
30
Makefile
30
Makefile
|
|
@ -1,26 +1,13 @@
|
||||||
CXX ?= g++
|
CXX ?= g++
|
||||||
PKG_CONFIG ?= pkg-config
|
PKG_CONFIG ?= pkg-config
|
||||||
PREFIX ?= /usr/local
|
|
||||||
BIN_DIR ?= $(PREFIX)/bin
|
|
||||||
APPLICATIONS_DIR ?= $(PREFIX)/share/applications
|
|
||||||
SYSTEM_HELPER := $(BIN_DIR)/reforger-kwin-capture
|
|
||||||
DESKTOP_FILE := org.codex.reforger-kwin-capture.desktop
|
|
||||||
|
|
||||||
QT_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus)
|
|
||||||
QT_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus)
|
|
||||||
PORTAL_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0)
|
|
||||||
PORTAL_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0)
|
|
||||||
MOC ?= /usr/lib/qt6/moc
|
MOC ?= /usr/lib/qt6/moc
|
||||||
|
|
||||||
.PHONY: all clean install-kwin-auth uninstall-kwin-auth
|
PORTAL_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0)
|
||||||
|
PORTAL_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus Qt6Gui gstreamer-1.0 gstreamer-app-1.0 gstreamer-video-1.0)
|
||||||
|
|
||||||
all: kwin_capture_screen portal_capture_frame
|
.PHONY: all clean
|
||||||
|
|
||||||
kwin_capture_screen: kwin_capture_screen.cpp kwin_capture_screen.moc
|
all: portal_capture_frame
|
||||||
$(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(QT_CFLAGS) $< -o $@ $(QT_LIBS)
|
|
||||||
|
|
||||||
kwin_capture_screen.moc: kwin_capture_screen.cpp
|
|
||||||
$(MOC) $< -o $@
|
|
||||||
|
|
||||||
portal_capture_frame: portal_capture_frame.cpp portal_capture_frame.moc
|
portal_capture_frame: portal_capture_frame.cpp portal_capture_frame.moc
|
||||||
$(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(PORTAL_CFLAGS) $< -o $@ $(PORTAL_LIBS)
|
$(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(PORTAL_CFLAGS) $< -o $@ $(PORTAL_LIBS)
|
||||||
|
|
@ -28,12 +15,5 @@ portal_capture_frame: portal_capture_frame.cpp portal_capture_frame.moc
|
||||||
portal_capture_frame.moc: portal_capture_frame.cpp
|
portal_capture_frame.moc: portal_capture_frame.cpp
|
||||||
$(MOC) $< -o $@
|
$(MOC) $< -o $@
|
||||||
|
|
||||||
install-kwin-auth: kwin_capture_screen $(DESKTOP_FILE)
|
|
||||||
install -Dm755 kwin_capture_screen "$(DESTDIR)$(SYSTEM_HELPER)"
|
|
||||||
install -Dm644 $(DESKTOP_FILE) "$(DESTDIR)$(APPLICATIONS_DIR)/$(DESKTOP_FILE)"
|
|
||||||
|
|
||||||
uninstall-kwin-auth:
|
|
||||||
rm -f "$(DESTDIR)$(SYSTEM_HELPER)" "$(DESTDIR)$(APPLICATIONS_DIR)/$(DESKTOP_FILE)"
|
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f kwin_capture_screen kwin_capture_screen.moc portal_capture_frame portal_capture_frame.moc
|
rm -f portal_capture_frame portal_capture_frame.moc
|
||||||
|
|
|
||||||
112
README.md
112
README.md
|
|
@ -1,8 +1,8 @@
|
||||||
# Anti Prestige Tool
|
# Anti Prestige Tool v0.7.5
|
||||||
|
|
||||||
Queue-position reader for Arma Reforger on Linux.
|
Queue-position reader for Arma Reforger on Linux Wayland.
|
||||||
|
|
||||||
The tool captures the Reforger queue screen, crops the queue-number region, and reads the orange UI digits with bundled real-game digit templates. It does not use Tesseract, OpenCV, or Pillow.
|
The tool captures a user-selected Reforger window through xdg-desktop-portal/PipeWire, crops the queue-number region, and reads the orange UI digits with bundled real-game digit templates. Normal use does not require external OCR libraries, desktop-specific screenshot hooks, or a specific installed font.
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ Install system dependencies:
|
||||||
sudo pacman -S --needed base-devel uv qt6-base gstreamer gst-plugins-base gst-plugin-pipewire imagemagick xdg-desktop-portal
|
sudo pacman -S --needed base-devel uv qt6-base gstreamer gst-plugins-base gst-plugin-pipewire imagemagick xdg-desktop-portal
|
||||||
```
|
```
|
||||||
|
|
||||||
You also need a portal backend for your desktop:
|
Install the portal backend for your desktop:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
KDE: xdg-desktop-portal-kde
|
KDE: xdg-desktop-portal-kde
|
||||||
|
|
@ -21,17 +21,18 @@ GNOME: xdg-desktop-portal-gnome
|
||||||
wlr: xdg-desktop-portal-wlr
|
wlr: xdg-desktop-portal-wlr
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the local helpers:
|
Set up the Python environment and build the portal helper:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
uv sync
|
||||||
make
|
make
|
||||||
```
|
```
|
||||||
|
|
||||||
No sudo install is required for the default Portal/PipeWire capture path. No specific font package is required for normal use.
|
No sudo install step is required.
|
||||||
|
|
||||||
## Use
|
## Use
|
||||||
|
|
||||||
Preferred command:
|
Run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run reforger_queue_read.py --portal-window --show-crop --debug
|
uv run reforger_queue_read.py --portal-window --show-crop --debug
|
||||||
|
|
@ -43,13 +44,13 @@ On first run, your desktop should show a window-sharing picker. Select the Arma
|
||||||
~/.local/state/anti-prestige-tool/portal-window-restore-token
|
~/.local/state/anti-prestige-tool/portal-window-restore-token
|
||||||
```
|
```
|
||||||
|
|
||||||
Later runs reuse that token:
|
Later runs try to reuse that token:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run reforger_queue_read.py --portal-window --show-crop --debug
|
uv run reforger_queue_read.py --portal-window --show-crop --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
If the saved window selection is wrong or stale:
|
Force a fresh picker if the saved selection is wrong or stale:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run reforger_queue_read.py --portal-window --portal-reselect --show-crop --debug
|
uv run reforger_queue_read.py --portal-window --portal-reselect --show-crop --debug
|
||||||
|
|
@ -61,85 +62,27 @@ Save the captured input image while debugging:
|
||||||
uv run reforger_queue_read.py --portal-window --save-input /tmp/apt-portal-window.png --show-crop --debug
|
uv run reforger_queue_read.py --portal-window --save-input /tmp/apt-portal-window.png --show-crop --debug
|
||||||
```
|
```
|
||||||
|
|
||||||
The default matcher uses `templates/reforger_digits.json`. Font mode is only for debugging:
|
## Validation
|
||||||
|
|
||||||
```bash
|
Run the regression set:
|
||||||
uv run reforger_queue_read.py --portal-window --font Roboto-Condensed --show-crop --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
## Optional KDE/KWin Mode
|
|
||||||
|
|
||||||
KWin mode can automatically find the Arma window by KWin metadata and can rebind after the game restarts. It is KDE-specific and requires installing an authorized helper.
|
|
||||||
|
|
||||||
Install the helper:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo make install-kwin-auth
|
|
||||||
kbuildsycoca6
|
|
||||||
```
|
|
||||||
|
|
||||||
Run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run reforger_queue_read.py --kwin-window auto --show-crop --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
If auto matching is ambiguous, list windows and pass the PID or internal id:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run reforger_queue_read.py --list-kwin-windows
|
|
||||||
uv run reforger_queue_read.py --kwin-window 53561 --show-crop --debug
|
|
||||||
uv run reforger_queue_read.py --kwin-window-id '{77a38955-a827-479c-971b-af8de226ac7b}' --show-crop --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
The auth install places:
|
|
||||||
|
|
||||||
```text
|
|
||||||
/usr/local/bin/reforger-kwin-capture
|
|
||||||
/usr/local/share/applications/org.codex.reforger-kwin-capture.desktop
|
|
||||||
```
|
|
||||||
|
|
||||||
The desktop file must be in a root-owned application directory because KWin does not trust user-writable desktop entries for restricted screenshot interfaces.
|
|
||||||
|
|
||||||
## Other Inputs
|
|
||||||
|
|
||||||
Read an existing screenshot:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
Use Spectacle/KDE screen capture:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run reforger_queue_read.py --capture --screen DP-3 --show-crop --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
Use an OBS scene screenshot:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run reforger_queue_read.py --obs-scene APT --show-crop --debug
|
|
||||||
```
|
|
||||||
|
|
||||||
Validate the regression set:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run reforger_queue_read.py --dataset datasets/regression-test-set --expect-filenames
|
uv run reforger_queue_read.py --dataset datasets/regression-test-set --expect-filenames
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected current result:
|
Expected result:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
summary: 21 passed, 0 failed
|
summary: 21 passed, 0 failed
|
||||||
```
|
```
|
||||||
|
|
||||||
Validate the smaller included dataset:
|
Run the smaller smoke dataset:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop
|
uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop
|
||||||
```
|
```
|
||||||
|
|
||||||
Expected current output:
|
Expected output:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
datasets/scootz-dataset/1920x1080.png crop=788,465,75,45
|
datasets/scootz-dataset/1920x1080.png crop=788,465,75,45
|
||||||
|
|
@ -148,7 +91,26 @@ datasets/scootz-dataset/2160x1440.png crop=1050,620,100,60
|
||||||
datasets/scootz-dataset/2160x1440.png 24
|
datasets/scootz-dataset/2160x1440.png 24
|
||||||
```
|
```
|
||||||
|
|
||||||
## Notes
|
## Debug Options
|
||||||
|
|
||||||
|
Read an existing screenshot:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug
|
||||||
|
```
|
||||||
|
|
||||||
|
The default matcher uses bundled templates from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
templates/reforger_digits.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Font mode is only for debugging. Known-good overrides:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run reforger_queue_read.py --portal-window --font Roboto-Condensed --show-crop --debug
|
||||||
|
uv run reforger_queue_read.py --portal-window --font Adwaita-Sans --show-crop --debug
|
||||||
|
```
|
||||||
|
|
||||||
Default crop reference:
|
Default crop reference:
|
||||||
|
|
||||||
|
|
@ -158,6 +120,4 @@ reference-crop: 1050,620,100,60
|
||||||
scale-mode: width
|
scale-mode: width
|
||||||
```
|
```
|
||||||
|
|
||||||
Portal/PipeWire is easiest to install, but restore behavior depends on the desktop portal. If the selected window disappears after an Arma restart, the portal may show the picker again.
|
Portal restore behavior depends on the desktop portal. If the selected window disappears after an Arma restart, the portal may show the picker again.
|
||||||
|
|
||||||
KWin mode is the best KDE automation path, but it has the extra authorization install step.
|
|
||||||
|
|
|
||||||
|
|
@ -1,334 +0,0 @@
|
||||||
#include <QCoreApplication>
|
|
||||||
#include <QDBusConnection>
|
|
||||||
#include <QDBusError>
|
|
||||||
#include <QDBusInterface>
|
|
||||||
#include <QDBusReply>
|
|
||||||
#include <QDBusUnixFileDescriptor>
|
|
||||||
#include <QDir>
|
|
||||||
#include <QFile>
|
|
||||||
#include <QJsonArray>
|
|
||||||
#include <QJsonDocument>
|
|
||||||
#include <QJsonObject>
|
|
||||||
#include <QTemporaryFile>
|
|
||||||
#include <QTextStream>
|
|
||||||
#include <QTimer>
|
|
||||||
#include <QVariantMap>
|
|
||||||
|
|
||||||
#include <unistd.h>
|
|
||||||
|
|
||||||
class WindowReceiver : public QObject
|
|
||||||
{
|
|
||||||
Q_OBJECT
|
|
||||||
Q_CLASSINFO("D-Bus Interface", "org.codex.ReforgerQueueKWinProbe")
|
|
||||||
|
|
||||||
public:
|
|
||||||
QString json;
|
|
||||||
QString error;
|
|
||||||
bool done = false;
|
|
||||||
|
|
||||||
public slots:
|
|
||||||
void WindowsJson(const QString &payload)
|
|
||||||
{
|
|
||||||
json = payload;
|
|
||||||
done = true;
|
|
||||||
QCoreApplication::quit();
|
|
||||||
}
|
|
||||||
|
|
||||||
void Error(const QString &message)
|
|
||||||
{
|
|
||||||
error = message;
|
|
||||||
done = true;
|
|
||||||
QCoreApplication::quit();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
QDBusConnection sessionBus()
|
|
||||||
{
|
|
||||||
QDBusConnection bus = QDBusConnection::sessionBus();
|
|
||||||
if (bus.isConnected()) {
|
|
||||||
return bus;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString runtimeDir = qEnvironmentVariable("XDG_RUNTIME_DIR");
|
|
||||||
if (runtimeDir.isEmpty()) {
|
|
||||||
runtimeDir = QStringLiteral("/run/user/%1").arg(getuid());
|
|
||||||
}
|
|
||||||
|
|
||||||
return QDBusConnection::connectToBus(
|
|
||||||
QStringLiteral("unix:path=%1/bus").arg(runtimeDir),
|
|
||||||
QStringLiteral("reforger-queue-session"));
|
|
||||||
}
|
|
||||||
|
|
||||||
QString jsString(const QString &value)
|
|
||||||
{
|
|
||||||
QJsonArray array;
|
|
||||||
array.append(value);
|
|
||||||
QByteArray encoded = QJsonDocument(array).toJson(QJsonDocument::Compact);
|
|
||||||
encoded.chop(1);
|
|
||||||
encoded.remove(0, 1);
|
|
||||||
return QString::fromUtf8(encoded);
|
|
||||||
}
|
|
||||||
|
|
||||||
int capture(const QString &method, const QString &target, const QString &outputPath, QTextStream &out, QTextStream &err)
|
|
||||||
{
|
|
||||||
QFile file(outputPath);
|
|
||||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
|
|
||||||
err << "failed to open output file: " << file.errorString() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QDBusConnection bus = sessionBus();
|
|
||||||
if (!bus.isConnected()) {
|
|
||||||
err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QDBusInterface iface(
|
|
||||||
QStringLiteral("org.kde.KWin"),
|
|
||||||
QStringLiteral("/org/kde/KWin/ScreenShot2"),
|
|
||||||
QStringLiteral("org.kde.KWin.ScreenShot2"),
|
|
||||||
bus);
|
|
||||||
|
|
||||||
if (!iface.isValid()) {
|
|
||||||
err << "failed to connect to KWin ScreenShot2: "
|
|
||||||
<< iface.lastError().message() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QVariantMap options;
|
|
||||||
QDBusUnixFileDescriptor fd(file.handle());
|
|
||||||
QDBusReply<QVariantMap> reply = iface.call(method, target, options, QVariant::fromValue(fd));
|
|
||||||
|
|
||||||
file.close();
|
|
||||||
|
|
||||||
if (!reply.isValid()) {
|
|
||||||
err << method << " failed for " << target << ": "
|
|
||||||
<< reply.error().name() << ": " << reply.error().message() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QJsonObject resultJson;
|
|
||||||
const QVariantMap resultMap = reply.value();
|
|
||||||
for (auto it = resultMap.constBegin(); it != resultMap.constEnd(); ++it) {
|
|
||||||
resultJson.insert(it.key(), QJsonValue::fromVariant(it.value()));
|
|
||||||
}
|
|
||||||
out << QJsonDocument(resultJson).toJson(QJsonDocument::Compact) << "\n";
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
QString windowProbeScript(const QString &service, const QString &path, const QString &interface)
|
|
||||||
{
|
|
||||||
return QStringLiteral(R"JS(
|
|
||||||
function safeString(value) {
|
|
||||||
if (value === undefined || value === null) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
return String(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeNumber(value) {
|
|
||||||
var number = Number(value);
|
|
||||||
return isNaN(number) ? 0 : number;
|
|
||||||
}
|
|
||||||
|
|
||||||
function safeBool(value) {
|
|
||||||
return Boolean(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function prop(object, name, fallback) {
|
|
||||||
try {
|
|
||||||
if (object[name] === undefined || object[name] === null) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
return object[name];
|
|
||||||
} catch (error) {
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rect(value) {
|
|
||||||
if (!value) {
|
|
||||||
return {"x": 0, "y": 0, "width": 0, "height": 0};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
"x": Math.round(safeNumber(prop(value, "x", 0))),
|
|
||||||
"y": Math.round(safeNumber(prop(value, "y", 0))),
|
|
||||||
"width": Math.round(safeNumber(prop(value, "width", 0))),
|
|
||||||
"height": Math.round(safeNumber(prop(value, "height", 0)))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function outputName(window) {
|
|
||||||
try {
|
|
||||||
if (window.output && window.output.name) {
|
|
||||||
return String(window.output.name);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
}
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function windowInfo(window) {
|
|
||||||
var geometry = rect(prop(window, "frameGeometry", prop(window, "bufferGeometry", null)));
|
|
||||||
return {
|
|
||||||
"internalId": safeString(prop(window, "internalId", "")),
|
|
||||||
"pid": safeNumber(prop(window, "pid", 0)),
|
|
||||||
"caption": safeString(prop(window, "caption", "")),
|
|
||||||
"resourceClass": safeString(prop(window, "resourceClass", "")),
|
|
||||||
"resourceName": safeString(prop(window, "resourceName", "")),
|
|
||||||
"windowRole": safeString(prop(window, "windowRole", "")),
|
|
||||||
"desktopFileName": safeString(prop(window, "desktopFileName", "")),
|
|
||||||
"output": outputName(window),
|
|
||||||
"frameGeometry": geometry,
|
|
||||||
"normalWindow": safeBool(prop(window, "normalWindow", false)),
|
|
||||||
"fullScreen": safeBool(prop(window, "fullScreen", false)),
|
|
||||||
"minimized": safeBool(prop(window, "minimized", false)),
|
|
||||||
"hidden": safeBool(prop(window, "hidden", false)),
|
|
||||||
"keepAbove": safeBool(prop(window, "keepAbove", false)),
|
|
||||||
"skipTaskbar": safeBool(prop(window, "skipTaskbar", false))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var windows = workspace.windowList().map(windowInfo);
|
|
||||||
callDBus(%1, %2, %3, "WindowsJson", JSON.stringify(windows));
|
|
||||||
} catch (error) {
|
|
||||||
callDBus(%1, %2, %3, "Error", safeString(error && error.stack ? error.stack : error));
|
|
||||||
}
|
|
||||||
)JS")
|
|
||||||
.arg(jsString(service), jsString(path), jsString(interface));
|
|
||||||
}
|
|
||||||
|
|
||||||
int listWindows(QCoreApplication &app, QTextStream &out, QTextStream &err)
|
|
||||||
{
|
|
||||||
QDBusConnection bus = sessionBus();
|
|
||||||
if (!bus.isConnected()) {
|
|
||||||
err << "failed to connect to the session D-Bus: " << bus.lastError().message() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const QString service = QStringLiteral("org.codex.ReforgerQueueKWinProbe%1").arg(QCoreApplication::applicationPid());
|
|
||||||
const QString path = QStringLiteral("/Probe");
|
|
||||||
const QString interface = QStringLiteral("org.codex.ReforgerQueueKWinProbe");
|
|
||||||
|
|
||||||
WindowReceiver receiver;
|
|
||||||
if (!bus.registerService(service)) {
|
|
||||||
err << "failed to register D-Bus service " << service << ": " << bus.lastError().message() << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (!bus.registerObject(path, &receiver, QDBusConnection::ExportAllSlots)) {
|
|
||||||
err << "failed to register D-Bus object " << path << ": " << bus.lastError().message() << "\n";
|
|
||||||
bus.unregisterService(service);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QTemporaryFile scriptFile;
|
|
||||||
scriptFile.setFileTemplate(QDir::tempPath() + QStringLiteral("/reforger-kwin-window-probe-XXXXXX.js"));
|
|
||||||
if (!scriptFile.open()) {
|
|
||||||
err << "failed to create temporary KWin script: " << scriptFile.errorString() << "\n";
|
|
||||||
bus.unregisterObject(path);
|
|
||||||
bus.unregisterService(service);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
scriptFile.write(windowProbeScript(service, path, interface).toUtf8());
|
|
||||||
scriptFile.close();
|
|
||||||
|
|
||||||
const QString pluginName = QStringLiteral("reforger-queue-window-probe-%1").arg(QCoreApplication::applicationPid());
|
|
||||||
QDBusInterface scripting(
|
|
||||||
QStringLiteral("org.kde.KWin"),
|
|
||||||
QStringLiteral("/Scripting"),
|
|
||||||
QStringLiteral("org.kde.kwin.Scripting"),
|
|
||||||
bus);
|
|
||||||
|
|
||||||
if (!scripting.isValid()) {
|
|
||||||
err << "failed to connect to KWin scripting: " << scripting.lastError().message() << "\n";
|
|
||||||
bus.unregisterObject(path);
|
|
||||||
bus.unregisterService(service);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QDBusReply<int> loadReply = scripting.call(QStringLiteral("loadScript"), scriptFile.fileName(), pluginName);
|
|
||||||
if (!loadReply.isValid() || loadReply.value() < 0) {
|
|
||||||
err << "failed to load KWin window probe script: "
|
|
||||||
<< loadReply.error().name() << ": " << loadReply.error().message() << "\n";
|
|
||||||
bus.unregisterObject(path);
|
|
||||||
bus.unregisterService(service);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const int scriptId = loadReply.value();
|
|
||||||
QDBusInterface script(
|
|
||||||
QStringLiteral("org.kde.KWin"),
|
|
||||||
QStringLiteral("/Scripting/Script%1").arg(scriptId),
|
|
||||||
QStringLiteral("org.kde.kwin.Script"),
|
|
||||||
bus);
|
|
||||||
QDBusMessage runReply = script.call(QStringLiteral("run"));
|
|
||||||
if (runReply.type() == QDBusMessage::ErrorMessage) {
|
|
||||||
err << "failed to run KWin window probe script: "
|
|
||||||
<< runReply.errorName() << ": " << runReply.errorMessage() << "\n";
|
|
||||||
scripting.call(QStringLiteral("unloadScript"), pluginName);
|
|
||||||
bus.unregisterObject(path);
|
|
||||||
bus.unregisterService(service);
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
QTimer::singleShot(5000, &app, [&receiver]() {
|
|
||||||
if (!receiver.done) {
|
|
||||||
receiver.error = QStringLiteral("timed out waiting for KWin window probe results");
|
|
||||||
receiver.done = true;
|
|
||||||
QCoreApplication::quit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
app.exec();
|
|
||||||
|
|
||||||
scripting.call(QStringLiteral("unloadScript"), pluginName);
|
|
||||||
bus.unregisterObject(path);
|
|
||||||
bus.unregisterService(service);
|
|
||||||
|
|
||||||
if (!receiver.error.isEmpty()) {
|
|
||||||
err << receiver.error << "\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (receiver.json.isEmpty()) {
|
|
||||||
err << "KWin window probe returned no data\n";
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
out << receiver.json << "\n";
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
int main(int argc, char *argv[])
|
|
||||||
{
|
|
||||||
QCoreApplication app(argc, argv);
|
|
||||||
QTextStream out(stdout);
|
|
||||||
QTextStream err(stderr);
|
|
||||||
|
|
||||||
const QStringList args = QCoreApplication::arguments();
|
|
||||||
|
|
||||||
if (args.size() == 3 && !args.at(1).startsWith(QLatin1String("--"))) {
|
|
||||||
return capture(QStringLiteral("CaptureScreen"), args.at(1), args.at(2), out, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.size() == 2 && args.at(1) == QLatin1String("--list-windows")) {
|
|
||||||
return listWindows(app, out, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.size() == 4 && args.at(1) == QLatin1String("--capture-screen")) {
|
|
||||||
return capture(QStringLiteral("CaptureScreen"), args.at(2), args.at(3), out, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.size() == 4 && args.at(1) == QLatin1String("--capture-window")) {
|
|
||||||
return capture(QStringLiteral("CaptureWindow"), args.at(2), args.at(3), out, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
err << "usage:\n"
|
|
||||||
<< " " << args.value(0) << " OUTPUT_NAME OUTPUT_FILE\n"
|
|
||||||
<< " " << args.value(0) << " --capture-screen OUTPUT_NAME OUTPUT_FILE\n"
|
|
||||||
<< " " << args.value(0) << " --capture-window INTERNAL_ID OUTPUT_FILE\n"
|
|
||||||
<< " " << args.value(0) << " --list-windows\n";
|
|
||||||
return 64;
|
|
||||||
}
|
|
||||||
|
|
||||||
#include "kwin_capture_screen.moc"
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
[Desktop Entry]
|
|
||||||
Type=Application
|
|
||||||
Name=Reforger KWin Capture Helper
|
|
||||||
Comment=Authorized KWin screenshot helper for the Reforger queue reader
|
|
||||||
Exec=/usr/local/bin/reforger-kwin-capture
|
|
||||||
NoDisplay=true
|
|
||||||
Terminal=false
|
|
||||||
Categories=Utility;
|
|
||||||
X-KDE-DBUS-Restricted-Interfaces=org.kde.KWin.ScreenShot2
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
[project]
|
[project]
|
||||||
name = "anti-prestige-tool"
|
name = "anti-prestige-tool"
|
||||||
version = "0.1.0"
|
version = "0.7.5"
|
||||||
description = "Add your description here"
|
description = "Arma Reforger queue-position reader for Linux Wayland"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
|
||||||
1
reforger_queue/__init__.py
Normal file
1
reforger_queue/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
"""Queue reader internals for anti-prestige-tool."""
|
||||||
234
reforger_queue/cli.py
Normal file
234
reforger_queue/cli.py
Normal file
|
|
@ -0,0 +1,234 @@
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
from .config import (
|
||||||
|
DEFAULT_POINTSIZE,
|
||||||
|
DEFAULT_PORTAL_RESTORE_TOKEN,
|
||||||
|
DEFAULT_REFERENCE_CROP,
|
||||||
|
DEFAULT_REFERENCE_SIZE,
|
||||||
|
DEFAULT_TEMPLATE_SET,
|
||||||
|
)
|
||||||
|
from .magick import run_bytes
|
||||||
|
from .ocr import (
|
||||||
|
read_queue_number,
|
||||||
|
resolve_crop,
|
||||||
|
templates_from_dataset,
|
||||||
|
write_template_set,
|
||||||
|
)
|
||||||
|
from .portal import capture_portal_window
|
||||||
|
|
||||||
|
|
||||||
|
def parse_crop(value):
|
||||||
|
parts = value.replace(",", " ").split()
|
||||||
|
if len(parts) != 4:
|
||||||
|
raise argparse.ArgumentTypeError("crop must be four numbers: x,y,width,height")
|
||||||
|
try:
|
||||||
|
return tuple(int(part) for part in parts)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError("crop values must be integers") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_size(value):
|
||||||
|
parts = value.lower().replace(",", "x").split("x")
|
||||||
|
if len(parts) != 2:
|
||||||
|
raise argparse.ArgumentTypeError("size must be WIDTHxHEIGHT")
|
||||||
|
try:
|
||||||
|
return int(parts[0]), int(parts[1])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise argparse.ArgumentTypeError("size values must be integers") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def image_paths(directory):
|
||||||
|
extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||||
|
return [
|
||||||
|
os.path.join(directory, name)
|
||||||
|
for name in sorted(os.listdir(directory))
|
||||||
|
if os.path.splitext(name)[1].lower() in extensions
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def run_dataset(args):
|
||||||
|
paths = image_paths(args.dataset)
|
||||||
|
if not paths:
|
||||||
|
print("no dataset images found")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
status = 0
|
||||||
|
passed = 0
|
||||||
|
failed = 0
|
||||||
|
for path in paths:
|
||||||
|
crop = resolve_crop(path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||||
|
if args.show_crop and crop:
|
||||||
|
print(f"{path}\tcrop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
||||||
|
number, details = read_queue_number(path, crop, args.template_set, args.font, args.pointsize, args.cropped)
|
||||||
|
if not number:
|
||||||
|
status = 2
|
||||||
|
failed += 1
|
||||||
|
suffix = ""
|
||||||
|
if args.expect_filenames:
|
||||||
|
expected = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
suffix = f"\texpected={expected}\tFAIL"
|
||||||
|
print(f"{path}\tno digits found{suffix}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
suffix = ""
|
||||||
|
if args.expect_filenames:
|
||||||
|
expected = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
if number == expected:
|
||||||
|
passed += 1
|
||||||
|
suffix = f"\texpected={expected}\tPASS"
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
status = 1
|
||||||
|
suffix = f"\texpected={expected}\tFAIL"
|
||||||
|
|
||||||
|
print(f"{path}\t{number}{suffix}")
|
||||||
|
if args.debug:
|
||||||
|
for digit, bbox, area, hamming, iou in details:
|
||||||
|
print(f" digit={digit} bbox={bbox} area={area} hamming={hamming:.3f} iou={iou:.3f}")
|
||||||
|
if args.expect_filenames:
|
||||||
|
print(f"summary: {passed} passed, {failed} failed")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def build_template_set(args):
|
||||||
|
paths = image_paths(args.build_template_set)
|
||||||
|
if not paths:
|
||||||
|
print("no template source images found")
|
||||||
|
return 2
|
||||||
|
templates = templates_from_dataset(paths, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||||
|
write_template_set(args.template_output, templates, paths)
|
||||||
|
print(args.template_output)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def read_single_image(args):
|
||||||
|
crop = resolve_crop(args.image, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||||
|
if args.show_crop and crop:
|
||||||
|
print(f"crop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
||||||
|
number, details = read_queue_number(args.image, crop, args.template_set, args.font, args.pointsize, args.cropped)
|
||||||
|
if not number:
|
||||||
|
print("no digits found")
|
||||||
|
return 2
|
||||||
|
print(number)
|
||||||
|
if args.debug:
|
||||||
|
for digit, bbox, area, hamming, iou in details:
|
||||||
|
print(f"digit={digit} bbox={bbox} area={area} hamming={hamming:.3f} iou={iou:.3f}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def read_portal_window(args):
|
||||||
|
fd, image_path = tempfile.mkstemp(prefix="reforger-queue-", suffix=".png")
|
||||||
|
os.close(fd)
|
||||||
|
try:
|
||||||
|
portal_info = capture_portal_window(
|
||||||
|
image_path,
|
||||||
|
args.portal_restore_token,
|
||||||
|
args.portal_reselect,
|
||||||
|
args.portal_timeout,
|
||||||
|
)
|
||||||
|
if args.save_input:
|
||||||
|
run_bytes(["magick", image_path, "+repage", args.save_input])
|
||||||
|
crop = resolve_crop(image_path, args.crop, args.reference_crop, args.reference_size, args.scale_mode, args.cropped)
|
||||||
|
if args.show_crop and crop:
|
||||||
|
print(f"crop={crop[0]},{crop[1]},{crop[2]},{crop[3]}")
|
||||||
|
number, details = read_queue_number(image_path, crop, args.template_set, args.font, args.pointsize, args.cropped)
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.unlink(image_path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not number:
|
||||||
|
print("no digits found")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
print(number)
|
||||||
|
if args.debug:
|
||||||
|
print(f"portal-window node_id={portal_info.get('node_id', '')} streams={len(portal_info.get('streams', []))}")
|
||||||
|
if portal_info.get("restore_token") and args.portal_restore_token:
|
||||||
|
print(f"portal-restore-token {args.portal_restore_token}")
|
||||||
|
for digit, bbox, area, hamming, iou in details:
|
||||||
|
print(f"digit={digit} bbox={bbox} area={area} hamming={hamming:.3f} iou={iou:.3f}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Read Arma Reforger queue number from a fixed screen crop.")
|
||||||
|
parser.add_argument("--image", help="Read from an existing image.")
|
||||||
|
parser.add_argument("--dataset", help="Read every image in a dataset directory.")
|
||||||
|
parser.add_argument("--portal-window", action="store_true", help="Capture a user-selected Wayland window.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--portal-restore-token",
|
||||||
|
default=DEFAULT_PORTAL_RESTORE_TOKEN,
|
||||||
|
help="Path used to store the portal restore token for later runs.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--portal-reselect",
|
||||||
|
action="store_true",
|
||||||
|
help="Ignore the saved portal restore token and show the portal picker again.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--portal-timeout",
|
||||||
|
type=float,
|
||||||
|
default=60.0,
|
||||||
|
help="Seconds to wait for portal selection and the first PipeWire frame.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--cropped", action="store_true", help="Treat input image(s) as already cropped to the queue number.")
|
||||||
|
parser.add_argument("--crop", type=parse_crop, help="Absolute crop as x,y,width,height.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--reference-crop",
|
||||||
|
type=parse_crop,
|
||||||
|
default=DEFAULT_REFERENCE_CROP,
|
||||||
|
help="Reference crop as x,y,width,height, scaled to the input image size when --crop is omitted.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reference-size",
|
||||||
|
type=parse_size,
|
||||||
|
default=DEFAULT_REFERENCE_SIZE,
|
||||||
|
help="Reference screen size for --reference-crop scaling.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scale-mode",
|
||||||
|
choices=("width", "independent"),
|
||||||
|
default="width",
|
||||||
|
help="How to scale --reference-crop. Width mode preserves a 16:9 UI layout and is the default.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--template-set",
|
||||||
|
default=DEFAULT_TEMPLATE_SET,
|
||||||
|
help="Digit template JSON used by default recognition.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--font", help="Render synthetic digit templates from an ImageMagick font instead of using --template-set.")
|
||||||
|
parser.add_argument("--pointsize", type=int, default=DEFAULT_POINTSIZE, help="Point size used for --font templates.")
|
||||||
|
parser.add_argument("--debug", action="store_true", help="Print component and match details.")
|
||||||
|
parser.add_argument("--show-crop", action="store_true", help="Print the resolved crop used for each full-size image.")
|
||||||
|
parser.add_argument("--save-input", help="Save the live captured portal input image for debugging.")
|
||||||
|
parser.add_argument(
|
||||||
|
"--expect-filenames",
|
||||||
|
action="store_true",
|
||||||
|
help="In dataset mode, compare detected values to each image filename stem.",
|
||||||
|
)
|
||||||
|
parser.add_argument("--build-template-set", metavar="DATASET", help=argparse.SUPPRESS)
|
||||||
|
parser.add_argument("--template-output", default=DEFAULT_TEMPLATE_SET, help=argparse.SUPPRESS)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.portal_reselect and not args.portal_window:
|
||||||
|
parser.error("--portal-reselect can only be used with --portal-window")
|
||||||
|
if args.save_input and not args.portal_window:
|
||||||
|
parser.error("--save-input can only be used with --portal-window")
|
||||||
|
if args.portal_timeout <= 0:
|
||||||
|
parser.error("--portal-timeout must be positive")
|
||||||
|
|
||||||
|
selected_modes = [args.image, args.dataset, args.build_template_set, args.portal_window]
|
||||||
|
if sum(bool(value) for value in selected_modes) != 1:
|
||||||
|
parser.error("choose exactly one of --image, --dataset, --portal-window, or --build-template-set")
|
||||||
|
|
||||||
|
if args.build_template_set:
|
||||||
|
return build_template_set(args)
|
||||||
|
if args.dataset:
|
||||||
|
return run_dataset(args)
|
||||||
|
if args.image:
|
||||||
|
return read_single_image(args)
|
||||||
|
return read_portal_window(args)
|
||||||
19
reforger_queue/config.py
Normal file
19
reforger_queue/config.py
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
DEFAULT_REFERENCE_SIZE = (2560, 1440)
|
||||||
|
DEFAULT_REFERENCE_CROP = (1050, 620, 100, 60)
|
||||||
|
DEFAULT_TEMPLATE_SET = os.path.join(PROJECT_DIR, "templates", "reforger_digits.json")
|
||||||
|
DEFAULT_POINTSIZE = 43
|
||||||
|
DEFAULT_PORTAL_RESTORE_TOKEN = os.path.expanduser(
|
||||||
|
"~/.local/state/anti-prestige-tool/portal-window-restore-token"
|
||||||
|
)
|
||||||
|
AUTO_FONT_CANDIDATES = (
|
||||||
|
"Roboto-Condensed",
|
||||||
|
"RobotoCondensed-Regular",
|
||||||
|
"Roboto-Condensed-Regular",
|
||||||
|
"Roboto",
|
||||||
|
"Adwaita-Sans",
|
||||||
|
"Adwaita-Sans-Bold",
|
||||||
|
)
|
||||||
83
reforger_queue/magick.py
Normal file
83
reforger_queue/magick.py
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def run_bytes(args):
|
||||||
|
try:
|
||||||
|
return subprocess.run(args, check=True, stdout=subprocess.PIPE).stdout
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit(f"missing required command: {args[0]}")
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
sys.exit(f"command failed ({exc.returncode}): {' '.join(args)}")
|
||||||
|
|
||||||
|
|
||||||
|
def run_text(args):
|
||||||
|
return run_bytes(args).decode("utf-8", "replace")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ppm(data):
|
||||||
|
idx = 0
|
||||||
|
|
||||||
|
def next_token():
|
||||||
|
nonlocal idx
|
||||||
|
while idx < len(data) and data[idx] in b" \t\r\n":
|
||||||
|
idx += 1
|
||||||
|
if idx < len(data) and data[idx] == ord("#"):
|
||||||
|
while idx < len(data) and data[idx] not in b"\r\n":
|
||||||
|
idx += 1
|
||||||
|
return next_token()
|
||||||
|
start = idx
|
||||||
|
while idx < len(data) and data[idx] not in b" \t\r\n":
|
||||||
|
idx += 1
|
||||||
|
return data[start:idx]
|
||||||
|
|
||||||
|
magic = next_token()
|
||||||
|
if magic != b"P6":
|
||||||
|
sys.exit("ImageMagick did not return binary PPM data")
|
||||||
|
|
||||||
|
width = int(next_token())
|
||||||
|
height = int(next_token())
|
||||||
|
max_value = int(next_token())
|
||||||
|
if max_value != 255:
|
||||||
|
sys.exit(f"unsupported PPM max value: {max_value}")
|
||||||
|
|
||||||
|
while idx < len(data) and data[idx] in b" \t\r\n":
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
return width, height, data[idx:]
|
||||||
|
|
||||||
|
|
||||||
|
def ppm_from_magick(args):
|
||||||
|
return parse_ppm(run_bytes(["magick", *args, "-alpha", "off", "-depth", "8", "ppm:-"]))
|
||||||
|
|
||||||
|
|
||||||
|
def try_ppm_from_magick(args):
|
||||||
|
command = ["magick", *args, "-alpha", "off", "-depth", "8", "ppm:-"]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit("missing required command: magick")
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None, result.stderr.decode("utf-8", "replace").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
return parse_ppm(result.stdout), ""
|
||||||
|
except Exception as exc:
|
||||||
|
return None, str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def magick_fonts():
|
||||||
|
fonts = set()
|
||||||
|
for line in run_text(["magick", "-list", "font"]).splitlines():
|
||||||
|
match = re.match(r"\s*Font:\s*(\S+)", line)
|
||||||
|
if match:
|
||||||
|
fonts.add(match.group(1))
|
||||||
|
return fonts
|
||||||
|
|
||||||
|
|
||||||
|
def image_size(image_path):
|
||||||
|
output = run_bytes(["magick", "identify", "-format", "%w %h", image_path]).decode("ascii")
|
||||||
|
width, height = output.split()
|
||||||
|
return int(width), int(height)
|
||||||
294
reforger_queue/ocr.py
Normal file
294
reforger_queue/ocr.py
Normal file
|
|
@ -0,0 +1,294 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .config import AUTO_FONT_CANDIDATES
|
||||||
|
from .magick import image_size, magick_fonts, ppm_from_magick, try_ppm_from_magick
|
||||||
|
|
||||||
|
|
||||||
|
def orange_digit_mask(width, height, raw):
|
||||||
|
mask = [[False] * width for _ in range(height)]
|
||||||
|
for y in range(height):
|
||||||
|
row_offset = y * width * 3
|
||||||
|
for x in range(width):
|
||||||
|
offset = row_offset + x * 3
|
||||||
|
r, g, b = raw[offset], raw[offset + 1], raw[offset + 2]
|
||||||
|
mask[y][x] = r >= 115 and 55 <= g <= 205 and b <= 100 and r - g >= 20
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def white_mask(width, height, raw):
|
||||||
|
mask = [[False] * width for _ in range(height)]
|
||||||
|
for y in range(height):
|
||||||
|
row_offset = y * width * 3
|
||||||
|
for x in range(width):
|
||||||
|
offset = row_offset + x * 3
|
||||||
|
r, g, b = raw[offset], raw[offset + 1], raw[offset + 2]
|
||||||
|
mask[y][x] = r >= 120 and g >= 120 and b >= 120
|
||||||
|
return mask
|
||||||
|
|
||||||
|
|
||||||
|
def connected_components(mask):
|
||||||
|
height = len(mask)
|
||||||
|
width = len(mask[0]) if height else 0
|
||||||
|
seen = [[False] * width for _ in range(height)]
|
||||||
|
components = []
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
for x in range(width):
|
||||||
|
if seen[y][x] or not mask[y][x]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
stack = [(x, y)]
|
||||||
|
seen[y][x] = True
|
||||||
|
points = set()
|
||||||
|
|
||||||
|
while stack:
|
||||||
|
cx, cy = stack.pop()
|
||||||
|
points.add((cx, cy))
|
||||||
|
for ny in range(cy - 1, cy + 2):
|
||||||
|
for nx in range(cx - 1, cx + 2):
|
||||||
|
if nx < 0 or ny < 0 or nx >= width or ny >= height:
|
||||||
|
continue
|
||||||
|
if seen[ny][nx] or not mask[ny][nx]:
|
||||||
|
continue
|
||||||
|
seen[ny][nx] = True
|
||||||
|
stack.append((nx, ny))
|
||||||
|
|
||||||
|
xs = [point[0] for point in points]
|
||||||
|
ys = [point[1] for point in points]
|
||||||
|
bbox = (min(xs), min(ys), max(xs), max(ys))
|
||||||
|
box_width = bbox[2] - bbox[0] + 1
|
||||||
|
box_height = bbox[3] - bbox[1] + 1
|
||||||
|
|
||||||
|
if len(points) >= 40 and box_width >= 5 and box_height >= 15:
|
||||||
|
components.append({"points": points, "bbox": bbox, "area": len(points)})
|
||||||
|
|
||||||
|
components.sort(key=lambda component: component["bbox"][0])
|
||||||
|
return components
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(component, width=24, height=36):
|
||||||
|
x1, y1, x2, y2 = component["bbox"]
|
||||||
|
source_width = x2 - x1 + 1
|
||||||
|
source_height = y2 - y1 + 1
|
||||||
|
points = component["points"]
|
||||||
|
output = []
|
||||||
|
|
||||||
|
for y in range(height):
|
||||||
|
source_y = y1 + int((y + 0.5) * source_height / height)
|
||||||
|
row = []
|
||||||
|
for x in range(width):
|
||||||
|
source_x = x1 + int((x + 0.5) * source_width / width)
|
||||||
|
row.append((source_x, source_y) in points)
|
||||||
|
output.append(row)
|
||||||
|
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def template_distance(left, right):
|
||||||
|
total = len(left) * len(left[0])
|
||||||
|
differences = 0
|
||||||
|
intersection = 0
|
||||||
|
union = 0
|
||||||
|
|
||||||
|
for left_row, right_row in zip(left, right):
|
||||||
|
for left_value, right_value in zip(left_row, right_row):
|
||||||
|
differences += left_value != right_value
|
||||||
|
intersection += left_value and right_value
|
||||||
|
union += left_value or right_value
|
||||||
|
|
||||||
|
hamming = differences / total
|
||||||
|
iou = intersection / union if union else 0.0
|
||||||
|
return hamming, iou
|
||||||
|
|
||||||
|
|
||||||
|
def template_font_candidates(font):
|
||||||
|
if font != "auto":
|
||||||
|
return [font]
|
||||||
|
|
||||||
|
available = magick_fonts()
|
||||||
|
candidates = [candidate for candidate in AUTO_FONT_CANDIDATES if candidate in available]
|
||||||
|
return [*candidates, None]
|
||||||
|
|
||||||
|
|
||||||
|
def build_template_args(font, pointsize):
|
||||||
|
args = ["-background", "black", "-fill", "white"]
|
||||||
|
if font:
|
||||||
|
args.extend(["-font", font])
|
||||||
|
args.extend(["-pointsize", str(pointsize), "label:0123456789"])
|
||||||
|
return args
|
||||||
|
|
||||||
|
|
||||||
|
def build_templates(font, pointsize):
|
||||||
|
errors = []
|
||||||
|
for candidate in template_font_candidates(font):
|
||||||
|
ppm, error = try_ppm_from_magick(build_template_args(candidate, pointsize))
|
||||||
|
if ppm is None:
|
||||||
|
errors.append(f"{candidate or 'ImageMagick default'}: {error}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
width, height, raw = ppm
|
||||||
|
components = connected_components(white_mask(width, height, raw))
|
||||||
|
if len(components) < 10:
|
||||||
|
errors.append(f"{candidate or 'ImageMagick default'}: rendered only {len(components)} digit components")
|
||||||
|
continue
|
||||||
|
return {str(index): [normalize(component)] for index, component in enumerate(components[:10])}
|
||||||
|
|
||||||
|
if font == "auto":
|
||||||
|
sys.exit("could not render digit templates with any available ImageMagick font:\n " + "\n ".join(errors))
|
||||||
|
sys.exit(
|
||||||
|
f"could not render digit templates with font {font!r}. "
|
||||||
|
"Install that font, use --font auto, or pass another ImageMagick font name."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_template_grid(grid, digit, sample_index, path):
|
||||||
|
if not isinstance(grid, list) or len(grid) != 36:
|
||||||
|
sys.exit(f"invalid template grid for digit {digit} sample {sample_index} in {path}: expected 36 rows")
|
||||||
|
for row in grid:
|
||||||
|
if not isinstance(row, list) or len(row) != 24 or any(not isinstance(value, bool) for value in row):
|
||||||
|
sys.exit(
|
||||||
|
f"invalid template grid for digit {digit} sample {sample_index} in {path}: "
|
||||||
|
"expected 24 boolean columns per row"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def load_template_set(path):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit(f"missing digit template set {path}; regenerate it from the regression dataset")
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
sys.exit(f"failed to parse digit template set {path}: {exc}")
|
||||||
|
|
||||||
|
digits = payload.get("digits") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(digits, dict):
|
||||||
|
sys.exit(f"invalid digit template set {path}: missing digits object")
|
||||||
|
|
||||||
|
templates = {}
|
||||||
|
for digit in "0123456789":
|
||||||
|
samples = digits.get(digit)
|
||||||
|
if not isinstance(samples, list) or not samples:
|
||||||
|
sys.exit(f"invalid digit template set {path}: missing samples for digit {digit}")
|
||||||
|
templates[digit] = []
|
||||||
|
for index, grid in enumerate(samples):
|
||||||
|
validate_template_grid(grid, digit, index, path)
|
||||||
|
templates[digit].append(grid)
|
||||||
|
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def templates_from_dataset(paths, reference_crop, reference_size, scale_mode, cropped):
|
||||||
|
templates = {str(digit): [] for digit in range(10)}
|
||||||
|
for path in paths:
|
||||||
|
expected = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
if not expected.isdigit():
|
||||||
|
sys.exit(f"template source filename must be numeric: {path}")
|
||||||
|
|
||||||
|
crop = resolve_crop(path, None, reference_crop, reference_size, scale_mode, cropped)
|
||||||
|
width, height, raw = load_queue_image(path, crop, cropped)
|
||||||
|
components = connected_components(orange_digit_mask(width, height, raw))
|
||||||
|
if len(components) != len(expected):
|
||||||
|
sys.exit(
|
||||||
|
f"template source {path} produced {len(components)} digit components, "
|
||||||
|
f"but filename expects {len(expected)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
for digit, component in zip(expected, components):
|
||||||
|
templates[digit].append(normalize(component))
|
||||||
|
|
||||||
|
missing = [digit for digit, samples in templates.items() if not samples]
|
||||||
|
if missing:
|
||||||
|
sys.exit(f"template dataset has no samples for digits: {', '.join(missing)}")
|
||||||
|
return templates
|
||||||
|
|
||||||
|
|
||||||
|
def write_template_set(output, templates, source_paths):
|
||||||
|
payload = {
|
||||||
|
"version": 1,
|
||||||
|
"normalize_size": [24, 36],
|
||||||
|
"source": "regression digit crops",
|
||||||
|
"source_files": [os.path.basename(path) for path in source_paths],
|
||||||
|
"digits": templates,
|
||||||
|
}
|
||||||
|
directory = os.path.dirname(output)
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
temp_path = f"{output}.tmp"
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as handle:
|
||||||
|
json.dump(payload, handle, separators=(",", ":"))
|
||||||
|
handle.write("\n")
|
||||||
|
os.replace(temp_path, output)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_templates(template_set, font, pointsize):
|
||||||
|
if font:
|
||||||
|
return build_templates(font, pointsize)
|
||||||
|
return load_template_set(template_set)
|
||||||
|
|
||||||
|
|
||||||
|
def classify(component, templates):
|
||||||
|
sample = normalize(component)
|
||||||
|
ranked = []
|
||||||
|
for digit, digit_templates in templates.items():
|
||||||
|
for template in digit_templates:
|
||||||
|
hamming, iou = template_distance(sample, template)
|
||||||
|
ranked.append((hamming, -iou, digit, iou))
|
||||||
|
ranked.sort()
|
||||||
|
hamming, negative_iou, digit, iou = ranked[0]
|
||||||
|
return digit, hamming, iou
|
||||||
|
|
||||||
|
|
||||||
|
def load_queue_image(image_path, crop, already_cropped=False):
|
||||||
|
if already_cropped:
|
||||||
|
return ppm_from_magick([image_path, "+repage"])
|
||||||
|
|
||||||
|
x, y, width, height = crop
|
||||||
|
return ppm_from_magick([image_path, "+repage", "-crop", f"{width}x{height}+{x}+{y}", "+repage"])
|
||||||
|
|
||||||
|
|
||||||
|
def read_queue_number(image_path, crop, template_set, font, pointsize, already_cropped=False):
|
||||||
|
width, height, raw = load_queue_image(image_path, crop, already_cropped)
|
||||||
|
components = connected_components(orange_digit_mask(width, height, raw))
|
||||||
|
templates = resolve_templates(template_set, font, pointsize)
|
||||||
|
|
||||||
|
digits = []
|
||||||
|
details = []
|
||||||
|
for component in components:
|
||||||
|
digit, hamming, iou = classify(component, templates)
|
||||||
|
digits.append(digit)
|
||||||
|
details.append((digit, component["bbox"], component["area"], hamming, iou))
|
||||||
|
|
||||||
|
return "".join(digits), details
|
||||||
|
|
||||||
|
|
||||||
|
def scale_crop(reference_crop, reference_size, target_size, scale_mode):
|
||||||
|
reference_width, reference_height = reference_size
|
||||||
|
target_width, target_height = target_size
|
||||||
|
x, y, width, height = reference_crop
|
||||||
|
|
||||||
|
if scale_mode == "width":
|
||||||
|
x_scale = target_width / reference_width
|
||||||
|
y_scale = x_scale
|
||||||
|
elif scale_mode == "independent":
|
||||||
|
x_scale = target_width / reference_width
|
||||||
|
y_scale = target_height / reference_height
|
||||||
|
else:
|
||||||
|
raise ValueError(f"unsupported scale mode: {scale_mode}")
|
||||||
|
|
||||||
|
return (
|
||||||
|
round(x * x_scale),
|
||||||
|
round(y * y_scale),
|
||||||
|
max(1, round(width * x_scale)),
|
||||||
|
max(1, round(height * y_scale)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_crop(image_path, explicit_crop, reference_crop, reference_size, scale_mode, already_cropped):
|
||||||
|
if already_cropped:
|
||||||
|
return None
|
||||||
|
if explicit_crop:
|
||||||
|
return explicit_crop
|
||||||
|
return scale_crop(reference_crop, reference_size, image_size(image_path), scale_mode)
|
||||||
75
reforger_queue/portal.py
Normal file
75
reforger_queue/portal.py
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .config import PROJECT_DIR
|
||||||
|
|
||||||
|
|
||||||
|
def portal_helper_path():
|
||||||
|
return os.path.join(PROJECT_DIR, "portal_capture_frame")
|
||||||
|
|
||||||
|
|
||||||
|
def require_portal_helper():
|
||||||
|
helper = portal_helper_path()
|
||||||
|
if not os.path.exists(helper):
|
||||||
|
sys.exit(f"missing {helper}; run `make` in {PROJECT_DIR}")
|
||||||
|
if not os.access(helper, os.X_OK):
|
||||||
|
sys.exit(f"portal helper is not executable: {helper}")
|
||||||
|
return helper
|
||||||
|
|
||||||
|
|
||||||
|
def read_portal_restore_token(path):
|
||||||
|
try:
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
return handle.read().strip()
|
||||||
|
except FileNotFoundError:
|
||||||
|
return ""
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(f"failed to read portal restore token {path}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def write_portal_restore_token(path, token):
|
||||||
|
directory = os.path.dirname(path)
|
||||||
|
if directory:
|
||||||
|
os.makedirs(directory, exist_ok=True)
|
||||||
|
temp_path = f"{path}.tmp"
|
||||||
|
try:
|
||||||
|
with open(temp_path, "w", encoding="utf-8") as handle:
|
||||||
|
handle.write(token)
|
||||||
|
handle.write("\n")
|
||||||
|
os.replace(temp_path, path)
|
||||||
|
except OSError as exc:
|
||||||
|
sys.exit(f"failed to write portal restore token {path}: {exc}")
|
||||||
|
|
||||||
|
|
||||||
|
def capture_portal_window(output, restore_token_path, reselect, timeout):
|
||||||
|
helper = require_portal_helper()
|
||||||
|
restore_token = ""
|
||||||
|
if restore_token_path and not reselect:
|
||||||
|
restore_token = read_portal_restore_token(restore_token_path)
|
||||||
|
|
||||||
|
command = [helper, "--window", output, "--timeout", str(timeout)]
|
||||||
|
if restore_token_path:
|
||||||
|
command.append("--persist")
|
||||||
|
if restore_token:
|
||||||
|
command.extend(["--restore-token", restore_token])
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
||||||
|
except subprocess.CalledProcessError as exc:
|
||||||
|
message = exc.stderr.strip() if exc.stderr else str(exc)
|
||||||
|
sys.exit(message)
|
||||||
|
|
||||||
|
capture_info = {}
|
||||||
|
if result.stdout.strip():
|
||||||
|
try:
|
||||||
|
capture_info = json.loads(result.stdout)
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
sys.exit(f"failed to parse portal capture metadata: {exc}: {result.stdout!r}")
|
||||||
|
|
||||||
|
new_restore_token = str(capture_info.get("restore_token", "") or "")
|
||||||
|
if restore_token_path and new_restore_token:
|
||||||
|
write_portal_restore_token(restore_token_path, new_restore_token)
|
||||||
|
|
||||||
|
return capture_info
|
||||||
File diff suppressed because it is too large
Load diff
2
uv.lock
generated
2
uv.lock
generated
|
|
@ -4,5 +4,5 @@ requires-python = ">=3.10"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anti-prestige-tool"
|
name = "anti-prestige-tool"
|
||||||
version = "0.1.0"
|
version = "0.7.5"
|
||||||
source = { virtual = "." }
|
source = { virtual = "." }
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue