치터_4. 이미지 메타데이터 수정 오류 해결

최지웅·2025년 1월 10일
0

프로젝트1_치터

목록 보기
4/4
post-thumbnail

(25.01.10)
gpt로 확인해본 결과 dart에서 exif로는 메타 데이터 수정에 제약이 있기에 exiftool모듈을 사용하여 리팩토링을 수행해보라고 하였다.

exif->exiftool 모듈로 리팩터링

https://exiftool.org/index.html 에서 exiftool-13.11_64.zip을 다운하고 readme대로 exe이름을 cmd-line실행을 위해 변경한 후 환경변수 설정하였다. 하지만 경로 관련 문제가 발생하여 제대로 실행되지 않았다.

다시 exif모듈을 사용하는 코드로 롤백에서 차근차근 시도해보자.

exif를 이용한 메타 데이터 수정

디버깅 중 아주 중요한 문제를 발견했다.

첫 번째로 원본 이미지의 exif(메타) 데이터를 읽어온 시점이다.

이 시점에서의 exif데이터는 총 24개의 IfgTag를 가지고 있다.

두 번째로 메타 데이터 수정조작을 마친 시점이다.

이 시점에서 exif데이터는 총 29개의 IfgTag를 가지고 있다.

첫 번째와 두 번째 시점의 exif데이터를 비교했을 때, 덮어씌워져야하는 exif데이터가 실제 사용되고 있는 태그와 다른 태그를 사용하여 수정이 아닌 추가가 되었던 것이다. 이를 실제 사용하고 있는 태그의 이름대로 변경해주자.

수정한 뒤 exif데이터 총 24개로 유지되었지만, 제대로 수정되지 않았다.

문제를 쪼개서 수정 시각부터 해결해보자.


(25.01.14)

  • 디버깅을 이어가기 앞서, 메타 데이터에 대한 이해부터 시도해보자.

사진 메타 데이터의 이해

이미지 메타데이터 표준은 Exif(Exahcnable Image File Format), XMP(Extensible Metadata Platform), IPTC-IIM(International Press Telecommunications Council - Information Interchange Model) 세가지이다.

그 중 디지털 카메라의 표준은 Exif이기에 현 작업에 exif 모듈을 사용하여 메타데이터를 수정하려는 것이다.

GPS정보와 시각 정보는 EXIF Format에 기록되어 있다.

GPS정보는 총 30개의 태그로 exif에서 표현이 되며, 일반적인 정보는 아래의 태그에 담긴다.

  1. GPSLatitudeRef: (ASCII) N(북), S(남)
  2. GPSLatitude: (RATIONAL) 위도
  3. GPSLongitudeRef: (ASCII) E(동), W(서)
  4. GPSLongitude: (RATIONAL) 경도
  5. GPSTimeStamp: (RATIONAL) UTC기준 GPS촬영 시간
  6. GPSImgDirectionRef: (ASCII) 피사페 방향에 관한 부로
  7. GPSImgDirection: (RATIONAL) 피사제의 방향

시각정보는 총 3개의 태그로 exif에서 표현이 되며, 아래의 태그에 담긴다.

  1. DateTime: (ASCII) 이미지의 생성시각
  2. DateTimeOriginal: (ASCII) 피사체가 촬영된 시각
  3. DateTimeDigitized: (ASCII) 피사체가 디지털이미지로 처리된 시각

출처: https://fl0ckfl0ck.tistory.com/253

그러면 다시한번 Android Studio에서 exif데이터 태그 구조를 살펴보자.

GPS에 관한 태그는 4~9번 태그, 시각에 관한 태그는 2, 19, 22번 태그이다.

  • 시각에 관한 태그를 하나씩 변경해보며, 변경 사항을 확인해보자.

exif 태그 별 변경사항 확인

  1. Image DateTime
    (수정 전)

    (수정 코드)
    final formattedDateTime2 = '${dateTime.year}:${dateTime.month}:${dateTime.day} ${dateTime.hour}:${dateTime.minute}:${dateTime.second}';
    exifData['Image DateTime'] = IfdTag(
      tag: 0x0132, //
      tagType: 'ASCII',
      printable: formattedDateTime2,
      values: IfdBytes(Uint8List.fromList(formattedDateTime2.codeUnits)),
    );

