Spaces:
Sleeping
Sleeping
tesalonikahtp commited on
Commit ·
ada6310
1
Parent(s): e82342b
fix: not enough zoom
Browse files- app/util/passport_photo_engine/passport_cropper.py +59 -39
- server.py +6 -3
app/util/passport_photo_engine/passport_cropper.py
CHANGED
|
@@ -2,56 +2,76 @@ import cv2
|
|
| 2 |
import numpy as np
|
| 3 |
|
| 4 |
class PassportCropper:
|
| 5 |
-
def __init__(self, output_size=(
|
|
|
|
| 6 |
self.out_w, self.out_h = output_size
|
| 7 |
self.bg_color = tuple(int(x) for x in bg_color)
|
| 8 |
self.target_aspect = self.out_w / self.out_h
|
| 9 |
|
| 10 |
def composite(self, img_rgb, mask):
|
|
|
|
| 11 |
bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
|
|
|
|
|
|
|
|
|
|
| 12 |
m = cv2.GaussianBlur(mask, (5,5), 0)
|
| 13 |
alpha = (m.astype(float) / 255.0)[:,:,None]
|
| 14 |
bg = np.full_like(bgr, np.array(self.bg_color, dtype=np.uint8))
|
|
|
|
|
|
|
| 15 |
return (bgr.astype(float) * alpha + bg.astype(float) * (1.0 - alpha)).astype(np.uint8)
|
| 16 |
|
| 17 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
h0, w0 = img_bgr.shape[:2]
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
#
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
rot_img = cv2.warpAffine(img_bgr, M, (nW, nH), borderValue=self.bg_color)
|
| 32 |
-
|
| 33 |
-
# Rotate Box Points
|
| 34 |
-
pts = np.array([[hx1,hy1,1],[hx2,hy1,1],[hx2,hy2,1],[hx1,hy2,1]]).T
|
| 35 |
-
rot_pts = M @ pts
|
| 36 |
-
rx, ry = rot_pts[0,:], rot_pts[1,:]
|
| 37 |
-
return rot_img, (int(rx.min()), int(ry.min()), int(rx.max()), int(ry.max()))
|
| 38 |
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
# Canvas Crop
|
| 50 |
-
H, W = img.shape[:2]
|
| 51 |
-
canvas = np.full((by2-by1, bx2-bx1, 3), self.bg_color, dtype=np.uint8)
|
| 52 |
-
sx1, sy1 = max(0, bx1), max(0, by1)
|
| 53 |
-
sx2, sy2 = min(W, bx2), min(H, by2)
|
| 54 |
-
dx1, dy1 = max(0, sx1-bx1), max(0, sy1-by1)
|
| 55 |
-
if sx2>sx1 and sy2>sy1: canvas[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = img[sy1:sy2, sx1:sx2]
|
| 56 |
-
return cv2.resize(canvas, (self.out_w, self.out_h), interpolation=cv2.INTER_AREA)
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
import numpy as np
|
| 3 |
|
| 4 |
class PassportCropper:
|
| 5 |
+
def __init__(self, output_size=(413, 531), bg_color=(255, 255, 255)):
|
| 6 |
+
# Default size: 3.5cm x 4.5cm @ 300 DPI
|
| 7 |
self.out_w, self.out_h = output_size
|
| 8 |
self.bg_color = tuple(int(x) for x in bg_color)
|
| 9 |
self.target_aspect = self.out_w / self.out_h
|
| 10 |
|
| 11 |
def composite(self, img_rgb, mask):
|
| 12 |
+
"""Standard background removal composite"""
|
| 13 |
bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
|
| 14 |
+
if mask is None: return bgr
|
| 15 |
+
|
| 16 |
+
# Blur mask slightly for better blending
|
| 17 |
m = cv2.GaussianBlur(mask, (5,5), 0)
|
| 18 |
alpha = (m.astype(float) / 255.0)[:,:,None]
|
| 19 |
bg = np.full_like(bgr, np.array(self.bg_color, dtype=np.uint8))
|
| 20 |
+
|
| 21 |
+
# Blend
|
| 22 |
return (bgr.astype(float) * alpha + bg.astype(float) * (1.0 - alpha)).astype(np.uint8)
|
| 23 |
|
| 24 |
+
def crop_with_dynamic_zoom(self, img_bgr, angle_deg, raw_face_box, head_percent=0.75):
|
| 25 |
+
"""
|
| 26 |
+
REPLACES 'rotate_and_expand_face' AND 'crop_to_ratio'.
|
| 27 |
+
|
| 28 |
+
This function guarantees the face height is exactly 'head_percent' (75%)
|
| 29 |
+
of the final image height, performing rotation and padding in one step.
|
| 30 |
+
"""
|
| 31 |
h0, w0 = img_bgr.shape[:2]
|
| 32 |
+
fx, fy, fw, fh = raw_face_box
|
| 33 |
+
|
| 34 |
+
# 1. Estimate Real Head Height (Chin to Top of Hair)
|
| 35 |
+
# Haar usually detects eyebrows to chin. We multiply by 1.35 to get full head.
|
| 36 |
+
estimated_head_h = fh * 1.35
|
| 37 |
+
|
| 38 |
+
# 2. Calculate the Required Canvas Height based on the 75% rule
|
| 39 |
+
# If head needs to be 75%, then Canvas = Head / 0.75
|
| 40 |
+
required_crop_h = int(estimated_head_h / head_percent)
|
| 41 |
+
|
| 42 |
+
# 3. Calculate Width based on the Aspect Ratio of the output (e.g. 3.5cm / 4.5cm)
|
| 43 |
+
required_crop_w = int(required_crop_h * self.target_aspect)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
+
# 4. Find the center of the face
|
| 46 |
+
face_center_x = fx + fw // 2
|
| 47 |
+
face_center_y = fy + fh // 2
|
| 48 |
+
|
| 49 |
+
# 5. Determine the center of the Crop
|
| 50 |
+
# Eyes are usually at 45-50% from the top, not dead center.
|
| 51 |
+
# We shift the crop box UP slightly so the face sits nicely.
|
| 52 |
+
shift_y = int(required_crop_h * 0.05)
|
| 53 |
+
center_x = face_center_x
|
| 54 |
+
center_y = face_center_y - shift_y
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
|
| 56 |
+
# 6. Create the Rotation Matrix
|
| 57 |
+
# We rotate around the calculated center
|
| 58 |
+
M = cv2.getRotationMatrix2D((center_x, center_y), -angle_deg, 1.0)
|
| 59 |
+
|
| 60 |
+
# 7. Modify the Matrix for Translation
|
| 61 |
+
# This is the math magic: We tell OpenCV to move the (center_x, center_y)
|
| 62 |
+
# to the center of our new (required_crop_w, required_crop_h) image.
|
| 63 |
+
M[0, 2] += (required_crop_w / 2) - center_x
|
| 64 |
+
M[1, 2] += (required_crop_h / 2) - center_y
|
| 65 |
+
|
| 66 |
+
# 8. Generate the Final Image
|
| 67 |
+
# cv2.warpAffine does the rotation, cropping, and background padding all at once.
|
| 68 |
+
result = cv2.warpAffine(
|
| 69 |
+
img_bgr,
|
| 70 |
+
M,
|
| 71 |
+
(required_crop_w, required_crop_h),
|
| 72 |
+
borderValue=self.bg_color,
|
| 73 |
+
flags=cv2.INTER_LINEAR
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# 9. Resize to the final output pixels (e.g. 413x531)
|
| 77 |
+
return cv2.resize(result, (self.out_w, self.out_h), interpolation=cv2.INTER_AREA)
|
server.py
CHANGED
|
@@ -268,9 +268,12 @@ def create_app() -> Flask:
|
|
| 268 |
cropper = PassportCropper(output_size=output_size, bg_color=selected_bg)
|
| 269 |
|
| 270 |
img_clean = cropper.composite(img_rgb, mask)
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
| 274 |
# Return result
|
| 275 |
is_success, buffer = cv2.imencode(".jpg", final_passport)
|
| 276 |
return send_file(io.BytesIO(buffer), mimetype='image/jpeg')
|
|
|
|
| 268 |
cropper = PassportCropper(output_size=output_size, bg_color=selected_bg)
|
| 269 |
|
| 270 |
img_clean = cropper.composite(img_rgb, mask)
|
| 271 |
+
final_passport = cropper.crop_with_dynamic_zoom(
|
| 272 |
+
img_clean,
|
| 273 |
+
angle,
|
| 274 |
+
(x, y, w, h),
|
| 275 |
+
head_percent=0.75
|
| 276 |
+
)
|
| 277 |
# Return result
|
| 278 |
is_success, buffer = cv2.imencode(".jpg", final_passport)
|
| 279 |
return send_file(io.BytesIO(buffer), mimetype='image/jpeg')
|