From e59133152dc864ee4b60fb5ec5921ce1bf1192a8 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Thu, 5 Feb 2026 08:59:06 +0100 Subject: [PATCH 01/13] poc(zoom): Generate img from audio in GET Request --- .gitlab-ci.yml | 1 - backend/api/urls.py | 4 +++ backend/api/views/zoom.py | 58 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 backend/api/views/zoom.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 0a82caf78..7c544fbca 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -8,7 +8,6 @@ # * OSMOSE_DB_USER : DB user (ex: osmose) # * OSMOSE_DB_PWD : DB password # * OSMOSE_DB_BASE : DB Aplose dedicated database name (ex: osmose) -# * OSMOSE_PROXY_URL : Proxy host:port if needed (ex: proxy-wiz.osmose.fr:3128) # * Exposed port: 8000 # * Folder dedicated to "datawork" mount: /opt/datawork # * Folder dedicated to server static files mount: /opt/staticfiles diff --git a/backend/api/urls.py b/backend/api/urls.py index 0eaf15a07..8058b1591 100644 --- a/backend/api/urls.py +++ b/backend/api/urls.py @@ -5,8 +5,12 @@ AnnotationViewSet, DownloadViewSet, ) +from backend.api.views.zoom import ZoomViewSet # API urls are meant to be used by our React frontend api_router = routers.DefaultRouter() api_router.register(r"annotation", AnnotationViewSet, basename="annotation") api_router.register("download", DownloadViewSet, basename="download") + + +api_router.register("data", ZoomViewSet, basename="data-zoom") diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py new file mode 100644 index 000000000..a17a258c6 --- /dev/null +++ b/backend/api/views/zoom.py @@ -0,0 +1,58 @@ +from io import BytesIO + +import matplotlib.pyplot as plt +from django.http import HttpResponse +from osekit.core_api.audio_data import AudioData +from osekit.core_api.spectro_data import SpectroData +from rest_framework.decorators import action +from rest_framework.viewsets import ViewSet +from scipy.signal import ShortTimeFFT, spectrogram +from scipy.signal.windows import hamming + +from backend.api.models import Spectrogram, SpectrogramAnalysis + + +class ZoomViewSet(ViewSet): + """Zoom view set""" + + @action( + detail=False, + url_path="zoom/(?P[^/.]+)/(?P[^/.]+)", + url_name="zoom", + ) + def zoom(self, request, level=0, tile=0): + file: Spectrogram = Spectrogram.objects.filter(analysis__legacy=False).first() + analysis: SpectrogramAnalysis = file.analysis.filter(legacy=False).first() + audio_data: AudioData = file.get_spectro_data_for(analysis).audio_data + + zoom_level = pow(2, int(level)) + + audio_data = audio_data.split(zoom_level)[int(tile)] + + win_size = analysis.fft.window_size or 1_024 + overlap = analysis.fft.overlap or 0.95 + hop = round(win_size * (1 - overlap)) + spectro_data = SpectroData.from_audio_data( + data=audio_data, + fft=ShortTimeFFT( + win=hamming(win_size), + hop=hop // zoom_level, # Improve temporal definition with zoom + fs=analysis.fft.sampling_frequency, + scale_to="magnitude", + ), + v_lim=(0.0, 150.0), # Boundaries of the spectrogram + # colormap="Greys", # This is the default value + colormap="viridis", # This is the default value + ) + + spectro_data.plot() + + # Get the (plotted) image into memory file + imgdata = BytesIO() + plt.savefig(imgdata, format="png", bbox_inches="tight", pad_inches=0) + imgdata.seek(0) # rewind the data + + response = HttpResponse(content_type="image/png") + # Write the value of our buffer to the response + response.write(imgdata.getvalue()) + return response From b723558d22e792247c171b102aa92b9d166dc5c2 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Thu, 5 Feb 2026 14:53:35 +0100 Subject: [PATCH 02/13] feat(annotation): Optimize tile management [APLOSE-411] --- frontend/src/components/ui/index.ts | 1 + frontend/src/components/ui/window.hook.ts | 5 + .../src/features/Annotator/Canvas/Window.tsx | 11 +- .../src/features/Annotator/Canvas/hooks.ts | 15 +- .../Annotator/Canvas/styles.module.scss | 6 + .../features/Annotator/Canvas/window.hooks.ts | 4 +- .../features/Annotator/Spectrogram/hooks.ts | 85 ----------- .../features/Annotator/Spectrogram/index.ts | 2 - .../Display/SpectrogramDisplay.tsx | 29 ++++ .../Spectrogram/Display/dimension.hook.ts | 24 ++++ .../features/Spectrogram/Display/draw.hook.ts | 135 ++++++++++++++++++ .../src/features/Spectrogram/Display/index.ts | 1 + frontend/src/features/Spectrogram/index.ts | 0 13 files changed, 217 insertions(+), 101 deletions(-) create mode 100644 frontend/src/components/ui/window.hook.ts delete mode 100644 frontend/src/features/Annotator/Spectrogram/hooks.ts create mode 100644 frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx create mode 100644 frontend/src/features/Spectrogram/Display/dimension.hook.ts create mode 100644 frontend/src/features/Spectrogram/Display/draw.hook.ts create mode 100644 frontend/src/features/Spectrogram/Display/index.ts create mode 100644 frontend/src/features/Spectrogram/index.ts diff --git a/frontend/src/components/ui/index.ts b/frontend/src/components/ui/index.ts index 838705e28..774c84b63 100644 --- a/frontend/src/components/ui/index.ts +++ b/frontend/src/components/ui/index.ts @@ -19,3 +19,4 @@ export * from './Tooltip' export * from './popover.hook' export * from './toast.hook' +export * from './window.hook' diff --git a/frontend/src/components/ui/window.hook.ts b/frontend/src/components/ui/window.hook.ts new file mode 100644 index 000000000..38eb71935 --- /dev/null +++ b/frontend/src/components/ui/window.hook.ts @@ -0,0 +1,5 @@ +import { useMemo } from 'react'; + +export const useWindowRatio = () => { + return useMemo(() => window.devicePixelRatio * (1920 / (window.screen.width * window.devicePixelRatio)), []) +} diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index 53957f50f..20c6bcec4 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -25,6 +25,7 @@ import { selectIsColormapReversed, } from '@/features/Annotator/VisualConfiguration'; import { selectAnalysis } from '@/features/Annotator/Analysis'; +import { SpectrogramDisplay } from '@/features/Spectrogram/Display'; import { AcousticFeatures } from '@/features/Annotator/AcousticFeatures'; export const AnnotatorCanvasWindow: React.FC = () => { @@ -67,7 +68,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { else if (event.deltaY > 0) zoomOut(origin) }, [ zoomIn, zoomOut, getCoords ]) - const seekAudio = useCallback((event: MouseEvent) => { + const seekAudio = useCallback((event: MouseEvent) => { seek(getFreqTime(event)?.time ?? 0) }, [ seek, getFreqTime ]) @@ -160,9 +161,15 @@ export const AnnotatorCanvasWindow: React.FC = () => {
e.stopPropagation() }> - } + + { const width = useWindowWidth() const height = useWindowHeight() - const drawSpectrogram = useDrawSpectrogram() const drawTempAnnotation = useDrawTempAnnotation() const applyFilter = useApplyFilter() const applyColormap = useApplyColormap() @@ -39,17 +37,16 @@ export const useDrawCanvas = () => { const { mainCanvasRef } = useAnnotatorCanvasContext() return useCallback(async () => { - const context = mainCanvasRef?.current?.getContext('2d', { alpha: false }); + const context = mainCanvasRef?.current?.getContext('2d'); if (!context) return; // Reset context.clearRect(0, 0, width, height); - applyFilter(context) - await drawSpectrogram(context) - applyColormap(context) + // applyFilter(context) + // applyColormap(context) drawTempAnnotation(context) - }, [ width, height, drawSpectrogram, applyFilter, applyColormap, drawTempAnnotation ]); + }, [ width, height, applyFilter, applyColormap, drawTempAnnotation ]); } export const useDownloadCanvas = () => { @@ -58,7 +55,7 @@ export const useDownloadCanvas = () => { const draw = useDrawCanvas() - const { mainCanvasRef, xAxisCanvasRef, yAxisCanvasRef } = useAnnotatorCanvasContext() + const { xAxisCanvasRef, yAxisCanvasRef } = useAnnotatorCanvasContext() return useCallback(async (filename: string) => { const link = document.createElement('a'); @@ -68,7 +65,7 @@ export const useDownloadCanvas = () => { // Get spectro images await draw() - const spectroDataURL = mainCanvasRef?.current?.toDataURL('image/png'); + const spectroDataURL = (document.getElementById('spectrogram') as HTMLCanvasElement)?.toDataURL('image/png'); if (!spectroDataURL) throw new Error('Cannot recover spectro dataURL'); draw() const spectroImg = new Image(); diff --git a/frontend/src/features/Annotator/Canvas/styles.module.scss b/frontend/src/features/Annotator/Canvas/styles.module.scss index 2adfac282..77cf0aac8 100644 --- a/frontend/src/features/Annotator/Canvas/styles.module.scss +++ b/frontend/src/features/Annotator/Canvas/styles.module.scss @@ -15,6 +15,12 @@ canvas { display: block; + &.interfaction { + position: absolute; + top: 0; + left: 0; + } + &.drawable { cursor: crosshair; } diff --git a/frontend/src/features/Annotator/Canvas/window.hooks.ts b/frontend/src/features/Annotator/Canvas/window.hooks.ts index 3d71032bf..a73d56b62 100644 --- a/frontend/src/features/Annotator/Canvas/window.hooks.ts +++ b/frontend/src/features/Annotator/Canvas/window.hooks.ts @@ -1,6 +1,7 @@ import { useAppSelector } from '@/features/App'; import { selectZoom } from '@/features/Annotator/Zoom'; import { useMemo } from 'react'; +import { useWindowRatio } from '@/components/ui'; const SPECTRO_HEIGHT: number = 512; const SPECTRO_WIDTH: number = 1813; @@ -8,9 +9,6 @@ export const Y_AXIS_WIDTH: number = 35; export const X_AXIS_HEIGHT: number = 30; -const useWindowRatio = () => - useMemo(() => window.devicePixelRatio * (1920 / (window.screen.width * window.devicePixelRatio)), []) - export const useWindowContainerWidth = () => { const ratio = useWindowRatio() return useMemo(() => SPECTRO_WIDTH / ratio, [ ratio ]) diff --git a/frontend/src/features/Annotator/Spectrogram/hooks.ts b/frontend/src/features/Annotator/Spectrogram/hooks.ts deleted file mode 100644 index 93ba2cecd..000000000 --- a/frontend/src/features/Annotator/Spectrogram/hooks.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useCallback, useRef } from 'react'; -import { selectZoom } from '@/features/Annotator/Zoom'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; -import { useAnnotationTask } from '@/api'; -import { useToast } from '@/components/ui'; -import { useWindowHeight } from '@/features/Annotator/Canvas'; -import { useTimeScale } from '@/features/Annotator/Axis'; -import { useAppSelector } from '@/features/App'; - -export const useDrawSpectrogram = () => { - const analysis = useAppSelector(selectAnalysis) - const zoom = useAppSelector(selectZoom) - const { spectrogram } = useAnnotationTask() - const height = useWindowHeight() - const timeScale = useTimeScale() - const toast = useToast() - const images = useRef>>(new Map); - const failedImagesSources = useRef([]) - - const areAllImagesLoaded = useCallback((): boolean => { - return images.current.get(zoom)?.filter(i => !!i).length === zoom - }, [ zoom ]) - - const loadImages = useCallback(async () => { - if (!analysis || !spectrogram?.path) { - images.current = new Map(); - return; - } - if (areAllImagesLoaded()) return; - - const filename = spectrogram.filename - return Promise.all( - Array.from(new Array(zoom)).map(async (_, index) => { - let src = spectrogram?.path; - if (!src) return; - if (analysis.legacy) { - src = `${ src.split(filename)[0] }${ filename }_${ zoom }_${ index }${ src.split(filename)[1] }` - } - if (failedImagesSources.current.includes(src)) return; - console.info(`Will load for zoom ${ zoom }, image ${ index }`) - const image = new Image(); - image.src = src; - return await new Promise((resolve) => { - image.onload = () => { - console.info(`Image loaded: ${ image.src }`) - resolve(image); - } - image.onerror = error => { - failedImagesSources.current.push(src) - toast.raiseError({ - message: `Cannot load spectrogram image with source: ${ image.src }`, - error, - }) - resolve(undefined); - } - }) - }), - ).then(loadedImages => { - images.current.set(zoom, loadedImages) - }) - }, [ analysis, zoom, failedImagesSources, areAllImagesLoaded, spectrogram, analysis ]) - - return useCallback(async (context: CanvasRenderingContext2D) => { - if (!areAllImagesLoaded()) await loadImages(); - if (!areAllImagesLoaded()) return; - - const currentImages = images.current.get(zoom) - if (!currentImages || !spectrogram) return; - for (const i in currentImages) { - const index: number | undefined = i ? +i : undefined; - if (index === undefined) continue; - const start = index * spectrogram.duration / zoom; - const end = (index + 1) * spectrogram.duration / zoom; - const image = currentImages[index]; - if (!image) continue - context.drawImage( - image, - timeScale.valueToPosition(start), - 0, - Math.floor(timeScale.valuesToPositionRange(start, end)), - height, - ) - } - }, [ images, zoom, spectrogram, timeScale, height, areAllImagesLoaded, loadImages ]) -} diff --git a/frontend/src/features/Annotator/Spectrogram/index.ts b/frontend/src/features/Annotator/Spectrogram/index.ts index 9df95dede..e6afa7e31 100644 --- a/frontend/src/features/Annotator/Spectrogram/index.ts +++ b/frontend/src/features/Annotator/Spectrogram/index.ts @@ -1,4 +1,2 @@ -export * from './hooks' - export * from './Info' export * from './DownloadButton' diff --git a/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx b/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx new file mode 100644 index 000000000..ae7e9fb3f --- /dev/null +++ b/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx @@ -0,0 +1,29 @@ +import React, { useCallback, useRef } from 'react'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook.ts'; +import { useSpectrogramTiles } from '@/features/Spectrogram/Display/draw.hook.ts'; +import { AnnotationSpectrogramNode, SpectrogramAnalysisNode } from '@/api'; + +export const SpectrogramDisplay: React.FC<{ + zoomLevel: number, + spectrogram: Pick, + analysis: Pick +}> = ({ zoomLevel, spectrogram, analysis }) => { + const canvasRef = useRef(null); + const { width, height } = useSpectrogramDimensions(0) + + const getUrl = useCallback((_: number, index: number) => { + if (analysis.legacy) { + const p = spectrogram.path + const f = spectrogram.filename + return `${ p.split(f)[0] }${ f }_${ zoomLevel }_${ index }${ p.split(f)[1] }` + } else return spectrogram.path + }, [ spectrogram, zoomLevel, analysis ]) + + useSpectrogramTiles({ canvasRef, zoomLevel: zoomLevel.toString(2).length - 1, getUrl }) + + return +} diff --git a/frontend/src/features/Spectrogram/Display/dimension.hook.ts b/frontend/src/features/Spectrogram/Display/dimension.hook.ts new file mode 100644 index 000000000..ef5a9ce08 --- /dev/null +++ b/frontend/src/features/Spectrogram/Display/dimension.hook.ts @@ -0,0 +1,24 @@ +import { useWindowRatio } from '@/components/ui'; +import { useMemo } from 'react'; + +export const SpectrogramDimensions = { + height: 512, + width: 1813, +} + +export const useSpectrogramDimensions = (zoomLevel: number = 0) => { + const ratio = useWindowRatio() + const width = useMemo(() => { + return (SpectrogramDimensions.width / ratio) * (zoomLevel + 1) + }, [ ratio, zoomLevel ]) + const height = useMemo(() => { + return SpectrogramDimensions.height / ratio + }, [ ratio ]) + + return { + originalWidth: SpectrogramDimensions.width, + originalHeight: SpectrogramDimensions.height, + width, + height, + } +} diff --git a/frontend/src/features/Spectrogram/Display/draw.hook.ts b/frontend/src/features/Spectrogram/Display/draw.hook.ts new file mode 100644 index 000000000..0c200ae4c --- /dev/null +++ b/frontend/src/features/Spectrogram/Display/draw.hook.ts @@ -0,0 +1,135 @@ +import { MutableRefObject, useCallback, useEffect, useState } from 'react'; +import { SpectrogramDimensions, useSpectrogramDimensions } from './dimension.hook.ts'; + +const PRELOAD_MARGIN = 1 +type TileKey = `${ number }_${ number }` // [zoomLevel]_[index] + +export const useSpectrogramTiles = ({ + canvasRef, + zoomLevel, + getUrl, + }: { + canvasRef: MutableRefObject, + zoomLevel: number, + getUrl: (zoomLevel: number, index: number) => string +}) => { + const { width, height } = useSpectrogramDimensions(0) + + const [ loadedTiles, setLoadedTiles ] = useState>(new Map()); + const [ preloadedTiles, setPreloadedTiles ] = useState>(new Set()); + const [ visibleTiles, setVisibleTiles ] = useState>(new Set()); + + useEffect(() => { + + }, [ canvasRef.current, preloadedTiles, visibleTiles, canvasRef.current?.scrollLeft ]); + + /** + * Update tiles sets + */ + useEffect(() => { + if (!canvasRef.current) return; + const viewportX = canvasRef.current.scrollLeft + const viewportWidth = canvasRef.current.width; + const tilesPerZoom = Math.pow(2, zoomLevel); + const startTileIdx = Math.floor(viewportX / SpectrogramDimensions.width); + const endTileIdx = Math.ceil((viewportX + viewportWidth) / SpectrogramDimensions.width); + + const visible = Array.from( + { length: endTileIdx - startTileIdx + 1 }, + (_, i) => Math.min(startTileIdx + i, tilesPerZoom - 1), + ); + + const min = Math.min(...visible); + const max = Math.max(...visible); + const preloaded = [ + Math.max(0, min - PRELOAD_MARGIN), + Math.min(tilesPerZoom - 1, max + PRELOAD_MARGIN), + ].filter(index => !visible.includes(index)) + + console.log(visible, preloaded, startTileIdx, tilesPerZoom, zoomLevel) + + setVisibleTiles(new Set(visible.map(index => `${ zoomLevel }_${ index }` as TileKey))); + setPreloadedTiles(new Set(preloaded.map(index => `${ zoomLevel }_${ index }` as TileKey))); + }, [ canvasRef.current, zoomLevel, canvasRef.current?.scrollLeft ]); + + /** + * Actually load and display tiles + */ + useEffect(() => { + if (!canvasRef.current) return; + loadTiles().catch(console.warn) + }, [ visibleTiles, preloadedTiles ]); + + const display = useCallback((index: number, image: HTMLImageElement) => { + const context = canvasRef.current?.getContext('2d', { alpha: false }); + if (!context) return; + context.drawImage( + image, + index * width, + 0, + width, + height, + ) + }, [ canvasRef.current, width, height ]) + + const loadTile = useCallback(async (key: TileKey) => { + const [ zoom, index ] = key.split('_') + const url = getUrl(+zoom, +index); + + const img = new Image(); + const loadPromise = new Promise((resolve, reject) => { + img.onload = () => resolve(img); + img.onerror = reject; + }); + + img.src = url; + console.debug('url:', url) + + try { + await loadPromise; + setLoadedTiles(prev => { + prev.set(key, img) + return prev + }) + display(+index, img) + return img; + } catch (error) { + console.error(`Failed to load tile ${ key }:`, error); + throw error; + } + }, [ getUrl, display ]) + + const loadTiles = useCallback(async () => { + + // Priority 1: Visible tiles + let promises = []; + for (const key of visibleTiles) { + if (!loadedTiles.has(key)) { + promises.push(loadTile(key)); + } + } + await Promise.allSettled(promises); + + // Priority 2: Adjacent tiles for smooth panning + promises = []; + for (const key of preloadedTiles) { + if (!loadedTiles.has(key)) { + promises.push(loadTile(key)); + } + } + await Promise.allSettled(promises); + + // Clean distant tiles + const displayedKeys = [ ...visibleTiles, ...preloadedTiles ]; + setLoadedTiles(prev => { + for (const key of prev.keys()) { + if (!displayedKeys.includes(key)) { + prev.delete(key); + // Let garbage collector handle the Image object + } + } + return prev + }); + }, [ visibleTiles, preloadedTiles, loadedTiles, loadTile ]) + +} diff --git a/frontend/src/features/Spectrogram/Display/index.ts b/frontend/src/features/Spectrogram/Display/index.ts new file mode 100644 index 000000000..f9d581861 --- /dev/null +++ b/frontend/src/features/Spectrogram/Display/index.ts @@ -0,0 +1 @@ +export { SpectrogramDisplay } from './SpectrogramDisplay' diff --git a/frontend/src/features/Spectrogram/index.ts b/frontend/src/features/Spectrogram/index.ts new file mode 100644 index 000000000..e69de29bb From b8a7ccbfed6181ad2295fdde4f284b38bd91633f Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Fri, 6 Feb 2026 08:14:04 +0100 Subject: [PATCH 03/13] feat(annotation): Scenario B - generate from wav on GET request [APLOSE-376] --- backend/api/models/data/spectrogram.py | 100 +++++++- .../schema/nodes/annotation_spectrogram.py | 35 +-- backend/api/views/zoom.py | 38 ++- .../src/features/Annotator/Canvas/Window.tsx | 3 +- .../src/features/Annotator/Zoom/selectors.ts | 42 ++-- .../Display/SpectrogramDisplay.tsx | 22 +- .../features/Spectrogram/Display/draw.hook.ts | 235 +++++++++--------- 7 files changed, 268 insertions(+), 207 deletions(-) diff --git a/backend/api/models/data/spectrogram.py b/backend/api/models/data/spectrogram.py index c88dca735..353bdb4c3 100644 --- a/backend/api/models/data/spectrogram.py +++ b/backend/api/models/data/spectrogram.py @@ -1,18 +1,23 @@ """Spectrogram model""" import csv from datetime import datetime, timedelta +from os import path from os.path import join -from pathlib import Path +from pathlib import Path, PureWindowsPath from django.conf import settings from django.db import models from django.db.models import Q, F, QuerySet +from django.utils import timezone from metadatax.data.models import FileFormat from osekit.config import TIMESTAMP_FORMAT_EXPORTED_FILES_LOCALIZED - -# from osekit.core_api.spectro_data import SpectroData -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset, SpectroData +from osekit.core_api.audio_data import AudioData +from osekit.core_api.audio_file import AudioFile +from osekit.core_api.spectro_data import SpectroData +from osekit.core_api.spectro_dataset import SpectroDataset +from pandas import Timestamp +from scipy.signal import ShortTimeFFT +from scipy.signal.windows import hamming from .__abstract_file import AbstractFile from .__abstract_time_segment import TimeSegment @@ -157,6 +162,87 @@ def __str__(self): analysis = models.ManyToManyField(SpectrogramAnalysis, related_name="spectrograms") + def get_audio_path(self, analysis: SpectrogramAnalysis) -> str: + if analysis.dataset.legacy: + folders = PureWindowsPath(analysis.path).as_posix().split("/") + folders.pop() + return path.join( + analysis.dataset.path.split( + settings.DATASET_EXPORT_PATH.stem + "/" + ).pop(), + PureWindowsPath(settings.DATASET_FILES_FOLDER), + PureWindowsPath(folders.pop()), + PureWindowsPath(f"{self.filename}.wav"), + ) + else: + spectro_data: SpectroData = self.get_spectro_data_for(analysis) + audio_files = list(spectro_data.audio_data.files) + if len(audio_files) != 1: + return None + + audio_file = audio_files[0] + if audio_file.begin != ( + self.start if audio_file.begin.tz else timezone.make_naive(self.start) + ): + return None + if audio_file.end < ( + self.end if audio_file.end.tz else timezone.make_naive(self.end) + ): + return None + + audio_path = str(audio_file.path) + return ( + audio_path.split(str(settings.DATASET_EXPORT_PATH)).pop().lstrip("\\") + ) + def get_spectro_data_for(self, analysis: SpectrogramAnalysis) -> SpectroData: - spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() - return [d for d in spectro_dataset.data if d.name == self.filename].pop() + if analysis.legacy: + audio_path = Path( + join( + settings.VOLUMES_ROOT, + settings.DATASET_EXPORT_PATH, + self.get_audio_path(analysis), + ) + ) + audio_file = AudioFile( + path=audio_path, + begin=Timestamp.fromisoformat(self.start.isoformat()), + ) + audio_data = AudioData.from_files([audio_file]) + overlap = analysis.fft.overlap or 0.95 + hop = round(analysis.fft.window_size * (1 - overlap)) + return SpectroData.from_audio_data( + data=audio_data, + fft=ShortTimeFFT( + win=hamming(analysis.fft.window_size), + hop=hop, + fs=analysis.fft.sampling_frequency, + scale_to="magnitude", + ), + colormap="viridis", # This is the default value + ) + else: + spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() + return [d for d in spectro_dataset.data if d.name == self.filename].pop() + + def get_audio_data_for(self, analysis: SpectrogramAnalysis) -> AudioData: + if analysis.legacy: + audio_path = Path( + join( + settings.VOLUMES_ROOT, + settings.DATASET_EXPORT_PATH, + self.get_audio_path(analysis), + ) + ) + audio_file = AudioFile( + path=audio_path, + begin=Timestamp.fromisoformat(self.start.isoformat()), + ) + return AudioData.from_files([audio_file]) + else: + spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() + return ( + [d for d in spectro_dataset.data if d.name == self.filename] + .pop() + .audio_data + ) diff --git a/backend/api/schema/nodes/annotation_spectrogram.py b/backend/api/schema/nodes/annotation_spectrogram.py index 0a5cd47c6..4cc232a75 100644 --- a/backend/api/schema/nodes/annotation_spectrogram.py +++ b/backend/api/schema/nodes/annotation_spectrogram.py @@ -102,43 +102,10 @@ def resolve_is_assigned( @graphene_django_optimizer.resolver_hints() def resolve_audio_path(self: Spectrogram, info, analysis_id: int): analysis: SpectrogramAnalysis = self.analysis.get(id=analysis_id) - - audio_path: str - if analysis.dataset.legacy: - folders = PureWindowsPath(analysis.path).as_posix().split("/") - folders.pop() - audio_path = path.join( - analysis.dataset.path.split( - settings.DATASET_EXPORT_PATH.stem + "/" - ).pop(), - PureWindowsPath(settings.DATASET_FILES_FOLDER), - PureWindowsPath(folders.pop()), - PureWindowsPath(f"{self.filename}.wav"), - ) - else: - spectro_data: SpectroData = self.get_spectro_data_for(analysis) - audio_files = list(spectro_data.audio_data.files) - if len(audio_files) != 1: - return None - - audio_file = audio_files[0] - if audio_file.begin != ( - self.start if audio_file.begin.tz else timezone.make_naive(self.start) - ): - return None - if audio_file.end < ( - self.end if audio_file.end.tz else timezone.make_naive(self.end) - ): - return None - - audio_path = str(audio_file.path) - audio_path = ( - audio_path.split(str(settings.DATASET_EXPORT_PATH)).pop().lstrip("\\") - ) return path.join( PureWindowsPath(settings.STATIC_URL), PureWindowsPath(settings.DATASET_EXPORT_PATH), - PureWindowsPath(audio_path), + PureWindowsPath(self.get_audio_path(analysis)), ) path = graphene.String(analysis_id=graphene.ID(required=True), required=True) diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py index a17a258c6..cb0363916 100644 --- a/backend/api/views/zoom.py +++ b/backend/api/views/zoom.py @@ -2,11 +2,12 @@ import matplotlib.pyplot as plt from django.http import HttpResponse +from django.shortcuts import get_object_or_404 from osekit.core_api.audio_data import AudioData from osekit.core_api.spectro_data import SpectroData from rest_framework.decorators import action from rest_framework.viewsets import ViewSet -from scipy.signal import ShortTimeFFT, spectrogram +from scipy.signal import ShortTimeFFT from scipy.signal.windows import hamming from backend.api.models import Spectrogram, SpectrogramAnalysis @@ -17,26 +18,30 @@ class ZoomViewSet(ViewSet): @action( detail=False, - url_path="zoom/(?P[^/.]+)/(?P[^/.]+)", + url_path="analysis/(?P[^/.]+)/spectrogram/(?P[^/.]+)/zoom/(?P[^/.]+)/tile/(?P[^/.]+)", url_name="zoom", ) - def zoom(self, request, level=0, tile=0): - file: Spectrogram = Spectrogram.objects.filter(analysis__legacy=False).first() - analysis: SpectrogramAnalysis = file.analysis.filter(legacy=False).first() - audio_data: AudioData = file.get_spectro_data_for(analysis).audio_data + def zoom(self, request, analysis_id=None, spectrogram_id=None, zoom=0, tile=0): + print("view", analysis_id, spectrogram_id, zoom, tile) + zoom = int(zoom) + tile = int(tile) - zoom_level = pow(2, int(level)) + file: Spectrogram = get_object_or_404(Spectrogram, pk=spectrogram_id) + analysis: SpectrogramAnalysis = get_object_or_404( + SpectrogramAnalysis, pk=analysis_id + ) + audio_data: AudioData = file.get_audio_data_for(analysis) - audio_data = audio_data.split(zoom_level)[int(tile)] + zoom_level = pow(2, zoom) + audio_data = audio_data.split(zoom_level)[tile] - win_size = analysis.fft.window_size or 1_024 overlap = analysis.fft.overlap or 0.95 - hop = round(win_size * (1 - overlap)) + hop = round(analysis.fft.window_size * (1 - overlap)) spectro_data = SpectroData.from_audio_data( data=audio_data, fft=ShortTimeFFT( - win=hamming(win_size), - hop=hop // zoom_level, # Improve temporal definition with zoom + win=hamming(analysis.fft.window_size), + hop=max(1, hop // zoom_level), # Improve temporal definition with zoom fs=analysis.fft.sampling_frequency, scale_to="magnitude", ), @@ -49,7 +54,14 @@ def zoom(self, request, level=0, tile=0): # Get the (plotted) image into memory file imgdata = BytesIO() - plt.savefig(imgdata, format="png", bbox_inches="tight", pad_inches=0) + plt.savefig( + imgdata, + transparent=False, + format="png", + bbox_inches="tight", + pad_inches=0, + dpi=72, + ) imgdata.seek(0) # rewind the data response = HttpResponse(content_type="image/png") diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index 20c6bcec4..f9c24d97c 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -167,7 +167,8 @@ export const AnnotatorCanvasWindow: React.FC = () => { { spectrogram && analysis && } + zoomLevel={ zoom } + origin='wav'/> } zoom / 2 >= 1 ? zoom / 2 : undefined, + selectZoom, + (zoom) => zoom / 2 >= 1 ? zoom / 2 : undefined, ) export const selectMaxZoom = createSelector( - [ - // Input selectors - selectAnalysis, - // Pass through input arguments - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (_state: AppState, _campaignID?: string) => undefined, - ], - (analysis) => analysis?.legacyConfiguration?.zoomLevel ?? 0, + [ + // Input selectors + selectAnalysis, + // Pass through input arguments + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_state: AppState, _campaignID?: string) => undefined, + ], + (analysis) => analysis?.legacyConfiguration?.zoomLevel ?? 4, ) export const selectZoomInLevel = createSelector( - [ - // Input selectors - selectMaxZoom, - selectZoom, - // Pass through input arguments - // eslint-disable-next-line @typescript-eslint/no-unused-vars - (_state: AppState, _campaignID?: string) => undefined, - ], - (maxZoom, zoom) => zoom * 2 <= 2 ** maxZoom ? zoom * 2 : undefined, + [ + // Input selectors + selectMaxZoom, + selectZoom, + // Pass through input arguments + // eslint-disable-next-line @typescript-eslint/no-unused-vars + (_state: AppState, _campaignID?: string) => undefined, + ], + (maxZoom, zoom) => zoom * 2 <= 2 ** maxZoom ? zoom * 2 : undefined, ) diff --git a/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx b/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx index ae7e9fb3f..ccb0b857e 100644 --- a/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx +++ b/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx @@ -6,18 +6,24 @@ import { AnnotationSpectrogramNode, SpectrogramAnalysisNode } from '@/api'; export const SpectrogramDisplay: React.FC<{ zoomLevel: number, spectrogram: Pick, - analysis: Pick -}> = ({ zoomLevel, spectrogram, analysis }) => { + analysis: Pick, + origin: 'spectrogram' | 'wav' +}> = ({ zoomLevel, spectrogram, analysis, origin }) => { const canvasRef = useRef(null); const { width, height } = useSpectrogramDimensions(0) const getUrl = useCallback((_: number, index: number) => { - if (analysis.legacy) { - const p = spectrogram.path - const f = spectrogram.filename - return `${ p.split(f)[0] }${ f }_${ zoomLevel }_${ index }${ p.split(f)[1] }` - } else return spectrogram.path - }, [ spectrogram, zoomLevel, analysis ]) + switch (origin) { + case "wav": + return `/api/data/analysis/${ analysis.id }/spectrogram/${ spectrogram.id }/zoom/${ zoomLevel }/tile/${ index }/${ origin }` + default: + if (analysis.legacy) { + const p = spectrogram.path + const f = spectrogram.filename + return `${ p.split(f)[0] }${ f }_${ zoomLevel }_${ index }${ p.split(f)[1] }` + } else return spectrogram.path + } + }, [ spectrogram, zoomLevel, analysis, origin ]) useSpectrogramTiles({ canvasRef, zoomLevel: zoomLevel.toString(2).length - 1, getUrl }) diff --git a/frontend/src/features/Spectrogram/Display/draw.hook.ts b/frontend/src/features/Spectrogram/Display/draw.hook.ts index 0c200ae4c..4c4d55538 100644 --- a/frontend/src/features/Spectrogram/Display/draw.hook.ts +++ b/frontend/src/features/Spectrogram/Display/draw.hook.ts @@ -5,131 +5,120 @@ const PRELOAD_MARGIN = 1 type TileKey = `${ number }_${ number }` // [zoomLevel]_[index] export const useSpectrogramTiles = ({ - canvasRef, - zoomLevel, - getUrl, + canvasRef, + zoomLevel, + getUrl, }: { - canvasRef: MutableRefObject, - zoomLevel: number, - getUrl: (zoomLevel: number, index: number) => string + canvasRef: MutableRefObject, + zoomLevel: number, + getUrl: (zoomLevel: number, index: number) => string }) => { - const { width, height } = useSpectrogramDimensions(0) - - const [ loadedTiles, setLoadedTiles ] = useState>(new Map()); - const [ preloadedTiles, setPreloadedTiles ] = useState>(new Set()); - const [ visibleTiles, setVisibleTiles ] = useState>(new Set()); - - useEffect(() => { - - }, [ canvasRef.current, preloadedTiles, visibleTiles, canvasRef.current?.scrollLeft ]); - - /** - * Update tiles sets - */ - useEffect(() => { - if (!canvasRef.current) return; - const viewportX = canvasRef.current.scrollLeft - const viewportWidth = canvasRef.current.width; - const tilesPerZoom = Math.pow(2, zoomLevel); - const startTileIdx = Math.floor(viewportX / SpectrogramDimensions.width); - const endTileIdx = Math.ceil((viewportX + viewportWidth) / SpectrogramDimensions.width); - - const visible = Array.from( - { length: endTileIdx - startTileIdx + 1 }, - (_, i) => Math.min(startTileIdx + i, tilesPerZoom - 1), - ); - - const min = Math.min(...visible); - const max = Math.max(...visible); - const preloaded = [ - Math.max(0, min - PRELOAD_MARGIN), - Math.min(tilesPerZoom - 1, max + PRELOAD_MARGIN), - ].filter(index => !visible.includes(index)) - - console.log(visible, preloaded, startTileIdx, tilesPerZoom, zoomLevel) - - setVisibleTiles(new Set(visible.map(index => `${ zoomLevel }_${ index }` as TileKey))); - setPreloadedTiles(new Set(preloaded.map(index => `${ zoomLevel }_${ index }` as TileKey))); - }, [ canvasRef.current, zoomLevel, canvasRef.current?.scrollLeft ]); - - /** - * Actually load and display tiles - */ - useEffect(() => { - if (!canvasRef.current) return; - loadTiles().catch(console.warn) - }, [ visibleTiles, preloadedTiles ]); - - const display = useCallback((index: number, image: HTMLImageElement) => { - const context = canvasRef.current?.getContext('2d', { alpha: false }); - if (!context) return; - context.drawImage( - image, - index * width, - 0, - width, - height, - ) - }, [ canvasRef.current, width, height ]) - - const loadTile = useCallback(async (key: TileKey) => { - const [ zoom, index ] = key.split('_') - const url = getUrl(+zoom, +index); - - const img = new Image(); - const loadPromise = new Promise((resolve, reject) => { - img.onload = () => resolve(img); - img.onerror = reject; - }); - - img.src = url; - console.debug('url:', url) - - try { - await loadPromise; - setLoadedTiles(prev => { - prev.set(key, img) - return prev - }) - display(+index, img) - return img; - } catch (error) { - console.error(`Failed to load tile ${ key }:`, error); - throw error; - } - }, [ getUrl, display ]) - - const loadTiles = useCallback(async () => { - - // Priority 1: Visible tiles - let promises = []; - for (const key of visibleTiles) { - if (!loadedTiles.has(key)) { - promises.push(loadTile(key)); - } - } - await Promise.allSettled(promises); - - // Priority 2: Adjacent tiles for smooth panning - promises = []; - for (const key of preloadedTiles) { - if (!loadedTiles.has(key)) { - promises.push(loadTile(key)); - } - } - await Promise.allSettled(promises); - - // Clean distant tiles - const displayedKeys = [ ...visibleTiles, ...preloadedTiles ]; - setLoadedTiles(prev => { - for (const key of prev.keys()) { - if (!displayedKeys.includes(key)) { - prev.delete(key); - // Let garbage collector handle the Image object + const { width, height } = useSpectrogramDimensions(0) + + const [ loadedTiles, setLoadedTiles ] = useState>(new Map()); + const [ preloadedTiles, setPreloadedTiles ] = useState>(new Set()); + const [ visibleTiles, setVisibleTiles ] = useState>(new Set()); + + /** + * Update tiles sets + */ + useEffect(() => { + if (!canvasRef.current) return; + const viewportX = canvasRef.current.parentElement!.parentElement!.scrollLeft; + const tilesPerZoom = Math.pow(2, zoomLevel); + const startTileIdx = Math.floor(viewportX / SpectrogramDimensions.width); + const endTileIdx = Math.ceil((viewportX + SpectrogramDimensions.width) / SpectrogramDimensions.width); + + const visible = Array.from( + { length: endTileIdx - startTileIdx + 1 }, + (_, i) => Math.min(startTileIdx + i, tilesPerZoom - 1), + ); + + const min = Math.min(...visible); + const max = Math.max(...visible); + const preloaded = [ + Math.max(0, min - PRELOAD_MARGIN), + Math.min(tilesPerZoom - 1, max + PRELOAD_MARGIN), + ].filter(index => !visible.includes(index)) + + setVisibleTiles(new Set(visible.map(index => `${ zoomLevel }_${ index }` as TileKey))); + setPreloadedTiles(new Set(preloaded.map(index => `${ zoomLevel }_${ index }` as TileKey))); + }, [ canvasRef.current, zoomLevel, canvasRef.current?.parentElement?.parentElement?.scrollLeft ]); + + /** + * Actually load and display tiles + */ + useEffect(() => { + if (!canvasRef.current) return; + loadTiles().catch(console.warn) + }, [ visibleTiles, preloadedTiles ]); + + const display = useCallback((index: number, image: HTMLImageElement) => { + const context = canvasRef.current?.getContext('2d', { alpha: false }); + if (!context) return; + context.drawImage( + image, + index * width, + 0, + width, + height, + ) + }, [ canvasRef.current, width, height ]) + + const loadTile = useCallback(async (key: TileKey) => { + const [ zoom, index ] = key.split('_') + const url = getUrl(+zoom, +index); + + const img = new Image(); + const loadPromise = new Promise((resolve, reject) => { + img.onload = () => resolve(img); + img.onerror = reject; + }); + + img.src = url; + console.debug('url:', url) + + try { + await loadPromise; + setLoadedTiles(prev => { + prev.set(key, img) + return prev + }) + display(+index, img) + return img; + } catch (error) { + console.error(`Failed to load tile ${ key }:`, error); + throw error; } - } - return prev - }); - }, [ visibleTiles, preloadedTiles, loadedTiles, loadTile ]) + }, [ getUrl, display ]) + + const loadTiles = useCallback(async () => { + + // Priority 1: Visible tiles + for (const key of visibleTiles) { + if (!loadedTiles.has(key)) { + await loadTile(key); + } + } + + // Priority 2: Adjacent tiles for smooth panning + for (const key of preloadedTiles) { + if (!loadedTiles.has(key)) { + await loadTile(key); + } + } + + // Clean distant tiles + const displayedKeys = [ ...visibleTiles, ...preloadedTiles ]; + setLoadedTiles(prev => { + for (const key of prev.keys()) { + if (!displayedKeys.includes(key)) { + prev.delete(key); + // Let garbage collector handle the Image object + } + } + return prev + }); + }, [ visibleTiles, preloadedTiles, loadedTiles, loadTile ]) } From bb3f5cd6bb03336fb571380c815e894650e6a370 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Tue, 10 Feb 2026 10:45:54 +0100 Subject: [PATCH 04/13] feat(zoom): Review tile management --- backend/api/models/data/spectrogram.py | 23 +++ .../schema/nodes/annotation_spectrogram.py | 30 +-- backend/api/views/zoom.py | 122 +++++++++--- .../src/features/Annotator/Canvas/Window.tsx | 11 +- .../SpectrogramModeSelect.tsx | 27 +++ .../VisualConfiguration/selectors.ts | 4 + .../Annotator/VisualConfiguration/slice.ts | 151 ++++++++------- .../features/Annotator/Zoom/ZoomButtons.tsx | 2 +- frontend/src/features/Annotator/Zoom/hooks.ts | 8 +- .../src/features/Annotator/Zoom/selectors.ts | 8 +- frontend/src/features/Annotator/Zoom/slice.ts | 2 +- .../Display/SpectrogramDisplay.tsx | 58 +++--- .../Spectrogram/Display/TileManager.ts | 176 ++++++++++++++++++ .../features/Spectrogram/Display/draw.hook.ts | 124 ------------ .../src/features/Spectrogram/Display/index.ts | 1 + .../src/features/Spectrogram/Display/types.ts | 11 ++ .../spectrogram/[spectrogramID]/index.tsx | 2 + 17 files changed, 478 insertions(+), 282 deletions(-) create mode 100644 frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx create mode 100644 frontend/src/features/Spectrogram/Display/TileManager.ts delete mode 100644 frontend/src/features/Spectrogram/Display/draw.hook.ts create mode 100644 frontend/src/features/Spectrogram/Display/types.ts diff --git a/backend/api/models/data/spectrogram.py b/backend/api/models/data/spectrogram.py index 353bdb4c3..b85629503 100644 --- a/backend/api/models/data/spectrogram.py +++ b/backend/api/models/data/spectrogram.py @@ -195,6 +195,29 @@ def get_audio_path(self, analysis: SpectrogramAnalysis) -> str: audio_path.split(str(settings.DATASET_EXPORT_PATH)).pop().lstrip("\\") ) + def get_base_spectro_path(self, analysis: SpectrogramAnalysis) -> str: + if analysis.dataset.legacy: + return path.join( + PureWindowsPath( + analysis.dataset.path.split( + settings.DATASET_EXPORT_PATH.stem + "/" + ).pop() + ), + PureWindowsPath(analysis.path), + PureWindowsPath("image"), + PureWindowsPath(f"{self.filename}.{self.format.name}"), + ) + else: + spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() + spectro_dataset_path = str(spectro_dataset.folder).split( + str(settings.DATASET_EXPORT_PATH) + )[1] + return path.join( + PureWindowsPath(spectro_dataset_path), + PureWindowsPath("spectrogram"), # TODO: avoid static path parts!!! + PureWindowsPath(f"{self.filename}.{self.format.name}"), + ).lstrip("\\") + def get_spectro_data_for(self, analysis: SpectrogramAnalysis) -> SpectroData: if analysis.legacy: audio_path = Path( diff --git a/backend/api/schema/nodes/annotation_spectrogram.py b/backend/api/schema/nodes/annotation_spectrogram.py index 4cc232a75..d4ffb7a02 100644 --- a/backend/api/schema/nodes/annotation_spectrogram.py +++ b/backend/api/schema/nodes/annotation_spectrogram.py @@ -5,16 +5,11 @@ import graphene import graphene_django_optimizer from django.conf import settings -from django.utils import timezone from django_extension.schema.errors import NotFoundError from django_extension.schema.fields import AuthenticatedPaginationConnectionField from django_extension.schema.types import ExtendedNode from graphql import GraphQLResolveInfo -# from osekit.core_api.spectro_data import SpectroData -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset, SpectroData - from backend.api.models import ( Spectrogram, AnnotationCampaign, @@ -113,33 +108,10 @@ def resolve_audio_path(self: Spectrogram, info, analysis_id: int): @graphene_django_optimizer.resolver_hints() def resolve_path(self: Spectrogram, info, analysis_id: int): analysis: SpectrogramAnalysis = self.analysis.get(id=analysis_id) - - spectrogram_path: str - if analysis.dataset.legacy: - spectrogram_path = path.join( - PureWindowsPath( - analysis.dataset.path.split( - settings.DATASET_EXPORT_PATH.stem + "/" - ).pop() - ), - PureWindowsPath(analysis.path), - PureWindowsPath("image"), - PureWindowsPath(f"{self.filename}.{self.format.name}"), - ) - else: - spectro_dataset: SpectroDataset = analysis.get_osekit_spectro_dataset() - spectro_dataset_path = str(spectro_dataset.folder).split( - str(settings.DATASET_EXPORT_PATH) - )[1] - spectrogram_path = path.join( - PureWindowsPath(spectro_dataset_path), - PureWindowsPath("spectrogram"), # TODO: avoid static path parts!!! - PureWindowsPath(f"{self.filename}.{self.format.name}"), - ).lstrip("\\") return path.join( PureWindowsPath(settings.STATIC_URL), PureWindowsPath(settings.DATASET_EXPORT_PATH), - PureWindowsPath(spectrogram_path), + PureWindowsPath(self.get_base_spectro_path(analysis)), ) task = graphene.Field( diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py index cb0363916..16857cb95 100644 --- a/backend/api/views/zoom.py +++ b/backend/api/views/zoom.py @@ -1,11 +1,16 @@ +from http import HTTPStatus from io import BytesIO +from os import path +from pathlib import PureWindowsPath import matplotlib.pyplot as plt -from django.http import HttpResponse +from django.conf import settings +from django.http import HttpResponse, HttpResponseRedirect, QueryDict from django.shortcuts import get_object_or_404 -from osekit.core_api.audio_data import AudioData +from django.templatetags.static import static from osekit.core_api.spectro_data import SpectroData from rest_framework.decorators import action +from rest_framework.request import Request from rest_framework.viewsets import ViewSet from scipy.signal import ShortTimeFFT from scipy.signal.windows import hamming @@ -18,36 +23,105 @@ class ZoomViewSet(ViewSet): @action( detail=False, - url_path="analysis/(?P[^/.]+)/spectrogram/(?P[^/.]+)/zoom/(?P[^/.]+)/tile/(?P[^/.]+)", + url_path="analysis/(?P[^/.]+)/spectrogram/(?P[^/.]+)", url_name="zoom", ) - def zoom(self, request, analysis_id=None, spectrogram_id=None, zoom=0, tile=0): - print("view", analysis_id, spectrogram_id, zoom, tile) - zoom = int(zoom) - tile = int(tile) + def zoom( + self, + request: Request, + analysis_id=None, + spectrogram_id=None, + ): + mode = request.query_params.get("mode", "png") + zoom = int(request.query_params.get("zoom", 0)) + tile = int(request.query_params.get("tile", 0)) - file: Spectrogram = get_object_or_404(Spectrogram, pk=spectrogram_id) + spectrogram: Spectrogram = get_object_or_404(Spectrogram, pk=spectrogram_id) analysis: SpectrogramAnalysis = get_object_or_404( SpectrogramAnalysis, pk=analysis_id ) - audio_data: AudioData = file.get_audio_data_for(analysis) + if mode == "png": + return self.get_from_png(analysis, spectrogram, zoom, tile) + elif mode == "wav": + return self.get_from_wav( + request.query_params, analysis, spectrogram, zoom, tile + ) + return HttpResponse( + f"Mode not implemented ({mode})", status=HTTPStatus.NOT_IMPLEMENTED + ) + + def get_from_png( + self, + analysis: SpectrogramAnalysis, + spectrogram: Spectrogram, + zoom=0, + tile=0, + ): + base_path = spectrogram.get_base_spectro_path(analysis) + if analysis.legacy: + f = spectrogram.filename + image_path = f"{base_path.split(f)[0]}{ f }_{ zoom + 1 }_{ tile }{ base_path.split(f)[1] }" + else: + if zoom != 0 or tile != 0: + return HttpResponse( + f"Cannot query other than 0 level for new OSEkit format ({zoom}-{tile} requested).", + status=HTTPStatus.BAD_REQUEST, + ) + else: + image_path = base_path + + local_path = path.join( + PureWindowsPath(settings.DATASET_IMPORT_FOLDER), + PureWindowsPath(image_path), + ) + if not path.exists(local_path): + return HttpResponse( + f"Image {local_path} not found.", status=HTTPStatus.NOT_FOUND + ) + + static_path = static( + path.join( + PureWindowsPath(settings.DATASET_EXPORT_PATH), + PureWindowsPath(image_path), + ) + ) + return HttpResponseRedirect(static_path) + + def get_from_wav( + self, + query_params: QueryDict, + analysis: SpectrogramAnalysis, + spectrogram: Spectrogram, + zoom=0, + tile=0, + ): + if analysis.legacy: + return HttpResponse( + f"Cannot query npz for old OSEkit format.", + status=HTTPStatus.BAD_REQUEST, + ) + + # Check matrix exists zoom_level = pow(2, zoom) - audio_data = audio_data.split(zoom_level)[tile] - - overlap = analysis.fft.overlap or 0.95 - hop = round(analysis.fft.window_size * (1 - overlap)) - spectro_data = SpectroData.from_audio_data( - data=audio_data, - fft=ShortTimeFFT( - win=hamming(analysis.fft.window_size), - hop=max(1, hop // zoom_level), # Improve temporal definition with zoom - fs=analysis.fft.sampling_frequency, - scale_to="magnitude", - ), - v_lim=(0.0, 150.0), # Boundaries of the spectrogram - # colormap="Greys", # This is the default value - colormap="viridis", # This is the default value + spectro_data: SpectroData = spectrogram.get_spectro_data_for(analysis) + spectro_data = spectro_data.split(zoom_level)[tile] + + colormap = query_params.get("colormap", None) + if colormap is not None: + spectro_data.colormap = colormap + + win_size = int(query_params.get("windowSize", len(spectro_data.fft.win))) + overlap = query_params.get("overlap", None) + mfft = int(query_params.get("nfft", spectro_data.fft.mfft)) + spectro_data.fft = ShortTimeFFT( + mfft=mfft, + win=hamming(win_size), + hop=round(win_size * (1 - float(overlap))) + if overlap + else spectro_data.fft.hop, + fs=spectro_data.fft.fs, + scale_to="magnitude", ) spectro_data.plot() diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index f9c24d97c..e49cefceb 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -1,4 +1,4 @@ -import React, { MouseEvent, UIEvent, useCallback, useEffect, useRef, WheelEvent } from 'react'; +import React, { MouseEvent, UIEvent, useCallback, useEffect, useRef, useState, WheelEvent } from 'react'; import styles from './styles.module.scss'; import { FrequencyAxis, TimeAxis } from '@/features/Annotator/Axis'; import { TimeBar } from './TimeBar'; @@ -23,6 +23,7 @@ import { selectColormap, selectContrast, selectIsColormapReversed, + selectSpectrogramMode, } from '@/features/Annotator/VisualConfiguration'; import { selectAnalysis } from '@/features/Annotator/Analysis'; import { SpectrogramDisplay } from '@/features/Spectrogram/Display'; @@ -44,6 +45,8 @@ export const AnnotatorCanvasWindow: React.FC = () => { const draw = useDrawCanvas() const dispatch = useAppDispatch() const pointer = usePointer() + const mode = useAppSelector(selectSpectrogramMode); + const [ left, setLeft ] = useState(0); const clearPointer = useCallback(() => { pointer.clearPosition() @@ -54,7 +57,8 @@ export const AnnotatorCanvasWindow: React.FC = () => { const div = event.currentTarget; const left = div.scrollWidth - div.scrollLeft - div.clientWidth; if (left <= 0) dispatch(setAllFileAsSeen()) - }, [dispatch]) + setLeft(div.scrollLeft) + }, [dispatch, setAllFileAsSeen, setLeft]) const onWheel = useCallback((event: WheelEvent) => { // Disable zoom if the user wants horizontal scroll @@ -167,8 +171,9 @@ export const AnnotatorCanvasWindow: React.FC = () => { { spectrogram && analysis && } + mode={ mode }/> } { + const mode = useAppSelector(selectSpectrogramMode); + const dispatch = useAppDispatch() + + const set = useCallback((mode?: string | number) => { + dispatch(setSpectrogramMode(mode as SpectrogramMode)) + }, [ dispatch ]) + + return + const allAnalysis = useAppSelector(selectAllAnalysis) + const analysis = useAppSelector(selectAnalysis) + const mode = useAppSelector(selectSpectrogramMode) + const fft = useAppSelector(selectFFT) + const dispatch = useAppDispatch() + + 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 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) dispatch(setAnalysis(analysis)) + }, [ allAnalysis, dispatch ]) + + + const [ nfft, setNfft ] = useState(fft?.nfft); + const [ windowSize, setWindowSize ] = useState(fft?.windowSize); + const [ overlap, setOverlap ] = useState(fft?.overlap); + + const updateFFT = useCallback(() => { + dispatch(setFFT({ + nfft: !nfft || isNaN(nfft) ? undefined : nfft, + windowSize: !windowSize || isNaN(windowSize) ? undefined : windowSize, + overlap: !overlap || isNaN(overlap) ? undefined : overlap, + })) + }, [ dispatch, nfft, windowSize, overlap ]) + + switch (mode) { + case 'png': + return setNfft(e.currentTarget.valueAsNumber) }/> + setWindowSize(e.currentTarget.valueAsNumber) }/> + setOverlap(e.currentTarget.valueAsNumber) }/> + + + } } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Analysis/selectors.ts b/frontend/src/features/Annotator/Analysis/selectors.ts index 24025a951..89a5c741b 100644 --- a/frontend/src/features/Annotator/Analysis/selectors.ts +++ b/frontend/src/features/Annotator/Analysis/selectors.ts @@ -7,6 +7,10 @@ export const selectAnalysisID = createSelector( selectAnnotator, AnnotatorAnalysisSlice.selectors.selectID, ) +export const selectFFT = createSelector( + selectAnnotator, AnnotatorAnalysisSlice.selectors.selectFFT, +) + export const selectAllAnalysis = createSelector( selectAnnotatorCampaign, diff --git a/frontend/src/features/Annotator/Analysis/slice.ts b/frontend/src/features/Annotator/Analysis/slice.ts index e2e329619..d86b77fe9 100644 --- a/frontend/src/features/Annotator/Analysis/slice.ts +++ b/frontend/src/features/Annotator/Analysis/slice.ts @@ -1,55 +1,62 @@ import { createSlice } from '@reduxjs/toolkit'; import type { GetCampaignQueryVariables } from '@/api'; import { ColormapNode, getCampaignFulfilled, type GetCampaignQuery, SpectrogramAnalysisNode } from '@/api'; +import type { FFT } from '@/features/Spectrogram/Display'; export type Analysis = Pick & { - colormap: Pick; + colormap: Pick; } | undefined type AnalysisState = { - id?: string; + id?: string; + fft?: Partial - _campaignID?: string; + _campaignID?: string; } export function getDefaultAnalysisID({ data, id }: { data: GetCampaignQuery, id?: string }) { - 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 minID = Math.min(...allAnalysis.map(a => +a!.id))?.toString(); - if (minID) return baseScaleAnalysis?.id ?? minID - return 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 minID = Math.min(...allAnalysis.map(a => +a!.id))?.toString(); + if (minID) return baseScaleAnalysis?.id ?? minID + return id } export const AnnotatorAnalysisSlice = createSlice({ - name: 'AnnotatorAnalysis', - initialState: { - _campaignID: undefined, - } as AnalysisState, - reducers: { - setAnalysis: (state, action: { payload: Analysis }) => { - state.id = action.payload?.id + name: 'AnnotatorAnalysis', + initialState: { + _campaignID: undefined, + } as AnalysisState, + reducers: { + setAnalysis: (state, action: { payload: Analysis }) => { + state.id = action.payload?.id + }, + setFFT: (state, action: { payload: Partial }) => { + state.fft = action.payload + }, + }, + extraReducers: builder => { + builder.addMatcher(getCampaignFulfilled, (state: AnalysisState, action: { + payload: GetCampaignQuery + meta: { arg: { originalArgs: GetCampaignQueryVariables } } + }) => { + if (state._campaignID !== action.payload.annotationCampaignById?.id) { + state._campaignID = action.payload.annotationCampaignById?.id + state.id = getDefaultAnalysisID({ data: action.payload }) + } else { + state.id = getDefaultAnalysisID({ data: action.payload, id: state.id }) + } + }) + }, + selectors: { + selectID: state => state.id, + selectFFT: state => state.fft, }, - }, - extraReducers: builder => { - builder.addMatcher(getCampaignFulfilled, (state: AnalysisState, action: { - payload: GetCampaignQuery - meta: { arg: { originalArgs: GetCampaignQueryVariables } } - }) => { - if (state._campaignID !== action.payload.annotationCampaignById?.id) { - state._campaignID = action.payload.annotationCampaignById?.id - state.id = getDefaultAnalysisID({ data: action.payload }) - } else { - state.id = getDefaultAnalysisID({ data: action.payload, id: state.id }) - } - }) - }, - selectors: { - selectID: state => state.id, - }, }) export const { - setAnalysis, + setAnalysis, + setFFT, } = AnnotatorAnalysisSlice.actions diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index 5936d2f54..40c89cb51 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -25,7 +25,7 @@ import { selectIsColormapReversed, selectSpectrogramMode, } from '@/features/Annotator/VisualConfiguration'; -import { selectAnalysis } from '@/features/Annotator/Analysis'; +import { selectAnalysis, selectFFT } from '@/features/Annotator/Analysis'; import { SpectrogramDisplay } from '@/features/Spectrogram/Display'; import { AcousticFeatures } from '@/features/Annotator/AcousticFeatures'; @@ -46,6 +46,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { const dispatch = useAppDispatch() const pointer = usePointer() const mode = useAppSelector(selectSpectrogramMode); + const fft = useAppSelector(selectFFT); const [ left, setLeft ] = useState(0); const clearPointer = useCallback(() => { @@ -58,7 +59,7 @@ export const AnnotatorCanvasWindow: React.FC = () => { const left = div.scrollWidth - div.scrollLeft - div.clientWidth; if (left <= 0) dispatch(setAllFileAsSeen()) setLeft(div.scrollLeft) - }, [dispatch, setAllFileAsSeen, setLeft]) + }, [ dispatch, setAllFileAsSeen, setLeft ]) const onWheel = useCallback((event: WheelEvent) => { // Disable zoom if the user wants horizontal scroll @@ -171,7 +172,8 @@ export const AnnotatorCanvasWindow: React.FC = () => { { spectrogram && analysis && } diff --git a/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx b/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx index e24e410c2..b5dd3c9c2 100644 --- a/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx +++ b/frontend/src/features/Spectrogram/Display/SpectrogramDisplay.tsx @@ -10,7 +10,7 @@ export const SpectrogramDisplay: React.FC<{ spectrogram: Pick, analysis: Pick & { fft: FFT }, mode: SpectrogramMode, - fft?: FFT, + fft?: Partial, }> = ({ zoom, spectrogram, analysis, mode, fft, left }) => { const canvasRef = useRef(null); const { width, height } = useSpectrogramDimensions(zoom) diff --git a/frontend/src/features/UX/Events/hook.ts b/frontend/src/features/UX/Events/hook.ts index f54672f37..df69df37c 100644 --- a/frontend/src/features/UX/Events/hook.ts +++ b/frontend/src/features/UX/Events/hook.ts @@ -2,83 +2,85 @@ import { useCallback, useEffect, useRef } from 'react'; import { Signal } from 'signal-ts'; import { useAppSelector } from '@/features/App'; import { - AUX_CLICK_EVENT, - CLICK_EVENT, - KEY_DOWN_EVENT, - MOUSE_DOWN_EVENT, - MOUSE_MOVE_EVENT, - MOUSE_UP_EVENT, - NON_FILTERED_KEY_DOWN_EVENT, + AUX_CLICK_EVENT, + CLICK_EVENT, + KEY_DOWN_EVENT, + MOUSE_DOWN_EVENT, + MOUSE_MOVE_EVENT, + MOUSE_UP_EVENT, + NON_FILTERED_KEY_DOWN_EVENT, } from './event'; import { selectAreKbdShortcutsEnabled } from '@/features/UX'; export const useLoadEventService = () => { - const areKbdShortcutsEnabled = useAppSelector(selectAreKbdShortcutsEnabled); - const areKbdShortcutsEnabledRef = useRef(areKbdShortcutsEnabled); + const areKbdShortcutsEnabled = useAppSelector(selectAreKbdShortcutsEnabled); + const areKbdShortcutsEnabledRef = useRef(areKbdShortcutsEnabled); - useEffect(() => { - document.addEventListener('keydown', onKeyDown.bind(this)); - document.addEventListener('mousedown', onMouseDown.bind(this)); - document.addEventListener('mousemove', onMouseMove.bind(this)); - document.addEventListener('mouseup', onMouseUp.bind(this)); - document.addEventListener('click', onClick.bind(this)); - document.addEventListener('auxclick', onAuxClick.bind(this)); + useEffect(() => { + document.addEventListener('keydown', onKeyDown.bind(this)); + document.addEventListener('mousedown', onMouseDown.bind(this)); + document.addEventListener('mousemove', onMouseMove.bind(this)); + document.addEventListener('mouseup', onMouseUp.bind(this)); + document.addEventListener('click', onClick.bind(this)); + document.addEventListener('auxclick', onAuxClick.bind(this)); - return () => { - document.removeEventListener('keydown', onKeyDown.bind(this)); - document.removeEventListener('mousedown', onMouseDown.bind(this)); - document.removeEventListener('mousemove', onMouseMove.bind(this)); - document.removeEventListener('mouseup', onMouseUp.bind(this)); - document.removeEventListener('click', onClick.bind(this)); - document.removeEventListener('auxclick', onAuxClick.bind(this)); - } - }, []); + return () => { + document.removeEventListener('keydown', onKeyDown.bind(this)); + document.removeEventListener('mousedown', onMouseDown.bind(this)); + document.removeEventListener('mousemove', onMouseMove.bind(this)); + document.removeEventListener('mouseup', onMouseUp.bind(this)); + document.removeEventListener('click', onClick.bind(this)); + document.removeEventListener('auxclick', onAuxClick.bind(this)); + } + }, []); - useEffect(() => { - areKbdShortcutsEnabledRef.current = areKbdShortcutsEnabled; - }, [ areKbdShortcutsEnabled ]); + useEffect(() => { + areKbdShortcutsEnabledRef.current = areKbdShortcutsEnabled; + }, [ areKbdShortcutsEnabled ]); - function onKeyDown(event: KeyboardEvent) { - NON_FILTERED_KEY_DOWN_EVENT.emit(event); - if (!areKbdShortcutsEnabledRef.current) return; - KEY_DOWN_EVENT.emit(event); - } + const onKeyDown = useCallback((event: KeyboardEvent) => { + NON_FILTERED_KEY_DOWN_EVENT.emit(event); + if (!areKbdShortcutsEnabledRef.current) return; + KEY_DOWN_EVENT.emit(event); + }, [ areKbdShortcutsEnabledRef ]) - function onMouseDown(event: MouseEvent) { - MOUSE_DOWN_EVENT.emit(event); - } + function onMouseDown(event: MouseEvent) { + MOUSE_DOWN_EVENT.emit(event); + } - function onMouseMove(event: MouseEvent) { - MOUSE_MOVE_EVENT.emit(event); - } + function onMouseMove(event: MouseEvent) { + MOUSE_MOVE_EVENT.emit(event); + } - function onMouseUp(event: MouseEvent) { - MOUSE_UP_EVENT.emit(event); - } + function onMouseUp(event: MouseEvent) { + MOUSE_UP_EVENT.emit(event); + } - function onClick(event: MouseEvent) { - CLICK_EVENT.emit(event); - } + function onClick(event: MouseEvent) { + CLICK_EVENT.emit(event); + } - function onAuxClick(event: MouseEvent) { - AUX_CLICK_EVENT.emit(event); - } + function onAuxClick(event: MouseEvent) { + AUX_CLICK_EVENT.emit(event); + } } export const useEvent = (signal: Signal, callback: (event: T) => void) => { - useEffect(() => { - signal.add(callback); - return () => { - signal.remove(callback); - } - }, [ callback ]); + useEffect(() => { + signal.add(callback); + return () => { + signal.remove(callback); + } + }, [ callback ]); } export const useKeyDownEvent = (keys: string[], callback: (event: KeyboardEvent) => void) => { - const onKbdEvent = useCallback((event: KeyboardEvent) => { - if (!keys.includes(event.key)) return - event.preventDefault(); - callback(event); - }, [ keys, callback ]) - useEvent(KEY_DOWN_EVENT, onKbdEvent) + const areKbdShortcutsEnabled = useAppSelector(selectAreKbdShortcutsEnabled); + const onKbdEvent = useCallback((event: KeyboardEvent) => { + if (!areKbdShortcutsEnabled) return; + if (!keys.includes(event.key)) return + event.preventDefault(); + callback(event); + }, [ keys, callback, areKbdShortcutsEnabled ]) + useEvent(KEY_DOWN_EVENT, onKbdEvent) } From 4770283badc561a141101b6790a68fdaf1f1508a Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Fri, 13 Feb 2026 08:35:50 +0100 Subject: [PATCH 07/13] clear canvas on options or zoom updated --- .../features/Spectrogram/Display/tile-manager.hook.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts b/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts index 2ed144f03..52d629f98 100644 --- a/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts +++ b/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts @@ -38,6 +38,12 @@ export const useTileManager = ({ canvasRef, options, zoom: _zoom, left: _left }: const [ getTile ] = SpectrogramRestAPI.endpoints.getTile.useLazyQuery() + const clearCanvas = useCallback((): void => { + const context = canvasRef.current?.getContext('2d', { alpha: false }); + if (!context || !canvasRef.current) return; + context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height) + }, [ canvasRef ]) + const displayTile = useCallback(async (objectURL: string, index: number): Promise => { const context = canvasRef.current?.getContext('2d', { alpha: false }); if (!context) return; @@ -134,10 +140,10 @@ export const useTileManager = ({ canvasRef, options, zoom: _zoom, left: _left }: loadingTileIndexesRef.current = []; loadedTileIndexesRef.current = []; queriesRef.current = []; + clearCanvas() update() - }, [ queriesRef, loadingTileIndexesRef, loadedTileIndexesRef, update ]) + }, [ queriesRef, loadingTileIndexesRef, loadedTileIndexesRef, update, clearCanvas ]) useEffect(() => { - console.log('options', options) let needInit = false; if (options.mode !== modeRef.current) { needInit = true @@ -169,6 +175,7 @@ export const useTileManager = ({ canvasRef, options, zoom: _zoom, left: _left }: loadedTileIndexesRef.current = [] zoomRef.current = Math.max(0, _zoom) queriesRef.current = []; + clearCanvas() update() }, [ _zoom ]); From f3a96e4ea3dd82d50b7839ca6e7d73613a0bfcf4 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Fri, 13 Feb 2026 08:41:15 +0100 Subject: [PATCH 08/13] update use of spectrogram dimensions --- frontend/src/features/Annotator/Axis/Axis.tsx | 17 ++++++----- frontend/src/features/Annotator/Axis/hooks.ts | 9 ++++-- .../src/features/Annotator/Canvas/TimeBar.tsx | 7 +++-- .../src/features/Annotator/Canvas/Window.tsx | 12 ++++---- .../Annotator/Canvas/axis-size.const.ts | 3 ++ .../src/features/Annotator/Canvas/hooks.ts | 20 ++++++------- .../src/features/Annotator/Canvas/index.ts | 2 +- .../features/Annotator/Canvas/window.hooks.ts | 28 ------------------- .../src/features/Annotator/Pointer/hooks.ts | 10 ++++--- .../Annotator/VisualConfiguration/hooks.ts | 7 +++-- .../Spectrogram/Display/dimension.hook.ts | 2 +- 11 files changed, 48 insertions(+), 69 deletions(-) create mode 100644 frontend/src/features/Annotator/Canvas/axis-size.const.ts delete mode 100644 frontend/src/features/Annotator/Canvas/window.hooks.ts diff --git a/frontend/src/features/Annotator/Axis/Axis.tsx b/frontend/src/features/Annotator/Axis/Axis.tsx index 6b86961d9..43926aa89 100644 --- a/frontend/src/features/Annotator/Axis/Axis.tsx +++ b/frontend/src/features/Annotator/Axis/Axis.tsx @@ -1,21 +1,19 @@ import React, { useMemo } from 'react'; import styles from './styles.module.scss'; -import { - useAnnotatorCanvasContext, - useWindowHeight, - useWindowWidth, - X_AXIS_HEIGHT, - Y_AXIS_WIDTH, -} from '@/features/Annotator/Canvas'; +import { useAnnotatorCanvasContext, X_AXIS_HEIGHT, Y_AXIS_WIDTH } from '@/features/Annotator/Canvas'; import { useAxis } from '@/components/ui'; import { formatTime, frequencyToString } from '@/service/function'; import { useFrequencyScale, useTimeScale } from './hooks' import { useAnnotationTask } from '@/api'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; +import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; export const TimeAxis: React.FC = () => { const { spectrogram } = useAnnotationTask() const timeScale = useTimeScale() - const width = useWindowWidth() + const zoom = useAppSelector(selectZoom) + const { width } = useSpectrogramDimensions(zoom) const { xAxisCanvasRef } = useAnnotatorCanvasContext() const timeStep = useMemo(() => { @@ -46,7 +44,8 @@ export const TimeAxis: React.FC = () => { export const FrequencyAxis: React.FC = () => { const frequencyScale = useFrequencyScale() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { height } = useSpectrogramDimensions(zoom) const { yAxisCanvasRef } = useAnnotatorCanvasContext() const steps = useMemo(() => frequencyScale.getSteps(), [ frequencyScale ]) useAxis({ diff --git a/frontend/src/features/Annotator/Axis/hooks.ts b/frontend/src/features/Annotator/Axis/hooks.ts index 1929105b9..c6086fba3 100644 --- a/frontend/src/features/Annotator/Axis/hooks.ts +++ b/frontend/src/features/Annotator/Axis/hooks.ts @@ -2,12 +2,14 @@ import { useMemo } from 'react'; import { LinearScaleService, MultiScaleService } from '@/components/ui'; import { useAnnotationTask } from '@/api'; import { selectAnalysis } from '@/features/Annotator/Analysis'; -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const useTimeScale = () => { const { spectrogram } = useAnnotationTask() - const width = useWindowWidth() + const zoom = useAppSelector(selectZoom) + const { width } = useSpectrogramDimensions(zoom) return useMemo(() => new LinearScaleService( width, @@ -21,7 +23,8 @@ export const useTimeScale = () => { export const useFrequencyScale = () => { const analysis = useAppSelector(selectAnalysis) - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { height } = useSpectrogramDimensions(zoom) return useMemo(() => { const options = { diff --git a/frontend/src/features/Annotator/Canvas/TimeBar.tsx b/frontend/src/features/Annotator/Canvas/TimeBar.tsx index 314ad2ce5..e76c60d88 100644 --- a/frontend/src/features/Annotator/Canvas/TimeBar.tsx +++ b/frontend/src/features/Annotator/Canvas/TimeBar.tsx @@ -1,11 +1,14 @@ import React, { Fragment } from 'react'; import styles from './styles.module.scss'; import { useAudio } from '@/features/Audio'; -import { useWindowWidth } from './window.hooks'; +import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const TimeBar: React.FC = () => { const audio = useAudio(); - const width = useWindowWidth() + const zoom = useAppSelector(selectZoom) + const { width } = useSpectrogramDimensions(zoom) if (!audio.source || !audio.duration) return return ( diff --git a/frontend/src/features/Annotator/Canvas/Window.tsx b/frontend/src/features/Annotator/Canvas/Window.tsx index 40c89cb51..fe4c4e377 100644 --- a/frontend/src/features/Annotator/Canvas/Window.tsx +++ b/frontend/src/features/Annotator/Canvas/Window.tsx @@ -8,8 +8,8 @@ import { StrongAnnotation, useTempAnnotationsEvents, } from '@/features/Annotator/Annotation'; -import { useWindowContainerWidth, useWindowHeight, useWindowWidth, Y_AXIS_WIDTH } from './window.hooks'; -import { useGetCoords, useGetFreqTime, useIsHoverCanvas, usePointer } from '@/features/Annotator/Pointer'; +import { Y_AXIS_WIDTH } from './axis-size.const'; +import { usePointer, useGetCoords, useGetFreqTime, useIsHoverCanvas } from '@/features/Annotator/Pointer'; import { selectZoom, selectZoomOrigin, useZoomIn, useZoomOut } from '@/features/Annotator/Zoom'; import { selectCanDraw } from '@/features/Annotator/UX'; import { useAudio } from '@/features/Audio'; @@ -28,11 +28,12 @@ import { import { selectAnalysis, selectFFT } from '@/features/Annotator/Analysis'; import { SpectrogramDisplay } from '@/features/Spectrogram/Display'; import { AcousticFeatures } from '@/features/Annotator/AcousticFeatures'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const AnnotatorCanvasWindow: React.FC = () => { - const width = useWindowWidth() - const height = useWindowHeight() - const containerWidth = useWindowContainerWidth() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) + const { width: containerWidth } = useSpectrogramDimensions(0) const { mainCanvasRef, windowCanvasRef } = useAnnotatorCanvasContext() const { onStartTempAnnotation } = useTempAnnotationsEvents() const getFreqTime = useGetFreqTime() @@ -117,7 +118,6 @@ export const AnnotatorCanvasWindow: React.FC = () => { // Zoom update - const zoom = useAppSelector(selectZoom) const zoomOrigin = useAppSelector(selectZoomOrigin) const oldZoom = useRef(1) const isHoverCanvas = useIsHoverCanvas() diff --git a/frontend/src/features/Annotator/Canvas/axis-size.const.ts b/frontend/src/features/Annotator/Canvas/axis-size.const.ts new file mode 100644 index 000000000..c05aa1154 --- /dev/null +++ b/frontend/src/features/Annotator/Canvas/axis-size.const.ts @@ -0,0 +1,3 @@ +export const Y_AXIS_WIDTH: number = 35; +export const X_AXIS_HEIGHT: number = 30; + diff --git a/frontend/src/features/Annotator/Canvas/hooks.ts b/frontend/src/features/Annotator/Canvas/hooks.ts index 3930d63c3..76f8c7ded 100644 --- a/frontend/src/features/Annotator/Canvas/hooks.ts +++ b/frontend/src/features/Annotator/Canvas/hooks.ts @@ -3,19 +3,15 @@ import { useCallback } from 'react'; import { selectZoom } from '@/features/Annotator/Zoom'; import { useApplyColormap, useApplyFilter } from '@/features/Annotator/VisualConfiguration'; import { useDrawTempAnnotation } from '@/features/Annotator/Annotation'; -import { - useWindowContainerWidth, - useWindowHeight, - useWindowWidth, - Y_AXIS_WIDTH, -} from '@/features/Annotator/Canvas/window.hooks'; +import { Y_AXIS_WIDTH } from '@/features/Annotator/Canvas/axis-size.const'; import { useTimeScale } from '@/features/Annotator/Axis'; import { useAppSelector } from '@/features/App'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const useFocusCanvasOnTime = () => { const timeScale = useTimeScale() - const containerWidth = useWindowContainerWidth() + const { width: containerWidth } = useSpectrogramDimensions(0) const { mainCanvasRef, } = useAnnotatorCanvasContext() @@ -23,12 +19,12 @@ export const useFocusCanvasOnTime = () => { return useCallback((time: number) => { const left = timeScale.valueToPosition(time) - containerWidth / 2; mainCanvasRef?.current?.parentElement?.scrollTo({ left }) - }, [ timeScale, containerWidth ]) + }, [ timeScale, containerWidth, mainCanvasRef ]) } export const useDrawCanvas = () => { - const width = useWindowWidth() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) const drawTempAnnotation = useDrawTempAnnotation() const applyFilter = useApplyFilter() @@ -46,12 +42,12 @@ export const useDrawCanvas = () => { // applyFilter(context) // applyColormap(context) drawTempAnnotation(context) - }, [ width, height, applyFilter, applyColormap, drawTempAnnotation ]); + }, [ width, height, applyFilter, applyColormap, drawTempAnnotation, mainCanvasRef ]); } export const useDownloadCanvas = () => { - const height = useWindowHeight() const zoom = useAppSelector(selectZoom) + const { height } = useSpectrogramDimensions(zoom) const draw = useDrawCanvas() diff --git a/frontend/src/features/Annotator/Canvas/index.ts b/frontend/src/features/Annotator/Canvas/index.ts index f9e9d8280..b0cf7a829 100644 --- a/frontend/src/features/Annotator/Canvas/index.ts +++ b/frontend/src/features/Annotator/Canvas/index.ts @@ -1,6 +1,6 @@ export { AnnotatorCanvasContextProvider, useAnnotatorCanvasContext } from './context' export * from './hooks' -export * from './window.hooks' +export * from './axis-size.const' export * from './Window' diff --git a/frontend/src/features/Annotator/Canvas/window.hooks.ts b/frontend/src/features/Annotator/Canvas/window.hooks.ts deleted file mode 100644 index 6c7df69c2..000000000 --- a/frontend/src/features/Annotator/Canvas/window.hooks.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { useAppSelector } from '@/features/App'; -import { selectDisplayZoom } from '@/features/Annotator/Zoom'; -import { useMemo } from 'react'; -import { useWindowRatio } from '@/components/ui'; - -const SPECTRO_HEIGHT: number = 512; -const SPECTRO_WIDTH: number = 1813; -export const Y_AXIS_WIDTH: number = 35; -export const X_AXIS_HEIGHT: number = 30; - - -export const useWindowContainerWidth = () => { - const ratio = useWindowRatio() - return useMemo(() => SPECTRO_WIDTH / ratio, [ ratio ]) -} - -export const useWindowWidth = () => { - const zoom = useAppSelector(selectDisplayZoom) - const containerWidth = useWindowContainerWidth() - - return useMemo(() => containerWidth * zoom, [ containerWidth, zoom ]) -} - -export const useWindowHeight = () => { - const ratio = useWindowRatio() - - return useMemo(() => SPECTRO_HEIGHT / ratio, [ ratio ]) -} diff --git a/frontend/src/features/Annotator/Pointer/hooks.ts b/frontend/src/features/Annotator/Pointer/hooks.ts index fbeccf9b5..2f8725e57 100644 --- a/frontend/src/features/Annotator/Pointer/hooks.ts +++ b/frontend/src/features/Annotator/Pointer/hooks.ts @@ -1,13 +1,15 @@ import { useCallback } from 'react'; -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; import { useFrequencyScale, useTimeScale } from '@/features/Annotator/Axis'; import { type Position, type TimeFreqPosition } from './context'; import { useAnnotatorCanvasContext } from '@/features/Annotator/Canvas/context'; import type { AnnotationNode } from '@/api'; +import { useAppSelector } from '@/features/App'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const useIsHoverCanvas = () => { - const width = useWindowWidth() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) return useCallback((e: Position): boolean => { return document.elementsFromPoint(e.clientX, e.clientY).some((element: Element): boolean => { @@ -33,7 +35,7 @@ export const useGetCoords = () => { y: Math.min(Math.max(0, y), bounds.height), } } else return { x, y } - }, []) + }, [mainCanvasRef]) } export const useGetFreqTime = () => { diff --git a/frontend/src/features/Annotator/VisualConfiguration/hooks.ts b/frontend/src/features/Annotator/VisualConfiguration/hooks.ts index 8e1429846..d72f3d6d9 100644 --- a/frontend/src/features/Annotator/VisualConfiguration/hooks.ts +++ b/frontend/src/features/Annotator/VisualConfiguration/hooks.ts @@ -2,7 +2,8 @@ import { useCallback } from 'react'; import { useAppSelector } from '@/features/App'; import { selectBrightness, selectColormap, selectContrast, selectIsColormapReversed } from './selectors' import { COLORMAPS, createColormap } from './colormaps' -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; +import { selectZoom } from '@/features/Annotator/Zoom'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; function interpolate(value: number, minSource: number, maxSource: number, minTarget: number, maxTarget: number): number { @@ -25,8 +26,8 @@ export const useApplyFilter = () => { export const useApplyColormap = () => { const colormap = useAppSelector(selectColormap); const isColormapReversed = useAppSelector(selectIsColormapReversed); - const width = useWindowWidth() - const height = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width, height } = useSpectrogramDimensions(zoom) return useCallback((context: CanvasRenderingContext2D) => { if (!colormap) return; diff --git a/frontend/src/features/Spectrogram/Display/dimension.hook.ts b/frontend/src/features/Spectrogram/Display/dimension.hook.ts index ef5a9ce08..c7a628650 100644 --- a/frontend/src/features/Spectrogram/Display/dimension.hook.ts +++ b/frontend/src/features/Spectrogram/Display/dimension.hook.ts @@ -9,7 +9,7 @@ export const SpectrogramDimensions = { export const useSpectrogramDimensions = (zoomLevel: number = 0) => { const ratio = useWindowRatio() const width = useMemo(() => { - return (SpectrogramDimensions.width / ratio) * (zoomLevel + 1) + return (SpectrogramDimensions.width / ratio) * Math.pow(2, zoomLevel) }, [ ratio, zoomLevel ]) const height = useMemo(() => { return SpectrogramDimensions.height / ratio From 10739afdc9156018a1999bee19d13a23470bd8b6 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Fri, 13 Feb 2026 08:43:47 +0100 Subject: [PATCH 09/13] fix ref use in useAxis --- frontend/src/components/ui/Scale/hooks.ts | 30 +++++++++---------- frontend/src/features/Annotator/Axis/Axis.tsx | 4 +-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/frontend/src/components/ui/Scale/hooks.ts b/frontend/src/components/ui/Scale/hooks.ts index e16fff8bb..505ebb0e2 100644 --- a/frontend/src/components/ui/Scale/hooks.ts +++ b/frontend/src/components/ui/Scale/hooks.ts @@ -1,8 +1,8 @@ import { Step } from './types'; -import { useCallback, useEffect } from 'react'; +import { type MutableRefObject, useCallback, useEffect } from 'react'; -export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue }: { - canvas?: HTMLCanvasElement | null, +export const useAxis = ({ canvasRef, steps, orientation, pixelSize, valueToString, displaySmallStepValue }: { + canvasRef?: MutableRefObject, steps: Step[], orientation: 'horizontal' | 'vertical', pixelSize: number, @@ -10,15 +10,11 @@ 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; + const context = canvasRef?.current?.getContext('2d'); + if (!canvasRef?.current || !context || !pixelSize) return; - context.clearRect(0, 0, canvas.width, canvas.height); + context.clearRect(0, 0, canvasRef.current.width, canvasRef.current.height); context.fillStyle = 'rgba(0, 0, 0)'; context.font = '500 10px \'Exo 2\''; @@ -46,10 +42,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, let position = step.position; switch (orientation) { case 'horizontal': - position = canvas.width - position; + position = canvasRef.current.width - position; break; case 'vertical': - position = canvas.height - position + position = canvasRef.current.height - position break; } @@ -72,10 +68,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, switch (orientation) { case 'vertical': - context.fillRect(canvas.width - tickLength, tickPosition, tickLength, tickWidth); + context.fillRect(canvasRef.current.width - tickLength, tickPosition, tickLength, tickWidth); break; case 'horizontal': - context.fillRect(tickPosition <= canvas.width - 2 ? tickPosition : canvas.width - 2, 0, tickWidth, tickLength); + context.fillRect(tickPosition <= canvasRef.current.width - 2 ? tickPosition : canvasRef.current.width - 2, 0, tickWidth, tickLength); break; } @@ -129,6 +125,10 @@ export const useAxis = ({ canvas, steps, orientation, pixelSize, valueToString, } } } - }, [ canvas, steps, orientation, pixelSize, valueToString, displaySmallStepValue ]) + }, [ canvasRef, steps, orientation, pixelSize, valueToString, displaySmallStepValue ]) + + useEffect(() => { + draw() + }, [ canvasRef, pixelSize, valueToString, orientation, steps ]); } \ No newline at end of file diff --git a/frontend/src/features/Annotator/Axis/Axis.tsx b/frontend/src/features/Annotator/Axis/Axis.tsx index 43926aa89..6054ba8a1 100644 --- a/frontend/src/features/Annotator/Axis/Axis.tsx +++ b/frontend/src/features/Annotator/Axis/Axis.tsx @@ -28,7 +28,7 @@ export const TimeAxis: React.FC = () => { return timeScale.getSteps(timeStep.regularStep, timeStep.smallStep) }, [ timeScale, timeStep ]) useAxis({ - canvas: xAxisCanvasRef?.current, + canvasRef: xAxisCanvasRef, pixelSize: width, orientation: 'horizontal', valueToString: formatTime, @@ -49,7 +49,7 @@ export const FrequencyAxis: React.FC = () => { const { yAxisCanvasRef } = useAnnotatorCanvasContext() const steps = useMemo(() => frequencyScale.getSteps(), [ frequencyScale ]) useAxis({ - canvas: yAxisCanvasRef?.current, + canvasRef: yAxisCanvasRef, pixelSize: height, orientation: 'vertical', valueToString: frequencyToString, From 40b36ea665c90b9432b00fcafd7bfd772a257ddb Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Thu, 26 Feb 2026 10:51:43 +0100 Subject: [PATCH 10/13] remove .npz generation option --- backend/api/views/zoom.py | 7 ++++--- .../VisualConfiguration/SpectrogramModeSelect.tsx | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py index 16857cb95..8cc62652e 100644 --- a/backend/api/views/zoom.py +++ b/backend/api/views/zoom.py @@ -72,7 +72,8 @@ def get_from_png( image_path = base_path local_path = path.join( - PureWindowsPath(settings.DATASET_IMPORT_FOLDER), + PureWindowsPath(settings.VOLUMES_ROOT), + PureWindowsPath(settings.DATASET_EXPORT_PATH), PureWindowsPath(image_path), ) if not path.exists(local_path): @@ -96,14 +97,14 @@ def get_from_wav( zoom=0, tile=0, ): + zoom_level = pow(2, zoom) if analysis.legacy: return HttpResponse( - f"Cannot query npz for old OSEkit format.", + f"Cannot query wav for old OSEkit format.", status=HTTPStatus.BAD_REQUEST, ) # Check matrix exists - zoom_level = pow(2, zoom) spectro_data: SpectroData = spectrogram.get_spectro_data_for(analysis) spectro_data = spectro_data.split(zoom_level)[tile] diff --git a/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx b/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx index c0604e1c3..95f6c9574 100644 --- a/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx +++ b/frontend/src/features/Annotator/VisualConfiguration/SpectrogramModeSelect.tsx @@ -18,7 +18,7 @@ export const SpectrogramModeSelect: React.FC = () => { options={ ([ { value: 'png', label: 'From png' }, { value: 'wav', label: 'From wav' }, - { value: 'npz', label: 'From npz' }, + // { value: 'npz', label: 'From npz' }, ]) } optionsContainer="popover" value={ mode } From c56e7c9eadb13f9b94c533d4162b4e4d8c5986cb Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Thu, 26 Feb 2026 11:06:30 +0100 Subject: [PATCH 11/13] FIX rebase --- .../Annotator/AcousticFeatures/AcousticFeatures.tsx | 4 ++-- .../src/features/Annotator/Annotation/StrongAnnotation.tsx | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx b/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx index 151351dfa..f7816f2d3 100644 --- a/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx +++ b/frontend/src/features/Annotator/AcousticFeatures/AcousticFeatures.tsx @@ -14,7 +14,7 @@ import { Trend } from './Trend'; import { Duration } from '@/features/Annotator/AcousticFeatures/Duration'; import { NonLinearPhenomena } from '@/features/Annotator/AcousticFeatures/NonLinearPhenomena'; import { Checks } from '@/features/Annotator/AcousticFeatures/Checks'; -import { useWindowWidth } from '@/features/Annotator/Canvas'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; export const AcousticFeatures: React.FC = () => { const focusedAnnotation = useAppSelector(selectAnnotation) @@ -33,7 +33,7 @@ export const AcousticFeatures: React.FC = () => { }, [ getAnnotation, focusedAnnotation, dispatch ]) const divRef = useRef(null) - const windowWidth = useWindowWidth() + const { width: windowWidth } = useSpectrogramDimensions(0) const initialPosition = useMemo(() => { const initialLeft = window.innerWidth - 500 const position: ExtendedDivPosition ={ diff --git a/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx b/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx index d0772ebc8..82a1a4da6 100644 --- a/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx +++ b/frontend/src/features/Annotator/Annotation/StrongAnnotation.tsx @@ -16,7 +16,8 @@ import { formatTime } from '@/service/function'; import { useUpdateAnnotation } from '@/features/Annotator/Annotation/hooks'; import { AnnotationHeadContent } from '@/features/Annotator/Annotation/Head'; import { MOUSE_DOWN_EVENT } from '@/features/UX'; -import { useWindowHeight, useWindowWidth } from '@/features/Annotator/Canvas'; +import { useSpectrogramDimensions } from '@/features/Spectrogram/Display/dimension.hook'; +import { selectZoom } from '@/features/Annotator/Zoom'; const POINT_RADIUS = 16; // px @@ -25,8 +26,8 @@ export const StrongAnnotation: React.FC<{ }> = ({ annotation }) => { const dispatch = useAppDispatch(); const { spectrogram } = useAnnotationTask() - const windowWidth = useWindowWidth() - const windowHeight = useWindowHeight() + const zoom = useAppSelector(selectZoom) + const { width: windowWidth, height: windowHeight } = useSpectrogramDimensions(zoom) const isEditionAuthorized = useAppSelector(selectTaskIsEditionAuthorized) const isDrawingEnabled = useAppSelector(selectIsDrawingEnabled) From cb2ccb5425b5096ef4ec061f11c362caa58f1aa5 Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Thu, 26 Feb 2026 11:17:26 +0100 Subject: [PATCH 12/13] Add legacy for from wav and fix legacy png --- backend/api/views/zoom.py | 45 +++++++++++-------- .../Spectrogram/Display/tile-manager.hook.ts | 2 +- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py index 8cc62652e..ee47b9743 100644 --- a/backend/api/views/zoom.py +++ b/backend/api/views/zoom.py @@ -8,7 +8,9 @@ from django.http import HttpResponse, HttpResponseRedirect, QueryDict from django.shortcuts import get_object_or_404 from django.templatetags.static import static +from osekit.core_api.audio_file import AudioFile from osekit.core_api.spectro_data import SpectroData +from pandas import Timestamp from rest_framework.decorators import action from rest_framework.request import Request from rest_framework.viewsets import ViewSet @@ -16,6 +18,7 @@ from scipy.signal.windows import hamming from backend.api.models import Spectrogram, SpectrogramAnalysis +from backend.utils.osekit_replace import AudioData class ZoomViewSet(ViewSet): @@ -98,33 +101,39 @@ def get_from_wav( tile=0, ): zoom_level = pow(2, zoom) + win_size = int(query_params.get("windowSize", analysis.fft.window_size)) + overlap = query_params.get("overlap", analysis.fft.overlap) + mfft = int(query_params.get("nfft", analysis.fft.nfft)) + fft = ShortTimeFFT( + mfft=mfft, + win=hamming(win_size), + hop=round(win_size * (1 - float(overlap))), + fs=analysis.fft.sampling_frequency, + scale_to="magnitude", + ) + if analysis.legacy: - return HttpResponse( - f"Cannot query wav for old OSEkit format.", - status=HTTPStatus.BAD_REQUEST, + audio_file = AudioFile( + path=spectrogram.get_audio_path(analysis), + begin=Timestamp(str(spectrogram.start)), + ) + audio_data = AudioData.from_files([audio_file]) + spectro_data = SpectroData.from_audio_data( + data=audio_data, + fft=fft, + colormap="viridis", # This is the default value ) + else: + # Check matrix exists + spectro_data: SpectroData = spectrogram.get_spectro_data_for(analysis) + spectro_data.fft = fft - # Check matrix exists - spectro_data: SpectroData = spectrogram.get_spectro_data_for(analysis) spectro_data = spectro_data.split(zoom_level)[tile] colormap = query_params.get("colormap", None) if colormap is not None: spectro_data.colormap = colormap - win_size = int(query_params.get("windowSize", len(spectro_data.fft.win))) - overlap = query_params.get("overlap", None) - mfft = int(query_params.get("nfft", spectro_data.fft.mfft)) - spectro_data.fft = ShortTimeFFT( - mfft=mfft, - win=hamming(win_size), - hop=round(win_size * (1 - float(overlap))) - if overlap - else spectro_data.fft.hop, - fs=spectro_data.fft.fs, - scale_to="magnitude", - ) - spectro_data.plot() # Get the (plotted) image into memory file diff --git a/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts b/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts index 52d629f98..e7544cd88 100644 --- a/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts +++ b/frontend/src/features/Spectrogram/Display/tile-manager.hook.ts @@ -72,7 +72,7 @@ export const useTileManager = ({ canvasRef, options, zoom: _zoom, left: _left }: const p = spectrogramRef.current.path const f = spectrogramRef.current.filename query = getTile({ - url: `${ p.split(f)[0] }${ f }_${ zoomRef.current }_${ index }${ p.split(f)[1] }`, + url: `${ p.split(f)[0] }${ f }_${ zoomRef.current + 1 }_${ index }${ p.split(f)[1] }`, }) } else { query = getTile({ From f3083e212f89ef432c4d45ccb866fab4a67a407d Mon Sep 17 00:00:00 2001 From: Elodie MORIN Date: Thu, 26 Feb 2026 11:53:02 +0100 Subject: [PATCH 13/13] Remove OSEkit replace --- .../api/models/data/spectrogram_analysis.py | 4 +- backend/api/views/zoom.py | 2 +- backend/storage/schema/resolver/analysis.py | 4 +- backend/storage/schema/resolver/dataset.py | 5 +- backend/utils/osekit_replace.py | 142 ------------------ .../src/components/ui/Modal/modal.hook.tsx | 3 - 6 files changed, 5 insertions(+), 155 deletions(-) delete mode 100644 backend/utils/osekit_replace.py diff --git a/backend/api/models/data/spectrogram_analysis.py b/backend/api/models/data/spectrogram_analysis.py index bc12f9f0f..ae5b61a9c 100644 --- a/backend/api/models/data/spectrogram_analysis.py +++ b/backend/api/models/data/spectrogram_analysis.py @@ -14,8 +14,8 @@ from backend.aplose.models import User -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset +from osekit.core_api.spectro_dataset import SpectroDataset + from .__abstract_analysis import AbstractAnalysis from .colormap import Colormap from .dataset import Dataset diff --git a/backend/api/views/zoom.py b/backend/api/views/zoom.py index ee47b9743..9a3b6dedb 100644 --- a/backend/api/views/zoom.py +++ b/backend/api/views/zoom.py @@ -9,6 +9,7 @@ from django.shortcuts import get_object_or_404 from django.templatetags.static import static from osekit.core_api.audio_file import AudioFile +from osekit.core_api.audio_data import AudioData from osekit.core_api.spectro_data import SpectroData from pandas import Timestamp from rest_framework.decorators import action @@ -18,7 +19,6 @@ from scipy.signal.windows import hamming from backend.api.models import Spectrogram, SpectrogramAnalysis -from backend.utils.osekit_replace import AudioData class ZoomViewSet(ViewSet): diff --git a/backend/storage/schema/resolver/analysis.py b/backend/storage/schema/resolver/analysis.py index b1cfa1e27..7ec71917c 100644 --- a/backend/storage/schema/resolver/analysis.py +++ b/backend/storage/schema/resolver/analysis.py @@ -1,11 +1,9 @@ from typing import Optional from django.conf import settings +from osekit.core_api.spectro_dataset import SpectroDataset from backend.api.models import SpectrogramAnalysis as AnalysisModel - -# from osekit.core_api.spectro_dataset import SpectroDataset -from backend.utils.osekit_replace import SpectroDataset from .base_resolver import BaseResolver from .exceptions import AnalysisBrowseException from .types import ImportStatus diff --git a/backend/storage/schema/resolver/dataset.py b/backend/storage/schema/resolver/dataset.py index c73815d85..aadf846d5 100644 --- a/backend/storage/schema/resolver/dataset.py +++ b/backend/storage/schema/resolver/dataset.py @@ -5,10 +5,7 @@ from backend.api.models.data.dataset import Dataset as DatasetModel -# from osekit.public_api.dataset import ( -# SpectroDataset, -# ) -from backend.utils.osekit_replace import SpectroDataset, OSEkitDataset +from osekit.public_api.dataset import SpectroDataset, Dataset as OSEkitDataset from .analysis import Analysis from .base_resolver import BaseResolver from .types import LegacyCSVDataset, ImportStatus diff --git a/backend/utils/osekit_replace.py b/backend/utils/osekit_replace.py deleted file mode 100644 index d1dbd26b7..000000000 --- a/backend/utils/osekit_replace.py +++ /dev/null @@ -1,142 +0,0 @@ -import json -from os.path import join -from pathlib import PureWindowsPath, Path - -import numpy as np -from pandas import Timestamp, Timedelta -from scipy.signal import ShortTimeFFT - - -class TFile: - begin: Timestamp - end: Timestamp - path: str - - def __init__(self, d: dict, dataset_path): - self.begin = d["begin"] - self.end = d["end"] - self.path = join( - dataset_path, - PureWindowsPath(d["path"]) - .as_posix() - .split(PureWindowsPath(dataset_path).stem) - .pop() - .strip("/"), - ) - - -class AudioData: - dataset_path: Path - - files: list[TFile] - - def __init__(self, d: dict, dataset_path): - self.dataset_path = dataset_path - self.audio_data = [TFile(d, dataset_path) for name, d in d["files"].items()] - - -class SpectroData: - dataset_path: Path - name: str - v_lim: list[int] # TODO - begin: Timestamp - end: Timestamp - duration: Timedelta - audio_data: AudioData - - def __init__(self, d: dict, name, dataset_path): - self.dataset_path = dataset_path - self.name = name - self.begin = Timestamp(d["begin"]) - self.end = Timestamp(d["end"]) - self.v_lim = d["v_lim"] - self.duration = self.end - self.begin - self.audio_data = AudioData(d["audio_data"], dataset_path) - - -class SpectroDataset: - folder: Path - name: str - data: list[SpectroData] = [] - - def __init__(self, d: dict, path): - self.d = d - self.folder = path - self.name = d["name"] - - @property - def fft(self) -> ShortTimeFFT: - sft = list(self.d["sft"].values())[0] - return ShortTimeFFT( - win=np.array(sft["win"]), - hop=sft["hop"], - fs=sft["fs"], - mfft=sft["mfft"], - ) - - @property - def colormap(self) -> str | None: - return list(self.d["data"].values())[0]["colormap"] - - @property - def begin(self) -> Timestamp: - """Begin of the first data object.""" - return min(data.begin for data in self.data) - - @property - def end(self) -> Timestamp: - """End of the last data object.""" - return max(data.end for data in self.data) - - @property - def data_duration(self) -> Timedelta: - data_durations = [ - Timedelta(data.duration).round(freq="1s") for data in self.data - ] - return max(set(data_durations), key=data_durations.count) - - def load_data(self): - self.data = [ - SpectroData(d, name, self.folder) for name, d in self.d["data"].items() - ] - - @staticmethod - def from_json(json_path: Path) -> "SpectroDataset": - with json_path.open("r") as f: - return SpectroDataset( - json.loads(f.read()), - PureWindowsPath(json_path).as_posix()[ - : -len(f"/{json_path.stem}{json_path.suffix}") - ], - ) - - -class OSEkitDataset: - datasets: dict - - def __init__(self, d: dict, path): - self.datasets = {} - for name, dataset in d["datasets"].items(): - analysis_json_path = join( - path, - PureWindowsPath(dataset["json"]) - .as_posix() - .split(PureWindowsPath(path).stem) - .pop() - .strip("/"), - ) - self.datasets[name] = { - "class": dataset["class"], - "analysis": dataset["analysis"], - "dataset": SpectroDataset.from_json( - Path(PureWindowsPath(analysis_json_path).as_posix()) - ), - } - - @staticmethod - def from_json(json_path: Path) -> "OSEkitDataset": - with json_path.open("r") as f: - return OSEkitDataset( - json.loads(f.read()), - PureWindowsPath(json_path).as_posix()[: -len("/dataset.json")], - ) diff --git a/frontend/src/components/ui/Modal/modal.hook.tsx b/frontend/src/components/ui/Modal/modal.hook.tsx index 59bf5a829..f1b0dd600 100644 --- a/frontend/src/components/ui/Modal/modal.hook.tsx +++ b/frontend/src/components/ui/Modal/modal.hook.tsx @@ -6,15 +6,12 @@ export type ModalProps = { onClose: () => void }; export const useModal = (component: React.FC, extraArgs?: object) => { const [ isOpen, setIsOpen ] = useState(false); const toggle = useCallback(() => { - console.log('toggle', component.name) setIsOpen(prev => !prev) }, [ setIsOpen, component ]) const open = useCallback(() => { - console.log('open', component.name) setIsOpen(true) }, [ setIsOpen , component]) const close = useCallback(() => { - console.log('close', component.name) setIsOpen(false) }, [ setIsOpen, component ])