96 lines
3.1 KiB
Python
96 lines
3.1 KiB
Python
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
|