├── README.md ├── community.py ├── detect_community.py ├── input.txt └── sample_image.png /README.md: -------------------------------------------------------------------------------- 1 | # Community Detection 2 | This project implements a community detection algorithm using divisive hierarchical clustering ([Girvan-Newman algorithm!](https://en.wikipedia.org/wiki/Girvan%E2%80%93Newman_algorithm)). 3 | 4 | It makes use of 2 python libraries called `networkx` and `community`. The project uses betweenness function and the modularity function which are a part of the networkx and the community libraries respectively. It also uses the matplotlib library for plotting the communities. 5 | 6 | 7 | # Execution Details 8 | The python code takes two parameters, namely input file containing the graph and the output image with the community structure. 9 | 10 | For example: 11 | 12 | `python detect_community.py input.txt image.png` 13 | 14 | #Input Parameters: 15 | `input.txt`: This file consists of the representation of the graph. All graphs will be undirected graphs. Each line in the input file is of the format: 16 | 1 2 where 1 and 2 are the nodes and each line represents an edge between the two 17 | nodes. The nodes are separated by one space. 18 | 19 | `image.png`: This will be a visualization of the communities detected by this algorithm. Each community is represented in a unique color. Each node contains a label representing the node numbers in the input file. 20 | 21 | #Output: 22 | The Python code outputs the communities in the form of arrays 23 | to standard output (the console). Each community is an array representing 24 | nodes in that community. 25 | 26 | For example: 27 | 28 | [1,2,3] 29 | 30 | [4,5,6] 31 | 32 | [7] 33 | 34 | [8] 35 | 36 | [9,10,11] 37 | 38 | [12,13,14] 39 | 40 | These 6 arrays represent the 6 communities in the input graph. 41 | 42 | Also, the community graph will be stored in output file. 43 | 44 | Sample graph for above community: 45 | ![alt tag](sample_image.png) 46 | -------------------------------------------------------------------------------- /community.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | """ 4 | This module implements community detection. 5 | """ 6 | from __future__ import print_function 7 | __all__ = ["partition_at_level", "modularity", "best_partition", "generate_dendrogram", "generate_dendogram", "induced_graph"] 8 | __author__ = """Thomas Aynaud (thomas.aynaud@lip6.fr)""" 9 | # Copyright (C) 2009 by 10 | # Thomas Aynaud 11 | # All rights reserved. 12 | # BSD license. 13 | 14 | __PASS_MAX = -1 15 | __MIN = 0.0000001 16 | 17 | import networkx as nx 18 | import sys 19 | import types 20 | import array 21 | 22 | 23 | def partition_at_level(dendrogram, level) : 24 | """Return the partition of the nodes at the given level 25 | 26 | A dendrogram is a tree and each level is a partition of the graph nodes. 27 | Level 0 is the first partition, which contains the smallest communities, and the best is len(dendrogram) - 1. 28 | The higher the level is, the bigger are the communities 29 | 30 | Parameters 31 | ---------- 32 | dendrogram : list of dict 33 | a list of partitions, ie dictionnaries where keys of the i+1 are the values of the i. 34 | level : int 35 | the level which belongs to [0..len(dendrogram)-1] 36 | 37 | Returns 38 | ------- 39 | partition : dictionnary 40 | A dictionary where keys are the nodes and the values are the set it belongs to 41 | 42 | Raises 43 | ------ 44 | KeyError 45 | If the dendrogram is not well formed or the level is too high 46 | 47 | See Also 48 | -------- 49 | best_partition which directly combines partition_at_level and generate_dendrogram to obtain the partition of highest modularity 50 | 51 | Examples 52 | -------- 53 | >>> G=nx.erdos_renyi_graph(100, 0.01) 54 | >>> dendo = generate_dendrogram(G) 55 | >>> for level in range(len(dendo) - 1) : 56 | >>> print "partition at level", level, "is", partition_at_level(dendo, level) 57 | """ 58 | partition = dendrogram[0].copy() 59 | for index in range(1, level + 1) : 60 | for node, community in partition.items() : 61 | partition[node] = dendrogram[index][community] 62 | return partition 63 | 64 | 65 | def modularity(partition, graph) : 66 | """Compute the modularity of a partition of a graph 67 | 68 | Parameters 69 | ---------- 70 | partition : dict 71 | the partition of the nodes, i.e a dictionary where keys are their nodes and values the communities 72 | graph : networkx.Graph 73 | the networkx graph which is decomposed 74 | 75 | Returns 76 | ------- 77 | modularity : float 78 | The modularity 79 | 80 | Raises 81 | ------ 82 | KeyError 83 | If the partition is not a partition of all graph nodes 84 | ValueError 85 | If the graph has no link 86 | TypeError 87 | If graph is not a networkx.Graph 88 | 89 | References 90 | ---------- 91 | .. 1. Newman, M.E.J. & Girvan, M. Finding and evaluating community structure in networks. Physical Review E 69, 26113(2004). 92 | 93 | Examples 94 | -------- 95 | >>> G=nx.erdos_renyi_graph(100, 0.01) 96 | >>> part = best_partition(G) 97 | >>> modularity(part, G) 98 | """ 99 | if type(graph) != nx.Graph : 100 | raise TypeError("Bad graph type, use only non directed graph") 101 | 102 | inc = dict([]) 103 | deg = dict([]) 104 | links = graph.size(weight='weight') 105 | if links == 0 : 106 | raise ValueError("A graph without link has an undefined modularity") 107 | 108 | for node in graph : 109 | com = partition[node] 110 | deg[com] = deg.get(com, 0.) + graph.degree(node, weight = 'weight') 111 | for neighbor, datas in graph[node].items() : 112 | weight = datas.get("weight", 1) 113 | if partition[neighbor] == com : 114 | if neighbor == node : 115 | inc[com] = inc.get(com, 0.) + float(weight) 116 | else : 117 | inc[com] = inc.get(com, 0.) + float(weight) / 2. 118 | 119 | res = 0. 120 | for com in set(partition.values()) : 121 | res += (inc.get(com, 0.) / links) - (deg.get(com, 0.) / (2.*links))**2 122 | return res 123 | 124 | 125 | def best_partition(graph, partition = None) : 126 | """Compute the partition of the graph nodes which maximises the modularity 127 | (or try..) using the Louvain heuristices 128 | 129 | This is the partition of highest modularity, i.e. the highest partition of the dendrogram 130 | generated by the Louvain algorithm. 131 | 132 | Parameters 133 | ---------- 134 | graph : networkx.Graph 135 | the networkx graph which is decomposed 136 | partition : dict, optionnal 137 | the algorithm will start using this partition of the nodes. It's a dictionary where keys are their nodes and values the communities 138 | 139 | Returns 140 | ------- 141 | partition : dictionnary 142 | The partition, with communities numbered from 0 to number of communities 143 | 144 | Raises 145 | ------ 146 | NetworkXError 147 | If the graph is not Eulerian. 148 | 149 | See Also 150 | -------- 151 | generate_dendrogram to obtain all the decompositions levels 152 | 153 | Notes 154 | ----- 155 | Uses Louvain algorithm 156 | 157 | References 158 | ---------- 159 | .. 1. Blondel, V.D. et al. Fast unfolding of communities in large networks. J. Stat. Mech 10008, 1-12(2008). 160 | 161 | Examples 162 | -------- 163 | >>> #Basic usage 164 | >>> G=nx.erdos_renyi_graph(100, 0.01) 165 | >>> part = best_partition(G) 166 | 167 | >>> #other example to display a graph with its community : 168 | >>> #better with karate_graph() as defined in networkx examples 169 | >>> #erdos renyi don't have true community structure 170 | >>> G = nx.erdos_renyi_graph(30, 0.05) 171 | >>> #first compute the best partition 172 | >>> partition = community.best_partition(G) 173 | >>> #drawing 174 | >>> size = float(len(set(partition.values()))) 175 | >>> pos = nx.spring_layout(G) 176 | >>> count = 0. 177 | >>> for com in set(partition.values()) : 178 | >>> count = count + 1. 179 | >>> list_nodes = [nodes for nodes in partition.keys() 180 | >>> if partition[nodes] == com] 181 | >>> nx.draw_networkx_nodes(G, pos, list_nodes, node_size = 20, 182 | node_color = str(count / size)) 183 | >>> nx.draw_networkx_edges(G,pos, alpha=0.5) 184 | >>> plt.show() 185 | """ 186 | dendo = generate_dendrogram(graph, partition) 187 | return partition_at_level(dendo, len(dendo) - 1 ) 188 | 189 | 190 | def generate_dendogram(graph, part_init = None) : 191 | """Deprecated, use generate_dendrogram""" 192 | return generate_dendrogram(graph, part_init) 193 | 194 | 195 | def generate_dendrogram(graph, part_init = None) : 196 | """Find communities in the graph and return the associated dendrogram 197 | 198 | A dendrogram is a tree and each level is a partition of the graph nodes. Level 0 is the first partition, which contains the smallest communities, and the best is len(dendrogram) - 1. The higher the level is, the bigger are the communities 199 | 200 | 201 | Parameters 202 | ---------- 203 | graph : networkx.Graph 204 | the networkx graph which will be decomposed 205 | part_init : dict, optionnal 206 | the algorithm will start using this partition of the nodes. It's a dictionary where keys are their nodes and values the communities 207 | 208 | Returns 209 | ------- 210 | dendrogram : list of dictionaries 211 | a list of partitions, ie dictionnaries where keys of the i+1 are the values of the i. and where keys of the first are the nodes of graph 212 | 213 | Raises 214 | ------ 215 | TypeError 216 | If the graph is not a networkx.Graph 217 | 218 | See Also 219 | -------- 220 | best_partition 221 | 222 | Notes 223 | ----- 224 | Uses Louvain algorithm 225 | 226 | References 227 | ---------- 228 | .. 1. Blondel, V.D. et al. Fast unfolding of communities in large networks. J. Stat. Mech 10008, 1-12(2008). 229 | 230 | Examples 231 | -------- 232 | >>> G=nx.erdos_renyi_graph(100, 0.01) 233 | >>> dendo = generate_dendrogram(G) 234 | >>> for level in range(len(dendo) - 1) : 235 | >>> print "partition at level", level, "is", partition_at_level(dendo, level) 236 | """ 237 | if type(graph) != nx.Graph : 238 | raise TypeError("Bad graph type, use only non directed graph") 239 | 240 | #special case, when there is no link 241 | #the best partition is everyone in its community 242 | if graph.number_of_edges() == 0 : 243 | part = dict([]) 244 | for node in graph.nodes() : 245 | part[node] = node 246 | return part 247 | 248 | current_graph = graph.copy() 249 | status = Status() 250 | status.init(current_graph, part_init) 251 | mod = __modularity(status) 252 | status_list = list() 253 | __one_level(current_graph, status) 254 | new_mod = __modularity(status) 255 | partition = __renumber(status.node2com) 256 | status_list.append(partition) 257 | mod = new_mod 258 | current_graph = induced_graph(partition, current_graph) 259 | status.init(current_graph) 260 | 261 | while True : 262 | __one_level(current_graph, status) 263 | new_mod = __modularity(status) 264 | if new_mod - mod < __MIN : 265 | break 266 | partition = __renumber(status.node2com) 267 | status_list.append(partition) 268 | mod = new_mod 269 | current_graph = induced_graph(partition, current_graph) 270 | status.init(current_graph) 271 | return status_list[:] 272 | 273 | 274 | def induced_graph(partition, graph) : 275 | """Produce the graph where nodes are the communities 276 | 277 | there is a link of weight w between communities if the sum of the weights of the links between their elements is w 278 | 279 | Parameters 280 | ---------- 281 | partition : dict 282 | a dictionary where keys are graph nodes and values the part the node belongs to 283 | graph : networkx.Graph 284 | the initial graph 285 | 286 | Returns 287 | ------- 288 | g : networkx.Graph 289 | a networkx graph where nodes are the parts 290 | 291 | Examples 292 | -------- 293 | >>> n = 5 294 | >>> g = nx.complete_graph(2*n) 295 | >>> part = dict([]) 296 | >>> for node in g.nodes() : 297 | >>> part[node] = node % 2 298 | >>> ind = induced_graph(part, g) 299 | >>> goal = nx.Graph() 300 | >>> goal.add_weighted_edges_from([(0,1,n*n),(0,0,n*(n-1)/2), (1, 1, n*(n-1)/2)]) 301 | >>> nx.is_isomorphic(int, goal) 302 | True 303 | """ 304 | ret = nx.Graph() 305 | ret.add_nodes_from(partition.values()) 306 | 307 | for node1, node2, datas in graph.edges_iter(data = True) : 308 | weight = datas.get("weight", 1) 309 | com1 = partition[node1] 310 | com2 = partition[node2] 311 | w_prec = ret.get_edge_data(com1, com2, {"weight":0}).get("weight", 1) 312 | ret.add_edge(com1, com2, weight = w_prec + weight) 313 | 314 | return ret 315 | 316 | 317 | def __renumber(dictionary) : 318 | """Renumber the values of the dictionary from 0 to n 319 | """ 320 | count = 0 321 | ret = dictionary.copy() 322 | new_values = dict([]) 323 | 324 | for key in dictionary.keys() : 325 | value = dictionary[key] 326 | new_value = new_values.get(value, -1) 327 | if new_value == -1 : 328 | new_values[value] = count 329 | new_value = count 330 | count = count + 1 331 | ret[key] = new_value 332 | 333 | return ret 334 | 335 | 336 | def __load_binary(data) : 337 | """Load binary graph as used by the cpp implementation of this algorithm 338 | """ 339 | data = open(data, "rb") 340 | 341 | reader = array.array("I") 342 | reader.fromfile(data, 1) 343 | num_nodes = reader.pop() 344 | reader = array.array("I") 345 | reader.fromfile(data, num_nodes) 346 | cum_deg = reader.tolist() 347 | num_links = reader.pop() 348 | reader = array.array("I") 349 | reader.fromfile(data, num_links) 350 | links = reader.tolist() 351 | graph = nx.Graph() 352 | graph.add_nodes_from(range(num_nodes)) 353 | prec_deg = 0 354 | 355 | for index in range(num_nodes) : 356 | last_deg = cum_deg[index] 357 | neighbors = links[prec_deg:last_deg] 358 | graph.add_edges_from([(index, int(neigh)) for neigh in neighbors]) 359 | prec_deg = last_deg 360 | 361 | return graph 362 | 363 | 364 | def __one_level(graph, status) : 365 | """Compute one level of communities 366 | """ 367 | modif = True 368 | nb_pass_done = 0 369 | cur_mod = __modularity(status) 370 | new_mod = cur_mod 371 | 372 | while modif and nb_pass_done != __PASS_MAX : 373 | cur_mod = new_mod 374 | modif = False 375 | nb_pass_done += 1 376 | 377 | for node in graph.nodes() : 378 | com_node = status.node2com[node] 379 | degc_totw = status.gdegrees.get(node, 0.) / (status.total_weight*2.) 380 | neigh_communities = __neighcom(node, graph, status) 381 | __remove(node, com_node, 382 | neigh_communities.get(com_node, 0.), status) 383 | best_com = com_node 384 | best_increase = 0 385 | for com, dnc in neigh_communities.items() : 386 | incr = dnc - status.degrees.get(com, 0.) * degc_totw 387 | if incr > best_increase : 388 | best_increase = incr 389 | best_com = com 390 | __insert(node, best_com, 391 | neigh_communities.get(best_com, 0.), status) 392 | if best_com != com_node : 393 | modif = True 394 | new_mod = __modularity(status) 395 | if new_mod - cur_mod < __MIN : 396 | break 397 | 398 | 399 | class Status : 400 | """ 401 | To handle several data in one struct. 402 | 403 | Could be replaced by named tuple, but don't want to depend on python 2.6 404 | """ 405 | node2com = {} 406 | total_weight = 0 407 | internals = {} 408 | degrees = {} 409 | gdegrees = {} 410 | 411 | def __init__(self) : 412 | self.node2com = dict([]) 413 | self.total_weight = 0 414 | self.degrees = dict([]) 415 | self.gdegrees = dict([]) 416 | self.internals = dict([]) 417 | self.loops = dict([]) 418 | 419 | def __str__(self) : 420 | return ("node2com : " + str(self.node2com) + " degrees : " 421 | + str(self.degrees) + " internals : " + str(self.internals) 422 | + " total_weight : " + str(self.total_weight)) 423 | 424 | def copy(self) : 425 | """Perform a deep copy of status""" 426 | new_status = Status() 427 | new_status.node2com = self.node2com.copy() 428 | new_status.internals = self.internals.copy() 429 | new_status.degrees = self.degrees.copy() 430 | new_status.gdegrees = self.gdegrees.copy() 431 | new_status.total_weight = self.total_weight 432 | 433 | def init(self, graph, part = None) : 434 | """Initialize the status of a graph with every node in one community""" 435 | count = 0 436 | self.node2com = dict([]) 437 | self.total_weight = 0 438 | self.degrees = dict([]) 439 | self.gdegrees = dict([]) 440 | self.internals = dict([]) 441 | self.total_weight = graph.size(weight = 'weight') 442 | if part == None : 443 | for node in graph.nodes() : 444 | self.node2com[node] = count 445 | deg = float(graph.degree(node, weight = 'weight')) 446 | if deg < 0 : 447 | raise ValueError("Bad graph type, use positive weights") 448 | self.degrees[count] = deg 449 | self.gdegrees[node] = deg 450 | self.loops[node] = float(graph.get_edge_data(node, node, 451 | {"weight":0}).get("weight", 1)) 452 | self.internals[count] = self.loops[node] 453 | count = count + 1 454 | else : 455 | for node in graph.nodes() : 456 | com = part[node] 457 | self.node2com[node] = com 458 | deg = float(graph.degree(node, weight = 'weight')) 459 | self.degrees[com] = self.degrees.get(com, 0) + deg 460 | self.gdegrees[node] = deg 461 | inc = 0. 462 | for neighbor, datas in graph[node].items() : 463 | weight = datas.get("weight", 1) 464 | if weight <= 0 : 465 | raise ValueError("Bad graph type, use positive weights") 466 | if part[neighbor] == com : 467 | if neighbor == node : 468 | inc += float(weight) 469 | else : 470 | inc += float(weight) / 2. 471 | self.internals[com] = self.internals.get(com, 0) + inc 472 | 473 | 474 | 475 | def __neighcom(node, graph, status) : 476 | """ 477 | Compute the communities in the neighborood of node in the graph given 478 | with the decomposition node2com 479 | """ 480 | weights = {} 481 | for neighbor, datas in graph[node].items() : 482 | if neighbor != node : 483 | weight = datas.get("weight", 1) 484 | neighborcom = status.node2com[neighbor] 485 | weights[neighborcom] = weights.get(neighborcom, 0) + weight 486 | 487 | return weights 488 | 489 | 490 | def __remove(node, com, weight, status) : 491 | """ Remove node from community com and modify status""" 492 | status.degrees[com] = ( status.degrees.get(com, 0.) 493 | - status.gdegrees.get(node, 0.) ) 494 | status.internals[com] = float( status.internals.get(com, 0.) - 495 | weight - status.loops.get(node, 0.) ) 496 | status.node2com[node] = -1 497 | 498 | 499 | def __insert(node, com, weight, status) : 500 | """ Insert node into community and modify status""" 501 | status.node2com[node] = com 502 | status.degrees[com] = ( status.degrees.get(com, 0.) + 503 | status.gdegrees.get(node, 0.) ) 504 | status.internals[com] = float( status.internals.get(com, 0.) + 505 | weight + status.loops.get(node, 0.) ) 506 | 507 | 508 | def __modularity(status) : 509 | """ 510 | Compute the modularity of the partition of the graph faslty using status precomputed 511 | """ 512 | links = float(status.total_weight) 513 | result = 0. 514 | for community in set(status.node2com.values()) : 515 | in_degree = status.internals.get(community, 0.) 516 | degree = status.degrees.get(community, 0.) 517 | if links > 0 : 518 | result = result + in_degree / links - ((degree / (2.*links))**2) 519 | return result 520 | 521 | 522 | def main() : 523 | """Main function to mimic C++ version behavior""" 524 | try : 525 | filename = sys.argv[1] 526 | graphfile = __load_binary(filename) 527 | partition = best_partition(graphfile) 528 | print(str(modularity(partition, graphfile)), file=sys.stderr) 529 | for elem, part in partition.items() : 530 | print(str(elem) + " " + str(part)) 531 | except (IndexError, IOError): 532 | print("Usage : ./community filename") 533 | print("find the communities in graph filename and display the dendrogram") 534 | print("Parameters:") 535 | print("filename is a binary file as generated by the ") 536 | print("convert utility distributed with the C implementation") -------------------------------------------------------------------------------- /detect_community.py: -------------------------------------------------------------------------------- 1 | import networkx as nx 2 | import community as c 3 | import matplotlib.pyplot as plt 4 | import sys 5 | import pylab 6 | import copy 7 | 8 | pylab.show() 9 | 10 | #returns subgraphs after removing edges with maximum betweeness from the original graph 11 | def removeEdges(G): 12 | remove = [] #stores edges having maximum betweenness which needs to be removed from graph 13 | b = nx.edge_betweenness_centrality(G) 14 | max_betweenness = b[max(b,key=b.get)] 15 | for k,v in b.iteritems(): 16 | if v==max_betweenness: 17 | remove.append(k) 18 | 19 | G.remove_edges_from(remove) # remove edges from G with max betwenness 20 | graphs = list(nx.connected_component_subgraphs(G)) 21 | 22 | d={} 23 | counter = 0 24 | for graph in graphs: 25 | counter+=1 26 | for node in graph: 27 | d[node]=counter 28 | 29 | if G.number_of_edges() == 0: 30 | return [list(nx.connected_component_subgraphs(G)),0,G] 31 | 32 | modularity = c.modularity(d,G) 33 | return [list(nx.connected_component_subgraphs(G)),modularity,G] 34 | 35 | 36 | if __name__=="__main__": 37 | if len(sys.argv)!=3: 38 | print "Usage: detect_communities " 39 | print "Inputfile : Contains represenation of the graph" 40 | print "Outputfile : This file store community visualization" 41 | exit(-1) 42 | 43 | result_communities=[] 44 | G = nx.read_edgelist(sys.argv[1]) 45 | copyGraph = copy.deepcopy(G) 46 | d={} 47 | for node in G: 48 | d[node]=0 49 | 50 | initial_modularity = c.modularity(d,G) 51 | result_communities.append([d,initial_modularity,G]) 52 | 53 | while G.number_of_edges()>0: 54 | subgraphs = removeEdges(G) 55 | result_communities.append(subgraphs) 56 | G=subgraphs[-1] 57 | 58 | for step in result_communities: 59 | # print ("modularity",step[1]) 60 | if step[1]>initial_modularity: 61 | ng=step[0] 62 | result=[] 63 | modularity=step[1] 64 | 65 | for graph in step[0]: 66 | result.append(sorted([int(vertex) for vertex in graph])) 67 | 68 | 69 | for community in result: 70 | print community 71 | 72 | d={};counter=0 73 | 74 | for graph in ng: 75 | for node in graph: 76 | d[node] = counter 77 | counter+=1 78 | 79 | 80 | 81 | pos=nx.spring_layout(copyGraph) 82 | colors = ["violet","black","orange","cyan","red","blue","green","yellow","indigo","pink"] 83 | for i in range(len(ng)): 84 | graph=ng[i] 85 | nlist = [node for node in graph] 86 | nx.draw_networkx_nodes(copyGraph,pos,nodelist=nlist,node_color=colors[i%10],node_size=500,alpha=0.8) 87 | 88 | nx.draw_networkx_edges(copyGraph,pos) 89 | nx.draw_networkx_labels(copyGraph,pos,font_size=10) 90 | plt.axis('off') 91 | plt.savefig(sys.argv[2]) # save as png 92 | 93 | -------------------------------------------------------------------------------- /input.txt: -------------------------------------------------------------------------------- 1 | 1 2 2 | 1 3 3 | 2 1 4 | 2 3 5 | 3 1 6 | 3 2 7 | 3 7 8 | 4 5 9 | 4 6 10 | 5 4 11 | 5 6 12 | 6 4 13 | 6 5 14 | 6 7 15 | 7 3 16 | 7 6 17 | 7 8 18 | 8 7 19 | 8 9 20 | 8 12 21 | 9 8 22 | 9 11 23 | 9 10 24 | 10 9 25 | 10 11 26 | 11 9 27 | 11 10 28 | 12 8 29 | 12 14 30 | 12 13 31 | 13 12 32 | 13 14 33 | 14 13 34 | 14 12 -------------------------------------------------------------------------------- /sample_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/riteshkasat/Community-Detection-Algorithm/34731d04404de7f167111754717b90a24bef72e0/sample_image.png --------------------------------------------------------------------------------