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.
Files changed (116) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +202 -0
  3. data/README.md +236 -0
  4. data/docs/ACCEPTANCE.md +60 -0
  5. data/docs/BENCHMARKS.md +79 -0
  6. data/docs/DECISIONS.md +52 -0
  7. data/docs/MIGRATION.md +117 -0
  8. data/docs/PROFILE.md +75 -0
  9. data/docs/PROGRESS.md +199 -0
  10. data/docs/decisions/0000-template.md +17 -0
  11. data/docs/decisions/0001-pin-golden-reference-and-cpu-validation.md +27 -0
  12. data/docs/decisions/0002-retain-ruby-fallbacks-for-native-backend.md +26 -0
  13. data/docs/decisions/0003-share-projection-for-distorted-cameras.md +26 -0
  14. data/docs/decisions/0004-use-portable-world-space-reference-paths.md +26 -0
  15. data/docs/decisions/0005-reuse-ewa-core-for-2dgs.md +24 -0
  16. data/docs/decisions/0006-keep-eval3d-as-portable-reference.md +25 -0
  17. data/docs/decisions/0007-share-compositor-semantics-for-contribution-indices.md +25 -0
  18. data/examples/data/README.md +14 -0
  19. data/examples/data/colmap/images/view_000.png +0 -0
  20. data/examples/data/colmap/images/view_001.png +0 -0
  21. data/examples/data/colmap/images/view_002.png +0 -0
  22. data/examples/data/colmap/sparse/0/cameras.txt +2 -0
  23. data/examples/data/colmap/sparse/0/images.txt +6 -0
  24. data/examples/data/colmap/sparse/0/points3D.txt +17 -0
  25. data/examples/data/splats.ply +0 -0
  26. data/examples/fit_image.rb +35 -0
  27. data/examples/generate_sample_data.rb +147 -0
  28. data/examples/render_path.rb +117 -0
  29. data/examples/simple_trainer.rb +76 -0
  30. data/ext/gsplat_native/common.h +54 -0
  31. data/ext/gsplat_native/extconf.rb +39 -0
  32. data/ext/gsplat_native/gsplat_native.c +60 -0
  33. data/ext/gsplat_native/intersections.c +211 -0
  34. data/ext/gsplat_native/projection.c +199 -0
  35. data/ext/gsplat_native/raster_backward.c +129 -0
  36. data/ext/gsplat_native/raster_backward_bridge.c +69 -0
  37. data/ext/gsplat_native/raster_forward.c +150 -0
  38. data/ext/gsplat_native/rasterization.h +51 -0
  39. data/ext/gsplat_native/spherical_harmonics.c +129 -0
  40. data/gsplat.gemspec +36 -0
  41. data/lib/gsplat/autograd/context.rb +60 -0
  42. data/lib/gsplat/autograd/function.rb +68 -0
  43. data/lib/gsplat/autograd/variable.rb +159 -0
  44. data/lib/gsplat/backend/ruby/accumulate.rb +139 -0
  45. data/lib/gsplat/backend/ruby/accumulate_backward.rb +40 -0
  46. data/lib/gsplat/backend/ruby/eval3d_rasterizer.rb +175 -0
  47. data/lib/gsplat/backend/ruby/isect_tiles.rb +198 -0
  48. data/lib/gsplat/backend/ruby/projection.rb +251 -0
  49. data/lib/gsplat/backend/ruby/projection_backward.rb +190 -0
  50. data/lib/gsplat/backend/ruby/projection_covariance_vjp.rb +72 -0
  51. data/lib/gsplat/backend/ruby/projection_input_vjp.rb +252 -0
  52. data/lib/gsplat/backend/ruby/quat_scale_to_covar_preci.rb +139 -0
  53. data/lib/gsplat/backend/ruby/rasterize_to_indices_in_range.rb +121 -0
  54. data/lib/gsplat/backend/ruby/rasterize_to_pixels.rb +199 -0
  55. data/lib/gsplat/backend/ruby/rasterize_to_pixels_backward.rb +121 -0
  56. data/lib/gsplat/backend/ruby/spherical_harmonics.rb +135 -0
  57. data/lib/gsplat/backend/ruby/tile_compositor.rb +50 -0
  58. data/lib/gsplat/backend/ruby/tile_compositor_backward.rb +84 -0
  59. data/lib/gsplat/backend.rb +79 -0
  60. data/lib/gsplat/compression/grid_sort.rb +79 -0
  61. data/lib/gsplat/compression/kmeans.rb +121 -0
  62. data/lib/gsplat/compression/png.rb +147 -0
  63. data/lib/gsplat/compression/png_codec.rb +133 -0
  64. data/lib/gsplat/compression/quantizer.rb +79 -0
  65. data/lib/gsplat/io/checkpoint.rb +145 -0
  66. data/lib/gsplat/io/colmap.rb +175 -0
  67. data/lib/gsplat/io/colmap_binary.rb +98 -0
  68. data/lib/gsplat/io/colmap_text.rb +84 -0
  69. data/lib/gsplat/io/image.rb +63 -0
  70. data/lib/gsplat/io/image_backends.rb +81 -0
  71. data/lib/gsplat/io/npy.rb +189 -0
  72. data/lib/gsplat/io/ply.rb +185 -0
  73. data/lib/gsplat/io/ply_reader.rb +142 -0
  74. data/lib/gsplat/io/zip_archive.rb +183 -0
  75. data/lib/gsplat/math/camera_distortion.rb +123 -0
  76. data/lib/gsplat/math/camera_projection.rb +202 -0
  77. data/lib/gsplat/math/mat.rb +114 -0
  78. data/lib/gsplat/math/quaternion.rb +175 -0
  79. data/lib/gsplat/math/small_matrix_primitives.rb +112 -0
  80. data/lib/gsplat/math/spherical_harmonic_basis.rb +148 -0
  81. data/lib/gsplat/math/ssim.rb +153 -0
  82. data/lib/gsplat/native.rb +30 -0
  83. data/lib/gsplat/native_ops.rb +148 -0
  84. data/lib/gsplat/native_raster_ops.rb +76 -0
  85. data/lib/gsplat/ops/accumulate.rb +68 -0
  86. data/lib/gsplat/ops/eval3d_rasterize.rb +62 -0
  87. data/lib/gsplat/ops/isect_tiles.rb +38 -0
  88. data/lib/gsplat/ops/projection.rb +154 -0
  89. data/lib/gsplat/ops/quat_scale_to_covar_preci.rb +118 -0
  90. data/lib/gsplat/ops/rasterize_to_indices_in_range.rb +38 -0
  91. data/lib/gsplat/ops/rasterize_to_pixels.rb +105 -0
  92. data/lib/gsplat/ops/relocation.rb +94 -0
  93. data/lib/gsplat/ops/spherical_harmonics.rb +71 -0
  94. data/lib/gsplat/ops/tensor_shape_ops.rb +173 -0
  95. data/lib/gsplat/ops/tensor_value_ops.rb +150 -0
  96. data/lib/gsplat/optim/adam.rb +212 -0
  97. data/lib/gsplat/optim/lr_scheduler.rb +36 -0
  98. data/lib/gsplat/optim/selective_adam.rb +68 -0
  99. data/lib/gsplat/rasterization.rb +130 -0
  100. data/lib/gsplat/rasterization_2dgs.rb +140 -0
  101. data/lib/gsplat/rasterization_helpers.rb +162 -0
  102. data/lib/gsplat/rasterization_validation.rb +99 -0
  103. data/lib/gsplat/strategy/base.rb +49 -0
  104. data/lib/gsplat/strategy/default.rb +188 -0
  105. data/lib/gsplat/strategy/mcmc.rb +103 -0
  106. data/lib/gsplat/strategy/mcmc_ops.rb +143 -0
  107. data/lib/gsplat/strategy/ops.rb +165 -0
  108. data/lib/gsplat/training/config.rb +91 -0
  109. data/lib/gsplat/training/image_fitter.rb +158 -0
  110. data/lib/gsplat/training/losses.rb +191 -0
  111. data/lib/gsplat/training/scene.rb +116 -0
  112. data/lib/gsplat/training/trainer.rb +236 -0
  113. data/lib/gsplat/utils.rb +110 -0
  114. data/lib/gsplat/version.rb +6 -0
  115. data/lib/gsplat.rb +98 -0
  116. 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