#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 生成布里斯托大便分类法(Bristol Stool Scale)类型 1-7 示意图。 输出:cfc-frontend/static/health/bristol/bristol-{1..7}.png(200x200 透明底 PNG) 用法:python3 scripts/gen-bristol-stool-images.py 依赖:Pillow 风格:暖棕配色(#8C6444 主体 / #684830 描边),与问卷暖色主题一致; 4 倍超采样后 LANCZOS 缩放,保证小程序端边缘平滑。 """ import math import os import random from PIL import Image, ImageDraw OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "static", "health", "bristol") SUPER = 4 # 超采样倍数 SIZE = 200 # 最终尺寸 S = SIZE * SUPER # 绘制画布尺寸 BODY = (140, 100, 68, 255) # 主体 BODY_LIGHT = (176, 136, 100, 255) # 高光 EDGE = (104, 72, 48, 255) # 描边 CRACK = (94, 66, 44, 255) # 裂痕 random.seed(20261018) def new_canvas(): img = Image.new("RGBA", (S, S), (0, 0, 0, 0)) return img, ImageDraw.Draw(img) def outlined_ellipse(draw, cx, cy, rx, ry, outline=5): """带描边的椭圆""" draw.ellipse([cx - rx - outline, cy - ry - outline, cx + rx + outline, cy + ry + outline], fill=EDGE) draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=BODY) def outlined_chain(draw, pts, r, outline=5): """圆形链:先画描边层再画主体层,内部不出现多余描边""" for (x, y) in pts: draw.ellipse([x - r - outline, y - r - outline, x + r + outline, y + r + outline], fill=EDGE) for (x, y) in pts: draw.ellipse([x - r, y - r, x + r, y + r], fill=BODY) def outlined_polygon(draw, pts, outline=5): """多边形:等比放大后作描边层""" cx = sum(p[0] for p in pts) / len(pts) cy = sum(p[1] for p in pts) / len(pts) scale = 1.0 + outline / 60.0 big = [(cx + (x - cx) * scale, cy + (y - cy) * scale) for (x, y) in pts] draw.polygon(big, fill=EDGE) draw.polygon(pts, fill=BODY) def ragged_pts(cx, cy, rx, ry, n=48, noise=0.10): """不规则(毛边)轮廓点集""" pts = [] for i in range(n): a = 2 * math.pi * i / n k = 1 + noise * math.sin(a * 3.1) + noise * 0.7 * math.sin(a * 7.7) pts.append((cx + rx * k * math.cos(a), cy + ry * k * math.sin(a))) return pts def curve(p0, p1, p2, steps=40): """二次贝塞尔采样,用于平滑弯曲的肠状轮廓""" out = [] for i in range(steps + 1): t = i / steps x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0] y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1] out.append((x, y)) return out def highlight(draw, cx, cy, rx, ry): """柔和高光(半透明,模拟体积)""" layer = Image.new("RGBA", (S, S), (0, 0, 0, 0)) ld = ImageDraw.Draw(layer) ld.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=(255, 245, 235, 70)) return Image.alpha_composite(draw._image, layer) def save(img, n): out = os.path.join(OUT_DIR, "bristol-%d.png" % n) img.resize((SIZE, SIZE), Image.LANCZOS).save(out, "PNG", optimize=True) print("已生成", os.path.relpath(out), os.path.getsize(out), "bytes") # ---- Type 1:一粒粒硬球(很难排出) ---- def type1(): img, d = new_canvas() for (cx, cy, r) in [(300, 300, 88), (500, 268, 76), (338, 512, 72), (536, 500, 80)]: outlined_ellipse(d, cx, cy, r, r * 0.94) return img # ---- Type 2:香肠状,表面凹凸(由小块拼成) ---- def type2(): img, d = new_canvas() pts = [] radii = [46, 62, 52, 68, 50, 66, 54, 70, 48, 60, 42] for i, r in enumerate(radii): t = i / float(len(radii) - 1) x = 110 + t * 580 y = 400 + 34 * math.sin(t * math.pi * 1.5) + (16 if i % 2 else -14) pts.append((x, y, r)) for (x, y, r) in pts: d.ellipse([x - r - 5, y - r - 5, x + r + 5, y + r + 5], fill=EDGE) for (x, y, r) in pts: d.ellipse([x - r, y - r, x + r, y + r], fill=BODY) return img # ---- Type 3:香肠状,表面有裂痕(正常偏干) ---- def type3(): img, d = new_canvas() path = curve((130, 430), (400, 300), (670, 410)) outlined_chain(d, [(x, y) for (x, y) in path], 78) # 沿肠体分布的不规则裂痕(垂直于走向,略带角度) for t, ang, length in ((0.22, -16, 118), (0.38, 12, 132), (0.56, -10, 108), (0.72, 18, 124), (0.88, -8, 96)): i = int(t * (len(path) - 1)) x, y = path[i] i2 = min(len(path) - 1, i + 1) dx = path[i2][0] - x dy = path[i2][1] - y base = math.atan2(dy, dx) + math.pi / 2 + math.radians(ang) hx = math.cos(base) * length / 2.0 hy = math.sin(base) * length / 2.0 d.line([x - hx, y - hy, x + hx, y + hy], fill=CRACK, width=9) d.ellipse([x - hx - 4, y - hy - 4, x - hx + 4, y - hy + 4], fill=CRACK) d.ellipse([x + hx - 4, y + hy - 4, x + hx + 4, y + hy + 4], fill=CRACK) return img # ---- Type 4:光滑柔软(理想:像香肠或蛇) ---- def type4(): img, d = new_canvas() path = curve((120, 470), (400, 250), (690, 430), steps=60) outlined_chain(d, path, 74) # 背部高光带 for i in range(6, len(path) - 6, 3): x, y = path[i] d.ellipse([x - 26, y - 44, x + 26, y - 16], fill=BODY_LIGHT) return img # ---- Type 5:软块状,边缘清晰(缺乏纤维) ---- def type5(): img, d = new_canvas() for (cx, cy, rx, ry) in [(268, 300, 86, 72), (512, 288, 78, 68), (240, 520, 74, 66), (470, 528, 92, 74), (660, 420, 66, 60)]: outlined_ellipse(d, cx, cy, rx, ry) return img # ---- Type 6:糊状,边缘不清(蓬松) ---- def type6(): img, d = new_canvas() for (cx, cy, rx, ry) in [(300, 320, 130, 100), (560, 300, 110, 88), (330, 540, 118, 92), (590, 540, 126, 96)]: outlined_polygon(d, ragged_pts(cx, cy, rx, ry, noise=0.14)) return img # ---- Type 7:水样,无固形物(腹泻) ---- def type7(): img, d = new_canvas() puddle = ragged_pts(400, 430, 290, 132, n=72, noise=0.09) outlined_polygon(d, puddle) outlined_ellipse(d, 250, 190, 46, 38) outlined_ellipse(d, 566, 168, 34, 28) return img def main(): os.makedirs(OUT_DIR, exist_ok=True) for n, fn in enumerate([type1, type2, type3, type4, type5, type6, type7], start=1): save(fn(), n) if __name__ == "__main__": main()