Coverage for paintmaskanimator/auto_preparation.py: 94%

105 statements  

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

1"""独立した対象の先行準備と、不変の見本のバケツ前処理の共有。""" 

2from collections import OrderedDict 

3from concurrent.futures import Future, ThreadPoolExecutor 

4from hashlib import blake2b 

5from typing import Callable, Generic, Iterable, TypeVar 

6 

7import numpy as np 

8 

9from . import bucket_fill 

10 

11 

12T = TypeVar("T") 

13 

14 

15class Preparation(Generic[T]): 

16 """最大2セルだけ先行する。塗り・採色・見本の更新は主処理に残す。""" 

17 

18 def __init__(self, order: Iterable[int], build: Callable[[int], T], workers=2): 

19 self.order = iter(order) 

20 self.build = build 

21 self.workers = min(2, max(1, workers)) 

22 self.executor = ThreadPoolExecutor(max_workers=self.workers, 

23 thread_name_prefix="auto-paint-prepare") 

24 self.pending: dict[int, Future[T]] = {} 

25 self.prepared = 0 

26 

27 def __enter__(self) -> "Preparation[T]": 

28 return self 

29 

30 def __exit__(self, *exception): 

31 for future in self.pending.values(): 

32 future.cancel() 

33 self.executor.shutdown(wait=True, cancel_futures=True) 

34 

35 def schedule(self, known): 

36 while len(self.pending) < self.workers: 

37 index = next(self.order, None) 

38 if index is None: 

39 break 

40 if index not in known: 

41 self.pending[index] = self.executor.submit(self.build, index) 

42 

43 def take(self, index) -> T | None: 

44 future = self.pending.pop(index, None) 

45 if future is None: 45 ↛ 46line 45 didn't jump to line 46 because the condition on line 45 was never true

46 return None 

47 result = future.result() 

48 self.prepared += 1 

49 return result 

50 

51 

52class ReferenceRegions: 

53 """同じ画素と設定のBucketSessionを共有する。1計画内・最大192MiB。""" 

54 

55 def __init__(self, *, capacity=4, max_bytes=192 * 1024 * 1024, gpu_regions=None): 

56 self.sessions = OrderedDict() 

57 self.capacity = capacity 

58 self.max_bytes = max_bytes 

59 self.hits = 0 

60 self.created = 0 

61 self.regions = OrderedDict() 

62 self.region_hits = 0 

63 self.max_region_bytes = 32 * 1024 * 1024 

64 self.gpu_regions = gpu_regions 

65 

66 @staticmethod 

67 def key(geometry, options): 

68 immutable = getattr(geometry, "_native_immutable", False) 

69 key = getattr(geometry, "_reference_pixels_key", None) if immutable else None 

70 if key is None: 

71 pixels = np.ascontiguousarray(geometry.pixels) 

72 key = (pixels.shape, pixels.dtype.str, 

73 blake2b(pixels.tobytes(), digest_size=16).digest()) 

74 if immutable: 

75 geometry._reference_pixels_key = key 

76 return key, repr(options) 

77 

78 @property 

79 def nbytes(self): 

80 return sum(session.nbytes for session in self.sessions.values()) + self.region_nbytes 

81 

82 @property 

83 def region_nbytes(self): 

84 return sum(result.nbytes + seedable.nbytes + len(getattr(result, "_native_runs", b"")) 

85 for records in self.regions.values() for result, seedable in records) 

86 

87 def session(self, geometry, options): 

88 key = self.key(geometry, options) 

89 if key in self.sessions: 89 ↛ 90line 89 didn't jump to line 90 because the condition on line 89 was never true

90 self.hits += 1 

91 session = self.sessions.pop(key) 

92 else: 

93 self.created += 1 

94 session = bucket_fill.BucketSession(geometry.pixels, options, indexed=True, 

95 gpu_regions=self.gpu_regions) 

96 self.sessions[key] = session 

97 self.trim() 

98 return session 

99 

100 def trim(self): 

101 while self.regions and self.region_nbytes > self.max_region_bytes: 101 ↛ 102line 101 didn't jump to line 102 because the condition on line 101 was never true

102 self.regions.popitem(last=False) 

103 while self.sessions and (len(self.sessions) > self.capacity or self.nbytes > self.max_bytes): 

104 self.sessions.popitem(last=False) 

105 while self.regions and self.nbytes > self.max_bytes: 

106 self.regions.popitem(last=False) 

107 

108 def compact(self, geometry, x, y, options, *, active=None): 

109 key = self.key(geometry, options) 

110 height, width = geometry.pixels.shape[:2] 

111 category = (bucket_fill.seed_category(geometry.pixels, x, y) 

112 if 0 <= x < width and 0 <= y < height else None) 

113 for result, seedable in self.regions.get(key, ()): 

114 x0, y0, x1, y1 = result.bounds 

115 if (result.target == category and x0 <= x < x1 and y0 <= y < y1 

116 and seedable[y - y0, x - x0]): 

117 self.region_hits += 1 

118 self.regions.move_to_end(key) 

119 self.trim() 

120 return result 

121 if active is not None and geometry in active: 

122 session = active[geometry] 

123 else: 

124 session = self.session(geometry, options) 

125 if active is not None: 

126 active[geometry] = session 

127 result = session.compact(x, y) 

128 if (0 <= x < width and 0 <= y < height and options.adjacent 128 ↛ 140line 128 didn't jump to line 140 because the condition on line 128 was always true

129 and options.target_color == bucket_fill.TARGET_ALL and not options.include_masks 

130 and not options.island_replacements and result.local is not None 

131 and result.bounds is not None): 

132 _target, passable, _gaps = session.prepare(category) 

133 if passable is not None and passable[y, x]: 

134 # 回収した仮想境界は両側の面に含まれる。そこをクリック可能な 

135 # 種と取り違えないよう、探索可能な元の連結成分だけを記録する。 

136 x0, y0, x1, y1 = result.bounds 

137 seedable = result.local & passable[y0:y1, x0:x1] 

138 self.regions.setdefault(key, []).append((result, seedable)) 

139 self.regions.move_to_end(key) 

140 self.trim() 

141 return result