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
« 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
7import numpy as np
9from . import bucket_fill
12T = TypeVar("T")
15class Preparation(Generic[T]):
16 """最大2セルだけ先行する。塗り・採色・見本の更新は主処理に残す。"""
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
27 def __enter__(self) -> "Preparation[T]":
28 return self
30 def __exit__(self, *exception):
31 for future in self.pending.values():
32 future.cancel()
33 self.executor.shutdown(wait=True, cancel_futures=True)
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)
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
52class ReferenceRegions:
53 """同じ画素と設定のBucketSessionを共有する。1計画内・最大192MiB。"""
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
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)
78 @property
79 def nbytes(self):
80 return sum(session.nbytes for session in self.sessions.values()) + self.region_nbytes
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)
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
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)
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