diff --git a/bats_ai/core/admin/__init__.py b/bats_ai/core/admin/__init__.py index 48b05ad..728cc62 100644 --- a/bats_ai/core/admin/__init__.py +++ b/bats_ai/core/admin/__init__.py @@ -5,6 +5,7 @@ from .species import SpeciesAdmin from .spectrogram import SpectrogramAdmin from .temporal_annotations import TemporalAnnotationsAdmin +from .compressed_spectrogram import CompressedSpectrogramAdmin __all__ = [ 'AnnotationsAdmin', @@ -14,4 +15,5 @@ 'TemporalAnnotationsAdmin', 'SpeciesAdmin', 'GRTSCellsAdmin', + 'CompressedSpectrogramAdmin', ] diff --git a/bats_ai/core/admin/compressed_spectrogram.py b/bats_ai/core/admin/compressed_spectrogram.py new file mode 100644 index 0000000..9dd6cd8 --- /dev/null +++ b/bats_ai/core/admin/compressed_spectrogram.py @@ -0,0 +1,29 @@ +from django.contrib import admin + +from bats_ai.core.models import CompressedSpectrogram + +@admin.register(CompressedSpectrogram) +class CompressedSpectrogramAdmin(admin.ModelAdmin): + list_display = [ + 'pk', + 'recording', + 'spectrogram', + 'image_file', + 'length', + 'widths', + 'starts', + 'stops', + ] + list_display_links = ['pk', 'recording', 'spectrogram'] + list_select_related = True + autocomplete_fields = ['recording'] + readonly_fields = [ + 'recording', + 'spectrogram', + 'image_file', + 'created', + 'modified', + 'widths', + 'starts', + 'stops', + ] diff --git a/bats_ai/core/admin/spectrogram.py b/bats_ai/core/admin/spectrogram.py index 6f2f601..23bfc15 100644 --- a/bats_ai/core/admin/spectrogram.py +++ b/bats_ai/core/admin/spectrogram.py @@ -1,6 +1,8 @@ from django.contrib import admin from bats_ai.core.models import Spectrogram +from django.db.models import QuerySet +from django.http import HttpRequest @admin.register(Spectrogram) @@ -31,3 +33,15 @@ class SpectrogramAdmin(admin.ModelAdmin): 'frequency_min', 'frequency_max', ] + + actions = ['computed_compressed_spectrogram'] + + + @admin.action(description='Compute Compressed Spectrograms') + def computed_compressed_spectrogram(self, request: HttpRequest, queryset: QuerySet): + counter = 0 + for recording in queryset: + if not recording.has_spectrogram: + recording_compute_spectrogram.delay(recording.pk) + counter += 1 + self.message_user(request, f'{counter} recordings queued', messages.SUCCESS) diff --git a/bats_ai/core/migrations/0009_annotations_type.py b/bats_ai/core/migrations/0009_annotations_type.py deleted file mode 100644 index 6091cd7..0000000 --- a/bats_ai/core/migrations/0009_annotations_type.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.1.13 on 2024-03-22 17:23 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('core', '0008_grtscells_recording_recorded_time'), - ] - - operations = [ - migrations.AddField( - model_name='annotations', - name='type', - field=models.TextField(blank=True, null=True), - ), - ] diff --git a/bats_ai/core/migrations/0010_recording_computed_species_recording_detector_and_more.py b/bats_ai/core/migrations/0009_annotations_type_recording_computed_species_and_more.py similarity index 64% rename from bats_ai/core/migrations/0010_recording_computed_species_recording_detector_and_more.py rename to bats_ai/core/migrations/0009_annotations_type_recording_computed_species_and_more.py index 1819063..004d224 100644 --- a/bats_ai/core/migrations/0010_recording_computed_species_recording_detector_and_more.py +++ b/bats_ai/core/migrations/0009_annotations_type_recording_computed_species_and_more.py @@ -1,20 +1,24 @@ -# Generated by Django 4.1.13 on 2024-04-03 13:07 +# Generated by Django 4.1.13 on 2024-04-11 13:06 from django.db import migrations, models class Migration(migrations.Migration): + dependencies = [ - ('core', '0009_annotations_type'), + ('core', '0008_grtscells_recording_recorded_time'), ] operations = [ + migrations.AddField( + model_name='annotations', + name='type', + field=models.TextField(blank=True, null=True), + ), migrations.AddField( model_name='recording', name='computed_species', - field=models.ManyToManyField( - related_name='recording_computed_species', to='core.species' - ), + field=models.ManyToManyField(related_name='recording_computed_species', to='core.species'), ), migrations.AddField( model_name='recording', @@ -24,9 +28,7 @@ class Migration(migrations.Migration): migrations.AddField( model_name='recording', name='official_species', - field=models.ManyToManyField( - related_name='recording_official_species', to='core.species' - ), + field=models.ManyToManyField(related_name='recording_official_species', to='core.species'), ), migrations.AddField( model_name='recording', @@ -48,4 +50,9 @@ class Migration(migrations.Migration): name='unusual_occurrences', field=models.TextField(blank=True, null=True), ), + migrations.AddField( + model_name='spectrogram', + name='colormap', + field=models.CharField(max_length=20, null=True), + ), ] diff --git a/bats_ai/core/migrations/0009_spectrogram_colormap.py b/bats_ai/core/migrations/0009_spectrogram_colormap.py deleted file mode 100644 index a89259e..0000000 --- a/bats_ai/core/migrations/0009_spectrogram_colormap.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 4.1.13 on 2024-03-12 21:53 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ('core', '0008_grtscells_recording_recorded_time'), - ] - - operations = [ - migrations.AddField( - model_name='spectrogram', - name='colormap', - field=models.CharField(max_length=20, null=True), - ), - ] diff --git a/bats_ai/core/migrations/0010_compressedspectrogram.py b/bats_ai/core/migrations/0010_compressedspectrogram.py new file mode 100644 index 0000000..f7a1fb3 --- /dev/null +++ b/bats_ai/core/migrations/0010_compressedspectrogram.py @@ -0,0 +1,36 @@ +# Generated by Django 4.1.13 on 2024-04-12 18:56 + +import django.contrib.postgres.fields +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_annotations_type_recording_computed_species_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='CompressedSpectrogram', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('image_file', models.FileField(upload_to='')), + ('length', models.IntegerField()), + ('starts', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None), size=None)), + ('stops', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None), size=None)), + ('widths', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.IntegerField(), size=None), size=None)), + ('cache_invalidated', models.BooleanField(default=True)), + ('recording', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.recording')), + ('spectrogram', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.spectrogram')), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/bats_ai/core/migrations/0011_spectrogram_colormap.py b/bats_ai/core/migrations/0011_spectrogram_colormap.py deleted file mode 100644 index 9052b5d..0000000 --- a/bats_ai/core/migrations/0011_spectrogram_colormap.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.13 on 2024-04-09 13:49 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('core', '0010_recording_computed_species_recording_detector_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='spectrogram', - name='colormap', - field=models.CharField(max_length=20, null=True), - ), - ] diff --git a/bats_ai/core/models/__init__.py b/bats_ai/core/models/__init__.py index 5979660..ac6f233 100644 --- a/bats_ai/core/models/__init__.py +++ b/bats_ai/core/models/__init__.py @@ -6,6 +6,7 @@ from .species import Species from .spectrogram import Spectrogram from .temporal_annotations import TemporalAnnotations +from .compressed_spectrogram import CompressedSpectrogram __all__ = [ 'Annotations', @@ -17,4 +18,5 @@ 'TemporalAnnotations', 'GRTSCells', 'colormap', + 'CompressedSpectrogram', ] diff --git a/bats_ai/core/models/compressed_spectrogram.py b/bats_ai/core/models/compressed_spectrogram.py new file mode 100644 index 0000000..aa3dadd --- /dev/null +++ b/bats_ai/core/models/compressed_spectrogram.py @@ -0,0 +1,116 @@ +import base64 +import io +import math + +from PIL import Image +import cv2 +from django.core.files import File +from django.db import models +from django.db.models.fields.files import FieldFile +from django.contrib.postgres.fields import ArrayField +from django_extensions.db.models import TimeStampedModel +import librosa +import matplotlib.pyplot as plt +import numpy as np +import scipy +import tqdm + +from bats_ai.core.models import Annotations, Recording, Spectrogram +from django.dispatch import receiver + +FREQ_MIN = 5e3 +FREQ_MAX = 120e3 +FREQ_PAD = 2e3 + + +# TimeStampedModel also provides "created" and "modified" fields +class CompressedSpectrogram(TimeStampedModel, models.Model): + recording = models.ForeignKey(Recording, on_delete=models.CASCADE) + spectrogram = models.ForeignKey(Spectrogram, on_delete=models.CASCADE) + image_file = models.FileField() + length = models.IntegerField() # pixels + starts = ArrayField(ArrayField(models.IntegerField())) # pixels + stops = ArrayField(ArrayField(models.IntegerField())) # milliseconds + widths = ArrayField(ArrayField(models.IntegerField())) # hz + cache_invalidated = models.BooleanField(default=True) + + + def predict(self): + import json + import os + + import onnx + import onnxruntime as ort + import tqdm + + img = Image.open(self.image_file) + + relative = ('..',) * 4 + asset_path = os.path.abspath(os.path.join(__file__, *relative, 'assets')) + + onnx_filename = os.path.join(asset_path, 'model.mobilenet.onnx') + assert os.path.exists(onnx_filename) + + session = ort.InferenceSession( + onnx_filename, + providers=[ + ( + 'CUDAExecutionProvider', + { + 'cudnn_conv_use_max_workspace': '1', + 'device_id': 0, + 'cudnn_conv_algo_search': 'HEURISTIC', + }, + ), + 'CPUExecutionProvider', + ], + ) + + img = np.array(img) + + h, w, c = img.shape + ratio_y = 224 / h + ratio_x = ratio_y * 0.5 + raw = cv2.resize(img, None, fx=ratio_x, fy=ratio_y, interpolation=cv2.INTER_LANCZOS4) + + h, w, c = raw.shape + if w <= h: + canvas = np.zeros((h, h + 1, 3), dtype=raw.dtype) + canvas[:, :w, :] = raw + raw = canvas + h, w, c = raw.shape + + inputs_ = [] + for index in range(0, w - h, 100): + inputs_.append(raw[:, index : index + h, :]) + inputs_.append(raw[:, -h:, :]) + inputs_ = np.array(inputs_) + + chunksize = 1 + chunks = np.array_split(inputs_, np.arange(chunksize, len(inputs_), chunksize)) + outputs = [] + for chunk in tqdm.tqdm(chunks, desc='Inference'): + outputs_ = session.run( + None, + {'input': chunk}, + ) + outputs.append(outputs_[0]) + outputs = np.vstack(outputs) + outputs = outputs.mean(axis=0) + + model = onnx.load(onnx_filename) + mapping = json.loads(model.metadata_props[0].value) + labels = [mapping['forward'][str(index)] for index in range(len(mapping['forward']))] + + prediction = np.argmax(outputs) + label = labels[prediction] + score = outputs[prediction] + + confs = dict(zip(labels, outputs)) + + return label, score, confs + +@receiver(models.signals.pre_delete, sender=Spectrogram) +def delete_content(sender, instance, **kwargs): + if instance.image_file: + instance.image_file.delete(save=False) diff --git a/bats_ai/core/models/recording.py b/bats_ai/core/models/recording.py index 15e3288..78b3e59 100644 --- a/bats_ai/core/models/recording.py +++ b/bats_ai/core/models/recording.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User from django.contrib.gis.db import models from django_extensions.db.models import TimeStampedModel +from django.dispatch import receiver from .species import Species @@ -86,13 +87,12 @@ def spectrogram(self): spectrograms = self.spectrograms - if len(spectrograms) == 0: - Spectrogram.generate(self, colormap=COLORMAP) - - spectrograms = self.spectrograms - assert len(spectrograms) == 1 - assert len(spectrograms) >= 1 spectrogram = spectrograms[0] # most recently created return spectrogram + +@receiver(models.signals.pre_delete, sender=Recording) +def delete_content(sender, instance, **kwargs): + if instance.audio_file: + instance.audio_file.delete(save=False) diff --git a/bats_ai/core/models/spectrogram.py b/bats_ai/core/models/spectrogram.py index 4640923..9ff800f 100644 --- a/bats_ai/core/models/spectrogram.py +++ b/bats_ai/core/models/spectrogram.py @@ -15,6 +15,7 @@ import tqdm from bats_ai.core.models import Annotations, Recording +from django.dispatch import receiver FREQ_MIN = 5e3 FREQ_MAX = 120e3 @@ -155,7 +156,6 @@ def generate(cls, recording, colormap=None, dpi=600): img_filtered = cv2.imread(f'/tmp/temp.{colormap}.jpg', cv2.IMREAD_GRAYSCALE) img_filtered = img_filtered.astype(np.float32) assert img_filtered.min() == 0 - assert img_filtered.max() == 255 img_filtered = img_filtered.astype(np.float32) img_filtered /= 0.9 img_filtered[img_filtered < 0] = 0 @@ -188,7 +188,7 @@ def generate(cls, recording, colormap=None, dpi=600): img.save(buf, format='JPEG', quality=80) buf.seek(0) - name = 'spectrogram.jpg' + name = f'{recording.pk}_{colormap}_spectrogram.jpg' image_file = File(buf, name=name) spectrogram = cls( @@ -202,148 +202,7 @@ def generate(cls, recording, colormap=None, dpi=600): colormap=colormap, ) spectrogram.save() - - @property - def compressed(self): - img = self.image_np - - annotations = Annotations.objects.filter(recording=self.recording) - - threshold = 0.5 - while True: - canvas = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) - canvas = canvas.astype(np.float32) - - is_light = np.median(canvas) > 128.0 - if is_light: - canvas = 255.0 - canvas - - amplitude = canvas.max(axis=0) - amplitude -= amplitude.min() - amplitude /= amplitude.max() - amplitude[amplitude < threshold] = 0.0 - amplitude[amplitude > 0] = 1.0 - amplitude = amplitude.reshape(1, -1) - - canvas -= canvas.min() - canvas /= canvas.max() - canvas *= 255.0 - canvas *= amplitude - canvas = np.around(canvas).astype(np.uint8) - - width = canvas.shape[1] - for annotation in annotations: - start = annotation.start_time / self.duration - stop = annotation.end_time / self.duration - - start = int(np.around(start * width)) - stop = int(np.around(stop * width)) - canvas[:, start : stop + 1] = 255.0 - - mask = canvas.max(axis=0) - mask = scipy.signal.medfilt(mask, 3) - mask[0] = 0 - mask[-1] = 0 - - starts = [] - stops = [] - for index in range(1, len(mask) - 1): - value_pre = mask[index - 1] - value = mask[index] - value_post = mask[index + 1] - if value != 0: - if value_pre == 0: - starts.append(index) - if value_post == 0: - stops.append(index) - assert len(starts) == len(stops) - - starts = [val - 40 for val in starts] # 10 ms buffer - stops = [val + 40 for val in stops] # 10 ms buffer - ranges = list(zip(starts, stops)) - - while True: - found = False - merged = [] - index = 0 - while index < len(ranges) - 1: - start1, stop1 = ranges[index] - start2, stop2 = ranges[index + 1] - - start1 = min(max(start1, 0), len(mask)) - start2 = min(max(start2, 0), len(mask)) - stop1 = min(max(stop1, 0), len(mask)) - stop2 = min(max(stop2, 0), len(mask)) - - if stop1 >= start2: - found = True - merged.append((start1, stop2)) - index += 2 - else: - merged.append((start1, stop1)) - index += 1 - if index == len(ranges) - 1: - merged.append((start2, stop2)) - ranges = merged - if not found: - for index in range(1, len(ranges)): - start1, stop1 = ranges[index - 1] - start2, stop2 = ranges[index] - assert start1 < stop1 - assert start2 < stop2 - assert start1 < start2 - assert stop1 < stop2 - assert stop1 < start2 - break - - segments = [] - starts_ = [] - stops_ = [] - domain = img.shape[1] - widths = [] - total_width = 0 - for start, stop in ranges: - segment = img[:, start:stop] - segments.append(segment) - - starts_.append(int(round(self.duration * (start / domain)))) - stops_.append(int(round(self.duration * (stop / domain)))) - widths.append(stop - start) - total_width += stop - start - - # buffer = np.zeros((len(img), 20, 3), dtype=img.dtype) - # segments.append(buffer) - # segments = segments[:-1] - - if len(segments) > 0: - break - - threshold -= 0.05 - if threshold < 0: - segments = None - break - - if segments is None: - canvas = img.copy() - else: - canvas = np.hstack(segments) - - canvas = Image.fromarray(canvas, 'RGB') - - canvas.save('temp.compressed.jpg') - - buf = io.BytesIO() - canvas.save(buf, format='JPEG', quality=80) - buf.seek(0) - img_base64 = base64.b64encode(buf.getvalue()).decode('utf-8') - - metadata = { - 'starts': starts_, - 'stops': stops_, - 'widths': widths, - 'length': total_width, - } - return canvas, img_base64, metadata + return spectrogram.pk @property def image_np(self): @@ -439,3 +298,9 @@ def predict(self): confs = dict(zip(labels, outputs)) return label, score, confs + +@receiver(models.signals.pre_delete, sender=Spectrogram) +def delete_content(sender, instance, **kwargs): + if instance.image_file: + instance.image_file.delete(save=False) + diff --git a/bats_ai/core/tasks.py b/bats_ai/core/tasks.py index 8a7780c..215e92d 100644 --- a/bats_ai/core/tasks.py +++ b/bats_ai/core/tasks.py @@ -1,6 +1,154 @@ from celery import shared_task -from bats_ai.core.models import Image, Recording, colormap +from bats_ai.core.models import Image, Recording, colormap, Spectrogram, CompressedSpectrogram, Annotations + +import io +import math +from django.core.files import File +from PIL import Image +import cv2 +import librosa +import matplotlib.pyplot as plt +import numpy as np +import scipy +import tqdm + +def generate_compressed(recording: Recording, spectrogram: Spectrogram): + print(spectrogram) + img = spectrogram.image_np + + annotations = Annotations.objects.filter(recording=recording) + + threshold = 0.5 + while True: + canvas = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY) + canvas = canvas.astype(np.float32) + + is_light = np.median(canvas) > 128.0 + if is_light: + canvas = 255.0 - canvas + + amplitude = canvas.max(axis=0) + amplitude -= amplitude.min() + amplitude /= amplitude.max() + amplitude[amplitude < threshold] = 0.0 + amplitude[amplitude > 0] = 1.0 + amplitude = amplitude.reshape(1, -1) + + canvas -= canvas.min() + canvas /= canvas.max() + canvas *= 255.0 + canvas *= amplitude + canvas = np.around(canvas).astype(np.uint8) + + width = canvas.shape[1] + for annotation in annotations: + start = annotation.start_time / spectrogram.duration + stop = annotation.end_time / spectrogram.duration + + start = int(np.around(start * width)) + stop = int(np.around(stop * width)) + canvas[:, start : stop + 1] = 255.0 + + mask = canvas.max(axis=0) + mask = scipy.signal.medfilt(mask, 3) + mask[0] = 0 + mask[-1] = 0 + + starts = [] + stops = [] + for index in range(1, len(mask) - 1): + value_pre = mask[index - 1] + value = mask[index] + value_post = mask[index + 1] + if value != 0: + if value_pre == 0: + starts.append(index) + if value_post == 0: + stops.append(index) + assert len(starts) == len(stops) + + starts = [val - 40 for val in starts] # 10 ms buffer + stops = [val + 40 for val in stops] # 10 ms buffer + ranges = list(zip(starts, stops)) + + while True: + found = False + merged = [] + index = 0 + while index < len(ranges) - 1: + start1, stop1 = ranges[index] + start2, stop2 = ranges[index + 1] + + start1 = min(max(start1, 0), len(mask)) + start2 = min(max(start2, 0), len(mask)) + stop1 = min(max(stop1, 0), len(mask)) + stop2 = min(max(stop2, 0), len(mask)) + + if stop1 >= start2: + found = True + merged.append((start1, stop2)) + index += 2 + else: + merged.append((start1, stop1)) + index += 1 + if index == len(ranges) - 1: + merged.append((start2, stop2)) + ranges = merged + if not found: + for index in range(1, len(ranges)): + start1, stop1 = ranges[index - 1] + start2, stop2 = ranges[index] + assert start1 < stop1 + assert start2 < stop2 + assert start1 < start2 + assert stop1 < stop2 + assert stop1 < start2 + break + + segments = [] + starts_ = [] + stops_ = [] + domain = img.shape[1] + widths = [] + total_width = 0 + for start, stop in ranges: + segment = img[:, start:stop] + segments.append(segment) + + starts_.append(int(round(spectrogram.duration * (start / domain)))) + stops_.append(int(round(spectrogram.duration * (stop / domain)))) + widths.append(stop - start) + total_width += stop - start + + # buffer = np.zeros((len(img), 20, 3), dtype=img.dtype) + # segments.append(buffer) + # segments = segments[:-1] + + if len(segments) > 0: + break + + threshold -= 0.05 + if threshold < 0: + segments = None + break + + if segments is None: + canvas = img.copy() + else: + canvas = np.hstack(segments) + + canvas = Image.fromarray(canvas, 'RGB') + + canvas.save('temp.compressed.jpg') + + buf = io.BytesIO() + canvas.save(buf, format='JPEG', quality=80) + buf.seek(0) + name = f'{spectrogram.pk}_spectrogram_compressed.jpg' + image_file = File(buf, name=name) + + return total_width, image_file, widths, starts, stops @shared_task @@ -18,9 +166,41 @@ def recording_compute_spectrogram(recording_id: int): None, # Default (dark) spectrogram 'light', # Light spectrogram ] - + spectrogram_id = None for cmap in cmaps: with colormap(cmap): - if not recording.has_spectrogram: - recording.spectrogram # compute by simply referenceing the attribute - assert recording.has_spectrogram + spectrogram_id_temp = Spectrogram.generate(recording, cmap) + if cmap is None: + spectrogram_id = spectrogram_id_temp + if spectrogram_id is not None: + generate_compress_spectrogram.delay(recording_id, spectrogram_id) + + +@shared_task +def generate_compress_spectrogram(recording_id: int, spectrogram_id: int): + recording = Recording.objects.get(pk=recording_id) + spectrogram = Spectrogram.objects.get(pk=spectrogram_id) + length, image_file, widths, starts, stops = generate_compressed(recording, spectrogram) + found = CompressedSpectrogram.objects.filter(recording=recording, spectrogram=spectrogram) + if found.exists(): + existing = found.first() + existing.length = length + existing.image_file = image_file + existing.widths = widths + existing.starts = starts + existing.stops = stops + existing.cache_invalidated = False + existing.save() + else: + CompressedSpectrogram.objects.create( + recording=recording, + spectrogram=spectrogram, + image_file=image_file, + length=length, + widths=widths, + starts=starts, + stops=stops, + cache_invalidated=False + ) + +