initial v0.1 APT

This commit is contained in:
scootz 2026-05-01 10:26:41 +01:00
commit ee0cb2bc8a
9 changed files with 838 additions and 0 deletions

12
.gitignore vendored Normal file
View file

@ -0,0 +1,12 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
kwin_capture_screen

1
.python-version Normal file
View file

@ -0,0 +1 @@
3.10

15
Makefile Normal file
View file

@ -0,0 +1,15 @@
CXX ?= g++
PKG_CONFIG ?= pkg-config
QT_CFLAGS := $(shell $(PKG_CONFIG) --cflags Qt6Core Qt6DBus)
QT_LIBS := $(shell $(PKG_CONFIG) --libs Qt6Core Qt6DBus)
.PHONY: all clean
all: kwin_capture_screen
kwin_capture_screen: kwin_capture_screen.cpp
$(CXX) -std=c++17 -O2 -Wall -Wextra $(QT_CFLAGS) $< -o $@ $(QT_LIBS)
clean:
rm -f kwin_capture_screen

202
README.md Normal file
View file

@ -0,0 +1,202 @@
# Anti Prestige Tool
Prototype queue-position reader for Arma Reforger.
## Current State
The working script is:
```bash
python3 reforger_queue_read.py
```
It reads the queue number by:
1. Cropping the queue-number region.
2. Thresholding orange UI pixels.
3. Splitting connected digit components.
4. Matching digits against synthetic `Roboto-Condensed` templates.
No Tesseract/OpenCV/Pillow dependency is currently required.
## Dataset Validation
Dataset:
```text
/home/scootz/Pictures/codex-dataset
```
Validation command:
```bash
python3 reforger_queue_read.py --dataset /home/scootz/Pictures/codex-dataset --expect-filenames
```
Current result:
```text
summary: 14 passed, 0 failed
```
The default crop is derived from this 2560x1440 reference:
```text
reference-size: 2560x1440
reference-crop: x=1050 y=620 width=100 height=60
scale-mode: width
```
`scale-mode=width` means the crop scales uniformly from the screenshot width. This handles the current dataset better than independent width/height scaling because the dataset images are slightly shorter than 1440 pixels, while the UI coordinates still match a 1440p layout.
## Real Screenshot Calibration
Dataset:
```text
datasets/scootz-dataset
```
Files:
```text
1920x1080.png
2160x1440.png # actual image size is 2560x1440
```
Validation command:
```bash
python3 reforger_queue_read.py --dataset datasets/scootz-dataset --show-crop --debug
```
Current result:
```text
datasets/scootz-dataset/1920x1080.png crop=788,465,75,45
datasets/scootz-dataset/1920x1080.png 22
datasets/scootz-dataset/2160x1440.png crop=1050,620,100,60
datasets/scootz-dataset/2160x1440.png 24
```
Measured digit box positions:
```text
1440p digit bbox abs=(1080,636)-(1118,666)
1440p digit bbox rel=(0.421875,0.441667)-(0.436719,0.462500)
1080p digit bbox abs=(810,477)-(838,500)
1080p digit bbox rel=(0.421875,0.441667)-(0.436458,0.462963)
```
The match is exact enough to use width-scaled coordinates for normal 16:9 1080p and 1440p users.
Default search crop relative to a 2560x1440 screen:
```text
x=1050 / 2560 = 0.410156
y=620 / 1440 = 0.430556
w=100 / 2560 = 0.039063
h=60 / 1440 = 0.041667
```
Resolved default crop examples:
```text
2560x1440 -> x=1050 y=620 width=100 height=60
1920x1080 -> x=788 y=465 width=75 height=45
```
## Single-Monitor Capture Direction
The old approach captured the whole KDE desktop across all screens. That is not ideal.
List available KWin outputs:
```bash
python3 reforger_queue_read.py --list-screens
```
Example output:
```text
DP-2 enabled 0,360,1920x1080
DP-3 enabled 1920,0,2560x1440
```
Preferred live usage is to pass the screen containing Reforger explicitly:
```bash
python3 reforger_queue_read.py --capture --screen DP-3 --show-crop --debug
```
The tool first tries KWin's screen-specific screenshot API. On this machine KWin currently rejects that direct API for an untrusted process, so the tool falls back to:
1. Capturing the full Spectacle desktop.
2. Cropping to the requested KWin output geometry.
3. Applying the queue-number crop inside that monitor image.
This still avoids the "wrong current monitor" problem. It does not yet avoid the temporary full-desktop screenshot in the fallback path.
The script also supports Spectacle's current-monitor capture:
```bash
python3 reforger_queue_read.py --capture
```
This uses:
```bash
spectacle --background --current --nonotify --output <tempfile>
```
That should capture only one monitor. For this to work reliably, Reforger should be focused or the relevant monitor should be the current KDE monitor.
Whole-desktop capture is still available for fallback:
```bash
python3 reforger_queue_read.py --capture --capture-mode fullscreen
```
Live DP-3 test result from 2026-05-01:
```text
crop=1050,620,100,60
19
```
## Useful Commands
Read a full single-monitor screenshot:
```bash
python3 reforger_queue_read.py --image /path/to/screenshot.png --debug
```
Show the crop selected for a screenshot:
```bash
python3 reforger_queue_read.py --image /path/to/screenshot.png --show-crop --debug
```
Read a full screenshot with an explicit crop:
```bash
python3 reforger_queue_read.py --image /path/to/screenshot.png --crop 1050,620,100,60 --debug
```
Read already-cropped number images:
```bash
python3 reforger_queue_read.py --dataset /path/to/cropped-digits --cropped --expect-filenames
```
## Next Step
Get real full screenshots from the game on the actual monitor setup and validate:
```bash
python3 reforger_queue_read.py --image /path/to/real-full-screenshot.png --debug
```
If the default crop misses, use ImageMagick to crop around the queue value, then update `--reference-crop`.

