mirror of
https://github.com/tomasriveral/Hairloss-report.git
synced 2026-08-12 02:48:38 +02:00
upload the project
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
from tinydb import Query, TinyDB
|
||||
from logging import getLogger
|
||||
from base64 import b64encode
|
||||
from requests import post, get, RequestException
|
||||
import subprocess
|
||||
import time
|
||||
from os import listdir, path
|
||||
from re import search
|
||||
|
||||
|
||||
logger = getLogger("hairloss")
|
||||
|
||||
OLLAMA_URL = "http://localhost:11434"
|
||||
|
||||
PROMPT = """
|
||||
Think
|
||||
You are a visual estimator of scalp hair density.
|
||||
|
||||
You will be given:
|
||||
|
||||
an image
|
||||
an angle label: "top", "lateral", or "face"
|
||||
|
||||
Angle meaning:
|
||||
|
||||
top: evaluate crown/vertex only
|
||||
lateral: evaluate temple recession and side density
|
||||
face: evaluate frontal hairline and symmetry
|
||||
|
||||
Task:
|
||||
Estimate hair loss severity as a continuous value between 0.0 and 1.0.
|
||||
|
||||
Scoring meaning:
|
||||
|
||||
0.0 → full dense hair
|
||||
0.5 → moderate thinning / visible scalp
|
||||
1.0 → severe hair loss
|
||||
|
||||
Guidelines:
|
||||
|
||||
Use only visible evidence in the image.
|
||||
Be robust to lighting and hairstyle, but consider scalp visibility.
|
||||
Only evaluate regions that are visible from the given angle.
|
||||
If a region is not visible, do not infer it.
|
||||
|
||||
If image quality is unclear:
|
||||
|
||||
use a neutral estimate based on visible areas (do not guess extremes)
|
||||
|
||||
Output:
|
||||
Return ONLY a single float between 0.0 and 1.0.
|
||||
No text, no explanation, no punctuation.
|
||||
"""
|
||||
|
||||
def prepareModel(timeout: int = 15):
|
||||
try:
|
||||
get(f"{OLLAMA_URL}/api/tags", timeout=2)
|
||||
logger.info("Ollama already running")
|
||||
return
|
||||
except RequestException:
|
||||
logger.info("Ollama not running, starting server...")
|
||||
|
||||
subprocess.Popen(
|
||||
["ollama", "serve"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
start = time.time()
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
get(f"{OLLAMA_URL}/api/tags", timeout=2)
|
||||
logger.info("Ollama server started")
|
||||
return
|
||||
except RequestException:
|
||||
time.sleep(1)
|
||||
|
||||
raise RuntimeError("Failed to start Ollama server")
|
||||
|
||||
def evaluate(image_path: str, model: str, repetitions: int = 10, timeOut: int = 10):
|
||||
db = TinyDB(path.abspath(path.join(image_path, "../hairlineResults.json")))
|
||||
q = Query()
|
||||
prepareModel()
|
||||
|
||||
for image in listdir(image_path):
|
||||
oldInfo = db.get(q.filename == image)
|
||||
if model in oldInfo.keys():
|
||||
logger.info(f"Skipping evaluation {image} with {model}...")
|
||||
continue
|
||||
logger.info(f"Evaluating {image} with model {model} {repetitions} times.")
|
||||
with open(path.join(image_path, image), "rb") as f:
|
||||
imageb64 = b64encode(f.read()).decode("utf-8")
|
||||
average = 0
|
||||
averageWithoutUnsure = 0
|
||||
averageWithoutUnsureCount = 0
|
||||
if "f" in image:
|
||||
angle = "face"
|
||||
elif "l" in image:
|
||||
angle = "lateral"
|
||||
elif "t" in image:
|
||||
angle = "top"
|
||||
else:
|
||||
raise ValueError(f"no angle information in filename {image}")
|
||||
imagePrompt = PROMPT + f"\nlabel:\"{angle}\""
|
||||
for i in range(repetitions): # we ask multiple times and get the average response
|
||||
response = post(
|
||||
f"{OLLAMA_URL}/api/generate",
|
||||
json={
|
||||
"model": model,
|
||||
"prompt": imagePrompt,
|
||||
"images": [imageb64],
|
||||
"stream": False,
|
||||
"thinking": model != "qwen3-vl:8b" # for some reason this model thinkgs so much, that it spends all it's tokens on thinking and None in output...
|
||||
},
|
||||
timeout=timeOut,
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
logger.debug(response.json())
|
||||
result = search(r"^(0(?:\.\d+)?|1(?:\.0+)?)$", response.json()["response"].strip())
|
||||
logger.debug(result)
|
||||
if result == None:
|
||||
result = 0.5
|
||||
else:
|
||||
data = float(search(r"^(0(?:\.\d+)?|1(?:\.0+)?)$", response.json()["response"].strip()).group(1)) # extract the float in case it outputed some text
|
||||
|
||||
if data != 0.5: # if the model is uncertain it should return 0.5
|
||||
averageWithoutUnsure += data
|
||||
averageWithoutUnsureCount += 1
|
||||
average += data
|
||||
average /= repetitions
|
||||
if averageWithoutUnsureCount != 0: # avoids case where all repetitions are unsure
|
||||
averageWithoutUnsure /= averageWithoutUnsureCount
|
||||
else:
|
||||
averageWithoutUnsure = 0.5
|
||||
|
||||
if db.contains(q.filename == image): # we add the result to a database. It allows to do everything in multiple runs and combine results from multiple models
|
||||
imageResult = db.get(q.filename == image)
|
||||
imageResult[model] = average
|
||||
imageResult[model+"WithoutUnsure"] = averageWithoutUnsure
|
||||
db.upsert(imageResult, q.filename == image)
|
||||
else:
|
||||
imageResult = {
|
||||
"filename": image,
|
||||
model: average,
|
||||
model + "WithoutUnsure": averageWithoutUnsure
|
||||
}
|
||||
db.upsert(imageResult, q.filename == image)
|
||||
|
||||
logger.info("Normalizing values ...")
|
||||
# normalise values
|
||||
maxValue = max(image[model + "WithoutUnsure"] for image in db)
|
||||
minValue = min(image[model + "WithoutUnsure"] for image in db)
|
||||
for image in listdir(image_path):
|
||||
unnormalizedValues = db.get(q.filename == image)
|
||||
unnormalizedValues[model+"Normalized"] = (unnormalizedValues[model+"WithoutUnsure"] - minValue)/(maxValue - minValue)
|
||||
db.upsert(unnormalizedValues, q.filename == image)
|
||||
logger.info("Stopping model...")
|
||||
subprocess.Popen(
|
||||
["ollama", "stop", model],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
Reference in New Issue
Block a user