diff --git a/backend/api/admin/data/legacy_spectrogram_configuration.py b/backend/api/admin/data/legacy_spectrogram_configuration.py index 7926f1d98..2e96c3958 100644 --- a/backend/api/admin/data/legacy_spectrogram_configuration.py +++ b/backend/api/admin/data/legacy_spectrogram_configuration.py @@ -23,8 +23,6 @@ class LegacySpectrogramConfigurationAdmin(ExtendedModelAdmin): "temporal_resolution", "sensitivity_dB", "peak_voltage", - "linear_frequency_scale", - "multi_linear_frequency_scale", ) search_fields = [ "spectrogram_analysis__dataset__name", diff --git a/backend/api/admin/data/spectrogram_analysis.py b/backend/api/admin/data/spectrogram_analysis.py index 4c350ff8d..c28595c15 100644 --- a/backend/api/admin/data/spectrogram_analysis.py +++ b/backend/api/admin/data/spectrogram_analysis.py @@ -62,6 +62,7 @@ class SpectrogramAnalysisAdmin(ExtendedModelAdmin): "start", "end", "legacy", + "list_frequency_scale_parts", ) search_fields = ["id", "name", "dataset__name"] @@ -69,6 +70,11 @@ def dynamic(self, obj: SpectrogramAnalysis) -> str: """Get dynamic min and max in one field""" return f"{obj.dynamic_min} - {obj.dynamic_max}" + @admin.display(description="Frequency scale parts") + def list_frequency_scale_parts(self, obj: SpectrogramAnalysis): + """show_labels""" + return self.list_queryset(obj.frequency_scale_parts.all()) + actions = [ "store_paths", ] diff --git a/backend/api/management/commands/seed.py b/backend/api/management/commands/seed.py index a508593fa..ec37acad0 100644 --- a/backend/api/management/commands/seed.py +++ b/backend/api/management/commands/seed.py @@ -28,8 +28,9 @@ Annotation, ) from backend.api.models.annotation.annotation_campaign import AnnotationCampaignAnalysis -from backend.api.models.data.scales import get_frequency_scales -from backend.api.schema.enums import AnnotationType +from backend.api.models.data.linear_scale import ( + get_frequency_scale_parts, +) from backend.aplose.models import AploseUser from backend.aplose.models.user import ExpertiseLevel, User from backend.osmosewebsite.management.commands.seed import Command as WebsiteCommand @@ -170,7 +171,12 @@ def _create_metadata(self): def __get_analysis( self, dataset: Dataset - ) -> ([SpectrogramAnalysis], [LegacySpectrogramConfiguration]): + ) -> tuple[ + list[SpectrogramAnalysis], + list[LegacySpectrogramConfiguration], + list[SpectrogramAnalysis.frequency_scale_parts.through], + ]: + scales_rel = [] analysis = [ SpectrogramAnalysis( # AbstractDataset @@ -194,9 +200,15 @@ def __get_analysis( dynamic_max=0, ) ] - linear_scale, multi_linear_scale = get_frequency_scales( + + for scale in get_frequency_scale_parts( name=dataset.name, sample_rate=self.legacy_fft.sampling_frequency - ) + ): + scales_rel.append( + SpectrogramAnalysis.frequency_scale_parts.through( + spectrogramanalysis=analysis[0], linearscale=scale + ) + ) legacy_configurations = [ LegacySpectrogramConfiguration( spectrogram_analysis=analysis[0], @@ -208,20 +220,15 @@ def __get_analysis( hp_filter_min_frequency=0, window_type="Hamming", frequency_resolution=0, - linear_frequency_scale=linear_scale, - multi_linear_frequency_scale=multi_linear_scale, ) ] if dataset.name == "Test Dataset": - for scale in ["porp_delph", "dual_lf_hf", "audible"]: - linear_scale, multi_linear_scale = get_frequency_scales( - name=scale, sample_rate=self.legacy_fft.sampling_frequency - ) + for scale_name in ["porp_delph", "dual_lf_hf", "audible"]: a = SpectrogramAnalysis( # AbstractDataset - name=f"4096_4096_90_{scale}", - path=f"processed/spectrogram/4096_4096_90_{scale}", + name=f"4096_4096_90_{scale_name}", + path=f"processed/spectrogram/4096_4096_90_{scale_name}", owner=self.admin, legacy=True, # AbstractAnalysis @@ -239,11 +246,19 @@ def __get_analysis( dynamic_min=0, dynamic_max=0, ) + for scale in get_frequency_scale_parts( + name=scale_name, sample_rate=self.legacy_fft.sampling_frequency + ): + scales_rel.append( + SpectrogramAnalysis.frequency_scale_parts.through( + spectrogramanalysis=analysis[0], linearscale=scale + ) + ) analysis.append(a) legacy_configurations.append( LegacySpectrogramConfiguration( spectrogram_analysis=a, - folder=f"4096_4096_90_{scale}", + folder=f"4096_4096_90_{scale_name}", zoom_level=3, spectrogram_normalization="density", data_normalization="0", @@ -251,15 +266,13 @@ def __get_analysis( hp_filter_min_frequency=0, window_type="Hamming", frequency_resolution=0, - linear_frequency_scale=linear_scale, - multi_linear_frequency_scale=multi_linear_scale, ) ) - return analysis, legacy_configurations + return analysis, legacy_configurations, scales_rel def __get_spectrograms( - self, analysis: [SpectrogramAnalysis] - ) -> ([Spectrogram], [Spectrogram.analysis.through]): + self, analysis: list[SpectrogramAnalysis] + ) -> tuple[list[Spectrogram], list[Spectrogram.analysis.through]]: spectrograms = [] rels = [] for k in range(1, self.files_nb): @@ -290,6 +303,7 @@ def _create_datasets(self): spectrograms = [] spectrogram_rels = [] analysis = [] + analysis_scales_relations = [] legacy_configurations = [] for name in self.dataset_names: @@ -304,8 +318,11 @@ def _create_datasets(self): datasets.append(dataset) # Create analysis - dataset_analysis, dataset_legacy_conf = self.__get_analysis(dataset) + dataset_analysis, dataset_legacy_conf, scales_rel = self.__get_analysis( + dataset + ) analysis += dataset_analysis + analysis_scales_relations += scales_rel legacy_configurations += dataset_legacy_conf # Create spectrograms @@ -315,6 +332,9 @@ def _create_datasets(self): Dataset.objects.bulk_create(datasets) SpectrogramAnalysis.objects.bulk_create(analysis) + SpectrogramAnalysis.frequency_scale_parts.through.objects.bulk_create( + analysis_scales_relations + ) LegacySpectrogramConfiguration.objects.bulk_create(legacy_configurations) Spectrogram.objects.bulk_create(spectrograms) Spectrogram.analysis.through.objects.bulk_create(spectrogram_rels) diff --git a/backend/api/migrations/0006_spectrogramanalysis_frequency_scale_parts.py b/backend/api/migrations/0006_spectrogramanalysis_frequency_scale_parts.py new file mode 100644 index 000000000..0cebd513d --- /dev/null +++ b/backend/api/migrations/0006_spectrogramanalysis_frequency_scale_parts.py @@ -0,0 +1,65 @@ +# Generated by Django 3.2.25 on 2026-05-28 12:47 + +from django.db import migrations, models + + +def reverse_insert(apps, _): + SpectrogramAnalysis = apps.get_model("api", "SpectrogramAnalysis") + MultiLinearScale = apps.get_model("api", "MultiLinearScale") + + for a in SpectrogramAnalysis.objects.all(): + if a.frequency_scale_parts.exists() and a.legacy_configuration: + multi_scale = MultiLinearScale.objects.create() + for linear_scale in a.frequency_scale_parts.all(): + multi_scale.inner_scales.add(linear_scale) + a.legacy_configuration.multi_linear_frequency_scale = multi_scale + a.legacy_configuration.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("api", "0005_alter_spectrogramanalysisrelation_analysis"), + ] + + operations = [ + migrations.AddField( + model_name="spectrogramanalysis", + name="frequency_scale_parts", + field=models.ManyToManyField( + related_name="spectrogram_analysis", to="api.LinearScale" + ), + ), + migrations.RunSQL( + """ + INSERT INTO api_spectrogramanalysis_frequency_scale_parts (spectrogramanalysis_id, linearscale_id) + SELECT + conf.spectrogram_analysis_id, + conf.linear_frequency_scale_id + FROM api_legacyspectrogramconfiguration conf + WHERE conf.linear_frequency_scale_id is not null ; + INSERT INTO api_spectrogramanalysis_frequency_scale_parts (spectrogramanalysis_id, linearscale_id) + SELECT + conf.spectrogram_analysis_id, + innerscales.linearscale_id + FROM api_legacyspectrogramconfiguration conf + LEFT JOIN api_multilinearscale multiscale ON multiscale.id = conf.multi_linear_frequency_scale_id + LEFT JOIN api_multilinearscale_inner_scales innerscales ON innerscales.multilinearscale_id = multiscale.id + WHERE conf.multi_linear_frequency_scale_id is not null ; + """, + migrations.RunSQL.noop, + ), + migrations.RunPython(migrations.RunPython.noop, reverse_insert), + migrations.RemoveConstraint( + model_name="legacyspectrogramconfiguration", + name="legacy_spectrogram_configuration_max_one_scale", + ), + migrations.RemoveField( + model_name="legacyspectrogramconfiguration", + name="linear_frequency_scale", + ), + migrations.RemoveField( + model_name="legacyspectrogramconfiguration", + name="multi_linear_frequency_scale", + ), + ] diff --git a/backend/api/models/data/__init__.py b/backend/api/models/data/__init__.py index f15c83ae0..1839be4d8 100644 --- a/backend/api/models/data/__init__.py +++ b/backend/api/models/data/__init__.py @@ -4,6 +4,6 @@ from .dataset import Dataset from .fft import FFT from .legacy_spectrogram_configuration import LegacySpectrogramConfiguration -from .scales import LinearScale, MultiLinearScale +from .linear_scale import LinearScale, MultiLinearScale from .spectrogram import Spectrogram, SpectrogramAnalysisRelation from .spectrogram_analysis import SpectrogramAnalysis diff --git a/backend/api/models/data/legacy_spectrogram_configuration.py b/backend/api/models/data/legacy_spectrogram_configuration.py index 94c363504..de23e544b 100644 --- a/backend/api/models/data/legacy_spectrogram_configuration.py +++ b/backend/api/models/data/legacy_spectrogram_configuration.py @@ -2,35 +2,12 @@ from django.contrib.postgres.fields import ArrayField from django.db import models -from .scales import LinearScale, MultiLinearScale - class LegacySpectrogramConfiguration(models.Model): """ Table containing spectrogram configuration used for datasets and annotation campaigns. """ - class Meta: - constraints = [ - models.CheckConstraint( - name="legacy_spectrogram_configuration_max_one_scale", - check=( - models.Q( - linear_frequency_scale__isnull=True, - multi_linear_frequency_scale__isnull=False, - ) - | models.Q( - linear_frequency_scale__isnull=False, - multi_linear_frequency_scale__isnull=True, - ) - | models.Q( - linear_frequency_scale__isnull=True, - multi_linear_frequency_scale__isnull=True, - ) - ), - ), - ] - def __str__(self): return self.folder @@ -57,13 +34,6 @@ def __str__(self): temporal_resolution = models.FloatField(null=True, blank=True) gain_dB = models.FloatField(null=True, blank=True) - linear_frequency_scale = models.ForeignKey( - LinearScale, on_delete=models.SET_NULL, blank=True, null=True - ) - multi_linear_frequency_scale = models.ForeignKey( - MultiLinearScale, on_delete=models.SET_NULL, blank=True, null=True - ) - # TODO: # def zoom_tiles(self, tile_name): # """Generate zoom tile filenames for SpectrogramConfiguration""" diff --git a/backend/api/models/data/linear_scale.py b/backend/api/models/data/linear_scale.py new file mode 100644 index 000000000..02edc9703 --- /dev/null +++ b/backend/api/models/data/linear_scale.py @@ -0,0 +1,63 @@ +"""Spectrogram scale models""" + +from django.db import models + + +class LinearScale(models.Model): + """Linear spectrogram scale""" + + def __str__(self): + if self.name: + return self.name + return f"Linear ({self.min_value} - {self.max_value})[{self.ratio}]" + + name = models.CharField(max_length=255, blank=True, null=True) + ratio = models.FloatField(default=1) + min_value = models.FloatField() + max_value = models.FloatField() + + +class MultiLinearScale(models.Model): + """Multi-linear spectrogram scale""" + + def __str__(self): + if self.name: + return self.name + return f"Multi-Linear {self.id}" + + name = models.CharField(max_length=255, blank=True, null=True) + inner_scales = models.ManyToManyField(LinearScale, related_name="outer_scales") + + +def get_frequency_scale_parts(name: str | None, sample_rate: int) -> list[LinearScale]: + """return scale type, min freq, max freq and parameters for multiscale""" + if name is None: + return [] + if name.lower() == "porp_delph": + return [ + LinearScale.objects.get_or_create(ratio=0.5, min_value=0, max_value=30_000)[ + 0 + ], + LinearScale.objects.get_or_create( + ratio=0.7, min_value=30_000, max_value=80_000 + )[0], + LinearScale.objects.get_or_create( + ratio=1, min_value=80_000, max_value=sample_rate / 2 + )[0], + ] + if name.lower() == "dual_lf_hf": + return [ + LinearScale.objects.get_or_create(ratio=0.5, min_value=0, max_value=22_000)[ + 0 + ], + LinearScale.objects.get_or_create( + ratio=1, min_value=100_000, max_value=sample_rate / 2 + )[0], + ] + if name.lower() == "audible": + return [ + LinearScale.objects.get_or_create( + name="audible", min_value=0, max_value=22_000 + )[0] + ] + return [] diff --git a/backend/api/models/data/scales.py b/backend/api/models/data/scales.py deleted file mode 100644 index b85d3edbf..000000000 --- a/backend/api/models/data/scales.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Spectrogram scale models""" -from typing import Optional - -from django.db import models - - -class LinearScale(models.Model): - """Linear spectrogram scale""" - - def __str__(self): - if self.name: - return self.name - return f"Linear ({self.min_value} - {self.max_value})[{self.ratio}]" - - name = models.CharField(max_length=255, blank=True, null=True) - ratio = models.FloatField(default=1) - min_value = models.FloatField() - max_value = models.FloatField() - - -class MultiLinearScale(models.Model): - """Multi-linear spectrogram scale""" - - def __str__(self): - if self.name: - return self.name - return f"Multi-Linear {self.id}" - - name = models.CharField(max_length=255, blank=True, null=True) - inner_scales = models.ManyToManyField(LinearScale, related_name="outer_scales") - - -def get_frequency_scales( - name: str, sample_rate: int -) -> (Optional[LinearScale], Optional[MultiLinearScale]): - """return scale type, min freq, max freq and parameters for multiscale""" - if name.lower() == "porp_delph": - scale, _ = MultiLinearScale.objects.get_or_create( - name="porp_delph", - ) - scale.inner_scales.get_or_create(ratio=0.5, min_value=0, max_value=30_000) - scale.inner_scales.get_or_create(ratio=0.7, min_value=30_000, max_value=80_000) - scale.inner_scales.get_or_create( - ratio=1, min_value=80_000, max_value=sample_rate / 2 - ) - scale.save() - return None, scale - if name.lower() == "dual_lf_hf": - scale, _ = MultiLinearScale.objects.get_or_create( - name="dual_lf_hf", - ) - scale.inner_scales.get_or_create(ratio=0.5, min_value=0, max_value=22_000) - scale.inner_scales.get_or_create( - ratio=1, min_value=100_000, max_value=sample_rate / 2 - ) - scale.save() - return None, scale - if name.lower() == "audible": - scale, _ = LinearScale.objects.get_or_create( - name="audible", min_value=0, max_value=22_000 - ) - return scale, None - return None, None diff --git a/backend/api/models/data/spectrogram_analysis.py b/backend/api/models/data/spectrogram_analysis.py index bc18be2b9..f35cd757c 100644 --- a/backend/api/models/data/spectrogram_analysis.py +++ b/backend/api/models/data/spectrogram_analysis.py @@ -12,6 +12,7 @@ from .colormap import Colormap from .dataset import Dataset from .fft import FFT +from .linear_scale import LinearScale class SpectrogramAnalysis(AbstractAnalysis, models.Model): @@ -56,6 +57,10 @@ def __str__(self): dynamic_min = models.FloatField() dynamic_max = models.FloatField() + frequency_scale_parts = models.ManyToManyField( + LinearScale, related_name="spectrogram_analysis" + ) + @deprecated("Related to legacy OSEkit") # Legacy def legacy_audio_metadatum_csv(self) -> str: """Legacy audio metadata CSV export""" diff --git a/backend/api/schema/nodes/legacy_spectrogram_configuration.py b/backend/api/schema/nodes/legacy_spectrogram_configuration.py index a4d8749b8..cc0660107 100644 --- a/backend/api/schema/nodes/legacy_spectrogram_configuration.py +++ b/backend/api/schema/nodes/legacy_spectrogram_configuration.py @@ -1,33 +1,13 @@ """LegacySpectrogramConfiguration schema""" -import graphene_django_optimizer from django_extension.schema.types import ExtendedNode -from graphene import String from backend.api.models import LegacySpectrogramConfiguration -from .linear_scale import LinearScaleNode -from .multi_linear_scale import MultiLinearScaleNode class LegacySpectrogramConfigurationNode(ExtendedNode): """LegacySpectrogramConfiguration schema""" - linear_frequency_scale = LinearScaleNode() - multi_linear_frequency_scale = MultiLinearScaleNode() - class Meta: model = LegacySpectrogramConfiguration fields = "__all__" filter_fields = {} - - scale_name = String() - - @graphene_django_optimizer.resolver_hints( - select_related=("linear_frequency_scale", "multi_linear_frequency_scale") - ) - def resolve_scale_name(self: LegacySpectrogramConfiguration, info): - """Get scale name""" - if self.multi_linear_frequency_scale: - return self.multi_linear_frequency_scale.name - if self.linear_frequency_scale: - return self.linear_frequency_scale.name - return None diff --git a/backend/api/schema/nodes/spectrogram_analysis.py b/backend/api/schema/nodes/spectrogram_analysis.py index e1c797d4a..47bbf7afe 100644 --- a/backend/api/schema/nodes/spectrogram_analysis.py +++ b/backend/api/schema/nodes/spectrogram_analysis.py @@ -5,6 +5,7 @@ from backend.api.models import SpectrogramAnalysis from backend.api.schema.connections import SpectrogramConnection from backend.api.schema.filter_sets import SpectrogramAnalysisFilterSet +from .linear_scale import LinearScaleNode from .colormap import ColormapNode from .fft import FFTNode from .legacy_spectrogram_configuration import LegacySpectrogramConfigurationNode @@ -28,3 +29,9 @@ class Meta: @graphene_django_optimizer.resolver_hints() def resolve_spectrograms(self: SpectrogramAnalysis, info, **kwargs): return self.spectrograms.distinct() + + frequency_scale_parts = graphene.List(LinearScaleNode) + + @graphene_django_optimizer.resolver_hints() + def resolve_frequency_scale_parts(self: SpectrogramAnalysis, info, **kwargs): + return self.frequency_scale_parts.distinct() diff --git a/backend/api/tests/schema/annotation_campaign/annotation_campaigns_by_id.py b/backend/api/tests/schema/annotation_campaign/annotation_campaigns_by_id.py index 7870e50a9..fa58d270c 100644 --- a/backend/api/tests/schema/annotation_campaign/annotation_campaigns_by_id.py +++ b/backend/api/tests/schema/annotation_campaign/annotation_campaigns_by_id.py @@ -77,21 +77,13 @@ overlap samplingFrequency } + frequencyScaleParts { + ratio + minValue + maxValue + } legacyConfiguration { - scaleName zoomLevel - linearFrequencyScale { - ratio - minValue - maxValue - } - multiLinearFrequencyScale { - innerScales { - ratio - minValue - maxValue - } - } } legacy } diff --git a/backend/storage/resolvers/_legacy_osekit.py b/backend/storage/resolvers/_legacy_osekit.py index 3bcd86a41..a1feb384a 100644 --- a/backend/storage/resolvers/_legacy_osekit.py +++ b/backend/storage/resolvers/_legacy_osekit.py @@ -15,7 +15,6 @@ Colormap, LegacySpectrogramConfiguration, LinearScale, - MultiLinearScale, Spectrogram, SpectrogramAnalysisRelation, ) @@ -30,6 +29,7 @@ make_path_relative, ) from ._storage import StorageResolver +from ...api.models.data.linear_scale import get_frequency_scale_parts class LegacyCSVDataset(TypedDict): @@ -256,6 +256,15 @@ def _get_analysis( dynamic_max=float(metadata["dynamic_max"]), ) + def get_frequency_scale_parts_for_analysis( + self, analysis: SpectrogramAnalysis + ) -> list[LinearScale]: + metadata = self._get_spectro_metadata(analysis.dataset.path, analysis.path) + return get_frequency_scale_parts( + name=metadata.get("custom_frequency_scale", None), + sample_rate=analysis.fft.sampling_frequency, + ) + def get_all_spectrograms_for_analysis( self, analysis: SpectrogramAnalysis ) -> list[Spectrogram]: @@ -287,56 +296,6 @@ def create_legacy_configuration(self, analysis: SpectrogramAnalysis): sampling_frequency=analysis.fft.sampling_frequency, ) - linear_scale: LinearScale | None = None - multilinear_scale: MultiLinearScale | None = None - if "custom_frequency_scale" in metadata: - scale_name = metadata["custom_frequency_scale"] - if scale_name.lower() == "porp_delph": - ( - multilinear_scale, - is_created, - ) = MultiLinearScale.objects.get_or_create(name="porp_delph") - if is_created: - multilinear_scale.inner_scales.add( - LinearScale.objects.get_or_create( - ratio=0.5, min_value=0, max_value=30_000 - )[0] - ) - multilinear_scale.inner_scales.add( - LinearScale.objects.get_or_create( - ratio=0.7, min_value=30_000, max_value=80_000 - )[0] - ) - multilinear_scale.inner_scales.add( - LinearScale.objects.get_or_create( - ratio=1, - min_value=80_000, - max_value=analysis.fft.sampling_frequency / 2, - )[0] - ) - elif scale_name.lower() == "dual_lf_hf": - ( - multilinear_scale, - is_created, - ) = MultiLinearScale.objects.get_or_create(name="dual_lf_hf") - if is_created: - multilinear_scale.inner_scales.add( - LinearScale.objects.get_or_create( - ratio=0.5, min_value=0, max_value=22_000 - )[0] - ) - multilinear_scale.inner_scales.add( - LinearScale.objects.get_or_create( - ratio=1, - min_value=100_000, - max_value=analysis.fft.sampling_frequency / 2, - )[0] - ) - elif scale_name.lower() == "audible": - linear_scale = LinearScale( - name="audible", min_value=0, max_value=22_000 - ) - LegacySpectrogramConfiguration.objects.create( spectrogram_analysis=analysis, folder=PureWindowsPath(analysis.path).parts[-1], @@ -362,8 +321,6 @@ def create_legacy_configuration(self, analysis: SpectrogramAnalysis): temporal_resolution=metadata["temporal_resolution"] if "temporal_resolution" in metadata else None, - linear_frequency_scale=linear_scale, - multi_linear_frequency_scale=multilinear_scale, audio_files_subtypes=literal_eval(audio["sample_bits"]), channel_count=int(audio["channel_count"]), ) diff --git a/backend/storage/resolvers/_osekit.py b/backend/storage/resolvers/_osekit.py index 4da35592e..1d31c3892 100644 --- a/backend/storage/resolvers/_osekit.py +++ b/backend/storage/resolvers/_osekit.py @@ -12,6 +12,7 @@ FFT, Spectrogram, SpectrogramAnalysisRelation, + LinearScale, ) from backend.storage.types import ( FailedItem, @@ -167,3 +168,20 @@ def get_spectrogram_paths( return paths["audio"], paths["spectrogram"] return None, None + + def get_frequency_scale_parts_for_analysis( + self, analysis: SpectrogramAnalysis + ) -> list[LinearScale]: + if analysis.legacy: + return super().get_frequency_scale_parts_for_analysis(analysis=analysis) + sd = self._get_spectro_dataset(analysis=analysis) + if not sd.scale: + return [] + return [ + LinearScale.objects.get_or_create( + ratio=scale.p_max, + min_value=scale.f_min, + max_value=scale.f_max, + )[0] + for scale in sd.scale.parts + ] diff --git a/backend/storage/schema/mutations/import_dataset.py b/backend/storage/schema/mutations/import_dataset.py index 70eb686ed..d3dd3f5bc 100644 --- a/backend/storage/schema/mutations/import_dataset.py +++ b/backend/storage/schema/mutations/import_dataset.py @@ -9,6 +9,7 @@ from backend.api.models import ( FFT, Colormap, + SpectrogramAnalysis, ) from backend.api.schema import DatasetNode from backend.api.schema.nodes import SpectrogramAnalysisNode @@ -58,17 +59,21 @@ def mutate(self, info, dataset_path: str, analysis_path: str | None = None): continue analysis.append(a) + sa: SpectrogramAnalysis for sa in analysis: if sa.pk is not None: continue sa.owner = info.context.user sa.dataset = resolver.dataset + print(model_to_dict(sa.fft)) sa.fft, _ = FFT.objects.get_or_create(**model_to_dict(sa.fft)) sa.colormap, _ = Colormap.objects.get_or_create(name=sa.colormap.name) sa.save() resolver.create_legacy_configuration(sa) + for scale in resolver.get_frequency_scale_parts_for_analysis(sa): + sa.frequency_scale_parts.add(scale) spectrograms = resolver.get_all_spectrograms_for_analysis(analysis=sa) sa.add_spectrograms(spectrograms=spectrograms) diff --git a/backend/storage/tests/schema/import_dataset.py b/backend/storage/tests/schema/import_dataset.py index 44eaf0ef1..e9fb03416 100644 --- a/backend/storage/tests/schema/import_dataset.py +++ b/backend/storage/tests/schema/import_dataset.py @@ -232,10 +232,6 @@ def test_analysis_legacy_with_scales(self): ).last() self.assertEqual(content["analysis"]["id"], str(analysis.id)) self.assertEqual( - analysis.legacy_configuration.multi_linear_frequency_scale.name, - "dual_lf_hf", - ) - self.assertEqual( - analysis.legacy_configuration.multi_linear_frequency_scale.inner_scales.count(), + analysis.frequency_scale_parts.count(), 2, ) diff --git a/frontend/schema.graphql b/frontend/schema.graphql index 46876082e..4eb1633c3 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -1842,6 +1842,7 @@ type SpectrogramAnalysisNode implements ExtendedInterface { colormap: ColormapNode! dynamicMin: Float! dynamicMax: Float! + frequencyScaleParts: [LinearScaleNode] legacyConfiguration: LegacySpectrogramConfigurationNode spectrograms( """Query limit""" @@ -1947,31 +1948,6 @@ type ColormapNode implements ExtendedInterface { ): SpectrogramAnalysisNodeConnection! } -"""LegacySpectrogramConfiguration schema""" -type LegacySpectrogramConfigurationNode implements ExtendedInterface { - """The ID of the object""" - id: ID! - spectrogramAnalysis: SpectrogramAnalysisNode! - folder: String! - audioFilesSubtypes: [String!] - channelCount: Int - fileOverlap: Int - zoomLevel: Int! - hpFilterMinFrequency: Int! - dataNormalization: String! - frequencyResolution: Float! - spectrogramNormalization: String! - zscoreDuration: String - windowType: String - peakVoltage: Float - sensitivityDb: Float - temporalResolution: Float - gainDb: Float - linearFrequencyScale: LinearScaleNode - multiLinearFrequencyScale: MultiLinearScaleNode - scaleName: String -} - """LinearScale schema""" type LinearScaleNode implements ExtendedInterface { """The ID of the object""" @@ -1981,7 +1957,18 @@ type LinearScaleNode implements ExtendedInterface { minValue: Float! maxValue: Float! outerScales(offset: Int, before: String, after: String, first: Int, last: Int): MultiLinearScaleNodeConnection! - legacyspectrogramconfigurationSet(offset: Int, before: String, after: String, first: Int, last: Int): LegacySpectrogramConfigurationNodeConnection! + spectrogramAnalysis( + offset: Int + before: String + after: String + first: Int + last: Int + dataset: ID + annotationCampaigns_Id: ID + + """Ordering""" + orderBy: String + ): SpectrogramAnalysisNodeConnection! } type MultiLinearScaleNodeConnection { @@ -2007,26 +1994,28 @@ type MultiLinearScaleNode implements ExtendedInterface { id: ID! name: String innerScales: [LinearScaleNode] - legacyspectrogramconfigurationSet(offset: Int, before: String, after: String, first: Int, last: Int): LegacySpectrogramConfigurationNodeConnection! } -type LegacySpectrogramConfigurationNodeConnection { - """Pagination data for this connection.""" - pageInfo: PageInfo! - - """Contains the nodes in this connection.""" - edges: [LegacySpectrogramConfigurationNodeEdge]! -} - -""" -A Relay edge containing a `LegacySpectrogramConfigurationNode` and its cursor. -""" -type LegacySpectrogramConfigurationNodeEdge { - """The item at the end of the edge""" - node: LegacySpectrogramConfigurationNode - - """A cursor for use in pagination""" - cursor: String! +"""LegacySpectrogramConfiguration schema""" +type LegacySpectrogramConfigurationNode implements ExtendedInterface { + """The ID of the object""" + id: ID! + spectrogramAnalysis: SpectrogramAnalysisNode! + folder: String! + audioFilesSubtypes: [String!] + channelCount: Int + fileOverlap: Int + zoomLevel: Int! + hpFilterMinFrequency: Int! + dataNormalization: String! + frequencyResolution: Float! + spectrogramNormalization: String! + zscoreDuration: String + windowType: String + peakVoltage: Float + sensitivityDb: Float + temporalResolution: Float + gainDb: Float } type SpectrogramNodeNodeConnection { diff --git a/frontend/src/api/annotation-campaign/annotation-campaign.generated.ts b/frontend/src/api/annotation-campaign/annotation-campaign.generated.ts index be34187b4..9bc18b8cf 100644 --- a/frontend/src/api/annotation-campaign/annotation-campaign.generated.ts +++ b/frontend/src/api/annotation-campaign/annotation-campaign.generated.ts @@ -18,7 +18,7 @@ export type GetCampaignQueryVariables = Types.Exact<{ }>; -export type GetCampaignQuery = { __typename?: 'Query', annotationCampaignById?: { __typename?: 'AnnotationCampaignNode', id: string, name: string, createdAt: any, instructionsUrl?: string | null, deadline?: any | null, isArchived: boolean, isEditable: boolean, isUserAllowedToManage: boolean, allowPointAnnotation: boolean, allowColormapTuning: boolean, allowImageTuning: boolean, colormapDefault?: string | null, colormapInvertedDefault?: boolean | null, description?: string | null, spectrogramsCount: number, dataset: { __typename?: 'DatasetNode', id: string, name: string }, labelSet?: { __typename?: 'LabelSetNode', id: string, name: string, description?: string | null, labels: Array<{ __typename?: 'AnnotationLabelNode', id: string, name: string } | null> } | null, labelsWithAcousticFeatures?: Array<{ __typename?: 'AnnotationLabelNode', id: string, name: string } | null> | null, owner: { __typename?: 'UserNode', id: string, displayName: string, email: string }, archive?: { __typename?: 'ArchiveNode', date: any, byUser?: { __typename?: 'UserNode', displayName: string } | null } | null, confidenceSet?: { __typename?: 'ConfidenceSetNode', id: string, name: string, desc?: string | null, confidenceIndicators?: Array<{ __typename?: 'ConfidenceNode', label: string, isDefault?: boolean | null } | null> | null } | null, detectors?: Array<{ __typename?: 'DetectorNode', id: string, name: string } | null> | null, annotators?: Array<{ __typename?: 'UserNode', id: string, displayName: string } | null> | null, analysis: { __typename?: 'SpectrogramAnalysisNodeConnection', edges: Array<{ __typename?: 'SpectrogramAnalysisNodeEdge', node?: { __typename?: 'SpectrogramAnalysisNode', id: string, name: string, legacy: boolean, colormap: { __typename?: 'ColormapNode', name: string }, fft: { __typename?: 'FFTNode', nfft: number, windowSize: number, overlap: any, samplingFrequency: number }, legacyConfiguration?: { __typename?: 'LegacySpectrogramConfigurationNode', scaleName?: string | null, zoomLevel: number, linearFrequencyScale?: { __typename?: 'LinearScaleNode', ratio: number, minValue: number, maxValue: number } | null, multiLinearFrequencyScale?: { __typename?: 'MultiLinearScaleNode', innerScales?: Array<{ __typename?: 'LinearScaleNode', ratio: number, minValue: number, maxValue: number } | null> | null } | null } | null } | null } | null> }, phases?: { __typename?: 'AnnotationPhaseNodeNodeConnection', results: Array<{ __typename?: 'AnnotationPhaseNode', id: string, phase: Types.AnnotationPhaseType, isOpen: boolean, tasksCount: number, completedTasksCount: number } | null> } | null } | null }; +export type GetCampaignQuery = { __typename?: 'Query', annotationCampaignById?: { __typename?: 'AnnotationCampaignNode', id: string, name: string, createdAt: any, instructionsUrl?: string | null, deadline?: any | null, isArchived: boolean, isEditable: boolean, isUserAllowedToManage: boolean, allowPointAnnotation: boolean, allowColormapTuning: boolean, allowImageTuning: boolean, colormapDefault?: string | null, colormapInvertedDefault?: boolean | null, description?: string | null, spectrogramsCount: number, dataset: { __typename?: 'DatasetNode', id: string, name: string }, labelSet?: { __typename?: 'LabelSetNode', id: string, name: string, description?: string | null, labels: Array<{ __typename?: 'AnnotationLabelNode', id: string, name: string } | null> } | null, labelsWithAcousticFeatures?: Array<{ __typename?: 'AnnotationLabelNode', id: string, name: string } | null> | null, owner: { __typename?: 'UserNode', id: string, displayName: string, email: string }, archive?: { __typename?: 'ArchiveNode', date: any, byUser?: { __typename?: 'UserNode', displayName: string } | null } | null, confidenceSet?: { __typename?: 'ConfidenceSetNode', id: string, name: string, desc?: string | null, confidenceIndicators?: Array<{ __typename?: 'ConfidenceNode', label: string, isDefault?: boolean | null } | null> | null } | null, detectors?: Array<{ __typename?: 'DetectorNode', id: string, name: string } | null> | null, annotators?: Array<{ __typename?: 'UserNode', id: string, displayName: string } | null> | null, analysis: { __typename?: 'SpectrogramAnalysisNodeConnection', edges: Array<{ __typename?: 'SpectrogramAnalysisNodeEdge', node?: { __typename?: 'SpectrogramAnalysisNode', id: string, name: string, legacy: boolean, colormap: { __typename?: 'ColormapNode', name: string }, fft: { __typename?: 'FFTNode', nfft: number, windowSize: number, overlap: any, samplingFrequency: number }, frequencyScaleParts?: Array<{ __typename?: 'LinearScaleNode', ratio: number, minValue: number, maxValue: number } | null> | null, legacyConfiguration?: { __typename?: 'LegacySpectrogramConfigurationNode', zoomLevel: number } | null } | null } | null> }, phases?: { __typename?: 'AnnotationPhaseNodeNodeConnection', results: Array<{ __typename?: 'AnnotationPhaseNode', id: string, phase: Types.AnnotationPhaseType, isOpen: boolean, tasksCount: number, completedTasksCount: number } | null> } | null } | null }; export type CreateCampaignMutationVariables = Types.Exact<{ name: Types.Scalars['String']['input']; @@ -161,21 +161,13 @@ export const GetCampaignDocument = ` overlap samplingFrequency } + frequencyScaleParts { + ratio + minValue + maxValue + } legacyConfiguration { - scaleName zoomLevel - linearFrequencyScale { - ratio - minValue - maxValue - } - multiLinearFrequencyScale { - innerScales { - ratio - minValue - maxValue - } - } } legacy } diff --git a/frontend/src/api/annotation-campaign/annotation-campaign.graphql b/frontend/src/api/annotation-campaign/annotation-campaign.graphql index 19cd11094..e014c172c 100644 --- a/frontend/src/api/annotation-campaign/annotation-campaign.graphql +++ b/frontend/src/api/annotation-campaign/annotation-campaign.graphql @@ -110,21 +110,13 @@ query getCampaign($id: ID!) { overlap samplingFrequency } + frequencyScaleParts { + ratio + minValue + maxValue + } legacyConfiguration { - scaleName zoomLevel - linearFrequencyScale { - ratio - minValue - maxValue - } - multiLinearFrequencyScale { - innerScales { - ratio - minValue - maxValue - } - } } legacy } diff --git a/frontend/src/api/types.gql-generated.ts b/frontend/src/api/types.gql-generated.ts index 215d50cfe..0ec30b73f 100644 --- a/frontend/src/api/types.gql-generated.ts +++ b/frontend/src/api/types.gql-generated.ts @@ -3328,10 +3328,7 @@ export type LegacySpectrogramConfigurationNode = ExtendedInterface & { hpFilterMinFrequency: Scalars['Int']['output']; /** The ID of the object */ id: Scalars['ID']['output']; - linearFrequencyScale?: Maybe; - multiLinearFrequencyScale?: Maybe; peakVoltage?: Maybe; - scaleName?: Maybe; sensitivityDb?: Maybe; spectrogramAnalysis: SpectrogramAnalysisNode; spectrogramNormalization: Scalars['String']['output']; @@ -3341,39 +3338,22 @@ export type LegacySpectrogramConfigurationNode = ExtendedInterface & { zscoreDuration?: Maybe; }; -export type LegacySpectrogramConfigurationNodeConnection = { - __typename?: 'LegacySpectrogramConfigurationNodeConnection'; - /** Contains the nodes in this connection. */ - edges: Array>; - /** Pagination data for this connection. */ - pageInfo: PageInfo; -}; - -/** A Relay edge containing a `LegacySpectrogramConfigurationNode` and its cursor. */ -export type LegacySpectrogramConfigurationNodeEdge = { - __typename?: 'LegacySpectrogramConfigurationNodeEdge'; - /** A cursor for use in pagination */ - cursor: Scalars['String']['output']; - /** The item at the end of the edge */ - node?: Maybe; -}; - /** LinearScale schema */ export type LinearScaleNode = ExtendedInterface & { __typename?: 'LinearScaleNode'; /** The ID of the object */ id: Scalars['ID']['output']; - legacyspectrogramconfigurationSet: LegacySpectrogramConfigurationNodeConnection; maxValue: Scalars['Float']['output']; minValue: Scalars['Float']['output']; name?: Maybe; outerScales: MultiLinearScaleNodeConnection; ratio: Scalars['Float']['output']; + spectrogramAnalysis: SpectrogramAnalysisNodeConnection; }; /** LinearScale schema */ -export type LinearScaleNodeLegacyspectrogramconfigurationSetArgs = { +export type LinearScaleNodeOuterScalesArgs = { after?: InputMaybe; before?: InputMaybe; first?: InputMaybe; @@ -3383,12 +3363,15 @@ export type LinearScaleNodeLegacyspectrogramconfigurationSetArgs = { /** LinearScale schema */ -export type LinearScaleNodeOuterScalesArgs = { +export type LinearScaleNodeSpectrogramAnalysisArgs = { after?: InputMaybe; + annotationCampaigns_Id?: InputMaybe; before?: InputMaybe; + dataset?: InputMaybe; first?: InputMaybe; last?: InputMaybe; offset?: InputMaybe; + orderBy?: InputMaybe; }; export type MaintenanceNode = ExtendedInterface & { @@ -3486,20 +3469,9 @@ export type MultiLinearScaleNode = ExtendedInterface & { /** The ID of the object */ id: Scalars['ID']['output']; innerScales?: Maybe>>; - legacyspectrogramconfigurationSet: LegacySpectrogramConfigurationNodeConnection; name?: Maybe; }; - -/** MultiLinearScale schema */ -export type MultiLinearScaleNodeLegacyspectrogramconfigurationSetArgs = { - after?: InputMaybe; - before?: InputMaybe; - first?: InputMaybe; - last?: InputMaybe; - offset?: InputMaybe; -}; - export type MultiLinearScaleNodeConnection = { __typename?: 'MultiLinearScaleNodeConnection'; /** Contains the nodes in this connection. */ @@ -6313,6 +6285,7 @@ export type SpectrogramAnalysisNode = ExtendedInterface & { dynamicMin: Scalars['Float']['output']; end?: Maybe; fft: FftNode; + frequencyScaleParts?: Maybe>>; /** The ID of the object */ id: Scalars['ID']['output']; legacy: Scalars['Boolean']['output']; diff --git a/frontend/src/components/ui/Scale/Linear.service.ts b/frontend/src/components/ui/Scale/Linear.service.ts index ac4a4797e..ccaeef0df 100644 --- a/frontend/src/components/ui/Scale/Linear.service.ts +++ b/frontend/src/components/ui/Scale/Linear.service.ts @@ -5,155 +5,155 @@ export type LinearScale = Pick scale.maxValue) throw `Incorrect scale range: min=${ scale.minValue } and max=${ scale.maxValue }`; - } - - valueToPosition(value: number): number { - let position = this.options.pixelOffset + this.crossProduct(value - this.scale.minValue, this.range, this.pixelSize) - if (this.options.revert) position = this.pixelSize - position; - if (position > this.pixelSize) position = this.pixelSize - if (position < 0) position = 0 - return position - } - - valuesToPositionRange(min: number, max: number): number { - return Math.abs(this.valueToPosition(max) - this.valueToPosition(min)); - } - - positionToValue(position: number): number { - if (position < 0) position = 0; - if (this.options.revert) position = this.pixelSize - position; - let value = this.scale.minValue + this.crossProduct(position - this.options.pixelOffset, this.pixelSize, this.range); - if (this.options.disableValueFloats) value = Math.floor(value) - return value; - } - - positionsToRange(min: number, max: number): number { - return Math.abs(this.positionToValue(max) - this.positionToValue(min)); - } - - getSteps(regularStepsRange = this.getMinBigStepsRange(), - smallStepsRange = this.getMinSmallStepsRange(regularStepsRange)): Array { - - const array = new Array(); - - const innerSteps = Math.max(1, this.getNumber(1, smallStepsRange.toString().length)) - for (let value = Math.floor(this.scale.minValue / innerSteps) * innerSteps; - value <= Math.round(this.scale.maxValue / innerSteps) * innerSteps; value += innerSteps) { - if (value < this.scale.minValue || value > this.scale.maxValue) continue; - const position: number = this.options.pixelOffset + Math.floor(this.valueToPosition(value)); - if (value % regularStepsRange === 0) - array.push({ position, value, size: 'regular' }) - else if (smallStepsRange > 0 && value % smallStepsRange === 0) - array.push({ position, value, size: 'small' }) - } - if (!array.filter(s => s.size === 'regular').some(s => s.value === this.scale.minValue)) { - array.push({ - position: this.options.pixelOffset + Math.floor(this.valueToPosition(this.scale.minValue)), - value: this.scale.minValue, - size: 'regular', - }) - } - if (!array.filter(s => s.size === 'regular').some(s => s.value === this.scale.maxValue)) { - array.push({ - position: this.options.pixelOffset + Math.floor(this.valueToPosition(this.scale.maxValue)), - value: this.scale.maxValue, - size: 'regular', - }) - } - return array - } - - isRangeContinuouslyOnScale(min: number, max: number): boolean { - return min >= this.scale.minValue && max >= this.scale.minValue - && min <= this.scale.maxValue && max <= this.scale.maxValue - } - - private getMinBigStepsRange(): number { - const maxFrequencyStr = Math.ceil(this.range).toString(); - let bigStepsRange = this.getNumber(1, maxFrequencyStr.length - 2); - while (bigStepsRange * this.pixelSize / this.range < this.MIN_BIG_STEPS_RANGE_PX) { - switch (+bigStepsRange.toString()[0]) { - case 1: - bigStepsRange *= 2 - break; - case 2: - bigStepsRange *= 5 - break; - } - } - return bigStepsRange - } - - private getMinSmallStepsRange(bigStep: number): number { - let range: number; - switch (+bigStep.toString()[0]) { - case 1: - range = this.getNumber(2, bigStep.toString().length - 1); - break; - case 2: - range = this.getNumber(5, bigStep.toString().length - 1); - break; - default: - return 0; - } - while (range * this.pixelSize / this.range < this.MIN_SMALL_STEPS_RANGE_PX) { - switch (+range.toString()[0]) { - case 2: - range *= 2.5 - break; - case 5: - range *= 4 - break; - } - } - return range; - } - - private crossProduct(value: number, range: number, maxOtherScale: number): number { - if (value < 0) return 0; - if (value > range) return maxOtherScale; - return value * maxOtherScale / range; - } - - private getNumber(first: number, length: number) { - length = Math.max(1, length); // Avoid length to be 0 - const numberArray = Array.from(new Array(length)).map((_, key) => key === 0 ? first : 0) - return Math.max(0, +numberArray.join('')) - } + private MIN_SMALL_STEPS_RANGE_PX = 14; + private MIN_BIG_STEPS_RANGE_PX = 30; + + get minValue(): number { + return this.scale.minValue; + } + + get maxValue(): number { + return this.scale.maxValue; + } + + get ratio(): number { + return this.scale.ratio; + } + + get range(): number { + return this.scale.maxValue - this.scale.minValue; + } + + public get height(): number { + return this.pixelSize; + } + + constructor(private pixelSize: number, + public scale: LinearScale, + private options: { + pixelOffset: number; + disableValueFloats: boolean; + revert: boolean; + } = { + pixelOffset: 0, + disableValueFloats: false, + revert: false, + }) { + if (scale.minValue && scale.minValue > scale.maxValue) throw `Incorrect scale range: min=${ scale.minValue } and max=${ scale.maxValue }`; + } + + valueToPosition(value: number): number { + let position = this.options.pixelOffset + this.crossProduct(value - this.scale.minValue, this.range, this.pixelSize) + if (this.options.revert) position = this.pixelSize - position; + if (position > (this.pixelSize + this.options.pixelOffset)) position = this.pixelSize + this.options.pixelOffset + if (position < 0) position = 0 + return position + } + + valuesToPositionRange(min: number, max: number): number { + return Math.abs(this.valueToPosition(max) - this.valueToPosition(min)); + } + + positionToValue(position: number): number { + if (position < 0) position = 0; + if (this.options.revert) position = this.pixelSize - position; + let value = this.scale.minValue + this.crossProduct(position - this.options.pixelOffset, this.pixelSize, this.range); + if (this.options.disableValueFloats) value = Math.floor(value) + return value; + } + + positionsToRange(min: number, max: number): number { + return Math.abs(this.positionToValue(max) - this.positionToValue(min)); + } + + getSteps(regularStepsRange = this.getMinBigStepsRange(), + smallStepsRange = this.getMinSmallStepsRange(regularStepsRange)): Array { + + const array = new Array(); + + const innerSteps = Math.max(1, this.getNumber(1, smallStepsRange.toString().length)) + for (let value = Math.floor(this.scale.minValue / innerSteps) * innerSteps; + value <= Math.round(this.scale.maxValue / innerSteps) * innerSteps; value += innerSteps) { + if (value < this.scale.minValue || value > this.scale.maxValue) continue; + const position: number = Math.floor(this.valueToPosition(value)); + if (value % regularStepsRange === 0) + array.push({ position, value, size: 'regular' }) + else if (smallStepsRange > 0 && value % smallStepsRange === 0) + array.push({ position, value, size: 'small' }) + } + if (!array.filter(s => s.size === 'regular').some(s => s.value === this.scale.minValue)) { + array.push({ + position: Math.floor(this.valueToPosition(this.scale.minValue)), + value: this.scale.minValue, + size: 'regular', + }) + } + if (!array.filter(s => s.size === 'regular').some(s => s.value === this.scale.maxValue)) { + array.push({ + position: Math.floor(this.valueToPosition(this.scale.maxValue)), + value: this.scale.maxValue, + size: 'regular', + }) + } + return array + } + + isRangeContinuouslyOnScale(min: number, max: number): boolean { + return min >= this.scale.minValue && max >= this.scale.minValue + && min <= this.scale.maxValue && max <= this.scale.maxValue + } + + private getMinBigStepsRange(): number { + const maxFrequencyStr = Math.ceil(this.range).toString(); + let bigStepsRange = this.getNumber(1, maxFrequencyStr.length - 2); + while (bigStepsRange * this.pixelSize / this.range < this.MIN_BIG_STEPS_RANGE_PX) { + switch (+bigStepsRange.toString()[0]) { + case 1: + bigStepsRange *= 2 + break; + case 2: + bigStepsRange *= 5 + break; + } + } + return bigStepsRange + } + + private getMinSmallStepsRange(bigStep: number): number { + let range: number; + switch (+bigStep.toString()[0]) { + case 1: + range = this.getNumber(2, bigStep.toString().length - 1); + break; + case 2: + range = this.getNumber(5, bigStep.toString().length - 1); + break; + default: + return 0; + } + while (range * this.pixelSize / this.range < this.MIN_SMALL_STEPS_RANGE_PX) { + switch (+range.toString()[0]) { + case 2: + range *= 2.5 + break; + case 5: + range *= 4 + break; + } + } + return range; + } + + private crossProduct(value: number, range: number, maxOtherScale: number): number { + if (value < 0) return 0; + if (value > range) return maxOtherScale; + return value * maxOtherScale / range; + } + + private getNumber(first: number, length: number) { + length = Math.max(1, length); // Avoid length to be 0 + const numberArray = Array.from(new Array(length)).map((_, key) => key === 0 ? first : 0) + return Math.max(0, +numberArray.join('')) + } } \ No newline at end of file diff --git a/frontend/src/components/ui/Scale/Multi.service.ts b/frontend/src/components/ui/Scale/Multi.service.ts index 272d3dc2d..2c84a0ccd 100644 --- a/frontend/src/components/ui/Scale/Multi.service.ts +++ b/frontend/src/components/ui/Scale/Multi.service.ts @@ -1,165 +1,160 @@ -import { ScaleService, Step } from "./types"; -import { LinearScale, LinearScaleService } from "./Linear.service"; +import { ScaleService, Step } from './types'; +import { LinearScale, LinearScaleService } from './Linear.service'; export class MultiScaleService implements ScaleService { - get minValue(): number { - return Math.min(...this.innerScales.map(s => s.minValue)); - } - - get maxValue(): number { - return Math.max(...this.innerScales.map(s => s.maxValue)); - } - - get ratio(): number { - return Math.max(...this.innerScales.map(s => s.ratio)); - } - - private innerScales: Array = []; - - constructor(private pixelSize: number, - innerScales: LinearScale[], - private options: { - disableValueFloats: boolean; - revert: boolean; - } = { - disableValueFloats: false, - revert: false - }) { - let previousRatio = 0 - const data = [ ...innerScales ].sort((a, b) => a.ratio - b.ratio) - for (const scale of data) { - // Go through scale with ascending ratio (since ratio correspond to the scale max frequency position) - if (innerScales.some((otherScale: LinearScale) => otherScale.minValue < scale.minValue && otherScale.maxValue > scale.minValue)) - throw new Error('Given scales are conflicting!') - if (innerScales.some((otherScale: LinearScale) => otherScale.minValue < scale.maxValue && otherScale.maxValue > scale.maxValue)) - throw new Error('Given scales are conflicting!') - if (scale.ratio === 0) - throw new Error('Cannot have a ratio of 0!') - - this.innerScales.push( - new LinearScaleService( - pixelSize * (scale.ratio - previousRatio), - scale, - { - ...options, - pixelOffset: pixelSize * previousRatio, - revert: false - } - ) - ) - previousRatio = scale.ratio; + get minValue(): number { + return Math.min(...this.innerScales.map(s => s.minValue)); } - } - - valueToPosition(value: number): number { - const scale = this.getScaleForValue(value)!; - let position = scale.valueToPosition(value) + this.getPreviousScalesHeight(scale.scale); - if (this.options.revert) position = this.pixelSize - position; - return position - } - - valuesToPositionRange(min: number, max: number): number { - return Math.abs(this.valueToPosition(min) - this.valueToPosition(max)) - } - - positionToValue(position: number): number { - if (position < 0) position = 0; - if (this.options.revert) position = this.pixelSize - position; - const scale = this.getScaleForPosition(position); - return scale.positionToValue(position - this.getPreviousScalesHeight(scale.scale)); - } - - positionsToRange(min: number, max: number): number { - return Math.abs(this.positionToValue(min) - this.positionToValue(max)) - } - - getSteps(): Array { - const array = new Array() - for (const scale of this.innerScales.sort(s => s.scale.ratio)) { - const scaleSteps = scale.getSteps(); - - for (const step of scaleSteps) { - if (array.find(s => s.value === step.value)) continue; - if (this.innerScales.some(s => s.scale.minValue === step.value || s.scale.maxValue === step.value)) - step.size = 'big' - if (step.value === scale.scale.maxValue - && this.innerScales.some(s => s.scale.minValue === scale.scale.maxValue)) { - array.push({ - ...step, - additionalValue: step.value, - correspondingRatio: scale.scale.ratio - }) - continue; - } - if (step.value === scale.scale.minValue - && this.innerScales.some(s => s.scale.maxValue === scale.scale.minValue)) - continue; - const existingPosition = array.find(s => s.position === step.position && s.correspondingRatio === step.correspondingRatio); - if (existingPosition) { - existingPosition.additionalValue = step.value; - } else { - array.push({ - ...step, - correspondingRatio: scale.scale.ratio - }) + + get maxValue(): number { + return Math.max(...this.innerScales.map(s => s.maxValue)); + } + + get ratio(): number { + return Math.max(...this.innerScales.map(s => s.ratio)); + } + + private innerScales: Array = []; + + constructor(private pixelSize: number, + innerScales: LinearScale[], + private options: { + disableValueFloats: boolean; + revert: boolean; + } = { + disableValueFloats: false, + revert: false, + }) { + let previousRatio = 0 + const data = [ ...innerScales ].sort((a, b) => a.ratio - b.ratio) + for (const scale of data) { + // Go through scale with ascending ratio (since ratio correspond to the scale max frequency position) + if (innerScales.some((otherScale: LinearScale) => otherScale.minValue < scale.minValue && otherScale.maxValue > scale.minValue)) + throw new Error('Given scales are conflicting!') + if (innerScales.some((otherScale: LinearScale) => otherScale.minValue < scale.maxValue && otherScale.maxValue > scale.maxValue)) + throw new Error('Given scales are conflicting!') + if (scale.ratio === 0) + throw new Error('Cannot have a ratio of 0!') + + this.innerScales.push( + new LinearScaleService( + pixelSize * (scale.ratio - previousRatio), + scale, + { + ...options, + pixelOffset: pixelSize * previousRatio, + revert: false, + }, + ), + ) + previousRatio = scale.ratio; } - } } - return array; - } - - isRangeContinuouslyOnScale(min: number, max: number): boolean { - const minScale = this.getScaleForValue(Math.min(min, max))?.scale; - const maxScale = this.getScaleForValue(Math.max(min, max))?.scale; - if (!minScale || !maxScale) return false; // Values are out of given scales - - // Check if range is over a void in the scale - if (minScale.ratio === maxScale?.ratio) return true; // On same linear scale - const scalesBetween = this.innerScales - .map(s => s.scale) - .filter(s => s.ratio >= minScale.ratio && s.ratio <= maxScale.ratio) - .sort((a, b) => a.ratio - b.ratio); - let previousMax = minScale.maxValue; - for (const scale of scalesBetween) { - if (scale.maxValue === previousMax) continue; - if (scale.minValue !== previousMax) return false; - previousMax = scale.maxValue; + + valueToPosition(value: number): number { + const scale = this.getScaleForValue(value)!; + let position = scale.valueToPosition(value); + if (this.options.revert) position = this.pixelSize - position; + return position } - return true; - } - - private getScaleForValue(value: number, force: boolean = true): LinearScaleService | undefined { - // Scale including value - let correspondingScale = this.innerScales.find(s => s.scale.minValue <= value && s.scale.maxValue >= value); - - if (!correspondingScale && force) { - // Search out of the scale - const absoluteMin = Math.min(...this.innerScales.map(s => s.scale.minValue)) - const absoluteMax = Math.max(...this.innerScales.map(s => s.scale.maxValue)) - if (value < absoluteMin) { - correspondingScale = this.innerScales.find(s => s.scale.minValue === absoluteMin); - } else if (value > absoluteMax) - correspondingScale = this.innerScales.find(s => s.scale.maxValue === absoluteMax); - else { - // Value between 2 scales - const directUp = Math.min(...this.innerScales.filter(s => s.scale.minValue > value).map(s => s.scale.minValue)) - correspondingScale = this.innerScales.find(s => s.scale.minValue === directUp); - } + + valuesToPositionRange(min: number, max: number): number { + return Math.abs(this.valueToPosition(min) - this.valueToPosition(max)) } - return correspondingScale; - } + positionToValue(position: number): number { + if (position < 0) position = 0; + if (this.options.revert) position = this.pixelSize - position; + const scale = this.getScaleForPosition(position); + return scale.positionToValue(position); + } - private getScaleForPosition(position: number): LinearScaleService { - const ratio = position / this.pixelSize; - const minUpperRatio = Math.min(...this.innerScales.filter(s => s.ratio >= ratio).map(s => s.ratio)) - return this.innerScales.find(s => s.ratio === minUpperRatio)!; - } + positionsToRange(min: number, max: number): number { + return Math.abs(this.positionToValue(min) - this.positionToValue(max)) + } - private getPreviousScalesHeight(scale: LinearScale): number { - return this.innerScales.filter(s => s.scale.ratio < scale.ratio) - .reduce((total, s) => s.height + total, 0) - } + getSteps(): Array { + const array = new Array() + for (const scale of this.innerScales.sort(s => s.scale.ratio)) { + const scaleSteps = scale.getSteps(); + + for (const step of scaleSteps) { + if (array.find(s => s.value === step.value)) continue; + if (this.innerScales.some(s => s.scale.minValue === step.value || s.scale.maxValue === step.value)) + step.size = 'big' + if (step.value === scale.scale.maxValue + && this.innerScales.some(s => s.scale.minValue === scale.scale.maxValue)) { + array.push({ + ...step, + additionalValue: step.value, + correspondingRatio: scale.scale.ratio, + }) + continue; + } + if (step.value === scale.scale.minValue + && this.innerScales.some(s => s.scale.maxValue === scale.scale.minValue)) + continue; + const existingPosition = array.find(s => s.position === step.position && s.correspondingRatio === step.correspondingRatio); + if (existingPosition) { + existingPosition.additionalValue = step.value; + } else { + array.push({ + ...step, + correspondingRatio: scale.scale.ratio, + }) + } + } + } + return array.map(s => ({ ...s, position: this.pixelSize - s.position })); + } + + isRangeContinuouslyOnScale(min: number, max: number): boolean { + const minScale = this.getScaleForValue(Math.min(min, max))?.scale; + const maxScale = this.getScaleForValue(Math.max(min, max))?.scale; + if (!minScale || !maxScale) return false; // Values are out of given scales + + // Check if range is over a void in the scale + if (minScale.ratio === maxScale?.ratio) return true; // On same linear scale + const scalesBetween = this.innerScales + .map(s => s.scale) + .filter(s => s.ratio >= minScale.ratio && s.ratio <= maxScale.ratio) + .sort((a, b) => a.ratio - b.ratio); + let previousMax = minScale.maxValue; + for (const scale of scalesBetween) { + if (scale.maxValue === previousMax) continue; + if (scale.minValue !== previousMax) return false; + previousMax = scale.maxValue; + } + return true; + } + + private getScaleForValue(value: number, force: boolean = true): LinearScaleService | undefined { + // Scale including value + let correspondingScale = this.innerScales.find(s => s.scale.minValue <= value && s.scale.maxValue >= value); + + if (!correspondingScale && force) { + // Search out of the scale + const absoluteMin = Math.min(...this.innerScales.map(s => s.scale.minValue)) + const absoluteMax = Math.max(...this.innerScales.map(s => s.scale.maxValue)) + if (value < absoluteMin) { + correspondingScale = this.innerScales.find(s => s.scale.minValue === absoluteMin); + } else if (value > absoluteMax) + correspondingScale = this.innerScales.find(s => s.scale.maxValue === absoluteMax); + else { + // Value between 2 scales + const directUp = Math.min(...this.innerScales.filter(s => s.scale.minValue > value).map(s => s.scale.minValue)) + correspondingScale = this.innerScales.find(s => s.scale.minValue === directUp); + } + } + + return correspondingScale; + } + + private getScaleForPosition(position: number): LinearScaleService { + const ratio = position / this.pixelSize; + const minUpperRatio = Math.min(...this.innerScales.filter(s => s.ratio >= ratio).map(s => s.ratio)) + return this.innerScales.find(s => s.ratio === minUpperRatio)!; + } } \ No newline at end of file diff --git a/frontend/src/components/ui/Scale/hooks.ts b/frontend/src/components/ui/Scale/hooks.ts index e16fff8bb..6e09f8743 100644 --- a/frontend/src/components/ui/Scale/hooks.ts +++ b/frontend/src/components/ui/Scale/hooks.ts @@ -10,10 +10,6 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue: boolean; }) => { - useEffect(() => { - draw() - }, [ canvas, pixelSize, valueToString, orientation, steps ]); - const draw = useCallback(() => { const context = canvas?.getContext('2d'); if (!canvas || !context || !pixelSize) return; @@ -22,19 +18,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, context.fillStyle = 'rgba(0, 0, 0)'; context.font = '500 10px \'Exo 2\''; - let previousRatio = 0; - let offset = 0; const scaleSteps = steps.sort((a: Step, b: Step) => (a.correspondingRatio ?? 0) - (b.correspondingRatio ?? 0)); - const maxRatio = Math.max(...scaleSteps.map(s => s.correspondingRatio ?? 0)); const realSteps = new Array(); for (const step of scaleSteps) { - if (step.correspondingRatio) { - if (step.correspondingRatio !== previousRatio) { - offset = previousRatio / maxRatio * pixelSize; - previousRatio = step.correspondingRatio - } - } - const position = Math.round(pixelSize - (offset + step.position)); + const position = Math.round(pixelSize - step.position); const existingStep = realSteps.find(s => s.position === position) if (existingStep) { existingStep.additionalValue = step.value; @@ -53,7 +40,6 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, break; } - // Tick let tickLength = 10; let tickWidth = 1; @@ -131,4 +117,8 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, } }, [ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue ]) + useEffect(() => { + draw() + }, [ canvas, pixelSize, valueToString, orientation, steps ]); + } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx b/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx index 63c26c407..74fd7b42d 100644 --- a/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx +++ b/frontend/src/features/Annotator/Analysis/AnalysisSelect.tsx @@ -3,37 +3,47 @@ import { Select } from '@/components/form'; import { useAppDispatch, useAppSelector } from '@/features/App'; import { selectAllAnalysis, selectAnalysis } from './selectors'; import { Analysis, setAnalysis } from './slice'; +import { frequencyToString } from '@/service/function'; export const AnalysisSelect: React.FC = () => { - const allAnalysis = useAppSelector(selectAllAnalysis) - const analysis = useAppSelector(selectAnalysis) - const dispatch = useAppDispatch() + const allAnalysis = useAppSelector(selectAllAnalysis) + const analysis = useAppSelector(selectAnalysis) + const dispatch = useAppDispatch() - const set = useCallback((value?: Analysis) => { - dispatch(setAnalysis(value)) - }, [ dispatch ]) + const set = useCallback((value?: Analysis) => { + dispatch(setAnalysis(value)) + }, [ dispatch ]) - const options = useMemo(() => { - return allAnalysis?.map(a => { - let label = `nfft: ${ a!.fft.nfft }`; - label += ` | winsize: ${ a!.fft.windowSize }` - label += ` | overlap: ${ a!.fft.overlap }` - label += ` | scale: ${ a!.legacyConfiguration?.scaleName ?? 'Default' }` - return { value: a!.id, label } - }) ?? [] - }, [ allAnalysis ]); + const options = useMemo(() => { + return allAnalysis?.map(a => { + let label = `nfft: ${ a!.fft.nfft }`; + label += ` | winsize: ${ a!.fft.windowSize }` + label += ` | overlap: ${ a!.fft.overlap }` + const parts = a?.frequencyScaleParts ?? [] + const defaultMax = a!.fft.samplingFrequency / 2 + let min = 0 + let max = defaultMax + if (parts.length) { + min = Math.min(...parts.map(a => a?.minValue ?? 0)) + max = Math.max(...parts.map(a => a?.maxValue ?? defaultMax)) + } + const range = `[${ frequencyToString(min) }Hz-${ frequencyToString(max) }Hz]` + label += ` | scale: ${ parts.length > 0 ? parts.length : 1 } ${ range }` + return { value: a!.id, label } + }) ?? [] + }, [ allAnalysis ]); - const select = useCallback((value: string | number | undefined) => { - if (value === undefined) return; - const analysis = allAnalysis?.find(a => a?.id === (typeof value === 'number' ? value.toString() : value)) - if (analysis) set(analysis) - }, [ allAnalysis, set ]) + const select = useCallback((value: string | number | undefined) => { + if (value === undefined) return; + const analysis = allAnalysis?.find(a => a?.id === (typeof value === 'number' ? value.toString() : value)) + if (analysis) set(analysis) + }, [ allAnalysis, set ]) - return } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Analysis/slice.ts b/frontend/src/features/Annotator/Analysis/slice.ts index e2e329619..16dc2df40 100644 --- a/frontend/src/features/Annotator/Analysis/slice.ts +++ b/frontend/src/features/Annotator/Analysis/slice.ts @@ -16,7 +16,10 @@ export function getDefaultAnalysisID({ data, id }: { data: GetCampaignQuery, id? const allAnalysis = data?.annotationCampaignById?.analysis.edges.filter(e => !!e?.node).map(e => e!.node!) // Select default analysis when none existing is selected if (!allAnalysis || allAnalysis.length === 0 || allAnalysis.find(a => a.id === id)) return id; - const baseScaleAnalysis = allAnalysis.find(a => !a!.legacyConfiguration?.scaleName); + const baseScaleAnalysis = allAnalysis.find(a => + !a.frequencyScaleParts || a.frequencyScaleParts.length == 0 || + (a.frequencyScaleParts.length == 1 && a.frequencyScaleParts[0]!.minValue == 0 && a.frequencyScaleParts[0]!.maxValue == a.fft.samplingFrequency / 2) + ); const minID = Math.min(...allAnalysis.map(a => +a!.id))?.toString(); if (minID) return baseScaleAnalysis?.id ?? minID return id diff --git a/frontend/src/features/Annotator/Axis/hooks.ts b/frontend/src/features/Annotator/Axis/hooks.ts index 1929105b9..9b2a04fe3 100644 --- a/frontend/src/features/Annotator/Axis/hooks.ts +++ b/frontend/src/features/Annotator/Axis/hooks.ts @@ -29,17 +29,10 @@ export const useFrequencyScale = () => { disableValueFloats: true, revert: true, } - if (analysis?.legacyConfiguration?.linearFrequencyScale) { - return new LinearScaleService( - height, - analysis.legacyConfiguration.linearFrequencyScale, - options, - ) - } - if (analysis?.legacyConfiguration?.multiLinearFrequencyScale) { + if (analysis?.frequencyScaleParts && analysis?.frequencyScaleParts.length) { return new MultiScaleService( height, - analysis.legacyConfiguration.multiLinearFrequencyScale.innerScales?.filter(s => s !== null).map(s => s!) ?? [], + analysis.frequencyScaleParts?.filter(s => s !== null).map(s => s!) ?? [], options, ) } diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index 29888bd0a..fdc700e2d 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -17,7 +17,7 @@ import { useAnnotatorCanvasContext } from '@/features/Annotator/Canvas/context'; import { useAppDispatch, useAppSelector } from '@/features/App'; import { setAllFileAsSeen } from '@/features/Annotator/UX/slice'; import { useDrawCanvas } from '@/features/Annotator/Canvas/hooks'; -import { useAnnotationTask } from '@/api'; +import { AnnotationType, useAnnotationTask } from '@/api'; import { selectBrightness, selectColormap, @@ -174,7 +174,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { - { allAnnotations.map(annotation => ) } + { allAnnotations.filter(a => a.type !== AnnotationType.Weak).map(annotation => ) } diff --git a/frontend/tests/utils/mock/types/legacyConfiguration.ts b/frontend/tests/utils/mock/types/legacyConfiguration.ts index c8d96dd25..7c9718f09 100644 --- a/frontend/tests/utils/mock/types/legacyConfiguration.ts +++ b/frontend/tests/utils/mock/types/legacyConfiguration.ts @@ -3,7 +3,6 @@ import type { LegacySpectrogramConfigurationNode } from '../../../../src/api/typ export type LegacyConfiguration = Omit export const legacyConfiguration: LegacyConfiguration = { id: '1', - scaleName: 'Default', channelCount: 1, dataNormalization: 'instrument', spectrogramNormalization: 'density', @@ -14,7 +13,5 @@ export const legacyConfiguration: LegacyConfiguration = { hpFilterMinFrequency: 1000, zscoreDuration: 'original', windowType: null, - linearFrequencyScale: null, - multiLinearFrequencyScale: null, zoomLevel: 2, }