diff --git a/libs/segcore/src/normalized_format_serializer.cpp b/libs/segcore/src/normalized_format_serializer.cpp index 87fb152..5b19d9f 100644 --- a/libs/segcore/src/normalized_format_serializer.cpp +++ b/libs/segcore/src/normalized_format_serializer.cpp @@ -1,5 +1,6 @@ #include +#include #include #include @@ -9,7 +10,7 @@ namespace segcore std::string SegmentsToNormalizedFormat(const std::vector& segs, int w, int h) { std::ostringstream out; - out << std::fixed << std::setprecision(3); + out << std::fixed << std::setprecision(6); for (const auto& seg : segs) { if (seg.points.size() < 3) @@ -58,7 +59,7 @@ std::vector NormalizedFormatToSegments(const std::string& text, int w, { double nx = std::stod(tokens[i]); double ny = std::stod(tokens[i + 1]); - seg.points.push_back({static_cast(nx * w), static_cast(ny * h)}); + seg.points.push_back({static_cast(std::round(nx * w)), static_cast(std::round(ny * h))}); } result.push_back(std::move(seg)); } diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index e805947..de8b6ce 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -599,8 +599,6 @@ void MainWindow::LoadImageAtIndex(int index) return; } - AutoSaveCurrentImage(); - // Save clipboard from current image before switching (deep copy) std::optional clipboard_backup; if (ui->label->GetAnnotationSet() != nullptr) diff --git a/src/polygoncanvas.cpp b/src/polygoncanvas.cpp index 63f45c3..1cd4b0f 100644 --- a/src/polygoncanvas.cpp +++ b/src/polygoncanvas.cpp @@ -505,7 +505,7 @@ int PolygonCanvas::GetAnnotationCount() const void PolygonCanvas::Undo() { - if (annotation_set_ == nullptr) return; + if (annotation_set_ == nullptr || !annotation_set_->CanUndo()) return; annotation_set_->Undo(); emit PolygonsChanged(); repaint(); @@ -513,7 +513,7 @@ void PolygonCanvas::Undo() void PolygonCanvas::Redo() { - if (annotation_set_ == nullptr) return; + if (annotation_set_ == nullptr || !annotation_set_->CanRedo()) return; annotation_set_->Redo(); emit PolygonsChanged(); repaint(); @@ -543,8 +543,8 @@ void PolygonCanvas::PastePolygon() { if (annotation_set_ == nullptr) return; segcore::SegmentId pasted_id = annotation_set_->PasteSegment(); - if (pasted_id != segcore::kInvalidSegmentId) - annotation_set_->SelectSegment(pasted_id); + if (pasted_id == segcore::kInvalidSegmentId) return; + annotation_set_->SelectSegment(pasted_id); emit PolygonsChanged(); repaint(); } diff --git a/tests/polygoncanvas_test.cpp b/tests/polygoncanvas_test.cpp index 578ccaa..e35b5d3 100644 --- a/tests/polygoncanvas_test.cpp +++ b/tests/polygoncanvas_test.cpp @@ -2,6 +2,7 @@ #include #include +#include #include #include "polygoncanvas.h" @@ -120,10 +121,54 @@ TEST_F(PolygonCanvasTest, KeyPressCtrlZCallsUndo) { canvas_->SetAnnotationSet(&mock_); canvas_->setFocus(); + ON_CALL(mock_, CanUndo()).WillByDefault(testing::Return(true)); EXPECT_CALL(mock_, Undo()); QTest::keyClick(canvas_, Qt::Key_Z, Qt::ControlModifier); } +TEST_F(PolygonCanvasTest, UndoSkippedWhenCanUndoFalse) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + ON_CALL(mock_, CanUndo()).WillByDefault(testing::Return(false)); + EXPECT_CALL(mock_, Undo()).Times(0); + QTest::keyClick(canvas_, Qt::Key_Z, Qt::ControlModifier); +} + +TEST_F(PolygonCanvasTest, RedoSkippedWhenCanRedoFalse) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + ON_CALL(mock_, CanRedo()).WillByDefault(testing::Return(false)); + EXPECT_CALL(mock_, Redo()).Times(0); + QTest::keyClick(canvas_, Qt::Key_Y, Qt::ControlModifier); +} + +TEST_F(PolygonCanvasTest, PasteEmitsPolygonsChangedOnSuccess) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + ON_CALL(mock_, PasteSegment()).WillByDefault(testing::Return(segcore::SegmentId{7})); + ON_CALL(mock_, GetSegments()).WillByDefault(testing::ReturnRef(empty_segments_)); + + QSignalSpy spy(canvas_, &PolygonCanvas::PolygonsChanged); + QTest::keyClick(canvas_, Qt::Key_V, Qt::ControlModifier); + + EXPECT_EQ(spy.count(), 1); +} + +TEST_F(PolygonCanvasTest, PasteDoesNotEmitPolygonsChangedWhenClipboardEmpty) +{ + canvas_->SetAnnotationSet(&mock_); + canvas_->setFocus(); + ON_CALL(mock_, PasteSegment()).WillByDefault(testing::Return(segcore::kInvalidSegmentId)); + + QSignalSpy spy(canvas_, &PolygonCanvas::PolygonsChanged); + QTest::keyClick(canvas_, Qt::Key_V, Qt::ControlModifier); + + EXPECT_EQ(spy.count(), 0); +} + TEST_F(PolygonCanvasTest, KeyPressDeleteCallsDeleteSegment) { canvas_->SetAnnotationSet(&mock_); diff --git a/tests/segcore/normalized_format_serializer_test.cpp b/tests/segcore/normalized_format_serializer_test.cpp index 84b8d72..06d95ec 100644 --- a/tests/segcore/normalized_format_serializer_test.cpp +++ b/tests/segcore/normalized_format_serializer_test.cpp @@ -16,7 +16,7 @@ TEST(YoloSerializerTest, SerializeSingleSegment) { std::vector segs = {MakeTriangle(1)}; std::string out = SegmentsToNormalizedFormat(segs, 1000, 1000); - EXPECT_EQ(out, "0 0.100 0.200 0.300 0.400 0.500 0.600\n"); + EXPECT_EQ(out, "0 0.100000 0.200000 0.300000 0.400000 0.500000 0.600000\n"); } TEST(YoloSerializerTest, SerializeMultipleSegments) @@ -25,8 +25,8 @@ TEST(YoloSerializerTest, SerializeMultipleSegments) Segment s2 = MakeTriangle(2, 1); std::string out = SegmentsToNormalizedFormat({s1, s2}, 1000, 1000); EXPECT_EQ(out, - "0 0.100 0.200 0.300 0.400 0.500 0.600\n" - "1 0.100 0.200 0.300 0.400 0.500 0.600\n"); + "0 0.100000 0.200000 0.300000 0.400000 0.500000 0.600000\n" + "1 0.100000 0.200000 0.300000 0.400000 0.500000 0.600000\n"); } TEST(YoloSerializerTest, SkipsSegmentWithFewPoints) @@ -68,7 +68,67 @@ TEST(YoloSerializerTest, RoundTrip) ASSERT_EQ(deserialized[0].points.size(), original[0].points.size()); for (size_t i = 0; i < original[0].points.size(); ++i) { - EXPECT_NEAR(deserialized[0].points[i].x, original[0].points[i].x, 1); - EXPECT_NEAR(deserialized[0].points[i].y, original[0].points[i].y, 1); + EXPECT_EQ(deserialized[0].points[i].x, original[0].points[i].x); + EXPECT_EQ(deserialized[0].points[i].y, original[0].points[i].y); + } +} + +// Coordinates that are not evenly divisible by image dimensions. +// With 3 decimal places + truncation these would drift by 1px per save/load cycle: +// 105/640 = 0.164062... -> "0.164" -> int(104.96) = 104 (drift) +// 100/480 = 0.208333... -> "0.208" -> int(99.84) = 99 (drift) +// 160/480 = 0.333333... -> "0.333" -> int(159.84) = 159 (drift) +TEST(YoloSerializerTest, RoundTripNonDivisibleCoordinates) +{ + Segment s; + s.id = 1; + s.class_id = 0; + s.points = {{105, 100}, {171, 160}, {320, 399}}; + + const int w = 640; + const int h = 480; + + std::string serialized = SegmentsToNormalizedFormat({s}, w, h); + auto deserialized = NormalizedFormatToSegments(serialized, w, h); + + ASSERT_EQ(deserialized.size(), 1u); + ASSERT_EQ(deserialized[0].points.size(), s.points.size()); + for (size_t i = 0; i < s.points.size(); ++i) + { + EXPECT_EQ(deserialized[0].points[i].x, s.points[i].x); + EXPECT_EQ(deserialized[0].points[i].y, s.points[i].y); + } +} + +// Simulates navigating away and back to the same image multiple times. +// Each cycle: serialize current points, then deserialize back. +// Points must be identical after every cycle - no accumulated drift. +TEST(YoloSerializerTest, RoundTripStableAcrossMultipleCycles) +{ + Segment s; + s.id = 1; + s.class_id = 0; + s.points = {{105, 100}, {171, 160}, {320, 399}}; + + const int w = 640; + const int h = 480; + + std::string text = SegmentsToNormalizedFormat({s}, w, h); + auto after_first = NormalizedFormatToSegments(text, w, h); + ASSERT_EQ(after_first.size(), 1u); + + for (int cycle = 0; cycle < 5; ++cycle) + { + text = SegmentsToNormalizedFormat(after_first, w, h); + auto after_cycle = NormalizedFormatToSegments(text, w, h); + ASSERT_EQ(after_cycle.size(), 1u); + ASSERT_EQ(after_cycle[0].points.size(), after_first[0].points.size()); + for (size_t i = 0; i < after_first[0].points.size(); ++i) + { + EXPECT_EQ(after_cycle[0].points[i].x, after_first[0].points[i].x) + << "x drift at cycle " << cycle + 1 << ", point " << i; + EXPECT_EQ(after_cycle[0].points[i].y, after_first[0].points[i].y) + << "y drift at cycle " << cycle + 1 << ", point " << i; + } } }