From 3a085e0f7ead99a3bdb910829c3234f142176b77 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Mon, 16 Feb 2026 01:49:54 -0100 Subject: [PATCH 1/7] fix: eliminate critical/high security vulnerabilities - Remove numpy.load(allow_pickle=True) in compress_octree.py and training_pipeline.py to prevent arbitrary code execution via crafted .npy/.npz files. Metadata now saved as JSON sidecar; optimizer variables saved as individual .npy files with numeric dtypes. - Add path validation in training_pipeline.py (traversal guard) and evaluation_pipeline.py (existence check) for checkpoint loading. - Replace model.save() with model.save_weights() in cli_train.py to avoid full SavedModel format vulnerable to Keras deserialization RCE. Co-Authored-By: Claude Opus 4.6 --- src/cli_train.py | 4 ++-- src/compress_octree.py | 35 ++++++++++++++++++++++++++------- src/evaluation_pipeline.py | 5 ++++- src/training_pipeline.py | 25 ++++++++++++----------- tests/test_compress_octree.py | 8 +++++++- tests/test_training_pipeline.py | 5 +++++ 6 files changed, 59 insertions(+), 23 deletions(-) diff --git a/src/cli_train.py b/src/cli_train.py index 9eb21a00e..36ad2c44a 100644 --- a/src/cli_train.py +++ b/src/cli_train.py @@ -70,7 +70,7 @@ def tune_hyperparameters(input_dir, output_dir, num_epochs=10): best_hps = tuner.get_best_hyperparameters(num_trials=1)[0] print("Best Hyperparameters:", best_hps.values) - best_model.save(os.path.join(output_dir, 'best_model')) + best_model.save_weights(os.path.join(output_dir, 'best_model.weights.h5')) def main(): parser = argparse.ArgumentParser(description="Train a point cloud compression model with hyperparameter tuning.") @@ -94,7 +94,7 @@ def main(): model.compile(optimizer='adam', loss='mean_squared_error') dataset = load_and_preprocess_data(args.input_dir, args.batch_size) model.fit(dataset, epochs=args.num_epochs) - model.save(os.path.join(args.output_dir, 'trained_model')) + model.save_weights(os.path.join(args.output_dir, 'trained_model.weights.h5')) if __name__ == "__main__": main() diff --git a/src/compress_octree.py b/src/compress_octree.py index dd738ac38..cc88e9caa 100644 --- a/src/compress_octree.py +++ b/src/compress_octree.py @@ -186,14 +186,35 @@ def _save_debug_info(self, stage: str, data: Dict[str, Any]) -> None: def save_compressed(self, grid: np.ndarray, metadata: Dict[str, Any], filename: str) -> None: """Save compressed data with metadata.""" - os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True) - np.savez_compressed(filename, grid=grid, metadata=metadata) + import json - if self.debug_output: - debug_path = f"{filename}.debug.npz" - np.savez_compressed(debug_path, **metadata) + os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True) + # Save grid without pickle (bool array, no object dtype) + np.savez_compressed(filename, grid=grid) + # Save metadata as JSON sidecar (safe, no arbitrary code execution) + meta_path = filename + '.meta.json' + serializable = {} + for k, v in metadata.items(): + if isinstance(v, np.ndarray): + serializable[k] = v.tolist() + elif isinstance(v, (np.floating, np.integer)): + serializable[k] = v.item() + else: + serializable[k] = v + with open(meta_path, 'w') as f: + json.dump(serializable, f) def load_compressed(self, filename: str) -> Tuple[np.ndarray, Dict[str, Any]]: """Load compressed data with metadata.""" - data = np.load(filename, allow_pickle=True) - return data['grid'], data['metadata'].item() + import json + + data = np.load(filename, allow_pickle=False) + grid = data['grid'] + meta_path = filename + '.meta.json' + with open(meta_path, 'r') as f: + metadata = json.load(f) + # Convert lists back to numpy arrays for known array fields + for key in ('min_bounds', 'max_bounds', 'ranges', 'normal_grid'): + if key in metadata: + metadata[key] = np.array(metadata[key]) + return grid, metadata diff --git a/src/evaluation_pipeline.py b/src/evaluation_pipeline.py index a1787110b..2ede7926c 100644 --- a/src/evaluation_pipeline.py +++ b/src/evaluation_pipeline.py @@ -52,7 +52,10 @@ def _load_model(self) -> DeepCompressModel: # Load weights if checkpoint provided checkpoint_path = self.config.get('checkpoint_path') if checkpoint_path: - model.load_weights(checkpoint_path) + resolved = Path(checkpoint_path).resolve() + if not resolved.exists(): + raise FileNotFoundError(f"Checkpoint not found: {resolved}") + model.load_weights(str(resolved)) return model diff --git a/src/training_pipeline.py b/src/training_pipeline.py index ffdb1070f..18dc67162 100644 --- a/src/training_pipeline.py +++ b/src/training_pipeline.py @@ -155,26 +155,27 @@ def save_checkpoint(self, name: str): for opt_name, optimizer in self.optimizers.items(): if optimizer.variables: - opt_weights = [v.numpy() for v in optimizer.variables] - np.save( - str(checkpoint_path / f'{opt_name}_optimizer.npy'), - np.array(opt_weights, dtype=object), - allow_pickle=True, - ) + opt_dir = checkpoint_path / f'{opt_name}_optimizer' + opt_dir.mkdir(parents=True, exist_ok=True) + for i, v in enumerate(optimizer.variables): + np.save(str(opt_dir / f'{i}.npy'), v.numpy()) self.logger.info(f"Saved checkpoint: {name}") def load_checkpoint(self, name: str): - checkpoint_path = self.checkpoint_dir / name + checkpoint_path = (self.checkpoint_dir / name).resolve() + if not str(checkpoint_path).startswith(str(self.checkpoint_dir.resolve())): + raise ValueError(f"Checkpoint path escapes checkpoint directory: {name}") self.model.load_weights(str(checkpoint_path / 'model.weights.h5')) self.entropy_model.load_weights(str(checkpoint_path / 'entropy.weights.h5')) for opt_name, optimizer in self.optimizers.items(): - opt_path = checkpoint_path / f'{opt_name}_optimizer.npy' - if opt_path.exists() and optimizer.variables: - opt_weights = np.load(str(opt_path), allow_pickle=True) - for var, w in zip(optimizer.variables, opt_weights): - var.assign(w) + opt_dir = checkpoint_path / f'{opt_name}_optimizer' + if opt_dir.exists() and optimizer.variables: + for i, var in enumerate(optimizer.variables): + path = opt_dir / f'{i}.npy' + if path.exists(): + var.assign(np.load(str(path), allow_pickle=False)) self.logger.info(f"Loaded checkpoint: {name}") diff --git a/tests/test_compress_octree.py b/tests/test_compress_octree.py index 87ae819f7..5f8fdafb4 100644 --- a/tests/test_compress_octree.py +++ b/tests/test_compress_octree.py @@ -116,6 +116,7 @@ def test_octree_partitioning(self): def test_save_and_load(self): """Test saving and loading functionality.""" save_path = Path(self.test_env['tmp_path']) / "test_compressed.npz" + meta_path = Path(str(save_path) + '.meta.json') # Compress and save grid, metadata = self.compressor.compress( @@ -124,8 +125,9 @@ def test_save_and_load(self): ) self.compressor.save_compressed(grid, metadata, str(save_path)) - # Verify file exists + # Verify both files exist self.assertTrue(save_path.exists()) + self.assertTrue(meta_path.exists()) # Load and verify loaded_grid, loaded_metadata = self.compressor.load_compressed(str(save_path)) @@ -137,6 +139,10 @@ def test_save_and_load(self): for key in ['min_bounds', 'max_bounds', 'ranges', 'has_normals']: self.assertIn(key, loaded_metadata) + # Check array fields are numpy arrays after load + for key in ['min_bounds', 'max_bounds', 'ranges']: + self.assertIsInstance(loaded_metadata[key], np.ndarray) + def test_error_handling(self): """Test error handling.""" # Test empty point cloud diff --git a/tests/test_training_pipeline.py b/tests/test_training_pipeline.py index 4a5b4290a..6967dff08 100644 --- a/tests/test_training_pipeline.py +++ b/tests/test_training_pipeline.py @@ -99,6 +99,11 @@ def test_save_load_checkpoint(self, pipeline, tmp_path): checkpoint_dir = Path(pipeline.checkpoint_dir) / checkpoint_name assert (checkpoint_dir / 'model.weights.h5').exists() assert (checkpoint_dir / 'entropy.weights.h5').exists() + # Optimizer variables saved as individual .npy files in subdirectories + for opt_name in pipeline.optimizers: + opt_dir = checkpoint_dir / f'{opt_name}_optimizer' + if pipeline.optimizers[opt_name].variables: + assert opt_dir.exists() new_pipeline = TrainingPipeline(pipeline.config_path) # Build the new model before loading weights From 6bf1ceee7d4269ccc808b53d1528f4cbf697e57b Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Mon, 16 Feb 2026 15:02:01 -0100 Subject: [PATCH 2/7] test: add 26 tests for security fixes + fix 3 bugs found during coverage analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes in source: - compress_octree: _save_debug_info no longer pickles dicts (only saves ndarrays) - compress_octree: save_compressed converts NaN/Inf scalars to None for valid JSON - training_pipeline: path validation uses Path.relative_to() to prevent prefix collision bypass (e.g. checkpoints_evil matching checkpoints prefix) New tests (26 total, 213 → 239): - test_compress_octree (13): NaN/Inf metadata, empty grid, no-normals roundtrip, missing sidecar/grid files, debug pickle prevention, metadata value fidelity, numpy scalar types, dtype change documentation, E2E quality check - test_training_pipeline (9): path traversal/absolute/prefix-collision rejection, NaN in optimizer vars, save before training, missing weights, partial optimizer files, old pickle format ignored, optimizer state value fidelity - test_evaluation_pipeline (3): no checkpoint configured, empty string checkpoint, missing checkpoint raises FileNotFoundError - test_integration (1): checkpoint resume preserves eval loss Co-Authored-By: Claude Opus 4.6 --- src/compress_octree.py | 11 +- src/training_pipeline.py | 4 +- tests/test_compress_octree.py | 213 ++++++++++++++++++++++++++++++ tests/test_evaluation_pipeline.py | 58 ++++++++ tests/test_integration.py | 34 +++++ tests/test_training_pipeline.py | 187 ++++++++++++++++++++++++++ 6 files changed, 504 insertions(+), 3 deletions(-) diff --git a/src/compress_octree.py b/src/compress_octree.py index cc88e9caa..08d83d681 100644 --- a/src/compress_octree.py +++ b/src/compress_octree.py @@ -181,12 +181,13 @@ def _save_debug_info(self, stage: str, data: Dict[str, Any]) -> None: os.makedirs(debug_dir, exist_ok=True) for name, array in data.items(): - if isinstance(array, (np.ndarray, dict)): + if isinstance(array, np.ndarray): np.save(os.path.join(debug_dir, f"{name}.npy"), array) def save_compressed(self, grid: np.ndarray, metadata: Dict[str, Any], filename: str) -> None: """Save compressed data with metadata.""" import json + import math os.makedirs(os.path.dirname(os.path.abspath(filename)), exist_ok=True) # Save grid without pickle (bool array, no object dtype) @@ -198,7 +199,13 @@ def save_compressed(self, grid: np.ndarray, metadata: Dict[str, Any], filename: if isinstance(v, np.ndarray): serializable[k] = v.tolist() elif isinstance(v, (np.floating, np.integer)): - serializable[k] = v.item() + val = v.item() + if isinstance(val, float) and (math.isnan(val) or math.isinf(val)): + serializable[k] = None + else: + serializable[k] = val + elif isinstance(v, float) and (math.isnan(v) or math.isinf(v)): + serializable[k] = None else: serializable[k] = v with open(meta_path, 'w') as f: diff --git a/src/training_pipeline.py b/src/training_pipeline.py index 18dc67162..ab92b923c 100644 --- a/src/training_pipeline.py +++ b/src/training_pipeline.py @@ -164,7 +164,9 @@ def save_checkpoint(self, name: str): def load_checkpoint(self, name: str): checkpoint_path = (self.checkpoint_dir / name).resolve() - if not str(checkpoint_path).startswith(str(self.checkpoint_dir.resolve())): + try: + checkpoint_path.relative_to(self.checkpoint_dir.resolve()) + except ValueError: raise ValueError(f"Checkpoint path escapes checkpoint directory: {name}") self.model.load_weights(str(checkpoint_path / 'model.weights.h5')) self.entropy_model.load_weights(str(checkpoint_path / 'entropy.weights.h5')) diff --git a/tests/test_compress_octree.py b/tests/test_compress_octree.py index 5f8fdafb4..d66347908 100644 --- a/tests/test_compress_octree.py +++ b/tests/test_compress_octree.py @@ -162,5 +162,218 @@ def test_error_handling(self): with self.assertRaisesRegex(ValueError, "shape must match"): self.compressor.compress(self.point_cloud, normals=wrong_shape_normals) + # --- NaN / Inf / degenerate value tests --- + + def test_save_load_metadata_with_nan_and_inf(self): + """NaN and Inf scalar values in metadata are converted to None.""" + save_path = Path(self.test_env['tmp_path']) / "special_values.npz" + grid = np.zeros((64, 64, 64), dtype=bool) + grid[0, 0, 0] = True + metadata = { + 'min_bounds': np.array([0.0, 0.0, 0.0]), + 'max_bounds': np.array([1.0, 1.0, 1.0]), + 'ranges': np.array([1.0, 1.0, 1.0]), + 'has_normals': False, + 'nan_value': float('nan'), + 'inf_value': float('inf'), + 'neg_inf_value': float('-inf'), + } + self.compressor.save_compressed(grid, metadata, str(save_path)) + _, loaded = self.compressor.load_compressed(str(save_path)) + self.assertIsNone(loaded['nan_value']) + self.assertIsNone(loaded['inf_value']) + self.assertIsNone(loaded['neg_inf_value']) + + def test_save_load_metadata_with_numpy_nan(self): + """NaN from np.floating scalar is also converted to None.""" + save_path = Path(self.test_env['tmp_path']) / "np_nan.npz" + grid = np.zeros((64, 64, 64), dtype=bool) + grid[0, 0, 0] = True + metadata = { + 'min_bounds': np.array([0.0, 0.0, 0.0]), + 'max_bounds': np.array([1.0, 1.0, 1.0]), + 'ranges': np.array([1.0, 1.0, 1.0]), + 'has_normals': False, + 'compression_error': np.float64('nan'), + } + self.compressor.save_compressed(grid, metadata, str(save_path)) + _, loaded = self.compressor.load_compressed(str(save_path)) + self.assertIsNone(loaded['compression_error']) + + def test_compress_all_points_same_voxel(self): + """All identical points compress to single occupied voxel.""" + same_points = np.full((100, 3), 5.0, dtype=np.float32) + grid, metadata = self.compressor.compress(same_points, validate=False) + self.assertEqual(np.sum(grid), 1) + np.testing.assert_allclose(metadata['ranges'], [1e-6, 1e-6, 1e-6]) + + # --- Zero / empty / boundary tests --- + + def test_save_load_empty_grid(self): + """All-False grid saves and loads correctly.""" + save_path = Path(self.test_env['tmp_path']) / "empty_grid.npz" + grid = np.zeros((64, 64, 64), dtype=bool) + metadata = { + 'min_bounds': np.array([0.0, 0.0, 0.0]), + 'max_bounds': np.array([1.0, 1.0, 1.0]), + 'ranges': np.array([1.0, 1.0, 1.0]), + 'has_normals': False, + } + self.compressor.save_compressed(grid, metadata, str(save_path)) + loaded_grid, loaded_metadata = self.compressor.load_compressed(str(save_path)) + self.assertEqual(np.sum(loaded_grid), 0) + self.assertFalse(loaded_metadata['has_normals']) + + def test_save_load_without_normals(self): + """Metadata without normal_grid round-trips correctly.""" + save_path = Path(self.test_env['tmp_path']) / "no_normals.npz" + grid, metadata = self.compressor.compress(self.point_cloud, validate=False) + self.assertFalse(metadata['has_normals']) + self.assertNotIn('normal_grid', metadata) + + self.compressor.save_compressed(grid, metadata, str(save_path)) + loaded_grid, loaded_metadata = self.compressor.load_compressed(str(save_path)) + np.testing.assert_array_equal(grid, loaded_grid) + self.assertFalse(loaded_metadata['has_normals']) + self.assertNotIn('normal_grid', loaded_metadata) + + # --- Negative / error path tests --- + + def test_load_compressed_missing_metadata_file(self): + """Missing .meta.json sidecar raises FileNotFoundError.""" + save_path = Path(self.test_env['tmp_path']) / "partial_write.npz" + grid = np.zeros((64, 64, 64), dtype=bool) + metadata = { + 'min_bounds': np.array([0.0, 0.0, 0.0]), + 'max_bounds': np.array([1.0, 1.0, 1.0]), + 'ranges': np.array([1.0, 1.0, 1.0]), + 'has_normals': False, + } + self.compressor.save_compressed(grid, metadata, str(save_path)) + + # Simulate partial write: delete the sidecar + meta_path = Path(str(save_path) + '.meta.json') + meta_path.unlink() + + with self.assertRaises(FileNotFoundError): + self.compressor.load_compressed(str(save_path)) + + def test_load_compressed_missing_grid_file(self): + """Missing .npz grid file raises error.""" + missing_path = Path(self.test_env['tmp_path']) / "nonexistent.npz" + with self.assertRaises(FileNotFoundError): + self.compressor.load_compressed(str(missing_path)) + + # --- Debug output security test --- + + def test_debug_info_does_not_pickle_dicts(self): + """Debug output skips dict values, only saves numpy arrays.""" + self.compressor.compress(self.point_cloud, validate=False) + + debug_dir = Path(self.test_env['tmp_path']) / 'debug' / 'grid_creation' + self.assertTrue(debug_dir.exists()) + + # 'metadata' (a dict) should NOT be saved as .npy + self.assertFalse((debug_dir / 'metadata.npy').exists()) + + # 'grid' and 'scaled_points' (arrays) SHOULD be saved + self.assertTrue((debug_dir / 'grid.npy').exists()) + self.assertTrue((debug_dir / 'scaled_points.npy').exists()) + + # All saved .npy files must be loadable without pickle + for npy_file in debug_dir.glob('*.npy'): + np.load(str(npy_file), allow_pickle=False) + + # --- Regression / format fidelity tests --- + + def test_save_load_metadata_values_roundtrip(self): + """Numeric metadata values are preserved after JSON round-trip.""" + save_path = Path(self.test_env['tmp_path']) / "fidelity.npz" + grid, metadata = self.compressor.compress(self.point_cloud) + self.compressor.save_compressed(grid, metadata, str(save_path)) + _, loaded = self.compressor.load_compressed(str(save_path)) + + np.testing.assert_allclose( + loaded['min_bounds'], metadata['min_bounds'], rtol=1e-6 + ) + np.testing.assert_allclose( + loaded['max_bounds'], metadata['max_bounds'], rtol=1e-6 + ) + np.testing.assert_allclose( + loaded['ranges'], metadata['ranges'], rtol=1e-6 + ) + self.assertAlmostEqual( + loaded['compression_error'], metadata['compression_error'], places=6 + ) + + def test_save_load_numpy_scalar_metadata(self): + """np.float64 and np.int32 scalars survive type conversion.""" + save_path = Path(self.test_env['tmp_path']) / "scalar_types.npz" + grid = np.zeros((64, 64, 64), dtype=bool) + grid[0, 0, 0] = True + metadata = { + 'min_bounds': np.array([0.0, 0.0, 0.0]), + 'max_bounds': np.array([1.0, 1.0, 1.0]), + 'ranges': np.array([1.0, 1.0, 1.0]), + 'has_normals': False, + 'float_scalar': np.float64(3.14), + 'int_scalar': np.int32(42), + } + self.compressor.save_compressed(grid, metadata, str(save_path)) + _, loaded = self.compressor.load_compressed(str(save_path)) + self.assertAlmostEqual(loaded['float_scalar'], 3.14, places=10) + self.assertEqual(loaded['int_scalar'], 42) + + def test_save_load_dtype_after_roundtrip(self): + """Documents that float32 arrays become float64 after JSON round-trip.""" + save_path = Path(self.test_env['tmp_path']) / "dtype_test.npz" + grid, metadata = self.compressor.compress(self.point_cloud, validate=False) + # Original is float32 from np.min on float32 input + self.assertEqual(metadata['min_bounds'].dtype, np.float32) + + self.compressor.save_compressed(grid, metadata, str(save_path)) + _, loaded = self.compressor.load_compressed(str(save_path)) + # After JSON round-trip, np.array() defaults to float64 + self.assertEqual(loaded['min_bounds'].dtype, np.float64) + + def test_decompress_after_save_load_matches_direct(self): + """Decompress from loaded metadata produces same points as from original.""" + save_path = Path(self.test_env['tmp_path']) / "roundtrip_quality.npz" + grid, metadata = self.compressor.compress(self.point_cloud, validate=False) + + # Decompress directly from original metadata + direct_points, _ = self.compressor.decompress(grid, metadata) + + # Save, load, decompress + self.compressor.save_compressed(grid, metadata, str(save_path)) + loaded_grid, loaded_metadata = self.compressor.load_compressed(str(save_path)) + loaded_points, _ = self.compressor.decompress(loaded_grid, loaded_metadata) + + # Points should match despite dtype change (float32 vs float64) + np.testing.assert_allclose( + loaded_points, direct_points.astype(np.float64), rtol=1e-5 + ) + + # --- E2E test --- + + @pytest.mark.e2e + def test_compress_save_load_decompress_quality(self): + """Full pipeline: compress, save, load, decompress, verify quality.""" + save_path = Path(self.test_env['tmp_path']) / "e2e.npz" + + grid, metadata = self.compressor.compress(self.point_cloud) + original_error = metadata['compression_error'] + self.compressor.save_compressed(grid, metadata, str(save_path)) + + loaded_grid, loaded_metadata = self.compressor.load_compressed(str(save_path)) + decompressed, _ = self.compressor.decompress(loaded_grid, loaded_metadata) + + # Decompressed point count should be reasonable + self.assertGreater(len(decompressed), 0) + # Reconstruction error should match original + self.assertAlmostEqual( + loaded_metadata['compression_error'], original_error, places=6 + ) + if __name__ == "__main__": tf.test.main() diff --git a/tests/test_evaluation_pipeline.py b/tests/test_evaluation_pipeline.py index dc254a2f6..1608406ae 100644 --- a/tests/test_evaluation_pipeline.py +++ b/tests/test_evaluation_pipeline.py @@ -102,5 +102,63 @@ def test_generate_report(self, pipeline, tmp_path): assert 'aggregate_metrics' in report_data assert len(report_data['model_performance']) == len(results) + def test_load_model_no_checkpoint_configured(self, config_path): + """Pipeline initializes when config has no checkpoint_path.""" + pipeline = EvaluationPipeline(config_path) + assert pipeline.model is not None + assert pipeline.config.get('checkpoint_path') is None + + def test_load_model_empty_string_checkpoint(self, tmp_path): + """Empty string checkpoint_path is treated as no checkpoint.""" + config = { + 'data': { + 'modelnet40_path': str(tmp_path / 'modelnet40'), + 'ivfb_path': str(tmp_path / '8ivfb') + }, + 'model': { + 'filters': 32, + 'activation': 'cenic_gdn', + 'conv_type': 'separable' + }, + 'evaluation': { + 'metrics': ['psnr'], + 'output_dir': str(tmp_path / 'results'), + 'visualize': True + }, + 'checkpoint_path': '' + } + config_file = tmp_path / 'config_empty_ckpt.yml' + with open(config_file, 'w') as f: + yaml.dump(config, f) + + pipeline = EvaluationPipeline(str(config_file)) + assert pipeline.model is not None + + def test_load_model_missing_checkpoint_raises(self, tmp_path): + """Non-existent checkpoint_path raises FileNotFoundError.""" + config = { + 'data': { + 'modelnet40_path': str(tmp_path / 'modelnet40'), + 'ivfb_path': str(tmp_path / '8ivfb') + }, + 'model': { + 'filters': 32, + 'activation': 'cenic_gdn', + 'conv_type': 'separable' + }, + 'evaluation': { + 'metrics': ['psnr'], + 'output_dir': str(tmp_path / 'results'), + 'visualize': True + }, + 'checkpoint_path': str(tmp_path / 'nonexistent' / 'model.weights.h5') + } + config_file = tmp_path / 'config_missing_ckpt.yml' + with open(config_file, 'w') as f: + yaml.dump(config, f) + + with pytest.raises(FileNotFoundError, match="Checkpoint not found"): + EvaluationPipeline(str(config_file)) + if __name__ == '__main__': tf.test.main() diff --git a/tests/test_integration.py b/tests/test_integration.py index 8714810ab..2f3b02e9a 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -322,5 +322,39 @@ def test_v2_gaussian_backward_compatible(self): self.assertEqual(x_hat.shape[:-1], input_tensor.shape[:-1]) +class TestCheckpointResumeIntegration(tf.test.TestCase): + """Integration test for checkpoint save/load through new serialization format.""" + + @pytest.fixture(autouse=True) + def setup(self, tmp_path): + self.test_env = setup_test_environment(tmp_path) + self.resolution = 16 + self.batch_size = 1 + + @pytest.mark.integration + def test_training_checkpoint_resume_loss_continuity(self): + """Model state is preserved through checkpoint save/load cycle.""" + pipeline = TrainingPipeline(self.test_env['config_path']) + batch = create_mock_voxel_grid(self.resolution, self.batch_size)[..., 0] + + # Train a few steps to establish non-trivial model + optimizer state + for _ in range(3): + pipeline._train_step(batch, training=True) + + pipeline.save_checkpoint('resume_test') + + # Record eval loss at checkpoint + checkpoint_loss = pipeline._train_step(batch, training=False)['total_loss'] + + # Load into fresh pipeline and verify same eval loss + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline._train_step(batch, training=True) # Build optimizer variables + new_pipeline.load_checkpoint('resume_test') + + resumed_loss = new_pipeline._train_step(batch, training=False)['total_loss'] + + self.assertAllClose(checkpoint_loss, resumed_loss, rtol=1e-4) + + if __name__ == '__main__': tf.test.main() diff --git a/tests/test_training_pipeline.py b/tests/test_training_pipeline.py index 6967dff08..afeb14007 100644 --- a/tests/test_training_pipeline.py +++ b/tests/test_training_pipeline.py @@ -1,6 +1,7 @@ import sys from pathlib import Path +import numpy as np import pytest import tensorflow as tf import yaml @@ -134,3 +135,189 @@ def create_sample_batch(): checkpoint_dir = Path(pipeline.checkpoint_dir) assert len(list(checkpoint_dir.glob('epoch_*'))) > 0 assert (checkpoint_dir / 'best_model').exists() + + # --- Security / path validation tests --- + + def test_load_checkpoint_rejects_path_traversal(self, pipeline): + """Path traversal via ../ is rejected.""" + with pytest.raises(ValueError, match="escapes"): + pipeline.load_checkpoint('../../etc/passwd') + + def test_load_checkpoint_rejects_absolute_path(self, pipeline): + """Absolute path outside checkpoint dir is rejected.""" + with pytest.raises(ValueError, match="escapes"): + pipeline.load_checkpoint('/tmp/evil_checkpoint') + + def test_load_checkpoint_prefix_collision(self, pipeline, tmp_path): + """Sibling directory with prefix-matching name is rejected.""" + # checkpoint_dir is tmp_path / 'checkpoints' + # Create a sibling with a name that is a prefix match + evil_dir = tmp_path / 'checkpoints_evil' + evil_dir.mkdir() + + # '../checkpoints_evil' resolves outside checkpoint_dir but + # starts with the same string prefix — must still be rejected + with pytest.raises(ValueError, match="escapes"): + pipeline.load_checkpoint('../checkpoints_evil') + + # --- NaN / degenerate value tests --- + + def test_checkpoint_nan_in_optimizer_variable(self, pipeline): + """NaN in optimizer variables is preserved through save/load.""" + dummy = tf.zeros((1, 16, 16, 16, 1)) + pipeline.model(dummy, training=False) + y = pipeline.model.analysis(dummy) + pipeline.entropy_model(y, training=False) + + # Train to populate momentum/variance variables + batch = tf.zeros((1, 16, 16, 16)) + pipeline._train_step(batch, training=True) + + opt = pipeline.optimizers['reconstruction'] + # Find a float variable (skip int64 iteration counter) + float_vars = [(i, v) for i, v in enumerate(opt.variables) + if v.dtype == tf.float32] + assert len(float_vars) > 0 + idx, target_var = float_vars[0] + + nan_value = np.full_like(target_var.numpy(), float('nan')) + target_var.assign(nan_value) + + pipeline.save_checkpoint('nan_test') + + # Load into fresh pipeline + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline.model(dummy, training=False) + y2 = new_pipeline.model.analysis(dummy) + new_pipeline.entropy_model(y2, training=False) + new_pipeline._train_step(batch, training=True) + new_pipeline.load_checkpoint('nan_test') + + loaded_var = new_pipeline.optimizers['reconstruction'].variables[idx] + assert np.all(np.isnan(loaded_var.numpy())) + + # --- Zero / empty / boundary tests --- + + def test_save_checkpoint_before_training(self, pipeline): + """Checkpoint saved before training loads without error.""" + dummy = tf.zeros((1, 16, 16, 16, 1)) + pipeline.model(dummy, training=False) + y = pipeline.model.analysis(dummy) + pipeline.entropy_model(y, training=False) + + # No training step — optimizer has only internal state (iteration counter) + pipeline.save_checkpoint('untrained') + + checkpoint_dir = Path(pipeline.checkpoint_dir) / 'untrained' + assert (checkpoint_dir / 'model.weights.h5').exists() + assert (checkpoint_dir / 'entropy.weights.h5').exists() + + # Loading the untrained checkpoint should not crash + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline.model(dummy, training=False) + y2 = new_pipeline.model.analysis(dummy) + new_pipeline.entropy_model(y2, training=False) + new_pipeline.load_checkpoint('untrained') + + # --- Negative / error path tests --- + + def test_load_checkpoint_missing_weights_file(self, pipeline): + """Missing model weights file raises error on load.""" + dummy = tf.zeros((1, 16, 16, 16, 1)) + pipeline.model(dummy, training=False) + y = pipeline.model.analysis(dummy) + pipeline.entropy_model(y, training=False) + + pipeline.save_checkpoint('incomplete') + + # Delete the model weights file + weights_path = Path(pipeline.checkpoint_dir) / 'incomplete' / 'model.weights.h5' + weights_path.unlink() + + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline.model(dummy, training=False) + y2 = new_pipeline.model.analysis(dummy) + new_pipeline.entropy_model(y2, training=False) + + with pytest.raises(Exception): + new_pipeline.load_checkpoint('incomplete') + + def test_checkpoint_partial_optimizer_files(self, pipeline): + """Missing optimizer .npy files are silently skipped.""" + dummy = tf.zeros((1, 16, 16, 16, 1)) + pipeline.model(dummy, training=False) + y = pipeline.model.analysis(dummy) + pipeline.entropy_model(y, training=False) + + batch = tf.zeros((1, 16, 16, 16)) + pipeline._train_step(batch, training=True) + pipeline.save_checkpoint('partial_test') + + # Delete the last .npy file from an optimizer dir + opt_dir = Path(pipeline.checkpoint_dir) / 'partial_test' / 'reconstruction_optimizer' + if opt_dir.exists(): + npy_files = sorted(opt_dir.glob('*.npy')) + if len(npy_files) > 1: + npy_files[-1].unlink() + + # Loading should succeed — missing files silently skipped + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline.model(dummy, training=False) + y2 = new_pipeline.model.analysis(dummy) + new_pipeline.entropy_model(y2, training=False) + new_pipeline._train_step(batch, training=True) + new_pipeline.load_checkpoint('partial_test') + + # --- Regression tests --- + + def test_load_old_format_pickle_file_ignored(self, pipeline): + """Old-style pickle .npy file at checkpoint level is safely ignored.""" + dummy = tf.zeros((1, 16, 16, 16, 1)) + pipeline.model(dummy, training=False) + y = pipeline.model.analysis(dummy) + pipeline.entropy_model(y, training=False) + + pipeline.save_checkpoint('format_test') + + # Place an old-format pickle file alongside new-format directories + checkpoint_dir = Path(pipeline.checkpoint_dir) / 'format_test' + old_file = checkpoint_dir / 'stale_optimizer.npy' + np.save(str(old_file), np.array([np.zeros(5)], dtype=object), + allow_pickle=True) + + # Loading should succeed, ignoring the old file + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline.model(dummy, training=False) + y2 = new_pipeline.model.analysis(dummy) + new_pipeline.entropy_model(y2, training=False) + new_pipeline.load_checkpoint('format_test') + + # --- Integration test --- + + def test_checkpoint_optimizer_state_values_survive_roundtrip(self, pipeline): + """Optimizer variable values are numerically equal after save/load.""" + dummy = tf.zeros((1, 16, 16, 16, 1)) + pipeline.model(dummy, training=False) + y = pipeline.model.analysis(dummy) + pipeline.entropy_model(y, training=False) + + batch = tf.zeros((1, 16, 16, 16)) + for _ in range(3): + pipeline._train_step(batch, training=True) + + opt = pipeline.optimizers['reconstruction'] + original_values = [v.numpy().copy() for v in opt.variables] + + pipeline.save_checkpoint('opt_fidelity') + + new_pipeline = TrainingPipeline(pipeline.config_path) + new_pipeline.model(dummy, training=False) + y2 = new_pipeline.model.analysis(dummy) + new_pipeline.entropy_model(y2, training=False) + new_pipeline._train_step(batch, training=True) + new_pipeline.load_checkpoint('opt_fidelity') + + new_opt = new_pipeline.optimizers['reconstruction'] + for orig, loaded in zip(original_values, + [v.numpy() for v in new_opt.variables]): + np.testing.assert_array_equal(orig, loaded) From 4d4ecdc341482036975bbe587b9daf714bd87306 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Fri, 27 Feb 2026 17:42:50 -0800 Subject: [PATCH 3/7] =?UTF-8?q?refactor:=20Phase=201=20=E2=80=94=20reposit?= =?UTF-8?q?ory=20hygiene,=20packaging,=20imports,=20and=20bug=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove 37 tracked .pyc/egg-info build artifacts from git index. Convert all intra-package imports in src/ to relative imports and add a meta-path import hook in conftest.py so test files work unchanged. Fix setup.py dependencies (add tensorflow, tf-probability, etc; remove pytest from install_requires), set version 2.0.0, require Python >=3.10. Update pyproject.toml target-version to py310. Replace hardcoded CI test file list with pytest discovery. Fix evaluation_pipeline --checkpoint CLI arg not being applied. Fix Popen.__exit__ unconditionally terminating finished processes. Fix mp_report compression_ratio direction (higher is better). Co-Authored-By: Claude Opus 4.6 --- .github/workflows/ci.yml | 14 +----- .python-version | 2 +- pyproject.toml | 2 +- setup.py | 16 +++++-- src/__init__.py | 1 + src/__pycache__/__init__.cpython-38.pyc | Bin 158 -> 0 bytes src/__pycache__/colorbar.cpython-38.pyc | Bin 3819 -> 0 bytes .../compress_octree.cpython-38.pyc | Bin 6740 -> 0 bytes src/__pycache__/ds_mesh_to_pc.cpython-38.pyc | Bin 7664 -> 0 bytes .../ds_pc_octree_blocks.cpython-38.pyc | Bin 3482 -> 0 bytes .../ev_run_experiment.cpython-38.pyc | Bin 3293 -> 0 bytes src/__pycache__/ev_run_render.cpython-38.pyc | Bin 8164 -> 0 bytes src/__pycache__/experiment.cpython-38.pyc | Bin 3718 -> 0 bytes src/__pycache__/map_color.cpython-38.pyc | Bin 4576 -> 0 bytes src/__pycache__/model_opt.cpython-38.pyc | Bin 3328 -> 0 bytes .../model_transforms.cpython-38.pyc | Bin 5166 -> 0 bytes src/__pycache__/octree_coding.cpython-38.pyc | Bin 4763 -> 0 bytes .../parallel_process.cpython-38.pyc | Bin 4877 -> 0 bytes .../patch_gaussian_conditional.cpython-38.pyc | Bin 4207 -> 0 bytes src/__pycache__/pc_metric.cpython-38.pyc | Bin 3585 -> 0 bytes src/attention_context.py | 12 ++--- src/benchmarks.py | 2 +- src/channel_context.py | 6 +-- src/cli_train.py | 2 +- src/context_model.py | 6 +-- src/data_loader.py | 4 +- src/deepcompress.egg-info/PKG-INFO | 10 ---- src/deepcompress.egg-info/SOURCES.txt | 9 ---- .../dependency_links.txt | 1 - src/deepcompress.egg-info/requires.txt | 3 -- src/deepcompress.egg-info/top_level.txt | 1 - src/entropy_model.py | 4 +- src/evaluation_pipeline.py | 15 +++--- src/model_transforms.py | 14 +++--- src/mp_report.py | 4 +- src/parallel_process.py | 8 +++- src/quick_benchmark.py | 2 +- src/training_pipeline.py | 6 +-- .../__pycache__/experiment.cpython-38.pyc | Bin 813 -> 0 bytes .../__pycache__/pc_metric.cpython-38.pyc | Bin 4463 -> 0 bytes .../test_colorbar.cpython-38-pytest-8.3.4.pyc | Bin 12079 -> 0 bytes ...ompress_octree.cpython-38-pytest-8.3.4.pyc | Bin 6405 -> 0 bytes ..._ds_mesh_to_pc.cpython-38-pytest-8.3.4.pyc | Bin 4855 -> 0 bytes ..._octree_blocks.cpython-38-pytest-8.3.4.pyc | Bin 7013 -> 0 bytes ...run_experiment.cpython-38-pytest-8.3.4.pyc | Bin 3644 -> 0 bytes ..._ev_run_render.cpython-38-pytest-8.3.4.pyc | Bin 11020 -> 0 bytes ...est_experiment.cpython-38-pytest-8.3.4.pyc | Bin 3274 -> 0 bytes ...test_map_color.cpython-38-pytest-8.3.4.pyc | Bin 3785 -> 0 bytes ...test_model_opt.cpython-38-pytest-8.3.4.pyc | Bin 2314 -> 0 bytes ...del_transforms.cpython-38-pytest-8.3.4.pyc | Bin 5149 -> 0 bytes ..._octree_coding.cpython-38-pytest-8.3.4.pyc | Bin 3459 -> 0 bytes ...rallel_process.cpython-38-pytest-8.3.4.pyc | Bin 3653 -> 0 bytes .../test_parallel_process.cpython-38.pyc | Bin 1972 -> 0 bytes ...an_conditional.cpython-38-pytest-8.3.4.pyc | Bin 3575 -> 0 bytes ...test_pc_metric.cpython-38-pytest-8.3.4.pyc | Bin 3807 -> 0 bytes tests/conftest.py | 44 ++++++++++++++++++ tests/test_mp_report.py | 4 +- tests/test_parallel_process.py | 24 ++++++++-- 58 files changed, 129 insertions(+), 87 deletions(-) mode change 100644 => 100755 .github/workflows/ci.yml mode change 100644 => 100755 .python-version mode change 100644 => 100755 pyproject.toml mode change 100644 => 100755 setup.py mode change 100644 => 100755 src/__init__.py delete mode 100644 src/__pycache__/__init__.cpython-38.pyc delete mode 100644 src/__pycache__/colorbar.cpython-38.pyc delete mode 100644 src/__pycache__/compress_octree.cpython-38.pyc delete mode 100644 src/__pycache__/ds_mesh_to_pc.cpython-38.pyc delete mode 100644 src/__pycache__/ds_pc_octree_blocks.cpython-38.pyc delete mode 100644 src/__pycache__/ev_run_experiment.cpython-38.pyc delete mode 100644 src/__pycache__/ev_run_render.cpython-38.pyc delete mode 100644 src/__pycache__/experiment.cpython-38.pyc delete mode 100644 src/__pycache__/map_color.cpython-38.pyc delete mode 100644 src/__pycache__/model_opt.cpython-38.pyc delete mode 100644 src/__pycache__/model_transforms.cpython-38.pyc delete mode 100644 src/__pycache__/octree_coding.cpython-38.pyc delete mode 100644 src/__pycache__/parallel_process.cpython-38.pyc delete mode 100644 src/__pycache__/patch_gaussian_conditional.cpython-38.pyc delete mode 100644 src/__pycache__/pc_metric.cpython-38.pyc mode change 100644 => 100755 src/attention_context.py mode change 100644 => 100755 src/benchmarks.py mode change 100644 => 100755 src/channel_context.py mode change 100644 => 100755 src/cli_train.py mode change 100644 => 100755 src/context_model.py mode change 100644 => 100755 src/data_loader.py delete mode 100644 src/deepcompress.egg-info/PKG-INFO delete mode 100644 src/deepcompress.egg-info/SOURCES.txt delete mode 100644 src/deepcompress.egg-info/dependency_links.txt delete mode 100644 src/deepcompress.egg-info/requires.txt delete mode 100644 src/deepcompress.egg-info/top_level.txt mode change 100644 => 100755 src/entropy_model.py mode change 100644 => 100755 src/evaluation_pipeline.py mode change 100644 => 100755 src/model_transforms.py mode change 100644 => 100755 src/mp_report.py mode change 100644 => 100755 src/parallel_process.py mode change 100644 => 100755 src/quick_benchmark.py mode change 100644 => 100755 src/training_pipeline.py delete mode 100644 src/utils/__pycache__/experiment.cpython-38.pyc delete mode 100644 src/utils/__pycache__/pc_metric.cpython-38.pyc delete mode 100644 tests/__pycache__/test_colorbar.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_compress_octree.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_ds_mesh_to_pc.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_ds_pc_octree_blocks.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_ev_run_experiment.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_ev_run_render.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_experiment.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_map_color.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_model_opt.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_model_transforms.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_octree_coding.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_parallel_process.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_parallel_process.cpython-38.pyc delete mode 100644 tests/__pycache__/test_patch_gaussian_conditional.cpython-38-pytest-8.3.4.pyc delete mode 100644 tests/__pycache__/test_pc_metric.cpython-38-pytest-8.3.4.pyc mode change 100644 => 100755 tests/conftest.py mode change 100644 => 100755 tests/test_mp_report.py mode change 100644 => 100755 tests/test_parallel_process.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml old mode 100644 new mode 100755 index 17cf517db..e487258d1 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,19 +41,7 @@ jobs: - name: Run tests run: | - pytest \ - tests/test_entropy_parameters.py \ - tests/test_context_model.py \ - tests/test_channel_context.py \ - tests/test_attention_context.py \ - tests/test_model_transforms.py \ - tests/test_integration.py \ - tests/test_performance.py \ - tests/test_parallel_process.py \ - tests/test_colorbar.py \ - tests/test_entropy_model.py \ - tests/test_octree_coding.py \ - -v --cov=src --cov-report=xml -m "not gpu and not slow" + pytest tests/ -v --cov=src --cov-report=xml -m "not gpu and not slow" - name: Upload coverage uses: codecov/codecov-action@v4 diff --git a/.python-version b/.python-version old mode 100644 new mode 100755 index eee6392d5..c8cfe3959 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.8.16 +3.10 diff --git a/pyproject.toml b/pyproject.toml old mode 100644 new mode 100755 index 0d00de1cd..65733cc3d --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.ruff] line-length = 120 -target-version = "py38" +target-version = "py310" [tool.ruff.lint] select = ["F", "I", "E", "W"] diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 77ceefe46..8f01136e1 --- a/setup.py +++ b/setup.py @@ -2,12 +2,20 @@ setup( name="deepcompress", - version="0.1", + version="2.0.0", package_dir={"": "src"}, packages=find_namespace_packages(include=["*"], where="src"), + python_requires=">=3.10", install_requires=[ 'numpy', - 'pytest', - 'numba' + 'tensorflow>=2.11', + 'tensorflow-probability~=0.19', + 'matplotlib', + 'pandas', + 'tqdm', + 'pyyaml', + 'scipy', + 'numba', + 'keras-tuner', ], -) \ No newline at end of file +) diff --git a/src/__init__.py b/src/__init__.py old mode 100644 new mode 100755 index e69de29bb..8c0d5d5bb --- a/src/__init__.py +++ b/src/__init__.py @@ -0,0 +1 @@ +__version__ = "2.0.0" diff --git a/src/__pycache__/__init__.cpython-38.pyc b/src/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 6cb68d69b10fb6b5b1d13bb548a168d4494b53e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmWIL<>g`k0$-P+bP)X*L?8o3AjbiSi&=m~3PUi1CZpdFMeD@Qj_<`5cxI?MUG4h7X}t6bCt*Wu?Win?yjft5#>KJnnIS zZB>ugHa!x;5$iozzy*m_4)$?xT=^5YaN$-bBo4?D2Lva=9(b>M96Qd1YR&6cuc}_Z zdiCD#)jV!CT@9K!FGibfP5T>m&bB$|+=HL!5KW^RC9&oc_0|)^H%TS4d>dqXTuU6^ zN$P$*aeX&w_zg8~#9q?$o3ptszXfy6xSe!-P|BL`Xw;(iQ;pht`X2G;=rz`brw5PA z9OT#aC2dKAUQf{mzGqxlaXmJ#o*v9M#|x~ED`@jsi?(R{spVgw*FjsQb)L>WvuKz0 zKz@-e()msOH-vla0$8;`9eROYq>FTEWI+ECU4ZvxdWBv+a6Z)iCI1pL4=*2Suov=B z8}yISS8D@PT8}1q%%rm##&P%rxgX|5l%-)T&G#Zv42Z1Lun327C=0T-V1_{Ct*ArsRaSehC#sVpp%=Z*boJm?H~x#bf#RuI2y$*Bn_Not;bnd z@Fof%bzoJL7910Xcah8k`9kAYkw@};_10s-xVV+?hQp1J@7%hd4JQdp3vuf{WBF>9 z8#*`vl~ElSrJPc!LmWIWmhH-<4sAMzHG z4w4>_K}(ujNt7ZDx1~8u!dy0B2^cl=DI7SSK;<0e0o?rQuFq{)+ME>=o(|D1Ck;Je z!wo8V3Ak$`H9Gaz8 zl94_lM`YS6$f32b9g)v8m}Qr(eF$$_KQN|k7^xj;hYod*2*Gcm&V7*9X@h#SNn0Zm zG<4FglC?qFqRmPMy4iI(0j=EmK_Ug^O1yOm`CKG$&0c>Zvq?7|99^)6IB zuph@RmUTLJ02;u)MWu1+NGlt(d!WM%3v~SAg|=L+xKvuDOM5t;c9i7usRXRJGQNtt zpz{a%XQa4R7~|`7;hFxa_90K~K2@yd+( z)5_?Zs{LhUU`S&aZu1*3O=T;BbOloo@z8NjpalUNv)~Jze+4JM24tX1vdwP-H$*Tx zX~<)oV|ocpxqWS)l-eHI!-7H96cm8{Z^96$Q=;FbvsiY!o>{}@&2 zKs2&@W5kL|3V6JHC(gi;;_mXx3kH4J`knx+L5O{u^NdR)j|*v6=OOhpmsZGmxFa2< z57XX`60wneB8|~7lnnu&*@{GjrOXVsnUKy23P~r9Qjv#4CXJY-vW{~RiG|bwMB3Qh z#{LZPm0;(Lz)12~+9&|5?mke6|FGZ{Q-I9c4!0vAd#EW1#~GiM1vR###;TfXR{|$o% zxLT4BYCGAuhaI z(3{u0Mi<@=>4NMv!y`A17MV9}=ymlD%v?11PhssbTvoJ!C7tyMP78|Rxy{K&I!OW% z!l5uR#PW91PD%}iv-lm5LK`C>lTxQd!3IF`41%pg8|9@5fN0G?M5o46r?l=v@K5cc zhDk7X#&v4KV<9jVZmDj4zrH=K6^*f1T8GUejRMd<)k+)Jq0W76+@j7i3jnxI-2;PY zKi0mjy{~k2+JL@)fdmkNj5u*nD`VfJ4UX%Wp`s#npkSqf@8m-ihIgH-!3qmw_gMcL-b4 zwcVQn?5ffg0$JI0nzN^i9)ac>#4=t5q{ATgiy+&S2m98W3q$`;u0&Nh2=JEC!E zRe5Rf>&W+ktU)_}Bg4{BUCRhZ>jzh)1-S{AKJ=-aJFU2I5yaXH&Q)*aed(T#tJ49` z?qDWxJUos`tS-0e1}hsc{){levDWfU{02l}u>mDrot?T%Drn?OIO7V=upg+JuAoJE zjw4L*M@Xt-hE?QDyuAoI>D;Mq;CFGO0>jUNqj1QgqMojkP7A>5)#u-MT-DQp<8$FT f>FdP%m-nvrrFkOx(pz*v6CA9PAV325AGiKL_&2;` diff --git a/src/__pycache__/compress_octree.cpython-38.pyc b/src/__pycache__/compress_octree.cpython-38.pyc deleted file mode 100644 index 98c63d9144983b89fcaf4f8088d031b08c003932..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6740 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 diff --git a/src/__pycache__/ev_run_experiment.cpython-38.pyc b/src/__pycache__/ev_run_experiment.cpython-38.pyc deleted file mode 100644 index 242ce49e7a5a0394fe238561b1c70956beace776..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3293 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 diff --git a/src/__pycache__/experiment.cpython-38.pyc b/src/__pycache__/experiment.cpython-38.pyc deleted file mode 100644 index 861f805c5bab57dfed70abb7aa64ca17785af3eb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3718 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 diff --git a/src/__pycache__/model_opt.cpython-38.pyc b/src/__pycache__/model_opt.cpython-38.pyc deleted file mode 100644 index 9970df53480d3d47fb464e1e2d4a53d9dedbdb43..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3328 zcmd5;%a7Z}8J`)xBrf&XwX^G2S^_DGRpDxjq-lUf(Av$qLC|=U#v8O?6R@;qq?JNZ z@{n?|0(DwndRX)ihzm$SFaCFOoI@`?w0q2<7o({AeM3sSvfZM`4#AJ}p6~Jde#6ge zHJ3m+{DJ@BuPVw-R9I&YlWU$CGSTo*g@8_SV}8Qc`N zcvIZun_ruMVyBdXT@OfW>l|z(hb^XzyUB^Njwa(+bg=JoL4VzWHywZw+8Y7mbzyM! zlxq8}z~l{K4eSBmTq%HCR+T_@am3ZCHACst5@WwycHrxw`YJu!m@uYq<= zRlh&tBK~BY^@shbkbnE&qokkpqZrQevt!|}pYC_!C{5e$YW7{(OZQhT z++s~9f43WtqO7&=e_X2SqpE&)B=zp2rCIIO9?}MF4_mf$;Hy~HRn}KRMyYaFlT&5h z<39khR0HleZsuMX!oP|3&uLOC~b zmK$A2q`8^f3p#UpWLC+n+&ZNP2G5)^xr9Vjl@6gxlKNg9Pe)PMOq?7eWi6150=TR1=BKxlpEq2Su+&Vr_?M$%h%TznDTe~2`1j*WcX0P^sb-D2#^fk zD<-Tjx&YvE;m;A0K0>lc*K+kG&wi$X`cnRD{`~E_Z*Ti?Kh3@b5920+m$Afn;Q%V9 zBl$RrCjtTr<_XBzLeUF7B?aUk0r6FqrcCX7m_YtHjy$J()5>k_s(t_b1(fGT`^Vi@ zQ{sK5OoWDV4hn#%t*#sm!%QUUNQPQ^NI20ayjh3_o?KtTKh!DHgg$+lkmfaNGLM?nqb|HxS)E>`b+$#n&!%tcbXumb zY_IRpvR-TLb=bONfmjw~KO|etc;A5v@C4(OGvF%=_{so|V>3Iq=VU?f3X4X7fmI&e zLnz@*gLhbw@9WG_`c!j<#@>=eF5&yt%dObye_{?%YH$Hx%iFYjas& z-ur&(Or%(J)rCdBkT)PQEh>M2ALl=V`E3}N@1Ve&`7*+mZYmIlQ&PNWNez+p3YOMq zAHvCqnHH0HLCP(xH}S?!qM-;w<%YQJ1QIn=UKl=_L~%J{Y11SkTC;nNq})7FBKp+4)%Wy2L-X{@>x?!a&?q{~r~mvI~-!Ze9w~#}K4_Uw#*6 zx8BsTk?-RPZozh7`tJ)$w6l{jTnO4mZJt(4zK6$Xhqw>KqX{q|{17TTj!xirN?8ZM znL=kV{Hp+mkA|Q-e1YLo*ia}(JNKQpFDMNbgHFJ(&HExs9zY;~h3-i?k_FizdlpXJ z!y0;4HOgQE>@>T{AL7t!{H-eQ7g7Ex-U2C-MTLUfS^Z9h*|bT$f7$jn-8MYKHEtM9 F`fq}#g+2fP diff --git a/src/__pycache__/model_transforms.cpython-38.pyc b/src/__pycache__/model_transforms.cpython-38.pyc deleted file mode 100644 index de9c5b972b55437d01ca5a6bbaecaa7c44c859ec..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5166 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 diff --git a/src/__pycache__/octree_coding.cpython-38.pyc b/src/__pycache__/octree_coding.cpython-38.pyc deleted file mode 100644 index 1be3bcbcf61adf636265de64e6e5428a2b9a6222..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4763 zcmbtXTW=gm74ELS%y@2|*yBqaC-g!94>AebMFLrXazn7vl3nd)Uplc`?Wr2OZ((hbE@i` z@6z`_`*mE7+8)6;2b&~j8zo+f~3hQ3Mx9MKR>`UUxry5^+WvICgzAUaz zIUBqojFW3(a|A8q`rSOsImgh&Q#fvWuzQ9Wut&Am9q22M@wl8bK5Vf(~ z!la*S8YB5wq5RV16<&pmMP9=R>sk5TRw9MC8*w2|Y3rZqSxJgG>hzN!3R?_alZR;f z?&6CHu&JJE9?%F$Z1C%nGk)F1_) z-^#+GLp-i%wgTD}AQP zGO90se)Gdv$oOV&;I|HZd3^I;)arLdn8Y{l3DLV7b$eLjTqBmPn~_qQ*NS)$?%(Jg zXGPBoaB9!9Avab^D-0SlC%-Y7fQ^)U5f0(CI#HizwiHQU&MTU9OPf}T{ym69dkj@S zW|OaNpvU0)3pQk+%v9%$8)#9#HEo%xnHuZb5It0<))kE;aC2R2XhS>EPUtJ7Hdm6V ze?$_BBQ`9cUchK6Ex^eDAGGroi|8$3yOr8P=3!ieG5Cp)g<725lLjBkPvAAZgw2#avWMo-8rs9cu!uX;Bq(tP zXq5o1!k8|n#dKjzSN64G74wibU`82bF|D*&+nDRC(A_)jE2o7E^YXsQyi#gikT=^m zKW|=MK5tH5yLeCgMBn}4BV$+_E)MJ57+%Vqkel567_LVNNm?VFq?gvULyB4RBNmOU zX@@!-c4>|mJH^=IYj`Ikv%Jf5ywE#%>#yORjLh;j6mP!za@rU%>}3Ug1}wM&dKI*u zu1;-hBE6oRs>VrDzRN{L=d0F(JX1Ev-`A^xp)G zrdP>(zD$r;Md9nB=Qwokb)wc`j0oE5%Qyf|k7s=K;!X1Zgiqzct-Jv*0R}jhbV`u9KchRK2YX>4*EmEgVmXK~7`^(Tjsl6uR~ia_x}2_VKtLs5&!gziyFIC_=tl zY8?a}?hzi(-TLzT*y$~N@%t#WbUUnGLxwQKtUjX|5h)VhN*Ap8~-%TbG6-I z71m(3h72YDzA~*%HrSlaRL(2zep8o}nc#;De+r@B6`q%sJg*z^euwCa=RN8Bo$-i) zgiX@Fq>6=^ou{>u@*GJqCo_Gy>AVv4#& zy}oe$+O-u^U(%WG{9TRG2D``vJg>=uzhcQ#TJ}^WgLDM-H#NBYt_=2(0C$}C=wAk| z`hnjCfSWs~Bz~|y3H;}W5#K5Em3OIl9d(D;m+tqwdjhW7c5;;LIP!ZmP$=CYLpxvU zJA>Nnb+O~jGUb6EeKobdqX4$!Jd{f$gl&U=xA}(r23i>lds!*ZhM~b{W0fOehhyd1 zBn2qF=PD;1${k|0A(uy8q`YB(o28rQ2^OV%GPlfGZlY{d%QK2;(roo4w3k)r-w}Vz zIdI9Ratr=|7BM;^%oYT)av>={*MrPbO36KtoLOF>Vv`DG>a#-S+r=N{IQt3dJN^!e iqF&V3S?!ma@`934gBPsotH8X8JbX#t)a$4>LH!Gyp2Kwj diff --git a/src/__pycache__/parallel_process.cpython-38.pyc b/src/__pycache__/parallel_process.cpython-38.pyc deleted file mode 100644 index c8b2259ce09912fdaae57c02305be7ae8e1d3ef5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4877 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 kh9ZWRh56TOR9`1ehUr~&Cxc~qF diff --git a/src/__pycache__/patch_gaussian_conditional.cpython-38.pyc b/src/__pycache__/patch_gaussian_conditional.cpython-38.pyc deleted file mode 100644 index 3f56e86348aeafba7ad4df6d9f62f5080d19b266..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4207 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 diff --git a/src/attention_context.py b/src/attention_context.py old mode 100644 new mode 100755 index 5cc6da477..481804bb5 --- a/src/attention_context.py +++ b/src/attention_context.py @@ -15,7 +15,7 @@ import tensorflow as tf -from constants import LOG_2_RECIPROCAL +from .constants import LOG_2_RECIPROCAL class WindowedAttention3D(tf.keras.layers.Layer): @@ -669,8 +669,8 @@ def __init__(self, self.num_attention_layers = num_attention_layers # Import here to avoid circular dependency - from entropy_model import ConditionalGaussian - from entropy_parameters import EntropyParameters + from .entropy_model import ConditionalGaussian + from .entropy_parameters import EntropyParameters # Hyperprior-based parameter prediction self.entropy_parameters = EntropyParameters( @@ -804,9 +804,9 @@ def __init__(self, self.num_channel_groups = num_channel_groups self.num_attention_layers = num_attention_layers - from channel_context import ChannelContext - from entropy_model import ConditionalGaussian - from entropy_parameters import EntropyParameters + from .channel_context import ChannelContext + from .entropy_model import ConditionalGaussian + from .entropy_parameters import EntropyParameters # Hyperprior parameters self.entropy_parameters = EntropyParameters( diff --git a/src/benchmarks.py b/src/benchmarks.py old mode 100644 new mode 100755 index 709a47a5c..36797e900 --- a/src/benchmarks.py +++ b/src/benchmarks.py @@ -391,7 +391,7 @@ def create_mask_vectorized(kernel_size, mask_type, in_channels, filters): def benchmark_attention(): """Benchmark attention implementations.""" - from attention_context import SparseAttention3D, WindowedAttention3D + from .attention_context import SparseAttention3D, WindowedAttention3D dim = 64 input_shape = (1, 16, 16, 16, dim) # Smaller for testing diff --git a/src/channel_context.py b/src/channel_context.py old mode 100644 new mode 100755 index 60b1ba76f..a26391c5f --- a/src/channel_context.py +++ b/src/channel_context.py @@ -11,7 +11,7 @@ import tensorflow as tf -from constants import LOG_2_RECIPROCAL +from .constants import LOG_2_RECIPROCAL class SliceTransform(tf.keras.layers.Layer): @@ -231,8 +231,8 @@ def __init__(self, self.channels_per_group = latent_channels // num_groups # Import here to avoid circular dependency - from entropy_model import ConditionalGaussian - from entropy_parameters import EntropyParameters + from .entropy_model import ConditionalGaussian + from .entropy_parameters import EntropyParameters # Hyperprior-based parameter prediction self.entropy_parameters = EntropyParameters( diff --git a/src/cli_train.py b/src/cli_train.py old mode 100644 new mode 100755 index 36ad2c44a..750ced4bb --- a/src/cli_train.py +++ b/src/cli_train.py @@ -5,7 +5,7 @@ import keras_tuner as kt import tensorflow as tf -from ds_mesh_to_pc import read_off +from .ds_mesh_to_pc import read_off def create_model(hp): diff --git a/src/context_model.py b/src/context_model.py old mode 100644 new mode 100755 index 7fea40322..54dca8262 --- a/src/context_model.py +++ b/src/context_model.py @@ -11,7 +11,7 @@ import numpy as np import tensorflow as tf -from constants import LOG_2_RECIPROCAL +from .constants import LOG_2_RECIPROCAL class MaskedConv3D(tf.keras.layers.Layer): @@ -265,8 +265,8 @@ def __init__(self, self.num_context_layers = num_context_layers # Import here to avoid circular dependency - from entropy_model import ConditionalGaussian - from entropy_parameters import EntropyParameters + from .entropy_model import ConditionalGaussian + from .entropy_parameters import EntropyParameters # Hyperprior-based parameter prediction self.entropy_parameters = EntropyParameters( diff --git a/src/data_loader.py b/src/data_loader.py old mode 100644 new mode 100755 index d48127d26..23976ffd6 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -5,8 +5,8 @@ import numpy as np import tensorflow as tf -from ds_mesh_to_pc import read_off -from ds_pc_octree_blocks import PointCloudProcessor +from .ds_mesh_to_pc import read_off +from .ds_pc_octree_blocks import PointCloudProcessor class DataLoader: diff --git a/src/deepcompress.egg-info/PKG-INFO b/src/deepcompress.egg-info/PKG-INFO deleted file mode 100644 index 8fa1abe9b..000000000 --- a/src/deepcompress.egg-info/PKG-INFO +++ /dev/null @@ -1,10 +0,0 @@ -Metadata-Version: 1.0 -Name: deepcompress -Version: 0.1 -Summary: UNKNOWN -Home-page: UNKNOWN -Author: UNKNOWN -Author-email: UNKNOWN -License: UNKNOWN -Description: UNKNOWN -Platform: UNKNOWN diff --git a/src/deepcompress.egg-info/SOURCES.txt b/src/deepcompress.egg-info/SOURCES.txt deleted file mode 100644 index 64c7685f4..000000000 --- a/src/deepcompress.egg-info/SOURCES.txt +++ /dev/null @@ -1,9 +0,0 @@ -LICENSE -README.md -setup.py -src/deepcompress.egg-info/PKG-INFO -src/deepcompress.egg-info/SOURCES.txt -src/deepcompress.egg-info/dependency_links.txt -src/deepcompress.egg-info/requires.txt -src/deepcompress.egg-info/top_level.txt -src/utils/patch_gaussian_conditional.py \ No newline at end of file diff --git a/src/deepcompress.egg-info/dependency_links.txt b/src/deepcompress.egg-info/dependency_links.txt deleted file mode 100644 index 8b1378917..000000000 --- a/src/deepcompress.egg-info/dependency_links.txt +++ /dev/null @@ -1 +0,0 @@ - diff --git a/src/deepcompress.egg-info/requires.txt b/src/deepcompress.egg-info/requires.txt deleted file mode 100644 index f039a2fae..000000000 --- a/src/deepcompress.egg-info/requires.txt +++ /dev/null @@ -1,3 +0,0 @@ -numpy -pytest -numba diff --git a/src/deepcompress.egg-info/top_level.txt b/src/deepcompress.egg-info/top_level.txt deleted file mode 100644 index 9487075c0..000000000 --- a/src/deepcompress.egg-info/top_level.txt +++ /dev/null @@ -1 +0,0 @@ -utils diff --git a/src/entropy_model.py b/src/entropy_model.py old mode 100644 new mode 100755 index 47bcbd185..d79014928 --- a/src/entropy_model.py +++ b/src/entropy_model.py @@ -3,7 +3,7 @@ import tensorflow as tf import tensorflow_probability as tfp -from constants import LOG_2_RECIPROCAL +from .constants import LOG_2_RECIPROCAL class PatchedGaussianConditional(tf.keras.layers.Layer): @@ -335,7 +335,7 @@ def __init__(self, self.hidden_channels = hidden_channels or latent_channels * 2 # Import here to avoid circular dependency - from entropy_parameters import EntropyParameters + from .entropy_parameters import EntropyParameters # Network to predict mean/scale from hyperprior self.entropy_parameters = EntropyParameters( diff --git a/src/evaluation_pipeline.py b/src/evaluation_pipeline.py old mode 100644 new mode 100755 index 2ede7926c..90f645cf2 --- a/src/evaluation_pipeline.py +++ b/src/evaluation_pipeline.py @@ -5,10 +5,10 @@ import tensorflow as tf -from data_loader import DataLoader -from ev_compare import PointCloudMetrics -from model_transforms import DeepCompressModel, TransformConfig -from mp_report import ExperimentReporter +from .data_loader import DataLoader +from .ev_compare import PointCloudMetrics +from .model_transforms import DeepCompressModel, TransformConfig +from .mp_report import ExperimentReporter @dataclass @@ -24,10 +24,13 @@ class EvaluationResult: class EvaluationPipeline: """Pipeline for evaluating DeepCompress model.""" - def __init__(self, config_path: str): + def __init__(self, config_path: str, checkpoint_override: str = None): self.config = self._load_config(config_path) self.logger = logging.getLogger(__name__) + if checkpoint_override: + self.config['checkpoint_path'] = checkpoint_override + # Initialize components self.data_loader = DataLoader(self.config) self.metrics = PointCloudMetrics() @@ -139,7 +142,7 @@ def main(): ) # Run evaluation - pipeline = EvaluationPipeline(args.config) + pipeline = EvaluationPipeline(args.config, checkpoint_override=args.checkpoint) results = pipeline.evaluate() pipeline.generate_report(results) diff --git a/src/model_transforms.py b/src/model_transforms.py old mode 100644 new mode 100755 index ab38c7ec3..7992ce359 --- a/src/model_transforms.py +++ b/src/model_transforms.py @@ -3,7 +3,7 @@ import tensorflow as tf -from constants import EPSILON, LOG_2_RECIPROCAL +from .constants import EPSILON, LOG_2_RECIPROCAL @dataclass @@ -322,25 +322,25 @@ def __init__(self, def _create_entropy_model(self): """Create the selected entropy model.""" if self.entropy_model_type == 'gaussian': - from entropy_model import EntropyModel + from .entropy_model import EntropyModel self.entropy_module = EntropyModel() elif self.entropy_model_type == 'hyperprior': - from entropy_model import MeanScaleHyperprior + from .entropy_model import MeanScaleHyperprior self.entropy_module = MeanScaleHyperprior( latent_channels=self.latent_channels, hyper_channels=self.hyper_channels ) elif self.entropy_model_type == 'context': - from context_model import ContextualEntropyModel + from .context_model import ContextualEntropyModel self.entropy_module = ContextualEntropyModel( latent_channels=self.latent_channels, hyper_channels=self.hyper_channels ) elif self.entropy_model_type == 'channel': - from channel_context import ChannelContextEntropyModel + from .channel_context import ChannelContextEntropyModel self.entropy_module = ChannelContextEntropyModel( latent_channels=self.latent_channels, hyper_channels=self.hyper_channels, @@ -348,7 +348,7 @@ def _create_entropy_model(self): ) elif self.entropy_model_type == 'attention': - from attention_context import AttentionEntropyModel + from .attention_context import AttentionEntropyModel self.entropy_module = AttentionEntropyModel( latent_channels=self.latent_channels, hyper_channels=self.hyper_channels, @@ -356,7 +356,7 @@ def _create_entropy_model(self): ) elif self.entropy_model_type == 'hybrid': - from attention_context import HybridAttentionEntropyModel + from .attention_context import HybridAttentionEntropyModel self.entropy_module = HybridAttentionEntropyModel( latent_channels=self.latent_channels, hyper_channels=self.hyper_channels, diff --git a/src/mp_report.py b/src/mp_report.py old mode 100644 new mode 100755 index b2e6aa22b..9e1d28992 --- a/src/mp_report.py +++ b/src/mp_report.py @@ -69,7 +69,7 @@ def _compute_best_metrics(self) -> Dict[str, Any]: 'psnr': float('-inf'), 'bd_rate': float('inf'), 'bitrate': float('inf'), - 'compression_ratio': float('inf'), + 'compression_ratio': float('-inf'), 'compression_time': float('inf'), 'decompression_time': float('inf') } @@ -91,7 +91,7 @@ def _compute_best_metrics(self) -> Dict[str, Any]: for metric in best_metrics.keys(): if metric in results: value = results[metric] - if metric == 'psnr': # Higher is better + if metric in ('psnr', 'compression_ratio'): # Higher is better if value > best_metrics[metric]: best_metrics[metric] = value best_models[metric] = file_name diff --git a/src/parallel_process.py b/src/parallel_process.py old mode 100644 new mode 100755 index 135d74189..663acf033 --- a/src/parallel_process.py +++ b/src/parallel_process.py @@ -71,7 +71,13 @@ def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): - self.terminate() + if self.process.poll() is None: + self.terminate() + else: + if hasattr(self.process, 'stdout') and self.process.stdout: + self.process.stdout.close() + if hasattr(self.process, 'stderr') and self.process.stderr: + self.process.stderr.close() def parallel_process( func: Callable[[Any], Any], diff --git a/src/quick_benchmark.py b/src/quick_benchmark.py old mode 100644 new mode 100755 index 4331ab7f4..b58d657ad --- a/src/quick_benchmark.py +++ b/src/quick_benchmark.py @@ -28,7 +28,7 @@ sys.path.insert(0, os.path.dirname(__file__)) -from model_transforms import DeepCompressModel, DeepCompressModelV2, TransformConfig +from .model_transforms import DeepCompressModel, DeepCompressModelV2, TransformConfig @dataclass diff --git a/src/training_pipeline.py b/src/training_pipeline.py old mode 100644 new mode 100755 index ab92b923c..7ac00c571 --- a/src/training_pipeline.py +++ b/src/training_pipeline.py @@ -10,9 +10,9 @@ class TrainingPipeline: def __init__(self, config_path: str): import yaml - from data_loader import DataLoader - from entropy_model import EntropyModel - from model_transforms import DeepCompressModel, TransformConfig + from .data_loader import DataLoader + from .entropy_model import EntropyModel + from .model_transforms import DeepCompressModel, TransformConfig self.config_path = config_path with open(config_path, 'r') as f: diff --git a/src/utils/__pycache__/experiment.cpython-38.pyc b/src/utils/__pycache__/experiment.cpython-38.pyc deleted file mode 100644 index 66434b236b2a355e47e55e409f831982c6d96815..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 813 zcmZ8f&2AGh5FUFsX}U@ZoX8DdkXQ~IiDOh%MMPYXdH_{OxkTHwlPv6?Y_HO+Hm6h~ zCtjdJf@9x;=h!PJUV#%cn^0AZG-G=_-)H;tTn+{vpsio(#d8kuixv+Jb8wDs1O^F` zB`~FtCoJVt&OkcSy@9DC*$41Dzp)A~?pV`V>98;nqxHdv%0+k3%@Biy9c=sI+mO2^*(MjliWgCsB;i$}{IPg@s3ztsBt;r|K)zSD)Y${@KiF7MlM-k&8_v0V~4 z&K5#t#EmR;V1)0xw+_z8pP`#E1`FTe2W3nmyj6v<7Htt0=IJ_S(5@(`vf>-nESQr#5lP2(;#--;pokGW%ZF;j=7N%-8^E-_j zqAE@I^Qs*^V?%9cZE;sCQzo|Qs|5c|$u^7s=#p}G@aNs=(kN|4MHNQNP_IXmJStO_ zSu>g_RlLd50vY~W7#)r9n-Vip*M-t}smun&x;diez{CmZ(e@wjF^~1xkX29jY6hoC zj=Gz(;83fneSEG5IOxX|2(9}R9${#@dmwd~>0$t{%TA8;6P)?3rgU@M_DG&5MpF~E adwZIes|_tz?=;P2nW!`BUqjd)>+`>bQ_r0M diff --git a/src/utils/__pycache__/pc_metric.cpython-38.pyc b/src/utils/__pycache__/pc_metric.cpython-38.pyc deleted file mode 100644 index 04997eed3b4c207452237c4f4b7a57e74c41df6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4463 zcmb7HOLN@D5ylL#*aw$yith(VO0hSQN$w_9b`)7LC0cfDXX8Xxs)Di#1@3?(s9j(+ z0A-Sey_oV*srV3I99Ky>q>6tbRrw>h=A=K6LoUkKgWcsrR#gNPdU|Ghx_f%Mdm8r# z2TK~Bf6dF?zbx{*t=4xiENC2w|rLA)j2 z7L{FnM%!PsEpP5eDVM#XH+jOmC2!fA_pB4cUV(*JbJbq;R-dhUkPh3hds^k47i8yp zg{8&MkAnD4GdvLJly7i>*(kEiH33PV~1nLi1mb@ur2Mu^)GoR*D(`4F>~4s&h%YP*KTTwk@XWT$<1il z*rOU&F}3Qw{wmB0_K?dxaL8Tap!(oo_Brf9BO}p;{+xGo$k6AsPCm)Q62@`*k^k5W ztaj)Ju@!l7WF1y5HxSlg?Yb39H;8sT8Ch-|OMm+y_A<0;?Z~i2q;Uw!wUUL$7l+jw zR=sAqQo2W0BMf5K4`4nk-t(-$bEOx>X#L&2?GQZuK==(X40pf+J+SNXseupFcym5g z#iyqDNW_iyLV=ffD!G&g?KF4n$uLTD(VpA((xMj}v^?p?UOM0%wy|+X_^qf?NOQF1 zYHHA~6s)CYBRmM=$S%^96CJdol=-O+ij2B;s>un+{O>30_aj(sy?yL9_FQ?iz7;kg z25Z;1JgL8iDeC^<-y*Kwf0du;6{?=Q6anBK`q3#@ z5>p*pJ>@AoOxcmzUVFU<8NX$u#6U4i6k!a8Ub?Ib0^A0?K(z!(n#{_rCFF&()wO?aob8ZSy1iu zN9AQ^u5Ey*C$j?l^~Xv(6pW-M@Z>q<+zFVs!s{>SLalV2HrEVys~hN%b<p(tod7;A-{;mF~{K$Zv`^K#3DPP+- zJKM7vsRE~Tv*|~11F7KFFCi$(t`*KqjlY!Z7`dPVg;qa=EOe9#(;XB93Hx6A0hdqK z?(lCpDN^CKj?e^v|BzC!#2T7Rp}v+%7bQ+9L9Pr1v`XGq$Fg}lYj1Dbd5Q*D(Jm+i zQEHp5$a9E<+S{9{j`r?_b0f?3)!srULEZ4+23aiD9-7fg%o6^+I!$asKVe^E8upM_ZY?KadM#$B%9|j*v8OpR2#jcDaT8jk(=Sj;n=ybWIrL(Ea zXFKuOZTbS2%6UoeLq&DU8WQy^GnjlABNsx8g1Dbe-a{whLVH+{6EO^z4UtRDzT|JGmrBH&%bcCTbVsf(gt{LF6d! z(AO!#2zi*J1hW*-4yc=^bizAjWuO6IgQ%&>$sn*HYNs?|q9z&!uI(EFtI-=t22s`Z zdZSn|l8j=-Seye^1U3$ARACdbnT#hB7`sCJlA&ansFN`oO|FQ--%6cHRAAb}BC6rP z$9dFkg(QEXcczjlrR_A0K--zP0KH4eH0EZ3vt%Hd0X_%$v&kGJ%v0OdcmepC#*4|^ zIgOXFVli353JZEJDm|BhEh%gT8ZRd+m|Z1)$$YXv)QLr-$*L$q0$GBA4Og$bw`ms(t#e11fpoN9MVTYP9+6K z)iTP>#En=e9P{}uuH@CuCQmc4^#yv=>n@WwF?z7 zoxtx1d`{r^1pYwa9)T|i+$Zoy0Gq7R<$E~+4EGX1cb=4p+5nW{$|?bjaVkp-tfbyD z8^W96CC=F^?|;m}QnmuH49fpwMHxxv0c55H2lta7IL`6Log3W>oa%5?-(LmI&=t{o zGYobGa#iF!4iBpvPW9&-8*A<6(Q)lfRycuL^TC^}p%rV}ey}FIMkqW%eg7o_NZuyh zd2opD$mYRuh25>79_ICW3y Hc!B*7&foDg diff --git a/tests/__pycache__/test_colorbar.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_colorbar.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index 7bfe97fbfbc4dd22e65b49e893876f87ffcfe992..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12079 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 diff --git a/tests/__pycache__/test_ds_mesh_to_pc.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_ds_mesh_to_pc.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index 6eb4a0255b3b2033ffea9a293daab0e825140fe2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4855 zcmbtYTaOgS6|U;r^z`hFWw|V1r$elm!PYD`#t$(9B-S7+&VtRNI4!JJGu6AyFt@Gh z1-7*$4_FG3vdZNxi7d@N@&odapOELg)vGNkb5Oa0TRnmhOz;kixOrM#+hUw`oyg@yj~98SrxN+* zS&gcATAiR*=!qT8riX=l-;!DC z4d=G8zTmf5-FuF=v!%MHeVHaA?ys!qV=CzmI%z!Uw|be1S7g5%cT;sw`zm>W=FWyb z+3fcoq%t$@an>)!#?4NDU1KUhq@~&!pGASRT1D>5j8P^r!OYO6rNQvEoSfx#33ASR!=RbDqB>v zrRDz3W|QL;qo0jJ4fj>t>I!-U-()cq3&}I9P4C9F3PsX|r%RWq7wvA@Pa|j?ji9GT z*Q&khV--hKwrEAytmutrpk!uXbUCA_o0*uu(@y<#?O)N`WDaY4Shv#lH&GHkX=TfP zjae-AvMSn`s6DZMb>}hPsDINEQ}YbH z(6c2eK4>n+zU?Ie#{D)R5A=|Hr) zO7n-gbK}~zAa}!a;kiK4IL*W7ge=X2rTT=Pq<0g({TUn~@PTH&6+EHm|MLYD4 z$fE_)N6(l%g?)T`W8t<+rCJ!|N%I~wv#{82u6I-T*uo+Xa<$(b$W-mC(5}^j*}G6X z?$&G!HngkK?Dk*^YpW{|%<{O$9q#YFzOky#Km3W8wAWs@r+C1R;?C!m@v~?WMp_rsNEcp*@*)`!Lrbs`zTqfk_`_y3)ZA(ULk)8K_J>Y+<6G z_Jk?bZFz#mSMMeYyog2RNz{$C3-Ov+E=v=+*nil27d1t%W7ZU(;JB?>$P+w2womh; z8GDpZV5X>Hl>asg8G{n}6AtHQFapo)Z42sk+V0TX^s(D>#+?*Ih$4+v4j+3YWBEh0e|Nn1HnTts|@|D(yrnekdil@;|yr6 z+-JnGTfm?QXoa;!qug44#wgcBPSS1>J41?{A!#BUaS3#78%0I#N&;?)rV5gXONx1J z<0Q+^EFJXLyJZO0(@k*0dX|m`LCNPZ%!t*$-}~z6AHVtfk|br+xul4ezj@2Va8Ya| z?Dey-)eHNwwMqoGvzvOYFfN%+9w9;R*R{gBACg%?Gxw4~s)M3!S+1uA7VM;e**+xCX!J$xG%Jc@!V(h&sGWfMrN+YB_tOqjIi z*|4^aJgWp0g-*9@^RfwAox&`rtN`33B{0LOO+biRb_LKX2rbAjlt6l5oV2u`Nvy)G zA12|=kAGsEHY6^BVNGCa!0u!{&86YK$Ohd>iVXR5+DR$M7@Mb$!u-%cB5uiSBQ$|7 z>^;&*0H-BUi{U>5&jJkZQ`>10-y`t`iDMADN-l|Dm@4@m)m@dz)C40z z=xY=4h6r_=e1!t`9pecgR3N%QI8yX+8Lh&bM;xLgQdG+k)LMC=)yL{)G~Qs=T01KO z*rS%8HTD>POpFP(P(GLqHZg%-HU4_u{Qh#;pdYDXP-FnAm8VhAZIxdl%D72>bZIm( ze3;5KYy#Fg|RE=?$>Qz~x%9twkWC8T+W|B1Emse;AxrDp`q02B?4l#T> z${M_4$J#Z|{XjU85j~ENy!-p9g@qM?hgrKIP??3FOyD|-E3Y2}hXYsJ0|ZI}h$0CP z>NALaK!EF*Xj%a&+HGg(l1m{L!o4#*h}<5p)OR*@%se%cT}drIF#MCM$=6^6)Pi_&kbHKGnnhL|9~3xV<(D%VX} zTa9y?uRP!9_|jDZXENiXP=dc+BE6Ecj1;;~*=(!l`453FVm^7m@kmo8=M; zqc_O!V08lWK7QI^`0vy1-Xw9I1o3&9(M7ewkxYhN9N-2GF)8lb2Ce_&&L~$?$02s% zmuQSz|MY6J{J52W@PC|psb1559QTrL8pk?_<8EK9;bX-YBn#8RrnaAhW8~%jv`O}brf5`OG7P<-8CaxdR{@wLf z2U)Yy6ITWs@whf04>m|~jeU!hI diff --git a/tests/__pycache__/test_ds_pc_octree_blocks.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_ds_pc_octree_blocks.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index 5423206ac29ca26b0ae8e60677359a82d6ba1611..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7013 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 diff --git a/tests/__pycache__/test_ev_run_experiment.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_ev_run_experiment.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index cf2b47cd234f8285c2d8a2960a73c79e0d5fe0cc..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3644 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 diff --git a/tests/__pycache__/test_experiment.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_experiment.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index 6fcc10f4fc146f96bec38ae4a3309a5991fa8119..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3274 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 diff --git a/tests/__pycache__/test_model_transforms.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_model_transforms.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index 15681daeece32bf0e3385b6a6f8f5a317d457280..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5149 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 E{}sn%AOHXW diff --git a/tests/__pycache__/test_octree_coding.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_octree_coding.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index 962eabbe11f88d81d112a3ccb52ce8f05eb55a6c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3459 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 diff --git a/tests/__pycache__/test_parallel_process.cpython-38.pyc b/tests/__pycache__/test_parallel_process.cpython-38.pyc deleted file mode 100644 index 7590b28b069300fcd1faf3953b4c27ba43432a6e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1972 zcma)7&2HO95Z+z>i2AW1I4RPiK*82+Q(Y*aNG?TC1aOM>62WzvHsFO|xGRYc|GG;y zHbI@7YtUD)kA9NA1Ft>l6^b4@L(;MpxPVvK(T;X^cJ}+eS-srZ84zfH{VNv_dxZRr z&dsKS^Ax)J6$~ewM#RN$7SWiwEY@6&qOV1Ith;(_xCZz-H{KD~{E={z_udiS6Xdve zrn#2rbL$P0M}nLWE(oloFUX|7hS+2_VYQL@d9bYe&;3OhJWqo+@Y^`^r5{Bi@-mqQ zLaEv~Of!)L6nAwE#|-)bbY+35;B%iqa9Zxa2;(dgw)(yBrLgBk669f;*z;7{xlnnS zESyVNSkn_}!q&$8b2NRagjCb4^n;}@Pp8k)pom41tLZZ#vV%0vBqX!#U^q2J>wTED zlbzOPn}UH!Vzz|IKG*<wEWf(CjNRz~c(Z|mBFdE=hbl(tIJ1c+F=PX5J@gfjnEwFGM-#o)m5AoGi8;o~ z9gCAp1i9c|7p3lXgKHT`)KdM?t&9#~s?i!@yL12BE5B=)YqGGf)3Ref@fQLm-;|0f0<6Ay5nXfZNMzI1 zMX*d=<0lbC>Fkn$&U2m?dGjr#Ece^kJmhwg=G}HE$W6rP)VVYz>Y>)(`e9y=q{s`I zcz^g&AtcJfeb9301^5_nFHHjBy$$oFya%(e`#vrj#PDc%8NLths2z8Yh8D!U?sbs6 z!!CZ1a`7SCdo5!a!|PJSBNR%L=q9zO$t*gcdu&Ww)n6&IE3=OBn*oCVW%m^Xy`9|! zy%V<@*6bFyM#HTJ+?wULcj9*Lhfx7{NECK$%>77-nfw@>^5HgtOF!Z8pl)`h zQbHjCiSkoeEk8rEc}d$6x(RBx?+aMA1*&;hP`d!LNw2m%FY#mHdG)~a;*=K=jz^w{ zCEw1-2bd2MAU{X5kLEhPW`t!f@t+W@PT{~}mIVpznzZ>H4~qRT@w~~fwu&UoQ6=uq z)yP4ywTGaL+cyYx+df1V(r^IiHo@eVFj4Cm@3us0>q&c)@3Fen2#iI?bj&P$?;DH$ E2W?&kZ~y=R diff --git a/tests/__pycache__/test_patch_gaussian_conditional.cpython-38-pytest-8.3.4.pyc b/tests/__pycache__/test_patch_gaussian_conditional.cpython-38-pytest-8.3.4.pyc deleted file mode 100644 index b76f9adbe4b17f92baae616463847f5675c33fd5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3575 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, +# making both conventions compatible without changing any test files. +# --------------------------------------------------------------------------- +_project_root = str(Path(__file__).parent.parent) +if _project_root not in sys.path: + sys.path.insert(0, _project_root) + +_src_dir = Path(__file__).parent.parent / 'src' + + +class _SrcPackageFinder: + """Meta-path finder that loads bare src module imports as src.X.""" + + _loading = set() + + def find_module(self, fullname, path=None): + if fullname in self._loading: + return None + if '.' not in fullname and (_src_dir / f'{fullname}.py').exists(): + return self + return None + + def load_module(self, fullname): + if fullname in sys.modules: + return sys.modules[fullname] + self._loading.add(fullname) + try: + mod = importlib.import_module(f'src.{fullname}') + sys.modules[fullname] = mod + return mod + finally: + self._loading.discard(fullname) + + +sys.meta_path.insert(0, _SrcPackageFinder()) + def pytest_collection_modifyitems(items): """Filter out tf.test.TestCase.test_session, which is a deprecated diff --git a/tests/test_mp_report.py b/tests/test_mp_report.py old mode 100644 new mode 100755 index a7e50bfc3..cf62a4904 --- a/tests/test_mp_report.py +++ b/tests/test_mp_report.py @@ -113,8 +113,8 @@ def test_best_performance_selection(setup_experiment): assert best_performance['best_bd_rate'] == 'original_3.ply' # The best bitrate should be from "original_2.ply" (lowest bitrate is best) assert best_performance['best_bitrate'] == 'original_2.ply' - # The best compression ratio should be from "original_1.ply" (lowest is best: 0.75) - assert best_performance['best_compression_ratio'] == 'original_1.ply' + # The best compression ratio should be from "original_3.ply" (highest is best: 0.85) + assert best_performance['best_compression_ratio'] == 'original_3.ply' # The best compression time should be from "original_3.ply" (shorter is better: 2.0) assert best_performance['best_compression_time'] == 'original_3.ply' # The best decompression time should be from "original_1.ply" (shorter is better: 1.0) diff --git a/tests/test_parallel_process.py b/tests/test_parallel_process.py old mode 100644 new mode 100755 index c57274da4..7abbd01a7 --- a/tests/test_parallel_process.py +++ b/tests/test_parallel_process.py @@ -66,18 +66,34 @@ def test_popen_timeout(self, mock_popen): process.wait() @patch("subprocess.Popen") - def test_popen_cleanup(self, mock_popen): - """Test proper cleanup of Popen resources.""" + def test_popen_cleanup_running(self, mock_popen): + """Test cleanup terminates a still-running process.""" mock_process = MagicMock() + mock_process.poll.return_value = None # Process still running mock_process.stdout = MagicMock() mock_process.stderr = MagicMock() mock_popen.return_value = mock_process - cmd = ["echo", "test"] + cmd = ["sleep", "10"] with Popen(cmd) as _: - pass # Context manager should handle cleanup + pass # Context manager should terminate mock_process.terminate.assert_called_once() + + @patch("subprocess.Popen") + def test_popen_cleanup_finished(self, mock_popen): + """Test cleanup skips terminate for already-finished process.""" + mock_process = MagicMock() + mock_process.poll.return_value = 0 # Process already exited + mock_process.stdout = MagicMock() + mock_process.stderr = MagicMock() + mock_popen.return_value = mock_process + + cmd = ["echo", "test"] + with Popen(cmd) as _: + pass # Context manager should just close handles + + mock_process.terminate.assert_not_called() mock_process.stdout.close.assert_called_once() mock_process.stderr.close.assert_called_once() From c002461359e9082a18f26c0c0167d1ea58f0dde7 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Fri, 27 Feb 2026 17:57:15 -0800 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20data=20pipeline=20=E2=80=94=20file?= =?UTF-8?q?=20I/O,=20parsing,=20sampling,=20and=20boundary=20duplication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add src/file_io.py with read_off(), read_ply(), read_point_cloud() dispatcher - Fix data_loader.py: use file_io for both .off/.ply, guard divide-by-zero in normalization - Fix ds_mesh_to_pc.py: triangulate n-gon faces, replace centroid with barycentric sampling - Fix ds_pc_octree_blocks.py: replace broken TF PLY parser, remove dual file write - Fix octree_coding.py and compress_octree.py: half-open intervals prevent boundary duplication - Fix cli_train.py: use read_point_cloud instead of read_off for .ply files Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- src/cli_train.py | 8 ++-- src/compress_octree.py | 16 ++++--- src/data_loader.py | 10 +++-- src/ds_mesh_to_pc.py | 45 +++++++++++-------- src/ds_pc_octree_blocks.py | 55 +++++++---------------- src/file_io.py | 91 ++++++++++++++++++++++++++++++++++++++ src/octree_coding.py | 29 +++++++++--- 8 files changed, 177 insertions(+), 79 deletions(-) mode change 100644 => 100755 src/compress_octree.py mode change 100644 => 100755 src/ds_mesh_to_pc.py mode change 100644 => 100755 src/ds_pc_octree_blocks.py create mode 100755 src/file_io.py mode change 100644 => 100755 src/octree_coding.py diff --git a/pyproject.toml b/pyproject.toml index 65733cc3d..2dad2a53e 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,5 +23,5 @@ known-first-party = [ "ev_compare", "ev_run_render", "mp_report", "mp_run", "quick_benchmark", "benchmarks", "parallel_process", "point_cloud_metrics", "map_color", "colorbar", - "cli_train", "test_utils", + "cli_train", "file_io", "test_utils", ] diff --git a/src/cli_train.py b/src/cli_train.py index 750ced4bb..f873b0300 100755 --- a/src/cli_train.py +++ b/src/cli_train.py @@ -5,7 +5,7 @@ import keras_tuner as kt import tensorflow as tf -from .ds_mesh_to_pc import read_off +from .file_io import read_point_cloud def create_model(hp): @@ -32,8 +32,10 @@ def load_and_preprocess_data(input_dir, batch_size): file_paths = glob.glob(os.path.join(input_dir, "*.ply")) def parse_ply_file(file_path): - mesh_data = read_off(file_path) - return mesh_data.vertices + vertices = read_point_cloud(file_path) + if vertices is None: + raise ValueError(f"Failed to read point cloud: {file_path}") + return vertices def data_generator(): for file_path in file_paths: diff --git a/src/compress_octree.py b/src/compress_octree.py old mode 100644 new mode 100755 index 08d83d681..8fa5471c3 --- a/src/compress_octree.py +++ b/src/compress_octree.py @@ -153,13 +153,15 @@ def partition_recursive(points: np.ndarray, bounds: Tuple[np.ndarray, np.ndarray mid[i] if octant[i] == 0 else bounds[1][i] for i in range(3) ]) - # Find points in this octant with epsilon for stability - epsilon = 1e-10 - mask = np.all( - (points >= min_corner - epsilon) & - (points <= max_corner + epsilon), - axis=1 - ) + # Half-open intervals: [min, mid) for lower, [mid, max] for upper + lower_cond = points >= min_corner + upper_cond = np.array([ + points[:, i] <= max_corner[i] + if octant[i] == 1 # upper half: inclusive + else points[:, i] < max_corner[i] # lower half: exclusive + for i in range(3) + ]).T + mask = np.all(lower_cond & upper_cond, axis=1) if np.any(mask): partition_recursive(points[mask], (min_corner, max_corner)) diff --git a/src/data_loader.py b/src/data_loader.py index 23976ffd6..a9a7914ed 100755 --- a/src/data_loader.py +++ b/src/data_loader.py @@ -5,8 +5,8 @@ import numpy as np import tensorflow as tf -from .ds_mesh_to_pc import read_off from .ds_pc_octree_blocks import PointCloudProcessor +from .file_io import read_point_cloud class DataLoader: @@ -22,8 +22,10 @@ def __init__(self, config: Dict[str, Any]): def process_point_cloud(self, file_path: str) -> tf.Tensor: """Process a single point cloud file.""" # Read point cloud - mesh_data = read_off(file_path.numpy().decode()) - points = tf.convert_to_tensor(mesh_data.vertices, dtype=tf.float32) + vertices = read_point_cloud(file_path.numpy().decode()) + if vertices is None: + raise ValueError(f"Failed to read point cloud: {file_path}") + points = tf.convert_to_tensor(vertices, dtype=tf.float32) # Normalize points to unit cube points = self._normalize_points(points) @@ -43,7 +45,7 @@ def _normalize_points(self, points: tf.Tensor) -> tf.Tensor: """Normalize points to unit cube.""" center = tf.reduce_mean(points, axis=0) points = points - center - scale = tf.reduce_max(tf.abs(points)) + scale = tf.maximum(tf.reduce_max(tf.abs(points)), 1e-8) points = points / scale return points diff --git a/src/ds_mesh_to_pc.py b/src/ds_mesh_to_pc.py old mode 100644 new mode 100755 index 72f19d0b8..2aa67ac42 --- a/src/ds_mesh_to_pc.py +++ b/src/ds_mesh_to_pc.py @@ -42,14 +42,19 @@ def read_off(file_path: str) -> Optional[MeshData]: vertices.append(vertex) vertices = np.array(vertices, dtype=np.float32) - # Read faces if present + # Read faces if present, triangulating n-gons via fan triangulation faces = None if n_faces > 0: - faces = [] + triangles = [] for _ in range(n_faces): - face = list(map(int, file.readline().strip().split()[1:])) # Skip count - faces.append(face) - faces = np.array(faces, dtype=np.int32) + indices = list(map(int, file.readline().strip().split()[1:])) + if len(indices) < 3: + continue + # Fan triangulation: (v0, v1, v2), (v0, v2, v3), ... + for i in range(1, len(indices) - 1): + triangles.append([indices[0], indices[i], indices[i + 1]]) + if triangles: + faces = np.array(triangles, dtype=np.int32) # Compute face normals if faces are present face_normals = None @@ -90,23 +95,25 @@ def sample_points_from_mesh( Tuple of points array and optionally normals array. """ if mesh_data.faces is not None and len(mesh_data.faces) > 0: - # Sample from faces using area weighting - areas = [] - centroids = [] - for face in mesh_data.faces: - v1, v2, v3 = mesh_data.vertices[face] - area = np.linalg.norm(np.cross(v2 - v1, v3 - v1)) / 2 - centroid = (v1 + v2 + v3) / 3 - areas.append(area) - centroids.append(centroid) - - # Normalize areas for probability distribution - areas = np.array(areas) + # Sample from faces using area-weighted barycentric sampling + v1s = mesh_data.vertices[mesh_data.faces[:, 0]] + v2s = mesh_data.vertices[mesh_data.faces[:, 1]] + v3s = mesh_data.vertices[mesh_data.faces[:, 2]] + + areas = np.linalg.norm(np.cross(v2s - v1s, v3s - v1s), axis=1) / 2 probabilities = areas / areas.sum() - # Sample points + # Sample faces by area indices = np.random.choice(len(areas), num_points, p=probabilities) - points = np.array(centroids)[indices] + + # Generate random barycentric coordinates for uniform sampling + r1 = np.sqrt(np.random.random(num_points)) + r2 = np.random.random(num_points) + points = ( + (1 - r1)[:, None] * v1s[indices] + + (r1 * (1 - r2))[:, None] * v2s[indices] + + (r1 * r2)[:, None] * v3s[indices] + ) # Get corresponding normals if requested normals = None diff --git a/src/ds_pc_octree_blocks.py b/src/ds_pc_octree_blocks.py old mode 100644 new mode 100755 index d0791888f..2d712de2b --- a/src/ds_pc_octree_blocks.py +++ b/src/ds_pc_octree_blocks.py @@ -4,6 +4,8 @@ import tensorflow as tf +from .file_io import read_point_cloud as _read_point_cloud + class PointCloudProcessor: """Point cloud processing with TF 2.x operations.""" @@ -12,22 +14,12 @@ def __init__(self, block_size: float = 1.0, min_points: int = 10): self.block_size = block_size self.min_points = min_points - @tf.function def read_point_cloud(self, file_path: str) -> tf.Tensor: - """Read point cloud using TF file operations.""" - raw_data = tf.io.read_file(file_path) - lines = tf.strings.split(raw_data, '\n')[1:] # Skip header - - def parse_line(line): - values = tf.strings.split(line) - return tf.strings.to_number(values[:3], out_type=tf.float32) - - points = tf.map_fn( - parse_line, - lines, - fn_output_signature=tf.float32 - ) - return points + """Read point cloud from PLY or OFF file.""" + vertices = _read_point_cloud(file_path) + if vertices is None: + raise ValueError(f"Failed to read point cloud: {file_path}") + return tf.convert_to_tensor(vertices, dtype=tf.float32) def partition_point_cloud(self, points: tf.Tensor) -> List[tf.Tensor]: """Partition point cloud into blocks using TF operations.""" @@ -67,31 +59,18 @@ def save_blocks(self, blocks: List[tf.Tensor], output_dir: str, base_name: str): for i, block in enumerate(blocks): file_path = output_dir / f"{base_name}_block_{i}.ply" - - header = [ - "ply", - "format ascii 1.0", - f"element vertex {block.shape[0]}", - "property float x", - "property float y", - "property float z", - "end_header" - ] + points = block.numpy() if isinstance(block, tf.Tensor) else block with open(file_path, 'w') as f: - f.write('\n'.join(header) + '\n') - - # Convert points to strings and write - points_str = tf.strings.reduce_join( - tf.strings.as_string(block), - axis=1, - separator=' ' - ) - points_str = tf.strings.join([points_str, tf.constant('\n')], '') - tf.io.write_file( - str(file_path), - tf.strings.join([tf.strings.join(header, '\n'), points_str]) - ) + f.write("ply\n") + f.write("format ascii 1.0\n") + f.write(f"element vertex {len(points)}\n") + f.write("property float x\n") + f.write("property float y\n") + f.write("property float z\n") + f.write("end_header\n") + for point in points: + f.write(f"{point[0]} {point[1]} {point[2]}\n") def main(): parser = argparse.ArgumentParser( diff --git a/src/file_io.py b/src/file_io.py new file mode 100755 index 000000000..1271c30c3 --- /dev/null +++ b/src/file_io.py @@ -0,0 +1,91 @@ +import logging +from pathlib import Path +from typing import Optional + +import numpy as np + + +def read_off(file_path: str) -> Optional[np.ndarray]: + """Read vertex coordinates from an OFF file. + + Args: + file_path: Path to the OFF file. + + Returns: + Numpy array of shape (N, 3) with vertex positions, or None on error. + """ + try: + with open(file_path, 'r') as f: + header = f.readline().strip() + if header != "OFF": + raise ValueError("Not a valid OFF file") + + n_verts, _, _ = map(int, f.readline().strip().split()) + + vertices = [] + for _ in range(n_verts): + values = f.readline().strip().split() + vertices.append([float(values[0]), float(values[1]), float(values[2])]) + + return np.array(vertices, dtype=np.float32) + except Exception as e: + logging.error(f"Error reading OFF file {file_path}: {e}") + return None + + +def read_ply(file_path: str) -> Optional[np.ndarray]: + """Read vertex coordinates from an ASCII PLY file. + + Args: + file_path: Path to the PLY file. + + Returns: + Numpy array of shape (N, 3) with vertex positions, or None on error. + """ + try: + with open(file_path, 'r') as f: + line = f.readline().strip() + if line != "ply": + raise ValueError("Not a valid PLY file") + + n_verts = 0 + while True: + line = f.readline().strip() + if line == "end_header": + break + if line.startswith("element vertex"): + n_verts = int(line.split()[-1]) + + if n_verts == 0: + return np.array([], dtype=np.float32).reshape(0, 3) + + vertices = [] + for _ in range(n_verts): + values = f.readline().strip().split() + vertices.append([float(values[0]), float(values[1]), float(values[2])]) + + return np.array(vertices, dtype=np.float32) + except Exception as e: + logging.error(f"Error reading PLY file {file_path}: {e}") + return None + + +def read_point_cloud(file_path: str) -> Optional[np.ndarray]: + """Read a point cloud from a file, dispatching by extension. + + Supports .off and .ply formats. + + Args: + file_path: Path to the point cloud file. + + Returns: + Numpy array of shape (N, 3) with vertex positions, or None on error. + """ + ext = Path(file_path).suffix.lower() + if ext == '.off': + return read_off(file_path) + elif ext == '.ply': + return read_ply(file_path) + else: + logging.error(f"Unsupported file format: {ext}") + return None diff --git a/src/octree_coding.py b/src/octree_coding.py old mode 100644 new mode 100755 index 1e9fc33b9..fdb9e1de7 --- a/src/octree_coding.py +++ b/src/octree_coding.py @@ -114,18 +114,33 @@ def partition_octree( ] for x_range, y_range, z_range in ranges: - # Compute conditions + # Half-open intervals: [min, mid) for lower half, [mid, max] for upper + x_upper_cond = ( + point_cloud[:, 0] <= x_range[1] + if x_range[1] == xmax + else point_cloud[:, 0] < x_range[1] + ) x_cond = tf.logical_and( - point_cloud[:, 0] >= x_range[0] - self.config.epsilon, - point_cloud[:, 0] <= x_range[1] + self.config.epsilon + point_cloud[:, 0] >= x_range[0], + x_upper_cond + ) + y_upper_cond = ( + point_cloud[:, 1] <= y_range[1] + if y_range[1] == ymax + else point_cloud[:, 1] < y_range[1] ) y_cond = tf.logical_and( - point_cloud[:, 1] >= y_range[0] - self.config.epsilon, - point_cloud[:, 1] <= y_range[1] + self.config.epsilon + point_cloud[:, 1] >= y_range[0], + y_upper_cond + ) + z_upper_cond = ( + point_cloud[:, 2] <= z_range[1] + if z_range[1] == zmax + else point_cloud[:, 2] < z_range[1] ) z_cond = tf.logical_and( - point_cloud[:, 2] >= z_range[0] - self.config.epsilon, - point_cloud[:, 2] <= z_range[1] + self.config.epsilon + point_cloud[:, 2] >= z_range[0], + z_upper_cond ) # Combine conditions From f77a6f770c67f91337d57869a4d85328b1795326 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Fri, 27 Feb 2026 18:33:13 -0800 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20entropy=20model=20mathematics=20?= =?UTF-8?q?=E2=80=94=20discretized=20likelihood,=20z=5Fbits,=20joint=20opt?= =?UTF-8?q?imization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace continuous log-PDF with discretized CDF-difference probability mass function for correct entropy coding. Fix quantization to use standard grid (round(y - mean), not round((y - mean) / scale)). Add missing z_bits (hyper-latent rate) to total bitrate across all entropy models. Switch training pipeline to joint rate-distortion optimization with gradient clipping and configurable lambda_rd. Co-Authored-By: Claude Opus 4.6 --- src/attention_context.py | 44 ++++++++++--- src/channel_context.py | 23 +++++-- src/context_model.py | 23 +++++-- src/entropy_model.py | 108 +++++++++++++++++--------------- src/model_transforms.py | 49 ++++++++++----- src/training_pipeline.py | 41 +++++++----- tests/test_attention_context.py | 9 ++- 7 files changed, 195 insertions(+), 102 deletions(-) mode change 100644 => 100755 tests/test_attention_context.py diff --git a/src/attention_context.py b/src/attention_context.py index 481804bb5..c80fbb6e8 100755 --- a/src/attention_context.py +++ b/src/attention_context.py @@ -669,7 +669,7 @@ def __init__(self, self.num_attention_layers = num_attention_layers # Import here to avoid circular dependency - from .entropy_model import ConditionalGaussian + from .entropy_model import ConditionalGaussian, PatchedGaussianConditional from .entropy_parameters import EntropyParameters # Hyperprior-based parameter prediction @@ -714,6 +714,9 @@ def __init__(self, # Conditional Gaussian for entropy coding self.conditional = ConditionalGaussian() + # Hyperprior entropy model (for z) + self.hyper_entropy = PatchedGaussianConditional() + self.scale_min = 0.01 def _split_params(self, params: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: @@ -723,6 +726,7 @@ def _split_params(self, params: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: return mean, scale def call(self, y: tf.Tensor, z_hat: tf.Tensor, + z: Optional[tf.Tensor] = None, training: Optional[bool] = None) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: """ Process latent y using hyperprior and attention context. @@ -730,6 +734,7 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, Args: y: Main latent representation. z_hat: Decoded hyperprior. + z: Quantized/noised hyper-latent for computing z rate. training: Whether in training mode. Returns: @@ -758,10 +763,18 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, # Process through conditional Gaussian y_hat, y_likelihood = self.conditional(y, scale, mean, training=training) - # Compute total bits - # Using pre-computed reciprocal: multiplication is faster than division - bits_per_element = -y_likelihood * LOG_2_RECIPROCAL - total_bits = tf.reduce_sum(bits_per_element) + # Compute y bits from discretized likelihood + y_bits = tf.reduce_sum(-tf.math.log(y_likelihood) * LOG_2_RECIPROCAL) + + # Compute z bits if z is provided + z_bits = tf.constant(0.0) + if z is not None: + if not self.hyper_entropy.built: + self.hyper_entropy.build(z.shape) + z_likelihood = self.hyper_entropy.likelihood(z) + z_bits = tf.reduce_sum(-tf.math.log(z_likelihood) * LOG_2_RECIPROCAL) + + total_bits = y_bits + z_bits return y_hat, y_likelihood, total_bits @@ -805,7 +818,7 @@ def __init__(self, self.num_attention_layers = num_attention_layers from .channel_context import ChannelContext - from .entropy_model import ConditionalGaussian + from .entropy_model import ConditionalGaussian, PatchedGaussianConditional from .entropy_parameters import EntropyParameters # Hyperprior parameters @@ -847,6 +860,9 @@ def __init__(self, for i in range(num_channel_groups) ] + # Hyperprior entropy model (for z) + self.hyper_entropy = PatchedGaussianConditional() + self.channels_per_group = latent_channels // num_channel_groups self.scale_min = 0.01 @@ -856,6 +872,7 @@ def _split_params(self, params: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: return mean, scale def call(self, y: tf.Tensor, z_hat: tf.Tensor, + z: Optional[tf.Tensor] = None, training: Optional[bool] = None) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: """Process with all context types combined.""" # Get hyperprior parameters @@ -902,9 +919,18 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, y_hat = tf.concat(y_hat_parts, axis=-1) y_likelihood = tf.concat(likelihood_parts, axis=-1) - # Using pre-computed reciprocal: multiplication is faster than division - bits_per_element = -y_likelihood * LOG_2_RECIPROCAL - total_bits = tf.reduce_sum(bits_per_element) + # Compute y bits from discretized likelihood + y_bits = tf.reduce_sum(-tf.math.log(y_likelihood) * LOG_2_RECIPROCAL) + + # Compute z bits if z is provided + z_bits = tf.constant(0.0) + if z is not None: + if not self.hyper_entropy.built: + self.hyper_entropy.build(z.shape) + z_likelihood = self.hyper_entropy.likelihood(z) + z_bits = tf.reduce_sum(-tf.math.log(z_likelihood) * LOG_2_RECIPROCAL) + + total_bits = y_bits + z_bits return y_hat, y_likelihood, total_bits diff --git a/src/channel_context.py b/src/channel_context.py index a26391c5f..18fd3e743 100755 --- a/src/channel_context.py +++ b/src/channel_context.py @@ -231,7 +231,7 @@ def __init__(self, self.channels_per_group = latent_channels // num_groups # Import here to avoid circular dependency - from .entropy_model import ConditionalGaussian + from .entropy_model import ConditionalGaussian, PatchedGaussianConditional from .entropy_parameters import EntropyParameters # Hyperprior-based parameter prediction @@ -251,6 +251,9 @@ def __init__(self, for i in range(num_groups) ] + # Hyperprior entropy model (for z) + self.hyper_entropy = PatchedGaussianConditional() + self.scale_min = 0.01 def _fuse_params(self, @@ -269,6 +272,7 @@ def _fuse_params(self, return mean, scale def call(self, y: tf.Tensor, z_hat: tf.Tensor, + z: Optional[tf.Tensor] = None, training: Optional[bool] = None) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: """ Process latent y using hyperprior and channel-wise context. @@ -279,6 +283,7 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, Args: y: Main latent representation. z_hat: Decoded hyperprior. + z: Quantized/noised hyper-latent for computing z rate. training: Whether in training mode. Returns: @@ -341,10 +346,18 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, y_hat = tf.concat(y_hat_parts, axis=-1) y_likelihood = tf.concat(likelihood_parts, axis=-1) - # Compute total bits - # Using pre-computed reciprocal: multiplication is faster than division - bits_per_element = -y_likelihood * LOG_2_RECIPROCAL - total_bits = tf.reduce_sum(bits_per_element) + # Compute y bits from discretized likelihood + y_bits = tf.reduce_sum(-tf.math.log(y_likelihood) * LOG_2_RECIPROCAL) + + # Compute z bits if z is provided + z_bits = tf.constant(0.0) + if z is not None: + if not self.hyper_entropy.built: + self.hyper_entropy.build(z.shape) + z_likelihood = self.hyper_entropy.likelihood(z) + z_bits = tf.reduce_sum(-tf.math.log(z_likelihood) * LOG_2_RECIPROCAL) + + total_bits = y_bits + z_bits return y_hat, y_likelihood, total_bits diff --git a/src/context_model.py b/src/context_model.py index 54dca8262..10703fa07 100755 --- a/src/context_model.py +++ b/src/context_model.py @@ -265,7 +265,7 @@ def __init__(self, self.num_context_layers = num_context_layers # Import here to avoid circular dependency - from .entropy_model import ConditionalGaussian + from .entropy_model import ConditionalGaussian, PatchedGaussianConditional from .entropy_parameters import EntropyParameters # Hyperprior-based parameter prediction @@ -299,6 +299,9 @@ def __init__(self, # Conditional Gaussian for entropy coding self.conditional = ConditionalGaussian() + # Hyperprior entropy model (for z) + self.hyper_entropy = PatchedGaussianConditional() + self.scale_min = 0.01 def _split_params(self, params: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: @@ -308,6 +311,7 @@ def _split_params(self, params: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: return mean, scale def call(self, y: tf.Tensor, z_hat: tf.Tensor, + z: Optional[tf.Tensor] = None, training: Optional[bool] = None) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: """ Process latent y using hyperprior and autoregressive context. @@ -318,6 +322,7 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, Args: y: Main latent representation. z_hat: Decoded hyperprior. + z: Quantized/noised hyper-latent for computing z rate. training: Whether in training mode. Returns: @@ -342,10 +347,18 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, # Process through conditional Gaussian y_hat, y_likelihood = self.conditional(y, scale, mean, training=training) - # Compute total bits - # Using pre-computed reciprocal: multiplication is faster than division - bits_per_element = -y_likelihood * LOG_2_RECIPROCAL - total_bits = tf.reduce_sum(bits_per_element) + # Compute y bits from discretized likelihood + y_bits = tf.reduce_sum(-tf.math.log(y_likelihood) * LOG_2_RECIPROCAL) + + # Compute z bits if z is provided + z_bits = tf.constant(0.0) + if z is not None: + if not self.hyper_entropy.built: + self.hyper_entropy.build(z.shape) + z_likelihood = self.hyper_entropy.likelihood(z) + z_bits = tf.reduce_sum(-tf.math.log(z_likelihood) * LOG_2_RECIPROCAL) + + total_bits = y_bits + z_bits return y_hat, y_likelihood, total_bits diff --git a/src/entropy_model.py b/src/entropy_model.py index d79014928..e1d32fe36 100755 --- a/src/entropy_model.py +++ b/src/entropy_model.py @@ -1,9 +1,33 @@ from typing import Any, Dict, Optional, Tuple import tensorflow as tf -import tensorflow_probability as tfp -from .constants import LOG_2_RECIPROCAL +from .constants import EPSILON, LOG_2_RECIPROCAL + + +def _discretized_gaussian_likelihood(inputs, mean, scale): + """Compute probability mass for quantized inputs under Gaussian model. + + P(x) = CDF((x - mean + 0.5) / scale) - CDF((x - mean - 0.5) / scale) + + This is the correct discretized likelihood for entropy coding, replacing + the continuous log-PDF which does not integrate to 1 over integers. + + Args: + inputs: Input tensor (quantized or noise-added values). + mean: Mean of the Gaussian distribution. + scale: Scale (std dev) of the Gaussian distribution. + + Returns: + Per-element probability mass, floored at EPSILON to prevent log(0). + """ + scale = tf.maximum(scale, 1e-6) + centered = inputs - mean + upper = (centered + 0.5) / scale + lower = (centered - 0.5) / scale + likelihood = 0.5 * (1 + tf.math.erf(upper / tf.sqrt(2.0))) - \ + 0.5 * (1 + tf.math.erf(lower / tf.sqrt(2.0))) + return tf.maximum(likelihood, EPSILON) class PatchedGaussianConditional(tf.keras.layers.Layer): @@ -115,32 +139,33 @@ def quantize_scale(self, scale: tf.Tensor) -> tf.Tensor: return tf.reshape(quantized_flat, original_shape) def compress(self, inputs: tf.Tensor) -> tf.Tensor: - scale = self.quantize_scale(self.scale) + """Quantize inputs relative to learned mean.""" centered = inputs - self.mean - normalized = centered / scale - quantized = tf.round(normalized) + quantized = tf.round(centered) self._debug_tensors.update({ 'compress_inputs': inputs, - 'compress_scale': scale, 'compress_outputs': quantized }) return quantized def decompress(self, inputs: tf.Tensor) -> tf.Tensor: - scale = self.quantize_scale(self.scale) - denormalized = inputs * scale - decompressed = denormalized + self.mean + """Reconstruct from integer symbols.""" + decompressed = inputs + self.mean self._debug_tensors.update({ 'decompress_inputs': inputs, - 'decompress_scale': scale, 'decompress_outputs': decompressed }) return decompressed + def likelihood(self, inputs: tf.Tensor) -> tf.Tensor: + """Compute discretized Gaussian likelihood for inputs.""" + scale = tf.maximum(tf.abs(self.scale), 1e-6) + return _discretized_gaussian_likelihood(inputs, self.mean, scale) + def call(self, inputs: tf.Tensor, training: Optional[bool] = None) -> tf.Tensor: self._debug_tensors['inputs'] = inputs compressed = self.compress(inputs) @@ -172,10 +197,7 @@ def call(self, inputs, training=None): self.gaussian.build(inputs.shape) compressed = self.gaussian.compress(inputs) - likelihood = tfp.distributions.Normal( - loc=self.gaussian.mean, - scale=self.gaussian.scale - ).log_prob(inputs) + likelihood = self.gaussian.likelihood(inputs) return compressed, likelihood @@ -208,21 +230,11 @@ def _add_noise(self, inputs: tf.Tensor, training: bool) -> tf.Tensor: return tf.round(inputs) def compress(self, inputs: tf.Tensor, scale: tf.Tensor, mean: tf.Tensor) -> tf.Tensor: - """ - Compress inputs using provided scale and mean. + """Quantize inputs relative to the learned mean. - Args: - inputs: Input tensor to compress. - scale: Scale parameter for the Gaussian distribution. - mean: Mean parameter for the Gaussian distribution. - - Returns: - Quantized (compressed) tensor. + The scale parameter affects entropy coding probability, not + the quantization grid. This is correct per the standard formulation. """ - # Ensure scale is positive - scale = tf.maximum(scale, self.scale_min) - - # Center and normalize centered = inputs - mean quantized = tf.round(centered) @@ -236,18 +248,7 @@ def compress(self, inputs: tf.Tensor, scale: tf.Tensor, mean: tf.Tensor) -> tf.T return quantized def decompress(self, inputs: tf.Tensor, scale: tf.Tensor, mean: tf.Tensor) -> tf.Tensor: - """ - Decompress inputs using provided scale and mean. - - Args: - inputs: Quantized tensor to decompress. - scale: Scale parameter for the Gaussian distribution. - mean: Mean parameter for the Gaussian distribution. - - Returns: - Decompressed (reconstructed) tensor. - """ - # Add back the mean + """Reconstruct from integer symbols.""" decompressed = inputs + mean self._debug_tensors.update({ @@ -272,7 +273,7 @@ def call(self, inputs: tf.Tensor, scale: tf.Tensor, mean: tf.Tensor, Returns: Tuple of (outputs, likelihood) where outputs are the reconstructed - values and likelihood is the log-probability under the distribution. + values and likelihood is the discretized probability mass. """ self._debug_tensors['inputs'] = inputs @@ -288,9 +289,8 @@ def call(self, inputs: tf.Tensor, scale: tf.Tensor, mean: tf.Tensor, # Reconstruct outputs = quantized + mean - # Compute likelihood using the Gaussian distribution - distribution = tfp.distributions.Normal(loc=mean, scale=scale) - likelihood = distribution.log_prob(inputs) + # Compute discretized likelihood on the output values + likelihood = _discretized_gaussian_likelihood(outputs, mean, scale) self._debug_tensors['outputs'] = outputs self._debug_tensors['likelihood'] = likelihood @@ -350,6 +350,7 @@ def __init__(self, self.hyper_entropy = PatchedGaussianConditional() def call(self, y: tf.Tensor, z_hat: tf.Tensor, + z: Optional[tf.Tensor] = None, training: Optional[bool] = None) -> Tuple[tf.Tensor, tf.Tensor, tf.Tensor]: """ Process latent y using hyperprior z_hat. @@ -357,13 +358,14 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, Args: y: Main latent representation. z_hat: Decoded hyperprior (typically from hyper_synthesis(z)). + z: Quantized/noised hyper-latent for computing z rate. training: Whether in training mode. Returns: Tuple of (y_hat, y_likelihood, total_bits) where: - y_hat: Reconstructed latent - - y_likelihood: Log-probability of y under the predicted distribution - - total_bits: Estimated total bits for encoding + - y_likelihood: Discretized probability mass of y + - total_bits: Estimated total bits (y_bits + z_bits) """ # Predict distribution parameters from hyperprior mean, scale = self.entropy_parameters(z_hat) @@ -371,10 +373,18 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, # Process through conditional Gaussian y_hat, y_likelihood = self.conditional(y, scale, mean, training=training) - # Estimate bits (negative log-likelihood converted to bits) - # Using pre-computed reciprocal: multiplication is faster than division - bits_per_element = -y_likelihood * LOG_2_RECIPROCAL - total_bits = tf.reduce_sum(bits_per_element) + # Compute y bits from discretized likelihood + y_bits = tf.reduce_sum(-tf.math.log(y_likelihood) * LOG_2_RECIPROCAL) + + # Compute z bits if z is provided + z_bits = tf.constant(0.0) + if z is not None: + if not self.hyper_entropy.built: + self.hyper_entropy.build(z.shape) + z_likelihood = self.hyper_entropy.likelihood(z) + z_bits = tf.reduce_sum(-tf.math.log(z_likelihood) * LOG_2_RECIPROCAL) + + total_bits = y_bits + z_bits return y_hat, y_likelihood, total_bits diff --git a/src/model_transforms.py b/src/model_transforms.py index 7992ce359..dc678fd18 100755 --- a/src/model_transforms.py +++ b/src/model_transforms.py @@ -209,9 +209,9 @@ def __init__(self, config: TransformConfig, **kwargs): self.analysis = AnalysisTransform(config) self.synthesis = SynthesisTransform(config) - # Final projection: map from synthesis channels back to 1-channel occupancy + # Final projection: outputs raw logits (no activation) for stable loss self.output_projection = tf.keras.layers.Conv3D( - filters=1, kernel_size=(1, 1, 1), activation='sigmoid', padding='same' + filters=1, kernel_size=(1, 1, 1), padding='same' ) # Hyperprior @@ -238,7 +238,7 @@ def call(self, inputs, training=None): # Synthesis y_hat = self.hyper_synthesis(z) - x_hat = self.output_projection(self.synthesis(y)) + x_hat = tf.sigmoid(self.output_projection(self.synthesis(y))) return x_hat, y, y_hat, z @@ -294,9 +294,9 @@ def __init__(self, self.analysis = AnalysisTransform(config) self.synthesis = SynthesisTransform(config) - # Final projection: map from synthesis channels back to 1-channel occupancy + # Final projection: outputs raw logits (no activation) for stable loss self.output_projection = tf.keras.layers.Conv3D( - filters=1, kernel_size=(1, 1, 1), activation='sigmoid', padding='same' + filters=1, kernel_size=(1, 1, 1), padding='same' ) # Hyperprior transforms @@ -374,7 +374,7 @@ def call(self, inputs, training=None): Returns: Tuple of (x_hat, y, y_hat, z, rate_info) where: - - x_hat: Reconstructed input + - x_hat: Reconstructed input (sigmoid of logits) - y: Latent representation - y_hat: Quantized latent (or reconstructed) - z: Hyper-latent @@ -395,29 +395,48 @@ def call(self, inputs, training=None): # Entropy model processing if self.entropy_model_type == 'gaussian': - # Original behavior + # Original behavior with discretized likelihood if training: y_noisy = y + tf.random.uniform(tf.shape(y), -0.5, 0.5) else: y_noisy = tf.round(y) compressed, likelihood = self.entropy_module(y_noisy) y_hat = y_noisy - # Using pre-computed reciprocal: multiplication is faster than division - total_bits = -tf.reduce_sum(likelihood) * LOG_2_RECIPROCAL + y_bits = tf.reduce_sum(-tf.math.log(likelihood) * LOG_2_RECIPROCAL) else: - # Advanced entropy models - y_hat, likelihood, total_bits = self.entropy_module( - y, z_hat, training=training + # Advanced entropy models — pass z for hyper-latent rate + y_hat, likelihood, y_bits = self.entropy_module( + y, z_hat, z=z, training=training ) - # Synthesis - x_hat = self.output_projection(self.synthesis(y_hat)) + # Compute z bits under learned prior + if self.entropy_model_type == 'gaussian': + # For gaussian, compute z bits directly + if not hasattr(self, '_z_entropy') or not self._z_entropy.built: + from .entropy_model import PatchedGaussianConditional + self._z_entropy = PatchedGaussianConditional() + self._z_entropy.build(z.shape) + z_likelihood = self._z_entropy.likelihood(z) + z_bits = tf.reduce_sum(-tf.math.log(z_likelihood) * LOG_2_RECIPROCAL) + else: + # z_bits already included in y_bits (via MeanScaleHyperprior) + z_bits = tf.constant(0.0) + + total_bits = y_bits + z_bits + + # Synthesis — apply sigmoid to logits for output + logits = self.output_projection(self.synthesis(y_hat)) + x_hat = tf.sigmoid(logits) # Rate information + num_voxels = tf.cast(tf.reduce_prod(tf.shape(inputs)[1:4]), tf.float32) rate_info = { 'likelihood': likelihood, 'total_bits': total_bits, - 'bpp': total_bits / tf.cast(tf.reduce_prod(tf.shape(inputs)[1:4]), tf.float32) + 'y_bits': y_bits, + 'z_bits': z_bits, + 'bpp': total_bits / num_voxels, + 'logits': logits, } return x_hat, y, y_hat, z, rate_info diff --git a/src/training_pipeline.py b/src/training_pipeline.py index 7ac00c571..781219375 100755 --- a/src/training_pipeline.py +++ b/src/training_pipeline.py @@ -32,6 +32,9 @@ def __init__(self, config_path: str): self.model = DeepCompressModel(model_config) self.entropy_model = EntropyModel() + # Rate-distortion trade-off weight + self.lambda_rd = self.config['training'].get('lambda_rd', 0.01) + # Initialize optimizers lrs = self.config['training']['learning_rates'] self.optimizers = { @@ -39,6 +42,9 @@ def __init__(self, config_path: str): 'entropy': tf.keras.optimizers.Adam(learning_rate=lrs['entropy']), } + # Gradient clipping for training stability + self.grad_clip_norm = self.config['training'].get('grad_clip_norm', 1.0) + # Checkpoint directory self.checkpoint_dir = Path(self.config['training']['checkpoint_dir']) self.checkpoint_dir.mkdir(parents=True, exist_ok=True) @@ -49,40 +55,47 @@ def __init__(self, config_path: str): self.summary_writer = tf.summary.create_file_writer(str(log_dir)) def _train_step(self, batch: tf.Tensor, training: bool = True) -> Dict[str, tf.Tensor]: - """Run a single training step.""" - with tf.GradientTape(persistent=True) as tape: + """Run a single training step with joint rate-distortion optimization.""" + with tf.GradientTape() as tape: inputs = batch[..., tf.newaxis] if len(batch.shape) == 4 else batch x_hat, y, y_hat, z = self.model(inputs, training=training) - # Compute focal loss on reconstruction + # Compute focal loss on reconstruction (distortion term) focal_loss = self.compute_focal_loss( batch[..., tf.newaxis] if len(batch.shape) == 4 else batch, x_hat, ) - # Compute entropy loss - # EntropyModel returns log-probabilities, so use them directly - _, log_likelihood = self.entropy_model(y, training=training) - entropy_loss = -tf.reduce_mean(log_likelihood) + # Compute entropy loss (rate term) + # EntropyModel returns discretized probability mass + _, likelihood = self.entropy_model(y, training=training) + entropy_loss = -tf.reduce_mean(tf.math.log(likelihood)) - total_loss = focal_loss + entropy_loss + # Joint rate-distortion loss + total_loss = focal_loss + self.lambda_rd * entropy_loss if training: - # Update reconstruction model - model_grads = tape.gradient(focal_loss, self.model.trainable_variables) + # Joint gradient computation over all trainable variables + all_vars = self.model.trainable_variables + self.entropy_model.trainable_variables + grads = tape.gradient(total_loss, all_vars) + + # Clip gradients for stability + grads, _ = tf.clip_by_global_norm(grads, self.grad_clip_norm) + + # Split gradients and apply to respective optimizers + model_var_count = len(self.model.trainable_variables) + model_grads = grads[:model_var_count] + entropy_grads = grads[model_var_count:] + self.optimizers['reconstruction'].apply_gradients( zip(model_grads, self.model.trainable_variables) ) - # Update entropy model - entropy_grads = tape.gradient(entropy_loss, self.entropy_model.trainable_variables) if entropy_grads and any(g is not None for g in entropy_grads): self.optimizers['entropy'].apply_gradients( zip(entropy_grads, self.entropy_model.trainable_variables) ) - del tape - return { 'focal_loss': focal_loss, 'entropy_loss': entropy_loss, diff --git a/tests/test_attention_context.py b/tests/test_attention_context.py old mode 100644 new mode 100755 index a1e001bb6..dd5dddb5f --- a/tests/test_attention_context.py +++ b/tests/test_attention_context.py @@ -191,13 +191,12 @@ def test_attention_entropy_improvement(self): # Basic sanity checks for untrained models avg_likelihood_attn = tf.reduce_mean(likelihood_attn) - # Likelihood should be finite and reasonable for Gaussian + # Likelihood should be finite and reasonable self.assertFalse(tf.math.is_nan(avg_likelihood_attn)) self.assertFalse(tf.math.is_inf(avg_likelihood_attn)) - # Log-likelihood for Gaussian should be negative (probability < 1) - self.assertLess(avg_likelihood_attn, 0.0) - # But not catastrophically negative (which would indicate numerical issues) - self.assertGreater(avg_likelihood_attn, -100.0) + # Discretized probability mass should be in (0, 1] + self.assertGreater(avg_likelihood_attn, 0.0) + self.assertLessEqual(avg_likelihood_attn, 1.0) def test_attention_entropy_gradient_flow(self): """Gradients flow through the entire model.""" From 07e997d0d93668e179569d1a36aa97ba89666601 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Fri, 27 Feb 2026 19:23:32 -0800 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20architecture=20alignment=20=E2=80=94?= =?UTF-8?q?=20GDN=20rewrite,=20synthesis=20fix,=20encode/decode=20consiste?= =?UTF-8?q?ncy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of adversarial code review fixes: - Replace CENICGDN with standard GDN (Balle 2016) supporting inverse (IGDN) for synthesis - Fix SynthesisTransform to always use Conv3DTranspose (was incorrectly downsampling with SpatialSeparableConv) - Fix DeepCompressModel.call() to pass quantized y_hat to synthesis, apply sigmoid to output - Fix DeepCompressModelV2 compress/decompress for all 6 entropy model types - Fix HybridAttentionEntropyModel: proper attention-to-params projection (replaces concat hack) - Fix ChannelContextEntropyModel inference path for first channel group - Fix quick_benchmark: proper decode timing, Shannon entropy estimate, V1 unpack - Fix training_pipeline: empty validation guard, V1 unpack - Fix evaluation_pipeline: V1 unpack - 238 tests pass, ruff clean Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 +- src/attention_context.py | 18 ++++- src/channel_context.py | 19 ++--- src/evaluation_pipeline.py | 2 +- src/model_transforms.py | 126 ++++++++++++++++++++------------- src/quick_benchmark.py | 42 ++++++----- src/training_pipeline.py | 5 +- tests/test_model_transforms.py | 37 ++++++---- 8 files changed, 155 insertions(+), 98 deletions(-) mode change 100644 => 100755 CLAUDE.md mode change 100644 => 100755 tests/test_model_transforms.py diff --git a/CLAUDE.md b/CLAUDE.md old mode 100644 new mode 100755 index 2f7cab7aa..f638e0645 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ else: **Rules:** - Model-level `call()` methods and any layer that branches on training mode **must** accept `training=None` and pass it through to sub-layers that need it. -- Leaf layers that do not use `training` internally (e.g., `CENICGDN`, `SpatialSeparableConv`, `MaskedConv3D`, `SliceTransform`) currently omit it from their signatures. This is the established convention — do not add `training` to these unless they gain training-dependent behavior. +- Leaf layers that do not use `training` internally (e.g., `GDN`, `SpatialSeparableConv`, `MaskedConv3D`, `SliceTransform`) currently omit it from their signatures. This is the established convention — do not add `training` to these unless they gain training-dependent behavior. - **Never remove the training conditional** from methods that have it. Never replace noise injection with unconditional `tf.round()`. - When adding new layers: include `training=None` if the layer has any training-dependent behavior. Omit it for pure computation layers. @@ -77,7 +77,7 @@ else: All model tensors are 5D: `(batch, depth, height, width, channels)` — channels-last. - Convolutions are `Conv3D`, never `Conv2D`. Kernels are 3-tuples: `(3, 3, 3)`. -- Channel axis is axis 4 (see `CENICGDN.call()` which does `tf.tensordot(norm, self.gamma, [[4], [0]])`). +- Channel axis is axis 4 (see `GDN.call()` which uses `tf.einsum('...c,cd->...d', ...)`). - Input voxel grids have 1 channel: shape `(B, D, H, W, 1)`. - Do not flatten spatial dimensions to use 2D ops. The 3D structure is load-bearing. diff --git a/src/attention_context.py b/src/attention_context.py index c80fbb6e8..cb5702211 100755 --- a/src/attention_context.py +++ b/src/attention_context.py @@ -832,6 +832,8 @@ def __init__(self, num_groups=num_channel_groups ) + self.channels_per_group = latent_channels // num_channel_groups + # Attention context (applied per channel group) self.attention_contexts = [ BidirectionalMaskTransformer( @@ -843,6 +845,17 @@ def __init__(self, for i in range(num_channel_groups) ] + # Attention output to parameters (replaces concat hack) + self.attention_to_params = [ + tf.keras.layers.Conv3D( + filters=self.channels_per_group * 2, # mean and scale + kernel_size=1, + padding='same', + name=f'attn_to_params_{i}' + ) + for i in range(num_channel_groups) + ] + # Parameter fusion per group self.param_fusions = [ tf.keras.layers.Conv3D( @@ -863,7 +876,6 @@ def __init__(self, # Hyperprior entropy model (for z) self.hyper_entropy = PatchedGaussianConditional() - self.channels_per_group = latent_channels // num_channel_groups self.scale_min = 0.01 def _split_params(self, params: tf.Tensor) -> Tuple[tf.Tensor, tf.Tensor]: @@ -900,9 +912,9 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, combined_mean = hyper_mean_slice + context_mean combined_scale = hyper_scale_slice * (1.0 + context_scale) - # Add attention refinement + # Project attention features to mean/scale parameters hyper_params = tf.concat([combined_mean, combined_scale], axis=-1) - attn_params = tf.concat([attn_features, attn_features], axis=-1) # Use features for both + attn_params = self.attention_to_params[i](attn_features) combined = tf.concat([hyper_params, attn_params], axis=-1) fused_params = self.param_fusions[i](combined) diff --git a/src/channel_context.py b/src/channel_context.py index 18fd3e743..7fcb06c4e 100755 --- a/src/channel_context.py +++ b/src/channel_context.py @@ -310,22 +310,17 @@ def call(self, y: tf.Tensor, z_hat: tf.Tensor, # Get context params (using y for training, y_hat for inference) # Note: Use .call() to pass non-tensor group_idx as keyword argument - if training: + if i == 0: + # First group: no context available, channel_context returns zeros + context_mean, context_scale = self.channel_context.call(y, group_idx=0) + elif training: # Training: use ground truth y for context (teacher forcing) context_mean, context_scale = self.channel_context.call(y, group_idx=i) else: - # Inference: use only already decoded groups (no padding needed!) - # The channel_context only uses channels 0..group_idx-1, so we - # only need to concatenate the decoded parts without padding. - # This optimization reduces memory allocations by ~25%. - if i == 0: - # First group has no context - channel_context handles this - y_hat_partial = y_hat_parts[0] if y_hat_parts else None - else: - # Concatenate only the decoded parts (no zero padding) - y_hat_partial = tf.concat(y_hat_parts, axis=-1) + # Inference: use already decoded groups for context + y_hat_partial = tf.concat(y_hat_parts, axis=-1) context_mean, context_scale = self.channel_context.call( - y_hat_partial if y_hat_partial is not None else y, group_idx=i + y_hat_partial, group_idx=i ) # Fuse parameters diff --git a/src/evaluation_pipeline.py b/src/evaluation_pipeline.py index 90f645cf2..f74623f89 100755 --- a/src/evaluation_pipeline.py +++ b/src/evaluation_pipeline.py @@ -66,7 +66,7 @@ def _evaluate_single(self, point_cloud) -> Dict[str, float]: """Evaluate model on single point cloud.""" # Forward pass through model - x_hat, y, y_hat, z = self.model(point_cloud, training=False) + x_hat, y, z_hat, z_noisy = self.model(point_cloud, training=False) # Compute metrics results = {} diff --git a/src/model_transforms.py b/src/model_transforms.py index dc678fd18..d79873bcd 100755 --- a/src/model_transforms.py +++ b/src/model_transforms.py @@ -3,7 +3,7 @@ import tensorflow as tf -from .constants import EPSILON, LOG_2_RECIPROCAL +from .constants import LOG_2_RECIPROCAL @dataclass @@ -16,40 +16,57 @@ class TransformConfig: conv_type: str = 'separable' -class CENICGDN(tf.keras.layers.Layer): - """CENIC-GDN activation function implementation.""" +class GDN(tf.keras.layers.Layer): + """Generalized Divisive Normalization (Balle et al., 2016). - def __init__(self, channels: int, **kwargs): + y_i = x_i / sqrt(beta_i + sum_j(gamma_ij * x_j^2)) + + When inverse=True, computes IGDN (inverse GDN) for the synthesis path: + y_i = x_i * sqrt(beta_i + sum_j(gamma_ij * x_j^2)) + + Args: + inverse: If True, compute IGDN instead of GDN. + """ + + def __init__(self, inverse: bool = False, **kwargs): super().__init__(**kwargs) - self.channels = channels + self.inverse = inverse def build(self, input_shape): + num_channels = input_shape[-1] self.beta = self.add_weight( name='beta', - shape=[self.channels], - initializer='ones', + shape=[num_channels], + initializer=tf.initializers.Ones(), + constraint=tf.keras.constraints.NonNeg(), trainable=True ) self.gamma = self.add_weight( name='gamma', - shape=[self.channels, self.channels], - initializer='zeros', + shape=[num_channels, num_channels], + initializer=tf.initializers.Identity(gain=0.1), trainable=True ) super().build(input_shape) - def call(self, x): - # Note: XLA compilation removed as it breaks gradient flow when layers are composed - norm = tf.abs(x) - # Use axis 4 (channel dimension) for 5D tensors (batch, D, H, W, C) - norm = tf.tensordot(norm, self.gamma, [[4], [0]]) - norm = tf.nn.bias_add(norm, self.beta) - return x / tf.maximum(norm, EPSILON) + def call(self, inputs): + # Ensure gamma is non-negative and symmetric + gamma = tf.nn.relu(self.gamma) + gamma = (gamma + tf.transpose(gamma)) / 2.0 + + # Compute normalization: beta_i + sum_j(gamma_ij * x_j^2) + norm = tf.einsum('...c,cd->...d', inputs ** 2, gamma) + norm = tf.sqrt(self.beta + norm) + + if self.inverse: + return inputs * norm # IGDN + else: + return inputs / norm # GDN def get_config(self): config = super().get_config() config.update({ - 'channels': self.channels + 'inverse': self.inverse }) return config @@ -126,8 +143,8 @@ def __init__(self, config: TransformConfig, **kwargs): self.conv_layers.append(conv) - if config.activation == 'cenic_gdn': - self.conv_layers.append(CENICGDN(current_filters)) + if config.activation in ('gdn', 'cenic_gdn'): + self.conv_layers.append(GDN(inverse=False)) else: self.conv_layers.append(tf.keras.layers.ReLU()) @@ -160,24 +177,19 @@ def __init__(self, config: TransformConfig, **kwargs): current_filters = config.filters * 4 # Start with max channels for i in range(3): # Three blocks as per paper - if config.conv_type == 'separable': - conv = SpatialSeparableConv( - filters=current_filters, - kernel_size=config.kernel_size, - strides=config.strides - ) - else: - conv = tf.keras.layers.Conv3DTranspose( - filters=current_filters, - kernel_size=config.kernel_size, - strides=config.strides, - padding='same' - ) + # Synthesis always needs Conv3DTranspose for upsampling + # SpatialSeparableConv only supports forward (downsampling) convolution + conv = tf.keras.layers.Conv3DTranspose( + filters=current_filters, + kernel_size=config.kernel_size, + strides=config.strides, + padding='same' + ) self.conv_layers.append(conv) - if config.activation == 'cenic_gdn': - self.conv_layers.append(CENICGDN(current_filters)) + if config.activation in ('gdn', 'cenic_gdn'): + self.conv_layers.append(GDN(inverse=True)) # IGDN for synthesis else: self.conv_layers.append(tf.keras.layers.ReLU()) @@ -231,16 +243,19 @@ def call(self, inputs, training=None): y = self.analysis(inputs) z = self.hyper_analysis(y) - # Add uniform noise for training + # Add uniform noise for training, hard rounding for inference if training: - y = y + tf.random.uniform(tf.shape(y), -0.5, 0.5) - z = z + tf.random.uniform(tf.shape(z), -0.5, 0.5) + y_hat = y + tf.random.uniform(tf.shape(y), -0.5, 0.5) + z_noisy = z + tf.random.uniform(tf.shape(z), -0.5, 0.5) + else: + y_hat = tf.round(y) + z_noisy = tf.round(z) - # Synthesis - y_hat = self.hyper_synthesis(z) - x_hat = tf.sigmoid(self.output_projection(self.synthesis(y))) + # Synthesis — decode from quantized latent (y_hat), not raw encoder output + z_hat = self.hyper_synthesis(z_noisy) + x_hat = tf.sigmoid(self.output_projection(self.synthesis(y_hat))) - return x_hat, y, y_hat, z + return x_hat, y, z_hat, z_noisy def get_config(self): config = super().get_config() @@ -311,10 +326,10 @@ def __init__(self, activation='relu' )) - # Compute channel dimensions - # Analysis progressively doubles channels 3 times - self.latent_channels = config.filters * 4 # After 3 blocks of doubling - self.hyper_channels = (config.filters // 2) * 4 + # Compute latent channel dimensions dynamically from analysis transforms + # Analysis doubles channels each block: filters -> 2*filters -> 4*filters + self.latent_channels = config.filters * (2 ** 2) # After 3 conv blocks with doubling + self.hyper_channels = (config.filters // 2) * (2 ** 2) # Create entropy model based on selection self._create_entropy_model() @@ -449,7 +464,7 @@ def compress(self, inputs): inputs: Input voxel grid. Returns: - Tuple of (compressed_data, metadata) for storage/transmission. + Dict with compressed symbols and metadata. """ # Analysis y = self.analysis(inputs) @@ -463,12 +478,17 @@ def compress(self, inputs): y_quantized = tf.round(y) compressed_y = y_quantized side_info = {} - elif self.entropy_model_type in ['hyperprior', 'context']: + elif self.entropy_model_type in ('hyperprior', 'context'): compressed_y, side_info = self.entropy_module.compress(y, z_hat) elif self.entropy_model_type == 'channel': compressed_y, side_info = self.entropy_module.compress(y, z_hat) + elif self.entropy_model_type in ('attention', 'hybrid'): + # Attention/hybrid: use hyperprior mean for centered quantization + # TODO: implement actual arithmetic coding for attention/hybrid models + mean, scale = self.entropy_module.entropy_parameters(z_hat) + compressed_y = tf.round(y - mean) + side_info = {'mean': mean, 'scale': scale} else: - # For attention models, use basic quantization compressed_y = tf.round(y) side_info = {} @@ -486,7 +506,7 @@ def decompress(self, compressed_data): compressed_data: Dict with compressed data from compress(). Returns: - Reconstructed voxel grid. + Reconstructed voxel grid (sigmoid-applied probabilities). """ y_compressed = compressed_data['y'] z = compressed_data['z'] @@ -500,11 +520,15 @@ def decompress(self, compressed_data): y_hat = self.entropy_module.decompress(y_compressed, z_hat) elif self.entropy_model_type == 'channel': y_hat = self.entropy_module.decode_parallel(z_hat, y_compressed) + elif self.entropy_model_type in ('attention', 'hybrid'): + # TODO: implement actual arithmetic coding for attention/hybrid models + mean, _ = self.entropy_module.entropy_parameters(z_hat) + y_hat = y_compressed + mean else: y_hat = y_compressed - # Synthesis - x_hat = self.output_projection(self.synthesis(y_hat)) + # Synthesis — apply sigmoid to logits + x_hat = tf.sigmoid(self.output_projection(self.synthesis(y_hat))) return x_hat diff --git a/src/quick_benchmark.py b/src/quick_benchmark.py index b58d657ad..5db26e5b6 100755 --- a/src/quick_benchmark.py +++ b/src/quick_benchmark.py @@ -165,26 +165,35 @@ def benchmark_model( decode_times = [] for _ in range(timed_runs): - # Encode - start = time.perf_counter() - outputs = model(input_tensor, training=False) - encode_time = time.perf_counter() - start - encode_times.append(encode_time) + if isinstance(model, DeepCompressModelV2): + # V2: measure encode and decode separately + start = time.perf_counter() + compressed = model.compress(input_tensor) + encode_time = time.perf_counter() - start + + start = time.perf_counter() + _ = model.decompress(compressed) + decode_time = time.perf_counter() - start + else: + # V1: full forward pass (no separate encode/decode) + start = time.perf_counter() + _ = model(input_tensor, training=False) + encode_time = time.perf_counter() - start + decode_time = 0 - # For decode timing, we'd need separate encode/decode methods - # For now, we include it in encode time - decode_times.append(0) + encode_times.append(encode_time) + decode_times.append(decode_time) # Average times avg_encode_ms = np.mean(encode_times) * 1000 avg_decode_ms = np.mean(decode_times) * 1000 # Get final outputs for metrics - # V1 returns (x_hat, y, y_hat, z) + # V1 returns (x_hat, y, z_hat, z_noisy) # V2 returns (x_hat, y, y_hat, z, rate_info) outputs = model(input_tensor, training=False) if len(outputs) == 4: - x_hat, y, y_hat, z = outputs + x_hat, y, z_hat, z_noisy = outputs rate_info = None else: x_hat, y, y_hat, z, rate_info = outputs @@ -202,12 +211,13 @@ def benchmark_model( # Use actual bits from entropy model estimated_bits = float(rate_info['total_bits']) else: - # Approximate - actual bits depend on entropy coding - # We use the entropy of the quantized latent - y_quantized = tf.round(y_hat) - unique_values = len(np.unique(y_quantized.numpy())) - entropy_estimate = np.log2(max(unique_values, 1)) - estimated_bits = latent_elements * entropy_estimate + # Approximate using Shannon entropy of quantized latent + y_quantized = tf.round(y) + y_flat = y_quantized.numpy().flatten() + _, counts = np.unique(y_flat, return_counts=True) + probs = counts / counts.sum() + entropy_per_symbol = -np.sum(probs * np.log2(probs)) + estimated_bits = latent_elements * entropy_per_symbol bits_per_voxel = estimated_bits / input_elements diff --git a/src/training_pipeline.py b/src/training_pipeline.py index 781219375..852132468 100755 --- a/src/training_pipeline.py +++ b/src/training_pipeline.py @@ -58,7 +58,7 @@ def _train_step(self, batch: tf.Tensor, training: bool = True) -> Dict[str, tf.T """Run a single training step with joint rate-distortion optimization.""" with tf.GradientTape() as tape: inputs = batch[..., tf.newaxis] if len(batch.shape) == 4 else batch - x_hat, y, y_hat, z = self.model(inputs, training=training) + x_hat, y, z_hat, z_noisy = self.model(inputs, training=training) # Compute focal loss on reconstruction (distortion term) focal_loss = self.compute_focal_loss( @@ -154,6 +154,9 @@ def _validate(self, val_dataset: tf.data.Dataset) -> Dict[str, float]: losses = self._train_step(batch, training=False) val_losses.append({k: v.numpy() for k, v in losses.items()}) + if not val_losses: + return {'focal_loss': 0.0, 'entropy_loss': 0.0, 'total_loss': float('inf')} + avg_losses = {} for metric in val_losses[0].keys(): avg_losses[metric] = float(tf.reduce_mean([x[metric] for x in val_losses])) diff --git a/tests/test_model_transforms.py b/tests/test_model_transforms.py old mode 100644 new mode 100755 index 03c8c2bf5..6540b7569 --- a/tests/test_model_transforms.py +++ b/tests/test_model_transforms.py @@ -8,7 +8,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) from model_transforms import ( - CENICGDN, + GDN, AnalysisTransform, DeepCompressModel, DeepCompressModelV2, @@ -33,10 +33,9 @@ def setup(self): self.resolution = 64 self.input_shape = (self.batch_size, self.resolution, self.resolution, self.resolution, 1) - def test_cenic_gdn(self): - channels = 64 - activation = CENICGDN(channels) - input_tensor = tf.random.uniform((2, 32, 32, 32, channels)) + def test_gdn(self): + activation = GDN(inverse=False) + input_tensor = tf.random.uniform((2, 32, 32, 32, 64)) output = activation(input_tensor) self.assertEqual(output.shape, input_tensor.shape) @@ -49,6 +48,13 @@ def test_cenic_gdn(self): # Check that gradients are non-zero self.assertGreater(tf.reduce_sum(tf.abs(gradients[0])), 0) + def test_igdn(self): + """IGDN (inverse GDN) used in synthesis path.""" + activation = GDN(inverse=True) + input_tensor = tf.random.uniform((2, 8, 8, 8, 64)) + output = activation(input_tensor) + self.assertEqual(output.shape, input_tensor.shape) + def test_spatial_separable_conv(self): conv = SpatialSeparableConv(filters=64, kernel_size=(3, 3, 3), strides=(1, 1, 1)) input_tensor = tf.random.uniform((2, 32, 32, 32, 32)) @@ -64,17 +70,21 @@ def test_analysis_transform(self): output = analysis(input_tensor) self.assertEqual(len(output.shape), 5) # 5D tensor (B, D, H, W, C) self.assertGreater(output.shape[-1], input_tensor.shape[-1]) - # Check that CENICGDN layers are present in the conv_layers list - has_gdn = any(isinstance(layer, CENICGDN) for layer in analysis.conv_layers) + # Check that GDN layers are present in the conv_layers list + has_gdn = any(isinstance(layer, GDN) for layer in analysis.conv_layers) self.assertTrue(has_gdn) def test_synthesis_transform(self): synthesis = SynthesisTransform(self.config) - input_tensor = tf.random.uniform((2, 32, 32, 32, 256)) # Match analysis output channels + # Use small spatial dims since Conv3DTranspose upsamples with strides=(2,2,2): + # 4 -> 8 -> 16 -> 32 + input_tensor = tf.random.uniform((1, 4, 4, 4, 256)) output = synthesis(input_tensor) # Synthesis reduces channels progressively self.assertEqual(len(output.shape), 5) # 5D tensor self.assertLessEqual(output.shape[-1], input_tensor.shape[-1]) + # Conv3DTranspose upsamples spatial dims + self.assertGreaterEqual(output.shape[1], input_tensor.shape[1]) def test_deep_compress_model(self): # Use strides=(1,1,1) to avoid spatial dimension changes @@ -87,16 +97,19 @@ def test_deep_compress_model(self): ) model = DeepCompressModel(config_no_stride) input_tensor = create_mock_voxel_grid(16, 1) # Smaller for faster test - # Model returns (x_hat, y, y_hat, z) tuple + # Model returns (x_hat, y, z_hat, z_noisy) tuple output = model(input_tensor, training=True) self.assertIsInstance(output, tuple) self.assertEqual(len(output), 4) - x_hat, y, y_hat, z = output + x_hat, y, z_hat, z_noisy = output # Check that output tensors have correct shapes self.assertEqual(x_hat.shape[:-1], input_tensor.shape[:-1]) self.assertEqual(len(y.shape), 5) - self.assertEqual(len(y_hat.shape), 5) - self.assertEqual(len(z.shape), 5) + self.assertEqual(len(z_hat.shape), 5) + self.assertEqual(len(z_noisy.shape), 5) + # x_hat should be sigmoid-activated (values in [0, 1]) + self.assertAllGreaterEqual(x_hat, 0.0) + self.assertAllLessEqual(x_hat, 1.0) def test_gradient_flow(self): model = DeepCompressModel(self.config) From 7e9bac2ab85377433ea7ec47ac91d816efc701c8 Mon Sep 17 00:00:00 2001 From: PMCLSF Date: Fri, 27 Feb 2026 19:55:54 -0800 Subject: [PATCH 7/7] =?UTF-8?q?test:=20add=20Phase=205=20validation=20test?= =?UTF-8?q?s=20=E2=80=94=20entropy=20correctness,=20causality,=20roundtrip?= =?UTF-8?q?,=20numerical=20stability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add 6 new test files with 141 tests validating mathematical correctness: - test_entropy_correctness: PMF validity, rate computation, quantization behavior - test_causality: MaskedConv3D causal masks, raster-scan ordering, AutoregressiveContext - test_roundtrip: V1/V2 compress/decompress shape/bounds/determinism, gradient flow - test_numerical: GDN/IGDN stability, entropy model extreme values, constants correctness - test_data_pipeline: OFF/PLY I/O, mesh sampling, point cloud partitioning - test_benchmarks: Benchmark utilities, timing methodology, comparison functions Full suite: 379 passed, 0 failures. Co-Authored-By: Claude Opus 4.6 --- tests/test_benchmarks.py | 213 ++++++++++++++++++ tests/test_causality.py | 315 ++++++++++++++++++++++++++ tests/test_data_pipeline.py | 325 +++++++++++++++++++++++++++ tests/test_entropy_correctness.py | 357 ++++++++++++++++++++++++++++++ tests/test_numerical.py | 333 ++++++++++++++++++++++++++++ tests/test_roundtrip.py | 326 +++++++++++++++++++++++++++ 6 files changed, 1869 insertions(+) create mode 100755 tests/test_benchmarks.py create mode 100755 tests/test_causality.py create mode 100755 tests/test_data_pipeline.py create mode 100755 tests/test_entropy_correctness.py create mode 100755 tests/test_numerical.py create mode 100755 tests/test_roundtrip.py diff --git a/tests/test_benchmarks.py b/tests/test_benchmarks.py new file mode 100755 index 000000000..44b26993b --- /dev/null +++ b/tests/test_benchmarks.py @@ -0,0 +1,213 @@ +""" +Tests for benchmark utilities and methodology. + +Validates that Benchmark, MemoryProfiler, and benchmark_function produce +sensible results and the comparison utilities work correctly. +""" + +import sys +import time +from pathlib import Path + +import tensorflow as tf + +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from benchmarks import ( + Benchmark, + BenchmarkResult, + benchmark_function, + compare_implementations, + create_test_input, +) + + +class TestBenchmarkResult(tf.test.TestCase): + """Tests for BenchmarkResult dataclass.""" + + def test_ms_per_iteration(self): + """ms_per_iteration should correctly compute milliseconds.""" + result = BenchmarkResult( + name="test", + elapsed_seconds=1.0, + iterations=10 + ) + self.assertAlmostEqual(result.ms_per_iteration, 100.0) + + def test_ms_per_iteration_single(self): + """Single iteration should report total time in ms.""" + result = BenchmarkResult( + name="test", + elapsed_seconds=0.5, + iterations=1 + ) + self.assertAlmostEqual(result.ms_per_iteration, 500.0) + + def test_str_representation(self): + """String representation should include name and timing.""" + result = BenchmarkResult( + name="my_op", + elapsed_seconds=1.0, + iterations=10, + memory_mb=256.0 + ) + s = str(result) + self.assertIn("my_op", s) + self.assertIn("100.00", s) + self.assertIn("256.0", s) + + +class TestBenchmarkContextManager(tf.test.TestCase): + """Tests for Benchmark context manager.""" + + def test_measures_time(self): + """Should measure elapsed time > 0.""" + with Benchmark("sleep_test") as b: + time.sleep(0.01) + + self.assertGreater(b.result.elapsed_seconds, 0.0) + + def test_result_has_correct_name(self): + """Result should carry the benchmark name.""" + with Benchmark("named_op") as b: + pass + + self.assertEqual(b.result.name, "named_op") + + def test_result_has_correct_iterations(self): + """Result should record iteration count.""" + with Benchmark("iter_test", iterations=5) as b: + pass + + self.assertEqual(b.result.iterations, 5) + + def test_timing_is_reasonable(self): + """Measured time should be within order of magnitude of actual work.""" + with Benchmark("timed_op") as b: + time.sleep(0.05) + + # Should be at least ~50ms but less than 1s + self.assertGreater(b.result.elapsed_seconds, 0.01) + self.assertLess(b.result.elapsed_seconds, 1.0) + + +class TestBenchmarkFunction(tf.test.TestCase): + """Tests for benchmark_function utility.""" + + def test_returns_benchmark_result(self): + """Should return a BenchmarkResult.""" + def noop(): + return 42 + + result = benchmark_function(noop, warmup=1, iterations=3) + + self.assertIsInstance(result, BenchmarkResult) + self.assertEqual(result.iterations, 3) + self.assertGreater(result.elapsed_seconds, 0.0) + + def test_warmup_not_timed(self): + """Warmup iterations should not be included in timing.""" + call_count = [0] + + def counting_fn(): + call_count[0] += 1 + return call_count[0] + + result = benchmark_function(counting_fn, warmup=5, iterations=3) + + # Total calls = warmup + iterations = 8 + self.assertEqual(call_count[0], 8) + # But result should say 3 iterations + self.assertEqual(result.iterations, 3) + + def test_custom_name(self): + """Should use custom name when provided.""" + result = benchmark_function(lambda: None, name="custom_name") + self.assertEqual(result.name, "custom_name") + + def test_default_name_from_function(self): + """Should use function name by default.""" + def my_function(): + return None + + result = benchmark_function(my_function, warmup=0, iterations=1) + self.assertEqual(result.name, "my_function") + + def test_passes_args_and_kwargs(self): + """Should pass args and kwargs to benchmarked function.""" + def add(a, b, c=0): + return a + b + c + + # Should not raise + result = benchmark_function(add, args=(1, 2), kwargs={'c': 3}) + self.assertGreater(result.elapsed_seconds, 0.0) + + +class TestCompareImplementations(tf.test.TestCase): + """Tests for compare_implementations utility.""" + + def test_returns_all_results(self): + """Should return one result per implementation.""" + impls = { + 'fast': lambda: 1 + 1, + 'slow': lambda: sum(range(100)), + } + + results = compare_implementations(impls, warmup=1, iterations=3) + + self.assertEqual(len(results), 2) + self.assertIn('fast', results) + self.assertIn('slow', results) + + def test_faster_impl_is_faster(self): + """Faster implementation should measure less time (with tolerance).""" + def fast(): + return 1 + 1 + + def slow(): + total = 0 + for i in range(10000): + total += i + return total + + results = compare_implementations( + {'fast': fast, 'slow': slow}, + warmup=2, + iterations=10 + ) + + # Fast should be faster (or at least not 10x slower) + self.assertLess( + results['fast'].elapsed_seconds, + results['slow'].elapsed_seconds * 10 + ) + + +class TestCreateTestInput(tf.test.TestCase): + """Tests for test input tensor creation.""" + + def test_default_shape(self): + """Default shape should be (1, 32, 32, 32, 64).""" + tensor = create_test_input() + self.assertEqual(tensor.shape, (1, 32, 32, 32, 64)) + + def test_custom_shape(self): + """Should respect custom dimensions.""" + tensor = create_test_input( + batch_size=2, depth=8, height=16, width=4, channels=32 + ) + self.assertEqual(tensor.shape, (2, 8, 16, 4, 32)) + + def test_default_dtype(self): + """Default dtype should be float32.""" + tensor = create_test_input() + self.assertEqual(tensor.dtype, tf.float32) + + def test_custom_dtype(self): + """Should respect custom dtype.""" + tensor = create_test_input(dtype=tf.float16) + self.assertEqual(tensor.dtype, tf.float16) + + +if __name__ == '__main__': + tf.test.main() diff --git a/tests/test_causality.py b/tests/test_causality.py new file mode 100755 index 000000000..91092716e --- /dev/null +++ b/tests/test_causality.py @@ -0,0 +1,315 @@ +""" +Tests for masked convolution causality and autoregressive ordering. + +Validates that MaskedConv3D enforces correct causal masks in raster-scan +order (depth, height, width), type A excludes center, type B includes it, +and the AutoregressiveContext model maintains causality. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest +import tensorflow as tf + +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from context_model import AutoregressiveContext, MaskedConv3D + + +class TestMaskedConv3DCausality(tf.test.TestCase): + """Tests for MaskedConv3D mask correctness.""" + + def test_mask_type_a_excludes_center(self): + """Type A mask should be 0 at the center position.""" + conv = MaskedConv3D(filters=4, kernel_size=3, mask_type='A') + conv.build((None, 8, 8, 8, 1)) + + mask = conv.mask.numpy() + # Center of 3x3x3 kernel is (1, 1, 1) + center_vals = mask[1, 1, 1, :, :] + np.testing.assert_array_equal( + center_vals, 0.0, + err_msg="Type A mask should exclude center position" + ) + + def test_mask_type_b_includes_center(self): + """Type B mask should be 1 at the center position.""" + conv = MaskedConv3D(filters=4, kernel_size=3, mask_type='B') + conv.build((None, 8, 8, 8, 1)) + + mask = conv.mask.numpy() + center_vals = mask[1, 1, 1, :, :] + np.testing.assert_array_equal( + center_vals, 1.0, + err_msg="Type B mask should include center position" + ) + + def test_future_positions_masked(self): + """All future positions in raster-scan order should be 0.""" + conv = MaskedConv3D(filters=4, kernel_size=5, mask_type='A') + conv.build((None, 8, 8, 8, 1)) + + mask = conv.mask.numpy() + kd, kh, kw = 5, 5, 5 + center_d, center_h, center_w = 2, 2, 2 + + for d in range(kd): + for h in range(kh): + for w in range(kw): + is_future = ( + (d > center_d) or + (d == center_d and h > center_h) or + (d == center_d and h == center_h and w > center_w) + ) + is_center = (d == center_d and h == center_h and w == center_w) + + if is_future or is_center: + np.testing.assert_array_equal( + mask[d, h, w, :, :], 0.0, + err_msg=f"Position ({d},{h},{w}) should be masked" + ) + else: + np.testing.assert_array_equal( + mask[d, h, w, :, :], 1.0, + err_msg=f"Position ({d},{h},{w}) should be unmasked" + ) + + def test_past_positions_unmasked_type_b(self): + """All past + center positions in raster-scan order should be 1 for type B.""" + conv = MaskedConv3D(filters=4, kernel_size=5, mask_type='B') + conv.build((None, 8, 8, 8, 1)) + + mask = conv.mask.numpy() + kd, kh, kw = 5, 5, 5 + center_d, center_h, center_w = 2, 2, 2 + + for d in range(kd): + for h in range(kh): + for w in range(kw): + is_future = ( + (d > center_d) or + (d == center_d and h > center_h) or + (d == center_d and h == center_h and w > center_w) + ) + + if is_future: + np.testing.assert_array_equal( + mask[d, h, w, :, :], 0.0, + err_msg=f"Position ({d},{h},{w}) should be masked (future)" + ) + else: + np.testing.assert_array_equal( + mask[d, h, w, :, :], 1.0, + err_msg=f"Position ({d},{h},{w}) should be unmasked (past/center)" + ) + + def test_mask_shape_matches_kernel(self): + """Mask shape should match (kd, kh, kw, in_channels, filters).""" + in_channels = 8 + filters = 16 + conv = MaskedConv3D(filters=filters, kernel_size=3, mask_type='A') + conv.build((None, 8, 8, 8, in_channels)) + + self.assertEqual(conv.mask.shape, (3, 3, 3, in_channels, filters)) + + def test_mask_broadcast_across_channels(self): + """Mask should be the same across all input/output channel pairs.""" + conv = MaskedConv3D(filters=8, kernel_size=3, mask_type='A') + conv.build((None, 8, 8, 8, 4)) + + mask = conv.mask.numpy() + # All channel slices should be identical + reference = mask[:, :, :, 0, 0] + for ic in range(4): + for oc in range(8): + np.testing.assert_array_equal( + mask[:, :, :, ic, oc], reference, + err_msg=f"Channel ({ic},{oc}) mask differs from reference" + ) + + def test_invalid_mask_type_raises(self): + """Invalid mask type should raise ValueError.""" + with self.assertRaises(ValueError): + MaskedConv3D(filters=4, kernel_size=3, mask_type='C') + + def test_output_shape_same_padding(self): + """Output should have same spatial dims with 'same' padding.""" + conv = MaskedConv3D(filters=8, kernel_size=3, mask_type='A', padding='same') + inputs = tf.random.normal((1, 8, 8, 8, 4)) + output = conv(inputs) + + self.assertEqual(output.shape, (1, 8, 8, 8, 8)) + + def test_kernel_size_1_type_a_all_zero(self): + """Kernel size 1 with type A should have all-zero mask (no past).""" + conv = MaskedConv3D(filters=4, kernel_size=1, mask_type='A') + conv.build((None, 8, 8, 8, 2)) + + mask = conv.mask.numpy() + np.testing.assert_array_equal(mask, 0.0) + + def test_kernel_size_1_type_b_all_one(self): + """Kernel size 1 with type B should have all-one mask (center only).""" + conv = MaskedConv3D(filters=4, kernel_size=1, mask_type='B') + conv.build((None, 8, 8, 8, 2)) + + mask = conv.mask.numpy() + np.testing.assert_array_equal(mask, 1.0) + + +class TestCausalOutputDependence(tf.test.TestCase): + """Tests that masked conv output at position (d,h,w) depends only on past.""" + + def test_type_a_output_independent_of_current_position(self): + """With type A, changing center input should not affect center output.""" + tf.random.set_seed(42) + conv = MaskedConv3D(filters=1, kernel_size=3, mask_type='A') + + # Create two inputs that differ only at center position (4,4,4) + input1 = tf.random.normal((1, 8, 8, 8, 1)) + input2 = tf.identity(input1) + # Modify center position + input2_np = input2.numpy() + input2_np[0, 4, 4, 4, 0] = 999.0 + input2 = tf.constant(input2_np) + + out1 = conv(input1) + out2 = conv(input2) + + # Output at (4,4,4) should be the same (center is masked for type A) + self.assertAllClose( + out1[0, 4, 4, 4, :], out2[0, 4, 4, 4, :], + atol=1e-5, + msg="Type A output should not depend on current position" + ) + + def test_type_b_output_depends_on_current_position(self): + """With type B, changing center input should affect center output.""" + tf.random.set_seed(42) + conv = MaskedConv3D(filters=1, kernel_size=3, mask_type='B') + + input1 = tf.random.normal((1, 8, 8, 8, 1)) + input2_np = input1.numpy().copy() + input2_np[0, 4, 4, 4, 0] = 999.0 + input2 = tf.constant(input2_np) + + out1 = conv(input1) + out2 = conv(input2) + + # Output at (4,4,4) should differ (center is unmasked for type B) + diff = tf.abs(out1[0, 4, 4, 4, :] - out2[0, 4, 4, 4, :]) + self.assertGreater(float(tf.reduce_max(diff)), 0.01) + + def test_future_change_does_not_affect_past_output(self): + """Changing a future position should not affect any past output.""" + tf.random.set_seed(42) + conv = MaskedConv3D(filters=1, kernel_size=3, mask_type='A') + + input1 = tf.random.normal((1, 8, 8, 8, 1)) + input2_np = input1.numpy().copy() + # Modify a "future" position (7,7,7) + input2_np[0, 7, 7, 7, 0] = 999.0 + input2 = tf.constant(input2_np) + + out1 = conv(input1) + out2 = conv(input2) + + # All positions before (7,7,7) should be identical + # Check positions 0..6 in depth (all are strictly before depth=7) + self.assertAllClose( + out1[0, :7, :, :, :], out2[0, :7, :, :, :], + atol=1e-5, + msg="Past outputs should not change when future input changes" + ) + + +class TestAutoregressiveContext(tf.test.TestCase): + """Tests for the AutoregressiveContext model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.channels = 16 + self.resolution = 8 + + def test_output_shape(self): + """Output should have shape (B, D, H, W, channels).""" + ctx = AutoregressiveContext(channels=self.channels, num_layers=3) + inputs = tf.random.normal((1, self.resolution, self.resolution, self.resolution, 4)) + output = ctx(inputs) + + self.assertEqual(output.shape, (1, self.resolution, self.resolution, self.resolution, self.channels)) + + def test_first_layer_is_type_a(self): + """First conv layer should use mask type A.""" + ctx = AutoregressiveContext(channels=self.channels, num_layers=3) + self.assertEqual(ctx.conv_layers[0].mask_type, 'A') + + def test_subsequent_layers_are_type_b(self): + """All subsequent conv layers should use mask type B.""" + ctx = AutoregressiveContext(channels=self.channels, num_layers=3) + for conv in ctx.conv_layers[1:]: + self.assertEqual(conv.mask_type, 'B') + + def test_causal_output(self): + """Changing future input should not affect past outputs.""" + tf.random.set_seed(42) + ctx = AutoregressiveContext(channels=self.channels, num_layers=2, kernel_size=3) + + input1 = tf.random.normal((1, self.resolution, self.resolution, self.resolution, 4)) + input2_np = input1.numpy().copy() + # Modify last depth slice (future) + input2_np[0, -1, :, :, :] = 999.0 + input2 = tf.constant(input2_np) + + out1 = ctx(input1) + out2 = ctx(input2) + + # Outputs at depth 0 should be identical (far from modified depth) + # With kernel_size=3 and 2 layers, receptive field is at most 4 + self.assertAllClose( + out1[0, 0, :, :, :], out2[0, 0, :, :, :], + atol=1e-5, + msg="Early depth outputs should not depend on last depth slice" + ) + + +class TestMaskCountProperties(tf.test.TestCase): + """Tests for statistical properties of the mask.""" + + def test_type_a_has_fewer_ones_than_type_b(self): + """Type A (excludes center) should have fewer 1s than type B.""" + conv_a = MaskedConv3D(filters=1, kernel_size=3, mask_type='A') + conv_b = MaskedConv3D(filters=1, kernel_size=3, mask_type='B') + conv_a.build((None, 8, 8, 8, 1)) + conv_b.build((None, 8, 8, 8, 1)) + + ones_a = np.sum(conv_a.mask.numpy()) + ones_b = np.sum(conv_b.mask.numpy()) + + self.assertLess(ones_a, ones_b) + + def test_type_a_count_3x3x3(self): + """3x3x3 type A should have 13 unmasked positions (half minus center).""" + conv = MaskedConv3D(filters=1, kernel_size=3, mask_type='A') + conv.build((None, 8, 8, 8, 1)) + + mask = conv.mask.numpy()[:, :, :, 0, 0] + # In 3x3x3=27 positions, 13 are past, 1 is center, 13 are future + # Type A: 13 past = unmasked + self.assertEqual(int(np.sum(mask)), 13) + + def test_type_b_count_3x3x3(self): + """3x3x3 type B should have 14 unmasked positions (half + center).""" + conv = MaskedConv3D(filters=1, kernel_size=3, mask_type='B') + conv.build((None, 8, 8, 8, 1)) + + mask = conv.mask.numpy()[:, :, :, 0, 0] + # 13 past + 1 center = 14 + self.assertEqual(int(np.sum(mask)), 14) + + +if __name__ == '__main__': + tf.test.main() diff --git a/tests/test_data_pipeline.py b/tests/test_data_pipeline.py new file mode 100755 index 000000000..247a8fd82 --- /dev/null +++ b/tests/test_data_pipeline.py @@ -0,0 +1,325 @@ +""" +Tests for data pipeline: OFF/PLY file I/O, mesh-to-point-cloud sampling, +and point cloud partitioning. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest +import tensorflow as tf + +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from ds_mesh_to_pc import ( + MeshData, + compute_face_normals, + partition_point_cloud, + read_off, + sample_points_from_mesh, + save_ply, +) + + +class TestReadOFF(tf.test.TestCase): + """Tests for OFF file reading.""" + + @pytest.fixture(autouse=True) + def inject_tmp_path(self, tmp_path): + self.tmp_path = tmp_path + + def test_valid_off_file(self): + """Should parse a valid OFF file correctly.""" + off_path = self.tmp_path / "test.off" + off_path.write_text( + "OFF\n" + "4 2 0\n" + "0.0 0.0 0.0\n" + "1.0 0.0 0.0\n" + "0.0 1.0 0.0\n" + "0.0 0.0 1.0\n" + "3 0 1 2\n" + "3 0 1 3\n" + ) + + mesh = read_off(str(off_path)) + + self.assertEqual(mesh.vertices.shape, (4, 3)) + self.assertEqual(mesh.faces.shape, (2, 3)) + + def test_vertices_only_off(self): + """Should handle OFF files with vertices but no faces.""" + off_path = self.tmp_path / "verts.off" + off_path.write_text( + "OFF\n" + "3 0 0\n" + "1.0 2.0 3.0\n" + "4.0 5.0 6.0\n" + "7.0 8.0 9.0\n" + ) + + mesh = read_off(str(off_path)) + + self.assertIsNotNone(mesh) + self.assertEqual(mesh.vertices.shape, (3, 3)) + self.assertIsNone(mesh.faces) + + def test_invalid_header_returns_none(self): + """Non-OFF header should return None.""" + off_path = self.tmp_path / "bad.off" + off_path.write_text("NOT_OFF\n1 0 0\n0.0 0.0 0.0\n") + + mesh = read_off(str(off_path)) + self.assertIsNone(mesh) + + def test_nonexistent_file_returns_none(self): + """Missing file should return None (not raise).""" + mesh = read_off(str(self.tmp_path / "nonexistent.off")) + self.assertIsNone(mesh) + + def test_ngon_triangulation(self): + """N-gons (quads etc.) should be triangulated via fan method.""" + off_path = self.tmp_path / "quad.off" + off_path.write_text( + "OFF\n" + "4 1 0\n" + "0.0 0.0 0.0\n" + "1.0 0.0 0.0\n" + "1.0 1.0 0.0\n" + "0.0 1.0 0.0\n" + "4 0 1 2 3\n" # Quad face + ) + + mesh = read_off(str(off_path)) + + # Quad should become 2 triangles + self.assertIsNotNone(mesh.faces) + self.assertEqual(mesh.faces.shape[0], 2) + self.assertEqual(mesh.faces.shape[1], 3) + + +class TestComputeFaceNormals(tf.test.TestCase): + """Tests for face normal computation.""" + + def test_unit_triangle_normal(self): + """Right triangle in XY plane should have Z-aligned normal.""" + vertices = np.array([ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + ], dtype=np.float32) + faces = np.array([[0, 1, 2]], dtype=np.int32) + + normals = compute_face_normals(vertices, faces) + + self.assertEqual(normals.shape, (1, 3)) + # Normal should be [0, 0, 1] (Z direction) + np.testing.assert_allclose(np.abs(normals[0]), [0, 0, 1], atol=1e-5) + + def test_normals_are_unit_length(self): + """Face normals should be unit length.""" + np.random.seed(42) + vertices = np.random.randn(10, 3).astype(np.float32) + faces = np.array([[0, 1, 2], [3, 4, 5], [6, 7, 8]], dtype=np.int32) + + normals = compute_face_normals(vertices, faces) + lengths = np.linalg.norm(normals, axis=1) + + np.testing.assert_allclose(lengths, 1.0, atol=1e-5) + + +class TestSamplePointsFromMesh(tf.test.TestCase): + """Tests for mesh-to-point-cloud sampling.""" + + @pytest.fixture(autouse=True) + def setup(self): + np.random.seed(42) + self.vertices = np.array([ + [0, 0, 0], + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + ], dtype=np.float32) + self.faces = np.array([[0, 1, 2], [0, 1, 3]], dtype=np.int32) + self.mesh = MeshData( + vertices=self.vertices, + faces=self.faces, + face_normals=compute_face_normals(self.vertices, self.faces) + ) + + def test_correct_num_points(self): + """Should return exactly num_points points.""" + points, normals = sample_points_from_mesh(self.mesh, num_points=100) + self.assertEqual(points.shape[0], 100) + self.assertEqual(points.shape[1], 3) + + def test_points_within_mesh_bounds(self): + """Sampled points should be within vertex bounding box.""" + points, _ = sample_points_from_mesh(self.mesh, num_points=1000) + + min_bound = self.vertices.min(axis=0) + max_bound = self.vertices.max(axis=0) + + for dim in range(3): + self.assertAllGreaterEqual(points[:, dim], min_bound[dim] - 1e-5) + self.assertAllLessEqual(points[:, dim], max_bound[dim] + 1e-5) + + def test_normals_unit_length(self): + """Returned normals should be unit length.""" + points, normals = sample_points_from_mesh( + self.mesh, num_points=100, compute_normals=True + ) + + self.assertIsNotNone(normals) + lengths = np.linalg.norm(normals, axis=1) + np.testing.assert_allclose(lengths, 1.0, atol=1e-5) + + def test_no_normals_when_disabled(self): + """Should return None normals when compute_normals=False.""" + mesh_no_normals = MeshData( + vertices=self.vertices, + faces=self.faces + ) + points, normals = sample_points_from_mesh( + mesh_no_normals, num_points=100, compute_normals=False + ) + self.assertIsNone(normals) + + def test_vertex_sampling_when_no_faces(self): + """Without faces, should sample directly from vertices.""" + mesh_no_faces = MeshData(vertices=self.vertices) + points, _ = sample_points_from_mesh( + mesh_no_faces, num_points=10, compute_normals=False + ) + self.assertEqual(points.shape, (10, 3)) + + def test_points_dtype_float32(self): + """Sampled points should be float32.""" + points, _ = sample_points_from_mesh(self.mesh, num_points=50) + self.assertEqual(points.dtype, np.float32) + + +class TestPartitionPointCloud(tf.test.TestCase): + """Tests for point cloud spatial partitioning.""" + + def test_single_block(self): + """Points within one block_size should create one block.""" + points = np.random.uniform(0, 0.5, (200, 3)).astype(np.float32) + + blocks = partition_point_cloud(points, block_size=1.0, min_points=10) + + self.assertEqual(len(blocks), 1) + self.assertEqual(blocks[0]['points'].shape[1], 3) + + def test_multiple_blocks(self): + """Spread-out points should create multiple blocks.""" + # Create two clusters far apart + cluster1 = np.random.uniform(0, 0.5, (100, 3)).astype(np.float32) + cluster2 = np.random.uniform(5, 5.5, (100, 3)).astype(np.float32) + points = np.vstack([cluster1, cluster2]) + + blocks = partition_point_cloud(points, block_size=1.0, min_points=10) + + self.assertGreaterEqual(len(blocks), 2) + + def test_min_points_filter(self): + """Blocks with fewer than min_points should be excluded.""" + # Create a big cluster and a tiny cluster + big_cluster = np.random.uniform(0, 0.5, (200, 3)).astype(np.float32) + tiny_cluster = np.random.uniform(10, 10.1, (5, 3)).astype(np.float32) + points = np.vstack([big_cluster, tiny_cluster]) + + blocks = partition_point_cloud(points, block_size=1.0, min_points=10) + + # Tiny cluster should be filtered out + total_points = sum(len(b['points']) for b in blocks) + self.assertEqual(total_points, 200) + + def test_normals_partitioned(self): + """Normals should be partitioned along with points.""" + np.random.seed(42) + points = np.random.uniform(0, 0.5, (200, 3)).astype(np.float32) + normals = np.random.randn(200, 3).astype(np.float32) + + blocks = partition_point_cloud( + points, normals=normals, block_size=1.0, min_points=10 + ) + + for block in blocks: + self.assertIn('normals', block) + self.assertEqual(block['normals'].shape[0], block['points'].shape[0]) + self.assertEqual(block['normals'].shape[1], 3) + + def test_all_points_accounted_for(self): + """All points in valid blocks should appear exactly once.""" + np.random.seed(42) + points = np.random.uniform(0, 3, (500, 3)).astype(np.float32) + + blocks = partition_point_cloud(points, block_size=1.0, min_points=1) + + total = sum(len(b['points']) for b in blocks) + self.assertEqual(total, 500) + + +class TestSavePly(tf.test.TestCase): + """Tests for PLY file writing.""" + + @pytest.fixture(autouse=True) + def inject_tmp_path(self, tmp_path): + self.tmp_path = tmp_path + + def test_write_points_only(self): + """Should write valid PLY with points only.""" + points = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32) + ply_path = str(self.tmp_path / "test.ply") + + save_ply(ply_path, points) + + content = Path(ply_path).read_text() + self.assertIn("ply", content) + self.assertIn("element vertex 2", content) + self.assertNotIn("property float nx", content) + + def test_write_points_with_normals(self): + """Should write valid PLY with points and normals.""" + points = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float32) + normals = np.array([[0, 0, 1], [0, 1, 0]], dtype=np.float32) + ply_path = str(self.tmp_path / "test_normals.ply") + + save_ply(ply_path, points, normals) + + content = Path(ply_path).read_text() + self.assertIn("property float nx", content) + self.assertIn("property float ny", content) + self.assertIn("property float nz", content) + + def test_roundtrip_off_to_ply(self): + """OFF → sample → PLY should produce valid output.""" + # Write OFF + off_path = str(self.tmp_path / "mesh.off") + with open(off_path, 'w') as f: + f.write("OFF\n4 2 0\n") + f.write("0 0 0\n1 0 0\n0 1 0\n0 0 1\n") + f.write("3 0 1 2\n3 0 1 3\n") + + # Read and sample + mesh = read_off(off_path) + self.assertIsNotNone(mesh) + points, normals = sample_points_from_mesh(mesh, num_points=50) + + # Write PLY + ply_path = str(self.tmp_path / "output.ply") + save_ply(ply_path, points, normals) + + # Verify file exists and has content + content = Path(ply_path).read_text() + lines = content.strip().split('\n') + # Header + 50 data lines + header_end = lines.index('end_header') + data_lines = lines[header_end + 1:] + self.assertEqual(len(data_lines), 50) + + +if __name__ == '__main__': + tf.test.main() diff --git a/tests/test_entropy_correctness.py b/tests/test_entropy_correctness.py new file mode 100755 index 000000000..9c9b22ce5 --- /dev/null +++ b/tests/test_entropy_correctness.py @@ -0,0 +1,357 @@ +""" +Tests for entropy model mathematical correctness. + +Validates that discretized Gaussian likelihood is a proper probability mass +function (PMF), rate estimates are non-negative, and quantization behavior +switches correctly between training and inference. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest +import tensorflow as tf + +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from constants import LOG_2_RECIPROCAL +from entropy_model import ( + ConditionalGaussian, + EntropyModel, + MeanScaleHyperprior, + PatchedGaussianConditional, + _discretized_gaussian_likelihood, +) + + +class TestDiscretizedLikelihood(tf.test.TestCase): + """Tests for the discretized Gaussian likelihood function.""" + + def test_pmf_positive(self): + """All PMF values must be strictly positive (floored at EPSILON).""" + tf.random.set_seed(42) + inputs = tf.random.normal((2, 8, 8, 8, 16)) + mean = tf.zeros_like(inputs) + scale = tf.ones_like(inputs) + + likelihood = _discretized_gaussian_likelihood(inputs, mean, scale) + + self.assertAllGreater(likelihood, 0.0) + + def test_pmf_at_most_one(self): + """No single bin should have probability > 1.""" + inputs = tf.constant([0.0, 1.0, -1.0, 5.0, -5.0]) + mean = tf.zeros_like(inputs) + scale = tf.ones_like(inputs) + + likelihood = _discretized_gaussian_likelihood(inputs, mean, scale) + + self.assertAllLessEqual(likelihood, 1.0) + + def test_pmf_sums_approximately_to_one(self): + """PMF over a wide range of integers should sum close to 1.""" + # Evaluate PMF over integers from -50 to +50 for various scales + for scale_val in [0.1, 0.5, 1.0, 2.0, 5.0]: + integers = tf.cast(tf.range(-50, 51), tf.float32) + mean = tf.zeros_like(integers) + scale = tf.fill(integers.shape, scale_val) + + likelihood = _discretized_gaussian_likelihood(integers, mean, scale) + total = tf.reduce_sum(likelihood) + + # Should be very close to 1.0 (small scale needs wider range) + self.assertAllClose(total, 1.0, atol=1e-3, + msg=f"PMF sum={total:.6f} for scale={scale_val}") + + def test_pmf_peaks_at_mean(self): + """PMF should be highest at the integer closest to mean.""" + mean_val = 2.3 + integers = tf.cast(tf.range(-10, 11), tf.float32) + mean = tf.fill(integers.shape, mean_val) + scale = tf.ones_like(integers) + + likelihood = _discretized_gaussian_likelihood(integers, mean, scale) + + # The peak should be at integer 2 (closest to 2.3) + peak_idx = tf.argmax(likelihood) + # integers[12] = 2 (index 0 -> -10, so index 12 -> 2) + self.assertEqual(int(integers[peak_idx]), 2) + + def test_pmf_symmetric_around_integer_mean(self): + """PMF should be symmetric when mean is an integer.""" + mean_val = 0.0 + integers = tf.cast(tf.range(-10, 11), tf.float32) + mean = tf.fill(integers.shape, mean_val) + scale = tf.ones_like(integers) + + likelihood = _discretized_gaussian_likelihood(integers, mean, scale) + likelihood_np = likelihood.numpy() + + # Check symmetry: P(k) == P(-k) for integer mean + for k in range(1, 11): + idx_pos = 10 + k # index of +k + idx_neg = 10 - k # index of -k + np.testing.assert_allclose( + likelihood_np[idx_pos], likelihood_np[idx_neg], rtol=1e-5, + err_msg=f"Asymmetry at k={k}" + ) + + def test_small_scale_concentrates_mass(self): + """Very small scale should concentrate mass near mean.""" + integers = tf.cast(tf.range(-10, 11), tf.float32) + mean = tf.zeros_like(integers) + scale = tf.fill(integers.shape, 0.01) + + likelihood = _discretized_gaussian_likelihood(integers, mean, scale) + + # Almost all mass at 0 + self.assertGreater(float(likelihood[10]), 0.99) # index 10 = integer 0 + + def test_large_scale_spreads_mass(self): + """Large scale should spread mass more evenly.""" + integers = tf.cast(tf.range(-10, 11), tf.float32) + mean = tf.zeros_like(integers) + scale = tf.fill(integers.shape, 10.0) + + likelihood = _discretized_gaussian_likelihood(integers, mean, scale) + + # Mass at 0 should be much less than for small scale + self.assertLess(float(likelihood[10]), 0.1) + + def test_scale_clipped_to_minimum(self): + """Scale values near zero should not produce NaN.""" + inputs = tf.constant([0.0, 1.0, -1.0]) + mean = tf.zeros_like(inputs) + scale = tf.constant([1e-10, 0.0, -1.0]) # degenerate scales + + likelihood = _discretized_gaussian_likelihood(inputs, mean, scale) + + self.assertAllGreater(likelihood, 0.0) + self.assertFalse(tf.reduce_any(tf.math.is_nan(likelihood))) + + +class TestRateComputation(tf.test.TestCase): + """Tests that rate (bits) from likelihood is non-negative and sensible.""" + + def test_rate_non_negative(self): + """Bits from discretized likelihood must be non-negative.""" + tf.random.set_seed(42) + inputs = tf.random.normal((1, 8, 8, 8, 16)) + mean = tf.zeros_like(inputs) + scale = tf.ones_like(inputs) + + likelihood = _discretized_gaussian_likelihood(inputs, mean, scale) + bits = -tf.math.log(likelihood) * LOG_2_RECIPROCAL + + self.assertAllGreaterEqual(bits, 0.0) + + def test_rate_increases_with_surprise(self): + """Unlikely values should require more bits than likely values.""" + mean = tf.constant([0.0]) + scale = tf.constant([1.0]) + + # Value at mean vs far from mean + likely = tf.constant([0.0]) + unlikely = tf.constant([10.0]) + + ll_likely = _discretized_gaussian_likelihood(likely, mean, scale) + ll_unlikely = _discretized_gaussian_likelihood(unlikely, mean, scale) + + bits_likely = float(-tf.math.log(ll_likely) * LOG_2_RECIPROCAL) + bits_unlikely = float(-tf.math.log(ll_unlikely) * LOG_2_RECIPROCAL) + + # Unlikely values should cost more bits + self.assertGreater(bits_unlikely, bits_likely) + + def test_total_bits_from_entropy_model(self): + """EntropyModel should produce non-negative total bits.""" + tf.random.set_seed(42) + model = EntropyModel() + inputs = tf.random.normal((1, 8, 8, 8, 16)) + + compressed, likelihood = model(inputs, training=False) + + total_bits = tf.reduce_sum(-tf.math.log(likelihood) * LOG_2_RECIPROCAL) + self.assertGreater(float(total_bits), 0.0) + + def test_low_entropy_vs_high_entropy(self): + """Small scale (concentrated) should have fewer bits than large scale.""" + integers = tf.cast(tf.range(-20, 21), tf.float32) + mean = tf.zeros_like(integers) + + # Small scale: concentrated distribution -> low entropy + scale_small = tf.fill(integers.shape, 0.5) + ll_small = _discretized_gaussian_likelihood(integers, mean, scale_small) + entropy_small = float(tf.reduce_sum(-ll_small * tf.math.log(ll_small))) + + # Large scale: spread distribution -> high entropy + scale_large = tf.fill(integers.shape, 5.0) + ll_large = _discretized_gaussian_likelihood(integers, mean, scale_large) + entropy_large = float(tf.reduce_sum(-ll_large * tf.math.log(ll_large))) + + self.assertGreater(entropy_large, entropy_small) + + +class TestQuantizationBehavior(tf.test.TestCase): + """Tests that quantization switches correctly between training/inference.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.inputs = tf.constant([[[[[1.3, -0.7, 2.5]]]]]) # (1,1,1,1,3) + + def test_conditional_gaussian_training_adds_noise(self): + """Training mode should add uniform noise, not round.""" + cg = ConditionalGaussian() + scale = tf.ones_like(self.inputs) + mean = tf.zeros_like(self.inputs) + + # Run multiple times to confirm stochasticity + outputs = set() + for _ in range(10): + out, _ = cg(self.inputs, scale, mean, training=True) + outputs.add(tuple(out.numpy().flatten())) + + # Should produce different outputs each time + self.assertGreater(len(outputs), 1) + + def test_conditional_gaussian_inference_rounds(self): + """Inference mode should produce deterministic rounded output.""" + cg = ConditionalGaussian() + scale = tf.ones_like(self.inputs) + mean = tf.zeros_like(self.inputs) + + out1, _ = cg(self.inputs, scale, mean, training=False) + out2, _ = cg(self.inputs, scale, mean, training=False) + + self.assertAllEqual(out1, out2) + + # Should be rounded (input - mean rounded + mean = rounded input for mean=0) + expected = tf.round(self.inputs) + self.assertAllClose(out1, expected) + + def test_conditional_gaussian_likelihood_always_positive(self): + """Likelihood should be positive in both training and inference.""" + cg = ConditionalGaussian() + scale = tf.ones_like(self.inputs) + mean = tf.zeros_like(self.inputs) + + _, ll_train = cg(self.inputs, scale, mean, training=True) + _, ll_eval = cg(self.inputs, scale, mean, training=False) + + self.assertAllGreater(ll_train, 0.0) + self.assertAllGreater(ll_eval, 0.0) + + +class TestPatchedGaussianConditional(tf.test.TestCase): + """Tests for PatchedGaussianConditional layer.""" + + def test_compress_decompress_roundtrip(self): + """compress → decompress should be identity for integer inputs.""" + pgc = PatchedGaussianConditional() + # Build the layer + inputs = tf.constant([[[[[1.0, 2.0, 3.0]]]]]) + pgc.build(inputs.shape) + + compressed = pgc.compress(inputs) + decompressed = pgc.decompress(compressed) + + self.assertAllClose(decompressed, inputs, atol=1e-5) + + def test_scale_quantization_binary_search(self): + """Binary search should map scales to nearest table entry.""" + scale_table = tf.constant([0.1, 0.5, 1.0, 2.0, 5.0]) + pgc = PatchedGaussianConditional(scale_table=scale_table) + + test_scales = tf.constant([0.05, 0.3, 0.8, 1.5, 3.0, 10.0]) + quantized = pgc.quantize_scale(test_scales) + + # Each should map to nearest table entry + expected = tf.constant([0.1, 0.1, 1.0, 1.0, 2.0, 5.0]) + self.assertAllClose(quantized, expected) + + def test_scale_quantization_preserves_shape(self): + """Quantized scales should have same shape as input.""" + scale_table = tf.constant([0.1, 0.5, 1.0, 2.0, 5.0]) + pgc = PatchedGaussianConditional(scale_table=scale_table) + + test_scales = tf.random.uniform((2, 4, 4, 4, 8), 0.1, 5.0) + quantized = pgc.quantize_scale(test_scales) + + self.assertEqual(quantized.shape, test_scales.shape) + + def test_likelihood_matches_standalone(self): + """Layer likelihood should match standalone function.""" + pgc = PatchedGaussianConditional() + inputs = tf.constant([[[[[0.0, 1.0, -1.0]]]]]) + pgc.build(inputs.shape) + + layer_ll = pgc.likelihood(inputs) + + # Compare with standalone function + scale = tf.maximum(tf.abs(pgc.scale), 1e-6) + standalone_ll = _discretized_gaussian_likelihood(inputs, pgc.mean, scale) + + self.assertAllClose(layer_ll, standalone_ll) + + +class TestMeanScaleHyperprior(tf.test.TestCase): + """Tests for MeanScaleHyperprior entropy model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.latent_channels = 32 + self.hyper_channels = 16 + + def test_total_bits_non_negative(self): + """Total bits from hyperprior should be non-negative.""" + model = MeanScaleHyperprior( + latent_channels=self.latent_channels, + hyper_channels=self.hyper_channels + ) + + y = tf.random.normal((1, 4, 4, 4, self.latent_channels)) + z_hat = tf.random.normal((1, 4, 4, 4, self.hyper_channels)) + z = tf.random.normal((1, 4, 4, 4, self.hyper_channels)) + + y_hat, y_likelihood, total_bits = model(y, z_hat, z=z, training=False) + + self.assertGreater(float(total_bits), 0.0) + self.assertAllGreater(y_likelihood, 0.0) + + def test_output_shape_matches_input(self): + """y_hat should have same shape as y.""" + model = MeanScaleHyperprior( + latent_channels=self.latent_channels, + hyper_channels=self.hyper_channels + ) + + y = tf.random.normal((1, 4, 4, 4, self.latent_channels)) + z_hat = tf.random.normal((1, 4, 4, 4, self.hyper_channels)) + + y_hat, y_likelihood, _ = model(y, z_hat, training=False) + + self.assertEqual(y_hat.shape, y.shape) + self.assertEqual(y_likelihood.shape, y.shape) + + def test_compress_decompress_consistency(self): + """compress then decompress should recover y_hat.""" + model = MeanScaleHyperprior( + latent_channels=self.latent_channels, + hyper_channels=self.hyper_channels + ) + + y = tf.random.normal((1, 4, 4, 4, self.latent_channels)) + z_hat = tf.random.normal((1, 4, 4, 4, self.hyper_channels)) + + symbols, side_info = model.compress(y, z_hat) + y_hat = model.decompress(symbols, z_hat) + + # Symbols + mean should give y_hat + self.assertEqual(y_hat.shape, y.shape) + + +if __name__ == '__main__': + tf.test.main() diff --git a/tests/test_numerical.py b/tests/test_numerical.py new file mode 100755 index 000000000..d2072cf04 --- /dev/null +++ b/tests/test_numerical.py @@ -0,0 +1,333 @@ +""" +Tests for numerical stability of GDN, entropy models, and data pipeline. + +Validates that GDN/IGDN handle edge cases (zero, large, negative inputs), +entropy models remain stable with extreme parameters, and the data pipeline +produces valid outputs. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest +import tensorflow as tf + +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from constants import EPSILON +from entropy_model import ( + ConditionalGaussian, + PatchedGaussianConditional, + _discretized_gaussian_likelihood, +) +from model_transforms import GDN + + +class TestGDNStability(tf.test.TestCase): + """Tests for GDN numerical stability.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.channels = 8 + self.shape = (1, 4, 4, 4, self.channels) + + def test_zero_input(self): + """GDN should handle zero input without NaN.""" + gdn = GDN(inverse=False) + inputs = tf.zeros(self.shape) + output = gdn(inputs) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(output))) + self.assertFalse(tf.reduce_any(tf.math.is_inf(output))) + # Zero divided by sqrt(beta) should be zero + self.assertAllClose(output, tf.zeros_like(output)) + + def test_zero_input_igdn(self): + """IGDN should handle zero input without NaN.""" + igdn = GDN(inverse=True) + inputs = tf.zeros(self.shape) + output = igdn(inputs) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(output))) + self.assertFalse(tf.reduce_any(tf.math.is_inf(output))) + self.assertAllClose(output, tf.zeros_like(output)) + + def test_large_input(self): + """GDN should handle large inputs without overflow.""" + gdn = GDN(inverse=False) + inputs = tf.constant(1000.0, shape=self.shape) + output = gdn(inputs) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(output))) + self.assertFalse(tf.reduce_any(tf.math.is_inf(output))) + + def test_large_input_igdn(self): + """IGDN should handle large inputs without overflow.""" + igdn = GDN(inverse=True) + inputs = tf.constant(100.0, shape=self.shape) + output = igdn(inputs) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(output))) + self.assertFalse(tf.reduce_any(tf.math.is_inf(output))) + + def test_negative_input(self): + """GDN should handle negative inputs.""" + gdn = GDN(inverse=False) + inputs = tf.constant(-5.0, shape=self.shape) + output = gdn(inputs) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(output))) + self.assertFalse(tf.reduce_any(tf.math.is_inf(output))) + + def test_igdn_gdn_approximate_inverse(self): + """IGDN(GDN(x)) should approximately recover x for moderate inputs.""" + tf.random.set_seed(42) + gdn = GDN(inverse=False) + igdn = GDN(inverse=True) + + # Use moderate values to stay in stable range + inputs = tf.random.normal(self.shape) * 2.0 + + # Forward through GDN then IGDN + encoded = gdn(inputs) + decoded = igdn(encoded) + + # GDN and IGDN are not exact inverses with independently initialized + # parameters, but with default params (beta=1, gamma=0.1*I) they + # should be reasonably close for moderate inputs + # We just check no NaN/Inf and shape preservation + self.assertFalse(tf.reduce_any(tf.math.is_nan(decoded))) + self.assertEqual(decoded.shape, inputs.shape) + + def test_gdn_output_bounded(self): + """GDN should reduce magnitude (divisive normalization).""" + gdn = GDN(inverse=False) + inputs = tf.constant(5.0, shape=self.shape) + output = gdn(inputs) + + # GDN divides by sqrt(beta + ...) >= sqrt(1) = 1 + # So output magnitude should be <= input magnitude + max_output = float(tf.reduce_max(tf.abs(output))) + max_input = float(tf.reduce_max(tf.abs(inputs))) + self.assertLessEqual(max_output, max_input + 1e-5) + + def test_gdn_gradient_no_nan(self): + """Gradients through GDN should not contain NaN.""" + gdn = GDN(inverse=False) + inputs = tf.random.normal(self.shape) + + with tf.GradientTape() as tape: + tape.watch(inputs) + output = gdn(inputs) + loss = tf.reduce_mean(output) + + grad = tape.gradient(loss, inputs) + self.assertFalse(tf.reduce_any(tf.math.is_nan(grad))) + + def test_igdn_gradient_no_nan(self): + """Gradients through IGDN should not contain NaN.""" + igdn = GDN(inverse=True) + inputs = tf.random.normal(self.shape) + + with tf.GradientTape() as tape: + tape.watch(inputs) + output = igdn(inputs) + loss = tf.reduce_mean(output) + + grad = tape.gradient(loss, inputs) + self.assertFalse(tf.reduce_any(tf.math.is_nan(grad))) + + def test_gamma_symmetry(self): + """Gamma matrix used in GDN should be symmetric after call.""" + gdn = GDN(inverse=False) + inputs = tf.random.normal(self.shape) + _ = gdn(inputs) + + # The effective gamma inside call() is (relu(gamma) + relu(gamma)^T) / 2 + gamma = tf.nn.relu(gdn.gamma) + gamma_sym = (gamma + tf.transpose(gamma)) / 2.0 + self.assertAllClose(gamma_sym, tf.transpose(gamma_sym)) + + +class TestEntropyStability(tf.test.TestCase): + """Tests for entropy model numerical stability.""" + + def test_very_small_scale(self): + """Very small scale should not produce NaN likelihood.""" + inputs = tf.constant([0.0, 1.0, -1.0]) + mean = tf.zeros_like(inputs) + scale = tf.constant([1e-8, 1e-8, 1e-8]) + + ll = _discretized_gaussian_likelihood(inputs, mean, scale) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(ll))) + self.assertAllGreater(ll, 0.0) + + def test_very_large_scale(self): + """Very large scale should not produce NaN likelihood.""" + inputs = tf.constant([0.0, 100.0, -100.0]) + mean = tf.zeros_like(inputs) + scale = tf.constant([1e6, 1e6, 1e6]) + + ll = _discretized_gaussian_likelihood(inputs, mean, scale) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(ll))) + self.assertAllGreater(ll, 0.0) + + def test_very_large_input(self): + """Very large inputs should produce small but non-NaN likelihood.""" + inputs = tf.constant([1000.0, -1000.0]) + mean = tf.zeros_like(inputs) + scale = tf.ones_like(inputs) + + ll = _discretized_gaussian_likelihood(inputs, mean, scale) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(ll))) + # Should be floored at EPSILON + self.assertAllGreaterEqual(ll, float(EPSILON)) + + def test_bits_no_nan_extreme_values(self): + """Bits computation should not produce NaN even for extreme values.""" + from constants import LOG_2_RECIPROCAL + + inputs = tf.constant([0.0, 50.0, -50.0, 1000.0]) + mean = tf.zeros_like(inputs) + scale = tf.ones_like(inputs) + + ll = _discretized_gaussian_likelihood(inputs, mean, scale) + bits = -tf.math.log(ll) * LOG_2_RECIPROCAL + + self.assertFalse(tf.reduce_any(tf.math.is_nan(bits))) + self.assertAllGreaterEqual(bits, 0.0) + + def test_conditional_gaussian_extreme_scale(self): + """ConditionalGaussian should be stable with extreme scales.""" + cg = ConditionalGaussian() + inputs = tf.constant([[[[[1.0, 2.0]]]]]) + mean = tf.zeros_like(inputs) + + for scale_val in [1e-6, 1e-3, 1.0, 1e3, 1e6]: + scale = tf.fill(inputs.shape, scale_val) + out, ll = cg(inputs, scale, mean, training=False) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(out)), + msg=f"NaN output at scale={scale_val}") + self.assertFalse(tf.reduce_any(tf.math.is_nan(ll)), + msg=f"NaN likelihood at scale={scale_val}") + self.assertAllGreater(ll, 0.0) + + def test_patched_gaussian_negative_scale(self): + """PatchedGaussianConditional should handle negative learned scale.""" + pgc = PatchedGaussianConditional() + inputs = tf.constant([[[[[1.0, 2.0, 3.0]]]]]) + pgc.build(inputs.shape) + + # Force negative scale + pgc.scale.assign(-tf.ones_like(pgc.scale)) + + ll = pgc.likelihood(inputs) + self.assertFalse(tf.reduce_any(tf.math.is_nan(ll))) + self.assertAllGreater(ll, 0.0) + + def test_gradient_through_likelihood(self): + """Gradients through discretized likelihood should not be NaN.""" + inputs = tf.Variable(tf.constant([0.0, 1.0, -1.0, 5.0])) + mean = tf.constant([0.0, 0.0, 0.0, 0.0]) + scale = tf.constant([1.0, 1.0, 1.0, 1.0]) + + with tf.GradientTape() as tape: + ll = _discretized_gaussian_likelihood(inputs, mean, scale) + loss = -tf.reduce_sum(tf.math.log(ll)) + + grad = tape.gradient(loss, inputs) + self.assertFalse(tf.reduce_any(tf.math.is_nan(grad))) + + +class TestConstantsCorrectness(tf.test.TestCase): + """Tests that pre-computed constants are correct.""" + + def test_log2_value(self): + """LOG_2 should equal ln(2).""" + from constants import LOG_2 + self.assertAllClose(LOG_2, tf.constant(np.log(2.0), dtype=tf.float32)) + + def test_log2_reciprocal_value(self): + """LOG_2_RECIPROCAL should equal 1/ln(2).""" + from constants import LOG_2_RECIPROCAL + expected = tf.constant(1.0 / np.log(2.0), dtype=tf.float32) + self.assertAllClose(LOG_2_RECIPROCAL, expected) + + def test_log2_reciprocal_identity(self): + """LOG_2 * LOG_2_RECIPROCAL should equal 1.""" + from constants import LOG_2, LOG_2_RECIPROCAL + product = LOG_2 * LOG_2_RECIPROCAL + self.assertAllClose(product, 1.0, atol=1e-6) + + def test_epsilon_positive(self): + """EPSILON should be a small positive value.""" + self.assertGreater(float(EPSILON), 0.0) + self.assertLess(float(EPSILON), 1e-6) + + def test_scale_bounds(self): + """SCALE_MIN < SCALE_MAX.""" + from constants import SCALE_MAX, SCALE_MIN + self.assertLess(float(SCALE_MIN), float(SCALE_MAX)) + + def test_f16_constants_match(self): + """Float16 constants should match float32 values within f16 precision.""" + from constants import LOG_2, LOG_2_F16, LOG_2_RECIPROCAL, LOG_2_RECIPROCAL_F16 + self.assertAllClose( + tf.cast(LOG_2, tf.float16), LOG_2_F16, atol=1e-3 + ) + self.assertAllClose( + tf.cast(LOG_2_RECIPROCAL, tf.float16), LOG_2_RECIPROCAL_F16, atol=1e-3 + ) + + +class TestScaleQuantizationNumerics(tf.test.TestCase): + """Tests for binary search scale quantization edge cases.""" + + @pytest.fixture(autouse=True) + def setup(self): + self.scale_table = tf.constant([0.1, 0.5, 1.0, 2.0, 5.0, 10.0]) + self.pgc = PatchedGaussianConditional(scale_table=self.scale_table) + + def test_exact_table_values_preserved(self): + """Input values exactly matching table entries should be preserved.""" + test_scales = tf.constant([0.1, 0.5, 1.0, 2.0, 5.0, 10.0]) + quantized = self.pgc.quantize_scale(test_scales) + self.assertAllClose(quantized, test_scales) + + def test_negative_scales_made_positive(self): + """Negative scales should be mapped to positive table entries.""" + test_scales = tf.constant([-0.5, -1.0, -2.0]) + quantized = self.pgc.quantize_scale(test_scales) + + self.assertAllGreater(quantized, 0.0) + + def test_below_minimum_clipped(self): + """Scales below table minimum should be clipped to minimum.""" + test_scales = tf.constant([0.001, 0.01, 0.05]) + quantized = self.pgc.quantize_scale(test_scales) + + self.assertAllGreaterEqual(quantized, 0.1) + + def test_above_maximum_clipped(self): + """Scales above table maximum should be clipped to maximum.""" + test_scales = tf.constant([20.0, 100.0, 1000.0]) + quantized = self.pgc.quantize_scale(test_scales) + + self.assertAllLessEqual(quantized, 10.0) + + def test_zero_scale(self): + """Zero scale should not cause errors.""" + test_scales = tf.constant([0.0]) + quantized = self.pgc.quantize_scale(test_scales) + + self.assertFalse(tf.reduce_any(tf.math.is_nan(quantized))) + + +if __name__ == '__main__': + tf.test.main() diff --git a/tests/test_roundtrip.py b/tests/test_roundtrip.py new file mode 100755 index 000000000..7a1aba66a --- /dev/null +++ b/tests/test_roundtrip.py @@ -0,0 +1,326 @@ +""" +Tests for end-to-end compress/decompress roundtrip consistency. + +Validates that DeepCompressModel and DeepCompressModelV2 produce correct +output shapes, bounded values, and deterministic inference across entropy +model configurations. +""" + +import sys +from pathlib import Path + +import pytest +import tensorflow as tf + +sys.path.insert(0, str(Path(__file__).parent.parent / 'src')) + +from model_transforms import DeepCompressModel, DeepCompressModelV2, TransformConfig +from test_utils import create_mock_voxel_grid + +# Standard small config for all roundtrip tests +_SMALL_CONFIG = TransformConfig( + filters=32, + kernel_size=(3, 3, 3), + strides=(1, 1, 1), + activation='relu', + conv_type='standard' +) + +_RESOLUTION = 16 +_BATCH_SIZE = 1 + + +class TestDeepCompressModelV1Roundtrip(tf.test.TestCase): + """Roundtrip tests for V1 model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.model = DeepCompressModel(_SMALL_CONFIG) + self.input_tensor = create_mock_voxel_grid(_RESOLUTION, _BATCH_SIZE) + + def test_output_shape_matches_input(self): + """x_hat should have same shape as input.""" + x_hat, y, z_hat, z_noisy = self.model(self.input_tensor, training=False) + self.assertEqual(x_hat.shape, self.input_tensor.shape) + + def test_output_bounded_zero_one(self): + """x_hat should be in [0, 1] (sigmoid output).""" + x_hat, _, _, _ = self.model(self.input_tensor, training=False) + self.assertAllGreaterEqual(x_hat, 0.0) + self.assertAllLessEqual(x_hat, 1.0) + + def test_inference_deterministic(self): + """Inference should be deterministic (tf.round, no noise).""" + out1 = self.model(self.input_tensor, training=False) + out2 = self.model(self.input_tensor, training=False) + + self.assertAllClose(out1[0], out2[0]) # x_hat + self.assertAllClose(out1[1], out2[1]) # y + self.assertAllClose(out1[2], out2[2]) # z_hat + self.assertAllClose(out1[3], out2[3]) # z_noisy (rounded) + + def test_training_stochastic(self): + """Training should be stochastic (uniform noise).""" + out1 = self.model(self.input_tensor, training=True) + out2 = self.model(self.input_tensor, training=True) + + # y_hat values should differ due to noise (check z_noisy) + diff = tf.reduce_sum(tf.abs(out1[3] - out2[3])) + self.assertGreater(float(diff), 0.0) + + def test_latent_has_channels(self): + """Latent y should have more channels than input.""" + _, y, _, _ = self.model(self.input_tensor, training=False) + self.assertGreater(y.shape[-1], self.input_tensor.shape[-1]) + + def test_returns_four_values(self): + """V1 model should return exactly 4 values.""" + outputs = self.model(self.input_tensor, training=False) + self.assertEqual(len(outputs), 4) + + +class TestDeepCompressModelV2Gaussian(tf.test.TestCase): + """Roundtrip tests for V2 model with gaussian entropy model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.input_tensor = create_mock_voxel_grid(_RESOLUTION, _BATCH_SIZE) + self.model = DeepCompressModelV2( + _SMALL_CONFIG, entropy_model='gaussian', + num_channel_groups=4, num_attention_layers=1 + ) + + def test_output_shape(self): + """x_hat should match input shape.""" + x_hat, y, y_hat, z, rate_info = self.model(self.input_tensor, training=False) + self.assertEqual(x_hat.shape, self.input_tensor.shape) + + def test_output_bounded(self): + """x_hat should be in [0, 1].""" + x_hat, _, _, _, _ = self.model(self.input_tensor, training=False) + self.assertAllGreaterEqual(x_hat, 0.0) + self.assertAllLessEqual(x_hat, 1.0) + + def test_returns_five_values(self): + """V2 model should return exactly 5 values.""" + outputs = self.model(self.input_tensor, training=False) + self.assertEqual(len(outputs), 5) + + def test_rate_info_keys(self): + """rate_info should contain required keys.""" + _, _, _, _, rate_info = self.model(self.input_tensor, training=False) + for key in ['likelihood', 'total_bits', 'y_bits', 'z_bits', 'bpp']: + self.assertIn(key, rate_info, msg=f"Missing key: {key}") + + def test_total_bits_positive(self): + """Total bits should be positive.""" + _, _, _, _, rate_info = self.model(self.input_tensor, training=False) + self.assertGreater(float(rate_info['total_bits']), 0.0) + + def test_bpp_positive(self): + """Bits per point should be positive.""" + _, _, _, _, rate_info = self.model(self.input_tensor, training=False) + self.assertGreater(float(rate_info['bpp']), 0.0) + + def test_inference_deterministic(self): + """Inference should be deterministic.""" + out1 = self.model(self.input_tensor, training=False) + out2 = self.model(self.input_tensor, training=False) + self.assertAllClose(out1[0], out2[0]) + + +class TestDeepCompressModelV2Hyperprior(tf.test.TestCase): + """Roundtrip tests for V2 model with hyperprior entropy model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.input_tensor = create_mock_voxel_grid(_RESOLUTION, _BATCH_SIZE) + self.model = DeepCompressModelV2( + _SMALL_CONFIG, entropy_model='hyperprior', + num_channel_groups=4, num_attention_layers=1 + ) + + def test_output_shape(self): + """x_hat should match input shape.""" + x_hat, y, y_hat, z, rate_info = self.model(self.input_tensor, training=False) + self.assertEqual(x_hat.shape, self.input_tensor.shape) + + def test_output_bounded(self): + """x_hat should be in [0, 1].""" + x_hat, _, _, _, _ = self.model(self.input_tensor, training=False) + self.assertAllGreaterEqual(x_hat, 0.0) + self.assertAllLessEqual(x_hat, 1.0) + + def test_returns_five_values(self): + """V2 model should return exactly 5 values.""" + outputs = self.model(self.input_tensor, training=False) + self.assertEqual(len(outputs), 5) + + def test_rate_info_keys(self): + """rate_info should contain required keys.""" + _, _, _, _, rate_info = self.model(self.input_tensor, training=False) + for key in ['likelihood', 'total_bits', 'y_bits', 'z_bits', 'bpp']: + self.assertIn(key, rate_info, msg=f"Missing key: {key}") + + def test_total_bits_positive(self): + """Total bits should be positive.""" + _, _, _, _, rate_info = self.model(self.input_tensor, training=False) + self.assertGreater(float(rate_info['total_bits']), 0.0) + + def test_inference_deterministic(self): + """Inference should be deterministic.""" + out1 = self.model(self.input_tensor, training=False) + out2 = self.model(self.input_tensor, training=False) + self.assertAllClose(out1[0], out2[0]) + + +class TestV2CompressDecompressGaussian(tf.test.TestCase): + """Tests for V2 compress/decompress path with gaussian model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.input_tensor = create_mock_voxel_grid(_RESOLUTION, _BATCH_SIZE) + self.model = DeepCompressModelV2(_SMALL_CONFIG, entropy_model='gaussian') + _ = self.model(self.input_tensor, training=False) # build + + def test_compress_returns_dict(self): + """compress() should return a dict with 'y', 'z', 'side_info'.""" + compressed = self.model.compress(self.input_tensor) + self.assertIn('y', compressed) + self.assertIn('z', compressed) + self.assertIn('side_info', compressed) + + def test_decompress_shape(self): + """decompress() output should match input shape.""" + compressed = self.model.compress(self.input_tensor) + x_hat = self.model.decompress(compressed) + self.assertEqual(x_hat.shape, self.input_tensor.shape) + + def test_decompress_bounded(self): + """Decompressed output should be in [0, 1].""" + compressed = self.model.compress(self.input_tensor) + x_hat = self.model.decompress(compressed) + self.assertAllGreaterEqual(x_hat, 0.0) + self.assertAllLessEqual(x_hat, 1.0) + + def test_compress_decompress_deterministic(self): + """Compress + decompress should be deterministic.""" + compressed1 = self.model.compress(self.input_tensor) + x_hat1 = self.model.decompress(compressed1) + compressed2 = self.model.compress(self.input_tensor) + x_hat2 = self.model.decompress(compressed2) + self.assertAllClose(x_hat1, x_hat2) + + +class TestV2CompressDecompressHyperprior(tf.test.TestCase): + """Tests for V2 compress/decompress path with hyperprior model.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.input_tensor = create_mock_voxel_grid(_RESOLUTION, _BATCH_SIZE) + self.model = DeepCompressModelV2(_SMALL_CONFIG, entropy_model='hyperprior') + _ = self.model(self.input_tensor, training=False) # build + + def test_compress_returns_dict(self): + """compress() should return a dict with 'y', 'z', 'side_info'.""" + compressed = self.model.compress(self.input_tensor) + self.assertIn('y', compressed) + self.assertIn('z', compressed) + self.assertIn('side_info', compressed) + + def test_decompress_shape(self): + """decompress() output should match input shape.""" + compressed = self.model.compress(self.input_tensor) + x_hat = self.model.decompress(compressed) + self.assertEqual(x_hat.shape, self.input_tensor.shape) + + def test_decompress_bounded(self): + """Decompressed output should be in [0, 1].""" + compressed = self.model.compress(self.input_tensor) + x_hat = self.model.decompress(compressed) + self.assertAllGreaterEqual(x_hat, 0.0) + self.assertAllLessEqual(x_hat, 1.0) + + def test_compress_decompress_deterministic(self): + """Compress + decompress should be deterministic.""" + compressed1 = self.model.compress(self.input_tensor) + x_hat1 = self.model.decompress(compressed1) + compressed2 = self.model.compress(self.input_tensor) + x_hat2 = self.model.decompress(compressed2) + self.assertAllClose(x_hat1, x_hat2) + + +class TestGradientFlow(tf.test.TestCase): + """Tests that gradients flow through the model during training.""" + + @pytest.fixture(autouse=True) + def setup(self): + tf.random.set_seed(42) + self.input_tensor = create_mock_voxel_grid(_RESOLUTION, _BATCH_SIZE) + + def test_v1_gradients_flow(self): + """V1 model should produce non-zero gradients.""" + model = DeepCompressModel(_SMALL_CONFIG) + + with tf.GradientTape() as tape: + x_hat, y, z_hat, z_noisy = model(self.input_tensor, training=True) + loss = tf.reduce_mean(tf.square(self.input_tensor - x_hat)) + + grads = tape.gradient(loss, model.trainable_variables) + non_none = [g for g in grads if g is not None] + + self.assertNotEmpty(non_none, "No gradients computed") + total_grad_norm = sum(float(tf.reduce_sum(tf.abs(g))) for g in non_none) + self.assertGreater(total_grad_norm, 0.0) + + def test_v2_gaussian_gradients_flow(self): + """V2 gaussian model should produce non-zero gradients.""" + model = DeepCompressModelV2(_SMALL_CONFIG, entropy_model='gaussian') + + with tf.GradientTape() as tape: + x_hat, y, y_hat, z, rate_info = model(self.input_tensor, training=True) + loss = tf.reduce_mean(tf.square(self.input_tensor - x_hat)) + + grads = tape.gradient(loss, model.trainable_variables) + non_none = [g for g in grads if g is not None] + + self.assertNotEmpty(non_none, "No gradients computed") + + def test_v2_hyperprior_gradients_flow(self): + """V2 hyperprior model should produce non-zero gradients.""" + model = DeepCompressModelV2(_SMALL_CONFIG, entropy_model='hyperprior') + + with tf.GradientTape() as tape: + x_hat, y, y_hat, z, rate_info = model(self.input_tensor, training=True) + distortion = tf.reduce_mean(tf.square(self.input_tensor - x_hat)) + rate = rate_info['total_bits'] + loss = distortion + 0.01 * rate + + grads = tape.gradient(loss, model.trainable_variables) + non_none = [g for g in grads if g is not None] + + self.assertNotEmpty(non_none, "No gradients computed") + + +class TestInvalidEntropyModel(tf.test.TestCase): + """Tests for invalid entropy model selection.""" + + def test_invalid_entropy_model_raises(self): + """Invalid entropy model string should raise ValueError.""" + with self.assertRaises(ValueError): + DeepCompressModelV2(_SMALL_CONFIG, entropy_model='invalid') + + def test_valid_entropy_models_accepted(self): + """All valid entropy model strings should be accepted.""" + for name in DeepCompressModelV2.ENTROPY_MODELS: + model = DeepCompressModelV2(_SMALL_CONFIG, entropy_model=name) + self.assertEqual(model.entropy_model_type, name) + + +if __name__ == '__main__': + tf.test.main()