openftgs 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openftgs/__init__.py +11 -0
- openftgs/_caffe_trunks.py +161 -0
- openftgs/_legacy.py +55 -0
- openftgs/_shards.py +127 -0
- openftgs/_stages.py +343 -0
- openftgs/_vendor/__init__.py +15 -0
- openftgs/_vendor/flip/LICENSE +31 -0
- openftgs/_vendor/flip/VENDOR.json +71 -0
- openftgs/_vendor/flip/__init__.py +13 -0
- openftgs/_vendor/flip/flip_loss.py +770 -0
- openftgs/_vendor/romatch/LICENSE +21 -0
- openftgs/_vendor/romatch/LICENSES/DINO.txt +201 -0
- openftgs/_vendor/romatch/LICENSES/DINOv2.txt +203 -0
- openftgs/_vendor/romatch/LICENSES/InLoc.txt +7 -0
- openftgs/_vendor/romatch/LICENSES/Kornia.txt +176 -0
- openftgs/_vendor/romatch/LICENSES/LoFTR.txt +202 -0
- openftgs/_vendor/romatch/LICENSES/Patch2Pix.txt +21 -0
- openftgs/_vendor/romatch/LICENSES/timm.txt +201 -0
- openftgs/_vendor/romatch/VENDOR.json +254 -0
- openftgs/_vendor/romatch/__init__.py +8 -0
- openftgs/_vendor/romatch/models/__init__.py +1 -0
- openftgs/_vendor/romatch/models/encoders.py +69 -0
- openftgs/_vendor/romatch/models/matcher.py +1002 -0
- openftgs/_vendor/romatch/models/model_zoo/__init__.py +110 -0
- openftgs/_vendor/romatch/models/model_zoo/roma_models.py +400 -0
- openftgs/_vendor/romatch/models/tiny.py +305 -0
- openftgs/_vendor/romatch/models/transformer/__init__.py +49 -0
- openftgs/_vendor/romatch/models/transformer/dinov2.py +359 -0
- openftgs/_vendor/romatch/models/transformer/layers/__init__.py +12 -0
- openftgs/_vendor/romatch/models/transformer/layers/attention.py +91 -0
- openftgs/_vendor/romatch/models/transformer/layers/block.py +246 -0
- openftgs/_vendor/romatch/models/transformer/layers/dino_head.py +59 -0
- openftgs/_vendor/romatch/models/transformer/layers/drop_path.py +35 -0
- openftgs/_vendor/romatch/models/transformer/layers/layer_scale.py +28 -0
- openftgs/_vendor/romatch/models/transformer/layers/mlp.py +41 -0
- openftgs/_vendor/romatch/models/transformer/layers/patch_embed.py +89 -0
- openftgs/_vendor/romatch/models/transformer/layers/swiglu_ffn.py +59 -0
- openftgs/_vendor/romatch/utils/__init__.py +14 -0
- openftgs/_vendor/romatch/utils/kde.py +13 -0
- openftgs/_vendor/romatch/utils/local_correlation.py +143 -0
- openftgs/_vendor/romatch/utils/utils.py +596 -0
- openftgs/_weights.py +430 -0
- openftgs/cli/__init__.py +1342 -0
- openftgs/cli/__main__.py +8 -0
- openftgs/cli/_adapter.py +266 -0
- openftgs/cli/_adapter_forward.py +408 -0
- openftgs/cli/_bench.py +3062 -0
- openftgs/config.py +775 -0
- openftgs/data.py +2946 -0
- openftgs/density.py +1052 -0
- openftgs/eval.py +913 -0
- openftgs/init.py +1098 -0
- openftgs/io.py +1793 -0
- openftgs/losses.py +847 -0
- openftgs/model.py +837 -0
- openftgs/motion.py +516 -0
- openftgs/optim.py +628 -0
- openftgs/py.typed +0 -0
- openftgs/render.py +1335 -0
- openftgs/sh.py +170 -0
- openftgs/train.py +1486 -0
- openftgs/types.py +563 -0
- openftgs-0.1.0.dist-info/METADATA +143 -0
- openftgs-0.1.0.dist-info/RECORD +69 -0
- openftgs-0.1.0.dist-info/WHEEL +5 -0
- openftgs-0.1.0.dist-info/entry_points.txt +3 -0
- openftgs-0.1.0.dist-info/licenses/LICENSE +201 -0
- openftgs-0.1.0.dist-info/licenses/NOTICE +84 -0
- openftgs-0.1.0.dist-info/top_level.txt +1 -0
openftgs/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""An independent clean-room implementation of FreeTimeGS, not affiliated with its authors (D-62).
|
|
3
|
+
|
|
4
|
+
openftgs implements FreeTimeGS (Wang et al., CVPR 2025). The distribution and the import
|
|
5
|
+
package are named openftgs, and the command-line interface ``ftgs``.
|
|
6
|
+
|
|
7
|
+
The public API is listed in spec §2.1; each module's ``__all__`` names its part of it, and
|
|
8
|
+
everything else is private.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__all__: list[str] = []
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""D-89's two Caffe trunk networks, `vgg16_oxford` and `alexnet_bvlc`: lpips's uncalibrated
|
|
3
|
+
distance on Oxford's VGG16 and BVLC's AlexNet, built from the convolutions `openftgs._weights`
|
|
4
|
+
converts from the publishers' Caffe files (#384).
|
|
5
|
+
|
|
6
|
+
Spec: D-89, §2.3's pretrained-trunk row. A private top-level module that imports nothing from
|
|
7
|
+
openftgs: `openftgs.losses` passes it the tensors and wraps it in `PerceptualLoss` (#385).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import functools
|
|
11
|
+
from collections.abc import Mapping
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Literal
|
|
14
|
+
|
|
15
|
+
import torch
|
|
16
|
+
import torch.nn.functional as F
|
|
17
|
+
from torch import Tensor
|
|
18
|
+
|
|
19
|
+
__all__ = ["CAFFE_TRUNKS", "CaffeTrunk"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# The construction below is adapted from #374's prototype `candidates.py` (the owner's
|
|
23
|
+
# Apache-2.0 research code, SHA-256
|
|
24
|
+
# 4386e7ce8bc97a2f94ca6b9e0eb11cc0d5559b20ccc08b72cb59c85c8e3486eb; source log 449), its
|
|
25
|
+
# `_CaffeTrunk`, `VGG16CC` and `AlexNetCC`: the construction #374 tested, which D-89 names.
|
|
26
|
+
# The layers are the publishers' (VGG16's configuration D, source log 420; BVLC's AlexNet
|
|
27
|
+
# prototxt, source log 421).
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class _Conv:
|
|
32
|
+
"""A convolution under its Caffe layer name, followed by its ReLU (Caffe's in-place one)."""
|
|
33
|
+
|
|
34
|
+
name: str
|
|
35
|
+
stride: int = 1
|
|
36
|
+
padding: int = 1
|
|
37
|
+
groups: int = 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class _Pool:
|
|
42
|
+
"""Max pooling in Caffe's ceiling mode (D-89)."""
|
|
43
|
+
|
|
44
|
+
kernel: int
|
|
45
|
+
stride: int
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# A step of a trunk, in network order: a convolution and its ReLU, a pooling, Caffe's local
|
|
49
|
+
# response normalization ("lrn"), or a feature D-89's distance reads ("tap").
|
|
50
|
+
_Step = _Conv | _Pool | Literal["tap", "lrn"]
|
|
51
|
+
|
|
52
|
+
_VGG16: tuple[_Step, ...] = (
|
|
53
|
+
*(_Conv("conv1_1"), _Conv("conv1_2"), "tap"),
|
|
54
|
+
*(_Pool(2, 2), _Conv("conv2_1"), _Conv("conv2_2"), "tap"),
|
|
55
|
+
*(_Pool(2, 2), _Conv("conv3_1"), _Conv("conv3_2"), _Conv("conv3_3"), "tap"),
|
|
56
|
+
*(_Pool(2, 2), _Conv("conv4_1"), _Conv("conv4_2"), _Conv("conv4_3"), "tap"),
|
|
57
|
+
*(_Pool(2, 2), _Conv("conv5_1"), _Conv("conv5_2"), _Conv("conv5_3"), "tap"),
|
|
58
|
+
)
|
|
59
|
+
# D-89: the taps after relu1 to relu5, before the normalization and pooling that follow relu1
|
|
60
|
+
# and relu2; conv2, conv4 and conv5 in two groups.
|
|
61
|
+
_ALEXNET: tuple[_Step, ...] = (
|
|
62
|
+
*(_Conv("conv1", stride=4, padding=0), "tap", "lrn", _Pool(3, 2)),
|
|
63
|
+
*(_Conv("conv2", padding=2, groups=2), "tap", "lrn", _Pool(3, 2)),
|
|
64
|
+
*(_Conv("conv3"), "tap"),
|
|
65
|
+
*(_Conv("conv4", groups=2), "tap"),
|
|
66
|
+
*(_Conv("conv5", groups=2), "tap"),
|
|
67
|
+
)
|
|
68
|
+
_STEPS: dict[str, tuple[_Step, ...]] = {"vgg16_oxford": _VGG16, "alexnet_bvlc": _ALEXNET}
|
|
69
|
+
# Q-263: the smallest height and width each trunk's layers take: AlexNet's conv1 gives 4
|
|
70
|
+
# from 23, which its poolings take to 2 and 1; VGG16's ceiling pooling takes 1 to 1.
|
|
71
|
+
_MIN_SIDE = {"vgg16_oxford": 1, "alexnet_bvlc": 23}
|
|
72
|
+
|
|
73
|
+
# D-89: each release's mean pixel, BGR on Caffe's 0 to 255 scale: Oxford's as published
|
|
74
|
+
# (source log 420); BVLC's the per-channel means of Caffe's ilsvrc_2012_mean.npy (source log
|
|
75
|
+
# 426, which records them to three decimals) to eight decimals (source log 449), as #374
|
|
76
|
+
# tested them, which D-89's table rounds to three (Q-264).
|
|
77
|
+
_MEAN_BGR = {
|
|
78
|
+
"vgg16_oxford": (103.939, 116.779, 123.68),
|
|
79
|
+
"alexnet_bvlc": (104.00698793, 116.66876762, 122.67891434), # Q-264
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# The two trunks, in D-89's table's order.
|
|
83
|
+
CAFFE_TRUNKS: tuple[str, ...] = tuple(_STEPS)
|
|
84
|
+
|
|
85
|
+
# D-89: lpips's `normalize_tensor` ε (source log 435).
|
|
86
|
+
_EPS = 1e-10
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _unit(features: Tensor) -> Tensor:
|
|
90
|
+
"""Each feature vector (dim 1) divided by its norm plus ε, as lpips's `normalize_tensor`."""
|
|
91
|
+
return features / (features.pow(2).sum(dim=1, keepdim=True).sqrt() + _EPS)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class CaffeTrunk(torch.nn.Module):
|
|
95
|
+
"""The Caffe trunk `name` (one of `CAFFE_TRUNKS`) on `tensors`, its convolutions'
|
|
96
|
+
`<layer>.weight` and `<layer>.bias` under Caffe's layer names, as `openftgs._weights`'s
|
|
97
|
+
`trunk_tensors` returns them (D-89). It computes in their dtype, and holds them, with the
|
|
98
|
+
mean pixel, as buffers that `.to()` moves and converts but no `state_dict` carries
|
|
99
|
+
(Q-263; §2.3: never redistributed)."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, name: str, tensors: Mapping[str, Tensor]) -> None:
|
|
102
|
+
super().__init__()
|
|
103
|
+
self._name = name
|
|
104
|
+
self._steps = _STEPS[name]
|
|
105
|
+
convs = [step for step in self._steps if isinstance(step, _Conv)]
|
|
106
|
+
for conv in convs:
|
|
107
|
+
for part in ("weight", "bias"):
|
|
108
|
+
tensor = tensors[f"{conv.name}.{part}"]
|
|
109
|
+
self.register_buffer(f"{conv.name}_{part}", tensor, persistent=False)
|
|
110
|
+
dtype = tensors[f"{convs[0].name}.weight"].dtype
|
|
111
|
+
mean = torch.tensor(_MEAN_BGR[name], dtype=dtype).view(1, 3, 1, 1)
|
|
112
|
+
self.register_buffer("mean_bgr", mean, persistent=False)
|
|
113
|
+
|
|
114
|
+
def features(self, image: Tensor) -> list[Tensor]:
|
|
115
|
+
"""The taps of D-89's distance for a [B, 3, H, W] RGB image in [0, 1]: Caffe's input,
|
|
116
|
+
BGR × 255 minus the mean pixel, through the trunk's layers in network order."""
|
|
117
|
+
x = image.flip(1) * 255.0 - self.get_buffer("mean_bgr")
|
|
118
|
+
taps: list[Tensor] = []
|
|
119
|
+
for step in self._steps:
|
|
120
|
+
if step == "tap":
|
|
121
|
+
taps.append(x)
|
|
122
|
+
elif step == "lrn":
|
|
123
|
+
x = F.local_response_norm(x, 5, alpha=1e-4, beta=0.75, k=1.0)
|
|
124
|
+
elif isinstance(step, _Pool):
|
|
125
|
+
x = F.max_pool2d(x, step.kernel, step.stride, ceil_mode=True)
|
|
126
|
+
else:
|
|
127
|
+
weight = self.get_buffer(f"{step.name}_weight")
|
|
128
|
+
bias = self.get_buffer(f"{step.name}_bias")
|
|
129
|
+
x = F.conv2d(
|
|
130
|
+
x, weight, bias, stride=step.stride, padding=step.padding, groups=step.groups
|
|
131
|
+
)
|
|
132
|
+
x = F.relu(x)
|
|
133
|
+
return taps
|
|
134
|
+
|
|
135
|
+
def forward(self, x: Tensor, y: Tensor) -> Tensor:
|
|
136
|
+
"""D-89's distance between the [B, 3, H, W] RGB images x (the render) and y (the
|
|
137
|
+
reference), per image: lpips's uncalibrated distance, its `lpips=False` (source log
|
|
138
|
+
350), over the five taps. Images of another dtype than the weights', or AlexNet's
|
|
139
|
+
sides below 23 pixels, are a ValueError (Q-263)."""
|
|
140
|
+
# Q-263: checked on the dtype and shape alone, so nothing synchronizes.
|
|
141
|
+
dtype = self.get_buffer("mean_bgr").dtype
|
|
142
|
+
if x.dtype != dtype or y.dtype != dtype:
|
|
143
|
+
raise ValueError(
|
|
144
|
+
f"the {self._name} trunk computes in its weights' dtype {dtype}, so it takes "
|
|
145
|
+
f"{dtype} images, got {x.dtype} and {y.dtype}"
|
|
146
|
+
)
|
|
147
|
+
side = _MIN_SIDE[self._name]
|
|
148
|
+
if min(x.shape[-2:]) < side or min(y.shape[-2:]) < side:
|
|
149
|
+
raise ValueError(
|
|
150
|
+
f"the {self._name} trunk needs H and W >= {side}, got {tuple(x.shape[-2:])} "
|
|
151
|
+
f"and {tuple(y.shape[-2:])}"
|
|
152
|
+
)
|
|
153
|
+
# D-89: the reference without a gradient.
|
|
154
|
+
with torch.no_grad():
|
|
155
|
+
reference = [_unit(tap) for tap in self.features(y)]
|
|
156
|
+
# Per tap, the squared differences summed over channels and averaged over space.
|
|
157
|
+
layers = [
|
|
158
|
+
(_unit(tap) - unit_y).pow(2).sum(dim=1).mean(dim=(1, 2))
|
|
159
|
+
for tap, unit_y in zip(self.features(x), reference, strict=True)
|
|
160
|
+
]
|
|
161
|
+
return functools.reduce(torch.add, layers)
|
openftgs/_legacy.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""The perceptual settings of revisions before 549o: LPIPS's two backbones as `loss.perc_net`,
|
|
3
|
+
which `openftgs.config` refuses (#375, decided item 3) and which checkpoints saved before 549o
|
|
4
|
+
still carry (Q-247).
|
|
5
|
+
|
|
6
|
+
Such a checkpoint's model doesn't depend on the loss it trained with, so the readers that take
|
|
7
|
+
only its model (`io.load_checkpoint`, `FreeTimeGaussians.load`, the CLI's model reader) validate
|
|
8
|
+
its configuration with 549o's defaults in place of its `perc_net` and `lambda_perc`, in memory
|
|
9
|
+
only; resuming it is refused (Q-247, Q-271). Plain JSON dicts in and out.
|
|
10
|
+
|
|
11
|
+
Spec: §2.1 `PercNet` and `LossConfig`, §2.2's checkpoint contract, D-9; Q-247. A private
|
|
12
|
+
top-level module that imports nothing from openftgs, so every band may import it, as the stage
|
|
13
|
+
instrumentation (PRD #220).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from collections.abc import Mapping
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
__all__ = ["LPIPS_BACKBONES", "resume_refusal", "with_current_loss"]
|
|
20
|
+
|
|
21
|
+
# #375 (decided item 3): LPIPS's backbones, the `perc_net` values revision 549o retired.
|
|
22
|
+
LPIPS_BACKBONES = ("vgg", "alex")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _legacy_perc_net(config: Mapping[str, Any]) -> str | None:
|
|
26
|
+
"""The `loss.perc_net` of a stored TrainConfig dict when it is one of LPIPS's backbones,
|
|
27
|
+
else None (another value, valid or not, or none)."""
|
|
28
|
+
loss = config.get("loss")
|
|
29
|
+
net = loss.get("perc_net") if isinstance(loss, Mapping) else None
|
|
30
|
+
return net if isinstance(net, str) and net in LPIPS_BACKBONES else None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# Q-247: in memory only; the dict given is never changed, so `TrainerState.config` stays as saved.
|
|
34
|
+
def with_current_loss(config: Mapping[str, Any]) -> dict[str, Any]:
|
|
35
|
+
"""A copy of a stored TrainConfig dict that `config_from_dict` reads as 549o's: a legacy
|
|
36
|
+
`loss.perc_net` and its `loss.lambda_perc` left out, so they take 549o's defaults (ꟻLIP at
|
|
37
|
+
its calibrated weight, D-9); every other key, and any other dict, as it is."""
|
|
38
|
+
out = dict(config)
|
|
39
|
+
if _legacy_perc_net(config) is not None:
|
|
40
|
+
loss = config["loss"]
|
|
41
|
+
out["loss"] = {k: v for k, v in loss.items() if k not in ("perc_net", "lambda_perc")}
|
|
42
|
+
return out
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resume_refusal(config: Mapping[str, Any], where: str) -> str | None:
|
|
46
|
+
"""Q-247: why the run that `where` (a checkpoint, say) stores can't be resumed, when its
|
|
47
|
+
configuration names LPIPS's backbone; else None."""
|
|
48
|
+
net = _legacy_perc_net(config)
|
|
49
|
+
if net is None:
|
|
50
|
+
return None
|
|
51
|
+
return (
|
|
52
|
+
f"{where} was trained with loss.perc_net {net!r}, one of LPIPS's backbones, which "
|
|
53
|
+
f"revision 549o of the spec retired (D-9, D-89): its run can't be resumed, though ftgs "
|
|
54
|
+
f"eval, render and export still read its model (Q-247)"
|
|
55
|
+
)
|
openftgs/_shards.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
"""Thread-per-device sharding (D-54): independent jobs spread over shards, one worker thread per
|
|
3
|
+
shard, without changing results.
|
|
4
|
+
|
|
5
|
+
Spec: D-54 ("Independent work is spread across GPUs without changing results: RoMa pairs,
|
|
6
|
+
evaluation frames, render requests"), §2.4 "Several GPUs". A private top-level module that
|
|
7
|
+
imports nothing from openftgs, so every band may import it (PRD #220, as `openftgs._stages`):
|
|
8
|
+
`init.roma_init` (Q-106), `render.render_request` and `eval.evaluate_images` (Q-175, Q-186)
|
|
9
|
+
share it.
|
|
10
|
+
|
|
11
|
+
A shard is whatever one worker needs for its device (a dense matcher, a model copy and its
|
|
12
|
+
evaluator), built by the caller in its own thread, one shard after another, before the workers
|
|
13
|
+
start: so nothing is downloaded, built or compiled from several threads at once. Workers take
|
|
14
|
+
jobs from one shared queue. A shard's exception stops that shard and is raised in the caller's
|
|
15
|
+
thread once every shard has stopped.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import queue
|
|
19
|
+
import threading
|
|
20
|
+
from collections.abc import Callable, Iterable, Sequence
|
|
21
|
+
from types import TracebackType
|
|
22
|
+
from typing import Generic, TypeVar
|
|
23
|
+
|
|
24
|
+
import torch
|
|
25
|
+
|
|
26
|
+
__all__ = ["Shards", "devices", "run"]
|
|
27
|
+
|
|
28
|
+
_S = TypeVar("_S") # a shard: what one worker thread needs
|
|
29
|
+
_J = TypeVar("_J") # a job
|
|
30
|
+
_R = TypeVar("_R") # a job's result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def devices(entries: Sequence[str | torch.device]) -> list[torch.device]:
|
|
34
|
+
"""Q-106, Q-122, Q-186: one shard per entry of a non-empty sequence of device names, so
|
|
35
|
+
a repeated entry is another shard on the same device. A string, an empty sequence or an
|
|
36
|
+
entry that isn't a device is a ValueError."""
|
|
37
|
+
if isinstance(entries, str): # Q-122: a string is a sequence of characters
|
|
38
|
+
raise ValueError(f"devices must be a sequence of device names, got the string {entries!r}")
|
|
39
|
+
out = []
|
|
40
|
+
for entry in entries:
|
|
41
|
+
try:
|
|
42
|
+
out.append(torch.device(entry))
|
|
43
|
+
except (RuntimeError, TypeError) as error:
|
|
44
|
+
raise ValueError(f"devices holds {entry!r}, which isn't a device: {error}") from None
|
|
45
|
+
if not out:
|
|
46
|
+
raise ValueError("devices must name at least one device")
|
|
47
|
+
return out
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Shards(Generic[_S, _J, _R]):
|
|
51
|
+
"""One worker thread per shard, taking jobs from a shared queue, as a context manager: the
|
|
52
|
+
threads start on entry and are joined on exit. Results come back in the order they finish,
|
|
53
|
+
with their job; a shard's exception is raised by `next_result`, and the shard that raised
|
|
54
|
+
takes no more jobs."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
shards: Sequence[_S],
|
|
59
|
+
jobs: Iterable[_J],
|
|
60
|
+
work: Callable[[_S, _J], _R],
|
|
61
|
+
*,
|
|
62
|
+
name: str,
|
|
63
|
+
) -> None:
|
|
64
|
+
self._jobs: queue.SimpleQueue[_J | None] = queue.SimpleQueue()
|
|
65
|
+
for job in jobs:
|
|
66
|
+
self._jobs.put(job)
|
|
67
|
+
for _ in shards:
|
|
68
|
+
self._jobs.put(None) # one stop per worker, after every job
|
|
69
|
+
self._results: queue.SimpleQueue[tuple[_J, _R | BaseException]] = queue.SimpleQueue()
|
|
70
|
+
self._stop = threading.Event()
|
|
71
|
+
self._work = work
|
|
72
|
+
self._threads = [
|
|
73
|
+
threading.Thread(target=self._serve, args=(shard,), name=f"{name} {i}")
|
|
74
|
+
for i, shard in enumerate(shards)
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
def _serve(self, shard: _S) -> None:
|
|
78
|
+
while not self._stop.is_set():
|
|
79
|
+
job = self._jobs.get()
|
|
80
|
+
if job is None:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
result: _R | BaseException = self._work(shard, job)
|
|
84
|
+
except BaseException as error: # noqa: BLE001 - raised again in the caller's thread
|
|
85
|
+
self._results.put((job, error))
|
|
86
|
+
return
|
|
87
|
+
self._results.put((job, result))
|
|
88
|
+
|
|
89
|
+
def __enter__(self) -> "Shards[_S, _J, _R]":
|
|
90
|
+
for thread in self._threads:
|
|
91
|
+
thread.start()
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(
|
|
95
|
+
self,
|
|
96
|
+
exc_type: type[BaseException] | None,
|
|
97
|
+
exc: BaseException | None,
|
|
98
|
+
traceback: TracebackType | None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self._stop.set()
|
|
101
|
+
for thread in self._threads:
|
|
102
|
+
thread.join()
|
|
103
|
+
|
|
104
|
+
def next_result(self) -> tuple[_J, _R]:
|
|
105
|
+
"""The next job to finish and its result; a shard's exception is raised here."""
|
|
106
|
+
job, result = self._results.get()
|
|
107
|
+
if isinstance(result, BaseException):
|
|
108
|
+
raise result
|
|
109
|
+
return job, result
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def run(
|
|
113
|
+
shards: Sequence[_S], jobs: Sequence[_J], work: Callable[[_S, _J], _R], *, name: str
|
|
114
|
+
) -> list[_R]:
|
|
115
|
+
"""Every job's result, in the jobs' order, done by `work` on the shards' worker threads
|
|
116
|
+
(named `name` and the shard's number). The first exception a shard raises is raised here,
|
|
117
|
+
once every shard has stopped."""
|
|
118
|
+
done: dict[int, _R] = {}
|
|
119
|
+
|
|
120
|
+
def numbered(shard: _S, job: tuple[int, _J]) -> _R:
|
|
121
|
+
return work(shard, job[1])
|
|
122
|
+
|
|
123
|
+
with Shards(shards, list(enumerate(jobs)), numbered, name=name) as running:
|
|
124
|
+
while len(done) < len(jobs):
|
|
125
|
+
(index, _), result = running.next_result()
|
|
126
|
+
done[index] = result
|
|
127
|
+
return [done[index] for index in range(len(jobs))]
|