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:
+19
@@ -224,6 +224,9 @@
|
|||||||
.wm-sub { padding: 0.3rem 0.8rem; border-radius: 6px; border: 1.5px solid var(--border); background: transparent; color: var(--muted); font-size: 0.82rem; cursor: pointer; transition: border-color 0.15s, color 0.15s; }
|
.wm-sub { padding: 0.3rem 0.8rem; border-radius: 6px; border: 1.5px solid var(--border); background: transparent; color: var(--muted); font-size: 0.82rem; cursor: pointer; transition: border-color 0.15s, color 0.15s; }
|
||||||
.wm-sub.active { border-color: var(--blue); color: var(--blue); }
|
.wm-sub.active { border-color: var(--blue); color: var(--blue); }
|
||||||
.hint-row { font-size: 0.75rem; color: var(--faint); margin-top: 0.2rem; }
|
.hint-row { font-size: 0.75rem; color: var(--faint); margin-top: 0.2rem; }
|
||||||
|
.feat-checks { display: flex; flex-wrap: wrap; gap: 0.4rem 0.75rem; margin-top: 0.35rem; }
|
||||||
|
.feat-check { display: flex; align-items: center; gap: 0.3rem; font-size: 0.82rem; color: var(--text); cursor: pointer; }
|
||||||
|
.feat-check input[type=checkbox] { accent-color: var(--blue); width: 14px; height: 14px; cursor: pointer; }
|
||||||
|
|
||||||
/* ── Ordner-Auswahl-Button in Drop-Zone ── */
|
/* ── Ordner-Auswahl-Button in Drop-Zone ── */
|
||||||
#folder-picker-btn:hover { border-color: var(--blue); color: var(--blue); }
|
#folder-picker-btn:hover { border-color: var(--blue); color: var(--blue); }
|
||||||
@@ -445,6 +448,16 @@
|
|||||||
<input type="text" id="fav-prefix" value="FAV_" placeholder="FAV_">
|
<input type="text" id="fav-prefix" value="FAV_" placeholder="FAV_">
|
||||||
<span class="hint-row">Favorisierte Fotos erhalten dieses Präfix im Dateinamen</span>
|
<span class="hint-row">Favorisierte Fotos erhalten dieses Präfix im Dateinamen</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="exp-row" style="margin-top:0.75rem; border-top:1px solid var(--border); padding-top:0.75rem;">
|
||||||
|
<label>Merkmale automatisch erkennen & als Präfix setzen</label>
|
||||||
|
<div class="feat-checks">
|
||||||
|
<label class="feat-check"><input type="checkbox" id="feat-qr"> QR_</label>
|
||||||
|
<label class="feat-check"><input type="checkbox" id="feat-bc"> BC_</label>
|
||||||
|
<label class="feat-check"><input type="checkbox" id="feat-face"> FACE_ / GROUP_</label>
|
||||||
|
<label class="feat-check"><input type="checkbox" id="feat-pano"> PANO_</label>
|
||||||
|
</div>
|
||||||
|
<span class="hint-row">Wird beim Export pro Foto erkannt — verlangsamt den Export leicht</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab: Bildeditor -->
|
<!-- Tab: Bildeditor -->
|
||||||
@@ -1598,6 +1611,12 @@
|
|||||||
opacity: parseFloat(el("wm-image-opacity").value),
|
opacity: parseFloat(el("wm-image-opacity").value),
|
||||||
scale: parseInt(el("wm-image-scale").value) / 100,
|
scale: parseInt(el("wm-image-scale").value) / 100,
|
||||||
} : {},
|
} : {},
|
||||||
|
feature_detectors: [
|
||||||
|
...(el("feat-qr").checked ? ["qr"] : []),
|
||||||
|
...(el("feat-bc").checked ? ["barcode"] : []),
|
||||||
|
...(el("feat-face").checked ? ["face"] : []),
|
||||||
|
...(el("feat-pano").checked ? ["pano"] : []),
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
+321
@@ -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()
|
||||||
@@ -124,6 +124,7 @@ class ExportRequest(BaseModel):
|
|||||||
text_watermark: dict = Field(default_factory=dict)
|
text_watermark: dict = Field(default_factory=dict)
|
||||||
image_watermark_path: str = ""
|
image_watermark_path: str = ""
|
||||||
image_watermark_settings: dict = Field(default_factory=dict)
|
image_watermark_settings: dict = Field(default_factory=dict)
|
||||||
|
feature_detectors: List[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
@@ -400,6 +401,7 @@ def _run_export_job(job_id: str, req: ExportRequest):
|
|||||||
buf = io.BytesIO()
|
buf = io.BytesIO()
|
||||||
used_names: set = set()
|
used_names: set = set()
|
||||||
|
|
||||||
|
detectors = set(req.feature_detectors)
|
||||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
for i, path in enumerate(export_paths):
|
for i, path in enumerate(export_paths):
|
||||||
exif = get_exif_info(path)
|
exif = get_exif_info(path)
|
||||||
@@ -407,6 +409,11 @@ def _run_export_job(job_id: str, req: ExportRequest):
|
|||||||
path, req.rename_mode, req.rename_prefix,
|
path, req.rename_mode, req.rename_prefix,
|
||||||
i + 1, exif, path in fav_set, req.fav_prefix,
|
i + 1, exif, path in fav_set, req.fav_prefix,
|
||||||
)
|
)
|
||||||
|
if detectors:
|
||||||
|
from processor import detect_features
|
||||||
|
feat_prefix = "".join(detect_features(path, detectors))
|
||||||
|
if feat_prefix:
|
||||||
|
new_name = feat_prefix + new_name
|
||||||
# Deduplicate filenames
|
# Deduplicate filenames
|
||||||
base, ext = os.path.splitext(new_name)
|
base, ext = os.path.splitext(new_name)
|
||||||
candidate, n = new_name, 1
|
candidate, n = new_name, 1
|
||||||
|
|||||||
Reference in New Issue
Block a user