This commit is contained in:
Oleksandr Bezdieniezhnykh
2025-09-29 14:40:43 +03:00
25 changed files with 417 additions and 158 deletions
@@ -1,61 +1,47 @@
.controls {
margin-top: 4px;
}
.input-group {
display: flex;
flex-direction: row;
padding: 0 4px;
background: #222531;
padding: 0 20px;
border-radius: 4px;
}
.time {
color: #fff;
}
.video-slider {
width: 100%;
margin: 12px 20px;
margin: 12px 26px;
}
.MuiSlider-root {
color: #fff !important;
}
.buttons-group {
display: flex;
justify-content: center;
position: relative;
z-index: 1;
padding: 10px;
background: #f5f5f5;
border-radius: 4px;
margin-top: 10px;
flex-direction: row;
gap: 10px;
margin-top: 6px;
}
.control-btn {
width: 40px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
gap: 4px;
padding: 10px 20px;
font-size: 16px;
margin: 0 5px;
border: 1px solid #ccc;
background: #222531;
padding: 4px;
border: 0;
border-radius: 4px;
cursor: pointer;
}
.play-btn {
background: #8aff8a;
}
.pause-btn {
background: #ff8a8a;
}
.arrow-btn {
background: #e0e0e0;
}
.stop-btn {
background: #e8a1a5;
}
.save-btn {
background: #8ad4ff;
}
.delete-btn {
background: #ffb38a;
}
.clean-btn{
background: #ff8a8a;
.control-btn:hover {
background: #535b77;
}
@@ -1,7 +1,13 @@
import { Slider } from '@mui/material';
import { FaStop } from "react-icons/fa";
import { MdCleaningServices } from "react-icons/md";
import './AnnotationControls.css';
import PreviousIcon from '../../icons/PreviousIcon';
import PlayIcon from '../../icons/PlayIcon';
import PauseIcon from '../../icons/PauseIcon';
import NextIcon from '../../icons/NextIcon';
import StopIcon from '../../icons/StopIcon';
import SaveIcon from '../../icons/SaveIcon';
import CleanIcon from '../../icons/CleanIcon';
import DeleteIcon from '../../icons/DeleteIcon';
function AnnotationControls({
videoRef,
@@ -32,7 +38,7 @@ function AnnotationControls({
return (
<div className='controls'>
<div className='input-group'>
<p>{formatDuration(currentTime)}</p>
<p className='time'>{formatDuration(currentTime)}</p>
<Slider
aria-label='time-indicator'
value={currentTime}
@@ -43,8 +49,8 @@ function AnnotationControls({
className='video-slider'
/>
{videoRef.current !== null
? <p>{formatDuration(videoRef.current.duration - currentTime)}</p>
: <p>{formatDuration(0)}</p>
? <p className='time'>{formatDuration(videoRef.current.duration - currentTime)}</p>
: <p className='time'>{formatDuration(0)}</p>
}
</div>
@@ -55,46 +61,48 @@ function AnnotationControls({
onClick={onFrameBackward}
title="Previous Frame"
>
<PreviousIcon />
</button>
<button
className={isPlaying ? 'control-btn pause-btn' : 'control-btn play-btn'}
onClick={onPlayPause}
>
{isPlaying ? '⏸️ Pause' : '▶️ Play'}
{isPlaying ? <PauseIcon /> : <PlayIcon />}
</button>
<button
className='control-btn arrow-btn'
onClick={onFrameForward}
title="Next Frame"
>
<NextIcon />
</button>
<button
className='control-btn stop-btn'
onClick={onStop}
title='Stop'
>
<FaStop /> Stop
<StopIcon />
</button>
<button
className='control-btn save-btn'
onClick={onSaveAnnotation}
title='Save'
>
💾 Save
<SaveIcon />
</button>
<button
className='control-btn delete-btn'
onClick={onDelete}
title='Delete'
>
🗑 Delete
<DeleteIcon />
</button>
<button
className='control-btn clean-btn'
onClick={onDeleteAll}
title='DeleteAll'
>
<MdCleaningServices /> Delete All
<CleanIcon />
</button>
</div>
</div>
-20
View File
@@ -1,20 +0,0 @@
// src/components/AnnotationList.js
// No changes
import React from 'react';
function AnnotationList({ annotations, onAnnotationClick }) {
return (
<div>
<h3 className='menu-title'>Annotations</h3>
<ul>
{annotations.map((annotation, index) => (
<li key={index} onClick={() => onAnnotationClick(annotation.time)}>
Frame {index + 1} - {annotation.annotations.length} objects
</li>
))}
</ul>
</div>
);
}
export default AnnotationList;
@@ -0,0 +1,28 @@
.annotation-section {
background: #222531;
border-radius: 4px;
padding: 8px;
height: 80%;
}
.annotation-list {
display: flex;
flex-direction: column;
gap: 4px;
list-style-type: none;
padding: 0;
}
.annotation-list-item {
box-sizing: border-box;
display: flex;
align-items: center;
height: 22px;
background: #858CA2;
padding: 4px;
color: #fff;
font-size: 14px;
line-height: 1;
font-weight: 600;
border-radius: 4px ;
}
@@ -0,0 +1,18 @@
import './AnnotationList.css'
function AnnotationList({ annotations, onAnnotationClick }) {
return (
<div className='annotation-section'>
<h3 className='menu-title'>Annotations</h3>
<ul className='annotation-list'>
{annotations.map((annotation, index) => (
<li className='annotation-list-item' key={index} onClick={() => onAnnotationClick(index)}>
Frame {index + 1} - {annotation.detections.length} objects
</li>
))}
</ul>
</div>
);
}
export default AnnotationList;
@@ -1,33 +1,32 @@
.content-wrapper {
display: flex;
gap: 4px;
height: 100vh;
overflow: hidden;
background: #0D1421;
padding: 4px;
}
.side-menu {
display: flex;
flex-direction: column;
width: 15%;
height: 100%;
}
.left-menu{
border-right: 1px solid #ccc;
width: 228px;
height: 100vh;
}
.right-menu{
overflow-y: auto;
border-left: 1px solid #ccc;
}
.player-wrapper {
width: 70%;
width: calc(100% - 464px);
height: 100%;
display: flex;
flex-direction: column;
}
.error-message {
position: absolute;
background: #ffdddd;
color: #d8000c;
padding: 6px;
@@ -51,4 +50,6 @@
justify-content: center;
align-items: center;
overflow: hidden;
border-radius: 8px;
border: 1px solid #222531;
}
+23 -14
View File
@@ -1,6 +1,6 @@
import React, { useState, useEffect, useRef } from 'react';
import VideoPlayer from '../VideoPlayer/VideoPlayer';
import AnnotationList from '../AnnotationList';
import AnnotationList from '../AnnotationList/AnnotationList';
import MediaList from '../MediaList/MediaList';
import DetectionClassList from '../DetectionClassList/DetectionClassList';
import CanvasEditor from '../CanvasEditor/CanvasEditor';
@@ -8,11 +8,12 @@ import * as AnnotationService from '../../services/AnnotationService';
import AnnotationControls from '../AnnotationControls/AnnotationControls';
import saveAnnotation from '../../services/DataHandler';
import './AnnotationMain.css';
import { detectionTypes } from '../../constants/detectionTypes';
function AnnotationMain() {
const [files, setFiles] = useState([]);
const [selectedFile, setSelectedFile] = useState(null);
const [annotations, setAnnotations] = useState({});
const [annotations, setAnnotations] = useState([]);
const [currentTime, setCurrentTime] = useState(0);
const [selectedClass, setSelectedClass] = useState(null);
const [detections, setDetections] = useState([]);
@@ -21,6 +22,7 @@ function AnnotationMain() {
const [videoWidth, setVideoWidth] = useState(640);
const [videoHeight, setVideoHeight] = useState(480);
const [errorMessage, setErrorMessage] = useState("");
const [detectionType, setDetectionType] = useState(detectionTypes.day)
const videoRef = useRef(null);
const containerRef = useRef(null);
@@ -34,7 +36,7 @@ function AnnotationMain() {
if (!file) return;
setSelectedFile(file);
setAnnotations({});
setAnnotations([]);
setDetections([]);
setSelectedDetectionIndices([]);
setCurrentTime(0);
@@ -71,16 +73,18 @@ function AnnotationMain() {
const imageData = AnnotationService.createAnnotationImage(
videoRef,
detections,
safeContainerRef
safeContainerRef,
detectionType
);
if (imageData) {
const newAnnotations = {
...annotations,
[currentTime]: { time: currentTime, annotations: detections, imageData }
time: currentTime,
detections: detections,
imageData: imageData
};
setAnnotations(newAnnotations);
setAnnotations(prevAnnotation => [...prevAnnotation, newAnnotations]);
saveAnnotation(currentTime, detections, imageData);
setErrorMessage("");
@@ -103,15 +107,15 @@ function AnnotationMain() {
setDetections([]);
}
const handleAnnotationClick = (time) => {
setCurrentTime(time);
const annotation = annotations[time];
const handleAnnotationClick = (index) => {
const annotation = annotations[index];
if (annotation) {
setDetections(annotation.annotations || []);
setCurrentTime(annotation.time);
setDetections(annotation.detections || []);
setSelectedDetectionIndices([]);
}
if (videoRef.current) {
videoRef.current.currentTime = time;
videoRef.current.currentTime = annotation.time;
}
setIsPlaying(false);
};
@@ -190,7 +194,11 @@ function AnnotationMain() {
onDropNewFiles={handleDropNewFiles}
/>
<DetectionClassList onClassSelect={handleClassSelect} />
<DetectionClassList
onClassSelect={handleClassSelect}
detectionType={detectionType}
setDetectionType={setDetectionType}
/>
</div>
<div className='player-wrapper' >
@@ -218,6 +226,7 @@ function AnnotationMain() {
onDetectionsChange={handleDetectionsChange}
onSelectionChange={handleSelectionChange}
detectionClass={selectedClass}
detectionType={detectionType}
/>
</VideoPlayer>
</div>
@@ -240,7 +249,7 @@ function AnnotationMain() {
<div className='side-menu right-menu'>
<AnnotationList
annotations={Object.values(annotations)}
annotations={annotations}
onAnnotationClick={handleAnnotationClick}
/>
</div>
+3 -1
View File
@@ -12,7 +12,8 @@ function CanvasEditor({
onDetectionsChange,
onSelectionChange,
children,
detectionClass
detectionClass,
detectionType
}) {
const containerRef = useRef(null);
const [currentDetection, setCurrentDetection] = useState(initialCurrentDetection);
@@ -258,6 +259,7 @@ function CanvasEditor({
onDetectionMouseDown={handleDetectionMouseDown}
currentDetection={currentDetection}
onResize={handleResize}
detectionType={detectionType}
/>
</div>
</div>
+4 -8
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { detectionTypes } from '../constants/detectionTypes';
function Detection({ detection, isSelected, onDetectionMouseDown, onResize }) {
function Detection({ detection, isSelected, onDetectionMouseDown, onResize, detectionType }) {
if (!detection || !detection.class) {
return null;
}
@@ -13,10 +14,6 @@ function Detection({ detection, isSelected, onDetectionMouseDown, onResize }) {
}
// Use startsWith to correctly handle RGBA and hex colors
const backgroundColor = color.startsWith('rgba')
? color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/, 'rgba($1, $2, $3, 0.4)')
: color.replace(/rgb\((\d+),\s*(\d+),\s*(\d+)\)/, 'rgba($1, $2, $3, 0.4)');
const borderColor = color.startsWith('rgba')
? color.replace(/rgba\((\d+),\s*(\d+),\s*(\d+),\s*[\d.]+\)/, 'rgba($1, $2, $3, 1)')
: color;
@@ -40,7 +37,6 @@ function Detection({ detection, isSelected, onDetectionMouseDown, onResize }) {
top: `${detection.y1}px`,
width: `${detection.x2 - detection.x1}px`,
height: `${detection.y2 - detection.y1}px`,
backgroundColor: backgroundColor,
border: `2px solid ${borderColor}`,
boxSizing: 'border-box',
cursor: isSelected ? 'move' : 'default',
@@ -50,7 +46,7 @@ function Detection({ detection, isSelected, onDetectionMouseDown, onResize }) {
if (isSelected) {
style.border = `3px solid black`;
style.boxShadow = `0 0 4px 2px ${borderColor}`;
style.boxShadow = `0 0 4px 4px ${borderColor}`;
}
const handleMouseDown = (e) => {
@@ -92,7 +88,7 @@ function Detection({ detection, isSelected, onDetectionMouseDown, onResize }) {
textShadow: '1px 1px 2px black',
pointerEvents: 'none'
}}>
{detection.class.Name}
{detection.class.Name} {detectionType !== detectionTypes.day && '(' + detectionType + ')'}
</span>
</div>
);
@@ -1,24 +1,80 @@
.detection {
margin-top: 4px;
}
.class-list {
flex-grow: 1;
display: flex;
flex-direction: column;
background: #858CA2;
border-radius: 4px;
padding: 4px;
height: 48vh;
}
.menu-title {
margin-bottom: 6px;
}
.class-list-group {
list-style-type: none;
display: flex;
flex-direction: column;
gap: 3px;
padding: 0;
margin: 0;
height: 40vh;
min-height: 300px;
overflow: auto;
scrollbar-width: none;
list-style-type: none;
}
.class-list-group::-webkit-scrollbar {
display: none;
}
.class-list-item {
display: flex;
align-items: center;
height: 12px;
height: 30px;
cursor: pointer;
padding: 8px;
font-size: 14px;
font-weight: 500;
margin-bottom: 2px;
border-radius: 4px;
}
.detection-type-group {
background: #222531;
display: flex;
flex-direction: row;
justify-content: space-between;
padding: 16px 9px;
margin-top: 4px;
border-radius: 4px;
}
.detection-type-btn {
width: 66px;
height: 40px;
display: flex;
justify-content: center;
align-items: center;
background: #3862fb41;
color: #3861FB;
font-size: 30px;
padding: 5px 17px;
border-radius: 4px;
border: 0;
}
.detection-type-btn:hover {
background: #0e2060;
}
.active-type {
color: white;
background: #3861FB;
}
.active-type:hover {
cursor: default;
background: #3861FB;
}
@@ -1,8 +1,11 @@
import React, { useEffect, useState } from 'react';
import DetectionClass from '../../models/DetectionClass';
import './DetectionClassList.css';
import { MdOutlineNightlightRound, MdOutlineWbSunny } from "react-icons/md";
import { FaRegSnowflake } from 'react-icons/fa';
import { detectionTypes } from '../../constants/detectionTypes';
function DetectionClassList({ onClassSelect }) {
function DetectionClassList({ onClassSelect, detectionType, setDetectionType }) {
const [detectionClasses, setDetectionClasses] = useState([]);
const [selectedClass, setSelectedClass] = useState(null);
@@ -83,30 +86,60 @@ function DetectionClassList({ onClassSelect }) {
onClassSelect && onClassSelect(cls);
};
return (
<div className='class-list'>
<h3 className='menu-title'>Classes</h3>
<ul className='class-list-group' >
{detectionClasses.map((cls) => {
const backgroundColor = calculateColor(cls.Id);
const darkBg = calculateColor(cls.Id, '0.8');
const isSelected = selectedClass && selectedClass.Id === cls.Id;
const handleTypeClick = (type) => {
setDetectionType(type);
}
return (
<li
key={cls.Id}
className='class-list-item'
style={{
border: `1px solid ${isSelected ? '#000' : '#eee'}`,
backgroundColor: isSelected ? darkBg : backgroundColor,
}}
onClick={() => handleClassClick(cls)}
>
{cls.Name}
</li>
);
})}
</ul>
return (
<div className='detection'>
<div className='class-list'>
<h3 className='menu-title'>Classes</h3>
<ul className='class-list-group' >
{detectionClasses.map((cls) => {
const backgroundColor = calculateColor(cls.Id);
const darkBg = calculateColor(cls.Id, '0.8');
const isSelected = selectedClass && selectedClass.Id === cls.Id;
return (
<li
key={cls.Id}
className='class-list-item'
style={{
border: `1px solid ${isSelected ? '#000' : '#eee0'}`,
backgroundColor: isSelected ? darkBg : backgroundColor,
}}
onClick={() => handleClassClick(cls)}
>
{cls.Name}
</li>
);
})}
</ul>
</div>
<div className='detection-type-group'>
<button className={detectionType == detectionTypes.day
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='День'
onClick={() => handleTypeClick(detectionTypes.day)}>
<MdOutlineWbSunny />
</button>
<button className={detectionType == detectionTypes.night
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='Ніч'
onClick={() => handleTypeClick(detectionTypes.night)}>
<MdOutlineNightlightRound />
</button>
<button className={detectionType == detectionTypes.winter
? 'detection-type-btn active-type'
: 'detection-type-btn'} title='Зима'
onClick={() => handleTypeClick(detectionTypes.winter)}>
<FaRegSnowflake />
</button>
</div>
</div>
);
}
+3 -1
View File
@@ -2,7 +2,7 @@
import React from 'react';
import Detection from './Detection';
function DetectionContainer({ detections, selectedDetectionIndices, onDetectionMouseDown, currentDetection, onResize }) {
function DetectionContainer({ detections, selectedDetectionIndices, onDetectionMouseDown, currentDetection, onResize, detectionType }) {
return (
<>
@@ -13,6 +13,7 @@ function DetectionContainer({ detections, selectedDetectionIndices, onDetectionM
isSelected={selectedDetectionIndices.includes(index)}
onDetectionMouseDown={(e) => onDetectionMouseDown(e, index)}
onResize={(e, position) => onResize(e, index, position)}
detectionType={detectionType}
/>
))}
{currentDetection && (
@@ -21,6 +22,7 @@ function DetectionContainer({ detections, selectedDetectionIndices, onDetectionM
isSelected={false}
onDetectionMouseDown={() => {}} // No-op handler for the current detection
onResize={() => {}} // No-op handler for the current detection
detectionType={detectionType}
/>
)}
</>
+60 -8
View File
@@ -1,26 +1,73 @@
.explorer{
height: 40vh ;
background: #222531;
padding: 8px;
border-radius: 4px;
min-height: 180px;
}
.explorer-head{
display: flex;
flex-direction: row;
justify-content: space-between;
margin-bottom: 6px;
}
.menu-title {
font-size: 14px;
margin: 15px 0;
font-size: 18px;
line-height: 20px;
color: white;
margin: 0;
margin-right: 10px;
}
.open-btn{
width: 80px;
height: 20px;
background: #6188FF;
color: white;
border: 0;
border-radius: 4px;
padding: 0;
}
.open-btn:hover{
background: #295cf7;
}
.file-filter{
box-sizing: border-box;
width: 100%;
padding: 4px 8px;
height: 26px;
background: white;
padding: 6px 12px;
border: 0;
border-radius: 2px;
font-size: 14px;
}
.file-list-group {
display: flex;
flex-direction: column;
gap: 4px;
padding: 0;
margin: 12px 0;
list-style-type: none;
max-height: 20vh;
overflow: auto;
scrollbar-width: none;
max-height: 36%;
}
.file-list-group::-webkit-scrollbar {
display: none;
}
.file-list-item {
cursor: pointer;
padding: 6px;
border-bottom: 1px solid #eee;
padding: 7px 6px;
font-size: 12px;
color: white;
cursor: pointer;
border-radius: 2px;
}
.label {
@@ -28,9 +75,14 @@
}
.file-input-block {
display: flex;
justify-content: center;
align-items: center;
height: 12%;
color: white;
border: 2px dashed #ccc;
border-radius: 4px;
padding: 8px;
text-align: center;
margin-top: 10px;
cursor: pointer;
}
+25 -7
View File
@@ -3,16 +3,23 @@ import { useDropzone } from 'react-dropzone';
import './MediaList.css'
function MediaList({ files, selectedFile, onFileSelect, onDropNewFiles }) {
const { getRootProps, getInputProps, isDragActive } = useDropzone({
const { getRootProps, getInputProps, isDragActive, open: openFileDialog } = useDropzone({
onDrop: onDropNewFiles,
multiple: true,
});
const { getRootProps: getFolderRootProps, getInputProps: getFolderInputProps, open: openFolderDialog } = useDropzone({
onDrop: onDropNewFiles,
multiple: true
});
const [filteredFiles, setFilteredFiles] = useState(files);
useEffect(()=>{
useEffect(() => {
setFilteredFiles(files);
},[files])
}, [files])
const handleInputChange = (e) => {
const value = e.target.value;
const filtered = files.filter((file) => file.name.toLowerCase().includes(value.toLowerCase()));
@@ -21,15 +28,23 @@ function MediaList({ files, selectedFile, onFileSelect, onDropNewFiles }) {
return (
<div className='explorer'>
<h3 className='menu-title' >Files</h3>
<input className='file-filter' type='text' placeholder='Filename' onChange={handleInputChange}/>
<div className='explorer-head'>
<h3 className='menu-title' >Files</h3>
<button className='open-btn' type="button" onClick={openFileDialog}>
Open File
</button>
<button className='open-btn' type="button" onClick={openFolderDialog}>
Open Folder
</button>
</div>
<input className='file-filter' type='text' placeholder='Filename' onChange={handleInputChange} />
<ul className='file-list-group' >
{filteredFiles.map((file) => (
<li
className='file-list-item'
key={file.name}
style={{
backgroundColor: selectedFile === file ? '#f0f0f0' : 'transparent'
backgroundColor: selectedFile === file ? '#474A52' : '#858CA2'
}}
onClick={() => onFileSelect(file)}
>
@@ -39,6 +54,9 @@ function MediaList({ files, selectedFile, onFileSelect, onDropNewFiles }) {
</ul>
<div className='file-input-block' {...getRootProps()} >
<input {...getInputProps()} />
<div style={{ display: 'none' }}>
<input {...getFolderInputProps()} webkitdirectory="true" mozdirectory="true" />
</div>
{isDragActive ? (
<p className='label' >Drop here</p>
) : (