(수정 후)

(갤러리에서 확인한 태그 값 결과)
이전의 시간도 변경한 시간도 아닌 현재 저장된 시간이다.

  1. EXIF DateTimeOriginal
    (수정 전)

    (수정 코드)
    final formattedDateTime = '${dateTime.year}:${dateTime.month}:${dateTime.day} ${dateTime.hour}:${dateTime.minute}:${dateTime.second}';
    exifData['EXIF DateTimeOriginal'] = IfdTag(
      tag: 0x9003, // DateTimeOriginal 태그 ID
      tagType: 'DateTimeOriginal',
      printable: formattedDateTime,
      values: IfdBytes(Uint8List.fromList(formattedDateTime.codeUnits)),
    );

(수정 후)

(갤러리에서 확인한 태그 값 결과)
이전의 시간도 변경한 시간도 아닌 현재 저장된 시간이다.

  1. EXIF DateTimeDigitized
    (수정 전)

    (수정 코드)
    final formattedDateTime3 = '${dateTime.year}:${dateTime.month}:${dateTime.day} ${dateTime.hour}:${dateTime.minute}:${dateTime.second}';
    exifData['EXIF DateTimeDigitized'] = IfdTag(
      tag: 36868, //
      tagType: 'ASCII',
      printable: formattedDateTime3,
      values: IfdBytes(Uint8List.fromList(formattedDateTime3.codeUnits)),
    );

(수정 후)

(갤러리에서 확인한 태그 값 결과)
이전의 시간도 변경한 시간도 아닌 현재 저장된 시간이다.

  1. 세가지 코드 전부 수정
    (갤러리에서 확인한 태그 값 결과)
    이전의 시간도 변경한 시간도 아닌 현재 저장된 시간이다.

가설1: 태그들은 정상적으로 수정이 되나, 메타 데이터 변경 후 GallerySaver를 이용한 최종 저장 과정에서의 시점을 기준으로 메타 데이터가 덮어씌워지는 듯 하다.

가설검즘1: 메타데이터를 변경한 사진의 exif 태그를 확인해보자.


메타 데이터를 수정했지만 갤러리 상에 25년 1월 14일 화요일로 뜨는 사진의 exif를 확인해보니 수정 전 원본 이미지의 exit tag값을 가지고 있다. 이게 무슨일이지..?

결론1: 메타 데이터 변경 후 GallerySaver를 이용한 최종 저장 과정에서의 시점을 기준으로 메타 데이터가 덮어씌워지지 않는다.


MediaStore을 이용한 리팩터링

GallerySaver대신 MediaStore를 이용해보았다.

갤러리가 아닌 내 파일/Pictures/cheeter/updated_~로 저장되었지만, 여전히 원본 파일의 시각정보를 가지고 있으며 GPS 정보는 아예 사라져버렸다.

utils/exif_utils.dart

import 'dart:io';
import 'dart:typed_data';
import 'package:exif/exif.dart';
import 'package:permission_handler/permission_handler.dart';

// 저장 권한 요청 및 갤러리 저장 함수
Future<void> saveToGallery(File updatedFile) async {
  try {
    // 저장 권한 요청
    final hasPermission = await requestStoragePermission();
    if (!hasPermission) {
      print("[DEBUG] 권한이 없어 이미지를 저장할 수 없습니다.");
      return;
    }

    // MediaStore API를 사용하여 갤러리에 추가
    final directory = Directory('/storage/emulated/0/Pictures/cheeter');
    if (!directory.existsSync()) {
      directory.createSync(recursive: true);
    }

    final newFile = await updatedFile.copy('${directory.path}/${updatedFile.uri.pathSegments.last}');
    print("[DEBUG] 갤러리에 파일 추가 성공: ${newFile.path}");
  } catch (e) {
    print("[DEBUG] saveToGallery 오류: $e");
  }
}

