refactor: split ImageProcessing into specialized modules and fix corruption
This commit is contained in:
parent
3f3123c5af
commit
36ef5c520d
4 changed files with 185 additions and 158 deletions
|
|
@ -2,6 +2,7 @@ import { Card } from '../../types';
|
||||||
import { cardModelService } from '../../services/CardModelService';
|
import { cardModelService } from '../../services/CardModelService';
|
||||||
import { CentroidTracker } from '../../utils/Tracker';
|
import { CentroidTracker } from '../../utils/Tracker';
|
||||||
import { ImageProcessing } from '../../utils/ImageProcessing';
|
import { ImageProcessing } from '../../utils/ImageProcessing';
|
||||||
|
import { ImageGeometry } from '../../utils/image-geometry';
|
||||||
import { getMostCommon, detectCardSuit, detectCardValue } from './DetectionUtils';
|
import { getMostCommon, detectCardSuit, detectCardValue } from './DetectionUtils';
|
||||||
|
|
||||||
export class DetectionPipeline {
|
export class DetectionPipeline {
|
||||||
|
|
@ -12,15 +13,15 @@ export class DetectionPipeline {
|
||||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
|
||||||
const edges = ImageProcessing.detectEdges(imageData);
|
const edges = ImageProcessing.detectEdges(imageData);
|
||||||
const cardPolygons = ImageProcessing.findRectangularRegions(edges, canvas.width, canvas.height);
|
const cardPolygons = ImageGeometry.findRectangularRegions(edges, canvas.width, canvas.height);
|
||||||
|
|
||||||
const detectedCards: Card[] = [];
|
const detectedCards: Card[] = [];
|
||||||
|
|
||||||
for (let i = 0; i < cardPolygons.length; i++) {
|
for (let i = 0; i < cardPolygons.length; i++) {
|
||||||
const polygon = cardPolygons[i];
|
const polygon = cardPolygons[i];
|
||||||
|
|
||||||
const corners = ImageProcessing.findCorners(polygon.points);
|
const corners = ImageGeometry.findCorners(polygon.points);
|
||||||
const cardCrop = ImageProcessing.warpPerspective(canvas, corners, 128, 192);
|
const cardCrop = ImageGeometry.warpPerspective(canvas, corners, 128, 192);
|
||||||
|
|
||||||
let suit: 'Schellen' | 'Schilten' | 'Eicheln' | 'Rosen';
|
let suit: 'Schellen' | 'Schilten' | 'Eicheln' | 'Rosen';
|
||||||
let value: number;
|
let value: number;
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,4 @@
|
||||||
export interface Rect {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
width: number;
|
|
||||||
height: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Point {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Polygon {
|
|
||||||
points: Point[];
|
|
||||||
bbox: Rect;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ImageProcessing {
|
export class ImageProcessing {
|
||||||
/**
|
|
||||||
* Simple Sobel filter to detect edges in a grayscale image
|
|
||||||
*/
|
|
||||||
static computeGradients(data: Uint8ClampedArray, width: number, height: number): { magnitude: Float32Array, direction: Float32Array } {
|
static computeGradients(data: Uint8ClampedArray, width: number, height: number): { magnitude: Float32Array, direction: Float32Array } {
|
||||||
const magnitude = new Float32Array(width * height);
|
const magnitude = new Float32Array(width * height);
|
||||||
const direction = new Float32Array(width * height);
|
const direction = new Float32Array(width * height);
|
||||||
|
|
@ -81,105 +61,6 @@ export class ImageProcessing {
|
||||||
return suppressed;
|
return suppressed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Find rectangular regions based on edge strength
|
|
||||||
*/
|
|
||||||
static findRectangularRegions(edges: Float32Array, width: number, height: number, threshold: number = 50): Polygon[] {
|
|
||||||
const regions: Polygon[] = [];
|
|
||||||
const visited = new Uint8Array(width * height);
|
|
||||||
|
|
||||||
for (let y = 0; y < height; y++) {
|
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
if (edges[y * width + x] > threshold && !visited[y * width + x]) {
|
|
||||||
const region = this.expandRegion(edges, visited, x, y, width, height, threshold);
|
|
||||||
if (region && region.bbox.width > 50 && region.bbox.height > 80) {
|
|
||||||
regions.push(region);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return regions;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static expandRegion(edges: Float32Array, visited: Uint8Array, startX: number, startY: number, width: number, height: number, threshold: number): Polygon | null {
|
|
||||||
let minX = startX, maxX = startX;
|
|
||||||
let minY = startY, maxY = startY;
|
|
||||||
const points: Point[] = [];
|
|
||||||
|
|
||||||
const stack = [[startX, startY]];
|
|
||||||
visited[startY * width + startX] = 1;
|
|
||||||
|
|
||||||
while (stack.length > 0) {
|
|
||||||
const [cx, cy] = stack.pop()!;
|
|
||||||
|
|
||||||
minX = Math.min(minX, cx);
|
|
||||||
maxX = Math.max(maxX, cx);
|
|
||||||
minY = Math.min(minY, cy);
|
|
||||||
maxY = Math.max(maxY, cy);
|
|
||||||
points.push({ x: cx, y: cy });
|
|
||||||
|
|
||||||
const neighbors = [
|
|
||||||
[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [nx, ny] of neighbors) {
|
|
||||||
if (nx >= 0 && nx < width && ny >= 0 && ny < height &&
|
|
||||||
!visited[ny * width + nx] && edges[ny * width + nx] > threshold) {
|
|
||||||
visited[ny * width + nx] = 1;
|
|
||||||
stack.push([nx, ny]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const w = maxX - minX;
|
|
||||||
const h = maxY - minY;
|
|
||||||
|
|
||||||
if (w < 50 || h < 80) return null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
points,
|
|
||||||
bbox: { x: minX, y: minY, width: w, height: h }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Find the 4 corners of a point set that most closely resemble a rectangle
|
|
||||||
*/
|
|
||||||
static findCorners(points: Point[]): Point[] {
|
|
||||||
if (points.length < 4) return [];
|
|
||||||
|
|
||||||
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
|
||||||
for (const p of points) {
|
|
||||||
if (p.x < minX) minX = p.x;
|
|
||||||
if (p.x > maxX) maxX = p.x;
|
|
||||||
if (p.y < minY) minY = p.y;
|
|
||||||
if (p.y > maxY) maxY = p.y;
|
|
||||||
}
|
|
||||||
|
|
||||||
const targets = [
|
|
||||||
{ x: minX, y: minY },
|
|
||||||
{ x: maxX, y: minY },
|
|
||||||
{ x: maxX, y: maxY },
|
|
||||||
{ x: minX, y: maxY },
|
|
||||||
];
|
|
||||||
|
|
||||||
const corners: Point[] = [];
|
|
||||||
for (const target of targets) {
|
|
||||||
let closest = points[0];
|
|
||||||
let minDist = Infinity;
|
|
||||||
for (const p of points) {
|
|
||||||
const dist = Math.sqrt((p.x - target.x) ** 2 + (p.y - target.y) ** 2);
|
|
||||||
if (dist < minDist) {
|
|
||||||
minDist = dist;
|
|
||||||
closest = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
corners.push(closest);
|
|
||||||
}
|
|
||||||
|
|
||||||
return corners;
|
|
||||||
}
|
|
||||||
|
|
||||||
static applyHysteresis(edges: Float32Array, width: number, height: number, lowThreshold: number, highThreshold: number): Float32Array {
|
static applyHysteresis(edges: Float32Array, width: number, height: number, lowThreshold: number, highThreshold: number): Float32Array {
|
||||||
const result = new Float32Array(width * height);
|
const result = new Float32Array(width * height);
|
||||||
const strongEdges = [];
|
const strongEdges = [];
|
||||||
|
|
@ -217,45 +98,24 @@ export class ImageProcessing {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static warpPerspective(sourceCanvas: HTMLCanvasElement, srcCorners: Point[], destWidth: number, destHeight: number): HTMLCanvasElement {
|
static rgbToHsv(r: number, g: number, b: number): { h: number, s: number, v: number } {
|
||||||
const destCanvas = document.createElement('canvas');
|
r /= 255; g /= 255; b /= 255;
|
||||||
destCanvas.width = destWidth;
|
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||||
destCanvas.height = destHeight;
|
let h = 0, s, v = max;
|
||||||
const destCtx = destCanvas.getContext('2d');
|
const d = max - min;
|
||||||
if (!destCtx) return destCanvas;
|
s = max === 0 ? 0 : d / max;
|
||||||
|
|
||||||
const srcCtx = sourceCanvas.getContext('2d');
|
if (max === min) {
|
||||||
if (!srcCtx) return destCanvas;
|
h = 0;
|
||||||
|
} else {
|
||||||
const srcData = srcCtx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height).data;
|
switch (max) {
|
||||||
const destData = destCtx.createImageData(destWidth, destHeight);
|
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||||
|
case g: h = (b - r) / d + 2; break;
|
||||||
for (let y = 0; y < destHeight; y++) {
|
case b: h = (r - g) / d + 4; break;
|
||||||
for (let x = 0; x < destWidth; x++) {
|
|
||||||
const u = x / destWidth;
|
|
||||||
const v = y / destHeight;
|
|
||||||
|
|
||||||
const srcX = (1 - u) * ((1 - v) * srcCorners[0].x + v * srcCorners[3].x) +
|
|
||||||
u * ((1 - v) * srcCorners[1].x + v * srcCorners[2].x);
|
|
||||||
const srcY = (1 - u) * ((1 - v) * srcCorners[0].y + v * srcCorners[3].y) +
|
|
||||||
u * ((1 - v) * srcCorners[1].y + v * srcCorners[2].y);
|
|
||||||
|
|
||||||
const sx = Math.floor(srcX);
|
|
||||||
const sy = Math.floor(srcY);
|
|
||||||
|
|
||||||
if (sx >= 0 && sx < sourceCanvas.width && sy >= 0 && sy < sourceCanvas.height) {
|
|
||||||
const srcIdx = (sy * sourceCanvas.width + sx) * 4;
|
|
||||||
const destIdx = (y * destWidth + x) * 4;
|
|
||||||
destData.data[destIdx] = srcData[srcIdx];
|
|
||||||
destData.data[destIdx + 1] = srcData[srcIdx + 1];
|
|
||||||
destData.data[destIdx + 2] = srcData[srcIdx + 2];
|
|
||||||
destData.data[destIdx + 3] = srcData[srcIdx + 3];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
h /= 6;
|
||||||
}
|
}
|
||||||
|
return { h: h * 360, s: s * 100, v: v * 100 };
|
||||||
destCtx.putImageData(destData, 0, 0);
|
|
||||||
return destCanvas;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static toGrayscale(imageData: ImageData): Uint8ClampedArray {
|
static toGrayscale(imageData: ImageData): Uint8ClampedArray {
|
||||||
|
|
|
||||||
150
src/utils/image-geometry.ts
Normal file
150
src/utils/image-geometry.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
||||||
|
import { Rect, Point, Polygon } from './image-types';
|
||||||
|
|
||||||
|
export class ImageGeometry {
|
||||||
|
/**
|
||||||
|
* Find rectangular regions based on edge strength
|
||||||
|
*/
|
||||||
|
static findRectangularRegions(edges: Float32Array, width: number, height: number, threshold: number = 50): Polygon[] {
|
||||||
|
const regions: Polygon[] = [];
|
||||||
|
const visited = new Uint8Array(width * height);
|
||||||
|
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
for (let x = 0; x < width; x++) {
|
||||||
|
if (edges[y * width + x] > threshold && !visited[y * width + x]) {
|
||||||
|
const region = this.expandRegion(edges, visited, x, y, width, height, threshold);
|
||||||
|
if (region) {
|
||||||
|
const aspectRatio = region.bbox.width / region.bbox.height;
|
||||||
|
// Card aspect ratio is roughly 0.6-0.8 (standard) or 1.2-1.6 (flipped)
|
||||||
|
const isValidRatio = (aspectRatio > 0.5 && aspectRatio < 0.9) || (aspectRatio > 1.1 && aspectRatio < 1.8);
|
||||||
|
const isLargeEnough = region.bbox.width > 40 && region.bbox.height > 60;
|
||||||
|
|
||||||
|
if (isValidRatio && isLargeEnough) {
|
||||||
|
regions.push(region);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return regions;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static expandRegion(edges: Float32Array, visited: Uint8Array, startX: number, startY: number, width: number, height: number, threshold: number): Polygon | null {
|
||||||
|
let minX = startX, maxX = startX;
|
||||||
|
let minY = startY, maxY = startY;
|
||||||
|
const points: Point[] = [];
|
||||||
|
|
||||||
|
const stack = [[startX, startY]];
|
||||||
|
visited[startY * width + startX] = 1;
|
||||||
|
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const [cx, cy] = stack.pop()!;
|
||||||
|
|
||||||
|
minX = Math.min(minX, cx);
|
||||||
|
maxX = Math.max(maxX, cx);
|
||||||
|
minY = Math.min(minY, cy);
|
||||||
|
maxY = Math.max(maxY, cy);
|
||||||
|
points.push({ x: cx, y: cy });
|
||||||
|
|
||||||
|
const neighbors = [
|
||||||
|
[cx + 1, cy], [cx - 1, cy], [cx, cy + 1], [cx, cy - 1]
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [nx, ny] of neighbors) {
|
||||||
|
if (nx >= 0 && nx < width && ny >= 0 && ny < height &&
|
||||||
|
!visited[ny * width + nx] && edges[ny * width + nx] > threshold) {
|
||||||
|
visited[ny * width + nx] = 1;
|
||||||
|
stack.push([nx, ny]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const w = maxX - minX;
|
||||||
|
const h = maxY - minY;
|
||||||
|
|
||||||
|
if (w < 50 || h < 80) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
bbox: { x: minX, y: minY, width: w, height: h }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the 4 corners of a point set that most closely resemble a rectangle
|
||||||
|
*/
|
||||||
|
static findCorners(points: Point[]): Point[] {
|
||||||
|
if (points.length < 4) return [];
|
||||||
|
|
||||||
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
|
||||||
|
for (const p of points) {
|
||||||
|
if (p.x < minX) minX = p.x;
|
||||||
|
if (p.x > maxX) maxX = p.x;
|
||||||
|
if (p.y < minY) minY = p.y;
|
||||||
|
if (p.y > maxY) maxY = p.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = [
|
||||||
|
{ x: minX, y: minY },
|
||||||
|
{ x: maxX, y: minY },
|
||||||
|
{ x: maxX, y: maxY },
|
||||||
|
{ x: minX, y: maxY },
|
||||||
|
];
|
||||||
|
|
||||||
|
const corners: Point[] = [];
|
||||||
|
for (const target of targets) {
|
||||||
|
let closest = points[0];
|
||||||
|
let minDist = Infinity;
|
||||||
|
for (const p of points) {
|
||||||
|
const dist = Math.sqrt((p.x - target.x) ** 2 + (p.y - target.y) ** 2);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closest = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
corners.push(closest);
|
||||||
|
}
|
||||||
|
|
||||||
|
return corners;
|
||||||
|
}
|
||||||
|
|
||||||
|
static warpPerspective(sourceCanvas: HTMLCanvasElement, srcCorners: Point[], destWidth: number, destHeight: number): HTMLCanvasElement {
|
||||||
|
const destCanvas = document.createElement('canvas');
|
||||||
|
destCanvas.width = destWidth;
|
||||||
|
destCanvas.height = destHeight;
|
||||||
|
const destCtx = destCanvas.getContext('2d');
|
||||||
|
if (!destCtx) return destCanvas;
|
||||||
|
|
||||||
|
const srcCtx = sourceCanvas.getContext('2d');
|
||||||
|
if (!srcCtx) return destCanvas;
|
||||||
|
|
||||||
|
const srcData = srcCtx.getImageData(0, 0, sourceCanvas.width, sourceCanvas.height).data;
|
||||||
|
const destData = destCtx.createImageData(destWidth, destHeight);
|
||||||
|
|
||||||
|
for (let y = 0; y < destHeight; y++) {
|
||||||
|
for (let x = 0; x < destWidth; x++) {
|
||||||
|
const u = x / destWidth;
|
||||||
|
const v = y / destHeight;
|
||||||
|
|
||||||
|
const srcX = (1 - u) * ((1 - v) * srcCorners[0].x + v * srcCorners[3].x) +
|
||||||
|
u * ((1 - v) * srcCorners[1].x + v * srcCorners[2].x);
|
||||||
|
const srcY = (1 - u) * ((1 - v) * srcCorners[0].y + v * srcCorners[3].y) +
|
||||||
|
u * ((1 - v) * srcCorners[1].y + v * srcCorners[2].y);
|
||||||
|
|
||||||
|
const sx = Math.floor(srcX);
|
||||||
|
const sy = Math.floor(srcY);
|
||||||
|
|
||||||
|
if (sx >= 0 && sx < sourceCanvas.width && sy >= 0 && sy < sourceCanvas.height) {
|
||||||
|
const srcIdx = (sy * sourceCanvas.width + sx) * 4;
|
||||||
|
const destIdx = (y * destWidth + x) * 4;
|
||||||
|
destData.data[destIdx] = srcData[srcIdx];
|
||||||
|
destData.data[destIdx + 1] = srcData[srcIdx + 1];
|
||||||
|
destData.data[destIdx + 2] = srcData[srcIdx + 2];
|
||||||
|
destData.data[destIdx + 3] = srcData[srcIdx + 3];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
destCtx.putImageData(destData, 0, 0);
|
||||||
|
return destCanvas;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
src/utils/image-types.ts
Normal file
16
src/utils/image-types.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
export interface Rect {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Polygon {
|
||||||
|
points: Point[];
|
||||||
|
bbox: Rect;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue