Major decoding algorithm alteration, working for all e2e tests

Find bounds using angles rather than skew.

Get data along linear axis rather than looking for dot starts.

Fixed unit tests, added testing all around.
This commit is contained in:
Justin Bass 2018-10-29 23:34:37 -07:00
parent 4f8b9e1f94
commit 1d09fac9ff
14 changed files with 1237 additions and 787 deletions

View File

@ -21,7 +21,8 @@ def encode(args):
args.printerDpi,
args.outPath,
args.saveImages,
args.noPdf)
args.noPdf
)
def decode(args):
@ -29,7 +30,9 @@ def decode(args):
args.filenames,
args.colorDepth,
args.outfile,
args.metadataFile)
args.metadataFile,
"." if args.debug else None # Save in current directory if debug option is specified
)
def main():
@ -88,6 +91,8 @@ def main():
help='Output filename of data file')
decoder_parser.add_argument('--metadataFile', type=str, default="",
help='Metadata filename')
decoder_parser.add_argument('--debug', action='store_true', default=False,
help='Debug output')
decoder_parser.set_defaults(func=decode)

View File

@ -34,4 +34,4 @@ TotalPagesMaxBytes = 8 # 8 bytes per page maximum for the total-pages field
MaxSkew = 5
MaxSkewPerc = 0.002
DefaultThresholdWeight = 0.5
DefaultThresholdWeight = 0.5

View File

@ -4,7 +4,7 @@ import os
from PIL import ImageDraw, Image
def drawPage(page, tmpdir, filename, pixels=None, lines=None, color=(0, 0, 0)):
def draw_page(page, tmpdir, filename, pixels=None, lines=None, color=(255, 0, 0)):
"""
Draw page with additional pixels and lines for debugging purposes
:param page: InputPage type
@ -20,14 +20,17 @@ def drawPage(page, tmpdir, filename, pixels=None, lines=None, color=(0, 0, 0)):
# Draw image
for y in range(page.height):
for x in range(page.width):
pixel = page.getPixel(int(y), int(x))
pixel = page.get_pixel(int(y), int(x))
pixel = tuple(map(operator.mul, pixel, (255,) * 3))
pixel = tuple(map(int, pixel))
image_pixels[int(x), int(y)] = pixel
if pixels:
for y, x in pixels:
image_pixels[x, y] = color
for pixel in pixels:
if pixel:
y, x = pixel
if page.width > x >= 0 and page.height > y >= 0:
image_pixels[x, y] = color
if lines:
draw = ImageDraw.Draw(image)

View File

@ -1,30 +1,8 @@
from colorsafe.csdatastructures import constants, DotByte, DotRow, Sector, Page, ColorSafeFile, MetadataSector, ColorChannels
from colorsafe.csdatastructures import constants, DotByte, DotRow, Sector, Page, ColorSafeFile, MetadataSector
from colorsafe.csdatastructures import Dot
from colorsafe.utils import floatToBinaryList, intToBinaryList
class InputPages:
def __init__(self, totalPages, height, width):
self.totalPages = totalPages
self.height = height
self.width = width
def getPagePixel(self, page, y, x):
"""For caller to implement"""
pass
class InputPage:
def __init__(self, pages, pageNum):
self.pages = pages
self.height = pages.height
self.width = pages.width
self.pageNum = pageNum
def getPixel(self, y, x):
return self.pages.getPagePixel(self.pageNum, y, x)
class DotDecoder(Dot):
"""A group of channels representing a group of colorDepth bits.
@ -33,7 +11,7 @@ class DotDecoder(Dot):
TODO: Extend to multiple shades
"""
def __init__(self, channels, colorDepth, thresholdWeight=0.0):
def __init__(self, channels, colorDepth, thresholdWeight):
"""Takes in a list of channels, returns a list of bytes
"""
channelNum = self.getChannelNum(colorDepth)
@ -68,9 +46,6 @@ class DotDecoder(Dot):
val = channels.getAverageShade()
bitList = [int(val > thresholdWeight)]
# val = max(0.0, val - thresholdWeight)
# bitList = floatToBinaryList(val, colorDepth)
return bitList
def decodeSecondaryMode(self, channels, colorDepth):
@ -110,7 +85,7 @@ class DotByteDecoder(DotByte):
"""A group of 8 Dots, representing colorDepth bytes of data.
"""
def __init__(self, channelsList, colorDepth, thresholdWeight=0.0):
def __init__(self, channelsList, colorDepth, thresholdWeight):
"""Takes in a list of exactly ByteSize (8) channels, returns a list of decoded bytes.
Sets each dot's decoded data into colorDepth bytes.
@ -142,7 +117,7 @@ class DotRowDecoder(DotRow):
colorDepth,
width,
rowNumber,
thresholdWeight=0.0,
thresholdWeight,
xorRow=True):
"""Takes in a list of width channels, returns a list of decoded bytes
"""

View File

