Coverage for piemap/bitmap.py: 56.73%

76 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-02-04 21:26:31 +00:00

1from typing import no_type_check 

2 

3from PIL import Image, ImageDraw, ImageFont # type: ignore 

4 

5import piemap.geometry as geom 

6from piemap import ( 

7 ANGLE_MAX, 

8 ANGLE_OFF, 

9 CIRCLE_CENTER, 

10 GRAY, 

11 GREEN, 

12 HEIGHT, 

13 HEIGHT_OFF, 

14 LABEL_SIZE, 

15 LINE_WIDTH, 

16 PIE_BOX, 

17 RADIUS, 

18 RED, 

19 SUBTITLE_SIZE, 

20 TITLE_SIZE, 

21 TOP_LEFT_X, 

22 TOP_LEFT_Y, 

23 WHITE, 

24 WIDTH, 

25 WIDTH_HALF, 

26 YELLOW, 

27) 

28 

29FONT_PATH = '../FreeMono.ttf' 

30OUT_PNG_PATH = '../bitmap.png' 

31FAKE_EPS_DEGREES = 0.05 

32 

33Coord = tuple[float, float] 

34BBox = tuple[Coord, Coord] 

35 

36 

37@no_type_check 

38def start_of(n: int, n_max: int) -> float: 

39 """Start angle in values [0, MAX] first ref is 12 o'clock (OFF) with the of sector n of [1, N].""" 

40 fractional_angle = ANGLE_MAX / n_max 

41 return (n * fractional_angle + ANGLE_OFF - fractional_angle / 2) % ANGLE_MAX 

42 

43 

44@no_type_check 

45def middle_of(n: int, n_max: int) -> float: 

46 """Middle angle (axis) in values [0, MAX] first ref is 12 o'clock (OFF) with the of sector n of [1, N].""" 

47 return (n * ANGLE_MAX / n_max + ANGLE_OFF) % ANGLE_MAX 

48 

49 

50@no_type_check 

51def end_of(n: int, n_max: int) -> float: 

52 """End angle in values [0, MAX] first ref is 12 o'clock (OFF) with the of sector n of [1, N].""" 

53 return (start_of(n, n_max) + ANGLE_MAX / n_max) % ANGLE_MAX 

54 

55 

56@no_type_check 

57def bbox_from_radii(ref_r: int, r: float) -> BBox: 

58 """Return bbox as left upper and right lower x,y tuples from radius within reference radius""" 

59 shift = ref_r - r 

60 return (TOP_LEFT_X + shift, TOP_LEFT_Y + shift), (TOP_LEFT_X + ref_r + r, TOP_LEFT_Y + ref_r + r) 

61 

62 

63@no_type_check 

64def extrude_bbox_by(bbox: BBox, extrusion: int) -> BBox: 

65 """Extrude bounding box symmetrically by extrusion amount.""" 

66 return (bbox[0][0] - extrusion, bbox[0][1] - extrusion), (bbox[1][0] + extrusion, bbox[1][1] + extrusion) 

67 

68 

69@no_type_check 

70def fake_full_circle(angle: float) -> Coord: 

71 """Fake a full circle to reduce the number of functions used.""" 

72 return angle, angle - FAKE_EPS_DEGREES 

73 

74 

75@no_type_check 

76def fake_line(angle: float) -> Coord: 

77 """Fake a line to reduce the number of functions used.""" 

78 return angle, angle + FAKE_EPS_DEGREES 

79 

80 

81@no_type_check 

82def draw_titles(engine, title: str, subtitle: str) -> None: 

83 """Draw the title and subtitle centered on top of the pie and adjust placement for text length.""" 

84 t_fnt = ImageFont.truetype(FONT_PATH, TITLE_SIZE) 

85 st_fnt = ImageFont.truetype(FONT_PATH, SUBTITLE_SIZE) 

86 t_len = engine.textlength(title, font=t_fnt) 

87 st_len = engine.textlength(subtitle, font=st_fnt) 

88 engine.multiline_text((WIDTH_HALF - int(t_len / 2), HEIGHT_OFF), title, font=t_fnt, fill=(0, 0, 0), align='center') 

89 engine.multiline_text( 

90 (WIDTH_HALF - int(st_len / 2), HEIGHT_OFF + TITLE_SIZE), subtitle, font=st_fnt, fill=(0, 0, 0), align='center' 

91 ) 

92 

93 

94@no_type_check 

95def draw_label_at(engine, label: str, at: Coord) -> None: 

