├── .gitignore ├── Makefile ├── README.md ├── coffee ├── AliasMethod.coffee ├── ImageUtils.coffee ├── KDTree.coffee ├── RandUtils.coffee ├── VoronoiImageTiles.coffee └── main.coffee ├── examples ├── hubble-25-year │ ├── original.jpg │ └── result.png └── invermere-mountains │ ├── original.jpg │ └── result.png ├── index.html └── lib ├── domReady.js └── require.js /.gitignore: -------------------------------------------------------------------------------- 1 | js/ 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build_js: coffee/* 2 | coffee -c -o js coffee 3 | 4 | continuously: 5 | coffee -c -w -o js coffee 6 | 7 | deploy: 8 | tar cfz payload.tar.gz index.html js 9 | ssh cmu 'cd ~/www/VoronoiImageTiles ;\ 10 | rm -rf ~/www/VoronoiImageTiles/* ;\ 11 | cat - > payload.tar.gz ;\ 12 | tar xvfz payload.tar.gz ;\ 13 | rm payload.tar.gz' < payload.tar.gz 14 | rm payload.tar.gz 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Voronoi Image Tiles 2 | 3 | A fun image processing project in javascript that is marginally related some of 4 | the learning theory projects that I work on. To try it for yourself, go here: 5 | http://imagetiles.travisdick.net. Instructions are given below. 6 | 7 | ## Examples 8 | 9 | ### The Hubble 25-year Anniversary Photo 10 |  11 | becomes 12 |  13 | 14 | ### A shot of the mountains in Invermere, BC, Canada 15 |  16 | becomes 17 |  18 | 19 | ## How to Use 20 | 21 | You can apply the tool to a new image by going to 22 | http://imagetiles.travisdick.net (or by cloning the repository and working in a 23 | local copy) and selecting your image file. There are a couple of additional 24 | options for tweaking the output that can be controlled below the file selection 25 | button. All three sliders (labeled "detail level", "edge sensitivity", and 26 | "uniform bias") control how much detail the resulting image has, and what 27 | regions of the image have detail. If you create an image that you would like to 28 | save, right clicking on the output will allow you to save it or open it in a new 29 | tab. 30 | 31 | For those of you worried about privacy, all the processing is done in your web 32 | browser and the images you apply it to never leave your computer. This approach 33 | saves us both the bandwidth necessary to send the images, and it saves me the 34 | computational power required to process your images. 35 | 36 | ## Brief Description 37 | 38 | The output is produced in several stages, some of which are similar to the 39 | constructions used in some of the learning theory projects that I am working on. 40 | 41 | First, a magic function is applied to the image to identify regions of the image 42 | that are interesting. The "map of interestingness" is represented as a weight 43 | image of the same dimensions as the original image, with high weights 44 | corresponding to highly interesting pixels. At the moment, this magic function 45 | computes the gradient of intensity at each pixel and passes the magnitude of the 46 | gradient through a sigmoidal function. This is a cheap and lousy edge detection 47 | algorithm, but it seems to produce reasonably nice output. 48 | 49 | Next, that weight map is normalized to create a probability distribution over 50 | the pixel coordinates and a sample of points is drawn from that distribution. 51 | Each one of these points will become one of the tiles in the final image, so 52 | drawing more points corresponds to more detail. Also, since we will see more 53 | points in places where the "interest map" had high weights, this will give more 54 | detail to the interesting portions of the image. 55 | 56 | Finally, the output is generated by partitioning the set of pixels into tiles 57 | and coloring each tile with the average color of the underlying pixels in the 58 | original image. To convert the sampled points into the tiling, we make one tile 59 | for each point sampled in the previous step. The tile created by one sample 60 | contains all the pixels that are closer to that sample than any other. This way 61 | of partitioning the space is called a Voronoi partition. 62 | 63 | I wrote this code because I am curious about how the Voronoi partitions behave 64 | when they are generated from samples randomly drawn from non-trivial 65 | distributions. I actually care about high-dimensional instances of this problem, 66 | but the 2 dimensional output is nice to look at. 67 | 68 | ## Build Instructions: 69 | 70 | Since the code is written in coffeescript, you must first compile the 71 | coffeescript into javascript. In the root directory running `make build_js` will 72 | build the javascript files. If you are editing the coffeescript code, running 73 | `make continuously` will watch the .coffee source files and recompile whenever 74 | they change. Finally, `make deploy` is a convenience target that copies the 75 | compiled page to my school webspace. 76 | -------------------------------------------------------------------------------- /coffee/AliasMethod.coffee: -------------------------------------------------------------------------------- 1 | define ["RandUtils"], (ru) -> 2 | 3 | class AliasTable 4 | constructor: (@prob, @alias) -> 5 | sample: -> 6 | bin = ru.random_int @prob.length 7 | if ru.random_bool @prob[bin] then bin else @alias[bin] 8 | 9 | make_alias_table = (ps) -> 10 | n = ps.length 11 | total = 0 12 | for p in ps 13 | total += p 14 | ps = (p / total * n for p in ps) 15 | 16 | prob = (0 for ix in [0...n]) 17 | alias = (0 for ix in [0...n]) 18 | 19 | # Make the small and large working lists 20 | small = [] 21 | large = [] 22 | for ix in [0...n] 23 | if ps[ix] < 1.0 then small.push ix else large.push ix 24 | 25 | while small.length > 0 && large.length > 0 26 | l = small.pop() 27 | g = large.pop() 28 | prob[l] = ps[l] 29 | alias[l] = g 30 | ps[g] = ps[g] - (1.0 - ps[l]) 31 | if ps[g] < 1.0 then small.push g else large.push g 32 | 33 | prob[g] = 1.0 for g in large 34 | return new AliasTable prob, alias 35 | 36 | return {make_alias_table: make_alias_table} 37 | -------------------------------------------------------------------------------- /coffee/ImageUtils.coffee: -------------------------------------------------------------------------------- 1 | define -> 2 | 3 | #-----------------# 4 | # Basic Datatypes # 5 | #-----------------# 6 | 7 | # Class for represnting image data 8 | class Image 9 | constructor: (@width, @height, @data = (0 for i in [1..@width * @height])) -> 10 | # Converts an (x,y) pair into an index into the data array 11 | c2ix: (x,y) -> y + x*@height 12 | # Convers an index in the data array into a coordinate pair 13 | ix2c: (ix) -> [ix // @height, ix % @height] 14 | # Gets the pixel data at the given coordinates 15 | get: (x,y) -> @data[@c2ix(x,y)] 16 | # Sets the pixel data at the given coordinates 17 | set: (x,y,c) -> @data[@c2ix(x,y)] = c 18 | 19 | # Class for representing RGB colors 20 | class RGB 21 | constructor: (@r, @g, @b) -> 22 | scale: (s) -> new RGB @r*s, @g*s, @b*s 23 | div: (s) -> @scale(1/s) 24 | add: (o) -> new RGB @r + o.r, @g + o.g, @b + o.b 25 | sub: (o) -> @add(o.scale(-1)) 26 | to_gray: -> 0.21*@r + 0.72*@g + 0.07*@b 27 | 28 | #------------------------------# 29 | # Image Manipulation Functions # 30 | #------------------------------# 31 | 32 | # Takes an RGB image and returns a new scalar image in grayscale 33 | rgb_to_grayscale = (img) -> 34 | gray = new Image img.width, img.height 35 | gray.data = (c.to_gray() for c in img.data) 36 | return gray 37 | 38 | # Takes a grayscale scalar image and returns an RGB image 39 | grayscale_to_rgb = (gsimg) -> 40 | rgb = new Image gsimg.width, gsimg.height 41 | rgb.data = (new RGB g, g, g for g in gsimg.data) 42 | return rgb 43 | 44 | # Given a grayscale image, rescales everything so that the maximum value is 45 | # 255. 46 | rescale_to_255 = (gsimg) -> 47 | max_g = 0 48 | for g in gsimg.data 49 | max_g = Math.max(max_g, g) 50 | scale = 255 / max_g 51 | result = new Image gsimg.width, gsimg.height 52 | result.data = (g * scale for g in gsimg.data) 53 | return result 54 | 55 | # Convolves a grayscale image with the given kernel. 56 | img_filter = (gsimg, kernel) -> 57 | [w,h] = [kernel.length, kernel[0].length] 58 | result = new Image gsimg.width, gsimg.height 59 | for x in [0...gsimg.width] 60 | for y in [0...gsimg.height] 61 | g = 0.0 62 | for dx in [-(w // 2)..(w // 2)] 63 | for dy in [-(h // 2)..(h // 2)] 64 | if 0 <= x+dx < gsimg.width and 0 <= y+dy < gsimg.height 65 | g += kernel[dx+(w // 2)][dy+(h // 2)] * gsimg.get(x+dx, y+dy) 66 | result.set(x,y,g) 67 | return result 68 | 69 | box_smooth = (gsimg, size) -> 70 | s = 1.0 / (size*size) 71 | kernel = ((s for i in [1..size]) for j in [1..size]) 72 | return img_filter gsimg, kernel 73 | 74 | # Takes a grayscale image and returns the image intensity derivative with 75 | # respect to the x coordinate 76 | img_gradx = (gsimg) -> 77 | gradx = new Image gsimg.width, gsimg.height 78 | for x in [1...gradx.width] 79 | for y in [0...gradx.height] 80 | gradx.set(x,y, gsimg.get(x,y) - gsimg.get(x-1,y)) 81 | return gradx 82 | 83 | # Takes a grayscale image and returns the image intensity derivative with 84 | # respect to the y coordinate 85 | img_grady = (gsimg) -> 86 | grady = new Image gsimg.width, gsimg.height 87 | for x in [0...grady.width] 88 | for y in [1...grady.height] 89 | grady.set(x,y, gsimg.get(x,y) - gsimg.get(x,y-1)) 90 | return grady 91 | 92 | # Takes a grayscale image and returns the magnitude of the image intensity 93 | # gradient (as a function of (x,y) coordinates). 94 | img_gradm = (gsimg) -> 95 | gradx = img_gradx gsimg 96 | grady = img_grady gsimg 97 | gradm = new Image gsimg.width, gsimg.height 98 | for x in [0...gradm.width] 99 | for y in [0...gradm.height] 100 | gx = gradx.get(x,y) 101 | gy = grady.get(x,y) 102 | m = Math.sqrt(gx*gx + gy*gy) 103 | gradm.set(x,y,m) 104 | return gradm 105 | 106 | #-------------------------------# 107 | # Interacting with DOM elements # 108 | #-------------------------------# 109 | 110 | # takes an HTML image element and returns an instance of the above image class 111 | convert_image_element = (imgelement, scale = 1.0) -> 112 | [w, h] = [Math.floor(imgelement.naturalWidth*scale), Math.floor(imgelement.naturalHeight*scale)] 113 | c = document.createElement("canvas") 114 | [c.width, c.height] = [w, h] 115 | ctx = c.getContext("2d") 116 | ctx.drawImage(imgelement,0,0,w,h) 117 | imgdata = ctx.getImageData(0,0,w,h) 118 | result = new Image w, h 119 | for x in [0...w] 120 | for y in [0...h] 121 | red_ix = 4*x + 4*y*w 122 | r = imgdata.data[red_ix] 123 | g = imgdata.data[red_ix+1] 124 | b = imgdata.data[red_ix+2] 125 | result.set x, y, new RGB(r,g,b) 126 | return result 127 | 128 | # takes an instance of the image class and returns a data URL in the given format 129 | image_to_dataurl = (img, format="image/png") -> 130 | cvs = document.createElement("canvas") 131 | [cvs.width, cvs.height] = [img.width, img.height] 132 | ctx = cvs.getContext("2d") 133 | imgdata = ctx.getImageData(0, 0, cvs.width, cvs.height) 134 | for x in [0...cvs.width] 135 | for y in [0...cvs.height] 136 | red_ix = 4*x + 4*y*cvs.width 137 | c = img.get(x,y) 138 | imgdata.data[red_ix] = c.r 139 | imgdata.data[red_ix+1] = c.g 140 | imgdata.data[red_ix+2] = c.b 141 | imgdata.data[red_ix+3] = 255 142 | ctx.putImageData(imgdata,0,0) 143 | return cvs.toDataURL(format) 144 | 145 | #----------------# 146 | # Module Exports # 147 | #----------------# 148 | 149 | return { 150 | Image: Image 151 | RGB: RGB 152 | rgb_to_grayscale: rgb_to_grayscale 153 | grayscale_to_rgb: grayscale_to_rgb 154 | rescale_to_255: rescale_to_255 155 | img_filter: img_filter 156 | box_smooth: box_smooth 157 | img_gradx: img_gradx 158 | img_grady: img_grady 159 | img_gradm: img_gradm 160 | convert_image_element: convert_image_element 161 | image_to_dataurl: image_to_dataurl 162 | } 163 | -------------------------------------------------------------------------------- /coffee/KDTree.coffee: -------------------------------------------------------------------------------- 1 | define -> 2 | 3 | dist = (p,q) -> 4 | total = 0.0 5 | for i in [0...p.length] 6 | d = p[i] - q[i] 7 | total += d*d 8 | return Math.sqrt(total) 9 | 10 | class KDNode 11 | constructor: (@axis, @pt, @ix, @left, @right) -> 12 | 13 | leaf: -> not @pt? 14 | 15 | depth: -> if @leaf() then 1 else 1 + Math.max(@left.depth(), @right.depth()) 16 | 17 | nns: (q, p = null, ix = null, d = Infinity) -> 18 | if @leaf() then return [p,ix,d] 19 | signed_margin = q[@axis] - @pt[@axis] 20 | [f,s] = if signed_margin <= 0 then [@left, @right] else [@right, @left] 21 | [p,ix,d] = f.nns(q, p, ix, d) 22 | [p,ix,d] = s.nns(q, p, ix, d) unless d < Math.abs(signed_margin) 23 | this_d = dist(q, @pt) 24 | [p,ix,d] = [@pt, @ix, this_d] if this_d < d 25 | return [p,ix,d] 26 | 27 | draw_on_context: (ctx, lb, ub) -> 28 | if @leaf() then return 29 | line_start = lb.slice() 30 | line_end = ub.slice() 31 | 32 | line_start[@axis] = @pt[@axis] 33 | line_end[@axis] = @pt[@axis] 34 | 35 | 36 | if (not @left.leaf()) or (not @right.leaf()) 37 | ctx.beginPath() 38 | ctx.moveTo(line_start[0], line_start[1]) 39 | ctx.lineTo(line_end[0], line_end[1]) 40 | ctx.stroke() 41 | 42 | ctx.fillRect(@pt[0]-2, @pt[1]-2, 4, 4) 43 | 44 | left_lb = lb.slice() 45 | left_ub = ub.slice() 46 | 47 | right_lb = lb.slice() 48 | right_ub = ub.slice() 49 | 50 | left_ub[@axis] = @pt[@axis] 51 | right_lb[@axis] = @pt[@axis] 52 | 53 | @left.draw_on_context ctx, left_lb, left_ub 54 | @right.draw_on_context ctx, right_lb, right_ub 55 | 56 | draw_on_canvas: (cvs) -> 57 | ctx = cvs.getContext("2d") 58 | @draw_on_context ctx, [0, 0], [cvs.width, cvs.height] 59 | 60 | 61 | intersect = (a, b) -> 62 | c = [] 63 | for ae in a 64 | if ae in b then c.push(ae) 65 | return c 66 | 67 | # This code can be considerably optimized by sorting the points along each 68 | # axis once at the beginning. 69 | make_kdtree = (points, ixs = [0...points.length], depth = 0) -> 70 | n = points.length 71 | if n == 0 72 | return new KDNode null, null, null, null, null 73 | else 74 | dim = points[0].length 75 | axis = depth % dim 76 | sortperm = [0...n] 77 | sortperm.sort((i,j) -> points[i][axis] - points[j][axis]) 78 | mid = n // 2 79 | [left_sub, center, right_sub] = [sortperm[0...mid], sortperm[mid], sortperm[(mid+1)...n]] 80 | 81 | left_pts = (points[i] for i in left_sub) 82 | left_ixs = (ixs[i] for i in left_sub) 83 | left = make_kdtree left_pts, left_ixs, depth + 1 84 | 85 | center_pt = points[center] 86 | center_ix = ixs[center] 87 | 88 | right_pts = (points[i] for i in right_sub) 89 | right_ixs = (ixs[i] for i in right_sub) 90 | right = make_kdtree right_pts, right_ixs, depth + 1 91 | 92 | return new KDNode axis, center_pt, center_ix, left, right 93 | 94 | return { 95 | make_kdtree: make_kdtree 96 | } 97 | -------------------------------------------------------------------------------- /coffee/RandUtils.coffee: -------------------------------------------------------------------------------- 1 | define -> 2 | random_int = (n) -> Math.floor(Math.random() * n) 3 | random_bool = (p = 0.5) -> Math.random() <= p 4 | random_point = (dim, w = (1 for i in [0...dim])) -> (Math.random()*w[i] for i in [0...dim]) 5 | random_points = (dim, n, w) -> (random_point(dim, w) for i in [1..n]) 6 | 7 | return { 8 | random_int: random_int 9 | random_bool: random_bool 10 | random_point: random_point 11 | random_points: random_points 12 | } 13 | -------------------------------------------------------------------------------- /coffee/VoronoiImageTiles.coffee: -------------------------------------------------------------------------------- 1 | define ["ImageUtils", "AliasMethod", "RandUtils", "KDTree"], (iu, am, ru, kdt) -> 2 | 3 | # Class for storing all intermediate stages in rendering the tiled image. 4 | class RenderedImage 5 | constructor: (@src, @dist, @kdtree, @out) -> 6 | 7 | # Given a scalar image with non-negative entries, draw a sample of size n from 8 | # the normalized distribution. 9 | sample_points = (dist_img, n) -> 10 | N = dist_img.data.length 11 | alias_table = am.make_alias_table dist_img.data 12 | sample_ixs = (alias_table.sample() for i in [1..n]) 13 | sample_pts = (dist_img.ix2c(ix) for ix in sample_ixs) 14 | return sample_pts 15 | 16 | # Creates a distribution to sample from that is obtained by computing the 17 | # magnitude of the image intensity gradient and mixing with the uniform 18 | # distribution. 19 | gradient_dist = (img, edge_sensitivity = 20, min_v = 0.005) -> 20 | gsimg = iu.rgb_to_grayscale img 21 | gradm = iu.img_gradm gsimg 22 | gradm.data = ((Math.tanh((g-123)/255*edge_sensitivity) + 1)/2 + min_v for g in gradm.data) 23 | gradm = iu.box_smooth gradm, 7 24 | return gradm 25 | 26 | # Given an image, a distribution, and a number of samples, render the tiled 27 | # image. 28 | render_tiled_image = (img, dist, n) -> 29 | # Draw n samples from the distribution and build a k-d tree on them 30 | samples = sample_points dist, n 31 | kdtree = kdt.make_kdtree(samples) 32 | # Compute the mean color in each Voronoi tile. (Note that it's a bit silly 33 | # to do this in RGB space. Should convert to XYZ before avaraging.) 34 | total_colors = (img.get(s[0], s[1]) for s in samples) 35 | counts = (1 for s in samples) 36 | for ix in [0...img.data.length] 37 | [nn_p, nn_ix, nn_d] = kdtree.nns(img.ix2c(ix)) 38 | total_colors[nn_ix] = total_colors[nn_ix].add(img.data[ix]) 39 | counts[nn_ix] += 1 40 | colors = (total_colors[i].div(counts[i]) for i in [0...n]) 41 | # Calculate the color of each pixel in the result as the average of the 42 | # colors of the nearest sample points to the four corners of this pixel. By 43 | # averaging the corners, we get some crude aliasing effects. 44 | get_pixel_color = (x,y) -> 45 | c = new iu.RGB 0, 0, 0 46 | for dx in [-0.5,0.5] 47 | for dy in [-0.5,0.5] 48 | [nn_p, nn_ix, nn_d] = kdtree.nns([x+dx,y+dy]) 49 | c = c.add colors[nn_ix] 50 | return c.div 4 51 | # Render the image using the above pixel color function 52 | result = new iu.Image img.width, img.height 53 | for ix in [0...result.data.length] 54 | result.data[ix] = get_pixel_color(img.ix2c(ix)...) 55 | # Return the result along with the intermediate steps 56 | return new RenderedImage img, dist, kdtree, result 57 | 58 | return { 59 | RenderedImage: RenderedImage 60 | sample_points: sample_points 61 | gradient_dist: gradient_dist 62 | render_tiled_image: render_tiled_image 63 | } 64 | -------------------------------------------------------------------------------- /coffee/main.coffee: -------------------------------------------------------------------------------- 1 | require.config { 2 | paths: 3 | "domReady": ["https://cdnjs.cloudflare.com/ajax/libs/require-domReady/2.0.1/domReady.min" 4 | "../lib/domReady"] 5 | } 6 | 7 | require ["ImageUtils" 8 | "VoronoiImageTiles" 9 | "domReady"], \ 10 | (iu, vit, domReady) -> 11 | 12 | # Sets the sourceimage element's src attribute to a data url obtained by 13 | # reading the file selected by the fileselector element. 14 | load_selected_image = -> 15 | fr = new FileReader() 16 | fr.onload = -> 17 | document.getElementById("sourceimage").src = fr.result 18 | fr.readAsDataURL(document.getElementById("fileselector").files[0]) 19 | 20 | get_clamping_scale = (w, h, mw=800, mh=600) -> 21 | wscale = mw / w 22 | hscale = mh / h 23 | return Math.min(wscale, hscale, 1.0) 24 | 25 | # Renders the tiled image using the content of the sourceimage element. 26 | # Updates other dom elements. 27 | make_tiled_image = -> 28 | simg_element = document.getElementById("sourceimage") 29 | dimg_element = document.getElementById("distimage") 30 | rimg_element = document.getElementById("resultimage") 31 | kdtreegraph_element = document.getElementById("kdtreegraph") 32 | numsamples = Math.floor(document.getElementById("numsamples").value) 33 | edge_sensitivity = Number(document.getElementById("edgesensitivity").value) 34 | uniform_bias = Number(document.getElementById("uniformbias").value) 35 | max_width = Number(document.getElementById("maxwidth").value) 36 | max_height = Number(document.getElementById("maxheight").value) 37 | 38 | # render the tiled image 39 | scale = get_clamping_scale simg_element.naturalWidth, simg_element.naturalHeight, max_width, max_height 40 | simg = iu.convert_image_element simg_element, scale 41 | dist = vit.gradient_dist simg, edge_sensitivity, uniform_bias 42 | result = vit.render_tiled_image simg, dist, numsamples 43 | 44 | # show the distribution 45 | dimg_element.src = iu.image_to_dataurl iu.grayscale_to_rgb iu.rescale_to_255 result.dist 46 | 47 | # Draw the k-d tree 48 | kdtreegraph.width = result.out.width 49 | kdtreegraph.height = result.out.height 50 | result.kdtree.draw_on_canvas(kdtreegraph_element) 51 | 52 | # Draw the tiled image 53 | rimg_element.src = iu.image_to_dataurl result.out 54 | 55 | 56 | 57 | domReady -> 58 | fs = document.getElementById("fileselector") 59 | fs.onchange = (e) -> load_selected_image() 60 | document.getElementById("sourceimage").onload = -> setTimeout(make_tiled_image, 0) 61 | document.getElementById("run_button").onclick = -> setTimeout(make_tiled_image, 0) 62 | -------------------------------------------------------------------------------- /examples/hubble-25-year/original.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TravisBarryDick/VoronoiImageTiles/99ada828a70d54a072dd088a3e7d85afe9ee8d6a/examples/hubble-25-year/original.jpg -------------------------------------------------------------------------------- /examples/hubble-25-year/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TravisBarryDick/VoronoiImageTiles/99ada828a70d54a072dd088a3e7d85afe9ee8d6a/examples/hubble-25-year/result.png -------------------------------------------------------------------------------- /examples/invermere-mountains/original.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TravisBarryDick/VoronoiImageTiles/99ada828a70d54a072dd088a3e7d85afe9ee8d6a/examples/invermere-mountains/original.jpg -------------------------------------------------------------------------------- /examples/invermere-mountains/result.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TravisBarryDick/VoronoiImageTiles/99ada828a70d54a072dd088a3e7d85afe9ee8d6a/examples/invermere-mountains/result.png -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 |