@ -0,0 +1,610 @@
import itertools
import operator
import os
import sys
from copy import copy
from colorsafe.debugutils import draw_page
from colorsafe import utils, defaults, exceptions
def get_data_bounds(page, sector_height, sector_width, gap_size, tmpdir):
if tmpdir:
tmpdir_bounds = os.path.join(str(tmpdir), "bounds")
os.mkdir(tmpdir_bounds)
tmpdir = tmpdir_bounds
bounds = get_bounds(page, tmpdir)
data_bounds = list()
debug_data_bounds = list()
for top_temp, bottom_temp, left_temp, right_temp in bounds:
height_per_dot = float(bottom_temp - top_temp + 1) / (sector_height + 2 * gap_size)
width_per_dot = float(right_temp - left_temp + 1) / (sector_width + 2 * gap_size)
if height_per_dot < 1.0 or width_per_dot < 1.0:
raise exceptions.DecodingError("Image has less than 1.0x resolution, cannot get all dots.")
top, bottom, left, right = get_real_sector_data_boundaries(page,
height_per_dot,
width_per_dot,
top_temp,
bottom_temp,
left_temp,
right_temp)
data_bounds.append((top, bottom, left, right))
if tmpdir:
debug_data_bounds.extend([(top, left), (top, right), (bottom, left), (bottom, right)])
if tmpdir:
draw_page(page, tmpdir, "data_bounds", tuple(debug_data_bounds), None)
return data_bounds
def get_bounds(page, tmpdir):
# Calculate vertical bounds first - more accurate since data typically extends the entire width of a page
page_y_begin = find_beginning_or_ending(page, True, False)
page_y_end = find_beginning_or_ending(page, True, True)
page_x_begin = find_beginning_or_ending(page, False, False, page_y_begin, page_y_end)
page_x_end = find_beginning_or_ending(page, False, True, page_y_begin, page_y_end)
page_y_length = page_y_end - page_y_begin
page_x_length = page_x_end - page_x_begin
# TODO: Come up a better heuristic
vertical_page_subdivisions = 4 + max(page_y_length - 128, 0) / 128
horizontal_page_subdivisions = 4 + max(page_x_length - 128, 0) / 128
horizontal_sub_borders_all = list()
for y_sub in range(vertical_page_subdivisions):
y_min = int(page_y_length * float(y_sub) / vertical_page_subdivisions) + page_y_begin
y_max = int(page_y_length * float(y_sub + 1) / vertical_page_subdivisions) + page_y_begin
x_min = page_x_begin
x_max = page_x_end
subset_page_bounds = (x_min, x_max, y_min, y_max)
horizontal_sub_borders = find_border_points_subset_page(page, subset_page_bounds, False)
horizontal_sub_borders_all.append(horizontal_sub_borders)
vertical_sub_borders_all = list()
for x_sub in range(horizontal_page_subdivisions):
x_min = int(page_x_length * float(x_sub) / horizontal_page_subdivisions) + page_x_begin
x_max = int(page_x_length * float(x_sub + 1) / horizontal_page_subdivisions) + page_x_begin
y_min = page_y_begin
y_max = page_y_end
subset_page_bounds = (y_min, y_max, x_min, x_max)
vertical_sub_borders = find_border_points_subset_page(page, subset_page_bounds, True)
vertical_sub_borders_all.append(vertical_sub_borders)
clean_vertical_borders = clean_and_infer_borders(vertical_sub_borders_all, False)
clean_horizontal_borders = clean_and_infer_borders(horizontal_sub_borders_all, True)
# Transpose lists so each borders points are within 1 list, not spread across all lists
# NOTE: Transposing turns the vertical sub-borders into a list of horizontal lines, and vice-versa
# TODO: Need to either tranpose by matching like values, or else work on inferring missing borders above
# TODO: Left off here
horizontal_borders = transpose_and_infer(clean_vertical_borders, True)
vertical_borders = transpose_and_infer(clean_horizontal_borders, False)
horizontal_border_angles_lines = infer_border_angled_lines(horizontal_borders, False)
vertical_border_angled_lines = infer_border_angled_lines(vertical_borders, True)
intersections = get_intersections(vertical_border_angled_lines, horizontal_border_angles_lines)
bounds = list()
for top_left, top_right, bottom_left, bottom_right in intersections:
# TODO: These seem mixed up, but it works...
left = utils.average([top_left[0], top_right[0]])
right = utils.average([bottom_left[0], bottom_right[0]])
top = utils.average([top_left[1], bottom_left[1]])
bottom = utils.average([top_right[1], bottom_right[1]])
bounds.append((top, bottom, left, right))
if tmpdir:
dots = list()
for h in horizontal_borders:
dots.extend(h)
draw_page(page, tmpdir, "horizontal_borders", tuple(dots), None)
dots = list()
for v in vertical_borders:
dots.extend(v)
draw_page(page, tmpdir, "vertical_borders", tuple(dots), None)
f = open(os.path.join(tmpdir, "get_bounds_data.txt"), "w")
f.write("page_y_begin " + str(page_y_begin))
f.write("\rpage_y_end " + str(page_y_end))
f.write("\rpage_x_begin " + str(page_x_begin))
f.write("\rpage_x_end " + str(page_x_end))
f.write("\r\rhorizontal_sub_borders_all " + str(horizontal_sub_borders_all))
f.write("\rvertical_sub_borders_all " + str(vertical_sub_borders_all))
f.write("\r\rclean_vertical_borders " + str(clean_vertical_borders))
f.write("\rclean_horizontal_borders " + str(clean_horizontal_borders))
f.write("\r\rvertical_borders " + str(vertical_borders))
f.write("\rhorizontal_borders " + str(horizontal_borders))
f.write("\r\rhorizontal_border_angles_lines " + str(horizontal_border_angles_lines))
f.write("\rvertical_border_angled_lines " + str(vertical_border_angled_lines))
f.write("\r\rintersections " + str(intersections))
f.write("\r\rbounds " + str(bounds))
f.close()
border_coordinates = list()
for slope, intercept in horizontal_border_angles_lines:
y1 = intercept
y2 = intercept + slope*page.width
border_coordinates.append((y1, 0, y2, page.width-1))
for slope, intercept in vertical_border_angled_lines:
x1 = intercept
x2 = intercept + slope*page.height
border_coordinates.append((0, x1, page.height-1, x2))
draw_page(page, tmpdir, "border_lines", None, tuple(border_coordinates))
if tmpdir:
converted_bounds = list()
for y1, y2, x1, x2 in bounds:
converted_bounds.extend([(y1, x1), (y1, x2), (y2, x1), (y2, x2)])
draw_page(page, tmpdir, "bounds", tuple(converted_bounds), None)
return bounds
def find_beginning_or_ending(page, vertical, reverse, perp_min_override=None, perp_max_override=None):
"""
:param page: Page to find beginning (or ending) of
:param vertical: True if vertical beginning (or ending), False if horizontal
:param reverse: True if beginning, False if ending
:return: Coordinate of beginning (or ending)
"""
low_border_threshold = 0.80
max_skew = 3
along_min = 0
perp_min = perp_min_override if perp_min_override else 0
along_max = page.height if vertical else page.width
perp_max = perp_max_override if perp_max_override else page.width if vertical else page.height
# TODO: Replace the first part with page.get_perpendicular_shade_averages()
along_range = range(along_min, along_max)
if reverse:
along_range = along_range[::-1]
for along_iter in along_range:
perp_sum = 0
for perp_iter in range(perp_min, perp_max):
y = along_iter if vertical else perp_iter
x = perp_iter if vertical else along_iter
shade = utils.average(page.get_pixel(y, x))
perp_sum += shade
perp_avg = perp_sum / (perp_max - perp_min)
if perp_avg < low_border_threshold:
# Add a buffer equal to the max skew.
along_border = along_iter + (1 if reverse else -1) * max_skew
# Don't exceed the page boundaries.
along_border = max(min(along_border, (page.height if vertical else page.width) - 1), 0)
return along_border
# TODO: Throw decoding error
return -1
def find_border_points_subset_page(page, sub_bounds, vertical):
low_border_threshold = 0.20
min_border_difference = 32
borders = list()
along_min, along_max, perp_min, perp_max = sub_bounds
border_started = False
last_perp_avg = -1
# TODO: Replace the first part with page.get_perpendicular_shade_averages()
along_iter = along_min
while along_iter <= along_max:
perp_sum = 0
for perp_iter in range(perp_min, perp_max + 1):
y = along_iter if vertical else perp_iter
x = perp_iter if vertical else along_iter
shade = utils.average(page.get_pixel(y, x))
perp_sum += shade
perp_avg = perp_sum / (perp_max - perp_min + 1)
# If the border has started, find a local darkest point in the border
if border_started and perp_avg > last_perp_avg:
border_started = False
along_coordinate = along_iter - 1
perp_coordinate = (perp_max + perp_min) / 2
y = along_coordinate if vertical else perp_coordinate
x = perp_coordinate if vertical else along_coordinate
borders.append((y, x))
along_iter += min_border_difference - 1
continue
# Check darkness to start the border (or if we're at the end of the range, end on a border)
if perp_avg <= low_border_threshold:
if along_iter == along_max:
along_coordinate = along_iter
perp_coordinate = (perp_max + perp_min) / 2
y = along_coordinate if vertical else perp_coordinate
x = perp_coordinate if vertical else along_coordinate
borders.append((y, x))
break
border_started = True
last_perp_avg = perp_avg
along_iter += 1
return borders
def clean_and_infer_borders(sub_borders_all, vertical):
# Transpose lists so each borders points are within 1 list, not spread across all lists
# TODO: Clean up and infer sub borders before transposing - unequal lists are clipped.
initial_points = list()
perp_points = list()
new_sub_borders_all = list()
along_diffs_all = list()
for sub_borders in sub_borders_all:
if not sub_borders or not len(sub_borders):
continue
initial_points.append(sub_borders[0][1] if vertical else sub_borders[0][0])
perp_points.append(sub_borders[0][0] if vertical else sub_borders[0][1])
along_diffs = list()
along_points = list()
for y, x in sub_borders:
along = x if vertical else y
if len(along_points):
along_diffs.append(along - along_points[-1])
along_points.append(along)
along_diffs_all.append(along_diffs)
along_diffs_flat = reduce(operator.concat, along_diffs_all)
if len(along_diffs_flat) < 3:
return map(list, zip(*sub_borders_all))
along_diffs_flat_std = utils.standard_deviation_squared(along_diffs_flat)
# Begin clean-up - mark boundaries that can be merged
# TODO: This needs to account for multiple merged diffs
# e.g. differentiate [25,75,27,75,100,100,100] and [25,25,25,25,100,100,100]
for all_iter, along_diffs in enumerate(along_diffs_all):
along_diffs_others = reduce(operator.concat, utils.remove_index(along_diffs_all, all_iter))
if len(along_diffs) < 2:
new_sub_borders_all.append(sub_borders_all[all_iter])
continue
# Combine two sequential along-diff values if they reduce the standard deviation of the list
merge_indexes = list()
for along_iter in range(1, len(along_diffs)):
diff = along_diffs[along_iter]
diff_previous = along_diffs[along_iter - 1]
# Merge diff1 and diff2 in the list
lrem = utils.remove_index(along_diffs, along_iter - 1)
lrem[along_iter - 1] = diff_previous + diff
# If merging those diffs would lower the overall standard deviation, then mark it for merge
# TODO: Refine tolerance factor
tolerance = 0.9
if utils.standard_deviation_squared(lrem + along_diffs_others) < along_diffs_flat_std * tolerance:
if not (len(merge_indexes) and merge_indexes[-1] == along_iter - 1):
merge_indexes.append(along_iter)
along_diffs_clean = copy(along_diffs)
# Merge diffs
# TODO: Could probably just use along_points
i = 0
while i < len(merge_indexes):
index = merge_indexes[i]
add = along_diffs_clean.pop(index-1)
along_diffs_clean[index-1] += add
merge_indexes = map(lambda x: x - 1, merge_indexes)
i += 1
# Turn diffs to new along points
new_along_points = [initial_points[all_iter]]
for i in range(len(along_diffs_clean)):
new_along_points.append(sum(along_diffs_clean[:i+1]) + initial_points[all_iter])
if vertical:
new_sub_borders = map(lambda x: (perp_points[all_iter], x), new_along_points)
else:
new_sub_borders = map(lambda y: (y, perp_points[all_iter]), new_along_points)
new_sub_borders_all.append(new_sub_borders)
return new_sub_borders_all
def transpose_and_infer(borders_all, vertical):
max_val = 0
min_val = sys.maxint
for borders in borders_all:
for y, x in borders:
max_val = max(y if vertical else x, max_val)
min_val = min(y if vertical else x, min_val)
approximate_max_sectors = 15.0
max_diff = (max_val - min_val) / (2.0 * approximate_max_sectors)
transposed_list = list()
for borders in borders_all:
for y, x in borders:
coord = (y, x)
val = y if vertical else x
inserted = False
for inserted_borders_index in range(len(transposed_list)):
inserted_borders = transposed_list[inserted_borders_index]
for y_ins, x_ins in inserted_borders:
ins_val = y_ins if vertical else x_ins
if ins_val - max_diff < val < ins_val + max_diff:
transposed_list[inserted_borders_index].append(coord)
inserted = True
break
if inserted:
break
if not inserted:
transposed_list.append([coord])
# Sort first by second value, then by first
sorted_transposed_list = sorted(transposed_list, key=lambda val: val[0][0 if vertical else 1])
for i in range(len(sorted_transposed_list)):
sorted_transposed_list[i] = sorted(sorted_transposed_list[i], key=lambda val: val[1 if vertical else 0])
return sorted_transposed_list
def remove_outliers(l):
"""
Remove all values in l whose presence increases the overall standard deviation of the list
:param l: Input list
:return: Corrected list
"""
MIN_LIST_LENGTH = 2
if len(l) <= MIN_LIST_LENGTH:
return l
tolerance = 0.95 # TODO: Determine this heuristically
# Sort by increasing distance from mean, since only removing the largest outliers in a list affects its variance.
l_med = utils.median(l)
l = sorted(l, key=lambda x: abs(x - l_med))
std = utils.standard_deviation_squared(l)
for i in range(0, len(l))[::-1]:
newstd = utils.standard_deviation_squared(utils.remove_index(l, i))
if newstd < std * tolerance:
l.pop(i)
if len(l) <= MIN_LIST_LENGTH:
break
std = utils.standard_deviation_squared(l)
continue
return l
def infer_border_angled_lines(border_points_list, vertical):
"""
Get slope and x-intercept if vertical border points, or slope and y-intercept if horizontal.
This method currently uses a simple average.
TODO: A least squares fit would be more accurate (removing outlier influence, for example)
:param border_points_list: List of border points, e.g. [[(0,10),(0,20),(0,30)],[(101,10),(102,20),(103,30)]]
:return: List of inferred angled lines, params slope and intercept, e.g. [(0,0), (0.1,100)]
"""
border_angled_lines = list()
for border_points in border_points_list:
while None in border_points:
border_points.remove(None)
if not border_points or not len(border_points) > 1:
continue
slope_list = list()
intercept_list = list()
# Don't just look at adjacent pairs, which could have small along-differences that amplify the slope
# Look at all n*(n-1) combinations of points, which are more likely to be far apart with smaller slopes
for i in range(len(border_points) - 1):
for j in range(i+1, len(border_points)):
y1, x1 = border_points[i]
y2, x2 = border_points[j]
if vertical:
if y1 != y2:
slope = float(x2 - x1) / float(y2 - y1)
slope_list.append(slope)
intercept_list.append(x1 - slope * y1)
else:
if x1 != x2:
slope = float(y2 - y1) / float(x2 - x1)
slope_list.append(slope)
intercept_list.append(y1 - slope * x1)
corrected_slope_list = remove_outliers(slope_list) if len(slope_list) > 2 else slope_list
corrected_intercept_list = remove_outliers(intercept_list) if len(intercept_list) > 2 else intercept_list
slope = utils.average(corrected_slope_list)
intercept = utils.average(corrected_intercept_list)
border_angled_lines.append((slope, intercept))
return border_angled_lines
def get_coordinates(v_value, h_value):
vertical_slope, vertical_intercept = v_value
horizontal_slope, horizontal_intercept = h_value
y = vertical_slope * horizontal_intercept + vertical_intercept / (
1 - vertical_slope * horizontal_slope)
x = horizontal_slope * vertical_intercept + horizontal_intercept / (
1 - vertical_slope * horizontal_slope)
return int(y), int(x)
def get_intersections(vertical_border_angled_lines, horizontal_border_angles_lines):
"""
y = vertical_slope * x + vertical_intercept
x = horizontal_slope * y + horizontal_intercept
Solving for these gives the intersection points:
y = vertical_slope * horizontal_intercept + vertical_intercept / (1 - vertical_slope * horizontal_slope)
x = horizontal_slope * vertical_intercept + horizontal_intercept / (1 - vertical_slope * horizontal_slope)
:param vertical_border_angled_lines: List of vertical slopes and intercepts
:param horizontal_border_angles_lines: List of horizontal slopes and intercepts
:return: List of intersection points
"""
intersections = list()
for h_index, h_value in enumerate(horizontal_border_angles_lines[1:], 1):
for v_index, v_value in enumerate(vertical_border_angled_lines[1:], 1):
v_value_prev = vertical_border_angled_lines[v_index - 1]
h_value_prev = horizontal_border_angles_lines[h_index - 1]
top_left = get_coordinates(v_value_prev, h_value_prev)
top_right = get_coordinates(v_value_prev, h_value)
bottom_left = get_coordinates(v_value, h_value_prev)
bottom_right = get_coordinates(v_value, h_value)
intersections.append((top_left, top_right, bottom_left, bottom_right))
return intersections
def get_real_sector_data_boundary(page, leastAlong, mostAlong, leastPerp, mostPerp, vertical, reverse):
"""Search within given rough sector bounds and return the true coordinate of the gap
E.g. if looking for the real top gap coordinate, along is y and perp is x. Return y.
"""
perp_shades = page.get_perpendicular_shade_averages(leastAlong, mostAlong, leastPerp, mostPerp, vertical, reverse)
dataIndex = 0
brightestShade = max(perp_shades)
firstGapIndex = perp_shades.index(brightestShade)
darkestShadeAfterGap = min(perp_shades[firstGapIndex:])
if brightestShade == darkestShadeAfterGap:
return dataIndex
# TODO: Improve this value
gapToDataTolerance = 0.25
# Get the closest value that has a sizeable drop from the max of all previous shades
# This only works if the initial shade is assumed to be the darkest part of the border
for perpIndex, perpShade in enumerate(perp_shades[firstGapIndex:], firstGapIndex):
thresholdedPerpShade = utils.threshold(perpShade, brightestShade, darkestShadeAfterGap)
if thresholdedPerpShade < 1 - gapToDataTolerance:
dataIndex = perpIndex
break
dataBound = dataIndex + leastAlong if not reverse else mostAlong - dataIndex
return dataBound
def get_real_sector_data_boundaries(page, heightPerDot, widthPerDot, topmost, bottommost, leftmost, rightmost):
"""Find real data boundaries.
Look within two-dot unit of pixels away, only looking inwards since input bounds will be within a border
"""
# Search just past the boundary and gaps
max_dots_away = defaults.borderSize + defaults.gapSize + 2
bottommostTop = topmost + int(round(max_dots_away * heightPerDot))
topmostBottom = bottommost - int(round(max_dots_away * heightPerDot))
rightmostLeft = leftmost + int(round(max_dots_away * widthPerDot))
leftmostRight = rightmost - int(round(max_dots_away * widthPerDot))
# TODO: First find gap, then find data (or else a blurry line could count as data)
top = get_real_sector_data_boundary(
page,
topmost,
bottommostTop,
rightmostLeft,
leftmostRight,
True,
False)
bottom = get_real_sector_data_boundary(
page,
topmostBottom,
bottommost,
rightmostLeft,
leftmostRight,
True,
True)
left = get_real_sector_data_boundary(
page,
leftmost,
rightmostLeft,
bottommostTop,
topmostBottom,
False,
False)
right = get_real_sector_data_boundary(
page,
leftmostRight,
rightmost,
bottommostTop,
topmostBottom,
False,
True)
top = top if top else topmost
bottom = bottom if bottom else bottommost
left = left if left else leftmost
right = right if right else rightmost
return top, bottom, left, right

