1#!/usr/bin/python
2#    Copyright (C) 1991-2004 Artifex Software, Inc.  All rights reserved.
3#    All Rights Reserved.
4#
5# This software is provided AS-IS with no warranty, either express or
6# implied.
7#
8# This software is distributed under license and may not be copied, modified
9# or distributed except as expressly authorized under the terms of that
10# license.  Refer to licensing information at http://www.artifex.com/
11# or contact Artifex Software, Inc.,  7 Mt. Lassen Drive - Suite A-134,
12# San Rafael, CA  94903, U.S.A., +1(415)492-9861, for further information.
13
14# $Id: cmpi.py 8409 2007-11-27 20:43:09Z giles $
15# python compare image -- interactive graphical image differencing
16#
17"""
18	cmpi	(compare image or compare interactive)
19
20	usage:	cmpi [basline_filename compare_filename]
21
22"""
23HelpMsg = '''
24       key	mnemonic	function
25       ---	------------	-----------------------------
26	b	"baseline"	show baseline image
27	c	"compare"	show compare image
28	d	"differences"	show differences (black is different)
29	h	"highlight"	toggle difference highlights (box outline)
30	m	"mask"		toggle mask of baseline/compare with diff
31	n	"next"		go to next difference
32	o	"open"		open new image files
33	p	"previous"	go to previous difference
34	t	"this"		go to current difference
35	q	"quit"
36	z	"zoom"		zoom to a value
37	+ =			zoom in
38	-			zoom out
39	?			help message
40'''
41
42from Tkinter import *
43import Image, ImageTk, ImageDraw
44import os
45import sys		# for exit
46
47# Set globals
48baseline_filename = ""
49compare_filename = ""
50
51class ScrolledCanvas(Frame):
52    def __init__(self, parent=None, color='#E4E4E4'):	# default bg = lightgray
53        Frame.__init__(self, parent)
54        self.pack(expand=YES, fill=BOTH)
55
56	self.statustext = StringVar()
57	self.statustext.set("                                                                                ")
58	self.statusbar = Label(self, anchor="w", height=1, textvariable=self.statustext)
59	self.statusbar.pack(side=BOTTOM, fill=X)
60
61        self.canv = Canvas(self, bg=color, relief=SUNKEN)
62        self.canv.config()
63
64	self.extraX = self.extraY = -1
65
66        self.sbarX = Scrollbar(self, orient=HORIZONTAL)
67        self.sbarX.config(command=self.canv.xview)
68        self.canv.config(xscrollcommand=self.sbarX.set)
69        self.sbarX.pack(side=BOTTOM, fill=X)
70
71        self.sbarY = Scrollbar(self, orient=VERTICAL)
72        self.sbarY.config(command=self.canv.yview)
73        self.canv.config(yscrollcommand=self.sbarY.set)
74        self.sbarY.pack(side=RIGHT, fill=Y)
75
76	self.canv.config(scrollregion=self.canv.bbox(ALL))
77	self.canvas_image = None
78
79	self.line1 = self.canv.create_line(0, 0, 0, 0, arrow=LAST, fill="red")
80	self.line2 = self.canv.create_line(0, 0, 0, 0, arrow=LAST, fill="darkgreen")
81
82        self.canv.pack(side=LEFT, expand=YES, fill=BOTH)
83	# self.canv.bind("<Button-1>", self.LeftMouse)
84
85    # not used currently - can't decide what we need the mouse for
86    def LeftMouse(self, event):
87	print "Left Mouse click at: ", event.x, event.y
88	self.canv.coords(self.line1, 0, event.y, event.x, event.y)
89	self.canv.coords(self.line2, event.x, 0, event.x, event.y)
90
91def mask_func(val):
92    return val*0.9
93
94# Display an image at the current zoom factor
95def DoDisplay(SC):
96    if SC.canvas_image != None: SC.canv.delete(SC.canvas_image)
97    if SC.mask != 0:
98	blank_image = Image.new("RGB", SC.image.size, "white")
99        mask_image = SC.image_diff.point(mask_func)
100	this_image = Image.composite(blank_image, SC.image, mask_image)
101    else:
102	this_image = SC.image
103    SC.image_zoomed = this_image.resize((this_image.size[0]*SC.zoom_factor,
104    	this_image.size[1]*SC.zoom_factor))
105    SC.photo = ImageTk.PhotoImage(SC.image_zoomed)
106    SC.canvas_image = SC.canv.create_image(0, 0, image=SC.photo, anchor=NW)
107    SC.canv.tag_lower(SC.canvas_image, 1)	# bottomost element
108    # set scrollregion
109    SC.canv.config(scrollregion=SC.canv.bbox(ALL))
110
111def b_proc(SC, key):
112    # set title to 'baseline image: '
113    s = "image: %s" % baseline_filename
114    SC.statustext.set(SC.statustext.get()[:66] + s )
115    # display baseline
116    SC.image = SC.image_baseline
117    DoDisplay(SC)
118
119def c_proc(SC, key):
120    # set title to 'compare image: '
121    s = "image: %s" % compare_filename
122    SC.statustext.set(SC.statustext.get()[:66] + s )
123    # display baseline
124    SC.image = SC.image_compare
125    DoDisplay(SC)
126
127def d_proc(SC, key):
128    # set title to 'compare image: '
129    s = "image: differences"
130    SC.statustext.set(SC.statustext.get()[:66] + s )
131    SC.image = SC.image_diff
132    SC.mask = 0
133    DoDisplay(SC)
134
135def DelHighlights():
136    if SC.highlight > 0:
137	for l in SC.highlight_list:
138	    SC.canv.delete(l)
139	SC.highlight_list = [ ]
140
141def GoToXY(X, Y):	# (0,0) is upper left
142
143    # window may have been resized - 't' == "this" will re-center
144    g = SC.master.geometry()
145    gw = int(g.split('x')[0])
146    gh = g.split('x')[1]
147    gh = int(gh.split('+')[0])
148
149    # compute display size
150    dw = gw - SC.extraX
151    dh = gh - SC.extraY
152
153    shx = dw / (float(SC.Width) * SC.zoom_factor)		# scrollbar 'handle' width
154    xf = (X / float(SC.Width)) - (shx/2)
155    shy = dh / (float(SC.Height) * SC.zoom_factor)		# scrollbar 'handle' width
156    yf = (Y / float(SC.Height)) - (shy/2)
157    SC.canv.xview("moveto", xf)
158    SC.canv.yview("moveto", yf)
159
160def HighlightArea(B, Z, Color):
161    SC.highlight_list.append(SC.canv.create_line(Z*B[0], Z*B[1], Z*(1+B[2]), Z*B[1], fill=Color))
162    SC.highlight_list.append(SC.canv.create_line(Z*B[0], Z*(1+B[3]), Z*(1+B[2]), Z*(1+B[3]), fill=Color))
163    SC.highlight_list.append(SC.canv.create_line(Z*B[0], Z*B[1], Z*B[0], Z*(1+B[3]), fill=Color))
164    SC.highlight_list.append(SC.canv.create_line(Z*(1+B[2]), Z*B[1], Z*(1+B[2]), Z*(1+B[3]), fill=Color))
165
166
167def DoHighlights():
168    global SC
169
170    if SC.highlight > 0:
171	# draw the highlight boxes at the current zoom_factor
172	z = SC.zoom_factor
173	for i in range(len(SC.areas)):
174	    if i == SC.current_area: color = "red"
175	    else: color = "green"
176	    HighlightArea(SC.area_boxes[i], z, color)
177
178def h_proc(SC, key):
179    DelHighlights()	# If they are on, turn them 'OFF'
180    SC.highlight = 1 - SC.highlight
181    DoHighlights()	# Draw highlights if now 'ON'
182
183def m_proc(SC, key):
184    if SC.image == SC.image_diff:
185	print 'Cannot mask. First select baseline or compare image.'
186    SC.mask = 1 - SC.mask
187    DoDisplay(SC)
188
189def npt_proc(SC, key):		# next, previous, this
190
191    if key == 'n': SC.current_area += 1
192    if SC.current_area >= 0:		# initially at -1, ignore 't' and 'p'
193	DelHighlights()	# If they are on, turn them 'OFF'
194	if key == 'p': SC.current_area -= 1
195	if SC.current_area >= len(SC.areas): SC.current_area -= 1	# at last
196	if SC.current_area < 0: SC.current_area = 0		# at first
197	# set statusbar to say which we are at, format "at diff: n of m"
198	s = "at diff: %d of %d, Box: (%d,%d) - (%d,%d)" % (SC.current_area + 1, len(SC.areas), \
199		SC.area_boxes[SC.current_area][0], SC.area_boxes[SC.current_area][1], \
200		SC.area_boxes[SC.current_area][2], SC.area_boxes[SC.current_area][3])
201	blanks = "                                                                    "
202	SC.statustext.set(s + blanks[0:66-len(s)] + SC.statustext.get()[66:])
203	b = SC.area_boxes[SC.current_area]
204	X = float(b[0] + b[2]) / 2.0
205	Y = float(b[1] + b[3]) / 2.0
206	GoToXY(X, Y)
207    DoHighlights()	# Draw highlights if now 'ON'
208
209def open_dlg_remove():
210    global open_dlg
211    global baseline_filename, compare_filename
212
213    open_dlg["takefocus"] = 0
214    open_dlg.grab_release()
215    open_dlg.unbind("<Key>")
216    open_dlg.destroy()
217    open_dlg == None
218    SC["takefocus"] = 1
219    SC.master.deiconify()
220    SC.bind_all("<Key>", KeyPress)
221    open_files()
222
223def open_files():
224    # collect differences
225    # image types: P4:1-bit binary, P5:8-bit gray, P6:8-bit RGB (24-bit)
226    print "Gathering diffs, please wait ..."	##DEBUG
227
228    DelHighlights()
229    SC.zoom_factor = 1
230    SC.current_area = -1
231    SC.mask = 0
232    SC.negate = 0
233    SC.highlight = 0
234    SC.highlight_list = [ ]
235
236    B = open(baseline_filename, "rb")
237    C = open(compare_filename, "rb")
238    BType = B.readline()
239    CType = C.readline()
240    while True:
241	BDim = B.readline()
242	CDim = C.readline()
243	if BDim[0] != '#': break
244
245    SC.Width = int(BDim.split()[0])
246    SC.Height = int(BDim.split()[1])
247
248    pixel_size = 1		# default to 1 byte per pixel.
249    if BType[0:2] == "P6":
250	pixel_size = 3
251	BMax = B.readline()
252	CMax = C.readline()
253
254    Bstr = B.read()
255    Cstr = C.read()
256
257    # areas are lists of (start, end, line#) triples. Note that there may be
258    # more than 1 triple with the same line# created when two areas merge on
259    # a line after the first (V shape)
260    SC.areas = [ ]
261    merge = [ ]
262    # diffs_prev_line stores triples: (start, end, area#)
263    # while collecting diffs_curr_line, area# is set to -1 (unknown)
264    diffs_prev_line = [ ]
265    for line in range(SC.Height):
266	line_base = line*SC.Width*pixel_size
267	diffs_curr_line = [ ]
268	start = end = -2
269	for i in range(line_base, line_base+(SC.Width*pixel_size), pixel_size):
270	    j = i+pixel_size
271	    if Bstr[i:j] != Cstr[i:j]:
272		# next differing pixel
273		if i == end+pixel_size:
274		    # set new end point
275		    end = i
276		else:
277		    if end >= 0:
278			# store previous run
279			diffs_curr_line.append( ((start-line_base)/pixel_size, (end-line_base)/pixel_size, -1) )
280		    start = end = i
281	# end-of-line, store final diff
282	if end >= 0:
283	    diffs_curr_line.append( ((start-line_base)/pixel_size, (end-line_base)/pixel_size, -1) )
284	if len(diffs_curr_line) > 0:
285	    # Now update the areas
286	    if len(diffs_prev_line) == 0:
287		# add all as new areas
288		for i in range(len(diffs_curr_line)):
289		    diff = diffs_curr_line[i]
290		    SC.areas.append( [ (diff[0], diff[1], line) ] )
291		    diffs_curr_line[i] = (diff[0], diff[1], len(SC.areas)-1)	# update area#
292	    else:
293		# process curr_line, checking for areas adjacent to those in prev_line
294		index_in_prev_line = 0
295		prev_diff = diffs_prev_line[0] # [index_in_prev_line]
296		for i in range(len(diffs_curr_line)):
297		    diff = diffs_curr_line[i]
298		    while diff[0] > prev_diff[1]+1:
299			# advance to next diff in prev_line (if any)
300			index_in_prev_line += 1
301			prev_diff = advance_diff_in_prev_line(diffs_prev_line, index_in_prev_line)
302
303		    #  curr.E  >= prev.S - 1     & curr.S  <= prev.E + 1
304		    if (prev_diff[2] >= 0) and \
305			    (diff[1] >= prev_diff[0]-1) and \
306			    (diff[0] <= prev_diff[1]+1):
307			# append this diff to area of prev diff's area
308			SC.areas[prev_diff[2]].append( (diff[0], diff[1], line) )
309			diffs_curr_line[i] = (diff[0], diff[1], prev_diff[2])	# update area# from prev line
310		    else:
311			SC.areas.append( [ (diff[0], diff[1], line) ] )
312			diffs_curr_line[i] = (diff[0], diff[1], len(SC.areas)-1)
313
314	    # Merge areas.
315	    for curr in diffs_curr_line:
316		for prev in diffs_prev_line:
317		    if curr[2] != prev[2]:
318			# areas are different
319			if (curr[0] <= prev[1]+1) & (curr[1] >= prev[0]):
320			    # merge areas curr[2] and prev[2]
321			    merge.append( [curr[2], prev[2]] )
322
323	# After updating the areas, save current line as previous
324	diffs_prev_line = diffs_curr_line
325
326    # finished all lines
327    B.close()
328    C.close()
329    # Process the merge list
330    for m in range(len(merge)):
331	for n in range(m+1, len(merge)):
332	    if merge[m][0] == merge[n][0] or merge[m][1] == merge[n][0]:
333		merge[m].append(merge[n][1])
334		merge[n][1] = -1
335	    if merge[m][0] == merge[n][1] or merge[m][1] == merge[n][1]:
336		merge[m].append(merge[n][0])
337		merge[n][0] = -1
338	dest = merge[m][0]
339	if dest >= 0:
340	    for src in merge[m][1:]:
341		if src >= 0:
342		    SC.areas[dest].extend(SC.areas[src])
343		    SC.areas[src] = [ ]
344
345    # remove any empty (placeholder) elements in areas
346    for i in range(SC.areas.count( [ ] )): SC.areas.remove( [ ] )
347
348    # Capture the images for the Canvas
349    SC.image_baseline = Image.open(baseline_filename)
350    SC.image_compare = Image.open(compare_filename)
351
352    # Finally, construct the difference image (mask)
353    # collect the box for each area as we process
354    SC.image_diff = Image.new("L", SC.image_baseline.size, 255)
355    SC.area_boxes = [ ]
356    for a in SC.areas:
357	box = [ 999999, 999999, -1, -1 ]
358	for run in a:
359	    for x in range(run[0], run[1]+1):
360		SC.image_diff.putpixel((x,run[2]), 0)
361	    if run[2] < box[1]: box[1] = run[2]
362	    if run[2] > box[3]: box[3] = run[2]
363	    if run[0] < box[0]: box[0] = run[0]
364	    if run[1] > box[2]: box[2] = run[1]
365	SC.area_boxes.append(box)
366
367    SC.master.title("cmpi %s %s" % (baseline_filename, compare_filename) )
368
369    print "Difference area count: ",len(SC.areas)	##DEBUG
370
371    # set the canvas to display the entire image if it fits, otherwise use the max
372    if SC.Width < 0.95*int(SC.master.maxsize()[0]): w = 1.06*int(SC.Width)
373    else: w = 0.95*int(SC.master.maxsize()[0])
374    if SC.Height < 0.90*int(SC.master.maxsize()[1]): h = 1.11*int(SC.Height)
375    else: h = 0.90*int(SC.master.maxsize()[1])
376    g = "%dx%d+50+50" % (w, h)
377    SC.master.geometry(g)
378
379    # update the statusbar
380    s = "%d differences." % len(SC.areas)
381    blanks = "                                          "
382    SC.statustext.set(s + blanks[0:66-len(s)] + SC.statustext.get()[66:])
383
384    # Start with the baseline image
385    b_proc(SC, 'b')
386
387def FileList_KeyPress(event):
388    global open_dlg, open_names, FileListPrompt
389    global baseline_filename, compare_filename
390
391    if event.char == 'q' or event.keysym == "Escape":
392	open_names = [ ]
393	if baseline_filename == "":
394	    FileListPrompt["text"] = "\nSelect baseline image file"
395	else:
396	    open_dlg_remove()
397    elif (event.char == "o" or event.keysym == "Return") and \
398	    len(open_names) == 2:
399	baseline_filename = open_names[0]
400	compare_filename = open_names[1]
401	open_names = [ ]
402	open_dlg_remove()
403
404def GrabFileName():
405    global FileList, FileListPrompt, open_names
406
407    cur = FileList.curselection()
408    if len(cur) > 0:
409	name = FileList.get(cur)
410	FileList.after(200, FileList.selection_clear, (cur))
411	if len(open_names) == 0:
412	    open_names.append(name)
413	    FileListPrompt["text"] = "\nSelect compare image file"
414	elif len(open_names) == 1:
415	    open_names.append(name)
416	    FileListPrompt["text"] = "\nPress <Enter> if OK or <Esc> to Cancel"
417
418def FileList_Clicked(event):
419    GrabFileName()
420
421def open_dlg_pop():
422    global open_dlg
423    global FileList, FileListPrompt, open_names
424
425    open_names = [ ]
426    open_dlg = Toplevel()
427    FileListPrompt = Label(open_dlg, height=3, width=80, anchor=CENTER, text="\nSelect baseline image file")
428    FileListPrompt.grid(row=0, sticky=N)
429    yScroll = Scrollbar ( open_dlg, orient=VERTICAL )
430    yScroll.grid ( row=1, column=1, sticky=N+S )
431    xScroll = Scrollbar ( open_dlg, orient=HORIZONTAL )
432    xScroll.grid ( row=2, column=0, sticky=E+W )
433    FileList = Listbox ( open_dlg, height=20, width=80, xscrollcommand=xScroll.set, yscrollcommand=yScroll.set )
434    FileList.grid ( row=1, column=0, sticky=N+S+E+W )
435    xScroll["command"] = FileList.xview
436    yScroll["command"] = FileList.yview
437    SC.master.iconify()
438    SC.unbind_all("<Key>")
439    open_dlg.bind("<Key>", FileList_KeyPress)
440    FileList.bind("<Button-1>",FileList_Clicked)
441    open_dlg.grab_set()
442    open_dlg.focus_set()
443    SC["takefocus"] = 0
444
445    files = os.listdir(".")
446    files.sort()
447    for f in files:
448	FileList.insert(END, f)
449
450def o_proc(SC, key):	# open file using listbox selections
451    open_dlg_pop()
452
453def quit(SC, key):
454    sys.exit(0)
455
456def x_proc(SC, key):
457    g = SC.master.geometry()
458    gw = int(g.split('x')[0])
459    gh = g.split('x')[1]
460    gh = int(gh.split('+')[0])
461    print "gw x gh = ",gw, " x ", gh	##DEBUG
462    print "extra X,Y: ",SC.extraX,",",SC.extraY
463    print "zoom_factor: ", SC.zoom_factor
464
465    xy = input("Enter factors x,y: ")
466    xf = xy[0]
467    yf = xy[1]
468    SC.canv.xview("moveto", xf)
469    SC.canv.yview("moveto", yf)
470
471def zoom(SC, key):
472    # We are changing zoom factor, so delete highlights and
473    # redraw them at new zoom
474    DelHighlights()
475    if key == '-':
476	SC.zoom_factor -= 1
477	if SC.zoom_factor < 1: SC.zoom_factor = 1
478    else:
479	SC.zoom_factor += 1
480    DoHighlights()	# if highlights are 'ON', draw them at new zoom_factor
481    DoDisplay(SC)
482
483def help_proc(SC, key):
484    print HelpMsg
485
486KeyProcs = { 'b' : b_proc, 'c' : c_proc, 'd' : d_proc,
487	'h' : h_proc, 'm' : m_proc, 'n' : npt_proc, 'o' : o_proc,
488	'p' : npt_proc, 't' : npt_proc, 'q' : quit, 'x' : x_proc,
489	'=' : zoom, '+' : zoom, '-' : zoom,
490	'?' : help_proc
491	}
492
493def KeyPress(event):
494    if SC.extraX < 0:
495	# compute space outside the Canvas (scroll bars, and bottom statusbar)
496	SC.extraX = int(SC.sbarY["width"])
497	SC.extraY = 2 * int(SC.sbarX["width"])
498
499    if event.keysym != "Return" and event.keysym != "Escape":
500    	if KeyProcs.has_key(event.char):
501	    KeyProcs[event.char](SC, event.char)
502	else:
503	    print "unknown key function: keysym='%s'" %  event.keysym
504	    print HelpMsg
505
506def advance_diff_in_prev_line(diffs_prev_line, index_in_prev_line):
507    if index_in_prev_line < len(diffs_prev_line):
508	prev_diff = diffs_prev_line[index_in_prev_line]
509    else:
510	prev_diff = (99999, 99999, -1)	# dummy value starts past max line length
511    return prev_diff
512
513
514# Initialize the Scrolled Canvas
515SC = ScrolledCanvas()
516SC.highlight = 0
517
518# get file names, open the images
519if len(sys.argv) < 3:
520    open_dlg_pop()
521else:
522    baseline_filename = sys.argv[1]
523    compare_filename = sys.argv[2]
524    open_files()
525
526SC.bind_all("<Key>", KeyPress)
527
528if __name__ == '__main__': SC.mainloop()
529