Spaces:
Runtime error
Runtime error
Upload 5 files
#2
by elmaevo7 - opened
image-classifier/build_code_embeddings.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
build_code_embeddings.py β Embed USPTO Design Codes for RAG retrieval
|
| 3 |
+
=======================================================================
|
| 4 |
+
One-time script that reads uspto_design_codes.json (from the scraper) and
|
| 5 |
+
embeds every code's description using Voyage AI. The output feeds the
|
| 6 |
+
RAG layer of design_code_classifier.py.
|
| 7 |
+
|
| 8 |
+
WHY THIS EXISTS:
|
| 9 |
+
The classifier needs to constrain Claude to USPTO's actual code vocabulary
|
| 10 |
+
(no hallucinating fake codes). Embedding every code's description once
|
| 11 |
+
lets us do fast vector search at classification time: Claude describes
|
| 12 |
+
the image, we retrieve the most semantically similar codes, Claude picks
|
| 13 |
+
from that validated menu.
|
| 14 |
+
|
| 15 |
+
SETUP:
|
| 16 |
+
pip install voyageai numpy python-dotenv
|
| 17 |
+
|
| 18 |
+
ENV VARS:
|
| 19 |
+
VOYAGE_API_KEY β Voyage AI API key (free tier covers 200M tokens)
|
| 20 |
+
|
| 21 |
+
USAGE:
|
| 22 |
+
python build_code_embeddings.py
|
| 23 |
+
|
| 24 |
+
# Re-embed with a different model:
|
| 25 |
+
python build_code_embeddings.py --model voyage-3-large
|
| 26 |
+
|
| 27 |
+
OUTPUT:
|
| 28 |
+
uspto_code_embeddings.pkl β pickle dict with:
|
| 29 |
+
- codes: list[str] ordered list of XX.YY.ZZ codes
|
| 30 |
+
- descriptions: list[str] parallel list of code descriptions
|
| 31 |
+
- categories: list[str] parent category for each code (XX)
|
| 32 |
+
- embeddings: np.ndarray shape (N, dim), float32
|
| 33 |
+
- metadata: dict model name, dim, timestamp
|
| 34 |
+
|
| 35 |
+
COST:
|
| 36 |
+
~1,300 codes Γ ~15 tokens each = ~20K tokens.
|
| 37 |
+
voyage-3.5 is ~$0.06 per 1M tokens β effectively free under the
|
| 38 |
+
200M-token free tier.
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
import os
|
| 42 |
+
import sys
|
| 43 |
+
import json
|
| 44 |
+
import pickle
|
| 45 |
+
import logging
|
| 46 |
+
from datetime import datetime, timezone
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
from typing import List
|
| 49 |
+
|
| 50 |
+
import numpy as np
|
| 51 |
+
from dotenv import load_dotenv
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
import voyageai
|
| 55 |
+
except ImportError:
|
| 56 |
+
print("ERROR: Voyage AI SDK not installed. Run:")
|
| 57 |
+
print(" pip install voyageai")
|
| 58 |
+
sys.exit(1)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
# ============================================================================
|
| 62 |
+
# CONFIG
|
| 63 |
+
# ============================================================================
|
| 64 |
+
|
| 65 |
+
env_path = Path(__file__).parent / ".env"
|
| 66 |
+
load_dotenv(dotenv_path=env_path)
|
| 67 |
+
|
| 68 |
+
VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY")
|
| 69 |
+
|
| 70 |
+
DEFAULT_MODEL = "voyage-3.5" # current general-purpose default; voyage-3-large is the premium upgrade
|
| 71 |
+
DEFAULT_DIM = 1024 # default for voyage-3.5; do not change without re-embedding
|
| 72 |
+
|
| 73 |
+
# Voyage allows up to 1,000 texts per batch; we have ~1,300 codes, so 2 batches
|
| 74 |
+
BATCH_SIZE = 1000
|
| 75 |
+
|
| 76 |
+
INPUT_PATH = Path(__file__).parent / "uspto_design_codes.json"
|
| 77 |
+
OUTPUT_PATH = Path(__file__).parent / "uspto_code_embeddings.pkl"
|
| 78 |
+
|
| 79 |
+
logging.basicConfig(
|
| 80 |
+
level=logging.INFO,
|
| 81 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 82 |
+
)
|
| 83 |
+
logger = logging.getLogger("embeddings")
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
# ============================================================================
|
| 87 |
+
# LOAD CODES FROM SCRAPED MANUAL
|
| 88 |
+
# ============================================================================
|
| 89 |
+
|
| 90 |
+
def load_codes() -> tuple[List[str], List[str], List[str]]:
|
| 91 |
+
"""Read uspto_design_codes.json and flatten into parallel lists.
|
| 92 |
+
|
| 93 |
+
Returns:
|
| 94 |
+
(codes, descriptions, categories) β three lists of equal length,
|
| 95 |
+
where codes[i] corresponds to descriptions[i] from category categories[i].
|
| 96 |
+
"""
|
| 97 |
+
if not INPUT_PATH.exists():
|
| 98 |
+
logger.error(f"β {INPUT_PATH} not found. Run scrape_uspto_design_codes.py first.")
|
| 99 |
+
sys.exit(1)
|
| 100 |
+
|
| 101 |
+
data = json.loads(INPUT_PATH.read_text())
|
| 102 |
+
codes_dict = data.get("categories", {})
|
| 103 |
+
|
| 104 |
+
codes: List[str] = []
|
| 105 |
+
descriptions: List[str] = []
|
| 106 |
+
categories: List[str] = []
|
| 107 |
+
seen = set() # deduplicate (the scraper produced some cross-listed codes)
|
| 108 |
+
|
| 109 |
+
for category_id, category_data in codes_dict.items():
|
| 110 |
+
for division_id, division_data in category_data.get("divisions", {}).items():
|
| 111 |
+
for section_code, section_data in division_data.get("sections", {}).items():
|
| 112 |
+
if section_code in seen:
|
| 113 |
+
continue
|
| 114 |
+
description = section_data.get("description", "").strip()
|
| 115 |
+
if not description:
|
| 116 |
+
continue
|
| 117 |
+
codes.append(section_code)
|
| 118 |
+
descriptions.append(description)
|
| 119 |
+
# Use the section's *real* category prefix (XX), not the JSON parent
|
| 120 |
+
# β this self-corrects the scraper's cross-listing duplicates
|
| 121 |
+
categories.append(section_code.split(".")[0])
|
| 122 |
+
seen.add(section_code)
|
| 123 |
+
|
| 124 |
+
logger.info(f"π Loaded {len(codes)} unique codes from {INPUT_PATH.name}")
|
| 125 |
+
return codes, descriptions, categories
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# ============================================================================
|
| 129 |
+
# EMBED VIA VOYAGE
|
| 130 |
+
# ============================================================================
|
| 131 |
+
|
| 132 |
+
def build_searchable_text(code: str, description: str) -> str:
|
| 133 |
+
"""Construct the text that gets embedded for each code.
|
| 134 |
+
|
| 135 |
+
We include the code itself in the text β the digits give the embedding
|
| 136 |
+
a tiny extra signal of which category/division things belong to, which
|
| 137 |
+
helps when descriptions are very generic ("Other plants" appears in
|
| 138 |
+
multiple divisions and would otherwise be indistinguishable).
|
| 139 |
+
"""
|
| 140 |
+
return f"USPTO Design Code {code}: {description}"
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def embed_descriptions(
|
| 144 |
+
descriptions_with_codes: List[str],
|
| 145 |
+
model: str,
|
| 146 |
+
) -> np.ndarray:
|
| 147 |
+
"""Call Voyage to embed all descriptions. Returns (N, dim) float32 array."""
|
| 148 |
+
if not VOYAGE_API_KEY:
|
| 149 |
+
logger.error("β VOYAGE_API_KEY not set in .env")
|
| 150 |
+
sys.exit(1)
|
| 151 |
+
|
| 152 |
+
client = voyageai.Client(api_key=VOYAGE_API_KEY)
|
| 153 |
+
|
| 154 |
+
all_embeddings: List[List[float]] = []
|
| 155 |
+
|
| 156 |
+
for batch_idx in range(0, len(descriptions_with_codes), BATCH_SIZE):
|
| 157 |
+
batch = descriptions_with_codes[batch_idx : batch_idx + BATCH_SIZE]
|
| 158 |
+
logger.info(
|
| 159 |
+
f"π Embedding batch {batch_idx // BATCH_SIZE + 1} "
|
| 160 |
+
f"({len(batch)} texts, total tokens ~{sum(len(t.split()) for t in batch)})"
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
try:
|
| 164 |
+
result = client.embed(
|
| 165 |
+
texts=batch,
|
| 166 |
+
model=model,
|
| 167 |
+
input_type="document", # corpus side of retrieval
|
| 168 |
+
)
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"β Voyage API error: {e}")
|
| 171 |
+
sys.exit(1)
|
| 172 |
+
|
| 173 |
+
all_embeddings.extend(result.embeddings)
|
| 174 |
+
logger.info(f" β
Batch returned {len(result.embeddings)} embeddings")
|
| 175 |
+
|
| 176 |
+
arr = np.array(all_embeddings, dtype=np.float32)
|
| 177 |
+
logger.info(f"π Final embeddings shape: {arr.shape}")
|
| 178 |
+
return arr
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
# ============================================================================
|
| 182 |
+
# SAVE
|
| 183 |
+
# ============================================================================
|
| 184 |
+
|
| 185 |
+
def save_embeddings(
|
| 186 |
+
codes: List[str],
|
| 187 |
+
descriptions: List[str],
|
| 188 |
+
categories: List[str],
|
| 189 |
+
embeddings: np.ndarray,
|
| 190 |
+
model: str,
|
| 191 |
+
):
|
| 192 |
+
"""Persist everything to a single pickle for easy loading by the classifier."""
|
| 193 |
+
payload = {
|
| 194 |
+
"codes": codes,
|
| 195 |
+
"descriptions": descriptions,
|
| 196 |
+
"categories": categories,
|
| 197 |
+
"embeddings": embeddings,
|
| 198 |
+
"metadata": {
|
| 199 |
+
"model": model,
|
| 200 |
+
"dimension": embeddings.shape[1],
|
| 201 |
+
"code_count": len(codes),
|
| 202 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 203 |
+
"source_file": INPUT_PATH.name,
|
| 204 |
+
},
|
| 205 |
+
}
|
| 206 |
+
with OUTPUT_PATH.open("wb") as f:
|
| 207 |
+
pickle.dump(payload, f, protocol=pickle.HIGHEST_PROTOCOL)
|
| 208 |
+
logger.info(f"πΎ Saved to {OUTPUT_PATH} ({OUTPUT_PATH.stat().st_size / 1024:.1f} KB)")
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ============================================================================
|
| 212 |
+
# CLI
|
| 213 |
+
# ============================================================================
|
| 214 |
+
|
| 215 |
+
def main():
|
| 216 |
+
import argparse
|
| 217 |
+
parser = argparse.ArgumentParser(description="Embed USPTO design codes via Voyage AI")
|
| 218 |
+
parser.add_argument(
|
| 219 |
+
"--model", default=DEFAULT_MODEL,
|
| 220 |
+
help=f"Voyage model to use (default: {DEFAULT_MODEL}). "
|
| 221 |
+
"Upgrade to voyage-3-large for marginal quality gains."
|
| 222 |
+
)
|
| 223 |
+
args = parser.parse_args()
|
| 224 |
+
|
| 225 |
+
codes, descriptions, categories = load_codes()
|
| 226 |
+
texts_to_embed = [
|
| 227 |
+
build_searchable_text(c, d) for c, d in zip(codes, descriptions)
|
| 228 |
+
]
|
| 229 |
+
embeddings = embed_descriptions(texts_to_embed, model=args.model)
|
| 230 |
+
|
| 231 |
+
save_embeddings(codes, descriptions, categories, embeddings, args.model)
|
| 232 |
+
|
| 233 |
+
logger.info("\n" + "=" * 60)
|
| 234 |
+
logger.info("π EMBEDDING BUILD COMPLETE")
|
| 235 |
+
logger.info("=" * 60)
|
| 236 |
+
logger.info(f" Model: {args.model}")
|
| 237 |
+
logger.info(f" Codes: {len(codes):,}")
|
| 238 |
+
logger.info(f" Dimensions: {embeddings.shape[1]}")
|
| 239 |
+
logger.info(f" Output: {OUTPUT_PATH}")
|
| 240 |
+
logger.info("=" * 60)
|
| 241 |
+
logger.info("\nπ‘ Next step: design_code_classifier.py will load this file at startup")
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
if __name__ == "__main__":
|
| 245 |
+
main()
|
image-classifier/design_code_classifier.py
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
design_code_classifier.py β USPTO Design Search Code classifier (RAG version)
|
| 3 |
+
==============================================================================
|
| 4 |
+
Uses a two-stage RAG flow to classify uploaded trademark images into USPTO
|
| 5 |
+
Design Search Codes β with the guarantee that returned codes are real USPTO
|
| 6 |
+
codes pulled from the official manual, not invented by the model.
|
| 7 |
+
|
| 8 |
+
ARCHITECTURE
|
| 9 |
+
------------
|
| 10 |
+
Stage 1 (visual description):
|
| 11 |
+
Claude vision model looks at the image and produces a structured natural-
|
| 12 |
+
language description of what's in it.
|
| 13 |
+
|
| 14 |
+
Vector retrieval:
|
| 15 |
+
The description is embedded via Voyage and matched against pre-embedded
|
| 16 |
+
USPTO code descriptions. Top-K candidates are pulled.
|
| 17 |
+
|
| 18 |
+
Stage 2 (constrained selection):
|
| 19 |
+
Claude sees the image AGAIN alongside the candidate codes (with their
|
| 20 |
+
official descriptions) and selects which apply, with confidence scores
|
| 21 |
+
and rationales.
|
| 22 |
+
|
| 23 |
+
This means Claude is *picking from a verified menu*, not free-styling. No
|
| 24 |
+
hallucinated codes.
|
| 25 |
+
|
| 26 |
+
DESIGN PHILOSOPHY
|
| 27 |
+
-----------------
|
| 28 |
+
This module is fully STANDALONE β does not import from or modify
|
| 29 |
+
nst_comparison.py. The output is structured JSON; how it gets plugged into
|
| 30 |
+
the matching algorithm is a deliberate decision left to the caller.
|
| 31 |
+
|
| 32 |
+
REQUIREMENTS
|
| 33 |
+
------------
|
| 34 |
+
pip install anthropic voyageai pillow numpy python-dotenv
|
| 35 |
+
|
| 36 |
+
ENV VARS
|
| 37 |
+
--------
|
| 38 |
+
ANTHROPIC_API_KEY β Anthropic API key
|
| 39 |
+
VOYAGE_API_KEY β Voyage AI API key (for query embedding only;
|
| 40 |
+
codes were pre-embedded by build_code_embeddings.py)
|
| 41 |
+
CLASSIFIER_MODEL β Optional: override Claude model (default: claude-sonnet-4-6)
|
| 42 |
+
|
| 43 |
+
PREREQUISITES
|
| 44 |
+
-------------
|
| 45 |
+
You must run scrape_uspto_design_codes.py and build_code_embeddings.py first,
|
| 46 |
+
producing uspto_code_embeddings.pkl in the same directory as this module.
|
| 47 |
+
|
| 48 |
+
QUICK CLI TEST
|
| 49 |
+
--------------
|
| 50 |
+
python design_code_classifier.py path/to/logo.jpg
|
| 51 |
+
|
| 52 |
+
INTEGRATION (the "subtle change" path for nst_comparison.py)
|
| 53 |
+
------------------------------------------------------------
|
| 54 |
+
# ββ At top of nst_comparison.py ββ
|
| 55 |
+
from .design_code_classifier import classify_image
|
| 56 |
+
|
| 57 |
+
# ββ Inside execute_model(), after np_img is created ββ
|
| 58 |
+
classification = await classify_image(np_img)
|
| 59 |
+
high_conf_codes = classification.high_confidence_codes(threshold=0.7)
|
| 60 |
+
|
| 61 |
+
# ββ Add design_search_codes to the SELECT, then filter results: ββ
|
| 62 |
+
if high_conf_codes:
|
| 63 |
+
response.data = [
|
| 64 |
+
r for r in response.data
|
| 65 |
+
if r.get("design_search_codes") and any(
|
| 66 |
+
c in r["design_search_codes"] for c in high_conf_codes
|
| 67 |
+
)
|
| 68 |
+
]
|
| 69 |
+
"""
|
| 70 |
+
|
| 71 |
+
import os
|
| 72 |
+
import io
|
| 73 |
+
import json
|
| 74 |
+
import base64
|
| 75 |
+
import pickle
|
| 76 |
+
import logging
|
| 77 |
+
import asyncio
|
| 78 |
+
from pathlib import Path
|
| 79 |
+
from typing import Optional, Any, List
|
| 80 |
+
from dataclasses import dataclass, asdict, field
|
| 81 |
+
|
| 82 |
+
import numpy as np
|
| 83 |
+
from anthropic import AsyncAnthropic
|
| 84 |
+
from PIL import Image
|
| 85 |
+
from dotenv import load_dotenv
|
| 86 |
+
|
| 87 |
+
try:
|
| 88 |
+
import voyageai
|
| 89 |
+
except ImportError:
|
| 90 |
+
raise ImportError("Voyage AI SDK required. Run: pip install voyageai")
|
| 91 |
+
|
| 92 |
+
# Optional FastAPI types β only loaded if FastAPI is present
|
| 93 |
+
try:
|
| 94 |
+
from fastapi import UploadFile, HTTPException
|
| 95 |
+
HAS_FASTAPI = True
|
| 96 |
+
except ImportError:
|
| 97 |
+
HAS_FASTAPI = False
|
| 98 |
+
UploadFile = None # type: ignore
|
| 99 |
+
HTTPException = None # type: ignore
|
| 100 |
+
|
| 101 |
+
# ============================================================================
|
| 102 |
+
# CONFIG
|
| 103 |
+
# ============================================================================
|
| 104 |
+
|
| 105 |
+
env_path = Path(__file__).parent / ".env"
|
| 106 |
+
load_dotenv(dotenv_path=env_path)
|
| 107 |
+
|
| 108 |
+
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
|
| 109 |
+
VOYAGE_API_KEY = os.getenv("VOYAGE_API_KEY")
|
| 110 |
+
|
| 111 |
+
DEFAULT_MODEL = os.getenv("CLASSIFIER_MODEL", "claude-sonnet-4-6")
|
| 112 |
+
MAX_TOKENS_STAGE1 = 1024
|
| 113 |
+
MAX_TOKENS_STAGE2 = 2048
|
| 114 |
+
|
| 115 |
+
TOP_K_CANDIDATES = 30
|
| 116 |
+
MIN_CONFIDENCE = 0.5
|
| 117 |
+
|
| 118 |
+
MAX_IMAGE_DIMENSION = 1568
|
| 119 |
+
|
| 120 |
+
EMBEDDINGS_PATH = Path(__file__).parent / "uspto_code_embeddings.pkl"
|
| 121 |
+
|
| 122 |
+
logger = logging.getLogger("design_classifier")
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ============================================================================
|
| 126 |
+
# CODE INDEX (loads embeddings once, supports vector search)
|
| 127 |
+
# ============================================================================
|
| 128 |
+
|
| 129 |
+
class CodeIndex:
|
| 130 |
+
"""In-memory vector index over USPTO design codes.
|
| 131 |
+
|
| 132 |
+
Loaded once on first use. All requests share the same index β no per-
|
| 133 |
+
request file I/O.
|
| 134 |
+
"""
|
| 135 |
+
|
| 136 |
+
_instance: Optional["CodeIndex"] = None
|
| 137 |
+
|
| 138 |
+
def __init__(self):
|
| 139 |
+
if not EMBEDDINGS_PATH.exists():
|
| 140 |
+
raise FileNotFoundError(
|
| 141 |
+
f"{EMBEDDINGS_PATH.name} not found. "
|
| 142 |
+
"Run scrape_uspto_design_codes.py + build_code_embeddings.py first."
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
with EMBEDDINGS_PATH.open("rb") as f:
|
| 146 |
+
payload = pickle.load(f)
|
| 147 |
+
|
| 148 |
+
self.codes: List[str] = payload["codes"]
|
| 149 |
+
self.descriptions: List[str] = payload["descriptions"]
|
| 150 |
+
self.categories: List[str] = payload["categories"]
|
| 151 |
+
self.embeddings: np.ndarray = payload["embeddings"]
|
| 152 |
+
self.metadata: dict = payload["metadata"]
|
| 153 |
+
|
| 154 |
+
# Pre-normalize for fast cosine similarity (just dot product after this)
|
| 155 |
+
norms = np.linalg.norm(self.embeddings, axis=1, keepdims=True)
|
| 156 |
+
norms[norms == 0] = 1.0
|
| 157 |
+
self.embeddings_normalized = self.embeddings / norms
|
| 158 |
+
|
| 159 |
+
self.code_to_description = dict(zip(self.codes, self.descriptions))
|
| 160 |
+
|
| 161 |
+
logger.info(
|
| 162 |
+
f"π Loaded code index: {len(self.codes)} codes, "
|
| 163 |
+
f"{self.metadata['dimension']}-dim {self.metadata['model']} embeddings"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
@classmethod
|
| 167 |
+
def get(cls) -> "CodeIndex":
|
| 168 |
+
if cls._instance is None:
|
| 169 |
+
cls._instance = CodeIndex()
|
| 170 |
+
return cls._instance
|
| 171 |
+
|
| 172 |
+
def search(self, query_embedding: np.ndarray, top_k: int = TOP_K_CANDIDATES) -> List[dict]:
|
| 173 |
+
"""Find top-K most similar codes to the query embedding.
|
| 174 |
+
|
| 175 |
+
Returns list of {code, description, category, similarity} dicts,
|
| 176 |
+
sorted descending by similarity.
|
| 177 |
+
"""
|
| 178 |
+
q = query_embedding / (np.linalg.norm(query_embedding) + 1e-12)
|
| 179 |
+
sims = self.embeddings_normalized @ q
|
| 180 |
+
top_idx = np.argpartition(sims, -top_k)[-top_k:]
|
| 181 |
+
top_idx = top_idx[np.argsort(-sims[top_idx])]
|
| 182 |
+
|
| 183 |
+
return [
|
| 184 |
+
{
|
| 185 |
+
"code": self.codes[i],
|
| 186 |
+
"description": self.descriptions[i],
|
| 187 |
+
"category": self.categories[i],
|
| 188 |
+
"similarity": float(sims[i]),
|
| 189 |
+
}
|
| 190 |
+
for i in top_idx
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ============================================================================
|
| 195 |
+
# IMAGE NORMALIZATION
|
| 196 |
+
# ============================================================================
|
| 197 |
+
|
| 198 |
+
def _normalize_image(image_input: Any) -> tuple[str, str]:
|
| 199 |
+
"""Convert various image input types to (base64_data, media_type).
|
| 200 |
+
|
| 201 |
+
Accepts: bytes, PIL.Image, numpy.ndarray, file path.
|
| 202 |
+
"""
|
| 203 |
+
if hasattr(image_input, "shape") and hasattr(image_input, "dtype"):
|
| 204 |
+
arr = image_input
|
| 205 |
+
if arr.dtype != np.uint8:
|
| 206 |
+
if arr.max() <= 1.0:
|
| 207 |
+
arr = (arr * 255).astype(np.uint8)
|
| 208 |
+
else:
|
| 209 |
+
arr = arr.astype(np.uint8)
|
| 210 |
+
img = Image.fromarray(arr)
|
| 211 |
+
elif isinstance(image_input, bytes):
|
| 212 |
+
img = Image.open(io.BytesIO(image_input))
|
| 213 |
+
elif isinstance(image_input, Image.Image):
|
| 214 |
+
img = image_input
|
| 215 |
+
elif isinstance(image_input, (str, Path)):
|
| 216 |
+
img = Image.open(image_input)
|
| 217 |
+
else:
|
| 218 |
+
raise TypeError(
|
| 219 |
+
f"Unsupported image input type: {type(image_input)}. "
|
| 220 |
+
"Use bytes, PIL.Image, numpy.ndarray, or a file path."
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
if img.mode != "RGB":
|
| 224 |
+
img = img.convert("RGB")
|
| 225 |
+
|
| 226 |
+
if max(img.size) > MAX_IMAGE_DIMENSION:
|
| 227 |
+
img.thumbnail((MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION), Image.Resampling.LANCZOS)
|
| 228 |
+
logger.debug(f"Resized image to {img.size}")
|
| 229 |
+
|
| 230 |
+
buf = io.BytesIO()
|
| 231 |
+
img.save(buf, format="JPEG", quality=90)
|
| 232 |
+
base64_data = base64.standard_b64encode(buf.getvalue()).decode("utf-8")
|
| 233 |
+
return base64_data, "image/jpeg"
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
# ============================================================================
|
| 237 |
+
# RESULT TYPES
|
| 238 |
+
# ============================================================================
|
| 239 |
+
|
| 240 |
+
@dataclass
|
| 241 |
+
class DesignCode:
|
| 242 |
+
code: str
|
| 243 |
+
description: str
|
| 244 |
+
confidence: float
|
| 245 |
+
rationale: str
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
@dataclass
|
| 249 |
+
class ClassificationResult:
|
| 250 |
+
codes: List[DesignCode] = field(default_factory=list)
|
| 251 |
+
image_description: str = ""
|
| 252 |
+
primary_category: Optional[str] = None
|
| 253 |
+
candidate_codes_considered: List[str] = field(default_factory=list)
|
| 254 |
+
|
| 255 |
+
def high_confidence_codes(self, threshold: float = 0.7) -> List[str]:
|
| 256 |
+
"""Return just the code strings above the confidence threshold."""
|
| 257 |
+
return [c.code for c in self.codes if c.confidence >= threshold]
|
| 258 |
+
|
| 259 |
+
def to_dict(self) -> dict:
|
| 260 |
+
return {
|
| 261 |
+
"codes": [asdict(c) for c in self.codes],
|
| 262 |
+
"image_description": self.image_description,
|
| 263 |
+
"primary_category": self.primary_category,
|
| 264 |
+
"candidate_codes_considered": self.candidate_codes_considered,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
|
| 268 |
+
# ============================================================================
|
| 269 |
+
# STAGE 1 β IMAGE β NATURAL LANGUAGE DESCRIPTION
|
| 270 |
+
# ============================================================================
|
| 271 |
+
|
| 272 |
+
STAGE1_SYSTEM_PROMPT = """You are an expert trademark image analyst. Your job is to describe trademark logos in a way that will help retrieve relevant USPTO Design Search Codes.
|
| 273 |
+
|
| 274 |
+
When you describe an image, focus on the visual elements a USPTO examiner would code:
|
| 275 |
+
- Living things: humans, animals, plants (be specific β "lion's head" not just "animal")
|
| 276 |
+
- Geometric shapes: circles, squares, triangles, lines (and their arrangement)
|
| 277 |
+
- Objects: tools, vehicles, buildings, food, clothing (be specific)
|
| 278 |
+
- Symbols: arrows, crosses, stars, hearts, mathematical symbols
|
| 279 |
+
- Text/letters: note their presence and style (stylized, in a frame, etc.)
|
| 280 |
+
- Colors: only if the color is a distinctive design element
|
| 281 |
+
- Composition: what's framing what, what's stylized vs. realistic
|
| 282 |
+
|
| 283 |
+
Output a single paragraph (3-6 sentences). Be precise and use concrete vocabulary that a search index would match. Don't editorialize about meaning or branding β just describe what's literally visible.
|
| 284 |
+
|
| 285 |
+
Output the description as plain text. No JSON, no prose preamble."""
|
| 286 |
+
|
| 287 |
+
|
| 288 |
+
async def stage1_describe_image(
|
| 289 |
+
base64_data: str,
|
| 290 |
+
media_type: str,
|
| 291 |
+
client: AsyncAnthropic,
|
| 292 |
+
model: str,
|
| 293 |
+
) -> str:
|
| 294 |
+
"""Have Claude describe the image in retrieval-optimized natural language."""
|
| 295 |
+
response = await client.messages.create(
|
| 296 |
+
model=model,
|
| 297 |
+
max_tokens=MAX_TOKENS_STAGE1,
|
| 298 |
+
system=STAGE1_SYSTEM_PROMPT,
|
| 299 |
+
messages=[{
|
| 300 |
+
"role": "user",
|
| 301 |
+
"content": [
|
| 302 |
+
{
|
| 303 |
+
"type": "image",
|
| 304 |
+
"source": {
|
| 305 |
+
"type": "base64",
|
| 306 |
+
"media_type": media_type,
|
| 307 |
+
"data": base64_data,
|
| 308 |
+
},
|
| 309 |
+
},
|
| 310 |
+
{"type": "text", "text": "Describe this trademark image."},
|
| 311 |
+
],
|
| 312 |
+
}],
|
| 313 |
+
)
|
| 314 |
+
description = response.content[0].text.strip()
|
| 315 |
+
logger.info(f"Stage 1 description ({len(description)} chars): {description[:120]}...")
|
| 316 |
+
return description
|
| 317 |
+
|
| 318 |
+
|
| 319 |
+
# ============================================================================
|
| 320 |
+
# RETRIEVAL β VOYAGE EMBED + VECTOR SEARCH
|
| 321 |
+
# ============================================================================
|
| 322 |
+
|
| 323 |
+
def embed_query(text: str, voyage_client) -> np.ndarray:
|
| 324 |
+
"""Embed a single query string with Voyage. Returns a 1D numpy array.
|
| 325 |
+
|
| 326 |
+
NOTE: model name MUST match what was used in build_code_embeddings.py.
|
| 327 |
+
Reading the metadata from the index ensures consistency.
|
| 328 |
+
"""
|
| 329 |
+
index = CodeIndex.get()
|
| 330 |
+
embedding_model = index.metadata["model"]
|
| 331 |
+
|
| 332 |
+
result = voyage_client.embed(
|
| 333 |
+
texts=[text],
|
| 334 |
+
model=embedding_model,
|
| 335 |
+
input_type="query",
|
| 336 |
+
)
|
| 337 |
+
return np.array(result.embeddings[0], dtype=np.float32)
|
| 338 |
+
|
| 339 |
+
|
| 340 |
+
# ============================================================================
|
| 341 |
+
# STAGE 2 β IMAGE + CANDIDATES β STRUCTURED CODE SELECTION
|
| 342 |
+
# ============================================================================
|
| 343 |
+
|
| 344 |
+
def _stage2_system_prompt(candidates: List[dict]) -> str:
|
| 345 |
+
"""Build the system prompt with the candidate codes inline."""
|
| 346 |
+
candidate_lines = "\n".join(
|
| 347 |
+
f" {c['code']} β {c['description']}"
|
| 348 |
+
for c in candidates
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
return f"""You are an expert USPTO Trademark Design Search Code classifier.
|
| 352 |
+
|
| 353 |
+
Below is a list of candidate USPTO Design Search Codes that may apply to the image. Your job is to look at the image and select ONLY the codes that genuinely apply β based on what is actually visible.
|
| 354 |
+
|
| 355 |
+
CANDIDATE CODES:
|
| 356 |
+
{candidate_lines}
|
| 357 |
+
|
| 358 |
+
CRITICAL RULES:
|
| 359 |
+
- ONLY return codes from the candidate list above. Do not invent codes that are not in the list.
|
| 360 |
+
- Be precise: include a code only if the corresponding visual element is clearly present.
|
| 361 |
+
- Most logos use 2β6 codes total. Don't pad β quality over quantity.
|
| 362 |
+
- If the logo contains text or letters, ALWAYS look at category 27 codes in the candidates.
|
| 363 |
+
- If the logo has a geometric frame around the elements, ALWAYS look at category 26 codes in the candidates.
|
| 364 |
+
|
| 365 |
+
Return ONLY valid JSON in this exact structure:
|
| 366 |
+
{{
|
| 367 |
+
"codes": [
|
| 368 |
+
{{
|
| 369 |
+
"code": "XX.YY.ZZ",
|
| 370 |
+
"confidence": 0.95,
|
| 371 |
+
"rationale": "Brief explanation of what in the image triggered this code"
|
| 372 |
+
}}
|
| 373 |
+
],
|
| 374 |
+
"primary_category": "XX"
|
| 375 |
+
}}
|
| 376 |
+
|
| 377 |
+
CONFIDENCE SCORING:
|
| 378 |
+
- 0.90β1.00: Element unmistakably present
|
| 379 |
+
- 0.70β0.89: Element clearly present, minor interpretation involved
|
| 380 |
+
- 0.50β0.69: Element likely present but ambiguous
|
| 381 |
+
- Below 0.50: DO NOT include the code
|
| 382 |
+
|
| 383 |
+
Output valid JSON only. No prose before or after."""
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
async def stage2_select_codes(
|
| 387 |
+
base64_data: str,
|
| 388 |
+
media_type: str,
|
| 389 |
+
candidates: List[dict],
|
| 390 |
+
client: AsyncAnthropic,
|
| 391 |
+
model: str,
|
| 392 |
+
) -> dict:
|
| 393 |
+
"""Have Claude pick the applicable codes from the candidate list."""
|
| 394 |
+
response = await client.messages.create(
|
| 395 |
+
model=model,
|
| 396 |
+
max_tokens=MAX_TOKENS_STAGE2,
|
| 397 |
+
system=_stage2_system_prompt(candidates),
|
| 398 |
+
messages=[{
|
| 399 |
+
"role": "user",
|
| 400 |
+
"content": [
|
| 401 |
+
{
|
| 402 |
+
"type": "image",
|
| 403 |
+
"source": {
|
| 404 |
+
"type": "base64",
|
| 405 |
+
"media_type": media_type,
|
| 406 |
+
"data": base64_data,
|
| 407 |
+
},
|
| 408 |
+
},
|
| 409 |
+
{
|
| 410 |
+
"type": "text",
|
| 411 |
+
"text": "Select the applicable codes from the candidates. Return JSON only.",
|
| 412 |
+
},
|
| 413 |
+
],
|
| 414 |
+
}],
|
| 415 |
+
)
|
| 416 |
+
|
| 417 |
+
raw_text = response.content[0].text.strip()
|
| 418 |
+
|
| 419 |
+
# Strip markdown fences if present
|
| 420 |
+
if raw_text.startswith("```"):
|
| 421 |
+
lines = raw_text.split("\n")
|
| 422 |
+
if lines[-1].startswith("```"):
|
| 423 |
+
lines = lines[1:-1]
|
| 424 |
+
else:
|
| 425 |
+
lines = lines[1:]
|
| 426 |
+
raw_text = "\n".join(lines).strip()
|
| 427 |
+
|
| 428 |
+
try:
|
| 429 |
+
return json.loads(raw_text)
|
| 430 |
+
except json.JSONDecodeError as e:
|
| 431 |
+
logger.error(f"Stage 2 JSON parse error: {e}")
|
| 432 |
+
logger.error(f"Raw response: {raw_text[:500]}")
|
| 433 |
+
return {"codes": [], "primary_category": None}
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
# ============================================================================
|
| 437 |
+
# PUBLIC API
|
| 438 |
+
# ============================================================================
|
| 439 |
+
|
| 440 |
+
async def classify_image(
|
| 441 |
+
image_input: Any,
|
| 442 |
+
model: str = DEFAULT_MODEL,
|
| 443 |
+
min_confidence: float = MIN_CONFIDENCE,
|
| 444 |
+
top_k: int = TOP_K_CANDIDATES,
|
| 445 |
+
) -> ClassificationResult:
|
| 446 |
+
"""Classify a trademark image into USPTO Design Search Codes via two-stage RAG.
|
| 447 |
+
|
| 448 |
+
Args:
|
| 449 |
+
image_input: bytes, PIL.Image, file path, or numpy.ndarray
|
| 450 |
+
model: Claude model (default: claude-sonnet-4-6)
|
| 451 |
+
min_confidence: Drop codes below this confidence (default: 0.5)
|
| 452 |
+
top_k: How many candidates to surface to stage 2 (default: 30)
|
| 453 |
+
|
| 454 |
+
Returns:
|
| 455 |
+
ClassificationResult with verified codes only β Claude cannot return
|
| 456 |
+
codes that aren't in USPTO's actual vocabulary.
|
| 457 |
+
"""
|
| 458 |
+
if not ANTHROPIC_API_KEY:
|
| 459 |
+
raise ValueError("ANTHROPIC_API_KEY environment variable not set")
|
| 460 |
+
if not VOYAGE_API_KEY:
|
| 461 |
+
raise ValueError("VOYAGE_API_KEY environment variable not set")
|
| 462 |
+
|
| 463 |
+
# Lazy-load the index (reused across all classifications)
|
| 464 |
+
index = CodeIndex.get()
|
| 465 |
+
|
| 466 |
+
# Normalize image once β used in both stages
|
| 467 |
+
base64_data, media_type = _normalize_image(image_input)
|
| 468 |
+
|
| 469 |
+
anthropic_client = AsyncAnthropic(api_key=ANTHROPIC_API_KEY)
|
| 470 |
+
voyage_client = voyageai.Client(api_key=VOYAGE_API_KEY)
|
| 471 |
+
|
| 472 |
+
# ββ Stage 1: describe ββ
|
| 473 |
+
description = await stage1_describe_image(
|
| 474 |
+
base64_data, media_type, anthropic_client, model
|
| 475 |
+
)
|
| 476 |
+
|
| 477 |
+
# ββ Retrieval: embed + vector search ββ
|
| 478 |
+
query_emb = embed_query(description, voyage_client)
|
| 479 |
+
candidates = index.search(query_emb, top_k=top_k)
|
| 480 |
+
logger.info(
|
| 481 |
+
f"Retrieved {len(candidates)} candidate codes "
|
| 482 |
+
f"(top similarity: {candidates[0]['similarity']:.3f})"
|
| 483 |
+
)
|
| 484 |
+
|
| 485 |
+
# ββ Stage 2: select ββ
|
| 486 |
+
selection = await stage2_select_codes(
|
| 487 |
+
base64_data, media_type, candidates, anthropic_client, model
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
# ββ Build result, validating that returned codes are in the candidate set ββ
|
| 491 |
+
candidate_codes = {c["code"] for c in candidates}
|
| 492 |
+
result = ClassificationResult(
|
| 493 |
+
image_description=description,
|
| 494 |
+
primary_category=selection.get("primary_category"),
|
| 495 |
+
candidate_codes_considered=[c["code"] for c in candidates],
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
for sel in selection.get("codes", []):
|
| 499 |
+
code = sel.get("code", "").strip()
|
| 500 |
+
confidence = float(sel.get("confidence", 0))
|
| 501 |
+
|
| 502 |
+
if confidence < min_confidence:
|
| 503 |
+
continue
|
| 504 |
+
if code not in candidate_codes:
|
| 505 |
+
# Defense in depth: even if Claude tries to invent a code, refuse it
|
| 506 |
+
logger.warning(f"Claude returned code {code} not in candidate set β dropping")
|
| 507 |
+
continue
|
| 508 |
+
|
| 509 |
+
result.codes.append(DesignCode(
|
| 510 |
+
code=code,
|
| 511 |
+
description=index.code_to_description.get(code, ""),
|
| 512 |
+
confidence=confidence,
|
| 513 |
+
rationale=sel.get("rationale", ""),
|
| 514 |
+
))
|
| 515 |
+
|
| 516 |
+
logger.info(
|
| 517 |
+
f"β
Classified into {len(result.codes)} verified code(s); "
|
| 518 |
+
f"primary category: {result.primary_category}"
|
| 519 |
+
)
|
| 520 |
+
return result
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
# ============================================================================
|
| 524 |
+
# OPTIONAL HELPERS
|
| 525 |
+
# ============================================================================
|
| 526 |
+
|
| 527 |
+
if HAS_FASTAPI:
|
| 528 |
+
async def classify_uploadfile(file: UploadFile, **kwargs) -> ClassificationResult:
|
| 529 |
+
"""Convenience wrapper for FastAPI UploadFile inputs."""
|
| 530 |
+
if file.content_type not in ("image/jpeg", "image/png", "image/gif", "image/webp"):
|
| 531 |
+
raise HTTPException(
|
| 532 |
+
status_code=400,
|
| 533 |
+
detail=f"Unsupported image type: {file.content_type}",
|
| 534 |
+
)
|
| 535 |
+
image_bytes = await file.read()
|
| 536 |
+
return await classify_image(image_bytes, **kwargs)
|
| 537 |
+
|
| 538 |
+
|
| 539 |
+
# ============================================================================
|
| 540 |
+
# CLI
|
| 541 |
+
# ============================================================================
|
| 542 |
+
|
| 543 |
+
async def _cli():
|
| 544 |
+
import argparse
|
| 545 |
+
parser = argparse.ArgumentParser(description="Test the USPTO design code classifier")
|
| 546 |
+
parser.add_argument("image_path", help="Path to a trademark image")
|
| 547 |
+
parser.add_argument("--model", default=DEFAULT_MODEL, help="Claude model")
|
| 548 |
+
parser.add_argument("--threshold", type=float, default=MIN_CONFIDENCE)
|
| 549 |
+
parser.add_argument("--top-k", type=int, default=TOP_K_CANDIDATES)
|
| 550 |
+
args = parser.parse_args()
|
| 551 |
+
|
| 552 |
+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
| 553 |
+
|
| 554 |
+
result = await classify_image(
|
| 555 |
+
args.image_path,
|
| 556 |
+
model=args.model,
|
| 557 |
+
min_confidence=args.threshold,
|
| 558 |
+
top_k=args.top_k,
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
print(json.dumps(result.to_dict(), indent=2))
|
| 562 |
+
|
| 563 |
+
|
| 564 |
+
if __name__ == "__main__":
|
| 565 |
+
asyncio.run(_cli())
|
image-classifier/uspto_code_embeddings.pkl
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:ddb2a0c81b1457c910226fe0e0c9a24e2723d0467f49a22a65fe289decbe42f3
|
| 3 |
+
size 5814306
|
image-classifier/uspto_design_codes.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
image-classifier/validate_classifier.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
validate_classifier.py β Test the design code classifier against examiner ground truth
|
| 3 |
+
========================================================================================
|
| 4 |
+
Pulls a sample of image marks from your Supabase database that already have
|
| 5 |
+
USPTO examiner-assigned design codes, runs each through the classifier, and
|
| 6 |
+
compares the classifier's output against the examiner's codes.
|
| 7 |
+
|
| 8 |
+
The output tells you concretely whether the classifier is good enough to
|
| 9 |
+
ship β or whether we need to iterate before exposing it to attorney-tier
|
| 10 |
+
customers.
|
| 11 |
+
|
| 12 |
+
WHAT IT MEASURES
|
| 13 |
+
----------------
|
| 14 |
+
For each test image, four agreement levels:
|
| 15 |
+
- Section-exact: XX.YY.ZZ matches exactly (strictest)
|
| 16 |
+
- Division-level: XX.YY matches (same code family)
|
| 17 |
+
- Category-level: XX matches (same broad category)
|
| 18 |
+
- Any overlap: at least one code in common
|
| 19 |
+
|
| 20 |
+
Plus retrieval-style metrics:
|
| 21 |
+
- Precision: of codes the classifier returned, what fraction were correct?
|
| 22 |
+
- Recall: of examiner codes, what fraction did the classifier find?
|
| 23 |
+
- F1: harmonic mean
|
| 24 |
+
|
| 25 |
+
WHAT TO LOOK FOR
|
| 26 |
+
----------------
|
| 27 |
+
For an MVP shipping to entrepreneurs at $49/mo:
|
| 28 |
+
- Category-level agreement >= 80% is reasonable
|
| 29 |
+
- Recall >= 60% means we catch most relevant matches
|
| 30 |
+
|
| 31 |
+
For attorney-tier customers at $250+/mo:
|
| 32 |
+
- Section-exact agreement >= 70%
|
| 33 |
+
- Recall >= 80% (missing codes is a liability risk)
|
| 34 |
+
|
| 35 |
+
If the numbers come in lower, look at the per-image CSV β usually the
|
| 36 |
+
classifier is right but using a slightly different code than the examiner,
|
| 37 |
+
or vice versa. That's data we can use to tune the prompt.
|
| 38 |
+
|
| 39 |
+
REQUIREMENTS
|
| 40 |
+
------------
|
| 41 |
+
Same as design_code_classifier.py, plus:
|
| 42 |
+
pip install httpx supabase
|
| 43 |
+
|
| 44 |
+
USAGE
|
| 45 |
+
-----
|
| 46 |
+
# Quick test β 20 random samples
|
| 47 |
+
python validate_classifier.py --samples 20
|
| 48 |
+
|
| 49 |
+
# Full validation β 100 samples, slower but more reliable
|
| 50 |
+
python validate_classifier.py --samples 100
|
| 51 |
+
|
| 52 |
+
# Resume after a crash
|
| 53 |
+
python validate_classifier.py --resume
|
| 54 |
+
|
| 55 |
+
OUTPUT
|
| 56 |
+
------
|
| 57 |
+
validation_results.csv β per-image: serial, examiner codes, classifier codes, scores
|
| 58 |
+
validation_summary.json β aggregate metrics
|
| 59 |
+
"""
|
| 60 |
+
|
| 61 |
+
import os
|
| 62 |
+
import sys
|
| 63 |
+
import csv
|
| 64 |
+
import json
|
| 65 |
+
import asyncio
|
| 66 |
+
import logging
|
| 67 |
+
import random
|
| 68 |
+
from pathlib import Path
|
| 69 |
+
from datetime import datetime, timezone
|
| 70 |
+
from typing import List, Dict, Any
|
| 71 |
+
|
| 72 |
+
import httpx
|
| 73 |
+
from dotenv import load_dotenv
|
| 74 |
+
from supabase import create_client, Client
|
| 75 |
+
|
| 76 |
+
# Local import β must be in same directory
|
| 77 |
+
from design_code_classifier import classify_image
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ============================================================================
|
| 81 |
+
# CONFIG
|
| 82 |
+
# ============================================================================
|
| 83 |
+
|
| 84 |
+
env_path = Path(__file__).parent / ".env"
|
| 85 |
+
load_dotenv(dotenv_path=env_path)
|
| 86 |
+
|
| 87 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 88 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
|
| 89 |
+
|
| 90 |
+
CONFIDENCE_THRESHOLD = 0.7 # only count classifier codes at or above this confidence
|
| 91 |
+
|
| 92 |
+
OUTPUT_CSV = Path(__file__).parent / "validation_results.csv"
|
| 93 |
+
OUTPUT_JSON = Path(__file__).parent / "validation_summary.json"
|
| 94 |
+
CHECKPOINT_PATH = Path(__file__).parent / ".validation_checkpoint.json"
|
| 95 |
+
|
| 96 |
+
# Politeness β don't hammer your DB or Claude API
|
| 97 |
+
DELAY_BETWEEN_SAMPLES_S = 1.0
|
| 98 |
+
|
| 99 |
+
logging.basicConfig(
|
| 100 |
+
level=logging.INFO,
|
| 101 |
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 102 |
+
)
|
| 103 |
+
logger = logging.getLogger("validate")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
# ============================================================================
|
| 107 |
+
# CHECKPOINT (resume after crash)
|
| 108 |
+
# ============================================================================
|
| 109 |
+
|
| 110 |
+
def load_checkpoint() -> Dict[str, Any]:
|
| 111 |
+
if CHECKPOINT_PATH.exists():
|
| 112 |
+
try:
|
| 113 |
+
return json.loads(CHECKPOINT_PATH.read_text())
|
| 114 |
+
except Exception as e:
|
| 115 |
+
logger.warning(f"Checkpoint unreadable, starting fresh: {e}")
|
| 116 |
+
return {"completed_serials": [], "results": []}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def save_checkpoint(state: Dict[str, Any]):
|
| 120 |
+
state["updated_at"] = datetime.now(timezone.utc).isoformat()
|
| 121 |
+
CHECKPOINT_PATH.write_text(json.dumps(state, indent=2))
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
# ============================================================================
|
| 125 |
+
# SAMPLE FETCHING
|
| 126 |
+
# ============================================================================
|
| 127 |
+
|
| 128 |
+
async def fetch_sample(supabase: Client, n: int, exclude_serials: set) -> List[Dict]:
|
| 129 |
+
"""Pull N random image marks that have both an image and examiner codes.
|
| 130 |
+
|
| 131 |
+
We over-fetch and randomize client-side because Postgres ORDER BY RANDOM()
|
| 132 |
+
on millions of rows is brutally slow.
|
| 133 |
+
"""
|
| 134 |
+
# Fetch a pool of candidates with codes + image (skip NOT_FOUND)
|
| 135 |
+
pool_size = min(n * 20, 5000) # 20x oversample, capped
|
| 136 |
+
logger.info(f"π‘ Fetching {pool_size} candidate image marks from Supabase...")
|
| 137 |
+
|
| 138 |
+
response = (
|
| 139 |
+
supabase.table("trademarks_images")
|
| 140 |
+
.select("serial_number,image_url,design_search_codes,mark_text")
|
| 141 |
+
.neq("image_url", "NOT_FOUND")
|
| 142 |
+
.not_.is_("image_url", "null")
|
| 143 |
+
.not_.is_("design_search_codes", "null")
|
| 144 |
+
.neq("design_search_codes", "")
|
| 145 |
+
.limit(pool_size)
|
| 146 |
+
.execute()
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
pool = [
|
| 150 |
+
r for r in response.data
|
| 151 |
+
if r["serial_number"] not in exclude_serials
|
| 152 |
+
and r.get("image_url")
|
| 153 |
+
and r.get("design_search_codes")
|
| 154 |
+
]
|
| 155 |
+
logger.info(f" Pool size after filtering: {len(pool)}")
|
| 156 |
+
|
| 157 |
+
# Random sample
|
| 158 |
+
random.shuffle(pool)
|
| 159 |
+
return pool[:n]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ============================================================================
|
| 163 |
+
# IMAGE FETCHING
|
| 164 |
+
# ============================================================================
|
| 165 |
+
|
| 166 |
+
async def fetch_image_bytes(url: str, http_client: httpx.AsyncClient) -> bytes:
|
| 167 |
+
"""Download a Supabase Storage image."""
|
| 168 |
+
resp = await http_client.get(url, timeout=20.0)
|
| 169 |
+
resp.raise_for_status()
|
| 170 |
+
return resp.content
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
# ============================================================================
|
| 174 |
+
# COMPARISON METRICS
|
| 175 |
+
# ============================================================================
|
| 176 |
+
|
| 177 |
+
def normalize_codes(codes_str: str) -> set:
|
| 178 |
+
"""Parse a comma-separated codes string into a set of XX.YY.ZZ codes.
|
| 179 |
+
|
| 180 |
+
USPTO bulk XML stores codes as 6-digit strings without separators
|
| 181 |
+
(e.g., "260121"). The classifier returns dotted format (e.g., "26.01.21").
|
| 182 |
+
This function accepts either format and normalizes everything to dotted
|
| 183 |
+
XX.YY.ZZ so set comparison works correctly.
|
| 184 |
+
"""
|
| 185 |
+
if not codes_str:
|
| 186 |
+
return set()
|
| 187 |
+
normalized = set()
|
| 188 |
+
for c in codes_str.split(","):
|
| 189 |
+
c = c.strip()
|
| 190 |
+
if not c:
|
| 191 |
+
continue
|
| 192 |
+
# Already-dotted format passes through unchanged
|
| 193 |
+
if "." in c:
|
| 194 |
+
normalized.add(c)
|
| 195 |
+
continue
|
| 196 |
+
# 6-digit USPTO format β insert dots: "260121" β "26.01.21"
|
| 197 |
+
if len(c) == 6 and c.isdigit():
|
| 198 |
+
normalized.add(f"{c[0:2]}.{c[2:4]}.{c[4:6]}")
|
| 199 |
+
continue
|
| 200 |
+
# Anything else: keep as-is (will simply not match, which is correct)
|
| 201 |
+
normalized.add(c)
|
| 202 |
+
return normalized
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def compare_codes(examiner: set, classifier: set) -> Dict[str, Any]:
|
| 206 |
+
"""Compute multi-level agreement between two code sets."""
|
| 207 |
+
examiner_divisions = {".".join(c.split(".")[:2]) for c in examiner}
|
| 208 |
+
examiner_categories = {c.split(".")[0] for c in examiner}
|
| 209 |
+
classifier_divisions = {".".join(c.split(".")[:2]) for c in classifier}
|
| 210 |
+
classifier_categories = {c.split(".")[0] for c in classifier}
|
| 211 |
+
|
| 212 |
+
section_overlap = examiner & classifier
|
| 213 |
+
division_overlap = examiner_divisions & classifier_divisions
|
| 214 |
+
category_overlap = examiner_categories & classifier_categories
|
| 215 |
+
|
| 216 |
+
return {
|
| 217 |
+
"examiner_count": len(examiner),
|
| 218 |
+
"classifier_count": len(classifier),
|
| 219 |
+
"section_exact_matches": len(section_overlap),
|
| 220 |
+
"division_matches": len(division_overlap),
|
| 221 |
+
"category_matches": len(category_overlap),
|
| 222 |
+
"any_section_overlap": bool(section_overlap),
|
| 223 |
+
"any_division_overlap": bool(division_overlap),
|
| 224 |
+
"any_category_overlap": bool(category_overlap),
|
| 225 |
+
# Retrieval metrics (treat examiner codes as ground truth)
|
| 226 |
+
"precision": len(section_overlap) / max(len(classifier), 1),
|
| 227 |
+
"recall": len(section_overlap) / max(len(examiner), 1),
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def f1_score(precision: float, recall: float) -> float:
|
| 232 |
+
if precision + recall == 0:
|
| 233 |
+
return 0.0
|
| 234 |
+
return 2 * precision * recall / (precision + recall)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
# ============================================================================
|
| 238 |
+
# PER-SAMPLE RUN
|
| 239 |
+
# ============================================================================
|
| 240 |
+
|
| 241 |
+
async def validate_one(
|
| 242 |
+
record: Dict,
|
| 243 |
+
http_client: httpx.AsyncClient,
|
| 244 |
+
) -> Dict[str, Any]:
|
| 245 |
+
"""Run the classifier on one image and compare to examiner codes."""
|
| 246 |
+
serial = record["serial_number"]
|
| 247 |
+
image_url = record["image_url"]
|
| 248 |
+
examiner_codes_raw = record["design_search_codes"]
|
| 249 |
+
examiner_codes = normalize_codes(examiner_codes_raw)
|
| 250 |
+
|
| 251 |
+
try:
|
| 252 |
+
image_bytes = await fetch_image_bytes(image_url, http_client)
|
| 253 |
+
except Exception as e:
|
| 254 |
+
return {
|
| 255 |
+
"serial_number": serial,
|
| 256 |
+
"status": "image_fetch_failed",
|
| 257 |
+
"error": str(e),
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
try:
|
| 261 |
+
result = await classify_image(image_bytes)
|
| 262 |
+
except Exception as e:
|
| 263 |
+
return {
|
| 264 |
+
"serial_number": serial,
|
| 265 |
+
"status": "classification_failed",
|
| 266 |
+
"error": str(e),
|
| 267 |
+
}
|
| 268 |
+
|
| 269 |
+
classifier_codes = set(result.high_confidence_codes(threshold=CONFIDENCE_THRESHOLD))
|
| 270 |
+
metrics = compare_codes(examiner_codes, classifier_codes)
|
| 271 |
+
|
| 272 |
+
return {
|
| 273 |
+
"serial_number": serial,
|
| 274 |
+
"mark_text": record.get("mark_text", ""),
|
| 275 |
+
"image_url": image_url,
|
| 276 |
+
"examiner_codes": sorted(examiner_codes),
|
| 277 |
+
"classifier_codes": sorted(classifier_codes),
|
| 278 |
+
"image_description": result.image_description,
|
| 279 |
+
"status": "ok",
|
| 280 |
+
**metrics,
|
| 281 |
+
}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
# ============================================================================
|
| 285 |
+
# MAIN
|
| 286 |
+
# ============================================================================
|
| 287 |
+
|
| 288 |
+
async def run(n_samples: int, resume: bool):
|
| 289 |
+
if not all([SUPABASE_URL, SUPABASE_KEY]):
|
| 290 |
+
logger.error("β SUPABASE_URL / SUPABASE_KEY not set in .env")
|
| 291 |
+
sys.exit(1)
|
| 292 |
+
|
| 293 |
+
state = load_checkpoint() if resume else {"completed_serials": [], "results": []}
|
| 294 |
+
completed = set(state["completed_serials"])
|
| 295 |
+
results: List[Dict] = state["results"]
|
| 296 |
+
|
| 297 |
+
if resume and completed:
|
| 298 |
+
logger.info(f"π Resuming β {len(completed)} samples already done")
|
| 299 |
+
n_samples = max(0, n_samples - len(completed))
|
| 300 |
+
if n_samples == 0:
|
| 301 |
+
logger.info("β
Sample target already met from checkpoint")
|
| 302 |
+
|
| 303 |
+
supabase = create_client(SUPABASE_URL, SUPABASE_KEY)
|
| 304 |
+
|
| 305 |
+
if n_samples > 0:
|
| 306 |
+
samples = await fetch_sample(supabase, n_samples, exclude_serials=completed)
|
| 307 |
+
logger.info(f"π― Will validate {len(samples)} new samples")
|
| 308 |
+
else:
|
| 309 |
+
samples = []
|
| 310 |
+
|
| 311 |
+
async with httpx.AsyncClient() as http_client:
|
| 312 |
+
for idx, record in enumerate(samples, 1):
|
| 313 |
+
serial = record["serial_number"]
|
| 314 |
+
logger.info(f"\n[{idx}/{len(samples)}] Validating {serial} ({record.get('mark_text', '')[:40]})")
|
| 315 |
+
|
| 316 |
+
try:
|
| 317 |
+
result = await validate_one(record, http_client)
|
| 318 |
+
results.append(result)
|
| 319 |
+
completed.add(serial)
|
| 320 |
+
|
| 321 |
+
if result["status"] == "ok":
|
| 322 |
+
logger.info(
|
| 323 |
+
f" Examiner: {result['examiner_codes']}\n"
|
| 324 |
+
f" Classifier: {result['classifier_codes']}\n"
|
| 325 |
+
f" P={result['precision']:.2f} R={result['recall']:.2f} "
|
| 326 |
+
f"category_match={result['any_category_overlap']}"
|
| 327 |
+
)
|
| 328 |
+
else:
|
| 329 |
+
logger.warning(f" β οΈ {result['status']}: {result.get('error', '')[:200]}")
|
| 330 |
+
except Exception as e:
|
| 331 |
+
logger.error(f" β Unexpected error: {e}")
|
| 332 |
+
results.append({
|
| 333 |
+
"serial_number": serial,
|
| 334 |
+
"status": "unexpected_error",
|
| 335 |
+
"error": str(e),
|
| 336 |
+
})
|
| 337 |
+
completed.add(serial)
|
| 338 |
+
|
| 339 |
+
# Checkpoint after each sample
|
| 340 |
+
state["completed_serials"] = list(completed)
|
| 341 |
+
state["results"] = results
|
| 342 |
+
save_checkpoint(state)
|
| 343 |
+
|
| 344 |
+
await asyncio.sleep(DELAY_BETWEEN_SAMPLES_S)
|
| 345 |
+
|
| 346 |
+
# ββ Aggregate ββ
|
| 347 |
+
ok_results = [r for r in results if r.get("status") == "ok"]
|
| 348 |
+
if not ok_results:
|
| 349 |
+
logger.error("β No successful validations to aggregate")
|
| 350 |
+
return
|
| 351 |
+
|
| 352 |
+
avg_precision = sum(r["precision"] for r in ok_results) / len(ok_results)
|
| 353 |
+
avg_recall = sum(r["recall"] for r in ok_results) / len(ok_results)
|
| 354 |
+
avg_f1 = f1_score(avg_precision, avg_recall)
|
| 355 |
+
|
| 356 |
+
section_match_rate = sum(1 for r in ok_results if r["any_section_overlap"]) / len(ok_results)
|
| 357 |
+
division_match_rate = sum(1 for r in ok_results if r["any_division_overlap"]) / len(ok_results)
|
| 358 |
+
category_match_rate = sum(1 for r in ok_results if r["any_category_overlap"]) / len(ok_results)
|
| 359 |
+
|
| 360 |
+
summary = {
|
| 361 |
+
"generated_at": datetime.now(timezone.utc).isoformat(),
|
| 362 |
+
"total_samples": len(results),
|
| 363 |
+
"successful_samples": len(ok_results),
|
| 364 |
+
"failed_samples": len(results) - len(ok_results),
|
| 365 |
+
"confidence_threshold": CONFIDENCE_THRESHOLD,
|
| 366 |
+
"metrics": {
|
| 367 |
+
"section_exact_match_rate": section_match_rate,
|
| 368 |
+
"division_match_rate": division_match_rate,
|
| 369 |
+
"category_match_rate": category_match_rate,
|
| 370 |
+
"avg_precision": avg_precision,
|
| 371 |
+
"avg_recall": avg_recall,
|
| 372 |
+
"avg_f1": avg_f1,
|
| 373 |
+
},
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
OUTPUT_JSON.write_text(json.dumps(summary, indent=2))
|
| 377 |
+
|
| 378 |
+
# CSV β per-sample, easy to sort/filter in a spreadsheet
|
| 379 |
+
with OUTPUT_CSV.open("w", newline="", encoding="utf-8") as f:
|
| 380 |
+
writer = csv.writer(f)
|
| 381 |
+
writer.writerow([
|
| 382 |
+
"serial_number", "mark_text", "image_url",
|
| 383 |
+
"examiner_codes", "classifier_codes",
|
| 384 |
+
"section_overlap", "division_overlap", "category_overlap",
|
| 385 |
+
"precision", "recall", "image_description", "status",
|
| 386 |
+
])
|
| 387 |
+
for r in results:
|
| 388 |
+
if r.get("status") != "ok":
|
| 389 |
+
writer.writerow([r.get("serial_number"), "", "", "", "", "", "", "", "", "", "", r.get("status")])
|
| 390 |
+
continue
|
| 391 |
+
writer.writerow([
|
| 392 |
+
r["serial_number"],
|
| 393 |
+
r["mark_text"],
|
| 394 |
+
r["image_url"],
|
| 395 |
+
",".join(r["examiner_codes"]),
|
| 396 |
+
",".join(r["classifier_codes"]),
|
| 397 |
+
r["any_section_overlap"],
|
| 398 |
+
r["any_division_overlap"],
|
| 399 |
+
r["any_category_overlap"],
|
| 400 |
+
f"{r['precision']:.3f}",
|
| 401 |
+
f"{r['recall']:.3f}",
|
| 402 |
+
r["image_description"],
|
| 403 |
+
"ok",
|
| 404 |
+
])
|
| 405 |
+
|
| 406 |
+
# ββ Print summary ββ
|
| 407 |
+
logger.info("\n" + "=" * 70)
|
| 408 |
+
logger.info("π VALIDATION RESULTS")
|
| 409 |
+
logger.info("=" * 70)
|
| 410 |
+
logger.info(f" Samples: {len(ok_results)} successful, {len(results) - len(ok_results)} failed")
|
| 411 |
+
logger.info(f" Confidence threshold: {CONFIDENCE_THRESHOLD}")
|
| 412 |
+
logger.info("")
|
| 413 |
+
logger.info(f" Section-exact match rate: {section_match_rate:.1%} (any XX.YY.ZZ in common)")
|
| 414 |
+
logger.info(f" Division match rate: {division_match_rate:.1%} (any XX.YY in common)")
|
| 415 |
+
logger.info(f" Category match rate: {category_match_rate:.1%} (any XX in common)")
|
| 416 |
+
logger.info("")
|
| 417 |
+
logger.info(f" Avg precision: {avg_precision:.1%} (classifier codes that were right)")
|
| 418 |
+
logger.info(f" Avg recall: {avg_recall:.1%} (examiner codes the classifier found)")
|
| 419 |
+
logger.info(f" Avg F1: {avg_f1:.3f}")
|
| 420 |
+
logger.info("")
|
| 421 |
+
logger.info(f" Per-sample CSV: {OUTPUT_CSV}")
|
| 422 |
+
logger.info(f" Summary JSON: {OUTPUT_JSON}")
|
| 423 |
+
logger.info("=" * 70)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ============================================================================
|
| 427 |
+
# CLI
|
| 428 |
+
# ============================================================================
|
| 429 |
+
|
| 430 |
+
async def main():
|
| 431 |
+
import argparse
|
| 432 |
+
parser = argparse.ArgumentParser(description="Validate the design code classifier")
|
| 433 |
+
parser.add_argument(
|
| 434 |
+
"--samples", type=int, default=20,
|
| 435 |
+
help="How many random image marks to test against (default: 20)"
|
| 436 |
+
)
|
| 437 |
+
parser.add_argument(
|
| 438 |
+
"--resume", action="store_true",
|
| 439 |
+
help="Skip samples already in .validation_checkpoint.json"
|
| 440 |
+
)
|
| 441 |
+
parser.add_argument(
|
| 442 |
+
"--reset-checkpoint", action="store_true",
|
| 443 |
+
help="Delete checkpoint and start fresh"
|
| 444 |
+
)
|
| 445 |
+
args = parser.parse_args()
|
| 446 |
+
|
| 447 |
+
if args.reset_checkpoint and CHECKPOINT_PATH.exists():
|
| 448 |
+
CHECKPOINT_PATH.unlink()
|
| 449 |
+
logger.info("ποΈ Checkpoint cleared")
|
| 450 |
+
|
| 451 |
+
await run(n_samples=args.samples, resume=args.resume)
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
if __name__ == "__main__":
|
| 455 |
+
try:
|
| 456 |
+
asyncio.run(main())
|
| 457 |
+
except KeyboardInterrupt:
|
| 458 |
+
logger.info("\nβ οΈ Interrupted β checkpoint saved, safe to re-run with --resume")
|
| 459 |
+
sys.exit(0)
|