diff --git a/processing_provider/Reamb_ImportPhotos.py b/processing_provider/Reamb_ImportPhotos.py
index 44802f3..eb67b5b 100644
--- a/processing_provider/Reamb_ImportPhotos.py
+++ b/processing_provider/Reamb_ImportPhotos.py
@@ -42,9 +42,28 @@
from lftools.translations.translate import translate
import os, re
import processing
+import math
+import struct
from math import pi, atan, degrees
from qgis.PyQt.QtGui import QIcon
-
+from lftools.dependencies import ensure_pillow
+
+# Tenta carregar o Pillow de forma segura
+Image = ensure_pillow()
+if Image:
+ from PIL import TiffTags, ExifTags
+ from PIL.TiffImagePlugin import ImageFileDirectory_v2
+ from PIL.TiffTags import TAGS
+ ImageFileDirectory_v2._load_dispatch[13] = ImageFileDirectory_v2._load_dispatch[TiffTags.LONG]
+
+ try:
+ from pillow_heif import register_heif_opener
+ register_heif_opener()
+ except ImportError:
+ pass
+else:
+ # Se falhar, as classes dependentes de PIL podem falhar, mas o algoritmo ainda será registrado no QGIS
+ pass
class ImportPhotos(QgsProcessingAlgorithm):
@@ -76,26 +95,85 @@ def icon(self):
txt_en = 'Imports photos with geotag to a Point Layer.'
txt_pt = 'Importa fotos com geotag para uma camada de pontos.'
- figure = 'images/tutorial/reamb_geotag.jpg'
-
+
def shortHelpString(self):
social_BW = Imgs().social_BW
+ figure = 'images/tutorial/reamb_geotag.jpg'
+
+ help_en = f"""{self.txt_en}
+
This tool is designed for high-precision photo import, supporting modern drone and camera metadata (EXIF/XMP).
+
Main Features:
+
+ - Smart GPS: Prioritizes EXIF data, with robust fallbacks to XMP (Absolute/Relative Altitude) for DNG and TIFF files.
+ - Azimuth Logic: Calculates orientation based on a prioritized hierarchy:
+
+ - EXIF GPS Direction (Tag 17)
+ - Calculated (vector between points)
+ - Gimbal Yaw (DJI XMP)
+ - Flight Yaw (Aircraft Yaw)
+
+
+ - Source Tracking: Stores the origin of orientation in the az_source field (EXIF, CALCULATED, GIMBAL, FLIGHT).
+ - DNG/TIFF Robustness: Native binary parser ensures full image resolution and metadata are extracted, even when libraries only see thumbnails.
+
+
Parameter Groups:
+
+ - Camera: Sensor size, focal length, real resolution (ImgW/ImgH), and calculated FOV.
+ - Exposure: Photographic metadata (ISO, Aperture, Shutter, Bias).
+ - Orientation: Yaw, Pitch, Roll for both Flight and Gimbal, optimized for 3D displays.
+
+
Formats:
+
+ - Full support: JPG, TIFF, DNG
+ - Limited support: HEIC, RAW (CR2, ARW)
+
"""
+
+ help_pt = f"""{self.txt_pt}
+
Ferramenta de alta precisão para importação de fotos, com suporte avançado a metadados EXIF/XMP de drones e câmeras modernas.
+
Principais Recursos:
+
+ - GPS Inteligente: Prioriza EXIF nativo, com fallbacks robustos para XMP (Altitude Absoluta/Relativa) em arquivos DNG e TIFF.
+ - Lógica de Azimute: Determina a orientação seguindo uma hierarquia de prioridade:
+
+ - Direção GPS EXIF (Tag 17)
+ - Calculado (vetor entre pontos sequenciais)
+ - Gimbal Yaw (Dados DJI XMP)
+ - Flight Yaw (Guinada da Aeronave)
+
+
+ - Rastreio de Origem: Registra a origem da orientação no campo az_source (EXIF, CALCULATED, GIMBAL, FLIGHT).
+ - Robustez DNG/TIFF: Parser binário nativo garante a extração da resolução real e metadados, mesmo quando miniaturas são detectadas.
+
+
Grupos de Parâmetros:
+
+ - Câmera: Tamanho do sensor, distância focal, resolução real (ImgW/ImgH) e FOV calculado.
+ - Exposição: Metadados fotográficos (ISO, Abertura, Obturação, Compensação).
+ - Orientação: Yaw, Pitch, Roll (Voo e Gimbal), otimizados para visualização 3D.
+
+
Formatos:
+
+ - Suporte total: JPG, TIFF, DNG
+ - Suporte limitado: HEIC, RAW (CR2, ARW)
+
"""
+
footer = '''
-
), self.figure) +''')
+
'''+self.tr('Author: Leandro Franca', 'Autor: Leandro França')+'''
'''+ social_BW + '''
'''
- return self.tr(self.txt_en, self.txt_pt) + footer
+ return self.tr(help_en, help_pt) + footer
FOLDER = 'FOLDER'
NONGEO = 'NONGEO'
OUTPUT = 'OUTPUT'
SUBFOLDER = 'SUBFOLDER'
AZIMUTH = 'AZIMUTH'
- YPR = 'YPR'
+ CAMERA = 'CAMERA'
+ ORIENTATION = 'ORIENTATION'
+ EXPOSURE = 'EXPOSURE'
STYLE = 'STYLE'
def initAlgorithm(self, config=None):
@@ -127,9 +205,25 @@ def initAlgorithm(self, config=None):
self.addParameter(
QgsProcessingParameterBoolean(
- self.YPR,
- self.tr('Yaw, pitch, roll'),
- defaultValue = False
+ self.CAMERA,
+ self.tr('Camera (sensor, lens, resolution)', 'Câmera (sensor, lente, resolução)'),
+ defaultValue = True
+ )
+ )
+
+ self.addParameter(
+ QgsProcessingParameterBoolean(
+ self.ORIENTATION,
+ self.tr('Orientation (yaw, pitch, roll)', 'Orientação (yaw, pitch, roll)'),
+ defaultValue = True
+ )
+ )
+
+ self.addParameter(
+ QgsProcessingParameterBoolean(
+ self.EXPOSURE,
+ self.tr('Exposure (ISO, aperture, shutter speed)', 'Exposição (ISO, abertura, obturação)'),
+ defaultValue = True
)
)
@@ -166,18 +260,6 @@ def initAlgorithm(self, config=None):
def processAlgorithm(self, parameters, context, feedback):
- from lftools.dependencies import ensure_pillow
- Image = ensure_pillow(feedback)
- if Image is None:
- raise QgsProcessingException(
- "The Pillow library (PIL) is required for this tool and could not be loaded automatically."
- )
-
- from PIL import TiffTags, ExifTags
- from PIL.TiffTags import TAGS
- from PIL.TiffImagePlugin import ImageFileDirectory_v2
- ImageFileDirectory_v2._load_dispatch[13] = ImageFileDirectory_v2._load_dispatch[TiffTags.LONG]
-
pasta = self.parameterAsFile(
parameters,
self.FOLDER,
@@ -200,9 +282,21 @@ def processAlgorithm(self, parameters, context, feedback):
if CalcAz:
Atributos = []
- YPR = self.parameterAsBool(
+ InCamera = self.parameterAsBool(
+ parameters,
+ self.CAMERA,
+ context
+ )
+
+ InOrientacao = self.parameterAsBool(
+ parameters,
+ self.ORIENTATION,
+ context
+ )
+
+ InExposição = self.parameterAsBool(
parameters,
- self.YPR,
+ self.EXPOSURE,
context
)
@@ -213,15 +307,19 @@ def processAlgorithm(self, parameters, context, feedback):
)
feedback.pushInfo(self.tr('Checking files in the folder...', 'Checando arquivos na pasta...'))
+
+ # Formatos: Prioridade e Limitados (Instruction 5 & 23)
+ ext_full = ('.jpg', '.jpeg', '.tif', '.tiff', '.dng', '.heic', '.heif', '.cr2', '.arw')
+
lista = []
if subpasta:
for root, dirs, files in os.walk(pasta, topdown=True):
for name in files:
- if (name).lower().endswith(('.jpg', '.jpeg', '.tif', '.tiff', '.dng')):
+ if (name).lower().endswith(ext_full):
lista += [os.path.join(root, name)]
else:
for item in os.listdir(pasta):
- if (item).lower().endswith(('.jpg', '.jpeg', '.tif', '.tiff', '.dng')):
+ if (item).lower().endswith(ext_full):
lista += [os.path.join(pasta, item)]
tam = len(lista)
@@ -229,64 +327,6 @@ def processAlgorithm(self, parameters, context, feedback):
if os.path.isdir(fotos_nao_geo):
copy_ngeo = True
- # Funcao para transformar os dados do EXIF em coordenadas em graus decimais
- def coordenadas(exif):
- try:
- ref_lat = exif['GPSInfo'][1][0]
- ref_lon = exif['GPSInfo'][3][0]
- sinal_lat, sinal_lon = 0, 0
- if ref_lat == 'S':
- sinal_lat = -1
- elif ref_lat == 'N':
- sinal_lat = 1
- if ref_lon == 'W':
- sinal_lon = -1
- elif ref_lon == 'E':
- sinal_lon = 1
-
- try:
- grausLat,grausLon = exif['GPSInfo'][2][0][0], exif['GPSInfo'][4][0][0]
- minLat, minLon = exif['GPSInfo'][2][1][0], exif['GPSInfo'][4][1][0]
- segLat = exif['GPSInfo'][2][2][0]/float(exif['GPSInfo'][2][2][1])
- segLon = exif['GPSInfo'][4][2][0]/float(exif['GPSInfo'][4][2][1])
- except:
- grausLat,grausLon = exif['GPSInfo'][2][0], exif['GPSInfo'][4][0]
- minLat, minLon = exif['GPSInfo'][2][1], exif['GPSInfo'][4][1]
- segLat = exif['GPSInfo'][2][2]
- segLon = exif['GPSInfo'][4][2]
- if sinal_lat!=0 and sinal_lon!=0:
- lat = sinal_lat*(float(grausLat)+minLat/60.0+segLat/3600.0)
- lon = sinal_lon*(float(grausLon)+minLon/60.0+segLon/3600.0)
- return lat, lon
- except:
- return 0,0
-
- # Funcao para pegar Azimute
- def azimute(exif):
- Az = exif['GPSInfo'][17]
- if isinstance(Az, tuple):
- Az = Az[0]/float(Az[1])
- return Az
- else:
- return Az
-
- # Funcao para gerar o padrao data-hora
- def data_hora(texto):
- data_hora = texto.replace(' ',':').replace('-',':')
- data_hora = data_hora.split(':')
- ano = int(data_hora[0])
- mes = int(data_hora[1])
- dia = int(data_hora[2])
- hora = int(data_hora[3])
- minuto = int(data_hora[4])
- segundo = int(data_hora[5])
- data_hora = unicode(datetime.datetime(ano, mes, dia, hora, minuto, segundo))
- return data_hora
-
- # Mensagem de erro
- def erro_msg(arquivo):
- return self.tr('The file "{}" has no geotag!'.format(arquivo), 'A imagem "{}" não possui geotag!'.format(arquivo))
-
# Criando Output
crs = QgsCoordinateReferenceSystem('EPSG:4326')
fields = QgsFields()
@@ -294,20 +334,34 @@ def erro_msg(arquivo):
fields.append(QgsField(self.tr('longitude'), QVariant.Double))
fields.append(QgsField(self.tr('latitude'), QVariant.Double))
fields.append(QgsField(self.tr('altitude'), QVariant.Double))
- fields.append(QgsField(self.tr('azimuth'), QVariant.Int))
+ fields.append(QgsField(self.tr('azimuth'), QVariant.Double))
+ fields.append(QgsField(self.tr('az_source'), QVariant.String))
fields.append(QgsField(self.tr('date_time'), QVariant.String))
fields.append(QgsField(self.tr('path'), QVariant.String))
- fields.append(QgsField(self.tr('make','fabricante'), QVariant.String))
- fields.append(QgsField(self.tr('model','modelo'), QVariant.String))
- if YPR:
+
+ if InCamera:
+ fields.append(QgsField(self.tr('make','fabricante'), QVariant.String))
+ fields.append(QgsField(self.tr('model','modelo'), QVariant.String))
+ fields.append(QgsField(self.tr('dimensions','dimensões'), QVariant.String))
+ fields.append(QgsField(self.tr('FOV'), QVariant.Double))
+ fields.append(QgsField(self.tr('SensorSize'), QVariant.String))
+ fields.append(QgsField('FocalLen', QVariant.Double))
+ fields.append(QgsField('ImgW', QVariant.Int))
+ fields.append(QgsField('ImgH', QVariant.Int))
+
+ if InExposição:
+ fields.append(QgsField(self.tr('iso'), QVariant.String))
+ fields.append(QgsField(self.tr('exposure_bias','exp_bias'), QVariant.String))
+ fields.append(QgsField(self.tr('aperture','abertura'), QVariant.String))
+ fields.append(QgsField(self.tr('shutter_speed','obturação'), QVariant.String))
+
+ if InOrientacao:
fields.append(QgsField(self.tr('FlightYaw'), QVariant.Double))
fields.append(QgsField(self.tr('FlightPitch'), QVariant.Double))
fields.append(QgsField(self.tr('FlightRoll'), QVariant.Double))
fields.append(QgsField(self.tr('GimbalYaw'), QVariant.Double))
fields.append(QgsField(self.tr('GimbalPitch'), QVariant.Double))
fields.append(QgsField(self.tr('GimbalRoll'), QVariant.Double))
- fields.append(QgsField(self.tr('FOV'), QVariant.Double))
- fields.append(QgsField(self.tr('SensorSize'), QVariant.String))
(sink, dest_id) = self.parameterAsSink(
parameters,
@@ -324,19 +378,25 @@ def erro_msg(arquivo):
for index, filepath in enumerate(lista):
- if (filepath).lower().endswith(('.jpg', '.jpeg')):
+ ext_file = (filepath).lower()
+ if ext_file.endswith(('.jpg', '.jpeg', '.png', '.bmp', '.webp', '.heic', '.heif')):
caminho, arquivo = os.path.split(filepath)
try:
img = Image.open(os.path.join(caminho,arquivo))
- if img._getexif():
- exif = {
- ExifTags.TAGS[k]: v
- for k, v in img._getexif().items()
- if k in ExifTags.TAGS
- }
+ raw_exif = img._getexif()
+ if raw_exif:
+ # Dicionário robusto com IDs e nomes
+ exif = {}
+ for k, v in raw_exif.items():
+ exif[k] = v
+ if k in ExifTags.TAGS:
+ exif[ExifTags.TAGS[k]] = v
else:
exif = {}
+
+ dimensions, iso_str, exp_str, fnum_str, obt_str, img_w, img_h = self.format_photo_metadata(img, exif)
+
lon, lat = 0, 0
Az = None
date_time = None
@@ -344,21 +404,63 @@ def erro_msg(arquivo):
img.close()
except:
lon = 0
+ img_w = img_h = 0
exif = {}
+
+ # 1. Metadados de Geometria e Sensor (Prioridade XMP para Drones/DNG)
+ FlightYaw = FlightPitch = FlightRoll = GimbalYaw = GimbalPitch = GimbalRoll = FOV = SensorSize = None
+ xmp_data = self.extract_image_metadata(filepath, exif)
+
+ # Desempacotar metadados XMP
+ (FlightYaw, FlightPitch, FlightRoll,
+ GimbalYaw, GimbalPitch, GimbalRoll,
+ FOV, SensorSize, focal_len, sens_w,
+ xmp_lat, xmp_lon, xmp_alt, xmp_alt_rel,
+ xmp_iso, xmp_obt, xmp_fnum, xmp_exp,
+ xmp_w, xmp_h) = xmp_data
+
+ # 2. Resolução (Correção para miniaturas de DNG)
+ if xmp_w and xmp_h:
+ if img_w < 1000 or img_h < 1000: # Provável miniatura
+ img_w, img_h = int(xmp_w), int(xmp_h)
+ dimensions = f"{img_w}x{img_h}"
+
+ # 3. GPS (Fallback/Prioridade XMP se EXIF falhar)
if 'GPSInfo' in exif:
- lat, lon = coordenadas(exif)
- if lat != 0:
- if 17 in exif['GPSInfo']:
- Az = float(azimute(exif))
- if 6 in exif['GPSInfo']:
- try:
- altitude = float(exif['GPSInfo'][6][0])/exif['GPSInfo'][6][1]
- except:
- altitude = float(exif['GPSInfo'][6])
+ lat, lon = self._coordenadas_exif(exif)
+ if 17 in exif['GPSInfo']:
+ Az = float(self._azimute_exif(exif))
+ if 6 in exif['GPSInfo']:
+ try:
+ altitude = float(exif['GPSInfo'][6][0])/exif['GPSInfo'][6][1]
+ except:
+ altitude = float(exif['GPSInfo'][6])
+
+ # Se EXIF falhar (como no DNG), usa XMP
+ if (lat == 0 or lon == 0) and (xmp_lat is not None and xmp_lon is not None):
+ lat, lon = xmp_lat, xmp_lon
+ if altitude is None and xmp_alt is not None:
+ altitude = xmp_alt
+ elif altitude is None and xmp_alt_rel is not None:
+ altitude = xmp_alt_rel
+
+ # 4. Fotometria (Se estiver vazio e tiver no XMP)
+ if not iso_str and xmp_iso: iso_str = f"ISO{int(xmp_iso)}"
+ if not fnum_str and xmp_fnum: fnum_str = f"f/{xmp_fnum:.1f}"
+ if not exp_str and xmp_exp is not None:
+ exp_str = f"EXP{float(xmp_exp):.1f}".replace('EXP-0.0', 'EXP0').replace('EXP0.0', 'EXP0')
+ if not obt_str and xmp_obt:
+ # Formatar obturação vinda do XMP (float)
+ if xmp_obt < 1:
+ obt_str = f"1/{int(round(1/xmp_obt))}"
+ else:
+ obt_str = f"{round(xmp_obt, 1)}s"
+
+ # 5. Data e Hora
if 'DateTimeOriginal' in exif:
- date_time = data_hora(exif['DateTimeOriginal'])
+ date_time = self._data_hora_format(exif['DateTimeOriginal'])
elif 'DateTime' in exif:
- date_time = data_hora(exif['DateTime'])
+ date_time = self._data_hora_format(exif['DateTime'])
if 'Make' in exif:
fabricante = str(exif['Make'].replace('\x00', ''))
@@ -371,28 +473,65 @@ def erro_msg(arquivo):
modelo = ''
if lon != 0:
- if YPR:
- FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll, FOV, SensorSize = self.extract_image_metadata(filepath, exif)
+ # Lógica de Azimute e Fonte
+ az_source = None
+ if Az is not None:
+ az_source = 'EXIF'
+ elif CalcAz:
+ az_source = 'CALCULATED' # Será preenchido depois
+ elif InOrientacao:
+ if GimbalYaw is not None:
+ Az = GimbalYaw
+ az_source = 'GIMBAL'
+ elif FlightYaw is not None:
+ Az = FlightYaw
+ az_source = 'FLIGHT'
+
if not CalcAz:
feature = QgsFeature(fields)
feature.setGeometry(QgsGeometry(QgsPoint(lon, lat, altitude if altitude != None else 0)))
- att = [arquivo, lon, lat, altitude, Az, date_time, filepath, fabricante, modelo]
- if YPR:
- att[4] = FlightYaw
- att += [FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll, FOV, SensorSize]
+
+ # Atributos Automáticos
+ # Usar casting explícito para evitar erros de conversão no QGIS 4.0 (Qt 6)
+ att = [arquivo, float(lon), float(lat), float(altitude) if altitude is not None else 0.0, float(Az) if Az is not None else None, az_source, date_time, filepath]
+
+ # Categorias
+ if InCamera:
+ # FOV, focal_len e sens_w devem ser float. img_w e img_h devem ser int.
+ att += [fabricante, modelo, dimensions,
+ float(FOV) if FOV is not None else None,
+ SensorSize,
+ float(focal_len) if focal_len is not None else None,
+ int(img_w) if img_w is not None else None,
+ int(img_h) if img_h is not None else None]
+ if InExposição:
+ att += [iso_str, exp_str, fnum_str, obt_str]
+ if InOrientacao:
+ # Todos os ângulos devem ser float
+ att += [float(val) if val is not None else None for val in [FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll]]
+
feature.setAttributes(att)
sink.addFeature(feature, QgsFeatureSink.FastInsert)
else:
- if YPR:
- Atributos += [[arquivo, lon, lat, altitude, Az, date_time, filepath, fabricante, modelo, FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll, FOV, SensorSize]]
- else:
- Atributos += [[arquivo, lon, lat, altitude, Az, date_time, filepath, fabricante, modelo]]
+ att = [arquivo, float(lon), float(lat), float(altitude) if altitude is not None else 0.0, float(Az) if Az is not None else None, az_source, date_time, filepath]
+ if InCamera:
+ att += [fabricante, modelo, dimensions,
+ float(FOV) if FOV is not None else None,
+ SensorSize,
+ float(focal_len) if focal_len is not None else None,
+ int(img_w) if img_w is not None else None,
+ int(img_h) if img_h is not None else None]
+ if InExposição:
+ att += [iso_str, exp_str, fnum_str, obt_str]
+ if InOrientacao:
+ att += [float(val) if val is not None else None for val in [FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll]]
+ Atributos += [att]
else:
feedback.reportError(erro_msg(arquivo))
if copy_ngeo:
shutil.copy2(os.path.join(pasta, arquivo), os.path.join(fotos_nao_geo, arquivo))
- elif (filepath).lower().endswith(('.tif', '.tiff', '.dng')):
+ elif ext_file.endswith(('.tif', '.tiff', '.dng', '.cr2', '.arw')):
caminho, arquivo = os.path.split(filepath)
img = Image.open(os.path.join(caminho,arquivo))
@@ -405,6 +544,8 @@ def erro_msg(arquivo):
tag_name = TAGS.get(tag_id)
meta_dict[tag_name] = value
+ dimensions, iso_str, exp_str, fnum_str, obt_str, img_w, img_h = self.format_photo_metadata(img, meta_dict)
+
gps_offset = img.tag_v2.get(0x8825)
tags = {}
if gps_offset:
@@ -423,55 +564,140 @@ def erro_msg(arquivo):
altitude = None
if 'GPSLatitudeRef' in tags:
- lat_ref = str(tags['GPSLatitudeRef'])
- lat = eval(str(tags['GPSLatitude']))
- lat = (-1 if lat_ref.upper() == 'S' else 1)*(lat[0] + lat[1]/60 + lat[2]/3600)
- lon_ref = str(tags['GPSLongitudeRef'])
- lon = eval(str(tags['GPSLongitude']))
- lon = (-1 if lon_ref.upper() == 'W' else 1)*(lon[0] + lon[1]/60 + lon[2]/3600)
- alt_ref = str(tags['GPSAltitudeRef'])
- altitude = eval(str(tags['GPSAltitude']))
try:
- date_time = data_hora(meta_dict['DateTime'][0])
- except:
- date_time = data_hora(meta_dict['DateTime'])
+ lat_ref = str(tags['GPSLatitudeRef'])
+ # Usando parsing explicito em vez de eval()
+ lat_val = self._safe_rational_eval(tags['GPSLatitude'])
+ lat = (-1 if lat_ref.upper() == 'S' else 1)*(float(lat_val[0]) + float(lat_val[1])/60 + float(lat_val[2])/3600)
+
+ lon_ref = str(tags['GPSLongitudeRef'])
+ lon_val = self._safe_rational_eval(tags['GPSLongitude'])
+ lon = (-1 if lon_ref.upper() == 'W' else 1)*(float(lon_val[0]) + float(lon_val[1])/60 + float(lon_val[2])/3600)
+
+ alt_val = self._safe_rational_eval(tags['GPSAltitude'])
+ altitude = float(alt_val[0])/alt_val[1] if alt_val[1] != 0 else float(alt_val[0])
+
+ # Extração de Azimute do GPS (tag 17)
+ if 'GPSImgDirection' in tags:
+ az_val = self._safe_rational_eval(tags['GPSImgDirection'])
+ Az = float(az_val[0])/az_val[1] if az_val[1] != 0 else float(az_val[0])
+ except Exception as e:
+ feedback.reportError(f"Error parsing GPS TIFF tags: {e}")
+ pass
+
+ # Data e Hora
+ for tag_date in ['DateTimeOriginal', 'DateTime', 36867, 306]:
+ if tag_date in meta_dict:
+ val = meta_dict[tag_date]
+ if isinstance(val, (tuple, list)): val = val[0]
+ date_time = self._data_hora_format(str(val))
+ break
+
if 'Make' in meta_dict:
- if isinstance(meta_dict['Make'], tuple):
- fabricante = str(meta_dict['Make'][0])
- else:
- fabricante = str(meta_dict['Make'])
+ fabricante = str(meta_dict['Make'][0]) if isinstance(meta_dict['Make'], tuple) else str(meta_dict['Make'])
+ fabricante = fabricante.replace('\x00', '').strip()
else:
fabricante = ''
if 'Model' in meta_dict:
- if isinstance(meta_dict['Model'], tuple):
- modelo = str(meta_dict['Model'][0])
- else:
- modelo = str(meta_dict['Model'])
+ modelo = str(meta_dict['Model'][0]) if isinstance(meta_dict['Model'], tuple) else str(meta_dict['Model'])
+ modelo = modelo.replace('\x00', '').strip()
else:
modelo = ''
if lon != 0:
- if YPR:
- FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll, FOV, SensorSize = self.extract_image_metadata(filepath, meta_dict)
+ # 1. Metadados de Geometria e Sensor (Prioridade XMP para Drones/DNG)
+ FlightYaw = FlightPitch = FlightRoll = GimbalYaw = GimbalPitch = GimbalRoll = FOV = SensorSize = None
+ xmp_data = self.extract_image_metadata(filepath, meta_dict)
+
+ # Desempacotar metadados XMP
+ (FlightYaw, FlightPitch, FlightRoll,
+ GimbalYaw, GimbalPitch, GimbalRoll,
+ FOV, SensorSize, focal_len, sens_w,
+ xmp_lat, xmp_lon, xmp_alt, xmp_alt_rel,
+ xmp_iso, xmp_obt, xmp_fnum, xmp_exp,
+ xmp_w, xmp_h) = xmp_data
+
+ # 2. Resolução (Correção para miniaturas de DNG)
+ if xmp_w and xmp_h:
+ if img_w < 1000 or img_h < 1000: # Provável miniatura
+ img_w, img_h = int(xmp_w), int(xmp_h)
+ dimensions = f"{img_w}x{img_h}"
+
+ # 3. GPS (Fallback/Prioridade XMP para DNG)
+ # Se EXIF falhar (como no DNG), usa XMP
+ if (lat == 0 or lon == 0) and (xmp_lat is not None and xmp_lon is not None):
+ lat, lon = xmp_lat, xmp_lon
+ if altitude is None and xmp_alt is not None:
+ altitude = xmp_alt
+ elif altitude is None and xmp_alt_rel is not None:
+ altitude = xmp_alt_rel
+
+ # 4. Fotometria (Se estiver vazio e tiver no XMP)
+ if not iso_str and xmp_iso: iso_str = f"ISO{int(xmp_iso)}"
+ if not fnum_str and xmp_fnum: fnum_str = f"f/{xmp_fnum:.1f}"
+ if not exp_str and xmp_exp is not None:
+ exp_str = f"EXP{float(xmp_exp):.1f}".replace('EXP-0.0', 'EXP0').replace('EXP0.0', 'EXP0')
+ if not obt_str and xmp_obt:
+ if xmp_obt < 1:
+ obt_str = f"1/{int(round(1/xmp_obt))}"
+ else:
+ obt_str = f"{round(xmp_obt, 1)}s"
+
+ # Lógica de Azimute e Fonte
+ az_source = None
+ if Az is not None:
+ az_source = 'EXIF'
+ elif CalcAz:
+ az_source = 'CALCULATED' # Será preenchido depois
+ elif InOrientacao:
+ if GimbalYaw is not None:
+ Az = GimbalYaw
+ az_source = 'GIMBAL'
+ elif FlightYaw is not None:
+ Az = FlightYaw
+ az_source = 'FLIGHT'
+
if not CalcAz:
feature = QgsFeature(fields)
feature.setGeometry(QgsGeometry(QgsPoint(lon, lat, altitude if altitude != None else 0)))
- att = [arquivo, lon, lat, altitude, Az, date_time, filepath, fabricante, modelo]
- if YPR:
- att[4] = FlightYaw
- att += [FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll, FOV, SensorSize]
+
+ # Atributos Automáticos
+ att = [arquivo, float(lon), float(lat), float(altitude) if altitude is not None else 0.0, float(Az) if Az is not None else None, az_source, date_time, filepath]
+
+ # Categorias
+ if InCamera:
+ att += [fabricante, modelo, dimensions,
+ float(FOV) if FOV is not None else None,
+ SensorSize,
+ float(focal_len) if focal_len is not None else None,
+ int(img_w) if img_w is not None else None,
+ int(img_h) if img_h is not None else None]
+ if InExposição:
+ att += [iso_str, exp_str, fnum_str, obt_str]
+ if InOrientacao:
+ att += [float(val) if val is not None else None for val in [FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll]]
+
feature.setAttributes(att)
sink.addFeature(feature, QgsFeatureSink.FastInsert)
else:
- if YPR:
- Atributos += [[arquivo, lon, lat, altitude, Az, date_time, filepath, fabricante, modelo, FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll, FOV, SensorSize]]
- else:
- Atributos += [[arquivo, lon, lat, altitude, Az, date_time, filepath, fabricante, modelo]]
+ att = [arquivo, float(lon), float(lat), float(altitude) if altitude is not None else 0.0, float(Az) if Az is not None else None, az_source, date_time, filepath]
+ if InCamera:
+ att += [fabricante, modelo, dimensions,
+ float(FOV) if FOV is not None else None,
+ SensorSize,
+ float(focal_len) if focal_len is not None else None,
+ int(img_w) if img_w is not None else None,
+ int(img_h) if img_h is not None else None]
+ if InExposição:
+ att += [iso_str, exp_str, fnum_str, obt_str]
+ if InOrientacao:
+ att += [float(val) if val is not None else None for val in [FlightYaw, FlightPitch, FlightRoll, GimbalYaw, GimbalPitch, GimbalRoll]]
+ Atributos += [att]
else:
- feedback.reportError(erro_msg(arquivo))
+ feedback.reportError(self._erro_msg(arquivo))
else:
- feedback.reportError(erro_msg(arquivo))
+ feedback.reportError(self._erro_msg(arquivo))
if copy_ngeo:
shutil.copy2(os.path.join(pasta, arquivo), os.path.join(fotos_nao_geo, arquivo))
img.close()
@@ -485,9 +711,16 @@ def erro_msg(arquivo):
for k in range(len(Atributos)-1):
pntA = QgsPoint( float(Atributos[k][1]), float(Atributos[k][2]))
pntB = QgsPoint( float(Atributos[k+1][1]),float(Atributos[k+1][2]))
- Az = int(180*CalAZ(pntA, pntB)[0]/pi)
- Atributos[k][4] = Az
- Atributos[-1][4] = Az
+ res_az = CalAZ(pntA, pntB)[0]
+ if res_az is not None:
+ # Apenas atualiza se os pontos não forem coincidentes
+ Az = round(180*res_az/pi, 1)
+ Atributos[k][4] = Az
+ Atributos[k][5] = 'CALCULATED'
+ # O último ponto mantém o azimute do penúltimo ou o seu próprio gimbal
+ if len(Atributos) > 1:
+ Atributos[-1][4] = Atributos[-2][4]
+ Atributos[-1][5] = Atributos[-2][5]
# Criar feições
for att in Atributos:
@@ -512,145 +745,296 @@ def erro_msg(arquivo):
return {self.OUTPUT: dest_id}
+ def _read_tiff_ifd(self, f, offset, endian, name="IFD", depth=0, results=None):
+ """Metodo auxiliar para ler IFDs recursivamente em arquivos TIFF/DNG"""
+ if depth > 5 or results is None: return
+ f.seek(offset)
+ count_data = f.read(2)
+ if len(count_data) < 2: return
+ num_tags = struct.unpack(endian + 'H', count_data)[0]
+
+ # Tags de interesse: 256=W, 257=H, 34855=ISO, 33434=ExpTime, 33437=FNum, 34665=ExifIFD, 34853=GPS_IFD, 330=SubIFDs, 37386=Folen, 41989=F35, 17=GPSAz
+ tags_map = {
+ 256: 'w', 257: 'h', 34855: 'iso', 33434: 'obt', 33437: 'fnum',
+ 34665: 'exif_off', 34853: 'gps_off', 330: 'sub_off',
+ 37386: 'flen', 41989: 'f35', 17: 'az'
+ }
+
+ next_calls = []
+ for _ in range(num_tags):
+ tag_data = f.read(12)
+ if len(tag_data) < 12: break
+ tag_id, tag_type, count, val_offset = struct.unpack(endian + 'HHII', tag_data)
+
+ if tag_id in tags_map:
+ key = tags_map[tag_id]
+ if tag_id in [256, 257, 34855, 41989]: # Valores diretos ou curtos (W, H, ISO, F35)
+ if results.get(key) is None or (key in ['w', 'h'] and val_offset > results.get(key, 0)):
+ results[key] = val_offset
+ elif tag_id in [34665, 34853]: # Offsets
+ next_calls.append((val_offset, key))
+ elif tag_id == 330: # SubIFDs
+ if count == 1:
+ next_calls.append((val_offset, 'sub'))
+ elif count > 1:
+ curr_pos = f.tell()
+ f.seek(val_offset)
+ for _ in range(count):
+ off_data = f.read(4)
+ if len(off_data) == 4:
+ next_calls.append((struct.unpack(endian + 'I', off_data)[0], 'sub'))
+ f.seek(curr_pos)
+ elif tag_id in [33434, 33437, 37386, 17]: # Racionais
+ curr_pos = f.tell()
+ f.seek(val_offset)
+ rat_data = f.read(8)
+ if len(rat_data) == 8:
+ n, d = struct.unpack(endian + 'II', rat_data)
+ if d != 0: results[key] = n / d
+ f.seek(curr_pos)
+
+ for off, n in next_calls:
+ self._read_tiff_ifd(f, off, endian, n, depth + 1, results)
+
+ def format_photo_metadata(self, img, exif):
+ """
+ Baseado nos exemplos do usuário: 4000x3000, ISO100, EXP0.3, f/1.7, 1/2500
+ Retorna: dimensões, iso, compensação, abertura, obturação
+ """
+ # Função interna para extrair valor seja por nome ou ID
+ def get_val(tags_names_or_ids):
+ for t in tags_names_or_ids:
+ if t in exif: return exif[t]
+ return None
+
+ # Helper para converter racionais/objetos em frações num/den
+ def get_ratio(val):
+ if hasattr(val, 'numerator') and hasattr(val, 'denominator'):
+ return val.numerator, val.denominator
+ if isinstance(val, (tuple, list)) and len(val) == 2:
+ return val[0], val[1]
+ return None
+
+ # --- Resolucao ---
+ w = get_val(['ImageWidth', 256, 'ExifImageWidth', 40962])
+ h = get_val(['ImageLength', 257, 'ExifImageHeight', 40963])
+
+ # Correcao para DNG/TIFF (Pillow costuma ler a miniatura IFD0)
+ ext = ""
+ if hasattr(img, 'filename'): ext = os.path.splitext(img.filename)[1].lower()
+ if ext in ['.dng', '.tif', '.tiff'] or (w and h and (int(w) < 500 or int(h) < 500)):
+ tiff_res = {'w': None, 'h': None, 'iso': None, 'obt': None, 'fnum': None}
+ try:
+ with open(img.filename, 'rb') as f:
+ header = f.read(8)
+ endian = '<' if header[:2] == b'II' else '>'
+ first_ifd = struct.unpack(endian + 'I', header[4:8])[0]
+ self._read_tiff_ifd(f, first_ifd, endian, results=tiff_res)
+ if tiff_res['w'] and tiff_res['h']:
+ w, h = tiff_res['w'], tiff_res['h']
+ except: pass
+
+ if isinstance(w, (tuple, list)): w = w[0]
+ if isinstance(h, (tuple, list)): h = h[0]
+ if not w or not h: w, h = img.size
+ dimensions = f"{int(w)}x{int(h)}"
+
+ # --- Fotometria ---
+ iso = get_val(['ISOSpeedRatings', 34855])
+ if isinstance(iso, (tuple, list)): iso = iso[0]
+ if iso is None and ext in ['.dng', '.tif'] and 'tiff_res' in locals():
+ iso = tiff_res.get('iso')
+ iso_str = f"ISO{iso}" if iso else ""
+
+ exp = get_val(['ExposureBiasValue', 37380])
+ ratio_exp = get_ratio(exp)
+ if ratio_exp:
+ exp = ratio_exp[0]/ratio_exp[1] if ratio_exp[1] != 0 else ratio_exp[0]
+ exp_str = f"EXP{float(exp):.1f}".replace('EXP-0.0', 'EXP0').replace('EXP0.0', 'EXP0') if exp is not None else ""
+
+ fnum = get_val(['FNumber', 33437])
+ ratio_fnum = get_ratio(fnum)
+ if ratio_fnum:
+ fnum = ratio_fnum[0]/ratio_fnum[1] if ratio_fnum[1] != 0 else ratio_fnum[0]
+ if not fnum and ext in ['.dng', '.tif'] and 'tiff_res' in locals():
+ fnum = tiff_res.get('fnum')
+ fnum_str = f"f/{float(fnum):.1f}" if fnum else ""
+
+ obt = get_val(['ExposureTime', 33434, 'ShutterSpeedValue', 37377, 'ShutterSpeed'])
+ if not obt and ext in ['.dng', '.tif'] and 'tiff_res' in locals():
+ obt = tiff_res.get('obt')
+
+ obt_str = ""
+ ratio_obt = get_ratio(obt)
+
+ if ratio_obt:
+ num, den = ratio_obt
+ if den == 0:
+ obt_str = ""
+ else:
+ com_div = math.gcd(int(num), int(den))
+ num, den = num // com_div, den // com_div
+
+ if num == 1:
+ obt_str = f"1/{den}"
+ elif num >= den:
+ obt_str = f"{round(num/den, 1)}s"
+ else:
+ if den / num > 1.5:
+ obt_str = f"1/{int(round(den/num))}"
+ else:
+ obt_str = f"{num}/{den}"
+ elif isinstance(obt, (float, int)):
+ if 0 < obt < 1:
+ obt_str = f"1/{int(round(1/obt))}"
+ elif obt >= 1:
+ obt_str = f"{round(float(obt), 1)}s"
+ else:
+ obt_str = str(obt)
+ else:
+ # Última tentativa: converter o objeto estranho para string direta
+ obt_str = str(obt) if obt is not None else ""
+
+ return dimensions, iso_str, exp_str, fnum_str, obt_str, int(w), int(h)
+
+
def extract_image_metadata(self, image_path, exif_data):
"""
- Extrai yaw, pitch, roll, FOV e tamanho do sensor analisando XMP + EXIF.
- Compatível com diversos modelos DJI (Phantom, Mavic, Enterprise, Matrice).
+ Extrai yaw, pitch, roll, FOV e Tamanho do Sensor de imagens analisando o bloco XMP e EXIF.
+ Desenvolvido para ser resiliente a diferentes versões de firmware e modelos DJI (P4P até Matrice 350).
"""
- FlightYaw = FlightPitch = FlightRoll = None
- GimbalYaw = GimbalPitch = GimbalRoll = None
- FOV = SensorWidth = SensorHeight = None
+ def to_float(val):
+ if val is None: return None
+ if isinstance(val, (tuple, list)) and len(val) > 0:
+ val = val[0]
+ try: return float(val)
+ except:
+ if hasattr(val, 'numerator') and hasattr(val, 'denominator'):
+ return float(val.numerator) / val.denominator if val.denominator != 0 else 0.0
+ return None
- # Base de sensores DJI (mm)
+ FlightYaw = FlightPitch = FlightRoll = GimbalYaw = GimbalPitch = GimbalRoll = FOV = SensorWidth = SensorHeight = None
+ f_real = f_35mm = None
+
+ # Base de dados de sensores DJI (Largura x Altura em mm)
DJI_SENSORS = {
- 'FC6310': (13.2, 8.8), # Phantom 4 Pro
- 'FC6310S': (13.2, 8.8), # Phantom 4 Pro V2
- 'FC220': (13.2, 8.8), # Mavic 2 Pro
- 'FC2204': (6.4, 4.8), # Mavic Air 2
- 'FC3170': (13.2, 8.8), # Air 2S
- 'FC7303': (17.3, 13.0), # Mavic 3
- 'FC3682': (6.4, 4.8), # M2 Enterprise Adv
- 'FC8484': (17.3, 13.0), # Mavic 3 Enterprise
- 'FC8482': (6.4, 4.8), # Mavic 3 Thermal
- 'M30T': (6.4, 4.8), # Matrice 30T
- 'ZenmuseP1': (35.9, 24.0),
- 'ZenmuseH20': (6.17, 4.55),
- 'ZenmuseH20T': (6.17, 4.55),
- 'FC6510': (13.2, 8.8),
- 'FC6520': (17.3, 13.0),
- 'FC6540': (23.5, 15.7),
+ 'FC6310': (13.2, 8.8), 'FC6310S': (13.2, 8.8), 'FC220': (13.2, 8.8),
+ 'FC2204': (6.4, 4.8), 'FC3170': (13.2, 8.8), 'FC7303': (17.3, 13.0),
+ 'FC3682': (6.4, 4.8), 'FC8484': (17.3, 13.0), 'FC8482': (6.4, 4.8),
+ 'M30T': (6.4, 4.8), 'ZenmuseP1': (35.9, 24.0), 'ZenmuseH20': (6.17, 4.55),
+ 'ZenmuseH20T': (6.17, 4.55), 'FC6510': (13.2, 8.8), 'FC6520': (17.3, 13.0),
+ 'FC6540': (23.5, 15.7), 'FC330': (6.5, 4.88)
}
try:
- # -------- 1. LEITURA DO XMP --------
+ # 1. Extração via XMP
with open(image_path, "rb") as fb:
- data = fb.read(131072) # lê só 128KB (mais eficiente)
-
- import re
+ data = fb.read(262144)
+
m = re.search(br"", data, re.IGNORECASE)
- if not m:
- m = re.search(br"", data, re.IGNORECASE)
-
+ if not m: m = re.search(br"", data, re.IGNORECASE)
+
if m:
xmp = m.group(0).decode("utf-8", errors="ignore")
-
- def find_attr_or_elem(names):
+ def find_attr_or_elem(names, as_string=False):
for name in names:
- # atributo
- rx_attr = re.compile(
- rf'(?:[\w-]+:)?{name}\s*=\s*["\']([\-+]?\d+(?:\.\d+)?)["\']',
- re.IGNORECASE
- )
+ rx_attr = re.compile(rf'(?:[\w-]+:)?{re.escape(name)}\s*=\s*["\']([\-+]?\d+(?:\.\d+)?)["\']', re.IGNORECASE)
m_attr = rx_attr.search(xmp)
if m_attr:
- return float(m_attr.group(1))
-
- # elemento XML
- rx_elem = re.compile(
- rf'<([^:>]*:)?{name}>([\-+]?\d+(?:\.\d+)?)([^:>]*:)?{name}>',
- re.IGNORECASE
- )
+ val = m_attr.group(1)
+ return val if as_string else float(val)
+ rx_elem = re.compile(rf'<([^:>]*:)?{re.escape(name)}\s*>([\-+]?\d+(?:\.\d+)?)\s*([^:>]*:)?{re.escape(name)}\s*>', re.IGNORECASE)
m_elem = rx_elem.search(xmp)
if m_elem:
- return float(m_elem.group(2))
-
+ val = m_elem.group(2)
+ return val if as_string else float(val)
return None
- # Drone
- FlightYaw = find_attr_or_elem(["FlightYawDegree", "Yaw", "DroneYawDegree"])
- FlightPitch = find_attr_or_elem(["FlightPitchDegree", "Pitch"])
- FlightRoll = find_attr_or_elem(["FlightRollDegree", "Roll"])
-
- # Gimbal
- GimbalYaw = find_attr_or_elem(["GimbalYawDegree", "CameraYaw"])
- GimbalPitch = find_attr_or_elem(["GimbalPitchDegree", "CameraPitch"])
- GimbalRoll = find_attr_or_elem(["GimbalRollDegree", "CameraRoll"])
-
- # Extras
- FOV = find_attr_or_elem(["FieldOfView", "HFOV"])
- SensorWidth = find_attr_or_elem(["SensorWidth"])
- SensorHeight = find_attr_or_elem(["SensorHeight"])
-
- # -------- 2. REFINAMENTO EXIF --------
+ FlightYaw = find_attr_or_elem(["FlightYawDegree", "YawDegree", "Yaw", "FlightYaw"])
+ FlightPitch = find_attr_or_elem(["FlightPitchDegree", "PitchDegree", "Pitch", "FlightPitch"])
+ FlightRoll = find_attr_or_elem(["FlightRollDegree", "RollDegree", "Roll", "FlightRoll"])
+ GimbalYaw = find_attr_or_elem(["GimbalYawDegree", "GimbalYaw", "PoseYawDegrees", "CameraYawDegree"])
+ GimbalPitch = find_attr_or_elem(["GimbalPitchDegree", "GimbalPitch", "PosePitchDegrees", "CameraPitchDegree"])
+ GimbalRoll = find_attr_or_elem(["GimbalRollDegree", "GimbalRoll", "PoseRollDegrees", "CameraRollDegree"])
+ FOV = find_attr_or_elem(["FieldOfView", "HorizontalFOV", "HFOV"])
+ SensorWidth = find_attr_or_elem(["SensorWidth", "SensorWidthmm"])
+ SensorHeight = find_attr_or_elem(["SensorHeight", "SensorHeightmm"])
+
+ XMPLat = find_attr_or_elem(["GPSLatitude", "Latitude"])
+ XMPLon = find_attr_or_elem(["GPSLongitude", "Longitude"])
+ XMPAlt = find_attr_or_elem(["AbsoluteAltitude", "GPSAltitude", "Altitude"])
+ XMPAltRel = find_attr_or_elem(["RelativeAltitude"])
+ XMPISO = find_attr_or_elem(["ISOSpeedRatings", "ISO", "ExposureIndex"])
+ XMPObt = find_attr_or_elem(["ExposureTime", "ShutterSpeedValue"])
+ XMPFNum = find_attr_or_elem(["FNumber", "ApertureValue"])
+ XMPExp = find_attr_or_elem(["ExposureBiasValue", "ExposureBias"])
+ XMPW = find_attr_or_elem(["FullImageWidth", "ImageWidth", "ExifImageWidth", "OriginalImageWidth"])
+ XMPH = find_attr_or_elem(["FullImageHeight", "ImageHeight", "ExifImageHeight", "OriginalImageHeight"])
+
+ # 2. Refinamento via IFD Nativo (para DNG/TIF onde EXIF falha)
+ ext = os.path.splitext(image_path)[1].lower()
+ tiff_res = {}
+ if ext in ['.dng', '.tif', '.tiff']:
+ try:
+ with open(image_path, 'rb') as f:
+ header = f.read(8)
+ endian = '<' if header[:2] == b'II' else '>'
+ first_ifd = struct.unpack(endian + 'I', header[4:8])[0]
+ self._read_tiff_ifd(f, first_ifd, endian, results=tiff_res)
+ except: pass
+
+ # 3. Refinamento de Metadados e Banco de Dados DJI
+ model = make = ""
if exif_data:
model = str(exif_data.get('Model', '')).replace('\x00', '').strip()
make = str(exif_data.get('Make', '')).replace('\x00', '').strip()
+ f_real = to_float(exif_data.get('FocalLength'))
+ f_35mm = to_float(exif_data.get('FocalLengthIn35mmFilm') or exif_data.get('FocalLengthIn35mmFormat'))
- f_real = exif_data.get('FocalLength')
- if isinstance(f_real, (tuple, list)) and f_real[1] != 0:
- f_real = float(f_real[0]) / f_real[1]
-
- # Se for DJI
- if "DJI" in make.upper() or model.startswith("FC"):
-
- # Pitch nadir
- if GimbalPitch is not None and abs(GimbalPitch) < 0.1:
- GimbalPitch = -90.0
-
- # Yaw fallback
- if GimbalYaw is None and FlightYaw is not None:
- GimbalYaw = FlightYaw
-
- # Sensor via banco
- if model in DJI_SENSORS:
- sw, sh = DJI_SENSORS[model]
- if SensorWidth is None:
- SensorWidth = sw
- if SensorHeight is None:
- SensorHeight = sh
-
- # Cálculo FOV
- f_35mm = exif_data.get('FocalLengthIn35mmFilm')
- if f_real and f_35mm:
- from math import atan, degrees
-
- if FOV is None:
- FOV = round(2 * degrees(atan(36.0 / (2.0 * float(f_35mm)))), 2)
-
- if SensorWidth is None:
- SensorWidth = round(36.0 * f_real / float(f_35mm), 2)
-
- # Altura do sensor
- if SensorWidth and SensorHeight is None:
- ratio = 0.75 # padrão 4:3
- if SensorWidth > 20:
- ratio = 0.66 # sensores grandes
- SensorHeight = round(SensorWidth * ratio, 2)
-
- sensor_str = (
- f"{SensorWidth} x {SensorHeight} mm"
- if (SensorWidth and SensorHeight)
- else (f"{SensorWidth} mm" if SensorWidth else None)
- )
+ # Fallbacks via Parser Binário
+ if not f_real: f_real = tiff_res.get('flen')
+ if not f_35mm: f_35mm = tiff_res.get('f35')
+ if GimbalYaw is None: GimbalYaw = tiff_res.get('az')
+
+ # Se ainda vazio, tenta Model/Make no XMP (comum em DNGs DJI)
+ if not model and 'xmp' in locals():
+ m_mod = re.search(r'Model=["\']([^"\']+)["\']', xmp)
+ if m_mod: model = m_mod.group(1)
+ m_mak = re.search(r'Make=["\']([^"\']+)["\']', xmp)
+ if m_mak: make = m_mak.group(1)
+
+ # Lógica Elástica DJI
+ if "DJI" in make.upper() or model.startswith("FC") or "ZENMUSE" in model.upper():
+ if GimbalPitch is not None and abs(GimbalPitch) < 0.1: GimbalPitch = -90.0
+ elif GimbalPitch is None and (model in ['FC3682', 'FC8482', 'FC330']): GimbalPitch = -90.0
+ if GimbalYaw is None and FlightYaw is not None: GimbalYaw = FlightYaw
+ if model in DJI_SENSORS:
+ sw, sh = DJI_SENSORS[model]
+ if SensorWidth is None: SensorWidth = sw
+ if SensorHeight is None: SensorHeight = sh
+
+ if FOV is None:
+ if f_35mm: FOV = round(2 * math.degrees(math.atan(36.0 / (2.0 * f_35mm))), 2)
+ elif f_real and SensorWidth: FOV = round(2 * math.degrees(math.atan(SensorWidth / (2.0 * f_real))), 2)
+
+ if SensorWidth is None and f_real and f_35mm:
+ SensorWidth = round(36.0 * f_real / f_35mm, 2)
+ if SensorWidth and SensorHeight is None:
+ ratio = 0.75 if SensorWidth < 20 else 0.66
+ SensorHeight = round(SensorWidth * ratio, 2)
- return (
- FlightYaw, FlightPitch, FlightRoll,
- GimbalYaw, GimbalPitch, GimbalRoll,
- FOV, sensor_str
- )
+ sensor_str = f"{SensorWidth} x {SensorHeight} mm" if (SensorWidth and SensorHeight) else (f"{SensorWidth} mm" if SensorWidth else None)
+
+ return (to_float(FlightYaw), to_float(FlightPitch), to_float(FlightRoll),
+ to_float(GimbalYaw), to_float(GimbalPitch), to_float(GimbalRoll),
+ to_float(FOV), sensor_str, to_float(f_real), to_float(SensorWidth),
+ to_float(XMPLat), to_float(XMPLon), to_float(XMPAlt), to_float(XMPAltRel),
+ to_float(XMPISO), to_float(XMPObt), to_float(XMPFNum), to_float(XMPExp),
+ to_float(XMPW), to_float(XMPH))
+
+ except Exception as e:
+ return None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None, None
- except Exception:
- return None, None, None, None, None, None, None, None
def postProcessAlgorithm(self, context, feedback):
@@ -670,3 +1054,72 @@ def postProcessAlgorithm(self, context, feedback):
layer.triggerRepaint()
return {self.OUTPUT: self.SAIDA}
+
+ def _coordenadas_exif(self, exif):
+ """Transforma os dados do EXIF em coordenadas em graus decimais"""
+ try:
+ gps_info = exif.get('GPSInfo', {})
+ ref_lat = gps_info.get(1)
+ ref_lon = gps_info.get(3)
+
+ sinal_lat = -1 if ref_lat == 'S' else 1 if ref_lat == 'N' else 0
+ sinal_lon = -1 if ref_lon == 'W' else 1 if ref_lon == 'E' else 0
+
+ lat_val = gps_info.get(2)
+ lon_val = gps_info.get(4)
+
+ def parse_gps_rational(val):
+ if isinstance(val[0], tuple): # Antigo formato Pillow
+ d = float(val[0][0]) / val[0][1]
+ m = float(val[1][0]) / val[1][1]
+ s = float(val[2][0]) / val[2][1]
+ else: # Formato novo
+ d, m, s = float(val[0]), float(val[1]), float(val[2])
+ return d + m/60.0 + s/3600.0
+
+ if sinal_lat != 0 and sinal_lon != 0:
+ lat = sinal_lat * parse_gps_rational(lat_val)
+ lon = sinal_lon * parse_gps_rational(lon_val)
+ return lat, lon
+ return 0, 0
+ except:
+ return 0, 0
+
+ def _azimute_exif(self, exif):
+ """Pega Azimute do EXIF"""
+ try:
+ Az = exif['GPSInfo'][17]
+ if isinstance(Az, (tuple, list)):
+ return Az[0]/float(Az[1]) if Az[1] != 0 else float(Az[0])
+ return float(Az)
+ except:
+ return None
+
+ def _data_hora_format(self, texto):
+ """Gera o padrao data-hora"""
+ try:
+ data_hora = texto.replace(' ',':').replace('-',':')
+ data_hora = data_hora.split(':')
+ ano, mes, dia, hora, minuto, segundo = map(int, data_hora[:6])
+ return str(datetime.datetime(ano, mes, dia, hora, minuto, segundo))
+ except:
+ return texto
+
+ def _erro_msg(self, arquivo):
+ """Mensagem de erro traduzida"""
+ return self.tr(f'The file "{arquivo}" has no geotag!', f'A imagem "{arquivo}" não possui geotag!')
+
+ def _safe_rational_eval(self, val_str):
+ """Converte strings de racionais TIFF '(num, den), ...' em lista de floats de forma segura"""
+ try:
+ # Tenta limpar e extrair numeros
+ # Ex: "((23, 1), (34, 1), (1234, 100))"
+ numbers = re.findall(r'(\d+)', str(val_str))
+ res = []
+ for i in range(0, len(numbers), 2):
+ num = int(numbers[i])
+ den = int(numbers[i+1]) if i+1 < len(numbers) else 1
+ res.append(num / den if den != 0 else num)
+ return res
+ except:
+ return [0, 1]