16 Commits

Author SHA1 Message Date
streaper2
6892d55c65 feat: add PhotoOverlay component for visualizing and editing impact coordinates 2026-05-07 21:07:55 +02:00
streaper2
cea2fab989 branch backend + ajout du serveur backend, next, et sqlite 2026-04-29 15:45:09 +02:00
streaper2
105eb1cab0 correction suppression modele yolo 2026-04-29 10:12:29 +02:00
streaper2
fba3b41f2f desactivation de tous les scripts et ia, supression du modele yolo 2026-04-28 16:52:20 +02:00
streaper2
e32833e366 preparation du modele yolo 2026-03-12 22:03:40 +01:00
streaper2
d4cb179fde correction du centrage des vecteurs de zone (cercle) pour le plotting 2026-02-21 10:22:18 +01:00
streaper2
2e81f4b69e Merge branch 'temp-fix'
detached branch a merge pour récupérer le travail
2026-02-15 22:21:31 +01:00
streaper2
de677aad7e désactivation autocalibration, et correction de distortion dans l'écran de calibration 2026-02-15 22:07:35 +01:00
streaper2
723900b860 Autocalibration de la cible sur le centre de la cible ok + petite correction de la distortion mais pas tres fonctionnel 2026-02-15 09:49:48 +01:00
streaper2
f78184d2cd nettoyage du code avec cunning 2026-02-14 22:33:35 +01:00
streaper2
7e55c52ae7 implementation de cunning pour scanner l'image et la redresser 2026-02-13 22:18:35 +01:00
streaper2
049f77ac6d ajout doc interne a l'appli 2026-02-06 13:40:01 +01:00
qguillaume
91fcd931e8 clickable home screen renamed 2026-02-03 12:16:30 +01:00
streaper2
9ccb149dda désactivation de l'auto analyse au chargement de l'écran d'analyse 2026-01-31 20:55:28 +01:00
streaper2
972474750f - bouton effacer
- bouton ajouter
+ bouton effacer sur l'image de cible
2026-01-30 20:58:57 +01:00
streaper2
2f69ff4ecf +changelog
+readme
2026-01-30 20:28:02 +01:00
73 changed files with 13168 additions and 826 deletions

4
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"avdmanager.executable": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\avdmanager.bat",
"avdmanager.sdkManager": "c:\\Users\\streaper2\\AppData\\Local\\Android\\Sdk\\cmdline-tools\\latest\\bin\\sdkmanager.bat"
}

25
CHANGELOG.md Normal file
View File

@@ -0,0 +1,25 @@
# Changelog
## [v0.0.1] - 2026-01-29
### Ajouté
- **Interface d'Analyse** :
- Implémentation d'un bouton de sauvegarde "Morphing" : le bouton flottant se transforme en un bouton large en bas de page lors du défilement.
- Ajout de la gestion du défilement et de l'espacement pour une meilleure ergonomie.
- Visualisation des impacts et statistiques de groupement.
- **Support Desktop (Windows)** :
- Configuration de la base de données SQLite pour fonctionner sur Windows via `sqflite_common_ffi`.
- Initialisation conditionnelle selon la plateforme.
### Corrigé
- **Crash Windows** : Résolution du plantage dû à l'initialisation manquante de la factory de base de données FFI.
- **Dépendances** : Fixation de la version de `sqflite_common_ffi` à `2.3.3` pour contourner un problème de cache/corruption avec la version `2.4.0+2`.
- **UI/UX** :
- Correction des débordements de texte ("zebra stripes") dans le bouton de sauvegarde lors de l'animation grâce à `FittedBox`.
- Optimisation de l'affichage du titre "Groupement" dans les statistiques pour éviter les dépassements sur petits écrans.
- Nettoyage des appels redondants (`super.initState`) et correction de la structure des widgets (`Stack` mal fermé).
### Historique des Commits
- `db7160b` - +désactivation (2026-01-29)
- `f1a8eef` - ajout correctif (2026-01-28)
- `031d4a4` - premier app version beta (2026-01-18)

View File

@@ -1,17 +1,35 @@
# bully # Bully - Analyseur de Cible
A new Flutter project. Application Flutter multiplateforme pour l'analyse et le suivi de vos séances de tir.
## Getting Started ## Fonctionnalités Principales
This project is a starting point for a Flutter application. * **Capture et Analyse** : Prenez une photo de votre cible et analysez vos impacts.
* **Détection Automatique** : Utilise des algorithmes pour détecter automatiquement les impacts de balle sur la cible.
* **Calibration** : Outils de calibration précis pour définir la taille et le centre de la cible, assurant des mesures exactes.
* **Statistiques Détaillées** :
* Calcul du score total.
* Analyse du groupement (H+L, diamètre moyen).
* Visualisation graphique de la dispersion.
* **Historique** : Sauvegardez vos sessions avec des notes et consultez votre progression au fil du temps.
* **Interface Intuitive** : Design moderne et fluide, avec un bouton de sauvegarde dynamique qui s'adapte à votre navigation.
A few resources to get you started if this is your first Flutter project: ## Détails Techniques
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter) * **Framework** : Flutter (Compatible Android, iOS, Windows, Linux, macOS).
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) * **Base de Données** : SQLite (via `sqflite` et `sqflite_common_ffi` pour le support Desktop).
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources) * **Graphiques** : `fl_chart` pour la visualisation des données.
* **Architecture** : Provider pour la gestion d'état.
For help getting started with Flutter development, view the ## Installation
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference. 1. Assurez-vous d'avoir Flutter installé.
2. Clonez le dépôt.
3. Installez les dépendances :
```bash
flutter pub get
```
4. Lancez l'application :
```bash
flutter run
```

31
analyze_log.txt Normal file
View File

@@ -0,0 +1,31 @@
Analyzing bully...
info - Statements in an if should be enclosed in a block - lib\features\analysis\analysis_screen.dart:122:17 - curly_braces_in_flow_control_structures
info - 'withOpacity' is deprecated and shouldn't be used. Use .withValues() to avoid precision loss - lib\features\analysis\analysis_screen.dart:650:51 - deprecated_member_use
warning - The declaration '_showAddShotHint' isn't referenced - lib\features\analysis\analysis_screen.dart:1083:8 - unused_element
warning - The declaration '_showAutoDetectDialog' isn't referenced - lib\features\analysis\analysis_screen.dart:1120:8 - unused_element
warning - Unused import: 'widgets/target_type_selector.dart' - lib\features\capture\capture_screen.dart:16:8 - unused_import
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:28:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:141:25 - deprecated_member_use
info - The import of 'package:flutter/foundation.dart' is unnecessary because all of the used elements are also provided by the import of 'package:flutter/material.dart' - lib\features\statistics\statistics_screen.dart:8:8 - unnecessary_import
warning - The declaration '_buildLegendItem' isn't referenced - lib\features\statistics\statistics_screen.dart:309:10 - unused_element
info - Unnecessary use of string interpolation - lib\features\statistics\statistics_screen.dart:408:15 - unnecessary_string_interpolations
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:104:5 - avoid_print
info - Don't invoke 'print' in production code - lib\services\opencv_impact_detection_service.dart:116:5 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:297:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:342:7 - avoid_print
27 issues found. (ran in 1.9s)

BIN
analyze_opencv.txt Normal file

Binary file not shown.

BIN
analyze_output.txt Normal file

Binary file not shown.

BIN
analyze_results_final.txt Normal file

Binary file not shown.

37
analyze_results_fixed.txt Normal file
View File

@@ -0,0 +1,37 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:553:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

38
analyze_utf8.txt Normal file
View File

@@ -0,0 +1,38 @@
Analyzing bully...
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:392:7 - avoid_print
info - Don't invoke 'print' in production code - lib\features\analysis\analysis_provider.dart:596:7 - avoid_print
info - The private field _selectedType could be 'final' - lib\features\capture\capture_screen.dart:27:14 - prefer_final_fields
info - 'scale' is deprecated and shouldn't be used. Use scaleByVector3, scaleByVector4, or scaleByDouble instead - lib\features\crop\crop_screen.dart:142:25 - deprecated_member_use
info - Statements in an if should be enclosed in a block - lib\services\distortion_correction_service.dart:566:11 - curly_braces_in_flow_control_structures
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:639:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:764:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:825:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:953:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1015:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\distortion_correction_service.dart:1063:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:192:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:239:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:246:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:278:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:289:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:292:11 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:297:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:332:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:336:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:683:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:725:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\image_processing_service.dart:736:7 - avoid_print
warning - The declaration '_detectDarkSpotsAdaptive' isn't referenced - lib\services\image_processing_service.dart:780:15 - unused_element
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:328:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:377:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\target_detection_service.dart:414:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:23:9 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:29:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:31:7 - avoid_print
info - Don't invoke 'print' in production code - lib\services\yolo_impact_detection_service.dart:67:7 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:4:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:5:3 - avoid_print
info - Don't invoke 'print' in production code - tests\opencv_quad_test.dart:6:3 - avoid_print
info - Don't invoke 'print' in production code - tests\test_homography.dart:4:3 - avoid_print

View File

@@ -35,6 +35,7 @@ android {
// TODO: Add your own signing config for the release build. // TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works. // Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug") signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
} }
} }
} }

7
android/app/proguard-rules.pro vendored Normal file
View File

@@ -0,0 +1,7 @@
# TensorFlow Lite
-keep class org.tensorflow.lite.** { *; }
-keep class com.google.android.gms.tflite.** { *; }
-dontwarn org.tensorflow.lite.**
# Specifically for the GPU delegate error
-dontwarn org.tensorflow.lite.gpu.GpuDelegateFactory$Options

View File

@@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA" />
<application <application
android:label="bully" android:label="bully"
android:name="${applicationName}" android:name="${applicationName}"

38
backendia/.gitignore vendored Normal file
View File