// 권한 요청 함수
Future<bool> requestStoragePermission() async {
  Permission permission;

  if (Platform.isAndroid) {
    // Android 13 이상 (API 33 이상)
    final androidVersion = int.tryParse(Platform.version.split(' ')[0].replaceAll(RegExp('[^0-9]'), '')) ?? 0;
    if (androidVersion >= 33) {
      permission = Permission.photos; // Android 13 이상에서는 사진 권한
    } else {
      permission = Permission.storage; // 그 외 버전에서는 저장소 권한
    }
  } else if (Platform.isIOS) {
    // iOS에서는 기본적으로 권한이 필요하지 않지만, 확장 가능
    permission = Permission.photos; // iOS에서도 사진 권한을 요청할 수 있음
  } else {
    return true; // Android와 iOS 외 플랫폼은 기본적으로 권한 필요 없음
  }

  final status = await permission.request();

  if (status.isGranted) {
    print("[DEBUG] 저장 권한 허용됨");
    return true;
  } else if (status.isPermanentlyDenied) {
    print("[DEBUG] 저장 권한이 영구적으로 거부됨, 앱 설정으로 이동");
    await openAppSettings(); // 앱 설정 화면으로 이동
  } else if (status.isDenied) {
    print("[DEBUG] 저장 권한 거부됨");
  }

  return false;
}


// 권한 확인 및 요청 실행
Future<void> saveImageWithPermission(File imageFile) async {
  final hasPermission = await requestStoragePermission();
  if (hasPermission) {
    await saveToGallery(imageFile);
  } else {
    print("[DEBUG] 권한이 없어 이미지를 저장할 수 없습니다.");
  }
}

// 이미지 메타데이터 업데이트 및 저장
Future<File?> updatePhotoMetadataAndSave(
    File imageFile, double latitude, double longitude, DateTime dateTime) async {
  try {
    final bytes = await imageFile.readAsBytes();
    final exifData = await readExifFromBytes(bytes);

    if (exifData.isEmpty) {
      print("[DEBUG] No EXIF data found.");
      return null;
    }

    // GPS 데이터 추가
    exifData['GPS GPSLatitude'] = IfdTag(
      tag: 0x0002,
      tagType: 'Ratio',
      printable: _formatDms(latitude.abs()),
      values: IfdRatios(_convertToDmsRatios(latitude.abs())),
    );
    exifData['GPS GPSLongitude'] = IfdTag(
      tag: 0x0004,
      tagType: 'Ratio',
      printable: _formatDms(longitude.abs()),
      values: IfdRatios(_convertToDmsRatios(longitude.abs())),
    );
    exifData['GPS GPSLatitudeRef'] = IfdTag(
      tag: 0x0001,
      tagType: 'ASCII',
      printable: latitude >= 0 ? 'N' : 'S',
      values: IfdBytes(Uint8List.fromList([latitude >= 0 ? 0x4E : 0x53])),
    );
    exifData['GPS GPSLongitudeRef'] = IfdTag(
      tag: 0x0003,
      tagType: 'ASCII',
      printable: longitude >= 0 ? 'E' : 'W',
      values: IfdBytes(Uint8List.fromList([longitude >= 0 ? 0x45 : 0x57])),
    );

    // 날짜와 시간 데이터 업데이트
    final formattedDateTime =
        '${dateTime.year}:${dateTime.month.toString().padLeft(2, '0')}:${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}:${dateTime.second.toString().padLeft(2, '0')}';
    exifData['EXIF DateTimeOriginal'] = IfdTag(
      tag: 0x9003,
      tagType: 'ASCII',
      printable: formattedDateTime,
      values: IfdBytes(Uint8List.fromList(formattedDateTime.codeUnits)),
    );
    exifData['EXIF DateTimeDigitized'] = IfdTag(
      tag: 0x9004,
      tagType: 'ASCII',
      printable: formattedDateTime,
      values: IfdBytes(Uint8List.fromList(formattedDateTime.codeUnits)),
    );

    // EXIF 데이터를 이미지 바이트에 적용
    final updatedBytes = await _writeExifData(bytes, exifData);

    // 새로운 이미지 파일 생성
    final newImagePath = '${imageFile.parent.path}/updated_${imageFile.uri.pathSegments.last}';
    final newImageFile = File(newImagePath);
    await newImageFile.writeAsBytes(updatedBytes);

    print("[DEBUG] 이미지 메타데이터 업데이트 성공");

    // 갤러리 저장 호출
    await saveImageWithPermission(newImageFile);

    return newImageFile;
  } catch (e) {
    print("[DEBUG] 이미지 메타데이터 업데이트 실패: $e");
    return null;
  }
}

