gsplat 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +202 -0
- data/README.md +236 -0
- data/docs/ACCEPTANCE.md +60 -0
- data/docs/BENCHMARKS.md +79 -0
- data/docs/DECISIONS.md +52 -0
- data/docs/MIGRATION.md +117 -0
- data/docs/PROFILE.md +75 -0
- data/docs/PROGRESS.md +199 -0
- data/docs/decisions/0000-template.md +17 -0
- data/docs/decisions/0001-pin-golden-reference-and-cpu-validation.md +27 -0
- data/docs/decisions/0002-retain-ruby-fallbacks-for-native-backend.md +26 -0
- data/docs/decisions/0003-share-projection-for-distorted-cameras.md +26 -0
- data/docs/decisions/0004-use-portable-world-space-reference-paths.md +26 -0
- data/docs/decisions/0005-reuse-ewa-core-for-2dgs.md +24 -0
- data/docs/decisions/0006-keep-eval3d-as-portable-reference.md +25 -0
- data/docs/decisions/0007-share-compositor-semantics-for-contribution-indices.md +25 -0
- data/examples/data/README.md +14 -0
- data/examples/data/colmap/images/view_000.png +0 -0
- data/examples/data/colmap/images/view_001.png +0 -0
- data/examples/data/colmap/images/view_002.png +0 -0
- data/examples/data/colmap/sparse/0/cameras.txt +2 -0
- data/examples/data/colmap/sparse/0/images.txt +6 -0
- data/examples/data/colmap/sparse/0/points3D.txt +17 -0
- data/examples/data/splats.ply +0 -0
- data/examples/fit_image.rb +35 -0
- data/examples/generate_sample_data.rb +147 -0
- data/examples/render_path.rb +117 -0
- data/examples/simple_trainer.rb +76 -0
- data/ext/gsplat_native/common.h +54 -0
- data/ext/gsplat_native/extconf.rb +39 -0
- data/ext/gsplat_native/gsplat_native.c +60 -0
- data/ext/gsplat_native/intersections.c +211 -0
- data/ext/gsplat_native/projection.c +199 -0
- data/ext/gsplat_native/raster_backward.c +129 -0
- data/ext/gsplat_native/raster_backward_bridge.c +69 -0
- data/ext/gsplat_native/raster_forward.c +150 -0
- data/ext/gsplat_native/rasterization.h +51 -0
- data/ext/gsplat_native/spherical_harmonics.c +129 -0
- data/gsplat.gemspec +36 -0
- data/lib/gsplat/autograd/context.rb +60 -0
- data/lib/gsplat/autograd/function.rb +68 -0
- data/lib/gsplat/autograd/variable.rb +159 -0
- data/lib/gsplat/backend/ruby/accumulate.rb +139 -0
- data/lib/gsplat/backend/ruby/accumulate_backward.rb +40 -0
- data/lib/gsplat/backend/ruby/eval3d_rasterizer.rb +175 -0
- data/lib/gsplat/backend/ruby/isect_tiles.rb +198 -0
- data/lib/gsplat/backend/ruby/projection.rb +251 -0
- data/lib/gsplat/backend/ruby/projection_backward.rb +190 -0
- data/lib/gsplat/backend/ruby/projection_covariance_vjp.rb +72 -0
- data/lib/gsplat/backend/ruby/projection_input_vjp.rb +252 -0
- data/lib/gsplat/backend/ruby/quat_scale_to_covar_preci.rb +139 -0
- data/lib/gsplat/backend/ruby/rasterize_to_indices_in_range.rb +121 -0
- data/lib/gsplat/backend/ruby/rasterize_to_pixels.rb +199 -0
- data/lib/gsplat/backend/ruby/rasterize_to_pixels_backward.rb +121 -0
- data/lib/gsplat/backend/ruby/spherical_harmonics.rb +135 -0
- data/lib/gsplat/backend/ruby/tile_compositor.rb +50 -0
- data/lib/gsplat/backend/ruby/tile_compositor_backward.rb +84 -0
- data/lib/gsplat/backend.rb +79 -0
- data/lib/gsplat/compression/grid_sort.rb +79 -0
- data/lib/gsplat/compression/kmeans.rb +121 -0
- data/lib/gsplat/compression/png.rb +147 -0
- data/lib/gsplat/compression/png_codec.rb +133 -0
- data/lib/gsplat/compression/quantizer.rb +79 -0
- data/lib/gsplat/io/checkpoint.rb +145 -0
- data/lib/gsplat/io/colmap.rb +175 -0
- data/lib/gsplat/io/colmap_binary.rb +98 -0
- data/lib/gsplat/io/colmap_text.rb +84 -0
- data/lib/gsplat/io/image.rb +63 -0
- data/lib/gsplat/io/image_backends.rb +81 -0
- data/lib/gsplat/io/npy.rb +189 -0
- data/lib/gsplat/io/ply.rb +185 -0
- data/lib/gsplat/io/ply_reader.rb +142 -0
- data/lib/gsplat/io/zip_archive.rb +183 -0
- data/lib/gsplat/math/camera_distortion.rb +123 -0
- data/lib/gsplat/math/camera_projection.rb +202 -0
- data/lib/gsplat/math/mat.rb +114 -0
- data/lib/gsplat/math/quaternion.rb +175 -0
- data/lib/gsplat/math/small_matrix_primitives.rb +112 -0
- data/lib/gsplat/math/spherical_harmonic_basis.rb +148 -0
- data/lib/gsplat/math/ssim.rb +153 -0
- data/lib/gsplat/native.rb +30 -0
- data/lib/gsplat/native_ops.rb +148 -0
- data/lib/gsplat/native_raster_ops.rb +76 -0
- data/lib/gsplat/ops/accumulate.rb +68 -0
- data/lib/gsplat/ops/eval3d_rasterize.rb +62 -0
- data/lib/gsplat/ops/isect_tiles.rb +38 -0
- data/lib/gsplat/ops/projection.rb +154 -0
- data/lib/gsplat/ops/quat_scale_to_covar_preci.rb +118 -0
- data/lib/gsplat/ops/rasterize_to_indices_in_range.rb +38 -0
- data/lib/gsplat/ops/rasterize_to_pixels.rb +105 -0
- data/lib/gsplat/ops/relocation.rb +94 -0
- data/lib/gsplat/ops/spherical_harmonics.rb +71 -0
- data/lib/gsplat/ops/tensor_shape_ops.rb +173 -0
- data/lib/gsplat/ops/tensor_value_ops.rb +150 -0
- data/lib/gsplat/optim/adam.rb +212 -0
- data/lib/gsplat/optim/lr_scheduler.rb +36 -0
- data/lib/gsplat/optim/selective_adam.rb +68 -0
- data/lib/gsplat/rasterization.rb +130 -0
- data/lib/gsplat/rasterization_2dgs.rb +140 -0
- data/lib/gsplat/rasterization_helpers.rb +162 -0
- data/lib/gsplat/rasterization_validation.rb +99 -0
- data/lib/gsplat/strategy/base.rb +49 -0
- data/lib/gsplat/strategy/default.rb +188 -0
- data/lib/gsplat/strategy/mcmc.rb +103 -0
- data/lib/gsplat/strategy/mcmc_ops.rb +143 -0
- data/lib/gsplat/strategy/ops.rb +165 -0
- data/lib/gsplat/training/config.rb +91 -0
- data/lib/gsplat/training/image_fitter.rb +158 -0
- data/lib/gsplat/training/losses.rb +191 -0
- data/lib/gsplat/training/scene.rb +116 -0
- data/lib/gsplat/training/trainer.rb +236 -0
- data/lib/gsplat/utils.rb +110 -0
- data/lib/gsplat/version.rb +6 -0
- data/lib/gsplat.rb +98 -0
- metadata +181 -0
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Compression
|
|
5
|
+
# Deterministic Morton ordering for square Gaussian parameter grids.
|
|
6
|
+
module GridSort
|
|
7
|
+
QUANTIZATION_MAX = 1023
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def prepare(parameters, use_sort: true)
|
|
12
|
+
values = parameters.to_h { |name, value| [name.to_s, data(value).dup] }
|
|
13
|
+
validate_lengths!(values)
|
|
14
|
+
side = ::Math.sqrt(values.fetch("means").shape[0]).floor
|
|
15
|
+
raise ArgumentError, "at least one Gaussian is required" if side.zero?
|
|
16
|
+
|
|
17
|
+
values = crop_to_square(values, side * side)
|
|
18
|
+
values = reorder(values, morton_order(values.fetch("means"))) if use_sort
|
|
19
|
+
[values, side]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def crop_to_square(values, count)
|
|
23
|
+
return values if values.fetch("means").shape[0] == count
|
|
24
|
+
|
|
25
|
+
opacities = values.fetch("opacities").reshape(values.fetch("opacities").shape[0])
|
|
26
|
+
keep = (0...opacities.size).sort_by { |index| [-opacities[index].to_f, index] }.first(count)
|
|
27
|
+
reorder(values, keep)
|
|
28
|
+
end
|
|
29
|
+
private_class_method :crop_to_square
|
|
30
|
+
|
|
31
|
+
def morton_order(means)
|
|
32
|
+
coordinates = means.to_a
|
|
33
|
+
mins = 3.times.map { |axis| coordinates.map { |point| point[axis] }.min }
|
|
34
|
+
maxs = 3.times.map { |axis| coordinates.map { |point| point[axis] }.max }
|
|
35
|
+
(0...coordinates.length).sort_by do |index|
|
|
36
|
+
quantized = 3.times.map do |axis|
|
|
37
|
+
span = maxs[axis] - mins[axis]
|
|
38
|
+
span.zero? ? 0 : (((coordinates[index][axis] - mins[axis]) / span) * QUANTIZATION_MAX).round
|
|
39
|
+
end
|
|
40
|
+
[morton3(*quantized), index]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def morton3(x_coord, y_coord, z_coord)
|
|
45
|
+
code = 0
|
|
46
|
+
10.times do |bit|
|
|
47
|
+
code |= ((x_coord >> bit) & 1) << (3 * bit)
|
|
48
|
+
code |= ((y_coord >> bit) & 1) << ((3 * bit) + 1)
|
|
49
|
+
code |= ((z_coord >> bit) & 1) << ((3 * bit) + 2)
|
|
50
|
+
end
|
|
51
|
+
code
|
|
52
|
+
end
|
|
53
|
+
private_class_method :morton3
|
|
54
|
+
|
|
55
|
+
def reorder(values, order)
|
|
56
|
+
indices = Numo::Int32.cast(order)
|
|
57
|
+
values.transform_values { |value| value[indices, *Array.new(value.ndim - 1, true)].dup }
|
|
58
|
+
end
|
|
59
|
+
private_class_method :reorder
|
|
60
|
+
|
|
61
|
+
def validate_lengths!(values)
|
|
62
|
+
required = %w[means scales quats opacities sh0 shN]
|
|
63
|
+
missing = required - values.keys
|
|
64
|
+
raise ArgumentError, "missing compression parameters: #{missing.join(', ')}" unless missing.empty?
|
|
65
|
+
|
|
66
|
+
count = values.fetch("means").shape[0]
|
|
67
|
+
invalid = values.find { |_name, value| !value.is_a?(Numo::NArray) || value.shape[0] != count }
|
|
68
|
+
raise ShapeError, "all compression parameters must share first dimension #{count}" if invalid
|
|
69
|
+
raise ShapeError, "expected means [N,3]" unless values.fetch("means").shape == [count, 3]
|
|
70
|
+
end
|
|
71
|
+
private_class_method :validate_lengths!
|
|
72
|
+
|
|
73
|
+
def data(value)
|
|
74
|
+
value.is_a?(Autograd::Variable) ? value.data : value
|
|
75
|
+
end
|
|
76
|
+
private_class_method :data
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Compression
|
|
5
|
+
# Deterministic Manhattan K-means for SH coefficient vectors.
|
|
6
|
+
module KMeans
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def compress(path, array, clusters:, iterations:, quantization:)
|
|
10
|
+
shape = array.shape
|
|
11
|
+
return empty_metadata(array, quantization) if array.empty?
|
|
12
|
+
|
|
13
|
+
vectors = array.reshape(shape[0], array.size / shape[0]).to_a
|
|
14
|
+
cluster_count = [clusters, vectors.length, 65_536].min
|
|
15
|
+
centroids, labels = cluster(vectors, cluster_count, iterations)
|
|
16
|
+
quantized, minimum, maximum = quantize_centroids(centroids, quantization)
|
|
17
|
+
IO::Npy.write_npz(
|
|
18
|
+
path,
|
|
19
|
+
{
|
|
20
|
+
centroids: Numo::UInt8.cast(quantized).reshape(cluster_count, vectors.first.length),
|
|
21
|
+
labels: Numo::UInt16.cast(labels)
|
|
22
|
+
}
|
|
23
|
+
)
|
|
24
|
+
{
|
|
25
|
+
"shape" => shape,
|
|
26
|
+
"dtype" => Quantizer.dtype_name(array.class),
|
|
27
|
+
"mins" => minimum,
|
|
28
|
+
"maxs" => maximum,
|
|
29
|
+
"quantization" => quantization,
|
|
30
|
+
"clusters" => cluster_count
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def decompress(path, metadata)
|
|
35
|
+
shape = metadata.fetch("shape")
|
|
36
|
+
return Quantizer.numeric_type(metadata.fetch("dtype")).zeros(*shape) if shape.inject(1, :*).zero?
|
|
37
|
+
|
|
38
|
+
archive = IO::Npy.read_npz(path)
|
|
39
|
+
centroids = decode_centroids(archive.fetch("centroids"), shape, metadata)
|
|
40
|
+
values = archive.fetch("labels").to_a.map { |label| centroids.fetch(label) }.flatten
|
|
41
|
+
Quantizer.numeric_type(metadata.fetch("dtype")).cast(values).reshape(*shape)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def decode_centroids(values, shape, metadata)
|
|
45
|
+
limit = (1 << metadata.fetch("quantization")) - 1
|
|
46
|
+
minimum = metadata.fetch("mins")
|
|
47
|
+
span = metadata.fetch("maxs") - minimum
|
|
48
|
+
feature_count = shape.drop(1).inject(1, :*)
|
|
49
|
+
values.to_a.flatten.each_slice(feature_count).map do |row|
|
|
50
|
+
row.map { |value| minimum + ((value.to_f / limit) * span) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
private_class_method :decode_centroids
|
|
54
|
+
|
|
55
|
+
def cluster(vectors, count, iterations)
|
|
56
|
+
return [vectors.map(&:dup), (0...vectors.length).to_a] if count == vectors.length
|
|
57
|
+
|
|
58
|
+
centroids = Array.new(count) do |index|
|
|
59
|
+
vectors[(index * (vectors.length - 1).fdiv([count - 1, 1].max)).round].dup
|
|
60
|
+
end
|
|
61
|
+
labels = Array.new(vectors.length, 0)
|
|
62
|
+
iterations.times do
|
|
63
|
+
changed = assign!(vectors, centroids, labels)
|
|
64
|
+
update!(vectors, centroids, labels)
|
|
65
|
+
break unless changed
|
|
66
|
+
end
|
|
67
|
+
[centroids, labels]
|
|
68
|
+
end
|
|
69
|
+
private_class_method :cluster
|
|
70
|
+
|
|
71
|
+
def assign!(vectors, centroids, labels)
|
|
72
|
+
changed = false
|
|
73
|
+
vectors.each_with_index do |vector, index|
|
|
74
|
+
label = centroids.each_index.min_by do |centroid_index|
|
|
75
|
+
vector.zip(centroids[centroid_index]).sum { |left, right| (left - right).abs }
|
|
76
|
+
end
|
|
77
|
+
changed ||= labels[index] != label
|
|
78
|
+
labels[index] = label
|
|
79
|
+
end
|
|
80
|
+
changed
|
|
81
|
+
end
|
|
82
|
+
private_class_method :assign!
|
|
83
|
+
|
|
84
|
+
def update!(vectors, centroids, labels)
|
|
85
|
+
sums = centroids.map { |centroid| Array.new(centroid.length, 0.0) }
|
|
86
|
+
counts = Array.new(centroids.length, 0)
|
|
87
|
+
vectors.each_with_index do |vector, index|
|
|
88
|
+
label = labels[index]
|
|
89
|
+
counts[label] += 1
|
|
90
|
+
vector.each_with_index { |value, feature| sums[label][feature] += value }
|
|
91
|
+
end
|
|
92
|
+
centroids.each_index do |index|
|
|
93
|
+
next if counts[index].zero?
|
|
94
|
+
|
|
95
|
+
centroids[index] = sums[index].map { |value| value / counts[index] }
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
private_class_method :update!
|
|
99
|
+
|
|
100
|
+
def quantize_centroids(centroids, bits)
|
|
101
|
+
values = centroids.flatten
|
|
102
|
+
minimum, maximum = values.minmax
|
|
103
|
+
limit = (1 << bits) - 1
|
|
104
|
+
span = maximum - minimum
|
|
105
|
+
quantized = values.map { |value| span.zero? ? 0 : (((value - minimum) / span) * limit).round }
|
|
106
|
+
[quantized, minimum, maximum]
|
|
107
|
+
end
|
|
108
|
+
private_class_method :quantize_centroids
|
|
109
|
+
|
|
110
|
+
def empty_metadata(array, quantization)
|
|
111
|
+
{
|
|
112
|
+
"shape" => array.shape,
|
|
113
|
+
"dtype" => Quantizer.dtype_name(array.class),
|
|
114
|
+
"quantization" => quantization,
|
|
115
|
+
"empty" => true
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
private_class_method :empty_metadata
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "grid_sort"
|
|
7
|
+
require_relative "kmeans"
|
|
8
|
+
require_relative "png_codec"
|
|
9
|
+
require_relative "quantizer"
|
|
10
|
+
|
|
11
|
+
module Gsplat
|
|
12
|
+
# Parameter codecs for compact Gaussian model storage.
|
|
13
|
+
module Compression
|
|
14
|
+
# Self-contained PNG and K-means Gaussian parameter compression.
|
|
15
|
+
class Png
|
|
16
|
+
# Parameter names encoded as 8-bit PNG images.
|
|
17
|
+
PNG8_PARAMETERS = %w[scales quats opacities sh0].freeze
|
|
18
|
+
|
|
19
|
+
attr_reader :use_sort, :kmeans_clusters, :kmeans_iterations, :sh_quantization
|
|
20
|
+
|
|
21
|
+
def initialize(use_sort: true, kmeans_clusters: 256, kmeans_iterations: 8, sh_quantization: 6)
|
|
22
|
+
@use_sort = use_sort
|
|
23
|
+
@kmeans_clusters = Integer(kmeans_clusters)
|
|
24
|
+
@kmeans_iterations = Integer(kmeans_iterations)
|
|
25
|
+
@sh_quantization = Integer(sh_quantization)
|
|
26
|
+
validate_options!
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Compresses Gaussian arrays into an upstream-compatible directory.
|
|
30
|
+
#
|
|
31
|
+
# @param directory [String] output directory
|
|
32
|
+
# @param parameters [Hash{String, Symbol=>Numo::NArray}] arrays with a shared first dimension
|
|
33
|
+
# @return [String] the output directory
|
|
34
|
+
def compress(directory, parameters)
|
|
35
|
+
FileUtils.mkdir_p(directory)
|
|
36
|
+
values, side = GridSort.prepare(parameters, use_sort: use_sort)
|
|
37
|
+
values["means"] = log_transform(values.fetch("means"))
|
|
38
|
+
values["quats"] = Math::Quaternion.normalize(values.fetch("quats"))
|
|
39
|
+
metadata = values.to_h do |name, value|
|
|
40
|
+
validate_name!(name)
|
|
41
|
+
[name, compress_parameter(directory, name, value, side)]
|
|
42
|
+
end
|
|
43
|
+
File.write(File.join(directory, "meta.json"), "#{JSON.pretty_generate(metadata)}\n")
|
|
44
|
+
directory
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Restores Gaussian arrays from a compressed directory.
|
|
48
|
+
#
|
|
49
|
+
# @param directory [String] directory produced by {#compress}
|
|
50
|
+
# @return [Hash{Symbol=>Numo::NArray}]
|
|
51
|
+
def decompress(directory)
|
|
52
|
+
metadata = JSON.parse(File.read(File.join(directory, "meta.json")))
|
|
53
|
+
values = metadata.to_h do |name, entry|
|
|
54
|
+
validate_name!(name)
|
|
55
|
+
[name.to_sym, decompress_parameter(directory, name, entry)]
|
|
56
|
+
end
|
|
57
|
+
values[:means] = inverse_log_transform(values.fetch(:means))
|
|
58
|
+
values
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
def compress_parameter(directory, name, value, side)
|
|
64
|
+
return compress_png16(directory, name, value, side) if name == "means"
|
|
65
|
+
return compress_png8(directory, name, value, side) if PNG8_PARAMETERS.include?(name)
|
|
66
|
+
if name == "shN"
|
|
67
|
+
return KMeans.compress(
|
|
68
|
+
File.join(directory, "shN.npz"),
|
|
69
|
+
value,
|
|
70
|
+
clusters: kmeans_clusters,
|
|
71
|
+
iterations: kmeans_iterations,
|
|
72
|
+
quantization: sh_quantization
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
IO::Npy.write_npz(File.join(directory, "#{name}.npz"), { arr: value })
|
|
77
|
+
{ "shape" => value.shape, "dtype" => Quantizer.dtype_name(value.class), "codec" => "npz" }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def decompress_parameter(directory, name, metadata)
|
|
81
|
+
return decompress_png16(directory, name, metadata) if name == "means"
|
|
82
|
+
return decompress_png8(directory, name, metadata) if PNG8_PARAMETERS.include?(name)
|
|
83
|
+
return KMeans.decompress(File.join(directory, "shN.npz"), metadata) if name == "shN"
|
|
84
|
+
|
|
85
|
+
IO::Npy.read_npz(File.join(directory, "#{name}.npz")).fetch("arr")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def compress_png8(directory, name, value, side)
|
|
89
|
+
pixels, metadata = Quantizer.encode(value, side, bits: 8)
|
|
90
|
+
PngCodec.write(File.join(directory, "#{name}.png"), pixels)
|
|
91
|
+
metadata
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def decompress_png8(directory, name, metadata)
|
|
95
|
+
Quantizer.decode(PngCodec.read(File.join(directory, "#{name}.png")), metadata)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def compress_png16(directory, name, value, side)
|
|
99
|
+
pixels, metadata = Quantizer.encode(value, side, bits: 16)
|
|
100
|
+
shape = pixels.shape
|
|
101
|
+
values = pixels.to_a.flatten
|
|
102
|
+
lower = Numo::UInt8.cast(values.map { |entry| entry & 0xff }).reshape(*shape)
|
|
103
|
+
upper = Numo::UInt8.cast(values.map { |entry| entry >> 8 }).reshape(*shape)
|
|
104
|
+
PngCodec.write(File.join(directory, "#{name}_l.png"), lower)
|
|
105
|
+
PngCodec.write(File.join(directory, "#{name}_u.png"), upper)
|
|
106
|
+
metadata
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def decompress_png16(directory, name, metadata)
|
|
110
|
+
lower = PngCodec.read(File.join(directory, "#{name}_l.png")).to_a.flatten
|
|
111
|
+
upper = PngCodec.read(File.join(directory, "#{name}_u.png")).to_a.flatten
|
|
112
|
+
side = ::Math.sqrt(metadata.fetch("shape").first).to_i
|
|
113
|
+
shape = [side, side, metadata.fetch("shape").drop(1).inject(1, :*)]
|
|
114
|
+
pixels = Numo::UInt16.cast(
|
|
115
|
+
lower.zip(upper).map { |low, high| low | (high << 8) }
|
|
116
|
+
).reshape(*shape)
|
|
117
|
+
Quantizer.decode(pixels, metadata)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def log_transform(values)
|
|
121
|
+
output = Numo::NMath.log(1 + values.abs)
|
|
122
|
+
negative = values.lt(0)
|
|
123
|
+
output[negative] *= -1 if negative.any?
|
|
124
|
+
output
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def inverse_log_transform(values)
|
|
128
|
+
output = Numo::NMath.exp(values.abs) - 1
|
|
129
|
+
negative = values.lt(0)
|
|
130
|
+
output[negative] *= -1 if negative.any?
|
|
131
|
+
output
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def validate_name!(name)
|
|
135
|
+
return if name.match?(/\A[a-zA-Z][a-zA-Z0-9_]*\z/)
|
|
136
|
+
|
|
137
|
+
raise ArgumentError, "invalid compression parameter name #{name.inspect}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def validate_options!
|
|
141
|
+
raise ArgumentError, "kmeans_clusters must be between 1 and 65536" unless (1..65_536).cover?(kmeans_clusters)
|
|
142
|
+
raise ArgumentError, "kmeans_iterations must be positive" unless kmeans_iterations.positive?
|
|
143
|
+
raise ArgumentError, "sh_quantization must be between 1 and 8" unless (1..8).cover?(sh_quantization)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Gsplat
|
|
6
|
+
module Compression
|
|
7
|
+
# Dependency-free PNG codec for 8-bit parameter grids.
|
|
8
|
+
module PngCodec
|
|
9
|
+
SIGNATURE = "\x89PNG\r\n\x1A\n".b
|
|
10
|
+
COLOR_TYPES = { 1 => 0, 2 => 4, 3 => 2, 4 => 6 }.freeze
|
|
11
|
+
CHANNELS = COLOR_TYPES.invert.freeze
|
|
12
|
+
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
def write(path, pixels)
|
|
16
|
+
validate_pixels!(pixels)
|
|
17
|
+
height, width, channels = pixels.shape
|
|
18
|
+
rows = pixels.to_a.map { |row| "\0".b + row.flatten.pack("C*") }.join
|
|
19
|
+
header = [width, height, 8, COLOR_TYPES.fetch(channels), 0, 0, 0].pack("NNC5")
|
|
20
|
+
File.binwrite(
|
|
21
|
+
path,
|
|
22
|
+
SIGNATURE + chunk("IHDR", header) + chunk("IDAT", Zlib::Deflate.deflate(rows)) + chunk("IEND", "")
|
|
23
|
+
)
|
|
24
|
+
path
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def read(path)
|
|
28
|
+
data = File.binread(path)
|
|
29
|
+
raise Gsplat::Error, "invalid PNG signature" unless data.start_with?(SIGNATURE)
|
|
30
|
+
|
|
31
|
+
width, height, channels, compressed = parse_chunks(data.byteslice(SIGNATURE.bytesize..))
|
|
32
|
+
bytes = unfilter(Zlib::Inflate.inflate(compressed), width, height, channels)
|
|
33
|
+
Numo::UInt8.from_binary(bytes).reshape(height, width, channels)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def validate_pixels!(pixels)
|
|
37
|
+
valid = pixels.is_a?(Numo::UInt8) && pixels.ndim == 3 && COLOR_TYPES.key?(pixels.shape[-1])
|
|
38
|
+
return if valid
|
|
39
|
+
|
|
40
|
+
actual = pixels.respond_to?(:shape) ? pixels.shape.inspect : pixels.class
|
|
41
|
+
raise ShapeError, "expected UInt8 PNG pixels [H,W,C] with C=1..4, got #{actual}"
|
|
42
|
+
end
|
|
43
|
+
private_class_method :validate_pixels!
|
|
44
|
+
|
|
45
|
+
def chunk(type, payload)
|
|
46
|
+
type = type.b
|
|
47
|
+
[payload.bytesize].pack("N") + type + payload + [Zlib.crc32(type + payload)].pack("N")
|
|
48
|
+
end
|
|
49
|
+
private_class_method :chunk
|
|
50
|
+
|
|
51
|
+
def parse_chunks(data)
|
|
52
|
+
width = height = channels = nil
|
|
53
|
+
compressed = +"".b
|
|
54
|
+
until data.empty?
|
|
55
|
+
length = data.byteslice(0, 4)&.unpack1("N")
|
|
56
|
+
raise Gsplat::Error, "truncated PNG chunk" unless length && data.bytesize >= length + 12
|
|
57
|
+
|
|
58
|
+
type = data.byteslice(4, 4)
|
|
59
|
+
payload = data.byteslice(8, length)
|
|
60
|
+
expected_crc = data.byteslice(8 + length, 4).unpack1("N")
|
|
61
|
+
raise Gsplat::Error, "invalid PNG CRC" unless Zlib.crc32(type + payload) == expected_crc
|
|
62
|
+
|
|
63
|
+
width, height, channels = parse_header(payload) if type == "IHDR"
|
|
64
|
+
compressed << payload if type == "IDAT"
|
|
65
|
+
data = data.byteslice((length + 12)..) || +"".b
|
|
66
|
+
break if type == "IEND"
|
|
67
|
+
end
|
|
68
|
+
raise Gsplat::Error, "PNG is missing IHDR or IDAT" unless width && !compressed.empty?
|
|
69
|
+
|
|
70
|
+
[width, height, channels, compressed]
|
|
71
|
+
end
|
|
72
|
+
private_class_method :parse_chunks
|
|
73
|
+
|
|
74
|
+
def parse_header(payload)
|
|
75
|
+
width, height, depth, color_type, compression, filter, interlace = payload.unpack("NNC5")
|
|
76
|
+
valid = depth == 8 && CHANNELS.key?(color_type) && compression.zero? && filter.zero? && interlace.zero?
|
|
77
|
+
raise NotSupportedError, "only non-interlaced 8-bit PNG is supported" unless valid
|
|
78
|
+
|
|
79
|
+
[width, height, CHANNELS.fetch(color_type)]
|
|
80
|
+
end
|
|
81
|
+
private_class_method :parse_header
|
|
82
|
+
|
|
83
|
+
def unfilter(data, width, height, channels)
|
|
84
|
+
stride = width * channels
|
|
85
|
+
output = String.new(capacity: stride * height, encoding: Encoding::BINARY)
|
|
86
|
+
previous = Array.new(stride, 0)
|
|
87
|
+
offset = 0
|
|
88
|
+
height.times do
|
|
89
|
+
filter = data.getbyte(offset)
|
|
90
|
+
encoded = data.byteslice(offset + 1, stride)&.bytes
|
|
91
|
+
raise Gsplat::Error, "truncated PNG scanline" unless encoded&.length == stride
|
|
92
|
+
|
|
93
|
+
row = reconstruct_row(encoded, previous, channels, filter)
|
|
94
|
+
output << row.pack("C*")
|
|
95
|
+
previous = row
|
|
96
|
+
offset += stride + 1
|
|
97
|
+
end
|
|
98
|
+
output
|
|
99
|
+
end
|
|
100
|
+
private_class_method :unfilter
|
|
101
|
+
|
|
102
|
+
def reconstruct_row(encoded, previous, channels, filter)
|
|
103
|
+
raise NotSupportedError, "unsupported PNG filter #{filter}" unless (0..4).cover?(filter)
|
|
104
|
+
|
|
105
|
+
encoded.each_with_index.map do |value, index|
|
|
106
|
+
left = index >= channels ? encoded[index - channels] : 0
|
|
107
|
+
upper = previous[index]
|
|
108
|
+
upper_left = index >= channels ? previous[index - channels] : 0
|
|
109
|
+
prediction = filter_prediction(filter, left, upper, upper_left)
|
|
110
|
+
encoded[index] = (value + prediction) & 0xff
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
private_class_method :reconstruct_row
|
|
114
|
+
|
|
115
|
+
def filter_prediction(filter, left, upper, upper_left)
|
|
116
|
+
return 0 if filter.zero?
|
|
117
|
+
return left if filter == 1
|
|
118
|
+
return upper if filter == 2
|
|
119
|
+
return (left + upper) / 2 if filter == 3
|
|
120
|
+
|
|
121
|
+
paeth(left, upper, upper_left)
|
|
122
|
+
end
|
|
123
|
+
private_class_method :filter_prediction
|
|
124
|
+
|
|
125
|
+
def paeth(left, upper, upper_left)
|
|
126
|
+
estimate = left + upper - upper_left
|
|
127
|
+
distances = [(estimate - left).abs, (estimate - upper).abs, (estimate - upper_left).abs]
|
|
128
|
+
[left, upper, upper_left][distances.each_with_index.min.last]
|
|
129
|
+
end
|
|
130
|
+
private_class_method :paeth
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gsplat
|
|
4
|
+
module Compression
|
|
5
|
+
# Per-channel linear quantization used by PNG parameter grids.
|
|
6
|
+
module Quantizer
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def encode(array, side, bits:)
|
|
10
|
+
feature_count = array.size / array.shape[0]
|
|
11
|
+
raise NotSupportedError, "PNG parameter grids support at most four channels" if feature_count > 4
|
|
12
|
+
|
|
13
|
+
flat = array.reshape(array.shape[0], feature_count)
|
|
14
|
+
mins, maxs = channel_extrema(flat, feature_count)
|
|
15
|
+
limit = (1 << bits) - 1
|
|
16
|
+
values = quantized_values(flat, mins, maxs, limit)
|
|
17
|
+
type = bits <= 8 ? Numo::UInt8 : Numo::UInt16
|
|
18
|
+
pixels = type.cast(values).reshape(side, side, feature_count)
|
|
19
|
+
[pixels, metadata(array, mins, maxs, bits)]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def decode(pixels, metadata)
|
|
23
|
+
shape = metadata.fetch("shape")
|
|
24
|
+
feature_count = shape.drop(1).inject(1, :*)
|
|
25
|
+
mins = metadata.fetch("mins")
|
|
26
|
+
maxs = metadata.fetch("maxs")
|
|
27
|
+
limit = (1 << metadata.fetch("bits")) - 1
|
|
28
|
+
values = pixels.reshape(pixels.shape[0] * pixels.shape[1], feature_count).to_a.flat_map do |row|
|
|
29
|
+
row.each_with_index.map do |value, channel|
|
|
30
|
+
mins[channel] + ((value.to_f / limit) * (maxs[channel] - mins[channel]))
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
numeric_type(metadata.fetch("dtype")).cast(values).reshape(*shape)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def channel_extrema(flat, feature_count)
|
|
37
|
+
[
|
|
38
|
+
feature_count.times.map { |channel| flat[true, channel].min.to_f },
|
|
39
|
+
feature_count.times.map { |channel| flat[true, channel].max.to_f }
|
|
40
|
+
]
|
|
41
|
+
end
|
|
42
|
+
private_class_method :channel_extrema
|
|
43
|
+
|
|
44
|
+
def quantized_values(flat, mins, maxs, limit)
|
|
45
|
+
flat.to_a.flat_map do |row|
|
|
46
|
+
row.each_with_index.map do |value, channel|
|
|
47
|
+
span = maxs[channel] - mins[channel]
|
|
48
|
+
span.zero? ? 0 : (((value - mins[channel]) / span) * limit).round.clamp(0, limit)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
private_class_method :quantized_values
|
|
53
|
+
|
|
54
|
+
def metadata(array, mins, maxs, bits)
|
|
55
|
+
{
|
|
56
|
+
"shape" => array.shape,
|
|
57
|
+
"dtype" => dtype_name(array.class),
|
|
58
|
+
"mins" => mins,
|
|
59
|
+
"maxs" => maxs,
|
|
60
|
+
"bits" => bits
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
private_class_method :metadata
|
|
64
|
+
|
|
65
|
+
def dtype_name(type)
|
|
66
|
+
return "float32" if type == Numo::SFloat
|
|
67
|
+
return "float64" if type == Numo::DFloat
|
|
68
|
+
|
|
69
|
+
raise NotSupportedError, "compression requires float32 or float64 parameters"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def numeric_type(name)
|
|
73
|
+
{ "float32" => Numo::SFloat, "float64" => Numo::DFloat }.fetch(name) do
|
|
74
|
+
raise NotSupportedError, "unsupported compressed dtype #{name.inspect}"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|