Coverage for paintmaskanimator/line_noise.py: 97%

64 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-02 04:50 +0000

1"""線のまわりの色ノイズ除去(Issue #19)。 

2 

3線の近くには、まわりがほとんど黒なのに1ドットだけ黄色、といった孤立した異色 

4ドットが残る。取り込んだ素材の圧縮ノイズや、減色でどちらとも付かなかった画素が 

5元になる。これを近傍の多数決でまわりの色へ寄せる。 

6 

7意図して置いた色面を壊さないよう、対象は「線の近く」かつ「まわりと色が違う」 

8画素だけに限る。Qt には依存しない素の numpy 配列として扱う。 

9""" 

10import numpy as np 

11 

12#: 黒い線とみなす明るさの上限(0-255)。色トレス線も拾えるよう少し高め。 

13DEFAULT_LINE_LUMA = 110 

14#: 線からこの距離(画素)までを「線の近く」とする。 

15DEFAULT_RADIUS = 2 

16#: 8近傍のうち、この数以上が同じ色なら多数派とみなす。 

17DEFAULT_MAJORITY = 5 

18#: 8近傍のうち、自分と同じ色がこの数以下なら「孤立している」とみなす。 

19#: 0 にすると、まわりに同じ色が1つも無い本当の1ドットだけが対象になる。 

20#: 色面のふちは必ず同じ色の隣をもつので、これで塗り分けを壊さずに済む。 

21DEFAULT_ISOLATION = 0 

22 

23#: 8近傍のずらし方(dy, dx)。 

24_OFFSETS = ( 

25 (-1, -1), (-1, 0), (-1, 1), 

26 (0, -1), (0, 1), 

27 (1, -1), (1, 0), (1, 1), 

28) 

29 

30 

31def _packed(rgba): 

32 """RGB を 24bit に詰め、透明画素は「色なし」の番兵にする。""" 

33 packed = ( 

34 (rgba[:, :, 0].astype(np.uint32) << 16) 

35 | (rgba[:, :, 1].astype(np.uint32) << 8) 

36 | rgba[:, :, 2].astype(np.uint32) 

37 ) 

38 # 透明は 24bit に収まらない値にして、どの色とも一致しないようにする。 

39 return np.where(rgba[:, :, 3] == 0, np.uint32(0x1000000), packed) 

40 

41 

42def _shifted(values, dy, dx, fill): 

43 """``values`` を (dy, dx) だけずらした配列。外側は ``fill`` で埋める。""" 

44 result = np.full_like(values, fill) 

45 height, width = values.shape 

46 source_y = slice(max(0, -dy), height - max(0, dy)) 

47 source_x = slice(max(0, -dx), width - max(0, dx)) 

48 target_y = slice(max(0, dy), height - max(0, -dy)) 

49 target_x = slice(max(0, dx), width - max(0, -dx)) 

50 result[target_y, target_x] = values[source_y, source_x] 

51 return result 

52 

53 

54def _dilate(mask, radius): 

55 """``mask`` を上下左右へ ``radius`` 回広げる(チェビシェフ距離)。""" 

56 grown = np.asarray(mask, dtype=bool) 

57 for _ in range(max(0, int(radius))): 

58 padded = grown.copy() 

59 padded[:-1, :] |= grown[1:, :] 

60 padded[1:, :] |= grown[:-1, :] 

61 padded[:, :-1] |= grown[:, 1:] 

62 padded[:, 1:] |= grown[:, :-1] 

63 padded[:-1, :-1] |= grown[1:, 1:] 

64 padded[1:, 1:] |= grown[:-1, :-1] 

65 padded[:-1, 1:] |= grown[1:, :-1] 

66 padded[1:, :-1] |= grown[:-1, 1:] 

67 grown = padded 

68 return grown 

69 

70 

71def line_mask(rgba, line_luma=DEFAULT_LINE_LUMA): 

72 """線とみなす画素。不透明で、じゅうぶん暗いもの。""" 

73 luma = rgba[:, :, :3].astype(np.uint16).sum(axis=2) // 3 

74 return (rgba[:, :, 3] > 0) & (luma <= int(line_luma)) 

75 

76 

77def remove_line_noise( 

78 rgba, 

79 *, 

80 line_luma=DEFAULT_LINE_LUMA, 

81 radius=DEFAULT_RADIUS, 

82 majority=DEFAULT_MAJORITY, 

83 isolation=DEFAULT_ISOLATION, 

84): 

85 """線の近くの孤立した異色ドットを、8近傍の多数決で塗り替える。 

86 

87 対象は「線の近く」かつ「まわりに自分と同じ色が ``isolation`` 個以下」の画素 

88 だけ。色面のふちは同じ色の隣を必ず持つので、塗り分けは壊れない。 

89 

90 :param rgba: ``(H, W, 4)`` uint8。書き換えず、新しい配列を返す。 

91 :returns: ``(塗り替えた配列, 塗り替えた画素数)``。 

92 """ 

93 rgba = np.asarray(rgba, dtype=np.uint8) 

94 if rgba.ndim != 3 or rgba.shape[2] != 4 or rgba.shape[0] < 3 or rgba.shape[1] < 3: 94 ↛ 95line 94 didn't jump to line 95 because the condition on line 94 was never true

95 return rgba, 0 

96 

97 outside = np.uint32(0x2000000) 

98 packed = _packed(rgba) 

99 neighbours = [_shifted(packed, dy, dx, outside) for dy, dx in _OFFSETS] 

100 

101 # 近傍どうしを突き合わせ、同じ色の数が最も多い近傍を選ぶ(多数決)。 

102 counts = np.zeros((len(neighbours),) + packed.shape, dtype=np.uint8) 

103 for index, value in enumerate(neighbours): 

104 valid = value != outside 

105 for other in neighbours: 

106 counts[index] += (valid & (other == value)).astype(np.uint8) 

107 

108 best = counts.argmax(axis=0) 

109 best_count = counts.max(axis=0) 

110 stacked = np.stack(neighbours, axis=0) 

111 winner = np.take_along_axis(stacked, best[None, :, :], axis=0)[0] 

112 

113 own_neighbours = np.zeros(packed.shape, dtype=np.uint8) 

114 for value in neighbours: 

115 own_neighbours += ((value != outside) & (value == packed)).astype(np.uint8) 

116 

117 noisy = ( 

118 (rgba[:, :, 3] > 0) 

119 & (own_neighbours <= int(isolation)) 

120 & (best_count >= int(majority)) 

121 & (winner != packed) 

122 & (winner != outside) 

123 # 多数派が透明なら消さない。線まわりの色だけを寄せる。 

124 & (winner != np.uint32(0x1000000)) 

125 # 線そのものは動かさない。 

126 & ~line_mask(rgba, line_luma) 

127 & _dilate(line_mask(rgba, line_luma), radius) 

128 ) 

129 changed = int(np.count_nonzero(noisy)) 

130 if not changed: 

131 return rgba, 0 

132 

133 result = rgba.copy() 

134 result[:, :, 0][noisy] = ((winner >> 16) & 0xFF).astype(np.uint8)[noisy] 

135 result[:, :, 1][noisy] = ((winner >> 8) & 0xFF).astype(np.uint8)[noisy] 

136 result[:, :, 2][noisy] = (winner & 0xFF).astype(np.uint8)[noisy] 

137 result[:, :, 3][noisy] = 255 

138 return result, changed