feat: auto feature detection with filename prefixes on export

Detects QR codes (QR_), barcodes (BC_), faces (FACE_/GROUP_),
and panoramas (PANO_) per photo using OpenCV — no new dependencies.
Opt-in checkboxes in the rename tab; prefixes prepend to filename.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-23 12:50:34 +00:00
parent 433fd93a36
commit 1aded7ff0d
3 changed files with 347 additions and 0 deletions
+321
View File
@@ -0,0 +1,321 @@
import io
import math
import os
import re
from datetime import datetime
from typing import List, Optional, Set
import cv2
import numpy as np
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps
from PIL.ExifTags import TAGS
# ---------------------------------------------------------------------------
# EXIF helpers
# ---------------------------------------------------------------------------
def get_exif_info(path: str) -> dict:
"""Returns dict with keys: date_str, date_formatted, camera, lens."""
info = {"date_str": None, "date_formatted": None, "camera": None, "lens": None}
try:
img = Image.open(path)
exif_raw = img.getexif()
if not exif_raw:
return info
exif = {TAGS.get(k, k): v for k, v in exif_raw.items()}
# Date
date_str = exif.get("DateTimeOriginal") or exif.get("DateTime")
if date_str and isinstance(date_str, str):
info["date_str"] = date_str
# "2024:07:15 14:30:22" -> "2024-07-15_143022"
clean = date_str.replace(":", "-", 2).replace(" ", "_").replace(":", "")
info["date_formatted"] = clean
# Camera
make = str(exif.get("Make", "")).strip().rstrip("\x00")
model = str(exif.get("Model", "")).strip().rstrip("\x00")
if model:
info["camera"] = model if make and make in model else f"{make} {model}".strip()
# Lens
lens = str(exif.get("LensModel", "")).strip().rstrip("\x00")
if lens:
info["lens"] = lens
except Exception:
pass
return info
def resolve_wm_template(template: str, exif: dict) -> str:
"""Replaces {date}, {time}, {camera}, {lens} in watermark text."""
date_str = exif.get("date_str") or ""
date_part = date_str[:10].replace(":", "-") if date_str else ""
time_part = date_str[11:] if len(date_str) > 10 else ""
return (template
.replace("{date}", date_part)
.replace("{time}", time_part)
.replace("{camera}", exif.get("camera") or "")
.replace("{lens}", exif.get("lens") or ""))
# ---------------------------------------------------------------------------
# Rename
# ---------------------------------------------------------------------------
def get_new_name(original_path: str, mode: str, prefix: str,
index: int, exif_info: dict,
is_fav: bool, fav_prefix: str) -> str:
original = os.path.basename(original_path)
stem, ext = os.path.splitext(original)
ext = ext.lower() or ".jpg"
date_fmt = exif_info.get("date_formatted") # "2024-07-15_143022"
if mode == "original":
new = prefix + original if prefix else original
elif mode == "datetime":
if date_fmt:
new = f"{prefix}{date_fmt}{ext}"
else:
new = f"{prefix}{stem}{ext}"
elif mode == "date_seq":
date_part = date_fmt[:10] if date_fmt else "nodate"
new = f"{prefix}{date_part}_{index:04d}{ext}"
elif mode == "prefix_seq":
new = f"{prefix}{index:04d}{ext}"
else:
new = f"{prefix}{index:04d}_{stem}{ext}"
if is_fav:
new = fav_prefix + new
return new
# ---------------------------------------------------------------------------
# Horizon detection
# ---------------------------------------------------------------------------
def detect_horizon_angle(path: str) -> float:
"""Returns skew angle in degrees (positive = clockwise). 0.0 if undetermined."""
try:
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
if img is None:
return 0.0
h, w = img.shape
# Downsample for speed
scale = min(1.0, 800 / max(w, 1))
if scale < 1.0:
img = cv2.resize(img, (int(w * scale), int(h * scale)))
h, w = img.shape
# Focus on middle horizontal band
roi = img[h // 3: 2 * h // 3, :]
edges = cv2.Canny(roi, 50, 150)
lines = cv2.HoughLinesP(edges, 1, np.pi / 180, threshold=60,
minLineLength=w // 6, maxLineGap=15)
if lines is None:
return 0.0
angles = []
for line in lines:
x1, y1, x2, y2 = line[0]
if x2 != x1:
angle = math.degrees(math.atan2(y2 - y1, x2 - x1))
if -20 < angle < 20:
angles.append(angle)
if not angles:
return 0.0
return round(float(np.median(angles)), 1)
except Exception:
return 0.0
# ---------------------------------------------------------------------------
# Feature detection
# ---------------------------------------------------------------------------
_face_cascade = None
def _get_face_cascade():
global _face_cascade
if _face_cascade is None:
path = cv2.data.haarcascades + "haarcascade_frontalface_default.xml"
_face_cascade = cv2.CascadeClassifier(path)
return _face_cascade
def detect_features(path: str, enabled: Set[str]) -> List[str]:
"""
Detects visual features and returns prefix strings to prepend to filename.
enabled: set of strings from {"qr", "barcode", "face", "pano"}
Returns e.g. ["QR_", "FACE_"]
"""
prefixes = []
try:
img = cv2.imread(path)
if img is None:
return prefixes
h, w = img.shape[:2]
if "qr" in enabled:
data, _, _ = cv2.QRCodeDetector().detectAndDecode(img)
if data:
prefixes.append("QR_")
if "barcode" in enabled:
try:
ok, decoded, _, _ = cv2.barcode.BarcodeDetector().detectAndDecode(img)
if ok and any(decoded):
prefixes.append("BC_")
except Exception:
pass
if "face" in enabled:
scale = min(1.0, 640 / max(w, 1))
small = cv2.resize(cv2.cvtColor(img, cv2.COLOR_BGR2GRAY),
(int(w * scale), int(h * scale))) if scale < 1.0 \
else cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
faces = _get_face_cascade().detectMultiScale(
small, scaleFactor=1.1, minNeighbors=5, minSize=(30, 30))
if len(faces) == 1:
prefixes.append("FACE_")
elif len(faces) > 1:
prefixes.append("GROUP_")
if "pano" in enabled and w / max(h, 1) > 2.5:
prefixes.append("PANO_")
except Exception:
pass
return prefixes
# ---------------------------------------------------------------------------
# Image processing
# ---------------------------------------------------------------------------
def _load_font(size: int) -> ImageFont.FreeTypeFont:
candidates = [
"/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",
"/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf",
"/usr/share/fonts/truetype/freefont/FreeSans.ttf",
]
for c in candidates:
if os.path.exists(c):
try:
return ImageFont.truetype(c, size)
except Exception:
pass
return ImageFont.load_default()
def _wm_xy(img_w: int, img_h: int, elem_w: int, elem_h: int,
position: str, margin: int = 24) -> tuple:
positions = {
"br": (img_w - elem_w - margin, img_h - elem_h - margin),
"bl": (margin, img_h - elem_h - margin),
"tr": (img_w - elem_w - margin, margin),
"tl": (margin, margin),
"bc": ((img_w - elem_w) // 2, img_h - elem_h - margin),
"tc": ((img_w - elem_w) // 2, margin),
"center": ((img_w - elem_w) // 2, (img_h - elem_h) // 2),
}
return positions.get(position, positions["br"])
def apply_corrections(img: Image.Image, rotation: float = 0.0,
brightness: float = 1.0, contrast: float = 1.0,
saturation: float = 1.0) -> Image.Image:
try:
img = ImageOps.exif_transpose(img)
except Exception:
pass
if rotation != 0.0:
img = img.rotate(-rotation, expand=True, resample=Image.BICUBIC)
if brightness != 1.0:
img = ImageEnhance.Brightness(img).enhance(brightness)
if contrast != 1.0:
img = ImageEnhance.Contrast(img).enhance(contrast)
if saturation != 1.0:
img = ImageEnhance.Color(img).enhance(saturation)
return img
def apply_text_watermark(img: Image.Image, text: str, position: str = "br",
font_size: int = 24, opacity: float = 0.7) -> Image.Image:
if not text.strip():
return img
alpha = int(opacity * 255)
font = _load_font(font_size)
base = img.convert("RGBA")
overlay = Image.new("RGBA", base.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
bbox = draw.textbbox((0, 0), text, font=font)
tw, th = bbox[2] - bbox[0], bbox[3] - bbox[1]
x, y = _wm_xy(base.width, base.height, tw, th, position)
# Shadow
draw.text((x + 2, y + 2), text, font=font, fill=(0, 0, 0, min(255, alpha)))
draw.text((x, y), text, font=font, fill=(255, 255, 255, alpha))
return Image.alpha_composite(base, overlay).convert("RGB")
def apply_image_watermark(img: Image.Image, wm_path: str, position: str = "br",
opacity: float = 0.6, scale: float = 0.2) -> Image.Image:
if not wm_path or not os.path.isfile(wm_path):
return img
try:
wm = Image.open(wm_path).convert("RGBA")
target_w = max(10, int(img.width * scale))
target_h = int(wm.height * target_w / wm.width)
wm = wm.resize((target_w, target_h), Image.LANCZOS)
r, g, b, a = wm.split()
a = a.point(lambda v: int(v * opacity))
wm = Image.merge("RGBA", (r, g, b, a))
base = img.convert("RGBA")
x, y = _wm_xy(base.width, base.height, target_w, target_h, position)
base.paste(wm, (x, y), wm)
return base.convert("RGB")
except Exception:
return img
def process_photo(path: str,
rotation: float = 0.0,
brightness: float = 1.0,
contrast: float = 1.0,
saturation: float = 1.0,
text_watermark: Optional[dict] = None,
image_watermark_path: Optional[str] = None,
image_watermark_settings: Optional[dict] = None,
exif_info: Optional[dict] = None) -> bytes:
"""Process a single photo and return JPEG bytes."""
img = Image.open(path)
img = apply_corrections(img, rotation=rotation, brightness=brightness,
contrast=contrast, saturation=saturation)
if text_watermark:
text = text_watermark.get("text", "")
if exif_info:
text = resolve_wm_template(text, exif_info)
img = apply_text_watermark(
img,
text=text,
position=text_watermark.get("position", "br"),
font_size=text_watermark.get("font_size", 24),
opacity=text_watermark.get("opacity", 0.7),
)
if image_watermark_path and image_watermark_settings:
img = apply_image_watermark(
img,
wm_path=image_watermark_path,
position=image_watermark_settings.get("position", "br"),
opacity=image_watermark_settings.get("opacity", 0.6),
scale=image_watermark_settings.get("scale", 0.2),
)
img = img.convert("RGB")
buf = io.BytesIO()
img.save(buf, "JPEG", quality=92)
return buf.getvalue()