prpy 0.4.2__tar.gz → 0.4.5__tar.gz
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.
- {prpy-0.4.2 → prpy-0.4.5}/PKG-INFO +3 -5
- {prpy-0.4.2 → prpy-0.4.5}/README.md +0 -2
- {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/probe.py +46 -15
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/filters.py +9 -6
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/model_saver.py +23 -19
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/nan.py +9 -0
- prpy-0.4.5/prpy/tensorflow/optimizer.py +67 -0
- prpy-0.4.5/prpy/torch/loss.py +46 -0
- prpy-0.4.5/prpy/torch/lr_schedule.py +54 -0
- prpy-0.4.5/prpy/torch/nan.py +200 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/PKG-INFO +3 -5
- {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/SOURCES.txt +9 -1
- {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/requires.txt +1 -1
- prpy-0.4.5/prpy.egg-info/scm_file_list.json +65 -0
- prpy-0.4.5/prpy.egg-info/scm_version.json +8 -0
- {prpy-0.4.2 → prpy-0.4.5}/pyproject.toml +13 -3
- {prpy-0.4.2 → prpy-0.4.5}/tests/conftest.py +14 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_ffmpeg.py +30 -5
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_filters.py +11 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_tensorflow.py +23 -64
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_torch.py +18 -43
- prpy-0.4.5/tests/test_torch_loss.py +40 -0
- prpy-0.4.5/tests/test_torch_lr_schedule.py +49 -0
- prpy-0.4.5/tests/test_torch_nan.py +155 -0
- prpy-0.4.2/prpy/tensorflow/optimizer.py +0 -233
- {prpy-0.4.2 → prpy-0.4.5}/.gitignore +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/CONTRIBUTING.md +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/LICENSE +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/MANIFEST.in +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/__init__.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/constants.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/__init__.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/readwrite.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/utils.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/helpers.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/__init__.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/core.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/detect.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/face.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/freq.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/image.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/image_ops.c +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/interp.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/metric.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/physio.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/rolling.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/stride_tricks.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/utils.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/__init__.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/image.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/loss.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/lr_schedule.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/signal.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/torch/__init__.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy/torch/model_saver.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/dependency_links.txt +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/top_level.txt +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/setup.cfg +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/setup.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_core.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_detect.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_face.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_freq.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_image.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_interp.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_metric.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_rolling.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_stride_tricks.py +0 -0
- {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prpy
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
4
4
|
Summary: Collection of Python utils for signal, image, and video processing
|
|
5
5
|
Author-email: Philipp Rouast <philipp@rouast.com>
|
|
6
6
|
License: MIT License
|
|
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Requires-Python:
|
|
14
|
+
Requires-Python: <3.13,>=3.10
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: numpy>=1.24
|
|
@@ -29,7 +29,7 @@ Requires-Dist: scipy; extra == "numpy-min"
|
|
|
29
29
|
Requires-Dist: Pillow; extra == "numpy-min"
|
|
30
30
|
Requires-Dist: psutil; extra == "numpy-min"
|
|
31
31
|
Provides-Extra: tensorflow
|
|
32
|
-
Requires-Dist: tensorflow
|
|
32
|
+
Requires-Dist: tensorflow>2.17.0; extra == "tensorflow"
|
|
33
33
|
Provides-Extra: torch
|
|
34
34
|
Requires-Dist: torch>=2.1.0; extra == "torch"
|
|
35
35
|
Provides-Extra: test
|
|
@@ -50,8 +50,6 @@ It contains subpackages for working with `numpy`, `ffmpeg`, `tensorflow`, and `t
|
|
|
50
50
|
|
|
51
51
|
General prerequisites are `python>=3.10` and `ffmpeg` installed and accessible via the `$PATH` environment variable.
|
|
52
52
|
|
|
53
|
-
- Please note: If using `numpy` or `tensorflow` options, we only support Python `<3.12` because of the dependencies.
|
|
54
|
-
|
|
55
53
|
The easiest way to install the latest version of `prpy`:
|
|
56
54
|
|
|
57
55
|
```
|
|
@@ -10,8 +10,6 @@ It contains subpackages for working with `numpy`, `ffmpeg`, `tensorflow`, and `t
|
|
|
10
10
|
|
|
11
11
|
General prerequisites are `python>=3.10` and `ffmpeg` installed and accessible via the `$PATH` environment variable.
|
|
12
12
|
|
|
13
|
-
- Please note: If using `numpy` or `tensorflow` options, we only support Python `<3.12` because of the dependencies.
|
|
14
|
-
|
|
15
13
|
The easiest way to install the latest version of `prpy`:
|
|
16
14
|
|
|
17
15
|
```
|
|
@@ -172,9 +172,18 @@ def probe_video(
|
|
|
172
172
|
|
|
173
173
|
return fps, total_frames, width, height, codec, bitrate, rotation, issues
|
|
174
174
|
|
|
175
|
+
def _parse_ffprobe_csv_floats(raw: str) -> np.ndarray:
|
|
176
|
+
"""Parse ffprobe's `-of csv=p=0` stdout (one value per line, possibly with a
|
|
177
|
+
trailing comma) into a float array."""
|
|
178
|
+
cleaned = "\n".join(line.rstrip(",") for line in raw.splitlines())
|
|
179
|
+
return np.fromstring(cleaned, dtype=float, sep="\n")
|
|
180
|
+
|
|
175
181
|
def probe_video_frame_timestamps(path: str, sanity_check: bool = False) -> list:
|
|
176
182
|
"""Probe a video file for a best effort estimate of its frame timestamps.
|
|
177
183
|
|
|
184
|
+
Tries reading each packet's pts directly and sorting (no decode needed).
|
|
185
|
+
Falls back to decoding every frame otherwise.
|
|
186
|
+
|
|
178
187
|
Args:
|
|
179
188
|
path: The path of the video.
|
|
180
189
|
sanity_check: Whether to check result against ffprobe count
|
|
@@ -184,23 +193,45 @@ def probe_video_frame_timestamps(path: str, sanity_check: bool = False) -> list:
|
|
|
184
193
|
if not os.path.exists(path):
|
|
185
194
|
raise FileNotFoundError(f"File {path} does not exist")
|
|
186
195
|
|
|
187
|
-
|
|
188
|
-
ts_cmd = [
|
|
189
|
-
"ffprobe", "-v", "error",
|
|
190
|
-
"-select_streams", "v:0",
|
|
191
|
-
"-show_entries", "frame=best_effort_timestamp_time",
|
|
192
|
-
"-of", "csv=p=0",
|
|
193
|
-
path
|
|
194
|
-
]
|
|
196
|
+
timestamps = None
|
|
195
197
|
try:
|
|
196
|
-
proc = subprocess.run(
|
|
197
|
-
|
|
198
|
+
proc = subprocess.run([
|
|
199
|
+
"ffprobe", "-v", "error",
|
|
200
|
+
"-select_streams", "v:0",
|
|
201
|
+
"-show_entries", "packet=pts_time",
|
|
202
|
+
"-of", "csv=p=0",
|
|
203
|
+
path
|
|
204
|
+
], capture_output=True, text=True, check=True)
|
|
205
|
+
lines = proc.stdout.splitlines()
|
|
206
|
+
if lines and all(line.rstrip(",") for line in lines):
|
|
207
|
+
candidate = np.sort(_parse_ffprobe_csv_floats(proc.stdout))
|
|
208
|
+
# A handful of tied pts (e.g. a GOP-boundary rounding artifact) is fine
|
|
209
|
+
# -- sorting already guarantees non-decreasing order, so the only real
|
|
210
|
+
# question is whether too many packets are tied to trust the result.
|
|
211
|
+
n_dupes = int(np.sum(np.diff(candidate) <= 0)) if candidate.size else 0
|
|
212
|
+
if candidate.size == len(lines) and n_dupes <= max(1, int(0.01 * candidate.size)):
|
|
213
|
+
timestamps = candidate
|
|
214
|
+
else:
|
|
215
|
+
logging.debug("Packet-level pts unusable (incomplete or too many duplicates); falling back.")
|
|
198
216
|
except subprocess.CalledProcessError as e:
|
|
199
|
-
logging.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
217
|
+
logging.debug("Fast packet-level probe failed, falling back: %s", e)
|
|
218
|
+
|
|
219
|
+
if timestamps is None:
|
|
220
|
+
# Fallback: decode every frame to read its best-effort timestamp.
|
|
221
|
+
ts_cmd = [
|
|
222
|
+
"ffprobe", "-v", "error",
|
|
223
|
+
"-select_streams", "v:0",
|
|
224
|
+
"-show_entries", "frame=best_effort_timestamp_time",
|
|
225
|
+
"-of", "csv=p=0",
|
|
226
|
+
path
|
|
227
|
+
]
|
|
228
|
+
try:
|
|
229
|
+
proc = subprocess.run(ts_cmd, capture_output=True, text=True, check=True)
|
|
230
|
+
raw = proc.stdout
|
|
231
|
+
except subprocess.CalledProcessError as e:
|
|
232
|
+
logging.error("ffprobe error: %s", e)
|
|
233
|
+
return []
|
|
234
|
+
timestamps = _parse_ffprobe_csv_floats(raw)
|
|
204
235
|
|
|
205
236
|
if sanity_check:
|
|
206
237
|
# Grab the frame count
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import math
|
|
22
22
|
import numpy as np
|
|
23
23
|
from scipy import signal
|
|
24
|
+
from scipy.linalg import solveh_banded
|
|
24
25
|
from scipy.ndimage import uniform_filter1d
|
|
25
26
|
from scipy.sparse import spdiags
|
|
26
27
|
from typing import Union, Tuple
|
|
@@ -151,18 +152,20 @@ def detrend(
|
|
|
151
152
|
T = z.shape[axis]
|
|
152
153
|
if T < 3:
|
|
153
154
|
return z
|
|
154
|
-
# Identity matrix
|
|
155
|
-
I = np.identity(T)
|
|
156
155
|
# Regularization matrix
|
|
157
156
|
D2 = spdiags(
|
|
158
157
|
[np.ones(T), -2*np.ones(T), np.ones(T)],
|
|
159
|
-
[0, 1, 2], (T-2), T)
|
|
160
|
-
#
|
|
161
|
-
|
|
158
|
+
[0, 1, 2], (T-2), T)
|
|
159
|
+
# I+lambda^2*D2’*D2 is symmetric pentadiagonal, so solve it in banded form
|
|
160
|
+
DTD = (D2.T @ D2).todia()
|
|
161
|
+
ab = np.zeros((3, T))
|
|
162
|
+
ab[2] = 1. + (Lambda**2) * DTD.diagonal(0)
|
|
163
|
+
ab[1, 1:] = (Lambda**2) * DTD.diagonal(1)
|
|
164
|
+
ab[0, 2:] = (Lambda**2) * DTD.diagonal(2)
|
|
162
165
|
# Compute the detrending operation (vectorized)
|
|
163
166
|
if axis == 0:
|
|
164
167
|
z = np.transpose(z)
|
|
165
|
-
z_trend =
|
|
168
|
+
z_trend = solveh_banded(ab, z.T)
|
|
166
169
|
proc_z = (z.T - z_trend)
|
|
167
170
|
if axis == 1:
|
|
168
171
|
proc_z = np.transpose(proc_z)
|
|
@@ -56,7 +56,7 @@ class ModelSaver(object):
|
|
|
56
56
|
dir: str = "checkpoints",
|
|
57
57
|
keep_best: int = 5,
|
|
58
58
|
keep_latest: int = 1,
|
|
59
|
-
save_format: str = "
|
|
59
|
+
save_format: str = "keras",
|
|
60
60
|
save_optimizer: bool = False,
|
|
61
61
|
compare_fn: Callable[[float, float], bool] = lambda x,y: x.score < y.score,
|
|
62
62
|
sort_reverse: bool = False,
|
|
@@ -68,7 +68,7 @@ class ModelSaver(object):
|
|
|
68
68
|
dir: The directory where models should be saved
|
|
69
69
|
keep_best: The number of best scoring models to keep
|
|
70
70
|
keep_latest: The number of latest models to keep
|
|
71
|
-
save_format: Model format for saving ['
|
|
71
|
+
save_format: Model format for saving ['keras' or 'h5']
|
|
72
72
|
save_optimizer: Also save optimizer state?
|
|
73
73
|
compare_fn: Function that compares two scores
|
|
74
74
|
sort_reverse: Reverse sort order?
|
|
@@ -110,15 +110,27 @@ class ModelSaver(object):
|
|
|
110
110
|
"""
|
|
111
111
|
assert isinstance(model, tf.keras.Model)
|
|
112
112
|
assert isinstance(filepath, str)
|
|
113
|
-
if self.save_format == 'h5': filepath += '.h5'
|
|
114
|
-
# Save model
|
|
115
113
|
if self.save_optimizer:
|
|
116
|
-
model.
|
|
117
|
-
|
|
118
|
-
|
|
114
|
+
# Full model saving requires .keras or .h5
|
|
115
|
+
if self.save_format == 'h5':
|
|
116
|
+
filepath += '.h5'
|
|
117
|
+
elif self.save_format == 'keras':
|
|
118
|
+
filepath += '.keras'
|
|
119
|
+
else:
|
|
120
|
+
raise ValueError(f"Unsupported save_format: {self.save_format}. Use 'keras' or 'h5'.")
|
|
121
|
+
model.save(filepath=filepath, overwrite=True)
|
|
119
122
|
else:
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
# Keras 3 strictly demands .weights.h5 for weights-only saves, regardless of format choice
|
|
124
|
+
filepath += '.weights.h5'
|
|
125
|
+
model.save_weights(filepath=filepath, overwrite=True)
|
|
126
|
+
|
|
127
|
+
def _cleanup_old_files(self, filepath_prefix: str):
|
|
128
|
+
"""Helper to clean up old files or directories robustly."""
|
|
129
|
+
for file_or_dir in glob.glob(fr"{filepath_prefix}*"):
|
|
130
|
+
if os.path.isdir(file_or_dir):
|
|
131
|
+
shutil.rmtree(file_or_dir)
|
|
132
|
+
else:
|
|
133
|
+
os.remove(file_or_dir)
|
|
122
134
|
|
|
123
135
|
def save_keep(
|
|
124
136
|
self,
|
|
@@ -170,11 +182,7 @@ class ModelSaver(object):
|
|
|
170
182
|
self.__save(model, filepath=candidate.filepath)
|
|
171
183
|
# Prune candidate
|
|
172
184
|
for candidate in self.latest_candidates[self.keep_latest:]:
|
|
173
|
-
|
|
174
|
-
if self.save_format == 'tf' and self.save_optimizer:
|
|
175
|
-
shutil.rmtree(file)
|
|
176
|
-
else:
|
|
177
|
-
os.remove(file)
|
|
185
|
+
self._cleanup_old_files(candidate.filepath)
|
|
178
186
|
self.latest_candidates = self.latest_candidates[0:self.keep_latest]
|
|
179
187
|
|
|
180
188
|
def save_best(
|
|
@@ -211,11 +219,7 @@ class ModelSaver(object):
|
|
|
211
219
|
# Prune candidates
|
|
212
220
|
for candidate in self.best_candidates[self.keep_best:]:
|
|
213
221
|
self.log_fn(f"Removing old model {candidate.filepath} with score {candidate.score:.4f}")
|
|
214
|
-
|
|
215
|
-
if self.save_format == 'tf' and self.save_optimizer:
|
|
216
|
-
shutil.rmtree(file)
|
|
217
|
-
else:
|
|
218
|
-
os.remove(file)
|
|
222
|
+
self._cleanup_old_files(candidate.filepath)
|
|
219
223
|
self.best_candidates = self.best_candidates[0:self.keep_best]
|
|
220
224
|
else:
|
|
221
225
|
# Skip the candidate
|
|
@@ -174,6 +174,15 @@ class ReduceNanSum:
|
|
|
174
174
|
out = tf.where(tf.reduce_all(tf.logical_not(mask), axis=self.axis),
|
|
175
175
|
tf.cast(self.default, x.dtype),
|
|
176
176
|
sum)
|
|
177
|
+
# TODO(plethnet KNOWN_ISSUES.md #2): this gradient never references
|
|
178
|
+
# self.weight, even though the forward pass above weights `x` before
|
|
179
|
+
# summing (`x * weight`). d(out)/dx should be `weight` at finite
|
|
180
|
+
# positions, not 1. In production this means ReduceNanSum(weight=
|
|
181
|
+
# loss_weights, ...) (plethnet_tensorflow/engine.py's per-signal loss
|
|
182
|
+
# combination) has loss_weights affecting only the logged loss value,
|
|
183
|
+
# not the actual training gradient. See KNOWN_ISSUES.md before fixing -
|
|
184
|
+
# every plethnet_v* checkpoint trained so far, including production
|
|
185
|
+
# plethnet_v4, was trained with this behavior.
|
|
177
186
|
def grad(upstream: tf.Tensor) -> tf.Tensor:
|
|
178
187
|
# Tile upstream to match x
|
|
179
188
|
if self.axis is not None:
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (c) 2026 Philipp Rouast
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
import tensorflow as tf
|
|
22
|
+
|
|
23
|
+
class EpochAdam(tf.keras.optimizers.Adam):
|
|
24
|
+
"""Adam optimizer that natively tracks epochs."""
|
|
25
|
+
def __init__(self, learning_rate=0.001, **kwargs):
|
|
26
|
+
super().__init__(learning_rate=learning_rate, **kwargs)
|
|
27
|
+
with tf.init_scope():
|
|
28
|
+
self._epochs = tf.Variable(
|
|
29
|
+
initial_value=0, name="epochs", dtype=tf.int64, trainable=False
|
|
30
|
+
)
|
|
31
|
+
@property
|
|
32
|
+
def epochs(self):
|
|
33
|
+
return self._epochs
|
|
34
|
+
@epochs.setter
|
|
35
|
+
def epochs(self, variable):
|
|
36
|
+
if self.built:
|
|
37
|
+
raise RuntimeError(
|
|
38
|
+
"Cannot set `epochs` to a new Variable after the Optimizer weights "
|
|
39
|
+
"have been created. Please set `epochs` before calling `apply_gradients()`."
|
|
40
|
+
)
|
|
41
|
+
self._epochs = variable
|
|
42
|
+
def finish_epoch(self):
|
|
43
|
+
"""Increment epoch count."""
|
|
44
|
+
self._epochs.assign_add(1)
|
|
45
|
+
|
|
46
|
+
class EpochAdamW(tf.keras.optimizers.AdamW):
|
|
47
|
+
"""AdamW optimizer that natively tracks epochs."""
|
|
48
|
+
def __init__(self, learning_rate=0.001, weight_decay=0.004, **kwargs):
|
|
49
|
+
super().__init__(learning_rate=learning_rate, weight_decay=weight_decay, **kwargs)
|
|
50
|
+
with tf.init_scope():
|
|
51
|
+
self._epochs = tf.Variable(
|
|
52
|
+
initial_value=0, name="epochs", dtype=tf.int64, trainable=False
|
|
53
|
+
)
|
|
54
|
+
@property
|
|
55
|
+
def epochs(self):
|
|
56
|
+
return self._epochs
|
|
57
|
+
@epochs.setter
|
|
58
|
+
def epochs(self, variable):
|
|
59
|
+
if self.built:
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
"Cannot set `epochs` to a new Variable after the Optimizer weights "
|
|
62
|
+
"have been created. Please set `epochs` before calling `apply_gradients()`."
|
|
63
|
+
)
|
|
64
|
+
self._epochs = variable
|
|
65
|
+
def finish_epoch(self):
|
|
66
|
+
"""Increment epoch count."""
|
|
67
|
+
self._epochs.assign_add(1)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Copyright (c) 2026 Philipp Rouast
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
import torch
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def balanced_sample_weights(labels: torch.Tensor, unique: torch.Tensor) -> torch.Tensor:
|
|
25
|
+
"""Calculate weights for a batch of dense categorical labels intended to be
|
|
26
|
+
multiplied with the losses.
|
|
27
|
+
- Larger weights for examples of under-represented classes, and smaller
|
|
28
|
+
weights for overrepresented classes, while keeping the total loss constant.
|
|
29
|
+
- Important: Only works for dense label representation that equal range from 0 to n.
|
|
30
|
+
Args:
|
|
31
|
+
labels: The dense categorical labels of shape (batch_size,) or (batch_size, 1)
|
|
32
|
+
unique: The unique labels of shape (n_unique_labels,)
|
|
33
|
+
Returns:
|
|
34
|
+
weights: The weights with same shape as labels.
|
|
35
|
+
"""
|
|
36
|
+
original_shape = labels.shape
|
|
37
|
+
f_labels = labels.reshape(-1).long()
|
|
38
|
+
minlength = int(unique.max().item()) + 1
|
|
39
|
+
count = torch.bincount(f_labels, minlength=minlength)[unique.long()]
|
|
40
|
+
batch_size = f_labels.numel()
|
|
41
|
+
unique_count = (count > 0).sum()
|
|
42
|
+
class_weights = torch.where(
|
|
43
|
+
count > 0, batch_size / count.clamp(min=1).to(torch.float32), torch.zeros_like(count, dtype=torch.float32))
|
|
44
|
+
class_weights = class_weights / unique_count.to(torch.float32)
|
|
45
|
+
sample_weights = class_weights[f_labels]
|
|
46
|
+
return sample_weights.reshape(original_shape)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# Copyright (c) 2026 Philipp Rouast
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
from typing import List, Union
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def piecewise_constant_decay_with_warmup(
|
|
25
|
+
step: Union[int, float],
|
|
26
|
+
boundaries: List[Union[int, float]],
|
|
27
|
+
values: List[float],
|
|
28
|
+
warmup_init_lr: float,
|
|
29
|
+
warmup_steps: Union[int, float]
|
|
30
|
+
) -> float:
|
|
31
|
+
"""Piecewise constant decay with linear warmup.
|
|
32
|
+
Args:
|
|
33
|
+
step: The current step (or epoch - unit-agnostic, just compared against
|
|
34
|
+
boundaries/warmup_steps in whatever unit they're expressed in).
|
|
35
|
+
boundaries: Step boundaries. len(boundaries) == len(values) - 1.
|
|
36
|
+
values: The constant LR value for each segment between boundaries.
|
|
37
|
+
warmup_init_lr: The initial LR at step 0, ramping linearly to values[0]
|
|
38
|
+
by `warmup_steps`.
|
|
39
|
+
warmup_steps: Number of steps for the warmup ramp.
|
|
40
|
+
Returns:
|
|
41
|
+
The learning rate at `step`.
|
|
42
|
+
"""
|
|
43
|
+
if len(boundaries) != len(values) - 1:
|
|
44
|
+
raise ValueError("The length of boundaries should be 1 less than the length of values")
|
|
45
|
+
if step <= warmup_steps:
|
|
46
|
+
return warmup_init_lr + step * (values[0] - warmup_init_lr) / warmup_steps
|
|
47
|
+
if step <= boundaries[0]:
|
|
48
|
+
return values[0]
|
|
49
|
+
if step > boundaries[-1]:
|
|
50
|
+
return values[-1]
|
|
51
|
+
for low, high, v in zip(boundaries[:-1], boundaries[1:], values[1:-1]):
|
|
52
|
+
if low < step <= high:
|
|
53
|
+
return v
|
|
54
|
+
raise ValueError(f"step={step} did not match any segment of boundaries={boundaries}")
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Copyright (c) 2026 Philipp Rouast
|
|
2
|
+
#
|
|
3
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
4
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
5
|
+
# in the Software without restriction, including without limitation the rights
|
|
6
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
7
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
8
|
+
# furnished to do so, subject to the following conditions:
|
|
9
|
+
#
|
|
10
|
+
# The above copyright notice and this permission notice shall be included in all
|
|
11
|
+
# copies or substantial portions of the Software.
|
|
12
|
+
#
|
|
13
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
14
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
18
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
19
|
+
# SOFTWARE.
|
|
20
|
+
|
|
21
|
+
from typing import Optional, Tuple, Union
|
|
22
|
+
|
|
23
|
+
import torch
|
|
24
|
+
|
|
25
|
+
Dims = Union[int, Tuple[int, ...], None]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _as_dims(dim: Dims) -> Optional[Tuple[int, ...]]:
|
|
29
|
+
if dim is None:
|
|
30
|
+
return None
|
|
31
|
+
return (dim,) if isinstance(dim, int) else tuple(dim)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def reduce_nanmean(x: torch.Tensor, dim: Dims = None) -> torch.Tensor:
|
|
35
|
+
"""torch.mean, ignoring non-finite vals.
|
|
36
|
+
- Returns `nan` for all-nan slices.
|
|
37
|
+
Args:
|
|
38
|
+
x: The input tensor.
|
|
39
|
+
dim: The dimension(s) to reduce.
|
|
40
|
+
Returns:
|
|
41
|
+
The reduced tensor.
|
|
42
|
+
"""
|
|
43
|
+
mask = torch.isfinite(x)
|
|
44
|
+
safe_x = torch.where(mask, x, torch.zeros_like(x))
|
|
45
|
+
dims = _as_dims(dim)
|
|
46
|
+
numerator = safe_x.sum(dim=dims) if dims is not None else safe_x.sum()
|
|
47
|
+
denominator = (mask.sum(dim=dims) if dims is not None else mask.sum()).to(x.dtype)
|
|
48
|
+
return numerator / denominator
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reduce_nansum(
|
|
52
|
+
x: torch.Tensor,
|
|
53
|
+
weight: Optional[torch.Tensor] = None,
|
|
54
|
+
dim: Dims = None,
|
|
55
|
+
default: float = float('nan')
|
|
56
|
+
) -> torch.Tensor:
|
|
57
|
+
"""torch.sum, weighted by weight, ignoring non-finite values.
|
|
58
|
+
- Returns default for all-nan slices.
|
|
59
|
+
Args:
|
|
60
|
+
x: The input tensor.
|
|
61
|
+
weight: The weight tensor, with the same shape as x or broadcastable to it.
|
|
62
|
+
dim: The dimension(s) to reduce.
|
|
63
|
+
default: The value to return for all-non-finite slices.
|
|
64
|
+
Returns:
|
|
65
|
+
The reduced tensor.
|
|
66
|
+
"""
|
|
67
|
+
mask = torch.isfinite(x)
|
|
68
|
+
safe_x = torch.where(mask, x, torch.zeros_like(x))
|
|
69
|
+
if weight is not None:
|
|
70
|
+
safe_weight = torch.where(mask, weight, torch.zeros_like(weight))
|
|
71
|
+
safe_x = safe_x * safe_weight.to(x.dtype)
|
|
72
|
+
dims = _as_dims(dim)
|
|
73
|
+
total = safe_x.sum(dim=dims) if dims is not None else safe_x.sum()
|
|
74
|
+
all_nan = (~mask).all(dim=dims) if dims is not None else (~mask).all()
|
|
75
|
+
return torch.where(all_nan, torch.tensor(default, dtype=x.dtype, device=x.device), total)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _unsqueeze_at(t: torch.Tensor, dims: Tuple[int, ...]) -> torch.Tensor:
|
|
79
|
+
# Ascending order so earlier insertions don't shift later indices.
|
|
80
|
+
for d in sorted(dims):
|
|
81
|
+
t = t.unsqueeze(d)
|
|
82
|
+
return t
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _ReduceNanMeanFn(torch.autograd.Function):
|
|
86
|
+
@staticmethod
|
|
87
|
+
def forward(ctx, x, dims):
|
|
88
|
+
mask = torch.isfinite(x)
|
|
89
|
+
safe_x = torch.where(mask, x, torch.zeros_like(x))
|
|
90
|
+
numerator = safe_x.sum(dim=dims) if dims is not None else safe_x.sum()
|
|
91
|
+
denominator = (mask.sum(dim=dims) if dims is not None else mask.sum()).to(x.dtype)
|
|
92
|
+
ctx.save_for_backward(mask, denominator)
|
|
93
|
+
ctx.dims = dims
|
|
94
|
+
return numerator / denominator
|
|
95
|
+
@staticmethod
|
|
96
|
+
def backward(ctx, grad_output):
|
|
97
|
+
mask, denominator = ctx.saved_tensors
|
|
98
|
+
if ctx.dims is not None:
|
|
99
|
+
grad_output = _unsqueeze_at(grad_output, ctx.dims)
|
|
100
|
+
denominator = _unsqueeze_at(denominator, ctx.dims)
|
|
101
|
+
dx = torch.where(mask, grad_output / denominator, torch.zeros_like(mask, dtype=grad_output.dtype))
|
|
102
|
+
return dx, None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class ReduceNanMean:
|
|
106
|
+
"""torch.mean, ignoring non-finite values. Supports gradient.
|
|
107
|
+
|
|
108
|
+
Behavior when x is non-finite:
|
|
109
|
+
- out: Non-finite vals in a slice contribute 0
|
|
110
|
+
- out: All-non-finite slices are nan
|
|
111
|
+
- grad = 0 at non-finite input positions (including for all-non-finite slices)
|
|
112
|
+
"""
|
|
113
|
+
def __init__(self, dim: Dims = None):
|
|
114
|
+
self.dims = _as_dims(dim)
|
|
115
|
+
def __call__(self, x: torch.Tensor) -> torch.Tensor:
|
|
116
|
+
return _ReduceNanMeanFn.apply(x, self.dims)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class _ReduceNanSumFn(torch.autograd.Function):
|
|
120
|
+
@staticmethod
|
|
121
|
+
def forward(ctx, x, weight, dims, default):
|
|
122
|
+
mask = torch.isfinite(x)
|
|
123
|
+
safe_x = torch.where(mask, x, torch.zeros_like(x))
|
|
124
|
+
if weight is not None:
|
|
125
|
+
safe_weight = torch.where(mask, weight, torch.zeros_like(weight)).to(x.dtype)
|
|
126
|
+
weighted = safe_x * safe_weight
|
|
127
|
+
else:
|
|
128
|
+
weighted = safe_x
|
|
129
|
+
total = weighted.sum(dim=dims) if dims is not None else weighted.sum()
|
|
130
|
+
all_nan = (~mask).all(dim=dims) if dims is not None else (~mask).all()
|
|
131
|
+
out = torch.where(all_nan, torch.tensor(default, dtype=x.dtype, device=x.device), total)
|
|
132
|
+
ctx.save_for_backward(mask)
|
|
133
|
+
ctx.dims = dims
|
|
134
|
+
return out
|
|
135
|
+
@staticmethod
|
|
136
|
+
def backward(ctx, grad_output):
|
|
137
|
+
# Deliberately does not multiply by weight - see ReduceNanSum's docstring.
|
|
138
|
+
(mask,) = ctx.saved_tensors
|
|
139
|
+
if ctx.dims is not None:
|
|
140
|
+
grad_output = _unsqueeze_at(grad_output, ctx.dims)
|
|
141
|
+
grad_output = torch.broadcast_to(grad_output, mask.shape)
|
|
142
|
+
dx = torch.where(mask, grad_output, torch.zeros_like(mask, dtype=grad_output.dtype))
|
|
143
|
+
return dx, None, None, None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
class ReduceNanSum:
|
|
147
|
+
"""torch.sum, weighted by weight, ignoring non-finite values. Supports gradient.
|
|
148
|
+
|
|
149
|
+
Behavior when x is non-finite:
|
|
150
|
+
- out: Non-finite vals in a slice contribute 0
|
|
151
|
+
- out: All-non-finite slices are set to default value
|
|
152
|
+
- grad = 0 at non-finite input positions
|
|
153
|
+
|
|
154
|
+
TODO(plethnet KNOWN_ISSUES.md #2): when `weight` is given, it scales the
|
|
155
|
+
forward sum (`x * weight`) but is NOT applied in the gradient - matches a
|
|
156
|
+
discrepancy in TF's own hand-written gradient, preserved here verbatim for
|
|
157
|
+
parity with what plethnet_v4 was actually trained with. Don't fix without
|
|
158
|
+
sign-off - it would change real training dynamics.
|
|
159
|
+
"""
|
|
160
|
+
def __init__(self, weight: Optional[torch.Tensor] = None, dim: Dims = None, default: float = float('nan')):
|
|
161
|
+
self.weight = weight
|
|
162
|
+
self.dims = _as_dims(dim)
|
|
163
|
+
self.default = default
|
|
164
|
+
def __call__(self, x: torch.Tensor) -> torch.Tensor:
|
|
165
|
+
return _ReduceNanSumFn.apply(x, self.weight, self.dims, self.default)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class _NanLinearCombinationFn(torch.autograd.Function):
|
|
169
|
+
@staticmethod
|
|
170
|
+
def forward(ctx, x, val_1, val_2):
|
|
171
|
+
val_1 = torch.broadcast_to(val_1, x.shape)
|
|
172
|
+
val_2 = torch.broadcast_to(val_2, x.shape)
|
|
173
|
+
ctx.save_for_backward(x, val_1, val_2)
|
|
174
|
+
return (1 - x) * val_1 + x * val_2
|
|
175
|
+
@staticmethod
|
|
176
|
+
def backward(ctx, grad_output):
|
|
177
|
+
x, val_1, val_2 = ctx.saved_tensors
|
|
178
|
+
mask = torch.isfinite(val_1) & torch.isfinite(val_2)
|
|
179
|
+
grad_x = torch.where(mask, grad_output * (val_2 - val_1), torch.zeros_like(x))
|
|
180
|
+
grad_val_1 = grad_output * (1 - x)
|
|
181
|
+
grad_val_2 = grad_output * x
|
|
182
|
+
return grad_x, grad_val_1, grad_val_2
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class NanLinearCombination:
|
|
186
|
+
"""Linear combination with one fixed element. Supports gradient.
|
|
187
|
+
- Calculates (1 - x) * val_1 + x * val_2
|
|
188
|
+
- Behavior when val_1 or val_2 are non-finite: out = nan, grad wrt x = 0
|
|
189
|
+
(grad wrt val_1/val_2 are ordinary (1-x)/x)
|
|
190
|
+
"""
|
|
191
|
+
def __call__(self, x: torch.Tensor, val_1: torch.Tensor, val_2: torch.Tensor) -> torch.Tensor:
|
|
192
|
+
"""Compute the linear combination.
|
|
193
|
+
Args:
|
|
194
|
+
x: The combination weight. All elements must be in [0, 1]
|
|
195
|
+
val_1: The first value used in the linear combination.
|
|
196
|
+
val_2: The second value used in the linear combination.
|
|
197
|
+
Returns:
|
|
198
|
+
out: The computed linear combination, broadcast to x's shape.
|
|
199
|
+
"""
|
|
200
|
+
return _NanLinearCombinationFn.apply(x, val_1, val_2)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: prpy
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.5
|
|
4
4
|
Summary: Collection of Python utils for signal, image, and video processing
|
|
5
5
|
Author-email: Philipp Rouast <philipp@rouast.com>
|
|
6
6
|
License: MIT License
|
|
@@ -11,7 +11,7 @@ Classifier: Programming Language :: Python :: 3
|
|
|
11
11
|
Classifier: Operating System :: OS Independent
|
|
12
12
|
Classifier: Development Status :: 4 - Beta
|
|
13
13
|
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
-
Requires-Python:
|
|
14
|
+
Requires-Python: <3.13,>=3.10
|
|
15
15
|
Description-Content-Type: text/markdown
|
|
16
16
|
License-File: LICENSE
|
|
17
17
|
Requires-Dist: numpy>=1.24
|
|
@@ -29,7 +29,7 @@ Requires-Dist: scipy; extra == "numpy-min"
|
|
|
29
29
|
Requires-Dist: Pillow; extra == "numpy-min"
|
|
30
30
|
Requires-Dist: psutil; extra == "numpy-min"
|
|
31
31
|
Provides-Extra: tensorflow
|
|
32
|
-
Requires-Dist: tensorflow
|
|
32
|
+
Requires-Dist: tensorflow>2.17.0; extra == "tensorflow"
|
|
33
33
|
Provides-Extra: torch
|
|
34
34
|
Requires-Dist: torch>=2.1.0; extra == "torch"
|
|
35
35
|
Provides-Extra: test
|
|
@@ -50,8 +50,6 @@ It contains subpackages for working with `numpy`, `ffmpeg`, `tensorflow`, and `t
|
|
|
50
50
|
|
|
51
51
|
General prerequisites are `python>=3.10` and `ffmpeg` installed and accessible via the `$PATH` environment variable.
|
|
52
52
|
|
|
53
|
-
- Please note: If using `numpy` or `tensorflow` options, we only support Python `<3.12` because of the dependencies.
|
|
54
|
-
|
|
55
53
|
The easiest way to install the latest version of `prpy`:
|
|
56
54
|
|
|
57
55
|
```
|