// GPS 좌표를 DMS 형식으로 변환
List<Ratio> _convertToDmsRatios(double coordinate) {
  final degrees = coordinate.floor();
  final minutes = ((coordinate - degrees) * 60).floor();
  final seconds = (((coordinate - degrees) * 60 - minutes) * 60 * 100).round();
  return [
    Ratio(degrees, 1),
    Ratio(minutes, 1),
    Ratio(seconds, 100),
  ];
}

// DMS 형식을 문자열로 변환
String _formatDms(double coordinate) {
  final degrees = coordinate.floor();
  final minutes = ((coordinate - degrees) * 60).floor();
  final seconds = (((coordinate - degrees) * 60 - minutes) * 60).toStringAsFixed(2);
  return '$degrees°$minutes\'$seconds"';
}

// EXIF 데이터 쓰기 (예시로 단순 반환)
Future<List<int>> _writeExifData(List<int> bytes, Map<String, IfdTag> updatedData) async {
  // EXIF 데이터를 수정한 바이트 반환 (실제 로직 구현 필요)
  return bytes;
}

AndroidManifest.xml 권한

<!-- 갤러리 저장 !-->
    <uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="28" />
    <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" tools:ignore="ScopedStorage" />

(25.01.23)
뭘 어떻게 시도해야할까나.. 지금까지의 상황은 모듈도 다양하게 바꾸어봤지만 제대로 수정을 해도 메타데이터가 생성된 시점으로 갱신되거나, 원본 이미지의 메타데이터를 그대로 유지하고있다는 문제가 있다.

이는 코드의 문제는 아닌 것으로 추정된다..하지만 방금 또다른 접근 방법을 찾아내어 시도해보겠다.
https://coding-shop.tistory.com/m/206
잘안되서.. 다시 GPT를 그냥 굴려보았다.

import 'dart:io';
import 'dart:typed_data';
import 'package:exif/exif.dart';
import 'package:image/image.dart' as img;

