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,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Gsplat
6
+ module IO
7
+ # Portable NPZ checkpoint for parameters, Adam moments, step, and config.
8
+ module Checkpoint
9
+ # Current checkpoint schema version.
10
+ VERSION = 1
11
+ # Separator reserved for structured NPZ entry names.
12
+ SEPARATOR = "___"
13
+ # Loaded checkpoint payload.
14
+ #
15
+ # @!attribute params
16
+ # @return [Hash{Symbol=>Numo::NArray}]
17
+ # @!attribute optimizer_states
18
+ # @return [Hash] optimizer moments grouped by optimizer and parameter
19
+ # @!attribute step
20
+ # @return [Integer]
21
+ # @!attribute config
22
+ # @return [Hash]
23
+ Snapshot = Data.define(:params, :optimizer_states, :step, :config)
24
+
25
+ module_function
26
+
27
+ def save(target, params:, optimizers:, step:, config: {})
28
+ raise ArgumentError, "step must be a non-negative integer" unless step.is_a?(Integer) && !step.negative?
29
+
30
+ arrays = {
31
+ "checkpoint_version" => Numo::Int32[VERSION],
32
+ "checkpoint_step" => Numo::Int64[step],
33
+ "checkpoint_config_json" => encode_json(config)
34
+ }
35
+ params.each do |name, value|
36
+ arrays[key("param", name)] = Ops::TensorOps.data(value)
37
+ end
38
+ optimizers.each do |optimizer_name, optimizer|
39
+ optimizer.groups.each_key do |group_name|
40
+ state = optimizer.state(group_name)
41
+ prefix = key("optimizer", optimizer_name, group_name)
42
+ arrays["#{prefix}#{SEPARATOR}step"] = Numo::Int64[state.step]
43
+ arrays["#{prefix}#{SEPARATOR}exp_avg"] = state.exp_avg
44
+ arrays["#{prefix}#{SEPARATOR}exp_avg_sq"] = state.exp_avg_sq
45
+ end
46
+ end
47
+ Npy.write_npz(target, arrays)
48
+ end
49
+
50
+ # Loads parameters and optimizer state without mutating live objects.
51
+ #
52
+ # @param source [String, #read] NPZ checkpoint path or IO
53
+ # @return [Snapshot]
54
+ def load(source)
55
+ arrays = Npy.read_npz(source)
56
+ version = arrays.fetch("checkpoint_version")[0].to_i
57
+ raise NotSupportedError, "unsupported checkpoint version #{version}" unless version == VERSION
58
+
59
+ Snapshot.new(
60
+ params: extract_params(arrays),
61
+ optimizer_states: extract_optimizer_states(arrays),
62
+ step: arrays.fetch("checkpoint_step")[0].to_i,
63
+ config: decode_json(arrays.fetch("checkpoint_config_json"))
64
+ )
65
+ rescue KeyError => e
66
+ raise Gsplat::Error, "invalid checkpoint: #{e.message}"
67
+ end
68
+
69
+ # Loads a checkpoint into existing Variables and optimizers.
70
+ #
71
+ # @param source [String, #read] NPZ checkpoint path or IO
72
+ # @param params [Hash{Symbol=>Autograd::Variable}]
73
+ # @param optimizers [Hash{Symbol=>Optim::Adam}]
74
+ # @return [Snapshot]
75
+ def restore!(source, params:, optimizers:)
76
+ snapshot = load(source)
77
+ snapshot.params.each do |name, value|
78
+ params.fetch(name).replace_data!(value.dup)
79
+ end
80
+ snapshot.optimizer_states.each do |optimizer_name, groups|
81
+ optimizer = optimizers.fetch(optimizer_name)
82
+ groups.each do |group_name, state|
83
+ optimizer.load_state!(
84
+ group_name,
85
+ step: state.fetch(:step),
86
+ exp_avg: state.fetch(:exp_avg),
87
+ exp_avg_sq: state.fetch(:exp_avg_sq)
88
+ )
89
+ end
90
+ end
91
+ snapshot
92
+ rescue KeyError => e
93
+ raise Gsplat::Error, "checkpoint target mismatch: #{e.message}"
94
+ end
95
+
96
+ def key(*parts)
97
+ values = parts.map(&:to_s)
98
+ invalid = values.find { |value| value.empty? || value.include?(SEPARATOR) || !value.match?(/\A\w+\z/) }
99
+ raise ArgumentError, "invalid checkpoint name #{invalid.inspect}" if invalid
100
+
101
+ values.join(SEPARATOR)
102
+ end
103
+ private_class_method :key
104
+
105
+ def encode_json(config)
106
+ value = config.respond_to?(:to_h) ? config.to_h : config
107
+ Numo::UInt8.cast(JSON.generate(value).bytes)
108
+ rescue JSON::GeneratorError => e
109
+ raise ArgumentError, "config is not JSON serializable: #{e.message}"
110
+ end
111
+ private_class_method :encode_json
112
+
113
+ def decode_json(bytes)
114
+ JSON.parse(bytes.to_a.pack("C*"), symbolize_names: true)
115
+ rescue JSON::ParserError => e
116
+ raise Gsplat::Error, "invalid checkpoint config JSON: #{e.message}"
117
+ end
118
+ private_class_method :decode_json
119
+
120
+ def extract_params(arrays)
121
+ prefix = "param#{SEPARATOR}"
122
+ arrays.filter_map do |name, value|
123
+ next unless name.start_with?(prefix)
124
+
125
+ [name.delete_prefix(prefix).to_sym, value]
126
+ end.to_h
127
+ end
128
+ private_class_method :extract_params
129
+
130
+ def extract_optimizer_states(arrays)
131
+ output = Hash.new { |hash, name| hash[name] = Hash.new { |groups, group| groups[group] = {} } }
132
+ pattern = /\Aoptimizer#{SEPARATOR}(\w+)#{SEPARATOR}(\w+)#{SEPARATOR}(step|exp_avg|exp_avg_sq)\z/
133
+ arrays.each do |name, value|
134
+ match = pattern.match(name)
135
+ next unless match
136
+
137
+ state_value = match[3] == "step" ? value[0].to_i : value
138
+ output[match[1].to_sym][match[2].to_sym][match[3].to_sym] = state_value
139
+ end
140
+ output.to_h { |name, groups| [name, groups.to_h] }
141
+ end
142
+ private_class_method :extract_optimizer_states
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "colmap_binary"
4
+ require_relative "colmap_text"
5
+
6
+ module Gsplat
7
+ module IO
8
+ # COLMAP sparse reconstruction reader and camera conversion utilities.
9
+ module Colmap
10
+ # Parsed camera calibration record.
11
+ Camera = Data.define(:id, :model, :width, :height, :params, :intrinsics, :distortion)
12
+ # Parsed registered-image pose and feature observations.
13
+ ImageRecord = Data.define(
14
+ :id, :qvec, :tvec, :camera_id, :name, :points2d, :point3d_ids, :rotation, :world_to_camera
15
+ )
16
+ # Parsed sparse 3D point and visibility track.
17
+ Point3D = Data.define(:id, :xyz, :rgb, :error, :track)
18
+ # Complete sparse model returned by {.read}.
19
+ Dataset = Data.define(:cameras, :images, :points3d, :path)
20
+
21
+ # Supported COLMAP numeric camera model IDs.
22
+ MODELS = {
23
+ 0 => "SIMPLE_PINHOLE",
24
+ 1 => "PINHOLE",
25
+ 2 => "SIMPLE_RADIAL",
26
+ 4 => "OPENCV"
27
+ }.freeze
28
+
29
+ module_function
30
+
31
+ # Reads cameras, registered images, and points from a sparse model directory.
32
+ def read(path, data_factor: 1)
33
+ directory = model_directory(path)
34
+ extension = model_extension(directory)
35
+ Dataset.new(
36
+ cameras: read_cameras(File.join(directory, "cameras.#{extension}"), data_factor: data_factor),
37
+ images: read_images(File.join(directory, "images.#{extension}")),
38
+ points3d: read_points3d(File.join(directory, "points3D.#{extension}")),
39
+ path: directory
40
+ )
41
+ end
42
+
43
+ # Reads and scales camera calibration records from a bin/txt file.
44
+ #
45
+ # @param path [String]
46
+ # @param data_factor [Numeric] image downsampling factor
47
+ # @return [Hash{Integer=>Camera}]
48
+ def read_cameras(path, data_factor: 1)
49
+ validate_factor!(data_factor)
50
+ raw_records(path, :cameras).transform_values { |record| camera_record(record, data_factor) }
51
+ end
52
+
53
+ # Reads registered image poses from a bin/txt file.
54
+ #
55
+ # @param path [String]
56
+ # @return [Hash{Integer=>ImageRecord}]
57
+ def read_images(path)
58
+ raw_records(path, :images).transform_values { |record| image_record(record) }
59
+ end
60
+
61
+ # Reads sparse points from a bin/txt file.
62
+ #
63
+ # @param path [String]
64
+ # @return [Hash{Integer=>Point3D}]
65
+ def read_points3d(path)
66
+ raw_records(path, :points3d).transform_values do |record|
67
+ Point3D.new(
68
+ id: record.fetch(:id),
69
+ xyz: Numo::DFloat.cast(record.fetch(:xyz)),
70
+ rgb: Numo::UInt8.cast(record.fetch(:rgb)),
71
+ error: record.fetch(:error),
72
+ track: record.fetch(:track).freeze
73
+ )
74
+ end
75
+ end
76
+
77
+ # Converts a COLMAP wxyz quaternion to a 3x3 world-to-camera rotation.
78
+ def qvec_to_rotmat(qvec)
79
+ quaternion = Numo::DFloat.cast(qvec).reshape(1, 4)
80
+ Math::Quaternion.to_rotmat(quaternion)[0, true, true].dup
81
+ end
82
+
83
+ def camera_record(record, factor)
84
+ model = model_name(record.fetch(:model))
85
+ params = record.fetch(:params).map(&:to_f)
86
+ focal, distortion = intrinsics_for(model, params)
87
+ intrinsics = Numo::DFloat[
88
+ [focal[0] / factor, 0, focal[2] / factor],
89
+ [0, focal[1] / factor, focal[3] / factor],
90
+ [0, 0, 1]
91
+ ]
92
+ Camera.new(
93
+ id: record.fetch(:id),
94
+ model: model,
95
+ width: record.fetch(:width).fdiv(factor).to_i,
96
+ height: record.fetch(:height).fdiv(factor).to_i,
97
+ params: Numo::DFloat.cast(params),
98
+ intrinsics: intrinsics,
99
+ distortion: Numo::DFloat.cast(distortion)
100
+ )
101
+ end
102
+ private_class_method :camera_record
103
+
104
+ def image_record(record)
105
+ qvec = Numo::DFloat.cast(record.fetch(:qvec))
106
+ tvec = Numo::DFloat.cast(record.fetch(:tvec))
107
+ rotation = qvec_to_rotmat(qvec)
108
+ view = Numo::DFloat.eye(4)
109
+ view[0...3, 0...3] = rotation
110
+ view[0...3, 3] = tvec
111
+ coordinates = record.fetch(:points2d)
112
+ points2d = coordinates.empty? ? Numo::DFloat.zeros(0, 2) : Numo::DFloat.cast(coordinates)
113
+ ImageRecord.new(
114
+ id: record.fetch(:id),
115
+ qvec: qvec,
116
+ tvec: tvec,
117
+ camera_id: record.fetch(:camera_id),
118
+ name: record.fetch(:name),
119
+ points2d: points2d,
120
+ point3d_ids: Numo::Int64.cast(record.fetch(:point3d_ids)),
121
+ rotation: rotation,
122
+ world_to_camera: view
123
+ )
124
+ end
125
+ private_class_method :image_record
126
+
127
+ def intrinsics_for(model, params)
128
+ case model
129
+ when "SIMPLE_PINHOLE" then [[params[0], params[0], params[1], params[2]], []]
130
+ when "PINHOLE" then [params.first(4), []]
131
+ when "SIMPLE_RADIAL" then [[params[0], params[0], params[1], params[2]], [params[3]]]
132
+ when "OPENCV" then [params.first(4), params.drop(4)]
133
+ end
134
+ end
135
+ private_class_method :intrinsics_for
136
+
137
+ def model_name(value)
138
+ return value if MODELS.value?(value)
139
+
140
+ MODELS.fetch(value) { raise NotSupportedError, "unsupported COLMAP camera model #{value.inspect}" }
141
+ end
142
+ private_class_method :model_name
143
+
144
+ def raw_records(path, kind)
145
+ data = File.binread(path)
146
+ reader = File.extname(path) == ".bin" ? ColmapBinary : ColmapText
147
+ reader.public_send(kind, data)
148
+ end
149
+ private_class_method :raw_records
150
+
151
+ def model_directory(path)
152
+ candidates = [path, File.join(path, "sparse", "0"), File.join(path, "sparse")]
153
+ directory = candidates.find do |candidate|
154
+ File.file?(File.join(candidate, "cameras.bin")) ||
155
+ File.file?(File.join(candidate, "cameras.txt"))
156
+ end
157
+ directory || raise(Gsplat::Error, "COLMAP sparse model not found under #{path}")
158
+ end
159
+ private_class_method :model_directory
160
+
161
+ def model_extension(directory)
162
+ return "bin" if %w[cameras images points3D].all? { |name| File.file?(File.join(directory, "#{name}.bin")) }
163
+ return "txt" if %w[cameras images points3D].all? { |name| File.file?(File.join(directory, "#{name}.txt")) }
164
+
165
+ raise Gsplat::Error, "COLMAP sparse model files are incomplete in #{directory}"
166
+ end
167
+ private_class_method :model_extension
168
+
169
+ def validate_factor!(factor)
170
+ raise ArgumentError, "data_factor must be positive" unless factor.is_a?(Numeric) && factor.positive?
171
+ end
172
+ private_class_method :validate_factor!
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module IO
5
+ # Decoder for COLMAP sparse-model binary files.
6
+ module ColmapBinary
7
+ CAMERA_PARAM_COUNTS = {
8
+ 0 => 3, 1 => 4, 2 => 4, 3 => 5, 4 => 8, 5 => 8,
9
+ 6 => 12, 7 => 5, 8 => 4, 9 => 5, 10 => 12
10
+ }.freeze
11
+
12
+ # Bounds-checked little-endian binary cursor.
13
+ class Cursor
14
+ def initialize(data)
15
+ @data = data
16
+ @offset = 0
17
+ end
18
+
19
+ def scalar(format, width)
20
+ bytes = @data.byteslice(@offset, width)
21
+ raise Gsplat::Error, "truncated COLMAP binary file" unless bytes&.bytesize == width
22
+
23
+ @offset += width
24
+ bytes.unpack1(format)
25
+ end
26
+
27
+ def array(format, width, count)
28
+ Array.new(count) { scalar(format, width) }
29
+ end
30
+
31
+ def cstring
32
+ finish = @data.index("\0", @offset)
33
+ raise Gsplat::Error, "unterminated COLMAP image name" unless finish
34
+
35
+ value = @data.byteslice(@offset, finish - @offset)
36
+ @offset = finish + 1
37
+ value.force_encoding(Encoding::UTF_8)
38
+ end
39
+ end
40
+
41
+ module_function
42
+
43
+ def cameras(data)
44
+ cursor = Cursor.new(data)
45
+ Array.new(cursor.scalar("Q<", 8)).to_h do
46
+ id = cursor.scalar("l<", 4)
47
+ model = cursor.scalar("l<", 4)
48
+ count = CAMERA_PARAM_COUNTS.fetch(model) do
49
+ raise NotSupportedError, "unknown COLMAP camera model id #{model}"
50
+ end
51
+ width = cursor.scalar("Q<", 8)
52
+ height = cursor.scalar("Q<", 8)
53
+ [id, { id: id, model: model, width: width, height: height, params: cursor.array("E", 8, count) }]
54
+ end
55
+ end
56
+
57
+ def images(data)
58
+ cursor = Cursor.new(data)
59
+ Array.new(cursor.scalar("Q<", 8)).to_h do
60
+ id = cursor.scalar("l<", 4)
61
+ qvec = cursor.array("E", 8, 4)
62
+ tvec = cursor.array("E", 8, 3)
63
+ camera_id = cursor.scalar("l<", 4)
64
+ name = cursor.cstring
65
+ count = cursor.scalar("Q<", 8)
66
+ points2d, point3d_ids = image_points(cursor, count)
67
+ [id, { id: id, qvec: qvec, tvec: tvec, camera_id: camera_id, name: name,
68
+ points2d: points2d, point3d_ids: point3d_ids }]
69
+ end
70
+ end
71
+
72
+ def points3d(data)
73
+ cursor = Cursor.new(data)
74
+ Array.new(cursor.scalar("Q<", 8)).to_h do
75
+ id = cursor.scalar("Q<", 8)
76
+ xyz = cursor.array("E", 8, 3)
77
+ rgb = cursor.array("C", 1, 3)
78
+ error = cursor.scalar("E", 8)
79
+ track_count = cursor.scalar("Q<", 8)
80
+ track = Array.new(track_count) { [cursor.scalar("l<", 4), cursor.scalar("l<", 4)] }
81
+ [id, { id: id, xyz: xyz, rgb: rgb, error: error, track: track }]
82
+ end
83
+ end
84
+
85
+ def image_points(cursor, count)
86
+ coordinates = []
87
+ ids = []
88
+ count.times do
89
+ coordinates << cursor.array("E", 8, 2)
90
+ raw_id = cursor.scalar("Q<", 8)
91
+ ids << (raw_id == ((1 << 64) - 1) ? -1 : raw_id)
92
+ end
93
+ [coordinates, ids]
94
+ end
95
+ private_class_method :image_points
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module IO
5
+ # Decoder for COLMAP sparse-model text files.
6
+ module ColmapText
7
+ module_function
8
+
9
+ def cameras(data)
10
+ content_lines(data).to_h do |line|
11
+ fields = line.split
12
+ id = Integer(fields.shift, 10)
13
+ model = fields.shift
14
+ width = Integer(fields.shift, 10)
15
+ height = Integer(fields.shift, 10)
16
+ [id, { id: id, model: model, width: width, height: height, params: fields.map { |item| Float(item) } }]
17
+ end
18
+ end
19
+
20
+ def images(data)
21
+ lines = data.lines(chomp: true)
22
+ output = {}
23
+ until lines.empty?
24
+ metadata = next_metadata_line(lines)
25
+ break unless metadata
26
+
27
+ fields = metadata.split
28
+ id = Integer(fields.shift, 10)
29
+ qvec = fields.shift(4).map { |item| Float(item) }
30
+ tvec = fields.shift(3).map { |item| Float(item) }
31
+ camera_id = Integer(fields.shift, 10)
32
+ name = fields.join(" ")
33
+ points_line = lines.shift.to_s.strip
34
+ points2d, point3d_ids = parse_image_points(points_line)
35
+ output[id] = {
36
+ id: id, qvec: qvec, tvec: tvec, camera_id: camera_id,
37
+ name: name, points2d: points2d, point3d_ids: point3d_ids
38
+ }
39
+ end
40
+ output
41
+ end
42
+
43
+ def points3d(data)
44
+ content_lines(data).to_h do |line|
45
+ fields = line.split
46
+ id = Integer(fields.shift, 10)
47
+ xyz = fields.shift(3).map { |item| Float(item) }
48
+ rgb = fields.shift(3).map { |item| Integer(item, 10) }
49
+ error = Float(fields.shift)
50
+ track = fields.each_slice(2).map { |image_id, point_index| [Integer(image_id, 10), Integer(point_index, 10)] }
51
+ [id, { id: id, xyz: xyz, rgb: rgb, error: error, track: track }]
52
+ end
53
+ end
54
+
55
+ def content_lines(data)
56
+ data.lines(chomp: true).map(&:strip).reject { |line| line.empty? || line.start_with?("#") }
57
+ end
58
+ private_class_method :content_lines
59
+
60
+ def next_metadata_line(lines)
61
+ until lines.empty?
62
+ line = lines.shift.strip
63
+ return line unless line.empty? || line.start_with?("#")
64
+ end
65
+ nil
66
+ end
67
+ private_class_method :next_metadata_line
68
+
69
+ def parse_image_points(line)
70
+ fields = line.split
71
+ coordinates = []
72
+ ids = []
73
+ fields.each_slice(3) do |x_coord, y_coord, point_id|
74
+ raise Gsplat::Error, "invalid COLMAP image point row" unless point_id
75
+
76
+ coordinates << [Float(x_coord), Float(y_coord)]
77
+ ids << Integer(point_id, 10)
78
+ end
79
+ [coordinates, ids]
80
+ end
81
+ private_class_method :parse_image_points
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "image_backends"
4
+
5
+ module Gsplat
6
+ module IO
7
+ # RGB image input/output with vips-first optional backends.
8
+ module Image
9
+ # Optional image backend implementations in preference order.
10
+ BACKENDS = {
11
+ vips: VipsImageBackend,
12
+ chunky_png: ChunkyPngImageBackend
13
+ }.freeze
14
+
15
+ module_function
16
+
17
+ # Reads an image as float32 [H,W,3] in the range 0..1.
18
+ def read(path, backend: :auto)
19
+ resolve_backend(backend).read(path)
20
+ end
21
+
22
+ # Writes a float [H,W,3] image, clamping values to 0..1.
23
+ def write(path, array, backend: :auto)
24
+ validate_array!(array)
25
+ resolve_backend(backend).write(path, array)
26
+ end
27
+
28
+ # Returns image codecs currently loadable in this process.
29
+ #
30
+ # @return [Array<Symbol>] subset of `:vips` and `:chunky_png`
31
+ def available_backends
32
+ BACKENDS.filter_map { |name, implementation| name if implementation.available? }
33
+ end
34
+
35
+ def resolve_backend(name)
36
+ if name == :auto
37
+ selected = BACKENDS.values.find(&:available?)
38
+ return selected if selected
39
+
40
+ raise NotSupportedError, "image IO requires ruby-vips or chunky_png"
41
+ end
42
+ implementation = BACKENDS.fetch(name.to_sym) do
43
+ raise ArgumentError, "unknown image backend #{name.inspect}"
44
+ end
45
+ return implementation if implementation.available?
46
+
47
+ raise NotSupportedError, "image backend #{name} is unavailable"
48
+ end
49
+ private_class_method :resolve_backend
50
+
51
+ def validate_array!(array)
52
+ valid = array.is_a?(Numo::NArray) &&
53
+ [Numo::SFloat, Numo::DFloat].include?(array.class) &&
54
+ array.ndim == 3 && array.shape[2] == 3
55
+ return if valid
56
+
57
+ actual = array.respond_to?(:shape) ? array.shape.inspect : array.class
58
+ raise ShapeError, "expected image [H,W,3] floating array, got #{actual}"
59
+ end
60
+ private_class_method :validate_array!
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Gsplat
4
+ module IO
5
+ # Optional ruby-vips implementation.
6
+ module VipsImageBackend
7
+ module_function
8
+
9
+ def available?
10
+ require "vips"
11
+ true
12
+ rescue LoadError, StandardError
13
+ false
14
+ end
15
+
16
+ def read(path)
17
+ image = Vips::Image.new_from_file(path.to_s, access: :sequential)
18
+ scale = { uchar: 255.0, ushort: 65_535.0 }.fetch(image.format.to_sym, 1.0)
19
+ rgb = rgb_image(image).cast(:float)
20
+ Numo::SFloat.from_binary(rgb.write_to_memory).reshape(rgb.height, rgb.width, 3) / scale
21
+ end
22
+
23
+ def write(path, array)
24
+ bytes = quantized_bytes(array)
25
+ image = Vips::Image.new_from_memory(bytes, array.shape[1], array.shape[0], 3, :uchar)
26
+ image.write_to_file(path.to_s)
27
+ path
28
+ end
29
+
30
+ def rgb_image(image)
31
+ return image.extract_band(0, n: 3) if image.bands >= 3
32
+
33
+ band = image.extract_band(0)
34
+ band.bandjoin([band, band])
35
+ end
36
+ private_class_method :rgb_image
37
+
38
+ def quantized_bytes(array)
39
+ array.to_a.flatten.map { |value| (value.to_f.clamp(0.0, 1.0) * 255).round }.pack("C*")
40
+ end
41
+ private_class_method :quantized_bytes
42
+ end
43
+
44
+ # Portable chunky_png implementation.
45
+ module ChunkyPngImageBackend
46
+ module_function
47
+
48
+ def available?
49
+ require "chunky_png"
50
+ true
51
+ rescue LoadError
52
+ false
53
+ end
54
+
55
+ def read(path)
56
+ image = ChunkyPNG::Image.from_file(path.to_s)
57
+ values = image.height.times.flat_map do |y_coord|
58
+ image.width.times.flat_map do |x_coord|
59
+ pixel = image[x_coord, y_coord]
60
+ [ChunkyPNG::Color.r(pixel), ChunkyPNG::Color.g(pixel), ChunkyPNG::Color.b(pixel)]
61
+ end
62
+ end
63
+ Numo::SFloat.cast(values).reshape(image.height, image.width, 3) / 255.0
64
+ end
65
+
66
+ def write(path, array)
67
+ image = ChunkyPNG::Image.new(array.shape[1], array.shape[0])
68
+ array.shape[0].times do |y_coord|
69
+ array.shape[1].times do |x_coord|
70
+ rgb = array[y_coord, x_coord, true].to_a.map do |value|
71
+ (value.to_f.clamp(0.0, 1.0) * 255).round
72
+ end
73
+ image[x_coord, y_coord] = ChunkyPNG::Color.rgb(*rgb)
74
+ end
75
+ end
76
+ image.save(path.to_s)
77
+ path
78
+ end
79
+ end
80
+ end
81
+ end