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
« prev ^ index » next coverage.py v7.17.0, created at 2026-11-02 04:50 +0000
1"""線のまわりの色ノイズ除去(Issue #19)。
3線の近くには、まわりがほとんど黒なのに1ドットだけ黄色、といった孤立した異色
4ドットが残る。取り込んだ素材の圧縮ノイズや、減色でどちらとも付かなかった画素が
5元になる。これを近傍の多数決でまわりの色へ寄せる。
7意図して置いた色面を壊さないよう、対象は「線の近く」かつ「まわりと色が違う」
8画素だけに限る。Qt には依存しない素の numpy 配列として扱う。
9"""
10import numpy as np
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
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)
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)
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
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
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))
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近傍の多数決で塗り替える。
87 対象は「線の近く」かつ「まわりに自分と同じ色が ``isolation`` 個以下」の画素
88 だけ。色面のふちは同じ色の隣を必ず持つので、塗り分けは壊れない。
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
97 outside = np.uint32(0x2000000)
98 packed = _packed(rgba)
99 neighbours = [_shifted(packed, dy, dx, outside) for dy, dx in _OFFSETS]
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)
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]
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)
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
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