diff --git a/src/components/Detection/DetectionLogic.ts b/src/components/Detection/DetectionLogic.ts index fa3aafd..e782518 100644 --- a/src/components/Detection/DetectionLogic.ts +++ b/src/components/Detection/DetectionLogic.ts @@ -2,6 +2,7 @@ import { Card } from '../../types'; import { cardModelService } from '../../services/CardModelService'; import { CentroidTracker } from '../../utils/Tracker'; import { ImageProcessing } from '../../utils/ImageProcessing'; +import { ImageGeometry } from '../../utils/image-geometry'; import { getMostCommon, detectCardSuit, detectCardValue } from './DetectionUtils'; export class DetectionPipeline { @@ -12,15 +13,15 @@ export class DetectionPipeline { const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); 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[] = []; for (let i = 0; i < cardPolygons.length; i++) { const polygon = cardPolygons[i]; - const corners = ImageProcessing.findCorners(polygon.points); - const cardCrop = ImageProcessing.warpPerspective(canvas, corners, 128, 192); + const corners = ImageGeometry.findCorners(polygon.points); + const cardCrop = ImageGeometry.warpPerspective(canvas, corners, 128, 192); let suit: 'Schellen' | 'Schilten' | 'Eicheln' | 'Rosen'; let value: number; diff --git a/src/utils/ImageProcessing.ts b/src/utils/ImageProcessing.ts index 5e382df..f9cd9f7 100644 --- a/src/utils/ImageProcessing.ts +++ b/src/utils/ImageProcessing.ts @@ -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 { - /** - * Simple Sobel filter to detect edges in a grayscale image - */ static computeGradients(data: Uint8ClampedArray, width: number, height: number): { magnitude: Float32Array, direction: Float32Array } { const magnitude = new Float32Array(width * height); const direction = new Float32Array(width * height); @@ -81,105 +61,6 @@ export class ImageProcessing { 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 { const result = new Float32Array(width * height); const strongEdges = []; @@ -217,45 +98,24 @@ export class ImageProcessing { return result; } - 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; + static rgbToHsv(r: number, g: number, b: number): { h: number, s: number, v: number } { + r /= 255; g /= 255; b /= 255; + const max = Math.max(r, g, b), min = Math.min(r, g, b); + let h = 0, s, v = max; + const d = max - min; + s = max === 0 ? 0 : d / max; - 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]; - } + if (max === min) { + h = 0; + } else { + switch (max) { + case r: h = (g - b) / d + (g < b ? 6 : 0); break; + case g: h = (b - r) / d + 2; break; + case b: h = (r - g) / d + 4; break; } + h /= 6; } - - destCtx.putImageData(destData, 0, 0); - return destCanvas; + return { h: h * 360, s: s * 100, v: v * 100 }; } static toGrayscale(imageData: ImageData): Uint8ClampedArray { diff --git a/src/utils/image-geometry.ts b/src/utils/image-geometry.ts new file mode 100644 index 0000000..f148660 --- /dev/null +++ b/src/utils/image-geometry.ts @@ -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; + } +} diff --git a/src/utils/image-types.ts b/src/utils/image-types.ts new file mode 100644 index 0000000..dac287b --- /dev/null +++ b/src/utils/image-types.ts @@ -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; +}