From 533a7650773131cb57fc0397f6d0095881c641d5 Mon Sep 17 00:00:00 2001 From: Zaitam Date: Sun, 4 May 2025 02:14:03 +0000 Subject: [PATCH 1/2] add shape recognition functionality and add .gitignore --- .gitignore | 58 +++++++++ syncscribble/Makefile | 6 +- syncscribble/shaperecognizer.cpp | 192 ++++++++++++++++++++++++++++ syncscribble/shaperecognizer.h | 22 ++++ syncscribble/strokebuilder.cpp | 213 +++++++++++++++++++++++++++++++ syncscribble/strokebuilder.h | 4 + 6 files changed, 492 insertions(+), 3 deletions(-) create mode 100644 .gitignore create mode 100644 syncscribble/shaperecognizer.cpp create mode 100644 syncscribble/shaperecognizer.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..41f98f5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +syncscribble/Release/ +syncscribble/Debug/ + +# Prerequisites +*.d + +# Object files +*.o +*.ko +*.obj +*.elf + +# Linker output +*.ilk +*.map +*.exp + +# Precompiled Headers +*.gch +*.pch + +# Libraries +*.lib +*.a +*.la +*.lo + +# Shared objects (inc. Windows DLLs) +*.dll +*.so +*.so.* +*.dylib + +# Executables +*.exe +*.out +*.app +*.i*86 +*.x86_64 +*.hex + +# Debug files +*.dSYM/ +*.su +*.idb +*.pdb + +# Kernel Module Compile Results +*.mod* +*.cmd +.tmp_versions/ +modules.order +Module.symvers +Mkfile.old +dkms.conf + +# Other +.vscode/ diff --git a/syncscribble/Makefile b/syncscribble/Makefile index a3d785d..56ce0fc 100644 --- a/syncscribble/Makefile +++ b/syncscribble/Makefile @@ -35,7 +35,8 @@ SOURCES = \ syncdialog.cpp \ touchwidgets.cpp \ mainwindow.cpp \ - scribbleapp.cpp + scribbleapp.cpp \ + shaperecognizer.cpp SOURCES += \ ../ugui/svggui.cpp \ @@ -99,7 +100,7 @@ DEFS += _USE_MATH_DEFINES UNICODE NOMINMAX FONS_WPATH # only dependencies under this path will be tracked in .d files; note [\\] must be used for "\" # ensure that no paths containing spaces are included -DEPENDBASE ?= c:[\\]temp[\\]styluslabs +DEPENDBASE ?= \\wsl.localhost\Ubuntu\home\zaitam\dev\forks\Write # shell32 for ShellExecute; user32 for clipboard fns; libs below opengl32.lib needed only for static SDL LIBS = \ @@ -172,7 +173,6 @@ IOSRES = \ ../scribbleres/Intro.svg INFOPLIST = ios/Info.plist PLISTENV = CURRENT_PROJECT_VERSION=1.5 MARKETING_VERSION=1.5 - # Xcode generated provisioning profile for free account is per-app and expires every 7 days; w/ paid account, 1-year profile w/ all apps (com.styluslabs.*) can be generated # For signing, we expect a valid .xcent file - the value for com.apple.developer.team-identifier can be found under the "Organizational Unit" field in "Get Info" for the iPhone Developer certificate in the Keychain Access app # run `security find-identity -v -p codesigning` to get argument for codesign --sign diff --git a/syncscribble/shaperecognizer.cpp b/syncscribble/shaperecognizer.cpp new file mode 100644 index 0000000..85b05ba --- /dev/null +++ b/syncscribble/shaperecognizer.cpp @@ -0,0 +1,192 @@ +#include "shaperecognizer.h" + +bool ShapeRecognizer::isCircle(const std::vector &points, Point ¢roid, double &avgRadius) +{ + double sumX = 0, sumY = 0; + for (const auto& point : points) { + sumX += point.x; + sumY += point.y; + } + + centroid = { sumX / points.size(), sumY / points.size() }; + + std::vector distances; + for (const auto& point : points) { + double dx = point.x - centroid.x; + double dy = point.y - centroid.y; + distances.push_back(std::sqrt(dx * dx + dy * dy)); + } + + avgRadius = std::accumulate(distances.begin(), distances.end(), 0.0) / distances.size(); + + double variance = 0; + for (double d : distances) { + variance += (d - avgRadius) * (d - avgRadius); + } + variance /= distances.size(); + + double stddev = std::sqrt(variance); + double ratio = stddev / avgRadius; + + double threshold = 0.13; + printf("Circle: StdDev %.5f, AvgRadius %.5f, Ratio %.5f\n", stddev, avgRadius, ratio); + + return ratio < threshold; +} + +bool ShapeRecognizer::isLine(const std::vector& points, Point& start, Point& end) { + double sumX = 0, sumY = 0; + for (const auto& p : points) { + sumX += p.x; + sumY += p.y; + } + double meanX = sumX / points.size(); + double meanY = sumY / points.size(); + + double num = 0, den = 0; + for (const auto& p : points) { + num += (p.x - meanX) * (p.y - meanY); + den += (p.x - meanX) * (p.x - meanX); + } + + if (den == 0) { + start = { meanX, points.front().y }; + end = { meanX, points.back().y }; + return true; + } + + double slope = num / den; + double intercept = meanY - slope * meanX; + + double sumSquaredDistances = 0; + for (const auto& p : points) { + double expectedY = slope * p.x + intercept; + double distance = std::abs(expectedY - p.y) / std::sqrt(1 + slope * slope); + sumSquaredDistances += distance * distance; + } + double rmse = std::sqrt(sumSquaredDistances / points.size()); + + double span = std::hypot(points.back().x - points.front().x, points.back().y - points.front().y); + double threshold = span * 0.05; + printf("Line: RMSE %.5f, Span %.5f, Threshold %.5f\n", rmse, span, threshold); + if (rmse > threshold) return false; + + auto project = [&](const Point& p) -> Point { + double x = (p.x + slope * (p.y - intercept)) / (1 + slope * slope); + double y = slope * x + intercept; + return { x, y }; + }; + + start = project(points.front()); + end = project(points.back()); + + return true; +} + +bool ShapeRecognizer::isRectangle(const std::vector& points, std::vector& outCorners) { + Point centroid{0, 0}; + for (const auto& p : points) { + centroid.x += p.x; + centroid.y += p.y; + } + centroid.x /= points.size(); + centroid.y /= points.size(); + + std::vector corners(4); + double maxDists[4] = {0, 0, 0, 0}; + + for (const auto& p : points) { + double dx = p.x - centroid.x; + double dy = p.y - centroid.y; + double dist = dx * dx + dy * dy; + + int quadrant = (dy >= 0) * 2 + (dx >= 0); + if (dist > maxDists[quadrant]) { + maxDists[quadrant] = dist; + corners[quadrant] = p; + } + } + + std::sort(corners.begin(), corners.end(), [¢roid](const Point& a, const Point& b) { + double angleA = std::atan2(a.y - centroid.y, a.x - centroid.x); + double angleB = std::atan2(b.y - centroid.y, b.x - centroid.x); + return angleA < angleB; + }); + + double sides[4]; + double angles[4]; + for (int i = 0; i < 4; ++i) { + Point a = corners[i]; + Point b = corners[(i + 1) % 4]; + Point c = corners[(i + 2) % 4]; + + double dx = b.x - a.x; + double dy = b.y - a.y; + sides[i] = std::hypot(dx, dy); + + double dx1 = a.x - b.x; + double dy1 = a.y - b.y; + double dx2 = c.x - b.x; + double dy2 = c.y - b.y; + + double dot = dx1 * dx2 + dy1 * dy2; + double mag1 = std::hypot(dx1, dy1); + double mag2 = std::hypot(dx2, dy2); + angles[i] = std::acos(dot / (mag1 * mag2)) * 180.0 / M_PI; + } + + double sideTol = 0.2 * ((sides[0] + sides[2]) / 2); + double angleTol = 15.0; + + bool sidesEqual = std::abs(sides[0] - sides[2]) < sideTol && + std::abs(sides[1] - sides[3]) < sideTol; + bool anglesRight = std::all_of(angles, angles + 4, [=](double angle) { + return std::abs(angle - 90.0) < angleTol; + }); + + bool closedFigure = points.front().dist(points.back()) < 0.5 * points.front().dist(centroid); + + printf("Rectangle: Closed=%s, SidesEqual=%s, AnglesRight=%s\n", + closedFigure ? "true" : "false", sidesEqual ? "true" : "false", anglesRight ? "true" : "false"); + + outCorners = corners; + return sidesEqual && anglesRight && closedFigure; +} + + +bool ShapeRecognizer::isArrow(const std::vector& points, Point& start, Point& end) { + size_t splitIndex = points.size() * 2 / 3; + if (splitIndex >= points.size() - 2) splitIndex = points.size() - 3; + + std::vector shaft(points.begin(), points.begin() + splitIndex); + std::vector head(points.begin() + splitIndex, points.end()); + + if (!isLine(shaft, start, end)) return false; + + auto vec = [](const Point& a, const Point& b) { + return std::pair{b.x - a.x, b.y - a.y}; + }; + + auto normalize = [](std::pair v) { + double len = std::hypot(v.first, v.second); + return std::pair{v.first / len, v.second / len}; + }; + + auto dot = [](std::pair u, std::pair v) { + return u.first * v.first + u.second * v.second; + }; + + Point shaftStart = shaft.front(), shaftEnd = shaft.back(); + Point headStart = head.front(), headEnd = head.back(); + + auto dir1 = normalize(vec(shaftStart, shaftEnd)); + auto dir2 = normalize(vec(headStart, headEnd)); + + double angleCos = dot(dir1, dir2); + double angleDeg = std::acos(angleCos) * 180.0 / M_PI; + + printf("Arrow: shaft-head angle: %.2f\n", angleDeg); + + return angleDeg > 140.0 && angleDeg < 160.0; +} + diff --git a/syncscribble/shaperecognizer.h b/syncscribble/shaperecognizer.h new file mode 100644 index 0000000..635c6c7 --- /dev/null +++ b/syncscribble/shaperecognizer.h @@ -0,0 +1,22 @@ +#ifndef SHAPERECOGNIZER_H +#define SHAPERECOGNIZER_H + +#include +#include "strokebuilder.h" + +class ShapeRecognizer { +public: + enum Shape { + None, + Circle, + Rectangle, + Arrow + }; + + static bool isCircle(const std::vector& points, Point& centroid, double& avgRadius); + static bool isLine(const std::vector& points, Point& start, Point& end); + static bool isRectangle(const std::vector& points, std::vector& outCorners); + static bool isArrow(const std::vector &points, Point& start, Point& end); +}; + +#endif diff --git a/syncscribble/strokebuilder.cpp b/syncscribble/strokebuilder.cpp index 9901130..567fd16 100644 --- a/syncscribble/strokebuilder.cpp +++ b/syncscribble/strokebuilder.cpp @@ -1,4 +1,6 @@ #include "strokebuilder.h" +#include "scribbleapp.h" +#include "shaperecognizer.h" // removePoints() doesn't really work except for removing all points // what about post-processing filters? StrokeBuilder saves list all the points it gets and feeds them to @@ -226,6 +228,14 @@ void FilledStrokeBuilder::addPoint(const StrokePoint& pt) Point pt1 = points.empty() ? pt2 : points.back(); Point pt0 = points.size() > 1 ? points[points.size() - 2] : pt1; + static Dim holdThreshold = 0.6; // Should be dynamic so that while using a pen or a finger, the threshold is larger + if (points.empty()) { + holdStartTime = 0; + } else if(holdStartTime == 0 || pt2.dist(pt1) > holdThreshold) { + holdStartTime = pt.t; + } + //printf("p.t: %ld, dist: %f, empty: %d, time: %ld\n", pt.t, pt2.dist(pt1), points.empty(), holdStartTime); + // width calculation - initially we had separate class for each, but only velocity has any complexity Dim wscale = 1.0; int nscales = 0; @@ -434,6 +444,209 @@ Rect FilledStrokeBuilder::getDirty() return r; } +bool FilledStrokeBuilder::shapeRecognize(Timestamp t) +{ + int holdTimeout = 0; + if (points.size() < 5 || holdStartTime == 0 || t - holdStartTime < holdTimeout) + return false; + + Point centroid; + double avgRadius; + Point start, end; + std::vector corners; + + if (ShapeRecognizer::isCircle(points, centroid, avgRadius)) { + removePoints(points.size()); + dirty = Rect(); + + double halfWidth = pen.width / 2.0; + double circumference = 2 * M_PI * avgRadius; + int numSegments = std::max(20, static_cast(circumference / 5.0)); + + Path2D outerCircle; + for (int i = 0; i < numSegments; ++i) { + double angle = 2 * M_PI * i / numSegments; + Point point = { + centroid.x + (avgRadius + halfWidth) * std::cos(angle), + centroid.y + (avgRadius + halfWidth) * std::sin(angle) + }; + if (i == 0) + outerCircle.moveTo(point.x, point.y); + else + outerCircle.lineTo(point.x, point.y); + } + outerCircle.closeSubpath(); + + Path2D innerCircle; + for (int i = 0; i < numSegments; ++i) { + double angle = 2 * M_PI * i / numSegments; + Point point = { + centroid.x + (avgRadius - halfWidth) * std::cos(angle), + centroid.y + (avgRadius - halfWidth) * std::sin(angle) + }; + if (i == 0) + innerCircle.moveTo(point.x, point.y); + else + innerCircle.lineTo(point.x, point.y); + } + innerCircle.closeSubpath(); + + stroke->clear(); + stroke->connectPath(outerCircle); + stroke->connectPath(innerCircle.toReversed()); + + return true; + } + if (ShapeRecognizer::isRectangle(points, corners)) { + removePoints(points.size()); + + double halfWidth = pen.width; // So not really halfWidth here + + Point p0 = corners[0]; + Point p1 = corners[1]; + Point p2 = corners[2]; + Point p3 = corners[3]; + + double avgLeftX = (p0.x + p3.x) / 2; + double avgRightX = (p1.x + p2.x) / 2; + double avgTopY = (p0.y + p1.y) / 2; + double avgBottomY = (p2.y + p3.y) / 2; + + Point o0 = {avgLeftX, avgTopY}; + Point o1 = {avgRightX, avgTopY}; + Point o2 = {avgRightX, avgBottomY}; + Point o3 = {avgLeftX, avgBottomY}; + + Point i0 = { o0.x + halfWidth, o0.y + halfWidth }; + Point i1 = { o1.x - halfWidth, o1.y + halfWidth }; + Point i2 = { o2.x - halfWidth, o2.y - halfWidth }; + Point i3 = { o3.x + halfWidth, o3.y - halfWidth }; + + Path2D hollowRect; + hollowRect.moveTo(o0.x, o0.y); + hollowRect.lineTo(o1.x, o1.y); + hollowRect.lineTo(o2.x, o2.y); + hollowRect.lineTo(o3.x, o3.y); + hollowRect.closeSubpath(); + + Path2D innerRect; + innerRect.moveTo(i0.x, i0.y); + innerRect.lineTo(i1.x, i1.y); + innerRect.lineTo(i2.x, i2.y); + innerRect.lineTo(i3.x, i3.y); + innerRect.closeSubpath(); + + stroke->clear(); + stroke->connectPath(hollowRect); + stroke->connectPath(innerRect.toReversed()); + return true; + } + if (ShapeRecognizer::isArrow(points, start, end)) { + removePoints(points.size()); + double halfWidth = pen.width / 2.0; + double headLength = std::max(10.0, pen.width * 2.5); + double headWidth = pen.width * 1.5; + + double dx = end.x - start.x; + double dy = end.y - start.y; + double shaftLen = std::hypot(dx, dy); + double ux = dx / shaftLen; + double uy = dy / shaftLen; + + double px = -uy; + double py = ux; + + Point a = { start.x + px * halfWidth, start.y + py * halfWidth }; + Point b = { start.x - px * halfWidth, start.y - py * halfWidth }; + Point c = { end.x - px * halfWidth, end.y - py * halfWidth }; + Point d = { end.x + px * halfWidth, end.y + py * halfWidth }; + + Path2D shaft; + shaft.moveTo(a.x, a.y); + shaft.lineTo(b.x, b.y); + shaft.lineTo(c.x, c.y); + shaft.lineTo(d.x, d.y); + shaft.closeSubpath(); + + const double angleRad1 = 135.0 * M_PI / 180.0; + double hx = std::cos(angleRad1) * ux - std::sin(angleRad1) * uy; + double hy = std::sin(angleRad1) * ux + std::cos(angleRad1) * uy; + + const double angleRad2 = -135.0 * M_PI / 180.0; + double hx2 = std::cos(angleRad2) * ux - std::sin(angleRad2) * uy; + double hy2 = std::sin(angleRad2) * ux + std::cos(angleRad2) * uy; + + Point tip1 = {end.x + hx * headLength, end.y + hy * headLength}; + Point tip2 = {end.x + hx2 * headLength, end.y + hy2 * headLength}; + + double pxHead1 = -hy; + double pyHead1 = hx; + double pxHead2 = -hy2; + double pyHead2 = hx2; + + Point ha1 = { end.x + pxHead1 * halfWidth, end.y + pyHead1 * halfWidth }; + Point hb1 = { end.x - pxHead1 * halfWidth, end.y - pyHead1 * halfWidth }; + Point hc1 = { tip1.x - pxHead1 * halfWidth, tip1.y - pyHead1 * halfWidth }; + Point hd1 = { tip1.x + pxHead1 * halfWidth, tip1.y + pyHead1 * halfWidth }; + + Point ha2 = { end.x + pxHead2 * halfWidth, end.y + pyHead2 * halfWidth }; + Point hb2 = { end.x - pxHead2 * halfWidth, end.y - pyHead2 * halfWidth }; + Point hc2 = { tip2.x - pxHead2 * halfWidth, tip2.y - pyHead2 * halfWidth }; + Point hd2 = { tip2.x + pxHead2 * halfWidth, tip2.y + pyHead2 * halfWidth }; + + Path2D headLine1; + headLine1.moveTo(ha1.x, ha1.y); + headLine1.lineTo(hb1.x, hb1.y); + headLine1.lineTo(hc1.x, hc1.y); + headLine1.lineTo(hd1.x, hd1.y); + headLine1.closeSubpath(); + + Path2D headLine2; + headLine2.moveTo(ha2.x, ha2.y); + headLine2.lineTo(hb2.x, hb2.y); + headLine2.lineTo(hc2.x, hc2.y); + headLine2.lineTo(hd2.x, hd2.y); + headLine2.closeSubpath(); + + stroke->clear(); + stroke->connectPath(shaft); + stroke->connectPath(headLine1); + stroke->connectPath(headLine2); + + return true; + } + if (ShapeRecognizer::isLine(points, start, end)) { + removePoints(points.size()); + dirty = Rect(); + + double halfWidth = pen.width / 2.0; + double dx = end.x - start.x; + double dy = end.y - start.y; + double length = std::hypot(dx, dy); + double ux = dx / length; + double uy = dy / length; + double px = -uy; + double py = ux; + + Point a = { start.x + px * halfWidth, start.y + py * halfWidth }; + Point b = { start.x - px * halfWidth, start.y - py * halfWidth }; + Point c = { end.x - px * halfWidth, end.y - py * halfWidth }; + Point d = { end.x + px * halfWidth, end.y + py * halfWidth }; + + Path2D linePath; + linePath.moveTo(a.x, a.y); + linePath.lineTo(b.x, b.y); + linePath.lineTo(c.x, c.y); + linePath.lineTo(d.x, d.y); + linePath.closeSubpath(); + + stroke->clear(); + stroke->connectPath(linePath); + + return true; + } + return false; +} // filters // when accounting for pressure, the decrease in number of points is fairly minor (maybe ~20%), esp. when diff --git a/syncscribble/strokebuilder.h b/syncscribble/strokebuilder.h index aa8c4ce..d82e5de 100644 --- a/syncscribble/strokebuilder.h +++ b/syncscribble/strokebuilder.h @@ -41,12 +41,14 @@ class StrokeBuilder : public InputProcessor Element* getElement() { return element; } Path2D* getPath() { return stroke; } virtual Rect getDirty() { return element->bbox(); } + virtual bool shapeRecognize(Timestamp t) { return false; }; Element* finish(); // caller assumes ownership of returned Element static StrokeBuilder* create(const ScribblePen& pen); static Point calcCom(SvgNode* node, Path2D* path); protected: + Timestamp holdStartTime = 0; Element* element; Path2D* stroke; void finalize() override; @@ -74,6 +76,7 @@ class FilledStrokeBuilder : public StrokeBuilder { public: FilledStrokeBuilder(const ScribblePen& _pen); Rect getDirty() override; + bool shapeRecognize(Timestamp t) override; static void addRoundSubpath(Path2D& path, Point pt1, Dim w1, Point pt2, Dim w2); static void addChiselSubpath(Path2D& path, Point pt1, Dim w1, Point pt2, Dim w2); @@ -82,6 +85,7 @@ class FilledStrokeBuilder : public StrokeBuilder { protected: void addPoint(const StrokePoint& pt) override; void removePoints(int n) override; + void finalize() override; private: void assembleFlatStroke(); From b8318cd00a215afe948216607457dbc43b04d738 Mon Sep 17 00:00:00 2001 From: Zaitam Date: Fri, 9 May 2025 11:57:06 -0300 Subject: [PATCH 2/2] Fix unintended change to Makefile --- syncscribble/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/syncscribble/Makefile b/syncscribble/Makefile index 56ce0fc..92d597d 100644 --- a/syncscribble/Makefile +++ b/syncscribble/Makefile @@ -100,7 +100,7 @@ DEFS += _USE_MATH_DEFINES UNICODE NOMINMAX FONS_WPATH # only dependencies under this path will be tracked in .d files; note [\\] must be used for "\" # ensure that no paths containing spaces are included -DEPENDBASE ?= \\wsl.localhost\Ubuntu\home\zaitam\dev\forks\Write +DEPENDBASE ?= c:[\\]temp[\\]styluslabs # shell32 for ShellExecute; user32 for clipboard fns; libs below opengl32.lib needed only for static SDL LIBS = \