Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Line detection PR #2 follow-up. #8

Merged
merged 13 commits into from
Nov 2, 2014
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions AUTHORS
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Dmitry Chaplinsky <[email protected]>
Fedir Nepyivoda <[email protected]>
Ievgen Varavva <[email protected]>
Konst Kolesnichenko <[email protected]>
3 changes: 2 additions & 1 deletion unshred/features/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
from .geometry import *
from .colours import *
from .geometry import *
from .lines import *
165 changes: 165 additions & 0 deletions unshred/features/lines.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import math
import cmath
import cv2
import numpy

from unshred import threshold
from unshred.features import AbstractShredFeature


DEBUG = False


MEAN, MEDIAN = range(2)


def _get_dominant_angle(lines, domination_type=MEDIAN):
"""Picks dominant angle of a set of lines.

Args:
lines: iterable of (x1, y1, x2, y2) tuples that define lines.
domination_type: either MEDIAN or MEAN.

Returns:
Dominant angle value in radians.

Raises:
ValueError: on unknown domination_type.
"""
if domination_type == MEDIAN:
return _get_median_angle(lines)
elif domination_type == MEAN:
return _get_mean_angle(lines)
else:
raise ValueError('Unknown domination type provided: %s' % (
domination_type))


def _normalize_angle(angle, range, step):
"""Finds an angle that matches the given one modulo step.

Increments and decrements the given value with a given step.

Args:
range: a 2-tuple of min and max target values.
step: tuning step.

Returns:
Normalized value within a given range.
"""
while angle <= range[0]:
angle += step
while angle >= range[1]:
angle -= step
return angle


def _get_mean_angle(lines):
unit_vectors = []
for x1, y1, x2, y2 in lines:
c = complex(x2, -y2) - complex(x1, -y1)
unit = c / abs(c)
unit_vectors.append(unit)

avg_angle = cmath.phase(numpy.average(unit_vectors))

return _normalize_angle(avg_angle, [-math.pi / 2, math.pi / 2], math.pi)


def _get_median_angle(lines):
angles = []
for x1, y1, x2, y2 in lines:
c = complex(x2, -y2) - complex(x1, -y1)
angle = cmath.phase(c)
angles.append(angle)

# Not np.median to avoid averaging middle elements.
median_angle = numpy.percentile(angles, .5)

return _normalize_angle(median_angle, [-math.pi / 2, math.pi / 2], math.pi)


class LinesFeatures(AbstractShredFeature):
"""Feature detector that recognizes lines.

If the lines are detected, tag "Has Lines" is set and "lines_angle" feature
is set to the value of best guess of lines angle in radians in range of
[-pi/2; pi/2].
"""
TAG_HAS_LINES_FEATURE = "Has Lines"

def get_info(self, shred, contour, name):
tags = []
params = {}

_, _, _, mask = cv2.split(shred)
#
# # expanding mask for future removal of a border
kernel = numpy.ones((5, 5), numpy.uint8)
if DEBUG:
cv2.imwrite('../debug/%s_mask_0.png' % name, mask)
mask = cv2.morphologyEx(mask, cv2.MORPH_ERODE, kernel, iterations=2)
if DEBUG:
cv2.imwrite('../debug/%s_mask_1.png' % name, mask)
_, mask = cv2.threshold(mask, 240, 0, cv2.THRESH_TOZERO)
if DEBUG:
cv2.imwrite('../debug/%s_mask_2.png' % name, mask)

# TODO: move thresholding to Shred class, to allow reusing from other
# feature detectors.
edges = 255 - threshold.threshold(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems that it's better to apply this smart-ass thresholding to each shred in split.py and then pass it to feature detectors as we'll reuse it for sure.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some feature detectors might prefer non-thresholded images.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, but I'm just saying that we should provide both to the feature detector.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds like a method to memoize in Shred() class. Adding a TODO.

shred, min(shred.shape[:2])).astype(numpy.uint8)
edges = edges & mask

if DEBUG:
cv2.imwrite('../debug/%s_asrc.png' % name, shred)
cv2.imwrite('../debug/%s_edges.png' % name, edges)
cv2.imwrite('../debug/%s_mask.png' % name, mask)

_, _, r_w, r_h = cv2.boundingRect(contour)

# Line len should be at least 30% of shred's width, gap - 20%
lines = cv2.HoughLinesP(edges, rho=10, theta=numpy.pi / 180 * 2,
threshold=30, maxLineGap=r_w * 0.2,
minLineLength=max([r_h, r_w]) * 0.3
)

if lines is not None:
lines = lines[0]
tags.append(self.TAG_HAS_LINES_FEATURE)

dominant_angle = _get_dominant_angle(lines)

if DEBUG:
dbg = cv2.cvtColor(edges, cv2.cv.CV_GRAY2BGRA)
# Draw detected lines in green.
for x1, y1, x2, y2 in lines:
cv2.line(dbg, (x1, y1), (x2, y2), (0, 255, 0, 255), 1)

approaches = [
((0, 0, 255, 255), _get_dominant_angle(lines, MEAN)),
((255, 0, 0, 255), _get_dominant_angle(lines, MEDIAN)),
]

print [a[1] for a in approaches]

# Draws lines originating from the middle of left border with
# computed slopes: MEAN in red, MEDIAN in blue.
for color, angle in approaches:
def y(x0, x):
return max(-2**15,
min(2**15,
int(x0 - math.tan(angle) * x)))

x0 = shred.shape[0]/2
x1 = 0
y1 = y(x0, x1)
x2 = shred.shape[1]
y2 = y(x0, x2)
cv2.line(dbg, (x1, y1), (x2, y2), color, 1)

