-
Notifications
You must be signed in to change notification settings - Fork 6
feat: implement perspective warp #484
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
Merged
Merged
Changes from 10 commits
Commits
Show all changes
17 commits
Select commit
Hold shift + click to select a range
cc4d5b6
feat: implement warping from image-js
EscapedGibbon 2a523c9
test: add tests for perspectiveWarp
EscapedGibbon 9f63ba7
test: add testing case for openCV
EscapedGibbon 04520ab
refactor: modify perspectiveWarp and transform function
EscapedGibbon d371091
fix: fix other functions to accept 3x3
EscapedGibbon b2f4130
test: simplify failing test
EscapedGibbon ec3ceb3
test: add and modify tests for coverage
EscapedGibbon 8eb8297
fix: fix bug and modify tests for coverage
EscapedGibbon 6de5168
feat: add different return type for perspective warp
EscapedGibbon be89641
test: modify and add tests for coverage
EscapedGibbon e03ad0b
fix: resolve conversation
EscapedGibbon 67ee781
fix: fix imports and exports
EscapedGibbon 193a8d8
Merge remote-tracking branch 'origin/main' into 461-implement-perspec…
EscapedGibbon 8e2a7c7
fix: move uint8-base64 package.json
EscapedGibbon 8feea4d
fix: move non-test files from __tests__ folder
EscapedGibbon 2469e0c
fix: resolve conversations
EscapedGibbon f0dae9b
docs: fix description phrasing
EscapedGibbon File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,218 @@ | ||
import { Image } from '../../Image.js'; | ||
import getPerspectiveWarp, { order4Points } from '../getPerspectiveWarp.js'; | ||
|
||
describe('4 points sorting', () => { | ||
test('basic sorting test', () => { | ||
const points = [ | ||
{ column: 0, row: 100 }, | ||
{ column: 0, row: 0 }, | ||
{ column: 100, row: 1 }, | ||
{ column: 100, row: 100 }, | ||
]; | ||
|
||
const result = order4Points(points); | ||
expect(result).toEqual([ | ||
{ column: 0, row: 0 }, | ||
{ column: 100, row: 1 }, | ||
{ column: 100, row: 100 }, | ||
{ column: 0, row: 100 }, | ||
]); | ||
}); | ||
test('inclined square', () => { | ||
const points = [ | ||
{ column: 45, row: 0 }, | ||
{ column: 0, row: 45 }, | ||
{ column: 45, row: 90 }, | ||
{ column: 90, row: 45 }, | ||
]; | ||
|
||
const result = order4Points(points); | ||
expect(result).toEqual([ | ||
{ column: 0, row: 45 }, | ||
{ column: 90, row: 45 }, | ||
{ column: 45, row: 0 }, | ||
{ column: 45, row: 90 }, | ||
]); | ||
}); | ||
test('basic sorting test', () => { | ||
const points = [ | ||
{ column: 155, row: 195 }, | ||
{ column: 154, row: 611 }, | ||
{ column: 858.5, row: 700 }, | ||
{ column: 911.5, row: 786 }, | ||
]; | ||
|
||
const result = order4Points(points); | ||
expect(result).toEqual([ | ||
{ column: 155, row: 195 }, | ||
|
||
{ column: 858.5, row: 700 }, | ||
{ column: 911.5, row: 786 }, | ||
{ column: 154, row: 611 }, | ||
]); | ||
}); | ||
}); | ||
|
||
describe('warping tests', () => { | ||
it('resize without rotation', () => { | ||
const image = new Image(3, 3, { | ||
data: new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8, 9]), | ||
colorModel: 'GREY', | ||
}); | ||
const points = [ | ||
{ column: 0, row: 0 }, | ||
{ column: 2, row: 0 }, | ||
{ column: 1, row: 2 }, | ||
{ column: 0, row: 2 }, | ||
]; | ||
const matrix = getPerspectiveWarp(points); | ||
const result = image.transform(matrix.matrix, { inverse: true }); | ||
expect(result.width).not.toBeLessThan(2); | ||
expect(result.height).not.toBeLessThan(2); | ||
expect(result.width).not.toBeGreaterThan(3); | ||
expect(result.height).not.toBeGreaterThan(3); | ||
}); | ||
it('resize without rotation 2', () => { | ||
const image = new Image(4, 4, { | ||
data: new Uint8Array([ | ||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, | ||
]), | ||
colorModel: 'GREY', | ||
}); | ||
|
||
const points = [ | ||
{ column: 0, row: 0 }, | ||
{ column: 3, row: 0 }, | ||
{ column: 2, row: 1 }, | ||
{ column: 0, row: 1 }, | ||
]; | ||
const matrix = getPerspectiveWarp(points); | ||
const result = image.transform(matrix.matrix, { inverse: true }); | ||
expect(result.width).not.toBeLessThan(3); | ||
expect(result.height).not.toBeLessThan(1); | ||
expect(result.width).not.toBeGreaterThan(4); | ||
expect(result.height).not.toBeGreaterThan(4); | ||
}); | ||
}); | ||
|
||
describe('openCV comparison', () => { | ||
test('nearest interpolation plants', () => { | ||
const image = testUtils.load('various/plants.png'); | ||
const openCvResult = testUtils.load( | ||
'opencv/test_perspective_warp_plants_nearest.png', | ||
); | ||
|
||
const points = [ | ||
{ column: 858.5, row: 9 }, | ||
{ column: 911.5, row: 786 }, | ||
{ column: 154.5, row: 611 }, | ||
{ column: 166.5, row: 195 }, | ||
]; | ||
const matrix = getPerspectiveWarp(points, { | ||
width: 1080, | ||
height: 810, | ||
}); | ||
const result = image.transform(matrix.matrix, { | ||
inverse: true, | ||
interpolationType: 'nearest', | ||
}); | ||
const croppedPieceOpenCv = openCvResult.crop({ | ||
origin: { column: 45, row: 0 }, | ||
width: 100, | ||
height: 100, | ||
}); | ||
|
||
const croppedPiece = result.crop({ | ||
origin: { column: 45, row: 0 }, | ||
width: 100, | ||
height: 100, | ||
}); | ||
|
||
expect(result.width).toEqual(openCvResult.width); | ||
expect(result.height).toEqual(openCvResult.height); | ||
expect(croppedPiece).toEqual(croppedPieceOpenCv); | ||
}); | ||
|
||
test('nearest interpolation card', () => { | ||
const image = testUtils.load('various/card.png'); | ||
const openCvResult = testUtils.load( | ||
'opencv/test_perspective_warp_card_nearest.png', | ||
); | ||
const points = [ | ||
{ column: 55, row: 140 }, | ||
{ column: 680, row: 38 }, | ||
{ column: 840, row: 340 }, | ||
{ column: 145, row: 460 }, | ||
]; | ||
const matrix = getPerspectiveWarp(points, { | ||
width: 700, | ||
height: 400, | ||
}); | ||
const result = image.transform(matrix.matrix, { | ||
inverse: true, | ||
interpolationType: 'nearest', | ||
width: 700, | ||
height: 400, | ||
}); | ||
const croppedPieceOpenCv = openCvResult.crop({ | ||
origin: { column: 45, row: 0 }, | ||
width: 5, | ||
height: 5, | ||
}); | ||
|
||
const croppedPiece = result.crop({ | ||
origin: { column: 45, row: 0 }, | ||
width: 5, | ||
height: 5, | ||
}); | ||
|
||
expect(result.width).toEqual(openCvResult.width); | ||
expect(result.height).toEqual(openCvResult.height); | ||
expect(croppedPiece).toEqual(croppedPieceOpenCv); | ||
}); | ||
test('nearest interpolation poker card', () => { | ||
const image = testUtils.load('various/poker_cards.png'); | ||
const openCvResult = testUtils.load( | ||
'opencv/test_perspective_warp_poker_cards_nearest.png', | ||
); | ||
|
||
const points = [ | ||
{ column: 1100, row: 660 }, | ||
{ column: 680, row: 660 }, | ||
{ column: 660, row: 290 }, | ||
{ column: 970, row: 290 }, | ||
]; | ||
const matrix = getPerspectiveWarp(points); | ||
const result = image.transform(matrix.matrix, { | ||
inverse: true, | ||
interpolationType: 'nearest', | ||
height: matrix.height, | ||
width: matrix.width, | ||
}); | ||
|
||
const cropped = result.crop({ | ||
origin: { column: 10, row: 10 }, | ||
width: 100, | ||
height: 100, | ||
}); | ||
const croppedCV = openCvResult.crop({ | ||
origin: { column: 10, row: 10 }, | ||
width: 100, | ||
height: 100, | ||
}); | ||
|
||
expect(result.width).toEqual(openCvResult.width); | ||
expect(result.height).toEqual(openCvResult.height); | ||
expect(cropped).toEqual(croppedCV); | ||
}); | ||
}); | ||
|
||
describe('error testing', () => { | ||
test("should throw if there aren't 4 points", () => { | ||
expect(() => { | ||
getPerspectiveWarp([{ column: 1, row: 1 }]); | ||
}).toThrow( | ||
'The array pts must have four elements, which are the four corners. Currently, pts have 1 elements', | ||
); | ||
}); | ||
}); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,161 @@ | ||
import { Matrix, SingularValueDecomposition } from 'ml-matrix'; | ||
|
||
import type { Point } from '../utils/geometry/points.js'; | ||
|
||
interface GetPerspectiveWarpOptions { | ||
/** | ||
* The horizontal dimension (in pixels) of the final rectified rectangular image. | ||
*/ | ||
width?: number; | ||
/** | ||
* The vertical dimension (in pixels) of the final rectified rectangular image. | ||
*/ | ||
height?: number; | ||
} | ||
/** | ||
* Returns result matrix along with vertical and horizontal dimensions for the rectangular image. | ||
*/ | ||
type GetPerspectiveWarpData = Required<GetPerspectiveWarpOptions> & { | ||
matrix: number[][]; | ||
}; | ||
|
||
// REFERENCES : | ||
// https://stackoverflow.com/questions/38285229/calculating-aspect-ratio-of-perspective-transform-destination-image/38402378#38402378 | ||
// http://www.corrmap.com/features/homography_transformation.php | ||
// https://ags.cs.uni-kl.de/fileadmin/inf_ags/3dcv-ws11-12/3DCV_WS11-12_lec04.pdf | ||
// http://graphics.cs.cmu.edu/courses/15-463/2011_fall/Lectures/morphing.pdf | ||
/** | ||
* Returns perspective warp matrix from 4 points. | ||
* @param pts - 4 reference corners of the new image. | ||
* @param options - PerspectiveWarpOptions | ||
* @returns - Matrix from 4 points. | ||
*/ | ||
export default function getPerspectiveWarp( | ||
pts: Point[], | ||
options: GetPerspectiveWarpOptions = {}, | ||
): GetPerspectiveWarpData { | ||
if (pts.length !== 4) { | ||
throw new Error( | ||
`The array pts must have four elements, which are the four corners. Currently, pts have ${pts.length} elements`, | ||
); | ||
} | ||
const { width, height } = options; | ||
const [tl, tr, br, bl] = order4Points(pts); | ||
|
||
let widthRect; | ||
let heightRect; | ||
if (height && width) { | ||
EscapedGibbon marked this conversation as resolved.
Show resolved
Hide resolved
|
||
widthRect = width; | ||
heightRect = height; | ||
} else { | ||
widthRect = Math.ceil( | ||
Math.max(distance2Points(tl, tr), distance2Points(bl, br)), | ||
); | ||
heightRect = Math.ceil( | ||
Math.max(distance2Points(tl, bl), distance2Points(tr, br)), | ||
); | ||
} | ||
|
||
const [x1, y1] = [0, 0]; | ||
const [x2, y2] = [widthRect - 1, 0]; | ||
const [x3, y3] = [widthRect - 1, heightRect - 1]; | ||
const [x4, y4] = [0, heightRect - 1]; | ||
|
||
const S = new Matrix([ | ||
[x1, y1, 1, 0, 0, 0, -x1 * tl.column, -y1 * tl.column], | ||
[x2, y2, 1, 0, 0, 0, -x2 * tr.column, -y2 * tr.column], | ||
[x3, y3, 1, 0, 0, 0, -x3 * br.column, -y3 * br.column], | ||
[x4, y4, 1, 0, 0, 0, -x4 * bl.column, -y4 * bl.column], | ||
[0, 0, 0, x1, y1, 1, -x1 * tl.row, -y1 * tl.row], | ||
[0, 0, 0, x2, y2, 1, -x2 * tr.row, -y2 * tr.row], | ||
[0, 0, 0, x3, y3, 1, -x3 * br.row, -y3 * br.row], | ||
[0, 0, 0, x4, y4, 1, -x4 * bl.row, -y4 * bl.row], | ||
]); | ||
const D = Matrix.columnVector([ | ||
tl.column, | ||
tr.column, | ||
br.column, | ||
bl.column, | ||
tl.row, | ||
tr.row, | ||
br.row, | ||
bl.row, | ||
]); | ||
|
||
const svd = new SingularValueDecomposition(S); | ||
const T = svd.solve(D).to1DArray(); // solve S*T = D | ||
T.push(1); | ||
|
||
const M = []; | ||
for (let i = 0; i < 3; i++) { | ||
const row = []; | ||
for (let j = 0; j < 3; j++) { | ||
row.push(T[i * 3 + j]); | ||
} | ||
M.push(row); | ||
} | ||
return { matrix: M, width: widthRect, height: heightRect }; | ||
} | ||
|
||
/** | ||
* Sorts 4 points in order =>[top-left,top-right,bottom-right,bottom-left]. Input points must be in clockwise or counter-clockwise order. | ||
* @param pts - Array of 4 points. | ||
* @returns Sorted array of 4 points. | ||
*/ | ||
export function order4Points(pts: Point[]) { | ||
let tl: Point; | ||
let tr: Point; | ||
let br: Point; | ||
let bl: Point; | ||
|
||
let minX = pts[0].column; | ||
let indexMinX = 0; | ||
|
||
for (let i = 1; i < pts.length; i++) { | ||
if (pts[i].column < minX) { | ||
minX = pts[i].column; | ||
indexMinX = i; | ||
} | ||
} | ||
|
||
let minX2 = pts[(indexMinX + 1) % pts.length].column; | ||
let indexMinX2 = (indexMinX + 1) % pts.length; | ||
|
||
for (let i = 0; i < pts.length; i++) { | ||
if (pts[i].column < minX2 && i !== indexMinX) { | ||
minX2 = pts[i].column; | ||
indexMinX2 = i; | ||
} | ||
} | ||
if (pts[indexMinX2].row < pts[indexMinX].row) { | ||
tl = pts[indexMinX2]; | ||
bl = pts[indexMinX]; | ||
if (indexMinX !== (indexMinX2 + 1) % 4) { | ||
tr = pts[(indexMinX2 + 1) % 4]; | ||
br = pts[(indexMinX2 + 2) % 4]; | ||
} else { | ||
tr = pts[(indexMinX2 + 2) % 4]; | ||
br = pts[(indexMinX2 + 3) % 4]; | ||
} | ||
} else { | ||
bl = pts[indexMinX2]; | ||
tl = pts[indexMinX]; | ||
if (indexMinX2 !== (indexMinX + 1) % 4) { | ||
tr = pts[(indexMinX + 1) % 4]; | ||
br = pts[(indexMinX + 2) % 4]; | ||
} else { | ||
tr = pts[(indexMinX + 2) % 4]; | ||
br = pts[(indexMinX + 3) % 4]; | ||
} | ||
} | ||
return [tl, tr, br, bl]; | ||
} | ||
/** | ||
* Calculates distance between points. | ||
* @param p1 - Point1 | ||
* @param p2 - Point2 | ||
* @returns distance between points. | ||
*/ | ||
function distance2Points(p1: Point, p2: Point) { | ||
return Math.hypot(p1.column - p2.column, p1.row - p2.row); | ||
} |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.