okmain 0.1.0 → 0.2.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1c82cf5cfc87c588329a84ccb9b8118f7f27d7818a45150b021310dc28e2049
4
- data.tar.gz: bef38f1590b89386ec4c8c9287edb55b5bb08255c787ec7b75d3ac45a6a81580
3
+ metadata.gz: 56e572b6cb9c8626fa17484a93fb4b5172d4f34f653071b7ca223548dd067926
4
+ data.tar.gz: 200af9151a6a0efd3abb01374eba1899b952c4e3b61a6455cd651fdf97516ecc
5
5
  SHA512:
6
- metadata.gz: dba390c347ccd57cd71ee46b03ae5699614c6754802e8c465cf700025d078eeca8982374a575705f810469801190310e14c11c379df2808b6cce6a8eff13e0d3
7
- data.tar.gz: b740152ded762d8bea52e83d81ee6eab78aab7ccc3a41ecbdf1be6e253051315dbd3f92fe553dc8df609adf0dcf7027ad7842c97cc54d7993dcbf7161b72d33e
6
+ metadata.gz: 88803b1165696b26217ca5ed76f15b0f1e946e8280b73a084c62d7f37878ffef5465ceadb071f14a1c6fedf0e3d8699a1f3942f841f5c560a92b87b8c76e9f87
7
+ data.tar.gz: 11b17ecaef7d6cd283e6042674f07ab3a40e06ac8a15c1d1c80f147f31ba9da627f15757cdb80ab8ccc24c85da2fccc3281a297f83e6805317505f359a7bec57
data/CHANGELOG.md ADDED
@@ -0,0 +1,50 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project might adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) if I ever get around to releasing a 1.0, but I wouldn't count on that happening any time soon.
7
+
8
+ ## [Unreleased]
9
+
10
+ - May try to make KMeans faster: now that I have harvested the low-hanging fruit in sampler performance, kmeans is almost 90% of runtime on my test images.
11
+ - Adding a suite of test images and automating benchmarking new versions against old versions for speed and correctness would be cool.
12
+ - Even starting this project was AI psychosis. Would have been smarter to see if I like the output of the algorithm for the one specific thing I need this for first, no?
13
+
14
+ ## [0.2.1] - 2026-04-25
15
+
16
+ ### Added
17
+ - `Okmain::Oklab` conversion helpers needed by a downstream app:
18
+ `srgb_to_linear`, `srgb8_to_oklab`, `oklab_to_oklch`, `oklch_to_oklab`,
19
+ `srgb8_to_oklch`. Hue is in degrees, normalized to `[0, 360)`.
20
+
21
+ ## [0.2.0] - 2026-04-25
22
+
23
+ ### Changed
24
+ - RNG replaced with a Xoshiro256++ implementation matching the upstream Rust
25
+ `okmain` crate, so results closely track the reference implementation.
26
+ Output for a given image will differ from 0.1.0.
27
+ - Sampler, distance mask, scorer, and Oklab conversion paths reworked for
28
+ speed and closer parity with the Rust implementation.
29
+
30
+ ### Added
31
+ - `Okmain::Xoshiro` PRNG (`lib/okmain/xoshiro.rb`).
32
+ - `bench.rb` benchmark script and a `rust_compare/` companion crate for
33
+ apples-to-apples timing and output comparisons against the Rust crate.
34
+ - `Rakefile` exposing the standard `bundler/gem_tasks` (`build`, `install`,
35
+ `release`).
36
+ - Development dependencies: `benchmark ~> 0.4`, `rake ~> 13.0`.
37
+
38
+ ## [0.1.0] - 2026-04-24
39
+
40
+ ### Added
41
+ - Initial port of the Rust `okmain` crate to Ruby: extracts up to 4 dominant
42
+ colors from an image via adaptive K-means clustering in Oklab space.
43
+ - `ruby-vips`-based image loading with block-average downsampling.
44
+ - Dual MIT / Apache-2.0 licensing matching the upstream crate.
45
+ - README with install and usage examples.
46
+
47
+ [Unreleased]: https://github.com/midnightmonster/okmain-ruby/compare/v0.2.1...HEAD
48
+ [0.2.1]: https://github.com/midnightmonster/okmain-ruby/compare/v0.2.0...v0.2.1
49
+ [0.2.0]: https://github.com/midnightmonster/okmain-ruby/compare/v0.1.0...v0.2.0
50
+ [0.1.0]: https://github.com/midnightmonster/okmain-ruby/releases/tag/v0.1.0
data/README.md CHANGED
@@ -62,6 +62,35 @@ Colors are returned as `[r, g, b]` arrays (0-255), sorted by score descending. T
62
62
  3. **Score** — Centroids ranked by a weighted combination of pixel count (with center-priority mask) and chroma
