This commit is contained in:
2025-07-21 15:32:49 +09:00
parent d24ab69bb1
commit 4a4a0138da
20 changed files with 5506 additions and 170 deletions

5
.gitignore vendored
View File

@@ -360,4 +360,7 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
.venv/
venv/

View File

@@ -7,6 +7,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<COMReference Include="Microsoft.Office.Interop.Excel">
<VersionMinor>9</VersionMinor>
@@ -21,13 +22,85 @@
<ItemGroup>
<PackageReference Include="Npgsql" Version="9.0.1" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<!-- Teigha DLL 참조 - 실제 경로로 수정 필요 -->
<ItemGroup>
<Reference Include="TD_Mgd_23.12_16">
<HintPath>..\..\..\..\GitNet8\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\TD_Mgd_23.12_16.dll</HintPath>
<HintPath>D:\dev_Net8_git\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\TD_Mgd_23.12_16.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<!-- Copy all Teigha DLLs including native dependencies -->
<ItemGroup>
<None Include="D:\dev_Net8_git\trunk\DLL\Teigha\vc16_amd64dll_23.12SP2\*.dll">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<!-- Define fletimageanalysis files as content to be copied -->
<!-- Separate JSON files and other files for different copy behaviors -->
<ItemGroup>
<!-- JSON files - always copy as new -->
<FletImageAnalysisJsonFiles Include="fletimageanalysis\**\*.json" />
<!-- Other files - incremental copy -->
<FletImageAnalysisOtherFiles Include="fletimageanalysis\**\*" Exclude="fletimageanalysis\**\*.json;fletimageanalysis\**\*.pyc;fletimageanalysis\**\__pycache__\**;fletimageanalysis\**\*.tmp;fletimageanalysis\**\*.log;fletimageanalysis\**\*.git\**" />
</ItemGroup>
<!-- Enhanced copy target that handles both incremental updates and missing folder scenarios -->
<Target Name="CopyFletImageAnalysisFolder" AfterTargets="Build">
<!-- Always show what we're doing -->
<Message Text="Copying fletimageanalysis folder contents..." Importance="normal" />
<Message Text="JSON files to copy: @(FletImageAnalysisJsonFiles->Count())" Importance="normal" />
<Message Text="Other files to copy: @(FletImageAnalysisOtherFiles->Count())" Importance="normal" />
<!-- Copy JSON files - ALWAYS as new (SkipUnchangedFiles=false) -->
<Copy
SourceFiles="@(FletImageAnalysisJsonFiles)"
DestinationFiles="@(FletImageAnalysisJsonFiles->'$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="false"
OverwriteReadOnlyFiles="true" />
<Message Text="JSON files copied (always as new): @(FletImageAnalysisJsonFiles->Count())" Importance="high" />
<!-- Copy other files - incrementally (SkipUnchangedFiles=true) -->
<Copy
SourceFiles="@(FletImageAnalysisOtherFiles)"
DestinationFiles="@(FletImageAnalysisOtherFiles->'$(OutDir)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="true"
OverwriteReadOnlyFiles="true" />
<Message Text="Other files copied (incremental): @(FletImageAnalysisOtherFiles->Count())" Importance="normal" />
<!-- Verify critical JSON file exists after copy -->
<Error Condition="!Exists('$(OutDir)fletimageanalysis\mapping_table_json.json')"
Text="Critical file missing after copy: mapping_table_json.json" />
<Message Text="fletimageanalysis folder copy completed successfully." Importance="high" />
</Target>
<!-- Additional target to ensure folder is copied during publish -->
<Target Name="CopyFletImageAnalysisFolderOnPublish" AfterTargets="Publish">
<Message Text="Copying fletimageanalysis folder for publish..." Importance="high" />
<!-- Copy JSON files - always as new -->
<Copy
SourceFiles="@(FletImageAnalysisJsonFiles)"
DestinationFiles="@(FletImageAnalysisJsonFiles->'$(PublishDir)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="false"
OverwriteReadOnlyFiles="true" />
<!-- Copy other files - always as new for publish -->
<Copy
SourceFiles="@(FletImageAnalysisOtherFiles)"
DestinationFiles="@(FletImageAnalysisOtherFiles->'$(PublishDir)%(RecursiveDir)%(Filename)%(Extension)')"
SkipUnchangedFiles="false"
OverwriteReadOnlyFiles="true" />
<Message Text="fletimageanalysis folder publish copy completed." Importance="high" />
</Target>
</Project>

View File

@@ -100,24 +100,60 @@
Text="준비됨" FontSize="12"
HorizontalAlignment="Center" Margin="0,5"/>
<!-- 추출 버튼 -->
<Button x:Name="btnExtract" Grid.Row="2"
Content="🚀 추출 시작" Width="200" Height="45"
Margin="0,10,0,5" HorizontalAlignment="Center"
Click="BtnExtract_Click" FontSize="16" FontWeight="Bold"
Background="#A3BE8C" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#A3BE8C"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#8FAE74"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<!-- 추출 버튼 -->
<StackPanel Grid.Row="2" Orientation="Horizontal" HorizontalAlignment="Center" Margin="0,10,0,5">
<Button x:Name="btnExtract"
Content="🚀 DWG 추출" Width="150" Height="45"
Margin="5,0"
Click="BtnExtract_Click" FontSize="14" FontWeight="Bold"
Background="#A3BE8C" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#A3BE8C"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#8FAE74"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnPdfExtract"
Content="📄 PDF 추출" Width="150" Height="45"
Margin="5,0"
Click="BtnPdfExtract_Click" FontSize="14" FontWeight="Bold"
Background="#D08770" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#D08770"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#C5774F"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
<Button x:Name="btnMerge"
Content="🔗 합치기" Width="150" Height="45"
Margin="5,0"
Click="BtnMerge_Click" FontSize="14" FontWeight="Bold"
Background="#B48EAD" Foreground="White"
BorderThickness="0">
<Button.Style>
<Style TargetType="Button">
<Setter Property="Background" Value="#B48EAD"/>
<Style.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter Property="Background" Value="#A07A95"/>
</Trigger>
</Style.Triggers>
</Style>
</Button.Style>
</Button>
</StackPanel>
</Grid>
</GroupBox>

View File