59
kwin_capture_screen.cpp Normal file
View file

@ -0,0 +1,59 @@
#include <QCoreApplication>
#include <QDBusConnection>
#include <QDBusError>
#include <QDBusInterface>
#include <QDBusReply>
#include <QDBusUnixFileDescriptor>
#include <QFile>
#include <QTextStream>
#include <QVariantMap>
int main(int argc, char *argv[])
{
QCoreApplication app(argc, argv);
QTextStream err(stderr);
if (argc != 3) {
err << "usage: " << argv[0] << " OUTPUT_NAME OUTPUT_FILE\n";
return 64;
}
const QString outputName = QString::fromLocal8Bit(argv[1]);
const QString outputPath = QString::fromLocal8Bit(argv[2]);
QFile file(outputPath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate)) {
err << "failed to open output file: " << file.errorString() << "\n";
return 1;
}
QDBusInterface iface(
QStringLiteral("org.kde.KWin"),
QStringLiteral("/org/kde/KWin/ScreenShot2"),
QStringLiteral("org.kde.KWin.ScreenShot2"),
QDBusConnection::sessionBus());
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(
QStringLiteral("CaptureScreen"),
outputName,
options,
QVariant::fromValue(fd));
file.close();
if (!reply.isValid()) {
err << "CaptureScreen failed for " << outputName << ": "
<< reply.error().name() << ": " << reply.error().message() << "\n";
return 1;
}
return 0;
}

6
main.py Normal file
View file

@ -0,0 +1,6 @@
def main():
print("Hello from anti-prestige-tool!")
if __name__ == "__main__":
main()

7
pyproject.toml Normal file
View file

@ -0,0 +1,7 @@
[project]
name = "anti-prestige-tool"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = []

528
reforger_queue_read.py Normal file
View file