dbg = numpy.concatenate([shred, dbg], 1)
cv2.imwrite('../debug/%s_houghlines.png' % name, dbg)
params['lines_angle'] = dominant_angle

return params, tags
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should try kind of adaptive filtration to filter out false positives.

For example (very stupid one, just out of the top of my head), we might use only lines that longer than 50% of a longest line found.

Another fruitful idea here is to build histogram for angles (say, with bucket of 3-5 degrees), find the biggest peak and filter out everything except lines of this angle it and their neighbors + the same angle + 90 degrees

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Many false positives come from "wide" lines on the image. This gives a number of related detected lines, all within a marginal slope and intercept.

Histogram of angles would likely work nice. Another approach might be using algorithm similar to RANSAC, to come up with a consensus on the lines angle and pick only matching ones (+-90º)

5 changes: 3 additions & 2 deletions unshred/split.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from jinja2 import FileSystemLoader, Environment
import numpy as np

from features import GeometryFeatures, ColourFeatures
from features import GeometryFeatures, ColourFeatures, LinesFeatures
from sheet import Sheet


Expand Down Expand Up @@ -155,7 +155,8 @@ def get_shreds(self):
sheet_name = os.path.splitext(os.path.basename(fname))[0]

print("Processing file %s" % fname)
sheet = SheetIO(fname, sheet_name, [GeometryFeatures, ColourFeatures],
sheet = SheetIO(fname, sheet_name,
[GeometryFeatures, ColourFeatures, LinesFeatures],
out_dir, out_format)

sheet.export_results_as_html()
Expand Down
115 changes: 115 additions & 0 deletions unshred/threshold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
"""Fancy adaptive threshlding.

Code adapted from
http://stackoverflow.com/questions/22122309/opencv-adaptive-threshold-ocr.

As I understand it:
1. Reduces an image to a smaller one, where each
DEFAULT_BLOCKSIZExDEFAULT_BLOCKSIZE block -> one pixel.
2. Creates a mask of small_image size, where pixels corresponding to
high-variance (non-background) blocks get value > 0, others (background
blocks) get 0.
3. Small image is inpainted using mask from step 2. So non-bg blocks are
inpainted by surrounding bg blocks.
4. Image is resize back to original size, resulting in what looks like just bg
from original image.
5. Bg image is subtracted from original and result thresholded.

The algorith assumes dark foreground on light background.

"""
import cv2
import numpy as np

DEFAULT_BLOCKSIZE = 40

# Blocks with variance over this value are assumed to contain foreground.
MEAN_VARIANCE_THRESHOLD = 0.01


def _calc_block_mean_variance(image, mask, blocksize):
"""Adaptively determines image background.

Args:
image: image converted 1-channel image.
mask: 1-channel mask, same size as image.
blocksize: adaptive algorithm parameter.

Returns:
image of same size as input with foreground inpainted with background.
"""
I = image.copy()
I_f = I.astype(np.float32) / 255. # Used for mean and std.

result = np.zeros(
(image.shape[0] / blocksize, image.shape[1] / blocksize),
dtype=np.float32)

for i in xrange(0, image.shape[0] - blocksize, blocksize):
for j in xrange(0, image.shape[1] - blocksize, blocksize):

patch = I_f[i:i+blocksize+1, j:j+blocksize+1]
mask_patch = mask[i:i+blocksize+1, j:j+blocksize+1]

tmp1 = np.zeros((blocksize, blocksize))
tmp2 = np.zeros((blocksize, blocksize))
mean, std_dev = cv2.meanStdDev(patch, tmp1, tmp2, mask_patch)

value = 0
if std_dev[0][0] > MEAN_VARIANCE_THRESHOLD:
value = mean[0][0]

result[i/blocksize, j/blocksize] = value

small_image = cv2.resize(I, (image.shape[1] / blocksize,
image.shape[0] / blocksize))

res, inpaintmask = cv2.threshold(result, 0.02, 1, cv2.THRESH_BINARY)

inpainted = cv2.inpaint(small_image, inpaintmask.astype(np.uint8), 5,
cv2.INPAINT_TELEA)

res = cv2.resize(inpainted, (image.shape[1], image.shape[0]))

return res


def threshold(image, block_size=DEFAULT_BLOCKSIZE, mask=None):
"""Applies adaptive thresholding to the given image.

Args:
image: BGRA image.
block_size: optional int block_size to use for adaptive thresholding.
mask: optional mask.
Returns:
Thresholded image.
"""
if mask is None:
mask = np.zeros(image.shape[:2], dtype=np.uint8)
mask[:] = 255

if len(image.shape) > 2 and image.shape[2] == 4:
image = cv2.cvtColor(image, cv2.COLOR_BGRA2GRAY)
res = _calc_block_mean_variance(image, mask, block_size)
res = image.astype(np.float32) - res.astype(np.float32) + 255
_, res = cv2.threshold(res, 215, 255, cv2.THRESH_BINARY)
return res


if __name__ == '__main__':
import argparse

parser = argparse.ArgumentParser()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whatta hell is going on here? We are using both argparse and sys.argv? In any case, I think import argparse and import sys should go under ifmain.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merge fail.

parser.add_argument('input', type=str, help='Input file name.',
nargs='?', default="11.jpg")
parser.add_argument('output', type=str, help='Output file name.',
nargs='?', default="out.png")

args = parser.parse_args()

fname = args.input
outfile = args.output

image = cv2.imread(fname, cv2.CV_LOAD_IMAGE_UNCHANGED)
result = threshold(image)
cv2.imwrite(outfile, result * 255)