gen-bristol-stool-images.py 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 生成布里斯托大便分类法(Bristol Stool Scale)类型 1-7 示意图。
  5. 输出:cfc-frontend/static/health/bristol/bristol-{1..7}.png(200x200 透明底 PNG)
  6. 用法:python3 scripts/gen-bristol-stool-images.py
  7. 依赖:Pillow
  8. 风格:暖棕配色(#8C6444 主体 / #684830 描边),与问卷暖色主题一致;
  9. 4 倍超采样后 LANCZOS 缩放,保证小程序端边缘平滑。
  10. """
  11. import math
  12. import os
  13. import random
  14. from PIL import Image, ImageDraw
  15. OUT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "static", "health", "bristol")
  16. SUPER = 4 # 超采样倍数
  17. SIZE = 200 # 最终尺寸
  18. S = SIZE * SUPER # 绘制画布尺寸
  19. BODY = (140, 100, 68, 255) # 主体
  20. BODY_LIGHT = (176, 136, 100, 255) # 高光
  21. EDGE = (104, 72, 48, 255) # 描边
  22. CRACK = (94, 66, 44, 255) # 裂痕
  23. random.seed(20261018)
  24. def new_canvas():
  25. img = Image.new("RGBA", (S, S), (0, 0, 0, 0))
  26. return img, ImageDraw.Draw(img)
  27. def outlined_ellipse(draw, cx, cy, rx, ry, outline=5):
  28. """带描边的椭圆"""
  29. draw.ellipse([cx - rx - outline, cy - ry - outline, cx + rx + outline, cy + ry + outline], fill=EDGE)
  30. draw.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=BODY)
  31. def outlined_chain(draw, pts, r, outline=5):
  32. """圆形链:先画描边层再画主体层,内部不出现多余描边"""
  33. for (x, y) in pts:
  34. draw.ellipse([x - r - outline, y - r - outline, x + r + outline, y + r + outline], fill=EDGE)
  35. for (x, y) in pts:
  36. draw.ellipse([x - r, y - r, x + r, y + r], fill=BODY)
  37. def outlined_polygon(draw, pts, outline=5):
  38. """多边形:等比放大后作描边层"""
  39. cx = sum(p[0] for p in pts) / len(pts)
  40. cy = sum(p[1] for p in pts) / len(pts)
  41. scale = 1.0 + outline / 60.0
  42. big = [(cx + (x - cx) * scale, cy + (y - cy) * scale) for (x, y) in pts]
  43. draw.polygon(big, fill=EDGE)
  44. draw.polygon(pts, fill=BODY)
  45. def ragged_pts(cx, cy, rx, ry, n=48, noise=0.10):
  46. """不规则(毛边)轮廓点集"""
  47. pts = []
  48. for i in range(n):
  49. a = 2 * math.pi * i / n
  50. k = 1 + noise * math.sin(a * 3.1) + noise * 0.7 * math.sin(a * 7.7)
  51. pts.append((cx + rx * k * math.cos(a), cy + ry * k * math.sin(a)))
  52. return pts
  53. def curve(p0, p1, p2, steps=40):
  54. """二次贝塞尔采样,用于平滑弯曲的肠状轮廓"""
  55. out = []
  56. for i in range(steps + 1):
  57. t = i / steps
  58. x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t ** 2 * p2[0]
  59. y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t ** 2 * p2[1]
  60. out.append((x, y))
  61. return out
  62. def highlight(draw, cx, cy, rx, ry):
  63. """柔和高光(半透明,模拟体积)"""
  64. layer = Image.new("RGBA", (S, S), (0, 0, 0, 0))
  65. ld = ImageDraw.Draw(layer)
  66. ld.ellipse([cx - rx, cy - ry, cx + rx, cy + ry], fill=(255, 245, 235, 70))
  67. return Image.alpha_composite(draw._image, layer)
  68. def save(img, n):
  69. out = os.path.join(OUT_DIR, "bristol-%d.png" % n)
  70. img.resize((SIZE, SIZE), Image.LANCZOS).save(out, "PNG", optimize=True)
  71. print("已生成", os.path.relpath(out), os.path.getsize(out), "bytes")
  72. # ---- Type 1:一粒粒硬球(很难排出) ----
  73. def type1():
  74. img, d = new_canvas()
  75. for (cx, cy, r) in [(300, 300, 88), (500, 268, 76), (338, 512, 72), (536, 500, 80)]:
  76. outlined_ellipse(d, cx, cy, r, r * 0.94)
  77. return img
  78. # ---- Type 2:香肠状,表面凹凸(由小块拼成) ----
  79. def type2():
  80. img, d = new_canvas()
  81. pts = []
  82. radii = [46, 62, 52, 68, 50, 66, 54, 70, 48, 60, 42]
  83. for i, r in enumerate(radii):
  84. t = i / float(len(radii) - 1)
  85. x = 110 + t * 580
  86. y = 400 + 34 * math.sin(t * math.pi * 1.5) + (16 if i % 2 else -14)
  87. pts.append((x, y, r))
  88. for (x, y, r) in pts:
  89. d.ellipse([x - r - 5, y - r - 5, x + r + 5, y + r + 5], fill=EDGE)
  90. for (x, y, r) in pts:
  91. d.ellipse([x - r, y - r, x + r, y + r], fill=BODY)
  92. return img
  93. # ---- Type 3:香肠状,表面有裂痕(正常偏干) ----
  94. def type3():
  95. img, d = new_canvas()
  96. path = curve((130, 430), (400, 300), (670, 410))
  97. outlined_chain(d, [(x, y) for (x, y) in path], 78)
  98. # 沿肠体分布的不规则裂痕(垂直于走向,略带角度)
  99. 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)):
  100. i = int(t * (len(path) - 1))
  101. x, y = path[i]
  102. i2 = min(len(path) - 1, i + 1)
  103. dx = path[i2][0] - x
  104. dy = path[i2][1] - y
  105. base = math.atan2(dy, dx) + math.pi / 2 + math.radians(ang)
  106. hx = math.cos(base) * length / 2.0
  107. hy = math.sin(base) * length / 2.0
  108. d.line([x - hx, y - hy, x + hx, y + hy], fill=CRACK, width=9)
  109. d.ellipse([x - hx - 4, y - hy - 4, x - hx + 4, y - hy + 4], fill=CRACK)
  110. d.ellipse([x + hx - 4, y + hy - 4, x + hx + 4, y + hy + 4], fill=CRACK)
  111. return img
  112. # ---- Type 4:光滑柔软(理想:像香肠或蛇) ----
  113. def type4():
  114. img, d = new_canvas()
  115. path = curve((120, 470), (400, 250), (690, 430), steps=60)
  116. outlined_chain(d, path, 74)
  117. # 背部高光带
  118. for i in range(6, len(path) - 6, 3):
  119. x, y = path[i]
  120. d.ellipse([x - 26, y - 44, x + 26, y - 16], fill=BODY_LIGHT)
  121. return img
  122. # ---- Type 5:软块状,边缘清晰(缺乏纤维) ----
  123. def type5():
  124. img, d = new_canvas()
  125. for (cx, cy, rx, ry) in [(268, 300, 86, 72), (512, 288, 78, 68),
  126. (240, 520, 74, 66), (470, 528, 92, 74), (660, 420, 66, 60)]:
  127. outlined_ellipse(d, cx, cy, rx, ry)
  128. return img
  129. # ---- Type 6:糊状,边缘不清(蓬松) ----
  130. def type6():
  131. img, d = new_canvas()
  132. for (cx, cy, rx, ry) in [(300, 320, 130, 100), (560, 300, 110, 88),
  133. (330, 540, 118, 92), (590, 540, 126, 96)]:
  134. outlined_polygon(d, ragged_pts(cx, cy, rx, ry, noise=0.14))
  135. return img
  136. # ---- Type 7:水样,无固形物(腹泻) ----
  137. def type7():
  138. img, d = new_canvas()
  139. puddle = ragged_pts(400, 430, 290, 132, n=72, noise=0.09)
  140. outlined_polygon(d, puddle)
  141. outlined_ellipse(d, 250, 190, 46, 38)
  142. outlined_ellipse(d, 566, 168, 34, 28)
  143. return img
  144. def main():
  145. os.makedirs(OUT_DIR, exist_ok=True)
  146. for n, fn in enumerate([type1, type2, type3, type4, type5, type6, type7], start=1):
  147. save(fn(), n)
  148. if __name__ == "__main__":
  149. main()