]> git.donarmstrong.com Git - lilypond.git/blob - buildscripts/output-distance.py
(FileLink): new class. collect
[lilypond.git] / buildscripts / output-distance.py
1 #!@TARGET_PYTHON@
2 import sys
3 import optparse
4 import os
5
6 ## so we can call directly as buildscripts/output-distance.py
7 me_path = os.path.abspath (os.path.split (sys.argv[0])[0])
8 sys.path.insert (0, me_path + '/../python/')
9
10
11 import safeeval
12
13
14 X_AXIS = 0
15 Y_AXIS = 1
16 INFTY = 1e6
17
18 OUTPUT_EXPRESSION_PENALTY = 100
19 ORPHAN_GROB_PENALTY = 1000
20 THRESHOLD = 1.0
21
22 def max_distance (x1, x2):
23     dist = 0.0
24
25     for (p,q) in zip (x1, x2):
26         dist = max (abs (p-q), dist)
27         
28     return dist
29
30
31 empty_interval = (INFTY, -INFTY)
32 empty_bbox = (empty_interval, empty_interval)
33
34 def interval_is_empty (i):
35     return i[0] > i[1]
36
37 def interval_length (i):
38     return max (i[1]-i[0], 0) 
39     
40 def interval_union (i1, i2):
41     return (min (i1[0], i2[0]),
42             max (i1[1], i2[1]))
43
44 def interval_intersect (i1, i2):
45     return (max (i1[0], i2[0]),
46             min (i1[1], i2[1]))
47
48 def bbox_is_empty (b):
49     return (interval_is_empty (b[0])
50             or interval_is_empty (b[1]))
51
52 def bbox_union (b1, b2):
53     return (interval_union (b1[X_AXIS], b2[X_AXIS]),
54             interval_union (b2[Y_AXIS], b2[Y_AXIS]))
55             
56 def bbox_intersection (b1, b2):
57     return (interval_intersect (b1[X_AXIS], b2[X_AXIS]),
58             interval_intersect (b2[Y_AXIS], b2[Y_AXIS]))
59
60 def bbox_area (b):
61     return interval_length (b[X_AXIS]) * interval_length (b[Y_AXIS])
62
63 def bbox_diameter (b):
64     return max (interval_length (b[X_AXIS]),
65                 interval_length (b[Y_AXIS]))
66                 
67
68 def difference_area (a, b):
69     return bbox_area (a) - bbox_area (bbox_intersection (a,b))
70
71 class GrobSignature:
72     def __init__ (self, exp_list):
73         (self.name, self.origin, bbox_x,
74          bbox_y, self.output_expression) = tuple (exp_list)
75         
76         self.bbox = (bbox_x, bbox_y)
77         self.centroid = (bbox_x[0] + bbox_x[1], bbox_y[0] + bbox_y[1])
78
79     def __repr__ (self):
80         return '%s: (%.2f,%.2f), (%.2f,%.2f)\n' % (self.name,
81                                                  self.bbox[0][0],
82                                                  self.bbox[0][1],
83                                                  self.bbox[1][0],
84                                                  self.bbox[1][1])
85                                                  
86     def axis_centroid (self, axis):
87         return apply (sum, self.bbox[axis])  / 2 
88     
89     def centroid_distance (self, other, scale):
90         return max_distance (self.centroid, other.centroid) / scale 
91         
92     def bbox_distance (self, other):
93         divisor = bbox_area (self.bbox) + bbox_area (other.bbox)
94
95         if divisor:
96             return (difference_area (self.bbox, other.bbox) +
97                     difference_area (other.bbox, self.bbox)) / divisor
98         else:
99             return 0.0
100         
101     def expression_distance (self, other):
102         if self.output_expression == other.output_expression:
103             return 0.0
104         else:
105             return OUTPUT_EXPRESSION_PENALTY
106
107 class SystemSignature:
108     def __init__ (self, grob_sigs):
109         d = {}
110         for g in grob_sigs:
111             val = d.setdefault (g.name, [])
112             val += [g]
113
114         self.grob_dict = d
115         self.set_all_bbox (grob_sigs)
116
117     def set_all_bbox (self, grobs):
118         self.bbox = empty_bbox
119         for g in grobs:
120             self.bbox = bbox_union (g.bbox, self.bbox)
121
122     def closest (self, grob_name, centroid):
123         min_d = INFTY
124         min_g = None
125         try:
126             grobs = self.grob_dict[grob_name]
127
128             for g in grobs:
129                 d = max_distance (g.centroid, centroid)
130                 if d < min_d:
131                     min_d = d
132                     min_g = g
133
134
135             return min_g
136
137         except KeyError:
138             return None
139     def grobs (self):
140         return reduce (lambda x,y: x+y, self.grob_dict.values(), [])
141
142 class SystemLink:
143     def __init__ (self, system1, system2):
144         self.system1 = system1
145         self.system2 = system2
146         
147         self.link_list_dict = {}
148         self.back_link_dict = {}
149
150         for g in system1.grobs ():
151
152             ## skip empty bboxes.
153             if bbox_is_empty (g.bbox):
154                 continue
155             
156             closest = system2.closest (g.name, g.centroid)
157             
158             self.link_list_dict.setdefault (closest, [])
159             self.link_list_dict[closest].append (g)
160             self.back_link_dict[g] = closest
161
162     def geometric_distance (self):
163         d = 0.0
164         for (g1,g2) in self.back_link_dict.items ():
165             if g2:
166                 # , scale
167                 d += g1.bbox_distance (g2)
168
169         return d
170     
171     def orphan_distance (self):
172         d = 0.0
173         for (g1,g2) in self.back_link_dict.items ():
174             if g2 == None:
175                 d += ORPHAN_GROB_PENALTY
176         return d
177     
178     def output_exp_distance (self):
179         d = 0.0
180         for (g1,g2) in self.back_link_dict.items ():
181             if g2:
182                 d += g1.expression_distance (g2)
183
184         return d
185
186     def distance (self):
187         return (self.output_exp_distance (),
188                 self.orphan_distance (),
189                 self.geometric_distance ())
190
191
192 class FileLink:
193     def __init__ (self):
194         self.original_name = ''
195         self.base_names = ('','')
196         self.system_links = {}
197         self._distance = None
198         
199     def add_system_link (self, link, number):
200         self.system_links[number] = link
201
202     def calc_distance (self):
203         d = 0.0
204         for l in self.system_links.values ():
205             d = max (d, l.geometric_distance ())
206         return d
207
208     def distance (self):
209         if type (self._distance) != type (0.0):
210             return self.calc_distance ()
211         
212         return self._distance
213
214     def text_record_string (self):
215         return '%-30f %-20s\n' % (self.distance (),
216                              self.original_name)
217
218     def source_file (self):
219         for ext in ('.ly', '.ly.txt'):
220             if os.path.exists (self.base_names[1] + ext):
221                 return self.base_names[1] + ext
222         return ''
223     
224     def add_file_compare (self, f1, f2):
225         system_index = [] 
226
227         def note_system_index (m):
228             system_index.append (int (m.group (1)))
229             return ''
230         
231         base1 = re.sub ("-([0-9]+).signature", note_system_index, f1)
232         base2 = re.sub ("-([0-9]+).signature", note_system_index, f2)
233 #        name = os.path.split (base1)[1]
234
235         self.base_names = (os.path.normpath (base1),
236                            os.path.normpath (base2))
237
238         def note_original (match):
239             self.original_name = match.group (1)
240             return ''
241         
242         if not self.original_name:
243
244             ## ugh: can't we drop the .ly.txt?
245             for ext in ('.ly', '.ly.txt'):
246                 try:
247                     re.sub (r'\\sourcefilename "([^"]+)"',
248                             note_original, open (base1 + ext).read ())
249                 except IOError:
250                     pass
251                 
252         s1 = read_signature_file (f1)
253         s2 = read_signature_file (f2)
254
255         link = SystemLink (s1, s2)
256
257         self.add_system_link (link, system_index[0])
258
259     def link_files_for_html (self, old_dir, new_dir):
260
261         ## todo should create new_dir/old_suffix/HIER/ARCHY/old-file
262         
263         old_suffix = os.path.split (old_dir)[1]
264         old_dest_dir = os.path.join (new_dir, old_suffix)
265         name = os.path.split (self.base_names[0])[1]
266         os.link (self.base_names[0] + '.png',
267                  old_dest_dir + '/' + name + '.png')
268         if self.source_file ():
269             os.link (self.source_file (),
270                      old_dest_dir + "/" + name + '.ly')
271         
272     def html_record_string (self, old_dir, new_dir):
273         def img_cell (ly, img, name):
274             if not name:
275                 name = 'source'
276             else:
277                 name = '<tt>%s</tt>' % name
278                 
279             return '''
280 <td align="center">
281 <a href="%(img)s">
282 <img src="%(img)s" style="border-style: none; max-width: 500px;">
283 </a><br>
284 <font size="-2">(<a href="%(ly)s">%(name)s</a>)
285 </font>
286 </td>
287 ''' % locals ()
288         
289
290         old_suffix = os.path.split (old_dir)[1]
291         old_name = os.path.split (self.base_names[0])[1]
292
293         img_1 = os.path.join (old_suffix, old_name + '.png')
294         ly_1 = os.path.join (old_suffix, old_name + '.ly')
295         name = self.original_name
296
297         base_2 = self.base_names[1].replace (new_dir, '')
298         base_2 = re.sub ("^/*", '', base_2)
299         img_2 = base_2 + '.png'
300         
301
302         ly_2 = img_2.replace ('.png','.ly')
303
304         html_entry = '''
305 <tr>
306 <td>
307 %f<br>
308 (<a href="%s">details</a>)
309 </td>
310
311 %s
312 %s
313 </tr>
314 ''' % (self.distance (), base_2 + '.html', img_cell (ly_1, img_1, name), img_cell (ly_2, img_2, name))
315
316
317         return html_entry
318
319
320     def html_system_details_string (self):
321         systems = self.system_links.items ()
322         systems.sort ()
323
324         html = ""
325         for (c, link) in systems:
326             e = '<td>%d</td>' % c
327             for d in link.distance ():
328                 e += '<td>%f</td>' % d
329             
330             e = '<tr>%s</tr>' % e
331             html += e
332
333         original = self.original_name
334         html = '''<html>
335 <head>
336 <title>comparison details for %(original)s</title>
337 </head>
338 <body>
339 <table border=1>
340 <tr>
341 <th>system</th>
342 <th>output</th>
343 <th>orphan</th>
344 <th>geo</th>
345 </tr>
346
347 %(html)s
348 </table>
349
350 </body>
351 </html>
352 ''' % locals ()
353         return html
354
355     def write_html_system_details (self, dir2):
356         details = open (os.path.join (dir2, os.path.split (self.base_names[1])[1]) + '.html', 'w')
357         details.write (self.html_system_details_string ())
358
359 ################################################################
360 # Files/directories
361
362 import glob
363 import re
364
365 def read_signature_file (name):
366     print 'reading', name
367     exp_str = ("[%s]" % open (name).read ())
368     entries = safeeval.safe_eval (exp_str)
369
370     grob_sigs = [GrobSignature (e) for e in entries]
371     sig = SystemSignature (grob_sigs)
372     return sig
373
374
375 def compare_signature_files (f1, f2):
376     s1 = read_signature_file (f1)
377     s2 = read_signature_file (f2)
378     
379     return SystemLink (s1, s2).distance ()
380
381 def paired_files (dir1, dir2, pattern):
382     """
383     Search DIR1 and DIR2 for PATTERN.
384
385     Return (PAIRED, MISSING-FROM-2, MISSING-FROM-1)
386
387     """
388     
389     files1 = dict ((os.path.split (f)[1], 1) for f in glob.glob (dir1 + '/' + pattern))
390     files2 = dict ((os.path.split (f)[1], 1) for f in glob.glob (dir2 + '/' + pattern))
391
392     pairs = []
393     missing = []
394     for f in files1.keys ():
395         try:
396             files2.pop (f)
397             pairs.append (f)
398         except KeyError:
399             missing.append (f)
400
401     return (pairs, files2.keys (), missing)
402     
403 class ComparisonData:
404     def __init__ (self):
405         self.result_dict = {}
406         self.missing = []
407         self.added = []
408         self.file_links = {}
409     def compare_trees (self, dir1, dir2):
410         self.compare_directories (dir1, dir2)
411         
412         (root, dirs, files) = os.walk (dir1).next ()
413         for d in dirs:
414             d1 = os.path.join (dir1, d)
415             d2 = os.path.join (dir2, d)
416
417             if os.path.islink (d1) or os.path.islink (d2):
418                 continue
419             
420             if os.path.isdir (d2):
421                 self.compare_trees (d1, d2)
422     
423     def compare_directories (self, dir1, dir2):
424         
425         (paired, m1, m2) = paired_files (dir1, dir2, '*.signature')
426
427         self.missing += [(dir1, m) for m in m1] 
428         self.added += [(dir2, m) for m in m2] 
429
430         for p in paired:
431             f2 = dir2 +  '/' + p
432             f1 = dir1 +  '/' + p
433             self.compare_files (f1, f2)
434
435     def compare_files (self, f1, f2):
436         name = os.path.split (f1)[1]
437         name = re.sub ('-[0-9]+.signature', '', name)
438         
439         file_link = None
440         try:
441             file_link = self.file_links[name]
442         except KeyError:
443             file_link = FileLink ()
444             self.file_links[name] = file_link
445
446         file_link.add_file_compare (f1,f2)
447
448     def write_text_result_page (self, filename):
449         print 'writing "%s"' % filename
450         out = None
451         if filename == '':
452             out = sys.stdout
453         else:
454             out = open (filename, 'w')
455             
456
457
458         ## todo: support more scores.
459         results = [(link.distance(), link)
460                    for link in self.file_links.values ()]
461         results.sort ()
462         results.reverse ()
463
464         
465         for (score, link) in results:
466             if score > THRESHOLD:
467                 out.write (link.text_record_string ())
468
469         out.write ('\n\n')
470         out.write ('%d below threshold\n' % len ([1 for s,l  in results
471                                                     if THRESHOLD >=  s > 0.0]))
472         out.write ('%d unchanged' % len ([1 for (s,l) in results if s == 0.0]))
473         
474     def create_text_result_page (self, dir1, dir2):
475         self.write_text_result_page (dir2 + '/' + os.path.split (dir1)[1] + '.txt')
476         
477     def create_html_result_page (self, dir1, dir2):
478         dir1 = dir1.replace ('//', '/')
479         dir2 = dir2.replace ('//', '/')
480         
481         results = [(link.distance(), link)
482                    for link in self.file_links.values ()]
483         results.sort ()
484         results.reverse ()
485
486         html = ''
487         old_prefix = os.path.split (dir1)[1]
488         dest_dir = os.path.join (dir2, old_prefix)
489         os.mkdir (dest_dir)
490
491         for (score, link) in results:
492             if score <= THRESHOLD:
493                 continue
494
495             link.write_html_system_details (dir2)
496
497             link.link_files_for_html (dir1, dir2) 
498             html += link.html_record_string (dir1, dir2) 
499
500
501         html = '''<html>
502 <table rules="rows" border bordercolor="blue">
503 <tr>
504 <th>distance</th>
505 <th>old</th>
506 <th>new</th>
507 </tr>
508 %(html)s
509 </table>
510 </html>''' % locals()
511
512         html += ('<p>')
513         below_count  =len ([1 for s,l  in results
514                          if THRESHOLD >=  s > 0.0])
515
516         if below_count:
517             html += ('<p>%d below threshold</p>' % below_count)
518
519         html += ('<p>%d unchanged</p>'
520                  % len ([1 for (s,l) in results if s == 0.0]))
521
522
523         open (os.path.join (dir2, old_prefix) + '.html', 'w').write (html)
524         
525     def print_results (self):
526         self.write_text_result_page ('')
527         
528         
529
530 def compare_trees (dir1, dir2):
531     data = ComparisonData ()
532     data.compare_trees (dir1, dir2)
533     data.print_results ()
534     data.create_html_result_page (dir1, dir2)
535 #    data.create_text_result_page (dir1, dir2)
536     
537 ################################################################
538 # TESTING
539
540 def system (x):
541     
542     print 'invoking', x
543     stat = os.system (x)
544     assert stat == 0
545
546
547 def test_paired_files ():
548     print paired_files (os.environ["HOME"] + "/src/lilypond/scripts/",
549                         os.environ["HOME"] + "/src/lilypond-stable/buildscripts/", '*.py')
550                   
551     
552 def test_compare_trees ():
553     system ('rm -rf dir1 dir2')
554     system ('mkdir dir1 dir2')
555     system ('cp 20{-*.signature,.ly,.png} dir1')
556     system ('cp 20{-*.signature,.ly,.png} dir2')
557     system ('cp 20expr{-*.signature,.ly,.png} dir1')
558     system ('cp 19{-*.signature,.ly,.png} dir2/')
559     system ('cp 19{-*.signature,.ly,.png} dir1/')
560     system ('cp 20grob{-*.signature,.ly,.png} dir2/')
561     system ('cp 20grob{-*.signature,.ly,.png} dir1/')
562
563     ## introduce differences
564     system ('cp 19-1.signature dir2/20-1.signature')
565
566     ## radical diffs.
567     system ('cp 19-1.signature dir2/20grob-1.signature')
568     system ('cp 19-1.signature dir2/20grob-2.signature')
569
570     compare_trees ('dir1', 'dir2')
571
572
573 def test_basic_compare ():
574     ly_template = r"""#(set! toplevel-score-handler print-score-with-defaults)
575 #(set! toplevel-music-handler
576  (lambda (p m)
577  (if (not (eq? (ly:music-property m 'void) #t))
578     (print-score-with-defaults
579     p (scorify-music m p)))))
580
581 \sourcefilename "my-source.ly"
582
583 %(papermod)s
584 <<
585 \new Staff \relative c {
586   c4^"%(userstring)s" %(extragrob)s
587   }
588 \new Staff \relative c {
589   c4^"%(userstring)s" %(extragrob)s
590   }
591 >>
592 """
593
594     dicts = [{ 'papermod' : '',
595                'name' : '20',
596                'extragrob': '',
597                'userstring': 'test' },
598              { 'papermod' : '#(set-global-staff-size 19.5)',
599                'name' : '19',
600                'extragrob': '',
601                'userstring': 'test' },
602              { 'papermod' : '',
603                'name' : '20expr',
604                'extragrob': '',
605                'userstring': 'blabla' },
606              { 'papermod' : '',
607                'name' : '20grob',
608                'extragrob': 'r2. \\break c1',
609                'userstring': 'test' }
610
611              ]
612
613     for d in dicts:
614         open (d['name'] + '.ly','w').write (ly_template % d)
615         
616     names = [d['name'] for d in dicts]
617     
618     system ('lilypond -ddump-signatures --png -b eps ' + ' '.join (names))
619     
620     sigs = dict ((n, read_signature_file ('%s-1.signature' % n)) for n in names)
621     combinations = {}
622     for (n1, s1) in sigs.items():
623         for (n2, s2) in sigs.items():
624             combinations['%s-%s' % (n1, n2)] = SystemLink (s1,s2).distance ()
625
626     results = combinations.items ()
627     results.sort ()
628     for k,v in results:
629         print '%-20s' % k, v
630
631     assert combinations['20-20'] == (0.0,0.0,0.0)
632     assert combinations['20-20expr'][0] > 50.0
633     assert combinations['20-19'][2] < 10.0
634     assert combinations['20-19'][2] > 0.0
635
636
637 def test_sigs (a,b):
638     sa = read_signature_file (a)
639     sb = read_signature_file (b)
640     link = SystemLink (sa, sb)
641     print link.distance()
642
643
644 def run_tests ():
645     do_clean = 0
646     dir = 'output-distance-test'
647
648     print 'test results in ', dir
649     if do_clean:
650         system ('rm -rf ' + dir)
651         system ('mkdir ' + dir)
652         
653     os.chdir (dir)
654     if do_clean:
655         test_basic_compare ()
656         
657     test_compare_trees ()
658     
659 ################################################################
660 #
661
662 def main ():
663     p = optparse.OptionParser ("output-distance - compare LilyPond formatting runs")
664     p.usage = 'output-distance.py [options] tree1 tree2'
665     
666     p.add_option ('', '--test',
667                   dest="run_test",
668                   action="store_true",
669                   help='run test method')
670
671     (o,a) = p.parse_args ()
672
673     if o.run_test:
674         run_tests ()
675         sys.exit (0)
676
677     if len (a) != 2:
678         p.print_usage()
679         sys.exit (2)
680
681     compare_trees (a[0], a[1])
682
683 if __name__ == '__main__':
684     main()
685