├── .gitignore
├── README.md
├── constants.py
├── image_to_gcode.py
├── images
├── flower.jpg
├── flower_gcode.png
├── tree.png
└── tree_gcode.png
└── requirements.txt
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__/
2 | .vscode/
3 | *.png
4 | *.jpg
5 | *.dot
6 | *.nc
7 | !images/*
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Image to G-code converter
2 |
3 | This repository contains a Python 3 script that takes an **image as input** and generates a **2D G-code file as output**. The script can either use the provided image as an edges image, or auto-detect the edges using the Sobel operator. Then a graph is built and it is converted to G-code. You can then **use the produced G-code in a 2D plotter**, you may find this other project of mine useful: [plotter](https://github.com/Stypox/plotter).
4 |
5 |
6 |
7 | Auto-detecting edges |
8 | Considering image as already edges |
9 |
10 |
11 |  |
12 |  |
13 |  |
14 |  |
15 |
16 |
17 | Obtained with http://jherrm.com/gcode-viewer/ |
18 |
19 |
20 |
21 | ## Usage
22 |
23 | You can run the script normally with [Python 3](https://www.python.org/downloads/):
24 | ```
25 | python3 image_to_gcode.py ARGUMENTS...
26 | ```
27 | This is the help screen with all valid arguments (obtainable with `python3 image_to_gcode.py --help`):
28 | ```
29 | usage: image_to_gcode.py [-h] -i FILE -o FILE [--dot-output FILE] [-e MODE] [-t VALUE]
30 |
31 | Detects the edges of an image and converts them to 2D gcode that can be printed by a plotter
32 |
33 | optional arguments:
34 | -h, --help show this help message and exit
35 | -i FILE, --input FILE
36 | Image to convert to gcode; all formats supported by the Python imageio library are supported
37 | -o FILE, --output FILE
38 | File in which to save the gcode result
39 | --dot-output FILE Optional file in which to save the graph (in DOT format) generated during an intermediary step of gcode generation
40 | -e MODE, --edges MODE
41 | Consider the input file already as an edges matrix, not as an image of which to detect the edges. MODE should be either `white` or `black`, that is the color of the edges in the image. The image should only be made of white or black pixels.
42 | -t VALUE, --threshold VALUE
43 | The threshold in range (0,255) above which to consider a pixel as part of an edge (after Sobel was applied to the image or on reading the edges from file with the --edges option)
44 | ```
45 |
46 | The required parameters are the input and the output. You may want to tune the threshold value in order to obtain a better graph.
47 |
48 | An example of command is:
49 | ```sh
50 | python3 image_to_gcode.py --input image.png --output graph.nc --threshold 100
51 | ```
--------------------------------------------------------------------------------
/constants.py:
--------------------------------------------------------------------------------
1 | circumferences = [
2 | # r=0
3 | [(0,0)],
4 | # r=1
5 | [(1,0),(0,1),(-1,0),(0,-1)],
6 | # r=2
7 | [(2,0),(2,1),(1,2),(0,2),(-1,2),(-2,1),(-2,0),(-2,-1),(-1,-2),(0,-2),(1,-2),(2,-1)],
8 | # r=3
9 | [(3,0),(3,1),(2,2),(1,3),(0,3),(-1,3),(-2,2),(-3,1),(-3,0),(-3,-1),(-2,-2),(-1,-3),(0,-3),(1,-3),(2,-2),(3,-1)],
10 | # r=4
11 | [(4,0),(4,1),(4,2),(3,3),(2,4),(1,4),(0,4),(-1,4),(-2,4),(-3,3),(-4,2),(-4,1),(-4,0),(-4,-1),(-4,-2),(-3,-3),(-2,-4),(-1,-4),(0,-4),(1,-4),(2,-4),(3,-3),(4,-2),(4,-1)],
12 | # r=5
13 | [(5,0),(5,1),(5,2),(4,3),(3,4),(2,5),(1,5),(0,5),(-1,5),(-2,5),(-3,4),(-4,3),(-5,2),(-5,1),(-5,0),(-5,-1),(-5,-2),(-4,-3),(-3,-4),(-2,-5),(-1,-5),(0,-5),(1,-5),(2,-5),(3,-4),(4,-3),(5,-2),(5,-1)],
14 | # r=6
15 | [(6,0),(6,1),(6,2),(5,3),(5,4),(4,5),(3,5),(2,6),(1,6),(0,6),(-1,6),(-2,6),(-3,5),(-4,5),(-5,4),(-5,3),(-6,2),(-6,1),(-6,0),(-6,-1),(-6,-2),(-5,-3),(-5,-4),(-4,-5),(-3,-5),(-2,-6),(-1,-6),(0,-6),(1,-6),(2,-6),(3,-5),(4,-5),(5,-4),(5,-3),(6,-2),(6,-1)],
16 | # r=7
17 | [(7,0),(7,1),(7,2),(6,3),(6,4),(5,5),(4,6),(3,6),(2,7),(1,7),(0,7),(-1,7),(-2,7),(-3,6),(-4,6),(-5,5),(-6,4),(-6,3),(-7,2),(-7,1),(-7,0),(-7,-1),(-7,-2),(-6,-3),(-6,-4),(-5,-5),(-4,-6),(-3,-6),(-2,-7),(-1,-7),(0,-7),(1,-7),(2,-7),(3,-6),(4,-6),(5,-5),(6,-4),(6,-3),(7,-2),(7,-1)],
18 | # r=8
19 | [(8,0),(8,1),(8,2),(7,3),(7,4),(6,5),(5,6),(4,7),(3,7),(2,8),(1,8),(0,8),(-1,8),(-2,8),(-3,7),(-4,7),(-5,6),(-6,5),(-7,4),(-7,3),(-8,2),(-8,1),(-8,0),(-8,-1),(-8,-2),(-7,-3),(-7,-4),(-6,-5),(-5,-6),(-4,-7),(-3,-7),(-2,-8),(-1,-8),(0,-8),(1,-8),(2,-8),(3,-7),(4,-7),(5,-6),(6,-5),(7,-4),(7,-3),(8,-2),(8,-1)],
20 | # r=9
21 | [(9,0),(9,1),(9,2),(9,3),(8,4),(8,5),(7,6),(6,7),(5,8),(4,8),(3,9),(2,9),(1,9),(0,9),(-1,9),(-2,9),(-3,9),(-4,8),(-5,8),(-6,7),(-7,6),(-8,5),(-8,4),(-9,3),(-9,2),(-9,1),(-9,0),(-9,-1),(-9,-2),(-9,-3),(-8,-4),(-8,-5),(-7,-6),(-6,-7),(-5,-8),(-4,-8),(-3,-9),(-2,-9),(-1,-9),(0,-9),(1,-9),(2,-9),(3,-9),(4,-8),(5,-8),(6,-7),(7,-6),(8,-5),(8,-4),(9,-3),(9,-2),(9,-1)],
22 | # r=10
23 | [(10,0),(10,1),(10,2),(10,3),(9,4),(9,5),(8,6),(7,7),(6,8),(5,9),(4,9),(3,10),(2,10),(1,10),(0,10),(-1,10),(-2,10),(-3,10),(-4,9),(-5,9),(-6,8),(-7,7),(-8,6),(-9,5),(-9,4),(-10,3),(-10,2),(-10,1),(-10,0),(-10,-1),(-10,-2),(-10,-3),(-9,-4),(-9,-5),(-8,-6),(-7,-7),(-6,-8),(-5,-9),(-4,-9),(-3,-10),(-2,-10),(-1,-10),(0,-10),(1,-10),(2,-10),(3,-10),(4,-9),(5,-9),(6,-8),(7,-7),(8,-6),(9,-5),(9,-4),(10,-3),(10,-2),(10,-1)]
24 | ]
25 |
--------------------------------------------------------------------------------
/image_to_gcode.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/python3
2 | #pylint: disable=no-member
3 |
4 | import numpy as np
5 | from scipy import ndimage
6 | import imageio
7 | from PIL import Image, ImageFilter
8 | import constants
9 | import argparse
10 |
11 |
12 | class CircularRange:
13 | def __init__(self, begin, end, value):
14 | self.begin, self.end, self.value = begin, end, value
15 |
16 | def __repr__(self):
17 | return f"[{self.begin},{self.end})->{self.value}"
18 |
19 | def halfway(self):
20 | return int((self.begin + self.end) / 2)
21 |
22 | class Graph:
23 | class Node:
24 | def __init__(self, point, index):
25 | self.x, self.y = point
26 | self.index = index
27 | self.connections = {}
28 |
29 | def __repr__(self):
30 | return f"({self.y},{-self.x})"
31 |
32 | def _addConnection(self, to):
33 | self.connections[to] = False # i.e. not already used in gcode generation
34 |
35 | def toDotFormat(self):
36 | return (f"{self.index} [pos=\"{self.y},{-self.x}!\", label=\"{self.index}\\n{self.x},{self.y}\"]\n" +
37 | "".join(f"{self.index}--{conn}\n" for conn in self.connections if self.index < conn))
38 |
39 |
40 | def __init__(self):
41 | self.nodes = []
42 |
43 | def __getitem__(self, index):
44 | return self.nodes[index]
45 |
46 | def __repr__(self):
47 | return repr(self.nodes)
48 |
49 |
50 | def addNode(self, point):
51 | index = len(self.nodes)
52 | self.nodes.append(Graph.Node(point, index))
53 | return index
54 |
55 | def addConnection(self, a, b):
56 | self.nodes[a]._addConnection(b)
57 | self.nodes[b]._addConnection(a)
58 |
59 | def distance(self, a, b):
60 | return np.hypot(self[a].x-self[b].x, self[a].y-self[b].y)
61 |
62 | def areConnectedWithin(self, a, b, maxDistance):
63 | if maxDistance < 0:
64 | return False
65 | elif a == b:
66 | return True
67 | else:
68 | for conn in self[a].connections:
69 | if self.areConnectedWithin(conn, b, maxDistance - self.distance(conn, b)):
70 | return True
71 | return False
72 |
73 | def saveAsDotFile(self, f):
74 | f.write("graph G {\nnode [shape=plaintext];\n")
75 | for node in self.nodes:
76 | f.write(node.toDotFormat())
77 | f.write("}\n")
78 |
79 | def saveAsGcodeFile(self, f):
80 | ### First follow all paths that have a start/end node (i.e. are not cycles)
81 | # The next chosen starting node is the closest to the current position
82 |
83 | def pathGcode(i, insidePath):
84 | f.write(f"G{1 if insidePath else 0} X{self[i].y} Y{-self[i].x}\n")
85 | for connTo, alreadyUsed in self[i].connections.items():
86 | if not alreadyUsed:
87 | self[i].connections[connTo] = True
88 | self[connTo].connections[i] = True
89 |
90 | return pathGcode(connTo, True)
91 | return i
92 |
93 | possibleStartingNodes = set()
94 | for i in range(len(self.nodes)):
95 | if len(self[i].connections) == 0 or len(self[i].connections) % 2 == 1:
96 | possibleStartingNodes.add(i)
97 |
98 | if len(possibleStartingNodes) != 0:
99 | node = next(iter(possibleStartingNodes)) # first element
100 | while 1:
101 | possibleStartingNodes.remove(node)
102 | pathEndNode = pathGcode(node, False)
103 |
104 | if len(self[node].connections) == 0:
105 | assert pathEndNode == node
106 | f.write(f"G1 X{self[node].y} Y{-self[node].x}\n")
107 | else:
108 | possibleStartingNodes.remove(pathEndNode)
109 |
110 | if len(possibleStartingNodes) == 0:
111 | break
112 |
113 | minDistanceSoFar = np.inf
114 | for nextNode in possibleStartingNodes:
115 | distance = self.distance(pathEndNode, nextNode)
116 | if distance < minDistanceSoFar:
117 | minDistanceSoFar = distance
118 | node = nextNode
119 |
120 |
121 | ### Then pick the node closest to the current position that still has unused/available connections
122 | # That node must belong to a cycle, because otherwise it would have been used above
123 | # TODO improve by finding Eulerian cycles
124 |
125 | cycleNodes = set()
126 | for i in range(len(self.nodes)):
127 | someConnectionsAvailable = False
128 | for _, alreadyUsed in self[i].connections.items():
129 | if not alreadyUsed:
130 | someConnectionsAvailable = True
131 | break
132 |
133 | if someConnectionsAvailable:
134 | cycleNodes.add(i)
135 |
136 | def cyclePathGcode(i, insidePath):
137 | f.write(f"G{1 if insidePath else 0} X{self[i].y} Y{-self[i].x}\n")
138 |
139 | foundConnections = 0
140 | for connTo, alreadyUsed in self[i].connections.items():
141 | if not alreadyUsed:
142 | if foundConnections == 0:
143 | self[i].connections[connTo] = True
144 | self[connTo].connections[i] = True
145 | cyclePathGcode(connTo, True)
146 |
147 | foundConnections += 1
148 | if foundConnections > 1:
149 | break
150 |
151 | if foundConnections == 1:
152 | cycleNodes.remove(i)
153 |
154 | if len(cycleNodes) != 0:
155 | node = next(iter(cycleNodes)) # first element
156 | while 1:
157 | # since every node has an even number of connections, ANY path starting from it
158 | # must complete at the same place (see Eulerian paths/cycles properties)
159 | cyclePathGcode(node, False)
160 |
161 | if len(cycleNodes) == 0:
162 | break
163 |
164 | pathEndNode = node
165 | minDistanceSoFar = np.inf
166 | for nextNode in possibleStartingNodes:
167 | distance = self.distance(pathEndNode, nextNode)
168 | if distance < minDistanceSoFar:
169 | minDistanceSoFar = distance
170 | node = nextNode
171 |
172 | class EdgesToGcode:
173 | def __init__(self, edges):
174 | self.edges = edges
175 | self.ownerNode = np.full(np.shape(edges), -1, dtype=int)
176 | self.xSize, self.ySize = np.shape(edges)
177 | self.graph = Graph()
178 |
179 | def getCircularArray(self, center, r, smallerArray = None):
180 | circumferenceSize = len(constants.circumferences[r])
181 | circularArray = np.zeros(circumferenceSize, dtype=bool)
182 |
183 | if smallerArray is None:
184 | smallerArray = np.ones(1, dtype=bool)
185 | smallerSize = np.shape(smallerArray)[0]
186 | smallerToCurrentRatio = smallerSize / circumferenceSize
187 |
188 | for i in range(circumferenceSize):
189 | x = center[0] + constants.circumferences[r][i][0]
190 | y = center[1] + constants.circumferences[r][i][1]
191 |
192 | if x not in range(self.xSize) or y not in range(self.ySize):
193 | circularArray[i] = False # consider pixels outside of the image as not-edges
194 | else:
195 | iSmaller = i * smallerToCurrentRatio
196 | a, b = int(np.floor(iSmaller)), int(np.ceil(iSmaller))
197 |
198 | if smallerArray[a] == False and (b not in range(smallerSize) or smallerArray[b] == False):
199 | circularArray[i] = False # do not take into consideration not connected regions (roughly)
200 | else:
201 | circularArray[i] = self.edges[x, y]
202 |
203 | return circularArray
204 |
205 | def toCircularRanges(self, circularArray):
206 | ranges = []
207 | circumferenceSize = np.shape(circularArray)[0]
208 |
209 | lastValue, lastValueIndex = circularArray[0], 0
210 | for i in range(1, circumferenceSize):
211 | if circularArray[i] != lastValue:
212 | ranges.append(CircularRange(lastValueIndex, i, lastValue))
213 | lastValue, lastValueIndex = circularArray[i], i
214 |
215 | ranges.append(CircularRange(lastValueIndex, circumferenceSize, lastValue))
216 | if len(ranges) > 1 and ranges[-1].value == ranges[0].value:
217 | ranges[0].begin = ranges[-1].begin - circumferenceSize
218 | ranges.pop() # the last range is now contained in the first one
219 | return ranges
220 |
221 | def getNextPoints(self, point):
222 | """
223 | Returns the radius of the circle used to identify the points and
224 | the points toward which propagate, in a tuple `(radius, [point0, point1, ...])`
225 | """
226 |
227 | bestRadius = 0
228 | circularArray = self.getCircularArray(point, 0)
229 | allRanges = [self.toCircularRanges(circularArray)]
230 | for radius in range(1, len(constants.circumferences)):
231 | circularArray = self.getCircularArray(point, radius, circularArray)
232 | allRanges.append(self.toCircularRanges(circularArray))
233 | if len(allRanges[radius]) > len(allRanges[bestRadius]):
234 | bestRadius = radius
235 | if len(allRanges[bestRadius]) >= 4 and len(allRanges[-2]) >= len(allRanges[-1]):
236 | # two consecutive circular arrays with the same or decreasing number>=4 of ranges
237 | break
238 | elif len(allRanges[radius]) == 2 and radius > 1:
239 | edge = 0 if allRanges[radius][0].value == True else 1
240 | if allRanges[radius][edge].end-allRanges[radius][edge].begin < len(constants.circumferences[radius]) / 4:
241 | # only two ranges but the edge range is small (1/4 of the circumference)
242 | if bestRadius == 1:
243 | bestRadius = 2
244 | break
245 | elif len(allRanges[radius]) == 1 and allRanges[radius][0].value == False:
246 | # this is a point-shaped edge not sorrounded by any edges
247 | break
248 |
249 | if bestRadius == 0:
250 | return 0, []
251 |
252 | circularRanges = allRanges[bestRadius]
253 | points = []
254 | for circularRange in circularRanges:
255 | if circularRange.value == True:
256 | circumferenceIndex = circularRange.halfway()
257 | x = point[0] + constants.circumferences[bestRadius][circumferenceIndex][0]
258 | y = point[1] + constants.circumferences[bestRadius][circumferenceIndex][1]
259 |
260 | if x in range(self.xSize) and y in range(self.ySize) and self.ownerNode[x, y] == -1:
261 | points.append((x,y))
262 |
263 | return bestRadius, points
264 |
265 | def propagate(self, point, currentNodeIndex):
266 | radius, nextPoints = self.getNextPoints(point)
267 |
268 | # depth first search to set the owner of all reachable connected pixels
269 | # without an owner and find connected nodes
270 | allConnectedNodes = set()
271 | def setSeenDFS(x, y):
272 | if (x in range(self.xSize) and y in range(self.ySize)
273 | and np.hypot(x-point[0], y-point[1]) <= radius + 0.5
274 | and self.edges[x, y] == True and self.ownerNode[x, y] != currentNodeIndex):
275 | if self.ownerNode[x, y] != -1:
276 | allConnectedNodes.add(self.ownerNode[x, y])
277 | self.ownerNode[x, y] = currentNodeIndex # index of just added node
278 | setSeenDFS(x+1, y)
279 | setSeenDFS(x-1, y)
280 | setSeenDFS(x, y+1)
281 | setSeenDFS(x, y-1)
282 |
283 | self.ownerNode[point] = -1 # reset to allow DFS to start
284 | setSeenDFS(*point)
285 | for nodeIndex in allConnectedNodes:
286 | if not self.graph.areConnectedWithin(currentNodeIndex, nodeIndex, 11):
287 | self.graph.addConnection(currentNodeIndex, nodeIndex)
288 |
289 | validNextPoints = []
290 | for nextPoint in nextPoints:
291 | if self.ownerNode[nextPoint] == currentNodeIndex:
292 | # only if this point belongs to the current node after the DFS,
293 | # which means it is reachable and connected
294 | validNextPoints.append(nextPoint)
295 |
296 | for nextPoint in validNextPoints:
297 | nodeIndex = self.graph.addNode(nextPoint)
298 | self.graph.addConnection(currentNodeIndex, nodeIndex)
299 | self.propagate(nextPoint, nodeIndex)
300 | self.ownerNode[point] = currentNodeIndex
301 |
302 | def addNodeAndPropagate(self, point):
303 | nodeIndex = self.graph.addNode(point)
304 | self.propagate(point, nodeIndex)
305 |
306 | def buildGraph(self):
307 | for point in np.ndindex(np.shape(self.edges)):
308 | if self.edges[point] == True and self.ownerNode[point] == -1:
309 | radius, nextPoints = self.getNextPoints(point)
310 | if radius == 0:
311 | self.addNodeAndPropagate(point)
312 | else:
313 | for nextPoint in nextPoints:
314 | if self.ownerNode[nextPoint] == -1:
315 | self.addNodeAndPropagate(nextPoint)
316 |
317 | return self.graph
318 |
319 |
320 | def sobel(image):
321 | image = np.array(image, dtype=float)
322 | image /= 255.0
323 | Gx = ndimage.sobel(image, axis=0)
324 | Gy = ndimage.sobel(image, axis=1)
325 | res = np.hypot(Gx, Gy)
326 | res /= np.max(res)
327 | res = np.array(res * 255, dtype=np.uint8)
328 | return res[2:-2, 2:-2, 0:3]
329 |
330 | def convertToBinaryEdges(edges, threshold):
331 | result = np.maximum.reduce([edges[:, :, 0], edges[:, :, 1], edges[:, :, 2]]) >= threshold
332 | if np.shape(edges)[2] > 3:
333 | result[edges[:, :, 3] < threshold] = False
334 | return result
335 |
336 |
337 | def parseArgs(namespace):
338 | argParser = argparse.ArgumentParser(fromfile_prefix_chars="@",
339 | description="Detects the edges of an image and converts them to 2D gcode that can be printed by a plotter")
340 |
341 | argParser.add_argument_group("Data options")
342 | argParser.add_argument("-i", "--input", type=argparse.FileType('br'), required=True, metavar="FILE",
343 | help="Image to convert to gcode; all formats supported by the Python imageio library are supported")
344 | argParser.add_argument("-o", "--output", type=argparse.FileType('w'), required=True, metavar="FILE",
345 | help="File in which to save the gcode result")
346 | argParser.add_argument("--dot-output", type=argparse.FileType('w'), metavar="FILE",
347 | help="Optional file in which to save the graph (in DOT format) generated during an intermediary step of gcode generation")
348 | argParser.add_argument("-e", "--edges", type=str, metavar="MODE",
349 | help="Consider the input file already as an edges matrix, not as an image of which to detect the edges. MODE should be either `white` or `black`, that is the color of the edges in the image. The image should only be made of white or black pixels.")
350 | argParser.add_argument("-t", "--threshold", type=int, default=32, metavar="VALUE",
351 | help="The threshold in range (0,255) above which to consider a pixel as part of an edge (after Sobel was applied to the image or on reading the edges from file with the --edges option)")
352 |
353 | argParser.parse_args(namespace=namespace)
354 |
355 | if namespace.edges is not None and namespace.edges not in ["white", "black"]:
356 | argParser.error("mode for --edges should be `white` or `black`")
357 | if namespace.threshold <= 0 or namespace.threshold >= 255:
358 | argParser.error("value for --threshold should be in range (0,255)")
359 |
360 | def main():
361 | class Args: pass
362 | parseArgs(Args)
363 |
364 | image = imageio.imread(Args.input)
365 | if Args.edges is None:
366 | edges = sobel(image)
367 | elif Args.edges == "black":
368 | edges = np.invert(image)
369 | else: # Args.edges == "white"
370 | edges = image
371 | edges = convertToBinaryEdges(edges, Args.threshold)
372 |
373 | converter = EdgesToGcode(edges)
374 | converter.buildGraph()
375 |
376 | if Args.dot_output is not None:
377 | converter.graph.saveAsDotFile(Args.dot_output)
378 | converter.graph.saveAsGcodeFile(Args.output)
379 |
380 | if __name__ == "__main__":
381 | main()
--------------------------------------------------------------------------------
/images/flower.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stypox/image-to-gcode/cf6143c2d64f3d5b84583ad132d26f13064bbee7/images/flower.jpg
--------------------------------------------------------------------------------
/images/flower_gcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stypox/image-to-gcode/cf6143c2d64f3d5b84583ad132d26f13064bbee7/images/flower_gcode.png
--------------------------------------------------------------------------------
/images/tree.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stypox/image-to-gcode/cf6143c2d64f3d5b84583ad132d26f13064bbee7/images/tree.png
--------------------------------------------------------------------------------
/images/tree_gcode.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Stypox/image-to-gcode/cf6143c2d64f3d5b84583ad132d26f13064bbee7/images/tree_gcode.png
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | numpy
2 | scipy
3 | imageio
--------------------------------------------------------------------------------