bloombase/plants/services/card_processing.py
Stephan Kerkman ba0ca6b169 feat: card scanning, OCR improvements, crop thumbnail, VPC enrichment
- 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>
2026-05-29 21:54:59 +02:00

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