@ -0,0 +1,528 @@
#!/usr/bin/env python3
import argparse
import os
import re
import subprocess
import sys
import tempfile
DEFAULT_REFERENCE_SIZE = (2560, 1440)
DEFAULT_REFERENCE_CROP = (1050, 620, 100, 60)
DEFAULT_FONT = "Roboto-Condensed"
DEFAULT_POINTSIZE = 43
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
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 ppm_from_magick(args):
data = run_bytes(["magick", *args, "-alpha", "off", "-depth", "8", "ppm:-"])
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 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)
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 build_templates(font, pointsize):
width, height, raw = ppm_from_magick(
[
"-background",
"black",
"-fill",
"white",
"-font",
font,
"-pointsize",
str(pointsize),
"label:0123456789",
]
)
components = connected_components(white_mask(width, height, raw))
if len(components) < 10:
sys.exit(f"template rendering produced only {len(components)} digit components")
return {str(index): normalize(component) for index, component in enumerate(components[:10])}
def classify(component, templates):
sample = normalize(component)
ranked = []
for digit, template in templates.items():
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 helper_path():
return os.path.join(SCRIPT_DIR, "kwin_capture_screen")
def capture_named_screen(output, screen):
helper = helper_path()
if not os.path.exists(helper):
sys.exit(f"missing {helper}; run `make` in {SCRIPT_DIR}")
subprocess.run(
[helper, screen, output],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def parse_geometry(geometry):
match = re.match(r"(-?\d+),(-?\d+),(\d+)x(\d+)", geometry)
if not match:
raise ValueError(f"could not parse screen geometry: {geometry}")
return tuple(int(group) for group in match.groups())
def screen_geometry(screen_name):
screens = kwin_screens()
for screen in screens:
if screen.get("name") == screen_name:
if not screen.get("enabled"):
sys.exit(f"screen {screen_name} is disabled")
geometry = screen.get("geometry")
if not geometry:
sys.exit(f"screen {screen_name} has no geometry")
return parse_geometry(geometry)
available = ", ".join(screen.get("name", "unknown") for screen in screens)
sys.exit(f"unknown screen {screen_name}; available screens: {available}")
def virtual_desktop_origin():
enabled_geometries = [
parse_geometry(screen["geometry"])
for screen in kwin_screens()
if screen.get("enabled") and screen.get("geometry")
]
if not enabled_geometries:
return 0, 0
return min(geometry[0] for geometry in enabled_geometries), min(geometry[1] for geometry in enabled_geometries)
def capture_screen_via_fullscreen_crop(output, screen):
x, y, width, height = screen_geometry(screen)
origin_x, origin_y = virtual_desktop_origin()
crop_x = x - origin_x
crop_y = y - origin_y
fd, full_path = tempfile.mkstemp(prefix="reforger-fullscreen-", suffix=".png")
os.close(fd)
try:
capture_spectacle(full_path, "fullscreen")
run_bytes(["magick", full_path, "-crop", f"{width}x{height}+{crop_x}+{crop_y}", "+repage", output])
finally:
try:
os.unlink(full_path)
except OSError:
pass
def capture_spectacle(output, mode):
if mode == "fullscreen":
capture_arg = "--fullscreen"
elif mode == "current":
capture_arg = "--current"
else:
raise ValueError(f"unsupported capture mode: {mode}")
subprocess.run(
["spectacle", "--background", capture_arg, "--nonotify", "--output", output],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def capture_screenshot(output, mode, screen=None):
if screen:
try:
capture_named_screen(output, screen)
except subprocess.CalledProcessError:
capture_screen_via_fullscreen_crop(output, screen)
return
capture_spectacle(output, mode)
def kwin_screens():
output = run_bytes(["qdbus6", "org.kde.KWin", "/KWin", "supportInformation"]).decode("utf-8", "replace")
screens = []
current = None
for line in output.splitlines():
if re.match(r"^Screen \d+:", line):
if current:
screens.append(current)
current = {}
continue
if current is None:
continue
stripped = line.strip()
if stripped.startswith("Name:"):
current["name"] = stripped.split(":", 1)[1].strip()
elif stripped.startswith("Enabled:"):
current["enabled"] = stripped.split(":", 1)[1].strip() == "1"
elif stripped.startswith("Geometry:"):
current["geometry"] = stripped.split(":", 1)[1].strip()
if current:
screens.append(current)
return screens
def print_screens():
for screen in kwin_screens():
enabled = "enabled" if screen.get("enabled") else "disabled"
geometry = screen.get("geometry", "no geometry")
print(f"{screen.get('name', 'unknown')}\t{enabled}\t{geometry}")
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, 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 = build_templates(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 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 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)
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("--list-screens", action="store_true", help="List KWin output names for use with --screen.")
parser.add_argument("--cropped", action="store_true", help="Treat input image(s) as already cropped to the queue number.")
parser.add_argument("--capture", action="store_true", help="Capture the current monitor with Spectacle.")
parser.add_argument("--screen", help="KWin output name to capture explicitly, for example DP-3.")
parser.add_argument(
"--capture-mode",
choices=("current", "fullscreen"),
default="current",
help="Spectacle capture mode. Use current for one monitor, fullscreen for the whole desktop.",
)
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("--font", default=DEFAULT_FONT, help="Font used for synthetic digit templates.")
parser.add_argument("--pointsize", type=int, default=DEFAULT_POINTSIZE, help="Point size used for 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(
"--expect-filenames",
action="store_true",
help="In dataset mode, compare detected values to each image filename stem.",
)
args = parser.parse_args()
if args.screen and not args.capture:
parser.error("--screen can only be used with --capture")
if sum(bool(value) for value in (args.image, args.dataset, args.capture, args.list_screens)) != 1:
parser.error("choose exactly one of --image, --dataset, --capture, or --list-screens")
if args.list_screens:
print_screens()
return 0
if args.dataset:
extensions = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
paths = [
os.path.join(args.dataset, name)
for name in sorted(os.listdir(args.dataset))
if os.path.splitext(name)[1].lower() in extensions
]
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.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
temp_path = None
image_path = args.image
if args.capture:
fd, temp_path = tempfile.mkstemp(prefix="reforger-queue-", suffix=".png")
os.close(fd)
image_path = temp_path
capture_screenshot(image_path, args.capture_mode, args.screen)
try:
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.font, args.pointsize, args.cropped)
finally:
if temp_path:
try:
os.unlink(temp_path)
except OSError:
pass
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
if __name__ == "__main__":
raise SystemExit(main())

8
uv.lock generated Normal file
View file

@ -0,0 +1,8 @@
version = 1
revision = 3
requires-python = ">=3.10"
[[package]]
name = "anti-prestige-tool"
version = "0.1.0"
source = { virtual = "." }