""" Botty Asset Manager - Unified asset management tool. All-in-one tool for managing D2R template assets: capture, crop, audit, analyze, and maintain your template library. Commands: inventory List all assets with size, dimensions, category audit Find issues: duplicates, orphans, naming problems quality Analyze image quality: resolution, transparency, size capture Capture D2R window to screenshots/captures/ crop X Y W H NAME Crop region from latest capture, save as template auto_crop Interactive: click D2R to select a crop region search TERM Find assets matching a name/pattern key NAME Look up the template key to use in code validate Check all templates load correctly similarity Find near-duplicate images cleanup [--yes] Find/remove duplicate assets batch OP VALUE Batch operation: "resize WxH" or "convert png" help Show this help Examples: python asset_manager.py inventory python asset_manager.py audit python asset_manager.py search akara python asset_manager.py key akara_front python asset_manager.py crop 100 200 50 80 my_npc python asset_manager.py auto_crop python asset_manager.py similarity python asset_manager.py cleanup python asset_manager.py validate Template naming convention: - Use lowercase_with_underscores (e.g. akara_front.png) - Template key is the filename uppercased (e.g. AKARA_FRONT) - NPC assets go in assets/npc// - UI templates go in assets/templates/ui/ - Item templates go in assets/item_properties/ """ import os, sys, argparse, json, hashlib, time, math, re from pathlib import Path from datetime import datetime from collections import defaultdict # DPI awareness try: import ctypes ctypes.windll.shcore.SetProcessDpiAwareness(2) except: pass # Fix DLL loading if sys.platform == "win32": _dll = os.path.join(os.path.dirname(os.path.dirname(sys.executable)), "Library", "bin") if os.path.isdir(_dll): os.add_dll_directory(_dll) import cv2 import numpy as np BASE = Path(os.path.dirname(os.path.abspath(__file__))) ASSETS = BASE / "assets" # Template directories that template_finder.py loads TEMPLATE_DIRS = [ "templates", "npc", "shop", "item_properties", "chests", "gamble", "items", ] # Known NPC names for routing NPC_NAMES = { 'akara', 'charsi', 'kashya', 'cain', 'drognan', 'lysander', 'fara', 'ormus', 'tyrael', 'jamella', 'halbu', 'qual_kehk', 'malah', 'larzuk', 'anya', 'carrow', 'ashera', 'alkaar', 'elzix', 'meshiff', 'hrrky', 'izhu', 'essjay', 'seraphina', 'aluria', 'jermak', 'griswold', 'hugel', 'rodek', 'meathead', 'gheed', 'act1', 'act2', 'act3', 'act4', 'act5', } # ===================== IMAGE UTILITIES ===================== def img_hash(path): """MD5 hash of image file content.""" try: with open(path, 'rb') as f: return hashlib.md5(f.read()).hexdigest() except: return None def img_hash_fast(path): """Faster hash: read first/last 4KB of file.""" try: sz = os.path.getsize(path) with open(path, 'rb') as f: h = hashlib.md5(f.read(4096)).hexdigest() if sz > 4096: f.seek(-4096, 2) h += hashlib.md5(f.read(4096)).hexdigest() return h except: return None def img_dims(path): """Return (w, h) or None.""" try: img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if img is None: return None return img.shape[1], img.shape[0] except: return None def img_quick_info(path): """Return (w, h, has_alpha) in a single image load.""" try: img = cv2.imread(str(path), cv2.IMREAD_UNCHANGED) if img is None: return None, None, False w, h = img.shape[1], img.shape[0] has_alpha = (img.shape[2] == 4 and np.min(img[:, :, 3]) < 255) if len(img.shape) > 1 and img.shape[2] >= 4 else False return w, h, has_alpha except: return None, None, False def img_similarity(path1, path2): """Compute visual similarity between two images (0-1, higher = more similar). Uses resized comparison + MSE for speed.""" try: img1 = cv2.imread(str(path1)) img2 = cv2.imread(str(path2)) if img1 is None or img2 is None: return 0.0 # Resize to same size for comparison img1 = cv2.resize(img1, (64, 64)) img2 = cv2.resize(img2, (64, 64)) mse = np.mean((img1.astype('float') - img2.astype('float')) ** 2) return float(math.exp(-mse / 10000)) except: return 0.0 # ===================== ASSET GATHERING ===================== def gather_assets(asset_dirs=None): """Gather all asset file paths with metadata. Uses lazy evaluation for image info.""" if asset_dirs is None: asset_dirs = TEMPLATE_DIRS assets = {} for d in asset_dirs: dir_path = ASSETS / d if not dir_path.exists(): continue for f in dir_path.rglob('*.png'): rel = str(f.relative_to(ASSETS)) assets[rel] = { 'path': f, 'category': d, 'size': f.stat().st_size, 'dims': None, # Lazy-loaded 'hash': img_hash(f), 'fast_hash': img_hash_fast(f), 'has_alpha': False, # Lazy-loaded } return assets def _ensure_image_info(info): """Lazy-load image dimensions and alpha info if not already loaded.""" if info['dims'] is not None: return w, h, alpha = img_quick_info(info['path']) info['dims'] = (w, h) if w is not None else None info['has_alpha'] = alpha # ===================== COMMANDS ===================== def cmd_inventory(args): """List all assets with details.""" assets = gather_assets() if not assets: print("No assets found.") return # Group by category cats = defaultdict(list) for name, info in sorted(assets.items()): cats[info['category']].append((name, info)) print(f"\n{'='*70}") print(f" Botty Asset Inventory ({len(assets)} assets)") print(f"{'='*70}\n") total_size = 0 for cat in sorted(cats.keys()): items = cats[cat] cat_size = sum(i['size'] for _, i in items) total_size += cat_size print(f" [{cat.upper()}] ({len(items)} files, {cat_size/1024:.1f} KB)") for name, info in items: _ensure_image_info(info) dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" alpha = " [A]" if info['has_alpha'] else "" size_str = f"{info['size']/1024:.1f} KB" if info['size'] >= 1024 else f"{info['size']} B" print(f" {name} {dims_str} {size_str}{alpha}") print() print(f" Total: {len(assets)} files, {total_size/1024:.1f} KB") print() def cmd_search(args): """Search assets by name/pattern.""" assets = gather_assets() if not assets: print("No assets found.") return term = ' '.join(args.args).lower() # Exact and fuzzy matches results = [] for name, info in assets.items(): name_lower = name.lower() stem = Path(name).stem.lower() score = 0 if term in stem: score = 100 elif stem in term: score = 80 elif term in name_lower: score = 60 elif any(w in stem for w in term.split()): score = 40 else: # Check with separators removed clean = stem.replace('_', '').replace('-', '') clean_term = term.replace('_', '').replace('-', '') if clean_term in clean: score = 30 elif clean in clean_term: score = 20 if score > 0: results.append((score, name, info)) # Sort by score descending results.sort(key=lambda x: -x[0]) print(f"\n{'='*70}") print(f" Search: '{term}' ({len(results)} results)") print(f"{'='*70}\n") if not results: print(" No matches found.") # Suggest closest best = None best_dist = 999 for name, info in assets.items(): stem = Path(name).stem.lower() dist = len(set(term) - set(stem)) if dist < best_dist and dist < len(term): best_dist = dist best = stem if best: print(f" Closest: {best}") else: for score, name, info in results[:50]: _ensure_image_info(info) dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" template_key = Path(name).stem.upper() alpha = " [A]" if info['has_alpha'] else "" print(f" {name} {dims_str} key={template_key}{alpha}") if len(results) > 50: print(f" ... and {len(results) - 50} more") print() def cmd_key(args): """Look up the template key to use in code.""" assets = gather_assets() if not assets: print("No assets found.") return term = ' '.join(args.args) if not term: print(" Usage: python asset_manager.py key ") print(" Example: python asset_manager.py key akara_front") return term_lower = term.lower().replace('-', '_') # Find matching assets matches = [] for name, info in assets.items(): stem = Path(name).stem.lower() if term_lower in stem or stem in term_lower: template_key = Path(name).stem.upper() matches.append((name, template_key, info)) print(f"\n{'='*70}") print(f" Template Key Lookup: '{term}'") print(f"{'='*70}\n") if not matches: print(f" No assets matching '{term}'.") print(f" Try: python asset_manager.py search {term}") else: for name, key, info in matches[:10]: _ensure_image_info(info) dims_str = f"{info['dims'][0]}x{info['dims'][1]}" if info['dims'] else "???" print(f" {name}") print(f" Key: '{key}'") print(f" Use: template_finder.search('{key}', img, threshold=0.XX)") print(f" Size: {dims_str}") print() print() def cmd_audit(args): """Find asset issues: duplicates, orphans, naming problems.""" assets = gather_assets() issues = [] # 1. Find exact duplicates (same hash) hash_map = defaultdict(list) for name, info in assets.items(): if info['hash']: hash_map[info['hash']].append(name) print(f"\n{'='*70}") print(f" Botty Asset Audit") print(f"{'='*70}\n") print(" DUPLICATES (identical content):") dup_count = 0 for h, names in hash_map.items(): if len(names) > 1: dup_count += len(names) - 1 print(f" {len(names)}x: {', '.join(names)}") if not dup_count: print(" None found.") # 2. Naming convention issues print(f"\n NAMING ISSUES:") naming_issues = 0 for name, info in assets.items(): base = Path(name).stem if ' ' in base: print(f" {name} - contains spaces") naming_issues += 1 if base != base.lower() and base != base.upper(): print(f" {name} - mixed case") naming_issues += 1 if '_' in base and '-' in base: print(f" {name} - mixed separators") naming_issues += 1 # Dots in filename (not extension) if '.' in base and not base.endswith('.png'): print(f" {name} - contains dots in name (use underscores)") naming_issues += 1 if not naming_issues: print(" None found.") # 3. Oversized assets print(f"\n OVERSIZED (>500x500, likely full screenshots misused as templates):") oversized = 0 for name, info in assets.items(): _ensure_image_info(info) if info['dims'] and (info['dims'][0] > 500 or info['dims'][1] > 500): print(f" {name} {info['dims'][0]}x{info['dims'][1]}") oversized += 1 if not oversized: print(" None found.") # 4. Tiny assets print(f"\n TINY (<10x10, likely corrupted or miscropped):") tiny = 0 for name, info in assets.items(): _ensure_image_info(info) if info['dims'] and (info['dims'][0] < 10 or info['dims'][1] < 10): print(f" {name} {info['dims'][0]}x{info['dims'][1]}") tiny += 1 if not tiny: print(" None found.") # 5. Asymmetric assets (potential miscrop) print(f"\n VERY ASYMMETRIC (ratio >10:1, potential miscrop):") asym = 0 for name, info in assets.items(): _ensure_image_info(info) if info['dims']: w, h = info['dims'] ratio = max(w, h) / max(min(w, h), 1) if ratio > 10 and max(w, h) > 30: print(f" {name} {w}x{h} ratio {ratio:.0f}:1") asym += 1 if not asym: print(" None found.") print(f"\n Summary: {dup_count} duplicates, {naming_issues} naming issues, " f"{oversized} oversized, {tiny} tiny, {asym} asymmetric") print() def cmd_quality(args): """Analyze image quality metrics.""" assets = gather_assets() if not assets: print("No assets found.") return print(f"\n{'='*70}") print(f" Botty Asset Quality Report") print(f"{'='*70}\n") # Resolution distribution dims = defaultdict(int) for name, info in assets.items(): _ensure_image_info(info) if info['dims']: dims[str(info['dims'][0]) + 'x' + str(info['dims'][1])] += 1 print(" Resolution distribution (top 20):") for d, c in sorted(dims.items(), key=lambda x: -x[1])[:20]: print(f" {d}: {c} files") print() # File size distribution sizes = defaultdict(int) for name, info in assets.items(): bucket = info['size'] // 1024 if bucket < 1: sizes['<1 KB'] += 1 elif bucket < 10: sizes['1-10 KB'] += 1 elif bucket < 50: sizes['10-50 KB'] += 1 elif bucket < 100: sizes['50-100 KB'] += 1 else: sizes['>100 KB'] += 1 print(" File size distribution:") for s, c in sorted(sizes.items()): print(f" {s}: {c} files") print() # Transparency usage alpha_count = sum(1 for info in assets.values() if info['has_alpha']) print(f" With transparency (alpha): {alpha_count}/{len(assets)}") print() # Per-category stats print(" Per-category stats:") cats = defaultdict(lambda: {'count': 0, 'total_size': 0, 'avg_dims': [0, 0]}) for name, info in assets.items(): _ensure_image_info(info) c = cats[info['category']] c['count'] += 1 c['total_size'] += info['size'] if info['dims']: c['avg_dims'][0] += info['dims'][0] c['avg_dims'][1] += info['dims'][1] for cat in sorted(cats.keys()): c = cats[cat] avg_w = c['avg_dims'][0] // c['count'] if c['count'] else 0 avg_h = c['avg_dims'][1] // c['count'] if c['count'] else 0 print(f" {cat}: {c['count']} files, {c['total_size']/1024:.1f} KB, avg {avg_w}x{avg_h}") print() def find_d2r(): """Find D2R window handle.""" import win32gui import psutil # Find D2R process first d2r_pids = set() for proc in psutil.process_iter(['name']): try: if proc.info['name'] and 'D2R' in proc.info['name']: d2r_pids.add(proc.pid) except: pass if not d2r_pids: return None hwnds = [] def cb(h, r): title = win32gui.GetWindowText(h) if 'diablo' in title.lower() and win32gui.IsWindowVisible(h): # Check if this window belongs to D2R process import win32process _, pid = win32process.GetWindowThreadProcessId(h) if pid in d2r_pids: r.append((h, title)) win32gui.EnumWindows(cb, hwnds) if not hwnds: return None # Return the window with most title characters (most likely the game window) hwnds.sort(key=lambda x: -len(x[1])) return hwnds[0][0] def grab_d2r(): """Grab D2R client area at 1280x720.""" from mss import mss import win32gui hwnd = find_d2r() if not hwnd: print(" [ERROR] D2R not found. Is it running and visible?") return None client = win32gui.GetClientRect(hwnd) w, h = client[2] - client[0], client[3] - client[1] screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) with mss() as sct: region = { 'top': screen_pos[1], 'left': screen_pos[0], 'width': w, 'height': h } sct_img = sct.grab(region) img = np.array(sct_img)[:, :, :3] if w != 1280 or h != 720: img = cv2.resize(img, (1280, 720), interpolation=cv2.INTER_LINEAR) print(f" [RESIZED] {w}x{h} -> 1280x720") else: print(f" [CAPTURED] {w}x{h}") return img def cmd_capture(args): """Capture D2R window and save.""" save_dir = BASE / "screenshots" / "captures" save_dir.mkdir(parents=True, exist_ok=True) img = grab_d2r() if img is None: return ts = datetime.now().strftime("%Y%m%d_%H%M%S") name = f"capture_{ts}.png" path = save_dir / name cv2.imwrite(str(path), img) print(f"\n [SAVED] {path}") print(f" Crop with: python asset_manager.py crop X Y W H template_name") print(f" Or use: python asset_manager.py auto_crop") print() def cmd_crop(args): """Crop a region from the latest capture and save as template.""" x, y, w, h = args.x, args.y, args.w, args.h name = args.name # Find latest capture or grab fresh save_dir = BASE / "screenshots" / "captures" captures = sorted(save_dir.glob("capture_*.png"), key=os.path.getmtime) if captures: img = cv2.imread(str(captures[-1]), cv2.IMREAD_UNCHANGED) if img is not None: print(f" [LOADED] {captures[-1].name}") else: img = None if img is None: print(" No recent capture. Grabbing fresh...") img = grab_d2r() if img is None: return # Crop h_img, w_img = img.shape[:2] x1, y1 = max(0, x), max(0, y) x2, y2 = min(w_img, x + w), min(h_img, y + h) crop = img[y1:y2, x1:x2] if crop.size == 0: print(f" [ERROR] Crop region ({x},{y},{w},{h}) is out of bounds (image is {w_img}x{h_img})") return # Auto-trim black/transparent borders crop = _trim_borders(crop) # Determine save location save_dir, name_lower = _resolve_save_path(name) # Auto-number if exists fname = f"{name_lower}.png" save_path = save_dir / fname variant = 1 while save_path.exists(): variant += 1 fname = f"{name_lower}_{variant}.png" save_path = save_dir / fname cv2.imwrite(str(save_path), crop) rel = str(save_path.relative_to(ASSETS)) print(f"\n [SAVED] {rel} ({crop.shape[1]}x{crop.shape[0]})") # Show template key for use in code template_key = fname[:-4].upper() print(f" Template key: '{template_key}'") print(f" Use in code: template_finder.search('{template_key}', img, threshold=0.XX)") print() def _trim_borders(img): """Trim black and transparent borders from an image.""" # Handle grayscale images (1 channel) if len(img.shape) == 2: mask = (img > 1).astype(np.uint8) * 255 elif img.shape[2] == 4: # RGBA: non-transparent AND non-black pixels alpha = img[:, :, 3] gray = cv2.cvtColor(img[:, :, :3], cv2.COLOR_BGR2GRAY) mask = ((gray > 1) & (alpha > 0)).astype(np.uint8) * 255 else: # BGR or other: non-black pixels gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) mask = (gray > 1).astype(np.uint8) * 255 coords = cv2.findNonZero(mask) if coords is None: return img x, y, w, h = cv2.boundingRect(coords) # Add 2px padding pad = 2 h_img, w_img = img.shape[:2] x = max(0, x - pad) y = max(0, y - pad) w = min(w_img - x, w + 2 * pad) h = min(h_img - y, h + 2 * pad) return img[y:y+h, x:x+w] def _resolve_save_path(name): """Determine where to save a new asset based on its name.""" name_lower = name.lower().replace('-', '_').replace(' ', '_') if name_lower in NPC_NAMES: save_dir = ASSETS / "npc" / name_lower elif 'template' in name_lower or 'ui' in name_lower: save_dir = ASSETS / "templates" / "ui" elif 'chest' in name_lower: save_dir = ASSETS / "chests" elif 'item' in name_lower: save_dir = ASSETS / "item_properties" elif 'npc' in name_lower or 'action' in name_lower: save_dir = ASSETS / "npc" / "action_btn" elif 'gamble' in name_lower: save_dir = ASSETS / "gamble" elif 'shop' in name_lower: save_dir = ASSETS / "shop" else: save_dir = ASSETS / "templates" save_dir.mkdir(parents=True, exist_ok=True) return save_dir, name_lower def cmd_auto_crop(args): """Interactive crop mode: click D2R to select region.""" try: from input_layer import keyboard except ImportError: sys.path.insert(0, str(BASE / "src")) from input_layer import keyboard print(f"\n{'='*70}") print(f" Botty Auto-Crop (Interactive)") print(f"{'='*70}") print(f" 1. Press F1 to capture D2R") print(f" 2. Position mouse over TOP-LEFT corner, press F2") print(f" 3. Position mouse over BOTTOM-RIGHT corner, press F2") print(f" 4. Preview shows in window - press:") print(f" F3 Accept and save (you'll be prompted for name)") print(f" F4 Retry selection (goes back to step 2)") print(f" F12 Exit") print(f" {'='*70}") print(" Ready. Press F1 to capture D2R.\n") img = None pt1 = None pt2 = None def on_f1(): nonlocal img img = grab_d2r() if img is not None: print(" [CAPTURED] Press F2 for top-left corner.") def on_f2(): nonlocal pt1, pt2 from input_layer import mouse mx, my = mouse.get_position() # Convert to D2R client coordinates hwnd = find_d2r() if hwnd: import win32gui screen_pos = win32gui.ClientToScreen(hwnd, (0, 0)) cx = mx - screen_pos[0] cy = my - screen_pos[1] # Scale if needed if img is not None: h_img, w_img = img.shape[:2] cx = int(cx * w_img / 1280) cy = int(cy * h_img / 720) if pt1 is None: pt1 = (cx, cy) print(f" Top-left: {pt1}. Now move mouse to bottom-right and press F2 again.") else: pt2 = (cx, cy) print(f" Bottom-right: {pt2}. Preview: F3=save, F4=retry") _show_preview() def _show_preview(): if img is None or pt1 is None or pt2 is None: return h_img, w_img = img.shape[:2] x1 = max(0, min(pt1[0], pt2[0])) y1 = max(0, min(pt1[1], pt2[1])) x2 = min(w_img, max(pt1[0], pt2[0])) y2 = min(h_img, max(pt1[1], pt2[1])) preview = img[y1:y2, x1:x2] preview = _trim_borders(preview) # Resize for display if too large disp = preview.copy() if max(disp.shape[:2]) > 500: scale = 500.0 / max(disp.shape[:2]) disp = cv2.resize(disp, (int(disp.shape[1] * scale), int(disp.shape[0] * scale))) cv2.imshow("Auto-Crop Preview", disp) cv2.waitKey(1) print(f" Preview: {preview.shape[1]}x{preview.shape[0]} (after trim)") def on_f3(): nonlocal img, pt1, pt2 if img is None or pt1 is None or pt2 is None: print(" [ERROR] No selection. Press F1 first, then F2 twice.") return h_img, w_img = img.shape[:2] x1 = max(0, min(pt1[0], pt2[0])) y1 = max(0, min(pt1[1], pt2[1])) x2 = min(w_img, max(pt1[0], pt2[0])) y2 = min(h_img, max(pt1[1], pt2[1])) crop = img[y1:y2, x1:x2] crop = _trim_borders(crop) # Ask for name name = input("\n Enter template name: ").strip() if not name: name = "new_asset" name = re.sub(r'[^a-zA-Z0-9_\-]', '_', name) save_dir, name_lower = _resolve_save_path(name) fname = f"{name_lower}.png" save_path = save_dir / fname variant = 1 while save_path.exists(): variant += 1 fname = f"{name_lower}_{variant}.png" save_path = save_dir / fname cv2.imwrite(str(save_path), crop) cv2.destroyWindow("Auto-Crop Preview") rel = str(save_path.relative_to(ASSETS)) template_key = fname[:-4].upper() print(f"\n [SAVED] {rel} ({crop.shape[1]}x{crop.shape[0]})") print(f" Template key: '{template_key}'") print(f" Use in code: template_finder.search('{template_key}', img, threshold=0.XX)") # Reset for next crop pt1 = pt2 = None def on_f4(): nonlocal pt1, pt2 pt1 = pt2 = None cv2.destroyWindow("Auto-Crop Preview") print(" Retry. Press F2 for top-left corner.") keyboard.add_hotkey('f1', on_f1) keyboard.add_hotkey('f2', on_f2) keyboard.add_hotkey('f3', on_f3) keyboard.add_hotkey('f4', on_f4) keyboard.add_hotkey('f12', lambda: (print("\n Bye."), sys.exit(0))) try: keyboard.wait() except KeyboardInterrupt: print("\n Bye.") def cmd_validate(args): """Validate all templates load correctly.""" assets = gather_assets() print(f"\n{'='*70}") print(f" Botty Template Validation") print(f"{'='*70}\n") errors = 0 warnings = 0 for name, info in sorted(assets.items()): _ensure_image_info(info) if info['dims'] is None: print(f" [ERROR] {name} - cannot read image") errors += 1 elif info['dims'][0] == 0 or info['dims'][1] == 0: print(f" [ERROR] {name} - zero dimensions") errors += 1 elif info['size'] == 0: print(f" [ERROR] {name} - empty file") errors += 1 else: # Check template key is usable template_key = Path(name).stem.upper() cleaned = ''.join(c for c in template_key if c not in '0123456789_') if not cleaned.isalpha(): print(f" [WARN] {name} - key '{template_key}' contains unusual chars") warnings += 1 if not errors and not warnings: print(" All templates are valid.") else: print(f"\n {errors} error(s), {warnings} warning(s)") print() def cmd_similarity(args): """Find near-duplicate images using visual similarity.""" assets = gather_assets() if len(assets) < 2: print("Need at least 2 assets to compare.") return print(f"\n{'='*70}") print(f" Botty Similarity Analysis (fast mode)") print(f"{'='*70}\n") print(" Comparing assets within each category...") print() # Group by category for faster comparison cats = defaultdict(list) for name, info in assets.items(): cats[info['category']].append((name, info)) pairs_found = 0 for cat, items in cats.items(): if len(items) < 2: continue # Quick pre-filter: only compare same-size images size_groups = defaultdict(list) for name, info in items: _ensure_image_info(info) if info['dims']: size_groups[(info['dims'][0], info['dims'][1])].append((name, info)) for size, group in size_groups.items(): if len(group) < 2: continue for i in range(len(group)): for j in range(i + 1, len(group)): n1, i1 = group[i] n2, i2 = group[j] # Skip exact duplicates (those are caught by audit) if i1['hash'] == i2['hash']: continue sim = img_similarity(i1['path'], i2['path']) if sim > 0.85: pairs_found += 1 print(f" [{sim:.2f}] {n1} ~= {n2} ({size[0]}x{size[1]})") elif sim > 0.70 and cat == 'npc': pairs_found += 1 print(f" [{sim:.2f}] {n1} ~= {n2} ({size[0]}x{size[1]})") if not pairs_found: print(" No near-duplicates found.") else: print(f"\n {pairs_found} near-duplicate pair(s) found.") print() def cmd_cleanup(args): """Remove duplicate assets (keep first occurrence).""" assets = gather_assets() hash_map = defaultdict(list) for name, info in assets.items(): if info['hash']: hash_map[info['hash']].append((name, info)) print(f"\n{'='*70}") print(f" Botty Asset Cleanup") print(f"{'='*70}\n") removed = 0 for h, items in hash_map.items(): if len(items) > 1: print(f" Duplicate group ({len(items)} files):") for i, (name, info) in enumerate(items): if i == 0: print(f" [KEEP] {name}") else: if args.yes: os.remove(str(info['path'])) print(f" [REMOVED] {name}") removed += 1 else: print(f" [WILL REMOVE] {name}") print() if args.yes: print(f" Removed {removed} duplicates.") else: print(f" Would remove {removed} duplicates. Use --yes to actually remove.") print() def cmd_batch(args): """Batch operations on assets.""" operation = args.operation.lower() if operation == "resize": try: target_w, target_h = map(int, args.value.split('x')) except: print(" Usage: python asset_manager.py batch resize WxH") return assets = gather_assets() count = 0 for name, info in assets.items(): _ensure_image_info(info) if info['dims'] and (info['dims'][0] != target_w or info['dims'][1] != target_h): img = cv2.imread(str(info['path']), cv2.IMREAD_UNCHANGED) if img is not None: # Use INTER_AREA for downscaling (better quality), INTER_CUBIC for upscaling if target_w < info['dims'][0]: interp = cv2.INTER_AREA else: interp = cv2.INTER_CUBIC resized = cv2.resize(img, (target_w, target_h), interpolation=interp) cv2.imwrite(str(info['path']), resized) count += 1 print(f" Resized {count} assets to {target_w}x{target_h}.") elif operation == "convert": fmt = args.value.lower() if fmt not in ('png', 'jpg', 'jpeg'): print(" Supported formats: png, jpg") return assets = gather_assets() count = 0 for name, info in assets.items(): if info['path'].suffix.lower() != f'.{fmt}': new_path = info['path'].with_suffix(f'.{fmt}') img = cv2.imread(str(info['path']), cv2.IMREAD_UNCHANGED) if img is not None: cv2.imwrite(str(new_path), img) count += 1 print(f" Converted {count} assets to .{fmt}") else: print(f" Unknown batch operation: {operation}") print(f" Supported: resize, convert") def print_help(): print(f""" {'='*70} Botty Asset Manager {'='*70} Usage: python asset_manager.py [command] [options] Commands: inventory List all assets with size, dimensions, category search TERM Find assets matching a name/pattern key NAME Look up the template key to use in code audit Find issues: duplicates, naming, oversized, tiny quality Analyze image quality: resolution, transparency, size similarity Find near-duplicate images capture Capture D2R window to screenshots/captures/ crop X Y W H NAME Crop region from latest capture, save as template auto_crop Interactive: click D2R to select a crop region validate Check all templates load correctly cleanup [--yes] Find/remove duplicate assets batch OP VALUE Batch operation: "resize WxH" or "convert png" help Show this help Examples: python asset_manager.py inventory python asset_manager.py audit python asset_manager.py quality python asset_manager.py search akara python asset_manager.py key akara_front python asset_manager.py capture python asset_manager.py crop 100 200 50 80 akara_front python asset_manager.py crop 300 400 100 120 npc_dialogue python asset_manager.py auto_crop python asset_manager.py similarity python asset_manager.py validate python asset_manager.py cleanup python asset_manager.py cleanup --yes python asset_manager.py batch resize 64x64 Template naming convention: - Use lowercase_with_underscores (e.g. akara_front.png) - Template key is the filename uppercased (e.g. AKARA_FRONT) - NPC assets go in assets/npc// - UI templates go in assets/templates/ui/ - Item templates go in assets/item_properties/ Template Finder search paths: """) for d in TEMPLATE_DIRS: print(f" assets/{d}/") print() def main(): parser = argparse.ArgumentParser(description='Botty Asset Manager', add_help=False) parser.add_argument('command', nargs='?', default='help', help='Command to run') parser.add_argument('args', nargs='*', help='Command arguments') parser.add_argument('--yes', action='store_true', help='Confirm destructive actions') parsed = parser.parse_args() cmd = parsed.command.lower() if cmd == 'inventory': cmd_inventory(parsed) elif cmd == 'search': cmd_search(parsed) elif cmd == 'key': cmd_key(parsed) elif cmd == 'audit': cmd_audit(parsed) elif cmd == 'quality': cmd_quality(parsed) elif cmd == 'capture': cmd_capture(parsed) elif cmd == 'crop': if len(parsed.args) < 5: print(" Usage: python asset_manager.py crop X Y W H NAME") print(" Example: python asset_manager.py crop 100 200 50 80 akara_front") return parsed.x = int(parsed.args[0]) parsed.y = int(parsed.args[1]) parsed.w = int(parsed.args[2]) parsed.h = int(parsed.args[3]) parsed.name = parsed.args[4] cmd_crop(parsed) elif cmd == 'auto_crop': cmd_auto_crop(parsed) elif cmd == 'validate': cmd_validate(parsed) elif cmd == 'similarity': cmd_similarity(parsed) elif cmd == 'cleanup': cmd_cleanup(parsed) elif cmd == 'batch': if len(parsed.args) < 2: print(" Usage: python asset_manager.py batch OP VALUE") print(" Example: python asset_manager.py batch resize 64x64") return parsed.operation = parsed.args[0] parsed.value = parsed.args[1] cmd_batch(parsed) else: print_help() if __name__ == "__main__": main()