marvelous1/prostate-mri-2afc
0
1"""SQLAlchemy models for Prostate MRI Reader Study Platform.2 3Schema covers: readers, cases, reading sessions (3-arm MRMC),4scores, turing test items + responses, audit log.5"""6 7import os8from datetime import datetime9from sqlalchemy import (10 Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Text,11 UniqueConstraint, Index, create_engine,12)13from sqlalchemy.orm import declarative_base, relationship, sessionmaker14 15BASE_DIR = os.path.dirname(os.path.abspath(__file__))16DB_PATH = os.path.join(BASE_DIR, "db", "readerstudy.db")17os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)18 19DB_URL = f"sqlite:///{DB_PATH}"20engine = create_engine(DB_URL, connect_args={"check_same_thread": False})21SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)22Base = declarative_base()23 24 25class Reader(Base):26 __tablename__ = "readers"27 id = Column(Integer, primary_key=True)28 username = Column(String(64), unique=True, nullable=False, index=True)29 password_hash = Column(String(256), nullable=False)30 display_name = Column(String(128))31 center = Column(String(128)) # e.g. "Renji Hospital"32 level = Column(String(32)) # "senior" | "mid" | "junior"33 experience_years = Column(Integer)34 is_admin = Column(Boolean, default=False)35 arm_order = Column(String(16), default="1,2,3") # randomized order per-reader36 created_at = Column(DateTime, default=datetime.utcnow)37 38 sessions = relationship("ReadingSession", back_populates="reader")39 turing_responses = relationship("TuringResponse", back_populates="reader")40 41 42class Case(Base):43 __tablename__ = "cases"44 id = Column(Integer, primary_key=True)45 case_code = Column(String(32), unique=True, nullable=False, index=True) # R2024-000146 task_type = Column(String(8)) # "A" | "B" | "C" | "D"47 # JSON encoded list of sequences available in each arm48 # Arm 1: bpMRI (no target), Arm 2: bpMRI + AI target, Arm 3: full (real target)49 arm1_sequences = Column(String(256)) # e.g. "T2WI,DWI,ADC"50 arm2_sequences = Column(String(256)) # e.g. "T2WI,DWI,ADC,T1_AI"51 arm3_sequences = Column(String(256)) # e.g. "T2WI,DWI,ADC,T1_real"52 num_slices = Column(Integer, default=32)53 # Reference standard (hidden from readers, used for analysis)54 gt_pirads = Column(Integer) # expert consensus PI-RADS55 gt_csPCa = Column(Boolean) # ISUP >= 2 by pathology56 gt_lesion_location = Column(String(64)) # e.g. "PZ-left-mid"57 prostate_volume_ml = Column(Float)58 notes = Column(Text)59 created_at = Column(DateTime, default=datetime.utcnow)60 61 sessions = relationship("ReadingSession", back_populates="case")62 63 64class ReadingSession(Base):65 """One entry = one (reader, case, arm) tuple."""66 __tablename__ = "reading_sessions"67 id = Column(Integer, primary_key=True)68 reader_id = Column(Integer, ForeignKey("readers.id"), nullable=False)69 case_id = Column(Integer, ForeignKey("cases.id"), nullable=False)70 arm = Column(Integer, nullable=False) # 1, 2, or 371 order_in_arm = Column(Integer) # randomized position in reader's queue72 status = Column(String(16), default="pending") # pending/started/completed/skipped73 started_at = Column(DateTime)74 completed_at = Column(DateTime)75 time_spent_sec = Column(Integer, default=0)76 77 reader = relationship("Reader", back_populates="sessions")78 case = relationship("Case", back_populates="sessions")79 score = relationship("Score", uselist=False, back_populates="session")80 81 __table_args__ = (82 UniqueConstraint("reader_id", "case_id", "arm", name="uq_reader_case_arm"),83 Index("idx_reader_status", "reader_id", "status"),84 )85 86 87class Score(Base):88 __tablename__ = "scores"89 id = Column(Integer, primary_key=True)90 session_id = Column(Integer, ForeignKey("reading_sessions.id"), unique=True, nullable=False)91 92 # PI-RADS primary scoring93 pi_rads = Column(Integer) # 1-594 lesion_location = Column(String(64)) # sector95 lesion_size_mm = Column(Float)96 epe_status = Column(String(16)) # "yes"/"no"/"equivocal"97 svi_status = Column(String(16)) # "yes"/"no"98 99 # Image quality Likert per sequence (1-5)100 t2_quality = Column(Integer)101 dwi_quality = Column(Integer)102 adc_quality = Column(Integer)103 t1_quality = Column(Integer)104 105 # Per-case judgments106 diagnostic_confidence = Column(Integer) # 1-5107 acceptable_for_clinical = Column(String(32)) # "yes"/"no"/"reservations"108 109 # Optional Turing-embedded judgment (Arm 2 only)110 turing_is_ai = Column(String(16)) # "real"/"ai"/"unsure"111 turing_confidence = Column(Integer)112 113 comments = Column(Text)114 submitted_at = Column(DateTime, default=datetime.utcnow)115 116 session = relationship("ReadingSession", back_populates="score")117 118 119class TuringItem(Base):120 __tablename__ = "turing_items"121 id = Column(Integer, primary_key=True)122 image_path = Column(String(512)) # path to rendered image123 sequence_type = Column(String(16)) # "T1WI"/"DWI-highb" etc.124 case_code = Column(String(32))125 slice_idx = Column(Integer)126 is_ai = Column(Boolean, nullable=False) # True = AI-generated, False = real127 created_at = Column(DateTime, default=datetime.utcnow)128 129 130class TuringResponse(Base):131 __tablename__ = "turing_responses"132 id = Column(Integer, primary_key=True)133 reader_id = Column(Integer, ForeignKey("readers.id"), nullable=False)134 item_id = Column(Integer, ForeignKey("turing_items.id"), nullable=False)135 judgment = Column(String(16)) # "real"/"ai"/"unsure"136 confidence = Column(Integer)137 time_spent_sec = Column(Integer)138 submitted_at = Column(DateTime, default=datetime.utcnow)139 140 reader = relationship("Reader", back_populates="turing_responses")141 142 __table_args__ = (143 UniqueConstraint("reader_id", "item_id", name="uq_reader_turing_item"),144 )145 146 147class BlindedCase(Base):148 """A single 2AFC Turing-test card. One of A/B is AI-enhanced GT, the149 other is real GT. Ground-truth mapping (ai_position) is hidden from150 readers and only used for post-hoc analysis."""151 __tablename__ = "blinded_cases"152 id = Column(Integer, primary_key=True)153 case_code = Column(String(16), unique=True, nullable=False, index=True) # R-0001154 png_path = Column(String(512), nullable=False) # relative to static/ or abs155 patient_id_full = Column(String(256)) # Renji DICOM uid (never shown)156 task_type = Column(String(4)) # A-G157 target_seq = Column(String(8)) # T2WI/DWI/ADC/T1WI158 input_seqs = Column(String(64)) # "T2WI+ADC+T1WI"159 ai_position = Column(String(4)) # "A" or "B" (hidden from readers)160 ssim = Column(Float)161 psnr = Column(Float)162 ncc = Column(Float)163 blend_alpha = Column(Float)164 created_at = Column(DateTime, default=datetime.utcnow)165 166 167class BlindedAssignment(Base):168 """Which reader reads which case, in what order."""169 __tablename__ = "blinded_assignments"170 id = Column(Integer, primary_key=True)171 reader_id = Column(Integer, ForeignKey("readers.id"), nullable=False, index=True)172 blinded_case_id = Column(Integer, ForeignKey("blinded_cases.id"), nullable=False, index=True)173 order_in_queue = Column(Integer, nullable=False)174 status = Column(String(16), default="pending") # pending | started | completed | skipped175 started_at = Column(DateTime)176 completed_at = Column(DateTime)177 time_spent_sec = Column(Integer, default=0)178 179 __table_args__ = (180 UniqueConstraint("reader_id", "blinded_case_id", name="uq_reader_blinded_case"),181 Index("idx_reader_blinded_status", "reader_id", "status"),182 )183 184 185class BlindedResponse(Base):186 """Reader's answer on a 2AFC card (5 items)."""187 __tablename__ = "blinded_responses"188 id = Column(Integer, primary_key=True)189 assignment_id = Column(Integer, ForeignKey("blinded_assignments.id"), unique=True, nullable=False)190 reader_id = Column(Integer, ForeignKey("readers.id"), nullable=False, index=True)191 blinded_case_id = Column(Integer, ForeignKey("blinded_cases.id"), nullable=False, index=True)192 193 q1_ai_choice = Column(String(16)) # "A" / "B" / "Cannot tell"194 q2_quality_a = Column(Integer) # 1-5195 q2_quality_b = Column(Integer)196 q3_anatomy_a = Column(Integer)197 q3_anatomy_b = Column(Integer)198 q4_preference = Column(String(16)) # "A" / "B" / "Equivalent"199 q5_confidence = Column(Integer) # 1-5 (Guessing..Certain)200 comments = Column(Text)201 time_spent_sec = Column(Integer)202 submitted_at = Column(DateTime, default=datetime.utcnow)203 204 205class AuditLog(Base):206 __tablename__ = "audit_log"207 id = Column(Integer, primary_key=True)208 reader_id = Column(Integer, ForeignKey("readers.id"))209 event = Column(String(64)) # "login"/"view_case"/"submit_score"210 detail = Column(Text)211 ip_address = Column(String(64))212 user_agent = Column(String(256))213 timestamp = Column(DateTime, default=datetime.utcnow)214 215 216def init_db():217 Base.metadata.create_all(bind=engine)218 219 220def get_db():221 db = SessionLocal()222 try:223 yield db224 finally:225 db.close()226 227 228if __name__ == "__main__":229 init_db()230 print(f"Database initialized at {DB_PATH}")231 