Release v0.7.5 portal-only refactor

This commit is contained in:
scootz 2026-05-01 14:18:16 +01:00
parent 7787c9f247
commit b1943c2f4b
14 changed files with 751 additions and 2118 deletions

1
.gitignore vendored
View file

@ -9,6 +9,5 @@ wheels/
# Virtual environments
.venv
kwin_capture_screen
portal_capture_frame
*.moc

View file

@ -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
View file

@ -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.

View file

@ -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"

View file

@ -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

View file

@ -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 = []

View file

@ -0,0 +1 @@
"""Queue reader internals for anti-prestige-tool."""

234
reforger_queue/cli.py Normal file
View 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
View 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
View 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
View 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
View 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
View file

@ -4,5 +4,5 @@ requires-python = ">=3.10"
[[package]]
name = "anti-prestige-tool"
version = "0.1.0"
version = "0.7.5"
source = { virtual = "." }