63
63
  4. **Return** — Up to 4 `[r, g, b]` arrays sorted by score
64
64
 
65
+ ## Comparing with the Rust implementation
66
+
67
+ A small Rust binary is included in `rust_compare/` for verifying results against the original [okmain](https://github.com/si14/okmain) crate. Requires [Rust](https://rustup.rs/) 1.93+.
68
+
69
+ Build it once:
70
+
71
+ ```
72
+ cd rust_compare
73
+ cargo build --release
74
+ ```
75
+
76
+ Then compare outputs on any image:
77
+
78
+ ```bash
79
+ # Rust
80
+ rust_compare/target/release/rust_compare path/to/photo.jpg
81
+
82
+ # Ruby
83
+ bundle exec ruby -e "require 'okmain'; pp Okmain.colors('path/to/photo.jpg')"
84
+ ```
85
+
86
+ Both print `[r, g, b]` arrays in the same order, so results can be diffed directly.
87
+
88
+ For benchmarking (against rust, but mostly to catch progress or regression in the gem), use:
89
+
90
+ ```bash
91
+ bundle exec ruby bench.rb path/to/image.png
92
+ ```
93
+
65
94
  ## Credits
66
95
 
67
96
  Based on the Rust [okmain](https://github.com/si14/okmain) crate (v0.2.0) by Dan Groshev and okmain contributors.
data/lib/okmain/config.rb CHANGED
@@ -2,14 +2,24 @@
2
2
 
3
3
  module Okmain
4
4
  class Config
5
- attr_reader :chroma_weight, :mask_weighted_counts_weight
5
+ attr_reader :chroma_weight, :mask_weighted_counts_weight,
6
+ :mask_weight, :mask_saturated_threshold
6
7
 
7
- def initialize(chroma_weight: 0.7, mask_weighted_counts_weight: 0.3)
8
+ def initialize(
9
+ chroma_weight: 0.7,
10
+ mask_weighted_counts_weight: 0.3,
11
+ mask_weight: 1.0,
12
+ mask_saturated_threshold: 0.3
13
+ )
8
14
  raise ArgumentError, "chroma_weight must be between 0 and 1" unless (0.0..1.0).cover?(chroma_weight)
9
15
  raise ArgumentError, "mask_weighted_counts_weight must be between 0 and 1" unless (0.0..1.0).cover?(mask_weighted_counts_weight)
16
+ raise ArgumentError, "mask_weight must be between 0 and 1" unless (0.0..1.0).cover?(mask_weight)
17
+ raise ArgumentError, "mask_saturated_threshold must be between 0 and 0.5 (exclusive)" unless mask_saturated_threshold >= 0.0 && mask_saturated_threshold < 0.5
10
18
 
11
19
  @chroma_weight = chroma_weight.to_f
12
20
  @mask_weighted_counts_weight = mask_weighted_counts_weight.to_f
21
+ @mask_weight = mask_weight.to_f
22
+ @mask_saturated_threshold = mask_saturated_threshold.to_f
13
23
  end
14
24
  end
15
25
  end
@@ -2,23 +2,24 @@
2
2
 
3
3
  module Okmain
4
4
  module DistanceMask
5
- SATURATED_THRESHOLD = 0.75
6
-
7
5
  module_function
8
6
 
9
- # Returns a flat Array of mask values (0.1..1.0) for each pixel, matching Rust's rectangular mask.
7
+ # Returns a flat Array of mask values (0.1..1.0) for each pixel,
8
+ # matching the Rust rectangular mask exactly.
10
9
  # Center pixels get 1.0, corner pixels get 0.1, with a linear ramp.
11
- def compute(width, height)
12
- half_w = width * 0.5
13
- half_h = height * 0.5
14
- x_threshold = half_w * SATURATED_THRESHOLD
15
- y_threshold = half_h * SATURATED_THRESHOLD
10
+ def compute(width, height, saturated_threshold)
11
+ w = width.to_f
12
+ h = height.to_f
13
+ middle_x = w / 2.0
14
+ middle_y = h / 2.0
15
+ x_threshold = w * saturated_threshold
16
+ y_threshold = h * saturated_threshold
16
17
 
17
18
  mask = Array.new(width * height)
18
19
  y = 0
19
20
  while y < height
20
- # Distance from nearest y-edge (0 at edge, half_h at center)
21
- my = y < half_h ? y.to_f : (height - 1 - y).to_f
21
+ yf = y.to_f
22
+ my = yf <= middle_y ? yf : h - yf
22
23
  y_contrib = if y_threshold > 0.0
23
24
  v = 0.1 + 0.9 * (my / y_threshold)
24
25
  v < 1.0 ? v : 1.0
@@ -29,8 +30,8 @@ module Okmain
29
30
  row_offset = y * width
30
31
  x = 0
31
32
  while x < width
32
- # Distance from nearest x-edge (0 at edge, half_w at center)
33
- mx = x < half_w ? x.to_f : (width - 1 - x).to_f
33
+ xf = x.to_f
34
+ mx = xf <= middle_x ? xf : w - xf
34
35
  x_contrib = if x_threshold > 0.0
35
36
  v = 0.1 + 0.9 * (mx / x_threshold)
36
37
  v < 1.0 ? v : 1.0
data/lib/okmain/kmeans.rb CHANGED
@@ -7,13 +7,14 @@ module Okmain
7
7
  LLOYDS_CONVERGENCE_TOL = 1e-3
8
8
  SIMILAR_CLUSTER_DISTANCE_SQ = 0.005
9
9
  KMEANSPP_CANDIDATES = 3
10
+ RANDOM_SEED = 314159
10
11
 
11
12
  module_function
12
13
 
13
14
  # Returns [centroids, assignments] where centroids is Array of [L, a, b]
14
15
  # and assignments is Array of centroid indices per pixel.
15
16
  def cluster(pixels, k: MAX_CENTROIDS)
16
- rng = Random.new(42)
17
+ rng = Xoshiro256PlusPlus.new(RANDOM_SEED)
17
18
  n = pixels.size
18
19
  return [pixels.dup, Array.new(n) { |i| i }] if n <= k
19
20
 
@@ -45,7 +46,7 @@ module Okmain
45
46
  # K-means++ initialization with 3 candidates per step
46
47
  def init_plusplus(pixels, k, rng)
47
48
  n = pixels.size
48
- centroids = [pixels[rng.rand(n)].dup]
49
+ centroids = [pixels[rng.random_range(n)].dup]
49
50
 
50
51
  dist_sq = Array.new(n, Float::INFINITY)
51
52
 
@@ -64,13 +65,13 @@ module Okmain
64
65
  best_potential = Float::INFINITY
65
66
 
66
67
  KMEANSPP_CANDIDATES.times do
67
- # Weighted random selection
68
- r = rng.rand * total
68
+ # Weighted random selection (matching Rust's sample_by_distance)
69
+ r = rng.random_f32 * total
69
70
  cumulative = 0.0
70
71
  idx = 0
71
72
  while idx < n
72
73
  cumulative += dist_sq[idx]
73
- if cumulative >= r
74
+ if cumulative > r
74
75
  break
75
76
  end
76
77
  idx += 1
@@ -143,7 +144,7 @@ module Okmain
143
144
  while c < k
144
145
  if counts[c] == 0
145
146
  # Reassign empty cluster to random data point
146
- ri = rng.rand(n)
147
+ ri = rng.random_range(n)
147
148
  new_centroids[c] = pixels[ri].dup
148
149
  else
149
150
  inv = 1.0 / counts[c]
data/lib/okmain/oklab.rb CHANGED
@@ -38,16 +38,6 @@ module Okmain
38
38
  [r, g, b_]
39
39
  end
40
40
 
41
- # sRGB component (0..255) → linear (0..1)
42
- def srgb_to_linear(c)
43
- c = c / 255.0
44
- if c <= 0.04045
45
- c / 12.92
46
- else
47
- ((c + 0.055) / 1.055)**2.4
48
- end
49
- end
50
-
51
41
  # Linear (0..1) → sRGB component (0..255), clamped
52
42
  def linear_to_srgb(c)
53
43
  c = c.clamp(0.0, 1.0)
@@ -59,10 +49,45 @@ module Okmain
59
49
  (c * 255.0).round.clamp(0, 255)
60
50
  end
61
51
 
52
+ # sRGB component (0..255) → linear (0..1)
53
+ def srgb_to_linear(c)
54
+ c = c / 255.0
55
+ if c <= 0.04045
56
+ c / 12.92
57
+ else
58
+ ((c + 0.055) / 1.055)**2.4
59
+ end
60
+ end
61
+
62
62
  # Oklab → sRGB [r, g, b] (0..255)
63
63
  def oklab_to_srgb8(l, a, b)
64
64
  r, g, b_ = oklab_to_linear_rgb(l, a, b)
65
65
  [linear_to_srgb(r), linear_to_srgb(g), linear_to_srgb(b_)]
66
66
  end
67
+
68
+ # sRGB [r, g, b] (0..255) → Oklab [L, a, b]
69
+ def srgb8_to_oklab(r, g, b)
70
+ linear_rgb_to_oklab(srgb_to_linear(r), srgb_to_linear(g), srgb_to_linear(b))
71
+ end
72
+
73
+ # Oklab [L, a, b] → OkLCh [L, C, h] with h in degrees [0, 360)
74
+ def oklab_to_oklch(l, a, b)
75
+ c = Math.sqrt(a * a + b * b)
76
+ h = Math.atan2(b, a) * 180.0 / Math::PI
77
+ h += 360.0 if h.negative?
78
+ [l, c, h]
79
+ end
80
+
81
+ # OkLCh [L, C, h] (h in degrees) → Oklab [L, a, b]
82
+ def oklch_to_oklab(l, c, h)
83
+ rad = h * Math::PI / 180.0
84
+ [l, c * Math.cos(rad), c * Math.sin(rad)]
85
+ end
86
+
87
+ # sRGB [r, g, b] (0..255) → OkLCh [L, C, h] with h in degrees [0, 360)
88
+ def srgb8_to_oklch(r, g, b)
89
+ l, a, b_ = srgb8_to_oklab(r, g, b)
90
+ oklab_to_oklch(l, a, b_)
91
+ end
67
92
  end
68
93
  end
@@ -26,27 +26,37 @@ module Okmain
26
26
  block_size = compute_block_size(total)
27
27
 
28
28
  if block_size > 1
29
+ # Claude wanted to do this in Ruby for a more-exact rust match, because apparently image.shrink
30
+ # uses a slightly different algorithm, but an occasional 1 or 2 RGB value difference is a small
31
+ # price for the huge speedup in letting VIPS do it for us.
29
32
  image = image.shrink(block_size, block_size)
30
33
  end
31
34
 
32
35
  width = image.width
33
36
  height = image.height
34
37
 
38
+ # Claude's approach: Doing the colourspace translation ourselves
39
+ # ==============================================================
35
40
  # Extract float pixel data [r, g, b] in linear space
36
- floats = image.write_to_memory.unpack("f*")
37
- bands = image.bands
38
- pixel_count = width * height
41
+ # floats = image.write_to_memory.unpack("f*")
42
+ # bands = image.bands
43
+ # pixel_count = width * height
39
44
 
40
- pixels = Array.new(pixel_count)
41
- i = 0
42
- while i < pixel_count
43
- offset = i * bands
44
- r = floats[offset]
45
- g = floats[offset + 1]
46
- b = floats[offset + 2]
47
- pixels[i] = Oklab.linear_rgb_to_oklab(r, g, b)
48
- i += 1
49
- end
45
+ # pixels = Array.new(pixel_count)
46
+ # i = 0
47
+ # while i < pixel_count
48
+ # offset = i * bands
49
+ # r = floats[offset]
50
+ # g = floats[offset + 1]
51
+ # b = floats[offset + 2]
52
+ # pixels[i] = Oklab.linear_rgb_to_oklab(r, g, b)
53
+ # i += 1
54
+ # end
55
+
56
+ # My approach: more than twice as fast to let VIPS do it for us
57
+ # =============================================================
58
+ image = image.colourspace(:oklab)
59
+ pixels = image.write_to_memory.unpack("f*").each_slice(3).to_a
50
60
 
51
61
  [pixels, width, height]
52
62
  end
data/lib/okmain/scorer.rb CHANGED
@@ -10,7 +10,8 @@ module Okmain
10
10
  def score(centroids, assignments, mask, config)
11
11
  k = centroids.size
12
12
  n = assignments.size
13
- mask_w = config.mask_weighted_counts_weight
13
+ mask_w = config.mask_weight
14
+ counts_w = config.mask_weighted_counts_weight
14
15
  chroma_w = config.chroma_weight
15
16
 
16
17
  # Weighted counts per centroid
@@ -37,7 +38,7 @@ module Okmain
37
38
  # Score each centroid
38
39
  scored = centroids.each_with_index.map do |centroid, idx|
39
40
  chroma = Math.sqrt(centroid[1] * centroid[1] + centroid[2] * centroid[2]) / MAX_SRGB_OKLAB_CHROMA
40
- score = (1.0 - chroma_w) * weighted_counts[idx] + chroma_w * chroma
41
+ score = counts_w * weighted_counts[idx] + chroma_w * chroma
41
42
  [score, centroid]
42
43
  end
43
44
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Okmain
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Okmain
4
+ # Xoshiro256++ PRNG, matching Rust's rand_xoshiro crate exactly.
5
+ # Seeding via SplitMix64 matches Rust's SeedableRng::seed_from_u64.
6
+ class Xoshiro256PlusPlus
7
+ MASK64 = (1 << 64) - 1
8
+
9
+ def initialize(seed)
10
+ # SplitMix64 seeding (matches Rust's SeedableRng::seed_from_u64)
11
+ sm = seed & MASK64
12
+ @s = Array.new(4) do
13
+ sm = (sm + 0x9e3779b97f4a7c15) & MASK64
14
+ z = sm
15
+ z = ((z ^ (z >> 30)) * 0xbf58476d1ce4e5b9) & MASK64
16
+ z = ((z ^ (z >> 27)) * 0x94d049bb133111eb) & MASK64
17
+ z ^ (z >> 31)
18
+ end
19
+ end
20
+
21
+ # Generate next u64
22
+ def next_u64
23
+ s = @s
24
+ result = (rotl(s[0] + s[3], 23) + s[0]) & MASK64
25
+ t = (s[1] << 17) & MASK64
26
+ s[2] ^= s[0]
27
+ s[3] ^= s[1]
28
+ s[1] ^= s[2]
29
+ s[0] ^= s[3]
30
+ s[2] ^= t
31
+ s[3] = rotl(s[3], 45)
32
+ result
33
+ end
34
+
35
+ # Uniform integer in 0...range, using Lemire's method (matching Rust's rand crate)
36
+ def random_range(range)
37
+ full = next_u64 * range
38
+ hi = full >> 64
39
+ lo = full & MASK64
40
+ if lo < range
41
+ threshold = (MASK64 - range + 1) % range # (2^64 - range) % range
42
+ while lo < threshold
43
+ full = next_u64 * range
44
+ hi = full >> 64
45
+ lo = full & MASK64
46
+ end
47
+ end
48
+ hi
49
+ end
50
+
51
+ # Uniform f32 in [0, 1), matching Rust's rand StandardUniform for f32.
52
+ # Xoshiro256++.next_u32() returns next_u64() >> 32 (upper bits).
53
+ # Then StandardUniform takes top 24 bits of the u32.
54
+ def random_f32
55
+ u64 = next_u64
56
+ u32 = u64 >> 32 # Rust's next_u32() for Xoshiro256++
57
+ (u32 >> 8).to_f / 16777216.0 # top 24 bits / 2^24
58
+ end
59
+
60
+ private
61
+
62
+ def rotl(x, k)
63
+ ((x << k) | (x >> (64 - k))) & MASK64
64
+ end
65
+ end
66
+ end
data/lib/okmain.rb CHANGED
@@ -5,6 +5,7 @@ require_relative "okmain/config"
5
5
  require_relative "okmain/oklab"
6
6
  require_relative "okmain/sampler"
7
7
  require_relative "okmain/distance_mask"
8
+ require_relative "okmain/xoshiro"
8
9
  require_relative "okmain/kmeans"
9
10
  require_relative "okmain/scorer"
10
11
 
@@ -18,7 +19,7 @@ module Okmain
18
19
  # @return [Array<Array<Integer>>] up to 4 [r, g, b] arrays sorted by score
19
20
  def colors(input, config: Config.new)
20
21
  pixels, width, height = Sampler.sample(input)
21
- mask = DistanceMask.compute(width, height)
22
+ mask = DistanceMask.compute(width, height, config.mask_saturated_threshold)
22
23
  centroids, assignments = KMeans.cluster(pixels)
23
24
  Scorer.score(centroids, assignments, mask, config)
24
25
  end
data/okmain.gemspec CHANGED
@@ -13,9 +13,13 @@ Gem::Specification.new do |spec|
13
13
 
14
14
  spec.metadata["homepage_uri"] = spec.homepage
15
15
  spec.metadata["source_code_uri"] = spec.homepage
16
+ spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/main/CHANGELOG.md"
16
17
 
17
- spec.files = Dir["lib/**/*.rb"] + ["okmain.gemspec", "README.md", "LICENSE-MIT", "LICENSE-APACHE"]
18
+ spec.files = Dir["lib/**/*.rb"] + ["okmain.gemspec", "README.md", "CHANGELOG.md", "LICENSE-MIT", "LICENSE-APACHE"]
18
19
  spec.require_paths = ["lib"]
19
20
 
20
21
  spec.add_dependency "ruby-vips", "~> 2.1"
22
+
23
+ spec.add_development_dependency "benchmark", "~> 0.4"
24
+ spec.add_development_dependency "rake", "~> 13.0"
21
25
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: okmain
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joshua Paine
@@ -23,10 +23,39 @@ dependencies:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
25
  version: '2.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: benchmark
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '0.4'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '0.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: rake
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '13.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '13.0'
26
54
  executables: []
27
55
  extensions: []
28
56
  extra_rdoc_files: []
29
57
  files:
58
+ - CHANGELOG.md
30
59
  - LICENSE-APACHE
31
60
  - LICENSE-MIT
32
61
  - README.md
@@ -38,6 +67,7 @@ files:
38
67
  - lib/okmain/sampler.rb
39
68
  - lib/okmain/scorer.rb
40
69
  - lib/okmain/version.rb
70
+ - lib/okmain/xoshiro.rb
41
71
  - okmain.gemspec
42
72
  homepage: https://github.com/midnightmonster/okmain-ruby
43
73
  licenses:
@@ -46,6 +76,7 @@ licenses:
46
76
  metadata:
47
77
  homepage_uri: https://github.com/midnightmonster/okmain-ruby
48
78
  source_code_uri: https://github.com/midnightmonster/okmain-ruby
79
+ changelog_uri: https://github.com/midnightmonster/okmain-ruby/blob/main/CHANGELOG.md
49
80
  rdoc_options: []
50
81
  require_paths:
51
82
  - lib
@@ -60,7 +91,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
60
91
  - !ruby/object:Gem::Version
61
92
  version: '0'
62
93
  requirements: []
63
- rubygems_version: 3.6.9
94
+ rubygems_version: 4.0.6
64
95
  specification_version: 4
65
96
  summary: Extract dominant colors from images using adaptive K-means in Oklab space
66
97
  test_files: []