Future<File?> updatePhotoMetadata(
    File imageFile, double latitude, double longitude, DateTime dateTime) async {
  try {
    // 이미지 파일 읽기
    final imageBytes = await imageFile.readAsBytes();

    // EXIF 데이터 읽기
    final Map<String, IfdTag> exifData = await readExifFromBytes(imageBytes);

    if (exifData.isEmpty) {
      print("[DEBUG] EXIF 데이터가 없습니다.");
      return null;
    }

    // GPS 정보 업데이트
    exifData['GPS GPSLatitude'] = IfdTag(
      tag: 0x0002,
      tagType: 'Ratio',
      printable: _formatDms(latitude.abs()),
      values: IfdRatios(_convertToDmsRatios(latitude.abs())),
    );
    exifData['GPS GPSLongitude'] = IfdTag(
      tag: 0x0004,
      tagType: 'Ratio',
      printable: _formatDms(longitude.abs()),
      values: IfdRatios(_convertToDmsRatios(longitude.abs())),
    );
    exifData['GPS GPSLatitudeRef'] = IfdTag(
      tag: 0x0001,
      tagType: 'ASCII',
      printable: latitude >= 0 ? 'N' : 'S',
      values: IfdBytes(Uint8List.fromList([latitude >= 0 ? 0x4E : 0x53])),
    );
    exifData['GPS GPSLongitudeRef'] = IfdTag(
      tag: 0x0003,
      tagType: 'ASCII',
      printable: longitude >= 0 ? 'E' : 'W',
      values: IfdBytes(Uint8List.fromList([longitude >= 0 ? 0x45 : 0x57])),
    );

    // 날짜와 시간 정보 업데이트
    final formattedDateTime =
        '${dateTime.year}:${dateTime.month.toString().padLeft(2, '0')}:${dateTime.day.toString().padLeft(2, '0')} ${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}:${dateTime.second.toString().padLeft(2, '0')}';
    exifData['EXIF DateTimeOriginal'] = IfdTag(
      tag: 0x9003,
      tagType: 'ASCII',
      printable: formattedDateTime,
      values: IfdBytes(Uint8List.fromList(formattedDateTime.codeUnits)),
    );
    exifData['EXIF DateTimeDigitized'] = IfdTag(
      tag: 0x9004,
      tagType: 'ASCII',
      printable: formattedDateTime,
      values: IfdBytes(Uint8List.fromList(formattedDateTime.codeUnits)),
    );

    // 이미지 파일 디코딩
    final decodedImage = img.decodeImage(imageBytes);
    if (decodedImage == null) {
      print("[DEBUG] 이미지 디코딩 실패.");
      return null;
    }

    // EXIF 데이터를 이미지에 삽입하기 위해 EXIF 바이너리 생성
    final exifBytes = _createExifBytes(exifData);

    // EXIF 데이터를 이미지 바이트에 삽입
    final updatedImageBytes = img.encodeJpg(decodedImage, quality: 100);

    // EXIF 바이너리를 이미지 바이트에 삽입
    final resultImageBytes = await _insertExifToImage(updatedImageBytes, exifBytes);

    // 업데이트된 이미지 저장
    final updatedFile = File('${imageFile.parent.path}/updated_${imageFile.uri.pathSegments.last}');
    await updatedFile.writeAsBytes(resultImageBytes);

    return updatedFile;
  } catch (e) {
    print("[DEBUG] 이미지 메타데이터 업데이트 실패: $e");
    return null;
  }
}
// EXIF 데이터를 이미지에 삽입하는 함수
List<int> _insertExifToImage(List<int> imageBytes, Uint8List exifBytes) {
  // EXIF 바이너리를 JPEG 이미지에 삽입하는 로직
  final updatedImage = <int>[];
  updatedImage.addAll(exifBytes); // EXIF 데이터 추가
  updatedImage.addAll(imageBytes); // 이미지 데이터 추가

  return updatedImage;
}

// EXIF 데이터 직렬화 로직
Uint8List _createExifBytes(Map<String, IfdTag> exifData) {
  final exifBuffer = <int>[]; // EXIF 데이터를 담을 버퍼

  // EXIF 데이터 순회
  exifData.forEach((key, tag) {
    final tagBytes = tag.values.toList().cast<int>(); // dynamic -> int로 강제 변환
    exifBuffer.addAll(tagBytes); // 버퍼에 추가
  });

  return Uint8List.fromList(exifBuffer); // 직렬화된 EXIF 데이터를 반환
}



// GPS 좌표를 DMS 형식으로 변환
List<Ratio> _convertToDmsRatios(double coordinate) {
  final degrees = coordinate.floor();
  final minutes = ((coordinate - degrees) * 60).floor();
  final seconds = (((coordinate - degrees) * 60 - minutes) * 60 * 100).round();
  return [
    Ratio(degrees, 1),
    Ratio(minutes, 1),
    Ratio(seconds, 100),
  ];
}

// DMS 형식을 문자열로 변환
String _formatDms(double coordinate) {
  final degrees = coordinate.floor();
  final minutes = ((coordinate - degrees) * 60).floor();
  final seconds = (((coordinate - degrees) * 60 - minutes) * 60).toStringAsFixed(2);
  return '$degrees°$minutes\'$seconds"';
}

직렬화 부분에서 문제가 있는데 아닐까.. 싶다. 이미지 메타 업데이트 실패 cast ratio to int뜸.. 생각보다 넘 어렵넹

profile
이제 3학년..

0개의 댓글