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
|
||||
.venv
|
||||
|
||||
kwin_capture_screen
|
||||
portal_capture_frame
|
||||
*.moc
|
||||
|
|
|
|||
30
Makefile
30
Makefile
|
|
@ -1,26 +1,13 @@
|
|||
CXX ?= g++
|
||||
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
|
||||
|
||||
.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
|
||||
$(CXX) -std=c++17 -O2 -Wall -Wextra -fPIC $(QT_CFLAGS) $< -o $@ $(QT_LIBS)
|
||||
|
||||
kwin_capture_screen.moc: kwin_capture_screen.cpp
|
||||
$(MOC) $< -o $@
|
||||
all: portal_capture_frame
|
||||
|
||||
portal_capture_frame: portal_capture_frame.cpp portal_capture_frame.moc
|
||||
$(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
|
||||
$(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:
|
||||
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
|
||||
|
||||
|
|
@ -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
|
||||
```
|
||||
|
||||
You also need a portal backend for your desktop:
|
||||
Install the portal backend for your desktop:
|
||||
|
||||
```text
|
||||
KDE: xdg-desktop-portal-kde
|
||||
|
|
@ -21,17 +21,18 @@ GNOME: xdg-desktop-portal-gnome
|
|||
wlr: xdg-desktop-portal-wlr
|
||||
```
|
||||
|
||||
Build the local helpers:
|
||||
Set up the Python environment and build the portal helper:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
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
|
||||
|
||||
Preferred command:
|
||||
Run:
|
||||
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
Later runs reuse that token:
|
||||
Later runs try to reuse that token:
|
||||
|
||||
```bash
|
||||
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
|
||||
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
|
||||
```
|
||||
|
||||
The default matcher uses `templates/reforger_digits.json`. Font mode is only for debugging:
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
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:
|
||||
Run the regression set:
|
||||
|
||||
```bash
|
||||
uv run reforger_queue_read.py --dataset datasets/regression-test-set --expect-filenames
|
||||
```
|
||||
|
||||
Expected current result:
|
||||
Expected result:
|
||||
|
||||
```text
|
||||
summary: 21 passed, 0 failed
|
||||
```
|
||||
|
||||
Validate the smaller included dataset:
|
||||
Run the smaller smoke dataset:
|
||||
|
||||
```bash
|
||||
uv run reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop
|
||||
```
|
||||
|
||||
Expected current output:
|
||||
Expected output:
|
||||
|
||||
```text
|
||||
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
|
||||
```
|
||||
|
||||
## 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:
|
||||
|
||||
|
|
@ -158,6 +120,4 @@ reference-crop: 1050,620,100,60
|
|||
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.
|
||||
|
||||
KWin mode is the best KDE automation path, but it has the extra authorization install step.
|
||||
Portal restore behavior depends on the desktop portal. If the selected window disappears after an Arma restart, the portal may show the picker again.
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
name = "anti-prestige-tool"
|
||||
version = "0.1.0"
|
||||
description = "Add your description here"
|
||||
version = "0.7.5"
|
||||
description = "Arma Reforger queue-position reader for Linux Wayland"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
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]]
|
||||
name = "anti-prestige-tool"
|
||||
version = "0.1.0"
|
||||
version = "0.7.5"
|
||||
source = { virtual = "." }
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue