├── LICENSE ├── application.coffee ├── README.md ├── color-tunes.coffee └── quantize.coffee /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Shao-Chung Chen 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /application.coffee: -------------------------------------------------------------------------------- 1 | $(document).ready -> 2 | $(".playlist").addClass "closed hidden transition transition-height" 3 | $(".cover-picker").on "click", "a", (event) -> 4 | coverAnchor = @ 5 | coverSelected = coverAnchor.parentNode 6 | coverActive = $(".cover-picker .active") 7 | albumSelected = coverAnchor.href.replace "#", "" 8 | indicatorPosition = (coverSelected.offsetLeft + (coverSelected.offsetWidth / 2) - 15) 9 | playlistHeight = $(".playlist-inner")[0].getBoundingClientRect().height 10 | 11 | togglePlaylistForAlbum = (album) => 12 | isExpanding = $(".playlist").hasClass "closed" 13 | targetHeight = if isExpanding then 400 else 0 14 | 15 | if isExpanding 16 | $(".playlist").removeClass "hidden" 17 | $(".playlist-indicator").removeClass("hidden").css "left", indicatorPosition 18 | else 19 | $(".playlist").on "webkitTransitionEnd", -> 20 | $(".playlist-indicator").addClass "hidden" 21 | $(".playlist").addClass("hidden").off "webkitTransitionEnd" 22 | 23 | $(coverSelected).toggleClass "active" 24 | $(".playlist").toggleClass("closed expanded").height targetHeight 25 | 26 | switchPlaylistToAlbum = (album) -> 27 | $(coverActive).removeClass "active" 28 | $(coverSelected).addClass "active" 29 | $(".playlist-indicator").css "left", indicatorPosition 30 | 31 | if ($(coverActive).length is 0) or (coverSelected is coverActive[0]) 32 | togglePlaylistForAlbum albumSelected 33 | else if $(coverActive).length > 0 34 | switchPlaylistToAlbum albumSelected 35 | 36 | coverAnchor.blur() 37 | event.preventDefault() 38 | 39 | canvas = document.getElementById "album-artwork" 40 | image = new Image 41 | image.src = coverAnchor.childNodes[0].src 42 | 43 | ColorTunes.launch image, canvas 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ColorTunes 2 | ========== 3 | 4 | This is a HTML5 version of the iTunes 11 album view, which is able to detect the colors in an album cover and generate color scheme for its track list based on the reduced color space. 5 | 6 | Color palettes generation is based on the [MMCQ (median cut color quantization) algorithm](http://www.leptonica.com/papers/mediancut.pdf) from the [Leptonica library](http://www.leptonica.com/). 7 | 8 | 9 | Demo 10 | ---- 11 | Please visit [http://dannvix.github.com/ColorTunes](http://dannvix.github.com/ColorTunes/). 12 | 13 | 14 | Acknowledgements 15 | ---------------- 16 | Million thanks to [Zhusee Zhang](http://twitter.com/zhusee2) for the [page design](http://github.com/zhusee2/coverTunes). Thank [Nick Rabinowitz](http://github.com/nrabinowitz) for his [JavaScript port](https://gist.github.com/1104622) of the MMCQ algorithm. 17 | 18 | 19 | License 20 | ------- 21 | Copyright (c) 2012 Shao-Chung Chen 22 | 23 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 24 | 25 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 26 | 27 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /color-tunes.coffee: -------------------------------------------------------------------------------- 1 | # color-tunes.coffee, Copyright 2012 Shao-Chung Chen. 2 | # Licensed under the MIT license (http://www.opensource.org/licenses/mit-license.php) 3 | 4 | class ColorTunes 5 | @getColorMap: (canvas, sx, sy, w, h, nc=8) -> 6 | pdata = canvas.getContext("2d").getImageData(sx, sy, w, h).data 7 | pixels = [] 8 | for y in [sy...(sy + h)] by 1 9 | indexBase = (y * w * 4) 10 | for x in [sx...(sx + w)] by 1 11 | index = (indexBase + (x * 4)) 12 | pixels.push [pdata[index], pdata[index+1], pdata[index+2]] # [r, g, b] 13 | (new MMCQ).quantize pixels, nc 14 | 15 | @colorDist: (a, b) -> 16 | square = (n) -> (n * n) 17 | (square(a[0] - b[0]) + square(a[1] - b[1]) + square(a[2] - b[2])) 18 | 19 | @fadeout: (canvas, width, height, opa=0.5, color=[0, 0, 0]) -> 20 | idata = canvas.getContext("2d").getImageData 0, 0, width, height 21 | pdata = idata.data 22 | for y in [0...height] 23 | for x in [0...width] 24 | idx = (y * width + x) * 4 25 | pdata[idx+0] = opa * pdata[idx+0] + (1 - opa) * color[0] 26 | pdata[idx+1] = opa * pdata[idx+1] + (1 - opa) * color[1] 27 | pdata[idx+2] = opa * pdata[idx+2] + (1 - opa) * color[2] 28 | canvas.getContext("2d").putImageData idata, 0, 0 29 | 30 | @feathering: (canvas, width, height, size=50, color=[0, 0, 0]) -> 31 | idata = canvas.getContext("2d").getImageData 0, 0, width, height 32 | pdata = idata.data 33 | 34 | conv = (x, y, p) -> 35 | p = 0 if p < 0 36 | p = 1 if p > 1 37 | idx = (y * width + x) * 4 38 | pdata[idx+0] = p * pdata[idx+0] + (1 - p) * color[0] 39 | pdata[idx+1] = p * pdata[idx+1] + (1 - p) * color[1] 40 | pdata[idx+2] = p * pdata[idx+2] + (1 - p) * color[2] 41 | 42 | dist = (xa, ya, xb, yb) -> 43 | Math.sqrt((xb-xa)*(xb-xa) + (yb-ya)*(yb-ya)) 44 | 45 | for x in [0...width] by 1 46 | for y in [0...size] by 1 47 | p = y / size 48 | p = 1 - dist(x, y, size, size) / size if x < size 49 | conv x, y, p 50 | for y in [(0 + size)...height] by 1 51 | for x in [0...size] by 1 52 | p = x / size 53 | conv x, y, p 54 | canvas.getContext("2d").putImageData idata, 0, 0 55 | 56 | @mirror: (canvas, sy, height, color=[0, 0, 0]) -> 57 | width = canvas.width 58 | idata = canvas.getContext("2d").getImageData 0, (sy - height), width, (height * 2) 59 | pdata = idata.data 60 | for y in [height...(height * 2)] by 1 61 | for x in [0...width] by 1 62 | idx = (y * width + x) * 4 63 | idxu = ((height * 2 - y) * width + x) * 4 64 | p = (y - height) / height + 0.33 65 | p = 1 if p > 1 66 | pdata[idx+0] = (1 - p) * pdata[idxu+0] + p * color[0] 67 | pdata[idx+1] = (1 - p) * pdata[idxu+1] + p * color[1] 68 | pdata[idx+2] = (1 - p) * pdata[idxu+2] + p * color[2] 69 | pdata[idx+3] = 255 70 | canvas.getContext("2d").putImageData idata, 0, (sy - height) 71 | 72 | @launch: (image, canvas) -> 73 | $(image).on "load", -> 74 | image.height = Math.round (image.height * (300 / image.width)) 75 | image.width = 300 76 | 77 | canvas.width = image.width 78 | canvas.height = image.height + 150 79 | canvas.getContext("2d").drawImage image, 0, 0, image.width, image.height 80 | 81 | bgColorMap = ColorTunes.getColorMap canvas, 0, 0, (image.width * 0.5), (image.height), 4 82 | bgPalette = bgColorMap.cboxes.map (cbox) -> { count: cbox.cbox.count(), rgb: cbox.color } 83 | bgPalette.sort (a, b) -> (b.count - a.count) 84 | bgColor = bgPalette[0].rgb 85 | 86 | fgColorMap = ColorTunes.getColorMap canvas, 0, 0, image.width, image.height, 10 87 | fgPalette = fgColorMap.cboxes.map (cbox) -> { count: cbox.cbox.count(), rgb: cbox.color } 88 | fgPalette.sort (a, b) -> (b.count - a. count) 89 | 90 | maxDist = 0 91 | for color in fgPalette 92 | dist = ColorTunes.colorDist bgColor, color.rgb 93 | if dist > maxDist 94 | maxDist = dist 95 | fgColor = color.rgb 96 | 97 | maxDist = 0 98 | for color in fgPalette 99 | dist = ColorTunes.colorDist bgColor, color.rgb 100 | if dist > maxDist and color.rgb != fgColor 101 | maxDist = dist 102 | fgColor2 = color.rgb 103 | 104 | ColorTunes.fadeout canvas, image.width, image.height, 0.5, bgColor 105 | ColorTunes.feathering canvas, image.width, image.height, 60, bgColor 106 | ColorTunes.mirror canvas, (image.height - 1), 150, bgColor 107 | 108 | rgbToCssString = (color) -> 109 | "rgb(#{color[0]}, #{color[1]}, #{color[2]})" 110 | 111 | $(".playlist").css "color", "#{rgbToCssString fgColor2}" 112 | $(".track-title").css "color", "#{rgbToCssString fgColor}" 113 | $(".playlist").css "background-color", "#{rgbToCssString bgColor}" 114 | $(".playlist-indicator").css "border-bottom-color", "#{rgbToCssString bgColor}" 115 | -------------------------------------------------------------------------------- /quantize.coffee: -------------------------------------------------------------------------------- 1 | # quantize.coffee, Copyright 2012 Shao-Chung Chen. 2 | # Licensed under the MIT license (http://www.opensource.org/licenses/mit-license.php) 3 | 4 | # Basic CoffeeScript port of the (MMCQ) Modified Media Cut Quantization 5 | # algorithm from the Leptonica library (http://www.leptonica.com/). 6 | # Return a color map you can use to map original pixels to the reduced palette. 7 | # 8 | # Rewritten from the JavaScript port (http://gist.github.com/1104622) 9 | # developed by Nick Rabinowitz under the MIT license. 10 | 11 | # example (pixels are represented in an array of [R,G,B] arrays) 12 | # 13 | # myPixels = [[190,197,190], [202,204,200], [207,214,210], [211,214,211], [205,207,207]] 14 | # maxColors = 4 15 | # 16 | # cmap = MMCQ.quantize myPixels, maxColors 17 | # newPalette = cmap.palette() 18 | # newPixels = myPixels.map (p) -> cmap.map(p) 19 | 20 | class PriorityQueue 21 | constructor: (@comparator) -> 22 | @contents = [] 23 | @sorted = false 24 | sort: -> 25 | @contents.sort @comparator 26 | @sorted = true 27 | push: (obj) -> 28 | @contents.push obj 29 | @sorted = false 30 | peek: (index = (@contents.length - 1)) -> 31 | @sort() unless @sorted 32 | @contents[index] 33 | pop: -> 34 | @sort() unless @sorted 35 | @contents.pop() 36 | size: -> 37 | @contents.length 38 | map: (func) -> 39 | @contents.map func 40 | 41 | 42 | class MMCQ 43 | @sigbits = 5 44 | @rshift = (8 - MMCQ.sigbits) 45 | 46 | constructor: -> 47 | @maxIterations = 1000 48 | @fractByPopulations = 0.75 49 | 50 | # private method 51 | getColorIndex = (r, g, b) -> 52 | (r << (2 * MMCQ.sigbits)) + (g << MMCQ.sigbits) + b 53 | 54 | class ColorBox 55 | constructor: (@r1, @r2, @g1, @g2, @b1, @b2, @histo) -> 56 | volume: (forced) -> 57 | @_volume = ((@r2 - @r1 + 1) * (@g2 - @g1 + 1) * (@b2 - @b1 + 1)) if !@_volume or forced 58 | @_volume 59 | count: (forced) -> 60 | if !@_count_set or forced 61 | numpix = 0 62 | for r in [@r1..@r2] by 1 63 | for g in [@g1..@g2] by 1 64 | for b in [@b1..@b2] by 1 65 | index = getColorIndex r, g, b 66 | numpix += (@histo[index] || 0) 67 | @_count_set = true 68 | @_count = numpix 69 | @_count 70 | copy: -> 71 | new ColorBox @r1, @r2, @g1, @g2, @b1, @b2, @histo 72 | average: (forced) -> 73 | if !@_average or forced 74 | mult = (1 << (8 - MMCQ.sigbits)) 75 | total = 0; rsum = 0; gsum = 0; bsum = 0; 76 | for r in [@r1..@r2] by 1 77 | for g in [@g1..@g2] by 1 78 | for b in [@b1..@b2] by 1 79 | index = getColorIndex r, g, b 80 | hval = (@histo[index] || 0) 81 | total += hval 82 | rsum += (hval * (r + 0.5) * mult) 83 | gsum += (hval * (g + 0.5) * mult) 84 | bsum += (hval * (b + 0.5) * mult) 85 | if total 86 | @_average = [~~(rsum / total), ~~(gsum / total), ~~(bsum / total)] 87 | else 88 | @_average = [ 89 | ~~(mult * (@r1 + @r2 + 1) / 2), 90 | ~~(mult * (@g1 + @g2 + 1) / 2), 91 | ~~(mult * (@b1 + @b2 + 1) / 2), 92 | ] 93 | @_average 94 | contains: (pixel) -> 95 | r = (pixel[0] >> MMCQ.rshift); g = (pixel[1] >> MMCQ.rshift); b = (pixel[2] >> MMCQ.rshift) 96 | ((@r1 <= r <= @r2) and (@g1 <= g <= @g2) and (@b1 <= b <= @b2)) 97 | 98 | 99 | class ColorMap 100 | constructor: -> 101 | @cboxes = new PriorityQueue (a, b) -> 102 | va = (a.count() * a.volume()); vb = (b.count() * b.volume()) 103 | if va > vb then 1 else if va < vb then (-1) else 0 104 | push: (cbox) -> 105 | @cboxes.push { cbox: cbox, color: cbox.average() } 106 | palette: -> 107 | @cboxes.map (cbox) -> cbox.color 108 | size: -> 109 | @cboxes.size() 110 | map: (color) -> 111 | for i in [0...(@cboxes.size())] by 1 112 | if @cboxes.peek(i).cbox.contains color 113 | return @cboxes.peek(i).color 114 | return @.nearest color 115 | cboxes: -> 116 | @cboxes 117 | nearest: (color) -> 118 | square = (n) -> n * n 119 | minDist = 1e9 120 | for i in [0...(@cboxes.size())] by 1 121 | dist = Math.sqrt( 122 | square(color[0] - @cboxes.peek(i).color[0]) + 123 | square(color[1] - @cboxes.peek(i).color[1]) + 124 | square(color[2] - @cboxes.peek(i).color[2])) 125 | if dist < minDist 126 | minDist = dist 127 | retColor = @cboxes.peek(i).color 128 | retColor 129 | 130 | # private method 131 | getHisto = (pixels) => 132 | histosize = 1 << (3 * @sigbits) 133 | histo = new Array(histosize) 134 | for pixel in pixels 135 | r = (pixel[0] >> @rshift); g = (pixel[1] >> @rshift); b = (pixel[2] >> @rshift) 136 | index = getColorIndex r, g, b 137 | histo[index] = (histo[index] || 0) + 1 138 | histo 139 | 140 | # private method 141 | cboxFromPixels = (pixels, histo) => 142 | rmin = 1e6; rmax = 0 143 | gmin = 1e6; gmax = 0 144 | bmin = 1e6; bmax = 0 145 | for pixel in pixels 146 | r = (pixel[0] >> @rshift); g = (pixel[1] >> @rshift); b = (pixel[2] >> @rshift) 147 | if r < rmin then rmin = r else if r > rmax then rmax = r 148 | if g < gmin then gmin = g else if g > gmax then gmax = g 149 | if b < bmin then bmin = b else if b > bmax then bmax = b 150 | new ColorBox rmin, rmax, gmin, gmax, bmin, bmax, histo 151 | 152 | # private method 153 | medianCutApply = (histo, cbox) -> 154 | return unless cbox.count() 155 | return [cbox.copy()] if cbox.count() is 1 156 | 157 | rw = (cbox.r2 - cbox.r1 + 1) 158 | gw = (cbox.g2 - cbox.g1 + 1) 159 | bw = (cbox.b2 - cbox.b1 + 1) 160 | maxw = Math.max rw, gw, bw 161 | 162 | total = 0; partialsum = []; lookaheadsum = [] 163 | if maxw is rw 164 | for r in [(cbox.r1)..(cbox.r2)] by 1 165 | sum = 0 166 | for g in [(cbox.g1)..(cbox.g2)] by 1 167 | for b in [(cbox.b1)..(cbox.b2)] by 1 168 | index = getColorIndex r, g, b 169 | sum += (histo[index] or 0) 170 | total += sum 171 | partialsum[r] = total 172 | else if maxw is gw 173 | for g in [(cbox.g1)..(cbox.g2)] by 1 174 | sum = 0 175 | for r in [(cbox.r1)..(cbox.r2)] by 1 176 | for b in [(cbox.b1)..(cbox.b2)] by 1 177 | index = getColorIndex r, g, b 178 | sum += (histo[index] or 0) 179 | total += sum 180 | partialsum[g] = total 181 | else # maxw is bw 182 | for b in [(cbox.b1)..(cbox.b2)] by 1 183 | sum = 0 184 | for r in [(cbox.r1)..(cbox.r2)] by 1 185 | for g in [(cbox.g1)..(cbox.g2)] by 1 186 | index = getColorIndex r, g, b 187 | sum += (histo[index] or 0) 188 | total += sum 189 | partialsum[b] = total 190 | 191 | partialsum.forEach (d, i) -> 192 | lookaheadsum[i] = (total - d) 193 | 194 | doCut = (color) -> 195 | dim1 = (color + '1'); dim2 = (color + '2') 196 | for i in [(cbox[dim1])..(cbox[dim2])] by 1 197 | if partialsum[i] > (total / 2) 198 | cbox1 = cbox.copy(); cbox2 = cbox.copy() 199 | left = (i - cbox[dim1]); right = (cbox[dim2] - i) 200 | if left <= right 201 | d2 = Math.min (cbox[dim2] - 1), ~~(i + right / 2) 202 | else 203 | d2 = Math.max (cbox[dim1]), ~~(i - 1 - left / 2) 204 | 205 | # avoid 0-count boxes 206 | d2++ while !partialsum[d2] 207 | count2 = lookaheadsum[d2] 208 | count2 = lookaheadsum[--d2] while !count2 and partialsum[(d2 - 1)] 209 | # set dimensions 210 | cbox1[dim2] = d2 211 | cbox2[dim1] = (cbox1[dim2] + 1) 212 | console.log "cbox counts: #{cbox.count()}, #{cbox1.count()}, #{cbox2.count()}" 213 | return [cbox1, cbox2] 214 | 215 | return doCut "r" if maxw == rw 216 | return doCut "g" if maxw == gw 217 | return doCut "b" if maxw == bw 218 | 219 | quantize: (pixels, maxcolors) -> 220 | if (!pixels.length) or (maxcolors < 2) or (maxcolors > 256) 221 | console.log "invalid arguments" 222 | return false 223 | 224 | # get the beginning cbox from the colors 225 | histo = getHisto pixels 226 | cbox = cboxFromPixels pixels, histo 227 | pq = new PriorityQueue (a, b) -> 228 | va = a.count(); vb = b.count() 229 | if va > vb then 1 else if va < vb then (-1) else 0 230 | pq.push cbox 231 | 232 | # inner function to do the iteration 233 | iter = (lh, target) => 234 | ncolors = 1 235 | niters = 0 236 | while niters < @maxIterations 237 | cbox = lh.pop() 238 | unless cbox.count() 239 | lh.push cbox 240 | niters++ 241 | continue 242 | # do the cut 243 | cboxes = medianCutApply histo, cbox 244 | cbox1 = cboxes[0]; cbox2 = cboxes[1] 245 | unless cbox1 246 | console.log "cbox1 not defined; shouldn't happen" 247 | return 248 | lh.push cbox1 249 | if cbox2 # cbox2 can be null 250 | lh.push cbox2 251 | ncolors++ 252 | return if (ncolors >= target) 253 | if (niters++) > @maxIterations 254 | console.log "infinite loop; perhaps too few pixels" 255 | return 256 | 257 | # first set of colors, sorted by population 258 | iter pq, (@fractByPopulations * maxcolors) 259 | 260 | # re-sort by the product of pixel occupancy times the size in color space 261 | pq2 = new PriorityQueue (a, b) -> 262 | va = (a.count() * a.volume()); vb = (b.count() * b.volume()) 263 | if va > vb then 1 else if va < vb then (-1) else 0 264 | pq2.push pq.pop() while pq.size() 265 | 266 | # next set - generate the median cuts using the (npix * vol) sorting 267 | iter pq2, (maxcolors - pq2.size()) 268 | 269 | # calculate the actual colors 270 | cmap = new ColorMap 271 | cmap.push pq2.pop() while pq2.size() 272 | 273 | cmap 274 | --------------------------------------------------------------------------------