Download app.py from KatenKyokotsu/CCTAwModifiers: direct link, hf CLI and curl.
- Browser
- Download file 21.7 kB
-
https://huggingface.co/spaces/KatenKyokotsu/CCTAwModifiers/resolve/main/app.py
- Command line
-
hf download hf://spaces/KatenKyokotsu/CCTAwModifiers/app.py
-
curl -L -o app.py https://huggingface.co/spaces/KatenKyokotsu/CCTAwModifiers/resolve/main/app.py
21.7 kB
| import os | |
| from dotenv import load_dotenv | |
| # Load environment variables from .env file | |
| load_dotenv() | |
| import json | |
| import torch | |
| import re | |
| import numpy as np | |
| import pandas as pd | |
| from flask import Flask, request, jsonify, render_template, redirect, url_for, flash, session | |
| from transformers import AutoTokenizer, AutoModelForSequenceClassification | |
| from docx import Document | |
| from sklearn.model_selection import train_test_split | |
| from torch.utils.data import Dataset, DataLoader | |
| import torch.optim as optim | |
| import torch.nn as nn | |
| from tqdm import tqdm | |
| import openai | |
| from openai import OpenAI | |
| import time | |
| from huggingface_hub import login | |
| import logging | |
| from functools import wraps | |
| import requests | |
| from safetensors.torch import save_file | |
| # Configure logging | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', | |
| handlers=[ | |
| logging.FileHandler("app.log"), | |
| logging.StreamHandler() | |
| ] | |
| ) | |
| logger = logging.getLogger(__name__) | |
| # Modify environment variable handling | |
| OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") | |
| if not OPENAI_API_KEY: | |
| raise ValueError("OPENAI_API_KEY environment variable is not set. Please set it in your .env file.") | |
| os.environ["OPENAI_API_KEY"] = OPENAI_API_KEY | |
| client = OpenAI( | |
| api_key=OPENAI_API_KEY, | |
| ) | |
| # Model configuration | |
| MODEL_NAME = 'dmis-lab_biobert-v1.1_20250316_195322' | |
| MODIFIER_MODEL_NAME = 'dmis-lab_biobert-v1.1_modifier_20250406_160702' | |
| OUTPUT_DIR = os.environ.get('CCTA_OUTPUT_DIR', 'Saved Models') | |
| # Set device | |
| device = torch.device('cuda' if torch.cuda.is_available() else | |
| 'mps' if torch.backends.mps.is_available() else | |
| 'cpu') | |
| logger.info(f"Using device: {device}") | |
| # Label mapping | |
| label_mapping = { | |
| 0: "0", | |
| 1: "1", | |
| 2: "2", | |
| 3: "3", | |
| 4: "4", | |
| 5: "5", | |
| 6: "N" | |
| } | |
| reverse_label_mapping = {v: k for k, v in label_mapping.items()} | |
| # Modifier mapping | |
| modifier_mapping = { | |
| 0: "P1", # Mild plaque burden (CAC 0-99) | |
| 1: "P2", # Moderate plaque burden (CAC 100-399) | |
| 2: "P3", # Severe plaque burden (CAC 400-999) | |
| 3: "P4" # Extensive plaque burden (CAC ≥1000) | |
| } | |
| # Load model | |
| def load_model(model_name, num_labels): | |
| try: | |
| # Direct path to model | |
| model_path = os.path.join(OUTPUT_DIR, model_name.replace("/", "_")) | |
| if not os.path.exists(model_path): | |
| raise FileNotFoundError(f"No model found at {model_path}") | |
| logger.info(f"Loading model from {model_path}") | |
| tokenizer = AutoTokenizer.from_pretrained(model_path) | |
| # Check if safetensors version exists | |
| safetensors_path = os.path.join(model_path, "model.safetensors") | |
| pytorch_path = os.path.join(model_path, "pytorch_model.bin") | |
| # If PyTorch model exists but safetensors doesn't, convert it | |
| if os.path.exists(pytorch_path) and not os.path.exists(safetensors_path): | |
| logger.info(f"Converting PyTorch model to safetensors format") | |
| # Load the PyTorch model | |
| temp_model = AutoModelForSequenceClassification.from_pretrained( | |
| model_path, | |
| num_labels=num_labels | |
| ) | |
| # Extract state dict | |
| state_dict = temp_model.state_dict() | |
| # Save as safetensors | |
| save_file(state_dict, safetensors_path) | |
| logger.info(f"Model converted and saved to {safetensors_path}") | |
| # Load the model (will use safetensors if available) | |
| model = AutoModelForSequenceClassification.from_pretrained( | |
| model_path, | |
| num_labels=num_labels | |
| ) | |
| model.to(device) | |
| model.eval() | |
| return model, tokenizer | |
| except Exception as e: | |
| logger.error(f"Error loading model: {str(e)}") | |
| raise | |
| # Load models and tokenizers | |
| try: | |
| # Load classifier model for CADRADS score | |
| classifier_model, classifier_tokenizer = load_model(MODEL_NAME, len(label_mapping)) | |
| logger.info("Classifier model and tokenizer loaded successfully") | |
| # Load modifier model | |
| modifier_model, modifier_tokenizer = load_model(MODIFIER_MODEL_NAME, len(modifier_mapping)) | |
| logger.info("Modifier model and tokenizer loaded successfully") | |
| except Exception as e: | |
| logger.error(f"Failed to load models: {str(e)}") | |
| raise | |
| # Create uploads directory if it doesn't exist | |
| UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads') | |
| os.makedirs(UPLOAD_FOLDER, exist_ok=True) | |
| app = Flask(__name__) | |
| # Get authentication credentials from environment variables | |
| username = os.getenv('BASIC_AUTH_USERNAME') | |
| password = os.getenv('BASIC_AUTH_PASSWORD') | |
| # Check if credentials are set | |
| if not username or not password: | |
| logger.error("BASIC_AUTH_USERNAME or BASIC_AUTH_PASSWORD environment variables are not set") | |
| raise ValueError("Authentication credentials are required. Set BASIC_AUTH_USERNAME and BASIC_AUTH_PASSWORD in environment variables") | |
| # Use environment variable for secret key if available, otherwise generate a random one | |
| app.config['SECRET_KEY'] = os.getenv('FLASK_SECRET_KEY', os.urandom(24)) | |
| app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER | |
| app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 | |
| # Custom login required decorator | |
| def login_required(f): | |
| def decorated_function(*args, **kwargs): | |
| if 'user_id' not in session: | |
| return redirect(url_for('login', next=request.url)) | |
| return f(*args, **kwargs) | |
| return decorated_function | |
| history = [] | |
| def extract_text_from_docx(docx_path): | |
| try: | |
| doc = Document(docx_path) | |
| full_text = [] | |
| # Extract text from paragraphs | |
| for para in doc.paragraphs: | |
| if para.text.strip(): # Only add non-empty paragraphs | |
| full_text.append(para.text) | |
| # Extract text from tables | |
| for table in doc.tables: | |
| for row in table.rows: | |
| row_text = [] | |
| for cell in row.cells: | |
| # Extract text from paragraphs inside cells | |
| cell_text = ' '.join(p.text.strip() for p in cell.paragraphs if p.text.strip()) | |
| if cell_text: | |
| row_text.append(cell_text) | |
| if row_text: | |
| full_text.append(' | '.join(row_text)) # Use pipe to separate cell content | |
| extracted_text = '\n'.join(full_text) | |
| # Add a check to warn about potentially empty documents | |
| if len(extracted_text.strip()) < 10: # Less than 10 characters is suspiciously small | |
| logger.warning(f"Very little text extracted ({len(extracted_text)} chars). Document may be corrupt or empty.") | |
| return extracted_text | |
| except Exception as e: | |
| logger.error(f"Error extracting text from document: {str(e)}") | |
| raise | |
| def generate_json_output(report_text, prompt_template): | |
| prompt = prompt_template.replace("{report_text}", report_text) | |
| try: | |
| response = client.chat.completions.create( | |
| model="gpt-4o-mini", | |
| messages=[ | |
| {"role": "system", "content": "You are a helpful assistant."}, | |
| {"role": "user", "content": prompt} | |
| ], | |
| temperature=0.3, | |
| max_tokens=6500, | |
| top_p=0.9, | |
| frequency_penalty=0.2, | |
| presence_penalty=0, | |
| ) | |
| response_text = response.choices[0].message.content | |
| cleaned_response_text = response_text.strip() | |
| if cleaned_response_text.startswith("```") and cleaned_response_text.endswith("```"): | |
| cleaned_response_text = cleaned_response_text[3:-3].strip() | |
| json_match = re.search(r'{.*}', cleaned_response_text, re.DOTALL) | |
| if json_match: | |
| cleaned_response_text = json_match.group(0) | |
| else: | |
| raise json.JSONDecodeError("No valid JSON object found", cleaned_response_text, 0) | |
| report_json = json.loads(cleaned_response_text) | |
| return report_json | |
| except json.JSONDecodeError as e: | |
| logger.error(f"JSON parsing error: {str(e)}") | |
| return {"error": "Unable to parse response"} | |
| except Exception as e: | |
| logger.error(f"Error generating JSON output: {str(e)}") | |
| return {"error": f"Error generating output: {str(e)}"} | |
| def create_text_representation(report_json): | |
| """Create a consistent text representation for prediction, using same format as in training""" | |
| try: | |
| combined_text = ( | |
| f"Technical Quality: {report_json['technical_quality']} | " | |
| f"Left Main: {report_json['left_main']} | " | |
| f"LAD: {report_json['lad']} | " | |
| f"LCX: {report_json['lcx']} | " | |
| f"RCA: {report_json['rca']} | " | |
| f"Stenosis Summary: {report_json['stenosis_summary']} | " | |
| f"Calcium Score: {report_json['calcium_score']} | " | |
| f"Cardiac Valves: {report_json['cardiac_valves']} | " | |
| f"Pericardium: {report_json['pericardium']} | " | |
| f"Dominance: {report_json['dominance']} | " | |
| f"Impression: {report_json['impression']}" | |
| ) | |
| return combined_text | |
| except KeyError as e: | |
| logger.error(f"Missing field in report JSON: {str(e)}") | |
| raise | |
| def predict_cadrads_score(report_json): | |
| try: | |
| # Create text representation consistent with training | |
| combined_text = create_text_representation(report_json) | |
| with torch.no_grad(): | |
| # Predict CADRADS score with classifier model | |
| classifier_encoding = classifier_tokenizer(combined_text, truncation=True, padding='max_length', max_length=512, return_tensors='pt') | |
| classifier_input_ids = classifier_encoding['input_ids'].to(device) | |
| classifier_attention_mask = classifier_encoding['attention_mask'].to(device) | |
| classifier_outputs = classifier_model(classifier_input_ids, attention_mask=classifier_attention_mask) | |
| # Get CADRADS score (highest probability class) | |
| classifier_probabilities = torch.nn.functional.softmax(classifier_outputs.logits, dim=1) | |
| _, predicted_cadrads = torch.max(classifier_probabilities, dim=1) | |
| cadrads_class = predicted_cadrads.item() | |
| # Convert to label | |
| if cadrads_class in label_mapping: | |
| cadrads_score = label_mapping[cadrads_class] | |
| else: | |
| logger.warning(f"Predicted CADRADS class {cadrads_class} not in label mapping") | |
| cadrads_score = "Unknown" | |
| # If CADRADS score is 0 or N, don't use modifier | |
| if cadrads_score == "0" or cadrads_score == "N": | |
| logger.info(f"CADRADS score is {cadrads_score}, not displaying modifier") | |
| return cadrads_score, "None" | |
| # Only predict modifier if CADRADS score is not 0 or N | |
| modifier_encoding = modifier_tokenizer(combined_text, truncation=True, padding='max_length', max_length=512, return_tensors='pt') | |
| modifier_input_ids = modifier_encoding['input_ids'].to(device) | |
| modifier_attention_mask = modifier_encoding['attention_mask'].to(device) | |
| modifier_outputs = modifier_model(modifier_input_ids, attention_mask=modifier_attention_mask) | |
| # Get modifier (highest probability class) | |
| modifier_probabilities = torch.nn.functional.softmax(modifier_outputs.logits, dim=1) | |
| _, predicted_modifier = torch.max(modifier_probabilities, dim=1) | |
| modifier_class = predicted_modifier.item() | |
| # Convert to label | |
| if modifier_class in modifier_mapping: | |
| modifier = modifier_mapping[modifier_class] | |
| else: | |
| logger.warning(f"Predicted modifier class {modifier_class} not in modifier mapping") | |
| modifier = "None" | |
| return cadrads_score, modifier | |
| except Exception as e: | |
| logger.error(f"Error in prediction: {str(e)}") | |
| return "Error", "None" | |
| # Load prompt template | |
| try: | |
| prompt_template_path = "prompt_template_modifiers_new.md" | |
| with open(prompt_template_path, "r") as file: | |
| prompt_template = file.read() | |
| logger.info("Prompt template loaded successfully") | |
| except Exception as e: | |
| logger.error(f"Error loading prompt template: {str(e)}") | |
| prompt_template = None | |
| def login(): | |
| if request.method == 'POST': | |
| input_username = request.form['username'] | |
| input_password = request.form['password'] | |
| if input_username == username and input_password == password: | |
| session['user_id'] = input_username | |
| next_page = request.args.get('next') | |
| if next_page and next_page.startswith('/'): | |
| return redirect(next_page) | |
| return redirect(url_for('home')) | |
| else: | |
| flash('Invalid username or password', 'danger') | |
| return render_template('login.html') | |
| def logout(): | |
| session.pop('user_id', None) | |
| flash('You have been logged out', 'info') | |
| return redirect(url_for('login')) | |
| def home(): | |
| return render_template('index.html', history=history) | |
| def predict(): | |
| if not prompt_template: | |
| flash('Prompt template not loaded', 'error') | |
| return redirect(url_for('home')) | |
| if 'file' not in request.files: | |
| flash('No file selected', 'error') | |
| return redirect(url_for('home')) | |
| file = request.files['file'] | |
| if file.filename == '': | |
| flash('No file selected', 'error') | |
| return redirect(url_for('home')) | |
| if not file.filename.endswith('.docx'): | |
| flash('Invalid file format. Please upload a .docx file.', 'error') | |
| return redirect(url_for('home')) | |
| try: | |
| session['processing'] = True | |
| filename = os.path.join( | |
| app.config['UPLOAD_FOLDER'], | |
| file.filename | |
| ) | |
| os.makedirs(os.path.dirname(filename), exist_ok=True) | |
| file.save(filename) | |
| logger.info(f"File saved to {filename}") | |
| try: | |
| # Extract text from document | |
| report_text = extract_text_from_docx(filename) | |
| logger.info(f"Text extracted from {file.filename}") | |
| os.remove(filename) | |
| # Generate structured JSON from report text | |
| report_json = generate_json_output(report_text, prompt_template) | |
| if "error" in report_json: | |
| logger.error(f"Error in JSON generation: {report_json['error']}") | |
| flash('Error processing report', 'error') | |
| return redirect(url_for('home')) | |
| # Predict CADRADS score | |
| cadrads_score, modifier = predict_cadrads_score(report_json) | |
| # Log prediction | |
| logger.info(f"Prediction for {file.filename}: CADRADS {cadrads_score}, Modifier {modifier}") | |
| # Add to history | |
| report_id = len(history) + 1 | |
| history.append({ | |
| 'report_id': report_id, | |
| 'prediction': cadrads_score, | |
| 'modifier': modifier, | |
| 'text': report_text, | |
| 'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'), | |
| 'filename': file.filename, | |
| 'json_output': report_json # Store the JSON output in the report history | |
| }) | |
| flash('Report processed successfully!', 'success') | |
| return redirect(url_for('home')) | |
| except Exception as e: | |
| if os.path.exists(filename): | |
| os.remove(filename) | |
| logger.error(f"Error processing file content: {str(e)}") | |
| raise e | |
| except Exception as e: | |
| logger.error(f"Error in prediction process: {str(e)}") | |
| flash(f'Error processing file: {str(e)}', 'error') | |
| return redirect(url_for('home')) | |
| finally: | |
| session.pop('processing', None) | |
| def view_report(report_id): | |
| report = next((item for item in history if item["report_id"] == report_id), None) | |
| if report: | |
| if 'mesa_results' not in report: | |
| report['mesa_results'] = None | |
| # Make the JSON data available to the template for extracting calcium score | |
| if 'json_output' in report: | |
| report['json_data'] = report['json_output'] | |
| return render_template('view_report.html', report=report) | |
| def calculate_mesa_score(): | |
| try: | |
| # Get form data from request | |
| data = request.json | |
| if not data: | |
| return jsonify({"error": "No data provided"}), 400 | |
| # Validate required fields | |
| required_fields = ['gender', 'age', 'ethnicity', 'diabetes', 'smoke', | |
| 'fhhx', 'chol', 'hdl', 'sbp', 'lipid', 'htnmed'] | |
| for field in required_fields: | |
| if field not in data: | |
| return jsonify({"error": f"Missing required field: {field}"}), 400 | |
| # Prepare API request | |
| api_endpoint = "https://www.mesa-nhlbi.org/MESACHDRiskCoronaryAge/api/score" | |
| # Add CAC score if provided | |
| cac_param = "" | |
| if 'cac' in data and data['cac']: | |
| cac_param = f"/{data['cac']}" | |
| else: | |
| cac_param = "/-9" # Default value for CAC if not provided | |
| # Construct full URL with parameters | |
| url = f"{api_endpoint}/{data['gender']}/{data['age']}/{data['ethnicity']}/{data['diabetes']}/{data['smoke']}/{data['fhhx']}/{data['chol']}/{data['hdl']}/{data['sbp']}/{data['lipid']}/{data['htnmed']}{cac_param}" | |
| # Call MESA API | |
| logger.info(f"Calling MESA API: {url}") | |
| response = requests.get(url) | |
| if response.status_code != 200: | |
| logger.error(f"MESA API error: {response.status_code} - {response.text}") | |
| return jsonify({"error": f"MESA API returned error: {response.status_code}"}), 500 | |
| # Process and return the results | |
| result = response.json() | |
| logger.info(f"MESA API result: {result}") | |
| # Extract and format relevant data | |
| formatted_result = { | |
| 'cac_riskscore': result.get('cac_riskscore', '-'), | |
| 'cac_coronary_age': result.get('cac_coronary_age', '-'), | |
| 'cac_age_diff': result.get('cac_age_diff', '-'), | |
| 'nocac_riskscore': result.get('nocac_riskscore', '-'), | |
| 'nocac_coronary_age': result.get('nocac_coronary_age', '-'), | |
| 'nocac_age_diff': result.get('nocac_age_diff', '-') | |
| } | |
| # Store the form values along with the MESA results in the report data | |
| report_id = request.json.get('report_id') | |
| if report_id: | |
| for item in history: | |
| if item['report_id'] == int(report_id): | |
| # Save both the results and the form input values | |
| item['mesa_results'] = formatted_result | |
| # Create a copy of the form data without the report_id | |
| form_data = {k: v for k, v in data.items() if k != 'report_id'} | |
| item['mesa_form_data'] = form_data | |
| logger.info(f"MESA results and form data stored for report {report_id}") | |
| break | |
| return jsonify(formatted_result) | |
| except Exception as e: | |
| logger.error(f"Error calculating MESA score: {str(e)}") | |
| return jsonify({"error": str(e)}), 500 | |
| def too_large(e): | |
| flash('File is too large. Maximum size is 16MB.', 'error') | |
| return redirect(url_for('home')) | |
| def not_found(e): | |
| return render_template('404.html'), 404 | |
| if __name__ == "__main__": | |
| # Get default port from environment variable or use 8080 | |
| default_port = int(os.environ.get('PORT', 8080)) | |
| port = default_port | |
| max_port_attempts = 10 | |
| # Try to find an available port | |
| for attempt in range(max_port_attempts): | |
| try: | |
| logger.info(f"Attempting to start Flask server on port {port}") | |
| app.run(host='0.0.0.0', port=port) | |
| break # If we get here, the app started successfully | |
| except OSError as e: | |
| if "Address already in use" in str(e) and attempt < max_port_attempts - 1: | |
| port = default_port + attempt + 1 | |
| logger.warning(f"Port {port-1} is already in use. Trying port {port}...") | |
| else: | |
| logger.error(f"Failed to start server after {attempt+1} attempts: {str(e)}") | |
| raise | |