@@ -0,0 +1,38 @@
# Dependencies
node_modules/
dashboard/node_modules/
# Next.js build output
dashboard/.next/
dashboard/out/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Database
backendia/uploads/data/database.sqlite
*.sqlite
*.sqlite-journal
# Uploads and Exports (Ignore content but keep directories)
uploads/images/*
!uploads/images/.gitkeep
uploads/data/*
!uploads/data/.gitkeep
!uploads/data/database.sqlite
exports/*
!exports/.gitkeep
# Logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
*.log
# OS files
.DS_Store
Thumbs.db

41
backendia/dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->

View File

@@ -0,0 +1 @@
@AGENTS.md

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,17 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
images: {
remotePatterns: [
{
protocol: 'http',
hostname: '127.0.0.1',
port: '3000',
pathname: '/uploads/**',
},
],
},
};
export default nextConfig;

6616
backendia/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"tailwind-merge": "^3.5.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,104 @@
import { fetchApi } from "@/lib/api";
import { Users, Award, Camera, Clock } from "lucide-react";
interface Contributor {
wallet_hash: string;
photo_count: number;
last_upload: string;
}
export default async function ContributorsPage() {
let contributors: Contributor[] = [];
try {
const response = await fetchApi('/api/contributors');
contributors = response.contributors;
} catch (error) {
console.error("Error fetching contributors:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Top Contributeurs</h2>
<p className="text-slate-400">Classement des utilisateurs selon leur contribution à l'entraînement de l'IA.</p>
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Stats Summary */}
<div className="lg:col-span-1 space-y-6">
<StatCard
title="Total Contributeurs"
value={contributors.length}
icon={<Users className="text-indigo-400" />}
/>
<StatCard
title="Total Photos"
value={contributors.reduce((acc, c) => acc + c.photo_count, 0)}
icon={<Camera className="text-emerald-400" />}
/>
</div>
{/* Table */}
<div className="lg:col-span-3 bg-slate-900 border border-slate-800 rounded-2xl overflow-hidden">
<table className="w-full text-left">
<thead className="bg-slate-950/50 border-b border-slate-800">
<tr>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Rang</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest">Wallet Hash</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-center">Photos</th>
<th className="px-6 py-4 text-xs font-bold text-slate-500 uppercase tracking-widest text-right">Dernier Upload</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{contributors.map((c, index) => (
<tr key={c.wallet_hash} className="hover:bg-slate-800/30 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
{index < 3 ? (
<Award className={index === 0 ? "text-yellow-500" : index === 1 ? "text-slate-400" : "text-amber-700"} />
) : (
<span className="text-slate-500 font-mono w-6 text-center">#{index + 1}</span>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="font-mono text-xs text-slate-300">{c.wallet_hash}</span>
</td>
<td className="px-6 py-4 text-center">
<span className="px-2 py-1 bg-indigo-500/10 text-indigo-400 rounded-md text-xs font-bold border border-indigo-500/20">
{c.photo_count}
</span>
</td>
<td className="px-6 py-4 text-right text-xs text-slate-400 flex items-center justify-end gap-2">
<Clock size={14} />
{new Date(c.last_upload).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
{contributors.length === 0 && (
<div className="py-24 text-center text-slate-500">
Aucun contributeur enregistré pour le moment.
</div>
)}
</div>
</div>
</div>
);
}
function StatCard({ title, value, icon }: any) {
return (
<div className="bg-slate-900 border border-slate-800 p-6 rounded-2xl flex items-center justify-between">
<div className="space-y-1">
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">{title}</p>
<p className="text-3xl font-bold">{value}</p>
</div>
<div className="w-12 h-12 bg-slate-800 rounded-xl flex items-center justify-center shadow-inner">
{icon}
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}

View File

@@ -0,0 +1,67 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Link from "next/link";
import { LayoutDashboard, Users, Image as ImageIcon } from "lucide-react";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "Bully IA Dashboard",
description: "Visualisation des données d'analyse de cibles",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr" className="dark">
<body className={`${inter.className} bg-slate-950 text-slate-50 min-h-screen flex`}>
{/* Sidebar */}
<aside className="w-64 border-r border-slate-800 bg-slate-900/50 p-6 flex flex-col gap-8">
<div className="flex items-center gap-3 px-2">
<div className="w-8 h-8 bg-indigo-600 rounded-lg flex items-center justify-center font-bold">B</div>
<h1 className="text-xl font-bold tracking-tight">Bully IA</h1>
</div>
<nav className="flex flex-col gap-2">
<Link
href="/"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<LayoutDashboard size={20} />
Tableau de bord
</Link>
<Link
href="/contributors"
className="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-slate-800 transition-colors text-slate-300 hover:text-white"
>
<Users size={20} />
Contributeurs
</Link>
</nav>
<div className="mt-auto pt-6 border-t border-slate-800">
<p className="text-xs text-slate-500 px-2">v1.0.0 Alpha</p>
</div>
</aside>
{/* Main Content */}
<main className="flex-1 flex flex-col">
<header className="h-16 border-b border-slate-800 flex items-center px-8 bg-slate-900/20">
<div className="flex items-center gap-2 text-sm text-slate-400">
<span>Admin</span>
<span>/</span>
<span className="text-slate-200">Dashboard</span>
</div>
</header>
<div className="flex-1 p-8 overflow-auto">
{children}
</div>
</main>
</body>
</html>
);
}

View File

@@ -0,0 +1,105 @@
import { fetchApi, API_BASE_URL } from "@/lib/api";
import DatasetToolbar from "@/components/DatasetToolbar";
import { Calendar, User, Crosshair } from "lucide-react";
import Link from "next/link";
interface PlottingData {
session_id: string;
wallet_hash: string;
timestamp: string;
plotting: {
impacts: any[];
};
}
interface PhotoData {
filename: string;
imageUrl: string;
data: PlottingData | null;
}
export default async function DashboardPage() {
let photos: PhotoData[] = [];
try {
const response = await fetchApi('/api/all-data');
photos = response.data;
} catch (error) {
console.error("Error fetching photos:", error);
}
return (
<div className="space-y-8">
<div className="flex flex-col md:flex-row md:items-end justify-between gap-4">
<div className="flex flex-col gap-2">
<h2 className="text-3xl font-bold tracking-tight">Galerie des Sessions</h2>
<p className="text-slate-400">Visualisez et gérez les données d'entraînement collectées.</p>
</div>
</div>
<DatasetToolbar />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{photos.map((photo) => (
<Link
key={photo.filename}
href={`/photo/${photo.filename}`}
className="group relative bg-slate-900 rounded-xl border border-slate-800 overflow-hidden hover:border-indigo-500/50 transition-all hover:shadow-2xl hover:shadow-indigo-500/10"
>
{/* Image Preview */}
<div className="aspect-[3/4] relative overflow-hidden bg-slate-800">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`${API_BASE_URL}${photo.imageUrl}`}
alt={photo.filename}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent opacity-80" />
{/* Badge impacts */}
{photo.data?.plotting?.impacts && (
<div className="absolute top-3 right-3 bg-indigo-600/90 backdrop-blur-md px-2 py-1 rounded-md text-[10px] font-bold flex items-center gap-1">
<Crosshair size={12} />
{photo.data.plotting.impacts.length} IMPACTS
</div>
)}
</div>
{/* Info */}
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-slate-400">
<Calendar size={14} className="text-indigo-400" />
{photo.data ? new Date(photo.data.timestamp).toLocaleDateString() : 'Date inconnue'}
</div>
{photo.data ? (
<span className="text-[10px] bg-emerald-500/10 text-emerald-400 px-1.5 py-0.5 rounded border border-emerald-500/20 font-bold">DATA OK</span>
) : (
<span className="text-[10px] bg-rose-500/10 text-rose-400 px-1.5 py-0.5 rounded border border-rose-500/20 font-bold">NO DATA</span>
)}
</div>
<div className="flex items-center gap-2 text-xs text-slate-300 font-mono truncate">
<User size={14} className="text-indigo-400" />
{photo.data?.wallet_hash?.substring(0, 12) || 'Anonyme'}...
</div>
<div className="pt-2 border-t border-slate-800 flex justify-between items-center">
<span className="text-[10px] text-slate-500 uppercase tracking-widest font-bold">Session ID</span>
<span className="text-xs text-slate-300 font-mono truncate max-w-[120px]">
{photo.data?.session_id || 'N/A'}
</span>
</div>
</div>
</Link>
))}
</div>
{photos.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-slate-500 gap-4 border-2 border-dashed border-slate-800 rounded-3xl">
<Crosshair size={48} className="opacity-20" />
<p>Aucune session d'entraînement trouvée.</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,49 @@
import { fetchApi, API_BASE_URL } from "@/lib/api";
import PhotoEditor from "@/components/PhotoEditor";
import { ChevronLeft, Download } from "lucide-react";
import Link from "next/link";
export default async function PhotoDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id: encodedId } = await params;
const id = decodeURIComponent(encodedId);
let photo = null;
try {
const response = await fetchApi('/api/all-data');
photo = response.data.find((p: any) => p.filename === id);
} catch (error) {
console.error("Error fetching photo detail:", error);
}
if (!photo) {
return <div className="p-12 text-center">Session non trouvée.</div>;
}
return (
<div className="space-y-8">
{/* Header */}
<div className="flex items-center justify-between">
<Link
href="/"
className="flex items-center gap-2 text-slate-400 hover:text-white transition-colors"
>
<ChevronLeft size={20} />
Retour à la galerie
</Link>
<div className="flex gap-3">
<a
href={`${API_BASE_URL}/uploads/images/${id}`}
download
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Download size={18} />
Image Originale
</a>
</div>
</div>
<PhotoEditor initialPhoto={photo} />
</div>
);
}

View File

@@ -0,0 +1,98 @@
"use client";
import { useState } from "react";
import { Download, Archive, AlertTriangle, Check, RefreshCw } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
export default function DatasetToolbar() {
const router = useRouter();
const [isExporting, setIsExporting] = useState(false);
const [isArchiving, setIsArchiving] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const handleExport = async () => {
setIsExporting(true);
try {
// On ouvre simplement l'URL dans un nouvel onglet pour déclencher le téléchargement
window.open(`${API_BASE_URL}/api/export/zip`, '_blank');
} catch (error) {
console.error("Export error:", error);
} finally {
setIsExporting(false);
}
};
const handleArchive = async () => {
setIsArchiving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/export/archive`, {
method: "POST"
});
if (response.ok) {
setShowArchiveConfirm(false);
router.refresh(); // Rafraîchir la galerie
} else {
alert("Erreur lors de l'archivage");
}
} catch (error) {
console.error("Archive error:", error);
alert("Erreur réseau");
} finally {
setIsArchiving(false);
}
};
return (
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 flex flex-col md:flex-row items-center justify-between gap-6 shadow-xl shadow-indigo-500/5">
<div className="space-y-1">
<h3 className="font-bold text-lg flex items-center gap-2">
<RefreshCw size={20} className="text-indigo-400" />
Gestion du Dataset
</h3>
<p className="text-xs text-slate-500">Exportez vos sessions étiquetées en ZIP et archivez-les pour nettoyer la galerie.</p>
</div>
<div className="flex gap-3">
<button
onClick={handleExport}
disabled={isExporting}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 px-5 py-2.5 rounded-xl text-sm font-bold transition-all hover:scale-105 active:scale-95 shadow-lg shadow-indigo-600/20"
>
<Download size={18} />
{isExporting ? "Préparation..." : "Télécharger ZIP"}
</button>
{!showArchiveConfirm ? (
<button
onClick={() => setShowArchiveConfirm(true)}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-5 py-2.5 rounded-xl text-sm font-bold transition-all border border-slate-700"
>
<Archive size={18} />
Archiver les sessions
</button>
) : (
<div className="flex items-center gap-2 animate-in fade-in slide-in-from-right-4 duration-300">
<div className="flex items-center gap-2 px-4 py-2 bg-rose-500/10 border border-rose-500/20 rounded-xl text-rose-400 text-xs font-bold">
<AlertTriangle size={14} />
Vider la galerie ?
</div>
<button
onClick={handleArchive}
disabled={isArchiving}
className="bg-rose-600 hover:bg-rose-500 p-2.5 rounded-xl transition-colors disabled:opacity-50"
>
<Check size={18} className="text-white" />
</button>
<button
onClick={() => setShowArchiveConfirm(false)}
className="bg-slate-800 hover:bg-slate-700 p-2.5 rounded-xl transition-colors"
>
<Archive size={18} className="text-slate-400" />
</button>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,226 @@
"use client";
import { useState } from "react";
import PhotoOverlay from "./PhotoOverlay";
import { Info, Calendar, User, Smartphone, Cpu, Save, Edit2, X, Trash2, Check, Tag } from "lucide-react";
import { API_BASE_URL } from "@/lib/api";
import { useRouter } from "next/navigation";
interface PhotoEditorProps {
initialPhoto: any;
}
const PREDEFINED_LABELS = ["bullet_hole", "tear", "miss", "rebound", "other"];
export default function PhotoEditor({ initialPhoto }: PhotoEditorProps) {
const router = useRouter();
const [isEditing, setIsEditing] = useState(false);
const [photoData, setPhotoData] = useState(initialPhoto.data);
const [isSaving, setIsSaving] = useState(false);
const handleImpactsChange = (newImpacts: any[]) => {
setPhotoData({
...photoData,
plotting: {
...photoData.plotting,
impacts: newImpacts
}
});
};
const updateLabel = (index: number, label: string) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], label };
handleImpactsChange(newImpacts);
};
const updateScore = (index: number, score: number) => {
const newImpacts = [...photoData.plotting.impacts];
newImpacts[index] = { ...newImpacts[index], score };
handleImpactsChange(newImpacts);
};
const handleSave = async () => {
setIsSaving(true);
try {
const response = await fetch(`${API_BASE_URL}/api/data/${initialPhoto.filename}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(photoData),
});
if (response.ok) {
setIsEditing(false);
router.refresh();
} else {
alert("Erreur lors de la sauvegarde");
}
} catch (error) {
console.error("Save error:", error);
alert("Erreur réseau");
} finally {
setIsSaving(false);
}
};
const impacts = photoData?.plotting?.impacts || [];
return (
<div className="grid grid-cols-1 xl:grid-cols-3 gap-8">
{/* Main View */}
<div className="xl:col-span-2 space-y-4">
<div className="flex items-center justify-between bg-slate-900/50 p-4 rounded-xl border border-slate-800">
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isEditing ? 'bg-amber-500 animate-pulse' : 'bg-emerald-500'}`} />
<span className="text-sm font-medium">
{isEditing ? "Mode Édition Actif" : "Mode Visualisation"}
</span>
</div>
<div className="flex gap-2">
{isEditing ? (
<>
<button
onClick={() => {
setPhotoData(initialPhoto.data);
setIsEditing(false);
}}
className="flex items-center gap-2 bg-slate-800 hover:bg-slate-700 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<X size={18} />
Annuler
</button>
<button
onClick={handleSave}
disabled={isSaving}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors disabled:opacity-50"
>
<Save size={18} />
{isSaving ? "Enregistrement..." : "Enregistrer"}
</button>
</>
) : (
<button
onClick={() => setIsEditing(true)}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 px-4 py-2 rounded-lg text-sm font-medium transition-colors"
>
<Edit2 size={18} />
Modifier les points
</button>
)}
</div>
</div>
<PhotoOverlay
imageUrl={`${API_BASE_URL}${initialPhoto.imageUrl}`}
impacts={impacts}
targetCorners={photoData?.plotting?.target_corners || []}
isEditing={isEditing}
onImpactsChange={handleImpactsChange}
/>
</div>
{/* Sidebar Info & Labeling */}
<div className="space-y-6">
{/* Labeling Section (Only if editing or has impacts) */}
<div className="bg-slate-900 border border-slate-800 rounded-2xl p-6 space-y-4">
<div className="flex items-center justify-between pb-4 border-b border-slate-800">
<div className="flex items-center gap-3">
<Tag className="text-indigo-400" size={20} />
<h3 className="font-bold text-lg">Étiquetage (Impacts)</h3>
</div>
<span className="bg-slate-800 text-slate-400 text-[10px] font-bold px-2 py-0.5 rounded">
{impacts.length} TOTAL
</span>
</div>
<div className="space-y-3 max-h-[400px] overflow-auto pr-2 custom-scrollbar">
{impacts.map((impact: any, index: number) => (
<div key={impact.id} className="bg-slate-950 p-3 rounded-xl border border-slate-800 space-y-3 group">
<div className="flex items-center justify-between">
<span className="text-[10px] font-bold text-slate-500 uppercase">Impact #{impact.id}</span>
{isEditing && (
<button
onClick={(e) => {
const newImpacts = impacts.filter((_: any, i: number) => i !== index);
handleImpactsChange(newImpacts);
}}
className="text-slate-600 hover:text-rose-500 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Label</label>
{isEditing ? (
<select
value={impact.label}
onChange={(e) => updateLabel(index, e.target.value)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
>
{PREDEFINED_LABELS.map(l => <option key={l} value={l}>{l}</option>)}
</select>
) : (
<div className="text-xs text-indigo-300 font-medium">{impact.label}</div>
)}
</div>
<div className="space-y-1">
<label className="text-[10px] text-slate-500 font-bold uppercase">Score</label>
{isEditing ? (
<input
type="number"
min="0"
max="10"
value={impact.score}
onChange={(e) => updateScore(index, parseInt(e.target.value) || 0)}
className="w-full bg-slate-900 border border-slate-800 rounded px-2 py-1 text-xs outline-none focus:border-indigo-500"
/>
) : (
<div className="text-xs text-emerald-400 font-bold">{impact.score}</div>
)}
</div>
</div>
</div>
))}
{impacts.length === 0 && (
<p className="text-center py-8 text-xs text-slate-500 italic">
Aucun impact détecté. {isEditing ? "Cliquez sur l'image pour en ajouter." : ""}
</p>
)}
</div>
</div>
{/* Metadata Card (Read Only) */}
<div className="bg-slate-900/50 border border-slate-800 rounded-2xl p-6 space-y-4 opacity-80">
<div className="flex items-center gap-3 pb-2 border-b border-slate-800">
<Info className="text-slate-400" size={18} />
<h3 className="font-bold text-sm text-slate-300">Métadonnées Session</h3>
</div>
<div className="space-y-3">
<InfoItem icon={<Calendar size={14} />} label="Date" value={photoData ? new Date(photoData.timestamp).toLocaleString() : 'Inconnue'} />
<InfoItem icon={<User size={14} />} label="Contributeur" value={photoData?.wallet_hash || 'Anonyme'} isMono />
<InfoItem icon={<Smartphone size={14} />} label="Appareil" value={photoData?.device_info?.model || 'Inconnu'} />
</div>
</div>
</div>
</div>
);
}
function InfoItem({ icon, label, value, isMono = false }: any) {
return (
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5 text-[10px] text-slate-500 font-bold uppercase">
{icon}
{label}
</div>
<p className={`text-xs text-slate-300 ${isMono ? 'font-mono truncate' : ''}`}>
{value}
</p>
</div>
);
}

View File

@@ -0,0 +1,183 @@
"use client";
import { useEffect, useRef, useState } from "react";
interface Point {
norm_x: number;
norm_y: number;
}
interface Impact {
id: number;
label: string;
score: number;
coords: Point;
}
interface PhotoOverlayProps {
imageUrl: string;
impacts: Impact[];
targetCorners: Point[];
isEditing?: boolean;
onImpactsChange?: (impacts: Impact[]) => void;
}
export default function PhotoOverlay({
imageUrl,
impacts,
targetCorners,
isEditing = false,
onImpactsChange
}: PhotoOverlayProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
const [dragIndex, setDragIndex] = useState<number | null>(null);
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
updateDimensions();
window.addEventListener("resize", updateDimensions);
return () => window.removeEventListener("resize", updateDimensions);
}, []);
const handleImageClick = (e: React.MouseEvent) => {
if (!isEditing || !onImpactsChange || dragIndex !== null) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
// Calculer les coordonnées normalisées
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
// Créer un nouvel impact
const newImpact: Impact = {
id: impacts.length > 0 ? Math.max(...impacts.map(i => i.id)) + 1 : 1,
label: "bullet_hole",
score: 10,
coords: { norm_x: x, norm_y: y }
};
onImpactsChange([...impacts, newImpact]);
};
const handleMouseMove = (e: React.MouseEvent) => {
if (dragIndex === null || !onImpactsChange) return;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = (e.clientX - rect.left) / dimensions.width;
const y = (e.clientY - rect.top) / dimensions.height;
const newImpacts = [...impacts];
newImpacts[dragIndex] = {
...newImpacts[dragIndex],
coords: { norm_x: x, norm_y: y }
};
onImpactsChange(newImpacts);
};
const deleteImpact = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (!isEditing || !onImpactsChange) return;
const newImpacts = impacts.filter((_, i) => i !== index);
onImpactsChange(newImpacts);
};
return (
<div
ref={containerRef}
className={`relative w-full flex items-center justify-center bg-slate-900 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl min-h-[400px] ${isEditing ? 'cursor-crosshair' : ''}`}
onClick={handleImageClick}
onMouseMove={handleMouseMove}
onMouseUp={() => setDragIndex(null)}
onMouseLeave={() => setDragIndex(null)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt="Target Analysis"
className="max-w-full max-h-[80vh] object-contain select-none"
draggable={false}
onLoad={(e) => {
const img = e.currentTarget;
setDimensions({ width: img.clientWidth, height: img.clientHeight });
}}
/>
{dimensions.width > 0 && (
<svg
className="absolute pointer-events-none"
viewBox={`0 0 ${dimensions.width} ${dimensions.height}`}
style={{ width: dimensions.width, height: dimensions.height }}
>
{/* Draw Target Box if corners exist */}
{targetCorners && targetCorners.length === 4 && (
<polygon
points={targetCorners.map(c => `${c.norm_x * dimensions.width},${c.norm_y * dimensions.height}`).join(' ')}
fill="rgba(79, 70, 229, 0.15)"
stroke="#6366f1"
strokeWidth="2"
strokeDasharray="4 2"
/>
)}
{/* Draw Impacts */}
{impacts.map((impact, index) => (
<g key={impact.id}>
{/* Glow effect */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r={isEditing ? "12" : "8"}
fill={isEditing ? "rgba(244, 63, 94, 0.2)" : "rgba(244, 63, 94, 0.3)"}
className={isEditing ? 'pointer-events-auto cursor-move' : ''}
onMouseDown={(e) => {
if (!isEditing) return;
e.stopPropagation();
setDragIndex(index);
}}
onContextMenu={(e) => deleteImpact(e, index)}
/>
{/* Main circle */}
<circle
cx={impact.coords.norm_x * dimensions.width}
cy={impact.coords.norm_y * dimensions.height}
r="5"
fill="#f43f5e"
stroke="white"
strokeWidth="1.5"
/>
{/* Score text */}
<text
x={impact.coords.norm_x * dimensions.width}
y={impact.coords.norm_y * dimensions.height - 10}
className="text-[12px] font-bold fill-white text-center select-none"
textAnchor="middle"
style={{ textShadow: '0 0 4px rgba(0,0,0,0.9), 1px 1px 0 rgba(0,0,0,0.9)' }}
>
{impact.score}
</text>
</g>
))}
</svg>
)}
{isEditing && (
<div className="absolute bottom-4 right-4 bg-slate-950/80 backdrop-blur-md px-3 py-1.5 rounded-full text-[10px] font-bold text-indigo-400 border border-indigo-500/30">
MODE ÉDITION : CLIC POUR AJOUTER DRAG POUR DÉPLACER CLIC DROIT POUR SUPPRIMER
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,11 @@
export const API_BASE_URL = 'http://127.0.0.1:3000';
export async function fetchApi(endpoint: string) {
const res = await fetch(`${API_BASE_URL}${endpoint}`, {
cache: 'no-store',
});
if (!res.ok) {
throw new Error(`Failed to fetch ${endpoint}`);
}
return res.json();
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

View File

@@ -0,0 +1 @@
# Keep directory structure

1682
backendia/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
backendia/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "backendia",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"start": "node server.js",
"dashboard": "cd dashboard && npm run dev -- -p 3001",
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"adm-zip": "^0.5.17",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
"multer": "^2.1.1",
"sqlite3": "^6.0.1"
}
}

346
backendia/server.js Normal file
View File

@@ -0,0 +1,346 @@
const express = require('express');
const multer = require('multer');
const cors = require('cors');
const path = require('path');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const AdmZip = require('adm-zip');
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors());
app.use(express.json());
// Arborescence demandée
const uploadDir = path.join(__dirname, 'uploads');
const imagesDir = path.join(uploadDir, 'images');
const dataDir = path.join(uploadDir, 'data');
const exportsDir = path.join(__dirname, 'exports');
// S'assurer que les dossiers existent
[uploadDir, imagesDir, dataDir, exportsDir].forEach(dir => {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
});
// Initialisation de la base de données SQLite
const dbPath = path.join(dataDir, 'database.sqlite');
const db = new sqlite3.Database(dbPath, (err) => {
if (err) {
console.error('Erreur de connexion à SQLite:', err.message);
} else {
console.log('Connecté à la base de données SQLite.');
db.run(`CREATE TABLE IF NOT EXISTS user_stats (
wallet_hash TEXT PRIMARY KEY,
photo_count INTEGER DEFAULT 0,
last_upload DATETIME DEFAULT CURRENT_TIMESTAMP
)`);
}
});
// Configuration de multer pour le stockage des fichiers
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, imagesDir); // Les photos vont dans uploads/images/
},
filename: function (req, file, cb) {
// Créer un nom de fichier unique tout en gardant l'extension originale
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
const ext = path.extname(file.originalname);
cb(null, file.fieldname + '-' + uniqueSuffix + ext);
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 15 * 1024 * 1024 // Limite de taille: 15 MB
},
fileFilter: (req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
const allowedExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
// Accepter si c'est un mimetype image OU si l'extension est celle d'une image
if (file.mimetype.startsWith('image/') || allowedExts.includes(ext) || file.mimetype === 'application/octet-stream') {
cb(null, true);
} else {
console.error(`Fichier rejeté: mimetype=${file.mimetype}, extension=${ext}`);
cb(new Error('Seules les images sont autorisées !'), false);
}
}
});
// Routes de base
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', message: 'Le serveur backend IA fonctionne' });
});
// Route pour l'upload de photo + données JSON
// Attend un form-data avec un champ nommé 'photo' et un champ texte 'plotting'
app.post('/api/upload', upload.single('photo'), (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'Aucune photo fournie' });
}
let plottingData = {};
if (req.body.plotting) {
try {
plottingData = JSON.parse(req.body.plotting);
} catch (e) {
console.error("Erreur parsing JSON:", e);
return res.status(400).json({ error: 'Le champ plotting doit être un JSON valide' });
}
}
// Nom de base sans l'extension
const baseFilename = path.parse(req.file.filename).name;
const jsonFilename = `${baseFilename}.json`;
const jsonFilePath = path.join(dataDir, jsonFilename);
// Sauvegarde du JSON dans uploads/data/
fs.writeFileSync(jsonFilePath, JSON.stringify(plottingData, null, 2));
// Mise à jour de la BDD si on a un wallet_hash
const walletHash = plottingData.wallet_hash;
if (walletHash) {
db.run(`
INSERT INTO user_stats (wallet_hash, photo_count, last_upload)
VALUES (?, 1, CURRENT_TIMESTAMP)
ON CONFLICT(wallet_hash) DO UPDATE SET
photo_count = photo_count + 1,
last_upload = CURRENT_TIMESTAMP
`, [walletHash], function(err) {
if (err) {
console.error("Erreur lors de la mise à jour des stats :", err);
} else {
console.log(`Stats mises à jour pour le wallet_hash ${walletHash.substring(0, 8)}...`);
}
});
}
console.log(`Données reçues et sauvegardées:`);
console.log(`- Image: uploads/images/${req.file.filename}`);
console.log(`- JSON : uploads/data/${jsonFilename}`);
res.status(200).json({
message: 'Photo et données uploadées avec succès',
file: {
filename: req.file.filename,
url: `/uploads/images/${req.file.filename}`,
size: req.file.size
},
data: {
filename: jsonFilename
}
});
} catch (error) {
console.error('Erreur lors de l\'upload:', error);
res.status(500).json({ error: 'Erreur interne du serveur lors de l\'upload' });
}
});
// Route pour récupérer toutes les photos disponibles
app.get('/api/photos', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const photos = files.map(file => ({
filename: file,
url: `/uploads/images/${file}`
}));
res.status(200).json({ photos });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour récupérer les statistiques d'un wallet_hash
app.get('/api/stats/:wallet_hash', (req, res) => {
const walletHash = req.params.wallet_hash;
db.get('SELECT photo_count, last_upload FROM user_stats WHERE wallet_hash = ?', [walletHash], (err, row) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des statistiques' });
}
if (row) {
res.json({ status: 'ok', stats: row });
} else {
res.json({ status: 'ok', stats: { photo_count: 0, last_upload: null } });
}
});
});
// Route pour récupérer TOUS les contributeurs
app.get('/api/contributors', (req, res) => {
db.all('SELECT * FROM user_stats ORDER BY photo_count DESC', [], (err, rows) => {
if (err) {
return res.status(500).json({ error: 'Erreur lors de la lecture des contributeurs' });
}
res.json({ contributors: rows });
});
});
// Route pour récupérer toutes les données (Photos + JSON)
app.get('/api/all-data', (req, res) => {
try {
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).json({ error: 'Impossible de lire le dossier des photos' });
}
const allData = files.map(file => {
const baseName = path.parse(file).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
let plotting = null;
if (fs.existsSync(jsonPath)) {
try {
plotting = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
} catch (e) {
console.error(`Erreur lecture JSON pour ${file}`);
}
}
return {
filename: file,
imageUrl: `/uploads/images/${file}`,
data: plotting
};
});
res.status(200).json({ data: allData });
});
} catch (error) {
res.status(500).json({ error: 'Erreur serveur' });
}
});
// Route pour mettre à jour les données JSON d'une session
app.put('/api/data/:filename', (req, res) => {
const filename = req.params.filename;
const baseName = path.parse(filename).name;
const jsonPath = path.join(dataDir, `${baseName}.json`);
try {
if (!fs.existsSync(jsonPath)) {
return res.status(404).json({ error: 'Fichier JSON non trouvé' });
}
// On écrase le fichier avec les nouvelles données reçues
const newData = req.body;
fs.writeFileSync(jsonPath, JSON.stringify(newData, null, 2));
console.log(`Données mises à jour pour : ${baseName}.json`);
res.status(200).json({ message: 'Données mises à jour avec succès' });
} catch (error) {
console.error('Erreur lors de la mise à jour du JSON:', error);
res.status(500).json({ error: 'Erreur serveur lors de la mise à jour' });
}
});
// Route pour exporter toutes les données en ZIP
app.get('/api/export/zip', (req, res) => {
try {
const zip = new AdmZip();
// Ajouter les dossiers au ZIP
if (fs.existsSync(imagesDir)) zip.addLocalFolder(imagesDir, 'images');
if (fs.existsSync(dataDir)) zip.addLocalFolder(dataDir, 'data');
const zipFilename = `dataset_export_${Date.now()}.zip`;
const zipPath = path.join(exportsDir, zipFilename);
// Sauvegarder sur le disque
zip.writeZip(zipPath);
// Envoyer le fichier au client
res.download(zipPath, zipFilename, (err) => {
if (err) console.error("Erreur lors de l'envoi du ZIP:", err);
// Optionnel: supprimer le ZIP local après envoi si vous voulez gagner de la place
// fs.unlinkSync(zipPath);
});
} catch (error) {
console.error('Erreur lors de la création du ZIP:', error);
res.status(500).json({ error: 'Erreur lors de la création de l\'archive' });
}
});
// Route pour archiver et vider les dossiers uploads
app.post('/api/export/archive', (req, res) => {
try {
const archiveName = `archive_${new Date().toISOString().replace(/[:.]/g, '-')}`;
const archivePath = path.join(exportsDir, archiveName);
if (!fs.existsSync(archivePath)) {
fs.mkdirSync(archivePath, { recursive: true });
}
// Fonction pour déplacer les fichiers
const moveFiles = (source, destSubDir) => {
const dest = path.join(archivePath, destSubDir);
if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true });
const files = fs.readdirSync(source);
files.forEach(file => {
// Ne pas déplacer la base de données sqlite
if (file === 'database.sqlite') return;
const oldPath = path.join(source, file);
const newPath = path.join(dest, file);
try {
fs.renameSync(oldPath, newPath);
} catch (e) {
// Fallback si renameSync échoue (ex: disque différent)
fs.copyFileSync(oldPath, newPath);
fs.unlinkSync(oldPath);
}
});
};
moveFiles(imagesDir, 'images');
moveFiles(dataDir, 'data');
res.json({ message: 'Données archivées avec succès', archive: archiveName });
} catch (error) {
console.error('Erreur lors de l\'archivage:', error);
res.status(500).json({ error: 'Erreur lors de l\'archivage des données' });
}
});
// Servir les fichiers statiques (pour pouvoir accéder aux photos via URL)
app.use('/uploads/images', express.static(imagesDir));
// Gestion des erreurs
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'Le fichier est trop volumineux (limite 15MB)' });
}
}
console.error(err.stack);
res.status(500).json({ error: err.message || 'Une erreur inattendue est survenue' });
});
// Démarrer le serveur
app.listen(PORT, () => {
console.log(`=================================`);
console.log(`Serveur Backend IA démarré`);
console.log(`Port: ${PORT}`);
console.log(`Dossiers:`);
console.log(` - Images: ${imagesDir}`);
console.log(` - Data : ${dataDir}`);
console.log(` - Export: ${exportsDir}`);
console.log(`=================================`);
});

Binary file not shown.

20
build_log.txt Normal file
View File

@@ -0,0 +1,20 @@
Running Gradle task 'assembleDebug'...
FAILURE: Build failed with an exception.
* What went wrong:
Execution failed for task ':app:processDebugResources'.
> A failure occurred while executing com.android.build.gradle.internal.res.LinkApplicationAndroidResourcesTask$TaskAction
> Android resource linking failed
ERROR: C:\Users\streaper2\Documents\00 - projet\bully\build\cunning_document_scanner\intermediates\merged_manifest\debug\processDebugManifest\AndroidManifest.xml:9:5-65: AAPT: error: unexpected element <uses-permission> found in <manifest><application>.
* Try:
> Run with --stacktrace option to get the stack trace.
> Run with --info or --debug option to get more log output.
> Run with --scan to get full insights.
> Get more help at https://help.gradle.org.
BUILD FAILED in 5s
Running Gradle task 'assembleDebug'... 5,4s
Gradle task assembleDebug failed with exit code 1

3
devtools_options.yaml Normal file
View File

@@ -0,0 +1,3 @@
description: This file stores settings for Dart & Flutter DevTools.
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
extensions:

26
docs/README.md Normal file
View File

@@ -0,0 +1,26 @@
# Documentation du Projet Bully
Bienvenue dans la documentation développeur de l'application **Bully**.
Ce projet est une application Flutter d'analyse de cibles de tir (Impact Detection).
## Architecture
Le code source est organisé dans le dossier `lib/` selon les couches suivantes :
- **Features (`lib/features`)** : Contient les écrans et la logique UI (Vues/Pages). C'est ici que réside l'interface utilisateur.
- **Services (`lib/services`)** : Services "métier" et utilitaires (traitement d'image, calculs, etc.). Indépendant de l'UI.
- **Data (`lib/data`)** : Gestion des données (Modèles, Base de données locale, Repositories).
## Sections de la Documentation
Pour plus de détails sur chaque partie, consultez les sections dédiées :
- 🏗️ **[Services (Logique Métier)](services/README.md)** : Documentation des services comme le traitement d'image et le calcul de score.
- 📱 **[Vues & Features (UI)](features/README.md)** : Documentation des écrans principaux (ex: Analyse).
- 💾 **[Base de Données & Modèles](data/README.md)** : Structure des données et persistance.
## Pour commencer
1. Assurez-vous d'avoir Flutter installé.
2. Lancez `flutter run` pour démarrer l'application.

17
docs/data/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Data & Persistance
Cette couche gère la sauvegarde et la récupération des données.
## Base de Données
L'application utilise une base de données locale (probablement SQLite/Drift ou Hive, à vérifier dans `lib/data/database`).
## Modèles (`lib/data/models`)
Les classes représentant les objets métier persistés.
Exemples probables :
- `Session` : Une session de tir.
- `Impact` : Un impact de balle sur la cible.
- `Target` : Configuration d'une cible.
## Repositories (`lib/data/repositories`)
Le pattern Repository est utilisé pour abstraire la source de données (DB locale, API distante, etc.) du reste de l'application.

17
docs/features/README.md Normal file
View File

@@ -0,0 +1,17 @@
# Features & Vues
Cette section documente les écrans principaux de l'application et leur organisation.
## Écrans Principaux
### Analysis (`lib/features/analysis`)
C'est le cœur de l'application. Il permet à l'utilisateur de prendre une photo ou choisir une image pour analyser les impacts.
- **AnalysisScreen** (`analysis_screen.dart`): L'écran principal qui orchestre la capture et l'affichage des résultats.
- **AnalysisProvider** (`analysis_provider.dart`): Gestionnaire d'état (State Management) pour cet écran. Il fait le pont entre la vue et les services.
## Structure d'une Feature
Chaque feature est généralement composée de :
- `_screen.dart` : Le Widget de la page.
- `_provider.dart` : La logique d'état (ChangeNotifier, Bloc, etc.).
- `widgets/` : Widgets spécifiques à cette feature.

20
docs/services/README.md Normal file
View File

@@ -0,0 +1,20 @@
# Services
Les services contiennent la logique métier de l'application, isolée de l'interface utilisateur.
## Liste des Services Principaux
| Service | Description | Fichier |
| :--- | :--- | :--- |
| **ImageProcessingService** | Gère le traitement lourd des images (filtres, détection). | `lib/services/image_processing_service.dart` |
| **DistortionCorrection** | Corrige la distorsion de perspective des cibles. | `lib/services/distortion_correction_service.dart` |
| **ScoreCalculator** | Calcule le score en fonction des impacts détectés. | `lib/services/score_calculator_service.dart` |
| **StatisticsService** | Génère des statistiques sur les sessions de tir. | `lib/services/statistics_service.dart` |
## Exemple d'utilisation (Fictif)
```dart
// Exemple d'appel au service de calcul de score
final calculator = ScoreCalculatorService();
final score = calculator.calculate(impacts);
```

View File

@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan documents</string>
<key>CADisableMinimumFrameDurationOnPhone</key> <key>CADisableMinimumFrameDurationOnPhone</key>
<true/> <true/>
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>

View File

@@ -17,6 +17,8 @@ import '../../services/target_detection_service.dart';
import '../../services/score_calculator_service.dart'; import '../../services/score_calculator_service.dart';
import '../../services/grouping_analyzer_service.dart'; import '../../services/grouping_analyzer_service.dart';
import '../../services/distortion_correction_service.dart'; import '../../services/distortion_correction_service.dart';
import '../../services/opencv_target_service.dart';
import '../../services/ai_export_service.dart';
enum AnalysisState { initial, loading, success, error } enum AnalysisState { initial, loading, success, error }
@@ -26,6 +28,7 @@ class AnalysisProvider extends ChangeNotifier {
final GroupingAnalyzerService _groupingAnalyzerService; final GroupingAnalyzerService _groupingAnalyzerService;
final SessionRepository _sessionRepository; final SessionRepository _sessionRepository;
final DistortionCorrectionService _distortionService; final DistortionCorrectionService _distortionService;
final OpenCVTargetService _opencvTargetService;
final Uuid _uuid = const Uuid(); final Uuid _uuid = const Uuid();
AnalysisProvider({ AnalysisProvider({
@@ -34,11 +37,13 @@ class AnalysisProvider extends ChangeNotifier {
required GroupingAnalyzerService groupingAnalyzerService, required GroupingAnalyzerService groupingAnalyzerService,
required SessionRepository sessionRepository, required SessionRepository sessionRepository,
DistortionCorrectionService? distortionService, DistortionCorrectionService? distortionService,
}) : _detectionService = detectionService, OpenCVTargetService? opencvTargetService,
_scoreCalculatorService = scoreCalculatorService, }) : _detectionService = detectionService,
_groupingAnalyzerService = groupingAnalyzerService, _scoreCalculatorService = scoreCalculatorService,
_sessionRepository = sessionRepository, _groupingAnalyzerService = groupingAnalyzerService,
_distortionService = distortionService ?? DistortionCorrectionService(); _sessionRepository = sessionRepository,
_distortionService = distortionService ?? DistortionCorrectionService(),
_opencvTargetService = opencvTargetService ?? OpenCVTargetService();
AnalysisState _state = AnalysisState.initial; AnalysisState _state = AnalysisState.initial;
String? _errorMessage; String? _errorMessage;
@@ -49,6 +54,7 @@ class AnalysisProvider extends ChangeNotifier {
double _targetCenterX = 0.5; double _targetCenterX = 0.5;
double _targetCenterY = 0.5; double _targetCenterY = 0.5;
double _targetRadius = 0.4; double _targetRadius = 0.4;
double _targetInnerRadius = 0.04;
int _ringCount = 10; int _ringCount = 10;
List<double>? _ringRadii; // Individual ring radii multipliers List<double>? _ringRadii; // Individual ring radii multipliers
double _imageAspectRatio = 1.0; // width / height double _imageAspectRatio = 1.0; // width / height
@@ -79,8 +85,10 @@ class AnalysisProvider extends ChangeNotifier {
double get targetCenterX => _targetCenterX; double get targetCenterX => _targetCenterX;
double get targetCenterY => _targetCenterY; double get targetCenterY => _targetCenterY;
double get targetRadius => _targetRadius; double get targetRadius => _targetRadius;
double get targetInnerRadius => _targetInnerRadius;
int get ringCount => _ringCount; int get ringCount => _ringCount;
List<double>? get ringRadii => _ringRadii != null ? List.unmodifiable(_ringRadii!) : null; List<double>? get ringRadii =>
_ringRadii != null ? List.unmodifiable(_ringRadii!) : null;
double get imageAspectRatio => _imageAspectRatio; double get imageAspectRatio => _imageAspectRatio;
List<Shot> get shots => List.unmodifiable(_shots); List<Shot> get shots => List.unmodifiable(_shots);
ScoreResult? get scoreResult => _scoreResult; ScoreResult? get scoreResult => _scoreResult;
@@ -97,13 +105,22 @@ class AnalysisProvider extends ChangeNotifier {
DistortionParameters? get distortionParams => _distortionParams; DistortionParameters? get distortionParams => _distortionParams;
String? get correctedImagePath => _correctedImagePath; String? get correctedImagePath => _correctedImagePath;
bool get hasDistortion => _distortionParams?.needsCorrection ?? false; bool get hasDistortion => _distortionParams?.needsCorrection ?? false;
/// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon) /// Retourne le chemin de l'image à afficher (corrigée si activée, originale sinon)
String? get displayImagePath => _distortionCorrectionEnabled && _correctedImagePath != null String? get displayImagePath =>
_distortionCorrectionEnabled && _correctedImagePath != null
? _correctedImagePath ? _correctedImagePath
: _imagePath; : _imagePath;
/// Analyze an image /// Analyze an image
Future<void> analyzeImage(String imagePath, TargetType targetType) async { ///
/// [autoAnalyze] determines if we should run automatic detection immediately.
/// If false, only the image is loaded and default target parameters are set.
Future<void> analyzeImage(
String imagePath,
TargetType targetType, {
bool autoAnalyze = true,
}) async {
_state = AnalysisState.loading; _state = AnalysisState.loading;
_imagePath = imagePath; _imagePath = imagePath;
_targetType = targetType; _targetType = targetType;
@@ -119,6 +136,21 @@ class AnalysisProvider extends ChangeNotifier {
_imageAspectRatio = frame.image.width / frame.image.height; _imageAspectRatio = frame.image.width / frame.image.height;
frame.image.dispose(); frame.image.dispose();
if (!autoAnalyze) {
// Just setup default values without running detection
_targetCenterX = 0.5;
_targetCenterY = 0.5;
_targetRadius = 0.4;
_targetInnerRadius = 0.04;
// Initialize empty shots list
_shots = [];
_state = AnalysisState.success;
notifyListeners();
return;
}
// Detect target and impacts // Detect target and impacts
final result = _detectionService.detectTarget(imagePath, targetType); final result = _detectionService.detectTarget(imagePath, targetType);
@@ -132,6 +164,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = result.centerX; _targetCenterX = result.centerX;
_targetCenterY = result.centerY; _targetCenterY = result.centerY;
_targetRadius = result.radius; _targetRadius = result.radius;
_targetInnerRadius = result.radius * 0.1;
// Create shots from detected impacts // Create shots from detected impacts
_shots = result.impacts.map((impact) { _shots = result.impacts.map((impact) {
@@ -162,13 +195,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a manual shot /// Add a manual shot
void addShot(double x, double y) { void addShot(double x, double y) {
final score = _calculateShotScore(x, y); final score = _calculateShotScore(x, y);
final shot = Shot( final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
id: _uuid.v4(),
x: x,
y: y,
score: score,
sessionId: '',
);
_shots.add(shot); _shots.add(shot);
_recalculateScores(); _recalculateScores();
@@ -190,17 +217,23 @@ class AnalysisProvider extends ChangeNotifier {
if (index == -1) return; if (index == -1) return;
final newScore = _calculateShotScore(newX, newY); final newScore = _calculateShotScore(newX, newY);
_shots[index] = _shots[index].copyWith( _shots[index] = _shots[index].copyWith(x: newX, y: newY, score: newScore);
x: newX,
y: newY,
score: newScore,
);
_recalculateScores(); _recalculateScores();
_recalculateGrouping(); _recalculateGrouping();
notifyListeners(); notifyListeners();
} }
/// Update a shot's score manually
void updateShotScore(String shotId, int newScore) {
final index = _shots.indexWhere((shot) => shot.id == shotId);
if (index == -1) return;
_shots[index] = _shots[index].copyWith(score: newScore);
_recalculateScores();
notifyListeners();
}
/// Auto-detect impacts using image processing /// Auto-detect impacts using image processing
Future<int> autoDetectImpacts({ Future<int> autoDetectImpacts({
int darkThreshold = 80, int darkThreshold = 80,
@@ -271,6 +304,8 @@ class AnalysisProvider extends ChangeNotifier {
double param2 = 30, double param2 = 30,
int minRadius = 5, int minRadius = 5,
int maxRadius = 50, int maxRadius = 50,
int minSize = 5,
int maxSize = 1000,
int blurSize = 5, int blurSize = 5,
bool useContourDetection = true, bool useContourDetection = true,
double minCircularity = 0.6, double minCircularity = 0.6,
@@ -334,7 +369,9 @@ class AnalysisProvider extends ChangeNotifier {
double tolerance = 2.0, double tolerance = 2.0,
bool clearExisting = false, bool clearExisting = false,
}) async { }) async {
if (_imagePath == null || _targetType == null || _referenceImpacts.length < 2) { if (_imagePath == null ||
_targetType == null ||
_referenceImpacts.length < 2) {
return 0; return 0;
} }
@@ -343,16 +380,17 @@ class AnalysisProvider extends ChangeNotifier {
.map((shot) => ReferenceImpact(x: shot.x, y: shot.y)) .map((shot) => ReferenceImpact(x: shot.x, y: shot.y))
.toList(); .toList();
final detectedImpacts = _detectionService.detectImpactsWithOpenCVFromReferences( final detectedImpacts = _detectionService
_imagePath!, .detectImpactsWithOpenCVFromReferences(
_targetType!, _imagePath!,
_targetCenterX, _targetType!,
_targetCenterY, _targetCenterX,
_targetRadius, _targetCenterY,
_ringCount, _targetRadius,
references, _ringCount,
tolerance: tolerance, references,
); tolerance: tolerance,
);
if (clearExisting) { if (clearExisting) {
_shots.clear(); _shots.clear();
@@ -381,13 +419,7 @@ class AnalysisProvider extends ChangeNotifier {
/// Add a reference impact for calibrated detection /// Add a reference impact for calibrated detection
void addReferenceImpact(double x, double y) { void addReferenceImpact(double x, double y) {
final score = _calculateShotScore(x, y); final score = _calculateShotScore(x, y);
final shot = Shot( final shot = Shot(id: _uuid.v4(), x: x, y: y, score: score, sessionId: '');
id: _uuid.v4(),
x: x,
y: y,
score: score,
sessionId: '',
);
_referenceImpacts.add(shot); _referenceImpacts.add(shot);
notifyListeners(); notifyListeners();
} }
@@ -428,7 +460,9 @@ class AnalysisProvider extends ChangeNotifier {
double tolerance = 2.0, double tolerance = 2.0,
bool clearExisting = false, bool clearExisting = false,
}) async { }) async {
if (_imagePath == null || _targetType == null || _learnedCharacteristics == null) { if (_imagePath == null ||
_targetType == null ||
_learnedCharacteristics == null) {
return 0; return 0;
} }
@@ -468,9 +502,17 @@ class AnalysisProvider extends ChangeNotifier {
} }
/// Adjust target position /// Adjust target position
void adjustTargetPosition(double centerX, double centerY, double radius, {int? ringCount, List<double>? ringRadii}) { void adjustTargetPosition(
double centerX,
double centerY,
double innerRadius,
double radius, {
int? ringCount,
List<double>? ringRadii,
}) {
_targetCenterX = centerX; _targetCenterX = centerX;
_targetCenterY = centerY; _targetCenterY = centerY;
_targetInnerRadius = innerRadius;
_targetRadius = radius; _targetRadius = radius;
if (ringCount != null) { if (ringCount != null) {
_ringCount = ringCount; _ringCount = ringCount;
@@ -489,6 +531,43 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// Auto-calibrate target using OpenCV
Future<bool> autoCalibrateTarget() async {
if (_imagePath == null) return false;
try {
// 1. Attempt to correct perspective/distortion first
final correctedPath = await _distortionService
.correctPerspectiveWithConcentricMesh(_imagePath!);
if (correctedPath != _imagePath) {
_imagePath = correctedPath;
_correctedImagePath = correctedPath;
_distortionCorrectionEnabled = true;
_imageAspectRatio =
1.0; // The corrected image is always square (side x side)
notifyListeners();
}
// 2. Detect the target on the straight/corrected image
final result = await _opencvTargetService.detectTarget(_imagePath!);
if (result.success) {
adjustTargetPosition(
result.centerX,
result.centerY,
result.radius * 0.1,
result.radius,
);
return true;
}
return false;
} catch (e) {
print('Auto-calibration error: $e');
return false;
}
}
/// Calcule les paramètres de distorsion basés sur la calibration actuelle /// Calcule les paramètres de distorsion basés sur la calibration actuelle
void calculateDistortion() { void calculateDistortion() {
_distortionParams = _distortionService.calculateDistortionFromCalibration( _distortionParams = _distortionService.calculateDistortionFromCalibration(
@@ -529,8 +608,7 @@ class AnalysisProvider extends ChangeNotifier {
} }
} }
/* version deux a tester*/
/* version deux a tester*/
/// Calcule ET applique la correction pour un feedback immédiat /// Calcule ET applique la correction pour un feedback immédiat
Future<void> calculateAndApplyDistortion() async { Future<void> calculateAndApplyDistortion() async {
// 1. Calcul des paramètres (votre code actuel) // 1. Calcul des paramètres (votre code actuel)
@@ -566,7 +644,7 @@ class AnalysisProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
} }
/* fin section deux a tester*/ /* fin section deux a tester*/
int _calculateShotScore(double x, double y) { int _calculateShotScore(double x, double y) {
if (_targetType == TargetType.concentric) { if (_targetType == TargetType.concentric) {
@@ -611,6 +689,42 @@ class AnalysisProvider extends ChangeNotifier {
_groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots); _groupingResult = _groupingAnalyzerService.analyzeGrouping(_shots);
} }
/// Exporte l'image et le json vers le backend IA
Future<bool> exportToAiBackend() async {
if (_imagePath == null || _targetType == null) {
_errorMessage = "Impossible d'exporter : image ou type de cible manquant.";
notifyListeners();
return false;
}
// Identifiant de session temporaire si non sauvegardée
final sessionId = _shots.isNotEmpty && _shots.first.sessionId.isNotEmpty
? _shots.first.sessionId
: 'session_${DateTime.now().millisecondsSinceEpoch}';
final service = AiExportService(); // Local instanciation for simplicity
_state = AnalysisState.loading;
notifyListeners();
final success = await service.exportData(
imagePath: _imagePath!,
sessionId: sessionId,
targetType: _targetType!,
targetCenterX: _targetCenterX,
targetCenterY: _targetCenterY,
targetRadius: _targetRadius,
shots: _shots,
);
_state = AnalysisState.success;
if (!success) {
_errorMessage = "Échec de l'export vers le serveur IA.";
}
notifyListeners();
return success;
}
/// Save the session /// Save the session
Future<Session> saveSession({String? notes}) async { Future<Session> saveSession({String? notes}) async {
if (_imagePath == null || _targetType == null) { if (_imagePath == null || _targetType == null) {
@@ -647,6 +761,7 @@ class AnalysisProvider extends ChangeNotifier {
_targetCenterX = 0.5; _targetCenterX = 0.5;
_targetCenterY = 0.5; _targetCenterY = 0.5;
_targetRadius = 0.4; _targetRadius = 0.4;
_targetInnerRadius = 0.04;
_ringCount = 10; _ringCount = 10;
_ringRadii = null; _ringRadii = null;
_imageAspectRatio = 1.0; _imageAspectRatio = 1.0;

File diff suppressed because it is too large Load Diff

View File

@@ -13,16 +13,26 @@ class TargetCalibration extends StatefulWidget {
final double initialCenterX; final double initialCenterX;
final double initialCenterY; final double initialCenterY;
final double initialRadius; final double initialRadius;
final double initialInnerRadius;
final int initialRingCount; final int initialRingCount;
final TargetType targetType; final TargetType targetType;
final List<double>? initialRingRadii; final List<double>? initialRingRadii;
final Function(double centerX, double centerY, double radius, int ringCount, {List<double>? ringRadii}) onCalibrationChanged; final Function(
double centerX,
double centerY,
double innerRadius,
double radius,
int ringCount, {
List<double>? ringRadii,
})
onCalibrationChanged;
const TargetCalibration({ const TargetCalibration({
super.key, super.key,
required this.initialCenterX, required this.initialCenterX,
required this.initialCenterY, required this.initialCenterY,
required this.initialRadius, required this.initialRadius,
required this.initialInnerRadius,
this.initialRingCount = 10, this.initialRingCount = 10,
required this.targetType, required this.targetType,
this.initialRingRadii, this.initialRingRadii,
@@ -37,11 +47,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
late double _centerX; late double _centerX;
late double _centerY; late double _centerY;
late double _radius; late double _radius;
late double _innerRadius;
late int _ringCount; late int _ringCount;
late List<double> _ringRadii; late List<double> _ringRadii;
bool _isDraggingCenter = false; bool _isDraggingCenter = false;
bool _isDraggingRadius = false; bool _isDraggingRadius = false;
bool _isDraggingInnerRadius = false;
@override @override
void initState() { void initState() {
@@ -49,28 +61,57 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = widget.initialCenterX; _centerX = widget.initialCenterX;
_centerY = widget.initialCenterY; _centerY = widget.initialCenterY;
_radius = widget.initialRadius; _radius = widget.initialRadius;
_innerRadius = widget.initialInnerRadius;
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); _initRingRadii();
} }
void _initRingRadii() { void _initRingRadii() {
if (widget.initialRingRadii != null && widget.initialRingRadii!.length == _ringCount) { if (widget.initialRingRadii != null &&
widget.initialRingRadii!.length == _ringCount) {
_ringRadii = List.from(widget.initialRingRadii!); _ringRadii = List.from(widget.initialRingRadii!);
} else { } else {
// Initialize with default proportional radii // Initialize with default proportional radii interpolated between inner and outer
_ringRadii = List.generate(_ringCount, (i) => (i + 1) / _ringCount); _ringRadii = List.generate(_ringCount, (i) {
if (_ringCount <= 1) return 1.0;
final ratio = _innerRadius / _radius;
return ratio + (1.0 - ratio) * i / (_ringCount - 1);
});
} }
} }
@override @override
void didUpdateWidget(TargetCalibration oldWidget) { void didUpdateWidget(TargetCalibration oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
bool shouldReinit = false;
if (widget.initialCenterX != oldWidget.initialCenterX &&
!_isDraggingCenter) {
_centerX = widget.initialCenterX;
}
if (widget.initialCenterY != oldWidget.initialCenterY &&
!_isDraggingCenter) {
_centerY = widget.initialCenterY;
}
if (widget.initialRingCount != oldWidget.initialRingCount) { if (widget.initialRingCount != oldWidget.initialRingCount) {
_ringCount = widget.initialRingCount; _ringCount = widget.initialRingCount;
_initRingRadii(); shouldReinit = true;
} }
if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) { if (widget.initialRadius != oldWidget.initialRadius && !_isDraggingRadius) {
_radius = widget.initialRadius; _radius = widget.initialRadius;
shouldReinit = true;
}
if (widget.initialInnerRadius != oldWidget.initialInnerRadius &&
!_isDraggingInnerRadius) {
_innerRadius = widget.initialInnerRadius;
shouldReinit = true;
}
if (widget.initialRingRadii != oldWidget.initialRingRadii) {
shouldReinit = true;
}
if (shouldReinit) {
_initRingRadii();
} }
} }
@@ -90,11 +131,13 @@ class _TargetCalibrationState extends State<TargetCalibration> {
centerX: _centerX, centerX: _centerX,
centerY: _centerY, centerY: _centerY,
radius: _radius, radius: _radius,
innerRadius: _innerRadius,
ringCount: _ringCount, ringCount: _ringCount,
ringRadii: _ringRadii, ringRadii: _ringRadii,
targetType: widget.targetType, targetType: widget.targetType,
isDraggingCenter: _isDraggingCenter, isDraggingCenter: _isDraggingCenter,
isDraggingRadius: _isDraggingRadius, isDraggingRadius: _isDraggingRadius,
isDraggingInnerRadius: _isDraggingInnerRadius,
), ),
), ),
); );
@@ -109,21 +152,42 @@ class _TargetCalibrationState extends State<TargetCalibration> {
// Check if tapping on center handle // Check if tapping on center handle
final distToCenter = _distance(tapX, tapY, _centerX, _centerY); final distToCenter = _distance(tapX, tapY, _centerX, _centerY);
// Check if tapping on radius handle (on the right edge of the outermost circle) // Check if tapping on outer radius handle
final minDim = math.min(size.width, size.height); final minDim = math.min(size.width, size.height);
final outerRadius = _radius * (_ringRadii.isNotEmpty ? _ringRadii.last : 1.0); final outerRadius = _radius;
final radiusHandleX = _centerX + outerRadius * minDim / size.width; final radiusHandleX = _centerX + outerRadius * minDim / size.width;
final radiusHandleY = _centerY; final radiusHandleY = _centerY;
final distToRadiusHandle = _distance(tapX, tapY, radiusHandleX.clamp(0.0, 1.0), radiusHandleY.clamp(0.0, 1.0)); final distToOuterHandle = _distance(
tapX,
tapY,
radiusHandleX.clamp(0.0, 1.0),
radiusHandleY.clamp(0.0, 1.0),
);
// Check if tapping on inner radius handle (top edge of innermost circle)
final actualInnerRadius = _innerRadius;
final innerHandleX = _centerX;
final innerHandleY = _centerY - actualInnerRadius * minDim / size.height;
final distToInnerHandle = _distance(
tapX,
tapY,
innerHandleX.clamp(0.0, 1.0),
innerHandleY.clamp(0.0, 1.0),
);
// Increase touch target size slightly for handles
if (distToCenter < 0.05) { if (distToCenter < 0.05) {
setState(() { setState(() {
_isDraggingCenter = true; _isDraggingCenter = true;
}); });
} else if (distToRadiusHandle < 0.05) { } else if (distToOuterHandle < 0.05) {
setState(() { setState(() {
_isDraggingRadius = true; _isDraggingRadius = true;
}); });
} else if (distToInnerHandle < 0.05) {
setState(() {
_isDraggingInnerRadius = true;
});
} else if (distToCenter < _radius + 0.02) { } else if (distToCenter < _radius + 0.02) {
// Tapping inside the target - move center // Tapping inside the target - move center
setState(() { setState(() {
@@ -143,19 +207,36 @@ class _TargetCalibrationState extends State<TargetCalibration> {
_centerX = _centerX + deltaX; _centerX = _centerX + deltaX;
_centerY = _centerY + deltaY; _centerY = _centerY + deltaY;
} else if (_isDraggingRadius) { } else if (_isDraggingRadius) {
// Adjust outer radius (scales all rings proportionally) // Adjust outer radius
final newRadius = _radius + deltaX * (size.width / minDim); final newRadius = _radius + deltaX * (size.width / minDim);
_radius = newRadius.clamp(0.05, 3.0); _radius = newRadius.clamp(math.max(0.05, _innerRadius + 0.01), 3.0);
_initRingRadii(); // Recalculate linear separation
} else if (_isDraggingInnerRadius) {
// Adjust inner radius (sliding up reduces Y, so deltaY is negative when growing. Thus we subtract deltaY)
final newInnerRadius = _innerRadius - deltaY * (size.height / minDim);
_innerRadius = newInnerRadius.clamp(
0.01,
math.max(0.01, _radius - 0.01),
);
_initRingRadii(); // Recalculate linear separation
} }
}); });
widget.onCalibrationChanged(_centerX, _centerY, _radius, _ringCount, ringRadii: _ringRadii); widget.onCalibrationChanged(
_centerX,
_centerY,
_innerRadius,
_radius,
_ringCount,
ringRadii: _ringRadii,
);
} }
void _onPanEnd() { void _onPanEnd() {
setState(() { setState(() {
_isDraggingCenter = false; _isDraggingCenter = false;
_isDraggingRadius = false; _isDraggingRadius = false;
_isDraggingInnerRadius = false;
}); });
} }
@@ -170,21 +251,25 @@ class _CalibrationPainter extends CustomPainter {
final double centerX; final double centerX;
final double centerY; final double centerY;
final double radius; final double radius;
final double innerRadius;
final int ringCount; final int ringCount;
final List<double> ringRadii; final List<double> ringRadii;
final TargetType targetType; final TargetType targetType;
final bool isDraggingCenter; final bool isDraggingCenter;
final bool isDraggingRadius; final bool isDraggingRadius;
final bool isDraggingInnerRadius;
_CalibrationPainter({ _CalibrationPainter({
required this.centerX, required this.centerX,
required this.centerY, required this.centerY,
required this.radius, required this.radius,
required this.innerRadius,
required this.ringCount, required this.ringCount,
required this.ringRadii, required this.ringRadii,
required this.targetType, required this.targetType,
required this.isDraggingCenter, required this.isDraggingCenter,
required this.isDraggingRadius, required this.isDraggingRadius,
required this.isDraggingInnerRadius,
}); });
@override @override
@@ -192,6 +277,7 @@ class _CalibrationPainter extends CustomPainter {
final centerPx = Offset(centerX * size.width, centerY * size.height); final centerPx = Offset(centerX * size.width, centerY * size.height);
final minDim = size.width < size.height ? size.width : size.height; final minDim = size.width < size.height ? size.width : size.height;
final baseRadiusPx = radius * minDim; final baseRadiusPx = radius * minDim;
final innerRadiusPx = innerRadius * minDim;
if (targetType == TargetType.concentric) { if (targetType == TargetType.concentric) {
_drawConcentricZones(canvas, size, centerPx, baseRadiusPx); _drawConcentricZones(canvas, size, centerPx, baseRadiusPx);
@@ -199,17 +285,42 @@ class _CalibrationPainter extends CustomPainter {
_drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx); _drawSilhouetteZones(canvas, size, centerPx, baseRadiusPx);
} }
// Fullscreen crosshairs when dragging center
if (isDraggingCenter) {
final crosshairLinePaint = Paint()
..color = AppTheme.successColor.withValues(alpha: 0.5)
..strokeWidth = 1;
canvas.drawLine(
Offset(0, centerPx.dy),
Offset(size.width, centerPx.dy),
crosshairLinePaint,
);
canvas.drawLine(
Offset(centerPx.dx, 0),
Offset(centerPx.dx, size.height),
crosshairLinePaint,
);
}
// Draw center handle // Draw center handle
_drawCenterHandle(canvas, centerPx); _drawCenterHandle(canvas, centerPx);
// Draw radius handle (for outer ring) // Draw radius handle (for outer ring)
_drawRadiusHandle(canvas, size, centerPx, baseRadiusPx); _drawRadiusHandle(canvas, size, centerPx, baseRadiusPx);
// Draw inner radius handle
_drawInnerRadiusHandle(canvas, size, centerPx, innerRadiusPx);
// Draw instructions // Draw instructions
_drawInstructions(canvas, size); _drawInstructions(canvas, size);
} }
void _drawConcentricZones(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawConcentricZones(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Generate colors for zones // Generate colors for zones
List<Color> zoneColors = []; List<Color> zoneColors = [];
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
@@ -235,7 +346,9 @@ class _CalibrationPainter extends CustomPainter {
// Draw from outside to inside // Draw from outside to inside
for (int i = ringCount - 1; i >= 0; i--) { for (int i = ringCount - 1; i >= 0; i--) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final zoneRadius = baseRadius * ringRadius; final zoneRadius = baseRadius * ringRadius;
zonePaint.color = zoneColors[i]; zonePaint.color = zoneColors[i];
@@ -244,12 +357,12 @@ class _CalibrationPainter extends CustomPainter {
} }
// Draw zone labels (only if within visible area) // Draw zone labels (only if within visible area)
final textPainter = TextPainter( final textPainter = TextPainter(textDirection: TextDirection.ltr);
textDirection: TextDirection.ltr,
);
for (int i = 0; i < ringCount; i++) { for (int i = 0; i < ringCount; i++) {
final ringRadius = ringRadii.length > i ? ringRadii[i] : (i + 1) / ringCount; final ringRadius = ringRadii.length > i
? ringRadii[i]
: (i + 1) / ringCount;
final prevRingRadius = i > 0 final prevRingRadius = i > 0
? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount) ? (ringRadii.length > i - 1 ? ringRadii[i - 1] : i / ringCount)
: 0.0; : 0.0;
@@ -268,9 +381,7 @@ class _CalibrationPainter extends CustomPainter {
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.9),
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
shadows: const [ shadows: const [Shadow(color: Colors.black, blurRadius: 2)],
Shadow(color: Colors.black, blurRadius: 2),
],
), ),
); );
textPainter.layout(); textPainter.layout();
@@ -278,14 +389,24 @@ class _CalibrationPainter extends CustomPainter {
// Draw label on the right side of each zone // Draw label on the right side of each zone
final labelY = center.dy - textPainter.height / 2; final labelY = center.dy - textPainter.height / 2;
if (labelY >= 0 && labelY <= size.height) { if (labelY >= 0 && labelY <= size.height) {
textPainter.paint(canvas, Offset(labelX - textPainter.width / 2, labelY)); textPainter.paint(
canvas,
Offset(labelX - textPainter.width / 2, labelY),
);
} }
} }
} }
void _drawSilhouetteZones(Canvas canvas, Size size, Offset center, double radius) { void _drawSilhouetteZones(
Canvas canvas,
Size size,
Offset center,
double radius,
) {
// Simplified silhouette zones // Simplified silhouette zones
final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 2; final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 2;
// Draw silhouette outline (simplified as rectangle for now) // Draw silhouette outline (simplified as rectangle for now)
final silhouetteWidth = radius * 0.8; final silhouetteWidth = radius * 0.8;
@@ -293,7 +414,11 @@ class _CalibrationPainter extends CustomPainter {
paint.color = Colors.green.withValues(alpha: 0.5); paint.color = Colors.green.withValues(alpha: 0.5);
canvas.drawRect( canvas.drawRect(
Rect.fromCenter(center: center, width: silhouetteWidth, height: silhouetteHeight), Rect.fromCenter(
center: center,
width: silhouetteWidth,
height: silhouetteHeight,
),
paint, paint,
); );
} }
@@ -316,17 +441,36 @@ class _CalibrationPainter extends CustomPainter {
final crossPaint = Paint() final crossPaint = Paint()
..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor ..color = isDraggingCenter ? AppTheme.successColor : AppTheme.primaryColor
..strokeWidth = 2; ..strokeWidth = 2;
canvas.drawLine(Offset(center.dx - 20, center.dy), Offset(center.dx - 8, center.dy), crossPaint); canvas.drawLine(
canvas.drawLine(Offset(center.dx + 8, center.dy), Offset(center.dx + 20, center.dy), crossPaint); Offset(center.dx - 20, center.dy),
canvas.drawLine(Offset(center.dx, center.dy - 20), Offset(center.dx, center.dy - 8), crossPaint); Offset(center.dx - 8, center.dy),
canvas.drawLine(Offset(center.dx, center.dy + 8), Offset(center.dx, center.dy + 20), crossPaint); crossPaint,
);
canvas.drawLine(
Offset(center.dx + 8, center.dy),
Offset(center.dx + 20, center.dy),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy - 20),
Offset(center.dx, center.dy - 8),
crossPaint,
);
canvas.drawLine(
Offset(center.dx, center.dy + 8),
Offset(center.dx, center.dy + 20),
crossPaint,
);
} }
void _drawRadiusHandle(Canvas canvas, Size size, Offset center, double baseRadius) { void _drawRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double baseRadius,
) {
// Radius handle on the right edge of the outermost ring // Radius handle on the right edge of the outermost ring
final outerRingRadius = ringRadii.isNotEmpty ? ringRadii.last : 1.0; final actualHandleX = center.dx + baseRadius;
final actualRadius = baseRadius * outerRingRadius;
final actualHandleX = center.dx + actualRadius;
final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20); final clampedHandleX = actualHandleX.clamp(20.0, size.width - 20);
final clampedHandleY = center.dy.clamp(20.0, size.height - 20); final clampedHandleY = center.dy.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY); final handlePos = Offset(clampedHandleX, clampedHandleY);
@@ -376,7 +520,7 @@ class _CalibrationPainter extends CustomPainter {
// Label // Label
final textPainter = TextPainter( final textPainter = TextPainter(
text: const TextSpan( text: const TextSpan(
text: 'RAYON', text: 'EXT.',
style: TextStyle( style: TextStyle(
color: Colors.white, color: Colors.white,
fontSize: 8, fontSize: 8,
@@ -392,6 +536,78 @@ class _CalibrationPainter extends CustomPainter {
); );
} }
void _drawInnerRadiusHandle(
Canvas canvas,
Size size,
Offset center,
double innerRadiusPx,
) {
// Inner radius handle on the top edge of the innermost ring
final actualHandleY = center.dy - innerRadiusPx;
final clampedHandleX = center.dx.clamp(20.0, size.width - 20);
final clampedHandleY = actualHandleY.clamp(20.0, size.height - 20);
final handlePos = Offset(clampedHandleX, clampedHandleY);
final isClamped = actualHandleY < 20.0;
final paint = Paint()
..color = isDraggingInnerRadius
? AppTheme.successColor
: (isClamped ? Colors.orange : Colors.purpleAccent)
..style = PaintingStyle.fill;
// Draw handle
canvas.drawCircle(handlePos, 14, paint);
// Up/Down arrow indicators
final arrowPaint = Paint()
..color = Colors.white
..strokeWidth = 2
..style = PaintingStyle.stroke;
// Up arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx - 4, handlePos.dy - 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy - 4),
Offset(handlePos.dx + 4, handlePos.dy - 8),
arrowPaint,
);
// Down arrow
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx - 4, handlePos.dy + 8),
arrowPaint,
);
canvas.drawLine(
Offset(handlePos.dx, handlePos.dy + 4),
Offset(handlePos.dx + 4, handlePos.dy + 8),
arrowPaint,
);
// Label
final textPainter = TextPainter(
text: const TextSpan(
text: 'INT.',
style: TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
textDirection: TextDirection.ltr,
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(handlePos.dx - textPainter.width / 2, handlePos.dy - 24),
);
}
void _drawInstructions(Canvas canvas, Size size) { void _drawInstructions(Canvas canvas, Size size) {
const instruction = 'Deplacez le centre ou ajustez le rayon'; const instruction = 'Deplacez le centre ou ajustez le rayon';
@@ -418,9 +634,11 @@ class _CalibrationPainter extends CustomPainter {
return centerX != oldDelegate.centerX || return centerX != oldDelegate.centerX ||
centerY != oldDelegate.centerY || centerY != oldDelegate.centerY ||
radius != oldDelegate.radius || radius != oldDelegate.radius ||
innerRadius != oldDelegate.innerRadius ||
ringCount != oldDelegate.ringCount || ringCount != oldDelegate.ringCount ||
isDraggingCenter != oldDelegate.isDraggingCenter || isDraggingCenter != oldDelegate.isDraggingCenter ||
isDraggingRadius != oldDelegate.isDraggingRadius || isDraggingRadius != oldDelegate.isDraggingRadius ||
isDraggingInnerRadius != oldDelegate.isDraggingInnerRadius ||
ringRadii != oldDelegate.ringRadii; ringRadii != oldDelegate.ringRadii;
} }
} }

View File

@@ -25,6 +25,7 @@ class TargetOverlay extends StatelessWidget {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
const TargetOverlay({ const TargetOverlay({
super.key, super.key,
@@ -42,6 +43,7 @@ class TargetOverlay extends StatelessWidget {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
@@ -72,6 +74,7 @@ class TargetOverlay extends StatelessWidget {
groupingDiameter: groupingDiameter, groupingDiameter: groupingDiameter,
referenceImpacts: referenceImpacts, referenceImpacts: referenceImpacts,
zoomScale: zoomScale, zoomScale: zoomScale,
showRings: showRings,
), ),
child: Stack( child: Stack(
children: shots.map((shot) { children: shots.map((shot) {
@@ -132,6 +135,7 @@ class _TargetOverlayPainter extends CustomPainter {
final double? groupingDiameter; final double? groupingDiameter;
final List<Shot>? referenceImpacts; final List<Shot>? referenceImpacts;
final double zoomScale; final double zoomScale;
final bool showRings;
_TargetOverlayPainter({ _TargetOverlayPainter({
required this.shots, required this.shots,
@@ -146,12 +150,15 @@ class _TargetOverlayPainter extends CustomPainter {
this.groupingDiameter, this.groupingDiameter,
this.referenceImpacts, this.referenceImpacts,
this.zoomScale = 1.0, this.zoomScale = 1.0,
this.showRings = false,
}); });
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
// Draw target center indicator // Draw target center indicator
_drawTargetCenter(canvas, size); if (showRings) {
_drawTargetCenter(canvas, size);
}
// Draw grouping circle // Draw grouping circle
if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) { if (groupingCenterX != null && groupingCenterY != null && groupingDiameter != null && shots.length > 1) {
@@ -371,6 +378,7 @@ class _TargetOverlayPainter extends CustomPainter {
groupingCenterY != oldDelegate.groupingCenterY || groupingCenterY != oldDelegate.groupingCenterY ||
groupingDiameter != oldDelegate.groupingDiameter || groupingDiameter != oldDelegate.groupingDiameter ||
referenceImpacts != oldDelegate.referenceImpacts || referenceImpacts != oldDelegate.referenceImpacts ||
zoomScale != oldDelegate.zoomScale; zoomScale != oldDelegate.zoomScale ||
showRings != oldDelegate.showRings;
} }
} }

View File

@@ -6,13 +6,13 @@
library; library;
import 'dart:io'; import 'dart:io';
import 'package:google_mlkit_document_scanner/google_mlkit_document_scanner.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart'; import '../../core/theme/app_theme.dart';
import '../../data/models/target_type.dart'; import '../../data/models/target_type.dart';
import '../crop/crop_screen.dart'; import '../crop/crop_screen.dart';
import 'widgets/target_type_selector.dart';
import 'widgets/image_source_button.dart'; import 'widgets/image_source_button.dart';
class CaptureScreen extends StatefulWidget { class CaptureScreen extends StatefulWidget {
@@ -31,24 +31,12 @@ class _CaptureScreenState extends State<CaptureScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(title: const Text('Nouvelle Analyse')),
title: const Text('Nouvelle Analyse'),
),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// TODO: une fois la cible de silhouette mise en place, rajouter le selecteur
// Target type selection
// _buildSectionTitle('Type de Cible'),
// const SizedBox(height: 12),
// TargetTypeSelector(
// selectedType: _selectedType,
// onTypeSelected: (type) {
// setState(() => _selectedType = type);
// },
// ),
const SizedBox(height: AppConstants.largePadding), const SizedBox(height: AppConstants.largePadding),
// Image source selection // Image source selection
@@ -59,8 +47,8 @@ class _CaptureScreenState extends State<CaptureScreen> {
Expanded( Expanded(
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.camera_alt, icon: Icons.camera_alt,
label: 'Camera', label: 'Scanner',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.camera), onPressed: _isLoading ? null : _scanDocument,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -68,7 +56,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
child: ImageSourceButton( child: ImageSourceButton(
icon: Icons.photo_library, icon: Icons.photo_library,
label: 'Galerie', label: 'Galerie',
onPressed: _isLoading ? null : () => _captureImage(ImageSource.gallery), onPressed: _isLoading
? null
: () => _captureImage(ImageSource.gallery),
), ),
), ),
], ],
@@ -87,16 +77,15 @@ class _CaptureScreenState extends State<CaptureScreen> {
_buildImagePreview(), _buildImagePreview(),
// Guide text // Guide text
if (_selectedImagePath == null && !_isLoading) if (_selectedImagePath == null && !_isLoading) _buildGuide(),
_buildGuide(),
], ],
), ),
), ),
floatingActionButton: _selectedImagePath != null floatingActionButton: _selectedImagePath != null
? FloatingActionButton.extended( ? FloatingActionButton.extended(
onPressed: _analyzeImage, onPressed: _analyzeImage,
icon: const Icon(Icons.analytics), icon: const Icon(Icons.arrow_forward),
label: const Text('Analyser'), label: const Text('Suivant'),
) )
: null, : null,
); );
@@ -105,9 +94,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
Widget _buildSectionTitle(String title) { Widget _buildSectionTitle(String title) {
return Text( return Text(
title, title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
); );
} }
@@ -160,7 +149,9 @@ class _CaptureScreenState extends State<CaptureScreen> {
Expanded( Expanded(
child: Text( child: Text(
'Assurez-vous que la cible est bien centree et visible.', 'Assurez-vous que la cible est bien centree et visible.',
style: TextStyle(color: AppTheme.warningColor.withValues(alpha: 0.8)), style: TextStyle(
color: AppTheme.warningColor.withValues(alpha: 0.8),
),
), ),
), ),
], ],
@@ -175,20 +166,19 @@ class _CaptureScreenState extends State<CaptureScreen> {
padding: const EdgeInsets.all(AppConstants.defaultPadding), padding: const EdgeInsets.all(AppConstants.defaultPadding),
child: Column( child: Column(
children: [ children: [
Icon( Icon(Icons.help_outline, size: 48, color: Colors.grey[400]),
Icons.help_outline,
size: 48,
color: Colors.grey[400],
),
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
'Conseils pour une bonne analyse', 'Conseils pour une bonne analyse',
style: Theme.of(context).textTheme.titleSmall?.copyWith( style: Theme.of(
fontWeight: FontWeight.bold, context,
), ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_buildGuideItem(Icons.crop_free, 'Cadrez la cible entiere dans l\'image'), _buildGuideItem(
Icons.crop_free,
'Cadrez la cible entiere dans l\'image',
),
_buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'), _buildGuideItem(Icons.wb_sunny, 'Utilisez un bon eclairage'),
_buildGuideItem(Icons.straighten, 'Prenez la photo de face'), _buildGuideItem(Icons.straighten, 'Prenez la photo de face'),
_buildGuideItem(Icons.blur_off, 'Evitez les images floues'), _buildGuideItem(Icons.blur_off, 'Evitez les images floues'),
@@ -211,6 +201,39 @@ class _CaptureScreenState extends State<CaptureScreen> {
); );
} }
Future<void> _scanDocument() async {
setState(() => _isLoading = true);
try {
final options = DocumentScannerOptions(
documentFormat: DocumentFormat.jpeg,
mode: ScannerMode.base,
pageLimit: 1,
isGalleryImport: false,
);
final scanner = DocumentScanner(options: options);
final documents = await scanner.scanDocument();
if (documents.images.isNotEmpty) {
setState(() => _selectedImagePath = documents.images.first);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Erreur lors du scan: $e'),
backgroundColor: AppTheme.errorColor,
),
);
}
} finally {
if (mounted) {
setState(() => _isLoading = false);
}
}
}
Future<void> _captureImage(ImageSource source) async { Future<void> _captureImage(ImageSource source) async {
setState(() => _isLoading = true); setState(() => _isLoading = true);

View File

@@ -119,7 +119,8 @@ class _CropScreenState extends State<CropScreen> {
_viewportSize = Size(constraints.maxWidth, constraints.maxHeight); _viewportSize = Size(constraints.maxWidth, constraints.maxHeight);
// Taille du carré de crop (90% de la plus petite dimension) // Taille du carré de crop (90% de la plus petite dimension)
_cropSize = math.min(constraints.maxWidth, constraints.maxHeight) * 0.85; _cropSize =
math.min(constraints.maxWidth, constraints.maxHeight) * 0.85;
// Calculer l'échelle initiale si pas encore fait // Calculer l'échelle initiale si pas encore fait
if (_scale == 1.0 && _offset == Offset.zero) { if (_scale == 1.0 && _offset == Offset.zero) {
@@ -138,7 +139,7 @@ class _CropScreenState extends State<CropScreen> {
child: Transform( child: Transform(
transform: Matrix4.identity() transform: Matrix4.identity()
..setTranslationRaw(_offset.dx, _offset.dy, 0) ..setTranslationRaw(_offset.dx, _offset.dy, 0)
..scale(_scale, _scale, 1.0), ..scale(_scale, _scale),
alignment: Alignment.center, alignment: Alignment.center,
child: Image.file( child: Image.file(
File(widget.imagePath), File(widget.imagePath),
@@ -153,10 +154,7 @@ class _CropScreenState extends State<CropScreen> {
// Overlay de recadrage // Overlay de recadrage
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
child: CropOverlay( child: CropOverlay(cropSize: _cropSize, showGrid: true),
cropSize: _cropSize,
showGrid: true,
),
), ),
), ),

View File

@@ -13,20 +13,13 @@ class CropOverlay extends StatelessWidget {
/// Afficher la grille des tiers /// Afficher la grille des tiers
final bool showGrid; final bool showGrid;
const CropOverlay({ const CropOverlay({super.key, required this.cropSize, this.showGrid = true});
super.key,
required this.cropSize,
this.showGrid = true,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return CustomPaint( return CustomPaint(
size: Size.infinite, size: Size.infinite,
painter: _CropOverlayPainter( painter: _CropOverlayPainter(cropSize: cropSize, showGrid: showGrid),
cropSize: cropSize,
showGrid: showGrid,
),
); );
} }
} }
@@ -35,10 +28,7 @@ class _CropOverlayPainter extends CustomPainter {
final double cropSize; final double cropSize;
final bool showGrid; final bool showGrid;
_CropOverlayPainter({ _CropOverlayPainter({required this.cropSize, required this.showGrid});
required this.cropSize,
required this.showGrid,
});
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
@@ -77,6 +67,9 @@ class _CropOverlayPainter extends CustomPainter {
if (showGrid) { if (showGrid) {
_drawGrid(canvas, cropRect); _drawGrid(canvas, cropRect);
} }
// Dessiner le point central (croix)
_drawCenterPoint(canvas, cropRect);
} }
void _drawCorners(Canvas canvas, Rect rect) { void _drawCorners(Canvas canvas, Rect rect) {
@@ -171,6 +164,38 @@ class _CropOverlayPainter extends CustomPainter {
); );
} }
void _drawCenterPoint(Canvas canvas, Rect rect) {
final centerPaint = Paint()
..color = Colors.white.withValues(alpha: 0.8)
..style = PaintingStyle.stroke
..strokeWidth = 2;
const size = 10.0;
final centerX = rect.center.dx;
final centerY = rect.center.dy;
// Ligne horizontale
canvas.drawLine(
Offset(centerX - size, centerY),
Offset(centerX + size, centerY),
centerPaint,
);
// Ligne verticale
canvas.drawLine(
Offset(centerX, centerY - size),
Offset(centerX, centerY + size),
centerPaint,
);
// Petit cercle central pour précision (optionnel, mais aide à viser)
canvas.drawCircle(
rect.center,
2,
Paint()..color = Colors.red.withValues(alpha: 0.6),
);
}
@override @override
bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) { bool shouldRepaint(covariant _CropOverlayPainter oldDelegate) {
return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid; return cropSize != oldDelegate.cropSize || showGrid != oldDelegate.showGrid;

View File

@@ -12,6 +12,7 @@ import '../../data/repositories/session_repository.dart';
import '../capture/capture_screen.dart'; import '../capture/capture_screen.dart';
import '../history/history_screen.dart'; import '../history/history_screen.dart';
import '../statistics/statistics_screen.dart'; import '../statistics/statistics_screen.dart';
import '../settings/settings_screen.dart';
import 'widgets/stats_card.dart'; import 'widgets/stats_card.dart';
class HomeScreen extends StatefulWidget { class HomeScreen extends StatefulWidget {
@@ -58,6 +59,11 @@ class _HomeScreenState extends State<HomeScreen> {
onPressed: () => _navigateToHistory(context), onPressed: () => _navigateToHistory(context),
tooltip: 'Historique', tooltip: 'Historique',
), ),
IconButton(
icon: const Icon(Icons.settings),
onPressed: () => _navigateToSettings(context),
tooltip: 'Paramètres',
),
], ],
), ),
body: RefreshIndicator( body: RefreshIndicator(
@@ -148,21 +154,27 @@ class _HomeScreenState extends State<HomeScreen> {
Text( Text(
'Statistiques', 'Statistiques',
style: Theme.of(context).textTheme.titleLarge?.copyWith( style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
// --- BOUTON SESSIONS (Redirige vers Statistiques) ---
Expanded( Expanded(
child: StatsCard( child: InkWell(
icon: Icons.assessment, onTap: () => _navigateToStatistics(context),
title: 'Sessions', borderRadius: BorderRadius.circular(AppConstants.borderRadius),
value: '${_stats!['totalSessions']}', child: StatsCard(
color: AppTheme.primaryColor, icon: Icons.assessment,
title: 'Sessions',
value: '${_stats!['totalSessions']}',
color: AppTheme.primaryColor,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Ce bouton reste statique (ou tu peux ajouter une action)
Expanded( Expanded(
child: StatsCard( child: StatsCard(
icon: Icons.gps_fixed, icon: Icons.gps_fixed,
@@ -176,15 +188,21 @@ class _HomeScreenState extends State<HomeScreen> {
const SizedBox(height: 12), const SizedBox(height: 12),
Row( Row(
children: [ children: [
// --- BOUTON SCORE MOYEN (Redirige vers Historique) ---
Expanded( Expanded(
child: StatsCard( child: InkWell(
icon: Icons.trending_up, onTap: () => _navigateToHistory(context),
title: 'Score Moyen', borderRadius: BorderRadius.circular(AppConstants.borderRadius),
value: (_stats!['averageScore'] as double).toStringAsFixed(1), child: StatsCard(
color: AppTheme.warningColor, icon: Icons.trending_up,
title: 'Historique',
value: (_stats!['averageScore'] as double).toStringAsFixed(1),
color: AppTheme.warningColor,
),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
// Ce bouton reste statique
Expanded( Expanded(
child: StatsCard( child: StatsCard(
icon: Icons.emoji_events, icon: Icons.emoji_events,
@@ -225,4 +243,11 @@ class _HomeScreenState extends State<HomeScreen> {
// Refresh stats when returning // Refresh stats when returning
_loadStats(); _loadStats();
} }
void _navigateToSettings(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute(builder: (_) => const SettingsScreen()),
);
}
} }

View File

@@ -0,0 +1,339 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import '../../core/constants/app_constants.dart';
import '../../core/theme/app_theme.dart';
import '../../services/wallet_identity_service.dart';
class SettingsScreen extends StatefulWidget {
const SettingsScreen({super.key});
@override
State<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
final WalletIdentityService _walletService = WalletIdentityService();
String? _identityPhrase;
int? _photoCount;
bool _isLoadingStats = false;
bool _isUploadEnabled = false;
@override
void initState() {
super.initState();
_loadIdentity();
}
Future<void> _loadIdentity() async {
final phrase = await _walletService.getIdentityPhrase();
final isEnabled = await _walletService.isUploadEnabled();
if (mounted) {
setState(() {
_identityPhrase = phrase;
_isUploadEnabled = isEnabled;
});
_fetchStats(phrase);
}
}
Future<void> _fetchStats(String phrase) async {
setState(() => _isLoadingStats = true);
try {
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost
final baseUrl = Theme.of(context).platform == TargetPlatform.android
? 'http://10.0.2.2:3000'
: 'http://localhost:3000';
final response = await http.get(Uri.parse('$baseUrl/api/stats/$walletHash'));
if (response.statusCode == 200) {
final data = jsonDecode(response.body);
if (mounted) {
setState(() {
_photoCount = data['stats']['photo_count'] ?? 0;
_isLoadingStats = false;
});
}
} else {
if (mounted) setState(() => _isLoadingStats = false);
}
} catch (e) {
print('Erreur lors du chargement des statistiques: $e');
if (mounted) setState(() => _isLoadingStats = false);
}
}
void _copyToClipboard() {
if (_identityPhrase != null) {
Clipboard.setData(ClipboardData(text: _identityPhrase!));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Identité copiée dans le presse-papiers'),
backgroundColor: AppTheme.successColor,
),
);
}
}
void _showIdentityDialog() {
if (_identityPhrase == null) return;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Votre Identité Unique', textAlign: TextAlign.center),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.security, size: 48, color: AppTheme.primaryColor),
const SizedBox(height: 16),
const Text(
'Cette phrase de 15 mots vous identifie de manière unique. Ne la partagez qu\'en cas de besoin.',
textAlign: TextAlign.center,
style: TextStyle(fontSize: 14, color: Colors.grey),
),
const SizedBox(height: 24),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppTheme.primaryColor.withAlpha(100)),
),
child: Text(
_identityPhrase!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
height: 1.5,
),
textAlign: TextAlign.center,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Fermer'),
),
ElevatedButton.icon(
icon: const Icon(Icons.copy, size: 18),
label: const Text('Copier'),
onPressed: () {
_copyToClipboard();
Navigator.pop(context);
},
),
],
),
);
}
void _showOptInDisclaimer(bool value) {
if (!value) {
// Si on désactive, pas besoin de disclaimer, on le fait direct.
_walletService.setUploadEnabled(false);
setState(() {
_isUploadEnabled = false;
});
return;
}
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('Participer à l\'entraînement IA', textAlign: TextAlign.center),
content: const Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.psychology, size: 48, color: AppTheme.primaryColor),
SizedBox(height: 16),
Text(
'En activant cette option, vous acceptez d\'envoyer vos photos de cibles à notre serveur sécurisé.',
style: TextStyle(fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 12),
Text(
'🔒 Anonymat Garanti : L\'envoi est totalement anonymisé. Votre identité est remplacée par un hash cryptographique incassable.',
style: TextStyle(fontSize: 13),
),
SizedBox(height: 12),
Text(
'🎁 Avantages Futurs : Votre contribution (Hash de Wallet) sera comptabilisée. Lors de la sortie publique de notre IA, les participants actifs recevront des fonctionnalités premium ou des badges exclusifs en récompense !',
style: TextStyle(fontSize: 13),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Refuser', style: TextStyle(color: Colors.grey)),
),
ElevatedButton(
style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primaryColor),
onPressed: () {
_walletService.setUploadEnabled(true);
setState(() {
_isUploadEnabled = true;
});
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Merci pour votre contribution !'),
backgroundColor: AppTheme.successColor,
),
);
},
child: const Text('J\'accepte', style: TextStyle(color: Colors.white)),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Paramètres'),
),
body: ListView(
padding: const EdgeInsets.all(AppConstants.defaultPadding),
children: [
_buildSectionHeader('Identité'),
_buildSettingsTile(
context: context,
icon: Icons.fingerprint,
title: 'Identité Wallet',
subtitle: _identityPhrase != null ? 'Phrase de 15 mots générée' : 'Génération en cours...',
onTap: _showIdentityDialog,
),
_buildSettingsTile(
context: context,
icon: Icons.color_lens_outlined,
title: 'Apparence',
subtitle: 'Thème clair/sombre (à venir)',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Fonctionnalité en développement')),
);
},
),
const SizedBox(height: 24),
_buildSectionHeader('Configuration Serveur IA'),
Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: SwitchListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
title: const Text('Participer à l\'entraînement IA', style: TextStyle(fontWeight: FontWeight.w500)),
subtitle: const Text('Aidez-nous à améliorer la détection tout en gagnant des avantages', style: TextStyle(fontSize: 12)),
secondary: const Icon(Icons.psychology, color: AppTheme.textPrimary),
value: _isUploadEnabled,
activeColor: AppTheme.primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onChanged: _showOptInDisclaimer,
),
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.cloud_outlined,
title: 'Adresse du Serveur IA',
subtitle: 'http://localhost:3000/api/upload',
onTap: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Changement d\'adresse à venir')),
);
},
),
if (_isUploadEnabled)
_buildSettingsTile(
context: context,
icon: Icons.data_usage,
title: 'Photos Exportées',
subtitle: _isLoadingStats
? 'Chargement...'
: (_photoCount != null ? '$_photoCount photos envoyées à l\'IA' : 'Non disponible'),
onTap: () {
if (_identityPhrase != null) {
_fetchStats(_identityPhrase!);
}
},
),
const SizedBox(height: 24),
_buildSectionHeader('À propos'),
_buildSettingsTile(
context: context,
icon: Icons.info_outline,
title: 'Version de l\'application',
subtitle: '1.0.0',
onTap: () {},
),
_buildSettingsTile(
context: context,
icon: Icons.privacy_tip_outlined,
title: 'Politique de confidentialité',
onTap: () {},
),
],
),
);
}
Widget _buildSectionHeader(String title) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 4.0),
child: Text(
title,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
color: AppTheme.primaryColor,
),
),
);
}
Widget _buildSettingsTile({
required BuildContext context,
required IconData icon,
required String title,
String? subtitle,
required VoidCallback onTap,
}) {
return Card(
elevation: 0,
color: Colors.transparent,
margin: const EdgeInsets.only(bottom: 8.0),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 4.0),
leading: Icon(icon, color: AppTheme.textPrimary),
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: subtitle != null ? Text(subtitle, style: const TextStyle(fontSize: 12)) : null,
trailing: const Icon(Icons.chevron_right, color: Colors.grey),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.0),
side: BorderSide(color: Colors.grey.withAlpha(50), width: 1),
),
onTap: onTap,
),
);
}
}

View File

@@ -5,7 +5,6 @@
/// écart-type et distribution régionale des tirs. /// écart-type et distribution régionale des tirs.
library; library;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
@@ -69,28 +68,38 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
} }
void _calculateStats() { void _calculateStats() {
debugPrint('Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod'); debugPrint(
'Calculating stats for ${_allSessions.length} sessions, period: $_selectedPeriod',
);
for (final session in _allSessions) { for (final session in _allSessions) {
debugPrint(' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}'); debugPrint(
' Session: ${session.id}, shots: ${session.shots.length}, date: ${session.createdAt}',
);
} }
_statistics = _statisticsService.calculateStatistics( _statistics = _statisticsService.calculateStatistics(
_allSessions, _allSessions,
period: _selectedPeriod, period: _selectedPeriod,
); );
debugPrint('Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}'); debugPrint(
'Statistics result: totalShots=${_statistics?.totalShots}, totalScore=${_statistics?.totalScore}',
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.singleSession != null ? 'Statistiques Session' : 'Statistiques'), title: Text(
widget.singleSession != null
? 'Statistiques Session'
: 'Statistiques',
),
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _statistics == null || _statistics!.totalShots == 0 : _statistics == null || _statistics!.totalShots == 0
? _buildEmptyState() ? _buildEmptyState()
: _buildStatistics(), : _buildStatistics(),
); );
} }
@@ -101,7 +110,11 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.analytics_outlined, size: 64, color: Colors.grey.shade400), Icon(
Icons.analytics_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'Aucune donnee disponible', 'Aucune donnee disponible',
@@ -292,11 +305,17 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: Text('Peu', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), child: Text(
'Peu',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
), ),
Padding( Padding(
padding: const EdgeInsets.only(right: 16), padding: const EdgeInsets.only(right: 16),
child: Text('Beaucoup', style: TextStyle(fontSize: 12, color: Colors.grey.shade600)), child: Text(
'Beaucoup',
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
),
), ),
], ],
), ),
@@ -306,28 +325,6 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
); );
} }
Widget _buildLegendItem(Color color, String label) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
border: Border.all(color: Colors.grey.shade400),
),
),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 10)),
],
),
);
}
Widget _buildPrecisionSection() { Widget _buildPrecisionSection() {
final precision = _statistics!.precision; final precision = _statistics!.precision;
@@ -339,7 +336,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.center_focus_strong, color: AppTheme.successColor), const Icon(
Icons.center_focus_strong,
color: AppTheme.successColor,
),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'Precision', 'Precision',
@@ -368,12 +368,18 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
], ],
), ),
const Divider(height: 32), const Divider(height: 32),
_buildStatRow('Distance moyenne du centre', _buildStatRow(
'${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%'), 'Distance moyenne du centre',
_buildStatRow('Diametre de groupement', '${(precision.avgDistanceFromCenter * 100).toStringAsFixed(1)}%',
'${(precision.groupingDiameter * 100).toStringAsFixed(1)}%'), ),
_buildStatRow('Score moyen', _buildStatRow(
_statistics!.avgScore.toStringAsFixed(2)), 'Diametre de groupement',
'${(precision.groupingDiameter * 100).toStringAsFixed(1)}%',
),
_buildStatRow(
'Score moyen',
_statistics!.avgScore.toStringAsFixed(2),
),
_buildStatRow('Meilleur score', '${_statistics!.maxScore}'), _buildStatRow('Meilleur score', '${_statistics!.maxScore}'),
_buildStatRow('Plus bas score', '${_statistics!.minScore}'), _buildStatRow('Plus bas score', '${_statistics!.minScore}'),
], ],
@@ -386,8 +392,8 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
final color = value > 70 final color = value > 70
? AppTheme.successColor ? AppTheme.successColor
: value > 40 : value > 40
? AppTheme.warningColor ? AppTheme.warningColor
: AppTheme.errorColor; : AppTheme.errorColor;
return Column( return Column(
children: [ children: [
@@ -405,7 +411,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
), ),
Text( Text(
'${value.toStringAsFixed(0)}', value.toStringAsFixed(0),
style: TextStyle( style: TextStyle(
fontSize: 20, fontSize: 20,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
@@ -415,10 +421,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(title, style: const TextStyle(fontWeight: FontWeight.bold)),
title,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Text( Text(
subtitle, subtitle,
style: TextStyle(fontSize: 10, color: Colors.grey.shade600), style: TextStyle(fontSize: 10, color: Colors.grey.shade600),
@@ -439,7 +442,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Row( Row(
children: [ children: [
const Icon(Icons.stacked_line_chart, color: AppTheme.warningColor), const Icon(
Icons.stacked_line_chart,
color: AppTheme.warningColor,
),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text( const Text(
'Ecart Type', 'Ecart Type',
@@ -453,21 +459,32 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
style: TextStyle(color: Colors.grey.shade600, fontSize: 12), style: TextStyle(color: Colors.grey.shade600, fontSize: 12),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildStatRow('Ecart type X (horizontal)', _buildStatRow(
'${(stdDev.stdDevX * 100).toStringAsFixed(2)}%'), 'Ecart type X (horizontal)',
_buildStatRow('Ecart type Y (vertical)', '${(stdDev.stdDevX * 100).toStringAsFixed(2)}%',
'${(stdDev.stdDevY * 100).toStringAsFixed(2)}%'), ),
_buildStatRow('Ecart type radial', _buildStatRow(
'${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%'), 'Ecart type Y (vertical)',
_buildStatRow('Ecart type score', '${(stdDev.stdDevY * 100).toStringAsFixed(2)}%',
stdDev.stdDevScore.toStringAsFixed(2)), ),
_buildStatRow(
'Ecart type radial',
'${(stdDev.stdDevRadial * 100).toStringAsFixed(2)}%',
),
_buildStatRow(
'Ecart type score',
stdDev.stdDevScore.toStringAsFixed(2),
),
const Divider(height: 24), const Divider(height: 24),
_buildStatRow('Position moyenne X', _buildStatRow(
'${(stdDev.meanX * 100).toStringAsFixed(1)}%'), 'Position moyenne X',
_buildStatRow('Position moyenne Y', '${(stdDev.meanX * 100).toStringAsFixed(1)}%',
'${(stdDev.meanY * 100).toStringAsFixed(1)}%'), ),
_buildStatRow('Score moyen', _buildStatRow(
stdDev.meanScore.toStringAsFixed(2)), 'Position moyenne Y',
'${(stdDev.meanY * 100).toStringAsFixed(1)}%',
),
_buildStatRow('Score moyen', stdDev.meanScore.toStringAsFixed(2)),
], ],
), ),
), ),
@@ -504,7 +521,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.compass_calibration, color: AppTheme.primaryColor), const Icon(
Icons.compass_calibration,
color: AppTheme.primaryColor,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -536,7 +556,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
), ),
child: Row( child: Row(
children: [ children: [
const Icon(Icons.warning_amber, color: AppTheme.warningColor), const Icon(
Icons.warning_amber,
color: AppTheme.warningColor,
),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
@@ -556,7 +579,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Sector distribution // Sector distribution
const Text('Repartition par secteur:', style: TextStyle(fontWeight: FontWeight.bold)), const Text(
'Repartition par secteur:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -572,7 +598,10 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
const SizedBox(height: 16), const SizedBox(height: 16),
// Quadrant distribution // Quadrant distribution
const Text('Repartition par quadrant:', style: TextStyle(fontWeight: FontWeight.bold)), const Text(
'Repartition par quadrant:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildQuadrantGrid(regional.quadrantDistribution), _buildQuadrantGrid(regional.quadrantDistribution),
], ],
@@ -598,7 +627,9 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: count > 0 ? AppTheme.primaryColor.withValues(alpha: 0.1) : Colors.grey.shade100, color: count > 0
? AppTheme.primaryColor.withValues(alpha: 0.1)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300, color: count > 0 ? AppTheme.primaryColor : Colors.grey.shade300,
@@ -649,10 +680,7 @@ class _StatisticsScreenState extends State<StatisticsScreen> {
children: [ children: [
Text( Text(
'$count', '$count',
style: const TextStyle( style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24),
fontWeight: FontWeight.bold,
fontSize: 24,
),
), ),
Text( Text(
'${percentage.toStringAsFixed(0)}%', '${percentage.toStringAsFixed(0)}%',
@@ -712,10 +740,7 @@ class _StatCard extends StatelessWidget {
color: color, color: color,
), ),
), ),
Text( Text(title, style: TextStyle(color: Colors.grey.shade600)),
title,
style: TextStyle(color: Colors.grey.shade600),
),
], ],
), ),
), ),

View File

@@ -44,9 +44,7 @@ void main() async {
Provider<GroupingAnalyzerService>( Provider<GroupingAnalyzerService>(
create: (_) => GroupingAnalyzerService(), create: (_) => GroupingAnalyzerService(),
), ),
Provider<SessionRepository>( Provider<SessionRepository>(create: (_) => SessionRepository()),
create: (_) => SessionRepository(),
),
], ],
child: const BullyApp(), child: const BullyApp(),
), ),

View File

@@ -0,0 +1,146 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:device_info_plus/device_info_plus.dart';
import 'package:crypto/crypto.dart';
import '../data/models/shot.dart';
import '../data/models/target_type.dart';
import 'wallet_identity_service.dart';
class AiExportService {
// Utilise 10.0.2.2 pour l'émulateur Android, sinon localhost.
// Pour un appareil physique, il faudra utiliser l'IP locale du PC (ex: 192.168.1.X).
static String get _defaultApiUrl {
if (Platform.isAndroid) {
return 'http://10.0.2.2:3000/api/upload';
}
return 'http://localhost:3000/api/upload';
}
/// Extrait les informations de l'appareil
Future<Map<String, dynamic>> _getDeviceInfo() async {
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
Map<String, dynamic> deviceData = {'model': 'Unknown', 'os': 'Unknown'};
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfoPlugin.androidInfo;
deviceData['model'] = '${androidInfo.brand} ${androidInfo.model}';
deviceData['os'] = 'Android ${androidInfo.version.release}';
} else if (Platform.isIOS) {
final iosInfo = await deviceInfoPlugin.iosInfo;
deviceData['model'] = iosInfo.name;
deviceData['os'] = '${iosInfo.systemName} ${iosInfo.systemVersion}';
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfoPlugin.windowsInfo;
deviceData['model'] = 'Windows PC';
deviceData['os'] = 'Windows ${windowsInfo.majorVersion}.${windowsInfo.minorVersion}';
}
} catch (e) {
print('Erreur lors de la récupération des infos appareil: $e');
}
return deviceData;
}
/// Exporte l'image et les données de plotting vers le serveur
Future<bool> exportData({
required String imagePath,
required String sessionId,
required TargetType targetType,
required double targetCenterX,
required double targetCenterY,
required double targetRadius,
required List<Shot> shots,
String? apiUrl,
}) async {
try {
final url = Uri.parse(apiUrl ?? _defaultApiUrl);
final request = http.MultipartRequest('POST', url);
// 1. Prepare image
final file = File(imagePath);
if (!await file.exists()) {
throw Exception('Le fichier image n\'existe pas');
}
// Read image metadata (approximate dimensions since decoding image can be heavy)
// On the frontend we usually have aspectRatio, here we use generic values if not available.
final deviceData = await _getDeviceInfo();
// We approximate the target corners from center and radius
// radius is relative (0 to 1). We need image width/height to get pixels.
// But we can just pass relative corners as well, or a normalized bounding box.
// Let's create normalized corners (0 to 1).
final corners = [
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY - targetRadius},
{"norm_x": targetCenterX + targetRadius, "norm_y": targetCenterY + targetRadius},
{"norm_x": targetCenterX - targetRadius, "norm_y": targetCenterY + targetRadius},
];
// Format the impacts
final formattedImpacts = shots.asMap().entries.map((entry) {
final index = entry.key;
final shot = entry.value;
return {
"id": index + 1,
"label": "bullet_hole",
"score": shot.score,
"coords": {
"norm_x": shot.x,
"norm_y": shot.y
}
};
}).toList();
// Get and hash the wallet identity
final walletService = WalletIdentityService();
final phrase = await walletService.getIdentityPhrase();
final phraseBytes = utf8.encode(phrase);
final walletHash = sha256.convert(phraseBytes).toString();
// Build JSON payload
final plottingJson = {
"session_id": sessionId,
"wallet_hash": walletHash,
"timestamp": DateTime.now().toIso8601String(),
"device_info": deviceData,
"target_metadata": {
"type": targetType.name,
"distance_meters": 25, // Default/placeholder
"weapon": "Unknown", // Default/placeholder
// The backend could extract exact width/height from the image.
},
"plotting": {
"target_corners": corners,
"impacts": formattedImpacts
}
};
// Add fields to request
request.fields['plotting'] = jsonEncode(plottingJson);
// Add file
request.files.add(
await http.MultipartFile.fromPath('photo', imagePath),
);
// Send request
final response = await request.send();
if (response.statusCode == 200) {
final responseData = await response.stream.bytesToString();
print('Export réussi: $responseData');
return true;
} else {
final errorData = await response.stream.bytesToString();
print('Erreur d\'export: ${response.statusCode} - $errorData');
return false;
}
} catch (e) {
print('Exception lors de l\'export: $e');
return false;
}
}
}

View File

@@ -8,6 +8,7 @@ library;
import 'dart:io'; import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:image/image.dart' as img; import 'package:image/image.dart' as img;
import 'package:opencv_dart/opencv_dart.dart' as cv;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
/// Paramètres de distorsion calculés à partir de la calibration /// Paramètres de distorsion calculés à partir de la calibration
@@ -281,16 +282,56 @@ class DistortionCorrectionService {
final p11 = image.getPixel(x1, y1); final p11 = image.getPixel(x1, y1);
// Interpoler chaque canal // Interpoler chaque canal
final r = _lerp2D(p00.r.toDouble(), p10.r.toDouble(), p01.r.toDouble(), p11.r.toDouble(), wx, wy); final r = _lerp2D(
final g = _lerp2D(p00.g.toDouble(), p10.g.toDouble(), p01.g.toDouble(), p11.g.toDouble(), wx, wy); p00.r.toDouble(),
final b = _lerp2D(p00.b.toDouble(), p10.b.toDouble(), p01.b.toDouble(), p11.b.toDouble(), wx, wy); p10.r.toDouble(),
final a = _lerp2D(p00.a.toDouble(), p10.a.toDouble(), p01.a.toDouble(), p11.a.toDouble(), wx, wy); p01.r.toDouble(),
p11.r.toDouble(),
wx,
wy,
);
final g = _lerp2D(
p00.g.toDouble(),
p10.g.toDouble(),
p01.g.toDouble(),
p11.g.toDouble(),
wx,
wy,
);
final b = _lerp2D(
p00.b.toDouble(),
p10.b.toDouble(),
p01.b.toDouble(),
p11.b.toDouble(),
wx,
wy,
);
final a = _lerp2D(
p00.a.toDouble(),
p10.a.toDouble(),
p01.a.toDouble(),
p11.a.toDouble(),
wx,
wy,
);
return img.ColorRgba8(r.round().clamp(0, 255), g.round().clamp(0, 255), b.round().clamp(0, 255), a.round().clamp(0, 255)); return img.ColorRgba8(
r.round().clamp(0, 255),
g.round().clamp(0, 255),
b.round().clamp(0, 255),
a.round().clamp(0, 255),
);
} }
/// Interpolation linéaire 2D /// Interpolation linéaire 2D
double _lerp2D(double v00, double v10, double v01, double v11, double wx, double wy) { double _lerp2D(
double v00,
double v10,
double v01,
double v11,
double wx,
double wy,
) {
final top = v00 * (1 - wx) + v10 * wx; final top = v00 * (1 - wx) + v10 * wx;
final bottom = v01 * (1 - wx) + v11 * wx; final bottom = v01 * (1 - wx) + v11 * wx;
return top * (1 - wy) + bottom * wy; return top * (1 - wy) + bottom * wy;
@@ -320,7 +361,9 @@ class DistortionCorrectionService {
final height = image.height; final height = image.height;
// Convertir les coordonnées normalisées en pixels // Convertir les coordonnées normalisées en pixels
final srcCorners = corners.map((c) => (x: c.x * width, y: c.y * height)).toList(); final srcCorners = corners
.map((c) => (x: c.x * width, y: c.y * height))
.toList();
// Calculer la taille du rectangle destination // Calculer la taille du rectangle destination
// On prend la moyenne des largeurs et hauteurs // On prend la moyenne des largeurs et hauteurs
@@ -336,20 +379,21 @@ class DistortionCorrectionService {
final result = img.Image(width: dstWidth, height: dstHeight); final result = img.Image(width: dstWidth, height: dstHeight);
// Calculer la matrice de transformation perspective // Calculer la matrice de transformation perspective
final matrix = _computePerspectiveMatrix( final matrix = _computePerspectiveMatrix(srcCorners, [
srcCorners, (x: 0.0, y: 0.0),
[ (x: dstWidth.toDouble(), y: 0.0),
(x: 0.0, y: 0.0), (x: dstWidth.toDouble(), y: dstHeight.toDouble()),
(x: dstWidth.toDouble(), y: 0.0), (x: 0.0, y: dstHeight.toDouble()),
(x: dstWidth.toDouble(), y: dstHeight.toDouble()), ]);
(x: 0.0, y: dstHeight.toDouble()),
],
);
// Appliquer la transformation // Appliquer la transformation
for (int y = 0; y < dstHeight; y++) { for (int y = 0; y < dstHeight; y++) {
for (int x = 0; x < dstWidth; x++) { for (int x = 0; x < dstWidth; x++) {
final src = _applyPerspectiveTransform(matrix, x.toDouble(), y.toDouble()); final src = _applyPerspectiveTransform(
matrix,
x.toDouble(),
y.toDouble(),
);
if (src.x >= 0 && src.x < width && src.y >= 0 && src.y < height) { if (src.x >= 0 && src.x < width && src.y >= 0 && src.y < height) {
final pixel = _bilinearInterpolate(image, src.x, src.y); final pixel = _bilinearInterpolate(image, src.x, src.y);
@@ -408,7 +452,10 @@ class DistortionCorrectionService {
// Le système 'a' est de taille 8x9 (8 équations, 9 inconnues). // Le système 'a' est de taille 8x9 (8 équations, 9 inconnues).
// On fixe h8 = 1.0 pour résoudre le système, ce qui nous donne un système 8x8. // On fixe h8 = 1.0 pour résoudre le système, ce qui nous donne un système 8x8.
final int n = 8; final int n = 8;
final List<List<double>> matrix = List.generate(n, (i) => List<double>.from(a[i])); final List<List<double>> matrix = List.generate(
n,
(i) => List<double>.from(a[i]),
);
// Vecteur B (les constantes de l'autre côté de l'égalité) // Vecteur B (les constantes de l'autre côté de l'égalité)
// Dans DLT, -h8 * dx (ou dy) devient le terme constant. // Dans DLT, -h8 * dx (ou dy) devient le terme constant.
@@ -462,7 +509,11 @@ class DistortionCorrectionService {
return h; return h;
} }
({double x, double y}) _applyPerspectiveTransform(List<double> h, double x, double y) { ({double x, double y}) _applyPerspectiveTransform(
List<double> h,
double x,
double y,
) {
final w = h[6] * x + h[7] * y + h[8]; final w = h[6] * x + h[7] * y + h[8];
if (w.abs() < 1e-10) { if (w.abs() < 1e-10) {
return (x: x, y: y); return (x: x, y: y);
@@ -471,4 +522,553 @@ class DistortionCorrectionService {
final ny = (h[3] * x + h[4] * y + h[5]) / w; final ny = (h[3] * x + h[4] * y + h[5]) / w;
return (x: nx, y: ny); return (x: nx, y: ny);
} }
/// Corrige la perspective en se basant sur la détection de cercles (ellipses)
/// dans l'image.
///
/// Cette méthode tente de détecter l'ellipse la plus proéminente (la cible)
/// et calcule une transformation pour la rendre parfaitement circulaire.
Future<String> correctPerspectiveUsingCircles(String imagePath) async {
try {
// 1. Charger l'image avec OpenCV
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
// 2. Prétraitement
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
// Canny edge detector avec seuil adaptatif (Otsu)
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
// 3. Trouver les contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath; // Pas de contours trouvés
// 4. Trouver le meilleur candidat ellipse
cv.RotatedRect? bestEllipse;
double maxArea = 0;
for (final contour in contours) {
if (contour.length < 5)
continue; // fitEllipse nécessite au moins 5 points
final area = cv.contourArea(contour);
if (area < 1000) continue; // Ignorer les trop petits bruits
final ellipse = cv.fitEllipse(contour);
// Critère de sélection: on cherche la plus grande ellipse qui est proche d'un cercle
// Mais comme on veut corriger la distorsion, elle PEUT être aplatie.
// Donc on prend juste la plus grande ellipse raisonnablement centrée.
if (area > maxArea) {
maxArea = area;
bestEllipse = ellipse;
}
}
if (bestEllipse == null) return imagePath;
// 5. Calculer la transformation perspective
// L'idée est de mapper les 4 sommets de l'ellipse détectée vers un cercle parfait.
// Ou plus simplement, mapper le rectangle englobant de l'ellipse vers un carré.
// Points source: les 4 coins du rotated rect de l'ellipse
// Note: opencv_dart RotatedRect points() non dispo directement?
// On peut utiliser boxPoints(ellipse)
final boxPoints = cv.boxPoints(bestEllipse);
// boxPoints returns Mat (4x2 float32)
// Extraire les 4 points
final List<cv.Point> srcPoints = [];
for (int i = 0; i < boxPoints.length; i++) {
// On accède directement au point à l'index i
final point2f = boxPoints[i];
// On convertit les coordonnées float en int pour cv.Point
srcPoints.add(cv.Point(point2f.x.toInt(), point2f.y.toInt()));
}
// Trier les points pour avoir: TL, TR, BR, BL
_sortPoints(srcPoints);
// Dimensions cibles
final side = math
.max(bestEllipse.size.width, bestEllipse.size.height)
.toInt();
final List<cv.Point> dstPoints = [
cv.Point(0, 0),
cv.Point(side, 0),
cv.Point(side, side),
cv.Point(0, side),
];
// Matrice de perspective
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
// 6. Warper l'image
final corrected = cv.warpPerspective(src, M, (side, side));
// 7. Sauvegarder
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_circle_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
// En cas d'erreur, retourner l'image originale
print('Erreur correction perspective cercles: $e');
return imagePath;
}
}
/// Trie les points dans l'ordre: Top-Left, Top-Right, Bottom-Right, Bottom-Left
void _sortPoints(List<cv.Point> points) {
// Calculer le centre de gravité
double cx = 0;
double cy = 0;
for (final p in points) {
cx += p.x;
cy += p.y;
}
cx /= points.length;
cy /= points.length;
points.sort((a, b) {
// Trier par angle autour du centre
final angleA = math.atan2(a.y - cy, a.x - cx);
final angleB = math.atan2(b.y - cy, b.x - cx);
return angleA.compareTo(angleB);
});
// Re-trier pour être sûr:
points.sort((a, b) => (a.y + a.x).compareTo(b.y + b.x));
final tl = points[0];
final br = points[3];
// Reste tr et bl
final remaining = [points[1], points[2]];
remaining.sort((a, b) => a.x.compareTo(b.x));
final bl = remaining[0];
final tr = remaining[1];
points[0] = tl;
points[1] = tr;
points[2] = br;
points[3] = bl;
}
/// Corrige la perspective en reformant le plus grand ovale (ellipse) en un cercle parfait,
/// sans recadrer agressivement l'image entière.
Future<String> correctPerspectiveUsingOvals(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath;
cv.RotatedRect? bestEllipse;
double maxArea = 0;
for (final contour in contours) {
if (contour.length < 5) continue;
final area = cv.contourArea(contour);
if (area < 1000) continue;
final ellipse = cv.fitEllipse(contour);
if (area > maxArea) {
maxArea = area;
bestEllipse = ellipse;
}
}
if (bestEllipse == null) return imagePath;
// The goal here is to morph the bestEllipse into a perfect circle, while
// keeping the image the same size and the center of the ellipse in the same place.
// We'll use the average of the width and height (or max) to define the target circle
final targetRadius =
math.max(bestEllipse.size.width, bestEllipse.size.height) / 2.0;
// Extract the 4 bounding box points of the ellipse
final boxPoints = cv.boxPoints(bestEllipse);
final List<cv.Point> srcPoints = [];
for (int i = 0; i < boxPoints.length; i++) {
srcPoints.add(cv.Point(boxPoints[i].x.toInt(), boxPoints[i].y.toInt()));
}
_sortPoints(srcPoints);
// Calculate the size of the perfectly squared output image
final int side = (targetRadius * 2).toInt();
final List<cv.Point> dstPoints = [
cv.Point(0, 0), // Top-Left
cv.Point(side, 0), // Top-Right
cv.Point(side, side), // Bottom-Right
cv.Point(0, side), // Bottom-Left
];
// Morph the target region into a perfect square, cropping the rest of the image
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_oval_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective ovales: $e');
return imagePath;
}
}
/// Corrige la distorsion et la profondeur (perspective) en créant un maillage
/// basé sur la concentricité des différents cercles de la cible pour trouver le meilleur plan.
Future<String> correctPerspectiveWithConcentricMesh(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
final blurred = cv.gaussianBlur(gray, (5, 5), 0);
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
final contoursResult = cv.findContours(
edges,
cv.RETR_LIST,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
if (contours.isEmpty) return imagePath;
List<cv.RotatedRect> ellipses = [];
for (final contour in contours) {
if (contour.length < 5) continue;
if (cv.contourArea(contour) < 500) continue;
ellipses.add(cv.fitEllipse(contour));
}
if (ellipses.isEmpty) return imagePath;
// Find the largest ellipse to serve as our central reference
ellipses.sort(
(a, b) => (b.size.width * b.size.height).compareTo(
a.size.width * a.size.height,
),
);
final largestEllipse = ellipses.first;
final maxDist =
math.max(largestEllipse.size.width, largestEllipse.size.height) *
0.15;
// Group all ellipses that are roughly concentric with the largest one
List<cv.RotatedRect> concentricGroup = [];
for (final e in ellipses) {
final dx = e.center.x - largestEllipse.center.x;
final dy = e.center.y - largestEllipse.center.y;
if (math.sqrt(dx * dx + dy * dy) < maxDist) {
concentricGroup.add(e);
}
}
if (concentricGroup.length < 2) {
print(
"Pas assez de cercles concentriques pour le maillage, utilisation de la méthode simple.",
);
return await correctPerspectiveUsingOvals(imagePath);
}
final targetRadius =
math.max(largestEllipse.size.width, largestEllipse.size.height) / 2.0;
final int side = (targetRadius * 2.4).toInt(); // Add padding
final double cx = side / 2.0;
final double cy = side / 2.0;
List<cv.Point2f> srcPointsList = [];
List<cv.Point2f> dstPointsList = [];
for (final ellipse in concentricGroup) {
final box = cv.boxPoints(ellipse);
final m0 = cv.Point2f(
(box[0].x + box[1].x) / 2,
(box[0].y + box[1].y) / 2,
);
final m1 = cv.Point2f(
(box[1].x + box[2].x) / 2,
(box[1].y + box[2].y) / 2,
);
final m2 = cv.Point2f(
(box[2].x + box[3].x) / 2,
(box[2].y + box[3].y) / 2,
);
final m3 = cv.Point2f(
(box[3].x + box[0].x) / 2,
(box[3].y + box[0].y) / 2,
);
final d02 = math.sqrt(
math.pow(m0.x - m2.x, 2) + math.pow(m0.y - m2.y, 2),
);
final d13 = math.sqrt(
math.pow(m1.x - m3.x, 2) + math.pow(m1.y - m3.y, 2),
);
cv.Point2f maj1, maj2, min1, min2;
double r;
if (d02 > d13) {
maj1 = m0;
maj2 = m2;
min1 = m1;
min2 = m3;
r = d02 / 2.0;
} else {
maj1 = m1;
maj2 = m3;
min1 = m0;
min2 = m2;
r = d13 / 2.0;
}
// Sort maj1 and maj2 so maj1 is left/top
if ((maj1.x - maj2.x).abs() > (maj1.y - maj2.y).abs()) {
if (maj1.x > maj2.x) {
final t = maj1;
maj1 = maj2;
maj2 = t;
}
} else {
if (maj1.y > maj2.y) {
final t = maj1;
maj1 = maj2;
maj2 = t;
}
}
// Sort min1 and min2 so min1 is top/left
if ((min1.y - min2.y).abs() > (min1.x - min2.x).abs()) {
if (min1.y > min2.y) {
final t = min1;
min1 = min2;
min2 = t;
}
} else {
if (min1.x > min2.x) {
final t = min1;
min1 = min2;
min2 = t;
}
}
srcPointsList.addAll([maj1, maj2, min1, min2]);
dstPointsList.addAll([
cv.Point2f(cx - r, cy),
cv.Point2f(cx + r, cy),
cv.Point2f(cx, cy - r),
cv.Point2f(cx, cy + r),
]);
// Add ellipse centers mapping perfectly to the origin to force concentric depth alignment
srcPointsList.add(cv.Point2f(ellipse.center.x, ellipse.center.y));
dstPointsList.add(cv.Point2f(cx, cy));
}
// We explicitly convert points to VecPoint to use findHomography standard binding
final srcVec = cv.VecPoint.fromList(
srcPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(),
);
final dstVec = cv.VecPoint.fromList(
dstPointsList.map((p) => cv.Point(p.x.toInt(), p.y.toInt())).toList(),
);
final M = cv.findHomography(
cv.Mat.fromVec(srcVec),
cv.Mat.fromVec(dstVec),
method: cv.RANSAC,
);
if (M.isEmpty) {
return await correctPerspectiveUsingOvals(imagePath);
}
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_mesh_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective maillage concentrique: $e');
return imagePath;
}
}
/// Corrige la perspective en détectant les 4 coins de la feuille (quadrilatère)
///
/// Cette méthode cherche le plus grand polygone à 4 côtés (le bord du papier)
/// et le déforme pour en faire un carré parfait.
Future<String> correctPerspectiveUsingQuadrilateral(String imagePath) async {
try {
final src = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (src.isEmpty) throw Exception("Impossible de charger l'image");
final gray = cv.cvtColor(src, cv.COLOR_BGR2GRAY);
// Flou plus important pour ignorer les détails internes (cercles, trous)
final blurred = cv.gaussianBlur(gray, (9, 9), 0);
// Canny edge detector
final thresh = cv.threshold(
blurred,
0,
255,
cv.THRESH_BINARY | cv.THRESH_OTSU,
);
final edges = cv.canny(blurred, thresh.$1 * 0.5, thresh.$1);
// Pour la détection de la feuille (les bords peuvent être discontinus à cause de l'éclairage)
final kernel = cv.getStructuringElement(cv.MORPH_RECT, (5, 5));
final closedEdges = cv.morphologyEx(edges, cv.MORPH_CLOSE, kernel);
// Find contours
final contoursResult = cv.findContours(
closedEdges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
cv.VecPoint? bestQuad;
double maxArea = 0;
final minArea = src.rows * src.cols * 0.1; // Au moins 10% de l'image
for (final contour in contours) {
final area = cv.contourArea(contour);
if (area < minArea) continue;
final peri = cv.arcLength(contour, true);
// Approximation polygonale (tolérance = 2% à 5% du périmètre)
final approx = cv.approxPolyDP(contour, 0.04 * peri, true);
if (approx.length == 4) {
if (area > maxArea) {
maxArea = area;
bestQuad = approx;
}
}
}
// Fallback
if (bestQuad == null) {
print(
"Aucun papier quadrilatère détecté, on utilise les cercles à la place.",
);
return await correctPerspectiveUsingCircles(imagePath);
}
// Convert to List<cv.Point>
final List<cv.Point> srcPoints = [];
for (int i = 0; i < bestQuad.length; i++) {
srcPoints.add(bestQuad[i]);
}
_sortPoints(srcPoints);
// Calculate max width and height
double widthA = _distanceCV(srcPoints[2], srcPoints[3]);
double widthB = _distanceCV(srcPoints[1], srcPoints[0]);
int dstWidth = math.max(widthA, widthB).toInt();
double heightA = _distanceCV(srcPoints[1], srcPoints[2]);
double heightB = _distanceCV(srcPoints[0], srcPoints[3]);
int dstHeight = math.max(heightA, heightB).toInt();
// Since standard target paper forms a square, we force the resulting warp to be a perfect square.
int side = math.max(dstWidth, dstHeight);
final List<cv.Point> dstPoints = [
cv.Point(0, 0),
cv.Point(side, 0),
cv.Point(side, side),
cv.Point(0, side),
];
final M = cv.getPerspectiveTransform(
cv.VecPoint.fromList(srcPoints),
cv.VecPoint.fromList(dstPoints),
);
final corrected = cv.warpPerspective(src, M, (side, side));
final tempDir = await getTemporaryDirectory();
final timestamp = DateTime.now().millisecondsSinceEpoch;
final outputPath = '${tempDir.path}/corrected_quad_$timestamp.jpg';
cv.imwrite(outputPath, corrected);
return outputPath;
} catch (e) {
print('Erreur correction perspective quadrilatère: $e');
// Fallback
return await correctPerspectiveUsingCircles(imagePath);
}
}
double _distanceCV(cv.Point p1, cv.Point p2) {
final dx = p2.x - p1.x;
final dy = p2.y - p1.y;
return math.sqrt(dx * dx + dy * dy);
}
} }

View File

@@ -1,13 +1,8 @@
/// Service de détection d'impacts utilisant OpenCV. /// Service de détection d'impacts utilisant OpenCV.
///
/// NOTE: OpenCV est actuellement désactivé sur Windows en raison de problèmes
/// de compilation. Ce fichier contient des stubs qui permettent au code de
/// compiler sans OpenCV. Réactiver opencv_dart dans pubspec.yaml et
/// décommenter le code ci-dessous quand le support sera corrigé.
library; library;
// import 'dart:math' as math; import 'dart:math' as math;
// import 'package:opencv_dart/opencv_dart.dart' as cv; import 'package:opencv_dart/opencv_dart.dart' as cv;
/// Paramètres de détection d'impacts OpenCV /// Paramètres de détection d'impacts OpenCV
class OpenCVDetectionSettings { class OpenCVDetectionSettings {
@@ -90,30 +85,144 @@ class OpenCVDetectedImpact {
} }
/// Service de détection d'impacts utilisant OpenCV /// Service de détection d'impacts utilisant OpenCV
///
/// NOTE: Actuellement désactivé - retourne des listes vides.
/// OpenCV n'est pas disponible sur Windows pour le moment.
class OpenCVImpactDetectionService { class OpenCVImpactDetectionService {
/// Détecte les impacts dans une image en utilisant OpenCV /// Détecte les impacts dans une image en utilisant OpenCV
///
/// STUB: Retourne une liste vide car OpenCV est désactivé.
List<OpenCVDetectedImpact> detectImpacts( List<OpenCVDetectedImpact> detectImpacts(
String imagePath, { String imagePath, {
OpenCVDetectionSettings settings = const OpenCVDetectionSettings(), OpenCVDetectionSettings settings = const OpenCVDetectionSettings(),
}) { }) {
print('OpenCV est désactivé - utilisation de la détection classique recommandée'); try {
return []; final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) return [];
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply blur to reduce noise
final blurKSize = (settings.blurSize, settings.blurSize);
final blurred = cv.gaussianBlur(gray, blurKSize, 2, sigmaY: 2);
final List<OpenCVDetectedImpact> detectedImpacts = [];
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
settings.minDist,
param1: settings.param1,
param2: settings.param2,
minRadius: settings.minRadius,
maxRadius: settings.maxRadius,
);
if (circles.rows > 0 && circles.cols > 0) {
// Mat shape: (1, N, 3) usually for HoughCircles (CV_32FC3)
// We use at<Vec3f> directly.
for (int i = 0; i < circles.cols; i++) {
final vec = circles.at<cv.Vec3f>(0, i);
final x = vec.val1;
final y = vec.val2;
final r = vec.val3;
detectedImpacts.add(
OpenCVDetectedImpact(
x: x / img.cols,
y: y / img.rows,
radius: r,
confidence: 0.8,
method: 'hough',
),
);
}
}
// 2. Contour Detection (if enabled)
if (settings.useContourDetection) {
// Canny edge detection
final edges = cv.canny(
blurred,
settings.cannyThreshold1,
settings.cannyThreshold2,
);
// Find contours
final contoursResult = cv.findContours(
edges,
cv.RETR_EXTERNAL,
cv.CHAIN_APPROX_SIMPLE,
);
final contours = contoursResult.$1;
// hierarchy is $2
for (int i = 0; i < contours.length; i++) {
final contour = contours[i];
// Filter by area
final area = cv.contourArea(contour);
if (area < settings.minContourArea ||
area > settings.maxContourArea) {
continue;
}
// Filter by circularity
final perimeter = cv.arcLength(contour, true);
if (perimeter == 0) continue;
final circularity = 4 * math.pi * area / (perimeter * perimeter);
if (circularity < settings.minCircularity) continue;
// Get bounding circle
final enclosingCircle = cv.minEnclosingCircle(contour);
final center = enclosingCircle.$1;
final radius = enclosingCircle.$2;
// Avoid duplicates (simple distance check against Hough results)
bool isDuplicate = false;
for (final existing in detectedImpacts) {
final dx = existing.x * img.cols - center.x;
final dy = existing.y * img.rows - center.y;
final dist = math.sqrt(dx * dx + dy * dy);
if (dist < radius) {
isDuplicate = true;
break;
}
}
if (!isDuplicate) {
detectedImpacts.add(
OpenCVDetectedImpact(
x: center.x / img.cols,
y: center.y / img.rows,
radius: radius,
confidence: circularity, // Use circularity as confidence
method: 'contour',
),
);
}
}
}
return detectedImpacts;
} catch (e) {
// print('OpenCV Error: $e');
return [];
}
} }
/// Détecte les impacts en utilisant une image de référence /// Détecte les impacts en utilisant une image de référence
///
/// STUB: Retourne une liste vide car OpenCV est désactivé.
List<OpenCVDetectedImpact> detectFromReferences( List<OpenCVDetectedImpact> detectFromReferences(
String imagePath, String imagePath,
List<({double x, double y})> referencePoints, { List<({double x, double y})> referencePoints, {
double tolerance = 2.0, double tolerance = 2.0,
}) { }) {
print('OpenCV est désactivé - utilisation de la détection par références classique recommandée'); // Basic implementation: use average color/brightness of reference points
return []; // This is a placeholder for a more complex template matching or feature matching
// For now, we can just run the standard detection but filter results
// based on properties of the reference points (e.g. size/radius if we had it).
// Returning standard detection for now to enable the feature.
return detectImpacts(imagePath);
} }
} }

View File

@@ -0,0 +1,240 @@
import 'dart:math' as math;
import 'package:opencv_dart/opencv_dart.dart' as cv;
class TargetDetectionResult {
final double centerX;
final double centerY;
final double radius;
final bool success;
TargetDetectionResult({
required this.centerX,
required this.centerY,
required this.radius,
this.success = true,
});
factory TargetDetectionResult.failure() {
return TargetDetectionResult(
centerX: 0.5,
centerY: 0.5,
radius: 0.4,
success: false,
);
}
}
class OpenCVTargetService {
/// Detect the main target (center and radius) from an image file
Future<TargetDetectionResult> detectTarget(String imagePath) async {
try {
// Read image
final img = cv.imread(imagePath, flags: cv.IMREAD_COLOR);
if (img.isEmpty) {
return TargetDetectionResult.failure();
}
// Convert to grayscale
final gray = cv.cvtColor(img, cv.COLOR_BGR2GRAY);
// Apply Gaussian blur to reduce noise
final blurred = cv.gaussianBlur(gray, (9, 9), 2, sigmaY: 2);
// Detect circles using Hough Transform
// Parameters need to be tuned for the specific target type
final circles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1, // dp
(img.rows / 16)
.toDouble(), // minDist decreased to allow more rings in same general area
param1: 100, // Canny edge detection
param2:
60, // Accumulator threshold (higher = fewer false circles, more accurate)
minRadius: img.cols ~/ 20,
maxRadius: img.cols ~/ 2,
);
// HoughCircles returns a Mat of shape (1, N, 3) where N is number of circles.
// In opencv_dart, we cannot iterate easily.
// However, we can access data via pointer if needed, or check if Vec3f is supported.
// Given the user report, `at<Vec3f>` likely failed compilation or runtime.
// Let's use a safer approach: assume standard memory layout (x, y, r, x, y, r...).
// Or use `at<double>` carefully.
// Better yet: try to use `circles.data` if available, but it returns a Pointer.
// Let's stick to `at` but use `double` and manual offset if Vec3f fails.
// actually, let's try to trust `at<double>` for flattened access OR `at<Vec3f>`.
// NOTE: `at<Vec3f>` was reported as "method at not defined for VecPoint2f" earlier, NOT for Mat.
// The user error was for `VecPoint2f`. `Mat` definitely has `at`.
// BUT `VecPoint2f` is a List-like structure in Dart wrapper.
// usage of `at` on `VecPoint2f` was the error.
// Here `circles` IS A MAT. So `at` IS defined.
// However, to be safe and robust, and to implement clustering...
if (circles.isEmpty) {
// Try with different parameters if first attempt fails (more lenient)
final looseCircles = cv.HoughCircles(
blurred,
cv.HOUGH_GRADIENT,
1,
(img.rows / 8).toDouble(),
param1: 100,
param2: 40,
minRadius: img.cols ~/ 20,
maxRadius: img.cols ~/ 2,
);
if (looseCircles.isEmpty) {
return TargetDetectionResult.failure();
}
return _findBestConcentricCircles(looseCircles, img.cols, img.rows);
}
return _findBestConcentricCircles(circles, img.cols, img.rows);
} catch (e) {
// print('Error detecting target with OpenCV: $e');
return TargetDetectionResult.failure();
}
}
TargetDetectionResult _findBestConcentricCircles(
cv.Mat circles,
int width,
int height,
) {
if (circles.rows == 0 || circles.cols == 0) {
return TargetDetectionResult.failure();
}
final int numCircles = circles.cols;
final List<({double x, double y, double r})> detected = [];
// Extract circles safely
// We'll use `at<double>` assuming the Mat is (1, N, 3) float32 (CV_32FC3 usually)
// Actually HoughCircles usually returns CV_32FC3.
// So we can access `at<cv.Vec3f>(0, i)`.
// If that fails, we can fall back. But since `Mat` has `at`, it should work unless generic is bad.
// Let's assume it works for Mat but checking boundaries.
// NOTE: If this throws "at not defined" (unlikely for Mat), we'd need another way.
// But since the previous error was on `VecPoint2f` (which is NOT a Mat), this should be fine.
for (int i = 0; i < numCircles; i++) {
// Access using Vec3f if possible, or try to interpret memory
// Using `at<cv.Vec3f>` is the standard way.
final vec = circles.at<cv.Vec3f>(0, i);
detected.add((x: vec.val1, y: vec.val2, r: vec.val3));
}
if (detected.isEmpty) return TargetDetectionResult.failure();
// Cluster circles by center position
// We consider circles "concentric" if their centers are within 5% of image min dimension
final double tolerance = math.min(width, height) * 0.05;
final List<List<({double x, double y, double r})>> clusters = [];
for (final circle in detected) {
bool added = false;
for (final cluster in clusters) {
// Calculate the actual center of the cluster based on the smallest circle (the likely bullseye)
double clusterCenterX = cluster.first.x;
double clusterCenterY = cluster.first.y;
double minRadiusInCluster = cluster.first.r;
for (final c in cluster) {
if (c.r < minRadiusInCluster) {
minRadiusInCluster = c.r;
clusterCenterX = c.x;
clusterCenterY = c.y;
}
}
final dist = math.sqrt(
math.pow(circle.x - clusterCenterX, 2) +
math.pow(circle.y - clusterCenterY, 2),
);
if (dist < tolerance) {
cluster.add(circle);
added = true;
break;
}
}
if (!added) {
clusters.add([circle]);
}
}
// Find the best cluster
// 1. Prefer clusters with more circles (concentric rings)
// 2. Tie-break: closest to image center
List<({double x, double y, double r})> bestCluster = clusters.first;
double bestScore = -1.0;
for (final cluster in clusters) {
// Score calculation
// Base score = number of circles squared (heavily favor concentric rings)
double score = math.pow(cluster.length, 2).toDouble() * 10.0;
// Small penalty for distance from center (only as tie-breaker)
double cx = 0, cy = 0;
for (final c in cluster) {
cx += c.x;
cy += c.y;
}
cx /= cluster.length;
cy /= cluster.length;
final distFromCenter = math.sqrt(
math.pow(cx - width / 2, 2) + math.pow(cy - height / 2, 2),
);
final relDist = distFromCenter / math.min(width, height);
score -=
relDist * 2.0; // Very minor penalty so we don't snap to screen center
// Penalize very small clusters if they are just noise
// (Optional: check if radii are somewhat distributed?)
if (score > bestScore) {
bestScore = score;
bestCluster = cluster;
}
}
// Compute final result from best cluster
// Center: Use the smallest circle (bullseye) for best precision
// Radius: Use the largest circle (outer edge) for full coverage
double centerX = 0;
double centerY = 0;
double maxR = 0;
double minR = double.infinity;
for (final c in bestCluster) {
if (c.r > maxR) {
maxR = c.r;
}
if (c.r < minR) {
minR = c.r;
centerX = c.x;
centerY = c.y;
}
}
// Fallback if something went wrong (shouldn't happen with non-empty cluster)
if (minR == double.infinity) {
centerX = bestCluster.first.x;
centerY = bestCluster.first.y;
}
return TargetDetectionResult(
centerX: centerX / width,
centerY: centerY / height,
radius: maxR / math.min(width, height),
success: true,
);
}
}

View File

@@ -2,9 +2,10 @@ import 'dart:math' as math;
import '../data/models/target_type.dart'; import '../data/models/target_type.dart';
import 'image_processing_service.dart'; import 'image_processing_service.dart';
import 'opencv_impact_detection_service.dart'; import 'opencv_impact_detection_service.dart';
export 'image_processing_service.dart'
export 'image_processing_service.dart' show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics; show ImpactDetectionSettings, ReferenceImpact, ImpactCharacteristics;
export 'opencv_impact_detection_service.dart' show OpenCVDetectionSettings, OpenCVDetectedImpact; export 'opencv_impact_detection_service.dart'
show OpenCVDetectionSettings, OpenCVDetectedImpact;
class TargetDetectionResult { class TargetDetectionResult {
final double centerX; // Relative (0-1) final double centerX; // Relative (0-1)
@@ -56,14 +57,12 @@ class TargetDetectionService {
TargetDetectionService({ TargetDetectionService({
ImageProcessingService? imageProcessingService, ImageProcessingService? imageProcessingService,
OpenCVImpactDetectionService? opencvService, OpenCVImpactDetectionService? opencvService,
}) : _imageProcessingService = imageProcessingService ?? ImageProcessingService(), }) : _imageProcessingService =
_opencvService = opencvService ?? OpenCVImpactDetectionService(); imageProcessingService ?? ImageProcessingService(),
_opencvService = opencvService ?? OpenCVImpactDetectionService();
/// Detect target and impacts from an image file /// Detect target and impacts from an image file
TargetDetectionResult detectTarget( TargetDetectionResult detectTarget(String imagePath, TargetType targetType) {
String imagePath,
TargetType targetType,
) {
try { try {
// Detect main target // Detect main target
final mainTarget = _imageProcessingService.detectMainTarget(imagePath); final mainTarget = _imageProcessingService.detectMainTarget(imagePath);
@@ -84,7 +83,13 @@ class TargetDetectionService {
// Convert impacts to relative coordinates and calculate scores // Convert impacts to relative coordinates and calculate scores
final detectedImpacts = impacts.map((impact) { final detectedImpacts = impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScore(impact.x, impact.y, centerX, centerY, radius) ? _calculateConcentricScore(
impact.x,
impact.y,
centerX,
centerY,
radius,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -149,9 +154,9 @@ class TargetDetectionService {
// Vertical zones // Vertical zones
if (dy < -0.25) return 5; // Head zone (top) if (dy < -0.25) return 5; // Head zone (top)
if (dy < 0.0) return 5; // Center mass (upper body) if (dy < 0.0) return 5; // Center mass (upper body)
if (dy < 0.15) return 4; // Body if (dy < 0.15) return 4; // Body
if (dy < 0.35) return 3; // Lower body if (dy < 0.35) return 3; // Lower body
return 0; // Outside target return 0; // Outside target
} }
@@ -177,7 +182,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -221,7 +232,10 @@ class TargetDetectionService {
String imagePath, String imagePath,
List<ReferenceImpact> references, List<ReferenceImpact> references,
) { ) {
return _imageProcessingService.analyzeReferenceImpacts(imagePath, references); return _imageProcessingService.analyzeReferenceImpacts(
imagePath,
references,
);
} }
/// Detect impacts based on reference characteristics (calibrated detection) /// Detect impacts based on reference characteristics (calibrated detection)
@@ -245,7 +259,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -283,7 +303,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -315,9 +341,7 @@ class TargetDetectionService {
}) { }) {
try { try {
// Convertir les références au format OpenCV // Convertir les références au format OpenCV
final refPoints = references final refPoints = references.map((r) => (x: r.x, y: r.y)).toList();
.map((r) => (x: r.x, y: r.y))
.toList();
final impacts = _opencvService.detectFromReferences( final impacts = _opencvService.detectFromReferences(
imagePath, imagePath,
@@ -328,7 +352,13 @@ class TargetDetectionService {
return impacts.map((impact) { return impacts.map((impact) {
final score = targetType == TargetType.concentric final score = targetType == TargetType.concentric
? _calculateConcentricScoreWithRings( ? _calculateConcentricScoreWithRings(
impact.x, impact.y, centerX, centerY, radius, ringCount) impact.x,
impact.y,
centerX,
centerY,
radius,
ringCount,
)
: _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY); : _calculateSilhouetteScore(impact.x, impact.y, centerX, centerY);
return DetectedImpactResult( return DetectedImpactResult(
@@ -343,4 +373,5 @@ class TargetDetectionService {
return []; return [];
} }
} }
} }

View File

@@ -0,0 +1,112 @@
import 'dart:math';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'dart:io';
class WalletIdentityService {
static const String _prefsKey = 'wallet_identity_phrase';
static const String _uploadEnabledKey = 'is_ai_upload_enabled';
// A standard list of 256 words (8 bits of entropy per word)
static const List<String> _wordList = [
// ... same as before
'abandon', 'ability', 'able', 'about', 'above', 'absent', 'absorb', 'abstract', 'absurd', 'abuse',
'access', 'accident', 'account', 'accuse', 'achieve', 'acid', 'acoustic', 'acquire', 'across', 'act',
'action', 'actor', 'actress', 'actual', 'adapt', 'add', 'addict', 'address', 'adjust', 'admit',
'adult', 'advance', 'advice', 'aerobic', 'affair', 'afford', 'afraid', 'again', 'age', 'agent',
'agree', 'ahead', 'aim', 'air', 'airport', 'aisle', 'alarm', 'album', 'alcohol', 'alert',
'alien', 'all', 'alley', 'allow', 'almost', 'alone', 'alpha', 'already', 'also', 'alter',
'always', 'amateur', 'amazing', 'among', 'amount', 'amused', 'analyst', 'anchor', 'ancient', 'anger',
'angle', 'angry', 'animal', 'ankle', 'announce', 'annual', 'another', 'answer', 'antenna', 'antique',
'anxiety', 'any', 'apart', 'apology', 'appear', 'apple', 'approve', 'april', 'arch', 'arctic',
'area', 'arena', 'argue', 'arm', 'armed', 'armor', 'army', 'around', 'arrange', 'arrest',
'arrive', 'arrow', 'art', 'artefact', 'artist', 'artwork', 'ask', 'aspect', 'assault', 'asset',
'assist', 'assume', 'asthma', 'athlete', 'atom', 'attack', 'attend', 'attitude', 'attract', 'auction',
'audit', 'august', 'aunt', 'author', 'auto', 'autumn', 'average', 'avocado', 'avoid', 'awake',
'aware', 'away', 'awesome', 'awful', 'awkward', 'axis', 'baby', 'bachelor', 'bacon', 'badge',
'bag', 'balance', 'balcony', 'ball', 'bamboo', 'banana', 'banner', 'bar', 'barely', 'bargain',
'barrel', 'base', 'basic', 'basket', 'battle', 'beach', 'bean', 'beauty', 'because', 'become',
'beef', 'before', 'begin', 'behave', 'behind', 'believe', 'below', 'belt', 'bench', 'benefit',
'best', 'betray', 'better', 'between', 'beyond', 'bicycle', 'bid', 'bike', 'bind', 'biology',
'bird', 'birth', 'bitter', 'black', 'blade', 'blame', 'blanket', 'blast', 'bleak', 'bless',
'blind', 'blood', 'blossom', 'blouse', 'blue', 'blur', 'blush', 'board', 'boat', 'body',
'boil', 'bomb', 'bone', 'bonus', 'book', 'boost', 'border', 'boring', 'borrow', 'boss',
'bottom', 'bounce', 'box', 'boy', 'bracket', 'brain', 'brand', 'brass', 'brave', 'bread',
'breeze', 'brick', 'bridge', 'brief', 'bright', 'bring', 'brisk', 'broccoli', 'broken', 'bronze',
'broom', 'brother', 'brown', 'brush', 'bubble', 'buddy', 'budget', 'buffalo', 'build', 'bulb',
'bulk', 'bullet', 'bundle', 'bunker', 'burden', 'burger', 'burst', 'bus', 'business', 'butter'
];
/// Vérifie si l'utilisateur a accepté l'envoi de données à l'IA
Future<bool> isUploadEnabled() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(_uploadEnabledKey) ?? false;
}
/// Active ou désactive l'envoi de données
Future<void> setUploadEnabled(bool enabled) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_uploadEnabledKey, enabled);
}
/// Gets the unique 15-word identity phrase
Future<String> getIdentityPhrase() async {
final prefs = await SharedPreferences.getInstance();
// Check if we already generated it
final existingPhrase = prefs.getString(_prefsKey);
if (existingPhrase != null && existingPhrase.isNotEmpty) {
return existingPhrase;
}
// Generate new phrase based on device ID
final phrase = await _generatePhraseFromDevice();
// Save for future use
await prefs.setString(_prefsKey, phrase);
return phrase;
}
Future<String> _generatePhraseFromDevice() async {
final deviceInfo = DeviceInfoPlugin();
String deviceId = 'unknown_device_${DateTime.now().millisecondsSinceEpoch}';
try {
if (Platform.isAndroid) {
final androidInfo = await deviceInfo.androidInfo;
deviceId = androidInfo.id; // Unique Android ID
} else if (Platform.isIOS) {
final iosInfo = await deviceInfo.iosInfo;
deviceId = iosInfo.identifierForVendor ?? deviceId;
} else if (Platform.isWindows) {
final windowsInfo = await deviceInfo.windowsInfo;
deviceId = windowsInfo.deviceId;
}
} catch (e) {
print('Erreur lecture device ID: $e');
}
// Add a salt to the device ID
final salt = 'bully_app_wallet_salt_v1';
final bytes = utf8.encode(deviceId + salt);
final digest = sha256.convert(bytes);
// Use the hash to seed a random number generator
// We take the first 4 bytes of the hash as the seed
final seedBytes = digest.bytes.sublist(0, 4);
final seed = (seedBytes[0] << 24) | (seedBytes[1] << 16) | (seedBytes[2] << 8) | seedBytes[3];
final random = Random(seed);
List<String> phraseWords = [];
for (int i = 0; i < 15; i++) {
final randomIndex = random.nextInt(_wordList.length);
phraseWords.add(_wordList[randomIndex]);
}
return phraseWords.join(' ');
}
}

View File

@@ -5,12 +5,16 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import device_info_plus
import file_selector_macos import file_selector_macos
import path_provider_foundation import path_provider_foundation
import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
} }

View File

@@ -25,6 +25,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.2" version: "2.1.2"
change_case:
dependency: transitive
description:
name: change_case
sha256: e41ef3df58521194ef8d7649928954805aeb08061917cf658322305e61568003
url: "https://pub.dev"
source: hosted
version: "2.2.0"
characters: characters:
dependency: transitive dependency: transitive
description: description:
@@ -61,12 +69,12 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: cross_file name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.5+1" version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: "direct main"
description: description:
name: crypto name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
@@ -81,6 +89,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.8" version: "1.0.8"
dartcv4:
dependency: transitive
description:
name: dartcv4
sha256: "43dba49162662f3b6e3daf5a95d071429365e2f1ada67d412b851fc9be442e58"
url: "https://pub.dev"
source: hosted
version: "2.2.1+1"
device_info_plus:
dependency: "direct main"
description:
name: device_info_plus
sha256: "6a642e1daa10190af89ba6cb6386c0df7d071a3592080bfe1e44faa63ae1df65"
url: "https://pub.dev"
source: hosted
version: "13.1.0"
device_info_plus_platform_interface:
dependency: transitive
description:
name: device_info_plus_platform_interface
sha256: "04b173a92e2d9161dfead145667037c8d834db725ce2e7b942bfe18fd2f45a46"
url: "https://pub.dev"
source: hosted
version: "8.1.0"
equatable: equatable:
dependency: transitive dependency: transitive
description: description:
@@ -101,10 +133,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.5" version: "2.2.0"
ffi_leak_tracker:
dependency: transitive
description:
name: ffi_leak_tracker
sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97"
url: "https://pub.dev"
source: hosted
version: "0.1.2"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -200,6 +240,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.3" version: "2.1.3"
google_mlkit_document_scanner:
dependency: "direct main"
description:
name: google_mlkit_document_scanner
sha256: "67428ddb853880c8185049a5834cd328e6420921a74786f6aadee0b76f8536bd"
url: "https://pub.dev"
source: hosted
version: "0.2.1"
hooks: hooks:
dependency: transitive dependency: transitive
description: description:
@@ -209,7 +257,7 @@ packages:
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -244,10 +292,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_android name: image_picker_android
sha256: "5e9bf126c37c117cf8094215373c6d561117a3cfb50ebc5add1a61dc6e224677" sha256: "518a16108529fc18657a3e6dde4a043dc465d16596d20ab2abd49a4cac2e703d"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+10" version: "0.8.13+13"
image_picker_for_web: image_picker_for_web:
dependency: transitive dependency: transitive
description: description:
@@ -260,10 +308,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: image_picker_ios name: image_picker_ios
sha256: "956c16a42c0c708f914021666ffcd8265dde36e673c9fa68c81f7d085d9774ad" sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.8.13+3" version: "0.8.13+6"
image_picker_linux: image_picker_linux:
dependency: transitive dependency: transitive
description: description:
@@ -384,6 +432,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.17.4" version: "0.17.4"
native_toolchain_cmake:
dependency: transitive
description:
name: native_toolchain_cmake
sha256: fe40e8483183ced98e851e08a9cd2a547fd412cccab98277aa23f2377e43d66f
url: "https://pub.dev"
source: hosted
version: "0.2.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -392,6 +448,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0" version: "1.0.0"
opencv_dart:
dependency: "direct main"
description:
name: opencv_dart
sha256: c2b7cc614cad69c2857e9b684e3066af662a03fe7100f4dc9a630e81ad42103a
url: "https://pub.dev"
source: hosted
version: "2.2.1+1"
path: path:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -496,6 +560,62 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.dev"
source: hosted
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
source: hosted
version: "2.5.6"
shared_preferences_linux:
dependency: transitive
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
source: hosted
version: "2.4.3"
shared_preferences_windows:
dependency: transitive
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
sky_engine: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -653,6 +773,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
win32:
dependency: transitive
description:
name: win32
sha256: ba7d5750e3441caa1bbe31d9e516348fcf8dfcb32aa29ef87a844a59f4d1f1d0
url: "https://pub.dev"
source: hosted
version: "6.1.0"
win32_registry:
dependency: transitive
description:
name: win32_registry
sha256: "73b1d78920a9d6e03f8b4e43e612b87bf3152a0e5c5e5150267762b7c4116904"
url: "https://pub.dev"
source: hosted
version: "3.0.3"
xdg_directories: xdg_directories:
dependency: transitive dependency: transitive
description: description:
@@ -679,4 +815,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.12.0-35.0.dev <4.0.0" dart: ">=3.12.0-35.0.dev <4.0.0"
flutter: ">=3.35.0" flutter: ">=3.38.1"

View File

@@ -35,11 +35,11 @@ dependencies:
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
# Image processing with OpenCV (désactivé temporairement - problèmes de build Windows) opencv_dart: ^2.1.0
# opencv_dart: ^2.1.0
# Image capture from camera/gallery # Image capture from camera/gallery
image_picker: ^1.0.7 image_picker: ^1.2.1
google_mlkit_document_scanner: ^0.2.0
# Local database for history # Local database for history
sqflite: ^2.3.2 sqflite: ^2.3.2
@@ -63,6 +63,13 @@ dependencies:
# Image processing for impact detection # Image processing for impact detection
image: ^4.1.7 image: ^4.1.7
http: ^1.6.0
device_info_plus: ^13.1.0
shared_preferences: ^2.5.5
crypto: ^3.0.7
# Machine Learning for YOLOv8
# tflite_flutter: ^0.11.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -88,6 +95,7 @@ flutter:
# To add assets to your application, add an assets section, like this: # To add assets to your application, add an assets section, like this:
# assets: # assets:
# - assets/models/yolov8n_32.tflite
# - images/a_dot_burr.jpeg # - images/a_dot_burr.jpeg
# - images/a_dot_ham.jpeg # - images/a_dot_ham.jpeg

View File

@@ -0,0 +1,12 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
var p1 = cv.VecPoint.fromList([cv.Point(0, 0), cv.Point(1, 1)]);
var p2 = cv.VecPoint2f.fromList([cv.Point2f(0, 0), cv.Point2f(1, 1)]);
// Is it p1.mat ?
// Or is it cv.findHomography(p1, p1) but actually needs specific types ?
cv.Mat mat1 = cv.Mat.fromVec(p1);
cv.Mat mat2 = cv.Mat.fromVec(p2);
cv.findHomography(mat1, mat2);
}

View File

@@ -0,0 +1,7 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.approxPolyDP);
print(cv.arcLength);
print(cv.contourArea);
}

View File

@@ -0,0 +1,5 @@
import 'package:opencv_dart/opencv_dart.dart' as cv;
void main() {
print(cv.findHomography);
}