- Add scan-to-plant flow: upload card photos, OCR, VPC match on confirm form - OpenCV CLAHE + adaptive threshold preprocessing; dual psm 6/11 OCR pass - Regex fixes for OCR misreads: height (s→5), frost (=,„,i,l,G), density, bloom months (E→I) - Scientific name extraction: passport section + cultivar quote strategy - Last-wins merge so back card data overrides front card garbage - Crop thumbnail tool with Cropper.js; cropped image shown in list and detail - VPC image always supersedes user thumbnail on detail page - VPC sunlight mapping: zon→full_sun, halfschaduw→part_sun, schaduw→full_shade - Auto-set thumbnail on plant created from scan Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
95 lines
3.4 KiB
Python
95 lines
3.4 KiB
Python
"""
|
|
Card image processing: extract card shape from background and reduce dirt/noise.
|
|
|
|
Uses OpenCV Otsu thresholding + morphological cleanup to isolate the card shape
|
|
regardless of background colour. Works at reduced resolution for speed, then
|
|
upscales the mask to apply to the original image.
|
|
"""
|
|
import io
|
|
import numpy as np
|
|
import cv2
|
|
from PIL import Image, ImageFilter
|
|
|
|
|
|
_PROCESS_SCALE = 0.25 # work at 25% resolution, upscale result
|
|
_MIN_COVERAGE = 0.05 # fail if detected card covers < 5% of image
|
|
|
|
|
|
def process_card_photo(pil_image: Image.Image) -> Image.Image:
|
|
"""
|
|
Return the card isolated on a white background, lightly denoised.
|
|
Falls back silently to the original image if extraction fails.
|
|
"""
|
|
try:
|
|
mask = _find_card_mask(pil_image)
|
|
if mask is None:
|
|
return pil_image
|
|
|
|
arr = np.array(pil_image.convert('RGB'))
|
|
white = np.full_like(arr, 255)
|
|
# mask is (H, W) bool → broadcast over RGB channels
|
|
result_arr = np.where(mask[:, :, np.newaxis], arr, white).astype(np.uint8)
|
|
result = Image.fromarray(result_arr)
|
|
|
|
# Mild median filter to soften dirt spots
|
|
result = result.filter(ImageFilter.MedianFilter(size=3))
|
|
|
|
# Crop to card bounding box
|
|
mask_img = Image.fromarray((mask * 255).astype(np.uint8))
|
|
bbox = mask_img.getbbox()
|
|
if bbox:
|
|
result = result.crop(bbox)
|
|
|
|
return result
|
|
except Exception:
|
|
return pil_image
|
|
|
|
|
|
def _find_card_mask(pil_image: Image.Image) -> np.ndarray | None:
|
|
"""
|
|
Return a boolean mask at original resolution covering the card, or None.
|
|
Uses Otsu threshold on the L channel of LAB colour space so it adapts
|
|
to any background brightness.
|
|
"""
|
|
w, h = pil_image.size
|
|
small_w = max(int(w * _PROCESS_SCALE), 80)
|
|
small_h = max(int(h * _PROCESS_SCALE), 80)
|
|
|
|
# Resize for speed
|
|
small = pil_image.resize((small_w, small_h), Image.LANCZOS).convert('RGB')
|
|
bgr = cv2.cvtColor(np.array(small), cv2.COLOR_RGB2BGR)
|
|
|
|
# L channel of LAB is perceptually uniform luminance
|
|
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
|
|
L = lab[:, :, 0]
|
|
|
|
# Otsu threshold: automatically finds the best separation
|
|
_, mask = cv2.threshold(L, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
|
mask = mask.astype(bool)
|
|
|
|
coverage = mask.mean()
|
|
if not (_MIN_COVERAGE < coverage < (1 - _MIN_COVERAGE)):
|
|
return None
|
|
|
|
# Morphological cleanup: open removes isolated specks, close fills gaps
|
|
kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
|
|
m = mask.astype(np.uint8) * 255
|
|
m = cv2.morphologyEx(m, cv2.MORPH_OPEN, kernel_open)
|
|
m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, kernel_close)
|
|
|
|
# Keep only the largest connected component (the card itself)
|
|
n_labels, labels, stats, _ = cv2.connectedComponentsWithStats(m, connectivity=8)
|
|
if n_labels <= 1:
|
|
return None
|
|
# stats[0] is background; find largest foreground component
|
|
largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
|
|
m = ((labels == largest) * 255).astype(np.uint8)
|
|
|
|
# Slight dilation to recover edges lost during thresholding
|
|
kernel_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
|
m = cv2.dilate(m, kernel_dilate)
|
|
|
|
# Upscale mask to original resolution
|
|
mask_full = cv2.resize(m, (w, h), interpolation=cv2.INTER_NEAREST)
|
|
return mask_full > 127
|