Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow matching empty frames in quality checks #8652

Merged
merged 28 commits into from
Nov 11, 2024
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
9605ac9
Add a quality setting for avoiding using bbox in point group matching
zhiltsov-max Nov 1, 2024
7bd4d87
Update ui
zhiltsov-max Nov 1, 2024
bd12c4c
Update changelog
zhiltsov-max Nov 1, 2024
af6e862
Merge branch 'develop' into zm/compare-point-groups-in-image-space
zhiltsov-max Nov 1, 2024
1f53477
Update schema
zhiltsov-max Nov 1, 2024
303375c
Merge remote-tracking branch 'origin/zm/compare-point-groups-in-image…
zhiltsov-max Nov 1, 2024
ccd3f8c
Use shorter name
zhiltsov-max Nov 1, 2024
bc141e7
Update test assets
zhiltsov-max Nov 1, 2024
8826c0f
Merge branch 'develop' into zm/compare-point-groups-in-image-space
zhiltsov-max Nov 1, 2024
b2cbc05
Merge branch 'develop' into zm/compare-point-groups-in-image-space
zhiltsov-max Nov 2, 2024
51f757f
Extend quality test
zhiltsov-max Nov 4, 2024
dc4667d
Add server support for empty frame matching
zhiltsov-max Nov 5, 2024
c8e5dc7
Add ui
zhiltsov-max Nov 5, 2024
fe4d8c4
Rename
zhiltsov-max Nov 6, 2024
41f467a
t
zhiltsov-max Nov 6, 2024
418b333
Update quality report numbers, extend setting description
zhiltsov-max Nov 6, 2024
01d5a07
Update changelog
zhiltsov-max Nov 6, 2024
f982371
Merge branch 'develop' into zm/match-empty-frames
zhiltsov-max Nov 6, 2024
f943343
Update parameter
zhiltsov-max Nov 6, 2024
129029e
Merge branch 'develop' into zm/compare-point-groups-in-image-space
zhiltsov-max Nov 6, 2024
d83a38f
Improve oks sigma side description
zhiltsov-max Nov 7, 2024
03a5969
Merge branch 'zm/compare-point-groups-in-image-space' into zm/match-e…
zhiltsov-max Nov 7, 2024
7e84f3a
Update schema
zhiltsov-max Nov 7, 2024
3e9b7e1
Merge branch 'develop' into zm/match-empty-frames
zhiltsov-max Nov 7, 2024
975fdc0
Merge branch 'develop' into zm/match-empty-frames
zhiltsov-max Nov 8, 2024
6570d43
Merge remote-tracking branch 'origin/zm/match-empty-frames' into zm/m…
zhiltsov-max Nov 8, 2024
f8a8249
Fix missing mean_iou value
zhiltsov-max Nov 8, 2024
b42daa3
Add missing shapes counts, rename function
zhiltsov-max Nov 8, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog.d/20241106_170626_mzhiltso_match_empty_frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
### Added