96 """Draw the label starting at position.""" 

97 fnt = ImageFont.truetype(FONT_PATH, LABEL_SIZE) 

98 l_len = engine.textlength(label, font=fnt) 

99 engine.multiline_text((at[0] - int(l_len / 2), at[1]), label, font=fnt, fill=(0, 0, 0), align='center') 

100 

101 

102@no_type_check 

103def to_plot(xy: Coord) -> Coord: 

104 """Map [0, 1] x [0, 1] onto the plot area that goes east, south.""" 

105 return NotImplemented 

106 

107 

108@no_type_check 

109def render(values: tuple[float | None, ...]) -> None: 

110 """Make importable to support tests.""" 

111 n_dim = len(values) 

112 

113 # create an image for prototyping 

114 im = Image.new(mode='RGBA', size=(WIDTH, HEIGHT), color=WHITE) 

115 

116 # create prototyping draw context 

117 draw = ImageDraw.Draw(im) 

118 

119 # All good if above disc at 80% all sectors below receive proportional red coloring adding to the yellow 

120 draw.pieslice(bbox_from_radii(RADIUS, RADIUS * 0.80), *fake_full_circle(ANGLE_OFF), fill=RED) 

121 

122 # Inner disc to hide singularity noise at center 

123 draw.pieslice(bbox_from_radii(RADIUS, RADIUS * 0.03), *fake_full_circle(ANGLE_OFF), fill=GRAY) 

124 

125 # The sectors 

126 for n, val in enumerate(values): 

127 if val is None: 

128 draw.pieslice(bbox_from_radii(RADIUS, RADIUS), start_of(n, n_dim), end_of(n, n_dim), fill=WHITE) 

129 continue 

130 

131 color = YELLOW if val < RADIUS * 0.80 else GREEN 

132 draw.pieslice(bbox_from_radii(RADIUS, val), start_of(n, n_dim), end_of(n, n_dim), fill=color) 

133 

134 draw_label_at(draw, '^', CIRCLE_CENTER) # protonly 

135 draw_label_at(draw, 'L', (CIRCLE_CENTER[0] - RADIUS, CIRCLE_CENTER[1] + RADIUS)) # protonly 

136 # The axes and dim=value labels 

137 for n, val in enumerate(values): 

138 draw.pieslice( 

139 extrude_bbox_by(PIE_BOX, 15), *fake_line(middle_of(n, n_dim)), fill=GRAY, outline=GRAY, width=LINE_WIDTH 

140 ) 

141 coords = geom.xy_point_from_radius_angle(RADIUS, middle_of(n, n_dim), *CIRCLE_CENTER) 

142 if val is None: 

143 val_disp = 'n/a' 

144 else: 

145 val_disp = f'{round(val, 1)}|{round(val/RADIUS, 1)}' # |{[round(c, 1) for c in coords]}' 

146 # ang_quest = (middle_of(n, n_dim) - ANGLE_OFF) % 360 

147 # val_disp = f'{round(ang_quest, 1)}|{[round(c, 1) for c in coords]}' 

148 # draw_label_at(draw, f'd[{n}]={val_disp}', (PIE_BOX[0][0], PIE_BOX[0][1] + n * LABEL_SIZE)) 

149 draw_label_at(draw, f'd[{n}]={val_disp}', coords) 

150 

151 # outer circle (axis marker joining all dimensions) 

152 draw.pieslice( 

153 bbox_from_radii(RADIUS, RADIUS * 1.00), *fake_full_circle(ANGLE_OFF), fill=None, outline=GRAY, width=LINE_WIDTH 

154 ) 

155 

156 # All good if above disc at 80% circle only as marker 

157 draw.pieslice( 

158 bbox_from_radii(RADIUS, RADIUS * 0.80), *fake_full_circle(ANGLE_OFF), fill=None, outline=GRAY, width=1 

159 ) 

160 

161 # title and sub title 

162 draw_titles(draw, title='Hällo', subtitle='Wörldß') 

163 del draw 

164 

165 # im.resize((640, 640), resample=Image.Resampling.LANCZOS) 

166 im.save(OUT_PNG_PATH, 'PNG') 

167 

168 im.show() 

169 

170 

171if __name__ == '__main__': # pragma: no cover 

172 R = RADIUS 

173 r = (R, R * 0.90, R * 0.80, R * 0.70, R * 0.65, R * 0.60, R * 0.55, 0, None) 

174 render(r)