343 lines
11 KiB
Dart
343 lines
11 KiB
Dart
import 'dart:isolate'; // AJOUT
|
|
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
import 'package:image/image.dart' as img;
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
class CropRect {
|
|
final double x;
|
|
final double y;
|
|
final double width;
|
|
final double height;
|
|
|
|
const CropRect({
|
|
required this.x,
|
|
required this.y,
|
|
required this.width,
|
|
required this.height,
|
|
});
|
|
|
|
@override
|
|
String toString() => 'CropRect(x: $x, y: $y, w: $width, h: $height)';
|
|
}
|
|
|
|
// AJOUT : Paramètres passés à l'Isolate (tout doit être sérialisable)
|
|
class _CropParams {
|
|
final String sourcePath;
|
|
final double cropX;
|
|
final double cropY;
|
|
final double cropWidth;
|
|
final double cropHeight;
|
|
final double rotationDegrees;
|
|
final int outputSize;
|
|
final String outputPath;
|
|
|
|
_CropParams({
|
|
required this.sourcePath,
|
|
required this.cropX,
|
|
required this.cropY,
|
|
required this.cropWidth,
|
|
required this.cropHeight,
|
|
required this.rotationDegrees,
|
|
required this.outputSize,
|
|
required this.outputPath,
|
|
});
|
|
}
|
|
|
|
// AJOUT : Fonction statique exécutée dans l'Isolate (doit être top-level ou static)
|
|
void _cropIsolateEntry(_CropParams params) {
|
|
final file = File(params.sourcePath);
|
|
final bytes = file.readAsBytesSync();
|
|
img.Image? originalImage = img.decodeImage(bytes);
|
|
|
|
if (originalImage == null) {
|
|
throw Exception('Impossible de décoder l\'image: ${params.sourcePath}');
|
|
}
|
|
|
|
// Rotation si nécessaire — LINEAR au lieu de CUBIC pour la vitesse
|
|
if (params.rotationDegrees != 0.0) {
|
|
originalImage = img.copyRotate(
|
|
originalImage,
|
|
angle: params.rotationDegrees,
|
|
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
|
|
);
|
|
}
|
|
|
|
// Recadrage
|
|
final srcX = (params.cropX * originalImage.width).round();
|
|
final srcY = (params.cropY * originalImage.height).round();
|
|
final srcWidth = (params.cropWidth * originalImage.width).round();
|
|
final srcHeight = (params.cropHeight * originalImage.height).round();
|
|
|
|
final clampedX = srcX.clamp(0, originalImage.width - 1);
|
|
final clampedY = srcY.clamp(0, originalImage.height - 1);
|
|
final clampedWidth = math.min(srcWidth, originalImage.width - clampedX);
|
|
final clampedHeight = math.min(srcHeight, originalImage.height - clampedY);
|
|
|
|
img.Image cropped = img.copyCrop(
|
|
originalImage,
|
|
x: clampedX,
|
|
y: clampedY,
|
|
width: clampedWidth,
|
|
height: clampedHeight,
|
|
);
|
|
|
|
// Redimensionnement — LINEAR au lieu de CUBIC
|
|
if (cropped.width != params.outputSize || cropped.height != params.outputSize) {
|
|
cropped = img.copyResize(
|
|
cropped,
|
|
width: params.outputSize,
|
|
height: params.outputSize,
|
|
interpolation: img.Interpolation.linear, // OPTIMISATION : linear >> cubic en vitesse
|
|
);
|
|
}
|
|
|
|
// Encodage JPEG qualité 85 au lieu de 90 (gain de vitesse non négligeable)
|
|
final outputFile = File(params.outputPath);
|
|
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
|
}
|
|
|
|
// AJOUT : Paramètres de la découpe calée sur la fenêtre de visée (padding noir)
|
|
class _ViewportCropParams {
|
|
final String sourcePath;
|
|
final double offsetDx;
|
|
final double offsetDy;
|
|
final double displayPerSourcePx; // facteur affichage/source (BoxFit.contain)
|
|
final double cropSizeDisplay; // côté de la fenêtre de visée, en pixels écran
|
|
final double zoomScale; // zoom utilisateur : sert UNIQUEMENT au pointage
|
|
final double rotationDegrees;
|
|
final int outputSize;
|
|
final String outputPath;
|
|
|
|
_ViewportCropParams({
|
|
required this.sourcePath,
|
|
required this.offsetDx,
|
|
required this.offsetDy,
|
|
required this.displayPerSourcePx,
|
|
required this.cropSizeDisplay,
|
|
required this.zoomScale,
|
|
required this.rotationDegrees,
|
|
required this.outputSize,
|
|
required this.outputPath,
|
|
});
|
|
}
|
|
|
|
// AJOUT : Découpe fidèle à ce que l'utilisateur voit. Reproduit la translation
|
|
// (pan) et la rotation de l'aperçu, ignore le zoom, et remplit en NOIR toute
|
|
// zone qui déborde de l'image → la cible reste exactement là où l'utilisateur
|
|
// l'a placée, même collée à un bord (aucun recentrage forcé).
|
|
void _viewportCropIsolateEntry(_ViewportCropParams p) {
|
|
final bytes = File(p.sourcePath).readAsBytesSync();
|
|
final img.Image? src = img.decodeImage(bytes);
|
|
if (src == null) {
|
|
throw Exception('Impossible de décoder l\'image: ${p.sourcePath}');
|
|
}
|
|
|
|
// Rotation autour du centre (canvas agrandi). Préserve l'échelle des pixels.
|
|
img.Image rotated = src;
|
|
if (p.rotationDegrees != 0.0) {
|
|
rotated = img.copyRotate(
|
|
src,
|
|
angle: p.rotationDegrees,
|
|
interpolation: img.Interpolation.linear,
|
|
);
|
|
}
|
|
|
|
final double f = p.displayPerSourcePx;
|
|
// Côté de la fenêtre de visée en pixels source. On utilise f SEUL (pas le
|
|
// zoom) → le champ de vision capturé est toujours celui de l'image non
|
|
// zoomée : le zoom n'est donc PAS pris en compte dans le rendu de sortie.
|
|
final int side = math.max(1, (p.cropSizeDisplay / f).round());
|
|
|
|
// Centre de la fenêtre de visée, en pixels de l'image (rotated).
|
|
// La translation écran s'applique APRÈS le zoom, donc un déplacement écran
|
|
// `offset` vaut `offset / (f * zoom)` pixels source. On inclut donc le zoom
|
|
// ICI (uniquement pour le pointage) afin que la croix vise le même point
|
|
// quel que soit le niveau de zoom.
|
|
final double fz = f * p.zoomScale;
|
|
final double cropCenterX = rotated.width / 2 - p.offsetDx / fz;
|
|
final double cropCenterY = rotated.height / 2 - p.offsetDy / fz;
|
|
|
|
final int srcX = (cropCenterX - side / 2).round();
|
|
final int srcY = (cropCenterY - side / 2).round();
|
|
|
|
// Toile carrée NOIRE opaque (numChannels 3 → pixels initialisés à 0 = noir).
|
|
final out = img.Image(width: side, height: side, numChannels: 3);
|
|
|
|
// Intersection de la fenêtre de visée avec l'image réelle. Tout ce qui
|
|
// déborde reste noir (padding) → aucun recentrage forcé.
|
|
final int vx0 = math.max(0, srcX);
|
|
final int vy0 = math.max(0, srcY);
|
|
final int vx1 = math.min(rotated.width, srcX + side);
|
|
final int vy1 = math.min(rotated.height, srcY + side);
|
|
final int vw = vx1 - vx0;
|
|
final int vh = vy1 - vy0;
|
|
|
|
if (vw > 0 && vh > 0) {
|
|
final region = img.copyCrop(rotated, x: vx0, y: vy0, width: vw, height: vh);
|
|
// dstW/dstH EXPLICITES = taille de la région → AUCUN redimensionnement
|
|
// (sinon compositeImage étire la source pour remplir la destination).
|
|
img.compositeImage(
|
|
out,
|
|
region,
|
|
dstX: vx0 - srcX,
|
|
dstY: vy0 - srcY,
|
|
dstW: vw,
|
|
dstH: vh,
|
|
);
|
|
}
|
|
|
|
img.Image result = out;
|
|
if (side != p.outputSize) {
|
|
result = img.copyResize(
|
|
out,
|
|
width: p.outputSize,
|
|
height: p.outputSize,
|
|
interpolation: img.Interpolation.linear,
|
|
);
|
|
}
|
|
|
|
File(p.outputPath).writeAsBytesSync(img.encodeJpg(result, quality: 85));
|
|
}
|
|
|
|
class ImageCropService {
|
|
final Uuid _uuid = const Uuid();
|
|
|
|
static const int maxOutputSize = 1024;
|
|
|
|
/// Découpe carrée calée sur la fenêtre de visée de l'écran de centrage.
|
|
/// Le décalage (pan) et la rotation sont respectés, le zoom est ignoré, et
|
|
/// tout débordement hors de l'image est rempli en noir (jamais de recentrage).
|
|
///
|
|
/// - [offsetDx]/[offsetDy] : translation de l'image dans l'aperçu, en px écran.
|
|
/// - [displayPerSourcePx] : facteur d'échelle affichage/source (BoxFit.contain).
|
|
/// - [cropSizeDisplay] : côté de la fenêtre carrée de visée, en px écran.
|
|
Future<String> cropViewport({
|
|
required String sourcePath,
|
|
required double offsetDx,
|
|
required double offsetDy,
|
|
required double displayPerSourcePx,
|
|
required double cropSizeDisplay,
|
|
double zoomScale = 1.0,
|
|
double rotationDegrees = 0.0,
|
|
int outputSize = maxOutputSize,
|
|
}) async {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
|
|
|
final params = _ViewportCropParams(
|
|
sourcePath: sourcePath,
|
|
offsetDx: offsetDx,
|
|
offsetDy: offsetDy,
|
|
displayPerSourcePx: displayPerSourcePx,
|
|
cropSizeDisplay: cropSizeDisplay,
|
|
zoomScale: zoomScale <= 0 ? 1.0 : zoomScale,
|
|
rotationDegrees: rotationDegrees,
|
|
outputSize: outputSize,
|
|
outputPath: outputPath,
|
|
);
|
|
|
|
// Traitement lourd dans un Isolate → thread UI fluide.
|
|
await Isolate.run(() => _viewportCropIsolateEntry(params));
|
|
|
|
return outputPath;
|
|
}
|
|
|
|
Future<String> cropToSquare(
|
|
String sourcePath,
|
|
CropRect cropRect, {
|
|
double rotationDegrees = 0.0,
|
|
int outputSize = maxOutputSize,
|
|
}) async {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final outputPath = '${tempDir.path}/cropped_${_uuid.v4()}.jpg';
|
|
|
|
final params = _CropParams(
|
|
sourcePath: sourcePath,
|
|
cropX: cropRect.x,
|
|
cropY: cropRect.y,
|
|
cropWidth: cropRect.width,
|
|
cropHeight: cropRect.height,
|
|
rotationDegrees: rotationDegrees,
|
|
outputSize: outputSize,
|
|
outputPath: outputPath,
|
|
);
|
|
|
|
// OPTIMISATION CLEF : Tout le traitement lourd dans un Isolate séparé
|
|
// → le thread UI reste fluide pendant le calcul
|
|
await Isolate.run(() => _cropIsolateEntry(params));
|
|
|
|
return outputPath;
|
|
}
|
|
|
|
/// Calcule la zone de recadrage carrée maximale centrée sur l'image
|
|
CropRect getDefaultSquareCrop(int imageWidth, int imageHeight) {
|
|
final aspectRatio = imageWidth / imageHeight;
|
|
|
|
if (aspectRatio > 1) {
|
|
final squareWidth = imageHeight / imageWidth;
|
|
final x = (1 - squareWidth) / 2;
|
|
return CropRect(x: x, y: 0, width: squareWidth, height: 1);
|
|
} else if (aspectRatio < 1) {
|
|
final squareHeight = imageWidth / imageHeight;
|
|
final y = (1 - squareHeight) / 2;
|
|
return CropRect(x: 0, y: y, width: 1, height: squareHeight);
|
|
} else {
|
|
return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
|
}
|
|
}
|
|
|
|
/// Nettoie les fichiers temporaires de crop anciens
|
|
Future<void> cleanupOldCrops({Duration maxAge = const Duration(hours: 24)}) async {
|
|
try {
|
|
final tempDir = await getTemporaryDirectory();
|
|
final dir = Directory(tempDir.path);
|
|
final now = DateTime.now();
|
|
|
|
await for (final entity in dir.list()) {
|
|
if (entity is File && entity.path.contains('cropped_')) {
|
|
final stat = await entity.stat();
|
|
final age = now.difference(stat.modified);
|
|
if (age > maxAge) {
|
|
await entity.delete();
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Ignorer les erreurs
|
|
}
|
|
}
|
|
|
|
/// Coupe automatiquement une photo brute de l'appareil photo en carré
|
|
/// calé sur la position haute du viseur de l'écran.
|
|
Future<String> cropRawCameraToSquare(String sourcePath) async {
|
|
final file = File(sourcePath);
|
|
final bytes = await file.readAsBytes();
|
|
final originalImage = img.decodeImage(bytes);
|
|
|
|
if (originalImage == null) return sourcePath;
|
|
|
|
final int imgW = originalImage.width;
|
|
final int imgH = originalImage.height;
|
|
|
|
final int cropSizePixels = (math.min(imgW, imgH) * 0.85).round();
|
|
|
|
final int x = ((imgW - cropSizePixels) / 2).round();
|
|
final int y = ((imgH - cropSizePixels) / 2).round();
|
|
|
|
final squareImage = img.copyCrop(
|
|
originalImage,
|
|
x: x.clamp(0, imgW - 1),
|
|
y: y.clamp(0, imgH - 1),
|
|
width: cropSizePixels,
|
|
height: cropSizePixels,
|
|
);
|
|
|
|
final tempDir = await getTemporaryDirectory();
|
|
final outputPath = '${tempDir.path}/camera_square_${DateTime.now().millisecondsSinceEpoch}.jpg';
|
|
await File(outputPath).writeAsBytes(img.encodeJpg(squareImage, quality: 85));
|
|
|
|
return outputPath;
|
|
}
|
|
} |