rbx-proofreader 1.0.1__py3-none-any.whl → 1.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proofreader/core/config.py +10 -6
- proofreader/core/matcher.py +59 -37
- proofreader/core/ocr.py +48 -35
- proofreader/core/schema.py +8 -0
- proofreader/main.py +70 -19
- proofreader/train/clip_trainer.py +173 -0
- proofreader/train/emulator/generator.py +185 -137
- proofreader/train/{train.py → yolo_trainer.py} +5 -8
- rbx_proofreader-1.1.1.dist-info/METADATA +160 -0
- rbx_proofreader-1.1.1.dist-info/RECORD +17 -0
- proofreader/train/builder.py +0 -94
- rbx_proofreader-1.0.1.dist-info/METADATA +0 -128
- rbx_proofreader-1.0.1.dist-info/RECORD +0 -17
- {rbx_proofreader-1.0.1.dist-info → rbx_proofreader-1.1.1.dist-info}/WHEEL +0 -0
- {rbx_proofreader-1.0.1.dist-info → rbx_proofreader-1.1.1.dist-info}/licenses/LICENSE +0 -0
- {rbx_proofreader-1.0.1.dist-info → rbx_proofreader-1.1.1.dist-info}/top_level.txt +0 -0
|
@@ -3,6 +3,7 @@ import random
|
|
|
3
3
|
import concurrent.futures
|
|
4
4
|
import sys
|
|
5
5
|
import traceback
|
|
6
|
+
import multiprocessing
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
from playwright.sync_api import sync_playwright
|
|
8
9
|
from tqdm import tqdm
|
|
@@ -24,163 +25,210 @@ from proofreader.core.config import (
|
|
|
24
25
|
|
|
25
26
|
GENERATOR_CONFIG = AUGMENTER_CONFIG["generator"]
|
|
26
27
|
|
|
27
|
-
def
|
|
28
|
+
def process_batch(batch_ids, db, backgrounds_count, progress_counter):
|
|
28
29
|
try:
|
|
29
|
-
split = "train" if random.random() < GENERATOR_CONFIG["train_split_fraction"] else "val"
|
|
30
|
-
output_name = f"trade_{task_id:05d}"
|
|
31
|
-
|
|
32
|
-
img_dir = DATASET_ROOT / split / "images"
|
|
33
|
-
lbl_dir = DATASET_ROOT / split / "labels"
|
|
34
|
-
img_dir.mkdir(parents=True, exist_ok=True)
|
|
35
|
-
lbl_dir.mkdir(parents=True, exist_ok=True)
|
|
36
|
-
|
|
37
|
-
trade_input = [[], []]
|
|
38
|
-
is_empty_trade = random.random() < GENERATOR_CONFIG["empty_trade_chance"]
|
|
39
|
-
|
|
40
|
-
if not is_empty_trade:
|
|
41
|
-
for side in [0, 1]:
|
|
42
|
-
num_items = random.randint(0, 4)
|
|
43
|
-
for _ in range(num_items):
|
|
44
|
-
item = random.choice(db)
|
|
45
|
-
trade_input[side].append(f"../../../../assets/thumbnails/{item['id']}.png")
|
|
46
|
-
|
|
47
|
-
with open(AUGMENTER_PATH, 'r', encoding="utf-8") as f:
|
|
48
|
-
augmenter_js = f.read()
|
|
49
|
-
|
|
50
30
|
with sync_playwright() as p:
|
|
51
|
-
browser = p.chromium.launch(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
height = int(width / aspect_ratio)
|
|
56
|
-
height = max(GENERATOR_CONFIG["height_min"], min(height, GENERATOR_CONFIG["height_max"]))
|
|
31
|
+
browser = p.chromium.launch(
|
|
32
|
+
headless=True,
|
|
33
|
+
args=["--disable-gpu", "--disable-dev-shm-usage", "--no-sandbox"]
|
|
34
|
+
)
|
|
57
35
|
|
|
58
|
-
context = browser.new_context(
|
|
36
|
+
context = browser.new_context()
|
|
59
37
|
page = context.new_page()
|
|
60
38
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
page.evaluate(augmenter_js, [trade_input, is_empty_trade, backgrounds_count, AUGMENTER_CONFIG])
|
|
39
|
+
with open(AUGMENTER_PATH, 'r', encoding="utf-8") as f:
|
|
40
|
+
augmenter_js = f.read()
|
|
65
41
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
x1 = max(0, box['x'] - pad_px)
|
|
71
|
-
y1 = max(0, box['y'] - pad_px)
|
|
72
|
-
x2 = min(width, box['x'] + box['width'] + pad_px)
|
|
73
|
-
y2 = min(height, box['y'] + box['height'] + pad_px)
|
|
74
|
-
|
|
75
|
-
new_w = x2 - x1
|
|
76
|
-
new_h = y2 - y1
|
|
77
|
-
center_x = x1 + (new_w / 2)
|
|
78
|
-
center_y = y1 + (new_h / 2)
|
|
79
|
-
|
|
80
|
-
return [class_id, center_x / width, center_y / height, new_w / width, new_h / height]
|
|
81
|
-
|
|
82
|
-
def is_fully_visible(box, width, height, pad=4):
|
|
83
|
-
return (box['x'] - pad >= 0 and
|
|
84
|
-
box['y'] - pad >= 0 and
|
|
85
|
-
(box['x'] + box['width'] + pad) <= width and
|
|
86
|
-
(box['y'] + box['height'] + pad) <= height)
|
|
87
|
-
|
|
88
|
-
label_data = []
|
|
89
|
-
|
|
90
|
-
items = page.query_selector_all("div[trade-item-card]")
|
|
91
|
-
for item in items:
|
|
92
|
-
box = item.bounding_box()
|
|
93
|
-
if box and is_fully_visible(box, width, height):
|
|
94
|
-
card_box = get_padded_yolo(item, 0, pad_px=4)
|
|
95
|
-
if card_box: label_data.append(card_box)
|
|
96
|
-
|
|
97
|
-
thumb = item.query_selector(".item-card-thumb-container")
|
|
98
|
-
if thumb:
|
|
99
|
-
thumb_box = get_padded_yolo(thumb, 1, pad_px=4)
|
|
100
|
-
if thumb_box: label_data.append(thumb_box)
|
|
101
|
-
|
|
102
|
-
name = item.query_selector(".item-card-name")
|
|
103
|
-
if name:
|
|
104
|
-
name_box = get_padded_yolo(name, 2, pad_px=4)
|
|
105
|
-
if name_box: label_data.append(name_box)
|
|
106
|
-
|
|
107
|
-
robux_sections = page.query_selector_all(".robux-line:not(.total-value)")
|
|
108
|
-
for section in robux_sections:
|
|
109
|
-
box = section.bounding_box()
|
|
110
|
-
if box and is_fully_visible(box, width, height, 8) and section.is_visible():
|
|
111
|
-
line_box = get_padded_yolo(section, 3, pad_px=8)
|
|
112
|
-
if line_box: label_data.append(line_box)
|
|
113
|
-
|
|
114
|
-
value_element = section.query_selector(".robux-line-value")
|
|
115
|
-
if value_element:
|
|
116
|
-
value_box = get_padded_yolo(value_element, 4, pad_px=4)
|
|
117
|
-
if value_box: label_data.append(value_box)
|
|
118
|
-
|
|
119
|
-
img_path = img_dir / f"{output_name}.png"
|
|
120
|
-
page.screenshot(path=str(img_path))
|
|
121
|
-
|
|
122
|
-
if random.random() < 0.60:
|
|
123
|
-
img = cv2.imread(str(img_path))
|
|
124
|
-
if img is not None:
|
|
125
|
-
if random.random() < 0.5:
|
|
126
|
-
quality = random.randint(60, 90)
|
|
127
|
-
_, encimg = cv2.imencode('.jpg', img, [int(cv2.IMWRITE_JPEG_QUALITY), quality])
|
|
128
|
-
img = cv2.imdecode(encimg, 1)
|
|
129
|
-
|
|
130
|
-
if random.random() < 0.4:
|
|
131
|
-
alpha = random.uniform(0.8, 1.2)
|
|
132
|
-
beta = random.randint(-20, 20)
|
|
133
|
-
img = cv2.convertScaleAbs(img, alpha=alpha, beta=beta)
|
|
134
|
-
|
|
135
|
-
level = random.uniform(0.5, 2.5)
|
|
136
|
-
noise = np.random.normal(0, level, img.shape).astype('float32')
|
|
137
|
-
img = np.clip(img.astype('float32') + noise, 0, 255).astype('uint8')
|
|
138
|
-
cv2.imwrite(str(img_path), img)
|
|
139
|
-
|
|
140
|
-
label_path = lbl_dir / f"{output_name}.txt"
|
|
141
|
-
with open(label_path, "w") as f:
|
|
142
|
-
for label in label_data:
|
|
143
|
-
f.write(f"{label[0]} {label[1]:.6f} {label[2]:.6f} {label[3]:.6f} {label[4]:.6f}\n")
|
|
42
|
+
for task_id in batch_ids:
|
|
43
|
+
generate_single_image(page, task_id, db, backgrounds_count, augmenter_js)
|
|
44
|
+
progress_counter.value += 1
|
|
144
45
|
|
|
145
46
|
browser.close()
|
|
146
|
-
|
|
147
47
|
except Exception:
|
|
148
|
-
print(f"
|
|
48
|
+
print(f"Batch failed starting at {batch_ids[0]}:")
|
|
149
49
|
traceback.print_exc()
|
|
150
50
|
|
|
151
|
-
def
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
51
|
+
def generate_single_image(page, task_id, db, backgrounds_count, augmenter_js):
|
|
52
|
+
split = "train" if random.random() < GENERATOR_CONFIG["train_split_fraction"] else "val"
|
|
53
|
+
output_name = f"trade_{task_id:05d}"
|
|
54
|
+
img_dir = DATASET_ROOT / split / "images"
|
|
55
|
+
lbl_dir = DATASET_ROOT / split / "labels"
|
|
56
|
+
img_dir.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
lbl_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
|
|
59
|
+
trade_input = [[], []]
|
|
60
|
+
is_empty_trade = random.random() < GENERATOR_CONFIG["empty_trade_chance"]
|
|
61
|
+
if not is_empty_trade:
|
|
62
|
+
for side in [0, 1]:
|
|
63
|
+
num_items = random.randint(0, 4)
|
|
64
|
+
for _ in range(num_items):
|
|
65
|
+
item = random.choice(db)
|
|
66
|
+
trade_input[side].append(f"../../../../assets/thumbnails/{item['id']}.png")
|
|
157
67
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
]
|
|
162
|
-
if not valid_templates:
|
|
163
|
-
print(f"❌ ERROR: No valid HTML templates found. Checked: {TEMPLATE_FILES}")
|
|
164
|
-
print("Ensure your template files exist and are not just .gitkeep placeholders.")
|
|
165
|
-
return
|
|
68
|
+
aspect_ratio = random.uniform(GENERATOR_CONFIG["aspect_ratio_min"], GENERATOR_CONFIG["aspect_ratio_max"])
|
|
69
|
+
width = random.randint(GENERATOR_CONFIG["width_min"], GENERATOR_CONFIG["width_max"])
|
|
70
|
+
height = int(width / aspect_ratio)
|
|
71
|
+
height = max(GENERATOR_CONFIG["height_min"], min(height, GENERATOR_CONFIG["height_max"]))
|
|
166
72
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
73
|
+
page.set_viewport_size({"width": width, "height": height})
|
|
74
|
+
random_file = random.choice(TEMPLATE_FILES)
|
|
75
|
+
page.goto(f"file://{Path(random_file).absolute()}")
|
|
170
76
|
|
|
77
|
+
zoom_factor = random.uniform(0.5, 2.0)
|
|
78
|
+
page.evaluate(f"document.body.style.zoom = '{zoom_factor}'")
|
|
79
|
+
page.evaluate(augmenter_js, [trade_input, is_empty_trade, backgrounds_count, AUGMENTER_CONFIG])
|
|
80
|
+
|
|
81
|
+
page.evaluate("""
|
|
82
|
+
async () => {
|
|
83
|
+
const imgs = Array.from(document.querySelectorAll('img'));
|
|
84
|
+
const promises = imgs.map(img => {
|
|
85
|
+
if (img.complete) return Promise.resolve();
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
img.onload = resolve;
|
|
88
|
+
img.onerror = resolve; // Continue even if image fails
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
await Promise.all(promises);
|
|
92
|
+
|
|
93
|
+
// Final safety: Wait for the browser to paint
|
|
94
|
+
await new Promise(r => requestAnimationFrame(r));
|
|
95
|
+
}
|
|
96
|
+
""")
|
|
97
|
+
|
|
98
|
+
def get_padded_yolo(element, class_id, pad_px=2):
|
|
99
|
+
box = element.bounding_box()
|
|
100
|
+
if not box: return None
|
|
101
|
+
x1, y1 = max(0, box['x'] - pad_px), max(0, box['y'] - pad_px)
|
|
102
|
+
x2, y2 = min(width, box['x'] + box['width'] + pad_px), min(height, box['y'] + box['height'] + pad_px)
|
|
103
|
+
nw, nh = x2 - x1, y2 - y1
|
|
104
|
+
return [class_id, (x1 + nw/2)/width, (y1 + nh/2)/height, nw/width, nh/height]
|
|
105
|
+
|
|
106
|
+
def is_fully_visible(box, width, height, pad=4):
|
|
107
|
+
return (box['x'] - pad >= 0 and
|
|
108
|
+
box['y'] - pad >= 0 and
|
|
109
|
+
(box['x'] + box['width'] + pad) <= width and
|
|
110
|
+
(box['y'] + box['height'] + pad) <= height)
|
|
111
|
+
|
|
112
|
+
label_data = []
|
|
113
|
+
|
|
114
|
+
items = page.query_selector_all("div[trade-item-card]")
|
|
115
|
+
for item in items:
|
|
116
|
+
box = item.bounding_box()
|
|
117
|
+
if box and is_fully_visible(box, width, height):
|
|
118
|
+
card_box = get_padded_yolo(item, 0, pad_px=4)
|
|
119
|
+
if card_box: label_data.append(card_box)
|
|
120
|
+
|
|
121
|
+
thumb = item.query_selector(".item-card-thumb-container")
|
|
122
|
+
if thumb:
|
|
123
|
+
thumb_box = get_padded_yolo(thumb, 1, pad_px=4)
|
|
124
|
+
if thumb_box: label_data.append(thumb_box)
|
|
125
|
+
|
|
126
|
+
name = item.query_selector(".item-card-name")
|
|
127
|
+
if name:
|
|
128
|
+
name_box = get_padded_yolo(name, 2, pad_px=4)
|
|
129
|
+
if name_box: label_data.append(name_box)
|
|
130
|
+
|
|
131
|
+
robux_sections = page.query_selector_all(".robux-line:not(.total-value)")
|
|
132
|
+
for section in robux_sections:
|
|
133
|
+
box = section.bounding_box()
|
|
134
|
+
if box and is_fully_visible(box, width, height, 8) and section.is_visible():
|
|
135
|
+
line_box = get_padded_yolo(section, 3, pad_px=8)
|
|
136
|
+
if line_box: label_data.append(line_box)
|
|
137
|
+
|
|
138
|
+
value_element = section.query_selector(".robux-line-value")
|
|
139
|
+
if value_element:
|
|
140
|
+
value_box = get_padded_yolo(value_element, 4, pad_px=4)
|
|
141
|
+
if value_box: label_data.append(value_box)
|
|
142
|
+
|
|
143
|
+
img_buffer = page.screenshot(type="jpeg", quality=100)
|
|
144
|
+
nparr = np.frombuffer(img_buffer, np.uint8)
|
|
145
|
+
full_img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
|
146
|
+
|
|
147
|
+
item_cards = page.query_selector_all("div[trade-item-card]")
|
|
148
|
+
for i, card in enumerate(item_cards):
|
|
149
|
+
if not (card.is_visible() and float(card.evaluate("el => getComputedStyle(el).opacity")) > 0):
|
|
150
|
+
continue
|
|
151
|
+
thumb_container = card.query_selector(".item-card-thumb-container")
|
|
152
|
+
if thumb_container and thumb_container.is_visible():
|
|
153
|
+
img_src = thumb_container.query_selector("img").get_attribute("src")
|
|
154
|
+
item_id = Path(img_src).stem
|
|
155
|
+
box = thumb_container.bounding_box()
|
|
156
|
+
if box:
|
|
157
|
+
pad = 4
|
|
158
|
+
max_offset = 5
|
|
159
|
+
off_x = random.randint(-max_offset, max_offset)
|
|
160
|
+
off_y = random.randint(-max_offset, max_offset)
|
|
161
|
+
|
|
162
|
+
x1, y1 = int(box['x'] - pad + off_x), int(box['y'] - pad + off_y)
|
|
163
|
+
x2, y2 = int(box['x'] + box['width'] + pad + off_x), int(box['y'] + box['height'] + pad + off_y)
|
|
164
|
+
if 0 <= x1 and 0 <= y1 and x2 <= width and y2 <= height:
|
|
165
|
+
crop = full_img[y1:y2, x1:x2]
|
|
166
|
+
if crop.size > 0:
|
|
167
|
+
class_dir = DATASET_ROOT / "classification" / item_id
|
|
168
|
+
class_dir.mkdir(parents=True, exist_ok=True)
|
|
169
|
+
if random.random() < 0.3:
|
|
170
|
+
brightness = random.uniform(0.7, 1.3)
|
|
171
|
+
crop = cv2.convertScaleAbs(crop, alpha=brightness, beta=0)
|
|
172
|
+
|
|
173
|
+
if random.random() < 0.2:
|
|
174
|
+
k_size = random.choice([3, 5])
|
|
175
|
+
crop = cv2.GaussianBlur(crop, (k_size, k_size), 0)
|
|
176
|
+
|
|
177
|
+
q = random.randint(70, 95)
|
|
178
|
+
cv2.imwrite(str(class_dir / f"{output_name}_{i}.jpg"), crop, [int(cv2.IMWRITE_JPEG_QUALITY), q])
|
|
179
|
+
|
|
180
|
+
if random.random() < 0.60:
|
|
181
|
+
if random.random() < 0.5:
|
|
182
|
+
q = random.randint(60, 90)
|
|
183
|
+
_, enc = cv2.imencode('.jpg', full_img, [int(cv2.IMWRITE_JPEG_QUALITY), q])
|
|
184
|
+
full_img = cv2.imdecode(enc, 1)
|
|
185
|
+
|
|
186
|
+
if random.random() < 0.4:
|
|
187
|
+
full_img = cv2.convertScaleAbs(full_img, alpha=random.uniform(0.8, 1.2), beta=random.randint(-20, 20))
|
|
188
|
+
|
|
189
|
+
noise = np.random.normal(0, random.uniform(0.5, 2.5), full_img.shape).astype('float32')
|
|
190
|
+
full_img = np.clip(full_img.astype('float32') + noise, 0, 255).astype('uint8')
|
|
191
|
+
|
|
192
|
+
cv2.imwrite(str(img_dir / f"{output_name}.jpg"), full_img, [int(cv2.IMWRITE_JPEG_QUALITY), 95])
|
|
193
|
+
|
|
194
|
+
with open(lbl_dir / f"{output_name}.txt", "w") as f:
|
|
195
|
+
for label in label_data:
|
|
196
|
+
f.write(f"{label[0]} {label[1]:.6f} {label[2]:.6f} {label[3]:.6f} {label[4]:.6f}\n")
|
|
197
|
+
|
|
198
|
+
def run_mass_generation(total_images=65536, max_workers=24):
|
|
171
199
|
with open(DB_PATH, "r") as f:
|
|
172
200
|
db = json.load(f)
|
|
173
201
|
|
|
174
|
-
backgrounds_count = len([f for f in BACKGROUNDS_DIR.iterdir() if f.is_file()]) - 1
|
|
175
|
-
|
|
176
202
|
setup_dataset_directories(force_reset=True)
|
|
177
203
|
|
|
178
|
-
|
|
204
|
+
batch_size = 500
|
|
205
|
+
all_ids = list(range(total_images))
|
|
206
|
+
chunks = [all_ids[i:i + batch_size] for i in range(0, len(all_ids), batch_size)]
|
|
207
|
+
|
|
208
|
+
backgrounds_count = len([f for f in BACKGROUNDS_DIR.iterdir() if f.is_file()])
|
|
209
|
+
|
|
210
|
+
manager = multiprocessing.Manager()
|
|
211
|
+
progress_counter = manager.Value('i', 0)
|
|
212
|
+
|
|
213
|
+
print(f"Generating {total_images} images using {max_workers} workers...")
|
|
179
214
|
|
|
180
215
|
with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
181
|
-
futures = [
|
|
182
|
-
|
|
183
|
-
|
|
216
|
+
futures = [
|
|
217
|
+
executor.submit(process_batch, chunk, db, backgrounds_count, progress_counter)
|
|
218
|
+
for chunk in chunks
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
with tqdm(total=total_images, desc="Generating Images") as pbar:
|
|
222
|
+
last_val = 0
|
|
223
|
+
while True:
|
|
224
|
+
done, not_done = concurrent.futures.wait(futures, timeout=0.5)
|
|
225
|
+
|
|
226
|
+
current_val = progress_counter.value
|
|
227
|
+
pbar.update(current_val - last_val)
|
|
228
|
+
last_val = current_val
|
|
229
|
+
|
|
230
|
+
if len(not_done) == 0:
|
|
231
|
+
break
|
|
184
232
|
|
|
185
233
|
if __name__ == "__main__":
|
|
186
|
-
run_mass_generation(
|
|
234
|
+
run_mass_generation()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from ultralytics import YOLO
|
|
2
2
|
from ..core.config import TRAINING_CONFIG, DATA_YAML_PATH
|
|
3
3
|
|
|
4
|
-
def
|
|
4
|
+
def train_yolo(device):
|
|
5
5
|
model = YOLO("yolo11n.pt")
|
|
6
6
|
|
|
7
7
|
model.train(
|
|
@@ -27,7 +27,7 @@ def train_model(device):
|
|
|
27
27
|
workers = 8
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
-
def finish_training(file_path):
|
|
30
|
+
def finish_training(file_path, device):
|
|
31
31
|
model = YOLO(file_path)
|
|
32
32
|
|
|
33
33
|
model.train(
|
|
@@ -35,10 +35,7 @@ def finish_training(file_path):
|
|
|
35
35
|
epochs = 32,
|
|
36
36
|
close_mosaic = 32,
|
|
37
37
|
patience = 20,
|
|
38
|
-
imgsz =
|
|
39
|
-
batch =
|
|
40
|
-
device =
|
|
38
|
+
imgsz = TRAINING_CONFIG["img_size"],
|
|
39
|
+
batch = TRAINING_CONFIG["batch_size"],
|
|
40
|
+
device = device
|
|
41
41
|
)
|
|
42
|
-
|
|
43
|
-
if __name__ == "__main__":
|
|
44
|
-
train_model()
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: rbx-proofreader
|
|
3
|
+
Version: 1.1.1
|
|
4
|
+
Summary: Visual trade detection and OCR engine
|
|
5
|
+
License: MIT
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
8
|
+
Requires-Python: >=3.12
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: easyocr>=1.7.0
|
|
12
|
+
Requires-Dist: numpy>=1.24.0
|
|
13
|
+
Requires-Dist: opencv-python>=4.8.0
|
|
14
|
+
Requires-Dist: Pillow>=10.0.0
|
|
15
|
+
Requires-Dist: rapidfuzz>=3.0.0
|
|
16
|
+
Requires-Dist: requests>=2.31.0
|
|
17
|
+
Requires-Dist: torch>=2.0.0
|
|
18
|
+
Requires-Dist: tqdm>=4.66.0
|
|
19
|
+
Requires-Dist: transformers>=4.30.0
|
|
20
|
+
Requires-Dist: ultralytics>=8.0.0
|
|
21
|
+
Provides-Extra: train
|
|
22
|
+
Requires-Dist: playwright>=1.40.0; extra == "train"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
# Proofreader 🔍
|
|
26
|
+
|
|
27
|
+
A high-speed vision pipeline for reading Roblox trade screenshots.
|
|
28
|
+
|
|
29
|
+
[](https://pypi.org/project/rbx-proofreader/)
|
|
30
|
+
[](https://pepy.tech/project/rbx-proofreader)
|
|
31
|
+
[](https://pypi.org/project/rbx-proofreader/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](https://github.com/lucacrose/proofreader/actions)
|
|
34
|
+
[](https://developer.nvidia.com/cuda-zone)
|
|
35
|
+
[](https://github.com/ultralytics/ultralytics)
|
|
36
|
+
|
|
37
|
+
Proofreader transforms unstructured screenshots of Roblox trades ("proofs", hence "proofreader") into structured Python dictionaries. By combining **YOLOv11** for object detection, **CLIP** for visual similarity, and **EasyOCR**, it achieves high accuracy across diverse UI themes, resolutions, and extensions.
|
|
38
|
+
|
|
39
|
+
## Why Proofreader?
|
|
40
|
+
|
|
41
|
+
Roblox trade screenshots are commonly used as proof in marketplaces, moderation workflows, and value analysis, yet they are manually verified and error-prone. Proofreader automates this process by converting screenshots into structured, verifiable data in milliseconds.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
## Example
|
|
45
|
+
|
|
46
|
+

|
|
47
|
+
|
|
48
|
+
## ⚡ Performance
|
|
49
|
+
|
|
50
|
+
Tested on an **RTX 5070** using $n=500$ real-world "worst-case" user screenshots (compressed, cropped, and varied UI).
|
|
51
|
+
|
|
52
|
+
| Metric | Result (E2E) |
|
|
53
|
+
|:------------------------|:----------------------------|
|
|
54
|
+
| Exact Match Accuracy | 97.2% (95% CI: 95.4–98.5%) |
|
|
55
|
+
| Median latency | 36.8 ms |
|
|
56
|
+
| 95th percentile latency | 73.4 ms |
|
|
57
|
+
|
|
58
|
+
> [!NOTE]
|
|
59
|
+
> End-to-End **(E2E)** latency includes image loading, YOLO detection, spatial organization, CLIP similarity matching, and OCR fallback.
|
|
60
|
+
|
|
61
|
+
## ✨ Key Features
|
|
62
|
+
|
|
63
|
+
- **Sub-40ms Latency:** Optimized with "Fast-Path" logic that skips OCR for high-confidence visual matches, ensuring near-instant processing.
|
|
64
|
+
|
|
65
|
+
- **Multi-modal decision engine:** Weighs visual embeddings against OCR text to resolve identities across 2,500+ distinct item classes.
|
|
66
|
+
|
|
67
|
+
- **Fuzzy Logic Recovery:** Built-in string distance matching corrects OCR typos and text obscurations against a local asset database.
|
|
68
|
+
|
|
69
|
+
- **Theme & Scale Agnostic:** Robust performance across various UI themes (Dark/Light), resolutions, and custom display scales.
|
|
70
|
+
|
|
71
|
+
## 💻 Quick Start
|
|
72
|
+
|
|
73
|
+
### Installation
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
pip install rbx-proofreader
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
> [!IMPORTANT]
|
|
80
|
+
> **Hardware Acceleration:** Proofreader automatically detects NVIDIA GPUs. For sub-40ms performance, ensure you have the CUDA-enabled version of PyTorch installed. If a CPU-only environment is detected on a GPU-capable machine, the engine will provide the exact `pip` command to fix your environment.
|
|
81
|
+
|
|
82
|
+
### Usage
|
|
83
|
+
|
|
84
|
+
```py
|
|
85
|
+
import proofreader
|
|
86
|
+
|
|
87
|
+
# Extract metadata from a screenshot
|
|
88
|
+
data = proofreader.get_trade_data("trade_proof.png")
|
|
89
|
+
|
|
90
|
+
print(f"Items Out: {data['outgoing']['item_count']}")
|
|
91
|
+
print(f"Robux In: {data['incoming']['robux_value']}")
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
> [!TIP]
|
|
95
|
+
> **First Run:** On your first execution, Proofreader will automatically download the model weights and item database (~360MB). Subsequent runs will use the local cache for maximum speed.
|
|
96
|
+
|
|
97
|
+
## 🧩 How it Works
|
|
98
|
+
The model handles the inconsistencies of user-generated screenshots (varied crops, UI themes, and extensions) through a multi-stage process:
|
|
99
|
+
|
|
100
|
+
1. **Detection:** YOLOv11 localizes item cards, thumbnails, and robux containers.
|
|
101
|
+
|
|
102
|
+
2. **Spatial Organization:** Assigns child elements (names/values) to parents and determines trade side.
|
|
103
|
+
|
|
104
|
+
3. **Identification:** CLIP performs similarity matching. High-confidence results become Resolved Items immediately.
|
|
105
|
+
|
|
106
|
+
4. **Heuristic Judge:** Low-confidence visual matches trigger OCR and fuzzy-logic reconciliation.
|
|
107
|
+
|
|
108
|
+

|
|
109
|
+
|
|
110
|
+
## 📊 Data Schema
|
|
111
|
+
The `get_trade_data()` function returns a structured dictionary containing `incoming` and `outgoing` trade sides.
|
|
112
|
+
|
|
113
|
+
| Key | Type | Description |
|
|
114
|
+
| :--- | :--- | :--- |
|
|
115
|
+
| `item_count` | `int` | Number of distinct item boxes detected. |
|
|
116
|
+
| `robux_value` | `int` | Total Robux parsed from the trade. |
|
|
117
|
+
| `items` | `list` | List of `ResolvedItem` objects containing `id` and `name`. |
|
|
118
|
+
|
|
119
|
+
**ResolvedItem Schema:**
|
|
120
|
+
|
|
121
|
+
| Property | Type | Description |
|
|
122
|
+
| :--- | :--- | :--- |
|
|
123
|
+
| `id` | `int` | The official Roblox Asset ID. |
|
|
124
|
+
| `name` | `str` | Canonical item name from the database. |
|
|
125
|
+
|
|
126
|
+
## 🏗️ Development & Training
|
|
127
|
+
To set up a custom training environment for the YOLO and CLIP models:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
# 1. Clone and Install
|
|
131
|
+
git clone https://github.com/lucacrose/proofreader.git
|
|
132
|
+
cd proofreader
|
|
133
|
+
pip install -e ".[train]"
|
|
134
|
+
|
|
135
|
+
# 2. Initialize Database
|
|
136
|
+
python scripts/setup_items.py
|
|
137
|
+
|
|
138
|
+
# 3. Training
|
|
139
|
+
# Place backgrounds in src/proofreader/train/emulator/backgrounds
|
|
140
|
+
# Place HTML templates in src/proofreader/train/emulator/templates
|
|
141
|
+
python scripts/train_models.py
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
> [!CAUTION]
|
|
145
|
+
> **GPU Required:** Training is not recommended on a CPU. Final models save to `runs/train/weights/best.pt`. Rename to `yolo.pt` and move to `src/assets/weights`.
|
|
146
|
+
|
|
147
|
+
## 🛠️ Tech Stack
|
|
148
|
+
|
|
149
|
+
- **Vision:** YOLOv11 (Detection), CLIP (Embeddings), OpenCV (Processing)
|
|
150
|
+
- **OCR:** EasyOCR
|
|
151
|
+
- **Logic:** RapidFuzz (Fuzzy String Matching)
|
|
152
|
+
- **Core:** Python 3.12, PyTorch, NumPy
|
|
153
|
+
|
|
154
|
+
## 🤝 Contributing
|
|
155
|
+
|
|
156
|
+
Contributions are welcome! Please open an issue or submit a pull request.
|
|
157
|
+
|
|
158
|
+
## 📜 License
|
|
159
|
+
|
|
160
|
+
This project is licensed under the MIT License.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
proofreader/__init__.py,sha256=YVsRxmHmC2nvCrxvNmZX230B1s5k36RFM51kElXSxB4,285
|
|
2
|
+
proofreader/main.py,sha256=01G_-ppevuNNafi-QCc6UB_Y2NuIW6sDoZwvjjdm1B0,5220
|
|
3
|
+
proofreader/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
proofreader/core/config.py,sha256=8L6MTBn3Z3Xa0bjPYt5q-OI-mm0-wMqeDSS0beAQ1fk,5906
|
|
5
|
+
proofreader/core/detector.py,sha256=em2Kx0v96Zofi4kK5ipWlqMX9czq9YobHuEGuZkAQEc,987
|
|
6
|
+
proofreader/core/matcher.py,sha256=4URgBb6EgBaCNFpafjnQrIot9KeIwoYUUNraaC9nlIk,3603
|
|
7
|
+
proofreader/core/ocr.py,sha256=FFhIS1TVrqSXUPGOll5RNbHX18q7de4xFUP1ewrnhSc,3652
|
|
8
|
+
proofreader/core/resolver.py,sha256=DTbf5qyQaJrBbw1QWQQJ_BZf_dg003p_xH8RMpI6sn8,2685
|
|
9
|
+
proofreader/core/schema.py,sha256=ga_7cYCBO13yFvLAtyAgDw7CFEb9c8Ui85SJDu2pcsA,2512
|
|
10
|
+
proofreader/train/clip_trainer.py,sha256=6hiVrJ6WX6m13E3FE8kouIxXjQo3GPrU_8X266oeXqs,6416
|
|
11
|
+
proofreader/train/yolo_trainer.py,sha256=nOHPrYmBuefsUyiGEYqboNU6i3pykBXE0U4HYwNaqg8,986
|
|
12
|
+
proofreader/train/emulator/generator.py,sha256=_l7qFLSoQxPYUKLDrqVIS-0sUs5FkjBK7ENWmZ-q2ls,9681
|
|
13
|
+
rbx_proofreader-1.1.1.dist-info/licenses/LICENSE,sha256=eHSaONn9P_ZcYiY9QCi_XzVARIoQu7l2AI5BtFGA_BY,1069
|
|
14
|
+
rbx_proofreader-1.1.1.dist-info/METADATA,sha256=CNi-FAGJvwoEL6LPmpL8m39canCcXsg_idlPTQFVeFA,6568
|
|
15
|
+
rbx_proofreader-1.1.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
16
|
+
rbx_proofreader-1.1.1.dist-info/top_level.txt,sha256=U3s8IVdLtGeGD3JgMmCHUgAsFhZXSSamp3vIojAFTxU,12
|
|
17
|
+
rbx_proofreader-1.1.1.dist-info/RECORD,,
|
proofreader/train/builder.py
DELETED
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import json
|
|
3
|
-
import torch
|
|
4
|
-
from PIL import Image
|
|
5
|
-
from ..core.config import DB_PATH, CACHE_PATH, THUMBNAILS_DIR, BUILDER_BATCH_SIZE
|
|
6
|
-
|
|
7
|
-
class EmbeddingBuilder:
|
|
8
|
-
def __init__(self, model, processor):
|
|
9
|
-
self.model = model
|
|
10
|
-
self.processor = processor
|
|
11
|
-
|
|
12
|
-
def get_clip_embedding(self, pil_img):
|
|
13
|
-
inputs = self.processor(images=pil_img, return_tensors="pt", padding=True).to(self.model.device)
|
|
14
|
-
with torch.no_grad():
|
|
15
|
-
features = self.model.get_image_features(**inputs)
|
|
16
|
-
return features.cpu().numpy().flatten()
|
|
17
|
-
|
|
18
|
-
def build(self, batch_size=BUILDER_BATCH_SIZE):
|
|
19
|
-
self.model.eval()
|
|
20
|
-
print(f"Starting build process...")
|
|
21
|
-
print(f"Source Images: {THUMBNAILS_DIR}")
|
|
22
|
-
print(f"Item Database: {DB_PATH}")
|
|
23
|
-
|
|
24
|
-
if not os.path.exists(DB_PATH):
|
|
25
|
-
print(f"Error: Missing {DB_PATH}. Cannot map IDs to Names.")
|
|
26
|
-
return
|
|
27
|
-
|
|
28
|
-
with open(DB_PATH, "r") as f:
|
|
29
|
-
items = json.load(f)
|
|
30
|
-
|
|
31
|
-
embedding_bank = {}
|
|
32
|
-
item_names = []
|
|
33
|
-
|
|
34
|
-
if not os.path.exists(THUMBNAILS_DIR):
|
|
35
|
-
print(f"Error: Image directory {THUMBNAILS_DIR} not found.")
|
|
36
|
-
return
|
|
37
|
-
|
|
38
|
-
image_files = [f for f in os.listdir(THUMBNAILS_DIR) if f.lower().endswith(('.png', '.jpg', '.jpeg'))]
|
|
39
|
-
total_files = len(image_files)
|
|
40
|
-
|
|
41
|
-
embedding_bank = {}
|
|
42
|
-
item_names = []
|
|
43
|
-
|
|
44
|
-
for i in range(0, total_files, batch_size):
|
|
45
|
-
batch_files = image_files[i : i + batch_size]
|
|
46
|
-
batch_imgs = []
|
|
47
|
-
batch_item_names = []
|
|
48
|
-
|
|
49
|
-
for filename in batch_files:
|
|
50
|
-
item_id = os.path.splitext(filename)[0]
|
|
51
|
-
item_info = next((item for item in items if str(item.get("id")) == item_id), None)
|
|
52
|
-
|
|
53
|
-
if item_info:
|
|
54
|
-
try:
|
|
55
|
-
img_path = os.path.join(THUMBNAILS_DIR, filename)
|
|
56
|
-
raw_img = Image.open(img_path)
|
|
57
|
-
|
|
58
|
-
if raw_img.mode in ("RGBA", "P"):
|
|
59
|
-
bg = Image.new("RGB", raw_img.size, (255, 255, 255))
|
|
60
|
-
bg.paste(raw_img.convert("RGBA"), (0, 0), raw_img.convert("RGBA"))
|
|
61
|
-
img = bg
|
|
62
|
-
else:
|
|
63
|
-
img = raw_img.convert("RGB")
|
|
64
|
-
|
|
65
|
-
batch_imgs.append(img)
|
|
66
|
-
batch_item_names.append(item_info["name"])
|
|
67
|
-
except Exception as e:
|
|
68
|
-
print(f"Could not load {filename}: {e}")
|
|
69
|
-
|
|
70
|
-
if not batch_imgs:
|
|
71
|
-
continue
|
|
72
|
-
try:
|
|
73
|
-
inputs = self.processor(images=batch_imgs, return_tensors="pt", padding=True).to(self.model.device)
|
|
74
|
-
with torch.no_grad():
|
|
75
|
-
features = self.model.get_image_features(**inputs)
|
|
76
|
-
|
|
77
|
-
features_numpy = features.cpu().numpy()
|
|
78
|
-
for name, emb in zip(batch_item_names, features_numpy):
|
|
79
|
-
embedding_bank[name] = emb
|
|
80
|
-
item_names.append(name)
|
|
81
|
-
|
|
82
|
-
print(f"Progress: {min(i + batch_size, total_files)}/{total_files} items indexed...")
|
|
83
|
-
except Exception as e:
|
|
84
|
-
print(f"Batch processing error: {e}")
|
|
85
|
-
|
|
86
|
-
output_data = {
|
|
87
|
-
'embeddings': embedding_bank,
|
|
88
|
-
'names': item_names
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
torch.save(output_data, CACHE_PATH)
|
|
92
|
-
print(f"\n✅ Build Complete!")
|
|
93
|
-
print(f"Target: {CACHE_PATH}")
|
|
94
|
-
print(f"Total Embeddings Saved: {len(embedding_bank)}")
|