commit 2077db79b2971ce006736dd0de8dbdccf26b0b4c Author: Tyson Brown Date: Tue Apr 30 17:19:35 2024 -0400 Initial commit. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..376af6e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module smariot.com/oklab + +go 1.22.1 diff --git a/oklab.go b/oklab.go new file mode 100644 index 0000000..d1121b1 --- /dev/null +++ b/oklab.go @@ -0,0 +1,174 @@ +// Based on: https://bottosson.github.io/posts/oklab/ +// +// OkLab first defines a transform from xyz, and multiplies by this matrix: +// [+0.8189330101, +0.3618667424, -0.1288597137] +// [+0.0329845436, +0.9293118715, +0.0361456387] +// [+0.0482003018, +0.2643662691, +0.633851707 ] +// +// Wikipedia lists this matrix for converting from linear sRGB to D65 CIE XYZ, so +// I'm considering it canonical: +// [+0.4124, +0.3576, +0.1805] +// [+0.2126, +0.7152, +0.0722] +// [+0.0193, +0.1192, +0.9505] +// +// Combined, we get: +// [+0.41217385032507, +0.5362974607032, +0.05146302925248] +// [+0.21187214048845, +0.6807476834212, +0.10740645682645] +// [+0.08831541121808, +0.2818663070584, +0.63026344660742] +// +// And its inverse: +// [+4.0767584135565013494237930518854, -3.3072279873944731418619352916485, +0.230721459944885632473018834049 ] +// [-1.2681810851624033989047813181437, +2.6092932102856398573991970933594, -0.3411121165477535569679616041822] +// [-0.0040984077180314400491332639337372, -0.70350366010241732765095902557887, +1.7068604529788013559365593912662] +// +// After a non-linear transformation (cube root), OkLab applies a second matrix: +// [+0.2104542553, +0.793617785, -0.0040720468] +// [+1.9779984951, -2.428592205, +0.4505937099] +// [+0.0259040371, +0.7827717662, -0.808675766 ] +// +// And its inverse: +// [+0.99999999845051981426207542502031, +0.39633779217376785682345989261573, +0.21580375806075880342314146183004 ] +// [+1.0000000088817607767160752456705, -0.10556134232365634941095687705472, -0.063854174771705903405254198817796] +// [+1.0000000546724109177012928651534, -0.089484182094965759689052745863391, -1.2914855378640917399489287529148 ] +// +// It should be noted that the numbers in the first column were supposed to be 1, and other implementations skip +// multiplying by that column altogether. I however am insane, and shall continue barging forward. + +package oklab + +import ( + "image/color" + "math" +) + +type Color struct { + Lightness, ChromaA, ChromaB, A float64 +} + +func linearize(c uint32) float64 { + l := float64(c) / 0xffff + + if l <= 0.039285714285714285714285714285714 { + return l / 12.923210180787861094641554898407 + } + + return math.Pow((l+0.055)/1.055, 2.4) +} + +func delinearize(l float64) uint32 { + switch { + case l <= 0: + return 0 + case l <= 0.0030399346397784299969770436366690: + return uint32(l*846922.57919793247683733430026710 + 0.5) + case l >= 1: + return 0xffff + default: + return uint32(69139.425*math.Pow(l, 1/2.4) - 3603.925) + } +} + +// FromNRGBA create an OkLab color from non-pre-multiplied RGBA. +func FromNRGBA(r, g, b, a uint32) Color { + rLin, gLin, bLin := linearize(r), linearize(g), linearize(b) + + l := math.Cbrt(0.41217385032507*rLin + 0.5362974607032*gLin + 0.05146302925248*bLin) + m := math.Cbrt(0.21187214048845*rLin + 0.6807476834212*gLin + 0.10740645682645*bLin) + s := math.Cbrt(0.08831541121808*rLin + 0.2818663070584*gLin + 0.63026344660742*bLin) + + return Color{ + 0.2104542553*l + 0.793617785*m - 0.0040720468*s, + 1.9779984951*l - 2.428592205*m + 0.4505937099*s, + 0.0259040371*l + 0.7827717662*m - 0.808675766*s, + float64(a) / float64(0xffff), + } +} + +// FromRGBA create an OkLab color from pre-multiplied RGBA. +func FromRGBA(r, g, b, a uint32) Color { + switch a { + case 0xffff: + // do nothing. + case 0: + // completely transparent, color information was lost. + // pretend it was gray. + r, g, b = 0x7fff, 0x7fff, 0x7fff + default: + // un-premultiply rgb. + // + // Note that I'm rounding up here, which is the opposite of what the NRGBA/NRGBA64 colors do, + // which may be a bug as RGBA64->NRGBA64->RGBA64 is lossy. + r = (r*0xffff + a - 1) / a + g = (g*0xffff + a - 1) / a + b = (b*0xffff + a - 1) / a + } + + return FromNRGBA(r, g, b, a) +} + +func cube(v float64) float64 { + return v * v * v +} + +// NRGBA converts to non-premultiplied RGBA. +func (c Color) NRGBA() (r, g, b, a uint32) { + l := cube(0.99999999845051981426207542502031*c.Lightness + 0.39633779217376785682345989261573*c.ChromaA + 0.21580375806075880342314146183004*c.ChromaB) + m := cube(1.0000000088817607767160752456705*c.Lightness - 0.10556134232365634941095687705472*c.ChromaA - 0.063854174771705903405254198817796*c.ChromaB) + s := cube(1.0000000546724109177012928651534*c.Lightness - 0.089484182094965759689052745863391*c.ChromaA - 1.2914855378640917399489287529148*c.ChromaB) + + r = delinearize(4.0767584135565013494237930518854*l - 3.3072279873944731418619352916485*m + 0.230721459944885632473018834049*s) + g = delinearize(-1.2681810851624033989047813181437*l + 2.6092932102856398573991970933594*m - 0.3411121165477535569679616041822*s) + b = delinearize(-0.0040984077180314400491332639337372*l - 0.70350366010241732765095902557887*m + 1.7068604529788013559365593912662*s) + a = uint32(c.A*0xffff + 0.5) + + return +} + +// RGBA converts to premultiplied RGBA. +func (c Color) RGBA() (r, g, b, a uint32) { + r, g, b, a = c.NRGBA() + + r = r * a / 0xffff + g = g * a / 0xffff + b = b * a / 0xffff + + return +} + +func sqr(a float64) float64 { + return a * a +} + +func Distance(a, b Color) float64 { + dL := a.Lightness*a.A - b.Lightness*b.A + da := a.ChromaA*a.A - b.ChromaA*b.A + db := a.ChromaB*a.A - b.ChromaB*b.A + dA := a.A - b.A + + return math.Sqrt(max(sqr(dL), sqr(dL+dA)) + max(sqr(da), sqr(da+dA)) + max(sqr(db), sqr(db+dA))) + //return math.Sqrt((sqr(dL)+sqr(da)+sqr(db))*(a.A*b.A) + sqr(dA)) +} + +func okLabModel(c color.Color) color.Color { + switch c := c.(type) { + case Color: + return c + + // Special handling for color.NRGBA and color.NRGBA64 + case color.NRGBA: + return FromNRGBA(uint32(c.R)*0x101, uint32(c.G)*0x101, uint32(c.B)*0x101, uint32(c.A)*0x101) + + case color.NRGBA64: + return FromNRGBA(uint32(c.R), uint32(c.G), uint32(c.B), uint32(c.A)) + + // This isn't a standard interface, but I'm going to check for it regardless. + case interface{ NRGBA() (r, g, b, a uint32) }: + return FromNRGBA(c.NRGBA()) + + default: + return FromRGBA(c.RGBA()) + } +} + +// Implements a color model for converting arbitrary colors to OKLab. +var Model = color.ModelFunc(okLabModel) diff --git a/oklab_test.go b/oklab_test.go new file mode 100644 index 0000000..c52f9cd --- /dev/null +++ b/oklab_test.go @@ -0,0 +1,195 @@ +package oklab + +import ( + "image/color" + "math" + "reflect" + "testing" +) + +func TestColor_NRGBA(t *testing.T) { + // Not checking the whole space because that would be slow, and alpha is given less resolution in this test. + // The increments were chosen because they're factors of 0xffff, so we'll end on the maximum value. + for a := uint32(0); a < 0x10000; a += 4369 { + for b := uint32(0); b < 0x10000; b += 1285 { + for g := uint32(0); g < 0x10000; g += 1285 { + for r := uint32(0); r < 0x10000; r += 1285 { + _r, _g, _b, _a := FromNRGBA(r, g, b, a).NRGBA() + + if r != _r || g != _g || b != _b || a != _a { + t.Errorf("NRGBA(0x%04x,0x%04x,0x%04x,0x%04x) -> OkLab -> NRGBA(0x%04x,0x%04x,0x%04x,0x%04x)", r, g, b, a, _r, _g, _b, _a) + return + } + } + } + } + } +} + +func TestColor_RGBA(t *testing.T) { + // This is similar to the NRGBA test, except the components step by a/17 instead of a fixed 1285. + // a/17 was chosen because 17 is a factor of 4369, so the components will end equalling a. + for a := uint32(0); a < 0x10000; a += 4369 { + step := max(1, a/17) + for b := uint32(0); b <= a; b += step { + for g := uint32(0); g <= a; g += step { + for r := uint32(0); r <= a; r += step { + _r, _g, _b, _a := FromRGBA(r, g, b, a).RGBA() + + if r != _r || g != _g || b != _b || a != _a { + t.Errorf("RGBA(0x%04x,0x%04x,0x%04x,0x%04x) -> OkLab -> RGBA(0x%04x,0x%04x,0x%04x,0x%04x)", r, g, b, a, _r, _g, _b, _a) + return + } + } + } + } + } +} + +func Test_delinearize(t *testing.T) { + for c := uint32(0); c < 0x10000; c++ { + got := delinearize(linearize(c)) + if got != c { + t.Errorf("delinearize(linearize(0x%04x)) != 0x%04x", c, got) + return + } + } +} + +// The NRGBA and NRGBA64 models don't have sensible ways to recover transparent colors from types it +// doesn't know about, so I'm going to help them out. +func fixedNRGBAModel(c color.Color) color.Color { + if c, ok := c.(interface{ NRGBA() (r, g, b, a uint32) }); ok { + r, g, b, a := c.NRGBA() + return color.NRGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: uint8(a >> 8)} + } + + return color.NRGBAModel.Convert(c) +} + +func fixedNRGBA64Model(c color.Color) color.Color { + if c, ok := c.(interface{ NRGBA() (r, g, b, a uint32) }); ok { + r, g, b, a := c.NRGBA() + return color.NRGBA64{R: uint16(r), G: uint16(g), B: uint16(b), A: uint16(a)} + } + + return color.NRGBA64Model.Convert(c) +} + +func Test_Model(t *testing.T) { + // test to make sure we can reproduce some test colors. + // In particular, I want to make sure the colors can be recovered from transparent NRGBA and NRGBA64. + for _, tt := range [...]struct { + name string + color color.Color + model color.Model + }{ + { + "OkLab: nop", + Color{math.Inf(1), math.Inf(-1), math.MaxFloat64, 0}, + Model, + }, { + "NRGBA: opaque Sapphire", + color.NRGBA{R: 0x0f, G: 0x52, B: 0xba, A: 0xff}, + color.ModelFunc(fixedNRGBAModel), + }, { + "NRGBA: translucent Orchid", + color.NRGBA{R: 0xda, G: 0x70, B: 0xd6, A: 0x7f}, + color.ModelFunc(fixedNRGBAModel), + }, { + "NRGBA: invisible Floral White", + color.NRGBA{R: 0xff, G: 0xfa, B: 0xf0, A: 0x00}, + color.ModelFunc(fixedNRGBAModel), + }, { + "NRGBA64: opaque Amethyst", + color.NRGBA64{R: 0x9999, G: 0x6666, B: 0xcccc, A: 0xffff}, + color.ModelFunc(fixedNRGBA64Model), + }, { + "NRGBA64: translucent Smoke", + color.NRGBA64{R: 0xf5f5, G: 0xf5f5, B: 0xf5f5, A: 0x7fff}, + color.ModelFunc(fixedNRGBA64Model), + }, { + "NRGBA64: invisible Emerald", + color.NRGBA64{R: 0x5050, G: 0xC8C8, B: 0x7878, A: 0x0000}, + color.ModelFunc(fixedNRGBA64Model), + }, { + "RGBA64: opaque Chartreuse", + color.RGBA64{R: 0xb2b2, G: 0xd6d6, B: 0x3f3f, A: 0xffff}, + color.RGBA64Model, + }, { + "RGBA64: transparent Mauveine", + color.RGBA64{R: 0x577c, G: 0x013e, B: 0x602b, A: 0x9e37}, + color.RGBA64Model, + }, { + "RGBA64: invisible nothing", + color.RGBA64{R: 0x0, G: 0x0, B: 0x0, A: 0x0}, + color.RGBA64Model, + }, { + "RGBA: opaque Chartreuse", + color.RGBA{R: 0xb2, G: 0xd6, B: 0x3f, A: 0xff}, + color.RGBAModel, + }, { + "RGBA: transparent Mauveine", + color.RGBA{R: 0x57, G: 0x01, B: 0x60, A: 0x9e}, + color.RGBAModel, + }, { + "RGBA: invisible nothing", + color.RGBA{R: 0x0, G: 0x0, B: 0x0, A: 0x0}, + color.RGBAModel, + }, + } { + t.Run(tt.name, func(t *testing.T) { + c := Model.Convert(tt.color).(Color) + + if got := tt.model.Convert(c); !reflect.DeepEqual(got, tt.color) { + t.Errorf("%#+v -> %#+v -> %#+v, want %#+v", tt.color, c, got, tt.color) + return + } + }) + } +} + +func TestDistance(t *testing.T) { + colours := []Color{ + FromNRGBA(0xffff, 0xffff, 0xffff, 0xffff), + FromNRGBA(0xffff, 0xffff, 0xffff, 0x7fff), + FromNRGBA(0xffff, 0xffff, 0xffff, 0x0000), + FromNRGBA(0x0000, 0x0000, 0x0000, 0xffff), + FromNRGBA(0x0000, 0x0000, 0x0000, 0x7fff), + FromNRGBA(0x0000, 0x0000, 0x0000, 0x0000), + } + + for i, c0 := range colours { + for j, c1 := range colours { + d := Distance(c0, c1) + + if i == j || (c0.A == 0 && c1.A == 0) { + // if they're the same color, or both are completely transparent, + // they should be perceived as identical. + + if d != 0 { + t.Errorf("Distance(%v, %v) = %v, want 0", c0, c1, d) + } + } else { + // otherwise, there should be some kind of difference between them. + if d <= 0 { + t.Errorf("Distance(%v, %v) = %v, want > 0", c0, c1, d) + } + } + + mid := Color{ + Lightness: (c0.Lightness + c1.Lightness) * 0.5, + ChromaA: (c0.ChromaA + c1.ChromaA) * 0.5, + ChromaB: (c0.ChromaB + c1.ChromaB) * 0.5, + A: (c0.A + c1.A) * 0.5, + } + + // traveling from c0 to c1 via mid can't possibly be + // shorter than traveling from c0 to c1 directly. + d2 := Distance(c0, mid) + Distance(mid, c1) + if d2 < d { + t.Errorf("Distance(%v, %v)+Distance(%v, %v) < Distance(%v, %v), want %f >= %f", c0, mid, mid, c1, c0, c1, d2, d) + } + } + } +}