fix: recentrage incorrect si loin du centre d origine
This commit is contained in:
@@ -389,19 +389,24 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
Future<void> _onCropConfirm() async {
|
Future<void> _onCropConfirm() async {
|
||||||
setState(() => _isLoading = true);
|
setState(() => _isLoading = true);
|
||||||
try {
|
try {
|
||||||
// La zone de découpe doit refléter le DÉPLACEMENT (pan) et la ROTATION,
|
// Facteur d'échelle affichage/source (BoxFit.contain) — identique à
|
||||||
// mais PAS le ZOOM. On neutralise donc temporairement _scale à 1.0 pour
|
// celui utilisé pour afficher l'image dans l'aperçu.
|
||||||
// le calcul, tout en conservant _offset. La rotation est appliquée à part
|
final imageAspect = _imageSize!.width / _imageSize!.height;
|
||||||
// par cropToSquare(rotationDegrees: _rotation).
|
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
||||||
final savedScale = _scale;
|
final double displayWidth = imageAspect > viewportAspect
|
||||||
_scale = 1.0;
|
? _viewportSize.width
|
||||||
final cropRect = _calculateCropRect();
|
: _viewportSize.height * imageAspect;
|
||||||
_scale = savedScale; // on restaure pour l'affichage (retour arrière)
|
final double displayPerSourcePx = displayWidth / _imageSize!.width;
|
||||||
|
|
||||||
// AJOUT DE LA DECOUPE ET DU REDRESSEMENT GÉOMÉTRIQUE PHYSIQUE
|
// Découpe calée sur la fenêtre de visée : le DÉPLACEMENT (pan) et la
|
||||||
final croppedImagePath = await _cropService.cropToSquare(
|
// ROTATION sont pris en compte, le ZOOM est ignoré, et les débordements
|
||||||
widget.imagePath,
|
// sont remplis en noir → la position choisie est respectée à l'identique.
|
||||||
cropRect,
|
final croppedImagePath = await _cropService.cropViewport(
|
||||||
|
sourcePath: widget.imagePath,
|
||||||
|
offsetDx: _offset.dx,
|
||||||
|
offsetDy: _offset.dy,
|
||||||
|
displayPerSourcePx: displayPerSourcePx,
|
||||||
|
cropSizeDisplay: _cropSize,
|
||||||
rotationDegrees: _rotation,
|
rotationDegrees: _rotation,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -438,43 +443,4 @@ class _CropScreenState extends State<CropScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
CropRect _calculateCropRect() {
|
|
||||||
if (_imageSize == null) return const CropRect(x: 0, y: 0, width: 1, height: 1);
|
|
||||||
|
|
||||||
final imageAspect = _imageSize!.width / _imageSize!.height;
|
|
||||||
final viewportAspect = _viewportSize.width / _viewportSize.height;
|
|
||||||
|
|
||||||
double displayWidth, displayHeight;
|
|
||||||
if (imageAspect > viewportAspect) {
|
|
||||||
displayWidth = _viewportSize.width;
|
|
||||||
displayHeight = _viewportSize.width / imageAspect;
|
|
||||||
} else {
|
|
||||||
displayHeight = _viewportSize.height;
|
|
||||||
displayWidth = _viewportSize.height * imageAspect;
|
|
||||||
}
|
|
||||||
|
|
||||||
final scaledWidth = displayWidth * _scale;
|
|
||||||
final scaledHeight = displayHeight * _scale;
|
|
||||||
|
|
||||||
final imageCenterX = _viewportSize.width / 2 + _offset.dx;
|
|
||||||
final imageCenterY = _viewportSize.height / 2 + _offset.dy;
|
|
||||||
|
|
||||||
final imageLeft = imageCenterX - scaledWidth / 2;
|
|
||||||
final imageTop = imageCenterY - scaledHeight / 2;
|
|
||||||
|
|
||||||
final cropLeft = (_viewportSize.width - _cropSize) / 2;
|
|
||||||
final cropTop = (_viewportSize.height - _cropSize) / 2;
|
|
||||||
|
|
||||||
final relCropLeft = (cropLeft - imageLeft) / scaledWidth;
|
|
||||||
final relCropTop = (cropTop - imageTop) / scaledHeight;
|
|
||||||
final relCropWidth = _cropSize / scaledWidth;
|
|
||||||
final relCropHeight = _cropSize / scaledHeight;
|
|
||||||
|
|
||||||
return CropRect(
|
|
||||||
x: relCropLeft.clamp(0.0, 1.0),
|
|
||||||
y: relCropTop.clamp(0.0, 1.0),
|
|
||||||
width: relCropWidth.clamp(0.0, 1.0),
|
|
||||||
height: relCropHeight.clamp(0.0, 1.0),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -98,11 +98,122 @@ void _cropIsolateEntry(_CropParams params) {
|
|||||||
outputFile.writeAsBytesSync(img.encodeJpg(cropped, quality: 85));
|
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 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.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, exprimé en pixels source (rotation = même échelle)
|
||||||
|
final int side = math.max(1, (p.cropSizeDisplay / f).round());
|
||||||
|
|
||||||
|
// Centre de la fenêtre de visée, en pixels de l'image (rotated) :
|
||||||
|
// le centre de l'image est affiché à (centre_viewport + offset), la fenêtre
|
||||||
|
// est centrée sur le viewport → décalage de -offset/f par rapport au centre.
|
||||||
|
final double cropCenterX = rotated.width / 2 - p.offsetDx / f;
|
||||||
|
final double cropCenterY = rotated.height / 2 - p.offsetDy / f;
|
||||||
|
|
||||||
|
final int srcX = (cropCenterX - side / 2).round();
|
||||||
|
final int srcY = (cropCenterY - side / 2).round();
|
||||||
|
|
||||||
|
// Toile carrée noire opaque ; compositeImage rogne automatiquement les bords
|
||||||
|
// hors-cadre, donc les débordements restent noirs (padding).
|
||||||
|
final out = img.Image(width: side, height: side, numChannels: 3);
|
||||||
|
img.compositeImage(out, rotated, dstX: -srcX, dstY: -srcY);
|
||||||
|
|
||||||
|
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 {
|
class ImageCropService {
|
||||||
final Uuid _uuid = const Uuid();
|
final Uuid _uuid = const Uuid();
|
||||||
|
|
||||||
static const int maxOutputSize = 1024;
|
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 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,
|
||||||
|
rotationDegrees: rotationDegrees,
|
||||||
|
outputSize: outputSize,
|
||||||
|
outputPath: outputPath,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Traitement lourd dans un Isolate → thread UI fluide.
|
||||||
|
await Isolate.run(() => _viewportCropIsolateEntry(params));
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
Future<String> cropToSquare(
|
Future<String> cropToSquare(
|
||||||
String sourcePath,
|
String sourcePath,
|
||||||
CropRect cropRect, {
|
CropRect cropRect, {
|
||||||
|
|||||||
Reference in New Issue
Block a user