@@ -3,6 +3,15 @@ using System.Windows;
using System.Diagnostics;
using System.Windows.Threading;
using DwgExtractorManual.Models;
using System.Linq;
using System.Collections.Generic;
using System.Threading.Tasks;
using System.Threading;
using System.Text;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace DwgExtractorManual
{
@@ -23,8 +32,8 @@ namespace DwgExtractorManual
private void InitializeDefaultPaths()
{
// 기본 경로 설정 - 실제 환경에 맞게 수정
txtSourceFolder.Text = @"D:\MyProjects\AI_TaskForce\AI_도면_dwg_pdf\대산당진_2공구_02도서성과품_01_설계도면\002_토공\01_본선\01_평면 및 종단면도";
txtResultFolder.Text = @"D:\MyProjects\AI_TaskForce\AI_도면_dwg_pdf\대산당진_2공구_02도서성과품_01_설계도면";
txtSourceFolder.Text = @"D:\dwgpdfcompare\test2";
txtResultFolder.Text = @"D:\dwgpdfcompare\result";
// 경로가 존재하지 않으면 기본값으로 설정
if (!Directory.Exists(txtSourceFolder.Text))
@@ -94,29 +103,50 @@ namespace DwgExtractorManual
try
{
var targetDir = new DirectoryInfo(folderPath);
var files = targetDir.GetFiles("*.dwg", SearchOption.AllDirectories);
var dwgFiles = targetDir.GetFiles("*.dwg", SearchOption.AllDirectories);
var pdfFiles = targetDir.GetFiles("*.pdf", SearchOption.AllDirectories);
txtFileCount.Text = $"파일: {files.Length}개";
txtFileCount.Text = $"DWG: {dwgFiles.Length}개, PDF: {pdfFiles.Length}개";
if (files.Length > 0)
if (dwgFiles.Length > 0)
{
LogMessage($"✅ 총 {files.Length}개의 DWG 파일을 발견했습니다.");
LogMessage($"✅ 총 {dwgFiles.Length}개의 DWG 파일을 발견했습니다.");
// 처음 몇 개 파일명 로깅
int showCount = Math.Min(5, files.Length);
int showCount = Math.Min(3, dwgFiles.Length);
for (int i = 0; i < showCount; i++)
{
LogMessage($" 📄 {files[i].Name}");
LogMessage($" 📄 {dwgFiles[i].Name}");
}
if (files.Length > 5)
if (dwgFiles.Length > 3)
{
LogMessage($" ... 외 {files.Length - 5}개 파일");
LogMessage($" ... 외 {dwgFiles.Length - 3}개 DWG 파일");
}
}
else
{
LogMessage("⚠️ 선택한 폴더에 DWG 파일이 없습니다.");
}
if (pdfFiles.Length > 0)
{
LogMessage($"✅ 총 {pdfFiles.Length}개의 PDF 파일을 발견했습니다.");
// 처음 몇 개 파일명 로깅
int showCount = Math.Min(3, pdfFiles.Length);
for (int i = 0; i < showCount; i++)
{
LogMessage($" 📄 {pdfFiles[i].Name}");
}
if (pdfFiles.Length > 3)
{
LogMessage($" ... 외 {pdfFiles.Length - 3}개 PDF 파일");
}
}
else
{
LogMessage("⚠️ 선택한 폴더에 PDF 파일이 없습니다.");
}
}
catch (Exception ex)
{
@@ -174,6 +204,8 @@ namespace DwgExtractorManual
// UI 비활성화
btnExtract.IsEnabled = false;
btnPdfExtract.IsEnabled = false;
btnMerge.IsEnabled = false;
btnBrowseSource.IsEnabled = false;
btnBrowseResult.IsEnabled = false;
rbExcel.IsEnabled = false;
@@ -200,9 +232,11 @@ namespace DwgExtractorManual
{
// UI 활성화
btnExtract.IsEnabled = true;
btnPdfExtract.IsEnabled = true;
btnMerge.IsEnabled = true;
btnBrowseSource.IsEnabled = true;
btnBrowseResult.IsEnabled = true;
rbExcel.IsEnabled = true;
rbExcel.IsChecked = true;
rbDatabase.IsEnabled = true;
progressBar.Value = 100;
LogMessage(new string('=', 50));
@@ -308,11 +342,18 @@ namespace DwgExtractorManual
await Task.Delay(10);
}
// Excel 파일 저장
var excelFileName = Path.Combine(txtResultFolder.Text,
$"{DateTime.Now:yyyyMMdd_HHmmss}_DwgToExcel.xlsx");
// Excel 파일 및 매핑 데이터 저장
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
var excelFileName = Path.Combine(txtResultFolder.Text, $"{timestamp}_DwgToExcel.xlsx");
var mappingDataFile = Path.Combine(txtResultFolder.Text, $"{timestamp}_mapping_data.json");
LogMessage("💾 Excel 파일 저장합니다...");
LogMessage("💾 Excel 파일과 매핑 데이터를 저장합니다...");
// 매핑 딕셔너리를 JSON 파일로 저장 (PDF 데이터 병합용)
_exportExcel.SaveMappingDictionary(mappingDataFile);
LogMessage($"✅ 매핑 데이터 저장 완료: {Path.GetFileName(mappingDataFile)}");
// Excel 파일 저장
_exportExcel.SaveAndCloseExcel(excelFileName);
LogMessage($"✅ Excel 파일 저장 완료: {Path.GetFileName(excelFileName)}");
}
@@ -429,6 +470,290 @@ namespace DwgExtractorManual
"완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
private async void BtnPdfExtract_Click(object sender, RoutedEventArgs e)
{
// 입력 유효성 검사
if (string.IsNullOrEmpty(txtSourceFolder.Text) || !Directory.Exists(txtSourceFolder.Text))
{
System.Windows.MessageBox.Show("유효한 변환할 폴더를 선택하세요.", "오류",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
if (string.IsNullOrEmpty(txtResultFolder.Text) || !Directory.Exists(txtResultFolder.Text))
{
System.Windows.MessageBox.Show("유효한 결과 저장 폴더를 선택하세요.", "오류",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// PDF 파일 존재 여부 확인
var targetDir = new DirectoryInfo(txtSourceFolder.Text);
var pdfFiles = targetDir.GetFiles("*.pdf", SearchOption.AllDirectories);
if (pdfFiles.Length == 0)
{
System.Windows.MessageBox.Show("선택한 폴더에 PDF 파일이 없습니다.", "정보",
MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// UI 비활성화
btnExtract.IsEnabled = false;
btnPdfExtract.IsEnabled = false;
btnMerge.IsEnabled = false;
btnBrowseSource.IsEnabled = false;
btnBrowseResult.IsEnabled = false;
rbExcel.IsEnabled = false;
rbDatabase.IsEnabled = false;
progressBar.Value = 0;
UpdateStatus("📄 PDF 추출 작업을 시작합니다...");
LogMessage(new string('=', 50));
LogMessage("📄 PDF 정보 추출 작업 시작");
LogMessage(new string('=', 50));
try
{
await ProcessPdfFiles(pdfFiles);
}
catch (Exception ex)
{
LogMessage($"❌ PDF 추출 중 치명적 오류 발생: {ex.Message}");
UpdateStatus("PDF 추출 중 오류가 발생했습니다.");
System.Windows.MessageBox.Show($"PDF 추출 중 오류가 발생했습니다:\n\n{ex.Message}",
"오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
// UI 활성화
btnExtract.IsEnabled = true;
btnPdfExtract.IsEnabled = true;
btnMerge.IsEnabled = true;
btnBrowseSource.IsEnabled = true;
btnBrowseResult.IsEnabled = true;
rbExcel.IsEnabled = true;
rbDatabase.IsEnabled = true;
progressBar.Value = 100;
LogMessage(new string('=', 50));
LogMessage("🏁 PDF 추출 작업 완료");
LogMessage(new string('=', 50));
}
}
private async Task ProcessPdfFiles(FileInfo[] pdfFiles)
{
var stopwatch = Stopwatch.StartNew();
UpdateStatus($"총 {pdfFiles.Length}개의 PDF 파일을 처리합니다...");
LogMessage($"📊 처리할 PDF 파일 수: {pdfFiles.Length}개");
LogMessage($"📂 소스 폴더: {txtSourceFolder.Text}");
LogMessage($"💾 결과 폴더: {txtResultFolder.Text}");
// 임시 파일 리스트 생성 (긴 명령줄 방지)
string tempFileList = Path.Combine(Path.GetTempPath(), $"pdf_files_{Guid.NewGuid():N}.txt");
try
{
// PDF 파일 경로를 임시 파일에 저장
await File.WriteAllLinesAsync(tempFileList, pdfFiles.Select(f => f.FullName));
LogMessage($"📄 임시 파일 리스트 생성됨: {pdfFiles.Length}개 파일");
// Python 스크립트 실행 설정 - 가상환경의 Python 사용
string baseDirectory = AppDomain.CurrentDomain.BaseDirectory;
string fletAnalysisPath = Path.Combine(baseDirectory, "fletimageanalysis");
string pythonPath = Path.Combine(fletAnalysisPath, "venv", "Scripts", "python.exe");
string scriptPath = Path.Combine(fletAnalysisPath, "batch_cli.py");
string schema = "한국도로공사"; // Valid schema option
int concurrent = 3;
bool batchMode = true;
bool saveIntermediate = false;
bool includeErrors = true;
string outputPath = Path.Combine(txtResultFolder.Text, $"{DateTime.Now:yyyyMMdd_HHmmss}_PdfExtraction.csv");
// 파일 리스트 방식으로 변경 (긴 명령줄 방지)
string arguments = $"--file-list \"{tempFileList}\" --schema \"{schema}\" --concurrent {concurrent} " +
$"--batch-mode {batchMode.ToString().ToLower()} " +
$"--save-intermediate {saveIntermediate.ToString().ToLower()} " +
$"--include-errors {includeErrors.ToString().ToLower()} " +
$"--output \"{outputPath}\"";
LogMessage($"🐍 Python 스크립트 실행 준비...");
LogMessage($"📁 Python 경로: {pythonPath}");
LogMessage($"📁 스크립트 경로: {scriptPath}");
LogMessage($"📄 파일 리스트: {Path.GetFileName(tempFileList)}");
// 가상환경 Python 확인
if (!File.Exists(pythonPath))
{
throw new Exception($"가상환경 Python을 찾을 수 없습니다: {pythonPath}\n\n" +
"cleanup_and_setup.bat을 실행하여 가상환경을 설정하세요.");
}
// 스크립트 파일 존재 확인
if (!File.Exists(scriptPath))
{
throw new Exception($"Python 스크립트를 찾을 수 없습니다: {scriptPath}");
}
ProcessStartInfo startInfo = new ProcessStartInfo
{
FileName = pythonPath, // 가상환경의 Python 사용
Arguments = $"\"{scriptPath}\" {arguments}",
WorkingDirectory = fletAnalysisPath,
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true
};
using (Process process = Process.Start(startInfo))
{
if (process == null)
{
throw new Exception("Python 프로세스를 시작할 수 없습니다.");
}
LogMessage("🚀 Python 스크립트 실행 시작");
// 비동기로 출력 읽기
var outputTask = ReadProcessOutputAsync(process);
// 프로세스 완료 대기
await Task.Run(() => process.WaitForExit());
var (output, errors) = await outputTask;
stopwatch.Stop();
if (process.ExitCode == 0)
{
LogMessage("✅ PDF 추출 완료!");
LogMessage($"📄 결과 파일: {Path.GetFileName(outputPath)}");
LogMessage($"🕐 총 소요 시간: {stopwatch.ElapsedMilliseconds}ms");
UpdateStatus("PDF 추출 완료!");
// 결과 파일 존재 확인
if (File.Exists(outputPath))
{
LogMessage($"✅ 결과 파일 생성 확인됨: {outputPath}");
}
else
{
LogMessage($"⚠️ 결과 파일이 예상 위치에 없습니다: {outputPath}");
}
// 자동 Excel 매핑 업데이트 (새로운 효율적 방식)
await AutoUpdateExcelMappingWithPdfResults(outputPath);
System.Windows.MessageBox.Show(
$"PDF 추출이 완료되었습니다!\n\n" +
$"📄 처리된 파일: {pdfFiles.Length}개\n" +
$"⏱️ 총 소요시간: {stopwatch.Elapsed:mm\\:ss}\n\n" +
$"📊 결과 파일: {Path.GetFileName(outputPath)}",
"완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
else
{
LogMessage($"❌ PDF 추출 실패 (Exit Code: {process.ExitCode})");
if (!string.IsNullOrEmpty(errors))
{
LogMessage($"❌ 오류 내용: {errors}");
}
throw new Exception($"Python 스크립트 실행 실패 (Exit Code: {process.ExitCode})\n\n{errors}");
}
}
}
finally
{
// 임시 파일 정리
try
{
if (File.Exists(tempFileList))
{
File.Delete(tempFileList);
LogMessage("🗑️ 임시 파일 리스트 정리 완료");
}
}
catch (Exception ex)
{
LogMessage($"⚠️ 임시 파일 정리 중 오류: {ex.Message}");
}
}
}
private async Task<(string output, string errors)> ReadProcessOutputAsync(Process process)
{
var outputBuilder = new System.Text.StringBuilder();
var errorBuilder = new System.Text.StringBuilder();
var outputTask = Task.Run(async () =>
{
string line;
while ((line = await process.StandardOutput.ReadLineAsync()) != null)
{
outputBuilder.AppendLine(line);
// 진행 상태 업데이트 처리
if (line.StartsWith("PROGRESS:"))
{
try
{
var progressPart = line.Substring("PROGRESS:".Length).Trim();
var parts = progressPart.Split('/');
if (parts.Length == 2 &&
int.TryParse(parts[0], out int current) &&
int.TryParse(parts[1], out int total))
{
var percentage = (current * 100.0) / total;
await Dispatcher.InvokeAsync(() =>
{
progressBar.Value = percentage;
UpdateStatus($"PDF 처리 중: {current}/{total} ({percentage:F1}%)");
});
}
}
catch
{
// 파싱 오류 무시
}
}
else if (line.StartsWith("START:"))
{
var message = line.Substring("START:".Length).Trim();
await Dispatcher.InvokeAsync(() => LogMessage($"🔄 {message}"));
}
else if (line.StartsWith("COMPLETED:"))
{
var message = line.Substring("COMPLETED:".Length).Trim();
await Dispatcher.InvokeAsync(() => LogMessage($"✅ {message}"));
}
else if (line.StartsWith("ERROR:"))
{
var errorInfo = line.Substring("ERROR:".Length).Trim();
await Dispatcher.InvokeAsync(() => LogMessage($"❌ 오류: {errorInfo}"));
}
}
});
var errorTask = Task.Run(async () =>
{
string line;
while ((line = await process.StandardError.ReadLineAsync()) != null)
{
errorBuilder.AppendLine(line);
await Dispatcher.InvokeAsync(() => LogMessage($"⚠️ {line}"));
}
});
await Task.WhenAll(outputTask, errorTask);
return (outputBuilder.ToString(), errorBuilder.ToString());
}
private void UpdateStatus(string message)
{
txtStatus.Text = message;
@@ -442,6 +767,505 @@ namespace DwgExtractorManual
txtLog.ScrollToEnd();
}
/// <summary>
/// PDF 추출 결과를 사용하여 Excel 매핑을 효율적으로 업데이트합니다 (새로운 방식).
/// </summary>
/// <param name="csvFilePath">CSV 결과 파일 경로</param>
private async Task AutoUpdateExcelMappingWithPdfResults(string csvFilePath)
{
try
{
LogMessage("🔄 Excel 매핑 자동 업데이트 시작 (효율적 방식)...");
// JSON 파일 경로 구성
string jsonFilePath = csvFilePath.Replace(".csv", ".json");
if (!File.Exists(jsonFilePath))
{
LogMessage($"⚠️ JSON 파일이 없습니다: {Path.GetFileName(jsonFilePath)}");
return;
}
LogMessage($"📄 JSON 파일 확인됨: {Path.GetFileName(jsonFilePath)}");
// 최신 매핑 데이터 파일 찾기
string resultDir = Path.GetDirectoryName(csvFilePath) ?? txtResultFolder.Text;
var mappingDataFiles = Directory.GetFiles(resultDir, "*_mapping_data.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(f => File.GetCreationTime(f))
.ToArray();
if (mappingDataFiles.Length == 0)
{
LogMessage("⚠️ 매핑 데이터 파일이 없습니다. DWG 파일을 먼저 처리하세요.");
LogMessage("💡 'DWG 정보 추출' 버튼을 먼저 실행하여 매핑 데이터를 생성하세요.");
return;
}
string latestMappingDataFile = mappingDataFiles[0];
LogMessage($"📊 최신 매핑 데이터 파일 발견: {Path.GetFileName(latestMappingDataFile)}");
// 새로운 ExportExcel 인스턴스 생성 및 데이터 로드
ExportExcel? exportExcel = null;
try
{
LogMessage("🔄 매핑 데이터 로드 및 PDF 값 업데이트 중...");
exportExcel = new ExportExcel();
// 기존 매핑 딕셔너리 로드
exportExcel.LoadMappingDictionary(latestMappingDataFile);
// PDF 데이터로 업데이트
exportExcel.UpdateWithPdfData(jsonFilePath);
// 완전한 Excel 파일 생성 (DWG + PDF 데이터)
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string completeExcelPath = Path.Combine(resultDir, $"{timestamp}_Complete_Mapping.xlsx");
LogMessage("📊 통합 Excel 파일 생성 중...");
// ⭐ 중요: 매핑 데이터를 Excel 시트에 기록
exportExcel.WriteCompleteMapping();
// 매핑 워크북만 저장 (완전한 매핑 데이터용)
exportExcel.SaveMappingWorkbookOnly(completeExcelPath);
// 업데이트된 매핑 데이터 저장
exportExcel.SaveMappingDictionary(latestMappingDataFile);
// Excel 객체 정리
exportExcel.CloseExcelObjectsWithoutSaving();
LogMessage($"✅ Excel 매핑 자동 업데이트 완료: {Path.GetFileName(completeExcelPath)}");
LogMessage("📊 DWG 값과 PDF 값이 모두 포함된 통합 Excel 파일이 생성되었습니다.");
}
catch (Exception ex)
{
LogMessage($"❌ Excel 매핑 자동 업데이트 중 오류: {ex.Message}");
Debug.WriteLine($"Excel 매핑 자동 업데이트 오류: {ex}");
}
}
catch (Exception ex)
{
LogMessage($"❌ Excel 매핑 자동 업데이트 중 오류: {ex.Message}");
Debug.WriteLine($"Excel 매핑 자동 업데이트 오류: {ex}");
}
}
/// <summary>
/// PDF 추출 결과 JSON 파일을 사용하여 Excel 매핑 시트를 업데이트합니다 (기존 방식 - 사용 안함).
/// </summary>
/// <param name="csvFilePath">CSV 결과 파일 경로</param>
[Obsolete("이 메서드는 비효율적입니다. AutoUpdateExcelMappingWithPdfResults를 사용하세요.")]
private async Task UpdateExcelMappingWithPdfResults(string csvFilePath)
{
try
{
LogMessage("🔄 Excel 매핑 시트 업데이트 시작...");
// JSON 파일 경로 구성
string jsonFilePath = csvFilePath.Replace(".csv", ".json");
if (!File.Exists(jsonFilePath))
{
LogMessage($"⚠️ JSON 파일이 없습니다: {Path.GetFileName(jsonFilePath)}");
return;
}
LogMessage($"📄 JSON 파일 확인됨: {Path.GetFileName(jsonFilePath)}");
// 기존 Excel 매핑 파일 검색 (임시 파일 제외)
string resultDir = Path.GetDirectoryName(csvFilePath) ?? txtResultFolder.Text;
var allExcelFiles = Directory.GetFiles(resultDir, "*_Mapping.xlsx", SearchOption.TopDirectoryOnly);
// 임시 파일(~$로 시작하는 파일) 필터링
var excelFiles = allExcelFiles
.Where(f => !Path.GetFileName(f).StartsWith("~$"))
.ToArray();
ExportExcel? exportExcel = null;
string excelFilePath = "";
LogMessage($"🔍 Excel 파일 검색 결과: 전체 {allExcelFiles.Length}개, 유효 {excelFiles.Length}개");
if (excelFiles.Length > 0)
{
// 기존 매핑 파일이 있는 경우 (가장 최근 파일 선택)
excelFilePath = excelFiles.OrderByDescending(f => File.GetCreationTime(f)).First();
LogMessage($"📊 기존 Excel 매핑 파일 발견: {Path.GetFileName(excelFilePath)}");
// 파일이 다른 프로그램에서 열려 있는지 확인
if (IsFileInUse(excelFilePath))
{
LogMessage("⚠️ Excel 파일이 다른 프로그램에서 열려 있습니다. 파일을 닫고 다시 시도하세요.");
return;
}
// 기존 파일을 복사하여 새 버전 생성
string newExcelPath = Path.Combine(resultDir,
$"{DateTime.Now:yyyyMMdd_HHmmss}_Mapping_Updated.xlsx");
File.Copy(excelFilePath, newExcelPath, true);
excelFilePath = newExcelPath;
LogMessage($"📋 Excel 파일 복사됨: {Path.GetFileName(newExcelPath)}");
}
else
{
LogMessage("⚠️ 기존 Excel 매핑 파일이 없습니다. DWG 파일을 먼저 처리하세요.");
LogMessage("💡 'DWG 정보 추출' 버튼을 먼저 실행하여 매핑 파일을 생성하세요.");
return;
}
// ExportExcel 클래스로 JSON 데이터 업데이트
LogMessage("🔄 PDF 값으로 Excel 업데이트 중...");
exportExcel = new ExportExcel();
// 기존 Excel 파일을 열어 JSON 값으로 업데이트
bool success = exportExcel.UpdateExistingExcelWithJson(excelFilePath, jsonFilePath);
if (success)
{
// 업데이트된 Excel 파일 저장
bool saveSuccess = exportExcel.SaveExcel();
if (saveSuccess)
{
LogMessage($"✅ Excel 매핑 시트 업데이트 완료: {Path.GetFileName(excelFilePath)}");
LogMessage("📊 PDF 값이 Excel 매핑 시트의 'Pdf_value' 컬럼에 추가되었습니다.");
}
else
{
LogMessage("❌ Excel 파일 저장 실패");
}
}
else
{
LogMessage("❌ Excel 매핑 시트 업데이트 실패");
}
exportExcel?.Dispose();
}
catch (Exception ex)
{
LogMessage($"❌ Excel 매핑 업데이트 중 오류: {ex.Message}");
Debug.WriteLine($"Excel 매핑 업데이트 오류: {ex}");
}
}
/// <summary>
/// 파일이 다른 프로세스에서 사용 중인지 확인합니다.
/// </summary>
/// <param name="filePath">확인할 파일 경로</param>
/// <returns>사용 중이면 true, 아니면 false</returns>
private bool IsFileInUse(string filePath)
{
try
{
using (FileStream stream = File.Open(filePath, FileMode.Open, FileAccess.Read, FileShare.None))
{
// 파일을 열 수 있으면 사용 중이 아님
return false;
}
}
catch (IOException)
{
// 파일을 열 수 없으면 사용 중
return true;
}
catch (Exception)
{
// 다른 오류가 발생하면 안전하게 사용 중으로 간주
return true;
}
}
/// <summary>
/// 가장 최근의 매핑 데이터 파일을 찾습니다.
/// </summary>
/// <param name="resultDir">검색할 디렉토리</param>
/// <returns>최신 매핑 데이터 파일 경로 또는 null</returns>
private string? FindLatestMappingDataFile(string resultDir)
{
try
{
var mappingDataFiles = Directory.GetFiles(resultDir, "*_mapping_data.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(f => File.GetCreationTime(f))
.ToArray();
return mappingDataFiles.Length > 0 ? mappingDataFiles[0] : null;
}
catch (Exception ex)
{
LogMessage($"❌ 매핑 데이터 파일 검색 중 오류: {ex.Message}");
return null;
}
}
private async void BtnMerge_Click(object sender, RoutedEventArgs e)
{
// 입력 유효성 검사
if (string.IsNullOrEmpty(txtResultFolder.Text) || !Directory.Exists(txtResultFolder.Text))
{
System.Windows.MessageBox.Show("유효한 결과 저장 폴더를 선택하세요.", "오류",
MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// UI 비활성화
btnExtract.IsEnabled = false;
btnPdfExtract.IsEnabled = false;
btnMerge.IsEnabled = false;
btnBrowseSource.IsEnabled = false;
btnBrowseResult.IsEnabled = false;
rbExcel.IsEnabled = false;
rbDatabase.IsEnabled = false;
progressBar.Value = 0;
UpdateStatus("🔗 Excel 매핑 병합 작업을 시작합니다...");
LogMessage(new string('=', 50));
LogMessage("🔗 Excel 매핑 병합 작업 시작");
LogMessage(new string('=', 50));
try
{
await MergeLatestPdfResults();
}
catch (Exception ex)
{
LogMessage($"❌ 매핑 병합 중 치명적 오류 발생: {ex.Message}");
UpdateStatus("매핑 병합 중 오류가 발생했습니다.");
System.Windows.MessageBox.Show($"매핑 병합 중 오류가 발생했습니다:\n\n{ex.Message}",
"오류", MessageBoxButton.OK, MessageBoxImage.Error);
}
finally
{
// UI 활성화
btnExtract.IsEnabled = true;
btnPdfExtract.IsEnabled = true;
btnMerge.IsEnabled = true;
btnBrowseSource.IsEnabled = true;
btnBrowseResult.IsEnabled = true;
rbExcel.IsEnabled = true;
rbDatabase.IsEnabled = true;
progressBar.Value = 100;
LogMessage(new string('=', 50));
LogMessage("🏁 매핑 병합 작업 완료");
LogMessage(new string('=', 50));
}
}
/// <summary>
/// 가장 최근의 PDF 추출 JSON 파일을 찾아 Excel 매핑을 효율적으로 업데이트합니다.
/// </summary>
private async Task MergeLatestPdfResults()
{
try
{
LogMessage("🔍 최신 PDF 추출 결과 파일 검색 중...");
string resultDir = txtResultFolder.Text;
// 가장 최근의 PDF 추출 JSON 파일 찾기
var jsonFiles = Directory.GetFiles(resultDir, "*_PdfExtraction.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(f => File.GetCreationTime(f))
.ToArray();
if (jsonFiles.Length == 0)
{
LogMessage("❌ PDF 추출 JSON 파일을 찾을 수 없습니다.");
LogMessage("💡 먼저 'PDF 추출' 버튼을 실행하여 JSON 파일을 생성하세요.");
System.Windows.MessageBox.Show(
"PDF 추출 JSON 파일을 찾을 수 없습니다.\n\n" +
"먼저 'PDF 추출' 버튼을 실행하여 JSON 파일을 생성하세요.",
"파일 없음", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
// 최신 매핑 데이터 파일 찾기
var mappingDataFiles = Directory.GetFiles(resultDir, "*_mapping_data.json", SearchOption.TopDirectoryOnly)
.OrderByDescending(f => File.GetCreationTime(f))
.ToArray();
if (mappingDataFiles.Length == 0)
{
LogMessage("❌ 매핑 데이터 파일을 찾을 수 없습니다.");
LogMessage("💡 먼저 'DWG 정보 추출' 버튼을 실행하여 매핑 데이터를 생성하세요.");
System.Windows.MessageBox.Show(
"매핑 데이터 파일을 찾을 수 없습니다.\n\n" +
"먼저 'DWG 정보 추출' 버튼을 실행하여 매핑 데이터를 생성하세요.",
"파일 없음", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
string latestJsonFile = jsonFiles[0];
string latestMappingDataFile = mappingDataFiles[0];
LogMessage($"✅ 최신 JSON 파일 발견: {Path.GetFileName(latestJsonFile)}");
LogMessage($"📅 생성일시: {File.GetCreationTime(latestJsonFile):yyyy-MM-dd HH:mm:ss}");
LogMessage($"📊 최신 매핑 데이터 파일: {Path.GetFileName(latestMappingDataFile)}");
// JSON 파일 내용 미리보기
await ShowJsonPreview(latestJsonFile);
// 사용자 확인
var result = System.Windows.MessageBox.Show(
$"다음 파일들로 Excel 매핑을 업데이트하시겠습니까?\n\n" +
$"📄 PDF JSON: {Path.GetFileName(latestJsonFile)}\n" +
$"📊 매핑 데이터: {Path.GetFileName(latestMappingDataFile)}\n" +
$"📅 생성: {File.GetCreationTime(latestJsonFile):yyyy-MM-dd HH:mm:ss}\n" +
$"📏 크기: {new FileInfo(latestJsonFile).Length / 1024:N0} KB",
"매핑 업데이트 확인",
MessageBoxButton.YesNo,
MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
{
LogMessage("❌ 사용자에 의해 매핑 업데이트가 취소되었습니다.");
UpdateStatus("매핑 업데이트가 취소되었습니다.");
return;
}
// 효율적인 Excel 매핑 업데이트 실행
UpdateStatus("🔄 Excel 매핑 업데이트 중 (효율적 방식)...");
progressBar.Value = 25;
ExportExcel? exportExcel = null;
try
{
LogMessage("🔄 매핑 데이터 로드 및 PDF 값 업데이트 중...");
exportExcel = new ExportExcel();
progressBar.Value = 40;
// 기존 매핑 딕셔너리 로드
exportExcel.LoadMappingDictionary(latestMappingDataFile);
progressBar.Value = 60;
// PDF 데이터로 업데이트
exportExcel.UpdateWithPdfData(latestJsonFile);
progressBar.Value = 80;
// 완전한 Excel 파일 생성 (DWG + PDF 데이터)
var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
string completeExcelPath = Path.Combine(resultDir, $"{timestamp}_Complete_Mapping_Merged.xlsx");
LogMessage("📊 통합 Excel 파일 생성 중...");
// ⭐ 중요: 매핑 데이터를 Excel 시트에 기록
exportExcel.WriteCompleteMapping();
// 매핑 워크북만 저장 (완전한 매핑 데이터용)
exportExcel.SaveMappingWorkbookOnly(completeExcelPath);
// 업데이트된 매핑 데이터 저장
exportExcel.SaveMappingDictionary(latestMappingDataFile);
// Excel 객체 정리
exportExcel.CloseExcelObjectsWithoutSaving();
progressBar.Value = 100;
UpdateStatus("✅ 매핑 병합 완료!");
LogMessage($"✅ Excel 매핑 병합 완료: {Path.GetFileName(completeExcelPath)}");
LogMessage("📊 DWG 값과 PDF 값이 모두 포함된 통합 Excel 파일이 생성되었습니다.");
System.Windows.MessageBox.Show(
$"Excel 매핑 병합이 완료되었습니다!\n\n" +
$"📄 사용된 JSON: {Path.GetFileName(latestJsonFile)}\n" +
$"📊 생성된 파일: {Path.GetFileName(completeExcelPath)}\n" +
$"✅ DWG 값과 PDF 값이 모두 포함된 통합 Excel 파일",
"완료", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
LogMessage($"❌ 매핑 병합 중 오류: {ex.Message}");
throw;
}
}
catch (Exception ex)
{
LogMessage($"❌ 매핑 병합 중 오류: {ex.Message}");
throw;
}
}
/// <summary>
/// JSON 파일의 내용을 미리보기로 보여줍니다.
/// </summary>
/// <param name="jsonFilePath">JSON 파일 경로</param>
private async Task ShowJsonPreview(string jsonFilePath)
{
try
{
LogMessage("📋 JSON 파일 내용 미리보기:");
string jsonContent = await File.ReadAllTextAsync(jsonFilePath);
using var jsonDocument = JsonDocument.Parse(jsonContent);
var root = jsonDocument.RootElement;
// 메타데이터 정보 표시
if (root.TryGetProperty("metadata", out var metadata))
{
if (metadata.TryGetProperty("total_files", out var totalFiles))
{
LogMessage($" 📊 총 파일 수: {totalFiles}");
}
if (metadata.TryGetProperty("success_files", out var successFiles))
{
LogMessage($" ✅ 성공 파일: {successFiles}");
}
if (metadata.TryGetProperty("failed_files", out var failedFiles))
{
LogMessage($" ❌ 실패 파일: {failedFiles}");
}
if (metadata.TryGetProperty("generated_at", out var generatedAt))
{
LogMessage($" 📅 생성시간: {generatedAt}");
}
}
// 결과 파일 수 표시
if (root.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array)
{
var resultsArray = results.EnumerateArray().ToArray();
LogMessage($" 📄 분석 결과: {resultsArray.Length}개 파일");
// 처음 몇 개 파일 이름 표시
int showCount = Math.Min(3, resultsArray.Length);
for (int i = 0; i < showCount; i++)
{
if (resultsArray[i].TryGetProperty("file_info", out var fileInfo) &&
fileInfo.TryGetProperty("name", out var fileName))
{
LogMessage($" - {fileName.GetString()}");
}
}
if (resultsArray.Length > 3)
{
LogMessage($" ... 외 {resultsArray.Length - 3}개 파일");
}
}
LogMessage("📋 JSON 미리보기 완료");
}
catch (Exception ex)
{
LogMessage($"⚠️ JSON 미리보기 중 오류: {ex.Message}");
}
}
protected override void OnClosed(EventArgs e)
{
_timer?.Stop();

File diff suppressed because it is too large Load Diff

67
cleanup_and_setup.bat Normal file
View File

@@ -0,0 +1,67 @@
@echo off
echo Cleaning up fletimageanalysis folder for CLI-only processing...
cd fletimageanalysis
REM Delete unnecessary test files
del /q test_*.py 2>nul
del /q python_mapping_usage.py 2>nul
REM Delete backup and alternative files
del /q *_backup.py 2>nul
del /q *_previous.py 2>nul
del /q *_fixed.py 2>nul
del /q cross_tabulated_csv_exporter_*.py 2>nul
REM Delete documentation files
del /q *.md 2>nul
del /q LICENSE 2>nul
del /q .gitignore 2>nul
REM Delete directories not needed for CLI
rmdir /s /q back_src 2>nul
rmdir /s /q docs 2>nul
rmdir /s /q testsample 2>nul
rmdir /s /q uploads 2>nul
rmdir /s /q assets 2>nul
rmdir /s /q results 2>nul
rmdir /s /q __pycache__ 2>nul
rmdir /s /q .vscode 2>nul
rmdir /s /q .git 2>nul
rmdir /s /q .gemini 2>nul
rmdir /s /q .venv 2>nul
echo Essential files for CLI processing:
echo - batch_cli.py
echo - config.py
echo - multi_file_processor.py
echo - pdf_processor.py
echo - dxf_processor.py
echo - gemini_analyzer.py
echo - csv_exporter.py
echo - requirements.txt
echo - mapping_table_json.json
echo - .env
echo.
echo Creating virtual environment...
python -m venv venv
echo.
echo Activating virtual environment and installing packages...
call venv\Scripts\activate.bat
pip install --upgrade pip
pip install PyMuPDF google-genai Pillow ezdxf numpy python-dotenv pandas requests
echo.
echo Testing installation...
python -c "import fitz; print('✓ PyMuPDF OK')"
python -c "import google.genai; print('✓ Gemini API OK')"
python -c "import pandas; print('✓ Pandas OK')"
python -c "import ezdxf; print('✓ EZDXF OK')"
echo.
echo Setup complete!
echo Virtual environment created at: fletimageanalysis\venv\
echo.
pause

View File

@@ -79,6 +79,18 @@ public class FieldMapper
return null;
}
/// <summary>
/// AI 라벨을 DocAiKey 값으로 변환
/// </summary>
public string AilabelToDocAiKey(string ailabel)
{
if (_mappingData.MappingTable.AilabelToSystems.TryGetValue(ailabel, out var systemFields))
{
return systemFields.DocAiKey;
}
return null;
}
/// <summary>
/// 고속도로공사 필드명을 교통부 필드명으로 변환
/// </summary>
@@ -91,6 +103,46 @@ public class FieldMapper
return null;
}
/// <summary>
/// DocAiKey 값으로부터 해당하는 AI 라벨을 반환
/// </summary>
public string DocAiKeyToAilabel(string docAiKey)
{
if (string.IsNullOrEmpty(docAiKey))
{
return null;
}
foreach (var kvp in _mappingData.MappingTable.AilabelToSystems)
{
if (kvp.Value.DocAiKey == docAiKey)
{
return kvp.Key;
}
}
return null;
}
/// <summary>
/// Expressway 필드값으로부터 해당하는 AI 라벨을 반환
/// </summary>
public string ExpresswayToAilabel(string expresswayField)
{
if (string.IsNullOrEmpty(expresswayField))
{
return null;
}
foreach (var kvp in _mappingData.MappingTable.AilabelToSystems)
{
if (kvp.Value.Expressway == expresswayField)
{
return kvp.Key;
}
}
return null;
}
/// <summary>
/// AI 라벨 → 고속도로공사 → 교통부 순서로 변환
/// </summary>
@@ -141,102 +193,121 @@ public class FieldMapper
}
return results;
}
/// <summary>
/// 매핑 테이블에서 모든 DocAiKey 값의 목록을 반환합니다.
/// </summary>
public List<string> GetAllDocAiKeys()
{
var docAiKeys = new List<string>();
foreach (var kvp in _mappingData.MappingTable.AilabelToSystems)
{
var docAiKey = kvp.Value.DocAiKey;
if (!string.IsNullOrEmpty(docAiKey))
{
docAiKeys.Add(docAiKey);
}
}
return docAiKeys;
}
}
// 사용 예제 프로그램
class Program
{
static void Main(string[] args)
{
try
{
// 매핑 테이블 로드
var mapper = FieldMapper.LoadFromFile("mapping_table.json");
//class Program
//{
// static void Main(string[] args)
// {
// try
// {
// // 매핑 테이블 로드
// var mapper = FieldMapper.LoadFromFile("mapping_table.json");
Console.WriteLine("=== AI 라벨 → 고속도로공사 필드명 변환 ===");
var testLabels = new[] { "도면명", "편철번호", "도면번호", "Main Title", "계정번호" };
// Console.WriteLine("=== AI 라벨 → 고속도로공사 필드명 변환 ===");
// var testLabels = new[] { "도면명", "편철번호", "도면번호", "Main Title", "계정번호" };
foreach (var label in testLabels)
{
var expresswayField = mapper.AilabelToExpressway(label);
Console.WriteLine($"{label} → {expresswayField ?? "N/A"}");
}
// foreach (var label in testLabels)
// {
// var expresswayField = mapper.AilabelToExpressway(label);
// Console.WriteLine($"{label} → {expresswayField ?? "N/A"}");
// }
Console.WriteLine("\n=== 고속도로공사 → 교통부 필드명 변환 ===");
var expresswayFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TR_RNUM1" };
// Console.WriteLine("\n=== 고속도로공사 → 교통부 필드명 변환 ===");
// var expresswayFields = new[] { "TD_DNAME_MAIN", "TD_DWGNO", "TD_DWGCODE", "TR_RNUM1" };
foreach (var field in expresswayFields)
{
var transportationField = mapper.ExpresswayToTransportation(field);
Console.WriteLine($"{field} → {transportationField ?? "N/A"}");
}
// foreach (var field in expresswayFields)
// {
// var transportationField = mapper.ExpresswayToTransportation(field);
// Console.WriteLine($"{field} → {transportationField ?? "N/A"}");
// }
Console.WriteLine("\n=== AI 라벨 → 고속도로공사 → 교통부 (연속 변환) ===");
foreach (var label in testLabels)
{
var expresswayField = mapper.AilabelToExpressway(label);
var transportationField = mapper.AilabelToTransportationViaExpressway(label);
Console.WriteLine($"{label} → {expresswayField ?? "N/A"} → {transportationField ?? "N/A"}");
}
// Console.WriteLine("\n=== AI 라벨 → 고속도로공사 → 교통부 (연속 변환) ===");
// foreach (var label in testLabels)
// {
// var expresswayField = mapper.AilabelToExpressway(label);
// var transportationField = mapper.AilabelToTransportationViaExpressway(label);
// Console.WriteLine($"{label} → {expresswayField ?? "N/A"} → {transportationField ?? "N/A"}");
// }
Console.WriteLine("\n=== 특정 AI 라벨의 모든 시스템 필드명 ===");
var allFields = mapper.GetAllSystemFields("도면명");
if (allFields != null)
{
Console.WriteLine("도면명에 해당하는 모든 시스템 필드:");
Console.WriteLine($" 국토교통부: {allFields.Molit}");
Console.WriteLine($" 고속도로공사: {allFields.Expressway}");
Console.WriteLine($" 국가철도공단: {allFields.Railway}");
Console.WriteLine($" 문서AI키: {allFields.DocAiKey}");
}
// Console.WriteLine("\n=== 특정 AI 라벨의 모든 시스템 필드명 ===");
// var allFields = mapper.GetAllSystemFields("도면명");
// if (allFields != null)
// {
// Console.WriteLine("도면명에 해당하는 모든 시스템 필드:");
// Console.WriteLine($" 국토교통부: {allFields.Molit}");
// Console.WriteLine($" 고속도로공사: {allFields.Expressway}");
// Console.WriteLine($" 국가철도공단: {allFields.Railway}");
// Console.WriteLine($" 문서AI키: {allFields.DocAiKey}");
// }
Console.WriteLine("\n=== 배치 처리 예제 ===");
var batchResults = mapper.BatchConvertAilabelToExpressway(testLabels);
foreach (var result in batchResults)
{
Console.WriteLine($"배치 변환: {result.Key} → {result.Value ?? "N/A"}");
}
}
catch (Exception ex)
{
Console.WriteLine($"오류 발생: {ex.Message}");
}
}
}
// Console.WriteLine("\n=== 배치 처리 예제 ===");
// var batchResults = mapper.BatchConvertAilabelToExpressway(testLabels);
// foreach (var result in batchResults)
// {
// Console.WriteLine($"배치 변환: {result.Key} → {result.Value ?? "N/A"}");
// }
// }
// catch (Exception ex)
// {
// Console.WriteLine($"오류 발생: {ex.Message}");
// }
// }
// 확장 메서드 (선택사항)
public static class FieldMapperExtensions
{
/// <summary>
/// 특정 시스템의 필드명을 다른 시스템으로 변환
/// </summary>
public static string ConvertBetweenSystems(this FieldMapper mapper, string sourceField, string sourceSystem, string targetSystem)
{
// 역방향 조회를 위한 확장 메서드
foreach (var kvp in mapper._mappingData.MappingTable.AilabelToSystems)
{
var systemFields = kvp.Value;
string sourceFieldValue = sourceSystem switch
{
"molit" => systemFields.Molit,
"expressway" => systemFields.Expressway,
"railway" => systemFields.Railway,
"docaikey" => systemFields.DocAiKey,
_ => null
};
//public static class FieldMapperExtensions
//{
// /// <summary>
// /// 특정 시스템의 필드명을 다른 시스템으로 변환
// /// </summary>
// public static string ConvertBetweenSystems(this FieldMapper mapper, string sourceField, string sourceSystem, string targetSystem)
// {
// // 역방향 조회를 위한 확장 메서드
// foreach (var kvp in mapper._mappingData.MappingTable.AilabelToSystems)
// {
// var systemFields = kvp.Value;
// string sourceFieldValue = sourceSystem switch
// {
// "molit" => systemFields.Molit,
// "expressway" => systemFields.Expressway,
// "railway" => systemFields.Railway,
// "docaikey" => systemFields.DocAiKey,
// _ => null
// };
if (sourceFieldValue == sourceField)
{
return targetSystem switch
{
"molit" => systemFields.Molit,
"expressway" => systemFields.Expressway,
"railway" => systemFields.Railway,
"docaikey" => systemFields.DocAiKey,
_ => null
};
}
}
return null;
}
}
// if (sourceFieldValue == sourceField)LL
// {
// return targetSystem switch
// {
// "molit" => systemFields.Molit,
// "expressway" => systemFields.Expressway,
// "railway" => systemFields.Railway,
// "docaikey" => systemFields.DocAiKey,
// _ => null
// };
// }
// }
// return null;
// }
//}

19
fletimageanalysis/.env Normal file
View File

@@ -0,0 +1,19 @@
# 환경 변수 설정 파일
# 실제 사용 시 이 파일을 .env로 복사하고 실제 값으로 변경하세요
# Gemini API 키 (필수)
GEMINI_API_KEY=AIzaSyA4XUw9LJp5zQ5CkB3GVVAQfTL8z6BGVcs
# 애플리케이션 설정
APP_TITLE=PDF 도면 분석기
APP_VERSION=1.0.0
DEBUG=False
# 파일 업로드 설정
MAX_FILE_SIZE_MB=50
ALLOWED_EXTENSIONS=pdf
UPLOAD_FOLDER=uploads
# Gemini API 설정
GEMINI_MODEL=gemini-2.5-flash
DEFAULT_PROMPT=pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.

View File

@@ -0,0 +1,19 @@
# 환경 변수 설정 파일
# 실제 사용 시 이 파일을 .env로 복사하고 실제 값으로 변경하세요
# Gemini API 키 (필수)
GEMINI_API_KEY=your_gemini_api_key_here
# 애플리케이션 설정
APP_TITLE=PDF 도면 분석기
APP_VERSION=1.0.0
DEBUG=False
# 파일 업로드 설정
MAX_FILE_SIZE_MB=50
ALLOWED_EXTENSIONS=pdf
UPLOAD_FOLDER=uploads
# Gemini API 설정
GEMINI_MODEL=gemini-2.5-flash
DEFAULT_PROMPT=pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.

View File

@@ -0,0 +1,216 @@
"""
배치 처리 명령줄 인터페이스
WPF 애플리케이션에서 호출 가능한 간단한 배치 처리 도구
Usage:
python batch_cli.py --files "file1.pdf,file2.dxf" --schema "한국도로공사" --concurrent 3 --batch-mode true --save-intermediate false --include-errors true --output "results.csv"
"""
import argparse
import asyncio
import logging
import os
import sys
import time
from datetime import datetime
from typing import List
# 프로젝트 모듈 임포트
from config import Config
from multi_file_processor import MultiFileProcessor, BatchProcessingConfig, generate_default_csv_filename
# 로깅 설정
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class BatchCLI:
"""배치 처리 명령줄 인터페이스 클래스"""
def __init__(self):
self.processor = None
self.start_time = None
def setup_processor(self) -> bool:
"""다중 파일 처리기 설정"""
try:
# 설정 검증
config_errors = Config.validate_config()
if config_errors:
for error in config_errors:
print(f"ERROR: {error}")
return False
# Gemini API 키 확인
gemini_api_key = Config.get_gemini_api_key()
if not gemini_api_key:
print("ERROR: Gemini API 키가 설정되지 않았습니다")
return False
# 처리기 초기화
self.processor = MultiFileProcessor(gemini_api_key)
print("START: 배치 처리기 초기화 완료")
return True
except Exception as e:
print(f"ERROR: 처리기 초기화 실패: {e}")
return False
def parse_file_paths(self, files_arg: str) -> List[str]:
"""파일 경로 문자열을 리스트로 파싱"""
if not files_arg:
return []
# 쉼표로 구분된 파일 경로들을 분리
file_paths = [path.strip().strip('"\'') for path in files_arg.split(',')]
# 파일 존재 여부 확인
valid_paths = []
for path in file_paths:
if os.path.exists(path):
valid_paths.append(path)
print(f"START: 파일 확인: {os.path.basename(path)}")
else:
print(f"ERROR: 파일을 찾을 수 없습니다: {path}")
return valid_paths
def parse_file_list_from_file(self, file_list_path: str) -> List[str]:
"""파일 리스트 파일에서 파일 경로들을 읽어옴"""
if not file_list_path or not os.path.exists(file_list_path):
return []
valid_paths = []
try:
with open(file_list_path, 'r', encoding='utf-8') as f:
for line in f:
path = line.strip().strip('"\'')
if path and os.path.exists(path):
valid_paths.append(path)
print(f"START: 파일 확인: {os.path.basename(path)}")
elif path:
print(f"ERROR: 파일을 찾을 수 없음: {path}")
print(f"START: 총 {len(valid_paths)}개 파일 로드됨")
return valid_paths
except Exception as e:
print(f"ERROR: 파일 리스트 읽기 실패: {e}")
return []
def create_batch_config(self, args) -> BatchProcessingConfig:
"""명령줄 인수에서 배치 설정 생성"""
config = BatchProcessingConfig(
organization_type=args.schema,
enable_gemini_batch_mode=args.batch_mode,
max_concurrent_files=args.concurrent,
save_intermediate_results=args.save_intermediate,
output_csv_path=args.output,
include_error_files=args.include_errors
)
return config
def progress_callback(self, current: int, total: int, status: str):
"""진행률 콜백 함수 - WPF가 기대하는 형식으로 출력"""
# WPF가 파싱할 수 있는 간단한 형식으로 출력
print(f"PROGRESS: {current}/{total}")
print(f"COMPLETED: {status}")
async def run_batch_processing(self, file_paths: List[str], config: BatchProcessingConfig) -> bool:
"""배치 처리 실행"""
try:
self.start_time = time.time()
total_files = len(file_paths)
print(f"START: 배치 처리 시작: {total_files}개 파일")
# 처리 실행
results = await self.processor.process_multiple_files(
file_paths, config, self.progress_callback
)
# 처리 완료
end_time = time.time()
total_time = end_time - self.start_time
# 요약 정보
summary = self.processor.get_processing_summary()
print(f"COMPLETED: 배치 처리 완료!")
print(f"COMPLETED: 총 처리 시간: {total_time:.1f}")
print(f"COMPLETED: 성공: {summary['success_files']}개, 실패: {summary['failed_files']}")
print(f"COMPLETED: CSV 결과 저장: {config.output_csv_path}")
print(f"COMPLETED: JSON 결과 저장: {config.output_csv_path.replace('.csv', '.json')}")
return True
except Exception as e:
print(f"ERROR: 배치 처리 중 오류: {e}")
return False
def str_to_bool(value: str) -> bool:
"""문자열을 boolean으로 변환"""
return value.lower() in ["true", "1", "yes", "on"]
async def main():
"""메인 함수"""
parser = argparse.ArgumentParser(description="PDF/DXF 파일 배치 처리 도구")
# 파일 입력 방식 (둘 중 하나 필수)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument("--files", "-f", help="처리할 파일 경로들 (쉼표로 구분)")
input_group.add_argument("--file-list", "-fl", help="처리할 파일 경로가 담긴 텍스트 파일")
# 선택적 인수들
parser.add_argument("--schema", "-s", default="한국도로공사", help="분석 스키마")
parser.add_argument("--concurrent", "-c", type=int, default=3, help="동시 처리할 파일 수")
parser.add_argument("--batch-mode", "-b", default="false", help="배치 모드 사용 여부")
parser.add_argument("--save-intermediate", "-i", default="true", help="중간 결과 저장 여부")
parser.add_argument("--include-errors", "-e", default="true", help="오류 파일 포함 여부")
parser.add_argument("--output", "-o", help="출력 CSV 파일 경로 (JSON 파일도 함께 생성됨)")
args = parser.parse_args()
# CLI 인스턴스 생성
cli = BatchCLI()
# 처리기 설정
if not cli.setup_processor():
sys.exit(1)
# 파일 경로 파싱
if args.files:
input_files = cli.parse_file_paths(args.files)
else:
input_files = cli.parse_file_list_from_file(args.file_list)
if not input_files:
print("ERROR: 처리할 파일이 없습니다.")
sys.exit(1)
# boolean 변환
args.batch_mode = str_to_bool(args.batch_mode)
args.save_intermediate = str_to_bool(args.save_intermediate)
args.include_errors = str_to_bool(args.include_errors)
# 배치 설정 생성
config = cli.create_batch_config(args)
# 배치 처리 실행
success = await cli.run_batch_processing(input_files, config)
if success:
print("SUCCESS: 배치 처리가 성공적으로 완료되었습니다.")
sys.exit(0)
else:
print("ERROR: 배치 처리 중 오류가 발생했습니다.")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,74 @@
"""
설정 관리 모듈
환경 변수 및 애플리케이션 설정을 관리합니다.
"""
import os
from dotenv import load_dotenv
from pathlib import Path
# .env 파일 로드
load_dotenv()
class Config:
"""애플리케이션 설정 클래스"""
# 기본 애플리케이션 설정
APP_TITLE = os.getenv("APP_TITLE", "PDF/DXF 도면 분석기")
APP_VERSION = os.getenv("APP_VERSION", "1.1.0")
DEBUG = os.getenv("DEBUG", "False").lower() == "true"
# API 설정
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-pro")
DEFAULT_PROMPT = os.getenv(
"DEFAULT_PROMPT",
"pdf 이미지 분석하여 도면인지 어떤 정보들이 있는지 알려줘.structured_output 이외에 정보도 기타에 넣어줘."
)
# 파일 업로드 설정
MAX_FILE_SIZE_MB = int(os.getenv("MAX_FILE_SIZE_MB", "50"))
ALLOWED_EXTENSIONS = os.getenv("ALLOWED_EXTENSIONS", "pdf,dxf").split(",")
UPLOAD_FOLDER = os.getenv("UPLOAD_FOLDER", "uploads")
# 경로 설정
BASE_DIR = Path(__file__).parent
UPLOAD_DIR = BASE_DIR / UPLOAD_FOLDER
ASSETS_DIR = BASE_DIR / "assets"
RESULTS_FOLDER = BASE_DIR / "results"
@classmethod
def validate_config(cls):
"""설정 유효성 검사"""
errors = []
if not cls.GEMINI_API_KEY:
errors.append("GEMINI_API_KEY가 설정되지 않았습니다.")
if not cls.UPLOAD_DIR.exists():
try:
cls.UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
except Exception as e:
errors.append(f"업로드 폴더 생성 실패: {e}")
return errors
@classmethod
def get_file_size_limit_bytes(cls):
"""파일 크기 제한을 바이트로 반환"""
return cls.MAX_FILE_SIZE_MB * 1024 * 1024
@classmethod
def get_gemini_api_key(cls):
"""Gemini API 키 반환"""
return cls.GEMINI_API_KEY
# 설정 검증
if __name__ == "__main__":
config_errors = Config.validate_config()
if config_errors:
print("설정 오류:")
for error in config_errors:
print(f" - {error}")
else:
print("설정이 올바르게 구성되었습니다.")

View File

@@ -0,0 +1,638 @@
"""
Cross-Tabulated CSV 내보내기 모듈 (개선된 통합 버전)
JSON 형태의 분석 결과를 key-value 형태의 cross-tabulated CSV로 저장하는 기능을 제공합니다.
관련 키들(value, x, y)을 하나의 행으로 통합하여 저장합니다.
Author: Claude Assistant
Created: 2025-07-15
Updated: 2025-07-16 (키 통합 개선 버전)
Version: 2.0.0
"""
import pandas as pd
import json
import logging
from datetime import datetime
from typing import List, Dict, Any, Optional, Union, Tuple
import os
import re
from collections import defaultdict
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class CrossTabulatedCSVExporter:
"""Cross-Tabulated CSV 내보내기 클래스 (개선된 통합 버전)"""
def __init__(self):
"""Cross-Tabulated CSV 내보내기 초기화"""
self.coordinate_pattern = re.compile(r'\b(\d+)\s*,\s*(\d+)\b') # x,y 좌표 패턴
self.debug_mode = True # 디버깅 모드 활성화
# 키 그룹핑을 위한 패턴들
self.value_suffixes = ['_value', '_val', '_text', '_content']
self.x_suffixes = ['_x', '_x_coord', '_x_position', '_left']
self.y_suffixes = ['_y', '_y_coord', '_y_position', '_top']
def export_cross_tabulated_csv(
self,
processing_results: List[Any],
output_path: str,
include_coordinates: bool = True,
coordinate_source: str = "auto" # "auto", "text_blocks", "analysis_result", "none"
) -> bool:
"""
처리 결과를 cross-tabulated CSV 형태로 저장 (키 통합 기능 포함)
Args:
processing_results: 다중 파일 처리 결과 리스트
output_path: 출력 CSV 파일 경로
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처 ("auto", "text_blocks", "analysis_result", "none")
Returns:
저장 성공 여부
"""
try:
if self.debug_mode:
logger.info(f"=== Cross-tabulated CSV 저장 시작 (통합 버전) ===")
logger.info(f"입력된 결과 수: {len(processing_results)}")
logger.info(f"출력 경로: {output_path}")
logger.info(f"좌표 포함: {include_coordinates}, 좌표 출처: {coordinate_source}")
# 입력 데이터 검증
if not processing_results:
logger.warning("입력된 처리 결과가 비어있습니다.")
return False
# 각 결과 객체의 구조 분석
for i, result in enumerate(processing_results):
if self.debug_mode:
logger.info(f"결과 {i+1}: {self._analyze_result_structure(result)}")
# 모든 파일의 key-value 쌍을 수집
all_grouped_data = []
for i, result in enumerate(processing_results):
try:
if not hasattr(result, 'success'):
logger.warning(f"결과 {i+1}: 'success' 속성이 없습니다. 스킵합니다.")
continue
if not result.success:
if self.debug_mode:
logger.info(f"결과 {i+1}: 실패한 파일, 스킵합니다 ({getattr(result, 'error_message', 'Unknown error')})")
continue # 실패한 파일은 제외
# 기본 key-value 쌍 추출
file_data = self._extract_key_value_pairs(result, include_coordinates, coordinate_source)
if file_data:
# 관련 키들을 그룹화하여 통합된 데이터 생성
grouped_data = self._group_and_merge_keys(file_data, result)
if grouped_data:
all_grouped_data.extend(grouped_data)
if self.debug_mode:
logger.info(f"결과 {i+1}: {len(file_data)}개 key-value 쌍 → {len(grouped_data)}개 통합 행 생성")
else:
if self.debug_mode:
logger.warning(f"결과 {i+1}: 그룹화 후 데이터가 없습니다")
else:
if self.debug_mode:
logger.warning(f"결과 {i+1}: key-value 쌍을 추출할 수 없습니다")
except Exception as e:
logger.error(f"결과 {i+1} 처리 중 오류: {str(e)}")
continue
if not all_grouped_data:
logger.warning("저장할 데이터가 없습니다. 모든 파일에서 유효한 key-value 쌍을 추출할 수 없었습니다.")
if self.debug_mode:
self._print_debug_summary(processing_results)
return False
# DataFrame 생성
df = pd.DataFrame(all_grouped_data)
# 컬럼 순서 정렬
column_order = ['file_name', 'file_type', 'key', 'value']
if include_coordinates and coordinate_source != "none":
column_order.extend(['x', 'y'])
# 추가 컬럼들을 뒤에 배치
existing_columns = [col for col in column_order if col in df.columns]
additional_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + additional_columns]
# 출력 디렉토리 생성
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"Cross-tabulated CSV 저장 완료: {output_path}")
logger.info(f"{len(all_grouped_data)}개 통합 행 저장")
return True
except Exception as e:
logger.error(f"Cross-tabulated CSV 저장 오류: {str(e)}")
return False
def _group_and_merge_keys(self, raw_data: List[Dict[str, Any]], result: Any) -> List[Dict[str, Any]]:
"""
관련된 키들을 그룹화하고 하나의 행으로 통합
Args:
raw_data: 원시 key-value 쌍 리스트
result: 파일 처리 결과
Returns:
통합된 데이터 리스트
"""
# 파일 기본 정보
file_name = getattr(result, 'file_name', 'Unknown')
file_type = getattr(result, 'file_type', 'Unknown')
# 키별로 데이터 그룹화
key_groups = defaultdict(dict)
for data_row in raw_data:
key = data_row.get('key', '')
value = data_row.get('value', '')
x = data_row.get('x', '')
y = data_row.get('y', '')
# 기본 키 추출 (예: "사업명_value" -> "사업명")
base_key = self._extract_base_key(key)
# 키 타입 결정 (value, x, y 등)
key_type = self._determine_key_type(key)
if self.debug_mode and not key_groups[base_key]:
logger.info(f"새 키 그룹 생성: '{base_key}' (원본: '{key}', 타입: '{key_type}')")
# 그룹에 데이터 추가
if key_type == 'value':
key_groups[base_key]['value'] = value
# value에 좌표가 포함된 경우 사용
if not key_groups[base_key].get('x') and x:
key_groups[base_key]['x'] = x
if not key_groups[base_key].get('y') and y:
key_groups[base_key]['y'] = y
elif key_type == 'x':
key_groups[base_key]['x'] = value # x 값은 value 컬럼에서 가져옴
elif key_type == 'y':
key_groups[base_key]['y'] = value # y 값은 value 컬럼에서 가져옴
else:
# 일반적인 키인 경우 (suffix가 없는 경우)
if not key_groups[base_key].get('value'):
key_groups[base_key]['value'] = value
if x and not key_groups[base_key].get('x'):
key_groups[base_key]['x'] = x
if y and not key_groups[base_key].get('y'):
key_groups[base_key]['y'] = y
# 그룹화된 데이터를 최종 형태로 변환
merged_data = []
for base_key, group_data in key_groups.items():
# 빈 값이나 의미없는 데이터 제외
if not group_data.get('value') or str(group_data.get('value')).strip() == '':
continue
merged_row = {
'file_name': file_name,
'file_type': file_type,
'key': base_key,
'value': str(group_data.get('value', '')),
'x': str(group_data.get('x', '')) if group_data.get('x') else '',
'y': str(group_data.get('y', '')) if group_data.get('y') else '',
}
merged_data.append(merged_row)
if self.debug_mode:
logger.info(f"통합 행 생성: {base_key} = '{merged_row['value']}' ({merged_row['x']}, {merged_row['y']})")
return merged_data
def _extract_base_key(self, key: str) -> str:
"""
키에서 기본 이름 추출 (suffix 제거)
Args:
key: 원본 키 (예: "사업명_value", "사업명_x")
Returns:
기본 키 이름 (예: "사업명")
"""
if not key:
return key
# 모든 가능한 suffix 확인
all_suffixes = self.value_suffixes + self.x_suffixes + self.y_suffixes
for suffix in all_suffixes:
if key.endswith(suffix):
return key[:-len(suffix)]
# suffix가 없는 경우 원본 반환
return key
def _determine_key_type(self, key: str) -> str:
"""
키의 타입 결정 (value, x, y, other)
Args:
key: 키 이름
Returns:
키 타입 ("value", "x", "y", "other")
"""
if not key:
return "other"
key_lower = key.lower()
# value 타입 확인
for suffix in self.value_suffixes:
if key_lower.endswith(suffix.lower()):
return "value"
# x 타입 확인
for suffix in self.x_suffixes:
if key_lower.endswith(suffix.lower()):
return "x"
# y 타입 확인
for suffix in self.y_suffixes:
if key_lower.endswith(suffix.lower()):
return "y"
return "other"
def _analyze_result_structure(self, result: Any) -> str:
"""결과 객체의 구조를 분석하여 문자열로 반환"""
try:
info = []
# 기본 속성들 확인
if hasattr(result, 'file_name'):
info.append(f"file_name='{result.file_name}'")
if hasattr(result, 'file_type'):
info.append(f"file_type='{result.file_type}'")
if hasattr(result, 'success'):
info.append(f"success={result.success}")
# PDF 관련 속성
if hasattr(result, 'pdf_analysis_result'):
pdf_result = result.pdf_analysis_result
if pdf_result:
if isinstance(pdf_result, str):
info.append(f"pdf_analysis_result=str({len(pdf_result)} chars)")
else:
info.append(f"pdf_analysis_result={type(pdf_result).__name__}")
else:
info.append("pdf_analysis_result=None")
# DXF 관련 속성
if hasattr(result, 'dxf_title_blocks'):
dxf_blocks = result.dxf_title_blocks
if dxf_blocks:
info.append(f"dxf_title_blocks=list({len(dxf_blocks)} blocks)")
else:
info.append("dxf_title_blocks=None")
return " | ".join(info) if info else "구조 분석 실패"
except Exception as e:
return f"분석 오류: {str(e)}"
def _print_debug_summary(self, processing_results: List[Any]):
"""디버깅을 위한 요약 정보 출력"""
logger.info("=== 디버깅 요약 ===")
success_count = 0
pdf_count = 0
dxf_count = 0
has_pdf_data = 0
has_dxf_data = 0
for i, result in enumerate(processing_results):
try:
if hasattr(result, 'success') and result.success:
success_count += 1
file_type = getattr(result, 'file_type', 'unknown').lower()
if file_type == 'pdf':
pdf_count += 1
if getattr(result, 'pdf_analysis_result', None):
has_pdf_data += 1
elif file_type == 'dxf':
dxf_count += 1
if getattr(result, 'dxf_title_blocks', None):
has_dxf_data += 1
except Exception as e:
logger.error(f"결과 {i+1} 분석 중 오류: {str(e)}")
logger.info(f"총 결과: {len(processing_results)}")
logger.info(f"성공한 결과: {success_count}")
logger.info(f"PDF 파일: {pdf_count}개 (분석 데이터 있음: {has_pdf_data}개)")
logger.info(f"DXF 파일: {dxf_count}개 (타이틀블록 데이터 있음: {has_dxf_data}개)")
def _extract_key_value_pairs(
self,
result: Any,
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""
단일 파일 결과에서 key-value 쌍 추출
Args:
result: 파일 처리 결과
include_coordinates: 좌표 정보 포함 여부
coordinate_source: 좌표 정보 출처
Returns:
key-value 쌍 리스트
"""
data_rows = []
try:
# 기본 정보 확인
file_name = getattr(result, 'file_name', 'Unknown')
file_type = getattr(result, 'file_type', 'Unknown')
base_info = {
'file_name': file_name,
'file_type': file_type,
}
if self.debug_mode:
logger.info(f"처리 중: {file_name} ({file_type})")
# PDF 분석 결과 처리
if file_type.lower() == 'pdf':
pdf_result = getattr(result, 'pdf_analysis_result', None)
if pdf_result:
pdf_rows = self._extract_pdf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(pdf_rows)
if self.debug_mode:
logger.info(f"PDF에서 {len(pdf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"PDF 분석 결과가 없습니다: {file_name}")
# DXF 분석 결과 처리
elif file_type.lower() == 'dxf':
dxf_blocks = getattr(result, 'dxf_title_blocks', None)
if dxf_blocks:
dxf_rows = self._extract_dxf_key_values(result, base_info, include_coordinates, coordinate_source)
data_rows.extend(dxf_rows)
if self.debug_mode:
logger.info(f"DXF에서 {len(dxf_rows)}개 key-value 쌍 추출")
else:
if self.debug_mode:
logger.warning(f"DXF 타이틀블록 데이터가 없습니다: {file_name}")
else:
if self.debug_mode:
logger.warning(f"지원하지 않는 파일 형식: {file_type}")
except Exception as e:
logger.error(f"Key-value 추출 오류 ({getattr(result, 'file_name', 'Unknown')}): {str(e)}")
return data_rows
def _extract_pdf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""PDF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
# PDF 분석 결과를 JSON으로 파싱
analysis_result = getattr(result, 'pdf_analysis_result', None)
if not analysis_result:
return data_rows
if isinstance(analysis_result, str):
try:
analysis_data = json.loads(analysis_result)
except json.JSONDecodeError:
# JSON이 아닌 경우 텍스트로 처리
analysis_data = {"분석결과": analysis_result}
else:
analysis_data = analysis_result
if self.debug_mode:
logger.info(f"PDF 분석 데이터 구조: {type(analysis_data).__name__}")
if isinstance(analysis_data, dict):
logger.info(f"PDF 분석 데이터 키: {list(analysis_data.keys())}")
# 중첩된 구조를 평탄화하여 key-value 쌍 생성
flattened_data = self._flatten_dict(analysis_data)
for key, value in flattened_data.items():
if value is None or str(value).strip() == "":
continue # 빈 값 제외
row_data = base_info.copy()
row_data.update({
'key': key,
'value': str(value),
})
# 좌표 정보 추가
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates(key, value, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
except Exception as e:
logger.error(f"PDF key-value 추출 오류: {str(e)}")
return data_rows
def _extract_dxf_key_values(
self,
result: Any,
base_info: Dict[str, str],
include_coordinates: bool,
coordinate_source: str
) -> List[Dict[str, Any]]:
"""DXF 분석 결과에서 key-value 쌍 추출"""
data_rows = []
try:
title_blocks = getattr(result, 'dxf_title_blocks', None)
if not title_blocks:
return data_rows
if self.debug_mode:
logger.info(f"DXF 타이틀블록 수: {len(title_blocks)}")
for block_idx, title_block in enumerate(title_blocks):
if not isinstance(title_block, dict):
continue
block_name = title_block.get('block_name', 'Unknown')
# 블록 정보
row_data = base_info.copy()
row_data.update({
'key': f"{block_name}_블록명",
'value': block_name,
})
if include_coordinates and coordinate_source != "none":
coordinates = self._extract_coordinates('블록명', block_name, coordinate_source)
row_data.update(coordinates)
data_rows.append(row_data)
# 속성 정보
attributes = title_block.get('attributes', [])
if self.debug_mode:
logger.info(f"블록 {block_idx+1} ({block_name}): {len(attributes)}개 속성")
for attr_idx, attr in enumerate(attributes):
if not isinstance(attr, dict):
continue
attr_text = attr.get('text', '')
if not attr_text or str(attr_text).strip() == "":
continue # 빈 속성 제외
# 속성별 key-value 쌍 생성
attr_key = attr.get('tag', attr.get('prompt', f'Unknown_Attr_{attr_idx}'))
attr_value = str(attr_text)
row_data = base_info.copy()
row_data.update({
'key': attr_key,
'value': attr_value,
})
# DXF 속성의 경우 insert 좌표 사용
if include_coordinates and coordinate_source != "none":
x_coord = attr.get('insert_x', '')
y_coord = attr.get('insert_y', '')
if x_coord and y_coord:
row_data.update({
'x': round(float(x_coord), 2) if isinstance(x_coord, (int, float)) else x_coord,
'y': round(float(y_coord), 2) if isinstance(y_coord, (int, float)) else y_coord,
})
else:
row_data.update({'x': '', 'y': ''})
data_rows.append(row_data)
except Exception as e:
logger.error(f"DXF key-value 추출 오류: {str(e)}")
return data_rows
def _flatten_dict(self, data: Dict[str, Any], parent_key: str = '', sep: str = '_') -> Dict[str, Any]:
"""
중첩된 딕셔너리를 평탄화
Args:
data: 평탄화할 딕셔너리
parent_key: 부모 키
sep: 구분자
Returns:
평탄화된 딕셔너리
"""
items = []
for k, v in data.items():
new_key = f"{parent_key}{sep}{k}" if parent_key else k
if isinstance(v, dict):
# 중첩된 딕셔너리인 경우 재귀 호출
items.extend(self._flatten_dict(v, new_key, sep=sep).items())
elif isinstance(v, list):
# 리스트인 경우 인덱스와 함께 처리
for i, item in enumerate(v):
if isinstance(item, dict):
items.extend(self._flatten_dict(item, f"{new_key}_{i}", sep=sep).items())
else:
items.append((f"{new_key}_{i}", item))
else:
items.append((new_key, v))
return dict(items)
def _extract_coordinates(self, key: str, value: str, coordinate_source: str) -> Dict[str, str]:
"""
텍스트에서 좌표 정보 추출
Args:
key: 키
value: 값
coordinate_source: 좌표 정보 출처
Returns:
좌표 딕셔너리
"""
coordinates = {'x': '', 'y': ''}
try:
# 값에서 좌표 패턴 찾기
matches = self.coordinate_pattern.findall(str(value))
if matches:
# 첫 번째 매치 사용
x, y = matches[0]
coordinates = {'x': x, 'y': y}
else:
# 키에서 좌표 정보 찾기
key_matches = self.coordinate_pattern.findall(str(key))
if key_matches:
x, y = key_matches[0]
coordinates = {'x': x, 'y': y}
except Exception as e:
logger.warning(f"좌표 추출 오류: {str(e)}")
return coordinates
def generate_cross_tabulated_csv_filename(base_name: str = "cross_tabulated_analysis") -> str:
"""기본 Cross-tabulated CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{base_name}_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
# 테스트용 예시
exporter = CrossTabulatedCSVExporter()
# 샘플 처리 결과 (실제 데이터 구조에 맞게 수정)
sample_results = []
# 실제 사용 시에는 processing_results를 전달
# success = exporter.export_cross_tabulated_csv(
# sample_results,
# "test_cross_tabulated.csv",
# include_coordinates=True
# )
print("Cross-tabulated CSV 내보내기 모듈 (통합 버전) 테스트 완료")

View File

@@ -0,0 +1,306 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CSV 저장 유틸리티 모듈
DXF 타이틀블럭 Attribute 정보를 CSV 형식으로 저장
"""
import csv
import os
import logging
from typing import List, Dict, Any, Optional
from datetime import datetime
from config import Config
logger = logging.getLogger(__name__)
class TitleBlockCSVExporter:
"""타이틀블럭 속성 정보 CSV 저장 클래스"""
def __init__(self, output_dir: str = None):
"""CSV 저장기 초기화"""
self.output_dir = output_dir or Config.RESULTS_FOLDER
os.makedirs(self.output_dir, exist_ok=True)
def export_title_block_attributes(
self,
title_block_info: Dict[str, Any],
filename: str = None
) -> Optional[str]:
"""
타이틀블럭 속성 정보를 CSV 파일로 저장
Args:
title_block_info: 타이틀블럭 정보 딕셔너리
filename: 저장할 파일명 (없으면 자동 생성)
Returns:
저장된 파일 경로 또는 None (실패시)
"""
try:
if not title_block_info or not title_block_info.get('all_attributes'):
logger.warning("타이틀블럭 속성 정보가 없습니다.")
return None
# 파일명 생성
if not filename:
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
block_name = title_block_info.get('block_name', 'Unknown_Block')
filename = f"title_block_attributes_{block_name}_{timestamp}.csv"
# 확장자 확인
if not filename.endswith('.csv'):
filename += '.csv'
filepath = os.path.join(self.output_dir, filename)
# CSV 헤더 정의
headers = [
'block_name', # block_ref.name
'attr_prompt', # attr.prompt
'attr_text', # attr.text
'attr_tag', # attr.tag
'attr_insert_x', # attr.insert_x
'attr_insert_y', # attr.insert_y
'bounding_box_min_x', # attr.bounding_box.min_x
'bounding_box_min_y', # attr.bounding_box.min_y
'bounding_box_max_x', # attr.bounding_box.max_x
'bounding_box_max_y', # attr.bounding_box.max_y
'bounding_box_width', # attr.bounding_box.width
'bounding_box_height', # attr.bounding_box.height
'attr_height', # 추가: 텍스트 높이
'attr_rotation', # 추가: 회전각
'attr_layer', # 추가: 레이어
'attr_style', # 추가: 스타일
'entity_handle' # 추가: 엔티티 핸들
]
# CSV 데이터 준비
csv_rows = []
block_name = title_block_info.get('block_name', '')
for attr in title_block_info.get('all_attributes', []):
row = {
'block_name': block_name,
'attr_prompt': attr.get('prompt', '') or '',
'attr_text': attr.get('text', '') or '',
'attr_tag': attr.get('tag', '') or '',
'attr_insert_x': attr.get('insert_x', '') or '',
'attr_insert_y': attr.get('insert_y', '') or '',
'attr_height': attr.get('height', '') or '',
'attr_rotation': attr.get('rotation', '') or '',
'attr_layer': attr.get('layer', '') or '',
'attr_style': attr.get('style', '') or '',
'entity_handle': attr.get('entity_handle', '') or '',
}
# 바운딩 박스 정보 추가
bbox = attr.get('bounding_box')
if bbox:
row.update({
'bounding_box_min_x': bbox.get('min_x', ''),
'bounding_box_min_y': bbox.get('min_y', ''),
'bounding_box_max_x': bbox.get('max_x', ''),
'bounding_box_max_y': bbox.get('max_y', ''),
'bounding_box_width': bbox.get('max_x', 0) - bbox.get('min_x', 0) if bbox.get('max_x') and bbox.get('min_x') else '',
'bounding_box_height': bbox.get('max_y', 0) - bbox.get('min_y', 0) if bbox.get('max_y') and bbox.get('min_y') else '',
})
else:
row.update({
'bounding_box_min_x': '',
'bounding_box_min_y': '',
'bounding_box_max_x': '',
'bounding_box_max_y': '',
'bounding_box_width': '',
'bounding_box_height': '',
})
csv_rows.append(row)
# CSV 파일 저장
with open(filepath, 'w', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.DictWriter(csvfile, fieldnames=headers)
# 헤더 작성
writer.writeheader()
# 데이터 작성
writer.writerows(csv_rows)
logger.info(f"타이틀블럭 속성 CSV 저장 완료: {filepath}")
return filepath
except Exception as e:
logger.error(f"CSV 저장 중 오류: {e}")
return None
def create_attribute_table_data(
self,
title_block_info: Dict[str, Any]
) -> List[Dict[str, str]]:
"""
UI 테이블 표시용 데이터 생성
Args:
title_block_info: 타이틀블럭 정보 딕셔너리
Returns:
테이블 표시용 데이터 리스트
"""
try:
if not title_block_info or not title_block_info.get('all_attributes'):
return []
table_data = []
block_name = title_block_info.get('block_name', '')
for i, attr in enumerate(title_block_info.get('all_attributes', [])):
# 바운딩 박스 정보 포맷팅
bbox_str = ""
bbox = attr.get('bounding_box')
if bbox:
bbox_str = f"({bbox.get('min_x', 0):.1f}, {bbox.get('min_y', 0):.1f}) - ({bbox.get('max_x', 0):.1f}, {bbox.get('max_y', 0):.1f})"
row = {
'No.': str(i + 1),
'Block Name': block_name,
'Tag': attr.get('tag', ''),
'Text': attr.get('text', '')[:30] + ('...' if len(attr.get('text', '')) > 30 else ''), # 텍스트 길이 제한
'Prompt': attr.get('prompt', '') or 'N/A',
'X': f"{attr.get('insert_x', 0):.1f}",
'Y': f"{attr.get('insert_y', 0):.1f}",
'Bounding Box': bbox_str or 'N/A',
'Height': f"{attr.get('height', 0):.1f}",
'Layer': attr.get('layer', ''),
}
table_data.append(row)
return table_data
except Exception as e:
logger.error(f"테이블 데이터 생성 중 오류: {e}")
return []
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.INFO)
# 테스트 데이터
test_title_block = {
'block_name': 'TEST_TITLE_BLOCK',
'all_attributes': [
{
'tag': 'DRAWING_NAME',
'text': '테스트 도면',
'prompt': '도면명을 입력하세요',
'insert_x': 100.0,
'insert_y': 200.0,
'height': 5.0,
'rotation': 0.0,
'layer': '0',
'style': 'Standard',
'entity_handle': 'ABC123',
'bounding_box': {
'min_x': 100.0,
'min_y': 200.0,
'max_x': 180.0,
'max_y': 210.0
}
},
{
'tag': 'DRAWING_NUMBER',
'text': 'TEST-001',
'prompt': '도면번호를 입력하세요',
'insert_x': 100.0,
'insert_y': 190.0,
'height': 4.0,
'rotation': 0.0,
'layer': '0',
'style': 'Standard',
'entity_handle': 'DEF456',
'bounding_box': {
'min_x': 100.0,
'min_y': 190.0,
'max_x': 150.0,
'max_y': 198.0
}
}
]
}
# CSV 저장 테스트
exporter = TitleBlockCSVExporter()
# 테이블 데이터 생성 테스트
table_data = exporter.create_attribute_table_data(test_title_block)
print("테이블 데이터:")
for row in table_data:
print(row)
# CSV 저장 테스트
saved_path = exporter.export_title_block_attributes(test_title_block, "test_export.csv")
if saved_path:
print(f"\nCSV 저장 성공: {saved_path}")
else:
print("\nCSV 저장 실패")
if __name__ == "__main__":
main()
import json
def export_analysis_results_to_csv(data: List[Dict[str, Any]], file_path: str):
"""
분석 결과를 CSV 파일로 저장합니다. pdf_analysis_result 컬럼의 JSON 데이터를 평탄화합니다.
Args:
data: 분석 결과 딕셔너리 리스트
file_path: 저장할 CSV 파일 경로
"""
if not data:
logger.warning("내보낼 데이터가 없습니다.")
return
all_keys = set()
processed_data = []
for row in data:
new_row = row.copy()
if 'pdf_analysis_result' in new_row and new_row['pdf_analysis_result']:
try:
json_data = new_row['pdf_analysis_result']
if isinstance(json_data, str):
json_data = json.loads(json_data)
if isinstance(json_data, dict):
for k, v in json_data.items():
new_row[f"pdf_analysis_result_{k}"] = v
del new_row['pdf_analysis_result']
else:
new_row['pdf_analysis_result'] = str(json_data)
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"pdf_analysis_result 파싱 오류: {e}, 원본 데이터 유지: {new_row['pdf_analysis_result']}")
new_row['pdf_analysis_result'] = str(new_row['pdf_analysis_result'])
processed_data.append(new_row)
all_keys.update(new_row.keys())
# 'pdf_analysis_result'가 평탄화된 경우 최종 키에서 제거
if 'pdf_analysis_result' in all_keys:
all_keys.remove('pdf_analysis_result')
sorted_keys = sorted(list(all_keys))
try:
with open(file_path, 'w', newline='', encoding='utf-8-sig') as output_file:
dict_writer = csv.DictWriter(output_file, sorted_keys)
dict_writer.writeheader()
dict_writer.writerows(processed_data)
logger.info(f"분석 결과 CSV 저장 완료: {file_path}")
except Exception as e:
logger.error(f"분석 결과 CSV 저장 중 오류: {e}")

View File

@@ -0,0 +1,871 @@
# -*- coding: utf-8 -*-
"""
향상된 DXF 파일 처리 모듈
ezdxf 라이브러리를 사용하여 DXF 파일에서 도곽 정보, 텍스트 엔티티 및 모든 Block Reference/Attribute Reference를 추출
"""
import os
import json
import logging
from typing import Dict, List, Optional, Tuple, Any
from dataclasses import dataclass, asdict, field
try:
import ezdxf
from ezdxf.document import Drawing
from ezdxf.entities import Insert, Attrib, AttDef, Text, MText
from ezdxf.layouts import BlockLayout, Modelspace
from ezdxf import bbox, disassemble
EZDXF_AVAILABLE = True
except ImportError:
EZDXF_AVAILABLE = False
logging.warning("ezdxf 라이브러리가 설치되지 않았습니다. DXF 기능이 비활성화됩니다.")
from config import Config
@dataclass
class BoundingBox:
"""바운딩 박스 정보를 담는 데이터 클래스"""
min_x: float
min_y: float
max_x: float
max_y: float
@property
def width(self) -> float:
return self.max_x - self.min_x
@property
def height(self) -> float:
return self.max_y - self.min_y
@property
def center(self) -> Tuple[float, float]:
return ((self.min_x + self.max_x) / 2, (self.min_y + self.max_y) / 2)
def merge(self, other: 'BoundingBox') -> 'BoundingBox':
"""다른 바운딩 박스와 병합하여 가장 큰 외곽 박스 반환"""
return BoundingBox(
min_x=min(self.min_x, other.min_x),
min_y=min(self.min_y, other.min_y),
max_x=max(self.max_x, other.max_x),
max_y=max(self.max_y, other.max_y)
)
@dataclass
class TextInfo:
"""텍스트 엔티티 정보를 담는 데이터 클래스"""
entity_type: str # TEXT, MTEXT, ATTRIB
text: str
position: Tuple[float, float, float]
height: float
rotation: float
layer: str
bounding_box: Optional[BoundingBox] = None
entity_handle: Optional[str] = None
style: Optional[str] = None
color: Optional[int] = None
@dataclass
class AttributeInfo:
"""속성 정보를 담는 데이터 클래스 - 모든 DXF 속성 포함"""
tag: str
text: str
position: Tuple[float, float, float] # insert point (x, y, z)
height: float
width: float
rotation: float
layer: str
bounding_box: Optional[BoundingBox] = None
# 추가 DXF 속성들
prompt: Optional[str] = None
style: Optional[str] = None
invisible: bool = False
const: bool = False
verify: bool = False
preset: bool = False
align_point: Optional[Tuple[float, float, float]] = None
halign: int = 0
valign: int = 0
text_generation_flag: int = 0
oblique_angle: float = 0.0
width_factor: float = 1.0
color: Optional[int] = None
linetype: Optional[str] = None
lineweight: Optional[int] = None
# 좌표 정보
insert_x: float = 0.0
insert_y: float = 0.0
insert_z: float = 0.0
# 계산된 정보
estimated_width: float = 0.0
entity_handle: Optional[str] = None
@dataclass
class BlockInfo:
"""블록 정보를 담는 데이터 클래스"""
name: str
position: Tuple[float, float, float]
scale: Tuple[float, float, float]
rotation: float
layer: str
attributes: List[AttributeInfo]
bounding_box: Optional[BoundingBox] = None
@dataclass
class TitleBlockInfo:
"""도곽 정보를 담는 데이터 클래스"""
drawing_name: Optional[str] = None
drawing_number: Optional[str] = None
construction_field: Optional[str] = None
construction_stage: Optional[str] = None
scale: Optional[str] = None
project_name: Optional[str] = None
designer: Optional[str] = None
date: Optional[str] = None
revision: Optional[str] = None
location: Optional[str] = None
bounding_box: Optional[BoundingBox] = None
block_name: Optional[str] = None
# 모든 attributes 정보 저장
all_attributes: List[AttributeInfo] = field(default_factory=list)
attributes_count: int = 0
# 추가 메타데이터
block_position: Optional[Tuple[float, float, float]] = None
block_scale: Optional[Tuple[float, float, float]] = None
block_rotation: float = 0.0
block_layer: Optional[str] = None
def __post_init__(self):
"""초기화 후 처리"""
self.attributes_count = len(self.all_attributes)
@dataclass
class ComprehensiveExtractionResult:
"""종합적인 추출 결과를 담는 데이터 클래스"""
text_entities: List[TextInfo] = field(default_factory=list)
all_block_references: List[BlockInfo] = field(default_factory=list)
title_block: Optional[TitleBlockInfo] = None
overall_bounding_box: Optional[BoundingBox] = None
summary: Dict[str, Any] = field(default_factory=dict)
class EnhancedDXFProcessor:
"""향상된 DXF 파일 처리 클래스"""
# 도곽 식별을 위한 키워드 정의
TITLE_BLOCK_KEYWORDS = {
'건설분야': ['construction_field', 'field', '분야', '공사', 'category'],
'건설단계': ['construction_stage', 'stage', '단계', 'phase'],
'도면명': ['drawing_name', 'title', '제목', 'name', ''],
'축척': ['scale', '축척', 'ratio', '비율'],
'도면번호': ['drawing_number', 'number', '번호', 'no', 'dwg'],
'설계자': ['designer', '설계', 'design', 'drawn'],
'프로젝트': ['project', '사업', '공사명'],
'날짜': ['date', '일자', '작성일'],
'리비전': ['revision', 'rev', '개정'],
'위치': ['location', '위치', '지역']
}
def __init__(self):
"""DXF 처리기 초기화"""
self.logger = logging.getLogger(__name__)
if not EZDXF_AVAILABLE:
raise ImportError("ezdxf 라이브러리가 필요합니다. 'pip install ezdxf'로 설치하세요.")
def validate_dxf_file(self, file_path: str) -> bool:
"""DXF 파일 유효성 검사"""
try:
if not os.path.exists(file_path):
self.logger.error(f"파일이 존재하지 않습니다: {file_path}")
return False
if not file_path.lower().endswith('.dxf'):
self.logger.error(f"DXF 파일이 아닙니다: {file_path}")
return False
# ezdxf로 파일 읽기 시도
doc = ezdxf.readfile(file_path)
if doc is None:
return False
self.logger.info(f"DXF 파일 유효성 검사 성공: {file_path}")
return True
except ezdxf.DXFStructureError as e:
self.logger.error(f"DXF 구조 오류: {e}")
return False
except Exception as e:
self.logger.error(f"DXF 파일 검증 중 오류: {e}")
return False
def load_dxf_document(self, file_path: str) -> Optional[Drawing]:
"""DXF 문서 로드"""
try:
doc = ezdxf.readfile(file_path)
self.logger.info(f"DXF 문서 로드 성공: {file_path}")
return doc
except Exception as e:
self.logger.error(f"DXF 문서 로드 실패: {e}")
return None
def _is_empty_text(self, text: str) -> bool:
"""텍스트가 비어있는지 확인 (공백 문자만 있거나 완전히 비어있는 경우)"""
return not text or text.strip() == ""
def calculate_comprehensive_bounding_box(self, doc: Drawing) -> Optional[BoundingBox]:
"""전체 문서의 종합적인 바운딩 박스 계산 (ezdxf.bbox 사용)"""
try:
msp = doc.modelspace()
# ezdxf의 bbox 모듈을 사용하여 전체 바운딩 박스 계산
cache = bbox.Cache()
overall_bbox = bbox.extents(msp, cache=cache)
if overall_bbox:
self.logger.info(f"전체 바운딩 박스: {overall_bbox}")
return BoundingBox(
min_x=overall_bbox.extmin.x,
min_y=overall_bbox.extmin.y,
max_x=overall_bbox.extmax.x,
max_y=overall_bbox.extmax.y
)
else:
self.logger.warning("바운딩 박스 계산 실패")
return None
except Exception as e:
self.logger.warning(f"바운딩 박스 계산 중 오류: {e}")
return None
def extract_all_text_entities(self, doc: Drawing) -> List[TextInfo]:
"""모든 텍스트 엔티티 추출 (TEXT, MTEXT, DBTEXT)"""
text_entities = []
try:
msp = doc.modelspace()
# TEXT 엔티티 추출
for text_entity in msp.query('TEXT'):
text_content = getattr(text_entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(text_entity, 'TEXT')
if text_info:
text_entities.append(text_info)
# MTEXT 엔티티 추출
for mtext_entity in msp.query('MTEXT'):
# MTEXT는 .text 속성 사용
text_content = getattr(mtext_entity, 'text', '') or getattr(mtext_entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(mtext_entity, 'MTEXT')
if text_info:
text_entities.append(text_info)
# ATTRIB 엔티티 추출 (블록 외부의 독립적인 속성)
for attrib_entity in msp.query('ATTRIB'):
text_content = getattr(attrib_entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(attrib_entity, 'ATTRIB')
if text_info:
text_entities.append(text_info)
# 페이퍼스페이스도 확인
for layout_name in doc.layout_names_in_taborder():
if layout_name.startswith('*'): # 모델스페이스 제외
continue
try:
layout = doc.paperspace(layout_name)
# TEXT, MTEXT, ATTRIB 추출
for entity_type in ['TEXT', 'MTEXT', 'ATTRIB']:
for entity in layout.query(entity_type):
if entity_type == 'MTEXT':
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
else:
text_content = getattr(entity.dxf, 'text', '')
if not self._is_empty_text(text_content):
text_info = self._extract_text_info(entity, entity_type)
if text_info:
text_entities.append(text_info)
except Exception as e:
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
self.logger.info(f"{len(text_entities)}개의 텍스트 엔티티를 찾았습니다.")
return text_entities
except Exception as e:
self.logger.error(f"텍스트 엔티티 추출 중 오류: {e}")
return []
def _extract_text_info(self, entity, entity_type: str) -> Optional[TextInfo]:
"""텍스트 엔티티에서 정보 추출"""
try:
# 텍스트 내용 추출
if entity_type == 'MTEXT':
text_content = getattr(entity, 'text', '') or getattr(entity.dxf, 'text', '')
else:
text_content = getattr(entity.dxf, 'text', '')
# 위치 정보
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
position = (
insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
insert_point.z if hasattr(insert_point, 'z') else insert_point[2]
)
# 기본 속성
height = getattr(entity.dxf, 'height', 1.0)
rotation = getattr(entity.dxf, 'rotation', 0.0)
layer = getattr(entity.dxf, 'layer', '0')
entity_handle = getattr(entity.dxf, 'handle', None)
style = getattr(entity.dxf, 'style', None)
color = getattr(entity.dxf, 'color', None)
# 바운딩 박스 계산
bounding_box = self._calculate_text_bounding_box(entity)
return TextInfo(
entity_type=entity_type,
text=text_content,
position=position,
height=height,
rotation=rotation,
layer=layer,
bounding_box=bounding_box,
entity_handle=entity_handle,
style=style,
color=color
)
except Exception as e:
self.logger.warning(f"텍스트 정보 추출 중 오류: {e}")
return None
def _calculate_text_bounding_box(self, entity) -> Optional[BoundingBox]:
"""텍스트 엔티티의 바운딩 박스 계산"""
try:
# ezdxf bbox 모듈 사용
entity_bbox = bbox.extents([entity])
if entity_bbox:
return BoundingBox(
min_x=entity_bbox.extmin.x,
min_y=entity_bbox.extmin.y,
max_x=entity_bbox.extmax.x,
max_y=entity_bbox.extmax.y
)
except Exception as e:
self.logger.debug(f"바운딩 박스 계산 실패, 추정값 사용: {e}")
# 대안: 추정 계산
try:
if hasattr(entity, 'dxf'):
insert_point = getattr(entity.dxf, 'insert', (0, 0, 0))
height = getattr(entity.dxf, 'height', 1.0)
# 텍스트 내용 길이 추정
if hasattr(entity, 'text'):
text_content = entity.text
elif hasattr(entity.dxf, 'text'):
text_content = entity.dxf.text
else:
text_content = ""
# 텍스트 너비 추정 (높이의 0.6배 * 글자 수)
estimated_width = len(text_content) * height * 0.6
x, y = insert_point[0], insert_point[1]
return BoundingBox(
min_x=x,
min_y=y,
max_x=x + estimated_width,
max_y=y + height
)
except Exception as e:
self.logger.warning(f"텍스트 바운딩 박스 계산 실패: {e}")
return None
def extract_all_block_references(self, doc: Drawing) -> List[BlockInfo]:
"""모든 Block Reference 추출 (재귀적으로 중첩된 블록도 포함)"""
block_refs = []
try:
# 모델스페이스에서 INSERT 엔티티 찾기
msp = doc.modelspace()
for insert in msp.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
# 페이퍼스페이스도 확인
for layout_name in doc.layout_names_in_taborder():
if layout_name.startswith('*'): # 모델스페이스 제외
continue
try:
layout = doc.paperspace(layout_name)
for insert in layout.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
except Exception as e:
self.logger.warning(f"레이아웃 {layout_name} 처리 중 오류: {e}")
# 블록 정의 내부도 재귀적으로 검사
for block_layout in doc.blocks:
if not block_layout.name.startswith('*'): # 시스템 블록 제외
for insert in block_layout.query('INSERT'):
block_info = self._process_block_reference(doc, insert)
if block_info:
block_refs.append(block_info)
self.logger.info(f"{len(block_refs)}개의 블록 참조를 찾았습니다.")
return block_refs
except Exception as e:
self.logger.error(f"블록 참조 추출 중 오류: {e}")
return []
def _process_block_reference(self, doc: Drawing, insert: Insert) -> Optional[BlockInfo]:
"""개별 Block Reference 처리 - ATTDEF 정보도 함께 수집"""
try:
# 블록 정보 추출
block_name = insert.dxf.name
position = (insert.dxf.insert.x, insert.dxf.insert.y, insert.dxf.insert.z)
scale = (
getattr(insert.dxf, 'xscale', 1.0),
getattr(insert.dxf, 'yscale', 1.0),
getattr(insert.dxf, 'zscale', 1.0)
)
rotation = getattr(insert.dxf, 'rotation', 0.0)
layer = getattr(insert.dxf, 'layer', '0')
# ATTDEF 정보 수집 (프롬프트 정보 포함)
attdef_info = {}
try:
block_layout = doc.blocks.get(block_name)
if block_layout:
for attdef in block_layout.query('ATTDEF'):
tag = getattr(attdef.dxf, 'tag', '')
prompt = getattr(attdef.dxf, 'prompt', '')
if tag:
attdef_info[tag] = {
'prompt': prompt,
'default_text': getattr(attdef.dxf, 'text', ''),
'position': (attdef.dxf.insert.x, attdef.dxf.insert.y, attdef.dxf.insert.z),
'height': getattr(attdef.dxf, 'height', 1.0),
'style': getattr(attdef.dxf, 'style', 'Standard'),
'invisible': getattr(attdef.dxf, 'invisible', False),
'const': getattr(attdef.dxf, 'const', False),
'verify': getattr(attdef.dxf, 'verify', False),
'preset': getattr(attdef.dxf, 'preset', False)
}
except Exception as e:
self.logger.debug(f"ATTDEF 정보 수집 실패: {e}")
# ATTRIB 속성 추출 및 ATTDEF 정보와 결합 (빈 텍스트 제외)
attributes = []
for attrib in insert.attribs:
attr_info = self._extract_attribute_info(attrib)
if attr_info and not self._is_empty_text(attr_info.text):
# ATTDEF에서 프롬프트 정보 추가
if attr_info.tag in attdef_info:
attr_info.prompt = attdef_info[attr_info.tag]['prompt']
attributes.append(attr_info)
# 블록 바운딩 박스 계산
block_bbox = self._calculate_block_bounding_box(insert)
return BlockInfo(
name=block_name,
position=position,
scale=scale,
rotation=rotation,
layer=layer,
attributes=attributes,
bounding_box=block_bbox
)
except Exception as e:
self.logger.warning(f"블록 참조 처리 중 오류: {e}")
return None
def _calculate_block_bounding_box(self, insert: Insert) -> Optional[BoundingBox]:
"""블록의 바운딩 박스 계산"""
try:
# ezdxf bbox 모듈 사용
block_bbox = bbox.extents([insert])
if block_bbox:
return BoundingBox(
min_x=block_bbox.extmin.x,
min_y=block_bbox.extmin.y,
max_x=block_bbox.extmax.x,
max_y=block_bbox.extmax.y
)
except Exception as e:
self.logger.debug(f"블록 바운딩 박스 계산 실패: {e}")
return None
def _extract_attribute_info(self, attrib: Attrib) -> Optional[AttributeInfo]:
"""Attribute Reference에서 모든 정보 추출 (빈 텍스트 포함)"""
try:
# 기본 속성
tag = getattr(attrib.dxf, 'tag', '')
text = getattr(attrib.dxf, 'text', '')
# 위치 정보
insert_point = getattr(attrib.dxf, 'insert', (0, 0, 0))
position = (insert_point.x if hasattr(insert_point, 'x') else insert_point[0],
insert_point.y if hasattr(insert_point, 'y') else insert_point[1],
insert_point.z if hasattr(insert_point, 'z') else insert_point[2])
# 텍스트 속성
height = getattr(attrib.dxf, 'height', 1.0)
width = getattr(attrib.dxf, 'width', 1.0)
rotation = getattr(attrib.dxf, 'rotation', 0.0)
# 레이어 및 스타일
layer = getattr(attrib.dxf, 'layer', '0')
style = getattr(attrib.dxf, 'style', 'Standard')
# 속성 플래그
invisible = getattr(attrib.dxf, 'invisible', False)
const = getattr(attrib.dxf, 'const', False)
verify = getattr(attrib.dxf, 'verify', False)
preset = getattr(attrib.dxf, 'preset', False)
# 정렬 정보
align_point_data = getattr(attrib.dxf, 'align_point', None)
align_point = None
if align_point_data:
align_point = (align_point_data.x if hasattr(align_point_data, 'x') else align_point_data[0],
align_point_data.y if hasattr(align_point_data, 'y') else align_point_data[1],
align_point_data.z if hasattr(align_point_data, 'z') else align_point_data[2])
halign = getattr(attrib.dxf, 'halign', 0)
valign = getattr(attrib.dxf, 'valign', 0)
# 텍스트 형식
text_generation_flag = getattr(attrib.dxf, 'text_generation_flag', 0)
oblique_angle = getattr(attrib.dxf, 'oblique_angle', 0.0)
width_factor = getattr(attrib.dxf, 'width_factor', 1.0)
# 시각적 속성
color = getattr(attrib.dxf, 'color', None)
linetype = getattr(attrib.dxf, 'linetype', None)
lineweight = getattr(attrib.dxf, 'lineweight', None)
# 엔티티 핸들
entity_handle = getattr(attrib.dxf, 'handle', None)
# 텍스트 폭 추정
estimated_width = len(text) * height * 0.6 * width_factor
# 바운딩 박스 계산
bounding_box = self._calculate_text_bounding_box(attrib)
return AttributeInfo(
tag=tag,
text=text,
position=position,
height=height,
width=width,
rotation=rotation,
layer=layer,
bounding_box=bounding_box,
prompt=None, # 나중에 ATTDEF에서 설정
style=style,
invisible=invisible,
const=const,
verify=verify,
preset=preset,
align_point=align_point,
halign=halign,
valign=valign,
text_generation_flag=text_generation_flag,
oblique_angle=oblique_angle,
width_factor=width_factor,
color=color,
linetype=linetype,
lineweight=lineweight,
insert_x=position[0],
insert_y=position[1],
insert_z=position[2],
estimated_width=estimated_width,
entity_handle=entity_handle
)
except Exception as e:
self.logger.warning(f"속성 정보 추출 중 오류: {e}")
return None
def identify_title_block(self, block_refs: List[BlockInfo]) -> Optional[TitleBlockInfo]:
"""블록 참조들 중에서 도곽을 식별하고 정보 추출"""
title_block_candidates = []
for block_ref in block_refs:
# 도곽 키워드를 포함한 속성이 있는지 확인
keyword_matches = 0
for attr in block_ref.attributes:
for keyword_group in self.TITLE_BLOCK_KEYWORDS.keys():
if self._contains_keyword(attr.tag, keyword_group) or \
self._contains_keyword(attr.text, keyword_group):
keyword_matches += 1
break
# 충분한 키워드가 매칭되면 도곽 후보로 추가
if keyword_matches >= 2: # 최소 2개 이상의 키워드 매칭
title_block_candidates.append((block_ref, keyword_matches))
if not title_block_candidates:
self.logger.warning("도곽 블록을 찾을 수 없습니다.")
return None
# 가장 많은 키워드를 포함한 블록을 도곽으로 선택
title_block_candidates.sort(key=lambda x: x[1], reverse=True)
best_candidate = title_block_candidates[0][0]
self.logger.info(f"도곽 블록 발견: {best_candidate.name} (키워드 매칭: {title_block_candidates[0][1]})")
return self._extract_title_block_info(best_candidate)
def _contains_keyword(self, text: str, keyword_group: str) -> bool:
"""텍스트에 특정 키워드 그룹의 단어가 포함되어 있는지 확인"""
if not text:
return False
text_lower = text.lower()
keywords = self.TITLE_BLOCK_KEYWORDS.get(keyword_group, [])
return any(keyword.lower() in text_lower for keyword in keywords)
def _extract_title_block_info(self, block_ref: BlockInfo) -> TitleBlockInfo:
"""도곽 블록에서 상세 정보 추출"""
# TitleBlockInfo 객체 생성
title_block = TitleBlockInfo(
block_name=block_ref.name,
all_attributes=block_ref.attributes.copy(),
block_position=block_ref.position,
block_scale=block_ref.scale,
block_rotation=block_ref.rotation,
block_layer=block_ref.layer
)
# 속성들을 분석하여 도곽 정보 매핑
for attr in block_ref.attributes:
text_value = attr.text.strip()
if not text_value:
continue
# 각 키워드 그룹별로 매칭 시도
if self._contains_keyword(attr.tag, '도면명') or self._contains_keyword(attr.text, '도면명'):
title_block.drawing_name = text_value
elif self._contains_keyword(attr.tag, '도면번호') or self._contains_keyword(attr.text, '도면번호'):
title_block.drawing_number = text_value
elif self._contains_keyword(attr.tag, '건설분야') or self._contains_keyword(attr.text, '건설분야'):
title_block.construction_field = text_value
elif self._contains_keyword(attr.tag, '건설단계') or self._contains_keyword(attr.text, '건설단계'):
title_block.construction_stage = text_value
elif self._contains_keyword(attr.tag, '축척') or self._contains_keyword(attr.text, '축척'):
title_block.scale = text_value
elif self._contains_keyword(attr.tag, '설계자') or self._contains_keyword(attr.text, '설계자'):
title_block.designer = text_value
elif self._contains_keyword(attr.tag, '프로젝트') or self._contains_keyword(attr.text, '프로젝트'):
title_block.project_name = text_value
elif self._contains_keyword(attr.tag, '날짜') or self._contains_keyword(attr.text, '날짜'):
title_block.date = text_value
elif self._contains_keyword(attr.tag, '리비전') or self._contains_keyword(attr.text, '리비전'):
title_block.revision = text_value
elif self._contains_keyword(attr.tag, '위치') or self._contains_keyword(attr.text, '위치'):
title_block.location = text_value
# 도곽 바운딩 박스는 블록의 바운딩 박스 사용
title_block.bounding_box = block_ref.bounding_box
# 속성 개수 업데이트
title_block.attributes_count = len(title_block.all_attributes)
self.logger.info(f"도곽 '{block_ref.name}'에서 {title_block.attributes_count}개의 속성 추출 완료")
return title_block
def process_dxf_file_comprehensive(self, file_path: str) -> Dict[str, Any]:
"""DXF 파일 종합적인 처리"""
result = {
'success': False,
'error': None,
'file_path': file_path,
'comprehensive_result': None,
'summary': {}
}
try:
# 파일 유효성 검사
if not self.validate_dxf_file(file_path):
result['error'] = "유효하지 않은 DXF 파일입니다."
return result
# DXF 문서 로드
doc = self.load_dxf_document(file_path)
if not doc:
result['error'] = "DXF 문서를 로드할 수 없습니다."
return result
# 종합적인 추출 시작
comprehensive_result = ComprehensiveExtractionResult()
# 1. 모든 텍스트 엔티티 추출
self.logger.info("텍스트 엔티티 추출 중...")
comprehensive_result.text_entities = self.extract_all_text_entities(doc)
# 2. 모든 블록 참조 추출
self.logger.info("블록 참조 추출 중...")
comprehensive_result.all_block_references = self.extract_all_block_references(doc)
# 3. 도곽 정보 추출
self.logger.info("도곽 정보 추출 중...")
comprehensive_result.title_block = self.identify_title_block(comprehensive_result.all_block_references)
# 4. 전체 바운딩 박스 계산
self.logger.info("전체 바운딩 박스 계산 중...")
comprehensive_result.overall_bounding_box = self.calculate_comprehensive_bounding_box(doc)
# 5. 요약 정보 생성
comprehensive_result.summary = {
'total_text_entities': len(comprehensive_result.text_entities),
'total_block_references': len(comprehensive_result.all_block_references),
'title_block_found': comprehensive_result.title_block is not None,
'title_block_name': comprehensive_result.title_block.block_name if comprehensive_result.title_block else None,
'total_attributes': sum(len(br.attributes) for br in comprehensive_result.all_block_references),
'non_empty_attributes': sum(len([attr for attr in br.attributes if not self._is_empty_text(attr.text)])
for br in comprehensive_result.all_block_references),
'overall_bounding_box': comprehensive_result.overall_bounding_box.__dict__ if comprehensive_result.overall_bounding_box else None
}
# 결과 저장
result['comprehensive_result'] = asdict(comprehensive_result)
result['summary'] = comprehensive_result.summary
result['success'] = True
self.logger.info(f"DXF 파일 종합 처리 완료: {file_path}")
self.logger.info(f"추출 요약: 텍스트 엔티티 {comprehensive_result.summary['total_text_entities']}개, "
f"블록 참조 {comprehensive_result.summary['total_block_references']}개, "
f"비어있지 않은 속성 {comprehensive_result.summary['non_empty_attributes']}")
except Exception as e:
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
result['error'] = str(e)
return result
def save_analysis_result(self, result: Dict[str, Any], output_file: str) -> bool:
"""분석 결과를 JSON 파일로 저장"""
try:
os.makedirs(Config.RESULTS_FOLDER, exist_ok=True)
output_path = os.path.join(Config.RESULTS_FOLDER, output_file)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(result, f, ensure_ascii=False, indent=2, default=str)
self.logger.info(f"분석 결과 저장 완료: {output_path}")
return True
except Exception as e:
self.logger.error(f"분석 결과 저장 실패: {e}")
return False
# 기존 클래스명과의 호환성을 위한 별칭
DXFProcessor = EnhancedDXFProcessor
def main():
"""테스트용 메인 함수"""
logging.basicConfig(level=logging.INFO)
if not EZDXF_AVAILABLE:
print("ezdxf 라이브러리가 설치되지 않았습니다.")
return
processor = EnhancedDXFProcessor()
# 테스트 파일 경로 (실제 파일 경로로 변경 필요)
test_file = "test_drawing.dxf"
if os.path.exists(test_file):
result = processor.process_dxf_file_comprehensive(test_file)
if result['success']:
print("DXF 파일 종합 처리 성공!")
summary = result['summary']
print(f"텍스트 엔티티: {summary['total_text_entities']}")
print(f"블록 참조: {summary['total_block_references']}")
print(f"도곽 발견: {summary['title_block_found']}")
print(f"비어있지 않은 속성: {summary['non_empty_attributes']}")
if summary['overall_bounding_box']:
bbox_info = summary['overall_bounding_box']
print(f"전체 바운딩 박스: ({bbox_info['min_x']:.2f}, {bbox_info['min_y']:.2f}) ~ "
f"({bbox_info['max_x']:.2f}, {bbox_info['max_y']:.2f})")
else:
print(f"처리 실패: {result['error']}")
else:
print(f"테스트 파일을 찾을 수 없습니다: {test_file}")
def process_dxf_file(self, file_path: str) -> Dict[str, Any]:
"""
기존 코드와의 호환성을 위한 메서드
process_dxf_file_comprehensive를 호출하고 기존 형식으로 변환
"""
try:
# 새로운 종합 처리 메서드 호출
comprehensive_result = self.process_dxf_file_comprehensive(file_path)
if not comprehensive_result['success']:
return comprehensive_result
# 기존 형식으로 변환
comp_data = comprehensive_result['comprehensive_result']
# 기존 형식으로 데이터 재구성
result = {
'success': True,
'error': None,
'file_path': file_path,
'title_block': comp_data.get('title_block'),
'block_references': comp_data.get('all_block_references', []),
'summary': comp_data.get('summary', {})
}
return result
except Exception as e:
self.logger.error(f"DXF 파일 처리 중 오류: {e}")
return {
'success': False,
'error': str(e),
'file_path': file_path,
'title_block': None,
'block_references': [],
'summary': {}
}

View File

@@ -0,0 +1,271 @@
"""
Gemini API 연동 모듈 (좌표 추출 기능 추가)
Google Gemini API를 사용하여 이미지와 텍스트 좌표를 함께 분석합니다.
"""
import base64
import logging
import json
from google import genai
from google.genai import types
from typing import Optional, Dict, Any, List
from config import Config
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
# --- 새로운 스키마 정의 ---
# 좌표를 포함하는 값을 위한 재사용 가능한 스키마
ValueWithCoords = types.Schema(
type=types.Type.OBJECT,
properties={
"value": types.Schema(type=types.Type.STRING, description="추출된 텍스트 값"),
"x": types.Schema(type=types.Type.NUMBER, description="텍스트의 시작 x 좌표"),
"y": types.Schema(type=types.Type.NUMBER, description="텍스트의 시작 y 좌표"),
},
required=["value", "x", "y"]
)
# 모든 필드가 ValueWithCoords를 사용하도록 스키마 업데이트
SCHEMA_EXPRESSWAY = types.Schema(
type=types.Type.OBJECT,
properties={
"도면명_line0": ValueWithCoords,
"도면명_line1": ValueWithCoords,
"도면명_line2": ValueWithCoords,
"편철번호": ValueWithCoords,
"도면번호": ValueWithCoords,
"Main_Title": ValueWithCoords,
"Sub_Title": ValueWithCoords,
"수평_도면_축척": ValueWithCoords,
"수직_도면_축척": ValueWithCoords,
"적용표준버전": ValueWithCoords,
"사업명_top": ValueWithCoords,
"사업명_bot": ValueWithCoords,
"시설_공구": ValueWithCoords,
"설계공구_공구명": ValueWithCoords,
"설계공구_범위": ValueWithCoords,
"시공공구_공구명": ValueWithCoords,
"시공공구_범위": ValueWithCoords,
"건설분야": ValueWithCoords,
"건설단계": ValueWithCoords,
"설계사": ValueWithCoords,
"시공사": ValueWithCoords,
"노선이정": ValueWithCoords,
"개정번호_1": ValueWithCoords,
"개정날짜_1": ValueWithCoords,
"개정내용_1": ValueWithCoords,
"작성자_1": ValueWithCoords,
"검토자_1": ValueWithCoords,
"확인자_1": ValueWithCoords
},
)
SCHEMA_TRANSPORTATION = types.Schema(
type=types.Type.OBJECT,
properties={
"도면명": ValueWithCoords,
"편철번호": ValueWithCoords,
"도면번호": ValueWithCoords,
"Main Title": ValueWithCoords,
"Sub Title": ValueWithCoords,
"수평축척": ValueWithCoords,
"수직축척": ValueWithCoords,
"적용표준": ValueWithCoords,
"사업명": ValueWithCoords,
"시설_공구": ValueWithCoords,
"건설분야": ValueWithCoords,
"건설단계": ValueWithCoords,
"개정차수": ValueWithCoords,
"개정일자": ValueWithCoords,
"과업책임자": ValueWithCoords,
"분야별책임자": ValueWithCoords,
"설계자": ValueWithCoords,
"위치정보": ValueWithCoords
},
)
class GeminiAnalyzer:
"""Gemini API 이미지 및 텍스트 분석 클래스"""
def __init__(self, api_key: Optional[str] = None, model: Optional[str] = None):
self.api_key = api_key or Config.GEMINI_API_KEY
self.model = model or Config.GEMINI_MODEL
self.default_prompt = Config.DEFAULT_PROMPT
if not self.api_key:
raise ValueError("Gemini API 키가 설정되지 않았습니다.")
try:
self.client = genai.Client(api_key=self.api_key)
logger.info(f"Gemini 클라이언트 초기화 완료 (모델: {self.model})")
except Exception as e:
logger.error(f"Gemini 클라이언트 초기화 실패: {e}")
raise
def _get_schema(self, organization_type: str) -> types.Schema:
"""조직 유형에 따른 스키마를 반환합니다."""
return SCHEMA_EXPRESSWAY if organization_type == "한국도로공사" else SCHEMA_TRANSPORTATION
def analyze_pdf_page(
self,
base64_data: str,
text_blocks: List[Dict[str, Any]],
prompt: Optional[str] = None,
mime_type: str = "image/png",
organization_type: str = "transportation"
) -> Optional[str]:
"""
Base64 이미지와 추출된 텍스트 좌표를 함께 분석합니다.
Args:
base64_data: Base64로 인코딩된 이미지 데이터.
text_blocks: PDF에서 추출된 텍스트와 좌표 정보 리스트.
prompt: 분석 요청 텍스트 (None인 경우 기본값 사용).
mime_type: 이미지 MIME 타입.
organization_type: 조직 유형 ("transportation" 또는 "expressway").
Returns:
분석 결과 JSON 문자열 또는 None (실패 시).
"""
try:
# 텍스트 블록 정보를 JSON 문자열로 변환하여 프롬프트에 추가
text_context = "\n".join([
f"- text: '{block['text']}', bbox: ({block['bbox'][0]:.0f}, {block['bbox'][1]:.0f})"
for block in text_blocks
])
analysis_prompt = (
(prompt or self.default_prompt) +
"\n\n--- 추출된 텍스트와 좌표 정보 ---\n" +
text_context +
"\n\n--- 지시사항 ---\n"
"위 텍스트와 좌표 정보를 바탕으로, 이미지의 내용을 분석하여 JSON 스키마를 채워주세요."
"각 필드에 해당하는 텍스트를 찾고, 해당 텍스트의 'value'와 시작 'x', 'y' 좌표를 JSON에 기입하세요."
"top은 주로 문서 상단에, bot은 주로 문서 하단입니다. "
"특히 설계공구과 시공공구의 경우, 여러 개의 컬럼(공구명, 범위)으로 나누어진 경우가 있습니다. "
"설계공구 | 설계공구_공구명 | 설계공구_범위"
"시공공구 | 시공공구_공구명 | 시공공구_범위"
"와 같은 구조입니다. 구분자 색은 항상 black이 아닐 수 있음에 주의하세요"
"Given an image with a row like '설계공구 | 제2-1공구 | 12780.00-15860.00', the output should be:"
"설계공구_공구명: '제2-1공구'"
"설계공구_범위: '12780.00-15860.00'"
"도면명_line{n}은 도면명에 해당하는 값 여러 줄을 위에서부터 0, 1, 2, ...라고 정의합니다."
"도면명에 해당하는 값이 두 줄인 경우 line0이 생략된 경우입니다."
"{ }_Title은 중앙 상단의 비교적 큰 폰트입니다. "
"사업명_top에 해당하는 텍스트 아랫줄은 '시설_공구' 항목입니다."
"개정번호_{n}의 n은 삼각형 내부의 숫자입니다."
"각각의 컬럼에 해당하는 값을 개별적으로 추출해주세요."
"해당하는 값이 없으면 빈 문자열을 사용하세요."
)
contents = [
types.Content(
role="user",
parts=[
types.Part.from_bytes(
mime_type=mime_type,
data=base64.b64decode(base64_data),
),
types.Part.from_text(text=analysis_prompt),
],
)
]
selected_schema = self._get_schema(organization_type)
generate_content_config = types.GenerateContentConfig(
temperature=0,
top_p=0.05,
response_mime_type="application/json",
response_schema=selected_schema
)
logger.info("Gemini API 분석 요청 시작 (텍스트 좌표 포함)...")
response = self.client.models.generate_content(
model=self.model,
contents=contents,
config=generate_content_config,
)
if response and hasattr(response, 'text'):
result = response.text
# JSON 응답을 파싱하여 다시 직렬화 (일관된 포맷팅)
parsed_json = json.loads(result)
# 디버깅: Gemini 응답 내용 로깅
logger.info(f"=== Gemini 응답 디버깅 ===")
logger.info(f"조직 유형: {organization_type}")
logger.info(f"응답 필드 수: {len(parsed_json) if isinstance(parsed_json, dict) else 'N/A'}")
if isinstance(parsed_json, dict):
# 새로운 필드들이 응답에 포함되었는지 확인
new_fields = ["설계공구_Station_col1", "설계공구_Station_col2", "시공공구_Station_col1", "시공공구_Station_col2"]
old_fields = ["설계공구_Station", "시공공구_Station"]
logger.info("=== 새 필드 확인 ===")
for field in new_fields:
if field in parsed_json:
field_data = parsed_json[field]
if isinstance(field_data, dict) and field_data.get('value'):
logger.info(f"{field}: '{field_data.get('value')}' at ({field_data.get('x', 'N/A')}, {field_data.get('y', 'N/A')})")
else:
logger.info(f"⚠️ {field}: 빈 값 또는 잘못된 형식 - {field_data}")
else:
logger.info(f"{field}: 응답에 없음")
logger.info("=== 기존 필드 확인 ===")
for field in old_fields:
if field in parsed_json:
field_data = parsed_json[field]
if isinstance(field_data, dict) and field_data.get('value'):
logger.info(f"⚠️ {field}: '{field_data.get('value')}' (기존 필드가 여전히 존재)")
else:
logger.info(f"⚠️ {field}: 빈 값 - {field_data}")
else:
logger.info(f"{field}: 응답에 없음 (예상됨)")
logger.info("=== 전체 응답 필드 목록 ===")
for key in parsed_json.keys():
value = parsed_json[key]
if isinstance(value, dict) and 'value' in value:
logger.info(f"필드: {key} = '{value.get('value', '')}' at ({value.get('x', 'N/A')}, {value.get('y', 'N/A')})")
else:
logger.info(f"필드: {key} = {type(value).__name__}")
logger.info("=== 디버깅 끝 ===")
pretty_result = json.dumps(parsed_json, ensure_ascii=False, indent=2)
logger.info(f"분석 완료: {len(pretty_result)} 문자")
return pretty_result
else:
logger.error("API 응답에서 텍스트를 찾을 수 없습니다.")
return None
except Exception as e:
logger.error(f"이미지 및 텍스트 분석 중 오류 발생: {e}")
return None
# --- 기존 다른 메서드들은 필요에 따라 수정 또는 유지 ---
# analyze_image_stream_from_base64, analyze_pdf_images 등은
# 새로운 analyze_pdf_page 메서드와 호환되도록 수정 필요.
# 지금은 핵심 기능에 집중.
def validate_api_connection(self) -> bool:
"""API 연결 상태를 확인합니다."""
try:
test_response = self.client.models.generate_content("안녕하세요")
if test_response and hasattr(test_response, 'text'):
logger.info("Gemini API 연결 테스트 성공")
return True
else:
logger.error("Gemini API 연결 테스트 실패")
return False
except Exception as e:
logger.error(f"Gemini API 연결 테스트 중 오류: {e}")
return False

View File

@@ -1,78 +1,96 @@
{
"mapping_table": {
"ailabel_to_systems": {
"도면명": {
"도면명_line0": {
"molit": "",
"expressway": "TD_DNAME_TOP",
"railway": "",
"docaikey": "DNAME_TOP"
},
"도면명_line1": {
"molit": "DI_TITLE",
"expressway": "TD_DNAME_MAIN",
"expressway": "TD_DNAME_MAIN",
"railway": "TD_DNAME_MAIN",
"docaikey": "DNAME_MAIN"
},
"편철번호": {
"도면명_line2": {
"molit": "DI_SUBTITLE",
"expressway": "TD_DNAME_BOT",
"railway": "TD_DNAME_BOT",
"railway": "TD_DNAME_BOT",
"docaikey": "DNAME_BOT"
},
"도면번호": {
"편철번호": {
"molit": "DA_PAGENO",
"expressway": "TD_DWGNO",
"railway": "TD_DWGNO",
"docaikey": "DWGNO"
},
"Main Title": {
"도면번호": {
"molit": "DI_DRWNO",
"expressway": "TD_DWGCODE",
"railway": "TD_DWGCODE",
"docaikey": "DWGCODE"
},
"Sub Title": {
"Main_Title": {
"molit": "UD_TITLE",
"expressway": "TB_MTITIL",
"railway": "TB_MTITIL",
"docaikey": "MTITIL"
},
"수평축척": {
"Sub_Title": {
"molit": "UD_SUBTITLE",
"expressway": "TB_STITL",
"railway": "TB_STITL",
"docaikey": "STITL"
},
"수축척": {
"molit": "",
"expressway": "TD_DWGCODE_PREV",
"railway": "",
"docaikey": "DWGCODE_PREV"
},
"도면축척": {
"수평_도면_축척": {
"molit": "DA_HSCALE",
"expressway": "TD_HSCAL",
"railway": "",
"docaikey": "HSCAL"
},
"적용표준버전": {
"molit": "DA_STDNAME",
"expressway": "STDNAME",
"수직_도면_축척": {
"molit": "DA_VSCALE",
"expressway": "TD_VSCAL",
"railway": "",
"docaikey": ""
"docaikey": "VSCAL"
},
"사업명": {
"적용표준버전": {
"molit": "DA_STDVER",
"expressway": "TD_VERSION",
"railway": "TD_VERSION",
"docaikey": "VERSION"
},
"시설_공구": {
"사업명_top": {
"molit": "PI_CNAME",
"expressway": "TB_CNAME",
"railway": "",
"docaikey": "TBCNAME"
},
"설계공구_Station": {
"시설_공구": {
"molit": "UD_CDNAME",
"expressway": "TB_CSCOP",
"railway": "",
"docaikey": "CSCOP"
},
"사업명_bot": {
"molit": "",
"expressway": "TD_CNAME",
"railway": "TD_CNAME",
"docaikey": "TDCNAME"
},
"설계공구_공구명": {
"molit": "",
"expressway": "TD_DSECT",
"railway": "",
"docaikey": "DSECT"
},
"시공공구_공구명": {
"molit": "",
"expressway": "TD_CSECT",
"railway": "",
"docaikey": "CSECT"
},
"건설분야": {
"molit": "PA_CCLASS",
"expressway": "TD_FIELD",
@@ -86,54 +104,66 @@
"docaikey": "CSTEP"
},
"설계사": {
"molit": "TD_DCOMP",
"molit": "",
"expressway": "TD_DCOMP",
"railway": "",
"railway": "TD_DCOMP",
"docaikey": "DCOMP"
},
"시공사": {
"molit": "TD_CCOMP",
"molit": "",
"expressway": "TD_CCOMP",
"railway": "",
"railway": "TD_CCOMP",
"docaikey": "CCOMP"
},
"노선이정": {
"molit": "TD_LNDST",
"expressway": "",
"molit": "",
"expressway": "TD_LNDST",
"railway": "",
"docaikey": "LNDST"
},
"계정번호": {
"설계공구_범위": {
"molit": "",
"expressway": "TD_DDIST",
"railway": "",
"docaikey": "DDIST"
},
"시공공구_범위": {
"molit": "",
"expressway": "TD_CDIST",
"railway": "",
"docaikey": "CDIST"
},
"개정번호_1": {
"molit": "DC_RNUM1",
"expressway": "TR_RNUM1",
"railway": "TR_RNUM1",
"docaikey": "RNUM1"
},
"정날짜": {
"정날짜_1": {
"molit": "DC_RDATE1",
"expressway": "TR_RDAT1",
"railway": "TR_RDAT1",
"docaikey": "RDAT1"
},
"개정내용": {
"개정내용_1": {
"molit": "DC_RDES1",
"expressway": "TR_RCON1",
"railway": "TR_RCON1",
"docaikey": "RCON1"
},
"작성자": {
"작성자_1": {
"molit": "DC_RDGN1",
"expressway": "TR_DGN1",
"railway": "TR_DGN1",
"docaikey": "DGN1"
},
"검토자": {
"검토자_1": {
"molit": "DC_RCHK1",
"expressway": "TR_CHK1",
"railway": "TR_CHK1",
"docaikey": "CHK1"
},
"확인자": {
"확인자_1": {
"molit": "DC_RAPP1",
"expressway": "TR_APP1",
"railway": "TR_APP1",
@@ -143,7 +173,7 @@
"system_mappings": {
"expressway_to_transportation": {
"TD_DNAME_MAIN": "DNAME_MAIN",
"TD_DNAME_BOT": "DNAME_BOT",
"TD_DNAME_BOT": "DNAME_BOT",
"TD_DWGNO": "DWGNO",
"TD_DWGCODE": "DWGCODE",
"TB_MTITIL": "MTITIL",

View File

@@ -0,0 +1,588 @@
"""
다중 파일 처리 모듈
여러 PDF/DXF 파일을 배치로 처리하고 결과를 CSV로 저장하는 기능을 제공합니다.
Author: Claude Assistant
Created: 2025-07-14
Version: 1.0.0
"""
import asyncio
import os
import pandas as pd
from datetime import datetime
from typing import List, Dict, Any, Optional, Callable
from dataclasses import dataclass
import logging
from pdf_processor import PDFProcessor
from dxf_processor import EnhancedDXFProcessor
from gemini_analyzer import GeminiAnalyzer
from csv_exporter import TitleBlockCSVExporter
import json # Added import
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class FileProcessingResult:
"""단일 파일 처리 결과"""
file_path: str
file_name: str
file_type: str
file_size: int
processing_time: float
success: bool
error_message: Optional[str] = None
# PDF 분석 결과
pdf_analysis_result: Optional[str] = None
# DXF 분석 결과
dxf_title_blocks: Optional[List[Dict]] = None
dxf_total_attributes: Optional[int] = None
dxf_total_text_entities: Optional[int] = None
# 공통 메타데이터
processed_at: Optional[str] = None
@dataclass
class BatchProcessingConfig:
"""배치 처리 설정"""
organization_type: str = "한국도로공사"
enable_gemini_batch_mode: bool = False
max_concurrent_files: int = 3
save_intermediate_results: bool = True
output_csv_path: Optional[str] = None
include_error_files: bool = True
class MultiFileProcessor:
"""다중 파일 처리기"""
def __init__(self, gemini_api_key: str):
"""
다중 파일 처리기 초기화
Args:
gemini_api_key: Gemini API 키
"""
self.gemini_api_key = gemini_api_key
self.pdf_processor = PDFProcessor()
self.dxf_processor = EnhancedDXFProcessor()
self.gemini_analyzer = GeminiAnalyzer(gemini_api_key)
self.csv_exporter = TitleBlockCSVExporter() # CSV 내보내기 추가
self.processing_results: List[FileProcessingResult] = []
self.current_progress = 0
self.total_files = 0
async def process_multiple_files(
self,
file_paths: List[str],
config: BatchProcessingConfig,
progress_callback: Optional[Callable[[int, int, str], None]] = None
) -> List[FileProcessingResult]:
"""
여러 파일을 배치로 처리
Args:
file_paths: 처리할 파일 경로 리스트
config: 배치 처리 설정
progress_callback: 진행률 콜백 함수 (current, total, status)
Returns:
처리 결과 리스트
"""
self.processing_results = []
self.total_files = len(file_paths)
self.current_progress = 0
logger.info(f"배치 처리 시작: {self.total_files}개 파일")
# 동시 처리 제한을 위한 세마포어
semaphore = asyncio.Semaphore(config.max_concurrent_files)
# 각 파일에 대한 처리 태스크 생성
tasks = []
for i, file_path in enumerate(file_paths):
task = self._process_single_file_with_semaphore(
semaphore, file_path, config, progress_callback, i + 1
)
tasks.append(task)
# 모든 파일 처리 완료까지 대기
results = await asyncio.gather(*tasks, return_exceptions=True)
# 예외 발생한 결과 처리
for i, result in enumerate(results):
if isinstance(result, Exception):
error_result = FileProcessingResult(
file_path=file_paths[i],
file_name=os.path.basename(file_paths[i]),
file_type="unknown",
file_size=0,
processing_time=0,
success=False,
error_message=str(result),
processed_at=datetime.now().isoformat()
)
self.processing_results.append(error_result)
logger.info(f"배치 처리 완료: {len(self.processing_results)}개 결과")
# CSV 저장
if config.output_csv_path:
await self.save_results_to_csv(config.output_csv_path)
# JSON 출력도 함께 생성 (좌표 정보 포함)
json_output_path = config.output_csv_path.replace('.csv', '.json')
await self.save_results_to_json(json_output_path)
return self.processing_results
async def _process_single_file_with_semaphore(
self,
semaphore: asyncio.Semaphore,
file_path: str,
config: BatchProcessingConfig,
progress_callback: Optional[Callable[[int, int, str], None]],
file_number: int
) -> None:
"""세마포어를 사용하여 단일 파일 처리"""
async with semaphore:
result = await self._process_single_file(file_path, config)
self.processing_results.append(result)
self.current_progress += 1
if progress_callback:
status = f"처리 완료: {result.file_name}"
if not result.success:
status = f"처리 실패: {result.file_name} - {result.error_message}"
progress_callback(self.current_progress, self.total_files, status)
async def _process_single_file(
self,
file_path: str,
config: BatchProcessingConfig
) -> FileProcessingResult:
"""
단일 파일 처리
Args:
file_path: 파일 경로
config: 처리 설정
Returns:
처리 결과
"""
start_time = asyncio.get_event_loop().time()
file_name = os.path.basename(file_path)
try:
# 파일 정보 수집
file_size = os.path.getsize(file_path)
file_type = self._detect_file_type(file_path)
logger.info(f"파일 처리 시작: {file_name} ({file_type})")
result = FileProcessingResult(
file_path=file_path,
file_name=file_name,
file_type=file_type,
file_size=file_size,
processing_time=0,
success=False,
processed_at=datetime.now().isoformat()
)
# 파일 유형에 따른 처리
if file_type.lower() == 'pdf':
await self._process_pdf_file(file_path, result, config)
elif file_type.lower() == 'dxf':
await self._process_dxf_file(file_path, result, config)
else:
raise ValueError(f"지원하지 않는 파일 형식: {file_type}")
result.success = True
except Exception as e:
logger.error(f"파일 처리 오류 ({file_name}): {str(e)}")
result.success = False
result.error_message = str(e)
finally:
# 처리 시간 계산
end_time = asyncio.get_event_loop().time()
result.processing_time = round(end_time - start_time, 2)
return result
async def _process_pdf_file(
self,
file_path: str,
result: FileProcessingResult,
config: BatchProcessingConfig
) -> None:
"""PDF 파일 처리"""
# PDF 이미지 변환
images = self.pdf_processor.convert_to_images(file_path)
if not images:
raise ValueError("PDF를 이미지로 변환할 수 없습니다")
# 첫 번째 페이지만 분석 (다중 페이지 처리는 향후 개선)
first_page = images[0]
base64_image = self.pdf_processor.image_to_base64(first_page)
# PDF에서 텍스트 블록 추출
text_blocks = self.pdf_processor.extract_text_with_coordinates(file_path, 0)
# Gemini API로 분석
# 실제 구현에서는 batch mode 사용 가능
analysis_result = await self._analyze_with_gemini(
base64_image, text_blocks, config.organization_type
)
result.pdf_analysis_result = analysis_result
async def _process_dxf_file(
self,
file_path: str,
result: FileProcessingResult,
config: BatchProcessingConfig
) -> None:
"""DXF 파일 처리"""
# DXF 파일 분석
extraction_result = self.dxf_processor.extract_comprehensive_data(file_path)
# 타이틀 블록 정보를 딕셔너리 리스트로 변환
title_blocks = []
for tb_info in extraction_result.title_blocks:
tb_dict = {
'block_name': tb_info.block_name,
'block_position': f"{tb_info.block_position[0]:.2f}, {tb_info.block_position[1]:.2f}",
'attributes_count': tb_info.attributes_count,
'attributes': [
{
'tag': attr.tag,
'text': attr.text,
'prompt': attr.prompt,
'insert_x': attr.insert_x,
'insert_y': attr.insert_y
}
for attr in tb_info.all_attributes
]
}
title_blocks.append(tb_dict)
result.dxf_title_blocks = title_blocks
result.dxf_total_attributes = sum(tb['attributes_count'] for tb in title_blocks)
result.dxf_total_text_entities = len(extraction_result.text_entities)
# 상세한 title block attributes CSV 생성
if extraction_result.title_blocks:
await self._save_detailed_dxf_csv(file_path, extraction_result)
async def _analyze_with_gemini(
self,
base64_image: str,
text_blocks: list,
organization_type: str
) -> str:
"""Gemini API로 이미지 분석"""
try:
# 비동기 처리를 위해 동기 함수를 태스크로 실행
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
self.gemini_analyzer.analyze_pdf_page,
base64_image,
text_blocks,
None, # prompt (default 사용)
"image/png", # mime_type
organization_type
)
return result
except Exception as e:
logger.error(f"Gemini 분석 오류: {str(e)}")
return f"분석 실패: {str(e)}"
async def _save_detailed_dxf_csv(
self,
file_path: str,
extraction_result
) -> None:
"""상세한 DXF title block attributes CSV 저장"""
try:
# 파일명에서 확장자 제거
file_name = os.path.splitext(os.path.basename(file_path))[0]
# 출력 디렉토리 확인 및 생성
output_dir = os.path.join(os.path.dirname(file_path), '..', 'results')
os.makedirs(output_dir, exist_ok=True)
# CSV 파일명 생성
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
csv_filename = f"detailed_title_blocks_{file_name}_{timestamp}.csv"
csv_path = os.path.join(output_dir, csv_filename)
# TitleBlockCSVExporter를 사용하여 CSV 생성
loop = asyncio.get_event_loop()
await loop.run_in_executor(
None,
self.csv_exporter.save_title_block_info_to_csv,
extraction_result.title_blocks,
csv_path
)
logger.info(f"상세 DXF CSV 저장 완료: {csv_path}")
except Exception as e:
logger.error(f"상세 DXF CSV 저장 오류: {str(e)}")
def _detect_file_type(self, file_path: str) -> str:
"""파일 확장자로 파일 유형 검출"""
_, ext = os.path.splitext(file_path.lower())
if ext == '.pdf':
return 'PDF'
elif ext == '.dxf':
return 'DXF'
else:
return ext.upper().lstrip('.')
async def save_results_to_csv(self, output_path: str) -> None:
"""
처리 결과를 CSV 파일로 저장
Args:
output_path: 출력 CSV 파일 경로
"""
try:
# 결과를 DataFrame으로 변환
data_rows = []
for result in self.processing_results:
# 기본 정보
row = {
'file_name': result.file_name,
'file_path': result.file_path,
'file_type': result.file_type,
'file_size_bytes': result.file_size,
'file_size_mb': round(result.file_size / (1024 * 1024), 2),
'processing_time_seconds': result.processing_time,
'success': result.success,
'error_message': result.error_message or '',
'processed_at': result.processed_at
}
# PDF 분석 결과
if result.file_type.lower() == 'pdf':
row['pdf_analysis_result'] = result.pdf_analysis_result or ''
row['dxf_total_attributes'] = ''
row['dxf_total_text_entities'] = ''
row['dxf_title_blocks_summary'] = ''
# DXF 분석 결과
elif result.file_type.lower() == 'dxf':
row['pdf_analysis_result'] = ''
row['dxf_total_attributes'] = result.dxf_total_attributes or 0
row['dxf_total_text_entities'] = result.dxf_total_text_entities or 0
# 타이틀 블록 요약
if result.dxf_title_blocks:
summary = f"{len(result.dxf_title_blocks)}개 타이틀블록"
for tb in result.dxf_title_blocks[:3]: # 처음 3개만 표시
summary += f" | {tb['block_name']}({tb['attributes_count']}속성)"
if len(result.dxf_title_blocks) > 3:
summary += f" | ...외 {len(result.dxf_title_blocks)-3}"
row['dxf_title_blocks_summary'] = summary
else:
row['dxf_title_blocks_summary'] = '타이틀블록 없음'
data_rows.append(row)
# DataFrame 생성 및 CSV 저장
df = pd.DataFrame(data_rows)
# pdf_analysis_result 컬럼 평탄화
if 'pdf_analysis_result' in df.columns:
# JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리)
df['pdf_analysis_result'] = df['pdf_analysis_result'].apply(lambda x: json.loads(x) if isinstance(x, str) and x.strip() else {}).fillna({})
# 평탄화된 데이터를 새로운 DataFrame으로 생성
# errors='ignore'를 사용하여 JSON이 아닌 값은 무시
# record_prefix를 사용하여 컬럼 이름에 접두사 추가
pdf_analysis_df = pd.json_normalize(df['pdf_analysis_result'], errors='ignore', record_prefix='pdf_analysis_result_')
# 원본 df에서 pdf_analysis_result 컬럼 제거
df = df.drop(columns=['pdf_analysis_result'])
# 원본 df와 평탄화된 DataFrame을 병합
df = pd.concat([df, pdf_analysis_df], axis=1)
# 컬럼 순서 정렬을 위한 기본 순서 정의
column_order = [
'file_name', 'file_type', 'file_size_mb', 'processing_time_seconds',
'success', 'error_message', 'processed_at', 'file_path', 'file_size_bytes',
'dxf_total_attributes', 'dxf_total_text_entities', 'dxf_title_blocks_summary'
]
# 기존 컬럼 순서를 유지하면서 새로운 컬럼을 추가
existing_columns = [col for col in column_order if col in df.columns]
new_columns = [col for col in df.columns if col not in existing_columns]
df = df[existing_columns + sorted(new_columns)]
# UTF-8 BOM으로 저장 (한글 호환성)
df.to_csv(output_path, index=False, encoding='utf-8-sig')
logger.info(f"CSV 저장 완료: {output_path}")
logger.info(f"{len(data_rows)}개 파일 결과 저장")
except Exception as e:
logger.error(f"CSV 저장 오류: {str(e)}")
raise
async def save_results_to_json(self, output_path: str) -> None:
"""
처리 결과를 JSON 파일로 저장 (좌표 정보 포함)
Args:
output_path: 출력 JSON 파일 경로
"""
try:
# 결과를 JSON 구조로 변환
json_data = {
"metadata": {
"total_files": len(self.processing_results),
"success_files": sum(1 for r in self.processing_results if r.success),
"failed_files": sum(1 for r in self.processing_results if not r.success),
"generated_at": datetime.now().isoformat(),
"format_version": "1.0"
},
"results": []
}
for result in self.processing_results:
# 기본 정보
result_data = {
"file_info": {
"name": result.file_name,
"path": result.file_path,
"type": result.file_type,
"size_bytes": result.file_size,
"size_mb": round(result.file_size / (1024 * 1024), 2)
},
"processing_info": {
"success": result.success,
"processing_time_seconds": result.processing_time,
"processed_at": result.processed_at,
"error_message": result.error_message
}
}
# PDF 분석 결과 (좌표 정보 포함)
if result.file_type.lower() == 'pdf' and result.pdf_analysis_result:
try:
# JSON 문자열을 딕셔너리로 변환 (이미 딕셔너리인 경우도 처리)
if isinstance(result.pdf_analysis_result, str):
analysis_data = json.loads(result.pdf_analysis_result)
else:
analysis_data = result.pdf_analysis_result
result_data["pdf_analysis"] = analysis_data
except (json.JSONDecodeError, TypeError) as e:
logger.warning(f"PDF 분석 결과 JSON 파싱 오류: {e}")
result_data["pdf_analysis"] = {"error": "JSON 파싱 실패", "raw_data": str(result.pdf_analysis_result)}
# DXF 분석 결과
elif result.file_type.lower() == 'dxf':
result_data["dxf_analysis"] = {
"total_attributes": result.dxf_total_attributes or 0,
"total_text_entities": result.dxf_total_text_entities or 0,
"title_blocks": result.dxf_title_blocks or []
}
json_data["results"].append(result_data)
# JSON 파일 저장 (예쁜 포맷팅과 한글 지원)
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(json_data, f, ensure_ascii=False, indent=2, default=str)
logger.info(f"JSON 저장 완료: {output_path}")
logger.info(f"{len(json_data['results'])}개 파일 결과 저장 (좌표 정보 포함)")
except Exception as e:
logger.error(f"JSON 저장 오류: {str(e)}")
raise
def get_processing_summary(self) -> Dict[str, Any]:
"""처리 결과 요약 정보 반환"""
if not self.processing_results:
return {}
total_files = len(self.processing_results)
success_files = sum(1 for r in self.processing_results if r.success)
failed_files = total_files - success_files
pdf_files = sum(1 for r in self.processing_results if r.file_type.lower() == 'pdf')
dxf_files = sum(1 for r in self.processing_results if r.file_type.lower() == 'dxf')
total_processing_time = sum(r.processing_time for r in self.processing_results)
avg_processing_time = total_processing_time / total_files if total_files > 0 else 0
total_file_size = sum(r.file_size for r in self.processing_results)
return {
'total_files': total_files,
'success_files': success_files,
'failed_files': failed_files,
'pdf_files': pdf_files,
'dxf_files': dxf_files,
'total_processing_time': round(total_processing_time, 2),
'avg_processing_time': round(avg_processing_time, 2),
'total_file_size_mb': round(total_file_size / (1024 * 1024), 2),
'success_rate': round((success_files / total_files) * 100, 1) if total_files > 0 else 0
}
def generate_default_csv_filename() -> str:
"""기본 CSV 파일명 생성"""
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"batch_analysis_results_{timestamp}.csv"
# 사용 예시
if __name__ == "__main__":
async def main():
# 테스트용 예시
processor = MultiFileProcessor("your-gemini-api-key")
config = BatchProcessingConfig(
organization_type="한국도로공사",
max_concurrent_files=2,
output_csv_path="test_results.csv"
)
# 진행률 콜백 함수
def progress_callback(current: int, total: int, status: str):
print(f"진행률: {current}/{total} ({current/total*100:.1f}%) - {status}")
# 파일 경로 리스트 (실제 파일 경로로 교체 필요)
file_paths = [
"sample1.pdf",
"sample2.dxf",
"sample3.pdf"
]
results = await processor.process_multiple_files(
file_paths, config, progress_callback
)
summary = processor.get_processing_summary()
print("처리 요약:", summary)
# asyncio.run(main())

View File

@@ -0,0 +1,322 @@
"""
PDF 처리 모듈
PDF 파일을 이미지로 변환하고 base64로 인코딩하는 기능을 제공합니다.
"""
import base64
import io
import fitz # PyMuPDF
from PIL import Image
from typing import List, Optional, Tuple, Dict, Any
import logging
from pathlib import Path
# 로깅 설정
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class PDFProcessor:
"""PDF 파일 처리 클래스"""
def __init__(self):
self.supported_formats = ['pdf']
def validate_pdf_file(self, file_path: str) -> bool:
"""PDF 파일 유효성 검사"""
try:
path = Path(file_path)
# 파일 존재 여부 확인
if not path.exists():
logger.error(f"파일이 존재하지 않습니다: {file_path}")
return False
# 파일 확장자 확인
if path.suffix.lower() != '.pdf':
logger.error(f"지원하지 않는 파일 형식입니다: {path.suffix}")
return False
# PDF 파일 열기 테스트
doc = fitz.open(file_path)
page_count = len(doc)
doc.close()
if page_count == 0:
logger.error("PDF 파일에 페이지가 없습니다.")
return False
logger.info(f"PDF 검증 완료: {page_count}페이지")
return True
except Exception as e:
logger.error(f"PDF 파일 검증 중 오류 발생: {e}")
return False
def get_pdf_info(self, file_path: str) -> Optional[dict]:
"""PDF 파일 정보 조회"""
try:
doc = fitz.open(file_path)
info = {
'page_count': len(doc),
'metadata': doc.metadata,
'file_size': Path(file_path).stat().st_size,
'filename': Path(file_path).name
}
doc.close()
return info
except Exception as e:
logger.error(f"PDF 정보 조회 중 오류 발생: {e}")
return None
def convert_pdf_page_to_image(
self,
file_path: str,
page_number: int = 0,
zoom: float = 2.0,
image_format: str = "PNG"
) -> Optional[Image.Image]:
"""PDF 페이지를 PIL Image로 변환"""
try:
doc = fitz.open(file_path)
if page_number >= len(doc):
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
doc.close()
return None
# 페이지 로드
page = doc.load_page(page_number)
# 이미지 변환을 위한 매트릭스 설정 (확대/축소)
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
# PIL Image로 변환
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
doc.close()
logger.info(f"페이지 {page_number + 1} 이미지 변환 완료: {img.size}")
return img
except Exception as e:
logger.error(f"PDF 페이지 이미지 변환 중 오류 발생: {e}")
return None
def convert_pdf_to_images(
self,
file_path: str,
max_pages: Optional[int] = None,
zoom: float = 2.0
) -> List[Image.Image]:
"""PDF의 모든 페이지를 이미지로 변환"""
images = []
try:
doc = fitz.open(file_path)
total_pages = len(doc)
# 최대 페이지 수 제한
if max_pages:
total_pages = min(total_pages, max_pages)
for page_num in range(total_pages):
img = self.convert_pdf_page_to_image(file_path, page_num, zoom)
if img:
images.append(img)
doc.close()
logger.info(f"{len(images)}개 페이지 이미지 변환 완료")
except Exception as e:
logger.error(f"PDF 전체 페이지 변환 중 오류 발생: {e}")
return images
def image_to_base64(
self,
image: Image.Image,
format: str = "PNG",
quality: int = 95
) -> Optional[str]:
"""PIL Image를 base64 문자열로 변환"""
try:
buffer = io.BytesIO()
# JPEG 형식인 경우 품질 설정
if format.upper() == "JPEG":
image.save(buffer, format=format, quality=quality)
else:
image.save(buffer, format=format)
buffer.seek(0)
base64_string = base64.b64encode(buffer.getvalue()).decode('utf-8')
logger.info(f"이미지를 base64로 변환 완료 (크기: {len(base64_string)} 문자)")
return base64_string
except Exception as e:
logger.error(f"이미지 base64 변환 중 오류 발생: {e}")
return None
def pdf_page_to_base64(
self,
file_path: str,
page_number: int = 0,
zoom: float = 2.0,
format: str = "PNG"
) -> Optional[str]:
"""PDF 페이지를 직접 base64로 변환"""
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
if img:
return self.image_to_base64(img, format)
return None
def pdf_page_to_image_bytes(
self,
file_path: str,
page_number: int = 0,
zoom: float = 2.0,
format: str = "PNG"
) -> Optional[bytes]:
"""PDF 페이지를 이미지 바이트로 변환 (Flet 이미지 표시용)"""
try:
img = self.convert_pdf_page_to_image(file_path, page_number, zoom)
if img:
buffer = io.BytesIO()
img.save(buffer, format=format)
buffer.seek(0)
image_bytes = buffer.getvalue()
logger.info(f"페이지 {page_number + 1} 이미지 바이트 변환 완료 (크기: {len(image_bytes)} 바이트)")
return image_bytes
return None
except Exception as e:
logger.error(f"PDF 페이지 이미지 바이트 변환 중 오류 발생: {e}")
return None
def get_optimal_zoom_for_size(self, target_size: Tuple[int, int]) -> float:
"""목표 크기에 맞는 최적 줌 비율 계산"""
# 기본 PDF 페이지 크기 (A4: 595x842 points)
default_width, default_height = 595, 842
target_width, target_height = target_size
# 비율 계산
width_ratio = target_width / default_width
height_ratio = target_height / default_height
# 작은 비율을 선택하여 전체 페이지가 들어가도록 함
zoom = min(width_ratio, height_ratio)
logger.info(f"최적 줌 비율 계산: {zoom:.2f}")
return zoom
def extract_text_with_coordinates(self, file_path: str, page_number: int = 0) -> List[Dict[str, Any]]:
"""PDF 페이지에서 텍스트와 좌표를 추출합니다."""
text_blocks = []
try:
doc = fitz.open(file_path)
if page_number >= len(doc):
logger.error(f"페이지 번호가 범위를 벗어남: {page_number}")
doc.close()
return []
page = doc.load_page(page_number)
# 'dict' 옵션은 블록, 라인, 스팬에 대한 상세 정보를 제공합니다.
blocks = page.get_text("dict")["blocks"]
for b in blocks: # 블록 반복
if b['type'] == 0: # 텍스트 블록
for l in b["lines"]: # 라인 반복
for s in l["spans"]: # 스팬(텍스트 조각) 반복
text_blocks.append({
"text": s["text"],
"bbox": s["bbox"], # (x0, y0, x1, y1)
"font": s["font"],
"size": s["size"]
})
doc.close()
logger.info(f"페이지 {page_number + 1}에서 {len(text_blocks)}개의 텍스트 블록 추출 완료")
return text_blocks
except Exception as e:
logger.error(f"PDF 텍스트 및 좌표 추출 중 오류 발생: {e}")
return []
def convert_to_images(
self,
file_path: str,
zoom: float = 2.0,
max_pages: int = 10
) -> List[Image.Image]:
"""PDF의 모든 페이지(또는 지정된 수까지)를 PIL Image 리스트로 변환"""
images = []
try:
doc = fitz.open(file_path)
page_count = min(len(doc), max_pages) # 최대 페이지 수 제한
logger.info(f"PDF 변환 시작: {page_count}페이지")
for page_num in range(page_count):
page = doc.load_page(page_num)
# 이미지 변환을 위한 매트릭스 설정
mat = fitz.Matrix(zoom, zoom)
pix = page.get_pixmap(matrix=mat)
# PIL Image로 변환
img = Image.frombytes("RGB", [pix.width, pix.height], pix.samples)
images.append(img)
logger.info(f"페이지 {page_num + 1}/{page_count} 변환 완료: {img.size}")
doc.close()
logger.info(f"PDF 전체 변환 완료: {len(images)}개 이미지")
return images
except Exception as e:
logger.error(f"PDF 다중 페이지 변환 중 오류 발생: {e}")
return []
def image_to_bytes(self, image: Image.Image, format: str = 'PNG') -> bytes:
"""
PIL Image를 바이트 데이터로 변환합니다.
Args:
image: PIL Image 객체
format: 이미지 포맷 ('PNG', 'JPEG' 등)
Returns:
이미지 바이트 데이터
"""
try:
buffer = io.BytesIO()
image.save(buffer, format=format)
image_bytes = buffer.getvalue()
buffer.close()
logger.info(f"이미지를 {format} 바이트로 변환: {len(image_bytes)} bytes")
return image_bytes
except Exception as e:
logger.error(f"이미지 바이트 변환 중 오류 발생: {e}")
return b''
# 사용 예시
if __name__ == "__main__":
processor = PDFProcessor()
# 테스트용 코드 (실제 PDF 파일 경로로 변경 필요)
test_pdf = "test.pdf"
if processor.validate_pdf_file(test_pdf):
info = processor.get_pdf_info(test_pdf)
print(f"PDF 정보: {info}")
# 첫 번째 페이지를 base64로 변환
base64_data = processor.pdf_page_to_base64(test_pdf, 0)
if base64_data:
print(f"Base64 변환 성공: {len(base64_data)} 문자")
else:
print("PDF 파일 검증 실패")

View File

@@ -0,0 +1,9 @@
# Essential packages for CLI batch processing only
PyMuPDF>=1.26.3
google-genai>=1.0.0
Pillow>=10.0.0
ezdxf>=1.4.2
numpy>=1.24.0
python-dotenv>=1.0.0
pandas>=2.0.0
requests>=2.31.0

View File

@@ -0,0 +1,38 @@
# Flet 기반 PDF 이미지 분석기 - 필수 라이브러리
# UI 프레임워크
flet>=0.25.1
# Google Generative AI SDK
google-genai>=1.0.0
# PDF 처리 라이브러리 (둘 중 하나 선택)
PyMuPDF>=1.26.3
pdf2image>=1.17.0
# 이미지 처리
Pillow>=10.0.0
# DXF 파일 처리 (NEW)
ezdxf>=1.4.2
# 수치 계산 (NEW)
numpy>=1.24.0
# 환경 변수 관리
python-dotenv>=1.0.0
# 추가 유틸리티
requests>=2.31.0
# 데이터 처리 (NEW - 다중 파일 CSV 출력용)
pandas>=2.0.0
# Flet Material Design (선택 사항)
flet-material>=0.3.3
# 개발 도구 (선택 사항)
# black>=23.0.0
# flake8>=6.0.0
# pytest>=7.0.0
# mypy>=1.0.0