View File

@ -0,0 +1,148 @@
import math
import os
import sys
from colorsafe.debugutils import draw_page
from colorsafe import exceptions, utils
from colorsafe.csdatastructures import ColorChannels
def get_normalized_channels_list(page, data_bounds, sector_height, sector_width, sectorNum, tmpdir):
if tmpdir:
tmpdir_bounds = os.path.join(str(tmpdir), "channels")
try:
os.mkdir(tmpdir_bounds)
except OSError:
pass
tmpdir = tmpdir_bounds
top, bottom, left, right = data_bounds
channels_list = get_channels_list(page,
top,
bottom,
left,
right,
sector_height,
sector_width,
sectorNum,
tmpdir)
normalized_channels_list = normalizeChannelsList(channels_list)
if (tmpdir):
f = open(os.path.join(tmpdir, "normalized_channels_" + str(sectorNum) + ".txt"), "w")
for i in channels_list:
f.write(str(i.getChannels()) + "\r")
f.close()
return normalized_channels_list
def get_channels_list(page, top, bottom, left, right, sector_height, sector_width, sector_num, tmpdir):
# TODO: Use bilinear interpolation to get pixel values instead
total_pixels_height = bottom - top + 1
total_pixels_width = right - left + 1
pixels_per_dot_width = float(total_pixels_height) / float(sector_height)
pixels_per_dot_height = float(total_pixels_width) / float(sector_width)
if tmpdir:
all_pixels_and_weight = list()
# For each dot in the sector
channels_list = list()
for y in range(sector_height):
for x in range(sector_width):
# Center halfway through the dot, y + 0.5 and x + 0.5
y_center = pixels_per_dot_height * (y + 0.5) + top
x_center = pixels_per_dot_width * (x + 0.5) + left
y_min = y_center - pixels_per_dot_height / 2
y_max = y_center + pixels_per_dot_height / 2
x_min = x_center - pixels_per_dot_width / 2
x_max = x_center + pixels_per_dot_width / 2
pixels_and_weight = list()
weight_sum = 0.0
y_pixel_min = int(math.floor(y_min))
y_pixel_max = int(math.floor(y_max))
x_pixel_min = int(math.floor(x_min))
x_pixel_max = int(math.floor(x_max))
for y_pixel in range(y_pixel_min, y_pixel_max + 1):
for x_pixel in range(x_pixel_min, x_pixel_max + 1):
pixel = page.get_pixel(y_pixel, x_pixel)
weight = 1.0
if y_pixel > y_max - 1:
weight *= (y_max % 1)
if y_pixel < y_min:
weight *= ((1 - y_min) % 1)
if x_pixel > x_max - 1:
weight *= (x_max % 1)
if x_pixel < x_min:
weight *= ((1 - x_min) % 1)
weight_sum += weight
pixels_and_weight.append((pixel, weight, y_pixel, x_pixel))
if tmpdir:
all_pixels_and_weight.append((y, x, pixels_and_weight))
number_of_channels = len(page.get_pixel(0, 0))
channels_sum = [0] * number_of_channels
for pixel, weight, y_pixel, x_pixel in pixels_and_weight:
for i in range(number_of_channels):
channels_sum[i] += pixel[i] * weight
channels_avg = map(lambda i: i / weight_sum, channels_sum)
channels_list.append(channels_avg)
if tmpdir:
f = open(os.path.join(tmpdir, "all_pixels_and_weight_" + str(sector_num) + ".txt"), "w")
for y, x, pixels_and_weight in all_pixels_and_weight:
f.write(str(y) + "," + str(x) + ":\r")
for i in pixels_and_weight:
f.write(" " + str(i) + "\r")
f.close()
color_channels_list = map(lambda i: ColorChannels(*i), channels_list)
return color_channels_list
def normalizeChannelsList(channelsList):
minVals = [1.0, 1.0, 1.0]
maxVals = [0.0, 0.0, 0.0]
# Get min and max vals for normalization
for c in channelsList:
vals = c.getChannels()
for i, val in enumerate(vals):
if val < minVals[i]:
minVals[i] = val
if val > maxVals[i]:
maxVals[i] = val
normalizedChannelsList = list()
for i, channels in enumerate(channelsList):
minVal = sum(minVals) / len(minVals)
maxVal = sum(maxVals) / len(maxVals)
if minVal == maxVal:
raise exceptions.DecodingError("No variance detected in the data. All channels are the same color.")
channels.subtractShade(minVal)
channels.multiplyShade([1.0 / (maxVal - minVal)])
normalizedChannelsList.append(channels)
return normalizedChannelsList

