1

Consider the following image: enter image description here

and the following bounding contour( which is a smooth version of the output of a text-detection neural network of the above image ), so this contour is a given. enter image description here

I need to warp both images so that I end up with a straight enough textline, so that it can be fed to a text recognition neural network: enter image description here

using Piecewise Affine Transformation, or some other method. with an implementation if possible or key points of implementation in python.

I know how to find the medial axis, order its points, simplify it (e.g using Douglas-Peucker algorithm), and find the corresponding points on a straight line.

EDIT: the question can be rephrased -naively- as the following :
have you tried the "puppet warp" feature in Adobe Photoshop? you specify "joint" points on an image , and you move these points to the desired place to perform the image warping, we can calculate the source points using a simplified medial axis (e.g 20 points instead of 200 points), and calculate the corresponding target points on a straight line, how to perform Piecewise Affine Transformation using these two sets of points( source and target)?

EDIT: modified the images, my bad

Papers

Here's a paper that does the needed result: A Novel Technique for Unwarping Curved Handwritten Texts Using Mathematical Morphology and Piecewise Affine Transformation

another paper: A novel method for straightening curved text-lines in stylistic documents

Similar questions:

yazan sayed
  • 777
  • 7
  • 24
  • please state a question. -- start by finding the medial axis of that thing. I know it's not "a straight line", so perhaps you're asking about that? – Christoph Rackwitz Sep 05 '22 at 20:44
  • @ChristophRackwitz I attached an image with the result needed. – yazan sayed Sep 05 '22 at 20:50
  • Translate every single column so that the medial axis is at the wanted y coordinate. – Micka Sep 05 '22 at 22:07
  • The problem is insufficiently specified, you need to show how the 2D space around the curve should be transformed. Otherwise, you could just join the two endpoint with a simple line segment. –  Sep 05 '22 at 22:11
  • 1
    welll once you have a path, you can easily walk it in unit increments, calculate normals, and use points along the normals to sample the source image, and there you go. the "hard part" is getting a clean path that represents this sausage. skeletonization results are notoriously "linty" – Christoph Rackwitz Sep 05 '22 at 22:30
  • @ChristophRackwitz, can you provide some links ? I don't know what are 'normals' or 'walk it in unit increments' – yazan sayed Sep 05 '22 at 22:53
  • @Micka can you provide a link for some code doing that , or something similar? – yazan sayed Sep 05 '22 at 22:56
  • @YvesDaoust sorry, edited the question , hope it's more clear now – yazan sayed Sep 05 '22 at 22:57
  • @ChristophRackwitz also please take a look at the first paper I mentioned, I can calculate the source points and the target points just as seen in the paper, I need to warp the image using these two sets of points – yazan sayed Sep 05 '22 at 23:02
  • 1
    *that* type of path-following just renders the text with shifted y coordinate instead of making it follow the path perpendicularly. to fix that, just unshift all the pixels. please review [ask]. show your own attempt. "piecewise affine transform" surely has been asked and answered before. I don't see why this is a problem. – Christoph Rackwitz Sep 06 '22 at 04:23
  • 1
    The contour does not match the warped text ! :-( –  Sep 06 '22 at 08:38
  • @ChristophRackwitz, thanks, you're right, after a couple of minutes of search I found an implementation of Piecewise Affine Transform and posted an answer, next time I'll do more research first, thanks – yazan sayed Sep 06 '22 at 09:56

2 Answers2

1

Full code also available in this notebook , runtime -> run all to reproduce the result.

import cv2
import matplotlib.pyplot as plt
import numpy as np
from PIL import Image
from scipy import interpolate
from scipy.spatial import distance
from shapely.geometry import LineString, GeometryCollection, MultiPoint
from skimage.morphology import skeletonize
from sklearn.decomposition import PCA
from warp import PiecewiseAffineTransform  # https://raw.githubusercontent.com/TimSC/image-piecewise-affine/master/warp.py


# Helper functions

def extendline(line, length):
    a = line[0]
    b = line[1]
    lenab = distance.euclidean(a, b)
    cx = b[0] + ((b[0] - a[0]) / lenab * length)
    cy = b[1] + ((b[1] - a[1]) / lenab * length)
    return [cx, cy]


def XYclean(x, y):
    xy = np.concatenate((x.reshape(-1, 1), y.reshape(-1, 1)), axis=1)
    # make PCA object
    pca = PCA(2)
    # fit on data
    pca.fit(xy)
    # transform into pca space   
    xypca = pca.transform(xy)
    newx = xypca[:, 0]
    newy = xypca[:, 1]
    # sort
    indexSort = np.argsort(x)
    newx = newx[indexSort]
    newy = newy[indexSort]

    # add some more points (optional)
    f = interpolate.interp1d(newx, newy, kind='linear')
    newX = np.linspace(np.min(newx), np.max(newx), 100)
    newY = f(newX)

    # #smooth with a filter (optional)
    # window = 43
    # newY = savgol_filter(newY, window, 2)

    # return back to old coordinates
    xyclean = pca.inverse_transform(np.concatenate((newX.reshape(-1, 1), newY.reshape(-1, 1)), axis=1))
    xc = xyclean[:, 0]
    yc = xyclean[:, 1]
    return np.hstack((xc.reshape(-1, 1), yc.reshape(-1, 1))).astype(int)


def contour2skeleton(cnt):
    x, y, w, h = cv2.boundingRect(cnt)
    cnt_trans = cnt - [x, y]
    bim = np.zeros((h, w))
    bim = cv2.drawContours(bim, [cnt_trans], -1, color=255, thickness=cv2.FILLED) // 255
    sk = skeletonize(bim > 0)
    #####
    skeleton_yx = np.argwhere(sk > 0)
    skeleton_xy = np.flip(skeleton_yx, axis=None)
    xx, yy = skeleton_xy[:, 0], skeleton_xy[:, 1]
    skeleton_xy = XYclean(xx, yy)
    skeleton_xy = skeleton_xy + [x, y]
    return skeleton_xy


mm = cv2.imread('cont.png', cv2.IMREAD_GRAYSCALE)
plt.imshow(mm)
cnts, _ = cv2.findContours(mm.astype('uint8'), cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
cont = cnts[0].reshape(-1, 2)

# find skeleton
sk = contour2skeleton(cont)
mm = np.zeros_like(mm)
cv2.polylines(mm, [sk], False, 255, 2)
plt.imshow(mm)

# simplify the skeleton
ln = LineString(sk).simplify(2)
sk_simp = np.int0(ln.coords)
mm = np.zeros_like(mm)
for pt in sk_simp:
    cv2.circle(mm, pt, 5, 255, -1)
plt.imshow(mm)

# extend both ends of the skeleton
print(len(sk_simp))

a, b = sk_simp[1], sk_simp[0]
c1 = np.int0(extendline([a, b], 50))
sk_simp = np.vstack([c1, sk_simp])
a, b = sk_simp[-2], sk_simp[-1]
c2 = np.int0(extendline([a, b], 50))
sk_simp = np.vstack([sk_simp, c2])

print(len(sk_simp))

cv2.circle(mm, c1, 10, 255, -1)
cv2.circle(mm, c2, 10, 255, -1)
plt.imshow(mm)

########
# find the target points
########

pts1 = sk_simp.copy()

dists = [distance.euclidean(p1, p2) for p1, p2 in zip(pts1[:-1], pts1[1:])]
zip1 = list(zip(pts1[:-1], dists))

# find the first 2 target points
a = pts1[0]
b = a - (dists[0], 0)

pts2 = [a, b, ]
for z in zip1[1:]:
    lastpt = pts2[-1]
    pt, dst = z
    ln = [a, lastpt]
    c = extendline(ln, dst)
    pts2.append(c)

pts2 = np.int0(pts2)

ln1 = LineString(pts1)
ln2 = LineString(pts2)
GeometryCollection([ln1.buffer(5), ln2.buffer(5),
                    MultiPoint(pts2), MultiPoint(pts1)])

########
# create translated copies of source and target points

# 50 is arbitary
pts1 = np.vstack([pts1 + [0, 50], pts1 + [0, -50]])
pts2 = np.vstack([pts2 + [0, 50], pts2 + [0, -50]])
MultiPoint(pts1)

########
# performing the warping

im = Image.open('orig.png')
dstIm = Image.new(im.mode, im.size, color=(255, 255, 255))

# Perform transform
PiecewiseAffineTransform(im, pts1, dstIm, pts2)

plt.figure(figsize=(10, 10))
plt.imshow(dstIm)

1- find medial axis , e.g using skimage.morphology.skeletonize and simplify it ,e.g using shapely object.simplify , I used a tolerance of 2 , the medial axis points are in white: enter image description here

2- find the corresponding points on a straight line, using the distance between each point and the next: enter image description here

3 - also added extra points on the ends, colored blue, so that the points fit the entire contour length enter image description here

4- create 2 copies of the source and target points, one copy translated up and the other translated down (I choose an offset of 50 here), so the source points are now like this, please note that simple upward/downward displacement may not be the best approach for all contours, e.g if the contour is curving with degrees > 45: enter image description here

5- using the code here , perform PiecewiseAffineTransform using the source and target points, here's the result, it's straight enough: enter image description here

yazan sayed
  • 777
  • 7
  • 24
  • I mean, shouldn't you post the code too? Also, if this requires manual addition of points, it's... a bit iffy? – AKX Sep 06 '22 at 09:52
  • it's not manual addition, for example we have a line from `pts_orig[2]` to `pts_orig[1]`, just extend it by a large enough distance, e.g 50, this distance can be determind based on the "stroke width" of the contour, or we can use the intersection point of that extended line and the contour ,,, the steps are self-explanatory, I didn't think I should add code , but I will, sorry (or maybe my code is ugly :) , will refine it and post it though) – yazan sayed Sep 06 '22 at 10:02
  • meant from `pts_orig[1]` to `pts_orig[0]` , or the other end `pts_orig[-2]` to `pts_orig[-1]` – yazan sayed Sep 06 '22 at 10:20
  • @AKX added link to notebook with full code – yazan sayed Sep 06 '22 at 11:26
  • A link to a notebook is effectively a "link-only answer". (Think about what will happen if the Colab file is deleted.) – AKX Sep 06 '22 at 11:29
  • @AKX , oh you're right, will add code here – yazan sayed Sep 06 '22 at 11:30
  • I exported the code from your colab – I think there are still "no-op" things that would only make sense in a cell. – AKX Sep 06 '22 at 11:31
0

If the goal is to just unshift each column, then:

import numpy as np
from PIL import Image

source_img = Image.open("73614379-input-v2.png")
contour_img = Image.open("73614379-map-v3.png").convert("L")
assert source_img.size == contour_img.size

contour_arr = np.array(contour_img) != 0  # convert to boolean array
col_offsets = np.argmax(
    contour_arr, axis=0
)  # find the first non-zero row for each column
assert len(col_offsets) == source_img.size[0]  # sanity check
min_nonzero_col_offset = np.min(
    col_offsets[col_offsets > 0]
)  # find the minimum non-zero row

target_img = Image.new("RGB", source_img.size, (255, 255, 255))
for x, col_offset in enumerate(col_offsets):
    offset = col_offset - min_nonzero_col_offset if col_offset > 0 else 0
    target_img.paste(
        source_img.crop((x, offset, x + 1, source_img.size[1])), (x, 0)
    )

target_img.save("unshifted3.png")

with the new input and the new contour from OP outputs this image:

enter image description here

AKX
  • 152,115
  • 15
  • 115
  • 172