rbx-proofreader 1.0.0__py3-none-any.whl → 1.1.0__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.
@@ -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 worker_task(task_id, db, backgrounds_count):
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(headless=True)
52
-
53
- aspect_ratio = random.uniform(GENERATOR_CONFIG["aspect_ratio_min"], GENERATOR_CONFIG["aspect_ratio_max"])
54
- width = random.randint(GENERATOR_CONFIG["width_min"], GENERATOR_CONFIG["width_max"])
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(viewport={"width": width, "height": height})
36
+ context = browser.new_context()
59
37
  page = context.new_page()
60
38
 
61
- random_file = random.choice(TEMPLATE_FILES)
62
- page.goto(f"file://{Path(random_file).absolute()}")
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
- def get_padded_yolo(element, class_id, pad_px=2):
67
- box = element.bounding_box()
68
- if not box: return None
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"Error generating task {task_id}:")
48
+ print(f"Batch failed starting at {batch_ids[0]}:")
149
49
  traceback.print_exc()
150
50
 
151
- def run_mass_generation(total_images=GENERATOR_CONFIG["total_images"], max_workers=GENERATOR_CONFIG["max_workers"]):
152
- bg_files = [f for f in BACKGROUNDS_DIR.iterdir() if f.is_file() and f.name != ".gitkeep"]
153
- if not bg_files:
154
- print(f"❌ ERROR: No background images found in {BACKGROUNDS_DIR}")
155
- print("Please add background images (JPG/PNG) to the folder before running.")
156
- return
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
- valid_templates = [
159
- t for t in TEMPLATE_FILES
160
- if Path(t).exists() and Path(t).name != ".gitkeep"
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
- if not DB_PATH.exists():
168
- print(f"❌ ERROR: Item database missing at {DB_PATH}")
169
- return
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
- print(f"Starting generation of {total_images} images using {max_workers} processes...")
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 = [executor.submit(worker_task, i, db, backgrounds_count) for i in range(total_images)]
182
- for _ in tqdm(concurrent.futures.as_completed(futures), total=total_images):
183
- pass
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(total_images=16, max_workers=8)
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 train_model(device):
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 = 640,
39
- batch = 24,
40
- device = 0 # Change to "cpu" if no CUDA devices
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.0
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
+ [![PyPI](https://img.shields.io/pypi/v/rbx-proofreader?color=blue&label=PyPI)](https://pypi.org/project/rbx-proofreader/)
30
+ [![Downloads](https://static.pepy.tech/badge/rbx-proofreader)](https://pepy.tech/project/rbx-proofreader)
31
+ [![Python](https://img.shields.io/pypi/pyversions/rbx-proofreader?logo=python&logoColor=white&color=blue)](https://pypi.org/project/rbx-proofreader/)
32
+ [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
33
+ [![Build Status](https://github.com/lucacrose/proofreader/actions/workflows/build.yml/badge.svg)](https://github.com/lucacrose/proofreader/actions)
34
+ [![GPU](https://img.shields.io/badge/GPU-CUDA-blueviolet)](https://developer.nvidia.com/cuda-zone)
35
+ [![YOLOv11](https://img.shields.io/badge/model-YOLOv11-blueviolet)](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
+ ![Example](https://github.com/lucacrose/proofreader/raw/main/docs/assets/example.png)
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
+ ![Diagram](https://github.com/lucacrose/proofreader/raw/main/docs/assets/flow_diagram.png)
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=eMl4Zc9790mVKouPlGaNJtST8OhoIVyt-W6k-6PutDQ,5120
3
+ proofreader/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ proofreader/core/config.py,sha256=CCYXL7uY0fc4EjMQy4GmtmV46rNk3ndQ7b3S7fPsGL4,5931
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.0.dist-info/licenses/LICENSE,sha256=eHSaONn9P_ZcYiY9QCi_XzVARIoQu7l2AI5BtFGA_BY,1069
14
+ rbx_proofreader-1.1.0.dist-info/METADATA,sha256=S4s8IOiRRhZKsGkUpqrUmjUWG_gMdY2uNWplV1_21Ts,6568
15
+ rbx_proofreader-1.1.0.dist-info/WHEEL,sha256=qELbo2s1Yzl39ZmrAibXA2jjPLUYfnVhUNTlyF1rq0Y,92
16
+ rbx_proofreader-1.1.0.dist-info/top_level.txt,sha256=U3s8IVdLtGeGD3JgMmCHUgAsFhZXSSamp3vIojAFTxU,12
17
+ rbx_proofreader-1.1.0.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.1)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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)}")