import io import logging import os import cv2 from PIL import Image, ImageOps import config logger = logging.getLogger(__name__) def list_template_files(): if not os.path.isdir(config.TEMPLATE_IMAGES_DIR): raise FileNotFoundError(f"Template folder not found: {config.TEMPLATE_IMAGES_DIR}") return sorted( f for f in os.listdir(config.TEMPLATE_IMAGES_DIR) if f.lower().endswith(config.VALID_EXTS) ) def template_name(fname): return os.path.splitext(fname)[0] def annotate_score(bgr_img, score, confidence_pct, label=None): img = bgr_img.copy() text = f"Score: {score} Conf: {confidence_pct:.1f}%" if label: text = f"{label} | {text}" font = cv2.FONT_HERSHEY_SIMPLEX scale = max(0.55, img.shape[1] / 900) thickness = max(1, int(scale * 2)) (tw, th), baseline = cv2.getTextSize(text, font, scale, thickness) cv2.rectangle(img, (5, 5), (15 + tw, 20 + th + baseline), (20, 20, 20), -1) cv2.putText(img, text, (10, 15 + th), font, scale, (110, 231, 183), thickness, cv2.LINE_AA) return img def encode_png_bytes(bgr_or_bgra_img): ok, buf = cv2.imencode(".png", bgr_or_bgra_img) if not ok: raise RuntimeError("Failed to encode image to PNG") return buf.tobytes() def compress_image_bytes(image_bytes: bytes, max_bytes: int, max_dim: int) -> tuple: """ Downscale + re-encode as JPEG only if image_bytes exceeds max_bytes; otherwise returns it untouched -- images already under the limit are never re-compressed, so nothing is lost for the common case. Downscaling to max_dim costs no *usable* detail here: the matching pipeline (bg removal, SIFT/ORB/SuperPoint/LoFTR) already caps every image to this same size before processing it, and the external AI verification endpoint's vision model downsamples internally to its own fixed input resolution regardless. This just stops storing/transmitting pixels nothing in the system ever actually looks at. Returns (bytes, was_compressed). """ if len(image_bytes) <= max_bytes: return image_bytes, False original_size = len(image_bytes) pil_img = Image.open(io.BytesIO(image_bytes)) pil_img = ImageOps.exif_transpose(pil_img) # bake in camera rotation before resizing pil_img = pil_img.convert("RGB") w, h = pil_img.size scale = max_dim / max(w, h) if scale < 1.0: pil_img = pil_img.resize((max(1, int(w * scale)), max(1, int(h * scale))), Image.LANCZOS) quality = config.COMPRESS_JPEG_QUALITY_START data = None while True: buf = io.BytesIO() pil_img.save(buf, format="JPEG", quality=quality, optimize=True) data = buf.getvalue() if len(data) <= max_bytes or quality <= config.COMPRESS_JPEG_QUALITY_MIN: break quality -= config.COMPRESS_JPEG_QUALITY_STEP logger.info( "Compressed upload: %.1f MB -> %.1f MB (%dx%d, JPEG q%d)", original_size / (1024 * 1024), len(data) / (1024 * 1024), pil_img.width, pil_img.height, quality, ) return data, True