334 lines
12 KiB
Python
334 lines
12 KiB
Python
import math
|
||
import os
|
||
import cv2
|
||
import yaml
|
||
import numpy as np
|
||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||
|
||
# Canvas Configuration
|
||
CANVAS_SIZE = (1920, 1080)
|
||
BG_COLOR_START = (240, 242, 245)
|
||
BG_COLOR_END = (200, 210, 230)
|
||
|
||
# Colors
|
||
PRIMARY_COLOR = (23, 43, 77) # Dark Blue for headings
|
||
SECONDARY_COLOR = (94, 108, 132) # Grey for secondary text
|
||
ACCENT_COLOR = (0, 82, 204) # Bright Blue for highlights/numbers
|
||
CARD_BG_COLOR = (255, 255, 255)
|
||
CARD_SHADOW_COLOR = (9, 30, 66, 40) # Shadow color
|
||
|
||
INSTRUCTION_TITLE = "访客预约流程"
|
||
INSTRUCTION_SUBTITLE = "Visitor Registration Process"
|
||
INSTRUCTION_STEPS = [
|
||
"第一步:扫码关注“康达新材”公众号。",
|
||
"第二步:点击【关于我们】→【我是访客】进入“访客注册”界面,填写并上传相应信息并点击“提交”。",
|
||
"第三步:将第二步信息提交完毕后在“访客预约”界面选择右下角的“+”号按钮,填写预约信息。",
|
||
"第四步:请仔细阅读安全告知书,点击我知道了。",
|
||
"第五步:填写被访人信息及来访事由等内容并提交。",
|
||
"第六步:显示提交成功,并且手机、微信会收到预约相关短信通知。",
|
||
]
|
||
|
||
def load_config(path="config.yaml"):
|
||
try:
|
||
with open(path, "r", encoding="utf-8") as f:
|
||
return yaml.safe_load(f)
|
||
except FileNotFoundError:
|
||
return {}
|
||
|
||
def resolve_font(size: int, preferred: str | None = None) -> ImageFont.FreeTypeFont:
|
||
candidates = [preferred] if preferred else []
|
||
candidates += [
|
||
"C:/Windows/Fonts/msyhbd.ttc", # Microsoft YaHei Bold
|
||
"C:/Windows/Fonts/msyh.ttc", # Microsoft YaHei
|
||
"C:/Windows/Fonts/simhei.ttf",
|
||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Bold.ttc",
|
||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||
"/usr/share/fonts/truetype/noto/NotoMono-Regular.ttf",
|
||
]
|
||
for path in candidates:
|
||
if path and os.path.exists(path):
|
||
try:
|
||
return ImageFont.truetype(path, size)
|
||
except OSError:
|
||
continue
|
||
return ImageFont.load_default()
|
||
|
||
def wrap_text(text: str, font: ImageFont.FreeTypeFont, max_width: int, draw: ImageDraw.ImageDraw):
|
||
lines = []
|
||
current = ""
|
||
for ch in text:
|
||
tentative = current + ch
|
||
# Pillow 10+ uses textlength, older uses textsize
|
||
try:
|
||
width = draw.textlength(tentative, font=font)
|
||
except AttributeError:
|
||
width, _ = draw.textsize(tentative, font=font)
|
||
|
||
if width <= max_width:
|
||
current = tentative
|
||
else:
|
||
if current:
|
||
lines.append(current)
|
||
current = ch
|
||
if current:
|
||
lines.append(current)
|
||
return lines
|
||
|
||
def create_gradient_background(size: tuple[int, int], start_color: tuple[int, int, int], end_color: tuple[int, int, int]) -> Image.Image:
|
||
width, height = size
|
||
# Vertical gradient
|
||
base = Image.new('RGB', size, start_color)
|
||
top = Image.new('RGB', size, end_color)
|
||
mask = Image.new('L', size)
|
||
mask_data = np.tile(np.linspace(0, 255, height, dtype=np.uint8), (width, 1)).T
|
||
mask = Image.fromarray(mask_data, 'L')
|
||
return Image.composite(top, base, mask)
|
||
|
||
def draw_rounded_rect(draw, box, radius, fill, outline=None, width=0):
|
||
draw.rounded_rectangle(box, radius=radius, fill=fill, outline=outline, width=width)
|
||
|
||
def draw_shadow(image, box, radius, offset=(0, 4), blur=10, shadow_color=(0,0,0,50)):
|
||
# Create a separate shadow layer
|
||
shadow_layer = Image.new('RGBA', image.size, (0,0,0,0))
|
||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||
|
||
sx0, sy0, sx1, sy1 = box
|
||
dx, dy = offset
|
||
|
||
shadow_box = (sx0 + dx, sy0 + dy, sx1 + dx, sy1 + dy)
|
||
shadow_draw.rounded_rectangle(shadow_box, radius=radius, fill=shadow_color)
|
||
|
||
# Blur the shadow
|
||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(blur))
|
||
|
||
# Composite
|
||
image.alpha_composite(shadow_layer)
|
||
|
||
def build_canvas(qr_image: np.ndarray, title_font, subtitle_font, body_font, step_title_font, badge_font) -> np.ndarray:
|
||
canvas_width, canvas_height = CANVAS_SIZE
|
||
|
||
# 1. Background
|
||
bg = create_gradient_background(CANVAS_SIZE, (245, 247, 250), (223, 225, 230)).convert("RGBA")
|
||
|
||
draw = ImageDraw.Draw(bg, "RGBA")
|
||
|
||
# Layout Constants
|
||
MARGIN = 50
|
||
GUTTER = 40
|
||
|
||
# Left Panel (QR Code) - 35% width approx
|
||
left_width = int((canvas_width - 2 * MARGIN - GUTTER) * 0.35)
|
||
right_width = canvas_width - 2 * MARGIN - GUTTER - left_width
|
||
|
||
left_box = (MARGIN, MARGIN, MARGIN + left_width, canvas_height - MARGIN)
|
||
right_box = (MARGIN + left_width + GUTTER, MARGIN, canvas_width - MARGIN, canvas_height - MARGIN)
|
||
|
||
# --- Draw Left Panel ---
|
||
# Shadow
|
||
draw_shadow(bg, left_box, radius=30, offset=(0, 10), blur=20, shadow_color=(0,0,0,30))
|
||
# Card
|
||
draw_rounded_rect(draw, left_box, radius=30, fill=(255, 255, 255, 255))
|
||
|
||
# Left Content
|
||
cx = (left_box[0] + left_box[2]) // 2
|
||
cy_top = left_box[1] + 120
|
||
|
||
# Title
|
||
text = "访客登记"
|
||
try:
|
||
w = draw.textlength(text, font=title_font)
|
||
except:
|
||
w, _ = draw.textsize(text, font=title_font)
|
||
draw.text((cx - w/2, cy_top), text, font=title_font, fill=PRIMARY_COLOR)
|
||
|
||
# Subtitle
|
||
text = "Visitor Registration"
|
||
try:
|
||
w = draw.textlength(text, font=subtitle_font)
|
||
except:
|
||
w, _ = draw.textsize(text, font=subtitle_font)
|
||
draw.text((cx - w/2, cy_top + 70), text, font=subtitle_font, fill=SECONDARY_COLOR)
|
||
|
||
# QR Code
|
||
qr_size = min(left_width - 100, 500)
|
||
qr_y = cy_top + 180
|
||
|
||
# Resize QR
|
||
qr_pil = Image.fromarray(cv2.cvtColor(qr_image, cv2.COLOR_BGR2RGB))
|
||
qr_pil = qr_pil.resize((qr_size, qr_size), Image.LANCZOS)
|
||
bg.paste(qr_pil, (cx - qr_size//2, qr_y))
|
||
|
||
# Scan Hint
|
||
hint_y = qr_y + qr_size + 50
|
||
hint_text = "请使用微信扫码登记"
|
||
try:
|
||
w = draw.textlength(hint_text, font=step_title_font)
|
||
except:
|
||
w, _ = draw.textsize(hint_text, font=step_title_font)
|
||
|
||
# Draw a pill background for hint
|
||
pill_padding = 20
|
||
pill_box = (cx - w/2 - pill_padding, hint_y - pill_padding, cx + w/2 + pill_padding, hint_y + 40 + pill_padding)
|
||
draw.rounded_rectangle(pill_box, radius=30, fill=(240, 242, 245), outline=None)
|
||
draw.text((cx - w/2, hint_y), hint_text, font=step_title_font, fill=ACCENT_COLOR)
|
||
|
||
# --- Draw Right Panel ---
|
||
|
||
# Right Title Area - Compacted
|
||
rt_y = MARGIN + 20
|
||
draw.text((right_box[0], rt_y), INSTRUCTION_TITLE, font=title_font, fill=PRIMARY_COLOR)
|
||
draw.text((right_box[0], rt_y + 60), INSTRUCTION_SUBTITLE, font=subtitle_font, fill=SECONDARY_COLOR)
|
||
|
||
# Separator Line
|
||
sep_y = rt_y + 110
|
||
draw.line((right_box[0], sep_y, right_box[2], sep_y), fill=(200, 200, 200), width=2)
|
||
|
||
# Grid Configuration
|
||
grid_y_start = sep_y + 40
|
||
grid_width = right_width
|
||
cols = 2
|
||
col_gap = 30
|
||
row_gap = 30
|
||
|
||
col_width = (grid_width - (cols - 1) * col_gap) // cols
|
||
|
||
# Pre-calculate text layout to find uniform height
|
||
max_lines = 0
|
||
processed_steps = []
|
||
|
||
padding = 30
|
||
badge_size = 50
|
||
text_left_margin = badge_size + 20
|
||
|
||
# Calculate available width for text inside a card
|
||
text_max_width = col_width - padding * 2 - text_left_margin
|
||
|
||
for i, step in enumerate(INSTRUCTION_STEPS):
|
||
# Remove "第一步:" etc prefix if present to make it cleaner, we have badges
|
||
clean_step = step.split(":", 1)[-1] if ":" in step else step
|
||
|
||
lines = wrap_text(clean_step, body_font, text_max_width, draw)
|
||
processed_steps.append(lines)
|
||
max_lines = max(max_lines, len(lines))
|
||
|
||
# Calculate Card Height
|
||
# Padding top + max_lines * line_height + Padding bottom
|
||
line_height = body_font.size + 10
|
||
card_content_height = max(badge_size, max_lines * line_height)
|
||
uniform_card_height = int(padding * 2 + card_content_height)
|
||
|
||
# Check if we overflow canvas height
|
||
total_grid_height = 3 * uniform_card_height + 2 * row_gap
|
||
if grid_y_start + total_grid_height > canvas_height - MARGIN:
|
||
print(f"Warning: Content might overflow vertically. Required: {grid_y_start + total_grid_height}, Available: {canvas_height - MARGIN}")
|
||
# Dynamic adjustment if needed (e.g. reduce gaps) but for now we rely on the font resizing done in main()
|
||
|
||
# Draw Grid
|
||
for idx, lines in enumerate(processed_steps):
|
||
row = idx // cols
|
||
col = idx % cols
|
||
|
||
x = right_box[0] + col * (col_width + col_gap)
|
||
y = grid_y_start + row * (uniform_card_height + row_gap)
|
||
|
||
card_box = (x, y, x + col_width, y + uniform_card_height)
|
||
|
||
# Card Shadow
|
||
draw_shadow(bg, card_box, radius=20, offset=(0, 4), blur=12, shadow_color=(0,0,0,15))
|
||
# Card Body
|
||
draw_rounded_rect(draw, card_box, radius=20, fill=(255, 255, 255))
|
||
|
||
# Badge (Step Number)
|
||
bx = x + padding
|
||
by = y + padding
|
||
draw.ellipse((bx, by, bx + badge_size, by + badge_size), fill=ACCENT_COLOR)
|
||
|
||
num_text = str(idx + 1)
|
||
try:
|
||
nw = draw.textlength(num_text, font=badge_font)
|
||
except:
|
||
nw, _ = draw.textsize(num_text, font=badge_font)
|
||
# Center number
|
||
draw.text((bx + (badge_size - nw)/2, by + (badge_size - badge_font.size)/2 - 2),
|
||
num_text, font=badge_font, fill=(255, 255, 255))
|
||
|
||
# Text
|
||
tx = x + padding + text_left_margin
|
||
ty = y + padding
|
||
|
||
for line in lines:
|
||
draw.text((tx, ty), line, font=body_font, fill=PRIMARY_COLOR)
|
||
ty += line_height
|
||
|
||
return cv2.cvtColor(np.array(bg.convert("RGB")), cv2.COLOR_RGB2BGR)
|
||
|
||
def create_mock_qrcode(size: int = 640) -> np.ndarray:
|
||
img = np.full((size, size, 3), 255, dtype=np.uint8) # White background
|
||
|
||
# Draw some patterns
|
||
block = size // 15
|
||
for y in range(0, size, block):
|
||
for x in range(0, size, block):
|
||
if (x // block + y // block) % 2 == 0:
|
||
color = (0, 0, 0)
|
||
# Corner markers
|
||
if (x < 3*block and y < 3*block) or (x > size-4*block and y < 3*block) or (x < 3*block and y > size-4*block):
|
||
color = (0, 0, 0)
|
||
elif np.random.rand() > 0.3:
|
||
color = (0, 0, 0)
|
||
else:
|
||
color = (255, 255, 255)
|
||
|
||
cv2.rectangle(img, (x, y), (x + block, y + block), color, -1)
|
||
|
||
# Borders for markers
|
||
marker_len = 3 * block
|
||
thickness = block
|
||
# Top Left
|
||
cv2.rectangle(img, (0,0), (marker_len, marker_len), (0,0,0), thickness)
|
||
# Top Right
|
||
cv2.rectangle(img, (size-marker_len,0), (size, marker_len), (0,0,0), thickness)
|
||
# Bottom Left
|
||
cv2.rectangle(img, (0,size-marker_len), (marker_len, size), (0,0,0), thickness)
|
||
|
||
return img
|
||
|
||
def main():
|
||
config = load_config()
|
||
preferred_font = config.get("display", {}).get("font_path") if config else None
|
||
|
||
# Define Fonts - Adjusted sizes for better fit
|
||
# Title for left/right headers
|
||
title_font = resolve_font(56, preferred_font)
|
||
# Subtitles
|
||
subtitle_font = resolve_font(30, preferred_font)
|
||
# Step text - Reduced to ensure fit
|
||
body_font = resolve_font(24, preferred_font)
|
||
# Step titles or emphasis
|
||
step_title_font = resolve_font(28, preferred_font)
|
||
# Badge numbers
|
||
badge_font = resolve_font(30, preferred_font)
|
||
|
||
# Mock data
|
||
mock_qr = create_mock_qrcode(500)
|
||
|
||
# Build
|
||
canvas = build_canvas(mock_qr, title_font, subtitle_font, body_font, step_title_font, badge_font)
|
||
|
||
# Display
|
||
window_name = "Visitor Registration Preview"
|
||
cv2.namedWindow(window_name, cv2.WINDOW_NORMAL)
|
||
cv2.resizeWindow(window_name, 1280, 720)
|
||
cv2.imshow(window_name, canvas)
|
||
|
||
print("Displaying preview. Press any key to exit.")
|
||
cv2.waitKey(0)
|
||
cv2.destroyAllWindows()
|
||
|
||
# Save for verification
|
||
cv2.imwrite("preview_qrcode_sota.png", canvas)
|
||
print("Saved preview to preview_qrcode_sota.png")
|
||
|
||
if __name__ == "__main__":
|
||
main()
|