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.
Files changed (69) hide show
  1. {prpy-0.4.2 → prpy-0.4.5}/PKG-INFO +3 -5
  2. {prpy-0.4.2 → prpy-0.4.5}/README.md +0 -2
  3. {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/probe.py +46 -15
  4. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/filters.py +9 -6
  5. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/model_saver.py +23 -19
  6. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/nan.py +9 -0
  7. prpy-0.4.5/prpy/tensorflow/optimizer.py +67 -0
  8. prpy-0.4.5/prpy/torch/loss.py +46 -0
  9. prpy-0.4.5/prpy/torch/lr_schedule.py +54 -0
  10. prpy-0.4.5/prpy/torch/nan.py +200 -0
  11. {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/PKG-INFO +3 -5
  12. {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/SOURCES.txt +9 -1
  13. {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/requires.txt +1 -1
  14. prpy-0.4.5/prpy.egg-info/scm_file_list.json +65 -0
  15. prpy-0.4.5/prpy.egg-info/scm_version.json +8 -0
  16. {prpy-0.4.2 → prpy-0.4.5}/pyproject.toml +13 -3
  17. {prpy-0.4.2 → prpy-0.4.5}/tests/conftest.py +14 -0
  18. {prpy-0.4.2 → prpy-0.4.5}/tests/test_ffmpeg.py +30 -5
  19. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_filters.py +11 -0
  20. {prpy-0.4.2 → prpy-0.4.5}/tests/test_tensorflow.py +23 -64
  21. {prpy-0.4.2 → prpy-0.4.5}/tests/test_torch.py +18 -43
  22. prpy-0.4.5/tests/test_torch_loss.py +40 -0
  23. prpy-0.4.5/tests/test_torch_lr_schedule.py +49 -0
  24. prpy-0.4.5/tests/test_torch_nan.py +155 -0
  25. prpy-0.4.2/prpy/tensorflow/optimizer.py +0 -233
  26. {prpy-0.4.2 → prpy-0.4.5}/.gitignore +0 -0
  27. {prpy-0.4.2 → prpy-0.4.5}/CONTRIBUTING.md +0 -0
  28. {prpy-0.4.2 → prpy-0.4.5}/LICENSE +0 -0
  29. {prpy-0.4.2 → prpy-0.4.5}/MANIFEST.in +0 -0
  30. {prpy-0.4.2 → prpy-0.4.5}/prpy/__init__.py +0 -0
  31. {prpy-0.4.2 → prpy-0.4.5}/prpy/constants.py +0 -0
  32. {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/__init__.py +0 -0
  33. {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/readwrite.py +0 -0
  34. {prpy-0.4.2 → prpy-0.4.5}/prpy/ffmpeg/utils.py +0 -0
  35. {prpy-0.4.2 → prpy-0.4.5}/prpy/helpers.py +0 -0
  36. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/__init__.py +0 -0
  37. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/core.py +0 -0
  38. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/detect.py +0 -0
  39. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/face.py +0 -0
  40. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/freq.py +0 -0
  41. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/image.py +0 -0
  42. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/image_ops.c +0 -0
  43. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/interp.py +0 -0
  44. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/metric.py +0 -0
  45. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/physio.py +0 -0
  46. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/rolling.py +0 -0
  47. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/stride_tricks.py +0 -0
  48. {prpy-0.4.2 → prpy-0.4.5}/prpy/numpy/utils.py +0 -0
  49. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/__init__.py +0 -0
  50. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/image.py +0 -0
  51. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/loss.py +0 -0
  52. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/lr_schedule.py +0 -0
  53. {prpy-0.4.2 → prpy-0.4.5}/prpy/tensorflow/signal.py +0 -0
  54. {prpy-0.4.2 → prpy-0.4.5}/prpy/torch/__init__.py +0 -0
  55. {prpy-0.4.2 → prpy-0.4.5}/prpy/torch/model_saver.py +0 -0
  56. {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/dependency_links.txt +0 -0
  57. {prpy-0.4.2 → prpy-0.4.5}/prpy.egg-info/top_level.txt +0 -0
  58. {prpy-0.4.2 → prpy-0.4.5}/setup.cfg +0 -0
  59. {prpy-0.4.2 → prpy-0.4.5}/setup.py +0 -0
  60. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_core.py +0 -0
  61. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_detect.py +0 -0
  62. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_face.py +0 -0
  63. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_freq.py +0 -0
  64. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_image.py +0 -0
  65. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_interp.py +0 -0
  66. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_metric.py +0 -0
  67. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_rolling.py +0 -0
  68. {prpy-0.4.2 → prpy-0.4.5}/tests/test_numpy_stride_tricks.py +0 -0
  69. {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.2
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: >=3.10
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<2.13; extra == "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
- # Build the ffprobe command to extract the best effort timestamp for each frame.
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(ts_cmd, capture_output=True, text=True, check=True)
197
- raw = proc.stdout
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.error("ffprobe error: %s", e)
200
- return []
201
- # Clean trailing commas and process with numpy
202
- cleaned = "\n".join(line.rstrip(",") for line in raw.splitlines())
203
- timestamps = np.fromstring(cleaned, dtype=float, sep="\n")
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).toarray()
160
- # Inverse of I+lambda^2*D2’*D2
161
- A = I + (Lambda**2) * np.dot(D2.T, D2)
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 = np.linalg.solve(A, z.T)
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 = "tf",
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 ['tf' or 'h5']
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.save(
117
- filepath=filepath, overwrite=True, include_optimizer=True,
118
- save_format=self.save_format)
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
- model.save_weights(
121
- filepath=filepath, overwrite=True, save_format=self.save_format)
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
- for file in glob.glob(fr"{candidate.filepath}*"):
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
- for file in glob.glob(fr"{candidate.filepath}*"):
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.2
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: >=3.10
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<2.13; extra == "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
  ```