View File

@ -1,7 +1,7 @@
from PIL import Image
import re
from colorsafe.decoder.csdecoder import InputPages
from colorsafe.decoder.csinput_page import InputPages
from colorsafe.decoder.csimages_decoder import ColorSafeImagesDecoder
MaxColorVal = 255

View File

@ -2,10 +2,12 @@ import os
from unireedsolomon import RSCoder, RSCodecError
from colorsafe import constants, defaults, exceptions, utils
from colorsafe.csdatastructures import ColorSafeImages, ColorChannels, Sector, DotRow
from colorsafe.debugutils import drawPage
from colorsafe.decoder.csdecoder import SectorDecoder, InputPage
from colorsafe import constants, defaults, utils
from colorsafe.csdatastructures import ColorSafeImages, Sector, DotRow
from colorsafe.decoder.csdecoder import SectorDecoder
from colorsafe.decoder.csdecoder_getbounds import get_data_bounds
from colorsafe.decoder.csdecoder_getchannels import get_normalized_channels_list
from colorsafe.decoder.csinput_page import InputPage
class ColorSafeImagesDecoder(ColorSafeImages):
@ -30,48 +32,6 @@ class ColorSafeImagesDecoder(ColorSafeImages):
for pageNum in range(pages.totalPages):
page = InputPage(pages, pageNum)
# TODO: Use angles, not skew, for a more granular accuracy.
# Get skews
verticalSkew = self.findSkew(page, True)
horizontalSkew = self.findSkew(page, False)
# Vertical bounds are more accurate since the data will extend to the horizontal end of the page.
# Retrieve them first and use them to clip the horizontal bound region to not check an empty page bottom.
# TODO: Clip at the outer bounds of the image, for best accuracy. Scan could have whitespace to either side.
verticalChannelShadeAvg = self.getChannelShadeAvg(page, verticalSkew, True)
verticalBounds = self.findBounds(verticalChannelShadeAvg)
if not len(verticalBounds):
raise exceptions.DecodingError("No vertical bounds detected.")
# TODO: Get the real bottom of the image. Currently this is simply the lowest inner sector bound.
verticalEnd = verticalBounds[-1][-1]
horizontalChannelShadeAvg = self.getChannelShadeAvg(page, horizontalSkew, False, verticalEnd)
horizontalBounds = self.findBounds(horizontalChannelShadeAvg)
if not len(horizontalBounds):
raise exceptions.DecodingError("No horizontal bounds detected")
if (tmpdir):
f = open(os.path.join(tmpdir, "skewAndBounds.txt"), "w")
f.write("verticalSkew " + str(verticalSkew))
f.write("\rhorizontalSkew " + str(horizontalSkew))
f.write("\rverticalBounds " + str(verticalBounds))
f.write("\rhorizontalBounds " + str(horizontalBounds))
f.write("\rverticalChannelShadeAvg " + str(verticalChannelShadeAvg))
f.write("\rhorizontalChannelShadeAvg " + str(horizontalChannelShadeAvg))
f.close()
bounds = self.getSkewSectorBounds(verticalBounds, horizontalBounds, verticalSkew, horizontalSkew)
if (tmpdir):
converted_bounds = list()
for y1, y2, x1, x2 in bounds:
converted_bounds.extend([(y1, x1), (y1, x2), (y2, x1), (y2, x2)])
drawPage(page, tmpdir, "bounds", tuple(converted_bounds), None, (255, 0, 0))
# TODO: Calculate dynamically
# TODO: Override by command-line argument
sectorHeight = defaults.sectorHeight
@ -79,66 +39,22 @@ class ColorSafeImagesDecoder(ColorSafeImages):
gapSize = defaults.gapSize
eccRate = defaults.eccRate
sectorNum = -1
bounds = get_data_bounds(page, sectorHeight, sectorWidth, gapSize, tmpdir)
if (tmpdir):
debug_dots = list()
sectorNum = -1
# For each sector, beginning and ending at its gaps
sectorDamage = list()
for topTemp, bottomTemp, leftTemp, rightTemp in bounds:
for each_bounds in bounds:
sectorNum += 1
# perc = str(int(100.0 * sectorNum / (sectorsHorizontal*sectorsVertical))) + "%"
# Use page-average to calculate height/width, works better for small sector sizes
# Rotation should even out on average
heightPerDot = float(bottomTemp - topTemp + 1) / (sectorHeight + 2 * gapSize)
widthPerDot = float(rightTemp - leftTemp + 1) / (sectorWidth + 2 * gapSize)
if widthPerDot < 1.0: # Less than 1.0x resolution, cannot get all dots
raise exceptions.DecodingError
top, bottom, left, right = self.getRealGaps(page,
heightPerDot,
widthPerDot,
topTemp,
bottomTemp,
leftTemp,
rightTemp)
rowsBoundaryChanges = self.getBoundaryChanges(page, left, right, top, bottom)
columnsBoundaryChanges = self.getBoundaryChanges(page, top, bottom, left, right, False)
rowDotStartLocations = self.dotStartLocations(widthPerDot,
rowsBoundaryChanges,
sectorWidth)
columnDotStartLocations = self.dotStartLocations(widthPerDot,
columnsBoundaryChanges,
sectorHeight)
if (tmpdir):
for x in rowDotStartLocations:
for y in columnDotStartLocations:
debug_dots.append((top + x, left + y))
channelsList = get_normalized_channels_list(page, each_bounds, sectorHeight, sectorWidth,
sectorNum, tmpdir)
# TODO: Calculate dynamically
bucketNum = 40
channelsList = self.getChannelsList(page,
columnDotStartLocations,
rowDotStartLocations,
top,
left,
sectorHeight,
sectorWidth)
channelsList = self.normalizeChannelsList(channelsList)
if (tmpdir):
f = open(os.path.join(tmpdir, "normalizedChannels" + str(sectorNum) + ".txt"), "w")
for i in channelsList:
f.write(str(i.getChannels()) + "\r")
f.close()
thresholdWeight = self.getThresholdWeight(channelsList, bucketNum)
dataRows = Sector.getDataRowCount(sectorHeight, eccRate)
@ -170,9 +86,6 @@ class ColorSafeImagesDecoder(ColorSafeImages):
# TODO: Use ColorSafeFileDecoder to organize and parse this
metadataStr += str(sectorNum) + "\n" + outData + "\n\n"
if tmpdir:
drawPage(page, tmpdir, "dots", tuple(debug_dots), None, (255, 0, 0))
if len(sectorDamage):
self.sectorDamageAvg = sum(sectorDamage) / len(sectorDamage)
else:
@ -185,515 +98,18 @@ class ColorSafeImagesDecoder(ColorSafeImages):
self.dataStr = dataStr
self.metadataStr = metadataStr
@staticmethod
def normalize(val, minVal, maxVal):
if maxVal == minVal:
raise exceptions.DecodingError
return (val - minVal) / (maxVal - minVal)
@staticmethod
def getSectorBounds(
page,
leastAlong,
mostAlong,
leastPerp,
mostPerp,
gapThreshold,
vertical=True,
reverse=False):
"""Search within given rough sector bounds and return the true coordinate of the gap
E.g. if looking for the real top gap coordinate, along is y and perp is x. Return y.
"""
alongRange = range(leastAlong + 1, mostAlong + 1)
if reverse:
alongRange = alongRange[::-1]
for along in alongRange:
perpShadeSum = 0.0
for perp in range(leastPerp, mostPerp + 1):
y = along if vertical else perp
x = perp if vertical else along
if y < 0 or y >= page.height or x < 0 or x >= page.width:
break
perpShadeSum += utils.average(page.getPixel(y, x))
perpShadeSum /= (mostPerp - leastPerp)
if perpShadeSum > gapThreshold:
return along
@staticmethod
def findSkew(page, vertical=True, reverse=False):
"""Find the a grid image's skew, based on the vertical (or horizontal) boolean
Vertical skew is the number of pixels skewed right on the bottom side (negative if left) if the top is constant
Horizontal skew is the number of pixels skewed down on the right side (negative if up) if the left is constant
"""
# TODO: Binary search through perp to find first boundary. This will improve speed.
# TODO: Search for two or more skew lines to improve accuracy? Scale
# isn't known yet, so this is tricky
DEFAULT_BEST_SKEW = 0
MAX_SHADE_VARIANCE = 0.1
dataDensity = 0.5
alongStep = 10 # TODO: Refine this
# Get length of column (or row) and the length of the axis
# perpendicular to it, row (or column)
# Along the axis specified by the vertical bool
alongLength = page.height if vertical else page.width
# Perpendicular to the axis specified by the vertical bool
perpLength = page.width if vertical else page.height
maxSkew = max(int(constants.MaxSkewPerc * perpLength),
constants.MaxSkew)
# For each angle, find the minimum shade in the first border
minShade = 1.0
minShadeIter = perpLength if not reverse else 0
bestSkewShadeList = []
for skew in range(-maxSkew, maxSkew + 1):
slope = float(skew) / alongLength
# Choose perpendicular range bounds such that sloped line will not
# run off the page
perpBounds = range(min(0, skew), perpLength - max(0, skew))
if reverse:
perpBounds = perpBounds[::-1]
# TODO: Consider stepping perp/along 5-10 at a time for speedup
for perpIter in perpBounds:
# Don't check far past the first border coordinate, in order to
# speed up execution
if not reverse:
breakCond = (perpIter > minShadeIter + 2 * maxSkew)
else:
breakCond = (perpIter < minShadeIter - 2 * maxSkew)
if breakCond:
break
skewLine = list()
for alongIter in range(0, alongLength, alongStep):
perpValue = int(round(alongIter * slope)) + perpIter
if perpValue < 0 or perpValue >= perpLength:
skewLine = list()
break
x = perpValue if vertical else alongIter
y = alongIter if vertical else perpValue
pixelShade = utils.average(page.getPixel(y, x))
skewLine.append(pixelShade)
if not len(skewLine):
continue
# TODO: Normalize
# Add all skews and shades to a list if they're smaller than the smallest shade (plus some variance)
avgShade = sum(skewLine) / len(skewLine)
if avgShade < minShade * (1 + MAX_SHADE_VARIANCE) and avgShade < dataDensity:
minShade = avgShade
bestSkewShadeList.append((skew, minShade))
minShadeIter = perpIter
# The bestSkewShadeList will have many shades/skews not near the bottom, since above we started from 1.0 and
# included all smaller shades along the way. We did that to reduce the list size here, where we take the average
# of shades within the variance of the global minimum.
globalMinShade = 1.0
for skew, minShade in bestSkewShadeList:
globalMinShade = min(globalMinShade, minShade)
bestSkews = []
for skew, minShade in bestSkewShadeList:
if minShade < globalMinShade * (1 + MAX_SHADE_VARIANCE):
bestSkews.append(skew)
bestSkew = utils.average(bestSkews) if len(bestSkews) else DEFAULT_BEST_SKEW
return bestSkew
@staticmethod
def getChannelShadeAvg(page, skew, vertical=True, perpEnd = None):
"""Get bounds including skew
"""
perpStep = 3 # TODO: Refine this
# Get length of column (or row) and the length of the axis
# perpendicular to it, row (or column)
# Along the axis specified by the vertical bool
alongLength = page.height if vertical else page.width
# Perpendicular, e.g. in the direction of the bounds. PerpEnd can replace this is supplied.
perpLength = perpEnd if perpEnd else (page.width if vertical else page.height)
slope = float(skew) / perpLength
# Get all skew line shade sums
channelShadeAvg = list()
for alongIter in range(0, alongLength):
alongShadeSum = 0
# Sum all shades along the skew line
perpLengthIterated = 0
for perpIter in range(0, perpLength, perpStep):
perpValue = int(round(perpIter * slope)) + alongIter
# If coordinates are out of bounds of the image, use white pixels
if perpValue < 0 or perpValue >= alongLength:
# TODO: Make all white, or else start at perp values that will all be contained in the image
# alongShadeSum = 1.0 * perpLength / perpStep # White border
alongShadeSum += 1.0
continue
y = perpValue if vertical else perpIter
x = perpIter if vertical else perpValue
pixelShade = utils.average(page.getPixel(y, x))
alongShadeSum += pixelShade
channelShadeAvg.append(alongShadeSum * perpStep / perpLength)
return channelShadeAvg
@staticmethod
def getSkewSectorBounds(
verticalBounds,
horizontalBounds,
verticalSkew,
horizontalSkew):
"""Given skews, get skewed bounds from non-skewed ones
"""
bounds = list()
for top, bottom in verticalBounds:
for left, right in horizontalBounds:
bound = (top, bottom, left, right)
bounds.append(bound)
return bounds
# TODO: Consider moving this logic to a new ChannelsGrid object
# TODO: Set previousCount as average pixel width when called
@staticmethod
def findBounds(l, previousCount=6):
"""Given a 1D black and white grid matrix (one axis of a 2D grid matrix) return a list of beginnings and ends.
A beginning is the first whitespace (gap) after any black border, and an end is the last whitespace (gap)
before the next black border.
Use whitespace after borders rather than searching for the data within each border, since the
data may be empty or inconsistent. Borders are the most reliable unit of recognition.
"""
minLengthSector = max(defaults.sectorWidth, defaults.sectorHeight)
lowBorderThreshold = 0.35
highGapThreshold = 0.65
# Threshold for throwing out a value and using the mode end-begin diff
# instead
diffThreshold = 0.1
# Trim leading/trailing whitespace for better min/max normalization
borderBeginning = 0
for i, val in enumerate(l):
if ColorSafeImagesDecoder.normalize(val, min(l), max(l)) < highGapThreshold:
borderBeginning = i
break
borderEnding = len(l)
for i, val in reversed(list(enumerate(l))):
if ColorSafeImagesDecoder.normalize(val, min(l), max(l)) < highGapThreshold:
borderEnding = i
break
lTruncated = l[borderBeginning:borderEnding + 1]
# TODO: Use top/bottom 10th percentiles to threshold, for better accuracy
minVal = min(lTruncated)
maxVal = max(lTruncated)
begins = list()
ends = list()
# Find beginning border, and then all begin/end gaps that surround
# sector data
for i, val in enumerate(l):
if i < borderBeginning or i > borderEnding:
continue
val = ColorSafeImagesDecoder.normalize(val, minVal, maxVal)
# Look for expected values to cross thresholds anywhere in the last
# previousCount values.
previousVals = list()
for shift in range(previousCount):
prevIndex = i - shift - 1
if prevIndex >= 0:
previousVals.append(
ColorSafeImagesDecoder.normalize(
l[prevIndex], minVal, maxVal))
# Begins and ends matched, looking for new begin gap
if len(begins) == len(ends):
# Boundary where black turns white
if val > highGapThreshold and any(
v < lowBorderThreshold for v in previousVals):
begins.append(i)
continue
# More begins than ends, looking for new end gap
if len(ends) < len(begins):
# Boundary where white turns black
if val < lowBorderThreshold and any(
v > highGapThreshold for v in previousVals):
if i >= begins[-1] + minLengthSector:
ends.append(i - 1)
continue
# If begins and ends don't match, correct by cutting off excess
# beginning
if len(begins) > len(ends):
begins = begins[0:len(ends)]
# Correct bounds
if len(begins) > 1:
beginsDiffs = list()
endsDiffs = list()
for i in range(1, len(begins)):
beginsDiffs.append(begins[i] - begins[i - 1])
endsDiffs.append(ends[i] - ends[i - 1])
beginsDiffsMode = max(beginsDiffs, key=beginsDiffs.count)
endsDiffsMode = max(endsDiffs, key=endsDiffs.count)
# TODO: Doesn't correct first begin or end - assumes first one is
# correct
for i in range(1, len(begins)):
if abs(begins[i] - begins[i - 1]) / float(beginsDiffsMode) > diffThreshold:
begins[i] = begins[i - 1] + beginsDiffsMode
if abs(ends[i] - ends[i - 1]) / float(endsDiffsMode) > diffThreshold:
ends[i] = ends[i - 1] + endsDiffsMode
bounds = list()
for i in range(0, len(begins)):
bounds.append((begins[i], ends[i]))
return bounds
@staticmethod
def getBoundaryChanges(page, begin, end, perp_begin, perp_end, rows = True):
""" For all pixels in sector, mark and sum boundary changes for all rows or columns
TODO: Generalize to multiple shades, e.g. shadesPerChannel = 2
:param page
:param begin:
:param end:
:param perp_begin:
:param perp_end:
:param rows
:return:
"""
BoundaryThreshold = 0.8
boundaryChanges = list()
for i in range(begin + 1, end + 1):
allBoundaryChanges = 0
for j in range(perp_begin, perp_end + 1):
current = page.getPixel(j if rows else i, i if rows else j)
previous = page.getPixel(j if rows else i - 1, i - 1 if rows else j)
for k in range(len(current)):
bucketCurrent = (0 if current[k] < BoundaryThreshold else 1)
bucketPrevious = (0 if previous[k] < BoundaryThreshold else 1)
# Get white to black only, seems to be more
# consistent
if bucketCurrent != bucketPrevious and bucketCurrent == 0:
allBoundaryChanges += 1
boundaryChanges.append(allBoundaryChanges)
return boundaryChanges
@staticmethod
def dotStartLocations(widthPerDot, boundaryChanges, sectorLength):
"""Find the most likely dot start locations
:param sectorLength SectorHeight for columns, SectorWidth for rows
:return:
"""
avgPixelsWidth = int(round(widthPerDot))
if widthPerDot == 1.0: # Exactly 1.0, e.g. original output, or perfectly scanned
maxPixelsWidth = 1
else:
maxPixelsWidth = avgPixelsWidth + 1
minPixelsWidth = max(
avgPixelsWidth - 1,
1) # Cannot be less than 1
dotStartLocations = list()
currentLocation = 0
for i in range(sectorLength):
# TODO: Account for the gap, find initial data start
mnw = minPixelsWidth if i else 0
possible = boundaryChanges[currentLocation + mnw: currentLocation + maxPixelsWidth + (1 if i else 0)]
if possible:
index = possible.index(max(possible))
else:
index = 0
currentLocation += index + mnw
dotStartLocations.append(currentLocation)
# For ending, add average width to the end so that dot
# padding/fill is correct
dotStartLocations.append(dotStartLocations[-1] + avgPixelsWidth)
return dotStartLocations
@staticmethod
def getRealGaps(page, heightPerDot, widthPerDot, topTemp, bottomTemp, leftTemp, rightTemp):
"""Find real gaps, since small rotation across a large page may distort this.
Look within one-dot unit of pixels away
"""