namespace OcrDaemon; using System.Drawing; using System.Drawing.Imaging; using System.Runtime.InteropServices; class EdgeCropHandler { [StructLayout(LayoutKind.Sequential)] private struct POINT { public int X, Y; } [DllImport("user32.dll")] private static extern bool GetCursorPos(out POINT lpPoint); public (Bitmap cropped, Bitmap fullCapture, RegionRect region)? EdgeCrop(Request req, EdgeCropParams p) { int cursorX, cursorY; if (req.CursorX.HasValue && req.CursorY.HasValue) { cursorX = req.CursorX.Value; cursorY = req.CursorY.Value; } else { GetCursorPos(out var pt); cursorX = pt.X; cursorY = pt.Y; } var fullCapture = ScreenCapture.CaptureOrLoad(req.File, null); int w = fullCapture.Width; int h = fullCapture.Height; cursorX = Math.Clamp(cursorX, 0, w - 1); cursorY = Math.Clamp(cursorY, 0, h - 1); var bmpData = fullCapture.LockBits(new Rectangle(0, 0, w, h), ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb); byte[] px = new byte[bmpData.Stride * h]; Marshal.Copy(bmpData.Scan0, px, 0, px.Length); fullCapture.UnlockBits(bmpData); int stride = bmpData.Stride; int darkThresh = p.DarkThresh; int colGap = p.RunGapTolerance; int maxGap = p.MaxGap; // ── Phase 1: Per-row horizontal extent ── // Scan left/right from cursorX per row. Gap tolerance bridges through text. // Percentile-based filtering for robustness. int bandHalf = p.MinDarkRun; // repurpose: half-height of horizontal scan band int bandTop = Math.Max(0, cursorY - bandHalf); int bandBot = Math.Min(h - 1, cursorY + bandHalf); var leftExtents = new List(); var rightExtents = new List(); for (int y = bandTop; y <= bandBot; y++) { int rowOff = y * stride; int seedX = FindDarkSeedInRow(px, stride, w, rowOff, cursorX, darkThresh, seedRadius: 6); if (seedX < 0) continue; int leftEdge = seedX; int gap = 0; for (int x = seedX - 1; x >= 0; x--) { int i = rowOff + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; if (brightness < darkThresh) { leftEdge = x; gap = 0; } else if (++gap > colGap) break; } int rightEdge = seedX; gap = 0; for (int x = seedX + 1; x < w; x++) { int i = rowOff + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; if (brightness < darkThresh) { rightEdge = x; gap = 0; } else if (++gap > colGap) break; } leftExtents.Add(leftEdge); rightExtents.Add(rightEdge); } if (leftExtents.Count < 10) { Console.Error.WriteLine($" edge-crop: too few dark rows ({leftExtents.Count})"); fullCapture.Dispose(); return null; } leftExtents.Sort(); rightExtents.Sort(); // Use RowThreshDiv/ColThreshDiv as percentile denominators // e.g., RowThreshDiv=4 → 25th percentile for left, ColThreshDiv=4 → 75th for right int leftPctIdx = leftExtents.Count / p.RowThreshDiv; int rightPctIdx = rightExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv; leftPctIdx = Math.Clamp(leftPctIdx, 0, leftExtents.Count - 1); rightPctIdx = Math.Clamp(rightPctIdx, 0, rightExtents.Count - 1); int bestColStart = leftExtents[leftPctIdx]; int bestColEnd = rightExtents[rightPctIdx]; Console.Error.WriteLine($" edge-crop: horizontal: left={bestColStart} right={bestColEnd} ({bestColEnd - bestColStart + 1}px) samples={leftExtents.Count} pctL={leftPctIdx}/{leftExtents.Count} pctR={rightPctIdx}/{rightExtents.Count}"); if (bestColEnd - bestColStart + 1 < 50) { Console.Error.WriteLine($" edge-crop: horizontal extent too small"); fullCapture.Dispose(); return null; } // ── Phase 2: Per-column vertical extent ── int colBandHalf = (bestColEnd - bestColStart + 1) / 3; int colBandLeft = Math.Max(bestColStart, cursorX - colBandHalf); int colBandRight = Math.Min(bestColEnd, cursorX + colBandHalf); var topExtents = new List(); var bottomExtents = new List(); // Asymmetric gap: larger upward to bridge header decorations (~30-40px bright) int maxGapUp = maxGap * 3; for (int x = colBandLeft; x <= colBandRight; x++) { int seedY = FindDarkSeedInColumn(px, stride, h, x, cursorY, darkThresh, seedRadius: 6); if (seedY < 0) continue; int topEdge = seedY; int gap = 0; for (int y = seedY - 1; y >= 0; y--) { int i = y * stride + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; if (brightness < darkThresh) { topEdge = y; gap = 0; } else if (++gap > maxGapUp) break; } int bottomEdge = seedY; gap = 0; for (int y = seedY + 1; y < h; y++) { int i = y * stride + x * 4; int brightness = (px[i] + px[i + 1] + px[i + 2]) / 3; if (brightness < darkThresh) { bottomEdge = y; gap = 0; } else if (++gap > maxGap) break; } topExtents.Add(topEdge); bottomExtents.Add(bottomEdge); } if (topExtents.Count < 10) { Console.Error.WriteLine($" edge-crop: too few dark columns ({topExtents.Count})"); fullCapture.Dispose(); return null; } topExtents.Sort(); bottomExtents.Sort(); int topPctIdx = topExtents.Count / p.RowThreshDiv; int botPctIdx = topExtents.Count * (p.ColThreshDiv - 1) / p.ColThreshDiv; topPctIdx = Math.Clamp(topPctIdx, 0, topExtents.Count - 1); botPctIdx = Math.Clamp(botPctIdx, 0, bottomExtents.Count - 1); int bestRowStart = topExtents[topPctIdx]; int bestRowEnd = bottomExtents[botPctIdx]; Console.Error.WriteLine($" edge-crop: vertical: top={bestRowStart} bottom={bestRowEnd} ({bestRowEnd - bestRowStart + 1}px) samples={topExtents.Count}"); if (bestRowEnd - bestRowStart + 1 < 50) { Console.Error.WriteLine($" edge-crop: vertical extent too small"); fullCapture.Dispose(); return null; } int minX = bestColStart; int minY = bestRowStart; int maxX = bestColEnd; int maxY = bestRowEnd; int rw = maxX - minX + 1; int rh = maxY - minY + 1; Console.Error.WriteLine($" edge-crop: result ({minX},{minY}) {rw}x{rh}"); if (rw < 50 || rh < 50) { Console.Error.WriteLine($" edge-crop: region too small ({rw}x{rh})"); fullCapture.Dispose(); return null; } var cropRect = new Rectangle(minX, minY, rw, rh); var cropped = fullCapture.Clone(cropRect, PixelFormat.Format32bppArgb); var region = new RegionRect { X = minX, Y = minY, Width = rw, Height = rh }; return (cropped, fullCapture, region); } private static int FindDarkSeedInRow(byte[] px, int stride, int w, int rowOff, int cursorX, int darkThresh, int seedRadius) { int maxR = Math.Min(seedRadius, Math.Min(cursorX, w - 1 - cursorX)); for (int r = 0; r <= maxR; r++) { int x1 = cursorX - r; int i1 = rowOff + x1 * 4; int b1 = (px[i1] + px[i1 + 1] + px[i1 + 2]) / 3; if (b1 < darkThresh) return x1; int x2 = cursorX + r; int i2 = rowOff + x2 * 4; int b2 = (px[i2] + px[i2 + 1] + px[i2 + 2]) / 3; if (b2 < darkThresh) return x2; } return -1; } private static int FindDarkSeedInColumn(byte[] px, int stride, int h, int x, int cursorY, int darkThresh, int seedRadius) { int maxR = Math.Min(seedRadius, Math.Min(cursorY, h - 1 - cursorY)); for (int r = 0; r <= maxR; r++) { int y1 = cursorY - r; int i1 = y1 * stride + x * 4; int b1 = (px[i1] + px[i1 + 1] + px[i1 + 2]) / 3; if (b1 < darkThresh) return y1; int y2 = cursorY + r; int i2 = y2 * stride + x * 4; int b2 = (px[i2] + px[i2 + 1] + px[i2 + 2]) / 3; if (b2 < darkThresh) return y2; } return -1; } }