initial v0.1 APT
This commit is contained in:
commit
ee0cb2bc8a
9 changed files with 838 additions and 0 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
1
.python-version
Normal file
|
|
@ -0,0 +1 @@
|
|||
3.10
|
||||
15
Makefile
Normal file
15
Makefile
Normal 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
202
README.md
Normal 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
59
kwin_capture_screen.cpp
Normal 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
6
main.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
def main():
|
||||
print("Hello from anti-prestige-tool!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
7
pyproject.toml
Normal file
7
pyproject.toml
Normal 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
528
reforger_queue_read.py
Normal 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
8
uv.lock
generated
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10"
|
||||
|
||||
[[package]]
|
||||
name = "anti-prestige-tool"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
Loading…
Add table
Reference in a new issue