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.
Files changed (69) hide show
  1. openftgs/__init__.py +11 -0
  2. openftgs/_caffe_trunks.py +161 -0
  3. openftgs/_legacy.py +55 -0
  4. openftgs/_shards.py +127 -0
  5. openftgs/_stages.py +343 -0
  6. openftgs/_vendor/__init__.py +15 -0
  7. openftgs/_vendor/flip/LICENSE +31 -0
  8. openftgs/_vendor/flip/VENDOR.json +71 -0
  9. openftgs/_vendor/flip/__init__.py +13 -0
  10. openftgs/_vendor/flip/flip_loss.py +770 -0
  11. openftgs/_vendor/romatch/LICENSE +21 -0
  12. openftgs/_vendor/romatch/LICENSES/DINO.txt +201 -0
  13. openftgs/_vendor/romatch/LICENSES/DINOv2.txt +203 -0
  14. openftgs/_vendor/romatch/LICENSES/InLoc.txt +7 -0
  15. openftgs/_vendor/romatch/LICENSES/Kornia.txt +176 -0
  16. openftgs/_vendor/romatch/LICENSES/LoFTR.txt +202 -0
  17. openftgs/_vendor/romatch/LICENSES/Patch2Pix.txt +21 -0
  18. openftgs/_vendor/romatch/LICENSES/timm.txt +201 -0
  19. openftgs/_vendor/romatch/VENDOR.json +254 -0
  20. openftgs/_vendor/romatch/__init__.py +8 -0
  21. openftgs/_vendor/romatch/models/__init__.py +1 -0
  22. openftgs/_vendor/romatch/models/encoders.py +69 -0
  23. openftgs/_vendor/romatch/models/matcher.py +1002 -0
  24. openftgs/_vendor/romatch/models/model_zoo/__init__.py +110 -0
  25. openftgs/_vendor/romatch/models/model_zoo/roma_models.py +400 -0
  26. openftgs/_vendor/romatch/models/tiny.py +305 -0
  27. openftgs/_vendor/romatch/models/transformer/__init__.py +49 -0
  28. openftgs/_vendor/romatch/models/transformer/dinov2.py +359 -0
  29. openftgs/_vendor/romatch/models/transformer/layers/__init__.py +12 -0
  30. openftgs/_vendor/romatch/models/transformer/layers/attention.py +91 -0
  31. openftgs/_vendor/romatch/models/transformer/layers/block.py +246 -0
  32. openftgs/_vendor/romatch/models/transformer/layers/dino_head.py +59 -0
  33. openftgs/_vendor/romatch/models/transformer/layers/drop_path.py +35 -0
  34. openftgs/_vendor/romatch/models/transformer/layers/layer_scale.py +28 -0
  35. openftgs/_vendor/romatch/models/transformer/layers/mlp.py +41 -0
  36. openftgs/_vendor/romatch/models/transformer/layers/patch_embed.py +89 -0
  37. openftgs/_vendor/romatch/models/transformer/layers/swiglu_ffn.py +59 -0
  38. openftgs/_vendor/romatch/utils/__init__.py +14 -0
  39. openftgs/_vendor/romatch/utils/kde.py +13 -0
  40. openftgs/_vendor/romatch/utils/local_correlation.py +143 -0
  41. openftgs/_vendor/romatch/utils/utils.py +596 -0
  42. openftgs/_weights.py +430 -0
  43. openftgs/cli/__init__.py +1342 -0
  44. openftgs/cli/__main__.py +8 -0
  45. openftgs/cli/_adapter.py +266 -0
  46. openftgs/cli/_adapter_forward.py +408 -0
  47. openftgs/cli/_bench.py +3062 -0
  48. openftgs/config.py +775 -0
  49. openftgs/data.py +2946 -0
  50. openftgs/density.py +1052 -0
  51. openftgs/eval.py +913 -0
  52. openftgs/init.py +1098 -0
  53. openftgs/io.py +1793 -0
  54. openftgs/losses.py +847 -0
  55. openftgs/model.py +837 -0
  56. openftgs/motion.py +516 -0
  57. openftgs/optim.py +628 -0
  58. openftgs/py.typed +0 -0
  59. openftgs/render.py +1335 -0
  60. openftgs/sh.py +170 -0
  61. openftgs/train.py +1486 -0
  62. openftgs/types.py +563 -0
  63. openftgs-0.1.0.dist-info/METADATA +143 -0
  64. openftgs-0.1.0.dist-info/RECORD +69 -0
  65. openftgs-0.1.0.dist-info/WHEEL +5 -0
  66. openftgs-0.1.0.dist-info/entry_points.txt +3 -0
  67. openftgs-0.1.0.dist-info/licenses/LICENSE +201 -0
  68. openftgs-0.1.0.dist-info/licenses/NOTICE +84 -0
  69. 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))]