skin_research / app.py
SonoUno's picture
Update app.py
7fed019 verified
import gradio as gr
from transformers import pipeline
from PIL import Image
import logging
from datetime import datetime
from functools import lru_cache
import hashlib
from typing import Tuple, Optional, Dict, Any
from collections import defaultdict, deque
from threading import Lock
import traceback
# ==================== KONFIGURACJA ====================
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('skin_analysis.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
# Limity bezpieczeństwa
MAX_IMAGE_DIMENSION = 4096
ALLOWED_FORMATS = {'PNG', 'JPEG', 'JPG', 'WEBP'}
RATE_LIMIT_WINDOW = 60 # sekund
MAX_REQUESTS_PER_WINDOW = 10
Image.MAX_IMAGE_PIXELS = 89478485 # ~178 megapikseli
# ==================== RATE LIMITER ====================
class RateLimiter:
"""Thread-safe rate limiter z automatycznym czyszczeniem"""
def __init__(self, max_requests: int = MAX_REQUESTS_PER_WINDOW,
window: int = RATE_LIMIT_WINDOW):
self.max_requests = max_requests
self.window = window
self.requests = defaultdict(deque)
self.lock = Lock()
self.last_cleanup = datetime.now().timestamp()
def _hash_identifier(self, identifier: str) -> str:
"""Hashuje identyfikator dla prywatności"""
return hashlib.sha256(identifier.encode()).hexdigest()[:12]
def _cleanup_old_entries(self, current_time: float):
"""Okresowe czyszczenie starych wpisów (co 5 minut)"""
if current_time - self.last_cleanup > 300: # 5 minut
expired_keys = [
key for key, timestamps in self.requests.items()
if not timestamps or current_time - timestamps[-1] > self.window * 2
]
for key in expired_keys:
del self.requests[key]
self.last_cleanup = current_time
if expired_keys:
logger.info(f"Cleaned up {len(expired_keys)} expired rate limit entries")
def is_allowed(self, identifier: str) -> bool:
"""Sprawdza czy żądanie jest dozwolone"""
with self.lock:
now = datetime.now().timestamp()
hashed_id = self._hash_identifier(identifier)
# Usuń stare żądania dla tego użytkownika
while (self.requests[hashed_id] and
now - self.requests[hashed_id][0] > self.window):
self.requests[hashed_id].popleft()
# Sprawdź limit
if len(self.requests[hashed_id]) >= self.max_requests:
logger.warning(f"Rate limit exceeded for user {hashed_id}")
return False
# Dodaj nowe żądanie
self.requests[hashed_id].append(now)
# Okresowe czyszczenie
self._cleanup_old_entries(now)
return True
# Globalna instancja rate limitera
rate_limiter = RateLimiter()
# ==================== MODEL ====================
@lru_cache(maxsize=1)
def load_model():
"""Załaduj model z cache'owaniem"""
try:
logger.info("Loading skin classification model...")
model = pipeline(
"image-classification",
model="Anwarkh1/Skin_Cancer-Image_Classification"
)
logger.info("Model loaded successfully")
return model
except Exception as e:
logger.error(f"Failed to load model: {str(e)}")
raise
# Inicjalizacja modelu
try:
skin_model = load_model()
except Exception as e:
logger.critical("Application startup failed - model loading error")
skin_model = None
# ==================== DEFINICJE ZMIAN SKÓRNYCH ====================
SKIN_CONDITIONS: Dict[str, Dict[str, str]] = {
"Actinic keratoses": {
"name": "Rogowacenie słoneczne",
"risk": "medium",
"description": "Rogowacenie słoneczne to stan przednowotworowy, zmiana rozwijająca się pod wpływem przewlekłej ekspozycji na promieniowanie ultrafioletowe.",
"recommendation": "Wymaga kontroli dermatologicznej – ryzyko progresji pojedynczego ogniska do raka kolczystokomórkowego (SCC) jest niskie, ale występowanie wielu zmian zwiększa całkowite ryzyko."
},
"Basal cell carcinoma": {
"name": "Rak podstawnokomórkowy",
"risk": "high",
"description": "Najczęstszy nowotwór złośliwy u osób rasy kaukaskiej. Charakteryzuje się naciekaniem i niszczeniem sąsiadujących tkanek, co może prowadzić do poważnych defektów estetycznych.",
"recommendation": "PILNIE skonsultuj się z dermatologiem."
},
"Dermatofibroma": {
"name": "Włókniak twardy",
"risk": "low",
"description": "Łagodna zmiana nowotworowa, zbudowana z tkanki włóknistej.",
"recommendation": "Zwykle nie wymaga leczenia, zalecana konsultacja dermatologiczna w celu potwierdzenia rozpoznania."
},
"Melanoma": {
"name": "Czerniak",
"risk": "high",
"description": "Złośliwy nowotwór skóry wywodzący się z melanocytów. Jeden z najbardziej agresywnych nowotworów skóry, o wysokim potencjale przerzutowym.",
"recommendation": "NATYCHMIAST skonsultuj się z dermatologiem! Obserwuj zmiany według reguły ABCDE."
},
"Nevus": {
"name": "Znamię",
"risk": "low",
"description": "Łagodna zmiana barwnikowa skóry, zbudowana z komórek barwnikowych (melanocytów).",
"recommendation": "Obserwuj zmiany według reguły ABCDE, skonsultuj się z dermatologiem w przypadku jakichkolwiek zmian wyglądu."
},
"Pigmented benign keratosis": {
"name": "Rogowacenie łojotokowe (barwnikowe)",
"risk": "low",
"description": "Zmiana łagodna, niezłośliwa, nie ulega transformacji nowotworowej. Wariant rogowacenia łojotokowego z nasilonym przebarwieniem.",
"recommendation": "Może klinicznie przypominać czerniaka, zaleca się konsultację dermatologiczną."
},
"Seborrheic keratosis": {
"name": "Rogowacenie łojotokowe",
"risk": "low",
"description": "Zmiana łagodna, niezłośliwa, nie ulega transformacji nowotworowej.",
"recommendation": "Może klinicznie przypominać czerniaka, zaleca się konsultację dermatologiczną."
},
"Squamous cell carcinoma": {
"name": "Rak kolczystokomórkowy",
"risk": "high",
"description": "Drugi najczęstszy typ raka skóry, charakteryzuje się powolnym wzrostem i zdolnością do tworzenia przerzutów.",
"recommendation": "PILNIE skonsultuj się z dermatologiem."
}
}
# ==================== WALIDACJA OBRAZÓW ====================
def validate_image(image: Image.Image) -> Tuple[bool, str]:
"""Kompleksowa walidacja obrazu wejściowego"""
try:
# Sprawdź czy obraz jest prawidłowym obiektem PIL
if not isinstance(image, Image.Image):
return False, "❌ Nieprawidłowy format danych obrazu"
# Sprawdź format
if image.format and image.format.upper() not in ALLOWED_FORMATS:
return False, f"❌ Nieobsługiwany format. Dozwolone: {', '.join(ALLOWED_FORMATS)}"
# Sprawdź wymiary
width, height = image.size
if width < 10 or height < 10:
return False, "❌ Obraz zbyt mały (minimum 10x10 pikseli)"
if width > MAX_IMAGE_DIMENSION or height > MAX_IMAGE_DIMENSION:
return False, f"❌ Obraz zbyt duży. Maksymalna rozdzielczość: {MAX_IMAGE_DIMENSION}x{MAX_IMAGE_DIMENSION}px"
# Sprawdź całkowitą liczbę pikseli
if width * height > Image.MAX_IMAGE_PIXELS:
return False, "❌ Obraz przekracza bezpieczny limit pikseli"
# Sprawdź tryb koloru
if image.mode not in ['RGB', 'RGBA', 'L', 'P']:
return False, "❌ Nieprawidłowy tryb koloru obrazu"
# Weryfikuj integralność obrazu (używamy kopii)
try:
img_copy = image.copy()
img_copy.verify()
except Exception as e:
return False, f"❌ Uszkodzony obraz: {str(e)}"
return True, "OK"
except Exception as e:
logger.error(f"Image validation error: {str(e)}")
return False, f"❌ Błąd podczas walidacji: {str(e)}"
def sanitize_image(image: Image.Image) -> Image.Image:
"""Sanityzacja i optymalizacja obrazu"""
try:
# Konwertuj do RGB
if image.mode != 'RGB':
logger.info(f"Converting image from {image.mode} to RGB")
image = image.convert('RGB')
# Zmniejsz jeśli zbyt duży
max_size = (MAX_IMAGE_DIMENSION, MAX_IMAGE_DIMENSION)
if image.size[0] > max_size[0] or image.size[1] > max_size[1]:
original_size = image.size
image.thumbnail(max_size, Image.Resampling.LANCZOS)
logger.info(f"Image resized from {original_size} to {image.size}")
return image
except Exception as e:
logger.error(f"Image sanitization error: {str(e)}")
raise
# ==================== FORMATOWANIE WYNIKÓW ====================
def get_risk_indicator(risk_level: str) -> str:
"""Zwraca kolorowy wskaźnik ryzyka"""
risk_map = {
"high": "🔴 WYSOKIE RYZYKO",
"medium": "🟡 ŚREDNIE RYZYKO",
"low": "🟢 NISKIE RYZYKO"
}
return risk_map.get(risk_level, "⚪ NIEZNANE RYZYKO")
def format_analysis_result(result: list) -> Tuple[str, str]:
"""Formatuje wynik analizy do wyświetlenia"""
if not result or len(result) == 0:
return "❌ Brak wyników analizy", ""
output = "# 🔬 WYNIK ANALIZY ZMIANY SKÓRNEJ\n\n"
# Główny wynik
top_result = result[0]
top_condition = SKIN_CONDITIONS.get(top_result['label'], {
'name': top_result['label'],
'risk': 'medium',
'description': 'Nieznana zmiana skórna',
'recommendation': 'Skonsultuj się z dermatologiem'
})
risk_indicator = get_risk_indicator(top_condition['risk'])
output += f"## 🎯 NAJBARDZIEJ PRAWDOPODOBNE:\n"
output += f"**{top_condition['name']}** ({top_result['label']})\n\n"
output += f"**Prawdopodobieństwo:** {top_result['score']:.1%}\n\n"
output += f"**Poziom ryzyka:** {risk_indicator}\n\n"
output += f"### 📋 Opis:\n"
output += f"{top_condition['description']}\n\n"
output += f"### 💡 Zalecenie:\n"
output += f"{top_condition['recommendation']}\n\n"
# Wszystkie wyniki
output += "## 📊 SZCZEGÓŁOWE PRAWDOPODOBIEŃSTWA:\n\n"
for i, r in enumerate(result, 1):
condition = SKIN_CONDITIONS.get(r['label'], {'name': r['label'], 'risk': 'medium'})
risk_emoji = "🔴" if condition['risk'] == 'high' else "🟡" if condition['risk'] == 'medium' else "🟢"
output += f"{i}. {risk_emoji} **{condition['name']}** – {r['score']:.2%}\n"
# Ostrzeżenia bezpieczeństwa
safety_warning = """
---
## ⚠️ WAŻNE OSTRZEŻENIA BEZPIECZEŃSTWA
### 🩺 Ta aplikacja NIE ZASTĘPUJE konsultacji lekarskiej!
- Wyniki mają charakter orientacyjny i mogą zawierać błędy
- Dokładność modelu nie została klinicznie zwalidowana
- Ostateczną diagnozę może postawić tylko lekarz dermatolog
- W przypadku jakichkolwiek wątpliwości - skonsultuj się z dermatologiem
### 🔍 Zasada ABCDE dla znamion
- **Asymetry** Asymetria - połówki znamienia różnią się
- **Borders** Brzegi nieregularne - postrzępione, rozmazane
- **Color** Kolor niejednolity - różne odcienie w jednym znamieniu
- **Diamater** Średnica > 6mm - większa niż gumka na ołówku
- **Evolution** Ewolucja - zmiany wielkości, kształtu lub koloru
### 📞 Kiedy natychmiast do lekarza
- Zmiany rosnące, krwawiące, swędzące lub bolesne
- Nowe znamiona po 30. roku życia
- Zmiany wyglądu istniejących znamion
- Owrzodzenia, które nie goją się przez 4 tygodnie
"""
return output, safety_warning
# ==================== GŁÓWNA FUNKCJA ANALIZY ====================
def analyze_skin(image: Optional[Image.Image], request: gr.Request) -> Tuple[str, Optional[Image.Image], str]:
"""Główna funkcja analizy zmiany skórnej"""
# Sprawdź czy model jest załadowany
if skin_model is None:
logger.error("Model not available")
return "❌ Model niedostępny. Skontaktuj się z administratorem.", None, ""
# Sprawdź czy obraz został wgrany
if image is None:
return "❌ Proszę wgrać zdjęcie", None, ""
# Rate limiting
client_id = request.client.host if request and hasattr(request, 'client') else "unknown"
if not rate_limiter.is_allowed(client_id):
return "⏳ Zbyt wiele żądań. Proszę poczekać chwilę (max 10 analiz na minutę).", None, ""
try:
# Walidacja obrazu
is_valid, validation_msg = validate_image(image)
if not is_valid:
logger.warning(f"Invalid image from user: {validation_msg}")
return validation_msg, None, ""
# Sanityzacja obrazu
image = sanitize_image(image)
# Logowanie analizy
image_hash = hashlib.sha256(image.tobytes()).hexdigest()[:12]
logger.info(f"Analyzing image {image_hash} from user {rate_limiter._hash_identifier(client_id)}")
# Analiza zmiany skórnej
try:
result = skin_model(image)
except Exception as model_error:
logger.error(f"Model error for image {image_hash}: {str(model_error)}")
return f"❌ Błąd modelu: {str(model_error)}", None, ""
# Walidacja wyniku
if not isinstance(result, list) or len(result) == 0:
logger.error(f"Invalid model output for image {image_hash}: {type(result)}")
return "❌ Model zwrócił nieprawidłowe dane. Spróbuj ponownie.", None, ""
# Formatuj wynik
output, safety_warning = format_analysis_result(result)
logger.info(f"Analysis completed for image {image_hash}: {result[0]['label']} ({result[0]['score']:.2%})")
return output, image, safety_warning
except Exception as e:
logger.error(f"Analysis error: {str(e)}", exc_info=True)
error_trace = traceback.format_exc()
return f"❌ Wystąpił błąd podczas analizy:\n\n```\n{error_trace}\n```", None, ""
# ==================== FUNKCJE UI ====================
def clear_all():
"""Funkcja czyszcząca wszystkie elementy interfejsu"""
return (
None, # image_input
"Wgraj zdjęcie i kliknij 'Analizuj' aby rozpocząć", # result_output
None, # uploaded_image
"", # safety_output
gr.update(visible=True), # instructions
gr.update(visible=False), # clear_btn
gr.update(visible=True) # analyze_btn
)
def show_uploaded_image(img):
"""Pokazuje przesłany obraz po analizie"""
if img is not None:
return gr.update(visible=True, value=img)
return gr.update(visible=False)
# ==================== INTERFEJS GRADIO ====================
with gr.Blocks(
theme=gr.themes.Soft(),
title="🔬 Klasyfikator Zmian Skórnych",
css="""
.gradio-container {max-width: 1200px; margin: auto;}
#clear_btn {
background: linear-gradient(to bottom right, #dc2626, #b91c1c) !important;
border: none !important;
color: white !important;
}
#clear_btn:hover {
background: linear-gradient(to bottom right, #b91c1c, #991b1b) !important;
transform: scale(1.02);
box-shadow: 0 4px 12px rgba(220, 38, 38, 0.4) !important;
}
/* === Sekcja Informacyjna (Niebieska) === */
.info-section {
background: #f0f9ff !important;
border-radius: 12px !important;
padding: 2rem !important;
border-left: 4px solid #3b82f6 !important;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1) !important;
margin: 1rem 0 !important;
}
/* Uniwersalne nadpisanie dla Safari i innych przeglądarek */
.info-section *,
.info-section > div,
.info-section .markdown,
.info-section div[data-testid="markdown-body"] {
background-color: transparent !important;
}
.info-section p,
.info-section h2,
.info-section h3,
.info-section ul,
.info-section li,
.info-section strong {
color: #1e40af !important;
}
/* === Sekcja Prywatności (Zielona) === */
.privacy-section {
background: #f0fdf4 !important;
border-radius: 12px !important;
padding: 2rem !important;
border-left: 4px solid #10b981 !important;
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.1) !important;
margin: 1rem 0 !important;
}
/* Uniwersalne nadpisanie dla Safari i innych przeglądarek */
.privacy-section *,
.privacy-section > div,
.privacy-section .markdown,
.privacy-section div[data-testid="markdown-body"] {
background-color: transparent !important;
}
.privacy-section p,
.privacy-section h2,
.privacy-section h3,
.privacy-section ul,
.privacy-section li,
.privacy-section strong {
color: #065f46 !important;
}
/* === Sekcja Prawna (Czerwona) === */
.legal-section {
background: #fef2f2 !important;
border-radius: 12px !important;
padding: 2rem !important;
border-left: 4px solid #dc2626 !important;
box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2) !important;
margin: 1rem 0 !important;
}
/* Uniwersalne nadpisanie dla Safari i innych przeglądarek */
.legal-section *,
.legal-section > div,
.legal-section .markdown,
.legal-section div[data-testid="markdown-body"] {
background-color: transparent !important;
}
.legal-section p,
.legal-section h2,
.legal-section h3,
.legal-section ul,
.legal-section li,
.legal-section strong {
color: #991b1b !important;
}
"""
) as demo:
gr.Markdown("""
# 🔬 Klasyfikator Zmian Skórnych - Detekcja Raka Skóry
**⚠️ WYŁĄCZNIE DO CELÓW EDUKACYJNYCH - NIE STANOWI PORADY MEDYCZNEJ**
Zaawansowana analiza AI wykrywająca 8 typów zmian skórnych:
- Czerniak (Melanoma) 🔴
- Rak podstawnokomórkowy 🔴
- Rak kolczystokomórkowy 🔴
- Rogowacenie słoneczne 🟡
- Znamiona i inne łagodne zmiany 🟢
**Formaty:** JPG, PNG, WEBP | **Rate limit:** 10 analiz/minutę
""")
with gr.Row():
with gr.Column():
image_input = gr.Image(
type="pil",
label="📸 Wgraj zdjęcie zmiany skórnej",
height=400,
sources=["upload"],
show_download_button=False,
show_share_button=False
)
with gr.Row():
analyze_btn = gr.Button(
"🔍 ANALIZUJ ZMIANĘ SKÓRNĄ",
variant="primary",
size="lg",
scale=2
)
clear_btn = gr.Button(
"🗑️ WYCZYŚĆ",
size="lg",
scale=1,
visible=False,
elem_id="clear_btn"
)
with gr.Column():
result_output = gr.Markdown(
label="🔎 Wynik analizy",
value="Wgraj zdjęcie i kliknij 'Analizuj' aby rozpocząć"
)
with gr.Row():
uploaded_image = gr.Image(
label="📷 Przesłane zdjęcie",
height=300,
interactive=False,
visible=False,
show_download_button=False,
show_share_button=False
)
safety_output = gr.Markdown(label="⚠️ Ostrzeżenia bezpieczeństwa")
instructions = gr.Markdown("""
## 📋 Instrukcje użycia:
1. **Wgraj zdjęcie** - najlepiej wysokiej jakości, dobrze oświetlone
2. **Kliknij 'Analizuj'** - poczekaj na wynik AI (może zająć kilka sekund)
3. **Sprawdź wynik** - poziom ryzyka i prawdopodobieństwa
4. **Przeczytaj zalecenia** - czy potrzebna konsultacja lekarska
""", visible=True)
# Event handlers
analyze_output = analyze_btn.click(
fn=analyze_skin,
inputs=[image_input],
outputs=[result_output, uploaded_image, safety_output]
)
analyze_output.then(
fn=lambda: (
gr.update(visible=False),
gr.update(visible=True),
gr.update(visible=False)
),
outputs=[instructions, clear_btn, analyze_btn]
)
clear_btn.click(
fn=clear_all,
outputs=[image_input, result_output, uploaded_image, safety_output,
instructions, clear_btn, analyze_btn]
)
with gr.Group(elem_classes="info-section"):
gr.Markdown("""
## 🎯 Aplikacja analizuje 8 typów zmian:
🔴 **Wysokie ryzyko:** Czerniak, Rak podstawnokomórkowy, Rak kolczystokomórkowy
🟡 **Średnie ryzyko:** Rogowacenie słoneczne
🟢 **Niskie ryzyko:** Znamiona, Włókniak twardy, Rogowacenie łojotokowe
""")
with gr.Group(elem_classes="privacy-section"):
gr.Markdown("""
## 🔒 Prywatność i bezpieczeństwo:
✅ Zdjęcia przetwarzane lokalnie (nie są przechowywane)
✅ Brak zbierania danych osobowych
✅ Anonimowe analizy z hashowanymi identyfikatorami (RODO)
✅ Rate limiting: 10 analiz/minutę
✅ Automatyczne czyszczenie pamięci cache
""")
with gr.Group(elem_classes="legal-section"):
gr.Markdown("""
## ⚖️ Zastrzeżenia prawne:
Ta aplikacja jest narzędziem edukacyjnym i nie powinna być używana do samodzielnej diagnozy.
Model AI może popełniać błędy. W przypadku jakichkolwiek obaw dotyczących zdrowia,
skonsultuj się z wykwalifikowanym lekarzem dermatologiem.
**Wersja:** 3.1
**Ostatnia aktualizacja:** Październik 2025
""")
if __name__ == "__main__":
demo.launch(
show_error=True,
quiet=False
)