- A quality check option to consider empty frames matching
(<https://github.com/cvat-ai/cvat/pull/8652>)
11 changes: 11 additions & 0 deletions cvat-core/src/quality-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export default class QualitySettings {
#objectVisibilityThreshold: number;
#panopticComparison: boolean;
#compareAttributes: boolean;
#matchEmptyFrames: boolean;
#descriptions: Record<string, string>;

constructor(initialData: SerializedQualitySettingsData) {
Expand All @@ -59,6 +60,7 @@ export default class QualitySettings {
this.#objectVisibilityThreshold = initialData.object_visibility_threshold;
this.#panopticComparison = initialData.panoptic_comparison;
this.#compareAttributes = initialData.compare_attributes;
this.#matchEmptyFrames = initialData.match_empty_frames;
this.#descriptions = initialData.descriptions;
}

Expand Down Expand Up @@ -198,6 +200,14 @@ export default class QualitySettings {
this.#maxValidationsPerJob = newVal;
}

get matchEmptyFrames(): boolean {
return this.#matchEmptyFrames;
}

set matchEmptyFrames(newVal: boolean) {
this.#matchEmptyFrames = newVal;
}

get descriptions(): Record<string, string> {
const descriptions: Record<string, string> = Object.keys(this.#descriptions).reduce((acc, key) => {
const camelCaseKey = _.camelCase(key);
Expand Down Expand Up @@ -226,6 +236,7 @@ export default class QualitySettings {
target_metric: this.#targetMetric,
target_metric_threshold: this.#targetMetricThreshold,
max_validations_per_job: this.#maxValidationsPerJob,
match_empty_frames: this.#matchEmptyFrames,
};

return result;
Expand Down
1 change: 1 addition & 0 deletions cvat-core/src/server-response-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export interface SerializedQualitySettingsData {
object_visibility_threshold?: number;
panoptic_comparison?: boolean;
compare_attributes?: boolean;
match_empty_frames?: boolean;
descriptions?: Record<string, string>;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ function QualityControlPage(): JSX.Element {
settings.lowOverlapThreshold = values.lowOverlapThreshold / 100;
settings.iouThreshold = values.iouThreshold / 100;
settings.compareAttributes = values.compareAttributes;
settings.matchEmptyFrames = values.matchEmptyFrames;

settings.oksSigma = values.oksSigma / 100;
settings.pointSizeBase = values.pointSizeBase;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export default function QualitySettingsForm(props: Readonly<Props>): JSX.Element
lowOverlapThreshold: settings.lowOverlapThreshold * 100,
iouThreshold: settings.iouThreshold * 100,
compareAttributes: settings.compareAttributes,
matchEmptyFrames: settings.matchEmptyFrames,

oksSigma: settings.oksSigma * 100,
pointSizeBase: settings.pointSizeBase,
Expand Down Expand Up @@ -79,6 +80,8 @@ export default function QualitySettingsForm(props: Readonly<Props>): JSX.Element
<>
{makeTooltipFragment('Target metric', targetMetricDescription)}
{makeTooltipFragment('Target metric threshold', settings.descriptions.targetMetricThreshold)}
{makeTooltipFragment('Compare attributes', settings.descriptions.compareAttributes)}
{makeTooltipFragment('Match empty frames', settings.descriptions.matchEmptyFrames)}
</>,
);

Expand Down Expand Up @@ -181,6 +184,30 @@ export default function QualitySettingsForm(props: Readonly<Props>): JSX.Element
</Form.Item>
</Col>
</Row>
<Row>
<Col span={12}>
<Form.Item
name='compareAttributes'
valuePropName='checked'
rules={[{ required: true }]}
>
<Checkbox>
<Text className='cvat-text-color'>Compare attributes</Text>
</Checkbox>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name='matchEmptyFrames'
valuePropName='checked'
rules={[{ required: true }]}
>
<Checkbox>
<Text className='cvat-text-color'>Match empty frames</Text>
</Checkbox>
</Form.Item>
</Col>
</Row>
<Divider />
<Row className='cvat-quality-settings-title'>
<Text strong>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 4.2.15 on 2024-11-05 14:22

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("quality_control", "0004_qualitysettings_point_size_base"),
]

operations = [
migrations.AddField(
model_name="qualitysettings",
name="match_empty_frames",
field=models.BooleanField(default=False),
),
]
2 changes: 2 additions & 0 deletions cvat/apps/quality_control/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,8 @@ class QualitySettings(models.Model):

compare_attributes = models.BooleanField()

match_empty_frames = models.BooleanField(default=False)

target_metric = models.CharField(
max_length=32,
choices=QualityTargetMetricType.choices(),
Expand Down
120 changes: 81 additions & 39 deletions cvat/apps/quality_control/quality_reports.py
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ class ComparisonParameters(_Serializable):
panoptic_comparison: bool = True
"Use only the visible part of the masks and polygons in comparisons"

match_empty_frames: bool = False
"""
Consider unannotated (empty) frames as matching. If disabled, quality metrics, such as accuracy,
will be 0 if both GT and DS frames have no annotations. When enabled, they will be 1 instead.
This will also add virtual annotations to empty frames in the comparison results.
"""
zhiltsov-max marked this conversation as resolved.
Show resolved Hide resolved

def _value_serializer(self, v):
if isinstance(v, dm.AnnotationType):
return str(v.name)
Expand All @@ -232,11 +239,11 @@ def from_dict(cls, d: dict):
@define(kw_only=True)
class ConfusionMatrix(_Serializable):
labels: List[str]
rows: np.array
precision: np.array
recall: np.array
accuracy: np.array
jaccard_index: Optional[np.array]
rows: np.ndarray
precision: np.ndarray
recall: np.ndarray
accuracy: np.ndarray
jaccard_index: Optional[np.ndarray]
Comment on lines +242 to +246
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Initialize optional field jaccard_index in ConfusionMatrix.

The jaccard_index field is optional but may cause issues if not properly initialized, especially when old serialized instances are used.

Consider setting a default value in the constructor:

 @define(kw_only=True)
 class ConfusionMatrix(_Serializable):
     labels: List[str]
     rows: np.ndarray
     precision: np.ndarray
     recall: np.ndarray
     accuracy: np.ndarray
-    jaccard_index: Optional[np.ndarray]
+    jaccard_index: Optional[np.ndarray] = None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
rows: np.ndarray
precision: np.ndarray
recall: np.ndarray
accuracy: np.ndarray
jaccard_index: Optional[np.ndarray]
rows: np.ndarray
precision: np.ndarray
recall: np.ndarray
accuracy: np.ndarray
jaccard_index: Optional[np.ndarray] = None


@property
def axes(self):
Expand Down Expand Up @@ -1972,8 +1979,16 @@ def _find_closest_unmatched_shape(shape: dm.Annotation):
gt_label_idx = label_id_map[gt_ann.label] if gt_ann else self._UNMATCHED_IDX
confusion_matrix[ds_label_idx, gt_label_idx] += 1

if self.settings.match_empty_frames and not gt_item.annotations and not ds_item.annotations:
# Add virtual annotations for empty frames
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the purpose of these virtual annotations?

Copy link
Contributor Author

@zhiltsov-max zhiltsov-max Nov 7, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Basically, to break math and code less and get the expected values from accuracy, precision, and recall here and in aggregated reports (job report, task report). For instance, if there are both empty and not empty frames, this helps to get correct metrics in an aggregated report. It's not totally nonsense, as an empty annotation can be considered a frame annotation by itself.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, but it seems like bad UX. You choose to match empty frames, and you inexplicably get fake totals. Moreover, if I understand the code correctly, with this change the totals no longer match the confusion matrix, which seems like it could cause more confusion and math errors down the line.

Is this solution really that much less disruptive than fudging the metric formulas to return 1 instead of 0?

It's not totally nonsense, as an empty annotation can be considered a frame annotation by itself.

This is a valid point, but you're not implementing that consistently. In your implementation, this "empty annotation" only appears when both GT and DS frames are empty. To do this consistently, you'd need to increase the GT count whenever the GT frame is empty, and the DS count whenever the DS frame is empty (and the valid count when both are).

You could also resolve the inconsistency between the confusion matrix and the totals by adding another row/column to the matrix specifically for these "empty annotations".

This would resolve the consistency issues, and slightly improve the UX issue, since you could see in the report where the extra annotation is coming from. But frankly, it still seems easier to me to just fudge the metrics.

valid_labels_count = 1
total_labels_count = 1

valid_shapes_count = 1
total_shapes_count = 1
SpecLad marked this conversation as resolved.
Show resolved Hide resolved

self._frame_results[frame_id] = ComparisonReportFrameSummary(
annotations=self._generate_annotations_summary(
annotations=self._generate_frame_annotations_summary(
confusion_matrix, confusion_matrix_labels
),
annotation_components=ComparisonReportAnnotationComponentsSummary(
Expand Down Expand Up @@ -2015,9 +2030,8 @@ def _make_zero_confusion_matrix(self) -> Tuple[List[str], np.ndarray, Dict[int,

return label_names, confusion_matrix, label_id_idx_map

@classmethod
def _generate_annotations_summary(
cls, confusion_matrix: np.ndarray, confusion_matrix_labels: List[str]
def _compute_annotation_summary(
zhiltsov-max marked this conversation as resolved.
Show resolved Hide resolved
self, confusion_matrix: np.ndarray, confusion_matrix_labels: List[str]
) -> ComparisonReportAnnotationsSummary:
matched_ann_counts = np.diag(confusion_matrix)
ds_ann_counts = np.sum(confusion_matrix, axis=1)
Expand All @@ -2037,10 +2051,10 @@ def _generate_annotations_summary(
) / (total_annotations_count or 1)

valid_annotations_count = np.sum(matched_ann_counts)
missing_annotations_count = np.sum(confusion_matrix[cls._UNMATCHED_IDX, :])
extra_annotations_count = np.sum(confusion_matrix[:, cls._UNMATCHED_IDX])
ds_annotations_count = np.sum(ds_ann_counts[: cls._UNMATCHED_IDX])
gt_annotations_count = np.sum(gt_ann_counts[: cls._UNMATCHED_IDX])
missing_annotations_count = np.sum(confusion_matrix[self._UNMATCHED_IDX, :])
extra_annotations_count = np.sum(confusion_matrix[:, self._UNMATCHED_IDX])
ds_annotations_count = np.sum(ds_ann_counts[: self._UNMATCHED_IDX])
gt_annotations_count = np.sum(gt_ann_counts[: self._UNMATCHED_IDX])

return ComparisonReportAnnotationsSummary(
valid_count=valid_annotations_count,
Expand All @@ -2059,12 +2073,24 @@ def _generate_annotations_summary(
),
)

def generate_report(self) -> ComparisonReport:
self._find_gt_conflicts()
def _generate_frame_annotations_summary(
self, confusion_matrix: np.ndarray, confusion_matrix_labels: List[str]
) -> ComparisonReportAnnotationsSummary:
summary = self._compute_annotation_summary(confusion_matrix, confusion_matrix_labels)

if self.settings.match_empty_frames and summary.total_count == 0:
# Add virtual annotations for empty frames
summary.valid_count = 1
summary.total_count = 1
summary.ds_count = 1
summary.gt_count = 1

return summary

def _generate_dataset_annotations_summary(
self, frame_summaries: Dict[int, ComparisonReportFrameSummary]
) -> Tuple[ComparisonReportAnnotationsSummary, ComparisonReportAnnotationComponentsSummary]:
# accumulate stats
intersection_frames = []
conflicts = []
annotation_components = ComparisonReportAnnotationComponentsSummary(
shape=ComparisonReportAnnotationShapeSummary(
valid_count=0,
Expand All @@ -2082,19 +2108,52 @@ def generate_report(self) -> ComparisonReport:
),
)
mean_ious = []
empty_frame_count = 0
confusion_matrix_labels, confusion_matrix, _ = self._make_zero_confusion_matrix()

for frame_id, frame_result in self._frame_results.items():
intersection_frames.append(frame_id)
conflicts += frame_result.conflicts
for frame_result in frame_summaries.values():
confusion_matrix += frame_result.annotations.confusion_matrix.rows

if not np.any(frame_result.annotations.confusion_matrix.rows):
empty_frame_count += 1

if annotation_components is None:
annotation_components = deepcopy(frame_result.annotation_components)
else:
annotation_components.accumulate(frame_result.annotation_components)

mean_ious.append(frame_result.annotation_components.shape.mean_iou)

annotation_summary = self._compute_annotation_summary(
confusion_matrix, confusion_matrix_labels
)

if self.settings.match_empty_frames and empty_frame_count:
# Add virtual annotations for empty frames,
# they are not included in the confusion matrix
annotation_summary.valid_count += empty_frame_count
annotation_summary.total_count += empty_frame_count
annotation_summary.ds_count += empty_frame_count
annotation_summary.gt_count += empty_frame_count
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if this is really a useful thing to do here. Imagine a situation where there are 100 frames, frame 1 has 1 valid annotations and 2 total annotations, every other frame is empty.

In this case, with match_empty_frames off:

  • frame 1 has accuracy 50%, others have 0%.
  • total accuracy is 50%.

With match_empty_frames on:

  • frame 1 has accuracy 50%, others have 100%.
  • total accuracy is (1+99)/(2+99) = 99%.

Do you think this jump in total accuracy would be expected by the user?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's hard to say about the expectations, to me the user just works in one of the modes - they either consider empty frames as annotated or not. But the second value seems to be more correct, so maybe something should be done for the case with the option disabled.


# Cannot be computed in accumulate()
annotation_components.shape.mean_iou = np.mean(mean_ious)
SpecLad marked this conversation as resolved.
Show resolved Hide resolved

return annotation_summary, annotation_components

def generate_report(self) -> ComparisonReport:
self._find_gt_conflicts()

intersection_frames = []
conflicts = []
for frame_id, frame_result in self._frame_results.items():
intersection_frames.append(frame_id)
conflicts += frame_result.conflicts

annotation_summary, annotations_component_summary = (
self._generate_dataset_annotations_summary(self._frame_results)
)

return ComparisonReport(
parameters=self.settings,
comparison_summary=ComparisonReportComparisonSummary(
Expand All @@ -2110,25 +2169,8 @@ def generate_report(self) -> ComparisonReport:
[c for c in conflicts if c.severity == AnnotationConflictSeverity.ERROR]
),
conflicts_by_type=Counter(c.type for c in conflicts),
annotations=self._generate_annotations_summary(
confusion_matrix, confusion_matrix_labels
),
annotation_components=ComparisonReportAnnotationComponentsSummary(
shape=ComparisonReportAnnotationShapeSummary(
valid_count=annotation_components.shape.valid_count,
missing_count=annotation_components.shape.missing_count,
extra_count=annotation_components.shape.extra_count,
total_count=annotation_components.shape.total_count,
ds_count=annotation_components.shape.ds_count,
gt_count=annotation_components.shape.gt_count,
mean_iou=np.mean(mean_ious),
),
label=ComparisonReportAnnotationLabelSummary(
valid_count=annotation_components.label.valid_count,
invalid_count=annotation_components.label.invalid_count,
total_count=annotation_components.label.total_count,
),
),
annotations=annotation_summary,
annotation_components=annotations_component_summary,
),
frame_results=self._frame_results,
)
Expand Down
8 changes: 8 additions & 0 deletions cvat/apps/quality_control/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,13 +92,15 @@ class Meta:
"object_visibility_threshold",
"panoptic_comparison",
"compare_attributes",
"match_empty_frames",
)
read_only_fields = (
"id",
"task_id",
)

extra_kwargs = {k: {"required": False} for k in fields}
extra_kwargs.setdefault("match_empty_frames", {}).setdefault("default", False)

for field_name, help_text in {
"target_metric": "The primary metric used for quality estimation",
Expand Down Expand Up @@ -164,6 +166,12 @@ class Meta:
Use only the visible part of the masks and polygons in comparisons
""",
"compare_attributes": "Enables or disables annotation attribute comparison",
"match_empty_frames": """
Count empty frames as matching. This affects target metrics like accuracy in cases
there are no annotations. If disabled, frames without annotations
are counted as not matching (accuracy is 0). If enabled, accuracy will be 1 instead.
This will also add virtual annotations to empty frames in the comparison results.
""",
}.items():
extra_kwargs.setdefault(field_name, {}).setdefault(
"help_text", textwrap.dedent(help_text.lstrip("\n"))
Expand Down
16 changes: 16 additions & 0 deletions cvat/schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9729,6 +9729,14 @@ components:
compare_attributes:
type: boolean
description: Enables or disables annotation attribute comparison
match_empty_frames:
type: boolean
default: false
description: |
Count empty frames as matching. This affects target metrics like accuracy in cases
there are no annotations. If disabled, frames without annotations
are counted as not matching (accuracy is 0). If enabled, accuracy will be 1 instead.
This will also add virtual annotations to empty frames in the comparison results.
PatchedTaskValidationLayoutWriteRequest:
type: object
properties:
Expand Down Expand Up @@ -10236,6 +10244,14 @@ components:
compare_attributes:
type: boolean
description: Enables or disables annotation attribute comparison
match_empty_frames:
type: boolean
default: false
description: |
Count empty frames as matching. This affects target metrics like accuracy in cases
there are no annotations. If disabled, frames without annotations
are counted as not matching (accuracy is 0). If enabled, accuracy will be 1 instead.
This will also add virtual annotations to empty frames in the comparison results.
RegisterSerializerEx:
type: object
properties:
Expand Down
Loading
Loading