Commit 2a8f24aa authored by njean42's avatar njean42
Browse files

Expand panels to fill more page space (page margins are preserved).

Kumiko CLI can now also open a webpage to show results.
Moved Panel and HTML classes to separate files.
JS reader now gets an image dir once and not for each file.
parent 1ffe5534
#!/usr/bin/env python
import sys, os, json
import argparse
import sys, os, json, argparse, subprocess
from kumikolib import Kumiko
from lib.html import HTML
parser = argparse.ArgumentParser(description='Kumiko CLI')
......@@ -19,6 +19,9 @@ parser.add_argument('-o', '--output', nargs=1, help='A file name to save json ou
# Configuration tweaks
parser.add_argument('--min-panel-size-ratio', nargs=1, type=float, help='Panels will be considered too small and exluded if they have a width < img.width * ratio or height < img/height * ratio (default is 1/15th)')
parser.add_argument('-b', '--browser', nargs=1, help='Opens given browser to view the result when ready', choices=['firefox','konqueror','chromium'])
args = parser.parse_args()
k = Kumiko({
......@@ -29,10 +32,12 @@ k = Kumiko({
file_or_folder = args.input[0]
folder = file_or_folder
if os.path.isdir(file_or_folder):
info = k.parse_dir(file_or_folder)
elif os.path.isfile(file_or_folder):
info = k.parse_images([file_or_folder])
folder = os.path.dirname(file_or_folder)
else:
print("--input (-i) is not a file or directory: '"+file_or_folder+"'")
sys.exit()
......@@ -45,3 +50,16 @@ if args.output:
f.close()
else:
print(info)
# generate html file for viewing the result
html_file = os.path.join('tests/results',os.path.basename(file_or_folder)+'.html')
fh = open(html_file, 'w')
fh.write(HTML.header(reldir='../../'))
fh.write(HTML.reader(info,os.path.relpath(folder,'tests/results')+'/'))
fh.write(HTML.footer)
fh.close()
if args.browser:
subprocess.run([args.browser[0],html_file])
......@@ -5,6 +5,8 @@ import os
import cv2 as cv
import numpy as np
from lib.panel import Panel
class Kumiko:
......@@ -17,8 +19,6 @@ class Kumiko:
self.options['debug_dir'] = 'debug_dir' in options and options['debug_dir']
self.options['progress'] = 'progress' in options and options['progress']
self.options['reldir'] = options['reldir'] if 'reldir' in options else os.getcwd()
self.options['min_panel_size_ratio'] = 1/15
if 'min_panel_size_ratio' in options and options['min_panel_size_ratio']:
self.options['min_panel_size_ratio'] = options['min_panel_size_ratio']
......@@ -99,6 +99,26 @@ class Kumiko:
return [poly1,poly2] # TODO: recurse?
def deoverlap_panels(self,panels):
for i in range(len(panels)):
for j in range(len(panels)):
if panels[i] == panels[j]: continue
opanel = panels[i].overlap_panel(panels[j])
if not opanel:
continue
if opanel.w < opanel.h and panels[i].r == opanel.r:
panels[i].r = opanel.x
panels[j].x = opanel.r
continue
if opanel.w > opanel.h and panels[i].b == opanel.b:
panels[i].b = opanel.y
panels[j].y = opanel.b
continue
# Merge every two panels where one contains the other
def merge_panels(self, panels):
panels_to_remove = []
for i in range(len(panels)):
......@@ -112,6 +132,49 @@ class Kumiko:
del panels[i]
# Find out actual gutters between panels
def actual_gutters(panels,func=min):
gutters_x = []
gutters_y = []
for p in panels:
left_panel = p.find_left_panel(panels)
if left_panel: gutters_x.append(p.x - left_panel.r)
top_panel = p.find_top_panel(panels)
if top_panel: gutters_y.append(p.y - top_panel.b)
if not gutters_x: gutters_x = [1]
if not gutters_y: gutters_y = [1]
return {
'x': func(gutters_x),
'y': func(gutters_y),
'r': -func(gutters_x),
'b': -func(gutters_y)
}
# Expand panels to their edges or page frame
def expand_panels(self, panels):
gutters = Kumiko.actual_gutters(panels)
for i in range(len(panels)):
for d in ['x','y','r','b']: # expand in all four directions
pcoords = {'x':panels[i].x, 'y':panels[i].y, 'r':panels[i].r, 'b':panels[i].b}
neighbour = panels[i].find_neighbour_panel(d,panels)
if neighbour:
# expand to that neighbour's edge (minus gutter)
newcoord = getattr(neighbour,{'x':'r','r':'x','y':'b','b':'y'}[d]) + gutters[d]
else:
# expand to the furthest known edge (frame around all panels)
min_panel = min(panels,key=lambda p: getattr(p,d)) if d in ['x','y'] else max(panels,key=lambda p: getattr(p,d))
newcoord = getattr(min_panel,d)
if d in ['r','b'] and newcoord > pcoords[d] or d in ['x','y'] and newcoord < pcoords[d]:
pcoords[d] = newcoord
panels[i] = Panel([pcoords['x'], pcoords['y'], pcoords['r']-pcoords['x'], pcoords['b']-pcoords['y']], self.gutterThreshold)
def getGutterThreshold(size):
return sum(size) / 2 / 20
......@@ -124,7 +187,7 @@ class Kumiko:
size.reverse() # get a [width,height] list
infos = {
'filename': os.path.relpath(filename,self.options['reldir']),
'filename': os.path.basename(filename),
'size': size,
'panels': []
}
......@@ -149,7 +212,9 @@ class Kumiko:
# See if this panel can be cut into several (two non-consecutive points are close)
polygons = self.split_polygon(approx)
i = -1
for p in polygons:
i += 1
x,y,w,h = cv.boundingRect(p)
# exclude very small panels
......@@ -158,14 +223,23 @@ class Kumiko:
if self.options['debug_dir']:
contourSize = int(sum(infos['size']) / 2 * 0.004)
cv.drawContours(img, [p], 0, (0,0,255), contourSize)
if len(polygons) == 1:
cv.drawContours(img, [p], 0, (0,0,255), contourSize)
else:
cv.drawContours(img, [p], 0, [(0,255,0),(255,0,0)][i], contourSize)
panel = Panel([x,y,w,h], self.gutterThreshold)
infos['panels'].append(panel)
# Merge every two panels where one contains the other
# merge panels that shouldn't have been split (speech bubble diving in a panel)
self.merge_panels(infos['panels'])
# cutting polygons may result in panels slightly overlapping, de-overlap them
self.deoverlap_panels(infos['panels'])
infos['panels'].sort() # TODO: remove
self.expand_panels(infos['panels'])
if len(infos['panels']) == 0:
infos['panels'].append( Panel([0,0,infos['size'][0],infos['size'][1]], self.gutterThreshold) );
......@@ -173,76 +247,10 @@ class Kumiko:
infos['panels'].sort()
# Simplify panels back to lists (x,y,w,h)
infos['panels'] = list(map(lambda p: p.toarray(), infos['panels']))
infos['panels'] = list(map(lambda p: p.to_xywh(), infos['panels']))
# write panel numbers on debug image
if (self.options['debug_dir']):
cv.imwrite(os.path.join(self.options['debug_dir'],os.path.basename(filename)+'-contours.jpg'),img)
return infos
class Panel:
def __init__(self, xywh, gutterThreshold):
[self.x, self.y, self.w, self.h] = xywh
self.b = self.y + self.h # panel's bottom
self.r = self.x + self.w # panel's right side
self.gutterThreshold = gutterThreshold
def toarray(self):
return [self.x, self.y, self.w, self.h]
def __eq__(self, other):
return abs(self.x-other.x) < self.gutterThreshold and abs(self.y-other.y) < self.gutterThreshold and abs(self.r-other.r) < self.gutterThreshold and abs(self.b-other.b) < self.gutterThreshold
def __lt__(self, other):
# panel is above other
if other.y >= self.b - self.gutterThreshold and other.y >= self.y - self.gutterThreshold:
return True
# panel is below other
if self.y >= other.b - self.gutterThreshold and self.y >= other.y - self.gutterThreshold:
return False
# panel is left from other
if other.x >= self.r - self.gutterThreshold and other.x >= self.x - self.gutterThreshold:
return True
# panel is right from other
if self.x >= other.r - self.gutterThreshold and self.x >= other.x - self.gutterThreshold:
return False
return True # should not happen, TODO: raise an exception?
def __le__(self, other):
return self.__lt__(other)
def __gt__(self, other):
return not self.__lt__(other)
def __ge__(self, other):
return self.__gt__(other)
def area(self):
return self.w * self.h
def __str__(self):
return "[[{0},{1}],[{2},{3}],[{4},{5}],[{6},{7}]]".format(self.x,self.y, self.r,self.y, self.r,self.b, self.x,self.b)
# returns an overlapping percentage
def contains(self,other):
if self.x > other.r or other.x > self.r: # panels are left and right from one another
return 0
if self.y > other.b or other.y > self.b: # panels are above and below one another
return 0
# if we're here, panels overlap at least a bit
x = max(self.x,other.x)
y = max(self.y,other.y)
r = min(self.r,other.r)
b = min(self.b,other.b)
overlap_panel = Panel([x,y,r-x,b-y], self.gutterThreshold)
# panels overlap if the overlapping area is more than 75% of the smallest one's area
return overlap_panel.area() / other.area() > 0.75
import json
from lib.panel import Panel
class HTML:
def header(title='',reldir=''):
return """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script type="text/javascript" src="{reldir}jquery-3.2.1.min.js"></script>
<script type="text/javascript" src="{reldir}reader.js"></script>
<link rel="stylesheet" media="all" href="{reldir}style.css" />
<style type="text/css">
h2 {{ text-align: center; }}
.sidebyside {{ display: flex; justify-content: space-around; }}
.sidebyside > div {{ width: 45%; }}
.version {{ text-align: center; }}
.kumiko-reader {{ height: 90vh; }}
.kumiko-reader.fullpage {{ height: 100%; width: 100%; }}
</style>
</head>
<body>
<h1>{title}</h1>
""".format(title=title,reldir=reldir)
def nbdiffs(files_diff):
return "<p>{0} differences found in files</p>".format(len(files_diff))
pageId = 0
def side_by_side_panels(img,jsons,v1,v2,images_dir):
html = '<h2>{0}</h2><div class="sidebyside"><div class="version">{1}</div><div class="version">{2}</div></div><div class="sidebyside">'.format(img,v1,v2)
oneside = """
<div id="page{id}" class="kumiko-reader debug"></div>
<script type="text/javascript">
var reader = new Reader({{
container: $('#page{id}'),
comicsJson: {json},
images_dir: '{images_dir}'
}});
reader.loadPage(0);
</script>
"""
for js in jsons:
html += oneside.format(id=HTML.pageId,json=js,images_dir=images_dir)
HTML.pageId += 1
html += '</div>'
return html
def reader(js,images_dir):
page = json.loads(js)
ol = '<ol>'
for p in page[0]['panels']:
ol += '<li>' + str(Panel(p,0)) + '</li>'
ol += '</ol>'
return """
<div style="display: flex;">
<div id="reader" class="kumiko-reader debug"></div>
{ol}
</div>
<script type="text/javascript">
var reader = new Reader({{
container: $('#reader'),
comicsJson: {json},
images_dir: '{images_dir}'
}});
reader.loadPage(0);
</script>
""".format(json=js,images_dir=images_dir,ol=ol)
footer = """
</body>
</html>
"""
import sys
class Panel:
def __init__(self, xywh, gutterThreshold):
for d in ['x','y','r','b']: super().__setattr__(d,0) # dummy init so that all four edges have a value (see __setattr__)
self.x = xywh[0] # panel's left edge
self.y = xywh[1] # panel's top edge
self.r = self.x + xywh[2] # panel's right edge
self.b = self.y + xywh[3] # panel's bottom edge
self.gutterThreshold = gutterThreshold
def to_xywh(self):
return [self.x, self.y, self.w, self.h]
def __eq__(self, other):
return \
abs(self.x-other.x) < self.gutterThreshold and \
abs(self.y-other.y) < self.gutterThreshold and \
abs(self.r-other.r) < self.gutterThreshold and \
abs(self.b-other.b) < self.gutterThreshold
def __lt__(self, other):
# panel is above other
if other.y >= self.b - self.gutterThreshold and other.y >= self.y - self.gutterThreshold:
return True
# panel is below other
if self.y >= other.b - self.gutterThreshold and self.y >= other.y - self.gutterThreshold:
return False
# panel is left from other
if other.x >= self.r - self.gutterThreshold and other.x >= self.x - self.gutterThreshold:
return True
# panel is right from other
if self.x >= other.r - self.gutterThreshold and self.x >= other.x - self.gutterThreshold:
return False
return True # should not happen, TODO: raise an exception?
def __le__(self, other): return self.__lt__(other)
def __gt__(self, other): return not self.__lt__(other)
def __ge__(self, other): return self.__gt__(other)
def area(self): return self.w * self.h
def __str__(self): return "[left: {}, right: {}, top: {}, bottom: {} ({}x{})]".format(self.x,self.r,self.y,self.b,self.w,self.h)
def __setattr__(self,name,value):
if name in ['w','h']:
print("Fatal error, can't set a panel's width or height")
sys.exit(1)
super().__setattr__(name, value)
# update width and height
if name in ['x','y','r','b']:
super().__setattr__('w',self.r - self.x)
super().__setattr__('h',self.b - self.y)
def overlap_panel(self,other):
if self.x > other.r or other.x > self.r: # panels are left and right from one another
return None
if self.y > other.b or other.y > self.b: # panels are above and below one another
return None
# if we're here, panels overlap at least a bit
x = max(self.x,other.x)
y = max(self.y,other.y)
r = min(self.r,other.r)
b = min(self.b,other.b)
return Panel([x,y,r-x,b-y], self.gutterThreshold)
def contains(self,other):
o_panel = self.overlap_panel(other)
if not o_panel:
return False
# self contains other if their overlapping area is more than 75% of other's area
return o_panel.area() / other.area() > 0.75
def same_row(self,other): return other.y <= self.y <= other.b or self.y <= other.y <= self.b
def same_col(self,other): return other.x <= self.x <= other.r or self.x <= other.x <= self.r
def find_top_panel(self,panels):
all_top = list(filter(lambda p: p.y < self.y and p.same_col(self), panels))
return max(all_top, key=lambda p: p.b) if all_top else None
def find_left_panel(self,panels):
all_left = list(filter(lambda p: p.x < self.x and p.same_row(self), panels))
if self.x == 981:
print('panel5.neighbour',list(map(lambda p: str(p),all_left)))
return max(all_left, key=lambda p: p.r) if all_left else None
def find_bottom_panel(self,panels):
all_bottom = list(filter(lambda p: p.y > self.y and p.same_col(self), panels))
return min(all_bottom, key=lambda p: p.y) if all_bottom else None
def find_right_panel(self,panels):
all_right = list(filter(lambda p: p.x > self.x and p.same_row(self), panels))
return min(all_right, key=lambda p: p.x) if all_right else None
def find_neighbour_panel(self,d,panels):
return {
'x': self.find_left_panel,
'y': self.find_top_panel,
'r': self.find_right_panel,
'b': self.find_bottom_panel,
}[d](panels)
......@@ -12,10 +12,12 @@ class Reader {
console.error('no container given in options');
return;
}
if (!options.imageURLs) {
console.error('no imageURLs given in options');
if (!options.images_dir) {
console.error('no images_dir given in options');
return;
}
this.images_dir = options.images_dir
if (options.comicsJson)
{
......@@ -25,8 +27,6 @@ class Reader {
else
this.comicsPath = options.comicsPath;
this.imageURLs = options.imageURLs;
// add image sub-container
this.container = $('<div class="container"/>');
this.container.css({
......@@ -105,8 +105,9 @@ class Reader {
var page = this.currpage[this.comicsPath];
var imginfo = this.comic[page];
var imgurl = this.images_dir + imginfo.filename.split('/').reverse()[0];
var img = $('<img class="pageimg" src="'+this.imageURLs[page]+'"/>');
var img = $('<img class="pageimg" src="'+imgurl+'"/>');
img.css({
position: 'absolute',
'width': '100%',
......
......@@ -60,7 +60,8 @@ input[name=panelview]:not(:checked) + span + span {
margin-left: -2px;
margin-top: -2px;
}
.kumiko-reader.debug .container:not(.zoomed) .panel:hover {
.kumiko-reader.debug .container:not(.zoomed) .panel:hover,
.kumiko-reader.debug .container:not(.zoomed) .panel:active {
background-color: rgba(52,152,219,0.5);
}
.kumiko-reader:not(.debug) .container .panel *,
......@@ -68,10 +69,10 @@ input[name=panelview]:not(:checked) + span + span {
display: none;
}
.kumiko-reader.debug .container:not(.zoomed) .panel span {
color: white;
color: red;
font-size: 75%;
background-color: black;
opacity: 0.8;
background-color: white;
opacity: 0.9;
border-radius: 30px;
padding: 2px 6px;
}
......
......@@ -3,6 +3,8 @@
import os, subprocess, json, argparse
from kumikolib import Kumiko,Panel
from lib.html import HTML
class Tester:
......@@ -10,7 +12,9 @@ class Tester:
files = []
git_repo = 'https://framagit.org/nicooo/kumiko'
git_versions = [
'v1.1',
#'v1.0',
#'v1.1',
'v1.11',
'current',
]
......@@ -83,11 +87,11 @@ class Tester:
for p in range(len(json1)): # for each page
# check both images' filename and size, should be the same
if json1[p]['filename'] != json2[p]['filename']:
if os.path.basename(json1[p]['filename']) != os.path.basename(json2[p]['filename']):
print('error, filenames are not the same',json1[p]['filename'],json2[p]['filename'])
continue
if json1[p]['size'] != json2[p]['size']:
print('error, filenames are not the same',json1[p]['size'],json2[p]['size'])
print('error, image sizes are not the same',json1[p]['size'],json2[p]['size'])
continue
is_diff = False
......@@ -102,86 +106,36 @@ class Tester:
is_diff = True
break
images_dir = f if os.path.isdir(f) else os.path.dirname(f)
images_dir = os.path.relpath(images_dir,'tests/results')+'/'
if is_diff:
files_diff[json1[p]['filename']] = [[json1[p]],[json2[p]]]
files_diff[json1[p]['filename']] = {
'jsons': [json.dumps([json1[p]]),json.dumps([json2[p]])],
'images_dir': images_dir
}
html_diff_file = os.path.join(self.savedir,'diff-'+v1+'-'+v2+'.html')
diff_file = open(html_diff_file, 'w')
diff_file.write(HTML.header)
diff_file.write(HTML.header('Comparing Kumiko results','../../'))
print('Found',len(files_diff),'differences')
diff_file.write(HTML.nbdiffs(files_diff))
for img in files_diff:
diff_file.write(HTML.side_by_side_panels(img,files_diff[img],v1,v2))
diff_file.write(HTML.side_by_side_panels(img,files_diff[img]['jsons'],v1,v2,images_dir=files_diff[img]['images_dir']))
diff_file.write(HTML.footer)
diff_file.close()
if self.options['browser']:
subprocess.run([self.options['browser'],html_diff_file])
class HTML:
header = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script type="text/javascript" src="../../jquery-3.2.1.min.js"></script>
<script type="text/javascript" src="../../reader.js"></script>
<link rel="stylesheet" media="all" href="../../style.css" />
<style type="text/css">
h2 { text-align: center; }
.sidebyside { display: flex; justify-content: space-around; }
.sidebyside > div { width: 45%; }