Test files and initial data gathering completed

This commit is contained in:
2026-08-10 21:33:00 -06:00
commit 2181655801
28 changed files with 510 additions and 0 deletions
Binary file not shown.
Binary file not shown.
+46
View File
@@ -0,0 +1,46 @@
{"time": "21:22:12", "frame": 0, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}}
{"time": "21:22:13", "frame": 1, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}}
{"time": "21:22:14", "frame": 2, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 204, "action_points": 140}}
{"time": "21:22:16", "frame": 3, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 203, "action_points": 140}}
{"time": "21:22:17", "frame": 4, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 202, "action_points": 140}}
{"time": "21:22:18", "frame": 5, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 202, "action_points": 140}}
{"time": "21:22:20", "frame": 6, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 201, "action_points": 140}}
{"time": "21:22:21", "frame": 7, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 201, "action_points": 140}}
{"time": "21:22:22", "frame": 8, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}}
{"time": "21:22:23", "frame": 9, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}}
{"time": "21:22:24", "frame": 10, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 200, "action_points": 140}}
{"time": "21:22:26", "frame": 11, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 199, "action_points": 140}}
{"time": "21:22:27", "frame": 12, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 198, "action_points": 140}}
{"time": "21:22:28", "frame": 13, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 198, "action_points": 140}}
{"time": "21:22:29", "frame": 14, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1971, "action_points": 140}}
{"time": "21:22:30", "frame": 15, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1971, "action_points": 140}}
{"time": "21:22:32", "frame": 16, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}}
{"time": "21:22:33", "frame": 17, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}}
{"time": "21:22:34", "frame": 18, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 196, "action_points": 140}}
{"time": "21:22:36", "frame": 19, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 195, "action_points": 140}}
{"time": "21:22:37", "frame": 20, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 194, "action_points": 140}}
{"time": "21:22:38", "frame": 21, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 194, "action_points": 140}}
{"time": "21:22:40", "frame": 22, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 193, "action_points": 140}}
{"time": "21:22:41", "frame": 23, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 193, "action_points": 140}}
{"time": "21:22:43", "frame": 24, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1921, "action_points": 140}}
{"time": "21:22:44", "frame": 25, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1921, "action_points": 140}}
{"time": "21:22:46", "frame": 26, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1911, "action_points": 140}}
{"time": "21:22:47", "frame": 27, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 190, "action_points": 140}}
{"time": "21:22:49", "frame": 28, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 190, "action_points": 140}}
{"time": "21:22:50", "frame": 29, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 189, "action_points": 140}}
{"time": "21:22:52", "frame": 30, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 189, "action_points": 140}}
{"time": "21:22:53", "frame": 31, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 188, "action_points": 140}}
{"time": "21:22:55", "frame": 32, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 188, "action_points": 140}}
{"time": "21:22:56", "frame": 33, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": 28, "health": 40, "inventory": 1871, "action_points": 140}}
{"time": "21:22:58", "frame": 34, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 1871, "action_points": 140}}
{"time": "21:22:59", "frame": 35, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 186, "action_points": 140}}
{"time": "21:23:01", "frame": 36, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:02", "frame": 37, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:04", "frame": 38, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:05", "frame": 39, "is_harvesting": false, "harvest_pixel": "#664c33", "harvest_luma": 81, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:07", "frame": 40, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:08", "frame": 41, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:10", "frame": 42, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 185, "action_points": 140}}
{"time": "21:23:12", "frame": 43, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 184, "action_points": 140}}
{"time": "21:23:13", "frame": 44, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 184, "action_points": 140}}
{"time": "21:23:15", "frame": 45, "is_harvesting": true, "harvest_pixel": "#f2c285", "harvest_luma": 201, "bars": {"mana": 32, "food": null, "health": 40, "inventory": 183, "action_points": 140}}
+127
View File
@@ -0,0 +1,127 @@
"""Lightweight image analysis for live capture feedback.
All stats are computed on a small downsampled copy so the demo loop stays
fast at full 1080p capture.
"""
from collections import Counter
from PIL import Image
ANALYSIS_SIZE = (320, 180)
_TOP_COLORS = 5
# Harvesting is detected from the single pixel at this window coordinate.
# Harvesting shows a light tan (~#f2c285, luma ~201); idle shows dark brown
# (~#664c33, luma ~81).
HARVEST_PIXEL = (1710, 1068)
HARVEST_LUMA_THRESHOLD = 140
# The small numeric readout just before each bar at the bottom of the panel.
# Regions are (x0, y0, x1, y1) in captured-window pixel coordinates. y0 starts
# below the panel's top border line (y=1032) which would confuse OCR.
NUMBER_REGIONS = {
"mana": (0, 1033, 62, 1048),
"food": (195, 1033, 226, 1048),
"health": (355, 1033, 389, 1048),
"inventory": (520, 1033, 552, 1048),
"action_points": (610, 1033, 714, 1048),
}
_OCR_SCALE = 3
try:
import pytesseract
except ImportError:
pytesseract = None
def _luma(pixel):
r, g, b = pixel
return round(0.299 * r + 0.587 * g + 0.114 * b)
def load_image(path):
"""Load a PNG as RGB; return None if the file is unreadable/corrupt."""
try:
return Image.open(path).convert("RGB")
except OSError:
return None
def is_harvesting(image):
"""Return whether the player is harvesting, based on the harvest pixel."""
rgb = image.convert("RGB")
color = rgb.getpixel(HARVEST_PIXEL)
lum = _luma(color)
return {
"is_harvesting": lum >= HARVEST_LUMA_THRESHOLD,
"color": "#%02x%02x%02x" % color,
"luma": lum,
}
def _ocr_number(image, region):
"""OCR a single number region; return int or None if it can't be read."""
if pytesseract is None:
return None
crop = image.convert("L").crop(region)
crop = crop.resize((crop.width * _OCR_SCALE, crop.height * _OCR_SCALE))
crop = crop.point(lambda p: 0 if p < 140 else 255)
try:
text = pytesseract.image_to_string(
crop,
config="--psm 7 -c tessedit_char_whitelist=0123456789",
)
except Exception:
return None
digits = "".join(ch for ch in text if ch.isdigit())
return int(digits) if digits else None
def read_bar_values(image):
"""Return the numeric readout before each bar, or None if unreadable."""
rgb = image.convert("RGB")
return {
name: _ocr_number(rgb, region)
for name, region in NUMBER_REGIONS.items()
}
def _pixels(image):
px = image.load()
w, h = image.size
return [px[x, y] for y in range(h) for x in range(w)]
def summarize(image):
"""Return size, mean RGB, and dominant colors of a captured frame."""
small = image.convert("RGB").resize(ANALYSIS_SIZE)
pixels = _pixels(small)
n = len(pixels)
total = [0, 0, 0]
for r, g, b in pixels:
total[0] += r
total[1] += g
total[2] += b
mean = tuple(round(c / n) for c in total)
top = Counter(pixels).most_common(_TOP_COLORS)
dominant = [
{"hex": "#%02x%02x%02x" % color, "fraction": round(count / n, 3)}
for color, count in top
]
return {"size": (image.width, image.height), "mean_rgb": mean, "dominant": dominant}
def difference(a, b):
"""Sum of absolute channel differences between two same-size RGB images."""
if a.size != b.size:
return None
pa, pb = a.load(), b.load()
w, h = a.size
diff = 0
for y in range(h):
for x in range(w):
ca, cb = pa[x, y], pb[x, y]
diff += abs(ca[0] - cb[0]) + abs(ca[1] - cb[1]) + abs(ca[2] - cb[2])
return diff
+86
View File
@@ -0,0 +1,86 @@
"""X11 window capture for Eternal Lands analysis.
Captures a specific window's pixels even when it is not focused. On X11,
focus only affects input routing, never pixel retrieval: XGetImage reads the
window's own pixmap. Cinnamon's compositor keeps the redirected content
available, so this works regardless of focus or occlusion.
If window-level capture is unavailable (e.g. compositing is disabled), we
fall back to grabbing the visible region from the root window at the
window's screen coordinates.
"""
import re
from PIL import Image
from Xlib import X, display
from Xlib.error import XError
class WindowNotFound(Exception):
"""Raised when no window matching the title pattern is found."""
class WindowCapture:
def __init__(self, title_pattern, display_name=None):
self.title_pattern = re.compile(title_pattern, re.IGNORECASE)
self.display = display.Display(display_name)
self.root = self.display.screen().root
self.window = None
def find_window(self):
"""Return the cached or freshly-searched X window for the title."""
if self.window is not None:
try:
self.window.get_geometry()
return self.window
except XError:
self.window = None
self.window = self._search(self.root)
return self.window
def _search(self, window, depth=0):
if depth > 24:
return None
try:
name = window.get_wm_name()
except XError:
name = None
if name and self.title_pattern.search(name):
try:
window.get_geometry()
return window
except XError:
return None
try:
children = window.query_tree().children
except XError:
return None
for child in children:
found = self._search(child, depth + 1)
if found:
return found
return None
def capture(self):
"""Capture the target window and return a PIL.Image."""
window = self.find_window()
if window is None:
raise WindowNotFound(
f"no window matching {self.title_pattern.pattern!r} "
f"on display {self.display.display_string}"
)
geo = window.get_geometry()
try:
ximage = window.get_image(0, 0, geo.width, geo.height, X.ZPixmap, 0xFFFFFFFF)
except XError:
coords = self.root.translate_coords(window, 0, 0)
ximage = self.root.get_image(
coords.x, coords.y, geo.width, geo.height, X.ZPixmap, 0xFFFFFFFF
)
return _to_image(ximage, geo.width, geo.height)
def _to_image(ximage, width, height):
return Image.frombytes("RGBA", (width, height), ximage.data, "raw", "BGRA")
+144
View File
@@ -0,0 +1,144 @@
#!/usr/bin/env python3
"""Demo: capture the Eternal Lands window, analyze it, and log results as JSONL.
Usage:
python3 main.py [--title "Eternal Lands"] [--fps 5] [--save-every 0] [--log analysis.jsonl]
python3 main.py --replay tests/images/harvesting [--log analysis.jsonl]
"""
import argparse
import json
import os
import time
import analyze
from capture import WindowCapture, WindowNotFound
def analyze_and_print(image, frame, prev, logf):
global _last_t
info = analyze.summarize(image)
harvest = analyze.is_harvesting(image)
bars = analyze.read_bar_values(image)
fps = 1.0 / max(1e-6, time.monotonic() - _last_t)
_last_t = time.monotonic()
record = {
"time": _now(),
"frame": frame,
"is_harvesting": harvest["is_harvesting"],
"harvest_pixel": harvest["color"],
"harvest_luma": harvest["luma"],
"bars": bars,
}
if logf is not None:
logf.write(json.dumps(record) + "\n")
logf.flush()
small = image.convert("RGB").resize(analyze.ANALYSIS_SIZE)
delta = None if prev is None else analyze.difference(prev, small)
top = ", ".join(f"{c['hex']} {c['fraction'] * 100:.0f}%" for c in info["dominant"][:3])
status = "harvesting" if harvest["is_harvesting"] else "not harvesting"
frame_str = f"{frame:05d}" if isinstance(frame, int) else frame
print(
f"[{frame_str}] {status} luma={harvest['luma']} "
f"size={info['size'][0]}x{info['size'][1]} "
f"mean={info['mean_rgb']} top=[{top}] bars={bars} "
f"delta={delta} loop_fps={fps:.1f}",
flush=True,
)
return small
def run_replay(args):
"""Process PNGs from a directory instead of a live window."""
logf = open(args.log, "a") if args.log else None
prev = None
try:
for name in sorted(os.listdir(args.replay)):
if not name.lower().endswith(".png"):
continue
path = os.path.join(args.replay, name)
image = analyze.load_image(path)
if image is None:
print(f"[skipped] unreadable image: {path}", flush=True)
continue
prev = analyze_and_print(image, name, prev, logf)
finally:
if logf:
logf.close()
def run_live(args):
cap = WindowCapture(args.title)
os.makedirs(args.snapshots, exist_ok=True)
interval = 1.0 / args.fps
print(f"Capturing window matching {args.title!r} at {args.fps:.1f} fps (Ctrl-C to stop)", flush=True)
logf = open(args.log, "a") if args.log else None
prev = None
frame = 0
try:
while True:
started = time.monotonic()
try:
image = cap.capture()
except WindowNotFound as exc:
print(f"[{_now()}] {exc}; retrying...", flush=True)
time.sleep(1.0)
continue
prev = analyze_and_print(image, frame, prev, logf)
if args.save_every and frame % args.save_every == 0:
path = os.path.join(args.snapshots, f"frame_{frame:05d}.png")
image.save(path)
frame += 1
elapsed = time.monotonic() - started
time.sleep(max(0.0, interval - elapsed))
finally:
if logf:
logf.close()
def main():
parser = argparse.ArgumentParser(description="Capture and analyze a specific window on X11")
parser.add_argument(
"--title", default="Eternal Lands",
help="window title to match (substring, case-insensitive)",
)
parser.add_argument("--fps", type=float, default=5.0, help="target capture rate")
parser.add_argument("--snapshots", default="snapshots", help="directory for PNG snapshots")
parser.add_argument(
"--save-every", type=int, default=0, metavar="N",
help="save a snapshot every N frames (0 = off)",
)
parser.add_argument(
"--log", default="analysis.jsonl", metavar="PATH",
help="append one JSON record per frame to PATH (empty string disables)",
)
parser.add_argument(
"--replay", metavar="DIR",
help="process existing PNGs from DIR instead of capturing a window",
)
args = parser.parse_args()
if args.replay:
run_replay(args)
else:
run_live(args)
_last_t = time.monotonic()
def _now():
return time.strftime("%H:%M:%S")
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
print("\nstopped")
+4
View File
@@ -0,0 +1,4 @@
python-xlib>=0.33
pillow>=10
pytesseract>=0.3
pytest>=8
Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+70
View File
@@ -0,0 +1,70 @@
import shutil
from pathlib import Path
import pytest
import analyze
ALL_IMAGES = (
sorted(Path("tests/images/harvesting").glob("*.png"))
+ sorted(Path("tests/images/not_harvesting").glob("*.png"))
)
assert len(ALL_IMAGES) > 0, "no images found under tests/images"
# Inventory capacity was observed to change as the player collects items.
# Frames not listed here are expected to read 106.
INVENTORY_EXPECTED = {
"frame_00000.png": 110,
"frame_00001.png": 110,
"frame_00002.png": 108,
"frame_00003.png": 108,
}
OCR_AVAILABLE = analyze.pytesseract is not None and shutil.which("tesseract") is not None
pytestmark = pytest.mark.skipif(
not OCR_AVAILABLE,
reason="pytesseract or tesseract-ocr not installed",
)
@pytest.fixture(scope="session")
def bar_values():
values = {}
for path in ALL_IMAGES:
image = analyze.load_image(path)
assert image is not None, f"cannot load {path}"
values[path.name] = analyze.read_bar_values(image)
return values
def test_mana_is_32(bar_values):
for name, values in bar_values.items():
assert values["mana"] == 32, name
def test_food_is_39(bar_values):
for name, values in bar_values.items():
assert values["food"] == 39, name
def test_health_is_40(bar_values):
for name, values in bar_values.items():
assert values["health"] == 40, name
def test_action_points_are_140(bar_values):
for name, values in bar_values.items():
assert values["action_points"] == 140, name
def test_inventory_matches_expected(bar_values):
for name, values in bar_values.items():
expected = INVENTORY_EXPECTED.get(name, 106)
assert values["inventory"] == expected, name
def test_every_bar_has_a_value(bar_values):
for name, values in bar_values.items():
assert all(v is not None for v in values.values()), name
+10
View File
@@ -0,0 +1,10 @@
from pathlib import Path
import analyze
CORRUPT = sorted(Path("tests/images/corrupt").glob("*.png"))
def test_corrupt_images_are_skipped_gracefully():
for path in CORRUPT:
assert analyze.load_image(path) is None, path
+23
View File
@@ -0,0 +1,23 @@
from pathlib import Path
import analyze
HARVESTING = sorted(Path("tests/images/harvesting").glob("*.png"))
NOT_HARVESTING = sorted(Path("tests/images/not_harvesting").glob("*.png"))
assert len(HARVESTING) > 0, "no images found in tests/images/harvesting"
assert len(NOT_HARVESTING) > 0, "no images found in tests/images/not_harvesting"
def test_harvesting_frames_report_harvesting():
for path in HARVESTING:
image = analyze.load_image(path)
assert image is not None, f"cannot load {path}"
assert analyze.is_harvesting(image)["is_harvesting"] is True, path
def test_not_harvesting_frames_report_not_harvesting():
for path in NOT_HARVESTING:
image = analyze.load_image(path)
assert image is not None, f"cannot load {path}"
assert analyze.is_harvesting(image)["is_harvesting"] is False, path